Skip to content
Merged
4 changes: 4 additions & 0 deletions .changeset/slick-eyes-pay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
---

Enforce recorded sandbox identity before smoke-suite teardown commands. This changes test infrastructure only and does not alter any published package.
12 changes: 9 additions & 3 deletions bin/smoke/backup-conservation-live.smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import {
type SpaceAuth,
} from "@cotal-ai/core";
import { authDir, loadSoleSpaceAuth } from "@cotal-ai/workspace";
import { assertSmokeSandboxDown, recordSmokeSandbox } from "@cotal-ai/smoke-kit";

const freePort = () => new Promise<number>((resolvePort, reject) => {
const server = createServer();
Expand Down Expand Up @@ -106,10 +107,15 @@ interface Mesh {
async function openMesh(label: string, space: string): Promise<Mesh> {
const root = realpathSync.native(mkdtempSync(join(tmpdir(), `cotal-${label}-root-`)));
const home = realpathSync.native(mkdtempSync(join(tmpdir(), `cotal-${label}-home-`)));
const configDir = join(home, "xdg");
const sandbox = recordSmokeSandbox({ root, cotalHome: home, xdgConfigHome: configDir });
const server = `nats://127.0.0.1:${await freePort()}`;
const env = { ...process.env, COTAL_HOME: home };
const run = (...args: string[]) =>
spawnSync(tsx, [cliPath, ...args], { cwd: root, env, encoding: "utf8", timeout: 240_000 });
const env = { ...process.env, COTAL_HOME: home, XDG_CONFIG_HOME: configDir };
const run = (...args: string[]) => {
const options = { cwd: root, env, encoding: "utf8" as const, timeout: 240_000 };
assertSmokeSandboxDown(sandbox, args, options);
return spawnSync(tsx, [cliPath, ...args], options);
};
return {
root, home, server, space, run,
must: (name, result) => {
Expand Down
21 changes: 17 additions & 4 deletions bin/smoke/backup-faults-live.smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
type SpaceBackupSelection,
} from "@cotal-ai/core";
import { authDir, loadSoleSpaceAuth } from "@cotal-ai/workspace";
import { assertSmokeSandboxDown, recordSmokeSandbox } from "@cotal-ai/smoke-kit";
import { BACKUP_MANIFEST_FORMAT, type BackupManifest } from "../../implementations/cli/src/lib/backup-artifact.js";

const freePort = () => new Promise<number>((resolvePort, reject) => {
Expand Down Expand Up @@ -77,11 +78,17 @@ function assertValidManifest(directory: string, space: string, selection: SpaceB
async function backupStageFaultScenario(): Promise<void> {
const root = realpathSync.native(mkdtempSync(join(tmpdir(), "cotal-backup-faults-stages-root-")));
const home = realpathSync.native(mkdtempSync(join(tmpdir(), "cotal-backup-faults-stages-home-")));
const configDir = join(home, "xdg");
const sandbox = recordSmokeSandbox({ root, cotalHome: home, xdgConfigHome: configDir });
const port = await freePort();
const server = `nats://127.0.0.1:${port}`;
const space = "backup_faults_stages";
const env = { ...process.env, COTAL_HOME: home };
const run = (...args: string[]) => spawnSync(tsx, [cliPath, ...args], { cwd: root, env, encoding: "utf8", timeout: 240_000 });
const env = { ...process.env, COTAL_HOME: home, XDG_CONFIG_HOME: configDir };
const run = (...args: string[]) => {
const options = { cwd: root, env, encoding: "utf8" as const, timeout: 240_000 };
assertSmokeSandboxDown(sandbox, args, options);
return spawnSync(tsx, [cliPath, ...args], options);
};
const must = (label: string, result: ReturnType<typeof run>) => {
assert.equal(result.status, 0, `stages ${label}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
};
Expand Down Expand Up @@ -143,12 +150,18 @@ const RESTORE_FAULTS = [
async function restoreExactIdTimeoutReplayScenario(): Promise<void> {
const root = realpathSync.native(mkdtempSync(join(tmpdir(), "cotal-backup-faults-replay-root-")));
const home = realpathSync.native(mkdtempSync(join(tmpdir(), "cotal-backup-faults-replay-home-")));
const configDir = join(home, "xdg");
const sandbox = recordSmokeSandbox({ root, cotalHome: home, xdgConfigHome: configDir });
const artifact = join(root, "full-backup");
const port = await freePort();
const server = `nats://127.0.0.1:${port}`;
const space = "backup_faults_replay";
const env = { ...process.env, COTAL_HOME: home };
const run = (...args: string[]) => spawnSync(tsx, [cliPath, ...args], { cwd: root, env, encoding: "utf8", timeout: 240_000 });
const env = { ...process.env, COTAL_HOME: home, XDG_CONFIG_HOME: configDir };
const run = (...args: string[]) => {
const options = { cwd: root, env, encoding: "utf8" as const, timeout: 240_000 };
assertSmokeSandboxDown(sandbox, args, options);
return spawnSync(tsx, [cliPath, ...args], options);
};
const must = (label: string, result: ReturnType<typeof run>) => {
assert.equal(result.status, 0, `replay ${label}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
};
Expand Down
66 changes: 37 additions & 29 deletions bin/smoke/backup-restore-live.smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
createAttemptClone,
startIsolatedBroker,
} from "../../implementations/cli/src/lib/isolated-broker.js";
import { assertSmokeSandboxDown, recordSmokeSandbox, type SmokeSandboxAnchor } from "@cotal-ai/smoke-kit";

const freePort = () => new Promise<number>((resolvePort, reject) => {
const server = createServer();
Expand Down Expand Up @@ -66,6 +67,22 @@ const cliPath = join(worktree, "bin", "cotal.ts");
const taskSeedPath = join(worktree, "implementations", "cli", "smoke", "seed-task-durable.ts");
const tsx = join(worktree, "node_modules", ".bin", "tsx");

function sandboxRun(root: string, home: string): {
run: (...args: string[]) => SpawnSyncReturns<string>;
env: NodeJS.ProcessEnv;
sandbox: SmokeSandboxAnchor;
} {
const configDir = join(home, "xdg");
const sandbox = recordSmokeSandbox({ root, cotalHome: home, xdgConfigHome: configDir });
const env = { ...process.env, COTAL_HOME: home, XDG_CONFIG_HOME: configDir };
const run = (...args: string[]) => {
const options = { cwd: root, env, encoding: "utf8" as const, timeout: 240_000 };
assertSmokeSandboxDown(sandbox, args, options);
return spawnSync(tsx, [cliPath, ...args], options);
};
return { run, env, sandbox };
}

interface Checkpoint { stream: string; name: string }

async function scenario(mode: "open" | "auth"): Promise<void> {
Expand All @@ -76,8 +93,7 @@ async function scenario(mode: "open" | "auth"): Promise<void> {
const port = await freePort();
const server = `nats://127.0.0.1:${port}`;
const space = `backup_live_${mode}`;
const env = { ...process.env, COTAL_HOME: home };
const run = (...args: string[]) => spawnSync(tsx, [cliPath, ...args], { cwd: root, env, encoding: "utf8", timeout: 240_000 });
const { run } = sandboxRun(root, home);
const must = (label: string, result: ReturnType<typeof run>) => {
assert.equal(result.status, 0, `${mode} ${label}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
};
Expand Down Expand Up @@ -291,8 +307,7 @@ async function occupiedRestoreScenario(): Promise<void> {
const port = await freePort();
const server = `nats://127.0.0.1:${port}`;
const space = "backup_occupied_restore";
const env = { ...process.env, COTAL_HOME: home };
const run = (...args: string[]) => spawnSync(tsx, [cliPath, ...args], { cwd: root, env, encoding: "utf8", timeout: 240_000 });
const { run } = sandboxRun(root, home);
let occupied: ChildProcess | undefined;
try {
assert.equal(run("up", "--detach", "--open", "--server", server, "--space", space).status, 0);
Expand Down Expand Up @@ -325,8 +340,7 @@ async function restoreReentryScenario(
const port = await freePort();
const server = `nats://127.0.0.1:${port}`;
const space = `backup_reentry_${label}`;
const env = { ...process.env, COTAL_HOME: home };
const run = (...args: string[]) => spawnSync(tsx, [cliPath, ...args], { cwd: root, env, encoding: "utf8", timeout: 240_000 });
const { run } = sandboxRun(root, home);
const journalPath = join(root, ".cotal", "maintenance", "v1", "journal.json");
try {
assert.equal(run("up", "--detach", ...(authenticated ? [] : ["--open"]), "--server", server, "--space", space).status, 0);
Expand Down Expand Up @@ -400,8 +414,7 @@ async function ordinaryResumeReentryScenario(injection: "resume-commit" | "resum
const port = await freePort();
const server = `nats://127.0.0.1:${port}`;
const space = `backup_ordinary_${injection}`;
const env = { ...process.env, COTAL_HOME: home };
const run = (...args: string[]) => spawnSync(tsx, [cliPath, ...args], { cwd: root, env, encoding: "utf8", timeout: 240_000 });
const { run } = sandboxRun(root, home);
const journalPath = join(root, ".cotal", "maintenance", "v1", "journal.json");
try {
assert.equal(run("up", "--detach", "--open", "--server", server, "--space", space).status, 0);
Expand Down Expand Up @@ -450,8 +463,7 @@ async function deadBoundListenerReplacementScenario(): Promise<void> {
const port = await freePort();
const server = `nats://127.0.0.1:${port}`;
const space = "backup_dead_listener_replacement";
const env = { ...process.env, COTAL_HOME: home };
const run = (...args: string[]) => spawnSync(tsx, [cliPath, ...args], { cwd: root, env, encoding: "utf8", timeout: 240_000 });
const { run } = sandboxRun(root, home);
const journalPath = join(root, ".cotal", "maintenance", "v1", "journal.json");
try {
assert.equal(run("up", "--detach", "--open", "--server", server, "--space", space).status, 0);
Expand Down Expand Up @@ -497,8 +509,7 @@ async function unboundRestoreReentryScenario(detached: boolean): Promise<void> {
const port = await freePort();
const server = `nats://127.0.0.1:${port}`;
const space = `backup_unbound_${label}`;
const env = { ...process.env, COTAL_HOME: home };
const run = (...args: string[]) => spawnSync(tsx, [cliPath, ...args], { cwd: root, env, encoding: "utf8", timeout: 240_000 });
const { run } = sandboxRun(root, home);
const journalPath = join(root, ".cotal", "maintenance", "v1", "journal.json");
const pidPath = join(root, ".cotal", "nats.pid");
let listenerPid: number | undefined;
Expand Down Expand Up @@ -540,8 +551,7 @@ async function boundForeignListenerScenario(): Promise<void> {
const port = await freePort();
const server = `nats://127.0.0.1:${port}`;
const space = "backup_bound_foreign";
const env = { ...process.env, COTAL_HOME: home };
const run = (...args: string[]) => spawnSync(tsx, [cliPath, ...args], { cwd: root, env, encoding: "utf8", timeout: 240_000 });
const { run } = sandboxRun(root, home);
const journalPath = join(root, ".cotal", "maintenance", "v1", "journal.json");
let foreign: ChildProcess | undefined;
try {
Expand Down Expand Up @@ -589,8 +599,7 @@ async function missingPidfileListenerScenario(): Promise<void> {
const port = await freePort();
const server = `nats://127.0.0.1:${port}`;
const space = "backup_missing_pid_listener";
const env = { ...process.env, COTAL_HOME: home };
const run = (...args: string[]) => spawnSync(tsx, [cliPath, ...args], { cwd: root, env, encoding: "utf8", timeout: 240_000 });
const { run } = sandboxRun(root, home);
const pidPath = join(root, ".cotal", "nats.pid");
const journalPath = join(root, ".cotal", "maintenance", "v1", "journal.json");
let foreign: ChildProcess | undefined;
Expand Down Expand Up @@ -632,17 +641,18 @@ async function preservationCommitCrashScenario(): Promise<void> {
const port = await freePort();
const server = `nats://127.0.0.1:${port}`;
const space = "backup_preserve_commit_crash";
const env = { ...process.env, COTAL_HOME: home };
const run = (...args: string[]) => spawnSync(tsx, [cliPath, ...args], { cwd: root, env, encoding: "utf8", timeout: 240_000 });
const { run, env, sandbox } = sandboxRun(root, home);
const journalPath = join(root, ".cotal", "maintenance", "v1", "journal.json");
try {
assert.equal(run("up", "--detach", "--open", "--server", server, "--space", space).status, 0);
const interrupted = spawnSync(tsx, [cliPath, "down", "--preserve-state"], {
const interruptOptions = {
cwd: root,
env: { ...env, COTAL_SMOKE_EXIT_AFTER_PRESERVATION_MANAGER_COMMIT: "1" },
encoding: "utf8",
encoding: "utf8" as const,
timeout: 240_000,
});
};
assertSmokeSandboxDown(sandbox, ["down", "--preserve-state"], interruptOptions);
const interrupted = spawnSync(tsx, [cliPath, "down", "--preserve-state"], interruptOptions);
assert.equal(interrupted.status, 90);
assert.equal((JSON.parse(readFileSync(journalPath, "utf8")) as { state: string }).state, "cut-committed");
const recovered = run("down", "--preserve-state");
Expand All @@ -667,13 +677,13 @@ async function preservationStopCrashRecoveryScenario(): Promise<void> {
const port = await freePort();
const server = `nats://127.0.0.1:${port}`;
const space = `backup_preserve_stop_${suffix}`;
const env = { ...process.env, COTAL_HOME: home };
const run = (...args: string[]) => spawnSync(tsx, [cliPath, ...args], { cwd: root, env, encoding: "utf8", timeout: 240_000 });
const { run, env, sandbox } = sandboxRun(root, home);
const journalPath = join(root, ".cotal", "maintenance", "v1", "journal.json");
assert.equal(run("up", "--detach", "--open", "--server", server, "--space", space).status, 0, `${suffix} up`);
const mgrPid = Number(readFileSync(join(root, ".cotal", "manager.pid"), "utf8").trim());
const crashed = spawnSync(tsx, [cliPath, "down", "--preserve-state"],
{ cwd: root, env: { ...env, [hook]: "1" }, encoding: "utf8", timeout: 240_000 });
const crashOptions = { cwd: root, env: { ...env, [hook]: "1" }, encoding: "utf8" as const, timeout: 240_000 };
assertSmokeSandboxDown(sandbox, ["down", "--preserve-state"], crashOptions);
const crashed = spawnSync(tsx, [cliPath, "down", "--preserve-state"], crashOptions);
assert.equal((JSON.parse(readFileSync(journalPath, "utf8")) as { state: string }).state, "cut-intent", `${suffix} parked at cut-intent`);
if (alive(mgrPid)) process.kill(mgrPid, "SIGKILL");
await waitUntil(() => !alive(mgrPid), `${suffix} manager dead`);
Expand Down Expand Up @@ -718,8 +728,7 @@ async function restoreClaimRaceScenario(): Promise<void> {
const port = await freePort();
const server = `nats://127.0.0.1:${port}`;
const space = "backup_claim_race";
const env = { ...process.env, COTAL_HOME: home };
const run = (...args: string[]) => spawnSync(tsx, [cliPath, ...args], { cwd: root, env, encoding: "utf8", timeout: 240_000 });
const { run } = sandboxRun(root, home);
try {
assert.equal(run("up", "--detach", "--open", "--server", server, "--space", space).status, 0);
assert.equal(run("down", "--preserve-state").status, 0);
Expand Down Expand Up @@ -783,8 +792,7 @@ async function backupRestoreCycleScenario(): Promise<void> {
const port = await freePort();
const server = `nats://127.0.0.1:${port}`;
const space = "backup_cycle";
const env = { ...process.env, COTAL_HOME: home };
const run = (...args: string[]) => spawnSync(tsx, [cliPath, ...args], { cwd: root, env, encoding: "utf8", timeout: 240_000 });
const { run } = sandboxRun(root, home);
const must = (label: string, result: ReturnType<typeof run>) => {
assert.equal(result.status, 0, `cycle ${label}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
};
Expand Down
9 changes: 8 additions & 1 deletion bin/smoke/backup-usermode-live.smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,18 @@ import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { assertSmokeSandboxDown, recordSmokeSandbox } from "@cotal-ai/smoke-kit";

const worktree = resolve(import.meta.dirname, "..", "..");

// COTAL_HOME must be sandboxed BEFORE any @cotal-ai module loads (module-level state reads it).
const home = mkdtempSync(join(tmpdir(), "cotal-bum-home-"));
const configDir = join(home, "xdg");
process.env.COTAL_HOME = home;
process.env.XDG_CONFIG_HOME = configDir;
const root = mkdtempSync(join(tmpdir(), "cotal-bum-root-"));
const sandbox = recordSmokeSandbox({ root, cotalHome: home, xdgConfigHome: configDir });
const childEnv = { ...process.env, COTAL_HOME: home, XDG_CONFIG_HOME: configDir };

// better-auth is a dependency of implementations/auth, not of this root package, so it does not
// resolve from bin/. Resolve it from the package that owns it rather than widen root deps.
Expand Down Expand Up @@ -86,7 +91,9 @@ const SEEDED_TEXT = "seeded before the user-mode backup";
* process's event loop — and the in-process IdP with it — deadlocking every user-mode step. */
function cotal(args: string[], timeoutMs = 120_000): Promise<{ status: number | null; out: string }> {
return new Promise((resolveRun) => {
const child = spawn(TSX, [BIN, ...args], { cwd: root, env: { ...process.env, COTAL_HOME: home } });
const options = { cwd: root, env: childEnv };
assertSmokeSandboxDown(sandbox, args, options);
const child = spawn(TSX, [BIN, ...args], options);
let out = "";
child.stdout.on("data", (d: Buffer) => { out += d.toString(); });
child.stderr.on("data", (d: Buffer) => { out += d.toString(); });
Expand Down
15 changes: 11 additions & 4 deletions bin/smoke/dogfood-live.smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, wri
import { createServer, type AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { assertSmokeSandboxDown, assertSmokeSandboxTargetDown, recordSmokeSandbox } from "@cotal-ai/smoke-kit";

// Ephemeral OS-assigned ports: no fixed-port collision across back-to-back / concurrent runs.
const freePort = (): Promise<number> =>
Expand All @@ -39,6 +40,7 @@ const configDir = join(sandbox, "xdg");
const home = join(sandbox, "home");
const root = join(sandbox, "proj");
for (const d of [configDir, home, root]) mkdirSync(d, { recursive: true });
const sandboxAnchor = recordSmokeSandbox({ root, cotalHome: home, xdgConfigHome: configDir });

let pass = 0;
const ok = (name: string, cond: boolean, extra?: unknown) => {
Expand All @@ -51,8 +53,13 @@ const env = { ...process.env, XDG_CONFIG_HOME: configDir, COTAL_HOME: home };
const realNode = spawnSync("which", ["node"], { encoding: "utf8" }).stdout.trim();
const tsxCli = join(REPO, "node_modules", "tsx", "dist", "cli.mjs");
const binCotal = join(REPO, "bin", "cotal.ts");
const cotalAt = (cwd: string, args: string[], timeout = 180_000) =>
spawnSync(realNode, [tsxCli, binCotal, ...args], { encoding: "utf8", env, cwd, timeout });
const cotalAt = (cwd: string, args: string[], timeout = 180_000) => {
const options = { encoding: "utf8" as const, env, cwd, timeout };
if (args[0] === "down" && args[1] === "web")
assertSmokeSandboxTargetDown(sandboxAnchor, args, options);
else assertSmokeSandboxDown(sandboxAnchor, args, options);
return spawnSync(realNode, [tsxCli, binCotal, ...args], options);
};
const cotal = (args: string[], timeout = 180_000) => cotalAt(root, args, timeout);
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const alive = (pid: number) => {
Expand Down Expand Up @@ -140,7 +147,7 @@ try {
ok("live /api/meta answers with the mesh's space and serving pid", meta.space === SPACE && meta.pid === foregroundPid && alive(foregroundPid), meta);
const removeLive = cotal(["ext", "remove", "@cotal-ai/web"]);
ok("ext remove refuses to orphan a running web process", removeLive.status === 1 && /cotal down web/.test(removeLive.stderr), removeLive.stderr.slice(-400));
const webDown = cotal(["down", "web"]);
const webDown = cotal(["down", "web", "--space", SPACE]);
if (webChild.exitCode === null)
await Promise.race([new Promise<void>((resolve) => webChild!.once("exit", () => resolve())), sleep(2_000)]);
ok("down web stops the extension-owned process only", webDown.status === 0 && webChild.exitCode !== null, webDown.stdout + webDown.stderr);
Expand Down Expand Up @@ -199,7 +206,7 @@ try {
console.log(`\nDOGFOOD LIVE SMOKE OK ✅ (${pass} checks)`);
} finally {
webChild?.kill("SIGKILL");
spawnSync(realNode, [tsxCli, binCotal, "down"], { encoding: "utf8", env, cwd: root });
cotal(["down"]);
for (const p of ownPids) if (alive(p)) { try { process.kill(p, "SIGTERM"); } catch { /* gone */ } }
rmSync(sandbox, { recursive: true, force: true });
}
Loading
Loading