The Terminal panel opens a shell on the device an agent chat works on, in its
folder: for a new agent chat as soon as the device and folder are chosen (the
server's /api/v2/devices/{device}/terminal/ws?directory=, which the chat's first
message takes over -- the same socket carries on), and for an open agent chat
from the header (/api/v2/chats/{chat}/terminal/ws, the web page's shell).
Offered where GET /devices says the device opens a terminal.
The socket is Rust's (src-tauri/src/terminal.rs, tungstenite on the rustls
ureq already brings): the bearer token goes in the handshake and never reaches
the page, which gets the frames as `terminal` events and sends keys, sizes and
close through commands. 4401 signs the app out as a 401 does.
xterm and its fit addon are the server's vendored build, pinned through
fetch-vendor.sh like CodeMirror and loaded on the first open. Send to chat puts
the selection, the last marked command and its output, or the screen into the
composer, never sent. The terminal and the activity panel take turns in the
panel column. Continue in a terminal stays in the chat's menu.
tests/ui: seven B3 cases with a mocked socket, at 1280 and 390 px.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
636 lines
30 KiB
JavaScript
636 lines
30 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)")
|
|
}
|
|
|
|
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, [])
|
|
})
|
|
|
|
// --- 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)
|