From ce9485f50a995e718b34270c4837c48de0a1dd77 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Tue, 27 Oct 2026 13:50:09 -0700 Subject: [PATCH 1/5] Upgrade React Router and verify application navigation contracts --- ...pgrades-need-current-consumer-contracts.md | 45 ++++++++ .agents/issues/INDEX.md | 1 + .agents/issues/security-audit-index.md | 1 + docs/quality/SECURITY_SCOPE_2026-10-23.md | 22 ++++ package-lock.json | 100 ++++++++++-------- packages/web/package.json | 2 +- .../web/src/lib/routerNavigation.test.tsx | 45 ++++++++ tests/e2e/auth/router-navigation.spec.ts | 85 +++++++++++++++ tests/lib/dependencies/router.test.mjs | 19 ++++ tests/lib/runner/unified-cli.test.mjs | 5 +- tests/sequences/unified.config.mjs | 1 + 11 files changed, 282 insertions(+), 44 deletions(-) create mode 100644 .agents/inbox/2026-10-27-router-upgrades-need-current-consumer-contracts.md create mode 100644 packages/web/src/lib/routerNavigation.test.tsx create mode 100644 tests/e2e/auth/router-navigation.spec.ts create mode 100644 tests/lib/dependencies/router.test.mjs diff --git a/.agents/inbox/2026-10-27-router-upgrades-need-current-consumer-contracts.md b/.agents/inbox/2026-10-27-router-upgrades-need-current-consumer-contracts.md new file mode 100644 index 00000000..e66a6f6d --- /dev/null +++ b/.agents/inbox/2026-10-27-router-upgrades-need-current-consumer-contracts.md @@ -0,0 +1,45 @@ +# Validate the installed router contract and real navigation controls + +On 2026-10-27, the #186 router work started at dev +`8712389e24f567f5b3e6ed132c1ac5d4dafb21fa`. A fresh-cache production audit +reported five moderate entries: Router/DOM and UUID/federation/subgraph. The +[navigation advisory](https://github.com/advisories/GHSA-wrjc-x8rr-h8h6) and +[hydration advisory](https://github.com/advisories/GHSA-337j-9hxr-rhxg) both +identify7.18.0 as fixed. The selected Router/DOM7.18.4 retains React18 and the +existing declarative BrowserRouter; its Node20 minimum fits supported Node22/24. +The [versioned migration guide](https://reactrouter.com/7.18.4/upgrading/v6) +was checked. No multi-segment splat route, data router or component-local lazy +page declaration was found in the live app routes. + +The first navigation test copied an older upstream normalization expectation. +Router6.30.6 failed three mixed-separator cases; installed7.18.4 rejected all four +external-looking destinations instead of navigating to a normalized internal +path. That four-case failure was a test-contract mismatch, not an application +regression. Inspection of the installed `validateNavigationTarget` established +the actual rejection boundary. The corrected control captures the thrown error +and requires the route to remain unchanged. Reinstalling the original locked6.30.6 +then running the final control produced **4 failures and1 history pass**; +the7.18.4 control plus existing role guards produced **9 passes**. + +The first six actual-browser cases produced **4 failures and2 passes**, with no +retries. The two failing assumptions on each viewport were that the sign-in +page always selected Password, and that Admin opened its Users tab. The current +UI preserves the sign-in method and opens Feedback & operations. The corrected +test explicitly selects Password and Users, retains the exact route/content +assertions, and checks that denied users cannot mount the administration heading. +History, lazy-page navigation, viewer guards and fresh-context callback routing +remain required. Final browser and integrated results belong in the PR. + +Corrective rule: use the installed consumer and current UI to establish a +regression contract, then run the exact final negative control against the old +locked consumer. Preserve the earlier failing logs; do not treat a test rewrite +or a successful install as proof that navigation remains usable. The lock +regression checks every Router/DOM resolution, not just the manifest range. + +Limits: MemoryRouter tests establish library navigation behavior, while the +actual-browser suite covers GraphDone routes at1440px and390px. No hostile +application-controlled destination, SSR hydration surface, live provider +callback, deployed revision or broad security clearance is claimed. Test tokens +are confined to disposable local/CI accounts; failure traces are not public +credential samples. Local evidence is retained in +`/tmp/graphdone-resolution-20261027/router-*` and this record travels with the fix. diff --git a/.agents/issues/INDEX.md b/.agents/issues/INDEX.md index 42ef9dbe..809a6eea 100644 --- a/.agents/issues/INDEX.md +++ b/.agents/issues/INDEX.md @@ -2,6 +2,7 @@ | Symptom | Canonical lesson and regression | | --- | --- | +| A router major update passes install but path handling or route UI assertions differ | [Validate installed navigation and current UI contracts](../inbox/2026-10-27-router-upgrades-need-current-consumer-contracts.md); `routerNavigation.test.tsx`, `router-navigation.spec.ts`, dependency guard | | Minimap zoom or overview inspection is overwritten while a newly loaded graph settles | [Explicit camera input must stop automatic framing](../inbox/2026-10-27-explicit-camera-input-must-stop-auto-fit.md); timer-controlled `semantic-lod.spec.ts` negative control | | Dense-graph browser case exhausts its timeout after slow API fixture creation; cleanup errors disappear | [Bound fixture setup separately and fail on cleanup errors](../inbox/2026-10-27-cold-lod-fixtures-need-separate-budgets.md); `tests/e2e/graph/semantic-lod.spec.ts` | | GitHub connector can read a repo but PR creation returns `Resource not accessible by integration` | [Client-specific write permissions](../inbox/2026-10-27-github-connector-write-permissions.md); verify existing authorized access without changing credentials or policy | diff --git a/.agents/issues/security-audit-index.md b/.agents/issues/security-audit-index.md index 039affee..48197f52 100644 --- a/.agents/issues/security-audit-index.md +++ b/.agents/issues/security-audit-index.md @@ -2,6 +2,7 @@ | Symptom | Evidence and corrective rule | | --- | --- | +| Router advisory requires a major update and older upstream navigation expectations differ | [Installed consumer and route compatibility](../inbox/2026-10-27-router-upgrades-need-current-consumer-contracts.md); every locked Router path plus desktop/phone route gates | | Fresh production audit flags Undici through optional SQLite build tooling without a direct application import | [Trace the native-build consumer and patch its actual resolved copy](../inbox/2026-10-24-undici-native-build-dependency.md); `tests/lib/dependencies/undici.test.mjs` rejects affected locked and installed consumer versions | | Green production audit hides development-tool findings or package counts are treated as exploit counts | [Audit scope lesson](../inbox/2026-10-23-dependency-audit-scope.md); [dated security scope](../../docs/quality/SECURITY_SCOPE_2026-10-23.md) | | Nested lint glob stalls the process | `tests/lib/dependencies/minimatch.test.mjs`; scoped parser matcher override and bounded child-process regression | diff --git a/docs/quality/SECURITY_SCOPE_2026-10-23.md b/docs/quality/SECURITY_SCOPE_2026-10-23.md index 07012d82..fb3005fe 100644 --- a/docs/quality/SECURITY_SCOPE_2026-10-23.md +++ b/docs/quality/SECURITY_SCOPE_2026-10-23.md @@ -45,6 +45,28 @@ Follow-up implementation on 2026-10-23: [PR #169](https://github.com/GraphDone/G Source locations are relative to `packages/server/src/` and anchored to the snapshot above. Local, secret-free evidence is under `/tmp/graphdone-residual-*-audit.json`, `/tmp/graphdone-minimatch-*.log`, and `/tmp/graphdone-residual-auth-probe.log`; the latter reports only `parallelMagicLinkSuccesses: 2` and `arbitraryCaptchaAccepted: true`. +## Router correction on 2026-10-27 + +The dedicated #186 router change replaces Router/DOM6.30.6 with7.18.4, removes +the old `@remix-run/router` resolution and retains the existing declarative SPA. +Official advisories [GHSA-wrjc-x8rr-h8h6](https://github.com/advisories/GHSA-wrjc-x8rr-h8h6) +and [GHSA-337j-9hxr-rhxg](https://github.com/advisories/GHSA-337j-9hxr-rhxg) +identify7.18.0 as fixed. Existing app routes use fixed destinations, with no +observed SSR hydration path; upgrading is not a claim that a live exploit was +demonstrated. The DOM compatibility package is retained to keep imports stable. + +The locked dependency regression checks every installed resolution. Navigation +controls reject external-looking mixed separators and preserve internal query, +fragment and history behavior; existing role guards remain covered. Six required +browser cases exercise public and authenticated deep links, reload, lazy pages, +desktop links/phone navigation, back/forward, role rejection and a fresh-context +login callback. The callback uses an owned password-issued token and does not +establish provider/email authentication. Initial negative and fixture-contract +failures are retained in the [dated lesson](../../.agents/inbox/2026-10-27-router-upgrades-need-current-consumer-contracts.md). +Fresh full-suite, browser, audit and final composition results are recorded on +the reviewable PR; UUID, CSS evidence, tooling and hosted acceptance remain +separate work. + ## Coverage still needed The folder tests do not establish all generated Graph/WorkItem/Edge permissions, concurrent deletion integrity, MCP/agent grant revocation, share-link scopes, session/cookie policy, uploads, backups, email delivery, or deployed proxy controls. Those need isolated contracts and user-level tests with current roles, multiple accounts, races, and negative cases. Dependency counts, passing smoke tests, and absence of a discovered source sink are separate pieces of evidence; none is a full security assessment. diff --git a/package-lock.json b/package-lock.json index 0a9f0196..3a7292a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2584,15 +2584,6 @@ "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", "license": "BSD-3-Clause" }, - "node_modules/@remix-run/router": { - "version": "1.23.4", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz", - "integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "dev": true, @@ -11174,38 +11165,6 @@ "node": ">=0.10.0" } }, - "node_modules/react-router": { - "version": "6.30.6", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz", - "integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==", - "license": "MIT", - "dependencies": { - "@remix-run/router": "1.23.4" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "react": ">=16.8" - } - }, - "node_modules/react-router-dom": { - "version": "6.30.6", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz", - "integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==", - "license": "MIT", - "dependencies": { - "@remix-run/router": "1.23.4", - "react-router": "6.30.6" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" - } - }, "node_modules/react-syntax-highlighter": { "version": "16.1.1", "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz", @@ -11777,6 +11736,12 @@ "node": ">= 0.8.0" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.3.tgz", + "integrity": "sha512-irZHtTqurLna57KQG/s+fzOwv/J6XSH4cs8M/vsrY3uCaQ3S6+QJguAH0WdN47SXlk82voMZ4naWMQf8fdEoIg==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "license": "MIT", @@ -15939,7 +15904,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^10.1.0", - "react-router-dom": "^6.20.0", + "react-router-dom": "^7.18.4", "react-syntax-highlighter": "^16.1.1", "remark-gfm": "^4.0.1", "tailwindcss": "^3.3.0", @@ -16342,6 +16307,19 @@ "node": "18 || 20 || >=22" } }, + "packages/web/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "packages/web/node_modules/eslint": { "version": "9.39.5", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", @@ -16542,6 +16520,44 @@ "url": "https://github.com/sponsors/isaacs" } }, + "packages/web/node_modules/react-router": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "packages/web/node_modules/react-router-dom": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz", + "integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "packages/web/node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", diff --git a/packages/web/package.json b/packages/web/package.json index f011b528..d2e4e70d 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -26,7 +26,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^10.1.0", - "react-router-dom": "^6.20.0", + "react-router-dom": "^7.18.4", "react-syntax-highlighter": "^16.1.1", "remark-gfm": "^4.0.1", "tailwindcss": "^3.3.0", diff --git a/packages/web/src/lib/routerNavigation.test.tsx b/packages/web/src/lib/routerNavigation.test.tsx new file mode 100644 index 00000000..5244f71a --- /dev/null +++ b/packages/web/src/lib/routerNavigation.test.tsx @@ -0,0 +1,45 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { useState } from 'react'; +import { MemoryRouter, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; + +function Navigation({ destination }: { destination: string }) { + const navigate = useNavigate(); + const location = useLocation(); + const [error, setError] = useState(''); + return <> + + + + {location.pathname}{location.search}{location.hash} + {error &&

{error}

} + + Home} /> + Settings} /> + + ; +} + +describe('installed router navigation contract', () => { + for (const prefix of ['//', '\\\\', '/\\', '\\/']) { + it(`rejects external-looking separators ${JSON.stringify(prefix)} before navigation`, async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Open destination' })); + expect(await screen.findByRole('alert')).toHaveTextContent('External navigation is not allowed'); + expect(screen.getByRole('heading', { name: 'Home' })).toBeVisible(); + expect(screen.getByLabelText('Current location')).toHaveTextContent(/^\/home$/); + }); + } + + it('preserves query, fragment and browser-style back/forward history', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Open destination' })); + await waitFor(() => expect(screen.getByLabelText('Current location')).toHaveTextContent('/settings?tab=profile#account')); + fireEvent.click(screen.getByRole('button', { name: 'Back' })); + expect(await screen.findByRole('heading', { name: 'Home' })).toBeVisible(); + fireEvent.click(screen.getByRole('button', { name: 'Forward' })); + await waitFor(() => expect(screen.getByLabelText('Current location')).toHaveTextContent('/settings?tab=profile#account')); + }); +}); diff --git a/tests/e2e/auth/router-navigation.spec.ts b/tests/e2e/auth/router-navigation.spec.ts new file mode 100644 index 00000000..0ebc1788 --- /dev/null +++ b/tests/e2e/auth/router-navigation.spec.ts @@ -0,0 +1,85 @@ +import { test, expect, type Page } from '@playwright/test'; +import { login, TEST_USERS, getBaseURL } from '../../lib/auth'; + +for (const width of [1440, 390]) { + test.describe(`application route compatibility at ${width}px @router`, () => { + test.use({ viewport: { width, height: 900 }, isMobile: width === 390, hasTouch: width === 390 }); + test.describe.configure({ timeout: 90_000, retries: 0 }); + const errors = new WeakMap(); + test.beforeEach(async ({ page }) => { + errors.set(page, []); + page.on('pageerror', error => errors.get(page)!.push(error.message)); + await page.addInitScript(() => localStorage.setItem('graphdone:viewMode', 'cards')); + }); + test.afterEach(async ({ page }) => expect(errors.get(page)).toEqual([])); + + test('public deep links, callback errors and unknown paths preserve route behavior', async ({ page }) => { + await page.goto(`${getBaseURL()}/forgot-password`); + await expect(page.getByRole('heading', { name: 'Reset Password', exact: true })).toBeVisible(); + await page.reload(); + await expect(page.getByRole('heading', { name: 'Reset Password', exact: true })).toBeVisible(); + await page.goto(`${getBaseURL()}/reset-password?token=owned-invalid-router-fixture`); + await expect(page.getByRole('heading', { name: 'Set New Password' })).toBeVisible(); + await page.goto(`${getBaseURL()}/login?error=invalid_magic_link`); + await expect(page.getByText('This magic link is not valid or has already been used.', { exact: true })).toBeVisible(); + await page.goto(`${getBaseURL()}/owned-unknown-route/nested`); + await expect(page).toHaveURL(new URL('/', getBaseURL()).href); + await page.getByRole('tab', { name: 'Password', exact: true }).click(); + await expect(page.locator('input[name="emailOrUsername"]')).toBeVisible(); + }); + + test('deep routes, lazy page navigation and back/forward history remain usable', async ({ page }) => { + await login(page, TEST_USERS.ADMIN, { retries: 1 }); + await page.goto(`${getBaseURL()}/settings?tab=profile#account`); + await expect(page.getByRole('heading', { name: 'Settings', exact: true })).toBeVisible(); + await page.reload(); + await expect(page.getByRole('heading', { name: 'Settings', exact: true })).toBeVisible(); + if (width === 390) { + await page.getByTestId('mobile-bottom-nav').getByText('More', { exact: true }).tap(); + await page.getByTestId('mobile-more-sheet').getByText('Analytics', { exact: true }).tap(); + } else { + await page.getByRole('link', { name: 'Analytics', exact: true }).click(); + } + await expect(page.getByRole('heading', { name: 'Your graph, in motion' })).toBeVisible(); + await expect(page).toHaveURL(new URL('/analytics', getBaseURL()).href); + await page.goBack(); + await expect(page.getByRole('heading', { name: 'Settings', exact: true })).toBeVisible(); + await expect(page).toHaveURL(new URL('/settings?tab=profile#account', getBaseURL()).href); + await page.goForward(); + await expect(page.getByRole('heading', { name: 'Your graph, in motion' })).toBeVisible(); + await page.goto(`${getBaseURL()}/admin`); + await expect(page).toHaveURL(new URL('/admin', getBaseURL()).href); + await expect(page.getByRole('heading', { name: 'System Administration', exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Users', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Create User', exact: true })).toBeVisible(); + }); + + test('viewer route guards and a fresh-context login callback preserve authorization', async ({ page, browser }) => { + await login(page, TEST_USERS.VIEWER, { retries: 1 }); + for (const route of ['/admin', '/backend']) { + await page.goto(`${getBaseURL()}${route}`); + await expect(page).toHaveURL(new URL('/', getBaseURL()).href); + await expect(page.locator('[data-testid="view-content"]')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'System Administration', exact: true })).toHaveCount(0); + } + const token = await page.evaluate(() => localStorage.getItem('authToken') || sessionStorage.getItem('authToken')); + expect(Boolean(token)).toBe(true); + const callbackContext = await browser.newContext({ viewport: { width, height: 900 }, isMobile: width === 390, hasTouch: width === 390, ignoreHTTPSErrors: true }); + try { + const callback = await callbackContext.newPage(); + const callbackErrors: string[] = []; + callback.on('pageerror', error => callbackErrors.push(error.message)); + await callback.addInitScript(() => localStorage.setItem('graphdone:viewMode', 'cards')); + await callback.goto(`${getBaseURL()}/login?token=${encodeURIComponent(token!)}`); + await expect(callback).toHaveURL(new URL('/', getBaseURL()).href); + await expect(callback.locator('[data-testid="view-content"]')).toBeVisible(); + expect(await callback.evaluate(() => !window.location.search.includes('token='))).toBe(true); + await callback.goto(`${getBaseURL()}/admin`); + await expect(callback).toHaveURL(new URL('/', getBaseURL()).href); + expect(callbackErrors).toEqual([]); + } finally { + await callbackContext.close(); + } + }); + }); +} diff --git a/tests/lib/dependencies/router.test.mjs b/tests/lib/dependencies/router.test.mjs new file mode 100644 index 00000000..cc363e5d --- /dev/null +++ b/tests/lib/dependencies/router.test.mjs @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import test from 'node:test'; + +const root = new URL('../../../', import.meta.url); +const require = createRequire(new URL('package.json', root)); +const webRequire = createRequire(new URL('packages/web/package.json', root)); +const semver = require('semver'); + +test('all locked React Router paths contain the 7.18 security fixes', async () => { + const lock = JSON.parse(await readFile(new URL('package-lock.json', root), 'utf8')); + const paths = Object.entries(lock.packages).filter(([path]) => /(^|\/)node_modules\/react-router(?:-dom)?$/.test(path)); + assert.ok(paths.length >= 2); + for (const [path, entry] of paths) { + assert.ok(semver.satisfies(entry.version, '>=7.18.0', { includePrerelease: true }), `${path}: ${entry.version}`); + } + assert.equal(webRequire('react-router-dom/package.json').version, webRequire('react-router/package.json').version); +}); diff --git a/tests/lib/runner/unified-cli.test.mjs b/tests/lib/runner/unified-cli.test.mjs index 36d2bf23..1e937b3f 100644 --- a/tests/lib/runner/unified-cli.test.mjs +++ b/tests/lib/runner/unified-cli.test.mjs @@ -47,7 +47,10 @@ for (const sequence of ['new-user', 'mobile', 'perf-budgets']) { const result = run(['--sequence', sequence]); assert.equal(result.status, 0, result.stdout); const args = JSON.parse(readFileSync(join(dir, 'bin/argv.json'))); - if (sequence === 'new-user') assert.ok(args.includes('tests/e2e/graph/semantic-lod.spec.ts')); + if (sequence === 'new-user') { + assert.ok(args.includes('tests/e2e/graph/semantic-lod.spec.ts')); + assert.ok(args.includes('tests/e2e/auth/router-navigation.spec.ts')); + } assert.deepEqual(args.filter(arg => arg.startsWith('--trace=')), ['--trace=retain-on-failure']); assert.deepEqual(args.filter(arg => arg.startsWith('--retries=')), ['--retries=0']); assert.deepEqual(args.filter(arg => arg.startsWith('--workers=')), ['--workers=1']); diff --git a/tests/sequences/unified.config.mjs b/tests/sequences/unified.config.mjs index 1cd4e35a..63cef837 100644 --- a/tests/sequences/unified.config.mjs +++ b/tests/sequences/unified.config.mjs @@ -23,6 +23,7 @@ export const SEQUENCES = { 'tests/e2e/graph/recorded-ai-work.spec.ts', 'tests/e2e/graph/admin-dialog-responsive.spec.ts', 'tests/e2e/graph/semantic-lod.spec.ts', + 'tests/e2e/auth/router-navigation.spec.ts', 'tests/e2e/smoke/user-smoke.spec.ts', 'tests/e2e/quality/persona-lifecycle.spec.ts', 'tests/e2e/quality/graph-motion.spec.ts', From 3e5a761dc0b067ed3827dd29fca50d45fb5d3952 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Tue, 27 Oct 2026 13:50:40 -0700 Subject: [PATCH 2/5] Clarify router version and validation evidence prose --- ...r-upgrades-need-current-consumer-contracts.md | 16 ++++++++-------- docs/quality/SECURITY_SCOPE_2026-10-23.md | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.agents/inbox/2026-10-27-router-upgrades-need-current-consumer-contracts.md b/.agents/inbox/2026-10-27-router-upgrades-need-current-consumer-contracts.md index e66a6f6d..6b8bddd1 100644 --- a/.agents/inbox/2026-10-27-router-upgrades-need-current-consumer-contracts.md +++ b/.agents/inbox/2026-10-27-router-upgrades-need-current-consumer-contracts.md @@ -5,23 +5,23 @@ On 2026-10-27, the #186 router work started at dev reported five moderate entries: Router/DOM and UUID/federation/subgraph. The [navigation advisory](https://github.com/advisories/GHSA-wrjc-x8rr-h8h6) and [hydration advisory](https://github.com/advisories/GHSA-337j-9hxr-rhxg) both -identify7.18.0 as fixed. The selected Router/DOM7.18.4 retains React18 and the -existing declarative BrowserRouter; its Node20 minimum fits supported Node22/24. +identify 7.18.0 as fixed. The selected Router/DOM 7.18.4 retains React 18 and the +existing declarative BrowserRouter; its Node 20 minimum fits supported Node 22/24. The [versioned migration guide](https://reactrouter.com/7.18.4/upgrading/v6) was checked. No multi-segment splat route, data router or component-local lazy page declaration was found in the live app routes. The first navigation test copied an older upstream normalization expectation. -Router6.30.6 failed three mixed-separator cases; installed7.18.4 rejected all four +Router 6.30.6 failed three mixed-separator cases; installed 7.18.4 rejected all four external-looking destinations instead of navigating to a normalized internal path. That four-case failure was a test-contract mismatch, not an application regression. Inspection of the installed `validateNavigationTarget` established the actual rejection boundary. The corrected control captures the thrown error -and requires the route to remain unchanged. Reinstalling the original locked6.30.6 -then running the final control produced **4 failures and1 history pass**; -the7.18.4 control plus existing role guards produced **9 passes**. +and requires the route to remain unchanged. Reinstalling the original locked 6.30.6 +then running the final control produced **4 failures and 1 history pass**; +the 7.18.4 control plus existing role guards produced **9 passes**. -The first six actual-browser cases produced **4 failures and2 passes**, with no +The first six actual-browser cases produced **4 failures and 2 passes**, with no retries. The two failing assumptions on each viewport were that the sign-in page always selected Password, and that Admin opened its Users tab. The current UI preserves the sign-in method and opens Feedback & operations. The corrected @@ -37,7 +37,7 @@ or a successful install as proof that navigation remains usable. The lock regression checks every Router/DOM resolution, not just the manifest range. Limits: MemoryRouter tests establish library navigation behavior, while the -actual-browser suite covers GraphDone routes at1440px and390px. No hostile +actual-browser suite covers GraphDone routes at 1440px and 390px. No hostile application-controlled destination, SSR hydration surface, live provider callback, deployed revision or broad security clearance is claimed. Test tokens are confined to disposable local/CI accounts; failure traces are not public diff --git a/docs/quality/SECURITY_SCOPE_2026-10-23.md b/docs/quality/SECURITY_SCOPE_2026-10-23.md index fb3005fe..099e293d 100644 --- a/docs/quality/SECURITY_SCOPE_2026-10-23.md +++ b/docs/quality/SECURITY_SCOPE_2026-10-23.md @@ -47,11 +47,11 @@ Source locations are relative to `packages/server/src/` and anchored to the snap ## Router correction on 2026-10-27 -The dedicated #186 router change replaces Router/DOM6.30.6 with7.18.4, removes +The dedicated #186 router change replaces Router/DOM 6.30.6 with 7.18.4, removes the old `@remix-run/router` resolution and retains the existing declarative SPA. Official advisories [GHSA-wrjc-x8rr-h8h6](https://github.com/advisories/GHSA-wrjc-x8rr-h8h6) and [GHSA-337j-9hxr-rhxg](https://github.com/advisories/GHSA-337j-9hxr-rhxg) -identify7.18.0 as fixed. Existing app routes use fixed destinations, with no +identify 7.18.0 as fixed. Existing app routes use fixed destinations, with no observed SSR hydration path; upgrading is not a claim that a live exploit was demonstrated. The DOM compatibility package is retained to keep imports stable. From 4e248287386ac3dbc6c1e6cf7ad458e8d60b3a0f Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Tue, 27 Oct 2026 13:53:35 -0700 Subject: [PATCH 3/5] Verify installed router versions against patched locked consumers --- ...-27-router-upgrades-need-current-consumer-contracts.md | 8 ++++++++ docs/quality/SECURITY_SCOPE_2026-10-23.md | 3 ++- tests/lib/dependencies/router.test.mjs | 8 ++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/.agents/inbox/2026-10-27-router-upgrades-need-current-consumer-contracts.md b/.agents/inbox/2026-10-27-router-upgrades-need-current-consumer-contracts.md index 6b8bddd1..c29c6920 100644 --- a/.agents/inbox/2026-10-27-router-upgrades-need-current-consumer-contracts.md +++ b/.agents/inbox/2026-10-27-router-upgrades-need-current-consumer-contracts.md @@ -43,3 +43,11 @@ callback, deployed revision or broad security clearance is claimed. Test tokens are confined to disposable local/CI accounts; failure traces are not public credential samples. Local evidence is retained in `/tmp/graphdone-resolution-20261027/router-*` and this record travels with the fix. + +At frozen `3e5a761dc0b067ed3827dd29fca50d45fb5d3952`, the corrected browser suite +passed 6/6 without retries, the full web suite passed 340/340 and the harness +passed 167/167. Review found the inventory overstated the installed-version guard: +it checked locked ranges and installed-pair equality, which could accept two +equally stale installed copies. The guard now requires each actual web consumer +to satisfy the fixed range and match its resolved lock entry. The inventory +states that exact scope. Refresh the changed harness and final CI before merge. diff --git a/docs/quality/SECURITY_SCOPE_2026-10-23.md b/docs/quality/SECURITY_SCOPE_2026-10-23.md index 099e293d..4ef75bc5 100644 --- a/docs/quality/SECURITY_SCOPE_2026-10-23.md +++ b/docs/quality/SECURITY_SCOPE_2026-10-23.md @@ -55,7 +55,8 @@ identify 7.18.0 as fixed. Existing app routes use fixed destinations, with no observed SSR hydration path; upgrading is not a claim that a live exploit was demonstrated. The DOM compatibility package is retained to keep imports stable. -The locked dependency regression checks every installed resolution. Navigation +The dependency regression checks every locked path and the actual web consumers +against both the patched range and their resolved lock entries. Navigation controls reject external-looking mixed separators and preserve internal query, fragment and history behavior; existing role guards remain covered. Six required browser cases exercise public and authenticated deep links, reload, lazy pages, diff --git a/tests/lib/dependencies/router.test.mjs b/tests/lib/dependencies/router.test.mjs index cc363e5d..6fc4d4d9 100644 --- a/tests/lib/dependencies/router.test.mjs +++ b/tests/lib/dependencies/router.test.mjs @@ -1,7 +1,9 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; +import { relative, sep } from 'node:path'; import test from 'node:test'; +import { fileURLToPath } from 'node:url'; const root = new URL('../../../', import.meta.url); const require = createRequire(new URL('package.json', root)); @@ -15,5 +17,11 @@ test('all locked React Router paths contain the 7.18 security fixes', async () = for (const [path, entry] of paths) { assert.ok(semver.satisfies(entry.version, '>=7.18.0', { includePrerelease: true }), `${path}: ${entry.version}`); } + for (const name of ['react-router', 'react-router-dom']) { + const installed = webRequire(`${name}/package.json`).version; + const path = relative(fileURLToPath(root), webRequire.resolve(`${name}/package.json`)).split(sep).join('/').replace(/\/package\.json$/, ''); + assert.ok(semver.satisfies(installed, '>=7.18.0', { includePrerelease: true }), `${name}: installed ${installed}`); + assert.equal(installed, lock.packages[path]?.version, `${name}: installed consumer matches lock`); + } assert.equal(webRequire('react-router-dom/package.json').version, webRequire('react-router/package.json').version); }); From 70efd1ec793e67ed01451029bccfcdd9523e75eb Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Tue, 27 Oct 2026 14:39:18 -0700 Subject: [PATCH 4/5] Recognize authenticated empty workspaces in route acceptance --- ...pgrades-need-current-consumer-contracts.md | 19 +++++++++++++++++++ tests/e2e/auth/router-navigation.spec.ts | 18 +++++++++++++++--- tests/lib/auth.ts | 4 +++- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/.agents/inbox/2026-10-27-router-upgrades-need-current-consumer-contracts.md b/.agents/inbox/2026-10-27-router-upgrades-need-current-consumer-contracts.md index c29c6920..08a94e7f 100644 --- a/.agents/inbox/2026-10-27-router-upgrades-need-current-consumer-contracts.md +++ b/.agents/inbox/2026-10-27-router-upgrades-need-current-consumer-contracts.md @@ -51,3 +51,22 @@ it checked locked ranges and installed-pair equality, which could accept two equally stale installed copies. The guard now requires each actual web consumer to satisfy the fixed range and match its resolved lock entry. The inventory states that exact scope. Refresh the changed harness and final CI before merge. + +## Empty-account CI correction + +Composed Router head `14a4fcc` [CI 40584829418](https://github.com/GraphDone/GraphDone-Core/actions/runs/40584829418) +completed 50 browser cases in 854 seconds: 48 passed, two viewer cases failed, +plus the harness's failed-process row. This was not a process-budget timeout. +Retained screenshots show the authenticated empty workspace at both widths. +The desktop assertion incorrectly required a populated graph's `view-content`; +the phone login helper did not recognize an authenticated account without a +graph selector. The local fixture had graphs and hid both assumptions. + +Use the existing authenticated `workspace-content` container for account-level +route checks and keep login-form exclusion. Router cases additionally assert +`me.role === VIEWER` through the real API before and after the fresh-context +callback. A standalone real-browser control recognizes an empty workspace, +rejects a password form even with workspace markup, and rejects a loading page. +With the old helper, the empty-workspace positive failed deterministically; +this helper correction adds no retry, wait or timeout. Original CI artifacts +remain evidence, and final current-head browser/full CI gates still apply. diff --git a/tests/e2e/auth/router-navigation.spec.ts b/tests/e2e/auth/router-navigation.spec.ts index 0ebc1788..3b41dac1 100644 --- a/tests/e2e/auth/router-navigation.spec.ts +++ b/tests/e2e/auth/router-navigation.spec.ts @@ -1,5 +1,15 @@ import { test, expect, type Page } from '@playwright/test'; -import { login, TEST_USERS, getBaseURL } from '../../lib/auth'; +import { login, TEST_USERS, getBaseURL, getAuthState } from '../../lib/auth'; +import { gql } from '../../lib/graphRequest'; + +test('auth state recognizes an empty authenticated workspace and rejects a login form @router', async ({ page }) => { + await page.setContent('

No Graphs Available

'); + expect((await getAuthState(page)).isLoggedIn).toBe(true); + await page.setContent('
'); + expect((await getAuthState(page)).isLoggedIn).toBe(false); + await page.setContent('
Loading
'); + expect((await getAuthState(page)).isLoggedIn).toBe(false); +}); for (const width of [1440, 390]) { test.describe(`application route compatibility at ${width}px @router`, () => { @@ -56,10 +66,11 @@ for (const width of [1440, 390]) { test('viewer route guards and a fresh-context login callback preserve authorization', async ({ page, browser }) => { await login(page, TEST_USERS.VIEWER, { retries: 1 }); + expect((await gql(page, '{ me { role } }')).me.role).toBe('VIEWER'); for (const route of ['/admin', '/backend']) { await page.goto(`${getBaseURL()}${route}`); await expect(page).toHaveURL(new URL('/', getBaseURL()).href); - await expect(page.locator('[data-testid="view-content"]')).toBeVisible(); + await expect(page.getByTestId('workspace-content')).toBeVisible(); await expect(page.getByRole('heading', { name: 'System Administration', exact: true })).toHaveCount(0); } const token = await page.evaluate(() => localStorage.getItem('authToken') || sessionStorage.getItem('authToken')); @@ -72,7 +83,8 @@ for (const width of [1440, 390]) { await callback.addInitScript(() => localStorage.setItem('graphdone:viewMode', 'cards')); await callback.goto(`${getBaseURL()}/login?token=${encodeURIComponent(token!)}`); await expect(callback).toHaveURL(new URL('/', getBaseURL()).href); - await expect(callback.locator('[data-testid="view-content"]')).toBeVisible(); + await expect(callback.getByTestId('workspace-content')).toBeVisible(); + expect((await gql(callback, '{ me { role } }')).me.role).toBe('VIEWER'); expect(await callback.evaluate(() => !window.location.search.includes('token='))).toBe(true); await callback.goto(`${getBaseURL()}/admin`); await expect(callback).toHaveURL(new URL('/', getBaseURL()).href); diff --git a/tests/lib/auth.ts b/tests/lib/auth.ts index f03945a2..ade4f7a7 100644 --- a/tests/lib/auth.ts +++ b/tests/lib/auth.ts @@ -90,6 +90,7 @@ export async function getAuthState(page: Page): Promise { // Workspace-specific indicators '[data-testid="graph-selector"]', '[data-testid="graph-selector-mobile"]', + '[data-testid="workspace-content"]', '.graph-selector', 'text="Graph Viewer"', // Error indicators @@ -136,6 +137,7 @@ export async function getAuthState(page: Page): Promise { ind.includes('Logout') || ind.includes('user-menu') || ind.includes('graph-selector') || + ind.includes('workspace-content') || ind.includes('Graph Viewer') ); @@ -859,4 +861,4 @@ export async function waitForAuthentication( } throw new Error(`Authentication timeout after ${timeout}ms`); -} \ No newline at end of file +} From 8c4a21bd8deb41e8dbeed6e944c3b34373495bcd Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Tue, 27 Oct 2026 14:43:08 -0700 Subject: [PATCH 5/5] Keep all LOD and route checks in bounded required CI sequences --- ...er-upgrades-need-current-consumer-contracts.md | 15 +++++++++++++++ .github/workflows/ci.yml | 10 ++++++++++ tests/e2e/auth/router-navigation.spec.ts | 4 +++- tests/lib/runner/unified-cli.test.mjs | 14 +++++++++++--- tests/sequences/unified.config.mjs | 12 ++++++++---- 5 files changed, 47 insertions(+), 8 deletions(-) diff --git a/.agents/inbox/2026-10-27-router-upgrades-need-current-consumer-contracts.md b/.agents/inbox/2026-10-27-router-upgrades-need-current-consumer-contracts.md index 08a94e7f..803c9e0d 100644 --- a/.agents/inbox/2026-10-27-router-upgrades-need-current-consumer-contracts.md +++ b/.agents/inbox/2026-10-27-router-upgrades-need-current-consumer-contracts.md @@ -70,3 +70,18 @@ rejects a password form even with workspace markup, and rejects a loading page. With the old helper, the empty-workspace positive failed deterministically; this helper correction adds no retry, wait or timeout. Original CI artifacts remain evidence, and final current-head browser/full CI gates still apply. + +The first empty-workspace/password-form negative used a zero-height empty main; +independent review measured it invisible, so that case could pass for the wrong +reason. It now retains visible content and asserts both the workspace and the +password field are visible before requiring an unauthenticated result. + +Gate capacity is separate from that failure: focus CI's 44-case new-user sequence +passed in 892 seconds, only eight below the 900-second process budget; UUID's +same suite took 768 seconds. Required LOD and route acceptance now run as separate +blocking sequences after the 42-case new-user suite. All 51 cases remain required +(42 existing, two LOD, seven route/helper controls), with unchanged per-action +and per-test deadlines, one worker and zero retries. CI runs and uploads each +result independently; pr/full profiles also include both new sequences. CLI +contract tests execute the enrollment/trace arguments. Do not describe this as +fixing the original two viewer failures or count superseded runs as passes. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73eae2d3..e389bb08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -187,6 +187,16 @@ jobs: env: TEST_URL: http://localhost:3127 CI: true + - name: Semantic LOD acceptance + run: xvfb-run --auto-servernum node tests/run-unified.mjs --sequence semantic-lod --out test-artifacts/ci-semantic-lod + env: + TEST_URL: http://localhost:3127 + CI: true + - name: Route compatibility acceptance + run: xvfb-run --auto-servernum node tests/run-unified.mjs --sequence router-navigation --out test-artifacts/ci-router-navigation + env: + TEST_URL: http://localhost:3127 + CI: true - name: Mobile UI audit (layout / contrast / dialogs across every screen) run: node tests/run-unified.mjs --sequence mobile --out test-artifacts/ci-mobile env: diff --git a/tests/e2e/auth/router-navigation.spec.ts b/tests/e2e/auth/router-navigation.spec.ts index 3b41dac1..12fbf852 100644 --- a/tests/e2e/auth/router-navigation.spec.ts +++ b/tests/e2e/auth/router-navigation.spec.ts @@ -5,7 +5,9 @@ import { gql } from '../../lib/graphRequest'; test('auth state recognizes an empty authenticated workspace and rejects a login form @router', async ({ page }) => { await page.setContent('

No Graphs Available

'); expect((await getAuthState(page)).isLoggedIn).toBe(true); - await page.setContent('
'); + await page.setContent('

No Graphs Available

'); + await expect(page.getByTestId('workspace-content')).toBeVisible(); + await expect(page.locator('input[type="password"]')).toBeVisible(); expect((await getAuthState(page)).isLoggedIn).toBe(false); await page.setContent('
Loading
'); expect((await getAuthState(page)).isLoggedIn).toBe(false); diff --git a/tests/lib/runner/unified-cli.test.mjs b/tests/lib/runner/unified-cli.test.mjs index 1e937b3f..8e56a277 100644 --- a/tests/lib/runner/unified-cli.test.mjs +++ b/tests/lib/runner/unified-cli.test.mjs @@ -37,7 +37,7 @@ function fixture(t) { return { dir, run }; } -for (const sequence of ['new-user', 'mobile', 'perf-budgets']) { +for (const sequence of ['new-user', 'semantic-lod', 'router-navigation', 'mobile', 'perf-budgets']) { test(`CLI forwards failure-only tracing without retries for ${sequence}`, t => { const { dir, run } = fixture(t); const report = { suites: [{ specs: [{ title: 'fixture', ok: true, @@ -48,8 +48,16 @@ for (const sequence of ['new-user', 'mobile', 'perf-budgets']) { assert.equal(result.status, 0, result.stdout); const args = JSON.parse(readFileSync(join(dir, 'bin/argv.json'))); if (sequence === 'new-user') { - assert.ok(args.includes('tests/e2e/graph/semantic-lod.spec.ts')); - assert.ok(args.includes('tests/e2e/auth/router-navigation.spec.ts')); + assert.ok(!args.includes('tests/e2e/graph/semantic-lod.spec.ts')); + assert.ok(!args.includes('tests/e2e/auth/router-navigation.spec.ts')); + } + if (sequence === 'semantic-lod' || sequence === 'router-navigation') { + const spec = sequence === 'semantic-lod' ? 'tests/e2e/graph/semantic-lod.spec.ts' : 'tests/e2e/auth/router-navigation.spec.ts'; + assert.ok(args.includes(spec)); + assert.equal(SEQUENCES[sequence].blocking, true); + const workflow = readFileSync(join(root, '.github/workflows/ci.yml'), 'utf8'); + const command = `run: xvfb-run --auto-servernum node tests/run-unified.mjs --sequence ${sequence} --out test-artifacts/ci-${sequence}`; + assert.equal(workflow.split(command).length, 2); } assert.deepEqual(args.filter(arg => arg.startsWith('--trace=')), ['--trace=retain-on-failure']); assert.deepEqual(args.filter(arg => arg.startsWith('--retries=')), ['--retries=0']); diff --git a/tests/sequences/unified.config.mjs b/tests/sequences/unified.config.mjs index 63cef837..6f72a0cc 100644 --- a/tests/sequences/unified.config.mjs +++ b/tests/sequences/unified.config.mjs @@ -22,8 +22,6 @@ export const SEQUENCES = { 'tests/e2e/graph/overview-board-actions.spec.ts', 'tests/e2e/graph/recorded-ai-work.spec.ts', 'tests/e2e/graph/admin-dialog-responsive.spec.ts', - 'tests/e2e/graph/semantic-lod.spec.ts', - 'tests/e2e/auth/router-navigation.spec.ts', 'tests/e2e/smoke/user-smoke.spec.ts', 'tests/e2e/quality/persona-lifecycle.spec.ts', 'tests/e2e/quality/graph-motion.spec.ts', @@ -31,6 +29,12 @@ export const SEQUENCES = { 'tests/e2e/quality/graph-details.spec.ts', `--project=${PW}`, '--headed', '--workers=1', '--retries=0', '--trace=retain-on-failure', ], blocking: true }, + 'semantic-lod': { adapter: 'playwright', title: 'Semantic LOD acceptance', args: [ + 'tests/e2e/graph/semantic-lod.spec.ts', `--project=${PW}`, '--headed', '--workers=1', '--retries=0', '--trace=retain-on-failure', + ], blocking: true }, + 'router-navigation': { adapter: 'playwright', title: 'Route compatibility acceptance', args: [ + 'tests/e2e/auth/router-navigation.spec.ts', `--project=${PW}`, '--headed', '--workers=1', '--retries=0', '--trace=retain-on-failure', + ], blocking: true }, 'e2e-auth': { adapter: 'playwright', title: 'Auth e2e — passwordless focus', args: ['tests/e2e/auth/passwordless-focus.spec.ts', `--project=${PW}`], blocking: true }, 'e2e-graph': { adapter: 'playwright', title: 'Graph e2e — camera', args: ['tests/e2e/graph/camera.spec.ts', `--project=${PW}`], blocking: true }, 'mobile': { adapter: 'playwright', title: 'Mobile audit', args: ['--grep', '@mobile|@audit', `--project=${PW}`, '--workers=1', '--retries=0', '--trace=retain-on-failure'], blocking: true }, @@ -52,8 +56,8 @@ export const SEQUENCES = { export const PROFILES = { smoke: ['unit-web', 'smoke', 'e2e-auth'], - pr: ['unit-web', 'smoke', 'e2e-auth', 'e2e-graph', 'mobile', 'perf-budgets'], - full: ['unit-web', 'smoke', 'e2e-auth', 'e2e-graph', 'mobile', 'perf-budgets', 'diagnostics', 'showcase', 'matrix', 'vlm', 'perf-scale', 'cloud-audit'], + pr: ['unit-web', 'smoke', 'e2e-auth', 'e2e-graph', 'semantic-lod', 'router-navigation', 'mobile', 'perf-budgets'], + full: ['unit-web', 'smoke', 'e2e-auth', 'e2e-graph', 'semantic-lod', 'router-navigation', 'mobile', 'perf-budgets', 'diagnostics', 'showcase', 'matrix', 'vlm', 'perf-scale', 'cloud-audit'], report: ['diagnostics', 'showcase', 'matrix', 'vlm', 'perf-scale', 'cloud-audit'], live: ['live-journey'], };