Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .agents/pm/history/pm-2kkl.jsonl

Large diffs are not rendered by default.

63 changes: 57 additions & 6 deletions .agents/pm/issues/pm-2kkl.toon

Large diffs are not rendered by default.

7 changes: 5 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -288,15 +288,18 @@ jobs:
# (5) the telemetry runtime's detached flush scheduler, whose unit suite
# must mock the spawn rather than launch a real, network-touching
# node child that lingers and crashes the vitest worker on teardown
# (GH-348).
# (GH-348), and
# (6) background test-run signal handling, whose unit helpers must not
# send OS-level signals to the Vitest worker process on Windows
# (GH-376).
# The two non-scripts guards (cli-main-errors, runtime-schema-path-win32)
# plus the harness contract test stay pinned; the full tests/unit/scripts/
# directory now runs so a fresh Windows-only script regression fails this
# required per-PR check rather than only the nightly.
- name: Run Windows source-import and script regression tests
env:
PM_RUN_TESTS_SKIP_BUILD: "1"
run: node scripts/run-tests.mjs test -- tests/unit/cli/cli-main-errors.spec.ts tests/unit/core/schema/runtime-schema-path-win32-guard.spec.ts tests/unit/helpers/scriptModule.spec.ts tests/unit/scripts/ tests/unit/packages/runtime-loaders.spec.ts tests/unit/core/telemetry/telemetry-runtime.spec.ts
run: node scripts/run-tests.mjs test -- tests/unit/cli/cli-main-errors.spec.ts tests/unit/core/schema/runtime-schema-path-win32-guard.spec.ts tests/unit/helpers/scriptModule.spec.ts tests/unit/scripts/ tests/unit/packages/runtime-loaders.spec.ts tests/unit/core/telemetry/telemetry-runtime.spec.ts tests/unit/commands/test-runs-command.spec.ts

# The packed-npm-extension install path exercises the Windows `.cmd` npm
# spawn + tar extraction + hosted-SDK junction class that only the Node 24
Expand Down
6 changes: 0 additions & 6 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,6 @@ on:
- ".github/ISSUE_TEMPLATE/**"
- ".github/workflows/docs.yml"
pull_request:
paths:
- "docs/**"
- ".agents/skills/**"
- "**/*.md"
- ".github/ISSUE_TEMPLATE/**"
- ".github/workflows/docs.yml"

permissions:
contents: read
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/mcp-dynamic-package-actions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1534,7 +1534,7 @@ describe("MCP dynamic package actions", () => {
jsonrpc: "2.0",
id: 136,
method: "tools/call",
params: { name: "pm_run", arguments: { path: context.pmPath, action: "upgrade" } },
params: { name: "pm_run", arguments: { path: context.pmPath, action: "upgrade", options: { dryRun: true } } },
});
expect(upgrade?.isError).not.toBe(true);

Expand Down
137 changes: 129 additions & 8 deletions tests/unit/commands/test-runs-command.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,96 @@ async function withTemporaryPlatform<T>(platform: NodeJS.Platform, callback: ()
}
}

function captureProcessSignalHandlers(): {
sigtermHandlers: NodeJS.SignalsListener[];
sigintHandlers: NodeJS.SignalsListener[];
restore: () => void;
} {
const sigtermHandlers: NodeJS.SignalsListener[] = [];
const sigintHandlers: NodeJS.SignalsListener[] = [];
const originalOn: typeof process.on = process.on.bind(process);
const originalOff: typeof process.off = process.off.bind(process);
const removeCapturedHandler = (
handlers: NodeJS.SignalsListener[],
listener: Parameters<typeof process.off>[1],
): boolean => {
const index = handlers.indexOf(listener as NodeJS.SignalsListener);
if (index < 0) {
return false;
}
handlers.splice(index, 1);
return true;
};
const onSpy = vi.spyOn(process, "on").mockImplementation((event, listener) => {
if (event === "SIGTERM") {
sigtermHandlers.push(listener as NodeJS.SignalsListener);
return process;
}
if (event === "SIGINT") {
sigintHandlers.push(listener as NodeJS.SignalsListener);
return process;
}
return originalOn(event, listener);
});
const addListenerSpy = vi
.spyOn(process, "addListener")
.mockImplementation((event, listener) => process.on(event, listener));
const offSpy = vi.spyOn(process, "off").mockImplementation((event, listener) => {
if (event === "SIGTERM" && removeCapturedHandler(sigtermHandlers, listener)) {
return process;
}
if (event === "SIGINT" && removeCapturedHandler(sigintHandlers, listener)) {
return process;
}
return originalOff(event, listener);
});
Comment thread
unbraind marked this conversation as resolved.
Resolved
const removeListenerSpy = vi
.spyOn(process, "removeListener")
.mockImplementation((event, listener) => process.off(event, listener));

return {
sigtermHandlers,
sigintHandlers,
restore: () => {
addListenerSpy.mockRestore();
removeListenerSpy.mockRestore();
onSpy.mockRestore();
offSpy.mockRestore();
},
Comment thread
unbraind marked this conversation as resolved.
Resolved
};
}

async function dispatchSignalsWhenProgressReady(options: {
stderrPath: string;
marker: string;
isWorkerFinished: () => boolean;
signalGroups: ReadonlyArray<{
signal: NodeJS.Signals;
handlers: readonly NodeJS.SignalsListener[];
}>;
}): Promise<void> {
let progressObserved = false;
for (let attempt = 0; attempt < 1000; attempt += 1) {
if (options.isWorkerFinished()) {
break;
}
await new Promise<void>((resolve) => setTimeout(resolve, 10));
const stderr = await readFile(options.stderrPath, "utf8").catch(() => "");
if (stderr.includes(options.marker)) {
progressObserved = true;
break;
}
}
if (!progressObserved || options.isWorkerFinished()) {
return;
}
for (const group of options.signalGroups) {
for (const handler of group.handlers) {
handler(group.signal);
}
}
}

describe("test-runs command attribution fallback", () => {
afterEach(() => {
vi.restoreAllMocks();
Expand Down Expand Up @@ -770,15 +860,14 @@ describe("background test run lifecycle", () => {
" stopped = true;",
" process.stderr.write('[pm test-all] item 1/1 end id=pm-stop status=failed\\n');",
"});",
"setInterval(() => {",
"const timer = setInterval(() => {",
" if (!stopped) {",
" process.stderr.write('[pm test] linked-test 1/1 running elapsed_ms=15\\n');",
" process.stderr.write('[pm test-all] item 1/1 start id=pm-stop linked_tests=1\\n');",
" } else {",
" clearInterval(timer);",
" }",
"}, 1);",
"setTimeout(() => {",
" process.kill(process.ppid, 'SIGTERM');",
" process.kill(process.ppid, 'SIGINT');",
"}, 5);",
"",
].join("\n"),
"utf8",
Expand All @@ -795,7 +884,25 @@ describe("background test run lifecycle", () => {
requestedBy: "unit",
});

const stopped = await runBackgroundTestRunWorker(context.pmPath, started.run.id, true);
let workerFinished = false;
const { sigtermHandlers, sigintHandlers, restore } = captureProcessSignalHandlers();
const signalWhenProgressReady = dispatchSignalsWhenProgressReady({
stderrPath: getTestRunStderrPath(context.pmPath, started.run.id),
marker: "id=pm-stop",
isWorkerFinished: () => workerFinished,
signalGroups: [
{ signal: "SIGTERM", handlers: sigtermHandlers },
{ signal: "SIGINT", handlers: sigintHandlers },
],
}).catch(() => undefined);
const stopped = await runBackgroundTestRunWorker(context.pmPath, started.run.id, true).finally(async () => {
workerFinished = true;
try {
await signalWhenProgressReady;
} finally {
restore();
}
});
Comment thread
unbraind marked this conversation as resolved.
Resolved
expect(stopped.status).toBe("stopped");
expect(stopped.stop_requested_at).toBeDefined();
expect(stopped.progress).toMatchObject({
Expand Down Expand Up @@ -1055,7 +1162,6 @@ describe("background test run lifecycle", () => {
[
"process.on('SIGTERM', () => {});",
"setInterval(() => process.stderr.write('[pm test] linked-test 1/1 running elapsed_ms=10\\n'), 1);",
"setTimeout(() => process.kill(process.ppid, 'SIGTERM'), 5);",
"",
].join("\n"),
"utf8",
Expand All @@ -1073,7 +1179,22 @@ describe("background test run lifecycle", () => {
commandArgs: ["signal-stop-default-delay"],
requestedBy: "unit",
});
const stopped = await runBackgroundTestRunWorker(context.pmPath, started.run.id, true);
let workerFinished = false;
const { sigtermHandlers, restore } = captureProcessSignalHandlers();
const signalWhenProgressReady = dispatchSignalsWhenProgressReady({
stderrPath: getTestRunStderrPath(context.pmPath, started.run.id),
marker: "linked-test 1/1 running",
isWorkerFinished: () => workerFinished,
signalGroups: [{ signal: "SIGTERM", handlers: sigtermHandlers }],
}).catch(() => undefined);
const stopped = await runBackgroundTestRunWorker(context.pmPath, started.run.id, true).finally(async () => {
workerFinished = true;
try {
await signalWhenProgressReady;
} finally {
restore();
}
});
Comment thread
unbraind marked this conversation as resolved.
Resolved
expect(stopped.status).toBe("stopped");
expect(stopped.progress?.phase).toBe("finished");
});
Expand Down
Loading