// The slopbox Shell tab: a bash prompt on the app's own VFS (`/?app=`). // Driven like a user — click the tab, type with page.keyboard — never through the // `__slopboxShell` test hook's internals beyond `readBuffer()`/`state()`. import { test, expect, type Page } from "@playwright/test"; type ShellHook = { readBuffer(): string; state(): string }; const shellBuffer = (page: Page) => page.evaluate(() => (globalThis as unknown as { __slopboxShell: ShellHook }).__slopboxShell.readBuffer()); const shellState = (page: Page) => page.evaluate(() => (globalThis as unknown as { __slopboxShell: ShellHook }).__slopboxShell.state()); /** Wait for the app to leave `booting` (install done, or no install needed), open the * Shell tab and wait for its terminal to exist. */ async function openShell(page: Page, app: string): Promise { await page.goto(`/?app=${app}`); await expect(page.locator("#state")).not.toHaveAttribute("data-state", "booting", { timeout: 20_000 }); await page.locator("#tab-shell").click(); await expect.poll(() => page.evaluate(() => "__slopboxShell" in globalThis)).toBe(true); } // Waits for the previous line to finish (the session back in `idle`) before typing the // next one, so each line is its own submission rather than typeahead. async function type(page: Page, text: string): Promise { await expect.poll(() => shellState(page), { timeout: 10_000 }).toBe("idle"); await page.keyboard.type(text); await page.keyboard.press("Enter"); } async function waitFor(page: Page, text: string): Promise { await expect.poll(() => shellBuffer(page), { timeout: 10_000 }).toContain(text); } /** The line the terminal shows right after the prompt line that ran `command`. */ async function lineAfter(page: Page, command: string): Promise { const lines = (await shellBuffer(page)).split("\n"); for (let i = lines.length - 1; i >= 0; i--) if (lines[i]?.endsWith(`$ ${command}`)) return lines[i + 1]; return undefined; } test("the shell loads lazily: only after the tab is first chosen", async ({ page }) => { const requested: string[] = []; page.on("request", (r) => { if (/\/dist\/demo\/(shell\.js|bashkit\.wasm)$/.test(r.url())) requested.push(r.url()); }); await page.goto("/?app=crashy"); await expect(page.locator("#state")).not.toHaveAttribute("data-state", "booting", { timeout: 20_000 }); expect(requested).toEqual([]); await page.locator("#tab-shell").click(); await expect.poll(() => requested.filter((u) => u.endsWith("shell.js")).length).toBeGreaterThan(0); await expect.poll(() => requested.filter((u) => u.endsWith("bashkit.wasm")).length).toBeGreaterThan(0); }); test("cat, jq and ls see the app's own files", async ({ page }) => { await openShell(page, "hono"); await type(page, "cat package.json | jq -r .name"); await waitFor(page, "nib-fixture-hono"); await type(page, "ls node_modules | grep -c hono"); await waitFor(page, "2"); }); test("the shell and the app's own node share one VFS, both ways", async ({ page }) => { await openShell(page, "hono"); await type(page, "echo made-in-shell > made.txt"); await type(page, 'node -p \'require("fs").readFileSync("made.txt", "utf8").trim()\''); await waitFor(page, "made-in-shell"); await type(page, 'node -e \'require("fs").writeFileSync("from-node.txt", "hi\\n")\''); await type(page, "cat from-node.txt"); await waitFor(page, "hi"); }); test("output streams through a pipe as the process produces it", async ({ page }) => { await openShell(page, "hono"); await type(page, 'node -e \'let i = 0; const t = setInterval(() => { console.log("tick", i++); if (i === 3) clearInterval(t) }, 400)\' | grep tick'); await waitFor(page, "tick 0"); expect(await shellBuffer(page)).not.toContain("tick 2"); await waitFor(page, "tick 2"); }); test("^C on a foreground node process reaches it as SIGINT", async ({ page }) => { await openShell(page, "hono"); await type(page, "node -e 'setInterval(() => {}, 1000)'"); await expect.poll(() => shellState(page), { timeout: 10_000 }).toBe("foreground"); await page.keyboard.press("Control+C"); await type(page, "echo $?"); await waitFor(page, "130"); }); test("an interactive CLI runs through the shell on a pty", async ({ page }) => { await openShell(page, "clack"); await type(page, "node cli.mjs"); await waitFor(page, "What is your project called?"); await page.keyboard.type("shelldemo"); await page.keyboard.press("Enter"); await waitFor(page, "Pick a template"); await page.keyboard.press("ArrowDown"); await page.keyboard.press("Enter"); await waitFor(page, "Initialize a git repository?"); await page.keyboard.press("y"); await waitFor(page, "Scaffolded"); await type(page, "echo $?"); await waitFor(page, "0"); }); test("cd changes the prompt and $PWD", async ({ page }) => { await openShell(page, "hono"); await type(page, "cd node_modules"); await waitFor(page, "~/node_modules $"); await type(page, "pwd"); await waitFor(page, "/app/node_modules"); }); test("npm run through the guest npm", async ({ page }) => { // The brief's own `node -e` script doesn't run: the guest npm's run-script spawns a // real `node:child_process` child, and this runtime's child_process only runs a // script FILE (no eval flags — see run()'s ERR_NIB_UNSUPPORTED). `node hi.js` does. await openShell(page, "hono"); await type(page, "npm run nope"); await waitFor(page, 'npm error Missing script: "nope"'); await type(page, "echo $?"); await waitFor(page, "1"); await type(page, "echo 'console.log(42 * 2)' > hi.js"); await type(page, 'jq \'.scripts = {"hi": "node hi.js"}\' package.json > p.json && mv p.json package.json'); await type(page, "npm run hi"); await waitFor(page, "84"); }); test("npx on the esbuild app", async ({ page }) => { await openShell(page, "esbuild"); await type(page, "npx esbuild --version"); await waitFor(page, "0.28.2"); }); test("symlinks: readlink and ls -l both see the esbuild bin link", async ({ page }) => { await openShell(page, "esbuild"); await type(page, "readlink node_modules/.bin/esbuild"); await waitFor(page, "../esbuild-wasm/bin/esbuild"); await type(page, "ls -l node_modules/.bin | grep -c esbuild"); await waitFor(page, "1"); }); test("continuation: an open quote keeps reading, and a pasted heredoc runs whole", async ({ page }) => { await openShell(page, "hono"); await page.keyboard.type('echo "a'); await page.keyboard.press("Enter"); await expect.poll(() => shellBuffer(page)).toMatch(/>\s*$/); await page.keyboard.type('b"'); await page.keyboard.press("Enter"); await waitFor(page, "a"); await waitFor(page, "b"); // A pasted heredoc: xterm's bracketed-paste delivers one onData chunk with embedded \r. await page.evaluate(() => { const el = document.querySelector("#shell-screen textarea") as HTMLTextAreaElement; el.focus(); }); await page.keyboard.insertText("cat <<'EOF'\rx $HOME\rEOF\r"); await waitFor(page, "x $HOME"); }); test("a syntax error prints bash's message and sets $? to 2", async ({ page }) => { await openShell(page, "hono"); await type(page, "echo )"); await expect.poll(() => lineAfter(page, "echo )")).toBe("bash: parse error at line 1, column 6: syntax error near unexpected token `)'"); await type(page, "echo $?"); await expect.poll(() => lineAfter(page, "echo $?")).toBe("2"); }); test("keys typed while a line runs wait for the next prompt", async ({ page }) => { await openShell(page, "hono"); await type(page, "node -e 'setTimeout(() => console.log(\"slow\"), 800)' | cat"); await expect.poll(() => shellState(page)).toBe("running"); await page.keyboard.type("echo typed-ahead"); await page.keyboard.press("Enter"); await expect.poll(() => lineAfter(page, "echo typed-ahead")).toBe("typed-ahead"); const lines = (await shellBuffer(page)).split("\n"); expect(lines.indexOf("slow")).toBeLessThan(lines.findIndex((l) => l.endsWith("$ echo typed-ahead"))); });