// Demo harness acceptance proofs: the REAL page UI, restyled as a VS Code // Explorer (activity bar | explorer sidebar | editor tabs + gutter | OUTPUT // panel | status bar). Every step is a click or a typing action through // Playwright locators (the same things a human does) — no page.evaluate on // runtime internals. The harness page exposes window.__runtime only as a debug // hook; these tests deliberately do not use it. // // Tree updates arrive via runtime.onFsChanged (the host coalesces ~25 ms) and guest // stdio arrives as stream chunks, so every assertion rides Playwright's // auto-retrying expect() with generous timeouts instead of fixed sleeps. // // Selector map for the VS Code behaviors (the brief's stable hooks): // #tree [data-path=…] tree rows (role=treeitem; dirs carry aria-expanded) // row .twisty folder expand/collapse chevron (▸ rotates to ▾) // #inline-row / #inline-input / #inline-ok create + rename (no window.prompt) // #confirm-row / #confirm-text / #confirm-yes recursive dir delete (no window.confirm) // #tabs .tab[data-path=…] editor tabs (role=tab; data-dirty="true|false", // .tab-dirty-dot is the ● marker) // #btn-save / #btn-editor-delete tab-bar actions, accessible names Save/Delete // #entry / #btn-run / #exit guest runner in the OUTPUT panel header // #status single outcome/notification element // #sb-boot / #sb-exit / #sb-selected status bar segments // Editor-library hooks (this slice): // #hl-wrap > #hl-mirror microlighter mirror behind #content for code // and markdown files (hidden for .txt/binary/ // unknown); token colors via CSS Custom Highlight API import { test, expect, type Page } from "@playwright/test"; const tab = (page: Page, path: string) => page.locator(`#tabs .tab[data-path="${path}"]`); async function openHarness(page: Page): Promise { await page.goto("/demo/harness.html"); // The tree renders after boot + seed; the root row is the "ready" marker. await expect(page.locator('#tree [data-path="/app"]')).toBeVisible({ timeout: 20_000 }); await expect(page.locator("#sb-boot")).toContainText("VFS ready"); } // Right-click a tree row and pick a context-menu item (New File / New Folder / // Rename / Delete / Copy Path — ids ctx-*). async function ctxMenu(page: Page, path: string, item: string): Promise { await page.locator(`#tree [data-path="${path}"]`).click({ button: "right" }); await expect(page.locator("#context-menu")).toBeVisible(); await page.locator(`#ctx-${item}`).click(); } test("boot: node_modules starts collapsed; its twisty reveals leftpad; scratch dir visible", async ({ page }) => { await openHarness(page); const tree = page.locator("#tree"); // Calm VS Code default: node_modules collapsed, the rest of the first level visible. await expect(tree.locator('[data-path="/app/node_modules"]')).toBeVisible(); await expect(tree.locator('[data-path="/app/node_modules/leftpad"]')).toHaveCount(0); await expect(tree.locator('[data-path="/app/scratch"]')).toBeVisible(); await expect(tree.locator('[data-path="/app/main.cjs"]')).toBeVisible(); await expect(tree.locator('[data-path="/app/notes.md"]')).toBeVisible(); // Expanding the twisty reveals the packages; a nested folder still starts // collapsed until its own twisty is clicked. await tree.locator('[data-path="/app/node_modules"] .twisty').click(); await expect(tree.locator('[data-path="/app/node_modules/leftpad"]')).toBeVisible(); await expect(tree.locator('[data-path="/app/node_modules/shouty"]')).toBeVisible(); await expect(tree.locator('[data-path="/app/node_modules/leftpad/index.js"]')).toHaveCount(0); await tree.locator('[data-path="/app/node_modules/leftpad"] .twisty').click(); await expect(tree.locator('[data-path="/app/node_modules/leftpad/index.js"]')).toBeVisible(); // Expansion survives tree re-renders: creating a file fires onFsChanged → // re-render, and node_modules must still be open afterwards. await ctxMenu(page, "/app", "new-file"); await page.locator("#inline-input").fill("re-render-probe.txt"); await page.locator("#inline-ok").click(); await expect(tree.locator('[data-path="/app/re-render-probe.txt"]')).toBeVisible(); await expect(tree.locator('[data-path="/app/node_modules/leftpad/index.js"]')).toBeVisible(); }); test("create: New File via context menu → type → Save (dirty dot clears) → guest echoes, exit 0", async ({ page }) => { await openHarness(page); await ctxMenu(page, "/app", "new-file"); await expect(page.locator("#inline-row")).toBeVisible(); await page.locator("#inline-input").fill("hello.txt"); await page.locator("#inline-ok").click(); // The created file auto-opens as the active editor tab. await expect(page.locator('#tree [data-path="/app/hello.txt"]')).toBeVisible(); await expect(tab(page, "/app/hello.txt")).toHaveAttribute("aria-selected", "true"); await page.locator("#content").fill("hi from the harness"); await expect(tab(page, "/app/hello.txt")).toHaveAttribute("data-dirty", "true"); await page.locator("#btn-save").click(); await expect(page.locator("#status")).toContainText("saved /app/hello.txt"); await expect(tab(page, "/app/hello.txt")).toHaveAttribute("data-dirty", "false"); // The guest reads what the page wrote — the cross-writer round-trip. await page.locator("#entry").fill("/app/echo.cjs"); await page.locator("#btn-run").click(); await expect(page.locator("#stdout")).toContainText("hi from the harness", { timeout: 15_000 }); await expect(page.locator("#exit")).toHaveText("0", { timeout: 15_000 }); await expect(page.locator("#stderr")).toHaveText(""); }); test("guest→page: writer.cjs writeFileSync appears in the expanded scratch without a manual refresh", async ({ page }) => { await openHarness(page); await page.locator("#entry").fill("/app/writer.cjs"); await page.locator("#btn-run").click(); await expect(page.locator("#stdout")).toContainText("wrote /app/scratch/from-guest.txt", { timeout: 15_000 }); await expect(page.locator("#exit")).toHaveText("0", { timeout: 15_000 }); // No Refresh click anywhere in this test: the row must appear via onFsChanged. await expect(page.locator('#tree [data-path="/app/scratch/from-guest.txt"]')).toBeVisible({ timeout: 10_000 }); }); test("edit + rename: notes.md dirty dot clears on save; hello.txt renames with content preserved", async ({ page }) => { await openHarness(page); // Create hello.txt with content the rename must preserve. await ctxMenu(page, "/app", "new-file"); await page.locator("#inline-input").fill("hello.txt"); await page.locator("#inline-ok").click(); await expect(tab(page, "/app/hello.txt")).toHaveAttribute("aria-selected", "true"); await page.locator("#content").fill("hi from the harness"); await page.locator("#btn-save").click(); await expect(page.locator("#status")).toContainText("saved /app/hello.txt"); // Edit notes.md; the dirty dot shows until Save. await page.locator('#tree [data-path="/app/notes.md"]').click(); await expect(tab(page, "/app/notes.md")).toHaveAttribute("aria-selected", "true"); await expect(page.locator("#content")).toHaveValue("# scratch\n"); await expect(tab(page, "/app/notes.md")).toHaveAttribute("data-dirty", "false"); await page.locator("#content").fill("# scratch\nedited in the harness\n"); await expect(tab(page, "/app/notes.md")).toHaveAttribute("data-dirty", "true"); await expect(tab(page, "/app/notes.md").locator(".tab-dirty-dot")).toBeVisible(); await page.locator("#btn-save").click(); await expect(page.locator("#status")).toContainText("saved /app/notes.md"); await expect(tab(page, "/app/notes.md")).toHaveAttribute("data-dirty", "false"); await expect(tab(page, "/app/notes.md").locator(".tab-dirty-dot")).toBeHidden(); // Rename via the context menu's inline input, pre-filled with the current name. await page.locator('#tree [data-path="/app/hello.txt"]').click(); await ctxMenu(page, "/app/hello.txt", "rename"); await expect(page.locator("#inline-input")).toHaveValue("hello.txt"); await page.locator("#inline-input").fill("hello2.txt"); await page.locator("#inline-ok").click(); await expect(page.locator('#tree [data-path="/app/hello.txt"]')).toHaveCount(0); await expect(page.locator('#tree [data-path="/app/hello2.txt"]')).toBeVisible(); // Content preserved: open the renamed file and read it back. await page.locator('#tree [data-path="/app/hello2.txt"]').click(); await expect(page.locator("#content")).toHaveValue("hi from the harness"); // The notes.md edit survived its own tree refresh too. await page.locator('#tree [data-path="/app/notes.md"]').click(); await expect(page.locator("#content")).toHaveValue("# scratch\nedited in the harness\n"); }); test("delete: file rm is immediate from the tab actions; dir delete goes through the inline recursive confirm", async ({ page }) => { await openHarness(page); // Make /app/scratch/from-guest.txt exist by running the seeded writer guest. await page.locator("#entry").fill("/app/writer.cjs"); await page.locator("#btn-run").click(); await expect(page.locator('#tree [data-path="/app/scratch/from-guest.txt"]')).toBeVisible({ timeout: 10_000 }); // File delete: the tab-bar Delete (accessible name "Delete") acts on the // ACTIVE tab's file — no confirm step for files. await page.locator('#tree [data-path="/app/scratch/from-guest.txt"]').click(); await expect(tab(page, "/app/scratch/from-guest.txt")).toHaveAttribute("aria-selected", "true"); await page.locator("#btn-editor-delete").click(); await expect(page.locator("#status")).toContainText("deleted /app/scratch/from-guest.txt"); await expect(page.locator('#tree [data-path="/app/scratch/from-guest.txt"]')).toHaveCount(0); await expect(tab(page, "/app/scratch/from-guest.txt")).toHaveCount(0); // Dir delete: context menu → inline confirm flow — no window.confirm anywhere. await ctxMenu(page, "/app/scratch", "delete"); await expect(page.locator("#confirm-row")).toBeVisible(); await expect(page.locator("#confirm-text")).toContainText("/app/scratch"); await page.locator("#confirm-yes").click(); await expect(page.locator("#status")).toContainText("deleted /app/scratch"); await expect(page.locator('#tree [data-path="/app/scratch"]')).toHaveCount(0); }); test("error surfacing: saving into a missing dir shows ENOENT in #status; the page stays usable", async ({ page }) => { await openHarness(page); await ctxMenu(page, "/app", "new-file"); await page.locator("#inline-input").fill("/app/nope/deep.txt"); // parent dir does not exist await page.locator("#inline-ok").click(); await expect(page.locator("#status")).toContainText("ENOENT"); // The failed create made nothing. await expect(page.locator('#tree [data-path="/app/nope"]')).toHaveCount(0); // Nothing wedged: the runner still works and exits 0. await page.locator("#entry").fill("/app/writer.cjs"); await page.locator("#btn-run").click(); await expect(page.locator("#exit")).toHaveText("0", { timeout: 15_000 }); await expect(page.locator('#tree [data-path="/app/scratch/from-guest.txt"]')).toBeVisible({ timeout: 10_000 }); }); test("tabs: dirty dot tracks edits, switching preserves unsaved edits, closing the dirty tab moves selection", async ({ page }) => { await openHarness(page); // Two tabs: main.cjs first, then notes.md (active). await page.locator('#tree [data-path="/app/main.cjs"]').click(); await expect(tab(page, "/app/main.cjs")).toHaveAttribute("aria-selected", "true"); await expect(page.locator("#content")).toHaveValue(/node:fs/); const mainSource = await page.locator("#content").inputValue(); await page.locator('#tree [data-path="/app/notes.md"]').click(); await expect(tab(page, "/app/notes.md")).toHaveAttribute("aria-selected", "true"); await page.locator("#content").fill("unsaved tab edit\n"); // The dirty dot appears on the edited tab only. await expect(tab(page, "/app/notes.md")).toHaveAttribute("data-dirty", "true"); await expect(tab(page, "/app/notes.md").locator(".tab-dirty-dot")).toBeVisible(); await expect(tab(page, "/app/main.cjs")).toHaveAttribute("data-dirty", "false"); await expect(tab(page, "/app/main.cjs").locator(".tab-dirty-dot")).toBeHidden(); // Switch to main.cjs and back: the unsaved notes.md edit is preserved in // memory and re-shown. await tab(page, "/app/main.cjs").click(); await expect(page.locator("#content")).toHaveValue(mainSource); await tab(page, "/app/notes.md").click(); await expect(page.locator("#content")).toHaveValue("unsaved tab edit\n"); await expect(tab(page, "/app/notes.md")).toHaveAttribute("data-dirty", "true"); // Close the dirty tab: selection moves to the neighbor, and the tree keeps // notes.md — closing a tab is not deleting its file. await tab(page, "/app/notes.md").locator(".tab-close").click(); await expect(tab(page, "/app/notes.md")).toHaveCount(0); await expect(tab(page, "/app/main.cjs")).toHaveAttribute("aria-selected", "true"); await expect(page.locator("#content")).toHaveValue(mainSource); await expect(page.locator('#tree [data-path="/app/notes.md"]')).toBeVisible(); await expect(page.locator('#tree [data-path="/app/notes.md"]')).toHaveCount(1); }); // ---- editor-library slice ---- // The assertion hook the brief prescribes: microlighter registers one CSS // Custom Highlight per token category over the mirror; expect.poll retries // until the async grammar load + rAF-debounced re-highlight have landed. const highlightCount = (page: Page) => () => page.evaluate("CSS.highlights.size"); test("highlighting: opening a code file registers CSS Custom Highlights on the mirror", async ({ page }) => { await openHarness(page); await page.locator('#tree [data-path="/app/main.cjs"]').click(); await expect(tab(page, "/app/main.cjs")).toHaveAttribute("aria-selected", "true"); // The mirror is visible, carries the file's text, and at least one token // category is registered after the rAF-debounced highlight pass. await expect(page.locator("#hl-wrap")).toBeVisible(); await expect(page.locator("#hl-mirror")).toContainText("node:fs"); await expect.poll(highlightCount(page), { timeout: 5_000 }).toBeGreaterThan(0); }); test("highlighting: edits re-register highlights after a rAF (input → re-highlight)", async ({ page }) => { await openHarness(page); await page.locator('#tree [data-path="/app/greet.js"]').click(); await expect(tab(page, "/app/greet.js")).toHaveAttribute("aria-selected", "true"); await expect.poll(highlightCount(page), { timeout: 5_000 }).toBeGreaterThan(0); // Type a comment + a string; the mirror re-syncs on input and highlightAll // re-runs — no assertion on exact token counts, just that registration // survives the edit (a broken re-highlight would empty the registry). await page.locator("#content").fill('// harness probe\nconst neon = "#00ff87";\n'); await expect(page.locator("#hl-mirror")).toContainText("#00ff87"); await expect.poll(highlightCount(page), { timeout: 5_000 }).toBeGreaterThan(0); }); test("markdown: .md files edit in the textarea with a markdown highlight mirror", async ({ page }) => { await openHarness(page); await page.locator('#tree [data-path="/app/notes.md"]').click(); await expect(tab(page, "/app/notes.md")).toHaveAttribute("aria-selected", "true"); await expect(page.locator("#content")).toHaveValue("# scratch\n"); await expect(page.locator("#hl-wrap")).toBeVisible(); await expect(page.locator("#hl-mirror")).toHaveAttribute("data-language", "markdown"); await expect.poll(highlightCount(page), { timeout: 5_000 }).toBeGreaterThan(0); }); test("plain text: .txt files get no highlight mirror and edit normally", async ({ page }) => { await openHarness(page); await page.locator('#tree [data-path="/app/name.txt"]').click(); await expect(tab(page, "/app/name.txt")).toHaveAttribute("aria-selected", "true"); // No grammar for .txt → the mirror stays hidden and the textarea keeps its // own text color (no hl-on overlay). await expect(page.locator("#hl-wrap")).toBeHidden(); await expect(page.locator("#content")).toBeVisible(); await page.locator("#content").fill("plain text, no tokens here"); await expect(tab(page, "/app/name.txt")).toHaveAttribute("data-dirty", "true"); await expect(page.locator("#content")).toHaveValue("plain text, no tokens here"); await page.locator("#btn-save").click(); await expect(page.locator("#status")).toContainText("saved /app/name.txt"); await expect(tab(page, "/app/name.txt")).toHaveAttribute("data-dirty", "false"); });