diff --git a/scanner/.dockerignore b/scanner/.dockerignore index 1679dd7..8df243a 100644 --- a/scanner/.dockerignore +++ b/scanner/.dockerignore @@ -18,7 +18,5 @@ !codec/*.c !codec/*.patch -!site-preview.mjs - !page-image.mjs !jpeg.mjs diff --git a/scanner/Dockerfile b/scanner/Dockerfile index ecd7b18..fb557e6 100644 --- a/scanner/Dockerfile +++ b/scanner/Dockerfile @@ -34,7 +34,7 @@ RUN node node_modules/playwright/cli.js install-deps chromium \ && apt-get clean && rm -rf /var/lib/apt/lists/* FROM chromium-runtime AS worker -COPY worker.mjs preview.mjs site-preview.mjs page-image.mjs jpeg.mjs public-worker.mjs public-report.mjs public-report.test.mjs ./ +COPY worker.mjs preview.mjs page-image.mjs jpeg.mjs public-worker.mjs public-report.mjs public-report.test.mjs ./ COPY gateway/ ./gateway/ COPY firewall/ ./firewall/ COPY fixtures/ ./fixtures/ @@ -99,7 +99,7 @@ WORKDIR /opt/kindpath-worker COPY package.json package-lock.json ./ RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force \ && rm -rf /usr/lib/node_modules/npm /usr/bin/npm /usr/bin/npx /root/.npm -COPY worker.mjs preview.mjs site-preview.mjs page-image.mjs jpeg.mjs public-worker.mjs public-report.mjs public-report.test.mjs ./ +COPY worker.mjs preview.mjs page-image.mjs jpeg.mjs public-worker.mjs public-report.mjs public-report.test.mjs ./ COPY gateway/ ./gateway/ COPY firewall/ ./firewall/ COPY fixtures/ ./fixtures/ diff --git a/scanner/public-report.mjs b/scanner/public-report.mjs index e3c42b3..635dd46 100644 --- a/scanner/public-report.mjs +++ b/scanner/public-report.mjs @@ -11,19 +11,22 @@ export function analyzePublicPage(page) { // Bound worker output without presenting a shortened list as the complete result. export function summarizeRules(rules) { - const selected = rules.slice(0, 100); + const selected = rules.slice(0, 100).map(rule => ({ + id: rule.id, impact: rule.impact ?? 'unknown', help: rule.help, + description: rule.description, helpUrl: rule.helpUrl, + nodes: rule.nodes.slice(0, 20).flatMap(node => { + if (node.target.length > 100) return []; + const target = node.target.map(selector => typeof selector === 'string' ? selector : JSON.stringify(selector)); + // A shortened selector could point to the wrong element. + if (target.some(selector => selector.length > 4000)) return []; + return [{ target, summary: (node.failureSummary ?? '').slice(0, 2000) }]; + }), + })); return { omittedRules: rules.length - selected.length, omittedNodes: rules.reduce((count, rule) => count + rule.nodes.length, 0) - - selected.reduce((count, rule) => count + Math.min(rule.nodes.length, 20), 0), - rules: selected.map(rule => ({ - id: rule.id, impact: rule.impact ?? 'unknown', help: rule.help, - description: rule.description, helpUrl: rule.helpUrl, - nodes: rule.nodes.slice(0, 20).map(node => ({ - target: node.target.map(selector => typeof selector === 'string' ? selector : JSON.stringify(selector)), - summary: (node.failureSummary ?? '').slice(0, 2000), - })), - })), + - selected.reduce((count, rule) => count + rule.nodes.length, 0), + rules: selected, }; } diff --git a/scanner/public-report.test.mjs b/scanner/public-report.test.mjs index 6987a29..cacf35c 100644 --- a/scanner/public-report.test.mjs +++ b/scanner/public-report.test.mjs @@ -21,3 +21,17 @@ test('complete and empty result sets have no omitted items', () => { assert.equal(output.omittedRules, 0); assert.equal(output.omittedNodes, 0); assert.equal(output.rules[0].nodes[0].summary, 'review'); }); + +test('oversized selector paths omit the element and preserve the rule and valid elements', () => { + const boundary = 'a'.repeat(4000); + const output = summarizeRules([{ ...rule, nodes: [ + { ...node, target: [boundary + 'a'] }, + { ...node, target: [[boundary]] }, + { ...node, target: Array(101).fill('a') }, + { ...node, target: Array(100).fill(boundary) }, + ] }]); + assert.equal(output.omittedRules, 0); + assert.equal(output.omittedNodes, 3); + assert.equal(output.rules[0].id, rule.id); + assert.deepEqual(output.rules[0].nodes, [{ target: Array(100).fill(boundary), summary: 'review' }]); +}); diff --git a/scanner/site-preview.mjs b/scanner/site-preview.mjs deleted file mode 100644 index 34c4520..0000000 --- a/scanner/site-preview.mjs +++ /dev/null @@ -1,130 +0,0 @@ -// A bounded visual approximation. No source HTML, stylesheet text, image URLs or form values leave the worker. -export async function captureSiteSnapshot(page) { - const snapshot = await page.evaluate(() => { - const tags = new Set(['header','nav','main','section','article','aside','footer','div','span','p','h1','h2','h3','h4','h5','h6','blockquote','figure','figcaption','dl','dt','dd','table','caption','thead','tbody','tfoot','tr','th','td','hr','a','form','input','button','label','strong','em','small','b','i','pre','code','time','br','ul','ol','li']); - const length = /^(?:auto|none|normal|-?(?:\d{1,5})(?:\.\d{1,4})?px)(?: (?:auto|-?\d{1,5}(?:\.\d{1,4})?px)){0,3}$/; - const color = /^(?:transparent|[a-z]{1,24}|#[a-fA-F0-9]{3,8}|rgba?\([0-9.,% ]{1,80}\))$/; - const rules = new Map([ - ...['width','height','max-width','max-height','min-width','min-height','margin','padding','gap','font-size','line-height','border-width','border-radius'].map(key => [key,length]), - ...['color','background-color','border-color'].map(key => [key,color]), - ['box-sizing', /^(?:content-box|border-box)$/], - ['white-space', /^(?:normal|nowrap|pre|pre-wrap|pre-line|break-spaces)$/], - ['overflow-wrap', /^(?:normal|break-word|anywhere)$/], ['border-collapse', /^(?:collapse|separate)$/], - ['grid-template-columns', /^(?:none|\d{1,5}(?:\.\d{1,4})?px(?: \d{1,5}(?:\.\d{1,4})?px){0,7})$/], - ['display', /^(?:none|block|inline|inline-block|flex|inline-flex|grid|list-item|table|inline-table|table-row-group|table-header-group|table-footer-group|table-row|table-cell|table-column-group|table-column|table-caption)$/], - ['font-weight', /^(?:normal|bold|[1-9]00)$/], ['font-style', /^(?:normal|italic|oblique)$/], - ['text-align', /^(?:start|end|left|right|center|justify)$/], ['text-decoration-line', /^(?:none|underline|line-through|overline)$/], - ['border-style', /^(?:none|solid|dashed|dotted|double)$/], ['flex-direction', /^(?:row|column|row-reverse|column-reverse)$/], - ['flex-wrap', /^(?:nowrap|wrap|wrap-reverse)$/], ['align-items', /^(?:normal|start|end|center|stretch|flex-start|flex-end|baseline)$/], - ['justify-content', /^(?:normal|start|end|center|flex-start|flex-end|space-between|space-around|space-evenly)$/], - ]); - let nodes = 0, visited = 0, textLeft = 8000, styleLeft = 20000, omittedSubtrees = 0, omittedText = 0, omittedStyles = 0; - function styles(declaration) { - const result = {}; - for (const [key, valid] of rules) { - const value = declaration.getPropertyValue(key).trim(); - if (!value) continue; - const cost = key.length + value.length + 2; - if (!valid.test(value) || cost > styleLeft) { omittedStyles++; continue; } - styleLeft -= cost; result[key] = value; - } - return result; - } - // Only simple terminal :hover selectors from readable, top-level rules are supported. - const hover = []; let checkedRules = 0; - for (let sheetIndex=0; sheetIndex 500) break; - if (!(rule instanceof CSSStyleRule) || rule.selectorText.length > 180) continue; - for (const selector of rule.selectorText.split(',')) { - const value = selector.trim(); - if (/^[a-zA-Z0-9_.*# >+~-]{1,170}:hover$/.test(value)) hover.push([value.slice(0,-6), rule.style]); - } - } - } catch { /* Cross-origin stylesheet rules are intentionally not read. */ } - if (checkedRules > 500) break; - } - function children(parent, depth, renderedOnly = false) { - const result = []; - for (let i = 0; i < parent.childNodes.length; i++) { - if (nodes >= 250 || visited >= 1000) { omittedSubtrees += parent.childNodes.length-i; break; } - const child = capture(parent.childNodes[i],depth,renderedOnly); - if (child) result.push(child); - } - return result; - } - // These tags remain block-level if the output style budget is exhausted. - // Omitted/hidden nodes and styled inline tags cannot justify removing a separator. - const blockTags = new Set(['header','nav','main','section','article','aside','footer','div','p','h1','h2','h3','h4','h5','h6','blockquote','figure','figcaption','dl','dt','dd','table','pre','hr','ul','ol','li','form']); - function blockInFlow(node) { - if (!(node instanceof HTMLElement) || !blockTags.has(node.localName) || node.hidden) return false; - const computed = getComputedStyle(node); - return ['block','flow-root','list-item','flex','grid','table'].includes(computed.display) - && !['hidden','collapse'].includes(computed.visibility) - && node.getClientRects().length > 0 - && !['absolute','fixed'].includes(computed.position) && computed.getPropertyValue('float') === 'none'; - } - function formattingSpace(node, text) { - // Discard only short, collapsible ASCII spacing beside normal-flow blocks. - // Inline word separators and preformatted/out-of-flow spacing must survive. - if (text.length > 4000 || !/^[ \t\r\n]+$/.test(text) || !node.parentElement - || !['normal','nowrap'].includes(getComputedStyle(node.parentElement).whiteSpace)) return false; - const before = node.previousSibling, after = node.nextSibling; - return !!(before || after) && (!before || blockInFlow(before)) && (!after || blockInFlow(after)); - } - function capture(node, depth, renderedOnly = false) { - visited++; - if (depth > 15) { omittedSubtrees++; return null; } - if (node.nodeType === Node.TEXT_NODE) { - const text = node.textContent ?? ''; - if (formattingSpace(node, text)) return null; - if (renderedOnly && text.trim()) { - const range = document.createRange(); range.selectNodeContents(node); - if (!range.getClientRects().length) { omittedText += text.length; return null; } - } - const value = text.slice(0,Math.min(textLeft,4000)); - omittedText += text.length-value.length; textLeft -= value.length; - if (!value) return null; - nodes++; return {text:value}; - } - if (!(node instanceof HTMLElement)) { omittedSubtrees++; return null; } - // Component hosts become ordinary inert wrappers. Walk light DOM only; - // layout checks exclude unassigned content hidden by open or closed roots. - const component = !tags.has(node.localName) && node.localName.includes('-'); - if (!tags.has(node.localName) && !(node instanceof HTMLImageElement) && !component) { omittedSubtrees++; return null; } - const computed=getComputedStyle(node); - if (node.hidden || computed.display === 'none' || ['hidden','collapse'].includes(computed.visibility)) { omittedSubtrees++; return null; } - if ((renderedOnly || component) && computed.display !== 'contents' && !node.getClientRects().length) { omittedSubtrees++; return null; } - if (node instanceof HTMLImageElement) return capture(document.createTextNode('[Image omitted]'),depth); - nodes++; - const attributes = {}; - for (const key of ['aria-label','aria-hidden']) { - const value = node.getAttribute(key); - if (value && value.length <= 120) attributes[key] = value; - } - if (['td','th'].includes(node.localName)) { - for (const key of ['colspan','rowspan']) { - const value=node.getAttribute(key); if (value && /^(?:[1-9][0-9]?|100)$/.test(value)) attributes[key]=value; - } - const scope=node.getAttribute('scope'); - if (node.localName === 'th' && ['row','col','rowgroup','colgroup'].includes(scope)) attributes.scope=scope; - } - const normal = styles(computed); const highlighted = {}; - if (node.localName === 'a' || node.localName === 'button') { - for (const [selector, declaration] of hover) { - try { if (node.matches(selector)) Object.assign(highlighted,styles(declaration)); } catch {} - } - } - // A div inside p implicitly closes the paragraph when serialized HTML is parsed. - // Use a span in that context; validated display styling still preserves its box. - const tag = component ? (computed.display.startsWith('inline') || computed.display === 'contents' || node.closest('p') ? 'span' : 'div') : node.localName; - return {tag,attributes,style:normal,hover:highlighted,children:children(node,depth+1,renderedOnly || component)}; - } - // Preserve canvas text contrast and spacing before spending the shared style budget on descendants. - const bodyStyle = styles(getComputedStyle(document.body)); - return {url:location.href,bodyStyle,nodes:children(document.body,0),omittedSubtrees,omittedText,omittedStyles}; - }); - return {...snapshot,capturedAt:new Date().toISOString()}; -} diff --git a/scripts/preview-browser.mjs b/scripts/preview-browser.mjs index 7685e57..1c08e1c 100644 --- a/scripts/preview-browser.mjs +++ b/scripts/preview-browser.mjs @@ -446,7 +446,6 @@ try { {...publicReport.coverage,failedRequests:-1},{blockedRequests:0,failedRequests:0,omittedRules:0,omittedNodes:2}]) { publicResponse={...publicReport,findings:[],coverage}; await publicButton.click(); - await page.waitForFunction(()=>document.querySelector('form').getAttribute('aria-busy')==='false'); await coverageNotice.waitFor(); const text=await coverageNotice.textContent(); assert.ok(text.includes('Zero violations does not establish accessibility conformance.')); @@ -951,10 +950,13 @@ try { await publicButton.click(); await heldCancellation; assert.equal(publicHeld, true); + const scanStatus = page.locator('form').getByRole('status'); + await scanStatus.filter({ hasText: 'Checking the page and preparing your report' }).waitFor(); + assert.equal(await scanStatus.evaluate(status => status.closest('[aria-busy="true"]')), null); assert.equal(await websiteEntry.getAttribute('readonly'), ''); assert.deepEqual(await landingPositions(), beforeMobileScan, 'A pending scan must also keep the mobile landing controls in place'); await page.getByRole('button', {name:'Cancel request',exact:true}).click(); - await page.getByRole('status').filter({hasText:'Request cancelled'}).waitFor(); + await scanStatus.filter({hasText:'Request cancelled'}).waitFor(); assert.equal(await sprout.count(), 0); assert.equal(await frog.evaluate(node => getComputedStyle(node).animationName), 'none'); assert.equal(await page.locator('.results').count(), 0); @@ -970,7 +972,7 @@ try { assert.equal(await scanRetry.evaluate(node => node === document.activeElement), true); publicResponseOverride = undefined; await scanRetry.click(); - await page.locator('.results').waitFor(); + await scanStatus.filter({hasText:'Report ready.'}).waitFor(); await assertDownload(publicResponse); // The draft URL and the already-completed report are independent. await page.getByRole('button', { name: /Visual preview/ }).click(); diff --git a/scripts/validate-chrome.mjs b/scripts/validate-chrome.mjs index 36c2b17..c4a5be7 100644 --- a/scripts/validate-chrome.mjs +++ b/scripts/validate-chrome.mjs @@ -102,7 +102,7 @@ try { if (index === 0) await input.press("Enter"); else await page.getByRole("button", { name: "Scan website", exact: true }).click(); await page.locator(".results").waitFor({ state: "detached" }); - await page.waitForFunction(() => document.querySelector("form")?.getAttribute("aria-busy") === "false", void 0, { timeout: 1e5 }); + await page.waitForFunction(() => document.querySelector('button[type="submit"]')?.disabled === false, void 0, { timeout: 1e5 }); assert.equal(requests, before + 1); assert.ok(actual); const report = actual.report; diff --git a/src/components/WebsiteForm.tsx b/src/components/WebsiteForm.tsx index db52b45..067ad62 100644 --- a/src/components/WebsiteForm.tsx +++ b/src/components/WebsiteForm.tsx @@ -39,7 +39,7 @@ export function WebsiteForm({ url, activity, reportReady, onViewReport, onCancel return
{ if (fieldError) urlInput.current?.focus(); onSubmit(event); - }} aria-busy={busy}> + }}>