Integrate reviewed Docs accessibility and guidance repair

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot] 2026-10-01 20:00:13 +01:00
commit f99803f46d
11 changed files with 395 additions and 34 deletions

View file

@ -20,6 +20,18 @@
## Purpose
### Shipped documentation table headers
After sanitization, the documentation renderer assigns trusted `scope=col` to
native `thead th` cells without expanding the attribute allowlist. Document
roles, scope and classes remain untrusted, and body cells are not promoted.
Table-local horizontal scrolling is preserved. Renderer regression tests cover
trusted column scope and rejected author-supplied attributes. The production
Docs/router/styles fixture checks all eight current plan-table columnheader
roles in desktop Chromium and phone WebKit, including local scrolling and
page-width containment. This establishes browser roles, not screen-reader
speech or release availability.
### Shipped documentation fragment navigation
The shared documentation renderer assigns GitHub-compatible, document-local

View file

@ -2,6 +2,7 @@
<html>
<head>
<meta charset="UTF-8" />
<link rel="icon" href="data:," />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>

View file

@ -16,6 +16,7 @@ const scenario = new URLSearchParams(window.location.search).get('scenario') ??
const targets: Record<string, string> = {
plain: '/docs/API',
plans: '/docs/PULSE_PRO',
direct: '/docs/API#resource-maintenance-and-operator-state',
reload: '/docs/API#resource-maintenance-and-operator-state',
malformed: '/docs/API#%invalid',

View file

@ -0,0 +1,124 @@
// Production Docs/router/styles and shipped plan Markdown, no backend.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { chromium, webkit } = require('playwright');
(async () => {
const observe = process.argv.includes('observe');
const root = '/workspace/frontend-modern';
process.chdir(root);
const artifacts = path.join(root, 'node_modules', 'docs-header-browser');
fs.mkdirSync(artifacts, { recursive: true });
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
const server = await createServer({
root,
configFile: path.join(root, 'vite.config.ts'),
cacheDir: path.join(root, 'node_modules', '.vite-docs-table-headers'),
server: { host: '127.0.0.1', port: 5243, strictPort: true },
optimizeDeps: { noDiscovery: true, include: [] },
});
let browser;
const results = [];
try {
await server.listen();
for (const [engine, width, tone] of [
['chromium', 1280, 'light'],
['webkit', 390, 'dark'],
]) {
browser = await { chromium, webkit }[engine].launch(
engine === 'chromium'
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
const page = await browser.newPage({
viewport: { width, height: 900 },
...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}),
});
const errors = [];
page.on('response', (response) => {
if (response.status() >= 400)
console.log('FAILED_RESPONSE', response.status(), response.url());
});
page.on('pageerror', (e) => errors.push(e.message));
page.on('console', (m) => {
if (m.type() === 'error') {
console.log('CONSOLE_ERROR', m.text(), m.location());
errors.push(m.text());
}
});
await page.goto(
'http://127.0.0.1:5243/browser-tests/docs-fragment-navigation.html?scenario=plans',
{ waitUntil: 'domcontentloaded', timeout: 60_000 },
);
await page.locator('article table').first().waitFor();
if (tone === 'dark')
await page.evaluate(() => document.documentElement.classList.add('dark'));
const table = page.locator('article table').filter({ hasText: 'Relay (legacy)' }).first();
const th = await table.locator('thead th').allTextContents();
const headers = await table.getByRole('columnheader').allTextContents();
const snapshot = await table.ariaSnapshot();
const scopes = await table
.locator('thead th')
.evaluateAll((nodes) => nodes.map((n) => n.getAttribute('scope')));
const scroll = await table.evaluate((t) => ({
wrapper: t.parentElement.dataset.docTableScroll !== undefined,
outer: document.documentElement.scrollWidth,
viewport: window.innerWidth,
width: t.parentElement.clientWidth,
content: t.parentElement.scrollWidth,
}));
if (!observe) {
assert.equal(th.length, 8);
assert.deepEqual(headers, th);
assert.deepEqual(scopes, Array(th.length).fill('col'));
assert.ok(snapshot.includes('columnheader "Relay (legacy)"'));
assert.ok(scroll.wrapper);
assert.ok(scroll.outer <= scroll.viewport + 1);
if (width === 390) {
assert.ok(scroll.content > scroll.width);
const moved = await table.evaluate((t) => {
t.parentElement.scrollLeft = 180;
return t.parentElement.scrollLeft;
});
assert.ok(moved > 0);
}
assert.deepEqual(errors, []);
}
await table.locator('thead').scrollIntoViewIfNeeded();
await page.screenshot({ path: path.join(artifacts, `${engine}-headers.png`) });
await page.screenshot({
path: path.join(artifacts, `${engine}-${observe ? 'before' : 'after'}.png`),
fullPage: true,
});
results.push({
engine,
version: browser.version(),
width,
tone,
th,
headers,
scopes,
scroll,
snapshot,
errors,
});
await browser.close();
browser = undefined;
}
fs.writeFileSync(
path.join(artifacts, observe ? 'before.json' : 'after.json'),
JSON.stringify(
{ playwright: require('playwright/package.json').version, observe, results },
null,
2,
),
);
console.log(JSON.stringify({ result: observe ? 'observed' : 'passed', results, artifacts }));
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch((e) => {
console.error(e);
process.exitCode = 1;
});

View file

@ -0,0 +1,193 @@
// Scoped copy proof: actual installer/Doctor/styles, synthetic token API only.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { chromium, webkit } = require('playwright');
(async () => {
const root = '/workspace/frontend-modern';
process.chdir(root);
const artifacts = path.join(root, 'node_modules', 'unix-guidance-browser');
fs.mkdirSync(artifacts, { recursive: true });
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
const server = await createServer({
root,
configFile: path.join(root, 'vite.config.ts'),
cacheDir: path.join(root, 'node_modules', '.vite-unix-guidance-copy'),
optimizeDeps: {
noDiscovery: true,
include: ['solid-js', 'solid-js/web', '@solidjs/router'],
},
server: { host: '127.0.0.1', port: 5244, strictPort: true },
});
const secret = 'browser-synthetic-install-token';
const results = [];
let browser;
try {
await server.listen();
for (const [engine, width, tone] of [
['chromium', 1280, 'light'],
['webkit', 390, 'dark'],
]) {
browser = await { chromium, webkit }[engine].launch(
engine === 'chromium'
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
const page = await browser.newPage({
viewport: { width, height: 900 },
...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}),
});
const errors = [];
page.on('pageerror', (error) => {
errors.push(error.message);
console.log('PAGE_ERROR', error.message);
});
page.on('console', (message) => {
if (message.type() === 'error') {
errors.push(message.text());
console.log('CONSOLE_ERROR', message.text());
}
});
await page.addInitScript(() => {
window.__copies = [];
Object.defineProperty(navigator, 'clipboard', {
value: {
writeText: async (text) => window.__copies.push(text),
},
});
});
await page.route('**/api/**', async (route) => {
const pathname = new URL(route.request().url()).pathname;
// Do not intercept the production /src/api/*.ts module requests.
if (!pathname.startsWith('/api/')) return route.continue();
if (pathname === '/api/security/status')
return route.fulfill({
json: {
requiresAuth: true,
hasAuthentication: true,
apiTokenConfigured: true,
},
});
if (pathname === '/api/agent-install-command')
return route.fulfill({
json: {
token: secret,
record: {
id: 'synthetic-record',
name: 'Synthetic installer',
prefix: 'browser',
suffix: 'token',
createdAt: new Date().toISOString(),
},
},
});
if (pathname === '/api/state')
return route.fulfill({ json: { connectedInfrastructure: [] } });
return route.fulfill({ json: { data: [] } });
});
await page.goto('http://127.0.0.1:5244/browser-tests/unix-private-install.html', {
waitUntil: 'domcontentloaded',
timeout: 60_000,
});
await page
.getByRole('heading', { name: 'Private Unix agent installation', exact: true })
.waitFor({ timeout: 60_000 });
if (tone === 'dark')
await page.evaluate(() => document.documentElement.classList.add('dark'));
await page.getByRole('button', { name: 'Generate token', exact: true }).first().click();
const dialog = page.getByRole('dialog', { name: 'API token ready' });
await dialog.waitFor();
await dialog.getByRole('button', { name: 'Dismiss', exact: true }).click();
await dialog.waitFor({ state: 'hidden' });
const privateNote = page
.locator('span')
.filter({
hasText: /^Before running, save the separately revealed token/,
})
.first();
const fileText = await privateNote.innerText();
assert.ok(
fileText.includes(
'This command does not prompt or delete your file. Remove it through the same trusted file path afterwards.',
),
);
for (const warning of [
'0600',
'0700',
'Never put the token in a GUI command field',
'console or SSH instead',
])
assert.ok(fileText.includes(warning));
await page
.getByRole('button', {
name: 'Copy Install from a private token file (no terminal) command',
exact: true,
})
.click();
const fileCommand = await page.evaluate(() => window.__copies.at(-1));
assert.ok(fileCommand.includes('/root/.config/pulse-agent/bootstrap-token'));
assert.ok(fileCommand.includes('--token-file'));
assert.ok(!fileCommand.includes('read -r'));
assert.ok(!fileCommand.includes(secret));
await privateNote.screenshot({ path: path.join(artifacts, `${engine}-private-file.png`) });
await page
.getByRole('button', { name: 'Show details for removed-fixture', exact: true })
.click();
const removalNote = page.locator('p').filter({
hasText:
'This agent was removed from Pulse, but the agent software may still be installed on its host.',
});
const removalText = await removalNote.innerText();
assert.ok(
removalText.includes('token at its silent prompt. Never insert it into the command.'),
);
assert.ok(removalText.includes('Pulse does not run commands remotely'));
await page
.getByRole('button', {
name: 'Copy Linux / macOS / FreeBSD uninstall command for removed-fixture',
exact: true,
})
.click();
const removalCommand = await page.evaluate(() => window.__copies.at(-1));
assert.ok(removalCommand.includes('removed-agent-42'));
assert.ok(removalCommand.includes('--uninstall'));
assert.ok(!removalCommand.includes('--preflight-only'));
assert.ok(!removalCommand.includes(secret));
await removalNote.screenshot({ path: path.join(artifacts, `${engine}-removed-note.png`) });
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
await page.screenshot({ path: path.join(artifacts, `${engine}-viewport.png`) });
assert.deepEqual(errors, []);
results.push({
engine,
version: browser.version(),
width,
tone,
fileText,
removalText,
privateFileCopied: true,
scopedRemovalCopied: true,
errors,
});
await browser.close();
browser = undefined;
}
fs.writeFileSync(
path.join(artifacts, 'result.json'),
JSON.stringify(
{
playwright: require('playwright/package.json').version,
results,
},
null,
2,
),
);
console.log(JSON.stringify({ result: 'passed', artifacts, results }));
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});

View file

@ -91,7 +91,17 @@ const Doctor = () => {
agentIdentity: { hostname: 'doctor.example', commandsEnabled: false },
},
} as InfrastructureAgentDoctorTarget;
return <InfrastructureAgentDoctorPage targets={[target]} />;
const removedTarget = {
...target,
key: 'agent:removed-fixture',
connectionId: 'agent:removed-fixture',
displayName: 'removed-fixture',
status: 'removed',
needsCredentialRepair: false,
connection: undefined,
diagnostic: { agentId: 'removed-agent-42', hostname: 'removed.example' },
} as InfrastructureAgentDoctorTarget;
return <InfrastructureAgentDoctorPage targets={[target, removedTarget]} />;
};
render(
() => (

View file

@ -1,27 +1,17 @@
{
"version": 1,
"base_sha": "9189a7262e84b201a5af6649caf0f8cff7c6d144",
"verified_at": "2026-10-01T13:06:33.056850Z",
"base_sha": "1a0cd7c9d5aa02de754535a293dc0cacd4ef6fa0",
"verified_at": "2026-10-01T18:27:14.912695Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx",
"frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx",
"frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx",
"frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx",
"frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx",
"frontend-modern/src/utils/agentInstallCommand.ts"
"frontend-modern/src/features/docs/docMarkdown.ts"
],
"content_sha256": {
"frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx": "416c7627303c665f956168a32e5aaf6af4268abacdd7e93045ab26cd45732d14",
"frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx": "d2ff336c1e602794fd696aed3541684498adfcfeaf1f4e603e21a44865fce3bb",
"frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx": "983a44871cfb908851c215a177e4a39185f7ac9017cc1b2152323b7ee396995a",
"frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx": "c57bf5a99d457d6d21f0d22e0cc34af3618db214391fd09afce34743173a1c0f",
"frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx": "276c89076d36670752f5d37be1895d739ae13501ea9d87d1cc3a497c7e6f5895",
"frontend-modern/src/utils/agentInstallCommand.ts": "9bb98b1db1b49aa478f894b346479951954899d4c6049d991ec4c75730cafa30"
"frontend-modern/src/features/docs/docMarkdown.ts": "572a3b4bc5bc1114adef5aa64bb0ea833c0dbe5865bbd57d3dc767fd1965aa55"
},
"routes": [
"/browser-tests/unix-private-install.html",
"/browser-tests/unix-private-install.html?optional=1"
"/docs/PULSE_PRO",
"/browser-tests/docs-fragment-navigation.html?scenario=plans"
],
"viewports": [
{
@ -34,29 +24,26 @@
}
],
"states": [
"Actual infrastructure installer, operations closures, Agent Doctor and token dialog with production CSS; synthetic APIs, not installed agent/appliance execution",
"Required-auth Unix command and credential remain separate; single-line command grammar, private file no-terminal variant and separate-token instructions",
"Canonical repair/removal identity and custom CA preserved; saved-state update has no replacement credential; removal has no new-binary preflight",
"Optional-auth commands require no terminal or token-file; default TLS verification and monitoring-only issuance unchanged",
"Desktop Chromium light and phone-emulated WebKit dark; four final screenshots visually inspected"
"Production Docs page/router/styles with the current shipped eight-column plan comparison, not a replacement table fixture",
"Every sanitized thead TH has trusted scope=col and all eight native columnheader roles; document role/scope/class remain stripped",
"Desktop Chromium light and phone-emulated WebKit dark; table-local horizontal scrolling and outer-page containment; four final full-page/header screenshots inspected",
"This establishes browser accessibility roles, not screen-reader speech or published availability"
],
"interactions": [
"Generate scoped host token, copy token with keyboard, Escape, reopen existing reveal without issuing another token",
"Copy actual Linux installer, no-terminal private-file installer and Agent Doctor repair commands; raw credential absent from each clipboard result",
"Change endpoint and custom CA; copy actual operations-state repair/removal/saved-state update commands; identity/profile/trust checked",
"Reload with optional auth and confirm tokenless commands"
"Navigate to the real plans document and inspect table DOM and accessibility snapshot",
"On phone, scroll the table horizontally without moving the outer page; bring its header into view"
],
"command": "pulse-worker-browser frontend-modern/browser-tests/unix-private-install.cjs",
"command": "pulse-worker-browser frontend-modern/browser-tests/docs-table-headers.cjs",
"browser_versions": {
"playwright": "1.56.1",
"chromium": "141.0.7390.37",
"webkit": "26.0"
},
"artifacts": [
"/var/lib/pulse-maintainer/worker-outputs/web-product-wielhxw1/browser/chromium-commands.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-wielhxw1/browser/chromium-token.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-wielhxw1/browser/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-wielhxw1/browser/webkit-commands.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-wielhxw1/browser/webkit-token.png"
"/var/lib/pulse-maintainer/worker-outputs/web-product-yqww6hbj/browser/docs/chromium-after.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-yqww6hbj/browser/docs/chromium-headers.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-yqww6hbj/browser/docs/webkit-after.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-yqww6hbj/browser/docs/webkit-headers.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-yqww6hbj/browser/docs/after.json"
]
}

View file

@ -943,7 +943,7 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
the uninstall command on the affected host itself. Pulse does
not run commands remotely. For Unix commands that require a
token, run the command first, then paste the separately revealed
token at its silent prompt; never insert it into the command.
token at its silent prompt. Never insert it into the command.
</p>
<For each={handoff().commands}>
{(entry) => (

View file

@ -403,7 +403,7 @@ export const buildCommandsByPlatform = (
<code>/root/.config/pulse-agent/bootstrap-token</code> using a trusted file editor
or upload path. The file must be root-owned with mode <code>0600</code> in a
root-owned <code>0700</code> directory. Never put the token in a GUI command
field. This command does not prompt or delete your file; remove it through the
field. This command does not prompt or delete your file. Remove it through the
same trusted file path afterwards. If your GUI cannot create private files, use
console or SSH instead. Bash must already be installed.
</span>

View file

@ -238,3 +238,32 @@ describe('extractDocTitle', () => {
expect(extractDocTitle('Intro\n\n# Later heading', 'FAQ')).toBe('FAQ');
});
});
describe('documentation table column semantics', () => {
it('assigns column scope to every sanitized Markdown header without promoting data cells', () => {
const container = document.createElement('div');
container.innerHTML = renderDocMarkdown(
'| Feature | Community | Pro |\n| --- | --- | --- |\n| Metrics | Yes | Yes |',
'PULSE_PRO',
);
const headers = Array.from(container.querySelectorAll('thead th'));
expect(headers).toHaveLength(3);
expect(headers.map((header) => header.getAttribute('scope'))).toEqual(['col', 'col', 'col']);
expect(container.querySelectorAll('tbody [scope]')).toHaveLength(0);
expect(
container.querySelector('table')?.parentElement?.hasAttribute('data-doc-table-scroll'),
).toBe(true);
});
it('does not trust document-supplied roles or scope while assigning trusted column scope', () => {
const container = document.createElement('div');
container.innerHTML = renderDocMarkdown(
'<table><thead><tr><th role="presentation" scope="row" onclick="evil()">Heading</th></tr></thead><tbody><tr><th scope="col" class="fixed">Body label</th><td>Value</td></tr></tbody></table>',
'PULSE_PRO',
);
const header = container.querySelector('thead th');
expect(header?.getAttribute('scope')).toBe('col');
expect(container.querySelector('[role], [onclick], th[class]')).toBeNull();
expect(container.querySelector('tbody th')?.hasAttribute('scope')).toBe(false);
});
});

View file

@ -130,6 +130,10 @@ export function renderDocMarkdown(markdown: string, currentDocPath: string): str
*/
export function wrapTables(container: HTMLElement): void {
container.querySelectorAll('table').forEach((table) => {
// Sanitised Markdown has no author-controlled roles/scope. Assign trusted
// column semantics to header cells: browsers otherwise expose these THs
// as ordinary cells, leaving readers without table header associations.
table.querySelectorAll('thead th').forEach((header) => header.setAttribute('scope', 'col'));
const parent = table.parentElement;
if (parent?.dataset?.docTableScroll !== undefined) return;
const wrapper = window.document.createElement('div');