diff --git a/tui-opentui/src/data.ts b/tui-opentui/src/data.ts index eee8eed..fa942d5 100644 --- a/tui-opentui/src/data.ts +++ b/tui-opentui/src/data.ts @@ -1,340 +1,345 @@ import { type TabKey, type TasqueTask, normalizeTab } from "./model"; export interface TuiConfig { - intervalSeconds: number; - statusCsv: string; - assignee?: string; - initialTab: TabKey; - tsqBin: string; + intervalSeconds: number; + statusCsv: string; + assignee?: string; + initialTab: TabKey; + tsqBin: string; } interface ListEnvelope { - ok: boolean; - data?: { - tasks?: TasqueTask[]; - }; - error?: { - code?: string; - message?: string; - }; + ok: boolean; + data?: { + tasks?: TasqueTask[]; + }; + error?: { + code?: string; + message?: string; + }; } export interface DataSnapshot { - fetchedAt: string; - tasks: TasqueTask[]; - warning?: string; + fetchedAt: string; + tasks: TasqueTask[]; + warning?: string; } export interface DependencyNode { - id: string; - depType?: string; - direction?: string; - task?: TasqueTask; - children: DependencyNode[]; + id: string; + depType?: string; + direction?: string; + task?: TasqueTask; + children: DependencyNode[]; } const DEFAULT_STATUS = "open,in_progress,blocked,deferred,closed,canceled"; export function readConfigFromEnv(): TuiConfig { - const intervalRaw = process.env.TSQ_TUI_INTERVAL ?? "2"; - const parsedInterval = Number.parseInt(intervalRaw, 10); - const intervalSeconds = Number.isFinite(parsedInterval) - ? Math.min(60, Math.max(1, parsedInterval)) - : 2; - - const statusCsv = process.env.TSQ_TUI_STATUS?.trim() || DEFAULT_STATUS; - const assigneeRaw = process.env.TSQ_TUI_ASSIGNEE?.trim(); - const initialTab = normalizeTab(process.env.TSQ_TUI_VIEW?.trim()); - const tsqBin = process.env.TSQ_TUI_BIN?.trim() || "tsq"; - - return { - intervalSeconds, - statusCsv, - assignee: assigneeRaw ? assigneeRaw : undefined, - initialTab, - tsqBin, - }; + const intervalRaw = process.env.TSQ_TUI_INTERVAL ?? "2"; + const parsedInterval = Number.parseInt(intervalRaw, 10); + const intervalSeconds = Number.isFinite(parsedInterval) + ? Math.min(60, Math.max(1, parsedInterval)) + : 2; + + const statusCsv = process.env.TSQ_TUI_STATUS?.trim() || DEFAULT_STATUS; + const assigneeRaw = process.env.TSQ_TUI_ASSIGNEE?.trim(); + const initialTab = normalizeTab(process.env.TSQ_TUI_VIEW?.trim()); + const tsqBin = process.env.TSQ_TUI_BIN?.trim() || "tsq"; + + return { + intervalSeconds, + statusCsv, + assignee: assigneeRaw ? assigneeRaw : undefined, + initialTab, + tsqBin, + }; } export interface TsqSpawnResult { - exitCode: number; - stdout: string; - stderr: string; + exitCode: number; + stdout: string; + stderr: string; } export async function runTsq( - argv: string[], - options: { timeoutMs?: number } = {}, + argv: string[], + options: { timeoutMs?: number } = {}, ): Promise { - const subprocess = Bun.spawn(argv, { - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - timeout: options.timeoutMs ?? 10_000, - killSignal: "SIGKILL", - }); - - const [stdout, stderr, exitCode] = await Promise.all([ - new Response(subprocess.stdout).text().catch(() => ""), - new Response(subprocess.stderr).text().catch(() => ""), - subprocess.exited, - ]); - - return { exitCode: exitCode ?? -1, stdout, stderr }; + const subprocess = Bun.spawn(argv, { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + timeout: options.timeoutMs ?? 10_000, + killSignal: "SIGKILL", + }); + + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(subprocess.stdout).text().catch(() => ""), + new Response(subprocess.stderr).text().catch(() => ""), + subprocess.exited, + ]); + + return { exitCode: exitCode ?? -1, stdout, stderr }; } export function spawnWarning(bin: string, error: unknown): string { - const message = - error instanceof Error && error.message ? error.message : "unknown error"; - return `Failed to run ${bin}: ${message}`; + const message = + error instanceof Error && error.message ? error.message : "unknown error"; + return `Failed to run ${bin}: ${message}`; } export async function fetchTasks(config: TuiConfig): Promise { - const args = ["--json", "watch", "--once", "--status", config.statusCsv]; - if (config.assignee) { - args.push("--assignee", config.assignee); - } - - let result: TsqSpawnResult; - try { - result = await runTsq([config.tsqBin, ...args]); - } catch (error) { - return { - fetchedAt: new Date().toISOString(), - tasks: [], - warning: spawnWarning(config.tsqBin, error), - }; - } - - const fetchedAt = new Date().toISOString(); - - if (result.exitCode !== 0) { - return { - fetchedAt, - tasks: [], - warning: - result.stderr.trim() || - `Failed to run ${config.tsqBin} ${args.join(" ")}`, - }; - } - - const parsed = parseTasksEnvelope(result.stdout); - return { - fetchedAt, - tasks: parsed.tasks, - warning: parsed.warning, - }; + const args = ["--json", "watch", "--once", "--status", config.statusCsv]; + if (config.assignee) { + args.push("--assignee", config.assignee); + } + + let result: TsqSpawnResult; + try { + result = await runTsq([config.tsqBin, ...args]); + } catch (error) { + return { + fetchedAt: new Date().toISOString(), + tasks: [], + warning: spawnWarning(config.tsqBin, error), + }; + } + + const fetchedAt = new Date().toISOString(); + + if (result.exitCode !== 0) { + return { + fetchedAt, + tasks: [], + warning: + result.stderr.trim() || + `Failed to run ${config.tsqBin} ${args.join(" ")}`, + }; + } + + const parsed = parseTasksEnvelope(result.stdout); + return { + fetchedAt, + tasks: parsed.tasks, + warning: parsed.warning, + }; } export function parseTasksEnvelope(stdout: string): { - tasks: TasqueTask[]; - warning?: string; + tasks: TasqueTask[]; + warning?: string; } { - let payload: unknown; - try { - payload = JSON.parse(stdout); - } catch { - return { - tasks: [], - warning: "Unable to parse JSON output from tsq watch --once", - }; - } - - if (!payload || typeof payload !== "object") { - return { - tasks: [], - warning: "Unexpected payload from tsq watch --once", - }; - } - const envelope = payload as ListEnvelope; - - if (typeof envelope.ok !== "boolean") { - return { tasks: [], warning: "Unexpected payload from tsq watch --once" }; - } - - if (!envelope.ok) { - return { - tasks: [], - warning: envelope.error?.message ?? "tsq watch returned an error", - }; - } - - const tasks = envelope.data?.tasks; - if (tasks !== undefined && !Array.isArray(tasks)) { - return { tasks: [], warning: "Task payload missing tasks array" }; - } - - return { tasks: tasks ?? [] }; + let payload: unknown; + try { + payload = JSON.parse(stdout); + } catch { + return { + tasks: [], + warning: "Unable to parse JSON output from tsq watch --once", + }; + } + + if (!payload || typeof payload !== "object") { + return { + tasks: [], + warning: "Unexpected payload from tsq watch --once", + }; + } + const envelope = payload as ListEnvelope; + + if (typeof envelope.ok !== "boolean") { + return { tasks: [], warning: "Unexpected payload from tsq watch --once" }; + } + + if (!envelope.ok) { + return { + tasks: [], + warning: envelope.error?.message ?? "tsq watch returned an error", + }; + } + + const tasks = envelope.data?.tasks; + if (tasks !== undefined && !Array.isArray(tasks)) { + return { tasks: [], warning: "Task payload missing tasks array" }; + } + + return { tasks: tasks ?? [] }; } interface DepEnvelope { - ok: boolean; - data?: { - root?: unknown; - }; - error?: { - code?: string; - message?: string; - }; + ok: boolean; + data?: { + root?: unknown; + }; + error?: { + code?: string; + message?: string; + }; } export async function fetchDependencyTree( - tsqBin: string, - taskId: string, + tsqBin: string, + taskId: string, ): Promise<{ root?: DependencyNode; warning?: string }> { - let result: TsqSpawnResult; - try { - result = await runTsq([ - tsqBin, - "--json", - "deps", - taskId, - "--direction", - "both", - "--depth", - "4", - ]); - } catch (error) { - return { warning: spawnWarning(tsqBin, error) }; - } - - if (result.exitCode !== 0) { - return { - warning: result.stderr.trim() || `Failed to run ${tsqBin} deps ${taskId}`, - }; - } - - return parseDependencyEnvelope(result.stdout); + let result: TsqSpawnResult; + try { + result = await runTsq([ + tsqBin, + "--json", + "deps", + taskId, + "--direction", + "both", + "--depth", + "4", + ]); + } catch (error) { + return { warning: spawnWarning(tsqBin, error) }; + } + + if (result.exitCode !== 0) { + return { + warning: result.stderr.trim() || `Failed to run ${tsqBin} deps ${taskId}`, + }; + } + + return parseDependencyEnvelope(result.stdout); } export function createInFlightGuard( - fetcher: () => Promise, + fetcher: () => Promise, ): () => Promise { - let inFlight = false; - return async () => { - if (inFlight) { - return undefined; - } - inFlight = true; - try { - return await fetcher(); - } finally { - inFlight = false; - } - }; + let inFlight = false; + return async () => { + if (inFlight) { + return undefined; + } + inFlight = true; + try { + return await fetcher(); + } finally { + inFlight = false; + } + }; } export function createLatestGuard(): ( - fetcher: () => Promise, + fetcher: () => Promise, ) => Promise { - let sequence = 0; - return async (fetcher: () => Promise) => { - sequence += 1; - const ticket = sequence; - try { - const result = await fetcher(); - return ticket === sequence ? result : undefined; - } catch (error) { - if (ticket !== sequence) { - return undefined; - } - throw error; - } - }; + let sequence = 0; + return async (fetcher: () => Promise) => { + sequence += 1; + const ticket = sequence; + try { + const result = await fetcher(); + return ticket === sequence ? result : undefined; + } catch (error) { + if (ticket !== sequence) { + return undefined; + } + throw error; + } + }; } export function parseDependencyEnvelope(stdout: string): { - root?: DependencyNode; - warning?: string; + root?: DependencyNode; + warning?: string; } { - let payload: unknown; - try { - payload = JSON.parse(stdout); - } catch { - return { warning: "Unable to parse JSON output from tsq deps" }; - } - - if (!payload || typeof payload !== "object") { - return { warning: "Unexpected payload from tsq deps" }; - } - const envelope = payload as DepEnvelope; - - if (typeof envelope.ok !== "boolean") { - return { warning: "Unexpected payload from tsq deps" }; - } - - if (!envelope.ok) { - return { warning: envelope.error?.message ?? "tsq deps returned an error" }; - } - - const root = normalizeDependencyNode(envelope.data?.root); - if (!root) { - return { warning: "Dependency tree payload missing root node" }; - } - - return { root }; + let payload: unknown; + try { + payload = JSON.parse(stdout); + } catch { + return { warning: "Unable to parse JSON output from tsq deps" }; + } + + if (!payload || typeof payload !== "object") { + return { warning: "Unexpected payload from tsq deps" }; + } + const envelope = payload as DepEnvelope; + + if (typeof envelope.ok !== "boolean") { + return { warning: "Unexpected payload from tsq deps" }; + } + + if (!envelope.ok) { + return { warning: envelope.error?.message ?? "tsq deps returned an error" }; + } + + const root = normalizeDependencyNode(envelope.data?.root); + if (!root) { + return { warning: "Dependency tree payload missing root node" }; + } + + return { root }; } function normalizeDependencyNode(value: unknown): DependencyNode | undefined { - if (!value || typeof value !== "object") { - return undefined; - } - const item = value as Record; - const id = typeof item.id === "string" ? item.id : undefined; - if (!id) { - return undefined; - } - - const task = normalizeTask(item.task); - const rawChildren = Array.isArray(item.children) ? item.children : []; - const children = rawChildren - .map((child) => normalizeDependencyNode(child)) - .filter((node): node is DependencyNode => node !== undefined); - - return { - id, - depType: typeof item.dep_type === "string" ? item.dep_type : undefined, - direction: typeof item.direction === "string" ? item.direction : undefined, - task, - children, - }; + if (!value || typeof value !== "object") { + return undefined; + } + const item = value as Record; + const id = typeof item.id === "string" ? item.id : undefined; + if (!id) { + return undefined; + } + + const task = normalizeTask(item.task); + const rawChildren = Array.isArray(item.children) ? item.children : []; + const children = rawChildren + .map((child) => normalizeDependencyNode(child)) + .filter((node): node is DependencyNode => node !== undefined); + + return { + id, + depType: typeof item.dep_type === "string" ? item.dep_type : undefined, + direction: typeof item.direction === "string" ? item.direction : undefined, + task, + children, + }; } function normalizeTask(value: unknown): TasqueTask | undefined { - if (!value || typeof value !== "object") { - return undefined; - } - const item = value as Record; - if ( - typeof item.id !== "string" || - typeof item.kind !== "string" || - typeof item.title !== "string" || - typeof item.status !== "string" || - typeof item.priority !== "number" || - !Array.isArray(item.labels) || - typeof item.created_at !== "string" || - typeof item.updated_at !== "string" - ) { - return undefined; - } - - return { - id: item.id, - kind: item.kind as TasqueTask["kind"], - title: item.title, - status: item.status as TasqueTask["status"], - priority: item.priority, - assignee: typeof item.assignee === "string" ? item.assignee : undefined, - parent_id: typeof item.parent_id === "string" ? item.parent_id : undefined, - planning_state: - item.planning_state === "needs_planning" || item.planning_state === "planned" - ? item.planning_state - : undefined, - labels: item.labels.filter((label): label is string => typeof label === "string"), - created_at: item.created_at, - updated_at: item.updated_at, - spec_path: typeof item.spec_path === "string" ? item.spec_path : undefined, - spec_fingerprint: - typeof item.spec_fingerprint === "string" ? item.spec_fingerprint : undefined, - }; + if (!value || typeof value !== "object") { + return undefined; + } + const item = value as Record; + if ( + typeof item.id !== "string" || + typeof item.kind !== "string" || + typeof item.title !== "string" || + typeof item.status !== "string" || + typeof item.priority !== "number" || + !Array.isArray(item.labels) || + typeof item.created_at !== "string" || + typeof item.updated_at !== "string" + ) { + return undefined; + } + + return { + id: item.id, + kind: item.kind as TasqueTask["kind"], + title: item.title, + status: item.status as TasqueTask["status"], + priority: item.priority, + assignee: typeof item.assignee === "string" ? item.assignee : undefined, + parent_id: typeof item.parent_id === "string" ? item.parent_id : undefined, + planning_state: + item.planning_state === "needs_planning" || + item.planning_state === "planned" + ? item.planning_state + : undefined, + labels: item.labels.filter( + (label): label is string => typeof label === "string", + ), + created_at: item.created_at, + updated_at: item.updated_at, + spec_path: typeof item.spec_path === "string" ? item.spec_path : undefined, + spec_fingerprint: + typeof item.spec_fingerprint === "string" + ? item.spec_fingerprint + : undefined, + }; } diff --git a/tui-opentui/src/tui-helpers.ts b/tui-opentui/src/tui-helpers.ts index b335764..c3c7cea 100644 --- a/tui-opentui/src/tui-helpers.ts +++ b/tui-opentui/src/tui-helpers.ts @@ -1,4 +1,9 @@ -import { type DependencyNode, type TsqSpawnResult, runTsq, spawnWarning } from "./data"; +import { + type DependencyNode, + type TsqSpawnResult, + runTsq, + spawnWarning, +} from "./data"; import { TAB_ORDER, TASK_STATUS_ORDER, diff --git a/tui-opentui/tests/data.test.ts b/tui-opentui/tests/data.test.ts index f3db590..2c97246 100644 --- a/tui-opentui/tests/data.test.ts +++ b/tui-opentui/tests/data.test.ts @@ -1,220 +1,224 @@ import { describe, expect, it } from "bun:test"; -import { parseDependencyEnvelope, parseTasksEnvelope, runTsq } from "../src/data"; +import { + parseDependencyEnvelope, + parseTasksEnvelope, + runTsq, +} from "../src/data"; import { parseSpecEnvelope } from "../src/tui-helpers"; const sampleTask = { - id: "tsq-1", - kind: "task", - title: "demo", - status: "open", - priority: 2, - labels: [], - created_at: "2026-01-01T00:00:00.000Z", - updated_at: "2026-01-01T00:00:00.000Z", + id: "tsq-1", + kind: "task", + title: "demo", + status: "open", + priority: 2, + labels: [], + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-01T00:00:00.000Z", }; describe("parseTasksEnvelope", () => { - it("returns tasks from a valid watch envelope", () => { - const stdout = JSON.stringify({ - ok: true, - data: { frame_ts: "2026-01-01T00:00:00.000Z", tasks: [sampleTask] }, - }); - const result = parseTasksEnvelope(stdout); - expect(result.warning).toBeUndefined(); - expect(result.tasks).toHaveLength(1); - expect(result.tasks[0]?.id).toBe("tsq-1"); - }); - - it("surfaces the error message from an ok:false envelope", () => { - const stdout = JSON.stringify({ - ok: false, - error: { code: "VALIDATION_ERROR", message: "bad filter" }, - }); - const result = parseTasksEnvelope(stdout); - expect(result.tasks).toEqual([]); - expect(result.warning).toBe("bad filter"); - }); - - it("warns on malformed JSON", () => { - const result = parseTasksEnvelope("not json"); - expect(result.tasks).toEqual([]); - expect(result.warning).toBe( - "Unable to parse JSON output from tsq watch --once", - ); - }); - - it("returns an empty task list when data is missing", () => { - const result = parseTasksEnvelope(JSON.stringify({ ok: true })); - expect(result.tasks).toEqual([]); - expect(result.warning).toBeUndefined(); - }); - - it("warns on a null payload", () => { - const result = parseTasksEnvelope("null"); - expect(result.tasks).toEqual([]); - expect(result.warning).toBe("Unexpected payload from tsq watch --once"); - }); - - it("warns on a non-object payload", () => { - const result = parseTasksEnvelope(JSON.stringify("oops")); - expect(result.tasks).toEqual([]); - expect(result.warning).toBe("Unexpected payload from tsq watch --once"); - }); - - it("warns on a non-boolean ok field", () => { - const result = parseTasksEnvelope( - JSON.stringify({ ok: "false", data: { tasks: [sampleTask] } }), - ); - expect(result.tasks).toEqual([]); - expect(result.warning).toBe("Unexpected payload from tsq watch --once"); - }); - - it("warns on a non-array tasks field", () => { - const result = parseTasksEnvelope( - JSON.stringify({ ok: true, data: { tasks: "not-a-list" } }), - ); - expect(result.tasks).toEqual([]); - expect(result.warning).toBe("Task payload missing tasks array"); - }); + it("returns tasks from a valid watch envelope", () => { + const stdout = JSON.stringify({ + ok: true, + data: { frame_ts: "2026-01-01T00:00:00.000Z", tasks: [sampleTask] }, + }); + const result = parseTasksEnvelope(stdout); + expect(result.warning).toBeUndefined(); + expect(result.tasks).toHaveLength(1); + expect(result.tasks[0]?.id).toBe("tsq-1"); + }); + + it("surfaces the error message from an ok:false envelope", () => { + const stdout = JSON.stringify({ + ok: false, + error: { code: "VALIDATION_ERROR", message: "bad filter" }, + }); + const result = parseTasksEnvelope(stdout); + expect(result.tasks).toEqual([]); + expect(result.warning).toBe("bad filter"); + }); + + it("warns on malformed JSON", () => { + const result = parseTasksEnvelope("not json"); + expect(result.tasks).toEqual([]); + expect(result.warning).toBe( + "Unable to parse JSON output from tsq watch --once", + ); + }); + + it("returns an empty task list when data is missing", () => { + const result = parseTasksEnvelope(JSON.stringify({ ok: true })); + expect(result.tasks).toEqual([]); + expect(result.warning).toBeUndefined(); + }); + + it("warns on a null payload", () => { + const result = parseTasksEnvelope("null"); + expect(result.tasks).toEqual([]); + expect(result.warning).toBe("Unexpected payload from tsq watch --once"); + }); + + it("warns on a non-object payload", () => { + const result = parseTasksEnvelope(JSON.stringify("oops")); + expect(result.tasks).toEqual([]); + expect(result.warning).toBe("Unexpected payload from tsq watch --once"); + }); + + it("warns on a non-boolean ok field", () => { + const result = parseTasksEnvelope( + JSON.stringify({ ok: "false", data: { tasks: [sampleTask] } }), + ); + expect(result.tasks).toEqual([]); + expect(result.warning).toBe("Unexpected payload from tsq watch --once"); + }); + + it("warns on a non-array tasks field", () => { + const result = parseTasksEnvelope( + JSON.stringify({ ok: true, data: { tasks: "not-a-list" } }), + ); + expect(result.tasks).toEqual([]); + expect(result.warning).toBe("Task payload missing tasks array"); + }); }); describe("parseDependencyEnvelope", () => { - it("returns the root node from a valid deps envelope", () => { - const stdout = JSON.stringify({ - ok: true, - data: { - root: { - id: "tsq-1", - task: sampleTask, - children: [ - { - id: "tsq-2", - dep_type: "blocks", - direction: "outgoing", - children: [], - }, - ], - }, - }, - }); - const result = parseDependencyEnvelope(stdout); - expect(result.warning).toBeUndefined(); - expect(result.root?.id).toBe("tsq-1"); - expect(result.root?.task?.title).toBe("demo"); - expect(result.root?.children).toHaveLength(1); - expect(result.root?.children[0]?.depType).toBe("blocks"); - }); - - it("surfaces the error message from an ok:false envelope", () => { - const stdout = JSON.stringify({ - ok: false, - error: { message: "task not found" }, - }); - expect(parseDependencyEnvelope(stdout).warning).toBe("task not found"); - }); - - it("warns on malformed JSON", () => { - expect(parseDependencyEnvelope("{oops").warning).toBe( - "Unable to parse JSON output from tsq deps", - ); - }); - - it("warns when the root node is missing", () => { - const result = parseDependencyEnvelope( - JSON.stringify({ ok: true, data: {} }), - ); - expect(result.root).toBeUndefined(); - expect(result.warning).toBe("Dependency tree payload missing root node"); - }); - - it("warns on a null payload", () => { - const result = parseDependencyEnvelope("null"); - expect(result.root).toBeUndefined(); - expect(result.warning).toBe("Unexpected payload from tsq deps"); - }); - - it("warns on a non-object payload", () => { - const result = parseDependencyEnvelope(JSON.stringify(42)); - expect(result.root).toBeUndefined(); - expect(result.warning).toBe("Unexpected payload from tsq deps"); - }); - - it("warns on a non-boolean ok field", () => { - const result = parseDependencyEnvelope(JSON.stringify({ ok: "true" })); - expect(result.root).toBeUndefined(); - expect(result.warning).toBe("Unexpected payload from tsq deps"); - }); + it("returns the root node from a valid deps envelope", () => { + const stdout = JSON.stringify({ + ok: true, + data: { + root: { + id: "tsq-1", + task: sampleTask, + children: [ + { + id: "tsq-2", + dep_type: "blocks", + direction: "outgoing", + children: [], + }, + ], + }, + }, + }); + const result = parseDependencyEnvelope(stdout); + expect(result.warning).toBeUndefined(); + expect(result.root?.id).toBe("tsq-1"); + expect(result.root?.task?.title).toBe("demo"); + expect(result.root?.children).toHaveLength(1); + expect(result.root?.children[0]?.depType).toBe("blocks"); + }); + + it("surfaces the error message from an ok:false envelope", () => { + const stdout = JSON.stringify({ + ok: false, + error: { message: "task not found" }, + }); + expect(parseDependencyEnvelope(stdout).warning).toBe("task not found"); + }); + + it("warns on malformed JSON", () => { + expect(parseDependencyEnvelope("{oops").warning).toBe( + "Unable to parse JSON output from tsq deps", + ); + }); + + it("warns when the root node is missing", () => { + const result = parseDependencyEnvelope( + JSON.stringify({ ok: true, data: {} }), + ); + expect(result.root).toBeUndefined(); + expect(result.warning).toBe("Dependency tree payload missing root node"); + }); + + it("warns on a null payload", () => { + const result = parseDependencyEnvelope("null"); + expect(result.root).toBeUndefined(); + expect(result.warning).toBe("Unexpected payload from tsq deps"); + }); + + it("warns on a non-object payload", () => { + const result = parseDependencyEnvelope(JSON.stringify(42)); + expect(result.root).toBeUndefined(); + expect(result.warning).toBe("Unexpected payload from tsq deps"); + }); + + it("warns on a non-boolean ok field", () => { + const result = parseDependencyEnvelope(JSON.stringify({ ok: "true" })); + expect(result.root).toBeUndefined(); + expect(result.warning).toBe("Unexpected payload from tsq deps"); + }); }); describe("parseSpecEnvelope", () => { - it("splits data.spec.content into lines", () => { - const stdout = JSON.stringify({ - ok: true, - data: { - spec: { - path: ".tasque/specs/tsq-1/spec.md", - fingerprint: "abc123", - content: "# hello\r\nworld", - }, - }, - }); - const result = parseSpecEnvelope(stdout); - expect(result.warning).toBeUndefined(); - expect(result.lines).toEqual(["# hello", "world"]); - }); - - it("returns a placeholder for empty content", () => { - const stdout = JSON.stringify({ - ok: true, - data: { spec: { path: "p", fingerprint: "f", content: "" } }, - }); - expect(parseSpecEnvelope(stdout).lines).toEqual(["(empty spec)"]); - }); - - it("surfaces the error message from an ok:false envelope", () => { - const stdout = JSON.stringify({ - ok: false, - error: { message: "no spec attached" }, - }); - const result = parseSpecEnvelope(stdout); - expect(result.lines).toEqual([]); - expect(result.warning).toBe("no spec attached"); - }); - - it("warns on malformed JSON", () => { - expect(parseSpecEnvelope("").warning).toBe( - "Unable to parse JSON output from tsq spec --show", - ); - }); - - it("warns when data.spec.content is missing", () => { - const result = parseSpecEnvelope( - JSON.stringify({ ok: true, data: { spec: { path: "p" } } }), - ); - expect(result.lines).toEqual([]); - expect(result.warning).toBe("Spec payload missing content"); - }); + it("splits data.spec.content into lines", () => { + const stdout = JSON.stringify({ + ok: true, + data: { + spec: { + path: ".tasque/specs/tsq-1/spec.md", + fingerprint: "abc123", + content: "# hello\r\nworld", + }, + }, + }); + const result = parseSpecEnvelope(stdout); + expect(result.warning).toBeUndefined(); + expect(result.lines).toEqual(["# hello", "world"]); + }); + + it("returns a placeholder for empty content", () => { + const stdout = JSON.stringify({ + ok: true, + data: { spec: { path: "p", fingerprint: "f", content: "" } }, + }); + expect(parseSpecEnvelope(stdout).lines).toEqual(["(empty spec)"]); + }); + + it("surfaces the error message from an ok:false envelope", () => { + const stdout = JSON.stringify({ + ok: false, + error: { message: "no spec attached" }, + }); + const result = parseSpecEnvelope(stdout); + expect(result.lines).toEqual([]); + expect(result.warning).toBe("no spec attached"); + }); + + it("warns on malformed JSON", () => { + expect(parseSpecEnvelope("").warning).toBe( + "Unable to parse JSON output from tsq spec --show", + ); + }); + + it("warns when data.spec.content is missing", () => { + const result = parseSpecEnvelope( + JSON.stringify({ ok: true, data: { spec: { path: "p" } } }), + ); + expect(result.lines).toEqual([]); + expect(result.warning).toBe("Spec payload missing content"); + }); }); describe("runTsq", () => { - it("returns stdout, stderr, and exitCode for a normal process", async () => { - const result = await runTsq([ - process.execPath, - "-e", - "process.stdout.write('hi'); process.stderr.write('bye'); process.exit(3)", - ]); - - expect(result).toEqual({ exitCode: 3, stdout: "hi", stderr: "bye" }); - }); - - it("returns a non-zero exit when a process times out", async () => { - const start = Date.now(); - const result = await runTsq( - [process.execPath, "-e", "setInterval(() => {}, 1000)"], - { timeoutMs: 300 }, - ); - - expect(result.exitCode).not.toBe(0); - expect(Date.now() - start).toBeLessThan(5000); - }); + it("returns stdout, stderr, and exitCode for a normal process", async () => { + const result = await runTsq([ + process.execPath, + "-e", + "process.stdout.write('hi'); process.stderr.write('bye'); process.exit(3)", + ]); + + expect(result).toEqual({ exitCode: 3, stdout: "hi", stderr: "bye" }); + }); + + it("returns a non-zero exit when a process times out", async () => { + const start = Date.now(); + const result = await runTsq( + [process.execPath, "-e", "setInterval(() => {}, 1000)"], + { timeoutMs: 300 }, + ); + + expect(result.exitCode).not.toBe(0); + expect(Date.now() - start).toBeLessThan(5000); + }); });