playwright-e2e-mcp 0.7.0 → 0.9.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 +134 -25
- 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/upload.d.ts +6 -5
- package/dist/cli/upload.js +20 -17
- package/dist/cli/upload.js.map +1 -1
- 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 +9 -0
- package/dist/http.js +49 -3
- package/dist/http.js.map +1 -1
- package/dist/index.js +8 -3
- 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 +23 -3
- package/dist/server.js +55 -10
- 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.js +9 -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/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/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 +88 -14
- package/dist/tools/probe-script.js.map +1 -1
- package/dist/tools/run-test.js +2 -1
- package/dist/tools/run-test.js.map +1 -1
- package/dist/tools/save-login.d.ts +3 -0
- package/dist/tools/shared.d.ts +25 -1
- package/dist/tools/shared.js +56 -5
- package/dist/tools/shared.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/history-client.d.ts +32 -0
- package/dist/utils/{pro-client.js → history-client.js} +27 -20
- package/dist/utils/history-client.js.map +1 -0
- 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/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/test-change-guard.d.ts +21 -0
- package/dist/utils/test-change-guard.js +128 -3
- package/dist/utils/test-change-guard.js.map +1 -1
- 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 +81 -0
- package/examples/gitlab-ci/playwright-e2e.yml +71 -0
- package/package.json +2 -2
- package/skill/playwright-e2e-mcp/SKILL.md +10 -4
- package/skill/playwright-e2e-mcp/references/setup.md +7 -2
- package/skill/playwright-e2e-mcp/references/tools.md +1 -1
- package/dist/utils/pro-client.d.ts +0 -29
- package/dist/utils/pro-client.js.map +0 -1
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Python support: run pytest-playwright suites through the same tools.
|
|
3
|
+
*
|
|
4
|
+
* A Python project has no node_modules and no playwright.config, so the
|
|
5
|
+
* Node runner cannot touch it. This module runs `python -m pytest` instead
|
|
6
|
+
* and turns its results into the same RunTestResult the Node runner
|
|
7
|
+
* returns, so run-test, get-failure (traces included), list-tests,
|
|
8
|
+
* analyze-history and the ci command work unchanged.
|
|
9
|
+
*
|
|
10
|
+
* Results come from a small pytest plugin the server writes to its temp
|
|
11
|
+
* directory and loads with `-p` (nothing is installed in the project). It
|
|
12
|
+
* appends one JSON line per test as the test finishes: node id, absolute
|
|
13
|
+
* file and line, outcome, the full failure text, the crash location and
|
|
14
|
+
* the trace files pytest-playwright kept. Because lines are written as
|
|
15
|
+
* tests end, a run that is killed still reports the tests that finished.
|
|
16
|
+
*
|
|
17
|
+
* The project's own Python is used: PW_MCP_PYTHON, else a virtualenv in
|
|
18
|
+
* the project (.venv, venv, env), else python3 / python on PATH.
|
|
19
|
+
*/
|
|
20
|
+
import { readFile, rm, stat, writeFile, mkdir } from 'node:fs/promises';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { PlaywrightMcpError } from '../types/index.js';
|
|
23
|
+
import { childEnv, runProcess } from './playwright-runner.js';
|
|
24
|
+
import { classifyFailure, emptyStats, stripAnsi } from './report-parser.js';
|
|
25
|
+
import { hasOptionLikeSegment, isAbsolutePath, isPathInside, normalizePath, relativeToRoot, tempFilePath, toNativePath, } from './path-utils.js';
|
|
26
|
+
import { logger } from './logger.js';
|
|
27
|
+
/** Environment variable naming the JSONL file the plugin appends to. */
|
|
28
|
+
export const PYTEST_RESULTS_ENV = 'PW_MCP_PYTEST_RESULTS';
|
|
29
|
+
const PLUGIN_MODULE = 'pw_mcp_pytest_results';
|
|
30
|
+
const DEFAULT_TIMEOUT_MS = 600_000;
|
|
31
|
+
const TAIL_CHARS = 8_000;
|
|
32
|
+
const MAX_FAILURES = 50;
|
|
33
|
+
/* ------------------------------------------------------------------ */
|
|
34
|
+
/* Detection */
|
|
35
|
+
/* ------------------------------------------------------------------ */
|
|
36
|
+
async function fileText(p) {
|
|
37
|
+
try {
|
|
38
|
+
return await readFile(p, 'utf8');
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async function isFile(p) {
|
|
45
|
+
try {
|
|
46
|
+
return (await stat(p)).isFile();
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Whether the folder is a pytest project, and which file says so:
|
|
54
|
+
* pytest.ini, a [tool.pytest…] table in pyproject.toml, [tool:pytest] in
|
|
55
|
+
* setup.cfg, [pytest] in tox.ini, or a conftest.py at the root or in tests/.
|
|
56
|
+
*/
|
|
57
|
+
export async function detectPytestProject(root) {
|
|
58
|
+
if (await isFile(path.join(root, 'pytest.ini')))
|
|
59
|
+
return { marker: 'pytest.ini' };
|
|
60
|
+
const pyproject = await fileText(path.join(root, 'pyproject.toml'));
|
|
61
|
+
if (pyproject !== null && /^\s*\[tool\.pytest(?:\.ini_options)?\]/m.test(pyproject))
|
|
62
|
+
return { marker: 'pyproject.toml' };
|
|
63
|
+
const setupCfg = await fileText(path.join(root, 'setup.cfg'));
|
|
64
|
+
if (setupCfg !== null && /^\s*\[tool:pytest\]/m.test(setupCfg))
|
|
65
|
+
return { marker: 'setup.cfg' };
|
|
66
|
+
const tox = await fileText(path.join(root, 'tox.ini'));
|
|
67
|
+
if (tox !== null && /^\s*\[pytest\]/m.test(tox))
|
|
68
|
+
return { marker: 'tox.ini' };
|
|
69
|
+
for (const conftest of ['conftest.py', 'tests/conftest.py']) {
|
|
70
|
+
if (await isFile(path.join(root, conftest)))
|
|
71
|
+
return { marker: conftest };
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
/** The Python to run: PW_MCP_PYTHON, a project virtualenv, or python3 / python. */
|
|
76
|
+
export async function resolvePython(root) {
|
|
77
|
+
const fromEnv = process.env.PW_MCP_PYTHON?.trim();
|
|
78
|
+
if (fromEnv)
|
|
79
|
+
return fromEnv;
|
|
80
|
+
const windows = process.platform === 'win32';
|
|
81
|
+
for (const venv of ['.venv', 'venv', 'env']) {
|
|
82
|
+
const candidate = windows ? path.join(root, venv, 'Scripts', 'python.exe') : path.join(root, venv, 'bin', 'python');
|
|
83
|
+
if (await isFile(candidate))
|
|
84
|
+
return candidate;
|
|
85
|
+
}
|
|
86
|
+
return windows ? 'python' : 'python3';
|
|
87
|
+
}
|
|
88
|
+
/* ------------------------------------------------------------------ */
|
|
89
|
+
/* The results plugin */
|
|
90
|
+
/* ------------------------------------------------------------------ */
|
|
91
|
+
/**
|
|
92
|
+
* Runs inside the project's pytest. One line per test, written at its last
|
|
93
|
+
* phase (teardown), with the first failing phase's details. Only the
|
|
94
|
+
* standard library is required; the trace lookup uses pytest-playwright's
|
|
95
|
+
* own slugify so folder names match exactly.
|
|
96
|
+
*/
|
|
97
|
+
const PLUGIN_SOURCE = `import json
|
|
98
|
+
import os
|
|
99
|
+
|
|
100
|
+
_path = os.environ.get("${PYTEST_RESULTS_ENV}")
|
|
101
|
+
_state = {}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _write(obj):
|
|
105
|
+
if not _path:
|
|
106
|
+
return
|
|
107
|
+
try:
|
|
108
|
+
with open(_path, "a", encoding="utf-8") as handle:
|
|
109
|
+
handle.write(json.dumps(obj) + "\\n")
|
|
110
|
+
except OSError:
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _trace_files(item):
|
|
115
|
+
try:
|
|
116
|
+
from slugify import slugify
|
|
117
|
+
output = item.config.getoption("--output")
|
|
118
|
+
except Exception:
|
|
119
|
+
return []
|
|
120
|
+
folder = slugify(item.nodeid)
|
|
121
|
+
retry = getattr(item, "execution_count", 1) - 1
|
|
122
|
+
if retry > 0:
|
|
123
|
+
folder += "-retry" + str(retry)
|
|
124
|
+
if len(folder) >= 256:
|
|
125
|
+
import hashlib
|
|
126
|
+
folder = folder[:100] + "-" + hashlib.sha256(folder.encode()).hexdigest()[:7] + "-" + folder[-100:]
|
|
127
|
+
base = os.path.join(os.path.abspath(output), folder)
|
|
128
|
+
try:
|
|
129
|
+
return [os.path.join(base, name) for name in sorted(os.listdir(base)) if name.startswith("trace") and name.endswith(".zip")]
|
|
130
|
+
except OSError:
|
|
131
|
+
return []
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def pytest_collection_finish(session):
|
|
135
|
+
_write({"event": "collected", "count": len(session.items), "rootdir": str(session.config.rootpath)})
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def pytest_runtest_logreport(report):
|
|
139
|
+
entry = _state.setdefault(report.nodeid, {"outcome": "passed", "duration": 0.0})
|
|
140
|
+
entry["duration"] += getattr(report, "duration", 0.0) or 0.0
|
|
141
|
+
if report.when == "call" and report.passed and hasattr(report, "wasxfail"):
|
|
142
|
+
entry["outcome"] = "xpassed"
|
|
143
|
+
if report.failed and entry["outcome"] not in ("failed", "error"):
|
|
144
|
+
entry["outcome"] = "failed" if report.when == "call" else "error"
|
|
145
|
+
entry["when"] = report.when
|
|
146
|
+
entry["longrepr"] = (report.longreprtext or "")[:12000]
|
|
147
|
+
crash = getattr(getattr(report, "longrepr", None), "reprcrash", None)
|
|
148
|
+
if crash is not None:
|
|
149
|
+
entry["crash"] = {"path": str(crash.path), "line": crash.lineno, "message": (crash.message or "")[:4000]}
|
|
150
|
+
elif report.skipped and entry["outcome"] == "passed":
|
|
151
|
+
entry["outcome"] = "xfailed" if hasattr(report, "wasxfail") else "skipped"
|
|
152
|
+
if report.longrepr and isinstance(report.longrepr, tuple) and len(report.longrepr) == 3:
|
|
153
|
+
entry["reason"] = str(report.longrepr[2])[:300]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def pytest_runtest_logfinish(nodeid, location):
|
|
157
|
+
entry = _state.pop(nodeid, None)
|
|
158
|
+
if entry is None:
|
|
159
|
+
return
|
|
160
|
+
entry["nodeid"] = nodeid
|
|
161
|
+
entry["file"] = location[0]
|
|
162
|
+
entry["line"] = location[1]
|
|
163
|
+
_write(entry)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def pytest_runtest_teardown(item, nextitem):
|
|
167
|
+
entry = _state.get(item.nodeid)
|
|
168
|
+
if entry is not None:
|
|
169
|
+
entry["path"] = str(item.path)
|
|
170
|
+
entry["traces"] = []
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def pytest_runtest_makereport(item, call):
|
|
174
|
+
if call.when == "teardown":
|
|
175
|
+
entry = _state.get(item.nodeid)
|
|
176
|
+
if entry is not None:
|
|
177
|
+
entry["traces"] = _trace_files(item)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def pytest_sessionfinish(session, exitstatus):
|
|
181
|
+
_write({"event": "finished", "exitstatus": int(exitstatus)})
|
|
182
|
+
`;
|
|
183
|
+
let pluginDir;
|
|
184
|
+
/** Folder holding the plugin module, added to PYTHONPATH for the run. */
|
|
185
|
+
export function pytestPluginDir() {
|
|
186
|
+
if (!pluginDir) {
|
|
187
|
+
const dir = tempFilePath('pw-mcp-pytest-plugin', '');
|
|
188
|
+
pluginDir = mkdir(dir, { recursive: true })
|
|
189
|
+
.then(() => writeFile(path.join(dir, `${PLUGIN_MODULE}.py`), PLUGIN_SOURCE, 'utf8'))
|
|
190
|
+
.then(() => dir, (err) => {
|
|
191
|
+
pluginDir = undefined;
|
|
192
|
+
throw err;
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
return pluginDir;
|
|
196
|
+
}
|
|
197
|
+
const BROWSERS = new Set(['chromium', 'firefox', 'webkit']);
|
|
198
|
+
/** Split a node id into file, readable title and browser project. */
|
|
199
|
+
export function describeNodeId(nodeid) {
|
|
200
|
+
const [file, ...rest] = nodeid.split('::');
|
|
201
|
+
let project;
|
|
202
|
+
const parts = rest.map((part, i) => {
|
|
203
|
+
if (i !== rest.length - 1)
|
|
204
|
+
return part;
|
|
205
|
+
const param = /^(.*)\[(.*)\]$/.exec(part);
|
|
206
|
+
if (!param)
|
|
207
|
+
return part;
|
|
208
|
+
const values = param[2].split('-');
|
|
209
|
+
if (BROWSERS.has(values[0])) {
|
|
210
|
+
project = values[0];
|
|
211
|
+
const others = values.slice(1).join('-');
|
|
212
|
+
return others ? `${param[1]}[${others}]` : param[1];
|
|
213
|
+
}
|
|
214
|
+
return part;
|
|
215
|
+
});
|
|
216
|
+
return { file, title: [path.posix.basename(file), ...parts].join(' › '), ...(project ? { project } : {}) };
|
|
217
|
+
}
|
|
218
|
+
function asLine(value) {
|
|
219
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
220
|
+
return null;
|
|
221
|
+
const entry = value;
|
|
222
|
+
if (typeof entry.nodeid !== 'string' || typeof entry.outcome !== 'string')
|
|
223
|
+
return null;
|
|
224
|
+
return entry;
|
|
225
|
+
}
|
|
226
|
+
/** Map a pytest outcome onto the server's statuses (xfail counts as skipped, xpass as failed). */
|
|
227
|
+
function statusOf(outcome, longrepr) {
|
|
228
|
+
switch (outcome) {
|
|
229
|
+
case 'passed':
|
|
230
|
+
return 'passed';
|
|
231
|
+
case 'skipped':
|
|
232
|
+
case 'xfailed':
|
|
233
|
+
return 'skipped';
|
|
234
|
+
case 'failed':
|
|
235
|
+
case 'error':
|
|
236
|
+
case 'xpassed':
|
|
237
|
+
// pytest-timeout's whole-test limit; a Playwright action timeout is a plain failure, as in Node.
|
|
238
|
+
return /^E\s+Failed: Timeout >/m.test(longrepr ?? '') ? 'timedOut' : 'failed';
|
|
239
|
+
default:
|
|
240
|
+
return 'unknown';
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* The failure's code frame: the source lines pytest prints with `>` at the
|
|
245
|
+
* failing line, up to the first `E` line that follows.
|
|
246
|
+
*/
|
|
247
|
+
export function pytestCodeFrame(longrepr) {
|
|
248
|
+
const lines = longrepr.split('\n');
|
|
249
|
+
const marker = lines.findIndex((line) => /^>\s/.test(line));
|
|
250
|
+
if (marker < 0)
|
|
251
|
+
return undefined;
|
|
252
|
+
let start = marker;
|
|
253
|
+
while (start > 0 && marker - start < 6 && lines[start - 1].trim() !== '' && !/^(?:_{3,}|E\s)/.test(lines[start - 1]))
|
|
254
|
+
start -= 1;
|
|
255
|
+
let end = marker + 1;
|
|
256
|
+
while (end < lines.length && end - marker < 8 && /^E\s/.test(lines[end]))
|
|
257
|
+
end += 1;
|
|
258
|
+
return lines.slice(start, end).join('\n');
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* The test file's own line in a traceback: pytest prints `path:LINE: Error`
|
|
262
|
+
* for each frame, and the crash itself is often inside Playwright, so take
|
|
263
|
+
* the last frame that is in the test file.
|
|
264
|
+
*/
|
|
265
|
+
export function testFileLine(longrepr, file) {
|
|
266
|
+
const want = normalizePath(file);
|
|
267
|
+
let found;
|
|
268
|
+
for (const match of longrepr.matchAll(/^(\S[^\n]*?):(\d+):(?: |$)/gm)) {
|
|
269
|
+
const at = normalizePath(match[1]);
|
|
270
|
+
if (at === want || at.endsWith(`/${want}`) || want.endsWith(`/${at}`))
|
|
271
|
+
found = Number(match[2]);
|
|
272
|
+
}
|
|
273
|
+
return found;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* The traceback without the frames inside Playwright and other libraries:
|
|
277
|
+
* pytest separates frames with \`_ _ _\` lines; keep the test's own frames
|
|
278
|
+
* (those that mention the test file) and the final \`E\` lines.
|
|
279
|
+
*/
|
|
280
|
+
export function shortTraceback(longrepr, max = 6_000) {
|
|
281
|
+
const chunks = longrepr.split(/^(?:_ ){10,}_?\s*$/m);
|
|
282
|
+
if (chunks.length === 1)
|
|
283
|
+
return longrepr.slice(0, max);
|
|
284
|
+
const own = chunks.filter((chunk, i) => i === 0 || /^\S[^\n]*\.py:\d+:(?: |$)/m.test(chunk) && !/site-packages|dist-packages/.test(chunk.match(/^(\S[^\n]*?\.py):\d+:(?: |$)/m)?.[1] ?? ''));
|
|
285
|
+
const errors = chunks.at(-1).split('\n').filter((l) => /^E\s/.test(l) || /^\S[^\n]*\.py:\d+:(?: |$)/.test(l));
|
|
286
|
+
const dropped = chunks.length - own.length;
|
|
287
|
+
return [...own.map((c) => c.trim()), dropped > 0 ? `… ${dropped} frame${dropped === 1 ? '' : 's'} inside libraries …` : '', ...errors]
|
|
288
|
+
.filter(Boolean)
|
|
289
|
+
.join('\n')
|
|
290
|
+
.slice(0, max);
|
|
291
|
+
}
|
|
292
|
+
/** Expected / actual values from a Playwright Python assertion message. */
|
|
293
|
+
export function expectedActual(message) {
|
|
294
|
+
const expected = /^\s*Expected (?:value|pattern|string|substring)?:?\s*(.+)$/im.exec(message)?.[1]?.trim() ??
|
|
295
|
+
/expected to (?:have|be|contain) [\w ]+? '(.*)'$/im.exec(message)?.[1];
|
|
296
|
+
const actual = /^\s*(?:Actual value|Received(?: string| value)?):\s*(.+)$/im.exec(message)?.[1]?.trim();
|
|
297
|
+
const fromAssert = /assert (.+?) == (.+)$/m.exec(message);
|
|
298
|
+
return {
|
|
299
|
+
...(expected ? { expected } : fromAssert ? { expected: fromAssert[2].trim() } : {}),
|
|
300
|
+
...(actual ? { actual } : fromAssert ? { actual: fromAssert[1].trim() } : {}),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
/** The failure's own message: pytest's crash message, else the E lines of the report. */
|
|
304
|
+
function failureMessage(line) {
|
|
305
|
+
const crash = line.crash?.message?.trim();
|
|
306
|
+
if (crash)
|
|
307
|
+
return stripAnsi(crash);
|
|
308
|
+
const eLines = (line.longrepr ?? '')
|
|
309
|
+
.split('\n')
|
|
310
|
+
.filter((l) => /^E\s/.test(l))
|
|
311
|
+
.map((l) => l.replace(/^E\s{0,3}/, ''));
|
|
312
|
+
if (eLines.length > 0)
|
|
313
|
+
return stripAnsi(eLines.join('\n')).trim();
|
|
314
|
+
const last = (line.longrepr ?? '').trim().split('\n').at(-1);
|
|
315
|
+
return stripAnsi(last ?? '') || (line.when && line.when !== 'call' ? `Error in test ${line.when}.` : 'Test failed.');
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Turn the plugin's JSONL into failures, outcomes and stats. Paths become
|
|
319
|
+
* project-relative; malformed lines (a write cut off by a kill) are skipped.
|
|
320
|
+
*/
|
|
321
|
+
export function parsePytestResults(raw, root, maxFailures = MAX_FAILURES) {
|
|
322
|
+
const localRoot = normalizePath(root);
|
|
323
|
+
const stats = emptyStats();
|
|
324
|
+
const failures = [];
|
|
325
|
+
const outcomes = [];
|
|
326
|
+
const tests = [];
|
|
327
|
+
let collected;
|
|
328
|
+
let exitStatus;
|
|
329
|
+
let rootdir = localRoot;
|
|
330
|
+
let failing = 0;
|
|
331
|
+
for (const text of raw.split('\n')) {
|
|
332
|
+
if (text.trim() === '')
|
|
333
|
+
continue;
|
|
334
|
+
let parsed;
|
|
335
|
+
try {
|
|
336
|
+
parsed = JSON.parse(text);
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
const event = parsed;
|
|
342
|
+
if (event.event === 'collected') {
|
|
343
|
+
if (typeof event.count === 'number')
|
|
344
|
+
collected = event.count;
|
|
345
|
+
if (typeof event.rootdir === 'string')
|
|
346
|
+
rootdir = normalizePath(event.rootdir);
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
if (event.event === 'finished') {
|
|
350
|
+
if (typeof event.exitstatus === 'number')
|
|
351
|
+
exitStatus = event.exitstatus;
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
const line = asLine(parsed);
|
|
355
|
+
if (!line)
|
|
356
|
+
continue;
|
|
357
|
+
const described = describeNodeId(line.nodeid);
|
|
358
|
+
const absolute = line.path
|
|
359
|
+
? normalizePath(line.path)
|
|
360
|
+
: normalizePath(path.join(rootdir, line.file ?? described.file));
|
|
361
|
+
const file = isPathInside(absolute, localRoot) ? relativeToRoot(localRoot, absolute) : normalizePath(line.file ?? described.file);
|
|
362
|
+
const lineNo = typeof line.line === 'number' ? line.line + 1 : undefined;
|
|
363
|
+
const status = statusOf(line.outcome, line.longrepr);
|
|
364
|
+
const durationMs = typeof line.duration === 'number' ? Math.round(line.duration * 1000) : undefined;
|
|
365
|
+
stats.duration += durationMs ?? 0;
|
|
366
|
+
tests.push({ file, title: described.title, ...(lineNo ? { line: lineNo } : {}), ...(described.project ? { projects: [described.project] } : {}) });
|
|
367
|
+
const outcome = { file, ...(lineNo ? { line: lineNo } : {}), title: described.title, ...(described.project ? { project: described.project } : {}), status, ...(durationMs !== undefined ? { durationMs } : {}) };
|
|
368
|
+
if (status === 'passed')
|
|
369
|
+
stats.expected += 1;
|
|
370
|
+
else if (status === 'skipped')
|
|
371
|
+
stats.skipped += 1;
|
|
372
|
+
else if (status === 'failed' || status === 'timedOut') {
|
|
373
|
+
stats.unexpected += 1;
|
|
374
|
+
failing += 1;
|
|
375
|
+
const message = line.outcome === 'xpassed' ? 'Test was expected to fail (xfail strict) but passed.' : failureMessage(line);
|
|
376
|
+
const longrepr = stripAnsi(line.longrepr ?? '');
|
|
377
|
+
const failureKind = classifyFailure(`${message}\n${longrepr}`, status);
|
|
378
|
+
outcome.message = (message.split('\n')[0] ?? '').slice(0, 200) || undefined;
|
|
379
|
+
outcome.failureKind = failureKind;
|
|
380
|
+
if (failures.length < maxFailures) {
|
|
381
|
+
// pytest's crash location is the innermost frame; prefer the test file's own line.
|
|
382
|
+
const crashPath = line.crash?.path ? normalizePath(line.crash.path) : undefined;
|
|
383
|
+
const crashLine = crashPath && crashPath === absolute && typeof line.crash?.line === 'number' ? line.crash.line : testFileLine(longrepr, line.file ?? described.file);
|
|
384
|
+
const trace = (line.traces ?? []).map((t) => normalizePath(t)).find((t) => isAbsolutePath(t));
|
|
385
|
+
failures.push({
|
|
386
|
+
file,
|
|
387
|
+
line: crashLine ?? lineNo,
|
|
388
|
+
title: described.title,
|
|
389
|
+
...(described.project ? { project: described.project } : {}),
|
|
390
|
+
status,
|
|
391
|
+
failureKind,
|
|
392
|
+
message,
|
|
393
|
+
stack: shortTraceback(longrepr) || undefined,
|
|
394
|
+
codeframe: pytestCodeFrame(longrepr),
|
|
395
|
+
...expectedActual(message),
|
|
396
|
+
...(durationMs !== undefined ? { durationMs } : {}),
|
|
397
|
+
...(trace ? { tracePath: trace } : {}),
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
outcomes.push(outcome);
|
|
402
|
+
}
|
|
403
|
+
return { stats, failures, failuresTruncated: failing > failures.length, outcomes, tests, collected, exitStatus };
|
|
404
|
+
}
|
|
405
|
+
/* ------------------------------------------------------------------ */
|
|
406
|
+
/* Targets */
|
|
407
|
+
/* ------------------------------------------------------------------ */
|
|
408
|
+
/**
|
|
409
|
+
* Turn `file.py:LINE` into the node id of the test defined at or above that
|
|
410
|
+
* line (`file.py::TestCart::test_add`), so get-failure's re-run hints work
|
|
411
|
+
* for pytest too. `file.py::name` and plain paths pass through.
|
|
412
|
+
*/
|
|
413
|
+
export function nodeIdForLine(relativeFile, source, line) {
|
|
414
|
+
const lines = source.split('\n');
|
|
415
|
+
for (let i = Math.min(line, lines.length) - 1; i >= 0; i -= 1) {
|
|
416
|
+
const def = /^(\s*)(?:async\s+)?def\s+(test\w*)\s*\(/.exec(lines[i]);
|
|
417
|
+
if (!def)
|
|
418
|
+
continue;
|
|
419
|
+
const indent = def[1].length;
|
|
420
|
+
if (indent === 0)
|
|
421
|
+
return `${relativeFile}::${def[2]}`;
|
|
422
|
+
for (let j = i - 1; j >= 0; j -= 1) {
|
|
423
|
+
const cls = /^(\s*)class\s+(\w+)/.exec(lines[j]);
|
|
424
|
+
if (cls && cls[1].length < indent)
|
|
425
|
+
return `${relativeFile}::${cls[2]}::${def[2]}`;
|
|
426
|
+
}
|
|
427
|
+
return `${relativeFile}::${def[2]}`;
|
|
428
|
+
}
|
|
429
|
+
return relativeFile;
|
|
430
|
+
}
|
|
431
|
+
async function pytestTarget(file, root) {
|
|
432
|
+
const absolute = normalizePath(isAbsolutePath(file) ? file : path.join(root, file));
|
|
433
|
+
const nodeSuffix = absolute.indexOf('::');
|
|
434
|
+
const [base, suffix] = nodeSuffix >= 0 ? [absolute.slice(0, nodeSuffix), absolute.slice(nodeSuffix)] : [absolute, ''];
|
|
435
|
+
const lineMatch = suffix === '' ? /^(.*\.py):(\d+)(?::\d+)?$/.exec(base) : null;
|
|
436
|
+
const filePath = lineMatch ? lineMatch[1] : base;
|
|
437
|
+
if (!isPathInside(filePath, root)) {
|
|
438
|
+
throw new PlaywrightMcpError(`Test path "${file}" is outside the project root`, 'INVALID_PATH', {
|
|
439
|
+
hint: `Only test paths inside ${root} are allowed.`,
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
const relative = relativeToRoot(root, filePath);
|
|
443
|
+
if (hasOptionLikeSegment(relative) || /^-/.test(suffix.replace(/^::/, ''))) {
|
|
444
|
+
throw new PlaywrightMcpError(`Test path "${file}" has a segment starting with "-"`, 'INVALID_PATH', {
|
|
445
|
+
hint: 'Rename the file or folder so no path segment starts with "-".',
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
if (lineMatch) {
|
|
449
|
+
const source = await fileText(filePath);
|
|
450
|
+
return source === null ? relative : nodeIdForLine(relative, source, Number(lineMatch[2]));
|
|
451
|
+
}
|
|
452
|
+
return `${relative}${suffix}`;
|
|
453
|
+
}
|
|
454
|
+
/** Flags the caller may pass through `args` to pytest. */
|
|
455
|
+
const ALLOWED_ARGS = [
|
|
456
|
+
/^-x$/,
|
|
457
|
+
/^--exitfirst$/,
|
|
458
|
+
/^--maxfail=\d{1,4}$/,
|
|
459
|
+
/^--headed$/,
|
|
460
|
+
/^--slowmo=\d{1,6}$/,
|
|
461
|
+
/^--browser-channel=(?:chrome|chrome-beta|msedge|msedge-beta)$/,
|
|
462
|
+
/^--device=[\w .()+-]{2,60}$/,
|
|
463
|
+
/^-m=[\w ()]{1,200}$/,
|
|
464
|
+
/^--reruns=\d{1,2}$/,
|
|
465
|
+
/^-n=(?:auto|\d{1,3})$/,
|
|
466
|
+
];
|
|
467
|
+
function checkedArgs(extra) {
|
|
468
|
+
const out = [];
|
|
469
|
+
for (const arg of extra ?? []) {
|
|
470
|
+
if (!ALLOWED_ARGS.some((re) => re.test(arg))) {
|
|
471
|
+
throw new PlaywrightMcpError(`pytest argument "${arg}" is not allowed`, 'INVALID_INPUT', {
|
|
472
|
+
hint: 'Allowed: -x, --maxfail=N, --headed, --slowmo=MS, --browser-channel=…, --device=…, -m=EXPR, --reruns=N (pytest-rerunfailures), -n=N (pytest-xdist).',
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
// pytest's -m and -n take a separate value.
|
|
476
|
+
if (arg.startsWith('-m=') || arg.startsWith('-n='))
|
|
477
|
+
out.push(arg.slice(0, 2), arg.slice(3));
|
|
478
|
+
else
|
|
479
|
+
out.push(arg);
|
|
480
|
+
}
|
|
481
|
+
return out;
|
|
482
|
+
}
|
|
483
|
+
/* ------------------------------------------------------------------ */
|
|
484
|
+
/* Running */
|
|
485
|
+
/* ------------------------------------------------------------------ */
|
|
486
|
+
function tail(text, limit = TAIL_CHARS) {
|
|
487
|
+
return text.length <= limit ? text : `…${text.slice(-limit)}`;
|
|
488
|
+
}
|
|
489
|
+
/** Run-level problems pytest reports on stderr/stdout instead of per test. */
|
|
490
|
+
export function diagnosePytestOutput(text, exitCode) {
|
|
491
|
+
if (/No module named pytest\b/.test(text)) {
|
|
492
|
+
return { errorKind: 'NO_PLAYWRIGHT', errorMessage: 'pytest is not installed for this Python.', hint: 'Run: pip install pytest-playwright && playwright install chromium (or set PW_MCP_PYTHON to the Python of your virtualenv).' };
|
|
493
|
+
}
|
|
494
|
+
if (/unrecognized arguments: .*--(?:tracing|browser|output)\b/.test(text)) {
|
|
495
|
+
return { errorKind: 'NO_PLAYWRIGHT', errorMessage: 'pytest-playwright is not installed for this Python.', hint: 'Run: pip install pytest-playwright && playwright install chromium.' };
|
|
496
|
+
}
|
|
497
|
+
if (/Executable doesn't exist at|playwright install/.test(text) && /BrowserType\.launch/.test(text)) {
|
|
498
|
+
return { errorKind: 'NO_PLAYWRIGHT', errorMessage: 'The Playwright browsers are not installed for this Python.', hint: 'Run: playwright install chromium (inside the same virtualenv).' };
|
|
499
|
+
}
|
|
500
|
+
if (exitCode === 4) {
|
|
501
|
+
const usage = /error: (.+)/.exec(text)?.[1] ?? 'pytest rejected the command line.';
|
|
502
|
+
return { errorKind: 'INVALID_INPUT', errorMessage: usage, hint: 'Check the test paths and the args you passed.' };
|
|
503
|
+
}
|
|
504
|
+
if (exitCode === 5) {
|
|
505
|
+
return { errorKind: 'INVALID_PATH', errorMessage: 'pytest collected no tests.', hint: 'Check the test paths (and grep, which becomes pytest -k). Test files must match test_*.py or *_test.py.' };
|
|
506
|
+
}
|
|
507
|
+
if (/^E\s+(?:SyntaxError|IndentationError)|ERROR collecting/m.test(text)) {
|
|
508
|
+
return { errorKind: 'SYNTAX_ERROR', errorMessage: 'pytest could not import a test file.', hint: 'See the collection error in the output below and fix the import or syntax error.' };
|
|
509
|
+
}
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
function browserArg(browser, project) {
|
|
513
|
+
const wanted = project ?? browser;
|
|
514
|
+
if (!wanted)
|
|
515
|
+
return [];
|
|
516
|
+
if (!BROWSERS.has(wanted)) {
|
|
517
|
+
throw new PlaywrightMcpError(`"${wanted}" is not a pytest-playwright browser`, 'INVALID_INPUT', { hint: 'Use chromium, firefox or webkit.' });
|
|
518
|
+
}
|
|
519
|
+
return ['--browser', wanted];
|
|
520
|
+
}
|
|
521
|
+
/** Run a pytest-playwright suite and return the same result shape as the Node runner. */
|
|
522
|
+
export async function runPytest(options) {
|
|
523
|
+
const root = normalizePath(options.projectRoot);
|
|
524
|
+
if (options.signal?.aborted) {
|
|
525
|
+
throw new PlaywrightMcpError('Request cancelled before the run started', 'CLIENT_DISCONNECT', { hint: 'The MCP client disconnected.' });
|
|
526
|
+
}
|
|
527
|
+
for (const [name, value] of [['repeatEach', options.repeatEach], ['shuffleSeed', options.shuffleSeed], ['fullyParallel', options.fullyParallel]]) {
|
|
528
|
+
if (value !== undefined && value !== false && !(name === 'repeatEach' && value === 1)) {
|
|
529
|
+
throw new PlaywrightMcpError(`${name} is not supported for pytest projects yet`, 'INVALID_INPUT', {
|
|
530
|
+
hint: 'Repeat runs with pytest-repeat (`--count`) yourself, or re-run the test with run-test a few times.',
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
const python = await resolvePython(root);
|
|
535
|
+
const targets = [];
|
|
536
|
+
for (const file of options.testFiles ?? [])
|
|
537
|
+
targets.push(await pytestTarget(file, root));
|
|
538
|
+
const resultsPath = tempFilePath('pw-pytest', '.jsonl');
|
|
539
|
+
const args = ['-m', 'pytest', '-p', PLUGIN_MODULE, '-q', '-rN', '--color=no'];
|
|
540
|
+
args.push('--tracing', 'retain-on-failure');
|
|
541
|
+
args.push(...browserArg(options.browser, options.project));
|
|
542
|
+
if (options.headed)
|
|
543
|
+
args.push('--headed');
|
|
544
|
+
if (options.grep) {
|
|
545
|
+
if (/[\x00-\x1f]/.test(options.grep) || options.grep.length > 500) {
|
|
546
|
+
throw new PlaywrightMcpError('grep is not a valid pytest -k expression', 'INVALID_INPUT');
|
|
547
|
+
}
|
|
548
|
+
args.push('-k', options.grep);
|
|
549
|
+
}
|
|
550
|
+
if (options.lastFailed)
|
|
551
|
+
args.push('--last-failed');
|
|
552
|
+
const retries = options.retries ?? 0;
|
|
553
|
+
if (retries > 0)
|
|
554
|
+
args.push(`--reruns=${Math.min(retries, 10)}`);
|
|
555
|
+
args.push(...checkedArgs(options.extraArgs));
|
|
556
|
+
args.push('--', ...targets);
|
|
557
|
+
const plugin = await pytestPluginDir();
|
|
558
|
+
const pythonPath = [toNativePath(plugin), process.env.PYTHONPATH].filter(Boolean).join(path.delimiter);
|
|
559
|
+
const env = childEnv({
|
|
560
|
+
[PYTEST_RESULTS_ENV]: toNativePath(resultsPath),
|
|
561
|
+
PYTHONPATH: pythonPath,
|
|
562
|
+
PYTHONDONTWRITEBYTECODE: '1',
|
|
563
|
+
PYTHONUNBUFFERED: '1',
|
|
564
|
+
PY_COLORS: '0',
|
|
565
|
+
});
|
|
566
|
+
const timeoutMs = Math.min(Math.max(options.timeoutMs ?? DEFAULT_TIMEOUT_MS, 1_000), 3_600_000);
|
|
567
|
+
const startedAt = Date.now();
|
|
568
|
+
logger.debug('Starting pytest run', { python, args: args.join(' '), cwd: root });
|
|
569
|
+
const outcome = await runProcess(python, args, { cwd: root, env, timeoutMs, signal: options.signal });
|
|
570
|
+
const durationMs = Date.now() - startedAt;
|
|
571
|
+
const raw = (await fileText(resultsPath)) ?? '';
|
|
572
|
+
await rm(resultsPath, { force: true }).catch(() => undefined);
|
|
573
|
+
const parsed = parsePytestResults(raw, root);
|
|
574
|
+
const finished = parsed.exitStatus !== undefined;
|
|
575
|
+
const combined = `${outcome.stderr}\n${outcome.stdout}`;
|
|
576
|
+
let errorKind;
|
|
577
|
+
let errorMessage;
|
|
578
|
+
let hint;
|
|
579
|
+
if (outcome.spawnError) {
|
|
580
|
+
errorKind = outcome.spawnError.code === 'ENOENT' ? 'NO_PLAYWRIGHT' : 'SPAWN_FAILED';
|
|
581
|
+
errorMessage = outcome.spawnError.code === 'ENOENT' ? `Python was not found (${python}).` : outcome.spawnError.message;
|
|
582
|
+
hint = 'Install Python 3 with pytest-playwright, or set PW_MCP_PYTHON to the Python of your virtualenv.';
|
|
583
|
+
}
|
|
584
|
+
else if (outcome.aborted) {
|
|
585
|
+
errorKind = 'CLIENT_DISCONNECT';
|
|
586
|
+
errorMessage = 'Run aborted because the MCP client disconnected.';
|
|
587
|
+
hint = 'Child processes and browsers were terminated.';
|
|
588
|
+
}
|
|
589
|
+
else if (outcome.timedOut) {
|
|
590
|
+
errorKind = 'TIMEOUT';
|
|
591
|
+
errorMessage = `Run exceeded the ${timeoutMs}ms limit and was killed.`;
|
|
592
|
+
hint = 'The tests that finished are shown below. Raise timeoutMs or run fewer tests.';
|
|
593
|
+
}
|
|
594
|
+
else {
|
|
595
|
+
const diagnosis = parsed.failures.length === 0 || !finished ? diagnosePytestOutput(combined, outcome.code) : null;
|
|
596
|
+
if (diagnosis)
|
|
597
|
+
({ errorKind, errorMessage, hint } = diagnosis);
|
|
598
|
+
else if (!finished && outcome.code !== 0) {
|
|
599
|
+
errorKind = 'REPORT_MISSING';
|
|
600
|
+
errorMessage = 'pytest stopped before the session finished.';
|
|
601
|
+
hint = 'The tests that finished are shown below; see the output for why pytest stopped.';
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
const ok = outcome.code === 0 && !outcome.timedOut && !outcome.aborted && !outcome.spawnError;
|
|
605
|
+
return {
|
|
606
|
+
ok,
|
|
607
|
+
exitCode: outcome.code,
|
|
608
|
+
signalName: outcome.signalName,
|
|
609
|
+
durationMs,
|
|
610
|
+
timedOut: outcome.timedOut,
|
|
611
|
+
killed: outcome.timedOut || outcome.aborted,
|
|
612
|
+
partial: !finished,
|
|
613
|
+
stats: parsed.collected !== undefined || parsed.outcomes.length > 0 ? { ...parsed.stats, duration: durationMs } : null,
|
|
614
|
+
outcomes: parsed.outcomes,
|
|
615
|
+
failures: parsed.failures,
|
|
616
|
+
failuresTruncated: parsed.failuresTruncated,
|
|
617
|
+
stdoutTail: tail(outcome.stdout),
|
|
618
|
+
stderrTail: tail(outcome.stderr),
|
|
619
|
+
truncatedOutput: outcome.stdout.length + outcome.stderr.length > TAIL_CHARS,
|
|
620
|
+
// The command a person would type: without the server's own plugin.
|
|
621
|
+
command: ['python', ...args.filter((a, i) => !(a === '-p' && args[i + 1] === PLUGIN_MODULE) && a !== PLUGIN_MODULE && !(a === '--' && targets.length === 0))].join(' '),
|
|
622
|
+
lastFailed: options.lastFailed === true,
|
|
623
|
+
errorKind,
|
|
624
|
+
errorMessage,
|
|
625
|
+
hint,
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
/* ------------------------------------------------------------------ */
|
|
629
|
+
/* Listing */
|
|
630
|
+
/* ------------------------------------------------------------------ */
|
|
631
|
+
const TEST_FILE = /^(?:test_.*|.*_test)\.py$/;
|
|
632
|
+
async function scanPythonTests(root, dir, out, depth = 0) {
|
|
633
|
+
if (depth > 8 || out.length > 5_000)
|
|
634
|
+
return;
|
|
635
|
+
const { readdir } = await import('node:fs/promises');
|
|
636
|
+
let entries;
|
|
637
|
+
try {
|
|
638
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
639
|
+
}
|
|
640
|
+
catch {
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
for (const entry of entries) {
|
|
644
|
+
if (entry.name.startsWith('.') || ['node_modules', '__pycache__', 'venv', 'env', 'site-packages', 'test-results'].includes(entry.name))
|
|
645
|
+
continue;
|
|
646
|
+
const full = path.join(dir, entry.name);
|
|
647
|
+
if (entry.isDirectory())
|
|
648
|
+
await scanPythonTests(root, full, out, depth + 1);
|
|
649
|
+
else if (entry.isFile() && TEST_FILE.test(entry.name)) {
|
|
650
|
+
const source = await fileText(full);
|
|
651
|
+
if (source === null)
|
|
652
|
+
continue;
|
|
653
|
+
const file = relativeToRoot(root, normalizePath(full));
|
|
654
|
+
const lines = source.split('\n');
|
|
655
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
656
|
+
if (/^\s*(?:async\s+)?def\s+test\w*\s*\(/.test(lines[i])) {
|
|
657
|
+
const id = nodeIdForLine(file, source, i + 1);
|
|
658
|
+
out.push({ file, line: i + 1, title: describeNodeId(id).title });
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* List a pytest project's tests: `pytest --collect-only -q` when allowed to
|
|
666
|
+
* run project code, a scan of test_*.py / *_test.py files otherwise (or
|
|
667
|
+
* when collection fails, with the reason).
|
|
668
|
+
*/
|
|
669
|
+
export async function listPytestTests(options) {
|
|
670
|
+
const root = normalizePath(options.projectRoot);
|
|
671
|
+
const limit = Math.min(Math.max(options.limit ?? 200, 1), 1_000);
|
|
672
|
+
let tests = [];
|
|
673
|
+
let source = 'file-scan';
|
|
674
|
+
let error;
|
|
675
|
+
if (!options.noSpawn) {
|
|
676
|
+
const python = await resolvePython(root);
|
|
677
|
+
const outcome = await runProcess(python, ['-m', 'pytest', '--collect-only', '-q', '--color=no', '-p', 'no:cacheprovider'], {
|
|
678
|
+
cwd: root,
|
|
679
|
+
env: childEnv({ PYTHONDONTWRITEBYTECODE: '1', PY_COLORS: '0' }),
|
|
680
|
+
timeoutMs: 120_000,
|
|
681
|
+
signal: options.signal,
|
|
682
|
+
});
|
|
683
|
+
const ids = outcome.stdout.split('\n').map((l) => l.trim()).filter((l) => l.includes('::') && !l.startsWith('<'));
|
|
684
|
+
if (outcome.code === 0 && ids.length > 0) {
|
|
685
|
+
source = 'pytest-collect';
|
|
686
|
+
const seen = new Map();
|
|
687
|
+
for (const id of ids) {
|
|
688
|
+
const d = describeNodeId(id);
|
|
689
|
+
const key = `${d.file}\u0000${d.title}`;
|
|
690
|
+
const known = seen.get(key);
|
|
691
|
+
if (known) {
|
|
692
|
+
if (d.project && !known.projects?.includes(d.project))
|
|
693
|
+
known.projects = [...(known.projects ?? []), d.project];
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
seen.set(key, { file: d.file, title: d.title, ...(d.project ? { projects: [d.project] } : {}) });
|
|
697
|
+
}
|
|
698
|
+
tests = [...seen.values()];
|
|
699
|
+
}
|
|
700
|
+
else {
|
|
701
|
+
const diagnosis = diagnosePytestOutput(`${outcome.stderr}\n${outcome.stdout}`, outcome.code);
|
|
702
|
+
error = diagnosis
|
|
703
|
+
? { kind: diagnosis.errorKind, message: diagnosis.errorMessage, hint: diagnosis.hint }
|
|
704
|
+
: { kind: outcome.spawnError ? 'NO_PLAYWRIGHT' : 'UNKNOWN', message: outcome.spawnError ? `Python was not found (${python}).` : 'pytest --collect-only failed.', hint: 'Set PW_MCP_PYTHON to the Python of your virtualenv.' };
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
if (source === 'file-scan')
|
|
708
|
+
await scanPythonTests(root, root, tests);
|
|
709
|
+
if (options.filter) {
|
|
710
|
+
const needle = options.filter.toLowerCase();
|
|
711
|
+
tests = tests.filter((t) => `${t.file} › ${t.title}`.toLowerCase().includes(needle));
|
|
712
|
+
}
|
|
713
|
+
return { tests: tests.slice(0, limit), total: tests.length, truncated: tests.length > limit, testDir: null, source, ...(error ? { error } : {}) };
|
|
714
|
+
}
|
|
715
|
+
//# sourceMappingURL=pytest-runner.js.map
|