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"; import { renderPlain } from "../../../src/trackChangesModel"; 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 && api?.proposalController, "exports preview + proposal"); return api; } /** Create + open a fresh markdown doc under WS, returning the doc + its uri key. */ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> { const abs = path.join(WS, rel); fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.writeFileSync(abs, body, "utf8"); const uri = vscode.Uri.file(abs); const doc = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(doc); await settle(); return { doc, key: uri.toString() }; } async function propose( doc: vscode.TextDocument, key: string, target: string, newText: string, turnId: string, ): Promise { const start = doc.getText().indexOf(target); assert.ok(start >= 0, `fixture contains "${target}"`); const id = await vscode.commands.executeCommand("cowriting.proposeAgentEdit", { uri: key, start, end: start + target.length, newText, model: "sonnet", sessionId: "e2e-f10rev", turnId, }); assert.ok(id, "propose returns an id"); return id!; } // F10 host E2E (no LLM): the rendered preview is the single INTERACTIVE review // surface. This suite owns docs/f10review.md (its main flow is order-dependent) // plus its own disjoint fresh docs for the isolated cases (status-bar, toggle). // The editor is zero-decoration; everything observable here is the data layer + // the on-state renderReview HTML the panel posts. suite("F10 interactive review (host E2E — preview is the single review surface, no LLM)", () => { const DOC_REL = "docs/f10review.md"; const PROSE = "The original review paragraph that lives in this doc."; const T1 = "A first claude target sentence here."; const T2 = "A second claude target sentence here."; test("open on a markdown doc → panel open, fresh baseline all-unchanged, mode is on (PUC-1)", async () => { const { doc, key } = await freshDoc(DOC_REL, `# F10 review\n\n${PROSE}\n\n${T1}\n\n${T2}\n`); const api = await getApi(); assert.strictEqual(api.trackChangesPreviewController.isOpen(key), false, "no panel yet"); await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await settle(); assert.strictEqual(api.trackChangesPreviewController.isOpen(key), true, "panel open"); assert.strictEqual(api.trackChangesPreviewController.getMode(key), "on", "annotations default on (INV-33)"); const model = api.trackChangesPreviewController.getLastModel(key); assert.ok(model && model.length > 0, "a model was computed"); assert.ok(model!.every((o) => o.kind === "unchanged"), "fresh baseline == buffer → every block unchanged"); void doc; }); test("typing produces an added/changed block and a cw-by-human span in the on-state render (PUC-2)", async () => { const { key } = await reopen(DOC_REL); const api = await getApi(); const doc = byKey(key)!; const edit = new vscode.WorkspaceEdit(); edit.insert(doc.uri, doc.positionAt(doc.getText().length), "\n\nA freshly typed human paragraph.\n"); assert.ok(await vscode.workspace.applyEdit(edit), "operator edit applied"); await settle(); const kinds = (api.trackChangesPreviewController.getLastModel(key) ?? []).map((o) => o.kind); assert.ok(kinds.some((k) => k === "added" || k === "changed"), "an added/changed block after typing"); // Attribution recorded the human span (data layer), and the on-state render // author-colors that prose as cw-by-human. assert.ok( api.attributionController.spansFor(doc).some((s) => s.author === "human"), "a human span was recorded for the typed text", ); const html = api.trackChangesPreviewController.renderHtmlFor(key); assert.match(html, //, "typed text is author-colored human in the on-state"); }); test("propose → the preview surfaces it as a cw-proposal block with ✓/✗ actions (PUC-3)", async () => { const { doc, key } = await reopen(DOC_REL); const api = await getApi(); const id = await propose(doc, key, T1, "A FIRST claude REPLACEMENT sentence.", "turn-f10-1"); await settle(); const views = api.proposalController.listProposals(doc); assert.ok(views.some((v) => v.id === id), "listProposals returns a view with the id"); const html = api.trackChangesPreviewController.renderHtmlFor(key); assert.ok(html.includes(`data-proposal-id="${id}"`), "the preview renders the proposal block by id"); assert.match(html, /class="cw-actions"/, "the proposal block carries ✓/✗ actions"); // #31: the proposal renders INLINE at its anchor (right after T1's block), not // trailing the whole document — so it appears BEFORE the following T2 block. const pIdx = html.indexOf(`data-proposal-id="${id}"`); const t2Idx = html.indexOf("second claude target"); assert.ok(t2Idx >= 0 && pIdx < t2Idx, "the proposal renders in place, before the following block (#31)"); // INV-10: proposing never touches the document. assert.ok(doc.getText().includes(T1), "document unchanged by propose"); }); test("accept → the proposal lands, clears from the preview, and the baseline advances (PUC-4)", async () => { const { doc, key } = await reopen(DOC_REL); const api = await getApi(); const id = api.proposalController.listProposals(doc).find((v) => v.replaced === T1)!.id; assert.ok(await api.proposalController.acceptById(DOC_REL, id), "accept applies via the seam"); await settle(); const replacement = "A FIRST claude REPLACEMENT sentence."; assert.ok(doc.getText().includes(replacement), "replacement landed in the document"); assert.ok(!doc.getText().includes(T1), "original target gone"); assert.ok(!api.proposalController.listProposals(doc).some((v) => v.id === id), "proposal cleared from listProposals"); const html = api.trackChangesPreviewController.renderHtmlFor(key); assert.ok(!html.includes(`data-proposal-id="${id}"`), "the accepted proposal block is gone from the preview"); // The baseline advanced on the landing (INV-18): the landed text is not marked. const model = api.trackChangesPreviewController.getLastModel(key) ?? []; const marked = model.some((o) => o.kind !== "unchanged" && o.block.raw.includes("FIRST claude REPLACEMENT")); assert.ok(!marked, "the just-landed Claude text renders unmarked (baseline advanced)"); }); test("reject → the proposal vanishes and the document is untouched (PUC-5)", async () => { const { doc, key } = await reopen(DOC_REL); const api = await getApi(); const before = doc.getText(); const id2 = await propose(doc, key, T2, "A SECOND would-be replacement.", "turn-f10-2"); await settle(); assert.ok(api.proposalController.listProposals(doc).some((v) => v.id === id2), "second proposal pending"); assert.strictEqual(api.proposalController.rejectById(DOC_REL, id2), true, "reject"); await settle(); assert.ok(!api.proposalController.listProposals(doc).some((v) => v.id === id2), "rejected proposal gone"); assert.strictEqual(doc.getText(), before, "document untouched by reject"); const html = api.trackChangesPreviewController.renderHtmlFor(key); assert.ok(!html.includes(`data-proposal-id="${id2}"`), "no rejected block in the preview"); }); test("toggle annotations off → mode round-trips and the off-state render is plain (no cw- marks) (INV-33)", async () => { // A fresh doc with a pending proposal so the on-state DOES carry a cw- mark, // making the off-state's absence of marks meaningful (not tautological). const { doc, key } = await freshDoc( "docs/f10toggle.md", "# F10 toggle\n\nA toggle target sentence to propose over.\n", ); const api = await getApi(); await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await settle(); const id = await propose(doc, key, "A toggle target sentence to propose over.", "A TOGGLED replacement.", "turn-tog"); await settle(); // on-state: the proposal block + its actions are present. assert.strictEqual(api.trackChangesPreviewController.getMode(key), "on", "starts on"); const onHtml = api.trackChangesPreviewController.renderHtmlFor(key); assert.ok(onHtml.includes(`data-proposal-id="${id}"`), "on-state shows the proposal block"); // toggle off → mode round-trips; the off-state body is plain markdown. api.trackChangesPreviewController.setMode(key, "off"); await settle(); assert.strictEqual(api.trackChangesPreviewController.getMode(key), "off", "mode flipped to off"); assert.ok(api.trackChangesPreviewController.isOpen(key), "panel stays open across the toggle"); // renderHtmlFor is the on-state seam; the off-state body is renderPlain(current) // (INV-33). Assert the actual off-state body the controller posts has no cw- // author/proposal marks — meaningful because the on-state above DID carry one. const offBody = renderPlain(doc.getText()); assert.ok(!/cw-proposal|cw-by-claude|cw-by-human|cw-del/.test(offBody), "off-state render carries no cw- marks"); // toggle back on → marks return. api.trackChangesPreviewController.setMode(key, "on"); await settle(); assert.strictEqual(api.trackChangesPreviewController.getMode(key), "on", "mode flipped back on"); assert.ok( api.trackChangesPreviewController.renderHtmlFor(key).includes(`data-proposal-id="${id}"`), "on-state shows the proposal block again", ); }); test("status-bar (PUC-6): a pending proposal with NO panel shows the indicator; opening the preview hides it", async () => { // Isolated fresh doc: no preview opened, so the off-panel indicator is live. const { doc, key } = await freshDoc("docs/f10status.md", "# F10 status\n\nA status target sentence here.\n"); const api = await getApi(); assert.strictEqual(api.trackChangesPreviewController.isOpen(key), false, "no panel for this doc"); await propose(doc, key, "A status target sentence here.", "A STATUS replacement.", "turn-stat"); await settle(); const text = api.trackChangesPreviewController.statusText(); assert.ok(text && text.length > 0, "the off-panel indicator shows a non-empty status"); assert.match(text!, /1 Claude proposal/, "it mentions the pending count"); // open the preview for this doc → the off-panel indicator hides (undefined). await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await settle(); assert.strictEqual(api.trackChangesPreviewController.isOpen(key), true, "panel open"); assert.strictEqual(api.trackChangesPreviewController.statusText(), undefined, "indicator hidden once the panel is open"); }); test("clean editor: data layers intact AND the retired in-editor surfaces are gone (INV-32)", async () => { const { doc, key } = await freshDoc("docs/f10clean.md", "# F10 clean\n\nA clean target sentence here.\n"); const api = await getApi(); const id = await propose(doc, key, "A clean target sentence here.", "A CLAUDE clean replacement.", "turn-clean"); await settle(); assert.ok(await api.proposalController.acceptById("docs/f10clean.md", id), "accept lands the Claude edit"); await settle(); // data layer intact: a Claude (agent) attribution span exists. assert.ok( api.attributionController.getSpans("docs/f10clean.md").some((s) => s.authorKind === "agent"), "agent span recorded (attribution data layer intact)", ); // the retired toggle is gone from the palette (no editor decorations — INV-32). const all = await vscode.commands.getCommands(true); assert.ok(!all.includes("cowriting.toggleAttribution"), "cowriting.toggleAttribution is retired"); // the F6 diff toggle's keybinding is hidden (when:false) — it is not a user surface. const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8")); const dKb = (pkg.contributes.keybindings as Array<{ command: string; key: string; when?: string }>).find( (k) => k.command === "cowriting.toggleDiffView" && k.key === "ctrl+alt+d", ); assert.ok(dKb, "the ctrl+alt+d toggleDiffView keybinding is declared"); assert.strictEqual(dKb!.when, "false", "…but hidden (when:false) — F6 is a data layer, not a user surface"); void key; }); }); // ---- helpers reused across the order-dependent main-flow tests ---- function byKey(key: string): vscode.TextDocument | undefined { return vscode.workspace.textDocuments.find((d) => d.uri.toString() === key); } async function reopen(rel: string): Promise<{ doc: vscode.TextDocument; key: string }> { const uri = vscode.Uri.file(path.join(WS, rel)); const doc = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(doc); await settle(); return { doc, key: uri.toString() }; }