/** * The first message of a fresh session shares the first-session creation with * Attach. That creation publishes the id as soon as POST /frames answers and * then opens the conversation (loadSessions + openConversation). send() used * to dispatch on the id alone, so openConversation's reset -- closeTurnTicket, * running=false, an unlocked composer, a wiped #messages, a bumped _openGen -- * landed in the middle of the turn it had just started. It now waits for the * opening before it captures its generation and posts. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const wsMock = vi.hoisted(() => ({ sub: vi.fn(), unsub: vi.fn() })); const loadMock = vi.hoisted(() => ({ loadSessions: vi.fn(async () => {}) })); vi.mock("../ws/connect", () => wsMock); vi.mock("../sessions/load", () => loadMock); import { _openGen, currentId, historyUnconfirmed, project } from "../../stores/session"; import { resetStoreFields } from "../../stores/signal-field"; import { pendingRequestId, running } from "../../stores/stream"; import { UPLOAD_STATE } from "../chrome/upload"; import { SUBMISSION_GRACE_MS, send } from "./send"; import { closeTurnTicket } from "./ticket"; type FakeEl = Record & { classList: { add: () => void; remove: () => void; toggle: () => void; contains: () => boolean }; value: string; children: unknown[]; }; function fakeEl(): FakeEl { const node: FakeEl = { classList: { add: () => {}, remove: () => {}, toggle: () => {}, contains: () => false }, value: "", children: [], dataset: {}, style: {}, innerHTML: "", textContent: "", appendChild(child: unknown) { node.children.push(child); return child; }, remove() {}, focus() {}, setAttribute() {}, removeAttribute() {}, querySelector: () => null, querySelectorAll: () => [], addEventListener() {}, scrollTo() {}, }; return node; } describe("send(): first message of a fresh session", () => { const nodes: Record = {}; let composer: FakeEl = fakeEl(); const order: string[] = []; let releaseSessions: () => void = () => {}; beforeEach(() => { resetStoreFields(); UPLOAD_STATE.pending.clear(); UPLOAD_STATE.failures.clear(); UPLOAD_STATE.creations.clear(); order.length = 0; for (const id of ["composer", "messages", "composer-hint", "cancel-btn", "send-btn", "workspace"]) { nodes[id] = fakeEl(); } composer = fakeEl(); composer.value = "hello"; nodes.composer = composer; vi.stubGlobal("document", { querySelector: (sel: string) => nodes[sel.replace(/^#/, "")] ?? null, querySelectorAll: () => [], getElementById: (id: string) => nodes[id] ?? null, createElement: () => fakeEl(), createElementNS: () => fakeEl(), createTextNode: (text: string) => ({ text }), documentElement: fakeEl(), body: fakeEl(), }); const sessionsHeld = new Promise((resolve) => { releaseSessions = resolve; }); vi.stubGlobal("window", { loadSessions: () => { order.push("loadSessions"); return sessionsHeld; }, openConversation: (fid: string) => { order.push(`openConversation:${fid}`); // The reset the real openConversation performs on the way in. closeTurnTicket(); running.value = false; _openGen.value = (_openGen.value || 0) + 1; return Promise.resolve(); }, }); vi.stubGlobal("fetch", (url: string, opts: { method?: string }) => { const path = String(url).replace("/api/v1", ""); order.push(`${opts?.method || "GET"} ${path}`); let body: unknown = {}; if (path === "/frames") body = { id: "frame_new" }; else if (path === "/frames/frame_new/message") { body = { request_id: "req-1", execution_id: "exec-1", queue_position: 0 }; } return Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve(JSON.stringify(body)), }); }); currentId.value = null; project.value = "proj_1"; }); afterEach(() => { vi.unstubAllGlobals(); }); it("dispatches only after the shared creation has opened the conversation", async () => { const done = send("hello"); // Let POST /frames answer and the adoption park on loadSessions(). for (let i = 0; i < 10; i++) await Promise.resolve(); expect(currentId.value).toBe("frame_new"); expect(order).toEqual(["POST /frames", "loadSessions"]); expect(order.some((step) => step.startsWith("POST /frames/frame_new/message"))).toBe(false); releaseSessions(); await done; expect(order).toEqual([ "POST /frames", "loadSessions", "openConversation:frame_new", "POST /frames/frame_new/message", ]); // The turn this send started survived the open: it is still running, // its ticket was accepted, and the composer's draft is gone. expect(running.value).toBe(true); expect(pendingRequestId.value).toBe("req-1"); expect(composer.value).toBe(""); expect(historyUnconfirmed.value).toBe(0); }); }); describe("send(): an unanswered POST /message", () => { it("releases the history-submission hold after a bounded grace, then realigns", async () => { vi.useFakeTimers(); const nodes: Record = {}; for (const id of ["messages", "composer-hint", "cancel-btn", "send-btn", "workspace"]) nodes[id] = fakeEl(); const composerNode = fakeEl(); composerNode.value = "hello"; nodes.composer = composerNode; vi.stubGlobal("document", { querySelector: (sel: string) => nodes[sel.replace(/^#/, "")] ?? null, querySelectorAll: () => [], getElementById: (id: string) => nodes[id] ?? null, createElement: () => fakeEl(), createElementNS: () => fakeEl(), createTextNode: (text: string) => ({ text }), documentElement: fakeEl(), body: fakeEl(), }); const align = vi.fn(); vi.stubGlobal("window", { alignHistoryAfterTurn: align }); vi.stubGlobal("alignHistoryAfterTurn", align); vi.stubGlobal("fetch", (url: string) => { if (String(url).endsWith("/message")) return Promise.reject(new TypeError("Failed to fetch")); return Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve("{}") }); }); try { resetStoreFields(); currentId.value = "frame_1"; project.value = "proj_1"; _openGen.value = 4; await send("hello"); expect(historyUnconfirmed.value).toBe(1); expect(running.value).toBe(false); await vi.advanceTimersByTimeAsync(SUBMISSION_GRACE_MS - 1); expect(historyUnconfirmed.value).toBe(1); await vi.advanceTimersByTimeAsync(1); expect(historyUnconfirmed.value).toBe(0); expect(align).toHaveBeenCalledWith("frame_1", 4); } finally { vi.useRealTimers(); vi.unstubAllGlobals(); } }); });