diff --git a/packages/ai/src/fetch-seam.ts b/packages/ai/src/fetch-seam.ts new file mode 100644 index 00000000..f915834a --- /dev/null +++ b/packages/ai/src/fetch-seam.ts @@ -0,0 +1,15 @@ +// Single responsibility: the one injectable HTTP call every transport in this package takes. +// +// Shared by all three rather than declared three times: both chat providers and the embedder hand +// a URL and a `RequestInit` to something that answers a `Response`, and three separate spellings +// of that is three places a test double has to be kept assignable to. + +/** + * Just the call. `typeof fetch` also carries `preconnect`, which no test double should have to — + * and none can supply, so every fake written against `typeof fetch` here needed + * `as unknown as typeof fetch` to compile: an option no caller could fill without a double cast. + * + * The same seam `@ultimat3/cache` (`PurgeFetch`), `@ultimat3/auth` (`OAuthFetch`), + * `@ultimat3/mail` (`MailFetch`) and `@ultimat3/scraping` (`ScrapeFetch`) already name. + */ +export type AiFetch = (input: string, init: RequestInit) => Promise; diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 2de4d44f..df29ee94 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -91,6 +91,7 @@ export { promptsWithoutEvals, resetEvals, } from './evals'; +export type { AiFetch } from './fetch-seam'; export type { CreateGatewayInput, Gateway, GatewayCache, RetryPolicy } from './gateway'; export { backoffMs, cacheKeyFor, createGateway, DEFAULT_RETRY, isRetryable } from './gateway'; export type { HiveDef, HiveSplitArgs } from './hive'; diff --git a/packages/ai/src/openai-provider.test.ts b/packages/ai/src/openai-provider.test.ts index 953b39d4..23b14e5a 100644 --- a/packages/ai/src/openai-provider.test.ts +++ b/packages/ai/src/openai-provider.test.ts @@ -13,6 +13,7 @@ import { beforeEach, describe, expect, test } from 'bun:test'; import { anonymousCtx, t } from '@ultimat3/action'; import { secret } from '@ultimat3/core'; import { allow } from '@ultimat3/policy'; +import type { AiFetch } from './fetch-seam'; import { createGateway } from './gateway'; import { llm } from './llm'; import { modelSpec } from './models'; @@ -32,16 +33,16 @@ interface Call { } /** Records what left the process and replies with whatever the test wants back. */ -function fakeFetch(calls: Call[], reply: (call: Call, index: number) => Response): typeof fetch { - const impl = async (input: unknown, init?: RequestInit): Promise => { - calls.push({ - url: String(input), - headers: { ...(init?.headers as Record | undefined) }, - body: JSON.parse(String(init?.body ?? '{}')) as Record, - }); - return reply(calls[calls.length - 1] as Call, calls.length - 1); +function fakeFetch(calls: Call[], reply: (call: Call, index: number) => Response): AiFetch { + return async (input, init) => { + const call: Call = { + url: input, + headers: { ...(init.headers as Record | undefined) }, + body: JSON.parse(String(init.body ?? '{}')) as Record, + }; + calls.push(call); + return reply(call, calls.length - 1); }; - return impl as unknown as typeof fetch; } const jsonResponse = (body: unknown, status = 200): Response => diff --git a/packages/ai/src/openai-provider.ts b/packages/ai/src/openai-provider.ts index fcc0bbe8..65579f27 100644 --- a/packages/ai/src/openai-provider.ts +++ b/packages/ai/src/openai-provider.ts @@ -11,6 +11,7 @@ import type { Secret } from '@ultimat3/core'; import { isSecret, revealSecret } from '@ultimat3/core'; import { detailOf, withoutKey } from './error-body'; import { AiKeyMissingError, AiRequestInvalidError, AiTransportError } from './errors'; +import type { AiFetch } from './fetch-seam'; import type { ModelId } from './models'; import { chatCompletionBody } from './openai-body'; // Imported for its registration side effect: a provider that cannot price what it serves throws @@ -66,7 +67,7 @@ export interface OpenAiProviderInput { */ readonly name?: string; /** Injectable so a test can assert the request body without a network. Defaults to `fetch`. */ - readonly fetch?: typeof fetch; + readonly fetch?: AiFetch; } /** @@ -197,7 +198,7 @@ class OpenAiProvider implements Provider { signal: AbortSignal | undefined, ): Promise { const apiKey = this.apiKey(); - const doFetch = this.config.fetch ?? fetch; + const doFetch: AiFetch = this.config.fetch ?? fetch; const response = await doFetch(this.url(), { method: 'POST', headers: { diff --git a/packages/ai/src/provider-fixture.ts b/packages/ai/src/provider-fixture.ts index d0271bcd..e3be2cf8 100644 --- a/packages/ai/src/provider-fixture.ts +++ b/packages/ai/src/provider-fixture.ts @@ -1,6 +1,7 @@ // Shared fixtures for the Anthropic provider suites: a recording fetch, a real SSE body, and the // canonical event sequence. Here rather than duplicated because `provider.test.ts` and // `provider-stream.test.ts` assert on the same wire and must not drift apart. +import type { AiFetch } from './fetch-seam'; import type { StreamChunk } from './provider'; export interface Call { @@ -10,20 +11,16 @@ export interface Call { } /** Records what left the process and replies with whatever the test wants back. */ -export function fakeFetch( - calls: Call[], - reply: (call: Call, index: number) => Response, -): typeof fetch { - const impl = async (input: unknown, init?: RequestInit): Promise => { +export function fakeFetch(calls: Call[], reply: (call: Call, index: number) => Response): AiFetch { + return async (input, init) => { const call: Call = { - url: String(input), - headers: { ...(init?.headers as Record | undefined) }, - body: JSON.parse(String(init?.body ?? '{}')) as Record, + url: input, + headers: { ...(init.headers as Record | undefined) }, + body: JSON.parse(String(init.body ?? '{}')) as Record, }; calls.push(call); return reply(call, calls.length - 1); }; - return impl as unknown as typeof fetch; } /** A real SSE body — the provider reads it through the same framing a socket would deliver. */ diff --git a/packages/ai/src/provider-parity.test.ts b/packages/ai/src/provider-parity.test.ts index 5cfd20b0..33edee98 100644 --- a/packages/ai/src/provider-parity.test.ts +++ b/packages/ai/src/provider-parity.test.ts @@ -12,6 +12,7 @@ import { beforeEach, describe, expect, test } from 'bun:test'; import { secret } from '@ultimat3/core'; import { AiTransportError } from './errors'; +import type { AiFetch } from './fetch-seam'; import { OPENAI_MODEL_IDS, registerOpenAiModels } from './openai-models'; import { openAiProvider } from './openai-provider'; import { ChatCompletionStream, parseChatCompletion } from './openai-wire'; @@ -44,9 +45,8 @@ const transportError = (value: unknown): AiTransportError => { }; /** Records what left the process and replies with whatever the case wants back. */ -function fakeFetch(reply: () => Response): typeof fetch { - const impl = async (): Promise => reply(); - return impl as unknown as typeof fetch; +function fakeFetch(reply: () => Response): AiFetch { + return async () => reply(); } beforeEach(() => { @@ -226,15 +226,12 @@ describe("the caller's abort signal reaches the socket", () => { // flight — which on a long completion is the expensive one. `agent()` puts `ctx.signal` on // every request; a provider that drops it makes that guarantee a comment. const seen: (AbortSignal | undefined)[] = []; - const recording = (): typeof fetch => { - const impl = async (_url: string, init?: RequestInit): Promise => { - seen.push(init?.signal ?? undefined); - return new Response(JSON.stringify({ error: { message: 'stop here' } }), { - status: 503, - headers: { 'content-type': 'application/json' }, - }); - }; - return impl as unknown as typeof fetch; + const recording = (): AiFetch => async (_url, init) => { + seen.push(init.signal ?? undefined); + return new Response(JSON.stringify({ error: { message: 'stop here' } }), { + status: 503, + headers: { 'content-type': 'application/json' }, + }); }; const signal = new AbortController().signal; @@ -282,14 +279,11 @@ describe("the caller's abort signal reaches the socket", () => { test('a request with no signal attaches none, rather than an explicit undefined', async () => { let init: RequestInit | undefined; - const impl = async (_url: string, given?: RequestInit): Promise => { + const impl: AiFetch = async (_url, given) => { init = given; return new Response('{}', { status: 503 }); }; - const anthropic = new AnthropicProvider({ - apiKey: KEY, - fetch: impl as unknown as typeof fetch, - }); + const anthropic = new AnthropicProvider({ apiKey: KEY, fetch: impl }); await anthropic .generate({ messages: [{ role: 'user', content: 'hi' }], maxTokens: 16 }) .catch(() => undefined); diff --git a/packages/ai/src/provider.ts b/packages/ai/src/provider.ts index b2b5f1a8..7e7f16fa 100644 --- a/packages/ai/src/provider.ts +++ b/packages/ai/src/provider.ts @@ -6,6 +6,7 @@ import type { Money } from '@ultimat3/money'; import { detailOf, withoutKey } from './error-body'; import { AiKeyMissingError, AiTransportError } from './errors'; +import type { AiFetch } from './fetch-seam'; import type { Effort, ModelId, ThinkingMode } from './models'; import { ANTHROPIC_MODEL_IDS, DEFAULT_MODEL, modelIds, modelSpec, reasoningBody } from './models'; import { readSse } from './sse'; @@ -180,7 +181,7 @@ export interface AnthropicProviderInput { readonly apiKey?: string; readonly baseUrl?: string; /** Injectable so a test can assert the request body without a network. Defaults to `fetch`. */ - readonly fetch?: typeof fetch; + readonly fetch?: AiFetch; } const ANTHROPIC_VERSION = '2023-06-01'; @@ -303,7 +304,7 @@ export class AnthropicProvider implements Provider { if (apiKey === undefined || apiKey === '') { throw new AiKeyMissingError({ provider: this.name, envVar: API_KEY_ENV }); } - const doFetch = this.config.fetch ?? fetch; + const doFetch: AiFetch = this.config.fetch ?? fetch; const url = `${this.config.baseUrl ?? 'https://api.anthropic.com'}/v1/messages`; const response = await doFetch(url, { method: 'POST', diff --git a/packages/ai/src/remote-embedder.test.ts b/packages/ai/src/remote-embedder.test.ts index 636eaa02..1f442764 100644 --- a/packages/ai/src/remote-embedder.test.ts +++ b/packages/ai/src/remote-embedder.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test'; import { isUltimateError } from '@ultimat3/core'; import { cosine } from './embeddings'; +import type { AiFetch } from './fetch-seam'; import { RemoteEmbedder } from './remote-embedder'; interface Call { @@ -9,16 +10,16 @@ interface Call { readonly body: { model?: string; input?: string[] }; } -function fakeFetch(calls: Call[], reply: (call: Call, index: number) => Response): typeof fetch { - const impl = async (input: unknown, init?: RequestInit): Promise => { - calls.push({ - url: String(input), - headers: { ...(init?.headers as Record | undefined) }, - body: JSON.parse(String(init?.body ?? '{}')) as Call['body'], - }); - return reply(calls[calls.length - 1] as Call, calls.length - 1); +function fakeFetch(calls: Call[], reply: (call: Call, index: number) => Response): AiFetch { + return async (input, init) => { + const call: Call = { + url: input, + headers: { ...(init.headers as Record | undefined) }, + body: JSON.parse(String(init.body ?? '{}')) as Call['body'], + }; + calls.push(call); + return reply(call, calls.length - 1); }; - return impl as unknown as typeof fetch; } /** A provider reply whose vectors encode their own input index, so order is checkable. */ @@ -173,8 +174,8 @@ describe('RemoteEmbedder outbound safety', () => { test('every request carries an AbortSignal', async () => { let seen: unknown; - const impl = async (_input: unknown, init?: RequestInit): Promise => { - seen = init?.signal; + const impl: AiFetch = async (_input, init) => { + seen = init.signal; return embeddingsFor(['a'], 0); }; const remote = new RemoteEmbedder({ @@ -182,16 +183,16 @@ describe('RemoteEmbedder outbound safety', () => { dimension: 2, apiKey: 'key-1', baseUrl: 'https://embeddings.test/v1', - fetch: impl as unknown as typeof fetch, + fetch: impl, }); await remote.embed(['a']); expect(seen).toBeInstanceOf(AbortSignal); }); test('a deadline that expires is a coded transport failure, never a bare DOMException', async () => { - const impl = async (_input: unknown, init?: RequestInit): Promise => + const impl: AiFetch = async (_input, init) => await new Promise((_resolve, reject) => { - init?.signal?.addEventListener('abort', () => { + init.signal?.addEventListener('abort', () => { reject(init.signal?.reason ?? new Error('aborted')); }); }); @@ -201,13 +202,13 @@ describe('RemoteEmbedder outbound safety', () => { apiKey: 'key-1', baseUrl: 'https://embeddings.test/v1', timeoutMs: 5, - fetch: impl as unknown as typeof fetch, + fetch: impl, }); expect(await codeOf(() => remote.embed(['a']))).toBe('X_AI_PROVIDER_UNAVAILABLE'); }); test('a response body past the cap is refused rather than buffered', async () => { - const impl = async (): Promise => + const impl: AiFetch = async () => new Response(JSON.stringify({ data: [{ index: 0, embedding: new Array(4096).fill(1) }] }), { headers: { 'content-type': 'application/json' }, }); @@ -217,7 +218,7 @@ describe('RemoteEmbedder outbound safety', () => { apiKey: 'key-1', baseUrl: 'https://embeddings.test/v1', maxResponseBytes: 512, - fetch: impl as unknown as typeof fetch, + fetch: impl, }); expect(await codeOf(() => remote.embed(['a']))).toBe('X_AI_PROVIDER_UNAVAILABLE'); }); diff --git a/packages/ai/src/remote-embedder.ts b/packages/ai/src/remote-embedder.ts index f0e8d9ba..bc2f77b2 100644 --- a/packages/ai/src/remote-embedder.ts +++ b/packages/ai/src/remote-embedder.ts @@ -10,6 +10,7 @@ import { readWithinLimit } from '@ultimat3/core'; import type { Embedder } from './embeddings'; import { normalize } from './embeddings'; import { AiKeyMissingError, AiTransportError, EmbedderDimMismatchError } from './errors'; +import type { AiFetch } from './fetch-seam'; const API_KEY_ENV = 'EMBEDDINGS_API_KEY'; const DEFAULT_BASE_URL = 'https://api.voyageai.com/v1'; @@ -44,7 +45,7 @@ export interface RemoteEmbedderInput { /** Bytes this process will hold of one response. Defaults to 32 MiB. */ readonly maxResponseBytes?: number; /** Injectable so a test can assert the request body without a network. Defaults to `fetch`. */ - readonly fetch?: typeof fetch; + readonly fetch?: AiFetch; } export class RemoteEmbedder implements Embedder { @@ -77,7 +78,7 @@ export class RemoteEmbedder implements Embedder { if (apiKey === undefined || apiKey === '') { throw new AiKeyMissingError({ provider: this.name, envVar: API_KEY_ENV }); } - const doFetch = this.config.fetch ?? fetch; + const doFetch: AiFetch = this.config.fetch ?? fetch; const timeoutMs = this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS; const url = `${this.config.baseUrl ?? DEFAULT_BASE_URL}/embeddings`; let response: Response; diff --git a/packages/jobs/src/backfill-inspect.test.ts b/packages/jobs/src/backfill-inspect.test.ts index 44114d7f..f8343ec6 100644 --- a/packages/jobs/src/backfill-inspect.test.ts +++ b/packages/jobs/src/backfill-inspect.test.ts @@ -59,7 +59,12 @@ describe('inspectBackfills', () => { test('a driver that ships no ledger answers an EMPTY list, never a throw', async () => { // `x jobs ls` and the jobs panel report the queue; a queue that failed on "no backfills // recorded" would be a broken command for a fact nobody asked about. - const driver: JobDriver = { ...createMemoryDriver(), backfills: undefined }; + // The key is REMOVED, not set to `undefined`: `backfills?: BackfillLedger` under + // `exactOptionalPropertyTypes` is "absent or a ledger", and a driver that ships no ledger is + // one where the property does not exist — which is also the only shape a real driver has. + const { backfills: _ledger, ...withoutLedger } = createMemoryDriver(); + const driver: JobDriver = withoutLedger; + expect(Object.hasOwn(driver, 'backfills')).toBe(false); expect(await inspectBackfills(driver)).toEqual([]); }); diff --git a/packages/jobs/src/backfill-pass.test.ts b/packages/jobs/src/backfill-pass.test.ts index 0f918e3c..8fbd81ee 100644 --- a/packages/jobs/src/backfill-pass.test.ts +++ b/packages/jobs/src/backfill-pass.test.ts @@ -102,11 +102,11 @@ describe('one pass', () => { // `steps.ts` retains a completed step's output for the whole run, so a checkpoint carrying // its page would hold every row the pass has touched until the job ended. expect(Object.keys(output).sort()).toEqual(['cursor', 'rows']); - expect(typeof output.rows).toBe('number'); - expect(output.cursor === null || typeof output.cursor === 'string').toBe(true); + expect(typeof output['rows']).toBe('number'); + expect(output['cursor'] === null || typeof output['cursor'] === 'string').toBe(true); } // The pass ends because the source did, and that is what the last checkpoint records. - expect(checkpoints.at(-1)?.cursor).toBeNull(); + expect(checkpoints.at(-1)?.['cursor']).toBeNull(); }); }); diff --git a/packages/jobs/src/backfill-throttle.test.ts b/packages/jobs/src/backfill-throttle.test.ts index 83ec6c17..0643cc9a 100644 --- a/packages/jobs/src/backfill-throttle.test.ts +++ b/packages/jobs/src/backfill-throttle.test.ts @@ -68,6 +68,9 @@ const throttled = (rate: number): Throttled => { }, }); const definition: BackfillDefinition = { + // REQUIRED, and the same declaration the real `slow-sweep` below makes: the source narrows to + // one org itself, so the pass declares no tenant of its own. + tenant: 'none', name: 'paced-sweep', source: () => table.where({ orgId: ORG }), handle: ({ rows: page, index }) => { diff --git a/packages/jobs/src/driver-memory.ts b/packages/jobs/src/driver-memory.ts index 85cb481a..35ac0c42 100644 --- a/packages/jobs/src/driver-memory.ts +++ b/packages/jobs/src/driver-memory.ts @@ -38,7 +38,18 @@ export interface MemoryDriverOptions { const LIVE_STATES = new Set(['ready', 'delayed', 'running', 'suspended']); -export function createMemoryDriver(options: MemoryDriverOptions = {}): JobDriver { +/** + * The in-memory driver's own type: `JobDriver` with `close` REQUIRED. + * + * `JobDriver.close` is optional because a driver may hold nothing to release. This one always + * does — it clears the job map — and every wrapper in the test suite delegates through + * `base.close()`. Declaring it here is what makes that delegation a CHECKED call: against a plain + * `JobDriver` the only way to write it is `base.close?.()`, which a driver that quietly stopped + * shipping a `close` would satisfy in silence. + */ +export type MemoryJobDriver = JobDriver & { close(): Promise }; + +export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJobDriver { const clock = options.clock ?? systemClock; const steps = options.steps ?? createMemoryStepStore(); const backfills = options.backfills ?? createMemoryBackfillLedger(clock); diff --git a/packages/jobs/src/driver-pg-rows.test.ts b/packages/jobs/src/driver-pg-rows.test.ts index 5d0c9001..60c687ae 100644 --- a/packages/jobs/src/driver-pg-rows.test.ts +++ b/packages/jobs/src/driver-pg-rows.test.ts @@ -124,11 +124,16 @@ describe('toStepRecord', () => { expect(Object.hasOwn(record, 'error')).toBe(false); }); - test('a suspended step carries its wake time and the event it waits for, as numbers', () => { + // `'waiting'`, not `'suspended'`: `'suspended'` is a JOB state (`JobRecord['state']`), and + // `StepStatus` is `completed | sleeping | waiting | failed`. `toStepRecord` casts the column + // with `as`, so the wrong vocabulary flowed through this mapper unchallenged — `steps.ts` writes + // `'waiting'` for a `waitForEvent` step, and that row is the one carrying all three of + // `wake_at`, `event` and `correlation_key`. + test('a waiting step carries its wake time and the event it waits for, as numbers', () => { expect( toStepRecord( stepRow({ - status: 'suspended', + status: 'waiting', wake_at: '9000', completed_at: '5000', event: 'invoice.paid', @@ -139,7 +144,7 @@ describe('toStepRecord', () => { ).toEqual({ runId: 'run-1', name: 'charge', - status: 'suspended', + status: 'waiting', output: { chargeId: 'ch_1' }, startedAt: 1000, attempts: 1, diff --git a/packages/jobs/src/driver-pg-stores.test.ts b/packages/jobs/src/driver-pg-stores.test.ts index 43c4e0dd..3c092d65 100644 --- a/packages/jobs/src/driver-pg-stores.test.ts +++ b/packages/jobs/src/driver-pg-stores.test.ts @@ -297,13 +297,13 @@ describe('the pg driver`s introspection', () => { test('cancel answers the cancelled row, and undefined for a job no longer cancellable', async () => { const cancelled = executorFor({ x_jobs: [row({ state: 'dead', last_error: 'cancelled' })] }); - const record = await driverWith(cancelled).introspect?.cancel('job-1', 'operator'); + const record = await driverWith(cancelled).introspect?.cancel?.('job-1', 'operator'); expect(cancelled.calls[0]?.sql).toBe(SQL_CANCEL); expect(cancelled.calls[0]?.params).toEqual(['job-1', 'operator']); expect(record?.lastError).toBe('cancelled'); const gone = executorFor(); - expect(await driverWith(gone).introspect?.cancel('job-1')).toBeUndefined(); + expect(await driverWith(gone).introspect?.cancel?.('job-1')).toBeUndefined(); expect(gone.calls[0]?.params).toEqual(['job-1', null]); }); }); diff --git a/packages/jobs/src/driver-pg.test.ts b/packages/jobs/src/driver-pg.test.ts index bf53ba3f..85e52988 100644 --- a/packages/jobs/src/driver-pg.test.ts +++ b/packages/jobs/src/driver-pg.test.ts @@ -18,6 +18,14 @@ import { } from './driver-pg-sql'; import { DriverUnavailableError, JobDuplicateError } from './errors'; +/** + * The row the live-key lookup answers, typed `unknown` for the reason `recordingExecutor`'s + * parameter is: `PgExecutor.query` is generic over the CALLER's row type, so no fake can name + * it. `unknown` is also what a driver really gets back — a row off the wire that nothing has + * validated yet — so the one cast stays at that boundary instead of being restated per test. + */ +const LIVE_KEY_ROW: readonly unknown[] = [{ id: 'job-9', run_id: 'run-9' }]; + function recordingExecutor(rows: readonly unknown[] = []): PgExecutor & { readonly calls: { sql: string; params: readonly unknown[] }[]; } { @@ -235,14 +243,13 @@ describe('pg enqueue, ack and nack', () => { query(sql: string, params: readonly unknown[]): Promise { calls.push({ sql, params }); call += 1; - return Promise.resolve( - (call === 1 ? [] : [{ id: 'job-9', run_id: 'run-9' }]) as readonly R[], - ); + return Promise.resolve((call === 1 ? [] : LIVE_KEY_ROW) as readonly R[]); }, }; await createPgDriver({ executor }).enqueue({ name: 'onboardOrg', queue: 'default', + input: { orgId: 'org-1' }, idempotencyKey: 'onboard:org-1', maxAttempts: 5, tenantId: 'org-1', @@ -256,14 +263,13 @@ describe('pg enqueue, ack and nack', () => { const executor: PgExecutor = { query(): Promise { call += 1; - return Promise.resolve( - (call === 1 ? [] : [{ id: 'job-9', run_id: 'run-9' }]) as readonly R[], - ); + return Promise.resolve((call === 1 ? [] : LIVE_KEY_ROW) as readonly R[]); }, }; const enqueue = createPgDriver({ executor }).enqueue({ name: 'onboardOrg', queue: 'default', + input: { orgId: 'org-1' }, idempotencyKey: 'onboard:org-1', maxAttempts: 5, onConflict: 'error', @@ -279,6 +285,7 @@ describe('pg enqueue, ack and nack', () => { const enqueue = createPgDriver({ executor }).enqueue({ name: 'onboardOrg', queue: 'default', + input: { orgId: 'org-1' }, idempotencyKey: 'onboard:org-1', maxAttempts: 5, }); diff --git a/packages/jobs/src/index.ts b/packages/jobs/src/index.ts index ac14674f..2ec97cec 100644 --- a/packages/jobs/src/index.ts +++ b/packages/jobs/src/index.ts @@ -81,7 +81,7 @@ export { resetJobDriver, setJobDriver, } from './driver'; -export type { MemoryDriverOptions } from './driver-memory'; +export type { MemoryDriverOptions, MemoryJobDriver } from './driver-memory'; export { createMemoryDriver } from './driver-memory'; export type { NatsDriverOptions } from './driver-nats'; export { createNatsDriver } from './driver-nats'; diff --git a/packages/jobs/src/run-signal.test.ts b/packages/jobs/src/run-signal.test.ts index a97461ee..56692f16 100644 --- a/packages/jobs/src/run-signal.test.ts +++ b/packages/jobs/src/run-signal.test.ts @@ -33,11 +33,22 @@ describe('the signal one run is cancelled by', () => { const remove = caller.signal.removeEventListener.bind(caller.signal); // The count is the assertion: `AbortSignal.any` registers nothing here, which is exactly why // there was nothing to hand back — the composite hung off the caller's signal instead. - caller.signal.addEventListener = (type, listener, options): void => { + // Annotated, not inferred: `addEventListener` is OVERLOADED on `AbortSignal`, and TypeScript + // gives an assignment target with overloads no contextual parameter types — so every parameter + // here was an implicit `any` that nothing checked against the method it replaces. + caller.signal.addEventListener = ( + type: string, + listener: EventListenerOrEventListenerObject, + options?: AddEventListenerOptions | boolean, + ): void => { listeners += 1; add(type, listener, options); }; - caller.signal.removeEventListener = (type, listener, options): void => { + caller.signal.removeEventListener = ( + type: string, + listener: EventListenerOrEventListenerObject, + options?: EventListenerOptions | boolean, + ): void => { listeners -= 1; remove(type, listener, options); }; diff --git a/packages/jobs/src/step-options.test.ts b/packages/jobs/src/step-options.test.ts index 52ec89b3..bc9a2e1e 100644 --- a/packages/jobs/src/step-options.test.ts +++ b/packages/jobs/src/step-options.test.ts @@ -189,7 +189,9 @@ describe('the event poll actually paces a waiting step', () => { test('a declared eventPoll is how long the run parks, not the 30s default', async () => { const harness = await claimOne({ eventPoll: '1s', - run: ({ step }) => step.waitForEvent('invoice.paid', { timeout: '1h' }), + // `waitForEvent(name, event, options)` — three arguments. Called with two, `{ timeout }` + // landed on the `event` parameter and the declared timeout was never read at all. + run: ({ step }) => step.waitForEvent('await-payment', 'invoice.paid', { timeout: '1h' }), }); const execution = await harness.execute(); diff --git a/packages/mcp/src/app-tools.test.ts b/packages/mcp/src/app-tools.test.ts index cbba5d2f..c06a22d7 100644 --- a/packages/mcp/src/app-tools.test.ts +++ b/packages/mcp/src/app-tools.test.ts @@ -127,9 +127,12 @@ describe('tools as a named record', () => { // The authoring type requires `policy`; this is the JS caller that ignored it. const tools = { unsafe: { description: 'no gate', input: t.object({}), handle: () => null } }; - expect(() => defineAppMcp({ tools } as Parameters[0])).toThrow( - 'X_MCP_TOOL_UNSAFE', - ); + // `@ts-expect-error` is the first half of the claim: the authoring type REFUSES a tool with + // no `policy`, so no typed app can write this. The throw is the second half, for the untyped + // caller the type cannot reach — delete the boot-time check and this line goes red while the + // directive stays needed. + // @ts-expect-error a tool definition with no `policy` is not an AppToolDefinition + expect(() => defineAppMcp({ tools })).toThrow('X_MCP_TOOL_UNSAFE'); }); test('the ready-McpTool array form still works, untouched', async () => { diff --git a/packages/mcp/src/cross-surface.test.ts b/packages/mcp/src/cross-surface.test.ts index fb2429f7..8c8d2cce 100644 --- a/packages/mcp/src/cross-surface.test.ts +++ b/packages/mcp/src/cross-surface.test.ts @@ -54,7 +54,9 @@ const Input = t.object({ const caller: McpCaller = { role: 'owner', actor: agentActor({ id: 'agent-1', orgId: 'o1', roles: ['owner'] }), - scopes: [], + // A SET, and empty on purpose: no tool declared here carries a `scope`, so the scope gate is + // out of this file's question. An empty array is not a set and never was one. + scopes: new Set(), }; const declare = () => @@ -118,9 +120,9 @@ describe('one declaration, three schema surfaces', () => { (schema.properties ?? {}) as Record; test('`pattern` reaches every surface an agent or a client reads', async () => { - const openapi = props(openapiSchema()).orderRef as Record; - const tool = props(archiveOrder.tool().inputSchema).orderRef as Record; - const listed = props(await listedSchema()).orderRef as Record; + const openapi = props(openapiSchema())['orderRef'] as Record; + const tool = props(archiveOrder.tool().inputSchema)['orderRef'] as Record; + const listed = props(await listedSchema())['orderRef'] as Record; expect(openapi['pattern']).toBe(ORDER_REF.source); expect(tool['pattern']).toBe(ORDER_REF.source); @@ -131,9 +133,9 @@ describe('one declaration, three schema surfaces', () => { test('`nullable` reaches every surface, and never by making the field optional', async () => { const nullBranch = { type: 'null' }; - const openapi = props(openapiSchema()).note as Record; - const tool = props(archiveOrder.tool().inputSchema).note as Record; - const listed = props(await listedSchema()).note as Record; + const openapi = props(openapiSchema())['note'] as Record; + const tool = props(archiveOrder.tool().inputSchema)['note'] as Record; + const listed = props(await listedSchema())['note'] as Record; for (const projected of [openapi, tool, listed]) { expect(projected['anyOf']).toContainEqual(nullBranch); @@ -194,11 +196,16 @@ describe('one declaration, ONE tool name', () => { }; /** The operation as the published document carries it, located by id rather than by path. */ - const openapiMcpTool = (): unknown => { + const openapiMcpTool = (): string | undefined => { for (const item of Object.values(buildOpenApi().paths)) { const operation = (item as { post?: Record }).post; if (operation?.['operationId'] === 'archiveOrder') { - return (operation['x-ultimate'] as Record | undefined)?.['mcpTool']; + const published = (operation['x-ultimate'] as Record | undefined)?.[ + 'mcpTool' + ]; + // Checked, not asserted: the document is `unknown` here, and a published name that is not + // a string is a name no `tools/call` can ever resolve — which is this block's whole claim. + return typeof published === 'string' ? published : undefined; } } return undefined; @@ -212,7 +219,11 @@ describe('one declaration, ONE tool name', () => { // The three an action publishes. Each was `archive_order` until 2026-08, and none of the // three is a name this catalog has ever contained. expect(served).toContain(archiveOrder.tool().name); - expect(served).toContain(openapiMcpTool()); + // Asserted defined first: `toContain(undefined)` would be a comparison against nothing, and a + // missing `x-ultimate.mcpTool` is exactly one of the drifts this file reports. + const published = openapiMcpTool(); + expect(published).toBeDefined(); + expect(served).toContain(published ?? ''); expect(served).toContain(archiveOrder.describe().mcp.tool); // A query publishes one; `QueryDescriptor` carries no `mcp` block, so there is no fourth. expect(served).toContain(recentOrders.tool().name); diff --git a/packages/mcp/src/dev-server.test.ts b/packages/mcp/src/dev-server.test.ts index 02c14952..c3d0ffd8 100644 --- a/packages/mcp/src/dev-server.test.ts +++ b/packages/mcp/src/dev-server.test.ts @@ -4,11 +4,21 @@ import type { DevHost } from './dev-server'; import { DEV_SCOPES, devTools } from './dev-server'; import type { QueryRows } from './query-limits'; import type { DatabaseTarget } from './readonly-sql'; -import type { AnyMcpTool, McpCaller } from './registry'; +import type { AnyMcpTool, McpCaller, McpToolResult } from './registry'; import { ToolRegistry } from './registry'; const agent = { kind: 'agent', id: 'a1' } as unknown as Actor; +/** + * The first block's text. `ContentBlock` is a UNION — a `resource` block carries a `uri` and no + * text — so the narrowing is part of the assertion: a tool that answered a resource where the test + * expects text reads as `undefined` here rather than compiling against a field it never had. + */ +const textOf = (result: McpToolResult | undefined): string | undefined => { + const first = result?.content[0]; + return first?.type === 'text' ? first.text : undefined; +}; + /** What layers 1–2 hand back when they both engaged and the statement matched nothing. */ const EMPTY_ROWS: QueryRows = { columns: [], @@ -119,7 +129,7 @@ describe('db.query is read-only, enforced', () => { test('the answer names every layer that engaged, parse and caps included', async () => { const { tool } = toolset(BRANCH); const result = await tool('db.query').handle({ sql: 'select 1' }, caller); - const answer = JSON.parse(result.content[0]?.text ?? '{}') as { + const answer = JSON.parse(textOf(result) ?? '{}') as { guards: string[]; truncatedBy: string | null; }; @@ -146,7 +156,7 @@ describe('db.query is read-only, enforced', () => { }); const tool = tools.find((t) => t.name === 'db.query'); const result = await tool?.handle({ sql: 'select n from wide', limit: 9999 }, caller); - const answer = JSON.parse(result?.content[0]?.text ?? '{}') as { + const answer = JSON.parse(textOf(result) ?? '{}') as { rowCount: number; truncated: boolean; truncatedBy: string | null; @@ -210,7 +220,7 @@ describe('catalog shape', () => { const found = await tool('errors.explain').handle({ code: 'X_DB_DRIFT' }, caller); // The payload is JSON, so the fix command arrives with escaped quotes — assert on the // parsed object rather than the wire text, which can never match an unescaped string. - const explained = JSON.parse((found.content[0] as { text: string }).text) as { + const explained = JSON.parse(textOf(found) ?? 'null') as { code: string; cause: string; fix: string; @@ -224,8 +234,7 @@ describe('catalog shape', () => { }); describe('the read tools return what the host answered, each from its own source', () => { - const parse = (result: { content: readonly { text: string }[] }): unknown => - JSON.parse(result.content[0]?.text ?? 'null'); + const parse = (result: McpToolResult): unknown => JSON.parse(textOf(result) ?? 'null'); test('routes.list, schema.describe and policies.list do not share a source', async () => { const { tool } = toolset(BRANCH); @@ -269,7 +278,7 @@ describe('the read tools return what the host answered, each from its own source test('manifest.read hands back the manifest as text, not as re-encoded JSON', async () => { const { tool } = toolset(BRANCH); const result = await tool('manifest.read').handle({}, caller); - expect(result.content[0]?.text).toBe('{"version":1}'); + expect(textOf(result)).toBe('{"version":1}'); }); }); @@ -337,7 +346,7 @@ describe('the tools that execute something report failure as isError', () => { if (tail === undefined) expect.unreachable('no logs.tail tool'); const result = await tail.handle({}, caller); - expect(result.content[0]?.text).toBe('a\nb'); + expect(textOf(result)).toBe('a\nb'); await tail.handle({ lines: 5, role: 'worker' }, caller); await tail.handle({ lines: '5', role: 12 }, caller); expect(asked).toEqual([ diff --git a/packages/mcp/src/projectable.test.ts b/packages/mcp/src/projectable.test.ts index 96566372..1a8f0a30 100644 --- a/packages/mcp/src/projectable.test.ts +++ b/packages/mcp/src/projectable.test.ts @@ -23,7 +23,7 @@ import { t } from '@ultimat3/schema'; import { defineAppMcp } from './app-tools'; import type { ProjectablePrimitive } from './from-action'; import { asProjectable } from './projectable'; -import type { McpCaller, McpToolResult } from './registry'; +import type { AnyMcpTool, McpCaller, McpToolResult } from './registry'; const owner = agentActor({ id: 'a1', orgId: 'o1', roles: ['owner'] }); const guest = agentActor({ id: 'a2', orgId: 'o1', roles: ['guest'] }); @@ -148,7 +148,10 @@ describe('a written-out list of real primitives projects like the registry sweep test('the listed projection is byte-identical to what include: exposed produces', () => { const target = exposedAction(); - const strip = (tool: { name: string; description: string; destructive: boolean }) => ({ + // `AnyMcpTool`, because `destructive` is OPTIONAL on it — an inline shape that required the + // field described a tool this package never produces, and both projections carry `undefined` + // for a read. What is compared is still all three fields, on both sides, from one function. + const strip = (tool: AnyMcpTool) => ({ name: tool.name, description: tool.description, destructive: tool.destructive, diff --git a/packages/mcp/src/registry.test.ts b/packages/mcp/src/registry.test.ts index 64d4a93d..441a864f 100644 --- a/packages/mcp/src/registry.test.ts +++ b/packages/mcp/src/registry.test.ts @@ -66,7 +66,12 @@ describe('visibleToCaller', () => { test('a truthy non-boolean does not widen the gate — only a literal true admits', () => { // A JS caller (or a predicate returning the permission it matched) must not be an "allow". - const truthy = tool({ name: 'truthy', visibleTo: (() => 'admin') as () => boolean }); + // `@ts-expect-error` IS the first half of the assertion: `McpVisibility` refuses a predicate + // that answers anything but `boolean`, so a typed app cannot write this. What the runtime + // check below adds is the untyped caller the type system cannot reach — drop the `=== true` + // in `visibleToCaller` and this test goes red while the directive stays needed. + // @ts-expect-error a predicate returning a string is not assignable to McpVisibility + const truthy = tool({ name: 'truthy', visibleTo: () => 'admin' }); expect(visibleToCaller(truthy, caller({ role: 'admin' }))).toBe(false); }); }); @@ -225,10 +230,13 @@ describe('ToolRegistry.resolve ordering: visibility -> scope -> args -> ok', () test('a missing rawArgs defaults to an empty object rather than throwing', () => { const registry = new ToolRegistry(); - registry.register(tool({ name: 'open' })); + // The registered instance, not `registry.get()`: `get` answers `AnyMcpTool | undefined`, so + // asserting against it would pass on a resolution that handed back nothing at all. + const t = tool({ name: 'open' }); + registry.register(t); expect(registry.resolve('open', undefined, caller())).toEqual({ kind: 'ok', - tool: registry.get('open'), + tool: t, args: {}, }); }); @@ -288,8 +296,14 @@ describe('textResult / jsonResult', () => { ]) { const result = jsonResult(value); expect(result.isError).toBe(true); - expect(typeof result.content[0]?.text).toBe('string'); - expect(result.content[0]?.text).toContain('not JSON'); + // Narrowed, not cast: `ContentBlock` is a union, and a `resource` block here would be as + // wrong an answer as a non-string `text`. The `typeof` check stays — narrowing proves the + // declared type, and this proves the value. + const first = result.content[0]; + expect(first?.type).toBe('text'); + const text = first?.type === 'text' ? first.text : undefined; + expect(typeof text).toBe('string'); + expect(text).toContain('not JSON'); } }); diff --git a/packages/mcp/src/transport-http.test.ts b/packages/mcp/src/transport-http.test.ts index ea55508b..e83aafdc 100644 --- a/packages/mcp/src/transport-http.test.ts +++ b/packages/mcp/src/transport-http.test.ts @@ -205,7 +205,7 @@ describe('an unauthenticated caller learns nothing about its own request', () => test('a non-agent actor answers the same way for either body', async () => { const route = mcpHttpRoute({ server, - resolveToken: () => ({ actor: userActor({ id: 'u1' }), scopes: [] }), + resolveToken: () => ({ actor: userActor({ id: 'u1' }), scopes: new Set() }), }); const bad = await route.handle(malformed({ authorization: 'Bearer t' })); @@ -226,7 +226,7 @@ describe('an unauthenticated caller learns nothing about its own request', () => test('an authenticated agent still gets the parse error', async () => { const route = mcpHttpRoute({ server, - resolveToken: () => ({ actor: agentActor({ id: 'a1' }), scopes: [] }), + resolveToken: () => ({ actor: agentActor({ id: 'a1' }), scopes: new Set() }), }); const res = await route.handle(malformed({ authorization: 'Bearer t' })); diff --git a/packages/mcp/src/validate-args.test.ts b/packages/mcp/src/validate-args.test.ts index 5262e566..41a7a5a5 100644 --- a/packages/mcp/src/validate-args.test.ts +++ b/packages/mcp/src/validate-args.test.ts @@ -322,9 +322,13 @@ describe('validateArgs: a property named after one of Object.prototype', () => { // A schema that really does declare one of those names is the reason the discriminator is // `Object.hasOwn` rather than a deny-list: declared is declared, and it still validates. test('a schema that declares such a property validates it like any other', () => { + // The inner schema is annotated rather than inlined: `constructor` resolves to + // `Object.prototype.constructor` before the index signature of `properties`, so the literal + // never receives `JsonSchema` as its contextual type and `'string'` widens to `string`. + const stringProperty: JsonSchema = { type: 'string' }; const declared: JsonSchema = { type: 'object', - properties: { constructor: { type: 'string' } }, + properties: { constructor: stringProperty }, required: ['constructor'], additionalProperties: false, }; diff --git a/packages/query/CLAUDE.md b/packages/query/CLAUDE.md index ecd0e33d..204c8e52 100644 --- a/packages/query/CLAUDE.md +++ b/packages/query/CLAUDE.md @@ -322,11 +322,16 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher. `orgId` narrows to the actor rather than widening to everyone, because nothing here can prove two org-less callers share a tenant. **All THREE spellings of "no org" take that branch**, `As of 2026-08`: `undefined`, `''` and `null`. The last one missed it, so every org-less caller shared - the single key `["org",null]` and was served the rows of whoever asked first — core's `Actor` - declares `orgId?: string`, but `@ultimat3/policy`'s `PolicyActorFields` widens it to - `string | null | undefined` and its `testActor` mints `null`, which is why `orgless()` widens its - parameter rather than trusting the declared type. The authority is JSON, never a joined string, - for the reason + the single key `["org",null]` and was served the rows of whoever asked first. `orgless()` widens + its parameter past core's `orgId?: string` because **`orgId` is a value off the wire** — an app's + adapter, a decoded session row, a JSON payload — not because a declared type permits a `null`. + `@ultimat3/policy`'s `PolicyActorFields` reads like the reason and is not it (corrected + 2026-08-19): `Actor = CoreActor & PolicyActorFields`, and that intersection collapses its + `string | null | undefined` back to `string | undefined`, so the widening is **inert** at the type + level and `{ orgId: null }` is a type error. Its `testActor` mints `orgId: null` through the one + cast left in `packages/policy/src/test-kit.ts`, which is why `cache-authority.test.ts` can reach + this branch at all — the repo's only producer of that `null`, and a test seam rather than a proof. + The authority is JSON, never a joined string, for the reason `@ultimat3/entity`'s `scopeKey` gives: an actor id is app data and may carry the separator. - **`cache.ttlMs` is judged at `query()`, not on the first read.** Every `CacheTier` refuses a lease that is not positive and finite (`assertTtl`), and the read tier's one catch absorbs diff --git a/packages/realtime/src/channel.test.ts b/packages/realtime/src/channel.test.ts index fea6f288..d0ee12a7 100644 --- a/packages/realtime/src/channel.test.ts +++ b/packages/realtime/src/channel.test.ts @@ -57,7 +57,9 @@ function connect(sockets: SocketRegistry, who: Actor): { socket: SyncSocket; ws: describe('channels', () => { test('topic segments are validated, never escaped', () => { - expect(topic('org', 'o1', 'cursors')).toBe('org.o1.cursors'); + // `String(...)`, because `Topic` is a BRANDED string: the matcher is typed on what it + // received, so a bare literal is not a `Topic` and the assertion could not be written. + expect(String(topic('org', 'o1', 'cursors'))).toBe('org.o1.cursors'); expect(() => topic('org', 'o1.evil', 'cursors')).toThrow(TopicForbiddenError); expect(() => topic('org', '>', 'cursors')).toThrow(TopicForbiddenError); }); diff --git a/packages/realtime/src/errors.test.ts b/packages/realtime/src/errors.test.ts index d0835710..f2653dca 100644 --- a/packages/realtime/src/errors.test.ts +++ b/packages/realtime/src/errors.test.ts @@ -56,7 +56,10 @@ describe('REALTIME_ERROR_CODES', () => { }); test('is exactly the original members plus the ones added since', () => { - expect(REALTIME_ERROR_CODES.length).toBe(ORIGINAL_MEMBERS.length + ADDED_SINCE.length); + // Read into a `number` first: `REALTIME_ERROR_CODES` is a readonly tuple, so `.length` is the + // LITERAL `20` and the matcher would only accept that literal back. + const declared: number = REALTIME_ERROR_CODES.length; + expect(declared).toBe(ORIGINAL_MEMBERS.length + ADDED_SINCE.length); expect([...EVERY_CODE].sort()).toEqual([...ORIGINAL_MEMBERS, ...ADDED_SINCE].sort()); }); @@ -111,7 +114,11 @@ describe('error code registry', () => { */ describe('isClientFault', () => { test('a denied topic, a cap and a skewed protocol are the client’s, not the node’s', () => { - expect(isClientFault(new TopicForbiddenError({ topic: 'org:1', actorId: 'u_1' }))).toBe(true); + expect( + isClientFault( + new TopicForbiddenError({ topic: 'org:1', actorId: 'u_1', reason: 'no guard is declared' }), + ), + ).toBe(true); expect(isClientFault({ code: 'X_SUBSCRIPTION_LIMIT' })).toBe(true); expect(isClientFault({ code: 'X_PROTOCOL_VERSION' })).toBe(true); // A name this node never registered is the client's typo, not this node's outage. @@ -148,7 +155,11 @@ describe('isPolicyDenial', () => { }); test('a client fault that is not an authz answer is still not a denial', () => { - expect(isPolicyDenial(new TopicForbiddenError({ topic: 'org:1', actorId: 'u_1' }))).toBe(false); + expect( + isPolicyDenial( + new TopicForbiddenError({ topic: 'org:1', actorId: 'u_1', reason: 'no guard is declared' }), + ), + ).toBe(false); expect(isPolicyDenial({ code: 'X_SUBSCRIPTION_LIMIT' })).toBe(false); expect(isPolicyDenial({ code: 'X_CURSOR_STALE' })).toBe(false); }); diff --git a/packages/realtime/src/live-contract.test.ts b/packages/realtime/src/live-contract.test.ts index 1414a287..fd5029e7 100644 --- a/packages/realtime/src/live-contract.test.ts +++ b/packages/realtime/src/live-contract.test.ts @@ -12,6 +12,7 @@ import { describe, expect, test } from 'bun:test'; import { userActor } from '@ultimat3/core'; import { queryHash } from '@ultimat3/query'; +import { RingChangeBuffer } from './change-buffer'; import { formatLsn } from './changefeed'; import type { JsonValue, Row } from './json'; import type { LiveQueryDefinition } from './live-contract'; @@ -41,9 +42,10 @@ const definition: LiveQueryDefinition = { }; const registryWith = (): LiveQueryRegistry => { - const registry = new LiveQueryRegistry({ - source: { snapshot: async () => await Promise.resolve([]) }, - }); + // The REAL retained-change source. `LiveQueryRegistryOptions.source` is a `ResumeSource` + // (`append` / `since` / `headLsn`), and the stub that stood here declared a `snapshot` method the + // seam has never had — so anything reaching for the resume window would have hit `undefined`. + const registry = new LiveQueryRegistry({ source: new RingChangeBuffer() }); registry.register(definition); return registry; }; diff --git a/packages/realtime/src/live-fanout.test.ts b/packages/realtime/src/live-fanout.test.ts index 0a97b01c..a04ea349 100644 --- a/packages/realtime/src/live-fanout.test.ts +++ b/packages/realtime/src/live-fanout.test.ts @@ -100,6 +100,9 @@ const change = (lsn: string): ChangeEvent => ({ before: { id: 'p1', orgId: 'o1', likes: 0 }, after: { id: 'p1', orgId: 'o1', likes: 1 }, lsn, + // One commit per lsn, as `InMemoryChangeFeed` numbers them: the txid tracks the position rather + // than being a constant, so two changes in this file are never the same transaction. + txid: lsn, at: 0, orgId: 'o1', }); diff --git a/packages/realtime/src/live-query-window.test.ts b/packages/realtime/src/live-query-window.test.ts index ba7b30c0..42e14f98 100644 --- a/packages/realtime/src/live-query-window.test.ts +++ b/packages/realtime/src/live-query-window.test.ts @@ -116,6 +116,7 @@ async function feedWithOneSlowGate(): Promise<{ registry: LiveQueryRegistry; ws: FakeWs; sid: string; + socketId: string; }> { let slowNext = false; const registry = new LiveQueryRegistry({ source: new RingChangeBuffer() }).register( diff --git a/packages/realtime/src/nats-transport.test.ts b/packages/realtime/src/nats-transport.test.ts index 3b19ccbd..6a0d8066 100644 --- a/packages/realtime/src/nats-transport.test.ts +++ b/packages/realtime/src/nats-transport.test.ts @@ -29,6 +29,12 @@ interface Bus { /** Whatever the transport reported in the background. Collected so it never reaches the log. */ readonly reported: readonly unknown[]; readonly transport: (overrides?: Partial) => NatsTransport; + /** + * A transport with NO `onError` — the key ABSENT, not set to `undefined`. `onError` is optional, + * so "nobody is listening" is a missing property, and `{ onError: undefined }` cannot say that + * under `exactOptionalPropertyTypes`. This is the shape that falls back to the log. + */ + readonly transportWithoutOnError: () => NatsTransport; readonly dials: () => number; /** What the transport handed the client as its reconnect policy, from the last dial. */ readonly reconnectDelay: () => (() => number) | undefined; @@ -46,21 +52,21 @@ function bus(options: { version?: string } = {}): Bus { delay = clientOptions.reconnectDelay; return open(clientOptions); }; + const base = { + url: 'nats://bus.test:4222', + bucket: 'x-test', + clock, + rng: () => 0.5, + connect, + } satisfies Partial; return { broker, reported, dials: () => dials, reconnectDelay: () => delay, transport: (overrides = {}) => - new NatsTransport({ - url: 'nats://bus.test:4222', - bucket: 'x-test', - clock, - rng: () => 0.5, - onError: (error) => reported.push(error), - connect, - ...overrides, - }), + new NatsTransport({ ...base, onError: (error) => reported.push(error), ...overrides }), + transportWithoutOnError: () => new NatsTransport({ ...base }), }; } @@ -308,7 +314,7 @@ describe('NatsTransport', () => { test('a background failure with no onError reaches the log rather than silence', async () => { const harness = bus(); - const transport = harness.transport({ onError: undefined }); + const transport = harness.transportWithoutOnError(); const logged = spyOn(logger, 'error').mockImplementation(() => undefined); try { diff --git a/packages/realtime/src/pg-connection.test.ts b/packages/realtime/src/pg-connection.test.ts index d9b3ff69..dd122694 100644 --- a/packages/realtime/src/pg-connection.test.ts +++ b/packages/realtime/src/pg-connection.test.ts @@ -6,7 +6,7 @@ import { describe, expect, test } from 'bun:test'; import { ReplicationFailedError, ReplicationProtocolError } from './errors'; import { md5Password, SCRAM_SHA_256, scramNonce, scramSession } from './pg-auth'; import { ByteReader } from './pg-bytes'; -import { PgConnection } from './pg-connection'; +import { PgConnection, type PgConnectionOptions } from './pg-connection'; import { authCleartext, authGssapi, diff --git a/packages/realtime/src/pg-entity-row-parity.test.ts b/packages/realtime/src/pg-entity-row-parity.test.ts index bdd55a51..162025a2 100644 --- a/packages/realtime/src/pg-entity-row-parity.test.ts +++ b/packages/realtime/src/pg-entity-row-parity.test.ts @@ -25,11 +25,18 @@ afterAll(() => { * The narrowest `DbClient` a point read needs. Typed structurally rather than imported: * `@ultimat3/db` is not a dependency of this package, and the row is all the repository reads. */ -const clientOver = (row: Readonly>) => ({ - query: () => Promise.resolve([row]), - one: () => Promise.resolve(row), - execute: () => Promise.resolve(1), -}); +const clientOver = (row: Readonly>) => { + // Held as `unknown`: `DbClient.query`/`one` are generic over the CALLER's row type, so no + // fake can name it — and `unknown` is what a driver really has, bytes off the wire that nothing + // has validated. One assertion, at that boundary, instead of one per method body. + const opaque: unknown = row; + const rows: readonly unknown[] = [row]; + return { + query: (): Promise => Promise.resolve(rows as readonly T[]), + one: (): Promise => Promise.resolve(opaque as T), + execute: () => Promise.resolve(1), + }; +}; /** The same physical row, read the way a repository reads one. */ const throughRepository = async ( @@ -53,7 +60,7 @@ test('a scaled amount is one object on both surfaces', async () => { price_scale: 6, }; - const live = entityRow(physical); + const live: Readonly> = entityRow(physical); const stored = await throughRepository(physical); // Both sides absolutely, so the two cannot fail open together and still agree. @@ -81,7 +88,7 @@ test('an unscaled amount carries no scale key on either surface', async () => { price_scale: null, }; - const live = entityRow(physical); + const live: Readonly> = entityRow(physical); const stored = await throughRepository(physical); expect(live).toStrictEqual({ @@ -102,7 +109,7 @@ test('a projection that left the scale column out reads as no scale, not as zero price_currency: 'USD', }; - const live = entityRow(physical); + const live: Readonly> = entityRow(physical); const stored = await throughRepository(physical); expect(priceOf(live)).toStrictEqual({ minor: 1990, currency: 'USD' }); @@ -119,7 +126,7 @@ test('the scale column never survives as a property of its own', async () => { price_scale: 6, }; - const live = entityRow(physical); + const live: Readonly> = entityRow(physical); const stored = await throughRepository(physical); expect(Object.keys(live)).toEqual(['id', 'title', 'price']); diff --git a/packages/realtime/src/pg-replication.test.ts b/packages/realtime/src/pg-replication.test.ts index 69baccf9..cc7566ed 100644 --- a/packages/realtime/src/pg-replication.test.ts +++ b/packages/realtime/src/pg-replication.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test'; +import type { PgLogicalReplicationFeed } from './changefeed'; import { pgTimestampToEpochMs } from './pg-bytes'; import { changeLsn, commitPositionOf } from './pg-replication'; import { diff --git a/packages/realtime/src/presence.test.ts b/packages/realtime/src/presence.test.ts index 89a2c8b0..3fd0a7f2 100644 --- a/packages/realtime/src/presence.test.ts +++ b/packages/realtime/src/presence.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'bun:test'; import { frozenClock } from '@ultimat3/core'; import { topic } from './channel'; -import { InProcessTransport } from './fanout'; +import { InProcessTransport, type Transport } from './fanout'; import { PRESENCE_KEY_PREFIX, PRESENCE_SWEEP_PREFIX, PresenceRegistry } from './presence'; const room = topic('org', 'o1', 'cursors'); @@ -104,13 +104,20 @@ describe('presence at all-hands size', () => { touch: shared.touch.bind(shared), drop: shared.drop.bind(shared), }; + // Delegated member by member, NOT `{ ...transport, shared: counting }`: `transport` is a class + // instance, so `publish`, `subscribe` and `close` live on its prototype and a spread copies + // none of them — the wrapper that stood here was a `Transport` that could not fan out, and it + // survived only because `PresenceRegistry` reads nothing but `shared`. The `shared` wrapper + // above already re-binds every method by hand, for exactly this reason. + const observed: Transport = { + name: transport.name, + shared: counting, + publish: (subject, payload) => transport.publish(subject, payload), + subscribe: (subject, handler) => transport.subscribe(subject, handler), + close: () => transport.close(), + }; const fleet = ['node-a', 'node-b', 'node-c'].map( - (nodeId) => - new PresenceRegistry({ - transport: { ...transport, shared: counting }, - clock, - nodeId, - }), + (nodeId) => new PresenceRegistry({ transport: observed, clock, nodeId }), ); for (const node of fleet) await node.join(room, { id: `s-${node.constructor.name}`, actorId: null }); diff --git a/packages/realtime/src/rebase.ts b/packages/realtime/src/rebase.ts index 965d0545..bf6df3d0 100644 --- a/packages/realtime/src/rebase.ts +++ b/packages/realtime/src/rebase.ts @@ -9,7 +9,7 @@ import { RebaseConflictError } from './errors'; import type { Row } from './json'; import type { LocalStore, LocalTx, TableMap } from './local-store'; -import { type ConflictStrategyName, type Frame, PROTOCOL_VERSION } from './sync-protocol'; +import { type ConflictStrategyName, PROTOCOL_VERSION, type RebaseFrame } from './sync-protocol'; export interface MergeArgs { /** Local row as the user last saw it, before any rollback. */ @@ -246,7 +246,12 @@ function numberAt(row: Row | null | undefined, field: string): number | null { return typeof value === 'number' ? value : null; } -export function rebaseFrame(ack: ServerAck, strategy: ConflictStrategy): Frame { +/** + * `RebaseFrame`, not `Frame`: this builds exactly one member of the union and declaring the whole + * union threw that away, so every caller had to re-narrow a frame it had just constructed before it + * could read `strategy` or `row` back off it. + */ +export function rebaseFrame(ack: ServerAck, strategy: ConflictStrategy): RebaseFrame { return { type: 'rebase', v: PROTOCOL_VERSION, diff --git a/packages/realtime/src/sync-drain.test.ts b/packages/realtime/src/sync-drain.test.ts index ae4fa055..2d0206e7 100644 --- a/packages/realtime/src/sync-drain.test.ts +++ b/packages/realtime/src/sync-drain.test.ts @@ -191,9 +191,12 @@ describe('a socket the node evicts is released the way a closed one is', () => { const plan = await app.node.drain({ graceMs: 0 }); expect( + // Sorted by the NAMED field before mapping: `[socketId, notified]` is a `(string | boolean)[]` + // to TypeScript, so `a[0]` was `string | boolean | undefined` and the comparison read an + // index nothing guaranteed was there. [...plan] - .map((entry) => [entry.socketId, entry.notified]) - .sort((a, b) => (a[0] < b[0] ? -1 : 1)), + .sort((a, b) => a.socketId.localeCompare(b.socketId)) + .map((entry) => [entry.socketId, entry.notified]), ).toEqual([ ['s1', true], ['s2', false], diff --git a/packages/realtime/src/sync-node-ack.test.ts b/packages/realtime/src/sync-node-ack.test.ts index a1664297..ab643e1d 100644 --- a/packages/realtime/src/sync-node-ack.test.ts +++ b/packages/realtime/src/sync-node-ack.test.ts @@ -29,7 +29,10 @@ class FakeWs implements WsLike { class MutationFailed extends Error { readonly code = 'X_INVARIANT_VIOLATED'; - readonly cause = 'the row no longer exists'; + // `cause` IS a member of `Error` (ES2022), so shadowing it without `override` is what + // `noImplicitOverride` refuses — and silently shadowing it is how a `cause` a caller set + // through `new Error(msg, { cause })` disappears. + override readonly cause = 'the row no longer exists'; readonly fix = 'refetch the post before liking it'; } diff --git a/packages/render/src/dsl.test.ts b/packages/render/src/dsl.test.ts index 8d651d52..e903aa54 100644 --- a/packages/render/src/dsl.test.ts +++ b/packages/render/src/dsl.test.ts @@ -6,8 +6,17 @@ import { describe, expect, test } from 'bun:test'; import { assertModeInvariants, assertModeShape } from './modes'; import { clearRoutes, registerRoute, routeFor } from './registry'; -import type { RouteConfig, RouteDefinition } from './route'; +import type { RouteConfig, RouteData, RouteDefinition, RouteMetaContext } from './route'; import { defineRoute, isRouteConfig, tagKeys } from './route'; +import { metaContextFor } from './route-data'; + +/** + * What `meta` is actually handed at render time, built by the one builder `x dev` and the + * prerenderer both use — so calling `config.meta({})` can never again stand in for a context and + * hide the fact that `meta` reads `ctx.data`, not the bare object. + */ +const metaCtx = (data: RouteData = {}): RouteMetaContext => + metaContextFor({ params: {}, url: 'http://localhost/' }, data); /** The stable code every route-shape refusal carries; a bare `toThrow()` would accept any. */ const codeOf = (run: () => unknown): string => { @@ -80,8 +89,8 @@ describe('the route DSL surface', () => { const sync = defineRoute(minimal); const async = defineRoute({ ...minimal, meta: async () => ({ title: 'A', description: 'B' }) }); // Both are promises. No consumer branches on a thenable. - expect(sync.meta({})).toBeInstanceOf(Promise); - expect(async.meta({})).toBeInstanceOf(Promise); + expect(sync.meta(metaCtx())).toBeInstanceOf(Promise); + expect(async.meta(metaCtx())).toBeInstanceOf(Promise); // And the descriptor's `meta` is a wrapper, never the function the author passed. expect(sync.meta).not.toBe(minimal.meta); }); diff --git a/packages/render/src/island-collector.test.ts b/packages/render/src/island-collector.test.ts index b3a99f72..37d6a0d2 100644 --- a/packages/render/src/island-collector.test.ts +++ b/packages/render/src/island-collector.test.ts @@ -14,6 +14,12 @@ import type { JsxProps } from './jsx'; const FILE = 'apps/web/site/pricing/page.tsx'; +// `'idle'` wherever the strategy is incidental: it is a real `HydrateStrategy` (the collector +// emits it verbatim into `directive.strategy`, so `'load'` — a name no strategy has carried since +// the four were fixed — was writing a value the client runtime cannot dispatch on), it is not +// `'never'` so `assertHydrates` passes, and it is not `'interaction'` so no test picks up replay +// events it did not ask for. + const specOf = (over: Partial = {}): IslandSpec => ({ moduleId: 'cart', src: './cart.island.tsx', @@ -39,7 +45,7 @@ beforeEach(() => { describe('createIslandCollector · record', () => { test('numbers the instances per module, so two of one island get two ids and one entry', () => { - const collector = createIslandCollector({ file: FILE, hydrate: 'load' }); + const collector = createIslandCollector({ file: FILE, hydrate: 'idle' }); const cart = specOf(); const first = collector.record(cart, {}); const second = collector.record(cart, {}); @@ -73,24 +79,24 @@ describe('createIslandCollector · record', () => { }); test('an empty prop bag is omitted from the directive rather than emitted as {}', () => { - const collector = createIslandCollector({ file: FILE, hydrate: 'load' }); + const collector = createIslandCollector({ file: FILE, hydrate: 'idle' }); expect(Object.hasOwn(collector.record(specOf(), {}), 'props')).toBe(false); const withProps = collector.record(specOf({ propKeys: ['id'] }), { id: 'p1' } as JsxProps); expect(withProps.props).toEqual({ id: 'p1' }); }); test('replay events default only under interaction, and a declaration still wins', () => { - const load = createIslandCollector({ file: FILE, hydrate: 'load' }); - expect(load.record(specOf(), {}).events).toBeUndefined(); + const idle = createIslandCollector({ file: FILE, hydrate: 'idle' }); + expect(idle.record(specOf(), {}).events).toBeUndefined(); const interaction = createIslandCollector({ file: FILE, hydrate: 'interaction' }); expect(interaction.record(specOf(), {}).events).toEqual(DEFAULT_REPLAY_EVENTS); expect(interaction.record(specOf({ events: ['pointerdown'] }), {}).events).toEqual([ 'pointerdown', ]); - // A declared list is kept whatever the strategy: `load` above answered undefined, not the + // A declared list is kept whatever the strategy: `idle` above answered undefined, not the // default, so this is the declaration and not the fallback. - expect(load.record(specOf({ events: ['focusin'] }), {}).events).toEqual(['focusin']); + expect(idle.record(specOf({ events: ['focusin'] }), {}).events).toEqual(['focusin']); }); test('rootMargin travels only when declared', () => { @@ -106,7 +112,7 @@ describe('createIslandCollector · two modules, one id', () => { test('is refused, naming both resolved entries', () => { const collector = createIslandCollector({ file: FILE, - hydrate: 'load', + hydrate: 'idle', resolve: (src) => `/_x/${src.replace('./', '')}`, }); collector.record(specOf({ src: './cart.island.tsx' }), {}); @@ -121,7 +127,7 @@ describe('createIslandCollector · two modules, one id', () => { }); test('the same id resolving to the same entry is the ordinary two-instance case', () => { - const collector = createIslandCollector({ file: FILE, hydrate: 'load' }); + const collector = createIslandCollector({ file: FILE, hydrate: 'idle' }); collector.record(specOf(), {}); expect(() => collector.record(specOf(), {})).not.toThrow(); expect(islandModuleIds(collector.directives)).toEqual(['cart']); @@ -129,9 +135,9 @@ describe('createIslandCollector · two modules, one id', () => { test('a collector is per render — a second one does not inherit the first claim', () => { const resolve = (src: string) => `/_x/${src.replace('./', '')}`; - const first = createIslandCollector({ file: FILE, hydrate: 'load', resolve }); + const first = createIslandCollector({ file: FILE, hydrate: 'idle', resolve }); first.record(specOf({ src: './cart.island.tsx' }), {}); - const second = createIslandCollector({ file: FILE, hydrate: 'load', resolve }); + const second = createIslandCollector({ file: FILE, hydrate: 'idle', resolve }); expect(() => second.record(specOf({ src: './other.island.tsx' }), {})).not.toThrow(); expect(second.directives).toHaveLength(1); }); @@ -146,7 +152,7 @@ describe('createIslandCollector · an entry that cannot be emitted', () => { ])('%s in the resolver output is refused', (_name, resolved) => { const collector = createIslandCollector({ file: FILE, - hydrate: 'load', + hydrate: 'idle', resolve: () => resolved, }); const error = thrownBy(() => collector.record(specOf(), {})); @@ -158,7 +164,7 @@ describe('createIslandCollector · an entry that cannot be emitted', () => { test('a plain URL path is accepted, so the check is the characters and not the shape', () => { const collector = createIslandCollector({ file: FILE, - hydrate: 'load', + hydrate: 'idle', resolve: () => '/_x/chunks/cart-9f3a.js', }); expect(collector.record(specOf(), {}).entry).toBe('/_x/chunks/cart-9f3a.js'); diff --git a/packages/render/src/island.test.ts b/packages/render/src/island.test.ts index d5b169f6..6ea000dd 100644 --- a/packages/render/src/island.test.ts +++ b/packages/render/src/island.test.ts @@ -193,15 +193,22 @@ describe('island · what an island may close over', () => { test('an undeclared prop is refused by name — a spread entity row names every column', async () => { const Modal = island({ src: `./contact-modal${ISLAND_EXTENSION}`, props: ['subject'] }); const row = { subject: 'pricing', email: 'a@b.c', passwordHash: 'deadbeef' }; - const code = await asyncCodeOf(() => render(h(Modal, row))); + const code = await asyncCodeOf(() => render(Modal(row))); expect(code).toBe('X_ISLAND_PROPS_INVALID'); }); test('a value the browser could never receive is refused, naming the path and the type', async () => { const Modal = island({ src: `./contact-modal${ISLAND_EXTENSION}`, props: ['db', 'at'] }); const db = { query: () => Promise.resolve([]) }; - expect(await asyncCodeOf(() => render(h(Modal, { db })))).toBe('X_ISLAND_PROPS_INVALID'); - expect(await asyncCodeOf(() => render(h(Modal, { at: new Date(0) })))).toBe( + // Both halves are the contract, and each `@ts-expect-error` carries one of them: a handle and + // a `Date` are not `JsonValue`, so the type refuses the prop (`type-pins.tsx` pins that), and + // the render refuses it again for the caller that arrived through `renderToHtml(node: unknown)` + // — an untyped spread, a JS caller, a value laundered through `unknown`. Deleting either check + // makes exactly one of these two lines fail. + // @ts-expect-error a database handle is not a JsonValue, and `at` is not supplied + expect(await asyncCodeOf(() => render(Modal({ db })))).toBe('X_ISLAND_PROPS_INVALID'); + // @ts-expect-error a Date is not a JsonValue, and `db` is not supplied + expect(await asyncCodeOf(() => render(Modal({ at: new Date(0) })))).toBe( 'X_ISLAND_PROPS_INVALID', ); }); @@ -209,12 +216,14 @@ describe('island · what an island may close over', () => { test('props over the cap are refused: every byte here ships in the HTML on every request', async () => { const Modal = island({ src: `./contact-modal${ISLAND_EXTENSION}`, props: ['blob'] }); const blob = 'x'.repeat(ISLAND_PROPS_MAX_BYTES + 1); - expect(await asyncCodeOf(() => render(h(Modal, { blob })))).toBe('X_ISLAND_PROPS_INVALID'); + expect(await asyncCodeOf(() => render(Modal({ blob })))).toBe('X_ISLAND_PROPS_INVALID'); }); test('children are the server shell, never serialized props', async () => { const Modal = island({ src: `./contact-modal${ISLAND_EXTENSION}`, props: ['subject'] }); - const html = await render(h(Modal, { subject: 'pricing' }, h('button', null, 'Contact us'))); + const html = await render( + Modal({ subject: 'pricing', children: h('button', null, 'Contact us') }), + ); expect(html).toContain(''); expect(html).toContain('{"subject":"pricing"}'); expect(html).not.toContain('"children"'); @@ -232,7 +241,7 @@ describe('island · a static page ships JS for only its island', () => { 'main', null, h(Hero, null), - h(Modal, { subject: 'pricing' }, h('button', null, 'Contact us')), + Modal({ subject: 'pricing', children: h('button', null, 'Contact us') }), ), { islands: collector }, ); @@ -274,7 +283,7 @@ describe('island · a static page ships JS for only its island', () => { const Modal = island({ src: `./contact-modal${ISLAND_EXTENSION}`, props: ['subject'] }); const collector = createIslandCollector({ file: PAGE, hydrate: 'interaction' }); const html = await renderToHtml( - h('main', null, h(Modal, { subject: 'top' }), h(Modal, { subject: 'bottom' })), + h('main', null, Modal({ subject: 'top' }), Modal({ subject: 'bottom' })), { islands: collector }, ); @@ -352,7 +361,7 @@ describe('island · declaring one is the whole declaration', () => { // 3. it renders, and the browser is told to boot it const collector = createIslandCollector({ file: PAGE, hydrate: entry.config.hydrate }); - const html = await renderToHtml(h(Modal, { subject: 'pricing' }, 'Contact us'), { + const html = await renderToHtml(Modal({ subject: 'pricing', children: 'Contact us' }), { islands: collector, }); expect(html).toContain('data-x-hydrate="interaction"'); diff --git a/packages/render/src/render-spa.test.ts b/packages/render/src/render-spa.test.ts index 1e6a0c53..fa8ec128 100644 --- a/packages/render/src/render-spa.test.ts +++ b/packages/render/src/render-spa.test.ts @@ -41,6 +41,8 @@ function spaEntryWithoutPolicy(): RouteEntry { hydrate: 'idle', meta: async () => ({ title: 'T', description: 'd'.repeat(60) }), budget: {}, + // Required on the descriptor, and the honest value: this shell declares no island. + islands: [], }; return { file: 'apps/web/app/dashboard/page.tsx', diff --git a/packages/render/src/render-static.test.ts b/packages/render/src/render-static.test.ts index 8863404f..8e1ce0ed 100644 --- a/packages/render/src/render-static.test.ts +++ b/packages/render/src/render-static.test.ts @@ -17,14 +17,15 @@ import { staticHeaders, staticResult, } from './render-static'; -import type { PrerenderFn, RouteMetaFn } from './route'; +import type { PrerenderFn, RouteConfig, RouteMetaFn } from './route'; import { defineRoute } from './route'; -const meta = (() => ({ title: 'T', description: 'd'.repeat(60) })) as unknown as RouteMetaFn; +const meta: RouteMetaFn = () => ({ title: 'T', description: 'd'.repeat(60) }); -function staticConfig(overrides: { - readonly prerender?: PrerenderFn; -}): ReturnType { +// `RouteConfig`, not `ReturnType`: `defineRoute` is generic in `TData`, so +// `ReturnType` resolves it to `unknown` and every entry built from this helper was a +// `RouteEntry` that no route consumer accepts. +function staticConfig(overrides: { readonly prerender?: PrerenderFn }): RouteConfig { return defineRoute({ render: 'static', offline: 'precache', diff --git a/packages/render/src/route-data.test.ts b/packages/render/src/route-data.test.ts index 9763d5cc..896013ef 100644 --- a/packages/render/src/route-data.test.ts +++ b/packages/render/src/route-data.test.ts @@ -47,7 +47,7 @@ describe('routeDataFor', () => { const config = defineRoute({ ...base, load: (ctx) => ({ id: ctx.params['id'] }), - meta: (data) => ({ title: `post ${data.id}` }), + meta: (ctx) => ({ title: `post ${ctx.data.id}` }), }); expect(await routeDataFor(config, CTX)).toEqual({ id: '7' }); }); diff --git a/packages/render/src/route.test.ts b/packages/render/src/route.test.ts index e6492f87..e556da4a 100644 --- a/packages/render/src/route.test.ts +++ b/packages/render/src/route.test.ts @@ -1,8 +1,15 @@ import { describe, expect, test } from 'bun:test'; import { RouteMetaMissingError, RouteModeInvalidError, RouteOfflineMissingError } from './errors'; import { assertModeInvariants } from './modes'; -import type { RouteConfig, RouteDefinition, RouteMetaFn } from './route'; +import type { + RouteConfig, + RouteData, + RouteDefinition, + RouteMetaContext, + RouteMetaFn, +} from './route'; import { defineRoute, isRouteConfig } from './route'; +import { metaContextFor } from './route-data'; /** UltimateError carries a `fix`; read it structurally so the test needs no core import. */ export function fixOf(error: unknown): string { @@ -11,10 +18,16 @@ export function fixOf(error: unknown): string { const DESCRIPTION = 'A description that is comfortably inside the fifty-to-one-sixty range.'; -const meta = (() => ({ - title: 'Title', - description: DESCRIPTION, -})) as unknown as RouteMetaFn; +const meta: RouteMetaFn = () => ({ title: 'Title', description: DESCRIPTION }); + +/** + * What `meta` is actually handed, built by the one builder every render mode uses. The `{}` these + * calls used to pass satisfied nothing: `meta` reads `ctx.data`, and a bare object is not a + * context, so the descriptor's own normalization was being asserted against a shape production + * never produces. + */ +const metaCtx = (data: RouteData = {}): RouteMetaContext => + metaContextFor({ params: {}, url: 'http://localhost/' }, data); describe('defineRoute', () => { test('offline is required by the type — axiom 3 lives in the type system', () => { @@ -64,8 +77,8 @@ describe('the descriptor normalizes meta to one shape', () => { defineRoute({ render: 'static', offline: 'precache', hydrate: 'never', meta: metaFn }); test('a synchronous meta is awaitable and resolves to what it returned', async () => { - const config = route((data) => ({ title: `Sync ${String(data['id'] ?? '')}`.trim() })); - const resolved = config.meta({ id: '7' }); + const config = route((ctx) => ({ title: `Sync ${String(ctx.data['id'] ?? '')}`.trim() })); + const resolved = config.meta(metaCtx({ id: '7' })); expect(resolved).toBeInstanceOf(Promise); expect(await resolved).toEqual({ title: 'Sync 7' }); }); @@ -73,16 +86,16 @@ describe('the descriptor normalizes meta to one shape', () => { test('an async meta behaves identically — the caller cannot tell them apart', async () => { const syncRoute = route(() => ({ title: 'Same', description: DESCRIPTION })); const asyncRoute = route(async () => ({ title: 'Same', description: DESCRIPTION })); - expect(syncRoute.meta({})).toBeInstanceOf(Promise); - expect(asyncRoute.meta({})).toBeInstanceOf(Promise); - expect(await syncRoute.meta({})).toEqual(await asyncRoute.meta({})); + expect(syncRoute.meta(metaCtx())).toBeInstanceOf(Promise); + expect(asyncRoute.meta(metaCtx())).toBeInstanceOf(Promise); + expect(await syncRoute.meta(metaCtx())).toEqual(await asyncRoute.meta(metaCtx())); }); test('a meta that throws synchronously rejects instead, so one catch covers both', async () => { const config = route(() => { throw new RangeError('no post'); }); - await expect(config.meta({})).rejects.toThrow('no post'); + await expect(config.meta(metaCtx())).rejects.toThrow('no post'); }); }); diff --git a/packages/scraping/src/cdp-fake.test.ts b/packages/scraping/src/cdp-fake.test.ts index 546d46f7..a7468ac7 100644 --- a/packages/scraping/src/cdp-fake.test.ts +++ b/packages/scraping/src/cdp-fake.test.ts @@ -131,7 +131,7 @@ describe('unit · the fake page`s DOM answers the real target`s reads', () => { }); const target = await openOver(browser); - await target.click('#pay'); + await target.click('#pay', 0); expect(target.url()).toBe('https://shop.test/receipt'); expect(await target.content()).toContain('Paid'); @@ -141,7 +141,7 @@ describe('unit · the fake page`s DOM answers the real target`s reads', () => { test('a click with no route leaves the page where it was', async () => { const browser = fakeCdpBrowser(PAGE); const target = await openOver(browser); - await target.click('#pay'); + await target.click('#pay', 0); expect(target.url()).toBe('https://shop.test/orders'); }); @@ -185,8 +185,8 @@ describe('unit · the fake page`s DOM answers the real target`s reads', () => { test('screenshot and pdf answer bytes, so the artifact path has something to write', async () => { const browser = fakeCdpBrowser(PAGE); const target = await openOver(browser); - expect([...(await target.screenshot({ fullPage: true }))]).toEqual([1, 2, 3]); - expect([...(await target.pdf({}))]).toEqual([4, 5]); + expect([...(await target.screenshot({ fullPage: true, timeoutMs: 1_000 }))]).toEqual([1, 2, 3]); + expect([...(await target.pdf({ timeoutMs: 1_000 }))]).toEqual([4, 5]); }); }); diff --git a/packages/scraping/src/cdp-target-surface.test.ts b/packages/scraping/src/cdp-target-surface.test.ts index c5679c55..cd7ffa71 100644 --- a/packages/scraping/src/cdp-target-surface.test.ts +++ b/packages/scraping/src/cdp-target-surface.test.ts @@ -171,7 +171,7 @@ describe('unit · the target`s input calls reach the page, not a reimplementatio test('click, type and select pass their selector and values straight through', async () => { const fixture = rich(); const target = await targetOver(fixture); - await target.click('#buy'); + await target.click('#buy', 0); await target.type('#email', 'a@b.test'); await target.select('#size', ['m', 'l']); expect(fixture.calls).toContain('click #buy'); @@ -207,7 +207,7 @@ describe('unit · capture', () => { // Some builds answer text and some answer bytes; an artifact written from the string would be // a PNG nobody can open. const fixture = rich({ screenshotBase64: true }); - const shot = await (await targetOver(fixture)).screenshot({ fullPage: true }); + const shot = await (await targetOver(fixture)).screenshot({ fullPage: true, timeoutMs: 1_000 }); expect(shot).toBeInstanceOf(Uint8Array); expect(new TextDecoder().decode(shot)).toBe('PNG'); expect(fixture.calls).toContain('screenshot fullPage=true'); @@ -215,14 +215,14 @@ describe('unit · capture', () => { test('bytes are passed through unchanged, and fullPage defaults to false', async () => { const fixture = rich(); - const shot = await (await targetOver(fixture)).screenshot({}); + const shot = await (await targetOver(fixture)).screenshot({ timeoutMs: 1_000 }); expect([...shot]).toEqual([1, 2, 3]); expect(fixture.calls).toContain('screenshot fullPage=false'); }); test('pdf comes straight off the page', async () => { const fixture = rich(); - expect([...(await (await targetOver(fixture)).pdf({}))]).toEqual([4, 5]); + expect([...(await (await targetOver(fixture)).pdf({ timeoutMs: 1_000 }))]).toEqual([4, 5]); }); test('download() is an honest X_NOT_IMPLEMENTED, never empty bytes', async () => { @@ -302,7 +302,7 @@ describe('unit · frames', () => { expect(inner.url()).toBe('https://shop.test/checkout-frame'); expect(await inner.content()).toBe('

frame

'); expect((await inner.query('a')).map((element) => element.text)).toEqual(['Order 1']); - await inner.click('#pay'); + await inner.click('#pay', 0); await inner.type('#card', '4242'); await inner.select('#country', ['de']); await inner.evaluate('1 + 1'); @@ -329,7 +329,7 @@ describe('unit · a dead renderer is a CODE, not a hang', () => { fixture.emit('error', { message: 'Renderer process crashed' }); await expect(target.content()).rejects.toThrow(/X_SCRAPE_PAGE_CRASHED|crashed/); - await expect(target.click('#buy')).rejects.toThrow(/X_SCRAPE_PAGE_CRASHED|crashed/); + await expect(target.click('#buy', 0)).rejects.toThrow(/X_SCRAPE_PAGE_CRASHED|crashed/); // And the call never reached the page: a crashed tab would wait out its own timeout. expect(fixture.calls).not.toContain('click #buy'); }); diff --git a/packages/scraping/src/http.test.ts b/packages/scraping/src/http.test.ts index 2bd90af1..d164bcd7 100644 --- a/packages/scraping/src/http.test.ts +++ b/packages/scraping/src/http.test.ts @@ -33,10 +33,10 @@ const transport = ( timeoutMs: 1_000, network, session: () => Promise.resolve(session), - fetch: ((url: string, init: RequestInit) => { + fetch: (url, init) => { calls.push({ url, headers: (init.headers ?? {}) as Record }); return Promise.resolve(new Response(answer.body, { status: answer.status })); - }) as unknown as typeof fetch, + }, }); return { http, calls, network }; }; @@ -62,7 +62,7 @@ describe('unit · the HTTP leg is session-bound, not a bare fetch', () => { }, ); await http.request('https://api.test/orders'); - expect(calls[0]?.headers.cookie).toBe('sid=abc'); + expect(calls[0]?.headers['cookie']).toBe('sid=abc'); expect(calls[0]?.headers['x-csrf']).toBe('tok'); expect(calls[0]?.headers['user-agent']).toBe('Mozilla/5.0 (a real one)'); }); @@ -85,7 +85,7 @@ describe('unit · the HTTP leg is session-bound, not a bare fetch', () => { }, ); await http.request('https://api.test/orders'); - expect(calls[0]?.headers.cookie).toBeUndefined(); + expect(calls[0]?.headers['cookie']).toBeUndefined(); }); test('the SAME allow list gates this transport — refused before a byte leaves', async () => { @@ -152,27 +152,27 @@ describe('unit · the session jar is EVERY domain the browser touched, so scopin test('a host-only bank.test cookie never reaches evilbank.test — a suffix is not a domain', async () => { const { http, calls } = transport({ status: 200, body: '{}' }, hostOnly('bank.test'), ['*']); await http.request('https://evilbank.test/a'); - expect(calls[0]?.headers.cookie).toBeUndefined(); + expect(calls[0]?.headers['cookie']).toBeUndefined(); }); test('a host-only bank.test cookie never reaches sub.bank.test — host-only means the host', async () => { const { http, calls } = transport({ status: 200, body: '{}' }, hostOnly('bank.test'), ['*']); await http.request('https://sub.bank.test/a'); - expect(calls[0]?.headers.cookie).toBeUndefined(); + expect(calls[0]?.headers['cookie']).toBeUndefined(); }); test('the cookie DOES reach the host it belongs to', async () => { const { http, calls } = transport({ status: 200, body: '{}' }, hostOnly('bank.test'), ['*']); await http.request('https://bank.test/a'); - expect(calls[0]?.headers.cookie).toBe('sid=SECRET'); + expect(calls[0]?.headers['cookie']).toBe('sid=SECRET'); }); test('a domain-scoped .bank.test cookie DOES reach a subdomain, and still not evilbank.test', async () => { const { http, calls } = transport({ status: 200, body: '{}' }, hostOnly('.bank.test'), ['*']); await http.request('https://sub.bank.test/a'); await http.request('https://evilbank.test/a'); - expect(calls[0]?.headers.cookie).toBe('sid=SECRET'); - expect(calls[1]?.headers.cookie).toBeUndefined(); + expect(calls[0]?.headers['cookie']).toBe('sid=SECRET'); + expect(calls[1]?.headers['cookie']).toBeUndefined(); }); test('a cookie scoped to /admin is not sent to /public', async () => { @@ -183,8 +183,8 @@ describe('unit · the session jar is EVERY domain the browser touched, so scopin ); await http.request('https://bank.test/public'); await http.request('https://bank.test/admin/users'); - expect(calls[0]?.headers.cookie).toBeUndefined(); - expect(calls[1]?.headers.cookie).toBe('sid=SECRET'); + expect(calls[0]?.headers['cookie']).toBeUndefined(); + expect(calls[1]?.headers['cookie']).toBe('sid=SECRET'); }); }); @@ -196,10 +196,10 @@ describe('unit · the response body is bounded by BYTES, not only by time', () = timeoutMs: 1_000, network: createRing(), session: () => Promise.resolve(EMPTY_SESSION), - fetch: (() => + fetch: () => Promise.resolve( new Response(body, { status: 200, ...(headers === undefined ? {} : { headers }) }), - )) as unknown as typeof fetch, + ), }); test('a body past maxBytes is refused rather than buffered whole', async () => { @@ -235,8 +235,7 @@ describe('unit · the response body is bounded by BYTES, not only by time', () = timeoutMs: 1_000, network: createRing(), session: () => Promise.resolve(EMPTY_SESSION), - fetch: (() => - Promise.resolve(new Response(endless, { status: 200 }))) as unknown as typeof fetch, + fetch: () => Promise.resolve(new Response(endless, { status: 200 })), }); expect(await codeOf(http.request('https://api.test/firehose', { maxBytes: 256 * 1024 }))).toBe( 'X_SCRAPE_BODY_TOO_LARGE', @@ -252,8 +251,7 @@ describe('unit · response headers are data, not a prototype the site can reach' timeoutMs: 1_000, network: createRing(), session: () => Promise.resolve(EMPTY_SESSION), - fetch: (() => - Promise.resolve(new Response('{}', { status: 200, headers }))) as unknown as typeof fetch, + fetch: () => Promise.resolve(new Response('{}', { status: 200, headers })), }); test('a site sending __proto__ and constructor gets both filed as ordinary keys', async () => { diff --git a/packages/scraping/src/http.ts b/packages/scraping/src/http.ts index 929eedc7..185ede0f 100644 --- a/packages/scraping/src/http.ts +++ b/packages/scraping/src/http.ts @@ -22,6 +22,24 @@ import type { NetworkRing } from './rings'; import type { RobotsGate } from './robots'; import type { SessionSnapshot } from './session-state'; +/** + * Just the call. `typeof fetch` also carries `preconnect`, which no test double and no app wrapper + * can supply — so an option typed `typeof fetch` was unusable without a double cast, which is + * exactly what every caller of it had written. The same seam `@ultimat3/cache`, `@ultimat3/auth` + * and `@ultimat3/mail` already name. + */ +export type ScrapeFetch = (input: string, init: ScrapeFetchInit) => Promise; + +/** + * `RequestInit` plus the one Bun extension this package sets. Named rather than cast: the DOM's + * `RequestInit` has no `proxy`, and an `as RequestInit` over the literal silenced the excess-key + * check for `proxy` AND for every neighbouring key it was standing next to. + */ +export interface ScrapeFetchInit extends RequestInit { + /** The session's exit. A different exit IP mid-session is a different client to an anti-bot. */ + readonly proxy?: string | undefined; +} + export interface HttpRequestInit { readonly method?: string | undefined; readonly headers?: Readonly> | undefined; @@ -80,7 +98,7 @@ export interface HttpTransportInit { readonly onActivity?: (() => void) | undefined; /** The SAME proxy the browser dialled through. A different exit IP is a different client. */ readonly proxy?: string | undefined; - readonly fetch?: typeof fetch | undefined; + readonly fetch?: ScrapeFetch | undefined; } /** @@ -132,7 +150,7 @@ export function responseOver( * robots rule, and neither is re-implemented for the second leg. */ export function httpOverFetch(init: HttpTransportInit): ScrapeHttp { - const call = init.fetch ?? fetch; + const call: ScrapeFetch = init.fetch ?? fetch; return { async request(url: string, request: HttpRequestInit = {}): Promise { init.onActivity?.(); @@ -162,7 +180,7 @@ export function httpOverFetch(init: HttpTransportInit): ScrapeHttp { ...(request.body === undefined ? {} : { body: request.body }), signal: AbortSignal.any(signals), ...(init.proxy === undefined ? {} : { proxy: init.proxy }), - } as RequestInit); + }); init.network.push({ method: request.method ?? 'GET', url, diff --git a/packages/scraping/src/robots-fetch.test.ts b/packages/scraping/src/robots-fetch.test.ts index 436519ba..b7e0b88d 100644 --- a/packages/scraping/src/robots-fetch.test.ts +++ b/packages/scraping/src/robots-fetch.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test'; +import type { ScrapeFetch } from './http'; import { createRobotsGate } from './robots'; import { DEFAULT_ROBOTS_MAX_BYTES, robotsFetcher } from './robots-fetch'; @@ -14,9 +15,9 @@ const bodyResponse = (body: ReadableStream, status = 200): Response new Response(body, { status }); /** Resolves only when the caller's signal aborts — a hung CDN, without the wait. */ -const hangingFetch: typeof fetch = (_url, init) => +const hangingFetch: ScrapeFetch = (_url, init) => new Promise((_resolve, reject) => { - const signal = (init as RequestInit | undefined)?.signal; + const { signal } = init; if (signal == null) return; signal.addEventListener('abort', () => { reject(signal.reason ?? new Error('aborted')); @@ -79,7 +80,7 @@ describe('unit · the default robots.txt read', () => { // came to exit from the worker's IP while every page load exited through the proxy. test('the exit is resolved per read, and only dialled when there is one', async () => { const seen: Array> = []; - const record: typeof fetch = (_url, init) => { + const record: ScrapeFetch = (_url, init) => { seen.push((init ?? {}) as Record); return Promise.resolve(bodyResponse(streamOf([]))); }; diff --git a/packages/scraping/src/robots-fetch.ts b/packages/scraping/src/robots-fetch.ts index 56747e8d..62926c78 100644 --- a/packages/scraping/src/robots-fetch.ts +++ b/packages/scraping/src/robots-fetch.ts @@ -9,6 +9,7 @@ // `driver.open()`, and the proxy is a driver option the session only reports on the way back out. import { readWithinLimit } from '@ultimat3/core'; +import type { ScrapeFetch } from './http'; import type { RobotsFetch } from './robots'; /** @@ -40,8 +41,12 @@ export interface RobotsFetchInit { */ readonly proxy?: (() => string | undefined) | undefined; readonly maxBytes?: number | undefined; - /** The platform `fetch`, injectable so the default path itself is testable. */ - readonly fetch?: typeof fetch | undefined; + /** + * The platform `fetch`, injectable so the default path itself is testable. `ScrapeFetch` and not + * `typeof fetch`: the latter also carries `preconnect`, so nothing a caller can write satisfies + * it and the option was reachable only through a cast. + */ + readonly fetch?: ScrapeFetch | undefined; } /** @@ -50,7 +55,7 @@ export interface RobotsFetchInit { * and a 404 are all the same answer on purpose: none of them is evidence of a rule. */ export function robotsFetcher(init: RobotsFetchInit = {}): RobotsFetch { - const call = init.fetch ?? fetch; + const call: ScrapeFetch = init.fetch ?? fetch; const limit = init.maxBytes ?? DEFAULT_ROBOTS_MAX_BYTES; return async (robotsUrl: string): Promise => { // Armed per read, not per gate: the gate is long-lived and reads once per origin, so a @@ -64,7 +69,7 @@ export function robotsFetcher(init: RobotsFetchInit = {}): RobotsFetch { const response = await call(robotsUrl, { signal, ...(proxy === undefined || proxy === '' ? {} : { proxy }), - } as RequestInit); + }); if (!response.ok) return undefined; // Counted as it arrives rather than `.text()`, which materialises the whole body first: a // multi-gigabyte robots.txt is a heap the worker never gets back. diff --git a/packages/scraping/src/scrape.test.ts b/packages/scraping/src/scrape.test.ts index 2ce3b4aa..29c1af43 100644 --- a/packages/scraping/src/scrape.test.ts +++ b/packages/scraping/src/scrape.test.ts @@ -172,12 +172,12 @@ describe('unit · the login path', () => { }, }), ); - process.env.SHOP_PASSWORD = 'hunter2'; + process.env['SHOP_PASSWORD'] = 'hunter2'; const report = (await handle.run(runArgs({ page: 1 }))) as ScrapeReport<{ id: string }>; expect(logins).toBe(1); expect(report.rows).toHaveLength(2); expect(await store.load('no-tenant/orders/account-a')).toBeDefined(); - delete process.env.SHOP_PASSWORD; + delete process.env['SHOP_PASSWORD']; }); test('a refused credential is recorded, and the next run never reaches the site', async () => { diff --git a/packages/scraping/src/session-state.test.ts b/packages/scraping/src/session-state.test.ts index af945a9f..2c3e7c34 100644 --- a/packages/scraping/src/session-state.test.ts +++ b/packages/scraping/src/session-state.test.ts @@ -4,6 +4,7 @@ import { describe, expect, test } from 'bun:test'; import type { StorageDriver, StorageObject } from '@ultimat3/storage'; +import { cookieHeaderFor } from './cookie-scope'; import type { SessionState } from './session-state'; import { DEFAULT_SESSION_PREFIX, @@ -228,7 +229,28 @@ describe('unit · parseSessionState', () => { }, 'k', ); - expect(parsed?.cookies).toEqual([{ name: 'sid', value: 'abc' }]); + expect(parsed?.cookies).toEqual([ + { name: 'sid', value: 'abc', domain: '', path: '/', httpOnly: false, secure: false }, + ]); + }); + + test('a cookie missing its scope is COMPLETED, never smuggled through as half a ScrapeCookie', () => { + // `isCookie` asserted `value is ScrapeCookie` while checking two of that type's six required + // fields, so a stored `{ name, value }` left this function typed as a whole cookie with no + // `domain` — and `cookieDomainMatches`, reached from the public `cookieHeaderFor`, calls + // `.trim()` on it. Completing the record is what makes the declared type true. + const parsed = parseSessionState( + { savedAt: '2026-01-01T00:00:00.000Z', cookies: [{ name: 'sid', value: 'abc' }] }, + 'k', + ); + expect(parsed?.cookies).toEqual([ + { name: 'sid', value: 'abc', domain: '', path: '/', httpOnly: false, secure: false }, + ]); + // And what makes the completion SAFE rather than a guess: an unscoped cookie reaches no host. + // `cookiesForUrl` fails closed on an empty domain, so the default cannot widen a jar — the + // alternative, inferring the domain from whichever URL asked, is how a `bank.test` session + // cookie ends up on `evilbank.test`. + expect(cookieHeaderFor(parsed?.cookies ?? [], 'https://bank.test/')).toBeUndefined(); }); test('the missing halves default rather than making the record unreadable', () => { diff --git a/packages/scraping/src/session-state.ts b/packages/scraping/src/session-state.ts index 9ad16eca..c3911a49 100644 --- a/packages/scraping/src/session-state.ts +++ b/packages/scraping/src/session-state.ts @@ -157,11 +157,35 @@ export function storageSessionStore( }; } -const isCookie = (value: unknown): value is ScrapeCookie => - typeof value === 'object' && - value !== null && - typeof (value as { name?: unknown }).name === 'string' && - typeof (value as { value?: unknown }).value === 'string'; +/** + * A stored cookie is somebody else's JSON. `name` and `value` are what makes it a cookie at all; + * the four scope fields `ScrapeCookie` REQUIRES are completed here rather than asserted. + * + * Asserting them was the bug: this was a `value is ScrapeCookie` predicate that checked two of + * that type's six required fields, so a stored `{ name, value }` left `parseSessionState` typed as + * a whole cookie with no `domain` — and `cookieHeaderFor`, a public export, hands it to + * `cookieDomainMatches`, which calls `.trim()` on it and throws a bare `TypeError`. + * + * The defaults are the ones `cookie-scope.ts` already documents. An empty `domain` matches NO + * host, which is the point: an unscoped cookie must reach nothing, because the only other way to + * scope it is to infer the domain from whichever URL is asking, and that is exactly how a + * `bank.test` session cookie reaches `evilbank.test`. `/` is §5.1.4's reading of an absent path, + * and an attribute a jar never wrote is `false`. + */ +const toCookie = (value: unknown): ScrapeCookie | undefined => { + if (typeof value !== 'object' || value === null) return undefined; + const entry = value as Partial; + if (typeof entry.name !== 'string' || typeof entry.value !== 'string') return undefined; + return { + name: entry.name, + value: entry.value, + domain: typeof entry.domain === 'string' ? entry.domain : '', + path: typeof entry.path === 'string' ? entry.path : '/', + ...(typeof entry.expires === 'number' ? { expires: entry.expires } : {}), + httpOnly: entry.httpOnly === true, + secure: entry.secure === true, + }; +}; /** Stored JSON is `unknown`. Read structurally, and answer `undefined` rather than half a session. */ export function parseSessionState(raw: unknown, key: string): SessionState | undefined { @@ -172,7 +196,7 @@ export function parseSessionState(raw: unknown, key: string): SessionState | und key, savedAt: value.savedAt, ...(typeof value.refusedAt === 'string' ? { refusedAt: value.refusedAt } : {}), - cookies: value.cookies.filter((cookie): cookie is ScrapeCookie => isCookie(cookie)), + cookies: value.cookies.flatMap((cookie: unknown) => toCookie(cookie) ?? []), headers: value.headers ?? {}, storage: value.storage ?? {}, userAgent: value.userAgent ?? '', diff --git a/packages/testing/src/determinism.test.ts b/packages/testing/src/determinism.test.ts index 92d92291..3883326c 100644 --- a/packages/testing/src/determinism.test.ts +++ b/packages/testing/src/determinism.test.ts @@ -58,7 +58,11 @@ describe('unit · determinism', () => { expect(frozenNow() instanceof Date).toBe(true); // Still a real predicate: it must not wave through something that is not a date at all. expect(({} as unknown) instanceof Date).toBe(false); - expect('2020-05-05' instanceof (Date as unknown as new () => object)).toBe(false); + // `unknown`, not a cast on the constructor: the LHS is a PRIMITIVE, which is what the + // narrowing `instanceof` refuses, and a date-shaped string is the value most likely to be + // waved through by a patched predicate that only looks at the text. + const dateShapedString: unknown = '2020-05-05'; + expect(dateShapedString instanceof Date).toBe(false); }); test('a Date built in another realm is still a Date', () => { diff --git a/packages/testing/src/factories.test.ts b/packages/testing/src/factories.test.ts index 3adc2a2a..910b3919 100644 --- a/packages/testing/src/factories.test.ts +++ b/packages/testing/src/factories.test.ts @@ -50,10 +50,14 @@ const postFactory = () => /** Every `create()` in this file goes through one recorder; a leaked one would fail the next file. */ const recorder = () => { - const written: { table: string; row: Record }[] = []; + // A `Map`, not a spread into a `Record`: `Persister.insert` receives `TRow extends object`, and + // an `interface` row has no index signature — which is exactly why `defineFactory` constrains + // `TRow` to `object` and not to `Record`. Enumerating the columns once, here, + // is what keeps every assertion below free of a cast. + const written: { table: string; row: ReadonlyMap }[] = []; usePersister({ insert: async (table: string, row: TRow) => { - written.push({ table, row: { ...row } }); + written.push({ table, row: new Map(Object.entries(row)) }); }, }); return written; @@ -189,8 +193,8 @@ describe(testName('unit', 'factory associations'), () => { const written = recorder(); const row = await postFactory().create(); expect(written.map((entry) => entry.table)).toEqual(['orgs', 'posts']); - expect(written[1]?.row['orgId']).toBe(row.orgId); - expect(written[0]?.row['id']).toBe(row.orgId); + expect(written[1]?.row.get('orgId')).toBe(row.orgId); + expect(written[0]?.row.get('id')).toBe(row.orgId); }); test('an overridden association column creates no parent row at all', async () => { @@ -222,7 +226,7 @@ describe(testName('unit', 'factory create'), () => { const written = recorder(); const rows = await orgFactory().createMany(3); expect(rows).toHaveLength(3); - expect(written.map((entry) => entry.row['id'])).toEqual(rows.map((row) => row.id)); + expect(written.map((entry) => entry.row.get('id'))).toEqual(rows.map((row) => row.id)); }); test('with no persister it names the table and the two ways out', async () => { diff --git a/packages/testing/src/factories.ts b/packages/testing/src/factories.ts index 24cfbcb9..137ffb30 100644 --- a/packages/testing/src/factories.ts +++ b/packages/testing/src/factories.ts @@ -63,7 +63,15 @@ export interface FactoryOptions = TraitMap< /** Values for every column the entity requires; called once per built row. */ defaults(index: number, ids: FactoryIds): TRow; readonly traits?: TTraits; - readonly associations?: AssociationMap; + /** + * `NoInfer`, because `AssociationMap` is homomorphic over `keyof TRow` and TypeScript + * reverse-maps it into an inference candidate: a factory declaring `associations: { orgId }` + * inferred `TRow = { orgId: string }` and silently dropped every other column from `build()`, + * `Partial` overrides and `Trait`. Associations are a SUBSET of the columns by + * construction, so they can never be a correct source for the row type — `defaults` is, and it + * is the one member required to name every column. + */ + readonly associations?: AssociationMap>; } export interface Factory { diff --git a/packages/testing/src/fixtures.test.ts b/packages/testing/src/fixtures.test.ts index 1b6afa81..f8fe5f69 100644 --- a/packages/testing/src/fixtures.test.ts +++ b/packages/testing/src/fixtures.test.ts @@ -1,4 +1,5 @@ import { afterAll, beforeEach, test as bunTest, describe, expect } from 'bun:test'; +import type { FixtureBag } from './fixtures'; import { clearFixtures, defineFixtures, @@ -8,6 +9,24 @@ import { runWithFixtures, } from './fixtures'; +/** + * The annotation on a body `requestedFixtures` only ever READS — it parses `body.toString()` and + * never calls it, so the parameter's type is not part of what is under test. It only has to admit + * the PATTERNS under test, which is why the values are objects: two of them destructure a nested + * one (`clock: { now }`) and one defaults to one (`clock = { now: 1 }`). `never` was the previous + * answer and could do neither — destructuring `never` types every binding `never`, so nothing is + * assignable to it and nothing can be read out of it. + */ +interface ProbeBag { + readonly seed: unknown; + readonly actorFor: unknown; + readonly mail: unknown; + readonly network: unknown; + readonly page: unknown; + /** The one name a pattern below reaches INTO, so it is the one with a shape. */ + readonly clock: { readonly now: unknown }; +} + // The registry is process-global and the preload filled it. Hand it back, or every file that // runs after this one loses `clock`, `seed` and the rest — a load-order flake, not a failure. const preloaded = fixtureSnapshot(); @@ -23,10 +42,9 @@ afterAll(() => { describe('requestedFixtures', () => { bunTest('reads the destructured names so unused fixtures are never built', () => { - expect(requestedFixtures(async ({ seed, actorFor }: never) => void [seed, actorFor])).toEqual([ - 'seed', - 'actorFor', - ]); + expect( + requestedFixtures(async ({ seed, actorFor }: ProbeBag) => void [seed, actorFor]), + ).toEqual(['seed', 'actorFor']); }); bunTest('returns nothing for a body that takes no fixtures', () => { @@ -41,19 +59,18 @@ describe('requestedFixtures', () => { // the exact failure this module's own header says it exists to prevent. bunTest('reads past a nested object in the pattern', () => { expect( - requestedFixtures(({ mail, clock: { now }, network }: never) => void [mail, now, network]), + requestedFixtures(({ mail, clock: { now }, network }: ProbeBag) => void [mail, now, network]), ).toEqual(['mail', 'clock', 'network']); }); bunTest('reads past an object default in the pattern', () => { - expect(requestedFixtures(({ clock = { now: 1 }, mail }: never) => void [clock, mail])).toEqual([ - 'clock', - 'mail', - ]); + expect( + requestedFixtures(({ clock = { now: 1 }, mail }: ProbeBag) => void [clock, mail]), + ).toEqual(['clock', 'mail']); }); bunTest('handles renaming and whitespace', () => { - expect(requestedFixtures(({ seed: s, page }: never) => void [s, page])).toEqual([ + expect(requestedFixtures(({ seed: s, page }: ProbeBag) => void [s, page])).toEqual([ 'seed', 'page', ]); @@ -69,18 +86,24 @@ describe('fixtureTest teardown', () => { outer: () => ({ [Symbol.dispose]: () => void disposed.push('outer') }), broken: () => ({ [Symbol.dispose]: () => { + // Recorded BEFORE the throw, so the list pins the ORDER as well as the survival: + // asserting only `['outer']` passes just as well when teardown runs in build order, + // which is the opposite of what this seam promises. + disposed.push('broken'); throw new Error('teardown exploded'); }, }), }); // `broken` is built second, so it disposes first — `outer` must still be reached. - const thrown = await runWithFixtures(({ outer, broken }: never) => void [outer, broken]).then( + const thrown = await runWithFixtures( + ({ outer, broken }: FixtureBag) => void [outer, broken], + ).then( () => undefined, (error: unknown) => error, ); - expect(disposed).toEqual(['outer']); + expect(disposed).toEqual(['broken', 'outer']); expect((thrown as Error).message).toBe('teardown exploded'); }); @@ -93,7 +116,7 @@ describe('fixtureTest teardown', () => { }), }); - const thrown = await runWithFixtures(({ broken }: never) => { + const thrown = await runWithFixtures(({ broken }: FixtureBag) => { void broken; throw new Error('the assertion that actually broke'); }).then( diff --git a/packages/testing/src/fixtures.ts b/packages/testing/src/fixtures.ts index 360b036c..08d56fac 100644 --- a/packages/testing/src/fixtures.ts +++ b/packages/testing/src/fixtures.ts @@ -160,6 +160,21 @@ export function requestedFixtures(body: (...args: never[]) => unknown): readonly export type FixtureBody = (fixtures: Fixtures) => void | Promise; +/** + * What the RUNNER hands a body, which is wider than `Fixtures` and always was: `defineFixtures` + * accepts a key `Fixtures` does not name — "the app's", per its own docstring — so a body + * destructuring an app-registered fixture is the primary case, not an edge. `runWithFixtures` + * built exactly this shape and then asserted it back to `Fixtures` to call the body. + * + * `fixtureTest` still takes `FixtureBody`, so nothing an app writes gets looser: the framework + * keys stay exactly typed, and an app types its OWN keys by augmenting `Fixtures`, which remains + * the one documented way to do it. + */ +export type FixtureBag = Fixtures & Readonly>; + +/** The runner's body. Looser than `FixtureBody` for the reason `FixtureBag` states. */ +export type FixtureRunBody = (bag: FixtureBag) => unknown; + /** * A fixture that installs process-global state — the ambient job driver, the ambient mail * driver — implements one of the standard disposal symbols to put it back. Bun shares one @@ -187,8 +202,8 @@ const disposerOf = (value: unknown): (() => PromiseLike | void) | undefine * testing, and it cannot be observed through a registration. Not in the package's public API: * `fixtureTest` stays the one way to write a test with fixtures. */ -export async function runWithFixtures(body: FixtureBody): Promise { - const wanted = requestedFixtures(body as (...args: never[]) => unknown); +export async function runWithFixtures(body: FixtureRunBody): Promise { + const wanted = requestedFixtures(body); // Partial by construction — only what the body destructured is built. Handed over as the // full `Fixtures` because the keys came from that same body: a key it did not name is a key // it cannot read, so the missing ones are unobservable. @@ -205,7 +220,7 @@ export async function runWithFixtures(body: FixtureBody): Promise { built.push(value); bag[key] = value; } - await body(bag as Fixtures); + await body(bag as FixtureBag); } catch (error) { failure = { error }; } diff --git a/scripts/lib/test-typecheck-pins.ts b/scripts/lib/test-typecheck-pins.ts index c9a54981..85c954d0 100644 --- a/scripts/lib/test-typecheck-pins.ts +++ b/scripts/lib/test-typecheck-pins.ts @@ -11,29 +11,37 @@ // about its own: a pin keyed on a file and a line goes stale on every edit to the file above it, // and churn teaches a reader to regenerate a ratchet without looking at it. // -// Cheapest first — this is the order the remaining packages should be sliced in, one PR per -// batch. The classes are the 2026-08-19 measurement and are advisory; the number is the rule: +// What is left, and the order to slice it in. The classes are advisory; the number is the rule: // // | Package | Errors | The classes behind the count | // |---|---|---| -// | testing | 20 | TS2339, TS2353, TS2322, TS2345 | // | action | 21 | TS4111, TS2353, TS2322, TS2769 | -// | realtime | 22 | TS2769, TS2339, TS2741, TS2353 | -// | jobs | 25 | TS2722, TS7006, TS4111, TS2741 | -// | scraping | 27 | TS4111, TS2741, TS2554 | -// | mcp | 30 | TS2345, TS4111, TS2339, TS2739 | -// | render | 48 | TS2379 (`exactOptionalPropertyTypes`), TS2322, TS2345, TS2739 | -// | cli | 60 | TS2345, TS2769, TS2322, TS18046 | -// | entity | 78 | TS4111 (index-signature access), TS2769, TS18048 | +// | entity | 22 | TS4111 (index-signature access), TS2769, TS18048 | +// | cli | 59 | TS2345, TS2769, TS2322, TS18046 | // -// At zero and staying there: `create-ultimate`, `money`, `seo` (never had a line), plus the 18 -// closed by the first phase-2 batch — `core`, `schema`, `i18n`, `time`, `pwa`, `storage`, `db`, -// `cache`, `flags`, `auth`, `http`, `policy`, `query`, `mail`, `manifest`, `ui`, `ai`, `admin`. -// 115 errors, and four of them were the type being wrong rather than the test: `LocaleSources` -// refused the `undefined` its own sibling reader produces, `testActor()` minted an `Actor` with no -// `kind` and no `scopes` so `hasScope()` threw out of a predicate, `RowProvider` forbade the -// synchronous thunk `Builder.execute` has always awaited, and `ERROR_STATUS` was typed open in the -// one table whose whole argument is that it is closed. +// At zero and staying there: 27 of 30 workspaces. `create-ultimate`, `money` and `seo` never had a +// line; the other 24 were closed in two batches, 344 errors, and eleven of them were the type +// being wrong rather than the test: +// +// | Where | What shipped | +// |---|---| +// | `i18n/context.ts` | `LocaleSources` refused the `undefined` its own sibling reader produces | +// | `policy/test-kit.ts` | `testActor()` minted an `Actor` with no `kind`/`scopes`, so `hasScope()` threw out of a predicate | +// | `query/source.ts` | `RowProvider` forbade the synchronous thunk `execute` has always awaited | +// | `http/error-map.ts` | `ERROR_STATUS` was typed open in the one table whose argument is that it is closed | +// | `scraping/session-state.ts` | `isCookie` claimed `value is ScrapeCookie` after checking 2 of 6 fields | +// | `scraping/http.ts` + `robots-fetch.ts` | `fetch?: typeof fetch` — an option no caller could fill | +// | `ai/{provider,openai-provider,remote-embedder}.ts` | the same, nine double casts deep | +// | `realtime/rebase.ts` | `rebaseFrame` declared the whole union and built one member | +// | `jobs/driver-memory.ts` | `close` optional on a driver that always implements it | +// | `realtime/presence.test.ts` | a `Transport` built by spreading a class instance — no prototype, no `publish` | +// | `jobs/step-options.test.ts` | a two-arg `waitForEvent` call put `{ timeout }` on the `event` parameter | +// +// Two constraints this program imposes that nothing else does. `tsconfig.tests.json` is a SINGLE +// program, so a `declare module` in a `.test.ts` is globally visible — write an augmentation in +// `packages/testing/src/matcher-surface.ts` or a `.d.ts`, never in a test. And a fixture module +// under `src/` is subject to the coverage gate, so every export in one must be reachable from a +// test or it reads as `X_COVERAGE_UNMEASURED`. // // Shrink it with `bun run scripts/test-typecheck-gate.ts --unpin [,]`, which lowers a // count to what is measured and refuses to raise one. Raising a count is a hand edit, in a review. @@ -55,21 +63,21 @@ export const TEST_TYPECHECK_PINS: Readonly> = { flags: 0, http: 0, i18n: 0, - jobs: 25, + jobs: 0, mail: 0, manifest: 0, - mcp: 30, + mcp: 0, money: 0, policy: 0, pwa: 0, query: 0, - realtime: 22, - render: 48, + realtime: 0, + render: 0, schema: 0, - scraping: 27, + scraping: 0, seo: 0, storage: 0, - testing: 20, + testing: 0, time: 0, ui: 0, };