From 22c9da0cec71b0bb83595514adba4878c231352c Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Thu, 2 Jul 2026 15:37:15 -0700 Subject: [PATCH] =?UTF-8?q?test:=20full=20native-loop=20E2E=20(PUC-1/2/7/8?= =?UTF-8?q?)=20+=20real-SDK=20smoke=20(spec=20=C2=A76.8,=20=C2=A77.1=20run?= =?UTF-8?q?g=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 9 of the native-surfaces migration plan. fullLoop.test.ts walks the whole inner loop in one doc, stub-turned: enter (snapshot baseline) -> two comment asks -> reply -> offer -> pending proposals (F12 optimistic-apply, decorated + isApplied) -> human tweak inside a pending proposal's range (INV-11: breaks the raw fingerprint anchor, but finalizeInPlace/"Keep" is built to survive that) -> keep one (attribution split via spansFor: claude's words + the human tweak, char-honest) -> reject the other (buffer text returns EXACTLY to its pre-proposal text, INV-5) -> markReviewed -> change count 0. scripts/smoke-native-loop.mjs + test/e2e/smoke/nativeLoop.ts are the rung-3 manual gate: launches the real EDH against sandbox/, posts a real comment via ThreadController.createThreadOnSelection with BOTH turns left unstubbed (the real @cline/sdk, INV-8), waits for the reply/offer, makes + accepts the edit, and prints the created proposal ids. Lives outside test/e2e/suite so it is never swept into `npm run test:e2e`; `npm run smoke:native` builds + compiles then runs it. Operator-run only (real credentials) -- not executed here, verified by typecheck + a syntax/structural pass only. 300 unit + 86 workspace/5 no-workspace host E2E green (1 pending, pre-existing #54 undo-suite skip). Co-Authored-By: Claude Fable 5 --- package.json | 1 + scripts/smoke-native-loop.mjs | 67 +++++++++++++ test/e2e/smoke/nativeLoop.ts | 77 +++++++++++++++ test/e2e/suite/fullLoop.test.ts | 164 ++++++++++++++++++++++++++++++++ 4 files changed, 309 insertions(+) create mode 100644 scripts/smoke-native-loop.mjs create mode 100644 test/e2e/smoke/nativeLoop.ts create mode 100644 test/e2e/suite/fullLoop.test.ts diff --git a/package.json b/package.json index 76f3d68..6d7159c 100644 --- a/package.json +++ b/package.json @@ -352,6 +352,7 @@ "pretest:e2e": "npm run build && npm run clean:e2e && tsc -p tsconfig.e2e.json", "test:e2e": "node ./out/test/e2e/runTest.js", "smoke:live": "npm run build && node scripts/smoke-live-turn.mjs", + "smoke:native": "npm run pretest:e2e && node scripts/smoke-native-loop.mjs", "vscode:prepublish": "node esbuild.mjs" }, "dependencies": { diff --git a/scripts/smoke-native-loop.mjs b/scripts/smoke-native-loop.mjs new file mode 100644 index 0000000..6eb2be5 --- /dev/null +++ b/scripts/smoke-native-loop.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +// Task 9 / spec §7.1 rung 3: the manual, real-@cline/sdk smoke for the native +// comment loop -- mirrors scripts/smoke-live-turn.mjs's role for F3 (a live +// login-backed check, deliberately NOT in CI), but this one drives the whole +// EDH (comment -> reply -> offer -> proposal needs vscode's own comment/doc +// APIs, unlike smoke-live-turn.mjs's bare module call) via +// test/e2e/smoke/nativeLoop.ts, launched exactly like test/e2e/runTest.ts +// launches the regular host-E2E suites -- just against a single smoke entry +// point that is NOT swept into `npm run test:e2e` (it lives outside +// test/e2e/suite, which is the only directory suite/index.ts's mocha glob +// searches). +// +// Prereqs: `npm run pretest:e2e` (builds the extension + compiles +// test/e2e/**). `npm run smoke:native` does this for you. +// +// Opens the real sandbox/ folder (the repo's EDH workspace root), writes a +// throwaway doc, posts a real comment via ThreadController.createThreadOnSelection, +// waits for the real @cline/sdk reply + offer, makes + accepts the edit, then +// prints the created proposal ids and deletes the throwaway doc. +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { runTests } from "@vscode/test-electron"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +async function main() { + const projectRoot = path.resolve(__dirname, ".."); + const extensionDevelopmentPath = projectRoot; + const extensionTestsPath = path.resolve(projectRoot, "out/test/e2e/smoke/nativeLoop"); + const sandbox = path.resolve(projectRoot, "sandbox"); + + if (!fs.existsSync(`${extensionTestsPath}.js`)) { + console.error( + `smoke-native-loop: ${extensionTestsPath}.js is missing -- run "npm run pretest:e2e" first (or "npm run smoke:native", which does this for you).`, + ); + process.exit(1); + } + + // Same macOS UNIX-socket-length workaround as test/e2e/runTest.ts (a long + // worktree path + the default .vscode-test/user-data dir can blow past the + // ~103-char limit). + const userDataRoot = process.platform === "win32" ? os.tmpdir() : "/tmp"; + const userDataDir = fs.mkdtempSync(path.join(userDataRoot, "cwud-smoke-")); + + try { + await runTests({ + extensionDevelopmentPath, + extensionTestsPath, + launchArgs: [sandbox, "--disable-extensions", "--user-data-dir", userDataDir], + }); + console.log("smoke-native-loop: PASS"); + process.exit(0); + } catch (err) { + console.error( + `smoke-native-loop: FAILED (expected when Claude Code is absent/signed out, or the sandbox/ folder is missing): ${ + err instanceof Error ? err.message : String(err) + }`, + ); + process.exit(1); + } finally { + fs.rmSync(userDataDir, { recursive: true, force: true }); + } +} + +main(); diff --git a/test/e2e/smoke/nativeLoop.ts b/test/e2e/smoke/nativeLoop.ts new file mode 100644 index 0000000..4dcf4cd --- /dev/null +++ b/test/e2e/smoke/nativeLoop.ts @@ -0,0 +1,77 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as vscode from "vscode"; +import type { CowritingApi } from "../../../src/extension"; +import { settleUntil } from "../suite/helpers"; + +/** + * Task 9 (spec §7.1 rung 3 — the real @cline/sdk, manual gate). Drives the + * SAME comments-first ask -> reply -> offer -> proposal loop as + * test/e2e/suite/commentLoop.test.ts / fullLoop.test.ts, but with BOTH turns + * left UNSTUBBED: ThreadController's reply loop and EditFlow's edit turn hit + * the real local `claude` login (INV-8 -- the extension itself holds no + * credentials). Not part of `npm run test:e2e` / CI -- driven only by + * `scripts/smoke-native-loop.mjs` (`npm run smoke:native`), mirroring + * `scripts/smoke-live-turn.mjs`'s "manual gate, real SDK" role for F3. + * + * There is no VS Code API to drive the native comment-widget's OWN submit + * gesture from outside the UI (see commentLoop.test.ts's "Finding 1" note) -- + * this calls the exact seam a real comment submission reaches + * (ThreadController.createThreadOnSelection / .makeThreadEdit), which is the + * closest a headless script gets to "post a real comment" on a live doc. + */ +export async function run(): Promise { + const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin"); + if (!ext) throw new Error("extension not found -- is extensionDevelopmentPath wired correctly?"); + const api = (await ext.activate()) as CowritingApi; + if (!api?.threadController || !api?.editFlow || !api?.proposalController || !api?.sidecarRouter) { + throw new Error("extension did not export the expected controllers"); + } + + const folder = vscode.workspace.workspaceFolders?.[0]; + if (!folder) throw new Error("no workspace folder -- launch with the sandbox/ folder open"); + const target = path.join(folder.uri.fsPath, `.smoke-native-loop-${process.pid}.md`); + const ORIGINAL = "The quick brown fox jumps over the lazy dog paragraph."; + fs.writeFileSync(target, `# Native-loop smoke\n\n${ORIGINAL}\n`, "utf8"); + + try { + const uri = vscode.Uri.file(target); + const doc = await vscode.workspace.openTextDocument(uri); + const ed = await vscode.window.showTextDocument(doc); + await vscode.commands.executeCommand("cowriting.coeditDocument"); + + const docKey = api.proposalController.keyFor(doc); + const start = doc.getText().indexOf(ORIGINAL); + ed.selection = new vscode.Selection(doc.positionAt(start), doc.positionAt(start + ORIGINAL.length)); + + console.log("[smoke-native-loop] posting a real comment (createThreadOnSelection)..."); + const threadId = await api.threadController.createThreadOnSelection( + "Rewrite this sentence to be more concise.", + ); + if (!threadId) throw new Error("createThreadOnSelection refused (not coediting, or no selection)"); + console.log(`[smoke-native-loop] thread created: ${threadId} -- waiting for the real @cline/sdk reply (this calls the live SDK; may take a while)...`); + + await settleUntil(() => { + const t = api.sidecarRouter.load(docKey)?.threads.find((x) => x.id === threadId); + return (t?.messages.length ?? 0) >= 2 && t!.messages[1].author.kind === "agent"; + }, 180000); + console.log("[smoke-native-loop] reply landed -- thread flipped to \"offer\". Making the edit (real SDK)..."); + + const ids = await api.threadController.makeThreadEdit(threadId, docKey); + if (ids.length === 0) throw new Error("makeThreadEdit produced no proposals (the turn returned an empty/no-op replacement)"); + console.log(`[smoke-native-loop] proposal id(s) created: ${ids.join(", ")}`); + + await settleUntil(() => ids.every((id) => api.proposalController.isApplied(docKey, id)), 10000); + for (const id of ids) { + const ok = await api.proposalController.finalizeInPlace(docKey, id); + console.log(`[smoke-native-loop] accepted proposal ${id}: ${ok}`); + } + console.log(`[smoke-native-loop] PASS -- ${ids.length} proposal(s) created and accepted: ${ids.join(", ")}`); + } finally { + try { + fs.rmSync(target, { force: true }); + } catch { + // best-effort cleanup -- never let cleanup mask the real result + } + } +} diff --git a/test/e2e/suite/fullLoop.test.ts b/test/e2e/suite/fullLoop.test.ts new file mode 100644 index 0000000..a2ee4d7 --- /dev/null +++ b/test/e2e/suite/fullLoop.test.ts @@ -0,0 +1,164 @@ +import * as assert from "node:assert"; +import * as vscode from "vscode"; +import { activateApi, settle, settleUntil } from "./helpers"; + +/** + * Task 9 (spec §6.8 named E2E, §7.1 rung 2 — stub-turned): the full + * native-surfaces inner loop end to end, in ONE doc: + * PUC-7 (enter coediting, snapshot baseline) + * → PUC-8 (comment → reply → offer → pending proposal, D10/D19) + * → PUC-2 (buffer review: tweak under typing / re-anchor, keep, reject) + * → PUC-1 (pin the baseline clean). + * Both turns are stubbed (no real @cline/sdk call) — the real-SDK rung 3 is + * `scripts/smoke-native-loop.mjs`, operator-run against live credentials. + */ +suite("full native loop — PUC-7 → PUC-8 → PUC-2 → PUC-1 (§6.8, §7.1 rung 2)", () => { + test("enter, ask twice, offer, tweak-under-typing, keep one, reject one, pin — clean review", async () => { + const api = await activateApi(); + const ORIG_KEEP = "The quick brown fox jumps over the lazy dog paragraph."; + const ORIG_REJECT = "A second paragraph that should stay exactly as written."; + const doc = await vscode.workspace.openTextDocument({ + language: "markdown", + content: `# Full loop\n\n${ORIG_KEEP}\n\n${ORIG_REJECT}\n`, + }); + const ed = await vscode.window.showTextDocument(doc); + + // PUC-7: enter coediting. An untitled doc has no git HEAD, so it lands in + // snapshot mode (INV-7/D13/D14 — baselineRouter.test.ts covers the fork). + await vscode.commands.executeCommand("cowriting.coeditDocument"); + await settle(); + assert.strictEqual( + api.diffViewController.modeOf(doc.uri.toString()), + "snapshot", + "untitled doc enters coediting in snapshot mode", + ); + + const docKey = api.proposalController.keyFor(doc); + const KEEP_REPLACEMENT = "The quick fox jumps the lazy dog."; + const REJECT_REPLACEMENT = "A rewritten second paragraph nobody asked to keep."; + + // ---- PUC-8, first ask: the "keep" paragraph ------------------------------------- + api.threadController.setTurnRunnerForTest(async () => ({ + replacement: "I'd tighten this sentence.", + model: "stub", + sessionId: "full-loop-1", + })); + api.editFlow.setEditTurnForTest(async () => ({ + replacement: KEEP_REPLACEMENT, + model: "stub", + sessionId: "full-loop-1", + })); + const keepStart = doc.getText().indexOf(ORIG_KEEP); + ed.selection = new vscode.Selection(doc.positionAt(keepStart), doc.positionAt(keepStart + ORIG_KEEP.length)); + const keepThreadId = (await api.threadController.createThreadOnSelection("tighten this"))!; + assert.ok(keepThreadId, "thread created on a coedited doc (INV-10 gate open)"); + // The reply loop fires on the human message; wait for the machine reply to + // land and flip the thread to "offer" (D10/PUC-8). + await settleUntil(() => { + const t = api.sidecarRouter.load(docKey)?.threads.find((x) => x.id === keepThreadId); + return (t?.messages.length ?? 0) >= 2 && t!.messages[1].author.kind === "agent"; + }, 10000); + const keepIds = await api.threadController.makeThreadEdit(keepThreadId, docKey); + assert.strictEqual(keepIds.length, 1, "one pending proposal from the ranged ask"); + const keepId = keepIds[0]; + + // ---- PUC-8, second ask: the "reject" paragraph ---------------------------------- + api.editFlow.setEditTurnForTest(async () => ({ + replacement: REJECT_REPLACEMENT, + model: "stub", + sessionId: "full-loop-2", + })); + const rejectStart = doc.getText().indexOf(ORIG_REJECT); + ed.selection = new vscode.Selection(doc.positionAt(rejectStart), doc.positionAt(rejectStart + ORIG_REJECT.length)); + const rejectThreadId = (await api.threadController.createThreadOnSelection("rewrite this"))!; + await settleUntil(() => { + const t = api.sidecarRouter.load(docKey)?.threads.find((x) => x.id === rejectThreadId); + return (t?.messages.length ?? 0) >= 2 && t!.messages[1].author.kind === "agent"; + }, 10000); + const rejectIds = await api.threadController.makeThreadEdit(rejectThreadId, docKey); + assert.strictEqual(rejectIds.length, 1, "one pending proposal from the second ranged ask"); + const rejectId = rejectIds[0]; + + // ---- decorated pending proposals (F12 optimistic-apply) -------------------------- + await settleUntil( + () => api.proposalController.isApplied(docKey, keepId) && api.proposalController.isApplied(docKey, rejectId), + 5000, + ); + let views = api.proposalController.listProposals(doc); + assert.strictEqual(views.length, 2, "two pending proposals decorated"); + for (const id of [keepId, rejectId]) { + const v = views.find((x) => x.id === id)!; + assert.notStrictEqual(v.anchorStart, null, "each proposal resolves (pending, not orphaned)"); + assert.ok(api.proposalController.isApplied(docKey, id), "optimistically applied into the buffer"); + } + assert.ok(doc.getText().includes(KEEP_REPLACEMENT), "claude's first proposed rewrite is live in the buffer"); + assert.ok(doc.getText().includes(REJECT_REPLACEMENT), "claude's second proposed rewrite is live in the buffer"); + + // ---- PUC-2, tweak: type INSIDE the still-pending "keep" range -------------------- + // Verified against src/anchorer.ts: `resolve()` is an EXACT-substring search + // (INV-1 — no fuzzy tolerance), so an interior edit to a proposal's own + // fingerprinted text unconditionally breaks that fingerprint — this is INV-11, + // already covered by proposals.test.ts's "editing the target text makes the + // proposal stale" case. F12's finalizeInPlace ("Keep") is deliberately built to + // bypass that staleness check (see its own doc comment: "Direct finalizeInPlace + // calls ... where the user may have edited inside the applied span bypass this + // check intentionally") — the tweaked text is ALREADY the accepted result + // sitting in the buffer, so no re-resolve is needed to keep it. That is the + // real "re-anchor" story for a pending-range tweak: the proposal survives as a + // live, addressable id (not silently dropped) and Keep still lands it, even + // though its raw anchor is (correctly) stale. + const at = doc.getText().indexOf(KEEP_REPLACEMENT); + const tweak = new vscode.WorkspaceEdit(); + tweak.replace(doc.uri, new vscode.Range(doc.positionAt(at + 10), doc.positionAt(at + 13)), "FOX"); // "fox" -> "FOX" + assert.ok(await vscode.workspace.applyEdit(tweak), "human tweak applied inside the pending range"); + await settle(); + assert.ok(doc.getText().includes("The quick FOX jumps the lazy dog."), "human tweak landed in the buffer"); + views = api.proposalController.listProposals(doc); + const keepView = views.find((v) => v.id === keepId); + assert.ok(keepView, "the tweaked proposal is still a live, addressable pending proposal (not silently dropped)"); + assert.strictEqual( + keepView!.anchorStart, + null, + "INV-11: the interior tweak breaks the exact-fingerprint anchor (flagged stale, never guessed)", + ); + + // ---- PUC-2, keep: finalize the tweaked proposal in place ------------------------- + assert.ok(await api.proposalController.finalizeInPlace(docKey, keepId), "keep finalizes in place"); + await settle(); + assert.strictEqual( + api.proposalController.listProposals(doc).find((v) => v.id === keepId), + undefined, + "kept proposal cleared", + ); + // Attribution split (F3xF12): Claude's words AND the human tweak are both + // attributed, char-honest (spansFor). + const spans = api.attributionController.spansFor(doc); + const claudeSpan = spans.find( + (s) => s.author === "claude" && doc.getText().slice(s.start, s.end).includes("jumps the lazy dog"), + ); + const humanSpan = spans.find((s) => s.author === "human" && doc.getText().slice(s.start, s.end) === "FOX"); + assert.ok(claudeSpan, "claude's words are attributed"); + assert.ok(humanSpan, "the human tweak is attributed separately (char-honest split)"); + + // ---- PUC-2, reject: revert the untouched second proposal in place ---------------- + assert.ok(await api.proposalController.revertInPlace(docKey, rejectId), "reject reverts in place"); + await settle(); + assert.strictEqual( + api.proposalController.listProposals(doc).find((v) => v.id === rejectId), + undefined, + "rejected proposal cleared", + ); + // INV-5: the rejected range returns EXACTLY to its pre-proposal text. + assert.ok(doc.getText().includes(ORIG_REJECT), "rejected range restored EXACTLY to its pre-proposal text (INV-5)"); + assert.ok(!doc.getText().includes(REJECT_REPLACEMENT), "claude's rejected rewrite is gone from the buffer"); + + // ---- PUC-1: pin the baseline (Mark Changes as Reviewed) -------------------------- + await vscode.commands.executeCommand("cowriting.markReviewed"); + await settleUntil(() => api.scmSurfaceController.changeCount(doc.uri.toString()) === 0, 5000); + assert.strictEqual( + api.diffViewController.getBaseline(doc.uri.toString())?.reason, + "pinned", + "baseline re-pinned to the clean, fully-reviewed buffer", + ); + }); +});