Showing the panel connects a frame later (the panel needs its width first). A shell that ended before that frame -- reopened and closed by the server within one frame, as headless CI's slow frames made happen -- was quietly replaced by a new one, against "no reconnect of its own". The frame now connects only if no shell was open as the showing began. The cause of the B3 failures on GitHub since 94e903e; a test makes it happen every time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1017 lines
52 KiB
JavaScript
1017 lines
52 KiB
JavaScript
#!/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)")
|
||
}
|
||
|
||
/** One of the server's /api/v2 contract fixtures, as copied by scripts/fetch-fixtures.sh. */
|
||
const fixture = (name) => JSON.parse(readFileSync(join(HERE, "fixtures", `${name}.json`), "utf8"))
|
||
|
||
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: a shell that ends while its panel is being shown again is not replaced by a new one", 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)
|
||
await p.waitForFunction(() => document.querySelector("#terminal-message").textContent === "Connected.")
|
||
await p.click("#terminal-close")
|
||
// Shown again, and the shell ends before the panel has drawn its first frame.
|
||
await p.evaluate(() => {
|
||
document.querySelector("#chat-tools [data-terminal-toggle]").click()
|
||
window.__mock.shell("control", { t: "closed", message: "The shell exited." })
|
||
})
|
||
await p.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))))
|
||
await p.waitForTimeout(200)
|
||
assert.equal(await p.evaluate(() => window.__mock.terminals().length), 1, "no new shell in its place")
|
||
assert.equal(await p.textContent("#terminal-message"), "The shell exited.")
|
||
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")
|
||
// 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 p.textContent(".msg.is-live .msg__status"), "Running bake --slow")
|
||
// The server's status frame (event_status.json), its clock set 65 s back from now.
|
||
const status = fixture("event_status")
|
||
await p.evaluate(([path, f]) => {
|
||
window.__mock.frame(path, "status", { ...f, progress: { ...f.progress, since: Date.now() - 65000 } })
|
||
}, [STREAM2, status])
|
||
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 (event_status_idle.json: no text, `progress: null`): the plain words again.
|
||
await p.evaluate(([path, f]) => window.__mock.frame(path, "status", f), [STREAM2, fixture("event_status_idle")])
|
||
assert.equal(await line(), "Writing…")
|
||
// Waiting for a person's answer (event_status_person.json): the phase, not the text.
|
||
await p.evaluate(([path, f]) => window.__mock.frame(path, "status", { ...f, progress: { ...f.progress, since: Date.now() } }), [STREAM2, fixture("event_status_person")])
|
||
assert.equal(await line(), "Waiting for you: a question · 0s")
|
||
// Stopped and failed have no clock.
|
||
await p.evaluate(([path, f]) => window.__mock.frame(path, "status", f), [STREAM2, fixture("event_status_stopped")])
|
||
assert.equal(await line(), "Stopped: by you")
|
||
assert.deepEqual(p.errors, [])
|
||
})
|
||
|
||
// --- C4: subagents (spec 3.4.0) ---------------------------------------------------------------
|
||
//
|
||
// The server's /api/v2 (its fixtures, copied into tests/ui/fixtures): GET /chats/{chat}/tree ->
|
||
// {tree, room, live}, the stream's `tree` frame, a subagent's transcript and findings, POST
|
||
// .../stop; an agent chat's device away -> the kept tree (`live` false) and 409 for the rest;
|
||
// /models?kind=agent&device= for a new agent chat's Subagents; `subagent` on an approval item.
|
||
|
||
const ACTIVITY = { activity: { version: "v1", summary: { active: 0 }, agents: [], context: null, outputs: [], team: null, plan: null } }
|
||
|
||
function c4(over = {}) {
|
||
return b3({
|
||
"GET /chats/chat2/activity": ACTIVITY,
|
||
"GET /chats/chat2/tree": fixture("tree"),
|
||
"GET /chats/chat2/subagents/s2/transcript": fixture("subagent_transcript"),
|
||
"GET /chats/chat2/subagents/s2/findings": fixture("subagent_findings"),
|
||
"POST /chats/chat2/subagents/s2/stop": { stopped: ["s2"] },
|
||
...over,
|
||
})
|
||
}
|
||
|
||
async function openTree(p) {
|
||
await openChatRow(p, "chat2")
|
||
await p.click("#activity-open")
|
||
await p.waitForSelector("#activity .subagents .subagent")
|
||
}
|
||
|
||
const rows = (p) => p.evaluate(() => [...document.querySelectorAll("#activity .subagents__list > .subagent")].map((r) => [r.dataset.id, r.style.getPropertyValue("--depth")]))
|
||
|
||
test("C4: the activity panel draws the server's tree: nested by parent, slots, the ended ones apart", async () => {
|
||
const p = await page(c4())
|
||
await openTree(p)
|
||
assert.deepEqual(await rows(p), [["lead", "0"], ["s1", "1"], ["s2", "2"]])
|
||
const routes = await p.textContent('#activity .subagent[data-id="s2"]')
|
||
assert.match(routes, /routes/)
|
||
assert.match(routes, /writes the routes/)
|
||
assert.match(routes, /Running read: src\/api\.py/)
|
||
assert.match(routes, /6%/, "context: floor(9100 × 100 / 131072)")
|
||
assert.match(routes, /edit \+ execute/)
|
||
assert.match(routes, /messages: 1/)
|
||
assert.match(routes, /routes → area: Routes are in\./, "the last room message to or from it")
|
||
assert.match(await p.textContent('#activity .subagent[data-id="lead"]'), /the main agent/)
|
||
assert.match(await p.textContent("#activity .subagents"), /3 of 4/)
|
||
assert.match(await p.textContent("#activity .subagents"), /They run on the computer this chat runs on\./)
|
||
// The ended one, folded apart, with how it ended, and no stop square.
|
||
await p.click("#activity .subagents__ended > summary")
|
||
const scout = await p.textContent('#activity .subagents__ended .subagent[data-id="s3"]')
|
||
assert.match(scout, /done/)
|
||
assert.equal(await p.locator('#activity .subagent[data-id="s3"] button.agent__stop').count(), 0)
|
||
assert.equal(await p.locator('#activity .subagent[data-id="lead"] button.agent__stop').count(), 0, "the main agent is not stopped from here")
|
||
if (process.env.SHOTS) await p.screenshot({ path: join(process.env.SHOTS, "c4-tree-1280.png") })
|
||
assert.deepEqual(p.errors, [])
|
||
})
|
||
|
||
test("C4: a subagent's conversation and findings open read-only; Stop asks, then stops it", async () => {
|
||
const p = await page(c4())
|
||
await openTree(p)
|
||
await p.click('#activity .subagent[data-id="s2"] > summary')
|
||
await p.click('#activity .subagent[data-id="s2"] .subagent__buttons .btn >> text=Open its conversation')
|
||
await p.waitForSelector("#activity .convo .convo__turn")
|
||
const convo = await p.textContent("#activity-body")
|
||
assert.match(convo, /Write the routes\./)
|
||
assert.match(convo, /Reading the API first\./)
|
||
assert.match(convo, /src\/api\.py/)
|
||
assert.equal(await p.locator("#activity-body textarea, #activity-body input").count(), 0, "nothing to type into")
|
||
await p.click("#activity-back")
|
||
await p.click('#activity .subagent[data-id="s2"] .subagent__buttons .btn >> text=Findings')
|
||
await p.waitForSelector("#activity .findings")
|
||
assert.match(await p.textContent("#activity .findings"), /Login is POST \/session\./)
|
||
await p.click("#activity-back")
|
||
await p.click('#activity .subagent[data-id="s2"] button.agent__stop')
|
||
await p.waitForSelector("dialog[open]")
|
||
assert.match(await p.textContent("dialog[open]"), /Stop routes and the subagents under it\?/)
|
||
await p.click("dialog[open] .btn--danger")
|
||
await p.waitForFunction(() => window.__mock.api("POST", "/chats/chat2/subagents/s2/stop").length === 1)
|
||
assert.deepEqual(p.errors, [])
|
||
})
|
||
|
||
test("C4: the stream's tree frame redraws the panel; with the device away the kept tree shows and the rest says why", async () => {
|
||
const away = { ...fixture("tree"), live: false }
|
||
const p = await page(c4({ "GET /chats/chat2/tree": away, "GET /chats/chat2/subagents/s2/transcript": { __error: { status: 409, detail: "That is kept on the computer this chat runs on, which is not linked right now." } } }))
|
||
await openTree(p)
|
||
assert.match(await p.textContent("#activity .subagents"), /This device is not linked right now\./)
|
||
await p.click('#activity .subagent[data-id="s2"] > summary')
|
||
await p.click('#activity .subagent[data-id="s2"] .subagent__buttons .btn >> text=Open its conversation')
|
||
await p.waitForSelector("#activity-body .is-error")
|
||
assert.match(await p.textContent("#activity-body"), /not linked right now/)
|
||
await p.click("#activity-back")
|
||
// A reply streams; its `tree` frame (event_tree.json) is the tree now.
|
||
await p.evaluate(() => window.__mock.frame("/chats/chat2/messages/m2/stream", "tree", null))
|
||
await p.context().close()
|
||
const live = await page({ ...c4(), routes: { ...c4().routes, "GET /chats/chat2": { chat: agentChat(), messages: [userMessage("m1", "Go")], more: false, live: "m2" } }, streams: { [STREAM2]: [] } })
|
||
await openTree(live)
|
||
await live.evaluate(([path, f]) => window.__mock.frame(path, "tree", f), [STREAM2, fixture("event_tree")])
|
||
await live.waitForFunction(() => !!document.querySelector('#activity .subagent[data-id="s2"]') && /parser/.test(document.querySelector('#activity .subagent[data-id="s2"]').textContent))
|
||
assert.match(await live.textContent('#activity .subagent[data-id="s2"]'), /reviews the parser/)
|
||
assert.match(await live.textContent('#activity .subagent[data-id="s2"]'), /manual \+ write/)
|
||
assert.deepEqual(live.errors, [])
|
||
})
|
||
|
||
test("C4: an approval a subagent asks for names it", async () => {
|
||
const card = fixture("event_ask").cards.find((c) => c.id === "card4")
|
||
const p = await page(agentChatOpen([["ask", { cards: [card] }]], { "POST /chats/chat2/interactions/card4": { answered: true } }))
|
||
await openChatRow(p, "chat2")
|
||
await p.waitForSelector("#cards .ask__subagent")
|
||
assert.equal(await p.textContent("#cards .ask__subagent"), "Subagent parser (s1), reviews the parser, asks:")
|
||
assert.equal(await p.inputValue("#cards .ask__edit"), "git push", "the command, correctable, as for the main agent's")
|
||
assert.deepEqual(p.errors, [])
|
||
})
|
||
|
||
test("C4: Subagents for a new agent chat as /models?kind=agent says for this device; for an open one as the chat says", async () => {
|
||
const facts = fixture("models_agent")
|
||
const newAgent = async (models) => {
|
||
const p = await page(c4({ "GET /models?kind=agent&device=token1": models }))
|
||
await p.click("#new-chat-btn")
|
||
await p.click(".kind__tab >> text=Agent")
|
||
await p.waitForSelector("#composer-chips .cchip")
|
||
await p.waitForTimeout(200)
|
||
return p
|
||
}
|
||
let p = await newAgent(facts)
|
||
assert.equal(await p.locator("#composer-chips .cchip--subagents").count(), 1, "offered, and the device runs them")
|
||
await p.click("#composer-chips .cchip--subagents")
|
||
await p.waitForSelector(".range2")
|
||
assert.equal(await p.getAttribute(".range2__input--min", "max"), "32", "the server's cap")
|
||
await p.context().close()
|
||
p = await newAgent({ ...facts, agent_subagents: { offered: true, device_runs: false } })
|
||
assert.equal(await p.locator("#composer-chips .cchip--subagents").count(), 0, "this computer's CLI cannot run them")
|
||
await p.context().close()
|
||
p = await newAgent({ ...facts, agent_subagents: { offered: false, device_runs: true } })
|
||
assert.equal(await p.locator("#composer-chips .cchip--subagents").count(), 0, "not offered")
|
||
await p.context().close()
|
||
p = await newAgent({ __error: { status: 400, detail: "older server" } })
|
||
assert.equal(await p.locator("#composer-chips .cchip--subagents").count(), 0, "a server before 3.4.0: none, as before")
|
||
await p.context().close()
|
||
// An open agent chat: its own `subagents` (chat_agent.json offers them).
|
||
const chat = fixture("chat_agent")
|
||
p = await page(c4({ "GET /chats/chat2": { chat: { ...agentChat({ running: false }), subagents: chat.subagents, agents: chat.agents }, messages: [userMessage("m1", "Go")], more: false, live: null } }))
|
||
await openChatRow(p, "chat2")
|
||
await p.waitForSelector("#composer-chips .cchip--subagents")
|
||
assert.deepEqual(p.errors, [])
|
||
})
|
||
|
||
test("C4: the tree at 390 px stays inside the window", async () => {
|
||
const p = await page(c4(), { width: 390, height: 844 })
|
||
await p.evaluate(() => document.querySelector('.chatrow[data-id="chat2"]').click())
|
||
await p.waitForSelector("#activity-open")
|
||
await p.click("#activity-open")
|
||
await p.waitForSelector("#activity .subagents .subagent")
|
||
await p.click('#activity .subagent[data-id="s2"] > summary')
|
||
assert.ok(await p.evaluate(() => document.documentElement.scrollWidth <= 390), "nothing scrolls sideways")
|
||
const panel = await p.evaluate(() => { const b = document.querySelector("#activity-body"); return b.scrollWidth <= b.clientWidth })
|
||
assert.ok(panel, "the panel does not scroll sideways")
|
||
if (process.env.SHOTS) await p.screenshot({ path: join(process.env.SHOTS, "c4-tree-390.png") })
|
||
assert.deepEqual(p.errors, [])
|
||
})
|
||
|
||
// --- D: parity (beta.9) ------------------------------------------------------------------------
|
||
|
||
const shot = (p, name) => (process.env.SHOTS ? p.screenshot({ path: join(process.env.SHOTS, `${name}.png`) }) : null)
|
||
const noSideways = (p, width) => p.evaluate((w) => document.documentElement.scrollWidth <= w, width)
|
||
|
||
function archivedRoutes(over = {}) {
|
||
return {
|
||
status: status(),
|
||
routes: baseRoutes({
|
||
"GET /chats?archived=true&limit=50": {
|
||
chats: [
|
||
chatRow({ id: "old1", title: "Lembas for the road, a recipe with a title long enough to be cut short", archived: true }),
|
||
agentChat({ id: "old2", title: "Old refactor", archived: true, running: false }),
|
||
],
|
||
more: false,
|
||
},
|
||
"PATCH /chats/old1": { chat: chatRow({ id: "old1", title: "Lembas for the road", archived: false }) },
|
||
"GET /chats/old2": { chat: agentChat({ id: "old2", title: "Old refactor", archived: true, running: false }), messages: [userMessage("m9", "Tidy it")], more: false, live: null },
|
||
"PATCH /chats/old2": { chat: agentChat({ id: "old2", title: "Old refactor", archived: false, running: false }) },
|
||
...over,
|
||
}),
|
||
streams: {},
|
||
}
|
||
}
|
||
|
||
test("D2: Archived lists the archived chats; Restore chat takes one back and the sidebar is read again", async () => {
|
||
const p = await page(archivedRoutes())
|
||
await p.click("#nav-archived")
|
||
await p.waitForSelector("#page-archived:not([hidden]) .arow")
|
||
assert.equal(await p.locator("#page-archived .arow").count(), 2)
|
||
assert.match(await p.textContent('.arow[data-id="old2"]'), /project/, "an agent chat names its folder")
|
||
await shot(p, "d2-archived-1280")
|
||
const before = await p.evaluate(() => window.__mock.api("GET", "/chats?limit").length)
|
||
await p.click('.arow[data-id="old1"] .arow__unarchive')
|
||
await p.waitForFunction(() => window.__mock.api("PATCH", "/chats/old1").length === 1)
|
||
const [call] = await p.evaluate(() => window.__mock.api("PATCH", "/chats/old1"))
|
||
assert.deepEqual(call.args.body, { archived: false })
|
||
await p.waitForFunction(() => !document.querySelector('.arow[data-id="old1"]'))
|
||
await p.waitForFunction((n) => window.__mock.api("GET", "/chats?limit").length > n, before)
|
||
assert.deepEqual(p.errors, [])
|
||
})
|
||
|
||
test("D2: an archived chat opens from the page and says so in its header; Restore chat there too", async () => {
|
||
const p = await page(archivedRoutes())
|
||
await p.click("#nav-archived")
|
||
await p.waitForSelector('.arow[data-id="old2"] .arow__open')
|
||
await p.click('.arow[data-id="old2"] .arow__open')
|
||
await p.waitForSelector("#main:not([hidden]) #messages .msg")
|
||
await p.click("#chat-tools .head__unarchive")
|
||
await p.waitForFunction(() => window.__mock.api("PATCH", "/chats/old2").length === 1)
|
||
await p.waitForSelector("#chat-tools .head__unarchive", { state: "detached" })
|
||
assert.deepEqual(p.errors, [])
|
||
})
|
||
|
||
test("D2: at 390 px the archived page stays inside the window, its buttons reachable", async () => {
|
||
const p = await page(archivedRoutes(), { width: 390, height: 844 })
|
||
await p.evaluate(() => document.getElementById("nav-archived").click())
|
||
await p.waitForSelector("#page-archived:not([hidden]) .arow")
|
||
assert.ok(await noSideways(p, 390), "nothing scrolls sideways")
|
||
const b = await p.locator('.arow[data-id="old1"] .arow__unarchive').boundingBox()
|
||
assert.ok(b && b.x + b.width <= 390, `Unarchive is on the screen: ${JSON.stringify(b)}`)
|
||
await shot(p, "d2-archived-390")
|
||
assert.deepEqual(p.errors, [])
|
||
})
|
||
|
||
// D4: a new agent chat on another linked computer. `GET /devices` lists this one (its host is the
|
||
// app's own) and another; folders on the other come from its agent chats in the sidebar, or typed.
|
||
|
||
const STUDIO = { id: "token9", name: "studio", host: "studio", platform: "linux", terminal: true }
|
||
|
||
function d4(over = {}) {
|
||
return {
|
||
status: status(),
|
||
routes: baseRoutes({
|
||
"GET /devices": { devices: [{ id: "token1", name: "workbench", host: "workbench", platform: "linux", terminal: true }, STUDIO] },
|
||
"GET /chats": { chats: [chatRow(), agentChat(), agentChat({ id: "chat7", title: "Site tweaks", running: false, device: { token_id: "token9", directory: "/srv/site" } })], more: false },
|
||
"POST /chats": { chat: agentChat({ id: "chat8", running: false, device: { token_id: "token9", directory: "/srv/site" } }), message: userMessage("m9", "Check the logs"), reply: null },
|
||
...over,
|
||
}),
|
||
streams: {},
|
||
terminal: [READY, ["output", "dev@studio:/srv/site$ "]],
|
||
}
|
||
}
|
||
|
||
async function pickDevice(p, name) {
|
||
await p.click("#composer-chips .cchip--device")
|
||
await p.locator(".menu .menu__item", { hasText: name }).first().click()
|
||
}
|
||
|
||
test("D4: a new agent chat defaults to this computer and can be sent to another linked one, in a folder it knows", async () => {
|
||
const p = await page(d4())
|
||
await p.click("#new-chat-btn")
|
||
await p.click(".kind__tab >> text=Agent")
|
||
await p.waitForSelector("#composer-chips .cchip--device")
|
||
assert.match(await p.textContent("#composer-chips .cchip--device"), /This computer/)
|
||
await pickDevice(p, "studio")
|
||
await p.waitForFunction(() => /studio/.test(document.querySelector("#composer-chips .cchip--device")?.textContent || ""))
|
||
assert.match(await p.textContent("#new-chat .hello__sub"), /studio/, "the greeting names the computer")
|
||
assert.match(await p.textContent("#composer-chips"), /site/, "the folder its agent chats work in")
|
||
assert.ok(await p.isHidden("#link-notice"), "this computer's agent is not in the way")
|
||
await shot(p, "d4-device-1280")
|
||
// The Terminal opens on that computer, in that folder, before the first message.
|
||
await p.click("#composer-chips .cchip--terminal")
|
||
await p.waitForFunction(() => window.__mock.terminals().length === 1)
|
||
const [shell] = await p.evaluate(() => window.__mock.terminals())
|
||
assert.deepEqual(shell.target, { chat: null, device: "token9", directory: "/srv/site" })
|
||
await p.fill("#composer-text", "Check the logs")
|
||
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.kind, "agent")
|
||
assert.equal(made.args.body.device, "token9")
|
||
assert.equal(made.args.body.directory, "/srv/site")
|
||
assert.deepEqual(p.errors, [])
|
||
})
|
||
|
||
test("D4: on a computer with no known folder the path is typed; back to this computer gives its own folders", async () => {
|
||
const p = await page(d4({ "GET /chats": { chats: [chatRow(), agentChat()], more: false } }))
|
||
await p.click("#new-chat-btn")
|
||
await p.click(".kind__tab >> text=Agent")
|
||
await pickDevice(p, "studio")
|
||
await p.waitForSelector("#composer-chips .cchip.is-warn")
|
||
await p.fill("#composer-text", "Hello")
|
||
await p.click("#send")
|
||
await p.waitForSelector("#thread-note:not([hidden])")
|
||
assert.equal(await p.evaluate(() => window.__mock.api("POST", "/chats").length), 0, "nothing sent without a folder")
|
||
await p.click("#composer-chips .cchip.is-warn")
|
||
await p.waitForSelector("dialog[open] .dialog__input")
|
||
await p.fill("dialog[open] .dialog__input", "/home/dev/other")
|
||
await p.click("dialog[open] .btn--primary")
|
||
await p.waitForFunction(() => /other/.test(document.querySelector("#composer-chips").textContent))
|
||
await pickDevice(p, "This computer")
|
||
await p.waitForFunction(() => /project/.test(document.querySelector("#composer-chips").textContent))
|
||
assert.deepEqual(p.errors, [])
|
||
})
|
||
|
||
test("D4: with no other computer linked there is no choice to make; at 390 px the chips stay inside", async () => {
|
||
let p = await page(b3())
|
||
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--device").count(), 0)
|
||
await p.context().close()
|
||
p = await page(d4(), { width: 390, height: 844 })
|
||
await p.evaluate(() => document.getElementById("new-chat-btn").click())
|
||
await p.click(".kind__tab >> text=Agent")
|
||
await pickDevice(p, "studio")
|
||
await p.waitForFunction(() => /studio/.test(document.querySelector("#composer-chips .cchip--device")?.textContent || ""))
|
||
assert.ok(await noSideways(p, 390), "nothing scrolls sideways")
|
||
await shot(p, "d4-device-390")
|
||
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 ")}`)
|
||
// On GitHub Actions, also as an annotation: readable without the job's log.
|
||
if (process.env.GITHUB_ACTIONS) console.log(`::error title=tests/ui::${`${name}: ${String(err?.message || err)} (${String(err?.stack || "").match(/run\.mjs:\d+/)?.[0] || ""})`.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A").slice(0, 900)}`)
|
||
}
|
||
}
|
||
await browser.close()
|
||
console.log(failed ? `tests/ui: ${failed} failed` : "tests/ui: all passed")
|
||
process.exit(failed ? 1 : 0)
|