F10 SLICE-4: host E2E for interactive review (open/toggle/propose→accept→reject/clean-editor/hidden-F6/status); update obsolete F9/toggle suites (#29)

Adds test/e2e/suite/f10Review.test.ts covering the F10 flow: open preview
(mode "on", fresh baseline all-unchanged), type → cw-by-human span, propose →
cw-proposal block with ✓/✗, accept → lands + baseline advances + block clears,
reject → vanishes + doc untouched, toggle off → renderPlain has no cw- marks,
status-bar PUC-6 (indicator with no panel, hidden once opened), and the
clean-editor INV-32 facts (retired toggleAttribution, F6 ctrl+alt+d hidden).

Rewrites the obsolete F9 authorship-mode test as an F10 review test
(cw-by-claude in the on-state render; getMode defaults to "on"). Updates
attribution.test.ts (drops the retired isVisible/toggleAttribution toggle,
asserts the toggle is gone) and noWorkspace.test.ts (toggleAttribution +
acceptProposal/rejectProposal are retired, preview-only — INV-32).

Honesty fix in the status-bar seam: hideStatus() clears statusItem.text so
statusText() reports undefined when the indicator is hidden (it previously
returned its stale last value after a panel opened).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-12 00:31:54 -07:00
parent 42d0ec155e
commit cdeb41ede4
5 changed files with 284 additions and 38 deletions
+12 -3
View File
@@ -111,7 +111,7 @@ export class TrackChangesPreviewController implements vscode.Disposable {
); );
this.panels.set(key, panel); this.panels.set(key, panel);
// A panel is now open for this doc — the off-panel indicator is redundant. // A panel is now open for this doc — the off-panel indicator is redundant.
this.statusItem.hide(); this.hideStatus();
this.refresh(document); this.refresh(document);
} }
@@ -169,12 +169,12 @@ export class TrackChangesPreviewController implements vscode.Disposable {
private updateStatus(uri: string): void { private updateStatus(uri: string): void {
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri); const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
if (!doc) { if (!doc) {
this.statusItem.hide(); this.hideStatus();
return; return;
} }
const n = this.proposals.listProposals(doc).length; const n = this.proposals.listProposals(doc).length;
if (n === 0 || this.panels.has(uri)) { if (n === 0 || this.panels.has(uri)) {
this.statusItem.hide(); this.hideStatus();
return; return;
} }
this.statusItem.text = `$(comment-discussion) ${n} Claude proposal${n === 1 ? "" : "s"}`; this.statusItem.text = `$(comment-discussion) ${n} Claude proposal${n === 1 ? "" : "s"}`;
@@ -182,6 +182,15 @@ export class TrackChangesPreviewController implements vscode.Disposable {
this.statusItem.show(); this.statusItem.show();
} }
/**
* Hide the off-panel indicator AND clear its text, so the `statusText()` seam
* is honest: a hidden indicator reports `undefined` (not its stale last value).
*/
private hideStatus(): void {
this.statusItem.text = "";
this.statusItem.hide();
}
private epochLabel(baseline: { reason: string; capturedAt: string } | undefined): string { private epochLabel(baseline: { reason: string; capturedAt: string } | undefined): string {
if (!baseline) return "opened (no baseline yet)"; if (!baseline) return "opened (no baseline yet)";
const time = new Date(baseline.capturedAt).toLocaleTimeString(); const time = new Date(baseline.capturedAt).toLocaleTimeString();
@@ -29,14 +29,20 @@ suite("no-workspace authoring (F8 — real folder-less, #8 lineage)", () => {
"cowriting.resolveThread", "cowriting.resolveThread",
"cowriting.reopenThread", "cowriting.reopenThread",
"cowriting.editSelection", "cowriting.editSelection",
"cowriting.toggleAttribution",
"cowriting.applyAgentEdit", "cowriting.applyAgentEdit",
"cowriting.acceptProposal",
"cowriting.rejectProposal",
"cowriting.proposeAgentEdit", "cowriting.proposeAgentEdit",
]) { ]) {
assert.ok(all.includes(command), `${command} is registered`); assert.ok(all.includes(command), `${command} is registered`);
} }
// F10 (INV-32): proposals are preview-only — the in-editor accept/reject
// commands and the attribution toggle were retired (no editor decorations).
for (const retired of [
"cowriting.toggleAttribution",
"cowriting.acceptProposal",
"cowriting.rejectProposal",
]) {
assert.ok(!all.includes(retired), `${retired} is retired (F10 preview-only)`);
}
}); });
test("authoring works folder-less: propose→accept on an untitled buffer routes to global storage (F8)", async () => { test("authoring works folder-less: propose→accept on an untitled buffer routes to global storage (F8)", async () => {
+5 -6
View File
@@ -132,7 +132,7 @@ suite("F3 live attribution (host E2E — seam-driven, no LLM)", () => {
assert.ok(api.attributionController.getOrphanCount(DOC_REL) >= 1, "…it is orphaned instead (INV-1)"); assert.ok(api.attributionController.getOrphanCount(DOC_REL) >= 1, "…it is orphaned instead (INV-1)");
}); });
test("the applyAgentEdit command wrapper and the toggle command work end-to-end", async () => { test("the applyAgentEdit command wrapper works end-to-end (data layer; no editor decorations — F10/INV-32)", async () => {
const doc = await openDoc(); const doc = await openDoc();
const api = await getApi(); const api = await getApi();
const anchor = "stable first paragraph"; const anchor = "stable first paragraph";
@@ -153,10 +153,9 @@ suite("F3 live attribution (host E2E — seam-driven, no LLM)", () => {
assert.ok(agent, "command-driven agent span exists"); assert.ok(agent, "command-driven agent span exists");
assert.strictEqual(agent!.authorKind, "agent"); assert.strictEqual(agent!.authorKind, "agent");
assert.strictEqual(api.attributionController.isVisible(), true); // F10/INV-32: the editor carries no attribution decorations and the toggle was
await vscode.commands.executeCommand("cowriting.toggleAttribution"); // retired — the rendered preview is the single review surface.
assert.strictEqual(api.attributionController.isVisible(), false, "toggle hides (PUC-5)"); const all = await vscode.commands.getCommands(true);
await vscode.commands.executeCommand("cowriting.toggleAttribution"); assert.ok(!all.includes("cowriting.toggleAttribution"), "the in-editor attribution toggle is retired (F10)");
assert.strictEqual(api.attributionController.isVisible(), true, "toggle restores");
}); });
}); });
+20 -26
View File
@@ -14,16 +14,20 @@ async function getApi(): Promise<CowritingApi> {
return api; return api;
} }
// F9 host E2E (no LLM): authorship mode reflects Claude's landed span. Owns its // F10 host E2E (no LLM): the rewrite of the obsolete F9 authorship-mode test.
// own markdown doc, disjoint from the other suites' fixtures. // F9's "authorship" mode / renderAuthorship is gone — the on-state renderReview
suite("F9 authorship preview (host E2E — seam ingress, no LLM)", () => { // now author-colors Claude's landed prose. This suite confirms a Claude-landed
const DOC_REL = "docs/f9authorship.md"; // span renders as a cw-by-claude span in the on-state preview HTML. Owns its own
// markdown doc, disjoint from the other suites' fixtures.
suite("F10 review preview — Claude-authored prose is cw-by-claude in the on-state (host E2E, no LLM)", () => {
const DOC_REL = "docs/f10claude.md";
const TARGET = "The sentence Claude will compose over."; const TARGET = "The sentence Claude will compose over.";
const REPLACEMENT = "The sentence CLAUDE COMPOSED via the seam.";
test("authorship mode marks Claude's accepted edit; track-changes mode still works", async () => { test("an accepted Claude edit author-colors as cw-by-claude in the on-state render; mode defaults to on", async () => {
const abs = path.join(WS, DOC_REL); const abs = path.join(WS, DOC_REL);
fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, `# F9\n\n${TARGET}\n`, "utf8"); fs.writeFileSync(abs, `# F10\n\n${TARGET}\n`, "utf8");
const uri = vscode.Uri.file(abs); const uri = vscode.Uri.file(abs);
const doc = await vscode.workspace.openTextDocument(uri); const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc); await vscode.window.showTextDocument(doc);
@@ -31,11 +35,11 @@ suite("F9 authorship preview (host E2E — seam ingress, no LLM)", () => {
const api = await getApi(); const api = await getApi();
const key = uri.toString(); const key = uri.toString();
// open the preview (track-changes mode by default) // open the preview — annotations default ON (F10/INV-33)
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle(); await settle();
assert.ok(api.trackChangesPreviewController.isOpen(key), "preview open"); assert.ok(api.trackChangesPreviewController.isOpen(key), "preview open");
assert.strictEqual(api.trackChangesPreviewController.getMode(key), "changes", "defaults to track-changes"); assert.strictEqual(api.trackChangesPreviewController.getMode(key), "on", "annotations default to on");
// Claude composes via the seam (propose → accept) // Claude composes via the seam (propose → accept)
const start = doc.getText().indexOf(TARGET); const start = doc.getText().indexOf(TARGET);
@@ -43,32 +47,22 @@ suite("F9 authorship preview (host E2E — seam ingress, no LLM)", () => {
uri: key, uri: key,
start, start,
end: start + TARGET.length, end: start + TARGET.length,
newText: "The sentence CLAUDE COMPOSED.", newText: REPLACEMENT,
model: "sonnet", model: "sonnet",
sessionId: "e2e-f9", sessionId: "e2e-f10",
turnId: "turn-f9", turnId: "turn-f10",
}); });
assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies"); assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies via the seam");
await settle(); await settle();
// attribution has a Claude span now // attribution recorded a Claude (agent) span (data layer intact)
const claudeSpan = api.attributionController.getSpans(DOC_REL).find((s) => s.authorKind === "agent"); const claudeSpan = api.attributionController.getSpans(DOC_REL).find((s) => s.authorKind === "agent");
assert.ok(claudeSpan, "Claude span recorded by F3"); assert.ok(claudeSpan, "Claude span recorded by F3");
// flip to authorship mode → the preview reads a Claude span
api.trackChangesPreviewController.setMode(key, "authorship");
await settle();
assert.strictEqual(api.trackChangesPreviewController.getMode(key), "authorship");
const spans = api.attributionController.spansFor(doc); const spans = api.attributionController.spansFor(doc);
assert.ok(spans.some((s) => s.author === "claude"), "spansFor reports a Claude span for the preview"); assert.ok(spans.some((s) => s.author === "claude"), "spansFor reports a Claude span for the preview");
// back to track-changes — still functional (regression) // the on-state render author-colors the landed Claude text as cw-by-claude
api.trackChangesPreviewController.setMode(key, "changes"); const html = api.trackChangesPreviewController.renderHtmlFor(key);
await settle(); assert.match(html, /<span class="cw-by-claude">/, "landed Claude prose is author-colored in the on-state render");
assert.strictEqual(api.trackChangesPreviewController.getMode(key), "changes");
assert.ok(
(api.trackChangesPreviewController.getLastModel(key) ?? []).length >= 1,
"track-changes model still computed",
);
}); });
}); });
+238
View File
@@ -0,0 +1,238 @@
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");
// INV-10: proposing never touches the document.
assert.ok(doc.getText().includes(T1), "document unchanged by propose");
});
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 untouched (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");
assert.strictEqual(api.proposalController.rejectById(DOC_REL, id2), true, "reject");
await settle();
assert.ok(!api.proposalController.listProposals(doc).some((v) => v.id === id2), "rejected proposal gone");
assert.strictEqual(doc.getText(), before, "document untouched by reject");
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 toggle is 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");
// the F6 diff toggle's keybinding is hidden (when:false) — it is not a user surface.
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" && k.key === "ctrl+alt+d",
);
assert.ok(dKb, "the ctrl+alt+d toggleDiffView keybinding is declared");
assert.strictEqual(dKb!.when, "false", "…but hidden (when:false) — F6 is a data layer, not a user surface");
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() };
}