F2 SLICE-5: @vscode/test-electron host E2E (create→reload→re-anchor→orphan) (#4)

Drives the extension's returned ThreadController API + asserts the on-disk
sidecar and rendered Comments state (spec §6.8 fallback). All 4 host tests
green against VS Code 1.124.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-10 06:58:51 -07:00
parent 2a9c198859
commit 4ce6c9da52
8 changed files with 1720 additions and 0 deletions
+4
View File
@@ -1,3 +1,7 @@
node_modules/
out/
*.vsix
.vscode-test/
# E2E throwaway sidecars (the harness copies the fixture to a tmpdir, but guard anyway)
test/e2e/fixtures/workspace/.threads/
+1507
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -52,15 +52,21 @@
"watch": "node esbuild.mjs --watch",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"pretest:e2e": "npm run build && tsc -p tsconfig.e2e.json",
"test:e2e": "node ./out/test/e2e/runTest.js",
"vscode:prepublish": "node esbuild.mjs"
},
"dependencies": {
"@cline/sdk": "0.0.46"
},
"devDependencies": {
"@types/mocha": "^10.0.7",
"@types/node": "^22.0.0",
"@types/vscode": "^1.90.0",
"@vscode/test-electron": "^2.4.0",
"esbuild": "^0.23.0",
"glob": "^11.0.0",
"mocha": "^10.7.0",
"typescript": "^5.5.0",
"vitest": "^2.0.0"
}
@@ -0,0 +1,7 @@
# Sample Document
The quick brown fox jumps over the lazy dog.
A second paragraph with the phrase anchor target inside it.
Closing line.
+31
View File
@@ -0,0 +1,31 @@
import * as path from "path";
import * as os from "os";
import * as fs from "fs";
import { runTests } from "@vscode/test-electron";
async function main(): Promise<void> {
const projectRoot = path.resolve(__dirname, "../../../");
const extensionDevelopmentPath = projectRoot;
const extensionTestsPath = path.resolve(__dirname, "./suite/index");
// Copy the fixture workspace to a temp dir so test writes are throwaway.
const fixture = path.resolve(projectRoot, "test/e2e/fixtures/workspace");
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "cowriting-e2e-"));
fs.cpSync(fixture, workspace, { recursive: true });
try {
await runTests({
extensionDevelopmentPath,
extensionTestsPath,
launchArgs: [workspace, "--disable-extensions"],
extensionTestsEnv: { E2E_WORKSPACE: workspace },
});
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
}
main().catch((err) => {
console.error("E2E run failed:", err);
process.exit(1);
});
+13
View File
@@ -0,0 +1,13 @@
import * as path from "path";
import Mocha from "mocha";
import { glob } from "glob";
export async function run(): Promise<void> {
const mocha = new Mocha({ ui: "tdd", color: true, timeout: 60000 });
const testsRoot = path.resolve(__dirname);
const files = await glob("**/*.test.js", { cwd: testsRoot });
for (const f of files) mocha.addFile(path.resolve(testsRoot, f));
await new Promise<void>((resolve, reject) => {
mocha.run((failures) => (failures > 0 ? reject(new Error(`${failures} E2E test(s) failed`)) : resolve()));
});
}
+139
View File
@@ -0,0 +1,139 @@
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<vscode.TextDocument> {
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<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;
}
async function getApi(): Promise<CowritingApi> {
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<string, { vsThreads: Map<string, vscode.CommentThread> }>;
}
).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)");
});
});
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "Node",
"noEmit": false,
"outDir": "out",
"rootDir": ".",
"sourceMap": true,
"types": ["node", "mocha", "vscode"]
},
"include": ["src", "test/e2e"]
}