@describe-me/vitest 0.1.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.
- package/LICENSE +21 -0
- package/README.md +47 -0
- package/dist/action-label.d.ts +2 -0
- package/dist/action-label.js +16 -0
- package/dist/collect-component-docs-safely.d.ts +9 -0
- package/dist/collect-component-docs-safely.js +25 -0
- package/dist/collect-component-docs.d.ts +12 -0
- package/dist/collect-component-docs.js +48 -0
- package/dist/component-entries.d.ts +4 -0
- package/dist/component-entries.js +13 -0
- package/dist/describe-arg.d.ts +2 -0
- package/dist/describe-arg.js +26 -0
- package/dist/extract-props.d.ts +8 -0
- package/dist/extract-props.js +168 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/load-compiler-options.d.ts +6 -0
- package/dist/load-compiler-options.js +30 -0
- package/dist/patch-locators.d.ts +6 -0
- package/dist/patch-locators.js +54 -0
- package/dist/patch-user-event.d.ts +2 -0
- package/dist/patch-user-event.js +25 -0
- package/dist/plugin.d.ts +20 -0
- package/dist/plugin.js +55 -0
- package/dist/pretty-selector.d.ts +6 -0
- package/dist/pretty-selector.js +30 -0
- package/dist/record-action.d.ts +2 -0
- package/dist/record-action.js +22 -0
- package/dist/reporter.d.ts +23 -0
- package/dist/reporter.js +90 -0
- package/dist/resolve-component-file.d.ts +15 -0
- package/dist/resolve-component-file.js +85 -0
- package/dist/setup.d.ts +1 -0
- package/dist/setup.js +19 -0
- package/dist/snapshot-store.d.ts +12 -0
- package/dist/snapshot-store.js +37 -0
- package/dist/test-path.d.ts +3 -0
- package/dist/test-path.js +10 -0
- package/package.json +74 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Grzegorz Łotysz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# @describe-me/vitest
|
|
2
|
+
|
|
3
|
+
The Vitest integration for [describe-me](https://github.com/grzehub/describe-me):
|
|
4
|
+
a plugin, a setup file that makes every interaction record a frame, and the Node
|
|
5
|
+
reporter that writes `.describe-me/`.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pnpm add -D @describe-me/vitest @describe-me/react describe-me
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
One line in the Vitest config registers the setup file and the reporter, and
|
|
16
|
+
redirects `vitest-browser-react` imports to the recording adapter:
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { describeMe } from '@describe-me/vitest/plugin'
|
|
20
|
+
|
|
21
|
+
export default defineConfig({
|
|
22
|
+
plugins: [react(), describeMe()],
|
|
23
|
+
test: {
|
|
24
|
+
browser: { enabled: true, provider: playwright(), instances: [{ browser: 'chromium' }] },
|
|
25
|
+
},
|
|
26
|
+
})
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The pieces can also be wired by hand, in which case tests import `render` from
|
|
30
|
+
`@describe-me/react` themselves:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import DescribeMeReporter from '@describe-me/vitest/reporter'
|
|
34
|
+
|
|
35
|
+
export default defineConfig({
|
|
36
|
+
test: {
|
|
37
|
+
setupFiles: ['@describe-me/vitest/setup'],
|
|
38
|
+
reporters: ['default', new DescribeMeReporter()],
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`typescript` is an optional peer: without it the docs are still generated, only
|
|
44
|
+
the props tables are left out.
|
|
45
|
+
|
|
46
|
+
See the [root README](https://github.com/grzehub/describe-me#readme) for the
|
|
47
|
+
full picture.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describeArg } from './describe-arg.js';
|
|
2
|
+
import { prettySelector } from './pretty-selector.js';
|
|
3
|
+
/** Build the frame label for an interaction, e.g. `click(button "increment")` or `keyboard("{Escape}")`. */
|
|
4
|
+
export function actionLabel(method, selector, args) {
|
|
5
|
+
const parts = [];
|
|
6
|
+
if (selector) {
|
|
7
|
+
parts.push(prettySelector(selector));
|
|
8
|
+
}
|
|
9
|
+
for (const arg of args) {
|
|
10
|
+
const shown = describeArg(arg);
|
|
11
|
+
if (shown) {
|
|
12
|
+
parts.push(shown);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return `${method}(${parts.join(', ')})`;
|
|
16
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ComponentDoc } from '@describe-me/core/types';
|
|
2
|
+
import type { ComponentEntry } from './collect-component-docs.js';
|
|
3
|
+
/**
|
|
4
|
+
* Document the rendered components, unless TypeScript is missing. Props are read
|
|
5
|
+
* with TypeScript's own compiler, which is an optional peer dependency, so the
|
|
6
|
+
* collector is imported lazily: a JavaScript-only project gets a warning and no
|
|
7
|
+
* props tables instead of a crashing reporter.
|
|
8
|
+
*/
|
|
9
|
+
export declare function collectComponentDocsSafely(root: string, entries: Iterable<ComponentEntry>): Promise<Record<string, ComponentDoc>>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
let warned = false;
|
|
2
|
+
function warnAboutMissingTypeScript() {
|
|
3
|
+
if (warned) {
|
|
4
|
+
return;
|
|
5
|
+
}
|
|
6
|
+
warned = true;
|
|
7
|
+
console.warn('describe-me: typescript is not installed, component props will not be documented');
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Document the rendered components, unless TypeScript is missing. Props are read
|
|
11
|
+
* with TypeScript's own compiler, which is an optional peer dependency, so the
|
|
12
|
+
* collector is imported lazily: a JavaScript-only project gets a warning and no
|
|
13
|
+
* props tables instead of a crashing reporter.
|
|
14
|
+
*/
|
|
15
|
+
export async function collectComponentDocsSafely(root, entries) {
|
|
16
|
+
try {
|
|
17
|
+
await import('typescript');
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
warnAboutMissingTypeScript();
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
const { collectComponentDocs } = await import('./collect-component-docs.js');
|
|
24
|
+
return collectComponentDocs(root, entries);
|
|
25
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ComponentDoc } from '@describe-me/core/types';
|
|
2
|
+
/** A component name as the adapter reported it, and the test module that rendered it. */
|
|
3
|
+
export interface ComponentEntry {
|
|
4
|
+
testFile: string;
|
|
5
|
+
componentName: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Document every component the tests rendered: find its source file through the
|
|
9
|
+
* test's imports, then read its props from TypeScript. One program covers all of
|
|
10
|
+
* them; a component that cannot be found or read is left out of the result.
|
|
11
|
+
*/
|
|
12
|
+
export declare function collectComponentDocs(root: string, entries: Iterable<ComponentEntry>): Record<string, ComponentDoc>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { relative, sep } from 'node:path';
|
|
2
|
+
import ts from 'typescript';
|
|
3
|
+
import { extractProps } from './extract-props.js';
|
|
4
|
+
import { loadCompilerOptions } from './load-compiler-options.js';
|
|
5
|
+
import { resolveComponentFile } from './resolve-component-file.js';
|
|
6
|
+
/**
|
|
7
|
+
* Document every component the tests rendered: find its source file through the
|
|
8
|
+
* test's imports, then read its props from TypeScript. One program covers all of
|
|
9
|
+
* them; a component that cannot be found or read is left out of the result.
|
|
10
|
+
*/
|
|
11
|
+
export function collectComponentDocs(root, entries) {
|
|
12
|
+
const options = loadCompilerOptions(root);
|
|
13
|
+
const resolved = resolveAll(entries, options);
|
|
14
|
+
if (resolved.size === 0) {
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
17
|
+
const files = Array.from(new Set(Array.from(resolved.values(), (found) => found.file)));
|
|
18
|
+
const program = ts.createProgram(files, options);
|
|
19
|
+
const docs = {};
|
|
20
|
+
for (const name of Array.from(resolved.keys()).sort()) {
|
|
21
|
+
const found = resolved.get(name);
|
|
22
|
+
const props = extractProps(program, found.file, found.exportName);
|
|
23
|
+
if (!props) {
|
|
24
|
+
console.warn(`[describe-me] could not read props of ${name} from ${found.file}`);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
docs[name] = { name, file: toPosix(relative(root, found.file)), props };
|
|
28
|
+
}
|
|
29
|
+
return docs;
|
|
30
|
+
}
|
|
31
|
+
/** One source file per component name; the same component is looked up once. */
|
|
32
|
+
function resolveAll(entries, options) {
|
|
33
|
+
const resolved = new Map();
|
|
34
|
+
for (const entry of entries) {
|
|
35
|
+
if (resolved.has(entry.componentName)) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const found = resolveComponentFile(entry.testFile, entry.componentName, options);
|
|
39
|
+
if (found) {
|
|
40
|
+
resolved.set(entry.componentName, found);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return resolved;
|
|
44
|
+
}
|
|
45
|
+
/** The manifest is read in a browser, where paths are posix whatever wrote them. */
|
|
46
|
+
function toPosix(path) {
|
|
47
|
+
return path.split(sep).join('/');
|
|
48
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ManifestModule } from '@describe-me/core/types';
|
|
2
|
+
import type { ComponentEntry } from './collect-component-docs.js';
|
|
3
|
+
/** Every component the recorded tests rendered, paired with the module that rendered it. */
|
|
4
|
+
export declare function componentEntries(modules: ManifestModule[], root: string): ComponentEntry[];
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
/** Every component the recorded tests rendered, paired with the module that rendered it. */
|
|
3
|
+
export function componentEntries(modules, root) {
|
|
4
|
+
const entries = [];
|
|
5
|
+
for (const mod of modules) {
|
|
6
|
+
for (const test of mod.tests) {
|
|
7
|
+
if (test.component) {
|
|
8
|
+
entries.push({ testFile: resolve(root, mod.id), componentName: test.component.name });
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
return entries;
|
|
13
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { prettySelector } from './pretty-selector.js';
|
|
2
|
+
/** Render one argument of a `userEvent.*` call for the frame label. */
|
|
3
|
+
export function describeArg(arg) {
|
|
4
|
+
if (arg == null) {
|
|
5
|
+
return '';
|
|
6
|
+
}
|
|
7
|
+
if (typeof arg === 'string') {
|
|
8
|
+
return JSON.stringify(arg);
|
|
9
|
+
}
|
|
10
|
+
if (typeof arg === 'number' || typeof arg === 'boolean') {
|
|
11
|
+
return String(arg);
|
|
12
|
+
}
|
|
13
|
+
if (typeof arg === 'object') {
|
|
14
|
+
const candidate = arg;
|
|
15
|
+
if (typeof candidate.selector === 'string') {
|
|
16
|
+
return prettySelector(candidate.selector);
|
|
17
|
+
}
|
|
18
|
+
if (typeof candidate.tagName === 'string') {
|
|
19
|
+
const text = (candidate.textContent ?? '').trim().slice(0, 24);
|
|
20
|
+
return `<${candidate.tagName.toLowerCase()}>${text ? ` "${text}"` : ''}`;
|
|
21
|
+
}
|
|
22
|
+
const json = JSON.stringify(arg);
|
|
23
|
+
return json.length <= 32 ? json : '{…}';
|
|
24
|
+
}
|
|
25
|
+
return String(arg);
|
|
26
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import type { PropDoc } from '@describe-me/core/types';
|
|
3
|
+
/**
|
|
4
|
+
* Read a component's props from its TypeScript type: one PropDoc per own prop,
|
|
5
|
+
* with the literal choices and the default from the destructuring pattern.
|
|
6
|
+
* Returns null when the export is not a callable taking a props object.
|
|
7
|
+
*/
|
|
8
|
+
export declare function extractProps(program: ts.Program, file: string, exportName: string): PropDoc[] | null;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
/** Type names that mean "renderable children" once TypeScript has printed them. */
|
|
3
|
+
const NODE_TYPE_NAMES = ['ReactNode', 'ReactElement', 'Element'];
|
|
4
|
+
/**
|
|
5
|
+
* Read a component's props from its TypeScript type: one PropDoc per own prop,
|
|
6
|
+
* with the literal choices and the default from the destructuring pattern.
|
|
7
|
+
* Returns null when the export is not a callable taking a props object.
|
|
8
|
+
*/
|
|
9
|
+
export function extractProps(program, file, exportName) {
|
|
10
|
+
const checker = program.getTypeChecker();
|
|
11
|
+
const sourceFile = program.getSourceFile(file);
|
|
12
|
+
if (!sourceFile) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
const component = exportedSymbol(checker, sourceFile, exportName);
|
|
16
|
+
const declaration = component?.declarations?.[0];
|
|
17
|
+
if (!component || !declaration) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const signature = componentSignature(checker, component, declaration);
|
|
21
|
+
if (!signature) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
// A component that takes no props is still a component, with nothing to list.
|
|
25
|
+
const [parameter] = signature.parameters;
|
|
26
|
+
if (!parameter) {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
const propsType = checker.getTypeOfSymbolAtLocation(parameter, declaration);
|
|
30
|
+
const defaults = defaultValues(declaration);
|
|
31
|
+
return propsType
|
|
32
|
+
.getProperties()
|
|
33
|
+
.filter(isDeclaredInProject)
|
|
34
|
+
.map((prop) => toPropDoc(checker, prop, declaration, defaults));
|
|
35
|
+
}
|
|
36
|
+
/** The exported symbol behind `exportName`, with `export { X } from` aliases followed. */
|
|
37
|
+
function exportedSymbol(checker, sourceFile, exportName) {
|
|
38
|
+
const moduleSymbol = checker.getSymbolAtLocation(sourceFile);
|
|
39
|
+
if (!moduleSymbol) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
const exported = checker.getExportsOfModule(moduleSymbol).find((sym) => sym.name === exportName);
|
|
43
|
+
if (exported && exported.flags & ts.SymbolFlags.Alias) {
|
|
44
|
+
return checker.getAliasedSymbol(exported);
|
|
45
|
+
}
|
|
46
|
+
return exported;
|
|
47
|
+
}
|
|
48
|
+
/** The first call signature of the export, or null when it is not callable (not a component). */
|
|
49
|
+
function componentSignature(checker, component, declaration) {
|
|
50
|
+
const type = checker.getTypeOfSymbolAtLocation(component, declaration);
|
|
51
|
+
const [signature] = type.getCallSignatures();
|
|
52
|
+
return signature ?? null;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Drop props inherited from a library type. `Omit<ButtonHTMLAttributes<…>, …>`
|
|
56
|
+
* would otherwise contribute hundreds of DOM attributes nobody documents.
|
|
57
|
+
*/
|
|
58
|
+
function isDeclaredInProject(prop) {
|
|
59
|
+
return (prop.declarations ?? []).some((declaration) => {
|
|
60
|
+
return !declaration.getSourceFile().fileName.includes('/node_modules/');
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
function toPropDoc(checker, prop, location, defaults) {
|
|
64
|
+
// An optional prop carries `| undefined`, which says nothing the `required`
|
|
65
|
+
// flag does not already say and makes every printed type noisier.
|
|
66
|
+
const type = checker.getNonNullableType(checker.getTypeOfSymbolAtLocation(prop, location));
|
|
67
|
+
const description = ts.displayPartsToString(prop.getDocumentationComment(checker));
|
|
68
|
+
const shape = classify(checker, type);
|
|
69
|
+
return {
|
|
70
|
+
name: prop.name,
|
|
71
|
+
type: declaredTypeText(prop) ??
|
|
72
|
+
checker.typeToString(type, undefined, ts.TypeFormatFlags.NoTruncation),
|
|
73
|
+
required: !(prop.flags & ts.SymbolFlags.Optional),
|
|
74
|
+
defaultValue: defaults.get(prop.name),
|
|
75
|
+
kind: shape.kind,
|
|
76
|
+
values: shape.values,
|
|
77
|
+
description: description || undefined,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The type as the author wrote it (`ReactNode`), when the prop has an
|
|
82
|
+
* annotation. The checker would otherwise print the expanded alias, which for
|
|
83
|
+
* ReactNode is a union longer than the table column.
|
|
84
|
+
*/
|
|
85
|
+
function declaredTypeText(prop) {
|
|
86
|
+
for (const declaration of prop.declarations ?? []) {
|
|
87
|
+
if (ts.isPropertySignature(declaration) && declaration.type) {
|
|
88
|
+
return declaration.type.getText();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
/** What the viewer can do with a prop: pick from a list, toggle, type a value. */
|
|
94
|
+
function classify(checker, type) {
|
|
95
|
+
const members = type.isUnion() ? type.types : [type];
|
|
96
|
+
if (type.flags & ts.TypeFlags.Boolean || members.every(isBooleanLiteral)) {
|
|
97
|
+
return { kind: 'boolean', values: ['true', 'false'] };
|
|
98
|
+
}
|
|
99
|
+
if (members.every(isTextualLiteral)) {
|
|
100
|
+
return { kind: 'literals', values: members.map(literalText) };
|
|
101
|
+
}
|
|
102
|
+
if (type.flags & ts.TypeFlags.NumberLike) {
|
|
103
|
+
return { kind: 'number' };
|
|
104
|
+
}
|
|
105
|
+
if (type.flags & ts.TypeFlags.StringLike) {
|
|
106
|
+
return { kind: 'string' };
|
|
107
|
+
}
|
|
108
|
+
if (type.getCallSignatures().length > 0) {
|
|
109
|
+
return { kind: 'function' };
|
|
110
|
+
}
|
|
111
|
+
const printed = checker.typeToString(type);
|
|
112
|
+
if (NODE_TYPE_NAMES.some((name) => printed.includes(name))) {
|
|
113
|
+
return { kind: 'node' };
|
|
114
|
+
}
|
|
115
|
+
return { kind: 'other' };
|
|
116
|
+
}
|
|
117
|
+
function isBooleanLiteral(type) {
|
|
118
|
+
return (type.flags & ts.TypeFlags.BooleanLiteral) !== 0;
|
|
119
|
+
}
|
|
120
|
+
function isTextualLiteral(type) {
|
|
121
|
+
return type.isStringLiteral() || type.isNumberLiteral();
|
|
122
|
+
}
|
|
123
|
+
/** A literal as it would be written in source: `'sm'` for strings, `3` for numbers. */
|
|
124
|
+
function literalText(type) {
|
|
125
|
+
if (type.isStringLiteral()) {
|
|
126
|
+
return `'${type.value}'`;
|
|
127
|
+
}
|
|
128
|
+
if (type.isNumberLiteral()) {
|
|
129
|
+
return String(type.value);
|
|
130
|
+
}
|
|
131
|
+
return '';
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Defaults live in the destructuring pattern, not in the type, so they are read
|
|
135
|
+
* from the source text: `{ size = 'md' }` gives `size` → `'md'`.
|
|
136
|
+
*/
|
|
137
|
+
function defaultValues(declaration) {
|
|
138
|
+
const defaults = new Map();
|
|
139
|
+
const parameter = functionOf(declaration)?.parameters[0];
|
|
140
|
+
if (!parameter || !ts.isObjectBindingPattern(parameter.name)) {
|
|
141
|
+
return defaults;
|
|
142
|
+
}
|
|
143
|
+
for (const element of parameter.name.elements) {
|
|
144
|
+
if (element.initializer) {
|
|
145
|
+
defaults.set((element.propertyName ?? element.name).getText(), element.initializer.getText());
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return defaults;
|
|
149
|
+
}
|
|
150
|
+
/** The function behind a component declaration, however it was written. */
|
|
151
|
+
function functionOf(declaration) {
|
|
152
|
+
if (ts.isFunctionDeclaration(declaration) || ts.isFunctionExpression(declaration)) {
|
|
153
|
+
return declaration;
|
|
154
|
+
}
|
|
155
|
+
if (ts.isVariableDeclaration(declaration) && declaration.initializer) {
|
|
156
|
+
return asFunction(declaration.initializer);
|
|
157
|
+
}
|
|
158
|
+
if (ts.isExportAssignment(declaration)) {
|
|
159
|
+
return asFunction(declaration.expression);
|
|
160
|
+
}
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
function asFunction(expression) {
|
|
164
|
+
if (ts.isArrowFunction(expression) || ts.isFunctionExpression(expression)) {
|
|
165
|
+
return expression;
|
|
166
|
+
}
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { step, recorder } from '@describe-me/core';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { step, recorder } from '@describe-me/core';
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { dirname } from 'node:path';
|
|
2
|
+
import ts from 'typescript';
|
|
3
|
+
/**
|
|
4
|
+
* Used when the project has no tsconfig.json. Modern React defaults, so a
|
|
5
|
+
* component still types correctly instead of failing to parse its JSX.
|
|
6
|
+
*/
|
|
7
|
+
const FALLBACK_OPTIONS = {
|
|
8
|
+
jsx: ts.JsxEmit.ReactJSX,
|
|
9
|
+
module: ts.ModuleKind.ESNext,
|
|
10
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
11
|
+
strict: true,
|
|
12
|
+
target: ts.ScriptTarget.ES2022,
|
|
13
|
+
allowJs: true,
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* The project's own compiler options, so components are read the way the
|
|
17
|
+
* project reads them (paths, jsx runtime, module resolution).
|
|
18
|
+
*/
|
|
19
|
+
export function loadCompilerOptions(root) {
|
|
20
|
+
const configPath = ts.findConfigFile(root, ts.sys.fileExists);
|
|
21
|
+
if (!configPath) {
|
|
22
|
+
return FALLBACK_OPTIONS;
|
|
23
|
+
}
|
|
24
|
+
const { config, error } = ts.readConfigFile(configPath, ts.sys.readFile);
|
|
25
|
+
if (error || !config) {
|
|
26
|
+
return FALLBACK_OPTIONS;
|
|
27
|
+
}
|
|
28
|
+
const parsed = ts.parseJsonConfigFileContent(config, ts.sys, dirname(configPath));
|
|
29
|
+
return parsed.options;
|
|
30
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { page } from 'vitest/browser';
|
|
2
|
+
import { actionLabel } from './action-label.js';
|
|
3
|
+
import { recordAction } from './record-action.js';
|
|
4
|
+
/** Locator methods that act on the page. Query methods (`getByRole`, `nth`, ...) are left alone. */
|
|
5
|
+
const ACTION_METHODS = [
|
|
6
|
+
'click',
|
|
7
|
+
'dblClick',
|
|
8
|
+
'tripleClick',
|
|
9
|
+
'wheel',
|
|
10
|
+
'clear',
|
|
11
|
+
'hover',
|
|
12
|
+
'unhover',
|
|
13
|
+
'fill',
|
|
14
|
+
'dropTo',
|
|
15
|
+
'selectOptions',
|
|
16
|
+
'upload',
|
|
17
|
+
];
|
|
18
|
+
const PATCHED = Symbol.for('describe-me.patched');
|
|
19
|
+
/** Walk the prototype chain and return the first object that defines `name` itself. */
|
|
20
|
+
function findOwner(start, name) {
|
|
21
|
+
let current = start;
|
|
22
|
+
while (current && current !== Object.prototype) {
|
|
23
|
+
if (Object.prototype.hasOwnProperty.call(current, name)) {
|
|
24
|
+
return current;
|
|
25
|
+
}
|
|
26
|
+
current = Object.getPrototypeOf(current);
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Wrap every action method of Vitest's `Locator` so that
|
|
32
|
+
* `screen.getByRole('button').click()` records a frame, exactly like
|
|
33
|
+
* `userEvent.click()` does. Safe to call more than once.
|
|
34
|
+
*/
|
|
35
|
+
export function patchLocators() {
|
|
36
|
+
const sample = page.elementLocator(document.body);
|
|
37
|
+
const prototype = Object.getPrototypeOf(sample);
|
|
38
|
+
for (const method of ACTION_METHODS) {
|
|
39
|
+
const owner = findOwner(prototype, method);
|
|
40
|
+
if (!owner || typeof owner[method] !== 'function') {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const original = owner[method];
|
|
44
|
+
if (original[PATCHED]) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const wrapped = function (...args) {
|
|
48
|
+
const label = actionLabel(method, this.selector, args);
|
|
49
|
+
return recordAction(label, () => original.apply(this, args));
|
|
50
|
+
};
|
|
51
|
+
wrapped[PATCHED] = true;
|
|
52
|
+
owner[method] = wrapped;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { userEvent } from 'vitest/browser';
|
|
2
|
+
import { actionLabel } from './action-label.js';
|
|
3
|
+
import { recordAction } from './record-action.js';
|
|
4
|
+
/**
|
|
5
|
+
* `userEvent` methods that do not go through a locator. The element-based ones
|
|
6
|
+
* (`click`, `fill`, ...) delegate to `Locator` and are covered by `patchLocators`.
|
|
7
|
+
*/
|
|
8
|
+
const KEYBOARD_METHODS = ['type', 'tab', 'keyboard', 'copy', 'cut', 'paste'];
|
|
9
|
+
const PATCHED = Symbol.for('describe-me.patched');
|
|
10
|
+
/** Wrap the keyboard-level `userEvent` methods in place so each call records a frame. Safe to call more than once. */
|
|
11
|
+
export function patchUserEvent() {
|
|
12
|
+
const target = userEvent;
|
|
13
|
+
for (const method of KEYBOARD_METHODS) {
|
|
14
|
+
const original = target[method];
|
|
15
|
+
if (typeof original !== 'function' || original[PATCHED]) {
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
const wrapped = (...args) => {
|
|
19
|
+
const label = actionLabel(method, undefined, args);
|
|
20
|
+
return recordAction(label, () => original.apply(userEvent, args));
|
|
21
|
+
};
|
|
22
|
+
wrapped[PATCHED] = true;
|
|
23
|
+
target[method] = wrapped;
|
|
24
|
+
}
|
|
25
|
+
}
|
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ViteUserConfig } from 'vitest/config';
|
|
2
|
+
export interface DescribeMeOptions {
|
|
3
|
+
/** Set to false to run the same tests without recording, e.g. for benchmarks. Default: true. */
|
|
4
|
+
enabled?: boolean;
|
|
5
|
+
/** Which framework adapter replaces the test-library `render`. Default: 'react'. */
|
|
6
|
+
framework?: 'react';
|
|
7
|
+
/** Output directory, relative to the Vitest root. Default: `.describe-me`. */
|
|
8
|
+
outDir?: string;
|
|
9
|
+
}
|
|
10
|
+
type VitePlugin = NonNullable<ViteUserConfig['plugins']>[number];
|
|
11
|
+
/**
|
|
12
|
+
* One-line integration: `plugins: [describeMe()]`.
|
|
13
|
+
*
|
|
14
|
+
* Registers the setup file and the reporter, and redirects the framework's
|
|
15
|
+
* `render` import to our adapter so existing tests record frames unchanged.
|
|
16
|
+
* The adapter itself still needs the real module, so imports coming from
|
|
17
|
+
* inside it are left alone.
|
|
18
|
+
*/
|
|
19
|
+
export declare function describeMe(options?: DescribeMeOptions): VitePlugin;
|
|
20
|
+
export {};
|
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import DescribeMeReporter from './reporter.js';
|
|
2
|
+
/** For each framework: the render module tests import, and our adapter that wraps it. */
|
|
3
|
+
const RENDER_MODULES = {
|
|
4
|
+
react: { original: 'vitest-browser-react', adapter: '@describe-me/react' },
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* One-line integration: `plugins: [describeMe()]`.
|
|
8
|
+
*
|
|
9
|
+
* Registers the setup file and the reporter, and redirects the framework's
|
|
10
|
+
* `render` import to our adapter so existing tests record frames unchanged.
|
|
11
|
+
* The adapter itself still needs the real module, so imports coming from
|
|
12
|
+
* inside it are left alone.
|
|
13
|
+
*/
|
|
14
|
+
export function describeMe(options = {}) {
|
|
15
|
+
const { enabled = true, framework = 'react', outDir } = options;
|
|
16
|
+
const { original, adapter } = RENDER_MODULES[framework];
|
|
17
|
+
return {
|
|
18
|
+
name: 'describe-me',
|
|
19
|
+
enforce: 'pre',
|
|
20
|
+
config(userConfig) {
|
|
21
|
+
if (!enabled) {
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
const reporter = new DescribeMeReporter({ outDir });
|
|
25
|
+
// Vite concatenates arrays when merging, so only add `default` when the
|
|
26
|
+
// user has not chosen their own reporters; otherwise they would lose it.
|
|
27
|
+
const reporters = userConfig.test?.reporters ? [reporter] : ['default', reporter];
|
|
28
|
+
return {
|
|
29
|
+
// Vite's dependency scanner runs before our `resolveId` redirect, so it
|
|
30
|
+
// never sees the adapter. Discovering it mid-run makes Vite re-optimize
|
|
31
|
+
// and reload, and the test ends up with two copies of the bundled deps
|
|
32
|
+
// ("Vitest failed to find the runner"). Declaring the adapter up front
|
|
33
|
+
// keeps a single optimization pass.
|
|
34
|
+
optimizeDeps: {
|
|
35
|
+
include: [adapter, original],
|
|
36
|
+
},
|
|
37
|
+
test: {
|
|
38
|
+
setupFiles: ['@describe-me/vitest/setup'],
|
|
39
|
+
reporters,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
resolveId(source, importer) {
|
|
44
|
+
if (!enabled || source !== original) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
const fromAdapter = importer !== undefined &&
|
|
48
|
+
(importer.includes(adapter) || importer.includes('/packages/react/'));
|
|
49
|
+
if (fromAdapter) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return this.resolve(adapter, importer, { skipSelf: true });
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Playwright's internal selector, e.g.
|
|
3
|
+
* `internal:testid=[data-testid="__vitest_0__"s] >> internal:role=button[name="increment"i]`
|
|
4
|
+
* becomes `button "increment"`.
|
|
5
|
+
*/
|
|
6
|
+
export function prettySelector(selector) {
|
|
7
|
+
const last = selector
|
|
8
|
+
.split('>>')
|
|
9
|
+
.map((part) => part.trim())
|
|
10
|
+
.filter((part) => !part.startsWith('internal:testid'))
|
|
11
|
+
.pop() ?? selector;
|
|
12
|
+
const match = /^internal:(\w+)=(.*)$/.exec(last);
|
|
13
|
+
if (!match) {
|
|
14
|
+
return last;
|
|
15
|
+
}
|
|
16
|
+
const [, kind, rest] = match;
|
|
17
|
+
const name = /\[name=("[^"]*")[si]?\]/.exec(rest)?.[1];
|
|
18
|
+
const base = rest.replace(/\[.*$/, '').replace(/[si]$/, '');
|
|
19
|
+
if (kind === 'role') {
|
|
20
|
+
return name ? `${base} ${name}` : base;
|
|
21
|
+
}
|
|
22
|
+
if (kind === 'text' ||
|
|
23
|
+
kind === 'label' ||
|
|
24
|
+
kind === 'placeholder' ||
|
|
25
|
+
kind === 'alt' ||
|
|
26
|
+
kind === 'title') {
|
|
27
|
+
return `${kind} ${base}`;
|
|
28
|
+
}
|
|
29
|
+
return last;
|
|
30
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { recorder } from '@describe-me/core';
|
|
2
|
+
/**
|
|
3
|
+
* Nesting depth of interactions currently in flight. `userEvent.click(el)`
|
|
4
|
+
* delegates to `locator.click()`, and `copy()` delegates to `keyboard()`, so
|
|
5
|
+
* without this guard one gesture would produce two frames.
|
|
6
|
+
*/
|
|
7
|
+
let depth = 0;
|
|
8
|
+
/** Run one user interaction and record a frame for it, but only for the outermost call. */
|
|
9
|
+
export async function recordAction(label, run) {
|
|
10
|
+
depth++;
|
|
11
|
+
let result;
|
|
12
|
+
try {
|
|
13
|
+
result = await run();
|
|
14
|
+
}
|
|
15
|
+
finally {
|
|
16
|
+
depth--;
|
|
17
|
+
}
|
|
18
|
+
if (depth === 0) {
|
|
19
|
+
await recorder.capture('action', label);
|
|
20
|
+
}
|
|
21
|
+
return result;
|
|
22
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Reporter, TestModule, Vitest } from 'vitest/node';
|
|
2
|
+
export interface DescribeMeReporterOptions {
|
|
3
|
+
/** Output directory, relative to the Vitest root. Default: `.describe-me` */
|
|
4
|
+
outDir?: string;
|
|
5
|
+
}
|
|
6
|
+
export default class DescribeMeReporter implements Reporter {
|
|
7
|
+
private readonly outDirOption;
|
|
8
|
+
private root;
|
|
9
|
+
private outDir;
|
|
10
|
+
private snapshots;
|
|
11
|
+
private modules;
|
|
12
|
+
constructor(options?: DescribeMeReporterOptions);
|
|
13
|
+
onInit(vitest: Vitest): void;
|
|
14
|
+
/**
|
|
15
|
+
* A filtered run (`vitest run Button.test.tsx`) must not wipe the other
|
|
16
|
+
* modules from the manifest, so start from whatever the last run wrote.
|
|
17
|
+
* Modules whose file no longer exists are dropped.
|
|
18
|
+
*/
|
|
19
|
+
private seedFromPreviousRun;
|
|
20
|
+
onTestModuleEnd(module: TestModule): void;
|
|
21
|
+
onTestRunEnd(): Promise<void>;
|
|
22
|
+
private toManifestTest;
|
|
23
|
+
}
|
package/dist/reporter.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-side Vitest reporter. Collects frames from task.meta and writes
|
|
3
|
+
* `.describe-me/manifest.json` plus one file per snapshot.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { join, relative, resolve } from 'node:path';
|
|
7
|
+
import { META_KEY, } from '@describe-me/core/types';
|
|
8
|
+
import { collectComponentDocsSafely } from './collect-component-docs-safely.js';
|
|
9
|
+
import { componentEntries } from './component-entries.js';
|
|
10
|
+
import { SnapshotStore } from './snapshot-store.js';
|
|
11
|
+
import { testPath } from './test-path.js';
|
|
12
|
+
export default class DescribeMeReporter {
|
|
13
|
+
outDirOption;
|
|
14
|
+
root = process.cwd();
|
|
15
|
+
outDir = '';
|
|
16
|
+
snapshots;
|
|
17
|
+
modules = new Map();
|
|
18
|
+
constructor(options = {}) {
|
|
19
|
+
this.outDirOption = options.outDir ?? '.describe-me';
|
|
20
|
+
}
|
|
21
|
+
onInit(vitest) {
|
|
22
|
+
this.root = vitest.config.root;
|
|
23
|
+
this.outDir = resolve(this.root, this.outDirOption);
|
|
24
|
+
this.snapshots = new SnapshotStore(this.outDir);
|
|
25
|
+
this.seedFromPreviousRun();
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* A filtered run (`vitest run Button.test.tsx`) must not wipe the other
|
|
29
|
+
* modules from the manifest, so start from whatever the last run wrote.
|
|
30
|
+
* Modules whose file no longer exists are dropped.
|
|
31
|
+
*/
|
|
32
|
+
seedFromPreviousRun() {
|
|
33
|
+
const file = join(this.outDir, 'manifest.json');
|
|
34
|
+
if (!existsSync(file)) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const previous = JSON.parse(readFileSync(file, 'utf8'));
|
|
39
|
+
for (const mod of previous.modules ?? []) {
|
|
40
|
+
if (existsSync(resolve(this.root, mod.id))) {
|
|
41
|
+
this.modules.set(mod.id, mod);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// corrupt or incompatible manifest: start fresh
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
onTestModuleEnd(module) {
|
|
50
|
+
const id = relative(this.root, module.moduleId);
|
|
51
|
+
const tests = Array.from(module.children.allTests()).map((tc) => this.toManifestTest(tc));
|
|
52
|
+
this.modules.set(id, { id, tests });
|
|
53
|
+
}
|
|
54
|
+
async onTestRunEnd() {
|
|
55
|
+
const modules = Array.from(this.modules.values()).sort((left, right) => left.id.localeCompare(right.id));
|
|
56
|
+
const components = await collectComponentDocsSafely(this.root, componentEntries(modules, this.root));
|
|
57
|
+
const manifest = {
|
|
58
|
+
version: 1,
|
|
59
|
+
generatedAt: new Date().toISOString(),
|
|
60
|
+
root: this.root,
|
|
61
|
+
modules,
|
|
62
|
+
components,
|
|
63
|
+
};
|
|
64
|
+
this.snapshots.collectGarbage(manifest.modules.flatMap((mod) => mod.tests.flatMap((test) => test.frames.map((frame) => frame.snapshot))));
|
|
65
|
+
writeFileSync(join(this.outDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
|
66
|
+
}
|
|
67
|
+
toManifestTest(tc) {
|
|
68
|
+
const record = tc.meta()[META_KEY];
|
|
69
|
+
const result = tc.result();
|
|
70
|
+
const frames = (record?.frames ?? []).map((frame) => ({
|
|
71
|
+
id: frame.id,
|
|
72
|
+
kind: frame.kind,
|
|
73
|
+
label: frame.label,
|
|
74
|
+
at: frame.at,
|
|
75
|
+
meta: frame.meta,
|
|
76
|
+
snapshot: this.snapshots.write(frame.snapshot),
|
|
77
|
+
}));
|
|
78
|
+
return {
|
|
79
|
+
id: tc.id,
|
|
80
|
+
name: tc.name,
|
|
81
|
+
path: testPath(tc),
|
|
82
|
+
fullName: tc.fullName,
|
|
83
|
+
state: result.state ?? 'pending',
|
|
84
|
+
duration: tc.diagnostic()?.duration,
|
|
85
|
+
errors: result.errors?.map((error) => ({ message: error.message, stack: error.stack })),
|
|
86
|
+
component: record?.component,
|
|
87
|
+
frames,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
/** Where a component lives, and under which name its module exports it. */
|
|
3
|
+
export interface ResolvedComponent {
|
|
4
|
+
/** Absolute path of the source file. */
|
|
5
|
+
file: string;
|
|
6
|
+
/** Exported name, or `default` for a default export. */
|
|
7
|
+
exportName: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Find the file a test imports a component from. The React adapter only
|
|
11
|
+
* reports a name, so the test module itself is the index: whichever import
|
|
12
|
+
* binds that name points at the component. Returns null when no import
|
|
13
|
+
* matches or the match comes from a library.
|
|
14
|
+
*/
|
|
15
|
+
export declare function resolveComponentFile(testFile: string, componentName: string, options: ts.CompilerOptions): ResolvedComponent | null;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { extname } from 'node:path';
|
|
3
|
+
import ts from 'typescript';
|
|
4
|
+
const SCRIPT_KINDS = {
|
|
5
|
+
'.ts': ts.ScriptKind.TS,
|
|
6
|
+
'.tsx': ts.ScriptKind.TSX,
|
|
7
|
+
'.mts': ts.ScriptKind.TS,
|
|
8
|
+
'.cts': ts.ScriptKind.TS,
|
|
9
|
+
'.js': ts.ScriptKind.JS,
|
|
10
|
+
'.jsx': ts.ScriptKind.JSX,
|
|
11
|
+
'.mjs': ts.ScriptKind.JS,
|
|
12
|
+
'.cjs': ts.ScriptKind.JS,
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Find the file a test imports a component from. The React adapter only
|
|
16
|
+
* reports a name, so the test module itself is the index: whichever import
|
|
17
|
+
* binds that name points at the component. Returns null when no import
|
|
18
|
+
* matches or the match comes from a library.
|
|
19
|
+
*/
|
|
20
|
+
export function resolveComponentFile(testFile, componentName, options) {
|
|
21
|
+
const source = parseModule(testFile);
|
|
22
|
+
if (!source) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
for (const statement of source.statements) {
|
|
26
|
+
if (!ts.isImportDeclaration(statement)) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const exportName = exportNameFor(statement, componentName);
|
|
30
|
+
if (exportName === null) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const file = resolveSpecifier(statement, testFile, options);
|
|
34
|
+
if (file) {
|
|
35
|
+
return { file, exportName };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
/** Parse the test module on its own; a full program would cost far more than reading its imports. */
|
|
41
|
+
function parseModule(testFile) {
|
|
42
|
+
try {
|
|
43
|
+
const text = readFileSync(testFile, 'utf8');
|
|
44
|
+
const kind = SCRIPT_KINDS[extname(testFile)] ?? ts.ScriptKind.TSX;
|
|
45
|
+
return ts.createSourceFile(testFile, text, ts.ScriptTarget.Latest, true, kind);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** The exported name this import binds to `componentName`, or null when it binds something else. */
|
|
52
|
+
function exportNameFor(statement, componentName) {
|
|
53
|
+
const clause = statement.importClause;
|
|
54
|
+
if (!clause) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
if (clause.name?.text === componentName) {
|
|
58
|
+
return 'default';
|
|
59
|
+
}
|
|
60
|
+
const bindings = clause.namedBindings;
|
|
61
|
+
if (!bindings || !ts.isNamedImports(bindings)) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
for (const element of bindings.elements) {
|
|
65
|
+
const original = element.propertyName ?? element.name;
|
|
66
|
+
// The adapter reports the component's own name: that is the local name,
|
|
67
|
+
// unless the test aliased the import, in which case it is the exported one.
|
|
68
|
+
if (element.name.text === componentName || original.text === componentName) {
|
|
69
|
+
return original.text;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
/** Resolve the import specifier to a file in the project, skipping anything from node_modules. */
|
|
75
|
+
function resolveSpecifier(statement, testFile, options) {
|
|
76
|
+
const specifier = statement.moduleSpecifier;
|
|
77
|
+
if (!ts.isStringLiteral(specifier)) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
const { resolvedModule } = ts.resolveModuleName(specifier.text, testFile, options, ts.sys);
|
|
81
|
+
if (!resolvedModule || resolvedModule.isExternalLibraryImport) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
return resolvedModule.resolvedFileName || null;
|
|
85
|
+
}
|
package/dist/setup.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/setup.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vitest setup file: `setupFiles: ['@describe-me/vitest/setup']`.
|
|
3
|
+
* Runs in the browser. Patches Vitest's locators and userEvent so every
|
|
4
|
+
* interaction records a frame, begins a recording before each test, and hands
|
|
5
|
+
* the frames to the reporter via task.meta.
|
|
6
|
+
*/
|
|
7
|
+
import { afterEach, beforeEach } from 'vitest';
|
|
8
|
+
import { META_KEY, recorder } from '@describe-me/core';
|
|
9
|
+
import { patchLocators } from './patch-locators.js';
|
|
10
|
+
import { patchUserEvent } from './patch-user-event.js';
|
|
11
|
+
patchLocators();
|
|
12
|
+
patchUserEvent();
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
recorder.begin();
|
|
15
|
+
});
|
|
16
|
+
afterEach(async (context) => {
|
|
17
|
+
await recorder.capture('end', 'end of test');
|
|
18
|
+
context.task.meta[META_KEY] = recorder.end();
|
|
19
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-addressed store for the serialized DOM of every frame, living in
|
|
3
|
+
* `<outDir>/snapshots`. Identical DOM across tests or runs is written once.
|
|
4
|
+
*/
|
|
5
|
+
export declare class SnapshotStore {
|
|
6
|
+
private readonly dir;
|
|
7
|
+
constructor(outDir: string);
|
|
8
|
+
/** Write one snapshot and return its path relative to the manifest. */
|
|
9
|
+
write(snapshot: unknown): string;
|
|
10
|
+
/** Drop the files no manifest frame points at any more. */
|
|
11
|
+
collectGarbage(referenced: Iterable<string>): void;
|
|
12
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* Content-addressed store for the serialized DOM of every frame, living in
|
|
6
|
+
* `<outDir>/snapshots`. Identical DOM across tests or runs is written once.
|
|
7
|
+
*/
|
|
8
|
+
export class SnapshotStore {
|
|
9
|
+
dir;
|
|
10
|
+
constructor(outDir) {
|
|
11
|
+
this.dir = join(outDir, 'snapshots');
|
|
12
|
+
mkdirSync(this.dir, { recursive: true });
|
|
13
|
+
}
|
|
14
|
+
/** Write one snapshot and return its path relative to the manifest. */
|
|
15
|
+
write(snapshot) {
|
|
16
|
+
const json = JSON.stringify(snapshot);
|
|
17
|
+
const hash = createHash('sha1').update(json).digest('hex').slice(0, 16);
|
|
18
|
+
const file = `${hash}.json`;
|
|
19
|
+
const abs = join(this.dir, file);
|
|
20
|
+
if (!existsSync(abs)) {
|
|
21
|
+
writeFileSync(abs, json);
|
|
22
|
+
}
|
|
23
|
+
return `snapshots/${file}`;
|
|
24
|
+
}
|
|
25
|
+
/** Drop the files no manifest frame points at any more. */
|
|
26
|
+
collectGarbage(referenced) {
|
|
27
|
+
const keep = new Set();
|
|
28
|
+
for (const path of referenced) {
|
|
29
|
+
keep.add(path.replace('snapshots/', ''));
|
|
30
|
+
}
|
|
31
|
+
for (const file of readdirSync(this.dir)) {
|
|
32
|
+
if (!keep.has(file)) {
|
|
33
|
+
rmSync(join(this.dir, file), { force: true });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Suite names of a test case, from outermost to innermost. */
|
|
2
|
+
export function testPath(tc) {
|
|
3
|
+
const path = [];
|
|
4
|
+
let parent = tc.parent;
|
|
5
|
+
while (parent && parent.type === 'suite') {
|
|
6
|
+
path.unshift(parent.name);
|
|
7
|
+
parent = parent.parent;
|
|
8
|
+
}
|
|
9
|
+
return path;
|
|
10
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@describe-me/vitest",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Vitest integration for describe-me: plugin, setup file and reporter",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"vitest",
|
|
7
|
+
"storybook",
|
|
8
|
+
"component documentation",
|
|
9
|
+
"visual testing",
|
|
10
|
+
"testing"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"author": "Grzegorz Łotysz",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/grzehub/describe-me.git",
|
|
17
|
+
"directory": "packages/vitest"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://grzehub.github.io/describe-me/",
|
|
20
|
+
"bugs": "https://github.com/grzehub/describe-me/issues",
|
|
21
|
+
"type": "module",
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=20"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"default": "./dist/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./setup": {
|
|
34
|
+
"types": "./dist/setup.d.ts",
|
|
35
|
+
"default": "./dist/setup.js"
|
|
36
|
+
},
|
|
37
|
+
"./reporter": {
|
|
38
|
+
"types": "./dist/reporter.d.ts",
|
|
39
|
+
"default": "./dist/reporter.js"
|
|
40
|
+
},
|
|
41
|
+
"./plugin": {
|
|
42
|
+
"types": "./dist/plugin.d.ts",
|
|
43
|
+
"default": "./dist/plugin.js"
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"files": [
|
|
47
|
+
"dist",
|
|
48
|
+
"README.md",
|
|
49
|
+
"LICENSE"
|
|
50
|
+
],
|
|
51
|
+
"sideEffects": [
|
|
52
|
+
"./dist/setup.js"
|
|
53
|
+
],
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"@describe-me/core": "^0.1.0"
|
|
56
|
+
},
|
|
57
|
+
"peerDependencies": {
|
|
58
|
+
"vitest": ">=4",
|
|
59
|
+
"typescript": ">=5.0"
|
|
60
|
+
},
|
|
61
|
+
"peerDependenciesMeta": {
|
|
62
|
+
"typescript": {
|
|
63
|
+
"optional": true
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
"devDependencies": {
|
|
67
|
+
"vitest": "^5.0.1",
|
|
68
|
+
"@types/node": "^22",
|
|
69
|
+
"typescript": "^6"
|
|
70
|
+
},
|
|
71
|
+
"scripts": {
|
|
72
|
+
"build": "rimraf dist && tsc -p tsconfig.json"
|
|
73
|
+
}
|
|
74
|
+
}
|