165 lines
7.4 KiB
TypeScript
165 lines
7.4 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/attrib.md";
|
|
|
|
function sidecarPath(): string {
|
|
return path.join(WS, ".threads", "docs", "attrib.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: attribution tracking 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?.attributionController, "extension exports attributionController");
|
|
return api;
|
|
}
|
|
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: later tests consume earlier tests' document/sidecar
|
|
// state by design, mirroring the F2 suite. This suite owns docs/attrib.md;
|
|
// the threads suite owns docs/sample.md — keep fixtures disjoint.
|
|
suite("F3 live attribution (host E2E — seam-driven, no LLM)", () => {
|
|
const TYPED = "A human-typed sentence. ";
|
|
|
|
test("typing produces a live human span", async () => {
|
|
const doc = await openDoc();
|
|
const api = await getApi();
|
|
const editor = vscode.window.activeTextEditor!;
|
|
const insertAt = doc.getText().length;
|
|
await editor.edit((eb) => eb.insert(doc.positionAt(insertAt), TYPED));
|
|
await settle();
|
|
const spans = api.attributionController.getSpans(DOC_REL);
|
|
assert.strictEqual(spans.length, 1);
|
|
assert.strictEqual(spans[0].authorKind, "human");
|
|
assert.strictEqual(doc.getText().slice(spans[0].range.start, spans[0].range.end), TYPED);
|
|
});
|
|
|
|
test("a seam edit produces an agent span; mixed edits stay char-honest (INV-7/INV-9)", async () => {
|
|
const doc = await openDoc();
|
|
const api = await getApi();
|
|
const target = "target sentence";
|
|
const start = doc.getText().indexOf(target);
|
|
const ok = await api.attributionController.applyAgentEdit(
|
|
doc,
|
|
new vscode.Range(doc.positionAt(start), doc.positionAt(start + target.length)),
|
|
"REWRITTEN-BY-CLAUDE sentence",
|
|
{ kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "e2e-run" } },
|
|
{ turnId: "turn-e2e-1" },
|
|
);
|
|
assert.strictEqual(ok, true, "seam edit applies");
|
|
await settle();
|
|
const spans = api.attributionController.getSpans(DOC_REL);
|
|
const agent = spans.find((s) => s.authorKind === "agent");
|
|
assert.ok(agent, "agent span exists");
|
|
assert.strictEqual(agent!.turnId, "turn-e2e-1");
|
|
assert.strictEqual(
|
|
doc.getText().slice(agent!.range.start, agent!.range.end),
|
|
"REWRITTEN-BY-CLAUDE sentence",
|
|
);
|
|
assert.ok(spans.some((s) => s.authorKind === "human"), "human span still tracked");
|
|
});
|
|
|
|
test("stale expectedVersion refuses to apply (spec §6.9)", async () => {
|
|
const doc = await openDoc();
|
|
const api = await getApi();
|
|
const ok = await api.attributionController.applyAgentEdit(
|
|
doc,
|
|
new vscode.Range(doc.positionAt(0), doc.positionAt(1)),
|
|
"X",
|
|
{ kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "e2e" } },
|
|
{ expectedVersion: doc.version - 1 },
|
|
);
|
|
assert.strictEqual(ok, false);
|
|
});
|
|
|
|
test("save persists attributions[]; reload restores spans at re-resolved anchors (PUC-4)", async () => {
|
|
const doc = await openDoc();
|
|
const api = await getApi();
|
|
await doc.save();
|
|
await settle();
|
|
const art = readSidecar();
|
|
assert.ok(art.attributions.length >= 2, "human + agent records persisted");
|
|
const agentRec = art.attributions.find((a) => a.author.kind === "agent")!;
|
|
assert.strictEqual(art.anchors[agentRec.anchorId].fingerprint.text, "REWRITTEN-BY-CLAUDE sentence");
|
|
assert.strictEqual(agentRec.turnId, "turn-e2e-1");
|
|
|
|
api.attributionController.loadAll(doc);
|
|
const spans = api.attributionController.getSpans(DOC_REL);
|
|
assert.ok(spans.some((s) => s.authorKind === "agent"), "agent span restored from sidecar");
|
|
assert.strictEqual(api.attributionController.getOrphanCount(DOC_REL), 0);
|
|
});
|
|
|
|
test("external move re-anchors; mangling the text orphans (INV-1/INV-6)", async () => {
|
|
const uri = vscode.Uri.file(path.join(WS, DOC_REL));
|
|
const original = fs.readFileSync(uri.fsPath, "utf8");
|
|
const api = await getApi();
|
|
|
|
let doc = await externalWriteAndReload(uri, "PREPENDED LINE\n\n" + original);
|
|
await settle();
|
|
api.attributionController.loadAll(doc);
|
|
let spans = api.attributionController.getSpans(DOC_REL);
|
|
const agent = spans.find((s) => s.authorKind === "agent")!;
|
|
const moved = doc.getText().indexOf("REWRITTEN-BY-CLAUDE sentence");
|
|
assert.strictEqual(agent.range.start, moved, "agent span re-anchored after move");
|
|
assert.strictEqual(api.attributionController.getOrphanCount(DOC_REL), 0);
|
|
|
|
const mangled = doc.getText().replace("REWRITTEN-BY-CLAUDE sentence", "now something else entirely");
|
|
doc = await externalWriteAndReload(uri, mangled);
|
|
await settle();
|
|
api.attributionController.loadAll(doc);
|
|
spans = api.attributionController.getSpans(DOC_REL);
|
|
assert.ok(!spans.some((s) => s.authorKind === "agent"), "mangled agent span not rendered");
|
|
assert.ok(api.attributionController.getOrphanCount(DOC_REL) >= 1, "…it is orphaned instead (INV-1)");
|
|
});
|
|
|
|
test("the applyAgentEdit command wrapper works end-to-end (data layer; no editor decorations — F10/INV-32)", async () => {
|
|
const doc = await openDoc();
|
|
const api = await getApi();
|
|
const anchor = "stable first paragraph";
|
|
const start = doc.getText().indexOf(anchor);
|
|
const ok = await vscode.commands.executeCommand<boolean>("cowriting.applyAgentEdit", {
|
|
uri: doc.uri.toString(),
|
|
start,
|
|
end: start + anchor.length,
|
|
newText: "stable COMMAND-EDITED paragraph",
|
|
model: "sonnet",
|
|
sessionId: "e2e-cmd",
|
|
turnId: "turn-e2e-cmd",
|
|
});
|
|
assert.strictEqual(ok, true, "command wrapper applies via the seam");
|
|
await settle();
|
|
const spans = api.attributionController.getSpans(DOC_REL);
|
|
const agent = spans.find((s) => s.turnId === "turn-e2e-cmd");
|
|
assert.ok(agent, "command-driven agent span exists");
|
|
assert.strictEqual(agent!.authorKind, "agent");
|
|
|
|
// F10/INV-32: the editor carries no attribution decorations and the toggle was
|
|
// retired — the rendered preview is the single review surface.
|
|
const all = await vscode.commands.getCommands(true);
|
|
assert.ok(!all.includes("cowriting.toggleAttribution"), "the in-editor attribution toggle is retired (F10)");
|
|
});
|
|
});
|