156 lines
6.9 KiB
TypeScript
156 lines
6.9 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 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);
|
|
// INV-10: thread creation/rendering is gated on coediting (Task 4) — idempotent
|
|
// across this order-dependent suite's repeated openSample() calls.
|
|
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
|
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;
|
|
}
|
|
|
|
// Task 6 (D10): every human comment on a coedited doc now also fires the
|
|
// respond-in-thread turn (createThreadOnSelection/reply). This suite predates
|
|
// that loop and asserts exact message sequences unrelated to it — stub the
|
|
// turn to reject so the loop runs harmlessly (no real @cline/sdk call, and no
|
|
// extra machine message lands in the sidecar this suite's assertions read).
|
|
function stubReplyLoop(api: CowritingApi): void {
|
|
api.threadController.setTurnRunnerForTest(async () => {
|
|
throw new Error("F2 suite: reply-loop turn stubbed off (not under test here)");
|
|
});
|
|
}
|
|
|
|
// 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();
|
|
stubReplyLoop(api);
|
|
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();
|
|
stubReplyLoop(api);
|
|
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)");
|
|
});
|
|
});
|