import { test } from "node:test"; import assert from "node:assert/strict"; import { mkdtemp, mkdir, writeFile, readdir, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import type { Paths } from "../lib/paths"; import { ulid } from "./ulid"; import { listAllJobs, getJobEntry, pruneJobLogs, readLogTail, } from "./listJobs"; // Run with: pnpm --filter yt-dlp-transcript-common exec tsx --test common/jobs/listJobs.test.ts // // listAllJobs/pruneJobLogs only touch paths.jobsDir and the (empty in a fresh // process) global registry, so a stub Paths with just jobsDir + seeded log/meta // files exercises the full disk path. Ids are ULIDs stamped at a known time so // jobIdTime() orders them deterministically. const BASE = 1_700_000_000_000; async function withJobs( fn: (paths: Paths, seed: (i: number, t: number) => Promise) => Promise, ): Promise { const dir = await mkdtemp(path.join(tmpdir(), "list-jobs-")); const jobsDir = path.join(dir, ".jobs"); await mkdir(jobsDir, { recursive: true }); const paths = { jobsDir } as Paths; const seed = async (i: number, t: number): Promise => { const id = ulid(t); await writeFile(path.join(jobsDir, `${id}.log`), `log ${i}\n`); await writeFile( path.join(jobsDir, `${id}.meta.json`), JSON.stringify({ id, kind: "sync", queueKey: "q", status: "done", queuedAt: t, startedAt: t, endedAt: t + 500, exitCode: 0, }), ); return id; }; try { await fn(paths, seed); } finally { await rm(dir, { recursive: true, force: true }); } } async function logCount(paths: Paths): Promise { return (await readdir(paths.jobsDir)).filter((n) => n.endsWith(".log")).length; } test("listAllJobs paginates newest-first and reports hasMore/total", async () => { await withJobs(async (paths, seed) => { const ids: string[] = []; for (let i = 0; i < 5; i++) ids.push(await seed(i, BASE + i * 1000)); const page1 = await listAllJobs(paths, { limit: 2 }); assert.equal(page1.entries.length, 2); assert.equal(page1.hasMore, true); assert.equal(page1.total, 5); // Newest first: the last two seeded ids, in reverse order. assert.deepEqual( page1.entries.map((e) => e.id), [ids[4], ids[3]], ); const all = await listAllJobs(paths, { limit: 5 }); assert.equal(all.hasMore, false); assert.equal(all.entries.length, 5); }); }); test("listAllJobs before-cursor skips newer entries", async () => { await withJobs(async (paths, seed) => { const ids: string[] = []; for (let i = 0; i < 4; i++) ids.push(await seed(i, BASE + i * 1000)); // Everything strictly older than the 3rd job's time. const page = await listAllJobs(paths, { limit: 10, before: BASE + 2000 }); assert.deepEqual( page.entries.map((e) => e.id), [ids[1], ids[0]], ); }); }); // The boot pass (bootQueuedJobs.ts) closes a meta a restart left `queued` as // `cancelled` with a `cancelReason`; /jobs draws it from the entry. test("a cancelled meta's cancelReason reaches the entry; any other status drops it", async () => { await withJobs(async (paths) => { const write = async (id: string, status: string) => { await writeFile(path.join(paths.jobsDir, `${id}.log`), "log\n"); await writeFile( path.join(paths.jobsDir, `${id}.meta.json`), JSON.stringify({ id, kind: "whisper-all", queueKey: "q", status, queuedAt: BASE, endedAt: BASE + 1, cancelReason: "queued before the last restart, stale", }), ); }; const cancelled = ulid(BASE); const done = ulid(BASE + 1); await write(cancelled, "cancelled"); await write(done, "done"); const one = await getJobEntry(paths, cancelled); assert.equal(one?.status, "cancelled"); assert.equal(one?.cancelReason, "queued before the last restart, stale"); const page = await listAllJobs(paths); const byId = new Map(page.entries.map((e) => [e.id, e])); assert.equal( byId.get(cancelled)?.cancelReason, "queued before the last restart, stale", ); // A reason beside a status it does not explain is not carried. assert.equal(byId.get(done)?.cancelReason, undefined); }); }); test("getJobEntry resolves one job and null for unknown ids", async () => { await withJobs(async (paths, seed) => { const id = await seed(0, BASE); const entry = await getJobEntry(paths, id); assert.ok(entry); assert.equal(entry.id, id); assert.equal(entry.kind, "sync"); assert.equal(await getJobEntry(paths, "does-not-exist"), null); }); }); test("pruneJobLogs keepLast drops the oldest tail", async () => { await withJobs(async (paths, seed) => { for (let i = 0; i < 5; i++) await seed(i, BASE + i * 1000); const { deleted } = await pruneJobLogs(paths, { keepLast: 2 }); assert.equal(deleted, 3); assert.equal(await logCount(paths), 2); // Meta sidecars go too. assert.equal( (await readdir(paths.jobsDir)).filter((n) => n.endsWith(".meta.json")).length, 2, ); }); }); test("pruneJobLogs olderThanMs drops only aged entries", async () => { await withJobs(async (paths, seed) => { await seed(0, BASE); // old await seed(1, BASE + 1000); // old const fresh = await seed(2, BASE + 10 * 60 * 60 * 1000); // 10h newer const now = BASE + 11 * 60 * 60 * 1000; const { deleted } = await pruneJobLogs(paths, { olderThanMs: 60 * 60 * 1000, // 1h now, }); assert.equal(deleted, 2); const remaining = await readdir(paths.jobsDir); assert.ok(remaining.includes(`${fresh}.log`)); }); }); test("pruneJobLogs all clears everything", async () => { await withJobs(async (paths, seed) => { for (let i = 0; i < 3; i++) await seed(i, BASE + i * 1000); const { deleted } = await pruneJobLogs(paths, { all: true }); assert.equal(deleted, 3); assert.equal(await logCount(paths), 0); }); }); test("readLogTail returns the last N lines, read from the end, never a half line", async () => { const dir = await mkdtemp(path.join(tmpdir(), "log-tail-")); try { const file = path.join(dir, "a.log"); await writeFile(file, Array.from({ length: 100 }, (_, i) => `line ${i}`).join("\n") + "\n\n"); assert.deepEqual(await readLogTail(file, 3), ["line 97", "line 98", "line 99"]); // A window smaller than the file: the first (partial) line is dropped. const small = await readLogTail(file, 1000, 20); assert.ok(small.length > 0 && small.length < 100); assert.ok(small.every((l) => /^line \d+$/.test(l))); assert.equal(small[small.length - 1], "line 99"); assert.deepEqual(await readLogTail(file, 0), []); assert.deepEqual(await readLogTail(path.join(dir, "missing.log"), 5), []); } finally { await rm(dir, { recursive: true, force: true }); } });