fast-replay 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +126 -0
  3. package/dist/api.d.ts +65 -0
  4. package/dist/api.js +78 -0
  5. package/dist/api.js.map +1 -0
  6. package/dist/browser.d.ts +67 -0
  7. package/dist/browser.js +77 -0
  8. package/dist/browser.js.map +1 -0
  9. package/dist/cli/format.d.ts +12 -0
  10. package/dist/cli/format.js +55 -0
  11. package/dist/cli/format.js.map +1 -0
  12. package/dist/cli/index.d.ts +2 -0
  13. package/dist/cli/index.js +199 -0
  14. package/dist/cli/index.js.map +1 -0
  15. package/dist/compiler/compile.d.ts +18 -0
  16. package/dist/compiler/compile.js +226 -0
  17. package/dist/compiler/compile.js.map +1 -0
  18. package/dist/compiler/normalize.d.ts +16 -0
  19. package/dist/compiler/normalize.js +75 -0
  20. package/dist/compiler/normalize.js.map +1 -0
  21. package/dist/compiler/waits.d.ts +31 -0
  22. package/dist/compiler/waits.js +69 -0
  23. package/dist/compiler/waits.js.map +1 -0
  24. package/dist/ir/io.d.ts +47 -0
  25. package/dist/ir/io.js +122 -0
  26. package/dist/ir/io.js.map +1 -0
  27. package/dist/ir/schema.d.ts +236 -0
  28. package/dist/ir/schema.js +153 -0
  29. package/dist/ir/schema.js.map +1 -0
  30. package/dist/mcp/index.d.ts +2 -0
  31. package/dist/mcp/index.js +22 -0
  32. package/dist/mcp/index.js.map +1 -0
  33. package/dist/mcp/server.d.ts +8 -0
  34. package/dist/mcp/server.js +268 -0
  35. package/dist/mcp/server.js.map +1 -0
  36. package/dist/noise.d.ts +20 -0
  37. package/dist/noise.js +70 -0
  38. package/dist/noise.js.map +1 -0
  39. package/dist/recorder/agent/capture.d.ts +15 -0
  40. package/dist/recorder/agent/capture.js +310 -0
  41. package/dist/recorder/agent/capture.js.map +1 -0
  42. package/dist/recorder/agent/config.d.ts +25 -0
  43. package/dist/recorder/agent/config.js +23 -0
  44. package/dist/recorder/agent/config.js.map +1 -0
  45. package/dist/recorder/agent/dom-reaction.d.ts +13 -0
  46. package/dist/recorder/agent/dom-reaction.js +166 -0
  47. package/dist/recorder/agent/dom-reaction.js.map +1 -0
  48. package/dist/recorder/agent/main.d.ts +1 -0
  49. package/dist/recorder/agent/main.js +14 -0
  50. package/dist/recorder/agent/main.js.map +1 -0
  51. package/dist/recorder/agent/page-agent.d.ts +12 -0
  52. package/dist/recorder/agent/page-agent.js +40 -0
  53. package/dist/recorder/agent/page-agent.js.map +1 -0
  54. package/dist/recorder/agent/reveal-tracker.d.ts +26 -0
  55. package/dist/recorder/agent/reveal-tracker.js +41 -0
  56. package/dist/recorder/agent/reveal-tracker.js.map +1 -0
  57. package/dist/recorder/agent/roles.d.ts +11 -0
  58. package/dist/recorder/agent/roles.js +148 -0
  59. package/dist/recorder/agent/roles.js.map +1 -0
  60. package/dist/recorder/agent/selectors.d.ts +26 -0
  61. package/dist/recorder/agent/selectors.js +256 -0
  62. package/dist/recorder/agent/selectors.js.map +1 -0
  63. package/dist/recorder/agent/text.d.ts +19 -0
  64. package/dist/recorder/agent/text.js +82 -0
  65. package/dist/recorder/agent/text.js.map +1 -0
  66. package/dist/recorder/agent/transport.d.ts +14 -0
  67. package/dist/recorder/agent/transport.js +43 -0
  68. package/dist/recorder/agent/transport.js.map +1 -0
  69. package/dist/recorder/agent/visibility.d.ts +8 -0
  70. package/dist/recorder/agent/visibility.js +20 -0
  71. package/dist/recorder/agent/visibility.js.map +1 -0
  72. package/dist/recorder/agent-bundle.generated.d.ts +2 -0
  73. package/dist/recorder/agent-bundle.generated.js +6 -0
  74. package/dist/recorder/agent-bundle.generated.js.map +1 -0
  75. package/dist/recorder/attach.d.ts +52 -0
  76. package/dist/recorder/attach.js +159 -0
  77. package/dist/recorder/attach.js.map +1 -0
  78. package/dist/recorder/instrument.d.ts +17 -0
  79. package/dist/recorder/instrument.js +24 -0
  80. package/dist/recorder/instrument.js.map +1 -0
  81. package/dist/recorder/launch.d.ts +38 -0
  82. package/dist/recorder/launch.js +72 -0
  83. package/dist/recorder/launch.js.map +1 -0
  84. package/dist/recorder/reaction.d.ts +13 -0
  85. package/dist/recorder/reaction.js +65 -0
  86. package/dist/recorder/reaction.js.map +1 -0
  87. package/dist/recorder/types.d.ts +62 -0
  88. package/dist/recorder/types.js +2 -0
  89. package/dist/recorder/types.js.map +1 -0
  90. package/dist/replayer/artifacts.d.ts +25 -0
  91. package/dist/replayer/artifacts.js +58 -0
  92. package/dist/replayer/artifacts.js.map +1 -0
  93. package/dist/replayer/invariants.d.ts +26 -0
  94. package/dist/replayer/invariants.js +109 -0
  95. package/dist/replayer/invariants.js.map +1 -0
  96. package/dist/replayer/resolve.d.ts +35 -0
  97. package/dist/replayer/resolve.js +50 -0
  98. package/dist/replayer/resolve.js.map +1 -0
  99. package/dist/replayer/run.d.ts +104 -0
  100. package/dist/replayer/run.js +467 -0
  101. package/dist/replayer/run.js.map +1 -0
  102. package/dist/replayer/values.d.ts +13 -0
  103. package/dist/replayer/values.js +76 -0
  104. package/dist/replayer/values.js.map +1 -0
  105. package/dist/replayer/waits.d.ts +25 -0
  106. package/dist/replayer/waits.js +90 -0
  107. package/dist/replayer/waits.js.map +1 -0
  108. package/dist/version.d.ts +1 -0
  109. package/dist/version.js +10 -0
  110. package/dist/version.js.map +1 -0
  111. package/package.json +77 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mukhriddin Shakhriyorov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,126 @@
1
+ # fast-replay
2
+
3
+ Record a browser bug once. Verify the fix in seconds, with no model in the loop.
4
+
5
+ ```
6
+ $ repro run sensor-delete-crash
7
+
8
+ STEP ACTION ACT WAIT TOTAL WHAT
9
+ s1 fill 26ms 492ms 518ms New sensor name textbox in the section labelled "Sensors"
10
+ s5 click 7ms 25ms 32ms Delete Sensor 2 button in the row containing "Sensor 2"
11
+ s10 click 23ms 1.79s 1.81s Generate report button
12
+
13
+ ✓ PASS sensor-delete-crash — 10 steps in 2.84s
14
+ ```
15
+
16
+ Repros are disposable. They live in `.repros/`, and you delete them when the bug is fixed.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ npm install -D fast-replay
22
+ npx playwright install chromium
23
+ ```
24
+
25
+ Node >= 20.
26
+
27
+ ## Use
28
+
29
+ ```bash
30
+ repro record checkout-crash --url http://localhost:3000 # click the bug once
31
+ repro run checkout-crash # bug still reproduces?
32
+ repro run checkout-crash --expect-fixed # did my fix work?
33
+ repro list
34
+ ```
35
+
36
+ Stop recording with **Ctrl/Cmd + Shift + X**, or just close the browser.
37
+
38
+ Exit `0` on pass, `1` on fail. On failure you get the failing step, what it does in plain language, expected vs observed, and `.repros/<name>/artifacts/` with a screenshot, console tail and network log.
39
+
40
+ | Flag | |
41
+ |---|---|
42
+ | `--expect-fixed` | pass when the bug no longer happens |
43
+ | `--headed` | visible browser; some apps refuse headless |
44
+ | `--storage-state <file>` / `--profile <dir>` | get past a login |
45
+ | `--setup <cmd>` | reset state before replaying |
46
+ | `--timeout-scale <n>` | multiply recorded waits, for a slower machine |
47
+
48
+ ## From a coding agent
49
+
50
+ ```json
51
+ { "mcpServers": { "replay": { "command": "npx", "args": ["repro-mcp"] } } }
52
+ ```
53
+
54
+ `repro_run` returns the verdict, the failing step, console, network and **the page as an inline image** — in one call. Works with Claude Code, Codex, Gemini CLI, Cursor.
55
+
56
+ ## What makes it fast
57
+
58
+ Replay never sleeps. Every wait is a signal the recording actually observed — a request settling, an element appearing or vanishing — so a step proceeds the instant the app reacts. Timeouts are derived from what was measured, not guessed.
59
+
60
+ The recording is JSON, not generated code. It is meant to be read and hand-edited.
61
+
62
+ ```jsonc
63
+ {
64
+ "id": "s5",
65
+ "action": "click",
66
+ "target": {
67
+ "candidates": ["role=button[name=\"Delete Sensor 2\"]", "[data-testid=\"sensor-row-2\"] > button"],
68
+ "semantic": "Delete Sensor 2 button in the row containing \"Sensor 2\""
69
+ },
70
+ "waitAfter": {
71
+ "network": [{ "urlPattern": "/api/sensors/*", "method": "DELETE" }],
72
+ "domGone": ["[data-testid=\"sensor-row-2\"]"],
73
+ "timeoutMs": 3000
74
+ }
75
+ }
76
+ ```
77
+
78
+ Selectors are a ladder: test id → `name` → ARIA role + accessible name → labelled ancestor → stable CSS path → text. Build-generated class names are skipped, and a path unique only by sibling position ranks below the text anchor.
79
+
80
+ ## Results
81
+
82
+ Measured on one machine, same flow both ways.
83
+
84
+ | | fast-replay | Playwright MCP |
85
+ |---|---|---|
86
+ | Per verification | **2.7 s** | ~24–32 s |
87
+ | Tool calls | **1** | 3–14 |
88
+ | Model calls | **0** | 1 per action |
89
+ | Context added | ~120 tokens | ~700–2 000 |
90
+
91
+ 10-step flow: 20/20 consecutive replays, slowest 2.77 s.
92
+
93
+ The sharper claim is not speed. In a real app it caught a **736–779 ms** window where a total failed to render — a state that heals before a snapshot returns, so a model-in-the-loop tool structurally cannot see it. Two independent first passes with Playwright MCP concluded "no bug" and were wrong.
94
+
95
+ ## Expectations
96
+
97
+ Honest limits, from six benchmark rounds against two production codebases (React Native Web; React + Vite + Radix):
98
+
99
+ - **If you already have a Playwright suite, keep using it.** Authoring a spec costs about what recording costs, and a spec is legible where an IR is not. This earns its keep for throwaway repros and agent loops, not as a test framework.
100
+ - **Recording is a human clicking once.** Driving it programmatically means writing Playwright locators, which defeats the point. `repro auto` from a bug description is not built.
101
+ - **A bug with no console error or failed request needs one hand-written line** — `assertion.expectedWhenFixed` — or `--expect-fixed` refuses to answer rather than return a green that checked nothing.
102
+ - **A flow that mutates server state is single-shot.** Use `--setup` to reset, or `{{random:name}}` placeholders to make inputs unique.
103
+ - Top frame only. No iframes. Drag-and-drop and file upload are untested.
104
+
105
+ ## Programmatic
106
+
107
+ ```ts
108
+ import { record, run, list } from 'fast-replay';
109
+
110
+ const result = await run({ name: 'my-bug', expectFixed: true });
111
+ if (!result.passed) console.log(result.failure.semantic, result.failure.artifacts.screenshot);
112
+ ```
113
+
114
+ The CLI and MCP server are both thin wrappers over these.
115
+
116
+ ## Develop
117
+
118
+ ```bash
119
+ npm test # 110 tests, unit + real-browser integration
120
+ npm run stress # records once, replays 20x, fails on a single flake
121
+ npm run demo # examples/demo-app
122
+ ```
123
+
124
+ `src/recorder/agent/` runs inside the browser and is bundled by esbuild into `agent-bundle.generated.ts`. `npm run build:agent` regenerates it; a test fails if it goes stale.
125
+
126
+ MIT.
package/dist/api.d.ts ADDED
@@ -0,0 +1,65 @@
1
+ import type { Page } from 'playwright';
2
+ import { type ReproSummary } from './ir/io.js';
3
+ import { STOP_HOTKEY } from './recorder/launch.js';
4
+ import { type RunOptions, type RunResult } from './replayer/run.js';
5
+ import type { Repro } from './ir/schema.js';
6
+ export interface RecordOptions {
7
+ name: string;
8
+ baseUrl: string;
9
+ startPath?: string;
10
+ viewport?: {
11
+ width: number;
12
+ height: number;
13
+ };
14
+ root?: string;
15
+ /** Seed cookies/localStorage/IndexedDB from an existing Playwright state file. */
16
+ storageStatePath?: string | null;
17
+ /** Use a persistent Chromium profile instead of a fresh context. */
18
+ profileDir?: string | null;
19
+ onReady?: () => void;
20
+ headless?: boolean;
21
+ /**
22
+ * Drive the browser programmatically instead of waiting for a human. The
23
+ * capture pipeline is identical either way — the seam Phase 1's `repro auto`
24
+ * hands to an LLM browser agent.
25
+ */
26
+ drive?: (page: Page) => Promise<void>;
27
+ }
28
+ export interface RecordResult {
29
+ repro: Repro;
30
+ irPath: string;
31
+ stopReason: string;
32
+ }
33
+ /**
34
+ * The driver failed part-way, but the steps captured before it did were written
35
+ * to disk anyway. Carries the path so the caller can inspect or resume.
36
+ */
37
+ export declare class PartialRecordingError extends Error {
38
+ readonly cause: Error;
39
+ readonly irPath: string;
40
+ readonly repro: Repro;
41
+ constructor(cause: Error, irPath: string, repro: Repro);
42
+ }
43
+ /**
44
+ * Drive a real browser, capture the flow, compile it to IR on disk.
45
+ *
46
+ * This — not the CLI — is the product's entry point. `repro record` is a thin
47
+ * wrapper, and the Phase 1 MCP server will wrap this same function so an agent
48
+ * verifies a fix in one call with no per-step round trips.
49
+ */
50
+ export declare function record(options: RecordOptions): Promise<RecordResult>;
51
+ export interface RunReproOptions extends RunOptions {
52
+ name: string;
53
+ }
54
+ /** Replay a recorded repro. Reads and validates the IR, then drives the browser. */
55
+ export declare function run(options: RunReproOptions): Promise<RunResult>;
56
+ /** Every repro in `.repros/`, newest first. Never throws on a corrupt IR. */
57
+ export declare function list(root?: string): Promise<ReproSummary[]>;
58
+ export { createReplayServer, createServer } from './mcp/server.js';
59
+ export { BrowserPool } from './browser.js';
60
+ export { STOP_HOTKEY };
61
+ export { deleteRepro, readRepro, reproPaths } from './ir/io.js';
62
+ export { compile } from './compiler/compile.js';
63
+ export * from './ir/schema.js';
64
+ export type { RunResult, StepTiming, RunFailure } from './replayer/run.js';
65
+ export type { ReproSummary } from './ir/io.js';
package/dist/api.js ADDED
@@ -0,0 +1,78 @@
1
+ import path from 'node:path';
2
+ import { compile } from './compiler/compile.js';
3
+ import { listRepros, readRepro, reproPaths, writeFileAtomic, writeLastResult, writeRepro, } from './ir/io.js';
4
+ import { launchRecording, STOP_HOTKEY } from './recorder/launch.js';
5
+ import { runRepro } from './replayer/run.js';
6
+ /**
7
+ * The driver failed part-way, but the steps captured before it did were written
8
+ * to disk anyway. Carries the path so the caller can inspect or resume.
9
+ */
10
+ export class PartialRecordingError extends Error {
11
+ cause;
12
+ irPath;
13
+ repro;
14
+ constructor(cause, irPath, repro) {
15
+ super(`Recording stopped early after ${repro.steps.length} step(s): ${cause.message}\n` +
16
+ `The partial repro was still written to ${irPath}`);
17
+ this.cause = cause;
18
+ this.irPath = irPath;
19
+ this.repro = repro;
20
+ this.name = 'PartialRecordingError';
21
+ }
22
+ }
23
+ /**
24
+ * Drive a real browser, capture the flow, compile it to IR on disk.
25
+ *
26
+ * This — not the CLI — is the product's entry point. `repro record` is a thin
27
+ * wrapper, and the Phase 1 MCP server will wrap this same function so an agent
28
+ * verifies a fix in one call with no per-step round trips.
29
+ */
30
+ export async function record(options) {
31
+ const root = options.root ?? process.cwd();
32
+ const paths = reproPaths(options.name, root);
33
+ const { trace, storageState, stopReason, driveError } = await launchRecording({
34
+ baseUrl: options.baseUrl,
35
+ startPath: options.startPath,
36
+ viewport: options.viewport,
37
+ storageStatePath: options.storageStatePath ?? null,
38
+ profileDir: options.profileDir ?? null,
39
+ onReady: options.onReady,
40
+ headless: options.headless,
41
+ drive: options.drive,
42
+ });
43
+ await writeFileAtomic(paths.storageState, storageState);
44
+ const repro = compile(trace, {
45
+ name: options.name,
46
+ storageStatePath: path.relative(root, paths.storageState),
47
+ });
48
+ // Written before any error is raised: a driver that failed on step 12 still
49
+ // captured eleven real steps, and throwing them away wastes the whole run.
50
+ await writeRepro(repro, paths);
51
+ if (driveError)
52
+ throw new PartialRecordingError(driveError, paths.ir, repro);
53
+ return { repro, irPath: paths.ir, stopReason };
54
+ }
55
+ /** Replay a recorded repro. Reads and validates the IR, then drives the browser. */
56
+ export async function run(options) {
57
+ const root = options.root ?? process.cwd();
58
+ const repro = await readRepro(options.name, root);
59
+ const result = await runRepro(repro, options);
60
+ await writeLastResult(reproPaths(options.name, root), {
61
+ status: result.passed ? 'pass' : 'fail',
62
+ at: new Date().toISOString(),
63
+ durationMs: result.durationMs,
64
+ ...(result.failure ? { failedStepId: result.failure.stepId } : {}),
65
+ });
66
+ return result;
67
+ }
68
+ /** Every repro in `.repros/`, newest first. Never throws on a corrupt IR. */
69
+ export async function list(root = process.cwd()) {
70
+ return listRepros(root);
71
+ }
72
+ export { createReplayServer, createServer } from './mcp/server.js';
73
+ export { BrowserPool } from './browser.js';
74
+ export { STOP_HOTKEY };
75
+ export { deleteRepro, readRepro, reproPaths } from './ir/io.js';
76
+ export { compile } from './compiler/compile.js';
77
+ export * from './ir/schema.js';
78
+ //# sourceMappingURL=api.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"api.js","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,67 @@
1
+ import { type Browser, type BrowserContext, type Page } from 'playwright';
2
+ /**
3
+ * Opening a browser is shared by recording and replay, and both need the same
4
+ * two ways of getting an authenticated session.
5
+ *
6
+ * Session seeding is the single thing that decides whether this tool works on a
7
+ * real app, because every target worth verifying is behind a login:
8
+ *
9
+ * - `storageStatePath` — a portable JSON snapshot. Isolated and deterministic:
10
+ * every run starts from an identical session. Now includes IndexedDB, without
11
+ * which offline-first apps (Onyx, Dexie, Firebase Auth) cannot be seeded at
12
+ * all — their tokens never touch cookies or localStorage.
13
+ *
14
+ * - `profileDir` — a real Chromium profile directory. Everything persists:
15
+ * IndexedDB, service workers, caches. It also sidesteps non-idempotent signup,
16
+ * because you never sign up twice. The cost is isolation: state accumulates
17
+ * across runs, so the tenth replay does not start where the first one did.
18
+ *
19
+ * Prefer storageState for repeatable verification; reach for a profile when the
20
+ * app's auth cannot be captured any other way, or to skip a slow cold boot.
21
+ */
22
+ export interface OpenBrowserOptions {
23
+ headless: boolean;
24
+ viewport: {
25
+ width: number;
26
+ height: number;
27
+ };
28
+ storageStatePath?: string | null;
29
+ profileDir?: string | null;
30
+ /**
31
+ * Reuse an already-running browser instead of launching one.
32
+ *
33
+ * Every run otherwise pays a browser launch *and* a cold page load, because a
34
+ * newly launched Chromium has an empty V8 code cache. Keeping the process
35
+ * alive across runs leaves that cache warm, which on a large single-page app
36
+ * is worth far more than the launch itself. Each run still gets its own
37
+ * context, so isolation between replays is unchanged.
38
+ */
39
+ browser?: Browser | null;
40
+ }
41
+ export interface OpenedBrowser {
42
+ context: BrowserContext;
43
+ page: Page;
44
+ /** True when running against a persistent profile. */
45
+ persistent: boolean;
46
+ close(): Promise<void>;
47
+ }
48
+ export declare function openBrowser(options: OpenBrowserOptions): Promise<OpenedBrowser>;
49
+ /**
50
+ * Keeps one browser per headless mode alive across runs.
51
+ *
52
+ * Owned by long-lived callers — the MCP server, a stress loop — and closed with
53
+ * `dispose`. A one-shot CLI invocation has nothing to amortise and does not use
54
+ * this.
55
+ */
56
+ export declare class BrowserPool {
57
+ private readonly browsers;
58
+ acquire(headless: boolean): Promise<Browser>;
59
+ dispose(): Promise<void>;
60
+ }
61
+ /**
62
+ * Snapshot the session, including IndexedDB.
63
+ *
64
+ * Playwright omits IndexedDB by default, which silently produces a state file
65
+ * that looks fine and restores nothing for any app keeping its auth there.
66
+ */
67
+ export declare function captureStorageState(context: BrowserContext): Promise<string>;
@@ -0,0 +1,77 @@
1
+ import { chromium } from 'playwright';
2
+ export async function openBrowser(options) {
3
+ const { headless, viewport } = options;
4
+ if (options.profileDir) {
5
+ const context = await chromium.launchPersistentContext(options.profileDir, {
6
+ headless,
7
+ viewport,
8
+ });
9
+ // A persistent context always opens with one page; reuse it so we do not
10
+ // leave a stray about:blank tab behind.
11
+ const page = context.pages()[0] ?? (await context.newPage());
12
+ return {
13
+ context,
14
+ page,
15
+ persistent: true,
16
+ close: async () => {
17
+ await context.close().catch(() => { });
18
+ },
19
+ };
20
+ }
21
+ const borrowed = Boolean(options.browser);
22
+ const browser = options.browser ?? (await chromium.launch({ headless }));
23
+ const context = await browser.newContext({
24
+ viewport,
25
+ ...(options.storageStatePath ? { storageState: options.storageStatePath } : {}),
26
+ });
27
+ const page = await context.newPage();
28
+ return {
29
+ context,
30
+ page,
31
+ persistent: false,
32
+ close: async () => {
33
+ await context.close().catch(() => { });
34
+ // A borrowed browser belongs to the caller and outlives this run.
35
+ if (!borrowed)
36
+ await browser.close().catch(() => { });
37
+ },
38
+ };
39
+ }
40
+ /**
41
+ * Keeps one browser per headless mode alive across runs.
42
+ *
43
+ * Owned by long-lived callers — the MCP server, a stress loop — and closed with
44
+ * `dispose`. A one-shot CLI invocation has nothing to amortise and does not use
45
+ * this.
46
+ */
47
+ export class BrowserPool {
48
+ browsers = new Map();
49
+ async acquire(headless) {
50
+ let existing = this.browsers.get(headless);
51
+ if (existing) {
52
+ const browser = await existing;
53
+ if (browser.isConnected())
54
+ return browser;
55
+ this.browsers.delete(headless);
56
+ existing = undefined;
57
+ }
58
+ const launched = chromium.launch({ headless });
59
+ this.browsers.set(headless, launched);
60
+ return launched;
61
+ }
62
+ async dispose() {
63
+ const all = Array.from(this.browsers.values());
64
+ this.browsers.clear();
65
+ await Promise.all(all.map(async (p) => (await p).close().catch(() => { })));
66
+ }
67
+ }
68
+ /**
69
+ * Snapshot the session, including IndexedDB.
70
+ *
71
+ * Playwright omits IndexedDB by default, which silently produces a state file
72
+ * that looks fine and restores nothing for any app keeping its auth there.
73
+ */
74
+ export async function captureStorageState(context) {
75
+ return JSON.stringify(await context.storageState({ indexedDB: true }));
76
+ }
77
+ //# sourceMappingURL=browser.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"browser.js","sourceRoot":"","sources":["../src/browser.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,12 @@
1
+ export declare const bold: (s: string) => string;
2
+ export declare const dim: (s: string) => string;
3
+ export declare const red: (s: string) => string;
4
+ export declare const green: (s: string) => string;
5
+ export declare const yellow: (s: string) => string;
6
+ export declare const cyan: (s: string) => string;
7
+ export declare function ms(value: number): string;
8
+ /** Compact relative age, e.g. "3m", "2h", "5d". */
9
+ export declare function age(iso: string | null): string;
10
+ export declare function truncate(s: string, max: number): string;
11
+ /** Left-aligned columns sized to their content. */
12
+ export declare function table(rows: string[][], gap?: number): string;
@@ -0,0 +1,55 @@
1
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
2
+ const ESC = '\u001b[';
3
+ const wrap = (code) => (s) => (useColor ? `${ESC}${code}m${s}${ESC}0m` : s);
4
+ export const bold = wrap(1);
5
+ export const dim = wrap(2);
6
+ export const red = wrap(31);
7
+ export const green = wrap(32);
8
+ export const yellow = wrap(33);
9
+ export const cyan = wrap(36);
10
+ export function ms(value) {
11
+ return value >= 1000 ? `${(value / 1000).toFixed(2)}s` : `${Math.round(value)}ms`;
12
+ }
13
+ /** Compact relative age, e.g. "3m", "2h", "5d". */
14
+ export function age(iso) {
15
+ if (!iso)
16
+ return '?';
17
+ const then = Date.parse(iso);
18
+ if (Number.isNaN(then))
19
+ return '?';
20
+ const secs = Math.max(0, (Date.now() - then) / 1000);
21
+ if (secs < 60)
22
+ return `${Math.floor(secs)}s`;
23
+ if (secs < 3600)
24
+ return `${Math.floor(secs / 60)}m`;
25
+ if (secs < 86400)
26
+ return `${Math.floor(secs / 3600)}h`;
27
+ return `${Math.floor(secs / 86400)}d`;
28
+ }
29
+ export function truncate(s, max) {
30
+ return s.length <= max ? s : `${s.slice(0, max - 1)}…`;
31
+ }
32
+ /** Length ignoring ANSI escapes, so colored cells still align. */
33
+ function visibleLength(s) {
34
+ // eslint-disable-next-line no-control-regex
35
+ return s.replace(/\u001b\[\d+m/g, '').length;
36
+ }
37
+ /** Left-aligned columns sized to their content. */
38
+ export function table(rows, gap = 2) {
39
+ if (!rows.length)
40
+ return '';
41
+ const widths = [];
42
+ for (const row of rows) {
43
+ row.forEach((cell, i) => {
44
+ widths[i] = Math.max(widths[i] ?? 0, visibleLength(cell));
45
+ });
46
+ }
47
+ return rows
48
+ .map((row) => row
49
+ .map((cell, i) => i === row.length - 1
50
+ ? cell
51
+ : cell + ' '.repeat((widths[i] ?? 0) - visibleLength(cell) + gap))
52
+ .join(''))
53
+ .join('\n');
54
+ }
55
+ //# sourceMappingURL=format.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"format.js","sourceRoot":"","sources":["../../src/cli/format.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};