@orkestrel/scaffold 0.0.108 → 0.0.109
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/dist/agents/skills/orkestrel-dispatch/scripts/bench.js +326 -91
- package/dist/agents/skills/orkestrel-dispatch/scripts/brief.js +110 -14
- package/dist/agents/skills/orkestrel-dispatch/scripts/chain.js +280 -0
- package/dist/agents/skills/orkestrel-dispatch/scripts/cite.js +188 -26
- package/dist/agents/skills/orkestrel-dispatch/scripts/launch.js +142 -43
- package/dist/agents/skills/orkestrel-dispatch/scripts/login.js +240 -62
- package/dist/agents/skills/orkestrel-dispatch/scripts/patch.js +323 -0
- package/dist/agents/skills/orkestrel-dispatch/scripts/result.js +169 -32
- package/dist/agents/skills/orkestrel-dispatch/scripts/sweep.js +290 -69
- package/dist/agents/skills/orkestrel-falsify/scripts/mutate.js +560 -0
- package/dist/agents/skills/orkestrel-harden/scripts/discovery.js +1052 -205
- package/dist/agents/skills/orkestrel-harden/scripts/measure.js +361 -0
- package/dist/agents/skills/orkestrel-harden/scripts/titles.js +455 -80
- package/dist/agents/skills/orkestrel-parallel/scripts/cases.js +327 -0
- package/dist/agents/skills/orkestrel-publish/scripts/compare.js +298 -51
- package/dist/agents/skills/orkestrel-publish/scripts/pins.js +170 -13
- package/dist/agents/skills/orkestrel-publish/scripts/swap.js +956 -191
- package/dist/agents/skills/orkestrel-publish/scripts/wave.js +707 -112
- package/dist/agents/skills/orkestrel-publish/scripts/window.js +235 -41
- package/dist/agents/skills/orkestrel-scout/scripts/map.js +341 -54
- package/dist/agents/templates/brief.md +1 -1
- package/dist/bin/main.js +51 -46
- package/dist/bin/main.js.map +1 -1
- package/dist/host/AGENTS.md +23 -21
- package/dist/host/agents/orchestration.md +43 -18
- package/dist/host/agents/platforms/windows.md +3 -3
- package/dist/host/agents/skills/orkestrel-align/SKILL.md +10 -13
- package/dist/host/agents/skills/orkestrel-align/references/fleet.md +4 -10
- package/dist/host/agents/skills/orkestrel-align/references/integration.md +1 -1
- package/dist/host/agents/skills/orkestrel-build/SKILL.md +16 -48
- package/dist/host/agents/skills/orkestrel-build/agents/openai.yaml +1 -1
- package/dist/host/agents/skills/orkestrel-debrief/SKILL.md +19 -16
- package/dist/host/agents/skills/orkestrel-debrief/agents/openai.yaml +1 -1
- package/dist/host/agents/skills/orkestrel-debrief/references/field-testing.md +15 -28
- package/dist/host/agents/skills/orkestrel-debrief/references/instruction-audit.md +5 -6
- package/dist/host/agents/skills/orkestrel-debrief/references/retention.md +4 -6
- package/dist/host/agents/skills/orkestrel-dispatch/SKILL.md +31 -20
- package/dist/host/agents/skills/orkestrel-dispatch/references/launch.md +3 -4
- package/dist/host/agents/skills/orkestrel-dispatch/scripts/bench.ts +366 -75
- package/dist/host/agents/skills/orkestrel-dispatch/scripts/brief.ts +126 -16
- package/dist/host/agents/skills/orkestrel-dispatch/scripts/chain.ts +323 -0
- package/dist/host/agents/skills/orkestrel-dispatch/scripts/cite.ts +220 -31
- package/dist/host/agents/skills/orkestrel-dispatch/scripts/launch.ts +176 -33
- package/dist/host/agents/skills/orkestrel-dispatch/scripts/login.ts +294 -59
- package/dist/host/agents/skills/orkestrel-dispatch/scripts/patch.ts +401 -0
- package/dist/host/agents/skills/orkestrel-dispatch/scripts/result.ts +199 -34
- package/dist/host/agents/skills/orkestrel-dispatch/scripts/sweep.ts +330 -69
- package/dist/host/agents/skills/orkestrel-falsify/SKILL.md +10 -8
- package/dist/host/agents/skills/orkestrel-falsify/references/brief.md +1 -5
- package/dist/host/agents/skills/orkestrel-falsify/references/reconcile.md +1 -1
- package/dist/host/agents/skills/orkestrel-falsify/scripts/mutate.ts +701 -0
- package/dist/host/agents/skills/orkestrel-harden/SKILL.md +16 -12
- package/dist/host/agents/skills/orkestrel-harden/references/bindings.md +23 -81
- package/dist/host/agents/skills/orkestrel-harden/references/centralization.md +4 -5
- package/dist/host/agents/skills/orkestrel-harden/references/contract.md +2 -0
- package/dist/host/agents/skills/orkestrel-harden/references/document.md +33 -0
- package/dist/host/agents/skills/orkestrel-harden/references/equivalence.md +6 -3
- package/dist/host/agents/skills/orkestrel-harden/references/hardening.md +8 -4
- package/dist/host/agents/skills/orkestrel-harden/references/research.md +2 -0
- package/dist/host/agents/skills/orkestrel-harden/scripts/discovery.ts +1272 -279
- package/dist/host/agents/skills/orkestrel-harden/scripts/measure.ts +425 -0
- package/dist/host/agents/skills/orkestrel-harden/scripts/titles.ts +603 -98
- package/dist/host/agents/skills/orkestrel-journey/SKILL.md +75 -98
- package/dist/host/agents/skills/orkestrel-journey/references/captures.md +54 -17
- package/dist/host/agents/skills/orkestrel-journey/references/decide.md +1 -20
- package/dist/host/agents/skills/orkestrel-journey/references/layer.md +21 -31
- package/dist/host/agents/skills/orkestrel-journey/references/styles.md +1 -1
- package/dist/host/agents/skills/orkestrel-parallel/SKILL.md +28 -24
- package/dist/host/agents/skills/orkestrel-parallel/references/claude.md +15 -13
- package/dist/host/agents/skills/orkestrel-parallel/references/codex.md +1 -0
- package/dist/host/agents/skills/orkestrel-parallel/references/cursor.md +13 -13
- package/dist/host/agents/skills/orkestrel-parallel/scripts/cases.ts +389 -0
- package/dist/host/agents/skills/orkestrel-polish/SKILL.md +16 -20
- package/dist/host/agents/skills/orkestrel-polish/references/capture-harness.md +16 -26
- package/dist/host/agents/skills/orkestrel-publish/SKILL.md +12 -26
- package/dist/host/agents/skills/orkestrel-publish/agents/openai.yaml +2 -2
- package/dist/host/agents/skills/orkestrel-publish/references/adopt.md +23 -0
- package/dist/host/agents/skills/orkestrel-publish/references/release.md +4 -4
- package/dist/host/agents/skills/orkestrel-publish/references/wave.md +10 -9
- package/dist/host/agents/skills/orkestrel-publish/references/window.md +20 -25
- package/dist/host/agents/skills/orkestrel-publish/scripts/compare.ts +348 -59
- package/dist/host/agents/skills/orkestrel-publish/scripts/pins.ts +202 -22
- package/dist/host/agents/skills/orkestrel-publish/scripts/swap.ts +1061 -191
- package/dist/host/agents/skills/orkestrel-publish/scripts/wave.ts +812 -140
- package/dist/host/agents/skills/orkestrel-publish/scripts/window.ts +280 -38
- package/dist/host/agents/skills/orkestrel-scout/SKILL.md +3 -3
- package/dist/host/agents/skills/orkestrel-scout/scripts/map.ts +428 -70
- package/dist/host/agents/templates/brief.md +1 -1
- package/dist/host/agents/transports/claude-cli.md +3 -3
- package/dist/host/agents/transports/codex.md +2 -7
- package/dist/host/agents/transports/cursor.md +2 -3
- package/dist/host/claude/AGENTS.md +2 -2
- package/dist/host/claude/agents/analyst.md +3 -3
- package/dist/host/claude/agents/astra.md +3 -3
- package/dist/host/claude/agents/builder.md +1 -1
- package/dist/host/claude/agents/checker.md +1 -1
- package/dist/host/claude/agents/distiller.md +1 -1
- package/dist/host/claude/agents/grok.md +1 -1
- package/dist/host/claude/agents/opus.md +2 -2
- package/dist/host/claude/agents/orkestrel.md +57 -90
- package/dist/host/claude/agents/researcher.md +1 -1
- package/dist/host/claude/agents/reviewer.md +4 -0
- package/dist/host/claude/agents/scout.md +1 -1
- package/dist/host/claude/agents/verifier.md +1 -1
- package/dist/host/claude/rules/application.md +14 -21
- package/dist/host/claude/rules/architecture.md +34 -44
- package/dist/host/claude/rules/browser.md +3 -4
- package/dist/host/claude/rules/campaign.md +2 -2
- package/dist/host/claude/rules/documentation.md +25 -12
- package/dist/host/claude/rules/names.md +9 -4
- package/dist/host/claude/rules/patterns.md +6 -7
- package/dist/host/claude/rules/portability.md +1 -1
- package/dist/host/claude/rules/quality.md +4 -5
- package/dist/host/claude/rules/styles.md +2 -2
- package/dist/host/claude/rules/tests.md +57 -114
- package/dist/host/claude/rules/typescript.md +7 -9
- package/dist/host/claude/rules/workspace.md +21 -19
- package/dist/host/claude/rules/writing.md +2 -5
- package/dist/host/claude/skills/orkestrel-dispatch/SKILL.md +1 -1
- package/dist/host/claude/skills/orkestrel-harden/SKILL.md +1 -1
- package/dist/host/claude/skills/orkestrel-publish/SKILL.md +1 -1
- package/dist/host/claude/skills/orkestrel-scout/SKILL.md +1 -1
- package/dist/host/codex/agents/analyst.toml +9 -3
- package/dist/host/codex/agents/astra.toml +3 -4
- package/dist/host/codex/agents/builder.toml +1 -1
- package/dist/host/codex/agents/opus.toml +3 -4
- package/dist/host/codex/agents/orkestrel.toml +1 -1
- package/dist/host/codex/agents/researcher.toml +2 -2
- package/dist/host/codex/agents/verifier.toml +1 -1
- package/dist/host/configs/helpers.ts +21 -1
- package/dist/host/configs/policy.ts +299 -15
- package/dist/host/dotfiles/oxlintrc.json +137 -3
- package/dist/host/guides/agent.md +111 -134
- package/dist/host/guides/capture.md +801 -0
- package/dist/host/guides/probe.md +27 -8
- package/dist/host/guides/scaffold.md +96 -18
- package/dist/host/guides/study.md +534 -440
- package/dist/host/guides/test.md +111 -44
- package/dist/host/manifest.json +160 -240
- package/dist/host/tests/config.test.ts +971 -187
- package/dist/host/tests/policy.test.ts +127 -1
- package/dist/host/tests/setupPolicy.ts +893 -33
- package/dist/src/core/index.cjs +214 -86
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +122 -38
- package/dist/src/core/index.d.ts +122 -38
- package/dist/src/core/index.js +209 -85
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +20 -27
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.js +21 -28
- package/dist/src/server/index.js.map +1 -1
- package/package.json +11 -11
- package/dist/agents/skills/orkestrel-dispatch/scripts/helpers.js +0 -283
- package/dist/host/agents/skills/orkestrel-dispatch/references/bench.md +0 -25
- package/dist/host/agents/skills/orkestrel-dispatch/scripts/helpers.ts +0 -333
- package/dist/host/guides/supervisor.md +0 -4890
|
@@ -1,32 +1,79 @@
|
|
|
1
1
|
// Let Vitest select each gate's tests, then compare its identities with each config/mode universe.
|
|
2
2
|
// Run from the checkout root:
|
|
3
3
|
// node .agents/skills/orkestrel-harden/scripts/discovery.ts [--config vite.config.ts] [--projects a,b] [--json]
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
4
|
+
// Each value is a separate argument: `--flag=value` and any flag the script does not read exit 64.
|
|
5
|
+
// Roots are the package scripts no `npm run` invokes; `npm test` runs the root `test`, which stays a
|
|
6
|
+
// root. Follow root chains and forwarded arguments through `npm run [-s|--silent] NAME`,
|
|
7
|
+
// `npm run-script`, `npm test`, and `npm t`. Read a command past an environment prefix, `cross-env`,
|
|
8
|
+
// and a redirection. A gate is a Vitest run; list, bench, watch, dev, `--watch` without `=false`, and
|
|
9
|
+
// the mergeReports, listTags, and clearCache invocations are not gates. A test file counts as gated
|
|
10
|
+
// by a command only when `node` runs it as an argument (`node FILE`, `node --test FILE`).
|
|
11
|
+
// Collect an unfiltered full listing for each config/mode/root/dir, including the base config. Pass
|
|
12
|
+
// gate arguments verbatim except the reporter, output-file, coverage, color, cache, silent, UI,
|
|
13
|
+
// watch, run-mode, inspector, and standalone options; use filesOnly for file selection and full
|
|
14
|
+
// collection with --staticParse=false for name, tag, line, and shard selection, so runtime titles,
|
|
15
|
+
// setup modules, and the worker sequencer participate. Compare file/project/name/line/column
|
|
16
|
+
// identities without renaming projects. Preserve duplicate names within a listing and merge across
|
|
17
|
+
// units. Exit 2 naming the Vitest version when the option table lacks a required option.
|
|
18
|
+
// With --projects, list only the named projects: the universe, each gate listing, each project
|
|
19
|
+
// reading, the units enumerated, and the empty-project rescan carry the names, and a gate that
|
|
20
|
+
// reaches none of them is not listed. An exact name never lists a project outside the set, and the
|
|
21
|
+
// refusal for a name that matches none lists no other project either. A name holding `*` or opening
|
|
22
|
+
// with `!` is a Vitest pattern the script cannot match beforehand, so a gate that names projects
|
|
23
|
+
// keeps its own and a listing for such a name can reach projects beyond the names. Exit 64 for a
|
|
24
|
+
// name no project in the config matches, and skip the undiscovered scan.
|
|
25
|
+
// Flag ungated projects, empty chained projects, and undiscovered files under tests/ (the test
|
|
26
|
+
// and spec names, any extension). Report marker counts for the forms that need a ruling (`.todo`,
|
|
27
|
+
// `.skipIf`, `.runIf`, `.fails`, a `retry` option, a timeout option or a numeric last argument),
|
|
28
|
+
// read as call forms outside comments and strings; `vitest/no-focused-tests` and `vitest/no-disabled-tests` refuse `.only` and `.skip`. Report empty
|
|
29
|
+
// standalone workbenches without a flag: an empty project no chain containing " > " names.
|
|
13
30
|
// Retry when test files change during collection; refuse another change during that retry.
|
|
14
|
-
// Exit 0 with no flag, 3 with a flag, 2 for missing Vitest,
|
|
15
|
-
// CLI/help/argument/listing/output failures, repeated
|
|
16
|
-
// exit 64 on usage.
|
|
31
|
+
// Exit 0 with no flag, 3 with a flag, 2 for missing Vitest, an option table without a required
|
|
32
|
+
// option, unsupported related commands, CLI/help/argument/listing/output failures, repeated
|
|
33
|
+
// test-file changes, or filesystem/JSON errors; exit 64 on usage.
|
|
17
34
|
import { spawnSync } from 'node:child_process'
|
|
18
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
existsSync,
|
|
37
|
+
mkdtempSync,
|
|
38
|
+
readdirSync,
|
|
39
|
+
readFileSync,
|
|
40
|
+
realpathSync,
|
|
41
|
+
rmSync,
|
|
42
|
+
statSync,
|
|
43
|
+
} from 'node:fs'
|
|
19
44
|
import { tmpdir } from 'node:os'
|
|
20
45
|
import { basename, join, relative, resolve } from 'node:path'
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
46
|
+
|
|
47
|
+
/** Names the Vitest entry the census runs. */
|
|
48
|
+
export const VITEST = 'node_modules/vitest/vitest.mjs'
|
|
49
|
+
|
|
50
|
+
/** Lists the marker names the census counts, in report order. */
|
|
51
|
+
export const MARKERS: readonly string[] = [
|
|
52
|
+
'.todo',
|
|
53
|
+
'.skipIf',
|
|
54
|
+
'.runIf',
|
|
55
|
+
'.fails',
|
|
56
|
+
'retry',
|
|
57
|
+
'timeout',
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
/** Lists the options the scraped Vitest help table must hold before a listing runs. */
|
|
61
|
+
export const REQUIRED_OPTIONS: readonly string[] = [
|
|
62
|
+
'--reporter',
|
|
63
|
+
'--project',
|
|
64
|
+
'--config',
|
|
65
|
+
'--filesOnly',
|
|
66
|
+
'--staticParse',
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
/** Lists the flags this script reads, value flags and switches alike. */
|
|
70
|
+
export const FLAGS: readonly string[] = ['--config', '--projects', '--json']
|
|
71
|
+
|
|
72
|
+
/** Holds the usage line printed on a usage refusal. */
|
|
73
|
+
export const USAGE = 'usage: discovery.ts [--config vite.config.ts] [--projects a,b] [--json]'
|
|
74
|
+
|
|
75
|
+
/** Names the options a gate listing drops, because they change the run and not the selection. */
|
|
76
|
+
export const OMITTED = new Set([
|
|
30
77
|
'reporter',
|
|
31
78
|
'outputFile',
|
|
32
79
|
'coverage',
|
|
@@ -40,16 +87,111 @@ const OMITTED = new Set([
|
|
|
40
87
|
'inspectBrk',
|
|
41
88
|
'standalone',
|
|
42
89
|
])
|
|
43
|
-
// Bounds one listing, so an option that waits for input exits 2 naming its gate instead of
|
|
44
|
-
// hanging the census.
|
|
45
|
-
const LISTING_TIMEOUT = 600_000
|
|
46
90
|
|
|
47
|
-
|
|
91
|
+
/** Bounds one listing, so an option that waits for input exits 2 naming its gate. */
|
|
92
|
+
export const LISTING_TIMEOUT = 600_000
|
|
93
|
+
|
|
94
|
+
/** Caps the characters of a name an error message echoes. */
|
|
95
|
+
export const NAME_LIMIT = 80
|
|
96
|
+
|
|
97
|
+
/** Names the directories a tree walk skips by default, so build output and notes stay unread. */
|
|
98
|
+
export const SKIPPED_DIRECTORIES: ReadonlySet<string> = new Set([
|
|
99
|
+
'node_modules',
|
|
100
|
+
'.git',
|
|
101
|
+
'dist',
|
|
102
|
+
'tmp',
|
|
103
|
+
'.orkestrel',
|
|
104
|
+
'.idea',
|
|
105
|
+
])
|
|
106
|
+
|
|
107
|
+
/** Names the only directories the undiscovered scan skips. */
|
|
108
|
+
export const UNDISCOVERED_SKIPS: ReadonlySet<string> = new Set(['node_modules', '.git'])
|
|
109
|
+
|
|
110
|
+
/** Matches the test file names the modification snapshot watches. */
|
|
111
|
+
export const TEST_FILE_PATTERN = /\.test\.[cm]?[jt]sx?$/u
|
|
112
|
+
|
|
113
|
+
/** Matches the test and spec file names the undiscovered scan reports. */
|
|
114
|
+
export const DISCOVERABLE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$/u
|
|
115
|
+
|
|
116
|
+
/** Matches a test file path a `node` command can run as an argument. */
|
|
117
|
+
export const TEST_ARGUMENT_PATTERN = /^(?:tests|src|app)\/[A-Za-z0-9_./-]+\.test\.[cm]?ts$/u
|
|
118
|
+
|
|
119
|
+
/** Matches one command token, keeping a quoted run, an `&`-form redirection, or a separator whole. */
|
|
120
|
+
export const COMMAND_TOKEN_PATTERN = /\d*[<>]&[\d-]+|&>>?|(?:[^\s"';&|]+|"[^"]*"|'[^']*')+|[;&|]+/gu
|
|
121
|
+
|
|
122
|
+
/** Matches a redirection operator that takes the next token as its target. */
|
|
123
|
+
export const REDIRECT_OPERATOR_PATTERN = /^(?:\d*>>?|\d*<|&>>?)$/u
|
|
124
|
+
|
|
125
|
+
/** Matches a redirection that carries its target or duplicates a descriptor. */
|
|
126
|
+
export const REDIRECT_TARGET_PATTERN = /^(?:(?:\d*>>?|\d*<|&>>?)[^\s<>&]\S*|\d*[<>]&[\d-]+)$/u
|
|
127
|
+
|
|
128
|
+
/** Matches a leading `NAME=value` environment assignment. */
|
|
129
|
+
export const ENVIRONMENT_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*=/u
|
|
130
|
+
|
|
131
|
+
/** Matches a `--watch` switch that is not turned off in its own token. */
|
|
132
|
+
export const WATCH_PATTERN = /^(?:--watch|--watch=(?!false$).*|-w)$/u
|
|
133
|
+
|
|
134
|
+
/** Matches the reporting and cache-maintenance switches that make an invocation no gate. */
|
|
135
|
+
export const MAINTENANCE_PATTERN =
|
|
136
|
+
/^(?:--mergeReports|--merge-reports|--listTags|--list-tags|--clearCache|--clear-cache)(?:=|$)/u
|
|
137
|
+
|
|
138
|
+
/** Matches the options and filters that make a gate select tests by name, tag, line, or shard. */
|
|
139
|
+
export const FULL_PATTERN =
|
|
140
|
+
/^(?:-t|--testNamePattern|--test-name-pattern|--tagsFilter|--tags-filter|--shard|--(?:no-)?allowOnly|--(?:no-)?allow-only|--(?:no-)?strictTags|--(?:no-)?strict-tags)(?:=|$)/u
|
|
141
|
+
|
|
142
|
+
/** Matches a numeric literal token such as a timeout argument. */
|
|
143
|
+
export const NUMBER_PATTERN = /^\d[\d_]*(?:\.\d+)?(?:e\d+)?$/iu
|
|
144
|
+
|
|
145
|
+
/** Matches the quoted string literals the scanner keeps whole. */
|
|
146
|
+
export const STRING_PATTERN = /^(?:'(?:\\[\s\S]|[^'\\\r\n])*'|"(?:\\[\s\S]|[^"\\\r\n])*")/u
|
|
147
|
+
|
|
148
|
+
/** Matches a regular expression literal opening at a slash. */
|
|
149
|
+
export const REGEX_PATTERN = /^\/(?:\\.|\[(?:\\.|[^\]\\\r\n])*\]|[^/\\[\r\n])+\/[a-z]*/u
|
|
150
|
+
|
|
151
|
+
/** Matches an identifier or a number as one scanner word. */
|
|
152
|
+
export const WORD_PATTERN = /^(?:[\p{ID_Start}_$][\p{ID_Continue}$]*|\d[\w.]*)/u
|
|
153
|
+
|
|
154
|
+
/** Names the keywords after which a slash opens a regular expression. */
|
|
155
|
+
export const REGEX_KEYWORDS: ReadonlySet<string> = new Set([
|
|
156
|
+
'return',
|
|
157
|
+
'typeof',
|
|
158
|
+
'case',
|
|
159
|
+
'throw',
|
|
160
|
+
'in',
|
|
161
|
+
'of',
|
|
162
|
+
'void',
|
|
163
|
+
'delete',
|
|
164
|
+
'yield',
|
|
165
|
+
'await',
|
|
166
|
+
'else',
|
|
167
|
+
'do',
|
|
168
|
+
'instanceof',
|
|
169
|
+
'new',
|
|
170
|
+
])
|
|
171
|
+
|
|
172
|
+
/** Lists the tokens that open a bracket group. */
|
|
173
|
+
export const OPENERS: readonly string[] = ['(', '[', '{']
|
|
174
|
+
|
|
175
|
+
/** Lists the tokens that close a bracket group. */
|
|
176
|
+
export const CLOSERS: readonly string[] = [')', ']', '}']
|
|
177
|
+
|
|
178
|
+
/** Names the calls whose first argument list precedes the registration call. */
|
|
179
|
+
export const ARGUMENT_MODIFIERS: ReadonlySet<string> = new Set(['each', 'skipIf', 'runIf', 'for'])
|
|
180
|
+
|
|
181
|
+
/** Names the registration modifiers that need a ruling when a test file carries them. */
|
|
182
|
+
export const CHAIN_MARKERS: ReadonlySet<string> = new Set(['todo', 'skipIf', 'runIf', 'fails'])
|
|
183
|
+
|
|
184
|
+
/** Names the identifiers that open a test registration. */
|
|
185
|
+
export const REGISTRARS: ReadonlySet<string> = new Set(['it', 'test', 'describe', 'suite'])
|
|
186
|
+
|
|
187
|
+
/** Describes one Vitest command-line option read from the help table. */
|
|
188
|
+
export interface Option {
|
|
48
189
|
readonly name: string
|
|
49
190
|
readonly argument: string | undefined
|
|
50
191
|
}
|
|
51
192
|
|
|
52
|
-
|
|
193
|
+
/** Describes one test a listing collected, with the gate that names it. */
|
|
194
|
+
export interface Collected {
|
|
53
195
|
readonly file: string
|
|
54
196
|
readonly projectName: string
|
|
55
197
|
readonly name: string | undefined
|
|
@@ -58,54 +200,76 @@ interface Collected {
|
|
|
58
200
|
readonly gate?: string | undefined
|
|
59
201
|
}
|
|
60
202
|
|
|
61
|
-
|
|
203
|
+
/** Holds one listing's tests and whether Vitest matched the project filter it was given. */
|
|
204
|
+
export interface Listed {
|
|
205
|
+
readonly entries: readonly Collected[]
|
|
206
|
+
readonly matched: boolean
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Holds the tokens a gate command keeps and the position the option reader has reached. */
|
|
210
|
+
export interface Selection {
|
|
62
211
|
readonly selected: readonly string[]
|
|
63
212
|
readonly skip: number
|
|
64
213
|
readonly closed: boolean
|
|
65
214
|
}
|
|
66
215
|
|
|
67
|
-
|
|
216
|
+
/** Holds the gates the root scripts reach and the files `node` commands run. */
|
|
217
|
+
export interface Gates {
|
|
68
218
|
readonly units: readonly Gate[]
|
|
69
219
|
readonly files: ReadonlyMap<string, string>
|
|
70
220
|
}
|
|
71
221
|
|
|
72
|
-
|
|
222
|
+
/** Names one config, mode, root, and dir combination a listing runs under. */
|
|
223
|
+
export interface Unit {
|
|
73
224
|
readonly config: string | undefined
|
|
74
225
|
readonly mode: string | undefined
|
|
75
226
|
readonly root?: string | undefined
|
|
76
227
|
readonly dir?: string | undefined
|
|
77
228
|
}
|
|
78
229
|
|
|
79
|
-
|
|
230
|
+
/** Describes one Vitest run a root script chain reaches. */
|
|
231
|
+
export interface Gate extends Unit {
|
|
80
232
|
readonly projects: readonly string[]
|
|
81
233
|
readonly chain: string
|
|
82
234
|
readonly args: readonly string[]
|
|
83
235
|
readonly full: boolean
|
|
84
236
|
}
|
|
85
237
|
|
|
86
|
-
|
|
238
|
+
/** Holds one unit's tests with the gate that names each and the projects it lists empty. */
|
|
239
|
+
export interface Listing extends Unit {
|
|
87
240
|
readonly collected: readonly Collected[]
|
|
88
241
|
readonly empty: readonly string[]
|
|
89
242
|
}
|
|
90
243
|
|
|
91
|
-
|
|
244
|
+
/** Pairs one unit's listing with the projects that collected a test in it. */
|
|
245
|
+
export interface UnitResult {
|
|
92
246
|
readonly listing: Listing
|
|
93
247
|
readonly populated: readonly string[]
|
|
94
248
|
}
|
|
95
249
|
|
|
96
|
-
|
|
250
|
+
/** States whether one project collected a test and whether the report calls it empty. */
|
|
251
|
+
export interface ProjectResult {
|
|
97
252
|
readonly project: string
|
|
98
253
|
readonly populated: boolean
|
|
99
254
|
readonly empty: boolean
|
|
100
255
|
}
|
|
101
256
|
|
|
102
|
-
|
|
257
|
+
/** Describes one `npm` script call a command makes. */
|
|
258
|
+
export interface Invocation {
|
|
103
259
|
readonly name: string
|
|
104
260
|
readonly args: readonly string[]
|
|
105
261
|
readonly ancestors: readonly string[]
|
|
262
|
+
readonly shorthand: boolean
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Describes one script a root chain visits, with the commands its forwarded arguments extend. */
|
|
266
|
+
export interface Visit {
|
|
267
|
+
readonly chain: string
|
|
268
|
+
readonly commands: ReadonlyArray<readonly string[]>
|
|
106
269
|
}
|
|
107
270
|
|
|
108
|
-
|
|
271
|
+
/** Describes one project row the report prints. */
|
|
272
|
+
export interface Project {
|
|
109
273
|
readonly name: string
|
|
110
274
|
readonly gate: string | undefined
|
|
111
275
|
readonly files: number
|
|
@@ -113,13 +277,152 @@ interface Project {
|
|
|
113
277
|
readonly units?: readonly Unit[]
|
|
114
278
|
}
|
|
115
279
|
|
|
116
|
-
|
|
280
|
+
/** Holds one collected file with its projects and marker counts. */
|
|
281
|
+
export interface Census {
|
|
117
282
|
readonly file: string
|
|
118
283
|
readonly projects: readonly string[]
|
|
119
284
|
readonly markers: Readonly<Record<string, number>>
|
|
120
285
|
}
|
|
121
286
|
|
|
122
|
-
|
|
287
|
+
/** Describes one scanner token: a word, a punctuation mark, or a whole literal. */
|
|
288
|
+
export interface CodeToken {
|
|
289
|
+
readonly kind: 'word' | 'punct' | 'string' | 'template' | 'regex'
|
|
290
|
+
readonly text: string
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Holds the tokens of one source text and the index each opening bracket closes at. */
|
|
294
|
+
export interface CodeScan {
|
|
295
|
+
readonly tokens: readonly CodeToken[]
|
|
296
|
+
readonly closes: ReadonlyMap<number, number>
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Holds the modifier names of a registration chain and the index of its registration call. */
|
|
300
|
+
export interface Chain {
|
|
301
|
+
readonly names: readonly string[]
|
|
302
|
+
readonly call: number
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Names a run of tokens from a start index up to, and excluding, an end index. */
|
|
306
|
+
export interface Span {
|
|
307
|
+
readonly start: number
|
|
308
|
+
readonly end: number
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Normalizes a host path to forward slashes.
|
|
313
|
+
*
|
|
314
|
+
* @param path - A host path.
|
|
315
|
+
* @returns The same path with every backslash replaced by a slash.
|
|
316
|
+
*/
|
|
317
|
+
export function normalizeSlashes(path: string): string {
|
|
318
|
+
return path.split('\\').join('/')
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Reads the value that follows a flag.
|
|
323
|
+
*
|
|
324
|
+
* @remarks
|
|
325
|
+
* The first occurrence of a flag wins, a following value that opens with `--` is a missing value,
|
|
326
|
+
* and the `--flag=value` form is not read.
|
|
327
|
+
*
|
|
328
|
+
* @param argv - The arguments after the script path.
|
|
329
|
+
* @param name - The flag, such as `--config`.
|
|
330
|
+
* @returns The value after the flag, or undefined when the flag is absent, last, or followed by
|
|
331
|
+
* another flag.
|
|
332
|
+
*/
|
|
333
|
+
export function readOption(argv: readonly string[], name: string): string | undefined {
|
|
334
|
+
const index = argv.indexOf(name)
|
|
335
|
+
const value = index === -1 ? undefined : argv[index + 1]
|
|
336
|
+
return value === undefined || value.startsWith('--') ? undefined : value
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Names the value flags a caller gave with no value.
|
|
341
|
+
*
|
|
342
|
+
* @param argv - The arguments after the script path.
|
|
343
|
+
* @param names - The flags that take a value.
|
|
344
|
+
* @returns Each named flag that appears last or is followed by another flag, in argument order.
|
|
345
|
+
*/
|
|
346
|
+
export function readMissingFlags(
|
|
347
|
+
argv: readonly string[],
|
|
348
|
+
names: readonly string[],
|
|
349
|
+
): readonly string[] {
|
|
350
|
+
return names.filter((name) => argv.includes(name) && readOption(argv, name) === undefined)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Names the flags a caller gave that the script does not read.
|
|
355
|
+
*
|
|
356
|
+
* @remarks
|
|
357
|
+
* Reads the arguments before a bare `--`. A `--flag=value` argument is unknown, because the script
|
|
358
|
+
* reads `--flag value` pairs only.
|
|
359
|
+
*
|
|
360
|
+
* @param argv - The arguments after the script path.
|
|
361
|
+
* @param flags - Every flag the script reads, value flags and switches alike.
|
|
362
|
+
* @returns Each argument that opens with `--` and is not a read flag, in argument order.
|
|
363
|
+
*/
|
|
364
|
+
export function readUnknownFlags(
|
|
365
|
+
argv: readonly string[],
|
|
366
|
+
flags: readonly string[],
|
|
367
|
+
): readonly string[] {
|
|
368
|
+
const end = argv.indexOf('--')
|
|
369
|
+
return (end === -1 ? argv : argv.slice(0, end)).filter(
|
|
370
|
+
(argument) => argument.startsWith('--') && !flags.includes(argument),
|
|
371
|
+
)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Describes one file a tree walk found, with the modification time the snapshot reads. */
|
|
375
|
+
export interface WalkedFile {
|
|
376
|
+
readonly path: string
|
|
377
|
+
readonly modified: number
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Lists every regular file beneath a directory, skipping the named directories at any depth.
|
|
382
|
+
*
|
|
383
|
+
* @param directory - The directory to walk.
|
|
384
|
+
* @param skipped - The directory names to skip. Default: {@link SKIPPED_DIRECTORIES}.
|
|
385
|
+
* @returns Each file's forward-slash path and modification time, in walk order.
|
|
386
|
+
* @throws Thrown when `directory` does not exist or cannot be read; check `existsSync` first.
|
|
387
|
+
*/
|
|
388
|
+
export function listFiles(
|
|
389
|
+
directory: string,
|
|
390
|
+
skipped: ReadonlySet<string> = SKIPPED_DIRECTORIES,
|
|
391
|
+
): readonly WalkedFile[] {
|
|
392
|
+
const files: WalkedFile[] = []
|
|
393
|
+
const pending = [directory]
|
|
394
|
+
while (pending.length > 0) {
|
|
395
|
+
const current = pending.pop()
|
|
396
|
+
if (current === undefined) break
|
|
397
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
398
|
+
if (skipped.has(entry.name)) continue
|
|
399
|
+
const path = join(current, entry.name)
|
|
400
|
+
if (entry.isDirectory()) pending.push(path)
|
|
401
|
+
else if (entry.isFile()) {
|
|
402
|
+
files.push({ path: normalizeSlashes(path), modified: statSync(path).mtimeMs })
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return files
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Describes a name for an error message, JSON-encoded and length-capped.
|
|
411
|
+
*
|
|
412
|
+
* @param name - The untrusted name to echo.
|
|
413
|
+
* @returns The encoded name, cut where it exceeds the cap.
|
|
414
|
+
*/
|
|
415
|
+
export function describeName(name: string): string {
|
|
416
|
+
return JSON.stringify(name.length > NAME_LIMIT ? `${name.slice(0, NAME_LIMIT)}…` : name)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Reads the Vitest option table the installed CLI prints for its list command.
|
|
421
|
+
*
|
|
422
|
+
* @returns Each option keyed by its long, kebab, negated, and short spellings.
|
|
423
|
+
* @throws Thrown when the help command fails.
|
|
424
|
+
*/
|
|
425
|
+
export function readCLIOptions(): ReadonlyMap<string, Option> {
|
|
123
426
|
const help = spawnSync(process.execPath, [VITEST, 'list', '--help', '--expand-help'], {
|
|
124
427
|
encoding: 'utf8',
|
|
125
428
|
windowsHide: true,
|
|
@@ -142,8 +445,42 @@ function readCLIOptions(): ReadonlyMap<string, Option> {
|
|
|
142
445
|
)
|
|
143
446
|
}
|
|
144
447
|
|
|
145
|
-
|
|
146
|
-
|
|
448
|
+
/**
|
|
449
|
+
* Names the required options an option table lacks.
|
|
450
|
+
*
|
|
451
|
+
* @param options - The table `readCLIOptions` returned.
|
|
452
|
+
* @returns Each required option the table does not hold, in required order.
|
|
453
|
+
*/
|
|
454
|
+
export function readMissingOptions(options: ReadonlyMap<string, Option>): readonly string[] {
|
|
455
|
+
return REQUIRED_OPTIONS.filter((name) => !options.has(name))
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Reads the installed Vitest version.
|
|
460
|
+
*
|
|
461
|
+
* @returns The version string, or `unknown` when the manifest names none.
|
|
462
|
+
*/
|
|
463
|
+
export function readVitestVersion(): string {
|
|
464
|
+
const parsed: unknown = JSON.parse(readFileSync('node_modules/vitest/package.json', 'utf8'))
|
|
465
|
+
return typeof parsed === 'object' &&
|
|
466
|
+
parsed !== null &&
|
|
467
|
+
'version' in parsed &&
|
|
468
|
+
typeof parsed.version === 'string'
|
|
469
|
+
? parsed.version
|
|
470
|
+
: 'unknown'
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Skips the options of a `node` command so the first non-option token is the runner.
|
|
475
|
+
*
|
|
476
|
+
* @remarks
|
|
477
|
+
* An option that takes a value consumes the token after it, so that token is never read as a runner.
|
|
478
|
+
*
|
|
479
|
+
* @param command - The command tokens.
|
|
480
|
+
* @param index - The index to start reading options at.
|
|
481
|
+
* @returns The index of the first non-option token, or -1 for an eval, print, or check command.
|
|
482
|
+
*/
|
|
483
|
+
export function skipRunnerOptions(command: readonly string[], index: number): number {
|
|
147
484
|
const skipped = [...command.entries()].reduce<{
|
|
148
485
|
readonly next: number
|
|
149
486
|
readonly found: number | undefined
|
|
@@ -178,7 +515,13 @@ function skipRunnerOptions(command: readonly string[], index: number): number {
|
|
|
178
515
|
return skipped.found ?? skipped.next
|
|
179
516
|
}
|
|
180
517
|
|
|
181
|
-
|
|
518
|
+
/**
|
|
519
|
+
* Finds the Vitest runner in a command.
|
|
520
|
+
*
|
|
521
|
+
* @param command - The command tokens, past any environment prefix.
|
|
522
|
+
* @returns The runner's index, or -1 when the command does not run Vitest.
|
|
523
|
+
*/
|
|
524
|
+
export function findRunner(command: readonly string[]): number {
|
|
182
525
|
const executable = basename(command[0] ?? '').replace(/\.(?:exe|cmd)$/u, '')
|
|
183
526
|
const start =
|
|
184
527
|
executable === 'npm'
|
|
@@ -197,9 +540,18 @@ function findRunner(command: readonly string[]): number {
|
|
|
197
540
|
: -1
|
|
198
541
|
}
|
|
199
542
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
543
|
+
/**
|
|
544
|
+
* Keeps the tokens of a gate command that select tests and drops the run-mode options.
|
|
545
|
+
*
|
|
546
|
+
* @remarks
|
|
547
|
+
* The first `--` ends the options, so the rest of the arguments pass verbatim. A runner option that
|
|
548
|
+
* takes a value consumes the argument after it, so a step below `skip` reads nothing.
|
|
549
|
+
*
|
|
550
|
+
* @param argv - The tokens after the Vitest runner and verb.
|
|
551
|
+
* @param options - The option table `readCLIOptions` returned.
|
|
552
|
+
* @returns The tokens to pass to a listing.
|
|
553
|
+
*/
|
|
554
|
+
export function readSelection(
|
|
203
555
|
argv: readonly string[],
|
|
204
556
|
options: ReadonlyMap<string, Option>,
|
|
205
557
|
): readonly string[] {
|
|
@@ -230,7 +582,17 @@ function readSelection(
|
|
|
230
582
|
).selected
|
|
231
583
|
}
|
|
232
584
|
|
|
233
|
-
|
|
585
|
+
/**
|
|
586
|
+
* Reads every value a named flag carries before the first `--`.
|
|
587
|
+
*
|
|
588
|
+
* @param argv - The tokens to read.
|
|
589
|
+
* @param names - The flag spellings to read.
|
|
590
|
+
* @returns Each value of the flag in either the `--flag value` or the `--flag=value` form.
|
|
591
|
+
*/
|
|
592
|
+
export function readArguments(
|
|
593
|
+
argv: readonly string[],
|
|
594
|
+
names: readonly string[],
|
|
595
|
+
): readonly string[] {
|
|
234
596
|
const end = argv.indexOf('--')
|
|
235
597
|
return (end < 0 ? argv : argv.slice(0, end)).flatMap((token, index) => {
|
|
236
598
|
const equals = token.indexOf('=')
|
|
@@ -240,7 +602,12 @@ function readArguments(argv: readonly string[], names: readonly string[]): reado
|
|
|
240
602
|
})
|
|
241
603
|
}
|
|
242
604
|
|
|
243
|
-
|
|
605
|
+
/**
|
|
606
|
+
* Reads the script table of the package manifest in the working directory.
|
|
607
|
+
*
|
|
608
|
+
* @returns Each script name with its command text.
|
|
609
|
+
*/
|
|
610
|
+
export function readScripts(): Readonly<Record<string, string>> {
|
|
244
611
|
const parsed: unknown = JSON.parse(readFileSync('package.json', 'utf8'))
|
|
245
612
|
if (typeof parsed !== 'object' || parsed === null) return {}
|
|
246
613
|
const scripts = Object.fromEntries(Object.entries(parsed)).scripts
|
|
@@ -252,28 +619,218 @@ function readScripts(): Readonly<Record<string, string>> {
|
|
|
252
619
|
)
|
|
253
620
|
}
|
|
254
621
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
622
|
+
/**
|
|
623
|
+
* Drops the redirections from a command's tokens.
|
|
624
|
+
*
|
|
625
|
+
* @param tokens - The raw command tokens, quotes still attached.
|
|
626
|
+
* @returns The tokens without a redirection operator, its target, or a descriptor duplication.
|
|
627
|
+
*/
|
|
628
|
+
export function stripRedirections(tokens: readonly string[]): readonly string[] {
|
|
629
|
+
return tokens.filter((token, index) => {
|
|
630
|
+
const previous = tokens[index - 1]
|
|
631
|
+
return (
|
|
632
|
+
!(previous !== undefined && REDIRECT_OPERATOR_PATTERN.test(previous)) &&
|
|
633
|
+
!REDIRECT_OPERATOR_PATTERN.test(token) &&
|
|
634
|
+
!REDIRECT_TARGET_PATTERN.test(token)
|
|
635
|
+
)
|
|
636
|
+
})
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Drops the leading `cross-env` word and `NAME=value` assignments from a command's tokens.
|
|
641
|
+
*
|
|
642
|
+
* @param tokens - The command tokens.
|
|
643
|
+
* @returns The tokens from the first word that is neither, empty when the command is only a prefix.
|
|
644
|
+
*/
|
|
645
|
+
export function stripEnvironment(tokens: readonly string[]): readonly string[] {
|
|
646
|
+
const start = tokens.findIndex(
|
|
647
|
+
(token) => token !== 'cross-env' && !ENVIRONMENT_PATTERN.test(token),
|
|
258
648
|
)
|
|
649
|
+
return start < 0 ? [] : tokens.slice(start)
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Splits a script's text into its commands.
|
|
654
|
+
*
|
|
655
|
+
* @param text - The script text, with `;`, `&`, and `|` separating commands.
|
|
656
|
+
* @returns The commands as tokens, past redirections and environment prefixes, quotes removed.
|
|
657
|
+
*/
|
|
658
|
+
export function readCommands(text: string): ReadonlyArray<readonly string[]> {
|
|
659
|
+
const tokens = [...text.matchAll(COMMAND_TOKEN_PATTERN)].map((match) => match[0])
|
|
259
660
|
// A separator token ends a command, so the commands are the runs of tokens between the cuts.
|
|
260
661
|
const cuts = tokens.flatMap((token, index) => (/^[;&|]+$/u.test(token) ? [index] : []))
|
|
261
662
|
return [-1, ...cuts].map((cut, at) =>
|
|
262
|
-
tokens.slice(cut + 1, cuts[at] ?? tokens.length).map(
|
|
663
|
+
stripEnvironment(stripRedirections(tokens.slice(cut + 1, cuts[at] ?? tokens.length))).map(
|
|
664
|
+
(token) => token.replace(/["']/gu, ''),
|
|
665
|
+
),
|
|
263
666
|
)
|
|
264
667
|
}
|
|
265
668
|
|
|
266
|
-
|
|
267
|
-
|
|
669
|
+
/**
|
|
670
|
+
* Lists the script calls the commands make.
|
|
671
|
+
*
|
|
672
|
+
* @remarks
|
|
673
|
+
* Reads `npm run [-s|--silent] NAME`, `npm run-script NAME`, `npm test`, and `npm t`; the last two
|
|
674
|
+
* are shorthand, which runs a script a person also starts directly. Arguments after `--` forward.
|
|
675
|
+
*
|
|
676
|
+
* @param commands - The commands to read.
|
|
677
|
+
* @returns One invocation per script call.
|
|
678
|
+
*/
|
|
679
|
+
export function listInvocations(commands: ReadonlyArray<readonly string[]>): readonly Invocation[] {
|
|
680
|
+
return commands.flatMap((command): Invocation[] => {
|
|
268
681
|
const index = command.indexOf('npm')
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
const separator = command.indexOf('--', index +
|
|
272
|
-
|
|
682
|
+
if (index < 0) return []
|
|
683
|
+
const verb = command[index + 1]
|
|
684
|
+
const separator = command.indexOf('--', index + 2)
|
|
685
|
+
const args = separator < 0 ? [] : command.slice(separator + 1)
|
|
686
|
+
if (verb === 'test' || verb === 't') {
|
|
687
|
+
return [{ name: 'test', args, ancestors: [], shorthand: true }]
|
|
688
|
+
}
|
|
689
|
+
if (verb !== 'run' && verb !== 'run-script') return []
|
|
690
|
+
const name = (
|
|
691
|
+
separator < 0 ? command.slice(index + 2) : command.slice(index + 2, separator)
|
|
692
|
+
).find((token) => !token.startsWith('-'))
|
|
693
|
+
return name === undefined ? [] : [{ name, args, ancestors: [], shorthand: false }]
|
|
273
694
|
})
|
|
274
695
|
}
|
|
275
696
|
|
|
276
|
-
|
|
697
|
+
/**
|
|
698
|
+
* Walks the scripts a root reaches, last-pushed first, each script once per forwarded arguments.
|
|
699
|
+
*
|
|
700
|
+
* @param commands - The commands of every script, by name.
|
|
701
|
+
* @param root - The root script the chain starts at.
|
|
702
|
+
* @param pending - The invocations left to visit.
|
|
703
|
+
* @param visited - The name and argument keys already visited.
|
|
704
|
+
* @returns One visit per script reached, in visit order.
|
|
705
|
+
*/
|
|
706
|
+
export function walkInvocations(
|
|
707
|
+
commands: Readonly<Record<string, ReadonlyArray<readonly string[]>>>,
|
|
708
|
+
root: string,
|
|
709
|
+
pending: readonly Invocation[],
|
|
710
|
+
visited: ReadonlySet<string>,
|
|
711
|
+
): readonly Visit[] {
|
|
712
|
+
const invocation = pending.at(-1)
|
|
713
|
+
if (invocation === undefined) return []
|
|
714
|
+
const rest = pending.slice(0, -1)
|
|
715
|
+
const { name, args, ancestors } = invocation
|
|
716
|
+
const key = JSON.stringify([name, args])
|
|
717
|
+
if (visited.has(key) || ancestors.includes(name)) {
|
|
718
|
+
return walkInvocations(commands, root, rest, visited)
|
|
719
|
+
}
|
|
720
|
+
const seen = new Set([...visited, key])
|
|
721
|
+
const script = commands[name]
|
|
722
|
+
if (script === undefined) return walkInvocations(commands, root, rest, seen)
|
|
723
|
+
const expanded = script.map((command, index) =>
|
|
724
|
+
index === script.length - 1 ? [...command, ...args] : command,
|
|
725
|
+
)
|
|
726
|
+
const children = listInvocations(expanded).map((entry) => ({
|
|
727
|
+
...entry,
|
|
728
|
+
ancestors: [...ancestors, name],
|
|
729
|
+
}))
|
|
730
|
+
return [
|
|
731
|
+
{ chain: root === name ? name : `${root} > ${name}`, commands: expanded },
|
|
732
|
+
...walkInvocations(commands, root, [...rest, ...children], seen),
|
|
733
|
+
]
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Checks whether a command's options name a reporting or cache-maintenance invocation.
|
|
738
|
+
*
|
|
739
|
+
* @param before - The tokens before the first `--`.
|
|
740
|
+
* @returns True if a maintenance switch is present and not turned off.
|
|
741
|
+
*/
|
|
742
|
+
export function isMaintenance(before: readonly string[]): boolean {
|
|
743
|
+
return before.some(
|
|
744
|
+
(token, position) =>
|
|
745
|
+
MAINTENANCE_PATTERN.test(token) &&
|
|
746
|
+
!token.endsWith('=false') &&
|
|
747
|
+
before[position + 1] !== 'false',
|
|
748
|
+
)
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Checks whether a command's options start watch mode.
|
|
753
|
+
*
|
|
754
|
+
* @param before - The tokens before the first `--`.
|
|
755
|
+
* @returns True if `--watch` or `-w` is present and not turned off.
|
|
756
|
+
*/
|
|
757
|
+
export function isWatching(before: readonly string[]): boolean {
|
|
758
|
+
return before.some(
|
|
759
|
+
(token, position) => WATCH_PATTERN.test(token) && before[position + 1] !== 'false',
|
|
760
|
+
)
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Reads the gate a command makes, or none when the command does not run a test selection.
|
|
765
|
+
*
|
|
766
|
+
* @param command - The command tokens.
|
|
767
|
+
* @param chain - The root chain label.
|
|
768
|
+
* @param config - The base config path, forward-slashed and relative.
|
|
769
|
+
* @param options - The option table `readCLIOptions` returned.
|
|
770
|
+
* @returns The gate, or undefined for a command that is no Vitest run or no gate.
|
|
771
|
+
* @throws Thrown for a `related` command, which `vitest list` cannot select.
|
|
772
|
+
*/
|
|
773
|
+
export function readGate(
|
|
774
|
+
command: readonly string[],
|
|
775
|
+
chain: string,
|
|
776
|
+
config: string,
|
|
777
|
+
options: ReadonlyMap<string, Option>,
|
|
778
|
+
): Gate | undefined {
|
|
779
|
+
const index = findRunner(command)
|
|
780
|
+
if (index < 0) return undefined
|
|
781
|
+
const verb = command[index + 1]
|
|
782
|
+
if (verb === 'list' || verb === 'bench' || verb === 'watch' || verb === 'dev') return undefined
|
|
783
|
+
const tokens = command.slice(index + (verb === 'run' ? 2 : 1))
|
|
784
|
+
const separator = tokens.indexOf('--')
|
|
785
|
+
const before = separator < 0 ? tokens : tokens.slice(0, separator)
|
|
786
|
+
if (isMaintenance(before) || isWatching(before)) return undefined
|
|
787
|
+
if (verb === 'related') {
|
|
788
|
+
throw new Error(`${chain}: Vitest list exposes no related command or --related option`)
|
|
789
|
+
}
|
|
790
|
+
const flags = readSelection(tokens, options)
|
|
791
|
+
const mode = readArguments(flags, ['--mode'])[0]
|
|
792
|
+
const directory = readArguments(flags, ['--root', '-r'])[0]
|
|
793
|
+
const dir = readArguments(flags, ['--dir'])[0]
|
|
794
|
+
const configured = readArguments(flags, ['--config', '-c'])[0]
|
|
795
|
+
const path = configured ?? (directory === undefined && dir === undefined ? config : undefined)
|
|
796
|
+
return {
|
|
797
|
+
config:
|
|
798
|
+
path === undefined ? undefined : normalizeSlashes(relative(process.cwd(), resolve(path))),
|
|
799
|
+
mode: mode === 'test' ? undefined : mode,
|
|
800
|
+
...(directory === undefined ? {} : { root: directory }),
|
|
801
|
+
...(dir === undefined ? {} : { dir }),
|
|
802
|
+
projects: readArguments(flags, ['--project']),
|
|
803
|
+
chain,
|
|
804
|
+
args: configured === undefined && path !== undefined ? ['--config', path, ...flags] : flags,
|
|
805
|
+
full: before.some((flag) => FULL_PATTERN.test(flag) || /:\d+(?:-\d+)?$/u.test(flag)),
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Reads the test files a `node` command runs as its arguments.
|
|
811
|
+
*
|
|
812
|
+
* @param command - The command tokens.
|
|
813
|
+
* @returns The test file arguments of `node FILE` or `node --test FILE...`; none for any other runner.
|
|
814
|
+
*/
|
|
815
|
+
export function readFileCredits(command: readonly string[]): readonly string[] {
|
|
816
|
+
if (basename(command[0] ?? '').replace(/\.exe$/u, '') !== 'node') return []
|
|
817
|
+
const first = skipRunnerOptions(command, 1)
|
|
818
|
+
if (first < 0) return []
|
|
819
|
+
const candidates = command.includes('--test')
|
|
820
|
+
? command.slice(first)
|
|
821
|
+
: command.slice(first, first + 1)
|
|
822
|
+
return candidates.filter((argument) => TEST_ARGUMENT_PATTERN.test(argument))
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Reads the gates and node-run test files every root script chain reaches.
|
|
827
|
+
*
|
|
828
|
+
* @param scripts - The package scripts by name.
|
|
829
|
+
* @param config - The base config path, forward-slashed and relative.
|
|
830
|
+
* @param options - The option table `readCLIOptions` returned.
|
|
831
|
+
* @returns The gates in visit order, and the earliest chain that runs each test file with `node`.
|
|
832
|
+
*/
|
|
833
|
+
export function readGates(
|
|
277
834
|
scripts: Readonly<Record<string, string>>,
|
|
278
835
|
config: string,
|
|
279
836
|
options: ReadonlyMap<string, Option>,
|
|
@@ -281,102 +838,158 @@ function readGates(
|
|
|
281
838
|
const commands = Object.fromEntries(
|
|
282
839
|
Object.entries(scripts).map(([name, text]) => [name, readCommands(text)]),
|
|
283
840
|
)
|
|
841
|
+
// A shorthand call (`npm test`) runs a script a person also starts directly, so it stays a root.
|
|
284
842
|
const invoked = new Set(
|
|
285
843
|
Object.values(commands)
|
|
286
844
|
.flatMap(listInvocations)
|
|
845
|
+
.filter((entry) => !entry.shorthand)
|
|
287
846
|
.map((entry) => entry.name),
|
|
288
847
|
)
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
848
|
+
const visits = Object.keys(scripts)
|
|
849
|
+
.filter((name) => !invoked.has(name))
|
|
850
|
+
.flatMap((root) =>
|
|
851
|
+
walkInvocations(
|
|
852
|
+
commands,
|
|
853
|
+
root,
|
|
854
|
+
[{ name: root, args: [], ancestors: [], shorthand: false }],
|
|
855
|
+
new Set(),
|
|
856
|
+
),
|
|
857
|
+
)
|
|
858
|
+
const units = visits.flatMap((visit) =>
|
|
859
|
+
visit.commands.flatMap((command) => {
|
|
860
|
+
const gate = readGate(command, visit.chain, config, options)
|
|
861
|
+
return gate === undefined ? [] : [gate]
|
|
862
|
+
}),
|
|
863
|
+
)
|
|
864
|
+
// A Map keeps the last entry of a key, so the reversed credits leave the earliest chain.
|
|
865
|
+
const files = new Map(
|
|
866
|
+
visits
|
|
867
|
+
.flatMap((visit) =>
|
|
868
|
+
visit.commands.flatMap((command) =>
|
|
869
|
+
findRunner(command) >= 0
|
|
870
|
+
? []
|
|
871
|
+
: readFileCredits(command).map((file): [string, string] => [file, visit.chain]),
|
|
872
|
+
),
|
|
305
873
|
)
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
}
|
|
309
|
-
const gate = root === name ? name : `${root} > ${name}`
|
|
310
|
-
for (const command of expanded) {
|
|
311
|
-
const index = findRunner(command)
|
|
312
|
-
if (index >= 0) {
|
|
313
|
-
const verb = command[index + 1]
|
|
314
|
-
if (verb === 'list' || verb === 'bench') continue
|
|
315
|
-
const tokens = command.slice(
|
|
316
|
-
index + (['run', 'watch', 'dev'].includes(verb ?? '') ? 2 : 1),
|
|
317
|
-
)
|
|
318
|
-
const separator = tokens.indexOf('--')
|
|
319
|
-
const before = separator < 0 ? tokens : tokens.slice(0, separator)
|
|
320
|
-
if (
|
|
321
|
-
before.some(
|
|
322
|
-
(token, position) =>
|
|
323
|
-
/^(?:--mergeReports|--merge-reports|--listTags|--list-tags|--clearCache|--clear-cache)(?:=|$)/u.test(
|
|
324
|
-
token,
|
|
325
|
-
) &&
|
|
326
|
-
!token.endsWith('=false') &&
|
|
327
|
-
before[position + 1] !== 'false',
|
|
328
|
-
)
|
|
329
|
-
)
|
|
330
|
-
continue
|
|
331
|
-
if (verb === 'related')
|
|
332
|
-
throw new Error(`${gate}: Vitest list exposes no related command or --related option`)
|
|
333
|
-
const flags = readSelection(tokens, options)
|
|
334
|
-
const mode = readArguments(flags, ['--mode'])[0]
|
|
335
|
-
const directory = readArguments(flags, ['--root', '-r'])[0]
|
|
336
|
-
const dir = readArguments(flags, ['--dir'])[0]
|
|
337
|
-
const configured = readArguments(flags, ['--config', '-c'])[0]
|
|
338
|
-
const path =
|
|
339
|
-
configured ?? (directory === undefined && dir === undefined ? config : undefined)
|
|
340
|
-
units.push({
|
|
341
|
-
config:
|
|
342
|
-
path === undefined
|
|
343
|
-
? undefined
|
|
344
|
-
: relative(process.cwd(), resolve(path)).split('\\').join('/'),
|
|
345
|
-
mode: mode === 'test' ? undefined : mode,
|
|
346
|
-
...(directory === undefined ? {} : { root: directory }),
|
|
347
|
-
...(dir === undefined ? {} : { dir }),
|
|
348
|
-
projects: readArguments(flags, ['--project']),
|
|
349
|
-
chain: gate,
|
|
350
|
-
args:
|
|
351
|
-
configured === undefined && path !== undefined ? ['--config', path, ...flags] : flags,
|
|
352
|
-
full: before.some(
|
|
353
|
-
(flag) =>
|
|
354
|
-
/^(?:-t|--testNamePattern|--test-name-pattern|--tagsFilter|--tags-filter|--shard|--(?:no-)?allowOnly|--(?:no-)?allow-only|--(?:no-)?strictTags|--(?:no-)?strict-tags)(?:=|$)/u.test(
|
|
355
|
-
flag,
|
|
356
|
-
) || /:\d+(?:-\d+)?$/u.test(flag),
|
|
357
|
-
),
|
|
358
|
-
})
|
|
359
|
-
continue
|
|
360
|
-
}
|
|
361
|
-
for (const argument of command) {
|
|
362
|
-
if (
|
|
363
|
-
/^(?:tests|src|app)\/[A-Za-z0-9_./-]+\.test\.[cm]?ts$/u.test(argument) &&
|
|
364
|
-
!files.has(argument)
|
|
365
|
-
)
|
|
366
|
-
files.set(argument, gate)
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
}
|
|
874
|
+
.toReversed(),
|
|
875
|
+
)
|
|
371
876
|
return { units, files }
|
|
372
877
|
}
|
|
373
878
|
|
|
374
|
-
|
|
879
|
+
/**
|
|
880
|
+
* Checks whether a project name is a literal name and not a Vitest pattern.
|
|
881
|
+
*
|
|
882
|
+
* @param name - The project name or pattern.
|
|
883
|
+
* @returns True if the name holds no `*` and does not open with `!`.
|
|
884
|
+
*/
|
|
885
|
+
export function isConcreteName(name: string): boolean {
|
|
886
|
+
return !name.includes('*') && !name.startsWith('!')
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Checks whether a name matches a wildcard pattern the way Vitest matches a project filter.
|
|
891
|
+
*
|
|
892
|
+
* @param pattern - The pattern, where `*` matches any run of characters.
|
|
893
|
+
* @param name - The name to test.
|
|
894
|
+
* @returns True if the whole name matches, ignoring case.
|
|
895
|
+
*/
|
|
896
|
+
export function matchesWildcard(pattern: string, name: string): boolean {
|
|
897
|
+
const body = pattern
|
|
898
|
+
.split('*')
|
|
899
|
+
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'))
|
|
900
|
+
.join('.*')
|
|
901
|
+
return new RegExp(`^${body}$`, 'iu').test(name)
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* Checks whether a gate's project filters reach a project name.
|
|
906
|
+
*
|
|
907
|
+
* @remarks
|
|
908
|
+
* A browser instance name such as `web (chromium)` is also tested by its base name, because Vitest
|
|
909
|
+
* keeps every instance of a project whose base name a positive filter matches and drops them all
|
|
910
|
+
* when a negative filter matches it.
|
|
911
|
+
*
|
|
912
|
+
* @param filters - The gate's `--project` values, where a leading `!` negates.
|
|
913
|
+
* @param name - The concrete project name.
|
|
914
|
+
* @returns True if no negative filter matches and some positive filter does, or none is positive.
|
|
915
|
+
*/
|
|
916
|
+
export function matchesProjectFilter(filters: readonly string[], name: string): boolean {
|
|
917
|
+
const names = [name, name.replace(/ \([^)]*\)$/u, '')]
|
|
918
|
+
const negative = filters
|
|
919
|
+
.filter((filter) => filter.startsWith('!'))
|
|
920
|
+
.map((filter) => filter.slice(1))
|
|
921
|
+
const positive = filters.filter((filter) => !filter.startsWith('!'))
|
|
922
|
+
if (negative.some((pattern) => names.some((candidate) => matchesWildcard(pattern, candidate)))) {
|
|
923
|
+
return false
|
|
924
|
+
}
|
|
925
|
+
return (
|
|
926
|
+
positive.length === 0 ||
|
|
927
|
+
positive.some((pattern) => names.some((candidate) => matchesWildcard(pattern, candidate)))
|
|
928
|
+
)
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* Replaces the `--project` flags of a gate's arguments.
|
|
933
|
+
*
|
|
934
|
+
* @param args - The gate arguments.
|
|
935
|
+
* @param projects - The project names to pass in place of the existing flags.
|
|
936
|
+
* @returns The arguments with one `--project NAME` pair per name, before any bare `--`.
|
|
937
|
+
*/
|
|
938
|
+
export function replaceProjects(
|
|
939
|
+
args: readonly string[],
|
|
940
|
+
projects: readonly string[],
|
|
941
|
+
): readonly string[] {
|
|
942
|
+
const end = args.indexOf('--')
|
|
943
|
+
const head = end < 0 ? args : args.slice(0, end)
|
|
944
|
+
const tail = end < 0 ? [] : args.slice(end)
|
|
945
|
+
const kept = head.filter(
|
|
946
|
+
(token, index) =>
|
|
947
|
+
token !== '--project' && !token.startsWith('--project=') && head[index - 1] !== '--project',
|
|
948
|
+
)
|
|
949
|
+
return [...kept, ...projects.flatMap((project) => ['--project', project]), ...tail]
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* Narrows the gates to the named projects, so no listing runs for a project outside the names.
|
|
954
|
+
*
|
|
955
|
+
* @remarks
|
|
956
|
+
* A gate with no project filter lists the named projects alone. A gate that names projects lists the
|
|
957
|
+
* named ones its filters reach and is dropped when it reaches none. A pattern among the names
|
|
958
|
+
* (`*`, or a leading `!`) cannot be matched beforehand, so a gate that names projects keeps them.
|
|
959
|
+
*
|
|
960
|
+
* @param gates - The gates the root chains reach.
|
|
961
|
+
* @param named - The `--projects` names, empty for no narrowing.
|
|
962
|
+
* @returns The gates to list, each with its narrowed arguments.
|
|
963
|
+
*/
|
|
964
|
+
export function narrowGates(gates: readonly Gate[], named: readonly string[]): readonly Gate[] {
|
|
965
|
+
if (named.length === 0) return gates
|
|
966
|
+
const concrete = named.filter(isConcreteName)
|
|
967
|
+
const patterned = concrete.length < named.length
|
|
968
|
+
return gates.flatMap((gate): Gate[] => {
|
|
969
|
+
if (gate.projects.length === 0) return [{ ...gate, args: replaceProjects(gate.args, named) }]
|
|
970
|
+
if (patterned) return [gate]
|
|
971
|
+
const reached = concrete.filter((name) => matchesProjectFilter(gate.projects, name))
|
|
972
|
+
return reached.length === 0 ? [] : [{ ...gate, args: replaceProjects(gate.args, reached) }]
|
|
973
|
+
})
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* Lists the tests one Vitest listing collects.
|
|
978
|
+
*
|
|
979
|
+
* @param args - The arguments to pass after `vitest list`.
|
|
980
|
+
* @param full - True to collect each test with its name and location; false for files only.
|
|
981
|
+
* @param cache - The listings already read, keyed by their arguments.
|
|
982
|
+
* @returns The tests and whether the project filter matched, or undefined when Vitest refuses.
|
|
983
|
+
*/
|
|
984
|
+
export function listCollected(
|
|
375
985
|
args: readonly string[],
|
|
376
986
|
full: boolean,
|
|
377
|
-
cache: Map<string,
|
|
378
|
-
):
|
|
379
|
-
const parameters = [
|
|
987
|
+
cache: Map<string, Listed>,
|
|
988
|
+
): Listed | undefined {
|
|
989
|
+
const parameters = [
|
|
990
|
+
...(full ? ['--includeTaskLocation', '--staticParse=false'] : ['--filesOnly']),
|
|
991
|
+
...args,
|
|
992
|
+
]
|
|
380
993
|
const key = JSON.stringify(parameters)
|
|
381
994
|
const cached = cache.get(key)
|
|
382
995
|
if (cached !== undefined) return cached
|
|
@@ -396,10 +1009,12 @@ function listCollected(
|
|
|
396
1009
|
if (
|
|
397
1010
|
args.includes('--project') &&
|
|
398
1011
|
result.status === 1 &&
|
|
399
|
-
result.stderr.includes('No projects matched the filter "')
|
|
1012
|
+
(result.stderr.includes('No projects matched the filter "') ||
|
|
1013
|
+
result.stderr.includes('The filter matched no projects:'))
|
|
400
1014
|
) {
|
|
401
|
-
|
|
402
|
-
|
|
1015
|
+
const unmatched = { entries: [], matched: false }
|
|
1016
|
+
cache.set(key, unmatched)
|
|
1017
|
+
return unmatched
|
|
403
1018
|
}
|
|
404
1019
|
if (result.status !== 0 || !existsSync(listing)) {
|
|
405
1020
|
const diagnostic = result.stderr || result.error?.message || result.stdout
|
|
@@ -430,7 +1045,7 @@ function listCollected(
|
|
|
430
1045
|
}
|
|
431
1046
|
return [
|
|
432
1047
|
{
|
|
433
|
-
file: relative(process.cwd(), record.file)
|
|
1048
|
+
file: normalizeSlashes(relative(process.cwd(), record.file)),
|
|
434
1049
|
projectName: record.projectName ?? '',
|
|
435
1050
|
name: typeof record.name === 'string' ? record.name : undefined,
|
|
436
1051
|
line: typeof location.line === 'number' ? location.line : undefined,
|
|
@@ -438,8 +1053,9 @@ function listCollected(
|
|
|
438
1053
|
},
|
|
439
1054
|
]
|
|
440
1055
|
})
|
|
441
|
-
|
|
442
|
-
|
|
1056
|
+
const listed = { entries: collected, matched: true }
|
|
1057
|
+
cache.set(key, listed)
|
|
1058
|
+
return listed
|
|
443
1059
|
} catch (error) {
|
|
444
1060
|
console.error(error instanceof Error ? error.message : String(error))
|
|
445
1061
|
return undefined
|
|
@@ -448,83 +1064,314 @@ function listCollected(
|
|
|
448
1064
|
}
|
|
449
1065
|
}
|
|
450
1066
|
|
|
451
|
-
|
|
1067
|
+
/**
|
|
1068
|
+
* Lists the test and spec files beneath a directory.
|
|
1069
|
+
*
|
|
1070
|
+
* @param directory - The directory to walk; a missing directory holds none.
|
|
1071
|
+
* @returns The forward-slash paths of every test or spec file, skipping only `node_modules` and `.git`.
|
|
1072
|
+
*/
|
|
1073
|
+
export function listTestFiles(directory: string): readonly string[] {
|
|
452
1074
|
if (!existsSync(directory)) return []
|
|
453
|
-
return listFiles(directory)
|
|
1075
|
+
return listFiles(directory, UNDISCOVERED_SKIPS)
|
|
454
1076
|
.map((file) => file.path)
|
|
455
|
-
.filter((path) =>
|
|
1077
|
+
.filter((path) => DISCOVERABLE_PATTERN.test(path))
|
|
456
1078
|
}
|
|
457
1079
|
|
|
458
|
-
|
|
459
|
-
|
|
1080
|
+
/**
|
|
1081
|
+
* Scans source text into tokens that tell code from comments and strings.
|
|
1082
|
+
*
|
|
1083
|
+
* @remarks
|
|
1084
|
+
* Comments are dropped. A string, a template (whole, with its expressions), and a regular expression
|
|
1085
|
+
* literal each become one token. A slash opens a regular expression after an operator, an opening
|
|
1086
|
+
* bracket, or a keyword, and divides after a word, a literal, or a closing bracket. The scanner
|
|
1087
|
+
* pairs each opening bracket with its closing bracket as it reads.
|
|
1088
|
+
*/
|
|
1089
|
+
export class CodeScanner {
|
|
1090
|
+
readonly #text: string
|
|
1091
|
+
readonly #tokens: CodeToken[] = []
|
|
1092
|
+
readonly #closes = new Map<number, number>()
|
|
1093
|
+
readonly #opened: number[] = []
|
|
1094
|
+
#position = 0
|
|
1095
|
+
#last: CodeToken | undefined = undefined
|
|
1096
|
+
|
|
1097
|
+
constructor(text: string) {
|
|
1098
|
+
this.#text = text
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
scan(): CodeScan {
|
|
1102
|
+
while (this.#position < this.#text.length) {
|
|
1103
|
+
const token = this.#read()
|
|
1104
|
+
if (token === undefined) continue
|
|
1105
|
+
if (token.kind === 'punct' && OPENERS.includes(token.text)) {
|
|
1106
|
+
this.#opened.push(this.#tokens.length)
|
|
1107
|
+
}
|
|
1108
|
+
if (token.kind === 'punct' && CLOSERS.includes(token.text)) {
|
|
1109
|
+
const open = this.#opened.pop()
|
|
1110
|
+
if (open !== undefined) this.#closes.set(open, this.#tokens.length)
|
|
1111
|
+
}
|
|
1112
|
+
this.#tokens.push(token)
|
|
1113
|
+
}
|
|
1114
|
+
return { tokens: this.#tokens, closes: this.#closes }
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
#read(): CodeToken | undefined {
|
|
1118
|
+
const rest = this.#text.slice(this.#position)
|
|
1119
|
+
const skipped = /^(?:\s+|\/\/[^\r\n]*|\/\*[\s\S]*?(?:\*\/|$))/u.exec(rest)
|
|
1120
|
+
if (skipped !== null) {
|
|
1121
|
+
this.#position += skipped[0].length
|
|
1122
|
+
return undefined
|
|
1123
|
+
}
|
|
1124
|
+
if (rest.startsWith('`')) return this.#take('template', this.#readTemplate())
|
|
1125
|
+
const quoted = STRING_PATTERN.exec(rest)
|
|
1126
|
+
if (quoted !== null) return this.#take('string', quoted[0])
|
|
1127
|
+
if (rest.startsWith('/') && this.#expectsRegex()) {
|
|
1128
|
+
const regex = REGEX_PATTERN.exec(rest)
|
|
1129
|
+
if (regex !== null) return this.#take('regex', regex[0])
|
|
1130
|
+
}
|
|
1131
|
+
const word = WORD_PATTERN.exec(rest)
|
|
1132
|
+
if (word !== null) return this.#take('word', word[0])
|
|
1133
|
+
return this.#take('punct', rest.slice(0, 1))
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
#take(kind: CodeToken['kind'], text: string): CodeToken {
|
|
1137
|
+
if (kind !== 'template') this.#position += text.length
|
|
1138
|
+
const token = { kind, text }
|
|
1139
|
+
this.#last = token
|
|
1140
|
+
return token
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
#expectsRegex(): boolean {
|
|
1144
|
+
const last = this.#last
|
|
1145
|
+
if (last === undefined) return true
|
|
1146
|
+
if (last.kind === 'punct') return !CLOSERS.includes(last.text)
|
|
1147
|
+
if (last.kind === 'word') return REGEX_KEYWORDS.has(last.text)
|
|
1148
|
+
return false
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
#readTemplate(): string {
|
|
1152
|
+
const start = this.#position
|
|
1153
|
+
this.#position += 1
|
|
1154
|
+
while (this.#position < this.#text.length) {
|
|
1155
|
+
const character = this.#text[this.#position]
|
|
1156
|
+
if (character === '\\') {
|
|
1157
|
+
this.#position += 2
|
|
1158
|
+
} else if (character === '`') {
|
|
1159
|
+
this.#position += 1
|
|
1160
|
+
return this.#text.slice(start, this.#position)
|
|
1161
|
+
} else if (this.#text.startsWith('${', this.#position)) {
|
|
1162
|
+
this.#position += 2
|
|
1163
|
+
this.#skipExpression()
|
|
1164
|
+
} else {
|
|
1165
|
+
this.#position += 1
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
return this.#text.slice(start, this.#position)
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
#skipExpression(): void {
|
|
1172
|
+
while (this.#position < this.#text.length) {
|
|
1173
|
+
const token = this.#read()
|
|
1174
|
+
if (token?.kind !== 'punct') continue
|
|
1175
|
+
if (token.text === '}') return
|
|
1176
|
+
if (token.text === '{') this.#skipExpression()
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/**
|
|
1182
|
+
* Checks whether a token is a punctuation mark with the given text.
|
|
1183
|
+
*
|
|
1184
|
+
* @param token - The token, or undefined past the end.
|
|
1185
|
+
* @param text - The punctuation text.
|
|
1186
|
+
* @returns True if the token is that punctuation mark.
|
|
1187
|
+
*/
|
|
1188
|
+
export function isPunct(token: CodeToken | undefined, text: string): boolean {
|
|
1189
|
+
return token?.kind === 'punct' && token.text === text
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/**
|
|
1193
|
+
* Reads the modifier chain after a registrar, up to its registration call.
|
|
1194
|
+
*
|
|
1195
|
+
* @param scan - The scanned source.
|
|
1196
|
+
* @param at - The index after the registrar or the last chain element.
|
|
1197
|
+
* @param names - The modifier names read so far; an empty name marks a consumed argument group.
|
|
1198
|
+
* @returns The names and the index of the registration call's opening parenthesis, or undefined when
|
|
1199
|
+
* the tokens are no registration call.
|
|
1200
|
+
*/
|
|
1201
|
+
export function readChain(scan: CodeScan, at: number, names: readonly string[]): Chain | undefined {
|
|
1202
|
+
const token = scan.tokens[at]
|
|
1203
|
+
if (token === undefined) return undefined
|
|
1204
|
+
const last = names.at(-1) ?? ''
|
|
1205
|
+
if (isPunct(token, '.')) {
|
|
1206
|
+
const name = scan.tokens[at + 1]
|
|
1207
|
+
return name?.kind === 'word' ? readChain(scan, at + 2, [...names, name.text]) : undefined
|
|
1208
|
+
}
|
|
1209
|
+
if (token.kind === 'template' && ARGUMENT_MODIFIERS.has(last)) {
|
|
1210
|
+
return readChain(scan, at + 1, [...names, ''])
|
|
1211
|
+
}
|
|
1212
|
+
if (!isPunct(token, '(')) return undefined
|
|
1213
|
+
if (!ARGUMENT_MODIFIERS.has(last)) return { names, call: at }
|
|
1214
|
+
const close = scan.closes.get(at)
|
|
1215
|
+
return close === undefined ? undefined : readChain(scan, close + 1, [...names, ''])
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/**
|
|
1219
|
+
* Splits a token range into its top-level arguments, jumping over bracket groups.
|
|
1220
|
+
*
|
|
1221
|
+
* @param scan - The scanned source.
|
|
1222
|
+
* @param start - The index the current argument starts at.
|
|
1223
|
+
* @param at - The index being read.
|
|
1224
|
+
* @param end - The index the range ends at, excluded.
|
|
1225
|
+
* @returns One span per non-empty argument.
|
|
1226
|
+
*/
|
|
1227
|
+
export function splitArguments(
|
|
1228
|
+
scan: CodeScan,
|
|
1229
|
+
start: number,
|
|
1230
|
+
at: number,
|
|
1231
|
+
end: number,
|
|
1232
|
+
): readonly Span[] {
|
|
1233
|
+
if (at >= end) return start < end ? [{ start, end }] : []
|
|
1234
|
+
const token = scan.tokens[at]
|
|
1235
|
+
if (token?.kind === 'punct' && OPENERS.includes(token.text)) {
|
|
1236
|
+
return splitArguments(scan, start, (scan.closes.get(at) ?? at) + 1, end)
|
|
1237
|
+
}
|
|
1238
|
+
if (isPunct(token, ',')) {
|
|
1239
|
+
return [{ start, end: at }, ...splitArguments(scan, at + 1, at + 1, end)]
|
|
1240
|
+
}
|
|
1241
|
+
return splitArguments(scan, start, at + 1, end)
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/**
|
|
1245
|
+
* Reads the property keys at the top level of an object literal's token range.
|
|
1246
|
+
*
|
|
1247
|
+
* @param scan - The scanned source.
|
|
1248
|
+
* @param at - The index being read.
|
|
1249
|
+
* @param end - The index the range ends at, excluded.
|
|
1250
|
+
* @returns Each key that follows `{` or `,` and precedes `:`, `,`, or `}`, quotes removed.
|
|
1251
|
+
*/
|
|
1252
|
+
export function readKeys(scan: CodeScan, at: number, end: number): readonly string[] {
|
|
1253
|
+
const token = scan.tokens[at]
|
|
1254
|
+
if (token === undefined || at >= end) return []
|
|
1255
|
+
if (token.kind === 'punct' && OPENERS.includes(token.text)) {
|
|
1256
|
+
return readKeys(scan, (scan.closes.get(at) ?? at) + 1, end)
|
|
1257
|
+
}
|
|
1258
|
+
const previous = scan.tokens[at - 1]
|
|
1259
|
+
const following = scan.tokens[at + 1]
|
|
1260
|
+
const spelled =
|
|
1261
|
+
token.kind === 'word'
|
|
1262
|
+
? token.text
|
|
1263
|
+
: token.kind === 'string'
|
|
1264
|
+
? token.text.slice(1, -1)
|
|
1265
|
+
: undefined
|
|
1266
|
+
const isKey =
|
|
1267
|
+
spelled !== undefined &&
|
|
1268
|
+
(isPunct(previous, '{') || isPunct(previous, ',')) &&
|
|
1269
|
+
(isPunct(following, ':') || isPunct(following, ',') || isPunct(following, '}'))
|
|
1270
|
+
return [...(isKey ? [spelled] : []), ...readKeys(scan, at + 1, end)]
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/**
|
|
1274
|
+
* Reads the retry and timeout markers a registration's arguments carry.
|
|
1275
|
+
*
|
|
1276
|
+
* @param scan - The scanned source.
|
|
1277
|
+
* @param spans - The registration call's top-level arguments.
|
|
1278
|
+
* @returns A `timeout` entry for a numeric last argument after the title, and an entry for each
|
|
1279
|
+
* `retry` or `timeout` key of an object-literal argument.
|
|
1280
|
+
*/
|
|
1281
|
+
export function readArgumentMarkers(scan: CodeScan, spans: readonly Span[]): readonly string[] {
|
|
1282
|
+
const last = spans.at(-1)
|
|
1283
|
+
const lastToken = last === undefined ? undefined : scan.tokens[last.start]
|
|
1284
|
+
const numeric =
|
|
1285
|
+
spans.length >= 2 &&
|
|
1286
|
+
last !== undefined &&
|
|
1287
|
+
last.end - last.start === 1 &&
|
|
1288
|
+
lastToken?.kind === 'word' &&
|
|
1289
|
+
NUMBER_PATTERN.test(lastToken.text)
|
|
1290
|
+
const keys = spans.flatMap((span) =>
|
|
1291
|
+
isPunct(scan.tokens[span.start], '{') && scan.closes.get(span.start) === span.end - 1
|
|
1292
|
+
? readKeys(scan, span.start + 1, span.end - 1)
|
|
1293
|
+
: [],
|
|
1294
|
+
)
|
|
1295
|
+
return [
|
|
1296
|
+
...(numeric ? ['timeout'] : []),
|
|
1297
|
+
...keys.filter((key) => key === 'retry' || key === 'timeout'),
|
|
1298
|
+
]
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/**
|
|
1302
|
+
* Reads the markers one registration carries.
|
|
1303
|
+
*
|
|
1304
|
+
* @param scan - The scanned source.
|
|
1305
|
+
* @param index - The index of the registrar identifier.
|
|
1306
|
+
* @returns The chain markers (`.todo`, `.skipIf`, `.runIf`, `.fails`) and the argument markers.
|
|
1307
|
+
*/
|
|
1308
|
+
export function readHits(scan: CodeScan, index: number): readonly string[] {
|
|
1309
|
+
const chain = readChain(scan, index + 1, [])
|
|
1310
|
+
if (chain === undefined) return []
|
|
1311
|
+
const close = scan.closes.get(chain.call)
|
|
1312
|
+
const spans =
|
|
1313
|
+
close === undefined ? [] : splitArguments(scan, chain.call + 1, chain.call + 1, close)
|
|
1314
|
+
return [
|
|
1315
|
+
...chain.names.filter((name) => CHAIN_MARKERS.has(name)).map((name) => `.${name}`),
|
|
1316
|
+
...readArgumentMarkers(scan, spans),
|
|
1317
|
+
]
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
/**
|
|
1321
|
+
* Counts the markers a scanned test file carries, as call forms outside comments and strings.
|
|
1322
|
+
*
|
|
1323
|
+
* @param scan - The scanned source.
|
|
1324
|
+
* @returns Each marker with a nonzero count, in marker order.
|
|
1325
|
+
*/
|
|
1326
|
+
export function readMarkers(scan: CodeScan): Readonly<Record<string, number>> {
|
|
1327
|
+
const hits = scan.tokens.flatMap((token, index) =>
|
|
1328
|
+
token.kind === 'word' && REGISTRARS.has(token.text) && !isPunct(scan.tokens[index - 1], '.')
|
|
1329
|
+
? readHits(scan, index)
|
|
1330
|
+
: [],
|
|
1331
|
+
)
|
|
460
1332
|
return Object.fromEntries(
|
|
461
|
-
MARKERS.map((marker) => [marker,
|
|
1333
|
+
MARKERS.map((marker) => [marker, hits.filter((hit) => hit === marker).length] as const).filter(
|
|
462
1334
|
([, count]) => count > 0,
|
|
463
1335
|
),
|
|
464
1336
|
)
|
|
465
1337
|
}
|
|
466
1338
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
base,
|
|
476
|
-
...gates.units
|
|
477
|
-
.filter(
|
|
478
|
-
(gate, index) =>
|
|
479
|
-
![base, ...gates.units.slice(0, index)].some((unit) => isSameUnit(unit, gate)),
|
|
480
|
-
)
|
|
481
|
-
.map((gate) => ({
|
|
482
|
-
config: gate.config,
|
|
483
|
-
mode: gate.mode,
|
|
484
|
-
...(gate.root === undefined ? {} : { root: gate.root }),
|
|
485
|
-
...(gate.dir === undefined ? {} : { dir: gate.dir }),
|
|
486
|
-
})),
|
|
487
|
-
]
|
|
488
|
-
// A listing is a memo keyed by its arguments: each key is written once, on its first miss.
|
|
489
|
-
const cache = new Map<string, readonly Collected[]>()
|
|
490
|
-
const readings = collectReadings(units, (unit) => readUnit(unit, named, gates, cache))
|
|
491
|
-
if (readings === undefined) return undefined
|
|
492
|
-
const listings = readings.map(({ listing }) => listing)
|
|
493
|
-
const populated = new Set(readings.flatMap((reading) => reading.populated))
|
|
494
|
-
// A project empty under one unit may still list tests under another, so each unit is asked for it.
|
|
495
|
-
const rescans = collectReadings(
|
|
496
|
-
[...new Set(listings.flatMap((listing) => listing.empty))]
|
|
497
|
-
.filter((name) => !populated.has(name))
|
|
498
|
-
.flatMap((name) => units.map((unit) => ({ name, unit }))),
|
|
499
|
-
({ name, unit }) => {
|
|
500
|
-
const selection = listCollected([...readUnitArguments(unit), '--project', name], false, cache)
|
|
501
|
-
return selection === undefined ? undefined : { name, listed: selection.length > 0 }
|
|
502
|
-
},
|
|
503
|
-
)
|
|
504
|
-
if (rescans === undefined) return undefined
|
|
505
|
-
const resolved = new Set([
|
|
506
|
-
...populated,
|
|
507
|
-
...rescans.filter(({ listed }) => listed).map(({ name }) => name),
|
|
508
|
-
])
|
|
509
|
-
return listings.map((listing) => ({
|
|
510
|
-
...listing,
|
|
511
|
-
empty: listing.empty.filter((name) => !resolved.has(name)),
|
|
512
|
-
}))
|
|
1339
|
+
/**
|
|
1340
|
+
* Counts the markers of a test file on disk.
|
|
1341
|
+
*
|
|
1342
|
+
* @param file - The test file path.
|
|
1343
|
+
* @returns Each marker with a nonzero count.
|
|
1344
|
+
*/
|
|
1345
|
+
export function countMarkers(file: string): Readonly<Record<string, number>> {
|
|
1346
|
+
return readMarkers(new CodeScanner(readFileSync(file, 'utf8')).scan())
|
|
513
1347
|
}
|
|
514
1348
|
|
|
515
|
-
|
|
516
|
-
|
|
1349
|
+
/**
|
|
1350
|
+
* Runs one read per item in order and stops at the first refusal, so no later read spawns.
|
|
1351
|
+
*
|
|
1352
|
+
* @param items - The items to read.
|
|
1353
|
+
* @param read - The read for one item; undefined refuses.
|
|
1354
|
+
* @returns The readings in order, or undefined once any read refuses.
|
|
1355
|
+
*/
|
|
1356
|
+
export function collectResults<Item, Value>(
|
|
517
1357
|
items: readonly Item[],
|
|
518
|
-
read: (item: Item) =>
|
|
519
|
-
): readonly
|
|
520
|
-
return items.reduce<readonly
|
|
521
|
-
if (
|
|
522
|
-
const
|
|
523
|
-
return
|
|
1358
|
+
read: (item: Item) => Value | undefined,
|
|
1359
|
+
): readonly Value[] | undefined {
|
|
1360
|
+
return items.reduce<readonly Value[] | undefined>((results, item) => {
|
|
1361
|
+
if (results === undefined) return undefined
|
|
1362
|
+
const result = read(item)
|
|
1363
|
+
return result === undefined ? undefined : [...results, result]
|
|
524
1364
|
}, [])
|
|
525
1365
|
}
|
|
526
1366
|
|
|
527
|
-
|
|
1367
|
+
/**
|
|
1368
|
+
* Checks whether two units run under the same config, mode, root, and dir.
|
|
1369
|
+
*
|
|
1370
|
+
* @param left - The first unit.
|
|
1371
|
+
* @param right - The second unit.
|
|
1372
|
+
* @returns True if every field is equal.
|
|
1373
|
+
*/
|
|
1374
|
+
export function isSameUnit(left: Unit, right: Unit): boolean {
|
|
528
1375
|
return (
|
|
529
1376
|
left.config === right.config &&
|
|
530
1377
|
left.mode === right.mode &&
|
|
@@ -533,30 +1380,47 @@ function isSameUnit(left: Unit, right: Unit): boolean {
|
|
|
533
1380
|
)
|
|
534
1381
|
}
|
|
535
1382
|
|
|
536
|
-
|
|
1383
|
+
/**
|
|
1384
|
+
* Builds the arguments that select a unit's config, mode, root, and dir.
|
|
1385
|
+
*
|
|
1386
|
+
* @param unit - The unit.
|
|
1387
|
+
* @returns The arguments to pass to `vitest list`.
|
|
1388
|
+
*/
|
|
1389
|
+
export function readUnitArguments(unit: Unit): readonly string[] {
|
|
1390
|
+
return [
|
|
1391
|
+
...(unit.config === undefined ? [] : ['--config', unit.config]),
|
|
1392
|
+
...(unit.mode === undefined ? [] : ['--mode', unit.mode]),
|
|
1393
|
+
...(unit.root === undefined ? [] : ['--root', unit.root]),
|
|
1394
|
+
...(unit.dir === undefined ? [] : ['--dir', unit.dir]),
|
|
1395
|
+
]
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
/**
|
|
1399
|
+
* Reads one unit's universe, gate selections, and project readings.
|
|
1400
|
+
*
|
|
1401
|
+
* @param unit - The unit to read.
|
|
1402
|
+
* @param named - The `--projects` names; the universe and every listing carry them when present.
|
|
1403
|
+
* @param gates - The narrowed gates.
|
|
1404
|
+
* @param cache - The listings already read.
|
|
1405
|
+
* @returns The unit's reading, or undefined when a listing refuses.
|
|
1406
|
+
*/
|
|
1407
|
+
export function readUnit(
|
|
537
1408
|
unit: Unit,
|
|
538
1409
|
named: readonly string[],
|
|
539
1410
|
gates: Gates,
|
|
540
|
-
cache: Map<string,
|
|
541
|
-
):
|
|
1411
|
+
cache: Map<string, Listed>,
|
|
1412
|
+
): UnitResult | undefined {
|
|
542
1413
|
const reached = gates.units.filter((gate) => isSameUnit(gate, unit))
|
|
543
1414
|
const parameters = readUnitArguments(unit)
|
|
544
|
-
const
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
: listCollected(
|
|
550
|
-
[...parameters, ...named.flatMap((name) => ['--project', name])],
|
|
551
|
-
false,
|
|
552
|
-
cache,
|
|
553
|
-
)
|
|
554
|
-
if (named.length > 0 && scope === undefined) return undefined
|
|
555
|
-
const selected = collectReadings(reached, (gate) => {
|
|
1415
|
+
const universeArguments = [...parameters, ...named.flatMap((name) => ['--project', name])]
|
|
1416
|
+
const universe = listCollected(universeArguments, true, cache)
|
|
1417
|
+
if (universe === undefined) return undefined
|
|
1418
|
+
const collected = universe.entries
|
|
1419
|
+
const selected = collectResults(reached, (gate) => {
|
|
556
1420
|
const selection =
|
|
557
|
-
JSON.stringify(gate.args) === JSON.stringify(
|
|
1421
|
+
JSON.stringify(gate.args) === JSON.stringify(universeArguments)
|
|
558
1422
|
? collected
|
|
559
|
-
: listCollected(gate.args, gate.full, cache)
|
|
1423
|
+
: listCollected(gate.args, gate.full, cache)?.entries
|
|
560
1424
|
if (selection === undefined) {
|
|
561
1425
|
console.error(`discovery: gate ${gate.chain}: vitest list refused ${gate.args.join(' ')}`)
|
|
562
1426
|
return undefined
|
|
@@ -575,11 +1439,10 @@ function readUnit(
|
|
|
575
1439
|
}
|
|
576
1440
|
})
|
|
577
1441
|
if (selected === undefined) return undefined
|
|
578
|
-
const
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
)
|
|
582
|
-
(project): ProjectReading | undefined => {
|
|
1442
|
+
const candidates = named.length === 0 ? reached.flatMap((gate) => gate.projects) : named
|
|
1443
|
+
const projects = collectResults(
|
|
1444
|
+
[...new Set(candidates)].filter(isConcreteName),
|
|
1445
|
+
(project): ProjectResult | undefined => {
|
|
583
1446
|
if (
|
|
584
1447
|
selected.some(
|
|
585
1448
|
({ gate, identities }) =>
|
|
@@ -592,60 +1455,133 @@ function readUnit(
|
|
|
592
1455
|
if (selection === undefined) return undefined
|
|
593
1456
|
return {
|
|
594
1457
|
project,
|
|
595
|
-
populated: selection.length > 0,
|
|
1458
|
+
populated: selection.entries.length > 0,
|
|
596
1459
|
empty:
|
|
597
|
-
selection.length === 0 &&
|
|
1460
|
+
selection.entries.length === 0 &&
|
|
598
1461
|
(reached.some((gate) => gate.projects.includes(project)) || named.includes(project)) &&
|
|
599
1462
|
(named.length === 0 || named.includes(project)),
|
|
600
1463
|
}
|
|
601
1464
|
},
|
|
602
1465
|
)
|
|
603
|
-
if (
|
|
1466
|
+
if (projects === undefined) return undefined
|
|
604
1467
|
return {
|
|
605
1468
|
listing: {
|
|
606
1469
|
...unit,
|
|
607
|
-
collected: collected
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
entry.file,
|
|
621
|
-
entry.projectName,
|
|
622
|
-
...(gate.full ? [entry.name, entry.line, entry.column] : []),
|
|
623
|
-
]),
|
|
624
|
-
),
|
|
625
|
-
)?.gate.chain,
|
|
626
|
-
})),
|
|
627
|
-
empty: readings.filter(({ empty }) => empty).map(({ project }) => project),
|
|
1470
|
+
collected: collected.map((entry) => ({
|
|
1471
|
+
...entry,
|
|
1472
|
+
gate: selected.find(({ gate, identities }) =>
|
|
1473
|
+
identities.has(
|
|
1474
|
+
JSON.stringify([
|
|
1475
|
+
entry.file,
|
|
1476
|
+
entry.projectName,
|
|
1477
|
+
...(gate.full ? [entry.name, entry.line, entry.column] : []),
|
|
1478
|
+
]),
|
|
1479
|
+
),
|
|
1480
|
+
)?.gate.chain,
|
|
1481
|
+
})),
|
|
1482
|
+
empty: projects.filter(({ empty }) => empty).map(({ project }) => project),
|
|
628
1483
|
},
|
|
629
|
-
populated:
|
|
1484
|
+
populated: projects.filter(({ populated }) => populated).map(({ project }) => project),
|
|
630
1485
|
}
|
|
631
1486
|
}
|
|
632
1487
|
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
1488
|
+
/**
|
|
1489
|
+
* Reads the listing of every unit the narrowed gates reach.
|
|
1490
|
+
*
|
|
1491
|
+
* @param config - The base config path.
|
|
1492
|
+
* @param named - The `--projects` names.
|
|
1493
|
+
* @param gates - The narrowed gates.
|
|
1494
|
+
* @returns One listing per unit, or undefined when a listing refuses.
|
|
1495
|
+
*/
|
|
1496
|
+
export function collectListings(
|
|
1497
|
+
config: string,
|
|
1498
|
+
named: readonly string[],
|
|
1499
|
+
gates: Gates,
|
|
1500
|
+
): readonly Listing[] | undefined {
|
|
1501
|
+
const base: Unit = { config, mode: undefined }
|
|
1502
|
+
// A gate joins the units unless the base unit or an earlier gate already names the same unit.
|
|
1503
|
+
const units: readonly Unit[] = [
|
|
1504
|
+
base,
|
|
1505
|
+
...gates.units
|
|
1506
|
+
.filter(
|
|
1507
|
+
(gate, index) =>
|
|
1508
|
+
![base, ...gates.units.slice(0, index)].some((unit) => isSameUnit(unit, gate)),
|
|
1509
|
+
)
|
|
1510
|
+
.map((gate) => ({
|
|
1511
|
+
config: gate.config,
|
|
1512
|
+
mode: gate.mode,
|
|
1513
|
+
...(gate.root === undefined ? {} : { root: gate.root }),
|
|
1514
|
+
...(gate.dir === undefined ? {} : { dir: gate.dir }),
|
|
1515
|
+
})),
|
|
639
1516
|
]
|
|
1517
|
+
// A listing is a memo keyed by its arguments: each key is written once, on its first miss.
|
|
1518
|
+
const cache = new Map<string, Listed>()
|
|
1519
|
+
const results = collectResults(units, (unit) => readUnit(unit, named, gates, cache))
|
|
1520
|
+
if (results === undefined) return undefined
|
|
1521
|
+
const listings = results.map(({ listing }) => listing)
|
|
1522
|
+
const populated = new Set(results.flatMap((result) => result.populated))
|
|
1523
|
+
// A project empty under one unit may still list tests under another, so each unit is asked for it.
|
|
1524
|
+
const rescans = collectResults(
|
|
1525
|
+
[...new Set(listings.flatMap((listing) => listing.empty))]
|
|
1526
|
+
.filter((name) => !populated.has(name))
|
|
1527
|
+
.flatMap((name) => units.map((unit) => ({ name, unit }))),
|
|
1528
|
+
({ name, unit }) => {
|
|
1529
|
+
const selection = listCollected([...readUnitArguments(unit), '--project', name], false, cache)
|
|
1530
|
+
return selection === undefined ? undefined : { name, listed: selection.entries.length > 0 }
|
|
1531
|
+
},
|
|
1532
|
+
)
|
|
1533
|
+
if (rescans === undefined) return undefined
|
|
1534
|
+
const resolved = new Set([
|
|
1535
|
+
...populated,
|
|
1536
|
+
...rescans.filter(({ listed }) => listed).map(({ name }) => name),
|
|
1537
|
+
])
|
|
1538
|
+
return listings.map((listing) => ({
|
|
1539
|
+
...listing,
|
|
1540
|
+
empty: listing.empty.filter((name) => !resolved.has(name)),
|
|
1541
|
+
}))
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
/**
|
|
1545
|
+
* Names the `--projects` entries the base config's projects do not match.
|
|
1546
|
+
*
|
|
1547
|
+
* @param unit - The base unit.
|
|
1548
|
+
* @param named - The `--projects` names.
|
|
1549
|
+
* @param cache - The listings already read.
|
|
1550
|
+
* @returns The names Vitest matched to no project, or undefined when a listing refuses.
|
|
1551
|
+
*/
|
|
1552
|
+
export function readUnmatched(
|
|
1553
|
+
unit: Unit,
|
|
1554
|
+
named: readonly string[],
|
|
1555
|
+
cache: Map<string, Listed>,
|
|
1556
|
+
): readonly string[] | undefined {
|
|
1557
|
+
const matches = collectResults(named, (name) => {
|
|
1558
|
+
const listed = listCollected([...readUnitArguments(unit), '--project', name], false, cache)
|
|
1559
|
+
return listed === undefined ? undefined : { name, matched: listed.matched }
|
|
1560
|
+
})
|
|
1561
|
+
return matches?.filter(({ matched }) => !matched).map(({ name }) => name)
|
|
640
1562
|
}
|
|
641
1563
|
|
|
642
|
-
|
|
1564
|
+
/**
|
|
1565
|
+
* Identifies a collected test by its file, project, name, and location.
|
|
1566
|
+
*
|
|
1567
|
+
* @param entry - The collected test.
|
|
1568
|
+
* @returns A key two listings share for the same test.
|
|
1569
|
+
*/
|
|
1570
|
+
export function identifyCollected(entry: Collected): string {
|
|
643
1571
|
return JSON.stringify([entry.file, entry.projectName, entry.name, entry.line, entry.column])
|
|
644
1572
|
}
|
|
645
1573
|
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
1574
|
+
/**
|
|
1575
|
+
* Merges the tests of several listings into one list.
|
|
1576
|
+
*
|
|
1577
|
+
* @remarks
|
|
1578
|
+
* The same test listed twice in one listing is two occurrences, and the same occurrence listed by
|
|
1579
|
+
* two listings is one test: the last listing's fields win, and the gate is the last one any named.
|
|
1580
|
+
*
|
|
1581
|
+
* @param listings - The listings to merge.
|
|
1582
|
+
* @returns The merged tests.
|
|
1583
|
+
*/
|
|
1584
|
+
export function mergeCollected(listings: readonly Listing[]): readonly Collected[] {
|
|
649
1585
|
const keyed = listings.flatMap((listing) => {
|
|
650
1586
|
const positions = new Map(
|
|
651
1587
|
[...Map.groupBy(listing.collected, identifyCollected).values()].flatMap((group) =>
|
|
@@ -670,7 +1606,13 @@ function mergeCollected(listings: readonly Listing[]): readonly Collected[] {
|
|
|
670
1606
|
})
|
|
671
1607
|
}
|
|
672
1608
|
|
|
673
|
-
|
|
1609
|
+
/**
|
|
1610
|
+
* Reads the modification times of every test file the census can collect.
|
|
1611
|
+
*
|
|
1612
|
+
* @param gates - The narrowed gates, whose roots and dirs add watched directories.
|
|
1613
|
+
* @returns A JSON text two reads share when no test file changed.
|
|
1614
|
+
*/
|
|
1615
|
+
export function readSnapshot(gates: Gates): string {
|
|
674
1616
|
const bases = [
|
|
675
1617
|
process.cwd(),
|
|
676
1618
|
...gates.units.flatMap((gate) =>
|
|
@@ -683,7 +1625,7 @@ function readSnapshot(gates: Gates): string {
|
|
|
683
1625
|
[...roots].flatMap((root) =>
|
|
684
1626
|
existsSync(root)
|
|
685
1627
|
? listFiles(root)
|
|
686
|
-
.filter((file) =>
|
|
1628
|
+
.filter((file) => TEST_FILE_PATTERN.test(file.path))
|
|
687
1629
|
.map((file) => [file.path, file.modified] as const)
|
|
688
1630
|
: [],
|
|
689
1631
|
),
|
|
@@ -691,7 +1633,17 @@ function readSnapshot(gates: Gates): string {
|
|
|
691
1633
|
return JSON.stringify([...files].sort(([left], [right]) => left.localeCompare(right)))
|
|
692
1634
|
}
|
|
693
1635
|
|
|
694
|
-
|
|
1636
|
+
/**
|
|
1637
|
+
* Reads the listings, repeating once when a test file changed during the first read.
|
|
1638
|
+
*
|
|
1639
|
+
* @param config - The base config path.
|
|
1640
|
+
* @param named - The `--projects` names.
|
|
1641
|
+
* @param gates - The narrowed gates.
|
|
1642
|
+
* @param attempt - The attempt number, zero for the first.
|
|
1643
|
+
* @returns One listing per unit, or undefined when a listing refuses.
|
|
1644
|
+
* @throws Thrown when test files changed during both attempts.
|
|
1645
|
+
*/
|
|
1646
|
+
export function collectStableListings(
|
|
695
1647
|
config: string,
|
|
696
1648
|
named: readonly string[],
|
|
697
1649
|
gates: Gates,
|
|
@@ -704,7 +1656,20 @@ function collectStableListings(
|
|
|
704
1656
|
return collectStableListings(config, named, gates, attempt + 1)
|
|
705
1657
|
}
|
|
706
1658
|
|
|
707
|
-
|
|
1659
|
+
/**
|
|
1660
|
+
* Runs the census and prints its report.
|
|
1661
|
+
*
|
|
1662
|
+
* @param argv - The arguments after the script path.
|
|
1663
|
+
* @returns The exit code: 0 no flag, 3 a flag, 2 a tool failure, 64 usage.
|
|
1664
|
+
*/
|
|
1665
|
+
export function main(argv: readonly string[]): number {
|
|
1666
|
+
const unknown = readUnknownFlags(argv, FLAGS)
|
|
1667
|
+
if (unknown.length > 0) {
|
|
1668
|
+
console.error(
|
|
1669
|
+
`discovery: ${unknown.map(describeName).join(', ')} is not a flag this script reads; give each value as its own argument. ${USAGE}`,
|
|
1670
|
+
)
|
|
1671
|
+
return 64
|
|
1672
|
+
}
|
|
708
1673
|
const missing = readMissingFlags(argv, ['--config', '--projects'])
|
|
709
1674
|
if (missing.length > 0) {
|
|
710
1675
|
console.error(`discovery: ${missing.join(', ')} given with no value`)
|
|
@@ -712,16 +1677,42 @@ function main(argv: readonly string[]): number {
|
|
|
712
1677
|
}
|
|
713
1678
|
const config = readOption(argv, '--config') ?? 'vite.config.ts'
|
|
714
1679
|
if (!existsSync(config) || !existsSync('package.json')) {
|
|
715
|
-
console.error(
|
|
1680
|
+
console.error(USAGE)
|
|
716
1681
|
return 64
|
|
717
1682
|
}
|
|
718
1683
|
if (!existsSync(VITEST)) {
|
|
719
1684
|
console.error(`discovery: ${VITEST} is missing; run npm ci first`)
|
|
720
1685
|
return 2
|
|
721
1686
|
}
|
|
722
|
-
const canonical = relative(process.cwd(), resolve(config))
|
|
723
|
-
const
|
|
724
|
-
const
|
|
1687
|
+
const canonical = normalizeSlashes(relative(process.cwd(), resolve(config)))
|
|
1688
|
+
const options = readCLIOptions()
|
|
1689
|
+
const absent = readMissingOptions(options)
|
|
1690
|
+
if (absent.length > 0) {
|
|
1691
|
+
console.error(
|
|
1692
|
+
`discovery: Vitest ${readVitestVersion()} lists no ${absent.join(', ')} option; the help layout this script reads has changed`,
|
|
1693
|
+
)
|
|
1694
|
+
return 2
|
|
1695
|
+
}
|
|
1696
|
+
const named = [
|
|
1697
|
+
...new Set((readOption(argv, '--projects') ?? '').split(',').filter((name) => name !== '')),
|
|
1698
|
+
]
|
|
1699
|
+
const read = readGates(readScripts(), canonical, options)
|
|
1700
|
+
if (named.length > 0) {
|
|
1701
|
+
const base: Unit = { config: canonical, mode: undefined }
|
|
1702
|
+
const cache = new Map<string, Listed>()
|
|
1703
|
+
const unmatched = readUnmatched(base, named, cache)
|
|
1704
|
+
if (unmatched === undefined) {
|
|
1705
|
+
console.error('discovery: vitest list failed')
|
|
1706
|
+
return 2
|
|
1707
|
+
}
|
|
1708
|
+
if (unmatched.length > 0) {
|
|
1709
|
+
console.error(
|
|
1710
|
+
`discovery: --projects ${unmatched.map(describeName).join(', ')} matches no project in ${describeName(canonical)}; run without --projects to list every project that collects tests, since naming them here would collect the projects outside the set`,
|
|
1711
|
+
)
|
|
1712
|
+
return 64
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
const gates: Gates = { ...read, units: narrowGates(read.units, named) }
|
|
725
1716
|
const listings = collectStableListings(canonical, named, gates)
|
|
726
1717
|
if (listings === undefined) {
|
|
727
1718
|
console.error('discovery: vitest list failed')
|
|
@@ -821,9 +1812,11 @@ function main(argv: readonly string[]): number {
|
|
|
821
1812
|
return flagged ? 3 : 0
|
|
822
1813
|
}
|
|
823
1814
|
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
1815
|
+
if (process.argv[1] !== undefined && realpathSync(process.argv[1]) === import.meta.filename) {
|
|
1816
|
+
try {
|
|
1817
|
+
process.exitCode = main(process.argv.slice(2))
|
|
1818
|
+
} catch (error) {
|
|
1819
|
+
console.error(`discovery: ${error instanceof Error ? error.message : String(error)}`)
|
|
1820
|
+
process.exitCode = 2
|
|
1821
|
+
}
|
|
829
1822
|
}
|