mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
1b422dd1a6
Lands contributor PR #2067 (head5eeea72a3a, author rcourtman) as a single maintainer commit on main. The PR documents the authenticated /api/resources/{id}/operator-state maintenance window contract and repairs GitHub-compatible documentation heading fragments. Composed from the reviewed web-product candidate tree68465bc48d(candidate 20260921T054028Z-web-product), which resolved the conflicts against main and replaced the unavailable github-slugger dependency with a dependency-free GitHub-compatible heading slugger. The tree is unchanged from that reviewed candidate; only the commit shape differs so the fdb2be15be..HEAD provenance range contains no external PR commit. Validation retained from that candidate: vite build passed, check-bundle-size.mjs passed (vendor within budget), 61 focused vitest tests passed, and the offline Playwright docs-fragment-navigation run passed with a content-addressed browser-verification.json receipt based atfdb2be15be. Change-source: pulse-maintainer
214 lines
9.6 KiB
JavaScript
214 lines
9.6 KiB
JavaScript
// Offline real-browser verification for #2067's shipped-documentation
|
|
// navigation. Mounts the production Docs page under the production router and
|
|
// serves the real frontend-modern/public/docs/API.md asset, then checks
|
|
// GitHub-compatible heading IDs, direct/reload fragment focus and scrolling,
|
|
// malformed-fragment safety and keyboard activation of intra-document links.
|
|
//
|
|
// Run from the assigned workspace root:
|
|
// pulse-worker-browser scripts/check-docs-fragment-navigation.cjs
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { chromium } = require('playwright');
|
|
|
|
const ROOT = path.resolve(process.cwd(), 'frontend-modern');
|
|
const PORT = 5197;
|
|
const FIXTURE = '/browser-tests/docs-fragment-navigation.html';
|
|
const TARGET_ID = 'resource-maintenance-and-operator-state';
|
|
const launchOptions = { headless: true, channel: 'chromium', args: ['--no-sandbox'] };
|
|
|
|
const failures = [];
|
|
const observations = [];
|
|
const pageErrors = [];
|
|
const httpErrors = [];
|
|
|
|
function check(condition, message) {
|
|
if (!condition) failures.push(message);
|
|
observations.push(`${condition ? 'PASS' : 'FAIL'}: ${message}`);
|
|
}
|
|
|
|
(async () => {
|
|
process.chdir(ROOT);
|
|
const { createServer } = await import(
|
|
path.join(ROOT, 'node_modules', 'vite', 'dist', 'node', 'index.js')
|
|
);
|
|
const server = await createServer({
|
|
root: ROOT,
|
|
configFile: path.join(ROOT, 'vite.config.ts'),
|
|
server: { host: '127.0.0.1', port: PORT, strictPort: true },
|
|
});
|
|
let browser;
|
|
let result = { result: 'failed', failures, observations };
|
|
try {
|
|
await server.listen();
|
|
browser = await chromium.launch(launchOptions);
|
|
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
|
const page = await context.newPage();
|
|
page.on('pageerror', (error) => pageErrors.push(error.message));
|
|
page.on('console', (message) => {
|
|
if (message.type() !== 'error') return;
|
|
if (message.text().includes('Failed to load resource')) return;
|
|
pageErrors.push(`console: ${message.text()}`);
|
|
});
|
|
page.on('response', (response) => {
|
|
if (response.status() >= 400) httpErrors.push(`${response.status()} ${response.url()}`);
|
|
});
|
|
// The Docs fixture has no backend. Answer the app-runtime auth probe so it
|
|
// settles instead of logging an uncaught network failure. Anchor to the
|
|
// server root so `src/api/...` modules are not intercepted.
|
|
const backendApi = new RegExp(`^http://127\\.0\\.0\\.1:${PORT}/api/`);
|
|
await page.route(backendApi, (route) =>
|
|
route.fulfill({ status: 200, contentType: 'application/json', body: '{}' }),
|
|
);
|
|
await page.route(new RegExp(`^http://127\\.0\\.0\\.1:${PORT}/api/security/status$`), (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ hasAuthentication: false }),
|
|
}),
|
|
);
|
|
|
|
const url = (scenario) =>
|
|
`http://127.0.0.1:${PORT}${FIXTURE}?scenario=${scenario}`;
|
|
|
|
const waitForTargetFocus = async (timeout = 20000) => {
|
|
await page.waitForSelector(`#${TARGET_ID}`, { timeout });
|
|
await page.waitForFunction(
|
|
(id) => document.activeElement && document.activeElement.id === id,
|
|
TARGET_ID,
|
|
{ timeout },
|
|
);
|
|
};
|
|
|
|
// --- plain: heading IDs, anchor resolution, layout ----------------------
|
|
await page.goto(url('plain'));
|
|
await page.waitForSelector('article h1, article h2', { timeout: 20000 });
|
|
const plain = await page.evaluate(() => {
|
|
const headings = Array.from(document.querySelectorAll('article h1, article h2, article h3, article h4, article h5, article h6'));
|
|
const ids = headings.map((heading) => heading.id);
|
|
const docLinks = Array.from(document.querySelectorAll('article a[href]'));
|
|
return {
|
|
count: headings.length,
|
|
missingIds: ids.filter((id) => !id).length,
|
|
uniqueIds: new Set(ids).size === ids.length,
|
|
targetExists: ids.includes('resource-maintenance-and-operator-state'),
|
|
fragmentLinks: docLinks
|
|
.map((anchor) => anchor.getAttribute('href') || '')
|
|
.filter((href) => href.includes('#')),
|
|
overflow: document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
|
};
|
|
});
|
|
check(plain.count > 10, `renders the API reference headings (${plain.count})`);
|
|
check(plain.missingIds === 0, 'every rendered heading carries a generated id');
|
|
check(plain.uniqueIds, 'generated heading ids are unique within the document');
|
|
check(plain.targetExists, `generated id "${TARGET_ID}" exists for the maintenance section`);
|
|
check(
|
|
plain.fragmentLinks.some((href) => href.endsWith(`#${TARGET_ID}`)),
|
|
'the in-document maintenance fragment link resolves to the generated id',
|
|
);
|
|
check(!plain.overflow, 'desktop viewport has no horizontal page overflow');
|
|
|
|
// --- direct: fragment focus after async fetch ---------------------------
|
|
await page.goto(url('direct'));
|
|
await waitForTargetFocus();
|
|
const direct = await page.evaluate((id) => {
|
|
const target = document.getElementById(id);
|
|
const rect = target.getBoundingClientRect();
|
|
return {
|
|
active: document.activeElement ? document.activeElement.id : null,
|
|
tabIndex: target.tabIndex,
|
|
top: Math.round(rect.top),
|
|
hash: window.location.hash,
|
|
};
|
|
}, TARGET_ID);
|
|
check(direct.active === TARGET_ID, 'direct fragment link moves keyboard focus to the heading');
|
|
check(direct.tabIndex === -1, 'fragment target receives programmatic focus without joining the tab order');
|
|
check(direct.top >= -4 && direct.top <= 200, `direct fragment scrolled target into view (top ${direct.top})`);
|
|
check(direct.hash === `#${TARGET_ID}`, 'direct fragment hash is preserved');
|
|
|
|
// --- reload: same behaviour after a full reload -------------------------
|
|
await page.reload();
|
|
await waitForTargetFocus();
|
|
const reloaded = await page.evaluate((id) => ({
|
|
active: document.activeElement ? document.activeElement.id : null,
|
|
exists: Boolean(document.getElementById(id)),
|
|
}), TARGET_ID);
|
|
check(reloaded.exists && reloaded.active === TARGET_ID, 'reload on a direct fragment restores focus and target');
|
|
|
|
// --- malformed and missing fragments ------------------------------------
|
|
for (const scenario of ['malformed', 'missing']) {
|
|
await page.goto(url(scenario));
|
|
await page.waitForSelector('article h1, article h2', { timeout: 20000 });
|
|
await page.waitForTimeout(500);
|
|
const state = await page.evaluate(() => ({
|
|
activeTag: document.activeElement ? document.activeElement.tagName : null,
|
|
activeIsHeading: Boolean(document.activeElement && /^H[1-6]$/.test(document.activeElement.tagName)),
|
|
hasContent: Boolean(document.querySelector('article h1, article h2')),
|
|
}));
|
|
check(state.hasContent, `${scenario} fragment leaves the document rendered`);
|
|
check(!state.activeIsHeading, `${scenario} fragment does not move focus onto a heading`);
|
|
}
|
|
|
|
// --- keyboard activation of an intra-document link ----------------------
|
|
await page.goto(url('plain'));
|
|
await page.waitForSelector('article h1, article h2', { timeout: 20000 });
|
|
await page.evaluate(() => {
|
|
window.__spaMarker = 'alive';
|
|
});
|
|
const backLink = page.locator('a[data-doc-link]').first();
|
|
await backLink.focus();
|
|
await backLink.press('Enter');
|
|
await page.waitForFunction(
|
|
() => window.location.pathname === '/docs/README',
|
|
undefined,
|
|
{ timeout: 20000 },
|
|
);
|
|
await page.waitForFunction(
|
|
() => document.body.innerText.includes('Pulse documentation'),
|
|
undefined,
|
|
{ timeout: 20000 },
|
|
);
|
|
const keyboard = await page.evaluate(() => ({
|
|
path: window.location.pathname,
|
|
marker: window.__spaMarker,
|
|
heading: Boolean(document.querySelector('article h1, article h2')),
|
|
}));
|
|
check(keyboard.path === '/docs/README', 'keyboard activation of a documentation link navigates the router');
|
|
check(keyboard.marker === 'alive', 'keyboard navigation stays inside the SPA shell (no full reload)');
|
|
check(keyboard.heading, 'the destination document renders after keyboard navigation');
|
|
|
|
// --- narrow viewport ----------------------------------------------------
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await page.goto(url('plain'));
|
|
await page.waitForSelector('article h1, article h2', { timeout: 20000 });
|
|
const narrow = await page.evaluate(() => ({
|
|
overflow: document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
|
tableScrollers: Array.from(document.querySelectorAll('article [data-doc-table-scroll]')).length,
|
|
}));
|
|
check(!narrow.overflow, 'narrow viewport has no horizontal page overflow');
|
|
check(narrow.tableScrollers > 0, 'wide API tables are wrapped in their own scroll container');
|
|
|
|
check(pageErrors.length === 0, `no uncaught page errors (${pageErrors.join('; ') || 'none'})`);
|
|
check(httpErrors.length === 0, `no failed HTTP responses (${httpErrors.join('; ') || 'none'})`);
|
|
|
|
result = {
|
|
result: failures.length === 0 ? 'passed' : 'failed',
|
|
failures,
|
|
observations,
|
|
pageErrors,
|
|
httpErrors,
|
|
chromium: browser.version(),
|
|
};
|
|
} catch (error) {
|
|
failures.push(`runner error: ${error.message}`);
|
|
result = { result: 'failed', failures, observations, pageErrors, httpErrors };
|
|
} finally {
|
|
if (browser) await browser.close().catch(() => undefined);
|
|
await server.close().catch(() => undefined);
|
|
}
|
|
|
|
const out = path.join(process.cwd(), 'docs-fragment-verification-result.json');
|
|
fs.writeFileSync(out, JSON.stringify(result, null, 2));
|
|
console.log(JSON.stringify(result, null, 2));
|
|
process.exit(result.result === 'passed' ? 0 : 1);
|
|
})();
|