2170a0d282
Deletes the last bespoke UI surface (TrackChangesPreviewController + its sealed webview client assets); every entry point re-points at native VS Code chrome per spec §5: the #41 right-click entries become cowriting.openReviewPreview (enter coediting if needed -> "Open Preview to the Side"), Ctrl+Alt+R/Cmd+Alt+R moves to cowriting.reviewChanges (native diff), and the F12 CodeLens per-proposal titles read "Keep"/"Reject" with a top-of-file "Keep all (N)"/"Reject all" pair once >=2 proposals are pending. EditFlow drops its own askClaude/askEditInstruction (the webview's only caller) and its now-unused constructor params. Coverage that lived only in the webview's test seams (renderHtmlFor, receiveMessage, isOpen, ...) moves to direct calls against the surviving controllers/pure renderReview (test/e2e/suite/helpers.ts gains a shared renderHtmlFor probe); a genuine gap (pending-proposal + unchanged-block rendering) is backfilled in test/previewAnnotations.test.ts. README's "how it works" is rewritten as the native-surface map; the superseded F6/F7/F9/ F10/F11 sections are kept as a marked historical record rather than deleted outright. 292 unit + 91 E2E green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
190 lines
9.5 KiB
TypeScript
190 lines
9.5 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";
|
|
|
|
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?.proposalController && api?.editorProposalController, "exports controllers");
|
|
return api;
|
|
}
|
|
|
|
async function freshDoc(rel: string, body: string): Promise<vscode.TextDocument> {
|
|
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);
|
|
// INV-10: attribution tracking (accept's per-word attribution, INV-40) is
|
|
// gated on coediting (Task 4).
|
|
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
|
await settle();
|
|
return doc;
|
|
}
|
|
|
|
// #47 SLICE-2 (review, P1): a document rewrite proposes ONE F4 proposal per
|
|
// CHANGED BLOCK (INV-39 supersedes INV-37's per-word cut), but accepting a block
|
|
// reconciles attribution at WORD granularity (INV-40 — block = decision unit,
|
|
// word = attribution unit). Host E2E, no LLM (the edit turn is stubbed).
|
|
suite("F12 SLICE-2 — block-granularity document proposals (#47, INV-39/40/41)", () => {
|
|
// PUC-4: M changed blocks → M proposals; unchanged blocks → none.
|
|
test("edits across two paragraphs → two proposals; the untouched paragraph yields none (INV-39)", async () => {
|
|
const doc = await freshDoc(
|
|
"docs/f12-multi.md",
|
|
"# Doc\n\nFirst paragraph alpha.\n\nSecond paragraph beta.\n\nThird paragraph gamma.\n",
|
|
);
|
|
const api = await getApi();
|
|
const editFlow = api.editFlow;
|
|
editFlow.setEditTurnForTest(async () => ({
|
|
replacement: "# Doc\n\nFirst paragraph ALPHA.\n\nSecond paragraph beta.\n\nThird paragraph GAMMA.\n",
|
|
model: "sonnet",
|
|
sessionId: "e2e-f12-multi",
|
|
}));
|
|
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase the first/last nouns");
|
|
await settle();
|
|
assert.strictEqual(ids.length, 2, "two changed blocks → two proposals; the unchanged middle block → none");
|
|
|
|
// each proposal's anchor spans a whole block, and the replacement is that block's rewrite
|
|
const views = api.proposalController.listProposals(doc);
|
|
const replacements = views.map((v) => v.replacement).sort();
|
|
assert.deepStrictEqual(replacements, ["First paragraph ALPHA.", "Third paragraph GAMMA."]);
|
|
});
|
|
|
|
// PUC-4 + INV-23: a changed code fence is ONE atomic whole-fence proposal.
|
|
test("a changed code fence → one atomic proposal over the whole fence (INV-23)", async () => {
|
|
const doc = await freshDoc(
|
|
"docs/f12-fence.md",
|
|
"# Code\n\n```js\nconst a = 1;\nconst b = 2;\n```\n",
|
|
);
|
|
const api = await getApi();
|
|
const editFlow = api.editFlow;
|
|
editFlow.setEditTurnForTest(async () => ({
|
|
replacement: "# Code\n\n```js\nconst a = 10;\nconst b = 2;\n```\n",
|
|
model: "sonnet",
|
|
sessionId: "e2e-f12-fence",
|
|
}));
|
|
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "bump a to 10");
|
|
await settle();
|
|
assert.strictEqual(ids.length, 1, "a changed fence is one atomic proposal");
|
|
const view = api.proposalController.listProposals(doc).find((v) => v.id === ids[0])!;
|
|
assert.strictEqual(view.replacement, "```js\nconst a = 10;\nconst b = 2;\n```", "whole-fence replacement");
|
|
});
|
|
|
|
// PUC-5 / INV-40: accepting a block attributes ONLY the words Claude changed —
|
|
// unchanged words in the block are NOT swept into Claude's authorship.
|
|
test("accepting a block proposal attributes only the changed words to Claude (INV-40)", async () => {
|
|
const doc = await freshDoc("docs/f12-attr.md", "# T\n\nThe quick brown fox jumps lazily.\n");
|
|
const api = await getApi();
|
|
const editFlow = api.editFlow;
|
|
const key = api.proposalController.keyFor(doc);
|
|
|
|
editFlow.setEditTurnForTest(async () => ({
|
|
replacement: "# T\n\nThe quick RED fox jumps SLOWLY.\n",
|
|
model: "sonnet",
|
|
sessionId: "e2e-f12-attr",
|
|
}));
|
|
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "change two words");
|
|
await settle();
|
|
assert.strictEqual(ids.length, 1, "one changed paragraph → one block proposal");
|
|
|
|
const ok = await api.proposalController.acceptById(key, ids[0]);
|
|
assert.ok(ok, "the block proposal accepts");
|
|
await settle();
|
|
|
|
assert.strictEqual(doc.getText(), "# T\n\nThe quick RED fox jumps SLOWLY.\n", "the whole block landed");
|
|
|
|
const agentTexts = api.attributionController
|
|
.getSpans(key)
|
|
.filter((s) => s.authorKind === "agent")
|
|
.map((s) => doc.getText().slice(s.range.start, s.range.end).trim())
|
|
.filter((t) => t.length > 0);
|
|
// Only the two words Claude actually changed are Claude-attributed.
|
|
const joined = agentTexts.join(" ");
|
|
assert.ok(joined.includes("RED"), "the changed word RED is Claude-attributed");
|
|
assert.ok(joined.includes("SLOWLY"), "the changed word SLOWLY is Claude-attributed");
|
|
assert.ok(!/\bquick\b/.test(joined), "the unchanged word 'quick' is NOT swept into Claude's authorship");
|
|
assert.ok(!/\bfox\b/.test(joined), "the unchanged word 'fox' is NOT swept into Claude's authorship");
|
|
});
|
|
|
|
// PUC-4 / INV-41: a block-insertion rewrite produces acceptable proposals and
|
|
// accepting them all reconstructs the intended document (no born-orphaned hunk).
|
|
test("a rewrite that inserts a paragraph → acceptable proposals; accept-all reaches the rewrite (INV-41)", async () => {
|
|
const original = "# Ins\n\nAlpha block.\n\nBeta block.\n";
|
|
const rewrite = "# Ins\n\nAlpha block.\n\nBrand new middle block.\n\nBeta block.\n";
|
|
const doc = await freshDoc("docs/f12-insert.md", original);
|
|
const api = await getApi();
|
|
const editFlow = api.editFlow;
|
|
const key = api.proposalController.keyFor(doc);
|
|
|
|
editFlow.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-insert" }));
|
|
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "insert a paragraph");
|
|
await settle();
|
|
assert.ok(ids.length >= 1, "the insertion produced at least one proposal");
|
|
|
|
for (const id of ids) {
|
|
const ok = await api.proposalController.acceptById(key, id);
|
|
assert.ok(ok, `proposal ${id} is acceptable (not born-orphaned, INV-41)`);
|
|
await settle();
|
|
}
|
|
assert.strictEqual(doc.getText(), rewrite, "accepting all proposals reconstructs the intended rewrite");
|
|
assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "no proposals left pending");
|
|
});
|
|
});
|
|
|
|
// Task 8 (PUC-2 lens copy): the CodeLens titles read "✓ Keep"/"✗ Reject" per
|
|
// proposal, and — once ≥2 proposals are pending — a top-of-file "Keep all
|
|
// (N)"/"Reject all" pair routes straight at the batch commands.
|
|
suite("F12 CodeLens copy (Task 8, PUC-2)", () => {
|
|
test("one pending proposal → a per-proposal ✓ Keep / ✗ Reject pair, no top-of-file pair", async () => {
|
|
const doc = await freshDoc("docs/f12-lens-one.md", "# Lens\n\nAlpha block here.\n");
|
|
const api = await getApi();
|
|
const editFlow = api.editFlow;
|
|
editFlow.setEditTurnForTest(async () => ({
|
|
replacement: "# Lens\n\nALPHA block here.\n",
|
|
model: "sonnet",
|
|
sessionId: "e2e-lens-one",
|
|
}));
|
|
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase");
|
|
await settle();
|
|
assert.strictEqual(ids.length, 1, "one changed block → one proposal");
|
|
|
|
const lenses = api.editorProposalController.provideCodeLenses(doc);
|
|
const titles = lenses.map((l) => l.command?.title);
|
|
assert.deepStrictEqual(titles, ["✓ Keep", "✗ Reject"], "exactly one per-proposal pair, no top-of-file pair below threshold");
|
|
});
|
|
|
|
test("two pending proposals → a top-of-file 'Keep all (2)'/'Reject all' pair routed at the batch commands", async () => {
|
|
const doc = await freshDoc(
|
|
"docs/f12-lens-two.md",
|
|
"# Lens\n\nFirst block here.\n\nSecond block here.\n",
|
|
);
|
|
const api = await getApi();
|
|
const editFlow = api.editFlow;
|
|
editFlow.setEditTurnForTest(async () => ({
|
|
replacement: "# Lens\n\nFIRST block here.\n\nSECOND block here.\n",
|
|
model: "sonnet",
|
|
sessionId: "e2e-lens-two",
|
|
}));
|
|
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase both");
|
|
await settle();
|
|
assert.strictEqual(ids.length, 2, "two changed blocks → two proposals");
|
|
|
|
const lenses = api.editorProposalController.provideCodeLenses(doc);
|
|
const titles = lenses.map((l) => l.command?.title);
|
|
assert.strictEqual(titles[0], "✓ Keep all (2)", "top-of-file accept-all lens leads");
|
|
assert.strictEqual(titles[1], "✗ Reject all", "top-of-file reject-all lens follows");
|
|
assert.deepStrictEqual(titles.slice(2), ["✓ Keep", "✗ Reject", "✓ Keep", "✗ Reject"], "per-proposal pairs follow");
|
|
|
|
const acceptAll = lenses[0].command!;
|
|
const rejectAll = lenses[1].command!;
|
|
assert.strictEqual(acceptAll.command, "cowriting.acceptAllProposals", "top-of-file accept-all routes at the batch command");
|
|
assert.strictEqual(rejectAll.command, "cowriting.rejectAllProposals", "top-of-file reject-all routes at the batch command");
|
|
assert.deepStrictEqual(acceptAll.arguments, undefined, "acceptAllProposals takes no arguments (targets the active editor)");
|
|
});
|
|
});
|