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"); }); });