@pi-unipi/background-tasks 2.6.2 → 2.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/package.json +3 -2
  2. package/src/tools.ts +3 -1
  3. package/src/__tests__/anthropic-attribution.test.ts +0 -195
  4. package/src/__tests__/config.test.ts +0 -137
  5. package/src/__tests__/core.test.ts +0 -493
  6. package/src/__tests__/delegate-artifacts.test.ts +0 -528
  7. package/src/__tests__/delegate-budget.test.ts +0 -456
  8. package/src/__tests__/delegate-launch.test.ts +0 -676
  9. package/src/__tests__/delegate-result-package.test.ts +0 -350
  10. package/src/__tests__/delegate-seed.test.ts +0 -392
  11. package/src/__tests__/durable-fs.test.ts +0 -559
  12. package/src/__tests__/extension-api.test.ts +0 -579
  13. package/src/__tests__/fusion-artifacts.test.ts +0 -1039
  14. package/src/__tests__/fusion-budget.test.ts +0 -1356
  15. package/src/__tests__/fusion-claude-cache.test.ts +0 -320
  16. package/src/__tests__/fusion-config.test.ts +0 -335
  17. package/src/__tests__/fusion-context-prompts.test.ts +0 -670
  18. package/src/__tests__/fusion-evaluation.test.ts +0 -315
  19. package/src/__tests__/fusion-extraction-equivalence.test.ts +0 -58
  20. package/src/__tests__/fusion-golden-bytes.test.ts +0 -35
  21. package/src/__tests__/fusion-high-cardinality.test.ts +0 -192
  22. package/src/__tests__/fusion-model-selector.test.ts +0 -205
  23. package/src/__tests__/fusion-orchestrator.test.ts +0 -1194
  24. package/src/__tests__/fusion-rpc.test.ts +0 -369
  25. package/src/__tests__/fusion-sdk.test.ts +0 -1226
  26. package/src/__tests__/fusion-v5-core.test.ts +0 -219
  27. package/src/__tests__/fusion-validate-orchestrator.test.ts +0 -240
  28. package/src/__tests__/fusion-web-fetch.test.ts +0 -485
  29. package/src/__tests__/fusion-workflows.test.ts +0 -59
  30. package/src/__tests__/helpers/delegate-deterministic-seed.ts +0 -109
  31. package/src/__tests__/helpers/delegate-seed-subprocess.ts +0 -10
  32. package/src/__tests__/helpers/fusion-canonical-subprocess.ts +0 -21
  33. package/src/__tests__/helpers/fusion-canonical.ts +0 -140
  34. package/src/__tests__/helpers/fusion-fake-pi.ts +0 -279
  35. package/src/__tests__/helpers/fusion-golden-corpus.ts +0 -500
  36. package/src/__tests__/helpers/fusion-high-cardinality.ts +0 -140
  37. package/src/__tests__/helpers/normalize.ts +0 -22
  38. package/src/__tests__/helpers/pi-hook-contract-evidence.json +0 -18
  39. package/src/__tests__/pi-launch.test.ts +0 -202
  40. package/src/__tests__/registry.test.ts +0 -1580
  41. package/src/__tests__/scripted-provider/delegate-ambient-provider.test.ts +0 -130
  42. package/src/__tests__/scripted-provider/delegate-child-guard.test.ts +0 -631
  43. package/src/__tests__/scripted-provider/delegate-guard-provider.ts +0 -403
  44. package/src/__tests__/scripted-provider/follow-up.test.ts +0 -448
  45. package/src/__tests__/scripted-provider/fusion-output-recovery.test.ts +0 -132
  46. package/src/__tests__/scripted-provider/fusion-reason.test.ts +0 -310
  47. package/src/__tests__/scripted-provider/fusion-runtime-guard.test.ts +0 -163
  48. package/src/__tests__/scripted-provider/hook-contract-provider.ts +0 -179
  49. package/src/__tests__/scripted-provider/hook-probe-a.ts +0 -3
  50. package/src/__tests__/scripted-provider/hook-probe-b.ts +0 -3
  51. package/src/__tests__/scripted-provider/hook-probe-extension.ts +0 -126
  52. package/src/__tests__/scripted-provider/output-recovery-provider.ts +0 -153
  53. package/src/__tests__/scripted-provider/pi-hook-contract-evidence.json +0 -18
  54. package/src/__tests__/scripted-provider/pi-hook-contract.test.ts +0 -477
  55. package/src/__tests__/scripted-provider/runtime-guard-probe.ts +0 -28
  56. package/src/__tests__/scripted-provider/runtime-guard-provider.ts +0 -49
  57. package/src/__tests__/scripted-provider/scripted-provider-extension.ts +0 -408
  58. package/src/__tests__/task-manager.test.ts +0 -479
  59. package/src/__tests__/windows-taskkill.test.ts +0 -161
@@ -1,479 +0,0 @@
1
- import { describe, it } from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import { mkdtemp, rm, writeFile } from 'node:fs/promises';
4
- import { join } from 'node:path';
5
- import { tmpdir } from 'node:os';
6
- import { Theme, type ThemeColor } from '@earendil-works/pi-coding-agent';
7
- import { visibleWidth } from '@earendil-works/pi-tui';
8
- import {
9
- BackgroundTasksManager,
10
- type BackgroundTaskForUi,
11
- } from '../task-manager.js';
12
- import { stripAnsi } from './helpers/normalize.js';
13
-
14
- const themeColors: readonly ThemeColor[] = [
15
- 'accent',
16
- 'border',
17
- 'borderAccent',
18
- 'borderMuted',
19
- 'success',
20
- 'error',
21
- 'warning',
22
- 'muted',
23
- 'dim',
24
- 'text',
25
- 'thinkingText',
26
- 'userMessageText',
27
- 'customMessageText',
28
- 'customMessageLabel',
29
- 'toolTitle',
30
- 'toolOutput',
31
- 'mdHeading',
32
- 'mdLink',
33
- 'mdLinkUrl',
34
- 'mdCode',
35
- 'mdCodeBlock',
36
- 'mdCodeBlockBorder',
37
- 'mdQuote',
38
- 'mdQuoteBorder',
39
- 'mdHr',
40
- 'mdListBullet',
41
- 'toolDiffAdded',
42
- 'toolDiffRemoved',
43
- 'toolDiffContext',
44
- 'syntaxComment',
45
- 'syntaxKeyword',
46
- 'syntaxFunction',
47
- 'syntaxVariable',
48
- 'syntaxString',
49
- 'syntaxNumber',
50
- 'syntaxType',
51
- 'syntaxOperator',
52
- 'syntaxPunctuation',
53
- 'thinkingOff',
54
- 'thinkingMinimal',
55
- 'thinkingLow',
56
- 'thinkingMedium',
57
- 'thinkingHigh',
58
- 'thinkingXhigh',
59
- 'bashMode',
60
- ];
61
- const themeBackgrounds = [
62
- 'selectedBg',
63
- 'userMessageBg',
64
- 'customMessageBg',
65
- 'toolPendingBg',
66
- 'toolSuccessBg',
67
- 'toolErrorBg',
68
- ] as const;
69
- type ThemeForegrounds = ConstructorParameters<typeof Theme>[0];
70
- type ThemeBackgrounds = ConstructorParameters<typeof Theme>[1];
71
- const theme = new Theme(
72
- Object.fromEntries(themeColors.map((color) => [color, '#ffffff'])) as ThemeForegrounds,
73
- Object.fromEntries(themeBackgrounds.map((color) => [color, '#000000'])) as ThemeBackgrounds,
74
- 'truecolor',
75
- );
76
-
77
- function task(overrides: Partial<BackgroundTaskForUi> = {}): BackgroundTaskForUi {
78
- const now = Date.now();
79
- return {
80
- id: 'b12345678',
81
- name: 'Component Task',
82
- command: 'printf component-ok',
83
- status: 'running',
84
- outputPath: '.pi/tasks/test/b12345678.output',
85
- outputAbsPath: join(tmpdir(), 'missing-output'),
86
- cwd: tmpdir(),
87
- startTime: now - 1000,
88
- bytesWritten: 0,
89
- isAgent: false,
90
- notified: false,
91
- notifyOnCompletion: true,
92
- triggerOnCompletion: false,
93
- ...overrides,
94
- };
95
- }
96
-
97
- function manager(
98
- options: Partial<ConstructorParameters<typeof BackgroundTasksManager>[3]> = {},
99
- tasks: BackgroundTaskForUi[] = [task()],
100
- ) {
101
- let closed = false;
102
- let renders = 0;
103
- const stopped: string[] = [];
104
- const paths: string[] = [];
105
- const seen = new Set<string>();
106
- const instance = new BackgroundTasksManager(
107
- {
108
- requestRender: () => {
109
- renders++;
110
- },
111
- },
112
- theme,
113
- () => {
114
- closed = true;
115
- },
116
- {
117
- getTasks: () => tasks,
118
- stopTask: (t) => {
119
- stopped.push(t.id);
120
- t.status = 'killed';
121
- t.endTime = Date.now();
122
- return Promise.resolve();
123
- },
124
- stopAllRunning: () => {
125
- const running = tasks.filter((t) => t.status === 'running');
126
- for (const t of running) {
127
- stopped.push(t.id);
128
- t.status = 'killed';
129
- t.endTime = Date.now();
130
- }
131
- return Promise.resolve({ stopped: running.length, failures: [] });
132
- },
133
- rerunTask: (t) => {
134
- const overrides: Partial<BackgroundTaskForUi> = {
135
- id: `babcdef${String(tasks.length)}`,
136
- name: t.name,
137
- command: t.command,
138
- startTime: Date.now(),
139
- };
140
- if (t.description !== undefined) overrides.description = t.description;
141
- if (t.timeoutSeconds !== undefined) overrides.timeoutSeconds = t.timeoutSeconds;
142
- const rerun = task(overrides);
143
- tasks.unshift(rerun);
144
- return Promise.resolve(rerun);
145
- },
146
- showOutputPath: (t) => {
147
- paths.push(t.outputPath);
148
- },
149
- markSeen: (id) => {
150
- seen.add(id);
151
- },
152
- markFinishedSeen: (ids) => {
153
- for (const id of ids) seen.add(id);
154
- },
155
- isSeen: (id) => seen.has(id),
156
- ...options,
157
- },
158
- );
159
- return {
160
- instance,
161
- get closed() {
162
- return closed;
163
- },
164
- get renders() {
165
- return renders;
166
- },
167
- stopped,
168
- paths,
169
- seen,
170
- };
171
- }
172
-
173
- function assertWidth(lines: string[], width: number) {
174
- for (const line of lines) assert.ok(visibleWidth(stripAnsi(line)) <= width, line);
175
- }
176
-
177
- void describe('BackgroundTasksManager component', () => {
178
- void it('renders list within width and handles selection/actions', async () => {
179
- const baseTime = Date.now();
180
- const tasks = [
181
- task({ id: 'b11111111', name: 'First Task', startTime: baseTime + 1000 }),
182
- task({ id: 'b22222222', name: 'Second Task', startTime: baseTime }),
183
- ];
184
- const h = manager({}, tasks);
185
- try {
186
- const lines = h.instance.render(90);
187
- assert.match(stripAnsi(lines.join('\n')), /bg tasks focused/);
188
- assert.match(stripAnsi(lines.join('\n')), /First Task/);
189
- assertWidth(lines, 90);
190
-
191
- h.instance.handleInput('\x1b[B');
192
- h.instance.handleInput('k');
193
- await new Promise((resolve) => setTimeout(resolve, 0));
194
- assert.deepEqual(h.stopped, ['b22222222']);
195
-
196
- h.instance.handleInput('h');
197
- h.instance.handleInput('R');
198
- await new Promise((resolve) => setTimeout(resolve, 0));
199
- assert.ok(tasks.some((candidate) => candidate.id.startsWith('babcdef')));
200
-
201
- h.instance.handleInput('c');
202
- assert.ok(h.paths.length >= 1);
203
-
204
- h.instance.handleInput('x');
205
- assert.equal(h.closed, true);
206
- } finally {
207
- h.instance.dispose();
208
- }
209
- });
210
-
211
- void it('renders task-owned context, model, token, and tool telemetry in list/detail rows and placeholder when absent', async () => {
212
- const dir = await mkdtemp(join(tmpdir(), 'pi-bg-context-'));
213
- try {
214
- const outputAbsPath = join(dir, 'task.output');
215
- await writeFile(outputAbsPath, 'context-output\n', 'utf8');
216
- const tasks = [
217
- task({
218
- id: 'bctx00001',
219
- name: 'Context Task',
220
- outputAbsPath,
221
- isAgent: true,
222
- model: 'openai-codex/gpt-5.5',
223
- contextUsage: { tokens: 42_000, contextWindow: 200_000, percent: 21 },
224
- tokenUsage: {
225
- input: 1000,
226
- output: 200,
227
- cacheRead: 30,
228
- cacheWrite: 20,
229
- totalTokens: 1250,
230
- },
231
- toolUsage: { total: 3, failed: 1, byName: { bash: 2, read: 1 } },
232
- }),
233
- task({
234
- id: 'bctx00002',
235
- name: 'No Context Task',
236
- outputAbsPath,
237
- startTime: Date.now() - 2000,
238
- }),
239
- ];
240
- const h = manager({}, tasks);
241
- try {
242
- let text = stripAnsi(h.instance.render(120).join('\n'));
243
- assert.match(text, /ctx 21\.0%\/200k/);
244
- assert.match(text, /model gpt-5\.5/);
245
- assert.match(text, /tok 1\.3k/);
246
- assert.match(text, /tools 3\/1 failed/);
247
- assert.match(text, /ctx —/);
248
- h.instance.handleInput('\r');
249
- await new Promise((resolve) => setTimeout(resolve, 20));
250
- text = stripAnsi(h.instance.render(120).join('\n'));
251
- assert.match(text, /Model: openai-codex\/gpt-5\.5/);
252
- assert.match(text, /Context: 21\.0% of 200k window \(42k tokens\)/);
253
- assert.match(
254
- text,
255
- /Tokens: input 1\.0k · output 200 · cache read 30 · cache write 20 · total 1\.3k/,
256
- );
257
- assert.match(text, /Tools: 3 total · 1 failed · bash 2 · read 1/);
258
- h.instance.handleInput('\x1b[D');
259
- h.instance.handleInput('\x1b[B');
260
- h.instance.handleInput('\r');
261
- await new Promise((resolve) => setTimeout(resolve, 20));
262
- text = stripAnsi(h.instance.render(120).join('\n'));
263
- assert.match(text, /Model: not reported by this background task/);
264
- } finally {
265
- h.instance.dispose();
266
- }
267
- } finally {
268
- await rm(dir, { recursive: true, force: true });
269
- }
270
- });
271
-
272
- void it('opens detail, reads bounded tail, refreshes, acts, and returns to list', async () => {
273
- const dir = await mkdtemp(join(tmpdir(), 'pi-bg-component-'));
274
- try {
275
- const outputAbsPath = join(dir, 'task.output');
276
- await writeFile(outputAbsPath, 'line one\ncomponent-tail\n', 'utf8');
277
- const tasks = [task({ outputAbsPath, bytesWritten: 24 })];
278
- const h = manager({ initialTaskId: 'b12345678' }, tasks);
279
- try {
280
- await new Promise((resolve) => setTimeout(resolve, 20));
281
- let text = stripAnsi(h.instance.render(100).join('\n'));
282
- assert.match(text, /bg: Component Task/);
283
- assert.match(text, /component-tail/);
284
- assert.ok(h.seen.has('b12345678'));
285
-
286
- h.instance.handleInput('r');
287
- h.instance.handleInput('c');
288
- h.instance.handleInput('k');
289
- await new Promise((resolve) => setTimeout(resolve, 0));
290
- assert.deepEqual(h.paths, ['.pi/tasks/test/b12345678.output']);
291
- assert.deepEqual(h.stopped, ['b12345678']);
292
- h.instance.handleInput('R');
293
- await new Promise((resolve) => setTimeout(resolve, 0));
294
- assert.ok(tasks.some((candidate) => candidate.id.startsWith('babcdef')));
295
-
296
- h.instance.handleInput('\x1b[D');
297
- text = stripAnsi(h.instance.render(100).join('\n'));
298
- assert.match(text, /bg tasks focused/);
299
- } finally {
300
- h.instance.dispose();
301
- }
302
- } finally {
303
- await rm(dir, { recursive: true, force: true });
304
- }
305
- });
306
-
307
- void it('scrolls the detail output tail with arrows/pages and resumes follow at the bottom', async () => {
308
- const dir = await mkdtemp(join(tmpdir(), 'pi-bg-scroll-'));
309
- try {
310
- const outputAbsPath = join(dir, 'task.output');
311
- const fileLines = Array.from(
312
- { length: 40 },
313
- (_, i) => `LINE-${String(i + 1).padStart(3, '0')}`,
314
- );
315
- await writeFile(outputAbsPath, `${fileLines.join('\n')}\n`, 'utf8');
316
- const h = manager({ initialTaskId: 'b12345678' }, [
317
- task({ outputAbsPath, bytesWritten: 400 }),
318
- ]);
319
- try {
320
- await new Promise((resolve) => setTimeout(resolve, 20));
321
- let text = stripAnsi(h.instance.render(100).join('\n'));
322
- assert.match(text, /following tail/);
323
- assert.match(text, /LINE-040/);
324
- assert.match(text, /LINE-029/);
325
- assert.doesNotMatch(text, /LINE-001/);
326
- assert.doesNotMatch(text, /LINE-005/);
327
-
328
- // Scroll up 20 lines: pauses follow, reveals earlier lines, hides the latest.
329
- for (let i = 0; i < 20; i++) h.instance.handleInput('\x1b[A');
330
- text = stripAnsi(h.instance.render(100).join('\n'));
331
- assert.match(text, /lines 9\u201320 of 40/);
332
- assert.match(text, /LINE-009/);
333
- assert.match(text, /LINE-020/);
334
- assert.doesNotMatch(text, /LINE-040/);
335
-
336
- // PageUp reaches the top of the buffer.
337
- h.instance.handleInput('\x1b[5~');
338
- text = stripAnsi(h.instance.render(100).join('\n'));
339
- assert.match(text, /lines 1\u201312 of 40/);
340
- assert.match(text, /LINE-001/);
341
-
342
- // Paging back past the end resumes the live tail.
343
- h.instance.handleInput('\x1b[6~');
344
- h.instance.handleInput('\x1b[6~');
345
- h.instance.handleInput('\x1b[6~');
346
- await new Promise((resolve) => setTimeout(resolve, 20));
347
- text = stripAnsi(h.instance.render(100).join('\n'));
348
- assert.match(text, /following tail/);
349
- assert.match(text, /LINE-040/);
350
- } finally {
351
- h.instance.dispose();
352
- }
353
- } finally {
354
- await rm(dir, { recursive: true, force: true });
355
- }
356
- });
357
-
358
- void it('does not enter scroll mode when the output fits the detail window', async () => {
359
- const dir = await mkdtemp(join(tmpdir(), 'pi-bg-noscroll-'));
360
- try {
361
- const outputAbsPath = join(dir, 'task.output');
362
- await writeFile(outputAbsPath, 'only-line-a\nonly-line-b\nonly-line-c\n', 'utf8');
363
- const h = manager({ initialTaskId: 'b12345678' }, [
364
- task({ outputAbsPath, bytesWritten: 33 }),
365
- ]);
366
- try {
367
- await new Promise((resolve) => setTimeout(resolve, 20));
368
- h.instance.handleInput('\x1b[A');
369
- h.instance.handleInput('\x1b[A');
370
- const text = stripAnsi(h.instance.render(100).join('\n'));
371
- assert.match(text, /only-line-c/);
372
- assert.match(text, /following tail/);
373
- assert.doesNotMatch(text, /lines \d+\u2013\d+ of/);
374
- } finally {
375
- h.instance.dispose();
376
- }
377
- } finally {
378
- await rm(dir, { recursive: true, force: true });
379
- }
380
- });
381
-
382
- void it('requires confirmation before stopping all running tasks and reports no-op clearly', async () => {
383
- const tasks = [task({ id: 'b11111111' }), task({ id: 'b22222222' })];
384
- const h = manager({}, tasks);
385
- try {
386
- h.instance.handleInput('a');
387
- assert.deepEqual(h.stopped, []);
388
- assert.match(stripAnsi(h.instance.render(100).join('\n')), /Press a\/K again/);
389
- h.instance.handleInput('a');
390
- await new Promise((resolve) => setTimeout(resolve, 0));
391
- assert.deepEqual(h.stopped.sort(), ['b11111111', 'b22222222']);
392
- h.instance.handleInput('a');
393
- assert.match(stripAnsi(h.instance.render(100).join('\n')), /No running background tasks/);
394
- } finally {
395
- h.instance.dispose();
396
- }
397
- });
398
-
399
- void it('shows empty and history states, unread badges, and does not auto-clear finished notices on close', () => {
400
- const h = manager({}, []);
401
- try {
402
- const text = stripAnsi(h.instance.render(72).join('\n'));
403
- assert.match(text, /No background tasks/);
404
- h.instance.handleInput('h');
405
- assert.match(stripAnsi(h.instance.render(72).join('\n')), /No background tasks/);
406
- } finally {
407
- h.instance.dispose();
408
- }
409
-
410
- const finished = [
411
- task({
412
- id: 'bfailed01',
413
- name: 'Failed Task',
414
- status: 'failed',
415
- error: 'boom',
416
- endTime: Date.now(),
417
- exitCode: 1,
418
- }),
419
- ];
420
- const hf = manager({}, finished);
421
- try {
422
- const text = stripAnsi(hf.instance.render(80).join('\n'));
423
- assert.match(text, /1 failed/);
424
- assert.match(text, /1 unread/);
425
- assert.match(text, /●/);
426
- hf.instance.handleInput('x');
427
- assert.equal(hf.seen.has('bfailed01'), false);
428
- } finally {
429
- hf.instance.dispose();
430
- }
431
- });
432
-
433
- void it('handles non-running stop, output read failures, paging, long text, and close aliases', async () => {
434
- const many = Array.from({ length: 20 }, (_, i) =>
435
- task({
436
- id: `b${String(i).padStart(8, '0')}`,
437
- name: `Very Long Component Task Name ${String(i)} ${'x'.repeat(80)}`,
438
- command: `printf ${String(i)}`,
439
- startTime: Date.now() - i * 1000,
440
- }),
441
- );
442
- const firstTask = many[0];
443
- assert.ok(firstTask);
444
- firstTask.status = 'completed';
445
- firstTask.endTime = Date.now();
446
- const h = manager({}, many);
447
- try {
448
- h.instance.handleInput('\x1b[6~');
449
- h.instance.handleInput('\x1b[5~');
450
- const lines = h.instance.render(50);
451
- assertWidth(lines, 50);
452
- h.instance.handleInput('h');
453
- h.instance.handleInput('\x1b[5~');
454
- assertWidth(h.instance.render(64), 64);
455
- h.instance.handleInput('\r');
456
- await new Promise((resolve) => setTimeout(resolve, 20));
457
- let text = stripAnsi(h.instance.render(80).join('\n'));
458
- assert.match(text, /Output file not found|No output yet|bg:/);
459
- h.instance.handleInput('\x1b[D');
460
- h.instance.handleInput('h');
461
- h.instance.handleInput('k');
462
- await new Promise((resolve) => setTimeout(resolve, 0));
463
- text = stripAnsi(h.instance.render(90).join('\n'));
464
- assert.match(text, /nothing to stop|Stopped/);
465
- h.instance.handleInput('q');
466
- assert.equal(h.closed, true);
467
- } finally {
468
- h.instance.dispose();
469
- }
470
-
471
- const esc = manager({}, [task()]);
472
- try {
473
- esc.instance.handleInput('\x1b');
474
- assert.equal(esc.closed, true);
475
- } finally {
476
- esc.instance.dispose();
477
- }
478
- });
479
- });
@@ -1,161 +0,0 @@
1
- import { describe, it } from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import { EventEmitter } from 'node:events';
4
- import type { SpawnOptions } from 'node:child_process';
5
- import {
6
- resolveTaskkillPath,
7
- runWindowsTaskkill,
8
- type WindowsKillPhase,
9
- type WindowsTaskkillOptions,
10
- } from '../windows-taskkill.js';
11
-
12
- type WindowsTaskkillSpawn = NonNullable<WindowsTaskkillOptions['spawn']>;
13
-
14
- class FakeTaskkillChild extends EventEmitter {
15
- readonly stdout = new EventEmitter();
16
- readonly stderr = new EventEmitter();
17
- readonly killCalls: NodeJS.Signals[] = [];
18
-
19
- kill(signal?: NodeJS.Signals): boolean {
20
- if (signal !== undefined) this.killCalls.push(signal);
21
- return true;
22
- }
23
-
24
- writeStdout(value: Buffer | string): void {
25
- this.stdout.emit('data', value);
26
- }
27
-
28
- writeStderr(value: Buffer | string): void {
29
- this.stderr.emit('data', value);
30
- }
31
-
32
- close(code: number | null, signal: NodeJS.Signals | null = null): void {
33
- this.emit('close', code, signal);
34
- }
35
- }
36
-
37
- interface SpawnCall {
38
- readonly command: string;
39
- readonly args: string[];
40
- readonly options: SpawnOptions;
41
- readonly child: FakeTaskkillChild;
42
- }
43
-
44
- function fakeSpawn(calls: SpawnCall[], onSpawn?: (child: FakeTaskkillChild) => void): WindowsTaskkillSpawn {
45
- return (command, args, options) => {
46
- const child = new FakeTaskkillChild();
47
- calls.push({ command, args: [...args], options, child });
48
- onSpawn?.(child);
49
- return child;
50
- };
51
- }
52
-
53
- function env(root = 'C:\\Windows'): NodeJS.ProcessEnv {
54
- return {
55
- SystemRoot: root,
56
- PATH: 'C:\\attacker\\bin',
57
- };
58
- }
59
-
60
- void describe('windows taskkill helper', () => {
61
- void it('resolves taskkill from System32 and never PATH', () => {
62
- assert.equal(
63
- resolveTaskkillPath({ SystemRoot: 'C:\\Windows', PATH: 'C:\\attacker\\bin' }),
64
- 'C:\\Windows\\System32\\taskkill.exe',
65
- );
66
- assert.equal(
67
- resolveTaskkillPath({ WINDIR: 'D:\\WinNT', PATH: 'C:\\attacker\\bin' }),
68
- 'D:\\WinNT\\System32\\taskkill.exe',
69
- );
70
- assert.throws(
71
- () => resolveTaskkillPath({ SystemRoot: 'relative\\windows', WINDIR: 'C:\\Windows' }),
72
- /SystemRoot must be an absolute Windows path/,
73
- );
74
- assert.throws(() => resolveTaskkillPath({ PATH: 'C:\\attacker\\bin' }), /Cannot resolve taskkill/);
75
- });
76
-
77
- void it('uses structured argv for terminate and force phases', async () => {
78
- const calls: SpawnCall[] = [];
79
- const spawn = fakeSpawn(calls, (child) => {
80
- queueMicrotask(() => {
81
- child.close(0);
82
- });
83
- });
84
-
85
- for (const phase of ['terminate', 'force'] as const satisfies readonly WindowsKillPhase[]) {
86
- const outcome = await runWindowsTaskkill(1234, phase, { env: env(), spawn });
87
- assert.equal(outcome.exitCode, 0);
88
- }
89
-
90
- assert.equal(calls.length, 2);
91
- assert.deepEqual(calls[0]?.args, ['/PID', '1234', '/T']);
92
- assert.deepEqual(calls[1]?.args, ['/PID', '1234', '/T', '/F']);
93
- for (const call of calls) {
94
- assert.equal(call.command, 'C:\\Windows\\System32\\taskkill.exe');
95
- assert.equal(call.options.shell, false);
96
- assert.equal(call.options.windowsVerbatimArguments, false);
97
- assert.equal(call.options.windowsHide, true);
98
- assert.deepEqual(call.options.stdio, ['ignore', 'pipe', 'pipe']);
99
- }
100
- });
101
-
102
- void it('rejects invalid pid and unresolved taskkill before spawn', async () => {
103
- const calls: SpawnCall[] = [];
104
- const spawn = fakeSpawn(calls);
105
-
106
- assert.throws(() => runWindowsTaskkill(0, 'force', { env: env(), spawn }), /Invalid Windows taskkill pid/);
107
- assert.throws(
108
- () => runWindowsTaskkill(1, 'force', { env: { PATH: 'C:\\attacker\\bin' }, spawn }),
109
- /Cannot resolve taskkill/,
110
- );
111
- assert.equal(calls.length, 0);
112
- });
113
-
114
- void it('bounds stdout and stderr capture and sets truncation flags', async () => {
115
- const calls: SpawnCall[] = [];
116
- const outcomePromise = runWindowsTaskkill(4321, 'force', {
117
- env: env(),
118
- spawn: fakeSpawn(calls),
119
- maxCaptureBytes: 5,
120
- });
121
- const call = calls[0];
122
- assert.ok(call, 'spawn should be recorded');
123
- call.child.writeStdout('abcdef');
124
- call.child.writeStderr(Buffer.from('ghijkl', 'utf8'));
125
- call.child.close(7);
126
-
127
- const outcome = await outcomePromise;
128
- assert.equal(outcome.exitCode, 7);
129
- assert.equal(outcome.stdout, 'abcde');
130
- assert.equal(outcome.stderr, 'ghijk');
131
- assert.equal(outcome.stdoutTruncated, true);
132
- assert.equal(outcome.stderrTruncated, true);
133
- });
134
-
135
- void it('supports external abort and helper timeout without blocking', async () => {
136
- const abortCalls: SpawnCall[] = [];
137
- const controller = new AbortController();
138
- const abortPromise = runWindowsTaskkill(55, 'terminate', {
139
- env: env(),
140
- spawn: fakeSpawn(abortCalls),
141
- signal: controller.signal,
142
- });
143
- const abortCall = abortCalls[0];
144
- assert.ok(abortCall, 'abort helper spawn should be recorded');
145
- controller.abort();
146
- const abortOutcome = await abortPromise;
147
- assert.equal(abortOutcome.exitCode, null);
148
- assert.match(abortOutcome.stderr, /aborted/);
149
- assert.deepEqual(abortCall.child.killCalls, ['SIGKILL']);
150
-
151
- const timeoutCalls: SpawnCall[] = [];
152
- const timeoutOutcome = await runWindowsTaskkill(56, 'force', {
153
- env: env(),
154
- spawn: fakeSpawn(timeoutCalls),
155
- timeoutMs: 10,
156
- });
157
- assert.equal(timeoutOutcome.exitCode, null);
158
- assert.match(timeoutOutcome.stderr, /timed out/);
159
- assert.deepEqual(timeoutCalls[0]?.child.killCalls, ['SIGKILL']);
160
- });
161
- });