@unotest/web 0.5.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/CHANGELOG.md ADDED
@@ -0,0 +1,158 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@unotest/web` are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.5.0] - 2026-05-29
9
+
10
+ ### Added
11
+
12
+ - **Recording mode (exploration sessions).** Eight new MCP tools —
13
+ `explore_start`, `explore_stop`, `explore_state`, `explore_step`,
14
+ `explore_record`, `explore_remove_step`, `generate_dsl_from_exploration`,
15
+ `save_exploration_as_test`. The agent drives the browser through
16
+ `explore_step`, which both executes the action and records it into a
17
+ session log. When done, `save_exploration_as_test` generates a DSL
18
+ scenario and runs it via `run_test`. Session data persists as JSONL
19
+ under `unotest/.explorations/` (override via `EXPLORATIONS_DIR`).
20
+ - **Ref resolve-on-record.** Wire-only `{kind:"ref",ref:"eN"}` locators
21
+ emitted by `get_page_snapshot` are resolved to stable forms
22
+ (`getByTestId` / `getByRole` / `getByLabel` / `getByText` / `locator`)
23
+ at record time. The persisted scenario contains no stale refs; execution
24
+ still uses the original ref-locator for precise targeting.
25
+
26
+ ### Changed (BREAKING)
27
+
28
+ - **14 per-action MCP tools removed.** `click`, `double_click`, `fill`,
29
+ `hover`, `press`, `check`, `uncheck`, `select_option`,
30
+ `scroll_into_view`, `goto`, `reload`, `wait_for`, `wait_for_text`,
31
+ `wait_for_url` are replaced by `explore_step { action, locator, … }`.
32
+ Without an `explorationId`, `explore_step` runs the action ad-hoc with
33
+ the same semantics as the removed tools.
34
+
35
+ ## [0.4.0] - 2026-04-25
36
+
37
+ ### Added
38
+
39
+ - **Debug logging v2 — folder per call (`UNOTEST_DEBUG=1`).** Each MCP
40
+ tool call gets its own directory `unotest/.debug/<session>/<NNN>-<tool>/`
41
+ containing: `call.json` (args, events, result), `before.html` / `after.html`
42
+ (full shadow-pierced DOM), `before.txt` / `after.txt` (outline),
43
+ `diff.txt` (line-level LCS diff of outline), `aria.yaml` (ARIA tree),
44
+ `resolved/<ref>.html` (outerHTML of the resolved element). On failure:
45
+ `page.html` + `screenshot.png`. Levels: `=1` / `=full` all artifacts;
46
+ `=summary` tool + snapshot only, no HTML; unset — no I/O.
47
+ - **`audit_last_run` MCP tool.** Deterministic rule-based audit of the
48
+ last debug session: catches `ref-before-snapshot`, `resolve-loop`,
49
+ missing `section` descriptions, and selector downgrades.
50
+ - **`get_last_mcp_log` filters.** `errors`, `layer:N`, `tool:N`,
51
+ `exploration:<id>` — narrow the JSONL log to relevant events.
52
+ - **Resolver: `data-*` stable-id hints.** `data-id`, `data-guid`,
53
+ `data-key` and similar attributes are detected and used as primary
54
+ CSS selectors (`[data-id="…"]`), making table-row locators stable
55
+ across re-renders.
56
+ - **Resolver: tooltip / label `data-*` hints.** `data-tooltip`,
57
+ `data-shadow-title`, `data-original-title` and similar attributes
58
+ provide display names for icon-only controls.
59
+ - **Resolver: clickable-row detection.** Non-control elements with
60
+ `cursor:pointer` or `onclick` and a stable `data-*` id appear in
61
+ the outline as `[clickable]` entries, collapsing large grids to the
62
+ meaningful rows.
63
+ - **Ancestor-scope disambiguation.** When a best locator candidate is
64
+ ambiguous (multiple matches), the resolver climbs to the nearest
65
+ unique ancestor and emits a single combined CSS selector
66
+ (`<ancestorSel> <childCss>`) rather than failing.
67
+
68
+ ## [0.3.0] - 2026-03-18
69
+
70
+ ### Added
71
+
72
+ - **`UNOTEST_DEBUG` logging.** Set `UNOTEST_DEBUG=1` to write a
73
+ structured JSONL log to `unotest/.debug/mcp-latest.jsonl`. Captures
74
+ tool calls, resolver decisions, and driver events. Readable via the
75
+ `get_last_mcp_log` MCP tool.
76
+ - **`get_last_mcp_log` MCP tool.** Returns the last N lines of the
77
+ debug log. Agents use it to self-diagnose stale refs and resolve
78
+ failures without human intervention.
79
+ - **Shadow DOM full-capture.** `before.html` / `after.html` artifacts
80
+ pierce shadow roots via `getComputedStyle` — plain `outerHTML` misses
81
+ shadow-hosted content.
82
+
83
+ ### Fixed
84
+
85
+ - Outline renderer truncated role names longer than 32 chars; now
86
+ preserves full name.
87
+ - `RefResolver` emitted duplicate `getByText` selectors when two
88
+ adjacent elements had identical visible text; now falls back to
89
+ positional nth-selector.
90
+
91
+ ## [0.2.0] - 2026-01-08
92
+
93
+ ### Added
94
+
95
+ - **Local results viewer (`unotest-web viewer`).** Opens a browser UI
96
+ showing live test results, step logs, and screenshots. Backed by
97
+ [`@unotest/viewer`](https://github.com/unotest/viewer) — installed
98
+ automatically as a dependency.
99
+ - **`open_viewer` MCP tool.** Starts the viewer server in the background
100
+ and returns its URL. Subsequent calls reuse the running instance.
101
+ - **Collection runner (`unotest-web collection <name>`).** Runs every
102
+ scenario listed in `unotest/e2e/_collections/<name>.yaml` under a
103
+ shared parent run-id. Supports `--workers=N` for parallel execution
104
+ and `--bail` to stop on first failure.
105
+ - **`install-chromium` command.** Downloads Playwright's bundled Chromium
106
+ (~150 MB) for environments without a system browser.
107
+ - **`list_runtimes` MCP tool.** Returns all active scenario runtimes in
108
+ the current session (run-id, scenario name, status, step count).
109
+
110
+ ### Fixed
111
+
112
+ - `unotest-web init` failed silently when the target directory existed
113
+ but was missing `unotest/`; now creates missing subdirectories.
114
+ - `run_test` returned a stale run-id when called concurrently with an
115
+ in-progress run; each call now allocates a fresh id atomically.
116
+
117
+ ## [0.1.0] - 2025-10-01
118
+
119
+ Initial release.
120
+
121
+ ### Added
122
+
123
+ - **DSL — JavaScript scenarios on a sandboxed AST engine.** Scenario
124
+ files are `.js` in `unotest/e2e/` using Playwright-vocabulary
125
+ primitives (`goto`, `click`, `fill`, `getByRole`, `getByTestId`,
126
+ `assertText`, and ~54 others). The AST parser (now published as
127
+ [`@unotest/dsl`](https://github.com/unotest/dsl)) supports method
128
+ chains, options-object literals, and raw backtick strings.
129
+ - **MCP server with 37 tools.** Bundled server (`unotest-web mcp`,
130
+ default subcommand): 6 debugger tools (`run_test`, `step`, `resume`,
131
+ `inspect_runtime`, `abort_runtime`, `list_runtimes`), 21 browser
132
+ interaction tools, 4 multi-context tools (`list_pages`, `switch_page`,
133
+ `new_context`, `close_context`), 6 failure-bundle tools, and
134
+ `agent_fix`.
135
+ - **Semantic DOM canvas.** Inspection layer producing a compact,
136
+ agent-readable view of the page (role / name / relevant attributes).
137
+ Used for both live snapshots and failure bundles.
138
+ - **Failure bundle — tier 1 + 2 capture.** On test failure the runner
139
+ writes `failure.json`, `console.json`, `snapshot.json`, and
140
+ `screenshot.png` to `unotest/.runs/<runId>/`. Accessible via
141
+ `list_failures`, `get_failure_trace`, and related MCP tools.
142
+ - **`agent_fix` workflow.** `mcp__unotest-web__agent_fix` builds a
143
+ structured fix-context bundle (last failure, relevant DOM, scenario
144
+ excerpt). The package does not call an LLM or auto-apply patches —
145
+ human review is required.
146
+ - **AST scenario linter (`unotest-web lint`).** Six rules: deep CSS
147
+ selectors, XPath, obfuscated class names, explicit `pause()` calls,
148
+ disambiguate-by-index, and discouraged `evaluate()` patterns. Severity
149
+ is config-driven via `unotest.config.*`.
150
+ - **Project-agnostic sandbox primitives.** `shell(cmd, …args)` (execFile,
151
+ no shell interpolation), `dbQuery` / `dbExec` (lazy peer dependency —
152
+ pg / mysql2 / better-sqlite3 resolved from `sandbox.database` URL),
153
+ `apiCall(method, path, body?, headers?)` (against `sandbox.apiBaseUrl`).
154
+ Consumer-specific seeders live in `unotest/e2e/_helpers/`.
155
+ - **Cross-browser support.** Chromium, Firefox, WebKit via Playwright.
156
+ - **`unotest-web init`.** Bootstraps `unotest.config.mjs`,
157
+ `unotest/e2e/smoke-welcome.js`, `unotest/e2e/_helpers/`,
158
+ `unotest/.env`, MCP config, and Claude Code skill into any project.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ivan Volkov
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,146 @@
1
+ # @unotest/web
2
+
3
+ **AI-native E2E testing for web apps. You don't write the tests — your AI
4
+ agent does, by driving your real app through the MCP server. You review
5
+ and commit.**
6
+
7
+ MCP server + CLI runner + JavaScript DSL + semantic DOM snapshots +
8
+ structured failure bundles.
9
+
10
+ ---
11
+
12
+ ## 1. Setup (once)
13
+
14
+ Works in any project — Node, Django, Rails, Go, anything. No `package.json`
15
+ required; everything runs through `npx`.
16
+
17
+ ```sh
18
+ npx @unotest/web@latest init # writes config, starter scenario, and .mcp.json
19
+ npx @unotest/web install-chromium # skip if you picked system Chrome during init
20
+ ```
21
+
22
+ `init` is idempotent — it never overwrites your edits.
23
+
24
+ ## 2. Connect your agent
25
+
26
+ `init` already wrote `.mcp.json` for you, **pinned to the version that ran
27
+ it** so the MCP server never drifts. Most clients (Claude Code, Cursor, …)
28
+ pick it up automatically. The entry it writes looks like:
29
+
30
+ ```json
31
+ {
32
+ "mcpServers": {
33
+ "unotest-web": { "command": "npx", "args": ["-y", "@unotest/web@0.5.0", "mcp"] }
34
+ }
35
+ }
36
+ ```
37
+
38
+ Re-run `npx @unotest/web@latest init` to bump the pin. That's it — your
39
+ agent now has the tools to explore your app and write tests.
40
+
41
+ ## 3. Create a test — just ask
42
+
43
+ In your AI editor, point it at a running app and describe the flow in
44
+ plain English:
45
+
46
+ > **"Write an e2e test: log in as a demo user, open the first project,
47
+ > and check the dashboard shows today's date."**
48
+
49
+ The agent then, through the MCP server:
50
+
51
+ 1. **Explores your live app** (`explore_step` / `explore_record`) —
52
+ clicks, fills, navigates — and watches the real DOM via semantic
53
+ snapshots.
54
+ 2. **Writes the scenario** to `unotest/e2e/<name>.js`, picking stable
55
+ selectors (`getByTestId` → `getByRole` → … — never brittle CSS).
56
+ 3. **Runs it** (`run_test`) and, if it fails, **debugs itself** — reads
57
+ the paused browser state, the failure bundle, and console, then edits
58
+ and re-runs.
59
+
60
+ You get a clean, reviewable `.js` test. You read it, tweak if you want,
61
+ and commit. No hand-written selectors, no flaky waits.
62
+
63
+ > Give the agent its authoring guide for best results — see
64
+ > [agent-integration.md](https://github.com/unotest/web/blob/main/docs/manuals/agent-integration.md).
65
+
66
+ ## 4. Run your tests
67
+
68
+ ```sh
69
+ npx @unotest/web e2e <name> # one scenario
70
+ npx @unotest/web e2e # see usage / list
71
+ ```
72
+
73
+ Steps are grouped under `//@collapse("...")` blocks. The title says **what**
74
+ the group does in plain English; the steps inside are **how**. This isn't
75
+ decoration — it's the intent your agent reads back to repair a step when a
76
+ selector drifts, and it's how the viewer and block-editor fold a scenario
77
+ into readable sections. Every step lives inside a block:
78
+
79
+ ```js
80
+ function test_login() {
81
+ //@collapse("Log in as demo user")
82
+ goto('https://app.example.com');
83
+ click(getByRole('button', { name: /sign in/i }));
84
+ fill(getByLabel('Email'), 'demo@example.com');
85
+ fill(getByLabel('Password'), 'demo');
86
+ click(getByRole('button', { name: 'Continue' }));
87
+ //@endcollapse
88
+
89
+ //@collapse("Dashboard is shown after login")
90
+ assertText(getByRole('heading'), 'Dashboard');
91
+ //@endcollapse
92
+ }
93
+ ```
94
+
95
+ ## 5. Watch it run — the viewer
96
+
97
+ ```sh
98
+ npx @unotest/web viewer
99
+ ```
100
+
101
+ A local IDE-style UI for your test runs (no cloud, no account):
102
+
103
+ - **Scenario tree** — browse scenarios, collections, helpers, and run history.
104
+ - **Run from the UI** — launch any scenario or collection; results stream
105
+ live over WebSocket as steps execute.
106
+ - **Block view** — each `//@collapse` group rendered as a foldable section
107
+ with per-step status; failures pin an error card to the offending line.
108
+ - **Step debugger** — set breakpoints in the gutter, run paused, and
109
+ inspect the live DOM at each stop.
110
+ - **Inspector** — screenshots and failure artifacts per run.
111
+ - **Integrated terminal** — a docked xterm (toggle with `` ⌃` ``) to drive
112
+ the CLI without leaving the window.
113
+ - **Source ↔ block** — switch between the raw `.js` and the block view.
114
+
115
+ ---
116
+
117
+ ## Writing by hand (optional)
118
+
119
+ Scenarios are plain `.js`. The DSL uses the familiar modern
120
+ browser-automation vocabulary, so it reads the way you already expect:
121
+
122
+ - **Navigation** — `goto`, `reload`, `waitForUrl`
123
+ - **Locators** — `getByRole`, `getByTestId`, `getByLabel`, `getByText`, `getByPlaceholder`, `locator`
124
+ - **Actions** — `click`, `fill`, `press`, `hover`, `check`, `uncheck`, `selectOption`
125
+ - **Assertions** — `assertText`, `assertUrl`, `assertVisible`, `assertChecked`, `expect`
126
+ - **Setup** — `dbQuery`, `dbExec`, `apiCall`, `shell`, `evaluate`
127
+
128
+ Full reference: [`dsl-reference.md`](https://github.com/unotest/web/blob/main/docs/dsl-reference.md).
129
+
130
+ ## Browser support
131
+
132
+ Chromium — use your system Chrome, or a bundled build via
133
+ `npx @unotest/web install-chromium`.
134
+
135
+ ## Ecosystem
136
+
137
+ | Package | Role |
138
+ |---|---|
139
+ | `@unotest/web` | this package — CLI / MCP server / runner |
140
+ | [`@unotest/viewer`](https://github.com/unotest/viewer) | local results browser |
141
+ | [`@unotest/protocol`](https://github.com/unotest/protocol) | shared types |
142
+ | [`@unotest/dsl`](https://github.com/unotest/dsl) | scenario parser + validator engine |
143
+
144
+ ## License
145
+
146
+ MIT — see [`LICENSE`](LICENSE).
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env node
2
+ // CLI dispatcher for @unotest/web.
3
+ //
4
+ // unotest-web → MCP server (default)
5
+ // unotest-web mcp → MCP server (explicit)
6
+ // unotest-web e2e <name> → run scenario unotest/e2e/<name>.js
7
+ // unotest-web init → bootstrap consumer project
8
+ // unotest-web serve-fixture → local break/fix fixture server
9
+ // unotest-web lint → lint scenarios
10
+ // unotest-web prepare-fix --failing → compose fix context for external LLM (D-25)
11
+ //
12
+ // All entries are compiled by tsup to dist/ before publish. This dispatcher
13
+ // resolves the right one and execs it via plain node. NO tsx at runtime —
14
+ // consumers don't install dev deps and consumer scenarios are pure `.js`
15
+ // parsed by the bundled vendor/dsl AST engine (D-27). tsx is only used as
16
+ // a dev fallback when dist/ is absent (fresh clone without `pnpm build`).
17
+
18
+ import { spawn } from "node:child_process";
19
+ import { fileURLToPath, pathToFileURL } from "node:url";
20
+ import { dirname, resolve, join } from "node:path";
21
+ import { createRequire } from "node:module";
22
+ import { existsSync, readFileSync } from "node:fs";
23
+
24
+ const here = dirname(fileURLToPath(import.meta.url));
25
+ const root = resolve(here, "..");
26
+
27
+ // Load <cwd>/unotest/.env into process.env before anything else (MCP server
28
+ // and CLI both reach buildLaunchOpts / config / db drivers via process.env).
29
+ // Existing process.env wins — values from `.mcp.json` env or the user's
30
+ // shell override the file. Format: KEY=VALUE per line, `#` comments,
31
+ // surrounding single/double quotes stripped if balanced.
32
+ function loadUnotestEnv() {
33
+ const path = join(process.cwd(), "unotest", ".env");
34
+ if (!existsSync(path)) return;
35
+ let raw;
36
+ try { raw = readFileSync(path, "utf8"); } catch { return; }
37
+ for (const line of raw.split(/\r?\n/)) {
38
+ const trimmed = line.trim();
39
+ if (trimmed === "" || trimmed.startsWith("#")) continue;
40
+ const eq = trimmed.indexOf("=");
41
+ if (eq <= 0) continue;
42
+ const key = trimmed.slice(0, eq).trim();
43
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
44
+ if (process.env[key] !== undefined) continue;
45
+ let value = trimmed.slice(eq + 1).trim();
46
+ if (value.length >= 2) {
47
+ const first = value[0];
48
+ const last = value[value.length - 1];
49
+ if ((first === '"' || first === "'") && first === last) {
50
+ value = value.slice(1, -1);
51
+ }
52
+ }
53
+ process.env[key] = value;
54
+ }
55
+ }
56
+ loadUnotestEnv();
57
+
58
+ // Resolve tsx CLI binary from @unotest/web's own node_modules. tsx CLI
59
+ // (`tsx/cli` export → dist/cli.mjs) handles both legacy and modern Node
60
+ // loader registration internally — more robust across Node versions than
61
+ // `node --import tsx/esm`, which had issues on some setups dynamically
62
+ // importing .ts via pathToFileURL.
63
+ function resolveTsxCli() {
64
+ const req = createRequire(import.meta.url);
65
+ return req.resolve("tsx/cli");
66
+ }
67
+
68
+ const args = process.argv.slice(2);
69
+ const sub = args[0];
70
+
71
+ // Each route declares its compiled entry under dist/ AND its source entry
72
+ // under src/. In published packages dist/ exists (built by prepublishOnly);
73
+ // in a fresh clone we fall back to running TS sources via tsx so that
74
+ // `unotest-web --help` works without an explicit `pnpm build` first.
75
+ function dispatch(sub) {
76
+ switch (sub) {
77
+ case "mcp":
78
+ return { dist: "dist/mcp/server.js", src: "src/mcp/server.ts", forwardArgs: [] };
79
+ case "e2e":
80
+ return { dist: "dist/runner/cli.js", src: "src/runner/cli.ts", forwardArgs: args };
81
+ case "init":
82
+ return {
83
+ dist: "dist/runner/init.js",
84
+ src: "src/runner/init.ts",
85
+ forwardArgs: args.slice(1),
86
+ };
87
+ case "install-chromium":
88
+ return {
89
+ dist: "dist/runner/install-chromium.js",
90
+ src: "src/runner/install-chromium.ts",
91
+ forwardArgs: [],
92
+ };
93
+ case "serve-fixture":
94
+ return {
95
+ dist: "dist/runner/serve-fixture.js",
96
+ src: "src/runner/serve-fixture.ts",
97
+ forwardArgs: args.slice(1),
98
+ };
99
+ case "lint":
100
+ return { dist: "dist/runner/cli.js", src: "src/runner/cli.ts", forwardArgs: ["lint"] };
101
+ case "viewer":
102
+ return { dist: "dist/runner/cli.js", src: "src/runner/cli.ts", forwardArgs: ["viewer"] };
103
+ case "prepare-fix":
104
+ return {
105
+ dist: "dist/runner/prepare-fix.js",
106
+ src: "src/runner/prepare-fix.ts",
107
+ forwardArgs: args.slice(1),
108
+ };
109
+ case "--help":
110
+ case "-h":
111
+ case "--version":
112
+ return { dist: "dist/runner/cli.js", src: "src/runner/cli.ts", forwardArgs: args };
113
+ default:
114
+ if (sub === undefined) {
115
+ return { dist: "dist/mcp/server.js", src: "src/mcp/server.ts", forwardArgs: [] };
116
+ }
117
+ return { dist: "dist/runner/cli.js", src: "src/runner/cli.ts", forwardArgs: args };
118
+ }
119
+ }
120
+
121
+ const { dist, src, forwardArgs } = dispatch(sub);
122
+ const distPath = resolve(root, dist);
123
+
124
+ // Init is interactive (browser prompt) — running it via spawn through
125
+ // npx loses the prompt suffix mid-flush on some shells (Claude Code's
126
+ // REPL, nvm shim variants). Direct import keeps stdio attached to the
127
+ // real TTY without a spawn hop. We import the compiled module and call
128
+ // `runInit` directly so we can await its promise — the module's own
129
+ // self-invoke `if (invokedDirectly)` block fires only when bin spawns
130
+ // a child, which we explicitly skip here.
131
+ if (sub === "init" && existsSync(distPath)) {
132
+ const initModule = await import(pathToFileURL(distPath).href);
133
+ try {
134
+ const { exitCode } = await initModule.runInit(forwardArgs);
135
+ // exitCode (not process.exit) → pending stdout writes drain on
136
+ // TTY-pipe boundaries before termination.
137
+ process.exitCode = exitCode;
138
+ } catch (e) {
139
+ process.stderr.write(`init: ${e?.message ?? e}\n`);
140
+ process.exitCode = 1;
141
+ }
142
+ } else {
143
+
144
+ // D-27: every subcommand runs the compiled JS under dist/ when present.
145
+ // Consumer scenarios are pure `.js` read by the bundled vendor parser, so
146
+ // no TS loader is needed at runtime. tsx is used only as a dev fallback
147
+ // when dist/ is absent (fresh clone without `pnpm build`).
148
+ let cmd = process.execPath;
149
+ let cmdArgs;
150
+ if (existsSync(distPath)) {
151
+ // Published / built path — run compiled JS directly.
152
+ cmdArgs = [distPath, ...forwardArgs];
153
+ } else {
154
+ const srcPath = resolve(root, src);
155
+ const haveSrc = existsSync(srcPath);
156
+ const haveDist = existsSync(distPath);
157
+ if (!haveSrc && !haveDist) {
158
+ process.stderr.write(
159
+ `[unotest-web] missing entry: neither ${dist} nor ${src} exists.\n` +
160
+ `Did you delete dist/ AND src/? Reinstall the package or run \`pnpm install\`.\n`,
161
+ );
162
+ process.exit(1);
163
+ }
164
+ // tsx-backed dev fallback — only reached when dist/ is missing (fresh
165
+ // clone before `pnpm build`). Published packages always ship dist/.
166
+ let tsxCliPath;
167
+ try {
168
+ tsxCliPath = resolveTsxCli();
169
+ } catch (e) {
170
+ process.stderr.write(
171
+ `[unotest-web] tsx not found: ${e.message ?? e}\n` +
172
+ `tsx ships as a runtime dep — did the install complete? Run \`pnpm install\`.\n`,
173
+ );
174
+ process.exit(1);
175
+ }
176
+ // tsx CLI handles its own loader registration; prefer src in the dev
177
+ // fallback path so devs editing src/ see their changes without rebuild.
178
+ const entryForTsx = haveSrc ? srcPath : distPath;
179
+ cmdArgs = [tsxCliPath, entryForTsx, ...forwardArgs];
180
+ }
181
+
182
+ const child = spawn(cmd, cmdArgs, { stdio: "inherit" });
183
+ // Surface spawn failures explicitly — empty stderr on a silent exit makes
184
+ // debugging miserable for consumers. Reviewers hit this in a prior round.
185
+ child.on("error", (err) => {
186
+ process.stderr.write(`[unotest-web] failed to spawn child: ${err.message ?? err}\n`);
187
+ process.exit(1);
188
+ });
189
+ child.on("exit", (code, signal) => {
190
+ if (code === null && signal) {
191
+ process.stderr.write(`[unotest-web] child terminated by signal: ${signal}\n`);
192
+ process.exit(1);
193
+ }
194
+ process.exit(code ?? 1);
195
+ });
196
+
197
+ } // end else (spawn path — non-init subcommands)