@webjsdev/cli 0.10.58 → 0.10.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,7 +33,7 @@ npx @webjsdev/cli create my-app
33
33
  cd my-app && npm run dev
34
34
  ```
35
35
 
36
- Both `webjs create` and `create-webjs-app` auto-install dependencies in the new directory using your detected package manager (npm / pnpm / yarn / bun). Pass `--no-install` to opt out.
36
+ Both `webjs create` and `create-webjs-app` auto-install dependencies in the new directory using your detected package manager (npm / pnpm / yarn / bun: the invoking tool, else the enclosing project's lockfile). Pass `--no-install` to opt out.
37
37
 
38
38
  ## Commands
39
39
 
package/bin/webjs.js CHANGED
@@ -103,6 +103,8 @@ const USAGE = `webjs commands:
103
103
  --json emits the structured results (with stable codes). --strict additionally fails on every remaining warning.
104
104
  Per-check severity is CONFIG: map a code to off/warn/error under "webjs": { "doctor": { "gate": {...} } }
105
105
  in package.json, so CI gates on a chosen subset without every warning becoming fatal
106
+ webjs audit [--json] Run the npm / bun dependency audit, failing at "webjs": { "audit": { "level" } } (default high)
107
+ and above, minus the advisories "webjs.audit.ignore" accepts, each with its reason
106
108
  webjs types Generate .webjs/routes.d.ts (typed Route union + per-route params)
107
109
  webjs typecheck [tsc args...] Type-check the app with the project's tsc --noEmit (non-zero on errors)
108
110
  webjs create <name> [--template full-stack|api] [--db sqlite|postgres] [--runtime node|bun] [--no-install] [--skip-ci] Scaffold a new webjs app
@@ -226,6 +228,22 @@ const HELP = {
226
228
  ],
227
229
  examples: ['webjs elision', 'webjs elision --json', 'webjs elision --verify', 'webjs elision --verify --routes /,/blog/hello'],
228
230
  },
231
+ audit: {
232
+ usage: 'webjs audit [--json]',
233
+ summary:
234
+ 'Run the package manager\'s dependency audit (npm or bun, found by the nearest lockfile) and fail on any advisory at or above ' +
235
+ 'webjs.audit.level (default high), except the ones webjs.audit.ignore lists, each with the reason it is safe to accept.',
236
+ options: [
237
+ { flag: '--json', description: 'Emit { ok, manager, level, failing[], ignored[], stale[] } as JSON.' },
238
+ ],
239
+ notes: [
240
+ 'Config lives in package.json, in one place:',
241
+ '"webjs": { "audit": { "level": "high", "ignore": [{ "id": "GHSA-...", "reason": "..." }] } }.',
242
+ 'An entry with no reason, an unknown key, or a bad level exits 1 naming it. An ignored id',
243
+ 'the audit no longer reports is printed as stale, so the list shrinks when a fix ships.',
244
+ ],
245
+ examples: ['webjs audit', 'webjs audit --json'],
246
+ },
229
247
  doctor: {
230
248
  usage: 'webjs doctor [--json] [--strict]',
231
249
  summary: 'Verify project health. Each result carries a stable code so an agent branches on the failure kind.',
@@ -482,6 +500,14 @@ async function main() {
482
500
  // too, but that runs too late to affect the port the CLI computes.
483
501
  loadAppEnv(process.cwd());
484
502
  const port = resolvePort(flag(rest, '--port'));
503
+ // Exit with the supervisor (#1521): when its IPC channel closes, the
504
+ // parent is gone (killed outright, or crashed), and a child left
505
+ // running would hold the port against the next `webjs dev`. Unref'd
506
+ // so the channel itself never keeps this process alive.
507
+ if (process.connected) {
508
+ process.channel?.unref?.();
509
+ process.on('disconnect', () => process.exit(0));
510
+ }
485
511
  await startServer({ appDir: process.cwd(), port, dev: true });
486
512
  break;
487
513
  }
@@ -502,22 +528,21 @@ async function main() {
502
528
  loadAppEnv(process.cwd());
503
529
  await runPhaseBeforeSteps('dev', devTasks.dev.before, process.cwd());
504
530
  const killTasks = await startDevParallelTasks(devTasks.dev.parallel, process.cwd());
505
- process.on('SIGINT', () => { killTasks(); process.exit(0); });
506
- process.on('SIGTERM', () => { killTasks(); process.exit(0); });
507
531
 
508
- // Decide how to run: in-process (`--no-hot`), or re-exec'd under the host
509
- // runtime's hot-reload supervisor (`node --watch` on Node, `bun --hot` on
510
- // Bun, #514). The branch logic lives in the pure `planDevSupervisor` so it
511
- // is unit-testable without spawning a process.
512
- const { existsSync } = await import('node:fs');
532
+ // Decide how to run: in-process (`--no-hot`), or in a child under WebJs's
533
+ // reload supervisor (#1521), which restarts it on a change on Node and
534
+ // runs it under `bun --hot` on Bun (#514), and brings a crashed child
535
+ // back on either. The branch logic lives in the pure `planDevSupervisor`
536
+ // so it is unit-testable without spawning a process.
513
537
  const plan = planDevSupervisor({
514
538
  isBun: !!process.versions.bun,
515
539
  argv: process.argv.slice(1),
516
540
  noHot: rest.includes('--no-hot'),
517
- exists: (p) => existsSync(p),
518
541
  });
519
542
 
520
543
  if (plan.mode === 'inline') {
544
+ process.on('SIGINT', () => { killTasks(); process.exit(0); });
545
+ process.on('SIGTERM', () => { killTasks(); process.exit(0); });
521
546
  const { startServer } = await import('@webjsdev/server');
522
547
  loadAppEnv(process.cwd());
523
548
  const port = resolvePort(flag(rest, '--port'));
@@ -526,12 +551,13 @@ async function main() {
526
551
  break;
527
552
  }
528
553
 
529
- const child = spawn(process.execPath, plan.args, {
530
- stdio: 'inherit',
554
+ const { superviseDevServer } = await import('../lib/dev-reload.js');
555
+ superviseDevServer({
531
556
  cwd: process.cwd(),
557
+ plan,
532
558
  env: { ...process.env, __WEBJS_DEV_CHILD: '1' },
559
+ onExit: (code) => { killTasks(); process.exit(code); },
533
560
  });
534
- child.on('exit', (code) => { killTasks(); process.exit(code ?? 0); });
535
561
  break;
536
562
  }
537
563
  case 'start': {
@@ -749,7 +775,32 @@ async function main() {
749
775
  const useBrowserDir = !hasConfig && !serverOnly && existsSync(join(cwd, 'test', 'browser'));
750
776
  // Only resolve + run when there is actually something to run, so a
751
777
  // `webjs test` with no browser tests stays a no-op (not a hard error).
778
+ // Zero browser test files is a pass, not a failure (#1491). WTR throws
779
+ // `Could not find any test files` on an empty match, so an app with no
780
+ // browser tests yet (what `gallery:clear` leaves) would fail its own CI
781
+ // on this layer. Check the globs first and skip with a note. `null`
782
+ // patterns mean the config's `files` is not a plain literal, so WTR
783
+ // runs unchanged and reports for itself.
784
+ let noBrowserTests = false;
752
785
  if (hasConfig || useBrowserDir) {
786
+ const { readFile } = await import('node:fs/promises');
787
+ const { readWtrFilePatterns, findBrowserTestFiles } = await import('../lib/browser-test-files.js');
788
+ let patterns = ['test/browser/**/*.test.js'];
789
+ if (hasConfig) {
790
+ const cfg = existsSync(join(cwd, 'web-test-runner.config.js'))
791
+ ? 'web-test-runner.config.js' : 'web-test-runner.config.mjs';
792
+ patterns = readWtrFilePatterns(await readFile(join(cwd, cfg), 'utf8'));
793
+ }
794
+ if (patterns && (await findBrowserTestFiles(cwd, patterns)).length === 0) {
795
+ noBrowserTests = true;
796
+ console.log(
797
+ '\nwebjs test: no browser tests yet (no file matches '
798
+ + (patterns.length ? patterns.join(', ') : 'the configured `files`')
799
+ + '), skipping the browser layer.',
800
+ );
801
+ }
802
+ }
803
+ if ((hasConfig || useBrowserDir) && !noBrowserTests) {
753
804
  // Resolve the app's @web/test-runner bin and spawn it with the current
754
805
  // runtime, dropping `npx` (#570; absent in a pure oven/bun image).
755
806
  let wtrPath;
@@ -977,6 +1028,60 @@ async function main() {
977
1028
  }
978
1029
  break;
979
1030
  }
1031
+ case 'audit': {
1032
+ // Dependency audit with a reviewable allowlist (#1492). npm has no ignore
1033
+ // flag, so both managers' JSON reports are parsed and filtered the same
1034
+ // way here (see lib/audit.js for why each part fails closed).
1035
+ const cwd = process.cwd();
1036
+ const json = rest.includes('--json');
1037
+ const { readAuditConfigFrom, detectAuditManager, parseAuditReport, applyAuditConfig } = await import('../lib/audit.js');
1038
+ const { config, errors } = readAuditConfigFrom(cwd);
1039
+ if (errors.length) {
1040
+ for (const e of errors) console.error(`webjs audit: ${e}`);
1041
+ process.exit(1);
1042
+ }
1043
+ const pm = detectAuditManager(cwd);
1044
+ if (pm !== 'npm' && pm !== 'bun') {
1045
+ console.error(`webjs audit: drives npm and bun audits; this project uses ${pm}. Run \`${pm} audit\` directly.`);
1046
+ process.exit(1);
1047
+ }
1048
+ const { spawnSync } = await import('node:child_process');
1049
+ const res = spawnSync(pm, ['audit', '--json'], {
1050
+ cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, shell: process.platform === 'win32',
1051
+ });
1052
+ const advisories = res.error ? null : parseAuditReport(pm, res.stdout || '');
1053
+ if (!advisories) {
1054
+ console.error(`webjs audit: \`${pm} audit --json\` produced no report${res.error ? ` (${res.error.message})` : ''}.`);
1055
+ if (res.stderr) console.error(res.stderr.trim());
1056
+ process.exit(1);
1057
+ }
1058
+ const { failing, ignored, stale } = applyAuditConfig(advisories, config);
1059
+ const reasonOf = (id) => config.ignore.find((i) => i.id.toUpperCase() === id.toUpperCase())?.reason || '';
1060
+ if (json) {
1061
+ console.log(JSON.stringify({
1062
+ ok: failing.length === 0, manager: pm, level: config.level, failing,
1063
+ ignored: ignored.map((a) => ({ ...a, reason: reasonOf(a.id) })), stale,
1064
+ }, null, 2));
1065
+ process.exit(failing.length ? 1 : 0);
1066
+ }
1067
+ console.log(`webjs audit: ${pm} audit, failing at ${config.level} and above`);
1068
+ for (const a of ignored) {
1069
+ console.log(` ignored ${a.id} (${a.severity}, ${a.packages.join(', ')}): ${reasonOf(a.id)}`);
1070
+ }
1071
+ for (const i of stale) {
1072
+ console.log(` stale ${i.id} is no longer reported; remove it from webjs.audit.ignore`);
1073
+ }
1074
+ if (failing.length) {
1075
+ for (const a of failing) {
1076
+ console.log(` FAIL ${a.id} (${a.severity}, ${a.packages.join(', ')}): ${a.title}${a.url ? ` ${a.url}` : ''}`);
1077
+ }
1078
+ console.log(`\nwebjs audit: ${failing.length} advisor${failing.length === 1 ? 'y' : 'ies'} at ${config.level} or above. ` +
1079
+ 'Upgrade the dependency, or, when no patched release exists and it cannot reach users, add it to webjs.audit.ignore with a reason.');
1080
+ process.exit(1);
1081
+ }
1082
+ console.log(`webjs audit: no ${config.level}+ advisories outside the allowlist ✓`);
1083
+ break;
1084
+ }
980
1085
  case 'doctor': {
981
1086
  // Project-health checklist (#266). The checks are PURE (in lib/doctor.js);
982
1087
  // this branch only renders them and owns the exit code. The exit is
package/lib/audit.js ADDED
@@ -0,0 +1,218 @@
1
+ /**
2
+ * `webjs audit`: the dependency audit with a reviewable allowlist (#1492).
3
+ *
4
+ * A freshly generated app used to fail its own `Security: dependency audit`
5
+ * CI step on the day it was created, because the dev toolchain reaches an
6
+ * advisory with NO patched release (braces, GHSA-vfj7-8cjw-p6xm, through the
7
+ * Tailwind watcher and the test runner's globber). Nothing can be upgraded,
8
+ * so the step was red until someone deleted it, which also throws away the
9
+ * signal for every advisory that DOES have a fix.
10
+ *
11
+ * So the app declares the advisories it accepts, in ONE place, each with the
12
+ * reason it is safe, under `webjs.audit` in package.json:
13
+ *
14
+ * "audit": {
15
+ * "level": "high",
16
+ * "ignore": [{ "id": "GHSA-...", "reason": "why this cannot reach users" }]
17
+ * }
18
+ *
19
+ * and `webjs audit` runs the package manager's own audit (`npm audit --json`
20
+ * or `bun audit --json`) and filters that report here, the same way for both,
21
+ * since npm has no ignore flag at all. Every other advisory at or above
22
+ * `level` still fails.
23
+ *
24
+ * The config fails CLOSED, like the doctor gate: an entry without an id or a
25
+ * reason, an unknown key, or a bad level exits 1 naming the problem, because
26
+ * an allowlist that silently stopped applying (or silently applied to
27
+ * everything) would be worse than none. An ignored id that no longer appears in
28
+ * the report is printed as stale, so the list shrinks when upstream ships a fix.
29
+ *
30
+ * @module audit
31
+ */
32
+ import { existsSync, readFileSync } from 'node:fs';
33
+ import { dirname, join } from 'node:path';
34
+
35
+ export const AUDIT_LEVELS = ['low', 'moderate', 'high', 'critical'];
36
+
37
+ /**
38
+ * @typedef {{ id: string, reason: string }} AuditIgnore
39
+ * @typedef {{ level: string, ignore: AuditIgnore[] }} AuditConfig
40
+ * @typedef {{ id: string, url: string, severity: string, title: string, packages: string[] }} Advisory
41
+ */
42
+
43
+ /**
44
+ * Read and validate `webjs.audit` from a parsed package.json.
45
+ *
46
+ * @param {Record<string, any>} pkg
47
+ * @returns {{ config: AuditConfig, errors: string[] }}
48
+ */
49
+ export function readAuditConfig(pkg) {
50
+ /** @type {AuditConfig} */
51
+ const config = { level: 'high', ignore: [] };
52
+ /** @type {string[]} */
53
+ const errors = [];
54
+ const raw = pkg?.webjs?.audit;
55
+ if (raw === undefined) return { config, errors };
56
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
57
+ return { config, errors: ['webjs.audit must be an object'] };
58
+ }
59
+ for (const key of Object.keys(raw)) {
60
+ if (key !== 'level' && key !== 'ignore') errors.push(`webjs.audit: unknown key "${key}" (expected "level" or "ignore")`);
61
+ }
62
+ if (raw.level !== undefined) {
63
+ if (!AUDIT_LEVELS.includes(raw.level)) errors.push(`webjs.audit.level must be one of ${AUDIT_LEVELS.join(', ')}`);
64
+ else config.level = raw.level;
65
+ }
66
+ if (raw.ignore !== undefined) {
67
+ if (!Array.isArray(raw.ignore)) {
68
+ errors.push('webjs.audit.ignore must be an array of { id, reason }');
69
+ } else {
70
+ raw.ignore.forEach((entry, i) => {
71
+ const at = `webjs.audit.ignore[${i}]`;
72
+ if (!entry || typeof entry !== 'object') { errors.push(`${at} must be an object { id, reason }`); return; }
73
+ const extra = Object.keys(entry).filter((k) => k !== 'id' && k !== 'reason');
74
+ if (extra.length) errors.push(`${at}: unknown key "${extra[0]}" (expected "id" and "reason")`);
75
+ if (typeof entry.id !== 'string' || !/^(GHSA(-[0-9a-z]{4}){3}|CVE-\d{4}-\d+)$/i.test(entry.id)) {
76
+ errors.push(`${at}.id must be an advisory id such as GHSA-xxxx-xxxx-xxxx or CVE-2024-12345`);
77
+ return;
78
+ }
79
+ if (typeof entry.reason !== 'string' || entry.reason.trim() === '') {
80
+ errors.push(`${at} (${entry.id}) needs a non-empty "reason" saying why it is safe to accept`);
81
+ return;
82
+ }
83
+ config.ignore.push({ id: entry.id, reason: entry.reason });
84
+ });
85
+ }
86
+ }
87
+ return { config, errors };
88
+ }
89
+
90
+ /**
91
+ * Which package manager's audit to run. Walks up from `cwd` to the first
92
+ * lockfile, since an app inside a workspace has its lockfile at the root.
93
+ * Only bun and npm have an audit this command drives; anything else is
94
+ * reported as unsupported by the caller.
95
+ *
96
+ * @param {string} cwd
97
+ * @returns {'bun' | 'npm' | 'pnpm' | 'yarn'}
98
+ */
99
+ export function detectAuditManager(cwd) {
100
+ let dir = cwd;
101
+ for (;;) {
102
+ if (existsSync(join(dir, 'bun.lock')) || existsSync(join(dir, 'bun.lockb'))) return 'bun';
103
+ if (existsSync(join(dir, 'package-lock.json')) || existsSync(join(dir, 'npm-shrinkwrap.json'))) return 'npm';
104
+ if (existsSync(join(dir, 'pnpm-lock.yaml'))) return 'pnpm';
105
+ if (existsSync(join(dir, 'yarn.lock'))) return 'yarn';
106
+ const parent = dirname(dir);
107
+ if (parent === dir) break;
108
+ dir = parent;
109
+ }
110
+ return process.versions.bun ? 'bun' : 'npm';
111
+ }
112
+
113
+ /** The advisory id at the end of a GitHub advisory url. */
114
+ function idFromUrl(url) {
115
+ const m = /(GHSA(?:-[0-9a-z]{4}){3})/i.exec(url || '');
116
+ return m ? m[1] : '';
117
+ }
118
+
119
+ /**
120
+ * The source advisories in an `npm audit --json` (v2 report format) report,
121
+ * deduplicated by id. A vulnerability entry whose `via` holds only package
122
+ * NAMES is a dependent of one of these, not an advisory of its own, so it is
123
+ * covered by filtering the advisories it inherits from.
124
+ *
125
+ * @param {any} report
126
+ * @returns {Advisory[]}
127
+ */
128
+ export function npmAdvisories(report) {
129
+ /** @type {Map<string, Advisory>} */
130
+ const byId = new Map();
131
+ for (const [name, vuln] of Object.entries(report?.vulnerabilities || {})) {
132
+ for (const via of /** @type {any} */ (vuln).via || []) {
133
+ if (!via || typeof via !== 'object') continue;
134
+ const id = idFromUrl(via.url) || String(via.source ?? via.url);
135
+ const cur = byId.get(id);
136
+ if (cur) { if (!cur.packages.includes(name)) cur.packages.push(name); continue; }
137
+ byId.set(id, { id, url: via.url || '', severity: via.severity || 'low', title: via.title || '', packages: [name] });
138
+ }
139
+ }
140
+ return [...byId.values()];
141
+ }
142
+
143
+ /**
144
+ * Split advisories into the ones that still fail and the ones the allowlist
145
+ * accepted, keeping only those at or above `level`.
146
+ *
147
+ * @param {Advisory[]} advisories
148
+ * @param {AuditConfig} config
149
+ * @returns {{ failing: Advisory[], ignored: Advisory[], stale: AuditIgnore[] }}
150
+ */
151
+ export function applyAuditConfig(advisories, config) {
152
+ const floor = AUDIT_LEVELS.indexOf(config.level);
153
+ const atLevel = advisories.filter((a) => AUDIT_LEVELS.indexOf(a.severity) >= floor);
154
+ const ids = new Set(config.ignore.map((i) => i.id.toUpperCase()));
155
+ const failing = atLevel.filter((a) => !ids.has(a.id.toUpperCase()));
156
+ const ignored = atLevel.filter((a) => ids.has(a.id.toUpperCase()));
157
+ const seen = new Set(advisories.map((a) => a.id.toUpperCase()));
158
+ const stale = config.ignore.filter((i) => !seen.has(i.id.toUpperCase()));
159
+ return { failing, ignored, stale };
160
+ }
161
+
162
+ /**
163
+ * The source advisories in a `bun audit --json` report, which is keyed by
164
+ * package name with a list of advisories each.
165
+ *
166
+ * @param {any} report
167
+ * @returns {Advisory[]}
168
+ */
169
+ export function bunAdvisories(report) {
170
+ /** @type {Map<string, Advisory>} */
171
+ const byId = new Map();
172
+ for (const [name, list] of Object.entries(report || {})) {
173
+ if (!Array.isArray(list)) continue;
174
+ for (const adv of list) {
175
+ const id = idFromUrl(adv?.url) || String(adv?.id ?? adv?.url);
176
+ const cur = byId.get(id);
177
+ if (cur) { if (!cur.packages.includes(name)) cur.packages.push(name); continue; }
178
+ byId.set(id, { id, url: adv?.url || '', severity: adv?.severity || 'low', title: adv?.title || '', packages: [name] });
179
+ }
180
+ }
181
+ return [...byId.values()];
182
+ }
183
+
184
+ /**
185
+ * Parse a package manager's `audit --json` stdout into advisories. Both
186
+ * managers exit non-zero when they find anything, so the exit code says
187
+ * nothing; an unparseable stdout (a registry outage, an old manager) returns
188
+ * `null` and the caller fails closed.
189
+ *
190
+ * @param {'bun' | 'npm'} pm
191
+ * @param {string} stdout
192
+ * @returns {Advisory[] | null}
193
+ */
194
+ export function parseAuditReport(pm, stdout) {
195
+ let report;
196
+ try { report = JSON.parse(stdout); } catch { return null; }
197
+ if (!report || typeof report !== 'object') return null;
198
+ if (pm === 'npm') {
199
+ if (report.error) return null;
200
+ return npmAdvisories(report);
201
+ }
202
+ return bunAdvisories(report);
203
+ }
204
+
205
+ /**
206
+ * Read `webjs.audit` from the package.json in `cwd`.
207
+ *
208
+ * @param {string} cwd
209
+ */
210
+ export function readAuditConfigFrom(cwd) {
211
+ const p = join(cwd, 'package.json');
212
+ if (!existsSync(p)) return { config: { level: 'high', ignore: [] }, errors: ['no package.json in this directory'] };
213
+ try {
214
+ return readAuditConfig(JSON.parse(readFileSync(p, 'utf8')));
215
+ } catch (e) {
216
+ return { config: { level: 'high', ignore: [] }, errors: [`package.json is not valid JSON: ${/** @type {Error} */ (e).message}`] };
217
+ }
218
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Decide whether `webjs test --browser` has anything to run (#1491).
3
+ *
4
+ * web-test-runner throws `Could not find any test files with pattern(s)` when
5
+ * its `files` globs match nothing, so an app with no browser tests yet (the
6
+ * state `npm run gallery:clear` leaves behind) fails its own CI on the browser
7
+ * layer. A missing layer is not a failing layer: the server layer already
8
+ * passes with zero files, and the browser layer should too. WTR raises the
9
+ * error while building its session groups, after it has started, so the check
10
+ * has to happen here, before it is spawned.
11
+ *
12
+ * The globs are read from the config's SOURCE rather than by importing it,
13
+ * because the scaffold's config top-level-awaits a warmed webjs handler (the
14
+ * whole app boots) just to answer a question a file walk can answer. When the
15
+ * `files` value is not a plain literal (computed, imported, spread), the
16
+ * patterns come back `null` and the caller runs WTR exactly as before, so a
17
+ * config this reader does not understand never turns into a silent skip.
18
+ *
19
+ * @module browser-test-files
20
+ */
21
+ import { readdir } from 'node:fs/promises';
22
+ import { join, relative, sep, matchesGlob } from 'node:path';
23
+
24
+ /**
25
+ * Pull the `files` globs out of a web-test-runner config's source text.
26
+ * Accepts `files: 'one/glob'` and `files: ['a', "b", ...]` with only string
27
+ * literals inside the array. Anything else returns `null` (unknown).
28
+ *
29
+ * @param {string} source
30
+ * @returns {string[] | null}
31
+ */
32
+ export function readWtrFilePatterns(source) {
33
+ // Strip line + block comments first, so a commented-out `files:` cannot
34
+ // match and a comment inside the array does not read as a non-literal.
35
+ const code = stripComments(source);
36
+ const single = code.match(/\bfiles\s*:\s*(['"])([^'"]+)\1\s*[,}\n]/);
37
+ if (single) return [single[2]];
38
+ const arr = code.match(/\bfiles\s*:\s*\[([\s\S]*?)\]/);
39
+ if (!arr) return null;
40
+ const body = arr[1].trim();
41
+ if (body === '') return [];
42
+ const out = [];
43
+ // Every comma-separated element must be a quoted string literal.
44
+ for (const raw of body.split(',')) {
45
+ const el = raw.trim();
46
+ if (el === '') continue; // trailing comma
47
+ const m = el.match(/^(['"])([^'"]*)\1$/);
48
+ if (!m) return null;
49
+ out.push(m[2]);
50
+ }
51
+ return out;
52
+ }
53
+
54
+ /**
55
+ * Walk `cwd` and return the app-relative paths (forward slashes) matching
56
+ * the include globs and none of the `!`-prefixed exclude globs. Skips
57
+ * `node_modules` and dot directories, which WTR's globber skips too.
58
+ *
59
+ * @param {string} cwd
60
+ * @param {string[]} patterns
61
+ * @returns {Promise<string[]>}
62
+ */
63
+ export async function findBrowserTestFiles(cwd, patterns) {
64
+ const include = patterns.filter((p) => !p.startsWith('!')).map(normalize);
65
+ const exclude = patterns.filter((p) => p.startsWith('!')).map((p) => normalize(p.slice(1)));
66
+ if (include.length === 0) return [];
67
+ const found = [];
68
+ const walk = async (dir) => {
69
+ let entries;
70
+ try { entries = await readdir(dir, { withFileTypes: true }); }
71
+ catch { return; }
72
+ for (const ent of entries) {
73
+ if (ent.name === 'node_modules' || ent.name.startsWith('.')) continue;
74
+ const full = join(dir, ent.name);
75
+ if (ent.isDirectory()) { await walk(full); continue; }
76
+ if (!ent.isFile()) continue;
77
+ const rel = relative(cwd, full).split(sep).join('/');
78
+ if (include.some((g) => matchesGlob(rel, g)) && !exclude.some((g) => matchesGlob(rel, g))) {
79
+ found.push(rel);
80
+ }
81
+ }
82
+ };
83
+ await walk(cwd);
84
+ return found;
85
+ }
86
+
87
+ /**
88
+ * Remove `//` and `/* *\/` comments while leaving string literals intact. A
89
+ * regex strip is not enough: a glob such as `test/**\/browser` contains the
90
+ * very `/*` that opens a block comment.
91
+ *
92
+ * @param {string} src
93
+ * @returns {string}
94
+ */
95
+ function stripComments(src) {
96
+ let out = '';
97
+ let quote = '';
98
+ for (let i = 0; i < src.length; i++) {
99
+ const c = src[i];
100
+ if (quote) {
101
+ out += c;
102
+ if (c === '\\') { out += src[++i] ?? ''; continue; }
103
+ if (c === quote) quote = '';
104
+ continue;
105
+ }
106
+ if (c === '"' || c === "'" || c === '`') { quote = c; out += c; continue; }
107
+ if (c === '/' && src[i + 1] === '/') {
108
+ while (i < src.length && src[i] !== '\n') i++;
109
+ out += '\n';
110
+ continue;
111
+ }
112
+ if (c === '/' && src[i + 1] === '*') {
113
+ const end = src.indexOf('*/', i + 2);
114
+ i = end === -1 ? src.length : end + 1;
115
+ continue;
116
+ }
117
+ out += c;
118
+ }
119
+ return out;
120
+ }
121
+
122
+ /** @param {string} p */
123
+ function normalize(p) {
124
+ return p.replace(/^\.\//, '');
125
+ }