import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { openDatabase, type CompanionDatabase } from "../src/database.js"; import { JobRunner, type JobDefinition, type JobRun } from "../src/jobs.js"; const databases: CompanionDatabase[] = []; const directories: string[] = []; function database(path = ":memory:"): CompanionDatabase { const instance = openDatabase(path); databases.push(instance); return instance; } function runnerWith(run: JobDefinition["run"], timeoutMs = 100): JobRunner { const instance = database(); instance .prepare("INSERT INTO users (id, username, password_hash, role, created_at) VALUES (1, 'tester', 'unused', 'admin', ?)") .run(new Date().toISOString()); return new JobRunner(instance, [ { id: "test-job", title: "Test job", description: "Test only", timeoutMs, riskLevel: "low", confirmationRequired: false, run } ]); } async function settled(runner: JobRunner, id: string): Promise { for (let attempt = 0; attempt < 50; attempt += 1) { const run = runner.getRun(id); if (run && !["queued", "running"].includes(run.status)) return run; await new Promise((resolve) => setTimeout(resolve, 2)); } throw new Error("Job did not settle during the test"); } afterEach(() => { for (const instance of databases.splice(0)) { if (instance.open) instance.close(); } for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true }); }); describe("job runner", () => { it("runs only registered jobs and persists successful output", async () => { const runner = runnerWith(async () => ({ ok: true })); const statuses: string[] = []; const unsubscribe = runner.onRun((run) => statuses.push(run.status)); expect(runner.has("test-job")).toBe(true); expect(runner.has("shell-command")).toBe(false); expect(() => runner.enqueue("shell-command", 1)).toThrow("UNKNOWN_JOB"); const queued = runner.enqueue("test-job", 1); const run = await settled(runner, queued.id); expect(run).toMatchObject({ jobId: "test-job", status: "succeeded", output: { ok: true } }); expect(statuses).toEqual(["queued", "running", "succeeded"]); unsubscribe(); }); it("redacts likely secrets and bounds stored output", async () => { const secretRunner = runnerWith(async () => ({ password: "do-not-store", message: "authorization=abc123", nested: { accessToken: "also-secret" } })); const secretRun = await settled(secretRunner, secretRunner.enqueue("test-job", 1).id); expect(secretRun.output).toEqual({ password: "[REDACTED]", message: "[REDACTED]", nested: { accessToken: "[REDACTED]" } }); const largeRunner = runnerWith(async () => ({ value: "x".repeat(40_000) })); const largeRun = await settled(largeRunner, largeRunner.enqueue("test-job", 1).id); expect(largeRun.output).toMatchObject({ truncated: true }); }); it("marks jobs that exceed their timeout", async () => { const runner = runnerWith(() => new Promise(() => undefined), 5); const run = await settled(runner, runner.enqueue("test-job", 1).id); expect(run).toMatchObject({ status: "timed_out", errorCode: "JOB_TIMEOUT" }); }); it("marks incomplete persisted runs as interrupted on restart", () => { const directory = mkdtempSync(join(tmpdir(), "pi-car-jobs-")); directories.push(directory); const path = join(directory, "companion.db"); const first = database(path); first .prepare("INSERT INTO job_runs (id, job_id, status, created_at) VALUES ('run-1', 'test-job', 'running', ?)") .run(new Date().toISOString()); first.close(); const reopened = database(path); const row = reopened.prepare("SELECT status, error_code FROM job_runs WHERE id = 'run-1'").get(); expect(row).toEqual({ status: "interrupted", error_code: "PROCESS_RESTARTED" }); }); it("generates a manifest-based support bundle without account or session values", async () => { const instance = database(); instance .prepare("INSERT INTO users (id, username, password_hash, role, created_at) VALUES (1, 'private-owner', 'private-hash', 'admin', ?)") .run(new Date().toISOString()); instance .prepare("INSERT INTO sessions (id_hash, user_id, expires_at, created_at) VALUES ('private-session', 1, ?, ?)") .run(new Date(Date.now() + 60_000).toISOString(), new Date().toISOString()); const runner = new JobRunner(instance); const run = await settled(runner, runner.enqueue("support-bundle", 1).id); expect(run.status).toBe("succeeded"); expect(run.output).toMatchObject({ manifest: { format: "pi-car-companion-support-bundle", version: 1 }, diagnostics: { database: { tableCounts: { users: 1, sessions: 1 } } } }); const serialized = JSON.stringify(run.output); expect(serialized).not.toContain("private-owner"); expect(serialized).not.toContain("private-hash"); expect(serialized).not.toContain("private-session"); }); });