Native surfaces migration — evolve the extension onto VS Code's native review surfaces (9-task plan, spec v0.2.1) (#72)
This commit was merged in pull request #72.
This commit is contained in:
@@ -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<void> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,22 +79,25 @@ suite("no-workspace authoring (F8 — real folder-less, #8 lineage)", () => {
|
||||
|
||||
// F6 (#19) baseline data layer is workspace-INDEPENDENT: it captures a baseline
|
||||
// for an untitled buffer even with no folder open (the two-pane VIEW was
|
||||
// removed in #34; only the data layer remains). pinDiffBaseline stays real.
|
||||
// removed in #34; only the data layer remains). markReviewed stays real.
|
||||
// Native-surfaces migration (Task 2, INV-7): the baseline is established on
|
||||
// coediting ENTRY, not merely on open.
|
||||
test("F6 baseline data layer works with no folder open (untitled buffer)", async () => {
|
||||
const all = await vscode.commands.getCommands(true);
|
||||
assert.ok(all.includes("cowriting.pinDiffBaseline"), "pinDiffBaseline registered");
|
||||
assert.ok(all.includes("cowriting.markReviewed"), "markReviewed registered");
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||
const api = (await ext.activate()) as CowritingApi;
|
||||
const untitled = await vscode.workspace.openTextDocument({ content: "no-folder scratch\n", language: "markdown" });
|
||||
await vscode.window.showTextDocument(untitled);
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
const key = untitled.uri.toString();
|
||||
const baseline = api.diffViewController.getBaseline(key);
|
||||
assert.ok(baseline, "baseline captured for the untitled buffer with no folder");
|
||||
assert.strictEqual(baseline!.reason, "opened");
|
||||
// pin resets the baseline to now — works folder-less.
|
||||
await vscode.commands.executeCommand("cowriting.pinDiffBaseline");
|
||||
assert.strictEqual(baseline!.reason, "entered");
|
||||
// markReviewed resets the baseline to now — works folder-less.
|
||||
await vscode.commands.executeCommand("cowriting.markReviewed");
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
assert.strictEqual(api.diffViewController.getBaseline(key)!.reason, "pinned", "pin works with no folder");
|
||||
assert.strictEqual(api.diffViewController.getBaseline(key)!.reason, "pinned", "markReviewed works with no folder");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,9 @@ async function openDoc(): 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: attribution tracking is gated on coediting (Task 4) — idempotent
|
||||
// across this order-dependent suite's repeated openDoc() calls.
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
return doc;
|
||||
}
|
||||
async function getApi(): Promise<CowritingApi> {
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as vscode from "vscode";
|
||||
import type { CowritingApi } from "../../../src/extension";
|
||||
import { renderHtmlFor } from "./helpers";
|
||||
|
||||
const WS = process.env.E2E_WORKSPACE!;
|
||||
const settle = () => new Promise((r) => setTimeout(r, 300));
|
||||
@@ -10,38 +11,39 @@ const settle = () => new Promise((r) => setTimeout(r, 300));
|
||||
async function getApi(): Promise<CowritingApi> {
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||
const api = (await ext.activate()) as CowritingApi;
|
||||
assert.ok(api?.trackChangesPreviewController && api?.proposalController, "exports preview + proposal");
|
||||
assert.ok(api?.attributionController && api?.proposalController, "exports attribution + proposal");
|
||||
return api;
|
||||
}
|
||||
|
||||
// F10 host E2E (no LLM): the rewrite of the obsolete F9 authorship-mode test.
|
||||
// F9's "authorship" mode / renderAuthorship is gone — the on-state renderReview
|
||||
// now author-colors Claude's PENDING proposal blocks as cw-ins-claude. After the
|
||||
// proposal is accepted the baseline advances (INV-18/F12) and the text becomes
|
||||
// "unchanged" → renders plain. The class to check is cw-ins-claude (proposal block),
|
||||
// NOT cw-by-claude (the old F9 authorship render). Owns its own markdown doc.
|
||||
// now author-colors Claude's PENDING proposal blocks as cw-ins-claude. This
|
||||
// suite checks that render BEFORE accepting (the class to check is cw-ins-claude,
|
||||
// the proposal block, NOT cw-by-claude — the old F9 authorship render) and that
|
||||
// the attribution data layer survives the accept; it does not exercise the F6
|
||||
// baseline (see diffView.test.ts / baselineRouter.test.ts for the post-accept
|
||||
// baseline behavior, INV-7/D21). Owns its own markdown doc. Task 8: the render
|
||||
// probe is now the pure `renderReview` directly (the webview that used to wrap
|
||||
// it, `renderHtmlFor`, is gone).
|
||||
suite("F10 review preview — pending Claude proposal renders cw-ins-claude in the on-state (host E2E, no LLM)", () => {
|
||||
const DOC_REL = "docs/f10claude.md";
|
||||
const TARGET = "The sentence Claude will compose over.";
|
||||
const REPLACEMENT = "The sentence CLAUDE COMPOSED via the seam.";
|
||||
|
||||
test("a pending Claude proposal carries cw-ins-claude; accepted proposal leaves attribution data intact; mode defaults to on", async () => {
|
||||
test("a pending Claude proposal carries cw-ins-claude; accepted proposal leaves attribution data intact", async () => {
|
||||
const abs = path.join(WS, DOC_REL);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, `# F10\n\n${TARGET}\n`, "utf8");
|
||||
const uri = vscode.Uri.file(abs);
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
// INV-10: entering coediting is required for attribution tracking + F12
|
||||
// optimistic-apply to fire — this suite never entered it (pre-migration).
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settle();
|
||||
const api = await getApi();
|
||||
const key = uri.toString();
|
||||
|
||||
// open the preview — annotations default ON (F10/INV-33)
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
assert.ok(api.trackChangesPreviewController.isOpen(key), "preview open");
|
||||
assert.strictEqual(api.trackChangesPreviewController.getMode(key), "on", "annotations default to on");
|
||||
|
||||
// Claude proposes via the seam (F12 optimistic-apply; proposal stays PENDING)
|
||||
const start = doc.getText().indexOf(TARGET);
|
||||
const id = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
|
||||
@@ -59,13 +61,15 @@ suite("F10 review preview — pending Claude proposal renders cw-ins-claude in t
|
||||
// PENDING proposal: the preview renders the proposal block with cw-ins-claude.
|
||||
// After accepting, the baseline advances (INV-18) → the text becomes "unchanged"
|
||||
// and renders plain — so we check BEFORE accepting.
|
||||
const pendingHtml = api.trackChangesPreviewController.renderHtmlFor(key);
|
||||
const pendingHtml = renderHtmlFor(api, doc, key);
|
||||
assert.ok(
|
||||
pendingHtml.includes("cw-ins-claude"),
|
||||
"pending Claude proposal block carries cw-ins-claude in the on-state render",
|
||||
);
|
||||
|
||||
// accept via the seam — baseline advances (machine-landing, INV-18/F12)
|
||||
// accept via the seam — the proposal lands; the baseline no longer auto-advances
|
||||
// (INV-18/#48 retired, spec INV-7), but that isn't asserted here (no coediting
|
||||
// entry / baseline established for this doc — only the attribution data layer).
|
||||
assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies via the seam");
|
||||
await settle();
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import * as assert from "node:assert";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import * as vscode from "vscode";
|
||||
import { activateApi, settle, settleUntil } from "./helpers";
|
||||
|
||||
const WS = process.env.E2E_WORKSPACE!;
|
||||
|
||||
suite("baseline router (PUC-1, INV-7/D13/D14)", () => {
|
||||
test("snapshot mode: enter captures; markReviewed re-pins clean", async () => {
|
||||
const api = await activateApi();
|
||||
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "one\n" });
|
||||
const ed = await vscode.window.showTextDocument(doc);
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settle();
|
||||
assert.strictEqual(api.diffViewController.modeOf(doc.uri.toString()), "snapshot");
|
||||
assert.strictEqual(api.diffViewController.getBaseline(doc.uri.toString())?.text, "one\n");
|
||||
await ed.edit((b) => b.insert(new vscode.Position(1, 0), "two\n"));
|
||||
// Task 3 (INV-13): the status-bar/SCM change count tracks the live edit.
|
||||
await settleUntil(() => api.scmSurfaceController.changeCount(doc.uri.toString()) === 1);
|
||||
await vscode.commands.executeCommand("cowriting.markReviewed");
|
||||
const b = api.diffViewController.getBaseline(doc.uri.toString());
|
||||
assert.strictEqual(b?.text, "one\ntwo\n");
|
||||
assert.strictEqual(b?.reason, "pinned");
|
||||
// A pin re-baselines to the clean current text, so the change count resets.
|
||||
await settleUntil(() => api.scmSurfaceController.changeCount(doc.uri.toString()) === 0);
|
||||
});
|
||||
|
||||
test("head mode: baseline = HEAD; commit advances it", async function () {
|
||||
this.timeout(30000);
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cw-git-"));
|
||||
const git = (...a: string[]) => execFileSync("git", ["-C", dir, ...a], { encoding: "utf8" });
|
||||
git("init", "-q");
|
||||
fs.writeFileSync(path.join(dir, "doc.md"), "committed\n");
|
||||
git("add", "doc.md");
|
||||
git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "c1");
|
||||
const api = await activateApi();
|
||||
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(path.join(dir, "doc.md")));
|
||||
const ed = await vscode.window.showTextDocument(doc);
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settleUntil(() => api.diffViewController.modeOf(doc.uri.toString()) === "head", 10000);
|
||||
assert.strictEqual(api.diffViewController.getBaseline(doc.uri.toString())?.text, "committed\n");
|
||||
await ed.edit((b) => b.insert(new vscode.Position(1, 0), "uncommitted\n"));
|
||||
await doc.save();
|
||||
git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-aqm", "c2");
|
||||
await settleUntil(
|
||||
() => api.diffViewController.getBaseline(doc.uri.toString())?.text === "committed\nuncommitted\n",
|
||||
15000,
|
||||
);
|
||||
assert.strictEqual(api.diffViewController.getBaseline(doc.uri.toString())?.reason, "head");
|
||||
});
|
||||
|
||||
// Finding 3 (final whole-branch review, session native-surfaces-exec):
|
||||
// DiffViewController only establishes a baseline on the registry's ENTER
|
||||
// transition (a doc it can find already open) and, at startup, for docs
|
||||
// already open at that moment. A registry member entered while its document
|
||||
// was NOT yet open (the real-world case: a persisted coediting set restored
|
||||
// after a window reload, whose member is only lazily opened later) used to
|
||||
// never get a baseline at all — reproduced here directly via
|
||||
// `coeditingRegistry.enter(uri)` on a URI with no open document yet (the
|
||||
// registry API doesn't require one), then a genuine later open.
|
||||
test("a registry member entered before its doc was open gets its baseline established once the doc IS opened", async () => {
|
||||
const api = await activateApi();
|
||||
const rel = "docs/finding3-establish-on-open.md";
|
||||
const abs = path.join(WS, rel);
|
||||
const content = "# late open\n\nestablish-on-open should catch this.\n";
|
||||
fs.writeFileSync(abs, content, "utf8");
|
||||
const uri = vscode.Uri.file(abs);
|
||||
|
||||
// Membership without an open document — DiffViewController's enter-
|
||||
// transition handler no-ops (`textDocuments.find` misses), reproducing
|
||||
// the gap: a coediting member with no mode/baseline yet.
|
||||
api.coeditingRegistry.enter(uri);
|
||||
assert.strictEqual(api.coeditingRegistry.isCoediting(uri), true, "sanity: registry membership recorded");
|
||||
assert.strictEqual(api.diffViewController.modeOf(uri.toString()), undefined, "sanity: the gap is reproduced");
|
||||
|
||||
// A genuine later open — the fix's `renderIfOpen`/`onDidOpenTextDocument`
|
||||
// path must establish the baseline it missed.
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await settleUntil(() => api.diffViewController.modeOf(uri.toString()) !== undefined, 10000);
|
||||
assert.strictEqual(api.diffViewController.modeOf(uri.toString()), "snapshot");
|
||||
assert.strictEqual(api.diffViewController.getBaseline(uri.toString())?.text, content);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import * as assert from "node:assert";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import * as vscode from "vscode";
|
||||
import { cowritingMarkdownItPlugin } from "../../../src/previewAnnotations";
|
||||
import { activateApi, settle, settleUntil } from "./helpers";
|
||||
|
||||
const WS = process.env.E2E_WORKSPACE!;
|
||||
|
||||
async function api() {
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||
return (await ext.activate()) as import("../../../src/extension").CowritingApi;
|
||||
}
|
||||
|
||||
suite("coediting registry (PUC-7)", () => {
|
||||
test("enter → isCoediting true; exit → false; persists in list()", async () => {
|
||||
const { coeditingRegistry } = await api();
|
||||
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "# t\n\nbody\n" });
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
assert.strictEqual(coeditingRegistry.isCoediting(doc.uri), true);
|
||||
assert.ok(coeditingRegistry.list().includes(doc.uri.toString()));
|
||||
await vscode.commands.executeCommand("cowriting.stopCoediting");
|
||||
assert.strictEqual(coeditingRegistry.isCoediting(doc.uri), false);
|
||||
});
|
||||
|
||||
// Task 3 (INV-10): a document that was never entered into coediting gets no
|
||||
// native-diff surface at all. QuickDiff's `provideOriginalResource` isn't
|
||||
// directly queryable from a host-E2E test, so this asserts the gate INPUTS
|
||||
// structurally: `isCoediting` stays false and the SCM change count — which
|
||||
// `ScmSurfaceController` only ever populates for a coediting doc — stays 0.
|
||||
test("non-entered doc: no coediting, no change count (structural, INV-10)", async () => {
|
||||
const api = await activateApi();
|
||||
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "untouched\n" });
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await settle();
|
||||
assert.strictEqual(api.coeditingRegistry.isCoediting(doc.uri), false);
|
||||
assert.strictEqual(api.scmSurfaceController.changeCount(doc.uri.toString()), 0);
|
||||
});
|
||||
|
||||
// Task 4 (INV-10, PUC-7): the no-hijack scenario — a non-entered doc gets no
|
||||
// thread-creation surface at all; entering unlocks it; exiting hides the
|
||||
// rendered thread (not the sidecar-backed data); re-entering restores it.
|
||||
test("non-entered doc gets no surfaces; exit detaches; re-enter restores threads", async () => {
|
||||
const api = await activateApi();
|
||||
// Task 6 (D10): createThreadOnSelection now also fires the respond-in-
|
||||
// thread turn once the doc is coediting — stub it off (not under test
|
||||
// here; this suite asserts render/hide/restore, not the reply loop).
|
||||
api.threadController.setTurnRunnerForTest(async () => {
|
||||
throw new Error("coediting suite: reply-loop turn stubbed off");
|
||||
});
|
||||
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "# a\n\npara\n" });
|
||||
const ed = await vscode.window.showTextDocument(doc);
|
||||
// not entered → no thread creation
|
||||
ed.selection = new vscode.Selection(2, 0, 2, 4);
|
||||
const before = await api.threadController.createThreadOnSelection("hi");
|
||||
assert.strictEqual(before, undefined);
|
||||
// entered → works
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
const id = await api.threadController.createThreadOnSelection("hi");
|
||||
assert.ok(id);
|
||||
// exit → rendered thread set empty; re-enter → restored from sidecar
|
||||
await vscode.commands.executeCommand("cowriting.stopCoediting");
|
||||
assert.strictEqual(api.threadController.getRendered(api.proposalController.keyFor(doc)).length, 0);
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settle();
|
||||
assert.strictEqual(api.threadController.getRendered(api.proposalController.keyFor(doc)).length, 1);
|
||||
});
|
||||
|
||||
// PUC-7 restore-on-enter gap: unlike ThreadController (Task 4, above),
|
||||
// ProposalController.renderAll and AttributionController.loadAll were never
|
||||
// wired to registry.onDidChange — so a document's FIRST enter into
|
||||
// coediting with pre-existing sidecar proposals/attribution showed threads
|
||||
// but not proposal decorations/CodeLens or committed author-coloring until
|
||||
// a later edit happened to fire renderAll/loadAll. A same-session
|
||||
// exit-then-re-enter doesn't reproduce this on its own (nothing clears the
|
||||
// in-memory render caches on exit), so this test wipes those caches after
|
||||
// exit — simulating "never rendered in this session yet" — then re-enters
|
||||
// and asserts BOTH surfaces restore via the registry.onDidChange path
|
||||
// alone, with no subsequent edit.
|
||||
test("re-enter after exit restores proposal + attribution surfaces without an edit (PUC-7)", async () => {
|
||||
const api = await activateApi();
|
||||
// A workspace FILE (not untitled) — attribution only persists to the
|
||||
// sidecar on save, and this test needs the sidecar (not just in-memory
|
||||
// live state) to hold the seeded data before re-entering.
|
||||
const rel = "docs/puc7-restore.md";
|
||||
const abs = path.join(WS, rel);
|
||||
fs.writeFileSync(abs, "# t\n\nfoo bar baz\n", "utf8");
|
||||
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(abs));
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settle();
|
||||
|
||||
// Seed a pending proposal (F4) + a committed agent-attributed edit (F3) —
|
||||
// the two surfaces the gap left un-restored.
|
||||
const target = "bar";
|
||||
const start = doc.getText().indexOf(target);
|
||||
const proposalId = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
|
||||
uri: doc.uri.toString(),
|
||||
start,
|
||||
end: start + target.length,
|
||||
newText: "BAR-PROPOSED",
|
||||
model: "sonnet",
|
||||
sessionId: "e2e-puc7",
|
||||
turnId: "turn-puc7",
|
||||
});
|
||||
assert.ok(proposalId, "propose returns an id");
|
||||
// A real text change (not a no-op replace) — "foo" precedes the "bar"
|
||||
// target above, so it is unaffected by F12's optimistic-apply of the
|
||||
// proposal into the buffer.
|
||||
const fooStart = doc.getText().indexOf("foo");
|
||||
const applied = await api.attributionController.applyAgentEdit(
|
||||
doc,
|
||||
new vscode.Range(doc.positionAt(fooStart), doc.positionAt(fooStart + "foo".length)),
|
||||
"FOO-CLAUDE",
|
||||
{ kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "e2e-puc7" } },
|
||||
{ turnId: "turn-puc7" },
|
||||
);
|
||||
assert.strictEqual(applied, true, "seam edit applies");
|
||||
await settle();
|
||||
await doc.save(); // persists attribution records to the sidecar (F3 PUC-4)
|
||||
|
||||
const key = api.proposalController.keyFor(doc);
|
||||
assert.ok(api.proposalController.getRendered(key).length >= 1, "sanity: proposal rendered pre-exit");
|
||||
assert.ok(api.attributionController.getSpans(key).length >= 1, "sanity: attribution rendered pre-exit");
|
||||
|
||||
await vscode.commands.executeCommand("cowriting.stopCoediting");
|
||||
|
||||
// Wipe the controllers' in-memory render caches for this doc — nothing in
|
||||
// production does this on exit (by design: exit hides, it does not wipe
|
||||
// sidecar-backed state), so this reflection reproduces the "never
|
||||
// rendered this session" precondition of a genuinely fresh first-enter.
|
||||
(api.proposalController as unknown as { docs: Map<string, unknown> }).docs.delete(key);
|
||||
(api.attributionController as unknown as { docs: Map<string, unknown> }).docs.delete(key);
|
||||
assert.strictEqual(api.proposalController.getRendered(key).length, 0, "cache cleared pre-re-enter");
|
||||
assert.strictEqual(api.attributionController.getSpans(key).length, 0, "cache cleared pre-re-enter");
|
||||
|
||||
// Re-enter — the registry.onDidChange subscribers (PUC-7 fix) must
|
||||
// restore both surfaces from the sidecar with no subsequent edit.
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settleUntil(
|
||||
() => api.proposalController.getRendered(key).length >= 1 && api.attributionController.getSpans(key).length >= 1,
|
||||
);
|
||||
assert.strictEqual(api.proposalController.getRendered(key).length, 1, "proposal restored on re-enter");
|
||||
assert.ok(api.attributionController.getSpans(key).length >= 1, "attribution restored on re-enter");
|
||||
});
|
||||
|
||||
// Finding 1 (final whole-branch review, session native-surfaces-exec): the
|
||||
// preview-annotation host's INV-10 gate must never leak one document's
|
||||
// baseline/spans into another document's preview. The built-in preview's
|
||||
// rendered DOM isn't queryable from a host E2E test (§6.8, mirrored from
|
||||
// Task 7's own previewAnnotations.test.ts), so this drives the ACTUAL
|
||||
// production `previewAnnotationHost` through a real markdown-it instance
|
||||
// and asserts on the rendered HTML.
|
||||
test("INV-10: a non-coedited doc's preview never inherits another doc's annotations", async () => {
|
||||
const api = await activateApi();
|
||||
const docA = await vscode.workspace.openTextDocument({ language: "markdown", content: "alpha original\n" });
|
||||
await vscode.window.showTextDocument(docA);
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settle();
|
||||
const start = docA.getText().indexOf("original");
|
||||
const okA = await vscode.commands.executeCommand<boolean>("cowriting.applyAgentEdit", {
|
||||
uri: docA.uri.toString(),
|
||||
start,
|
||||
end: start + "original".length,
|
||||
newText: "REPLACED",
|
||||
model: "sonnet",
|
||||
sessionId: "e2e-inv10-leak",
|
||||
turnId: "turn-e2e-inv10-leak",
|
||||
});
|
||||
assert.strictEqual(okA, true, "seam edit applies to docA");
|
||||
await settle();
|
||||
|
||||
// docB is opened (becomes the active editor, so it would win any
|
||||
// `lastCoeditedUri` fallback if the gate were wrong) but NEVER entered
|
||||
// into coediting.
|
||||
const docB = await vscode.workspace.openTextDocument({ language: "markdown", content: "bravo untouched\n" });
|
||||
await vscode.window.showTextDocument(docB);
|
||||
await settle();
|
||||
|
||||
const md = new MarkdownIt();
|
||||
cowritingMarkdownItPlugin(md, api.previewAnnotationHost);
|
||||
const html = md.render(docB.getText(), { currentDocument: docB.uri });
|
||||
assert.ok(!html.includes("cw-ins-claude"), `docB must render with no annotations, got:\n${html}`);
|
||||
assert.ok(!html.includes("original"), `docA's baseline text must never leak into docB's preview:\n${html}`);
|
||||
assert.ok(html.includes("bravo untouched"), "docB renders its own content unannotated");
|
||||
});
|
||||
|
||||
// Finding 1, failure mode (b): `lastCoeditedUri` is never re-validated
|
||||
// against the registry, so a doc's OWN preview stayed annotated after
|
||||
// `stopCoediting` — an INV-10 violation for the doc whose gate was just
|
||||
// closed, not just for a different doc.
|
||||
test("INV-10: stopCoediting immediately clears a doc's own preview annotations", async () => {
|
||||
const api = await activateApi();
|
||||
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "gamma original\n" });
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settle();
|
||||
const start = doc.getText().indexOf("original");
|
||||
const ok = await vscode.commands.executeCommand<boolean>("cowriting.applyAgentEdit", {
|
||||
uri: doc.uri.toString(),
|
||||
start,
|
||||
end: start + "original".length,
|
||||
newText: "REPLACED",
|
||||
model: "sonnet",
|
||||
sessionId: "e2e-inv10-stop",
|
||||
turnId: "turn-e2e-inv10-stop",
|
||||
});
|
||||
assert.strictEqual(ok, true, "seam edit applies");
|
||||
await settle();
|
||||
|
||||
const md = new MarkdownIt();
|
||||
cowritingMarkdownItPlugin(md, api.previewAnnotationHost);
|
||||
const before = md.render(doc.getText(), { currentDocument: doc.uri });
|
||||
assert.ok(before.includes("cw-ins-claude"), `sanity: annotated while coediting, got:\n${before}`);
|
||||
|
||||
await vscode.commands.executeCommand("cowriting.stopCoediting");
|
||||
await settle();
|
||||
const after = md.render(doc.getText(), { currentDocument: doc.uri });
|
||||
assert.ok(!after.includes("cw-ins-claude"), `expected a clean render after stopCoediting, got:\n${after}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import * as assert from "assert";
|
||||
import * as vscode from "vscode";
|
||||
import { activateApi, settleUntil } from "./helpers";
|
||||
|
||||
// Task 6 (D19/D10/D8, PUC-8): the comments-first ask + comment→reply→offer→
|
||||
// proposal loop, with a stubbed reply turn (no real @cline/sdk call). Runs on
|
||||
// an untitled buffer (F8 routes it to the global sidecar) so this suite has no
|
||||
// workspace-folder dependency.
|
||||
suite("comment → reply → offer → proposal (PUC-8, D10/D19)", () => {
|
||||
test("reply on a coedited doc summons a machine reply + offer; accept lands pending proposals", async () => {
|
||||
const api = await activateApi();
|
||||
const doc = await vscode.workspace.openTextDocument({
|
||||
language: "markdown",
|
||||
content: "# T\n\nThe quick brown fox jumps over the lazy dog paragraph.\n",
|
||||
});
|
||||
const ed = await vscode.window.showTextDocument(doc);
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
api.threadController.setTurnRunnerForTest(async () => ({
|
||||
replacement: "I would tighten this sentence.",
|
||||
model: "stub",
|
||||
sessionId: "s1",
|
||||
}));
|
||||
api.editFlow.setEditTurnForTest(async () => ({
|
||||
replacement: "The quick fox jumps the lazy dog.",
|
||||
model: "stub",
|
||||
sessionId: "s1",
|
||||
}));
|
||||
ed.selection = new vscode.Selection(2, 0, 2, 20);
|
||||
const threadId = (await api.threadController.createThreadOnSelection("tighten this"))!;
|
||||
// reply-loop fires on the human message; wait for the machine message to persist
|
||||
await settleUntil(() => {
|
||||
const t = api.sidecarRouter.load(api.proposalController.keyFor(doc))?.threads.find((x) => x.id === threadId);
|
||||
return (t?.messages.length ?? 0) >= 2 && t!.messages[1].author.kind === "agent";
|
||||
}, 10000);
|
||||
// Finding 2 (final whole-branch review): the machine reply flips the
|
||||
// thread into an "offer" — this used to CLOBBER the "open" status token
|
||||
// instead of folding in alongside it, so Resolve/Reopen disappeared from
|
||||
// the menu the moment any thread got a reply. Assert both tokens survive
|
||||
// together, directly via the rendered vsThread's contextValue (the exact
|
||||
// string package.json's `comments/commentThread/title` `when`-clauses key
|
||||
// on) — no DOM/UI query needed.
|
||||
const docKey = api.proposalController.keyFor(doc);
|
||||
const afterReply = api.threadController.getRendered(docKey).find((t) => t.id === threadId);
|
||||
assert.ok(afterReply, "thread is rendered after the reply lands");
|
||||
assert.match(
|
||||
afterReply!.contextValue,
|
||||
/\bopen\b/,
|
||||
`expected the "open" status token to survive the offer transition, got "${afterReply!.contextValue}"`,
|
||||
);
|
||||
assert.match(
|
||||
afterReply!.contextValue,
|
||||
/\boffer\b/,
|
||||
`expected the "offer" token once the machine reply lands, got "${afterReply!.contextValue}"`,
|
||||
);
|
||||
const ids = await api.threadController.makeThreadEdit(threadId, api.proposalController.keyFor(doc));
|
||||
assert.ok(ids.length >= 1);
|
||||
assert.ok(api.proposalController.listProposals(doc).length >= 1); // pending, INV-5
|
||||
// Spending the offer flips it to "offerdone" — status token still intact,
|
||||
// and the plain "offer" token must not still match (it's a DIFFERENT word,
|
||||
// not just a substring: /\boffer\b/ must not match "offerdone").
|
||||
const afterMakeEdit = api.threadController.getRendered(docKey).find((t) => t.id === threadId);
|
||||
assert.match(afterMakeEdit!.contextValue, /\bopen\b/, "status token still intact after spending the offer");
|
||||
assert.match(afterMakeEdit!.contextValue, /\bofferdone\b/, "offer token flips to offerdone once spent");
|
||||
assert.doesNotMatch(afterMakeEdit!.contextValue, /\boffer\b(?!done)/, "the bare offer token is gone once spent");
|
||||
});
|
||||
|
||||
test("a comment on a NON-coedited doc summons nothing (INV-10)", async () => {
|
||||
const api = await activateApi();
|
||||
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "plain\n" });
|
||||
await vscode.window.showTextDocument(doc);
|
||||
const id = await api.threadController.createThreadOnSelection("hello");
|
||||
assert.strictEqual(id, undefined); // gate refuses thread creation entirely
|
||||
});
|
||||
|
||||
// Finding 1 (code review, session native-surfaces-exec): `cowriting.edit` with NO
|
||||
// selection used to route through `cowriting.editDocument` → the removed
|
||||
// `askEditInstruction` webview prompt — a rejecting stub `void`'d before any
|
||||
// try/catch, i.e. a completely silent dead end (no toast, no thread). It must now
|
||||
// reach ThreadController.askClaude()'s top-anchored whole-document path (D19) —
|
||||
// the same comments-first ask the selection route already used. There is no API to
|
||||
// drive VS Code's native comment-widget submission from a host E2E test (the actual
|
||||
// comment→reply→offer→proposal round trip is exercised above via the
|
||||
// createThreadOnSelection seam), so this asserts the routing itself: the gesture
|
||||
// reaches ThreadController.askClaude (never the old broken stub) and does not throw.
|
||||
test("cowriting.edit with no selection on a coedited doc reaches the top-anchored ask path, not a silent dead end", async () => {
|
||||
const api = await activateApi();
|
||||
const doc = await vscode.workspace.openTextDocument({
|
||||
language: "markdown",
|
||||
content: "# T\n\nA document-level ask with no selection.\n",
|
||||
});
|
||||
const ed = await vscode.window.showTextDocument(doc);
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
// Defensive stubs, mirroring the suite's other test — no real turn should fire
|
||||
// from this gesture alone (no comment text is ever submitted), but guard against
|
||||
// a real @cline/sdk call if the routing regresses.
|
||||
api.threadController.setTurnRunnerForTest(async () => ({ replacement: "stub", model: "stub", sessionId: "s1" }));
|
||||
api.editFlow.setEditTurnForTest(async () => ({ replacement: "stub", model: "stub", sessionId: "s1" }));
|
||||
ed.selection = new vscode.Selection(0, 0, 0, 0); // no selection
|
||||
|
||||
let askClaudeCalls = 0;
|
||||
const origAskClaude = api.threadController.askClaude.bind(api.threadController);
|
||||
api.threadController.askClaude = async () => {
|
||||
askClaudeCalls++;
|
||||
return origAskClaude();
|
||||
};
|
||||
try {
|
||||
await vscode.commands.executeCommand("cowriting.edit");
|
||||
} finally {
|
||||
api.threadController.askClaude = origAskClaude;
|
||||
}
|
||||
assert.strictEqual(
|
||||
askClaudeCalls,
|
||||
1,
|
||||
"cowriting.edit (no selection) reached ThreadController.askClaude — not the removed editDocument prompt stub",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,8 @@ async function open(docRel: string): Promise<vscode.TextDocument> {
|
||||
const uri = vscode.Uri.file(path.join(WS, docRel));
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
// INV-10: thread creation/rendering is gated on coediting (Task 4).
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
return doc;
|
||||
}
|
||||
async function getApi(): Promise<CowritingApi> {
|
||||
@@ -55,6 +57,13 @@ suite("F5 cross-rung round-trip (host E2E)", () => {
|
||||
const DOC = "docs/crossrung.md";
|
||||
const doc = await open(DOC);
|
||||
const api = await getApi();
|
||||
// Task 6 (D10): createThreadOnSelection also fires the respond-in-thread
|
||||
// turn now — stub it off so it can't race the forge stand-in's reply
|
||||
// below (this test asserts an EXACT 2-message sequence: editor note +
|
||||
// forge reply).
|
||||
api.threadController.setTurnRunnerForTest(async () => {
|
||||
throw new Error("crossrung suite: reply-loop turn stubbed off");
|
||||
});
|
||||
const target = "portable record";
|
||||
const start = doc.getText().indexOf(target);
|
||||
const editor = vscode.window.activeTextEditor!;
|
||||
|
||||
@@ -15,6 +15,16 @@ async function openDoc(): Promise<vscode.TextDocument> {
|
||||
await vscode.window.showTextDocument(doc);
|
||||
return doc;
|
||||
}
|
||||
/** Open the doc AND enter coediting (INV-10) — baselines are established only
|
||||
* on entry (Task 2). Idempotent across tests: re-entering an already-coedited
|
||||
* doc is a no-op, so the once-captured baseline survives (order-dependent
|
||||
* suite, same as before). */
|
||||
async function openAndCoedit(): Promise<vscode.TextDocument> {
|
||||
const doc = await openDoc();
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settle();
|
||||
return doc;
|
||||
}
|
||||
async function getApi(): Promise<CowritingApi> {
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||
const api = (await ext.activate()) as CowritingApi;
|
||||
@@ -29,22 +39,29 @@ const settle = () => new Promise((r) => setTimeout(r, 300));
|
||||
// later tests consume earlier state. Owns docs/diffview.md exclusively. The
|
||||
// baseline works on ANY file (#19), so the last two tests use an out-of-workspace
|
||||
// file and an untitled buffer.
|
||||
//
|
||||
// Native-surfaces migration (Task 2, spec §6.4/INV-7): a baseline is now
|
||||
// established only once a document ENTERS coediting (not merely opened), and
|
||||
// the fixture workspace is NOT a git repo, so every doc here resolves to
|
||||
// SNAPSHOT mode. The shipped machine-landing baseline advance (#48/INV-18) is
|
||||
// retired — an accepted proposal now stays a visible change-since-baseline
|
||||
// until "Mark Changes as Reviewed" (D21).
|
||||
suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress, no LLM)", () => {
|
||||
const TARGET = "A target sentence Claude will rewrite via the seam.";
|
||||
const REPLACEMENT = "A SENTENCE CLAUDE REWROTE via the seam.";
|
||||
|
||||
test("opening a tracked doc captures an `opened` baseline equal to the buffer (INV-18)", async () => {
|
||||
const doc = await openDoc();
|
||||
await getApi();
|
||||
test("entering coediting on a snapshot-mode doc captures an `entered` baseline equal to the buffer (INV-7)", async () => {
|
||||
const doc = await openAndCoedit();
|
||||
const api = await getApi();
|
||||
assert.strictEqual(api.diffViewController.modeOf(docUri()), "snapshot", "no git repo → snapshot mode");
|
||||
const baseline = api.diffViewController.getBaseline(docUri());
|
||||
assert.ok(baseline, "baseline captured on first sight");
|
||||
assert.strictEqual(baseline!.reason, "opened");
|
||||
assert.strictEqual(baseline!.text, doc.getText(), "baseline = open-time buffer");
|
||||
assert.ok(baseline, "baseline captured on coediting entry");
|
||||
assert.strictEqual(baseline!.reason, "entered");
|
||||
assert.strictEqual(baseline!.text, doc.getText(), "baseline = entry-time buffer");
|
||||
});
|
||||
|
||||
test("typing leaves the baseline unchanged while the buffer diverges", async () => {
|
||||
const doc = await openDoc();
|
||||
const doc = await openAndCoedit();
|
||||
const api = await getApi();
|
||||
const before = api.diffViewController.getBaseline(docUri())!.text;
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
@@ -55,9 +72,10 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress,
|
||||
assert.notStrictEqual(doc.getText(), before, "buffer diverged");
|
||||
});
|
||||
|
||||
test("accepting a proposal advances the baseline past the landed text (PUC-2, INV-18)", async () => {
|
||||
const doc = await openDoc();
|
||||
test("accepting a proposal does NOT advance the baseline — the landed text stays a change (INV-7/D21, #48 retired)", async () => {
|
||||
const doc = await openAndCoedit();
|
||||
const api = await getApi();
|
||||
const before = api.diffViewController.getBaseline(docUri())!;
|
||||
const start = doc.getText().indexOf(TARGET);
|
||||
assert.ok(start >= 0, "fixture contains the target");
|
||||
const id = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
|
||||
@@ -73,14 +91,14 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress,
|
||||
assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies via the seam");
|
||||
await settle();
|
||||
const baseline = api.diffViewController.getBaseline(docUri())!;
|
||||
assert.strictEqual(baseline.reason, "machine-landing", "baseline advanced on the landing");
|
||||
assert.ok(baseline.text.includes(REPLACEMENT), "landed text is in the baseline (won't show as a change)");
|
||||
assert.ok(!baseline.text.includes(TARGET), "old target gone from the baseline too");
|
||||
assert.strictEqual(baseline.text, doc.getText(), "baseline == buffer right after the landing");
|
||||
assert.strictEqual(baseline.reason, before.reason, "baseline reason untouched by the landing");
|
||||
assert.strictEqual(baseline.text, before.text, "baseline text untouched by the landing");
|
||||
assert.ok(!baseline.text.includes(REPLACEMENT), "landed text is NOT folded into the baseline");
|
||||
assert.notStrictEqual(baseline.text, doc.getText(), "baseline != buffer — the landing reads as a change");
|
||||
});
|
||||
|
||||
test("an operator edit after the landing makes baseline ≠ buffer (the operator delta)", async () => {
|
||||
const doc = await openDoc();
|
||||
test("an operator edit after the landing keeps baseline ≠ buffer (the operator delta)", async () => {
|
||||
const doc = await openAndCoedit();
|
||||
const api = await getApi();
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
edit.insert(doc.uri, new vscode.Position(0, 0), "POST-LANDING OPERATOR LINE\n");
|
||||
@@ -89,14 +107,14 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress,
|
||||
assert.notStrictEqual(
|
||||
api.diffViewController.getBaseline(docUri())!.text,
|
||||
doc.getText(),
|
||||
"operator changes show against the advanced baseline",
|
||||
"operator changes show against the untouched baseline",
|
||||
);
|
||||
});
|
||||
|
||||
test("pin resets the baseline to now: baseline == buffer, reason pinned", async () => {
|
||||
const doc = await openDoc();
|
||||
test("markReviewed resets the baseline to now: baseline == buffer, reason pinned", async () => {
|
||||
const doc = await openAndCoedit();
|
||||
const api = await getApi();
|
||||
await vscode.commands.executeCommand("cowriting.pinDiffBaseline");
|
||||
await vscode.commands.executeCommand("cowriting.markReviewed");
|
||||
await settle();
|
||||
const baseline = api.diffViewController.getBaseline(docUri())!;
|
||||
assert.strictEqual(baseline.reason, "pinned");
|
||||
@@ -104,14 +122,14 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress,
|
||||
});
|
||||
|
||||
test("the baseline is persisted in GLOBAL storage, never the repo (INV-19)", async () => {
|
||||
await openDoc();
|
||||
await openAndCoedit();
|
||||
const api = await getApi();
|
||||
const p = api.diffViewController.baselineFilePath(docUri());
|
||||
assert.ok(p, "storage-backed baseline path is available for a file: doc");
|
||||
assert.ok(fs.existsSync(p!), `baseline file exists at ${p}`);
|
||||
const onDisk = JSON.parse(fs.readFileSync(p!, "utf8"));
|
||||
assert.strictEqual(onDisk.uri, docUri(), "baseline records the document URI");
|
||||
assert.strictEqual(onDisk.reason, "pinned", "last epoch (pin) persisted");
|
||||
assert.strictEqual(onDisk.reason, "pinned", "last epoch (markReviewed) persisted");
|
||||
assert.strictEqual(onDisk.text, api.diffViewController.getBaseline(docUri())!.text, "on-disk == in-memory");
|
||||
// INV-19: baseline lives under the extension's storage dir, not the repo.
|
||||
assert.ok(!p!.includes(`${path.sep}.threads${path.sep}`), "baseline is NOT in the sidecar tree");
|
||||
@@ -127,11 +145,12 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress,
|
||||
const outsideUri = vscode.Uri.file(outsidePath);
|
||||
const doc = await vscode.workspace.openTextDocument(outsideUri);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settle();
|
||||
// Captured on open even though it is NOT under the workspace folder.
|
||||
// Captured on coediting entry even though it is NOT under the workspace folder.
|
||||
const baseline = api.diffViewController.getBaseline(outsideUri.toString());
|
||||
assert.ok(baseline, "baseline captured for an out-of-folder file");
|
||||
assert.strictEqual(baseline!.reason, "opened");
|
||||
assert.strictEqual(baseline!.reason, "entered");
|
||||
const fp = api.diffViewController.baselineFilePath(outsideUri.toString());
|
||||
assert.ok(fp && fs.existsSync(fp), "outside-file baseline persisted in global storage");
|
||||
assert.ok(!fp!.startsWith(WS + path.sep), "not under the workspace folder");
|
||||
@@ -143,6 +162,7 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress,
|
||||
const api = await getApi();
|
||||
const untitled = await vscode.workspace.openTextDocument({ content: "scratch line\n", language: "markdown" });
|
||||
await vscode.window.showTextDocument(untitled);
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settle();
|
||||
const key = untitled.uri.toString();
|
||||
assert.strictEqual(untitled.uri.scheme, "untitled", "it really is an untitled buffer");
|
||||
|
||||
@@ -1,302 +0,0 @@
|
||||
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 { renderPlain } from "../../../src/trackChangesModel";
|
||||
|
||||
const WS = process.env.E2E_WORKSPACE!;
|
||||
const settle = () => new Promise((r) => setTimeout(r, 400));
|
||||
|
||||
async function getApi(): Promise<CowritingApi> {
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||
const api = (await ext.activate()) as CowritingApi;
|
||||
assert.ok(api?.trackChangesPreviewController && api?.proposalController, "exports preview + proposal");
|
||||
return api;
|
||||
}
|
||||
|
||||
/** Create + open a fresh markdown doc under WS, returning the doc + its uri key. */
|
||||
async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> {
|
||||
const abs = path.join(WS, rel);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, body, "utf8");
|
||||
const uri = vscode.Uri.file(abs);
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await settle();
|
||||
return { doc, key: uri.toString() };
|
||||
}
|
||||
|
||||
async function propose(
|
||||
doc: vscode.TextDocument,
|
||||
key: string,
|
||||
target: string,
|
||||
newText: string,
|
||||
turnId: string,
|
||||
): Promise<string> {
|
||||
const start = doc.getText().indexOf(target);
|
||||
assert.ok(start >= 0, `fixture contains "${target}"`);
|
||||
const id = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
|
||||
uri: key,
|
||||
start,
|
||||
end: start + target.length,
|
||||
newText,
|
||||
model: "sonnet",
|
||||
sessionId: "e2e-f10rev",
|
||||
turnId,
|
||||
});
|
||||
assert.ok(id, "propose returns an id");
|
||||
return id!;
|
||||
}
|
||||
|
||||
// F10 host E2E (no LLM): the rendered preview is the single INTERACTIVE review
|
||||
// surface. This suite owns docs/f10review.md (its main flow is order-dependent)
|
||||
// plus its own disjoint fresh docs for the isolated cases (status-bar, toggle).
|
||||
// The editor is zero-decoration; everything observable here is the data layer +
|
||||
// the on-state renderReview HTML the panel posts.
|
||||
suite("F10 interactive review (host E2E — preview is the single review surface, no LLM)", () => {
|
||||
const DOC_REL = "docs/f10review.md";
|
||||
const PROSE = "The original review paragraph that lives in this doc.";
|
||||
const T1 = "A first claude target sentence here.";
|
||||
const T2 = "A second claude target sentence here.";
|
||||
|
||||
test("open on a markdown doc → panel open, fresh baseline all-unchanged, mode is on (PUC-1)", async () => {
|
||||
const { doc, key } = await freshDoc(DOC_REL, `# F10 review\n\n${PROSE}\n\n${T1}\n\n${T2}\n`);
|
||||
const api = await getApi();
|
||||
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), false, "no panel yet");
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), true, "panel open");
|
||||
assert.strictEqual(api.trackChangesPreviewController.getMode(key), "on", "annotations default on (INV-33)");
|
||||
const model = api.trackChangesPreviewController.getLastModel(key);
|
||||
assert.ok(model && model.length > 0, "a model was computed");
|
||||
assert.ok(model!.every((o) => o.kind === "unchanged"), "fresh baseline == buffer → every block unchanged");
|
||||
void doc;
|
||||
});
|
||||
|
||||
test("typing produces an added/changed block and a cw-ins-human span in the on-state render (PUC-2)", async () => {
|
||||
const { key } = await reopen(DOC_REL);
|
||||
const api = await getApi();
|
||||
const doc = byKey(key)!;
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
edit.insert(doc.uri, doc.positionAt(doc.getText().length), "\n\nA freshly typed human paragraph.\n");
|
||||
assert.ok(await vscode.workspace.applyEdit(edit), "operator edit applied");
|
||||
await settle();
|
||||
const kinds = (api.trackChangesPreviewController.getLastModel(key) ?? []).map((o) => o.kind);
|
||||
assert.ok(kinds.some((k) => k === "added" || k === "changed"), "an added/changed block after typing");
|
||||
// Attribution recorded the human span (data layer), and the on-state render
|
||||
// author-colors that prose as cw-ins-human (added blocks use cw-ins-{author} since Task 3/44ef0a2).
|
||||
assert.ok(
|
||||
api.attributionController.spansFor(doc).some((s) => s.author === "human"),
|
||||
"a human span was recorded for the typed text",
|
||||
);
|
||||
const html = api.trackChangesPreviewController.renderHtmlFor(key);
|
||||
assert.ok(html.includes("cw-ins-human"), "typed text is author-colored human in the on-state (cw-ins-human for added blocks)");
|
||||
});
|
||||
|
||||
test("propose → the preview surfaces it as a cw-proposal block with ✓/✗ actions (PUC-3)", async () => {
|
||||
const { doc, key } = await reopen(DOC_REL);
|
||||
const api = await getApi();
|
||||
const id = await propose(doc, key, T1, "A FIRST claude REPLACEMENT sentence.", "turn-f10-1");
|
||||
await settle();
|
||||
const views = api.proposalController.listProposals(doc);
|
||||
assert.ok(views.some((v) => v.id === id), "listProposals returns a view with the id");
|
||||
const html = api.trackChangesPreviewController.renderHtmlFor(key);
|
||||
assert.ok(html.includes(`data-proposal-id="${id}"`), "the preview renders the proposal block by id");
|
||||
assert.match(html, /class="cw-actions"/, "the proposal block carries ✓/✗ actions");
|
||||
// #31: the proposal renders INLINE at its anchor (right after T1's block), not
|
||||
// trailing the whole document — so it appears BEFORE the following T2 block.
|
||||
const pIdx = html.indexOf(`data-proposal-id="${id}"`);
|
||||
const t2Idx = html.indexOf("second claude target");
|
||||
assert.ok(t2Idx >= 0 && pIdx < t2Idx, "the proposal renders in place, before the following block (#31)");
|
||||
// F12 (INV-48): EditorProposalController auto-applies the proposal into the buffer;
|
||||
// the replacement text is now in the document, proposal is still pending.
|
||||
assert.ok(doc.getText().includes("A FIRST claude REPLACEMENT sentence."), "F12 optimistic-apply: replacement in buffer");
|
||||
});
|
||||
|
||||
test("accept → the proposal lands, clears from the preview, and the baseline advances (PUC-4)", async () => {
|
||||
const { doc, key } = await reopen(DOC_REL);
|
||||
const api = await getApi();
|
||||
const id = api.proposalController.listProposals(doc).find((v) => v.replaced === T1)!.id;
|
||||
assert.ok(await api.proposalController.acceptById(DOC_REL, id), "accept applies via the seam");
|
||||
await settle();
|
||||
const replacement = "A FIRST claude REPLACEMENT sentence.";
|
||||
assert.ok(doc.getText().includes(replacement), "replacement landed in the document");
|
||||
assert.ok(!doc.getText().includes(T1), "original target gone");
|
||||
assert.ok(!api.proposalController.listProposals(doc).some((v) => v.id === id), "proposal cleared from listProposals");
|
||||
const html = api.trackChangesPreviewController.renderHtmlFor(key);
|
||||
assert.ok(!html.includes(`data-proposal-id="${id}"`), "the accepted proposal block is gone from the preview");
|
||||
// The baseline advanced on the landing (INV-18): the landed text is not marked.
|
||||
const model = api.trackChangesPreviewController.getLastModel(key) ?? [];
|
||||
const marked = model.some((o) => o.kind !== "unchanged" && o.block.raw.includes("FIRST claude REPLACEMENT"));
|
||||
assert.ok(!marked, "the just-landed Claude text renders unmarked (baseline advanced)");
|
||||
});
|
||||
|
||||
test("reject → the proposal vanishes and the document is reverted (PUC-5)", async () => {
|
||||
const { doc, key } = await reopen(DOC_REL);
|
||||
const api = await getApi();
|
||||
const before = doc.getText();
|
||||
const id2 = await propose(doc, key, T2, "A SECOND would-be replacement.", "turn-f10-2");
|
||||
await settle();
|
||||
assert.ok(api.proposalController.listProposals(doc).some((v) => v.id === id2), "second proposal pending");
|
||||
// F12: use rejectByIdInPlace to revert the optimistically-applied buffer edit.
|
||||
assert.ok(await api.proposalController.rejectByIdInPlace(DOC_REL, id2), "rejectByIdInPlace reverts + removes");
|
||||
await settle();
|
||||
assert.ok(!api.proposalController.listProposals(doc).some((v) => v.id === id2), "rejected proposal gone");
|
||||
assert.strictEqual(doc.getText(), before, "document reverted to pre-propose state");
|
||||
const html = api.trackChangesPreviewController.renderHtmlFor(key);
|
||||
assert.ok(!html.includes(`data-proposal-id="${id2}"`), "no rejected block in the preview");
|
||||
});
|
||||
|
||||
test("toggle annotations off → mode round-trips and the off-state render is plain (no cw- marks) (INV-33)", async () => {
|
||||
// A fresh doc with a pending proposal so the on-state DOES carry a cw- mark,
|
||||
// making the off-state's absence of marks meaningful (not tautological).
|
||||
const { doc, key } = await freshDoc(
|
||||
"docs/f10toggle.md",
|
||||
"# F10 toggle\n\nA toggle target sentence to propose over.\n",
|
||||
);
|
||||
const api = await getApi();
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
const id = await propose(doc, key, "A toggle target sentence to propose over.", "A TOGGLED replacement.", "turn-tog");
|
||||
await settle();
|
||||
|
||||
// on-state: the proposal block + its actions are present.
|
||||
assert.strictEqual(api.trackChangesPreviewController.getMode(key), "on", "starts on");
|
||||
const onHtml = api.trackChangesPreviewController.renderHtmlFor(key);
|
||||
assert.ok(onHtml.includes(`data-proposal-id="${id}"`), "on-state shows the proposal block");
|
||||
|
||||
// toggle off → mode round-trips; the off-state body is plain markdown.
|
||||
api.trackChangesPreviewController.setMode(key, "off");
|
||||
await settle();
|
||||
assert.strictEqual(api.trackChangesPreviewController.getMode(key), "off", "mode flipped to off");
|
||||
assert.ok(api.trackChangesPreviewController.isOpen(key), "panel stays open across the toggle");
|
||||
// renderHtmlFor is the on-state seam; the off-state body is renderPlain(current)
|
||||
// (INV-33). Assert the actual off-state body the controller posts has no cw-
|
||||
// author/proposal marks — meaningful because the on-state above DID carry one.
|
||||
const offBody = renderPlain(doc.getText());
|
||||
assert.ok(!/cw-proposal|cw-by-|cw-ins-|cw-del/.test(offBody), "off-state render carries no cw- marks");
|
||||
|
||||
// toggle back on → marks return.
|
||||
api.trackChangesPreviewController.setMode(key, "on");
|
||||
await settle();
|
||||
assert.strictEqual(api.trackChangesPreviewController.getMode(key), "on", "mode flipped back on");
|
||||
assert.ok(
|
||||
api.trackChangesPreviewController.renderHtmlFor(key).includes(`data-proposal-id="${id}"`),
|
||||
"on-state shows the proposal block again",
|
||||
);
|
||||
});
|
||||
|
||||
test("status-bar (PUC-6): a pending proposal with NO panel shows the indicator; opening the preview hides it", async () => {
|
||||
// Isolated fresh doc: no preview opened, so the off-panel indicator is live.
|
||||
const { doc, key } = await freshDoc("docs/f10status.md", "# F10 status\n\nA status target sentence here.\n");
|
||||
const api = await getApi();
|
||||
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), false, "no panel for this doc");
|
||||
await propose(doc, key, "A status target sentence here.", "A STATUS replacement.", "turn-stat");
|
||||
await settle();
|
||||
const text = api.trackChangesPreviewController.statusText();
|
||||
assert.ok(text && text.length > 0, "the off-panel indicator shows a non-empty status");
|
||||
assert.match(text!, /1 Claude proposal/, "it mentions the pending count");
|
||||
|
||||
// open the preview for this doc → the off-panel indicator hides (undefined).
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), true, "panel open");
|
||||
assert.strictEqual(api.trackChangesPreviewController.statusText(), undefined, "indicator hidden once the panel is open");
|
||||
});
|
||||
|
||||
test("clean editor: data layers intact AND the retired in-editor surfaces are gone (INV-32)", async () => {
|
||||
const { doc, key } = await freshDoc("docs/f10clean.md", "# F10 clean\n\nA clean target sentence here.\n");
|
||||
const api = await getApi();
|
||||
const id = await propose(doc, key, "A clean target sentence here.", "A CLAUDE clean replacement.", "turn-clean");
|
||||
await settle();
|
||||
assert.ok(await api.proposalController.acceptById("docs/f10clean.md", id), "accept lands the Claude edit");
|
||||
await settle();
|
||||
// data layer intact: a Claude (agent) attribution span exists.
|
||||
assert.ok(
|
||||
api.attributionController.getSpans("docs/f10clean.md").some((s) => s.authorKind === "agent"),
|
||||
"agent span recorded (attribution data layer intact)",
|
||||
);
|
||||
// the retired in-editor surfaces are gone from the palette (no editor decorations — INV-32).
|
||||
const all = await vscode.commands.getCommands(true);
|
||||
assert.ok(!all.includes("cowriting.toggleAttribution"), "cowriting.toggleAttribution is retired");
|
||||
// #34: the F6 two-pane diff VIEW was removed (F10 preview is the single review
|
||||
// surface) — its toggle command + ctrl+alt+d keybinding are gone entirely.
|
||||
assert.ok(!all.includes("cowriting.toggleDiffView"), "cowriting.toggleDiffView command is gone (#34)");
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8"));
|
||||
const dKb = (pkg.contributes.keybindings as Array<{ command: string; key: string; when?: string }>).find(
|
||||
(k) => k.command === "cowriting.toggleDiffView",
|
||||
);
|
||||
assert.ok(!dKb, "the toggleDiffView keybinding is gone from package.json (#34)");
|
||||
// …but the F6 baseline data layer survives: pinDiffBaseline stays a real command.
|
||||
assert.ok(all.includes("cowriting.pinDiffBaseline"), "pinDiffBaseline (baseline data layer) is kept");
|
||||
void key;
|
||||
});
|
||||
|
||||
test("author-colored track changes: cw-ins-human + cw-ins-claude appear; unchanged blocks are plain (Tasks 1-4)", async () => {
|
||||
// Fresh doc with three blocks: one that stays unchanged, one for Claude to target, one for human typing.
|
||||
const { doc, key } = await freshDoc(
|
||||
"docs/f10authorcolors.md",
|
||||
"# Author Colors\n\nUnchanged paragraph that stays.\n\nClaude target block.\n",
|
||||
);
|
||||
const api = await getApi();
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
|
||||
// Human types a new paragraph → an "added" block relative to the opened baseline.
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
edit.insert(doc.uri, doc.positionAt(doc.getText().length), "\n\nHuman typed paragraph.\n");
|
||||
assert.ok(await vscode.workspace.applyEdit(edit), "human edit applied");
|
||||
await settle();
|
||||
|
||||
// Propose a Claude change (stays PENDING — F12 optimistic-apply; NOT accepted).
|
||||
// The proposal block renders with cw-ins-claude.
|
||||
const target = "Claude target block.";
|
||||
const targetStart = doc.getText().indexOf(target);
|
||||
assert.ok(targetStart >= 0, "target text present after human edit");
|
||||
await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
|
||||
uri: key,
|
||||
start: targetStart,
|
||||
end: targetStart + target.length,
|
||||
newText: "Claude replacement block.",
|
||||
model: "sonnet",
|
||||
sessionId: "e2e-f10-colors",
|
||||
turnId: "turn-colors-1",
|
||||
});
|
||||
await settle();
|
||||
|
||||
const html = api.trackChangesPreviewController.renderHtmlFor(key);
|
||||
|
||||
// The human-typed added block renders with cw-ins-human.
|
||||
assert.ok(html.includes("cw-ins-human"), "human added block is colored cw-ins-human");
|
||||
|
||||
// The pending Claude proposal block renders with cw-ins-claude.
|
||||
assert.ok(html.includes("cw-ins-claude"), "pending Claude proposal block carries cw-ins-claude");
|
||||
|
||||
// The unchanged block renders as cw-unchanged with no cw-ins-/cw-by- inside it
|
||||
// (color = "changed since baseline"; unchanged text is never annotated).
|
||||
const unchangedBlocks = [...html.matchAll(/<div class="cw-blk cw-unchanged"[^>]*>([\s\S]*?)<\/div>/g)];
|
||||
assert.ok(unchangedBlocks.length > 0, "at least one unchanged block exists");
|
||||
assert.ok(
|
||||
unchangedBlocks.some((m) => m[1].includes("Unchanged paragraph that stays")),
|
||||
"the unchanged paragraph is in a cw-unchanged block",
|
||||
);
|
||||
assert.ok(
|
||||
unchangedBlocks.every((m) => !/cw-ins-|cw-by-/.test(m[1])),
|
||||
"unchanged blocks carry no cw-ins- or cw-by- classes",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- helpers reused across the order-dependent main-flow tests ----
|
||||
|
||||
function byKey(key: string): vscode.TextDocument | undefined {
|
||||
return vscode.workspace.textDocuments.find((d) => d.uri.toString() === key);
|
||||
}
|
||||
async function reopen(rel: string): Promise<{ doc: vscode.TextDocument; key: string }> {
|
||||
const uri = vscode.Uri.file(path.join(WS, rel));
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await settle();
|
||||
return { doc, key: uri.toString() };
|
||||
}
|
||||
@@ -10,11 +10,13 @@ const settle = () => new Promise((r) => setTimeout(r, 400));
|
||||
async function getApi(): Promise<CowritingApi> {
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||
const api = (await ext.activate()) as CowritingApi;
|
||||
assert.ok(api?.trackChangesPreviewController && api?.diffViewController, "exports preview + diffView");
|
||||
assert.ok(api?.editFlow && api?.diffViewController, "exports editFlow + diffView");
|
||||
return api;
|
||||
}
|
||||
|
||||
/** Create + open a fresh markdown doc under WS, returning the doc + its uri key. */
|
||||
/** Create + open a fresh markdown doc under WS, returning the doc + its uri key.
|
||||
* Also enters coediting (INV-10/Task 2): the baseline is established only on
|
||||
* entry, not merely on open. */
|
||||
async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> {
|
||||
const abs = path.join(WS, rel);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
@@ -22,47 +24,28 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo
|
||||
const uri = vscode.Uri.file(abs);
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settle();
|
||||
return { doc, key: uri.toString() };
|
||||
}
|
||||
|
||||
// F11 host E2E (no LLM): the preview toolbar is the primary interaction surface.
|
||||
// The webview posts intent messages; the host routes them through the existing
|
||||
// F4/F6/F3 seams (INV-35). The webview DOM (real button clicks) is sealed and
|
||||
// manual-smoke only; here we simulate the inbound messages via `receiveMessage`.
|
||||
suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () => {
|
||||
// SLICE-1: the Pin baseline button.
|
||||
test("pinBaseline message pins the PREVIEWED doc → marks clear, baseline reason is pinned (PUC-5, INV-35)", async () => {
|
||||
const { doc, key } = await freshDoc("docs/f11pin.md", "# F11 pin\n\nA baseline paragraph that will diverge.\n");
|
||||
const api = await getApi();
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), true, "panel open");
|
||||
|
||||
// Diverge from the opened baseline so the preview carries a real change-mark.
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
edit.insert(doc.uri, doc.positionAt(doc.getText().length), "\n\nA freshly typed paragraph that diverges.\n");
|
||||
assert.ok(await vscode.workspace.applyEdit(edit), "operator edit applied");
|
||||
await settle();
|
||||
const marked = (api.trackChangesPreviewController.getLastModel(key) ?? []).some((o) => o.kind !== "unchanged");
|
||||
assert.ok(marked, "the typed paragraph shows as a change before pinning");
|
||||
|
||||
// Simulate the webview's Pin baseline button posting its intent.
|
||||
api.trackChangesPreviewController.receiveMessage(key, { type: "pinBaseline" });
|
||||
await settle();
|
||||
|
||||
const model = api.trackChangesPreviewController.getLastModel(key) ?? [];
|
||||
assert.ok(model.length > 0 && model.every((o) => o.kind === "unchanged"), "after pin, every block is unchanged");
|
||||
assert.strictEqual(api.diffViewController.getBaseline(key)?.reason, "pinned", "baseline reason advanced to pinned");
|
||||
});
|
||||
|
||||
// F11/F12 host E2E (no LLM): EditFlow.runEditAndPropose is the turn→proposal(s)
|
||||
// cut shared by every Ask-Claude entry point (comments-first ask, editDocument,
|
||||
// editSelection). Originally exercised via the now-sunset review webview's
|
||||
// toolbar (Task 8 deleted that webview and its message-routing seam,
|
||||
// `receiveMessage`/`pinBaseline` webview-message coverage duplicates
|
||||
// diffView.test.ts / baselineRouter.test.ts's `markReviewed` tests) — these
|
||||
// tests drive `runEditAndPropose` directly, the same programmatic seam the
|
||||
// deleted toolbar itself called into.
|
||||
suite("F11/F12 — EditFlow document/selection edit turns (host E2E, no LLM)", () => {
|
||||
// SLICE-1 reachability: the orphaned pin command gets a real palette `when`.
|
||||
test("pinDiffBaseline is reachable from the command palette (when: editorLangId == markdown)", async () => {
|
||||
// Renamed cowriting.pinDiffBaseline → cowriting.markReviewed (Task 2, D14).
|
||||
test("markReviewed is reachable from the command palette (when: editorLangId == markdown)", async () => {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8"));
|
||||
const entry = (pkg.contributes.menus.commandPalette as Array<{ command: string; when?: string }>).find(
|
||||
(m) => m.command === "cowriting.pinDiffBaseline",
|
||||
(m) => m.command === "cowriting.markReviewed",
|
||||
);
|
||||
assert.ok(entry, "pinDiffBaseline has a commandPalette entry");
|
||||
assert.ok(entry, "markReviewed has a commandPalette entry");
|
||||
assert.notStrictEqual(entry!.when, "false", "it is no longer hidden (when:false)");
|
||||
assert.match(entry!.when ?? "", /editorLangId == markdown/, "guarded on markdown");
|
||||
});
|
||||
@@ -76,18 +59,16 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
|
||||
"# F11 doc\n\nThe quick brown fox jumps over the lazy dog.\n",
|
||||
);
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
const editFlow = api.editFlow;
|
||||
|
||||
// Stub the host edit turn (no LLM in CI): rewrite two distinct words.
|
||||
ctl.setEditTurnForTest(async () => ({
|
||||
editFlow.setEditTurnForTest(async () => ({
|
||||
replacement: "# F11 doc\n\nThe quick RED fox jumps over the lazy CAT.\n",
|
||||
model: "sonnet",
|
||||
sessionId: "e2e-f11-doc",
|
||||
}));
|
||||
|
||||
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "swap brown→RED and dog→CAT");
|
||||
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "swap brown→RED and dog→CAT");
|
||||
await settle();
|
||||
assert.strictEqual(ids.length, 1, "two changed words in one block → ONE block proposal (INV-39)");
|
||||
|
||||
@@ -99,8 +80,9 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
|
||||
"The quick RED fox jumps over the lazy CAT.",
|
||||
"the block proposal carries the whole rewritten paragraph",
|
||||
);
|
||||
// F12 (INV-48): EditorProposalController optimistically applies the proposed text,
|
||||
// so after settle the buffer has the replacement. The proposal is still pending.
|
||||
// F12 (INV-48): EditorProposalController optimistically applies the proposed
|
||||
// text, so after settle the buffer has the replacement. The proposal is still
|
||||
// pending (not finalized) until Accept.
|
||||
assert.ok(doc.getText().includes("RED fox") && doc.getText().includes("lazy CAT"), "F12 optimistic-apply: proposed text in buffer");
|
||||
void key;
|
||||
});
|
||||
@@ -110,19 +92,17 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
|
||||
const body = "# F11 sel\n\nThe target paragraph Claude will rewrite.\n\nAnother untouched paragraph.\n";
|
||||
const { doc, key } = await freshDoc("docs/f11sel.md", body);
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
const editFlow = api.editFlow;
|
||||
|
||||
const target = "The target paragraph Claude will rewrite.";
|
||||
const start = doc.getText().indexOf(target);
|
||||
const end = start + target.length;
|
||||
ctl.setEditTurnForTest(async (_instruction, text) => {
|
||||
editFlow.setEditTurnForTest(async (_instruction, text) => {
|
||||
assert.strictEqual(text, target, "the turn receives exactly the selected source range");
|
||||
return { replacement: "The REWRITTEN paragraph from Claude.", model: "sonnet", sessionId: "e2e-f11-sel" };
|
||||
});
|
||||
|
||||
const ids = await ctl.runEditAndPropose(doc, { kind: "range", start, end }, "rewrite this paragraph");
|
||||
const ids = await editFlow.runEditAndPropose(doc, { kind: "range", start, end }, "rewrite this paragraph");
|
||||
await settle();
|
||||
assert.strictEqual(ids.length, 1, "a selection yields exactly one proposal");
|
||||
const views = api.proposalController.listProposals(doc);
|
||||
@@ -140,13 +120,11 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
|
||||
test("runEditAndPropose(range) where Claude returns the selection unchanged → no proposal", async () => {
|
||||
const { doc } = await freshDoc("docs/f11noop.md", "# noop\n\nLeave me exactly as I am.\n");
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
const editFlow = api.editFlow;
|
||||
const target = "Leave me exactly as I am.";
|
||||
const start = doc.getText().indexOf(target);
|
||||
ctl.setEditTurnForTest(async (_i, text) => ({ replacement: text, model: "sonnet", sessionId: "e2e-noop" }));
|
||||
const ids = await ctl.runEditAndPropose(doc, { kind: "range", start, end: start + target.length }, "no change");
|
||||
editFlow.setEditTurnForTest(async (_i, text) => ({ replacement: text, model: "sonnet", sessionId: "e2e-noop" }));
|
||||
const ids = await editFlow.runEditAndPropose(doc, { kind: "range", start, end: start + target.length }, "no change");
|
||||
assert.strictEqual(ids.length, 0, "an unchanged replacement proposes nothing");
|
||||
});
|
||||
|
||||
@@ -158,12 +136,10 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
|
||||
const rewrite = "# F11 accept\n\nThe brown fox QUIETLY sleeps today.\n";
|
||||
const { doc } = await freshDoc("docs/f11accept.md", original);
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
const editFlow = api.editFlow;
|
||||
|
||||
ctl.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-accept" }));
|
||||
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "expand the sentence");
|
||||
editFlow.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-accept" }));
|
||||
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "expand the sentence");
|
||||
await settle();
|
||||
assert.ok(ids.length >= 1, "the rewrite produced at least one proposal");
|
||||
|
||||
@@ -194,52 +170,4 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
|
||||
assert.ok(editEntry, "cowriting.edit has a commandPalette entry");
|
||||
assert.match(editEntry!.when ?? "", /editorLangId == markdown/, "guarded on markdown");
|
||||
});
|
||||
|
||||
// SLICE-5: the minimal right-click gateway lives in editor/title (markdown only).
|
||||
test("the editor/title gateway opens the preview, and the menu entry is markdown-guarded (PUC-6)", async () => {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8"));
|
||||
const entry = (pkg.contributes.menus["editor/title"] as Array<{ command: string; when?: string }>).find(
|
||||
(m) => m.command === "cowriting.showTrackChangesPreview",
|
||||
);
|
||||
assert.ok(entry, "showTrackChangesPreview is in editor/title");
|
||||
assert.match(entry!.when ?? "", /editorLangId == markdown/, "gateway guarded on markdown");
|
||||
// the command it invokes opens the panel.
|
||||
const { key } = await freshDoc("docs/f11gw.md", "# F11 gateway\n\nReachable end to end.\n");
|
||||
const api = await getApi();
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), true, "gateway command opens the preview");
|
||||
});
|
||||
|
||||
// SLICE-5: edit controls are inert on a non-authorable (read-only scheme) doc.
|
||||
test("toolbar edit controls are disabled for a non-authorable document (PUC-1/7)", async () => {
|
||||
const SCHEME = "cwf11ro";
|
||||
const provider = new (class implements vscode.TextDocumentContentProvider {
|
||||
onDidChange = undefined;
|
||||
provideTextDocumentContent(): string {
|
||||
return "# Read only\n\nThis markdown doc is not authorable.\n";
|
||||
}
|
||||
})();
|
||||
const reg = vscode.workspace.registerTextDocumentContentProvider(SCHEME, provider);
|
||||
try {
|
||||
const uri = vscode.Uri.parse(`${SCHEME}:/readonly.md`);
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.languages.setTextDocumentLanguage(doc, "markdown");
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await settle();
|
||||
const api = await getApi();
|
||||
const key = uri.toString();
|
||||
assert.strictEqual(doc.languageId, "markdown", "fixture is markdown");
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), true, "preview opens (reading is always allowed)");
|
||||
assert.strictEqual(
|
||||
api.trackChangesPreviewController.editControlsEnabled(key),
|
||||
false,
|
||||
"Pin + Ask-Claude controls are disabled on a non-authorable doc",
|
||||
);
|
||||
} finally {
|
||||
reg.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ const settle = () => new Promise((r) => setTimeout(r, 400));
|
||||
async function getApi(): Promise<CowritingApi> {
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||
const api = (await ext.activate()) as CowritingApi;
|
||||
assert.ok(api?.trackChangesPreviewController && api?.proposalController, "exports controllers");
|
||||
assert.ok(api?.editFlow && api?.proposalController, "exports controllers");
|
||||
return api;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo
|
||||
const uri = vscode.Uri.file(abs);
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
// INV-10: proposal rendering (F12 optimistic-apply, driven by
|
||||
// onDidChangeProposals) is gated on coediting (Task 4).
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settle();
|
||||
return { doc, key: uri.toString() };
|
||||
}
|
||||
@@ -33,19 +36,18 @@ suite("F12 SLICE-3 — accept-all (#46, INV-42)", () => {
|
||||
test("acceptAll applies every pending proposal and reconstructs the document", async () => {
|
||||
const original = "# All\n\nFirst para alpha.\n\nSecond para beta.\n\nThird para gamma.\n";
|
||||
const rewrite = "# All\n\nFirst para ALPHA.\n\nSecond para BETA.\n\nThird para GAMMA.\n";
|
||||
const { doc, key } = await freshDoc("docs/f12-all.md", original);
|
||||
const { doc } = await freshDoc("docs/f12-all.md", original);
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
const editFlow = api.editFlow;
|
||||
|
||||
ctl.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-all" }));
|
||||
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "uppercase the nouns");
|
||||
editFlow.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-all" }));
|
||||
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase the nouns");
|
||||
await settle();
|
||||
assert.strictEqual(ids.length, 3, "three changed blocks → three pending proposals");
|
||||
|
||||
// Simulate the toolbar "Accept all" button posting its intent.
|
||||
ctl.receiveMessage(key, { type: "acceptAll" });
|
||||
// Route through the real "Keep all" command (Task 8, PUC-2) — the doc is the
|
||||
// active editor from freshDoc, matching the top-of-file CodeLens's own target.
|
||||
await vscode.commands.executeCommand("cowriting.acceptAllProposals");
|
||||
await settle();
|
||||
await settle();
|
||||
|
||||
@@ -60,12 +62,10 @@ suite("F12 SLICE-3 — accept-all (#46, INV-42)", () => {
|
||||
const rewrite = "# Mix\n\nKeep ALPHA here.\n\nKeep GAMMA here.\n";
|
||||
const { doc } = await freshDoc("docs/f12-orphan.md", original);
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
const editFlow = api.editFlow;
|
||||
|
||||
ctl.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-orphan" }));
|
||||
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "uppercase");
|
||||
editFlow.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-orphan" }));
|
||||
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase");
|
||||
await settle();
|
||||
assert.strictEqual(ids.length, 2, "two pending proposals");
|
||||
|
||||
@@ -92,20 +92,19 @@ suite("F12 SLICE-3 — accept-all (#46, INV-42)", () => {
|
||||
assert.strictEqual(api.proposalController.listProposals(doc).length, 1, "the orphaned proposal remains pending");
|
||||
});
|
||||
|
||||
// A single pending proposal still applies through the batch path (the button is
|
||||
// hidden < 2 pending in the webview, but the command/seam handle any count).
|
||||
// A single pending proposal still applies through the batch path (the
|
||||
// top-of-file "Keep all" CodeLens is hidden < 2 pending, Task 8 PUC-2, but the
|
||||
// command/seam handle any count).
|
||||
test("acceptAllProposals with one pending proposal applies it", async () => {
|
||||
const { doc } = await freshDoc("docs/f12-one.md", "# One\n\nThe only paragraph here.\n");
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
ctl.setEditTurnForTest(async () => ({
|
||||
const editFlow = api.editFlow;
|
||||
editFlow.setEditTurnForTest(async () => ({
|
||||
replacement: "# One\n\nThe ONLY paragraph here.\n",
|
||||
model: "sonnet",
|
||||
sessionId: "e2e-f12-one",
|
||||
}));
|
||||
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "uppercase only");
|
||||
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase only");
|
||||
await settle();
|
||||
assert.strictEqual(ids.length, 1, "one pending proposal");
|
||||
const { applied, skipped } = await api.proposalController.acceptAllProposals(doc);
|
||||
|
||||
@@ -12,10 +12,12 @@ const settle = () => new Promise((r) => setTimeout(r, 400));
|
||||
async function getApi(): Promise<CowritingApi> {
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||
const api = (await ext.activate()) as CowritingApi;
|
||||
assert.ok(api?.trackChangesPreviewController && api?.proposalController, "exports controllers");
|
||||
assert.ok(api?.diffViewController && api?.proposalController, "exports controllers");
|
||||
return api;
|
||||
}
|
||||
|
||||
/** Also enters coediting (INV-10/Task 2): the baseline is established only on
|
||||
* entry, not merely on open. */
|
||||
async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> {
|
||||
const abs = path.join(WS, rel);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
@@ -23,6 +25,7 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo
|
||||
const uri = vscode.Uri.file(abs);
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settle();
|
||||
return { doc, key: uri.toString() };
|
||||
}
|
||||
@@ -31,8 +34,6 @@ suite("F12 inline diff — seam landBaseline (#64, INV-48)", () => {
|
||||
test("applyAgentEdit with landBaseline:false applies text but does NOT advance the baseline", async () => {
|
||||
const { doc, key } = await freshDoc("docs/f12-land.md", "# T\n\nalpha here.\n");
|
||||
const api = await getApi();
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
const before = api.diffViewController.getBaseline(key)?.text ?? doc.getText();
|
||||
const start = doc.getText().indexOf("alpha");
|
||||
const ok = await api.attributionController.applyAgentEdit(
|
||||
@@ -103,10 +104,10 @@ suite("F12 inline diff — finalize / revert in place (#64, INV-51)", () => {
|
||||
test("rejectAll reverts every pending proposal", async () => {
|
||||
const { doc } = await freshDoc("docs/f12-rejall.md", "# T\n\nOne aaa.\n\nTwo bbb.\n");
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
const editFlow = api.editFlow;
|
||||
const p = api.proposalController;
|
||||
ctl.setEditTurnForTest(async () => ({ replacement: "# T\n\nOne AAA.\n\nTwo BBB.\n", model: "m", sessionId: "s" }));
|
||||
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "up");
|
||||
editFlow.setEditTurnForTest(async () => ({ replacement: "# T\n\nOne AAA.\n\nTwo BBB.\n", model: "m", sessionId: "s" }));
|
||||
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "up");
|
||||
await settle();
|
||||
for (const id of ids) await p.optimisticApply(doc, id);
|
||||
await settle();
|
||||
@@ -123,9 +124,9 @@ suite("F12 inline diff — INV-50 listProposals.replaced", () => {
|
||||
test("listProposals reports the original as `replaced` after optimistic apply", async () => {
|
||||
const { doc } = await freshDoc("docs/f12-replaced.md", "# R\n\nbrown here.\n");
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
ctl.setEditTurnForTest(async () => ({ replacement: "# R\n\nred here.\n", model: "m", sessionId: "s" }));
|
||||
await ctl.runEditAndPropose(doc, { kind: "document" }, "x");
|
||||
const editFlow = api.editFlow;
|
||||
editFlow.setEditTurnForTest(async () => ({ replacement: "# R\n\nred here.\n", model: "m", sessionId: "s" }));
|
||||
await editFlow.runEditAndPropose(doc, { kind: "document" }, "x");
|
||||
await settle(); await settle();
|
||||
assert.strictEqual(api.proposalController.listProposals(doc)[0].replaced, "brown here.");
|
||||
});
|
||||
@@ -135,9 +136,9 @@ suite("F12 inline diff — editor surface (#64, INV-48/52)", () => {
|
||||
test("proposing optimistically applies into the editor and the buffer is the accepted result", async () => {
|
||||
const { doc } = await freshDoc("docs/f12-editor.md", "# E\n\nThe brown fox runs.\n");
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
ctl.setEditTurnForTest(async () => ({ replacement: "# E\n\nThe red fox runs.\n", model: "m", sessionId: "s" }));
|
||||
await ctl.runEditAndPropose(doc, { kind: "document" }, "recolor the fox");
|
||||
const editFlow = api.editFlow;
|
||||
editFlow.setEditTurnForTest(async () => ({ replacement: "# E\n\nThe red fox runs.\n", model: "m", sessionId: "s" }));
|
||||
await editFlow.runEditAndPropose(doc, { kind: "document" }, "recolor the fox");
|
||||
await settle(); await settle();
|
||||
assert.ok(doc.getText().includes("The red fox runs."), "optimistically applied into the buffer");
|
||||
const v = api.proposalController.listProposals(doc)[0];
|
||||
@@ -147,9 +148,9 @@ suite("F12 inline diff — editor surface (#64, INV-48/52)", () => {
|
||||
test("editing the inserted text then finalizing keeps the human edit", async () => {
|
||||
const { doc } = await freshDoc("docs/f12-edit-keep.md", "# E\n\nalpha word here.\n");
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
ctl.setEditTurnForTest(async () => ({ replacement: "# E\n\nALPHA word here.\n", model: "m", sessionId: "s" }));
|
||||
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "up");
|
||||
const editFlow = api.editFlow;
|
||||
editFlow.setEditTurnForTest(async () => ({ replacement: "# E\n\nALPHA word here.\n", model: "m", sessionId: "s" }));
|
||||
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "up");
|
||||
await settle(); await settle();
|
||||
// human tweaks the inserted text
|
||||
const at = doc.getText().indexOf("ALPHA");
|
||||
@@ -168,22 +169,25 @@ suite("F12 inline diff — editor surface (#64, INV-48/52)", () => {
|
||||
});
|
||||
|
||||
suite("F12 inline diff — control parity (#64, INV-53)", () => {
|
||||
test("reject from the webview reverts in place; rejectAll clears every proposal", async () => {
|
||||
const { doc, key } = await freshDoc("docs/f12-parity.md", "# P\n\nuno aaa.\n\ndos bbb.\n");
|
||||
test("reject-one via revertInPlace reverts in place; rejectAll clears every proposal", async () => {
|
||||
const { doc } = await freshDoc("docs/f12-parity.md", "# P\n\nuno aaa.\n\ndos bbb.\n");
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
ctl.setEditTurnForTest(async () => ({ replacement: "# P\n\nuno AAA.\n\ndos BBB.\n", model: "m", sessionId: "s" }));
|
||||
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "up");
|
||||
const p = api.proposalController;
|
||||
const editFlow = api.editFlow;
|
||||
editFlow.setEditTurnForTest(async () => ({ replacement: "# P\n\nuno AAA.\n\ndos BBB.\n", model: "m", sessionId: "s" }));
|
||||
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "up");
|
||||
await settle(); await settle();
|
||||
assert.ok(doc.getText().includes("AAA") && doc.getText().includes("BBB"));
|
||||
// reject ONE via the webview intent → that block reverts, the other stays applied
|
||||
ctl.receiveMessage(key, { type: "reject", proposalId: ids[0] });
|
||||
// reject ONE via the same seam the per-proposal "✗ Reject" CodeLens uses
|
||||
// (Task 8, PUC-2) → that block reverts, the other stays applied.
|
||||
const docKey = p.keyFor(doc);
|
||||
assert.ok(await p.revertInPlace(docKey, ids[0]), "revert one proposal");
|
||||
await settle(); await settle();
|
||||
assert.ok(doc.getText().includes("uno aaa.") && doc.getText().includes("BBB"), "one reverted, one applied");
|
||||
// rejectAll via the command → all gone, document restored
|
||||
ctl.receiveMessage(key, { type: "rejectAll" });
|
||||
// rejectAll via the same seam the top-of-file "✗ Reject all" CodeLens uses →
|
||||
// all gone, document restored.
|
||||
const { reverted } = await p.rejectAll(doc);
|
||||
assert.strictEqual(reverted, 1, "the one remaining proposal reverted");
|
||||
await settle(); await settle();
|
||||
assert.strictEqual(doc.getText(), "# P\n\nuno aaa.\n\ndos bbb.\n", "document restored");
|
||||
assert.strictEqual(api.proposalController.listProposals(doc).length, 0);
|
||||
|
||||
@@ -10,7 +10,7 @@ const settle = () => new Promise((r) => setTimeout(r, 400));
|
||||
async function getApi(): Promise<CowritingApi> {
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||
const api = (await ext.activate()) as CowritingApi;
|
||||
assert.ok(api?.trackChangesPreviewController, "exports preview controller");
|
||||
assert.ok(api?.threadController, "exports thread controller");
|
||||
return api;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,12 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo
|
||||
fs.writeFileSync(abs, body, "utf8");
|
||||
const uri = vscode.Uri.file(abs);
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
// INV-10: cowriting.editDocument now warns instead of editing a non-entered
|
||||
// doc (Task 4) — enter it here (briefly making it active) so the tests below
|
||||
// exercise the routing/targeting behavior, not the gate. The caller
|
||||
// re-establishes whichever doc it wants active afterward.
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
return { doc, key: uri.toString() };
|
||||
}
|
||||
|
||||
@@ -66,9 +72,17 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
|
||||
|
||||
// PUC-3 behavior: editDocument invoked with a tab URI targets THAT document,
|
||||
// not whatever editor happens to be active (mirrors #41's clicked-doc resolution).
|
||||
// Finding 1 fix (native-surfaces code review): editDocument no longer prompts via
|
||||
// the removed askEditInstruction webview stub (that path threw a silent, `void`'d,
|
||||
// unhandled rejection — no toast, no thread, no proposal). It now resolves/focuses
|
||||
// its target document and hands off to ThreadController.askClaude() (D19), same as
|
||||
// editSelection. The turn→proposal cut itself (EditFlow.runEditAndPropose against a
|
||||
// {kind:"document"} target) is exercised directly, without the command, in
|
||||
// f11Toolbar.test.ts / f12Accept.test.ts / f12Review.test.ts — this test's job is
|
||||
// the URI-targeting/focus behavior, plus confirming the routing actually reaches
|
||||
// askClaude (proving Finding 1's dead end is gone).
|
||||
test("editDocument(uri) targets the clicked tab's document, not the active editor", async () => {
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
|
||||
// Doc A is the active editor; Doc B is the "clicked tab" we pass by URI.
|
||||
const a = await freshDoc("docs/f12-active.md", "# Active\n\nThe active editor paragraph.\n");
|
||||
@@ -76,55 +90,68 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
|
||||
await vscode.window.showTextDocument(a.doc);
|
||||
await settle();
|
||||
|
||||
// Stub the document instruction prompt (the webview can't run in CI) + the LLM turn.
|
||||
const origPrompt = ctl.askEditInstruction;
|
||||
ctl.askEditInstruction = async () => "rewrite it";
|
||||
ctl.setEditTurnForTest(async () => ({
|
||||
replacement: "# Tab\n\nThe REWRITTEN tab paragraph.\n",
|
||||
model: "sonnet",
|
||||
sessionId: "e2e-f12-tab",
|
||||
}));
|
||||
let askClaudeCalls = 0;
|
||||
// Capture the active-editor doc INSIDE the spy, before delegating to the real
|
||||
// askClaude — askClaude's own `workbench.action.addComment` side effect moves
|
||||
// focus to the ephemeral comment-input widget (a `comment://` URI), which would
|
||||
// make a post-hoc `activeTextEditor` check meaningless. What we're proving here
|
||||
// is editDocument's OWN resolve-and-focus step ran against the right document
|
||||
// before handing off.
|
||||
let focusedDocAtHandoff: string | undefined;
|
||||
const origAskClaude = api.threadController.askClaude.bind(api.threadController);
|
||||
api.threadController.askClaude = async () => {
|
||||
askClaudeCalls++;
|
||||
focusedDocAtHandoff = vscode.window.activeTextEditor?.document.uri.toString();
|
||||
return origAskClaude();
|
||||
};
|
||||
try {
|
||||
await vscode.commands.executeCommand("cowriting.editDocument", b.doc.uri);
|
||||
await settle();
|
||||
} finally {
|
||||
ctl.askEditInstruction = origPrompt;
|
||||
api.threadController.askClaude = origAskClaude;
|
||||
}
|
||||
|
||||
// The proposal(s) landed on the TAB doc (B), and the ACTIVE doc (A) has none.
|
||||
assert.ok(api.proposalController.listProposals(b.doc).length >= 1, "tab doc B received the document-edit proposal(s)");
|
||||
assert.strictEqual(
|
||||
api.proposalController.listProposals(a.doc).length,
|
||||
0,
|
||||
"active doc A was NOT edited — editDocument honored the tab URI",
|
||||
askClaudeCalls,
|
||||
1,
|
||||
"editDocument(uri) reached ThreadController.askClaude — not the removed prompt stub",
|
||||
);
|
||||
assert.strictEqual(
|
||||
focusedDocAtHandoff,
|
||||
b.doc.uri.toString(),
|
||||
"editDocument(uri) focused the TAB doc B, not the previously-active doc A, before handing off to askClaude",
|
||||
);
|
||||
// F12 (INV-48): EditorProposalController optimistically applies proposals into the
|
||||
// buffer, so the proposed text is now in the tab doc B. Confirm one of the proposal
|
||||
// texts is present (the tab doc was edited, not A).
|
||||
assert.ok(b.doc.getText().includes("REWRITTEN"), "F12 optimistic-apply: proposed text in tab doc B");
|
||||
});
|
||||
|
||||
// No URI arg (palette / keybinding) → fall back to the active editor.
|
||||
test("editDocument() with no arg targets the active editor", async () => {
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
const a = await freshDoc("docs/f12-noarg.md", "# No arg\n\nThe active doc paragraph here.\n");
|
||||
await vscode.window.showTextDocument(a.doc);
|
||||
await settle();
|
||||
|
||||
const origPrompt = ctl.askEditInstruction;
|
||||
ctl.askEditInstruction = async () => "rewrite it";
|
||||
ctl.setEditTurnForTest(async () => ({
|
||||
replacement: "# No arg\n\nThe REWRITTEN active doc paragraph.\n",
|
||||
model: "sonnet",
|
||||
sessionId: "e2e-f12-noarg",
|
||||
}));
|
||||
let askClaudeCalls = 0;
|
||||
// See the sibling test above for why this is captured inside the spy rather
|
||||
// than after askClaude() returns.
|
||||
let focusedDocAtHandoff: string | undefined;
|
||||
const origAskClaude = api.threadController.askClaude.bind(api.threadController);
|
||||
api.threadController.askClaude = async () => {
|
||||
askClaudeCalls++;
|
||||
focusedDocAtHandoff = vscode.window.activeTextEditor?.document.uri.toString();
|
||||
return origAskClaude();
|
||||
};
|
||||
try {
|
||||
await vscode.commands.executeCommand("cowriting.editDocument");
|
||||
await settle();
|
||||
} finally {
|
||||
ctl.askEditInstruction = origPrompt;
|
||||
api.threadController.askClaude = origAskClaude;
|
||||
}
|
||||
assert.ok(api.proposalController.listProposals(a.doc).length >= 1, "active doc received the proposal(s) on no-arg");
|
||||
|
||||
assert.strictEqual(askClaudeCalls, 1, "editDocument() with no arg reached ThreadController.askClaude");
|
||||
assert.strictEqual(
|
||||
focusedDocAtHandoff,
|
||||
a.doc.uri.toString(),
|
||||
"editDocument() with no arg kept the active editor's doc active before handing off to askClaude",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ const settle = () => new Promise((r) => setTimeout(r, 400));
|
||||
async function getApi(): Promise<CowritingApi> {
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||
const api = (await ext.activate()) as CowritingApi;
|
||||
assert.ok(api?.trackChangesPreviewController && api?.proposalController, "exports controllers");
|
||||
assert.ok(api?.proposalController && api?.editorProposalController, "exports controllers");
|
||||
return api;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ async function freshDoc(rel: string, body: string): Promise<vscode.TextDocument>
|
||||
fs.writeFileSync(abs, body, "utf8");
|
||||
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(abs));
|
||||
await vscode.window.showTextDocument(doc);
|
||||
// INV-10: attribution tracking (accept's per-word attribution, INV-40) is
|
||||
// gated on coediting (Task 4).
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settle();
|
||||
return doc;
|
||||
}
|
||||
@@ -36,13 +39,13 @@ suite("F12 SLICE-2 — block-granularity document proposals (#47, INV-39/40/41)"
|
||||
"# Doc\n\nFirst paragraph alpha.\n\nSecond paragraph beta.\n\nThird paragraph gamma.\n",
|
||||
);
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
ctl.setEditTurnForTest(async () => ({
|
||||
const editFlow = api.editFlow;
|
||||
editFlow.setEditTurnForTest(async () => ({
|
||||
replacement: "# Doc\n\nFirst paragraph ALPHA.\n\nSecond paragraph beta.\n\nThird paragraph GAMMA.\n",
|
||||
model: "sonnet",
|
||||
sessionId: "e2e-f12-multi",
|
||||
}));
|
||||
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "uppercase the first/last nouns");
|
||||
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase the first/last nouns");
|
||||
await settle();
|
||||
assert.strictEqual(ids.length, 2, "two changed blocks → two proposals; the unchanged middle block → none");
|
||||
|
||||
@@ -59,13 +62,13 @@ suite("F12 SLICE-2 — block-granularity document proposals (#47, INV-39/40/41)"
|
||||
"# Code\n\n```js\nconst a = 1;\nconst b = 2;\n```\n",
|
||||
);
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
ctl.setEditTurnForTest(async () => ({
|
||||
const editFlow = api.editFlow;
|
||||
editFlow.setEditTurnForTest(async () => ({
|
||||
replacement: "# Code\n\n```js\nconst a = 10;\nconst b = 2;\n```\n",
|
||||
model: "sonnet",
|
||||
sessionId: "e2e-f12-fence",
|
||||
}));
|
||||
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "bump a to 10");
|
||||
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "bump a to 10");
|
||||
await settle();
|
||||
assert.strictEqual(ids.length, 1, "a changed fence is one atomic proposal");
|
||||
const view = api.proposalController.listProposals(doc).find((v) => v.id === ids[0])!;
|
||||
@@ -77,15 +80,15 @@ suite("F12 SLICE-2 — block-granularity document proposals (#47, INV-39/40/41)"
|
||||
test("accepting a block proposal attributes only the changed words to Claude (INV-40)", async () => {
|
||||
const doc = await freshDoc("docs/f12-attr.md", "# T\n\nThe quick brown fox jumps lazily.\n");
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
const editFlow = api.editFlow;
|
||||
const key = api.proposalController.keyFor(doc);
|
||||
|
||||
ctl.setEditTurnForTest(async () => ({
|
||||
editFlow.setEditTurnForTest(async () => ({
|
||||
replacement: "# T\n\nThe quick RED fox jumps SLOWLY.\n",
|
||||
model: "sonnet",
|
||||
sessionId: "e2e-f12-attr",
|
||||
}));
|
||||
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "change two words");
|
||||
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "change two words");
|
||||
await settle();
|
||||
assert.strictEqual(ids.length, 1, "one changed paragraph → one block proposal");
|
||||
|
||||
@@ -115,11 +118,11 @@ suite("F12 SLICE-2 — block-granularity document proposals (#47, INV-39/40/41)"
|
||||
const rewrite = "# Ins\n\nAlpha block.\n\nBrand new middle block.\n\nBeta block.\n";
|
||||
const doc = await freshDoc("docs/f12-insert.md", original);
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
const editFlow = api.editFlow;
|
||||
const key = api.proposalController.keyFor(doc);
|
||||
|
||||
ctl.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-insert" }));
|
||||
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "insert a paragraph");
|
||||
editFlow.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-insert" }));
|
||||
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "insert a paragraph");
|
||||
await settle();
|
||||
assert.ok(ids.length >= 1, "the insertion produced at least one proposal");
|
||||
|
||||
@@ -132,3 +135,55 @@ suite("F12 SLICE-2 — block-granularity document proposals (#47, INV-39/40/41)"
|
||||
assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "no proposals left pending");
|
||||
});
|
||||
});
|
||||
|
||||
// Task 8 (PUC-2 lens copy): the CodeLens titles read "✓ Keep"/"✗ Reject" per
|
||||
// proposal, and — once ≥2 proposals are pending — a top-of-file "Keep all
|
||||
// (N)"/"Reject all" pair routes straight at the batch commands.
|
||||
suite("F12 CodeLens copy (Task 8, PUC-2)", () => {
|
||||
test("one pending proposal → a per-proposal ✓ Keep / ✗ Reject pair, no top-of-file pair", async () => {
|
||||
const doc = await freshDoc("docs/f12-lens-one.md", "# Lens\n\nAlpha block here.\n");
|
||||
const api = await getApi();
|
||||
const editFlow = api.editFlow;
|
||||
editFlow.setEditTurnForTest(async () => ({
|
||||
replacement: "# Lens\n\nALPHA block here.\n",
|
||||
model: "sonnet",
|
||||
sessionId: "e2e-lens-one",
|
||||
}));
|
||||
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase");
|
||||
await settle();
|
||||
assert.strictEqual(ids.length, 1, "one changed block → one proposal");
|
||||
|
||||
const lenses = api.editorProposalController.provideCodeLenses(doc);
|
||||
const titles = lenses.map((l) => l.command?.title);
|
||||
assert.deepStrictEqual(titles, ["✓ Keep", "✗ Reject"], "exactly one per-proposal pair, no top-of-file pair below threshold");
|
||||
});
|
||||
|
||||
test("two pending proposals → a top-of-file 'Keep all (2)'/'Reject all' pair routed at the batch commands", async () => {
|
||||
const doc = await freshDoc(
|
||||
"docs/f12-lens-two.md",
|
||||
"# Lens\n\nFirst block here.\n\nSecond block here.\n",
|
||||
);
|
||||
const api = await getApi();
|
||||
const editFlow = api.editFlow;
|
||||
editFlow.setEditTurnForTest(async () => ({
|
||||
replacement: "# Lens\n\nFIRST block here.\n\nSECOND block here.\n",
|
||||
model: "sonnet",
|
||||
sessionId: "e2e-lens-two",
|
||||
}));
|
||||
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase both");
|
||||
await settle();
|
||||
assert.strictEqual(ids.length, 2, "two changed blocks → two proposals");
|
||||
|
||||
const lenses = api.editorProposalController.provideCodeLenses(doc);
|
||||
const titles = lenses.map((l) => l.command?.title);
|
||||
assert.strictEqual(titles[0], "✓ Keep all (2)", "top-of-file accept-all lens leads");
|
||||
assert.strictEqual(titles[1], "✗ Reject all", "top-of-file reject-all lens follows");
|
||||
assert.deepStrictEqual(titles.slice(2), ["✓ Keep", "✗ Reject", "✓ Keep", "✗ Reject"], "per-proposal pairs follow");
|
||||
|
||||
const acceptAll = lenses[0].command!;
|
||||
const rejectAll = lenses[1].command!;
|
||||
assert.strictEqual(acceptAll.command, "cowriting.acceptAllProposals", "top-of-file accept-all routes at the batch command");
|
||||
assert.strictEqual(rejectAll.command, "cowriting.rejectAllProposals", "top-of-file reject-all routes at the batch command");
|
||||
assert.deepStrictEqual(acceptAll.arguments, undefined, "acceptAllProposals takes no arguments (targets the active editor)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Shared host-E2E helpers (native-surfaces migration, Task 2). NEW suites use
|
||||
* these; existing suites keep their own local copies (out of scope to
|
||||
* refactor them onto this shared file — see the Task 2 brief).
|
||||
*/
|
||||
import * as assert from "node:assert";
|
||||
import * as vscode from "vscode";
|
||||
import type { CowritingApi } from "../../../src/extension";
|
||||
import { renderReview } from "../../../src/trackChangesModel";
|
||||
|
||||
/** Activate the extension and return its exported API (asserts it is real). */
|
||||
export async function activateApi(): Promise<CowritingApi> {
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||
const api = (await ext.activate()) as CowritingApi;
|
||||
assert.ok(api?.diffViewController, "extension exports diffViewController");
|
||||
return api;
|
||||
}
|
||||
|
||||
/**
|
||||
* Task 8: the pure render probe the deleted TrackChangesPreviewController used
|
||||
* to wrap (`renderHtmlFor`) — the sunset webview was a thin shell around this
|
||||
* exact call (F6 baseline + F3 spans + F4 pending proposals -> `renderReview`).
|
||||
* Suites that used to read `controller.renderHtmlFor(key)` now call this
|
||||
* directly against the surviving controllers; no webview involved.
|
||||
*/
|
||||
export function renderHtmlFor(api: CowritingApi, doc: vscode.TextDocument, key: string): string {
|
||||
const baseline = api.diffViewController.getBaseline(key);
|
||||
const current = doc.getText();
|
||||
return renderReview(
|
||||
baseline?.text ?? current,
|
||||
current,
|
||||
api.attributionController.spansFor(doc),
|
||||
api.proposalController.listProposals(doc),
|
||||
{ pinned: baseline?.reason === "pinned" },
|
||||
);
|
||||
}
|
||||
|
||||
/** A short fixed settle, for state that updates synchronously-ish after a command. */
|
||||
export function settle(): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, 150));
|
||||
}
|
||||
|
||||
/** Poll `predicate` every 100ms until it is true, or fail after `timeoutMs`. */
|
||||
export async function settleUntil(predicate: () => boolean, timeoutMs = 5000): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (!predicate()) {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
assert.fail(`settleUntil: predicate did not become true within ${timeoutMs}ms`);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ const settle = () => new Promise((r) => setTimeout(r, 400));
|
||||
async function getApi(): Promise<CowritingApi> {
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||
const api = (await ext.activate()) as CowritingApi;
|
||||
assert.ok(api?.trackChangesPreviewController, "exports preview controller");
|
||||
assert.ok(api?.editFlow && api?.proposalController, "exports edit flow + proposal controller");
|
||||
return api;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ async function freshDoc(rel: string, body: string): Promise<vscode.TextDocument>
|
||||
fs.writeFileSync(abs, body, "utf8");
|
||||
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(abs));
|
||||
await vscode.window.showTextDocument(doc);
|
||||
// INV-10: proposal rendering (F12 optimistic-apply, driven by
|
||||
// onDidChangeProposals) is gated on coediting (Task 4).
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settle();
|
||||
return doc;
|
||||
}
|
||||
@@ -33,19 +36,17 @@ suite("#60 live turn progress (additive + cancel)", () => {
|
||||
test("a stub that emits progress still produces the same proposals (INV-44)", async () => {
|
||||
const doc = await freshDoc("docs/live60-additive.md", "# Title\n\nOld paragraph.\n");
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
const editFlow = api.editFlow;
|
||||
|
||||
// The stub honors opts.onProgress (emitting synthetic snapshots) but returns
|
||||
// the same rewrite — proposals must be unaffected by progress events.
|
||||
ctl.setEditTurnForTest(async (_i, _text, opts) => {
|
||||
editFlow.setEditTurnForTest(async (_i, _text, opts) => {
|
||||
opts?.onProgress?.({ phase: "writing", chars: 5 });
|
||||
opts?.onProgress?.({ phase: "writing", chars: 13, tokens: 1234, textDelta: "New paragraph." });
|
||||
return { replacement: "# Title\n\nNew paragraph.\n", model: "sonnet", sessionId: "e2e-live60" };
|
||||
});
|
||||
|
||||
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "rewrite the paragraph");
|
||||
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "rewrite the paragraph");
|
||||
await settle();
|
||||
assert.strictEqual(ids.length, 1, "one changed block → one proposal, regardless of progress events");
|
||||
const view = api.proposalController.listProposals(doc).find((v) => v.id === ids[0]);
|
||||
@@ -59,14 +60,12 @@ suite("#60 live turn progress (additive + cancel)", () => {
|
||||
test("an aborted turn proposes nothing (INV-47)", async () => {
|
||||
const doc = await freshDoc("docs/live60-cancel.md", "# Title\n\nOld paragraph.\n");
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
const editFlow = api.editFlow;
|
||||
|
||||
// The stub throws ONLY when the aborted signal reached it — so if opts.signal
|
||||
// failed to thread through runEditAndPropose, the stub would instead return a
|
||||
// rewrite and create a proposal, failing this test. That proves propagation.
|
||||
ctl.setEditTurnForTest(async (_i, _text, opts) => {
|
||||
editFlow.setEditTurnForTest(async (_i, _text, opts) => {
|
||||
if (opts?.signal?.aborted) throw new Error("claude-code turn aborted");
|
||||
return { replacement: "# Title\n\nSHOULD NOT BE PROPOSED.\n", model: "sonnet", sessionId: "e2e-live60-nope" };
|
||||
});
|
||||
@@ -75,7 +74,7 @@ suite("#60 live turn progress (additive + cancel)", () => {
|
||||
ac.abort();
|
||||
let ids: string[] = [];
|
||||
try {
|
||||
ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "rewrite", { signal: ac.signal });
|
||||
ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "rewrite", { signal: ac.signal });
|
||||
} catch {
|
||||
ids = [];
|
||||
}
|
||||
|
||||
@@ -37,8 +37,16 @@ suite("F8 out-of-workspace authoring (host E2E — programmatic seam, no LLM)",
|
||||
const uri = vscode.Uri.file(outsidePath);
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
// INV-10: attribution tracking + thread creation are gated on coediting (Task 4).
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settle();
|
||||
const api = await getApi();
|
||||
// Task 6 (D10): createThreadOnSelection (used later in this test) also
|
||||
// fires the respond-in-thread turn now — stub it off (this "no LLM" suite
|
||||
// asserts the programmatic propose/accept seam, not the reply loop).
|
||||
api.threadController.setTurnRunnerForTest(async () => {
|
||||
throw new Error("out-of-workspace suite: reply-loop turn stubbed off");
|
||||
});
|
||||
const key = uri.toString();
|
||||
|
||||
const id = await proposeViaCommand(key, doc.getText(), TARGET, "The sibling sentence CLAUDE REWROTE.", "turn-oow1");
|
||||
@@ -80,6 +88,8 @@ suite("F8 out-of-workspace authoring (host E2E — programmatic seam, no LLM)",
|
||||
const TARGET = "The untitled draft sentence.";
|
||||
const untitled = await vscode.workspace.openTextDocument({ content: `${TARGET}\n`, language: "markdown" });
|
||||
await vscode.window.showTextDocument(untitled);
|
||||
// INV-10: attribution tracking is gated on coediting (Task 4).
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settle();
|
||||
const api = await getApi();
|
||||
const key = untitled.uri.toString();
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import * as assert from "node:assert";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import * as vscode from "vscode";
|
||||
import { cowritingMarkdownItPlugin } from "../../../src/previewAnnotations";
|
||||
import { activateApi, settle } from "./helpers";
|
||||
|
||||
// Task 7 (D3/D21, PUC-3): the built-in preview's DOM isn't queryable from a
|
||||
// host E2E test (§6.8), so this asserts the pure transform's INPUTS/OUTPUTS
|
||||
// through the real `CowritingApi` seams instead — the production
|
||||
// `previewAnnotationHost.inputsFor` (not a hand-rolled reimplementation) fed
|
||||
// through the real `cowritingMarkdownItPlugin` + a fresh markdown-it instance
|
||||
// (mirroring the unit suite's own `render()` helper), so this exercises the
|
||||
// ACTUAL registry/diffView/attribution/proposals/config wiring end to end.
|
||||
suite("PUC-3 annotate-toggle (built-in Markdown preview annotations)", () => {
|
||||
test("a machine-attributed change renders cw-ins-claude; disabling the setting passes the source through unchanged", async () => {
|
||||
const api = await activateApi();
|
||||
const doc = await vscode.workspace.openTextDocument({
|
||||
language: "markdown",
|
||||
content: "hello world\n",
|
||||
});
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settle();
|
||||
|
||||
const start = doc.getText().indexOf("world");
|
||||
const ok = await vscode.commands.executeCommand<boolean>("cowriting.applyAgentEdit", {
|
||||
uri: doc.uri.toString(),
|
||||
start,
|
||||
end: start + "world".length,
|
||||
newText: "brave new world",
|
||||
model: "sonnet",
|
||||
sessionId: "e2e-preview-annotations",
|
||||
turnId: "turn-e2e-preview-annotations",
|
||||
});
|
||||
assert.strictEqual(ok, true, "seam edit applies");
|
||||
await settle();
|
||||
|
||||
// Annotations ON (default): render the CURRENT buffer through the real
|
||||
// markdown-it plugin + the production host — proves the full pipeline
|
||||
// (core-rule swap → full-render sentinel walk) reaches a live doc.
|
||||
const md = new MarkdownIt();
|
||||
cowritingMarkdownItPlugin(md, api.previewAnnotationHost);
|
||||
const html = md.render(doc.getText());
|
||||
assert.ok(html.includes("cw-ins-claude"), `expected cw-ins-claude in:\n${html}`);
|
||||
|
||||
// Toggle OFF: the config-gated `enabled` flag reaches `annotateSource` and
|
||||
// the transform becomes the identity (no core-rule swap, no marks).
|
||||
const config = vscode.workspace.getConfiguration("cowriting");
|
||||
await config.update("annotations", false, vscode.ConfigurationTarget.Global);
|
||||
try {
|
||||
const inputs = api.previewAnnotationHost.inputsFor(undefined);
|
||||
assert.ok(inputs, "inputsFor still resolves a coedited doc while disabled");
|
||||
assert.strictEqual(inputs!.enabled, false);
|
||||
const md2 = new MarkdownIt();
|
||||
cowritingMarkdownItPlugin(md2, api.previewAnnotationHost);
|
||||
const plainMd = new MarkdownIt();
|
||||
assert.strictEqual(md2.render(doc.getText()), plainMd.render(doc.getText()));
|
||||
} finally {
|
||||
await config.update("annotations", true, vscode.ConfigurationTarget.Global);
|
||||
}
|
||||
});
|
||||
|
||||
test("cowriting.toggleAnnotations flips the setting", async () => {
|
||||
const api = await activateApi();
|
||||
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "toggle me\n" });
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settle();
|
||||
|
||||
const config = () => vscode.workspace.getConfiguration("cowriting");
|
||||
const before = config().get<boolean>("annotations", true);
|
||||
await vscode.commands.executeCommand("cowriting.toggleAnnotations");
|
||||
assert.strictEqual(config().get<boolean>("annotations", true), !before);
|
||||
// restore
|
||||
await vscode.commands.executeCommand("cowriting.toggleAnnotations");
|
||||
assert.strictEqual(config().get<boolean>("annotations", true), before);
|
||||
void api; // keep the activated extension alive for the duration of the test
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,10 @@ async function openDoc(): 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: proposal rendering (and the F12 optimistic-apply it drives via
|
||||
// onDidChangeProposals) is gated on coediting (Task 4) — idempotent across
|
||||
// this order-dependent suite's repeated openDoc() calls.
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
return doc;
|
||||
}
|
||||
async function getApi(): Promise<CowritingApi> {
|
||||
|
||||
@@ -10,7 +10,7 @@ const settle = () => new Promise((r) => setTimeout(r, 400));
|
||||
async function getApi(): Promise<CowritingApi> {
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||
const api = (await ext.activate()) as CowritingApi;
|
||||
assert.ok(api?.trackChangesPreviewController, "exports preview controller");
|
||||
assert.ok(api?.coeditingRegistry, "exports coeditingRegistry");
|
||||
return api;
|
||||
}
|
||||
|
||||
@@ -26,14 +26,19 @@ function pkg(): any {
|
||||
return JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8"));
|
||||
}
|
||||
|
||||
// #41 (story): "Open Cowriting Review Panel" reachable from the markdown
|
||||
// file/tab right-click menu. The review surface (showTrackChangesPreview) is the
|
||||
// plugin's central affordance; this adds explorer/context + editor/title/context
|
||||
// entry points and makes the command open the *clicked* document — not merely
|
||||
// the active editor — so the explorer right-click works even when the file is
|
||||
// not already open.
|
||||
suite("#41 review-panel right-click entry (host E2E — menu wiring + clicked-doc resolution)", () => {
|
||||
test("command with a Uri previews the CLICKED doc, not the active editor", async () => {
|
||||
// Task 8 (native-surfaces migration, spec §6.10): the review-webview's #41
|
||||
// right-click entries ("Open Cowriting Review Panel") are replaced by
|
||||
// `cowriting.openReviewPreview` — a minimal wrapper that resolves the clicked
|
||||
// document (or falls back to the active editor, mirroring #41's original
|
||||
// behavior), enters coediting if not already (so there is a baseline to review
|
||||
// against), and hands off to VS Code's OWN "Open Preview to the Side"
|
||||
// (`markdown.showPreviewToSide`) — the built-in preview IS the review surface
|
||||
// now (Task 7's annotations render inside it). There is no controller-level
|
||||
// `isOpen` seam anymore (no bespoke webview to introspect); these tests observe
|
||||
// the command's OWN side effects instead — which document it focused/entered
|
||||
// coediting on — the same signal #41's original suite checked via `isOpen`.
|
||||
suite("Open Cowriting Review Preview (host E2E — menu wiring + clicked-doc resolution, Task 8)", () => {
|
||||
test("command with a Uri targets the CLICKED doc, not the active editor", async () => {
|
||||
// Two markdown docs; A is the active editor, B is the right-clicked target.
|
||||
const aUri = writeFile("docs/menu-active.md", "# Active\n\nThe active editor's document.\n");
|
||||
const bUri = writeFile("docs/menu-clicked.md", "# Clicked\n\nThe right-clicked document.\n");
|
||||
@@ -43,19 +48,20 @@ suite("#41 review-panel right-click entry (host E2E — menu wiring + clicked-do
|
||||
await settle();
|
||||
const api = await getApi();
|
||||
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview", bUri);
|
||||
await vscode.commands.executeCommand("cowriting.openReviewPreview", bUri);
|
||||
await settle();
|
||||
|
||||
assert.strictEqual(api.trackChangesPreviewController.isOpen(bUri.toString()), true, "preview opened for the clicked doc");
|
||||
assert.strictEqual(
|
||||
api.trackChangesPreviewController.isOpen(aUri.toString()),
|
||||
false,
|
||||
"the active editor's doc did NOT get a preview",
|
||||
);
|
||||
// `markdown.showPreviewToSide` itself ends with the preview WEBVIEW focused
|
||||
// (not a text editor), so `activeTextEditor` is not a stable post-command
|
||||
// signal — `isCoediting` (set by the command's OWN resolve-and-enter step,
|
||||
// before it hands off to the preview) is: the clicked doc B entered, the
|
||||
// still-open-but-not-clicked doc A did not.
|
||||
assert.strictEqual(api.coeditingRegistry.isCoediting(bUri), true, "the clicked doc B entered coediting (a baseline to review against)");
|
||||
assert.strictEqual(api.coeditingRegistry.isCoediting(aUri), false, "the active-but-not-clicked doc A did NOT enter coediting");
|
||||
assert.ok(bDoc, "clicked doc handle held");
|
||||
});
|
||||
|
||||
test("command with a Uri for a not-yet-open file opens it and previews (explorer case)", async () => {
|
||||
test("command with a Uri for a not-yet-open file opens it and focuses it (explorer case)", async () => {
|
||||
// Write a file but do not openTextDocument it — mimics an Explorer right-click.
|
||||
const uri = writeFile("docs/menu-unopened.md", "# Unopened\n\nNever opened before the right-click.\n");
|
||||
const key = uri.toString();
|
||||
@@ -65,10 +71,14 @@ suite("#41 review-panel right-click entry (host E2E — menu wiring + clicked-do
|
||||
"precondition: the file is not an open document",
|
||||
);
|
||||
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview", uri);
|
||||
await vscode.commands.executeCommand("cowriting.openReviewPreview", uri);
|
||||
await settle();
|
||||
|
||||
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), true, "the clicked-but-unopened file got a preview");
|
||||
assert.ok(
|
||||
vscode.workspace.textDocuments.some((d) => d.uri.toString() === key),
|
||||
"the clicked-but-unopened file was opened",
|
||||
);
|
||||
assert.strictEqual(api.coeditingRegistry.isCoediting(uri), true, "it entered coediting");
|
||||
});
|
||||
|
||||
test("no-arg invocation still falls back to the active editor (palette / keybinding unchanged)", async () => {
|
||||
@@ -78,39 +88,74 @@ suite("#41 review-panel right-click entry (host E2E — menu wiring + clicked-do
|
||||
await settle();
|
||||
const api = await getApi();
|
||||
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await vscode.commands.executeCommand("cowriting.openReviewPreview");
|
||||
await settle();
|
||||
|
||||
assert.strictEqual(api.trackChangesPreviewController.isOpen(uri.toString()), true, "no-arg previews the active editor");
|
||||
assert.strictEqual(api.coeditingRegistry.isCoediting(uri), true, "no-arg targets the active editor");
|
||||
});
|
||||
|
||||
test("explorer/context contributes the review-panel item for markdown only", () => {
|
||||
test("a non-markdown doc: command warns, no coediting entered", async () => {
|
||||
const uri = writeFile("docs/menu-notmd.txt", "Not markdown.\n");
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await settle();
|
||||
const api = await getApi();
|
||||
assert.notStrictEqual(doc.languageId, "markdown", "fixture is not markdown");
|
||||
|
||||
await vscode.commands.executeCommand("cowriting.openReviewPreview");
|
||||
await settle();
|
||||
|
||||
assert.strictEqual(api.coeditingRegistry.isCoediting(uri), false, "a non-markdown doc never enters coediting via this gateway");
|
||||
});
|
||||
|
||||
test("explorer/context contributes the review-preview item for markdown only", () => {
|
||||
const items = (pkg().contributes.menus["explorer/context"] ?? []) as Array<{ command: string; when?: string }>;
|
||||
const entry = items.find((m) => m.command === "cowriting.showTrackChangesPreview");
|
||||
assert.ok(entry, "explorer/context has a showTrackChangesPreview entry");
|
||||
const entry = items.find((m) => m.command === "cowriting.openReviewPreview");
|
||||
assert.ok(entry, "explorer/context has an openReviewPreview entry");
|
||||
assert.match(entry!.when ?? "", /resourceLangId == markdown/, "gated on markdown resources");
|
||||
});
|
||||
|
||||
test("editor/title/context (tab right-click) contributes the review-panel item for markdown only", () => {
|
||||
test("editor/title/context (tab right-click) contributes the review-preview item for markdown only", () => {
|
||||
const items = (pkg().contributes.menus["editor/title/context"] ?? []) as Array<{ command: string; when?: string }>;
|
||||
const entry = items.find((m) => m.command === "cowriting.showTrackChangesPreview");
|
||||
assert.ok(entry, "editor/title/context has a showTrackChangesPreview entry");
|
||||
const entry = items.find((m) => m.command === "cowriting.openReviewPreview");
|
||||
assert.ok(entry, "editor/title/context has an openReviewPreview entry");
|
||||
assert.match(entry!.when ?? "", /resourceLangId == markdown/, "gated on markdown tabs");
|
||||
});
|
||||
|
||||
test("the command title reads 'Open Cowriting Review Panel'", () => {
|
||||
const cmd = (pkg().contributes.commands as Array<{ command: string; title: string }>).find(
|
||||
(c) => c.command === "cowriting.showTrackChangesPreview",
|
||||
);
|
||||
assert.ok(cmd, "command is contributed");
|
||||
assert.strictEqual(cmd!.title, "Open Cowriting Review Panel", "menus render this title");
|
||||
test("editor/title (toolbar icon) contributes the review-preview item for markdown only", () => {
|
||||
const items = (pkg().contributes.menus["editor/title"] ?? []) as Array<{ command: string; when?: string }>;
|
||||
const entry = items.find((m) => m.command === "cowriting.openReviewPreview");
|
||||
assert.ok(entry, "editor/title has an openReviewPreview entry");
|
||||
assert.match(entry!.when ?? "", /editorLangId == markdown/, "gated on markdown");
|
||||
});
|
||||
|
||||
test("the ctrl+alt+r keybinding still targets the command (unchanged)", () => {
|
||||
const kb = (pkg().contributes.keybindings as Array<{ command: string; key: string; when?: string }>).find(
|
||||
test("the command title reads 'Open Cowriting Review Preview'", () => {
|
||||
const cmd = (pkg().contributes.commands as Array<{ command: string; title: string }>).find(
|
||||
(c) => c.command === "cowriting.openReviewPreview",
|
||||
);
|
||||
assert.ok(cmd, "command is contributed");
|
||||
assert.strictEqual(cmd!.title, "Open Cowriting Review Preview", "menus render this title");
|
||||
});
|
||||
|
||||
test("the old cowriting.showTrackChangesPreview command/menus/keybinding are fully gone (Task 8 exit criterion)", () => {
|
||||
const p = pkg();
|
||||
const commands = p.contributes.commands as Array<{ command: string }>;
|
||||
assert.ok(!commands.some((c) => c.command === "cowriting.showTrackChangesPreview"), "command contribution removed");
|
||||
for (const menuId of ["commandPalette", "editor/title", "editor/title/context", "explorer/context"]) {
|
||||
const items = (p.contributes.menus[menuId] ?? []) as Array<{ command: string }>;
|
||||
assert.ok(!items.some((m) => m.command === "cowriting.showTrackChangesPreview"), `${menuId} no longer references it`);
|
||||
}
|
||||
const kb = (p.contributes.keybindings as Array<{ command: string }>).find(
|
||||
(k) => k.command === "cowriting.showTrackChangesPreview",
|
||||
);
|
||||
assert.ok(kb, "keybinding still present");
|
||||
assert.strictEqual(kb!.key, "ctrl+alt+r", "key unchanged");
|
||||
assert.ok(!kb, "keybinding removed");
|
||||
});
|
||||
|
||||
test("ctrl+alt+r now targets cowriting.reviewChanges (the native diff), not the retired preview command", () => {
|
||||
const kb = (pkg().contributes.keybindings as Array<{ command: string; key: string; when?: string }>).find(
|
||||
(k) => k.key === "ctrl+alt+r",
|
||||
);
|
||||
assert.ok(kb, "ctrl+alt+r keybinding still present");
|
||||
assert.strictEqual(kb!.command, "cowriting.reviewChanges", "the chord now opens the native diff (Step 1 re-point)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as vscode from "vscode";
|
||||
import type { CowritingApi } from "../../../src/extension";
|
||||
import { renderHtmlFor } from "./helpers";
|
||||
|
||||
const WS = process.env.E2E_WORKSPACE!;
|
||||
const settle = () => new Promise((r) => setTimeout(r, 400));
|
||||
@@ -10,10 +11,12 @@ const settle = () => new Promise((r) => setTimeout(r, 400));
|
||||
async function getApi(): Promise<CowritingApi> {
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||
const api = (await ext.activate()) as CowritingApi;
|
||||
assert.ok(api?.trackChangesPreviewController, "exports preview controller");
|
||||
assert.ok(api?.diffViewController && api?.attributionController, "exports diffView + attribution");
|
||||
return api;
|
||||
}
|
||||
|
||||
/** Also enters coediting (INV-10/Task 2): the baseline is established only on
|
||||
* entry, not merely on open. */
|
||||
async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> {
|
||||
const abs = path.join(WS, rel);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
@@ -21,6 +24,7 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo
|
||||
const uri = vscode.Uri.file(abs);
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settle();
|
||||
return { doc, key: uri.toString() };
|
||||
}
|
||||
@@ -32,21 +36,19 @@ suite("S48 — pin → fully clean review panel (host E2E, no LLM)", () => {
|
||||
test("pin clears authorship coloring; a later edit brings annotations back", async () => {
|
||||
const { doc, key } = await freshDoc("docs/s48pin.md", "# S48\n\nAn original baseline paragraph.\n");
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
|
||||
// Type a paragraph → a human attribution span + author coloring in the on-state.
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
edit.insert(doc.uri, doc.positionAt(doc.getText().length), "\n\nA freshly typed human paragraph.\n");
|
||||
assert.ok(await vscode.workspace.applyEdit(edit), "operator edit applied");
|
||||
await settle();
|
||||
assert.match(ctl.renderHtmlFor(key), /cw-ins-human/, "typed text is author-colored cw-ins-human before the pin");
|
||||
assert.match(renderHtmlFor(api, doc, key), /cw-ins-human/, "typed text is author-colored cw-ins-human before the pin");
|
||||
|
||||
// Pin the baseline → zero diff → the panel must be fully clean.
|
||||
ctl.receiveMessage(key, { type: "pinBaseline" });
|
||||
// Pin the baseline (the real command — replaces the F11-webview's pinBaseline
|
||||
// toolbar intent, Task 8) → zero diff → the render must be fully clean.
|
||||
await vscode.commands.executeCommand("cowriting.markReviewed");
|
||||
await settle();
|
||||
const pinned = ctl.renderHtmlFor(key);
|
||||
const pinned = renderHtmlFor(api, doc, key);
|
||||
assert.ok(!pinned.includes("cw-ins-human"), "no human insertion marks after pin");
|
||||
assert.ok(!pinned.includes("cw-ins-claude"), "no Claude insertion marks after pin");
|
||||
assert.ok(!pinned.includes("cw-ins-") && !pinned.includes("cw-del-"), "no insertion or deletion marks after pin");
|
||||
@@ -58,6 +60,6 @@ suite("S48 — pin → fully clean review panel (host E2E, no LLM)", () => {
|
||||
edit2.insert(doc.uri, doc.positionAt(doc.getText().length), "\n\nA second typed paragraph diverges again.\n");
|
||||
assert.ok(await vscode.workspace.applyEdit(edit2), "second operator edit applied");
|
||||
await settle();
|
||||
assert.match(ctl.renderHtmlFor(key), /cw-ins-human/, "cw-ins-human authorship coloring returns once the doc diverges from the pin");
|
||||
assert.match(renderHtmlFor(api, doc, key), /cw-ins-human/, "cw-ins-human authorship coloring returns once the doc diverges from the pin");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,9 @@ 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;
|
||||
}
|
||||
|
||||
@@ -38,6 +41,17 @@ async function getApi(): Promise<CowritingApi> {
|
||||
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.
|
||||
@@ -64,6 +78,7 @@ 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));
|
||||
@@ -86,6 +101,7 @@ suite("F2 region-anchored threads (host E2E)", () => {
|
||||
|
||||
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;
|
||||
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import * as assert from "assert";
|
||||
import * as path from "path";
|
||||
import * as vscode from "vscode";
|
||||
import type { CowritingApi } from "../../../src/extension";
|
||||
|
||||
const WS = process.env.E2E_WORKSPACE!;
|
||||
const DOC_REL = "docs/preview.md";
|
||||
const docUri = () => vscode.Uri.file(path.join(WS, DOC_REL)).toString();
|
||||
|
||||
async function openDoc(rel = DOC_REL): Promise<vscode.TextDocument> {
|
||||
const uri = vscode.Uri.file(path.join(WS, rel));
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
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?.trackChangesPreviewController, "extension exports trackChangesPreviewController");
|
||||
return api;
|
||||
}
|
||||
const settle = () => new Promise((r) => setTimeout(r, 400));
|
||||
const kinds = (api: CowritingApi) =>
|
||||
(api.trackChangesPreviewController.getLastModel(docUri()) ?? []).map((o) => o.kind);
|
||||
|
||||
// Order-dependent (F2–F6 pattern): later tests consume earlier state.
|
||||
suite("F7 track-changes preview (host E2E — markdown only, programmatic seam, no LLM)", () => {
|
||||
const TARGET = "A target sentence Claude will rewrite via the seam.";
|
||||
const REPLACEMENT = "A SENTENCE CLAUDE REWROTE via the seam.";
|
||||
|
||||
test("open on a markdown doc → panel open, opened baseline shows no changes (PUC-1)", async () => {
|
||||
const doc = await openDoc();
|
||||
const api = await getApi();
|
||||
assert.strictEqual(api.trackChangesPreviewController.isOpen(docUri()), false, "no panel yet");
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
assert.strictEqual(api.trackChangesPreviewController.isOpen(docUri()), true, "panel open");
|
||||
const model = api.trackChangesPreviewController.getLastModel(docUri());
|
||||
assert.ok(model && model.length > 0, "a model was computed");
|
||||
assert.ok(
|
||||
model!.every((o) => o.kind === "unchanged"),
|
||||
"baseline == buffer at open → every block unchanged",
|
||||
);
|
||||
void doc;
|
||||
});
|
||||
|
||||
test("typing produces a changed/added block (PUC-2)", async () => {
|
||||
const doc = await openDoc();
|
||||
const api = await getApi();
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
const end = doc.positionAt(doc.getText().length);
|
||||
edit.insert(doc.uri, end, "\n\nA freshly typed paragraph.\n");
|
||||
assert.ok(await vscode.workspace.applyEdit(edit), "operator edit applied");
|
||||
await settle();
|
||||
assert.ok(
|
||||
kinds(api).some((k) => k === "added" || k === "changed"),
|
||||
"an added/changed block appears after typing",
|
||||
);
|
||||
});
|
||||
|
||||
test("accepting a proposal advances the baseline; the landed block goes unchanged (PUC-3, INV-18)", async () => {
|
||||
const doc = await openDoc();
|
||||
const api = await getApi();
|
||||
const start = doc.getText().indexOf(TARGET);
|
||||
assert.ok(start >= 0, "fixture contains the target sentence");
|
||||
const id = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
|
||||
uri: doc.uri.toString(),
|
||||
start,
|
||||
end: start + TARGET.length,
|
||||
newText: REPLACEMENT,
|
||||
model: "sonnet",
|
||||
sessionId: "e2e-f7",
|
||||
turnId: "turn-f7-1",
|
||||
});
|
||||
assert.ok(id, "propose returns an id");
|
||||
assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies via the seam");
|
||||
await settle();
|
||||
// The baseline advanced to include REPLACEMENT, so the buffer == baseline for
|
||||
// that block → it is NOT marked. Confirm the replacement is not flagged as a change.
|
||||
const model = api.trackChangesPreviewController.getLastModel(docUri()) ?? [];
|
||||
const replacementMarked = model.some(
|
||||
(o) => o.kind !== "unchanged" && o.block.raw.includes("CLAUDE REWROTE"),
|
||||
);
|
||||
assert.ok(!replacementMarked, "the just-landed text renders unmarked (baseline advanced)");
|
||||
});
|
||||
|
||||
test("pin resets the baseline to now → preview shows no marks (PUC-4)", async () => {
|
||||
const doc = await openDoc();
|
||||
const api = await getApi();
|
||||
await vscode.commands.executeCommand("cowriting.pinDiffBaseline");
|
||||
await settle();
|
||||
assert.ok(
|
||||
kinds(api).every((k) => k === "unchanged"),
|
||||
"after pin, every block is unchanged (baseline == buffer)",
|
||||
);
|
||||
void doc;
|
||||
});
|
||||
|
||||
test("a non-markdown doc → command warns and opens no panel (PUC-6, markdown-only)", async () => {
|
||||
const txt = await openDoc("docs/notes.txt");
|
||||
const api = await getApi();
|
||||
const key = txt.uri.toString();
|
||||
assert.notStrictEqual(txt.languageId, "markdown", "fixture is not markdown");
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), false, "no panel for non-markdown");
|
||||
});
|
||||
|
||||
test("a changed flowchart augments the emitted mermaid source (#22, INV-29)", async () => {
|
||||
const doc = await openDoc();
|
||||
const api = await getApi();
|
||||
// Show the preview and pin the baseline so the fixture's `a --> b` flowchart
|
||||
// is the "before"; then add an edge `a --> c` and confirm the intra-diagram diff.
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await vscode.commands.executeCommand("cowriting.pinDiffBaseline");
|
||||
await settle();
|
||||
|
||||
const anchor = doc.getText().indexOf("a --> b");
|
||||
assert.ok(anchor >= 0, "fixture contains the flowchart edge");
|
||||
const insertAt = doc.positionAt(anchor + "a --> b".length);
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
edit.insert(doc.uri, insertAt, "\n a --> c");
|
||||
assert.ok(await vscode.workspace.applyEdit(edit), "flowchart edit applied");
|
||||
await settle();
|
||||
|
||||
const model = api.trackChangesPreviewController.getLastModel(docUri()) ?? [];
|
||||
const mermaidOp = model.find((o) => o.kind === "changed" && o.block.type === "mermaid");
|
||||
assert.ok(mermaidOp, "the flowchart block is a changed mermaid op");
|
||||
|
||||
const html = api.trackChangesPreviewController.renderHtmlFor(docUri());
|
||||
assert.match(html, /classDef cwAdded/, "augmented with cwAdded classDef");
|
||||
assert.match(html, /class\s+c\s+cwAdded/, "node c colored added");
|
||||
assert.match(html, /cw-mermaid-legend/, "legend emitted");
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import * as path from "path";
|
||||
import * as vscode from "vscode";
|
||||
import type { CowritingApi } from "../../../src/extension";
|
||||
import { undoWorks, UNDO_SKIP_REASON } from "./undoCapable";
|
||||
import { renderHtmlFor } from "./helpers";
|
||||
|
||||
const WS = process.env.E2E_WORKSPACE!;
|
||||
const settle = () => new Promise((r) => setTimeout(r, 400));
|
||||
@@ -11,10 +12,13 @@ const settle = () => new Promise((r) => setTimeout(r, 400));
|
||||
async function getApi(): Promise<CowritingApi> {
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||
const api = (await ext.activate()) as CowritingApi;
|
||||
assert.ok(api?.attributionController && api?.trackChangesPreviewController, "exports attribution + preview");
|
||||
assert.ok(api?.attributionController && api?.proposalController, "exports attribution + proposal");
|
||||
return api;
|
||||
}
|
||||
|
||||
/** Also enters coediting (INV-10/Task 2): the baseline is established only on
|
||||
* entry, not merely on open — needed for the preview's added/changed marks to
|
||||
* reflect a real diff rather than a vacuous baseline==current fallback. */
|
||||
async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> {
|
||||
const abs = path.join(WS, rel);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
@@ -22,6 +26,7 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo
|
||||
const uri = vscode.Uri.file(abs);
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settle();
|
||||
return { doc, key: uri.toString() };
|
||||
}
|
||||
@@ -79,7 +84,7 @@ suite("F10 #38 — undo does not mis-attribute restored text (host E2E, no LLM)"
|
||||
|
||||
// And the on-state render must not color 'bravo' as human-authored.
|
||||
// Added blocks use cw-ins-human (not cw-by-human) since Task 3/44ef0a2.
|
||||
const html = api.trackChangesPreviewController.renderHtmlFor(key);
|
||||
const html = renderHtmlFor(api, doc, key);
|
||||
const bravoColoredHuman = /cw-ins-human[^<]*bravo|<[^>]+class="[^"]*cw-ins-human[^"]*"[^>]*>[^<]*bravo/.test(html);
|
||||
assert.ok(!bravoColoredHuman, "restored 'bravo' is not colored cw-ins-human in the preview");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user