Skip to content

Commit ccf3381

Browse files
committed
feat(core): add runInMockTaskContext test infrastructure
In-memory managers for locals, lifecycle hooks, runtime, input streams, and realtime streams, plus a mock TaskContext. Lets task code be driven end-to-end without hitting the Trigger.dev runtime — send data into input streams, inspect chunks written to output streams, and pre-seed locals for dependency injection.
1 parent a3f4a2b commit ccf3381

File tree

8 files changed

+1005
-0
lines changed

8 files changed

+1005
-0
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/core": patch
3+
---
4+
5+
Add `runInMockTaskContext` test harness at `@trigger.dev/core/v3/test` for unit-testing task code offline. Installs in-memory managers for `locals`, `lifecycleHooks`, `runtime`, `inputStreams`, and `realtimeStreams`, plus a mock `TaskContext`, so tasks can be driven end-to-end without hitting the Trigger.dev runtime. Provides drivers to send data into input streams and inspect chunks written to output streams.

packages/core/package.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"./v3/utils/retries": "./src/v3/utils/retries.ts",
4343
"./v3/utils/structuredLogger": "./src/v3/utils/structuredLogger.ts",
4444
"./v3/chat-client": "./src/v3/chat-client.ts",
45+
"./v3/test": "./src/v3/test/index.ts",
4546
"./v3/zodfetch": "./src/v3/zodfetch.ts",
4647
"./v3/zodMessageHandler": "./src/v3/zodMessageHandler.ts",
4748
"./v3/zodNamespace": "./src/v3/zodNamespace.ts",
@@ -153,6 +154,9 @@
153154
],
154155
"v3/isomorphic": [
155156
"dist/commonjs/v3/isomorphic/index.d.ts"
157+
],
158+
"v3/test": [
159+
"dist/commonjs/v3/test/index.d.ts"
156160
]
157161
}
158162
},
@@ -458,6 +462,17 @@
458462
"default": "./dist/commonjs/v3/chat-client.js"
459463
}
460464
},
465+
"./v3/test": {
466+
"import": {
467+
"@triggerdotdev/source": "./src/v3/test/index.ts",
468+
"types": "./dist/esm/v3/test/index.d.ts",
469+
"default": "./dist/esm/v3/test/index.js"
470+
},
471+
"require": {
472+
"types": "./dist/commonjs/v3/test/index.d.ts",
473+
"default": "./dist/commonjs/v3/test/index.js"
474+
}
475+
},
461476
"./v3/zodfetch": {
462477
"import": {
463478
"@triggerdotdev/source": "./src/v3/zodfetch.ts",

packages/core/src/v3/test/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export {
2+
runInMockTaskContext,
3+
type MockTaskContextDrivers,
4+
type MockTaskContextOptions,
5+
} from "./mock-task-context.js";
6+
export { TestInputStreamManager } from "./test-input-stream-manager.js";
7+
export { TestRealtimeStreamsManager } from "./test-realtime-streams-manager.js";
8+
export { TestRunMetadataManager } from "./test-run-metadata-manager.js";
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
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

Comments
 (0)