diff --git a/README.md b/README.md index da726c3c..1229fb65 100644 --- a/README.md +++ b/README.md @@ -1978,6 +1978,35 @@ blocked, stopping discards queued output instead of waiting for the reader. Use `event stream` to consume events in a script. Use `listen` to forward exact bodies to a local webhook handler. +### Request logs + +`polymorfa logs tail` prints a project's API requests as they happen, like +`stripe logs tail`. It needs a team key or project token with `logs:read`. + +```shell +polymorfa logs tail \ + --project project_123 \ + --status 4xx,5xx \ + --method POST +``` + +Each line shows the time, method, route pattern, status, duration, error code, +source, request ID and W3C trace ID. The command prints the 10 most recent +matching requests (`--backfill 0` to 100), then polls for new ones every +`--poll-interval` seconds (default 2, 1 to 60). It reads again at once while +more requests are waiting, and waits for `Retry-After` when the read limit of +120 a minute is reached. `--json` writes one JSON line per request. +`--max-events` stops after a fixed number. Ctrl-C stops with exit code 0. + +Filters: `--status` (codes or classes), `--method`, `--route` (exact pattern +such as `/messaging/:session/messages`), `--source api|mcp`, `--credential` +(key or token ID), `--request-id` and `--trace-id`. Send a `traceparent` header +with your API requests to find them by your own trace ID. + +`polymorfa logs list` prints one page, newest first, with the same filters plus +`--since`, `--until` and `--limit`. Pass the printed `--cursor` for older +requests. Requests older than 30 days are not listed. + ## Local event listening and forwarding `polymorfa listen` opens the project listener stream and can forward the exact diff --git a/package.json b/package.json index cd3d35bd..97f2c5b9 100644 --- a/package.json +++ b/package.json @@ -183,6 +183,9 @@ "label:chat": { "description": "Manage the complete label set assigned to a chat." }, + "logs": { + "description": "Read and follow a project's API request log." + }, "media": { "description": "Inspect, download, and persist Messaging media." }, diff --git a/src/commands/logs/list.ts b/src/commands/logs/list.ts new file mode 100644 index 00000000..e49330cc --- /dev/null +++ b/src/commands/logs/list.ts @@ -0,0 +1,84 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import {developerRead} from '../../developer/output.js' +import {CliConditionError} from '../../errors/conditions.js' +import {listRequestLogs, requestLogLine} from '../../services/request-logs.js' +import { + requestLogFilterFlags, + requestLogFilters, +} from '../../input/request-log-filters.js' + +export default class LogsList extends BaseCommand { + static override description = + "List a project's API requests from the last 30 days, newest first. Requires logs:read." + + static override examples = [ + '<%= config.bin %> logs list --project 11111111-1111-4111-8111-111111111111 --status 4xx,5xx', + '<%= config.bin %> logs list --project 11111111-1111-4111-8111-111111111111 --trace-id 4bf92f3577b34da6a3ce929d0e0e4736 --json', + ] + + static override flags = { + ...requestLogFilterFlags, + cursor: Flags.string({ + description: + 'Continue from page.nextCursor of an earlier page; keeps its filters.', + }), + limit: Flags.integer({ + description: 'Requests per page (default 25).', + max: 100, + min: 1, + }), + since: Flags.string({description: 'Earliest request time (RFC 3339).'}), + until: Flags.string({description: 'Latest request time (RFC 3339).'}), + } + + async run(): Promise { + const {flags} = await this.parse(LogsList) + const session = await this.developerSession({ + ...flags, + owner: flags.owner ?? 'project', + }) + const projectId = session.context.projectId + if (session.context.owner !== 'project' || !projectId) { + throw new CliConditionError( + 'action_required', + 'Request logs belong to a project. Pass --project or use a project token.', + ) + } + const filters = requestLogFilters(flags) + if ( + flags.cursor !== undefined && + (Object.keys(filters).length > 0 || flags.since || flags.until) + ) { + throw new CliConditionError( + 'action_required', + '--cursor keeps the filters of the page that returned it; do not combine it with filters.', + ) + } + const page = await listRequestLogs(session.client, projectId, { + ...filters, + ...(flags.since === undefined ? {} : {since: flags.since}), + ...(flags.until === undefined ? {} : {until: flags.until}), + ...(flags.limit === undefined ? {} : {limit: flags.limit}), + ...(flags.cursor === undefined ? {} : {cursor: flags.cursor}), + }) + const human = [ + ...page.items.map((log) => requestLogLine(log)), + ...(page.nextCursor === null + ? [] + : [`Older requests: --cursor ${page.nextCursor}`]), + ].join('\n') + const outputPage = { + nextCursor: page.nextCursor, + hasMore: page.hasMore, + // Pass as --after to the API, or let `logs tail` follow for you. + followCursor: page.followCursor, + } + this.writeValue( + developerRead(page.items, page.metadata, session.context, outputPage), + page.items.length === 0 ? 'No requests.\n' : `${human}\n`, + flags, + ) + } +} diff --git a/src/commands/logs/tail.ts b/src/commands/logs/tail.ts new file mode 100644 index 00000000..90af9386 --- /dev/null +++ b/src/commands/logs/tail.ts @@ -0,0 +1,137 @@ +import {once} from 'node:events' +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import {CliConditionError} from '../../errors/conditions.js' +import { + POLL_INTERVAL_SECONDS, + requestLogLine, + tailRequestLogs, + type RequestLog, +} from '../../services/request-logs.js' +import { + requestLogFilterFlags, + requestLogFilters, +} from '../../input/request-log-filters.js' + +export default class LogsTail extends BaseCommand { + static override description = + "Print a project's API requests as they happen. Polls the request log and requires logs:read." + + static override examples = [ + '<%= config.bin %> logs tail --project 11111111-1111-4111-8111-111111111111', + '<%= config.bin %> logs tail --project 11111111-1111-4111-8111-111111111111 --status 4xx,5xx --method POST', + '<%= config.bin %> logs tail --project 11111111-1111-4111-8111-111111111111 --poll-interval 5 --json', + ] + + static override flags = { + ...requestLogFilterFlags, + backfill: Flags.integer({ + default: 10, + description: 'Print this many recent requests before following.', + max: 100, + min: 0, + }), + 'max-events': Flags.integer({ + description: 'Stop after printing this many requests.', + min: 1, + }), + 'poll-interval': Flags.integer({ + default: POLL_INTERVAL_SECONDS.default, + description: 'Seconds between polls while no new requests arrive.', + max: POLL_INTERVAL_SECONDS.max, + min: POLL_INTERVAL_SECONDS.min, + }), + } + + async run(): Promise { + const {flags} = await this.parse(LogsTail) + const session = await this.developerSession({ + ...flags, + owner: flags.owner ?? 'project', + }) + const projectId = session.context.projectId + if (session.context.owner !== 'project' || !projectId) { + throw new CliConditionError( + 'action_required', + 'Request logs belong to a project. Pass --project or use a project token.', + ) + } + const json = flags.json === true + const quiet = flags.quiet === true + const controller = new AbortController() + let interrupted = false + const stop = () => { + interrupted = true + controller.abort() + } + process.once('SIGINT', stop) + process.once('SIGTERM', stop) + const stdoutClosed = (error: unknown) => { + if (isClosedPipe(error)) controller.abort() + else throw error + } + process.stdout.on('error', stdoutClosed) + let printed = 0 + try { + if (!json && !quiet) { + process.stderr.write( + `Following request logs for project ${projectId}. Press Ctrl-C to stop.\n`, + ) + } + for await (const log of tailRequestLogs(session.client, projectId, { + ...requestLogFilters(flags), + backfill: flags.backfill, + intervalMs: flags['poll-interval'] * 1000, + signal: controller.signal, + onRateLimit: (waitMs) => { + if (!json && !quiet) { + process.stderr.write( + `Read limit reached; waiting ${Math.ceil(waitMs / 1000)} s.\n`, + ) + } + }, + })) { + printed += 1 + if (json) + await writeStdout( + `${JSON.stringify(jsonLine(log))}\n`, + controller.signal, + ) + else if (!quiet) + await writeStdout(`${requestLogLine(log)}\n`, controller.signal) + if (controller.signal.aborted) break + if (flags['max-events'] !== undefined && printed >= flags['max-events']) + break + } + } finally { + process.removeListener('SIGINT', stop) + process.removeListener('SIGTERM', stop) + process.stdout.removeListener('error', stdoutClosed) + if (interrupted && process.stdout.writableNeedDrain) process.exit(0) + } + } +} + +function jsonLine(log: RequestLog) { + return {schemaVersion: 1, type: 'request', request: log} +} + +async function writeStdout(value: string, signal: AbortSignal): Promise { + if (process.stdout.write(value)) return + try { + await once(process.stdout, 'drain', {signal}) + } catch (error) { + if (isClosedPipe(error)) return + if (signal.aborted) return + throw error + } +} + +function isClosedPipe(error: unknown): boolean { + return ( + error instanceof Error && + 'code' in error && + (error.code === 'EPIPE' || error.code === 'ERR_STREAM_DESTROYED') + ) +} diff --git a/src/errors/present.ts b/src/errors/present.ts index b24ef50d..6532a864 100644 --- a/src/errors/present.ts +++ b/src/errors/present.ts @@ -122,7 +122,10 @@ export function presentError( } /** `Retry-After` as whole seconds from now, from delta-seconds or an HTTP-date. */ -function retryAfter(error: unknown, now = Date.now()): number | undefined { +export function retryAfter( + error: unknown, + now = Date.now(), +): number | undefined { if (!(error instanceof PolymorfaError)) return undefined const header = error.metadata?.headers['retry-after']?.trim() if (header === undefined || header.length === 0) return undefined diff --git a/src/input/request-log-filters.ts b/src/input/request-log-filters.ts new file mode 100644 index 00000000..f2316a8d --- /dev/null +++ b/src/input/request-log-filters.ts @@ -0,0 +1,66 @@ +import {Flags} from '@oclif/core' + +import type {RequestLogFilters} from '../services/request-logs.js' + +/** Filters shared by `logs list` and `logs tail`. */ +export const requestLogFilterFlags = { + credential: Flags.string({ + description: 'Only requests made by this team key or project token ID.', + }), + method: Flags.string({ + description: 'Only these HTTP methods, for example POST or GET,DELETE.', + multiple: true, + }), + 'request-id': Flags.string({ + description: 'Only the request with this X-Request-Id.', + }), + route: Flags.string({ + description: + 'Only this exact route pattern, for example /messaging/:session/messages.', + }), + source: Flags.string({ + description: 'Only direct API requests or requests made through MCP.', + options: ['api', 'mcp'], + }), + status: Flags.string({ + description: + 'Only these status codes or classes, for example 404 or 4xx,5xx.', + multiple: true, + }), + 'trace-id': Flags.string({ + description: 'Only requests with this W3C trace ID (X-Trace-Id).', + }), +} + +export function requestLogFilters(flags: { + readonly credential?: string | undefined + readonly method?: string[] | undefined + readonly 'request-id'?: string | undefined + readonly route?: string | undefined + readonly source?: string | undefined + readonly status?: string[] | undefined + readonly 'trace-id'?: string | undefined +}): RequestLogFilters { + const list = (values: string[] | undefined) => + values?.flatMap((value) => + value + .split(',') + .map((item) => item.trim()) + .filter(Boolean), + ) + const status = list(flags.status) + const method = list(flags.method)?.map((value) => value.toUpperCase()) + return { + ...(status?.length ? {status} : {}), + ...(method?.length ? {method} : {}), + ...(flags.route === undefined ? {} : {route: flags.route}), + ...(flags.source === undefined ? {} : {source: flags.source}), + ...(flags.credential === undefined ? {} : {credentialId: flags.credential}), + ...(flags['request-id'] === undefined + ? {} + : {requestId: flags['request-id']}), + ...(flags['trace-id'] === undefined + ? {} + : {traceId: flags['trace-id'].toLowerCase()}), + } +} diff --git a/src/services/request-logs.ts b/src/services/request-logs.ts new file mode 100644 index 00000000..7c86901b --- /dev/null +++ b/src/services/request-logs.ts @@ -0,0 +1,214 @@ +import { + type ClientBase, + type ClientOwner, + PolymorfaRateLimitError, + PolymorfaServerError, + type ResponseMetadata, +} from '@polymorfa/sdk' +import {z} from 'zod' + +import {terminalText} from '../developer/render.js' +import {retryAfter} from '../errors/present.js' + +const entry = z.object({ + id: z.string(), + projectId: z.string(), + createdAt: z.string(), + method: z.string(), + route: z.string(), + status: z.number().int().nullable(), + durationMs: z.number().nullable(), + result: z.enum(['success', 'failure']), + source: z.enum(['api', 'mcp']), + requestId: z.string().nullable(), + traceId: z.string().nullable(), + errorCode: z.string().nullable(), + mcpTool: z.string().nullable(), + credential: z + .object({ + type: z.enum(['team_key', 'project_token', 'client_token']), + id: z.string().nullable(), + last4: z.string().nullable(), + }) + .nullable(), +}) +const page = z.object({ + data: z.array(entry), + page: z.object({ + nextCursor: z.string().nullable(), + hasMore: z.boolean(), + followCursor: z.string(), + }), +}) + +export type RequestLog = z.infer +export interface RequestLogPage { + readonly items: readonly RequestLog[] + readonly metadata: ResponseMetadata + readonly hasMore: boolean + readonly nextCursor: string | null + readonly followCursor: string +} + +export interface RequestLogFilters { + readonly status?: readonly string[] + readonly method?: readonly string[] + readonly route?: string + readonly source?: string + readonly credentialId?: string + readonly requestId?: string + readonly traceId?: string + readonly since?: string + readonly until?: string +} + +export const POLL_INTERVAL_SECONDS = {default: 2, min: 1, max: 60} as const + +export async function listRequestLogs( + client: ClientBase, + projectId: string, + query: RequestLogFilters & { + readonly limit?: number + readonly cursor?: string + readonly after?: string + }, + signal?: AbortSignal, +): Promise { + const params: Record = {} + for (const [name, value] of Object.entries(query)) { + if (value === undefined) continue + params[name] = Array.isArray(value) ? value.join(',') : String(value) + } + const response = await client.raw.request({ + method: 'GET', + // A project client's raw requests are already confined to its project. + path: + client.owner === 'project' + ? '/request-logs' + : `/platform/projects/${encodeURIComponent(projectId)}/request-logs`, + query: params, + ...(signal === undefined ? {} : {signal}), + }) + const parsed = page.safeParse(response.data) + if (!parsed.success) { + throw new PolymorfaServerError( + 'The Polymorfa API returned an invalid request log page.', + { + code: 'invalid_response', + status: response.metadata.status, + metadata: response.metadata, + }, + ) + } + return { + items: parsed.data.data, + metadata: response.metadata, + hasMore: parsed.data.page.hasMore, + nextCursor: parsed.data.page.nextCursor, + followCursor: parsed.data.page.followCursor, + } +} + +/** + * Yields new requests oldest first until `signal` aborts. Waits `intervalMs` + * between idle polls, reads again at once while more requests are waiting, + * and waits for Retry-After when reads are rate limited. + */ +export async function* tailRequestLogs( + client: ClientBase, + projectId: string, + options: RequestLogFilters & { + readonly intervalMs: number + readonly backfill: number + readonly signal: AbortSignal + readonly onRateLimit?: (waitMs: number) => void + }, +): AsyncGenerator { + const {intervalMs, backfill, signal, onRateLimit, ...filters} = options + const first = await retrying( + () => + listRequestLogs( + client, + projectId, + {...filters, limit: Math.max(backfill, 1)}, + signal, + ), + signal, + onRateLimit, + ) + if (first === undefined) return + if (backfill > 0) yield* [...first.items].reverse() + let after = first.followCursor + while (!signal.aborted) { + const next = await retrying( + () => listRequestLogs(client, projectId, {after, limit: 100}, signal), + signal, + onRateLimit, + ) + if (next === undefined) return + yield* next.items + after = next.followCursor + if (!next.hasMore && !(await sleep(intervalMs, signal))) return + } +} + +async function retrying( + read: () => Promise, + signal: AbortSignal, + onRateLimit: ((waitMs: number) => void) | undefined, +): Promise { + for (;;) { + if (signal.aborted) return undefined + try { + return await read() + } catch (error) { + if (signal.aborted) return undefined + if (!(error instanceof PolymorfaRateLimitError)) throw error + // Delta-seconds (including 0) or an HTTP-date; 60 s when absent or invalid. + const seconds = retryAfter(error) + const waitMs = Math.min( + seconds === undefined ? 60_000 : seconds * 1000, + 300_000, + ) + onRateLimit?.(waitMs) + if (!(await sleep(waitMs, signal))) return undefined + } + } +} + +function sleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal.aborted) { + resolve(false) + return + } + const onAbort = () => { + clearTimeout(timer) + resolve(false) + } + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort) + resolve(true) + }, ms) + signal.addEventListener('abort', onAbort, {once: true}) + }) +} + +/** One tab-separated human line: time, method, route, status, duration and IDs. */ +export function requestLogLine(log: RequestLog): string { + return [ + log.createdAt, + log.method, + log.route, + log.status ?? '-', + log.durationMs === null ? '-' : `${log.durationMs}ms`, + log.errorCode ?? '-', + log.source === 'mcp' && log.mcpTool !== null + ? `mcp:${log.mcpTool}` + : log.source, + log.requestId ?? '-', + log.traceId ?? '-', + ] + .map((value) => terminalText(value)) + .join('\t') +} diff --git a/test/commands/request-logs.test.ts b/test/commands/request-logs.test.ts new file mode 100644 index 00000000..1043d7d7 --- /dev/null +++ b/test/commands/request-logs.test.ts @@ -0,0 +1,256 @@ +import {runCommand} from '@oclif/test' +import {afterEach, expect, test} from 'vitest' + +import { + startHttpSequenceServer, + withEnvironment, + type TestHttpServer, +} from '../helpers/http-server.js' + +const key = + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const project = '22222222-2222-4222-8222-222222222222' +let server: TestHttpServer + +function log(id: string, createdAt: string) { + return { + id, + projectId: project, + createdAt, + method: 'POST', + route: '/messaging/:session/messages', + status: 422, + durationMs: 41, + result: 'failure', + source: 'api', + requestId: `req-${id}`, + traceId: '4bf92f3577b34da6a3ce929d0e0e4736', + errorCode: 'invalid_recipient', + mcpTool: null, + credential: {type: 'team_key', id: project, last4: 'abcd'}, + } +} + +function page(items: unknown[], follow: string, extra: object = {}) { + return { + body: { + data: items, + page: {nextCursor: null, hasMore: false, followCursor: follow, ...extra}, + }, + } +} + +async function run(args: string[]) { + return withEnvironment( + { + POLYMORFA_API_KEY: key, + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_PROJECT_TOKEN: undefined, + }, + () => runCommand(args), + ) +} + +afterEach(async () => { + await server?.close() +}) + +test('lists filtered requests newest first and prints the older-page cursor', async () => { + server = await startHttpSequenceServer([ + page([log('b', '2026-10-10T12:00:02.000Z')], 'follow-1', { + nextCursor: 'older-1', + hasMore: true, + }), + ]) + const result = await run([ + 'logs', + 'list', + '--project', + project, + '--status', + '4xx,5xx', + '--method', + 'post', + '--trace-id', + '4BF92F3577B34DA6A3CE929D0E0E4736', + ]) + expect(result.error).toBeUndefined() + const url = new URL(server.requests[0]!.url, 'http://localhost') + expect(url.pathname).toBe(`/platform/projects/${project}/request-logs`) + expect(Object.fromEntries(url.searchParams)).toEqual({ + status: '4xx,5xx', + method: 'POST', + traceId: '4bf92f3577b34da6a3ce929d0e0e4736', + }) + expect(result.stdout).toContain( + 'POST\t/messaging/:session/messages\t422\t41ms\tinvalid_recipient', + ) + expect(result.stdout).toContain('Older requests: --cursor older-1') +}) + +test('refuses a cursor combined with filters before sending a request', async () => { + server = await startHttpSequenceServer([page([], 'f')]) + const result = await run([ + 'logs', + 'list', + '--project', + project, + '--cursor', + 'older-1', + '--status', + '5xx', + ]) + expect(result.error).toBeDefined() + expect(server.requests).toHaveLength(0) +}) + +test('tails the backfill oldest first, then follows with the returned cursor', async () => { + server = await startHttpSequenceServer([ + page( + [ + log('b', '2026-10-10T12:00:02.000Z'), + log('a', '2026-10-10T12:00:01.000Z'), + ], + 'follow-1', + ), + page([log('c', '2026-10-10T12:00:03.000Z')], 'follow-2'), + ]) + const result = await run([ + 'logs', + 'tail', + '--project', + project, + '--backfill', + '2', + '--status', + '5xx', + '--max-events', + '3', + '--json', + ]) + expect(result.error).toBeUndefined() + const lines = result.stdout + .trim() + .split('\n') + .map((line) => JSON.parse(line) as {type: string; request: {id: string}}) + expect(lines.map((line) => line.request.id)).toEqual(['a', 'b', 'c']) + expect(lines[0]?.type).toBe('request') + const first = new URL(server.requests[0]!.url, 'http://localhost') + expect(first.searchParams.get('status')).toBe('5xx') + expect(first.searchParams.get('limit')).toBe('2') + const second = new URL(server.requests[1]!.url, 'http://localhost') + expect(Object.fromEntries(second.searchParams)).toEqual({ + after: 'follow-1', + limit: '100', + }) +}) + +test('waits for Retry-After when reads are rate limited', async () => { + server = await startHttpSequenceServer([ + page([], 'follow-1'), + { + status: 429, + headers: {'content-type': 'application/json', 'retry-after': '1'}, + body: { + error: { + type: 'rate_limit_error', + code: 'rate_limit_exceeded', + message: 'slow down', + }, + }, + }, + page([log('c', '2026-10-10T12:00:03.000Z')], 'follow-2'), + ]) + const started = Date.now() + const result = await run([ + 'logs', + 'tail', + '--project', + project, + '--backfill', + '0', + '--max-events', + '1', + '--json', + ]) + expect(result.error).toBeUndefined() + expect(Date.now() - started).toBeGreaterThanOrEqual(900) + expect(result.stdout).toContain('"id":"c"') +}) + +test('rejects a poll interval outside 1 to 60 seconds', async () => { + server = await startHttpSequenceServer([page([], 'f')]) + for (const interval of ['0', '61']) { + const result = await run([ + 'logs', + 'tail', + '--project', + project, + '--poll-interval', + interval, + ]) + expect(result.error).toBeDefined() + } + expect(server.requests).toHaveLength(0) +}) + +test('prints list JSON in the standard read envelope with the follow cursor', async () => { + server = await startHttpSequenceServer([ + { + ...page([log('b', '2026-10-10T12:00:02.000Z')], 'follow-1'), + headers: {'content-type': 'application/json', 'x-request-id': 'req_list'}, + }, + ]) + const result = await run(['logs', 'list', '--project', project, '--json']) + expect(result.error).toBeUndefined() + const output = JSON.parse(result.stdout) as { + schemaVersion: number + data: {id: string}[] + request: {id: string; status: number} + context: {owner: string; projectId: string} + page: {nextCursor: null; hasMore: boolean; followCursor: string} + } + expect(output.schemaVersion).toBe(1) + expect(output.data.map((item) => item.id)).toEqual(['b']) + expect(output.request).toMatchObject({id: 'req_list', status: 200}) + expect(output.context).toMatchObject({owner: 'project', projectId: project}) + expect(output.page).toEqual({ + nextCursor: null, + hasMore: false, + followCursor: 'follow-1', + }) +}) + +test('retries at once on Retry-After: 0', async () => { + server = await startHttpSequenceServer([ + page([], 'follow-1'), + { + status: 429, + headers: {'content-type': 'application/json', 'retry-after': '0'}, + body: { + error: { + type: 'rate_limit_error', + code: 'rate_limit_exceeded', + message: 'slow down', + }, + }, + }, + page([log('c', '2026-10-10T12:00:03.000Z')], 'follow-2'), + ]) + const started = Date.now() + const result = await run([ + 'logs', + 'tail', + '--project', + project, + '--backfill', + '0', + '--max-events', + '1', + '--json', + ]) + expect(result.error).toBeUndefined() + expect(Date.now() - started).toBeLessThan(5_000) + expect(result.stdout).toContain('"id":"c"') +})