Compare commits

...

1 Commits

Author SHA1 Message Date
abeatrix 43ddc8c846 fix(test): increase hook test timeouts for Windows in taskcancel tests
Add WINDOWS_HOOK_TEST_TIMEOUT_MS (15s) to taskcancel hook tests to
account for the slower PowerShell bridge that spawns a child Node
process on Windows CI. The double-process startup is variable and can
exceed Mocha's default 2s timeout, causing flaky failures.

Mirrors the approach already used in taskresume, hook-factory, and
user-prompt-submit tests.
2026-06-01 20:36:24 -07:00
3 changed files with 263 additions and 162 deletions
@@ -12,6 +12,12 @@ describe("TaskCancel Hook", () => {
let getEnv: () => { tempDir: string }
let hookTestEnv: HookTestEnv
// On Windows, hooks execute via a PowerShell bridge that spawns a child
// Node process. That double-process startup is slow and variable on CI and
// can easily exceed Mocha's default 2 s timeout, so spawning tests opt into a
// larger timeout. Mirrors taskresume/hook-factory/user-prompt-submit tests.
const WINDOWS_HOOK_TEST_TIMEOUT_MS = 15000
const writeHookScript = async (hookPath: string, nodeScript: string): Promise<void> => {
await writeHookScriptForPlatform(hookPath, nodeScript)
}
@@ -29,7 +35,11 @@ describe("TaskCancel Hook", () => {
})
describe("Hook Input Format", () => {
it("should receive task metadata with completionStatus", async () => {
it("should receive task metadata with completionStatus", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskCancel")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
@@ -61,7 +71,11 @@ console.log(JSON.stringify({
// Note: contextModification is ignored for TaskCancel hooks
})
it("should handle 'abandoned' completion status", async () => {
it("should handle 'abandoned' completion status", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskCancel")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
@@ -96,7 +110,11 @@ console.log(JSON.stringify({
// Note: contextModification is ignored for TaskCancel hooks
})
it("should receive all common hook input fields", async () => {
it("should receive all common hook input fields", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskCancel")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
@@ -136,7 +154,11 @@ console.log(JSON.stringify({
})
describe("Fire-and-Forget Behavior", () => {
it("should ignore contextModification regardless of content", async () => {
it("should ignore contextModification regardless of content", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskCancel")
const hookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
@@ -192,7 +214,11 @@ console.log(JSON.stringify({
// The contextModification value is different but behavior is identical (fire-and-forget)
})
it("should succeed regardless of hook return value", async () => {
it("should succeed regardless of hook return value", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskCancel")
const hookScript = `#!/usr/bin/env node
// Note: contextModification is ignored for TaskCancel hooks
@@ -222,7 +248,11 @@ console.log(JSON.stringify({
result.cancel.should.be.false()
})
it("should return error message when hook returns cancel: true", async () => {
it("should return error message when hook returns cancel: true", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskCancel")
const hookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
@@ -254,7 +284,11 @@ console.log(JSON.stringify({
result.errorMessage?.should.equal("Hook tried to block cancellation")
})
it("should execute without errors for cleanup purposes", async () => {
it("should execute without errors for cleanup purposes", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskCancel")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
@@ -288,7 +322,11 @@ console.log(JSON.stringify({
})
describe("Error Handling", () => {
it("should surface hook errors to the user", async () => {
it("should surface hook errors to the user", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskCancel")
const hookScript = `#!/usr/bin/env node
console.error("Hook execution error");
@@ -317,7 +355,11 @@ process.exit(1);`
}
})
it("should handle malformed JSON output from hook", async () => {
it("should handle malformed JSON output from hook", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskCancel")
const hookScript = `#!/usr/bin/env node
console.log("not valid json")`
@@ -359,7 +401,11 @@ console.log("not valid json")`
stubHookDirs(sandbox, [globalHooksDir, workspaceHooksDir])
})
it("should execute both global and workspace TaskCancel hooks", async () => {
it("should execute both global and workspace TaskCancel hooks", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
// Create global hook
const globalHookPath = path.join(globalHooksDir, "TaskCancel")
const globalHookScript = `#!/usr/bin/env node
@@ -399,7 +445,11 @@ console.log(JSON.stringify({
// Both hooks executed successfully
})
it("should execute both hooks with different completion statuses", async () => {
it("should execute both hooks with different completion statuses", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
const globalHookPath = path.join(globalHooksDir, "TaskCancel")
const globalHookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
@@ -441,7 +491,11 @@ console.log(JSON.stringify({
})
describe("No Hook Behavior", () => {
it("should succeed when no hook exists", async () => {
it("should succeed when no hook exists", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
const factory = new HookFactory()
const runner = await factory.create("TaskCancel")
@@ -461,7 +515,11 @@ console.log(JSON.stringify({
})
describe("Fixture-Based Tests", () => {
it("should handle cancel: true with no error message", async () => {
it("should handle cancel: true with no error message", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
await loadFixture("hooks/taskcancel/false-no-error", getEnv().tempDir)
const factory = new HookFactory()
@@ -484,7 +542,11 @@ console.log(JSON.stringify({
// Cancellation still proceeds (fire-and-forget)
})
it("should handle cancel: true with error message", async () => {
it("should handle cancel: true with error message", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
await loadFixture("hooks/taskcancel/false-with-error", getEnv().tempDir)
const factory = new HookFactory()
@@ -507,7 +569,11 @@ console.log(JSON.stringify({
// Cancellation still proceeds (fire-and-forget)
})
it("should handle cancel: false with no error message", async () => {
it("should handle cancel: false with no error message", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
await loadFixture("hooks/taskcancel/true-no-error", getEnv().tempDir)
const factory = new HookFactory()
@@ -529,7 +595,11 @@ console.log(JSON.stringify({
// Normal success case - no errors to surface
})
it("should handle cancel: false with error message", async () => {
it("should handle cancel: false with error message", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
await loadFixture("hooks/taskcancel/true-with-error", getEnv().tempDir)
const factory = new HookFactory()
@@ -553,7 +623,11 @@ console.log(JSON.stringify({
// Cancellation still proceeds (fire-and-forget)
})
it("should handle hook that exits with non-zero status code", async () => {
it("should handle hook that exits with non-zero status code", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
await loadFixture("hooks/taskcancel/error", getEnv().tempDir)
const factory = new HookFactory()
@@ -16,6 +16,12 @@ const sqliteAvailable = (() => {
}
})();
// The first SQLite-backed test in a file pays a one-time cost: loading the
// native `node:sqlite` module and creating the first temp database file. On
// Windows CI this cold start can exceed Vitest's default 5 s timeout, so give
// these tests extra headroom.
const SQLITE_TEST_TIMEOUT_MS = 30_000;
async function createTempDbPath(): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), "sdk-hub-schedule-"));
return join(directory, "cron.db");
@@ -34,129 +40,137 @@ afterEach(async () => {
describe("HubScheduleService", () => {
const sqliteIt = sqliteAvailable ? it : it.skip;
sqliteIt("creates, triggers, and reports schedule history", async () => {
const dbPath = await createTempDbPath();
cleanupPaths.push(dbPath);
const publishedEvents: Array<{
eventType: string;
payload: Record<string, unknown>;
}> = [];
const service = new HubScheduleService({
dbPath,
runtimeHandlers: {
startSession: vi.fn(async () => ({ sessionId: "session-1" })),
sendSession: vi.fn(async () => ({
result: {
text: "done",
iterations: 3,
inputTokens: 10,
outputTokens: 20,
usage: { totalCost: 1.25 },
sqliteIt(
"creates, triggers, and reports schedule history",
async () => {
const dbPath = await createTempDbPath();
cleanupPaths.push(dbPath);
const publishedEvents: Array<{
eventType: string;
payload: Record<string, unknown>;
}> = [];
const service = new HubScheduleService({
dbPath,
runtimeHandlers: {
startSession: vi.fn(async () => ({ sessionId: "session-1" })),
sendSession: vi.fn(async () => ({
result: {
text: "done",
iterations: 3,
inputTokens: 10,
outputTokens: 20,
usage: { totalCost: 1.25 },
},
})),
abortSession: vi.fn(async () => ({ applied: true })),
stopSession: vi.fn(async () => ({ applied: true })),
},
eventPublisher: (eventType, payload) => {
publishedEvents.push({ eventType, payload });
},
});
try {
const created = service.createSchedule({
name: "Routine",
cronPattern: "0 * * * *",
prompt: "Run the routine",
workspaceRoot: "/workspace",
cwd: "/workspace",
modelSelection: {
providerId: "openai",
modelId: "gpt-5.3-codex",
},
})),
abortSession: vi.fn(async () => ({ applied: true })),
stopSession: vi.fn(async () => ({ applied: true })),
},
eventPublisher: (eventType, payload) => {
publishedEvents.push({ eventType, payload });
},
});
try {
const created = service.createSchedule({
name: "Routine",
cronPattern: "0 * * * *",
prompt: "Run the routine",
workspaceRoot: "/workspace",
cwd: "/workspace",
modelSelection: {
providerId: "openai",
modelId: "gpt-5.3-codex",
},
maxParallel: 1,
timeoutSeconds: 30,
metadata: { delivery: { threadId: "thread-1" } },
});
maxParallel: 1,
timeoutSeconds: 30,
metadata: { delivery: { threadId: "thread-1" } },
});
const execution = await service.triggerScheduleNow(created.scheduleId);
expect(execution?.status).toBe("success");
expect(execution?.sessionId).toBe("session-1");
expect(publishedEvents).toEqual([
{
eventType: "schedule.execution.completed",
payload: expect.objectContaining({
scheduleId: created.scheduleId,
executionId: execution?.executionId,
sessionId: "session-1",
status: "success",
const execution = await service.triggerScheduleNow(created.scheduleId);
expect(execution?.status).toBe("success");
expect(execution?.sessionId).toBe("session-1");
expect(publishedEvents).toEqual([
{
eventType: "schedule.execution.completed",
payload: expect.objectContaining({
scheduleId: created.scheduleId,
executionId: execution?.executionId,
sessionId: "session-1",
status: "success",
}),
},
]);
const schedule = service.getSchedule(created.scheduleId);
expect(schedule?.metadata).toEqual({
delivery: { threadId: "thread-1" },
});
expect(
service.listScheduleExecutions({ scheduleId: created.scheduleId }),
).toHaveLength(1);
expect(service.getScheduleStats(created.scheduleId).totalRuns).toBe(1);
expect(service.getUpcomingRuns(10)).toHaveLength(1);
} finally {
await service.dispose();
}
},
SQLITE_TEST_TIMEOUT_MS,
);
sqliteIt(
"publishes failed schedule execution events",
async () => {
const dbPath = await createTempDbPath();
cleanupPaths.push(dbPath);
const publishedEvents: Array<{
eventType: string;
payload: Record<string, unknown>;
}> = [];
const service = new HubScheduleService({
dbPath,
runtimeHandlers: {
startSession: vi.fn(async () => ({ sessionId: "session-failed" })),
sendSession: vi.fn(async () => {
throw new Error("runtime failed");
}),
abortSession: vi.fn(async () => ({ applied: true })),
stopSession: vi.fn(async () => ({ applied: true })),
},
]);
const schedule = service.getSchedule(created.scheduleId);
expect(schedule?.metadata).toEqual({
delivery: { threadId: "thread-1" },
});
expect(
service.listScheduleExecutions({ scheduleId: created.scheduleId }),
).toHaveLength(1);
expect(service.getScheduleStats(created.scheduleId).totalRuns).toBe(1);
expect(service.getUpcomingRuns(10)).toHaveLength(1);
} finally {
await service.dispose();
}
});
sqliteIt("publishes failed schedule execution events", async () => {
const dbPath = await createTempDbPath();
cleanupPaths.push(dbPath);
const publishedEvents: Array<{
eventType: string;
payload: Record<string, unknown>;
}> = [];
const service = new HubScheduleService({
dbPath,
runtimeHandlers: {
startSession: vi.fn(async () => ({ sessionId: "session-failed" })),
sendSession: vi.fn(async () => {
throw new Error("runtime failed");
}),
abortSession: vi.fn(async () => ({ applied: true })),
stopSession: vi.fn(async () => ({ applied: true })),
},
eventPublisher: (eventType, payload) => {
publishedEvents.push({ eventType, payload });
},
});
try {
const created = service.createSchedule({
name: "Failure routine",
cronPattern: "0 * * * *",
prompt: "Run and fail",
workspaceRoot: "/workspace",
modelSelection: {
providerId: "openai",
modelId: "gpt-5.3-codex",
eventPublisher: (eventType, payload) => {
publishedEvents.push({ eventType, payload });
},
});
try {
const created = service.createSchedule({
name: "Failure routine",
cronPattern: "0 * * * *",
prompt: "Run and fail",
workspaceRoot: "/workspace",
modelSelection: {
providerId: "openai",
modelId: "gpt-5.3-codex",
},
});
const execution = await service.triggerScheduleNow(created.scheduleId);
expect(execution?.status).toBe("failed");
expect(publishedEvents).toEqual([
{
eventType: "schedule.execution.failed",
payload: expect.objectContaining({
scheduleId: created.scheduleId,
executionId: execution?.executionId,
sessionId: "session-failed",
status: "failed",
errorMessage: "runtime failed",
}),
},
]);
} finally {
await service.dispose();
}
});
const execution = await service.triggerScheduleNow(created.scheduleId);
expect(execution?.status).toBe("failed");
expect(publishedEvents).toEqual([
{
eventType: "schedule.execution.failed",
payload: expect.objectContaining({
scheduleId: created.scheduleId,
executionId: execution?.executionId,
sessionId: "session-failed",
status: "failed",
errorMessage: "runtime failed",
}),
},
]);
} finally {
await service.dispose();
}
},
SQLITE_TEST_TIMEOUT_MS,
);
sqliteIt(
"handles schedule commands through the hub command adapter",
@@ -209,5 +223,6 @@ describe("HubScheduleService", () => {
await service.dispose();
}
},
SQLITE_TEST_TIMEOUT_MS,
);
});
+41 -29
View File
@@ -20,6 +20,12 @@ import {
startHubWebSocketServer,
} from "../server";
// The first test that boots the hub server pays a one-time cold-start cost
// (loading the native `node:sqlite` module used by the schedule runtime plus the
// first server bind and discovery-file write). On Windows CI this can exceed
// Vitest's default 5 s timeout, so give the startup test extra headroom.
const HUB_SERVER_COLD_START_TIMEOUT_MS = 30_000;
async function reservePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = createNetServer();
@@ -84,37 +90,43 @@ describe("hub server startup", () => {
servers.clear();
});
it("starts on the requested port instead of drifting to a random port", async () => {
const owner = createInMemoryHubOwnerContext("hub-server-test-fixed-port");
const port = await reservePort();
await writeHubDiscovery(owner.discoveryPath, {
hubId: "stale-hub",
protocolVersion: "v1",
authToken: "stale-token",
host: "127.0.0.1",
port: port + 1,
url: `ws://127.0.0.1:${port + 1}/hub`,
startedAt: new Date(0).toISOString(),
updatedAt: new Date(0).toISOString(),
});
it(
"starts on the requested port instead of drifting to a random port",
async () => {
const owner = createInMemoryHubOwnerContext("hub-server-test-fixed-port");
const port = await reservePort();
await writeHubDiscovery(owner.discoveryPath, {
hubId: "stale-hub",
protocolVersion: "v1",
authToken: "stale-token",
host: "127.0.0.1",
port: port + 1,
url: `ws://127.0.0.1:${port + 1}/hub`,
startedAt: new Date(0).toISOString(),
updatedAt: new Date(0).toISOString(),
});
const result = await ensureHubWebSocketServer({
owner,
host: "127.0.0.1",
port,
pathname: "/hub",
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
});
expect(result.url).toBe(`ws://127.0.0.1:${port}/hub`);
expect(result.action).toBe("started");
const server = requireServer(result.server);
servers.add(server);
const result = await ensureHubWebSocketServer({
owner,
host: "127.0.0.1",
port,
pathname: "/hub",
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
});
expect(result.url).toBe(`ws://127.0.0.1:${port}/hub`);
expect(result.action).toBe("started");
const server = requireServer(result.server);
servers.add(server);
await expect(readHubDiscovery(owner.discoveryPath)).resolves.toMatchObject({
port,
url: `ws://127.0.0.1:${port}/hub`,
});
});
await expect(
readHubDiscovery(owner.discoveryPath),
).resolves.toMatchObject({
port,
url: `ws://127.0.0.1:${port}/hub`,
});
},
HUB_SERVER_COLD_START_TIMEOUT_MS,
);
it("fails when the requested port is already occupied", async () => {
const owner = createInMemoryHubOwnerContext("hub-server-test-port-busy");