mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-01 16:00:34 +00:00
1093 lines
50 KiB
TypeScript
1093 lines
50 KiB
TypeScript
import type { Page } from "@playwright/test"
|
|
import { expect, sourceURL, story } from "../../stories/playwright/story"
|
|
|
|
const png = Buffer.from(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a4ioAAAAASUVORK5CYII=",
|
|
"base64",
|
|
)
|
|
|
|
const fixture = sourceURL(new URL("./markdown.fixture.tsx", import.meta.url))
|
|
|
|
story.beforeEach(async ({ mount }) => {
|
|
const root = await mount("components-markdown--complete-response")
|
|
await expect(root.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
|
|
})
|
|
|
|
story("renders small completed Markdown immediately without skipping sanitization", async ({ page }) => {
|
|
const result = await page.evaluate(async (fixture) => {
|
|
const { mountMarkdown } = await import(fixture)
|
|
await mountMarkdown({
|
|
text: '**Small response**\n\n<img src="missing" onerror="alert(1)"><script>alert(2)</script>',
|
|
})
|
|
const markdown = document.querySelector('[data-testid="markdown-fixture"] [data-component="markdown"]')!
|
|
|
|
return {
|
|
ready: markdown.hasAttribute("data-markdown-ready"),
|
|
bold: markdown.querySelector("strong")?.textContent,
|
|
unsafe: markdown.querySelectorAll("script, [onerror]").length,
|
|
}
|
|
}, fixture)
|
|
|
|
expect(result).toEqual({ ready: true, bold: "Small response", unsafe: 0 })
|
|
const harness = page.getByTestId("markdown-fixture")
|
|
await harness.getByLabel("Markdown text").fill("```ts\nconst value = 42\n```")
|
|
await expect(harness.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
|
|
await expect(harness.locator("pre code")).toHaveText("const value = 42")
|
|
await expect(harness.locator("pre.highlights")).toBeVisible()
|
|
})
|
|
|
|
story("keeps Markdown text selectable inside a container that disables selection", async ({ page }) => {
|
|
await page.evaluate(async (fixture) => {
|
|
document.body.style.userSelect = "none"
|
|
const { mountMarkdown } = await import(fixture)
|
|
await mountMarkdown({ text: "Selectable answer text" })
|
|
}, fixture)
|
|
|
|
await page.getByTestId("markdown-fixture").getByText("Selectable answer text").click({ clickCount: 3 })
|
|
expect(await page.evaluate(() => getSelection()?.toString().trim())).toBe("Selectable answer text")
|
|
})
|
|
|
|
story("sanitizes raw HTML while preserving supported Markdown markup", async ({ page }) => {
|
|
const result = await page.evaluate(async (fixture) => {
|
|
const { sanitizeMarkdown } = await import(fixture)
|
|
|
|
return [
|
|
"<p><strong>Safe</strong> <em>formatting</em> <code>const x = 1</code></p>",
|
|
'<script>alert(1)</script><style>body { display: none }</style><img src="safe.png" onerror="alert(2)"><a href="javascript:alert(3)">unsafe</a>',
|
|
'<a href="https://example.com" target="_blank" rel="nofollow">external</a><a href="/local">local</a>',
|
|
'<form id="location" name="document"><input name="cookie"></form>',
|
|
"<math><mrow><mi>x</mi><mo>+</mo><mn>1</mn></mrow></math>",
|
|
'<svg viewBox="0 0 10 10"><path d="M0 0L10 10" onload="alert(4)"></path><script>alert(5)</script></svg>',
|
|
].map(sanitizeMarkdown)
|
|
}, fixture)
|
|
|
|
expect(result).toEqual([
|
|
"<p><strong>Safe</strong> <em>formatting</em> <code>const x = 1</code></p>",
|
|
'<img data-local-image="safe.png"><a>unsafe</a>',
|
|
'<a href="https://example.com" target="_blank" rel="nofollow noopener noreferrer">external</a><a data-local-link="/local" role="link" tabindex="0">local</a>',
|
|
'<form name="user-content-document" id="user-content-location"><input name="user-content-cookie"></form>',
|
|
"<math><mrow><mi>x</mi><mo>+</mo><mn>1</mn></mrow></math>",
|
|
'<svg viewBox="0 0 10 10"><path d="M0 0L10 10"></path></svg>',
|
|
])
|
|
})
|
|
|
|
story("keeps Markdown sanitization and link protections after Mermaid renders", async ({ page }) => {
|
|
const result = await page.evaluate(async (fixture) => {
|
|
const { renderMermaidSvg, sanitizeMarkdown } = await import(fixture)
|
|
|
|
const html =
|
|
'<a href="https://example.com" target="_blank" rel="nofollow">external</a><img src="safe.png" onerror="alert(1)">'
|
|
|
|
const before = sanitizeMarkdown(html)
|
|
const renders = []
|
|
|
|
for (const source of [
|
|
"flowchart LR\n A[Start] --> B[End]",
|
|
"sequenceDiagram\n Alice->>Bob: Hello",
|
|
'%%{init: {"flowchart": {"htmlLabels": true}}}%%\nflowchart LR\n A["<a href=\'https://example.com\' target=\'_blank\'>External</a>"] --> B[End]\n click A "https://example.com" _blank',
|
|
]) {
|
|
const svg = await renderMermaidSvg(source)
|
|
const document = new DOMParser().parseFromString(svg, "image/svg+xml")
|
|
renders.push({
|
|
root: document.documentElement.localName,
|
|
text: document.documentElement.textContent,
|
|
unsafe: document.querySelectorAll('script, [onerror], [onload], [href^="javascript:"]').length,
|
|
links: Array.from(document.querySelectorAll("a")).map((link) => ({
|
|
href: link.getAttribute("href"),
|
|
target: link.getAttribute("target"),
|
|
rel: link.getAttribute("rel"),
|
|
})),
|
|
markdown: sanitizeMarkdown(html),
|
|
})
|
|
}
|
|
|
|
return {
|
|
before,
|
|
renders,
|
|
invalid: await renderMermaidSvg("not a diagram"),
|
|
afterInvalid: sanitizeMarkdown(html),
|
|
}
|
|
}, fixture)
|
|
|
|
expect(result.before).toBe(
|
|
'<a href="https://example.com" target="_blank" rel="nofollow noopener noreferrer">external</a><img data-local-image="safe.png">',
|
|
)
|
|
expect(result.renders).toEqual([
|
|
{ root: "svg", text: expect.stringContaining("Start"), unsafe: 0, links: [], markdown: result.before },
|
|
{ root: "svg", text: expect.stringContaining("Hello"), unsafe: 0, links: [], markdown: result.before },
|
|
{
|
|
root: "svg",
|
|
text: expect.stringContaining("External"),
|
|
unsafe: 0,
|
|
links: [{ href: "https://example.com/", target: "_blank", rel: "noopener noreferrer" }],
|
|
markdown: result.before,
|
|
},
|
|
])
|
|
expect(result.invalid).toBeUndefined()
|
|
expect(result.afterInvalid).toBe(result.before)
|
|
})
|
|
|
|
for (const cached of [false, true]) {
|
|
story(`decorates known inline paths before insertion with cached Markdown ${cached}`, async ({ page }) => {
|
|
const initial = await page.evaluate(
|
|
async ({ fixture, cached }) => {
|
|
const { mountMarkdown } = await import(fixture)
|
|
|
|
return mountMarkdown({
|
|
text: "`src/file.ts` and `src/file.ts` and `missing.ts` and [`src/file.ts`](https://example.com)",
|
|
cached,
|
|
synchronousFiles: true,
|
|
})
|
|
},
|
|
{ fixture, cached },
|
|
)
|
|
|
|
expect(initial).toEqual([
|
|
{ text: "src/file.ts", kind: "path" },
|
|
{ text: "src/file.ts", kind: "path" },
|
|
{ text: "missing.ts", kind: null },
|
|
{ text: "src/file.ts", kind: null },
|
|
])
|
|
})
|
|
}
|
|
|
|
story("does not report completed Markdown ready until all inline path checks settle", async ({ page }) => {
|
|
const checks = await page.evaluateHandle(async (fixture) => {
|
|
const { mountMarkdown } = await import(fixture)
|
|
const existing = Promise.withResolvers<boolean>()
|
|
const missing = Promise.withResolvers<boolean>()
|
|
const requested: string[] = []
|
|
await mountMarkdown({
|
|
text: "`src/file.ts` and `src/file.ts` and `missing/file.ts`",
|
|
localFileExists: (path: string) => {
|
|
requested.push(path)
|
|
|
|
return path === "src/file.ts" ? existing.promise : missing.promise
|
|
},
|
|
})
|
|
|
|
return { requested, existing: () => existing.resolve(true), missing: () => missing.reject(new Error("Offline")) }
|
|
}, fixture)
|
|
|
|
const markdown = page.getByTestId("markdown-fixture").locator('[data-component="markdown"]')
|
|
await expect(markdown.locator("code")).toHaveCount(3)
|
|
expect(await checks.evaluate((state) => state.requested)).toEqual(["src/file.ts", "missing/file.ts"])
|
|
await expect(markdown).not.toHaveAttribute("data-markdown-ready")
|
|
await checks.evaluate((state) => state.existing())
|
|
await expect(markdown.locator('code[data-inline-code-kind="path"]')).toHaveCount(2)
|
|
await expect(markdown).not.toHaveAttribute("data-markdown-ready")
|
|
await checks.evaluate((state) => state.missing())
|
|
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
|
|
await expect(markdown.getByText("missing/file.ts", { exact: true })).not.toHaveAttribute("data-inline-code-kind")
|
|
await checks.dispose()
|
|
})
|
|
|
|
story("ignores stale inline path results when completed Markdown is replaced", async ({ page }) => {
|
|
const checks = await page.evaluateHandle(async (fixture) => {
|
|
const { mountMarkdown } = await import(fixture)
|
|
const first = Promise.withResolvers<boolean>()
|
|
const next = Promise.withResolvers<boolean>()
|
|
const requested: string[] = []
|
|
await mountMarkdown({
|
|
text: "Before `src/file.ts`",
|
|
cached: true,
|
|
localFileExists: (path: string) => {
|
|
requested.push(path)
|
|
|
|
return requested.length === 1 ? first.promise : next.promise
|
|
},
|
|
})
|
|
|
|
return { requested, first: () => first.resolve(true), next: () => next.resolve(false) }
|
|
}, fixture)
|
|
|
|
const harness = page.getByTestId("markdown-fixture")
|
|
const markdown = harness.locator('[data-component="markdown"]')
|
|
await expect(markdown.locator("p")).toHaveText("Before src/file.ts")
|
|
await expect(markdown).not.toHaveAttribute("data-markdown-ready")
|
|
await harness.getByLabel("Markdown text").fill("Replacement `src/file.ts`")
|
|
await expect(markdown.locator("p")).toHaveText("Replacement src/file.ts")
|
|
expect(await checks.evaluate((state) => state.requested)).toEqual(["src/file.ts", "src/file.ts"])
|
|
await checks.evaluate((state) => state.first())
|
|
await expect(markdown).not.toHaveAttribute("data-markdown-ready")
|
|
await checks.evaluate((state) => state.next())
|
|
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
|
|
await expect(markdown.locator('code[data-inline-code-kind="path"]')).toHaveCount(0)
|
|
await checks.dispose()
|
|
})
|
|
|
|
story("keeps streaming Markdown ready while a completed prefix waits for a path check", async ({ page }) => {
|
|
const checks = await page.evaluateHandle(async (fixture) => {
|
|
const { mountMarkdown } = await import(fixture)
|
|
const existing = Promise.withResolvers<boolean>()
|
|
const requested: string[] = []
|
|
await mountMarkdown({
|
|
text: "Completed `src/file.ts`.\n\nStreaming **answer**",
|
|
streaming: true,
|
|
localFileExists: (path: string) => {
|
|
requested.push(path)
|
|
|
|
return existing.promise
|
|
},
|
|
})
|
|
|
|
return { requested, resolve: () => existing.resolve(true) }
|
|
}, fixture)
|
|
|
|
const harness = page.getByTestId("markdown-fixture")
|
|
const markdown = harness.locator('[data-component="markdown"]')
|
|
await expect(markdown.locator("strong")).toHaveText("answer")
|
|
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
|
|
expect(await checks.evaluate((state) => state.requested)).toEqual(["src/file.ts"])
|
|
await harness.getByLabel("Streaming").uncheck()
|
|
await expect(markdown).not.toHaveAttribute("data-markdown-ready")
|
|
await checks.evaluate((state) => state.resolve())
|
|
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
|
|
await expect(markdown.locator('code[data-inline-code-kind="path"]')).toHaveText("src/file.ts")
|
|
await checks.dispose()
|
|
})
|
|
|
|
story("mounts cached completed Markdown with sanitized HTML and decorations", async ({ page }) => {
|
|
await page.evaluate(
|
|
async ({ fixture, text }) => {
|
|
const { mountMarkdown } = await import(fixture)
|
|
await mountMarkdown({ text, cached: true })
|
|
},
|
|
{
|
|
fixture,
|
|
text: [
|
|
"# Completed response",
|
|
"`src/file.ts` and `missing.ts` and `https://example.com/docs` and [link](https://example.com)",
|
|
'<img src="missing" onerror="alert(1)"><script>alert(2)</script><a href="javascript:alert(3)">unsafe</a>',
|
|
"```ts\nconst answer = 42\n```",
|
|
].join("\n\n"),
|
|
},
|
|
)
|
|
const harness = page.getByTestId("markdown-fixture")
|
|
const markdown = harness.locator('[data-component="markdown"]')
|
|
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
|
|
await expect(markdown.getByRole("heading")).toHaveText("Completed response")
|
|
await expect(markdown.locator("script, [onerror], [href^='javascript:']")).toHaveCount(0)
|
|
await expect(markdown.locator('code[data-inline-code-kind="path"]')).toHaveText("src/file.ts")
|
|
await expect(markdown.locator('code[data-inline-code-kind="path"]')).toHaveCSS(
|
|
"color",
|
|
await resolvedColor(page, "--v2-text-text-code-path"),
|
|
)
|
|
await expect(markdown.locator('code[data-inline-code-kind="path"]')).toHaveCSS("background-color", "rgba(0, 0, 0, 0)")
|
|
await expect(markdown.getByRole("link", { name: "https://example.com/docs" })).toHaveAttribute("target", "_blank")
|
|
await expect(markdown.getByRole("link", { name: "https://example.com/docs" })).toHaveAttribute(
|
|
"rel",
|
|
"noopener noreferrer",
|
|
)
|
|
await expect(markdown.locator("pre code")).toContainText("const answer = 42")
|
|
await expect(markdown.getByRole("button", { name: "Copy", exact: true })).toBeVisible()
|
|
await expect(markdown.locator("[data-markdown-word]")).toHaveCount(0)
|
|
|
|
await harness.getByLabel("Markdown text").fill("## Replacement\n\n`new/file.ts`")
|
|
await expect(markdown.getByRole("heading")).toHaveText("Replacement")
|
|
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
|
|
await expect(markdown.locator("pre, h1, a")).toHaveCount(0)
|
|
await expect(markdown.locator('code[data-inline-code-kind="path"]')).toHaveText("new/file.ts")
|
|
await harness.getByRole("button", { name: "Toggle Markdown" }).click()
|
|
await expect(markdown).toHaveCount(0)
|
|
await harness.getByRole("button", { name: "Toggle Markdown" }).click()
|
|
await expect(markdown.getByRole("heading")).toHaveText("Replacement")
|
|
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
|
|
await harness.getByLabel("Markdown text").fill("")
|
|
await expect(markdown).toBeEmpty()
|
|
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
|
|
})
|
|
|
|
story("shows a stable GitHub mark without changing link text or other sites", async ({ page }) => {
|
|
await page.evaluate(async (fixture) => {
|
|
const { mountMarkdown } = await import(fixture)
|
|
await mountMarkdown({
|
|
text: [
|
|
"[#540](https://github.com/anomalyco/opencode/pull/540)",
|
|
"[GitHub](https://github.com)",
|
|
"[other site](https://example.com/docs)",
|
|
"[lookalike](https://github.com.evil.example/pull/540)",
|
|
].join(" · "),
|
|
cached: true,
|
|
})
|
|
}, fixture)
|
|
|
|
const markdown = page.getByTestId("markdown-fixture").locator('[data-component="markdown"]')
|
|
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
|
|
const github = markdown.getByRole("link", { name: "#540" })
|
|
await expect(github).toHaveAttribute("href", "https://github.com/anomalyco/opencode/pull/540")
|
|
await expect(github).toHaveText("#540")
|
|
const mark = github.locator(".markdown-link-favicon[data-github]")
|
|
expect(await mark.evaluate((icon) => getComputedStyle(icon, "::before").width)).toBe("14px")
|
|
expect(await mark.evaluate((icon) => getComputedStyle(icon, "::before").maskImage)).toContain("data:image/svg+xml")
|
|
await expect(mark.locator("img")).toHaveCount(0)
|
|
await expect(markdown.getByRole("link", { name: "GitHub" }).locator(".markdown-link-favicon[data-github]")).toHaveCount(
|
|
1,
|
|
)
|
|
|
|
for (const name of ["other site", "lookalike"]) {
|
|
await expect(markdown.getByRole("link", { name }).locator(".markdown-link-favicon[data-github]")).toHaveCount(0)
|
|
}
|
|
})
|
|
|
|
story("keeps favicon space stable across loading and failure without fetching private hosts", async ({ page }) => {
|
|
const requested: string[] = []
|
|
let release: () => void = () => undefined
|
|
const loading = new Promise<void>((resolve) => (release = resolve))
|
|
await page.route("https://www.google.com/s2/favicons?**", async (route) => {
|
|
requested.push(route.request().url())
|
|
|
|
if (route.request().url().includes("developer.mozilla.org")) await loading
|
|
|
|
if (route.request().url().includes("broken.example.org")) return route.abort()
|
|
await route.fulfill({ status: 200, contentType: "image/png", body: png })
|
|
})
|
|
|
|
await page.evaluate(async (fixture) => {
|
|
const { mountMarkdown } = await import(fixture)
|
|
await mountMarkdown({
|
|
text: [
|
|
"[docs](https://developer.mozilla.org/docs)",
|
|
"[missing](https://broken.example.org/docs)",
|
|
"[private](http://localhost:8080/docs)",
|
|
"[GitHub](https://github.com/anomalyco/opencode)",
|
|
].join(" · "),
|
|
cached: true,
|
|
})
|
|
}, fixture)
|
|
|
|
const markdown = page.getByTestId("markdown-fixture").locator('[data-component="markdown"]')
|
|
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
|
|
const docs = markdown.getByRole("link", { name: "docs" })
|
|
const image = docs.locator(".markdown-link-favicon img")
|
|
await expect.poll(() => requested.some((url) => url.includes("developer.mozilla.org"))).toBe(true)
|
|
await expect(image).toHaveCSS("opacity", "0")
|
|
expect((await docs.locator(".markdown-link-favicon").boundingBox())?.width).toBe(14)
|
|
// A late web font swap also changes the link width; measure after fonts settle.
|
|
await page.evaluate(() => document.fonts.ready.then(() => undefined))
|
|
const width = await docs.evaluate((link) => link.getBoundingClientRect().width)
|
|
release()
|
|
await expect(image).toHaveAttribute("data-loaded", "")
|
|
await expect(image).toHaveCSS("opacity", "1")
|
|
await expect(image).not.toHaveAttribute("role", "button")
|
|
expect(await image.evaluate((favicon) => favicon.onclick)).toBeNull()
|
|
expect(await docs.evaluate((link) => link.getBoundingClientRect().width)).toBe(width)
|
|
expect(
|
|
await image.evaluate((favicon) => {
|
|
const image = favicon.getBoundingClientRect()
|
|
const box = favicon.parentElement!.getBoundingClientRect()
|
|
|
|
return image.top + image.height / 2 - (box.top + box.height / 2)
|
|
}),
|
|
).toBe(0)
|
|
|
|
await expect(
|
|
markdown.getByRole("link", { name: "missing" }).locator(".markdown-link-favicon img"),
|
|
).not.toHaveAttribute("data-loaded", "")
|
|
await expect(markdown.getByRole("link", { name: "private" }).locator(".markdown-link-favicon img")).toHaveCount(0)
|
|
await expect(markdown.getByRole("link", { name: "GitHub" }).locator(".markdown-link-favicon img")).toHaveCount(0)
|
|
expect(requested).toHaveLength(2)
|
|
expect(requested.every((url) => !url.includes("localhost") && !url.includes("github.com"))).toBe(true)
|
|
})
|
|
|
|
story("keeps link icons on the same line as the start of their label", async ({ page }) => {
|
|
await page.route("https://www.google.com/s2/favicons?**", (route) =>
|
|
route.fulfill({ status: 200, contentType: "image/png", body: png }),
|
|
)
|
|
await page.evaluate(async (fixture) => {
|
|
const { mountMarkdown } = await import(fixture)
|
|
await mountMarkdown({
|
|
text: [
|
|
"Read the [MDN reference](https://developer.mozilla.org/docs) and",
|
|
"[#540](https://github.com/anomalyco/opencode/pull/540) then",
|
|
"https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/white-space/long-path",
|
|
].join(" "),
|
|
cached: true,
|
|
})
|
|
}, fixture)
|
|
const markdown = page.getByTestId("markdown-fixture").locator('[data-component="markdown"]')
|
|
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
|
|
await expect(markdown.locator(".markdown-link-favicon img")).toHaveCount(2)
|
|
await page.evaluate(() => document.fonts.ready.then(() => undefined))
|
|
|
|
const result = await markdown.evaluate((root: HTMLElement) => {
|
|
const links = Array.from(root.querySelectorAll<HTMLAnchorElement>("a.external-link"))
|
|
|
|
// An icon is orphaned when the link's first line fragment, which holds the icon, sits above the label's first glyph.
|
|
const orphaned = (link: HTMLAnchorElement) => {
|
|
const text = document.createTreeWalker(link, NodeFilter.SHOW_TEXT).nextNode()!
|
|
const range = document.createRange()
|
|
range.setStart(text, 0)
|
|
range.setEnd(text, 1)
|
|
|
|
return range.getBoundingClientRect().top - link.getClientRects()[0].top > 1
|
|
}
|
|
|
|
const widths = Array.from({ length: 400 }, (_, index) => 120 + index).filter((width) => {
|
|
root.style.width = `${width}px`
|
|
|
|
return links.some(orphaned)
|
|
})
|
|
|
|
root.style.width = "160px"
|
|
|
|
return { widths, long: links[2].getClientRects().length }
|
|
})
|
|
|
|
expect(result.widths).toEqual([])
|
|
expect(result.long).toBeGreaterThan(1)
|
|
})
|
|
|
|
story("scrolls fragment links to their heading inside the document's scroller", async ({ page }) => {
|
|
await page.evaluate(async (fixture) => {
|
|
const { mountMarkdown } = await import(fixture)
|
|
const filler = Array.from({ length: 40 }, (_, index) => `Paragraph ${index}`).join("\n\n")
|
|
await mountMarkdown({
|
|
text: `See [Host services](#host-services) and [the second usage](#usage-1).\n\n## Usage\n\n${filler}\n\n## Host services\n\n${filler}\n\n## Usage\n\n${filler}`,
|
|
})
|
|
const host = document.querySelector<HTMLElement>('[data-testid="markdown-fixture"]')!
|
|
host.style.height = "240px"
|
|
host.style.overflowY = "auto"
|
|
}, fixture)
|
|
const host = page.getByTestId("markdown-fixture")
|
|
const markdown = host.locator('[data-component="markdown"]')
|
|
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
|
|
const url = page.url()
|
|
|
|
const offset = (index: number) =>
|
|
host.evaluate(
|
|
(host, index) =>
|
|
host.querySelectorAll("h2")[index]!.getBoundingClientRect().top - host.getBoundingClientRect().top,
|
|
index,
|
|
)
|
|
|
|
await expect(markdown.getByRole("link", { name: "Host services" })).not.toHaveAttribute("target", "_blank")
|
|
await markdown.getByRole("link", { name: "Host services" }).click()
|
|
await expect.poll(() => offset(1)).toBeCloseTo(0, 0)
|
|
|
|
await host.evaluate((host) => host.scrollTo({ top: 0 }))
|
|
await markdown.getByRole("link", { name: "the second usage" }).click()
|
|
await expect.poll(() => offset(2)).toBeCloseTo(0, 0)
|
|
expect(page.url()).toBe(url)
|
|
})
|
|
|
|
async function resolvedColor(page: Page, token: string) {
|
|
return page.evaluate((token) => {
|
|
const probe = document.createElement("span")
|
|
probe.style.color = `var(${token})`
|
|
document.body.append(probe)
|
|
const color = getComputedStyle(probe).color
|
|
probe.remove()
|
|
|
|
return color
|
|
}, token)
|
|
}
|
|
|
|
story("keeps inline code backgrounds 18px tall", async ({ page }) => {
|
|
await page.evaluate(async (fixture) => {
|
|
const { mountMarkdown } = await import(fixture)
|
|
await mountMarkdown({ text: "`value` and `src/file.ts`" })
|
|
}, fixture)
|
|
const code = page.getByTestId("markdown-fixture").locator(":not(pre) > code")
|
|
await expect(code).toHaveCount(2)
|
|
await expect(code.nth(1)).toHaveAttribute("data-inline-code-kind", "path")
|
|
expect(
|
|
await code.evaluateAll((elements) => elements.map((element) => element.getBoundingClientRect().height)),
|
|
).toEqual([18, 18])
|
|
})
|
|
|
|
story("shares in-flight Markdown rendering without overwriting a reclaimed cache entry", async ({ page }) => {
|
|
const result = await page.evaluate(async (fixture) => {
|
|
const { getCachedMarkdown, renderCachedMarkdown } = await import(fixture)
|
|
const raw = "**Shared result**"
|
|
const first = renderCachedMarkdown({ raw, src: raw }, "in-flight")
|
|
const second = renderCachedMarkdown({ raw, src: raw }, "in-flight")
|
|
const [left, right] = await Promise.all([first, second])
|
|
const abandoned = renderCachedMarkdown({ raw: "abandoned", src: "abandoned" }, "in-flight")
|
|
await renderCachedMarkdown({ raw, src: raw }, "in-flight")
|
|
await abandoned
|
|
|
|
return {
|
|
shared: left === right,
|
|
html: left.html,
|
|
cached: getCachedMarkdown("in-flight") === left,
|
|
}
|
|
}, fixture)
|
|
|
|
expect(result).toMatchObject({ shared: true, cached: true })
|
|
expect(result.html).toContain("<strong>Shared result</strong>")
|
|
})
|
|
|
|
story("settles an abandoned parse and permits immediate cache-key reuse", async ({ page }) => {
|
|
const result = await page.evaluate(async (fixture) => {
|
|
const { getCachedMarkdown, renderCachedMarkdown, MarkdownWorkerDisposedError } = await import(fixture)
|
|
const controller = new AbortController()
|
|
const raw = "```typescript\nconst abandoned = true\n```"
|
|
|
|
const pending = renderCachedMarkdown({ raw, src: raw }, "released", controller.signal).catch(
|
|
(error: Error) => error instanceof MarkdownWorkerDisposedError,
|
|
)
|
|
|
|
controller.abort()
|
|
const rejected = await pending
|
|
const empty = getCachedMarkdown("released") === undefined
|
|
const replacement = "```typescript\nconst replacement = true\n```"
|
|
const rendered = await renderCachedMarkdown({ raw: replacement, src: replacement }, "released")
|
|
|
|
return { rejected, empty, cached: getCachedMarkdown("released") === rendered, html: rendered.html }
|
|
}, fixture)
|
|
|
|
expect(result).toMatchObject({ rejected: true, empty: true, cached: true })
|
|
expect(result.html).toContain("replacement")
|
|
expect(result.html).not.toContain("abandoned")
|
|
})
|
|
|
|
for (const owned of [true, false]) {
|
|
story(
|
|
`preserves a shared parse for ${owned ? "another mounted consumer" : "an explicit preload"}`,
|
|
async ({ page }) => {
|
|
const result = await page.evaluate(
|
|
async ({ fixture, owned }) => {
|
|
const { getCachedMarkdown, renderCachedMarkdown, MarkdownWorkerDisposedError } = await import(fixture)
|
|
const first = new AbortController()
|
|
const second = new AbortController()
|
|
const raw = "```typescript\nconst shared = true\n```"
|
|
|
|
const pending = renderCachedMarkdown({ raw, src: raw }, "shared-lifetime", first.signal).catch(
|
|
(error: Error) => error instanceof MarkdownWorkerDisposedError,
|
|
)
|
|
|
|
let complete = false
|
|
|
|
const survivor = renderCachedMarkdown(
|
|
{ raw, src: raw },
|
|
"shared-lifetime",
|
|
owned ? second.signal : undefined,
|
|
).then((value: { html: string }) => {
|
|
complete = true
|
|
|
|
return value
|
|
})
|
|
|
|
first.abort()
|
|
const rejected = await pending
|
|
const independent = !complete
|
|
const rendered = await survivor
|
|
second.abort()
|
|
|
|
return {
|
|
rejected,
|
|
independent,
|
|
cached: getCachedMarkdown("shared-lifetime") === rendered,
|
|
html: rendered.html,
|
|
}
|
|
},
|
|
{ fixture, owned },
|
|
)
|
|
|
|
expect(result).toMatchObject({ rejected: true, independent: true, cached: true })
|
|
expect(result.html).toContain("shared")
|
|
},
|
|
)
|
|
}
|
|
|
|
story("does not admit an already disposed Markdown consumer", async ({ page }) => {
|
|
const result = await page.evaluate(async (fixture) => {
|
|
const { getCachedMarkdown, renderCachedMarkdown, MarkdownWorkerDisposedError } = await import(fixture)
|
|
const controller = new AbortController()
|
|
controller.abort()
|
|
const raw = "```typescript\nconst ignored = true\n```"
|
|
|
|
const rejected = await renderCachedMarkdown({ raw, src: raw }, "already-disposed", controller.signal).then(
|
|
() => false,
|
|
(error: Error) => error instanceof MarkdownWorkerDisposedError,
|
|
)
|
|
|
|
return { rejected, empty: getCachedMarkdown("already-disposed") === undefined }
|
|
}, fixture)
|
|
|
|
expect(result).toEqual({ rejected: true, empty: true })
|
|
})
|
|
|
|
story("releases a timeline preload without cancelling a mounted shared consumer", async ({ page }) => {
|
|
const result = await page.evaluate(async (fixture) => {
|
|
const { getCachedMarkdown, preloadMarkdown, renderCachedMarkdown, MarkdownWorkerDisposedError } = await import(
|
|
fixture
|
|
)
|
|
|
|
const controller = new AbortController()
|
|
const raw = "```typescript\nconst preloaded = true\n```"
|
|
|
|
const preload = preloadMarkdown(raw, "timeline-preload", controller.signal).then(
|
|
() => false,
|
|
(error: Error) => error instanceof MarkdownWorkerDisposedError,
|
|
)
|
|
|
|
const mounted = renderCachedMarkdown({ raw, src: raw }, "timeline-preload:0:full")
|
|
controller.abort()
|
|
const rejected = await preload
|
|
const rendered = await mounted
|
|
|
|
return { rejected, cached: getCachedMarkdown("timeline-preload:0:full") === rendered, html: rendered.html }
|
|
}, fixture)
|
|
|
|
expect(result).toMatchObject({ rejected: true, cached: true })
|
|
expect(result.html).toContain("preloaded")
|
|
})
|
|
|
|
story("keeps a reopened cached answer recent under cache pressure", async ({ page }) => {
|
|
await page.evaluate(async (fixture) => {
|
|
const { mountMarkdown, touchCachedMarkdown } = await import(fixture)
|
|
await mountMarkdown({ text: "**Cached answer**", cached: true })
|
|
Array.from({ length: 199 }, (_, index) =>
|
|
touchCachedMarkdown(`filler-${index}`, { raw: "filler", hash: "filler", html: "<p>filler</p>" }),
|
|
)
|
|
}, fixture)
|
|
const harness = page.getByTestId("markdown-fixture")
|
|
const markdown = harness.locator('[data-component="markdown"]')
|
|
await expect(markdown.locator("strong")).toHaveText("Cached answer")
|
|
await harness.getByRole("button", { name: "Toggle Markdown" }).click()
|
|
await expect(markdown).toHaveCount(0)
|
|
await harness.getByRole("button", { name: "Toggle Markdown" }).click()
|
|
await expect(markdown.locator("strong")).toHaveText("Cached answer")
|
|
|
|
const cached = await page.evaluate(async (fixture) => {
|
|
const { getCachedMarkdown, touchCachedMarkdown } = await import(fixture)
|
|
touchCachedMarkdown("newest", { raw: "newest", hash: "newest", html: "<p>newest</p>" })
|
|
|
|
return getCachedMarkdown("markdown-test:0:full")?.raw
|
|
}, fixture)
|
|
|
|
expect(cached).toBe("**Cached answer**")
|
|
})
|
|
|
|
story("renders cached Mermaid blocks and falls back to code for invalid diagrams", async ({ page }) => {
|
|
await page.evaluate(async (fixture) => {
|
|
const { mountMarkdown } = await import(fixture)
|
|
await mountMarkdown({ text: "```mermaid\nflowchart LR\n A[Start] --> B[End]\n```", cached: true })
|
|
}, fixture)
|
|
const harness = page.getByTestId("markdown-fixture")
|
|
const markdown = harness.locator('[data-component="markdown"]')
|
|
await expect(markdown.locator('[data-component="markdown-mermaid"] > svg.pele')).toBeVisible()
|
|
await harness.getByRole("button", { name: "Toggle Markdown" }).click()
|
|
await expect(markdown).toHaveCount(0)
|
|
await harness.getByRole("button", { name: "Toggle Markdown" }).click()
|
|
await expect(markdown.locator('[data-component="markdown-mermaid"] > svg')).toBeVisible()
|
|
await harness.getByLabel("Markdown text").fill("```mermaid\nflowchart LR; A[broken\n```")
|
|
await expect(markdown.locator('[data-component="markdown-mermaid"]')).toHaveCount(0)
|
|
await expect(markdown.locator("pre code")).toBeVisible()
|
|
await expect(markdown.locator("pre code")).toHaveText("flowchart LR; A[broken")
|
|
await expect(markdown.getByRole("button", { name: "Copy", exact: true })).toBeVisible()
|
|
})
|
|
|
|
story("renders the trailing chunk while the stream is paused", async ({ page }) => {
|
|
await page.evaluate(async (fixture) => {
|
|
const { mountMarkdown } = await import(fixture)
|
|
await mountMarkdown({ text: "Checking **the project** before running the comm", streaming: true, cached: true })
|
|
}, fixture)
|
|
const harness = page.getByTestId("markdown-fixture")
|
|
const markdown = harness.locator('[data-component="markdown"]')
|
|
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
|
|
await expect(markdown.locator("p")).toHaveText("Checking the project before running the comm")
|
|
await harness.getByLabel("Markdown text").fill("Checking **the project** before running the command.")
|
|
await expect(markdown.locator("p")).toHaveText("Checking the project before running the command.")
|
|
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
|
|
await expect(markdown.locator("strong")).toHaveText("the project")
|
|
await expect(harness.getByLabel("Streaming")).toBeChecked()
|
|
})
|
|
|
|
story("keeps live elements and selection when a stream completes and later changes", async ({ page }) => {
|
|
await page.evaluate(async (fixture) => {
|
|
const { mountMarkdown } = await import(fixture)
|
|
await mountMarkdown({ text: "Hello **world**", streaming: true, synchronousFiles: true })
|
|
}, fixture)
|
|
const harness = page.getByTestId("markdown-fixture")
|
|
const markdown = harness.locator('[data-component="markdown"]')
|
|
const paragraph = markdown.locator("p")
|
|
await expect(markdown.locator("[data-markdown-word]")).toHaveCount(2)
|
|
await paragraph.evaluate((element) => element.setAttribute("data-retained", "true"))
|
|
await harness.getByLabel("Markdown text").fill("Hello **world** again")
|
|
await expect(markdown.locator("[data-markdown-word]")).toHaveCount(3)
|
|
await expect(paragraph).toHaveAttribute("data-retained", "true")
|
|
await expect(markdown.locator("[data-markdown-enter]")).not.toHaveCount(0)
|
|
await paragraph.evaluate((element) => {
|
|
const range = document.createRange()
|
|
range.selectNodeContents(element.querySelector("strong")!)
|
|
window.getSelection()!.removeAllRanges()
|
|
window.getSelection()!.addRange(range)
|
|
// Change the control without moving browser focus or selection.
|
|
const input = document.querySelector<HTMLInputElement>('[data-testid="markdown-fixture"] input')!
|
|
input.checked = false
|
|
input.dispatchEvent(new Event("change", { bubbles: true }))
|
|
})
|
|
await expect(harness.getByLabel("Streaming")).not.toBeChecked()
|
|
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
|
|
await expect(paragraph).toHaveAttribute("data-retained", "true")
|
|
expect(await page.evaluate(() => window.getSelection()?.toString())).toBe("world")
|
|
await harness.getByLabel("Markdown text").fill("Changed **content** in `new/file.ts` and `new/file.ts`")
|
|
await expect(paragraph).toHaveText("Changed content in new/file.ts and new/file.ts")
|
|
await expect(markdown.locator('code[data-inline-code-kind="path"]')).toHaveText(["new/file.ts", "new/file.ts"])
|
|
await expect(paragraph).toHaveAttribute("data-retained", "true")
|
|
await expect(markdown.locator("[data-markdown-word]")).toHaveCount(0)
|
|
await harness.getByRole("button", { name: "Toggle Markdown" }).click()
|
|
await expect(markdown).toHaveCount(0)
|
|
})
|
|
|
|
story("replaces completed DOM before live rendering and retains streamed code copy actions", async ({ page }) => {
|
|
await page.evaluate(async (fixture) => {
|
|
const { mountMarkdown } = await import(fixture)
|
|
await mountMarkdown({ text: "Initial **content**" })
|
|
}, fixture)
|
|
const harness = page.getByTestId("markdown-fixture")
|
|
const markdown = harness.locator('[data-component="markdown"]')
|
|
await expect(markdown.locator("p")).toHaveText("Initial content")
|
|
await harness.getByLabel("Streaming").check()
|
|
await harness.getByLabel("Markdown text").fill("Initial **content** continues")
|
|
await expect(markdown.locator("p")).toHaveCount(1)
|
|
await expect(markdown.locator("[data-markdown-word]")).toHaveCount(3)
|
|
await harness.getByLabel("Markdown text").fill("```sh\necho hello\n")
|
|
await expect(markdown.locator("pre code")).toHaveText("echo hello\n")
|
|
await expect(markdown.locator("p")).toHaveCount(0)
|
|
await expect(markdown.locator('[data-component="markdown-code"]')).toHaveAttribute("data-code-kind", "shell")
|
|
await page.context().grantPermissions(["clipboard-read", "clipboard-write"])
|
|
await markdown.getByRole("button", { name: "Copy" }).click()
|
|
await expect(markdown.getByRole("button", { name: "Copied" })).toBeVisible()
|
|
expect((await page.evaluate(() => navigator.clipboard.readText())).replaceAll("\r\n", "\n")).toBe("echo hello\n")
|
|
await harness.getByLabel("Streaming").uncheck()
|
|
await expect(markdown.locator("[data-markdown-complete]")).toHaveAttribute("data-markdown-complete", "true")
|
|
await expect(markdown.locator("pre code")).toHaveText("echo hello\n")
|
|
await harness.getByLabel("Markdown text").fill("Replacement prose")
|
|
await expect(markdown.locator("p")).toHaveText("Replacement prose")
|
|
await expect(markdown.locator('pre, [data-slot="markdown-copy-button"]')).toHaveCount(0)
|
|
await harness.getByRole("button", { name: "Toggle Markdown" }).click()
|
|
await expect(markdown).toHaveCount(0)
|
|
})
|
|
|
|
story("relabels streamed code copy actions when the language changes", async ({ page }) => {
|
|
await page.evaluate(async (fixture) => {
|
|
const { mountMarkdown } = await import(fixture)
|
|
await mountMarkdown({ text: "```sh\necho hello\n", streaming: true })
|
|
}, fixture)
|
|
const harness = page.getByTestId("markdown-fixture")
|
|
const markdown = harness.locator('[data-component="markdown"]')
|
|
await expect(markdown.getByRole("button", { name: "Copy" })).toHaveCount(1)
|
|
await harness.getByLabel("Markdown text").fill("```sh\necho hello\n```\n\nStreaming continues")
|
|
await expect(markdown.locator("p")).toHaveText("Streaming continues")
|
|
await harness.getByLabel("German").check()
|
|
await expect(markdown.getByRole("button", { name: "Kopieren" })).toHaveCount(1)
|
|
await expect(markdown.getByRole("button", { name: "Copy" })).toHaveCount(0)
|
|
})
|
|
|
|
story("highlights streamed code across comment and string boundaries", async ({ page }) => {
|
|
await page.evaluate(async (fixture) => {
|
|
const { mountMarkdown } = await import(fixture)
|
|
await mountMarkdown({ text: "```ts\n", streaming: true })
|
|
}, fixture)
|
|
const harness = page.getByTestId("markdown-fixture")
|
|
const markdown = harness.locator('[data-component="markdown"]')
|
|
const code = markdown.locator("pre code")
|
|
const chunks = ["/* multi", "line\ncomment *", "/\nconst message = `hel", "lo ${1 + 2}`\n", "const pattern = /a+b/g"]
|
|
|
|
for (let index = 1; index <= chunks.length; index++) {
|
|
const text = chunks.slice(0, index).join("")
|
|
await harness.getByLabel("Markdown text").fill(`\`\`\`ts\n${text}`)
|
|
await expect(code).toHaveText(text)
|
|
}
|
|
|
|
await expect(code.locator('span[style*="color"]')).not.toHaveCount(0)
|
|
await harness.getByLabel("Markdown text").fill(`\`\`\`ts\n${chunks.join("")}\n\`\`\``)
|
|
await harness.getByLabel("Streaming").uncheck()
|
|
await expect(markdown.locator("[data-markdown-complete]")).toHaveAttribute("data-markdown-complete", "true")
|
|
await expect(code).toHaveText(chunks.join(""))
|
|
await expect(code).toContainText("const pattern = /a+b/g")
|
|
expect(
|
|
await code
|
|
.locator("span[style]")
|
|
.evaluateAll((spans) => new Set(spans.map((span) => getComputedStyle(span).color)).size),
|
|
).toBeGreaterThan(1)
|
|
await harness.getByRole("button", { name: "Toggle Markdown" }).click()
|
|
await harness.getByRole("button", { name: "Toggle Markdown" }).click()
|
|
await expect(code).toHaveText(chunks.join(""))
|
|
await expect(code.locator('span[style*="color"]')).not.toHaveCount(0)
|
|
await harness.getByLabel("Markdown text").fill("```unknown-language\n<plain> & text\n```")
|
|
await expect(code).toHaveText("<plain> & text")
|
|
await expect(code.locator("plain")).toHaveCount(0)
|
|
})
|
|
|
|
story("preserves streamed math through completion and a fresh render", async ({ page }) => {
|
|
await page.evaluate(async (fixture) => {
|
|
const { mountMarkdown } = await import(fixture)
|
|
await mountMarkdown({ text: "Formula: \\(P(x_{t+1})\\)", streaming: true })
|
|
}, fixture)
|
|
const harness = page.getByTestId("markdown-fixture")
|
|
const markdown = harness.locator('[data-component="markdown"]')
|
|
await expect(markdown.locator(".katex")).toHaveCount(1)
|
|
await expect(markdown.locator(".katex-mathml")).toContainText("P(x_{t+1})")
|
|
expect(await markdown.locator(".katex").evaluate((element) => element.nextSibling?.textContent ?? "")).toBe("")
|
|
await harness.getByLabel("Markdown text").fill("Formula: \\(P(x_{t+1})\\) and \\(w^{*}\\).\n\n$$\nx^2 + y^2\n$$\n")
|
|
await expect(markdown.locator(".katex")).toHaveCount(3)
|
|
await expect(markdown.locator(".katex-display")).toHaveCount(1)
|
|
await expect(markdown.locator(".katex-mathml")).toContainText(["P(x_{t+1})", "w^{*}", "x^2 + y^2"])
|
|
await expect(markdown.locator(".katex-error, em, strong")).toHaveCount(0)
|
|
await harness.getByLabel("Streaming").uncheck()
|
|
await expect(markdown.locator(".katex")).toHaveCount(3)
|
|
await harness.getByRole("button", { name: "Toggle Markdown" }).click()
|
|
await harness.getByRole("button", { name: "Toggle Markdown" }).click()
|
|
await expect(markdown.locator(".katex")).toHaveCount(3)
|
|
await expect(markdown.locator(".katex-display")).toHaveCount(1)
|
|
await expect(markdown.locator(".katex-error")).toHaveCount(0)
|
|
})
|
|
|
|
for (const theme of ["light", "dark"]) {
|
|
story(`renders class and connected subgraph diagrams in ${theme}`, async ({ mount, page }) => {
|
|
await page.setViewportSize({ width: 390, height: 900 })
|
|
await mount("components-markdown--complete-response", { globals: { theme } })
|
|
await expect(page.locator("html")).toHaveClass(new RegExp(theme))
|
|
await page.evaluate(async (fixture) => {
|
|
const { mountMarkdown } = await import(fixture)
|
|
await mountMarkdown({
|
|
text: [
|
|
"```mermaid\nclassDiagram\nAnimal <|-- Duck\nAnimal : +int age\nDuck : +swim()\n```",
|
|
"```mermaid\nflowchart LR\nsubgraph Input\ndirection TB\nA[Prompt] --> B[Parse]\nend\nsubgraph Output\ndirection TB\nC[Render] --> D[Display]\nend\nB --> C\n```",
|
|
].join("\n\n"),
|
|
streaming: true,
|
|
})
|
|
}, fixture)
|
|
const harness = page.getByTestId("markdown-fixture")
|
|
const diagrams = harness.locator('[data-component="markdown-mermaid"] > svg')
|
|
await expect(diagrams).toHaveCount(2)
|
|
await expect(diagrams.nth(0)).toBeVisible()
|
|
await expect(diagrams.nth(0)).toContainText("swim()")
|
|
await expect(diagrams.nth(1)).toBeVisible()
|
|
await expect(diagrams.nth(1)).toContainText("Display")
|
|
await expect(diagrams.nth(1).locator(".pele-edge")).toHaveCount(3)
|
|
await expect(harness.locator('[data-mermaid-ready="true"] > pre:visible')).toHaveCount(0)
|
|
await harness.getByLabel("Streaming").uncheck()
|
|
await expect(diagrams).toHaveCount(2)
|
|
await expect(diagrams.nth(0)).toBeVisible()
|
|
await expect(diagrams.nth(1)).toBeVisible()
|
|
})
|
|
}
|
|
|
|
for (const streaming of [false, true]) {
|
|
story(
|
|
`loads local images in ${streaming ? "streaming" : "cached"} Markdown and releases their URLs`,
|
|
async ({ page }) => {
|
|
const requests: string[] = []
|
|
await page.route("**/api/fs/read/**", async (route) => {
|
|
expect(route.request().headers().authorization).toBe(
|
|
`Basic ${Buffer.from("opencode:fixture").toString("base64")}`,
|
|
)
|
|
requests.push(route.request().url())
|
|
await route.fulfill({ contentType: "image/png", body: png })
|
|
})
|
|
await page.evaluate(
|
|
async ({ fixture, streaming }) => {
|
|
const { mountMarkdown } = await import(fixture)
|
|
await mountMarkdown({
|
|
text: "\n\n",
|
|
streaming,
|
|
cached: !streaming,
|
|
images: true,
|
|
})
|
|
},
|
|
{ fixture, streaming },
|
|
)
|
|
const harness = page.getByTestId("markdown-fixture")
|
|
const image = harness.getByRole("button", { name: "Chart", exact: true })
|
|
await expect.poll(() => image.evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(1)
|
|
await expect(harness.getByRole("button", { name: "Again", exact: true })).toHaveAttribute("src", /^blob:/)
|
|
expect(requests).toHaveLength(1)
|
|
expect(new URL(requests[0]).searchParams.get("location[directory]")).toBe("C:/tmp/")
|
|
const url = await image.getAttribute("src")
|
|
await harness.getByLabel("Streaming").uncheck()
|
|
await expect(harness.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
|
|
await expect(image).toHaveAttribute("src", url!)
|
|
await harness.getByLabel("Markdown text").fill("")
|
|
await expect
|
|
.poll(() =>
|
|
harness
|
|
.getByRole("button", { name: "Replacement" })
|
|
.evaluate((image: HTMLImageElement) => image.naturalWidth),
|
|
)
|
|
.toBe(1)
|
|
expect(requests).toHaveLength(2)
|
|
expect(
|
|
await page.evaluate(
|
|
(url) =>
|
|
fetch(url!).then(
|
|
() => false,
|
|
() => true,
|
|
),
|
|
url,
|
|
),
|
|
).toBe(true)
|
|
const next = await harness.getByRole("button", { name: "Replacement" }).getAttribute("src")
|
|
await harness.getByRole("button", { name: "Toggle Markdown" }).click()
|
|
await expect(harness.locator("img")).toHaveCount(0)
|
|
expect(
|
|
await page.evaluate(
|
|
(url) =>
|
|
fetch(url!).then(
|
|
() => false,
|
|
() => true,
|
|
),
|
|
next,
|
|
),
|
|
).toBe(true)
|
|
},
|
|
)
|
|
}
|
|
|
|
story("keeps remote images browser-owned and rejects unsafe image sources", async ({ page }) => {
|
|
await page.route("https://images.example/chart.png", (route) => {
|
|
expect(route.request().headers().authorization).toBeUndefined()
|
|
|
|
return route.fulfill({ contentType: "image/png", body: png })
|
|
})
|
|
await page.evaluate(async (fixture) => {
|
|
const { mountMarkdown } = await import(fixture)
|
|
await mountMarkdown({
|
|
images: true,
|
|
text: '<img alt="Remote" src="https://images.example/chart.png"><img alt="Unsafe" src="javascript:alert(1)" onerror="alert(2)" data-local-image="/tmp/forged.png">',
|
|
})
|
|
}, fixture)
|
|
const harness = page.getByTestId("markdown-fixture")
|
|
await expect
|
|
.poll(() =>
|
|
harness.getByRole("button", { name: "Remote" }).evaluate((image: HTMLImageElement) => image.naturalWidth),
|
|
)
|
|
.toBe(1)
|
|
await expect(harness.getByRole("button", { name: "Remote" })).toHaveAttribute(
|
|
"src",
|
|
"https://images.example/chart.png",
|
|
)
|
|
await expect(harness.locator("[onerror], [src^='javascript:'], [data-local-image]")).toHaveCount(0)
|
|
})
|
|
|
|
story("keeps scripts and external resources inactive inside local SVG images", async ({ page }) => {
|
|
const external: string[] = []
|
|
await page.context().route("https://images.example/**", async (route) => {
|
|
external.push(route.request().url())
|
|
await route.abort()
|
|
})
|
|
await page.route("**/api/fs/read/chart.svg?*", (route) =>
|
|
route.fulfill({
|
|
contentType: "image/svg+xml",
|
|
body: `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32">
|
|
<script>parent.document.title = "image-script-ran"; fetch("https://images.example/script")</script>
|
|
<image href="https://images.example/nested.png" width="32" height="32" />
|
|
<rect width="32" height="32" fill="green" />
|
|
</svg>`,
|
|
}),
|
|
)
|
|
await page.evaluate(async (fixture) => {
|
|
const { mountMarkdown } = await import(fixture)
|
|
await mountMarkdown({ images: true, text: "" })
|
|
}, fixture)
|
|
const image = page.getByTestId("markdown-fixture").getByRole("button", { name: "SVG" })
|
|
await expect.poll(() => image.evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(32)
|
|
await expect(image).toHaveAttribute("src", /^data:image\/svg\+xml;/)
|
|
await expect(page).not.toHaveTitle("image-script-ran")
|
|
expect(external).toEqual([])
|
|
// Opening an image as a document must not give its contents the app's origin.
|
|
const preview = await page.context().newPage()
|
|
await preview.goto((await image.getAttribute("src"))!)
|
|
expect(await preview.evaluate(() => location.origin)).toBe("null")
|
|
await preview.close()
|
|
})
|
|
|
|
story("loads file URLs and Windows paths, and leaves unreadable images as alt text", async ({ page }) => {
|
|
const requested = new Set<string>()
|
|
await page.route("**/api/fs/read/**", async (route) => {
|
|
const url = new URL(route.request().url())
|
|
requested.add(url.pathname)
|
|
await route.fulfill(
|
|
url.pathname.endsWith("missing.png")
|
|
? { status: 404, body: "Not found" }
|
|
: { contentType: "image/png", body: png },
|
|
)
|
|
})
|
|
await page.evaluate(async (fixture) => {
|
|
const { mountMarkdown } = await import(fixture)
|
|
await mountMarkdown({
|
|
images: true,
|
|
// Marked percent-encodes the backslashes of a Windows image path, here inside a table cell.
|
|
text: "\n\n\n\n| Windows |\n| --- |\n|  |",
|
|
})
|
|
}, fixture)
|
|
const harness = page.getByTestId("markdown-fixture")
|
|
|
|
for (const name of ["Available", "Windows"])
|
|
await expect
|
|
.poll(() =>
|
|
harness.getByRole("button", { name, exact: true }).evaluate((image: HTMLImageElement) => image.naturalWidth),
|
|
)
|
|
.toBe(1)
|
|
await expect
|
|
.poll(() => [...requested].sort())
|
|
.toEqual(["/api/fs/read/chart%25.png", "/api/fs/read/missing.png", "/api/fs/read/windows.png"])
|
|
await expect(harness.getByRole("button", { name: "Unavailable" })).not.toHaveAttribute("src")
|
|
await harness.getByLabel("Markdown text").fill("Still usable")
|
|
await expect(harness.locator('[data-component="markdown"]')).toHaveText("Still usable")
|
|
})
|
|
|
|
async function copySelection(page: Page, select: "markdown" | "harness") {
|
|
await page.evaluate((select) => {
|
|
const harness = document.querySelector('[data-testid="markdown-fixture"]')!
|
|
const range = document.createRange()
|
|
range.selectNodeContents(select === "markdown" ? harness.querySelector('[data-component="markdown"]')! : harness)
|
|
getSelection()!.removeAllRanges()
|
|
getSelection()!.addRange(range)
|
|
document.addEventListener(
|
|
"copy",
|
|
(event) => {
|
|
document.body.dataset.copiedText = event.clipboardData?.getData("text/plain")
|
|
document.body.dataset.copiedHtml = event.clipboardData?.getData("text/html")
|
|
},
|
|
{ once: true },
|
|
)
|
|
}, select)
|
|
await page.keyboard.press("ControlOrMeta+C")
|
|
|
|
return page.evaluate(() => ({ text: document.body.dataset.copiedText, html: document.body.dataset.copiedHtml }))
|
|
}
|
|
|
|
story("copies selected Markdown as semantic HTML without theme or highlight colours", async ({ page }) => {
|
|
await page.evaluate(async (fixture) => {
|
|
document.body.style.color = "rgb(237, 237, 237)"
|
|
const { mountMarkdown } = await import(fixture)
|
|
await mountMarkdown({
|
|
text: "## Answer\n\nSome **bold** and [docs](https://opencode.ai/docs).\n\n- one\n- two\n\n```ts\nconst value = 42\n```",
|
|
})
|
|
}, fixture)
|
|
const harness = page.getByTestId("markdown-fixture")
|
|
await expect(harness.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
|
|
await expect(harness.locator("pre code span[style]").first()).toBeAttached()
|
|
|
|
const copied = await copySelection(page, "markdown")
|
|
|
|
expect(copied.text).toContain("Some bold and docs.")
|
|
expect(copied.text).toContain("const value = 42")
|
|
expect(copied.html).toContain("<h2>Answer</h2>")
|
|
expect(copied.html).toContain('<p>Some <strong>bold</strong> and <a href="https://opencode.ai/docs">docs</a>.</p>')
|
|
expect(copied.html).toContain("<li>one</li>")
|
|
expect(copied.html).toMatch(/<pre><code>(<span>[^<]*<\/span>)+<\/code><\/pre>/)
|
|
expect(copied.html).not.toMatch(/style=|class=|<button|<svg/)
|
|
})
|
|
|
|
story("keeps the browser copy for selections that leave the Markdown", async ({ page }) => {
|
|
await page.evaluate(async (fixture) => {
|
|
const { mountMarkdown } = await import(fixture)
|
|
await mountMarkdown({ text: "Some **bold** text" })
|
|
}, fixture)
|
|
await expect(page.getByTestId("markdown-fixture").locator('[data-component="markdown"]')).toHaveAttribute(
|
|
"data-markdown-ready",
|
|
"",
|
|
)
|
|
|
|
const copied = await copySelection(page, "harness")
|
|
|
|
expect(copied.html).toBe("")
|
|
expect(copied.text).toBe("")
|
|
})
|