import { 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", '\n\n
Hello from nibling
\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('\n\n
Changed
\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 /