diff --git a/apps/cli/src/serve.fixture.ts b/apps/cli/src/serve.fixture.ts new file mode 100644 index 0000000..013e7cb --- /dev/null +++ b/apps/cli/src/serve.fixture.ts @@ -0,0 +1,117 @@ +// apps/cli/src/serve.fixture.ts +// +// A per-test laboratory for the serve suites: a temporary project on disk, +// the servers started against it, and request helpers bound to THAT project. +// +// These used to be module-level `root` and `handle` variables that +// `beforeEach` replaced. That works until a test times out: vitest moves on +// to the next test, but the timed-out test's promise chain keeps running, +// and every `send` or `writeMocks` it still has queued now resolves against +// the NEXT test's server and directory. Under load, one slow test turned +// into two failures, the second with an assertion that made no sense on its +// own (a 400 from a route the test never exercised). A fixture binds the +// helpers to one test for its whole life, so a stale chain can only reach +// its own, already-closed server, where every call fails harmlessly. +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { ConfigSchema } from '@laqi/schema' +import { test as base } from 'vitest' +import { startServer, type ServeHandle } from './serve' + +type StartOptions = Parameters[0] + +/** Loopback on an OS-assigned port: what nearly every test wants. */ +export const LOOPBACK = ConfigSchema.parse({ port: 0, host: '127.0.0.1' }) + +export type Lab = { + /** The temporary project directory. `laqi/` already exists inside it. */ + root: string + /** The server `start` brought up most recently. `get` and `send` target it. */ + handle: ServeHandle | undefined + /** + * `startServer` against this lab's root, on `LOOPBACK` unless told + * otherwise. Whatever it starts is closed at teardown, even if the test + * closed it first. + */ + start: (options?: Partial) => Promise + /** Overwrites `laqi/api.json` (or another file under `laqi/`). */ + writeMocks: (contents: Record, file?: string) => void + readMocks: () => Record> }> + /** A request to the local port of `handle`. */ + get: (path: string, init?: RequestInit) => Promise + /** A JSON request to the control plane of `handle`, mounted under `/__laqi`. */ + send: (path: string, method: string, body?: unknown) => Promise +} + +export const it = base.extend<{ lab: Lab }>({ + // oxlint-disable-next-line no-empty-pattern -- vitest requires the destructuring form here + lab: async ({}, use) => { + const root = mkdtempSync(join(tmpdir(), 'laqi-serve-')) + mkdirSync(join(root, 'laqi'), { recursive: true }) + const started: ServeHandle[] = [] + let tornDown = false + + const port = (): number => { + if (!lab.handle) throw new Error('no server started through lab.start yet') + return lab.handle.port + } + + const lab: Lab = { + root, + handle: undefined, + start: async (options = {}) => { + const handle = await startServer({ root, config: LOOPBACK, ...options }) + if (tornDown) { + // The test already timed out and was cleaned up while this + // listener was still binding. Nobody will close it otherwise. + await handle.close() + throw new Error('lab.start resolved after its test was torn down') + } + started.push(handle) + lab.handle = handle + return handle + }, + writeMocks: (contents, file = 'api.json') => { + writeFileSync(join(root, 'laqi', file), JSON.stringify(contents, null, 2), 'utf8') + }, + readMocks: () => JSON.parse(readFileSync(join(root, 'laqi', 'api.json'), 'utf8')) as never, + get: (path, init) => fetch(`http://127.0.0.1:${port()}${path}`, init), + send: (path, method, body) => + fetch(`http://127.0.0.1:${port()}/__laqi${path}`, { + method, + headers: { 'Content-Type': 'application/json' }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }), + } + + await use(lab) + + tornDown = true + // A test may have closed what it started already ("closes both + // listeners" does, on purpose), and closing an http.Server twice rejects + // with ERR_SERVER_NOT_RUNNING. That is not a failure of the test. + await Promise.all(started.map((handle) => handle.close().catch(() => undefined))) + rmSync(root, { recursive: true, force: true }) + }, +}) + +/** + * Loads the generation stack, TypeScript compiler included, into this + * worker before the first test needs it. + * + * `POST /api/schema/import` with a pasted model is the request that imports + * the 23 MB compiler. Idle, the first one takes about a second and a half; + * on a loaded machine (several suites running at once, which is what + * parallel worktrees do to it) it was measured at 13 to 16 seconds, most of + * a 20 second test budget gone before the test's own steps began. Paid here, + * it runs once per file under the hook budget, and every test in the file + * then sees the warm cost only. + */ +export async function warmGenerationStack(): Promise { + const { importSchema } = await import('@laqi/generate') + await importSchema({ + kind: 'typescript-paste', + source: 'export interface Warm { id: number }', + }) +} diff --git a/apps/cli/src/serve.schema.test.ts b/apps/cli/src/serve.schema.test.ts index 41373db..db8e860 100644 --- a/apps/cli/src/serve.schema.test.ts +++ b/apps/cli/src/serve.schema.test.ts @@ -4,63 +4,46 @@ // not in the server package because the conflict rules only mean anything // with a real file underneath them: the whole point is what happens when the // bytes on disk stop matching what the caller looked at. -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { ConfigSchema, type SchemaSnapshot } from '@laqi/schema' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { startServer, type ServeHandle } from './serve' +import { beforeAll, describe, expect, vi } from 'vitest' +import { it, warmGenerationStack, type Lab } from './serve.fixture' -let root: string -let handle: ServeHandle | undefined +// Nearly every request here is CPU-bound generation work: a pasted model +// goes through the TypeScript compiler, a preview compiles a JSON Schema +// and generates a body. Idle, each is a few hundred milliseconds. On a +// loaded machine they scale with the load, and one test chains up to six +// of them: measured at 2 to 4 seconds per compiler call with three suites +// running alongside, which put the longest tests past the 20 second +// default. The budget below covers that chain at that load; the warm-up +// hook keeps the compiler's one-time import out of it. +vi.setConfig({ testTimeout: 60_000 }) -const config = ConfigSchema.parse({ port: 0, host: '127.0.0.1' }) -const TODO = 'export interface Todo { id: number; title: string; done: boolean }' - -beforeEach(() => { - root = mkdtempSync(join(tmpdir(), 'laqi-schema-')) - mkdirSync(join(root, 'laqi'), { recursive: true }) -}) - -afterEach(async () => { - await handle?.close() - handle = undefined - rmSync(root, { recursive: true, force: true }) -}) - -function writeMocks(contents: Record) { - writeFileSync(join(root, 'laqi', 'api.json'), JSON.stringify(contents, null, 2), 'utf8') -} +beforeAll(warmGenerationStack) -function readMocks(): Record> }> { - return JSON.parse(readFileSync(join(root, 'laqi', 'api.json'), 'utf8')) as never -} - -const send = (path: string, method: string, body?: unknown) => - fetch(`http://127.0.0.1:${handle?.port}/__laqi${path}`, { - method, - headers: { 'Content-Type': 'application/json' }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - }) +const TODO = 'export interface Todo { id: number; title: string; done: boolean }' const RESPONSE = `/api/endpoints/${encodeURIComponent('GET /todos')}/responses/ok` /** Imports a model, previews a body, and writes both onto `GET /todos`. */ -async function seedGeneratedResponse(): Promise<{ revision: string; body: unknown }> { - writeMocks({ 'GET /todos': { default: 'ok', responses: { ok: { status: 200, body: null } } } }) - handle = await startServer({ root, config }) +async function seedGeneratedResponse(lab: Lab): Promise<{ revision: string; body: unknown }> { + lab.writeMocks({ + 'GET /todos': { default: 'ok', responses: { ok: { status: 200, body: null } } }, + }) + const handle = await lab.start() - const imported = await send('/api/schema/import', 'POST', { + const imported = await lab.send('/api/schema/import', 'POST', { source: { kind: 'typescript-paste', source: TODO }, }) const { snapshot } = (await imported.json()) as { snapshot: SchemaSnapshot } - const previewed = await send('/api/schema/preview', 'POST', { snapshot, seed: 42 }) + const previewed = await lab.send('/api/schema/preview', 'POST', { snapshot, seed: 42 }) const preview = (await previewed.json()) as { body: unknown; evidence: unknown } // The seeded response has no evidence yet, so the first write confirms. - const current = readMocks()['GET /todos']?.responses['ok'] - writeMocks({ + const current = lab.readMocks()['GET /todos']?.responses['ok'] + lab.writeMocks({ 'GET /todos': { default: 'ok', responses: { @@ -70,17 +53,17 @@ async function seedGeneratedResponse(): Promise<{ revision: string; body: unknow }) handle.reload() - const regenerated = await send(`${RESPONSE}/regenerate`, 'POST', { seed: 1 }) + const regenerated = await lab.send(`${RESPONSE}/regenerate`, 'POST', { seed: 1 }) const { revision } = (await regenerated.json()) as { revision: string } return { revision, body: preview.body } } describe('POST /api/schema/import', () => { - it('returns a snapshot for a pasted model', async () => { - writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) - handle = await startServer({ root, config }) + it('returns a snapshot for a pasted model', async ({ lab }) => { + lab.writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) + await lab.start() - const res = await send('/api/schema/import', 'POST', { + const res = await lab.send('/api/schema/import', 'POST', { source: { kind: 'typescript-paste', source: TODO }, }) @@ -91,11 +74,11 @@ describe('POST /api/schema/import', () => { expect(snapshot.document['$schema']).toBe('https://json-schema.org/draft/2020-12/schema') }) - it('names the known kinds when no adapter serves the one asked for', async () => { - writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) - handle = await startServer({ root, config }) + it('names the known kinds when no adapter serves the one asked for', async ({ lab }) => { + lab.writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) + await lab.start() - const res = await send('/api/schema/import', 'POST', { source: { kind: 'protobuf' } }) + const res = await lab.send('/api/schema/import', 'POST', { source: { kind: 'protobuf' } }) expect(res.status).toBe(422) const failure = (await res.json()) as { message: string; diagnostics: { code: string }[] } @@ -103,26 +86,26 @@ describe('POST /api/schema/import', () => { expect(failure.diagnostics[0]?.code).toBe('adapter.unknown') }) - it('refuses a source that is not an object', async () => { - writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) - handle = await startServer({ root, config }) + it('refuses a source that is not an object', async ({ lab }) => { + lab.writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) + await lab.start() - expect((await send('/api/schema/import', 'POST', { source: 'a string' })).status).toBe(400) + expect((await lab.send('/api/schema/import', 'POST', { source: 'a string' })).status).toBe(400) }) }) describe('POST /api/schema/preview', () => { - it('generates from a snapshot, and the same seed repeats the body', async () => { - writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) - handle = await startServer({ root, config }) + it('generates from a snapshot, and the same seed repeats the body', async ({ lab }) => { + lab.writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) + await lab.start() - const imported = await send('/api/schema/import', 'POST', { + const imported = await lab.send('/api/schema/import', 'POST', { source: { kind: 'typescript-paste', source: TODO }, }) const { snapshot } = (await imported.json()) as { snapshot: SchemaSnapshot } - const first = await send('/api/schema/preview', 'POST', { snapshot, seed: 7 }) - const second = await send('/api/schema/preview', 'POST', { snapshot, seed: 7 }) + const first = await lab.send('/api/schema/preview', 'POST', { snapshot, seed: 7 }) + const second = await lab.send('/api/schema/preview', 'POST', { snapshot, seed: 7 }) expect(first.status).toBe(200) const one = (await first.json()) as { body: unknown; evidence: { seed: number } } @@ -131,74 +114,77 @@ describe('POST /api/schema/preview', () => { expect(one.evidence.seed).toBe(7) }) - it('refuses a snapshot that is not one', async () => { - writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) - handle = await startServer({ root, config }) + it('refuses a snapshot that is not one', async ({ lab }) => { + lab.writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) + await lab.start() - expect((await send('/api/schema/preview', 'POST', { snapshot: { nope: 1 } })).status).toBe(400) + const res = await lab.send('/api/schema/preview', 'POST', { snapshot: { nope: 1 } }) + expect(res.status).toBe(400) }) }) describe('regenerating a response', () => { - it('generates from the stored schema and reports the revision to apply against', async () => { - const seeded = await seedGeneratedResponse() + it('generates from the stored schema and reports the revision to apply against', async ({ + lab, + }) => { + const seeded = await seedGeneratedResponse(lab) - const res = await send(`${RESPONSE}/regenerate`, 'POST', { seed: 99 }) + const res = await lab.send(`${RESPONSE}/regenerate`, 'POST', { seed: 99 }) expect(res.status).toBe(200) const result = (await res.json()) as { body: unknown; revision: string } expect(result.revision).toBe(seeded.revision) // Nothing was written: regenerate is a preview. - expect(readMocks()['GET /todos']?.responses['ok']?.['body']).toEqual(seeded.body) + expect(lab.readMocks()['GET /todos']?.responses['ok']?.['body']).toEqual(seeded.body) }) - it('refuses a response with no schema instead of inferring one', async () => { - writeMocks({ + it('refuses a response with no schema instead of inferring one', async ({ lab }) => { + lab.writeMocks({ 'GET /todos': { default: 'ok', responses: { ok: { status: 200, body: { id: 1 } } } }, }) - handle = await startServer({ root, config }) + await lab.start() - const res = await send(`${RESPONSE}/regenerate`, 'POST', {}) + const res = await lab.send(`${RESPONSE}/regenerate`, 'POST', {}) expect(res.status).toBe(400) expect(((await res.json()) as { message: string }).message).toContain('no schema') }) - it('404s an endpoint or response that does not exist', async () => { - writeMocks({ 'GET /todos': { default: 'ok', responses: { ok: { status: 200 } } } }) - handle = await startServer({ root, config }) + it('404s an endpoint or response that does not exist', async ({ lab }) => { + lab.writeMocks({ 'GET /todos': { default: 'ok', responses: { ok: { status: 200 } } } }) + await lab.start() const ghost = `/api/endpoints/${encodeURIComponent('GET /todos')}/responses/ghost/regenerate` - expect((await send(ghost, 'POST', {})).status).toBe(404) + expect((await lab.send(ghost, 'POST', {})).status).toBe(404) }) }) describe('applying a generated body', () => { - it('writes the body and its evidence, and reports the new revision', async () => { - const seeded = await seedGeneratedResponse() - const regenerated = await send(`${RESPONSE}/regenerate`, 'POST', { seed: 99 }) + it('writes the body and its evidence, and reports the new revision', async ({ lab }) => { + const seeded = await seedGeneratedResponse(lab) + const regenerated = await lab.send(`${RESPONSE}/regenerate`, 'POST', { seed: 99 }) const next = (await regenerated.json()) as { body: unknown; evidence: unknown } - const res = await send(`${RESPONSE}/body`, 'PUT', { + const res = await lab.send(`${RESPONSE}/body`, 'PUT', { body: next.body, evidence: next.evidence, revision: seeded.revision, }) expect(res.status).toBe(200) - const stored = readMocks()['GET /todos']?.responses['ok'] + const stored = lab.readMocks()['GET /todos']?.responses['ok'] expect(stored?.['body']).toEqual(next.body) expect(stored?.['generation']).toEqual(next.evidence) expect(((await res.json()) as { revision: string }).revision).not.toBe(seeded.revision) }) - it('409s a revision that is no longer current, and says what it is now', async () => { - const seeded = await seedGeneratedResponse() - const regenerated = await send(`${RESPONSE}/regenerate`, 'POST', { seed: 99 }) + it('409s a revision that is no longer current, and says what it is now', async ({ lab }) => { + const seeded = await seedGeneratedResponse(lab) + const regenerated = await lab.send(`${RESPONSE}/regenerate`, 'POST', { seed: 99 }) const next = (await regenerated.json()) as { body: unknown; evidence: unknown } - await send(`${RESPONSE}/body`, 'PUT', { ...next, revision: seeded.revision }) + await lab.send(`${RESPONSE}/body`, 'PUT', { ...next, revision: seeded.revision }) - const res = await send(`${RESPONSE}/body`, 'PUT', { ...next, revision: seeded.revision }) + const res = await lab.send(`${RESPONSE}/body`, 'PUT', { ...next, revision: seeded.revision }) expect(res.status).toBe(409) const failure = (await res.json()) as { reason: string; revision: string } @@ -206,35 +192,35 @@ describe('applying a generated body', () => { expect(failure.revision).not.toBe(seeded.revision) }) - it('409s a hand-written body until the caller confirms', async () => { - writeMocks({ + it('409s a hand-written body until the caller confirms', async ({ lab }) => { + lab.writeMocks({ 'GET /todos': { default: 'ok', responses: { ok: { status: 200, body: { written: 'by hand' } } }, }, }) - handle = await startServer({ root, config }) + const handle = await lab.start() - const imported = await send('/api/schema/import', 'POST', { + const imported = await lab.send('/api/schema/import', 'POST', { source: { kind: 'typescript-paste', source: TODO }, }) const { snapshot } = (await imported.json()) as { snapshot: SchemaSnapshot } - const previewed = await send('/api/schema/preview', 'POST', { snapshot, seed: 3 }) + const previewed = await lab.send('/api/schema/preview', 'POST', { snapshot, seed: 3 }) const preview = (await previewed.json()) as { body: unknown; evidence: unknown } // No stored schema, so the revision comes from a read the panel already // has: write it once, so regenerate can report it. - writeMocks({ + lab.writeMocks({ 'GET /todos': { default: 'ok', responses: { ok: { status: 200, body: { written: 'by hand' }, schema: snapshot } }, }, }) handle.reload() - const regenerated = await send(`${RESPONSE}/regenerate`, 'POST', {}) + const regenerated = await lab.send(`${RESPONSE}/regenerate`, 'POST', {}) const { revision } = (await regenerated.json()) as { revision: string } - const refused = await send(`${RESPONSE}/body`, 'PUT', { + const refused = await lab.send(`${RESPONSE}/body`, 'PUT', { body: preview.body, evidence: preview.evidence, revision, @@ -242,9 +228,9 @@ describe('applying a generated body', () => { expect(refused.status).toBe(409) expect(((await refused.json()) as { reason: string }).reason).toBe('body-unverified') - expect(readMocks()['GET /todos']?.responses['ok']?.['body']).toEqual({ written: 'by hand' }) + expect(lab.readMocks()['GET /todos']?.responses['ok']?.['body']).toEqual({ written: 'by hand' }) - const confirmed = await send(`${RESPONSE}/body`, 'PUT', { + const confirmed = await lab.send(`${RESPONSE}/body`, 'PUT', { body: preview.body, evidence: preview.evidence, revision, @@ -252,31 +238,35 @@ describe('applying a generated body', () => { }) expect(confirmed.status).toBe(200) - expect(readMocks()['GET /todos']?.responses['ok']?.['body']).toEqual(preview.body) + expect(lab.readMocks()['GET /todos']?.responses['ok']?.['body']).toEqual(preview.body) }) - it('serves the applied body on the next request, without a restart', async () => { - const seeded = await seedGeneratedResponse() - const regenerated = await send(`${RESPONSE}/regenerate`, 'POST', { seed: 99 }) + it('serves the applied body on the next request, without a restart', async ({ lab }) => { + const seeded = await seedGeneratedResponse(lab) + const regenerated = await lab.send(`${RESPONSE}/regenerate`, 'POST', { seed: 99 }) const next = (await regenerated.json()) as { body: unknown; evidence: unknown } - await send(`${RESPONSE}/body`, 'PUT', { ...next, revision: seeded.revision }) + await lab.send(`${RESPONSE}/body`, 'PUT', { ...next, revision: seeded.revision }) - const served = await fetch(`http://127.0.0.1:${handle?.port}/todos`) + const served = await lab.get('/todos') expect(await served.json()).toEqual(next.body) }) }) describe('refreshing a schema', () => { - it('re-reads the source file, replaces the schema, and leaves the body alone', async () => { + it('re-reads the source file, replaces the schema, and leaves the body alone', async ({ + lab, + }) => { writeFileSync( - join(root, 'todo.schema.json'), + join(lab.root, 'todo.schema.json'), JSON.stringify({ type: 'object', properties: { id: { type: 'number' } } }), 'utf8', ) - writeMocks({ 'GET /todos': { default: 'ok', responses: { ok: { status: 200, body: null } } } }) - handle = await startServer({ root, config }) + lab.writeMocks({ + 'GET /todos': { default: 'ok', responses: { ok: { status: 200, body: null } } }, + }) + const handle = await lab.start() - const imported = await send('/api/schema/import', 'POST', { + const imported = await lab.send('/api/schema/import', 'POST', { source: { kind: 'json-schema', document: { type: 'object', properties: { id: { type: 'number' } } }, @@ -285,7 +275,7 @@ describe('refreshing a schema', () => { }, }) const { snapshot } = (await imported.json()) as { snapshot: SchemaSnapshot } - writeMocks({ + lab.writeMocks({ 'GET /todos': { default: 'ok', responses: { ok: { status: 200, body: { kept: true }, schema: snapshot } }, @@ -293,40 +283,40 @@ describe('refreshing a schema', () => { }) handle.reload() - const regenerated = await send(`${RESPONSE}/regenerate`, 'POST', {}) + const regenerated = await lab.send(`${RESPONSE}/regenerate`, 'POST', {}) const { revision } = (await regenerated.json()) as { revision: string } // The source changes on disk, the way a developer's types would. writeFileSync( - join(root, 'todo.schema.json'), + join(lab.root, 'todo.schema.json'), JSON.stringify({ type: 'object', properties: { id: { type: 'string' } } }), 'utf8', ) - const res = await send(`${RESPONSE}/schema/refresh`, 'POST', { revision }) + const res = await lab.send(`${RESPONSE}/schema/refresh`, 'POST', { revision }) expect(res.status).toBe(200) - const stored = readMocks()['GET /todos']?.responses['ok'] + const stored = lab.readMocks()['GET /todos']?.responses['ok'] expect(stored?.['schema']).toMatchObject({ document: { properties: { id: { type: 'string' } } }, }) expect(stored?.['body']).toEqual({ kept: true }) }) - it('says a pasted model has no source to re-read', async () => { - await seedGeneratedResponse() - const regenerated = await send(`${RESPONSE}/regenerate`, 'POST', {}) + it('says a pasted model has no source to re-read', async ({ lab }) => { + await seedGeneratedResponse(lab) + const regenerated = await lab.send(`${RESPONSE}/regenerate`, 'POST', {}) const { revision } = (await regenerated.json()) as { revision: string } - const res = await send(`${RESPONSE}/schema/refresh`, 'POST', { revision }) + const res = await lab.send(`${RESPONSE}/schema/refresh`, 'POST', { revision }) expect(res.status).toBe(400) expect(((await res.json()) as { message: string }).message).toContain('paste it again') }) - it('refuses a source that resolves outside the source root', async () => { - writeFileSync(join(root, 'outside.json'), JSON.stringify({ type: 'string' }), 'utf8') - writeMocks({ + it('refuses a source that resolves outside the source root', async ({ lab }) => { + writeFileSync(join(lab.root, 'outside.json'), JSON.stringify({ type: 'string' }), 'utf8') + lab.writeMocks({ 'GET /todos': { default: 'ok', responses: { @@ -346,15 +336,14 @@ describe('refreshing a schema', () => { }, }, }) - handle = await startServer({ - root, + await lab.start({ config: ConfigSchema.parse({ port: 0, host: '127.0.0.1', schemaSources: { root: 'laqi' } }), }) - const regenerated = await send(`${RESPONSE}/regenerate`, 'POST', {}) + const regenerated = await lab.send(`${RESPONSE}/regenerate`, 'POST', {}) const { revision } = (await regenerated.json()) as { revision: string } - const res = await send(`${RESPONSE}/schema/refresh`, 'POST', { revision }) + const res = await lab.send(`${RESPONSE}/schema/refresh`, 'POST', { revision }) expect(res.status).toBe(400) expect(((await res.json()) as { message: string }).message).toContain('outside') @@ -362,13 +351,13 @@ describe('refreshing a schema', () => { }) describe('the budgets, through a live server', () => { - it('400s a stored schema nested past the depth budget, with a real message', async () => { + it('400s a stored schema nested past the depth budget, with a real message', async ({ lab }) => { // Deep enough to clear the compiler's MAX_SHAPE_DEPTH (500) with room to // spare, but shallow enough that serialising the fixture itself does not // hit its own stack limit. let document: Record = { type: 'string' } for (let i = 0; i < 2_000; i++) document = { type: 'array', items: document } - writeMocks({ + lab.writeMocks({ 'GET /todos': { default: 'ok', responses: { @@ -385,29 +374,31 @@ describe('the budgets, through a live server', () => { }, }, }) - handle = await startServer({ root, config }) + await lab.start() - const res = await send(`${RESPONSE}/regenerate`, 'POST', {}) + const res = await lab.send(`${RESPONSE}/regenerate`, 'POST', {}) expect(res.status).toBe(400) expect(((await res.json()) as { message: string }).message).toMatch(/nests deeper|depth/i) - }, 30_000) + }) - it('400s a model that would blow the generation budget, instead of a bare 500', async () => { - writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) - handle = await startServer({ root, config }) + it('400s a model that would blow the generation budget, instead of a bare 500', async ({ + lab, + }) => { + lab.writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) + await lab.start() // string[][][] at arrayLength 100 is 100^3 = 1,000,000 leaf values. - const imported = await send('/api/schema/import', 'POST', { + const imported = await lab.send('/api/schema/import', 'POST', { source: { kind: 'typescript-paste', source: 'export interface Big { a: string[][][] }' }, }) const { snapshot } = (await imported.json()) as { snapshot: SchemaSnapshot } - const res = await send('/api/schema/preview', 'POST', { snapshot, arrayLength: 100 }) + const res = await lab.send('/api/schema/preview', 'POST', { snapshot, arrayLength: 100 }) expect(res.status).toBe(400) expect(((await res.json()) as { message: string }).message).toMatch(/more than 100000 values/) - }, 30_000) + }) }) describe('executing a project module, from the panel', () => { @@ -420,10 +411,13 @@ describe('executing a project module, from the panel', () => { * show, confirm — and making it also depend on module resolution inside a * temporary directory would only give it a second way to fail. */ - function writeTypes(properties = `{ id: { type: 'string' }, total: { type: 'number' } }`) { - mkdirSync(join(root, 'src'), { recursive: true }) + function writeTypes( + lab: Lab, + properties = `{ id: { type: 'string' }, total: { type: 'number' } }`, + ) { + mkdirSync(join(lab.root, 'src'), { recursive: true }) writeFileSync( - join(root, 'src', 'types.ts'), + join(lab.root, 'src', 'types.ts'), `export const Invoice = {\n` + ` '~standard': {\n` + ` version: 1,\n` + @@ -441,18 +435,18 @@ describe('executing a project module, from the panel', () => { const ask = { file: 'src/types.ts', exportName: 'Invoice', side: 'output' } - it('resolves and shows the file without running it, then imports on confirm', async () => { - writeTypes() - writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) - handle = await startServer({ root, config }) + it('resolves and shows the file without running it, then imports on confirm', async ({ lab }) => { + writeTypes(lab) + lab.writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) + await lab.start() - const prepared = await send('/api/schema/module/prepare', 'POST', ask) + const prepared = await lab.send('/api/schema/module/prepare', 'POST', ask) expect(prepared.status).toBe(200) const ready = (await prepared.json()) as { token: string; resolvedPath: string } expect(ready.resolvedPath).toContain('src/types.ts') expect(ready.token).toMatch(/^[0-9a-f]{64}$/) - const confirmed = await send('/api/schema/module/confirm', 'POST', { token: ready.token }) + const confirmed = await lab.send('/api/schema/module/confirm', 'POST', { token: ready.token }) expect(confirmed.status).toBe(200) const { snapshot } = (await confirmed.json()) as { snapshot: SchemaSnapshot } expect(snapshot.name).toBe('Invoice') @@ -462,85 +456,84 @@ describe('executing a project module, from the panel', () => { exportName: 'Invoice', side: 'output', }) - }, 40_000) + }) - it('refuses a second confirmation with the same token', async () => { - writeTypes() - writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) - handle = await startServer({ root, config }) + it('refuses a second confirmation with the same token', async ({ lab }) => { + writeTypes(lab) + lab.writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) + await lab.start() - const prepared = await send('/api/schema/module/prepare', 'POST', ask) + const prepared = await lab.send('/api/schema/module/prepare', 'POST', ask) const { token } = (await prepared.json()) as { token: string } - await send('/api/schema/module/confirm', 'POST', { token }) + await lab.send('/api/schema/module/confirm', 'POST', { token }) - const again = await send('/api/schema/module/confirm', 'POST', { token }) + const again = await lab.send('/api/schema/module/confirm', 'POST', { token }) expect(again.status).toBe(400) - }, 40_000) + }) - it('refuses a confirmation once the file has changed underneath it', async () => { - writeTypes() - writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) - handle = await startServer({ root, config }) + it('refuses a confirmation once the file has changed underneath it', async ({ lab }) => { + writeTypes(lab) + lab.writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) + await lab.start() - const prepared = await send('/api/schema/module/prepare', 'POST', ask) + const prepared = await lab.send('/api/schema/module/prepare', 'POST', ask) const { token } = (await prepared.json()) as { token: string } - writeTypes(`{ id: { type: 'string' } }`) + writeTypes(lab, `{ id: { type: 'string' } }`) - const confirmed = await send('/api/schema/module/confirm', 'POST', { token }) + const confirmed = await lab.send('/api/schema/module/confirm', 'POST', { token }) expect(confirmed.status).toBe(400) expect(((await confirmed.json()) as { message: string }).message).toContain('changed') - }, 40_000) + }) // The only approval that counts is one laqi handed out. A caller asserting // it has approved itself is not approval. - it('does not treat a confirmed flag as approval', async () => { - writeTypes() - writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) - handle = await startServer({ root, config }) + it('does not treat a confirmed flag as approval', async ({ lab }) => { + writeTypes(lab) + lab.writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) + await lab.start() - const res = await send('/api/schema/module/confirm', 'POST', { ...ask, confirmed: true }) + const res = await lab.send('/api/schema/module/confirm', 'POST', { ...ask, confirmed: true }) expect(res.status).toBe(400) - }, 40_000) + }) - it('sends a project module through the two-step flow, not the plain import', async () => { - writeTypes() - writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) - handle = await startServer({ root, config }) + it('sends a project module through the two-step flow, not the plain import', async ({ lab }) => { + writeTypes(lab) + lab.writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) + await lab.start() - const res = await send('/api/schema/import', 'POST', { + const res = await lab.send('/api/schema/import', 'POST', { source: { kind: 'project-module', ...ask }, }) expect(res.status).toBe(400) expect(((await res.json()) as { message: string }).message).toContain('module/prepare') - }, 40_000) + }) - it('refuses to prepare a path outside the source root', async () => { - writeTypes() - writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) - handle = await startServer({ - root, + it('refuses to prepare a path outside the source root', async ({ lab }) => { + writeTypes(lab) + lab.writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) + await lab.start({ config: ConfigSchema.parse({ port: 0, host: '127.0.0.1', schemaSources: { root: 'laqi' } }), }) - const res = await send('/api/schema/module/prepare', 'POST', { + const res = await lab.send('/api/schema/module/prepare', 'POST', { ...ask, file: '../src/types.ts', }) expect(res.status).toBe(400) expect(((await res.json()) as { message: string }).message).toContain('outside') - }, 40_000) + }) }) describe('exporting a stored schema', () => { - async function seedSchema(): Promise { - writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) - handle = await startServer({ root, config }) - const imported = await send('/api/schema/import', 'POST', { + async function seedSchema(lab: Lab): Promise { + lab.writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) + await lab.start() + const imported = await lab.send('/api/schema/import', 'POST', { source: { kind: 'typescript-paste', source: 'export interface Point { at: [number, number] }', @@ -549,10 +542,10 @@ describe('exporting a stored schema', () => { return ((await imported.json()) as { snapshot: SchemaSnapshot }).snapshot } - it('prints the schema and reports what the target could not express', async () => { - const snapshot = await seedSchema() + it('prints the schema and reports what the target could not express', async ({ lab }) => { + const snapshot = await seedSchema(lab) - const res = await send('/api/schema/export', 'POST', { snapshot }) + const res = await lab.send('/api/schema/export', 'POST', { snapshot }) expect(res.status).toBe(200) const exported = (await res.json()) as { @@ -570,95 +563,95 @@ describe('exporting a stored schema', () => { // Every other language goes through quicktype, which renders no // fixed-arity tuple, and says so. - const python = await send('/api/schema/export', 'POST', { snapshot, target: 'python' }) + const python = await lab.send('/api/schema/export', 'POST', { snapshot, target: 'python' }) const translated = (await python.json()) as { diagnostics: { code: string }[] } expect(translated.diagnostics.map((item) => item.code)).toContain('export.tuple-approximated') - }, 40_000) + }) // Export is a read. A route that both prints and writes would make copying // types a thing that changes the project. - it('writes nothing to the mock file', async () => { - const snapshot = await seedSchema() - const before = readFileSync(join(root, 'laqi', 'api.json'), 'utf8') + it('writes nothing to the mock file', async ({ lab }) => { + const snapshot = await seedSchema(lab) + const before = readFileSync(join(lab.root, 'laqi', 'api.json'), 'utf8') - await send('/api/schema/export', 'POST', { snapshot }) - await send('/api/schema/export', 'POST', { snapshot, target: 'python' }) + await lab.send('/api/schema/export', 'POST', { snapshot }) + await lab.send('/api/schema/export', 'POST', { snapshot, target: 'python' }) - expect(readFileSync(join(root, 'laqi', 'api.json'), 'utf8')).toBe(before) - }, 40_000) + expect(readFileSync(join(lab.root, 'laqi', 'api.json'), 'utf8')).toBe(before) + }) - it('lists what this build serves, and every listed target prints', async () => { - writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) - handle = await startServer({ root, config }) + it('lists what this build serves, and every listed target prints', async ({ lab }) => { + lab.writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) + await lab.start() - const res = await send('/api/schema/capabilities', 'GET') + const res = await lab.send('/api/schema/capabilities', 'GET') expect(res.status).toBe(200) const listed = (await res.json()) as { inputs: string[]; exports: { targets: string[] } } expect(listed.inputs).toContain('openapi') expect(listed.inputs).toContain('project-module') expect(listed.exports.targets).toContain('typescript') - }, 40_000) + }) }) describe('giving a response a schema it never had', () => { - it('turns a body into a model, stores it, and unblocks regenerate', async () => { - writeMocks({ + it('turns a body into a model, stores it, and unblocks regenerate', async ({ lab }) => { + lab.writeMocks({ 'GET /todos': { default: 'ok', responses: { ok: { status: 200, body: { id: 1, title: 'write it down' } } }, }, }) - handle = await startServer({ root, config }) + await lab.start() // Before: there is nothing to regenerate from, and laqi says so. - const refused = await send(`${RESPONSE}/regenerate`, 'POST', {}) + const refused = await lab.send(`${RESPONSE}/regenerate`, 'POST', {}) expect(refused.status).toBe(400) // The draft is the body as a model, keys where the body had them — not // the export route, which prints through quicktype and alphabetises. - const drafted = await send(`${RESPONSE}/model`, 'GET') + const drafted = await lab.send(`${RESPONSE}/model`, 'GET') expect(drafted.status).toBe(200) const { source, typeName } = (await drafted.json()) as { source: string; typeName: string } expect(source).toContain(`export interface ${typeName}`) expect(source.indexOf('id:')).toBeLessThan(source.indexOf('title:')) - const imported = await send('/api/schema/import', 'POST', { + const imported = await lab.send('/api/schema/import', 'POST', { source: { kind: 'typescript-paste', source, typeName }, }) const { snapshot } = (await imported.json()) as { snapshot: SchemaSnapshot } - const revision = await send(`${RESPONSE}/revision`, 'GET') + const revision = await lab.send(`${RESPONSE}/revision`, 'GET') const { revision: current } = (await revision.json()) as { revision: string } - const stored = await send(`${RESPONSE}/schema`, 'PUT', { snapshot, revision: current }) + const stored = await lab.send(`${RESPONSE}/schema`, 'PUT', { snapshot, revision: current }) expect(stored.status).toBe(200) // The body is untouched — this wrote a schema, not data. - expect(readMocks()['GET /todos']?.responses['ok']?.['body']).toEqual({ + expect(lab.readMocks()['GET /todos']?.responses['ok']?.['body']).toEqual({ id: 1, title: 'write it down', }) - expect(readMocks()['GET /todos']?.responses['ok']?.['schema']).toMatchObject({ + expect(lab.readMocks()['GET /todos']?.responses['ok']?.['schema']).toMatchObject({ source: { kind: 'typescript-paste' }, }) // After: regenerate works, and the types now come from the schema. - const allowed = await send(`${RESPONSE}/regenerate`, 'POST', { seed: 4 }) + const allowed = await lab.send(`${RESPONSE}/regenerate`, 'POST', { seed: 4 }) expect(allowed.status).toBe(200) const regenerated = (await allowed.json()) as { body: Record } expect(typeof regenerated.body['title']).toBe('string') // The order is the contract: what the sample had is what every body has. expect(Object.keys(regenerated.body)).toEqual(['id', 'title']) - }, 60_000) + }) - it('refuses a stale revision, like every other write', async () => { - writeMocks({ + it('refuses a stale revision, like every other write', async ({ lab }) => { + lab.writeMocks({ 'GET /todos': { default: 'ok', responses: { ok: { status: 200, body: { id: 1 } } } }, }) - handle = await startServer({ root, config }) + await lab.start() - const res = await send(`${RESPONSE}/schema`, 'PUT', { + const res = await lab.send(`${RESPONSE}/schema`, 'PUT', { snapshot: { name: 'Todo', document: { $schema: 'https://json-schema.org/draft/2020-12/schema', type: 'object' }, @@ -670,5 +663,5 @@ describe('giving a response a schema it never had', () => { expect(res.status).toBe(409) expect(((await res.json()) as { reason: string }).reason).toBe('stale-revision') - }, 40_000) + }) }) diff --git a/apps/cli/src/serve.test.ts b/apps/cli/src/serve.test.ts index 208659d..cbbb0a1 100644 --- a/apps/cli/src/serve.test.ts +++ b/apps/cli/src/serve.test.ts @@ -3,99 +3,81 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'nod import { tmpdir } from 'node:os' import { join } from 'node:path' import { ConfigSchema } from '@laqi/schema' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { isLoopback, startServer, type ServeHandle } from './serve' +import { beforeAll, describe, expect } from 'vitest' +import { isLoopback } from './serve' +import { it, warmGenerationStack, type Lab } from './serve.fixture' -let root: string -let handle: ServeHandle | undefined - -const config = ConfigSchema.parse({ port: 0, host: '127.0.0.1' }) - -beforeEach(() => { - root = mkdtempSync(join(tmpdir(), 'laqi-serve-')) - mkdirSync(join(root, 'laqi'), { recursive: true }) -}) - -afterEach(async () => { - await handle?.close() - handle = undefined - rmSync(root, { recursive: true, force: true }) -}) - -function writeMocks(contents: Record) { - writeFileSync(join(root, 'laqi', 'api.json'), JSON.stringify(contents), 'utf8') -} - -const get = (path: string) => fetch(`http://127.0.0.1:${handle?.port}${path}`) +const endpoint = { default: 'ok', responses: { ok: { status: 200, body: [] } } } describe('startServer', () => { - it('serves a loaded endpoint', async () => { - writeMocks({ 'GET /users': { default: 'ok', responses: { ok: { status: 200, body: [] } } } }) - handle = await startServer({ root, config }) + it('serves a loaded endpoint', async ({ lab }) => { + lab.writeMocks({ 'GET /users': endpoint }) + await lab.start() - const res = await get('/users') + const res = await lab.get('/users') expect(res.status).toBe(200) expect(res.headers.get('X-Laqi-Resolved')).toBe('ok (default)') }) - it('starts even with no mock files at all (F9)', async () => { - handle = await startServer({ root, config }) - expect((await get('/anything')).status).toBe(404) + it('starts even with no mock files at all (F9)', async ({ lab }) => { + await lab.start() + expect((await lab.get('/anything')).status).toBe(404) }) - it('picks up a new endpoint on reload, on the same port', async () => { - writeMocks({ 'GET /users': { default: 'ok', responses: { ok: { status: 200, body: [] } } } }) - handle = await startServer({ root, config }) + it('picks up a new endpoint on reload, on the same port', async ({ lab }) => { + lab.writeMocks({ 'GET /users': endpoint }) + const handle = await lab.start() const port = handle.port - expect((await get('/orders')).status).toBe(404) + expect((await lab.get('/orders')).status).toBe(404) - writeMocks({ - 'GET /users': { default: 'ok', responses: { ok: { status: 200, body: [] } } }, - 'GET /orders': { default: 'ok', responses: { ok: { status: 200, body: [] } } }, - }) + lab.writeMocks({ 'GET /users': endpoint, 'GET /orders': endpoint }) handle.reload() expect(handle.port).toBe(port) - expect((await get('/orders')).status).toBe(200) + expect((await lab.get('/orders')).status).toBe(200) }) - it('survives rapid consecutive reloads without EADDRINUSE (v1 defect H)', async () => { - writeMocks({ 'GET /users': { default: 'ok', responses: { ok: { status: 200, body: [] } } } }) - handle = await startServer({ root, config }) + it('survives rapid consecutive reloads without EADDRINUSE (v1 defect H)', async ({ lab }) => { + lab.writeMocks({ 'GET /users': endpoint }) + const handle = await lab.start() for (let i = 0; i < 10; i++) handle.reload() - expect((await get('/users')).status).toBe(200) + expect((await lab.get('/users')).status).toBe(200) }) - it('keeps serving valid files when one is broken (H3)', async () => { - writeMocks({ 'GET /users': { default: 'ok', responses: { ok: { status: 200, body: [] } } } }) - writeFileSync(join(root, 'laqi', 'broken.json'), '{ nope', 'utf8') - handle = await startServer({ root, config }) + it('keeps serving valid files when one is broken (H3)', async ({ lab }) => { + lab.writeMocks({ 'GET /users': endpoint }) + writeFileSync(join(lab.root, 'laqi', 'broken.json'), '{ nope', 'utf8') + const handle = await lab.start() - expect((await get('/users')).status).toBe(200) + expect((await lab.get('/users')).status).toBe(200) expect(handle.current().errors).toHaveLength(1) }) - it('exposes load errors through the handle for the panel to render', async () => { - writeFileSync(join(root, 'laqi', 'broken.json'), '{\n "a": 1,\n}\n', 'utf8') - handle = await startServer({ root, config }) + it('exposes load errors through the handle for the panel to render', async ({ lab }) => { + writeFileSync(join(lab.root, 'laqi', 'broken.json'), '{\n "a": 1,\n}\n', 'utf8') + const handle = await lab.start() const [error] = handle.current().errors expect(error?.file).toContain('broken.json') expect(error?.excerpt).toContain('^') }) - it('rejects instead of hanging forever when the port is already in use (C3)', async () => { - handle = await startServer({ root, config }) + // This used to carry a 5 second budget of its own, to prove the startup + // rejects rather than hangs. The file's default proves the same thing, + // and two startups on a loaded machine can take longer than 5 seconds + // without anything being wrong. + it('rejects instead of hanging forever when the port is already in use (C3)', async ({ lab }) => { + const handle = await lab.start() const busyPort = handle.port const otherRoot = mkdtempSync(join(tmpdir(), 'laqi-serve-')) mkdirSync(join(otherRoot, 'laqi'), { recursive: true }) try { await expect( - startServer({ + lab.start({ root: otherRoot, config: ConfigSchema.parse({ port: busyPort, host: '127.0.0.1' }), }), @@ -103,108 +85,97 @@ describe('startServer', () => { } finally { rmSync(otherRoot, { recursive: true, force: true }) } - }, 5000) + }) }) describe('control plane, mounted under /__laqi', () => { - it('lists the loaded endpoints', async () => { - writeMocks({ 'GET /users': { default: 'ok', responses: { ok: { status: 200, body: [] } } } }) - handle = await startServer({ root, config }) + it('lists the loaded endpoints', async ({ lab }) => { + lab.writeMocks({ 'GET /users': endpoint }) + await lab.start() - const res = await get('/__laqi/api/endpoints') + const res = await lab.get('/__laqi/api/endpoints') expect(res.status).toBe(200) const body = (await res.json()) as { id: string }[] expect(body.map((e) => e.id)).toEqual(['GET /users']) }) - it('flips the live response via PUT /api/state, and the mock reflects it immediately', async () => { - writeMocks({ + it('flips the live response via PUT /api/state, and the mock reflects it immediately', async ({ + lab, + }) => { + lab.writeMocks({ 'GET /users': { default: 'ok', responses: { ok: { status: 200, body: [] }, boom: { status: 500, body: {} } }, }, }) - handle = await startServer({ root, config }) + await lab.start() - const put = await fetch(`http://127.0.0.1:${handle!.port}/__laqi/api/state`, { - method: 'PUT', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ scenario: null, overrides: { 'GET /users': 'boom' } }), + const put = await lab.send('/api/state', 'PUT', { + scenario: null, + overrides: { 'GET /users': 'boom' }, }) expect(put.status).toBe(200) - const res = await get('/users') + const res = await lab.get('/users') expect(res.status).toBe(500) expect(res.headers.get('X-Laqi-Resolved')).toBe('boom (state)') }) - it('creates an endpoint via POST, and it is immediately servable — no restart, no wait for the watcher', async () => { - writeMocks({ 'GET /users': { default: 'ok', responses: { ok: { status: 200, body: [] } } } }) - handle = await startServer({ root, config }) + it('creates an endpoint via POST, and it is immediately servable — no restart, no wait for the watcher', async ({ + lab, + }) => { + lab.writeMocks({ 'GET /users': endpoint }) + await lab.start() - const post = await fetch(`http://127.0.0.1:${handle!.port}/__laqi/api/endpoints`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - method: 'GET', - path: '/orders', - default: 'ok', - responses: { ok: { status: 200, body: [] } }, - }), + const post = await lab.send('/api/endpoints', 'POST', { + method: 'GET', + path: '/orders', + default: 'ok', + responses: { ok: { status: 200, body: [] } }, }) expect(post.status).toBe(201) - const res = await get('/orders') + const res = await lab.get('/orders') expect(res.status).toBe(200) }) - it('updates an endpoint via PUT, immediately reflected', async () => { - writeMocks({ + it('updates an endpoint via PUT, immediately reflected', async ({ lab }) => { + lab.writeMocks({ 'GET /users': { default: 'ok', responses: { ok: { status: 200, body: [{ id: 1 }] } } }, }) - handle = await startServer({ root, config }) - - const put = await fetch( - `http://127.0.0.1:${handle!.port}/__laqi/api/endpoints/${encodeURIComponent('GET /users')}`, - { - method: 'PUT', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ default: 'ok', responses: { ok: { status: 200, body: [] } } }), - }, - ) + await lab.start() + + const put = await lab.send(`/api/endpoints/${encodeURIComponent('GET /users')}`, 'PUT', { + default: 'ok', + responses: { ok: { status: 200, body: [] } }, + }) expect(put.status).toBe(200) - const res = await get('/users') + const res = await lab.get('/users') expect(await res.json()).toEqual([]) }) - it('deletes an endpoint via DELETE, immediately gone', async () => { - writeMocks({ - 'GET /users': { default: 'ok', responses: { ok: { status: 200, body: [] } } }, - 'GET /orders': { default: 'ok', responses: { ok: { status: 200, body: [] } } }, - }) - handle = await startServer({ root, config }) + it('deletes an endpoint via DELETE, immediately gone', async ({ lab }) => { + lab.writeMocks({ 'GET /users': endpoint, 'GET /orders': endpoint }) + await lab.start() - const del = await fetch( - `http://127.0.0.1:${handle!.port}/__laqi/api/endpoints/${encodeURIComponent('GET /orders')}`, - { method: 'DELETE' }, - ) + const del = await lab.send(`/api/endpoints/${encodeURIComponent('GET /orders')}`, 'DELETE') expect(del.status).toBe(204) - expect((await get('/orders')).status).toBe(404) - expect((await get('/users')).status).toBe(200) + expect((await lab.get('/orders')).status).toBe(404) + expect((await lab.get('/users')).status).toBe(200) }) - it('streams a request event over SSE when a mock is hit', async () => { - writeMocks({ 'GET /users': { default: 'ok', responses: { ok: { status: 200, body: [] } } } }) - handle = await startServer({ root, config }) + it('streams a request event over SSE when a mock is hit', async ({ lab }) => { + lab.writeMocks({ 'GET /users': endpoint }) + await lab.start() - const sse = await fetch(`http://127.0.0.1:${handle!.port}/__laqi/events`) + const sse = await lab.get('/__laqi/events') const reader = sse.body!.getReader() const decoder = new TextDecoder() await new Promise((resolve) => setTimeout(resolve, 20)) - await get('/users') + await lab.get('/users') const { value } = await reader.read() const text = decoder.decode(value) @@ -214,59 +185,47 @@ describe('control plane, mounted under /__laqi', () => { await reader.cancel() }) - it('a mock endpoint can never be created under the reserved /__laqi prefix', async () => { - handle = await startServer({ root, config }) + it('a mock endpoint can never be created under the reserved /__laqi prefix', async ({ lab }) => { + await lab.start() - const post = await fetch(`http://127.0.0.1:${handle!.port}/__laqi/api/endpoints`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - method: 'GET', - path: '/__laqi/panel', - default: 'ok', - responses: { ok: { status: 200, body: {} } }, - }), + const post = await lab.send('/api/endpoints', 'POST', { + method: 'GET', + path: '/__laqi/panel', + default: 'ok', + responses: { ok: { status: 200, body: {} } }, }) // The create route explicitly rejects the reserved prefix BEFORE // writing anything — no LOAD FAILED to clean up by hand, no file // contaminated with a dead entry. expect(post.status).toBe(400) - const status = await (await get('/__laqi/api/status')).json() + const status = await (await lab.get('/__laqi/api/status')).json() expect((status as { errors: unknown[] }).errors).toHaveLength(0) - expect((await get('/__laqi/panel')).status).not.toBe(200) + expect((await lab.get('/__laqi/panel')).status).not.toBe(200) }) - it('rejects creating an id that already exists in a DIFFERENT mock file, without touching the existing endpoint (cross-file duplicate)', async () => { + it('rejects creating an id that already exists in a DIFFERENT mock file, without touching the existing endpoint (cross-file duplicate)', async ({ + lab, + }) => { // Folder mode: every new endpoint goes to laqi/api.json, so an id that // already exists in laqi/users.json must be rejected BEFORE writing — // otherwise buildRouteTable sees the cross-file duplicate and drops // BOTH sides, killing the endpoint that already worked. - writeFileSync( - join(root, 'laqi', 'users.json'), - JSON.stringify({ - 'GET /users': { default: 'ok', responses: { ok: { status: 200, body: [] } } }, - }), - 'utf8', - ) - handle = await startServer({ root, config }) + lab.writeMocks({ 'GET /users': endpoint }, 'users.json') + await lab.start() - expect((await get('/users')).status).toBe(200) + expect((await lab.get('/users')).status).toBe(200) - const post = await fetch(`http://127.0.0.1:${handle!.port}/__laqi/api/endpoints`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - method: 'GET', - path: '/users', - default: 'ok', - responses: { ok: { status: 200, body: [] } }, - }), + const post = await lab.send('/api/endpoints', 'POST', { + method: 'GET', + path: '/users', + default: 'ok', + responses: { ok: { status: 200, body: [] } }, }) expect(post.status).toBe(409) - expect((await get('/users')).status).toBe(200) - const status = (await (await get('/__laqi/api/status')).json()) as { + expect((await lab.get('/users')).status).toBe(200) + const status = (await (await lab.get('/__laqi/api/status')).json()) as { endpointCount: number errors: unknown[] } @@ -276,19 +235,20 @@ describe('control plane, mounted under /__laqi', () => { }) describe('control plane mount is restricted to loopback hosts', () => { - it('does not mount /__laqi on a non-loopback host — falls through to the mock app 404', async () => { - writeMocks({ 'GET /users': { default: 'ok', responses: { ok: { status: 200, body: [] } } } }) + it('does not mount /__laqi on a non-loopback host — falls through to the mock app 404', async ({ + lab, + }) => { + lab.writeMocks({ 'GET /users': endpoint }) // The mount check is purely on the config.host string, not on the // actual binding — 0.0.0.0 with an ephemeral port is still reachable // via 127.0.0.1 (0.0.0.0 listens on every interface, loopback // included), so we don't need a real LAN IP to test this. - const nonLoopbackConfig = ConfigSchema.parse({ port: 0, host: '0.0.0.0' }) - handle = await startServer({ root, config: nonLoopbackConfig }) + await lab.start({ config: ConfigSchema.parse({ port: 0, host: '0.0.0.0' }) }) - const controlPlaneRes = await fetch(`http://127.0.0.1:${handle.port}/__laqi/api/endpoints`) + const controlPlaneRes = await lab.get('/__laqi/api/endpoints') expect(controlPlaneRes.status).toBe(404) - const mockRes = await fetch(`http://127.0.0.1:${handle.port}/users`) + const mockRes = await lab.get('/users') expect(mockRes.status).toBe(200) }) }) @@ -301,22 +261,24 @@ describe('startServer with --share (H1)', () => { ...over, }) - const publicGet = (path: string, init?: RequestInit) => - fetch(`http://127.0.0.1:${handle?.publicPort}${path}`, init) + const publicGet = (lab: Lab, path: string, init?: RequestInit) => + fetch(`http://127.0.0.1:${lab.handle?.publicPort}${path}`, init) const auth = { Authorization: 'Bearer testtoken' } - it('serves mocks on the public port only with a token', async () => { - writeMocks({ 'GET /users': { default: 'ok', responses: { ok: { status: 200, body: [] } } } }) - handle = await startServer({ root, config, share: shared() }) + it('serves mocks on the public port only with a token', async ({ lab }) => { + lab.writeMocks({ 'GET /users': endpoint }) + await lab.start({ share: shared() }) - expect((await publicGet('/users', { headers: auth })).status).toBe(200) - expect((await publicGet('/users')).status).toBe(401) + expect((await publicGet(lab, '/users', { headers: auth })).status).toBe(200) + expect((await publicGet(lab, '/users')).status).toBe(401) }) - it('never exposes the control plane on the public port, even with a valid token', async () => { - writeMocks({ 'GET /users': { default: 'ok', responses: { ok: { status: 200 } } } }) - handle = await startServer({ root, config, share: shared() }) + it('never exposes the control plane on the public port, even with a valid token', async ({ + lab, + }) => { + lab.writeMocks({ 'GET /users': { default: 'ok', responses: { ok: { status: 200 } } } }) + await lab.start({ share: shared() }) for (const path of [ '/__laqi', @@ -324,19 +286,21 @@ describe('startServer with --share (H1)', () => { '/__laqi/api/status', '/__laqi/events', ]) { - expect((await publicGet(path, { headers: auth })).status, `public ${path}`).toBe(404) + expect((await publicGet(lab, path, { headers: auth })).status, `public ${path}`).toBe(404) } // But it does on the local port — which is exactly the point of having two. - expect((await get('/__laqi/api/status')).status).toBe(200) + expect((await lab.get('/__laqi/api/status')).status).toBe(200) }) - it('leaves the mock files byte-identical after a write attempt through the tunnel', async () => { - writeMocks({ 'GET /users': { default: 'ok', responses: { ok: { status: 200 } } } }) - handle = await startServer({ root, config, share: shared() }) - const before = readFileSync(join(root, 'laqi', 'api.json'), 'utf8') + it('leaves the mock files byte-identical after a write attempt through the tunnel', async ({ + lab, + }) => { + lab.writeMocks({ 'GET /users': { default: 'ok', responses: { ok: { status: 200 } } } }) + await lab.start({ share: shared() }) + const before = readFileSync(join(lab.root, 'laqi', 'api.json'), 'utf8') - const res = await publicGet('/__laqi/api/endpoints', { + const res = await publicGet(lab, '/__laqi/api/endpoints', { method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -348,15 +312,17 @@ describe('startServer with --share (H1)', () => { }) expect(res.status).toBe(404) - expect(readFileSync(join(root, 'laqi', 'api.json'), 'utf8')).toBe(before) + expect(readFileSync(join(lab.root, 'laqi', 'api.json'), 'utf8')).toBe(before) }) - it('reports the share state to the panel, including the H1 guarantee in words', async () => { - writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) - handle = await startServer({ root, config, share: shared() }) + it('reports the share state to the panel, including the H1 guarantee in words', async ({ + lab, + }) => { + lab.writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) + const handle = await lab.start({ share: shared() }) handle.setShareUrl('https://shy-forest-1234.trycloudflare.com') - const status = (await (await get('/__laqi/api/status')).json()) as { + const status = (await (await lab.get('/__laqi/api/status')).json()) as { share: { url: string; token: string; exposed: string } } expect(status.share).toMatchObject({ @@ -366,54 +332,51 @@ describe('startServer with --share (H1)', () => { expect(status.share.exposed).toContain('not exposed') }) - it('says share is null when --share was not asked for', async () => { - writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) - handle = await startServer({ root, config }) + it('says share is null when --share was not asked for', async ({ lab }) => { + lab.writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) + await lab.start() - const status = (await (await get('/__laqi/api/status')).json()) as { share: unknown } + const status = (await (await lab.get('/__laqi/api/status')).json()) as { share: unknown } expect(status.share).toBeNull() }) - it('keeps the tunnel surface correct across a hot reload', async () => { - writeMocks({ 'GET /a': { default: 'ok', responses: { ok: { status: 200 } } } }) - handle = await startServer({ root, config, share: shared() }) - expect((await publicGet('/a', { headers: auth })).status).toBe(200) + it('keeps the tunnel surface correct across a hot reload', async ({ lab }) => { + lab.writeMocks({ 'GET /a': { default: 'ok', responses: { ok: { status: 200 } } } }) + const handle = await lab.start({ share: shared() }) + expect((await publicGet(lab, '/a', { headers: auth })).status).toBe(200) - writeMocks({ + lab.writeMocks({ 'GET /a': { default: 'ok', responses: { ok: { status: 200 } } }, 'GET /b': { default: 'ok', responses: { ok: { status: 201 } } }, }) handle.reload() - expect((await publicGet('/b', { headers: auth })).status).toBe(201) + expect((await publicGet(lab, '/b', { headers: auth })).status).toBe(201) // And the guarantee still holds after rebuilding the app. - expect((await publicGet('/__laqi/api/status', { headers: auth })).status).toBe(404) + expect((await publicGet(lab, '/__laqi/api/status', { headers: auth })).status).toBe(404) }) - it('never answers a wildcard CORS through the tunnel', async () => { - writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) - handle = await startServer({ - root, - config, - share: shared({ origins: ['https://app.example.com'] }), - }) + it('never answers a wildcard CORS through the tunnel', async ({ lab }) => { + lab.writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) + await lab.start({ share: shared({ origins: ['https://app.example.com'] }) }) - const allowed = await publicGet('/x', { + const allowed = await publicGet(lab, '/x', { headers: { ...auth, Origin: 'https://app.example.com' }, }) expect(allowed.headers.get('Access-Control-Allow-Origin')).toBe('https://app.example.com') - const evil = await publicGet('/x', { headers: { ...auth, Origin: 'https://evil.example' } }) + const evil = await publicGet(lab, '/x', { + headers: { ...auth, Origin: 'https://evil.example' }, + }) expect(evil.headers.get('Access-Control-Allow-Origin')).not.toBe('*') }) - it('closes both listeners', async () => { - writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) - handle = await startServer({ root, config, share: shared({ token: null }) }) + it('closes both listeners', async ({ lab }) => { + lab.writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) + const handle = await lab.start({ share: shared({ token: null }) }) const publicPort = handle.publicPort! await handle.close() - handle = undefined await expect(fetch(`http://127.0.0.1:${publicPort}/x`)).rejects.toThrow() }) @@ -422,51 +385,42 @@ describe('startServer with --share (H1)', () => { describe('the control plane and the MCP server share one implementation', () => { // These used to be two copies that had already diverged. These tests // pin the two rules the control plane's copy was missing. - it('refuses a path the loader would reject, instead of writing a dead endpoint', async () => { - writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) - handle = await startServer({ root, config }) - const before = readFileSync(join(root, 'laqi', 'api.json'), 'utf8') + it('refuses a path the loader would reject, instead of writing a dead endpoint', async ({ + lab, + }) => { + lab.writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) + await lab.start() + const before = readFileSync(join(lab.root, 'laqi', 'api.json'), 'utf8') for (const path of ['/my orders', '/../evil']) { - const res = await fetch(`http://127.0.0.1:${handle.port}/__laqi/api/endpoints`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - method: 'GET', - path, - default: 'ok', - responses: { ok: { status: 200 } }, - }), + const res = await lab.send('/api/endpoints', 'POST', { + method: 'GET', + path, + default: 'ok', + responses: { ok: { status: 200 } }, }) expect(res.status, path).not.toBe(201) } - expect(readFileSync(join(root, 'laqi', 'api.json'), 'utf8')).toBe(before) + expect(readFileSync(join(lab.root, 'laqi', 'api.json'), 'utf8')).toBe(before) // And no error band shows up: nothing broken was ever written. - const status = (await (await get('/__laqi/api/status')).json()) as { errors: unknown[] } + const status = (await (await lab.get('/__laqi/api/status')).json()) as { errors: unknown[] } expect(status.errors).toEqual([]) }) - it('drops the override when an endpoint is deleted through the panel', async () => { - writeMocks({ + it('drops the override when an endpoint is deleted through the panel', async ({ lab }) => { + lab.writeMocks({ 'GET /users': { default: 'ok', responses: { ok: { status: 200 }, boom: { status: 500 } } }, }) - handle = await startServer({ root, config }) + await lab.start() - await fetch(`http://127.0.0.1:${handle.port}/__laqi/api/state`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ scenario: null, overrides: { 'GET /users': 'boom' } }), - }) + await lab.send('/api/state', 'PUT', { scenario: null, overrides: { 'GET /users': 'boom' } }) - const deleted = await fetch( - `http://127.0.0.1:${handle.port}/__laqi/api/endpoints/${encodeURIComponent('GET /users')}`, - { method: 'DELETE' }, - ) + const deleted = await lab.send(`/api/endpoints/${encodeURIComponent('GET /users')}`, 'DELETE') expect(deleted.status).toBe(204) // Without this, recreating the endpoint later revives it serving "boom". - const state = (await (await get('/__laqi/api/state')).json()) as { + const state = (await (await lab.get('/__laqi/api/state')).json()) as { overrides: Record } expect(state.overrides).toEqual({}) @@ -474,26 +428,26 @@ describe('the control plane and the MCP server share one implementation', () => }) describe('the address the panel shows', () => { - it('reports the port actually bound, not the configured one', async () => { + it('reports the port actually bound, not the configured one', async ({ lab }) => { // config.port is 0 in every one of these tests: the OS assigns the // real one. The panel used to show "127.0.0.1:0" and the curl it // offered to copy would fail. - writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) - handle = await startServer({ root, config }) + lab.writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) + const handle = await lab.start() - const status = (await (await get('/__laqi/api/status')).json()) as { address: string } + const status = (await (await lab.get('/__laqi/api/status')).json()) as { address: string } expect(status.address).toBe(`127.0.0.1:${handle.port}`) expect(status.address).not.toContain(':0') }) }) describe('close() with a live SSE client', () => { - it('resolves instead of hanging forever', async () => { - writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) - const local = await startServer({ root, config }) + it('resolves instead of hanging forever', async ({ lab }) => { + lab.writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) + const local = await lab.start() // A panel tab left open: the /events stream doesn't end on its own. - const res = await fetch(`http://127.0.0.1:${local.port}/__laqi/events`) + const res = await lab.get('/__laqi/events') res .body!.getReader() .read() @@ -507,11 +461,11 @@ describe('close() with a live SSE client', () => { }) describe('the panel is mounted on every loopback address', () => { - it('mounts on ::1, not only 127.0.0.1 and localhost', async () => { + it('mounts on ::1, not only 127.0.0.1 and localhost', async ({ lab }) => { // `--host ::1` is loopback: it exposes nothing to the network. Leaving // it out silently disabled the panel and made it look broken. - writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) - handle = await startServer({ root, config: ConfigSchema.parse({ port: 0, host: '::1' }) }) + lab.writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) + const handle = await lab.start({ config: ConfigSchema.parse({ port: 0, host: '::1' }) }) const res = await fetch(`http://[::1]:${handle.port}/__laqi/api/status`) expect(res.status).toBe(200) @@ -528,46 +482,37 @@ describe('the panel is mounted on every loopback address', () => { }) describe('when the share listener cannot bind', () => { - it('does not leave the main listener running behind a thrown error', async () => { - writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) + it('does not leave the main listener running behind a thrown error', async ({ lab }) => { + lab.writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) // A free and KNOWN port for the main listener: if a failed startup // leaves it open, the second attempt collides with itself. - const probe = await startServer({ root, config: ConfigSchema.parse({ port: 0 }) }) + const probe = await lab.start({ config: ConfigSchema.parse({ port: 0 }) }) const mainPort = probe.port await probe.close() // And a busy port so the tunnel listener fails. - const blocker = await startServer({ root, config: ConfigSchema.parse({ port: 0 }) }) + const blocker = await lab.start({ config: ConfigSchema.parse({ port: 0 }) }) - try { - await expect( - startServer({ - root, - config: ConfigSchema.parse({ port: mainPort }), - share: { port: blocker.port, token: null, origins: [] }, - }), - ).rejects.toThrow() + await expect( + lab.start({ + config: ConfigSchema.parse({ port: mainPort }), + share: { port: blocker.port, token: null, origins: [] }, + }), + ).rejects.toThrow() - // If the main one stayed hanging open, this startup throws - // EADDRINUSE. The real process, moreover, would never terminate: - // the orphan listener keeps the event loop alive after saying it failed. - handle = await startServer({ root, config: ConfigSchema.parse({ port: mainPort }) }) - expect((await fetch(`http://127.0.0.1:${mainPort}/x`)).status).toBe(200) - } finally { - await blocker.close() - } + // If the main one stayed hanging open, this startup throws + // EADDRINUSE. The real process, moreover, would never terminate: + // the orphan listener keeps the event loop alive after saying it failed. + await lab.start({ config: ConfigSchema.parse({ port: mainPort }) }) + expect((await fetch(`http://127.0.0.1:${mainPort}/x`)).status).toBe(200) }) }) describe('the rate limiter survives a reload', () => { - it('does not hand a limited client its quota back when a file is saved', async () => { - writeMocks({ 'GET /ping': { default: 'ok', responses: { ok: { status: 200 } } } }) - handle = await startServer({ - root, - config, - share: { port: 0, token: null, origins: [] }, - }) + it('does not hand a limited client its quota back when a file is saved', async ({ lab }) => { + lab.writeMocks({ 'GET /ping': { default: 'ok', responses: { ok: { status: 200 } } } }) + const handle = await lab.start({ share: { port: 0, token: null, origins: [] } }) const url = `http://127.0.0.1:${handle.publicPort}/ping` const headers = { 'CF-Connecting-IP': '203.0.113.9' } @@ -586,60 +531,55 @@ describe('the rate limiter survives a reload', () => { }) describe('which listener failed', () => { - it('marks a share-listener failure so the CLI blames the right port', async () => { - writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) - const blocker = await startServer({ root, config }) - - try { - // The tunnel port is busy; the main one is free. - const error = await startServer({ - root, - config, - share: { port: blocker.port, token: null, origins: [] }, - }).then( - () => null, - (thrown: unknown) => thrown, - ) + it('marks a share-listener failure so the CLI blames the right port', async ({ lab }) => { + lab.writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) + const blocker = await lab.start() + + // The tunnel port is busy; the main one is free. + const error = await lab.start({ share: { port: blocker.port, token: null, origins: [] } }).then( + () => null, + (thrown: unknown) => thrown, + ) - expect((error as { laqiListener?: string }).laqiListener).toBe('share') - } finally { - await blocker.close() - } + expect((error as { laqiListener?: string }).laqiListener).toBe('share') }) - it('leaves a main-listener failure unmarked, so it blames --port', async () => { - writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) - const blocker = await startServer({ root, config }) + it('leaves a main-listener failure unmarked, so it blames --port', async ({ lab }) => { + lab.writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) + const blocker = await lab.start() - try { - const error = await startServer({ - root, + const error = await lab + .start({ // Now it's the main one that's busy. config: ConfigSchema.parse({ port: blocker.port }), share: { port: 0, token: null, origins: [] }, - }).then( + }) + .then( () => null, (thrown: unknown) => thrown, ) - expect((error as { laqiListener?: string }).laqiListener).toBeUndefined() - } finally { - await blocker.close() - } + expect((error as { laqiListener?: string }).laqiListener).toBeUndefined() }) }) describe('generation through a live server', () => { - it('derives types from the live response body', async () => { - writeMocks({ + // The types route is the one request in this file that loads the + // generation stack and the TypeScript compiler. That import used to be + // charged to the first test below, behind a 30 second budget of its own; + // under load it alone was measured at 13 to 16 seconds. + beforeAll(warmGenerationStack) + + it('derives types from the live response body', async ({ lab }) => { + lab.writeMocks({ 'GET /users': { default: 'ok', responses: { ok: { status: 200, body: [{ id: 1, name: 'Ada' }] } }, }, }) - handle = await startServer({ root, config }) + await lab.start() - const res = await get( + const res = await lab.get( `/__laqi/api/endpoints/${encodeURIComponent('GET /users')}/types?response=ok`, ) expect(res.status).toBe(200) @@ -647,12 +587,14 @@ describe('generation through a live server', () => { expect(language).toBe('typescript') expect(code).toContain('id') expect(code).toContain('name') - }, 30_000) + }) - it('404s types for an endpoint or response that does not exist', async () => { - writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) - handle = await startServer({ root, config }) - expect((await get('/__laqi/api/endpoints/GET%20%2Fnope/types')).status).toBe(404) - expect((await get('/__laqi/api/endpoints/GET%20%2Fx/types?response=ghost')).status).toBe(404) + it('404s types for an endpoint or response that does not exist', async ({ lab }) => { + lab.writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) + await lab.start() + expect((await lab.get('/__laqi/api/endpoints/GET%20%2Fnope/types')).status).toBe(404) + expect((await lab.get('/__laqi/api/endpoints/GET%20%2Fx/types?response=ghost')).status).toBe( + 404, + ) }) }) diff --git a/vitest.config.ts b/vitest.config.ts index 84b3035..ca7cbae 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -12,12 +12,12 @@ export default defineConfig({ // already hard-codes 30s inline for exactly that reason, and // `effect.test.ts` times out at 5s on a cold module cache. // - // This reduces the suite's flakiness rather than removing it. Under load - // the run still fails a varying handful of tests, always on time and - // never on an assertion, including worker RPC timeouts that no test - // budget covers. That was reproduced on main before any of this branch's - // work, and the MCP stdio suite pins its own 30s hook timeout inline, so - // the value below does not reach it. Diagnosing that is its own task. + // Under load (several suites running at once, as parallel worktrees + // do), the compiler-bound requests scale with the load rather than with + // the test, so the suites that chain several of them set their own + // budget and warm the compiler in a hook: see `apps/cli/src/serve.fixture.ts` + // for the measurements. The MCP stdio suite pins its own 30s hook + // timeout inline, so the value below does not reach it. testTimeout: 20_000, hookTimeout: 60_000, },