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 { addProposal, setProposalApplied } from "../../../src/proposalModel"; import { buildFingerprint } from "../../../src/anchorer"; 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 controllers"); return api; } /** Also enters coediting (INV-10/Task 2): the baseline is established only on * entry, not merely on open. */ 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 vscode.commands.executeCommand("cowriting.coeditDocument"); await settle(); return { doc, key: uri.toString() }; } suite("F12 inline diff — seam landBaseline (#64, INV-48)", () => { test("applyAgentEdit with landBaseline:false applies text but does NOT advance the baseline", async () => { const { doc, key } = await freshDoc("docs/f12-land.md", "# T\n\nalpha here.\n"); const api = await getApi(); await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await settle(); const before = api.diffViewController.getBaseline(key)?.text ?? doc.getText(); const start = doc.getText().indexOf("alpha"); const ok = await api.attributionController.applyAgentEdit( doc, new vscode.Range(doc.positionAt(start), doc.positionAt(start + "alpha".length)), "ALPHA", { kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { landBaseline: false }, ); await settle(); assert.ok(ok, "edit applied"); assert.ok(doc.getText().includes("ALPHA"), "text in buffer"); assert.strictEqual(api.diffViewController.getBaseline(key)?.text ?? doc.getText(), before, "baseline unchanged"); }); }); suite("F12 inline diff — finalize / revert in place (#64, INV-51)", () => { // Optimistic apply lands the text + re-anchors; the buffer becomes the accepted result. test("optimisticApply puts the proposed text in the buffer and re-anchors", async () => { const { doc } = await freshDoc("docs/f12-opt.md", "# T\n\nReplace alpha please.\n"); const api = await getApi(); const p = api.proposalController; const fp = { text: "Replace alpha please.", before: "", after: "", lineHint: 2 }; const id = await p.propose(doc, fp, "Replace ALPHA please.", { kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" }); await api.proposalController.optimisticApply(doc, id!); await settle(); assert.ok(doc.getText().includes("Replace ALPHA please."), "applied to buffer"); // re-anchored: the proposal still resolves against the mutated buffer assert.strictEqual(p.listProposals(doc)[0].anchorStart !== null, true, "re-anchored, resolves"); assert.strictEqual(p.listProposals(doc)[0].original, "Replace alpha please.", "original stored"); }); test("finalizeInPlace clears the proposal and keeps the applied text (no double-apply)", async () => { const { doc } = await freshDoc("docs/f12-fin.md", "# T\n\nKeep alpha now.\n"); const api = await getApi(); const p = api.proposalController; const docPath = p.keyFor(doc); const fp = { text: "Keep alpha now.", before: "", after: "", lineHint: 2 }; const id = await p.propose(doc, fp, "Keep ALPHA now.", { kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" }); await p.optimisticApply(doc, id!); await settle(); const ok = await p.finalizeInPlace(docPath, id!); await settle(); assert.ok(ok, "finalized"); assert.strictEqual(doc.getText(), "# T\n\nKeep ALPHA now.\n", "applied text retained, no double-apply"); assert.strictEqual(p.listProposals(doc).length, 0, "proposal cleared"); }); test("revertInPlace restores the original and clears the proposal", async () => { const { doc } = await freshDoc("docs/f12-rev.md", "# T\n\nUndo alpha here.\n"); const api = await getApi(); const p = api.proposalController; const docPath = p.keyFor(doc); const fp = { text: "Undo alpha here.", before: "", after: "", lineHint: 2 }; const id = await p.propose(doc, fp, "Undo ALPHA here.", { kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" }); await p.optimisticApply(doc, id!); await settle(); const ok = await p.revertInPlace(docPath, id!); await settle(); assert.ok(ok, "reverted"); assert.strictEqual(doc.getText(), "# T\n\nUndo alpha here.\n", "original restored"); assert.strictEqual(p.listProposals(doc).length, 0, "proposal cleared"); }); test("rejectAll reverts every pending proposal", async () => { const { doc } = await freshDoc("docs/f12-rejall.md", "# T\n\nOne aaa.\n\nTwo bbb.\n"); const api = await getApi(); const ctl = api.trackChangesPreviewController; const p = api.proposalController; ctl.setEditTurnForTest(async () => ({ replacement: "# T\n\nOne AAA.\n\nTwo BBB.\n", model: "m", sessionId: "s" })); const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "up"); await settle(); for (const id of ids) await p.optimisticApply(doc, id); await settle(); assert.ok(doc.getText().includes("AAA") && doc.getText().includes("BBB"), "both applied"); const { reverted } = await p.rejectAll(doc); await settle(); assert.strictEqual(reverted, ids.length, "all reverted"); assert.strictEqual(doc.getText(), "# T\n\nOne aaa.\n\nTwo bbb.\n", "document restored"); assert.strictEqual(p.listProposals(doc).length, 0, "all cleared"); }); }); suite("F12 inline diff — INV-50 listProposals.replaced", () => { test("listProposals reports the original as `replaced` after optimistic apply", async () => { const { doc } = await freshDoc("docs/f12-replaced.md", "# R\n\nbrown here.\n"); const api = await getApi(); const ctl = api.trackChangesPreviewController; ctl.setEditTurnForTest(async () => ({ replacement: "# R\n\nred here.\n", model: "m", sessionId: "s" })); await ctl.runEditAndPropose(doc, { kind: "document" }, "x"); await settle(); await settle(); assert.strictEqual(api.proposalController.listProposals(doc)[0].replaced, "brown here."); }); }); suite("F12 inline diff — editor surface (#64, INV-48/52)", () => { test("proposing optimistically applies into the editor and the buffer is the accepted result", async () => { const { doc } = await freshDoc("docs/f12-editor.md", "# E\n\nThe brown fox runs.\n"); const api = await getApi(); const ctl = api.trackChangesPreviewController; ctl.setEditTurnForTest(async () => ({ replacement: "# E\n\nThe red fox runs.\n", model: "m", sessionId: "s" })); await ctl.runEditAndPropose(doc, { kind: "document" }, "recolor the fox"); await settle(); await settle(); assert.ok(doc.getText().includes("The red fox runs."), "optimistically applied into the buffer"); const v = api.proposalController.listProposals(doc)[0]; assert.strictEqual(v.original, "The brown fox runs.", "original captured for the deletion hint/revert"); }); test("editing the inserted text then finalizing keeps the human edit", async () => { const { doc } = await freshDoc("docs/f12-edit-keep.md", "# E\n\nalpha word here.\n"); const api = await getApi(); const ctl = api.trackChangesPreviewController; ctl.setEditTurnForTest(async () => ({ replacement: "# E\n\nALPHA word here.\n", model: "m", sessionId: "s" })); const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "up"); await settle(); await settle(); // human tweaks the inserted text const at = doc.getText().indexOf("ALPHA"); const we = new vscode.WorkspaceEdit(); we.replace(doc.uri, new vscode.Range(doc.positionAt(at), doc.positionAt(at + 5)), "ALPHA!"); await vscode.workspace.applyEdit(we); await settle(); // keyFor(doc) gives the repo-relative path that finalizeInPlace uses as its key; // the `key` from freshDoc is the URI string, which would not match for in-workspace docs. const docKey = api.proposalController.keyFor(doc); await api.proposalController.finalizeInPlace(docKey, ids[0]); await settle(); assert.ok(doc.getText().includes("ALPHA! word here."), "human edit preserved on accept"); assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "cleared"); }); }); suite("F12 inline diff — control parity (#64, INV-53)", () => { test("reject from the webview reverts in place; rejectAll clears every proposal", async () => { const { doc, key } = await freshDoc("docs/f12-parity.md", "# P\n\nuno aaa.\n\ndos bbb.\n"); const api = await getApi(); const ctl = api.trackChangesPreviewController; await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await settle(); ctl.setEditTurnForTest(async () => ({ replacement: "# P\n\nuno AAA.\n\ndos BBB.\n", model: "m", sessionId: "s" })); const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "up"); await settle(); await settle(); assert.ok(doc.getText().includes("AAA") && doc.getText().includes("BBB")); // reject ONE via the webview intent → that block reverts, the other stays applied ctl.receiveMessage(key, { type: "reject", proposalId: ids[0] }); await settle(); await settle(); assert.ok(doc.getText().includes("uno aaa.") && doc.getText().includes("BBB"), "one reverted, one applied"); // rejectAll via the command → all gone, document restored ctl.receiveMessage(key, { type: "rejectAll" }); await settle(); await settle(); assert.strictEqual(doc.getText(), "# P\n\nuno aaa.\n\ndos bbb.\n", "document restored"); assert.strictEqual(api.proposalController.listProposals(doc).length, 0); }); test("cowriting.rejectAllProposals is registered + markdown-guarded", async () => { const all = await vscode.commands.getCommands(true); assert.ok(all.includes("cowriting.rejectAllProposals")); const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8")); const entry = (pkg.contributes.menus.commandPalette as Array<{ command: string; when?: string }>).find( (m) => m.command === "cowriting.rejectAllProposals", ); assert.ok(entry && /editorLangId == markdown/.test(entry.when ?? ""), "palette-guarded on markdown"); }); }); // Reload-safety: a proposal that was optimistically applied in a PRIOR session // persists with `original` set and its fp re-anchored to the applied text. A fresh // controller (empty in-memory `applied` set) must NOT re-capture `original` from the // already-applied buffer — doing so would clobber the true revert target and break // Reject. (Regression for the final-review CRITICAL; INV-51/54.) suite("F12 inline diff — reload-safety (#64, INV-51/54)", () => { test("optimisticApply does not clobber a previously-persisted original", async () => { const TRUE_ORIGINAL = "The original sentence here."; const APPLIED = "The APPLIED sentence here."; const { doc } = await freshDoc("docs/f12-reload.md", `# R\n\n${TRUE_ORIGINAL}\n`); const api = await getApi(); const p = api.proposalController; const key = p.keyFor(doc); // 1) The buffer holds the APPLIED text (as the saved-while-pending file would). const at = doc.getText().indexOf(TRUE_ORIGINAL); const we = new vscode.WorkspaceEdit(); we.replace(doc.uri, new vscode.Range(doc.positionAt(at), doc.positionAt(at + TRUE_ORIGINAL.length)), APPLIED); assert.ok(await vscode.workspace.applyEdit(we), "apply the prior-session applied text"); await settle(); // 2) Record the proposal directly in the sidecar exactly as a prior session left // it: fp anchored to the APPLIED text + `original` = the TRUE original. We do // NOT call optimisticApply, so this controller's in-memory `applied` stays empty. const appliedAt = doc.getText().indexOf(APPLIED); const appliedFp = buildFingerprint(doc.getText(), { start: appliedAt, end: appliedAt + APPLIED.length }); let id = ""; api.sidecarRouter.update(key, (a) => { id = addProposal(a, appliedFp, APPLIED, { kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" }).proposalId; setProposalApplied(a, id, appliedFp, TRUE_ORIGINAL); }); p.renderAll(doc); await settle(); // 3) Re-entry as a reload would trigger (EditorProposalController re-applies on // onDidChangeProposals because `applied` is empty). The guard must preserve original. await p.optimisticApply(doc, id); await settle(); const view = p.listProposals(doc).find((v) => v.id === id); assert.ok(view, "proposal still present"); assert.strictEqual(view!.original, TRUE_ORIGINAL, "true original preserved, NOT clobbered with the applied text"); // 4) Reject restores the TRUE original (not the applied text). assert.ok(await p.revertInPlace(key, id), "revert"); await settle(); assert.ok(doc.getText().includes(TRUE_ORIGINAL), "reject restored the true original"); assert.ok(!doc.getText().includes(APPLIED), "applied text removed on reject"); }); });