import { composeStories } from "@storybook/react" import { expect, userEvent, waitFor } from "storybook/test" import { test } from "vitest" import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts" import { mountStory } from "../mountStory.testHelpers.ts" import { expectAgentDrivable } from "../testing/index.ts" import * as stories from "./MarkdownView.stories.tsx" const { AllStates, Playground, HostileMarkdown, Interactive, NestedLinkText, SameAsTheEditor, } = composeStories(stories) /** * The gap this component was built to close, as a red/green fact. * * `isReadOnly` on either editor leaves nine disabled buttons in a * `role="toolbar"` above the document — findable by a screen reader, * findable by an agent, and useless to both. A CSS `display: none` * in the consuming app was the workaround; an element that is not * there is the fix. */ test("there is no toolbar, not a hidden one", async () => { const { canvas } = await mountStory(Playground) await expect(canvas.queryAllByRole("toolbar")).toEqual([]) await expect(canvas.queryAllByRole("button")).toEqual([]) // The side the CSS workaround could never reach: the editor's // toolbar collapses into an overflow **menu** at narrow widths, so // "hide the toolbar" left a menu button behind. await expect(canvas.queryAllByRole("menu")).toEqual([]) }) /** * A reader, not a text field. * * The role is the difference between heading navigation and being * dropped into forms mode with the whole document read as one flat * string — and the headings are the reason it matters, so both are * asserted together. */ test("it is an article whose headings are headings", async () => { const { canvas } = await mountStory(Playground) const article = expectAgentDrivable(canvas, { name: "Description", role: "article", }) await expect(article).toHaveAttribute( "contenteditable", "false", ) // `findBy`, not `getBy`: CodeMirror draws its decorations after the // first paint, and with four browser windows sharing the runner the // second heading was not drawn yet when a synchronous query looked. await expect( await canvas.findByRole("heading", { name: "Rack move, phase two", }), ).toHaveAttribute("aria-level", "1") await expect( await canvas.findByRole("heading", { name: "Before the window", }), ).toHaveAttribute("aria-level", "2") await expectNoAxeViolations(article) }) /** * The requirement that made this a component in the CodeMirror * subpath rather than a `react-markdown` reader in the barrel. * * Both surfaces get one string. The assertion is not "they look * similar" — it is that the constructs a second renderer would get * subtly wrong come out identical: the same table, the same cells, * the same task boxes, the same autolink. */ test("it renders what the editor renders", async () => { const { canvas } = await mountStory(SameAsTheEditor) // ⚠️ WAIT FOR THE COUNT, not for "a table". // // This test is the only one in the file that mounts a story with // BOTH surfaces, so it is the only one that has to wait for // CodeMirror. `MarkdownView` paints its table in the first commit; // CodeMirror parses and decorates the document afterwards. A // synchronous `getAllByRole("table")` therefore returned ONE table // on a slow runner, `editorTable` was `undefined`, and the failure // read "expected undefined to be defined" — which says nothing // about the real cause (CI, 2026-09-14). // // ⚠️ A bare `findAllByRole` does NOT fix it. `findAllBy*` retries // until the query matches AT LEAST ONE element, and one element is // exactly the broken state: the view's table is there from the // start. The count is the condition, so the count goes in the wait. const [viewTable, editorTable] = await waitFor(() => { const tables = canvas.getAllByRole("table") expect(tables).toHaveLength(2) return tables }) const toCellText = (table: HTMLElement) => Array.from(table.querySelectorAll("th, td")).map( (cell) => cell.textContent, ) await expect( toCellText(viewTable as HTMLElement), ).toEqual(toCellText(editorTable as HTMLElement)) // Three task boxes in each, and the first one ticked in each. // Same wait, same reason: CodeMirror's boxes arrive with its // decorations, so a count taken too early sees only the view's. const checkboxes = await waitFor(() => { const boxes: HTMLInputElement[] = canvas.getAllByRole("checkbox") expect(boxes).toHaveLength(6) return boxes }) await expect( checkboxes.filter((checkbox) => checkbox.checked), ).toHaveLength(2) // The bare URL, autolinked on both sides with nothing typed // around it. await waitFor(() => { expect( canvas.getAllByText( "https://example.invalid/product?id=1234", ), ).toHaveLength(2) }) }) /** * A link a reader can reach, which the editor's cannot be. * * The editor paints a `` and translates a `mousedown` into a * navigation, because an anchor inside a `contenteditable` fights * the caret for the line. A document has no caret, so the link is an * anchor: in the tab order, with an address the browser will show, * copy and open. */ test("a link is a real anchor in the tab order", async () => { const { canvas } = await mountStory(Interactive) // Markdown decorations arrive after the editor's asynchronous parse. // Wait for the link before checking its accessibility and keyboard behavior. await canvas.findByRole("link", { name: "the runbook" }) const link = expectAgentDrivable(canvas, { name: "the runbook", role: "link", }) await expect(link.tagName).toBe("A") await expect(link).toHaveAttribute( "href", "https://example.invalid/runbook", ) // `target="_blank"` without `noopener` hands the opened page a // live handle back into this one. await expect(link).toHaveAttribute( "rel", "noopener noreferrer", ) // The document itself is not a tab stop — there is nothing in it // to operate — but every link in it is. Tab from the button above // lands on the first link rather than on the article. canvas.getByRole("button", { name: "Before" }).focus() await userEvent.tab() await expect(document.activeElement?.tagName).toBe("A") }) /** * The answer to "is a checkbox interactive in a read-only view?", * asserted from both sides. * * Not offered by default, because two of the three surfaces this was * built for have nowhere to put a tick. Offered when the consumer * says where it goes, which is the same shape `onUploadImage` uses * on the editors. */ test("a checkbox is inert until a consumer says where the tick goes", async () => { const { canvas } = await mountStory(AllStates) const inert = await canvas.findByRole("checkbox", { name: "Photograph the current cabling", }) await expect(inert).toBeDisabled() const tickable = canvas.getByRole("checkbox", { name: "Close the change record", }) await expect(tickable).not.toBeDisabled() await userEvent.click(tickable) // The tick reaches the *markdown* and comes back as state, which // is the round trip that matters: the box is a view over the // document, and the document is what the consumer stores. await waitFor(async () => { await expect( canvas.getByRole("checkbox", { name: "Close the change record", }), ).toBeChecked() }) }) /** * The document this thing will actually be pointed at. * * Three separate claims, and each one used to be false or was one * refactor away from it: a `javascript:` URL is not a link, a * `