@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/.claude/skills/write-e2e-test.md +551 -0
- package/CHANGELOG.md +158 -0
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/bin/unotest-web.js +197 -0
- package/dist/config/schema.d.ts +356 -0
- package/dist/config/schema.js +1 -0
- package/dist/driver/index.d.ts +74 -0
- package/dist/driver/index.js +1 -0
- package/dist/dsl/index.d.ts +338 -0
- package/dist/dsl/index.js +1 -0
- package/dist/inspection/page-inject.d.ts +395 -0
- package/dist/inspection/page-inject.js +3533 -0
- package/dist/interfaces-iTfjd1zT.d.ts +407 -0
- package/dist/mcp/server.d.ts +2 -0
- package/dist/mcp/server.js +1 -0
- package/dist/runner/cli.d.ts +22 -0
- package/dist/runner/cli.js +1 -0
- package/dist/runner/init.d.ts +6 -0
- package/dist/runner/init.js +1 -0
- package/dist/runner/install-chromium.d.ts +9 -0
- package/dist/runner/install-chromium.js +1 -0
- package/dist/runner/prepare-fix.d.ts +8 -0
- package/dist/runner/prepare-fix.js +1 -0
- package/dist/runner/serve-fixture.d.ts +2 -0
- package/dist/runner/serve-fixture.js +1 -0
- package/dist/runner/web-runner-adapter.d.ts +8 -0
- package/dist/runner/web-runner-adapter.js +1 -0
- package/examples/fixtures/break-fix-app/README.md +45 -0
- package/examples/fixtures/break-fix-app/index.html +101 -0
- package/examples/fixtures/break-fix-app/serve.mjs +95 -0
- package/package.json +115 -0
- package/src/mcp/prompts/agent-test-author.md +208 -0
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)
|