diff --git a/packages/app/e2e/browser/file-editing.spec.ts b/packages/app/e2e/browser/file-editing.spec.ts index 0266d02691..cc57b5315c 100644 --- a/packages/app/e2e/browser/file-editing.spec.ts +++ b/packages/app/e2e/browser/file-editing.spec.ts @@ -826,6 +826,147 @@ test.describe("CodeMirror workspace file editing", () => { await expect(preview.host).toBeVisible(); }); + test("keeps HTML fragment links in the preview and scrolls to the target", async ({ + page, + withWorkspace, + }) => { + const workspace = await withWorkspace({ prefix: "html-fragments-" }); + await writeFile( + path.join(workspace.repoPath, "fragments.html"), + ` +Jump to section +
+

Target section

+`, + "utf8", + ); + await workspace.navigateTo(); + await openWorkspaceFile(page, "fragments.html"); + + const preview = htmlPreview(page); + await expect(preview.document.getByRole("link", { name: "Jump to section" })).toBeVisible(); + await page.screenshot({ path: test.info().outputPath("fragment-before-click.png") }); + await preview.document.getByRole("link", { name: "Jump to section" }).click(); + + await expect( + preview.document.getByRole("heading", { name: "Target section" }), + ).toBeInViewport(); + expect(await preview.document.locator("body").evaluate(() => location.href)).toBe( + "about:srcdoc#section", + ); + expect(await preview.document.locator("body").evaluate(() => window.scrollY)).toBeGreaterThan( + 0, + ); + await page.screenshot({ path: test.info().outputPath("fragment-after-click.png") }); + }); + + test("lets an HTML page prevent fragment navigation", async ({ page, withWorkspace }) => { + const workspace = await withWorkspace({ prefix: "html-fragment-handler-" }); + await writeFile( + path.join(workspace.repoPath, "handled.html"), + ` +Handled jump +Waiting for click +

Target section

+`, + "utf8", + ); + await workspace.navigateTo(); + await openWorkspaceFile(page, "handled.html"); + + const preview = htmlPreview(page); + await preview.document.getByRole("link", { name: "Handled jump" }).click(); + await expect(preview.document.locator("output")).toHaveText("Page handled click"); + await expect( + preview.document.getByRole("heading", { name: "Target section" }), + ).not.toBeInViewport(); + expect(await preview.document.locator("body").evaluate(() => location.href)).toBe( + "about:srcdoc", + ); + expect(await preview.document.locator("body").evaluate(() => window.scrollY)).toBe(0); + }); + + test("handles HTML fragment targets, empty and missing fragments, and modified clicks", async ({ + page, + withWorkspace, + }) => { + const workspace = await withWorkspace({ prefix: "html-fragment-targets-" }); + await writeFile( + path.join(workspace.repoPath, "targets.html"), + ` + +

Target section

+
Named target +

Encoded target

+
+`, + "utf8", + ); + await workspace.navigateTo(); + await openWorkspaceFile(page, "targets.html"); + + const preview = htmlPreview(page); + const body = preview.document.locator("body"); + const nested = preview.document.getByText("Nested jump", { exact: true }); + await nested.click(); + await expect(preview.document.locator("output").first()).toHaveText("#section"); + await expect( + preview.document.getByRole("heading", { name: "Target section" }), + ).toBeInViewport(); + await expect(preview.document.locator("#section")).toHaveCSS("color", "rgb(255, 0, 0)"); + + await body.evaluate(() => window.scrollTo(0, 0)); + await nested.click(); + await expect( + preview.document.getByRole("heading", { name: "Target section" }), + ).toBeInViewport(); + + await preview.document.getByRole("link", { name: "Named jump" }).click(); + await expect(preview.document.getByText("Named target", { exact: true })).toBeInViewport(); + await expect(preview.document.locator("output").first()).toHaveText("#legacy"); + + await preview.document.getByRole("link", { name: "Encoded jump" }).click(); + await expect( + preview.document.getByRole("heading", { name: "Encoded target" }), + ).toBeInViewport(); + await expect(preview.document.locator("output").first()).toHaveText("#encoded%20section"); + const scrollY = await body.evaluate(() => window.scrollY); + await preview.document.getByRole("link", { name: "Missing jump" }).click(); + expect(await body.evaluate(() => location.href)).toBe("about:srcdoc#encoded%20section"); + expect(await body.evaluate(() => window.scrollY)).toBe(scrollY); + + await preview.document.getByRole("link", { name: "Empty jump" }).click(); + await expect(preview.document.locator("output").first()).toHaveText("(empty)"); + expect(await body.evaluate(() => window.scrollY)).toBe(0); + + for (const modifier of ["Control", "Meta", "Shift", "Alt"] as const) { + await nested.click({ modifiers: [modifier] }); + await expect(preview.document.locator("#modified")).toHaveText("false"); + } + await nested.click({ button: "middle" }); + await expect(preview.document.locator("#modified")).toHaveText("false"); + expect(await body.evaluate(() => location.href)).toBe("about:srcdoc#"); + expect(await body.evaluate(() => window.scrollY)).toBe(0); + }); + test("runs inline scripts without allowing fetch egress", async ({ page, withWorkspace }) => { test.setTimeout(90_000); const workspace = await withWorkspace({ prefix: "file-editing-html-csp-" }); diff --git a/packages/app/src/file-panels/html-preview-csp.ts b/packages/app/src/file-panels/html-preview-csp.ts index 3a5e9c644a..f9bdb535ae 100644 --- a/packages/app/src/file-panels/html-preview-csp.ts +++ b/packages/app/src/file-panels/html-preview-csp.ts @@ -28,6 +28,29 @@ const POLICY = [ const META = ``; +// Native WebViews already handle fragments; only srcdoc inherits the app's base URL. +const FRAGMENT_NAVIGATION = ``; + // The policy must reach the parser before any markup the document declares, and it // only counts if it lands in `` — once the parser has moved on to ``, a // meta http-equiv CSP is ignored outright. @@ -45,7 +68,7 @@ const META = ``; // the parser ignores wherever it appears. Standards mode is guaranteed, the policy // is always the first element and therefore always in the head, and no part of the // document has to be parsed to place it. -const PROLOGUE = `${META}`; +const PROLOGUE = `${META}${FRAGMENT_NAVIGATION}`; // Left where it is, a BOM would sit mid-document and render as a zero-width space. const BOM = "\uFEFF";