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.
- package/LICENSE +202 -0
- package/README.md +215 -2
- package/bin/tslaws.js +5 -0
- package/dist/adapters/angular.d.ts +42 -0
- package/dist/adapters/angular.js +40 -0
- package/dist/adapters/dom.d.ts +5 -0
- package/dist/adapters/dom.js +17 -0
- package/dist/adapters/element.d.ts +10 -0
- package/dist/adapters/element.js +28 -0
- package/dist/adapters/react.d.ts +24 -0
- package/dist/adapters/react.js +15 -0
- package/dist/api.d.ts +59 -0
- package/dist/api.js +103 -0
- package/dist/browser.d.ts +24 -0
- package/dist/browser.js +252 -0
- package/dist/browserApi.d.ts +62 -0
- package/dist/browserApi.js +139 -0
- package/dist/bundled.d.ts +17 -0
- package/dist/bundled.js +67 -0
- package/dist/check.d.ts +12 -0
- package/dist/check.js +269 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +360 -0
- package/dist/compile.d.ts +15 -0
- package/dist/compile.js +142 -0
- package/dist/component.d.ts +37 -0
- package/dist/component.js +99 -0
- package/dist/coverage.d.ts +40 -0
- package/dist/coverage.js +237 -0
- package/dist/e2e.d.ts +46 -0
- package/dist/e2e.js +322 -0
- package/dist/e2eApi.d.ts +80 -0
- package/dist/e2eApi.js +140 -0
- package/dist/generators.d.ts +18 -0
- package/dist/generators.js +184 -0
- package/dist/hints.d.ts +3 -0
- package/dist/hints.js +37 -0
- package/dist/init.d.ts +23 -0
- package/dist/init.js +139 -0
- package/dist/lock.d.ts +16 -0
- package/dist/lock.js +40 -0
- package/dist/mcp.d.ts +30 -0
- package/dist/mcp.js +301 -0
- package/dist/mutate.d.ts +54 -0
- package/dist/mutate.js +293 -0
- package/dist/promote.d.ts +38 -0
- package/dist/promote.js +217 -0
- package/dist/property.d.ts +37 -0
- package/dist/property.js +172 -0
- package/dist/propertyWorker.d.ts +1 -0
- package/dist/propertyWorker.js +24 -0
- package/dist/queries.d.ts +37 -0
- package/dist/queries.js +297 -0
- package/dist/regressions.d.ts +31 -0
- package/dist/regressions.js +153 -0
- package/dist/reporters.d.ts +22 -0
- package/dist/reporters.js +160 -0
- package/dist/settings.d.ts +16 -0
- package/dist/settings.js +15 -0
- package/dist/smt.d.ts +33 -0
- package/dist/smt.js +396 -0
- package/dist/symbolic.d.ts +200 -0
- package/dist/symbolic.js +1727 -0
- package/dist/types.d.ts +77 -0
- package/dist/types.js +1 -0
- package/dist/ui.d.ts +16 -0
- package/dist/ui.js +326 -0
- package/dist/uiPage.d.ts +1 -0
- package/dist/uiPage.js +4 -0
- package/hooks/AGENTS.snippet.md +10 -0
- package/hooks/claude-hook.mjs +35 -0
- package/hooks/claude-settings.example.json +8 -0
- package/package.json +85 -4
- package/ui/index.html +1132 -0
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import * as fc from 'fast-check';
|
|
2
|
+
export { fc };
|
|
3
|
+
/**
|
|
4
|
+
* An integer the prover treats exactly (within the safe integers) instead of as an IEEE double, so laws over it can be
|
|
5
|
+
* proved. At runtime it is an ordinary number.
|
|
6
|
+
*/
|
|
7
|
+
export type int = number & {
|
|
8
|
+
readonly __tslawsInt: true;
|
|
9
|
+
};
|
|
10
|
+
/** Thrown by `assume`; the runner skips the input. */
|
|
11
|
+
export declare class LawDiscard extends Error {
|
|
12
|
+
constructor();
|
|
13
|
+
}
|
|
14
|
+
/** Skips the current input unless `condition` holds. */
|
|
15
|
+
export declare function assume(condition: boolean): asserts condition;
|
|
16
|
+
/**
|
|
17
|
+
* Loop invariant: put it first in a loop body. At runtime it throws when false, so property tests check it on every
|
|
18
|
+
* iteration; the prover assumes it at the top of an arbitrary iteration and proves it again at the end, which makes the
|
|
19
|
+
* loop's result a proof for every number of iterations instead of a bounded one.
|
|
20
|
+
*/
|
|
21
|
+
export declare function invariant(condition: boolean): void;
|
|
22
|
+
/**
|
|
23
|
+
* True when `predicate(x, i)` holds for every element of `xs`. In a law the prover lowers it over the array's slots, so
|
|
24
|
+
* a claim that relies on it is `bounded` by `--bound` like any array claim. The predicate must be an arrow function
|
|
25
|
+
* the prover can lower; otherwise the law stays `tested`.
|
|
26
|
+
*/
|
|
27
|
+
export declare function forall<T>(xs: readonly T[], predicate: (x: T, i: number) => boolean): boolean;
|
|
28
|
+
/** True when `predicate(x, i)` holds for some element of `xs`; the prover lowers it as `forall` does. */
|
|
29
|
+
export declare function exists<T>(xs: readonly T[], predicate: (x: T, i: number) => boolean): boolean;
|
|
30
|
+
/** Where the latest `runModel` / `runModelAsync` call diverged (`step` is the op index, -1 before the first op), or undefined if it agreed. */
|
|
31
|
+
export declare function modelFailure(): {
|
|
32
|
+
step: number;
|
|
33
|
+
op: unknown;
|
|
34
|
+
} | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* Stateful law helper: applies `ops` to `real` and to a simple reference `model` and returns false at the first step
|
|
37
|
+
* after which `agree` fails (`agree` is also checked before the first op). `apply` performs the op on `real` and returns
|
|
38
|
+
* the next model state. Use it in a law over `ops: Op[]` so failing sequences shrink to the shortest one. On a
|
|
39
|
+
* disagreement `modelFailure()` names the step and op.
|
|
40
|
+
*/
|
|
41
|
+
export declare function runModel<Op, Real, Model>(spec: {
|
|
42
|
+
ops: readonly Op[];
|
|
43
|
+
real: Real;
|
|
44
|
+
model: Model;
|
|
45
|
+
apply: (real: Real, model: Model, op: Op, step: number) => Model;
|
|
46
|
+
agree: (real: Real, model: Model) => boolean;
|
|
47
|
+
}): boolean;
|
|
48
|
+
/** `runModel` for `apply` and `agree` that return promises (a browser law can click and `await settle()`). */
|
|
49
|
+
export declare function runModelAsync<Op, Real, Model>(spec: {
|
|
50
|
+
ops: readonly Op[];
|
|
51
|
+
real: Real;
|
|
52
|
+
model: Model;
|
|
53
|
+
apply: (real: Real, model: Model, op: Op, step: number) => Model | Promise<Model>;
|
|
54
|
+
agree: (real: Real, model: Model) => boolean | Promise<boolean>;
|
|
55
|
+
}): Promise<boolean>;
|
|
56
|
+
type Registry = Map<string, fc.Arbitrary<unknown>>;
|
|
57
|
+
export declare function arbitraryRegistry(): Registry;
|
|
58
|
+
/** Registers the generator used for every parameter whose type is named `typeName`. */
|
|
59
|
+
export declare function arbitrary<T>(typeName: string, generator: fc.Arbitrary<T>): void;
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// What a laws file imports: `assume` to discard inputs a law does not cover, and `arbitrary` to register
|
|
2
|
+
// a generator for a named type the runner cannot derive.
|
|
3
|
+
import * as fc from 'fast-check';
|
|
4
|
+
export { fc };
|
|
5
|
+
/** Thrown by `assume`; the runner skips the input. */
|
|
6
|
+
export class LawDiscard extends Error {
|
|
7
|
+
constructor() {
|
|
8
|
+
super('law input discarded');
|
|
9
|
+
this.name = 'LawDiscard';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/** Skips the current input unless `condition` holds. */
|
|
13
|
+
export function assume(condition) {
|
|
14
|
+
if (!condition)
|
|
15
|
+
throw new LawDiscard();
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Loop invariant: put it first in a loop body. At runtime it throws when false, so property tests check it on every
|
|
19
|
+
* iteration; the prover assumes it at the top of an arbitrary iteration and proves it again at the end, which makes the
|
|
20
|
+
* loop's result a proof for every number of iterations instead of a bounded one.
|
|
21
|
+
*/
|
|
22
|
+
export function invariant(condition) {
|
|
23
|
+
if (!condition)
|
|
24
|
+
throw new Error('loop invariant violated');
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* True when `predicate(x, i)` holds for every element of `xs`. In a law the prover lowers it over the array's slots, so
|
|
28
|
+
* a claim that relies on it is `bounded` by `--bound` like any array claim. The predicate must be an arrow function
|
|
29
|
+
* the prover can lower; otherwise the law stays `tested`.
|
|
30
|
+
*/
|
|
31
|
+
export function forall(xs, predicate) {
|
|
32
|
+
for (let i = 0; i < xs.length; i += 1)
|
|
33
|
+
if (!predicate(xs[i], i))
|
|
34
|
+
return false;
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
/** True when `predicate(x, i)` holds for some element of `xs`; the prover lowers it as `forall` does. */
|
|
38
|
+
export function exists(xs, predicate) {
|
|
39
|
+
for (let i = 0; i < xs.length; i += 1)
|
|
40
|
+
if (predicate(xs[i], i))
|
|
41
|
+
return true;
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
let lastModelFailure;
|
|
45
|
+
/** Where the latest `runModel` / `runModelAsync` call diverged (`step` is the op index, -1 before the first op), or undefined if it agreed. */
|
|
46
|
+
export function modelFailure() {
|
|
47
|
+
return lastModelFailure;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Stateful law helper: applies `ops` to `real` and to a simple reference `model` and returns false at the first step
|
|
51
|
+
* after which `agree` fails (`agree` is also checked before the first op). `apply` performs the op on `real` and returns
|
|
52
|
+
* the next model state. Use it in a law over `ops: Op[]` so failing sequences shrink to the shortest one. On a
|
|
53
|
+
* disagreement `modelFailure()` names the step and op.
|
|
54
|
+
*/
|
|
55
|
+
export function runModel(spec) {
|
|
56
|
+
lastModelFailure = undefined;
|
|
57
|
+
let model = spec.model;
|
|
58
|
+
if (!spec.agree(spec.real, model)) {
|
|
59
|
+
lastModelFailure = { step: -1, op: undefined };
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
for (let step = 0; step < spec.ops.length; step += 1) {
|
|
63
|
+
const op = spec.ops[step];
|
|
64
|
+
model = spec.apply(spec.real, model, op, step);
|
|
65
|
+
if (!spec.agree(spec.real, model)) {
|
|
66
|
+
lastModelFailure = { step, op };
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
/** `runModel` for `apply` and `agree` that return promises (a browser law can click and `await settle()`). */
|
|
73
|
+
export async function runModelAsync(spec) {
|
|
74
|
+
lastModelFailure = undefined;
|
|
75
|
+
let model = spec.model;
|
|
76
|
+
if (!(await spec.agree(spec.real, model))) {
|
|
77
|
+
lastModelFailure = { step: -1, op: undefined };
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
for (let step = 0; step < spec.ops.length; step += 1) {
|
|
81
|
+
const op = spec.ops[step];
|
|
82
|
+
model = await spec.apply(spec.real, model, op, step);
|
|
83
|
+
if (!(await spec.agree(spec.real, model))) {
|
|
84
|
+
lastModelFailure = { step, op };
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
const registryKey = Symbol.for('tslaws.arbitraries');
|
|
91
|
+
export function arbitraryRegistry() {
|
|
92
|
+
const holder = globalThis;
|
|
93
|
+
let registry = holder[registryKey];
|
|
94
|
+
if (registry === undefined) {
|
|
95
|
+
registry = new Map();
|
|
96
|
+
holder[registryKey] = registry;
|
|
97
|
+
}
|
|
98
|
+
return registry;
|
|
99
|
+
}
|
|
100
|
+
/** Registers the generator used for every parameter whose type is named `typeName`. */
|
|
101
|
+
export function arbitrary(typeName, generator) {
|
|
102
|
+
arbitraryRegistry().set(typeName, generator);
|
|
103
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type Browser } from 'playwright-core';
|
|
2
|
+
import type * as fc from 'fast-check';
|
|
3
|
+
import { type PropertyOutcome } from './property.ts';
|
|
4
|
+
import type { CheckOptions } from './types.ts';
|
|
5
|
+
/** `a.browser.laws.ts` runs in a browser. */
|
|
6
|
+
export declare function isBrowserLawFile(file: string): boolean;
|
|
7
|
+
export type BrowserEngine = 'chromium' | 'firefox' | 'webkit';
|
|
8
|
+
/** The engine to use: the requested one, else `TSLAWS_BROWSER_ENGINE`, else chromium. Throws on an unknown name. */
|
|
9
|
+
export declare function resolveEngine(requested?: string, env?: NodeJS.ProcessEnv): BrowserEngine;
|
|
10
|
+
/**
|
|
11
|
+
* The executable to launch for an engine. Chromium falls back to an installed Chrome or Edge; Firefox and WebKit use
|
|
12
|
+
* TSLAWS_BROWSER or Playwright's own download. Throws, naming the install command, when there is none.
|
|
13
|
+
*/
|
|
14
|
+
export declare function engineExecutable(engine: BrowserEngine, env?: NodeJS.ProcessEnv): string;
|
|
15
|
+
export declare function launch(engine: BrowserEngine): Promise<Browser>;
|
|
16
|
+
export declare function closeBrowser(): Promise<void>;
|
|
17
|
+
export interface BrowserLaw {
|
|
18
|
+
file: string;
|
|
19
|
+
name: string;
|
|
20
|
+
jsFile: string;
|
|
21
|
+
generators: fc.Arbitrary<unknown>[];
|
|
22
|
+
}
|
|
23
|
+
/** Property-tests one law in a page; `'timeout'` when it does not finish within `options.propertyTimeoutMs`. */
|
|
24
|
+
export declare function runBrowserLaw(law: BrowserLaw, options: CheckOptions): Promise<PropertyOutcome | 'timeout'>;
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// Runs laws in a real browser. Node generates the inputs (from the declared types, with shrinking, like any law), and
|
|
2
|
+
// each input is sent to a page where the compiled law and the code it checks run against a live DOM.
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { mkdirSync } from 'node:fs';
|
|
5
|
+
import { delimiter, join } from 'node:path';
|
|
6
|
+
import { build as bundleWith } from 'esbuild';
|
|
7
|
+
import { chromium, firefox, webkit } from 'playwright-core';
|
|
8
|
+
import { toWire } from "./generators.js";
|
|
9
|
+
import { runProperty, summarise } from "./property.js";
|
|
10
|
+
/** `a.browser.laws.ts` runs in a browser. */
|
|
11
|
+
export function isBrowserLawFile(file) {
|
|
12
|
+
return /\.browser\.laws\.[cm]?ts$/.test(file);
|
|
13
|
+
}
|
|
14
|
+
function findBrowser() {
|
|
15
|
+
const fromEnv = process.env.TSLAWS_BROWSER;
|
|
16
|
+
if (fromEnv !== undefined && fromEnv !== '')
|
|
17
|
+
return fromEnv;
|
|
18
|
+
const candidates = [];
|
|
19
|
+
if (process.platform === 'win32') {
|
|
20
|
+
for (const base of [process.env.PROGRAMFILES, process.env['PROGRAMFILES(X86)'], process.env.LOCALAPPDATA]) {
|
|
21
|
+
if (base === undefined)
|
|
22
|
+
continue;
|
|
23
|
+
candidates.push(join(base, 'Google/Chrome/Application/chrome.exe'), join(base, 'Microsoft/Edge/Application/msedge.exe'));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
else if (process.platform === 'darwin') {
|
|
27
|
+
candidates.push('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', '/Applications/Chromium.app/Contents/MacOS/Chromium');
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
for (const dir of (process.env.PATH ?? '').split(delimiter)) {
|
|
31
|
+
for (const name of ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'microsoft-edge'])
|
|
32
|
+
candidates.push(join(dir, name));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
candidates.push(chromium.executablePath());
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// Playwright has no browser of its own installed.
|
|
40
|
+
}
|
|
41
|
+
return candidates.find((c) => existsSync(c));
|
|
42
|
+
}
|
|
43
|
+
const engines = ['chromium', 'firefox', 'webkit'];
|
|
44
|
+
/** The engine to use: the requested one, else `TSLAWS_BROWSER_ENGINE`, else chromium. Throws on an unknown name. */
|
|
45
|
+
export function resolveEngine(requested, env = process.env) {
|
|
46
|
+
const name = (requested ?? env.TSLAWS_BROWSER_ENGINE ?? '').trim().toLowerCase();
|
|
47
|
+
if (name === '')
|
|
48
|
+
return 'chromium';
|
|
49
|
+
if (engines.includes(name))
|
|
50
|
+
return name;
|
|
51
|
+
throw new Error(`unknown browser engine '${name}': use chromium, firefox or webkit`);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The executable to launch for an engine. Chromium falls back to an installed Chrome or Edge; Firefox and WebKit use
|
|
55
|
+
* TSLAWS_BROWSER or Playwright's own download. Throws, naming the install command, when there is none.
|
|
56
|
+
*/
|
|
57
|
+
export function engineExecutable(engine, env = process.env) {
|
|
58
|
+
if (engine === 'chromium') {
|
|
59
|
+
const found = findBrowser();
|
|
60
|
+
if (found === undefined)
|
|
61
|
+
throw new Error('no browser found: install Chrome, Edge or Chromium, or set TSLAWS_BROWSER to its executable');
|
|
62
|
+
return found;
|
|
63
|
+
}
|
|
64
|
+
const fromEnv = env.TSLAWS_BROWSER;
|
|
65
|
+
let path = fromEnv !== undefined && fromEnv !== '' ? fromEnv : undefined;
|
|
66
|
+
if (path === undefined) {
|
|
67
|
+
try {
|
|
68
|
+
path = (engine === 'firefox' ? firefox : webkit).executablePath();
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// Playwright has no such browser installed.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (path === undefined || !existsSync(path))
|
|
75
|
+
throw new Error(`no ${engine} installed: run \`npx playwright install ${engine}\` or set TSLAWS_BROWSER to its executable`);
|
|
76
|
+
return path;
|
|
77
|
+
}
|
|
78
|
+
const browsers = new Map();
|
|
79
|
+
export function launch(engine) {
|
|
80
|
+
let open = browsers.get(engine);
|
|
81
|
+
if (open === undefined) {
|
|
82
|
+
const type = { chromium, firefox, webkit }[engine];
|
|
83
|
+
open = (async () => type.launch({ executablePath: engineExecutable(engine), headless: true }))();
|
|
84
|
+
open.catch(() => browsers.delete(engine));
|
|
85
|
+
browsers.set(engine, open);
|
|
86
|
+
}
|
|
87
|
+
return open;
|
|
88
|
+
}
|
|
89
|
+
export async function closeBrowser() {
|
|
90
|
+
const open = [...browsers.values()];
|
|
91
|
+
browsers.clear();
|
|
92
|
+
for (const one of open) {
|
|
93
|
+
try {
|
|
94
|
+
await (await one).close();
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// Already gone.
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Runs inside the page: the law, a clean DOM before and after, and a result that survives structured cloning.
|
|
102
|
+
const harness = `
|
|
103
|
+
window.__runLaw = async (name, args, keep, pause) => {
|
|
104
|
+
const clean = () => {
|
|
105
|
+
for (const undo of (window.__tslawsCleanup || []).splice(0)) { try { undo(); } catch (e) {} }
|
|
106
|
+
document.body.innerHTML = '';
|
|
107
|
+
};
|
|
108
|
+
// Functions cannot cross into the page: generated ones arrive as { $tslawsFn: samples } and are rebuilt here.
|
|
109
|
+
const revive = (v) => {
|
|
110
|
+
if (Array.isArray(v)) return v.map(revive);
|
|
111
|
+
if (v && typeof v === 'object') {
|
|
112
|
+
if (Array.isArray(v.$tslawsFn)) {
|
|
113
|
+
const samples = v.$tslawsFn;
|
|
114
|
+
return (...a) => {
|
|
115
|
+
let key = '';
|
|
116
|
+
try { key = JSON.stringify(a) || ''; } catch (e) {}
|
|
117
|
+
let h = 5381;
|
|
118
|
+
for (let i = 0; i < key.length; i++) h = (Math.imul(h, 33) + key.charCodeAt(i)) | 0;
|
|
119
|
+
return samples[(h >>> 0) % samples.length];
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, revive(x)]));
|
|
123
|
+
}
|
|
124
|
+
return v;
|
|
125
|
+
};
|
|
126
|
+
clean();
|
|
127
|
+
try {
|
|
128
|
+
const result = await window.__lawModule[name](...revive(args));
|
|
129
|
+
return { ok: result !== false };
|
|
130
|
+
} catch (error) {
|
|
131
|
+
return { error: { name: error && error.name, message: error && error.message ? error.message : String(error) } };
|
|
132
|
+
} finally {
|
|
133
|
+
if (pause > 0) {
|
|
134
|
+
window.__shown = true;
|
|
135
|
+
await new Promise((done) => setTimeout(done, pause));
|
|
136
|
+
window.__shown = false;
|
|
137
|
+
}
|
|
138
|
+
if (!keep) clean();
|
|
139
|
+
}
|
|
140
|
+
};`;
|
|
141
|
+
// Bare imports resolve from the project's own node_modules first (a JSX runtime, a UI library, axe-core), then the
|
|
142
|
+
// usual way. axe-core is an optional peer dependency: without a copy the import stays for the page to fail on (a11y()
|
|
143
|
+
// turns that into a clear message), so projects without it still bundle.
|
|
144
|
+
function projectModulesPlugin(cwd) {
|
|
145
|
+
return {
|
|
146
|
+
name: 'tslaws-project-modules',
|
|
147
|
+
setup(build) {
|
|
148
|
+
build.onResolve({ filter: /^[^./]/ }, async (args) => {
|
|
149
|
+
if (args.pluginData === 'tslaws' || args.kind === 'entry-point' || /^[a-zA-Z]:/.test(args.path))
|
|
150
|
+
return undefined;
|
|
151
|
+
const found = await build.resolve(args.path, { kind: args.kind, resolveDir: cwd, pluginData: 'tslaws' });
|
|
152
|
+
if (found.errors.length === 0)
|
|
153
|
+
return { path: found.path, external: found.external, sideEffects: found.sideEffects };
|
|
154
|
+
return args.path === 'axe-core' ? { path: 'axe-core', external: true } : undefined;
|
|
155
|
+
});
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
async function bundleOf(jsFile, cwd) {
|
|
160
|
+
const result = await bundleWith({
|
|
161
|
+
plugins: [projectModulesPlugin(cwd)],
|
|
162
|
+
entryPoints: [jsFile],
|
|
163
|
+
bundle: true,
|
|
164
|
+
write: false,
|
|
165
|
+
format: 'iife',
|
|
166
|
+
globalName: '__lawModule',
|
|
167
|
+
platform: 'browser',
|
|
168
|
+
target: 'es2022',
|
|
169
|
+
absWorkingDir: cwd,
|
|
170
|
+
logLevel: 'silent',
|
|
171
|
+
define: { 'process.env.NODE_ENV': '"development"', global: 'globalThis' },
|
|
172
|
+
});
|
|
173
|
+
return result.outputFiles[0].text;
|
|
174
|
+
}
|
|
175
|
+
/** Runs the counterexample once more, leaves the DOM as it is, and photographs the page. Best effort. */
|
|
176
|
+
async function capture(page, law, args, options) {
|
|
177
|
+
try {
|
|
178
|
+
await page.evaluate(([name, input]) => window.__runLaw(name, input, true), [law.name, toWire(args)]);
|
|
179
|
+
const relative = `.tslaws/screenshots/${law.name.replace(/[^\w-]/g, '_')}.png`;
|
|
180
|
+
mkdirSync(join(options.cwd, '.tslaws/screenshots'), { recursive: true });
|
|
181
|
+
await page.screenshot({ path: join(options.cwd, relative) });
|
|
182
|
+
return relative;
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/** Property-tests one law in a page; `'timeout'` when it does not finish within `options.propertyTimeoutMs`. */
|
|
189
|
+
export async function runBrowserLaw(law, options) {
|
|
190
|
+
const code = await bundleOf(law.jsFile, options.cwd);
|
|
191
|
+
const page = await (await launch(resolveEngine(options.browser))).newPage();
|
|
192
|
+
// A watcher needs only the first few runs to linger; the rest should not eat the time limit.
|
|
193
|
+
let calls = 0;
|
|
194
|
+
let timedOut = false;
|
|
195
|
+
const timer = setTimeout(() => {
|
|
196
|
+
timedOut = true;
|
|
197
|
+
void page.close();
|
|
198
|
+
}, options.propertyTimeoutMs ?? 30000);
|
|
199
|
+
const pageErrors = [];
|
|
200
|
+
page.on('pageerror', (error) => pageErrors.push(error.message));
|
|
201
|
+
// While someone watches, photograph the page a few times a second.
|
|
202
|
+
let framing = false;
|
|
203
|
+
const frames = options.onEvent
|
|
204
|
+
? setInterval(() => {
|
|
205
|
+
if (framing || page.isClosed())
|
|
206
|
+
return;
|
|
207
|
+
framing = true;
|
|
208
|
+
// Only while the law has left the page as it wants it seen, not between runs when it is empty.
|
|
209
|
+
page
|
|
210
|
+
.evaluate(() => window.__shown === true)
|
|
211
|
+
.then((shown) => (shown ? page.screenshot({ type: 'jpeg', quality: 55, timeout: 2000 }) : undefined))
|
|
212
|
+
.then((jpeg) => jpeg === undefined ? undefined : options.onEvent?.({ type: 'frame', file: law.file, name: law.name, jpeg }))
|
|
213
|
+
.catch(() => undefined)
|
|
214
|
+
.finally(() => (framing = false));
|
|
215
|
+
}, 120)
|
|
216
|
+
: undefined;
|
|
217
|
+
try {
|
|
218
|
+
await page.setContent('<!doctype html><html><head><meta charset="utf-8"></head><body></body></html>');
|
|
219
|
+
await page.addScriptTag({ content: code });
|
|
220
|
+
await page.addScriptTag({ content: harness });
|
|
221
|
+
const call = async (...args) => {
|
|
222
|
+
const outcome = (await page.evaluate(([name, input, pause]) => window.__runLaw(name, input, false, pause), [law.name, toWire(args), calls++ < 12 ? (options.browserPauseMs ?? 0) : 0]));
|
|
223
|
+
if (outcome.error !== undefined) {
|
|
224
|
+
const error = new Error(outcome.error.message);
|
|
225
|
+
error.name = outcome.error.name ?? 'Error';
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
return outcome.ok;
|
|
229
|
+
};
|
|
230
|
+
const details = await runProperty(call, law.generators, options);
|
|
231
|
+
// Closing the page fails the call in flight, which the property reports as a failure of the law.
|
|
232
|
+
if (timedOut)
|
|
233
|
+
return 'timeout';
|
|
234
|
+
const outcome = summarise(details, options);
|
|
235
|
+
if (details.failed && details.counterexample !== null && !page.isClosed())
|
|
236
|
+
outcome.screenshot = await capture(page, law, details.counterexample, options);
|
|
237
|
+
return outcome;
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
if (timedOut)
|
|
241
|
+
return 'timeout';
|
|
242
|
+
const extra = pageErrors.length > 0 ? ` (page error: ${pageErrors[0]})` : '';
|
|
243
|
+
throw new Error(`${error.message}${extra}`);
|
|
244
|
+
}
|
|
245
|
+
finally {
|
|
246
|
+
clearTimeout(timer);
|
|
247
|
+
if (frames !== undefined)
|
|
248
|
+
clearInterval(frames);
|
|
249
|
+
if (!page.isClosed())
|
|
250
|
+
await page.close();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
type Cleanup = () => void;
|
|
2
|
+
/**
|
|
3
|
+
* A fresh container in the page, removed after the law. Pass markup, a node, or a function that renders into the
|
|
4
|
+
* container (a framework's `render` call) and may return what undoes it (an `unmount`).
|
|
5
|
+
*/
|
|
6
|
+
export declare function mount(content?: string | Node | ((container: HTMLElement) => void | Cleanup)): HTMLElement;
|
|
7
|
+
/** Waits until pending microtasks, timers and animation frames have run, so the DOM has caught up. */
|
|
8
|
+
export declare function settle(frames?: number): Promise<void>;
|
|
9
|
+
/** Dispatches a bubbling event. */
|
|
10
|
+
export declare function fire(target: EventTarget, type: string, init?: EventInit): boolean;
|
|
11
|
+
/** A real click: pointer events, then `click`. */
|
|
12
|
+
export declare function click(target: Element): void;
|
|
13
|
+
/** Sets a field's value the way typing does, so frameworks that track `input` events notice. */
|
|
14
|
+
export declare function type(field: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement, text: string): void;
|
|
15
|
+
export interface A11yViolation {
|
|
16
|
+
id: string;
|
|
17
|
+
impact: string | null;
|
|
18
|
+
help: string;
|
|
19
|
+
/** Outer HTML of each offending element, trimmed to 200 characters. */
|
|
20
|
+
nodes: string[];
|
|
21
|
+
}
|
|
22
|
+
export interface A11yOptions {
|
|
23
|
+
/** Run only these axe rule ids. */
|
|
24
|
+
rules?: string[];
|
|
25
|
+
/** Skip these axe rule ids. */
|
|
26
|
+
ignore?: string[];
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Audits `root` (default: the whole page) with axe-core. Needs `axe-core` in the project: `npm i -D axe-core`.
|
|
30
|
+
* Page-level rules (landmarks, a level-one heading) are skipped when `root` is an element.
|
|
31
|
+
*/
|
|
32
|
+
export declare function a11y(root?: Element, options?: A11yOptions): Promise<{
|
|
33
|
+
violations: A11yViolation[];
|
|
34
|
+
ok: boolean;
|
|
35
|
+
}>;
|
|
36
|
+
/** True when `root` has no accessibility violations, so a law can `return await accessible(el)`. */
|
|
37
|
+
export declare function accessible(root?: Element, options?: A11yOptions): Promise<boolean>;
|
|
38
|
+
export { mountComponent, rendersSame, spy } from './component.ts';
|
|
39
|
+
export type { Adapter, Mounted, Rendered } from './component.ts';
|
|
40
|
+
export { allByRole, allByText, allByLabel, allByTestId, byRole, byText, byLabel, byTestId, text, within, } from './queries.ts';
|
|
41
|
+
export type { Matcher, Queries, RoleOptions } from './queries.ts';
|
|
42
|
+
export { domAdapter } from './adapters/dom.ts';
|
|
43
|
+
export type { DomComponent } from './adapters/dom.ts';
|
|
44
|
+
export { elementAdapter } from './adapters/element.ts';
|
|
45
|
+
export type { ElementAdapterOptions } from './adapters/element.ts';
|
|
46
|
+
export { reactAdapter } from './adapters/react.ts';
|
|
47
|
+
export type { ReactFunctions, ReactRootLike } from './adapters/react.ts';
|
|
48
|
+
export { angularAdapter } from './adapters/angular.ts';
|
|
49
|
+
export type { AngularAppRefLike, AngularComponentRefLike, AngularFunctions } from './adapters/angular.ts';
|
|
50
|
+
export interface Timing {
|
|
51
|
+
runs: number;
|
|
52
|
+
min: number;
|
|
53
|
+
median: number;
|
|
54
|
+
p95: number;
|
|
55
|
+
max: number;
|
|
56
|
+
}
|
|
57
|
+
/** Times `work` (sync or async) over `runs` runs after a warm-up, in milliseconds as the browser clock reads them. */
|
|
58
|
+
export declare function measure(work: () => unknown, runs?: number): Promise<Timing>;
|
|
59
|
+
/** True when `work` takes at most `budgetMs` at the median, so a law can `return await fast(() => click(add), 50)`. */
|
|
60
|
+
export declare function fast(work: () => unknown, budgetMs: number, runs?: number): Promise<boolean>;
|
|
61
|
+
/** Milliseconds the main thread was blocked by long tasks (over 50 ms) while `work` ran; 0 where the browser has no such API. */
|
|
62
|
+
export declare function blocking(work: () => unknown): Promise<number>;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
// Helpers for laws that run in a browser (`*.browser.laws.ts`). They touch only the DOM, so they work the same with
|
|
3
|
+
// any framework or none: mount your component however you normally do, inside `mount(...)`.
|
|
4
|
+
const registry = () => (globalThis.__tslawsCleanup ??= []);
|
|
5
|
+
/**
|
|
6
|
+
* A fresh container in the page, removed after the law. Pass markup, a node, or a function that renders into the
|
|
7
|
+
* container (a framework's `render` call) and may return what undoes it (an `unmount`).
|
|
8
|
+
*/
|
|
9
|
+
export function mount(content) {
|
|
10
|
+
const container = document.createElement('div');
|
|
11
|
+
document.body.appendChild(container);
|
|
12
|
+
registry().push(() => container.remove());
|
|
13
|
+
if (typeof content === 'string')
|
|
14
|
+
container.innerHTML = content;
|
|
15
|
+
else if (content instanceof Node)
|
|
16
|
+
container.appendChild(content);
|
|
17
|
+
else if (typeof content === 'function') {
|
|
18
|
+
const undo = content(container);
|
|
19
|
+
if (typeof undo === 'function')
|
|
20
|
+
registry().push(undo);
|
|
21
|
+
}
|
|
22
|
+
return container;
|
|
23
|
+
}
|
|
24
|
+
/** Waits until pending microtasks, timers and animation frames have run, so the DOM has caught up. */
|
|
25
|
+
export async function settle(frames = 2) {
|
|
26
|
+
await Promise.resolve();
|
|
27
|
+
await new Promise((done) => setTimeout(done, 0));
|
|
28
|
+
for (let i = 0; i < frames; i++)
|
|
29
|
+
await new Promise((done) => requestAnimationFrame(() => done()));
|
|
30
|
+
}
|
|
31
|
+
/** Dispatches a bubbling event. */
|
|
32
|
+
export function fire(target, type, init = {}) {
|
|
33
|
+
return target.dispatchEvent(new Event(type, { bubbles: true, cancelable: true, ...init }));
|
|
34
|
+
}
|
|
35
|
+
/** A real click: pointer events, then `click`. */
|
|
36
|
+
export function click(target) {
|
|
37
|
+
const init = { bubbles: true, cancelable: true, composed: true };
|
|
38
|
+
target.dispatchEvent(new PointerEvent('pointerdown', init));
|
|
39
|
+
target.dispatchEvent(new MouseEvent('mousedown', init));
|
|
40
|
+
target.dispatchEvent(new PointerEvent('pointerup', init));
|
|
41
|
+
target.dispatchEvent(new MouseEvent('mouseup', init));
|
|
42
|
+
target.dispatchEvent(new MouseEvent('click', init));
|
|
43
|
+
}
|
|
44
|
+
/** Sets a field's value the way typing does, so frameworks that track `input` events notice. */
|
|
45
|
+
export function type(field, text) {
|
|
46
|
+
const proto = Object.getPrototypeOf(field);
|
|
47
|
+
Object.getOwnPropertyDescriptor(proto, 'value')?.set?.call(field, text);
|
|
48
|
+
fire(field, 'input');
|
|
49
|
+
fire(field, 'change');
|
|
50
|
+
}
|
|
51
|
+
// Rules about a whole page, which an element on its own cannot satisfy.
|
|
52
|
+
const pageRules = ['region', 'page-has-heading-one', 'landmark-one-main'];
|
|
53
|
+
/**
|
|
54
|
+
* Audits `root` (default: the whole page) with axe-core. Needs `axe-core` in the project: `npm i -D axe-core`.
|
|
55
|
+
* Page-level rules (landmarks, a level-one heading) are skipped when `root` is an element.
|
|
56
|
+
*/
|
|
57
|
+
export async function a11y(root, options = {}) {
|
|
58
|
+
let axe;
|
|
59
|
+
try {
|
|
60
|
+
// @ts-ignore axe-core is an optional peer dependency and may not be installed when this file is compiled.
|
|
61
|
+
const loaded = (await import('axe-core'));
|
|
62
|
+
axe = (loaded.default ?? loaded);
|
|
63
|
+
if (typeof axe.run !== 'function')
|
|
64
|
+
throw new Error('not axe');
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
throw new Error('a11y() needs axe-core: npm i -D axe-core');
|
|
68
|
+
}
|
|
69
|
+
const config = {};
|
|
70
|
+
if (options.rules !== undefined)
|
|
71
|
+
config.runOnly = { type: 'rule', values: options.rules };
|
|
72
|
+
const off = [...(options.ignore ?? []), ...(root !== undefined && options.rules === undefined ? pageRules : [])];
|
|
73
|
+
if (off.length > 0)
|
|
74
|
+
config.rules = Object.fromEntries(off.map((id) => [id, { enabled: false }]));
|
|
75
|
+
const results = await axe.run(root ?? document, config);
|
|
76
|
+
const violations = results.violations.map((v) => ({
|
|
77
|
+
id: v.id,
|
|
78
|
+
impact: v.impact ?? null,
|
|
79
|
+
help: v.help,
|
|
80
|
+
nodes: v.nodes.map((n) => (n.html.length > 200 ? `${n.html.slice(0, 197)}...` : n.html)),
|
|
81
|
+
}));
|
|
82
|
+
return { violations, ok: violations.length === 0 };
|
|
83
|
+
}
|
|
84
|
+
/** True when `root` has no accessibility violations, so a law can `return await accessible(el)`. */
|
|
85
|
+
export async function accessible(root, options) {
|
|
86
|
+
return (await a11y(root, options)).ok;
|
|
87
|
+
}
|
|
88
|
+
// Component-level testing: isolated mounting with generated props, framework adapters and queries.
|
|
89
|
+
export { mountComponent, rendersSame, spy } from "./component.js";
|
|
90
|
+
export { allByRole, allByText, allByLabel, allByTestId, byRole, byText, byLabel, byTestId, text, within, } from "./queries.js";
|
|
91
|
+
export { domAdapter } from "./adapters/dom.js";
|
|
92
|
+
export { elementAdapter } from "./adapters/element.js";
|
|
93
|
+
export { reactAdapter } from "./adapters/react.js";
|
|
94
|
+
export { angularAdapter } from "./adapters/angular.js";
|
|
95
|
+
/** Times `work` (sync or async) over `runs` runs after a warm-up, in milliseconds as the browser clock reads them. */
|
|
96
|
+
export async function measure(work, runs = 7) {
|
|
97
|
+
await settle();
|
|
98
|
+
await work();
|
|
99
|
+
const times = [];
|
|
100
|
+
for (let i = 0; i < runs; i++) {
|
|
101
|
+
const start = performance.now();
|
|
102
|
+
await work();
|
|
103
|
+
await settle(1);
|
|
104
|
+
times.push(performance.now() - start);
|
|
105
|
+
}
|
|
106
|
+
times.sort((a, b) => a - b);
|
|
107
|
+
const at = (q) => times[Math.min(times.length - 1, Math.floor(q * times.length))] ?? 0;
|
|
108
|
+
return { runs, min: times[0] ?? 0, median: at(0.5), p95: at(0.95), max: times[times.length - 1] ?? 0 };
|
|
109
|
+
}
|
|
110
|
+
/** True when `work` takes at most `budgetMs` at the median, so a law can `return await fast(() => click(add), 50)`. */
|
|
111
|
+
export async function fast(work, budgetMs, runs = 7) {
|
|
112
|
+
return (await measure(work, runs)).median <= budgetMs;
|
|
113
|
+
}
|
|
114
|
+
/** Milliseconds the main thread was blocked by long tasks (over 50 ms) while `work` ran; 0 where the browser has no such API. */
|
|
115
|
+
export async function blocking(work) {
|
|
116
|
+
let blocked = 0;
|
|
117
|
+
const add = (entries) => {
|
|
118
|
+
for (const entry of entries)
|
|
119
|
+
blocked += Math.max(0, entry.duration - 50);
|
|
120
|
+
};
|
|
121
|
+
let observer;
|
|
122
|
+
try {
|
|
123
|
+
observer = new PerformanceObserver((list) => add(list.getEntries()));
|
|
124
|
+
observer.observe({ type: 'longtask' });
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
observer = undefined;
|
|
128
|
+
}
|
|
129
|
+
// A task of its own, so that `work` is measured as a task and not as part of the caller's.
|
|
130
|
+
await new Promise((done, fail) => setTimeout(() => {
|
|
131
|
+
Promise.resolve(work()).then(() => done(), fail);
|
|
132
|
+
}, 0));
|
|
133
|
+
await settle();
|
|
134
|
+
// takeRecords flushes the entries the browser has queued for the observer, so there is nothing to wait for.
|
|
135
|
+
if (observer !== undefined)
|
|
136
|
+
add(observer.takeRecords());
|
|
137
|
+
observer?.disconnect();
|
|
138
|
+
return blocked;
|
|
139
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface Platform {
|
|
2
|
+
name: string;
|
|
3
|
+
os: string;
|
|
4
|
+
cpu: string;
|
|
5
|
+
libc?: 'glibc' | 'musl';
|
|
6
|
+
}
|
|
7
|
+
export declare const platforms: Platform[];
|
|
8
|
+
export declare const packageName: (platform: Platform) => string;
|
|
9
|
+
export declare const executableName: (os?: string) => string;
|
|
10
|
+
/** The platform of this process, or undefined when there is no compiler build for it. */
|
|
11
|
+
export declare function hostPlatform(): Platform | undefined;
|
|
12
|
+
/** The compiler installed as an optional dependency of tslaws, when there is one for this machine. */
|
|
13
|
+
export declare function bundledCompiler(): string | undefined;
|
|
14
|
+
/** The compiler built in a checkout of the tsc-laws repository (packages/tsc-ts/dist), found by walking up from `from`. */
|
|
15
|
+
export declare function checkoutCompiler(from: string): string | undefined;
|
|
16
|
+
/** The version of this package, for messages. */
|
|
17
|
+
export declare function ownVersion(): string;
|