canary-test-cli 6.6.0 → 6.7.1
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/bin/canary.js +69 -1
- package/dist/doctor-manifest.js +6 -1
- package/dist/doctor.js +7 -4
- package/dist/engine/cli-commands.js +84 -25
- package/dist/engine/cli.core.js +1 -1
- package/dist/engine/core/framework-probes.js +218 -0
- package/dist/engine/core/fs-glob.js +185 -0
- package/dist/engine/core/gate-result.js +27 -4
- package/dist/engine/core/migrator.js +240 -289
- package/dist/engine/core/static-linter.js +44 -4
- package/dist/engine/core/workspace-detect.js +0 -0
- package/dist/engine/guardian/adjudication.js +34 -30
- package/dist/engine/guardian/analysis-emit.js +13 -4
- package/dist/engine/guardian/cli.js +113 -16
- package/dist/engine/guardian/coverage.js +291 -9
- package/dist/engine/guardian/github-paging.js +97 -0
- package/dist/engine/guardian/pr-check.js +285 -26
- package/dist/engine/guardian/pr-comment.js +29 -15
- package/dist/gate-result.js +27 -4
- package/dist/overlay-commands.js +31 -3
- package/dist/reporters/testtracker.d.ts +18 -1
- package/dist/reporters/testtracker.js +59 -1
- package/package.json +2 -2
package/bin/canary.js
CHANGED
|
@@ -1,6 +1,68 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Node-floor guard (#559). Everything in this block runs BEFORE the bundled
|
|
6
|
+
// engine is required, and deliberately so.
|
|
7
|
+
//
|
|
8
|
+
// `engines` is advisory: npm prints `warn EBADENGINE` and installs anyway
|
|
9
|
+
// (verified — it errors only under `engine-strict=true`, which almost nobody
|
|
10
|
+
// sets). So on an unsupported Node the user reaches this file, and the require
|
|
11
|
+
// of `../dist/router.js` below can throw a bare SyntaxError from engine code
|
|
12
|
+
// compiled for a newer runtime. A guard placed after that require would only
|
|
13
|
+
// ever run on versions that did not need it.
|
|
14
|
+
//
|
|
15
|
+
// The floor is read from `engines.node` rather than hardcoded, so the manifest
|
|
16
|
+
// stays the single source of truth — see ts/test/node-engines-floor.test.ts,
|
|
17
|
+
// which holds the manifest, the README badge, and the README prose together.
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/** Floor major from this package's own `engines.node` (e.g. `>=22` -> 22). */
|
|
21
|
+
function readMinNodeMajor() {
|
|
22
|
+
var declared = (require('../package.json').engines || {}).node || '';
|
|
23
|
+
var match = /^>=\s*(\d+)/.exec(String(declared).trim());
|
|
24
|
+
return match ? Number(match[1]) : 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
var MIN_NODE_MAJOR = readMinNodeMajor();
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Returns an error message when `version` is below `minMajor`, else null.
|
|
31
|
+
*
|
|
32
|
+
* Abstains (returns null) on a version string it cannot parse: a false block
|
|
33
|
+
* would be worse than letting an odd runtime through to whatever the real
|
|
34
|
+
* error turns out to be.
|
|
35
|
+
*/
|
|
36
|
+
function checkNodeFloor(version, minMajor) {
|
|
37
|
+
var match = /^v?(\d+)\./.exec(String(version));
|
|
38
|
+
if (!match || !minMajor) return null;
|
|
39
|
+
var major = Number(match[1]);
|
|
40
|
+
if (major >= minMajor) return null;
|
|
41
|
+
return (
|
|
42
|
+
'canary requires Node ' +
|
|
43
|
+
minMajor +
|
|
44
|
+
' or newer, but this is Node ' +
|
|
45
|
+
version +
|
|
46
|
+
'.\n' +
|
|
47
|
+
'The bundled engine is compiled for Node ' +
|
|
48
|
+
minMajor +
|
|
49
|
+
', so older runtimes fail in ways that look\n' +
|
|
50
|
+
'like canary bugs. npm only warns about this (EBADENGINE), it does not ' +
|
|
51
|
+
'stop the install.\n' +
|
|
52
|
+
'Upgrade with nvm, volta, or mise — e.g. `nvm install ' +
|
|
53
|
+
minMajor +
|
|
54
|
+
'` — then re-run canary.\n'
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (require.main === module) {
|
|
59
|
+
var floorError = checkNodeFloor(process.versions.node, MIN_NODE_MAJOR);
|
|
60
|
+
if (floorError) {
|
|
61
|
+
process.stderr.write(floorError);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
4
66
|
const { execFileSync } = require('node:child_process');
|
|
5
67
|
const path = require('node:path');
|
|
6
68
|
const fs = require('node:fs');
|
|
@@ -65,5 +127,11 @@ function main() {
|
|
|
65
127
|
);
|
|
66
128
|
}
|
|
67
129
|
|
|
68
|
-
module.exports = {
|
|
130
|
+
module.exports = {
|
|
131
|
+
getEnginePath,
|
|
132
|
+
forwardToEngine,
|
|
133
|
+
run,
|
|
134
|
+
checkNodeFloor,
|
|
135
|
+
MIN_NODE_MAJOR,
|
|
136
|
+
};
|
|
69
137
|
if (require.main === module) main();
|
package/dist/doctor-manifest.js
CHANGED
|
@@ -308,7 +308,12 @@ function skipped(check, reason) {
|
|
|
308
308
|
}
|
|
309
309
|
function runCommandSucceeds(check, ctx, timeoutMs) {
|
|
310
310
|
if (!ctx.consentGranted) {
|
|
311
|
-
return skipped(check,
|
|
311
|
+
return skipped(check,
|
|
312
|
+
// #505: name the overlay's source, and say the re-add is safe. The bare
|
|
313
|
+
// "re-run 'canary overlay add'" read as "reinstall it", which is the
|
|
314
|
+
// one thing a user with a working overlay will not risk.
|
|
315
|
+
"command checks need consent — re-run 'canary overlay add <source>' " +
|
|
316
|
+
'(safe on an installed overlay: it re-asks consent, never re-clones)');
|
|
312
317
|
}
|
|
313
318
|
const command = check.command ?? [];
|
|
314
319
|
const cmd = `\`${command.join(' ')}\``;
|
package/dist/doctor.js
CHANGED
|
@@ -186,10 +186,13 @@ function summarizeChecks(groups) {
|
|
|
186
186
|
*/
|
|
187
187
|
function abstentionRemedy(summary) {
|
|
188
188
|
return summary.skipped.length > 0
|
|
189
|
-
?
|
|
190
|
-
|
|
191
|
-
'
|
|
192
|
-
|
|
189
|
+
? // #505: `<source>`, not `<name>` -- `add` takes the source spec, so the
|
|
190
|
+
// copied-and-pasted form failed for anyone who followed it literally.
|
|
191
|
+
'Every registered check was skipped or informational, so doctor verified ' +
|
|
192
|
+
'nothing. Grant command-check consent (re-run `canary overlay add ' +
|
|
193
|
+
'<source> --yes`, which re-asks consent on an already-installed overlay ' +
|
|
194
|
+
'without re-cloning) or install an overlay whose checks apply here, ' +
|
|
195
|
+
'then re-run.'
|
|
193
196
|
: 'No check was registered, so doctor verified nothing. Install an ' +
|
|
194
197
|
'overlay that ships a `.canary/doctor.json` (`canary overlay add ' +
|
|
195
198
|
'<source>`), then re-run.';
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* `\u{...}` escapes emitted verbatim.
|
|
12
12
|
*/
|
|
13
13
|
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, } from 'node:fs';
|
|
14
|
-
import { basename, join, resolve } from 'node:path';
|
|
14
|
+
import { basename, extname, join, resolve } from 'node:path';
|
|
15
15
|
import pc from 'picocolors';
|
|
16
16
|
import { CliExit, jsonIndent2 } from './cli-common.js';
|
|
17
17
|
import { gateOutcome } from './core/gate-result.js';
|
|
@@ -19,6 +19,7 @@ import { ckInitCmd } from './company-knowledge-cli.js';
|
|
|
19
19
|
import { extractFrameworkHint } from './core/classifier.js';
|
|
20
20
|
import { VALID_CATEGORIES, buildFeedback } from './core/feedback.js';
|
|
21
21
|
import { OverlayNotFound, listOverlays, resolveOverlay, } from './core/overlays.js';
|
|
22
|
+
import { JS_TEST_EXTENSIONS, lintableFramework } from './core/static-linter.js';
|
|
22
23
|
import { RunSummary } from './core/ticket-updater.js';
|
|
23
24
|
import { renderBanner } from './ui/banner.js';
|
|
24
25
|
import { ARROW, CHECK, CHECK_MARK, CROSS, EM_DASH, HAMMER, NEXT, REDX, ROCKET, WARN, WRENCH, } from './main-deps.js';
|
|
@@ -46,6 +47,24 @@ function isDir(p) {
|
|
|
46
47
|
return false;
|
|
47
48
|
}
|
|
48
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Directories never worth walking. A dependency's own test suite is not the
|
|
52
|
+
* consumer's to fix: before #566, `node_modules` accounted for 254 of 256
|
|
53
|
+
* findings in one downstream run, and the only `critical` sat inside vendored
|
|
54
|
+
* code. `pattern-matcher.ts` has carried this set since the Python port; this
|
|
55
|
+
* walk was the copy that never got it.
|
|
56
|
+
*/
|
|
57
|
+
const IGNORED_DIRS = new Set([
|
|
58
|
+
'node_modules',
|
|
59
|
+
'.git',
|
|
60
|
+
'__pycache__',
|
|
61
|
+
'.venv',
|
|
62
|
+
'venv',
|
|
63
|
+
'dist',
|
|
64
|
+
'build',
|
|
65
|
+
'.next',
|
|
66
|
+
'.nuxt',
|
|
67
|
+
]);
|
|
49
68
|
function walkFiles(dir) {
|
|
50
69
|
const out = [];
|
|
51
70
|
let entries;
|
|
@@ -57,23 +76,27 @@ function walkFiles(dir) {
|
|
|
57
76
|
}
|
|
58
77
|
for (const e of entries) {
|
|
59
78
|
const full = join(dir, e.name);
|
|
60
|
-
if (e.isDirectory())
|
|
61
|
-
|
|
79
|
+
if (e.isDirectory()) {
|
|
80
|
+
if (!IGNORED_DIRS.has(e.name))
|
|
81
|
+
out.push(...walkFiles(full));
|
|
82
|
+
}
|
|
62
83
|
else if (e.isFile())
|
|
63
84
|
out.push(full);
|
|
64
85
|
}
|
|
65
86
|
return out;
|
|
66
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* `test_*.py` plus `*.test.*` / `*.spec.*` over every extension the scanners
|
|
90
|
+
* can actually read -- `.mjs` and `.cjs` included, which is the half of #566
|
|
91
|
+
* that made a directory of ESM tests collect zero files.
|
|
92
|
+
*/
|
|
93
|
+
const JS_TEST_FILE_RE = new RegExp(`\\.(test|spec)\\.(${JS_TEST_EXTENSIONS.map((e) => e.slice(1)).join('|')})$`);
|
|
67
94
|
/** Recursive test-file glob matching Python's `rglob` union, sorted by path. */
|
|
68
95
|
function collectTestFiles(dir) {
|
|
69
96
|
return walkFiles(dir)
|
|
70
97
|
.filter((p) => {
|
|
71
98
|
const b = basename(p);
|
|
72
|
-
return ((b.startsWith('test_') && b.endsWith('.py')) ||
|
|
73
|
-
b.endsWith('.spec.ts') ||
|
|
74
|
-
b.endsWith('.spec.js') ||
|
|
75
|
-
b.endsWith('.test.ts') ||
|
|
76
|
-
b.endsWith('.test.js'));
|
|
99
|
+
return ((b.startsWith('test_') && b.endsWith('.py')) || JS_TEST_FILE_RE.test(b));
|
|
77
100
|
})
|
|
78
101
|
.sort();
|
|
79
102
|
}
|
|
@@ -394,6 +417,21 @@ function findingPayload(f) {
|
|
|
394
417
|
suggestion: f.suggestion,
|
|
395
418
|
};
|
|
396
419
|
}
|
|
420
|
+
/** Human-readable list of what the collectors look for, for remedy text. */
|
|
421
|
+
const SCANNABLE_DESC = `test_*.py, *.test|spec.{${JS_TEST_EXTENSIONS.map((e) => e.slice(1)).join(',')}}`;
|
|
422
|
+
/** Emit the abstention notice in the caller's output mode, then exit 3. */
|
|
423
|
+
function abstain(remedy, deps, json) {
|
|
424
|
+
const outcome = gateOutcome({ checked: 0, findings: [] }, 'gate');
|
|
425
|
+
if (json) {
|
|
426
|
+
deps.out(jsonIndent2([]));
|
|
427
|
+
deps.err(`${outcome.summaryLine} ${remedy}`);
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
deps.out(pc.bold(pc.yellow(outcome.summaryLine)));
|
|
431
|
+
deps.out(` ${remedy}`);
|
|
432
|
+
}
|
|
433
|
+
throw new CliExit(outcome.exitCode);
|
|
434
|
+
}
|
|
397
435
|
/**
|
|
398
436
|
* The denominator guard shared by the file-scanning gates (#508 Wave 4a).
|
|
399
437
|
*
|
|
@@ -410,29 +448,44 @@ function findingPayload(f) {
|
|
|
410
448
|
function abstainOnZeroFiles(files, path, deps, json) {
|
|
411
449
|
if (files.length > 0)
|
|
412
450
|
return;
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
451
|
+
abstain(`No test file matched under ${path} (looked for ${SCANNABLE_DESC}). ` +
|
|
452
|
+
`Point at a directory that holds tests, or pass a single file directly.`, deps, json);
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* The single-file half of the same guard (#566).
|
|
456
|
+
*
|
|
457
|
+
* `abstainOnZeroFiles` only ever fires on a directory: a single file is passed
|
|
458
|
+
* straight through as a one-element list, so its denominator is never zero. It
|
|
459
|
+
* can still be unmeasurable -- an extension no ruleset parses used to fall back
|
|
460
|
+
* to the Python scanners, which find nothing in ESM JavaScript and so rendered
|
|
461
|
+
* "No issues found" over a file that was never actually read. Zero findings
|
|
462
|
+
* from a scanner that could not parse the input is an abstention, not a pass.
|
|
463
|
+
*/
|
|
464
|
+
function abstainOnUnlintableFile(path, deps, json) {
|
|
465
|
+
if (lintableFramework(path) !== null)
|
|
466
|
+
return;
|
|
467
|
+
const ext = extname(path) || basename(path);
|
|
468
|
+
abstain(`Cannot lint ${ext} — no ruleset parses it, so a clean result would be ` +
|
|
469
|
+
`meaningless (looked for ${SCANNABLE_DESC}).`, deps, json);
|
|
426
470
|
}
|
|
427
471
|
export function reviewTestCmd(path, opts, deps) {
|
|
472
|
+
const json = opts.json === true;
|
|
428
473
|
const files = isDir(path) ? collectTestFiles(path) : [path];
|
|
429
|
-
abstainOnZeroFiles(files, path, deps,
|
|
474
|
+
abstainOnZeroFiles(files, path, deps, json);
|
|
475
|
+
if (!isDir(path) && !opts.framework)
|
|
476
|
+
abstainOnUnlintableFile(path, deps, json);
|
|
430
477
|
const linter = deps.makeLinter();
|
|
431
478
|
const allFindings = [];
|
|
432
479
|
for (const f of files)
|
|
433
480
|
allFindings.push(...linter.lint(f, opts.framework));
|
|
434
|
-
|
|
481
|
+
// `--json` renders the machine payload and then falls through to the same
|
|
482
|
+
// exit-code decision as human mode. It used to `return` here, so a consumer
|
|
483
|
+
// gating on `$?` saw every finding-bearing run as clean (#566).
|
|
484
|
+
if (json) {
|
|
435
485
|
deps.out(jsonIndent2(allFindings.map(findingPayload)));
|
|
486
|
+
if (allFindings.some((f) => f.severity === 'critical')) {
|
|
487
|
+
throw new CliExit(1);
|
|
488
|
+
}
|
|
436
489
|
return;
|
|
437
490
|
}
|
|
438
491
|
if (allFindings.length === 0) {
|
|
@@ -462,14 +515,20 @@ export function reviewTestCmd(path, opts, deps) {
|
|
|
462
515
|
throw new CliExit(1);
|
|
463
516
|
}
|
|
464
517
|
export function flakeCheckCmd(path, opts, deps) {
|
|
518
|
+
const json = opts.json === true;
|
|
465
519
|
const files = isDir(path) ? collectTestFiles(path) : [path];
|
|
466
|
-
abstainOnZeroFiles(files, path, deps,
|
|
520
|
+
abstainOnZeroFiles(files, path, deps, json);
|
|
521
|
+
if (!isDir(path))
|
|
522
|
+
abstainOnUnlintableFile(path, deps, json);
|
|
467
523
|
const linter = deps.makeLinter();
|
|
468
524
|
const allFindings = [];
|
|
469
525
|
for (const f of files)
|
|
470
526
|
allFindings.push(...linter.flakeCheck(f));
|
|
471
|
-
|
|
527
|
+
// Exit-code parity with human mode, same reason as `review-test` above.
|
|
528
|
+
if (json) {
|
|
472
529
|
deps.out(jsonIndent2(allFindings.map(findingPayload)));
|
|
530
|
+
if (allFindings.length > 0)
|
|
531
|
+
throw new CliExit(1);
|
|
473
532
|
return;
|
|
474
533
|
}
|
|
475
534
|
if (allFindings.length === 0) {
|
package/dist/engine/cli.core.js
CHANGED
|
@@ -102,7 +102,7 @@ export function createCanaryCommand(depsInit = {}) {
|
|
|
102
102
|
.command('migrate')
|
|
103
103
|
.description("Migrate a harness-scaffolded test-suite project to Canary's layout.")
|
|
104
104
|
.addOption(new Option('-p, --path <path>', 'Project root to migrate (default: current directory).').default('.'))
|
|
105
|
-
.option('-f, --framework <framework>', 'Override auto-detected framework.')
|
|
105
|
+
.option('-f, --framework <framework>', 'Override the auto-detected framework (also resolves its test shape).')
|
|
106
106
|
.option('--from <overlay>', 'Tracked overlay (name or path) whose .canary/skills/ are deployed into the target.')
|
|
107
107
|
.option('-o, --overlay <path>', '[deprecated: use --from] Path to an overlay repo whose .canary/skills/ are deployed.')
|
|
108
108
|
.option('--apply', 'Write files. Without this flag the command is a dry run.')
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test-framework detection probes, tiered by the kind of evidence they read.
|
|
3
|
+
*
|
|
4
|
+
* Split out of `migrator.ts` (#504 part 1) so workspace detection can probe an
|
|
5
|
+
* individual package without importing the migrator -- see `fs-glob.ts` for why
|
|
6
|
+
* the direction has to stay leafward.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { globFiles, readTextOrNull } from './fs-glob.js';
|
|
11
|
+
// (config_file, framework, shape, confidence)
|
|
12
|
+
export const _CONFIG_PROBES = [
|
|
13
|
+
['playwright.config.ts', 'playwright', 'e2e_ui', 'config'],
|
|
14
|
+
['playwright.config.js', 'playwright', 'e2e_ui', 'config'],
|
|
15
|
+
['cypress.config.ts', 'playwright', 'e2e_ui', 'config'],
|
|
16
|
+
['cypress.config.js', 'playwright', 'e2e_ui', 'config'],
|
|
17
|
+
['vitest.config.ts', 'vitest', 'frontend_unit', 'config'],
|
|
18
|
+
['vitest.config.js', 'vitest', 'frontend_unit', 'config'],
|
|
19
|
+
['vitest.config.mts', 'vitest', 'frontend_unit', 'config'],
|
|
20
|
+
['jest.config.ts', 'vitest', 'frontend_unit', 'config'],
|
|
21
|
+
['jest.config.js', 'vitest', 'frontend_unit', 'config'],
|
|
22
|
+
['jest.config.mjs', 'vitest', 'frontend_unit', 'config'],
|
|
23
|
+
['k6.config.js', 'k6', 'performance', 'config'],
|
|
24
|
+
['pytest.ini', 'pytest', 'api', 'config'],
|
|
25
|
+
['setup.cfg', 'pytest', 'api', 'config'],
|
|
26
|
+
['axe.config.js', 'axe-core', 'accessibility', 'config'],
|
|
27
|
+
['backstop.json', 'backstopjs', 'visual', 'config'],
|
|
28
|
+
['pact.json', 'pact', 'contract', 'config'],
|
|
29
|
+
['.pact', 'pact', 'contract', 'config'],
|
|
30
|
+
['stryker.config.js', 'stryker', 'mutation', 'config'],
|
|
31
|
+
['stryker.config.mjs', 'stryker', 'mutation', 'config'],
|
|
32
|
+
['locust.conf', 'locust', 'load', 'config'],
|
|
33
|
+
['locustfile.py', 'locust', 'load', 'config'],
|
|
34
|
+
['wdio.conf.ts', 'wdio', 'mobile', 'config'],
|
|
35
|
+
['wdio.conf.js', 'wdio', 'mobile', 'config'],
|
|
36
|
+
['wdio.conf.mjs', 'wdio', 'mobile', 'config'],
|
|
37
|
+
];
|
|
38
|
+
// pyproject.toml section markers
|
|
39
|
+
const _PYPROJECT_MARKERS = [
|
|
40
|
+
['[tool.pytest.ini_options]', 'pytest', 'api'],
|
|
41
|
+
['[tool.coverage', 'pytest', 'api'],
|
|
42
|
+
];
|
|
43
|
+
// package.json test script -> (framework, shape)
|
|
44
|
+
const _PACKAGE_SCRIPT_PATTERNS = [
|
|
45
|
+
[/\bplaywright\b/, 'playwright', 'e2e_ui'],
|
|
46
|
+
[/\bcypress\b/, 'playwright', 'e2e_ui'],
|
|
47
|
+
[/\bvitest\b/, 'vitest', 'frontend_unit'],
|
|
48
|
+
[/\bjest\b/, 'vitest', 'frontend_unit'],
|
|
49
|
+
[/\bk6\b/, 'k6', 'performance'],
|
|
50
|
+
[/\blocust\b/, 'locust', 'load'],
|
|
51
|
+
[/\bstryker\b/, 'stryker', 'mutation'],
|
|
52
|
+
[/\bwdio\b/, 'wdio', 'mobile'],
|
|
53
|
+
];
|
|
54
|
+
// Python dependency -> (framework, shape). MULTILINE `^` anchored on `\n` only.
|
|
55
|
+
const _PYTHON_DEP_PATTERNS = [
|
|
56
|
+
[/(?:^|(?<=\n))pytest\b/i, 'pytest', 'api'],
|
|
57
|
+
[/(?:^|(?<=\n))locust\b/i, 'locust', 'load'],
|
|
58
|
+
[/(?:^|(?<=\n))pact\b/i, 'pact', 'contract'],
|
|
59
|
+
[/(?:^|(?<=\n))sdv\b/i, 'sdv', 'synthetic_data'],
|
|
60
|
+
[/(?:^|(?<=\n))faker\b/i, 'faker', 'synthetic_data'],
|
|
61
|
+
[/(?:^|(?<=\n))testcontainers\b/i, 'testcontainers', 'integration'],
|
|
62
|
+
];
|
|
63
|
+
// Language -> (framework, shape) fallbacks from harness.config.json
|
|
64
|
+
const _LANGUAGE_FALLBACKS = {
|
|
65
|
+
python: ['pytest', 'api'],
|
|
66
|
+
typescript: ['playwright', 'e2e_ui'],
|
|
67
|
+
javascript: ['playwright', 'e2e_ui'],
|
|
68
|
+
};
|
|
69
|
+
// Detects playwright UI fixture params. MULTILINE is a no-op (no `^`/`$`).
|
|
70
|
+
const _PW_UI_FIXTURE_RE = /async\s*\(\s*\{[^}]*\b(?:page|browser)\b/;
|
|
71
|
+
/**
|
|
72
|
+
* Return 'api' when no playwright spec file uses page/browser fixtures, else
|
|
73
|
+
* 'e2e_ui' (the default when any UI signal is found or no spec files exist).
|
|
74
|
+
*/
|
|
75
|
+
export function inferPlaywrightShape(root) {
|
|
76
|
+
const specGlobs = [
|
|
77
|
+
'tests/**/*.spec.ts',
|
|
78
|
+
'tests/**/*.spec.js',
|
|
79
|
+
'test/**/*.spec.ts',
|
|
80
|
+
'test/**/*.spec.js',
|
|
81
|
+
];
|
|
82
|
+
let total = 0;
|
|
83
|
+
for (const glob of specGlobs) {
|
|
84
|
+
for (const path of globFiles(root, glob)) {
|
|
85
|
+
// Python read_text(errors="ignore"); readFileSync substitutes U+FFFD for
|
|
86
|
+
// invalid bytes -- immaterial for the ASCII fixture pattern below.
|
|
87
|
+
let content;
|
|
88
|
+
try {
|
|
89
|
+
content = readFileSync(path, 'utf-8');
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
total += 1;
|
|
95
|
+
if (_PW_UI_FIXTURE_RE.test(content))
|
|
96
|
+
return 'e2e_ui';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return total > 0 ? 'api' : 'e2e_ui';
|
|
100
|
+
}
|
|
101
|
+
/** Tier 1 -- a dedicated config file (highest confidence). */
|
|
102
|
+
function probeConfig(root) {
|
|
103
|
+
for (const [filename, framework, shape, confidence] of _CONFIG_PROBES) {
|
|
104
|
+
if (existsSync(join(root, filename))) {
|
|
105
|
+
// For playwright config files, distinguish API vs UI suites.
|
|
106
|
+
if (framework === 'playwright' && shape === 'e2e_ui') {
|
|
107
|
+
const inferred = inferPlaywrightShape(root);
|
|
108
|
+
if (inferred !== shape)
|
|
109
|
+
return [framework, inferred, filename, 'content'];
|
|
110
|
+
}
|
|
111
|
+
return [framework, shape, filename, confidence];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
/** Tier 2a -- pyproject.toml section markers, then its dependency scan. */
|
|
117
|
+
function probePyproject(root) {
|
|
118
|
+
const pyproject = join(root, 'pyproject.toml');
|
|
119
|
+
if (!existsSync(pyproject))
|
|
120
|
+
return null;
|
|
121
|
+
const content = readTextOrNull(pyproject);
|
|
122
|
+
if (content === null)
|
|
123
|
+
return null;
|
|
124
|
+
for (const [marker, framework, shape] of _PYPROJECT_MARKERS) {
|
|
125
|
+
if (content.includes(marker)) {
|
|
126
|
+
return [framework, shape, 'pyproject.toml', 'content'];
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
for (const [pattern, framework, shape] of _PYTHON_DEP_PATTERNS) {
|
|
130
|
+
if (pattern.test(content)) {
|
|
131
|
+
return [framework, shape, 'pyproject.toml (dependencies)', 'content'];
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
/** Tier 2b -- requirements*.txt dependency scan. */
|
|
137
|
+
function probeRequirements(root) {
|
|
138
|
+
for (const reqFile of [
|
|
139
|
+
'requirements.txt',
|
|
140
|
+
'requirements-test.txt',
|
|
141
|
+
'requirements-dev.txt',
|
|
142
|
+
]) {
|
|
143
|
+
const reqPath = join(root, reqFile);
|
|
144
|
+
if (!existsSync(reqPath))
|
|
145
|
+
continue;
|
|
146
|
+
const content = readTextOrNull(reqPath);
|
|
147
|
+
if (content === null)
|
|
148
|
+
continue;
|
|
149
|
+
for (const [pattern, framework, shape] of _PYTHON_DEP_PATTERNS) {
|
|
150
|
+
if (pattern.test(content))
|
|
151
|
+
return [framework, shape, reqFile, 'content'];
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
/** Tier 2c -- package.json scripts.test scan. */
|
|
157
|
+
function probePackageScripts(root) {
|
|
158
|
+
const pkgJson = join(root, 'package.json');
|
|
159
|
+
if (!existsSync(pkgJson))
|
|
160
|
+
return null;
|
|
161
|
+
try {
|
|
162
|
+
const pkg = JSON.parse(readFileSync(pkgJson, 'utf-8'));
|
|
163
|
+
const scripts = (pkg['scripts'] ?? {});
|
|
164
|
+
const testScript = String(scripts['test'] ?? '');
|
|
165
|
+
for (const [pattern, framework, shape] of _PACKAGE_SCRIPT_PATTERNS) {
|
|
166
|
+
if (pattern.test(testScript)) {
|
|
167
|
+
return [framework, shape, 'package.json (scripts.test)', 'content'];
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// OSError / JSONDecodeError -> ignore.
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
/** Tier 3 -- language fallback from harness config. */
|
|
177
|
+
function probeLanguage(config) {
|
|
178
|
+
const language = String(config['language'] ?? '').toLowerCase();
|
|
179
|
+
if (!Object.prototype.hasOwnProperty.call(_LANGUAGE_FALLBACKS, language)) {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
const [fw, shape] = _LANGUAGE_FALLBACKS[language];
|
|
183
|
+
return [fw, shape, `harness.config.json (language: ${language})`, 'language'];
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Detect a test framework under *dir*, running only the requested *tiers*.
|
|
187
|
+
*
|
|
188
|
+
* The tier list is the whole point of this function. Root detection passes all
|
|
189
|
+
* three; per-package detection passes `['config', 'content']` ONLY. The
|
|
190
|
+
* language tier maps `language: typescript` to playwright/e2e_ui, so running it
|
|
191
|
+
* per package would make every package in a TypeScript monorepo "detect"
|
|
192
|
+
* playwright by inheritance -- canary inventing findings it never observed
|
|
193
|
+
* (#504 part 1, spec test #8).
|
|
194
|
+
*
|
|
195
|
+
* Note the tier list and the returned `confidence` are not the same axis: the
|
|
196
|
+
* config tier returns confidence `content` when `inferPlaywrightShape` refines
|
|
197
|
+
* e2e_ui to api, because the refinement read file contents to decide.
|
|
198
|
+
*/
|
|
199
|
+
export function probe(dir, config, tiers) {
|
|
200
|
+
const on = (t) => tiers.includes(t);
|
|
201
|
+
if (on('config')) {
|
|
202
|
+
const hit = probeConfig(dir);
|
|
203
|
+
if (hit !== null)
|
|
204
|
+
return hit;
|
|
205
|
+
}
|
|
206
|
+
if (on('content')) {
|
|
207
|
+
const hit = probePyproject(dir) ?? probeRequirements(dir) ?? probePackageScripts(dir);
|
|
208
|
+
if (hit !== null)
|
|
209
|
+
return hit;
|
|
210
|
+
}
|
|
211
|
+
if (on('language')) {
|
|
212
|
+
const hit = probeLanguage(config);
|
|
213
|
+
if (hit !== null)
|
|
214
|
+
return hit;
|
|
215
|
+
}
|
|
216
|
+
return [null, 'unknown', 'none', 'none'];
|
|
217
|
+
}
|
|
218
|
+
//# sourceMappingURL=framework-probes.js.map
|