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/sample.md"; function sidecarPath(): string { return path.join(WS, ".threads", "docs", "sample.md.json"); } function readSidecar(): Artifact { return JSON.parse(fs.readFileSync(sidecarPath(), "utf8")) as Artifact; } async function openSample(): Promise { const uri = vscode.Uri.file(path.join(WS, DOC_REL)); const doc = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(doc); return doc; } // Simulate an EXTERNAL change to the document (e.g. a git pull): write the file // on disk, then force the open editor buffer to reload from disk with `revert` // (openTextDocument otherwise returns VS Code's cached in-memory buffer). async function externalWriteAndReload(uri: vscode.Uri, content: string): Promise { 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; } async function getApi(): Promise { const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; const api = (await ext.activate()) as CowritingApi; assert.ok(api?.threadController, "extension should export threadController"); return api; } // Reaches the controller's internal docs map for reply/resolve handlers (which // expect a live vscode.CommentThread). Spec §6.8 "drive the ThreadController and // assert Comments-controller state" fallback. function findVsThread(api: CowritingApi, threadId: string): vscode.CommentThread { const rendered = api.threadController.getRendered(DOC_REL); assert.ok(rendered.some((r) => r.id === threadId), "thread is rendered"); return ( api.threadController as unknown as { docs: Map }>; } ).docs .get(DOC_REL)! .vsThreads.get(threadId)!; } function setStatusViaApi(api: CowritingApi, threadId: string, status: "open" | "resolved"): void { const vsThread = findVsThread(api, threadId); void vscode.commands.executeCommand( status === "resolved" ? "cowriting.resolveThread" : "cowriting.reopenThread", vsThread, ); } suite("F2 region-anchored threads (host E2E)", () => { test("create on selection persists a thread sidecar", async () => { const doc = await openSample(); const api = await getApi(); const start = doc.getText().indexOf("quick brown fox"); const editor = vscode.window.activeTextEditor!; editor.selection = new vscode.Selection(doc.positionAt(start), doc.positionAt(start + "quick brown fox".length)); const threadId = await api.threadController.createThreadOnSelection("First note"); assert.ok(threadId, "createThreadOnSelection returns an id"); assert.ok(fs.existsSync(sidecarPath()), "sidecar written to disk"); const art = readSidecar(); assert.strictEqual(art.threads.length, 1); assert.strictEqual(art.threads[0].status, "open"); assert.strictEqual(art.threads[0].messages[0].body, "First note"); const anchorId = art.threads[0].anchorId; assert.strictEqual(art.anchors[anchorId].fingerprint.text, "quick brown fox"); const rendered = api.threadController.getRendered(DOC_REL); assert.strictEqual(rendered.length, 1); assert.strictEqual(rendered[0].orphaned, false); }); test("reply appends and resolve flips status, both persisted", async () => { const api = await getApi(); const art0 = readSidecar(); const threadId = art0.threads[0].id; api.threadController.reply({ thread: findVsThread(api, threadId), text: "A reply" } as vscode.CommentReply); const art1 = readSidecar(); assert.deepStrictEqual( art1.threads[0].messages.map((m) => m.body), ["First note", "A reply"], ); setStatusViaApi(api, threadId, "resolved"); const art2 = readSidecar(); assert.strictEqual(art2.threads[0].status, "resolved"); }); test("reload renders the persisted thread at its anchor", async () => { const doc = await openSample(); const api = await getApi(); api.threadController.renderAll(doc); const rendered = api.threadController.getRendered(DOC_REL); assert.strictEqual(rendered.length, 1); assert.strictEqual(rendered[0].orphaned, false); const anchorLine = doc.positionAt(doc.getText().indexOf("quick brown fox")).line; assert.strictEqual(rendered[0].range.startLine, anchorLine); }); test("external edit that moves the text re-anchors; deleting it orphans (INV-1)", async () => { const uri = vscode.Uri.file(path.join(WS, DOC_REL)); const original = fs.readFileSync(uri.fsPath, "utf8"); // Move the anchored text down by prepending lines (external change). let doc = await externalWriteAndReload(uri, "PREPENDED LINE ONE\nPREPENDED LINE TWO\n" + original); const api = await getApi(); api.threadController.renderAll(doc); let rendered = api.threadController.getRendered(DOC_REL); assert.strictEqual(rendered[0].orphaned, false, "still anchored after a move"); const movedLine = doc.positionAt(doc.getText().indexOf("quick brown fox")).line; assert.strictEqual(movedLine, 4, "fox moved from line 2 to line 4 after prepending two lines"); assert.strictEqual(rendered[0].range.startLine, movedLine); // Now delete the anchored text entirely → must orphan, not move. const deleted = "PREPENDED LINE ONE\nThe slow grey cat\n" + original.replace("The quick brown fox jumps over the lazy dog.", "The slow grey cat naps."); doc = await externalWriteAndReload(uri, deleted); assert.strictEqual(doc.getText().includes("quick brown fox"), false, "anchored text is gone from the buffer"); api.threadController.renderAll(doc); rendered = api.threadController.getRendered(DOC_REL); assert.strictEqual(rendered[0].orphaned, true, "deleted anchor must orphan (INV-1)"); }); });