diff --git a/docs/edge-runtime-process-once.md b/docs/edge-runtime-process-once.md new file mode 100644 index 000000000..94c074cc5 --- /dev/null +++ b/docs/edge-runtime-process-once.md @@ -0,0 +1,73 @@ +# Fix: Edge Runtime `process.once` build warning + +## 背景 + +`next build` 过程中出现 Edge Runtime 不支持 Node API 的告警:`process.once`。 + +### 复现基线(修复前) + +在修复前的版本(例如 tag `v0.4.1`)上运行 `bun run build` 可看到 Edge Runtime 不支持 Node API 的告警,其 import trace 包含: + +```text +A Node.js API is used (process.once) which is not supported in the Edge Runtime. +Import traces: + ./src/lib/async-task-manager.ts + ./src/lib/price-sync/cloud-price-updater.ts + ./src/instrumentation.ts +``` + +相关导入链路(import trace)包含: + +- `src/lib/async-task-manager.ts` +- `src/lib/price-sync/cloud-price-updater.ts` +- `src/instrumentation.ts` + +## 变更 + +- `AsyncTaskManager`: + - 在 `process.env.NEXT_RUNTIME === "edge"` 时跳过初始化,避免触发 `process.once` 等 Node-only API。 +- `cloud-price-updater`: + - 移除对 `AsyncTaskManager` 的顶层静态 import。 + - 在 `requestCloudPriceTableSync()` 内部按需动态 import `AsyncTaskManager`,并在 Edge runtime 下直接 no-op。 + +## 验证 + +- `bun run lint` +- `bun run typecheck` +- Targeted coverage(仅统计本次相关文件): + - `bunx vitest run tests/unit/lib/async-task-manager-edge-runtime.test.ts tests/unit/price-sync/cloud-price-updater.test.ts --coverage --coverage.provider v8 --coverage.reporter text --coverage.include src/lib/async-task-manager.ts --coverage.include src/lib/price-sync/cloud-price-updater.ts` + - 结果:All files >= 90%(Statements / Branches / Functions / Lines) +- `bun run build` + - 结果:不再出现 Edge Runtime `process.once` 相关告警 + +## 回滚 + +如需回滚,优先按提交粒度回退(以 commit message 为准): + +- `fix: skip async task manager init on edge` +- `fix: avoid static async task manager import` +- `test: cover edge runtime task scheduling` + +定位对应提交(示例): + +```bash +git log --oneline --grep "fix: skip async task manager init on edge" +git log --oneline --grep "fix: avoid static async task manager import" +git log --oneline --grep "test: cover edge runtime task scheduling" +``` + +## 备选方案(若回归) + +如果未来 Next/Turbopack 的静态分析行为变化导致告警回归,可将 Node-only 的 signal hooks 拆分到 `*.node.ts`(例如 `async-task-manager.node.ts`),并仅在 `NEXT_RUNTIME !== "edge"` 的分支里动态引入。 + +## 快速定位(避免文档漂移) + +```bash +rg -n "process\\.once" src/lib/async-task-manager.ts +rg -n "NEXT_RUNTIME|NEXT_PHASE" src/lib tests +rg -n "requestCloudPriceTableSync" src/lib/price-sync/cloud-price-updater.ts tests/unit/price-sync/cloud-price-updater.test.ts +``` + +## 备注 + +`.codex/plan/` 与 `.codex/issues/` 属于本地任务落盘目录,不应提交到 Git。 diff --git a/src/lib/async-task-manager.ts b/src/lib/async-task-manager.ts index 9ac0e71e9..0728744f4 100644 --- a/src/lib/async-task-manager.ts +++ b/src/lib/async-task-manager.ts @@ -26,11 +26,25 @@ interface TaskInfo { class AsyncTaskManagerClass { private tasks: Map = new Map(); private cleanupInterval: NodeJS.Timeout | null = null; + // Lazily initialize Node-only hooks on first use to avoid side effects at import time. + private initialized = false; - constructor() { - // Skip initialization during CI/build phase to avoid unnecessary logs and side effects - if (process.env.CI === "true" || process.env.NEXT_PHASE === "phase-production-build") { - logger.debug("[AsyncTaskManager] Skipping initialization in CI/build environment"); + private initializeIfNeeded(): void { + if (this.initialized) { + return; + } + this.initialized = true; + + // Skip initialization in Edge/CI environments to avoid Node-only APIs and side effects. + if ( + process.env.NEXT_RUNTIME === "edge" || + process.env.CI === "true" || + process.env.NEXT_PHASE === "phase-production-build" + ) { + logger.debug("[AsyncTaskManager] Skipping initialization in edge/CI environment", { + nextRuntime: process.env.NEXT_RUNTIME, + ci: process.env.CI, + }); return; } @@ -63,6 +77,8 @@ class AsyncTaskManagerClass { * @returns AbortController(可用于取消任务) */ register(taskId: string, promise: Promise, taskType = "unknown"): AbortController { + this.initializeIfNeeded(); + // 如果任务已存在,先取消旧任务 if (this.tasks.has(taskId)) { logger.warn("[AsyncTaskManager] Task already exists, cancelling old task", { diff --git a/src/lib/price-sync/cloud-price-updater.ts b/src/lib/price-sync/cloud-price-updater.ts index a64b28164..2bf1cb6ea 100644 --- a/src/lib/price-sync/cloud-price-updater.ts +++ b/src/lib/price-sync/cloud-price-updater.ts @@ -1,4 +1,3 @@ -import { AsyncTaskManager } from "@/lib/async-task-manager"; import { logger } from "@/lib/logger"; import type { PriceUpdateResult } from "@/types/model-price"; import { @@ -58,47 +57,73 @@ export function requestCloudPriceTableSync(options: { reason: "missing-model" | "scheduled" | "manual"; throttleMs?: number; }): void { - const throttleMs = options.throttleMs ?? DEFAULT_THROTTLE_MS; - const taskId = "cloud-price-table-sync"; - - // 去重:已有任务在跑则不重复触发 - const active = AsyncTaskManager.getActiveTasks(); - if (active.some((t) => t.taskId === taskId)) { + if (process.env.NEXT_RUNTIME === "edge") { return; } + const throttleMs = options.throttleMs ?? DEFAULT_THROTTLE_MS; + const taskId = "cloud-price-table-sync"; + // 节流:避免短时间内频繁拉取云端价格表 - const g = globalThis as unknown as { __CCH_CLOUD_PRICE_SYNC_LAST_AT__?: number }; + const g = globalThis as unknown as { + __CCH_CLOUD_PRICE_SYNC_LAST_AT__?: number; + __CCH_CLOUD_PRICE_SYNC_SCHEDULING__?: boolean; + }; const lastAt = g.__CCH_CLOUD_PRICE_SYNC_LAST_AT__ ?? 0; const now = Date.now(); if (now - lastAt < throttleMs) { return; } - AsyncTaskManager.register( - taskId, - (async () => { - try { - const result = await syncCloudPriceTableToDatabase(); - if (!result.ok) { - logger.warn("[PriceSync] Cloud price sync task failed", { - reason: options.reason, - error: result.error, - }); - return; - } + // 避免并发请求在 AsyncTaskManager 加载前重复触发(例如多请求同时命中 missing-model) + if (g.__CCH_CLOUD_PRICE_SYNC_SCHEDULING__) { + return; + } + g.__CCH_CLOUD_PRICE_SYNC_SCHEDULING__ = true; + + void (async () => { + try { + const { AsyncTaskManager } = await import("@/lib/async-task-manager"); - logger.info("[PriceSync] Cloud price sync task completed", { - reason: options.reason, - added: result.data.added.length, - updated: result.data.updated.length, - skippedConflicts: result.data.skippedConflicts?.length ?? 0, - total: result.data.total, - }); - } finally { - g.__CCH_CLOUD_PRICE_SYNC_LAST_AT__ = Date.now(); + // 去重:已有任务在跑则不重复触发 + const active = AsyncTaskManager.getActiveTasks(); + if (active.some((t) => t.taskId === taskId)) { + return; } - })(), - "cloud_price_table_sync" - ); + + AsyncTaskManager.register( + taskId, + (async () => { + try { + const result = await syncCloudPriceTableToDatabase(); + if (!result.ok) { + logger.warn("[PriceSync] Cloud price sync task failed", { + reason: options.reason, + error: result.error, + }); + return; + } + + logger.info("[PriceSync] Cloud price sync task completed", { + reason: options.reason, + added: result.data.added.length, + updated: result.data.updated.length, + skippedConflicts: result.data.skippedConflicts?.length ?? 0, + total: result.data.total, + }); + } finally { + g.__CCH_CLOUD_PRICE_SYNC_LAST_AT__ = Date.now(); + } + })(), + "cloud_price_table_sync" + ); + } catch (error) { + logger.warn("[PriceSync] Cloud price sync scheduling failed", { + reason: options.reason, + error: error instanceof Error ? error.message : String(error), + }); + } finally { + g.__CCH_CLOUD_PRICE_SYNC_SCHEDULING__ = false; + } + })(); } diff --git a/tests/unit/lib/async-task-manager-edge-runtime.test.ts b/tests/unit/lib/async-task-manager-edge-runtime.test.ts new file mode 100644 index 000000000..5173a81df --- /dev/null +++ b/tests/unit/lib/async-task-manager-edge-runtime.test.ts @@ -0,0 +1,308 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/logger", () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + }, +})); + +vi.mock("@/app/v1/_lib/proxy/errors", () => ({ + isClientAbortError: vi.fn(() => false), +})); + +describe.sequential("AsyncTaskManager edge runtime", () => { + const prevRuntime = process.env.NEXT_RUNTIME; + const prevCi = process.env.CI; + + beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + vi.useRealTimers(); + + delete (globalThis as unknown as { __ASYNC_TASK_MANAGER__?: unknown }).__ASYNC_TASK_MANAGER__; + + delete process.env.CI; + }); + + afterEach(() => { + process.env.NEXT_RUNTIME = prevRuntime; + if (prevCi === undefined) { + delete process.env.CI; + } else { + process.env.CI = prevCi; + } + + delete (globalThis as unknown as { __ASYNC_TASK_MANAGER__?: unknown }).__ASYNC_TASK_MANAGER__; + + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it("does not call process.once when NEXT_RUNTIME is edge", async () => { + const processOnceSpy = vi.spyOn(process, "once"); + process.env.NEXT_RUNTIME = "edge"; + + const { AsyncTaskManager } = await import("@/lib/async-task-manager"); + AsyncTaskManager.register("t1", Promise.resolve()); + + expect(processOnceSpy).not.toHaveBeenCalled(); + }); + + it("registers exit hooks when NEXT_RUNTIME is nodejs", async () => { + vi.useFakeTimers(); + + const processOnceSpy = vi.spyOn(process, "once"); + process.env.NEXT_RUNTIME = "nodejs"; + + const { AsyncTaskManager } = await import("@/lib/async-task-manager"); + AsyncTaskManager.register("t1", Promise.resolve()); + + expect(processOnceSpy).toHaveBeenCalledTimes(3); + expect(processOnceSpy).toHaveBeenNthCalledWith(1, "SIGTERM", expect.any(Function)); + expect(processOnceSpy).toHaveBeenNthCalledWith(2, "SIGINT", expect.any(Function)); + expect(processOnceSpy).toHaveBeenNthCalledWith(3, "beforeExit", expect.any(Function)); + }); + + it("handles exit signal callback by running cleanupAll", async () => { + vi.useFakeTimers(); + + const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval"); + const processOnceSpy = vi.spyOn(process, "once"); + process.env.NEXT_RUNTIME = "nodejs"; + + const { AsyncTaskManager } = await import("@/lib/async-task-manager"); + + let resolveTask: () => void; + const taskPromise = new Promise((resolve) => { + resolveTask = resolve; + }); + const controller = AsyncTaskManager.register("t1", taskPromise); + + const sigtermHandler = processOnceSpy.mock.calls.find((c) => c[0] === "SIGTERM")?.[1]; + const sigintHandler = processOnceSpy.mock.calls.find((c) => c[0] === "SIGINT")?.[1]; + const beforeExitHandler = processOnceSpy.mock.calls.find((c) => c[0] === "beforeExit")?.[1]; + expect(sigtermHandler).toBeTypeOf("function"); + expect(sigintHandler).toBeTypeOf("function"); + expect(beforeExitHandler).toBeTypeOf("function"); + + sigtermHandler?.(); + sigintHandler?.(); + beforeExitHandler?.(); + + expect(controller.signal.aborted).toBe(true); + expect(clearIntervalSpy).toHaveBeenCalled(); + + resolveTask!(); + await taskPromise; + }); + + it("runs cleanupCompletedTasks on interval tick", async () => { + vi.useFakeTimers(); + process.env.NEXT_RUNTIME = "nodejs"; + + const { AsyncTaskManager } = await import("@/lib/async-task-manager"); + const cleanupSpy = vi.spyOn( + AsyncTaskManager as unknown as { cleanupCompletedTasks: () => void }, + "cleanupCompletedTasks" + ); + + AsyncTaskManager.register("t1", new Promise(() => {})); + vi.advanceTimersByTime(60_000); + + expect(cleanupSpy).toHaveBeenCalledTimes(1); + }); + + it("registers and auto-cleans task after resolve", async () => { + process.env.CI = "true"; + process.env.NEXT_RUNTIME = "nodejs"; + + const { AsyncTaskManager } = await import("@/lib/async-task-manager"); + + let resolveTask: () => void; + const taskPromise = new Promise((resolve) => { + resolveTask = resolve; + }); + + const controller = AsyncTaskManager.register("t1", taskPromise); + expect(controller.signal.aborted).toBe(false); + expect(AsyncTaskManager.getActiveTaskCount()).toBe(1); + + resolveTask!(); + await taskPromise; + await new Promise((resolve) => queueMicrotask(() => resolve())); + + expect(AsyncTaskManager.getActiveTaskCount()).toBe(0); + }); + + it("does nothing when cancelling unknown taskId", async () => { + process.env.CI = "true"; + process.env.NEXT_RUNTIME = "nodejs"; + + const { logger } = await import("@/lib/logger"); + const { AsyncTaskManager } = await import("@/lib/async-task-manager"); + + AsyncTaskManager.cancel("missing"); + + expect(vi.mocked(logger.debug)).toHaveBeenCalled(); + }); + + it("getActiveTasks returns task metadata", async () => { + process.env.CI = "true"; + process.env.NEXT_RUNTIME = "nodejs"; + + const { AsyncTaskManager } = await import("@/lib/async-task-manager"); + + let resolveTask: () => void; + const taskPromise = new Promise((resolve) => { + resolveTask = resolve; + }); + + AsyncTaskManager.register("t1", taskPromise, "custom_type"); + + const tasks = AsyncTaskManager.getActiveTasks(); + expect(tasks).toHaveLength(1); + expect(tasks[0]).toMatchObject({ taskId: "t1", taskType: "custom_type" }); + expect(typeof tasks[0]?.age).toBe("number"); + + resolveTask!(); + await taskPromise; + }); + + it("cancels old task when registering same taskId again", async () => { + process.env.CI = "true"; + process.env.NEXT_RUNTIME = "nodejs"; + + const { AsyncTaskManager } = await import("@/lib/async-task-manager"); + + let resolveFirst: () => void; + const firstPromise = new Promise((resolve) => { + resolveFirst = resolve; + }); + + const firstController = AsyncTaskManager.register("t1", firstPromise); + expect(firstController.signal.aborted).toBe(false); + + let resolveSecond: () => void; + const secondPromise = new Promise((resolve) => { + resolveSecond = resolve; + }); + + AsyncTaskManager.register("t1", secondPromise); + + expect(firstController.signal.aborted).toBe(true); + + resolveFirst!(); + resolveSecond!(); + await Promise.all([firstPromise, secondPromise]); + }); + + it("logs task cancelled when isClientAbortError returns true", async () => { + process.env.CI = "true"; + process.env.NEXT_RUNTIME = "nodejs"; + + const { isClientAbortError } = await import("@/app/v1/_lib/proxy/errors"); + vi.mocked(isClientAbortError).mockReturnValue(true); + + const { logger } = await import("@/lib/logger"); + const { AsyncTaskManager } = await import("@/lib/async-task-manager"); + + const taskPromise = Promise.reject(new Error("aborted")); + AsyncTaskManager.register("t1", taskPromise); + + await taskPromise.catch(() => {}); + + expect(vi.mocked(logger.info)).toHaveBeenCalled(); + }); + + it("logs task failed when isClientAbortError returns false", async () => { + process.env.CI = "true"; + process.env.NEXT_RUNTIME = "nodejs"; + + const { isClientAbortError } = await import("@/app/v1/_lib/proxy/errors"); + vi.mocked(isClientAbortError).mockReturnValue(false); + + const { logger } = await import("@/lib/logger"); + const { AsyncTaskManager } = await import("@/lib/async-task-manager"); + + const taskPromise = Promise.reject(new Error("boom")); + AsyncTaskManager.register("t1", taskPromise); + + await taskPromise.catch(() => {}); + + expect(vi.mocked(logger.error)).toHaveBeenCalled(); + }); + + it("cleanupCompletedTasks cancels stale tasks", async () => { + process.env.CI = "true"; + process.env.NEXT_RUNTIME = "nodejs"; + + const { logger } = await import("@/lib/logger"); + const { AsyncTaskManager } = await import("@/lib/async-task-manager"); + + let resolveTask: () => void; + const taskPromise = new Promise((resolve) => { + resolveTask = resolve; + }); + + const controller = AsyncTaskManager.register("stale-task", taskPromise, "custom_type"); + + const managerAny = AsyncTaskManager as unknown as { + tasks: Map; + cleanupCompletedTasks: () => void; + }; + const info = managerAny.tasks.get("stale-task"); + expect(info).toBeDefined(); + info!.createdAt = Date.now() - 11 * 60 * 1000; + + let resolveFresh: () => void; + const freshPromise = new Promise((resolve) => { + resolveFresh = resolve; + }); + const freshController = AsyncTaskManager.register("fresh-task", freshPromise, "custom_type"); + + managerAny.cleanupCompletedTasks(); + + expect(controller.signal.aborted).toBe(true); + expect(freshController.signal.aborted).toBe(false); + expect(vi.mocked(logger.warn)).toHaveBeenCalled(); + + resolveTask!(); + resolveFresh!(); + await Promise.all([taskPromise, freshPromise]); + }); + + it("cleanupAll cancels tasks and clears interval", async () => { + process.env.CI = "true"; + process.env.NEXT_RUNTIME = "nodejs"; + + const { AsyncTaskManager } = await import("@/lib/async-task-manager"); + + let resolveTask: () => void; + const taskPromise = new Promise((resolve) => { + resolveTask = resolve; + }); + const controller = AsyncTaskManager.register("t1", taskPromise); + + const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval"); + const intervalId = setInterval(() => {}, 1_000); + const managerAny = AsyncTaskManager as unknown as { + cleanupInterval: ReturnType | null; + cleanupAll: () => void; + }; + managerAny.cleanupInterval = intervalId; + + managerAny.cleanupAll(); + + expect(controller.signal.aborted).toBe(true); + expect(clearIntervalSpy).toHaveBeenCalledWith(intervalId); + expect(managerAny.cleanupInterval).toBeNull(); + + resolveTask!(); + await taskPromise; + clearInterval(intervalId); + }); +}); diff --git a/tests/unit/price-sync/cloud-price-updater.test.ts b/tests/unit/price-sync/cloud-price-updater.test.ts index 6b2b69582..3c3cfc2bf 100644 --- a/tests/unit/price-sync/cloud-price-updater.test.ts +++ b/tests/unit/price-sync/cloud-price-updater.test.ts @@ -1,15 +1,10 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { CloudPriceTableResult } from "@/lib/price-sync/cloud-price-table"; -import { logger } from "@/lib/logger"; -import { - syncCloudPriceTableToDatabase, - requestCloudPriceTableSync, -} from "@/lib/price-sync/cloud-price-updater"; -import { AsyncTaskManager } from "@/lib/async-task-manager"; -import { processPriceTableInternal } from "@/actions/model-prices"; const asyncTasks: Promise[] = []; +let asyncTaskManagerLoaded = false; + vi.mock("@/lib/logger", () => ({ logger: { info: vi.fn(), @@ -20,15 +15,18 @@ vi.mock("@/lib/logger", () => ({ }, })); -vi.mock("@/lib/async-task-manager", () => ({ - AsyncTaskManager: { - getActiveTasks: vi.fn(() => []), - register: vi.fn((_taskId: string, promise: Promise) => { - asyncTasks.push(promise); - return new AbortController(); - }), - }, -})); +vi.mock("@/lib/async-task-manager", () => { + asyncTaskManagerLoaded = true; + return { + AsyncTaskManager: { + getActiveTasks: vi.fn(() => []), + register: vi.fn((_taskId: string, promise: Promise) => { + asyncTasks.push(promise); + return new AbortController(); + }), + }, + }; +}); vi.mock("@/actions/model-prices", () => ({ processPriceTableInternal: vi.fn(async () => ({ @@ -43,11 +41,17 @@ vi.mock("@/actions/model-prices", () => ({ })), })); +async function flushAsync(): Promise { + await new Promise((resolve) => setTimeout(() => resolve(), 0)); +} + describe("syncCloudPriceTableToDatabase", () => { beforeEach(() => { vi.clearAllMocks(); + vi.resetModules(); asyncTasks.splice(0, asyncTasks.length); vi.unstubAllGlobals(); + asyncTaskManagerLoaded = false; }); it("returns ok=false when cloud fetch fails with HTTP error", async () => { @@ -60,6 +64,7 @@ describe("syncCloudPriceTableToDatabase", () => { })) ); + const { syncCloudPriceTableToDatabase } = await import("@/lib/price-sync/cloud-price-updater"); const result = await syncCloudPriceTableToDatabase(); expect(result.ok).toBe(false); }); @@ -74,6 +79,7 @@ describe("syncCloudPriceTableToDatabase", () => { })) ); + const { syncCloudPriceTableToDatabase } = await import("@/lib/price-sync/cloud-price-updater"); const result = await syncCloudPriceTableToDatabase(); expect(result.ok).toBe(false); }); @@ -88,6 +94,7 @@ describe("syncCloudPriceTableToDatabase", () => { })) ); + const { syncCloudPriceTableToDatabase } = await import("@/lib/price-sync/cloud-price-updater"); const result = await syncCloudPriceTableToDatabase(); expect(result.ok).toBe(false); }); @@ -102,11 +109,13 @@ describe("syncCloudPriceTableToDatabase", () => { })) ); + const { processPriceTableInternal } = await import("@/actions/model-prices"); vi.mocked(processPriceTableInternal).mockResolvedValue({ ok: false, error: "write failed", } as unknown as CloudPriceTableResult); + const { syncCloudPriceTableToDatabase } = await import("@/lib/price-sync/cloud-price-updater"); const result = await syncCloudPriceTableToDatabase(); expect(result.ok).toBe(false); }); @@ -121,11 +130,13 @@ describe("syncCloudPriceTableToDatabase", () => { })) ); + const { processPriceTableInternal } = await import("@/actions/model-prices"); vi.mocked(processPriceTableInternal).mockResolvedValue({ ok: true, data: undefined, } as unknown as CloudPriceTableResult); + const { syncCloudPriceTableToDatabase } = await import("@/lib/price-sync/cloud-price-updater"); const result = await syncCloudPriceTableToDatabase(); expect(result.ok).toBe(false); }); @@ -143,6 +154,7 @@ describe("syncCloudPriceTableToDatabase", () => { })) ); + const { processPriceTableInternal } = await import("@/actions/model-prices"); vi.mocked(processPriceTableInternal).mockResolvedValue({ ok: true, data: { @@ -154,42 +166,206 @@ describe("syncCloudPriceTableToDatabase", () => { }, } as any); + const { syncCloudPriceTableToDatabase } = await import("@/lib/price-sync/cloud-price-updater"); const result = await syncCloudPriceTableToDatabase(); expect(result.ok).toBe(true); expect(processPriceTableInternal).toHaveBeenCalledTimes(1); }); + + it("falls back to default error message when write returns ok=false without error", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + status: 200, + text: async () => ['[models."m1"]', "input_cost_per_token = 0.000001"].join("\n"), + })) + ); + + const { processPriceTableInternal } = await import("@/actions/model-prices"); + vi.mocked(processPriceTableInternal).mockResolvedValue({ + ok: false, + error: undefined, + } as unknown as CloudPriceTableResult); + + const { syncCloudPriceTableToDatabase } = await import("@/lib/price-sync/cloud-price-updater"); + const result = await syncCloudPriceTableToDatabase(); + + expect(result.ok).toBe(false); + expect(result).toMatchObject({ ok: false, error: "云端价格表写入失败" }); + }); + + it("returns ok=false when write throws Error", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + status: 200, + text: async () => ['[models."m1"]', "input_cost_per_token = 0.000001"].join("\n"), + })) + ); + + const { processPriceTableInternal } = await import("@/actions/model-prices"); + vi.mocked(processPriceTableInternal).mockImplementationOnce(async () => { + throw new Error("boom"); + }); + + const { syncCloudPriceTableToDatabase } = await import("@/lib/price-sync/cloud-price-updater"); + const result = await syncCloudPriceTableToDatabase(); + + expect(result.ok).toBe(false); + expect(result).toMatchObject({ ok: false, error: expect.stringContaining("boom") }); + }); + + it("returns ok=false when write throws non-Error value", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + status: 200, + text: async () => ['[models."m1"]', "input_cost_per_token = 0.000001"].join("\n"), + })) + ); + + const { processPriceTableInternal } = await import("@/actions/model-prices"); + vi.mocked(processPriceTableInternal).mockImplementationOnce(async () => { + throw "boom"; + }); + + const { syncCloudPriceTableToDatabase } = await import("@/lib/price-sync/cloud-price-updater"); + const result = await syncCloudPriceTableToDatabase(); + + expect(result.ok).toBe(false); + expect(result).toMatchObject({ ok: false, error: expect.stringContaining("boom") }); + }); }); describe("requestCloudPriceTableSync", () => { + const prevRuntime = process.env.NEXT_RUNTIME; + beforeEach(() => { vi.clearAllMocks(); + vi.resetModules(); asyncTasks.splice(0, asyncTasks.length); vi.unstubAllGlobals(); + asyncTaskManagerLoaded = false; delete (globalThis as unknown as { __CCH_CLOUD_PRICE_SYNC_LAST_AT__?: number }) .__CCH_CLOUD_PRICE_SYNC_LAST_AT__; + delete (globalThis as unknown as { __CCH_CLOUD_PRICE_SYNC_SCHEDULING__?: boolean }) + .__CCH_CLOUD_PRICE_SYNC_SCHEDULING__; + + process.env.NEXT_RUNTIME = "nodejs"; + }); + + afterEach(() => { + if (prevRuntime === undefined) { + delete process.env.NEXT_RUNTIME; + return; + } + process.env.NEXT_RUNTIME = prevRuntime; }); - it("does nothing when same task is already active", () => { + it("no-ops in Edge runtime (does not load AsyncTaskManager)", async () => { + const prevRuntime = process.env.NEXT_RUNTIME; + process.env.NEXT_RUNTIME = "edge"; + + const { requestCloudPriceTableSync } = await import("@/lib/price-sync/cloud-price-updater"); + requestCloudPriceTableSync({ reason: "missing-model", throttleMs: 0 }); + await flushAsync(); + + expect(asyncTaskManagerLoaded).toBe(false); + + process.env.NEXT_RUNTIME = prevRuntime; + }); + + it("does nothing when same task is already active", async () => { + const { AsyncTaskManager } = await import("@/lib/async-task-manager"); + const { requestCloudPriceTableSync } = await import("@/lib/price-sync/cloud-price-updater"); + vi.mocked(AsyncTaskManager.getActiveTasks).mockReturnValue([ { taskId: "cloud-price-table-sync" }, ] as any); requestCloudPriceTableSync({ reason: "missing-model", throttleMs: 0 }); + await flushAsync(); expect(AsyncTaskManager.register).not.toHaveBeenCalled(); }); - it("throttles when called within throttle window", () => { + it("throttles when called within throttle window", async () => { ( globalThis as unknown as { __CCH_CLOUD_PRICE_SYNC_LAST_AT__?: number } ).__CCH_CLOUD_PRICE_SYNC_LAST_AT__ = Date.now(); + const { requestCloudPriceTableSync } = await import("@/lib/price-sync/cloud-price-updater"); requestCloudPriceTableSync({ reason: "missing-model", throttleMs: 60_000 }); + await flushAsync(); - expect(AsyncTaskManager.register).not.toHaveBeenCalled(); + expect(asyncTaskManagerLoaded).toBe(false); + }); + + it("uses default throttleMs when not provided", async () => { + ( + globalThis as unknown as { __CCH_CLOUD_PRICE_SYNC_LAST_AT__?: number } + ).__CCH_CLOUD_PRICE_SYNC_LAST_AT__ = Date.now(); + + const { requestCloudPriceTableSync } = await import("@/lib/price-sync/cloud-price-updater"); + requestCloudPriceTableSync({ reason: "missing-model" }); + await flushAsync(); + + expect(asyncTaskManagerLoaded).toBe(false); + }); + + it("does nothing when scheduling flag is already set", async () => { + ( + globalThis as unknown as { __CCH_CLOUD_PRICE_SYNC_SCHEDULING__?: boolean } + ).__CCH_CLOUD_PRICE_SYNC_SCHEDULING__ = true; + + const { requestCloudPriceTableSync } = await import("@/lib/price-sync/cloud-price-updater"); + requestCloudPriceTableSync({ reason: "missing-model", throttleMs: 0 }); + await flushAsync(); + + expect(asyncTaskManagerLoaded).toBe(false); + }); + + it("logs warn when scheduling fails with Error", async () => { + const { AsyncTaskManager } = await import("@/lib/async-task-manager"); + vi.mocked(AsyncTaskManager.getActiveTasks).mockImplementationOnce(() => { + throw new Error("import fail"); + }); + + const { requestCloudPriceTableSync } = await import("@/lib/price-sync/cloud-price-updater"); + requestCloudPriceTableSync({ reason: "scheduled", throttleMs: 0 }); + await flushAsync(); + + const { logger } = await import("@/lib/logger"); + expect(vi.mocked(logger.warn)).toHaveBeenCalledWith( + "[PriceSync] Cloud price sync scheduling failed", + expect.objectContaining({ error: "import fail" }) + ); + }); + + it("logs warn when scheduling fails with non-Error value", async () => { + const { AsyncTaskManager } = await import("@/lib/async-task-manager"); + vi.mocked(AsyncTaskManager.getActiveTasks).mockImplementationOnce(() => { + throw "import fail"; + }); + + const { requestCloudPriceTableSync } = await import("@/lib/price-sync/cloud-price-updater"); + requestCloudPriceTableSync({ reason: "scheduled", throttleMs: 0 }); + await flushAsync(); + + const { logger } = await import("@/lib/logger"); + expect(vi.mocked(logger.warn)).toHaveBeenCalledWith( + "[PriceSync] Cloud price sync scheduling failed", + expect.objectContaining({ error: "import fail" }) + ); }); it("registers a task and updates throttle timestamp after completion", async () => { + const { AsyncTaskManager } = await import("@/lib/async-task-manager"); + const { processPriceTableInternal } = await import("@/actions/model-prices"); + let resolveFetch: (value: unknown) => void; const fetchPromise = new Promise((resolve) => { resolveFetch = resolve; @@ -211,7 +387,9 @@ describe("requestCloudPriceTableSync", () => { }, } as any); + const { requestCloudPriceTableSync } = await import("@/lib/price-sync/cloud-price-updater"); requestCloudPriceTableSync({ reason: "missing-model", throttleMs: 0 }); + await flushAsync(); expect(AsyncTaskManager.register).toHaveBeenCalledTimes(1); @@ -227,11 +405,15 @@ describe("requestCloudPriceTableSync", () => { await Promise.all(asyncTasks.splice(0, asyncTasks.length)); expect(processPriceTableInternal).toHaveBeenCalledTimes(1); + const { logger } = await import("@/lib/logger"); expect(vi.mocked(logger.info)).toHaveBeenCalled(); expect(typeof g.__CCH_CLOUD_PRICE_SYNC_LAST_AT__).toBe("number"); }); it("logs warn when sync task fails", async () => { + const { AsyncTaskManager } = await import("@/lib/async-task-manager"); + const { requestCloudPriceTableSync } = await import("@/lib/price-sync/cloud-price-updater"); + vi.stubGlobal( "fetch", vi.fn(async () => ({ @@ -242,8 +424,12 @@ describe("requestCloudPriceTableSync", () => { ); requestCloudPriceTableSync({ reason: "scheduled", throttleMs: 0 }); + + await flushAsync(); + expect(AsyncTaskManager.register).toHaveBeenCalledTimes(1); await Promise.all(asyncTasks.splice(0, asyncTasks.length)); + const { logger } = await import("@/lib/logger"); expect(vi.mocked(logger.warn)).toHaveBeenCalled(); }); });