diff --git a/src/index.ts b/src/index.ts index cb0050a..7ed52fc 100755 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,10 @@ import chalk from "chalk"; import { loadUserConfig } from "./config"; +import { type Config } from "./config/types"; +import { startRenderer } from "./renderers"; import { getRunOptions } from "./runOptions/runOptions"; +import { type RunOptions } from "./runOptions/types"; import { hasTaskFailures, runTasks, startWatcher } from "./task"; void main().catch((error) => { @@ -12,6 +15,16 @@ void main().catch((error) => { process.exit(1); }); +const executeRun = async (config: Config, runOptions: RunOptions) => { + const stopRenderer = startRenderer(config, runOptions); + + try { + await runTasks(config); + } finally { + stopRenderer(); + } +}; + /** * Main entry point for executing tasks. */ @@ -34,12 +47,14 @@ async function main() { } if (!runOptions.isWatchMode) { - await runTasks(config, runOptions); + await executeRun(config, runOptions); process.exit(hasTaskFailures(config.tasks) ? 1 : 0); } - await runTasks(config, runOptions); + await executeRun(config, runOptions); - startWatcher(config, runOptions); + startWatcher(config, () => { + return executeRun(config, runOptions); + }); } diff --git a/src/renderers/index.ts b/src/renderers/index.ts index b4d10cd..b5fc0ee 100644 --- a/src/renderers/index.ts +++ b/src/renderers/index.ts @@ -1 +1 @@ -export { render } from "./render"; +export { startRenderer } from "./renderScheduler"; diff --git a/src/renderers/render.test.ts b/src/renderers/render.test.ts index be4ec3e..bfda5da 100644 --- a/src/renderers/render.test.ts +++ b/src/renderers/render.test.ts @@ -343,6 +343,36 @@ describe("render", () => { expect(logSpy).not.toHaveBeenCalledWith("Details:"); }); + it("uses current time for unfinished overall duration when only running tasks have started", () => { + vi.useFakeTimers(); + const now = new Date("2026-02-24T00:00:02.500Z"); + vi.setSystemTime(now); + Object.defineProperty(process.stdout, "isTTY", { + configurable: true, + value: false, + }); + + const tasks = [ + createTaskDouble({ + label: "Type Check", + tool: "TypeScript", + state: "running", + message: "Running...", + startTime: now.getTime() - 900, + endTime: null, + }), + ]; + + render(createConfig(tasks), baseRunOptions); + + expect(renderTableMock).toHaveBeenCalledWith(tasks, [ + "⏳ Overall", + "", + "0 issues", + "900 ms", + ]); + }); + it("does not print failure details until suite is finished", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(noOp); Object.defineProperty(process.stdout, "isTTY", { @@ -633,7 +663,7 @@ describe("render", () => { ]); }); - it("ignores tasks with partial timing information when computing suite duration", () => { + it("uses started tasks when computing suite duration even if end time is missing", () => { Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: false, @@ -672,7 +702,7 @@ describe("render", () => { "🟢 Overall", "", "0 issues", - "1.0 s", + "2.0 s", ]); }); diff --git a/src/renderers/render.ts b/src/renderers/render.ts index 020b25e..b604429 100644 --- a/src/renderers/render.ts +++ b/src/renderers/render.ts @@ -107,6 +107,8 @@ export const formatUnsupportedTools = (unsupportedTools: string[]) => { export const render = (config: Config, runOptions: RunOptions) => { const { tasks, unsupportedTools } = config; + const timeNow = Date.now(); + if (process.stdout.isTTY) { console.clear(); } @@ -150,28 +152,38 @@ export const render = (config: Config, runOptions: RunOptions) => { ); const { suiteStartTime, suiteDurationMs } = (() => { - let hasCompleteTask = false; - let startTime = Number.POSITIVE_INFINITY; - let endTime = Number.NEGATIVE_INFINITY; - - for (const task of tasks) { - const start = task.getStartTime(); - const end = task.getEndTime(); - - if (start !== null && end !== null) { - hasCompleteTask = true; - startTime = Math.min(startTime, start); - endTime = Math.max(endTime, end); + const { hasStartedTask, startTime, effectiveEndTime } = tasks.reduce( + (acc, task) => { + const start = task.getStartTime(); + const end = task.getEndTime(); + + if (start !== null) { + return { + hasStartedTask: true, + startTime: Math.min(acc.startTime, start), + effectiveEndTime: Math.max( + acc.effectiveEndTime, + end ?? (suiteFinished ? start : timeNow) + ), + }; + } + + return acc; + }, + { + hasStartedTask: false, + startTime: Number.POSITIVE_INFINITY, + effectiveEndTime: Number.NEGATIVE_INFINITY, } - } + ); - if (!hasCompleteTask) { + if (!hasStartedTask) { return { suiteStartTime: null, suiteDurationMs: 0 }; } return { suiteStartTime: startTime, - suiteDurationMs: endTime - startTime, + suiteDurationMs: effectiveEndTime - startTime, }; })(); @@ -184,7 +196,7 @@ export const render = (config: Config, runOptions: RunOptions) => { const overallDurationMs = suiteFinished ? suiteDurationMs : suiteStartTime - ? Date.now() - suiteStartTime + ? timeNow - suiteStartTime : 0; const breakdownParts = []; diff --git a/src/renderers/renderScheduler.test.ts b/src/renderers/renderScheduler.test.ts new file mode 100644 index 0000000..784160c --- /dev/null +++ b/src/renderers/renderScheduler.test.ts @@ -0,0 +1,95 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { type Config } from "../config/types"; +import { type RunOptions } from "../runOptions/types"; + +import { render } from "./render"; +import { RENDER_INTERVAL_MS, startRenderer } from "./renderScheduler"; + +vi.mock("./render", () => { + return { + render: vi.fn(), + }; +}); + +const renderMock = vi.mocked(render); + +const baseRunOptions: RunOptions = { + isFixMode: false, + isSilentMode: false, + isWatchMode: false, + isNoColor: false, + configPath: undefined, +}; + +const baseConfig: Config = { + tasks: [], + unsupportedTools: [], + executionMode: "parallel", +}; + +describe("startRenderer", () => { + afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + it("starts a render interval in TTY mode and renders on each tick", () => { + vi.useFakeTimers(); + Object.defineProperty(process.stdout, "isTTY", { + configurable: true, + value: true, + }); + + const stop = startRenderer(baseConfig, baseRunOptions); + + expect(renderMock).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(RENDER_INTERVAL_MS - 1); + expect(renderMock).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(1); + expect(renderMock).toHaveBeenCalledTimes(2); + + vi.advanceTimersByTime(RENDER_INTERVAL_MS); + expect(renderMock).toHaveBeenCalledTimes(3); + + stop(); + }); + + it("does not start an interval when stdout is not a TTY", () => { + vi.useFakeTimers(); + Object.defineProperty(process.stdout, "isTTY", { + configurable: true, + value: false, + }); + + const stop = startRenderer(baseConfig, baseRunOptions); + + vi.advanceTimersByTime(RENDER_INTERVAL_MS * 3); + + expect(renderMock).toHaveBeenCalledTimes(1); + expect(stop).toBeTypeOf("function"); + + stop(); + expect(renderMock).toHaveBeenCalledTimes(2); + }); + + it("stops rendering after cleanup", () => { + vi.useFakeTimers(); + Object.defineProperty(process.stdout, "isTTY", { + configurable: true, + value: true, + }); + + const stop = startRenderer(baseConfig, baseRunOptions); + + vi.advanceTimersByTime(RENDER_INTERVAL_MS); + expect(renderMock).toHaveBeenCalledTimes(2); + + stop(); + vi.advanceTimersByTime(RENDER_INTERVAL_MS * 2); + + expect(renderMock).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/renderers/renderScheduler.ts b/src/renderers/renderScheduler.ts new file mode 100644 index 0000000..ce93c6e --- /dev/null +++ b/src/renderers/renderScheduler.ts @@ -0,0 +1,33 @@ +import { type Config } from "../config/types"; +import { type RunOptions } from "../runOptions/types"; + +import { render } from "./render"; + +export const RENDER_INTERVAL_MS = 1000; + +/** + * Starts a concurrent renderer scheduler for TTY output and returns a cleanup + * function that stops the scheduler. + * + * @param {Config} config - Config and task metadata to render. + * @param {RunOptions} runOptions - Options that influenced the run. + * @returns {() => void} Cleanup function for the renderer scheduler. + */ +export const startRenderer = (config: Config, runOptions: RunOptions) => { + render(config, runOptions); + + if (!process.stdout.isTTY) { + return () => { + render(config, runOptions); + }; + } + + const interval = setInterval(() => { + render(config, runOptions); + }, RENDER_INTERVAL_MS); + + return () => { + clearInterval(interval); + render(config, runOptions); + }; +}; diff --git a/src/task/execute.test.ts b/src/task/execute.test.ts index 6b31d48..b709871 100644 --- a/src/task/execute.test.ts +++ b/src/task/execute.test.ts @@ -1,21 +1,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { type Config } from "../config/types"; -import { render } from "../renderers"; import { type RunOptions } from "../runOptions/types"; import { calculateTotalIssues, hasTaskFailures, runTasks } from "./execute"; import { Task } from "./task"; import { type TaskStatus } from "./types"; -vi.mock("../renderers", () => { - return { - render: vi.fn(), - }; -}); - -const renderMock = vi.mocked(render); - const baseRunOptions: RunOptions = { isFixMode: false, isSilentMode: false, @@ -25,7 +16,6 @@ const baseRunOptions: RunOptions = { }; type MockExecute = ReturnType; -type ExecuteHooks = { onStart?: () => void; onFinish?: () => void }; type TaskDoubleOptions = { tool: string; dependsOn?: readonly string[]; @@ -49,11 +39,9 @@ const createMockedTask = ({ }: TaskDoubleOptions) => { const executeMock: MockExecute = execute ?? - vi.fn(({ onStart, onFinish }: ExecuteHooks = {}) => { - onStart?.(); - onFinish?.(); + vi.fn(() => { + return Promise.resolve(); }); - const cancelMock = vi.fn(() => { cancel?.(); }); @@ -88,48 +76,12 @@ const createMockedTask = ({ return { task, executeMock, cancelMock, resetMock }; }; -const createTaskDouble = (tool: string) => { - const executeMock: MockExecute = vi.fn( - ({ onStart, onFinish }: ExecuteHooks = {}) => { - onStart?.(); - onFinish?.(); - } - ); - const { task } = createMockedTask({ - tool, - execute: executeMock, - }); - - return { task, executeMock }; -}; - describe("runTasks", () => { afterEach(() => { vi.clearAllMocks(); }); - it("renders before running tasks and whenever callbacks fire", async () => { - const firstTask = createTaskDouble("first-tool"); - const secondTask = createTaskDouble("second-tool"); - const config: Config = { - unsupportedTools: [], - tasks: [firstTask.task, secondTask.task], - executionMode: "parallel", - }; - - await runTasks(config, baseRunOptions); - - expect(renderMock).toHaveBeenCalledTimes(1 + config.tasks.length * 2); - renderMock.mock.calls.forEach(([configArg, optionsArg]) => { - expect(configArg).toBe(config); - expect(optionsArg).toBe(baseRunOptions); - }); - - expect(firstTask.executeMock).toHaveBeenCalledTimes(1); - expect(secondTask.executeMock).toHaveBeenCalledTimes(1); - }); - - it("resets tasks before the first render of each run", async () => { + it("resets tasks before execution begins", async () => { const resetEvents: string[] = []; const firstTask = createMockedTask({ tool: "first", @@ -149,40 +101,31 @@ describe("runTasks", () => { executionMode: "parallel", }; - await runTasks(config, baseRunOptions); + await runTasks(config); expect(firstTask.resetMock).toHaveBeenCalledTimes(1); expect(secondTask.resetMock).toHaveBeenCalledTimes(1); expect(resetEvents).toEqual(["first:reset", "second:reset"]); - expect(renderMock).toHaveBeenNthCalledWith(1, config, baseRunOptions); }); it("runs tasks sequentially when configured", async () => { let resolveFirst: (() => void) | undefined; const events: string[] = []; - const firstExecuteMock = vi.fn( - ({ onStart, onFinish }: ExecuteHooks = {}) => { - events.push("first:start"); - onStart?.(); + const firstExecuteMock = vi.fn(() => { + events.push("first:start"); - return new Promise((resolve) => { - resolveFirst = () => { - events.push("first:finish"); - onFinish?.(); - resolve(); - }; - }); - } - ); - const secondExecuteMock = vi.fn( - ({ onStart, onFinish }: ExecuteHooks = {}) => { - events.push("second:start"); - onStart?.(); - events.push("second:finish"); - onFinish?.(); - return Promise.resolve(); - } - ); + return new Promise((resolve) => { + resolveFirst = () => { + events.push("first:finish"); + resolve(); + }; + }); + }); + const secondExecuteMock = vi.fn(() => { + events.push("second:start"); + events.push("second:finish"); + return Promise.resolve(); + }); const config: Config = { executionMode: "sequential", unsupportedTools: [], @@ -192,7 +135,7 @@ describe("runTasks", () => { ], }; - const runPromise = runTasks(config, baseRunOptions); + const runPromise = runTasks(config); await Promise.resolve(); expect(firstExecuteMock).toHaveBeenCalledTimes(1); @@ -214,34 +157,26 @@ describe("runTasks", () => { let resolveFirst: (() => void) | undefined; let resolveSecond: (() => void) | undefined; const events: string[] = []; - const firstExecuteMock = vi.fn( - ({ onStart, onFinish }: ExecuteHooks = {}) => { - events.push("first:start"); - onStart?.(); + const firstExecuteMock = vi.fn(() => { + events.push("first:start"); - return new Promise((resolve) => { - resolveFirst = () => { - events.push("first:finish"); - onFinish?.(); - resolve(); - }; - }); - } - ); - const secondExecuteMock = vi.fn( - ({ onStart, onFinish }: ExecuteHooks = {}) => { - events.push("second:start"); - onStart?.(); + return new Promise((resolve) => { + resolveFirst = () => { + events.push("first:finish"); + resolve(); + }; + }); + }); + const secondExecuteMock = vi.fn(() => { + events.push("second:start"); - return new Promise((resolve) => { - resolveSecond = () => { - events.push("second:finish"); - onFinish?.(); - resolve(); - }; - }); - } - ); + return new Promise((resolve) => { + resolveSecond = () => { + events.push("second:finish"); + resolve(); + }; + }); + }); const config: Config = { executionMode: "parallel", unsupportedTools: [], @@ -251,7 +186,7 @@ describe("runTasks", () => { ], }; - const runPromise = runTasks(config, baseRunOptions); + const runPromise = runTasks(config); await Promise.resolve(); expect(firstExecuteMock).toHaveBeenCalledTimes(1); @@ -267,29 +202,21 @@ describe("runTasks", () => { let resolveFirst: (() => void) | undefined; const events: string[] = []; - const firstExecuteMock = vi.fn( - ({ onStart, onFinish }: ExecuteHooks = {}) => { - events.push("first:start"); - onStart?.(); + const firstExecuteMock = vi.fn(() => { + events.push("first:start"); - return new Promise((resolve) => { - resolveFirst = () => { - events.push("first:finish"); - onFinish?.(); - resolve(); - }; - }); - } - ); - const secondExecuteMock = vi.fn( - ({ onStart, onFinish }: ExecuteHooks = {}) => { - events.push("second:start"); - onStart?.(); - events.push("second:finish"); - onFinish?.(); - return Promise.resolve(); - } - ); + return new Promise((resolve) => { + resolveFirst = () => { + events.push("first:finish"); + resolve(); + }; + }); + }); + const secondExecuteMock = vi.fn(() => { + events.push("second:start"); + events.push("second:finish"); + return Promise.resolve(); + }); const firstTask = createMockedTask({ tool: "first", @@ -307,7 +234,7 @@ describe("runTasks", () => { tasks: [firstTask, secondTask], }; - const runPromise = runTasks(config, baseRunOptions); + const runPromise = runTasks(config); await Promise.resolve(); expect(firstExecuteMock).toHaveBeenCalledTimes(1); @@ -329,11 +256,9 @@ describe("runTasks", () => { const events: string[] = []; const makeExecuteMock = (name: string) => { - return vi.fn(({ onStart, onFinish }: ExecuteHooks = {}) => { + return vi.fn(() => { events.push(`${name}:start`); - onStart?.(); events.push(`${name}:finish`); - onFinish?.(); return Promise.resolve(); }); }; @@ -366,7 +291,7 @@ describe("runTasks", () => { ], }; - await runTasks(config, baseRunOptions); + await runTasks(config); expect(events.indexOf("prettier:finish")).toBeLessThan( events.indexOf("eslint:start") @@ -391,11 +316,9 @@ describe("runTasks", () => { const events: string[] = []; const makeExecuteMock = (name: string) => { - return vi.fn(({ onStart, onFinish }: ExecuteHooks = {}) => { + return vi.fn(() => { events.push(`${name}:start`); - onStart?.(); events.push(`${name}:finish`); - onFinish?.(); return Promise.resolve(); }); }; @@ -418,7 +341,7 @@ describe("runTasks", () => { ], }; - await runTasks(config, baseRunOptions); + await runTasks(config); expect(events).toEqual([ "B:start", @@ -439,13 +362,11 @@ describe("runTasks", () => { name: string, resolver: (fn: () => void) => void ) => { - return vi.fn(({ onStart, onFinish }: ExecuteHooks = {}) => { + return vi.fn(() => { events.push(`${name}:start`); - onStart?.(); return new Promise((resolve) => { resolver(() => { events.push(`${name}:finish`); - onFinish?.(); resolve(); }); }); @@ -458,11 +379,9 @@ describe("runTasks", () => { const secondMock = makeAsyncMock("second", (fn) => { resolveSecond = fn; }); - const thirdMock = vi.fn(({ onStart, onFinish }: ExecuteHooks = {}) => { + const thirdMock = vi.fn(() => { events.push("third:start"); - onStart?.(); events.push("third:finish"); - onFinish?.(); return Promise.resolve(); }); @@ -480,7 +399,7 @@ describe("runTasks", () => { ], }; - const runPromise = runTasks(config, baseRunOptions); + const runPromise = runTasks(config); await Promise.resolve(); expect(firstMock).toHaveBeenCalledTimes(1); @@ -517,8 +436,6 @@ describe("runTasks", () => { }).task; }; - // A fails; B and C both depend on A; D depends on both B and C. - // D should only be cancelled once even though it is reachable via both B and C. const taskA = createMockedTask({ tool: "A", execute: vi.fn(() => { @@ -542,7 +459,7 @@ describe("runTasks", () => { tasks: [taskA, taskB, taskC, taskD], }; - await runTasks(config, baseRunOptions); + await runTasks(config); expect(cancelMockD).toHaveBeenCalledTimes(1); }); @@ -551,20 +468,16 @@ describe("runTasks", () => { let resolveFirst: (() => void) | undefined; let resolveSecond: (() => void) | undefined; - const firstMock = vi.fn(({ onStart, onFinish }: ExecuteHooks = {}) => { - onStart?.(); + const firstMock = vi.fn(() => { return new Promise((resolve) => { resolveFirst = () => { - onFinish?.(); resolve(); }; }); }); - const secondMock = vi.fn(({ onStart, onFinish }: ExecuteHooks = {}) => { - onStart?.(); + const secondMock = vi.fn(() => { return new Promise((resolve) => { resolveSecond = () => { - onFinish?.(); resolve(); }; }); @@ -579,7 +492,7 @@ describe("runTasks", () => { ], }; - const runPromise = runTasks(config, baseRunOptions); + const runPromise = runTasks(config); await Promise.resolve(); let finished = false; @@ -638,7 +551,7 @@ describe("runTasks", () => { tasks: [failingTask, dependentTask], }; - await runTasks(config, baseRunOptions); + await runTasks(config); expect(failingExecuteMock).toHaveBeenCalledTimes(1); expect(dependentExecuteMock).not.toHaveBeenCalled(); @@ -672,7 +585,7 @@ describe("runTasks", () => { tasks: [failingTask, dependentTask], }; - await runTasks(config, baseRunOptions); + await runTasks(config); expect(failingExecuteMock).toHaveBeenCalledTimes(1); expect(dependentExecuteMock).not.toHaveBeenCalled(); @@ -697,7 +610,7 @@ describe("runTasks", () => { ], }; - await expect(runTasks(config, baseRunOptions)).rejects.toThrowError( + await expect(runTasks(config)).rejects.toThrowError( /could not find a runnable task/i ); }); @@ -761,8 +674,8 @@ describe("hasTaskFailures", () => { status: { state: "success", message: "Passed" }, }).task, createMockedTask({ - tool: "running", - status: { state: "running", message: "Running..." }, + tool: "also-success", + status: { state: "success", message: "Passed" }, }).task, ]; diff --git a/src/task/execute.ts b/src/task/execute.ts index 8a4aca9..a6dfb0e 100644 --- a/src/task/execute.ts +++ b/src/task/execute.ts @@ -1,6 +1,4 @@ import { type Config } from "../config/types"; -import { render } from "../renderers"; -import { type RunOptions } from "../runOptions/types"; import { type Task } from "./task"; @@ -8,15 +6,12 @@ import { type Task } from "./task"; * Runs the specified tasks with the given run options. * * @param {Config} config - The configuration containing the tasks to run. - * @param {RunOptions} runOptions - The options to use when running the tasks. */ -export const runTasks = async (config: Config, runOptions: RunOptions) => { +export const runTasks = async (config: Config) => { config.tasks.forEach((task) => { task.reset(); }); - render(config, runOptions); - const remaining: Record = Object.fromEntries( config.tasks.map((task) => { return [task.tool, task.dependsOn.length]; @@ -44,7 +39,6 @@ export const runTasks = async (config: Config, runOptions: RunOptions) => { if (!completed.has(dependent.tool)) { dependent.cancel(); completed.add(dependent.tool); - render(config, runOptions); cancelDependents(dependent); } } @@ -66,17 +60,6 @@ export const runTasks = async (config: Config, runOptions: RunOptions) => { } }; - const executeTask = (task: Task) => { - return task.execute({ - onStart: () => { - render(config, runOptions); - }, - onFinish: () => { - render(config, runOptions); - }, - }); - }; - if (config.executionMode === "sequential") { const runNextSequentialTask = async (): Promise => { const nextTask = config.tasks.find((task) => { @@ -98,7 +81,7 @@ export const runTasks = async (config: Config, runOptions: RunOptions) => { ); } - await executeTask(nextTask); + await nextTask.execute(); onTaskComplete(nextTask); await runNextSequentialTask(); }; @@ -114,7 +97,7 @@ export const runTasks = async (config: Config, runOptions: RunOptions) => { if (readyQueue.length > 0) { for (const task of readyQueue.splice(0)) { inFlight++; - Promise.resolve(executeTask(task)) + Promise.resolve(task.execute()) .then(() => { inFlight--; onTaskComplete(task); diff --git a/src/task/task.test.ts b/src/task/task.test.ts index c7a000b..835c154 100644 --- a/src/task/task.test.ts +++ b/src/task/task.test.ts @@ -564,22 +564,26 @@ describe("Task", () => { }); const task = createMockTask(); - const runPromise = task.execute({ - onStart: () => { - expect(task.getStatus()).toEqual({ - state: "running", - message: "Running...", - }); - expect(task.getStartTime()).not.toBeNull(); - expect(task.getEndTime()).toBeNull(); - expect(task.getDuration()).toBeNull(); - }, + const dateSpy = vi + .spyOn(Date, "now") + .mockReturnValueOnce(1_000) + .mockReturnValueOnce(1_500) + .mockReturnValueOnce(2_000); + const runPromise = task.execute(); + + expect(task.getStatus()).toEqual({ + state: "running", + message: "Running...", }); + expect(task.getStartTime()).not.toBeNull(); + expect(task.getEndTime()).toBeNull(); + expect(task.getDuration()).toBe(500); resolveRun?.(); await runPromise; expect(task.getStatus()).toEqual({ state: "success", message: "Passed" }); + dateSpy.mockRestore(); }); it("counts warnings without forcing a synthetic error", async () => { @@ -671,10 +675,12 @@ describe("Task", () => { expect(task.getDuration()).toBeNull(); }); - it("returns null duration when start time exists but end time is missing", () => { + it("returns live duration when start time exists but end time is missing", () => { const task = createMockTask({}, {}, { startTime: 1000, endTime: null }); + const dateSpy = vi.spyOn(Date, "now").mockReturnValue(1_250); - expect(task.getDuration()).toBeNull(); + expect(task.getDuration()).toBe(250); + dateSpy.mockRestore(); }); it("marks the task as failed when the parser finds issues despite a zero exit code", async () => { diff --git a/src/task/task.ts b/src/task/task.ts index 7fbd066..83d342a 100644 --- a/src/task/task.ts +++ b/src/task/task.ts @@ -172,9 +172,15 @@ export class Task { } getDuration() { - return this.endTime !== null && this.startTime !== null - ? this.endTime - this.startTime - : null; + if (this.startTime === null) { + return null; + } + + if (this.endTime === null) { + return Date.now() - this.startTime; + } + + return this.endTime - this.startTime; } getFailures() { diff --git a/src/task/watcher.test.ts b/src/task/watcher.test.ts index 3772af5..7f0b2e0 100644 --- a/src/task/watcher.test.ts +++ b/src/task/watcher.test.ts @@ -8,14 +8,12 @@ import { vi, } from "vitest"; -import { type RunOptions } from "../runOptions/types"; - import { startWatcher } from "./watcher"; -const { watchMock, runTasksMock, hasTaskFailuresMock } = vi.hoisted(() => { +const { watchMock, runMock, hasTaskFailuresMock } = vi.hoisted(() => { return { watchMock: vi.fn(), - runTasksMock: vi.fn(), + runMock: vi.fn(), hasTaskFailuresMock: vi.fn(), }; }); @@ -30,19 +28,10 @@ vi.mock("chokidar", () => { vi.mock("./execute", () => { return { - runTasks: runTasksMock, hasTaskFailures: hasTaskFailuresMock, }; }); -const baseRunOptions: RunOptions = { - isFixMode: false, - isSilentMode: false, - isWatchMode: true, - isNoColor: false, - configPath: undefined, -}; - type FileChangeHandler = (() => void) | undefined; describe("startWatcher", () => { @@ -54,7 +43,7 @@ describe("startWatcher", () => { beforeEach(() => { watchMock.mockReset(); - runTasksMock.mockReset(); + runMock.mockReset(); hasTaskFailuresMock.mockReset(); fileChangeHandler = undefined; @@ -79,14 +68,14 @@ describe("startWatcher", () => { watchMock.mockReturnValue(watcher); }); - it("uses default ignore patterns and reruns tasks on change events", () => { + it("uses default ignore patterns and handles change events", () => { const config: Config = { unsupportedTools: [], tasks: [], executionMode: "parallel", }; - startWatcher(config, baseRunOptions); + startWatcher(config, runMock); expect(watchMock).toHaveBeenCalledWith(".", { ignored: ["**/node_modules/**", "**/.git/**"], @@ -96,11 +85,11 @@ describe("startWatcher", () => { expect(fileChangeHandler).toBeDefined(); fileChangeHandler?.(); - expect(runTasksMock).toHaveBeenCalledTimes(1); - expect(runTasksMock).toHaveBeenCalledWith(config, baseRunOptions); + expect(runMock).toHaveBeenCalledTimes(1); + expect(runMock).toHaveBeenCalledWith(); }); - it("prevents overlapping reruns while tasks are executing", async () => { + it("prevents overlapping change handling while work is executing", async () => { const config: Config = { unsupportedTools: [], tasks: [], @@ -108,25 +97,62 @@ describe("startWatcher", () => { }; let resolveRun: (() => void) | undefined; - runTasksMock.mockImplementation(() => { + runMock.mockImplementation(() => { return new Promise((resolve) => { resolveRun = resolve; }); }); - startWatcher(config, baseRunOptions); + startWatcher(config, runMock); expect(fileChangeHandler).toBeDefined(); fileChangeHandler?.(); fileChangeHandler?.(); - expect(runTasksMock).toHaveBeenCalledTimes(1); + expect(runMock).toHaveBeenCalledTimes(1); resolveRun?.(); await Promise.resolve(); fileChangeHandler?.(); - expect(runTasksMock).toHaveBeenCalledTimes(2); + expect(runMock).toHaveBeenCalledTimes(2); + }); + + it("logs rejected change handling without breaking future runs", async () => { + const config: Config = { + unsupportedTools: [], + tasks: [], + executionMode: "parallel", + }; + const error = new Error("watch run failed"); + const consoleErrorSpy: MockInstance = vi + .spyOn(console, "error") + .mockImplementation(() => { + return undefined; + }); + + runMock.mockRejectedValueOnce(error).mockResolvedValueOnce(undefined); + + startWatcher(config, runMock); + expect(fileChangeHandler).toBeDefined(); + + fileChangeHandler?.(); + await vi.waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalledTimes(2); + }); + + expect(consoleErrorSpy).toHaveBeenNthCalledWith( + 1, + "Unexpected error while handling a watched file change." + ); + expect(consoleErrorSpy).toHaveBeenNthCalledWith(2, error); + + fileChangeHandler?.(); + await Promise.resolve(); + + expect(runMock).toHaveBeenCalledTimes(2); + + consoleErrorSpy.mockRestore(); }); it("closes the watcher and exits with correct status when interrupted", () => { @@ -152,7 +178,7 @@ describe("startWatcher", () => { return process; }) as typeof process.on); - startWatcher(config, baseRunOptions); + startWatcher(config, runMock); signalHandlers.SIGINT(); expect(watcher.close).toHaveBeenCalledTimes(1); diff --git a/src/task/watcher.ts b/src/task/watcher.ts index 6c70968..2724fea 100644 --- a/src/task/watcher.ts +++ b/src/task/watcher.ts @@ -1,48 +1,54 @@ import chokidar from "chokidar"; import { type Config } from "../config/types"; -import { type RunOptions } from "../runOptions/types"; -import { hasTaskFailures, runTasks } from "./execute"; - -let isRunning = false; +import { hasTaskFailures } from "./execute"; /** - * Reruns the specified tasks with the given run options. + * Logs watcher change handler failures without letting them escape as unhandled rejections. * - * @param {Config} config - The configuration containing the tasks to rerun. - * @param {RunOptions} runOptions - The options to use when rerunning the tasks. - * @returns {Promise} + * @param {unknown} error - The failure raised while handling a file change. */ -const rerunTasks = async (config: Config, runOptions: RunOptions) => { - if (isRunning) { - return; - } - - isRunning = true; - - try { - await runTasks(config, runOptions); - } finally { - isRunning = false; - } +const logWatcherError = (error: unknown) => { + console.error("Unexpected error while handling a watched file change."); + console.error(error); }; /** - * Starts the file watcher for the specified tasks. + * Starts watching files and invokes the provided change handler. * - * @param {Config} config - The configuration containing the tasks to watch. - * @param {RunOptions} runOptions - The options to use when running the tasks. + * @param {Config} config - The configuration containing watcher settings. + * @param {() => Promise} onChange - Function that handles a detected change. */ -export const startWatcher = (config: Config, runOptions: RunOptions) => { +export const startWatcher = (config: Config, onChange: () => Promise) => { + let isRunning = false; const watcher = chokidar.watch(".", { ignored: config.watchIgnore ?? ["**/node_modules/**", "**/.git/**"], persistent: true, ignoreInitial: true, }); + /** + * Executes change handling work when the watcher observes a change. + * + * @returns {Promise} + */ + const handleChange = async () => { + if (isRunning) { + return; + } + + isRunning = true; + + try { + await onChange(); + } finally { + isRunning = false; + } + }; + watcher.on("all", () => { - void rerunTasks(config, runOptions); + void handleChange().catch(logWatcherError); }); const handleExit = () => {