249 lines
14 KiB
TypeScript
249 lines
14 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 { renderPlain } from "../../../src/trackChangesModel";
|
|
|
|
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?.trackChangesPreviewController && api?.proposalController, "exports preview + proposal");
|
|
return api;
|
|
}
|
|
|
|
/** Create + open a fresh markdown doc under WS, returning the doc + its uri key. */
|
|
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 settle();
|
|
return { doc, key: uri.toString() };
|
|
}
|
|
|
|
async function propose(
|
|
doc: vscode.TextDocument,
|
|
key: string,
|
|
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: key,
|
|
start,
|
|
end: start + target.length,
|
|
newText,
|
|
model: "sonnet",
|
|
sessionId: "e2e-f10rev",
|
|
turnId,
|
|
});
|
|
assert.ok(id, "propose returns an id");
|
|
return id!;
|
|
}
|
|
|
|
// F10 host E2E (no LLM): the rendered preview is the single INTERACTIVE review
|
|
// surface. This suite owns docs/f10review.md (its main flow is order-dependent)
|
|
// plus its own disjoint fresh docs for the isolated cases (status-bar, toggle).
|
|
// The editor is zero-decoration; everything observable here is the data layer +
|
|
// the on-state renderReview HTML the panel posts.
|
|
suite("F10 interactive review (host E2E — preview is the single review surface, no LLM)", () => {
|
|
const DOC_REL = "docs/f10review.md";
|
|
const PROSE = "The original review paragraph that lives in this doc.";
|
|
const T1 = "A first claude target sentence here.";
|
|
const T2 = "A second claude target sentence here.";
|
|
|
|
test("open on a markdown doc → panel open, fresh baseline all-unchanged, mode is on (PUC-1)", async () => {
|
|
const { doc, key } = await freshDoc(DOC_REL, `# F10 review\n\n${PROSE}\n\n${T1}\n\n${T2}\n`);
|
|
const api = await getApi();
|
|
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), false, "no panel yet");
|
|
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
|
await settle();
|
|
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), true, "panel open");
|
|
assert.strictEqual(api.trackChangesPreviewController.getMode(key), "on", "annotations default on (INV-33)");
|
|
const model = api.trackChangesPreviewController.getLastModel(key);
|
|
assert.ok(model && model.length > 0, "a model was computed");
|
|
assert.ok(model!.every((o) => o.kind === "unchanged"), "fresh baseline == buffer → every block unchanged");
|
|
void doc;
|
|
});
|
|
|
|
test("typing produces an added/changed block and a cw-by-human span in the on-state render (PUC-2)", async () => {
|
|
const { key } = await reopen(DOC_REL);
|
|
const api = await getApi();
|
|
const doc = byKey(key)!;
|
|
const edit = new vscode.WorkspaceEdit();
|
|
edit.insert(doc.uri, doc.positionAt(doc.getText().length), "\n\nA freshly typed human paragraph.\n");
|
|
assert.ok(await vscode.workspace.applyEdit(edit), "operator edit applied");
|
|
await settle();
|
|
const kinds = (api.trackChangesPreviewController.getLastModel(key) ?? []).map((o) => o.kind);
|
|
assert.ok(kinds.some((k) => k === "added" || k === "changed"), "an added/changed block after typing");
|
|
// Attribution recorded the human span (data layer), and the on-state render
|
|
// author-colors that prose as cw-by-human.
|
|
assert.ok(
|
|
api.attributionController.spansFor(doc).some((s) => s.author === "human"),
|
|
"a human span was recorded for the typed text",
|
|
);
|
|
const html = api.trackChangesPreviewController.renderHtmlFor(key);
|
|
assert.match(html, /<span class="cw-by-human">/, "typed text is author-colored human in the on-state");
|
|
});
|
|
|
|
test("propose → the preview surfaces it as a cw-proposal block with ✓/✗ actions (PUC-3)", async () => {
|
|
const { doc, key } = await reopen(DOC_REL);
|
|
const api = await getApi();
|
|
const id = await propose(doc, key, T1, "A FIRST claude REPLACEMENT sentence.", "turn-f10-1");
|
|
await settle();
|
|
const views = api.proposalController.listProposals(doc);
|
|
assert.ok(views.some((v) => v.id === id), "listProposals returns a view with the id");
|
|
const html = api.trackChangesPreviewController.renderHtmlFor(key);
|
|
assert.ok(html.includes(`data-proposal-id="${id}"`), "the preview renders the proposal block by id");
|
|
assert.match(html, /class="cw-actions"/, "the proposal block carries ✓/✗ actions");
|
|
// #31: the proposal renders INLINE at its anchor (right after T1's block), not
|
|
// trailing the whole document — so it appears BEFORE the following T2 block.
|
|
const pIdx = html.indexOf(`data-proposal-id="${id}"`);
|
|
const t2Idx = html.indexOf("second claude target");
|
|
assert.ok(t2Idx >= 0 && pIdx < t2Idx, "the proposal renders in place, before the following block (#31)");
|
|
// F12 (INV-48): EditorProposalController auto-applies the proposal into the buffer;
|
|
// the replacement text is now in the document, proposal is still pending.
|
|
assert.ok(doc.getText().includes("A FIRST claude REPLACEMENT sentence."), "F12 optimistic-apply: replacement in buffer");
|
|
});
|
|
|
|
test("accept → the proposal lands, clears from the preview, and the baseline advances (PUC-4)", async () => {
|
|
const { doc, key } = await reopen(DOC_REL);
|
|
const api = await getApi();
|
|
const id = api.proposalController.listProposals(doc).find((v) => v.replaced === T1)!.id;
|
|
assert.ok(await api.proposalController.acceptById(DOC_REL, id), "accept applies via the seam");
|
|
await settle();
|
|
const replacement = "A FIRST claude REPLACEMENT sentence.";
|
|
assert.ok(doc.getText().includes(replacement), "replacement landed in the document");
|
|
assert.ok(!doc.getText().includes(T1), "original target gone");
|
|
assert.ok(!api.proposalController.listProposals(doc).some((v) => v.id === id), "proposal cleared from listProposals");
|
|
const html = api.trackChangesPreviewController.renderHtmlFor(key);
|
|
assert.ok(!html.includes(`data-proposal-id="${id}"`), "the accepted proposal block is gone from the preview");
|
|
// The baseline advanced on the landing (INV-18): the landed text is not marked.
|
|
const model = api.trackChangesPreviewController.getLastModel(key) ?? [];
|
|
const marked = model.some((o) => o.kind !== "unchanged" && o.block.raw.includes("FIRST claude REPLACEMENT"));
|
|
assert.ok(!marked, "the just-landed Claude text renders unmarked (baseline advanced)");
|
|
});
|
|
|
|
test("reject → the proposal vanishes and the document is reverted (PUC-5)", async () => {
|
|
const { doc, key } = await reopen(DOC_REL);
|
|
const api = await getApi();
|
|
const before = doc.getText();
|
|
const id2 = await propose(doc, key, T2, "A SECOND would-be replacement.", "turn-f10-2");
|
|
await settle();
|
|
assert.ok(api.proposalController.listProposals(doc).some((v) => v.id === id2), "second proposal pending");
|
|
// F12: use rejectByIdInPlace to revert the optimistically-applied buffer edit.
|
|
assert.ok(await api.proposalController.rejectByIdInPlace(DOC_REL, id2), "rejectByIdInPlace reverts + removes");
|
|
await settle();
|
|
assert.ok(!api.proposalController.listProposals(doc).some((v) => v.id === id2), "rejected proposal gone");
|
|
assert.strictEqual(doc.getText(), before, "document reverted to pre-propose state");
|
|
const html = api.trackChangesPreviewController.renderHtmlFor(key);
|
|
assert.ok(!html.includes(`data-proposal-id="${id2}"`), "no rejected block in the preview");
|
|
});
|
|
|
|
test("toggle annotations off → mode round-trips and the off-state render is plain (no cw- marks) (INV-33)", async () => {
|
|
// A fresh doc with a pending proposal so the on-state DOES carry a cw- mark,
|
|
// making the off-state's absence of marks meaningful (not tautological).
|
|
const { doc, key } = await freshDoc(
|
|
"docs/f10toggle.md",
|
|
"# F10 toggle\n\nA toggle target sentence to propose over.\n",
|
|
);
|
|
const api = await getApi();
|
|
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
|
await settle();
|
|
const id = await propose(doc, key, "A toggle target sentence to propose over.", "A TOGGLED replacement.", "turn-tog");
|
|
await settle();
|
|
|
|
// on-state: the proposal block + its actions are present.
|
|
assert.strictEqual(api.trackChangesPreviewController.getMode(key), "on", "starts on");
|
|
const onHtml = api.trackChangesPreviewController.renderHtmlFor(key);
|
|
assert.ok(onHtml.includes(`data-proposal-id="${id}"`), "on-state shows the proposal block");
|
|
|
|
// toggle off → mode round-trips; the off-state body is plain markdown.
|
|
api.trackChangesPreviewController.setMode(key, "off");
|
|
await settle();
|
|
assert.strictEqual(api.trackChangesPreviewController.getMode(key), "off", "mode flipped to off");
|
|
assert.ok(api.trackChangesPreviewController.isOpen(key), "panel stays open across the toggle");
|
|
// renderHtmlFor is the on-state seam; the off-state body is renderPlain(current)
|
|
// (INV-33). Assert the actual off-state body the controller posts has no cw-
|
|
// author/proposal marks — meaningful because the on-state above DID carry one.
|
|
const offBody = renderPlain(doc.getText());
|
|
assert.ok(!/cw-proposal|cw-by-claude|cw-by-human|cw-del/.test(offBody), "off-state render carries no cw- marks");
|
|
|
|
// toggle back on → marks return.
|
|
api.trackChangesPreviewController.setMode(key, "on");
|
|
await settle();
|
|
assert.strictEqual(api.trackChangesPreviewController.getMode(key), "on", "mode flipped back on");
|
|
assert.ok(
|
|
api.trackChangesPreviewController.renderHtmlFor(key).includes(`data-proposal-id="${id}"`),
|
|
"on-state shows the proposal block again",
|
|
);
|
|
});
|
|
|
|
test("status-bar (PUC-6): a pending proposal with NO panel shows the indicator; opening the preview hides it", async () => {
|
|
// Isolated fresh doc: no preview opened, so the off-panel indicator is live.
|
|
const { doc, key } = await freshDoc("docs/f10status.md", "# F10 status\n\nA status target sentence here.\n");
|
|
const api = await getApi();
|
|
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), false, "no panel for this doc");
|
|
await propose(doc, key, "A status target sentence here.", "A STATUS replacement.", "turn-stat");
|
|
await settle();
|
|
const text = api.trackChangesPreviewController.statusText();
|
|
assert.ok(text && text.length > 0, "the off-panel indicator shows a non-empty status");
|
|
assert.match(text!, /1 Claude proposal/, "it mentions the pending count");
|
|
|
|
// open the preview for this doc → the off-panel indicator hides (undefined).
|
|
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
|
await settle();
|
|
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), true, "panel open");
|
|
assert.strictEqual(api.trackChangesPreviewController.statusText(), undefined, "indicator hidden once the panel is open");
|
|
});
|
|
|
|
test("clean editor: data layers intact AND the retired in-editor surfaces are gone (INV-32)", async () => {
|
|
const { doc, key } = await freshDoc("docs/f10clean.md", "# F10 clean\n\nA clean target sentence here.\n");
|
|
const api = await getApi();
|
|
const id = await propose(doc, key, "A clean target sentence here.", "A CLAUDE clean replacement.", "turn-clean");
|
|
await settle();
|
|
assert.ok(await api.proposalController.acceptById("docs/f10clean.md", id), "accept lands the Claude edit");
|
|
await settle();
|
|
// data layer intact: a Claude (agent) attribution span exists.
|
|
assert.ok(
|
|
api.attributionController.getSpans("docs/f10clean.md").some((s) => s.authorKind === "agent"),
|
|
"agent span recorded (attribution data layer intact)",
|
|
);
|
|
// the retired in-editor surfaces are gone from the palette (no editor decorations — INV-32).
|
|
const all = await vscode.commands.getCommands(true);
|
|
assert.ok(!all.includes("cowriting.toggleAttribution"), "cowriting.toggleAttribution is retired");
|
|
// #34: the F6 two-pane diff VIEW was removed (F10 preview is the single review
|
|
// surface) — its toggle command + ctrl+alt+d keybinding are gone entirely.
|
|
assert.ok(!all.includes("cowriting.toggleDiffView"), "cowriting.toggleDiffView command is gone (#34)");
|
|
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8"));
|
|
const dKb = (pkg.contributes.keybindings as Array<{ command: string; key: string; when?: string }>).find(
|
|
(k) => k.command === "cowriting.toggleDiffView",
|
|
);
|
|
assert.ok(!dKb, "the toggleDiffView keybinding is gone from package.json (#34)");
|
|
// …but the F6 baseline data layer survives: pinDiffBaseline stays a real command.
|
|
assert.ok(all.includes("cowriting.pinDiffBaseline"), "pinDiffBaseline (baseline data layer) is kept");
|
|
void key;
|
|
});
|
|
});
|
|
|
|
// ---- helpers reused across the order-dependent main-flow tests ----
|
|
|
|
function byKey(key: string): vscode.TextDocument | undefined {
|
|
return vscode.workspace.textDocuments.find((d) => d.uri.toString() === key);
|
|
}
|
|
async function reopen(rel: string): Promise<{ doc: vscode.TextDocument; key: string }> {
|
|
const uri = vscode.Uri.file(path.join(WS, rel));
|
|
const doc = await vscode.workspace.openTextDocument(uri);
|
|
await vscode.window.showTextDocument(doc);
|
|
await settle();
|
|
return { doc, key: uri.toString() };
|
|
}
|