tslaws 0.6.4 → 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.
- package/README.md +8 -8
- package/dist/api.d.ts +10 -0
- package/dist/api.js +7 -0
- package/dist/browser.d.ts +2 -2
- package/dist/browser.js +2 -6
- package/dist/check.d.ts +2 -0
- package/dist/check.js +133 -41
- package/dist/cli.d.ts +18 -0
- package/dist/cli.js +149 -54
- package/dist/compile.d.ts +12 -0
- package/dist/compile.js +207 -6
- package/dist/coverage.d.ts +13 -1
- package/dist/coverage.js +16 -23
- package/dist/derived.d.ts +8 -0
- package/dist/derived.js +52 -0
- package/dist/e2e.d.ts +0 -9
- package/dist/e2e.js +0 -20
- package/dist/generators.d.ts +8 -8
- package/dist/generators.js +59 -99
- package/dist/globs.d.ts +3 -0
- package/dist/globs.js +24 -0
- package/dist/init.js +5 -5
- package/dist/lawFiles.d.ts +21 -0
- package/dist/lawFiles.js +95 -0
- package/dist/lawKinds.d.ts +38 -0
- package/dist/lawKinds.js +210 -0
- package/dist/lawNames.d.ts +6 -0
- package/dist/lawNames.js +22 -0
- package/dist/list.d.ts +22 -0
- package/dist/list.js +36 -0
- package/dist/lock.d.ts +16 -3
- package/dist/lock.js +23 -4
- package/dist/lower.d.ts +10 -0
- package/dist/lower.js +178 -0
- package/dist/mcp.d.ts +2 -8
- package/dist/mcp.js +40 -61
- package/dist/migrate/arbitraries.d.ts +23 -0
- package/dist/migrate/arbitraries.js +202 -0
- package/dist/migrate/convert.d.ts +20 -0
- package/dist/migrate/convert.js +915 -0
- package/dist/migrate/framework.d.ts +48 -0
- package/dist/migrate/framework.js +249 -0
- package/dist/migrate/index.d.ts +16 -0
- package/dist/migrate/index.js +155 -0
- package/dist/migrate/report.d.ts +35 -0
- package/dist/migrate/report.js +40 -0
- package/dist/migrate/source.d.ts +42 -0
- package/dist/migrate/source.js +163 -0
- package/dist/migrate/tests.d.ts +44 -0
- package/dist/migrate/tests.js +184 -0
- package/dist/plan.d.ts +50 -0
- package/dist/plan.js +118 -0
- package/dist/pool.d.ts +21 -0
- package/dist/pool.js +54 -0
- package/dist/promote.d.ts +30 -2
- package/dist/promote.js +133 -7
- package/dist/property.d.ts +12 -26
- package/dist/property.js +33 -74
- package/dist/propertyRun.d.ts +24 -0
- package/dist/propertyRun.js +56 -0
- package/dist/propertyWorker.js +7 -9
- package/dist/proposals.d.ts +43 -0
- package/dist/proposals.js +192 -0
- package/dist/regressions.js +23 -2
- package/dist/smt.js +9 -8
- package/dist/types.d.ts +2 -0
- package/hooks/AGENTS.snippet.md +2 -2
- package/package.json +8 -6
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import type { Source } from './source.ts';
|
|
3
|
+
export declare const sources: readonly ["vitest", "jest", "rstest", "bun", "node", "fast-check", "playwright"];
|
|
4
|
+
export type SourceName = (typeof sources)[number];
|
|
5
|
+
export declare function isSourceName(value: string | undefined): value is SourceName;
|
|
6
|
+
/** Modules that provide test structure or matchers. Their imports are rebuilt rather than copied. */
|
|
7
|
+
export declare const frameworkModules: Record<string, SourceName>;
|
|
8
|
+
/** Where `expect` (and the mock helpers) come from when a file uses the framework's globals instead of importing them. */
|
|
9
|
+
export declare const globalsModule: Partial<Record<SourceName, {
|
|
10
|
+
module: string;
|
|
11
|
+
names: string[];
|
|
12
|
+
}>>;
|
|
13
|
+
/** Names that only describe test structure: an import of one is dropped, never kept for the laws. */
|
|
14
|
+
export declare const structural: Set<string>;
|
|
15
|
+
export interface Callee {
|
|
16
|
+
kind: 'describe' | 'test' | 'hook' | 'config';
|
|
17
|
+
hook?: 'before' | 'after';
|
|
18
|
+
/** Playwright's `use`, `setTimeout`, `step`, `info`, `configure`, `extend`. */
|
|
19
|
+
config?: string;
|
|
20
|
+
mods: Set<string>;
|
|
21
|
+
/** The call of ``.each(...)`` or the tagged template of ``.each`...` ``. */
|
|
22
|
+
each?: ts.CallExpression | ts.TaggedTemplateExpression;
|
|
23
|
+
/** `.for` (vitest) hands each row to the test as one argument instead of spreading it. */
|
|
24
|
+
forRows?: boolean;
|
|
25
|
+
/** `it.prop(arbitraries, options?)` of @fast-check/vitest and @fast-check/jest. */
|
|
26
|
+
prop?: ts.CallExpression;
|
|
27
|
+
/** `.runIf(...)` / `.skipIf(...)`, which choose at run time. */
|
|
28
|
+
conditional?: string;
|
|
29
|
+
}
|
|
30
|
+
/** `a.b.c` as `['a', 'b', 'c']`, or undefined when the expression is anything else. */
|
|
31
|
+
export declare function chainOf(node: ts.Expression): string[] | undefined;
|
|
32
|
+
export interface Bindings {
|
|
33
|
+
/** Local name to the name the framework exports it under (`it as spec` gives `spec -> it`). */
|
|
34
|
+
imported: Map<string, string>;
|
|
35
|
+
/** Local names that are a framework's whole module (`import * as t from 'vitest'`). */
|
|
36
|
+
namespaces: Set<string>;
|
|
37
|
+
/** Local names the file declares itself or imports from elsewhere: they are not the framework's globals. */
|
|
38
|
+
own: Set<string>;
|
|
39
|
+
/** Local names of fast-check's namespace (`fc`) and its named exports (local to exported name). */
|
|
40
|
+
fcNamespaces: Set<string>;
|
|
41
|
+
fcNamed: Map<string, string>;
|
|
42
|
+
}
|
|
43
|
+
export declare function bindingsOf(source: Source): Bindings;
|
|
44
|
+
/**
|
|
45
|
+
* What a call's callee means in a test file: `describe`, `it.skip`, `test.each(table)`, `beforeEach`, `test.describe.serial`.
|
|
46
|
+
* Undefined when the callee is not the framework's.
|
|
47
|
+
*/
|
|
48
|
+
export declare function classify(callee: ts.Expression, b: Bindings, playwright: boolean): Callee | undefined;
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// The test frameworks migrate reads: which module each comes from, what its calls are named, and how a call in a test
|
|
2
|
+
// file is told apart (by the import it came from, or by the global the framework provides).
|
|
3
|
+
import ts from 'typescript';
|
|
4
|
+
export const sources = ['vitest', 'jest', 'rstest', 'bun', 'node', 'fast-check', 'playwright'];
|
|
5
|
+
export function isSourceName(value) {
|
|
6
|
+
return sources.includes(value ?? '');
|
|
7
|
+
}
|
|
8
|
+
/** Modules that provide test structure or matchers. Their imports are rebuilt rather than copied. */
|
|
9
|
+
export const frameworkModules = {
|
|
10
|
+
vitest: 'vitest',
|
|
11
|
+
'@jest/globals': 'jest',
|
|
12
|
+
'@rstest/core': 'rstest',
|
|
13
|
+
'bun:test': 'bun',
|
|
14
|
+
'node:test': 'node',
|
|
15
|
+
'@playwright/test': 'playwright',
|
|
16
|
+
'fast-check': 'fast-check',
|
|
17
|
+
'@fast-check/vitest': 'vitest',
|
|
18
|
+
'@fast-check/jest': 'jest',
|
|
19
|
+
};
|
|
20
|
+
/** Where `expect` (and the mock helpers) come from when a file uses the framework's globals instead of importing them. */
|
|
21
|
+
export const globalsModule = {
|
|
22
|
+
vitest: { module: 'vitest', names: ['expect', 'vi'] },
|
|
23
|
+
jest: { module: '@jest/globals', names: ['expect', 'jest'] },
|
|
24
|
+
rstest: { module: '@rstest/core', names: ['expect', 'rs', 'rstest'] },
|
|
25
|
+
bun: { module: 'bun:test', names: ['expect', 'mock', 'spyOn'] },
|
|
26
|
+
playwright: { module: '@playwright/test', names: ['expect'] },
|
|
27
|
+
};
|
|
28
|
+
/** Names that only describe test structure: an import of one is dropped, never kept for the laws. */
|
|
29
|
+
export const structural = new Set([
|
|
30
|
+
'describe',
|
|
31
|
+
'suite',
|
|
32
|
+
'it',
|
|
33
|
+
'test',
|
|
34
|
+
'xit',
|
|
35
|
+
'xtest',
|
|
36
|
+
'xdescribe',
|
|
37
|
+
'fit',
|
|
38
|
+
'ftest',
|
|
39
|
+
'fdescribe',
|
|
40
|
+
'beforeEach',
|
|
41
|
+
'afterEach',
|
|
42
|
+
'beforeAll',
|
|
43
|
+
'afterAll',
|
|
44
|
+
'before',
|
|
45
|
+
'after',
|
|
46
|
+
]);
|
|
47
|
+
/** `a.b.c` as `['a', 'b', 'c']`, or undefined when the expression is anything else. */
|
|
48
|
+
export function chainOf(node) {
|
|
49
|
+
if (ts.isIdentifier(node))
|
|
50
|
+
return [node.text];
|
|
51
|
+
if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.name)) {
|
|
52
|
+
const inner = chainOf(node.expression);
|
|
53
|
+
return inner === undefined ? undefined : [...inner, node.name.text];
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
function declaredTopLevel(statement) {
|
|
58
|
+
const names = [];
|
|
59
|
+
const bind = (name) => {
|
|
60
|
+
if (ts.isIdentifier(name))
|
|
61
|
+
names.push(name.text);
|
|
62
|
+
else
|
|
63
|
+
for (const element of name.elements)
|
|
64
|
+
if (ts.isBindingElement(element))
|
|
65
|
+
bind(element.name);
|
|
66
|
+
};
|
|
67
|
+
if (ts.isVariableStatement(statement))
|
|
68
|
+
for (const d of statement.declarationList.declarations)
|
|
69
|
+
bind(d.name);
|
|
70
|
+
else if ((ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) && statement.name !== undefined)
|
|
71
|
+
names.push(statement.name.text);
|
|
72
|
+
return names;
|
|
73
|
+
}
|
|
74
|
+
export function bindingsOf(source) {
|
|
75
|
+
const b = {
|
|
76
|
+
imported: new Map(),
|
|
77
|
+
namespaces: new Set(),
|
|
78
|
+
own: new Set(),
|
|
79
|
+
fcNamespaces: new Set(),
|
|
80
|
+
fcNamed: new Map(),
|
|
81
|
+
};
|
|
82
|
+
for (const statement of source.file.statements) {
|
|
83
|
+
if (!ts.isImportDeclaration(statement)) {
|
|
84
|
+
for (const name of declaredTopLevel(statement))
|
|
85
|
+
b.own.add(name);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const clause = statement.importClause;
|
|
89
|
+
if (clause === undefined || !ts.isStringLiteral(statement.moduleSpecifier))
|
|
90
|
+
continue;
|
|
91
|
+
const module = statement.moduleSpecifier.text;
|
|
92
|
+
const known = frameworkModules[module] !== undefined;
|
|
93
|
+
const fastCheck = module === 'fast-check';
|
|
94
|
+
const fastCheckRelated = fastCheck || module === '@fast-check/vitest' || module === '@fast-check/jest';
|
|
95
|
+
if (clause.name !== undefined) {
|
|
96
|
+
if (fastCheck)
|
|
97
|
+
b.fcNamespaces.add(clause.name.text);
|
|
98
|
+
else if (known)
|
|
99
|
+
b.imported.set(clause.name.text, 'test');
|
|
100
|
+
else
|
|
101
|
+
b.own.add(clause.name.text);
|
|
102
|
+
}
|
|
103
|
+
const named = clause.namedBindings;
|
|
104
|
+
if (named === undefined)
|
|
105
|
+
continue;
|
|
106
|
+
if (ts.isNamespaceImport(named)) {
|
|
107
|
+
if (fastCheck)
|
|
108
|
+
b.fcNamespaces.add(named.name.text);
|
|
109
|
+
else if (known)
|
|
110
|
+
b.namespaces.add(named.name.text);
|
|
111
|
+
else
|
|
112
|
+
b.own.add(named.name.text);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
for (const element of named.elements) {
|
|
116
|
+
const local = element.name.text;
|
|
117
|
+
const original = (element.propertyName ?? element.name).text;
|
|
118
|
+
if (fastCheck)
|
|
119
|
+
b.fcNamed.set(local, original);
|
|
120
|
+
else if (known && fastCheckRelated && original === 'fc')
|
|
121
|
+
b.fcNamespaces.add(local);
|
|
122
|
+
else if (known)
|
|
123
|
+
b.imported.set(local, original);
|
|
124
|
+
else
|
|
125
|
+
b.own.add(local);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return b;
|
|
129
|
+
}
|
|
130
|
+
const modifiers = new Set([
|
|
131
|
+
'skip',
|
|
132
|
+
'todo',
|
|
133
|
+
'fixme',
|
|
134
|
+
'only',
|
|
135
|
+
'concurrent',
|
|
136
|
+
'sequential',
|
|
137
|
+
'serial',
|
|
138
|
+
'parallel',
|
|
139
|
+
'shuffle',
|
|
140
|
+
'slow',
|
|
141
|
+
'fails',
|
|
142
|
+
'failing',
|
|
143
|
+
'fail',
|
|
144
|
+
]);
|
|
145
|
+
const aliases = {
|
|
146
|
+
xit: ['it', 'skip'],
|
|
147
|
+
xtest: ['test', 'skip'],
|
|
148
|
+
xdescribe: ['describe', 'skip'],
|
|
149
|
+
fit: ['it', 'only'],
|
|
150
|
+
ftest: ['test', 'only'],
|
|
151
|
+
fdescribe: ['describe', 'only'],
|
|
152
|
+
};
|
|
153
|
+
const playwrightConfig = new Set(['use', 'setTimeout', 'step', 'info', 'extend', 'configure']);
|
|
154
|
+
/**
|
|
155
|
+
* What a call's callee means in a test file: `describe`, `it.skip`, `test.each(table)`, `beforeEach`, `test.describe.serial`.
|
|
156
|
+
* Undefined when the callee is not the framework's.
|
|
157
|
+
*/
|
|
158
|
+
export function classify(callee, b, playwright) {
|
|
159
|
+
const mods = new Set();
|
|
160
|
+
let each;
|
|
161
|
+
let prop;
|
|
162
|
+
let conditional;
|
|
163
|
+
let forRows = false;
|
|
164
|
+
let node = callee;
|
|
165
|
+
if (ts.isTaggedTemplateExpression(node)) {
|
|
166
|
+
const tag = chainOf(node.tag);
|
|
167
|
+
if (tag?.[tag.length - 1] !== 'each')
|
|
168
|
+
return undefined;
|
|
169
|
+
each = node;
|
|
170
|
+
node = node.tag;
|
|
171
|
+
}
|
|
172
|
+
else if (ts.isCallExpression(node)) {
|
|
173
|
+
const inner = chainOf(node.expression);
|
|
174
|
+
const last = inner?.[inner.length - 1];
|
|
175
|
+
if (last === 'each' || last === 'for') {
|
|
176
|
+
each = node;
|
|
177
|
+
forRows = last === 'for';
|
|
178
|
+
}
|
|
179
|
+
else if (last === 'prop')
|
|
180
|
+
prop = node;
|
|
181
|
+
else if (last === 'runIf' || last === 'skipIf')
|
|
182
|
+
conditional = last;
|
|
183
|
+
else
|
|
184
|
+
return undefined;
|
|
185
|
+
node = node.expression;
|
|
186
|
+
}
|
|
187
|
+
const chain = chainOf(node);
|
|
188
|
+
if (chain === undefined)
|
|
189
|
+
return undefined;
|
|
190
|
+
let [root, ...rest] = chain;
|
|
191
|
+
if (b.namespaces.has(root) && rest.length > 0)
|
|
192
|
+
[root, ...rest] = rest;
|
|
193
|
+
else if (b.namespaces.has(root))
|
|
194
|
+
return undefined;
|
|
195
|
+
if (each !== undefined || prop !== undefined || conditional !== undefined)
|
|
196
|
+
rest = rest.slice(0, -1);
|
|
197
|
+
let name = b.imported.get(root);
|
|
198
|
+
if (name === undefined) {
|
|
199
|
+
if (b.own.has(root) || !structural.has(root))
|
|
200
|
+
return undefined;
|
|
201
|
+
name = root;
|
|
202
|
+
}
|
|
203
|
+
const alias = aliases[name];
|
|
204
|
+
if (alias !== undefined) {
|
|
205
|
+
name = alias[0];
|
|
206
|
+
mods.add(alias[1]);
|
|
207
|
+
}
|
|
208
|
+
const result = { kind: 'test', mods, each, forRows, prop, conditional };
|
|
209
|
+
if (playwright) {
|
|
210
|
+
if (name !== 'test')
|
|
211
|
+
return undefined;
|
|
212
|
+
const sub = rest[0];
|
|
213
|
+
if (sub === 'describe') {
|
|
214
|
+
result.kind = 'describe';
|
|
215
|
+
rest = rest.slice(1);
|
|
216
|
+
}
|
|
217
|
+
else if (sub === 'beforeEach' || sub === 'afterEach' || sub === 'beforeAll' || sub === 'afterAll') {
|
|
218
|
+
name = sub;
|
|
219
|
+
rest = rest.slice(1);
|
|
220
|
+
}
|
|
221
|
+
else if (sub !== undefined && playwrightConfig.has(sub)) {
|
|
222
|
+
result.kind = 'config';
|
|
223
|
+
result.config = sub;
|
|
224
|
+
rest = rest.slice(1);
|
|
225
|
+
}
|
|
226
|
+
// `test.describe.configure(...)`
|
|
227
|
+
if (result.kind === 'describe' && rest[0] !== undefined && playwrightConfig.has(rest[0])) {
|
|
228
|
+
result.kind = 'config';
|
|
229
|
+
result.config = rest[0];
|
|
230
|
+
rest = rest.slice(1);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
for (const m of rest) {
|
|
234
|
+
if (!modifiers.has(m))
|
|
235
|
+
return undefined;
|
|
236
|
+
mods.add(m);
|
|
237
|
+
}
|
|
238
|
+
if (result.kind === 'config')
|
|
239
|
+
return result;
|
|
240
|
+
if (name === 'describe' || name === 'suite')
|
|
241
|
+
result.kind = 'describe';
|
|
242
|
+
else if (/^(before|after)(Each|All)?$/.test(name)) {
|
|
243
|
+
result.kind = 'hook';
|
|
244
|
+
result.hook = name.startsWith('before') ? 'before' : 'after';
|
|
245
|
+
}
|
|
246
|
+
else if (name !== 'it' && name !== 'test')
|
|
247
|
+
return undefined;
|
|
248
|
+
return result;
|
|
249
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type SourceName } from './framework.ts';
|
|
2
|
+
import { type MigrateReport } from './report.ts';
|
|
3
|
+
export interface MigrateOptions {
|
|
4
|
+
cwd: string;
|
|
5
|
+
from: SourceName;
|
|
6
|
+
/** Test files or directories; empty means every test file under `cwd`. */
|
|
7
|
+
files: string[];
|
|
8
|
+
write: boolean;
|
|
9
|
+
/** A directory to write the laws under instead of next to the sources. */
|
|
10
|
+
out?: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function targetFor(file: string, options: Pick<MigrateOptions, 'cwd' | 'from' | 'out'>): string;
|
|
13
|
+
export declare function migrate(options: MigrateOptions): MigrateReport;
|
|
14
|
+
export declare const migrateUsage: string;
|
|
15
|
+
/** The `migrate` command: prints the report. Status 0 when it ran, 2 when the arguments are wrong. */
|
|
16
|
+
export declare function migrateCommand(cwd: string, from: string | undefined, files: string[], flags: Map<string, string | true>, write: (text: string) => void): number;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// `tslaws migrate <from> [files...] [--write] [--out DIR]`: reads test files of another framework and writes the same
|
|
2
|
+
// tests as laws. A dry run says what would happen; `--write` creates files that do not exist and never replaces one.
|
|
3
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
5
|
+
import { convertTestFile } from "./convert.js";
|
|
6
|
+
import { frameworkModules, isSourceName, sources } from "./framework.js";
|
|
7
|
+
import { renderReport } from "./report.js";
|
|
8
|
+
const ignoredDirs = new Set(['node_modules', '.tslaws', '.git', 'dist', 'build', 'coverage']);
|
|
9
|
+
const testFile = /\.(?:test|spec)\.[cm]?[jt]sx?$/;
|
|
10
|
+
const sourceFile = /\.[cm]?[jt]sx?$/;
|
|
11
|
+
const slash = (path) => path.replace(/\\/g, '/');
|
|
12
|
+
function walk(dir, into) {
|
|
13
|
+
for (const entry of readdirSync(dir).sort()) {
|
|
14
|
+
if (ignoredDirs.has(entry))
|
|
15
|
+
continue;
|
|
16
|
+
const full = join(dir, entry);
|
|
17
|
+
if (statSync(full).isDirectory())
|
|
18
|
+
walk(full, into);
|
|
19
|
+
else if (testFile.test(entry) && !entry.endsWith('.d.ts'))
|
|
20
|
+
into.push(full);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** The framework modules a file imports or requires. */
|
|
24
|
+
function modulesOf(text) {
|
|
25
|
+
const found = new Set();
|
|
26
|
+
for (const match of text.matchAll(/(?:from\s+|import\s*\(\s*|require\s*\(\s*|import\s+)(['"])([^'"]+)\1/g))
|
|
27
|
+
if (frameworkModules[match[2]] !== undefined)
|
|
28
|
+
found.add(match[2]);
|
|
29
|
+
return found;
|
|
30
|
+
}
|
|
31
|
+
/** Whether a file found by searching (not named by the person) belongs to the framework being migrated. */
|
|
32
|
+
function belongs(text, from) {
|
|
33
|
+
const modules = [...modulesOf(text)];
|
|
34
|
+
if (from === 'fast-check')
|
|
35
|
+
return modules.some((m) => frameworkModules[m] === 'fast-check' || m.startsWith('@fast-check/'));
|
|
36
|
+
if (from === 'playwright')
|
|
37
|
+
return modules.includes('@playwright/test');
|
|
38
|
+
const own = modules.filter((m) => frameworkModules[m] === from);
|
|
39
|
+
if (own.length > 0)
|
|
40
|
+
return true;
|
|
41
|
+
// No framework import at all: the file relies on globals, which vitest, jest and rstest provide.
|
|
42
|
+
return (!modules.some((m) => frameworkModules[m] !== 'fast-check') &&
|
|
43
|
+
(from === 'vitest' || from === 'jest' || from === 'rstest'));
|
|
44
|
+
}
|
|
45
|
+
/** `src/a.test.ts` to `a`, the name the laws file is built on. */
|
|
46
|
+
function baseOf(file) {
|
|
47
|
+
const name = basename(file);
|
|
48
|
+
const stripped = name.replace(/\.(?:test|spec)(?=\.[cm]?[jt]sx?$)/, '').replace(/\.[cm]?[jt]sx?$/, '');
|
|
49
|
+
return stripped === '' ? name : stripped;
|
|
50
|
+
}
|
|
51
|
+
export function targetFor(file, options) {
|
|
52
|
+
const suffix = options.from === 'playwright' ? '.browser.laws.ts' : '.laws.ts';
|
|
53
|
+
const name = `${baseOf(file)}${suffix}`;
|
|
54
|
+
const dir = dirname(resolve(options.cwd, file));
|
|
55
|
+
if (options.out === undefined)
|
|
56
|
+
return join(dir, name);
|
|
57
|
+
const mirrored = relative(options.cwd, dir);
|
|
58
|
+
return join(resolve(options.cwd, options.out), mirrored.startsWith('..') ? '' : mirrored, name);
|
|
59
|
+
}
|
|
60
|
+
/** How a module specifier written in the source reads from the file the laws are written to. */
|
|
61
|
+
function rewriter(sourceDir, targetDir) {
|
|
62
|
+
return (specifier) => {
|
|
63
|
+
if (sourceDir === targetDir)
|
|
64
|
+
return specifier;
|
|
65
|
+
let rel = slash(relative(targetDir, resolve(sourceDir, specifier)));
|
|
66
|
+
if (rel === '')
|
|
67
|
+
rel = '.';
|
|
68
|
+
return rel.startsWith('.') ? rel : `./${rel}`;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
export function migrate(options) {
|
|
72
|
+
const { cwd, from } = options;
|
|
73
|
+
const found = [];
|
|
74
|
+
const explicit = new Set();
|
|
75
|
+
for (const file of options.files.length > 0 ? options.files : ['.']) {
|
|
76
|
+
const full = resolve(cwd, file);
|
|
77
|
+
if (!existsSync(full))
|
|
78
|
+
throw new Error(`no such file: ${file}`);
|
|
79
|
+
if (statSync(full).isDirectory())
|
|
80
|
+
walk(full, found);
|
|
81
|
+
else if (sourceFile.test(full)) {
|
|
82
|
+
found.push(full);
|
|
83
|
+
explicit.add(full);
|
|
84
|
+
}
|
|
85
|
+
else
|
|
86
|
+
throw new Error(`not a TypeScript or JavaScript file: ${file}`);
|
|
87
|
+
}
|
|
88
|
+
const files = [];
|
|
89
|
+
const claimed = new Set();
|
|
90
|
+
for (const full of found) {
|
|
91
|
+
if (/\.laws\.[cm]?[jt]s$/.test(full))
|
|
92
|
+
continue;
|
|
93
|
+
const text = readFileSync(full, 'utf8');
|
|
94
|
+
if (!explicit.has(full) && !belongs(text, from))
|
|
95
|
+
continue;
|
|
96
|
+
const source = slash(relative(cwd, full));
|
|
97
|
+
const target = targetFor(full, options);
|
|
98
|
+
const converted = convertTestFile({
|
|
99
|
+
path: full,
|
|
100
|
+
text,
|
|
101
|
+
from,
|
|
102
|
+
rewrite: rewriter(dirname(full), dirname(target)),
|
|
103
|
+
});
|
|
104
|
+
for (const entry of converted.entries)
|
|
105
|
+
entry.file = source;
|
|
106
|
+
const result = { source, laws: converted.laws, entries: converted.entries, written: 'none' };
|
|
107
|
+
if (converted.output !== undefined && converted.laws.length > 0) {
|
|
108
|
+
result.target = slash(relative(cwd, target));
|
|
109
|
+
result.output = converted.output;
|
|
110
|
+
if (claimed.has(target)) {
|
|
111
|
+
result.written = 'exists';
|
|
112
|
+
result.entries.push({
|
|
113
|
+
file: source,
|
|
114
|
+
line: 1,
|
|
115
|
+
kind: 'note',
|
|
116
|
+
message: `${result.target} is already the target of another test file in this run; this file is not written`,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
else if (existsSync(target))
|
|
120
|
+
result.written = 'exists';
|
|
121
|
+
else if (options.write) {
|
|
122
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
123
|
+
writeFileSync(target, converted.output);
|
|
124
|
+
result.written = 'written';
|
|
125
|
+
}
|
|
126
|
+
else
|
|
127
|
+
result.written = 'dry-run';
|
|
128
|
+
claimed.add(target);
|
|
129
|
+
}
|
|
130
|
+
files.push(result);
|
|
131
|
+
}
|
|
132
|
+
return { from, write: options.write, files };
|
|
133
|
+
}
|
|
134
|
+
export const migrateUsage = `tslaws migrate ${sources.join('|')} [files...] [--write] [--out DIR] convert existing tests into laws (*.laws.ts; Playwright becomes *.browser.laws.ts); a dry run unless --write; never overwrites; the new laws are not in the lock`;
|
|
135
|
+
/** The `migrate` command: prints the report. Status 0 when it ran, 2 when the arguments are wrong. */
|
|
136
|
+
export function migrateCommand(cwd, from, files, flags, write) {
|
|
137
|
+
if (!isSourceName(from)) {
|
|
138
|
+
write(`tslaws migrate needs the framework to read: one of ${sources.join(', ')}\n`);
|
|
139
|
+
return 2;
|
|
140
|
+
}
|
|
141
|
+
const out = flags.get('out');
|
|
142
|
+
if (out === true || out === '') {
|
|
143
|
+
write('--out needs a directory\n');
|
|
144
|
+
return 2;
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
const report = migrate({ cwd, from, files, write: flags.has('write'), out });
|
|
148
|
+
write(renderReport(report));
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
153
|
+
return 2;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type EntryKind =
|
|
2
|
+
/** A test that was skipped on purpose (`.skip`, `.todo`, `xit`): no law is written for it. */
|
|
3
|
+
'skipped'
|
|
4
|
+
/** A construct with no faithful law: it is left out or kept as it was, and the person decides. */
|
|
5
|
+
| 'unconverted'
|
|
6
|
+
/** Converted, but something was dropped or changes meaning (a timeout, a `.only`, a hook that now runs per law). */
|
|
7
|
+
| 'note';
|
|
8
|
+
export interface Entry {
|
|
9
|
+
file: string;
|
|
10
|
+
line: number;
|
|
11
|
+
kind: EntryKind;
|
|
12
|
+
message: string;
|
|
13
|
+
}
|
|
14
|
+
export interface FileResult {
|
|
15
|
+
/** The source, relative to the working directory, with forward slashes. */
|
|
16
|
+
source: string;
|
|
17
|
+
/** Where the laws would be written (relative to the working directory), or undefined when none were produced. */
|
|
18
|
+
target?: string;
|
|
19
|
+
/** The text of the converted file. */
|
|
20
|
+
output?: string;
|
|
21
|
+
/** Names of the laws written, in order. */
|
|
22
|
+
laws: string[];
|
|
23
|
+
entries: Entry[];
|
|
24
|
+
/** Set by `--write`: whether the file was written, and why not otherwise. */
|
|
25
|
+
written?: 'written' | 'exists' | 'dry-run' | 'none';
|
|
26
|
+
}
|
|
27
|
+
export interface MigrateReport {
|
|
28
|
+
from: string;
|
|
29
|
+
write: boolean;
|
|
30
|
+
files: FileResult[];
|
|
31
|
+
}
|
|
32
|
+
export declare function lawCount(report: MigrateReport): number;
|
|
33
|
+
export declare function entriesOf(report: MigrateReport, kind: EntryKind): Entry[];
|
|
34
|
+
/** The text `tslaws migrate` prints: a summary, then what was skipped, what could not be converted, and notes. */
|
|
35
|
+
export declare function renderReport(report: MigrateReport): string;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// What a migration tells the person: files read, laws written, and every construct it left behind, with file:line.
|
|
2
|
+
export function lawCount(report) {
|
|
3
|
+
return report.files.reduce((total, f) => total + f.laws.length, 0);
|
|
4
|
+
}
|
|
5
|
+
export function entriesOf(report, kind) {
|
|
6
|
+
return report.files.flatMap((f) => f.entries.filter((e) => e.kind === kind));
|
|
7
|
+
}
|
|
8
|
+
/** The text `tslaws migrate` prints: a summary, then what was skipped, what could not be converted, and notes. */
|
|
9
|
+
export function renderReport(report) {
|
|
10
|
+
const lines = [`tslaws migrate ${report.from}${report.write ? '' : ' (dry run: nothing is written)'}`, ''];
|
|
11
|
+
for (const file of report.files) {
|
|
12
|
+
const counts = ['skipped', 'unconverted', 'note']
|
|
13
|
+
.map((k) => [k, file.entries.filter((e) => e.kind === k).length])
|
|
14
|
+
.filter(([, n]) => n > 0)
|
|
15
|
+
.map(([k, n]) => `${n} ${k === 'unconverted' ? 'not converted' : k === 'note' ? (n === 1 ? 'note' : 'notes') : k}`);
|
|
16
|
+
const where = file.target === undefined
|
|
17
|
+
? 'no laws'
|
|
18
|
+
: `${file.target}: ${file.laws.length} law${file.laws.length === 1 ? '' : 's'}`;
|
|
19
|
+
const state = file.written === 'written' ? ' [written]' : file.written === 'exists' ? ' [exists: not overwritten]' : '';
|
|
20
|
+
lines.push(`${file.source} -> ${where}${counts.length > 0 ? ` (${counts.join(', ')})` : ''}${state}`);
|
|
21
|
+
}
|
|
22
|
+
const section = (title, kind) => {
|
|
23
|
+
const found = entriesOf(report, kind);
|
|
24
|
+
if (found.length === 0)
|
|
25
|
+
return;
|
|
26
|
+
lines.push('', `${title} (${found.length}):`);
|
|
27
|
+
for (const e of found)
|
|
28
|
+
lines.push(` ${e.file}:${e.line} ${e.message}`);
|
|
29
|
+
};
|
|
30
|
+
section('Skipped, no law written', 'skipped');
|
|
31
|
+
section('Not converted', 'unconverted');
|
|
32
|
+
section('Notes', 'note');
|
|
33
|
+
const converted = report.files.filter((f) => f.target !== undefined).length;
|
|
34
|
+
lines.push('', `Files: ${report.files.length} read, ${converted} converted. Laws emitted: ${lawCount(report)}. Not converted: ${entriesOf(report, 'unconverted').length}.`);
|
|
35
|
+
if (!report.write && converted > 0)
|
|
36
|
+
lines.push('Dry run: pass --write to write the files. Existing files are never overwritten.');
|
|
37
|
+
if (converted > 0)
|
|
38
|
+
lines.push('The new laws are not in tsc-laws.lock. Read them, run `tslaws check`, and have a person run `tslaws accept`.');
|
|
39
|
+
return `${lines.join('\n')}\n`;
|
|
40
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
export interface Edit {
|
|
3
|
+
start: number;
|
|
4
|
+
end: number;
|
|
5
|
+
text: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function scriptKindOf(file: string): ts.ScriptKind;
|
|
8
|
+
export declare class Source {
|
|
9
|
+
readonly file: ts.SourceFile;
|
|
10
|
+
readonly text: string;
|
|
11
|
+
private readonly edits;
|
|
12
|
+
private readonly lines;
|
|
13
|
+
private readonly protectedLines;
|
|
14
|
+
/** Every identifier that appears anywhere in the file, so new names can avoid them. */
|
|
15
|
+
readonly taken: Set<string>;
|
|
16
|
+
readonly path: string;
|
|
17
|
+
constructor(path: string, text: string);
|
|
18
|
+
/** 1-based line of a position. */
|
|
19
|
+
lineOf(pos: number): number;
|
|
20
|
+
lineOfNode(node: ts.Node): number;
|
|
21
|
+
/** Replaces `start..end` with a single line of text; the same edit made twice counts once. */
|
|
22
|
+
edit(start: number, end: number, text: string): void;
|
|
23
|
+
/** The original text of `start..end` with the edits inside it applied. */
|
|
24
|
+
private render;
|
|
25
|
+
/** The text of a node, edits applied, on its own (no reindentation). */
|
|
26
|
+
textOf(node: ts.Node): string;
|
|
27
|
+
/** A statement's range including the comments written above it and a comment after it on the same line. */
|
|
28
|
+
rangeOf(node: ts.Node): [number, number];
|
|
29
|
+
/** The statements' text at `depth` levels of two spaces, comments included, template strings untouched. */
|
|
30
|
+
statements(nodes: readonly ts.Node[], depth: number): string[];
|
|
31
|
+
/** Copies `start..end` as lines at `depth`, keeping the shape the code had. */
|
|
32
|
+
block(start: number, end: number, depth: number): string[];
|
|
33
|
+
}
|
|
34
|
+
/** Names a statement declares at its top level (variables, destructured ones, functions, classes). */
|
|
35
|
+
export declare function declaredNames(statement: ts.Statement): string[];
|
|
36
|
+
/** Every identifier written inside `node`. */
|
|
37
|
+
export declare function identifiersIn(node: ts.Node, into: Set<string>): void;
|
|
38
|
+
/** A string as a single-quoted TypeScript literal. */
|
|
39
|
+
export declare function quote(value: string): string;
|
|
40
|
+
/** A string literal's value when `node` is a plain string or a template with no substitutions. */
|
|
41
|
+
export declare function literalText(node: ts.Node | undefined): string | undefined;
|
|
42
|
+
export declare function unwrap(node: ts.Expression): ts.Expression;
|