Files
openobserve/tests/ui-testing/playwright-tests/utils/enhanced-baseFixtures.js
T
Ashish Kolhe 32d02d6781 chore(traces): make Service Graph and Services their own routes (#13659)
## 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.
2026-08-06 10:30:03 +00:00

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
};