tslaws 0.0.0-stage → 0.3.2

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.
Files changed (74) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +215 -2
  3. package/bin/tslaws.js +5 -0
  4. package/dist/adapters/angular.d.ts +42 -0
  5. package/dist/adapters/angular.js +40 -0
  6. package/dist/adapters/dom.d.ts +5 -0
  7. package/dist/adapters/dom.js +17 -0
  8. package/dist/adapters/element.d.ts +10 -0
  9. package/dist/adapters/element.js +28 -0
  10. package/dist/adapters/react.d.ts +24 -0
  11. package/dist/adapters/react.js +15 -0
  12. package/dist/api.d.ts +59 -0
  13. package/dist/api.js +103 -0
  14. package/dist/browser.d.ts +24 -0
  15. package/dist/browser.js +252 -0
  16. package/dist/browserApi.d.ts +62 -0
  17. package/dist/browserApi.js +139 -0
  18. package/dist/bundled.d.ts +17 -0
  19. package/dist/bundled.js +67 -0
  20. package/dist/check.d.ts +12 -0
  21. package/dist/check.js +269 -0
  22. package/dist/cli.d.ts +1 -0
  23. package/dist/cli.js +360 -0
  24. package/dist/compile.d.ts +15 -0
  25. package/dist/compile.js +142 -0
  26. package/dist/component.d.ts +37 -0
  27. package/dist/component.js +99 -0
  28. package/dist/coverage.d.ts +40 -0
  29. package/dist/coverage.js +237 -0
  30. package/dist/e2e.d.ts +46 -0
  31. package/dist/e2e.js +322 -0
  32. package/dist/e2eApi.d.ts +80 -0
  33. package/dist/e2eApi.js +140 -0
  34. package/dist/generators.d.ts +18 -0
  35. package/dist/generators.js +184 -0
  36. package/dist/hints.d.ts +3 -0
  37. package/dist/hints.js +37 -0
  38. package/dist/init.d.ts +23 -0
  39. package/dist/init.js +139 -0
  40. package/dist/lock.d.ts +16 -0
  41. package/dist/lock.js +40 -0
  42. package/dist/mcp.d.ts +30 -0
  43. package/dist/mcp.js +301 -0
  44. package/dist/mutate.d.ts +54 -0
  45. package/dist/mutate.js +293 -0
  46. package/dist/promote.d.ts +38 -0
  47. package/dist/promote.js +217 -0
  48. package/dist/property.d.ts +37 -0
  49. package/dist/property.js +172 -0
  50. package/dist/propertyWorker.d.ts +1 -0
  51. package/dist/propertyWorker.js +24 -0
  52. package/dist/queries.d.ts +37 -0
  53. package/dist/queries.js +297 -0
  54. package/dist/regressions.d.ts +31 -0
  55. package/dist/regressions.js +153 -0
  56. package/dist/reporters.d.ts +22 -0
  57. package/dist/reporters.js +160 -0
  58. package/dist/settings.d.ts +16 -0
  59. package/dist/settings.js +15 -0
  60. package/dist/smt.d.ts +33 -0
  61. package/dist/smt.js +396 -0
  62. package/dist/symbolic.d.ts +200 -0
  63. package/dist/symbolic.js +1727 -0
  64. package/dist/types.d.ts +77 -0
  65. package/dist/types.js +1 -0
  66. package/dist/ui.d.ts +16 -0
  67. package/dist/ui.js +326 -0
  68. package/dist/uiPage.d.ts +1 -0
  69. package/dist/uiPage.js +4 -0
  70. package/hooks/AGENTS.snippet.md +10 -0
  71. package/hooks/claude-hook.mjs +35 -0
  72. package/hooks/claude-settings.example.json +8 -0
  73. package/package.json +85 -4
  74. package/ui/index.html +1132 -0
@@ -0,0 +1,40 @@
1
+ import ts from 'typescript';
2
+ import { type E2eConfig } from './e2e.ts';
3
+ export interface CoverageConfig {
4
+ cover?: string[];
5
+ /** Compiler options the runner passes to tsc; see `compilerFlags` in compile.ts for the supported keys. */
6
+ compilerOptions?: Record<string, unknown>;
7
+ /** End-to-end laws: the app to drive; see `parseE2eConfig`. */
8
+ e2e?: E2eConfig;
9
+ /** Run settings; a command line flag beats them and they beat the built-in default. */
10
+ runs?: number;
11
+ propertyTimeoutMs?: number;
12
+ proofTimeoutMs?: number;
13
+ bound?: number;
14
+ }
15
+ export interface Uncovered {
16
+ file: string;
17
+ name: string;
18
+ }
19
+ export interface CoverageReport {
20
+ uncovered: Uncovered[];
21
+ /** `file::name` of every function marked `@tslaws-exempt`. */
22
+ exempt: string[];
23
+ /** The source files the check covers, for the compiler to emit. */
24
+ files: string[];
25
+ }
26
+ /** Validates the top level of tslaws.json: known keys only, and the run settings are integers of at least 1. */
27
+ export declare function parseConfig(raw: unknown): CoverageConfig;
28
+ export declare function readConfig(cwd: string): CoverageConfig;
29
+ /** Source files matching the `cover` globs (a leading `!` excludes), minus laws files and declarations. */
30
+ export declare function coverFiles(cwd: string, globs: string[]): string[];
31
+ export interface CoverageInput {
32
+ cwd: string;
33
+ outDir: string;
34
+ lawFiles: string[];
35
+ /** Files named by the `cover` globs; empty means every module the laws reach. */
36
+ coverFiles: string[];
37
+ /** Declarations the compiler emitted for the program. */
38
+ program: ts.Program;
39
+ }
40
+ export declare function analyzeCoverage(input: CoverageInput): CoverageReport;
@@ -0,0 +1,237 @@
1
+ // Coverage: code is checked against the laws only if a law reaches it. Every exported function or class in the
2
+ // covered modules must be reachable from some law through calls, or a person must have exempted it.
3
+ //
4
+ // Covered modules are the `cover` globs of tslaws.json, or, without that file, every module the laws import
5
+ // (directly or through other project modules). A function carrying `@tslaws-exempt` in its doc comment is exempt,
6
+ // but only once the exemption is in tsc-laws.lock, so an agent cannot exempt its own code.
7
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
8
+ import { dirname, join, relative, resolve } from 'node:path';
9
+ import ts from 'typescript';
10
+ import { parseE2eConfig } from "./e2e.js";
11
+ const settingKeys = ['runs', 'propertyTimeoutMs', 'proofTimeoutMs', 'bound'];
12
+ const configKeys = ['cover', 'compilerOptions', 'e2e', ...settingKeys];
13
+ /** Validates the top level of tslaws.json: known keys only, and the run settings are integers of at least 1. */
14
+ export function parseConfig(raw) {
15
+ const fail = (message) => {
16
+ throw new Error(`tslaws.json: ${message}`);
17
+ };
18
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
19
+ return fail('must be a JSON object');
20
+ const object = raw;
21
+ for (const key of Object.keys(object)) {
22
+ if (key !== '$schema' && !configKeys.includes(key))
23
+ fail(`unknown option "${key}"; supported options are ${configKeys.join(', ')}`);
24
+ }
25
+ for (const key of settingKeys) {
26
+ const value = object[key];
27
+ if (value !== undefined && (typeof value !== 'number' || !Number.isInteger(value) || value < 1))
28
+ fail(`"${key}" must be an integer of at least 1, got ${JSON.stringify(value)}`);
29
+ }
30
+ return object;
31
+ }
32
+ export function readConfig(cwd) {
33
+ const path = join(cwd, 'tslaws.json');
34
+ if (!existsSync(path))
35
+ return {};
36
+ const config = parseConfig(JSON.parse(readFileSync(path, 'utf8')));
37
+ if (config.e2e !== undefined)
38
+ config.e2e = parseE2eConfig(config.e2e);
39
+ return config;
40
+ }
41
+ function globToRegExp(glob) {
42
+ let out = '';
43
+ for (let i = 0; i < glob.length; i += 1) {
44
+ const ch = glob[i];
45
+ if (ch === '*' && glob[i + 1] === '*') {
46
+ out += '.*';
47
+ i += glob[i + 2] === '/' ? 2 : 1;
48
+ }
49
+ else if (ch === '*')
50
+ out += '[^/]*';
51
+ else if (ch === '?')
52
+ out += '[^/]';
53
+ else
54
+ out += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
55
+ }
56
+ return new RegExp(`^${out}$`);
57
+ }
58
+ /** Source files matching the `cover` globs (a leading `!` excludes), minus laws files and declarations. */
59
+ export function coverFiles(cwd, globs) {
60
+ // Plain JavaScript sources are covered only when the project compiles them (`allowJs`).
61
+ const js = readConfig(cwd).compilerOptions?.allowJs === true;
62
+ const include = globs.filter((g) => !g.startsWith('!')).map(globToRegExp);
63
+ const exclude = globs.filter((g) => g.startsWith('!')).map((g) => globToRegExp(g.slice(1)));
64
+ const found = [];
65
+ const walk = (dir) => {
66
+ for (const entry of readdirSync(dir)) {
67
+ if (entry === 'node_modules' || entry === '.tslaws' || entry === '.git')
68
+ continue;
69
+ const full = join(dir, entry);
70
+ if (statSync(full).isDirectory())
71
+ walk(full);
72
+ else {
73
+ const file = relative(cwd, full).replaceAll('\\', '/');
74
+ if (!(js ? /\.(ts|tsx|mts|cts|js)$/ : /\.(ts|tsx|mts|cts)$/).test(file))
75
+ continue;
76
+ if (/\.d\.(ts|mts|cts)$/.test(file) || /\.laws\.ts$/.test(file))
77
+ continue;
78
+ if (include.some((r) => r.test(file)) && !exclude.some((r) => r.test(file)))
79
+ found.push(file);
80
+ }
81
+ }
82
+ };
83
+ walk(cwd);
84
+ return found.sort();
85
+ }
86
+ function parseModule(jsFile, modules) {
87
+ const known = modules.get(jsFile);
88
+ if (known !== undefined)
89
+ return known;
90
+ if (!existsSync(jsFile))
91
+ return undefined;
92
+ const text = readFileSync(jsFile, 'utf8');
93
+ const source = ts.createSourceFile(jsFile, text, ts.ScriptTarget.ES2022, true, ts.ScriptKind.JS);
94
+ const module = { nodes: new Map(), imports: new Map() };
95
+ modules.set(jsFile, module);
96
+ const exempt = (statement) => (ts.getLeadingCommentRanges(text, statement.getFullStart()) ?? []).some((r) => text.slice(r.pos, r.end).includes('@tslaws-exempt'));
97
+ for (const statement of source.statements) {
98
+ if ((ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) && statement.name !== undefined)
99
+ module.nodes.set(statement.name.text, { name: statement.name.text, body: statement, exempt: exempt(statement) });
100
+ if (!ts.isVariableStatement(statement))
101
+ continue;
102
+ for (const declaration of statement.declarationList.declarations) {
103
+ if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined)
104
+ continue;
105
+ const init = declaration.initializer;
106
+ if (ts.isArrowFunction(init) || ts.isFunctionExpression(init) || ts.isClassExpression(init))
107
+ module.nodes.set(declaration.name.text, { name: declaration.name.text, body: init, exempt: exempt(statement) });
108
+ else if (ts.isCallExpression(init) &&
109
+ ts.isIdentifier(init.expression) &&
110
+ init.expression.text === 'require' &&
111
+ init.arguments.length === 1 &&
112
+ ts.isStringLiteral(init.arguments[0]) &&
113
+ init.arguments[0].text.startsWith('.')) {
114
+ const base = resolve(dirname(jsFile), init.arguments[0].text);
115
+ module.imports.set(declaration.name.text, /\.[cm]?js$/.test(base) ? base : `${base}.js`);
116
+ }
117
+ }
118
+ }
119
+ return module;
120
+ }
121
+ function exportedFunctions(program, dts) {
122
+ const checker = program.getTypeChecker();
123
+ const source = program.getSourceFile(dts);
124
+ const moduleSymbol = source === undefined ? undefined : checker.getSymbolAtLocation(source);
125
+ if (moduleSymbol === undefined)
126
+ return [];
127
+ const names = [];
128
+ for (const symbol of checker.getExportsOfModule(moduleSymbol)) {
129
+ const target = symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol;
130
+ if (target.flags & (ts.SymbolFlags.Function | ts.SymbolFlags.Class))
131
+ names.push(symbol.getName());
132
+ else if (target.flags & ts.SymbolFlags.Variable) {
133
+ const declaration = target.valueDeclaration ?? target.declarations?.[0];
134
+ if (declaration !== undefined &&
135
+ checker.getTypeOfSymbolAtLocation(target, declaration).getCallSignatures().length > 0)
136
+ names.push(symbol.getName());
137
+ }
138
+ }
139
+ return names;
140
+ }
141
+ const jsOf = (outDir, file) => join(outDir, file.replace(/\.(ts|tsx|mts|cts)$/, '.js'));
142
+ const dtsOf = (outDir, file) => join(outDir, file.replace(/\.(ts|tsx|mts|cts|js)$/, '.d.ts'));
143
+ /** The project source behind an emitted module: TypeScript, JSX or, with `allowJs`, JavaScript. */
144
+ const sourceOf = (outDir, cwd, js) => {
145
+ const rel = relative(outDir, js).replaceAll('\\', '/');
146
+ if (rel === '')
147
+ return relative(cwd, js);
148
+ const base = rel.replace(/\.js$/, '');
149
+ const candidates = [`${base}.ts`, `${base}.tsx`, `${base}.js`];
150
+ return candidates.find((c) => existsSync(join(cwd, c))) ?? candidates[0];
151
+ };
152
+ export function analyzeCoverage(input) {
153
+ const modules = new Map();
154
+ const key = (js, name) => `${js}::${name}`;
155
+ const edges = (js, node) => {
156
+ const module = modules.get(js);
157
+ const out = [];
158
+ const visit = (n) => {
159
+ if (ts.isIdentifier(n) && module.nodes.has(n.text) && n.text !== node.name)
160
+ out.push(key(js, n.text));
161
+ if (ts.isPropertyAccessExpression(n) && ts.isIdentifier(n.expression)) {
162
+ const target = module.imports.get(n.expression.text);
163
+ if (target !== undefined) {
164
+ parseModule(target, modules);
165
+ out.push(key(target, n.name.text));
166
+ }
167
+ }
168
+ ts.forEachChild(n, visit);
169
+ };
170
+ visit(node.body);
171
+ return out;
172
+ };
173
+ // Everything a law calls, transitively, is checked by that law.
174
+ const reached = new Set();
175
+ const queue = [];
176
+ const lawJs = input.lawFiles.map((f) => jsOf(input.outDir, f));
177
+ for (const js of lawJs) {
178
+ const module = parseModule(js, modules);
179
+ if (module === undefined)
180
+ continue;
181
+ for (const node of module.nodes.values())
182
+ queue.push(key(js, node.name));
183
+ }
184
+ while (queue.length > 0) {
185
+ const current = queue.pop();
186
+ if (reached.has(current))
187
+ continue;
188
+ reached.add(current);
189
+ const [js, name] = current.split('::');
190
+ const module = parseModule(js, modules);
191
+ const node = module?.nodes.get(name);
192
+ if (node !== undefined)
193
+ for (const next of edges(js, node))
194
+ if (!reached.has(next))
195
+ queue.push(next);
196
+ }
197
+ // The modules in scope: the cover globs, or every project module the laws import.
198
+ const scope = new Set();
199
+ if (input.coverFiles.length > 0)
200
+ for (const f of input.coverFiles)
201
+ scope.add(jsOf(input.outDir, f));
202
+ else {
203
+ const visit = (js) => {
204
+ const module = parseModule(js, modules);
205
+ if (module === undefined)
206
+ return;
207
+ for (const target of module.imports.values()) {
208
+ if (!scope.has(target) && !lawJs.includes(target)) {
209
+ scope.add(target);
210
+ visit(target);
211
+ }
212
+ }
213
+ };
214
+ for (const js of lawJs)
215
+ visit(js);
216
+ }
217
+ const uncovered = [];
218
+ const exempt = [];
219
+ for (const js of scope) {
220
+ const module = parseModule(js, modules);
221
+ if (module === undefined)
222
+ continue;
223
+ const file = sourceOf(input.outDir, input.cwd, js);
224
+ for (const name of exportedFunctions(input.program, dtsOf(input.outDir, file))) {
225
+ const node = module.nodes.get(name);
226
+ if (node?.exempt === true)
227
+ exempt.push(`${file}::${name}`);
228
+ else if (!reached.has(key(js, name)))
229
+ uncovered.push({ file, name });
230
+ }
231
+ }
232
+ return {
233
+ uncovered: uncovered.sort((a, b) => `${a.file}::${a.name}`.localeCompare(`${b.file}::${b.name}`)),
234
+ exempt: exempt.sort(),
235
+ files: [...scope].map((js) => sourceOf(input.outDir, input.cwd, js)).sort(),
236
+ };
237
+ }
package/dist/e2e.d.ts ADDED
@@ -0,0 +1,46 @@
1
+ import type * as fc from 'fast-check';
2
+ import { type PropertyOutcome } from './property.ts';
3
+ import type { CheckOptions } from './types.ts';
4
+ /** `a.e2e.laws.ts` is an accepted alias: it always drives a running app. */
5
+ export declare function isE2eLawFile(file: string): boolean;
6
+ /** Whether a law file imports both helper sets, which would put one file at two levels. */
7
+ export declare function importsBoth(text: string): boolean;
8
+ /**
9
+ * The level a law file runs at. `*.browser.laws.ts` is a component law (in a page) unless it imports from
10
+ * `tslaws/e2e`, which makes it an end-to-end law (in Node, driving a running app); `*.e2e.laws.ts` is always e2e.
11
+ */
12
+ export declare function lawMode(file: string, text: string): 'node' | 'component' | 'e2e';
13
+ export interface E2eServerConfig {
14
+ /** Shell command that starts the app. It runs from your project, like a package script. */
15
+ command: string;
16
+ /** Address that answers (any status below 500) once the app is up. */
17
+ url: string;
18
+ /** How long to wait for `url`. Default 60000. */
19
+ timeoutMs?: number;
20
+ /** Use a server that already answers at `url` instead of starting one. Default false. */
21
+ reuseExisting?: boolean;
22
+ /** Directory the command runs in, relative to the project. Default the project. */
23
+ cwd?: string;
24
+ }
25
+ export interface E2eConfig {
26
+ baseURL?: string;
27
+ server?: E2eServerConfig;
28
+ /** Generated inputs per law. Default 10. */
29
+ runs?: number;
30
+ /** Longest a law may take overall. Default 120000. */
31
+ timeoutMs?: number;
32
+ }
33
+ /** Checks the `e2e` section of tslaws.json; unknown keys and wrong types are errors that say where. */
34
+ export declare function parseE2eConfig(raw: unknown): E2eConfig;
35
+ /** Stops the server this run started, if any. Safe to call more than once. */
36
+ export declare function stopServer(): Promise<void>;
37
+ /** Starts (once per check run) the server of `config` and waits for it; resolves to the base URL. */
38
+ export declare function ensureServer(config: E2eConfig, cwd: string): Promise<string>;
39
+ export interface E2eLaw {
40
+ file: string;
41
+ name: string;
42
+ fn: (...args: unknown[]) => unknown;
43
+ generators: fc.Arbitrary<unknown>[];
44
+ }
45
+ /** Property-tests one end-to-end law; `'timeout'` when it does not finish within the time limit. */
46
+ export declare function runE2eLaw(law: E2eLaw, options: CheckOptions, baseURL: string, timeoutMs: number): Promise<PropertyOutcome | 'timeout'>;
package/dist/e2e.js ADDED
@@ -0,0 +1,322 @@
1
+ // End-to-end laws: a whole running app, driven from outside through a real browser. The law runs in this process;
2
+ // the runner starts the app's dev server, gives every generated input a fresh browser context, and shrinks failures
3
+ // to a minimal counterexample like any other law.
4
+ import { spawn, spawnSync } from 'node:child_process';
5
+ import { mkdirSync } from 'node:fs';
6
+ import { join, resolve } from 'node:path';
7
+ import { launch, resolveEngine } from "./browser.js";
8
+ import { withE2e } from "./e2eApi.js";
9
+ import { runProperty, summarise } from "./property.js";
10
+ /** `a.e2e.laws.ts` is an accepted alias: it always drives a running app. */
11
+ export function isE2eLawFile(file) {
12
+ return /\.e2e\.laws\.[cm]?ts$/.test(file);
13
+ }
14
+ const importsOf = (module) => new RegExp(`(?:from|import)\\s*['"]${module}['"]`);
15
+ /** Whether a law file imports both helper sets, which would put one file at two levels. */
16
+ export function importsBoth(text) {
17
+ return importsOf('tslaws/e2e').test(text) && importsOf('tslaws/browser').test(text);
18
+ }
19
+ /**
20
+ * The level a law file runs at. `*.browser.laws.ts` is a component law (in a page) unless it imports from
21
+ * `tslaws/e2e`, which makes it an end-to-end law (in Node, driving a running app); `*.e2e.laws.ts` is always e2e.
22
+ */
23
+ export function lawMode(file, text) {
24
+ if (isE2eLawFile(file))
25
+ return 'e2e';
26
+ if (!/\.browser\.laws\.[cm]?ts$/.test(file))
27
+ return 'node';
28
+ return importsOf('tslaws/e2e').test(text) ? 'e2e' : 'component';
29
+ }
30
+ const topKeys = ['baseURL', 'server', 'runs', 'timeoutMs'];
31
+ const serverKeys = ['command', 'url', 'timeoutMs', 'reuseExisting', 'cwd'];
32
+ /** Checks the `e2e` section of tslaws.json; unknown keys and wrong types are errors that say where. */
33
+ export function parseE2eConfig(raw) {
34
+ const fail = (message) => {
35
+ throw new Error(`tslaws.json e2e: ${message}`);
36
+ };
37
+ const object = (value, where) => {
38
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
39
+ fail(`${where} must be an object`);
40
+ return value;
41
+ };
42
+ const text = (value, where) => {
43
+ if (typeof value !== 'string' || value === '')
44
+ fail(`${where} must be a non-empty string`);
45
+ return value;
46
+ };
47
+ const count = (value, where) => {
48
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 1)
49
+ fail(`${where} must be a positive integer`);
50
+ return value;
51
+ };
52
+ const section = object(raw, 'it');
53
+ for (const name of Object.keys(section))
54
+ if (!topKeys.includes(name))
55
+ fail(`unknown option "${name}"; supported options are ${topKeys.join(', ')}`);
56
+ const config = {};
57
+ if (section.baseURL !== undefined)
58
+ config.baseURL = text(section.baseURL, '"baseURL"');
59
+ if (section.runs !== undefined)
60
+ config.runs = count(section.runs, '"runs"');
61
+ if (section.timeoutMs !== undefined)
62
+ config.timeoutMs = count(section.timeoutMs, '"timeoutMs"');
63
+ if (section.server !== undefined) {
64
+ const server = object(section.server, '"server"');
65
+ for (const name of Object.keys(server))
66
+ if (!serverKeys.includes(name))
67
+ fail(`unknown option "server.${name}"; supported options are ${serverKeys.join(', ')}`);
68
+ const parsed = {
69
+ command: text(server.command, '"server.command"'),
70
+ url: text(server.url, '"server.url"'),
71
+ };
72
+ if (server.timeoutMs !== undefined)
73
+ parsed.timeoutMs = count(server.timeoutMs, '"server.timeoutMs"');
74
+ if (server.reuseExisting !== undefined) {
75
+ if (typeof server.reuseExisting !== 'boolean')
76
+ fail('"server.reuseExisting" must be true or false');
77
+ parsed.reuseExisting = server.reuseExisting;
78
+ }
79
+ if (server.cwd !== undefined)
80
+ parsed.cwd = text(server.cwd, '"server.cwd"');
81
+ config.server = parsed;
82
+ }
83
+ return config;
84
+ }
85
+ // ---- the dev server ----
86
+ let child;
87
+ let started;
88
+ let startedFor;
89
+ /** Whether `url` answers with anything but a server error. */
90
+ async function answers(url) {
91
+ try {
92
+ const response = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(2000) });
93
+ await response.body?.cancel();
94
+ return response.status < 500;
95
+ }
96
+ catch {
97
+ return false;
98
+ }
99
+ }
100
+ /** Kills `process` and everything it started. */
101
+ function killTree(process_) {
102
+ const pid = process_.pid;
103
+ if (pid === undefined || process_.exitCode !== null || process_.signalCode !== null)
104
+ return;
105
+ if (process.platform === 'win32')
106
+ spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { windowsHide: true });
107
+ else {
108
+ try {
109
+ process.kill(-pid, 'SIGTERM');
110
+ }
111
+ catch {
112
+ // Already gone.
113
+ }
114
+ setTimeout(() => {
115
+ try {
116
+ process.kill(-pid, 'SIGKILL');
117
+ }
118
+ catch {
119
+ // Already gone.
120
+ }
121
+ }, 2000).unref();
122
+ }
123
+ }
124
+ const onExit = () => {
125
+ if (child !== undefined)
126
+ killTree(child);
127
+ };
128
+ const onSignal = () => {
129
+ onExit();
130
+ process.exit(130);
131
+ };
132
+ /** Stops the server this run started, if any. Safe to call more than once. */
133
+ export async function stopServer() {
134
+ const running = child;
135
+ child = undefined;
136
+ started = undefined;
137
+ startedFor = undefined;
138
+ process.off('exit', onExit);
139
+ process.off('SIGINT', onSignal);
140
+ process.off('SIGTERM', onSignal);
141
+ if (running === undefined)
142
+ return;
143
+ const gone = new Promise((done) => {
144
+ if (running.exitCode !== null || running.signalCode !== null)
145
+ done();
146
+ else
147
+ running.once('exit', () => done());
148
+ });
149
+ killTree(running);
150
+ await Promise.race([gone, new Promise((done) => setTimeout(done, 5000))]);
151
+ }
152
+ /** Starts (once per check run) the server of `config` and waits for it; resolves to the base URL. */
153
+ export async function ensureServer(config, cwd) {
154
+ const server = config.server;
155
+ const baseURL = config.baseURL ?? server?.url;
156
+ if (baseURL === undefined)
157
+ throw new Error('tslaws.json e2e: set "baseURL" or "server" for end-to-end laws');
158
+ if (server === undefined)
159
+ return baseURL;
160
+ if (started === undefined || startedFor !== server.command) {
161
+ startedFor = server.command;
162
+ started = startServer(server, cwd);
163
+ }
164
+ await started;
165
+ return baseURL;
166
+ }
167
+ async function startServer(server, cwd) {
168
+ const alive = await answers(server.url);
169
+ if (alive) {
170
+ if (server.reuseExisting === true)
171
+ return;
172
+ throw new Error(`${server.url} already answers, so \`${server.command}\` would clash with it: stop that server, or set e2e.server.reuseExisting to use it`);
173
+ }
174
+ const process_ = spawn(server.command, {
175
+ cwd: resolve(cwd, server.cwd ?? '.'),
176
+ shell: true,
177
+ detached: process.platform !== 'win32',
178
+ stdio: ['ignore', 'pipe', 'pipe'],
179
+ windowsHide: true,
180
+ });
181
+ child = process_;
182
+ process.once('exit', onExit);
183
+ process.once('SIGINT', onSignal);
184
+ process.once('SIGTERM', onSignal);
185
+ let output = '';
186
+ const keep = (chunk) => {
187
+ output = (output + chunk.toString()).slice(-2000);
188
+ };
189
+ process_.stdout?.on('data', keep);
190
+ process_.stderr?.on('data', keep);
191
+ let ended;
192
+ process_.once('error', (error) => (ended = `could not start: ${error.message}`));
193
+ process_.once('exit', (code, signal) => (ended = `exited with ${code ?? signal}`));
194
+ const timeoutMs = server.timeoutMs ?? 60000;
195
+ const deadline = Date.now() + timeoutMs;
196
+ try {
197
+ while (!(await answers(server.url))) {
198
+ const tail = output.trim() === '' ? '' : `\n${output.trim().split('\n').slice(-8).join('\n')}`;
199
+ if (ended !== undefined)
200
+ throw new Error(`server \`${server.command}\` ${ended} before ${server.url} answered${tail}`);
201
+ if (Date.now() > deadline)
202
+ throw new Error(`server \`${server.command}\` did not answer at ${server.url} within ${timeoutMs / 1000}s${tail}`);
203
+ await new Promise((done) => setTimeout(done, 200));
204
+ }
205
+ }
206
+ catch (error) {
207
+ await stopServer();
208
+ throw error;
209
+ }
210
+ }
211
+ /** A browser that died under the machine, as opposed to a law that failed. */
212
+ const crashed = /Target (?:page, context or browser has been closed|crashed)|Page crashed|browser has been closed/;
213
+ /** Runs `law` once in a fresh context; `keep` leaves the page open for a photograph. A crashed browser tab is tried again, twice at most. */
214
+ async function runOnce(law, args, contextOf, baseURL, options, track, keep) {
215
+ for (let attempt = 0;; attempt += 1) {
216
+ try {
217
+ return await runAttempt(law, args, contextOf, baseURL, options, track, keep);
218
+ }
219
+ catch (error) {
220
+ if (attempt >= 2 || !crashed.test(error instanceof Error ? error.message : String(error)))
221
+ throw error;
222
+ }
223
+ }
224
+ }
225
+ async function runAttempt(law, args, contextOf, baseURL, options, track, keep) {
226
+ const context = await contextOf();
227
+ let closing = true;
228
+ try {
229
+ const page = await context.newPage();
230
+ track(page);
231
+ if (keep !== undefined) {
232
+ keep.page = page;
233
+ keep.context = context;
234
+ closing = false;
235
+ }
236
+ return await withE2e({ context, page, baseURL, cwd: options.cwd, onPage: track }, () => law.fn(...args));
237
+ }
238
+ finally {
239
+ if (closing)
240
+ await context.close().catch(() => undefined);
241
+ }
242
+ }
243
+ /** Property-tests one end-to-end law; `'timeout'` when it does not finish within the time limit. */
244
+ export async function runE2eLaw(law, options, baseURL, timeoutMs) {
245
+ const browser = await launch(resolveEngine(options.browser));
246
+ const open = new Set();
247
+ const contextOf = async () => {
248
+ const context = await browser.newContext({ viewport: { width: 1000, height: 720 } });
249
+ open.add(context);
250
+ context.once('close', () => open.delete(context));
251
+ return context;
252
+ };
253
+ let timedOut = false;
254
+ let latest;
255
+ const track = (page) => {
256
+ latest = page;
257
+ };
258
+ // While someone watches, photograph the newest page a few times a second.
259
+ let framing = false;
260
+ const frames = options.onEvent
261
+ ? setInterval(() => {
262
+ const page = latest;
263
+ if (framing || page === undefined || page.isClosed())
264
+ return;
265
+ framing = true;
266
+ page
267
+ .screenshot({ type: 'jpeg', quality: 55, timeout: 2000 })
268
+ .then((jpeg) => options.onEvent?.({ type: 'frame', file: law.file, name: law.name, jpeg }))
269
+ .catch(() => undefined)
270
+ .finally(() => (framing = false));
271
+ }, 120)
272
+ : undefined;
273
+ let timer;
274
+ const limit = new Promise((done) => {
275
+ timer = setTimeout(() => {
276
+ timedOut = true;
277
+ done('timeout');
278
+ }, timeoutMs);
279
+ });
280
+ const call = async (...args) => {
281
+ if (timedOut)
282
+ throw new Error('timed out');
283
+ return runOnce(law, args, contextOf, baseURL, options, track);
284
+ };
285
+ try {
286
+ const finished = await Promise.race([runProperty(call, law.generators, options), limit]);
287
+ if (finished === 'timeout')
288
+ return 'timeout';
289
+ const outcome = summarise(finished, options);
290
+ if (finished.failed && finished.counterexample !== null)
291
+ outcome.screenshot = await capture(law, finished.counterexample, contextOf, baseURL, options, track);
292
+ return outcome;
293
+ }
294
+ finally {
295
+ if (timer !== undefined)
296
+ clearTimeout(timer);
297
+ if (frames !== undefined)
298
+ clearInterval(frames);
299
+ latest = undefined;
300
+ // A law that never finishes leaves its pages open; closing the contexts frees them.
301
+ await Promise.all([...open].map((context) => context.close().catch(() => undefined)));
302
+ }
303
+ }
304
+ /** Runs the counterexample once more, leaves the page as the law left it, and photographs it. Best effort. */
305
+ async function capture(law, args, contextOf, baseURL, options, track) {
306
+ const kept = {};
307
+ try {
308
+ await runOnce(law, args, contextOf, baseURL, options, track, kept).catch(() => undefined);
309
+ if (kept.page === undefined || kept.page.isClosed())
310
+ return undefined;
311
+ const relative = `.tslaws/screenshots/${law.name.replace(/[^\w-]/g, '_')}.png`;
312
+ mkdirSync(join(options.cwd, '.tslaws/screenshots'), { recursive: true });
313
+ await kept.page.screenshot({ path: join(options.cwd, relative), timeout: 10000 });
314
+ return relative;
315
+ }
316
+ catch {
317
+ return undefined;
318
+ }
319
+ finally {
320
+ await kept.context?.close().catch(() => undefined);
321
+ }
322
+ }