Files
LLeMbas-Desktop/tests/ui/run.mjs
T
HomerandClaude Opus 5.5 89c769c806
ci / ui (push) Waiting to run
ci / check (push) Failing after 3s
ci / start (push) Failing after 3s
Subagents (C4, ahead of the server): the 3.4.0 tree as rows, not yet in the panel
ui/js/tree.js draws subagents.md §12's data: the main agent at the root, each
subagent under its parent in the order added (name, id, position, state in
team.read.states' words, model, mode with request options, context % and a
meter, its progress line, its room messages and the last one), open slots, and
the ended ones folded apart, dimmed, with how each ended; open its session,
its findings, stop a live one. Not wired into the activity panel yet: the
server's /api/v2 does not carry the tree, the transcript or the findings yet.

tests/ui: the spec's own example tree, drawn and acted on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 09:38:52 +00:00

723 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// The page under a headless browser, against a mocked bridge (tests/ui/mock-tauri.js): what the
// person sees for a given answer of the server's /api/v2, and what the page sends back.
//
// node tests/ui/run.mjs [name-filter]
//
// Needs Playwright (`playwright` or `playwright-core`, from NODE_PATH or a node_modules beside
// the repository) and a Chromium: Playwright's own, or the one in $CHROMIUM (/usr/bin/chromium
// is tried when Playwright has none). Nothing is built and no server is started: the page's
// files are handed to the browser from ui/ as they are in the repository. With $SHOTS set to a
// directory, some tests leave a screenshot there.
//
// The shapes are the server's (`api/v2/serialise.py`, its fixtures in tests/fixtures/api_v2),
// with made-up names. Where the server's fixture differs from what its code sends, the test
// says so and follows the code.
import { createRequire } from "node:module"
import { readFileSync, existsSync } from "node:fs"
import { dirname, join, extname } from "node:path"
import { fileURLToPath } from "node:url"
import assert from "node:assert/strict"
const HERE = dirname(fileURLToPath(import.meta.url))
const UI = join(HERE, "..", "..", "ui")
const ORIGIN = "http://app.test"
const require = createRequire(join(process.cwd(), "noop.js"))
function playwright() {
for (const name of ["playwright", "playwright-core"]) {
try {
return require(name)
} catch {}
try {
return createRequire(import.meta.url)(name)
} catch {}
}
console.error("tests/ui: Playwright is not installed (npm install playwright-core, and put it on NODE_PATH)")
process.exit(2)
}
async function launch() {
const { chromium } = playwright()
const paths = [process.env.CHROMIUM, "/usr/bin/chromium", "/usr/bin/chromium-browser"].filter(Boolean)
if (!process.env.CHROMIUM) {
try {
return await chromium.launch()
} catch {}
}
for (const executablePath of paths) if (existsSync(executablePath)) return chromium.launch({ executablePath })
throw new Error("tests/ui: no Chromium found (set CHROMIUM)")
}
const TYPES = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".mjs": "text/javascript", ".json": "application/json" }
// --- The server, as the tests' table -----------------------------------------------------------
const STAMP = "2026-01-02T03:04:05+00:00"
function status(over = {}) {
return {
version: "1.0.0-test",
server: "https://ai.example",
discovery: { name: "Example", app: "https://ai.example/api/v2", version: "2.0.0" },
refused: null,
checked: true,
account: { name: "Ada Example", email: "ada@example.org" },
signing_in: false,
linked: true,
agent: { state: "running", detail: null },
agent_wanted: true,
update: null,
remote: { enabled: true, roots: ["/home/dev/project"] },
theme: "moria",
host: "workbench",
notify: false,
...over,
}
}
const SUB = (offered, named_only = false) => ({ offered, named_only })
/** The models as `/api/v2/models` lists them; `subagents` per model as the server works it out. */
function models({ qwen = SUB(true), solo = SUB(true, true), plain = SUB(false) } = {}) {
const base = { vision: false, tools: true, efforts: ["low", "medium", "high"], thinking_switch: false, levels: ["low", "medium", "high"], effort: "medium", control: "slider", context: 131072, image: null }
return {
models: [
{ ...base, id: "qwen", name: "Qwen", parallel: 2, default: true, subagents: qwen },
{ ...base, id: "solo", name: "Solo", parallel: 1, default: false, subagents: solo },
{ ...base, id: "plain", name: "Plain", parallel: 1, default: false, subagents: plain },
],
subagents_cap: 8,
}
}
function chatRow(over = {}) {
return {
id: "chat1",
kind: "chat",
title: "Waybread",
model: "qwen",
folder_id: null,
pinned: false,
archived: false,
temporary: false,
unread: false,
running: false,
mode: "",
effort: "medium",
device: null,
device_session_id: null,
parent_chat_id: null,
agents: { min: 1, max: 4 },
subagents: { offered: true, named_only: false, cap: 8 },
created_at: STAMP,
updated_at: STAMP,
...over,
}
}
function agentChat(over = {}) {
return chatRow({
id: "chat2",
kind: "agent",
title: "Tidy the build",
running: true,
mode: "edit",
device: { token_id: "token1", directory: "/home/dev/project" },
device_session_id: "ses_01",
// An agent chat's team runs on its device: the server offers no setting (team_run.may_have_team).
subagents: { offered: false, named_only: false, cap: 8 },
...over,
})
}
const userMessage = (id, text) => ({ id, role: "user", created_at: STAMP, complete: true, stopped: false, error: null, queued: false, machine: false, origin: "", model: null, attachments: [], text })
/**
* A device's bash approval as `services/device_link.py:_permission` builds it and
* `api/v2/serialise.py:interruption` sends it: the title is the fixed "Run a command", the command
* is in `detail`, and it is `editable`. (The server's fixture `event_ask.json` puts the command in
* the title; its code does not.)
*/
function deviceBashCard(over = {}) {
return {
id: "card7",
kind: "approval",
why: "",
dismissable: false,
expires_in: 1800,
items: [
{
index: 0,
key: "0",
kind: "approval",
tool: "bash",
title: "Run a command",
header: "",
detail: "ls -la build",
reason: "Edit mode asks before anything that runs a command.",
purpose: "See what is in the build directory",
options: [],
multiple: false,
free_text: true,
editable: true,
plan: null,
mode_choice: false,
job: "",
},
],
...over,
}
}
function baseRoutes(over = {}) {
return {
"GET /me": { id: "u1", name: "Ada Example", email: "ada@example.org", theme: "moria", language: "en", permissions: {} },
"GET /models": models(),
"GET /devices": { devices: [{ id: "token1", name: "workbench", host: "workbench", platform: "linux", terminal: true }] },
"GET /chats": { chats: [chatRow(), agentChat()], more: false },
"GET /schedules": { schedules: [] },
...over,
}
}
// --- The browser --------------------------------------------------------------------------------
let browser
async function page(config, { width = 1280, height = 860 } = {}) {
const context = await browser.newContext({ viewport: { width, height } })
const p = await context.newPage()
const errors = []
p.on("pageerror", (e) => errors.push(String(e)))
await context.route(`${ORIGIN}/**`, (route) => {
const path = new URL(route.request().url()).pathname
const file = join(UI, path === "/" ? "index.html" : decodeURIComponent(path))
if (!file.startsWith(UI) || !existsSync(file)) return route.fulfill({ status: 404, body: "" })
return route.fulfill({ status: 200, contentType: TYPES[extname(file)] || "application/octet-stream", body: readFileSync(file) })
})
await p.addInitScript((c) => {
window.__mockConfig = c
}, config)
await p.addInitScript({ path: join(HERE, "mock-tauri.js") })
await p.goto(`${ORIGIN}/index.html`)
await p.waitForSelector("#shell:not([hidden])")
p.errors = errors
return p
}
async function openChatRow(p, id) {
await p.click(`.chatrow[data-id="${id}"]`)
await p.waitForSelector(`#messages .msg`)
}
// --- The tests ----------------------------------------------------------------------------------
const tests = []
const test = (name, fn) => tests.push({ name, fn })
const STREAM2 = "/chats/chat2/messages/m2/stream"
function agentChatOpen(frames, over = {}) {
return {
status: status(),
routes: baseRoutes({
"GET /chats/chat2": { chat: agentChat(), messages: [userMessage("m1", "What is in build?")], more: false, live: "m2" },
"GET /chats/chat1": { chat: chatRow(), messages: [userMessage("m5", "How is lembas made?")], more: false, live: null },
"POST /chats/chat2/interactions/card8": { answered: true, remembered: false, not_remembered: [] },
"GET /models?chat=chat2": models(),
"POST /chats/chat2/interactions/card7": { answered: true, remembered: false, not_remembered: [] },
...over,
}),
streams: { [STREAM2]: frames },
}
}
test("A1: an agent chat's device bash approval shows as a card, the command editable, and Allow answers it", async () => {
const card = deviceBashCard()
const p = await page(
agentChatOpen([
["steps", { steps: [], tail: { thinking: false, text: "", label: "" } }],
["status", { text: "Waiting for you to allow bash…" }],
["ask", { cards: [card] }],
]),
)
await openChatRow(p, "chat2")
await p.waitForSelector("#cards:not([hidden]) .ask")
assert.equal(await p.textContent(".msg.is-live .msg__status"), "Waiting for you to allow bash…")
const cardBox = await p.locator("#cards .ask").boundingBox()
const view = p.viewportSize()
assert.ok(cardBox && cardBox.height > 40, "the card has a size")
assert.ok(cardBox.y >= 0 && cardBox.y + 20 <= view.height, `the card is on the screen (y=${cardBox.y})`)
assert.ok(await p.locator("#cards .ask").isVisible(), "the card is visible")
// The command is the item's `detail`, in the box to correct it; the title is not the command.
assert.equal(await p.inputValue("#cards .ask__edit"), "ls -la build")
const text = await p.textContent("#cards .ask")
assert.match(text, /bash/)
assert.match(text, /See what is in the build directory/, "the model's purpose is said")
assert.match(text, /Edit mode asks before anything that runs a command\./, "the server's reason is said")
if (process.env.SHOTS) await p.screenshot({ path: join(process.env.SHOTS, "a1-card.png") })
await p.click("#cards .ask__actions .btn--primary")
await p.waitForFunction(() => window.__mock.api("POST", "/chats/chat2/interactions/").length === 1)
const [post] = await p.evaluate(() => window.__mock.api("POST", "/chats/chat2/interactions/"))
assert.equal(post.args.path, "/chats/chat2/interactions/card7")
assert.deepEqual(post.args.body, { verdict: "allow", choices: {}, texts: {} }, "an untouched command is not sent as an edit")
await p.waitForSelector("#cards", { state: "hidden" })
assert.deepEqual(p.errors, [])
})
test("A1: a corrected command is sent as the item's text; Deny sends deny", async () => {
const p = await page(agentChatOpen([["ask", { cards: [deviceBashCard()] }]]))
await openChatRow(p, "chat2")
await p.waitForSelector("#cards .ask__edit")
await p.fill("#cards .ask__edit", "ls -la build/out")
await p.click("#cards .ask__actions .btn--primary")
await p.waitForFunction(() => window.__mock.api("POST", "/chats/chat2/interactions/").length === 1)
const [post] = await p.evaluate(() => window.__mock.api("POST", "/chats/chat2/interactions/"))
assert.deepEqual(post.args.body, { verdict: "allow", choices: {}, texts: { 0: "ls -la build/out" } })
// The next one, refused.
await p.evaluate(([path, c]) => window.__mock.frame(path, "ask", { cards: [c] }), [STREAM2, deviceBashCard({ id: "card8" })])
await p.waitForSelector("#cards .ask__edit")
await p.click("#cards .ask__actions .btn >> text=Deny")
await p.waitForFunction(() => window.__mock.api("POST", "/chats/chat2/interactions/card8").length === 1)
const [deny] = await p.evaluate(() => window.__mock.api("POST", "/chats/chat2/interactions/card8"))
assert.deepEqual(deny.args.body, { verdict: "deny", choices: {}, texts: {} })
assert.deepEqual(p.errors, [])
})
test("A1: a card that arrives while the person is typing keeps what they typed and is not drawn again by an unchanged frame", async () => {
const card = deviceBashCard()
const p = await page(agentChatOpen([["ask", { cards: [card] }]]))
await openChatRow(p, "chat2")
await p.waitForSelector("#cards .ask__edit")
await p.fill("#cards .ask__edit", "ls build")
// The server sends the frame again when anything in it changes -- here only the countdown.
await p.evaluate(([path, c]) => window.__mock.frame(path, "ask", { cards: [{ ...c, expires_in: 1790 }] }), [STREAM2, card])
await p.evaluate(([path]) => window.__mock.frame(path, "status", { text: "Waiting for you to allow bash…" }), [STREAM2])
assert.equal(await p.inputValue("#cards .ask__edit"), "ls build")
assert.equal(await p.locator("#cards .ask").count(), 1)
// A second card joins the first without wiping it; the answered one goes.
await p.evaluate(([path, c]) => window.__mock.frame(path, "ask", { cards: [c, { ...c, id: "card8", items: [{ ...c.items[0], detail: "make clean" }] }] }), [STREAM2, card])
assert.equal(await p.locator("#cards .ask").count(), 2)
assert.equal(await p.inputValue("#cards .ask >> nth=0 >> .ask__edit"), "ls build")
await p.evaluate(([path]) => window.__mock.frame(path, "ask", { cards: [] }), [STREAM2])
await p.waitForSelector("#cards", { state: "hidden" })
assert.deepEqual(p.errors, [])
})
test("A1: reopening a chat whose reply waits shows the card from the stream's first pass", async () => {
const p = await page(agentChatOpen([["status", { text: "Waiting for you to allow bash…" }], ["ask", { cards: [deviceBashCard()] }]]))
await openChatRow(p, "chat2")
await p.waitForSelector("#cards .ask")
// Away to another chat and back: the stream is opened again and its first pass carries the card.
await p.click('.chatrow[data-id="chat1"]').catch(() => {})
await p.waitForFunction(() => !document.querySelector("#cards .ask"))
await openChatRow(p, "chat2")
await p.waitForSelector("#cards:not([hidden]) .ask")
assert.equal(await p.evaluate(() => window.__mock.calls.filter((c) => c.cmd === "stream_open" && c.args.path.includes("/messages/")).length), 2)
assert.deepEqual(p.errors, [])
})
test("A1: a card at 390 px wide is on the screen and its buttons can be reached", async () => {
const p = await page(agentChatOpen([["ask", { cards: [deviceBashCard()] }]]), { width: 390, height: 844 })
await p.evaluate(() => document.querySelector('.chatrow[data-id="chat2"]').click())
await p.waitForSelector("#cards:not([hidden]) .ask")
const allow = await p.locator("#cards .ask__actions .btn--primary").boundingBox()
assert.ok(allow && allow.y + allow.height <= 844 && allow.x >= 0 && allow.x + allow.width <= 390, `Allow is on the screen: ${JSON.stringify(allow)}`)
assert.ok(await p.evaluate(() => document.documentElement.scrollWidth <= 390), "nothing scrolls sideways")
assert.deepEqual(p.errors, [])
})
// --- A5: the Subagents setting ----------------------------------------------------------------
//
// The server decides (`services/team_run.py:offered`, `services/subagents.py:view`): subagents
// enabled on the instance, the person's permission, a model that calls tools, and some model that
// could serve one -- the chat's own serving two requests at once, or another off its one-model
// server (`named_only`: then the setting says a subagent needs a model named in the chat). It says
// so per model in `/models` (a new chat) and per chat in `/chats/{id}` (`subagents`). The web page
// reads the same function (`api/pages.py:_team_context`). The app shows what it is told.
async function newChatOn(p, kind, modelName) {
await p.click("#new-chat-btn")
await p.click(`.kind__tab >> text=${kind}`)
if (modelName) {
await p.click(".cchip--model")
await p.locator(".menu .menu__item", { hasText: modelName }).click()
}
}
async function subagentsChip(p) {
// The chip comes after the others (an open chat's waits for the server's word).
await p.waitForTimeout(150)
return p.locator("#composer-chips .cchip--subagents")
}
async function openSlider(p) {
await p.click("#composer-chips .cchip--subagents")
await p.waitForSelector(".range2")
return {
min: await p.getAttribute(".range2__input--min", "max"),
text: await p.textContent(".pop__body"),
}
}
const OWN = "This model serves one request at a time: subagents need another model, on another server, named in the chat."
function a5(routes = {}) {
return { status: status(), routes: baseRoutes(routes), streams: {} }
}
test("A5: a new chat on a model serving two at once offers Subagents, Min and Max to the server's cap", async () => {
const p = await page(a5())
await newChatOn(p, "Chat")
const chip = await subagentsChip(p)
assert.equal(await chip.count(), 1, "the chip is there")
const s = await openSlider(p)
assert.equal(s.min, "8", "up to the server's cap")
assert.ok(!s.text.includes(OWN), "no one-request note")
assert.deepEqual(p.errors, [])
})
test("A5: a new chat on a one-request model the server still offers: Subagents with the note", async () => {
const p = await page(a5())
await newChatOn(p, "Chat", "Solo")
const chip = await subagentsChip(p)
assert.equal(await chip.count(), 1)
const s = await openSlider(p)
assert.ok(s.text.includes(OWN), "the one-request note")
assert.deepEqual(p.errors, [])
})
test("A5: subagents off on the instance (offered false for every model): no Subagents", async () => {
const off = SUB(false)
const p = await page(a5({ "GET /models": models({ qwen: off, solo: off, plain: off }) }))
await newChatOn(p, "Chat")
assert.equal(await (await subagentsChip(p)).count(), 0)
await newChatOn(p, "Chat", "Solo")
assert.equal(await (await subagentsChip(p)).count(), 0)
assert.deepEqual(p.errors, [])
})
test("A5: subagents switched on while the app stays open: the next new chat offers them", async () => {
const off = SUB(false)
const p = await page(a5({ "GET /models": models({ qwen: off, solo: off, plain: off }) }))
await newChatOn(p, "Chat")
assert.equal(await (await subagentsChip(p)).count(), 0, "off at sign-in")
// The administrator switches subagents on; the app read /models at sign-in.
await p.evaluate((m) => (window.__mockConfig.routes["GET /models"] = m), models())
await p.waitForTimeout(5200)
await p.click("#new-chat-btn")
await p.waitForSelector("#composer-chips .cchip--subagents")
assert.deepEqual(p.errors, [])
})
test("A5: an open chat follows its own `subagents`: offered, offered with the note, not offered", async () => {
for (const [said, chip, note] of [
[{ offered: true, named_only: false, cap: 8 }, 1, false],
[{ offered: true, named_only: true, cap: 8 }, 1, true],
[{ offered: false, named_only: false, cap: 8 }, 0, false],
]) {
const p = await page(
a5({
"GET /chats/chat1": { chat: chatRow({ subagents: said }), messages: [userMessage("m5", "How is lembas made?")], more: false, live: null },
"GET /models?chat=chat1": models(),
}),
)
await openChatRow(p, "chat1")
const c = await subagentsChip(p)
assert.equal(await c.count(), chip, `chip for ${JSON.stringify(said)}`)
if (chip) assert.equal((await openSlider(p)).text.includes(OWN), note)
assert.deepEqual(p.errors, [])
await p.context().close()
}
})
// --- B3: the Terminal panel -------------------------------------------------------------------
//
// The server's two sockets for an app (`api/v2/terminal.py`): an agent chat's
// (`/chats/{chat}/terminal/ws`) and one before the chat exists, on the device and folder chosen
// (`/devices/{device}/terminal/ws?directory=`), which the chat's first message takes over.
// `GET /devices` says per device whether it opens a terminal. The socket is Rust's: the page asks
// `terminal_open` and gets the frames as `terminal` events (mock-tauri.js plays the server).
const READY = ["control", { t: "ready", label: "workbench", dir: "/home/dev/project", cols: 80, rows: 24, shared: false, osc: false }]
function b3(over = {}, terminal = [READY, ["output", "dev@workbench:~/project$ "]]) {
return {
status: status(),
routes: baseRoutes({
"GET /chats/chat2": { chat: agentChat({ running: false }), messages: [userMessage("m1", "What is in build?")], more: false, live: null },
"GET /models?chat=chat2": models(),
"POST /chats": { chat: agentChat({ running: false }), message: userMessage("m9", "Build it"), reply: null },
...over,
}),
streams: {},
terminal,
}
}
async function screenText(p) {
return p.evaluate(() => document.querySelector("#terminal .xterm-rows")?.textContent || "")
}
test("B3: a new agent chat offers the Terminal once its folder is chosen, and opens a shell there before the first message", async () => {
const p = await page(b3())
await p.click("#new-chat-btn")
await p.click(".kind__tab >> text=Agent")
await p.waitForSelector("#composer-chips .cchip--terminal")
assert.equal(await p.evaluate(() => window.__mock.terminals().length), 0, "nothing is opened until asked")
await p.click("#composer-chips .cchip--terminal")
await p.waitForSelector("#terminal:not([hidden]) .xterm")
await p.waitForFunction(() => window.__mock.terminals().length === 1)
const [shell] = await p.evaluate(() => window.__mock.terminals())
assert.deepEqual(shell.target, { chat: null, device: "token1", directory: "/home/dev/project" })
await p.waitForFunction(() => document.querySelector("#terminal .xterm-rows")?.textContent.includes("dev@workbench"))
assert.equal(await p.textContent("#terminal-message"), "Connected.")
assert.equal(await p.textContent("#terminal-title"), "workbench")
assert.equal(await p.getAttribute("#composer-chips .cchip--terminal", "aria-pressed"), "true")
// Keys go up through the bridge, never a socket of the page's own.
await p.click("#terminal .xterm")
await p.keyboard.type("ls")
await p.keyboard.press("Enter")
await p.waitForFunction(() => window.__mock.typed().includes("ls\r"))
assert.equal(await p.evaluate(() => window.__mock.calls.filter((c) => c.cmd === "terminal_open").length), 1)
if (process.env.SHOTS) await p.screenshot({ path: join(process.env.SHOTS, "b3-new-1280.png") })
// The first message makes the chat, which takes the shell over: the same socket goes on.
await p.click("#composer-text")
await p.fill("#composer-text", "Build it")
await p.click("#send")
await p.waitForFunction(() => window.__mock.api("POST", "/chats").length === 1)
const [made] = await p.evaluate(() => window.__mock.api("POST", "/chats"))
assert.equal(made.args.body.device, "token1")
assert.equal(made.args.body.directory, "/home/dev/project")
await p.waitForSelector('#chat-tools [data-terminal-toggle][aria-pressed="true"]')
assert.ok(await p.isVisible("#terminal"), "the panel stays open")
const shells = await p.evaluate(() => window.__mock.terminals())
assert.equal(shells.length, 1, "no second shell: the chat took the first one over")
assert.ok(shells[0].open, "and its socket is still open")
assert.deepEqual(p.errors, [])
})
test("B3: an open agent chat opens its own shell from the header; Send to chat fills the composer and sends nothing", async () => {
const p = await page(b3())
await openChatRow(p, "chat2")
await p.click('#chat-tools [data-terminal-toggle]')
await p.waitForFunction(() => window.__mock.terminals().length === 1)
assert.deepEqual((await p.evaluate(() => window.__mock.terminals()))[0].target, { chat: "chat2", device: null, directory: null })
await p.evaluate(() => window.__mock.shell("output", "\r\ntotal 4\r\n-rw-r--r-- 1 dev dev 12 build.log\r\n"))
await p.waitForFunction(() => document.querySelector("#terminal .xterm-rows")?.textContent.includes("build.log"))
await p.fill("#composer-text", "Look at this:")
await p.click("#terminal-send")
const text = await p.inputValue("#composer-text")
assert.ok(text.startsWith("Look at this:\n\n```\n"), `after what was typed, as a block: ${JSON.stringify(text.slice(0, 40))}`)
assert.match(text, /build\.log/)
assert.equal(await p.evaluate(() => window.__mock.api("POST", "/chats/chat2/messages").length), 0, "nothing is sent")
// Closing the panel keeps the shell; opening it again does not open another.
await p.click("#terminal-close")
assert.ok(await p.isHidden("#terminal"))
await p.click('#chat-tools [data-terminal-toggle]')
await p.waitForSelector("#terminal:not([hidden])")
assert.equal(await p.evaluate(() => window.__mock.terminals().length), 1)
// The server ends it: said, and no new shell in its place.
await p.evaluate(() => window.__mock.shell("control", { t: "closed", message: "The shell exited." }))
await p.waitForFunction(() => document.querySelector("#terminal-message").textContent === "The shell exited.")
assert.equal(await p.evaluate(() => window.__mock.terminals().length), 1)
assert.deepEqual(p.errors, [])
})
test("B3: the terminal and the activity panel take turns in the panel column", async () => {
const p = await page(b3({ "GET /chats/chat2/activity": { activity: {} } }))
await openChatRow(p, "chat2")
await p.click('#chat-tools [data-terminal-toggle]')
await p.waitForSelector("#terminal:not([hidden])")
assert.equal(await p.getAttribute("#shell", "data-terminal"), "true")
await p.click("#activity-open")
await p.waitForSelector("#activity:not([hidden])")
assert.ok(await p.isHidden("#terminal"), "the terminal gave way")
assert.equal(await p.getAttribute("#shell", "data-terminal"), "false")
await p.click('#chat-tools [data-terminal-toggle]')
await p.waitForSelector("#terminal:not([hidden])")
assert.ok(await p.isHidden("#activity"), "and the activity panel to it")
assert.equal(await p.evaluate(() => window.__mock.terminals().length), 1, "the shell lived on behind it")
})
test("B3: no Terminal where the device opens none, for a chat on the server, or before a folder", async () => {
const off = { "GET /devices": { devices: [{ id: "token1", name: "workbench", host: "workbench", platform: "linux", terminal: false }] } }
let p = await page(b3(off))
await p.click("#new-chat-btn")
await p.click(".kind__tab >> text=Agent")
await p.waitForSelector("#composer-chips .cchip")
assert.equal(await p.locator("#composer-chips .cchip--terminal").count(), 0, "the device opens no terminal")
await openChatRow(p, "chat2")
assert.equal(await p.locator("#chat-tools [data-terminal-toggle]").count(), 0)
await p.context().close()
p = await page(b3({}))
await p.click("#new-chat-btn")
await p.click(".kind__tab >> text=Chat")
await p.waitForSelector("#composer-chips .cchip")
assert.equal(await p.locator("#composer-chips .cchip--terminal").count(), 0, "a chat on the server has no shell")
await p.context().close()
p = await page({ ...b3({}), status: status({ remote: { enabled: true, roots: [] } }) })
await p.click("#new-chat-btn")
await p.click(".kind__tab >> text=Agent")
await p.waitForSelector("#composer-chips .cchip")
assert.equal(await p.locator("#composer-chips .cchip--terminal").count(), 0, "no folder yet")
assert.deepEqual(p.errors, [])
})
test("B3: a refused sign-in and an older server are said in words", async () => {
let p = await page(b3({}, [["end", { code: 4401, reason: "not allowed", status: null, error: null }]]))
await openChatRow(p, "chat2")
await p.click('#chat-tools [data-terminal-toggle]')
await p.waitForFunction(() => /Sign in again/.test(document.querySelector("#terminal-message").textContent))
assert.ok(await p.evaluate(() => document.querySelector("#terminal-message").classList.contains("is-error")))
await p.context().close()
p = await page(b3({}, [["end", { code: null, reason: "", status: 404, error: null }]]))
await openChatRow(p, "chat2")
await p.click('#chat-tools [data-terminal-toggle]')
await p.waitForFunction(() => /needs a newer LLeMbas server/.test(document.querySelector("#terminal-message").textContent))
assert.deepEqual(p.errors, [])
})
test("B3: at 390 px the terminal is a sheet over the chat, inside the window", async () => {
const p = await page(b3(), { width: 390, height: 844 })
await p.evaluate(() => document.querySelector('.chatrow[data-id="chat2"]').click())
await p.waitForSelector('#chat-tools [data-terminal-toggle]')
await p.click('#chat-tools [data-terminal-toggle]')
await p.waitForFunction(() => document.querySelector("#terminal .xterm-rows")?.textContent.includes("dev@workbench"))
const box = await p.locator("#terminal").boundingBox()
assert.ok(box.x >= 0 && box.x + box.width <= 390 && box.y + box.height <= 844, `inside: ${JSON.stringify(box)}`)
const send = await p.locator("#terminal-send").boundingBox()
assert.ok(send.x + send.width <= 390 && send.y + send.height <= 844, `Send is reachable: ${JSON.stringify(send)}`)
const close = await p.locator("#terminal-close").boundingBox()
assert.ok(close.x + close.width <= 390, "Close is reachable")
assert.ok(await p.evaluate(() => document.documentElement.scrollWidth <= 390), "nothing scrolls sideways")
if (process.env.SHOTS) await p.screenshot({ path: join(process.env.SHOTS, "b3-chat-390.png") })
// Send to chat: the sheet steps aside, and the message box has the block.
await p.click("#terminal-send")
assert.ok(await p.isHidden("#terminal"))
assert.match(await p.inputValue("#composer-text"), /dev@workbench/)
assert.deepEqual(p.errors, [])
})
test("B3: at 1280 px the terminal sits beside the chat, which keeps its column", async () => {
const p = await page(b3())
await openChatRow(p, "chat2")
await p.click('#chat-tools [data-terminal-toggle]')
await p.waitForFunction(() => document.querySelector("#terminal .xterm-rows")?.textContent.includes("dev@workbench"))
const term = await p.locator("#terminal").boundingBox()
const main = await p.locator("#main").boundingBox()
assert.ok(term.x >= main.x + main.width - 1, "beside, not over")
assert.ok(main.width >= 400, `the chat keeps room: ${main.width}`)
assert.ok(term.width >= 380, `the terminal has its width: ${term.width}`)
if (process.env.SHOTS) await p.screenshot({ path: join(process.env.SHOTS, "b3-chat-1280.png") })
assert.deepEqual(p.errors, [])
})
// --- B1: the progress line ---------------------------------------------------------------------
//
// Harness spec 3.4.0 (harness/progress.md in the LLeMbas CLI): one `progress` object, drawn by
// every client as `{{text}} · {{elapsed}}`. tests/ui/conformance/progress.json is a copy of the
// spec's conformance/progress.json (3.4.0); every case must come out word for word.
test("B1: every conformance case of the spec draws the same text and line", async () => {
const { cases } = JSON.parse(readFileSync(join(HERE, "conformance", "progress.json"), "utf8"))
const p = await page(b3())
const got = await p.evaluate(async (cs) => {
const m = await import("/js/progress.js")
return cs.map((c) => ({ text: m.progressText(c.progress, c.now), line: m.progressLine(c.progress, c.now) }))
}, cases)
cases.forEach((c, i) => assert.deepEqual(got[i], c.expect, c.about))
assert.deepEqual(p.errors, [])
})
test("B1: a reply being written draws the stream's progress, its clock moving, and the status text without one", async () => {
const p = await page(agentChatOpen([["status", { text: "Writing…" }]]))
await openChatRow(p, "chat2")
await p.waitForSelector(".msg.is-live .msg__status")
await p.evaluate((path) => {
window.__mock.frame(path, "status", { text: "Running bake --slow", progress: { phase: "running_tool", since: Date.now() - 65000, tool: "bash", what: "bake --slow" } })
}, STREAM2)
const line = () => p.textContent(".msg.is-live .msg__status")
assert.equal(await line(), "Running bash: bake --slow · 1m 05s")
assert.equal(await p.getAttribute(".msg.is-live .msg__status", "data-phase"), "running_tool")
await p.waitForFunction(() => document.querySelector(".msg.is-live .msg__status").textContent.endsWith("1m 06s"), null, { timeout: 2500 })
// A data-only frame with progress (the ask frame carries it too): the line follows.
await p.evaluate((path) => window.__mock.frame(path, "ask", { cards: [], progress: { phase: "waiting_for_person", since: Date.now(), what: "approval", tool: "bash" } }), STREAM2)
assert.equal(await line(), "Waiting for you: allow bash? · 0s")
// Nothing running: `progress: null`, and the status text is drawn again.
await p.evaluate((path) => window.__mock.frame(path, "status", { text: "Writing…", progress: null }), STREAM2)
assert.equal(await line(), "Writing…")
// An older server sends no progress at all: its status text, as before.
await p.evaluate((path) => window.__mock.frame(path, "status", { text: "Running bake --slow" }), STREAM2)
assert.equal(await line(), "Running bake --slow")
// Stopped and failed have no clock.
await p.evaluate((path) => window.__mock.frame(path, "status", { text: "Stopped.", progress: { phase: "stopped", since: Date.now() - 4000, reason: "person" } }), STREAM2)
assert.equal(await line(), "Stopped: by you")
assert.deepEqual(p.errors, [])
})
// --- C4 (ahead of the server): the subagent tree as spec 3.4.0 §12 gives it ---------------------
//
// ui/js/tree.js draws subagents.md §12's data; the activity panel takes it up once the server's
// /api/v2 carries the tree. Until then it is checked here on the spec's own example.
const TREE = {
min: 2, max: 4, size: 3, open_slots: 1,
agents: [
{ id: "lead", name: "lead", position: "the main agent", parent: null, state: "working", model: "qwen-27b", mode: "edit", request: [], manager: true, context: { used: 41200, window: 131072 }, progress: { phase: "thinking", since: 0 } },
{ id: "s1", name: "parser", position: "reviews the parser", parent: "lead", state: "working", model: "qwen-27b", mode: "manual", request: ["write"], manager: true, task: "Read src/parse.ts", context: { used: 9100, window: 131072 }, progress: { phase: "running_tool", since: 0, tool: "read", what: "src/parse.ts" }, findings: 2, messages: 3 },
{ id: "s3", name: "lexer", position: "the lexer's tests", parent: "s1", state: "waiting", model: "qwen-27b", mode: "manual", request: [], manager: false, context: { used: 0, window: 131072 }, progress: null, findings: 0, messages: 0 },
],
ended: [{ id: "s0", name: "scout", position: "maps the repository", parent: "lead", state: "out_of_context", ended: 0, findings: 1 }],
}
test("C4: the tree in order, each agent's position, state, mode, context and phase; slots; the ended ones apart", async () => {
const p = await page(b3())
const got = await p.evaluate(async (tree) => {
const m = await import("/js/tree.js")
const calls = []
const box = m.treePart(tree, { room: [{ n: 1, from: "lead", to: ["parser"], text: "Start with the grammar.\nThen the lexer." }], now: 65000, act: { open: (a) => calls.push(["open", a.id]), findings: (a) => calls.push(["findings", a.id]), stop: (a) => calls.push(["stop", a.id]) } })
document.body.append(box)
const rows = [...box.querySelectorAll(".subagents__list > .subagent:not(.subagent--slot)")].map((r) => ({ id: r.dataset.id, depth: r.style.getPropertyValue("--depth"), text: r.textContent }))
box.querySelector('[data-id="s1"] .subagent__actions .btn').click()
box.querySelector('[data-id="s0"] .subagent__actions .btn:last-child').click()
box.querySelector('[data-id="s1"] .agent__stop').click()
return { rows, slots: box.querySelectorAll(".subagent--slot").length, endedStop: box.querySelectorAll('[data-id="s0"] button.agent__stop').length, calls }
}, TREE)
assert.deepEqual(got.rows.map((r) => [r.id, r.depth]), [["lead", "0"], ["s1", "1"], ["s3", "2"], ["s0", "0"]])
const parser = got.rows[1].text
assert.match(parser, /parser \(s1\) — reviews the parser/)
assert.match(parser, /6%/, "context: floor(9100 × 100 / 131072)")
assert.match(parser, /manual \+ write/)
assert.match(parser, /Running read: src\/parse\.ts · 1m 05s/)
assert.match(parser, /Messages: 3/)
assert.match(parser, /lead: Start with the grammar\./)
assert.match(got.rows[2].text, /waiting for a slot/)
assert.match(got.rows[3].text, /out of context/)
assert.equal(got.slots, 1)
assert.equal(got.endedStop, 0, "an ended subagent cannot be stopped")
assert.deepEqual(got.calls, [["open", "s1"], ["findings", "s0"], ["stop", "s1"]])
assert.deepEqual(p.errors, [])
})
// --- Run ----------------------------------------------------------------------------------------
const only = process.argv[2] || ""
browser = await launch()
let failed = 0
for (const { name, fn } of tests) {
if (only && !name.includes(only)) continue
try {
await fn()
console.log(`ok ${name}`)
} catch (err) {
failed++
console.log(`FAIL ${name}\n ${String(err?.stack || err).split("\n").slice(0, 6).join("\n ")}`)
}
}
await browser.close()
console.log(failed ? `tests/ui: ${failed} failed` : "tests/ui: all passed")
process.exit(failed ? 1 : 0)