|
| 1 | +import { inputStreams } from "../input-streams-api.js"; |
| 2 | +import { realtimeStreams } from "../realtime-streams-api.js"; |
| 3 | +import { localsAPI } from "../locals-api.js"; |
| 4 | +import { runMetadata } from "../run-metadata-api.js"; |
| 5 | +import { taskContext } from "../task-context-api.js"; |
| 6 | +import { lifecycleHooks } from "../lifecycle-hooks-api.js"; |
| 7 | +import { runtime } from "../runtime-api.js"; |
| 8 | +import { StandardLocalsManager } from "../locals/manager.js"; |
| 9 | +import { StandardLifecycleHooksManager } from "../lifecycleHooks/manager.js"; |
| 10 | +import { NoopRuntimeManager } from "../runtime/noopRuntimeManager.js"; |
| 11 | +import { unregisterGlobal } from "../utils/globals.js"; |
| 12 | +import type { ServerBackgroundWorker, TaskRunContext } from "../schemas/index.js"; |
| 13 | +import type { LocalsKey } from "../locals/types.js"; |
| 14 | +import { TestInputStreamManager } from "./test-input-stream-manager.js"; |
| 15 | +import { TestRealtimeStreamsManager } from "./test-realtime-streams-manager.js"; |
| 16 | +import { TestRunMetadataManager } from "./test-run-metadata-manager.js"; |
| 17 | + |
| 18 | +/** |
| 19 | + * Shallow-partial overrides applied on top of the default mock |
| 20 | + * `TaskRunContext`. Each sub-object is a partial of its real shape — |
| 21 | + * unset fields get sensible defaults. |
| 22 | + */ |
| 23 | +export type MockTaskRunContextOverrides = { |
| 24 | + task?: Partial<TaskRunContext["task"]>; |
| 25 | + attempt?: Partial<TaskRunContext["attempt"]>; |
| 26 | + run?: Partial<TaskRunContext["run"]>; |
| 27 | + machine?: Partial<TaskRunContext["machine"]>; |
| 28 | + queue?: Partial<TaskRunContext["queue"]>; |
| 29 | + environment?: Partial<TaskRunContext["environment"]>; |
| 30 | + organization?: Partial<TaskRunContext["organization"]>; |
| 31 | + project?: Partial<TaskRunContext["project"]>; |
| 32 | + batch?: TaskRunContext["batch"]; |
| 33 | +}; |
| 34 | + |
| 35 | +/** |
| 36 | + * Options for overriding parts of the mock task context. |
| 37 | + */ |
| 38 | +export type MockTaskContextOptions = { |
| 39 | + /** Overrides applied on top of the default mock `TaskRunContext`. */ |
| 40 | + ctx?: MockTaskRunContextOverrides; |
| 41 | + /** Overrides applied on top of the default `ServerBackgroundWorker`. */ |
| 42 | + worker?: Partial<ServerBackgroundWorker>; |
| 43 | + /** Whether this is a warm start. */ |
| 44 | + isWarmStart?: boolean; |
| 45 | +}; |
| 46 | + |
| 47 | +/** |
| 48 | + * Drivers passed to the function running inside `runInMockTaskContext`. |
| 49 | + */ |
| 50 | +export type MockTaskContextDrivers = { |
| 51 | + /** Push data into input streams — simulates realtime input from outside the task. */ |
| 52 | + inputs: { |
| 53 | + /** |
| 54 | + * Send `data` to the named input stream. Resolves when all `.on()` |
| 55 | + * handlers have run. |
| 56 | + */ |
| 57 | + send(streamId: string, data: unknown): Promise<void>; |
| 58 | + /** Resolve any pending `.once()` waiters with a timeout error. */ |
| 59 | + close(streamId: string): void; |
| 60 | + }; |
| 61 | + /** Inspect chunks written to output (realtime) streams. */ |
| 62 | + outputs: { |
| 63 | + /** All chunks for a given stream, in the order they were written. */ |
| 64 | + chunks<T = unknown>(streamId: string): T[]; |
| 65 | + /** All chunks across every stream, keyed by stream id. */ |
| 66 | + all(): Record<string, unknown[]>; |
| 67 | + /** Clear chunks for one stream, or all streams if no id is provided. */ |
| 68 | + clear(streamId?: string): void; |
| 69 | + /** |
| 70 | + * Register a listener fired for every chunk written to any stream. |
| 71 | + * Returns an unsubscribe function. |
| 72 | + */ |
| 73 | + onWrite(listener: (streamId: string, chunk: unknown) => void): () => void; |
| 74 | + }; |
| 75 | + /** Read or seed locals for the run. */ |
| 76 | + locals: { |
| 77 | + /** Read a local set by either the task or `set()` below. */ |
| 78 | + get<T>(key: LocalsKey<T>): T | undefined; |
| 79 | + /** |
| 80 | + * Pre-seed a local before the task runs. Use this for dependency |
| 81 | + * injection — e.g. supply a test database client that the agent's |
| 82 | + * hooks read via `locals.get()` instead of constructing the prod one. |
| 83 | + */ |
| 84 | + set<T>(key: LocalsKey<T>, value: T): void; |
| 85 | + }; |
| 86 | + /** The mock `TaskRunContext` assembled from defaults + user overrides. */ |
| 87 | + ctx: TaskRunContext; |
| 88 | +}; |
| 89 | + |
| 90 | +function defaultTaskRunContext(overrides?: MockTaskRunContextOverrides): TaskRunContext { |
| 91 | + return { |
| 92 | + task: { |
| 93 | + id: "test-task", |
| 94 | + filePath: "test-task.ts", |
| 95 | + ...overrides?.task, |
| 96 | + }, |
| 97 | + attempt: { |
| 98 | + number: 1, |
| 99 | + startedAt: new Date(), |
| 100 | + ...overrides?.attempt, |
| 101 | + }, |
| 102 | + run: { |
| 103 | + id: "run_test", |
| 104 | + tags: [], |
| 105 | + isTest: false, |
| 106 | + createdAt: new Date(), |
| 107 | + startedAt: new Date(), |
| 108 | + ...overrides?.run, |
| 109 | + }, |
| 110 | + machine: { |
| 111 | + name: "micro", |
| 112 | + cpu: 1, |
| 113 | + memory: 0.5, |
| 114 | + centsPerMs: 0, |
| 115 | + ...overrides?.machine, |
| 116 | + }, |
| 117 | + queue: { |
| 118 | + name: "test-queue", |
| 119 | + id: "test-queue-id", |
| 120 | + ...overrides?.queue, |
| 121 | + }, |
| 122 | + environment: { |
| 123 | + id: "test-env-id", |
| 124 | + slug: "test-env", |
| 125 | + type: "DEVELOPMENT", |
| 126 | + ...overrides?.environment, |
| 127 | + }, |
| 128 | + organization: { |
| 129 | + id: "test-org-id", |
| 130 | + slug: "test-org", |
| 131 | + name: "Test Org", |
| 132 | + ...overrides?.organization, |
| 133 | + }, |
| 134 | + project: { |
| 135 | + id: "test-project-id", |
| 136 | + ref: "test-project-ref", |
| 137 | + slug: "test-project", |
| 138 | + name: "Test Project", |
| 139 | + ...overrides?.project, |
| 140 | + }, |
| 141 | + batch: overrides?.batch, |
| 142 | + }; |
| 143 | +} |
| 144 | + |
| 145 | +function defaultWorker(overrides?: Partial<ServerBackgroundWorker>): ServerBackgroundWorker { |
| 146 | + return { |
| 147 | + id: "test-worker-id", |
| 148 | + version: "test-version", |
| 149 | + contentHash: "test-content-hash", |
| 150 | + engine: "V2", |
| 151 | + ...overrides, |
| 152 | + }; |
| 153 | +} |
| 154 | + |
| 155 | +/** |
| 156 | + * Run a function inside a fully mocked task runtime context. |
| 157 | + * |
| 158 | + * Installs in-memory test managers for `locals`, `inputStreams`, |
| 159 | + * `realtimeStreams`, `lifecycleHooks`, and `runtime`, sets a mock |
| 160 | + * `TaskContext`, and tears everything down when the function returns. |
| 161 | + * |
| 162 | + * Inside the function, any code that reads from `locals`, `inputStreams`, |
| 163 | + * `realtimeStreams`, or `taskContext.ctx` will see the mock context — |
| 164 | + * so you can directly invoke the internal `run` function of any task |
| 165 | + * (including `chat.agent`) without hitting the Trigger.dev runtime. |
| 166 | + * |
| 167 | + * @example |
| 168 | + * ```ts |
| 169 | + * import { runInMockTaskContext } from "@trigger.dev/core/v3/test"; |
| 170 | + * |
| 171 | + * await runInMockTaskContext( |
| 172 | + * async ({ inputs, outputs, ctx }) => { |
| 173 | + * // Fire an input stream from the "outside" |
| 174 | + * setTimeout(() => { |
| 175 | + * inputs.send("chat-messages", { messages: [], chatId: "c1" }); |
| 176 | + * }, 0); |
| 177 | + * |
| 178 | + * // Run task code that reads from inputStreams.once(...) |
| 179 | + * await myTask.fns.run(payload, { ctx, signal: new AbortController().signal }); |
| 180 | + * |
| 181 | + * // Inspect chunks written to the output stream |
| 182 | + * expect(outputs.chunks("chat")).toContainEqual({ type: "text-delta", delta: "hi" }); |
| 183 | + * }, |
| 184 | + * { ctx: { run: { id: "run_abc" } } } |
| 185 | + * ); |
| 186 | + * ``` |
| 187 | + */ |
| 188 | +export async function runInMockTaskContext<T>( |
| 189 | + fn: (drivers: MockTaskContextDrivers) => T | Promise<T>, |
| 190 | + options?: MockTaskContextOptions |
| 191 | +): Promise<T> { |
| 192 | + const ctx = defaultTaskRunContext(options?.ctx); |
| 193 | + const worker = defaultWorker(options?.worker); |
| 194 | + |
| 195 | + const localsManager = new StandardLocalsManager(); |
| 196 | + const lifecycleManager = new StandardLifecycleHooksManager(); |
| 197 | + const runtimeManager = new NoopRuntimeManager(); |
| 198 | + const metadataManager = new TestRunMetadataManager(); |
| 199 | + const inputManager = new TestInputStreamManager(); |
| 200 | + const outputManager = new TestRealtimeStreamsManager(); |
| 201 | + |
| 202 | + // Unregister any previously-installed managers so `setGlobal*` wins — |
| 203 | + // `registerGlobal` returns false silently if an entry already exists. |
| 204 | + unregisterGlobal("locals"); |
| 205 | + unregisterGlobal("lifecycle-hooks"); |
| 206 | + unregisterGlobal("runtime"); |
| 207 | + unregisterGlobal("run-metadata"); |
| 208 | + unregisterGlobal("input-streams"); |
| 209 | + unregisterGlobal("realtime-streams"); |
| 210 | + unregisterGlobal("task-context"); |
| 211 | + |
| 212 | + localsAPI.setGlobalLocalsManager(localsManager); |
| 213 | + lifecycleHooks.setGlobalLifecycleHooksManager(lifecycleManager); |
| 214 | + runtime.setGlobalRuntimeManager(runtimeManager); |
| 215 | + runMetadata.setGlobalManager(metadataManager); |
| 216 | + inputStreams.setGlobalManager(inputManager); |
| 217 | + realtimeStreams.setGlobalManager(outputManager); |
| 218 | + taskContext.setGlobalTaskContext({ |
| 219 | + ctx, |
| 220 | + worker, |
| 221 | + isWarmStart: options?.isWarmStart ?? false, |
| 222 | + }); |
| 223 | + |
| 224 | + const drivers: MockTaskContextDrivers = { |
| 225 | + inputs: { |
| 226 | + send: (streamId, data) => inputManager.__sendFromTest(streamId, data), |
| 227 | + close: (streamId) => inputManager.__closeFromTest(streamId), |
| 228 | + }, |
| 229 | + outputs: { |
| 230 | + chunks: (streamId) => outputManager.__chunksFromTest(streamId), |
| 231 | + all: () => outputManager.__allChunksFromTest(), |
| 232 | + clear: (streamId) => outputManager.__clearFromTest(streamId), |
| 233 | + onWrite: (listener) => outputManager.onWrite(listener), |
| 234 | + }, |
| 235 | + locals: { |
| 236 | + get: <TValue>(key: LocalsKey<TValue>) => localsManager.getLocal(key), |
| 237 | + set: <TValue>(key: LocalsKey<TValue>, value: TValue) => |
| 238 | + localsManager.setLocal(key, value), |
| 239 | + }, |
| 240 | + ctx, |
| 241 | + }; |
| 242 | + |
| 243 | + try { |
| 244 | + return await fn(drivers); |
| 245 | + } finally { |
| 246 | + localsAPI.disable(); |
| 247 | + lifecycleHooks.disable(); |
| 248 | + runtime.disable(); |
| 249 | + // taskContext.disable() only sets a flag — unregister the global so |
| 250 | + // `taskContext.ctx` returns undefined after the harness returns. |
| 251 | + unregisterGlobal("task-context"); |
| 252 | + unregisterGlobal("input-streams"); |
| 253 | + unregisterGlobal("realtime-streams"); |
| 254 | + unregisterGlobal("run-metadata"); |
| 255 | + localsManager.reset(); |
| 256 | + inputManager.reset(); |
| 257 | + outputManager.reset(); |
| 258 | + metadataManager.reset(); |
| 259 | + } |
| 260 | +} |
0 commit comments