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.
- package/README.md +150 -24
- package/dist/cli/ci.js +3 -0
- package/dist/cli/ci.js.map +1 -1
- package/dist/cli/doctor.d.ts +12 -1
- package/dist/cli/doctor.js +34 -4
- package/dist/cli/doctor.js.map +1 -1
- package/dist/cli/guard.d.ts +11 -0
- package/dist/cli/guard.js +80 -0
- package/dist/cli/guard.js.map +1 -0
- package/dist/cli/upload.d.ts +14 -0
- package/dist/cli/upload.js +81 -0
- package/dist/cli/upload.js.map +1 -0
- package/dist/discovery.d.ts +61 -0
- package/dist/discovery.js +108 -0
- package/dist/discovery.js.map +1 -0
- package/dist/http.d.ts +10 -1
- package/dist/http.js +50 -4
- package/dist/http.js.map +1 -1
- package/dist/index.js +28 -1
- package/dist/index.js.map +1 -1
- package/dist/oauth.d.ts +2 -0
- package/dist/oauth.js +1 -1
- package/dist/oauth.js.map +1 -1
- package/dist/server.d.ts +24 -4
- package/dist/server.js +98 -15
- package/dist/server.js.map +1 -1
- package/dist/skills.d.ts +23 -0
- package/dist/skills.js +101 -0
- package/dist/skills.js.map +1 -0
- package/dist/tools/analyze-history.d.ts +15 -0
- package/dist/tools/analyze-history.js +44 -9
- package/dist/tools/analyze-history.js.map +1 -1
- package/dist/tools/audit-accessibility.d.ts +6 -0
- package/dist/tools/audit-performance.d.ts +6 -0
- package/dist/tools/check-test-change.d.ts +44 -0
- package/dist/tools/check-test-change.js +132 -0
- package/dist/tools/check-test-change.js.map +1 -0
- package/dist/tools/compare-visual-state.d.ts +6 -0
- package/dist/tools/diagnose-flaky.js +5 -1
- package/dist/tools/diagnose-flaky.js.map +1 -1
- package/dist/tools/generate-e2e-test.d.ts +3 -0
- package/dist/tools/generate-e2e-test.js +3 -0
- package/dist/tools/generate-e2e-test.js.map +1 -1
- package/dist/tools/get-failure.js +3 -1
- package/dist/tools/get-failure.js.map +1 -1
- package/dist/tools/get-run-status.js +8 -2
- package/dist/tools/get-run-status.js.map +1 -1
- package/dist/tools/inspect-page.d.ts +6 -0
- package/dist/tools/inspect-page.js +3 -1
- package/dist/tools/inspect-page.js.map +1 -1
- package/dist/tools/list-tests.js +16 -12
- package/dist/tools/list-tests.js.map +1 -1
- package/dist/tools/probe-script.js +181 -15
- package/dist/tools/probe-script.js.map +1 -1
- package/dist/tools/quarantine.d.ts +61 -0
- package/dist/tools/quarantine.js +133 -0
- package/dist/tools/quarantine.js.map +1 -0
- package/dist/tools/run-test.d.ts +10 -1
- package/dist/tools/run-test.js +38 -6
- package/dist/tools/run-test.js.map +1 -1
- package/dist/tools/save-login.d.ts +145 -0
- package/dist/tools/save-login.js +264 -0
- package/dist/tools/save-login.js.map +1 -0
- package/dist/tools/shared.d.ts +49 -2
- package/dist/tools/shared.js +57 -6
- package/dist/tools/shared.js.map +1 -1
- package/dist/tools/structured.d.ts +8 -1
- package/dist/tools/structured.js +8 -1
- package/dist/tools/structured.js.map +1 -1
- package/dist/tools/suggest-fix.js +4 -0
- package/dist/tools/suggest-fix.js.map +1 -1
- package/dist/tools/validate-selector.d.ts +6 -0
- package/dist/tools/validate-selector.js +10 -2
- package/dist/tools/validate-selector.js.map +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/utils/live-reporter.d.ts +29 -0
- package/dist/utils/live-reporter.js +145 -0
- package/dist/utils/live-reporter.js.map +1 -0
- package/dist/utils/playwright-runner.js +36 -14
- package/dist/utils/playwright-runner.js.map +1 -1
- package/dist/utils/pro-client.d.ts +29 -0
- package/dist/utils/pro-client.js +114 -0
- package/dist/utils/pro-client.js.map +1 -0
- package/dist/utils/pytest-runner.d.ts +102 -0
- package/dist/utils/pytest-runner.js +715 -0
- package/dist/utils/pytest-runner.js.map +1 -0
- package/dist/utils/quarantine.d.ts +64 -0
- package/dist/utils/quarantine.js +106 -0
- package/dist/utils/quarantine.js.map +1 -0
- package/dist/utils/test-change-guard.d.ts +90 -0
- package/dist/utils/test-change-guard.js +516 -0
- package/dist/utils/test-change-guard.js.map +1 -0
- package/dist/utils/totp.d.ts +26 -0
- package/dist/utils/totp.js +84 -0
- package/dist/utils/totp.js.map +1 -0
- package/dist/utils/update-check.d.ts +50 -0
- package/dist/utils/update-check.js +75 -0
- package/dist/utils/update-check.js.map +1 -0
- package/examples/github-actions/playwright-e2e.yml +80 -0
- package/examples/gitlab-ci/playwright-e2e.yml +71 -0
- package/package.json +2 -2
- package/skill/playwright-e2e-mcp/SKILL.md +45 -7
- package/skill/playwright-e2e-mcp/references/setup.md +12 -1
- 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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
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
|
-
|
|
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
|
|
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":"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"}
|
package/dist/tools/run-test.d.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/tools/run-test.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
123
|
-
? '
|
|
124
|
-
:
|
|
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
|
-
|
|
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
|