271 lines
11 KiB
TypeScript
271 lines
11 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { isTaskStuck, countStuckTasks } from "./taskStuck";
|
|
import type { Task } from "@fusion/core";
|
|
|
|
const createTask = (overrides: Partial<Task> = {}): Task =>
|
|
({
|
|
id: "FN-001",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: "2026-01-01T00:00:00Z",
|
|
updatedAt: "2026-01-01T00:00:00Z",
|
|
columnMovedAt: "2026-01-01T00:00:00Z",
|
|
...overrides,
|
|
}) as Task;
|
|
|
|
describe("isTaskStuck", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-04-04T12:00:00Z"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("returns false when timeout is undefined (disabled)", () => {
|
|
const task = createTask({ updatedAt: "2026-04-04T06:00:00Z" });
|
|
expect(isTaskStuck(task, undefined)).toBe(false);
|
|
});
|
|
|
|
it("returns false when timeout is 0", () => {
|
|
const task = createTask({ updatedAt: "2026-04-04T06:00:00Z" });
|
|
expect(isTaskStuck(task, 0)).toBe(false);
|
|
});
|
|
|
|
it("returns false when timeout is negative", () => {
|
|
const task = createTask({ updatedAt: "2026-04-04T06:00:00Z" });
|
|
expect(isTaskStuck(task, -1)).toBe(false);
|
|
});
|
|
|
|
it("returns false for non-in-progress tasks", () => {
|
|
const task = createTask({ column: "todo", updatedAt: "2026-04-04T06:00:00Z" });
|
|
expect(isTaskStuck(task, 600000)).toBe(false);
|
|
});
|
|
|
|
it("returns false for failed in-progress tasks", () => {
|
|
const stale = new Date(Date.now() - 600001).toISOString();
|
|
const task = createTask({ status: "failed", updatedAt: stale });
|
|
expect(isTaskStuck(task, 600000)).toBe(false);
|
|
});
|
|
|
|
it("returns false for stuck-killed in-progress tasks", () => {
|
|
const stale = new Date(Date.now() - 600001).toISOString();
|
|
const task = createTask({ status: "stuck-killed", updatedAt: stale });
|
|
expect(isTaskStuck(task, 600000)).toBe(false);
|
|
});
|
|
|
|
it("returns false for recent in-progress tasks within timeout", () => {
|
|
const recent = new Date(Date.now() - 300000).toISOString(); // 5 minutes ago
|
|
const task = createTask({ updatedAt: recent });
|
|
expect(isTaskStuck(task, 600000)).toBe(false); // 10 minute timeout
|
|
});
|
|
|
|
it("returns true for stale in-progress tasks exceeding timeout", () => {
|
|
const stale = new Date(Date.now() - 600001).toISOString(); // just over 10 minutes
|
|
const task = createTask({ updatedAt: stale });
|
|
expect(isTaskStuck(task, 600000)).toBe(true);
|
|
});
|
|
|
|
it("returns false for malformed updatedAt", () => {
|
|
const task = createTask({ updatedAt: "not-a-date" });
|
|
expect(isTaskStuck(task, 600000)).toBe(false);
|
|
});
|
|
|
|
it("returns false for empty updatedAt", () => {
|
|
const task = createTask({ updatedAt: "" });
|
|
expect(isTaskStuck(task, 600000)).toBe(false);
|
|
});
|
|
|
|
it("handles tasks in triage column", () => {
|
|
const stale = new Date(Date.now() - 600001).toISOString();
|
|
const task = createTask({ column: "triage", updatedAt: stale });
|
|
expect(isTaskStuck(task, 600000)).toBe(false);
|
|
});
|
|
|
|
it("handles tasks in done column", () => {
|
|
const stale = new Date(Date.now() - 600001).toISOString();
|
|
const task = createTask({ column: "done", updatedAt: stale });
|
|
expect(isTaskStuck(task, 600000)).toBe(false);
|
|
});
|
|
|
|
it("returns true exactly at timeout boundary (greater than)", () => {
|
|
const boundary = new Date(Date.now() - 600001).toISOString();
|
|
const task = createTask({ updatedAt: boundary });
|
|
expect(isTaskStuck(task, 600000)).toBe(true);
|
|
});
|
|
|
|
it("returns false exactly at timeout boundary (equal)", () => {
|
|
const boundary = new Date(Date.now() - 600000).toISOString();
|
|
const task = createTask({ updatedAt: boundary });
|
|
expect(isTaskStuck(task, 600000)).toBe(false);
|
|
});
|
|
|
|
describe("dataAsOfMs parameter (freshness-aware stuck detection)", () => {
|
|
it("uses dataAsOfMs instead of Date.now() when provided", () => {
|
|
// Task updatedAt is 11 minutes ago
|
|
const taskUpdatedAt = new Date(Date.now() - 11 * 60 * 1000).toISOString();
|
|
const task = createTask({ updatedAt: taskUpdatedAt });
|
|
|
|
// dataAsOfMs is 5 minutes ago (task was fresh 5 minutes ago)
|
|
const dataAsOfMs = Date.now() - 5 * 60 * 1000;
|
|
|
|
// 10 minute timeout
|
|
// With dataAsOfMs: 5 min - 11 min = -6 min < 10 min → NOT stuck
|
|
// Without dataAsOfMs: 0 min - 11 min = -11 min > 10 min → stuck
|
|
expect(isTaskStuck(task, 600000, dataAsOfMs)).toBe(false);
|
|
});
|
|
|
|
it("falls back to Date.now() when dataAsOfMs is undefined", () => {
|
|
// Task updatedAt is 5 minutes ago
|
|
const taskUpdatedAt = new Date(Date.now() - 5 * 60 * 1000).toISOString();
|
|
const task = createTask({ updatedAt: taskUpdatedAt });
|
|
|
|
// Without dataAsOfMs, should use Date.now() → NOT stuck (within 10 min timeout)
|
|
expect(isTaskStuck(task, 600000)).toBe(false);
|
|
});
|
|
|
|
it("correctly identifies a task that would be stuck with Date.now() but not with dataAsOfMs", () => {
|
|
// Scenario: Tab was in background for 20 minutes
|
|
// Task was updated 10 minutes ago (relative to dataAsOfMs)
|
|
// dataAsOfMs represents "10 minutes ago" (when we fetched fresh data)
|
|
// Date.now() is "now" (20 minutes after the fetch)
|
|
//
|
|
// This simulates the background tab scenario:
|
|
// - User opened tab at T=0, fetched tasks
|
|
// - Tab went to background at T=0
|
|
// - User came back at T=20
|
|
// - dataAsOfMs = T=0 (when we last had fresh data)
|
|
// - Task was updated at T=-10 (10 minutes before fetch)
|
|
// - task.updatedAt represents T=-10
|
|
//
|
|
// Check: dataAsOfMs - updatedAt = 0 - (-10) = 10 min < 10 min timeout → NOT stuck
|
|
// Without dataAsOfMs: Date.now() - updatedAt = 20 - (-10) = 30 min > 10 min → STUCK (false positive!)
|
|
|
|
// In fake timers, we set Date.now() to a fixed point
|
|
// Let's say Date.now() = 1000 (representing "now")
|
|
// dataAsOfMs = 0 (representing 20 minutes before "now" in fake time)
|
|
// task.updatedAt = -600 (representing 10 minutes before dataAsOfMs)
|
|
|
|
vi.setSystemTime(new Date(1000)); // Date.now() = 1000
|
|
const dataAsOfMs = 0; // 20 minutes before Date.now() in this scenario
|
|
const taskUpdatedAt = new Date(-600000).toISOString(); // 10 minutes before dataAsOfMs
|
|
const task = createTask({ updatedAt: taskUpdatedAt });
|
|
|
|
// With dataAsOfMs: 0 - (-600000) = 600000ms = 10 min = timeout → NOT stuck (boundary)
|
|
// Without dataAsOfMs: 1000 - (-600000) = 601000ms > 10 min → STUCK
|
|
// The key test: with dataAsOfMs it should NOT be stuck even though Date.now() would say it is
|
|
expect(isTaskStuck(task, 600000, dataAsOfMs)).toBe(false);
|
|
});
|
|
|
|
it("prevents false positive when tab was in background", () => {
|
|
// Simulate: Tab in background, data fetched 15 min ago
|
|
// Task.updatedAt is 12 min ago (stale from server perspective)
|
|
// taskStuckTimeoutMs = 10 min
|
|
// With fresh data (15 min ago): 15 - 12 = 3 min < 10 min → NOT stuck
|
|
// With stale Date.now(): 0 - 12 = 12 min > 10 min → STUCK (FALSE POSITIVE)
|
|
|
|
vi.setSystemTime(new Date(0)); // Date.now() = 0
|
|
const dataAsOfMs = -900000; // 15 minutes ago (in fake time)
|
|
const taskUpdatedAt = new Date(-720000).toISOString(); // 12 minutes ago (in fake time)
|
|
const task = createTask({ updatedAt: taskUpdatedAt });
|
|
|
|
// With dataAsOfMs: -900000 - (-720000) = -180000ms = -3 min < 10 min → NOT stuck
|
|
// Without dataAsOfMs: 0 - (-720000) = 720000ms = 12 min > 10 min → STUCK
|
|
expect(isTaskStuck(task, 600000, dataAsOfMs)).toBe(false);
|
|
});
|
|
|
|
it("correctly identifies genuinely stuck tasks even with dataAsOfMs", () => {
|
|
// Task really is stuck: updatedAt is 15 min ago, timeout is 10 min
|
|
// With dataAsOfMs of 2 min ago: 2 - 15 = -13 min < 10 min → NOT stuck (hmm, this is a problem)
|
|
|
|
// Actually, dataAsOfMs should represent when we last got FRESH data from the server
|
|
// If dataAsOfMs = 2 min ago and task.updatedAt = 15 min ago, the task was stale
|
|
// even when we fetched it, because 2 - 15 = -13 min > 10 min timeout
|
|
|
|
vi.setSystemTime(new Date(0));
|
|
const dataAsOfMs = -120000; // 2 minutes ago
|
|
const taskUpdatedAt = new Date(-900000).toISOString(); // 15 minutes ago
|
|
const task = createTask({ updatedAt: taskUpdatedAt });
|
|
|
|
// With dataAsOfMs: -120000 - (-900000) = 780000ms = 13 min > 10 min → STUCK
|
|
expect(isTaskStuck(task, 600000, dataAsOfMs)).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("countStuckTasks", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-04-04T12:00:00Z"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("returns 0 when timeout is undefined", () => {
|
|
const stale = new Date(Date.now() - 600001).toISOString();
|
|
const tasks = [createTask({ updatedAt: stale })];
|
|
expect(countStuckTasks(tasks, undefined)).toBe(0);
|
|
});
|
|
|
|
it("returns 0 when timeout is 0", () => {
|
|
const stale = new Date(Date.now() - 600001).toISOString();
|
|
const tasks = [createTask({ updatedAt: stale })];
|
|
expect(countStuckTasks(tasks, 0)).toBe(0);
|
|
});
|
|
|
|
it("counts only stuck tasks", () => {
|
|
const stale = new Date(Date.now() - 600001).toISOString();
|
|
const recent = new Date(Date.now() - 300000).toISOString();
|
|
const tasks = [
|
|
createTask({ id: "FN-001", updatedAt: stale }), // stuck
|
|
createTask({ id: "FN-002", updatedAt: recent }), // not stuck
|
|
createTask({ id: "FN-004", status: "failed", updatedAt: stale }), // terminal status
|
|
createTask({ id: "FN-003", column: "todo", updatedAt: stale }), // not in-progress
|
|
];
|
|
expect(countStuckTasks(tasks, 600000)).toBe(1);
|
|
});
|
|
|
|
it("returns 0 for empty task list", () => {
|
|
expect(countStuckTasks([], 600000)).toBe(0);
|
|
});
|
|
|
|
it("counts multiple stuck tasks", () => {
|
|
const stale = new Date(Date.now() - 600001).toISOString();
|
|
const tasks = [
|
|
createTask({ id: "FN-001", updatedAt: stale }),
|
|
createTask({ id: "FN-002", updatedAt: stale }),
|
|
];
|
|
expect(countStuckTasks(tasks, 600000)).toBe(2);
|
|
});
|
|
|
|
describe("dataAsOfMs parameter (freshness-aware stuck detection)", () => {
|
|
it("passes dataAsOfMs through to isTaskStuck", () => {
|
|
// Task would be stuck with Date.now() but not with dataAsOfMs
|
|
vi.setSystemTime(new Date(0));
|
|
const dataAsOfMs = -900000; // 15 minutes ago
|
|
const taskUpdatedAt = new Date(-720000).toISOString(); // 12 minutes ago
|
|
const tasks = [createTask({ updatedAt: taskUpdatedAt })];
|
|
|
|
// With dataAsOfMs: -900000 - (-720000) = -180000ms = -3 min < 10 min → NOT stuck
|
|
expect(countStuckTasks(tasks, 600000, dataAsOfMs)).toBe(0);
|
|
});
|
|
|
|
it("counts tasks that are genuinely stuck even with dataAsOfMs", () => {
|
|
vi.setSystemTime(new Date(0));
|
|
const dataAsOfMs = -120000; // 2 minutes ago
|
|
const taskUpdatedAt = new Date(-900000).toISOString(); // 15 minutes ago
|
|
const tasks = [createTask({ updatedAt: taskUpdatedAt })];
|
|
|
|
// With dataAsOfMs: -120000 - (-900000) = 780000ms = 13 min > 10 min → STUCK
|
|
expect(countStuckTasks(tasks, 600000, dataAsOfMs)).toBe(1);
|
|
});
|
|
});
|
|
});
|