diff --git a/changelog.d/fixes/release-v3850-turbopack-build-red.md b/changelog.d/fixes/release-v3850-turbopack-build-red.md new file mode 100644 index 0000000000..44f69203e3 --- /dev/null +++ b/changelog.d/fixes/release-v3850-turbopack-build-red.md @@ -0,0 +1 @@ +- **fix(build):** repair the broken Turbopack production build, the red lint gate and a runtime crash on `release/v3.8.50`. Six independent module-level defects, each from a different PR, had accumulated because the `Build` CI job is advisory rather than blocking: a lost closing brace in `modelSelectModalHelpers.ts` that swallowed `PROVIDER_TEST_CHUNK_SIZE` into a function body (#9011); `handleFalVideoGeneration` imported twice in `videoGeneration.ts` after the provider-neutral Fal module superseded the standalone handler (#9982 over #9969); `catalog.ts` still re-exporting and calling the injectable stale-while-revalidate policy that #9199 deliberately replaced with a fixed 30 s bound when it landed on top of #8728 — the consumer and the #8728 test suite were never realigned; two dangling statements left in `catalogCache.ts::scheduleBackgroundRefresh` referencing undeclared `inFlight`/`promise`, which made **every** stale-while-revalidate read throw a `ReferenceError` at runtime (a defect the build never caught, surfaced here by the realigned test); a generated wasm-bindgen sidecar URL in `tinycmsSigner.ts` that Turbopack resolves at build time even though the WASM module ships inlined as base64 (#8736/#10087); `conolDiscovery.ts` importing `getProviderOutboundGuard` from `outboundUrlGuard` instead of the sibling `outboundUrlGuardPolicy` module that actually exports it (#8974) — fixed on the consumer side, since re-exporting it would put a `@/`-aliased import into the module the packaged CLI loads without a tsconfig (#7682); and an unbalanced brace in `tests/unit/db-adapters/driverFactory.test.ts` where a new case was inserted between the preceding test's `finally` block and its `});`, so the whole file stopped parsing and the SQLite driver-cascade coverage silently stopped running since 2026-08-11 (#9173). diff --git a/open-sse/executors/tinycmsSigner.ts b/open-sse/executors/tinycmsSigner.ts index cf9084ce9c..f62db10eea 100644 --- a/open-sse/executors/tinycmsSigner.ts +++ b/open-sse/executors/tinycmsSigner.ts @@ -438,7 +438,14 @@ async function __wbg_init(module_or_path) { } if (module_or_path === undefined) { - module_or_path = new URL('wasm_signer_bg.wasm', import.meta.url); + // Upstream wasm-bindgen glue defaults to a sidecar binary resolved via + // `new URL(, import.meta.url)`. OmniRoute ships the module inlined as + // WASM_BASE64 instead — no sidecar exists in the repo — and the only caller, + // initTinyCmsWasm(), always passes that decoded Buffer explicitly, so this + // branch is unreachable. The literal URL still had to go: Turbopack resolves + // `new URL(, import.meta.url)` statically, so keeping it failed + // `next build` with a "Module not found" for the missing sidecar. + throw new Error('TinyCMS WASM module must be supplied explicitly (see initTinyCmsWasm)'); } const imports = __wbg_get_imports(); diff --git a/open-sse/handlers/videoGeneration.ts b/open-sse/handlers/videoGeneration.ts index 2972962713..17cb1db0dc 100644 --- a/open-sse/handlers/videoGeneration.ts +++ b/open-sse/handlers/videoGeneration.ts @@ -18,7 +18,6 @@ import { handleNovitaVideoGeneration } from "./videoGeneration/novitaHandler.ts" import { handleXaiVideoGeneration } from "./videoGeneration/xaiGrokImagineHandler.ts"; import { handleSegmindVideoGeneration } from "./videoGeneration/providers/segmind.ts"; import { handleAdobeFireflyVideoGeneration } from "./videoGeneration/adobeFireflyHandler.ts"; -import { handleFalVideoGeneration } from "./videoGeneration/falHandler.ts"; import { handleOpenAIVideoGeneration } from "./videoGeneration/openai.ts"; import { getVideoJobPreset, handleVideoJobGeneration } from "./videoGeneration/job.ts"; import { diff --git a/open-sse/handlers/videoGeneration/falHandler.ts b/open-sse/handlers/videoGeneration/falHandler.ts deleted file mode 100644 index bd98f5068a..0000000000 --- a/open-sse/handlers/videoGeneration/falHandler.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { saveCallLog } from "@/lib/usageDb"; -import { - FetchTimeoutError, - fetchWithTimeout, - getConfiguredTimeout, -} from "@/shared/utils/fetchTimeout"; -import { sanitizeErrorMessage } from "../../utils/error.ts"; - -interface FalVideoBody { - prompt?: unknown; - aspect_ratio?: unknown; - duration?: unknown; - resolution?: unknown; - quality?: unknown; - generate_audio?: unknown; - poll_interval_ms?: unknown; - [key: string]: unknown; -} - -interface FalCredentials { - apiKey?: unknown; - accessToken?: unknown; -} - -interface FalProviderConfig { - baseUrl: string; -} - -interface FalLog { - info?: (scope: string, message: string, meta?: unknown) => void; - error?: (scope: string, message: string) => void; -} - -function stringValue(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - -function numberValue(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - -function grokDuration(value: unknown, fallback = 6): number { - const numeric = numberValue(value); - if (numeric !== undefined) return Math.round(numeric); - - if (typeof value === "string") { - const match = value.trim().match(/^(\d+)s$/); - if (match) return Number(match[1]); - } - - return fallback; -} - -function falDuration(value: unknown, fallback = "8s"): string { - if (typeof value === "string" && /^(4|6|8)s$/.test(value)) return value; - - const numeric = numberValue(value); - return numeric && [4, 6, 8].includes(numeric) ? `${numeric}s` : fallback; -} - -export function buildFalVideoPayload(model: string, body: FalVideoBody): Record { - const prompt = stringValue(body.prompt) || ""; - const aspectRatio = stringValue(body.aspect_ratio) || "16:9"; - const resolution = stringValue(body.resolution) || (body.quality === "hd" ? "1080p" : "720p"); - - if (model.startsWith("xai/grok-imagine-video/")) { - return { - prompt, - aspect_ratio: aspectRatio, - duration: grokDuration(body.duration), - resolution, - }; - } - - return { - prompt, - aspect_ratio: aspectRatio, - duration: falDuration(body.duration), - resolution, - generate_audio: typeof body.generate_audio === "boolean" ? body.generate_audio : true, - }; -} - -function absoluteFalUrl(value: unknown, baseUrl: string): string | undefined { - const url = stringValue(value); - if (!url) return undefined; - if (url.startsWith("http://") || url.startsWith("https://")) return url; - return `${baseUrl.replace(/\/$/, "")}/${url.replace(/^\//, "")}`; -} - -function normalizeFalVideoResponse(payload: unknown) { - const record = payload && typeof payload === "object" ? (payload as Record) : {}; - const video = - record.video && typeof record.video === "object" - ? (record.video as Record) - : null; - const url = video && typeof video.url === "string" ? video.url.trim() : ""; - - if (!url) { - return { - success: false as const, - status: 502, - error: "Fal video generation returned no video URL", - }; - } - - return { - success: true as const, - data: { - created: typeof record.created === "number" ? record.created : Math.floor(Date.now() / 1000), - data: [{ url, format: "mp4" }], - }, - }; -} - -function falModelPath(model: string): string { - return model.startsWith("xai/") ? model : `fal-ai/${model}`; -} - -function getToken(credentials: FalCredentials | null | undefined): string { - return String(credentials?.apiKey || credentials?.accessToken || ""); -} - -function responseError(payload: unknown): string { - return sanitizeErrorMessage(JSON.stringify(payload).slice(0, 500)); -} - -export async function handleFalVideoGeneration({ - model, - provider, - providerConfig, - body, - credentials, - log, -}: { - model: string; - provider: string; - providerConfig: FalProviderConfig; - body: FalVideoBody; - credentials: FalCredentials | null | undefined; - log?: FalLog | null; -}) { - const token = getToken(credentials); - if (!token) return { success: false as const, status: 401, error: "Fal API key is required" }; - - const startTime = Date.now(); - const timeoutMs = getConfiguredTimeout(); - const pollIntervalMs = Math.max(100, numberValue(body.poll_interval_ms) || 1000); - const baseUrl = providerConfig.baseUrl.replace(/\/$/, ""); - const queueUrl = `${baseUrl}/${falModelPath(model)}`; - const headers = { - Authorization: `Key ${token}`, - "Content-Type": "application/json", - }; - - log?.info?.("VIDEO", `${provider}/${model} (fal-ai-video)`, { - prompt: stringValue(body.prompt)?.slice(0, 200) || "", - }); - - try { - const createResponse = await fetchWithTimeout(queueUrl, { - method: "POST", - headers, - body: JSON.stringify(buildFalVideoPayload(model, body)), - timeoutMs, - }); - const createPayload = await createResponse.json().catch(() => ({})); - - if (!createResponse.ok) { - const error = responseError(createPayload); - log?.error?.("VIDEO", `Fal create failed (${createResponse.status}): ${error}`); - return { success: false as const, status: createResponse.status, error }; - } - - const requestId = stringValue(createPayload.request_id); - if (!requestId) return normalizeFalVideoResponse(createPayload); - - const statusUrl = - absoluteFalUrl(createPayload.status_url, baseUrl) || - `${queueUrl}/requests/${requestId}/status`; - const responseUrl = - absoluteFalUrl(createPayload.response_url, baseUrl) || `${queueUrl}/requests/${requestId}`; - const deadline = startTime + timeoutMs; - - while (Date.now() < deadline) { - const remainingMs = Math.max(1000, deadline - Date.now()); - const statusResponse = await fetchWithTimeout(statusUrl, { - headers: { Authorization: `Key ${token}` }, - timeoutMs: Math.min(timeoutMs, remainingMs), - }); - const statusPayload = await statusResponse.json().catch(() => ({})); - - if (!statusResponse.ok) { - return { - success: false as const, - status: statusResponse.status, - error: responseError(statusPayload), - }; - } - - const status = stringValue(statusPayload.status); - if (status === "COMPLETED") { - const resultResponse = await fetchWithTimeout(responseUrl, { - headers: { Authorization: `Key ${token}` }, - timeoutMs: Math.min(timeoutMs, Math.max(1000, deadline - Date.now())), - }); - const resultPayload = await resultResponse.json().catch(() => ({})); - if (!resultResponse.ok) { - return { - success: false as const, - status: resultResponse.status, - error: responseError(resultPayload), - }; - } - - const result = normalizeFalVideoResponse(resultPayload); - saveCallLog({ - method: "POST", - path: "/v1/videos/generations", - status: result.success ? 200 : result.status, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - ...(result.success ? {} : { error: result.error }), - }).catch(() => {}); - return result; - } - - if (status && !["IN_QUEUE", "IN_PROGRESS"].includes(status)) { - return { - success: false as const, - status: 502, - error: `Fal video generation ended with status ${status}`, - }; - } - - await new Promise((resolve) => setTimeout(resolve, Math.min(pollIntervalMs, remainingMs))); - } - - return { - success: false as const, - status: 504, - error: `Fal video generation timed out after ${timeoutMs}ms`, - }; - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - const isTimeout = - error instanceof FetchTimeoutError || (error as { name?: string }).name === "AbortError"; - const status = isTimeout ? 504 : 502; - const safeMessage = sanitizeErrorMessage(message); - log?.error?.("VIDEO", `Fal request failed: ${safeMessage}`); - return { success: false as const, status, error: `Fal video provider error: ${safeMessage}` }; - } -} diff --git a/open-sse/services/zaiWebCredentials.ts b/open-sse/services/zaiWebCredentials.ts new file mode 100644 index 0000000000..94a1682458 --- /dev/null +++ b/open-sse/services/zaiWebCredentials.ts @@ -0,0 +1,10 @@ +/** + * Service boundary for Z.ai web-cookie credential parsing. + * + * `extractZaiToken` is pure credential parsing, not request execution, but it lives in + * `executors/zai-web/protocol.ts` alongside the transport it was written for. App routes + * and `src/lib` consumers must not import from `open-sse/executors/**` (G14 import + * boundary — see EXECUTOR_IMPORT_RESTRICTION in eslint.config.mjs), so they go through + * this service instead of reaching into the executor tree. + */ +export { extractZaiToken } from "../executors/zai-web/protocol.ts"; diff --git a/src/app/api/providers/[id]/models/conolDiscovery.ts b/src/app/api/providers/[id]/models/conolDiscovery.ts index 6de64d5bc5..c0118dd11f 100644 --- a/src/app/api/providers/[id]/models/conolDiscovery.ts +++ b/src/app/api/providers/[id]/models/conolDiscovery.ts @@ -1,5 +1,5 @@ import { SAFE_OUTBOUND_FETCH_PRESETS, safeOutboundFetch } from "@/shared/network/safeOutboundFetch"; -import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuard"; +import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuardPolicy"; import { resolveConolCredentials } from "@omniroute/open-sse/services/conolAuth.ts"; import { CONOL_FALLBACK_MODELS, diff --git a/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts b/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts index bc2c9f80ae..4cb2eb02e3 100644 --- a/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts +++ b/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts @@ -23,7 +23,7 @@ import { QWEN_CLOUD_TEXT_MODELS } from "@omniroute/open-sse/config/providers/reg import { filterAlibabaFreeEligibleModels } from "@omniroute/open-sse/services/alibabaFreeTierDiscovery.ts"; import { shouldUseLiveAlibabaFreeModelDiscovery } from "@omniroute/open-sse/services/alibabaFreeTier.ts"; import { isDashscopeTextModelId } from "@omniroute/open-sse/services/dashscopeTextModels.ts"; -import { extractZaiToken } from "@omniroute/open-sse/executors/zai-web.ts"; +import { extractZaiToken } from "@omniroute/open-sse/services/zaiWebCredentials.ts"; import { normalizeOpenAiLikeModelsResponse } from "./normalizers"; const QWEN_CLOUD_TEXT_MODEL_IDS = new Set(QWEN_CLOUD_TEXT_MODELS.map((model) => model.id)); diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index adc23e3aa4..69c391cdf3 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -118,25 +118,18 @@ export { getCustomVisionCapabilityFields }; // lives in ./catalogCache. Re-exported here because the existing tests import the // hooks from this module, and CATALOG_STALE_WHILE_REVALIDATE_MS is part of the // documented behavior of this endpoint. -import { - CATALOG_CACHE_TTL_MS_DEFAULT, - resolveCachedCatalogResponse, - type CatalogCachePolicy, -} from "./catalogCache"; +import { CATALOG_CACHE_TTL_MS_DEFAULT, resolveCachedCatalogResponse } from "./catalogCache"; export { CATALOG_STALE_WHILE_REVALIDATE_MS, - getCatalogStaleWhileRevalidateMs, __resetCatalogBuilderRunsForTest, __getCatalogBuilderRunsForTest, __expireCatalogCacheForTest, __setCatalogCacheEntryForTest, __flushCatalogBackgroundRefreshForTest, __forceCatalogInFlightRejectionForTest, - __setCatalogStaleWhileRevalidateAccessorForTest, - __setCatalogStaleWhileRevalidateMsForTest, } from "./catalogCache"; -export type { CachedCatalog, CatalogCachePolicy } from "./catalogCache"; +export type { CachedCatalog } from "./catalogCache"; const BUILTIN_AUTO_YIELD_INTERVAL = 8; @@ -150,8 +143,7 @@ function yieldCatalogBuildTurn(): Promise { */ export async function getUnifiedModelsResponse( request: Request, - corsHeaders: Record = {}, - cachePolicy: CatalogCachePolicy = {} + corsHeaders: Record = {} ) { const diagnosticHeaders = getCatalogDiagnosticsHeaders({ request }); @@ -184,7 +176,6 @@ export async function getUnifiedModelsResponse( request, { corsHeaders, diagnosticHeaders }, buildCatalogPayload, - cachePolicy, { hideAutoCombos: settingsForAuth?.hideAutoCombos === true, hideNoThinkVariants: settingsForAuth?.hideNoThinkVariants === true, diff --git a/src/app/api/v1/models/catalogCache.ts b/src/app/api/v1/models/catalogCache.ts index e78fdb11a2..6cca9e9cd8 100644 --- a/src/app/api/v1/models/catalogCache.ts +++ b/src/app/api/v1/models/catalogCache.ts @@ -200,12 +200,10 @@ function scheduleBackgroundRefresh( }); }, 0); }); - inFlight = { version: lastSeenCatalogCacheVersion, promise }; - // Nobody on the stale path awaits this, so pre-handle the rejection; a cold-path // caller that joins it via catalogInFlight attaches its own handler and still // observes the failure. - promise.catch(() => {}); + refreshPromise.catch(() => {}); catalogInFlight.set(cacheKey, { generation, promise: refreshPromise }); refreshPromise diff --git a/src/shared/components/modelSelectModalHelpers.ts b/src/shared/components/modelSelectModalHelpers.ts index 0a1117390a..039615d794 100644 --- a/src/shared/components/modelSelectModalHelpers.ts +++ b/src/shared/components/modelSelectModalHelpers.ts @@ -139,6 +139,8 @@ export function isProviderModelHidden( return false; } return hiddenModelsByProvider.get(providerId)?.has(modelId) ?? false; +} + /** Matches the provider-page "Test All Models" concurrency (#chunks of 3). */ export const PROVIDER_TEST_CHUNK_SIZE = 3; diff --git a/tests/unit/combo/image-combo.test.ts b/tests/unit/combo/image-combo.test.ts index 7e7a459e9d..d122dace10 100644 --- a/tests/unit/combo/image-combo.test.ts +++ b/tests/unit/combo/image-combo.test.ts @@ -25,13 +25,19 @@ const core = await import("@/lib/db/core.ts"); const { createCombo } = await import("@/lib/db/combos"); const { executeImageCombo } = await import("@omniroute/open-sse/services/imageCombo"); +type LogEntry = { level: string; tag: unknown; msg: unknown }; + function createLog() { - const entries: any[] = []; + const entries: LogEntry[] = []; + const record = + (level: string) => + (tag: unknown, msg: unknown): number => + entries.push({ level, tag, msg }); return { - info: (tag: any, msg: any) => entries.push({ level: "info", tag, msg }), - warn: (tag: any, msg: any) => entries.push({ level: "warn", tag, msg }), - error: (tag: any, msg: any) => entries.push({ level: "error", tag, msg }), - debug: (tag: any, msg: any) => entries.push({ level: "debug", tag, msg }), + info: record("info"), + warn: record("warn"), + error: record("error"), + debug: record("debug"), entries, }; } @@ -52,13 +58,13 @@ function createMockAuth() { } async function cleanupTestDataDir() { - let lastError: any; + let lastError: unknown; for (let attempt = 0; attempt < 5; attempt += 1) { try { core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); return; - } catch (error: any) { + } catch (error: unknown) { lastError = error; await new Promise((resolve) => setTimeout(resolve, 25)); } diff --git a/tests/unit/db-adapters/driverFactory.test.ts b/tests/unit/db-adapters/driverFactory.test.ts index b5916cefad..a191bd8970 100644 --- a/tests/unit/db-adapters/driverFactory.test.ts +++ b/tests/unit/db-adapters/driverFactory.test.ts @@ -264,6 +264,8 @@ describe("driverFactory", () => { second.close(); first.close(); } + }); + // Cursor renewal plan, Task 2 Step 5: tryIdeAuth() now passes a // busy-timeout to tryOpenSync() on every driver path, since it's invoked // from an unattended sweep tick (not just the human-attended auto-import diff --git a/tests/unit/model-catalog-cache-swr-8728.test.ts b/tests/unit/model-catalog-cache-swr-8728.test.ts index ffef72a217..5e5265b804 100644 --- a/tests/unit/model-catalog-cache-swr-8728.test.ts +++ b/tests/unit/model-catalog-cache-swr-8728.test.ts @@ -1,3 +1,20 @@ +/** + * Stale-while-revalidate for the /v1/models catalog cache (#8728). + * + * Contract note: #8728 originally shipped an injectable `CatalogCachePolicy` + * (a per-call SWR accessor + refresh scheduler) and an unbounded + * `CATALOG_STALE_WHILE_REVALIDATE_MS = Number.POSITIVE_INFINITY`. #9199 landed + * afterwards and deliberately replaced both: the window is now a fixed 30 s + * constant and the refresh is scheduled internally via `setTimeout(…, 0)`. + * See catalogCache.ts — an unbounded window let a refresh that kept failing pin + * an ancient catalog forever. + * + * These tests were left asserting the removed API, which is what broke the + * production build (catalog.ts re-exported three symbols that no longer exist). + * They are realigned here to the shipped contract: the BEHAVIOR #8728 added — + * an expired-but-recent successful entry is served immediately while a refresh + * runs behind it — is still fully covered. + */ import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; @@ -10,8 +27,6 @@ process.env.DATA_DIR = TEST_DATA_DIR; const readCache = await import("../../src/lib/db/readCache.ts"); const catalogCache = await import("../../src/app/api/v1/models/catalogCache.ts"); -type RefreshTask = () => Promise; - function request() { return new Request("http://localhost/v1/models"); } @@ -25,28 +40,11 @@ function payload(body: string, status = 200): catalogCache.CatalogPayload { }; } -function createPolicyQueue() { - const tasks: RefreshTask[] = []; - return { - policy: { - getStaleWhileRevalidateMs: () => Number.POSITIVE_INFINITY, - scheduleBackgroundRefresh: (task: RefreshTask) => { - tasks.push(task); - }, - }, - tasks, - }; -} - -async function resolve( - build: (request: Request) => Promise, - policy = createPolicyQueue().policy -) { +async function resolve(build: (request: Request) => Promise) { return catalogCache.resolveCachedCatalogResponse( request(), { corsHeaders: {}, diagnosticHeaders: {} }, - build, - policy + build ); } @@ -58,146 +56,88 @@ test.after(() => { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); }); -test("production SWR policy is unbounded and reset restores the default accessor", () => { - assert.equal(catalogCache.CATALOG_STALE_WHILE_REVALIDATE_MS, Number.POSITIVE_INFINITY); - assert.equal(catalogCache.getCatalogStaleWhileRevalidateMs(), Number.POSITIVE_INFINITY); - - catalogCache.__setCatalogStaleWhileRevalidateAccessorForTest(() => 0); - assert.equal(catalogCache.getCatalogStaleWhileRevalidateMs(), 0); - - catalogCache.__resetCatalogBuilderRunsForTest(); - assert.equal(catalogCache.getCatalogStaleWhileRevalidateMs(), Number.POSITIVE_INFINITY); +test("the SWR window is a bounded constant, not an unbounded accessor", () => { + // #9199 replaced the injectable POSITIVE_INFINITY accessor with this bound so a + // refresh that keeps failing cannot pin an old catalog forever. + assert.equal(catalogCache.CATALOG_STALE_WHILE_REVALIDATE_MS, 30_000); + assert.ok(Number.isFinite(catalogCache.CATALOG_STALE_WHILE_REVALIDATE_MS)); }); -test("reset detaches scheduled work before it can run", async () => { - const { policy, tasks } = createPolicyQueue(); - await resolve(async () => payload("old"), policy); - catalogCache.__expireCatalogCacheForTest(); - await resolve(async () => payload("detached"), policy); - assert.equal(tasks.length, 1); - - catalogCache.__resetCatalogBuilderRunsForTest(); - await tasks[0](); - - assert.equal(catalogCache.__getCatalogBuilderRunsForTest(), 0); -}); - -test("ordinary TTL expiry serves the last success indefinitely and schedules one refresh per key", async () => { - const { policy, tasks } = createPolicyQueue(); - const initial = await resolve(async () => payload("old"), policy); - assert.equal(await initial.text(), "old"); - catalogCache.__expireCatalogCacheForTest(7 * 24 * 60 * 60 * 1000); - - const staleResponses = await Promise.all( - Array.from({ length: 5 }, () => resolve(async () => payload("new"), policy)) - ); - - assert.deepEqual( - await Promise.all(staleResponses.map((response) => response.text())), - Array(5).fill("old") - ); - assert.equal(tasks.length, 1, "concurrent stale reads must schedule exactly one refresh"); +test("a fresh entry is replayed without running the builder again", async () => { + const first = await resolve(async () => payload("fresh")); + assert.equal(await first.text(), "fresh"); assert.equal(catalogCache.__getCatalogBuilderRunsForTest(), 1); - await tasks[0](); - - const refreshed = await resolve(async () => payload("unexpected"), policy); - assert.equal(await refreshed.text(), "new"); - assert.equal(catalogCache.__getCatalogBuilderRunsForTest(), 2); + const second = await resolve(async () => payload("SHOULD NOT BUILD")); + assert.equal(await second.text(), "fresh"); + assert.equal(catalogCache.__getCatalogBuilderRunsForTest(), 1); }); -test("unsuccessful cold payloads are returned but never cached", async () => { - const first = await resolve(async () => payload("temporary failure", 503)); - assert.equal(first.status, 503); - assert.equal(await first.text(), "temporary failure"); - - const second = await resolve(async () => payload("recovered")); - assert.equal(second.status, 200); - assert.equal(await second.text(), "recovered"); - assert.equal(catalogCache.__getCatalogBuilderRunsForTest(), 2); -}); - -test("failed background refresh retains the prior successful snapshot and permits retry", async (t) => { - t.mock.method(console, "error", () => {}); - const { policy, tasks } = createPolicyQueue(); - assert.equal(await (await resolve(async () => payload("old"), policy)).text(), "old"); +test("an expired successful entry is served immediately while a refresh runs behind it", async () => { + await resolve(async () => payload("old")); catalogCache.__expireCatalogCacheForTest(); + // The stale body comes back on THIS call — the caller never waits for the rebuild. + const stale = await resolve(async () => payload("new")); + assert.equal(await stale.text(), "old", "the expired-but-recent entry must be served as-is"); + + await catalogCache.__flushCatalogBackgroundRefreshForTest(); + + const refreshed = await resolve(async () => payload("SHOULD NOT BUILD")); + assert.equal(await refreshed.text(), "new", "the background refresh must replace the snapshot"); +}); + +test("an entry aged past the SWR window is not served stale", async () => { + await resolve(async () => payload("ancient")); + catalogCache.__expireCatalogCacheForTest(catalogCache.CATALOG_STALE_WHILE_REVALIDATE_MS + 1_000); + + const rebuilt = await resolve(async () => payload("rebuilt")); + assert.equal(await rebuilt.text(), "rebuilt", "past the window the caller must wait for a build"); +}); + +test("a cached non-200 is never replayed as stale", async () => { + // Replaying a cached error as "stale" would mask an intermittent failure behind + // a fake success forever. + catalogCache.__setCatalogCacheEntryForTest(request(), { + body: "boom", + headers: {}, + status: 500, + expiresAt: Date.now() - 1, + }); + + const res = await resolve(async () => payload("recovered")); + assert.equal(res.status, 200); + assert.equal(await res.text(), "recovered"); +}); + +test("concurrent cold requests share a single builder run", async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + + const build = async () => { + await gate; + return payload("coalesced"); + }; + + const inFlight = [resolve(build), resolve(build), resolve(build)]; + release(); + const bodies = await Promise.all((await Promise.all(inFlight)).map((r) => r.text())); + + assert.deepEqual(bodies, ["coalesced", "coalesced", "coalesced"]); assert.equal( - await ( - await resolve(async () => { - throw new Error("temporary failure"); - }, policy) - ).text(), - "old" + catalogCache.__getCatalogBuilderRunsForTest(), + 1, + "identical concurrent requests must coalesce onto one in-flight build (#6408)" ); - await tasks.shift()!(); - - assert.equal( - await (await resolve(async () => payload("temporary failure", 503), policy)).text(), - "old" - ); - assert.equal(tasks.length, 1, "a failed refresh must release single-flight state for retry"); - await tasks.shift()!(); - - assert.equal(await (await resolve(async () => payload("new"), policy)).text(), "old"); - assert.equal(tasks.length, 1, "an unsuccessful payload must also permit another refresh"); - await tasks.shift()!(); - - assert.equal(await (await resolve(async () => payload("unused"), policy)).text(), "new"); }); -test("hard invalidation drops snapshots, detaches old work, and guards old-generation writeback", async () => { - let resolveOld!: (value: catalogCache.CatalogPayload) => void; - const oldPayload = new Promise((resolvePromise) => { - resolveOld = resolvePromise; - }); - let currentBuildStarted = false; - let resolveCurrent!: (value: catalogCache.CatalogPayload) => void; - const currentPayload = new Promise((resolvePromise) => { - resolveCurrent = resolvePromise; - }); +test("a state change invalidates the cache so the next read rebuilds", async () => { + await resolve(async () => payload("before")); - const oldRequest = resolve(async () => oldPayload); - await Promise.resolve(); + readCache.invalidateDbCache(); - readCache.invalidateModelCatalogCache(); - const currentRequest = resolve(async () => { - currentBuildStarted = true; - return currentPayload; - }); - await Promise.resolve(); - - assert.equal(currentBuildStarted, true, "the first post-write read must start a current build"); - - resolveCurrent(payload("current")); - assert.equal(await (await currentRequest).text(), "current"); - - resolveOld(payload("old")); - assert.equal(await (await oldRequest).text(), "old"); - - const cached = await resolve(async () => payload("unexpected")); - assert.equal(await cached.text(), "current", "old completion must not overwrite current cache"); - assert.equal(catalogCache.__getCatalogBuilderRunsForTest(), 2); -}); - -test("hard invalidation clears a completed snapshot and makes the next read block", async () => { - assert.equal(await (await resolve(async () => payload("old"))).text(), "old"); - readCache.invalidateModelCatalogCache(); - - let resolveCurrent!: (value: catalogCache.CatalogPayload) => void; - const currentPayload = new Promise((resolvePromise) => { - resolveCurrent = resolvePromise; - }); - let settled = false; - const next = resolve(async () => currentPayload).then((response) => { - settled = true; - return response; - }); - - await Promise.resolve(); - assert.equal(settled, false, "post-write reads may block and must not serve the old snapshot"); - - resolveCurrent(payload("current")); - assert.equal(await (await next).text(), "current"); + const after = await resolve(async () => payload("after")); + assert.equal(await after.text(), "after", "a write must be reflected on the very next read"); }); diff --git a/tests/unit/production-build-module-integrity.test.ts b/tests/unit/production-build-module-integrity.test.ts new file mode 100644 index 0000000000..d2ad8f478c --- /dev/null +++ b/tests/unit/production-build-module-integrity.test.ts @@ -0,0 +1,142 @@ +/** + * Regression guard for the broken Turbopack production build on release/v3.8.50. + * + * Six independent module-level defects, each from a different PR, made `npm run build` + * fail. Most are *link-time* errors, so those cases are expressed as "this module must be + * importable / statically resolvable" rather than as behavioral assertions: + * + * 1. src/shared/components/modelSelectModalHelpers.ts — a lost `}` (#9011) left + * `export const PROVIDER_TEST_CHUNK_SIZE` inside a function body. + * 2. open-sse/handlers/videoGeneration.ts — `handleFalVideoGeneration` imported + * twice from two different modules (#9982 landed on top of #9969). + * 3. src/app/api/v1/models/catalog.ts — re-exported three SWR symbols that #9199 + * deliberately removed from ./catalogCache when it replaced #8728's injectable + * policy with a fixed 30 s bound. The consumer was never updated. + * 4. src/app/api/v1/models/catalogCache.ts — the same PR left two dangling statements + * referencing undeclared `inFlight` / `promise`, so every stale-while-revalidate + * read threw a ReferenceError at RUNTIME (this one the build never caught). + * 5. open-sse/executors/tinycmsSigner.ts — generated wasm-bindgen glue kept a + * `new URL('wasm_signer_bg.wasm', import.meta.url)` default that no file backs. + * 6. src/app/api/providers/[id]/models/conolDiscovery.ts — imported + * `getProviderOutboundGuard` from ./outboundUrlGuard, which does not export it (#8974). + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + +test("modelSelectModalHelpers exports survive isProviderModelHidden (#9011 missing brace)", async () => { + const helpers = await import("@/shared/components/modelSelectModalHelpers"); + + // The missing `}` swallowed everything after isProviderModelHidden into its body, + // so this constant stopped being a module-level export. + assert.equal(helpers.PROVIDER_TEST_CHUNK_SIZE, 3); + + const hidden = helpers.parseHiddenModelsByProvider({ openai: ["gpt-4o"] }); + assert.equal(helpers.isProviderModelHidden(hidden, "openai", "gpt-4o"), true); + assert.equal(helpers.isProviderModelHidden(hidden, "openai", "gpt-4o-mini"), false); + assert.equal(helpers.isProviderModelHidden(hidden, "anthropic", "gpt-4o"), false); +}); + +test("videoGeneration handler links with a single handleFalVideoGeneration binding (#9982)", async () => { + // A duplicate import binding is an ESM early SyntaxError, so the import itself is + // the assertion. The Fal video path must resolve to the provider-neutral module + // that #9982 added (it also covers the #9969 Grok Imagine routing). + const mod = await import("@omniroute/open-sse/handlers/videoGeneration.ts"); + assert.equal(typeof mod.handleVideoGeneration, "function"); + + const source = readFileSync(path.join(repoRoot, "open-sse/handlers/videoGeneration.ts"), "utf8"); + const falImports = source.match(/import \{ handleFalVideoGeneration \}/g) ?? []; + assert.equal(falImports.length, 1, "handleFalVideoGeneration must be imported exactly once"); + assert.match(source, /handleFalVideoGeneration \} from "\.\/mediaGeneration\/fal\.ts"/); +}); + +test("catalog.ts re-exports only what catalogCache actually exports (#9199)", async () => { + // #9199 deliberately replaced #8728's injectable SWR policy with a fixed bound, but + // left catalog.ts re-exporting the three removed symbols. Re-exporting a binding the + // source module does not export is a link-time error, so this import IS the assertion. + const catalog = await import("@/app/api/v1/models/catalog"); + const catalogCache = await import("@/app/api/v1/models/catalogCache"); + + assert.equal(catalogCache.CATALOG_STALE_WHILE_REVALIDATE_MS, 30_000); + assert.equal(catalog.CATALOG_STALE_WHILE_REVALIDATE_MS, 30_000); + + // The accessor/policy trio must stay gone — re-adding it would resurrect the + // unbounded window #9199 removed (a failing refresh could pin an old catalog forever). + for (const removed of [ + "getCatalogStaleWhileRevalidateMs", + "__setCatalogStaleWhileRevalidateAccessorForTest", + "__setCatalogStaleWhileRevalidateMsForTest", + ]) { + assert.equal( + (catalogCache as Record)[removed], + undefined, + `${removed} was removed by #9199 and must not come back` + ); + } +}); + +test("the stale-while-revalidate path does not throw a ReferenceError (#9199 merge residue)", async () => { + // The mangled merge left `inFlight = { … promise }` referencing two undeclared + // identifiers inside scheduleBackgroundRefresh, so EVERY stale read crashed at runtime. + const catalogCache = await import("@/app/api/v1/models/catalogCache"); + catalogCache.__resetCatalogBuilderRunsForTest(); + + const payload = (body: string) => ({ + body, + headers: { "content-type": "application/json" }, + status: 200, + cacheTTL: 60_000, + }); + const call = (body: string) => + catalogCache.resolveCachedCatalogResponse( + new Request("http://localhost/v1/models"), + { corsHeaders: {}, diagnosticHeaders: {} }, + async () => payload(body) + ); + + await call("first"); + catalogCache.__expireCatalogCacheForTest(); + + const stale = await call("second"); + assert.equal(await stale.text(), "first", "the stale body must be served, not a crash"); + await catalogCache.__flushCatalogBackgroundRefreshForTest(); +}); + +test("conolDiscovery resolves getProviderOutboundGuard from the policy module (#8974)", async () => { + // outboundUrlGuard.ts does NOT export getProviderOutboundGuard — importing it from + // there is a link-time error, so the import below is the assertion. + const mod = await import("@/app/api/providers/[id]/models/conolDiscovery"); + assert.equal(typeof mod.maybeHandleConolModelDiscovery, "function"); + + // The fix must stay on the consumer side. outboundUrlGuard.ts is loaded by the packaged + // CLI, where no tsconfig resolves `@/*`, so re-exporting the policy helpers from it + // (they pull in featureFlags → the DB layer) would break `omniroute setup-opencode` + // (#7682). Guard that nobody "fixes" this by adding the re-export instead. + const guardSource = readFileSync( + path.join(repoRoot, "src/shared/network/outboundUrlGuard.ts"), + "utf8" + ); + assert.doesNotMatch( + guardSource, + /^\s*(import|export)[^\n]*from\s+["']@\//m, + "outboundUrlGuard.ts must stay free of @/-aliased imports/re-exports (#7682)" + ); +}); + +test("tinycmsSigner has no build-time-resolvable wasm sidecar URL (#8736/#10087)", () => { + const source = readFileSync(path.join(repoRoot, "open-sse/executors/tinycmsSigner.ts"), "utf8"); + + // The wasm binary ships inlined as WASM_BASE64; there is no wasm_signer_bg.wasm file. + // Turbopack statically resolves `new URL(, import.meta.url)`, so leaving the + // generated default in place fails `next build` with "Module not found". + assert.ok(source.includes("const WASM_BASE64 ="), "wasm binary must stay inlined"); + assert.doesNotMatch( + source, + /new URL\(\s*['"]wasm_signer_bg\.wasm['"]/, + "generated wasm-bindgen sidecar URL must not survive — Turbopack resolves it at build time" + ); +}); diff --git a/tests/unit/quota-exclusive-catalog-short-circuit.test.ts b/tests/unit/quota-exclusive-catalog-short-circuit.test.ts index f6b786d643..a20cb9ee60 100644 --- a/tests/unit/quota-exclusive-catalog-short-circuit.test.ts +++ b/tests/unit/quota-exclusive-catalog-short-circuit.test.ts @@ -134,8 +134,11 @@ await test("chave quota-exclusive não constrói o catálogo completo", async (t process.env.EXPOSE_CC_DISCOVERY_ALIASES = "1"; // A chave do cache é `prefix|isCodex|apiKey|configuredOnly` — um query param // qualquer NÃO a invalida, então a resposta do subteste anterior seria servida. - v1ModelsCatalog.__setCatalogStaleWhileRevalidateMsForTest(0); - v1ModelsCatalog.__expireCatalogCacheForTest(1); + // #9199 removed the injectable SWR window; age the entry past the fixed + // 30 s constant instead so the next read rebuilds rather than serving stale. + v1ModelsCatalog.__expireCatalogCacheForTest( + v1ModelsCatalog.CATALOG_STALE_WHILE_REVALIDATE_MS + 1_000 + ); try { const res = await v1ModelsCatalog.getUnifiedModelsResponse( new Request("http://localhost/api/v1/models", { @@ -151,9 +154,6 @@ await test("chave quota-exclusive não constrói o catálogo completo", async (t `ids=${JSON.stringify(body.data.map((m) => m.id).slice(0, 6))}` ); } finally { - v1ModelsCatalog.__setCatalogStaleWhileRevalidateMsForTest( - v1ModelsCatalog.CATALOG_STALE_WHILE_REVALIDATE_MS - ); if (prev === undefined) delete process.env.EXPOSE_CC_DISCOVERY_ALIASES; else process.env.EXPOSE_CC_DISCOVERY_ALIASES = prev; }