mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-03 12:47:49 +00:00
Integrate reviewed Docs accessibility and guidance repair
Change-source: pulse-maintainer
This commit is contained in:
commit
f99803f46d
11 changed files with 395 additions and 34 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
124
frontend-modern/browser-tests/docs-table-headers.cjs
Normal file
124
frontend-modern/browser-tests/docs-table-headers.cjs
Normal 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;
|
||||
});
|
||||
193
frontend-modern/browser-tests/unix-guidance-copy.cjs
Normal file
193
frontend-modern/browser-tests/unix-guidance-copy.cjs
Normal 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;
|
||||
});
|
||||
|
|
@ -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(
|
||||
() => (
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue