diff --git a/test/e2e/suite/liveProgress.test.ts b/test/e2e/suite/liveProgress.test.ts new file mode 100644 index 0000000..8e339bd --- /dev/null +++ b/test/e2e/suite/liveProgress.test.ts @@ -0,0 +1,82 @@ +import * as assert from "assert"; +import * as fs from "fs"; +import * as path from "path"; +import * as vscode from "vscode"; +import type { CowritingApi } from "../../../src/extension"; + +const WS = process.env.E2E_WORKSPACE!; +const settle = () => new Promise((r) => setTimeout(r, 400)); + +async function getApi(): Promise { + const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; + const api = (await ext.activate()) as CowritingApi; + assert.ok(api?.trackChangesPreviewController, "exports preview controller"); + return api; +} + +async function freshDoc(rel: string, body: string): Promise { + const abs = path.join(WS, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, body, "utf8"); + const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(abs)); + await vscode.window.showTextDocument(doc); + await settle(); + return doc; +} + +// #60 host E2E (no LLM): live progress is purely additive observability. The +// harness can't read notification subtitles, so it asserts the CONTRACT — +// progress events don't change the proposals (INV-44), and an aborted turn +// proposes nothing (INV-47). The live notification/OutputChannel UI itself is +// covered by the turnProgress unit tests + the manual smoke. +suite("#60 live turn progress (additive + cancel)", () => { + test("a stub that emits progress still produces the same proposals (INV-44)", async () => { + const doc = await freshDoc("docs/live60-additive.md", "# Title\n\nOld paragraph.\n"); + const api = await getApi(); + const ctl = api.trackChangesPreviewController; + await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); + await settle(); + + // The stub honors opts.onProgress (emitting synthetic snapshots) but returns + // the same rewrite — proposals must be unaffected by progress events. + ctl.setEditTurnForTest(async (_i, _text, opts) => { + opts?.onProgress?.({ phase: "writing", chars: 5 }); + opts?.onProgress?.({ phase: "writing", chars: 13, tokens: 1234, textDelta: "New paragraph." }); + return { replacement: "# Title\n\nNew paragraph.\n", model: "sonnet", sessionId: "e2e-live60" }; + }); + + const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "rewrite the paragraph"); + await settle(); + assert.strictEqual(ids.length, 1, "one changed block → one proposal, regardless of progress events"); + const view = api.proposalController.listProposals(doc).find((v) => v.id === ids[0]); + assert.ok(view, "the proposal is live"); + assert.ok(doc.getText().includes("Old paragraph."), "document unchanged by propose (INV-10)"); + }); + + test("an aborted turn proposes nothing (INV-47)", async () => { + const doc = await freshDoc("docs/live60-cancel.md", "# Title\n\nOld paragraph.\n"); + const api = await getApi(); + const ctl = api.trackChangesPreviewController; + await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); + await settle(); + + // Simulate the SDK honoring the abort signal: throw as runEditTurn does on a + // non-"completed" (aborted) status. + ctl.setEditTurnForTest(async (_i, _text, opts) => { + if (opts?.signal?.aborted) throw new Error("claude-code turn aborted"); + throw new Error("claude-code turn aborted"); + }); + + const ac = new AbortController(); + ac.abort(); + let ids: string[] = []; + try { + ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "rewrite", { signal: ac.signal }); + } catch { + ids = []; + } + await settle(); + assert.strictEqual(ids.length, 0, "aborted turn must create no proposals"); + assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "no pending proposals after abort"); + }); +});