Official
tabs
The tabbed detail pane beside the chat: opened files and diffs (JavaScript UI plugin).
The app opens the listing; nothing installs until an agent in your Plugins workspace has read the files and you enable the plugin. In a terminal: cvg install convergence/tabs@0.2.0
Permissions in 0.2.0
Files
equivalence.test.ts37.9 KB
// Window-free equivalence scenarios for the three reference UI conversions.
// Real module imports exercise activation, trees, shared state and host-call order.
import assert from "node:assert/strict";
import { existsSync } from "node:fs";
import { test } from "node:test";
import * as z from "zod";
import type {
Api,
AppState,
AppStatePatch,
GitStatus,
HostEvent,
RenderResult,
SlotOptions,
ViewContext,
} from "convergence";
import { ui } from "convergence";
import { testLayer } from "convergence/effect/testing";
type Activate = (api: Api) => void | (() => Promise<void>);
const moduleSchema = z.object({ activate: z.custom<Activate>((value) => typeof value === "function") });
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
const stable = (value: unknown) =>
JSON.stringify(value, (_key, item: unknown) => (typeof item === "function" ? "@handler" : item));
const entry = (path: string, staged = " ", unstaged = "M", origPath?: string) => ({
path,
staged,
unstaged,
...(origPath ? { origPath } : {}),
});
// A service call or event both sides record in the same shape: the stub bus
// below logs the new code's real ones, and each fake's update() wrapper logs
// the baseline's shared writes as their replacements.
type ServiceEntry = {
op: string;
name: string;
method?: string;
event?: string;
payload?: unknown;
params?: unknown;
};
const repository = {
branch: "main",
upstream: "origin/main",
ahead: 1,
behind: 1,
isRepo: true,
entries: [
entry("src/a.ts", "M", "M"),
entry("new.ts", "?", "?"),
entry("gone.ts", " ", "D"),
entry("conflict.ts", "U", "U"),
entry("renamed.ts", "R", " ", "old.ts"),
],
};
function fakeUi(plugin: string, initial: AppState = {}) {
let state = initial;
const views: {
slot: string;
options?: SlotOptions;
render: (ctx: ViewContext<object>) => RenderResult;
active: boolean;
}[] = [];
const listeners = new Map<string, Set<(event: HostEvent) => unknown>>();
const appListeners = new Set<(state: AppState) => void>();
const commands = new Map<string, () => unknown>();
const calls: { method: string; params: unknown }[] = [];
const notices: string[] = [];
const focus: string[] = [];
const active = new Set<string>();
let dirty = true;
let failure: string | undefined;
let runCode = 0;
let status: GitStatus = repository;
let remotes = "backup\norigin\n";
let fileReply: unknown = { text: "# Fixture\nconst value = 1;" };
const held = new Map<string, ReturnType<typeof Promise.withResolvers<void>>>();
const cancelled: string[] = [];
const response = (method: string, params: unknown): unknown => {
if (failure === method) throw new Error("fixture failure");
switch (method) {
case "host/fs.read":
return fileReply;
case "host/fs.search":
return { paths: ["README.md", "src/a.ts", "src/z.ts", "package.json"] };
case "host/git.watch":
return {};
case "host/git.status":
return status;
case "host/git.diff":
return { diff: "--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1 +1 @@\n-old\n+new" };
case "host/git.run": {
const args = z.object({ args: z.array(z.string()) }).parse(params).args;
const stdout = args[0] === "branch" ? "main\nfeature\n" : args[0] === "remote" ? remotes : "";
return { stdout, stderr: runCode ? "fixture command failed" : "", code: runCode };
}
default:
throw new Error(`Unexpected host method ${method}`);
}
};
const call = async (method: string, params: unknown, options?: { signal?: AbortSignal }) => {
calls.push({ method, params });
const gate = held.get(method);
if (gate)
await new Promise<void>((resolve, reject) => {
const abort = () => {
cancelled.push(method);
reject(options?.signal?.reason);
};
options?.signal?.addEventListener("abort", abort, { once: true });
gate.promise.then(() => {
options?.signal?.removeEventListener("abort", abort);
resolve();
});
});
return response(method, params);
};
const update = (patch: AppStatePatch) => {
recordShared(patch);
state = {
...state,
...patch,
workspaceId: patch.workspaceId === null ? undefined : (patch.workspaceId ?? state.workspaceId),
chatId: patch.chatId === null ? undefined : (patch.chatId ?? state.chatId),
route: patch.route ?? state.route,
shared: { ...state.shared, ...patch.shared },
};
dirty = true;
for (const listener of appListeners) listener(state);
};
const broker = testLayer();
// Comparison only: the baseline's shared writes are recorded here as the
// service calls and events that replaced them, in the same shape the stub
// service bus below records the real ones in. Each rule mirrors a row of
// the harness mapping table: openTab/closeTab requests become tabs
// open/close calls (without the nonce), an activeTab write becomes the
// activeChanged event (activeTabPath rides along with it), and the
// null writes that only consumed a request are dropped with the keys.
const serviceLog: ServiceEntry[] = [];
const withoutNonce = (value: unknown) => {
if (typeof value === "object" && value !== null && "nonce" in value) {
const { nonce: _nonce, ...rest } = value as Record<string, unknown>;
return rest;
}
return value;
};
const recordShared = (patch: AppStatePatch) => {
const shared = patch.shared as Record<string, unknown> | undefined;
if (!shared) return;
const prev = (state.shared ?? {}) as Record<string, unknown>;
if (shared.openTab != null)
serviceLog.push({ op: "call", name: "convergence/tabs", method: "open", params: withoutNonce(shared.openTab) });
if (shared.closeTab != null)
serviceLog.push({ op: "call", name: "convergence/tabs", method: "close", params: withoutNonce(shared.closeTab) });
if (shared.activeTab !== undefined && (shared.activeTab != null || prev.activeTab !== undefined))
serviceLog.push({ op: "emit", name: "convergence/tabs", event: "activeChanged", payload: shared.activeTab });
};
// This assertion describes the locally constructed fake, not unchecked external data.
// Unused APIs fail on access; only the exercised UI/broker surface is implemented.
const serviceSubs = new Map<string, Set<(payload: unknown) => void>>();
const tabsClient = {
open: Object.assign(
async (params: { kind: string; workspaceId: string; path: string }) => ({
id: `${params.kind}:${params.workspaceId}:${params.path}`,
}),
{
// The Effect adapter calls through `result`, never the direct call.
result: async (params: { kind: string; workspaceId: string; path: string }) => {
serviceLog.push({ op: "call", name: "convergence/tabs", method: "open", params });
return { ok: true as const, value: { id: `${params.kind}:${params.workspaceId}:${params.path}` } };
},
},
),
close: Object.assign(async () => null, {
result: async (params: unknown) => {
serviceLog.push({ op: "call", name: "convergence/tabs", method: "close", params });
return { ok: true as const, value: null };
},
}),
active: Object.assign(async () => null, {
result: async () => {
serviceLog.push({ op: "call", name: "convergence/tabs", method: "active", params: {} });
return { ok: true as const, value: null };
},
}),
on: (event: string, fn: (payload: unknown) => void) => {
const key = `convergence/tabs|${event}`;
const set = serviceSubs.get(key) ?? new Set<(payload: unknown) => void>();
serviceSubs.set(key, set);
set.add(fn);
return () => {
set.delete(fn);
};
},
};
const servicesStub = {
provide: async (def: { name: string }) => {
serviceLog.push({ op: "provide", name: def.name });
return {
emit: (event: string, payload: unknown) => {
serviceLog.push({ op: "emit", name: def.name, event, payload });
for (const fn of serviceSubs.get(`${def.name}|${event}`) ?? []) fn(payload);
},
remove: () => {},
};
},
get: (name: string) => {
if (name === "convergence/tabs") return tabsClient;
throw new Error(`no provider for ${name}`);
},
available: (name: string) => name === "convergence/tabs",
when: (name: string, fn: (client: unknown) => void) => {
fn(name === "convergence/tabs" ? tabsClient : undefined);
return () => {};
},
};
const api = new Proxy(
{
...broker.api,
plugin,
id: `convergence/${plugin}`,
ui,
services: servicesStub,
host: {
...broker.api.host,
call,
kernel: call,
fs: {
read: (params: unknown) => call("host/fs.read", params),
search: (params: unknown) => call("host/fs.search", params),
},
git: Object.fromEntries(
["status", "watch", "run", "diff"].map((name) => [
name,
(params: unknown) => call(`host/git.${name}`, params),
]),
),
},
app: {
state: () => state,
update,
share: (key: string, value: unknown) => update({ shared: { [key]: value } }),
onChange: (listener: (state: AppState) => void) => {
appListeners.add(listener);
return () => {
appListeners.delete(listener);
};
},
},
slot: (slot: string, render: (ctx: ViewContext<object>) => RenderResult, options?: SlotOptions) => {
const view = { slot, render, options, active: true };
views.push(view);
active.add(`view:${views.length}`);
const id = views.length;
return {
id,
update: () => {
dirty = true;
},
remove: () => {
view.active = false;
active.delete(`view:${id}`);
},
};
},
on: (name: string, listener: (event: HostEvent) => unknown) => {
const subscribers = listeners.get(name) ?? new Set();
listeners.set(name, subscribers);
subscribers.add(listener);
return () => {
subscribers.delete(listener);
};
},
command: ({ id, run }: { id: string; run: () => unknown }) => {
commands.set(id, run);
active.add(id);
return {
remove: () => {
commands.delete(id);
active.delete(id);
},
};
},
notify: (message: string) => {
notices.push(message);
},
focus: (id: string) => {
focus.push(id);
},
},
{
get: (target, key, receiver) => {
if (Reflect.has(target, key)) return Reflect.get(target, key, receiver);
throw new Error(`Unexpected API ${String(key)}`);
},
},
) as unknown as Api;
const render = () =>
views
.filter((view) => view.active)
.map((view) => ({
slot: view.slot,
options: view.options,
tree: view.render({
state: {},
app: state,
plugin,
update: () => {
dirty = true;
},
}),
}));
let trees: ReturnType<typeof render> = [];
const settle = async () => {
for (let round = 0; round < 20; round++) {
await tick();
if (!dirty) {
await tick();
if (!dirty) return;
}
dirty = false;
trees = render();
}
throw new Error("UI did not settle");
};
const nodeSchema = z.object({ id: z.string().optional(), children: z.array(z.unknown()).optional() }).passthrough();
function nodes(tree: unknown): Record<string, unknown>[] {
if (Array.isArray(tree)) return tree.flatMap(nodes);
const parsed = nodeSchema.safeParse(tree);
if (!parsed.success) return [];
return [
parsed.data,
...(parsed.data.children ?? []).flatMap(nodes),
...(Array.isArray(parsed.data.items) ? parsed.data.items.flatMap(nodes) : []),
];
}
const invoke = (id: string, event = "onClick", payload: unknown = {}) => {
const node = trees.flatMap((view) => nodes(view.tree)).find((node) => node.id === id);
assert.ok(node, `Missing ${id}`);
const handler = z.custom<(payload: unknown) => unknown>((value) => typeof value === "function").parse(node[event]);
handler(payload);
};
return {
api,
views,
calls,
notices,
focus,
active,
appListeners,
listeners,
commands,
update,
settle,
invoke,
nodes: () => trees.flatMap((view) => nodes(view.tree)),
snapshot: () => stable({ trees, state, calls, notices, focus, serviceLog }),
tabsActive: (tab: { kind: string; path: string } | null) => {
// The new code follows the service event; the baseline reads the
// shared keys. Only one side can see each half.
const subs = serviceSubs.get("convergence/tabs|activeChanged");
if (subs?.size) {
// The stub bus stands in for the tabs provider here: its emit is
// recorded like a real one.
serviceLog.push({ op: "emit", name: "convergence/tabs", event: "activeChanged", payload: tab });
for (const fn of [...subs]) fn(tab);
} else {
update({
shared: tab
? { activeTab: tab, activeTabPath: tab.kind === "file" ? tab.path : null }
: { activeTab: null, activeTabPath: null },
});
}
},
serviceLog,
trees: () => trees,
state: () => state,
fail: (method?: string) => {
failure = method;
},
code: (code: number) => {
runCode = code;
},
status: (next: GitStatus) => {
status = next;
},
remotes: (value: string) => {
remotes = value;
},
fileReply: (value: unknown) => {
fileReply = value;
},
hold: (method: string) => {
held.set(method, Promise.withResolvers<void>());
},
release: (method: string) => {
const gate = held.get(method);
held.delete(method);
gate?.resolve();
},
cancelled,
emit: (event: HostEvent) => {
for (const name of [event.event, "*"]) for (const fn of listeners.get(name) ?? []) fn(event);
},
};
}
async function load(plugin: string, baseline: boolean): Promise<Activate> {
const path = baseline ? `../../target/js-baseline/${plugin}/main.js` : `../${plugin}/main.ts`;
// Both sides are actual modules; the baseline is never copied or rewritten.
const module: unknown = await import(new URL(path, import.meta.url).href);
return moduleSchema.parse(module).activate;
}
const hasBaseline = (plugin: string) =>
existsSync(new URL(`../../target/js-baseline/${plugin}/main.js`, import.meta.url));
// Deliberate changes since the JavaScript baseline, applied to its trees so
// everything else still has to match. The git message was a frameless
// input in a padded `muted` box beside a small primary button; it is now a
// plain input the kernel draws as a field (`crates/kernel/src/field.rs`),
// with a field-height button the same width under it.
const GIT_PRIMARY = new Set(["git-commit", "git-publish", "git-sync"]);
// A git section's rows, as the kernel's file tree the panel now draws: the
// path, the rename label, the status and its letter, and the row's buttons
// (none while a command runs, where the baseline disabled them).
const GIT_STATUS: Record<string, string> = {
M: "modified",
T: "modified",
A: "added",
D: "deleted",
R: "renamed",
C: "renamed",
U: "untracked",
"!": "conflict",
};
function gitTree(rows: Record<string, unknown>[]) {
const side = String(rows[0]?.["id"]).slice("git-row-".length).split(":")[0];
const files = rows.map((row) => {
const path = String(row["id"]).slice(`git-row-${side}:`.length);
const kids = (row["children"] ?? []) as Record<string, unknown>[];
const texts = kids.filter((kid) => kid["type"] === "text").map((kid) => String(kid["text"]));
const letter = texts[texts.length - 1] ?? "";
const label = texts[0] ?? "";
const strip = kids.find((kid) => kid["type"] === "div");
const buttons = ((strip?.["children"] ?? []) as Record<string, unknown>[]).filter(
(kid) => kid["type"] === "button",
);
return {
path,
...(label.includes(" → ") ? { label } : {}),
status: GIT_STATUS[letter] ?? "modified",
badge: letter,
actions: buttons.map((button) => ({
icon: button["icon"],
tooltip: button["tooltip"],
onClick: "@handler",
disabled: button["disabled"] === true,
})),
};
});
return { type: "tree", id: `git-tree-${side}`, fit: true, openFolders: true, files, onSelect: "@handler" };
}
// The files panel's tree, given flat by path now (the kernel makes the
// folders).
function leaves(items: Record<string, unknown>[]): { path: string }[] {
return items.flatMap((item) => {
const kids = (item["children"] ?? []) as Record<string, unknown>[];
return String(item["id"]).endsWith("/") ? leaves(kids) : [{ path: String(item["id"]) }];
});
}
function redesigned(node: unknown): void {
if (Array.isArray(node)) {
node.forEach((child, index) => {
const record = child as Record<string, unknown> | null;
const rows = record?.["children"];
if (
record?.["type"] === "div" &&
Array.isArray(rows) &&
rows.length > 0 &&
rows.every((row) => String((row as Record<string, unknown>)["id"]).startsWith("git-row-"))
) {
node[index] = gitTree(rows as Record<string, unknown>[]);
return;
}
const kids = record?.["children"];
const only = Array.isArray(kids) && kids.length === 1 ? (kids[0] as Record<string, unknown>) : null;
if (record?.["type"] === "div" && record["background"] === "muted" && only?.["id"] === "git-message") {
delete only["ghost"];
// A single-line field now: Return commits.
delete only["multiline"];
if (typeof only["placeholder"] === "string") only["placeholder"] = only["placeholder"].replace("⌘⏎", "⏎");
node[index] = only;
}
redesigned(node[index]);
});
return;
}
if (typeof node !== "object" || node === null) return;
const record = node as Record<string, unknown>;
if (record["type"] === "tree" && String(record["id"]).startsWith("files-") && Array.isArray(record["items"])) {
// In the new order of its props: files, then the selection.
const { selected, onSelect } = record;
const files = leaves(record["items"] as Record<string, unknown>[]);
for (const key of ["items", "selected", "onSelect"]) delete record[key];
Object.assign(record, { files, selected, onSelect });
}
// A git section's header is a taller muted heading on the tree's grid
// with the tree's chevron at its end, its hover buttons in the count's
// place; a line now separates two sections.
if (record["type"] === "div" && String(record["id"]).startsWith("git-section-")) {
record["gap"] = 6;
record["height"] = 30;
const kids = (record["children"] ?? []) as Record<string, unknown>[];
const icon = kids.findIndex((kid) => kid["type"] === "icon");
if (icon >= 0) kids.push(...kids.splice(icon, 1));
// The buttons and the count share a place: the count hides on hover.
const buttons = kids.findIndex((kid) => kid["type"] === "div" && kid["position"] === "absolute");
if (buttons >= 0 && kids[buttons + 1]?.["type"] === "text") {
const [strip, count] = kids.splice(buttons, 2) as Record<string, unknown>[];
strip!["right"] = 0;
kids.splice(buttons, 0, {
type: "div",
display: "flex",
flexDirection: "row",
alignItems: "center",
position: "relative",
height: "100%",
children: [strip, { type: "div", groupHover: { group: "hover", visibility: "hidden" }, children: [count] }],
});
}
for (const kid of kids) {
if (kid["type"] === "icon") {
kid["name"] = `tree-${String(kid["name"])}`;
delete kid["small"];
}
if (kid["type"] === "text" && kid["style"] === "section") {
const { text, truncate } = kid;
delete kid["text"];
delete kid["truncate"];
Object.assign(kid, { size: 11, tone: "muted", truncate, text });
}
}
}
if (Array.isArray(record["children"])) {
const isSection = (kid: unknown) =>
String(((kid as Record<string, unknown> | null)?.["children"] as Record<string, unknown>[] | undefined)?.[0]?.["id"]).startsWith(
"git-section-",
);
const kids = record["children"] as unknown[];
for (let i = kids.length - 1; i > 0; i--)
if (isSection(kids[i]) && isSection(kids[i - 1])) kids.splice(i, 0, { type: "divider" });
}
if (record["type"] === "button" && GIT_PRIMARY.has(String(record["id"]))) {
delete record["small"];
// An accent button now: the active selection colour.
record["variant"] = "accent";
}
for (const value of Object.values(record)) redesigned(value);
}
// A step on a git file row: the baseline's row or button by id, the new
// panel's tree (`onSelect`) or the file's action by its icon.
const ACTION_ICON: Record<string, string> = { open: "file", stage: "plus", unstage: "minus", discard: "undo-2" };
function gitRow(fake: ReturnType<typeof fakeUi>, key: string) {
if (fake.nodes().some((node) => node["id"] === `git-row-${key}`)) return fake.invoke(`git-row-${key}`);
const [side, ...rest] = key.split(":");
fake.invoke(`git-tree-${side}`, "onSelect", { id: rest.join(":"), folder: false });
}
function gitAct(fake: ReturnType<typeof fakeUi>, id: string) {
if (fake.nodes().some((node) => node["id"] === id)) return fake.invoke(id);
const [, verb, rest] = /^git-(open|stage|unstage|discard)-(.*)$/.exec(id) ?? [];
const [side, ...path] = String(rest).split(":");
const tree = fake.nodes().find((node) => node["id"] === `git-tree-${side}`);
assert.ok(tree, `Missing git-tree-${side}`);
const file = (tree["files"] as { path: string; actions: { icon: string; onClick: () => void }[] }[]).find(
(f) => f.path === path.join(":"),
);
const action = file?.actions.find((a) => a.icon === ACTION_ICON[String(verb)]);
assert.ok(action, `Missing ${id}`);
action.onClick();
}
async function compare(plugin: string, steps: ((fake: ReturnType<typeof fakeUi>) => void)[], initial: AppState = {}) {
const current = fakeUi(plugin, initial),
baseline = fakeUi(plugin, initial);
const dispose = (await load(plugin, false))(current.api);
(await load(plugin, true))(baseline.api);
assert.equal(current.views.length, plugin === "tabs" ? 1 : 2, "all views register before activate returns");
// The shared request keys are gone on the new side and the service log
// holds calls the baseline never makes: compare both sides without them.
// Reads (`active`) leave no trace on the baseline, so they go too.
const normalized = (fake: ReturnType<typeof fakeUi>) => {
const snap = JSON.parse(fake.snapshot()) as { state: AppState; serviceLog: ServiceEntry[] };
if (fake === baseline) redesigned(snap);
// A tree's files are sorted by the kernel: their order is not compared.
(function sortFiles(node: unknown): void {
if (Array.isArray(node)) return node.forEach(sortFiles);
if (typeof node !== "object" || node === null) return;
const record = node as Record<string, unknown>;
if (record["type"] === "tree" && Array.isArray(record["files"]))
(record["files"] as { path: string }[]).sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
Object.values(record).forEach(sortFiles);
})(snap);
const sharedState = snap.state as { shared?: Record<string, unknown> };
for (const key of ["openTab", "closeTab", "activeTab", "activeTabPath"]) {
if (sharedState.shared) delete sharedState.shared[key];
}
if (sharedState.shared && !Object.keys(sharedState.shared).length) delete sharedState.shared;
type Entry = { op: string; method?: string };
snap.serviceLog = snap.serviceLog.filter(
(entry: Entry) => entry.op !== "provide" && !(entry.op === "call" && entry.method === "active"),
) as ServiceEntry[];
return JSON.stringify(snap);
};
try {
await current.settle();
await baseline.settle();
assert.equal(normalized(current), normalized(baseline), "initial state");
for (const [index, step] of steps.entries()) {
current.calls.length = baseline.calls.length = 0;
current.serviceLog.length = baseline.serviceLog.length = 0;
step(current);
step(baseline);
await current.settle();
await baseline.settle();
assert.equal(normalized(current), normalized(baseline), `step ${index}`);
}
} finally {
if (dispose) await dispose();
}
}
const open =
(kind: string, path: string, nonce: number, extra: object = {}) =>
(fake: ReturnType<typeof fakeUi>) =>
fake.update({ shared: { openTab: { kind, path, workspaceId: "w1", nonce, ...extra } } });
test(
"tabs render and host calls equal the JS baseline across every content kind",
{ skip: !hasBaseline("tabs") },
async (ctx) => {
ctx.mock.method(Date, "now", () => 123);
await compare("tabs", [
open("file", "README.md", 1),
(fake) => fake.invoke("preview-file:w1:README.md"),
(fake) => fake.invoke("editor-file:w1:README.md"),
open("diff", "src/a.ts", 2, { diff: "" }),
open("diff", "src/a.ts", 3, { diff: "--- a\n+++ b\n" }),
open("terminal", "t1", 4),
open("view", "v1", 5, { title: "First", view: 10 }),
open("view", "v1", 6, { title: "Updated", icon: "file", view: 11 }),
(fake) => fake.update({ shared: { closeTab: { kind: "view", path: "v1", nonce: 7 } } }),
(fake) => fake.invoke("close-terminal:w1:t1"),
(fake) => fake.invoke("close-diff:w1:src/a.ts"),
(fake) =>
fake.emit({ event: "agent", chatId: "c1", kind: { event: "run_finished", outcome: { status: "completed" } } }),
(fake) => {
fake.fail("host/fs.read");
},
open("file", "error.txt", 8),
(fake) => fake.invoke("close-file:w1:error.txt"),
(fake) => fake.invoke("close-file:w1:README.md"),
]);
},
);
test(
"files render and host calls equal the JS baseline through panel and workspace changes",
{ skip: !hasBaseline("files") },
async (ctx) => {
ctx.mock.method(Date, "now", () => 123);
await compare("files", [
(fake) => fake.update({ shared: { rightPanelPreview: "files" } }),
(fake) => fake.update({ workspaceId: "w1" }),
(fake) => fake.invoke("files-search", "onChange", { value: " TS " }),
(fake) => fake.invoke("files-w1", "onSelect", { id: "src/", folder: true }),
(fake) => fake.invoke("files-w1", "onSelect", { id: "src/a.ts", folder: false }),
(fake) => fake.tabsActive({ kind: "file", path: "src/a.ts" }),
(fake) => fake.invoke("files-refresh"),
(fake) =>
fake.emit({ event: "agent", chatId: "c1", kind: { event: "run_finished", outcome: { status: "completed" } } }),
(fake) => fake.invoke("rail-files"),
(fake) => fake.update({ workspaceId: "w2" }),
(fake) => {
fake.fail("host/fs.search");
fake.invoke("files-refresh");
},
(fake) => fake.invoke("files-close"),
(fake) => fake.update({ shared: { rightPanelPreview: null } }),
]);
},
);
test(
"git render, menu, sections and command order equal the JS baseline",
{ skip: !hasBaseline("git") },
async (ctx) => {
ctx.mock.method(Date, "now", () => 123);
await compare("git", [
(fake) => fake.update({ workspaceId: "w1", shared: { rightPanel: "git" } }),
(fake) => fake.invoke("git-section-staged"),
(fake) => fake.invoke("git-section-staged"),
(fake) => gitRow(fake, "staged:renamed.ts"),
(fake) => gitRow(fake, "changes:src/a.ts"),
(fake) => gitAct(fake, "git-open-changes:src/a.ts"),
(fake) => gitAct(fake, "git-stage-changes:new.ts"),
(fake) => gitAct(fake, "git-unstage-staged:renamed.ts"),
(fake) => gitAct(fake, "git-discard-changes:new.ts"),
(fake) => gitAct(fake, "git-discard-changes:new.ts"),
(fake) => fake.invoke("git-message", "onChange", { value: " message " }),
(fake) => fake.invoke("git-commit"),
(fake) => fake.invoke("git-more"),
(fake) => fake.invoke("git-menu-fetch"),
(fake) => fake.invoke("git-branch", "onPress"),
(fake) => fake.invoke("git-branch-query", "onChange", { value: "new-branch" }),
(fake) => fake.invoke("git-branch-query", "onSubmit"),
(fake) => fake.invoke("git-branch", "onPress"),
(fake) => fake.invoke("git-branch-feature"),
(fake) => fake.emit({ event: "git_changed", workspaceId: "w1" }),
(fake) => fake.emit({ event: "workspaces_changed" }),
(fake) => {
fake.fail("host/git.status");
fake.invoke("git-refresh");
},
(fake) => {
fake.fail();
fake.invoke("git-refresh");
},
(fake) => fake.invoke("git-close"),
]);
},
);
for (const plugin of ["tabs", "files", "git"])
test(`${plugin} acquires and releases its views and subscriptions on unload`, async () => {
const fake = fakeUi(plugin);
const dispose = (await load(plugin, false))(fake.api);
assert.equal(fake.views.length, plugin === "tabs" ? 1 : 2);
const appSubscriptions = plugin === "git" ? 0 : 1;
assert.equal(fake.appListeners.size, appSubscriptions, "app subscriptions follow the baseline lifecycle");
assert.ok(
[...fake.listeners.values()].some((set) => set.size > 0),
"event subscriptions are ready before activate returns",
);
await fake.settle();
assert.equal(fake.appListeners.size, appSubscriptions);
assert.ok([...fake.listeners.values()].some((set) => set.size > 0));
assert.ok(dispose);
await dispose();
assert.equal(fake.active.size, 0);
assert.equal(fake.appListeners.size, 0);
assert.ok([...fake.listeners.values()].every((set) => set.size === 0));
});
test(
"git command fallbacks, smart commit and every menu action match the baseline",
{ skip: !hasBaseline("git") },
async (ctx) => {
ctx.mock.method(Date, "now", () => 123);
const changes = { ...repository, entries: [entry("tracked.ts"), entry("new.ts", "?", "?")] };
await compare(
"git",
[
(fake) => {
fake.status(changes);
fake.invoke("git-refresh");
},
(fake) => fake.invoke("git-message", "onChange", { value: "smart commit" }),
(fake) => fake.invoke("git-commit"),
(fake) => {
fake.code(1);
fake.invoke("git-message", "onChange", { value: "keep on failure" });
},
(fake) => fake.invoke("git-commit"),
(fake) => {
fake.code(0);
fake.invoke("git-message", "onChange", { value: "" });
},
...["pull", "push", "fetch", "amend", "undo", "stage-all", "unstage-all", "stash", "pop"].flatMap((action) => [
(fake: ReturnType<typeof fakeUi>) => fake.invoke("git-more"),
(fake: ReturnType<typeof fakeUi>) => fake.invoke(`git-menu-${action}`),
]),
(fake) => {
fake.status(repository);
fake.invoke("git-refresh");
},
(fake) => {
fake.code(1);
gitAct(fake, "git-unstage-staged:renamed.ts");
},
(fake) => {
fake.code(0);
fake.invoke("git-changes-discard-all");
},
(fake) => fake.invoke("git-changes-discard-all"),
(fake) => fake.invoke("git-merge-stage-all"),
(fake) => {
fake.status({ ...repository, entries: [] });
fake.invoke("git-refresh");
},
(fake) => fake.invoke("git-sync"),
(fake) => {
fake.status({ ...repository, entries: [], upstream: null });
fake.invoke("git-refresh");
},
(fake) => fake.invoke("git-publish"),
(fake) => {
fake.remotes("backup\n");
fake.invoke("git-publish");
},
(fake) => {
fake.remotes("");
fake.invoke("git-publish");
},
(fake) => {
fake.fail("host/git.diff");
fake.status(repository);
fake.invoke("git-refresh");
},
(fake) => gitRow(fake, "changes:src/a.ts"),
],
{ workspaceId: "w1", shared: { rightPanel: "git" } },
);
},
);
test("tabs accept close requests while a file is loading and cancel reads on unload", async () => {
const fake = fakeUi("tabs");
fake.hold("host/fs.read");
const dispose = (await load("tabs", false))(fake.api);
try {
await fake.settle();
open("file", "slow.ts", 1)(fake);
await fake.settle();
assert.equal(fake.calls.filter((call) => call.method === "host/fs.read").length, 1);
assert.ok(stable(fake.trees()).includes("Loading…"));
fake.update({ shared: { closeTab: { kind: "file", path: "slow.ts", nonce: 2 } } });
await fake.settle();
assert.equal(fake.trees()[0].tree, null);
assert.equal(fake.state().shared?.openTab, null);
assert.equal(fake.state().shared?.closeTab, null);
assert.ok(dispose);
await dispose();
assert.deepEqual(fake.cancelled, ["host/fs.read"]);
} finally {
fake.release("host/fs.read");
if (dispose) await dispose();
}
});
test("git coalesces refreshes and ignores a late status from the previous workspace", async () => {
const fake = fakeUi("git", { workspaceId: "w1", shared: { rightPanel: "git" } });
fake.hold("host/git.status");
const dispose = (await load("git", false))(fake.api);
try {
await fake.settle();
fake.emit({ event: "git_changed", workspaceId: "w1" });
fake.emit({ event: "workspaces_changed" });
fake.update({ workspaceId: "w2" });
await fake.settle();
assert.equal(fake.calls.filter((call) => call.method === "host/git.status").length, 1);
assert.ok(stable(fake.trees()).includes("Loading…"));
fake.release("host/git.status");
await fake.settle();
assert.deepEqual(
fake.calls.filter((call) => call.method === "host/git.status").map((call) => call.params),
[{ workspaceId: "w1" }, { workspaceId: "w2" }],
);
} finally {
fake.release("host/git.status");
if (dispose) await dispose();
}
});
test("files show loading immediately and unload cancels the outstanding search", async () => {
const fake = fakeUi("files", { workspaceId: "w1", shared: { rightPanel: "files" } });
fake.hold("host/fs.search");
const dispose = (await load("files", false))(fake.api);
try {
await fake.settle();
assert.ok(stable(fake.trees()).includes("Loading files…"));
assert.equal(fake.calls.length, 1);
assert.ok(dispose);
await dispose();
assert.deepEqual(fake.cancelled, ["host/fs.search"]);
} finally {
fake.release("host/fs.search");
if (dispose) await dispose();
}
});
test("git unload cancels a command without starting another status read", async () => {
const fake = fakeUi("git", { workspaceId: "w1", shared: { rightPanel: "git" } });
const dispose = (await load("git", false))(fake.api);
try {
await fake.settle();
fake.calls.length = 0;
fake.hold("host/git.run");
gitAct(fake, "git-stage-changes:new.ts");
await fake.settle();
assert.ok(stable(fake.trees()).includes("Staging"));
assert.ok(dispose);
await dispose();
assert.deepEqual(fake.cancelled, ["host/git.run"]);
assert.deepEqual(
fake.calls.map((call) => call.method),
["host/git.run"],
);
} finally {
fake.release("host/git.run");
if (dispose) await dispose();
}
});
test("malformed tab requests surface an error and the subscription accepts the next request", async () => {
const fake = fakeUi("tabs");
const dispose = (await load("tabs", false))(fake.api);
try {
await fake.settle();
fake.update({ shared: { openTab: { kind: "file", workspaceId: "w1", path: "a.ts", nonce: "invalid" } } });
await fake.settle();
assert.ok(fake.notices.some((notice) => notice.includes("openTab")));
assert.equal(fake.calls.length, 0);
open("file", "a.ts", 1)(fake);
await fake.settle();
assert.ok(stable(fake.trees()).includes("file-file:w1:a.ts"));
} finally {
if (dispose) await dispose();
}
});
test("tabs consume app requests delivered immediately after activate returns", async () => {
const fake = fakeUi("tabs");
const dispose = (await load("tabs", false))(fake.api);
try {
open("file", "immediate.ts", 1)(fake);
await fake.settle();
assert.equal(fake.state().shared?.openTab, null);
assert.ok(stable(fake.trees()).includes("file-file:w1:immediate.ts"));
} finally {
if (dispose) await dispose();
}
});
test("tabs render missing-file errors using the original QuickJS message", async () => {
const fake = fakeUi("tabs");
fake.fileReply(null);
const dispose = (await load("tabs", false))(fake.api);
try {
open("file", "missing.ts", 1)(fake);
await fake.settle();
assert.deepEqual(fake.calls, [{ method: "host/fs.read", params: { workspaceId: "w1", path: "missing.ts" } }]);
assert.ok(stable(fake.trees()).includes("cannot read property 'text' of null"));
assert.equal(fake.notices.length, 0);
fake.fileReply({ text: "recovered" });
fake.emit({ event: "agent", chatId: "c1", kind: { event: "run_finished", outcome: { status: "completed" } } });
await fake.settle();
assert.ok(stable(fake.trees()).includes("recovered"));
assert.ok(!stable(fake.trees()).includes("cannot read property"));
} finally {
if (dispose) await dispose();
}
});
test("git starts watching at the first rail render and reads status without waiting for watch", async () => {
const fake = fakeUi("git", { workspaceId: "w1", shared: { rightPanel: "git" } });
fake.hold("host/git.watch");
const dispose = (await load("git", false))(fake.api);
try {
assert.equal(fake.calls.length, 0, "activation only registers views and commands");
await fake.settle();
assert.deepEqual(fake.calls, [
{ method: "host/git.watch", params: { workspaceId: "w1" } },
{ method: "host/git.status", params: { workspaceId: "w1" } },
]);
assert.ok(stable(fake.trees()).includes('"path":"src/a.ts"'));
fake.update({ workspaceId: "w2" });
await fake.settle();
assert.deepEqual(fake.calls.slice(2), [
{ method: "host/git.watch", params: { workspaceId: "w2" } },
{ method: "host/git.status", params: { workspaceId: "w2" } },
]);
} finally {
fake.release("host/git.watch");
if (dispose) await dispose();
}
});Versions
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.2.0latest | Oct 5, 2026 | >=2 <3 | 18.8 KB | 3 permissions | Listed |
No comments yet.