| 1 | import assert from "node:assert/strict"; |
| 2 | import { act } from "react"; |
| 3 | import { installDom, installBridgeApp, renderComposer } from "./composerInboxHarness"; |
| 4 | import { emptySessionInput } from "../lib/sessionComposerPersistence"; |
| 5 | import { makeSessionUIMock } from "../lib/sessionUIMock"; |
| 6 | |
| 7 | const dom = installDom(); |
| 8 | globalThis.HTMLInputElement = dom.window.HTMLInputElement; |
| 9 | const calls: string[] = []; |
| 10 | const picked: string[] = []; |
| 11 | const catalog = [ |
| 12 | { ref: "configured/flash", provider: "configured", model: "flash", current: true }, |
| 13 | { ref: "configured/pro", provider: "configured", model: "pro", current: false }, |
| 14 | ]; |
| 15 | installBridgeApp({ |
| 16 | ...makeSessionUIMock(async () => {}), |
| 17 | ModelsForTab: async (id: string) => { calls.push(`tab:${id}`); return catalog; }, |
| 18 | ModelsForDraft: async (id: string) => { |
| 19 | calls.push(`draft:${id}`); |
| 20 | return id === "legacy-draft" ? catalog : []; |
| 21 | }, |
| 22 | }); |
| 23 | async function waitFor(check: () => boolean) { |
| 24 | for (let i = 0; i < 100 && !check(); i++) { |
| 25 | await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); }); |
| 26 | } |
| 27 | assert.ok(check(), "model picker should become ready"); |
| 28 | } |
| 29 | const { root, rerender } = await renderComposer({ |
| 30 | tabId: "formal-tab", sessionKey: "formal-session", modelLabel: "flash", |
| 31 | formalSessionRef: { hostId: "local", sessionId: "formal-session" }, |
| 32 | composerTarget: { kind: "session", tabId: "formal-tab" }, |
| 33 | onSwitchModel: async ref => { picked.push(ref); return true; }, |
| 34 | }); |
| 35 | try { |
| 36 | await waitFor(() => calls.length > 0); |
| 37 | assert.ok(calls.every(call => call === "tab:formal-tab"), `formal input persistence must not select the draft catalog: ${calls}`); |
| 38 | await waitFor(() => !!document.querySelector<HTMLButtonElement>(".modelsw__trigger:not(:disabled)")); |
| 39 | await act(async () => { document.querySelector<HTMLButtonElement>(".modelsw__trigger")!.click(); }); |
| 40 | await waitFor(() => document.querySelectorAll("[role=option]").length === 2); |
| 41 | await act(async () => { document.querySelector<HTMLButtonElement>("[role=option][aria-selected=false]")!.click(); }); |
| 42 | assert.deepEqual(picked, ["configured/pro"]); |
| 43 | |
| 44 | calls.length = 0; |
| 45 | await rerender({ tabId: "other-tab", sessionKey: "other-session", |
| 46 | formalSessionRef: { hostId: "local", sessionId: "other-session" }, |
| 47 | composerTarget: { kind: "session", tabId: "other-tab" } }); |
| 48 | await waitFor(() => calls.length > 0); |
| 49 | assert.ok(calls.every(call => call === "tab:other-tab"), "navigation must query the new formal tab"); |
| 50 | |
| 51 | calls.length = 0; |
| 52 | await rerender({ tabId: undefined, formalSessionRef: undefined, sessionKey: "legacy-draft", |
| 53 | composerTarget: { kind: "draft", draftId: "legacy-draft", generation: 1 }, |
| 54 | persistentDraft: { draftId: "legacy-draft", generation: 1, revision: 1, initial: emptySessionInput(), onChange() {} } }); |
| 55 | await waitFor(() => calls.length > 0); |
| 56 | assert.ok(calls.every(call => call === "draft:legacy-draft"), "recovered legacy drafts retain their catalog route"); |
| 57 | await act(async () => { document.querySelector<HTMLButtonElement>(".modelsw__trigger")!.click(); }); |
| 58 | await waitFor(() => document.querySelectorAll("[role=option]").length === 2); |
| 59 | console.log("PASS composer model catalog: formal persisted input, switching, navigation and recovered legacy draft"); |
| 60 | } finally { |
| 61 | await act(async () => root.unmount()); |
| 62 | dom.window.close(); |
| 63 | } |
| 64 |