| 1 | // Real sidebar interaction with a controlled host. Durable writes are covered |
| 2 | // by Desktop tests; this gate checks per-target UI state and transcript identity. |
| 3 | import path from "node:path"; |
| 4 | import { fileURLToPath } from "node:url"; |
| 5 | import assert from "node:assert/strict"; |
| 6 | import { createServer } from "vite"; |
| 7 | import { chromium } from "playwright"; |
| 8 | import { selectSession } from "./app-page-actions.mjs"; |
| 9 | |
| 10 | const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); |
| 11 | const server = await createServer({ root, logLevel: "error", server: { host: "127.0.0.1", port: 4679, strictPort: true } }); |
| 12 | await server.listen(); |
| 13 | let browser; |
| 14 | try { |
| 15 | browser = await chromium.launch({ headless: true }); |
| 16 | const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); |
| 17 | page.setDefaultTimeout(20000); |
| 18 | const errors = []; |
| 19 | page.on("pageerror", error => errors.push(error.message)); |
| 20 | await page.goto("http://127.0.0.1:4679/?mock=bench&bench=1"); |
| 21 | const composer = page.locator("textarea.composer__input:not([aria-hidden=true])"); |
| 22 | await composer.waitFor(); |
| 23 | await selectSession(page, "bench:small-6t"); |
| 24 | await page.waitForFunction(() => document.querySelector(".transcript")?.textContent?.includes("ASYNC LAYOUT EXPANSION COMPLETE")); |
| 25 | await composer.fill("keep my draft"); |
| 26 | await page.evaluate(async () => { |
| 27 | const { app } = await import("/src/lib/bridge.ts"); |
| 28 | const { installDesktopHostStub } = await import("/src/__tests__/desktopHostStub.ts"); |
| 29 | const { DESKTOP_COMMANDS } = await import("/src/generated/desktopContract.generated.ts"); |
| 30 | const fallback = Object.fromEntries(DESKTOP_COMMANDS.map(key => [key, app[key]])); |
| 31 | window.__titleFixture = { requests: [], composer: document.querySelector("textarea.composer__input:not([aria-hidden=true])"), nodes: [...document.querySelectorAll("[data-chat-anchor-key]")] }; |
| 32 | installDesktopHostStub({ ...fallback, AIRenameSessionTarget: selector => new Promise((resolve, reject) => { |
| 33 | window.__titleFixture.requests.push({ |
| 34 | target: selector.ref?.sessionId ? `session-id:${selector.ref.sessionId}` : selector.sessionPath || selector.topicId, |
| 35 | resolve: title => resolve({ targetKey: selector.sessionPath || selector.topicId || selector.ref?.sessionId || "", operationId: crypto.randomUUID(), committed: true, title, lifecycleGeneration: 1 }), |
| 36 | reject, |
| 37 | }); |
| 38 | }) }); |
| 39 | }); |
| 40 | const rows = page.locator(".project-tree__topic:not(.project-tree__topic--active)"); |
| 41 | const names = await rows.locator(".project-tree__topic-label").allTextContents(); |
| 42 | assert(names.length >= 2, "fixture needs two background sessions"); |
| 43 | const row = name => page.locator(".project-tree__topic").filter({ has: page.locator(".project-tree__topic-label", { hasText: name }) }).first(); |
| 44 | const aiItem = () => page.getByRole("menuitem", { name: /AI.*(rename|Rename)|AI.*命名/ }); |
| 45 | await row(names[0]).click({ button: "right" }); |
| 46 | await aiItem().click(); |
| 47 | await page.waitForFunction(() => window.__titleFixture.requests.length === 1); |
| 48 | await row(names[0]).click({ button: "right" }); |
| 49 | assert(await page.getByRole("menuitem", { name: /renaming|命名中/i }).isDisabled(), "target alone has loading state"); |
| 50 | await page.keyboard.press("Escape"); |
| 51 | await row(names[1]).click({ button: "right" }); |
| 52 | assert(await aiItem().isEnabled(), "another session remains independently available"); |
| 53 | await aiItem().click(); |
| 54 | await page.waitForFunction(() => window.__titleFixture.requests.length === 2); |
| 55 | assert(await page.evaluate(() => window.__titleFixture.requests[0].target !== window.__titleFixture.requests[1].target), "requests retain different identities"); |
| 56 | await page.evaluate(() => window.__titleFixture.requests[1].resolve("Second completed")); |
| 57 | await page.getByText(/Second completed/, { exact: false }).last().waitFor(); |
| 58 | await page.evaluate(() => window.__titleFixture.requests[0].reject(new Error("controller lease /private/fixture test-secret"))); |
| 59 | await page.getByText(/Unable to complete this session operation|无法完成该会话操作|無法完成此會話操作/).waitFor(); |
| 60 | assert(!await page.locator("body").textContent().then(text => text.includes("test-secret")), "raw host errors never reach the toast"); |
| 61 | assert.equal(await composer.inputValue(), "keep my draft"); |
| 62 | const unchanged = await page.evaluate(() => { |
| 63 | const f = window.__titleFixture; |
| 64 | const nodes = [...document.querySelectorAll("[data-chat-anchor-key]")]; |
| 65 | return f.composer === document.querySelector("textarea.composer__input:not([aria-hidden=true])") && nodes.length === f.nodes.length && nodes.every((node, i) => node === f.nodes[i]); |
| 66 | }); |
| 67 | assert(unchanged, "background rename preserves transcript hosts and composer"); |
| 68 | assert.deepEqual(errors, []); |
| 69 | console.log("PASS sidebar per-target loading, out-of-order completion, sanitized errors, stable transcript and draft"); |
| 70 | } finally { |
| 71 | await browser?.close(); |
| 72 | await server.close(); |
| 73 | } |
| 74 |