diff --git a/apps/web/src/review-frame/base-href.ts b/apps/web/src/review-frame/base-href.ts index add7f256..583e1a72 100644 --- a/apps/web/src/review-frame/base-href.ts +++ b/apps/web/src/review-frame/base-href.ts @@ -1,3 +1,5 @@ +import { installFragmentLinks } from "./fragment-links.ts"; + /** * Multi-file artifacts reference their sub-resources relatively. The viewer * renders the entry document from a `srcdoc` sandbox, whose base URL is the @@ -47,6 +49,9 @@ function withResolvedDocumentResources(html: string, baseHref: string): string { } } } + const fragmentLinks = parsed.createElement("script"); + fragmentLinks.textContent = `(${installFragmentLinks.toString()})();`; + parsed.body.append(fragmentLinks); const doctype = doctypeTag.exec(html)?.[0] ?? ""; return `${doctype}${parsed.documentElement.outerHTML}`; } diff --git a/apps/web/src/review-frame/fragment-links.ts b/apps/web/src/review-frame/fragment-links.ts new file mode 100644 index 00000000..7fabc198 --- /dev/null +++ b/apps/web/src/review-frame/fragment-links.ts @@ -0,0 +1,49 @@ +/** + * Run inside the artifact's srcdoc sandbox. Its injected content-domain base + * is needed for resources, but fragment-only links belong to this document. + * Listen after element/document handlers so authored and annotation gestures + * that cancel a click keep ownership of it. + * + * This function is serialized into an inline script and must be self-contained. + */ +export function installFragmentLinks(): void { + window.addEventListener("click", (event: MouseEvent): void => { + if ( + event.defaultPrevented || event.button !== 0 || event.altKey + || event.ctrlKey || event.metaKey || event.shiftKey + ) return; + const element = event.target; + if (!(element instanceof Element)) return; + const link = element.closest("a[href]"); + if (!(link instanceof HTMLAnchorElement) || link.hasAttribute("download")) return; + const target = link.getAttribute("target") + ?? document.querySelector("base[target]")?.getAttribute("target") + ?? ""; + if (target !== "" && target.toLowerCase() !== "_self") return; + const href = link.getAttribute("href")?.trim(); + if (href === undefined || !href.startsWith("#")) return; + + // Missing and malformed targets must not navigate to the base URL either. + event.preventDefault(); + let fragment = href.slice(1); + try { + fragment = decodeURIComponent(fragment); + } catch { + // An authored literal ID can contain an invalid percent escape. + } + if (fragment === "") { + window.scrollTo(0, 0); + return; + } + const destination = document.getElementById(fragment) + ?? Array.from(document.getElementsByName(fragment)).find( + (candidate) => candidate instanceof HTMLAnchorElement, + ); + if (destination !== undefined && destination !== null) { + destination.scrollIntoView(); + if (destination instanceof HTMLElement) destination.focus({preventScroll: true}); + } else if (fragment.toLowerCase() === "top") { + window.scrollTo(0, 0); + } + }); +} diff --git a/project/evidence/html-fragment-navigation-browser.json b/project/evidence/html-fragment-navigation-browser.json new file mode 100644 index 00000000..f11693a7 --- /dev/null +++ b/project/evidence/html-fragment-navigation-browser.json @@ -0,0 +1 @@ +{"numFailedTestSuites":0,"numFailedTests":0,"numPassedTestSuites":2,"numPassedTests":3,"numPendingTests":0,"numPendingTestSuites":0,"numTodoTests":0,"numTotalTestSuites":2,"numTotalTests":3,"startTime":1791301039847,"success":true,"testResults":[{"assertionResults":[{"ancestorTitles":["Review fragment links"],"duration":16090,"failureMessages":[],"fullName":"Review fragment links CMT-022-B CMT-022-F: fragment links scroll within the sandbox and tolerate invalid targets without navigation","status":"passed","title":"CMT-022-B CMT-022-F: fragment links scroll within the sandbox and tolerate invalid targets without navigation"},{"ancestorTitles":["Review fragment links"],"duration":10605,"failureMessages":[],"fullName":"Review fragment links CMT-022-F CMT-014-B: fragment links remain selectable for comments in annotation mode","status":"passed","title":"CMT-022-F CMT-014-B: fragment links remain selectable for comments in annotation mode"}],"endTime":1791301085865.15,"message":"","name":"/root/Codes/artifact-server-anchor-fix/tests/browser/review-fragment-anchors.spec.ts","startTime":1791301039847,"status":"passed"},{"assertionResults":[{"ancestorTitles":["Review sandbox isolation"],"duration":11914,"failureMessages":[],"fullName":"Review sandbox isolation CMT-014-B CMT-014-F: hostile artifact HTML stays inside an opaque-origin sandbox","status":"passed","title":"CMT-014-B CMT-014-F: hostile artifact HTML stays inside an opaque-origin sandbox"}],"endTime":1791301085865.15,"message":"","name":"/root/Codes/artifact-server-anchor-fix/tests/browser/review-sandbox.spec.ts","startTime":1791301039847,"status":"passed"}]} diff --git a/project/evidence/html-fragment-navigation-regression.json b/project/evidence/html-fragment-navigation-regression.json new file mode 100644 index 00000000..a5b71aea --- /dev/null +++ b/project/evidence/html-fragment-navigation-regression.json @@ -0,0 +1,18 @@ +{ + "recorded_at": "2026-10-06T15:36:14.096Z", + "baseline_commit": "5af28ac3e52228e7f1781270ead712148fb583cc", + "node_version": "24.15.0", + "browser": "chromium", + "command": "pnpm exec playwright test tests/browser/review-fragment-anchors.spec.ts --grep CMT-022-B", + "expected_outcome": "The pre-fix production web build fails to scroll on the first nested fragment link.", + "observed_outcome": "failed", + "tests": [ + { + "title": "CMT-022-B CMT-022-F: fragment links scroll within the sandbox and tolerate invalid targets without navigation", + "status": "unexpected", + "errors": [ + "Error: Timeout 10000ms exceeded while waiting on the predicate\n\n 169 | await expect.poll(() => preview.locator(selector).evaluate((element) =>\n 170 | Math.abs(element.getBoundingClientRect().top)\n> 171 | )).toBeLessThan(2);\n | ^\n 172 | }\n 173 |\n at expectTargetAtTop (/root/Codes/artifact-server-anchor-fix/tests/browser/review-fragment-anchors.spec.ts:171:6)\n at /root/Codes/artifact-server-anchor-fix/tests/browser/review-fragment-anchors.spec.ts:94:13" + ] + } + ] +} diff --git a/project/evidence/html-fragment-navigation-verification.json b/project/evidence/html-fragment-navigation-verification.json new file mode 100644 index 00000000..8e607ddb --- /dev/null +++ b/project/evidence/html-fragment-navigation-verification.json @@ -0,0 +1,71 @@ +{ + "recorded_at": "2026-10-06T15:49:35.235193+00:00", + "tested_code_commit": "f00764363aa5c0d45fe6705c352db047c5fbe6da", + "node_version": "24.15.0", + "focused_browser": { + "status": "passed", + "tests": 3, + "evidence": "project/evidence/html-fragment-navigation-browser.json" + }, + "regression_control": { + "status": "expected_failure_observed", + "baseline_commit": "5af28ac3e52228e7f1781270ead712148fb583cc", + "evidence": "project/evidence/html-fragment-navigation-regression.json" + }, + "passed_checks": [ + "release metadata", + "Oxlint with type checking and unchanged anti-slop rules", + "all pnpm typecheck targets", + "pnpm build", + "pnpm verify:site", + "conformance ledger validation", + "conformance test ID mapping" + ], + "canonical_gate": { + "command": "pnpm verify:iteration", + "status": "interrupted_after_test_failures", + "exit_code": 130, + "reason": "Stopped the broader run after five failures in unchanged CLI tests; no full-gate pass is claimed.", + "observed_failures": [ + { + "file": "tests/cli/auth-profile-remote-publish.test.ts", + "tests": [ + "CLI-002-B PUB-012-B: stores a verified profile outside the project and publishes through it after restart", + "CLI-002-F PUB-012-F: rejects an invalid credential without saving it and never sends one profile to another origin", + "completes browser PKCE, refresh, status, and revocation against real HTTP boundaries" + ] + }, + { + "file": "tests/cli/lifecycle-cli.test.ts", + "tests": [ + "foundation: initialization, configuration, and support diagnostics are stable and secret-free", + "foundation: compact serving withdraws readiness and integrity scans real committed bytes" + ] + } + ], + "remaining_stages_not_run": [ + "remainder of pnpm check", + "full browser suite", + "AWS and GCP Pulumi verification", + "object storage verification", + "external storage runtime verification", + "coverage", + "local package verification", + "performance baselines", + "compact and external storage compose verification", + "Helm verification", + "OIDC verification" + ] + }, + "isolated_cli_diagnostic": { + "command": "pnpm exec vitest run tests/cli/auth-profile-remote-publish.test.ts --testNamePattern 'stores a verified profile' --reporter=verbose", + "status": "failed", + "error": "Test timed out in 60000ms.", + "file": "tests/cli/auth-profile-remote-publish.test.ts:142" + }, + "proof_gaps": [ + "Firefox and WebKit browser evidence", + "team deployment evidence", + "successful complete pnpm verify:iteration run" + ] +} diff --git a/project/spec/artifact-comments-spec.md b/project/spec/artifact-comments-spec.md index 7187e30f..fef147a8 100644 --- a/project/spec/artifact-comments-spec.md +++ b/project/spec/artifact-comments-spec.md @@ -311,6 +311,18 @@ version and commenting surface. The first shell slice adds no product-specific keyboard shortcuts; normal browser focus and button semantics remain intact. +### HTML fragment navigation + +In Interact mode, ordinary same-frame fragment-only links in HTML previews +scroll to an element ID or legacy named anchor within the rendered document. +Encoded fragments and keyboard activation work; an empty fragment returns to +the top. Missing or malformed targets cannot navigate to the injected resource +base URL. Authored element and document click cancellation and Annotate mode +retain ownership of their gestures. Modified clicks, downloads, and other browsing targets retain +their authored behavior. This preview behavior changes scrolling and focus, +without emulating URL hash changes or browser history. It preserves stored +artifact bytes, relative resource resolution, and the opaque-origin sandbox. + ### Review route resolution The project, artifact, and version named in a Review URL are authoritative. The diff --git a/project/spec/conformance.yml b/project/spec/conformance.yml index 7e009b1f..0319f689 100644 --- a/project/spec/conformance.yml +++ b/project/spec/conformance.yml @@ -2556,6 +2556,25 @@ requirements: run: project/evidence/browser.json recorded_at: "2026-09-03T16:09:11.243Z" + - id: CMT-022 + kind: behavior + behavior: Ordinary same-frame fragment-only links in HTML Review previews scroll within the rendered document without navigating to the injected resource base or weakening sandbox isolation. + owner: artifact-comments + source: {file: artifact-comments-spec.md, anchor: html-fragment-navigation} + acceptance: + behavior: {id: CMT-022-B, description: "In Interact mode, activate nested, encoded, legacy named, and keyboard fragment links and observe the target within the same sandboxed document; empty and top fragments return to the document start with no navigation request."} + failure: {id: CMT-022-F, description: "Missing and malformed fragment targets cause no navigation or script error, authored element and document cancellation retains control, and Annotate mode can save a comment on a link without scrolling or weakening the opaque-origin sandbox."} + deployments: *all + status: behavior_verified + proof_gap: Local Chromium production-build evidence proves fragment scrolling, cancellation, annotation ownership, and retained sandbox isolation. Equivalent Firefox, WebKit, and team-deployment evidence remains unrecorded; URL hash and history emulation are outside this contract. + depends_on: [CMT-014, CMT-018] + evidence: + - deployment: local + tests: [CMT-022-B, CMT-022-F] + result: pass + run: project/evidence/html-fragment-navigation-browser.json + recorded_at: "2026-10-06T15:37:19.847Z" + - id: ADM-001 kind: behavior behavior: Artifact Server ships one canonical React application for Review and settings, plus the isolated Review frame; every former application route redirects to the equivalent canonical destination without serving or loading a legacy application bundle. diff --git a/tests/browser/review-fragment-anchors.spec.ts b/tests/browser/review-fragment-anchors.spec.ts new file mode 100644 index 00000000..7961fd00 --- /dev/null +++ b/tests/browser/review-fragment-anchors.spec.ts @@ -0,0 +1,172 @@ +import {expect, test, type FrameLocator} from "@playwright/test"; + +import {publishNew} from "../support/publishing.js"; +import { + localLogin, + startBrowserFixture, + stopBrowserFixture, +} from "./browser-fixture.js"; +import {listThreadsOverApi} from "./comment-api.js"; + +const anchorArtifact = ` + + + Fragment links + + + + +

Fragment links remain in the preview

+

Nested target

+

Encoded target

+
Legacy target
+

Keyboard target

+ + +`; + +test.describe("Review fragment links", () => { + test("CMT-022-B CMT-022-F: fragment links scroll within the sandbox and tolerate invalid targets without navigation", async ({browser}) => { + const fixture = await startBrowserFixture(browser); + try { + const published = await publishNew(fixture.server, fixture.installation, { + accessSetting: "account_required", + content: anchorArtifact, + idempotencyKey: "review-fragment-links", + mediaType: "text/html; charset=utf-8", + name: "Fragment links", + path: "index.html", + }); + await localLogin(fixture); + await fixture.page.goto( + `${fixture.server.baseUrl}/review?project=prj_default&artifact=${published.body.artifact.id}&version=${published.body.version.id}`, + ); + const review = fixture.page.frameLocator(".as-artifact-frame"); + const preview = review.frameLocator("iframe"); + await expect(preview.locator("h1")).toHaveText("Fragment links remain in the preview"); + await expect(review.locator("iframe")).toHaveAttribute("sandbox", "allow-scripts"); + const contentBase = await preview.locator("base").getAttribute("href"); + expect(contentBase).toMatch(/^http:\/\/review-[a-f0-9]+\.localhost:\d+\/$/u); + await preview.locator("#nested-link").focus(); + await fixture.page.keyboard.press("Escape"); + await expect(fixture.page.getByRole("button", {name: /^Interact mode:/u})) + .toHaveAttribute("aria-pressed", "false"); + const reviewUrl = fixture.page.url(); + const previewUrl = await preview.locator("body").evaluate(() => window.location.href); + expect(previewUrl).toBe("about:srcdoc"); + + const navigations: string[] = []; + const documentRequests: string[] = []; + const browserErrors: string[] = []; + fixture.page.on("framenavigated", (frame) => navigations.push(frame.url())); + fixture.page.on("request", (request) => { + if (request.isNavigationRequest()) documentRequests.push(request.url()); + }); + fixture.page.on("pageerror", (error) => browserErrors.push(error.message)); + + await preview.locator("#nested-link span").click(); + await expectTargetAtTop(preview, "#section"); + await preview.locator("#encoded-link").click(); + await expectTargetAtTop(preview, '[id="café [section]"]'); + await preview.locator("#named-link").click(); + await expectTargetAtTop(preview, 'a[name="legacy"]'); + await preview.locator("#keyboard-link").focus(); + await fixture.page.keyboard.press("Enter"); + await expectTargetAtTop(preview, "#keyboard"); + + const beforeMissing = await preview.locator("body").evaluate(() => window.scrollY); + await preview.locator("#missing-link").click(); + expect(await preview.locator("body").evaluate(() => window.scrollY)).toBe(beforeMissing); + await preview.locator("#malformed-link").click(); + expect(await preview.locator("body").evaluate(() => window.scrollY)).toBe(beforeMissing); + await preview.locator("#empty-link").click(); + await expect.poll(() => preview.locator("body").evaluate(() => window.scrollY)).toBe(0); + await preview.locator("#nested-link").click(); + await expectTargetAtTop(preview, "#section"); + await preview.locator("#top-link").click(); + await expect.poll(() => preview.locator("body").evaluate(() => window.scrollY)).toBe(0); + await preview.locator("#owned-link").click(); + await expect(preview.locator("#owned-link")).toHaveText("Authored action handled"); + expect(await preview.locator("body").evaluate(() => window.scrollY)).toBe(0); + await preview.locator("#dynamic-link").click(); + await expectTargetAtTop(preview, "#section"); + + await expect(preview.locator("h1")).toHaveText("Fragment links remain in the preview"); + expect(navigations).toEqual([]); + expect(documentRequests).toEqual([]); + expect(browserErrors).toEqual([]); + expect(fixture.page.url()).toBe(reviewUrl); + expect(await preview.locator("body").evaluate(() => window.location.href)).toBe(previewUrl); + } finally { + await stopBrowserFixture(fixture); + } + }); + + test("CMT-022-F CMT-014-B: fragment links remain selectable for comments in annotation mode", async ({browser}) => { + const fixture = await startBrowserFixture(browser); + try { + const published = await publishNew(fixture.server, fixture.installation, { + accessSetting: "account_required", + content: anchorArtifact, + idempotencyKey: "review-fragment-annotation", + name: "Fragment annotation", + }); + await localLogin(fixture); + await fixture.page.goto( + `${fixture.server.baseUrl}/review?project=prj_default&artifact=${published.body.artifact.id}&version=${published.body.version.id}`, + ); + const review = fixture.page.frameLocator(".as-artifact-frame"); + const preview = review.frameLocator("iframe"); + await expect(review.locator("iframe")).toHaveAttribute("sandbox", "allow-scripts"); + await expect(fixture.page.getByRole("button", {name: /^Annotate mode:/u})) + .toHaveAttribute("aria-pressed", "true"); + await preview.locator("#nested-link span").click(); + const composer = review.getByPlaceholder("Add a comment..."); + await expect(composer).toBeVisible(); + expect(await preview.locator("body").evaluate(() => window.scrollY)).toBe(0); + await composer.fill("Clarify the section link."); + await review.getByRole("button", {name: "Save"}).click(); + await expect(fixture.page.getByRole("article").filter({hasText: "Clarify the section link."})) + .toBeVisible(); + const threads = await listThreadsOverApi(fixture, published.body.artifact.id); + expect(threads).toHaveLength(1); + expect(threads[0]?.body).toBe("Clarify the section link."); + expect(threads[0]?.versionId).toBe(published.body.version.id); + expect(threads[0]?.path).toBe("index.html"); + } finally { + await stopBrowserFixture(fixture); + } + }); +}); + +async function expectTargetAtTop(preview: FrameLocator, selector: string): Promise { + await expect.poll(() => preview.locator(selector).evaluate((element) => + Math.abs(element.getBoundingClientRect().top) + )).toBeLessThan(2); +}