Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
23 changes: 23 additions & 0 deletions src/commands/batch/batch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand Down
21 changes: 16 additions & 5 deletions src/commands/batch/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, unknown>
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`)
Expand Down Expand Up @@ -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<string, string> = {
...sharedHeaders,
...interpolate(step.headers ?? {}, vars),
Expand Down Expand Up @@ -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) }),
Expand Down Expand Up @@ -327,10 +333,15 @@ export const runBatch = async (
if (step.save) {
const saved: Record<string, unknown> = {}
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
Expand Down
1 change: 1 addition & 0 deletions src/commands/batch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}}.',
Expand Down
Loading