a323b827a8
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
192 lines
9.9 KiB
TypeScript
192 lines
9.9 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 type { Artifact } from "../../../src/model";
|
|
|
|
const WS = process.env.E2E_WORKSPACE!;
|
|
const DOC_REL = "docs/proposal.md";
|
|
|
|
function sidecarPath(): string {
|
|
return path.join(WS, ".threads", "docs", "proposal.md.json");
|
|
}
|
|
function readSidecar(): Artifact {
|
|
return JSON.parse(fs.readFileSync(sidecarPath(), "utf8")) as Artifact;
|
|
}
|
|
async function openDoc(): Promise<vscode.TextDocument> {
|
|
const uri = vscode.Uri.file(path.join(WS, DOC_REL));
|
|
const doc = await vscode.workspace.openTextDocument(uri);
|
|
await vscode.window.showTextDocument(doc);
|
|
// INV-10: proposal rendering (and the F12 optimistic-apply it drives via
|
|
// onDidChangeProposals) is gated on coediting (Task 4) — idempotent across
|
|
// this order-dependent suite's repeated openDoc() calls.
|
|
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
|
return doc;
|
|
}
|
|
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, "extension exports proposalController");
|
|
return api;
|
|
}
|
|
async function proposeViaCommand(
|
|
doc: vscode.TextDocument,
|
|
target: string,
|
|
newText: string,
|
|
turnId: string,
|
|
): Promise<string> {
|
|
const start = doc.getText().indexOf(target);
|
|
assert.ok(start >= 0, `fixture contains "${target}"`);
|
|
const id = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
|
|
uri: doc.uri.toString(),
|
|
start,
|
|
end: start + target.length,
|
|
newText,
|
|
model: "sonnet",
|
|
sessionId: "e2e-prop",
|
|
turnId,
|
|
instruction: "e2e instruction",
|
|
});
|
|
assert.ok(id, "propose returns the proposal id");
|
|
return id!;
|
|
}
|
|
async function externalWriteAndReload(uri: vscode.Uri, content: string): Promise<vscode.TextDocument> {
|
|
fs.writeFileSync(uri.fsPath, content, "utf8");
|
|
const doc = await vscode.workspace.openTextDocument(uri);
|
|
await vscode.window.showTextDocument(doc);
|
|
await vscode.commands.executeCommand("workbench.action.files.revert");
|
|
return doc;
|
|
}
|
|
const settle = () => new Promise((r) => setTimeout(r, 300));
|
|
|
|
// Tests are ORDER-DEPENDENT (F2/F3 pattern): later tests consume earlier state.
|
|
// This suite OWNS docs/proposal.md; threads owns docs/sample.md, attribution
|
|
// owns docs/attrib.md — fixtures stay disjoint.
|
|
suite("F4 propose/accept (host E2E — programmatic ingress, no LLM)", () => {
|
|
const TARGET = "The propose target sentence lives here.";
|
|
const REPLACEMENT = "PROPOSED-BY-CLAUDE replacement sentence.";
|
|
|
|
test("propose records a pending proposal; F12 optimistically applies it (INV-48, reverses INV-10)", async () => {
|
|
const doc = await openDoc();
|
|
const api = await getApi();
|
|
await proposeViaCommand(doc, TARGET, REPLACEMENT, "turn-p1");
|
|
await settle();
|
|
// F12 (INV-48): EditorProposalController auto-applies the proposed text into the
|
|
// buffer, so after settle the buffer has the replacement text (INV-10 is reversed).
|
|
assert.ok(doc.getText().includes(REPLACEMENT), "F12 optimistic-apply: replacement in buffer after propose");
|
|
const rendered = api.proposalController.getRendered(DOC_REL);
|
|
assert.strictEqual(rendered.length, 1);
|
|
assert.strictEqual(rendered[0].pending, true);
|
|
assert.strictEqual(rendered[0].turnId, "turn-p1");
|
|
assert.strictEqual(rendered[0].canReply, false, "proposals are decide-only — no dead reply input (INV-12)");
|
|
const art = readSidecar();
|
|
assert.strictEqual(art.proposals.length, 1, "proposal persisted at propose time");
|
|
// After optimistic apply, the fingerprint re-anchors to the replacement text.
|
|
assert.strictEqual(art.anchors[art.proposals[0].anchorId].fingerprint.text, REPLACEMENT);
|
|
});
|
|
|
|
test("accept applies via the seam: text replaced, Claude-attributed, proposal removed (INV-9/11/13)", async () => {
|
|
const doc = await openDoc();
|
|
const api = await getApi();
|
|
const id = api.proposalController.getRendered(DOC_REL)[0].id;
|
|
const ok = await api.proposalController.acceptById(DOC_REL, id);
|
|
assert.strictEqual(ok, true, "accept applies");
|
|
await settle();
|
|
assert.ok(doc.getText().includes(REPLACEMENT), "replacement landed");
|
|
assert.ok(!doc.getText().includes(TARGET), "target gone");
|
|
const spans = api.attributionController.getSpans(DOC_REL);
|
|
const agent = spans.find((s) => s.turnId === "turn-p1");
|
|
assert.ok(agent, `accepted text is Claude-attributed with the proposal's turnId — got spans: ${JSON.stringify(spans)}`);
|
|
assert.strictEqual(agent!.authorKind, "agent");
|
|
assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 0, "proposal gone (INV-13)");
|
|
assert.strictEqual(readSidecar().proposals.length, 0, "removed from the sidecar");
|
|
});
|
|
|
|
test("reject reverts the optimistically-applied text and removes the proposal (PUC-3, F12-INV-48)", async () => {
|
|
const doc = await openDoc();
|
|
const api = await getApi();
|
|
const before = doc.getText();
|
|
const id = await proposeViaCommand(doc, "A second target for coexistence checks.", "WOULD-BE replacement.", "turn-p2");
|
|
await settle();
|
|
// F12: optimistic apply has put "WOULD-BE replacement." in the buffer.
|
|
assert.ok(doc.getText().includes("WOULD-BE replacement."), "F12: optimistically applied");
|
|
// rejectByIdInPlace reverts the buffer to the original (PUC-3, INV-51).
|
|
assert.ok(await api.proposalController.rejectByIdInPlace(DOC_REL, id), "rejectByIdInPlace removes + reverts");
|
|
await settle();
|
|
assert.strictEqual(doc.getText(), before, "document restored to pre-propose state");
|
|
assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 0);
|
|
assert.strictEqual(readSidecar().proposals.length, 0);
|
|
});
|
|
|
|
test("a pending proposal persists, survives reload, and re-anchors after an external move (PUC-4)", async () => {
|
|
let doc = await openDoc();
|
|
const api = await getApi();
|
|
const anchor = "A stable closing paragraph.";
|
|
const appliedText = "A PROPOSED closing paragraph.";
|
|
await proposeViaCommand(doc, anchor, appliedText, "turn-p3");
|
|
await settle();
|
|
// F12: optimistic apply has put the proposed text in the buffer (anchor → appliedText).
|
|
assert.ok(doc.getText().includes(appliedText), "F12: optimistically applied before reload");
|
|
const uri = vscode.Uri.file(path.join(WS, DOC_REL));
|
|
// doc.getText() now has appliedText; the reload prepends a line.
|
|
doc = await externalWriteAndReload(uri, "PREPENDED LINE\n\n" + doc.getText());
|
|
await settle();
|
|
api.proposalController.renderAll(doc);
|
|
const rendered = api.proposalController.getRendered(DOC_REL);
|
|
assert.strictEqual(rendered.length, 1, "proposal survived reload");
|
|
assert.strictEqual(rendered[0].pending, true, "still decidable");
|
|
// After F12 re-anchor, the fingerprint tracks the appliedText, not the original anchor.
|
|
const moved = doc.getText().indexOf(appliedText);
|
|
assert.ok(moved >= 0, "applied text present in the reloaded document");
|
|
assert.strictEqual(rendered[0].range.start, moved, "re-anchored to appliedText after the move");
|
|
});
|
|
|
|
test("editing the target text makes the proposal stale: flagged, accept refused, doc untouched (INV-11)", async () => {
|
|
let doc = await openDoc();
|
|
const api = await getApi();
|
|
const id = api.proposalController.getRendered(DOC_REL)[0].id;
|
|
// After F12 optimistic apply the fingerprint tracks the APPLIED text ("A PROPOSED
|
|
// closing paragraph."), not the original. Mangle that text to make the proposal stale.
|
|
const preMangled = doc.getText();
|
|
const mangled = preMangled.replace("A PROPOSED closing paragraph.", "A reworded closing paragraph.");
|
|
const uri = vscode.Uri.file(path.join(WS, DOC_REL));
|
|
doc = await externalWriteAndReload(uri, mangled);
|
|
await settle();
|
|
api.proposalController.renderAll(doc);
|
|
assert.strictEqual(api.proposalController.getStaleCount(DOC_REL), 1, "flagged stale");
|
|
const before = doc.getText();
|
|
const ok = await api.proposalController.acceptById(DOC_REL, id);
|
|
assert.strictEqual(ok, false, "accept refused (INV-11)");
|
|
assert.strictEqual(doc.getText(), before, "document untouched");
|
|
assert.strictEqual(readSidecar().proposals.length, 1, "proposal still pending (recoverable)");
|
|
// discard the husk so later tests see a clean sidecar
|
|
assert.strictEqual(api.proposalController.rejectById(DOC_REL, id), true);
|
|
// Restore the file so subsequent tests can find their fixture targets:
|
|
// replace the optimistically-applied text back to the original fixture text.
|
|
const restored = preMangled.replace("A PROPOSED closing paragraph.", "A stable closing paragraph.");
|
|
doc = await externalWriteAndReload(uri, restored);
|
|
await settle();
|
|
});
|
|
|
|
test("multiple pending proposals coexist and are decidable out of order (PUC-5)", async () => {
|
|
const doc = await openDoc();
|
|
const api = await getApi();
|
|
// Clean up any proposals left over from prior tests (F12: proposals persist in state).
|
|
await api.proposalController.rejectAll(doc);
|
|
await settle();
|
|
const id1 = await proposeViaCommand(doc, "A stable opening paragraph.", "An ACCEPTED opening paragraph.", "turn-p4");
|
|
const id2 = await proposeViaCommand(doc, "A stable closing paragraph.", "A REPLACED closing paragraph.", "turn-p5");
|
|
await settle();
|
|
assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 2, "two new proposals");
|
|
// decide the SECOND first, then the first — order independence
|
|
assert.strictEqual(await api.proposalController.acceptById(DOC_REL, id2), true);
|
|
await settle();
|
|
assert.strictEqual(await api.proposalController.acceptById(DOC_REL, id1), true);
|
|
await settle();
|
|
assert.ok(doc.getText().includes("An ACCEPTED opening paragraph."));
|
|
assert.ok(doc.getText().includes("A REPLACED closing paragraph."));
|
|
assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 0);
|
|
});
|
|
});
|