@vscode/component-explorer-cli 0.1.1-9 → 0.2.1-0

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 (134) hide show
  1. package/LICENSE +21 -0
  2. package/SECURITY.md +14 -0
  3. package/dist/WorktreePool.d.ts +22 -0
  4. package/dist/WorktreePool.d.ts.map +1 -0
  5. package/dist/WorktreePool.js +58 -0
  6. package/dist/WorktreePool.js.map +1 -0
  7. package/dist/WorktreePool.test.d.ts +2 -0
  8. package/dist/WorktreePool.test.d.ts.map +1 -0
  9. package/dist/_virtual/_build-info.js +4 -0
  10. package/dist/_virtual/_build-info.js.map +1 -0
  11. package/dist/browserPage.d.ts +15 -1
  12. package/dist/browserPage.d.ts.map +1 -1
  13. package/dist/browserPage.js +51 -7
  14. package/dist/browserPage.js.map +1 -1
  15. package/dist/commands/acceptCommand.d.ts.map +1 -1
  16. package/dist/commands/acceptCommand.js +3 -2
  17. package/dist/commands/acceptCommand.js.map +1 -1
  18. package/dist/commands/checkStabilityCommand.d.ts +12 -0
  19. package/dist/commands/checkStabilityCommand.d.ts.map +1 -0
  20. package/dist/commands/checkStabilityCommand.js +84 -0
  21. package/dist/commands/checkStabilityCommand.js.map +1 -0
  22. package/dist/commands/compareCommand.d.ts +1 -0
  23. package/dist/commands/compareCommand.d.ts.map +1 -1
  24. package/dist/commands/compareCommand.js +25 -4
  25. package/dist/commands/compareCommand.js.map +1 -1
  26. package/dist/commands/mcpCommand.d.ts +1 -0
  27. package/dist/commands/mcpCommand.d.ts.map +1 -1
  28. package/dist/commands/mcpCommand.js +23 -10
  29. package/dist/commands/mcpCommand.js.map +1 -1
  30. package/dist/commands/screenshotCommand.d.ts +2 -0
  31. package/dist/commands/screenshotCommand.d.ts.map +1 -1
  32. package/dist/commands/screenshotCommand.js +19 -4
  33. package/dist/commands/screenshotCommand.js.map +1 -1
  34. package/dist/commands/serveCommand.d.ts +4 -0
  35. package/dist/commands/serveCommand.d.ts.map +1 -1
  36. package/dist/commands/serveCommand.js +101 -26
  37. package/dist/commands/serveCommand.js.map +1 -1
  38. package/dist/commands/watchCommand.d.ts +2 -0
  39. package/dist/commands/watchCommand.d.ts.map +1 -1
  40. package/dist/commands/watchCommand.js +18 -66
  41. package/dist/commands/watchCommand.js.map +1 -1
  42. package/dist/comparison.d.ts +11 -1
  43. package/dist/comparison.d.ts.map +1 -1
  44. package/dist/comparison.js +25 -11
  45. package/dist/comparison.js.map +1 -1
  46. package/dist/component-explorer-config.schema.json +260 -55
  47. package/dist/componentExplorer.d.ts +21 -18
  48. package/dist/componentExplorer.d.ts.map +1 -1
  49. package/dist/componentExplorer.js +60 -19
  50. package/dist/componentExplorer.js.map +1 -1
  51. package/dist/daemon/DaemonService.d.ts +100 -11
  52. package/dist/daemon/DaemonService.d.ts.map +1 -1
  53. package/dist/daemon/DaemonService.js +512 -129
  54. package/dist/daemon/DaemonService.js.map +1 -1
  55. package/dist/daemon/dynamicSessions.test.d.ts +2 -0
  56. package/dist/daemon/dynamicSessions.test.d.ts.map +1 -0
  57. package/dist/daemon/lifecycle.d.ts +2 -1
  58. package/dist/daemon/lifecycle.d.ts.map +1 -1
  59. package/dist/daemon/lifecycle.js +52 -30
  60. package/dist/daemon/lifecycle.js.map +1 -1
  61. package/dist/daemon/pipeClient.d.ts.map +1 -1
  62. package/dist/daemon/pipeClient.js +81 -2
  63. package/dist/daemon/pipeClient.js.map +1 -1
  64. package/dist/daemon/pipeServer.d.ts +2 -1
  65. package/dist/daemon/pipeServer.d.ts.map +1 -1
  66. package/dist/daemon/pipeServer.js +59 -2
  67. package/dist/daemon/pipeServer.js.map +1 -1
  68. package/dist/daemon/version.d.ts +10 -0
  69. package/dist/daemon/version.d.ts.map +1 -0
  70. package/dist/daemon/version.js +17 -0
  71. package/dist/daemon/version.js.map +1 -0
  72. package/dist/dependencyInstaller.d.ts +2 -2
  73. package/dist/dependencyInstaller.d.ts.map +1 -1
  74. package/dist/dependencyInstaller.js.map +1 -1
  75. package/dist/explorerSession.d.ts +3 -3
  76. package/dist/explorerSession.d.ts.map +1 -1
  77. package/dist/explorerSession.js +26 -9
  78. package/dist/explorerSession.js.map +1 -1
  79. package/dist/git/gitIndexResolver.d.ts +25 -0
  80. package/dist/git/gitIndexResolver.d.ts.map +1 -0
  81. package/dist/git/gitIndexResolver.js +91 -0
  82. package/dist/git/gitIndexResolver.js.map +1 -0
  83. package/dist/git/gitIndexResolver.test.d.ts +2 -0
  84. package/dist/git/gitIndexResolver.test.d.ts.map +1 -0
  85. package/dist/git/gitService.d.ts +2 -0
  86. package/dist/git/gitService.d.ts.map +1 -1
  87. package/dist/git/gitService.js +6 -0
  88. package/dist/git/gitService.js.map +1 -1
  89. package/dist/git/gitWorktreeManager.d.ts +6 -0
  90. package/dist/git/gitWorktreeManager.d.ts.map +1 -1
  91. package/dist/git/gitWorktreeManager.js +42 -13
  92. package/dist/git/gitWorktreeManager.js.map +1 -1
  93. package/dist/git/gitWorktreeManager.test.d.ts +2 -0
  94. package/dist/git/gitWorktreeManager.test.d.ts.map +1 -0
  95. package/dist/git/testUtils.d.ts +13 -0
  96. package/dist/git/testUtils.d.ts.map +1 -0
  97. package/dist/httpServer.d.ts +18 -7
  98. package/dist/httpServer.d.ts.map +1 -1
  99. package/dist/httpServer.js +117 -18
  100. package/dist/httpServer.js.map +1 -1
  101. package/dist/httpServer.test.d.ts +2 -0
  102. package/dist/httpServer.test.d.ts.map +1 -0
  103. package/dist/index.js +11 -2
  104. package/dist/index.js.map +1 -1
  105. package/dist/logger.d.ts +1 -0
  106. package/dist/logger.d.ts.map +1 -1
  107. package/dist/logger.js +7 -1
  108. package/dist/logger.js.map +1 -1
  109. package/dist/mcp/McpServer.d.ts +18 -0
  110. package/dist/mcp/McpServer.d.ts.map +1 -1
  111. package/dist/mcp/McpServer.js +555 -13
  112. package/dist/mcp/McpServer.js.map +1 -1
  113. package/dist/mcp/TaskManager.d.ts +28 -0
  114. package/dist/mcp/TaskManager.d.ts.map +1 -0
  115. package/dist/mcp/TaskManager.js +54 -0
  116. package/dist/mcp/TaskManager.js.map +1 -0
  117. package/dist/packages/simple-api/dist/{chunk-Q24JOMNK.js → chunk-TAEFVNPN.js} +1 -1
  118. package/dist/packages/simple-api/dist/chunk-TAEFVNPN.js.map +1 -0
  119. package/dist/packages/simple-api/dist/express.js +11 -3
  120. package/dist/packages/simple-api/dist/express.js.map +1 -1
  121. package/dist/utils.d.ts +7 -0
  122. package/dist/utils.d.ts.map +1 -1
  123. package/dist/utils.js +6 -7
  124. package/dist/utils.js.map +1 -1
  125. package/dist/visualCache.d.ts +34 -0
  126. package/dist/visualCache.d.ts.map +1 -0
  127. package/dist/visualCache.js +90 -0
  128. package/dist/visualCache.js.map +1 -0
  129. package/dist/watchConfig.d.ts +68 -15
  130. package/dist/watchConfig.d.ts.map +1 -1
  131. package/dist/watchConfig.js +109 -65
  132. package/dist/watchConfig.js.map +1 -1
  133. package/package.json +21 -4
  134. package/dist/packages/simple-api/dist/chunk-Q24JOMNK.js.map +0 -1
@@ -9,10 +9,40 @@ import '../external/vscode-observables/observables/dist/observableInternal/obser
9
9
  import '../external/vscode-observables/observables/dist/observableInternal/utils/utils.js';
10
10
  import '../external/vscode-observables/observables/dist/observableInternal/observables/observableFromEvent.js';
11
11
  import { buildExplorerUrl } from '../utils.js';
12
+ import { TaskManager } from './TaskManager.js';
12
13
 
13
14
  // ---------------------------------------------------------------------------
14
15
  // Client-local state
15
16
  // ---------------------------------------------------------------------------
17
+ class ImageLruCache {
18
+ _maxSize;
19
+ _entries = [];
20
+ constructor(_maxSize = 10) {
21
+ this._maxSize = _maxSize;
22
+ }
23
+ put(hash, image) {
24
+ const idx = this._entries.findIndex(e => e.hash === hash);
25
+ if (idx !== -1) {
26
+ this._entries.splice(idx, 1);
27
+ }
28
+ this._entries.unshift({ hash, image });
29
+ if (this._entries.length > this._maxSize) {
30
+ this._entries.length = this._maxSize;
31
+ }
32
+ }
33
+ get(hash) {
34
+ const idx = this._entries.findIndex(e => e.hash === hash);
35
+ if (idx === -1) {
36
+ return undefined;
37
+ }
38
+ const [entry] = this._entries.splice(idx, 1);
39
+ this._entries.unshift(entry);
40
+ return entry.image;
41
+ }
42
+ keys() {
43
+ return this._entries.map(e => e.hash);
44
+ }
45
+ }
16
46
  class WatchList {
17
47
  _fixtureIds = new Set();
18
48
  _hashes = new Map();
@@ -100,6 +130,9 @@ class ComponentExplorerMcpServer extends Disposable {
100
130
  }
101
131
  _mcp;
102
132
  _watchList = new WatchList();
133
+ _imageLru = new ImageLruCache(10);
134
+ _taskManager = new TaskManager();
135
+ _taskLastReportedIndex = new Map();
103
136
  _pollFn;
104
137
  _noAutostartHint;
105
138
  _multiSessionTools = [];
@@ -110,6 +143,7 @@ class ComponentExplorerMcpServer extends Disposable {
110
143
  this._daemonConnection = _daemonConnection;
111
144
  this._pollFn = options.pollFn;
112
145
  this._noAutostartHint = options.noAutostartHint;
146
+ this._callTimeoutMs = options.callTimeoutMs ?? ComponentExplorerMcpServer._DEFAULT_CALL_TIMEOUT_MS;
113
147
  this._mcp = new McpServer({
114
148
  name: 'component-explorer',
115
149
  version: '0.1.0',
@@ -178,15 +212,27 @@ class ComponentExplorerMcpServer extends Disposable {
178
212
  _noDaemonError() {
179
213
  return noDaemonError(this._noAutostartHint);
180
214
  }
181
- async _withDaemon(fn) {
215
+ static _DEFAULT_CALL_TIMEOUT_MS = 15_000;
216
+ _callTimeoutMs;
217
+ async _withDaemon(fn, options) {
182
218
  const daemon = await this._waitForDaemon();
183
219
  if (!daemon) {
184
220
  return this._noDaemonError();
185
221
  }
186
222
  try {
187
- return await fn(daemon);
223
+ if (options?.noTimeout) {
224
+ return await fn(daemon);
225
+ }
226
+ const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error('__mcp_timeout__')), this._callTimeoutMs));
227
+ return await Promise.race([fn(daemon), timeout]);
188
228
  }
189
229
  catch (e) {
230
+ if (e instanceof Error && e.message === '__mcp_timeout__') {
231
+ return {
232
+ content: [{ type: 'text', text: `Error: Operation timed out after ${this._callTimeoutMs / 1000}s. Retry, if the error persists, restart the involved session using the restart_session tool and retry.` }],
233
+ isError: true,
234
+ };
235
+ }
190
236
  if (isPipeConnectionError(e)) {
191
237
  this._log('debug', { type: 'daemon-call-failed', error: String(e) });
192
238
  this._handleDisconnect();
@@ -212,7 +258,7 @@ class ComponentExplorerMcpServer extends Disposable {
212
258
  if (event.type === 'source-change' && event.sessionName && event.sourceTreeId) {
213
259
  this._updateSessionSourceTreeId(event.sessionName, event.sourceTreeId);
214
260
  }
215
- if (event.type === 'ref-change') {
261
+ if (event.type === 'ref-change' || event.type === 'session-change') {
216
262
  await this._refreshSessions();
217
263
  }
218
264
  this._log(event.type === 'log' && event.level === 'debug' ? 'debug' : 'info', event);
@@ -292,11 +338,37 @@ class ComponentExplorerMcpServer extends Disposable {
292
338
  }
293
339
  }
294
340
  // -- Tool registration ---------------------------------------------------
341
+ _filterFixtures(allFixtures, fixtureIdPattern, labelPattern) {
342
+ let fixtureIdRegex;
343
+ if (fixtureIdPattern) {
344
+ try {
345
+ fixtureIdRegex = new RegExp(fixtureIdPattern);
346
+ }
347
+ catch {
348
+ return { error: `Error: Invalid fixtureIdPattern: ${fixtureIdPattern}` };
349
+ }
350
+ }
351
+ let labelRegex;
352
+ if (labelPattern) {
353
+ try {
354
+ labelRegex = new RegExp(labelPattern);
355
+ }
356
+ catch {
357
+ return { error: `Error: Invalid labelPattern: ${labelPattern}` };
358
+ }
359
+ }
360
+ return {
361
+ fixtures: allFixtures.filter(f => (!fixtureIdRegex || fixtureIdRegex.test(f.fixtureId)) &&
362
+ (!labelRegex || f.labels.some(l => labelRegex.test(l)))),
363
+ };
364
+ }
295
365
  _registerTools() {
296
366
  this._registerListFixtures();
297
367
  this._registerScreenshot();
298
368
  this._registerCompareScreenshot();
299
369
  this._registerApproveDiff();
370
+ this._registerReviewVisual();
371
+ this._registerCheckVisuals();
300
372
  this._registerEvaluateJs();
301
373
  this._registerDebugReloadPage();
302
374
  this._registerWatchAdd();
@@ -305,12 +377,23 @@ class ComponentExplorerMcpServer extends Disposable {
305
377
  this._registerWatchCompare();
306
378
  this._registerWaitForUpdate();
307
379
  this._registerSessions();
380
+ this._registerRestartSession();
381
+ this._registerOpenSession();
382
+ this._registerCloseSession();
383
+ this._registerUpdateSessionRef();
308
384
  this._registerGetUrl();
385
+ this._registerCheckStability();
386
+ this._registerCheckTask();
387
+ this._registerCancelTask();
388
+ this._registerDebugGetImageByHash();
389
+ this._registerDebugSetBrowserVisibility();
309
390
  }
310
391
  _registerListFixtures() {
311
392
  this._mcp.registerTool('list_fixtures', {
312
393
  description: 'List all fixtures from a session',
313
394
  inputSchema: {
395
+ fixtureIdPattern: z.string().optional().describe('RegExp to filter fixtures by fixture ID'),
396
+ labelPattern: z.string().optional().describe('RegExp to filter fixtures by label (matched against inherited labels)'),
314
397
  sessionName: z.string().optional().describe('Session name (defaults to first session)'),
315
398
  sourceTreeId: z.string().optional().describe('Source tree ID (defaults to latest known)'),
316
399
  },
@@ -320,9 +403,13 @@ class ComponentExplorerMcpServer extends Disposable {
320
403
  this._log('debug', { type: 'tool-call', tool: 'list_fixtures', sessionName });
321
404
  return this._withSourceTreeRetry(async () => {
322
405
  const sourceTreeId = args.sourceTreeId ?? this._sourceTreeId(sessionName);
323
- const fixtures = await daemon.methods.fixtures.list({ sessionName, sourceTreeId });
406
+ const allFixtures = await daemon.methods.fixtures.list({ sessionName, sourceTreeId });
407
+ const filtered = this._filterFixtures(allFixtures, args.fixtureIdPattern, args.labelPattern);
408
+ if ('error' in filtered) {
409
+ return { content: [{ type: 'text', text: filtered.error }], isError: true };
410
+ }
324
411
  return {
325
- content: [{ type: 'text', text: JSON.stringify(fixtures, null, 2) }],
412
+ content: [{ type: 'text', text: JSON.stringify(filtered.fixtures, null, 2) }],
326
413
  };
327
414
  });
328
415
  }));
@@ -353,16 +440,46 @@ class ComponentExplorerMcpServer extends Disposable {
353
440
  });
354
441
  const r = result;
355
442
  this._updateSessionSourceTreeId(sessionName, r.sourceTreeId);
443
+ // Cache image for debug_get_image_by_hash
444
+ if (r.hash && r.image) {
445
+ this._imageLru.put(r.hash, r.image);
446
+ }
356
447
  const info = {
357
448
  hash: r.hash,
358
449
  sourceTreeId: r.sourceTreeId,
359
450
  };
360
- if (r.errors && r.errors.length > 0) {
361
- info.errors = r.errors;
451
+ if (r.hasError) {
452
+ info.hasError = true;
453
+ }
454
+ if (r.error) {
455
+ info.error = r.error;
456
+ }
457
+ if (r.events && r.events.length > 0) {
458
+ info.events = r.events;
459
+ }
460
+ if (r.resultData !== undefined) {
461
+ info.resultData = r.resultData;
362
462
  }
363
463
  if (r.isStable !== undefined) {
364
464
  info.isStable = r.isStable;
365
465
  }
466
+ // Visual review status
467
+ if (r.hash) {
468
+ try {
469
+ const allFixtures = await daemon.methods.fixtures.list({ sessionName, sourceTreeId });
470
+ const fixture = allFixtures.find(f => f.fixtureId === args.fixtureId);
471
+ const descriptions = fixture?.expectedVisualDescriptions ?? [];
472
+ const review = await daemon.methods.visualReview.getStatus({
473
+ fixtureId: args.fixtureId,
474
+ expectedVisualDescriptions: [...descriptions],
475
+ screenshotHash: r.hash,
476
+ });
477
+ info.review = review;
478
+ }
479
+ catch {
480
+ // Visual review not available — ignore
481
+ }
482
+ }
366
483
  const content = [];
367
484
  if (r.isStable === false && r.stabilityScreenshots) {
368
485
  // Not stable: return all distinct screenshots
@@ -417,12 +534,32 @@ class ComponentExplorerMcpServer extends Disposable {
417
534
  match: r.match,
418
535
  baselineHash: r.baselineHash,
419
536
  currentHash: r.currentHash,
537
+ baselineSourceTreeId,
538
+ currentSourceTreeId,
420
539
  };
421
- if (r.baselineErrors && r.baselineErrors.length > 0) {
422
- info.baselineErrors = r.baselineErrors;
540
+ if (r.baselineHasError) {
541
+ info.baselineHasError = true;
542
+ }
543
+ if (r.baselineError) {
544
+ info.baselineError = r.baselineError;
545
+ }
546
+ if (r.baselineEvents && r.baselineEvents.length > 0) {
547
+ info.baselineEvents = r.baselineEvents;
548
+ }
549
+ if (r.baselineResultData !== undefined) {
550
+ info.baselineResultData = r.baselineResultData;
551
+ }
552
+ if (r.currentHasError) {
553
+ info.currentHasError = true;
423
554
  }
424
- if (r.currentErrors && r.currentErrors.length > 0) {
425
- info.currentErrors = r.currentErrors;
555
+ if (r.currentError) {
556
+ info.currentError = r.currentError;
557
+ }
558
+ if (r.currentEvents && r.currentEvents.length > 0) {
559
+ info.currentEvents = r.currentEvents;
560
+ }
561
+ if (r.currentResultData !== undefined) {
562
+ info.currentResultData = r.currentResultData;
426
563
  }
427
564
  if (r.approval) {
428
565
  info.approval = r.approval;
@@ -463,6 +600,127 @@ class ComponentExplorerMcpServer extends Disposable {
463
600
  tool.disable();
464
601
  this._multiSessionTools.push(tool);
465
602
  }
603
+ _registerReviewVisual() {
604
+ this._mcp.registerTool('review_visual', {
605
+ description: 'Approve or reject a fixture\'s screenshot based on its expectedVisualDescriptions. ' +
606
+ 'You must take a screenshot first and pass the resulting hash. ' +
607
+ 'On approve, caches (expectedVisualDescriptions, screenshotHash) so future runs auto-approve.',
608
+ inputSchema: {
609
+ fixtureId: z.string().describe('The fixture ID'),
610
+ screenshotHash: z.string().describe('The screenshot hash (from a prior screenshot tool call)'),
611
+ verdict: z.enum(['approve', 'reject']).describe('Whether the visual matches expectations'),
612
+ comment: z.string().describe('Reason for the verdict'),
613
+ sessionName: z.string().optional().describe('Session name (defaults to first session)'),
614
+ sourceTreeId: z.string().optional().describe('Source tree ID (defaults to latest known)'),
615
+ },
616
+ }, async (args) => this._withDaemon(async (daemon) => {
617
+ const sessionName = args.sessionName ?? this._defaultSessionName();
618
+ this._log('debug', { type: 'tool-call', tool: 'review_visual', fixtureId: args.fixtureId, verdict: args.verdict });
619
+ return this._withSourceTreeRetry(async () => {
620
+ const sourceTreeId = args.sourceTreeId ?? this._sourceTreeId(sessionName);
621
+ // Get fixture descriptions
622
+ const allFixtures = await daemon.methods.fixtures.list({ sessionName, sourceTreeId });
623
+ const fixture = allFixtures.find(f => f.fixtureId === args.fixtureId);
624
+ if (!fixture) {
625
+ return { content: [{ type: 'text', text: `Fixture not found: ${args.fixtureId}` }], isError: true };
626
+ }
627
+ if (fixture.expectedVisualDescriptions.length === 0) {
628
+ return { content: [{ type: 'text', text: `Fixture ${args.fixtureId} has no expectedVisualDescriptions — nothing to review.` }], isError: true };
629
+ }
630
+ if (args.verdict === 'approve') {
631
+ await daemon.methods.visualReview.approve({
632
+ fixtureId: args.fixtureId,
633
+ expectedVisualDescriptions: [...fixture.expectedVisualDescriptions],
634
+ screenshotHash: args.screenshotHash,
635
+ comment: args.comment,
636
+ });
637
+ return {
638
+ content: [{ type: 'text', text: `Approved: ${args.fixtureId} (hash: ${args.screenshotHash})` }],
639
+ };
640
+ }
641
+ else {
642
+ return {
643
+ content: [{ type: 'text', text: JSON.stringify({
644
+ fixtureId: args.fixtureId,
645
+ verdict: 'rejected',
646
+ comment: args.comment,
647
+ screenshotHash: args.screenshotHash,
648
+ expectedVisualDescriptions: fixture.expectedVisualDescriptions,
649
+ }, null, 2) }],
650
+ };
651
+ }
652
+ });
653
+ }));
654
+ }
655
+ _registerCheckVisuals() {
656
+ this._mcp.registerTool('check_visuals', {
657
+ description: 'Batch check visual review status for fixtures that have expectedVisualDescription. ' +
658
+ 'Returns lists of approved, needs-review, and no-expectation fixtures.',
659
+ inputSchema: {
660
+ fixtureIdPattern: z.string().optional().describe('RegExp to filter fixtures by fixture ID'),
661
+ labelPattern: z.string().optional().describe('RegExp to filter fixtures by label'),
662
+ sessionName: z.string().optional().describe('Session name (defaults to first session)'),
663
+ sourceTreeId: z.string().optional().describe('Source tree ID (defaults to latest known)'),
664
+ },
665
+ annotations: { readOnlyHint: true },
666
+ }, async (args) => this._withDaemon(async (daemon) => {
667
+ const sessionName = args.sessionName ?? this._defaultSessionName();
668
+ this._log('debug', { type: 'tool-call', tool: 'check_visuals', sessionName });
669
+ return this._withSourceTreeRetry(async () => {
670
+ const sourceTreeId = args.sourceTreeId ?? this._sourceTreeId(sessionName);
671
+ const allFixtures = await daemon.methods.fixtures.list({ sessionName, sourceTreeId });
672
+ const filtered = this._filterFixtures(allFixtures, args.fixtureIdPattern, args.labelPattern);
673
+ if ('error' in filtered) {
674
+ return { content: [{ type: 'text', text: filtered.error }], isError: true };
675
+ }
676
+ const approved = [];
677
+ const needsReview = [];
678
+ const noExpectation = [];
679
+ // Take screenshots for all fixtures with expectations
680
+ const withExpectations = filtered.fixtures.filter(f => f.expectedVisualDescriptions.length > 0);
681
+ const withoutExpectations = filtered.fixtures.filter(f => f.expectedVisualDescriptions.length === 0);
682
+ for (const f of withoutExpectations) {
683
+ noExpectation.push(f.fixtureId);
684
+ }
685
+ for (const f of withExpectations) {
686
+ try {
687
+ const screenshotResult = await daemon.methods.screenshots.take({
688
+ fixtureId: f.fixtureId, sessionName, sourceTreeId, includeImage: true,
689
+ });
690
+ const hash = screenshotResult.hash;
691
+ const image = screenshotResult.image;
692
+ if (hash && image) {
693
+ this._imageLru.put(hash, image);
694
+ }
695
+ if (!hash) {
696
+ needsReview.push({ fixtureId: f.fixtureId, reason: 'screenshot-failed' });
697
+ continue;
698
+ }
699
+ const review = await daemon.methods.visualReview.getStatus({
700
+ fixtureId: f.fixtureId,
701
+ expectedVisualDescriptions: [...f.expectedVisualDescriptions],
702
+ screenshotHash: hash,
703
+ });
704
+ if (review === 'no-expectations') {
705
+ noExpectation.push(f.fixtureId);
706
+ }
707
+ else if (typeof review === 'object' && review.status === 'approved') {
708
+ approved.push({ fixtureId: f.fixtureId, screenshotHash: hash });
709
+ }
710
+ else {
711
+ needsReview.push({ fixtureId: f.fixtureId, screenshotHash: hash, reason: 'needs-review' });
712
+ }
713
+ }
714
+ catch {
715
+ needsReview.push({ fixtureId: f.fixtureId, reason: 'error' });
716
+ }
717
+ }
718
+ return {
719
+ content: [{ type: 'text', text: JSON.stringify({ approved, needsReview, noExpectation }, null, 2) }],
720
+ };
721
+ });
722
+ }));
723
+ }
466
724
  _registerEvaluateJs() {
467
725
  this._mcp.registerTool('evaluate_js', {
468
726
  description: 'Evaluate a JavaScript expression in the browser page where fixtures are rendered, for debugging purposes. ' +
@@ -660,8 +918,13 @@ class ComponentExplorerMcpServer extends Disposable {
660
918
  const events = await daemon.methods.events();
661
919
  const iterator = events[Symbol.asyncIterator]();
662
920
  try {
663
- const timeout = new Promise(resolve => setTimeout(() => resolve('timeout'), 5000));
921
+ const deadline = Date.now() + 5000;
664
922
  while (true) {
923
+ const remaining = deadline - Date.now();
924
+ if (remaining <= 0) {
925
+ return { content: [{ type: 'text', text: JSON.stringify({ timeout: true, sessionName, sourceTreeId: knownSourceTreeId }, null, 2) }] };
926
+ }
927
+ const timeout = new Promise(resolve => setTimeout(() => resolve('timeout'), remaining));
665
928
  const next = iterator.next();
666
929
  const result = await Promise.race([next, timeout]);
667
930
  if (result === 'timeout') {
@@ -676,7 +939,10 @@ class ComponentExplorerMcpServer extends Disposable {
676
939
  this._updateSessionSourceTreeId(ev.sessionName, ev.sourceTreeId);
677
940
  }
678
941
  if (ev.type === 'ref-change') {
679
- await this._refreshSessions();
942
+ const refreshResult = await Promise.race([this._refreshSessions(), timeout]);
943
+ if (refreshResult === 'timeout') {
944
+ return { content: [{ type: 'text', text: JSON.stringify({ timeout: true, sessionName, sourceTreeId: knownSourceTreeId }, null, 2) }] };
945
+ }
680
946
  }
681
947
  const newSourceTreeId = this._sourceTreeId(sessionName);
682
948
  if (newSourceTreeId && newSourceTreeId !== knownSourceTreeId) {
@@ -737,6 +1003,90 @@ class ComponentExplorerMcpServer extends Disposable {
737
1003
  };
738
1004
  }));
739
1005
  }
1006
+ _registerRestartSession() {
1007
+ this._mcp.registerTool('restart_session', {
1008
+ description: 'Restart a session by disposing its browser page and dev server, then recreating them. ' +
1009
+ 'Use this when a session appears stuck (e.g. after a timeout).',
1010
+ inputSchema: {
1011
+ sessionName: z.string().optional().describe('Session name to restart (defaults to first session)'),
1012
+ },
1013
+ annotations: { destructiveHint: true },
1014
+ }, async (args) => this._withDaemon(async (daemon) => {
1015
+ const sessionName = args.sessionName ?? this._defaultSessionName();
1016
+ this._log('info', { type: 'tool-call', tool: 'restart_session', sessionName });
1017
+ const sessions = await daemon.methods.restartSession({ sessionName });
1018
+ this._sessions = sessions;
1019
+ return {
1020
+ content: [{ type: 'text', text: `Session '${sessionName}' restarted.\n` + JSON.stringify(sessions, null, 2) }],
1021
+ };
1022
+ }));
1023
+ }
1024
+ _registerOpenSession() {
1025
+ this._mcp.registerTool('open_session', {
1026
+ description: 'Open a new worktree-backed session at a given git ref. ' +
1027
+ 'The ref can be a branch name, tag, commit SHA, or the special value "INDEX" to snapshot staged changes. ' +
1028
+ 'The daemon allocates a reusable worktree slot from a fixed pool (max configured in component-explorer.json). ' +
1029
+ 'Returns the updated session list on success.',
1030
+ inputSchema: {
1031
+ name: z.string().describe('Unique session name (e.g. "baseline", "bisect")'),
1032
+ ref: z.string().describe('Git ref: branch, tag, commit SHA, or "INDEX" for staged changes'),
1033
+ },
1034
+ }, async (args) => this._withDaemon(async (daemon) => {
1035
+ this._log('info', { type: 'tool-call', tool: 'open_session', name: args.name, ref: args.ref });
1036
+ const result = await daemon.methods.openSession({ name: args.name, ref: args.ref });
1037
+ if ('error' in result) {
1038
+ return { content: [{ type: 'text', text: result.error }], isError: true };
1039
+ }
1040
+ this._sessions = result.sessions;
1041
+ this._updateMultiSessionToolVisibility();
1042
+ return {
1043
+ content: [{ type: 'text', text: JSON.stringify(result.sessions, null, 2) }],
1044
+ };
1045
+ }, { noTimeout: true }));
1046
+ }
1047
+ _registerCloseSession() {
1048
+ this._mcp.registerTool('close_session', {
1049
+ description: 'Close a dynamic worktree session and release its worktree slot back to the pool. ' +
1050
+ 'Cannot close static sessions configured in component-explorer.json.',
1051
+ inputSchema: {
1052
+ name: z.string().describe('Session name to close'),
1053
+ },
1054
+ annotations: { destructiveHint: true },
1055
+ }, async (args) => this._withDaemon(async (daemon) => {
1056
+ this._log('info', { type: 'tool-call', tool: 'close_session', name: args.name });
1057
+ const result = await daemon.methods.closeSession({ name: args.name });
1058
+ if ('error' in result) {
1059
+ return { content: [{ type: 'text', text: result.error }], isError: true };
1060
+ }
1061
+ this._sessions = result.sessions;
1062
+ this._updateMultiSessionToolVisibility();
1063
+ return {
1064
+ content: [{ type: 'text', text: `Session '${args.name}' closed.\n` + JSON.stringify(result.sessions, null, 2) }],
1065
+ };
1066
+ }));
1067
+ }
1068
+ _registerUpdateSessionRef() {
1069
+ this._mcp.registerTool('update_session_ref', {
1070
+ description: 'Change the git ref of an existing dynamic session. ' +
1071
+ 'The worktree is checked out to the new ref and Vite\'s HMR handles the incremental update (no server restart). ' +
1072
+ 'Fails if the worktree has uncommitted changes — the error will list the dirty files. ' +
1073
+ 'The ref can be a branch, tag, commit SHA, or "INDEX" for staged changes.',
1074
+ inputSchema: {
1075
+ name: z.string().describe('Session name to update'),
1076
+ ref: z.string().describe('New git ref: branch, tag, commit SHA, or "INDEX"'),
1077
+ },
1078
+ }, async (args) => this._withDaemon(async (daemon) => {
1079
+ this._log('info', { type: 'tool-call', tool: 'update_session_ref', name: args.name, ref: args.ref });
1080
+ const result = await daemon.methods.updateSessionRef({ name: args.name, ref: args.ref });
1081
+ if ('error' in result) {
1082
+ return { content: [{ type: 'text', text: result.error }], isError: true };
1083
+ }
1084
+ this._sessions = result.sessions;
1085
+ return {
1086
+ content: [{ type: 'text', text: JSON.stringify(result.sessions, null, 2) }],
1087
+ };
1088
+ }, { noTimeout: true }));
1089
+ }
740
1090
  _registerGetUrl() {
741
1091
  this._mcp.registerTool('get_url', {
742
1092
  description: 'Get URL(s) for viewing fixtures. Returns the Component Explorer UI URL by default, ' +
@@ -814,6 +1164,198 @@ class ComponentExplorerMcpServer extends Disposable {
814
1164
  };
815
1165
  });
816
1166
  }
1167
+ _registerCheckStability() {
1168
+ this._mcp.registerTool('check_stability', {
1169
+ description: 'Check rendering stability of fixtures. Each fixture is unmounted, re-mounted, and screenshotted 3 times (~3s per fixture). ' +
1170
+ 'Returns results directly if finished within ~10s, otherwise returns a taskId for polling via check_task. ' +
1171
+ 'When returning a taskId, includes partial results collected so far.',
1172
+ inputSchema: {
1173
+ fixtureIdPattern: z.string().optional().describe('RegExp to filter fixtures by fixture ID'),
1174
+ labelPattern: z.string().optional().describe('RegExp to filter fixtures by label (matched against inherited labels)'),
1175
+ sessionName: z.string().optional().describe('Session name (defaults to first session)'),
1176
+ sourceTreeId: z.string().optional().describe('Source tree ID (defaults to latest known)'),
1177
+ },
1178
+ annotations: { readOnlyHint: true },
1179
+ }, async (args) => this._withDaemon(async (daemon) => {
1180
+ const sessionName = args.sessionName ?? this._defaultSessionName();
1181
+ this._log('debug', { type: 'tool-call', tool: 'check_stability', sessionName, fixtureIdPattern: args.fixtureIdPattern, labelPattern: args.labelPattern });
1182
+ return this._withSourceTreeRetry(async () => {
1183
+ const sourceTreeId = args.sourceTreeId ?? this._sourceTreeId(sessionName);
1184
+ const allFixtures = await daemon.methods.fixtures.list({ sessionName, sourceTreeId });
1185
+ const filtered = this._filterFixtures(allFixtures, args.fixtureIdPattern, args.labelPattern);
1186
+ if ('error' in filtered) {
1187
+ return { content: [{ type: 'text', text: filtered.error }], isError: true };
1188
+ }
1189
+ const fixtures = filtered.fixtures;
1190
+ this._log('info', { type: 'check-stability-start', total: fixtures.length, filtered: allFixtures.length - fixtures.length });
1191
+ const task = this._taskManager.startTask(async (report, signal) => {
1192
+ const results = [];
1193
+ report({ completed: 0, total: fixtures.length, partialResult: results });
1194
+ for (let i = 0; i < fixtures.length; i++) {
1195
+ if (signal.aborted) {
1196
+ break;
1197
+ }
1198
+ const fixture = fixtures[i];
1199
+ this._log('info', { type: 'check-stability-progress', fixtureId: fixture.fixtureId, index: i + 1, total: fixtures.length });
1200
+ const result = await daemon.methods.screenshots.take({
1201
+ fixtureId: fixture.fixtureId,
1202
+ sessionName,
1203
+ sourceTreeId,
1204
+ includeImage: false,
1205
+ stabilityCheck: true,
1206
+ });
1207
+ const r = result;
1208
+ results.push({
1209
+ fixtureId: fixture.fixtureId,
1210
+ isStable: r.isStable ?? true,
1211
+ screenshots: r.stabilityScreenshots?.map(s => ({ hash: s.hash, delayMs: s.delayMs })) ?? [],
1212
+ });
1213
+ report({ completed: i + 1, total: fixtures.length, partialResult: results });
1214
+ }
1215
+ const stable = results.filter(r => r.isStable).length;
1216
+ return {
1217
+ fixtures: results,
1218
+ summary: { total: results.length, stable, unstable: results.length - stable },
1219
+ };
1220
+ });
1221
+ const waited = await this._taskManager.waitForTask(task.id, 10_000);
1222
+ if (!waited) {
1223
+ return { content: [{ type: 'text', text: 'Error: task disappeared' }], isError: true };
1224
+ }
1225
+ if (waited.done) {
1226
+ return {
1227
+ content: [{ type: 'text', text: JSON.stringify(waited.result, null, 2) }],
1228
+ };
1229
+ }
1230
+ const partial = waited.progress.partialResult;
1231
+ this._taskLastReportedIndex.set(task.id, partial.length);
1232
+ return {
1233
+ content: [{
1234
+ type: 'text',
1235
+ text: JSON.stringify({
1236
+ taskId: task.id,
1237
+ status: 'running',
1238
+ progress: { completed: waited.progress.completed, total: waited.progress.total },
1239
+ elapsedMs: waited.elapsedMs,
1240
+ results: partial,
1241
+ }, null, 2),
1242
+ }],
1243
+ };
1244
+ });
1245
+ }));
1246
+ }
1247
+ _registerCheckTask() {
1248
+ this._mcp.registerTool('check_task', {
1249
+ description: 'Check on a running task. Waits up to ~2s for completion; if still running, returns progress and new results since last check.',
1250
+ inputSchema: {
1251
+ taskId: z.string().describe('The task ID returned by a previous tool call'),
1252
+ },
1253
+ annotations: { readOnlyHint: true },
1254
+ }, async (args) => {
1255
+ const waited = await this._taskManager.waitForTask(args.taskId, 2_000);
1256
+ if (!waited) {
1257
+ this._taskLastReportedIndex.delete(args.taskId);
1258
+ return {
1259
+ content: [{ type: 'text', text: `Error: No task found with id '${args.taskId}'` }],
1260
+ isError: true,
1261
+ };
1262
+ }
1263
+ if (waited.done) {
1264
+ const lastIndex = this._taskLastReportedIndex.get(args.taskId) ?? 0;
1265
+ this._taskLastReportedIndex.delete(args.taskId);
1266
+ const fullResult = waited.result;
1267
+ const newResults = fullResult.fixtures.slice(lastIndex);
1268
+ return {
1269
+ content: [{
1270
+ type: 'text',
1271
+ text: JSON.stringify({
1272
+ status: 'done',
1273
+ newResults,
1274
+ summary: fullResult.summary,
1275
+ }, null, 2),
1276
+ }],
1277
+ };
1278
+ }
1279
+ const partial = waited.progress.partialResult;
1280
+ const lastIndex = this._taskLastReportedIndex.get(args.taskId) ?? 0;
1281
+ const newResults = partial.slice(lastIndex);
1282
+ this._taskLastReportedIndex.set(args.taskId, partial.length);
1283
+ return {
1284
+ content: [{
1285
+ type: 'text',
1286
+ text: JSON.stringify({
1287
+ taskId: args.taskId,
1288
+ status: 'running',
1289
+ progress: { completed: waited.progress.completed, total: waited.progress.total },
1290
+ elapsedMs: waited.elapsedMs,
1291
+ newResults,
1292
+ }, null, 2),
1293
+ }],
1294
+ };
1295
+ });
1296
+ }
1297
+ _registerCancelTask() {
1298
+ this._mcp.registerTool('cancel_task', {
1299
+ description: 'Cancel a running task',
1300
+ inputSchema: {
1301
+ taskId: z.string().describe('The task ID to cancel'),
1302
+ },
1303
+ }, async (args) => {
1304
+ const task = this._taskManager.getTask(args.taskId);
1305
+ if (!task) {
1306
+ return {
1307
+ content: [{ type: 'text', text: `Error: No task found with id '${args.taskId}'` }],
1308
+ isError: true,
1309
+ };
1310
+ }
1311
+ this._taskManager.removeTask(args.taskId);
1312
+ return {
1313
+ content: [{ type: 'text', text: `Task '${args.taskId}' cancelled.` }],
1314
+ };
1315
+ });
1316
+ }
1317
+ _registerDebugGetImageByHash() {
1318
+ this._mcp.registerTool('debug_get_image_by_hash', {
1319
+ description: 'Retrieve a recently-taken screenshot image by its hash. ' +
1320
+ 'Keeps the last ~10 images in an LRU cache. ' +
1321
+ 'Useful for debugging when screenshot hashes behave unexpectedly.',
1322
+ inputSchema: {
1323
+ hash: z.string().describe('The screenshot hash to look up'),
1324
+ },
1325
+ annotations: { readOnlyHint: true },
1326
+ }, async (args) => {
1327
+ const image = this._imageLru.get(args.hash);
1328
+ if (!image) {
1329
+ return {
1330
+ content: [{ type: 'text', text: `No cached image for hash '${args.hash}'. Available hashes: ${this._imageLru.keys().join(', ') || '(none)'}` }],
1331
+ isError: true,
1332
+ };
1333
+ }
1334
+ return {
1335
+ content: [
1336
+ { type: 'text', text: `Image for hash: ${args.hash}` },
1337
+ { type: 'image', data: image, mimeType: 'image/png' },
1338
+ ],
1339
+ };
1340
+ });
1341
+ }
1342
+ _registerDebugSetBrowserVisibility() {
1343
+ this._mcp.registerTool('debug_set_browser_visibility', {
1344
+ description: 'Show or hide the browser window used for rendering fixtures. ' +
1345
+ 'Only use this tool when the user explicitly asks to show or hide the browser. ' +
1346
+ 'Do not call this tool automatically or as part of other workflows. ' +
1347
+ 'Note: changing visibility closes the current browser instance, so the next screenshot or evaluate_js call will relaunch it.',
1348
+ inputSchema: {
1349
+ visible: z.boolean().describe('true to show the browser window (headed mode), false to hide it (headless mode)'),
1350
+ },
1351
+ annotations: { destructiveHint: true },
1352
+ }, async (args) => this._withDaemon(async (daemon) => {
1353
+ await daemon.methods.setBrowserVisibility({ visible: args.visible });
1354
+ return {
1355
+ content: [{ type: 'text', text: `Browser is now ${args.visible ? 'visible (headed)' : 'hidden (headless)'}.` }],
1356
+ };
1357
+ }));
1358
+ }
817
1359
  }
818
1360
 
819
1361
  export { ComponentExplorerMcpServer, DaemonConnection };