diff --git a/README.md b/README.md index 83869ef..a5f1355 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ Inspect and test: - `routes [file]` - Show routes of your Hono app - `request [file]` - Send request to Hono app using `app.request()` - `batch [file]` - Run multiple requests from JSONL using `app.request()` +- `browse [file]` - Load a page in happy-dom, run its JavaScript, fill and click - `snapshot [file]` - Print the current behavior as batch JSONL lines - `diff` - Show how your uncommitted changes change what the app answers - `benchmark [file]` - Measure the performance of your Hono app @@ -272,6 +273,59 @@ With `--runtime workerd`, one workerd starts and every step runs in it, so a flo One JSON object per line: `method`, `path`, `body`, `headers`, `expect`, `save`. `save` stores a value from the response body by dot path, and later steps use it as `{{id}}` (a whole-variable string keeps the saved type). A redirect shows its `location` in the step, and `save` reads a header with `header:`: `"save":{"next":"header:location"}`, then `"path":"{{next}}"` follows the URL a create redirects to. A string `body` without a `content-type` header goes as a form, or as JSON when it is a JSON object or array. Cookies carry over like in a browser, so a login step signs in the steps after it; a `cookie` header in a step wins. `expect` declares the acceptance criteria: `status` matches exactly, `body` is a deep partial match (declared fields must match, extra response fields are ignored). The output carries the actual `status` and `body`, `pass` per step, and a `summary` — rerun until `failed` is 0. A step without `expect` passes on any 2xx or 3xx and fails on a 4xx or 5xx; to accept a 4xx on purpose, declare it with `expect.status`. +### `browse` + +Load a page in [happy-dom](https://github.com/capricorn86/happy-dom), run its JavaScript, and fill and click like a user. Use it for what an HTTP request cannot show: React hydration, a form that JavaScript handles, client-side navigation. Every request the page makes goes to the app the same way as `request` and `batch` (`app.request()`, `--runtime vite`, or `--runtime workerd`), so no server and no Chrome are needed. + +```bash +hono browse [file] [options] +``` + +**Arguments:** + +- `path` - Page path (e.g. `/todos`) +- `file` - Path to the Hono app file (optional) + +**Options:** + +- `--fill ` - type a value into a field (can be used multiple times) +- `--click ` - click the element that matches the CSS selector (can be used multiple times) +- `-H, --header
` - header for every request the page makes +- `--runtime ` - runtime to execute the app: `node` (default), `workerd`, or `vite` +- `--no-bindings` - Skip loading the local Cloudflare bindings +- `-e, --external ` - Mark package as external (can be used multiple times) + +```bash +npm install -D happy-dom +hono browse / --fill Title=milk --click 'button[type=submit]' +``` + +`--fill` and `--click` run in the order given, and each waits until the page is quiet. `--fill` finds the field by its `name`, label text, placeholder, `aria-label`, or `id` (the keys in `forms`), or by a CSS selector. `confirm()` is accepted. Redirects are followed like a browser, and a request to another origin gets 403: only the app runs here. + +happy-dom is not a dependency of Hono CLI. Install it in the project; without it, `browse` fails with `HAPPY_DOM_NOT_FOUND`. happy-dom has no layout or CSS, so check visual details in a real browser. + +**Output:** + +```json +{ + "ok": true, + "data": { + "url": "/?added=milk", + "title": "Todos", + "text": "Todos milk Add", + "forms": [{ "action": "/todos", "method": "POST", "fields": { "Title": "" } }], + "console": [], + "actions": [ + { "action": "fill Title=milk", "ok": true }, + { "action": "click button[type=submit]", "ok": true } + ], + "note": "happy-dom runs the page JS but has no layout or CSS. Confirm visual details in a real browser." + } +} +``` + +`console` has the page's console messages and its uncaught errors. An action that finds nothing stops the run with `"ok": false` and an `error`. + ### `snapshot` Print the current behavior of the app as batch JSONL lines, to stdout — no file is written. diff --git a/docs/agent-dx-log.md b/docs/agent-dx-log.md index 3574eaa..5072d9d 100644 --- a/docs/agent-dx-log.md +++ b/docs/agent-dx-log.md @@ -3,6 +3,36 @@ How measurements from [honojs/agent-dx](https://github.com/honojs/agent-dx) changed Hono CLI. Newest first. +## 2026-10-16: HTTP checks miss client-side bugs — add `hono browse` + +**Experiment**: bench3 on a Void app (a ramen log with create, edit, +delete), `claude -p`, 5 runs each: no CLI, `agent-init` with the Void +facts inline, and the released `agent-init` + the `hono-void` skill. +A black-box script graded each app. + +**Findings**: + +- `agent-init` cut the median cost from $2.23 / 59 turns to $1.64 / 42 + (p≈0.02). The released version with the skill: $1.80 / 58 (p≈0.06). +- Quality was the same in every condition (4/5 pass), and 1/5 in each + still shipped a delete that lands on `/?delete`. +- Client-side behavior could not be checked over HTTP: no run could see + whether the form clears after a submit. Many runs tried Chrome + DevTools MCP (denied; 5/5 without the CLI), and one installed + Playwright itself. +- A happy-dom spike loaded the Vite dev page, hydrated React, typed, + clicked, and found both bugs in about 5 seconds. + +**Changes**: + +- `hono browse --fill name=value --click `: the page + runs in happy-dom, and every request it makes goes to the app like + `request` and `batch`. happy-dom resolves from the project, so the + CLI does not grow for those who do not use it. +- happy-dom's module loader misreads a regex literal after `!`, `||`, + `?`, `-` or `return`. `browse` rewrites regex literals to + `new RegExp()` until it is fixed upstream. + ## 2026-10-11: Code mode (`hono exec`) loses to batch; the CLI wins on side effects (#166, #167) **Experiment 1: code mode.** Opus without the CLI already does a kind of diff --git a/package.json b/package.json index 0d4dd02..1cdced3 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@types/node": "^24.7.0", + "happy-dom": "^21.0.1", "magic-string": "^0.30.21", "np": "^10.2.0", "pkg-pr-new": "^0.0.62", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 285ce32..a42dee9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,6 +33,9 @@ importers: '@types/node': specifier: ^24.7.0 version: 24.13.3 + happy-dom: + specifier: ^21.0.1 + version: 21.0.1 magic-string: specifier: ^0.30.21 version: 0.30.21 @@ -53,10 +56,10 @@ importers: version: '@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.13.3)(esbuild@0.25.12)(publint@0.3.22)(typescript@5.9.3)(unrun@0.2.39)' vite-plus: specifier: 1.0.0 - version: 1.0.0(@types/node@24.13.3)(@vitest/ui@5.0.1)(esbuild@0.25.12)(publint@0.3.22)(typescript@5.9.3)(unrun@0.2.39) + version: 1.0.0(@types/node@24.13.3)(@vitest/ui@5.0.1)(esbuild@0.25.12)(happy-dom@21.0.1)(publint@0.3.22)(typescript@5.9.3)(unrun@0.2.39) vitest: specifier: 5.0.1 - version: 5.0.1(@types/node@24.13.3)(@vitest/browser-preview@5.0.1)(@vitest/ui@5.0.1(vitest@5.0.1))(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.13.3)(esbuild@0.25.12)(publint@0.3.22)(typescript@5.9.3)(unrun@0.2.39)) + version: 5.0.1(@types/node@24.13.3)(@vitest/browser-preview@5.0.1)(@vitest/ui@5.0.1(vitest@5.0.1))(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.13.3)(esbuild@0.25.12)(publint@0.3.22)(typescript@5.9.3)(unrun@0.2.39))(happy-dom@21.0.1) packages: @@ -986,6 +989,12 @@ packages: '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + + '@types/ws@8.18.2': + resolution: {integrity: sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==} + '@vitest/browser-preview@5.0.1': resolution: {integrity: sha512-NagNnZD6A8ccl+pycLWX2b/e9YJtH5NuDUyiiadQjptMRuZVBAB9r9I3itdKm6exxtcl+rOTv2rWt6vu5uOm1A==} peerDependencies: @@ -1394,6 +1403,10 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + buffer-image-size@0.6.4: + resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} + engines: {node: '>=4.0'} + bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} @@ -1568,6 +1581,10 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -1703,6 +1720,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + happy-dom@21.0.1: + resolution: {integrity: sha512-pRBs/u9fhrncyByOl58DMex0R2uAj6ZPKWIjV5j5yNGZBqLw3P6BTRhlc0DD5b/Ht4/6JYTMuE2LJhUWRC6SGw==} + engines: {node: '>=20.0.0'} + has-ansi@2.0.0: resolution: {integrity: sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg==} engines: {node: '>=0.10.0'} @@ -2770,6 +2791,10 @@ packages: jsdom: optional: true + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + when-exit@2.1.5: resolution: {integrity: sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==} @@ -3508,12 +3533,18 @@ snapshots: '@types/normalize-package-data@2.4.4': {} + '@types/whatwg-mimetype@3.0.2': {} + + '@types/ws@8.18.2': + dependencies: + '@types/node': 24.13.3 + '@vitest/browser-preview@5.0.1(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.13.3)(esbuild@0.25.12)(publint@0.3.22)(typescript@5.9.3)(unrun@0.2.39))(vitest@5.0.1)': dependencies: '@testing-library/dom': 10.4.2 '@testing-library/user-event': 14.6.8(@testing-library/dom@10.4.2) '@vitest/browser': 5.0.1(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.13.3)(esbuild@0.25.12)(publint@0.3.22)(typescript@5.9.3)(unrun@0.2.39))(vitest@5.0.1) - vitest: 5.0.1(@types/node@24.13.3)(@vitest/browser-preview@5.0.1)(@vitest/ui@5.0.1(vitest@5.0.1))(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.13.3)(esbuild@0.25.12)(publint@0.3.22)(typescript@5.9.3)(unrun@0.2.39)) + vitest: 5.0.1(@types/node@24.13.3)(@vitest/browser-preview@5.0.1)(@vitest/ui@5.0.1(vitest@5.0.1))(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.13.3)(esbuild@0.25.12)(publint@0.3.22)(typescript@5.9.3)(unrun@0.2.39))(happy-dom@21.0.1) transitivePeerDependencies: - bufferutil - msw @@ -3530,7 +3561,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.2.0 - vitest: 5.0.1(@types/node@24.13.3)(@vitest/browser-preview@5.0.1)(@vitest/ui@5.0.1(vitest@5.0.1))(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.13.3)(esbuild@0.25.12)(publint@0.3.22)(typescript@5.9.3)(unrun@0.2.39)) + vitest: 5.0.1(@types/node@24.13.3)(@vitest/browser-preview@5.0.1)(@vitest/ui@5.0.1(vitest@5.0.1))(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.13.3)(esbuild@0.25.12)(publint@0.3.22)(typescript@5.9.3)(unrun@0.2.39))(happy-dom@21.0.1) ws: 8.22.0 transitivePeerDependencies: - bufferutil @@ -3568,7 +3599,7 @@ snapshots: pathe: 2.0.3 sirv: 3.0.2 tinyrainbow: 3.2.0 - vitest: 5.0.1(@types/node@24.13.3)(@vitest/browser-preview@5.0.1)(@vitest/ui@5.0.1(vitest@5.0.1))(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.13.3)(esbuild@0.25.12)(publint@0.3.22)(typescript@5.9.3)(unrun@0.2.39)) + vitest: 5.0.1(@types/node@24.13.3)(@vitest/browser-preview@5.0.1)(@vitest/ui@5.0.1(vitest@5.0.1))(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.13.3)(esbuild@0.25.12)(publint@0.3.22)(typescript@5.9.3)(unrun@0.2.39))(happy-dom@21.0.1) '@vitest/utils@5.0.1': dependencies: @@ -3778,6 +3809,10 @@ snapshots: dependencies: fill-range: 7.1.1 + buffer-image-size@0.6.4: + dependencies: + '@types/node': 24.13.3 + bundle-name@4.1.0: dependencies: run-applescript: 7.1.0 @@ -3930,6 +3965,8 @@ snapshots: emoji-regex@8.0.0: {} + entities@7.0.1: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -4083,6 +4120,19 @@ snapshots: graceful-fs@4.2.11: {} + happy-dom@21.0.1: + dependencies: + '@types/node': 24.13.3 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.2 + buffer-image-size: 0.6.4 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.22.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + has-ansi@2.0.0: dependencies: ansi-regex: 2.1.1 @@ -4552,7 +4602,7 @@ snapshots: os-tmpdir@1.0.2: {} - oxfmt@0.70.0(vite-plus@1.0.0(@types/node@24.13.3)(@vitest/ui@5.0.1)(esbuild@0.25.12)(publint@0.3.22)(typescript@5.9.3)(unrun@0.2.39)): + oxfmt@0.70.0(vite-plus@1.0.0(@types/node@24.13.3)(@vitest/ui@5.0.1)(esbuild@0.25.12)(happy-dom@21.0.1)(publint@0.3.22)(typescript@5.9.3)(unrun@0.2.39)): dependencies: tinypool: 2.1.2 optionalDependencies: @@ -4575,7 +4625,7 @@ snapshots: '@oxfmt/binding-win32-arm64-msvc': 0.70.0 '@oxfmt/binding-win32-ia32-msvc': 0.70.0 '@oxfmt/binding-win32-x64-msvc': 0.70.0 - vite-plus: 1.0.0(@types/node@24.13.3)(@vitest/ui@5.0.1)(esbuild@0.25.12)(publint@0.3.22)(typescript@5.9.3)(unrun@0.2.39) + vite-plus: 1.0.0(@types/node@24.13.3)(@vitest/ui@5.0.1)(esbuild@0.25.12)(happy-dom@21.0.1)(publint@0.3.22)(typescript@5.9.3)(unrun@0.2.39) oxlint-tsgolint@7.0.2003: optionalDependencies: @@ -4586,7 +4636,7 @@ snapshots: '@oxlint-tsgolint/win32-arm64': 7.0.2003 '@oxlint-tsgolint/win32-x64': 7.0.2003 - oxlint@1.85.0(oxlint-tsgolint@7.0.2003)(vite-plus@1.0.0(@types/node@24.13.3)(@vitest/ui@5.0.1)(esbuild@0.25.12)(publint@0.3.22)(typescript@5.9.3)(unrun@0.2.39)): + oxlint@1.85.0(oxlint-tsgolint@7.0.2003)(vite-plus@1.0.0(@types/node@24.13.3)(@vitest/ui@5.0.1)(esbuild@0.25.12)(happy-dom@21.0.1)(publint@0.3.22)(typescript@5.9.3)(unrun@0.2.39)): optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.85.0 '@oxlint/binding-android-arm64': 1.85.0 @@ -4608,7 +4658,7 @@ snapshots: '@oxlint/binding-win32-ia32-msvc': 1.85.0 '@oxlint/binding-win32-x64-msvc': 1.85.0 oxlint-tsgolint: 7.0.2003 - vite-plus: 1.0.0(@types/node@24.13.3)(@vitest/ui@5.0.1)(esbuild@0.25.12)(publint@0.3.22)(typescript@5.9.3)(unrun@0.2.39) + vite-plus: 1.0.0(@types/node@24.13.3)(@vitest/ui@5.0.1)(esbuild@0.25.12)(happy-dom@21.0.1)(publint@0.3.22)(typescript@5.9.3)(unrun@0.2.39) p-limit@2.3.0: dependencies: @@ -5064,7 +5114,7 @@ snapshots: validate-npm-package-name@5.0.1: {} - vite-plus@1.0.0(@types/node@24.13.3)(@vitest/ui@5.0.1)(esbuild@0.25.12)(publint@0.3.22)(typescript@5.9.3)(unrun@0.2.39): + vite-plus@1.0.0(@types/node@24.13.3)(@vitest/ui@5.0.1)(esbuild@0.25.12)(happy-dom@21.0.1)(publint@0.3.22)(typescript@5.9.3)(unrun@0.2.39): dependencies: '@oxc-project/types': 0.151.0 '@oxlint/plugins': 1.79.0 @@ -5075,11 +5125,11 @@ snapshots: '@vitest/snapshot': 5.0.1 '@vitest/spy': 5.0.1 '@vitest/utils': 5.0.1 - oxfmt: 0.70.0(vite-plus@1.0.0(@types/node@24.13.3)(@vitest/ui@5.0.1)(esbuild@0.25.12)(publint@0.3.22)(typescript@5.9.3)(unrun@0.2.39)) - oxlint: 1.85.0(oxlint-tsgolint@7.0.2003)(vite-plus@1.0.0(@types/node@24.13.3)(@vitest/ui@5.0.1)(esbuild@0.25.12)(publint@0.3.22)(typescript@5.9.3)(unrun@0.2.39)) + oxfmt: 0.70.0(vite-plus@1.0.0(@types/node@24.13.3)(@vitest/ui@5.0.1)(esbuild@0.25.12)(happy-dom@21.0.1)(publint@0.3.22)(typescript@5.9.3)(unrun@0.2.39)) + oxlint: 1.85.0(oxlint-tsgolint@7.0.2003)(vite-plus@1.0.0(@types/node@24.13.3)(@vitest/ui@5.0.1)(esbuild@0.25.12)(happy-dom@21.0.1)(publint@0.3.22)(typescript@5.9.3)(unrun@0.2.39)) oxlint-tsgolint: 7.0.2003 vite: '@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.13.3)(esbuild@0.25.12)(publint@0.3.22)(typescript@5.9.3)(unrun@0.2.39)' - vitest: 5.0.1(@types/node@24.13.3)(@vitest/browser-preview@5.0.1)(@vitest/ui@5.0.1(vitest@5.0.1))(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.13.3)(esbuild@0.25.12)(publint@0.3.22)(typescript@5.9.3)(unrun@0.2.39)) + vitest: 5.0.1(@types/node@24.13.3)(@vitest/browser-preview@5.0.1)(@vitest/ui@5.0.1(vitest@5.0.1))(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.13.3)(esbuild@0.25.12)(publint@0.3.22)(typescript@5.9.3)(unrun@0.2.39))(happy-dom@21.0.1) optionalDependencies: '@voidzero-dev/vite-plus-darwin-arm64': 1.0.0 '@voidzero-dev/vite-plus-darwin-x64': 1.0.0 @@ -5119,7 +5169,7 @@ snapshots: - utf-8-validate - yaml - vitest@5.0.1(@types/node@24.13.3)(@vitest/browser-preview@5.0.1)(@vitest/ui@5.0.1(vitest@5.0.1))(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.13.3)(esbuild@0.25.12)(publint@0.3.22)(typescript@5.9.3)(unrun@0.2.39)): + vitest@5.0.1(@types/node@24.13.3)(@vitest/browser-preview@5.0.1)(@vitest/ui@5.0.1(vitest@5.0.1))(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.13.3)(esbuild@0.25.12)(publint@0.3.22)(typescript@5.9.3)(unrun@0.2.39))(happy-dom@21.0.1): dependencies: '@types/chai': 5.2.3 '@vitest/mocker': 5.0.1(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.13.3)(esbuild@0.25.12)(publint@0.3.22)(typescript@5.9.3)(unrun@0.2.39)) @@ -5139,9 +5189,12 @@ snapshots: '@types/node': 24.13.3 '@vitest/browser-preview': 5.0.1(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.13.3)(esbuild@0.25.12)(publint@0.3.22)(typescript@5.9.3)(unrun@0.2.39))(vitest@5.0.1) '@vitest/ui': 5.0.1(vitest@5.0.1) + happy-dom: 21.0.1 transitivePeerDependencies: - msw + whatwg-mimetype@3.0.0: {} + when-exit@2.1.5: {} which@2.0.2: diff --git a/src/cli.ts b/src/cli.ts index 9e225b9..691af23 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,6 +5,7 @@ import { Command, CommanderError } from 'commander' import { agentInitCommand } from './commands/agent-init/index.js' import { batchCommand } from './commands/batch/index.js' import { benchmarkCommand } from './commands/benchmark/index.js' +import { browseCommand } from './commands/browse/index.js' import { diffCommand } from './commands/diff/index.js' import { initCommand } from './commands/init/index.js' import { requestCommand } from './commands/request/index.js' @@ -37,6 +38,7 @@ program routesCommand(program) requestCommand(program) batchCommand(program) +browseCommand(program) snapshotCommand(program) diffCommand(program) benchmarkCommand(program) diff --git a/src/commands/browse/browser.test.ts b/src/commands/browse/browser.test.ts new file mode 100644 index 0000000..5fc1227 --- /dev/null +++ b/src/commands/browse/browser.test.ts @@ -0,0 +1,118 @@ +import * as happyDom from 'happy-dom' +import { Hono } from 'hono' +import { describe, expect, it } from 'vitest' +import { browse, rewriteRegexLiterals } from './browser.js' + +const page = (body: string) => + `Test${body}` + +const app = () => { + const app = new Hono() + app.get('/', (c) => + c.html( + page(` +

${c.req.query('added') ?? 'none'}

+ +
+ + + +
+ + `) + ) + ) + app.post('/items', async (c) => { + const { title } = await c.req.parseBody() + return c.redirect(`/?added=${title}`, 303) + }) + app.get('/module', (c) => + c.html(page('

waiting

')) + ) + app.get('/m.js', (c) => { + c.header('content-type', 'text/javascript') + // happy-dom's module loader fails on this regex as is + return c.body( + "const f = (s) => !/^[/\\\\]{2}/.test(s)\ndocument.getElementById('out').textContent = 'module ' + f('a')\nexport { f }" + ) + }) + app.get('/errors', (c) => + c.html( + page(`

start

+ `) + ) + ) + return app +} + +describe('browse', () => { + it('runs the page script and clicks', async () => { + const result = await browse(happyDom, app(), '/', [{ type: 'click', target: '#more' }]) + expect(result.url).toBe('/') + expect(result.title).toBe('Test') + expect(result.text).toContain('clicked') + expect(result.actions).toEqual([{ action: 'click #more', ok: true }]) + }) + + it('fills fields by name, label, or placeholder, submits, and follows the redirect', async () => { + const before = await browse(happyDom, app(), '/', [ + { type: 'fill', target: 'Title', value: 'milk' }, + { type: 'fill', target: 'Note', value: 'cold' }, + ]) + expect(before.forms).toEqual([ + { action: '/items', method: 'POST', fields: { title: 'milk', Note: 'cold' } }, + ]) + + const after = await browse(happyDom, app(), '/', [ + { type: 'fill', target: 'title', value: 'milk' }, + { type: 'click', target: 'button[type=submit]' }, + ]) + expect(after.url).toBe('/?added=milk') + expect(after.text).toContain('milk') + }) + + it('stops at an action that finds nothing', async () => { + const result = await browse(happyDom, app(), '/', [ + { type: 'fill', target: 'nothing', value: 'x' }, + { type: 'click', target: '#more' }, + ]) + expect(result.actions).toEqual([ + { + action: 'fill nothing=x', + ok: false, + error: expect.stringContaining('No field called nothing'), + }, + ]) + }) + + it('loads a module with a regex that happy-dom cannot parse', async () => { + const result = await browse(happyDom, app(), '/module', []) + expect(result.text).toBe('module true') + }) + + it('records page errors and blocks other origins', async () => { + const result = await browse(happyDom, app(), '/errors', []) + expect(result.console).toContainEqual({ type: 'error', text: expect.stringContaining('boom') }) + expect(result.text).toBe('other origin 403') + }) +}) + +describe('rewriteRegexLiterals', () => { + it('turns regex literals into new RegExp()', async () => { + const out = await rewriteRegexLiterals('const f = (s) => !/^[/\\\\]{2}/g.test(s) / 2') + expect(out).toBe('const f = (s) => !(new RegExp("^[/\\\\\\\\]{2}", "g")).test(s) / 2') + }) + + it('keeps code it cannot parse', async () => { + expect(await rewriteRegexLiterals('const =')).toBe('const =') + }) +}) diff --git a/src/commands/browse/browser.ts b/src/commands/browse/browser.ts new file mode 100644 index 0000000..f1f57f2 --- /dev/null +++ b/src/commands/browse/browser.ts @@ -0,0 +1,373 @@ +import { createRequire } from 'node:module' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import type * as HappyDomModule from 'happy-dom' +import type { Browser, BrowserWindow, Document, Element, Response as DomResponse } from 'happy-dom' +import { CliError } from '../../utils/output.js' +import type { RequestTarget } from '../../utils/target.js' + +type HappyDom = typeof HappyDomModule + +export interface BrowseAction { + type: 'fill' | 'click' + /** A field name for fill, a CSS selector for click */ + target: string + value?: string +} + +export interface BrowseResult { + url: string + title: string + text: string + forms: { action: string; method: string; fields: Record }[] + console: { type: string; text: string }[] + actions: { action: string; ok: boolean; error?: string }[] + note: string +} + +const ORIGIN = 'http://localhost' +const TEXT_LIMIT = 2000 +const SETTLE_MS = 300 +const TIMEOUT_MS = 15000 + +/** + * happy-dom is not a dependency of Hono CLI. It resolves from the + * user's project, like vite, so only `browse` pays for it. + */ +export const loadHappyDom = async (): Promise => { + const require = createRequire(join(process.cwd(), 'package.json')) + let resolved: string + try { + resolved = require.resolve('happy-dom') + } catch { + throw new CliError('HAPPY_DOM_NOT_FOUND', 'happy-dom is not installed in this project', { + suggestions: ['Install it: npm install -D happy-dom'], + docs: 'https://github.com/capricorn86/happy-dom', + }) + } + return import(pathToFileURL(resolved).href) +} + +/** + * happy-dom's module loader fails on some regex literals (e.g. a "/" + * in a character class). `new RegExp()` is the same regex and avoids it. + */ +export const rewriteRegexLiterals = async (code: string): Promise => { + // Loaded only here: the parser is large, and no other command needs it + const { parse } = await import('@babel/parser') + let ast: unknown + try { + ast = parse(code, { sourceType: 'module', errorRecovery: false }) + } catch { + return code + } + const found: { start: number; end: number; pattern: string; flags: string }[] = [] + const walk = (node: unknown): void => { + if (Array.isArray(node)) { + node.forEach(walk) + return + } + if (typeof node !== 'object' || node === null) { + return + } + const n = node as Record + if (n.type === 'RegExpLiteral' && typeof n.start === 'number' && typeof n.end === 'number') { + found.push({ + start: n.start, + end: n.end, + pattern: String(n.pattern), + flags: String(n.flags), + }) + return + } + for (const [key, value] of Object.entries(n)) { + if (key !== 'loc' && key !== 'extra' && typeof value === 'object') { + walk(value) + } + } + } + walk(ast) + let out = code + for (const r of found.sort((a, b) => b.start - a.start)) { + out = `${out.slice(0, r.start)}(new RegExp(${JSON.stringify(r.pattern)}, ${JSON.stringify(r.flags)}))${out.slice(r.end)}` + } + return out +} + +/** + * Vite's client opens a WebSocket for HMR, which a one-shot run does not + * need. This stub keeps its exports, so the modules that import it load. + */ +const VITE_CLIENT_STUB = ` +const hot = { data: {}, accept() {}, acceptExports() {}, dispose() {}, prune() {}, invalidate() {}, decline() {}, on() {}, off() {}, send() {} } +export const createHotContext = () => hot +export const injectQuery = (url) => url +export class ErrorOverlay {} +export const updateStyle = (id, css) => { + let style = document.querySelector(\`style[data-vite-dev-id="\${id}"]\`) + if (!style) { + style = document.createElement('style') + style.setAttribute('data-vite-dev-id', id) + document.head.appendChild(style) + } + style.textContent = css +} +export const removeStyle = (id) => document.querySelector(\`style[data-vite-dev-id="\${id}"]\`)?.remove() +` + +type Field = Element & { value: string; type?: string; checked?: boolean } + +const FIELDS = 'input:not([type=hidden]):not([type=submit]):not([type=button]), textarea, select' + +const textOf = (el: Element, skip: string): string => { + const copy = el.cloneNode(true) as Element + copy.querySelectorAll(skip).forEach((child) => child.remove()) + // Keep the words of two elements apart, as the page shows them + copy.querySelectorAll('*').forEach((child) => child.append(' ')) + return (copy.textContent ?? '').replace(/\s+/g, ' ').trim() +} + +/** The names a person or agent may call a field by */ +const namesOf = (el: Element): string[] => { + const label = + el.closest('label') ?? (el.id ? el.ownerDocument.querySelector(`label[for="${el.id}"]`) : null) + return [ + el.getAttribute('name'), + label ? textOf(label, 'input, textarea, select, button') : null, + el.getAttribute('placeholder'), + el.getAttribute('aria-label'), + el.id, + ].filter((name): name is string => !!name) +} + +/** A field by its name, label, placeholder, aria-label, or id, else a CSS selector */ +const findField = (doc: Document, key: string): Field | null => { + const found = [...doc.querySelectorAll(FIELDS)].find((el) => namesOf(el).includes(key)) + if (found) { + return found as Field + } + try { + return doc.querySelector(key) as Field | null + } catch { + return null + } +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +/** + * Load the page in happy-dom, with every request the page makes sent + * to the target (the app, Vite, or workerd), then run the actions in + * order and report what the page shows. + */ +export const browse = async ( + happyDom: HappyDom, + target: RequestTarget, + path: string, + actions: BrowseAction[], + sharedHeaders: Record = {}, + env?: unknown +): Promise => { + const consoleLog: BrowseResult['console'] = [] + const log = + (type: string) => + (...args: unknown[]) => { + consoleLog.push({ type, text: args.map(String).join(' ') }) + } + let inflight = 0 + let lastActivity = Date.now() + + const send = (url: string, method: string, headers: Headers, body?: ArrayBuffer) => + target.request(new Request(url, { method, headers, body, redirect: 'manual' }), undefined, env) + + /** Send a request of the page, and follow redirects like a browser */ + const toTarget = async (request: { + url: string + method: string + redirect: string + headers: Iterable<[string, string]> + arrayBuffer(): Promise + }): Promise<{ response: Response; url: string }> => { + const headers = new Headers(sharedHeaders) + for (const [key, value] of request.headers) { + headers.set(key, value) + } + let method = request.method + let body = method === 'GET' || method === 'HEAD' ? undefined : await request.arrayBuffer() + let url = request.url + let response = await send(url, method, headers, body) + for (let i = 0; i < 10 && request.redirect === 'follow'; i++) { + const location = response.headers.get('location') + if (response.status < 300 || response.status >= 400 || !location) { + break + } + url = new URL(location, url).href + if (new URL(url).origin !== ORIGIN) { + break + } + // 307 and 308 keep the method and body; the others turn into GET + if (response.status !== 307 && response.status !== 308) { + method = 'GET' + body = undefined + headers.delete('content-type') + } + response = await send(url, method, headers, body) + } + return { response, url } + } + + const browser: Browser = new happyDom.Browser({ + settings: { + enableJavaScriptEvaluation: true, + suppressInsecureJavaScriptEnvironmentWarning: true, + navigation: { disableChildPageNavigation: true }, + fetch: { + interceptor: { + beforeAsyncRequest: async ({ request, window }) => { + if (new URL(request.url).origin !== ORIGIN) { + // Only the app runs here: happy-dom is not a sandbox + return new window.Response('', { status: 403 }) + } + if (new URL(request.url).pathname === '/@vite/client') { + return new window.Response(VITE_CLIENT_STUB, { + headers: { 'content-type': 'text/javascript' }, + }) + } + inflight++ + try { + const { response, url } = await toTarget(request) + const type = response.headers.get('content-type') ?? '' + const body = type.includes('javascript') + ? await rewriteRegexLiterals(await response.text()) + : await response.arrayBuffer() + const headers = new window.Headers() + response.headers.forEach((value, key) => headers.append(key, value)) + headers.delete('content-length') + const result = new window.Response( + response.status === 204 || response.status === 304 ? null : body, + { status: response.status, statusText: response.statusText, headers } + ) as DomResponse + // The page reads the final URL, e.g. to show it in the address bar + Object.defineProperties(result, { + url: { value: url }, + redirected: { value: url !== request.url }, + }) + return result + } finally { + inflight-- + lastActivity = Date.now() + } + }, + }, + }, + }, + console: { + ...console, + log: log('log'), + info: log('info'), + warn: log('warn'), + error: log('error'), + debug: () => {}, + } as unknown as Console, + }) + + // An error the page does not catch is a fact about the page, not a crash of the CLI + const onPageError = (error: unknown) => { + consoleLog.push({ + type: 'error', + text: error instanceof Error ? `${error.name}: ${error.message}` : String(error), + }) + } + process.on('unhandledRejection', onPageError) + process.on('uncaughtException', onPageError) + + const page = browser.newPage() + const window = (): BrowserWindow => { + const win = page.mainFrame.window + // No human to answer a dialog: accept it + Object.assign(win, { confirm: () => true, alert: () => {}, prompt: () => null }) + return win + } + // The page is quiet when no request is open for a while + const settle = async () => { + const end = Date.now() + TIMEOUT_MS + while (Date.now() < end) { + await sleep(50) + if (inflight === 0 && Date.now() - lastActivity >= SETTLE_MS) { + return + } + } + } + + const results: BrowseResult['actions'] = [] + try { + await page.goto(`${ORIGIN}${path}`) + window() + await settle() + + for (const action of actions) { + const label = `${action.type} ${action.target}${action.value === undefined ? '' : `=${action.value}`}` + const win = window() + const doc = win.document + if (action.type === 'fill') { + const el = findField(doc, action.target) + if (!el) { + results.push({ + action: label, + ok: false, + error: `No field called ${action.target}. Use a name, label, placeholder, or CSS selector from "forms"`, + }) + break + } + // React tracks the value, so set it like a user does + const proto = Object.getPrototypeOf(el) as object + Object.getOwnPropertyDescriptor(proto, 'value')?.set?.call(el, action.value ?? '') + el.dispatchEvent(new win.Event('input', { bubbles: true })) + el.dispatchEvent(new win.Event('change', { bubbles: true })) + } else { + const el = doc.querySelector(action.target) + if (!el) { + results.push({ action: label, ok: false, error: `Nothing matches ${action.target}` }) + break + } + ;(el as unknown as { click(): void }).click() + } + lastActivity = Date.now() + await settle() + results.push({ action: label, ok: true }) + } + + const win = window() + const doc = win.document + const text = doc.body ? textOf(doc.body, 'script, style, noscript, template') : '' + return { + url: win.location.pathname + win.location.search, + title: doc.title, + text: text.length > TEXT_LIMIT ? `${text.slice(0, TEXT_LIMIT)}…` : text, + forms: [...doc.querySelectorAll('form')].map((form) => { + const fields: Record = {} + for (const el of form.querySelectorAll(FIELDS)) { + const field = el as Field + if ((field.type === 'checkbox' || field.type === 'radio') && !field.checked) { + continue + } + fields[namesOf(el)[0] ?? el.tagName.toLowerCase()] = field.value + } + const action = form.getAttribute('action') ?? '' + return { + // React puts a placeholder here when a function handles the submit + action: action.startsWith('javascript:') ? '' : action, + method: (form.getAttribute('method') ?? 'get').toUpperCase(), + fields, + } + }), + console: consoleLog, + actions: results, + note: 'happy-dom runs the page JS but has no layout or CSS. Confirm visual details in a real browser.', + } + } finally { + await browser.close() + process.off('unhandledRejection', onPageError) + process.off('uncaughtException', onPageError) + } +} diff --git a/src/commands/browse/index.test.ts b/src/commands/browse/index.test.ts new file mode 100644 index 0000000..09ad37c --- /dev/null +++ b/src/commands/browse/index.test.ts @@ -0,0 +1,71 @@ +import type * as NodeModule from 'node:module' +import { Command } from 'commander' +import { Hono } from 'hono' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('./browser.js', async (importOriginal) => ({ + ...(await importOriginal()), + browse: vi.fn(async () => ({ url: '/' })), +})) +vi.mock('node:module', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + // The project has no happy-dom when HONO_TEST_NO_HAPPY_DOM is set + createRequire: (path: string) => ({ + resolve: (id: string) => { + if (process.env.HONO_TEST_NO_HAPPY_DOM) { + throw new Error(`Cannot find module '${id}'`) + } + return original.createRequire(path).resolve(id) + }, + }), + } +}) +vi.mock('../../utils/load-app.js', () => ({ + getBuildIterator: vi.fn(async function* () { + yield new Hono() + }), +})) + +import type * as BrowserModule from './browser.js' +import { browse } from './browser.js' +import { browseCommand } from './index.js' + +describe('browseCommand', () => { + let log: ReturnType + + const run = async (...args: string[]) => { + const program = new Command() + browseCommand(program) + await program.parseAsync(['node', 'test', 'browse', ...args]) + return JSON.parse(log.mock.calls[0][0] as string) + } + + beforeEach(() => { + log = vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + afterEach(() => { + process.exitCode = undefined + vi.clearAllMocks() + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('keeps the order of --fill and --click, and splits at the first = outside []', async () => { + await run('/', 'app.ts', '--click', '#a', '--fill', 'input[name=q]=x=1', '--click', '#b') + expect(vi.mocked(browse).mock.calls[0][3]).toEqual([ + { type: 'click', target: '#a' }, + { type: 'fill', target: 'input[name=q]', value: 'x=1' }, + { type: 'click', target: '#b' }, + ]) + }) + + it('fails with HAPPY_DOM_NOT_FOUND when the project does not have happy-dom', async () => { + vi.stubEnv('HONO_TEST_NO_HAPPY_DOM', '1') + const output = await run('/', 'app.ts') + expect(output.error.code).toBe('HAPPY_DOM_NOT_FOUND') + expect(output.error.suggestions).toEqual(['Install it: npm install -D happy-dom']) + }) +}) diff --git a/src/commands/browse/index.ts b/src/commands/browse/index.ts new file mode 100644 index 0000000..acd65c0 --- /dev/null +++ b/src/commands/browse/index.ts @@ -0,0 +1,114 @@ +import type { Command } from 'commander' +import { maybeLoadBindings } from '../../utils/bindings.js' +import { parseHeaders } from '../../utils/headers.js' +import type { CommandHelp } from '../../utils/help.js' +import { renderCommandHelp } from '../../utils/help.js' +import { getBuildIterator } from '../../utils/load-app.js' +import { CliError, handleErrors, printResult } from '../../utils/output.js' +import { resolveRuntime } from '../../utils/runtime-option.js' +import { startVite } from '../../utils/vite.js' +import { startWorkerd } from '../../utils/workerd.js' +import type { BrowseAction } from './browser.js' +import { browse, loadHappyDom } from './browser.js' + +const help: CommandHelp = { + output: + '{ "url": "/", "title": "Todos", "text": "Todos milk", "forms": [{ "action": "/", "method": "POST", "fields": { "title": "" } }], "console": [], "actions": [{ "action": "fill title=milk", "ok": true }] }', + examples: ['hono browse /', "hono browse / --fill title=milk --click 'button[type=submit]'"], + notes: [ + 'Loads the page in happy-dom and runs its JavaScript (React hydration, client-side forms and navigation). Use it for what an HTTP request cannot show: what the page does in the browser.', + 'Every request the page makes goes to the app, the same way as request and batch. No server, no Chrome.', + '--fill and --click run in the order given. --fill finds the field by its name, label text, placeholder, aria-label, or id (the keys in "forms"), or a CSS selector. After each action it waits until the page is quiet.', + 'The output has the final URL, the visible text, the values of each form, and the console messages, including errors. confirm() is accepted.', + 'happy-dom has no layout or CSS. Confirm visual details in a real browser.', + 'happy-dom is not a dependency of Hono CLI: install it in the project with npm install -D happy-dom.', + ], +} + +interface BrowseOptions { + header?: string[] + external?: string[] + bindings: boolean + runtime?: string +} + +export function browseCommand(program: Command) { + // --fill and --click share one list, so the actions keep their order + const actions: BrowseAction[] = [] + const fill = (value: string) => { + // The first "=" outside [...], so a CSS selector like input[name=a] works + let depth = 0 + const i = [...value].findIndex((ch) => { + depth += ch === '[' ? 1 : ch === ']' ? -1 : 0 + return ch === '=' && depth === 0 + }) + if (i <= 0) { + throw new CliError('INVALID_OPTION', `--fill takes name=value, got ${value}`, { + suggestions: ['Example: --fill title=milk'], + }) + } + actions.push({ type: 'fill', target: value.slice(0, i), value: value.slice(i + 1) }) + return actions + } + const click = (value: string) => { + actions.push({ type: 'click', target: value }) + return actions + } + + program + .command('browse') + .addHelpText('after', renderCommandHelp(help)) + .description('Load a page in happy-dom, run its JavaScript, fill and click') + .argument('', 'Page path (e.g. /todos)') + .argument('[file]', 'Path to the Hono app file') + .option( + '--fill ', + 'type a value into a field (name, label, placeholder, or CSS selector)', + fill + ) + .option('--click ', 'click the element that matches the CSS selector', click) + .option( + '-H, --header
', + 'header for every request the page makes', + (value: string, previous: string[]) => [...previous, value], + [] as string[] + ) + .option('--runtime ', 'runtime to execute the app (node | workerd | vite)') + .option('--no-bindings', 'Skip loading the local Cloudflare bindings') + .option( + '-e, --external ', + 'Mark package as external (can be used multiple times)', + (value: string, previous: string[]) => [...previous, value], + [] as string[] + ) + .action( + handleErrors(async (path: string, file: string | undefined, options: BrowseOptions) => { + if (!path.startsWith('/')) { + throw new CliError('INVALID_OPTION', `The path must start with /, got ${path}`, { + suggestions: [`hono browse /${path}`], + }) + } + const runtime = resolveRuntime(options.runtime, file, options.bindings) + const happyDom = await loadHappyDom() + const headers = parseHeaders(options.header) + + if (runtime !== 'node') { + const target = runtime === 'vite' ? await startVite() : await startWorkerd() + try { + printResult(await browse(happyDom, target, path, actions, headers)) + } finally { + await target.dispose().catch(() => {}) + } + return + } + + const proxy = options.bindings ? await maybeLoadBindings() : undefined + try { + const app = (await getBuildIterator(file, false, options.external || []).next()).value + printResult(await browse(happyDom, app, path, actions, headers, proxy?.env)) + } finally { + await proxy?.dispose() + } + }) + ) +} diff --git a/src/utils/help.ts b/src/utils/help.ts index 90db0a3..3e1414e 100644 --- a/src/utils/help.ts +++ b/src/utils/help.ts @@ -56,6 +56,9 @@ export const agentHelp = `For coding agents: requests or a flow (POST, then GET), send them in one \`hono batch -\` call, not many \`hono request\` calls: it is one start, and in a Vite project each request starts the dev server again. + For what the page does in a browser (client JavaScript, a form React + handles), run \`hono browse --fill name=value --click \`. + It needs happy-dom in the project. After you change existing code, run \`hono diff\`: it lists the routes that answer differently from the last commit, with nothing to capture first.