From c8a8ee00143acc6662c46128daf9cf50afaf9eaf Mon Sep 17 00:00:00 2001 From: MortenFriisSiteImprove Date: Tue, 15 Sep 2026 23:16:58 +0200 Subject: [PATCH 1/2] Collect safe SDK bundle locations for result contract diagnosis --- docs/live-testing.md | 2 ++ tests/live/diagnostics.mjs | 18 +++++++++++++++++- tests/live/prepublish.spec.mjs | 7 +++++++ tests/live/result-view.mjs | 2 ++ tests/unit/live.test.cjs | 14 ++++++++++++++ 5 files changed, 42 insertions(+), 1 deletion(-) diff --git a/docs/live-testing.md b/docs/live-testing.md index 2d68191..e5c4910 100644 --- a/docs/live-testing.md +++ b/docs/live-testing.md @@ -58,3 +58,5 @@ After login, the test waits for authenticated report data for the mapped URL bef Report identity is checked against the exact `url` parameter on the SDK polling request. Like WordPress, the response must be authenticated, error-free, include a nonnegative numeric issue count and a nonempty `mainUrl`; that response field is not assumed to equal the crawled page URL. The test opens the Accessibility results section before asserting the documented image-alternative rule. Safe result diagnostics report only whether the category, target issue, alert, running/recheck controls or nested frames are present/visible. Setup and result diagnostics are merged through the same allowlist; no result text or URLs are retained. + +To investigate the result UI contract, sanitized diagnostics may include SDK JavaScript bundle locations from the official contentassistant origin and known static bundle paths. Query strings, fragments, application routes and other origins are excluded. These are software asset locations, not account/report URLs; no bundle contents or account text are uploaded. diff --git a/tests/live/diagnostics.mjs b/tests/live/diagnostics.mjs index a9fff32..1f377e6 100644 --- a/tests/live/diagnostics.mjs +++ b/tests/live/diagnostics.mjs @@ -1,7 +1,19 @@ const flags = ['entitlementReady', 'pollSeen', 'pollOk', 'pollAuthenticated', 'pollUrlMatches', 'pollMainUrlPresent', 'pollIssueCountValid', 'pollErrorNone', 'panelFramePresent', 'panelVisible', 'launcherVisible', 'blockedExternalRequest', 'accessibilityCategoryVisible', 'imageIssuePresent', 'imageIssueVisible', - 'resultAlertVisible', 'resultHasNestedFrame', 'resultRunning', 'resultRecheckVisible']; + 'resultAlertVisible', 'resultHasNestedFrame', 'resultRunning', 'resultRecheckVisible', + 'prepublishViewSelected', 'livePageViewSelected']; + +// Only static SDK bundles; never application routes or query strings. +export function publicSdkAsset(value) { + try { + const url = new URL(value); + if (!/^https:\/\/(?:contentassistant\.[a-z]+\.siteimprove\.com|cdn\.siteimprove\.net)$/.test(url.origin) + || url.username || url.password || url.hash) return null; + if (!/^\/(?:assets|js|scripts|dist|static|bundles|build|content|cms)\/(?:[a-z0-9_-]+\/){0,4}(?:app|main|index|runtime|vendor|vendors|cms|sdk|site|bundle|contentassistant)(?:[.-][a-z0-9_-]+)*\.js$/i.test(url.pathname)) return null; + return url.origin + url.pathname; + } catch { return null; } +} // Account text and arbitrary response fields must never enter public artifacts. export function safeDiagnostics(value) { @@ -9,5 +21,9 @@ export function safeDiagnostics(value) { for (const key of flags) if (typeof value?.[key] === 'boolean') result[key] = value[key]; for (const key of ['entitlementStatus', 'pollStatus']) if (Number.isInteger(value?.[key]) && value[key] >= 100 && value[key] <= 599) result[key] = value[key]; + if (Array.isArray(value?.sdkAssets)) { + const assets = value.sdkAssets.filter(item => typeof item === 'string').map(publicSdkAsset).filter(Boolean); + result.sdkAssets = [...new Set(assets)].sort().slice(0, 32); + } return result; } diff --git a/tests/live/prepublish.spec.mjs b/tests/live/prepublish.spec.mjs index 136aaaa..7c665c1 100644 --- a/tests/live/prepublish.spec.mjs +++ b/tests/live/prepublish.spec.mjs @@ -3,6 +3,7 @@ import { openLiveEditor } from './editor.mjs'; import { settings } from './settings.mjs'; import { observeDraft } from './prepublish.mjs'; import { openAccessibilityResults, resultViewState } from './result-view.mjs'; +import { publicSdkAsset } from './diagnostics.mjs'; import { imageAlternativeRule } from './accessibility-rule.mjs'; async function scan(page, evidence, marker) { @@ -32,6 +33,11 @@ test('prepublish detects WCAG 1.1.1 image alternative issue and clears it after const fixedMarker = process.env.CMS_DRAFT_FIXED_MARKER; expect(Boolean(marker && fixedMarker && marker !== fixedMarker)).toBe(true); const evidence = await observeDraft(context, config.cmsOrigin, [marker, fixedMarker]); + const sdkAssets = new Set(); + page.on('response', response => { + const asset = publicSdkAsset(response.url()); + if (asset) sdkAssets.add(asset); + }); await openLiveEditor(page, context); try { const preview = page.frameLocator('iframe[name="sitePreview"]'); @@ -76,6 +82,7 @@ test('prepublish detects WCAG 1.1.1 image alternative issue and clears it after expect(await (await page.request.get(publishedPath)).text()).not.toContain(fixedMarker); }); } finally { + test.info().annotations.push({ type: 'live-diagnostics', description: JSON.stringify({ sdkAssets: [...sdkAssets] }) }); try { const state = await resultViewState(page.frameLocator('iframe.si-iframe-element')); test.info().annotations.push({ type: 'live-diagnostics', description: JSON.stringify(state) }); diff --git a/tests/live/result-view.mjs b/tests/live/result-view.mjs index 1e588ac..8695e93 100644 --- a/tests/live/result-view.mjs +++ b/tests/live/result-view.mjs @@ -17,6 +17,8 @@ export async function openAccessibilityResults(overlay) { export async function resultViewState(overlay) { const issue = overlay.getByText(imageAlternativeRule.label, { exact: true }); return { + prepublishViewSelected: await overlay.getByRole('tab', { name: /Prepublish/i, selected: true }).count() > 0, + livePageViewSelected: await overlay.getByRole('tab', { name: /Live page/i, selected: true }).count() > 0, accessibilityCategoryVisible: await category(overlay).isVisible(), imageIssuePresent: await issue.count() > 0, imageIssueVisible: await issue.filter({ visible: true }).count() > 0, diff --git a/tests/unit/live.test.cjs b/tests/unit/live.test.cjs index ff3050e..f41a560 100644 --- a/tests/unit/live.test.cjs +++ b/tests/unit/live.test.cjs @@ -106,3 +106,17 @@ test('live diagnostics accept only known booleans and HTTP status codes', async { pollAuthenticated: true, pollStatus: 200 }); assert.deepEqual(safeDiagnostics({ pollStatus: 123456, entitlementStatus: 0, pollAuthenticated: 'private-value' }), {}); }); + +test('SDK asset diagnostics exclude account routes, origins, credentials and query strings', async () => { + const { publicSdkAsset, safeDiagnostics } = await import('../live/diagnostics.mjs'); + const asset = 'https://contentassistant.eu.siteimprove.com/assets/index-abcd1234.js'; + assert.equal(publicSdkAsset(asset + '?token=private-value'), asset); + for (const value of ['https://attacker.example/assets/index.js', + 'https://user:private-value@contentassistant.eu.siteimprove.com/assets/index.js', + 'https://contentassistant.eu.siteimprove.com/cms/private-value.js', + 'https://contentassistant.eu.siteimprove.com/assets/private-value.js', + asset + '#private-value', 'not-a-url']) assert.equal(publicSdkAsset(value), null); + const result = safeDiagnostics({ sdkAssets: [asset, asset + '?token=private-value', 'private-value'], url: 'private-value' }); + assert.deepEqual(result, { sdkAssets: [asset] }); + assert.equal(JSON.stringify(result).includes('private-value'), false); +}); From ee719c2cc7cf00c550e2d0782bc7b6112ad4b5ae Mon Sep 17 00:00:00 2001 From: MortenFriisSiteImprove Date: Tue, 15 Sep 2026 23:19:24 +0200 Subject: [PATCH 2/2] Record whether the test image reaches the SDK draft capture --- tests/live/diagnostics.mjs | 3 ++- tests/live/prepublish.mjs | 8 ++++++-- tests/live/prepublish.spec.mjs | 5 ++++- tests/unit/live.test.cjs | 3 ++- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/tests/live/diagnostics.mjs b/tests/live/diagnostics.mjs index 1f377e6..4805d29 100644 --- a/tests/live/diagnostics.mjs +++ b/tests/live/diagnostics.mjs @@ -2,7 +2,8 @@ const flags = ['entitlementReady', 'pollSeen', 'pollOk', 'pollAuthenticated', 'p 'pollMainUrlPresent', 'pollIssueCountValid', 'pollErrorNone', 'panelFramePresent', 'panelVisible', 'launcherVisible', 'blockedExternalRequest', 'accessibilityCategoryVisible', 'imageIssuePresent', 'imageIssueVisible', 'resultAlertVisible', 'resultHasNestedFrame', 'resultRunning', 'resultRecheckVisible', - 'prepublishViewSelected', 'livePageViewSelected']; + 'prepublishViewSelected', 'livePageViewSelected', 'firstDraftImagePresent', + 'fixedDraftImagePresent', 'fixedDraftAlternativePresent']; // Only static SDK bundles; never application routes or query strings. export function publicSdkAsset(value) { diff --git a/tests/live/prepublish.mjs b/tests/live/prepublish.mjs index fcf491e..5da2814 100644 --- a/tests/live/prepublish.mjs +++ b/tests/live/prepublish.mjs @@ -3,16 +3,20 @@ export const isSdkOrigin = origin => /^https:\/\/contentassistant\.[a-z]+\.sitei // Observe messages sent by the real integration without replacing the overlay or invoking its queue. export async function observeDraft(context, cmsOrigin, markers) { const evidence = Object.fromEntries(markers.map(marker => [marker, 0])); + evidence.captures = Object.fromEntries(markers.map(marker => [marker, {}])); await context.exposeBinding('__cmsDraftEvidence', ({ frame }, value) => { - if (isSdkOrigin(new URL(frame.url()).origin) && markers.includes(value?.marker)) + if (isSdkOrigin(new URL(frame.url()).origin) && markers.includes(value?.marker)) { evidence[value.marker]++; + evidence.captures[value.marker] = { imagePresent: value.imagePresent === true, fixedAlternativePresent: value.fixedAlternativePresent === true }; + } }); await context.addInitScript(({ cmsOrigin, markers }) => { window.addEventListener('message', event => { if (event.origin !== cmsOrigin || event.source !== window.parent || event.data?.si !== 'contentcheck-flat-dom') return; const dom = JSON.stringify(event.data.data?.dom ?? null); for (const marker of markers) - if (dom.includes(marker)) window.__cmsDraftEvidence({ marker }); + if (dom.includes(marker)) window.__cmsDraftEvidence({ marker, imagePresent: dom.includes('live-test-image'), + fixedAlternativePresent: dom.includes('Blue square for the prepublish test') }); }); }, { cmsOrigin, markers }); return evidence; diff --git a/tests/live/prepublish.spec.mjs b/tests/live/prepublish.spec.mjs index 7c665c1..ad860d8 100644 --- a/tests/live/prepublish.spec.mjs +++ b/tests/live/prepublish.spec.mjs @@ -82,7 +82,10 @@ test('prepublish detects WCAG 1.1.1 image alternative issue and clears it after expect(await (await page.request.get(publishedPath)).text()).not.toContain(fixedMarker); }); } finally { - test.info().annotations.push({ type: 'live-diagnostics', description: JSON.stringify({ sdkAssets: [...sdkAssets] }) }); + test.info().annotations.push({ type: 'live-diagnostics', description: JSON.stringify({ sdkAssets: [...sdkAssets], + firstDraftImagePresent: evidence.captures[marker].imagePresent, + fixedDraftImagePresent: evidence.captures[fixedMarker].imagePresent, + fixedDraftAlternativePresent: evidence.captures[fixedMarker].fixedAlternativePresent }) }); try { const state = await resultViewState(page.frameLocator('iframe.si-iframe-element')); test.info().annotations.push({ type: 'live-diagnostics', description: JSON.stringify(state) }); diff --git a/tests/unit/live.test.cjs b/tests/unit/live.test.cjs index f41a560..d436d9b 100644 --- a/tests/unit/live.test.cjs +++ b/tests/unit/live.test.cjs @@ -95,9 +95,10 @@ test('draft evidence ignores messages outside the real SDK origin and tracks eac receive({ frame: { url: () => 'https://unrelated.example.test/' } }, { marker: 'draft-one' }); receive({ frame: { url: () => 'https://contentassistant.eu.siteimprove.com/' } }, { marker: 'unrelated' }); assert.equal(evidence['draft-one'], 0); - receive({ frame: { url: () => 'https://contentassistant.eu.siteimprove.com/' } }, { marker: 'draft-two' }); + receive({ frame: { url: () => 'https://contentassistant.eu.siteimprove.com/' } }, { marker: 'draft-two', imagePresent: true, fixedAlternativePresent: true }); assert.equal(evidence['draft-one'], 0); assert.equal(evidence['draft-two'], 1); + assert.deepEqual(evidence.captures['draft-two'], { imagePresent: true, fixedAlternativePresent: true }); }); test('live diagnostics accept only known booleans and HTTP status codes', async () => {