From 0743cf9a8a4a015634561b9eace8c5d4afcd30f9 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Fri, 26 Jun 2026 04:37:52 -0700 Subject: [PATCH] =?UTF-8?q?test(#60):=20host=20E2E=20=E2=80=94=20progress?= =?UTF-8?q?=20is=20additive;=20abort=20proposes=20nothing=20(INV-44/47)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- test/e2e/suite/liveProgress.test.ts | 82 +++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 test/e2e/suite/liveProgress.test.ts 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"); + }); +});