diff --git a/packages/app/e2e/browser/file-editing.spec.ts b/packages/app/e2e/browser/file-editing.spec.ts index 0266d02691..e9fc0b15ba 100644 --- a/packages/app/e2e/browser/file-editing.spec.ts +++ b/packages/app/e2e/browser/file-editing.spec.ts @@ -63,6 +63,24 @@ function htmlPreview(page: Page) { }; } +async function followHtmlPreviewLink(page: Page, name: string): Promise { + await htmlPreview(page).document.getByRole("link", { name, exact: true }).click(); +} + +async function expectPageHandlesFragment( + page: Page, + handler: "Document" | "Window", +): Promise { + const preview = htmlPreview(page); + await followHtmlPreviewLink(page, `${handler} handled jump`); + await expect(preview.document.getByRole("status")).toHaveText(`${handler} handled click`); + await expect( + preview.document.getByRole("heading", { name: "Target section" }), + ).not.toBeInViewport(); + expect(await preview.document.locator("body").evaluate(() => location.hash)).toBe(""); + expect(await preview.document.locator("body").evaluate(() => window.scrollY)).toBe(0); +} + async function selectFileView(page: Page, view: "Preview" | "Source"): Promise { const option = page.getByTestId("file-panel-bar").getByRole("button", { name: view, @@ -826,6 +844,192 @@ 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.hash)).toBe("#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.hash)).toBe(""); + expect(await preview.document.locator("body").evaluate(() => window.scrollY)).toBe(0); + }); + + test("respects delegated HTML fragment handlers and normal URL parsing", async ({ + page, + withWorkspace, + }) => { + const workspace = await withWorkspace({ prefix: "html-fragment-defaults-" }); + await writeFile( + path.join(workspace.repoPath, "defaults.html"), + ` + +

Target section

+

Percent target

+
+`, + "utf8", + ); + await workspace.navigateTo(); + await openWorkspaceFile(page, "defaults.html"); + + const preview = htmlPreview(page); + await expectPageHandlesFragment(page, "Document"); + await expectPageHandlesFragment(page, "Window"); + + await followHtmlPreviewLink(page, "Whitespace jump"); + await expect( + preview.document.getByRole("heading", { name: "Target section" }), + ).toBeInViewport(); + await followHtmlPreviewLink(page, "Percent jump"); + await expect( + preview.document.getByRole("heading", { name: "Percent target" }), + ).toBeInViewport(); + }); + + 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.hash)).toBe("#missing"); + 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.hash)).toBe(""); + 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.web.tsx b/packages/app/src/file-panels/html-preview.web.tsx index 471362d8ca..eac275bfe8 100644 --- a/packages/app/src/file-panels/html-preview.web.tsx +++ b/packages/app/src/file-panels/html-preview.web.tsx @@ -25,12 +25,17 @@ const iframeStyle = { export function FileHtmlPreview({ html, testID }: { html: string; testID?: string }) { const { t } = useTranslation(); - const document = useMemo(() => withPreviewCsp(html), [html]); + // A document URL lets the browser resolve fragment links within the preview. + // srcDoc would inherit the app URL and navigate the frame back into Paseo. + const source = useMemo( + () => `data:text/html;charset=utf-8,${encodeURIComponent(withPreviewCsp(html))}`, + [html], + ); return (