test-inventory 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/parse.js CHANGED
@@ -7,6 +7,200 @@ function parserOptions(fileName) {
7
7
  const sourceType = extension.startsWith('.c') ? 'commonjs' : extension.startsWith('.m') ? 'module' : 'unambiguous';
8
8
  return { lang, sourceType, preserveParens: false };
9
9
  }
10
+ /**
11
+ * How deep brackets may nest. oxc-parser recurses on the native stack, which no catch can stop: brackets about 2,400
12
+ * deep crash a worker thread's 4 MB stack and 4,800 the main thread's 8 MB. Of the 10,000 files in this package's
13
+ * development dependencies, minified bundles included, the deepest nests 34.
14
+ */
15
+ const MAX_NESTING = 500;
16
+ /** Words after which a `/` starts a regular expression rather than a division. */
17
+ const BEFORE_REGEX = new Set([
18
+ 'return',
19
+ 'typeof',
20
+ 'instanceof',
21
+ 'in',
22
+ 'of',
23
+ 'new',
24
+ 'delete',
25
+ 'void',
26
+ 'throw',
27
+ 'case',
28
+ 'do',
29
+ 'else',
30
+ 'yield',
31
+ 'await',
32
+ ]);
33
+ const Char = {
34
+ Tab: 9,
35
+ LineFeed: 10,
36
+ CarriageReturn: 13,
37
+ Space: 32,
38
+ DoubleQuote: 34,
39
+ Dollar: 36,
40
+ SingleQuote: 39,
41
+ OpenParen: 40,
42
+ CloseParen: 41,
43
+ Star: 42,
44
+ Slash: 47,
45
+ LessThan: 60,
46
+ GreaterThan: 62,
47
+ OpenBracket: 91,
48
+ Backslash: 92,
49
+ CloseBracket: 93,
50
+ Backtick: 96,
51
+ OpenBrace: 123,
52
+ CloseBrace: 125,
53
+ };
54
+ /** Letters, digits, `_`, `$` and anything outside ASCII, as identifiers and numbers use. */
55
+ function isWordChar(code) {
56
+ return ((code >= 97 && code <= 122) ||
57
+ (code >= 65 && code <= 90) ||
58
+ (code >= 48 && code <= 57) ||
59
+ code === 95 ||
60
+ code === Char.Dollar ||
61
+ code > 127);
62
+ }
63
+ /** Whether the code right before `end` is a word after which a `/` starts a regular expression. */
64
+ function afterKeyword(text, end) {
65
+ let wordEnd = end;
66
+ while (wordEnd > 0 && /\s/.test(text[wordEnd - 1]))
67
+ wordEnd--;
68
+ let wordStart = wordEnd;
69
+ while (wordStart > 0 && isWordChar(text.charCodeAt(wordStart - 1)))
70
+ wordStart--;
71
+ return BEFORE_REGEX.has(text.slice(wordStart, wordEnd));
72
+ }
73
+ /**
74
+ * Whether the slash at `at` ends a JSX tag, as in `</ul>` or `<OrderLeg leg={leg} />`, where a regular expression
75
+ * could otherwise start and run on over the brackets after it.
76
+ */
77
+ function endsJsxTag(text, at) {
78
+ if (text.charCodeAt(at - 1) === Char.LessThan)
79
+ return true;
80
+ if (text.charCodeAt(at + 1) !== Char.GreaterThan)
81
+ return false;
82
+ let before = at - 1;
83
+ while (before > 0 && /\s/.test(text[before]))
84
+ before--;
85
+ return text.charCodeAt(before) === Char.CloseBrace;
86
+ }
87
+ /**
88
+ * The offset of the first bracket nested more than `limit` deep, or null. Parentheses, brackets, braces and template
89
+ * substitutions count; strings, comments, regular expressions and template text are skipped. A closing bracket closes
90
+ * up to the one it matches, so a literal read wrongly unbalances only the brackets around it.
91
+ */
92
+ export function tooDeeplyNested(text, limit) {
93
+ // Open brackets by their character; a substitution is kept as its backtick.
94
+ const open = [];
95
+ const counts = new Uint32Array(128);
96
+ let regexAllowed = true;
97
+ let index = 0;
98
+ const push = (code) => {
99
+ open.push(code);
100
+ counts[code]++;
101
+ return open.length > limit;
102
+ };
103
+ // Reads template text up to its end or a substitution; true when the substitution is one too deep.
104
+ const templateText = () => {
105
+ while (index < text.length) {
106
+ const code = text.charCodeAt(index);
107
+ if (code === Char.Backslash)
108
+ index += 2;
109
+ else if (code === Char.Backtick) {
110
+ index++;
111
+ regexAllowed = false;
112
+ return false;
113
+ }
114
+ else if (code === Char.Dollar && text.charCodeAt(index + 1) === Char.OpenBrace) {
115
+ if (push(Char.Backtick))
116
+ return true;
117
+ index += 2;
118
+ regexAllowed = true;
119
+ return false;
120
+ }
121
+ else
122
+ index++;
123
+ }
124
+ return false;
125
+ };
126
+ // Skips a string or regular expression; neither goes past the end of the line.
127
+ const literal = (end) => {
128
+ let inClass = false;
129
+ while (index < text.length) {
130
+ const code = text.charCodeAt(index);
131
+ if (code === Char.LineFeed)
132
+ return;
133
+ index += code === Char.Backslash ? 2 : 1;
134
+ if (end === Char.Slash && code === Char.OpenBracket)
135
+ inClass = true;
136
+ else if (code === Char.CloseBracket)
137
+ inClass = false;
138
+ else if (code === end && !inClass)
139
+ return;
140
+ }
141
+ };
142
+ while (index < text.length) {
143
+ const code = text.charCodeAt(index);
144
+ const next = text.charCodeAt(index + 1);
145
+ const start = index;
146
+ if (code === Char.Space || code === Char.LineFeed || code === Char.Tab || code === Char.CarriageReturn) {
147
+ index++;
148
+ }
149
+ else if (code === Char.Slash && next === Char.Slash) {
150
+ const end = text.indexOf('\n', index);
151
+ index = end === -1 ? text.length : end;
152
+ }
153
+ else if (code === Char.Slash && next === Char.Star) {
154
+ const end = text.indexOf('*/', index + 2);
155
+ index = end === -1 ? text.length : end + 2;
156
+ }
157
+ else if (code === Char.SingleQuote ||
158
+ code === Char.DoubleQuote ||
159
+ (code === Char.Slash && (regexAllowed || afterKeyword(text, start)) && !endsJsxTag(text, start))) {
160
+ index++;
161
+ literal(code);
162
+ regexAllowed = false;
163
+ }
164
+ else if (code === Char.Backtick) {
165
+ index++;
166
+ if (templateText())
167
+ return index;
168
+ }
169
+ else if (isWordChar(code)) {
170
+ while (index < text.length && isWordChar(text.charCodeAt(index)))
171
+ index++;
172
+ regexAllowed = false;
173
+ }
174
+ else if (code === Char.OpenParen || code === Char.OpenBracket || code === Char.OpenBrace) {
175
+ if (push(code))
176
+ return start;
177
+ index++;
178
+ regexAllowed = true;
179
+ }
180
+ else if (code === Char.CloseParen || code === Char.CloseBracket || code === Char.CloseBrace) {
181
+ index++;
182
+ // A block can end right before a regular expression; a value in parentheses or brackets is divided.
183
+ regexAllowed = code === Char.CloseBrace;
184
+ const opener = code === Char.CloseParen ? Char.OpenParen : code === Char.CloseBracket ? Char.OpenBracket : Char.OpenBrace;
185
+ const closesSubstitution = code === Char.CloseBrace && counts[Char.Backtick] !== 0;
186
+ if (counts[opener] === 0 && !closesSubstitution)
187
+ continue;
188
+ let closed;
189
+ do {
190
+ closed = open.pop();
191
+ counts[closed]--;
192
+ } while (closed !== opener && !(closed === Char.Backtick && code === Char.CloseBrace));
193
+ // A substitution after this one is as deep as the one it closed, so it is never the first too deep.
194
+ if (closed === Char.Backtick)
195
+ templateText();
196
+ }
197
+ else {
198
+ index++;
199
+ regexAllowed = true;
200
+ }
201
+ }
202
+ return null;
203
+ }
10
204
  /** The first parser error, located at its first label, or at the start of the file when it has none. */
11
205
  export function firstError(errors) {
12
206
  const first = errors.at(0);
@@ -18,6 +212,16 @@ export function firstError(errors) {
18
212
  export function parseFile(fileName, code) {
19
213
  // A byte order mark is not part of the code, and editors don't count it as a column.
20
214
  const text = code.charCodeAt(0) === 0xfeff ? code.slice(1) : code;
215
+ const tooDeep = tooDeeplyNested(text, MAX_NESTING);
216
+ if (tooDeep !== null) {
217
+ const message = `Brackets are nested more than ${MAX_NESTING} deep, too deep to parse safely`;
218
+ return {
219
+ source: new SourceText(text),
220
+ program: parseSync(fileName, '', parserOptions(fileName)).program,
221
+ comments: [],
222
+ error: { message, start: tooDeep, end: tooDeep + 1 },
223
+ };
224
+ }
21
225
  const result = parseSync(fileName, text, parserOptions(fileName));
22
226
  return {
23
227
  source: new SourceText(text),
package/dist/records.d.ts CHANGED
@@ -61,6 +61,8 @@ export interface FileConfig {
61
61
  };
62
62
  /** Vitest's tags; null when the scan can't work them out, or the projects that run the file define different ones. */
63
63
  tags: VitestTags | null;
64
+ /** The tags Playwright's config `tag` gives every test, before the test's own. */
65
+ configTags: string[];
64
66
  }
65
67
  interface RecordOptions {
66
68
  relativeFilePath: string;
package/dist/records.js CHANGED
@@ -314,7 +314,7 @@ class RecordBuilder {
314
314
  };
315
315
  }
316
316
  /** What the config's Vitest tags set on a test with `tags`, merged as Vitest merges them; null when none applies. */
317
- tagOptions(tags) {
317
+ tagOptions(tags, ownStateOptions) {
318
318
  const definitions = this.options.config?.tags?.definitions ?? [];
319
319
  const applied = tags.flatMap((name) => definitions.filter((definition) => definition.name === name));
320
320
  if (applied.length === 0)
@@ -330,6 +330,9 @@ class RecordBuilder {
330
330
  if (meta)
331
331
  merged.meta = { ...merged.meta, ...meta };
332
332
  }
333
+ // Vitest merges the test's own options over its tags', so `skip: false` runs a test whose tag skips it.
334
+ for (const option of ownStateOptions)
335
+ merged[option] = undefined;
333
336
  return merged;
334
337
  }
335
338
  test(pending, suites) {
@@ -343,11 +346,12 @@ class RecordBuilder {
343
346
  const id = this.id([this.describeTitles(describes), title.withoutTags]);
344
347
  const fileScope = scopes[scopes.length - 1];
345
348
  const tags = unique([
349
+ ...(this.options.config?.configTags ?? []),
346
350
  ...fileScope.details.tags,
347
351
  ...describes.flatMap((scope) => scope.details.tags),
348
352
  ...declaration.details.tags,
349
353
  ]);
350
- const fromTags = this.tagOptions(tags);
354
+ const fromTags = this.tagOptions(tags, declaration.ownStateOptions ?? []);
351
355
  // In Vitest 5 the options of a test's tags win over those the describes pass down; before, the describes' win.
352
356
  const tagsFirst = this.options.config?.tags?.beatsDescribes ?? true;
353
357
  const tagged = (own, tag, around) => tagsFirst ? [...own, ...tag, ...around] : [...own, ...around, ...tag];
package/dist/resolve.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { isUtf8 } from 'node:buffer';
1
2
  import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
2
3
  import path from 'node:path';
3
4
  import { globSync } from 'tinyglobby';
@@ -8,7 +9,7 @@ function isFile(file) {
8
9
  }
9
10
  /** The file a path refers to, found the way TypeScript and Node do: as is, with an extension, or a folder's index. */
10
11
  function findFile(base) {
11
- const stripped = base.replace(/\.[cm]?js$/, '');
12
+ const stripped = base.replace(/\.(?:[cm]?js|jsx)$/, '');
12
13
  const candidates = [
13
14
  base,
14
15
  ...EXTENSIONS.map((extension) => stripped + extension),
@@ -42,6 +43,16 @@ function readText(file) {
42
43
  return null;
43
44
  }
44
45
  }
46
+ /** A file's text, with its bytes too when it isn't UTF-8 text, as an image isn't; null when it can't be read. */
47
+ function readContent(file) {
48
+ try {
49
+ const bytes = readFileSync(file);
50
+ return isUtf8(bytes) ? { code: bytes.toString('utf8') } : { code: bytes.toString('utf8'), bytes };
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ }
45
56
  function readJson(file) {
46
57
  const text = isFile(file) ? readText(file) : null;
47
58
  return text === null ? null : record(parseJsonc(text));
@@ -362,17 +373,24 @@ export function moduleResolver(root) {
362
373
  }
363
374
  else {
364
375
  const options = optionsFor(directory);
365
- const aliased = options ? aliasFiles(specifier, options) : null;
376
+ const targets = options ? aliasFiles(specifier, options) : null;
377
+ const aliased = targets?.find((file) => findFile(file) !== null);
366
378
  if (aliased) {
367
- candidates = aliased;
379
+ candidates = [aliased];
368
380
  }
369
381
  else {
370
- // `baseUrl` turns any name into a path, so a name not found there may still be a workspace package.
382
+ // TypeScript goes on to the usual lookup when no file of a matching alias exists. `baseUrl` turns any name
383
+ // into a path, so a name not found there may still be a workspace package.
371
384
  const fromBaseUrl = options?.baseUrl ? findFile(path.resolve(options.baseUrl, specifier)) : null;
372
385
  const workspace = fromBaseUrl ? null : workspaceSource(specifier, directory, packagesOf);
373
386
  if (workspace && 'reason' in workspace)
374
387
  return workspace;
375
388
  const file = fromBaseUrl ?? workspace?.file;
389
+ // No package name starts with `@/` or `~/`, so these are aliases, as Vite's `resolve.alias` sets them; any
390
+ // other name with no file may still be a package or a built-in module, as TypeScript takes it to be.
391
+ if (!file && /^[@~]\//.test(specifier)) {
392
+ return { reason: targets ? 'could not be read' : 'is a path alias that no tsconfig paths define' };
393
+ }
376
394
  if (!file)
377
395
  return { reason: 'is not a local file, a path alias or a workspace package' };
378
396
  candidates = [file];
@@ -382,10 +400,12 @@ export function moduleResolver(root) {
382
400
  if (!found)
383
401
  return { reason: 'could not be read' };
384
402
  if (!modules.has(found)) {
385
- const code = readText(found);
386
- modules.set(found, code === null
387
- ? null
388
- : { code, relativeFilePath: path.relative(root, found).replaceAll('\\', '/'), readModule: reader(found) });
403
+ const content = readContent(found);
404
+ modules.set(found, content && {
405
+ ...content,
406
+ relativeFilePath: path.relative(root, found).replaceAll('\\', '/'),
407
+ readModule: reader(found),
408
+ });
389
409
  }
390
410
  return modules.get(found) ?? { reason: 'could not be read' };
391
411
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "test-inventory",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Test metadata from source code, not from test results.",
5
5
  "keywords": [
6
6
  "testing",