mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-11 17:32:22 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29277b8e7c | ||
|
|
37e0715c58 | ||
|
|
80cfb7d01a |
No files matched your search
@@ -1493,9 +1493,10 @@ Examples:
|
||||
.addHelpText("after", `
|
||||
Siblings are untouched. A dead seat is refused (use rig seat clean). An adopted
|
||||
session is refused: to end only it, run rig unclaim <session> (it releases the
|
||||
session and leaves its process running), then end that process in its own
|
||||
terminal. rig down <rig> ends every seat in its rig, so it is the route only
|
||||
for a one-seat rig.
|
||||
session and leaves its process running), then end that process and the
|
||||
underlying tmux session, including any remaining shell. Closing only its
|
||||
viewing window leaves the tmux session running. rig down <rig> leaves a
|
||||
session OpenRig didn't start running.
|
||||
A seat handed over (fresh, fork or rebuild) before OpenRig 0.7.9 still reads
|
||||
adopted and is refused too, until its next launched handover or managed launch;
|
||||
the refusal says so. Stopping ends that agent's session and any work in progress:
|
||||
|
||||
@@ -21,12 +21,17 @@
|
||||
// per founder authorization 2026-05-10 (LICENSE only); 404 is the
|
||||
// expected normal-state response until a separate publish authorization.
|
||||
|
||||
import fs from "node:fs";
|
||||
import nodePath from "node:path";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
|
||||
export interface PluginVendorFs {
|
||||
readFile(path: string): string;
|
||||
writeFile(path: string, content: string): void;
|
||||
/** A file's raw bytes. With `writeBytes`, a plugin's files are copied and compared byte for byte, so a binary file
|
||||
* (an icon) arrives intact. Optional: without them files are read and written as text. */
|
||||
readBytes?(path: string): Uint8Array;
|
||||
writeBytes?(path: string, content: Uint8Array): void;
|
||||
/** Copy raw bytes, failing if the recovery path already exists. */
|
||||
copyFileExclusive(source: string, target: string): void;
|
||||
exists(path: string): boolean;
|
||||
@@ -160,7 +165,7 @@ export class PluginVendorService {
|
||||
const destPath = nodePath.join(targetDir, relPath);
|
||||
if (
|
||||
this.fs.exists(destPath) &&
|
||||
hashContent(this.fs.readFile(destPath)) === hashContent(this.fs.readFile(srcPath))
|
||||
hashContent(this.read(destPath)) === hashContent(this.read(srcPath))
|
||||
) {
|
||||
this.preserveMode(srcPath, destPath);
|
||||
}
|
||||
@@ -170,7 +175,7 @@ export class PluginVendorService {
|
||||
}
|
||||
}
|
||||
|
||||
const updates: Array<[string, string]> = this.fs.listFiles(sourceDir).map(rel => [rel, this.fs.readFile(nodePath.join(sourceDir, rel))]);
|
||||
const updates: Array<[string, FileContent]> = this.fs.listFiles(sourceDir).map(rel => [rel, this.read(nodePath.join(sourceDir, rel))]);
|
||||
if (!this.preserveBeforeRefresh(targetDir, updates)) return;
|
||||
this.fs.mkdirp(targetDir);
|
||||
|
||||
@@ -179,23 +184,34 @@ export class PluginVendorService {
|
||||
const destPath = nodePath.join(targetDir, relPath);
|
||||
// Hash-skip: only write if content differs (idempotent re-runs). Reconcile mode even on
|
||||
// skip — a byte-identical copy staged earlier may still carry the wrong (default) mode.
|
||||
if (this.fs.exists(destPath) && hashContent(this.fs.readFile(destPath)) === hashContent(content)) {
|
||||
if (this.fs.exists(destPath) && hashContent(this.read(destPath)) === hashContent(content)) {
|
||||
this.preserveMode(srcPath, destPath);
|
||||
continue;
|
||||
}
|
||||
this.fs.mkdirp(nodePath.dirname(destPath));
|
||||
this.fs.writeFile(destPath, content);
|
||||
this.write(destPath, content);
|
||||
this.preserveMode(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
|
||||
/** A plugin file as the fs reads it exactly: bytes when it can, else text. */
|
||||
private read(path: string): FileContent {
|
||||
return this.fs.readBytes ? this.fs.readBytes(path) : this.fs.readFile(path);
|
||||
}
|
||||
|
||||
private write(path: string, content: FileContent): void {
|
||||
if (typeof content === "string") this.fs.writeFile(path, content);
|
||||
else if (this.fs.writeBytes) this.fs.writeBytes(path, content);
|
||||
else this.fs.writeFile(path, Buffer.from(content).toString("utf-8"));
|
||||
}
|
||||
|
||||
/** Preserve every replaced file before touching a target, including its version authority. */
|
||||
private preserveBeforeRefresh(targetDir: string, updates: Array<[string, string]>): boolean {
|
||||
private preserveBeforeRefresh(targetDir: string, updates: Array<[string, FileContent]>): boolean {
|
||||
const backupDir = nodePath.join(nodePath.dirname(this.userPluginsDir), "backups", "managed-refresh", randomUUID());
|
||||
try {
|
||||
for (const [rel, content] of new Map(updates)) {
|
||||
const dest = nodePath.join(targetDir, rel);
|
||||
if (!this.fs.exists(dest) || hashContent(this.fs.readFile(dest)) === hashContent(content)) continue;
|
||||
if (!this.fs.exists(dest) || hashContent(this.read(dest)) === hashContent(content)) continue;
|
||||
const backup = nodePath.join(backupDir, rel);
|
||||
this.fs.mkdirp(nodePath.dirname(backup));
|
||||
this.fs.copyFileExclusive(dest, backup);
|
||||
@@ -276,7 +292,7 @@ export class PluginVendorService {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const updates: Array<[string, string]> = files.map(rel => [rel, this.fs.readFile(nodePath.join(sourceDir, rel))]);
|
||||
const updates: Array<[string, FileContent]> = files.map(rel => [rel, this.read(nodePath.join(sourceDir, rel))]);
|
||||
if (!this.preserveBeforeRefresh(targetDir, [...updates, [GLOBAL_VENDOR_VERSION, `${sourceVersion}\n`]])) continue;
|
||||
this.fs.mkdirp(targetDir);
|
||||
for (const [relPath, content] of updates) {
|
||||
@@ -284,13 +300,13 @@ export class PluginVendorService {
|
||||
const destPath = nodePath.join(targetDir, relPath);
|
||||
if (
|
||||
this.fs.exists(destPath) &&
|
||||
hashContent(this.fs.readFile(destPath)) === hashContent(content)
|
||||
hashContent(this.read(destPath)) === hashContent(content)
|
||||
) {
|
||||
this.preserveMode(srcPath, destPath);
|
||||
continue;
|
||||
}
|
||||
this.fs.mkdirp(nodePath.dirname(destPath));
|
||||
this.fs.writeFile(destPath, content);
|
||||
this.write(destPath, content);
|
||||
this.preserveMode(srcPath, destPath);
|
||||
}
|
||||
this.fs.writeFile(versionMarker, `${sourceVersion}\n`);
|
||||
@@ -339,6 +355,37 @@ export class PluginVendorService {
|
||||
}
|
||||
}
|
||||
|
||||
function hashContent(content: string): string {
|
||||
/** A plugin file's content: its bytes, or its text from an fs that only reads text. */
|
||||
type FileContent = string | Uint8Array;
|
||||
|
||||
/** The same digest for a text and for its UTF-8 bytes, so a text compare and a byte compare agree. */
|
||||
function hashContent(content: FileContent): string {
|
||||
return createHash("sha256").update(content).digest("hex");
|
||||
}
|
||||
|
||||
/** The daemon's own filesystem for vendoring: files are copied and compared as bytes. */
|
||||
export function nodePluginVendorFs(): PluginVendorFs {
|
||||
return {
|
||||
readFile: (p) => fs.readFileSync(p, "utf-8"),
|
||||
writeFile: (p, c) => fs.writeFileSync(p, c, "utf-8"),
|
||||
readBytes: (p) => fs.readFileSync(p),
|
||||
writeBytes: (p, c) => fs.writeFileSync(p, c),
|
||||
copyFileExclusive: (src, dest) => fs.copyFileSync(src, dest, fs.constants.COPYFILE_EXCL),
|
||||
exists: (p) => fs.existsSync(p),
|
||||
mkdirp: (p) => { fs.mkdirSync(p, { recursive: true }); },
|
||||
listFiles: (dir) => {
|
||||
const r: string[] = [];
|
||||
/** Collect relative file paths recursively. */
|
||||
const w = (d: string, pre: string) => {
|
||||
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
||||
if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name));
|
||||
else r.push(pre ? nodePath.join(pre, e.name) : e.name);
|
||||
}
|
||||
};
|
||||
w(dir, "");
|
||||
return r;
|
||||
},
|
||||
statMode: (p) => fs.statSync(p).mode,
|
||||
chmod: (p, m) => fs.chmodSync(p, m),
|
||||
};
|
||||
}
|
||||
@@ -73,7 +73,8 @@ const SEAT_LOOKUP_GUIDANCE = "List seats with: rig ps --nodes";
|
||||
|
||||
/**
|
||||
* The supported route for a session registered as adopted (origin=claimed): `rig unclaim` releases
|
||||
* it and leaves its process running, so ending that process in its own terminal ends only this seat.
|
||||
* it and leaves its process running, so ending that process and its tmux session ends only this seat. Closing only
|
||||
* a viewing window detaches from the tmux session and leaves it running.
|
||||
* `rig down` stops only a session carrying the seat's OpenRig marker, which OpenRig sets on the sessions
|
||||
* it starts and an adopted, bound or self-attached session lacks, so it isn't a route here and the
|
||||
* guidance says why. Only the guidance names the route; the refusal stands. A seat handed over before
|
||||
@@ -85,9 +86,13 @@ export function claimedSessionRoutes(rigName: string, sessionName: string, exter
|
||||
const release = `To release it from OpenRig and leave its process running, run rig unclaim ${session}.`;
|
||||
if (externalCli) return `This seat was joined with rig attach as an external CLI, so rig down leaves it running. ${release} End the process in its own terminal.`;
|
||||
return `To end this session, run rig unclaim ${session} (it releases the session from OpenRig and leaves its process running), `
|
||||
+ `then end that process in its own terminal. ${rigDownLeavesIt(rigName)}`;
|
||||
+ `then end that process and the underlying tmux session, including any remaining shell, so no session keeps the seat's name. `
|
||||
+ `${VIEWER_LEAVES_IT} ${rigDownLeavesIt(rigName)}`;
|
||||
}
|
||||
|
||||
/** Closing a viewer detaches; the tmux session, its shell and the seat's name stay. */
|
||||
const VIEWER_LEAVES_IT = "Closing only its viewing window leaves the tmux session running.";
|
||||
|
||||
/** Why `rig down <rig>` isn't the route for an adopted session. */
|
||||
function rigDownLeavesIt(rigName: string): string {
|
||||
return `rig down ${shellQuote(rigName)} leaves a session OpenRig didn't start running.`;
|
||||
@@ -1381,6 +1386,8 @@ export class SeatLifecycleService {
|
||||
ok: false,
|
||||
code: "unmanaged_session_collision",
|
||||
message: `Canonical tmux session "${canonicalSessionName}" exists but is not owned by this seat's current managed rows; refusing to overwrite it.`,
|
||||
guidance: "If you released this session with rig unclaim, end that underlying tmux session, including any remaining shell, "
|
||||
+ "then launch again. Closing only its viewing window doesn't end the session.",
|
||||
};
|
||||
}
|
||||
if (!stop) {
|
||||
@@ -1424,8 +1431,10 @@ export class SeatLifecycleService {
|
||||
return {
|
||||
ok: false, code: "claimed_session",
|
||||
message: `Seat "${seatRef}" has an adopted/operator-owned occupant; fresh launch refuses even with --stop.`,
|
||||
guidance: `Release it with rig unclaim ${shellQuote(claimed.session_name)} (its process keeps running), end that process yourself, then launch fresh.`
|
||||
+ (externalCli ? "" : ` ${rigDownLeavesIt(seat.rigName)}`),
|
||||
guidance: `Release it with rig unclaim ${shellQuote(claimed.session_name)} (its process keeps running), `
|
||||
+ (externalCli
|
||||
? "end that process and close its terminal, then launch fresh."
|
||||
: `end that process and the underlying tmux session, including any remaining shell, then launch fresh. ${VIEWER_LEAVES_IT} ${rigDownLeavesIt(seat.rigName)}`),
|
||||
};
|
||||
}
|
||||
const binding = this.sessionRegistry.getBindingForNode(nodeId);
|
||||
|
||||
@@ -1049,30 +1049,9 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
// (repo currently empty as of 2026-05-10; vendored
|
||||
// copy is the source of truth at v0).
|
||||
try {
|
||||
const { PluginVendorService } = await import("./domain/plugin-vendor-service.js");
|
||||
const { PluginVendorService, nodePluginVendorFs } = await import("./domain/plugin-vendor-service.js");
|
||||
const vendoredAssetsDir = nodePath.resolve(import.meta.dirname, "../assets/plugins");
|
||||
const userPluginsDir = getDefaultOpenRigPath("plugins");
|
||||
const realFs = {
|
||||
readFile: (p: string) => fs.readFileSync(p, "utf-8"),
|
||||
writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"),
|
||||
copyFileExclusive: (src: string, dest: string) => fs.copyFileSync(src, dest, fs.constants.COPYFILE_EXCL),
|
||||
exists: (p: string) => fs.existsSync(p),
|
||||
mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }),
|
||||
listFiles: (dir: string) => {
|
||||
const r: string[] = [];
|
||||
/** Collect relative file paths recursively. */
|
||||
function w(d: string, pre: string) {
|
||||
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
||||
if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name));
|
||||
else r.push(pre ? nodePath.join(pre, e.name) : e.name);
|
||||
}
|
||||
}
|
||||
w(dir, "");
|
||||
return r;
|
||||
},
|
||||
statMode: (p: string) => fs.statSync(p).mode,
|
||||
chmod: (p: string, m: number) => fs.chmodSync(p, m),
|
||||
};
|
||||
const httpClient = async (url: string, opts?: { timeoutMs?: number }) => {
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), opts?.timeoutMs ?? 5000);
|
||||
@@ -1086,7 +1065,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
const vendorService = new PluginVendorService({
|
||||
vendoredAssetsDir,
|
||||
userPluginsDir,
|
||||
fs: realFs,
|
||||
fs: nodePluginVendorFs(),
|
||||
httpClient,
|
||||
logger: (...args) => console.log("[openrig]", ...args),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// Vendoring copies a bundled plugin into ~/.openrig/plugins byte for byte, binary files included: a plugin's icon.png
|
||||
// must arrive exactly as it ships, and a text file must arrive as it always did.
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import nodePath from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { nodePluginVendorFs, PluginVendorService } from "../src/domain/plugin-vendor-service.js";
|
||||
|
||||
const dirs: string[] = [];
|
||||
afterEach(() => { for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); });
|
||||
|
||||
/** A PNG header and bytes that aren't valid UTF-8, so a text round trip would change them. */
|
||||
const ICON = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.from([0xff, 0xfe, 0x80, 0x00, 0xc3, 0x28, 0xa0, 0xa1])]);
|
||||
const sha = (bytes: Buffer) => createHash("sha256").update(bytes).digest("hex");
|
||||
const httpClient = async () => ({ ok: false, status: 404 });
|
||||
|
||||
function scratch(): { assets: string; plugins: string } {
|
||||
const root = fs.mkdtempSync(nodePath.join(os.tmpdir(), "openrig-vendor-binary-"));
|
||||
dirs.push(root);
|
||||
return { assets: nodePath.join(root, "assets"), plugins: nodePath.join(root, "home", "plugins") };
|
||||
}
|
||||
|
||||
function shipPlugin(assets: string, version: string, icon = ICON): void {
|
||||
const plugin = nodePath.join(assets, "openrig");
|
||||
fs.mkdirSync(nodePath.join(plugin, ".claude-plugin"), { recursive: true });
|
||||
fs.mkdirSync(nodePath.join(plugin, "skills", "demo"), { recursive: true });
|
||||
fs.writeFileSync(nodePath.join(plugin, ".claude-plugin", "plugin.json"), JSON.stringify({ name: "openrig", version, description: "Team — work" }));
|
||||
fs.writeFileSync(nodePath.join(plugin, ".claude-plugin", "icon.png"), icon);
|
||||
fs.writeFileSync(nodePath.join(plugin, "skills", "demo", "SKILL.md"), "# Demo — a skill\n");
|
||||
fs.writeFileSync(nodePath.join(plugin, "skills", "demo", "badge.png"), icon);
|
||||
}
|
||||
|
||||
const service = (assets: string, plugins: string) =>
|
||||
new PluginVendorService({ vendoredAssetsDir: assets, userPluginsDir: plugins, fs: nodePluginVendorFs(), httpClient });
|
||||
|
||||
describe("vendoring a plugin with a binary file", () => {
|
||||
it("installs the icon byte for byte, and its text files as before", async () => {
|
||||
const { assets, plugins } = scratch();
|
||||
shipPlugin(assets, "0.1.0");
|
||||
await service(assets, plugins).ensureVendored("openrig");
|
||||
const installed = fs.readFileSync(nodePath.join(plugins, "openrig", ".claude-plugin", "icon.png"));
|
||||
expect(sha(installed)).toBe(sha(ICON));
|
||||
expect(fs.readFileSync(nodePath.join(plugins, "openrig", "skills", "demo", "SKILL.md"), "utf-8")).toBe("# Demo — a skill\n");
|
||||
});
|
||||
|
||||
it("leaves an identical install alone at the same version, and refreshes a newer one exactly, keeping the old bytes", async () => {
|
||||
const { assets, plugins } = scratch();
|
||||
shipPlugin(assets, "0.1.0");
|
||||
await service(assets, plugins).ensureVendored("openrig");
|
||||
await service(assets, plugins).ensureVendored("openrig");
|
||||
const backups = nodePath.join(nodePath.dirname(plugins), "backups");
|
||||
expect(fs.existsSync(backups)).toBe(false);
|
||||
|
||||
const newer = Buffer.concat([ICON, Buffer.from([0xfd, 0xbe])]);
|
||||
fs.rmSync(assets, { recursive: true });
|
||||
shipPlugin(assets, "0.2.0", newer);
|
||||
await service(assets, plugins).ensureVendored("openrig");
|
||||
expect(sha(fs.readFileSync(nodePath.join(plugins, "openrig", ".claude-plugin", "icon.png")))).toBe(sha(newer));
|
||||
const kept = fs.readdirSync(nodePath.join(backups, "managed-refresh"))
|
||||
.map((id) => nodePath.join(backups, "managed-refresh", id, ".claude-plugin", "icon.png"))
|
||||
.filter((path) => fs.existsSync(path));
|
||||
expect(kept.map((path) => sha(fs.readFileSync(path)))).toEqual([sha(ICON)]);
|
||||
});
|
||||
|
||||
it("projects a skill's binary file into a global skill root byte for byte", () => {
|
||||
const { assets, plugins } = scratch();
|
||||
shipPlugin(assets, "0.1.0");
|
||||
const root = nodePath.join(nodePath.dirname(plugins), "global-skills");
|
||||
service(assets, plugins).ensureSkillDirGlobally(nodePath.join(assets, "openrig", "skills", "demo"), "demo", "0.1.0", [root]);
|
||||
expect(sha(fs.readFileSync(nodePath.join(root, "demo", "badge.png")))).toBe(sha(ICON));
|
||||
expect(fs.readFileSync(nodePath.join(root, "demo", "SKILL.md"), "utf-8")).toBe("# Demo — a skill\n");
|
||||
});
|
||||
});
|
||||
@@ -499,7 +499,8 @@ describe("SeatLifecycleService.launchFresh", () => {
|
||||
code: "claimed_session",
|
||||
message: `Seat "${seat.sessionName}" has an adopted/operator-owned occupant; fresh launch refuses even with --stop.`,
|
||||
// B5: rig unclaim releases it (the process keeps running); rig down leaves a session OpenRig didn't start.
|
||||
guidance: "Release it with rig unclaim 'dev-impl@fresh-rig' (its process keeps running), end that process yourself, then launch fresh. "
|
||||
guidance: "Release it with rig unclaim 'dev-impl@fresh-rig' (its process keeps running), end that process and the underlying tmux session, "
|
||||
+ "including any remaining shell, then launch fresh. Closing only its viewing window leaves the tmux session running. "
|
||||
+ "rig down 'fresh-rig' leaves a session OpenRig didn't start running.",
|
||||
};
|
||||
const before = db.serialize();
|
||||
@@ -686,6 +687,11 @@ describe("SeatLifecycleService.launchFresh", () => {
|
||||
alive.add(seat.sessionName);
|
||||
const result = await service.launchFresh({ seatRef: "dev.impl", fresh: true, stop: true, reason: "x" });
|
||||
expect(result).toMatchObject({ ok: false, code: "unmanaged_session_collision" });
|
||||
// A session someone released with rig unclaim and left open is the usual cause: say how to clear it.
|
||||
expect(result).toMatchObject({
|
||||
guidance: "If you released this session with rig unclaim, end that underlying tmux session, including any remaining shell, "
|
||||
+ "then launch again. Closing only its viewing window doesn't end the session.",
|
||||
});
|
||||
expect(killed).toEqual([]);
|
||||
expect(alive.has(seat.sessionName)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -447,7 +447,7 @@ describe("SeatLifecycleService", () => {
|
||||
// the refusal names the supported route, rig unclaim, and says rig down doesn't stop a session OpenRig didn't start,
|
||||
// even in a one-seat rig.
|
||||
expect(claimedResult.guidance).toBe("To end this session, run rig unclaim 'dev-adopted@s5-rig' (it releases the session from OpenRig "
|
||||
+ "and leaves its process running), then end that process in its own terminal. rig down 's5-rig' leaves a session OpenRig didn't start running.");
|
||||
+ "and leaves its process running), then end that process and the underlying tmux session, including any remaining shell, so no session keeps the seat's name. Closing only its viewing window leaves the tmux session running. rig down 's5-rig' leaves a session OpenRig didn't start running.");
|
||||
|
||||
const probed = seatFixture("s5-rig", "dev.flaky");
|
||||
tmux.failProbeFor(probed.sessionName);
|
||||
@@ -471,7 +471,7 @@ describe("SeatLifecycleService", () => {
|
||||
const shared = await service.stopSeat({ seatRef: claimed.sessionName, reason: "x" });
|
||||
if (shared.ok) throw new Error("expected refusal");
|
||||
expect(shared.guidance).toBe("To end this session, run rig unclaim 'dev-adopted@adopted-rig' (it releases the session from OpenRig "
|
||||
+ "and leaves its process running), then end that process in its own terminal. rig down 'adopted-rig' leaves a session OpenRig didn't start running.");
|
||||
+ "and leaves its process running), then end that process and the underlying tmux session, including any remaining shell, so no session keeps the seat's name. Closing only its viewing window leaves the tmux session running. rig down 'adopted-rig' leaves a session OpenRig didn't start running.");
|
||||
|
||||
sessionRegistry.updateBinding(claimed.node.id, { attachmentType: "external_cli", externalSessionName: claimed.sessionName });
|
||||
const external = await service.stopSeat({ seatRef: claimed.sessionName, reason: "x" });
|
||||
@@ -485,7 +485,7 @@ describe("SeatLifecycleService", () => {
|
||||
|
||||
it("claimedSessionRoutes routes a tmux session through rig unclaim, never rig down", () => {
|
||||
expect(claimedSessionRoutes("r", "a@r")).toBe("To end this session, run rig unclaim 'a@r' (it releases the session from OpenRig "
|
||||
+ "and leaves its process running), then end that process in its own terminal. rig down 'r' leaves a session OpenRig didn't start running.");
|
||||
+ "and leaves its process running), then end that process and the underlying tmux session, including any remaining shell, so no session keeps the seat's name. Closing only its viewing window leaves the tmux session running. rig down 'r' leaves a session OpenRig didn't start running.");
|
||||
});
|
||||
|
||||
// ---- P5 / P6 — clean ----
|
||||
|
||||
@@ -636,10 +636,10 @@ describe("rig seat restart --keep-conversation: gates before any signal", () =>
|
||||
w.db.prepare("UPDATE sessions SET origin = 'claimed' WHERE id = ?").run(w.session.id);
|
||||
const claimed = await w.service.restartInPlace({ seatRef: SEAT, plan: true });
|
||||
expect(claimed).toMatchObject({ ok: false, code: "claimed_session" });
|
||||
// B5: the refusal's route is rig unclaim, then ending the process in its own terminal; rig down leaves a session
|
||||
// B5: the refusal's route is rig unclaim, then ending the process and its tmux session; rig down leaves a session
|
||||
// OpenRig didn't start running, so it isn't offered as the way to end this one.
|
||||
const guidance = (claimed as { guidance?: string }).guidance ?? "";
|
||||
expect(guidance).toMatch(/^To end this session, run rig unclaim '[^']+' \(it releases the session from OpenRig and leaves its process running\), then end that process in its own terminal\. /);
|
||||
expect(guidance).toMatch(/^To end this session, run rig unclaim '[^']+' \(it releases the session from OpenRig and leaves its process running\), then end that process and the underlying tmux session, including any remaining shell, so no session keeps the seat's name\. Closing only its viewing window leaves the tmux session running\. /);
|
||||
expect(guidance).toMatch(/rig down '[^']+' leaves a session OpenRig didn't start running\.$/);
|
||||
expect(guidance).not.toMatch(/run rig down/);
|
||||
});
|
||||
|
||||
+49
-30
@@ -48,6 +48,8 @@ interface ContentLine {
|
||||
zones?: Array<{ start: number; end: number; action: Action }>;
|
||||
/** slice-17: token segments for canvas-rendered rows (graph view) */
|
||||
segs?: Array<{ text: string; token?: import("./theme.js").Token; bold?: boolean; bg?: import("./theme.js").Token }>;
|
||||
/** Row of a labelled group. The first visible row of each group shows `text` when set, so a scrolled group keeps its label. */
|
||||
sticky?: { group: string; text?: string };
|
||||
}
|
||||
|
||||
function pad(text: string | number | null | undefined, width: number): string {
|
||||
@@ -94,17 +96,24 @@ function agentColumns(contentWidth: number): AgentColumn[] {
|
||||
];
|
||||
}
|
||||
|
||||
function instanceAgentColumns(contentWidth: number): AgentColumn[] {
|
||||
if (contentWidth >= 110) return [
|
||||
["rig", "RIG", 13, "left"], ["pod", "POD", 8, "left"], ["seat", "SEAT", 14, "left"],
|
||||
["runtime", "RT", 3, "left"], ["context", "CTX", 7, "right"], ["status", "STATE", 10, "left"],
|
||||
["queue", "Q", 2, "right"], ["work", "WORK", 12, "left"], ["now", "NOW", Math.max(18, contentWidth - 78), "left"],
|
||||
];
|
||||
if (contentWidth >= 78) return [
|
||||
["rig", "RIG", 10, "left"], ["pod", "POD", 6, "left"], ["seat", "SEAT", 12, "left"],
|
||||
["runtime", "RT", 3, "left"], ["context", "CTX", 5, "right"], ["status", "STATE", 9, "left"],
|
||||
["queue", "Q", 2, "right"], ["work", "WORK", Math.max(8, contentWidth - 54), "left"],
|
||||
];
|
||||
/** RIG fits the longest rig name shown, between the tier's old width and its cap; NOW or WORK gives up the difference. */
|
||||
function instanceAgentColumns(contentWidth: number, longestRigName = 0): AgentColumn[] {
|
||||
if (contentWidth >= 110) {
|
||||
const rig = Math.min(Math.max(longestRigName, 13), 24);
|
||||
return [
|
||||
["rig", "RIG", rig, "left"], ["pod", "POD", 8, "left"], ["seat", "SEAT", 14, "left"],
|
||||
["runtime", "RT", 3, "left"], ["context", "CTX", 7, "right"], ["status", "STATE", 10, "left"],
|
||||
["queue", "Q", 2, "right"], ["work", "WORK", 12, "left"], ["now", "NOW", Math.max(18, contentWidth - 78 - (rig - 13)), "left"],
|
||||
];
|
||||
}
|
||||
if (contentWidth >= 78) {
|
||||
const rig = Math.min(Math.max(longestRigName, 10), 16);
|
||||
return [
|
||||
["rig", "RIG", rig, "left"], ["pod", "POD", 6, "left"], ["seat", "SEAT", 12, "left"],
|
||||
["runtime", "RT", 3, "left"], ["context", "CTX", 5, "right"], ["status", "STATE", 9, "left"],
|
||||
["queue", "Q", 2, "right"], ["work", "WORK", Math.max(8, contentWidth - 54 - (rig - 10)), "left"],
|
||||
];
|
||||
}
|
||||
const fixed = 8 + 5 + 11 + 2 + 5 + 8 + 2 + 7;
|
||||
return [
|
||||
["rig", "RIG", 8, "left"], ["pod", "POD", 5, "left"], ["seat", "SEAT", 11, "left"],
|
||||
@@ -709,7 +718,7 @@ function noRigsText(snap: FleetSnapshot, host: FleetSnapshot["hosts"][number], l
|
||||
: ` (no rigs served by ${host.name})`;
|
||||
}
|
||||
|
||||
/** Rig boundaries take a solid rule and pod boundaries a dotted one; RIG repeats on every row so scrolled rows keep it. */
|
||||
/** Rig boundaries take a solid rule and pod boundaries a dotted one; RIG shows on a rig's first visible row so scrolled rows keep it. */
|
||||
function instanceContentLines(
|
||||
state: ViewState,
|
||||
snap: FleetSnapshot,
|
||||
@@ -801,7 +810,12 @@ function instanceContentLines(
|
||||
lines.push(healthSummaryLine(snap, scope, contentWidth));
|
||||
lines.push(...queueSampleLines(snap, contentWidth));
|
||||
lines.push({ text: state.filter ? `/ filter instance rows: ${state.filter} · / replace · esc clear` : "/ filter instance rows…" });
|
||||
const columns = instanceAgentColumns(contentWidth);
|
||||
const shownRigs = host.rigs.map((rig) => ({
|
||||
rig,
|
||||
agents: rig.pods.flatMap((pod) => pod.agents.map((agent) => ({ pod: pod.name, agent })))
|
||||
.filter(({ pod, agent }) => !state.filter || rig.name.includes(state.filter) || pod.includes(state.filter) || agent.name.includes(state.filter)),
|
||||
})).filter(({ rig, agents }) => !state.filter || agents.length > 0 || rig.name.includes(state.filter));
|
||||
const columns = instanceAgentColumns(contentWidth, Math.max(0, ...shownRigs.map(({ rig }) => rig.name.length)));
|
||||
lines.push({ text: tableRow(columns, { rig: "RIG", pod: "POD", seat: "SEAT", runtime: "RT", context: "CTX", status: "STATE", queue: "Q", work: "WORK", now: "NOW" }) });
|
||||
lines.push({ text: "━".repeat(columnsWidth(columns)) });
|
||||
let seatCount = 0;
|
||||
@@ -810,10 +824,7 @@ function instanceContentLines(
|
||||
let attentionUnknown = 0;
|
||||
let openCount = 0;
|
||||
let firstRig = true;
|
||||
for (const rig of host.rigs) {
|
||||
const agents = rig.pods.flatMap((pod) => pod.agents.map((agent) => ({ pod: pod.name, agent })))
|
||||
.filter(({ pod, agent }) => !state.filter || rig.name.includes(state.filter) || pod.includes(state.filter) || agent.name.includes(state.filter));
|
||||
if (state.filter && agents.length === 0 && !rig.name.includes(state.filter)) continue;
|
||||
for (const { rig, agents } of shownRigs) {
|
||||
if (!firstRig) lines.push({ text: "─".repeat(columnsWidth(columns)) });
|
||||
firstRig = false;
|
||||
const rigAction: Action = { type: "drill", resource: "rig", name: rig.name, target: { host: host.name } };
|
||||
@@ -825,7 +836,7 @@ function instanceContentLines(
|
||||
continue;
|
||||
}
|
||||
let previousPod: string | null = null;
|
||||
for (const { pod, agent } of agents) {
|
||||
for (const [index, { pod, agent }] of agents.entries()) {
|
||||
const firstInPod = pod !== previousPod;
|
||||
if (firstInPod && previousPod != null) lines.push({ text: "┈".repeat(columnsWidth(columns)) });
|
||||
previousPod = pod;
|
||||
@@ -836,18 +847,21 @@ function instanceContentLines(
|
||||
if (agent.needsAttention === true) attentionCount += 1;
|
||||
else if (agent.needsAttention == null) attentionUnknown += 1;
|
||||
openCount += queue.count;
|
||||
const cells = {
|
||||
rig: rig.name,
|
||||
pod: firstInPod ? pod : "",
|
||||
seat: `${stateCell.mark} ${seatName(pod, agent.name)}`,
|
||||
runtime: runtimeShort(agent.runtime),
|
||||
context: contextCompact(agent.context, contentWidth < 110),
|
||||
status: stateCell.word,
|
||||
queue: queueCount(snap, queue.count),
|
||||
work: queue.work,
|
||||
now: queue.now,
|
||||
};
|
||||
const named = tableRow(columns, cells);
|
||||
lines.push({
|
||||
text: tableRow(columns, {
|
||||
rig: rig.name,
|
||||
pod: firstInPod ? pod : "",
|
||||
seat: `${stateCell.mark} ${seatName(pod, agent.name)}`,
|
||||
runtime: runtimeShort(agent.runtime),
|
||||
context: contextCompact(agent.context, contentWidth < 110),
|
||||
status: stateCell.word,
|
||||
queue: queueCount(snap, queue.count),
|
||||
work: queue.work,
|
||||
now: queue.now,
|
||||
}),
|
||||
text: index === 0 ? named : tableRow(columns, { ...cells, rig: "" }),
|
||||
sticky: { group: `rig:${rig.name}`, ...(index === 0 ? {} : { text: named }) },
|
||||
action: { type: "drill", resource: "agent", name: agent.name, target: { host: host.name, rig: rig.name, pod } },
|
||||
zones: [{ start: 0, end: columns[0]![2], action: rigAction }],
|
||||
});
|
||||
@@ -1944,7 +1958,12 @@ function renderBody(state: ViewState, snap: FleetSnapshot, options: RenderOption
|
||||
const contentRows = content.length > bodyRows ? Math.max(bodyRows - 1, 0) : bodyRows;
|
||||
const maxContentOffset = Math.max(content.length - contentRows, 0);
|
||||
const contentStart = Math.min(state.contentOffset, maxContentOffset);
|
||||
const visibleContent = content.slice(contentStart, contentStart + contentRows);
|
||||
const labelledGroups = new Set<string>();
|
||||
const visibleContent = content.slice(contentStart, contentStart + contentRows).map((line) => {
|
||||
if (!line.sticky || labelledGroups.has(line.sticky.group)) return line;
|
||||
labelledGroups.add(line.sticky.group);
|
||||
return line.sticky.text == null ? line : { ...line, text: line.sticky.text };
|
||||
});
|
||||
if (content.length > bodyRows) {
|
||||
const scrollText = `scroll ↑/↓ · ${contentStart + 1}-${contentStart + visibleContent.length} of ${content.length}`;
|
||||
const up = scrollText.indexOf("↑");
|
||||
|
||||
@@ -6,6 +6,7 @@ import { decodeInput, resolveKeyAction, sgrClick } from "../src/input.js";
|
||||
import { renderScreen } from "../src/render.js";
|
||||
import { computeExplorerRows, createViewState } from "../src/state.js";
|
||||
import { createStyle, stripAnsi } from "../src/theme.js";
|
||||
import { explorerWidth } from "../src/visual-layout.js";
|
||||
import { stylizeLines } from "../src/stylize.js";
|
||||
import type { AgentRow, FleetSnapshot } from "../src/types.js";
|
||||
|
||||
@@ -118,16 +119,20 @@ describe("S12 instance mission control", () => {
|
||||
let scrolledSeatRows = 0;
|
||||
for (let offset = 0; offset <= screen.contentMaxOffset; offset += 1) {
|
||||
screen = renderScreen(view.get(), snap, { cols, rows, nowMs: 0, colorMode: "none" });
|
||||
const namedRigs = new Set<string>();
|
||||
for (const target of screen.contentTargets) {
|
||||
if (target.action.type !== "drill" || target.action.resource !== "agent") continue;
|
||||
reached.add(target.action.name);
|
||||
const rig = target.action.target?.rig;
|
||||
const content = stripAnsi(screen.lines[target.y - 1] ?? "").slice(screen.explorerWidth + 2);
|
||||
const firstVisible = !!rig && !namedRigs.has(rig);
|
||||
if (rig) namedRigs.add(rig);
|
||||
if (!rig || !/\b(working|needs you|idle|detached|failed|unknown|blocked)\b/.test(content)) continue;
|
||||
const headingVisible = screen.lines.some((line) => stripAnsi(line).slice(screen.explorerWidth + 2).includes(`RIG ${rig} ·`));
|
||||
if (headingVisible) continue;
|
||||
scrolledSeatRows += 1;
|
||||
expect(content.trimStart().startsWith(rig), `${cols}: scrolled seat row retains rig identity`).toBe(true);
|
||||
if (firstVisible) expect(content.startsWith(rig), `${cols}: first visible seat row of a rig retains rig identity`).toBe(true);
|
||||
else expect(content.startsWith(" ".repeat(rig.length)), `${cols}: later seat rows of a rig leave RIG blank`).toBe(true);
|
||||
}
|
||||
view.dispatch({ type: "content-scroll", delta: 1 });
|
||||
}
|
||||
@@ -140,7 +145,7 @@ describe("S12 instance mission control", () => {
|
||||
expect(whole).toMatch(/empty\s+—\s+\(no seats\).*stopped/);
|
||||
expect(whole).toMatch(/dev\s+[\s\S]*┈{8,}[\s\S]*qa\s+/);
|
||||
expect((whole.match(/┈{8,}/g) ?? [])).toHaveLength(1);
|
||||
expect(whole).toMatch(/build\s+qa\s+[^\n]*\n[^\n]*─{8,}[^\n]*\n[^\n]*docs\s+write/);
|
||||
expect(whole).toMatch(/\s+qa\s+[^\n]*\n[^\n]*─{8,}[^\n]*\n[^\n]*docs\s+write/);
|
||||
expect(whole).toMatch(/docs\s+write\s+[^\n]*\n[^\n]*─{8,}[^\n]*\n[^\n]*empty\s+—/);
|
||||
expect(whole).not.toMatch(/\+\d+|more hidden/i);
|
||||
expect(reached).toEqual(expectedAgents);
|
||||
@@ -319,3 +324,138 @@ describe("S12 view-aware hydration", () => {
|
||||
expect(snap.hosts[0]).toMatchObject({ id: "local", name: "local" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("instance table RIG column (#2848)", () => {
|
||||
const LONG = "RIG-session-review";
|
||||
const OVER_CAP = "rig-name-thirty-characters-xx";
|
||||
|
||||
function colsFor(contentWidth: number): number {
|
||||
for (let cols = contentWidth; cols < contentWidth + 80; cols += 1) {
|
||||
if (cols - explorerWidth(cols) - 2 === contentWidth) return cols;
|
||||
}
|
||||
throw new Error(`no terminal width gives content width ${contentWidth}`);
|
||||
}
|
||||
|
||||
function snapshotWithRigs(names: string[]): FleetSnapshot {
|
||||
const snap = multiRigSnapshot();
|
||||
snap.hosts[0]!.rigs.push(...names.map((name) => ({
|
||||
id: `r-${name}`,
|
||||
name,
|
||||
lifecycleState: "running",
|
||||
pods: [{ name: "ops", agents: [agent("ops.worker", `ops-worker@${name}`, "idle", 10)] }],
|
||||
})));
|
||||
return snap;
|
||||
}
|
||||
|
||||
function table(snap: FleetSnapshot, contentWidth: number, filter = "") {
|
||||
const view = openInstance(snap);
|
||||
if (filter) view.dispatch({ type: "filter", text: filter });
|
||||
const cols = colsFor(contentWidth);
|
||||
const screen = renderScreen(view.get(), snap, { cols, rows: 160, nowMs: 0, colorMode: "none" });
|
||||
const content = screen.lines.map((line) => stripAnsi(line).slice(screen.explorerWidth + 2));
|
||||
const header = content.find((line) => /^RIG\s+POD\s+SEAT/.test(line));
|
||||
expect(header, `${contentWidth}: table header`).toBeDefined();
|
||||
const heavyRule = content.find((line) => /^━{20,}/.test(line))!.match(/^━+/)![0].length;
|
||||
const rigWidth = header!.indexOf("POD") - 1;
|
||||
return { screen, content, header: header!, rigWidth, rowWidth: heavyRule };
|
||||
}
|
||||
|
||||
it.each([
|
||||
// [content width, expected RIG width, flexible column, expected flexible width]
|
||||
[77, 8, "WORK", 77 - (8 + 5 + 11 + 2 + 5 + 8 + 2 + 7)],
|
||||
[78, 16, "WORK", 78 - 54 - (16 - 10)],
|
||||
[109, 16, "WORK", 109 - 54 - (16 - 10)],
|
||||
[110, LONG.length, "NOW", 110 - 78 - (LONG.length - 13)],
|
||||
[190, LONG.length, "NOW", 190 - 78 - (LONG.length - 13)],
|
||||
] as const)("fits RIG to the longest shown name at content width %i", (contentWidth, rigWidth, flexible, flexibleWidth) => {
|
||||
const { header, rigWidth: actual, rowWidth } = table(snapshotWithRigs([LONG]), contentWidth);
|
||||
expect(actual).toBe(rigWidth);
|
||||
expect(rowWidth).toBeLessThanOrEqual(contentWidth);
|
||||
expect(header.trimEnd().endsWith(flexible)).toBe(true);
|
||||
expect(rowWidth - header.lastIndexOf(flexible)).toBe(flexibleWidth);
|
||||
});
|
||||
|
||||
it("keeps the old RIG width as the minimum when every name is short", () => {
|
||||
expect(table(multiRigSnapshot(), 160).rigWidth).toBe(13);
|
||||
expect(table(multiRigSnapshot(), 90).rigWidth).toBe(10);
|
||||
});
|
||||
|
||||
it.each([[110, 24], [160, 24], [78, 16], [100, 16]])("caps RIG and truncates a longer name at content width %i", (contentWidth, cap) => {
|
||||
const { content, rigWidth, rowWidth } = table(snapshotWithRigs([OVER_CAP]), contentWidth);
|
||||
expect(rigWidth).toBe(cap);
|
||||
expect(rowWidth).toBeLessThanOrEqual(contentWidth);
|
||||
expect(content.some((line) => line.startsWith(`${OVER_CAP.slice(0, cap - 1)}…`))).toBe(true);
|
||||
for (const line of content) expect(line.trimEnd().length).toBeLessThanOrEqual(contentWidth);
|
||||
});
|
||||
|
||||
function seatRows(screen: ReturnType<typeof renderScreen>) {
|
||||
return screen.contentTargets
|
||||
.filter((target) => target.action.type === "drill" && target.action.resource === "agent")
|
||||
.map((target) => ({
|
||||
y: target.y,
|
||||
rig: target.action.type === "drill" ? target.action.target?.rig ?? "" : "",
|
||||
name: target.action.type === "drill" ? target.action.name : "",
|
||||
text: stripAnsi(screen.lines[target.y - 1] ?? "").slice(screen.explorerWidth + 2),
|
||||
}));
|
||||
}
|
||||
|
||||
it("names every rig on its single row when each rig has one seat", () => {
|
||||
const names = Array.from({ length: 8 }, (_, index) => `solo-${index}`);
|
||||
const { screen } = table(snapshotWithRigs(names), 160);
|
||||
const rows = seatRows(screen).filter((row) => names.includes(row.rig));
|
||||
expect(rows.map((row) => row.rig)).toEqual(names);
|
||||
for (const row of rows) expect(row.text.startsWith(`${row.rig} `)).toBe(true);
|
||||
});
|
||||
|
||||
it("names a rig on its first row only, across pod dividers, while POD still leads each pod", () => {
|
||||
const { screen, content, rigWidth } = table(multiRigSnapshot(), 160);
|
||||
const build = seatRows(screen).filter((row) => row.rig === "build");
|
||||
expect(build.map((row) => row.name)).toEqual(["dev.driver", "dev.guard", "qa.checker"]);
|
||||
expect(build[0]!.text).toMatch(/^build\s+dev\s/);
|
||||
expect(build[1]!.text.slice(0, rigWidth + 4).trim()).toBe("");
|
||||
expect(build[2]!.text).toMatch(new RegExp(`^ {${rigWidth}} qa\\s`));
|
||||
expect(content[build[2]!.y - 2]).toMatch(/^┈{8,}/);
|
||||
expect(content.filter((line) => line.startsWith("build "))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("drills into the row's rig from a blank RIG cell", () => {
|
||||
const { screen, rigWidth } = table(multiRigSnapshot(), 160);
|
||||
const guard = seatRows(screen).find((row) => row.name === "dev.guard")!;
|
||||
expect(guard.text.slice(0, rigWidth).trim()).toBe("");
|
||||
for (const x of [screen.explorerWidth + 3, screen.explorerWidth + 2 + rigWidth]) {
|
||||
const hit = screen.hitMap.find((target) => target.y === guard.y && x >= target.x1 && x <= target.x2);
|
||||
expect(hit?.action).toEqual({ type: "drill", resource: "rig", name: "build", target: { host: "mm2-openrig1" } });
|
||||
}
|
||||
const pastRig = screen.explorerWidth + 3 + rigWidth;
|
||||
const seatHit = screen.hitMap.find((target) => target.y === guard.y && pastRig >= target.x1 && pastRig <= target.x2);
|
||||
expect(seatHit?.action).toMatchObject({ type: "drill", resource: "agent", name: "dev.guard" });
|
||||
});
|
||||
|
||||
it("names a scrolled rig on its top visible row and leaves the rows under it blank", () => {
|
||||
const snap = multiRigSnapshot();
|
||||
snap.hosts[0]!.rigs[0]!.pods[0]!.agents.push(...Array.from({ length: 36 }, (_, index) =>
|
||||
agent(`dev.extra-${index}`, `dev-extra-${index}@build`, "active", 20 + index),
|
||||
));
|
||||
const view = openInstance(snap);
|
||||
let screen = renderScreen(view.get(), snap, { cols: 160, rows: 30, nowMs: 0, colorMode: "none" });
|
||||
view.dispatch({ type: "layout", contentMaxOffset: screen.contentMaxOffset, contentTargetCount: screen.contentTargets.length });
|
||||
let checked = false;
|
||||
for (let offset = 0; offset <= screen.contentMaxOffset && !checked; offset += 1) {
|
||||
screen = renderScreen(view.get(), snap, { cols: 160, rows: 30, nowMs: 0, colorMode: "none" });
|
||||
const build = seatRows(screen).filter((row) => row.rig === "build");
|
||||
if (build.length >= 3 && build[0]!.name !== "dev.driver") {
|
||||
expect(build[0]!.text.startsWith("build ")).toBe(true);
|
||||
for (const row of build.slice(1)) expect(row.text.startsWith(" ")).toBe(true);
|
||||
checked = true;
|
||||
}
|
||||
view.dispatch({ type: "content-scroll", delta: 1 });
|
||||
}
|
||||
expect(checked).toBe(true);
|
||||
});
|
||||
|
||||
it("sizes RIG from the rigs the filter leaves on screen", () => {
|
||||
const snap = snapshotWithRigs([LONG, "short-rig"]);
|
||||
expect(table(snap, 160).rigWidth).toBe(LONG.length);
|
||||
expect(table(snap, 160, "short").rigWidth).toBe(13);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user