@temporalio/testing 1.11.8 → 1.12.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,90 @@
1
+ import 'abort-controller/polyfill'; // eslint-disable-line import/no-unassigned-import
2
+ import events from 'node:events';
3
+ import * as activity from '@temporalio/activity';
4
+ import {
5
+ ActivityFunction,
6
+ Logger,
7
+ SdkComponent,
8
+ defaultFailureConverter,
9
+ defaultPayloadConverter,
10
+ MetricMeter,
11
+ noopMetricMeter,
12
+ } from '@temporalio/common';
13
+ import { LoggerWithComposedMetadata } from '@temporalio/common/lib/logger';
14
+ import { ActivityInterceptorsFactory, DefaultLogger } from '@temporalio/worker';
15
+ import { Activity } from '@temporalio/worker/lib/activity';
16
+
17
+ export interface MockActivityEnvironmentOptions {
18
+ interceptors?: ActivityInterceptorsFactory[];
19
+ logger?: Logger;
20
+ metricMeter?: MetricMeter;
21
+ }
22
+
23
+ /**
24
+ * An execution environment for testing Activities.
25
+ *
26
+ * Mocks Activity {@link Context | activity.Context} and exposes hooks for cancellation and heartbeats.
27
+ *
28
+ * Note that the `Context` object used by this environment will be reused for all activities that are run in this
29
+ * environment. Consequently, once `cancel()` is called, any further activity that gets executed in this environment
30
+ * will immediately be in a cancelled state.
31
+ */
32
+ export class MockActivityEnvironment extends events.EventEmitter {
33
+ public cancel: (reason?: any) => void = () => undefined;
34
+ public readonly context: activity.Context;
35
+ private readonly activity: Activity;
36
+
37
+ constructor(info?: Partial<activity.Info>, opts?: MockActivityEnvironmentOptions) {
38
+ super();
39
+ const heartbeatCallback = (details?: unknown) => this.emit('heartbeat', details);
40
+ const loadedDataConverter = {
41
+ payloadConverter: defaultPayloadConverter,
42
+ payloadCodecs: [],
43
+ failureConverter: defaultFailureConverter,
44
+ };
45
+ this.activity = new Activity(
46
+ { ...defaultActivityInfo, ...info },
47
+ undefined,
48
+ loadedDataConverter,
49
+ heartbeatCallback,
50
+ LoggerWithComposedMetadata.compose(opts?.logger ?? new DefaultLogger(), { sdkComponent: SdkComponent.worker }),
51
+ opts?.metricMeter ?? noopMetricMeter,
52
+ opts?.interceptors ?? []
53
+ );
54
+ this.context = this.activity.context;
55
+ this.cancel = this.activity.cancel;
56
+ }
57
+
58
+ /**
59
+ * Run a function in Activity Context
60
+ */
61
+ public async run<P extends any[], R, F extends ActivityFunction<P, R>>(fn: F, ...args: P): Promise<R> {
62
+ return this.activity.runNoEncoding(fn as ActivityFunction<any, any>, { args, headers: {} }) as Promise<R>;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Used as the default activity info for Activities executed in the {@link MockActivityEnvironment}
68
+ *
69
+ * @hidden
70
+ */
71
+ export const defaultActivityInfo: activity.Info = {
72
+ attempt: 1,
73
+ taskQueue: 'test',
74
+ isLocal: false,
75
+ taskToken: Buffer.from('test'),
76
+ activityId: 'test',
77
+ activityType: 'unknown',
78
+ workflowType: 'test',
79
+ base64TaskToken: Buffer.from('test').toString('base64'),
80
+ heartbeatTimeoutMs: undefined,
81
+ heartbeatDetails: undefined,
82
+ activityNamespace: 'default',
83
+ workflowNamespace: 'default',
84
+ workflowExecution: { workflowId: 'test', runId: 'dead-beef' },
85
+ scheduledTimestampMs: 1,
86
+ startToCloseTimeoutMs: 1000,
87
+ scheduleToCloseTimeoutMs: 1000,
88
+ currentAttemptScheduledTimestampMs: 1,
89
+ priority: undefined,
90
+ };
package/src/pkg.ts ADDED
@@ -0,0 +1,7 @@
1
+ // ../package.json is outside of the TS project rootDir which causes TS to complain about this import.
2
+ // We do not want to change the rootDir because it messes up the output structure.
3
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
4
+ // @ts-ignore
5
+ import pkg from '../package.json';
6
+
7
+ export default pkg as { name: string; version: string };
@@ -0,0 +1,337 @@
1
+ import 'abort-controller/polyfill'; // eslint-disable-line import/no-unassigned-import
2
+ import { AsyncCompletionClient, Client, WorkflowClient } from '@temporalio/client';
3
+ import { Duration, TypedSearchAttributes } from '@temporalio/common';
4
+ import { msToNumber, msToTs, tsToMs } from '@temporalio/common/lib/time';
5
+ import { NativeConnection, Runtime } from '@temporalio/worker';
6
+ import { native } from '@temporalio/core-bridge';
7
+ import { filterNullAndUndefined } from '@temporalio/common/lib/internal-workflow';
8
+ import { Connection } from './connection';
9
+ import { toNativeEphemeralServerConfig, DevServerConfig, TimeSkippingServerConfig } from './ephemeral-server';
10
+ import { ClientOptionsForTestEnv, TestEnvClient } from './client';
11
+
12
+ /**
13
+ * Options for {@link TestWorkflowEnvironment.createLocal}
14
+ */
15
+ export type LocalTestWorkflowEnvironmentOptions = {
16
+ server?: Omit<DevServerConfig, 'type'>;
17
+ client?: ClientOptionsForTestEnv;
18
+ };
19
+
20
+ /**
21
+ * Options for {@link TestWorkflowEnvironment.createTimeSkipping}
22
+ */
23
+ export type TimeSkippingTestWorkflowEnvironmentOptions = {
24
+ server?: Omit<TimeSkippingServerConfig, 'type'>;
25
+ client?: ClientOptionsForTestEnv;
26
+ };
27
+
28
+ /**
29
+ * Options for {@link TestWorkflowEnvironment.createExistingServer}
30
+ */
31
+ export type ExistingServerTestWorkflowEnvironmentOptions = {
32
+ /** If not set, defaults to localhost:7233 */
33
+ address?: string;
34
+ /** If not set, defaults to default */
35
+ namespace?: string;
36
+ client?: ClientOptionsForTestEnv;
37
+ };
38
+
39
+ /**
40
+ * An execution environment for running Workflow integration tests.
41
+ *
42
+ * Runs an external server.
43
+ * By default, the Java test server is used which supports time skipping.
44
+ */
45
+ export class TestWorkflowEnvironment {
46
+ /**
47
+ * Namespace used in this environment (taken from {@link TestWorkflowEnvironmentOptions})
48
+ */
49
+ public readonly namespace?: string;
50
+
51
+ /**
52
+ * Get an established {@link Connection} to the ephemeral server
53
+ */
54
+ public readonly connection: Connection;
55
+
56
+ /**
57
+ * A {@link TestEnvClient} for interacting with the ephemeral server
58
+ */
59
+ public readonly client: Client;
60
+
61
+ /**
62
+ * An {@link AsyncCompletionClient} for interacting with the test server
63
+ *
64
+ * @deprecated - use `client.activity` instead
65
+ */
66
+ public readonly asyncCompletionClient: AsyncCompletionClient;
67
+
68
+ /**
69
+ * A {@link TimeSkippingWorkflowClient} for interacting with the test server
70
+ *
71
+ * @deprecated - use `client.workflow` instead
72
+ */
73
+ public readonly workflowClient: WorkflowClient;
74
+
75
+ /**
76
+ * A {@link NativeConnection} for interacting with the test server.
77
+ *
78
+ * Use this connection when creating Workers for testing.
79
+ */
80
+ public readonly nativeConnection: NativeConnection;
81
+
82
+ protected constructor(
83
+ private readonly runtime: Runtime,
84
+ public readonly options: TestWorkflowEnvironmentOptionsWithDefaults,
85
+ public readonly supportsTimeSkipping: boolean,
86
+ protected readonly server: native.EphemeralServer | 'existing',
87
+ connection: Connection,
88
+ nativeConnection: NativeConnection,
89
+ namespace: string | undefined
90
+ ) {
91
+ this.connection = connection;
92
+ this.nativeConnection = nativeConnection;
93
+ this.namespace = namespace;
94
+ this.client = new TestEnvClient({
95
+ connection,
96
+ namespace: this.namespace,
97
+ enableTimeSkipping: supportsTimeSkipping,
98
+ ...options.client,
99
+ });
100
+ this.asyncCompletionClient = this.client.activity; // eslint-disable-line deprecation/deprecation
101
+ this.workflowClient = this.client.workflow; // eslint-disable-line deprecation/deprecation
102
+ }
103
+
104
+ /**
105
+ * Start a time skipping workflow environment.
106
+ *
107
+ * This environment automatically skips to the next events in time when a workflow handle's `result` is awaited on
108
+ * (which includes {@link WorkflowClient.execute}). Before the result is awaited on, time can be manually skipped
109
+ * forward using {@link sleep}. The currently known time can be obtained via {@link currentTimeMs}.
110
+ *
111
+ * This environment will be powered by the Temporal Time Skipping Test Server (part of the [Java SDK](https://github.com/temporalio/sdk-java)).
112
+ * Note that the Time Skipping Test Server does not support full capabilities of the regular Temporal Server, and may
113
+ * occasionally present different behaviors. For general Workflow testing, it is generally preferable to use {@link createLocal}
114
+ * instead.
115
+ *
116
+ * Users can reuse this environment for testing multiple independent workflows, but not concurrently. Time skipping,
117
+ * which is automatically done when awaiting a workflow result and manually done on sleep, is global to the
118
+ * environment, not to the workflow under test. We highly recommend running tests serially when using a single
119
+ * environment or creating a separate environment per test.
120
+ *
121
+ * By default, the latest release of the Test Server will be downloaded and cached to a temporary directory
122
+ * (e.g. `$TMPDIR/temporal-test-server-sdk-typescript-*` or `%TEMP%/temporal-test-server-sdk-typescript-*.exe`). Note
123
+ * that existing cached binaries will be reused without validation that they are still up-to-date, until the SDK
124
+ * itself is updated. Alternatively, a specific version number of the Test Server may be provided, or the path to an
125
+ * existing Test Server binary may be supplied; see {@link LocalTestWorkflowEnvironmentOptions.server.executable}.
126
+ *
127
+ * Note that the Test Server implementation may be changed to another one in the future. Therefore, there is no
128
+ * guarantee that Test Server options, and particularly those provided through the `extraArgs` array, will continue to
129
+ * be supported in the future.
130
+ *
131
+ * IMPORTANT: At this time, the Time Skipping Test Server is not supported on ARM platforms. Execution on Apple
132
+ * silicon Macs will work if Rosetta 2 is installed.
133
+ */
134
+ static async createTimeSkipping(opts?: TimeSkippingTestWorkflowEnvironmentOptions): Promise<TestWorkflowEnvironment> {
135
+ return await this.create({
136
+ server: { type: 'time-skipping', ...opts?.server },
137
+ client: opts?.client,
138
+ supportsTimeSkipping: true,
139
+ });
140
+ }
141
+
142
+ /**
143
+ * Start a full Temporal server locally.
144
+ *
145
+ * This environment is good for testing full server capabilities, but does not support time skipping like
146
+ * {@link createTimeSkipping} does. {@link supportsTimeSkipping} will always return `false` for this environment.
147
+ * {@link sleep} will sleep the actual amount of time and {@link currentTimeMs} will return the current time.
148
+ *
149
+ * This local environment will be powered by [Temporal CLI](https://github.com/temporalio/cli), which is a
150
+ * self-contained executable for Temporal. By default, Temporal's database will not be persisted to disk, and no UI
151
+ * will be launched.
152
+ *
153
+ * By default, the latest release of the CLI will be downloaded and cached to a temporary directory
154
+ * (e.g. `$TMPDIR/temporal-sdk-typescript-*` or `%TEMP%/temporal-sdk-typescript-*.exe`). Note that existing cached
155
+ * binaries will be reused without validation that they are still up-to-date, until the SDK itself is updated.
156
+ * Alternatively, a specific version number of the CLI may be provided, or the path to an existing CLI binary may be
157
+ * supplied; see {@link LocalTestWorkflowEnvironmentOptions.server.executable}.
158
+ *
159
+ * Note that the Dev Server implementation may be changed to another one in the future. Therefore, there is no
160
+ * guarantee that Dev Server options, and particularly those provided through the `extraArgs` array, will continue to
161
+ * be supported in the future.
162
+ */
163
+ static async createLocal(opts?: LocalTestWorkflowEnvironmentOptions): Promise<TestWorkflowEnvironment> {
164
+ return await this.create({
165
+ server: { type: 'dev-server', ...opts?.server },
166
+ client: opts?.client,
167
+ namespace: opts?.server?.namespace,
168
+ supportsTimeSkipping: false,
169
+ });
170
+ }
171
+
172
+ /**
173
+ * Create a new test environment using an existing server. You must already be running a server, which the test
174
+ * environment will connect to.
175
+ */
176
+ static async createFromExistingServer(
177
+ opts?: ExistingServerTestWorkflowEnvironmentOptions
178
+ ): Promise<TestWorkflowEnvironment> {
179
+ return await this.create({
180
+ server: { type: 'existing' },
181
+ client: opts?.client,
182
+ namespace: opts?.namespace ?? 'default',
183
+ supportsTimeSkipping: false,
184
+ address: opts?.address,
185
+ });
186
+ }
187
+
188
+ /**
189
+ * Create a new test environment
190
+ */
191
+ private static async create(
192
+ opts: TestWorkflowEnvironmentOptions & {
193
+ supportsTimeSkipping: boolean;
194
+ namespace?: string;
195
+ address?: string;
196
+ }
197
+ ): Promise<TestWorkflowEnvironment> {
198
+ const { supportsTimeSkipping, namespace, ...rest } = opts;
199
+ const optsWithDefaults = addDefaults(filterNullAndUndefined(rest));
200
+
201
+ let address: string;
202
+ const runtime = Runtime.instance();
203
+ let server: native.EphemeralServer | 'existing';
204
+ if (optsWithDefaults.server.type !== 'existing') {
205
+ // Add search attributes to CLI server arguments
206
+ if ('searchAttributes' in optsWithDefaults.server && optsWithDefaults.server.searchAttributes) {
207
+ let newArgs: string[] = [];
208
+ for (const { name, type } of optsWithDefaults.server.searchAttributes) {
209
+ newArgs.push('--search-attribute');
210
+ newArgs.push(`${name}=${TypedSearchAttributes.toMetadataType(type)}`);
211
+ }
212
+ newArgs = newArgs.concat(optsWithDefaults.server.extraArgs ?? []);
213
+ optsWithDefaults.server.extraArgs = newArgs;
214
+ }
215
+
216
+ server = await runtime.createEphemeralServer(toNativeEphemeralServerConfig(optsWithDefaults.server));
217
+ address = native.ephemeralServerGetTarget(server);
218
+ } else {
219
+ address = opts.address ?? 'localhost:7233';
220
+ server = 'existing';
221
+ }
222
+
223
+ const nativeConnection = await NativeConnection.connect({ address });
224
+ const connection = await Connection.connect({ address });
225
+
226
+ return new this(runtime, optsWithDefaults, supportsTimeSkipping, server, connection, nativeConnection, namespace);
227
+ }
228
+
229
+ /**
230
+ * Kill the test server process and close the connection to it
231
+ */
232
+ async teardown(): Promise<void> {
233
+ await this.connection.close().catch((e) => {
234
+ console.error(e);
235
+ /* ignore */
236
+ });
237
+ await this.nativeConnection.close().catch((e) => {
238
+ console.error(e);
239
+ /* ignore */
240
+ });
241
+ if (this.server !== 'existing') {
242
+ await this.runtime.shutdownEphemeralServer(this.server).catch((e) => {
243
+ console.error(e);
244
+ /* ignore */
245
+ });
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Wait for `durationMs` in "server time".
251
+ *
252
+ * This awaits using regular setTimeout in regular environments or manually skips time in time-skipping environments.
253
+ *
254
+ * Useful for simulating events far into the future like completion of long running activities.
255
+ *
256
+ * **Time skippping**:
257
+ *
258
+ * The time skippping server toggles between skipped time and normal time depending on what it needs to execute.
259
+ *
260
+ * This method is _likely_ to resolve in less than `durationMs` of "real time".
261
+ *
262
+ * @param durationMs number of milliseconds or {@link https://www.npmjs.com/package/ms | ms-formatted string}
263
+ *
264
+ * @example
265
+ *
266
+ * `workflow.ts`
267
+ *
268
+ * ```ts
269
+ * const activities = proxyActivities({ startToCloseTimeout: 2_000_000 });
270
+ *
271
+ * export async function raceActivityAndTimer(): Promise<string> {
272
+ * return await Promise.race([
273
+ * wf.sleep(500_000).then(() => 'timer'),
274
+ * activities.longRunning().then(() => 'activity'),
275
+ * ]);
276
+ * }
277
+ * ```
278
+ *
279
+ * `test.ts`
280
+ *
281
+ * ```ts
282
+ * const worker = await Worker.create({
283
+ * connection: testEnv.nativeConnection,
284
+ * activities: {
285
+ * async longRunning() {
286
+ * await testEnv.sleep(1_000_000); // <-- sleep called here
287
+ * },
288
+ * },
289
+ * // ...
290
+ * });
291
+ * ```
292
+ */
293
+ sleep = async (durationMs: Duration): Promise<void> => {
294
+ if (this.supportsTimeSkipping) {
295
+ await (this.connection as Connection).testService.unlockTimeSkippingWithSleep({ duration: msToTs(durationMs) });
296
+ } else {
297
+ await new Promise((resolve) => setTimeout(resolve, msToNumber(durationMs)));
298
+ }
299
+ };
300
+
301
+ /**
302
+ * Get the current time known to this environment.
303
+ *
304
+ * For non-time-skipping environments this is simply the system time. For time-skipping environments this is whatever
305
+ * time has been skipped to.
306
+ */
307
+ async currentTimeMs(): Promise<number> {
308
+ if (this.supportsTimeSkipping) {
309
+ const { time } = await (this.connection as Connection).testService.getCurrentTime({});
310
+ return tsToMs(time);
311
+ } else {
312
+ return Date.now();
313
+ }
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Options for {@link TestWorkflowEnvironment.create}
319
+ */
320
+ type TestWorkflowEnvironmentOptions = {
321
+ server: DevServerConfig | TimeSkippingServerConfig | ExistingServerConfig;
322
+ client?: ClientOptionsForTestEnv;
323
+ };
324
+
325
+ type ExistingServerConfig = { type: 'existing' };
326
+
327
+ type TestWorkflowEnvironmentOptionsWithDefaults = Required<TestWorkflowEnvironmentOptions>;
328
+
329
+ function addDefaults(opts: TestWorkflowEnvironmentOptions): TestWorkflowEnvironmentOptionsWithDefaults {
330
+ return {
331
+ client: {},
332
+ ...opts,
333
+ server: {
334
+ ...opts.server,
335
+ },
336
+ };
337
+ }