mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
NS-T06: auto-snapshot on down + existing-rig power-on
Implements the core product loop: down (auto-snapshot) → up <name>
(auto-restore).
Key changes:
- RigTeardownOrchestrator: auto-snapshot (kind: auto-pre-down) before
every teardown, best-effort
- UpCommandRouter: rig_name detection (no '/' and no file extension)
- RigRepository.findRigsByName: returns all matching rigs for ambiguity
- SnapshotRepository.findLatestAutoPreDown: finds latest power-on snap
- /api/up: rig_name branch classifies raw sourceRef before path
resolution, finds rig → snapshot → restore via shared helper
- POST /api/rigs/:id/up: Explorer-facing power-on route
- Down response includes rigName + isUniqueName for conditional handoff
- CLI up: detects name vs path, prints restore handoff with node outcomes
- CLI down: prints 'To restore: rigged up {name}' (unique) or explicit
restore command (ambiguous)
Both power-on routes compute attachCommand identically from node
inventory (first running session, fallback to any session).
7 new tests. Daemon: 92/92, 1224/1224. CLI: 176/176.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
1 parent
8c918f3cc2
commit
e015ce9215
12 files changed
+276
-27
No files matched your search
@@ -92,9 +92,19 @@ export function downCommand(depsOverride?: StatusDeps): Command {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean stop
|
||||
// Clean stop with post-command handoff
|
||||
console.log(`Rig ${rigId} stopped. ${result.sessionsKilled} session(s) killed.`);
|
||||
if (result.snapshotId) console.log(`Snapshot: ${result.snapshotId}`);
|
||||
if (result.snapshotId) {
|
||||
console.log(`Snapshot: ${result.snapshotId}`);
|
||||
// Post-command handoff: how to restore (check for duplicate names)
|
||||
const rigName = (res.data as Record<string, unknown>)["rigName"] as string | undefined;
|
||||
const isUniqueName = (res.data as Record<string, unknown>)["isUniqueName"] as boolean | undefined;
|
||||
if (rigName && isUniqueName !== false) {
|
||||
console.log(`To restore: rigged up ${rigName}`);
|
||||
} else {
|
||||
console.log(`To restore: rigged restore ${result.snapshotId} --rig ${rigId}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return cmd;
|
||||
|
||||
@@ -39,8 +39,12 @@ export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: Lifecycl
|
||||
|
||||
const client = deps.clientFactory(`http://127.0.0.1:${status.port}`);
|
||||
|
||||
// Detect rig name vs file path: names don't contain / and don't end in .yaml/.yml/.rigbundle
|
||||
const isRigName = !source.includes("/") && !source.match(/\.(ya?ml|rigbundle)$/i);
|
||||
const sourceRef = isRigName ? source : nodePath.resolve(source);
|
||||
|
||||
const res = await client.post<Record<string, unknown>>("/api/up", {
|
||||
sourceRef: nodePath.resolve(source),
|
||||
sourceRef,
|
||||
plan: opts.plan ?? false,
|
||||
autoApprove: opts.yes ?? false,
|
||||
targetRoot: opts.target,
|
||||
@@ -75,16 +79,41 @@ export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: Lifecycl
|
||||
|
||||
// Success output
|
||||
const resStatus = res.data["status"] as string;
|
||||
const stages = (res.data["stages"] as Array<{ stage: string; status: string }>) ?? [];
|
||||
for (const s of stages) {
|
||||
console.log(` ${s.stage}: ${s.status}`);
|
||||
|
||||
if (resStatus === "restored") {
|
||||
// Existing-rig power-on handoff
|
||||
const rigId = res.data["rigId"] as string;
|
||||
const rigName = res.data["rigName"] as string | undefined;
|
||||
console.log(`Rig "${rigName ?? rigId}" restored (ID: ${rigId})`);
|
||||
const nodes = (res.data["nodes"] as Array<{ logicalId: string; status: string }>) ?? [];
|
||||
for (const n of nodes) {
|
||||
console.log(` ${n.logicalId}: ${n.status}`);
|
||||
}
|
||||
const warnings = (res.data["warnings"] as string[]) ?? [];
|
||||
for (const w of warnings) {
|
||||
console.error(` warning: ${w}`);
|
||||
}
|
||||
// Attach command from server response (uses real canonical session name)
|
||||
const attachCommand = res.data["attachCommand"] as string | undefined;
|
||||
if (attachCommand) {
|
||||
console.log(`Attach: ${attachCommand}`);
|
||||
}
|
||||
if (nodes.some((n) => n.status === "failed")) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} else {
|
||||
// Fresh boot output
|
||||
const stages = (res.data["stages"] as Array<{ stage: string; status: string }>) ?? [];
|
||||
for (const s of stages) {
|
||||
console.log(` ${s.stage}: ${s.status}`);
|
||||
}
|
||||
|
||||
const rigId = res.data["rigId"] as string | undefined;
|
||||
if (rigId) console.log(`\nRig: ${rigId}`);
|
||||
console.log(`Status: ${resStatus}`);
|
||||
|
||||
if (resStatus === "partial") process.exitCode = 1;
|
||||
}
|
||||
|
||||
const rigId = res.data["rigId"] as string | undefined;
|
||||
if (rigId) console.log(`\nRig: ${rigId}`);
|
||||
console.log(`Status: ${resStatus}`);
|
||||
|
||||
if (resStatus === "partial") process.exitCode = 1;
|
||||
});
|
||||
|
||||
return cmd;
|
||||
|
||||
@@ -138,6 +138,13 @@ export class RigRepository {
|
||||
return rows.map((r) => this.rowToRig(r));
|
||||
}
|
||||
|
||||
findRigsByName(name: string): Rig[] {
|
||||
const rows = this.db
|
||||
.prepare("SELECT * FROM rigs WHERE name = ? ORDER BY created_at")
|
||||
.all(name) as RigRow[];
|
||||
return rows.map((r) => this.rowToRig(r));
|
||||
}
|
||||
|
||||
getRigSummaries(): Array<{ id: string; name: string; nodeCount: number; latestSnapshotAt: string | null; latestSnapshotId: string | null }> {
|
||||
const rows = this.db.prepare(`
|
||||
SELECT
|
||||
|
||||
@@ -84,14 +84,13 @@ export class RigTeardownOrchestrator {
|
||||
return result;
|
||||
}
|
||||
|
||||
// 4. Snapshot before teardown
|
||||
if (opts?.snapshot) {
|
||||
try {
|
||||
const snap = this.deps.snapshotCapture.captureSnapshot(rigId, "manual");
|
||||
result.snapshotId = snap.id;
|
||||
} catch (err) {
|
||||
result.errors.push(`Snapshot failed: ${(err as Error).message}`);
|
||||
}
|
||||
// 4. Auto-snapshot before teardown (always, best-effort)
|
||||
try {
|
||||
const snap = this.deps.snapshotCapture.captureSnapshot(rigId, "auto-pre-down");
|
||||
result.snapshotId = snap.id;
|
||||
} catch (err) {
|
||||
result.errors.push(`Snapshot failed: ${(err as Error).message}`);
|
||||
// Best-effort — teardown proceeds even if snapshot fails
|
||||
}
|
||||
|
||||
// 5. Kill each live session
|
||||
|
||||
@@ -34,6 +34,15 @@ export class SnapshotRepository {
|
||||
return row ? this.rowToSnapshot(row) : null;
|
||||
}
|
||||
|
||||
findLatestAutoPreDown(rigId: string): Snapshot | null {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
"SELECT * FROM snapshots WHERE rig_id = ? AND kind = 'auto-pre-down' ORDER BY created_at DESC LIMIT 1"
|
||||
)
|
||||
.get(rigId) as SnapshotRow | undefined;
|
||||
return row ? this.rowToSnapshot(row) : null;
|
||||
}
|
||||
|
||||
getLatestSnapshot(rigId: string): Snapshot | null {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
|
||||
@@ -3,7 +3,7 @@ import { LegacyRigSpecSchema as LegacySchema } from "./rigspec-schema.js";
|
||||
import { RigSpecCodec as PodCodec } from "./rigspec-codec.js";
|
||||
import { RigSpecSchema as PodSchema } from "./rigspec-schema.js";
|
||||
|
||||
export type SourceKind = "rig_spec" | "rig_bundle";
|
||||
export type SourceKind = "rig_spec" | "rig_bundle" | "rig_name";
|
||||
|
||||
export interface RouteResult {
|
||||
sourceKind: SourceKind;
|
||||
@@ -31,8 +31,13 @@ export class UpCommandRouter {
|
||||
}
|
||||
|
||||
route(sourceRef: string): RouteResult {
|
||||
// Rig name detection: no '/' and no file extension → treat as existing rig name
|
||||
if (!sourceRef.includes("/") && !sourceRef.match(/\.(ya?ml|rigbundle)$/i)) {
|
||||
return { sourceKind: "rig_name", sourceRef };
|
||||
}
|
||||
|
||||
if (!this.fs.exists(sourceRef)) {
|
||||
throw new Error(`Source not found: ${sourceRef}`);
|
||||
throw new Error(`Source not found: ${sourceRef}. Provide a .yaml rig spec path or a rig name to restore.`);
|
||||
}
|
||||
|
||||
// Extension-based routing
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import type { RigTeardownOrchestrator } from "../domain/rig-teardown.js";
|
||||
import type { RigRepository } from "../domain/rig-repository.js";
|
||||
import { RigNotFoundError } from "../domain/errors.js";
|
||||
|
||||
export const downRoutes = new Hono();
|
||||
@@ -45,7 +46,13 @@ downRoutes.post("/", async (c) => {
|
||||
return c.json(result, 500);
|
||||
}
|
||||
|
||||
return c.json(result, 200);
|
||||
// Include rig name + uniqueness in response for post-command handoff
|
||||
const rigRepo = c.get("rigRepo" as never) as RigRepository;
|
||||
const rig = rigRepo.getRig(rigId);
|
||||
const rigName = rig?.rig.name ?? null;
|
||||
const isUniqueName = rigName ? rigRepo.findRigsByName(rigName).length === 1 : false;
|
||||
const enriched = { ...result, rigName, isUniqueName };
|
||||
return c.json(enriched, 200);
|
||||
} catch (err) {
|
||||
if (err instanceof RigNotFoundError) {
|
||||
return c.json({ error: err.message }, 404);
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Hono } from "hono";
|
||||
import type { RigRepository } from "../domain/rig-repository.js";
|
||||
import type { SessionRegistry } from "../domain/session-registry.js";
|
||||
import type { EventBus } from "../domain/event-bus.js";
|
||||
import type { SnapshotRepository } from "../domain/snapshot-repository.js";
|
||||
import type { RestoreOrchestrator } from "../domain/restore-orchestrator.js";
|
||||
import { projectRigToGraph } from "../domain/graph-projection.js";
|
||||
|
||||
export const rigsRoutes = new Hono();
|
||||
@@ -83,3 +85,43 @@ rigsRoutes.delete("/:id", (c) => {
|
||||
return c.json({ error: "delete failed" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/rigs/:id/up — power-on an existing rig from auto-pre-down snapshot
|
||||
rigsRoutes.post("/:id/up", async (c) => {
|
||||
const rigId = c.req.param("id")!;
|
||||
const repo = getRepo(c);
|
||||
const rig = repo.getRig(rigId);
|
||||
if (!rig) return c.json({ error: `Rig "${rigId}" not found. List rigs with: rigged ps` }, 404);
|
||||
|
||||
const snapshotRepo = c.get("snapshotRepo" as never) as SnapshotRepository;
|
||||
const snapshot = snapshotRepo.findLatestAutoPreDown(rigId);
|
||||
if (!snapshot) {
|
||||
return c.json({ error: `Rig "${rig.rig.name}" exists but has no auto-pre-down snapshot. Start fresh with: rigged up <spec-path>`, code: "no_snapshot" }, 404);
|
||||
}
|
||||
|
||||
const restoreOrch = c.get("restoreOrchestrator" as never) as RestoreOrchestrator | undefined;
|
||||
if (!restoreOrch) {
|
||||
return c.json({ error: "Restore orchestrator not available" }, 500);
|
||||
}
|
||||
|
||||
const result = await restoreOrch.restore(snapshot.id);
|
||||
if (!result.ok) {
|
||||
return c.json({ error: result.message, code: result.code }, result.code === "rig_not_stopped" ? 409 : 400);
|
||||
}
|
||||
|
||||
// Compute attach command from first running/resumed node (same logic as /api/up)
|
||||
const { getNodeInventory } = await import("../domain/node-inventory.js");
|
||||
const inventory = getNodeInventory(repo.db, rigId);
|
||||
const firstRunning = inventory.find((n) => n.canonicalSessionName && n.sessionStatus === "running");
|
||||
const attachCommand = firstRunning?.tmuxAttachCommand ?? inventory.find((n) => n.canonicalSessionName)?.tmuxAttachCommand ?? null;
|
||||
|
||||
return c.json({
|
||||
status: "restored",
|
||||
rigId,
|
||||
rigName: rig.rig.name,
|
||||
snapshotId: snapshot.id,
|
||||
nodes: result.result.nodes,
|
||||
warnings: result.result.warnings,
|
||||
attachCommand,
|
||||
}, 200);
|
||||
});
|
||||
@@ -4,6 +4,9 @@ import type { BootstrapOrchestrator } from "../domain/bootstrap-orchestrator.js"
|
||||
import type { BootstrapRepository } from "../domain/bootstrap-repository.js";
|
||||
import type { EventBus } from "../domain/event-bus.js";
|
||||
import type { UpCommandRouter } from "../domain/up-command-router.js";
|
||||
import type { RigRepository } from "../domain/rig-repository.js";
|
||||
import type { SnapshotRepository } from "../domain/snapshot-repository.js";
|
||||
import type { RestoreOrchestrator } from "../domain/restore-orchestrator.js";
|
||||
|
||||
export const upRoutes = new Hono();
|
||||
|
||||
@@ -13,9 +16,50 @@ function getDeps(c: { get: (key: string) => unknown }) {
|
||||
bootstrapRepo: c.get("bootstrapRepo" as never) as BootstrapRepository,
|
||||
eventBus: c.get("eventBus" as never) as EventBus,
|
||||
upRouter: c.get("upRouter" as never) as UpCommandRouter,
|
||||
rigRepo: c.get("rigRepo" as never) as RigRepository,
|
||||
snapshotRepo: c.get("snapshotRepo" as never) as SnapshotRepository,
|
||||
restoreOrchestrator: c.get("restoreOrchestrator" as never) as RestoreOrchestrator | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a rig by ID from its latest auto-pre-down snapshot.
|
||||
* Shared helper used by both /api/up (rig_name) and /api/rigs/:rigId/up (Explorer).
|
||||
*/
|
||||
async function restoreByRigId(rigId: string, rigName: string | null, deps: ReturnType<typeof getDeps>, c: { json: (data: unknown, status?: number) => Response }) {
|
||||
const { snapshotRepo, restoreOrchestrator } = deps;
|
||||
|
||||
const snapshot = snapshotRepo.findLatestAutoPreDown(rigId);
|
||||
if (!snapshot) {
|
||||
return c.json({ error: `Rig exists but has no auto-pre-down snapshot. Start fresh with: rigged up <spec-path>`, code: "no_snapshot" }, 404);
|
||||
}
|
||||
|
||||
if (!restoreOrchestrator) {
|
||||
return c.json({ error: "Restore orchestrator not available" }, 500);
|
||||
}
|
||||
|
||||
const result = await restoreOrchestrator.restore(snapshot.id);
|
||||
if (!result.ok) {
|
||||
return c.json({ error: result.message, code: result.code }, result.code === "rig_not_stopped" ? 409 : 400);
|
||||
}
|
||||
|
||||
// Compute attach command from first running node (same logic as /api/rigs/:id/up)
|
||||
const { getNodeInventory } = await import("../domain/node-inventory.js");
|
||||
const inventory = getNodeInventory(deps.snapshotRepo.db, rigId);
|
||||
const firstRunning = inventory.find((n) => n.canonicalSessionName && n.sessionStatus === "running");
|
||||
const attachCommand = firstRunning?.tmuxAttachCommand ?? inventory.find((n) => n.canonicalSessionName)?.tmuxAttachCommand ?? null;
|
||||
|
||||
return c.json({
|
||||
status: "restored",
|
||||
rigId,
|
||||
rigName,
|
||||
snapshotId: snapshot.id,
|
||||
nodes: result.result.nodes,
|
||||
warnings: result.result.warnings,
|
||||
attachCommand,
|
||||
}, 200);
|
||||
}
|
||||
|
||||
// POST /api/up — the hero route
|
||||
upRoutes.post("/", async (c) => {
|
||||
const { bootstrapOrchestrator, bootstrapRepo, eventBus, upRouter } = getDeps(c);
|
||||
@@ -29,11 +73,29 @@ upRoutes.post("/", async (c) => {
|
||||
return c.json({ error: "sourceRef is required" }, 400);
|
||||
}
|
||||
|
||||
// Route source
|
||||
// Route source — classify raw sourceRef first, only resolve path for file-based kinds
|
||||
let sourceKind: string;
|
||||
let resolvedSourceRef = sourceRef;
|
||||
try {
|
||||
const route = upRouter.route(nodePath.resolve(sourceRef));
|
||||
const route = upRouter.route(sourceRef);
|
||||
sourceKind = route.sourceKind;
|
||||
|
||||
// Rig name: restore from latest auto-pre-down snapshot
|
||||
if (sourceKind === "rig_name") {
|
||||
const { rigRepo } = getDeps(c);
|
||||
const rigs = rigRepo.findRigsByName(sourceRef);
|
||||
if (rigs.length === 0) {
|
||||
return c.json({ error: `No rig found named "${sourceRef}". Provide a .yaml spec path to create a new rig.`, code: "rig_not_found" }, 404);
|
||||
}
|
||||
if (rigs.length > 1) {
|
||||
const ids = rigs.map((r) => r.id).join(", ");
|
||||
return c.json({ error: `Multiple rigs named "${sourceRef}" found (IDs: ${ids}). Use rigged restore --rig <rigId> with a specific rig ID.`, code: "ambiguous_name" }, 409);
|
||||
}
|
||||
return restoreByRigId(rigs[0]!.id, sourceRef, getDeps(c), c) as any;
|
||||
}
|
||||
|
||||
// File-based: resolve path now
|
||||
resolvedSourceRef = nodePath.resolve(sourceRef);
|
||||
} catch (err) {
|
||||
return c.json({ error: (err as Error).message }, 400);
|
||||
}
|
||||
@@ -53,7 +115,7 @@ upRoutes.post("/", async (c) => {
|
||||
// Plan mode — no run lifecycle
|
||||
const result = await bootstrapOrchestrator.bootstrap({
|
||||
mode: "plan",
|
||||
sourceRef: nodePath.resolve(sourceRef),
|
||||
sourceRef: resolvedSourceRef,
|
||||
sourceKind,
|
||||
targetRoot,
|
||||
});
|
||||
@@ -82,7 +144,7 @@ upRoutes.post("/", async (c) => {
|
||||
try {
|
||||
const result = await bootstrapOrchestrator.bootstrap({
|
||||
mode: "apply",
|
||||
sourceRef: nodePath.resolve(sourceRef),
|
||||
sourceRef: resolvedSourceRef,
|
||||
sourceKind,
|
||||
autoApprove,
|
||||
targetRoot,
|
||||
|
||||
@@ -128,4 +128,47 @@ describe("POST /api/down route", () => {
|
||||
|
||||
snapshotCapture.captureSnapshot = origCapture;
|
||||
});
|
||||
|
||||
// NS-T06: auto-snapshot on down
|
||||
it("auto-snapshots before teardown (auto-pre-down)", async () => {
|
||||
const db = createFullTestDb();
|
||||
const { app, rigRepo, sessionRegistry } = createTestApp(db);
|
||||
const rig = rigRepo.createRig("auto-snap-rig");
|
||||
const node = rigRepo.addNode(rig.id, "impl", { runtime: "claude-code" });
|
||||
const session = sessionRegistry.registerSession(node.id, "r01-impl");
|
||||
sessionRegistry.updateStatus(session.id, "running");
|
||||
|
||||
const res = await app.request("/api/down", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ rigId: rig.id }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
// Auto-snapshot should have been created
|
||||
expect(body.snapshotId).toBeTruthy();
|
||||
// Verify snapshot kind
|
||||
const snap = db.prepare("SELECT kind FROM snapshots WHERE id = ?").get(body.snapshotId) as { kind: string } | undefined;
|
||||
expect(snap?.kind).toBe("auto-pre-down");
|
||||
db.close();
|
||||
});
|
||||
|
||||
// NS-T06: down response includes rig name for handoff
|
||||
it("response includes rigName for post-command handoff", async () => {
|
||||
const db = createFullTestDb();
|
||||
const { app, rigRepo, sessionRegistry } = createTestApp(db);
|
||||
const rig = rigRepo.createRig("handoff-test");
|
||||
const node = rigRepo.addNode(rig.id, "impl");
|
||||
const session = sessionRegistry.registerSession(node.id, "r01-impl");
|
||||
sessionRegistry.updateStatus(session.id, "running");
|
||||
|
||||
const res = await app.request("/api/down", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ rigId: rig.id }),
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(body.rigName).toBe("handoff-test");
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -326,4 +326,19 @@ describe("Rig CRUD routes", () => {
|
||||
// ZZZZ sorts after AAAA, so ZZZZ_snap should win the tiebreak
|
||||
expect(epsilons[0].latestSnapshotId).toBe("ZZZZ_snap");
|
||||
});
|
||||
|
||||
// NS-T06: POST /api/rigs/:id/up — power-on from auto-pre-down snapshot
|
||||
it("POST /api/rigs/:id/up returns 404 when no auto-pre-down snapshot exists", async () => {
|
||||
const rig = repo.createRig("no-snap-rig");
|
||||
const res = await app.request(`/api/rigs/${rig.id}/up`, { method: "POST" });
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json();
|
||||
expect(body.code).toBe("no_snapshot");
|
||||
});
|
||||
|
||||
it("POST /api/rigs/:id/up returns 404 for nonexistent rig", async () => {
|
||||
const res = await app.request("/api/rigs/nonexistent/up", { method: "POST" });
|
||||
expect(res.status).toBe(404);
|
||||
expect((await res.json()).error).toContain("not found");
|
||||
});
|
||||
});
|
||||
@@ -196,4 +196,25 @@ edges: []
|
||||
const result = router.route(specPath);
|
||||
expect(result.sourceKind).toBe("rig_spec");
|
||||
});
|
||||
|
||||
// NS-T06: rig name detection
|
||||
it("bare name without / or extension → rig_name", () => {
|
||||
const router = new UpCommandRouter({ fsOps: realFsOps() });
|
||||
const result = router.route("auth-feats");
|
||||
expect(result.sourceKind).toBe("rig_name");
|
||||
expect(result.sourceRef).toBe("auth-feats");
|
||||
});
|
||||
|
||||
it("name with .yaml extension → NOT rig_name (file path)", () => {
|
||||
const specPath = path.join(tmpDir, "auth.yaml");
|
||||
fs.writeFileSync(specPath, VALID_SPEC);
|
||||
const router = new UpCommandRouter({ fsOps: realFsOps() });
|
||||
const result = router.route(specPath);
|
||||
expect(result.sourceKind).toBe("rig_spec");
|
||||
});
|
||||
|
||||
it("path with / → NOT rig_name", () => {
|
||||
const router = new UpCommandRouter({ fsOps: realFsOps() });
|
||||
expect(() => router.route("/tmp/nonexistent")).toThrow(/Source not found/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user