playwright-e2e-mcp 0.6.1 → 0.8.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 (104) hide show
  1. package/README.md +150 -24
  2. package/dist/cli/ci.js +3 -0
  3. package/dist/cli/ci.js.map +1 -1
  4. package/dist/cli/doctor.d.ts +12 -1
  5. package/dist/cli/doctor.js +34 -4
  6. package/dist/cli/doctor.js.map +1 -1
  7. package/dist/cli/guard.d.ts +11 -0
  8. package/dist/cli/guard.js +80 -0
  9. package/dist/cli/guard.js.map +1 -0
  10. package/dist/cli/upload.d.ts +14 -0
  11. package/dist/cli/upload.js +81 -0
  12. package/dist/cli/upload.js.map +1 -0
  13. package/dist/discovery.d.ts +61 -0
  14. package/dist/discovery.js +108 -0
  15. package/dist/discovery.js.map +1 -0
  16. package/dist/http.d.ts +10 -1
  17. package/dist/http.js +50 -4
  18. package/dist/http.js.map +1 -1
  19. package/dist/index.js +28 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/oauth.d.ts +2 -0
  22. package/dist/oauth.js +1 -1
  23. package/dist/oauth.js.map +1 -1
  24. package/dist/server.d.ts +24 -4
  25. package/dist/server.js +98 -15
  26. package/dist/server.js.map +1 -1
  27. package/dist/skills.d.ts +23 -0
  28. package/dist/skills.js +101 -0
  29. package/dist/skills.js.map +1 -0
  30. package/dist/tools/analyze-history.d.ts +15 -0
  31. package/dist/tools/analyze-history.js +44 -9
  32. package/dist/tools/analyze-history.js.map +1 -1
  33. package/dist/tools/audit-accessibility.d.ts +6 -0
  34. package/dist/tools/audit-performance.d.ts +6 -0
  35. package/dist/tools/check-test-change.d.ts +44 -0
  36. package/dist/tools/check-test-change.js +132 -0
  37. package/dist/tools/check-test-change.js.map +1 -0
  38. package/dist/tools/compare-visual-state.d.ts +6 -0
  39. package/dist/tools/diagnose-flaky.js +5 -1
  40. package/dist/tools/diagnose-flaky.js.map +1 -1
  41. package/dist/tools/generate-e2e-test.d.ts +3 -0
  42. package/dist/tools/generate-e2e-test.js +3 -0
  43. package/dist/tools/generate-e2e-test.js.map +1 -1
  44. package/dist/tools/get-failure.js +3 -1
  45. package/dist/tools/get-failure.js.map +1 -1
  46. package/dist/tools/get-run-status.js +8 -2
  47. package/dist/tools/get-run-status.js.map +1 -1
  48. package/dist/tools/inspect-page.d.ts +6 -0
  49. package/dist/tools/inspect-page.js +3 -1
  50. package/dist/tools/inspect-page.js.map +1 -1
  51. package/dist/tools/list-tests.js +16 -12
  52. package/dist/tools/list-tests.js.map +1 -1
  53. package/dist/tools/probe-script.js +181 -15
  54. package/dist/tools/probe-script.js.map +1 -1
  55. package/dist/tools/quarantine.d.ts +61 -0
  56. package/dist/tools/quarantine.js +133 -0
  57. package/dist/tools/quarantine.js.map +1 -0
  58. package/dist/tools/run-test.d.ts +10 -1
  59. package/dist/tools/run-test.js +38 -6
  60. package/dist/tools/run-test.js.map +1 -1
  61. package/dist/tools/save-login.d.ts +145 -0
  62. package/dist/tools/save-login.js +264 -0
  63. package/dist/tools/save-login.js.map +1 -0
  64. package/dist/tools/shared.d.ts +49 -2
  65. package/dist/tools/shared.js +57 -6
  66. package/dist/tools/shared.js.map +1 -1
  67. package/dist/tools/structured.d.ts +8 -1
  68. package/dist/tools/structured.js +8 -1
  69. package/dist/tools/structured.js.map +1 -1
  70. package/dist/tools/suggest-fix.js +4 -0
  71. package/dist/tools/suggest-fix.js.map +1 -1
  72. package/dist/tools/validate-selector.d.ts +6 -0
  73. package/dist/tools/validate-selector.js +10 -2
  74. package/dist/tools/validate-selector.js.map +1 -1
  75. package/dist/types/index.d.ts +1 -1
  76. package/dist/utils/live-reporter.d.ts +29 -0
  77. package/dist/utils/live-reporter.js +145 -0
  78. package/dist/utils/live-reporter.js.map +1 -0
  79. package/dist/utils/playwright-runner.js +36 -14
  80. package/dist/utils/playwright-runner.js.map +1 -1
  81. package/dist/utils/pro-client.d.ts +29 -0
  82. package/dist/utils/pro-client.js +114 -0
  83. package/dist/utils/pro-client.js.map +1 -0
  84. package/dist/utils/pytest-runner.d.ts +102 -0
  85. package/dist/utils/pytest-runner.js +715 -0
  86. package/dist/utils/pytest-runner.js.map +1 -0
  87. package/dist/utils/quarantine.d.ts +64 -0
  88. package/dist/utils/quarantine.js +106 -0
  89. package/dist/utils/quarantine.js.map +1 -0
  90. package/dist/utils/test-change-guard.d.ts +90 -0
  91. package/dist/utils/test-change-guard.js +516 -0
  92. package/dist/utils/test-change-guard.js.map +1 -0
  93. package/dist/utils/totp.d.ts +26 -0
  94. package/dist/utils/totp.js +84 -0
  95. package/dist/utils/totp.js.map +1 -0
  96. package/dist/utils/update-check.d.ts +50 -0
  97. package/dist/utils/update-check.js +75 -0
  98. package/dist/utils/update-check.js.map +1 -0
  99. package/examples/github-actions/playwright-e2e.yml +80 -0
  100. package/examples/gitlab-ci/playwright-e2e.yml +71 -0
  101. package/package.json +2 -2
  102. package/skill/playwright-e2e-mcp/SKILL.md +45 -7
  103. package/skill/playwright-e2e-mcp/references/setup.md +12 -1
  104. package/skill/playwright-e2e-mcp/references/tools.md +42 -1
@@ -446,26 +446,111 @@ async function attachLocators(page, elements, budget, keep) {
446
446
 
447
447
  /* ---------------- page actions (reach a state before inspecting) ---------------- */
448
448
 
449
+ /* save-login: {{env:NAME}} and {{totp}} in fill values. The values come
450
+ in through this child's environment, never through the config file, and
451
+ a TOTP code is generated at the moment it is typed. */
452
+ function totpCode(t, atMs) {
453
+ const crypto = require('node:crypto');
454
+ const counter = Math.floor(atMs / 1000 / t.period);
455
+ const msg = Buffer.alloc(8);
456
+ msg.writeUInt32BE(Math.floor(counter / 0x100000000), 0);
457
+ msg.writeUInt32BE(counter % 0x100000000, 4);
458
+ const h = crypto.createHmac(t.algorithm.toLowerCase(), Buffer.from(t.key, 'hex')).update(msg).digest();
459
+ const o = h[h.length - 1] & 0x0f;
460
+ const bin = ((h[o] & 0x7f) << 24) | (h[o + 1] << 16) | (h[o + 2] << 8) | h[o + 3];
461
+ return String(bin % Math.pow(10, t.digits)).padStart(t.digits, '0');
462
+ }
463
+
464
+ async function fillValue(raw) {
465
+ const text = raw || '';
466
+ if (text.indexOf('{{') < 0) return text;
467
+ const values = JSON.parse(process.env.PW_MCP_FILL_VALUES || '{}');
468
+ const totp = process.env.PW_MCP_TOTP ? JSON.parse(process.env.PW_MCP_TOTP) : null;
469
+ if (totp && /\{\{\s*totp\s*\}\}/.test(text)) {
470
+ // A code with under 3 seconds left may expire before the form is sent.
471
+ const left = totp.period - (Math.floor(Date.now() / 1000) % totp.period);
472
+ if (left < 3) await new Promise((resolve) => setTimeout(resolve, left * 1000 + 200));
473
+ }
474
+ return text.replace(/\{\{\s*(totp|env:([A-Za-z0-9_]+))\s*\}\}/g, (m, all, name) => {
475
+ if (all === 'totp') {
476
+ if (!totp) throw new Error('{{totp}} needs the totp option');
477
+ return totpCode(totp, Date.now());
478
+ }
479
+ if (!Object.prototype.hasOwnProperty.call(values, name)) throw new Error('No value for {{env:' + name + '}}');
480
+ return values[name];
481
+ });
482
+ }
483
+
484
+ // Find the tab a switchTab step names: 'last' (default), 'first', a 1-based
485
+ // number, or text the tab's URL or title contains. Popups open
486
+ // asynchronously, so keep looking until the step's timeout.
487
+ async function findTab(context, want, timeout) {
488
+ const target = String(want == null || want === '' ? 'last' : want).trim();
489
+ const deadline = Date.now() + timeout;
490
+ for (;;) {
491
+ const pages = context.pages().filter((p) => !p.isClosed());
492
+ let found = null;
493
+ if (target === 'last') found = pages.length > 1 ? pages[pages.length - 1] : null;
494
+ else if (target === 'first') found = pages[0] || null;
495
+ else if (/^\d+$/.test(target)) found = pages[Number(target) - 1] || null;
496
+ else {
497
+ const needle = target.toLowerCase();
498
+ for (const p of pages) {
499
+ let title = '';
500
+ try { title = await p.title(); } catch (err) { /* navigating */ }
501
+ if (p.url().toLowerCase().indexOf(needle) >= 0 || title.toLowerCase().indexOf(needle) >= 0) { found = p; break; }
502
+ }
503
+ }
504
+ if (found) return found;
505
+ if (Date.now() >= deadline) {
506
+ const open = [];
507
+ for (const p of pages) open.push(p.url());
508
+ throw new Error('No tab matches "' + target + '"; open tabs: ' + (open.length ? open.join(', ') : 'none'));
509
+ }
510
+ await new Promise((resolve) => setTimeout(resolve, 100));
511
+ }
512
+ }
513
+
514
+ // Every open tab, for the tool output when an action opened more than one.
515
+ async function listTabs(context, active) {
516
+ const tabs = [];
517
+ for (const p of context.pages()) {
518
+ if (p.isClosed()) continue;
519
+ let title = '';
520
+ try { title = await p.title(); } catch (err) { /* navigating */ }
521
+ tabs.push({ url: p.url(), title: String(title).slice(0, 200), active: p === active });
522
+ }
523
+ return tabs;
524
+ }
525
+
449
526
  async function runActions(page, actions, timeout) {
450
527
  const done = [];
528
+ let current = page;
451
529
  for (let i = 0; i < actions.length; i += 1) {
452
530
  const a = actions[i];
453
531
  const label = 'step ' + (i + 1) + ' (' + (a.label || a.type) + ')';
454
532
  try {
455
- const loc = a.locator ? resolveChain(page, a.locator) : null;
533
+ if (a.type === 'switchTab') {
534
+ current = await findTab(current.context(), a.value, timeout);
535
+ await current.bringToFront().catch(() => undefined);
536
+ await current.waitForLoadState('domcontentloaded', { timeout: timeout }).catch(() => undefined);
537
+ done.push(label);
538
+ continue;
539
+ }
540
+ const loc = a.locator ? resolveChain(current, a.locator) : null;
456
541
  switch (a.type) {
457
- case 'goto': await page.goto(a.url, { waitUntil: cfg.waitUntil || 'domcontentloaded', timeout: timeout }); break;
542
+ case 'goto': await current.goto(a.url, { waitUntil: cfg.waitUntil || 'domcontentloaded', timeout: timeout }); break;
458
543
  case 'click': await loc.click({ timeout: timeout }); break;
459
544
  case 'dblclick': await loc.dblclick({ timeout: timeout }); break;
460
545
  case 'hover': await loc.hover({ timeout: timeout }); break;
461
- case 'fill': await loc.fill(a.value || '', { timeout: timeout }); break;
462
- case 'press': if (loc) await loc.press(a.value, { timeout: timeout }); else await page.keyboard.press(a.value); break;
546
+ case 'fill': await loc.fill(cfg.mode === 'login' ? await fillValue(a.value) : (a.value || ''), { timeout: timeout }); break;
547
+ case 'press': if (loc) await loc.press(a.value, { timeout: timeout }); else await current.keyboard.press(a.value); break;
463
548
  case 'check': await loc.check({ timeout: timeout }); break;
464
549
  case 'uncheck': await loc.uncheck({ timeout: timeout }); break;
465
550
  case 'select': await loc.selectOption(a.value, { timeout: timeout }); break;
466
551
  case 'wait':
467
552
  if (loc) await loc.first().waitFor({ state: 'visible', timeout: timeout });
468
- else await page.waitForTimeout(Math.min(a.ms || 500, 10000));
553
+ else await current.waitForTimeout(Math.min(a.ms || 500, 10000));
469
554
  break;
470
555
  default: throw new Error('Unknown action type ' + a.type);
471
556
  }
@@ -474,7 +559,7 @@ async function runActions(page, actions, timeout) {
474
559
  const message = err && err.message ? err.message : String(err);
475
560
  const callLog = callLogOf(message);
476
561
  let where = '';
477
- try { where = page.url(); } catch (urlErr) { /* page gone */ }
562
+ try { where = current.url(); } catch (urlErr) { /* page gone */ }
478
563
  const wrapped = new Error('Action ' + label + ' failed: ' + message.split('\n')[0] + (/intercepts pointer events/.test(callLog) ? ' (another element intercepts pointer events)' : ''));
479
564
  wrapped.actionsDone = done;
480
565
  wrapped.callLog = callLog;
@@ -482,7 +567,7 @@ async function runActions(page, actions, timeout) {
482
567
  throw wrapped;
483
568
  }
484
569
  }
485
- return done;
570
+ return { done: done, page: current };
486
571
  }
487
572
 
488
573
  /* ---------------- performance (mode: perf) ---------------- */
@@ -712,6 +797,18 @@ function summariseRequests(requests, timings, pageUrl) {
712
797
  if (recording) contextOptions.recordHar = { path: cfg.har.path, urlFilter: cfg.har.url, mode: 'minimal' };
713
798
  const context = await browser.newContext(contextOptions);
714
799
  openContext = context;
800
+ // blockWrites: registered first, so it runs last, after the HAR replay
801
+ // and every mock below have had their chance to answer.
802
+ const blockedWrites = { total: 0, requests: [] };
803
+ if (cfg.blockWrites && !cfg.snapshotHtmlPath) {
804
+ await context.route('**/*', (route) => {
805
+ const method = route.request().method();
806
+ if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') return route.fallback();
807
+ blockedWrites.total += 1;
808
+ if (blockedWrites.requests.length < 20) blockedWrites.requests.push({ method: method, url: route.request().url() });
809
+ return route.abort('blockedbyclient');
810
+ });
811
+ }
715
812
  // Replay goes first so the network mocks below, registered later, win.
716
813
  if (cfg.har && cfg.har.mode === 'replay' && !cfg.snapshotHtmlPath) {
717
814
  await context.routeFromHAR(cfg.har.path, { url: cfg.har.url, notFound: cfg.har.notFound || 'abort' });
@@ -737,7 +834,7 @@ function summariseRequests(requests, timings, pageUrl) {
737
834
  }
738
835
 
739
836
  if (cfg.mode === 'perf') await context.addInitScript(perfInit);
740
- const page = await context.newPage();
837
+ let page = await context.newPage();
741
838
 
742
839
  // perf: count bytes per request over CDP (cross-origin sizes included)
743
840
  // and apply CPU / network throttling before the first byte.
@@ -764,12 +861,17 @@ function summariseRequests(requests, timings, pageUrl) {
764
861
  }
765
862
 
766
863
  const consoleMessages = [];
767
- page.on('console', (msg) => {
768
- if (consoleMessages.length < 50) consoleMessages.push({ type: msg.type(), text: String(msg.text()).slice(0, 300) });
769
- });
770
- page.on('pageerror', (err) => {
771
- if (consoleMessages.length < 50) consoleMessages.push({ type: 'pageerror', text: String(err).slice(0, 300) });
772
- });
864
+ // Popups and new tabs report their console too.
865
+ const watchConsole = (p) => {
866
+ p.on('console', (msg) => {
867
+ if (consoleMessages.length < 50) consoleMessages.push({ type: msg.type(), text: String(msg.text()).slice(0, 300) });
868
+ });
869
+ p.on('pageerror', (err) => {
870
+ if (consoleMessages.length < 50) consoleMessages.push({ type: 'pageerror', text: String(err).slice(0, 300) });
871
+ });
872
+ };
873
+ watchConsole(page);
874
+ context.on('page', watchConsole);
773
875
 
774
876
  if (cfg.snapshotHtmlPath) {
775
877
  // A DOM snapshot from a trace: render it with every request blocked,
@@ -783,7 +885,9 @@ function summariseRequests(requests, timings, pageUrl) {
783
885
  // perf: let late images, fonts and shifts land before anything else.
784
886
  if (cfg.mode === 'perf' && cfg.settleMs) await page.waitForTimeout(cfg.settleMs);
785
887
  if (cfg.actions && cfg.actions.length) {
786
- actionsDone = await runActions(page, cfg.actions, cfg.actionTimeout || 10000);
888
+ const ran = await runActions(page, cfg.actions, cfg.actionTimeout || 10000);
889
+ actionsDone = ran.done;
890
+ page = ran.page;
787
891
  if (cfg.mode === 'perf') {
788
892
  await page.waitForLoadState('load', { timeout: cfg.waitTimeout || 5000 }).catch(() => undefined);
789
893
  await page.waitForTimeout(Math.min(cfg.settleMs || 1000, 2000));
@@ -791,6 +895,64 @@ function summariseRequests(requests, timings, pageUrl) {
791
895
  }
792
896
  }
793
897
 
898
+ if (cfg.mode === 'login') {
899
+ // Check the session (after logging in, or a saved one), then save it.
900
+ const v = cfg.loginVerify || null;
901
+ let verified = null;
902
+ let verifyNote = '';
903
+ if (v) {
904
+ if (v.url) {
905
+ const resp = await page.goto(v.url, { waitUntil: cfg.waitUntil || 'domcontentloaded', timeout: cfg.gotoTimeout || 15000 });
906
+ if (resp) httpStatus = resp.status();
907
+ }
908
+ verified = true;
909
+ if (v.visible) {
910
+ try {
911
+ await resolveChain(page, v.visible).first().waitFor({ state: 'visible', timeout: cfg.waitTimeout || 10000 });
912
+ } catch (waitErr) {
913
+ verified = false;
914
+ verifyNote = 'the signed-in marker did not appear';
915
+ }
916
+ }
917
+ if (verified && v.notUrl && page.url().indexOf(v.notUrl) >= 0) {
918
+ verified = false;
919
+ verifyNote = 'the page went to ' + page.url();
920
+ }
921
+ }
922
+ const state = await context.storageState();
923
+ const now = Date.now() / 1000;
924
+ const expiries = state.cookies.map((c) => c.expires).filter((e) => e > 0);
925
+ // The parent writes the file (inside the project, never through a symlink).
926
+ const keep = !cfg.loginCheckOnly && verified !== false;
927
+ send({
928
+ ok: true,
929
+ data: {
930
+ elementCount: 0,
931
+ matchCount: 0,
932
+ elements: [],
933
+ finalUrl: page.url(),
934
+ title: await page.title(),
935
+ actionsDone: actionsDone,
936
+ routeHits: routeHits,
937
+ blockedWrites: blockedWrites.total ? blockedWrites : undefined,
938
+ httpStatus: httpStatus,
939
+ durationMs: Date.now() - cfg.startedAt,
940
+ login: {
941
+ verified: verified,
942
+ verifyNote: verifyNote,
943
+ state: keep ? state : undefined,
944
+ cookies: state.cookies.length,
945
+ sessionCookies: state.cookies.length - expiries.length,
946
+ origins: state.origins.length,
947
+ firstExpiry: expiries.length ? Math.min.apply(null, expiries) : null,
948
+ expired: expiries.filter((e) => e <= now).length,
949
+ },
950
+ },
951
+ });
952
+ await closeBrowser();
953
+ process.exit(0);
954
+ }
955
+
794
956
  if (cfg.mode === 'screenshot') {
795
957
  if (cfg.selectorChain) {
796
958
  await resolveChain(page, cfg.selectorChain).first().screenshot({ path: cfg.screenshotPath });
@@ -804,11 +966,13 @@ function summariseRequests(requests, timings, pageUrl) {
804
966
  title: await page.title(),
805
967
  finalUrl: page.url(),
806
968
  viewport: page.viewportSize() || { width: 1280, height: 720 },
969
+ tabs: context.pages().length > 1 ? await listTabs(context, page) : undefined,
807
970
  elementCount: 0,
808
971
  matchCount: 0,
809
972
  elements: [],
810
973
  actionsDone: actionsDone,
811
974
  routeHits: routeHits,
975
+ blockedWrites: blockedWrites.total ? blockedWrites : undefined,
812
976
  httpStatus: httpStatus,
813
977
  durationMs: Date.now() - cfg.startedAt,
814
978
  },
@@ -1005,7 +1169,9 @@ function summariseRequests(requests, timings, pageUrl) {
1005
1169
 
1006
1170
  data.consoleMessages = consoleMessages;
1007
1171
  data.actionsDone = actionsDone;
1172
+ if (context.pages().length > 1) data.tabs = await listTabs(context, page);
1008
1173
  data.routeHits = routeHits;
1174
+ if (blockedWrites.total) data.blockedWrites = blockedWrites;
1009
1175
  data.finalUrl = data.finalUrl || page.url();
1010
1176
  data.httpStatus = httpStatus;
1011
1177
  data.durationMs = Date.now() - cfg.startedAt;
@@ -1 +1 @@
1
- {"version":3,"file":"probe-script.js","sourceRoot":"","sources":["../../src/tools/probe-script.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,MAAM,CAAC,MAAM,YAAY,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAs+BrC,CAAC"}
1
+ {"version":3,"file":"probe-script.js","sourceRoot":"","sources":["../../src/tools/probe-script.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,MAAM,CAAC,MAAM,YAAY,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4oCrC,CAAC"}
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Tool — quarantine: keep a flaky test running and reported while its
3
+ * failures stop blocking the build, with an owner, a reason and an expiry
4
+ * date (default 14 days, at most 90). run-test and the `ci` command read
5
+ * the list: a quarantined failure is reported but does not fail the run;
6
+ * once the entry expires it blocks again.
7
+ */
8
+ import { z } from 'zod';
9
+ import type { ToolContext, ToolResponse } from '../types/index.js';
10
+ declare const quarantineInput: z.ZodObject<{
11
+ action: z.ZodOptional<z.ZodEnum<{
12
+ add: "add";
13
+ list: "list";
14
+ remove: "remove";
15
+ }>>;
16
+ test: z.ZodOptional<z.ZodString>;
17
+ file: z.ZodOptional<z.ZodString>;
18
+ title: z.ZodOptional<z.ZodString>;
19
+ index: z.ZodOptional<z.ZodNumber>;
20
+ owner: z.ZodOptional<z.ZodString>;
21
+ reason: z.ZodOptional<z.ZodString>;
22
+ days: z.ZodOptional<z.ZodNumber>;
23
+ projectRoot: z.ZodOptional<z.ZodString>;
24
+ }, z.core.$strip>;
25
+ export type QuarantineInput = z.infer<typeof quarantineInput>;
26
+ export declare const quarantineSchema: z.ZodObject<{
27
+ action: z.ZodOptional<z.ZodEnum<{
28
+ add: "add";
29
+ list: "list";
30
+ remove: "remove";
31
+ }>>;
32
+ test: z.ZodOptional<z.ZodString>;
33
+ file: z.ZodOptional<z.ZodString>;
34
+ title: z.ZodOptional<z.ZodString>;
35
+ index: z.ZodOptional<z.ZodNumber>;
36
+ owner: z.ZodOptional<z.ZodString>;
37
+ reason: z.ZodOptional<z.ZodString>;
38
+ days: z.ZodOptional<z.ZodNumber>;
39
+ projectRoot: z.ZodOptional<z.ZodString>;
40
+ }, z.core.$strip>;
41
+ export declare const quarantineTool: {
42
+ name: string;
43
+ description: string;
44
+ inputSchema: z.ZodObject<{
45
+ action: z.ZodOptional<z.ZodEnum<{
46
+ add: "add";
47
+ list: "list";
48
+ remove: "remove";
49
+ }>>;
50
+ test: z.ZodOptional<z.ZodString>;
51
+ file: z.ZodOptional<z.ZodString>;
52
+ title: z.ZodOptional<z.ZodString>;
53
+ index: z.ZodOptional<z.ZodNumber>;
54
+ owner: z.ZodOptional<z.ZodString>;
55
+ reason: z.ZodOptional<z.ZodString>;
56
+ days: z.ZodOptional<z.ZodNumber>;
57
+ projectRoot: z.ZodOptional<z.ZodString>;
58
+ }, z.core.$strip>;
59
+ handler: (args: QuarantineInput, ctx: ToolContext) => Promise<ToolResponse>;
60
+ };
61
+ export {};
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Tool — quarantine: keep a flaky test running and reported while its
3
+ * failures stop blocking the build, with an owner, a reason and an expiry
4
+ * date (default 14 days, at most 90). run-test and the `ci` command read
5
+ * the list: a quarantined failure is reported but does not fail the run;
6
+ * once the entry expires it blocks again.
7
+ */
8
+ import { z } from 'zod';
9
+ import { PlaywrightMcpError } from '../types/index.js';
10
+ import { normalizePath, relativeToRoot, sanitizeUserPath } from '../utils/path-utils.js';
11
+ import { addDays, DEFAULT_QUARANTINE_DAYS, isExpired, matchesEntry, MAX_QUARANTINE_DAYS, QUARANTINE_FILE, readQuarantine, today, writeQuarantine, } from '../utils/quarantine.js';
12
+ import { readHistory, testStats } from '../utils/run-history.js';
13
+ import { guard, markdownCell, resolveProjectRoot, toolText } from './shared.js';
14
+ const quarantineInput = z.object({
15
+ action: z.enum(['list', 'add', 'remove']).optional().describe("'list' (default), 'add' or 'remove'"),
16
+ test: z
17
+ .string()
18
+ .optional()
19
+ .describe("The test as 'file › title' (as get-failure and analyze-history print it), or use file + title"),
20
+ file: z.string().optional().describe('Spec file, relative to the project'),
21
+ title: z.string().optional().describe("Test title, or its last parts ('Checkout › pays by card')"),
22
+ index: z.number().int().min(1).optional().describe('add: the Nth failure of the last run instead of test/file/title'),
23
+ owner: z.string().min(1).max(80).optional().describe('add: who will fix it (name or @handle)'),
24
+ reason: z.string().min(3).max(300).optional().describe('add: why it is flaky, or the issue link'),
25
+ days: z.number().int().min(1).max(MAX_QUARANTINE_DAYS).optional().describe(`add: how long it counts (default ${DEFAULT_QUARANTINE_DAYS}, max ${MAX_QUARANTINE_DAYS})`),
26
+ projectRoot: z.string().optional().describe('Project directory (default: server root)'),
27
+ });
28
+ export const quarantineSchema = quarantineInput;
29
+ function target(args, root, ctx) {
30
+ if (args.index !== undefined) {
31
+ const last = ctx.store.lastRun;
32
+ const failure = last?.projectRoot === root ? last.result.failures[args.index - 1] : undefined;
33
+ if (!failure) {
34
+ throw new PlaywrightMcpError(`No failure #${args.index} in the last run`, 'INVALID_INPUT', {
35
+ hint: 'Run the tests with run-test first, or name the test with test or file + title.',
36
+ });
37
+ }
38
+ return { file: failure.file, title: withoutFilePrefix(failure.file, failure.title) };
39
+ }
40
+ let file = args.file;
41
+ let title = args.title;
42
+ if (args.test) {
43
+ const at = args.test.indexOf(' › ');
44
+ if (at < 0)
45
+ throw new PlaywrightMcpError(`test must look like 'file › title': ${args.test}`, 'INVALID_INPUT');
46
+ file = args.test.slice(0, at);
47
+ title = args.test.slice(at + 3).replace(/ \[[^\]]+\]$/, '');
48
+ }
49
+ if (!file || !title) {
50
+ throw new PlaywrightMcpError('Name the test', 'INVALID_INPUT', { hint: "Pass test ('tests/cart.spec.ts › adds an item'), file + title, or index." });
51
+ }
52
+ const rel = relativeToRoot(root, sanitizeUserPath(normalizePath(file), root));
53
+ return { file: rel, title: withoutFilePrefix(rel, title.trim()) };
54
+ }
55
+ /** Reported titles start with the spec's file name ('cart.spec.ts › adds'); the entry keeps the rest. */
56
+ function withoutFilePrefix(file, title) {
57
+ const prefix = `${file.split('/').pop()} › `;
58
+ return title.startsWith(prefix) ? title.slice(prefix.length) : title;
59
+ }
60
+ /** The entry for this test, however much of the title path either side names. */
61
+ function findEntry(entries, test) {
62
+ return entries.find((e) => matchesEntry(e, test) || matchesEntry({ ...e, title: test.title }, { file: e.file, title: e.title }));
63
+ }
64
+ function renderList(entries, note) {
65
+ const lines = [];
66
+ if (note)
67
+ lines.push(note, '');
68
+ if (entries.length === 0) {
69
+ lines.push('## 🧊 Quarantine is empty', '', 'Add a flaky test with `action: "add"`, an owner and a reason. Its failures stop blocking runs until the entry expires.');
70
+ return lines.join('\n');
71
+ }
72
+ const expired = entries.filter((e) => isExpired(e));
73
+ lines.push(`## 🧊 ${entries.length} quarantined test${entries.length === 1 ? '' : 's'}${expired.length ? ` · ⏰ ${expired.length} expired` : ''}`, '');
74
+ lines.push('| Test | Owner | Until | Reason |', '| --- | --- | --- | --- |');
75
+ for (const e of entries) {
76
+ lines.push(`| \`${markdownCell(e.file)}\` › ${markdownCell(e.title)} | ${markdownCell(e.owner)} | ${isExpired(e) ? `⏰ ${e.until}` : e.until} | ${markdownCell(e.reason)} |`);
77
+ }
78
+ lines.push('', `Stored in \`${QUARANTINE_FILE}\`: commit it so CI and the team use the same list. Expired entries block again.`);
79
+ return lines.join('\n');
80
+ }
81
+ export const quarantineTool = {
82
+ name: 'quarantine',
83
+ description: "Quarantine flaky tests: they keep running and being reported, but their failures stop failing run-test and the ci command until the entry expires (default 14 days, max 90). add needs owner and reason; list shows what is quarantined and what expired; remove ends it. Stored in playwright-quarantine.json (commit it).",
84
+ inputSchema: quarantineSchema,
85
+ handler: async (args, ctx) => guard('quarantine', async () => {
86
+ const root = await resolveProjectRoot(args.projectRoot, ctx);
87
+ const action = args.action ?? 'list';
88
+ const list = await readQuarantine(root);
89
+ if (action === 'list') {
90
+ return toolText(renderList(list.entries), { status: 'listed', total: list.entries.length });
91
+ }
92
+ if (ctx.restricted) {
93
+ throw new PlaywrightMcpError('Changing the quarantine is not available on this server', 'INVALID_PATH', {
94
+ hint: 'It writes playwright-quarantine.json in the project. Run the server locally.',
95
+ });
96
+ }
97
+ const test = target(args, root, ctx);
98
+ const existing = findEntry(list.entries, test);
99
+ if (action === 'remove') {
100
+ if (!existing) {
101
+ return toolText(renderList(list.entries, `> Nothing to remove: \`${test.file}\` › ${test.title} is not quarantined.`), { status: 'unchanged', total: list.entries.length });
102
+ }
103
+ list.entries = list.entries.filter((e) => e !== existing);
104
+ await writeQuarantine(root, list);
105
+ return toolText(renderList(list.entries, `> ✅ Removed \`${existing.file}\` › ${existing.title}: its failures block runs again.`), { status: 'removed', total: list.entries.length });
106
+ }
107
+ if (!args.owner || !args.reason) {
108
+ throw new PlaywrightMcpError('add needs owner and reason', 'INVALID_INPUT', {
109
+ hint: 'A quarantine without an owner is a skip nobody fixes. Name who will fix it and why it is flaky (or an issue link).',
110
+ });
111
+ }
112
+ const days = args.days ?? DEFAULT_QUARANTINE_DAYS;
113
+ const entry = {
114
+ file: test.file,
115
+ title: test.title,
116
+ owner: args.owner.trim(),
117
+ reason: args.reason.trim(),
118
+ until: addDays(today(), days),
119
+ added: existing?.added ?? today(),
120
+ };
121
+ list.entries = [...list.entries.filter((e) => e !== existing), entry];
122
+ await writeQuarantine(root, list);
123
+ // How unstable has it been? The local history answers if it has the test.
124
+ const history = await readHistory(root, 50).catch(() => []);
125
+ const stats = testStats(history).find((s) => matchesEntry(entry, s));
126
+ const evidence = stats
127
+ ? `\n\n**History:** \`${stats.strip}\` · ${Math.round(stats.failRate * 100)}% of the last ${stats.runs} runs failed or needed a retry.`
128
+ : '';
129
+ const note = `> ✅ ${existing ? 'Extended' : 'Quarantined'} \`${entry.file}\` › ${entry.title} until ${entry.until} (owner ${entry.owner}). It still runs; its failures no longer fail the run.${evidence}`;
130
+ return toolText(renderList(list.entries, note), { status: existing ? 'extended' : 'added', total: list.entries.length });
131
+ }),
132
+ };
133
+ //# sourceMappingURL=quarantine.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"quarantine.js","sourceRoot":"","sources":["../../src/tools/quarantine.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAEvD,OAAO,EAAE,aAAa,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AACzF,OAAO,EACL,OAAO,EACP,uBAAuB,EACvB,SAAS,EACT,YAAY,EACZ,mBAAmB,EACnB,eAAe,EACf,cAAc,EACd,KAAK,EACL,eAAe,GAChB,MAAM,wBAAwB,CAAC;AAEhC,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC;AACjE,OAAO,EAAE,KAAK,EAAE,YAAY,EAAE,kBAAkB,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAEhF,MAAM,eAAe,GAAG,CAAC,CAAC,MAAM,CAAC;IAC/B,MAAM,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,KAAK,EAAE,QAAQ,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,qCAAqC,CAAC;IACpG,IAAI,EAAE,CAAC;SACJ,MAAM,EAAE;SACR,QAAQ,EAAE;SACV,QAAQ,CAAC,+FAA+F,CAAC;IAC5G,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,oCAAoC,CAAC;IAC1E,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,2DAA2D,CAAC;IAClG,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,iEAAiE,CAAC;IACrH,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,wCAAwC,CAAC;IAC9F,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,yCAAyC,CAAC;IACjG,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,mBAAmB,CAAC,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,oCAAoC,uBAAuB,SAAS,mBAAmB,GAAG,CAAC;IACtK,WAAW,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,0CAA0C,CAAC;CACxF,CAAC,CAAC;AAGH,MAAM,CAAC,MAAM,gBAAgB,GAAG,eAAe,CAAC;AAEhD,SAAS,MAAM,CAAC,IAAqB,EAAE,IAAY,EAAE,GAAgB;IACnE,IAAI,IAAI,CAAC,KAAK,KAAK,SAAS,EAAE,CAAC;QAC7B,MAAM,IAAI,GAAG,GAAG,CAAC,KAAK,CAAC,OAAO,CAAC;QAC/B,MAAM,OAAO,GAAG,IAAI,EAAE,WAAW,KAAK,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;QAC9F,IAAI,CAAC,OAAO,EAAE,CAAC;YACb,MAAM,IAAI,kBAAkB,CAAC,eAAe,IAAI,CAAC,KAAK,kBAAkB,EAAE,eAAe,EAAE;gBACzF,IAAI,EAAE,gFAAgF;aACvF,CAAC,CAAC;QACL,CAAC;QACD,OAAO,EAAE,IAAI,EAAE,OAAO,CAAC,IAAI,EAAE,KAAK,EAAE,iBAAiB,CAAC,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;IACvF,CAAC;IACD,IAAI,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC;IACrB,IAAI,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC;IACvB,IAAI,IAAI,CAAC,IAAI,EAAE,CAAC;QACd,MAAM,EAAE,GAAG,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;QACpC,IAAI,EAAE,GAAG,CAAC;YAAE,MAAM,IAAI,kBAAkB,CAAC,uCAAuC,IAAI,CAAC,IAAI,EAAE,EAAE,eAAe,CAAC,CAAC;QAC9G,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;QAC9B,KAAK,GAAG,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,OAAO,CAAC,cAAc,EAAE,EAAE,CAAC,CAAC;IAC9D,CAAC;IACD,IAAI,CAAC,IAAI,IAAI,CAAC,KAAK,EAAE,CAAC;QACpB,MAAM,IAAI,kBAAkB,CAAC,eAAe,EAAE,eAAe,EAAE,EAAE,IAAI,EAAE,0EAA0E,EAAE,CAAC,CAAC;IACvJ,CAAC;IACD,MAAM,GAAG,GAAG,cAAc,CAAC,IAAI,EAAE,gBAAgB,CAAC,aAAa,CAAC,IAAI,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC;IAC9E,OAAO,EAAE,IAAI,EAAE,GAAG,EAAE,KAAK,EAAE,iBAAiB,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC,EAAE,CAAC;AACpE,CAAC;AAED,yGAAyG;AACzG,SAAS,iBAAiB,CAAC,IAAY,EAAE,KAAa;IACpD,MAAM,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,KAAK,CAAC;IAC7C,OAAO,KAAK,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC;AACvE,CAAC;AAED,iFAAiF;AACjF,SAAS,SAAS,CAAC,OAA0B,EAAE,IAAqC;IAClF,OAAO,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,YAAY,CAAC,CAAC,EAAE,IAAI,CAAC,IAAI,YAAY,CAAC,EAAE,GAAG,CAAC,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;AACnI,CAAC;AAED,SAAS,UAAU,CAAC,OAA0B,EAAE,IAAa;IAC3D,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,IAAI,IAAI;QAAE,KAAK,CAAC,IAAI,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;IAC/B,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACzB,KAAK,CAAC,IAAI,CAAC,2BAA2B,EAAE,EAAE,EAAE,wHAAwH,CAAC,CAAC;QACtK,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC1B,CAAC;IACD,MAAM,OAAO,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC;IACpD,KAAK,CAAC,IAAI,CAAC,SAAS,OAAO,CAAC,MAAM,oBAAoB,OAAO,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,QAAQ,OAAO,CAAC,MAAM,UAAU,CAAC,CAAC,CAAC,EAAE,EAAE,EAAE,EAAE,CAAC,CAAC;IACtJ,KAAK,CAAC,IAAI,CAAC,mCAAmC,EAAE,2BAA2B,CAAC,CAAC;IAC7E,KAAK,MAAM,CAAC,IAAI,OAAO,EAAE,CAAC;QACxB,KAAK,CAAC,IAAI,CAAC,OAAO,YAAY,CAAC,CAAC,CAAC,IAAI,CAAC,QAAQ,YAAY,CAAC,CAAC,CAAC,KAAK,CAAC,MAAM,YAAY,CAAC,CAAC,CAAC,KAAK,CAAC,MAAM,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,MAAM,YAAY,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;IAC/K,CAAC;IACD,KAAK,CAAC,IAAI,CAAC,EAAE,EAAE,eAAe,eAAe,kFAAkF,CAAC,CAAC;IACjI,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAC1B,CAAC;AAED,MAAM,CAAC,MAAM,cAAc,GAAG;IAC5B,IAAI,EAAE,YAAY;IAClB,WAAW,EACT,6TAA6T;IAC/T,WAAW,EAAE,gBAAgB;IAC7B,OAAO,EAAE,KAAK,EAAE,IAAqB,EAAE,GAAgB,EAAyB,EAAE,CAChF,KAAK,CAAC,YAAY,EAAE,KAAK,IAAI,EAAE;QAC7B,MAAM,IAAI,GAAG,MAAM,kBAAkB,CAAC,IAAI,CAAC,WAAW,EAAE,GAAG,CAAC,CAAC;QAC7D,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,IAAI,MAAM,CAAC;QACrC,MAAM,IAAI,GAAG,MAAM,cAAc,CAAC,IAAI,CAAC,CAAC;QACxC,IAAI,MAAM,KAAK,MAAM,EAAE,CAAC;YACtB,OAAO,QAAQ,CAAC,UAAU,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,EAAE,MAAM,EAAE,QAAQ,EAAE,KAAK,EAAE,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;QAC9F,CAAC;QACD,IAAI,GAAG,CAAC,UAAU,EAAE,CAAC;YACnB,MAAM,IAAI,kBAAkB,CAAC,yDAAyD,EAAE,cAAc,EAAE;gBACtG,IAAI,EAAE,8EAA8E;aACrF,CAAC,CAAC;QACL,CAAC;QACD,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,EAAE,IAAI,EAAE,GAAG,CAAC,CAAC;QACrC,MAAM,QAAQ,GAAG,SAAS,CAAC,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;QAE/C,IAAI,MAAM,KAAK,QAAQ,EAAE,CAAC;YACxB,IAAI,CAAC,QAAQ,EAAE,CAAC;gBACd,OAAO,QAAQ,CAAC,UAAU,CAAC,IAAI,CAAC,OAAO,EAAE,0BAA0B,IAAI,CAAC,IAAI,QAAQ,IAAI,CAAC,KAAK,sBAAsB,CAAC,EAAE,EAAE,MAAM,EAAE,WAAW,EAAE,KAAK,EAAE,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;YAC9K,CAAC;YACD,IAAI,CAAC,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,QAAQ,CAAC,CAAC;YAC1D,MAAM,eAAe,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;YAClC,OAAO,QAAQ,CAAC,UAAU,CAAC,IAAI,CAAC,OAAO,EAAE,iBAAiB,QAAQ,CAAC,IAAI,QAAQ,QAAQ,CAAC,KAAK,kCAAkC,CAAC,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE,KAAK,EAAE,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;QACvL,CAAC;QAED,IAAI,CAAC,IAAI,CAAC,KAAK,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC;YAChC,MAAM,IAAI,kBAAkB,CAAC,4BAA4B,EAAE,eAAe,EAAE;gBAC1E,IAAI,EAAE,oHAAoH;aAC3H,CAAC,CAAC;QACL,CAAC;QACD,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,IAAI,uBAAuB,CAAC;QAClD,MAAM,KAAK,GAAoB;YAC7B,IAAI,EAAE,IAAI,CAAC,IAAI;YACf,KAAK,EAAE,IAAI,CAAC,KAAK;YACjB,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE;YACxB,MAAM,EAAE,IAAI,CAAC,MAAM,CAAC,IAAI,EAAE;YAC1B,KAAK,EAAE,OAAO,CAAC,KAAK,EAAE,EAAE,IAAI,CAAC;YAC7B,KAAK,EAAE,QAAQ,EAAE,KAAK,IAAI,KAAK,EAAE;SAClC,CAAC;QACF,IAAI,CAAC,OAAO,GAAG,CAAC,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,QAAQ,CAAC,EAAE,KAAK,CAAC,CAAC;QACtE,MAAM,eAAe,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;QAElC,0EAA0E;QAC1E,MAAM,OAAO,GAAG,MAAM,WAAW,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,EAAE,CAAC,CAAC;QAC5D,MAAM,KAAK,GAAG,SAAS,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,YAAY,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC;QACrE,MAAM,QAAQ,GAAG,KAAK;YACpB,CAAC,CAAC,sBAAsB,KAAK,CAAC,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,QAAQ,GAAG,GAAG,CAAC,iBAAiB,KAAK,CAAC,IAAI,iCAAiC;YACvI,CAAC,CAAC,EAAE,CAAC;QACP,MAAM,IAAI,GAAG,OAAO,QAAQ,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,aAAa,MAAM,KAAK,CAAC,IAAI,QAAQ,KAAK,CAAC,KAAK,UAAU,KAAK,CAAC,KAAK,WAAW,KAAK,CAAC,KAAK,yDAAyD,QAAQ,EAAE,CAAC;QAC3M,OAAO,QAAQ,CAAC,UAAU,CAAC,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,EAAE,EAAE,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,OAAO,EAAE,KAAK,EAAE,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAC3H,CAAC,CAAC;CACL,CAAC"}
@@ -2,6 +2,7 @@
2
2
  * Tool 1 — run-test: execute Playwright tests and report structured
3
3
  * results (stats, failures, diagnostics, hints).
4
4
  */
5
+ import type { QuarantineSplit } from '../utils/quarantine.js';
5
6
  import { z } from 'zod';
6
7
  import type { RunTestResult, TestFailure, ToolContext, ToolResponse } from '../types/index.js';
7
8
  declare const runTestInput: z.ZodObject<{
@@ -58,7 +59,15 @@ export interface FailureCluster {
58
59
  * tests the same way, and is fixed once.
59
60
  */
60
61
  export declare function clusterFailures(failures: TestFailure[]): FailureCluster[];
61
- export declare function renderRunResult(result: RunTestResult, root: string): string;
62
+ /**
63
+ * Did the run pass once quarantined failures are set aside? Only test
64
+ * failures can be quarantined: a crash, a timeout or a config error still
65
+ * fails the run.
66
+ */
67
+ export declare function passesWithQuarantine(result: RunTestResult, split: QuarantineSplit | undefined): boolean;
68
+ /** Split a run's failures by the project's quarantine list; undefined when there is none. */
69
+ export declare function quarantineSplit(root: string, result: RunTestResult): Promise<QuarantineSplit | undefined>;
70
+ export declare function renderRunResult(result: RunTestResult, root: string, split?: QuarantineSplit): string;
62
71
  export declare const runTestTool: {
63
72
  name: string;
64
73
  description: string;
@@ -2,10 +2,12 @@
2
2
  * Tool 1 — run-test: execute Playwright tests and report structured
3
3
  * results (stats, failures, diagnostics, hints).
4
4
  */
5
+ import { quarantineLines, readQuarantine, splitFailures } from '../utils/quarantine.js';
5
6
  import { z } from 'zod';
6
7
  import { PlaywrightMcpError } from '../types/index.js';
7
8
  import { detectProject, missingPlaywrightMessage } from '../utils/project-detector.js';
8
9
  import { runTests } from '../utils/playwright-runner.js';
10
+ import { detectPytestProject } from '../utils/pytest-runner.js';
9
11
  import { normalizePath, relativeToRoot } from '../utils/path-utils.js';
10
12
  import { recordRun } from '../utils/run-history.js';
11
13
  import { startBackgroundRun } from '../utils/run-registry.js';
@@ -113,15 +115,38 @@ function renderFailures(failures) {
113
115
  }
114
116
  return lines;
115
117
  }
116
- export function renderRunResult(result, root) {
118
+ /**
119
+ * Did the run pass once quarantined failures are set aside? Only test
120
+ * failures can be quarantined: a crash, a timeout or a config error still
121
+ * fails the run.
122
+ */
123
+ export function passesWithQuarantine(result, split) {
124
+ if (result.ok)
125
+ return true;
126
+ if (!split || split.quarantined.length === 0 || split.blocking.length > 0)
127
+ return false;
128
+ return !result.timedOut && !result.errorKind && !result.partial && !result.failuresTruncated;
129
+ }
130
+ /** Split a run's failures by the project's quarantine list; undefined when there is none. */
131
+ export async function quarantineSplit(root, result) {
132
+ if (result.failures.length === 0)
133
+ return undefined;
134
+ const list = await readQuarantine(root).catch(() => undefined);
135
+ if (!list || list.entries.length === 0)
136
+ return undefined;
137
+ return splitFailures(result.failures, list);
138
+ }
139
+ export function renderRunResult(result, root, split) {
117
140
  const flakyCount = result.stats?.flaky ?? 0;
118
141
  const status = result.ok
119
142
  ? flakyCount > 0
120
143
  ? `✅ PASSED (${flakyCount} flaky)`
121
144
  : '✅ PASSED'
122
- : result.timedOut
123
- ? '⏱️ KILLED (timeout)'
124
- : '❌ FAILED';
145
+ : passesWithQuarantine(result, split)
146
+ ? `✅ PASSED (${split.quarantined.length} quarantined failure${split.quarantined.length === 1 ? '' : 's'})`
147
+ : result.timedOut
148
+ ? '⏱️ KILLED (timeout)'
149
+ : '❌ FAILED';
125
150
  const lines = [`## Playwright run — ${status}`, ''];
126
151
  lines.push(`**Command:** \`${result.command}\``);
127
152
  const meta = [
@@ -149,6 +174,8 @@ export function renderRunResult(result, root) {
149
174
  if (flakyCount > 0) {
150
175
  lines.push(`> ⚠️ **${flakyCount} flaky test(s)** failed first but passed on the automatic retry. Re-run to confirm they are stable before trusting them.`, '');
151
176
  }
177
+ if (split)
178
+ lines.push(...quarantineLines(split));
152
179
  if (result.failures.length > 0) {
153
180
  const more = result.failuresTruncated ? ' (showing the first ones)' : '';
154
181
  lines.push(`### ❌ ${result.failures.length} failing test(s)${more}`, '');
@@ -175,7 +202,7 @@ export const runTestTool = {
175
202
  handler: async (args, ctx) => guard('run-test', async () => {
176
203
  const root = await resolveProjectRoot(args.projectRoot, ctx);
177
204
  const detection = await detectProject(root);
178
- if (!detection.hasPlaywright) {
205
+ if (!detection.hasPlaywright && !(await detectPytestProject(root))) {
179
206
  const { message, hint } = missingPlaywrightMessage(detection);
180
207
  return toolError('NO_PLAYWRIGHT', message, hint);
181
208
  }
@@ -238,7 +265,12 @@ export const runTestTool = {
238
265
  durationMs: result.durationMs,
239
266
  failures: result.failures.length,
240
267
  });
241
- return toolText(renderRunResult(result, root), { ok: result.ok, run: runData(result) });
268
+ const split = await quarantineSplit(root, result);
269
+ const passed = passesWithQuarantine(result, split);
270
+ return toolText(renderRunResult(result, root, split), {
271
+ ok: passed,
272
+ run: { ...runData(result), ...(split?.quarantined.length ? { quarantined: split.quarantined.length } : {}) },
273
+ });
242
274
  }),
243
275
  };
244
276
  //# sourceMappingURL=run-test.js.map