Something went wrong. Try again.
Monorepo for the nibling.run platform
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418import { expect, test, type Locator, type Page } from "@playwright/test";import { axNode, axTree } from "@nibling/web/testing/ax";import { run, terminalLog } from "./fixtures/terminal.ts";
const escape = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");const stack = (page: Page, slot: string) => page.locator(`nib-stack:state(slot-${slot})`);/** A tab by its title. Its text also includes the shadow close glyph. */const tab = (page: Page, title: string) => page.locator("nib-tablist > nib-tab").filter({ hasText: new RegExp(`^${escape(title)}×?$`) });const stackWith = (page: Page, title: string) => page.locator("nib-stack", { has: tab(page, title) });const content = (stack: Locator) => stack.locator(":scope > div");const shownBodies = (page: Page) => page.locator("nib-tabpanel:not([hidden])");/** A file tree item by its label. Its text is the label, plus "▶" when it has a preview action. */const item = (page: Page, name: string) => page.locator("nib-tree-item").filter({ hasText: new RegExp(`^${escape(name)}▶?$`) });const treeitem = (page: Page, name: string) => expect.poll(() => axNode(page, "treeitem", name));const preview = (page: Page) => page.frameLocator("nib-preview iframe");
/** Writes an HTML page and its stylesheet into the project, for tests that exercise the preview. */async function writeHtmlPage(page: Page) { await page.evaluate(() => { (globalThis as any).demo.write("page.html", '<!doctype html>\n<link rel="stylesheet" href="page.css">\n<h1>Hello from nibling</h1>\n'); (globalThis as any).demo.write("page.css", "h1 { color: rebeccapurple; }\n"); });}
/** Opens `path` as a second panel beside whichever stack currently holds the "main" slot. */async function openBeside(page: Page, kind: "editor" | "preview", path: string) { await page.waitForFunction(() => Boolean((globalThis as any).workbench)); await page.evaluate( ({ kind, path }) => { const wb = (globalThis as any).workbench; const find = (node: any): any => (node.type === "stack" ? (node.slot === "main" ? node : undefined) : node.children.map(find).find(Boolean)); wb.open({ kind, input: { path } }, { beside: find(wb.layout.root).id, edge: "right" }); }, { kind, path }, );}
/** The gutter on one side of the pane that holds `stack`, found through the split's manually assigned slots. */async function gutter(stack: Locator, side: "before" | "after") { const handle = await stack.evaluateHandle((el, side) => { const panel = el.closest("nib-panel")!; const split = panel.parentElement!; const slot = [...split.shadowRoot!.querySelectorAll("slot")].find((s) => s.assignedElements().includes(panel))!; return (side === "before" ? slot.previousElementSibling : slot.nextElementSibling) as HTMLElement; }, side); return handle.asElement()!;}
/** * Drags `from` to `position` within `onto` and holds there before letting go, as a split asks: an * area's edge only splits once the pointer has held at it. Waits out the drop's view transition, * which takes no pointer input while it runs. */async function holdAndDrop(page: Page, from: Locator, onto: Locator, position: { x: number; y: number }) { const start = (await from.boundingBox())!; const box = (await onto.boundingBox())!; const x = box.x + position.x; const y = box.y + position.y; await page.mouse.move(start.x + start.width / 2, start.y + start.height / 2); await page.mouse.down(); await page.mouse.move(x, y, { steps: 8 }); await page.waitForTimeout(500); // Playwright sends no dragover for a drag's last step; a nudge delivers one where it rests. await page.mouse.move(x, y + 1); await page.mouse.move(x, y); await page.mouse.up(); await settled(page);}
/** Waits until no view transition is running over the shell. */async function settled(page: Page) { await expect.poll(() => page.locator("nib-shell").evaluate((el) => el.shadowRoot!.getAnimations().length)).toBe(0);}
test.beforeEach(async ({ page }) => { await page.goto("/new?reset"); await page.evaluate(() => localStorage.clear());});
test("the default layout has every slot, and each stack shows only its active panel", async ({ page }) => { for (const slot of ["primary", "main", "panel", "secondary"]) await expect(stack(page, slot)).toBeVisible(); await expect(shownBodies(page)).toHaveCount(5); await expect(page.getByRole("textbox", { name: "index.ts" })).toBeVisible(); await expect(page.locator("nib-browser").getByRole("heading", { name: "No server is running" })).toBeVisible();});
test("every part is a named region, and tabpanels are named by their tabs", async ({ page }) => { const names = async () => (await axTree(page)).filter((n) => n.role === "region").map((n) => n.name); await expect.poll(names).toEqual(expect.arrayContaining(["Primary Side Bar", "Editor", "Panel", "Secondary Side Bar"])); await expect.poll(() => axNode(page, "tablist", "Editor")).toBeTruthy(); await expect.poll(() => axNode(page, "tab", "index.ts")).toMatchObject({ selected: true }); await expect.poll(() => axNode(page, "tabpanel", "index.ts")).toBeTruthy();});
test("a body sits exactly over its stack's content area", async ({ page }) => { const area = await content(stack(page, "panel")).boundingBox(); const body = await shownBodies(page).filter({ has: page.locator("nib-terminal") }).boundingBox(); expect(body).toEqual(area);});
test("the explorer opens editors and previews, and editing updates the preview", async ({ page }) => { await writeHtmlPage(page); await item(page, "page.html").click(); await expect(tab(page, "page.html")).toHaveJSProperty("selected", true); await page.getByRole("button", { name: "Preview page.html" }).click(); await tab(page, "page.html").click(); await page.getByRole("textbox", { name: "page.html" }).fill('<!doctype html>\n<link rel="stylesheet" href="page.css">\n<h1>Changed</h1>\n'); await expect(preview(page).getByRole("heading")).toHaveText("Changed");});
test("the preview applies linked stylesheets and restyles when they change", async ({ page }) => { await writeHtmlPage(page); await openBeside(page, "preview", "page.html"); const heading = preview(page).getByRole("heading"); await expect(heading).toHaveCSS("color", "rgb(102, 51, 153)"); await item(page, "page.css").click(); await page.getByRole("textbox", { name: "page.css" }).fill("h1 { color: rgb(0, 128, 0); }"); await expect(heading).toHaveCSS("color", "rgb(0, 128, 0)");});
test("the file tree is a tree to assistive tech, with levels and states", async ({ page }) => { await expect.poll(() => axNode(page, "tree", "Files")).toBeTruthy(); await treeitem(page, "package.json").toMatchObject({ level: 1, selected: false }); await expect.poll(async () => (await axNode(page, "treeitem", "package.json"))?.expanded).toBeUndefined();});
test("the file tree folds folders and moves by keyboard", async ({ page }) => { await page.evaluate(() => (globalThis as any).demo.write("src/lib/greet.ts", 'export const greet = (name: string) => `Hello, ${name}`;\n')); await expect(item(page, "greet.ts")).toHaveCount(0); await item(page, "src").click(); await treeitem(page, "src").toMatchObject({ expanded: true }); await expect(item(page, "src")).toHaveJSProperty("node", "src"); await treeitem(page, "lib").toMatchObject({ level: 2 }); await page.keyboard.press("ArrowDown"); await expect(item(page, "lib")).toBeFocused(); await page.keyboard.press("ArrowRight"); await page.keyboard.press("ArrowRight"); await expect(item(page, "greet.ts")).toBeFocused(); await page.keyboard.press("Enter"); await expect(tab(page, "greet.ts")).toHaveJSProperty("selected", true); await treeitem(page, "greet.ts").toMatchObject({ selected: true, level: 3 }); await item(page, "greet.ts").focus(); await page.keyboard.press("ArrowLeft"); await expect(item(page, "lib")).toBeFocused(); await page.keyboard.press("ArrowLeft"); await expect(item(page, "greet.ts")).toHaveCount(0);});
test("file tree states are custom states, not attributes", async ({ page }) => { await page.evaluate(() => (globalThis as any).demo.write("src/lib/greet.ts", 'export const greet = (name: string) => `Hello, ${name}`;\n')); await item(page, "src").click(); const states = (name: string) => item(page, name).evaluate((el) => ["branch", "expanded", "collapsed", "selected"].filter((s) => el.matches(`:state(${s})`))); expect(await states("src")).toEqual(["branch", "expanded"]); expect(await states("lib")).toEqual(["branch", "collapsed"]); expect(await states("index.ts")).toEqual(["selected"]); expect(await item(page, "src").evaluate((el) => el.getAttributeNames())).toEqual(["tabindex"]);});
test("the file tree reveals and selects the editor the workbench shows", async ({ page }) => { await page.evaluate(() => (globalThis as any).demo.write("src/lib/greet.ts", 'export const greet = (name: string) => `Hello, ${name}`;\n')); await treeitem(page, "index.ts").toMatchObject({ selected: true }); await page.evaluate(() => (globalThis as any).workbench.open({ kind: "editor", input: { path: "src/lib/greet.ts" } })); await treeitem(page, "greet.ts").toMatchObject({ selected: true }); await treeitem(page, "index.ts").toMatchObject({ selected: false });});
test("arrow keys switch tabs, and Delete closes one and keeps focus in the tablist", async ({ page }) => { await item(page, "package.json").click(); await tab(page, "package.json").click(); await tab(page, "package.json").focus(); await page.keyboard.press("ArrowLeft"); await expect(tab(page, "index.ts")).toBeFocused(); await expect(tab(page, "index.ts")).toHaveJSProperty("selected", true); await expect(page.getByRole("textbox", { name: "index.ts" })).toBeVisible(); await page.keyboard.press("Delete"); await expect(tab(page, "index.ts")).toHaveCount(0); await expect(tab(page, "package.json")).toBeFocused(); await expect(tab(page, "package.json")).toHaveJSProperty("selected", true);});
test("F6 moves keyboard focus between parts, and never into a sandboxed frame", async ({ page }) => { await page.getByRole("textbox", { name: "index.ts" }).focus(); // Sandbox panels (editor, preview) take focus on their tab, so the shell still hears the next F6. await page.keyboard.press("F6"); await expect(tab(page, "Preview")).toBeFocused(); await page.keyboard.press("F6"); await expect(page.getByRole("textbox", { name: "Terminal input" })).toBeFocused(); await page.keyboard.press("F6"); await expect(page.getByRole("textbox", { name: "Message" })).toBeFocused(); await page.keyboard.press("F6"); // Chrome panels take focus in their body: the explorer's tree. await expect(item(page, "index.ts")).toBeFocused(); await page.keyboard.press("Shift+F6"); await expect(page.getByRole("textbox", { name: "Message" })).toBeFocused(); await page.keyboard.press("Shift+F6"); await expect(page.getByRole("textbox", { name: "Terminal input" })).toBeFocused(); await page.keyboard.press("Shift+F6"); await expect(tab(page, "Preview")).toBeFocused(); await page.keyboard.press("Shift+F6"); // Wrapping around lands on the sandboxed group's tab, never inside its frame. await expect(tab(page, "index.ts")).toBeFocused();});
test("Alt+Shift+Arrow moves the active panel to the next group without dragging", async ({ page }) => { await writeHtmlPage(page); await item(page, "package.json").click(); await openBeside(page, "preview", "page.html"); await tab(page, "index.ts").click(); await page.getByRole("textbox", { name: "index.ts" }).focus(); await page.keyboard.press("Alt+Shift+ArrowRight"); await expect(stackWith(page, "index.ts")).not.toHaveCount(0); await expect(stack(page, "main").locator("nib-tab")).toHaveCount(1); await expect(stackWith(page, "Preview page.html").locator("nib-tab")).toHaveCount(2);});
test("a closed explorer comes back from the activity bar, which also hides and shows its side bar", async ({ page }) => { const explorer = page.getByRole("button", { name: "Explorer", exact: true }); await expect(explorer).toHaveAttribute("aria-pressed", "true"); await tab(page, "Explorer").locator("[part=close]").click(); await expect(page.locator("nib-tree")).toHaveCount(0); await expect(explorer).toHaveAttribute("aria-pressed", "false"); await explorer.click(); await expect(page.locator("nib-tree")).toBeVisible(); await expect(explorer).toHaveAttribute("aria-pressed", "true"); await explorer.click(); await expect(stack(page, "primary")).toHaveCount(0); await expect(explorer).toHaveAttribute("aria-pressed", "false"); await explorer.click(); await expect(page.locator("nib-tree")).toBeVisible();});
test("a closed terminal comes back from the activity bar", async ({ page }) => { await tab(page, "Terminal").locator("[part=close]").click(); await expect(page.locator("nib-terminal")).toHaveCount(0); await page.getByRole("button", { name: "Terminal", exact: true }).click(); await expect(page.locator("nib-terminal")).toBeVisible();});
test("the activity bar is a vertical toolbar with one tab stop", async ({ page }) => { await expect.poll(() => axNode(page, "toolbar", "Views")).toMatchObject({ orientation: "vertical" }); await page.getByRole("button", { name: "Explorer", exact: true }).focus(); await page.keyboard.press("ArrowDown"); await expect(page.getByRole("button", { name: "Terminal", exact: true })).toBeFocused(); await page.keyboard.press("End"); await expect(page.getByRole("button", { name: "Agent", exact: true })).toBeFocused(); await page.keyboard.press("Enter"); await expect(stack(page, "secondary")).toHaveCount(0); await expect(page.getByRole("button", { name: "Agent", exact: true })).toBeFocused();});
test("keybindings toggle parts without losing their panels", async ({ page }) => { await page.locator("body").click({ position: { x: 1, y: 1 } }); await page.keyboard.press("ControlOrMeta+b"); await expect(stack(page, "primary")).toHaveCount(0); await expect(page.locator("nib-tree")).toBeHidden(); await page.keyboard.press("ControlOrMeta+j"); await expect(stack(page, "panel")).toHaveCount(0); await page.keyboard.press("ControlOrMeta+b"); await expect(page.locator("nib-tree")).toBeVisible();});
test("dragging a tab to a content edge splits the stack", async ({ page }) => { await item(page, "package.json").click(); await expect(stack(page, "main")).toBeVisible(); const before = await page.locator("nib-stack").count(); const target = content(stack(page, "main")); const box = (await target.boundingBox())!; await holdAndDrop(page, tab(page, "package.json"), target, { x: box.width / 2, y: box.height - 5 }); await expect(page.locator("nib-stack")).toHaveCount(before + 1); await expect(stack(page, "main").locator("nib-tab")).toHaveCount(1); await expect(page.getByRole("textbox", { name: "package.json" })).toBeVisible();});
test("moving the preview keeps its iframe document instead of reloading it", async ({ page }) => { await writeHtmlPage(page); await openBeside(page, "preview", "page.html"); // The preview is opaque-origin, so the marker is set from inside the frame. const frame = (await (await page.locator("nib-preview iframe").elementHandle())!.contentFrame())!; await frame.evaluate(() => Object.assign(globalThis, { marker: 42 })); await page.evaluate(() => { const wb = (globalThis as any).workbench; const find = (node: any): any => (node.type === "stack" ? (node.slot === "main" ? node : undefined) : node.children.map(find).find(Boolean)); const [panel] = wb.panels("preview"); wb.dispatch({ type: "move", panel: panel.id, to: { stack: find(wb.layout.root).id } }); }); await expect(stack(page, "main").locator("nib-tab").filter({ hasText: "Preview page.html" })).toHaveCount(1); await expect(preview(page).getByRole("heading")).toBeVisible(); expect(await frame.evaluate(() => (globalThis as { marker?: number }).marker)).toBe(42);});
test("dragging a gutter resizes, and the layout survives a reload", async ({ page }) => { const primary = stack(page, "primary"); const width = (await primary.boundingBox())!.width; const box = (await (await gutter(primary, "after")).boundingBox())!; await page.mouse.move(box.x + 2, box.y + box.height / 2); await page.mouse.down(); await page.mouse.move(box.x + 102, box.y + box.height / 2, { steps: 5 }); await page.mouse.up(); const resized = (await primary.boundingBox())!.width; expect(resized).toBeGreaterThan(width + 80); await page.goto("/new"); expect((await stack(page, "primary").boundingBox())!.width).toBeCloseTo(resized, 0);});
test("a gutter's pointer target overhangs the panel bodies beside it", async ({ page }) => { const primary = stack(page, "primary"); const width = (await primary.boundingBox())!.width; const box = (await (await gutter(primary, "after")).boundingBox())!; // 10px left of the line is over the explorer's body, but still inside the gutter's target. const y = box.y + box.height / 2; await page.mouse.move(box.x - 10, y); await page.mouse.down(); await page.mouse.move(box.x + 90, y, { steps: 5 }); await page.mouse.up(); expect((await primary.boundingBox())!.width).toBeGreaterThan(width + 80); // Outside the target, the explorer's tree still takes the click. await item(page, "package.json").click(); await expect(tab(page, "package.json")).toHaveJSProperty("selected", true);});
test("a gutter resizes by keyboard and keeps focus across the layout update", async ({ page }) => { const primary = stack(page, "primary"); const width = (await primary.boundingBox())!.width; const handle = await gutter(primary, "after"); await handle.focus(); await page.keyboard.press("ArrowRight"); await page.keyboard.press("ArrowRight"); await expect.poll(async () => (await primary.boundingBox())!.width).toBeGreaterThan(width + 15); expect(await handle.evaluate((el) => (el.getRootNode() as ShadowRoot).activeElement === el)).toBe(true); const stored = await page.evaluate(() => (globalThis as any).workbench.layout.root.sizes[0]); expect(stored).toBeGreaterThan(0);});
test("a gutter stops at the minimum pane size instead of squeezing a pane off the edge", async ({ page }) => { await openBeside(page, "editor", "package.json"); const previewStack = stackWith(page, "package.json"); const box = (await (await gutter(previewStack, "before")).boundingBox())!; await page.mouse.move(box.x + 2, box.y + box.height / 2); await page.mouse.down(); await page.mouse.move(page.viewportSize()!.width - 1, box.y + box.height / 2, { steps: 5 }); await page.mouse.up(); expect((await previewStack.boundingBox())!.width).toBeGreaterThanOrEqual(159);});
test("widening the side bar never squeezes the editor groups past their minimums", async ({ page }) => { await tab(page, "Preview").locator("[part=close]").click(); await writeHtmlPage(page); await openBeside(page, "preview", "page.html"); const primary = stack(page, "primary"); const box = (await (await gutter(primary, "after")).boundingBox())!; await page.mouse.move(box.x, box.y + box.height / 2); await page.mouse.down(); await page.mouse.move(page.viewportSize()!.width - 1, box.y + box.height / 2, { steps: 6 }); await page.mouse.up(); const main = (await stack(page, "main").boundingBox())!; const group = (await stackWith(page, "Preview page.html").boundingBox())!; const terminal = (await stack(page, "panel").boundingBox())!; const secondary = (await stack(page, "secondary").boundingBox())!; // The iframe itself, not its tabpanel: a replaced element's intrinsic 300px width once overflowed a narrower panel. const frame = (await page.locator("nib-preview iframe").boundingBox())!; expect(main.width).toBeGreaterThanOrEqual(159); expect(group.width).toBeGreaterThanOrEqual(159); // The editor row and the terminal below it end at the same edge, left of the secondary side bar. expect(group.x + group.width).toBeCloseTo(terminal.x + terminal.width, 0); expect(frame.x + frame.width).toBeLessThanOrEqual(secondary.x + 0.5); expect(frame.width).toBeCloseTo(group.width, 0);});
test("a side bar's side edge joins it, and its bottom edge splits it no smaller than the minimum pane size", async ({ page }) => { await writeHtmlPage(page); await openBeside(page, "preview", "page.html"); const agent = stack(page, "secondary"); const target = content(agent); const box = (await target.boundingBox())!; // 30px in: the bar's outer edge is the window's, where letting go tears off instead. await tab(page, "Preview page.html").dragTo(target, { force: true, targetPosition: { x: box.width - 30, y: box.height / 2 } }); await expect(agent.locator("nib-tab")).toHaveCount(2); await settled(page); await holdAndDrop(page, tab(page, "Preview page.html"), target, { x: box.width / 2, y: box.height - 30 }); const previewStack = stackWith(page, "Preview page.html"); await expect(previewStack.locator("nib-tab")).toHaveCount(1); expect((await previewStack.boundingBox())!.height).toBeGreaterThanOrEqual(79); expect((await agent.boundingBox())!.height).toBeGreaterThanOrEqual(79); expect((await previewStack.boundingBox())!.x).toBeCloseTo((await agent.boundingBox())!.x, 0);});
test("the terminal drives the workbench with the same commands as the GUI", async ({ page }) => { await expect(terminalLog(page)).toContainText("~ $", { timeout: 20_000 }); await run(page, "open package.json"); await expect(tab(page, "package.json")).toHaveJSProperty("selected", true); await run(page, "nope"); await expect(terminalLog(page)).toContainText("nope: command not found");});
test("code fields turn off autocomplete, autocorrect, and suggestions; the agent's prose field keeps them", async ({ page }) => { const assist = (name: string) => page.getByRole("textbox", { name }).evaluate((el: Element) => ({ spellcheck: (el as HTMLElement).spellcheck, autocomplete: el.getAttribute("autocomplete"), autocapitalize: el.getAttribute("autocapitalize"), autocorrect: el.getAttribute("autocorrect"), writingsuggestions: el.getAttribute("writingsuggestions"), })); const off = { spellcheck: false, autocomplete: "off", autocapitalize: "off", autocorrect: "off", writingsuggestions: "false" }; // index.ts is CodeMirror's contenteditable content element: `autocomplete` isn't a meaningful // attribute there (it's an <input>/<textarea> attribute), so `<nib-code>` never sets it. expect(await assist("index.ts")).toEqual({ ...off, autocomplete: null }); expect(await assist("Terminal input")).toEqual(off); expect(await assist("Message")).toMatchObject({ spellcheck: true, autocorrect: null });});
test("agent messages are written to the conversation's transcript file", async ({ page }) => { await page.getByRole("textbox", { name: "Message" }).fill("hello"); await page.getByRole("button", { name: "Send" }).click(); await expect(page.locator("nib-agent li")).toHaveText([/hello$/]);});