tslaws 0.3.1 → 0.4.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 CHANGED
@@ -159,6 +159,7 @@ See [docs/laws.md](../../docs/laws.md#the-dashboard).
159
159
 
160
160
  `tslaws mutate [files...] [--max N] [--seed N] [--json]` mutation-tests the laws: it mutates the covered code, re-runs the laws against each mutant, and lists the survivors no law caught. It handles `.tsx`/`.jsx` and projects with a lock, and takes the run flags of `check`. See [docs/laws.md](../../docs/laws.md#mutation-testing).
161
161
 
162
+ - **Other source formats:** `tslaws.json` `bundlerPlugins` lists esbuild plugin modules (paths relative to the file) that teach the browser bundler formats like `.tsrx`. See [docs/laws.md](../../docs/laws.md#bundler-plugins).
162
163
  - **Settings:** `tslaws.json` `runs`, `bound`, `proofTimeoutMs` and `propertyTimeoutMs` set the run defaults; a command line flag beats them. See [docs/laws.md](../../docs/laws.md#settings).
163
164
 
164
165
  ## Agents cannot move the goalposts
package/dist/browser.js CHANGED
@@ -2,8 +2,10 @@
2
2
  // each input is sent to a page where the compiled law and the code it checks run against a live DOM.
3
3
  import { existsSync } from 'node:fs';
4
4
  import { mkdirSync } from 'node:fs';
5
- import { delimiter, join } from 'node:path';
5
+ import { delimiter, join, relative as pathRelative, resolve, sep } from 'node:path';
6
6
  import { build as bundleWith } from 'esbuild';
7
+ import { explainBundleError, loadBundlerPlugins } from "./bundlerPlugins.js";
8
+ import { readConfig } from "./coverage.js";
7
9
  import { chromium, firefox, webkit } from 'playwright-core';
8
10
  import { toWire } from "./generators.js";
9
11
  import { runProperty, summarise } from "./property.js";
@@ -156,21 +158,70 @@ function projectModulesPlugin(cwd) {
156
158
  },
157
159
  };
158
160
  }
161
+ // The compiler writes only what it compiles to .tslaws/out, so a relative import of any other source file (a `.tsrx`
162
+ // template, a stylesheet, an image) would look for it there. Such an import resolves to the file beside the source.
163
+ function sourceFilesPlugin(cwd) {
164
+ const out = join(cwd, '.tslaws', 'out');
165
+ return {
166
+ name: 'tslaws-source-files',
167
+ setup(build) {
168
+ build.onResolve({ filter: /^\.\.?\// }, (args) => {
169
+ if (args.resolveDir !== out && !args.resolveDir.startsWith(out + sep))
170
+ return undefined;
171
+ const inOut = resolve(args.resolveDir, args.path);
172
+ if (existsSync(inOut))
173
+ return undefined;
174
+ const source = join(cwd, pathRelative(out, inOut));
175
+ return existsSync(source) && !source.endsWith('.js') ? { path: source } : undefined;
176
+ });
177
+ },
178
+ };
179
+ }
180
+ const assetExtensions = [
181
+ '.png',
182
+ '.jpg',
183
+ '.jpeg',
184
+ '.gif',
185
+ '.webp',
186
+ '.avif',
187
+ '.svg',
188
+ '.ico',
189
+ '.woff',
190
+ '.woff2',
191
+ '.ttf',
192
+ '.otf',
193
+ '.eot',
194
+ ];
195
+ /** The project's `bundlerPlugins` from tslaws.json come first, so they can claim a format before the built-in resolver. */
159
196
  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;
197
+ const projectPlugins = await loadBundlerPlugins(cwd, readConfig(cwd).bundlerPlugins);
198
+ try {
199
+ const result = await bundleWith({
200
+ plugins: [...projectPlugins, sourceFilesPlugin(cwd), projectModulesPlugin(cwd)],
201
+ entryPoints: [jsFile],
202
+ bundle: true,
203
+ write: false,
204
+ // Stylesheets a law imports come out as a separate .css file, which needs an output directory to be named.
205
+ outdir: join(cwd, '.tslaws', 'bundle'),
206
+ // Images and fonts become data URLs, so a url() in the CSS or an import in the JavaScript needs no file server.
207
+ loader: Object.fromEntries(assetExtensions.map((extension) => [extension, 'dataurl'])),
208
+ format: 'iife',
209
+ globalName: '__lawModule',
210
+ platform: 'browser',
211
+ target: 'es2022',
212
+ absWorkingDir: cwd,
213
+ logLevel: 'silent',
214
+ define: { 'process.env.NODE_ENV': '"development"', global: 'globalThis' },
215
+ });
216
+ const emitted = (extension) => result.outputFiles
217
+ .filter((file) => file.path.endsWith(extension))
218
+ .map((file) => file.text)
219
+ .join('\n');
220
+ return { js: emitted('.js'), css: emitted('.css') };
221
+ }
222
+ catch (error) {
223
+ throw explainBundleError(error);
224
+ }
174
225
  }
175
226
  /** Runs the counterexample once more, leaves the DOM as it is, and photographs the page. Best effort. */
176
227
  async function capture(page, law, args, options) {
@@ -187,7 +238,7 @@ async function capture(page, law, args, options) {
187
238
  }
188
239
  /** Property-tests one law in a page; `'timeout'` when it does not finish within `options.propertyTimeoutMs`. */
189
240
  export async function runBrowserLaw(law, options) {
190
- const code = await bundleOf(law.jsFile, options.cwd);
241
+ const { js: code, css } = await bundleOf(law.jsFile, options.cwd);
191
242
  const page = await (await launch(resolveEngine(options.browser))).newPage();
192
243
  // A watcher needs only the first few runs to linger; the rest should not eat the time limit.
193
244
  let calls = 0;
@@ -216,6 +267,9 @@ export async function runBrowserLaw(law, options) {
216
267
  : undefined;
217
268
  try {
218
269
  await page.setContent('<!doctype html><html><head><meta charset="utf-8"></head><body></body></html>');
270
+ // The stylesheet goes in before the script runs, as a linked one would.
271
+ if (css !== '')
272
+ await page.addStyleTag({ content: css });
219
273
  await page.addScriptTag({ content: code });
220
274
  await page.addScriptTag({ content: harness });
221
275
  const call = async (...args) => {
@@ -47,3 +47,16 @@ export { reactAdapter } from './adapters/react.ts';
47
47
  export type { ReactFunctions, ReactRootLike } from './adapters/react.ts';
48
48
  export { angularAdapter } from './adapters/angular.ts';
49
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>;
@@ -92,3 +92,48 @@ export { domAdapter } from "./adapters/dom.js";
92
92
  export { elementAdapter } from "./adapters/element.js";
93
93
  export { reactAdapter } from "./adapters/react.js";
94
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
+ }
package/dist/bundled.d.ts CHANGED
@@ -11,5 +11,7 @@ export declare const executableName: (os?: string) => string;
11
11
  export declare function hostPlatform(): Platform | undefined;
12
12
  /** The compiler installed as an optional dependency of tslaws, when there is one for this machine. */
13
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;
14
16
  /** The version of this package, for messages. */
15
17
  export declare function ownVersion(): string;
package/dist/bundled.js CHANGED
@@ -44,6 +44,17 @@ export function bundledCompiler() {
44
44
  return undefined;
45
45
  }
46
46
  }
47
+ /** The compiler built in a checkout of the tsc-laws repository (packages/tsc-ts/dist), found by walking up from `from`. */
48
+ export function checkoutCompiler(from) {
49
+ const name = process.platform === 'win32' ? 'tsc.exe' : 'tsc';
50
+ for (let dir = from;; dir = dirname(dir)) {
51
+ const candidate = join(dir, 'packages', 'tsc-ts', 'dist', name);
52
+ if (existsSync(candidate))
53
+ return candidate;
54
+ if (dirname(dir) === dir)
55
+ return undefined;
56
+ }
57
+ }
47
58
  /** The version of this package, for messages. */
48
59
  export function ownVersion() {
49
60
  try {
@@ -0,0 +1,20 @@
1
+ import type { Plugin } from 'esbuild';
2
+ /** What a plugin factory receives. */
3
+ export interface BundlerPluginContext {
4
+ /** The project root: the directory holding tslaws.json. */
5
+ cwd: string;
6
+ }
7
+ export type BundlerPluginExport = Plugin | ((context: BundlerPluginContext) => Plugin | Promise<Plugin>);
8
+ /** The setting the messages name. */
9
+ export declare const bundlerPluginsSetting = "bundlerPlugins";
10
+ /**
11
+ * Loads each listed module (paths relative to `cwd`, where tslaws.json lives) with a dynamic import of its file URL.
12
+ * Throws one error naming the setting and the module when a module is missing, throws while loading or in its
13
+ * factory, or does not export a plugin, so the law file reports an `error` rather than the run crashing.
14
+ */
15
+ export declare function loadBundlerPlugins(cwd: string, paths: readonly string[] | undefined): Promise<Plugin[]>;
16
+ /**
17
+ * Rewrites an esbuild failure into advice when the cause is a source format the bundler has no loader for (the usual
18
+ * sign that a `bundlerPlugins` entry is missing). Other messages pass through unchanged.
19
+ */
20
+ export declare function explainBundleError(error: Error): Error;
@@ -0,0 +1,71 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ // Bundler plugins: a project teaches the browser bundler a source format (`.tsrx`, `.svelte`, ...) by listing esbuild
10
+ // plugin modules under `bundlerPlugins` in tslaws.json. Each module default-exports an esbuild Plugin, or a function
11
+ // (sync or async) that takes `{ cwd }` and returns one.
12
+ import { existsSync } from 'node:fs';
13
+ import { isAbsolute, resolve } from 'node:path';
14
+ import { pathToFileURL } from 'node:url';
15
+ /** The setting the messages name. */
16
+ export const bundlerPluginsSetting = 'bundlerPlugins';
17
+ /**
18
+ * Loads each listed module (paths relative to `cwd`, where tslaws.json lives) with a dynamic import of its file URL.
19
+ * Throws one error naming the setting and the module when a module is missing, throws while loading or in its
20
+ * factory, or does not export a plugin, so the law file reports an `error` rather than the run crashing.
21
+ */
22
+ export async function loadBundlerPlugins(cwd, paths) {
23
+ const plugins = [];
24
+ for (const path of paths ?? []) {
25
+ const file = isAbsolute(path) ? path : resolve(cwd, path);
26
+ const fail = (why) => {
27
+ throw new Error(`tslaws.json ${bundlerPluginsSetting}: cannot use "${path}" (${file}): ${why}`);
28
+ };
29
+ if (!existsSync(file))
30
+ fail('no such file; paths are relative to the directory of tslaws.json');
31
+ let exported;
32
+ try {
33
+ exported = (await import(__rewriteRelativeImportExtension(pathToFileURL(file).href))).default;
34
+ }
35
+ catch (error) {
36
+ return fail(`it failed to load: ${error.message}`);
37
+ }
38
+ let plugin = exported;
39
+ if (typeof exported === 'function') {
40
+ try {
41
+ plugin = await exported({ cwd });
42
+ }
43
+ catch (error) {
44
+ return fail(`its plugin function threw: ${error.message}`);
45
+ }
46
+ }
47
+ const candidate = plugin;
48
+ if (typeof candidate !== 'object' ||
49
+ candidate === null ||
50
+ typeof candidate.name !== 'string' ||
51
+ typeof candidate.setup !== 'function')
52
+ fail('its default export must be an esbuild plugin ({ name, setup }) or a function returning one');
53
+ plugins.push(plugin);
54
+ }
55
+ return plugins;
56
+ }
57
+ /**
58
+ * Rewrites an esbuild failure into advice when the cause is a source format the bundler has no loader for (the usual
59
+ * sign that a `bundlerPlugins` entry is missing). Other messages pass through unchanged.
60
+ */
61
+ export function explainBundleError(error) {
62
+ const match = /No loader is configured for "(\.[^"]+)" files/.exec(error.message);
63
+ if (match === null)
64
+ return error;
65
+ const extension = match[1];
66
+ const advice = `the browser bundler cannot load "${extension}" files. Add a bundler plugin for that extension in tslaws.json ` +
67
+ `"${bundlerPluginsSetting}", for example { "${bundlerPluginsSetting}": ["./tools/${extension.slice(1)}-plugin.mjs"] }, ` +
68
+ `where the module default-exports an esbuild plugin whose onLoad({ filter: /\${extension}$/ }) returns the compiled ` +
69
+ `JavaScript (see "Bundler plugins" in docs/laws.md)`;
70
+ return new Error(`${advice}. esbuild said: ${error.message.split('\n')[0]}`);
71
+ }
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@ import { createInterface } from 'node:readline/promises';
2
2
  import { readdirSync, statSync, watch } from 'node:fs';
3
3
  import { join, relative, resolve, sep } from 'node:path';
4
4
  import { resolveEngine } from "./browser.js";
5
- import { bundledCompiler } from "./bundled.js";
5
+ import { bundledCompiler, checkoutCompiler } from "./bundled.js";
6
6
  import { check, exemptionsOf } from "./check.js";
7
7
  import { readManifest } from "./compile.js";
8
8
  import { hintFor } from "./hints.js";
@@ -18,7 +18,7 @@ import { serveUi } from "./ui.js";
18
18
  import { writeLock } from "./lock.js";
19
19
  const usage = `tslaws init [--force] scaffold tslaws.json, .gitignore, AGENTS.md, Claude Code hooks and a starter law (never writes the lock)
20
20
  tslaws check [files...] [--reporter text|json|junit|github] [--json] [--summary] [--watch] [--runs N] [--seed N] [--no-prove] [--no-regressions] [--bound N] [--proof-timeout MS] [--property-timeout MS] [--proposed] [--compiler PATH] [--browser chromium|firefox|webkit]
21
- tslaws ui [files...] [--port N] [--no-open] open a dashboard of the laws in a browser: tree, filters, search, source, live reruns on save
21
+ tslaws ui [files...] [--port N] [--no-open] [--tab] open a dashboard of the laws in its own desktop window (--tab: a browser tab): tree, filters, search, source, live reruns on save
22
22
  tslaws mutate [files...] [--max N] [--seed N] [--runs N] [--bound N] [--proof-timeout MS] [--property-timeout MS] [--browser ENGINE] [--json] mutation-test the laws: mutate covered code, report mutants no law kills
23
23
  tslaws regressions [list|clear] show or delete the stored counterexamples (.tslaws/regressions); check replays them first, --no-regressions skips them
24
24
  tslaws accept [files...] record the current laws in tsc-laws.lock (people only: needs a terminal)
@@ -151,7 +151,7 @@ function options(cwd, flags, chosen) {
151
151
  cwd,
152
152
  runs: settings.runs,
153
153
  seed: flags.has('seed') ? Number(flags.get('seed')) : undefined,
154
- compiler: String(flags.get('compiler') ?? process.env.TSC_LAWS_BIN ?? bundledCompiler() ?? 'tsc-laws'),
154
+ compiler: String(flags.get('compiler') ?? process.env.TSC_LAWS_BIN ?? bundledCompiler() ?? checkoutCompiler(cwd) ?? 'tsc-laws'),
155
155
  lockFile: join(cwd, 'tsc-laws.lock'),
156
156
  noProve: flags.has('no-prove'),
157
157
  noRegressions: flags.has('no-regressions'),
@@ -350,6 +350,7 @@ export async function main(args) {
350
350
  cwd,
351
351
  port: Number(flags.get('port') ?? 51205),
352
352
  open: !flags.has('no-open'),
353
+ appWindow: !flags.has('tab'),
353
354
  // A watcher sees a browser page only if it stays as the law left it for a moment.
354
355
  run: (only, onEvent, laws) => check({ ...options(cwd, flags, only ?? chosen), only: laws, onEvent, browserPauseMs: 120 }, !flags.has('proposed')),
355
356
  log: (line) => process.stdout.write(`${line}\n`),
package/dist/compile.d.ts CHANGED
@@ -5,7 +5,6 @@ export interface BuildResult {
5
5
  diagnostics: string;
6
6
  ok: boolean;
7
7
  }
8
- /** Lists the laws of `files` with the problems that stop them running. */
9
8
  export declare function readManifest(compiler: string, files: string[], cwd: string): ManifestLaw[];
10
9
  /**
11
10
  * The compiler flags for the `compilerOptions` of tslaws.json. Only the supported keys pass; anything else is an
package/dist/compile.js CHANGED
@@ -36,6 +36,12 @@ function compilerEnv(cwd, compiler) {
36
36
  }
37
37
  }
38
38
  /** Lists the laws of `files` with the problems that stop them running. */
39
+ /** Says what to do when the compiler cannot start, in particular when none was found at all. */
40
+ function cannotRun(compiler, error) {
41
+ if (error.code === 'ENOENT')
42
+ return `no tsc-laws compiler found (${compiler}): install the tslaws-compiler package for this platform, or set TSC_LAWS_BIN / --compiler to the compiler executable`;
43
+ return `cannot run ${compiler}: ${error.message}`;
44
+ }
39
45
  export function readManifest(compiler, files, cwd) {
40
46
  const run = launch(compiler, ['--laws-manifest', ...files], {
41
47
  cwd,
@@ -44,7 +50,7 @@ export function readManifest(compiler, files, cwd) {
44
50
  env: compilerEnv(cwd, compiler),
45
51
  });
46
52
  if (run.error !== undefined)
47
- throw new Error(`cannot run ${compiler}: ${run.error.message}`);
53
+ throw new Error(cannotRun(compiler, run.error));
48
54
  const text = (run.stdout ?? '').trim();
49
55
  if (text === '')
50
56
  throw new Error(`${compiler} --laws-manifest printed nothing: ${run.stderr ?? ''}`);
@@ -131,6 +137,6 @@ export function build(compiler, files, cwd) {
131
137
  `${run.stdout ?? ''}${run.stderr ?? ''}`.trim() === ''; attempt += 1)
132
138
  run = launch(compiler, args, { cwd, encoding: 'utf8', maxBuffer: 64 << 20, env: compilerEnv(cwd, compiler) });
133
139
  if (run.error !== undefined)
134
- throw new Error(`cannot run ${compiler}: ${run.error.message}`);
140
+ throw new Error(cannotRun(compiler, run.error));
135
141
  return { outDir, diagnostics: `${run.stdout ?? ''}${run.stderr ?? ''}`.trim(), ok: run.status === 0 };
136
142
  }
@@ -6,6 +6,8 @@ export interface CoverageConfig {
6
6
  compilerOptions?: Record<string, unknown>;
7
7
  /** End-to-end laws: the app to drive; see `parseE2eConfig`. */
8
8
  e2e?: E2eConfig;
9
+ /** esbuild plugin modules (paths relative to tslaws.json) that teach the browser bundler new source formats. */
10
+ bundlerPlugins?: string[];
9
11
  /** Run settings; a command line flag beats them and they beat the built-in default. */
10
12
  runs?: number;
11
13
  propertyTimeoutMs?: number;
package/dist/coverage.js CHANGED
@@ -9,7 +9,7 @@ import { dirname, join, relative, resolve } from 'node:path';
9
9
  import ts from 'typescript';
10
10
  import { parseE2eConfig } from "./e2e.js";
11
11
  const settingKeys = ['runs', 'propertyTimeoutMs', 'proofTimeoutMs', 'bound'];
12
- const configKeys = ['cover', 'compilerOptions', 'e2e', ...settingKeys];
12
+ const configKeys = ['cover', 'compilerOptions', 'e2e', 'bundlerPlugins', ...settingKeys];
13
13
  /** Validates the top level of tslaws.json: known keys only, and the run settings are integers of at least 1. */
14
14
  export function parseConfig(raw) {
15
15
  const fail = (message) => {
@@ -27,6 +27,10 @@ export function parseConfig(raw) {
27
27
  if (value !== undefined && (typeof value !== 'number' || !Number.isInteger(value) || value < 1))
28
28
  fail(`"${key}" must be an integer of at least 1, got ${JSON.stringify(value)}`);
29
29
  }
30
+ const plugins = object.bundlerPlugins;
31
+ if (plugins !== undefined &&
32
+ (!Array.isArray(plugins) || plugins.some((entry) => typeof entry !== 'string' || entry === '')))
33
+ fail(`"bundlerPlugins" must be an array of module paths, got ${JSON.stringify(plugins)}`);
30
34
  return object;
31
35
  }
32
36
  export function readConfig(cwd) {
package/dist/e2e.js CHANGED
@@ -208,8 +208,21 @@ async function startServer(server, cwd) {
208
208
  throw error;
209
209
  }
210
210
  }
211
- /** Runs `law` once in a fresh context; `keep` leaves the page open for a photograph. */
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. */
212
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) {
213
226
  const context = await contextOf();
214
227
  let closing = true;
215
228
  try {
package/dist/e2eApi.d.ts CHANGED
@@ -46,3 +46,35 @@ export declare function a11y(target?: Page, options?: A11yOptions): Promise<{
46
46
  }>;
47
47
  /** True when the page has no accessibility violations, so a law can `return await accessible()`. */
48
48
  export declare function accessible(target?: Page, options?: A11yOptions): Promise<boolean>;
49
+ /** What the browser measured for the page: the numbers behind Core Web Vitals, plus size. Times are milliseconds. */
50
+ export interface Vitals {
51
+ /** Time to first contentful paint. */
52
+ fcp: number | null;
53
+ /** Largest contentful paint (Chromium). */
54
+ lcp: number | null;
55
+ /** Cumulative layout shift, unitless (Chromium). */
56
+ cls: number;
57
+ /** Time the main thread was blocked by long tasks beyond 50 ms each (Chromium). */
58
+ blocking: number;
59
+ /** DOMContentLoaded and load, from navigation start. */
60
+ domContentLoaded: number | null;
61
+ load: number | null;
62
+ /** Elements in the document. */
63
+ domNodes: number;
64
+ /** Bytes transferred for the page and everything it loaded. */
65
+ bytes: number;
66
+ }
67
+ export type SpeedBudget = Partial<Record<keyof Vitals, number>>;
68
+ /** Measures the page as it stands: paint, layout shift, blocking time, load and size. Call it after `open()` and `settle()`. */
69
+ export declare function vitals(target?: Page): Promise<Vitals>;
70
+ /**
71
+ * Checks the page against a budget, for example `{ lcp: 2500, cls: 0.1, blocking: 200 }`. `violations` names each measure over its
72
+ * budget, so an agent sees what to fix; a measure the browser cannot report counts as over.
73
+ */
74
+ export declare function speed(budget: SpeedBudget, target?: Page): Promise<{
75
+ vitals: Vitals;
76
+ violations: string[];
77
+ ok: boolean;
78
+ }>;
79
+ /** True when the page is within `budget`, so a law can `return await fast({ lcp: 2500 })`. */
80
+ export declare function fast(budget: SpeedBudget, target?: Page): Promise<boolean>;
package/dist/e2eApi.js CHANGED
@@ -90,3 +90,51 @@ function axeConfig(options) {
90
90
  export async function accessible(target, options) {
91
91
  return (await a11y(target, options)).ok;
92
92
  }
93
+ const vitalsScript = `new Promise((done) => {
94
+ const out = { fcp: null, lcp: null, cls: 0, blocking: 0 };
95
+ const watch = (type, onEntry) => {
96
+ try {
97
+ new PerformanceObserver((list) => list.getEntries().forEach(onEntry)).observe({ type, buffered: true });
98
+ } catch {}
99
+ };
100
+ watch('paint', (e) => { if (e.name === 'first-contentful-paint') out.fcp = e.startTime; });
101
+ watch('largest-contentful-paint', (e) => { out.lcp = e.startTime; });
102
+ watch('layout-shift', (e) => { if (!e.hadRecentInput) out.cls += e.value; });
103
+ watch('longtask', (e) => { out.blocking += Math.max(0, e.duration - 50); });
104
+ setTimeout(() => {
105
+ const nav = performance.getEntriesByType('navigation')[0];
106
+ const bytes = performance.getEntriesByType('resource').reduce((sum, r) => sum + (r.transferSize || 0), nav ? nav.transferSize || 0 : 0);
107
+ done({
108
+ ...out,
109
+ domContentLoaded: nav ? nav.domContentLoadedEventEnd : null,
110
+ load: nav && nav.loadEventEnd > 0 ? nav.loadEventEnd : null,
111
+ domNodes: document.getElementsByTagName('*').length,
112
+ bytes,
113
+ });
114
+ }, 250);
115
+ })`;
116
+ /** Measures the page as it stands: paint, layout shift, blocking time, load and size. Call it after `open()` and `settle()`. */
117
+ export async function vitals(target) {
118
+ const used = target ?? current().page;
119
+ return (await used.evaluate(vitalsScript));
120
+ }
121
+ /**
122
+ * Checks the page against a budget, for example `{ lcp: 2500, cls: 0.1, blocking: 200 }`. `violations` names each measure over its
123
+ * budget, so an agent sees what to fix; a measure the browser cannot report counts as over.
124
+ */
125
+ export async function speed(budget, target) {
126
+ const measured = await vitals(target);
127
+ const violations = [];
128
+ for (const [name, limit] of Object.entries(budget)) {
129
+ const value = measured[name];
130
+ if (value === null)
131
+ violations.push(`${name}: not reported (budget ${limit})`);
132
+ else if (value > limit)
133
+ violations.push(`${name}: ${Math.round(value * 1000) / 1000} over budget ${limit}`);
134
+ }
135
+ return { vitals: measured, violations, ok: violations.length === 0 };
136
+ }
137
+ /** True when the page is within `budget`, so a law can `return await fast({ lcp: 2500 })`. */
138
+ export async function fast(budget, target) {
139
+ return (await speed(budget, target)).ok;
140
+ }
package/dist/ui.d.ts CHANGED
@@ -4,6 +4,8 @@ export interface UiOptions {
4
4
  cwd: string;
5
5
  port: number;
6
6
  open: boolean;
7
+ /** Open in its own chromeless window (Chrome, Edge or Chromium) rather than a tab, so it reads as a desktop app. */
8
+ appWindow?: boolean;
7
9
  /** Runs the check of the project, of just these law files, or (with `laws`, in one file) of just these laws. */
8
10
  run: (files: string[] | undefined, onEvent: (event: LawEvent) => void, laws?: string[]) => Promise<CheckReport>;
9
11
  log: (line: string) => void;
package/dist/ui.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // status filters and search, a detail pane (report, source, diagnostics), and live reruns when a file changes.
3
3
  // It listens on 127.0.0.1 only and offers no way to accept laws: that stays a person's act at a terminal.
4
4
  import { spawn } from 'node:child_process';
5
- import { readFileSync, watch } from 'node:fs';
5
+ import { existsSync, readFileSync, watch } from 'node:fs';
6
6
  import { createServer } from 'node:http';
7
7
  import { basename, join, resolve, sep } from 'node:path';
8
8
  import { hintFor } from "./hints.js";
@@ -29,7 +29,34 @@ function sourceOf(cwd, file) {
29
29
  return undefined;
30
30
  }
31
31
  }
32
- function openInBrowser(url) {
32
+ /** A Chromium-family browser, which can show the page as an app window. */
33
+ function appBrowser() {
34
+ const env = process.env;
35
+ const candidates = process.platform === 'win32'
36
+ ? [env['ProgramFiles(x86)'], env['ProgramFiles'], env['LOCALAPPDATA']].flatMap((base) => base === undefined
37
+ ? []
38
+ : [join(base, 'Microsoft/Edge/Application/msedge.exe'), join(base, 'Google/Chrome/Application/chrome.exe')])
39
+ : process.platform === 'darwin'
40
+ ? [
41
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
42
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
43
+ ]
44
+ : ['/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/usr/bin/microsoft-edge'];
45
+ return candidates.find((path) => existsSync(path));
46
+ }
47
+ function openInBrowser(url, appWindow) {
48
+ const browser = appWindow ? appBrowser() : undefined;
49
+ if (browser !== undefined) {
50
+ try {
51
+ spawn(browser, [`--app=${url}`, '--window-size=1360,860', '--no-first-run'], { stdio: 'ignore', detached: true })
52
+ .on('error', () => undefined)
53
+ .unref();
54
+ return;
55
+ }
56
+ catch {
57
+ // Falls back to the default browser.
58
+ }
59
+ }
33
60
  const [command, args] = process.platform === 'win32'
34
61
  ? ['cmd', ['/c', 'start', '', url]]
35
62
  : process.platform === 'darwin'
@@ -276,7 +303,7 @@ export async function serveUi(options) {
276
303
  const url = `http://127.0.0.1:${port}/`;
277
304
  options.log(`tslaws ui: ${url} (Ctrl+C exits)`);
278
305
  if (options.open)
279
- openInBrowser(url);
306
+ openInBrowser(url, options.appWindow ?? false);
280
307
  let timer;
281
308
  const watcher = watch(cwd, { recursive: true }, (_event, filename) => {
282
309
  const name = filename === null ? null : String(filename);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tslaws",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Runs and checks `law` declarations compiled by tsc-laws: property tests, SMT proofs, tamper lock.",
5
5
  "bin": {
6
6
  "tslaws": "bin/tslaws.js"
@@ -58,7 +58,9 @@
58
58
  "z3-solver": "^4.15.0"
59
59
  },
60
60
  "devDependencies": {
61
- "@types/node": "^24.0.0"
61
+ "@tsrx/ripple": "0.2.11",
62
+ "@types/node": "^24.0.0",
63
+ "ripple": "0.6.1"
62
64
  },
63
65
  "peerDependencies": {
64
66
  "axe-core": "^4.0.0"
@@ -69,11 +71,11 @@
69
71
  }
70
72
  },
71
73
  "optionalDependencies": {
72
- "tslaws-compiler-darwin-arm64": "0.3.1",
73
- "tslaws-compiler-darwin-x64": "0.3.1",
74
- "tslaws-compiler-linux-arm64-gnu": "0.3.1",
75
- "tslaws-compiler-linux-x64-gnu": "0.3.1",
76
- "tslaws-compiler-win32-x64-msvc": "0.3.1"
74
+ "tslaws-compiler-darwin-arm64": "0.4.0",
75
+ "tslaws-compiler-darwin-x64": "0.4.0",
76
+ "tslaws-compiler-linux-arm64-gnu": "0.4.0",
77
+ "tslaws-compiler-linux-x64-gnu": "0.4.0",
78
+ "tslaws-compiler-win32-x64-msvc": "0.4.0"
77
79
  },
78
80
  "publishConfig": {
79
81
  "access": "public"