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 { const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; const api = (await ext.activate()) as CowritingApi; assert.ok(api?.trackChangesPreviewController && api?.diffViewController, "exports preview + diffView"); 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() }; } // F11 host E2E (no LLM): the preview toolbar is the primary interaction surface. // The webview posts intent messages; the host routes them through the existing // F4/F6/F3 seams (INV-35). The webview DOM (real button clicks) is sealed and // manual-smoke only; here we simulate the inbound messages via `receiveMessage`. suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () => { // SLICE-1: the Pin baseline button. test("pinBaseline message pins the PREVIEWED doc → marks clear, baseline reason is pinned (PUC-5, INV-35)", async () => { const { doc, key } = await freshDoc("docs/f11pin.md", "# F11 pin\n\nA baseline paragraph that will diverge.\n"); const api = await getApi(); await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await settle(); assert.strictEqual(api.trackChangesPreviewController.isOpen(key), true, "panel open"); // Diverge from the opened baseline so the preview carries a real change-mark. const edit = new vscode.WorkspaceEdit(); edit.insert(doc.uri, doc.positionAt(doc.getText().length), "\n\nA freshly typed paragraph that diverges.\n"); assert.ok(await vscode.workspace.applyEdit(edit), "operator edit applied"); await settle(); const marked = (api.trackChangesPreviewController.getLastModel(key) ?? []).some((o) => o.kind !== "unchanged"); assert.ok(marked, "the typed paragraph shows as a change before pinning"); // Simulate the webview's Pin baseline button posting its intent. api.trackChangesPreviewController.receiveMessage(key, { type: "pinBaseline" }); await settle(); const model = api.trackChangesPreviewController.getLastModel(key) ?? []; assert.ok(model.length > 0 && model.every((o) => o.kind === "unchanged"), "after pin, every block is unchanged"); assert.strictEqual(api.diffViewController.getBaseline(key)?.reason, "pinned", "baseline reason advanced to pinned"); }); // SLICE-1 reachability: the orphaned pin command gets a real palette `when`. test("pinDiffBaseline is reachable from the command palette (when: editorLangId == markdown)", async () => { const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8")); const entry = (pkg.contributes.menus.commandPalette as Array<{ command: string; when?: string }>).find( (m) => m.command === "cowriting.pinDiffBaseline", ); assert.ok(entry, "pinDiffBaseline has a commandPalette entry"); assert.notStrictEqual(entry!.when, "false", "it is no longer hidden (when:false)"); assert.match(entry!.when ?? "", /editorLangId == markdown/, "guarded on markdown"); }); // #47 (was INV-37): Edit Document now cuts at BLOCK granularity — two word // changes in ONE paragraph are ONE block proposal (INV-39 supersedes INV-37's // per-word cut). Full block coverage lives in f12Review.test.ts. test("runEditAndPropose(document) — two word edits in one paragraph → ONE block proposal (PUC-4, INV-39)", async () => { const { doc, key } = await freshDoc( "docs/f11doc.md", "# F11 doc\n\nThe quick brown fox jumps over the lazy dog.\n", ); const api = await getApi(); const ctl = api.trackChangesPreviewController; await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await settle(); // Stub the host edit turn (no LLM in CI): rewrite two distinct words. ctl.setEditTurnForTest(async () => ({ replacement: "# F11 doc\n\nThe quick RED fox jumps over the lazy CAT.\n", model: "sonnet", sessionId: "e2e-f11-doc", })); const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "swap brown→RED and dog→CAT"); await settle(); assert.strictEqual(ids.length, 1, "two changed words in one block → ONE block proposal (INV-39)"); const views = api.proposalController.listProposals(doc); const view = views.find((v) => v.id === ids[0]); assert.ok(view, "the returned proposal id is a live pending proposal"); assert.strictEqual( view!.replacement, "The quick RED fox jumps over the lazy CAT.", "the block proposal carries the whole rewritten paragraph", ); // INV-10: proposing never mutates the document. assert.ok(doc.getText().includes("brown fox") && doc.getText().includes("lazy dog"), "document unchanged by propose"); void key; }); // SLICE-4: Edit Selection → one single-range proposal over the resolved block-union. test("runEditAndPropose(range) with a stubbed turn → exactly one proposal over the resolved range (PUC-3, INV-37)", async () => { const body = "# F11 sel\n\nThe target paragraph Claude will rewrite.\n\nAnother untouched paragraph.\n"; const { doc, key } = await freshDoc("docs/f11sel.md", body); const api = await getApi(); const ctl = api.trackChangesPreviewController; await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await settle(); const target = "The target paragraph Claude will rewrite."; const start = doc.getText().indexOf(target); const end = start + target.length; ctl.setEditTurnForTest(async (_instruction, text) => { assert.strictEqual(text, target, "the turn receives exactly the selected source range"); return { replacement: "The REWRITTEN paragraph from Claude.", model: "sonnet", sessionId: "e2e-f11-sel" }; }); const ids = await ctl.runEditAndPropose(doc, { kind: "range", start, end }, "rewrite this paragraph"); await settle(); assert.strictEqual(ids.length, 1, "a selection yields exactly one proposal"); const views = api.proposalController.listProposals(doc); const view = views.find((v) => v.id === ids[0]); assert.ok(view, "the proposal is live"); assert.strictEqual(view!.replacement, "The REWRITTEN paragraph from Claude.", "carries the turn replacement"); assert.strictEqual(view!.replaced, target, "replaces exactly the selected range"); assert.ok(doc.getText().includes(target), "document unchanged by propose (INV-10)"); void key; }); // SLICE-4: a no-op turn (Claude returns the input unchanged) produces no proposal. test("runEditAndPropose(range) where Claude returns the selection unchanged → no proposal", async () => { const { doc } = await freshDoc("docs/f11noop.md", "# noop\n\nLeave me exactly as I am.\n"); const api = await getApi(); const ctl = api.trackChangesPreviewController; await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await settle(); const target = "Leave me exactly as I am."; const start = doc.getText().indexOf(target); ctl.setEditTurnForTest(async (_i, text) => ({ replacement: text, model: "sonnet", sessionId: "e2e-noop" })); const ids = await ctl.runEditAndPropose(doc, { kind: "range", start, end: start + target.length }, "no change"); assert.strictEqual(ids.length, 0, "an unchanged replacement proposes nothing"); }); // SLICE-3 (review follow-up): a document rewrite that INSERTS text → an // acceptable proposal (not a born-orphaned zero-width hunk), and accepting all // hunks of a multi-hunk rewrite lands the intended document. test("document rewrite with an insertion → acceptable proposals; accept-all reaches the rewrite", async () => { const original = "# F11 accept\n\nThe brown fox sleeps.\n"; const rewrite = "# F11 accept\n\nThe brown fox QUIETLY sleeps today.\n"; const { doc } = await freshDoc("docs/f11accept.md", original); const api = await getApi(); const ctl = api.trackChangesPreviewController; await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await settle(); ctl.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-accept" })); const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "expand the sentence"); await settle(); assert.ok(ids.length >= 1, "the rewrite produced at least one proposal"); // Every proposal must be acceptable (the insertion-anchoring fix): accept each. for (const id of ids) { const ok = await api.proposalController.acceptById("docs/f11accept.md", id); assert.ok(ok, `proposal ${id} is acceptable (not born-orphaned)`); await settle(); } assert.strictEqual(doc.getText(), rewrite, "accepting all hunks reconstructs the intended rewrite"); assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "no proposals left pending"); }); // SLICE-3: the document-scoped command exists for #42 reuse, guarded on markdown. test("cowriting.editDocument is a registered command, palette-guarded on markdown", async () => { const all = await vscode.commands.getCommands(true); assert.ok(all.includes("cowriting.editDocument"), "editDocument command registered"); const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8")); const entry = (pkg.contributes.menus.commandPalette as Array<{ command: string; when?: string }>).find( (m) => m.command === "cowriting.editDocument", ); assert.ok(entry, "editDocument has a commandPalette entry"); assert.match(entry!.when ?? "", /editorLangId == markdown/, "guarded on markdown"); }); // SLICE-5: the minimal right-click gateway lives in editor/title (markdown only). test("the editor/title gateway opens the preview, and the menu entry is markdown-guarded (PUC-6)", async () => { const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8")); const entry = (pkg.contributes.menus["editor/title"] as Array<{ command: string; when?: string }>).find( (m) => m.command === "cowriting.showTrackChangesPreview", ); assert.ok(entry, "showTrackChangesPreview is in editor/title"); assert.match(entry!.when ?? "", /editorLangId == markdown/, "gateway guarded on markdown"); // the command it invokes opens the panel. const { key } = await freshDoc("docs/f11gw.md", "# F11 gateway\n\nReachable end to end.\n"); const api = await getApi(); await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await settle(); assert.strictEqual(api.trackChangesPreviewController.isOpen(key), true, "gateway command opens the preview"); }); // SLICE-5: edit controls are inert on a non-authorable (read-only scheme) doc. test("toolbar edit controls are disabled for a non-authorable document (PUC-1/7)", async () => { const SCHEME = "cwf11ro"; const provider = new (class implements vscode.TextDocumentContentProvider { onDidChange = undefined; provideTextDocumentContent(): string { return "# Read only\n\nThis markdown doc is not authorable.\n"; } })(); const reg = vscode.workspace.registerTextDocumentContentProvider(SCHEME, provider); try { const uri = vscode.Uri.parse(`${SCHEME}:/readonly.md`); const doc = await vscode.workspace.openTextDocument(uri); await vscode.languages.setTextDocumentLanguage(doc, "markdown"); await vscode.window.showTextDocument(doc); await settle(); const api = await getApi(); const key = uri.toString(); assert.strictEqual(doc.languageId, "markdown", "fixture is markdown"); await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await settle(); assert.strictEqual(api.trackChangesPreviewController.isOpen(key), true, "preview opens (reading is always allowed)"); assert.strictEqual( api.trackChangesPreviewController.editControlsEnabled(key), false, "Pin + Ask-Claude controls are disabled on a non-authorable doc", ); } finally { reg.dispose(); } }); });