| 1 | import assert from "node:assert/strict"; |
| 2 | import { act } from "react"; |
| 3 | import { installDom, installBridgeApp, renderComposer } from "./composerInboxHarness"; |
| 4 | import { createInboxQueuePreviewBindings } from "../lib/inboxQueuePreview"; |
| 5 | import type { InboxQueueRequest } from "../lib/inboxQueueCommands"; |
| 6 | import type { InboxTarget } from "../lib/pendingFollowup"; |
| 7 | |
| 8 | const dom = installDom(); |
| 9 | globalThis.sessionStorage = dom.window.sessionStorage; |
| 10 | const fixture = createInboxQueuePreviewBindings(); |
| 11 | const calls: InboxQueueRequest[] = []; |
| 12 | let enqueues = 0; |
| 13 | let cancelled: string[] | undefined; |
| 14 | let otherHost = false; |
| 15 | let failSave = false; |
| 16 | let departOnSave = false; |
| 17 | installBridgeApp({ |
| 18 | ...fixture, |
| 19 | CaptureAttachmentTarget: async () => ({ token: "queue-test-attachment", capabilities: ["attachments-v2"] }), |
| 20 | ReleaseAttachmentTarget: async () => {}, |
| 21 | StageImageForTarget: async () => ({ draftId: "0123456789abcdef0123456789abcdef", displayName: "draft.png", mime: "image/png", width: 1, height: 1, bytes: 12 }), |
| 22 | ReadDraftImageForTarget: async () => "data:image/png;base64,iVBORw0KGgo=", |
| 23 | SavePastedImageForTab: async () => ".reasonix/attachments/draft.png", |
| 24 | AttachmentDataURLForTab: async () => "data:image/png;base64,iVBORw0KGgo=", |
| 25 | InboxSnapshot: async () => otherHost ? { revision: 1, sessionPath: "session-a", mutationsSupported: true, items: [{ id: "other-host", preview: "other host queue", state: "queued" }] } : fixture.InboxSnapshot("tab-a"), |
| 26 | ListTabs: async () => [{ id: "tab-a", turnId: "live-turn" }], |
| 27 | InboxQueueForTarget: async (target: InboxTarget, request: InboxQueueRequest) => { |
| 28 | calls.push(request); |
| 29 | if (request.kind === "edit" && failSave) return { outcome: "conflict", reason: "content_changed" }; |
| 30 | if (request.kind === "edit" && departOnSave) { |
| 31 | await fixture.InboxQueueForTarget!(target, { kind: "delete", itemId: request.itemId }); |
| 32 | } |
| 33 | return fixture.InboxQueueForTarget!(target, request); |
| 34 | }, |
| 35 | EnqueueInboxFollowupForTarget: async (...args: Parameters<NonNullable<typeof fixture.EnqueueInboxFollowupForTarget>>) => { |
| 36 | enqueues++; |
| 37 | return fixture.EnqueueInboxFollowupForTarget!(...args); |
| 38 | }, |
| 39 | }); |
| 40 | const flush = () => new Promise<void>(resolve => setTimeout(resolve, 20)); |
| 41 | const view = await renderComposer({ running: true, onCancel: async ids => { cancelled = ids; return { discardedItemIds: [] }; } }); |
| 42 | await act(async () => { await import("../components/ComposerInboxQueue"); await flush(); }); |
| 43 | await view.rerender({ insertRequest: { id: 8001, text: "default next turn", mode: "replace" } }); |
| 44 | await act(async () => { (document.querySelector(".composer__btn--send") as HTMLButtonElement).click(); await flush(); }); |
| 45 | assert.equal(enqueues, 1, "ordinary send while running enqueues a follow-up"); |
| 46 | assert.ok(!calls.some(call => call.kind === "enqueue_steer")); |
| 47 | await view.rerender({ insertRequest: { id: 8002, text: "explicit current turn", mode: "replace" } }); |
| 48 | await act(async () => { (document.querySelector(".composer__queue-steer") as HTMLButtonElement).click(); await flush(); }); |
| 49 | assert.equal(enqueues, 1); |
| 50 | assert.equal(calls.at(-1)?.kind, "enqueue_steer"); |
| 51 | assert.equal(calls.at(-1)?.turnId, "live-turn"); |
| 52 | assert.ok(calls.at(-1)?.idempotencyKey); |
| 53 | |
| 54 | await view.rerender({ insertRequest: { id: 8003, text: "main draft survives editing", mode: "replace" } }); |
| 55 | const paste = new Event("paste", { bubbles: true, cancelable: true }); |
| 56 | Object.defineProperty(paste, "clipboardData", { value: { files: [new File(["fixture"], "draft.png", { type: "image/png" })], items: [], types: [], getData: () => "" } }); |
| 57 | await act(async () => { document.querySelector("textarea.composer__input")!.dispatchEvent(paste); await flush(); }); |
| 58 | const attachmentCard = document.querySelector(".composer-context__item"); |
| 59 | assert.ok(attachmentCard, "main composer has an attachment before editing"); |
| 60 | const edit = document.querySelector('.inbox-queue__row[data-item-id="queue-save"] button[aria-label="Edit"]') as HTMLButtonElement; |
| 61 | assert.ok(edit); |
| 62 | await act(async () => { (document.querySelector('.inbox-queue__row[data-item-id="queue-save"] button[aria-label="Edit"]') as HTMLButtonElement).click(); await flush(); }); |
| 63 | const textarea = document.querySelector(".inbox-queue__editor textarea") as HTMLTextAreaElement; |
| 64 | assert.ok(textarea.value.includes("完成后验证"), "the editor receives the complete body"); |
| 65 | assert.equal(textarea.closest("li")?.dataset.itemId, "queue-save", "editor belongs to the original row"); |
| 66 | assert.equal(document.querySelectorAll(".inbox-queue__editor").length, 1); |
| 67 | assert.equal(document.querySelectorAll(".inbox-queue__row").length, 2, "editing does not expand unrelated rows"); |
| 68 | assert.equal(textarea.closest("li")?.querySelector('.inbox-queue__body'), null, "no duplicate message preview while editing"); |
| 69 | assert.equal(textarea.closest("li")?.querySelectorAll("footer button").length, 2, "only cancel and save in ordinary editing"); |
| 70 | assert.equal(document.querySelector(".inbox-queue__running")?.closest(".inbox-queue__editor"), null); |
| 71 | assert.ok(document.querySelector(".inbox-queue__running button"), "stop stays available outside editor"); |
| 72 | assert.equal(document.querySelector(".composer-wrap")?.getAttribute("data-queue-editing"), "true"); |
| 73 | assert.equal(document.querySelector(".composer-context__item"), attachmentCard, "hiding preserves the mounted attachment"); |
| 74 | await act(async () => { textarea.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true })); await flush(); }); |
| 75 | assert.equal(calls.at(-1)?.kind, "edit"); |
| 76 | assert.equal((document.querySelector("textarea.composer__input") as HTMLTextAreaElement).value, "main draft survives editing"); |
| 77 | assert.equal(enqueues, 1, "saving an edit never adds a new message"); |
| 78 | assert.equal(document.querySelector(".composer-context__item"), attachmentCard, "saved queue edits do not clear main attachments"); |
| 79 | assert.equal(document.querySelector(".composer-wrap")?.getAttribute("data-queue-editing"), null); |
| 80 | await act(async () => { (document.querySelector('.inbox-queue__row[data-item-id="queue-save"] button[aria-label="Edit"]') as HTMLButtonElement).click(); await flush(); }); |
| 81 | assert.ok(document.querySelector(".inbox-queue__editor")); |
| 82 | await act(async () => { |
| 83 | document.querySelector(".inbox-queue__editor textarea")!.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Escape", bubbles: true })); |
| 84 | await flush(); |
| 85 | }); |
| 86 | assert.equal(document.querySelector(".inbox-queue__editor"), null); |
| 87 | assert.equal(document.querySelector(".composer-wrap")?.getAttribute("data-queue-editing"), null); |
| 88 | assert.ok(!document.querySelector(".inbox-queue")!.textContent!.includes("Your edits have been kept"), "unchanged cancellation leaves no draft banner"); |
| 89 | assert.equal(cancelled, undefined, "Escape exits editing without stopping the task"); |
| 90 | await act(async () => { (document.querySelector('.inbox-queue__row[data-item-id="queue-save"] button[aria-label="Edit"]') as HTMLButtonElement).click(); await flush(); }); |
| 91 | await act(async () => { |
| 92 | const saveButton = document.querySelector(".inbox-queue__editor .btn--primary") as HTMLButtonElement; |
| 93 | saveButton.focus(); |
| 94 | saveButton.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Escape", bubbles: true })); |
| 95 | await flush(); |
| 96 | }); |
| 97 | assert.equal(Boolean(document.querySelector(".inbox-queue__editor")), false, "Escape also exits when an editor action has keyboard focus"); |
| 98 | assert.equal(document.querySelector(".composer-context__item"), attachmentCard, "cancelling restores the same attachment"); |
| 99 | assert.equal((document.querySelector("textarea.composer__input") as HTMLTextAreaElement).value, "main draft survives editing"); |
| 100 | failSave = true; |
| 101 | await act(async () => { (document.querySelector('.inbox-queue__row[data-item-id="queue-save"] button[aria-label="Edit"]') as HTMLButtonElement).click(); await flush(); }); |
| 102 | await act(async () => { |
| 103 | document.querySelector(".inbox-queue__editor textarea")!.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", ctrlKey: true, bubbles: true })); |
| 104 | await flush(); |
| 105 | }); |
| 106 | assert.equal(document.querySelector('.inbox-queue__editor [role="alert"]')?.textContent, "This message was edited elsewhere. Your changes were kept."); |
| 107 | assert.equal(document.querySelector(".composer-wrap")?.getAttribute("data-queue-editing"), "true", "failed save keeps edit mode and original text"); |
| 108 | assert.equal((document.querySelector(".inbox-queue__editor textarea") as HTMLTextAreaElement).value, textarea.value); |
| 109 | failSave = false; |
| 110 | await act(async () => { |
| 111 | const reload = [...document.querySelectorAll<HTMLButtonElement>(".inbox-queue__editor button")].find(button => button.textContent === "Load latest version")!; |
| 112 | reload.click(); await flush(); |
| 113 | }); |
| 114 | departOnSave = true; |
| 115 | await act(async () => { |
| 116 | document.querySelector(".inbox-queue__editor textarea")!.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", ctrlKey: true, bubbles: true })); |
| 117 | await flush(); |
| 118 | }); |
| 119 | assert.equal(document.querySelector('.inbox-queue__row[data-item-id="queue-save"]'), null); |
| 120 | assert.ok(document.querySelector('.inbox-queue__departed textarea'), "dispatch or deletion cannot unmount unsaved text"); |
| 121 | assert.ok(document.querySelector('.inbox-queue__departed [role="alert"]')); |
| 122 | await act(async () => { (document.querySelector(".inbox-queue__running button") as HTMLButtonElement).click(); await flush(); }); |
| 123 | assert.deepEqual(cancelled, [], "stop during editing leaves the queue intact"); |
| 124 | assert.ok(document.querySelector('.inbox-queue__departed textarea'), "stop does not discard edit text"); |
| 125 | await act(async () => { |
| 126 | document.querySelector(".inbox-queue__editor textarea")!.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Escape", bubbles: true })); |
| 127 | await flush(); |
| 128 | }); |
| 129 | await act(async () => { (document.querySelector(".composer__btn--stop") as HTMLButtonElement).click(); await flush(); }); |
| 130 | assert.deepEqual(cancelled, [], "stop leaves the durable queue intact"); |
| 131 | otherHost = true; |
| 132 | await view.rerender({ inboxHostId: "second-host", inboxWorkspace: "/repo" }); |
| 133 | await act(async () => { await flush(); }); |
| 134 | assert.ok(document.querySelector('.inbox-queue__row[data-item-id="other-host"]'), "a different host may start at a lower revision for the same session path"); |
| 135 | assert.equal(document.querySelector('.inbox-queue__row[data-item-id="queue-save"]'), null, "the previous host's queue cannot leak into the new host"); |
| 136 | await act(async () => { view.root.unmount(); }); |
| 137 | dom.window.close(); |
| 138 | console.log("PASS composer queue: default follow-up, explicit guide, in-place editing, draft preservation, stop"); |
| 139 |