mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-05 16:10:43 +00:00
## What Service Graph and Services Catalog were tabs on the Traces page, switched via a `?tab=` query param on a single keep-alive route. They are now real routes, reached from a hover flyout on the Traces rail tile: | Route | Notes | |---|---| | `/traces` | Traces search — keeps the **Spans / Traces** toggle | | `/traces/service-graph` | enterprise-gated; redirects to Traces on OSS | | `/traces/services` | | **Spans stays an in-page toggle** on purpose. It's a view *granularity* that shares the query, stream and time range — you switch to it mid-investigation and expect context to carry over. Datadog's Trace Explorer does the same. Service Graph and Services are destinations; Datadog, New Relic and Grafana all treat them that way. ## Why this wasn't just cosmetic As tabs, all three views shared one `meta.title`, one history entry, and the traces page's `searchObj`. That last part is why Service Graph and Services Catalog inherited the traces page's selected stream — the coupling this PR removes structurally. ## UI Both pages now use the Agent Graph header pattern (`OPageLayout` + title/subtitle/icon, date picker and last-run refresh in `#actions`), with a subnav row carrying stream selector + search on the left and view-type + layout on the right. ## Handoff `view-traces` no longer mutates the shared store from a sibling tab. Both views build the same filter string — extracted verbatim into `viewTracesHandoff.ts` — and navigate to Traces with it as query params, so the URL survives a reload. (Traces then canonicalises `filter=` to its own base64 `query=` via existing code in `useTraces.ts:290`.) ## Two bugs this surfaced Both were things the tab views inherited for free and now have to set themselves — and both would have hit real users deep-linking these pages: - **`organizationIdentifier`** was only assigned inside the Traces page's `loadPageData()`. Without it the catalog issued requests to `/api//_search_stream` (empty org segment) and rendered nothing. - **`?period=` / `?from=`+`?to=`** were only applied by the Traces page's `restoreUrlQueryParams()`. Without it a shared link carrying a time range silently opened on the 15-minute default. ## Tests | Suite | Result | |---|---| | `Traces/service-graph.spec.js` | 14 / 14 | | `Traces/service-catalog.spec.js` | 10 / 10 | | `RegressionSet/Traces/traces-ent.spec.js` | 1 / 1 | | Unit (`Navbar` + `plugins/traces`) | 2514 pass | Also clean: `type-check`, `eslint` (0 errors), `lint:design:strict`. Test-side changes: navigation POMs go through the rail flyout instead of the removed toggles; the graph POM pins an explicit window, since on its own route the graph no longer inherits the traces page's range. The shared auth fixture now verifies against `navbar-main-nav` — it keyed off `menu-link-/-item`, which the current rail no longer renders, so suites failed setup even after a successful login. Filter-building coverage moved with the code into `viewTracesHandoff.spec.ts` (21 tests) rather than being deleted alongside the component tests that asserted the old in-page handlers. ## Known / follow-up - `RegressionSet/Traces/traces-regression.spec.js` has 2 failures (trace column sorting, pagination). **Pre-existing** — they fail identically against production on `main`, and the Service Catalog test in that file passes. - Both views still default their stream picker to the traces page's selected stream. Defaulting Service Graph and Services to **all streams** is a deliberate follow-up: on our own instance the `default` stream shows 22 topology nodes vs 71 across all streams, and services like `o2-ai` span 4 streams. Graph is a one-line change (the API already supports it); the catalog needs a backend decision, so it's out of scope here.
186 lines
7.1 KiB
JavaScript
186 lines
7.1 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
const { test: baseTest } = require('@playwright/test');
|
|
const testLogger = require('./test-logger.js');
|
|
const { waitUtils } = require('./wait-helpers.js');
|
|
const { isCloudEnvironment } = require('../../pages/cloudPages/cloud-env.js');
|
|
|
|
const istanbulCLIOutput = path.join(process.cwd(), '.nyc_output');
|
|
const authFile = path.join(__dirname, 'auth', 'user.json');
|
|
|
|
function generateUUID() {
|
|
return crypto.randomBytes(16).toString('hex');
|
|
}
|
|
|
|
/**
|
|
* Enhanced test fixture with global session management
|
|
* Uses saved authentication state from global setup
|
|
*/
|
|
const test = baseTest.extend({
|
|
// Enhanced context with saved authentication state
|
|
context: async ({ browser }, use) => {
|
|
let context;
|
|
|
|
try {
|
|
// Check if auth file exists from global setup
|
|
if (fs.existsSync(authFile)) {
|
|
testLogger.info('Using saved authentication state');
|
|
context = await browser.newContext({
|
|
storageState: authFile,
|
|
viewport: { width: 1500, height: 1024 }
|
|
});
|
|
} else {
|
|
testLogger.warn('No saved auth state found, creating fresh context');
|
|
context = await browser.newContext({
|
|
viewport: { width: 1500, height: 1024 }
|
|
});
|
|
}
|
|
|
|
// Add coverage collection (from original baseFixtures)
|
|
await context.addInitScript(() =>
|
|
window.addEventListener('beforeunload', () => {
|
|
try {
|
|
(window).collectIstanbulCoverage(JSON.stringify((window).__coverage__))
|
|
} catch (error) {
|
|
testLogger.error('Failed to collect coverage on page unload', { error });
|
|
}
|
|
}),
|
|
);
|
|
|
|
await fs.promises.mkdir(istanbulCLIOutput, { recursive: true });
|
|
await context.exposeFunction('collectIstanbulCoverage', async (coverageJSON) => {
|
|
if (!coverageJSON) return;
|
|
const filename = path.join(istanbulCLIOutput, `playwright_coverage_${generateUUID()}.json`);
|
|
try {
|
|
await fs.promises.writeFile(filename, coverageJSON);
|
|
} catch (error) {
|
|
testLogger.error('Failed to write coverage data', { error });
|
|
}
|
|
});
|
|
|
|
await use(context);
|
|
|
|
// Collect final coverage
|
|
await Promise.all(context.pages().map(async (page) => {
|
|
try {
|
|
await page.evaluate(() => (window).collectIstanbulCoverage(JSON.stringify((window).__coverage__)))
|
|
} catch (error) {
|
|
testLogger.error('Failed to collect final coverage for page', { error });
|
|
}
|
|
}));
|
|
|
|
} finally {
|
|
if (context) {
|
|
await context.close();
|
|
}
|
|
}
|
|
},
|
|
|
|
// Enhanced page fixture
|
|
page: async ({ context }, use, testInfo) => {
|
|
const page = await context.newPage();
|
|
|
|
// Add wait helpers to page
|
|
page.waitHelpers = waitUtils.create(page);
|
|
|
|
testLogger.debug('New page created with global session and wait helpers');
|
|
|
|
await use(page);
|
|
|
|
// On failure, click "Click for error details" before the framework takes its auto-screenshot.
|
|
// This ensures the expanded error dialog is visible in the failure screenshot.
|
|
if (testInfo.status === 'failed' || testInfo.status === 'timedOut') {
|
|
try {
|
|
const errorBtn = page.getByRole('button', { name: /click for error details/i });
|
|
const isVisible = await errorBtn.isVisible({ timeout: 2000 }).catch(() => false);
|
|
if (isVisible) {
|
|
await errorBtn.click({ force: true }).catch(() => {});
|
|
await page.waitForTimeout(800);
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
});
|
|
|
|
const expect = test.expect;
|
|
|
|
/**
|
|
* Utility function to check if user is authenticated
|
|
* @param {import('@playwright/test').Page} page
|
|
*/
|
|
async function verifyAuthentication(page) {
|
|
try {
|
|
// Increase timeout for authentication verification, especially important for first test in suite
|
|
// Verify against the nav rail container rather than a specific item. The
|
|
// Home tile's `menu-link-/-item` no longer renders on the current rail
|
|
// (only Slack/Help still use that pattern), so keying auth off it made
|
|
// every suite fail setup even when login had succeeded.
|
|
await page.waitHelpers.waitForElementVisible('[data-test="navbar-main-nav"]', {
|
|
timeout: 15000,
|
|
description: 'main nav rail (auth verification)'
|
|
});
|
|
return true;
|
|
} catch (error) {
|
|
testLogger.warn('Authentication verification failed', { error: error.message });
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Utility function to navigate to base URL with authentication check
|
|
* @param {import('@playwright/test').Page} page
|
|
*/
|
|
async function navigateToBase(page) {
|
|
// Must include the /web/ SPA path. Navigating to the bare domain
|
|
// (`${ZO_BASE_URL}?org_identifier=X`) redirects to /web/ and DROPS the query
|
|
// string, so the app falls back to the user's DEFAULT org instead of ORGNAME.
|
|
// On cloud that default org can be a different, trial-expired org, which then
|
|
// redirects to /web/billings/plans where the home menu never renders and auth
|
|
// verification fails. Every other navigation in the suite already uses /web/.
|
|
const baseUrlWithOrg = `${process.env["ZO_BASE_URL"]}/web/?org_identifier=${process.env["ORGNAME"]}`;
|
|
testLogger.info('Navigating to base URL with org identifier', { url: baseUrlWithOrg });
|
|
|
|
// Use 60s navigation timeout for all environments (dev/staging can be slow to load)
|
|
const navTimeout = 60000;
|
|
await page.goto(baseUrlWithOrg, { timeout: navTimeout });
|
|
await page.waitForLoadState('domcontentloaded');
|
|
// Cloud needs full hydration before sidebar clicks — without this, clicks trigger Dex redirect
|
|
if (isCloudEnvironment()) {
|
|
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
|
|
}
|
|
|
|
let isAuthenticated = await verifyAuthentication(page);
|
|
|
|
// Self-heal on cloud: the shared session token is minted once at the start of the run
|
|
// and reused by every shard, so on long runs (or when shards sharing an org contend on
|
|
// the same credential) it can expire/invalidate mid-run — surfacing as this auth check
|
|
// failing. Rather than fail the test, re-authenticate (fresh Dex login + org switch +
|
|
// passcode refresh) in this context and resume.
|
|
if (!isAuthenticated && isCloudEnvironment()) {
|
|
testLogger.warn('Auth check failed — attempting re-authentication and resume');
|
|
const { reauthenticateAlpha1 } = require('./reauth-alpha1.js');
|
|
const recovered = await reauthenticateAlpha1(page);
|
|
if (recovered) {
|
|
await page.goto(baseUrlWithOrg, { timeout: navTimeout });
|
|
await page.waitForLoadState('domcontentloaded');
|
|
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
|
|
isAuthenticated = await verifyAuthentication(page);
|
|
}
|
|
}
|
|
|
|
if (!isAuthenticated) {
|
|
testLogger.error('User not authenticated - global setup might have failed (re-auth also failed or unavailable)');
|
|
throw new Error('User not authenticated. Global setup might have failed.');
|
|
}
|
|
|
|
testLogger.info('Successfully navigated to base URL with authentication');
|
|
}
|
|
|
|
module.exports = {
|
|
test,
|
|
expect,
|
|
generateUUID,
|
|
verifyAuthentication,
|
|
navigateToBase
|
|
}; |