mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-10-20 11:16:06 +00:00
fix(status): keep connected MCP indicators static at rest (#806)
Keep connected MCP status dots green without a permanent pulse. Pending connect/disconnect operations retain their warning pulse and spinner; the shared reduced-motion and metadata refresh policies are unchanged. Add real StatusTab/SUID browser regressions at 150% scale, a bounded historical/current Chromium profiler, and measurement-limit documentation. Five browser scenarios, UI typecheck/build and repeatable profiler assertions passed. Fix the gatekeeper P2 by creating the temporary evidence parent on clean machines; the second independent review reported zero findings. The change addresses the idle-animation source in #804, not its unconfirmed generation GPU symptom or #805. Remote CI was pending at the explicitly requested admin merge.
This commit is contained in:
parent
eb1bf48e21
commit
d995750065
6 changed files with 389 additions and 1 deletions
57
dev-docs/UI_MOTION_PROFILING.md
Normal file
57
dev-docs/UI_MOTION_PROFILING.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# UI motion profiling (#804)
|
||||
|
||||
Connected MCP servers describe a stable state, not active work. Their status
|
||||
dots stay green without pulsing. Only a pending connect/disconnect retains the
|
||||
warning pulse and spinner; success and failure both stop that feedback. This is
|
||||
shared UI behavior for web, Electron and Tauri.
|
||||
|
||||
The existing global `prefers-reduced-motion: reduce` rule limits animations to
|
||||
one near-instant iteration and shortens transitions. The MCP fix does not add a
|
||||
second motion policy or alter refresh/cache semantics.
|
||||
|
||||
## Regression and bounded profiling
|
||||
|
||||
```powershell
|
||||
node --import tsx --test --test-concurrency=1 packages/ui/tests/browser/mcp-motion.test.ts packages/ui/tests/browser/status-panel.test.ts
|
||||
node scripts/profile-ui-motion.mjs eb1bf48e
|
||||
```
|
||||
|
||||
The optional Git revision substitutes only the historical MCP component. The
|
||||
remaining source and installed frontend dependencies are identical. The script
|
||||
writes Chromium traces, screenshots and `results.json` under a temporary
|
||||
`opencode/issue804-motion-*` directory. No native daemon or user data is used.
|
||||
|
||||
Fixtures use the real StatusTab, SUID switches, full stylesheet and SessionView
|
||||
with the native event dispatcher. MCP calls and session HTTP are simulated. The
|
||||
session fixture seeds 620 short history messages, then samples idle, token-free
|
||||
single/grouped reasoning, 24 text deltas with a 50 ms delay between deliveries,
|
||||
and completion. Both motion settings use a 150% device scale.
|
||||
|
||||
Observed in the September 30 isolated Chromium run:
|
||||
|
||||
| State | Infinite animations | Layout activity in ~1.2 s sample |
|
||||
| --- | --- | --- |
|
||||
| Eight connected MCPs, before fix | 8 | 0 layouts (opacity animation) |
|
||||
| Eight connected MCPs, after fix | 0 | 0 layouts |
|
||||
| Collapsed MCP section | 0 | 0 layouts |
|
||||
| Single reasoning step, no deltas | 0 | 0 layouts |
|
||||
| Grouped reasoning, no deltas | 1 spinner | 0 layouts |
|
||||
| Grouped reasoning, reduced motion | 0 | 0 layouts |
|
||||
| Session complete | 0 | 0 layouts |
|
||||
|
||||
Text streaming still generates rendering work with reduced motion: 52 layouts
|
||||
were recorded for 24 deliveries in both settings (~1.7 s including delivery
|
||||
overhead). The mounted transcript remained at 16 rows, rather than 620. These
|
||||
observations do not establish a full-transcript redraw on every animation frame.
|
||||
|
||||
## Limits / follow-up
|
||||
|
||||
This is a **headless Chromium rendering sample**, not a WebView2/NVIDIA GPU
|
||||
measurement. Compositor-only animation can consume GPU without producing main
|
||||
thread layout/paint counters. No GPU percentage reduction is claimed.
|
||||
|
||||
The session fixture omits application/sidebar chrome and does not supply the
|
||||
full-history timeline RPC. Long reasoning, large tool results, remote HTTP
|
||||
bursts and full-app freezes still require a representative native recording.
|
||||
The generation symptom of #804 and the remote Electron freeze in #805 remain
|
||||
separate, unconfirmed problems; this change must not close either as fully fixed.
|
||||
|
|
@ -127,7 +127,8 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) =>
|
|||
const switchDisabled = () => isPending() || !instance().client
|
||||
const statusDotClass = () => {
|
||||
if (isPending()) return "status-dot animate-pulse"
|
||||
if (server.status === "running") return "status-dot ready animate-pulse"
|
||||
// A connected server is idle; only pending operations need motion.
|
||||
if (server.status === "running") return "status-dot ready"
|
||||
if (server.status === "error") return "status-dot error"
|
||||
return "status-dot stopped"
|
||||
}
|
||||
|
|
|
|||
58
packages/ui/tests/browser/fixtures/mcp-motion.tsx
Normal file
58
packages/ui/tests/browser/fixtures/mcp-motion.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { createSignal } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import StatusTab from "../../../src/components/instance/shell/right-panel/tabs/StatusTab"
|
||||
import { DEFAULT_RIGHT_PANEL_CUSTOMIZATION } from "../../../src/components/instance/shell/right-panel/registry"
|
||||
import { ConfigProvider } from "../../../src/stores/preferences"
|
||||
import { I18nProvider, useI18n } from "../../../src/lib/i18n"
|
||||
import { ThemeProvider } from "../../../src/lib/theme"
|
||||
import { shellStore } from "../../../src/stores/shells"
|
||||
import type { Instance } from "../../../src/types/instance"
|
||||
import { applyUiSettings } from "./ui-settings"
|
||||
import "../../../src/index.css"
|
||||
|
||||
const names = ["dnd-5e", "dnd-rules", "fastctx", "foundry", "open5e", "qmd", "remoteshell", "serena"]
|
||||
const calls: unknown[] = []
|
||||
let settle: ((fail: boolean) => void) | undefined
|
||||
const client = { mcp: {
|
||||
connect: (input: any) => mutate(input, "connected"),
|
||||
disconnect: (input: any) => mutate(input, "disabled"),
|
||||
} }
|
||||
const [instance, setInstance] = createSignal<Instance>({
|
||||
id: "mcp-motion", folder: "/fixture", port: 0, pid: 0, proxyPath: "/fixture", status: "ready", client: client as any,
|
||||
metadata: { mcpStatus: { location: { directory: "/fixture" }, data: names.map(name => ({ name, status: { status: "connected" } })) } as any },
|
||||
})
|
||||
function mutate(input: any, status: string) {
|
||||
calls.push(input)
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
settle = fail => {
|
||||
settle = undefined
|
||||
if (fail) return reject(new Error("Fixture connection failure"))
|
||||
setStatus(input.server, status)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
function setStatus(name: string, status: string) {
|
||||
setInstance(previous => ({ ...previous, metadata: { mcpStatus: {
|
||||
...previous.metadata!.mcpStatus!,
|
||||
data: previous.metadata!.mcpStatus!.data.map(server => server.name !== name ? server : {
|
||||
...server, status: { status, ...(status === "failed" ? { error: "Fixture server failed" } : {}) },
|
||||
}),
|
||||
} as any } }))
|
||||
}
|
||||
// No native runtime is contacted: only the transport and unrelated Shell inventory are replaced.
|
||||
shellStore.load = async () => {}
|
||||
await applyUiSettings({ locale: "en" })
|
||||
function Fixture() {
|
||||
const { t } = useI18n()
|
||||
const [expanded, setExpanded] = createSignal<string[]>(["mcp"])
|
||||
return <main style={{ width: "360px" }}>
|
||||
<StatusTab instanceId="mcp-motion" instance={instance()} t={t} activeSession={() => null}
|
||||
isActive={() => true} expandedItems={expanded} onExpandedItemsChange={setExpanded}
|
||||
customization={() => ({ ...DEFAULT_RIGHT_PANEL_CUSTOMIZATION,
|
||||
hiddenStatusSectionIds: ["tokens", "yolo-mode", "provider-usage", "background-processes", "plugins"],
|
||||
})} onCustomizationChange={() => {}} />
|
||||
</main>
|
||||
}
|
||||
render(() => <ConfigProvider><I18nProvider><ThemeProvider><Fixture /></ThemeProvider></I18nProvider></ConfigProvider>, document.getElementById("root")!)
|
||||
;(window as any).mcpFixture = { settle: (fail = false) => settle?.(fail), setStatus, calls }
|
||||
40
packages/ui/tests/browser/fixtures/streaming-motion.tsx
Normal file
40
packages/ui/tests/browser/fixtures/streaming-motion.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import "./session"
|
||||
import { sseManager } from "../../../src/lib/sse-manager"
|
||||
import { applyUiSettings } from "./ui-settings"
|
||||
|
||||
// Reuse the real SessionView/native dispatcher fixture; drive lifecycle events
|
||||
// independently from text deltas so token-free thinking can also be measured.
|
||||
const instanceId = "browser-instance", sessionID = "browser-session", assistantMessageID = "msg_assistant"
|
||||
let time = Date.now()
|
||||
let grouped = false
|
||||
const emit = (type: string, data: object = {}) => (sseManager as any).handleEvent(instanceId, {
|
||||
id: `motion-${++time}`, type, created: time, location: { directory: "/fixture" },
|
||||
data: { sessionID, assistantMessageID, ...data },
|
||||
})
|
||||
await applyUiSettings({ locale: "en", showThinkingBlocks: true,
|
||||
toolCallExpansionDefaults: { preset: "custom", thinking: "expanded", tools: {} } })
|
||||
const fixture = (window as any).fixture
|
||||
;(window as any).motionFixture = {
|
||||
seed: () => fixture.seedHistory(620),
|
||||
start: () => {
|
||||
emit("session.execution.started")
|
||||
fixture.startEmpty()
|
||||
emit("session.reasoning.started")
|
||||
emit("session.reasoning.delta", { ordinal: 0, delta: "**Considering the answer**\n\nInitial reasoning." })
|
||||
},
|
||||
text: () => emit("session.text.started"),
|
||||
groupThinking: () => {
|
||||
emit("session.reasoning.ended", { ordinal: 0, text: "**Considering the answer**\n\nInitial reasoning." })
|
||||
emit("session.reasoning.started")
|
||||
emit("session.reasoning.delta", { ordinal: 1, delta: "**Checking the result**\n\nAnother reasoning step." })
|
||||
grouped = true
|
||||
},
|
||||
delta: (delta: string) => fixture.delta(delta),
|
||||
finish: (text: string) => {
|
||||
emit("session.reasoning.ended", { ordinal: 0, text: "**Considering the answer**\n\nInitial reasoning." })
|
||||
if (grouped) emit("session.reasoning.ended", { ordinal: 1, text: "**Checking the result**\n\nAnother reasoning step." })
|
||||
fixture.end(text)
|
||||
emit("session.step.ended", { finish: "stop" })
|
||||
emit("session.execution.succeeded")
|
||||
},
|
||||
}
|
||||
101
packages/ui/tests/browser/mcp-motion.test.ts
Normal file
101
packages/ui/tests/browser/mcp-motion.test.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { after, before, test } from "node:test"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { chromium, type Browser, type Page } from "playwright"
|
||||
import { createServer, type ViteDevServer } from "vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
|
||||
let server: ViteDevServer, browser: Browser, url: string
|
||||
before(async () => {
|
||||
server = await createServer({ configFile: false, root: fileURLToPath(new URL("../..", import.meta.url)), logLevel: "error",
|
||||
plugins: [solid(), { name: "mcp-motion", configureServer(s) {
|
||||
s.middlewares.use("/mcp-motion", async (_req, res) => {
|
||||
res.setHeader("Content-Type", "text/html")
|
||||
res.end(await s.transformIndexHtml("/mcp-motion", '<html><body><div id="root"></div><script type="module" src="/tests/browser/fixtures/mcp-motion.tsx"></script></body></html>'))
|
||||
})
|
||||
} }], resolve: { dedupe: ["solid-js"] }, optimizeDeps: { exclude: ["lucide-solid"] },
|
||||
server: { host: "127.0.0.1", port: 0, hmr: false, watch: null },
|
||||
})
|
||||
await server.listen()
|
||||
url = `http://127.0.0.1:${(server.httpServer!.address() as { port: number }).port}/mcp-motion`
|
||||
browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined })
|
||||
})
|
||||
after(async () => { await browser?.close(); await server?.close() })
|
||||
|
||||
async function withFixture(motion: "reduce" | "no-preference", run: (page: Page) => Promise<void>) {
|
||||
const page = await browser.newPage({ viewport: { width: 700, height: 850 }, deviceScaleFactor: 1.5, reducedMotion: motion, locale: "en-US" })
|
||||
page.setDefaultTimeout(10_000)
|
||||
const errors: string[] = []
|
||||
page.on("pageerror", error => errors.push(error.message))
|
||||
await page.route("**/api/**", route => route.fulfill({ json: {} }))
|
||||
try {
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 })
|
||||
await page.waitForFunction(() => Boolean((window as any).mcpFixture), undefined, { timeout: 30_000 })
|
||||
await page.locator(".status-dot").first().waitFor()
|
||||
await run(page)
|
||||
assert.deepEqual(errors, [])
|
||||
} finally { await page.close() }
|
||||
}
|
||||
|
||||
async function perpetualAnimations(page: Page) {
|
||||
return page.evaluate(() => document.getAnimations().filter(animation =>
|
||||
animation.playState === "running" && animation.effect?.getComputedTiming().iterations === Infinity,
|
||||
).length)
|
||||
}
|
||||
|
||||
for (const motion of ["no-preference", "reduce"] as const) {
|
||||
test(`eight connected MCP servers stay static at rest (${motion}, 150% scale)`, () => withFixture(motion, async page => {
|
||||
assert.equal(await page.locator(".status-dot.ready").count(), 8)
|
||||
assert.equal(await page.getByRole("checkbox").count(), 8)
|
||||
assert.equal(await perpetualAnimations(page), 0, "connected servers must not schedule perpetual animation frames")
|
||||
assert.deepEqual(await page.evaluate(() => (window as any).mcpFixture.calls), [])
|
||||
const header = page.locator(".right-panel-accordion-trigger")
|
||||
await header.click()
|
||||
await page.locator(".status-dot").first().waitFor({ state: "hidden" })
|
||||
assert.equal(await perpetualAnimations(page), 0)
|
||||
await header.click()
|
||||
await page.locator(".status-dot").first().waitFor()
|
||||
assert.equal(await page.locator(".status-dot.ready").count(), 8)
|
||||
assert.equal(await perpetualAnimations(page), 0)
|
||||
}))
|
||||
|
||||
test(`MCP operation feedback stops on success and failure (${motion})`, () => withFixture(motion, async page => {
|
||||
const toggle = page.getByRole("checkbox", { name: "Toggle dnd-5e MCP server", exact: true })
|
||||
assert.equal(await toggle.isChecked(), true)
|
||||
await toggle.click()
|
||||
await page.locator("svg.animate-spin").waitFor()
|
||||
assert.equal(await toggle.isDisabled(), true)
|
||||
assert.equal(await perpetualAnimations(page), motion === "reduce" ? 0 : 2, "only the pending dot and spinner may loop")
|
||||
if (motion === "reduce") {
|
||||
// Exercise live OS-preference changes rather than only the initial media query.
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" })
|
||||
await page.waitForFunction(() => document.getAnimations().some(a => a.effect?.getComputedTiming().iterations === Infinity))
|
||||
await page.emulateMedia({ reducedMotion: "reduce" })
|
||||
assert.equal(await perpetualAnimations(page), 0)
|
||||
}
|
||||
await page.evaluate(() => (window as any).mcpFixture.settle())
|
||||
await page.locator("svg.animate-spin").waitFor({ state: "detached" })
|
||||
assert.equal(await toggle.isChecked(), false)
|
||||
assert.equal(await toggle.isEnabled(), true)
|
||||
assert.equal(await perpetualAnimations(page), 0)
|
||||
await toggle.click()
|
||||
await page.locator("svg.animate-spin").waitFor()
|
||||
await page.evaluate(() => (window as any).mcpFixture.settle(true))
|
||||
await page.locator("svg.animate-spin").waitFor({ state: "detached" })
|
||||
assert.equal(await toggle.isChecked(), false)
|
||||
assert.equal(await toggle.isEnabled(), true)
|
||||
assert.equal(await perpetualAnimations(page), 0)
|
||||
await toggle.click()
|
||||
await page.locator("svg.animate-spin").waitFor()
|
||||
await page.evaluate(() => (window as any).mcpFixture.settle())
|
||||
await page.locator("svg.animate-spin").waitFor({ state: "detached" })
|
||||
assert.equal(await toggle.isChecked(), true)
|
||||
assert.equal(await page.locator(".status-dot.ready").count(), 8)
|
||||
assert.equal(await perpetualAnimations(page), 0)
|
||||
assert.deepEqual(await page.evaluate(() => (window as any).mcpFixture.calls), Array(3).fill({ server: "dnd-5e", location: { directory: "/fixture" } }))
|
||||
await page.evaluate(() => (window as any).mcpFixture.setStatus("serena", "failed"))
|
||||
await page.getByText("Fixture server failed", { exact: true }).waitFor()
|
||||
assert.equal(await page.locator(".status-dot.error").count(), 1)
|
||||
assert.equal(await perpetualAnimations(page), 0)
|
||||
}))
|
||||
}
|
||||
131
scripts/profile-ui-motion.mjs
Normal file
131
scripts/profile-ui-motion.mjs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
// Bounded Chromium rendering traces, not a Task Manager/WebView2 GPU benchmark.
|
||||
// Usage: node scripts/profile-ui-motion.mjs [baseline-git-ref]
|
||||
// Only the baseline MCP component is substituted; all other sources/dependencies
|
||||
// and the real StatusTab/SessionView fixture paths remain identical.
|
||||
import assert from "node:assert/strict"
|
||||
import { execFileSync } from "node:child_process"
|
||||
import { mkdir, mkdtemp, writeFile } from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { chromium } from "playwright"
|
||||
import { createServer } from "vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
|
||||
const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
||||
const root = path.join(repo, "packages/ui")
|
||||
const baselineRef = process.argv[2]
|
||||
const temporaryParent = path.join(os.tmpdir(), "opencode")
|
||||
await mkdir(temporaryParent, { recursive: true })
|
||||
const evidence = await mkdtemp(path.join(temporaryParent, "issue804-motion-"))
|
||||
const delay = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
const browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined })
|
||||
const results = []
|
||||
|
||||
async function sample(page, name, drive) {
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
await cdp.send("Performance.enable")
|
||||
await delay(300) // let one-off disclosure/scroll/transition work settle
|
||||
const animationSnapshot = await page.evaluate(() => document.getAnimations().filter(a =>
|
||||
a.playState === "running" && a.effect?.getComputedTiming().iterations === Infinity,
|
||||
).map(a => ({ name: a.animationName, target: a.effect?.target?.getAttribute("class") })))
|
||||
const before = Object.fromEntries((await cdp.send("Performance.getMetrics")).metrics.map(m => [m.name, m.value]))
|
||||
const events = []
|
||||
cdp.on("Tracing.dataCollected", ({ value }) => events.push(...value))
|
||||
await cdp.send("Tracing.start", { categories: "devtools.timeline,disabled-by-default-devtools.timeline,cc", transferMode: "ReportEvents" })
|
||||
const started = performance.now()
|
||||
if (drive) await drive()
|
||||
else await delay(1200)
|
||||
const elapsedMs = performance.now() - started
|
||||
const done = new Promise(resolve => cdp.once("Tracing.tracingComplete", resolve))
|
||||
await cdp.send("Tracing.end")
|
||||
await done
|
||||
const after = Object.fromEntries((await cdp.send("Performance.getMetrics")).metrics.map(m => [m.name, m.value]))
|
||||
const counts = {}
|
||||
for (const event of events) if (["Paint", "Layout", "UpdateLayoutTree", "DrawFrame"].includes(event.name)) {
|
||||
counts[event.name] = (counts[event.name] ?? 0) + 1
|
||||
}
|
||||
const result = { name, elapsedMs, animations: animationSnapshot, events: counts,
|
||||
metrics: Object.fromEntries(["LayoutCount", "RecalcStyleCount", "LayoutDuration", "RecalcStyleDuration", "ScriptDuration", "TaskDuration"].map(key => [key, after[key] - before[key]])),
|
||||
mountedRows: await page.locator("[data-virtual-follow-key]").count(),
|
||||
}
|
||||
await writeFile(path.join(evidence, `${name}.json`), JSON.stringify({ traceEvents: events }))
|
||||
await page.screenshot({ path: path.join(evidence, `${name}.png`) })
|
||||
await cdp.detach()
|
||||
results.push(result)
|
||||
}
|
||||
|
||||
try {
|
||||
for (const baseline of baselineRef ? [true, false] : [false]) {
|
||||
const component = "packages/ui/src/components/instance-service-status.tsx"
|
||||
const oldSource = baseline ? execFileSync("git", ["show", `${baselineRef}:${component}`], { cwd: repo, encoding: "utf8" }) : undefined
|
||||
const server = await createServer({ configFile: false, root, logLevel: "error", plugins: [
|
||||
{ name: "motion-fixture", enforce: "pre", load(id) {
|
||||
if (id.replaceAll("\\", "/") === path.join(repo, component).replaceAll("\\", "/")) return oldSource
|
||||
}, configureServer(server) { server.middlewares.use("/motion", async (req, res) => {
|
||||
const fixture = req.url?.includes("streaming") ? "streaming-motion" : "mcp-motion"
|
||||
res.setHeader("Content-Type", "text/html")
|
||||
res.end(await server.transformIndexHtml("/motion", `<html><body><div id="root" style="display:flex;height:700px;width:1100px"></div><script type="module" src="/tests/browser/fixtures/${fixture}.tsx"></script></body></html>`))
|
||||
}) } }, solid()], resolve: { dedupe: ["solid-js"] }, optimizeDeps: { exclude: ["lucide-solid"] },
|
||||
server: { host: "127.0.0.1", port: 0, hmr: false, watch: null },
|
||||
})
|
||||
try {
|
||||
await server.listen()
|
||||
const url = `http://127.0.0.1:${server.httpServer.address().port}/motion`
|
||||
for (const reducedMotion of ["no-preference", "reduce"]) {
|
||||
const page = await browser.newPage({ viewport: { width: 1100, height: 850 }, deviceScaleFactor: 1.5, reducedMotion, locale: "en-US" })
|
||||
const errors = []
|
||||
page.on("pageerror", error => errors.push(error.message))
|
||||
page.setDefaultTimeout(30_000)
|
||||
await page.route("**/api/**", route => route.fulfill({ contentType: route.request().url().includes("events") ? "text/event-stream" : "application/json", body: "" }))
|
||||
const label = `${baseline ? "baseline" : "fixed"}-${reducedMotion}`
|
||||
try {
|
||||
await page.goto(url, { waitUntil: "domcontentloaded" })
|
||||
await page.waitForFunction(() => Boolean(window.mcpFixture))
|
||||
await sample(page, `${label}-mcp-expanded`)
|
||||
await page.locator(".right-panel-accordion-trigger").click()
|
||||
await page.locator(".status-dot").first().waitFor({ state: "hidden" })
|
||||
await sample(page, `${label}-mcp-collapsed`)
|
||||
if (!baseline) {
|
||||
await page.goto(`${url}?streaming`, { waitUntil: "domcontentloaded" })
|
||||
await page.waitForFunction(() => Boolean(window.motionFixture))
|
||||
await page.evaluate(() => window.motionFixture.seed())
|
||||
await page.waitForFunction(() => document.querySelector(".message-stream")?.textContent?.includes("History 619"))
|
||||
await sample(page, `${label}-session-idle`)
|
||||
await page.evaluate(() => window.motionFixture.start())
|
||||
await page.waitForFunction(() => document.querySelector(".message-stream")?.textContent?.includes("Initial reasoning"))
|
||||
await sample(page, `${label}-thinking-no-deltas`)
|
||||
await page.evaluate(() => window.motionFixture.groupThinking())
|
||||
await page.locator(".message-technical-group-spinner").waitFor()
|
||||
await sample(page, `${label}-grouped-thinking-no-deltas`)
|
||||
await page.evaluate(() => window.motionFixture.text())
|
||||
let text = ""
|
||||
await sample(page, `${label}-streaming-paced-50ms`, async () => {
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const delta = `Token ${i}. `
|
||||
text += delta
|
||||
await page.evaluate(delta => window.motionFixture.delta(delta), delta)
|
||||
await delay(50)
|
||||
}
|
||||
})
|
||||
await page.evaluate(text => window.motionFixture.finish(text), text)
|
||||
await sample(page, `${label}-session-complete`)
|
||||
}
|
||||
assert.deepEqual(errors, [])
|
||||
} finally { await page.close() }
|
||||
}
|
||||
} finally { await server.close() }
|
||||
}
|
||||
for (const result of results) {
|
||||
if (result.name.includes("-reduce-") || result.name.startsWith("fixed-") && result.name.includes("-mcp-")) {
|
||||
assert.equal(result.animations.length, 0, `${result.name} must not have perpetual animations`)
|
||||
}
|
||||
if (result.name.includes("-session-") || result.name.includes("-thinking-") || result.name.includes("-streaming-")) {
|
||||
assert.ok(result.mountedRows < 60, `${result.name} must retain bounded transcript mounting`)
|
||||
}
|
||||
}
|
||||
console.log(JSON.stringify({ evidence, results }, null, 2))
|
||||
} finally {
|
||||
await browser.close()
|
||||
await writeFile(path.join(evidence, "results.json"), JSON.stringify(results, null, 2))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue