test-inventory 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,7 @@ import { childNodes, isFunctionNode, propertyKey, unwrap } from "./ast.js";
4
4
  import { exportName, parseModule } from "./frameworks/bindings.js";
5
5
  import { destructuredNames, problem, takesWhole } from "./frameworks/common.js";
6
6
  import { shortHash, hashNodes } from "./hash.js";
7
+ import { parseFile } from "./parse.js";
7
8
  import { snippet } from "./source.js";
8
9
  import { bindingNames, isScopeNode, Values } from "./values.js";
9
10
  /** Statements that only declare something, unless code in them runs while they are evaluated. */
@@ -157,6 +158,50 @@ function isAutoOption(property) {
157
158
  property.value.value === true);
158
159
  }
159
160
  const CODE_FILE = /\.[cm]?[jt]sx?$/;
161
+ /** A Vue or Svelte single-file component: markup with its code in `<script>` blocks. */
162
+ const COMPONENT_FILE = /\.(?:vue|svelte)$/;
163
+ /**
164
+ * A comment, which may hold a `<script>` that isn't one, or a script block with its attributes and code. An attribute
165
+ * may hold `>`, as Svelte's `generics="T extends Record<string, number>"` does. Both end where HTML ends them: a
166
+ * comment also at `--!>`, a script at `</script` with anything before its `>`.
167
+ */
168
+ const COMPONENT_BLOCK = /<!--[\s\S]*?--!?>|<script\b((?:[^>"']|"[^"]*"|'[^']*')*)>([\s\S]*?)<\/script(?:[\s/][^>]*)?>/gi;
169
+ /**
170
+ * A file's bytes with line endings as `\n`, as git may check out a text file with either; one git takes for binary, with
171
+ * a NUL byte in its first 8,000, stays as it is.
172
+ */
173
+ function withLineFeeds(bytes) {
174
+ if (bytes.subarray(0, 8000).includes(0))
175
+ return bytes;
176
+ // Latin-1 maps each byte to one character and back, so only the line endings change.
177
+ return Buffer.from(Buffer.from(bytes).toString('latin1').replace(/\r\n?/g, '\n'), 'latin1');
178
+ }
179
+ /** Whether a script block holds code: one with a `type` other than a JavaScript one, as JSON-LD, holds data. */
180
+ function isScriptCode(attributes) {
181
+ const type = /(?:^|\s)type\s*=\s*["']?([^"'\s>]*)/i.exec(attributes)?.[1] ?? '';
182
+ return /^(?:module|(?:text|application)\/(?:java|ecma)script)?$/i.test(type);
183
+ }
184
+ /**
185
+ * A component's script blocks as one module, the rest of the file blanked so that offsets stay those of the file, and
186
+ * a file name for the language they are in: `lang="ts"` is TypeScript, no `lang` JavaScript.
187
+ */
188
+ function componentScript(code) {
189
+ // A byte order mark stays, for the parser to leave out as it does from any file.
190
+ const blank = (text) => text.replace(/[^\r\n\uFEFF]/g, ' ');
191
+ let script = '';
192
+ let end = 0;
193
+ let lang = 'js';
194
+ for (const match of code.matchAll(COMPONENT_BLOCK)) {
195
+ const [whole, attributes, body] = match;
196
+ if (whole.startsWith('<!--') || !isScriptCode(attributes))
197
+ continue;
198
+ const start = match.index + '<script'.length + attributes.length + 1;
199
+ script += blank(code.slice(end, start)) + body;
200
+ end = start + body.length;
201
+ lang = /(?:^|\s)lang\s*=\s*["']?(\w+)/.exec(attributes)?.[1] ?? lang;
202
+ }
203
+ return { fileName: `component.${lang}`, code: script + blank(code.slice(end)) };
204
+ }
160
205
  const DECLARATION_FILE = /\.d\.[cm]?ts$/;
161
206
  /**
162
207
  * What an import being looked up stands for while the lookup leads back to it, through re-exports: nothing found
@@ -469,7 +514,8 @@ export class DependencyGraph {
469
514
  hashOf(unit) {
470
515
  if (unit.hash === undefined) {
471
516
  if (unit.module.text !== null) {
472
- unit.hash = shortHash(unit.module.text);
517
+ // Counted once, through the unit of all of it, however many of a component's units a test reaches.
518
+ unit.hash = unit.module.whole.includes(unit) ? shortHash(unit.module.text) : null;
473
519
  }
474
520
  else {
475
521
  const cut = [...unit.cut].map(([start, end]) => ({ start, end }));
@@ -490,7 +536,12 @@ export class DependencyGraph {
490
536
  this.modules.set(parsed, module);
491
537
  }
492
538
  else {
493
- module = this.newModule(source.relativeFilePath, null, source.code.replace(/\r\n?/g, '\n'), new Values(), null);
539
+ const text = source.bytes ? withLineFeeds(source.bytes) : source.code.replace(/\r\n?/g, '\n');
540
+ // A component's markup and styles shape what it renders, so it is taken by its text, and its scripts are
541
+ // followed for the modules they import.
542
+ const script = COMPONENT_FILE.test(source.relativeFilePath) ? componentScript(source.code) : null;
543
+ const parsed = script && parseFile(script.fileName, script.code);
544
+ module = this.newModule(source.relativeFilePath, parsed, text, new Values(), parsed && source.readModule);
494
545
  }
495
546
  this.modules.set(source, module);
496
547
  }
@@ -572,7 +623,7 @@ export class DependencyGraph {
572
623
  importUnit(module, statement, specifier) {
573
624
  let unit = this.imports.get(specifier);
574
625
  if (!unit) {
575
- const roots = [statement.source, specifier].map((node) => ({ node, scopes: [] }));
626
+ const roots = [...new Set([statement.source, specifier])].map((node) => ({ node, scopes: [] }));
576
627
  unit = this.newUnit(module, roots, new Map(), false);
577
628
  unit.edges = new Set();
578
629
  this.imports.set(specifier, unit);
@@ -682,8 +733,12 @@ export class DependencyGraph {
682
733
  ];
683
734
  }
684
735
  if (module.parsed.error) {
685
- return [this.placeUnit(from, at, `'${specifier}' could not be parsed, so dependencyHash doesn't cover it.`)];
736
+ const place = this.placeUnit(from, at, `'${specifier}' could not be parsed, so dependencyHash doesn't cover it.`);
737
+ return module.text === null ? [place] : [this.textUnit(module), place];
686
738
  }
739
+ // A component's markup may use anything its scripts declare or import, its types included.
740
+ if (module.text !== null)
741
+ return [this.wholeUnit(module, false)];
687
742
  return name === '' ? [this.loadUnit(module, types)] : this.exported(module, name, types, new Set());
688
743
  }
689
744
  /**
@@ -711,7 +766,7 @@ export class DependencyGraph {
711
766
  : [this.declarationUnit(module, declaration, types)];
712
767
  }
713
768
  if (statement.type === 'ExportAllDeclaration' && statement.exported && exportName(statement.exported) === name) {
714
- return this.imported(module, statement.source.value, '*', types, statement);
769
+ return this.passedOn(module, statement, statement.source, statement.exported, '*', types);
715
770
  }
716
771
  if (statement.type !== 'ExportNamedDeclaration')
717
772
  continue;
@@ -735,7 +790,7 @@ export class DependencyGraph {
735
790
  continue;
736
791
  const localName = exportName(specifier.local);
737
792
  return statement.source
738
- ? this.imported(module, statement.source.value, localName, types, statement)
793
+ ? this.passedOn(module, statement, statement.source, specifier, localName, types)
739
794
  : local(localName);
740
795
  }
741
796
  // `export *` passes on every name but `default`, and only when the module doesn't export the name itself.
@@ -745,11 +800,19 @@ export class DependencyGraph {
745
800
  continue;
746
801
  const found = this.reexported(module, statement.source.value, name, types, searched);
747
802
  if (found)
748
- return found;
803
+ return [...found, this.importUnit(module, statement, statement.source)];
749
804
  }
750
805
  }
751
806
  return [this.wholeUnit(module, types)];
752
807
  }
808
+ /**
809
+ * A name a module passes on from another, as `export { quote } from './nasdaq'` does, with the re-export itself: which
810
+ * module the name comes from is code too, so the barrel is among the files a change to which affects the test.
811
+ */
812
+ passedOn(module, statement, source, specifier, name, types) {
813
+ const units = this.imported(module, source.value, name, types, statement);
814
+ return units === IN_PROGRESS ? units : [...units, this.importUnit(module, { source }, specifier)];
815
+ }
753
816
  /** What `export * from source` gives for a name; null when the module behind it doesn't export it. */
754
817
  reexported(from, source, name, types, searched) {
755
818
  const read = this.read(from, source);
@@ -1161,10 +1224,22 @@ export class DependencyGraph {
1161
1224
  }
1162
1225
  break;
1163
1226
  }
1164
- case 'ImportDeclaration':
1165
- if (node.importKind !== 'type' || types)
1166
- add(this.imported(module, node.source.value, '', types, node));
1227
+ case 'ImportDeclaration': {
1228
+ if (module.text === null) {
1229
+ if (node.importKind !== 'type' || types)
1230
+ add(this.imported(module, node.source.value, '', types, node));
1231
+ return;
1232
+ }
1233
+ // A component's markup uses what its scripts import, where the scan doesn't look, and Vue builds the props
1234
+ // of `defineProps<Props>()` from the types, so every import counts, types with their types.
1235
+ const isType = node.importKind === 'type';
1236
+ add(this.imported(module, node.source.value, '', isType, node));
1237
+ for (const specifier of node.specifiers) {
1238
+ const isTypeName = isType || (specifier.type === 'ImportSpecifier' && specifier.importKind === 'type');
1239
+ add(this.toDeclaration(module, specifier, specifier.local.name, null, isTypeName));
1240
+ }
1167
1241
  return;
1242
+ }
1168
1243
  case 'TSImportEqualsDeclaration':
1169
1244
  if (node.moduleReference.type !== 'TSExternalModuleReference')
1170
1245
  break;
@@ -7,6 +7,7 @@ export const LEVELS = {
7
7
  'unresolved-tag': 'warning',
8
8
  'unresolved-annotation': 'warning',
9
9
  'unresolved-cases': 'warning',
10
+ 'cases-not-expanded': 'warning',
10
11
  'test-in-loop': 'warning',
11
12
  'dynamic-title': 'warning',
12
13
  'duplicate-title': 'warning',
package/dist/extract.d.ts CHANGED
@@ -48,6 +48,8 @@ export interface PendingSuite {
48
48
  /** Declared under a condition, or in a function: it may not run while the file loads. */
49
49
  isInCondition: boolean;
50
50
  isInFunction: boolean;
51
+ /** With `expandCases`, the index of the case of a table or pass of a loop this describe is; null otherwise. */
52
+ caseIndex: number | null;
51
53
  }
52
54
  export interface PendingTest {
53
55
  scope: Scope;
@@ -66,8 +68,17 @@ export interface PendingTest {
66
68
  states: StateSetting[];
67
69
  annotations: Annotation[];
68
70
  isInLoop: boolean;
71
+ /**
72
+ * How many times each loop around the test registers it, outermost first; null for a loop the scan can't count, or
73
+ * one that registers it under a condition.
74
+ */
75
+ loops: (number | null)[];
76
+ /** How many passes each loop around the test makes, whether or not it registers the test on each. */
77
+ passes: (number | null)[];
69
78
  isInCondition: boolean;
70
79
  isInFunction: boolean;
80
+ /** With `expandCases`, the index of the case of a table or pass of a loop this test is; null otherwise. */
81
+ caseIndex: number | null;
71
82
  comments: string[];
72
83
  /** Run settings from calls in the body, which win over everything around the test. */
73
84
  run: RunSettings;
@@ -85,6 +96,13 @@ export interface Frame {
85
96
  readModule: ModuleReader | null;
86
97
  relativeFilePath: string;
87
98
  }
99
+ /** How the walk lists tables and loops. */
100
+ export interface ExtractOptions {
101
+ /** One test or describe per case of a table and per pass of a loop whose values the scan can work out. */
102
+ expandCases: boolean;
103
+ /** Vitest's `[major, minor]`, whose rules give each case its title; null for the newest rules. */
104
+ version: readonly [number, number] | null;
105
+ }
88
106
  /** Reads a module a helper is imported from as a frame; null when it can't be parsed. */
89
107
  export type OpenModule = (module: ModuleSource) => Frame | null;
90
108
  export interface Extraction {
@@ -112,4 +130,4 @@ export declare function anySlow(slows: NonNullable<RunSettings['slow']>[]): NonN
112
130
  export declare function eitherCondition(parts: string[]): string;
113
131
  export declare function noHooks(): HookCounts;
114
132
  /** Walks the scanned file; `openModule` reads the modules of imported helpers that the file calls. */
115
- export declare function extract(main: Frame, openModule: OpenModule): Extraction;
133
+ export declare function extract(main: Frame, openModule: OpenModule, options?: ExtractOptions): Extraction;