82cea74f2c
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
347 lines
19 KiB
TypeScript
347 lines
19 KiB
TypeScript
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<CowritingApi> {
|
|
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
|
const api = (await ext.activate()) as CowritingApi;
|
|
assert.ok(api?.diffViewController && 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();
|
|
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 editFlow = api.editFlow;
|
|
const p = api.proposalController;
|
|
editFlow.setEditTurnForTest(async () => ({ replacement: "# T\n\nOne AAA.\n\nTwo BBB.\n", model: "m", sessionId: "s" }));
|
|
const ids = await editFlow.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 editFlow = api.editFlow;
|
|
editFlow.setEditTurnForTest(async () => ({ replacement: "# R\n\nred here.\n", model: "m", sessionId: "s" }));
|
|
await editFlow.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 editFlow = api.editFlow;
|
|
editFlow.setEditTurnForTest(async () => ({ replacement: "# E\n\nThe red fox runs.\n", model: "m", sessionId: "s" }));
|
|
await editFlow.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 editFlow = api.editFlow;
|
|
editFlow.setEditTurnForTest(async () => ({ replacement: "# E\n\nALPHA word here.\n", model: "m", sessionId: "s" }));
|
|
const ids = await editFlow.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-one via revertInPlace reverts in place; rejectAll clears every proposal", async () => {
|
|
const { doc } = await freshDoc("docs/f12-parity.md", "# P\n\nuno aaa.\n\ndos bbb.\n");
|
|
const api = await getApi();
|
|
const p = api.proposalController;
|
|
const editFlow = api.editFlow;
|
|
editFlow.setEditTurnForTest(async () => ({ replacement: "# P\n\nuno AAA.\n\ndos BBB.\n", model: "m", sessionId: "s" }));
|
|
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "up");
|
|
await settle(); await settle();
|
|
assert.ok(doc.getText().includes("AAA") && doc.getText().includes("BBB"));
|
|
// reject ONE via the same seam the per-proposal "✗ Reject" CodeLens uses
|
|
// (Task 8, PUC-2) → that block reverts, the other stays applied.
|
|
const docKey = p.keyFor(doc);
|
|
assert.ok(await p.revertInPlace(docKey, ids[0]), "revert one proposal");
|
|
await settle(); await settle();
|
|
assert.ok(doc.getText().includes("uno aaa.") && doc.getText().includes("BBB"), "one reverted, one applied");
|
|
// rejectAll via the same seam the top-of-file "✗ Reject all" CodeLens uses →
|
|
// all gone, document restored.
|
|
const { reverted } = await p.rejectAll(doc);
|
|
assert.strictEqual(reverted, 1, "the one remaining proposal reverted");
|
|
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");
|
|
});
|
|
});
|
|
|
|
// #70 (INV-5): rejecting a proposal AFTER typing inside its pending range must
|
|
// still restore the retained original. The exact-substring anchor is orphaned by
|
|
// the interior tweak (INV-1/INV-11) — the revert falls back to the F12
|
|
// applied-range bookkeeping (appliedSpans), which shift-tracks the span through
|
|
// interior edits.
|
|
suite("F12 inline diff — #70 reject after interior edit (INV-5)", () => {
|
|
test("revertInPlace restores the original after a tweak inside the pending range", async () => {
|
|
const ORIGINAL = "The quick brown fox jumps.";
|
|
const { doc } = await freshDoc("docs/s70-reject-tweaked.md", `# T\n\n${ORIGINAL}\n`);
|
|
const api = await getApi();
|
|
const p = api.proposalController;
|
|
const docKey = p.keyFor(doc);
|
|
const fp = { text: ORIGINAL, before: "", after: "", lineHint: 2 };
|
|
const id = await p.propose(doc, fp, "The rapid brown fox jumps.",
|
|
{ kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" });
|
|
await p.optimisticApply(doc, id!);
|
|
await settle();
|
|
assert.ok(doc.getText().includes("The rapid brown fox jumps."), "optimistically applied");
|
|
// Human types INSIDE the pending range → orphans the exact anchor (INV-11).
|
|
const at = doc.getText().indexOf("rapid");
|
|
const we = new vscode.WorkspaceEdit();
|
|
we.replace(doc.uri, new vscode.Range(doc.positionAt(at), doc.positionAt(at + "rapid".length)), "RAPID-ish");
|
|
assert.ok(await vscode.workspace.applyEdit(we), "interior tweak applied");
|
|
await settle();
|
|
assert.strictEqual(p.listProposals(doc).find((v) => v.id === id)!.anchorStart, null, "anchor orphaned by the tweak");
|
|
// ✗ Reject: must restore the retained original EXACTLY (INV-5), not skip.
|
|
assert.ok(await p.revertInPlace(docKey, id!), "reject reports success");
|
|
await settle();
|
|
assert.strictEqual(doc.getText(), `# T\n\n${ORIGINAL}\n`, "original restored exactly (INV-5)");
|
|
assert.strictEqual(p.listProposals(doc).length, 0, "proposal cleared");
|
|
});
|
|
|
|
test("rejectByIdInPlace routes the tweaked-applied case the same way", async () => {
|
|
const ORIGINAL = "A steady closing line.";
|
|
const { doc } = await freshDoc("docs/s70-reject-route.md", `# T\n\n${ORIGINAL}\n`);
|
|
const api = await getApi();
|
|
const p = api.proposalController;
|
|
const docKey = p.keyFor(doc);
|
|
const fp = { text: ORIGINAL, before: "", after: "", lineHint: 2 };
|
|
const id = await p.propose(doc, fp, "A wobbly closing line.",
|
|
{ kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" });
|
|
await p.optimisticApply(doc, id!);
|
|
await settle();
|
|
const at = doc.getText().indexOf("wobbly");
|
|
const we = new vscode.WorkspaceEdit();
|
|
we.replace(doc.uri, new vscode.Range(doc.positionAt(at), doc.positionAt(at)), "very-");
|
|
assert.ok(await vscode.workspace.applyEdit(we), "interior insertion applied");
|
|
await settle();
|
|
assert.ok(await p.rejectByIdInPlace(docKey, id!), "gesture-level reject succeeds");
|
|
await settle();
|
|
assert.strictEqual(doc.getText(), `# T\n\n${ORIGINAL}\n`, "original restored exactly (INV-5)");
|
|
});
|
|
|
|
// Fix direction (b): when the tracked span itself is distrusted (an edit
|
|
// straddled its boundary — here a deletion running from the heading into the
|
|
// span's interior), the reject FAILS honestly: warning path, proposal kept,
|
|
// buffer untouched. Silent-skip-and-report-success (the #70 bug) must not come
|
|
// back; reverting at a clamped guessed offset (INV-11) must not either.
|
|
// (A boundary-straddling DELETION is used because VS Code minimizes workspace
|
|
// edits before change events fire — a wholesale rewrite sharing a prefix/suffix
|
|
// decomposes into interior hunks the span legitimately survives.)
|
|
test("reject fails loudly — proposal kept, buffer untouched — when the span is distrusted", async () => {
|
|
const ORIGINAL = "Sentence one stands here.";
|
|
const { doc } = await freshDoc("docs/s70-reject-distrust.md", `# T\n\n${ORIGINAL}\n`);
|
|
const api = await getApi();
|
|
const p = api.proposalController;
|
|
const docKey = p.keyFor(doc);
|
|
const fp = { text: ORIGINAL, before: "", after: "", lineHint: 2 };
|
|
const id = await p.propose(doc, fp, "Sentence ONE stands here.",
|
|
{ kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" });
|
|
await p.optimisticApply(doc, id!);
|
|
await settle();
|
|
// Delete from inside the heading through the middle of the applied span:
|
|
// the edit straddles the span's start boundary → the tracked span is
|
|
// distrusted AND the exact anchor no longer resolves.
|
|
const mid = doc.getText().indexOf("ONE");
|
|
const we = new vscode.WorkspaceEdit();
|
|
we.replace(doc.uri, new vscode.Range(doc.positionAt(2), doc.positionAt(mid + 1)), "");
|
|
assert.ok(await vscode.workspace.applyEdit(we), "boundary-straddling deletion applied");
|
|
await settle();
|
|
const before = doc.getText();
|
|
assert.strictEqual(await p.revertInPlace(docKey, id!), false, "reject reports failure (no silent success)");
|
|
await settle();
|
|
assert.strictEqual(doc.getText(), before, "buffer untouched — never reverted by guess (INV-11)");
|
|
assert.ok(p.listProposals(doc).some((v) => v.id === id), "proposal kept (not silently dropped)");
|
|
// Cleanup for later tests: the never-locatable husk is discarded via the
|
|
// plain record-only reject.
|
|
assert.strictEqual(p.rejectById(docKey, id!), true);
|
|
});
|
|
});
|
|
|
|
// 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");
|
|
});
|
|
});
|