From b5acfeddfcb27d4c4238e43a7731625f895c05f6 Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Sun, 6 Sep 2026 08:23:34 +0900 Subject: [PATCH] feat: split batch and snapshot into commands --- README.md | 61 ++++++++++--- docs/agent-dx-log.md | 23 +++++ src/cli.ts | 4 + src/commands/agent-context/document.ts | 4 + src/commands/agent-context/index.test.ts | 6 ++ src/commands/{request => batch}/batch.test.ts | 0 src/commands/{request => batch}/batch.ts | 0 src/commands/batch/index.test.ts | 76 +++++++++++++++++ src/commands/batch/index.ts | 85 +++++++++++++++++++ src/commands/benchmark/index.ts | 6 +- src/commands/request/index.test.ts | 54 ++++-------- src/commands/request/index.ts | 82 ++---------------- src/commands/request/positionals.test.ts | 24 ++---- src/commands/request/positionals.ts | 19 +---- src/commands/snapshot/index.ts | 45 ++++++++++ src/commands/snapshot/snapshot.test.ts | 43 ++++++++++ src/commands/snapshot/snapshot.ts | 48 +++++++++++ src/utils/headers.ts | 11 +++ src/utils/load-app.ts | 27 +++--- src/utils/output.test.ts | 2 + src/utils/output.ts | 2 + 21 files changed, 448 insertions(+), 174 deletions(-) rename src/commands/{request => batch}/batch.test.ts (100%) rename src/commands/{request => batch}/batch.ts (100%) create mode 100644 src/commands/batch/index.test.ts create mode 100644 src/commands/batch/index.ts create mode 100644 src/commands/snapshot/index.ts create mode 100644 src/commands/snapshot/snapshot.test.ts create mode 100644 src/commands/snapshot/snapshot.ts create mode 100644 src/utils/headers.ts diff --git a/README.md b/README.md index ad7d160..ef4b8f3 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,12 @@ hono routes # Send request to Hono app hono request / +# Run multiple requests from JSONL +hono batch - + +# Print the current behavior as batch JSONL lines +hono snapshot + # Measure the performance of your Hono app hono benchmark @@ -56,6 +62,8 @@ 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()` +- `snapshot [file]` - Print the current behavior as batch JSONL lines - `benchmark [file]` - Measure the performance of your Hono app Build: @@ -132,7 +140,6 @@ hono request [file] [options] - `--runtime ` - runtime to execute the app: `node` (default), `bun`, `deno`, or `workerd` - `-i, --include` - Include status and headers in the output (with `--plain`) - `-I, --head` - Show only status and headers in the output (with `--plain`) -- `--batch ` - Run multiple requests from JSONL (`-` reads stdin) - `-e, --external ` - Mark package as external (can be used multiple times) **Examples:** @@ -175,19 +182,8 @@ hono request / --runtime deno # Run the app on workerd with your wrangler config: bindings (c.env) are the local ones hono request /api --runtime workerd -# Run many requests in one call. One JSON object per line. -# `save` stores a value from the response body, later steps use it as {{name}}. -hono request --batch - <<'EOF' -{"path":"/users","expect":{"status":200}} -{"method":"POST","path":"/users","body":{"name":"Momo"},"expect":{"status":201,"body":{"name":"Momo"}},"save":{"id":".id"}} -{"path":"/users/{{id}}","expect":{"status":200}} -{"method":"DELETE","path":"/users/{{id}}","expect":{"status":204}} -{"path":"/users/{{id}}","expect":{"status":404}} -EOF ``` -A batch runs in order against one app instance, so in-memory state carries between steps. Each step reports the actual `status` and `body`, and `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 `pass` per step and a `summary` — rerun until `failed` is 0. A shared header from `-H` goes to every step. - `workerd` starts the app with the wrangler config of the project, so pass no file argument. It needs [wrangler](https://developers.cloudflare.com/workers/wrangler/) installed in the project. wrangler is not a dependency of Hono CLI. With `--trace`, the output has `matchedRoutes`. `responded` marks the route that returned the response: @@ -232,6 +228,47 @@ The result is JSON with the shared envelope. A JSON response body is embedded as A binary response body becomes `"body": null` with `"binary": true` — save it with `-o`. Use `--plain` to print the raw body like curl. A 404 result includes a suggestion to run `--trace`. +### `batch` + +Run multiple requests from JSONL in one call, in order, against one app instance — in-memory state carries between steps. + +```bash +hono batch [file] +``` + +**Arguments:** + +- `source` - JSONL file, or `-` to read stdin +- `file` - Path to the Hono app file (optional) + +**Options:** + +- `-H, --header
` - Shared headers for every step +- `-e, --external ` - Mark package as external (can be used multiple times) + +```bash +hono batch - <<'EOF' +{"path":"/users","expect":{"status":200}} +{"method":"POST","path":"/users","body":{"name":"Momo"},"expect":{"status":201,"body":{"name":"Momo"}},"save":{"id":".id"}} +{"path":"/users/{{id}}","expect":{"status":200}} +{"method":"DELETE","path":"/users/{{id}}","expect":{"status":204}} +EOF +``` + +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). `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. + +### `snapshot` + +Print the current behavior of the app as batch JSONL lines, to stdout — no file is written. + +```bash +hono snapshot [file] +``` + +Paramless GET routes are executed and their actual response becomes the `expect`. Param and non-GET routes are printed without one, to fill in. One probe line records the current response for a path that matches no route. Capture before a refactor, then rerun the lines with `hono batch` until `failed` is 0. + +Unlike `routes`, this command sends real requests to the app — middleware runs. `routes` never sends a request. + ### `benchmark` Measure the performance of your Hono app. It is a micro benchmark of routing and handlers: `app.request()` is called directly, with no HTTP stack and no network. Each run happens in a fresh process, so results are comparable. diff --git a/docs/agent-dx-log.md b/docs/agent-dx-log.md index 5f6c017..5120690 100644 --- a/docs/agent-dx-log.md +++ b/docs/agent-dx-log.md @@ -3,6 +3,29 @@ How measurements from [honojs/agent-dx](https://github.com/honojs/agent-dx) changed Hono CLI. Newest first. +## 2026-09-06: The spec travels in the conversation, not in a file + +**Experiment**: the `expect` re-run (`next.4`): a ready-made +`checks.jsonl` scored 3/3 at a 65k median — the top of every +condition — while agents transcribing the spec themselves lost runs +to interpretation (2/3, 110k). + +**Findings**: what won is an executable spec that exists before the +implementation, is reviewable by a human, and reruns until green. +The file was the harness's delivery detail: ten JSONL lines travel +fine in the request itself and run as a heredoc — no artifact to +clean up. + +**Changes**: two new commands, split out of `request` (both rejected +almost every single-request option — the sign of separate commands +under one flag). `hono batch [file]` runs the JSONL lines; +`hono snapshot [file]` prints the current behavior as batch JSONL +lines, to stdout: paramless GET routes run and their actual response +becomes the `expect`; param and non-GET routes print without one; a +probe line records the current not-found behavior as a fact. The +taxonomy: `routes` never sends a request; `request`, `batch`, and +`snapshot` exist to send them. + ## 2026-09-05: `expect` returns — measurement beats our reasoning **Experiment**: an auto-mode task (build a shop API against an diff --git a/src/cli.ts b/src/cli.ts index d656602..df58e49 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,10 +3,12 @@ import { readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { agentContextCommand } from './commands/agent-context/index.js' +import { batchCommand } from './commands/batch/index.js' import { benchmarkCommand } from './commands/benchmark/index.js' import { optimizeCommand } from './commands/optimize/index.js' import { requestCommand } from './commands/request/index.js' import { routesCommand } from './commands/routes/index.js' +import { snapshotCommand } from './commands/snapshot/index.js' import { ssgCommand } from './commands/ssg/index.js' import { formatArgumentsError } from './utils/output.js' @@ -30,6 +32,8 @@ program agentContextCommand(program) routesCommand(program) requestCommand(program) +batchCommand(program) +snapshotCommand(program) benchmarkCommand(program) optimizeCommand(program) ssgCommand(program) diff --git a/src/commands/agent-context/document.ts b/src/commands/agent-context/document.ts index 42832af..2c15eac 100644 --- a/src/commands/agent-context/document.ts +++ b/src/commands/agent-context/document.ts @@ -1,10 +1,12 @@ import type { Command } from 'commander' import type { CommandAgentContext } from '../../utils/agent-context.js' import { bullets, codeBlock, section, steps } from '../../utils/markdown.js' +import { agentContext as batchContext } from '../batch/index.js' import { agentContext as benchmarkContext } from '../benchmark/index.js' import { agentContext as optimizeContext } from '../optimize/index.js' import { agentContext as requestContext } from '../request/index.js' import { agentContext as routesContext } from '../routes/index.js' +import { agentContext as snapshotContext } from '../snapshot/index.js' import { agentContext as ssgContext } from '../ssg/index.js' const contexts: Record = { @@ -13,6 +15,8 @@ const contexts: Record = { optimize: optimizeContext, ssg: ssgContext, benchmark: benchmarkContext, + batch: batchContext, + snapshot: snapshotContext, } const commandDoc = (command: Command, context?: CommandAgentContext): string => { diff --git a/src/commands/agent-context/index.test.ts b/src/commands/agent-context/index.test.ts index 50cf887..e4a567e 100644 --- a/src/commands/agent-context/index.test.ts +++ b/src/commands/agent-context/index.test.ts @@ -1,8 +1,10 @@ import { Command } from 'commander' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { batchCommand } from '../batch/index.js' import { optimizeCommand } from '../optimize/index.js' import { requestCommand } from '../request/index.js' import { routesCommand } from '../routes/index.js' +import { snapshotCommand } from '../snapshot/index.js' import { agentContextCommand } from './index.js' describe('agentContextCommand', () => { @@ -15,6 +17,8 @@ describe('agentContextCommand', () => { optimizeCommand(program) requestCommand(program) routesCommand(program) + batchCommand(program) + snapshotCommand(program) agentContextCommand(program) consoleLogSpy = spyOnLog() }) @@ -48,6 +52,8 @@ describe('agentContextCommand', () => { expect(output).toContain('### hono optimize [entry]') expect(output).toContain('### hono request [path] [file]') expect(output).toContain('### hono routes [file]') + expect(output).toContain('### hono batch [file]') + expect(output).toContain('### hono snapshot [file]') expect(output).not.toContain('### hono agent-context') }) diff --git a/src/commands/request/batch.test.ts b/src/commands/batch/batch.test.ts similarity index 100% rename from src/commands/request/batch.test.ts rename to src/commands/batch/batch.test.ts diff --git a/src/commands/request/batch.ts b/src/commands/batch/batch.ts similarity index 100% rename from src/commands/request/batch.ts rename to src/commands/batch/batch.ts diff --git a/src/commands/batch/index.test.ts b/src/commands/batch/index.test.ts new file mode 100644 index 0000000..ab09a36 --- /dev/null +++ b/src/commands/batch/index.test.ts @@ -0,0 +1,76 @@ +import { Command } from 'commander' +import { Hono } from 'hono' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +vi.mock('node:fs', () => ({ + existsSync: vi.fn(), + realpathSync: vi.fn(), + readFileSync: vi.fn(), +})) + +vi.mock('node:path', () => ({ + resolve: vi.fn(), +})) + +vi.mock('../../utils/build.js', () => ({ + buildAndImportApp: vi.fn(), +})) + +import { batchCommand } from './index.js' + +describe('batchCommand', () => { + let program: Command + let consoleLogSpy: ReturnType + + async function* iteratorOf(app: Hono): AsyncGenerator { + yield app + } + + beforeEach(async () => { + program = new Command() + batchCommand(program) + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const fs = await import('node:fs') + const path = await import('node:path') + const build = await import('../../utils/build.js') + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.realpathSync).mockReturnValue('test-app.js') + vi.mocked(path.resolve).mockImplementation((cwd: string, p: string) => `${cwd}/${p}`) + const app = new Hono() + app.get('/data', (c) => c.json({ ok: 1 })) + vi.mocked(build.buildAndImportApp).mockReturnValue(iteratorOf(app)) + vi.mocked(fs.readFileSync).mockReturnValue('{"path":"/data","expect":{"status":200}}') + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.clearAllMocks() + }) + + it('should run the steps from a JSONL file and print the envelope', async () => { + await program.parseAsync(['node', 'test', 'batch', 'steps.jsonl', 'test-app.js']) + expect(JSON.parse(consoleLogSpy.mock.calls[0][0] as string)).toEqual({ + ok: true, + data: { + steps: [ + { + method: 'GET', + path: '/data', + status: 200, + body: { ok: 1 }, + pass: true, + expect: { status: 200 }, + }, + ], + summary: { total: 1, passed: 1, failed: 0 }, + }, + }) + }) + + it('should reject the app and the batch both from stdin', async () => { + await program.parseAsync(['node', 'test', 'batch', '-', '-']) + const output = JSON.parse(consoleLogSpy.mock.calls[0][0] as string) + expect(output.ok).toBe(false) + expect(output.error.code).toBe('INVALID_OPTION') + }) +}) diff --git a/src/commands/batch/index.ts b/src/commands/batch/index.ts new file mode 100644 index 0000000..4245db6 --- /dev/null +++ b/src/commands/batch/index.ts @@ -0,0 +1,85 @@ +import type { Command } from 'commander' +import { existsSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import type { CommandAgentContext } from '../../utils/agent-context.js' +import { parseHeaders } from '../../utils/headers.js' +import { getBuildIterator, readStdin } from '../../utils/load-app.js' +import { CliError, handleErrors, printResult } from '../../utils/output.js' +import { parseBatch, runBatch } from './batch.js' + +export const agentContext: CommandAgentContext = { + output: + '{ "steps": [{ "method": "GET", "path": "/users", "status": 200, "body": [], "pass": true, "expect": { "status": 200 } }], "summary": { "total": 1, "passed": 1, "failed": 0 } }', + errors: ['BATCH_INVALID', 'BATCH_NOT_FOUND', 'ENTRY_NOT_FOUND', 'BUILD_FAILED', 'INVALID_APP'], + examples: [ + `hono batch - <<'EOF' +{"path":"/users","expect":{"status":200}} +{"method":"POST","path":"/users","body":{"name":"Momo"},"expect":{"status":201,"body":{"name":"Momo"}},"save":{"id":".id"}} +{"path":"/users/{{id}}","expect":{"status":200}} +EOF`, + ], + notes: [ + 'Runs many requests in one call, in order, against one app instance — in-memory state carries between steps. One JSON object per line: {"method","path","body","headers","expect","save"}.', + '"save" stores a value from the response body by dot path (e.g. {"id":".id"}), and later steps use it as {{id}}. A whole-variable string like "{{id}}" keeps the saved type.', + 'Declare the acceptance criteria in "expect": {"status":201} and/or {"body":{...}} (a deep partial match — declared fields must match, extra response fields are ignored). Turn the spec into batch lines and rerun until "failed" is 0 — comparing a spec table by eye misses lines.', + 'A shared header from -H goes to every step. Prefer a heredoc over writing a file: the lines live in your context.', + 'hono snapshot prints the current behavior of an app in this format — capture before a refactor, rerun after.', + ], +} + +interface BatchOptions { + header?: string[] + external?: string[] +} + +export function batchCommand(program: Command) { + program + .command('batch') + .description('Run multiple requests from JSONL using app.request()') + .argument('', 'JSONL file (- reads stdin)') + .argument('[file]', 'Path to the Hono app file') + .option( + '-H, --header
', + 'Shared headers for every step', + (value: string, previous: string[]) => { + return previous ? [...previous, value] : [value] + }, + [] as string[] + ) + .option( + '-e, --external ', + 'Mark package as external (can be used multiple times)', + (value: string, previous: string[]) => { + return previous ? [...previous, value] : [value] + }, + [] as string[] + ) + .action( + handleErrors(async (source: string, file: string | undefined, options: BatchOptions) => { + if (source === '-' && file === '-') { + throw new CliError( + 'INVALID_OPTION', + 'Cannot read both the app and the batch from stdin', + { + suggestions: ['Pass the app as a file, or the batch as a file'], + } + ) + } + const input = source === '-' ? await readStdin() : readBatchFile(source) + const steps = parseBatch(input) + for await (const app of getBuildIterator(file, false, options.external || [])) { + printResult(await runBatch(app, steps, parseHeaders(options.header))) + } + }) + ) +} + +const readBatchFile = (source: string): string => { + const filepath = resolve(process.cwd(), source) + if (!existsSync(filepath)) { + throw new CliError('BATCH_NOT_FOUND', `Batch file ${source} does not exist`, { + suggestions: ['Pass a JSONL file, or - to read stdin'], + }) + } + return readFileSync(filepath, 'utf-8') +} diff --git a/src/commands/benchmark/index.ts b/src/commands/benchmark/index.ts index 560a97f..bdd8a1c 100644 --- a/src/commands/benchmark/index.ts +++ b/src/commands/benchmark/index.ts @@ -109,7 +109,7 @@ export function benchmarkCommand(program: Command) { } const external = options.external || [] - const entry = resolveEntry(file) + const entry = await resolveEntry(file) const targets = await collectTargets(file, entry, options, method, external) const sources: HonoSource[] = [] @@ -145,7 +145,7 @@ export function benchmarkCommand(program: Command) { const collectTargets = async ( file: string | undefined, - entry: ReturnType, + entry: Awaited>, options: BenchmarkOptions, method: string, external: string[] @@ -158,7 +158,7 @@ const collectTargets = async ( headers[key.trim()] = value.trim() } } - const body = resolveData(options.data) + const body = await resolveData(options.data) return options.path.map((path) => ({ method, path, diff --git a/src/commands/request/index.test.ts b/src/commands/request/index.test.ts index 112df5a..c502f38 100644 --- a/src/commands/request/index.test.ts +++ b/src/commands/request/index.test.ts @@ -1,6 +1,7 @@ import { Command } from 'commander' import { Hono } from 'hono' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { Readable } from 'node:stream' import type * as RuntimeModule from './runtime.js' // Mock dependencies @@ -53,6 +54,15 @@ describe('requestCommand', () => { let mockModules: Awaited> let mockBuildAndImportApp: Awaited> + const stubStdin = (content: string) => { + const original = Object.getOwnPropertyDescriptor(process, 'stdin')! + const fake = Readable.from([Buffer.from(content)]) + Object.defineProperty(process, 'stdin', { value: fake, configurable: true }) + return () => { + Object.defineProperty(process, 'stdin', original) + } + } + async function* createBuildIterator(app: Hono): AsyncGenerator { yield app } @@ -114,39 +124,6 @@ describe('requestCommand', () => { expect(output.error.suggestions).toEqual(['hono request /data -X GET']) }) - it('should run a batch from a JSONL file', async () => { - const mockApp = new Hono() - mockApp.get('/data', (c) => c.json({ ok: 1 })) - setupBasicMocks('test-app.js', mockApp) - mockModules.readFileSync.mockReturnValue('{"path":"/data","expect":{"status":200}}') - await program.parseAsync(['node', 'test', 'request', '--batch', 'steps.jsonl', 'test-app.js']) - expect(JSON.parse(consoleLogSpy.mock.calls[0][0])).toEqual({ - ok: true, - data: { - steps: [ - { - method: 'GET', - path: '/data', - status: 200, - body: { ok: 1 }, - pass: true, - expect: { status: 200 }, - }, - ], - summary: { total: 1, passed: 1, failed: 0 }, - }, - }) - }) - - it('should error when --batch is combined with a per-request option', async () => { - const mockApp = new Hono() - setupBasicMocks('test-app.js', mockApp) - await program.parseAsync(['node', 'test', 'request', '--batch', '-', '-X', 'POST']) - const output = JSON.parse(consoleLogSpy.mock.calls[0][0]) - expect(output.ok).toBe(false) - expect(output.error.code).toBe('INVALID_OPTION') - }) - it('should output a text body as a string in the envelope', async () => { const mockApp = new Hono() const text = 'Hello, World!' @@ -978,7 +955,7 @@ describe('requestCommand', () => { const mockApp = new Hono() mockApp.post('/echo', async (c) => c.json({ received: await c.req.text() })) setupBasicMocks('test-app.js', mockApp) - mockModules.readFileSync.mockReturnValue('from stdin') + const restore = stubStdin('from stdin') await program.parseAsync([ 'node', @@ -991,8 +968,8 @@ describe('requestCommand', () => { '@-', 'test-app.js', ]) + restore() - expect(mockModules.readFileSync).toHaveBeenCalledWith(0, 'utf-8') const parsed = JSON.parse(consoleLogSpy.mock.calls[0][0]) expect(parsed.data.body).toEqual({ received: 'from stdin' }) }) @@ -1000,12 +977,12 @@ describe('requestCommand', () => { it('should read the app code from stdin with -', async () => { const mockApp = new Hono() mockApp.get('/', (c) => c.text('from code')) - mockModules.readFileSync.mockReturnValue('export default app') + const restore = stubStdin('export default app') mockBuildAndImportApp.mockReturnValue(createBuildIterator(mockApp)) await program.parseAsync(['node', 'test', 'request', '/', '-']) + restore() - expect(mockModules.readFileSync).toHaveBeenCalledWith(0, 'utf-8') expect(mockBuildAndImportApp).toHaveBeenCalledWith( { code: 'export default app' }, { external: ['@hono/node-server'] } @@ -1017,10 +994,11 @@ describe('requestCommand', () => { it('should wrap stdin code without a default export', async () => { const mockApp = new Hono() mockApp.get('/', (c) => c.text('wrapped')) - mockModules.readFileSync.mockReturnValue('app.get("/", (c) => c.text("wrapped"))') + const restore = stubStdin('app.get("/", (c) => c.text("wrapped"))') mockBuildAndImportApp.mockReturnValue(createBuildIterator(mockApp)) await program.parseAsync(['node', 'test', 'request', '/', '-']) + restore() expect(mockBuildAndImportApp).toHaveBeenCalledWith( { diff --git a/src/commands/request/index.ts b/src/commands/request/index.ts index 265f558..5837322 100644 --- a/src/commands/request/index.ts +++ b/src/commands/request/index.ts @@ -1,12 +1,10 @@ import type { Command } from 'commander' import type { Hono } from 'hono' -import { existsSync, readFileSync } from 'node:fs' -import { resolve } from 'node:path' import type { CommandAgentContext } from '../../utils/agent-context.js' import { getFilenameFromPath, saveFile } from '../../utils/file.js' -import { getBuildIterator, readStdin, resolveData, resolveEntry } from '../../utils/load-app.js' +import { parseHeaders } from '../../utils/headers.js' +import { getBuildIterator, resolveData, resolveEntry } from '../../utils/load-app.js' import { CliError, handleErrors, printResult } from '../../utils/output.js' -import { parseBatch, runBatch } from './batch.js' import { resolvePositionals } from './positionals.js' import type { Runtime } from './runtime.js' import { RUNTIMES, runInRuntime } from './runtime.js' @@ -24,8 +22,6 @@ export const agentContext: CommandAgentContext = { 'RUNTIME_FAILED', 'WRANGLER_NOT_FOUND', 'WRANGLER_CONFIG_NOT_FOUND', - 'BATCH_INVALID', - 'BATCH_NOT_FOUND', ], examples: [ 'hono request /api/users', @@ -35,11 +31,6 @@ export const agentContext: CommandAgentContext = { 'hono request / --runtime bun', 'hono request /api --runtime workerd', `echo 'app.get("/hello", (c) => c.json({ ok: true }))' | hono request /hello -`, - `hono request --batch - <<'EOF' -{"path":"/users","expect":{"status":200}} -{"method":"POST","path":"/users","body":{"name":"Momo"},"expect":{"status":201,"body":{"name":"Momo"}},"save":{"id":".id"}} -{"path":"/users/{{id}}","expect":{"status":200}} -EOF`, ], notes: [ 'No server needed. The request goes directly to app.request().', @@ -48,8 +39,7 @@ EOF`, '--runtime runs the app on bun, deno, or workerd instead of Node.js. bun and deno must be installed. workerd starts the app with the wrangler config of the project, so the local bindings (c.env) are real — it needs wrangler installed and no file argument.', '--trace adds matchedRoutes to the output: which middleware and handler matched, and which one responded. Use it to debug an unexpected response. A 404 result includes a suggestion to run it.', 'A JSON response body is embedded as an object. A binary body becomes null with "binary": true — save it with -o.', - '--batch runs many requests in one call, in order, against one app instance — in-memory state carries between steps. One JSON object per line: {"method","path","body","headers","expect","save"}. "save" stores a value from the response body by dot path (e.g. {"id":".id"}), and later steps use it as {{id}}. Prefer --batch over writing a test script: no file to clean up.', - 'Declare the acceptance criteria in "expect": {"status":201} and/or {"body":{...}} (a deep partial match — declared fields must match, extra response fields are ignored). The CLI checks every line for you and reports "pass" per step and a summary — turn the spec into batch lines and rerun until "failed" is 0. Comparing a spec table by eye misses lines.', + 'For several requests, or a flow that keeps state, use hono batch. To capture the current behavior of the app, use hono snapshot.', ], } @@ -66,7 +56,6 @@ interface RequestOptions { include: boolean head: boolean external?: string[] - batch?: string } export function requestCommand(program: Command) { @@ -95,7 +84,6 @@ export function requestCommand(program: Command) { 'runtime to execute the app (node | bun | deno | workerd)', 'node' ) - .option('--batch ', 'Run multiple requests from JSONL (- reads stdin)') .option('-i, --include', 'Include protocol and headers in the output (with --plain)', false) .option('-I, --head', 'Show only protocol and headers in the output (with --plain)', false) .option( @@ -113,7 +101,7 @@ export function requestCommand(program: Command) { fileArg: string | undefined, options: RequestOptions ) => { - const { path = '/', file } = resolvePositionals(pathArg, fileArg, Boolean(options.batch)) + const { path = '/', file } = resolvePositionals(pathArg, fileArg) const doSaveFile = options.output || options.remoteName const watch = options.watch @@ -133,43 +121,6 @@ export function requestCommand(program: Command) { } ) } - if (options.batch) { - if (runtime !== 'node') { - throw new CliError('INVALID_OPTION', 'Cannot use --batch with --runtime yet', { - suggestions: ['Drop --runtime. The batch runs on Node.js for now'], - }) - } - const perRequest = - options.trace || - options.watch || - options.plain || - options.data !== undefined || - options.output !== undefined || - options.remoteName || - options.include || - options.head || - options.method !== 'GET' - if (perRequest) { - throw new CliError('INVALID_OPTION', 'Cannot use --batch with per-request options', { - suggestions: ['Put method, path, and body in the batch lines'], - }) - } - if (file === '-' && options.batch === '-') { - throw new CliError( - 'INVALID_OPTION', - 'Cannot read both the app and the batch from stdin', - { - suggestions: ['Pass the app as a file, or the batch with --batch '], - } - ) - } - const source = options.batch === '-' ? await readStdin() : readBatchFile(options.batch) - const steps = parseBatch(source) - for await (const app of getBuildIterator(file, false, external)) { - printResult(await runBatch(app, steps, parseHeaders(options.header))) - } - return - } if (options.trace && options.plain) { throw new CliError('INVALID_OPTION', 'Cannot use --trace with --plain', { @@ -185,7 +136,7 @@ export function requestCommand(program: Command) { } ) } - options.data = resolveData(options.data) + options.data = await resolveData(options.data) if (runtime === 'workerd') { if (file !== undefined) { @@ -210,7 +161,7 @@ export function requestCommand(program: Command) { } if (runtime !== 'node') { - const runnerResponse = await runInRuntime(runtime, resolveEntry(file), external, { + const runnerResponse = await runInRuntime(runtime, await resolveEntry(file), external, { path, method: options.method || 'GET', headers: parseHeaders(options.header), @@ -331,27 +282,6 @@ const handleSaveOutput = async ( } } -const readBatchFile = (source: string): string => { - const filepath = resolve(process.cwd(), source) - if (!existsSync(filepath)) { - throw new CliError('BATCH_NOT_FOUND', `Batch file ${source} does not exist`, { - suggestions: ['Pass a JSONL file, or - to read stdin'], - }) - } - return readFileSync(filepath, 'utf-8') -} - -const parseHeaders = (header: string[] | undefined): Record => { - const headers: Record = {} - for (const entry of header ?? []) { - const [key, value] = entry.split(':', 2) - if (key && value) { - headers[key.trim()] = value.trim() - } - } - return headers -} - export async function executeRequest( app: Hono, requestPath: string, diff --git a/src/commands/request/positionals.test.ts b/src/commands/request/positionals.test.ts index ac2401e..407254c 100644 --- a/src/commands/request/positionals.test.ts +++ b/src/commands/request/positionals.test.ts @@ -17,52 +17,42 @@ const expectSuggestions = (fn: () => unknown): string[] => { describe('resolvePositionals', () => { it('takes the path first and the file second', () => { - expect(resolvePositionals('/api/users', 'src/app.ts', false)).toEqual({ + expect(resolvePositionals('/api/users', 'src/app.ts')).toEqual({ path: '/api/users', file: 'src/app.ts', }) }) it('takes the path alone', () => { - expect(resolvePositionals('/', undefined, false)).toEqual({ path: '/', file: undefined }) + expect(resolvePositionals('/', undefined)).toEqual({ path: '/', file: undefined }) }) it('takes - as the stdin app file', () => { - expect(resolvePositionals('/hello', '-', false)).toEqual({ path: '/hello', file: '-' }) + expect(resolvePositionals('/hello', '-')).toEqual({ path: '/hello', file: '-' }) }) it('requires the path', () => { - expect(expectSuggestions(() => resolvePositionals(undefined, undefined, false))).toEqual([ + expect(expectSuggestions(() => resolvePositionals(undefined, undefined))).toEqual([ 'Request the root: hono request /', ]) }) it('corrects a curl-style method argument with the exact command', () => { - expect(expectSuggestions(() => resolvePositionals('GET', '/api/orders', false))).toEqual([ + expect(expectSuggestions(() => resolvePositionals('GET', '/api/orders'))).toEqual([ 'hono request /api/orders -X GET', ]) }) it('corrects a method argument without a path', () => { - expect(expectSuggestions(() => resolvePositionals('POST', undefined, false))).toEqual([ + expect(expectSuggestions(() => resolvePositionals('POST', undefined))).toEqual([ 'hono request -X POST', ]) }) it('corrects a file-first call with both readings', () => { - expect(expectSuggestions(() => resolvePositionals('src/app.ts', undefined, false))).toEqual([ + expect(expectSuggestions(() => resolvePositionals('src/app.ts', undefined))).toEqual([ 'If src/app.ts is the app file: hono request / src/app.ts', 'If it is the path: hono request /src/app.ts', ]) }) - - it('takes the single batch argument as the app file', () => { - expect(resolvePositionals('src/app.ts', undefined, true)).toEqual({ file: 'src/app.ts' }) - }) - - it('rejects a path argument with --batch', () => { - expect(expectSuggestions(() => resolvePositionals('/api/users', undefined, true))).toEqual([ - 'Put the path in the batch lines', - ]) - }) }) diff --git a/src/commands/request/positionals.ts b/src/commands/request/positionals.ts index 04d826c..ca131bd 100644 --- a/src/commands/request/positionals.ts +++ b/src/commands/request/positionals.ts @@ -14,25 +14,8 @@ export interface Positionals { */ export const resolvePositionals = ( pathArg: string | undefined, - fileArg: string | undefined, - batch: boolean + fileArg: string | undefined ): Positionals => { - if (batch) { - // Batch steps carry their own paths, so the only argument is the - // app file. - if (pathArg !== undefined && fileArg !== undefined) { - throw new CliError('INVALID_ARGUMENTS', 'Pass one app file with --batch', { - suggestions: ['hono request --batch - src/app.ts'], - }) - } - if (pathArg?.startsWith('/')) { - throw new CliError('INVALID_ARGUMENTS', 'Batch steps carry their own paths', { - suggestions: ['Put the path in the batch lines'], - }) - } - return { file: pathArg } - } - if (pathArg === undefined) { throw new CliError('INVALID_ARGUMENTS', 'The request path is required', { suggestions: ['Request the root: hono request /'], diff --git a/src/commands/snapshot/index.ts b/src/commands/snapshot/index.ts new file mode 100644 index 0000000..2a2493c --- /dev/null +++ b/src/commands/snapshot/index.ts @@ -0,0 +1,45 @@ +import type { Command } from 'commander' +import type { CommandAgentContext } from '../../utils/agent-context.js' +import { getBuildIterator } from '../../utils/load-app.js' +import { handleErrors } from '../../utils/output.js' +import { snapshotLines } from './snapshot.js' + +export const agentContext: CommandAgentContext = { + output: + '{"path":"/users","expect":{"status":200,"body":[{"id":1}]}} — one batch JSONL line per route, not the JSON envelope', + errors: ['ENTRY_NOT_FOUND', 'BUILD_FAILED', 'INVALID_APP'], + examples: ['hono snapshot', 'hono snapshot src/app.ts'], + notes: [ + 'Prints the current behavior of the app as batch JSONL lines, to stdout. No file is written — keep the lines in your context, or redirect if you want one.', + 'Paramless GET routes are executed and their actual status and body become the "expect". Param and non-GET routes are printed without one, for you to fill in — the tool does not invent intent.', + 'One probe line records the current response for a path that matches no route.', + 'Capture before a refactor, then rerun the lines with hono batch until "failed" is 0.', + 'Unlike routes, this command sends real requests to the app — middleware runs.', + ], +} + +interface SnapshotOptions { + external?: string[] +} + +export function snapshotCommand(program: Command) { + program + .command('snapshot') + .description('Print the current behavior as batch JSONL lines') + .argument('[file]', 'Path to the Hono app file') + .option( + '-e, --external ', + 'Mark package as external (can be used multiple times)', + (value: string, previous: string[]) => { + return previous ? [...previous, value] : [value] + }, + [] as string[] + ) + .action( + handleErrors(async (file: string | undefined, options: SnapshotOptions) => { + for await (const app of getBuildIterator(file, false, options.external || [])) { + console.log((await snapshotLines(app)).join('\n')) + } + }) + ) +} diff --git a/src/commands/snapshot/snapshot.test.ts b/src/commands/snapshot/snapshot.test.ts new file mode 100644 index 0000000..5b6c2f1 --- /dev/null +++ b/src/commands/snapshot/snapshot.test.ts @@ -0,0 +1,43 @@ +import { Hono } from 'hono' +import { describe, it, expect } from 'vitest' +import { snapshotLines } from './snapshot.js' + +describe('snapshotLines', () => { + const app = () => { + const a = new Hono() + a.get('/users', (c) => c.json([{ id: 1, name: 'Momo' }])) + a.get('/users/:id', (c) => c.json({ id: c.req.param('id') })) + a.post('/users', (c) => c.json({ ok: true }, 201)) + a.get('/health', (c) => c.text('ok')) + return a + } + + it('captures paramless GET routes with their actual response as expect', async () => { + const lines = (await snapshotLines(app())).map((l) => JSON.parse(l)) + expect(lines).toContainEqual({ + path: '/users', + expect: { status: 200, body: [{ id: 1, name: 'Momo' }] }, + }) + expect(lines).toContainEqual({ path: '/health', expect: { status: 200, body: 'ok' } }) + }) + + it('prints param and non-GET routes without an expect', async () => { + const lines = (await snapshotLines(app())).map((l) => JSON.parse(l)) + expect(lines).toContainEqual({ path: '/users/:id' }) + expect(lines).toContainEqual({ method: 'POST', path: '/users' }) + }) + + it('records the current not-found behavior as a probe line', async () => { + const lines = (await snapshotLines(app())).map((l) => JSON.parse(l)) + expect(lines).toContainEqual({ + path: '/__no_such_path__', + expect: { status: 404, body: '404 Not Found' }, + }) + }) + + it('every line is valid batch input', async () => { + const { parseBatch } = await import('../batch/batch.js') + const lines = await snapshotLines(app()) + expect(() => parseBatch(lines.join('\n'))).not.toThrow() + }) +}) diff --git a/src/commands/snapshot/snapshot.ts b/src/commands/snapshot/snapshot.ts new file mode 100644 index 0000000..8dd8c62 --- /dev/null +++ b/src/commands/snapshot/snapshot.ts @@ -0,0 +1,48 @@ +import type { Hono } from 'hono' +import { inspectRoutes } from 'hono/dev' + +/** + * Print the current behavior of the app as batch JSONL lines, to + * stdout — no file. Parameterless GET routes are executed and their + * actual status and body become the `expect`. Other routes are + * printed without an `expect`, for the caller to fill in. One probe + * line records the current not-found behavior as a fact. + */ +export const snapshotLines = async (app: Hono): Promise => { + const lines: string[] = [] + const routes = inspectRoutes(app).filter((route) => !route.isMiddleware) + + for (const route of routes) { + const isParamless = !route.path.includes(':') && !route.path.includes('*') + if (route.method === 'GET' && isParamless) { + lines.push(JSON.stringify({ path: route.path, expect: await capture(app, route.path) })) + } else { + const method = route.method === 'GET' ? {} : { method: route.method } + lines.push(JSON.stringify({ ...method, path: route.path })) + } + } + + lines.push( + JSON.stringify({ + path: '/__no_such_path__', + expect: await capture(app, '/__no_such_path__'), + }) + ) + + return lines +} + +const capture = async (app: Hono, path: string): Promise<{ status: number; body?: unknown }> => { + const response = await app.request(new URL(path, 'http://localhost').href) + const text = await response.text() + const isJson = response.headers.get('content-type')?.includes('json') + let body: unknown = text + if (isJson) { + try { + body = JSON.parse(text) + } catch { + // keep the text + } + } + return { status: response.status, ...(text === '' ? {} : { body }) } +} diff --git a/src/utils/headers.ts b/src/utils/headers.ts new file mode 100644 index 0000000..6baebdd --- /dev/null +++ b/src/utils/headers.ts @@ -0,0 +1,11 @@ +/** Parse repeated `-H 'Key: value'` options into a header record */ +export const parseHeaders = (header: string[] | undefined): Record => { + const headers: Record = {} + for (const entry of header ?? []) { + const [key, value] = entry.split(':', 2) + if (key && value) { + headers[key.trim()] = value.trim() + } + } + return headers +} diff --git a/src/utils/load-app.ts b/src/utils/load-app.ts index e8b5065..3e11abf 100644 --- a/src/utils/load-app.ts +++ b/src/utils/load-app.ts @@ -10,7 +10,7 @@ const DEFAULT_ENTRY_CANDIDATES = ['src/index.ts', 'src/index.tsx', 'src/index.js /** * Resolve the entry file and return an iterator of the built app. */ -export function getBuildIterator( +export async function* getBuildIterator( appPath: string | undefined, watch: boolean, external: string[] = [] @@ -21,12 +21,13 @@ export function getBuildIterator( suggestions: ['Pass a file path instead of - when using --watch'], }) } - return buildAndImportApp(resolveEntry(appPath), { + yield* buildAndImportApp(await resolveEntry(appPath), { external: ['@hono/node-server', ...external], }) + return } - return buildAndImportApp(resolveEntry(appPath), { + yield* buildAndImportApp(await resolveEntry(appPath), { external: ['@hono/node-server', ...external], watch, sourcemap: true, @@ -37,9 +38,9 @@ export function getBuildIterator( * Resolve the app source: `-` reads code from stdin, a path is used * as-is, and without a path the default candidates are tried. */ -export function resolveEntry(appPath: string | undefined): AppEntry { +export async function resolveEntry(appPath: string | undefined): Promise { if (appPath === '-') { - return { code: wrapCode(readStdin()) } + return { code: wrapCode(await readStdin()) } } let entry: string @@ -69,13 +70,19 @@ export function resolveEntry(appPath: string | undefined): AppEntry { return realpathSync(resolvedAppPath) } -export const readStdin = (): string => { +export const readStdin = async (): Promise => { if (process.stdin.isTTY) { throw new CliError('MISSING_STDIN', 'No input on stdin', { - suggestions: ['Pipe the app code: cat app.ts | hono request - -P /'], + suggestions: ['Pipe the input: hono snapshot | hono batch -'], }) } - return readFileSync(0, 'utf-8') + // Not readFileSync(0): a pipe from another process can be + // non-blocking, and the sync read fails with EAGAIN. + const chunks: Buffer[] = [] + for await (const chunk of process.stdin) { + chunks.push(chunk as Buffer) + } + return Buffer.concat(chunks).toString('utf-8') } /** @@ -93,12 +100,12 @@ export const wrapCode = (code: string): string => { * Resolve a request body option: `@file` reads a file, `@-` reads * stdin, anything else is the body itself. */ -export const resolveData = (data: string | undefined): string | undefined => { +export const resolveData = async (data: string | undefined): Promise => { if (data === undefined || !data.startsWith('@')) { return data } if (data === '@-') { - return readStdin() + return await readStdin() } return readFileSync(data.slice(1), 'utf-8') } diff --git a/src/utils/output.test.ts b/src/utils/output.test.ts index 5ce8675..a5f8f35 100644 --- a/src/utils/output.test.ts +++ b/src/utils/output.test.ts @@ -60,6 +60,8 @@ describe('formatArgumentsError flag fixes', () => { expect(body.error.suggestions[0]).toContain('-d') const method = JSON.parse(formatArgumentsError("error: unknown option '-m'")) expect(method.error.suggestions[0]).toContain('-X') + const batch = JSON.parse(formatArgumentsError("error: unknown option '--batch'")) + expect(batch.error.suggestions).toEqual(['batch is a command now: hono batch -']) }) }) diff --git a/src/utils/output.ts b/src/utils/output.ts index 4a96a36..57a4659 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -42,6 +42,8 @@ export const formatError = (error: CliError): string => const FLAG_FIXES: Record = { '-P': 'The path is the first argument: hono request /api/users', '--path': 'The path is the first argument: hono request /api/users', + '--batch': 'batch is a command now: hono batch -', + '--snapshot': 'snapshot is a command now: hono snapshot', '--body': `The body flag is -d: hono request /api/users -X POST -d '{"name":"Alice"}'`, '-j': `The body flag is -d: hono request /api/users -X POST -d '{"name":"Alice"}'`, '-m': 'The method flag is -X: hono request /api/users -X POST',