@hartvig/developer-control-center 0.8.5 → 0.8.7

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 (97) hide show
  1. package/.developer-control-center/metrics.json +1 -1
  2. package/.developer-control-center/status.json +1 -1
  3. package/.developer-control-center/timings.jsonl +24 -0
  4. package/.github/workflows/ci.yml +1 -7
  5. package/coverage/Developer Control Center/dcc.config.js.html +628 -0
  6. package/coverage/Developer Control Center/index.html +116 -0
  7. package/coverage/Developer Control Center/src/config/index.html +116 -0
  8. package/coverage/Developer Control Center/src/config/loader.ts.html +454 -0
  9. package/coverage/Developer Control Center/src/core/ci.ts.html +163 -0
  10. package/coverage/Developer Control Center/src/core/event-bus.ts.html +187 -0
  11. package/coverage/Developer Control Center/src/core/index.html +191 -0
  12. package/coverage/Developer Control Center/src/core/notifier.ts.html +187 -0
  13. package/coverage/Developer Control Center/src/core/persistence.ts.html +88 -0
  14. package/coverage/Developer Control Center/src/core/task-runner.ts.html +1498 -0
  15. package/coverage/Developer Control Center/src/core/workspaces.ts.html +304 -0
  16. package/coverage/Developer Control Center/src/plugins/index.html +116 -0
  17. package/coverage/Developer Control Center/src/plugins/manager.ts.html +259 -0
  18. package/coverage/Developer Control Center/src/status/index.html +116 -0
  19. package/coverage/Developer Control Center/src/status/store.ts.html +349 -0
  20. package/coverage/Developer Control Center/src/ui/command-list.tsx.html +574 -0
  21. package/coverage/Developer Control Center/src/ui/index.html +161 -0
  22. package/coverage/Developer Control Center/src/ui/metrics-panel.tsx.html +787 -0
  23. package/coverage/Developer Control Center/src/ui/panel.tsx.html +313 -0
  24. package/coverage/Developer Control Center/src/ui/status-panel.tsx.html +565 -0
  25. package/coverage/base.css +224 -0
  26. package/coverage/block-navigation.js +87 -0
  27. package/coverage/clover.xml +588 -0
  28. package/coverage/coverage-final.json +15 -0
  29. package/coverage/favicon.png +0 -0
  30. package/coverage/index.html +191 -0
  31. package/coverage/prettify.css +1 -0
  32. package/coverage/prettify.js +2 -0
  33. package/coverage/sort-arrow-sprite.png +0 -0
  34. package/coverage/sorter.js +210 -0
  35. package/dcc.config.js +8 -4
  36. package/dist/cli.js +0 -0
  37. package/dist/core/persistence.d.ts +2 -0
  38. package/dist/core/persistence.d.ts.map +1 -0
  39. package/dist/core/persistence.js +2 -0
  40. package/dist/core/persistence.js.map +1 -0
  41. package/dist/core/runtime.d.ts.map +1 -1
  42. package/dist/core/runtime.js +5 -2
  43. package/dist/core/runtime.js.map +1 -1
  44. package/dist/core/task-runner.d.ts +1 -0
  45. package/dist/core/task-runner.d.ts.map +1 -1
  46. package/dist/core/task-runner.js +24 -4
  47. package/dist/core/task-runner.js.map +1 -1
  48. package/dist/core/task-runner.test.d.ts +2 -0
  49. package/dist/core/task-runner.test.d.ts.map +1 -0
  50. package/dist/core/task-runner.test.js +326 -0
  51. package/dist/core/task-runner.test.js.map +1 -0
  52. package/dist/core/timer-plugin.d.ts.map +1 -1
  53. package/dist/core/timer-plugin.js +2 -1
  54. package/dist/core/timer-plugin.js.map +1 -1
  55. package/dist/plugins/manager.d.ts +2 -0
  56. package/dist/plugins/manager.d.ts.map +1 -1
  57. package/dist/plugins/manager.js +6 -2
  58. package/dist/plugins/manager.js.map +1 -1
  59. package/dist/plugins/manager.test.js +5 -2
  60. package/dist/plugins/manager.test.js.map +1 -1
  61. package/dist/ui/app.d.ts.map +1 -1
  62. package/dist/ui/app.js +106 -19
  63. package/dist/ui/app.js.map +1 -1
  64. package/dist/ui/command-list.test.d.ts +2 -0
  65. package/dist/ui/command-list.test.d.ts.map +1 -0
  66. package/dist/ui/command-list.test.js +104 -0
  67. package/dist/ui/command-list.test.js.map +1 -0
  68. package/dist/ui/metrics-panel.d.ts.map +1 -1
  69. package/dist/ui/metrics-panel.js +10 -9
  70. package/dist/ui/metrics-panel.js.map +1 -1
  71. package/dist/ui/metrics-panel.test.d.ts +2 -0
  72. package/dist/ui/metrics-panel.test.d.ts.map +1 -0
  73. package/dist/ui/metrics-panel.test.js +111 -0
  74. package/dist/ui/metrics-panel.test.js.map +1 -0
  75. package/dist/ui/panel.test.d.ts +2 -0
  76. package/dist/ui/panel.test.d.ts.map +1 -0
  77. package/dist/ui/panel.test.js +51 -0
  78. package/dist/ui/panel.test.js.map +1 -0
  79. package/dist/ui/status-panel.test.d.ts +2 -0
  80. package/dist/ui/status-panel.test.d.ts.map +1 -0
  81. package/dist/ui/status-panel.test.js +88 -0
  82. package/dist/ui/status-panel.test.js.map +1 -0
  83. package/package.json +3 -1
  84. package/src/core/persistence.ts +1 -0
  85. package/src/core/runtime.ts +7 -2
  86. package/src/core/task-runner.test.ts +395 -0
  87. package/src/core/task-runner.ts +26 -5
  88. package/src/core/timer-plugin.ts +2 -1
  89. package/src/plugins/manager.test.ts +5 -2
  90. package/src/plugins/manager.ts +6 -2
  91. package/src/ui/app.tsx +151 -31
  92. package/src/ui/command-list.test.tsx +124 -0
  93. package/src/ui/metrics-panel.test.tsx +128 -0
  94. package/src/ui/metrics-panel.tsx +10 -10
  95. package/src/ui/panel.test.tsx +84 -0
  96. package/src/ui/status-panel.test.tsx +116 -0
  97. package/vitest.config.ts +1 -1
@@ -0,0 +1,395 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { TaskRunner } from './task-runner.js';
3
+ import { StatusStore } from '../status/store.js';
4
+ import { EventBus } from './event-bus.js';
5
+
6
+ vi.mock('child_process', () => {
7
+ function createMockChild() {
8
+ const { EventEmitter } = require('events');
9
+ const child = new EventEmitter() as any;
10
+ child.pid = 12345;
11
+ child.stdout = new EventEmitter() as any;
12
+ child.stdout.readable = true;
13
+ child.stderr = new EventEmitter() as any;
14
+ child.stderr.readable = true;
15
+ child.kill = vi.fn();
16
+ child.unref = vi.fn();
17
+ return child;
18
+ }
19
+
20
+ return {
21
+ spawn: vi.fn(() => createMockChild()),
22
+ execSync: vi.fn(() => ''),
23
+ };
24
+ });
25
+
26
+ import { spawn as _spawn, execSync as _execSync } from 'child_process';
27
+
28
+ const spawn = vi.mocked(_spawn);
29
+ const execSync = vi.mocked(_execSync);
30
+
31
+ const mockCommand = (overrides: Record<string, any> = {}) => ({
32
+ id: 'test',
33
+ label: 'Test',
34
+ command: 'echo hello',
35
+ ...overrides,
36
+ });
37
+
38
+ function flushMicrotasks(): Promise<void> {
39
+ return new Promise((resolve) => setImmediate(resolve));
40
+ }
41
+
42
+ describe('TaskRunner', () => {
43
+ let store: StatusStore;
44
+ let bus: EventBus;
45
+ let runner: TaskRunner;
46
+
47
+ beforeEach(() => {
48
+ spawn.mockClear();
49
+ execSync.mockClear();
50
+ store = new StatusStore();
51
+ bus = new EventBus();
52
+ runner = new TaskRunner(store, bus);
53
+ });
54
+
55
+ afterEach(() => {
56
+ runner?.abortAll();
57
+ });
58
+
59
+ describe('run', () => {
60
+ it('spawns a process with the command', async () => {
61
+ const cmd = mockCommand({ command: 'echo hello' });
62
+ const promise = runner.run(cmd);
63
+ await flushMicrotasks();
64
+ const child = spawn.mock.results[0]?.value;
65
+ child.emit('close', 0);
66
+ await promise;
67
+
68
+ expect(spawn).toHaveBeenCalledWith('echo hello', [], {
69
+ shell: true,
70
+ detached: true,
71
+ windowsHide: true,
72
+ stdio: ['ignore', 'pipe', 'pipe'],
73
+ cwd: undefined,
74
+ });
75
+ });
76
+
77
+ it('marks the task as success on exit code 0', async () => {
78
+ const cmd = mockCommand({ command: 'true' });
79
+ const promise = runner.run(cmd);
80
+ await flushMicrotasks();
81
+ const child = spawn.mock.results[0]?.value;
82
+ child.emit('close', 0);
83
+ await promise;
84
+
85
+ const task = store.getTask('test');
86
+ expect(task?.status).toBe('success');
87
+ expect(task?.exitCode).toBe(0);
88
+ });
89
+
90
+ it('marks the task as failure on non-zero exit code', async () => {
91
+ const cmd = mockCommand({ command: 'false' });
92
+ const promise = runner.run(cmd);
93
+ await flushMicrotasks();
94
+ const child = spawn.mock.results[0]?.value;
95
+ child.emit('close', 1);
96
+ await promise;
97
+
98
+ const task = store.getTask('test');
99
+ expect(task?.status).toBe('failure');
100
+ expect(task?.exitCode).toBe(1);
101
+ });
102
+
103
+ it('does nothing if command string is empty', async () => {
104
+ await runner.run(mockCommand({ command: '' }));
105
+ expect(spawn).not.toHaveBeenCalled();
106
+ });
107
+
108
+ it('does nothing if task is already running', async () => {
109
+ store.updateTask('test', { id: 'test', label: 'T', status: 'running' });
110
+ await runner.run(mockCommand({ command: 'echo x' }));
111
+ expect(spawn).not.toHaveBeenCalled();
112
+ });
113
+
114
+ it('captures stdout output', async () => {
115
+ const cmd = mockCommand({ command: 'echo hello' });
116
+ const promise = runner.run(cmd);
117
+ await flushMicrotasks();
118
+ const child = spawn.mock.results[0]?.value;
119
+ child.stdout.emit('data', Buffer.from('hello\n'));
120
+ child.emit('close', 0);
121
+ await promise;
122
+
123
+ const task = store.getTask('test');
124
+ expect(task?.output).toBe('hello\n');
125
+ });
126
+
127
+ it('captures stderr output', async () => {
128
+ const cmd = mockCommand({ command: 'cmd' });
129
+ const promise = runner.run(cmd);
130
+ await flushMicrotasks();
131
+ const child = spawn.mock.results[0]?.value;
132
+ child.stderr.emit('data', Buffer.from('error\n'));
133
+ child.emit('close', 1);
134
+ await promise;
135
+
136
+ const task = store.getTask('test');
137
+ expect(task?.output).toBe('error\n');
138
+ });
139
+
140
+ it('handles spawn error', async () => {
141
+ const cmd = mockCommand({ command: 'bad' });
142
+ const promise = runner.run(cmd);
143
+ await flushMicrotasks();
144
+ const child = spawn.mock.results[0]?.value;
145
+ child.emit('error', new Error('spawn failed'));
146
+ await promise;
147
+
148
+ const task = store.getTask('test');
149
+ expect(task?.status).toBe('failure');
150
+ expect(task?.exitCode).toBe(-1);
151
+ expect(task?.output).toContain('spawn failed');
152
+ });
153
+
154
+ it('emits task:start and task:complete events', async () => {
155
+ const events: string[] = [];
156
+ bus.on('task:start', () => events.push('start'));
157
+ bus.on('task:complete', () => events.push('complete'));
158
+
159
+ const cmd = mockCommand({ command: 'echo x' });
160
+ const promise = runner.run(cmd);
161
+ await flushMicrotasks();
162
+ const child = spawn.mock.results[0]?.value;
163
+ child.emit('close', 0);
164
+ await promise;
165
+
166
+ expect(events).toEqual(['start', 'complete']);
167
+ });
168
+
169
+ it('emits task:error on spawn failure', async () => {
170
+ const events: string[] = [];
171
+ bus.on('task:error', () => events.push('error'));
172
+
173
+ const cmd = mockCommand({ command: 'bad' });
174
+ const promise = runner.run(cmd);
175
+ await flushMicrotasks();
176
+ const child = spawn.mock.results[0]?.value;
177
+ child.emit('error', new Error('fail'));
178
+ await promise;
179
+
180
+ expect(events).toEqual(['error']);
181
+ });
182
+
183
+ it('uses toggle.start for toggle commands', async () => {
184
+ const cmd = mockCommand({
185
+ toggle: { start: 'npm run dev' },
186
+ command: 'ignored',
187
+ });
188
+ const promise = runner.run(cmd);
189
+ await flushMicrotasks();
190
+ const child = spawn.mock.results[0]?.value;
191
+ child.emit('close', 0);
192
+ await promise;
193
+
194
+ expect(spawn).toHaveBeenCalledWith('npm run dev', [], expect.any(Object));
195
+ });
196
+
197
+ it('does not mark as complete for toggle.check commands', async () => {
198
+ const cmd = mockCommand({
199
+ toggle: { start: 'npm run dev', check: 'curl localhost' },
200
+ });
201
+ const promise = runner.run(cmd);
202
+ await flushMicrotasks();
203
+ const child = spawn.mock.results[0]?.value;
204
+ child.emit('close', 0);
205
+ await promise;
206
+
207
+ const task = store.getTask('test');
208
+ expect(task?.status).toBe('running');
209
+ });
210
+ });
211
+
212
+ describe('stop', () => {
213
+ it('kills a running process', async () => {
214
+ const cmd = mockCommand({ command: 'sleep 100' });
215
+ runner.run(cmd);
216
+ await flushMicrotasks();
217
+ const child = spawn.mock.results[0]?.value;
218
+
219
+ runner.stop(cmd);
220
+ child.emit('close', null);
221
+
222
+ expect(child.kill).toHaveBeenCalled();
223
+ const task = store.getTask('test');
224
+ expect(task?.status).toBe('success');
225
+ });
226
+
227
+ it('spawns toggle.stop command if defined', async () => {
228
+ const cmd = mockCommand({
229
+ toggle: { start: 'npm run dev', stop: 'pkill -f dev' },
230
+ });
231
+ const runPromise = runner.run(cmd);
232
+ await runPromise;
233
+
234
+ runner.stop(cmd);
235
+
236
+ expect(spawn).toHaveBeenCalledWith('pkill -f dev', {
237
+ shell: true,
238
+ stdio: 'ignore',
239
+ cwd: undefined,
240
+ });
241
+ });
242
+
243
+ it('is a no-op if no process is running', () => {
244
+ const cmd = mockCommand({ command: 'echo x' });
245
+ expect(() => runner.stop(cmd)).not.toThrow();
246
+ });
247
+ });
248
+
249
+ describe('abort / abortAll', () => {
250
+ it('aborts a single task', async () => {
251
+ const cmd = mockCommand({ command: 'sleep 100' });
252
+ runner.run(cmd);
253
+ await flushMicrotasks();
254
+ const child = spawn.mock.results[0]?.value;
255
+
256
+ runner.abort('test');
257
+
258
+ expect(child.kill).toHaveBeenCalled();
259
+ });
260
+
261
+ it('aborts all running tasks', async () => {
262
+ const cmd1 = mockCommand({ id: 'a', command: 'sleep 1' });
263
+ const cmd2 = mockCommand({ id: 'b', command: 'sleep 2' });
264
+ runner.run(cmd1);
265
+ await flushMicrotasks();
266
+ runner.run(cmd2);
267
+ await flushMicrotasks();
268
+ const child1 = spawn.mock.results[0]?.value;
269
+ const child2 = spawn.mock.results[1]?.value;
270
+
271
+ runner.abortAll();
272
+
273
+ expect(child1.kill).toHaveBeenCalled();
274
+ expect(child2.kill).toHaveBeenCalled();
275
+ });
276
+
277
+ it('abort does nothing for unknown id', () => {
278
+ expect(() => runner.abort('nope')).not.toThrow();
279
+ });
280
+ });
281
+
282
+ describe('setCommands', () => {
283
+ it('updates the internal command list', () => {
284
+ const cmds = [mockCommand({ id: 'a' }), mockCommand({ id: 'b' })];
285
+ runner.setCommands(cmds);
286
+ });
287
+ });
288
+
289
+ describe('pipeline', () => {
290
+ it('runs pipeline steps sequentially', async () => {
291
+ runner.setCommands([
292
+ mockCommand({ id: 'step1', command: 'echo step1' }),
293
+ mockCommand({ id: 'step2', command: 'echo step2' }),
294
+ ]);
295
+
296
+ const cmd = mockCommand({ pipelineSteps: ['step1', 'step2'] });
297
+ const promise = runner.run(cmd);
298
+ await flushMicrotasks();
299
+
300
+ const child1 = spawn.mock.results[0]?.value;
301
+ child1.emit('close', 0);
302
+ await flushMicrotasks();
303
+
304
+ const child2 = spawn.mock.results[1]?.value;
305
+ child2.emit('close', 0);
306
+ await promise;
307
+
308
+ const task = store.getTask('test');
309
+ expect(task?.status).toBe('success');
310
+ expect(task?.output).toContain('Pipeline completed');
311
+ });
312
+
313
+ it('fails if a step is not found', async () => {
314
+ const cmd = mockCommand({ pipelineSteps: ['missing-step'] });
315
+ await runner.run(cmd);
316
+
317
+ const task = store.getTask('test');
318
+ expect(task?.status).toBe('failure');
319
+ expect(task?.output).toContain('not found');
320
+ });
321
+
322
+ it('fails pipeline on step failure', async () => {
323
+ runner.setCommands([
324
+ mockCommand({ id: 'ok', command: 'true' }),
325
+ mockCommand({ id: 'bad', command: 'false' }),
326
+ ]);
327
+
328
+ const cmd = mockCommand({ pipelineSteps: ['ok', 'bad'] });
329
+ const promise = runner.run(cmd);
330
+ await flushMicrotasks();
331
+
332
+ const child1 = spawn.mock.results[0]?.value;
333
+ child1.emit('close', 0);
334
+ await flushMicrotasks();
335
+
336
+ const child2 = spawn.mock.results[1]?.value;
337
+ child2.emit('close', 1);
338
+ await promise;
339
+
340
+ const task = store.getTask('test');
341
+ expect(task?.status).toBe('failure');
342
+ expect(task?.output).toContain('Pipeline failed at step 2');
343
+ });
344
+ });
345
+
346
+ describe('parallel steps', () => {
347
+ it('runs steps in parallel and succeeds on all passing', async () => {
348
+ runner.setCommands([
349
+ mockCommand({ id: 'p1', command: 'echo 1' }),
350
+ mockCommand({ id: 'p2', command: 'echo 2' }),
351
+ ]);
352
+
353
+ const cmd = mockCommand({ parallelSteps: ['p1', 'p2'] });
354
+ const promise = runner.run(cmd);
355
+ await flushMicrotasks();
356
+
357
+ const children = spawn.mock.results;
358
+ children[0]?.value.emit('close', 0);
359
+ children[1]?.value.emit('close', 0);
360
+ await promise;
361
+
362
+ const task = store.getTask('test');
363
+ expect(task?.status).toBe('success');
364
+ expect(task?.output).toContain('All parallel steps passed');
365
+ });
366
+
367
+ it('fails if any step fails', async () => {
368
+ runner.setCommands([
369
+ mockCommand({ id: 'p1', command: 'echo 1' }),
370
+ mockCommand({ id: 'p2', command: 'echo 2' }),
371
+ ]);
372
+
373
+ const cmd = mockCommand({ parallelSteps: ['p1', 'p2'] });
374
+ const promise = runner.run(cmd);
375
+ await flushMicrotasks();
376
+
377
+ const children = spawn.mock.results;
378
+ children[0]?.value.emit('close', 0);
379
+ children[1]?.value.emit('close', 1);
380
+ await promise;
381
+
382
+ const task = store.getTask('test');
383
+ expect(task?.status).toBe('failure');
384
+ });
385
+
386
+ it('fails if a step is not found', async () => {
387
+ const cmd = mockCommand({ parallelSteps: ['missing'] });
388
+ await runner.run(cmd);
389
+
390
+ const task = store.getTask('test');
391
+ expect(task?.status).toBe('failure');
392
+ expect(task?.output).toContain('not found');
393
+ });
394
+ });
395
+ });
@@ -9,15 +9,16 @@ class FileWatcher {
9
9
  private watcher?: fs.FSWatcher;
10
10
  private debounceTimer?: ReturnType<typeof setTimeout>;
11
11
 
12
- watch(dir: string, onChange: () => void): void {
12
+ watch(dir: string, onChange: () => void): boolean {
13
13
  this.stop();
14
14
  try {
15
15
  this.watcher = fs.watch(dir, { recursive: true }, () => {
16
16
  if (this.debounceTimer) clearTimeout(this.debounceTimer);
17
17
  this.debounceTimer = setTimeout(onChange, 500);
18
18
  });
19
+ return true;
19
20
  } catch {
20
- console.warn(`[dcc] watch mode: recursive file watching not supported on ${dir}`);
21
+ return false;
21
22
  }
22
23
  }
23
24
 
@@ -85,6 +86,7 @@ export class TaskRunner {
85
86
  const child = spawn(command.command, [], {
86
87
  shell: true,
87
88
  detached: true,
89
+ windowsHide: true,
88
90
  stdio: ['ignore', 'pipe', 'pipe'],
89
91
  cwd: command.cwd,
90
92
  });
@@ -200,14 +202,29 @@ export class TaskRunner {
200
202
  });
201
203
  this.eventBus.emit('task:complete', command.id, 0);
202
204
  if (command.toggle?.stop) {
203
- spawn(command.toggle.stop, {
205
+ const stopCmd = process.platform === 'win32'
206
+ ? this.windowsStopCommand(command.toggle.stop)
207
+ : command.toggle.stop;
208
+ spawn(stopCmd, {
204
209
  shell: true,
205
210
  stdio: 'ignore',
206
211
  cwd: command.cwd,
207
- });
212
+ }).unref();
208
213
  }
209
214
  }
210
215
 
216
+ private windowsStopCommand(cmd: string): string {
217
+ const pkillMatch = cmd.match(/^pkill\s+-f\s+(.+)$/);
218
+ if (pkillMatch) {
219
+ return `taskkill /F /IM ${pkillMatch[1]}* 2>nul`;
220
+ }
221
+ const killMatch = cmd.match(/^kill\s+-?\d*\s+(\d+)$/);
222
+ if (killMatch) {
223
+ return `taskkill /F /PID ${killMatch[1]} 2>nul`;
224
+ }
225
+ return cmd;
226
+ }
227
+
211
228
  private async runPipeline(command: ProkomCommand): Promise<void> {
212
229
  const existing = this.statusStore.getTask(command.id);
213
230
  if (existing?.status === 'running') return;
@@ -388,9 +405,13 @@ export class TaskRunner {
388
405
 
389
406
  private startWatcher(command: ProkomCommand): void {
390
407
  const watcher = new FileWatcher();
391
- watcher.watch(process.cwd(), () => {
408
+ const ok = watcher.watch(process.cwd(), () => {
392
409
  this.run(command);
393
410
  });
411
+ if (!ok) {
412
+ this.eventBus.emit('task:output', command.id, `watch mode: recursive file watching not supported\n`);
413
+ return;
414
+ }
394
415
  this.watchers.set(command.id, watcher);
395
416
  this.statusStore.updateTask(command.id, { watchMode: true });
396
417
  }
@@ -1,9 +1,10 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import type { Plugin } from '../plugins/types.js';
4
+ import { PERSISTENCE_DIR } from './persistence.js';
4
5
 
5
6
  const startTimes = new Map<string, number>();
6
- const TIMINGS_FILE = path.join(process.cwd(), '.developer-control-center', 'timings.jsonl');
7
+ const TIMINGS_FILE = path.join(process.cwd(), PERSISTENCE_DIR, 'timings.jsonl');
7
8
 
8
9
  export const timerPlugin: Plugin = {
9
10
  id: 'timer',
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, beforeEach, vi } from 'vitest';
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
2
  import { PluginManager } from './manager.js';
3
3
  import type { Plugin } from './types.js';
4
4
 
@@ -68,7 +68,8 @@ describe('PluginManager', () => {
68
68
  });
69
69
 
70
70
  it('does not throw when a hook fails', async () => {
71
- vi.spyOn(console, 'error').mockImplementation(() => {});
71
+ const errors: string[] = [];
72
+ pm = new PluginManager((msg) => errors.push(msg));
72
73
  pm.register({
73
74
  id: 'bad',
74
75
  name: 'Bad',
@@ -77,6 +78,8 @@ describe('PluginManager', () => {
77
78
  await expect(
78
79
  pm.executeHook('beforeRun', { id: 'x', label: 'X', command: 'x' }),
79
80
  ).resolves.toBeUndefined();
81
+ expect(errors.length).toBe(1);
82
+ expect(errors[0]).toContain('bad');
80
83
  });
81
84
 
82
85
  it('skips plugin with no hooks for the requested hook', async () => {
@@ -5,6 +5,10 @@ type HookName = keyof PluginHooks;
5
5
  export class PluginManager {
6
6
  private plugins = new Map<string, Plugin>();
7
7
 
8
+ constructor(
9
+ private onError?: (msg: string, err: unknown) => void,
10
+ ) {}
11
+
8
12
  register(plugin: Plugin): void {
9
13
  this.plugins.set(plugin.id, plugin);
10
14
  }
@@ -33,7 +37,7 @@ export class PluginManager {
33
37
  try {
34
38
  await Promise.resolve(fn(...args));
35
39
  } catch (e) {
36
- console.error(`Plugin ${plugin.id} ${hook} error:`, e);
40
+ this.onError?.(`Plugin ${plugin.id} ${hook} error`, e);
37
41
  }
38
42
  }
39
43
  }
@@ -47,7 +51,7 @@ export class PluginManager {
47
51
  const p: Plugin = mod.default || mod;
48
52
  this.register(p);
49
53
  } catch (e) {
50
- console.error(`Failed to load plugin "${name}":`, e);
54
+ this.onError?.(`Failed to load plugin "${name}"`, e);
51
55
  }
52
56
  }
53
57
  }