diff --git a/test/e2e/fixtures/workspace/docs/crossrung.md b/test/e2e/fixtures/workspace/docs/crossrung.md new file mode 100644 index 0000000..c7abd46 --- /dev/null +++ b/test/e2e/fixtures/workspace/docs/crossrung.md @@ -0,0 +1,5 @@ +# Crossrung doc + +The portable record travels with ordinary git. + +Nothing else here. diff --git a/test/e2e/fixtures/workspace/docs/v2.md b/test/e2e/fixtures/workspace/docs/v2.md new file mode 100644 index 0000000..8aa4b8d --- /dev/null +++ b/test/e2e/fixtures/workspace/docs/v2.md @@ -0,0 +1,3 @@ +# Future doc + +Anchored text from the future. diff --git a/test/e2e/suite/crossrung.test.ts b/test/e2e/suite/crossrung.test.ts new file mode 100644 index 0000000..aa188e9 --- /dev/null +++ b/test/e2e/suite/crossrung.test.ts @@ -0,0 +1,129 @@ +import * as assert from "assert"; +import * as fs from "fs"; +import * as path from "path"; +import { execFileSync } from "child_process"; +import * as vscode from "vscode"; +import type { CowritingApi } from "../../../src/extension"; +import type { Artifact } from "../../../src/model"; + +const WS = process.env.E2E_WORKSPACE!; +const PROJECT_ROOT = path.resolve(__dirname, "../../../.."); +const STANDIN = path.join(PROJECT_ROOT, "scripts", "crossrung-reply.mjs"); + +function sidecarPath(docRel: string): string { + return path.join(WS, ".threads", `${docRel}.json`); +} +async function open(docRel: string): Promise { + const uri = vscode.Uri.file(path.join(WS, docRel)); + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc); + 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?.versionGuard, "extension exports versionGuard (F5)"); + return api; +} +// The extension host is Electron: run the stand-in with the same binary in +// plain-node mode — a forge's write is just a local process to git (fork e). +function runStandin(...args: string[]): void { + execFileSync(process.execPath, [STANDIN, ...args], { + encoding: "utf8", + env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" }, + }); +} +function commentsOf(api: CowritingApi, docRel: string, threadId: string): number { + const docs = ( + api.threadController as unknown as { + docs: Map }>; + } + ).docs; + const vsThread = docs.get(docRel)?.vsThreads.get(threadId); + return vsThread ? vsThread.comments.length : -1; +} +async function until(cond: () => boolean, ms = 5000): Promise { + const t0 = Date.now(); + while (!cond()) { + if (Date.now() - t0 > ms) throw new Error("timed out waiting for condition"); + await new Promise((r) => setTimeout(r, 100)); + } +} + +suite("F5 cross-rung round-trip (host E2E)", () => { + test("stand-in reply lands in the editor's thread via external change (PUC-2)", async () => { + const DOC = "docs/crossrung.md"; + const doc = await open(DOC); + const api = await getApi(); + const target = "portable record"; + const start = doc.getText().indexOf(target); + const editor = vscode.window.activeTextEditor!; + editor.selection = new vscode.Selection(doc.positionAt(start), doc.positionAt(start + target.length)); + const threadId = await api.threadController.createThreadOnSelection("Editor note"); + assert.ok(threadId, "thread created in the editor"); + + // the Gitea-rung stand-in appends a reply OUT OF EDITOR (spec fork e) + runStandin(sidecarPath(DOC), threadId!, "Reply from the forge", "--author-id", "gitea", "--author-email", "forge@example.org"); + + // watcher → external change → re-render with the new message (the F2 path) + await until(() => commentsOf(api, DOC, threadId!) === 2); + const rendered = api.threadController.getRendered(DOC); + const mine = rendered.find((r) => r.id === threadId)!; + assert.strictEqual(mine.orphaned, false, "anchor still resolves after the foreign write"); + const anchorLine = doc.positionAt(doc.getText().indexOf(target)).line; + assert.strictEqual(mine.range.startLine, anchorLine, "rendered at the re-resolved anchor"); + + const art = JSON.parse(fs.readFileSync(sidecarPath(DOC), "utf8")) as Artifact; + const msgs = art.threads.find((t) => t.id === threadId)!.messages; + assert.strictEqual(msgs.length, 2); + assert.deepStrictEqual(msgs[1].author, { kind: "human", id: "gitea", email: "forge@example.org" }); + }); + + test("newer-major sidecar: warning + read-only, file bytes never change (PUC-4)", async () => { + const DOC = "docs/v2.md"; + const scPath = sidecarPath(DOC); + fs.mkdirSync(path.dirname(scPath), { recursive: true }); + // a v2 sidecar: valid v1 sections plus future content this build can't know + const v2 = { + schemaVersion: 2, + document: { path: DOC }, + anchors: { + a_1: { fingerprint: { text: "Anchored text", before: "", after: " from the future.", lineHint: 2 } }, + }, + threads: [ + { + id: "t_1", + anchorId: "a_1", + status: "open", + messages: [{ id: "m_1", author: { kind: "human", id: "future" }, body: "from v2", createdAt: "2026-06-10T00:00:00.000Z" }], + }, + ], + attributions: [], + proposals: [], + v2OnlySection: { shiny: true }, + }; + fs.writeFileSync(scPath, JSON.stringify(v2, null, 2) + "\n", "utf8"); + const before = fs.readFileSync(scPath, "utf8"); + + const doc = await open(DOC); + const api = await getApi(); + api.threadController.renderAll(doc); // render best-effort: the v1 subset shows + const rendered = api.threadController.getRendered(DOC); + assert.strictEqual(rendered.length, 1, "renders what it understands"); + + // write gestures are refused + warned (PUC-4) + const target = "Anchored text"; + const start = doc.getText().indexOf(target); + const editor = vscode.window.activeTextEditor!; + editor.selection = new vscode.Selection(doc.positionAt(start), doc.positionAt(start + target.length)); + const created = await api.threadController.createThreadOnSelection("should not land"); + assert.strictEqual(created, undefined, "createThread refused on newer major"); + assert.strictEqual(api.versionGuard.wasWarned(DOC), true, "user warned once (PUC-4)"); + + // an edit + save must NOT rewrite the sidecar (INV-16) + await editor.edit((b) => b.insert(doc.positionAt(0), "edited ")); + await doc.save(); + await new Promise((r) => setTimeout(r, 500)); + assert.strictEqual(fs.readFileSync(scPath, "utf8"), before, "sidecar bytes untouched (INV-16)"); + }); +});