From 23beb74a4b6c07738ebfd07a07cb634f23672d0d Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Fri, 16 Oct 2026 01:59:38 +0900 Subject: [PATCH] feat(batch): show the Location of a redirect and save headers --- README.md | 2 +- src/commands/batch/batch.test.ts | 23 +++++++++++++++++++++++ src/commands/batch/batch.ts | 21 ++++++++++++++++----- src/commands/batch/index.ts | 1 + 4 files changed, 41 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index d95fafa..8ce96e4 100644 --- a/README.md +++ b/README.md @@ -269,7 +269,7 @@ EOF With `--runtime workerd`, one workerd starts and every step runs in it, so a flow over the real bindings (put to KV, then get; D1; an AI binding) runs in one call. The entry is `main` in the wrangler config, so pass no file argument. With `--runtime vite`, one Vite dev server starts and every step goes through it. -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 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`. +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`. ### `snapshot` diff --git a/src/commands/batch/batch.test.ts b/src/commands/batch/batch.test.ts index 3af97da..5dc4ddc 100644 --- a/src/commands/batch/batch.test.ts +++ b/src/commands/batch/batch.test.ts @@ -202,6 +202,29 @@ describe('runBatch', () => { expect(result.steps[1].body).toEqual({ id: 1, name: 'Momo' }) }) + it('shows the Location of a redirect and saves a header with header:', async () => { + const app = crudApp() + app.post('/form', (c) => c.redirect('http://localhost/users/1', 303)) + const result = await runBatch( + app, + parseBatch( + [ + '{"method":"POST","path":"/users","body":{"name":"Momo"}}', + '{"method":"POST","path":"/form","save":{"next":"header:location"}}', + '{"path":"{{next}}","expect":{"status":200,"body":{"name":"Momo"}}}', + '{"path":"/users","save":{"x":"header:x-none"}}', + ].join('\n') + ) + ) + expect(result.steps[0].location).toBeUndefined() + expect(result.steps[1].location).toBe('http://localhost/users/1') + expect(result.steps[1].saved).toEqual({ next: 'http://localhost/users/1' }) + expect(result.steps[2].path).toBe('/users/1') + expect(result.steps[2].pass).toBe(true) + expect(result.steps[3].pass).toBe(false) + expect(result.steps[3].error).toBe('save: x-none not found in the headers') + }) + it('passes a step without expect on a 2xx or 3xx', async () => { const app = crudApp() app.get('/moved', (c) => c.redirect('/users')) diff --git a/src/commands/batch/batch.ts b/src/commands/batch/batch.ts index 8fa562f..a6f7130 100644 --- a/src/commands/batch/batch.ts +++ b/src/commands/batch/batch.ts @@ -20,6 +20,8 @@ export interface StepResult { method: string path: string status: number + /** The Location header of a redirect */ + location?: string body: unknown pass: boolean expect?: StepExpect @@ -62,8 +64,9 @@ export const parseBatch = (input: string): BatchStep[] => { throw invalid(`Line ${i + 1} is not a JSON object`) } const step = parsed as Record - if (typeof step.path !== 'string' || !step.path.startsWith('/')) { - throw invalid(`Line ${i + 1} needs a "path" starting with /`) + // "{{next}}" is a saved path, e.g. from "header:location" + if (typeof step.path !== 'string' || !/^(\/|\{\{)/.test(step.path)) { + throw invalid(`Line ${i + 1} needs a "path" starting with / or a saved {{name}}`) } if (step.method !== undefined && typeof step.method !== 'string') { throw invalid(`Line ${i + 1}: "method" must be a string`) @@ -252,7 +255,8 @@ export const runBatch = async ( const results: StepResult[] = [] for (const step of steps) { - const path = interpolate(step.path, vars) + // A saved absolute URL (a Location) goes to the same app + const path = String(interpolate(step.path, vars)).replace(/^https?:\/\/[^/]+/, '') const headers: Record = { ...sharedHeaders, ...interpolate(step.headers ?? {}, vars), @@ -292,10 +296,12 @@ export const runBatch = async ( } } + const location = response.headers.get('location') const result: StepResult = { method: step.method, path, status: response.status, + ...(location === null ? {} : { location }), body, pass: true, ...(step.expect === undefined ? {} : { expect: interpolate(step.expect, vars) }), @@ -327,10 +333,15 @@ export const runBatch = async ( if (step.save) { const saved: Record = {} for (const [name, savePath] of Object.entries(step.save)) { - const value = getByPath(body, savePath) + // "header:location" reads a header, e.g. the URL a create redirects to + const header = savePath.startsWith('header:') ? savePath.slice(7) : undefined + const value = + header === undefined + ? getByPath(body, savePath) + : (response.headers.get(header) ?? undefined) if (value === undefined) { result.pass = false - result.error = `save: ${savePath} not found in the body` + result.error = `save: ${header ?? savePath} not found in the ${header ? 'headers' : 'body'}` } else { vars[name] = value saved[name] = value diff --git a/src/commands/batch/index.ts b/src/commands/batch/index.ts index 69435dd..f1214eb 100644 --- a/src/commands/batch/index.ts +++ b/src/commands/batch/index.ts @@ -26,6 +26,7 @@ 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.', + 'A redirect shows its "location". "save" reads a header with "header:", e.g. {"next":"header:location"}, then {"path":"{{next}}"} follows it.', 'Cookies carry over like in a browser: a login step that sets a cookie signs in the steps after it. A "cookie" header in a step wins.', '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 step without "expect" passes on any 2xx or 3xx and fails on a 4xx or 5xx. To accept a 4xx on purpose, declare it: {"expect":{"status":404}}.',