diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d03964524..97f6864b8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,16 +8,15 @@ - [Maintenance](#maintenance) - [Getting started](#getting-started) - [Contributor License Agreement (CLA)](#contributor-license-agreement-cla) - - [SDK Structure](#sdk-structure) - [Environment setup](#environment-setup) - [Development](#development) + - [Working with Individual Packages](#working-with-individual-packages) - [Testing](#testing) - [Testing local changes to core](#testing-local-changes-to-core) - - [Integration tests](#integration-tests) + - [Integration tests](#integration-tests) - [test-npm-init](#test-npm-init) - [Style Guide](#style-guide) -- [Publishing](#publishing) -- [Updating published packages](#updating-published-packages) +- [Updating and pruning dependencies](#updating-and-pruning-dependencies) @@ -69,9 +68,9 @@ a version manager, such as [fnm](https://github.com/Schniz/fnm) or [nvm](https:/ 6. Install `pnpm` TS SDK uses PNPM to manage dependencies. Corepack is the recommend way to install `pnpm` and is included in Node 14+ -```sh -corepack enable -``` + ```sh + corepack enable + ``` 7. Install the dependencies: @@ -90,7 +89,7 @@ pnpm run build If building fails, resetting your environment may help: ``` -pnpm run clean -y +pnpm run clean pnpm install --frozen-lockfile ``` @@ -112,6 +111,20 @@ After your environment is set up, you can run these commands: - `pnpm run lint` verifies code style with prettier and ES lint. - `pnpm run commitlint` validates [commit messages](#style-guide). +### Working with Individual Packages + +You can build or test a single package using pnpm's filter flag: + +```sh +# Build a single package and all its dependencies explicitly +pnpm -F @temporalio/worker... run build + +# Run tests for a single package +pnpm -F @temporalio/common run test +``` + +The `...` suffix includes all dependencies of the specified package. + ### Testing #### Testing local changes to core @@ -119,7 +132,7 @@ After your environment is set up, you can run these commands: Create a `.cargo/config.toml` file and override the path to sdk-core and/or sdk-core-protos as described [here](https://doc.rust-lang.org/cargo/reference/overriding-dependencies.html#paths-overrides) -##### Integration tests +#### Integration tests In order to run integration tests: diff --git a/package.json b/package.json index a3ba7d7cc..8a1a69396 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "build": "pnpm --recursive --stream run build", "build:watch": "pnpm run build:protos && tsc --build --watch packages/*/tsconfig.json", "build:protos": "tsx ./packages/proto/scripts/compile-proto.ts", - "test": "pnpm --recursive --stream run test", + "test": "pnpm --recursive --parallel --stream run test", "test:watch": "pnpm --recursive --stream run test:watch", "ci-stress": "node ./packages/test/lib/load/run-all-stress-ci-scenarios.js", "ci-nightly": "node ./packages/test/lib/load/run-all-nightly-scenarios.js", diff --git a/packages/activity/package.json b/packages/activity/package.json index 5fccb900e..f5f0969b1 100644 --- a/packages/activity/package.json +++ b/packages/activity/package.json @@ -12,6 +12,9 @@ ], "author": "Temporal Technologies Inc. ", "license": "MIT", + "scripts": { + "build": "tsc --build" + }, "dependencies": { "@temporalio/client": "workspace:*", "@temporalio/common": "workspace:*", diff --git a/packages/ai-sdk/package.json b/packages/ai-sdk/package.json index 72228a52f..67952c619 100644 --- a/packages/ai-sdk/package.json +++ b/packages/ai-sdk/package.json @@ -14,6 +14,15 @@ ], "author": "Temporal Technologies Inc. ", "license": "MIT", + "scripts": { + "build": "tsc --build", + "test": "ava ./lib/__tests__/test-*.js" + }, + "ava": { + "timeout": "120s", + "concurrency": 1, + "workerThreads": false + }, "dependencies": { "@temporalio/common": "workspace:*", "@temporalio/plugin": "workspace:*", @@ -27,6 +36,26 @@ "@ai-sdk/provider": "^3.0.0", "ai": "^6.0.0" }, + "devDependencies": { + "@ai-sdk/mcp": "^1.0.0", + "@ai-sdk/openai": "^3.0.0", + "@ai-sdk/provider": "^3.0.0", + "@modelcontextprotocol/sdk": "^1.25.2", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^1.25.1", + "@opentelemetry/sdk-node": "^0.52.1", + "@opentelemetry/semantic-conventions": "^1.25.1", + "@temporalio/client": "workspace:*", + "@temporalio/interceptors-opentelemetry": "workspace:*", + "@temporalio/proto": "workspace:*", + "@temporalio/test-helpers": "workspace:*", + "@temporalio/testing": "workspace:*", + "@temporalio/worker": "workspace:*", + "ai": "^6.0.0", + "ava": "^5.3.1", + "uuid": "^11.1.0", + "zod": "^3.25.76" + }, "engines": { "node": ">= 20.0.0" }, @@ -44,6 +73,8 @@ }, "files": [ "src", - "lib" + "lib", + "!src/__tests__", + "!lib/__tests__" ] } diff --git a/packages/test/src/activities/ai-sdk.ts b/packages/ai-sdk/src/__tests__/activities/ai-sdk.ts similarity index 100% rename from packages/test/src/activities/ai-sdk.ts rename to packages/ai-sdk/src/__tests__/activities/ai-sdk.ts diff --git a/packages/test/src/test-ai-sdk.ts b/packages/ai-sdk/src/__tests__/test-ai-sdk.ts similarity index 95% rename from packages/test/src/test-ai-sdk.ts rename to packages/ai-sdk/src/__tests__/test-ai-sdk.ts index d35408180..f8e84c3f0 100644 --- a/packages/test/src/test-ai-sdk.ts +++ b/packages/ai-sdk/src/__tests__/test-ai-sdk.ts @@ -17,6 +17,7 @@ import type { TranscriptionModelV3, } from '@ai-sdk/provider'; import { openai } from '@ai-sdk/openai'; +import { TestFn } from 'ava'; import { v4 as uuid4 } from 'uuid'; import * as opentelemetry from '@opentelemetry/sdk-node'; import { SEMRESATTRS_SERVICE_NAME } from '@opentelemetry/semantic-conventions'; @@ -26,7 +27,6 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { experimental_createMCPClient as createMCPClient } from '@ai-sdk/mcp'; -import { AiSdkPlugin, createActivities } from '@temporalio/ai-sdk'; import { temporal } from '@temporalio/proto'; import { WorkflowClient } from '@temporalio/client'; import { @@ -37,7 +37,12 @@ import { OpenTelemetryWorkflowClientCallsInterceptor, OpenTelemetryWorkflowClientInterceptor, } from '@temporalio/interceptors-opentelemetry'; -import { InjectedSinks, Runtime } from '@temporalio/worker'; +import { workflowInterceptorModules } from '@temporalio/testing'; +import { bundleWorkflowCode, DefaultLogger, InjectedSinks, Runtime } from '@temporalio/worker'; + +import { test as anyTest, BaseContext, helpers, Worker, TestWorkflowEnvironment } from '@temporalio/test-helpers'; + +import { AiSdkPlugin, createActivities } from '..'; import { embeddingWorkflow, generateObjectWorkflow, @@ -48,9 +53,7 @@ import { telemetryWorkflow, toolsWorkflow, } from './workflows/ai-sdk'; -import { helpers, makeTestFunction } from './helpers-integration'; import { getWeather } from './activities/ai-sdk'; -import { Worker } from './helpers'; import EventType = temporal.api.enums.v1.EventType; const remoteTests = ['1', 't', 'true'].includes((process.env.AI_SDK_REMOTE_TESTS ?? 'false').toLowerCase()); @@ -226,8 +229,20 @@ function* embeddingGenerator(): Generator { yield embeddingResponse(['Hello, world!', 'How are you?']); } -const test = makeTestFunction({ - workflowsPath: require.resolve('./workflows/ai-sdk'), +const test = anyTest as TestFn; + +test.before(async (t) => { + const env = await TestWorkflowEnvironment.createLocal(); + const workflowBundle = await bundleWorkflowCode({ + workflowsPath: require.resolve('./workflows/ai-sdk'), + workflowInterceptorModules, + logger: new DefaultLogger('WARN'), + }); + t.context = { env, workflowBundle }; +}); + +test.after.always(async (t) => { + await t.context.env?.teardown(); }); test('Hello world agent responds in haikus', async (t) => { @@ -527,7 +542,7 @@ test('callToolActivity awaits tool.execute before closing MCP client', async (t) }) as Record Promise>; // Get the callTool activity - const callToolActivity = activities['testServer-callTool']; + const callToolActivity = activities['testServer-callTool']!; t.truthy(callToolActivity, 'callToolActivity should exist'); // Call the activity diff --git a/packages/test/src/workflows/ai-sdk.ts b/packages/ai-sdk/src/__tests__/workflows/ai-sdk.ts similarity index 96% rename from packages/test/src/workflows/ai-sdk.ts rename to packages/ai-sdk/src/__tests__/workflows/ai-sdk.ts index 293191ec7..64a28533a 100644 --- a/packages/test/src/workflows/ai-sdk.ts +++ b/packages/ai-sdk/src/__tests__/workflows/ai-sdk.ts @@ -1,11 +1,11 @@ // Test workflow using AI model // eslint-disable-next-line import/no-unassigned-import -import '@temporalio/ai-sdk/lib/load-polyfills'; +import '../../load-polyfills'; import { embedMany, generateText, Output, stepCountIs, tool, wrapLanguageModel } from 'ai'; import { z } from 'zod'; import type { LanguageModelV3Middleware } from '@ai-sdk/provider'; import { proxyActivities } from '@temporalio/workflow'; -import { TemporalMCPClient, temporalProvider } from '@temporalio/ai-sdk'; +import { TemporalMCPClient, temporalProvider } from '../..'; import type * as activities from '../activities/ai-sdk'; const { getWeather } = proxyActivities({ @@ -126,7 +126,7 @@ export async function mcpSchemaTestWorkflow(): Promise<{ const mcpClient = new TemporalMCPClient({ name: 'testServer' }); const tools = await mcpClient.tools(); - const [toolName, tool] = Object.entries(tools)[0]; + const [toolName, tool] = Object.entries(tools)[0]!; const schema = (tool as any).inputSchema.jsonSchema; diff --git a/packages/ai-sdk/src/__tests__/workflows/otel-interceptors.ts b/packages/ai-sdk/src/__tests__/workflows/otel-interceptors.ts new file mode 100644 index 000000000..c9f5d663f --- /dev/null +++ b/packages/ai-sdk/src/__tests__/workflows/otel-interceptors.ts @@ -0,0 +1,14 @@ +/** Not a workflow, just interceptors */ + +import { WorkflowInterceptors } from '@temporalio/workflow'; +import { + OpenTelemetryInboundInterceptor, + OpenTelemetryOutboundInterceptor, + OpenTelemetryInternalsInterceptor, +} from '@temporalio/interceptors-opentelemetry/lib/workflow'; + +export const interceptors = (): WorkflowInterceptors => ({ + inbound: [new OpenTelemetryInboundInterceptor()], + outbound: [new OpenTelemetryOutboundInterceptor()], + internals: [new OpenTelemetryInternalsInterceptor()], +}); diff --git a/packages/ai-sdk/tsconfig.json b/packages/ai-sdk/tsconfig.json index 17be15184..976efce6d 100644 --- a/packages/ai-sdk/tsconfig.json +++ b/packages/ai-sdk/tsconfig.json @@ -4,6 +4,15 @@ "outDir": "./lib", "rootDir": "./src" }, - "references": [{ "path": "../plugin" }, { "path": "../workflow" }], + "references": [ + { "path": "../plugin" }, + { "path": "../workflow" }, + { "path": "../client" }, + { "path": "../proto" }, + { "path": "../test-helpers" }, + { "path": "../testing" }, + { "path": "../worker" }, + { "path": "../interceptors-opentelemetry" } + ], "include": ["./src/**/*.ts"] } diff --git a/packages/client/package.json b/packages/client/package.json index 5d962fe48..09ec3c3b8 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -4,7 +4,9 @@ "description": "Temporal.io SDK Client sub-package", "main": "lib/index.js", "types": "./lib/index.d.ts", - "scripts": {}, + "scripts": { + "build": "tsc --build" + }, "keywords": [ "temporal", "workflow", diff --git a/packages/cloud/package.json b/packages/cloud/package.json index 06604cbd3..6cf8baed6 100644 --- a/packages/cloud/package.json +++ b/packages/cloud/package.json @@ -4,7 +4,9 @@ "description": "Temporal.io SDK — Temporal Cloud Client", "main": "lib/index.js", "types": "./lib/index.d.ts", - "scripts": {}, + "scripts": { + "build": "tsc --build" + }, "keywords": [ "temporal", "workflow", diff --git a/packages/common/package.json b/packages/common/package.json index 577fdeeb0..32f6d6805 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -18,7 +18,17 @@ "nexus-rpc": "^0.0.1", "proto3-json-serializer": "^2.0.0" }, + "scripts": { + "build": "tsc --build", + "test": "ava ./lib/__tests__/test-*.js" + }, + "ava": { + "timeout": "60s", + "concurrency": 1, + "workerThreads": false + }, "devDependencies": { + "ava": "^5.3.1", "protobufjs": "^7.2.5" }, "bugs": { @@ -38,6 +48,8 @@ }, "files": [ "src", - "lib" + "lib", + "!src/__tests__", + "!lib/__tests__" ] } diff --git a/packages/test/src/test-enums-helpers.ts b/packages/common/src/__tests__/test-enums-helpers.ts similarity index 99% rename from packages/test/src/test-enums-helpers.ts rename to packages/common/src/__tests__/test-enums-helpers.ts index d5d73c02b..fdb99389e 100644 --- a/packages/test/src/test-enums-helpers.ts +++ b/packages/common/src/__tests__/test-enums-helpers.ts @@ -1,6 +1,6 @@ import test from 'ava'; import { coresdk } from '@temporalio/proto'; -import { makeProtoEnumConverters as makeProtoEnumConverters } from '@temporalio/common/lib/internal-workflow/enums-helpers'; +import { makeProtoEnumConverters as makeProtoEnumConverters } from '../internal-workflow/enums-helpers'; // ASSERTION: There MUST be a corresponding `KEY: 'KEY'` in the const object of strings enum (must be present) { diff --git a/packages/test/src/test-parse-uri.ts b/packages/common/src/__tests__/test-parse-uri.ts similarity index 97% rename from packages/test/src/test-parse-uri.ts rename to packages/common/src/__tests__/test-parse-uri.ts index 56d7da861..16d61a02e 100644 --- a/packages/test/src/test-parse-uri.ts +++ b/packages/common/src/__tests__/test-parse-uri.ts @@ -1,5 +1,5 @@ import test from 'ava'; -import { splitProtoHostPort, normalizeGrpcEndpointAddress } from '@temporalio/common/lib/internal-non-workflow'; +import { splitProtoHostPort, normalizeGrpcEndpointAddress } from '../internal-non-workflow'; test('splitProtoHostPort', (t) => { t.deepEqual(splitProtoHostPort('127.0.0.1'), { scheme: undefined, hostname: '127.0.0.1', port: undefined }); diff --git a/packages/test/src/test-retry-policy.ts b/packages/common/src/__tests__/test-retry-policy.ts similarity index 95% rename from packages/test/src/test-retry-policy.ts rename to packages/common/src/__tests__/test-retry-policy.ts index b597d346b..0936da3b4 100644 --- a/packages/test/src/test-retry-policy.ts +++ b/packages/common/src/__tests__/test-retry-policy.ts @@ -1,6 +1,6 @@ import test from 'ava'; -import { compileRetryPolicy, ValueError } from '@temporalio/common'; -import { msToTs } from '@temporalio/common/lib/time'; +import { compileRetryPolicy, ValueError } from '../index'; +import { msToTs } from '../time'; test('compileRetryPolicy validates intervals are not 0', (t) => { t.throws(() => compileRetryPolicy({ initialInterval: 0 }), { diff --git a/packages/test/src/test-time.ts b/packages/common/src/__tests__/test-time.ts similarity index 85% rename from packages/test/src/test-time.ts rename to packages/common/src/__tests__/test-time.ts index da1df0a67..0e119e5fe 100644 --- a/packages/test/src/test-time.ts +++ b/packages/common/src/__tests__/test-time.ts @@ -1,7 +1,7 @@ import test from 'ava'; import Long from 'long'; -import { msToTs } from '@temporalio/common/lib/time'; +import { msToTs } from '../time'; test('msToTs converts to Timestamp', (t) => { t.deepEqual({ seconds: Long.fromInt(600), nanos: 0 }, msToTs('10 minutes')); diff --git a/packages/test/src/test-type-helpers.ts b/packages/common/src/__tests__/test-type-helpers.ts similarity index 98% rename from packages/test/src/test-type-helpers.ts rename to packages/common/src/__tests__/test-type-helpers.ts index 5dbae6a02..c96a86640 100644 --- a/packages/test/src/test-type-helpers.ts +++ b/packages/common/src/__tests__/test-type-helpers.ts @@ -1,6 +1,6 @@ import vm from 'vm'; import anyTest, { TestFn } from 'ava'; -import { SymbolBasedInstanceOfError } from '@temporalio/common/lib/type-helpers'; +import { SymbolBasedInstanceOfError } from '../type-helpers'; interface Context { cx1: (script: string) => any; diff --git a/packages/envconfig/package.json b/packages/envconfig/package.json index b8f60bbf7..07d055714 100644 --- a/packages/envconfig/package.json +++ b/packages/envconfig/package.json @@ -4,7 +4,15 @@ "description": "Temporal.io SDK Environment Configuration sub-package", "main": "lib/index.js", "types": "./lib/index.d.ts", - "scripts": {}, + "scripts": { + "build": "tsc --build", + "test": "ava ./lib/__tests__/test-*.js" + }, + "ava": { + "timeout": "60s", + "concurrency": 1, + "workerThreads": false + }, "keywords": [ "temporal", "environment", @@ -18,7 +26,11 @@ "smol-toml": "1.4.2" }, "devDependencies": { - "@temporalio/worker": "workspace:*" + "@temporalio/client": "workspace:*", + "@temporalio/testing": "workspace:*", + "@temporalio/worker": "workspace:*", + "ava": "^5.3.1", + "dedent": "^1.5.1" }, "engines": { "node": ">= 20.0.0" @@ -37,6 +49,8 @@ }, "files": [ "src", - "lib" + "lib", + "!src/__tests__", + "!lib/__tests__" ] } diff --git a/packages/test/src/test-envconfig.ts b/packages/envconfig/src/__tests__/test-envconfig.ts similarity index 97% rename from packages/test/src/test-envconfig.ts rename to packages/envconfig/src/__tests__/test-envconfig.ts index db18d1647..5d5ee5d03 100644 --- a/packages/test/src/test-envconfig.ts +++ b/packages/envconfig/src/__tests__/test-envconfig.ts @@ -4,7 +4,9 @@ import * as os from 'os'; import test from 'ava'; import dedent from 'dedent'; import { Connection, Client } from '@temporalio/client'; +import { encode } from '@temporalio/common/lib/encoding'; import { TestWorkflowEnvironment } from '@temporalio/testing'; +import { NativeConnection } from '@temporalio/worker'; import { ClientConfig, ClientConfigProfile, @@ -17,10 +19,8 @@ import { toClientOptions, toTomlConfig, toTomlProfile, -} from '@temporalio/envconfig'; -import { toPathAndData } from '@temporalio/envconfig/lib/utils'; -import { NativeConnection } from '@temporalio/worker'; -import { encode } from '@temporalio/common/lib/encoding'; +} from '../index'; +import { toPathAndData } from '../utils'; // Focused TOML fixtures const TOML_CONFIG_BASE = dedent` @@ -244,7 +244,7 @@ test('gRPC metadata normalization from TOML', (t) => { sOme-hEader_key = "some-value" `; const conf = loadClientConfig({ configSource: dataSource(toml) }); - const prof = conf.profiles['foo']; + const prof = conf.profiles['foo']!; t.truthy(prof); t.is(prof.grpcMeta?.['some-header-key'], 'some-value'); }); @@ -302,7 +302,7 @@ test('Load profiles without profile-level env overrides', (t) => { configSource: pathSource(filepath), overrideEnvVars: env, }); - t.is(conf.profiles['default'].address, 'default-address'); + t.is(conf.profiles['default']?.address, 'default-address'); // Test that profile-level loading with disableEnv ignores environment const profile = loadClientConfigProfile({ @@ -334,8 +334,8 @@ test('Load all profiles from file', (t) => { const conf = loadClientConfig({ configSource: dataSource(TOML_CONFIG_BASE) }); t.truthy(conf.profiles['default']); t.truthy(conf.profiles['custom']); - t.is(conf.profiles['default'].address, 'default-address'); - t.is(conf.profiles['custom'].apiKey, 'custom-api-key'); + t.is(conf.profiles['default']!.address, 'default-address'); + t.is(conf.profiles['custom']!.apiKey, 'custom-api-key'); }); test('Load all profiles from data', (t) => { @@ -351,8 +351,8 @@ test('Load all profiles from data', (t) => { const conf = loadClientConfig({ configSource: dataSource(configData) }); t.truthy(conf.profiles['alpha']); t.truthy(conf.profiles['beta']); - t.is(conf.profiles['alpha'].address, 'alpha-address'); - t.is(conf.profiles['beta'].apiKey, 'beta-key'); + t.is(conf.profiles['alpha']?.address, 'alpha-address'); + t.is(conf.profiles['beta']?.apiKey, 'beta-key'); }); test('Load profiles from non-existent file', (t) => { @@ -366,7 +366,7 @@ test('Load all profiles with overridden file path', (t) => { withTempFile(TOML_CONFIG_BASE, (filepath) => { const conf = loadClientConfig({ overrideEnvVars: { TEMPORAL_CONFIG_FILE: filepath } }); t.truthy(conf.profiles['default']); - t.is(conf.profiles['default'].address, 'default-address'); + t.is(conf.profiles['default']?.address, 'default-address'); }); }); @@ -669,11 +669,11 @@ test('Client config to/from TOML round-trip', (t) => { }; const tomlConfig = toTomlConfig(conf); const back = fromTomlConfig(tomlConfig); - t.is(back.profiles['default'].address, 'addr'); - t.is(back.profiles['default'].namespace, 'ns'); - t.is(back.profiles['custom'].address, 'addr2'); - t.is(back.profiles['custom'].apiKey, 'key2'); - t.is(back.profiles['custom'].grpcMeta?.['h'], 'v'); + t.is(back.profiles['default']?.address, 'addr'); + t.is(back.profiles['default']?.namespace, 'ns'); + t.is(back.profiles['custom']?.address, 'addr2'); + t.is(back.profiles['custom']?.apiKey, 'key2'); + t.is(back.profiles['custom']?.grpcMeta?.['h'], 'v'); }); // ============================================================================= diff --git a/packages/envconfig/tsconfig.json b/packages/envconfig/tsconfig.json index 557f4afc1..a40eb253f 100644 --- a/packages/envconfig/tsconfig.json +++ b/packages/envconfig/tsconfig.json @@ -4,6 +4,19 @@ "outDir": "./lib", "rootDir": "./src" }, - "references": [{ "path": "../common" }, { "path": "../worker" }], + "references": [ + { + "path": "../common" + }, + { + "path": "../client" + }, + { + "path": "../testing" + }, + { + "path": "../worker" + } + ], "include": ["./src/**/*.ts"] } diff --git a/packages/interceptors-opentelemetry/package.json b/packages/interceptors-opentelemetry/package.json index c475a7a5f..fcdffdda3 100644 --- a/packages/interceptors-opentelemetry/package.json +++ b/packages/interceptors-opentelemetry/package.json @@ -4,7 +4,10 @@ "description": "Temporal.io SDK interceptors bundle for tracing with opentelemetry", "main": "lib/index.js", "types": "./lib/index.d.ts", - "scripts": {}, + "scripts": { + "build": "tsc --build", + "test": "ava ./lib/__tests__/test-*.js" + }, "keywords": [ "temporal", "workflow", @@ -21,11 +24,19 @@ "@temporalio/plugin": "workspace:*" }, "devDependencies": { + "@opentelemetry/exporter-trace-otlp-grpc": "^0.52.1", + "@opentelemetry/sdk-node": "^0.52.1", + "@opentelemetry/semantic-conventions": "^1.25.1", "@temporalio/activity": "workspace:*", "@temporalio/client": "workspace:*", "@temporalio/common": "workspace:*", + "@temporalio/proto": "workspace:*", + "@temporalio/test-helpers": "workspace:*", + "@temporalio/testing": "workspace:*", "@temporalio/worker": "workspace:*", - "@temporalio/workflow": "workspace:*" + "@temporalio/workflow": "workspace:*", + "ava": "^5.3.1", + "uuid": "^11.1.0" }, "peerDependencies": { "@temporalio/common": "workspace:*", @@ -53,6 +64,13 @@ }, "files": [ "src", - "lib" - ] + "lib", + "!src/__tests__", + "!lib/__tests__" + ], + "ava": { + "timeout": "120s", + "concurrency": 1, + "workerThreads": false + } } diff --git a/packages/interceptors-opentelemetry/src/__tests__/activities/helpers.ts b/packages/interceptors-opentelemetry/src/__tests__/activities/helpers.ts new file mode 100644 index 000000000..f571e57f4 --- /dev/null +++ b/packages/interceptors-opentelemetry/src/__tests__/activities/helpers.ts @@ -0,0 +1,19 @@ +import { WorkflowHandle } from '@temporalio/client'; +import { QueryDefinition } from '@temporalio/common'; +import { Context } from '@temporalio/activity'; + +function getSchedulingWorkflowHandle(): WorkflowHandle { + const { info, client } = Context.current(); + const { workflowExecution } = info; + return client.workflow.getHandle(workflowExecution.workflowId, workflowExecution.runId); +} + +export async function signalSchedulingWorkflow(signalName: string): Promise { + const handle = getSchedulingWorkflowHandle(); + await handle.signal(signalName); +} + +export async function queryOwnWf(queryDef: QueryDefinition, ...args: A): Promise { + const handle = getSchedulingWorkflowHandle(); + return await handle.query(queryDef, ...args); +} diff --git a/packages/interceptors-opentelemetry/src/__tests__/activities/index.ts b/packages/interceptors-opentelemetry/src/__tests__/activities/index.ts new file mode 100644 index 000000000..7de3434d7 --- /dev/null +++ b/packages/interceptors-opentelemetry/src/__tests__/activities/index.ts @@ -0,0 +1,39 @@ +import { activityInfo, Context } from '@temporalio/activity'; +import { ApplicationFailure, ApplicationFailureCategory, CancelledFailure } from '@temporalio/common'; + +export { queryOwnWf, signalSchedulingWorkflow } from './helpers'; + +export async function echo(message?: string): Promise { + return message ?? 'echo'; +} + +export async function fakeProgress(sleepIntervalMs = 1000, numIters = 100): Promise { + await signalSchedulingWorkflow('activityStarted'); + try { + for (let progress = 1; progress <= numIters; ++progress) { + await Context.current().sleep(sleepIntervalMs); + Context.current().heartbeat(progress); + } + } catch (err) { + if (!(err instanceof CancelledFailure)) { + throw err; + } + throw err; + } +} + +async function signalSchedulingWorkflow(signalName: string): Promise { + const { info, client } = Context.current(); + const { workflowExecution } = info; + const handle = client.workflow.getHandle(workflowExecution.workflowId, workflowExecution.runId); + await handle.signal(signalName); +} + +export async function throwMaybeBenign(): Promise { + if (activityInfo().attempt === 1) { + throw ApplicationFailure.create({ message: 'not benign' }); + } + if (activityInfo().attempt === 2) { + throw ApplicationFailure.create({ message: 'benign', category: ApplicationFailureCategory.BENIGN }); + } +} diff --git a/packages/test/history_files/otel_1_11_3.json b/packages/interceptors-opentelemetry/src/__tests__/history_files/otel_1_11_3.json similarity index 100% rename from packages/test/history_files/otel_1_11_3.json rename to packages/interceptors-opentelemetry/src/__tests__/history_files/otel_1_11_3.json diff --git a/packages/test/history_files/otel_1_13_1.json b/packages/interceptors-opentelemetry/src/__tests__/history_files/otel_1_13_1.json similarity index 100% rename from packages/test/history_files/otel_1_13_1.json rename to packages/interceptors-opentelemetry/src/__tests__/history_files/otel_1_13_1.json diff --git a/packages/test/history_files/otel_smorgasbord_1_13_1.json b/packages/interceptors-opentelemetry/src/__tests__/history_files/otel_smorgasbord_1_13_1.json similarity index 100% rename from packages/test/history_files/otel_smorgasbord_1_13_1.json rename to packages/interceptors-opentelemetry/src/__tests__/history_files/otel_smorgasbord_1_13_1.json diff --git a/packages/test/history_files/otel_smorgasbord_1_13_2.json b/packages/interceptors-opentelemetry/src/__tests__/history_files/otel_smorgasbord_1_13_2.json similarity index 100% rename from packages/test/history_files/otel_smorgasbord_1_13_2.json rename to packages/interceptors-opentelemetry/src/__tests__/history_files/otel_smorgasbord_1_13_2.json diff --git a/packages/test/history_files/signal_workflow_1_13_1.json b/packages/interceptors-opentelemetry/src/__tests__/history_files/signal_workflow_1_13_1.json similarity index 100% rename from packages/test/history_files/signal_workflow_1_13_1.json rename to packages/interceptors-opentelemetry/src/__tests__/history_files/signal_workflow_1_13_1.json diff --git a/packages/test/src/test-otel.ts b/packages/interceptors-opentelemetry/src/__tests__/test-otel.ts similarity index 94% rename from packages/test/src/test-otel.ts rename to packages/interceptors-opentelemetry/src/__tests__/test-otel.ts index 7eae2ee2d..84f3a91a3 100644 --- a/packages/test/src/test-otel.ts +++ b/packages/interceptors-opentelemetry/src/__tests__/test-otel.ts @@ -3,6 +3,7 @@ */ import * as http from 'http'; import * as http2 from 'http2'; +import * as path from 'path'; import * as otelApi from '@opentelemetry/api'; import { SpanStatusCode, createTraceState } from '@opentelemetry/api'; import { ExportResultCode } from '@opentelemetry/core'; @@ -12,36 +13,62 @@ import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from ' import { SEMRESATTRS_SERVICE_NAME } from '@opentelemetry/semantic-conventions'; import test from 'ava'; import { v4 as uuid4 } from 'uuid'; -import type * as workflowImportStub from '@temporalio/interceptors-opentelemetry/lib/workflow/workflow-imports'; -import type * as workflowImportImpl from '@temporalio/interceptors-opentelemetry/lib/workflow/workflow-imports-impl'; import { WorkflowClient, WithStartWorkflowOperation, WorkflowClientInterceptor, Client } from '@temporalio/client'; -import { OpenTelemetryPlugin, OpenTelemetryWorkflowClientInterceptor } from '@temporalio/interceptors-opentelemetry'; -import { instrument } from '@temporalio/interceptors-opentelemetry/lib/instrumentation'; +import { TestWorkflowEnvironment } from '@temporalio/testing'; +import { + ActivityInboundCallsInterceptor, + ActivityOutboundCallsInterceptor, + BundlerPlugin, + bundleWorkflowCode, + DefaultLogger, + InjectedSinks, + Runtime, + Worker, +} from '@temporalio/worker'; +import { WorkflowInboundCallsInterceptor, WorkflowOutboundCallsInterceptor } from '@temporalio/workflow'; + +import { + RUN_INTEGRATION_TESTS, + loadHistory as loadHistoryBase, + createBaseBundlerOptions, + createTestWorkflowBundle, +} from '@temporalio/test-helpers'; + +import type * as workflowImportStub from '../workflow/workflow-imports'; +import type * as workflowImportImpl from '../workflow/workflow-imports-impl'; +import { OpenTelemetryWorkflowClientInterceptor } from '../client'; +import { OpenTelemetryPlugin, OpenTelemetryWorkflowClientCallsInterceptor } from '..'; +import { instrument } from '../instrumentation'; import { makeWorkflowExporter, OpenTelemetryActivityInboundInterceptor, OpenTelemetryActivityOutboundInterceptor, -} from '@temporalio/interceptors-opentelemetry/lib/worker'; +} from '../worker'; import { OpenTelemetrySinks, SpanName, SPAN_DELIMITER, OpenTelemetryOutboundInterceptor, OpenTelemetryInboundInterceptor, -} from '@temporalio/interceptors-opentelemetry/lib/workflow'; -import { - ActivityInboundCallsInterceptor, - ActivityOutboundCallsInterceptor, - bundleWorkflowCode, - DefaultLogger, - InjectedSinks, - Runtime, -} from '@temporalio/worker'; -import { WorkflowInboundCallsInterceptor, WorkflowOutboundCallsInterceptor } from '@temporalio/workflow'; +} from '../workflow'; import * as activities from './activities'; -import { bundlerOptions, loadHistory, RUN_INTEGRATION_TESTS, Worker } from './helpers'; import * as workflows from './workflows'; -import { createTestWorkflowBundle } from './helpers-integration'; + +async function loadHistory(fname: string) { + const fpath = path.resolve(__dirname, `../../src/__tests__/history_files/${fname}`); + return loadHistoryBase(fpath); +} + +async function createOtelTestWorkflowBundle(opts: { + workflowsPath: string; + workflowInterceptorModules?: string[]; + plugins?: BundlerPlugin[]; +}) { + return createTestWorkflowBundle({ + ...opts, + additionalIgnoreModules: [require.resolve('./activities')], + }); +} async function withFakeGrpcServer( fn: (port: number) => Promise, @@ -319,7 +346,7 @@ if (RUN_INTEGRATION_TESTS) { const spans = memoryExporter.getFinishedSpans(); t.is(spans.length, 1); - const span = spans[0]; + const span = spans[0]!; t.is(span.status.code, SpanStatusCode.ERROR); @@ -361,11 +388,11 @@ if (RUN_INTEGRATION_TESTS) { const spans = memoryExporter.getFinishedSpans(); t.is(spans.length, 3); - t.is(spans[0].status.code, SpanStatusCode.ERROR); - t.is(spans[0].status.message, 'not benign'); - t.is(spans[1].status.code, SpanStatusCode.UNSET); - t.is(spans[1].status.message, 'benign'); - t.is(spans[2].status.code, SpanStatusCode.OK); + t.is(spans[0]!.status.code, SpanStatusCode.ERROR); + t.is(spans[0]!.status.message, 'not benign'); + t.is(spans[1]!.status.code, SpanStatusCode.UNSET); + t.is(spans[1]!.status.message, 'benign'); + t.is(spans[2]!.status.code, SpanStatusCode.OK); }); test('executeUpdateWithStart works correctly with OTEL interceptors', async (t) => { @@ -384,7 +411,7 @@ if (RUN_INTEGRATION_TESTS) { spanProcessor: new SimpleSpanProcessor(traceExporter), }); const worker = await Worker.create({ - workflowBundle: await createTestWorkflowBundle({ + workflowBundle: await createOtelTestWorkflowBundle({ workflowsPath: require.resolve('./workflows'), plugins: [plugin], }), @@ -484,7 +511,7 @@ if (RUN_INTEGRATION_TESTS) { const client = new WorkflowClient(); await worker.runUntil(client.execute(workflows.successString, { taskQueue, workflowId: uuid4() })); - t.deepEqual(spans[0].resource.attributes, { + t.deepEqual(spans[0]!.resource.attributes, { [SEMRESATTRS_SERVICE_NAME]: serviceName, // If not using a span processor, then we do not expect the async attr to be present ...(useSpanProcessor ? { 'async.attr': 'resolved' } : {}), @@ -525,7 +552,7 @@ if (RUN_INTEGRATION_TESTS) { // Bundle workflow code with the plugin - this tests that configureBundler passes workflowInterceptorModules const workflowBundle = await bundleWorkflowCode({ - ...bundlerOptions, + ...createBaseBundlerOptions([require.resolve('./activities')]), workflowsPath: require.resolve('./workflows'), plugins: [plugin], logger: new DefaultLogger('WARN'), @@ -687,7 +714,7 @@ test('Can replay otel history from 1.11.3', async (t) => { await t.notThrowsAsync(async () => { await Worker.runReplayHistory( { - workflowBundle: await createTestWorkflowBundle({ + workflowBundle: await createOtelTestWorkflowBundle({ workflowsPath: require.resolve('./workflows/signal-start-otel'), workflowInterceptorModules: [require.resolve('./workflows/signal-start-otel')], }), @@ -710,7 +737,7 @@ test('Can replay otel history from 1.13.1', async (t) => { await t.notThrowsAsync(async () => { await Worker.runReplayHistory( { - workflowBundle: await createTestWorkflowBundle({ + workflowBundle: await createOtelTestWorkflowBundle({ workflowsPath: require.resolve('./workflows/signal-start-otel'), workflowInterceptorModules: [require.resolve('./workflows/signal-start-otel')], }), @@ -734,7 +761,7 @@ test('Can replay smorgasbord from 1.13.1', async (t) => { await t.notThrowsAsync(async () => { await Worker.runReplayHistory( { - workflowBundle: await createTestWorkflowBundle({ + workflowBundle: await createOtelTestWorkflowBundle({ workflowsPath: require.resolve('./workflows'), workflowInterceptorModules: [require.resolve('./workflows/otel-interceptors')], }), @@ -757,7 +784,7 @@ test('Can replay signal workflow from 1.13.1', async (t) => { await t.notThrowsAsync(async () => { await Worker.runReplayHistory( { - workflowBundle: await createTestWorkflowBundle({ + workflowBundle: await createOtelTestWorkflowBundle({ workflowsPath: require.resolve('./workflows/signal-workflow'), workflowInterceptorModules: [require.resolve('./workflows/otel-interceptors')], }), @@ -780,7 +807,7 @@ test('Can replay smorgasbord from 1.13.2', async (t) => { await t.notThrowsAsync(async () => { await Worker.runReplayHistory( { - workflowBundle: await createTestWorkflowBundle({ + workflowBundle: await createOtelTestWorkflowBundle({ workflowsPath: require.resolve('./workflows'), workflowInterceptorModules: [require.resolve('./workflows/otel-interceptors')], }), diff --git a/packages/interceptors-opentelemetry/src/__tests__/workflows/definitions.ts b/packages/interceptors-opentelemetry/src/__tests__/workflows/definitions.ts new file mode 100644 index 000000000..eb9d53d1d --- /dev/null +++ b/packages/interceptors-opentelemetry/src/__tests__/workflows/definitions.ts @@ -0,0 +1,8 @@ +import { defineQuery, defineSignal } from '@temporalio/workflow'; + +export const activityStartedSignal = defineSignal('activityStarted'); +export const failSignal = defineSignal('fail'); +export const failWithMessageSignal = defineSignal<[string]>('fail'); +export const argsTestSignal = defineSignal<[number, string]>('argsTest'); +export const unblockSignal = defineSignal('unblock'); +export const versionQuery = defineQuery('version'); diff --git a/packages/interceptors-opentelemetry/src/__tests__/workflows/index.ts b/packages/interceptors-opentelemetry/src/__tests__/workflows/index.ts new file mode 100644 index 000000000..27de6701d --- /dev/null +++ b/packages/interceptors-opentelemetry/src/__tests__/workflows/index.ts @@ -0,0 +1,6 @@ +export * from './definitions'; +export * from './signal-target'; +export * from './smorgasbord'; +export * from './success-string'; +export * from './throw-maybe-benign'; +export * from './update-start-otel'; diff --git a/packages/interceptors-opentelemetry/src/__tests__/workflows/otel-interceptors.ts b/packages/interceptors-opentelemetry/src/__tests__/workflows/otel-interceptors.ts new file mode 100644 index 000000000..b0dae0c90 --- /dev/null +++ b/packages/interceptors-opentelemetry/src/__tests__/workflows/otel-interceptors.ts @@ -0,0 +1,14 @@ +/** Not a workflow, just interceptors */ + +import { WorkflowInterceptors } from '@temporalio/workflow'; +import { + OpenTelemetryInboundInterceptor, + OpenTelemetryOutboundInterceptor, + OpenTelemetryInternalsInterceptor, +} from '../../workflow'; + +export const interceptors = (): WorkflowInterceptors => ({ + inbound: [new OpenTelemetryInboundInterceptor()], + outbound: [new OpenTelemetryOutboundInterceptor()], + internals: [new OpenTelemetryInternalsInterceptor()], +}); diff --git a/packages/test/src/workflows/signal-start-otel.ts b/packages/interceptors-opentelemetry/src/__tests__/workflows/signal-start-otel.ts similarity index 78% rename from packages/test/src/workflows/signal-start-otel.ts rename to packages/interceptors-opentelemetry/src/__tests__/workflows/signal-start-otel.ts index 734e213cf..a33c4ff6a 100644 --- a/packages/test/src/workflows/signal-start-otel.ts +++ b/packages/interceptors-opentelemetry/src/__tests__/workflows/signal-start-otel.ts @@ -3,16 +3,20 @@ import { OpenTelemetryInboundInterceptor, OpenTelemetryOutboundInterceptor, OpenTelemetryInternalsInterceptor, -} from '@temporalio/interceptors-opentelemetry/lib/workflow'; +} from '../../workflow'; export const startSignal = workflow.defineSignal('startSignal'); -const { a, b, c } = workflow.proxyLocalActivities({ +const { a, b, c } = workflow.proxyLocalActivities<{ + a: () => Promise; + b: () => Promise; + c: () => Promise; +}>({ scheduleToCloseTimeout: '1m', }); export async function signalStartOtel(): Promise { - const order = []; + const order: string[] = []; order.push(await a()); workflow.setHandler(startSignal, async () => { order.push(await b()); diff --git a/packages/interceptors-opentelemetry/src/__tests__/workflows/signal-target.ts b/packages/interceptors-opentelemetry/src/__tests__/workflows/signal-target.ts new file mode 100644 index 000000000..80ef8543f --- /dev/null +++ b/packages/interceptors-opentelemetry/src/__tests__/workflows/signal-target.ts @@ -0,0 +1,25 @@ +/** + * Workflow that can be failed and unblocked using signals. + * Useful for testing Workflow interactions. + * + * @module + */ +import { condition, setHandler } from '@temporalio/workflow'; +import { argsTestSignal, failWithMessageSignal, unblockSignal } from './definitions'; + +export async function signalTarget(): Promise { + let unblocked = false; + + // Verify arguments are sent correctly + setHandler(argsTestSignal, (num, str) => { + if (!(num === 123 && str === 'kid')) { + throw new Error('Invalid arguments'); + } + }); + setHandler(failWithMessageSignal, (message) => { + throw new Error(message); + }); + setHandler(unblockSignal, () => void (unblocked = true)); + + await condition(() => unblocked); +} diff --git a/packages/test/src/workflows/signal-workflow.ts b/packages/interceptors-opentelemetry/src/__tests__/workflows/signal-workflow.ts similarity index 100% rename from packages/test/src/workflows/signal-workflow.ts rename to packages/interceptors-opentelemetry/src/__tests__/workflows/signal-workflow.ts diff --git a/packages/interceptors-opentelemetry/src/__tests__/workflows/smorgasbord.ts b/packages/interceptors-opentelemetry/src/__tests__/workflows/smorgasbord.ts new file mode 100644 index 000000000..b71c714dc --- /dev/null +++ b/packages/interceptors-opentelemetry/src/__tests__/workflows/smorgasbord.ts @@ -0,0 +1,74 @@ +/** + * This workflow does a little bit of everything + */ +import { + sleep, + startChild, + proxyActivities, + ActivityCancellationType, + CancellationScope, + isCancellation, + defineQuery, + setHandler, + condition, + continueAsNew, + proxyLocalActivities, +} from '@temporalio/workflow'; +import * as activities from '../activities'; +import { signalTarget } from './signal-target'; +import { activityStartedSignal, unblockSignal } from './definitions'; + +const { fakeProgress, queryOwnWf } = proxyActivities({ + startToCloseTimeout: '1m', + cancellationType: ActivityCancellationType.WAIT_CANCELLATION_COMPLETED, +}); + +const { echo } = proxyLocalActivities({ + startToCloseTimeout: '1m', + cancellationType: ActivityCancellationType.WAIT_CANCELLATION_COMPLETED, +}); + +export const stepQuery = defineQuery('step'); + +export async function smorgasbord(iteration = 0): Promise { + let unblocked = false; + + setHandler(stepQuery, () => iteration); + setHandler(activityStartedSignal, () => void (unblocked = true)); + + try { + await CancellationScope.cancellable(async () => { + const activityPromise = fakeProgress(100, 10); + const queryActPromise = queryOwnWf(stepQuery); + const timerPromise = sleep(1000); + + const childWfPromise = (async () => { + const childWf = await startChild(signalTarget, {}); + await childWf.signal(unblockSignal); + await childWf.result(); + })(); + + const localActivityPromise = echo('local-activity'); + + if (iteration === 0) { + CancellationScope.current().cancel(); + } + + await Promise.all([ + activityPromise, + queryActPromise, + timerPromise, + childWfPromise, + localActivityPromise, + condition(() => unblocked), + ]); + }); + } catch (e) { + if (iteration !== 0 || !isCancellation(e)) { + throw e; + } + } + if (iteration < 2) { + await continueAsNew(iteration + 1); + } +} diff --git a/packages/interceptors-opentelemetry/src/__tests__/workflows/success-string.ts b/packages/interceptors-opentelemetry/src/__tests__/workflows/success-string.ts new file mode 100644 index 000000000..7e872c165 --- /dev/null +++ b/packages/interceptors-opentelemetry/src/__tests__/workflows/success-string.ts @@ -0,0 +1,3 @@ +export async function successString(): Promise { + return 'success'; +} diff --git a/packages/test/src/workflows/throw-maybe-benign.ts b/packages/interceptors-opentelemetry/src/__tests__/workflows/throw-maybe-benign.ts similarity index 100% rename from packages/test/src/workflows/throw-maybe-benign.ts rename to packages/interceptors-opentelemetry/src/__tests__/workflows/throw-maybe-benign.ts diff --git a/packages/test/src/workflows/update-start-otel.ts b/packages/interceptors-opentelemetry/src/__tests__/workflows/update-start-otel.ts similarity index 100% rename from packages/test/src/workflows/update-start-otel.ts rename to packages/interceptors-opentelemetry/src/__tests__/workflows/update-start-otel.ts diff --git a/packages/interceptors-opentelemetry/tsconfig.json b/packages/interceptors-opentelemetry/tsconfig.json index 9cb98e836..61d3ef5c7 100644 --- a/packages/interceptors-opentelemetry/tsconfig.json +++ b/packages/interceptors-opentelemetry/tsconfig.json @@ -5,21 +5,14 @@ "rootDir": "./src" }, "references": [ - { - "path": "../client" - }, - { - "path": "../common" - }, - { - "path": "../worker" - }, - { - "path": "../workflow" - }, - { - "path": "../plugin" - } + { "path": "../activity" }, + { "path": "../client" }, + { "path": "../common" }, + { "path": "../plugin" }, + { "path": "../proto" }, + { "path": "../testing" }, + { "path": "../worker" }, + { "path": "../workflow" } ], "include": ["./src/**/*.ts"] } diff --git a/packages/meta/package.json b/packages/meta/package.json index c84051420..c04ca21dc 100644 --- a/packages/meta/package.json +++ b/packages/meta/package.json @@ -18,6 +18,9 @@ }, "author": "Temporal Technologies Inc. ", "license": "MIT", + "scripts": { + "build": "tsc --build" + }, "repository": { "type": "git", "url": "git+https://github.com/temporalio/sdk-typescript.git" diff --git a/packages/nexus/package.json b/packages/nexus/package.json index 9453f4a83..abb6377ac 100644 --- a/packages/nexus/package.json +++ b/packages/nexus/package.json @@ -12,6 +12,18 @@ ], "author": "Temporal Technologies Inc. ", "license": "MIT", + "scripts": { + "build": "tsc --build", + "test": "ava ./lib/__tests__/test-*.js" + }, + "ava": { + "timeout": "60s", + "concurrency": 1, + "workerThreads": false + }, + "devDependencies": { + "ava": "^5.3.1" + }, "dependencies": { "@temporalio/client": "workspace:*", "@temporalio/common": "workspace:*", @@ -36,6 +48,8 @@ }, "files": [ "src", - "lib" + "lib", + "!src/__tests__", + "!lib/__tests__" ] } diff --git a/packages/test/src/test-nexus-link-converter.ts b/packages/nexus/src/__tests__/test-nexus-link-converter.ts similarity index 97% rename from packages/test/src/test-nexus-link-converter.ts rename to packages/nexus/src/__tests__/test-nexus-link-converter.ts index 6763af1cf..efc452363 100644 --- a/packages/test/src/test-nexus-link-converter.ts +++ b/packages/nexus/src/__tests__/test-nexus-link-converter.ts @@ -1,10 +1,7 @@ import test from 'ava'; import Long from 'long'; import { temporal } from '@temporalio/proto'; -import { - convertWorkflowEventLinkToNexusLink, - convertNexusLinkToWorkflowEventLink, -} from '@temporalio/nexus/lib/link-converter'; +import { convertWorkflowEventLinkToNexusLink, convertNexusLinkToWorkflowEventLink } from '../link-converter'; const { EventType } = temporal.api.enums.v1; const WORKFLOW_EVENT_TYPE = (temporal.api.common.v1.Link.WorkflowEvent as any).fullName.slice(1); diff --git a/packages/test/src/test-nexus-token-helpers.ts b/packages/nexus/src/__tests__/test-nexus-token-helpers.ts similarity index 90% rename from packages/test/src/test-nexus-token-helpers.ts rename to packages/nexus/src/__tests__/test-nexus-token-helpers.ts index 57b9eca35..6d159e3ec 100644 --- a/packages/test/src/test-nexus-token-helpers.ts +++ b/packages/nexus/src/__tests__/test-nexus-token-helpers.ts @@ -1,9 +1,5 @@ import test from 'ava'; -import { - base64URLEncodeNoPadding, - generateWorkflowRunOperationToken, - loadWorkflowRunOperationToken, -} from '@temporalio/nexus/lib/token'; +import { base64URLEncodeNoPadding, generateWorkflowRunOperationToken, loadWorkflowRunOperationToken } from '../token'; test('encode and decode workflow run Operation token', (t) => { const expected = { diff --git a/packages/nyc-test-coverage/package.json b/packages/nyc-test-coverage/package.json index 24fae49d4..7d898ba70 100644 --- a/packages/nyc-test-coverage/package.json +++ b/packages/nyc-test-coverage/package.json @@ -21,6 +21,9 @@ "author": "Temporal Technologies Inc. ", "main": "lib/index.js", "types": "./lib/index.d.ts", + "scripts": { + "build": "tsc --build" + }, "files": [ "lib", "src" @@ -39,6 +42,7 @@ "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-lib-instrument": "^1.7.7", "@types/webpack": "^5.28.5", + "typescript": "^5.6.3", "webpack": "^5.104.1" }, "peerDependencies": { diff --git a/packages/nyc-test-coverage/tsconfig.json b/packages/nyc-test-coverage/tsconfig.json index 55b496be4..675aa8a5d 100644 --- a/packages/nyc-test-coverage/tsconfig.json +++ b/packages/nyc-test-coverage/tsconfig.json @@ -4,6 +4,16 @@ "outDir": "./lib", "rootDir": "./src" }, - "references": [{ "path": "../worker" }, { "path": "../workflow" }], + "references": [ + { + "path": "../worker" + }, + { + "path": "../workflow" + }, + { + "path": "../common" + } + ], "include": ["./src/**/*.ts"] } diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 18145e5d5..02747e5bf 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -12,6 +12,9 @@ ], "author": "Temporal Technologies Inc. ", "license": "MIT", + "scripts": { + "build": "tsc --build" + }, "devDependencies": { "@temporalio/client": "workspace:*", "@temporalio/common": "workspace:*", diff --git a/packages/proto/package.json b/packages/proto/package.json index 032461cf2..6ca597f96 100644 --- a/packages/proto/package.json +++ b/packages/proto/package.json @@ -10,7 +10,9 @@ "lib/" ], "scripts": { - "build": "tsx ./scripts/compile-proto.ts" + "build": "pnpm run \"/^build:.*/\"", + "build:ts": "tsc --build", + "build:protos": "tsx ./scripts/compile-proto.ts" }, "keywords": [ "temporal", diff --git a/packages/test-helpers/package.json b/packages/test-helpers/package.json new file mode 100644 index 000000000..a66d95f0c --- /dev/null +++ b/packages/test-helpers/package.json @@ -0,0 +1,41 @@ +{ + "private": true, + "name": "@temporalio/test-helpers", + "version": "1.14.1", + "description": "Temporal.io SDK Test Helpers - Internal shared test infrastructure", + "main": "lib/index.js", + "types": "./lib/index.d.ts", + "scripts": { + "build": "tsc --build" + }, + "keywords": [ + "temporal", + "workflow", + "testing", + "helpers" + ], + "author": "Temporal Technologies Inc. ", + "license": "MIT", + "dependencies": { + "@temporalio/client": "workspace:*", + "@temporalio/common": "workspace:*", + "@temporalio/proto": "workspace:*", + "@temporalio/testing": "workspace:*", + "@temporalio/worker": "workspace:*", + "@temporalio/workflow": "workspace:*", + "ava": "^5.3.1", + "stack-utils": "^2.0.6" + }, + "devDependencies": { + "@types/stack-utils": "^2.0.3" + }, + "bugs": { + "url": "https://github.com/temporalio/sdk-typescript/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/temporalio/sdk-typescript.git", + "directory": "packages/test-helpers" + }, + "homepage": "https://github.com/temporalio/sdk-typescript#readme" +} diff --git a/packages/test-helpers/src/ava-helpers.ts b/packages/test-helpers/src/ava-helpers.ts new file mode 100644 index 000000000..90a673b66 --- /dev/null +++ b/packages/test-helpers/src/ava-helpers.ts @@ -0,0 +1,22 @@ +import ava, { TestFn } from 'ava'; +import { inWorkflowContext } from '@temporalio/workflow'; + +function noopTest(): void { + // eslint: this function body is empty and it's okay. +} + +noopTest.serial = () => undefined; +noopTest.macro = () => undefined; +noopTest.before = () => undefined; +noopTest.after = () => undefined; +(noopTest.after as any).always = () => undefined; +noopTest.beforeEach = () => undefined; +noopTest.afterEach = () => undefined; +noopTest.skip = () => noopTest; + +/** + * (Mostly complete) helper to allow mixing workflow and non-workflow code in the same test file. + */ +export const test: TestFn = inWorkflowContext() ? (noopTest as any) : ava; + +export { noopTest }; diff --git a/packages/test-helpers/src/bundler.ts b/packages/test-helpers/src/bundler.ts new file mode 100644 index 000000000..928b696d1 --- /dev/null +++ b/packages/test-helpers/src/bundler.ts @@ -0,0 +1,40 @@ +import type { BundleOptions } from '@temporalio/worker'; + +/** + * Base bundler options that can be extended by packages. + * These are modules that should be ignored when bundling workflow code. + */ +export const baseBundlerIgnoreModules = [ + // This is a bit ugly but it does the trick, when a test that includes workflow code tries to import a forbidden + // workflow module, add it to this list: + '@temporalio/common/lib/internal-non-workflow', + '@temporalio/activity', + '@temporalio/client', + '@temporalio/testing', + '@temporalio/nexus', + '@temporalio/worker', + 'ava', + 'crypto', + 'module', + 'path', + 'stack-utils', + '@grpc/grpc-js', + 'async-retry', + 'uuid', + 'net', + 'fs/promises', + 'timers', + 'timers/promises', +]; + +/** + * Create base bundler options that can be extended with package-specific modules. + * + * @param additionalIgnoreModules - Additional modules to ignore when bundling + * @returns BundlerOptions with ignoreModules + */ +export function createBaseBundlerOptions(additionalIgnoreModules: string[] = []): Partial { + return { + ignoreModules: [...baseBundlerIgnoreModules, ...additionalIgnoreModules], + }; +} diff --git a/packages/test-helpers/src/codecs.ts b/packages/test-helpers/src/codecs.ts new file mode 100644 index 000000000..809a89ca5 --- /dev/null +++ b/packages/test-helpers/src/codecs.ts @@ -0,0 +1,20 @@ +import { Payload, PayloadCodec } from '@temporalio/common'; + +/** + * A PayloadCodec used for testing purposes, skews the bytes in the payload data by 1 + */ +export class ByteSkewerPayloadCodec implements PayloadCodec { + async encode(payloads: Payload[]): Promise { + return payloads.map((payload) => ({ + ...payload, + data: payload.data?.map((byte) => byte + 1), + })); + } + + async decode(payloads: Payload[]): Promise { + return payloads.map((payload) => ({ + ...payload, + data: payload.data?.map((byte) => byte - 1), + })); + } +} diff --git a/packages/test-helpers/src/environment.ts b/packages/test-helpers/src/environment.ts new file mode 100644 index 000000000..c04a62232 --- /dev/null +++ b/packages/test-helpers/src/environment.ts @@ -0,0 +1,106 @@ +import { + LocalTestWorkflowEnvironmentOptions, + workflowInterceptorModules as defaultWorkflowInterceptorModules, +} from '@temporalio/testing'; +import { + bundleWorkflowCode, + BundlerPlugin, + DefaultLogger, + WorkflowBundleWithSourceMap, + BundleOptions, +} from '@temporalio/worker'; +import { defineSearchAttributeKey, SearchAttributeType } from '@temporalio/common/lib/search-attributes'; +import { TestWorkflowEnvironment } from './wrappers'; +import { baseBundlerIgnoreModules } from './bundler'; + +export const defaultDynamicConfigOptions = [ + 'frontend.activityAPIsEnabled=true', + 'frontend.enableExecuteMultiOperation=true', + 'frontend.workerVersioningDataAPIs=true', + 'frontend.workerVersioningWorkflowAPIs=true', + 'system.enableActivityEagerExecution=true', + 'system.enableDeploymentVersions=true', + 'system.enableEagerWorkflowStart=true', + 'system.forceSearchAttributesCacheRefreshOnRead=true', + 'worker.buildIdScavengerEnabled=true', + 'worker.removableBuildIdDurationSinceDefault=1', + 'component.nexusoperations.recordCancelRequestCompletionEvents=true', +]; + +export const defaultSAKeys = { + CustomIntField: defineSearchAttributeKey('CustomIntField', SearchAttributeType.INT), + CustomBoolField: defineSearchAttributeKey('CustomBoolField', SearchAttributeType.BOOL), + CustomKeywordField: defineSearchAttributeKey('CustomKeywordField', SearchAttributeType.KEYWORD), + CustomTextField: defineSearchAttributeKey('CustomTextField', SearchAttributeType.TEXT), + CustomDatetimeField: defineSearchAttributeKey('CustomDatetimeField', SearchAttributeType.DATETIME), + CustomDoubleField: defineSearchAttributeKey('CustomDoubleField', SearchAttributeType.DOUBLE), +}; + +/** + * Options for creating test workflow bundles. + */ +export interface TestWorkflowBundleOptions { + workflowsPath: string; + workflowInterceptorModules?: string[]; + additionalIgnoreModules?: string[]; + plugins?: BundlerPlugin[]; +} + +/** + * Create a test workflow bundle with standard configuration. + */ +export async function createTestWorkflowBundle({ + workflowsPath, + workflowInterceptorModules, + additionalIgnoreModules = [], + plugins, +}: TestWorkflowBundleOptions): Promise { + const bundlerOptions: Partial = { + ignoreModules: [...baseBundlerIgnoreModules, ...additionalIgnoreModules], + }; + + return await bundleWorkflowCode({ + ...bundlerOptions, + workflowInterceptorModules: [...defaultWorkflowInterceptorModules, ...(workflowInterceptorModules ?? [])], + workflowsPath, + logger: new DefaultLogger('WARN'), + plugins: plugins ?? [], + }); +} + +/** + * Create a local test environment with default search attributes and dynamic config. + */ +export async function createLocalTestEnvironment( + opts?: LocalTestWorkflowEnvironmentOptions +): Promise { + return await TestWorkflowEnvironment.createLocal({ + ...(opts || {}), + server: { + searchAttributes: Object.values(defaultSAKeys), + ...(opts?.server || {}), + extraArgs: [ + ...defaultDynamicConfigOptions.flatMap((opt) => ['--dynamic-config-value', opt]), + ...(opts?.server?.extraArgs ?? []), + ], + }, + }); +} + +/** + * Create a test workflow environment, using an existing server if TEMPORAL_SERVICE_ADDRESS is set, + * otherwise creating a local one. + */ +export async function createTestWorkflowEnvironment( + opts?: LocalTestWorkflowEnvironmentOptions +): Promise { + let env: TestWorkflowEnvironment; + if (process.env.TEMPORAL_SERVICE_ADDRESS) { + env = await TestWorkflowEnvironment.createFromExistingServer({ + address: process.env.TEMPORAL_SERVICE_ADDRESS, + }); + } else { + env = await createLocalTestEnvironment(opts); + } + return env; +} diff --git a/packages/test-helpers/src/flags.ts b/packages/test-helpers/src/flags.ts new file mode 100644 index 000000000..030641147 --- /dev/null +++ b/packages/test-helpers/src/flags.ts @@ -0,0 +1,18 @@ +import { inWorkflowContext } from '@temporalio/workflow'; + +export function isSet(env: string | undefined, def: boolean): boolean { + if (env === undefined) return def; + env = env.toLocaleLowerCase(); + return env === '1' || env === 't' || env === 'true'; +} + +export const RUN_INTEGRATION_TESTS = inWorkflowContext() || isSet(process.env.RUN_INTEGRATION_TESTS, true); +export const REUSE_V8_CONTEXT = inWorkflowContext() || isSet(process.env.REUSE_V8_CONTEXT, true); +export const RUN_TIME_SKIPPING_TESTS = + inWorkflowContext() || !(process.platform === 'linux' && process.arch === 'arm64'); + +export const TESTS_CLI_VERSION = inWorkflowContext() ? '' : process.env.TESTS_CLI_VERSION; + +export const TESTS_TIME_SKIPPING_SERVER_VERSION = inWorkflowContext() + ? '' + : process.env.TESTS_TIME_SKIPPING_SERVER_VERSION; diff --git a/packages/test-helpers/src/helpers.ts b/packages/test-helpers/src/helpers.ts new file mode 100644 index 000000000..fb957081d --- /dev/null +++ b/packages/test-helpers/src/helpers.ts @@ -0,0 +1,104 @@ +import { randomUUID } from 'crypto'; +import type { ExecutionContext } from 'ava'; +import { WorkflowHandleWithFirstExecutionRunId, WorkflowStartOptions } from '@temporalio/client'; +import type { TestWorkflowEnvironment as RealTestWorkflowEnvironment } from '@temporalio/testing'; +import { WorkerOptions, WorkflowBundle } from '@temporalio/worker'; +import * as workflow from '@temporalio/workflow'; +import { Worker, TestWorkflowEnvironment } from './wrappers'; + +export const isBun = typeof (globalThis as any).Bun !== 'undefined'; +/** Union type for all supported test environment types */ +export type AnyTestWorkflowEnvironment = TestWorkflowEnvironment | RealTestWorkflowEnvironment; + +/** + * Base context interface for test environments. + * Generic parameter allows specifying a more specific environment type. + * Defaults to TestWorkflowEnvironment since that's the most common case. + */ +export interface BaseContext { + env: TEnv; + workflowBundle: WorkflowBundle; +} + +/** + * Base helpers interface providing common test utilities. + * Packages can extend this interface with additional methods. + */ +export interface BaseHelpers { + readonly taskQueue: string; + createWorker(opts?: Partial): Promise; + executeWorkflow Promise>(workflowType: T): Promise>; + executeWorkflow( + fn: T, + opts: Omit & Partial> + ): Promise>; + startWorkflow Promise>(workflowType: T): Promise>; + startWorkflow( + fn: T, + opts: Omit & Partial> + ): Promise>; +} + +/** + * Default task queue transform function that converts test title to a valid task queue name. + */ +export function defaultTaskQueueTransform(title: string): string { + return title + .toLowerCase() + .replaceAll(/[ _()'-]+/g, '-') + .replace(/^[-]?(.+?)[-]?$/, '$1'); +} + +/** + * Create helpers for a test. + * + * When called with just `t`, extracts env and workflowBundle from `t.context`. + * When called with `t` and `env`, uses the provided env with workflowBundle from context. + * + * @param t - The test execution context + * @param env - Optional environment override (defaults to t.context.env) + * @returns BaseHelpers instance + */ +export function helpers( + t: ExecutionContext>, + env: AnyTestWorkflowEnvironment = t.context.env +): BaseHelpers { + // createBaseHelpers(t.title, env, t.context.workflowBundle); + const taskQueue = defaultTaskQueueTransform(t.title); + const workflowBundle = t.context.workflowBundle; + + return { + taskQueue, + async createWorker(workerOpts?: Partial): Promise { + return await Worker.create({ + connection: env.nativeConnection, + workflowBundle, + taskQueue, + showStackTraceSources: true, + ...workerOpts, + }); + }, + async executeWorkflow( + fn: workflow.Workflow, + workflowOpts?: Omit & + Partial> + ): Promise { + return await env.client.workflow.execute(fn, { + taskQueue, + workflowId: randomUUID(), + ...workflowOpts, + }); + }, + async startWorkflow( + fn: workflow.Workflow, + workflowOpts?: Omit & + Partial> + ): Promise> { + return await env.client.workflow.start(fn, { + taskQueue, + workflowId: randomUUID(), + ...workflowOpts, + }); + }, + }; +} diff --git a/packages/test-helpers/src/history.ts b/packages/test-helpers/src/history.ts new file mode 100644 index 000000000..0e53fe02b --- /dev/null +++ b/packages/test-helpers/src/history.ts @@ -0,0 +1,42 @@ +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as iface from '@temporalio/proto'; +import { historyToJSON } from '@temporalio/common/lib/proto-utils'; + +/** + * Load a history file from a given path. Supports both JSON and binary formats. + */ +export async function loadHistory(fpath: string): Promise { + const isJson = fpath.endsWith('json'); + if (isJson) { + const hist = await fs.readFile(fpath, 'utf8'); + return JSON.parse(hist); + } else { + const hist = await fs.readFile(fpath); + return iface.temporal.api.history.v1.History.decode(hist); + } +} + +/** + * Save a history to a file in JSON format. + */ +export async function saveHistory(fpath: string, history: iface.temporal.api.history.v1.IHistory): Promise { + await fs.writeFile(fpath, historyToJSON(history)); +} + +/** + * Load a history file from a directory relative to the given dirname. + * This is a convenience function for loading history files from a package's history_files directory. + * + * @param dirname - The __dirname of the calling module + * @param historyDir - The relative path to the history files directory (defaults to '../history_files') + * @param fname - The filename of the history file to load + */ +export async function loadHistoryFromDir( + dirname: string, + fname: string, + historyDir = '../history_files' +): Promise { + const fpath = path.resolve(dirname, historyDir, fname); + return loadHistory(fpath); +} diff --git a/packages/test-helpers/src/index.ts b/packages/test-helpers/src/index.ts new file mode 100644 index 000000000..91ab5f5b6 --- /dev/null +++ b/packages/test-helpers/src/index.ts @@ -0,0 +1,53 @@ +// Utilities +export { sleep, waitUntil, u8, approximatelyEqual } from './utilities'; + +// Flags +export { + isSet, + RUN_INTEGRATION_TESTS, + REUSE_V8_CONTEXT, + RUN_TIME_SKIPPING_TESTS, + TESTS_CLI_VERSION, + TESTS_TIME_SKIPPING_SERVER_VERSION, +} from './flags'; + +// Stack trace utilities +export { cleanStackTrace, cleanOptionalStackTrace, compareStackTrace } from './stack-trace'; + +// Port utilities +export { getRandomPort } from './port'; + +// History utilities +export { loadHistory, saveHistory, loadHistoryFromDir } from './history'; + +// Bundler utilities +export { baseBundlerIgnoreModules, createBaseBundlerOptions } from './bundler'; + +// Codec utilities +export { ByteSkewerPayloadCodec } from './codecs'; + +// AVA helpers +export { test, noopTest } from './ava-helpers'; + +// Wrappers +export { Worker, TestWorkflowEnvironment } from './wrappers'; + +// Helpers +export { + AnyTestWorkflowEnvironment, + BaseContext, + BaseHelpers, + helpers, + defaultTaskQueueTransform, + isBun, +} from './helpers'; + +// Environment utilities +export { + defaultDynamicConfigOptions, + defaultSAKeys, + TestWorkflowBundleOptions, + createTestWorkflowBundle, + createLocalTestEnvironment, + createTestWorkflowEnvironment, +} from './environment'; diff --git a/packages/test-helpers/src/port.ts b/packages/test-helpers/src/port.ts new file mode 100644 index 000000000..d622b88f3 --- /dev/null +++ b/packages/test-helpers/src/port.ts @@ -0,0 +1,42 @@ +import * as net from 'net'; + +/** + * Return a random TCP port number, that is guaranteed to be either available, or to be in use. + * + * To get a port that is guaranteed to be available, simply call the function directly. + * + * ```ts + * const port = await getRandomPort(); + * ``` + * + * To get a port that is guaranteed to be in use, pass a function that will be called with the port + * number; the port is guaranteed to be in use until the function returns. This may be useful for + * example to test for proper error handling when a port is already in use. + * + * ```ts + * const port = await getRandomPort(async (port) => { + * t.throws( + * () => startMyService({ bindAddress: `127.0.0.1:${port}` }), + * { + * instanceOf: Error, + * message: /(Address already in use|socket address)/, + * } + * ); + * }); + * }); + * ``` + */ +export async function getRandomPort(fn = (_port: number) => Promise.resolve()): Promise { + return new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.listen({ port: 0, host: '127.0.0.1' }, () => { + const addr = srv.address(); + if (typeof addr === 'string' || addr === null) { + throw new Error('Unexpected server address type'); + } + fn(addr.port) + .catch((e) => reject(e)) + .finally(() => srv.close((_) => resolve(addr.port))); + }); + }); +} diff --git a/packages/test-helpers/src/stack-trace.ts b/packages/test-helpers/src/stack-trace.ts new file mode 100644 index 000000000..27acd1f11 --- /dev/null +++ b/packages/test-helpers/src/stack-trace.ts @@ -0,0 +1,61 @@ +import path from 'path'; +import StackUtils from 'stack-utils'; +import type { ExecutionContext } from 'ava'; + +export function cleanOptionalStackTrace(stackTrace: string | undefined | null): string | undefined { + return stackTrace ? cleanStackTrace(stackTrace) : undefined; +} + +/** + * Relativize paths and remove line and column numbers from stack trace. + * + * Note: The cwd parameter should be the path to the directory containing the test package + * (e.g., packages/test, packages/ai-sdk, etc.) + */ +export function cleanStackTrace(ostack: string): string { + // For some reason, a code snippet with carret on error location is sometime prepended before the actual stacktrace. + // If there is such a snippet, get rid of it. + const stack = ostack.replace(/^.*\n[ ]*\^[ ]*\n+/gms, ''); + + const su = new StackUtils({ cwd: path.join(__dirname, '../..') }); + const firstLine = stack.split('\n')[0]!; + const cleanedStack = su.clean(stack).trimEnd(); + let normalizedStack = + cleanedStack && + cleanedStack + .replace(/:\d+:\d+/g, '') + .replace(/^\s*/gms, ' at ') + .replace(/\[as fn\] /, '') + // Avoid https://github.com/nodejs/node/issues/42417 + .replace(/at null\./g, 'at ') + .replace(/\\/g, '/'); + + // FIXME: Find a better way to handle package vendoring; this will come back again. + normalizedStack = normalizedStack + .replaceAll(/\([^() ]*\/node_modules\//g, '(') + .replaceAll(/\([^() ]*\/nexus-sdk-typescript\/src/g, '(nexus-rpc/src'); + + return normalizedStack ? `${firstLine}\n${normalizedStack}` : firstLine; +} +/** + * Compare stack traces using keywords to match any inconsistent parts of the stack trace + * + * As of Node 24.6.0 type names are now present on source mapped stack traces which leads + * to different stack traces depending on Node version. + * See [f33e0fcc83954f728fcfd2ef6ae59435bc4af059](https://github.com/nodejs/node/commit/f33e0fcc83954f728fcfd2ef6ae59435bc4af059) + * + * Bun does not support promise hooks meaning we are currently unable to apply the source map on stack traces and workflow bundles + * end up in the stack trace. + * + * Special: + * - $CLASS: used to match class names that might be inconsistent + * - $HASH: used to match bundle hash suffixes in workflow paths + */ +export function compareStackTrace(t: ExecutionContext, actual: string, expected: string): void { + const escapedTrace = expected + .replace(/[|\\{}()[\]^$+*?.]/g, '\\$&') + .replace(/-/g, '\\x2d') + .replaceAll('\\$CLASS', '(?:[A-Za-z]+)') + .replaceAll('\\$HASH', '(?:[A-Za-z0-9]+)'); + t.regex(actual, RegExp(`^${escapedTrace}$`)); +} diff --git a/packages/test-helpers/src/utilities.ts b/packages/test-helpers/src/utilities.ts new file mode 100644 index 000000000..09a396194 --- /dev/null +++ b/packages/test-helpers/src/utilities.ts @@ -0,0 +1,50 @@ +/** + * Sleep for a given number of milliseconds + */ +export async function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Wait until a condition is met or timeout + */ +export async function waitUntil( + condition: () => Promise, + timeoutMs: number, + intervalMs: number = 100 +): Promise { + const endTime = Date.now() + timeoutMs; + for (;;) { + if (await condition()) { + return; + } else if (Date.now() >= endTime) { + throw new Error('timed out waiting for condition'); + } else { + await sleep(intervalMs); + } + } +} + +/** + * Convert a string to Uint8Array + */ +export function u8(s: string): Uint8Array { + // TextEncoder requires lib "dom" + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + return new TextEncoder().encode(s); +} + +/** + * Check if two numbers are approximately equal within a tolerance + */ +export function approximatelyEqual( + a: number | null | undefined, + b: number | null | undefined, + tolerance = 0.000001 +): boolean { + if (a === null || a === undefined || b === null || b === undefined) { + return false; + } + return Math.abs(a - b) < tolerance; +} diff --git a/packages/test-helpers/src/wrappers.ts b/packages/test-helpers/src/wrappers.ts new file mode 100644 index 000000000..2626b7273 --- /dev/null +++ b/packages/test-helpers/src/wrappers.ts @@ -0,0 +1,79 @@ +import { + ExistingServerTestWorkflowEnvironmentOptions, + LocalTestWorkflowEnvironmentOptions, + TestWorkflowEnvironment as RealTestWorkflowEnvironment, + TimeSkippingTestWorkflowEnvironmentOptions, +} from '@temporalio/testing'; +import * as worker from '@temporalio/worker'; +import { Worker as RealWorker, WorkerOptions } from '@temporalio/worker'; +import { inWorkflowContext } from '@temporalio/workflow'; +import { REUSE_V8_CONTEXT, TESTS_CLI_VERSION, TESTS_TIME_SKIPPING_SERVER_VERSION } from './flags'; + +// Hack around Worker and TestWorkflowEnvironment not being available in workflow context +if (inWorkflowContext()) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + worker.Worker = class {}; // eslint-disable-line import/namespace + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + RealTestWorkflowEnvironment = class {}; +} + +/** + * Worker wrapper that defaults to REUSE_V8_CONTEXT from environment. + */ +export class Worker extends worker.Worker { + static async create(options: WorkerOptions): Promise { + return RealWorker.create({ ...options, reuseV8Context: options.reuseV8Context ?? REUSE_V8_CONTEXT }); + } +} + +/** + * A custom version of TestWorkflowEnvironment for our own testing use, that + * allows specifying the version of the CLI and Time Skipping Server binaries + * through environment variables. + */ +export class TestWorkflowEnvironment extends RealTestWorkflowEnvironment { + static async createLocal(opts?: LocalTestWorkflowEnvironmentOptions): Promise { + return RealTestWorkflowEnvironment.createLocal({ + ...opts, + ...(TESTS_CLI_VERSION + ? { + server: { + ...opts?.server, + executable: { + ...opts?.server?.executable, + type: 'cached-download', + version: TESTS_CLI_VERSION, + }, + }, + } + : undefined), + }); + } + + static async createTimeSkipping(opts?: TimeSkippingTestWorkflowEnvironmentOptions): Promise { + return RealTestWorkflowEnvironment.createTimeSkipping({ + ...opts, + ...(TESTS_TIME_SKIPPING_SERVER_VERSION + ? { + server: { + ...opts?.server, + executable: { + ...opts?.server?.executable, + type: 'cached-download', + version: TESTS_TIME_SKIPPING_SERVER_VERSION, + }, + }, + } + : undefined), + }); + } + + static async createFromExistingServer( + opts?: ExistingServerTestWorkflowEnvironmentOptions + ): Promise { + return RealTestWorkflowEnvironment.createFromExistingServer(opts); + } +} diff --git a/packages/test-helpers/tsconfig.json b/packages/test-helpers/tsconfig.json new file mode 100644 index 000000000..9cb59c3d5 --- /dev/null +++ b/packages/test-helpers/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./lib", + "rootDir": "./src" + }, + "references": [ + { "path": "../client" }, + { "path": "../common" }, + { "path": "../proto" }, + { "path": "../testing" }, + { "path": "../worker" }, + { "path": "../workflow" } + ], + "include": ["./src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/test/package.json b/packages/test/package.json index 3b66a4623..2b4741497 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -49,6 +49,7 @@ "@temporalio/nyc-test-coverage": "workspace:*", "@temporalio/plugin": "workspace:*", "@temporalio/proto": "workspace:*", + "@temporalio/test-helpers": "workspace:*", "@temporalio/testing": "workspace:*", "@temporalio/worker": "workspace:*", "@temporalio/workflow": "workspace:*", diff --git a/packages/test/src/helpers-integration.ts b/packages/test/src/helpers-integration.ts index 85338727f..7c8ed0976 100644 --- a/packages/test/src/helpers-integration.ts +++ b/packages/test/src/helpers-integration.ts @@ -1,19 +1,7 @@ -import { randomUUID } from 'crypto'; import { status as grpcStatus } from '@grpc/grpc-js'; import { ErrorConstructor, ExecutionContext, TestFn } from 'ava'; -import { - isGrpcServiceError, - WorkflowFailedError, - WorkflowHandle, - WorkflowHandleWithFirstExecutionRunId, - WorkflowStartOptions, - WorkflowUpdateFailedError, -} from '@temporalio/client'; -import { - LocalTestWorkflowEnvironmentOptions, - NexusEndpointIdentifier, - workflowInterceptorModules as defaultWorkflowInterceptorModules, -} from '@temporalio/testing'; +import { isGrpcServiceError, WorkflowFailedError, WorkflowHandle, WorkflowUpdateFailedError } from '@temporalio/client'; +import { LocalTestWorkflowEnvironmentOptions, NexusEndpointIdentifier } from '@temporalio/testing'; import { BundlerPlugin, DefaultLogger, @@ -24,26 +12,36 @@ import { ReplayWorkerOptions, Runtime, RuntimeOptions, - WorkerOptions, WorkflowBundle, - WorkflowBundleWithSourceMap, - bundleWorkflowCode, makeTelemetryFilterString, } from '@temporalio/worker'; import * as workflow from '@temporalio/workflow'; import { temporal } from '@temporalio/proto'; -import { defineSearchAttributeKey, SearchAttributeType } from '@temporalio/common/lib/search-attributes'; -import { Worker, TestWorkflowEnvironment, test as anyTest, bundlerOptions } from './helpers'; -export interface Context { - env: TestWorkflowEnvironment; - workflowBundle: WorkflowBundle; -} +// Import from test-helpers +import { + BaseContext, + BaseHelpers, + helpers as baseHelpers, + defaultTaskQueueTransform, + createTestWorkflowBundle as createTestWorkflowBundleBase, + createTestWorkflowEnvironment as createTestWorkflowEnvironmentBase, + createLocalTestEnvironment, + defaultSAKeys, + TestWorkflowBundleOptions as BaseTestWorkflowBundleOptions, + test as anyTest, + Worker, + TestWorkflowEnvironment, +} from '@temporalio/test-helpers'; + +export { defaultSAKeys, createLocalTestEnvironment }; -const defaultDynamicConfigOptions = [ - 'system.enableActivityEagerExecution=true', - 'history.enableRequestIdRefLinks=true', -]; +/** + * Context interface for integration tests. + * Extends BaseContext with TestWorkflowEnvironment. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface Context extends BaseContext {} function setupRuntime(recordedLogs?: { [workflowId: string]: LogEntry[] }, runtimeOpts?: Partial) { const logger = recordedLogs @@ -68,48 +66,21 @@ function setupRuntime(recordedLogs?: { [workflowId: string]: LogEntry[] }, runti }); } -export interface HelperTestBundleOptions { +export interface HelperTestBundleOptions extends BaseTestWorkflowBundleOptions { workflowsPath: string; workflowInterceptorModules?: string[]; plugins?: BundlerPlugin[]; } -export async function createTestWorkflowBundle({ - workflowsPath, - workflowInterceptorModules, - plugins, -}: HelperTestBundleOptions): Promise { - return await bundleWorkflowCode({ - ...bundlerOptions, - workflowInterceptorModules: [...defaultWorkflowInterceptorModules, ...(workflowInterceptorModules ?? [])], - workflowsPath, - logger: new DefaultLogger('WARN'), - plugins: plugins ?? [], - }); -} - -export const defaultSAKeys = { - CustomIntField: defineSearchAttributeKey('CustomIntField', SearchAttributeType.INT), - CustomBoolField: defineSearchAttributeKey('CustomBoolField', SearchAttributeType.BOOL), - CustomKeywordField: defineSearchAttributeKey('CustomKeywordField', SearchAttributeType.KEYWORD), - CustomTextField: defineSearchAttributeKey('CustomTextField', SearchAttributeType.TEXT), - CustomDatetimeField: defineSearchAttributeKey('CustomDatetimeField', SearchAttributeType.DATETIME), - CustomDoubleField: defineSearchAttributeKey('CustomDoubleField', SearchAttributeType.DOUBLE), -}; - -export async function createLocalTestEnvironment( - opts?: LocalTestWorkflowEnvironmentOptions -): Promise { - return await TestWorkflowEnvironment.createLocal({ - ...(opts || {}), // Use provided options or default to an empty object - server: { - searchAttributes: Object.values(defaultSAKeys), - ...(opts?.server || {}), // Use provided server options or default to an empty object - extraArgs: [ - ...defaultDynamicConfigOptions.flatMap((opt) => ['--dynamic-config-value', opt]), - ...(opts?.server?.extraArgs ?? []), - ], - }, +/** + * Create a test workflow bundle with the package-specific bundler options. + */ +export async function createTestWorkflowBundle( + opts: HelperTestBundleOptions +): ReturnType { + return createTestWorkflowBundleBase({ + ...opts, + additionalIgnoreModules: [require.resolve('./activities'), require.resolve('./mock-native-worker')], }); } @@ -132,7 +103,7 @@ export function makeConfigurableEnvironmentTestFn(opts: { return test; } -export interface TestFunctionOptions { +export interface TestFunctionOptions { workflowsPath: string; workflowEnvironmentOpts?: LocalTestWorkflowEnvironmentOptions; workflowInterceptorModules?: string[]; @@ -144,8 +115,8 @@ export function makeTestFunction(opts: TestFunction return makeConfigurableEnvironmentTestFn({ recordedLogs: opts.recordedLogs, runtimeOpts: opts.runtimeOpts, - createTestContext: makeDefaultTestContextFunction(opts), - teardown: async (c: C) => { + createTestContext: makeDefaultTestContextFunction(opts) as (t: ExecutionContext) => Promise, + teardown: async (c: Context) => { if (c.env) { await c.env.teardown(); } @@ -153,8 +124,8 @@ export function makeTestFunction(opts: TestFunction }); } -export function makeDefaultTestContextFunction(opts: TestFunctionOptions) { - return async (_t: ExecutionContext): Promise => { +export function makeDefaultTestContextFunction(opts: TestFunctionOptions): (t: ExecutionContext) => Promise { + return async (_t: ExecutionContext): Promise => { const env = await createTestWorkflowEnvironment(opts.workflowEnvironmentOpts); return { workflowBundle: await createTestWorkflowBundle({ @@ -162,39 +133,22 @@ export function makeDefaultTestContextFunction(opts workflowInterceptorModules: opts.workflowInterceptorModules, }), env, - } as unknown as C; + }; }; } export async function createTestWorkflowEnvironment( opts?: LocalTestWorkflowEnvironmentOptions ): Promise { - let env: TestWorkflowEnvironment; - if (process.env.TEMPORAL_SERVICE_ADDRESS) { - env = await TestWorkflowEnvironment.createFromExistingServer({ - address: process.env.TEMPORAL_SERVICE_ADDRESS, - }); - } else { - env = await createLocalTestEnvironment(opts); - } - return env; + return createTestWorkflowEnvironmentBase(opts); } -export interface Helpers { - taskQueue: string; - createWorker(opts?: Partial): Promise; +/** + * Extended helpers interface with additional test utilities specific to the test package. + */ +export interface Helpers extends BaseHelpers { createNativeConnection(opts?: Partial): Promise; runReplayHistory(opts: Partial, history: temporal.api.history.v1.IHistory): Promise; - executeWorkflow Promise>(workflowType: T): Promise>; - executeWorkflow( - fn: T, - opts: Omit & Partial> - ): Promise>; - startWorkflow Promise>(workflowType: T): Promise>; - startWorkflow( - fn: T, - opts: Omit & Partial> - ): Promise>; assertWorkflowUpdateFailed(p: Promise, causeConstructor: ErrorConstructor, message?: string): Promise; assertWorkflowFailedError(p: Promise, causeConstructor: ErrorConstructor, message?: string): Promise; updateHasBeenAdmitted(handle: WorkflowHandle, updateId: string): Promise; @@ -203,27 +157,19 @@ export interface Helpers { ): Promise<{ endpointName: string; endpointIdentifier: NexusEndpointIdentifier }>; } -export function configurableHelpers( - t: ExecutionContext, - workflowBundle: WorkflowBundle, - testEnv: TestWorkflowEnvironment -): Helpers { - const taskQueue = t.title - .toLowerCase() - .replaceAll(/[ _()'-]+/g, '-') - .replace(/^[-]?(.+?)[-]?$/, '$1'); +/** + * Create extended helpers with package-specific functionality. + * @param t - The test execution context + * @param env - Optional environment override (defaults to t.context.env) + */ +export function helpers(t: ExecutionContext, env?: TestWorkflowEnvironment): Helpers { + const testEnv = env ?? t.context.env; + const { workflowBundle } = t.context; + const base = baseHelpers(t, testEnv); + const taskQueue = defaultTaskQueueTransform(t.title); return { - taskQueue, - async createWorker(opts?: Partial): Promise { - return await Worker.create({ - connection: testEnv.nativeConnection, - workflowBundle, - taskQueue, - showStackTraceSources: true, - ...opts, - }); - }, + ...base, async createNativeConnection(opts?: Partial): Promise { return await NativeConnection.connect({ address: testEnv.address, ...opts }); }, @@ -239,26 +185,6 @@ export function configurableHelpers( history ); }, - async executeWorkflow( - fn: workflow.Workflow, - opts?: Omit & Partial> - ): Promise { - return await testEnv.client.workflow.execute(fn, { - taskQueue, - workflowId: randomUUID(), - ...opts, - }); - }, - async startWorkflow( - fn: workflow.Workflow, - opts?: Omit & Partial> - ): Promise> { - return await testEnv.client.workflow.start(fn, { - taskQueue, - workflowId: randomUUID(), - ...opts, - }); - }, async assertWorkflowUpdateFailed( p: Promise, causeConstructor: ErrorConstructor, @@ -324,6 +250,14 @@ export function configurableHelpers( }; } -export function helpers(t: ExecutionContext, testEnv: TestWorkflowEnvironment = t.context.env): Helpers { - return configurableHelpers(t, t.context.workflowBundle, testEnv); +/** + * Create helpers with explicit workflowBundle and env parameters. + * Use this for tests with non-standard context structures (e.g., multiple environments). + */ +export function configurableHelpers( + t: ExecutionContext, + workflowBundle: WorkflowBundle, + testEnv: TestWorkflowEnvironment +): BaseHelpers { + return baseHelpers({ title: t.title, context: { env: testEnv, workflowBundle } } as ExecutionContext); } diff --git a/packages/test/src/helpers.ts b/packages/test/src/helpers.ts index 0c64f9268..efe21068b 100644 --- a/packages/test/src/helpers.ts +++ b/packages/test/src/helpers.ts @@ -1,256 +1,49 @@ -import * as fs from 'fs/promises'; -import * as net from 'net'; import path from 'path'; import * as grpc from '@grpc/grpc-js'; import asyncRetry from 'async-retry'; -import ava, { TestFn, ExecutionContext } from 'ava'; -import StackUtils from 'stack-utils'; import { v4 as uuid4 } from 'uuid'; import { Client, Connection } from '@temporalio/client'; -import { Payload, PayloadCodec } from '@temporalio/common'; -import { historyToJSON } from '@temporalio/common/lib/proto-utils'; import * as iface from '@temporalio/proto'; import { - ExistingServerTestWorkflowEnvironmentOptions, - LocalTestWorkflowEnvironmentOptions, - TestWorkflowEnvironment as RealTestWorkflowEnvironment, - TimeSkippingTestWorkflowEnvironmentOptions, -} from '@temporalio/testing'; -import * as worker from '@temporalio/worker'; -import { Worker as RealWorker, WorkerOptions } from '@temporalio/worker'; -import { inWorkflowContext } from '@temporalio/workflow'; - -export function u8(s: string): Uint8Array { - // TextEncoder requires lib "dom" - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - return new TextEncoder().encode(s); -} - -function isSet(env: string | undefined, def: boolean): boolean { - if (env === undefined) return def; - env = env.toLocaleLowerCase(); - return env === '1' || env === 't' || env === 'true'; -} - -export const RUN_INTEGRATION_TESTS = inWorkflowContext() || isSet(process.env.RUN_INTEGRATION_TESTS, true); -export const isBun = typeof (globalThis as any).Bun !== 'undefined'; -export const REUSE_V8_CONTEXT = inWorkflowContext() || isSet(process.env.REUSE_V8_CONTEXT, true); -export const RUN_TIME_SKIPPING_TESTS = - inWorkflowContext() || !(process.platform === 'linux' && process.arch === 'arm64'); - -export const TESTS_CLI_VERSION = inWorkflowContext() ? '' : process.env.TESTS_CLI_VERSION; - -export const TESTS_TIME_SKIPPING_SERVER_VERSION = inWorkflowContext() - ? '' - : process.env.TESTS_TIME_SKIPPING_SERVER_VERSION; - -export async function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -export async function waitUntil( - condition: () => Promise, - timeoutMs: number, - intervalMs: number = 100 -): Promise { - const endTime = Date.now() + timeoutMs; - for (;;) { - if (await condition()) { - return; - } else if (Date.now() >= endTime) { - throw new Error('timed out waiting for condition'); - } else { - await sleep(intervalMs); - } - } -} - -export function cleanOptionalStackTrace(stackTrace: string | undefined | null): string | undefined { - return stackTrace ? cleanStackTrace(stackTrace) : undefined; -} - -/** - * Relativize paths and remove line and column numbers from stack trace - */ -export function cleanStackTrace(ostack: string): string { - // For some reason, a code snippet with carret on error location is sometime prepended before the actual stacktrace. - // If there is such a snippet, get rid of it. - const stack = ostack.replace(/^.*\n[ ]*\^[ ]*\n+/gms, ''); - - const su = new StackUtils({ cwd: path.join(__dirname, '../..') }); - const firstLine = stack.split('\n')[0]; - const cleanedStack = su.clean(stack).trimEnd(); - let normalizedStack = - cleanedStack && - cleanedStack - .replace(/:\d+:\d+/g, '') - .replace(/^\s*/gms, ' at ') - .replace(/\[as fn\] /, '') - // Avoid https://github.com/nodejs/node/issues/42417 - .replace(/at null\./g, 'at ') - .replace(/\\/g, '/'); - - // FIXME: Find a better way to handle package vendoring; this will come back again. - normalizedStack = normalizedStack - .replaceAll(/\([^() ]*\/node_modules\//g, '(') - .replaceAll(/\([^() ]*\/nexus-sdk-typescript\/src/g, '(nexus-rpc/src'); - - return normalizedStack ? `${firstLine}\n${normalizedStack}` : firstLine; -} - -/** - * Compare stack traces using keywords to match any inconsistent parts of the stack trace - * - * As of Node 24.6.0 type names are now present on source mapped stack traces which leads - * to different stack traces depending on Node version. - * See [f33e0fcc83954f728fcfd2ef6ae59435bc4af059](https://github.com/nodejs/node/commit/f33e0fcc83954f728fcfd2ef6ae59435bc4af059) - * - * Bun does not support promise hooks meaning we are currently unable to apply the source map on stack traces and workflow bundles - * end up in the stack trace. - * - * Special: - * - $CLASS: used to match class names that might be inconsistent - * - $HASH: used to match bundle hash suffixes in workflow paths - */ -export function compareStackTrace(t: ExecutionContext, actual: string, expected: string): void { - const escapedTrace = expected - .replace(/[|\\{}()[\]^$+*?.]/g, '\\$&') - .replace(/-/g, '\\x2d') - .replaceAll('\\$CLASS', '(?:[A-Za-z]+)') - .replaceAll('\\$HASH', '(?:[A-Za-z0-9]+)'); - t.regex(actual, RegExp(`^${escapedTrace}$`)); -} - -function noopTest(): void { - // eslint: this function body is empty and it's okay. -} - -noopTest.serial = () => undefined; -noopTest.macro = () => undefined; -noopTest.before = () => undefined; -noopTest.after = () => undefined; -(noopTest.after as any).always = () => undefined; -noopTest.beforeEach = () => undefined; -noopTest.afterEach = () => undefined; -noopTest.skip = () => noopTest; - -/** - * (Mostly complete) helper to allow mixing workflow and non-workflow code in the same test file. - */ -export const test: TestFn = inWorkflowContext() ? (noopTest as any) : ava; + createBaseBundlerOptions, + loadHistory as loadHistoryBase, + saveHistory as saveHistoryBase, + RUN_TIME_SKIPPING_TESTS, + test, + noopTest, +} from '@temporalio/test-helpers'; + +// Re-export from test-helpers +export { + sleep, + waitUntil, + u8, + approximatelyEqual, + RUN_INTEGRATION_TESTS, + REUSE_V8_CONTEXT, + RUN_TIME_SKIPPING_TESTS, + cleanStackTrace, + cleanOptionalStackTrace, + compareStackTrace, + getRandomPort, + ByteSkewerPayloadCodec, + test, + noopTest, + Worker, + TestWorkflowEnvironment, + baseBundlerIgnoreModules, + isBun, +} from '@temporalio/test-helpers'; export const testTimeSkipping = RUN_TIME_SKIPPING_TESTS ? test : noopTest; -export const bundlerOptions = { - // This is a bit ugly but it does the trick, when a test that includes workflow code tries to import a forbidden - // workflow module, add it to this list: - ignoreModules: [ - '@temporalio/common/lib/internal-non-workflow', - '@temporalio/activity', - '@temporalio/client', - '@temporalio/testing', - '@temporalio/nexus', - '@temporalio/worker', - 'ava', - 'crypto', - 'module', - 'path', - 'stack-utils', - '@grpc/grpc-js', - 'async-retry', - 'uuid', - 'net', - 'fs/promises', - 'timers', - 'timers/promises', - require.resolve('./activities'), - require.resolve('./mock-native-worker'), - ], -}; - /** - * A PayloadCodec used for testing purposes, skews the bytes in the payload data by 1 + * Package-specific bundler options that include local activity and mock-native-worker modules. */ -export class ByteSkewerPayloadCodec implements PayloadCodec { - async encode(payloads: Payload[]): Promise { - return payloads.map((payload) => ({ - ...payload, - data: payload.data?.map((byte) => byte + 1), - })); - } - - async decode(payloads: Payload[]): Promise { - return payloads.map((payload) => ({ - ...payload, - data: payload.data?.map((byte) => byte - 1), - })); - } -} - -// Hack around Worker and TestWorkflowEnvironment not being available in workflow context -if (inWorkflowContext()) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - worker.Worker = class {}; // eslint-disable-line import/namespace - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - RealTestWorkflowEnvironment = class {}; -} - -export class Worker extends worker.Worker { - static async create(options: WorkerOptions): Promise { - return RealWorker.create({ ...options, reuseV8Context: options.reuseV8Context ?? REUSE_V8_CONTEXT }); - } -} - -// A custom version of TestWorkflowEnvironment for our own testing use, that -// allow specifying the version of the CLI and Time Skipping Server binaries to -// through environment variables. -export class TestWorkflowEnvironment extends RealTestWorkflowEnvironment { - static async createLocal(opts?: LocalTestWorkflowEnvironmentOptions): Promise { - return RealTestWorkflowEnvironment.createLocal({ - ...opts, - ...(TESTS_CLI_VERSION - ? { - server: { - ...opts?.server, - executable: { - ...opts?.server?.executable, - type: 'cached-download', - version: TESTS_CLI_VERSION, - }, - }, - } - : undefined), - }); - } - - static async createTimeSkipping(opts?: TimeSkippingTestWorkflowEnvironmentOptions): Promise { - return RealTestWorkflowEnvironment.createTimeSkipping({ - ...opts, - ...(TESTS_TIME_SKIPPING_SERVER_VERSION - ? { - server: { - ...opts?.server, - executable: { - ...opts?.server?.executable, - type: 'cached-download', - version: TESTS_TIME_SKIPPING_SERVER_VERSION, - }, - }, - } - : undefined), - }); - } - - static async createFromExistingServer( - opts?: ExistingServerTestWorkflowEnvironmentOptions - ): Promise { - return RealTestWorkflowEnvironment.createFromExistingServer(opts); - } -} +export const bundlerOptions = createBaseBundlerOptions([ + require.resolve('./activities'), + require.resolve('./mock-native-worker'), +]); // Some of our tests expect "default custom search attributes" to exists, which used to be the case // in all deployment with support for advanced visibility. However, this might no longer be true in @@ -309,70 +102,17 @@ export async function registerDefaultCustomSearchAttributes(connection: Connecti } /** - * Return a random TCP port number, that is guaranteed to be either available, or to be in use. - * - * To get a port that is guaranteed to be available, simply call the function directly. - * - * ```ts - * const port = await getRandomPort(); - * ``` - * - * To get a port that is guaranteed to be in use, pass a function that will be called with the port - * number; the port is guaranteed to be in use until the function returns. This may be useful for - * example to test for proper error handling when a port is already in use. - * - * ```ts - * const port = await getRandomPort(async (port) => { - * t.throws( - * () => startMyService({ bindAddress: `127.0.0.1:${port}` }), - * { - * instanceOf: Error, - * message: /(Address already in use|socket address)/, - * } - * ); - * }); - * }); - * ``` + * Load a history file from the history_files directory. */ -export async function getRandomPort(fn = (_port: number) => Promise.resolve()): Promise { - return new Promise((resolve, reject) => { - const srv = net.createServer(); - srv.listen({ port: 0, host: '127.0.0.1' }, () => { - const addr = srv.address(); - if (typeof addr === 'string' || addr === null) { - throw new Error('Unexpected server address type'); - } - fn(addr.port) - .catch((e) => reject(e)) - .finally(() => srv.close((_) => resolve(addr.port))); - }); - }); -} - export async function loadHistory(fname: string): Promise { - const isJson = fname.endsWith('json'); const fpath = path.resolve(__dirname, `../history_files/${fname}`); - if (isJson) { - const hist = await fs.readFile(fpath, 'utf8'); - return JSON.parse(hist); - } else { - const hist = await fs.readFile(fpath); - return iface.temporal.api.history.v1.History.decode(hist); - } + return loadHistoryBase(fpath); } +/** + * Save a history file to the history_files directory. + */ export async function saveHistory(fname: string, history: iface.temporal.api.history.v1.IHistory): Promise { const fpath = path.resolve(__dirname, `../history_files/${fname}`); - await fs.writeFile(fpath, historyToJSON(history)); -} - -export function approximatelyEqual( - a: number | null | undefined, - b: number | null | undefined, - tolerance = 0.000001 -): boolean { - if (a === null || a === undefined || b === null || b === undefined) { - return false; - } - return Math.abs(a - b) < tolerance; + return saveHistoryBase(fpath, history); } diff --git a/packages/test/src/test-ephemeral-server.ts b/packages/test/src/test-ephemeral-server.ts index c28f32829..09e079f8c 100644 --- a/packages/test/src/test-ephemeral-server.ts +++ b/packages/test/src/test-ephemeral-server.ts @@ -7,7 +7,7 @@ import { TestWorkflowEnvironment as RealTestWorkflowEnvironment } from '@tempora import { Worker, TestWorkflowEnvironment, - testTimeSkipping as anyTestTimeSkipping, + testTimeSkipping as testTimeSkippingFromHelpers, getRandomPort, isBun, } from './helpers'; @@ -18,7 +18,7 @@ interface Context { } const test = anyTest as TestFn; -const testTimeSkipping = anyTestTimeSkipping as TestFn; +const testTimeSkipping = testTimeSkippingFromHelpers as TestFn; test.before(async (t) => { t.context.bundle = await bundleWorkflowCode({ workflowsPath: require.resolve('./workflows') }); diff --git a/packages/test/src/test-local-activities.ts b/packages/test/src/test-local-activities.ts index ac051fead..0a9233aae 100644 --- a/packages/test/src/test-local-activities.ts +++ b/packages/test/src/test-local-activities.ts @@ -1,93 +1,17 @@ -import { randomUUID } from 'crypto'; import { firstValueFrom, Subject } from 'rxjs'; -import { ExecutionContext, TestFn } from 'ava'; +import { TestFn } from 'ava'; import { Context as ActivityContext } from '@temporalio/activity'; -import { - ApplicationFailure, - defaultPayloadConverter, - WorkflowFailedError, - WorkflowHandle, - WorkflowStartOptions, -} from '@temporalio/client'; +import { ApplicationFailure, defaultPayloadConverter, WorkflowFailedError } from '@temporalio/client'; import { LocalActivityOptions, RetryPolicy } from '@temporalio/common'; import { msToNumber } from '@temporalio/common/lib/time'; import { temporal } from '@temporalio/proto'; import { workflowInterceptorModules } from '@temporalio/testing'; -import { - bundleWorkflowCode, - DefaultLogger, - LogLevel, - Runtime, - WorkflowBundle, - WorkerOptions, -} from '@temporalio/worker'; +import { bundleWorkflowCode, DefaultLogger, LogLevel, Runtime } from '@temporalio/worker'; import * as workflow from '@temporalio/workflow'; -import { test as anyTest, bundlerOptions, Worker, TestWorkflowEnvironment } from './helpers'; +import { test as anyTest, Worker, TestWorkflowEnvironment, BaseContext, helpers } from '@temporalio/test-helpers'; +import { bundlerOptions } from './helpers'; -// FIXME MOVE THIS SECTION SOMEWHERE IT CAN BE SHARED // - -interface Context { - env: TestWorkflowEnvironment; - workflowBundle: WorkflowBundle; -} - -const test = anyTest as TestFn; - -interface Helpers { - taskQueue: string; - createWorker(opts?: Partial): Promise; - executeWorkflow Promise>(workflowType: T): Promise>; - executeWorkflow( - fn: T, - opts: Omit, 'taskQueue' | 'workflowId'> - ): Promise>; - startWorkflow Promise>(workflowType: T): Promise>; - startWorkflow( - fn: T, - opts: Omit, 'taskQueue' | 'workflowId'> - ): Promise>; -} - -function helpers(t: ExecutionContext): Helpers { - const taskQueue = t.title.replace(/ /g, '_'); - - return { - taskQueue, - async createWorker(opts?: Partial): Promise { - const { interceptors, ...rest } = opts ?? {}; - return await Worker.create({ - connection: t.context.env.nativeConnection, - workflowBundle: t.context.workflowBundle, - taskQueue, - interceptors: { - activity: interceptors?.activity ?? [], - }, - showStackTraceSources: true, - ...rest, - }); - }, - async executeWorkflow( - fn: workflow.Workflow, - opts?: Omit - ): Promise { - return await t.context.env.client.workflow.execute(fn, { - taskQueue, - workflowId: randomUUID(), - ...opts, - }); - }, - async startWorkflow( - fn: workflow.Workflow, - opts?: Omit - ): Promise> { - return await t.context.env.client.workflow.start(fn, { - taskQueue, - workflowId: randomUUID(), - ...opts, - }); - }, - }; -} +const test = anyTest as TestFn; test.before(async (t) => { // Ignore invalid log levels @@ -108,8 +32,6 @@ test.after.always(async (t) => { await t.context.env.teardown(); }); -// END OF TO BE MOVED SECTION // - export async function runOneLocalActivity(s: string): Promise { return await workflow.proxyLocalActivities({ startToCloseTimeout: '1m' }).echo(s); } diff --git a/packages/test/src/workflows/index.ts b/packages/test/src/workflows/index.ts index 85c42ea45..8d2285f9d 100644 --- a/packages/test/src/workflows/index.ts +++ b/packages/test/src/workflows/index.ts @@ -68,13 +68,11 @@ export * from './shared-cancellation-scopes'; export * from './noncancellable-awaited-in-root-scope'; export * from './noncancellable-in-noncancellable'; export * from './signal-handlers-clear'; -export * from './signal-start-otel'; export * from './signal-target'; export * from './signals-are-always-processed'; export * from './signals-ordering'; export * from './signal-update-ordering'; export * from './signals-timers-activities-order'; -export * from './signal-workflow'; export * from './sinks'; export * from './sleep'; export * from './smorgasbord'; @@ -84,7 +82,6 @@ export * from './swc'; export * from './tasks-and-microtasks'; export * from './text-encoder-decoder'; export * from './throw-async'; -export * from './throw-maybe-benign'; export * from './trailing-timer'; export * from './try-to-continue-after-completion'; export * from './two-strings'; @@ -95,6 +92,5 @@ export * from './unhandled-rejection'; export * from './upsert-and-read-search-attributes'; export * from './wait-on-user'; export * from './upsert-and-read-memo'; -export * from './update-start-otel'; export * from './updates-ordering'; export * from './wait-on-signal-then-activity'; diff --git a/packages/test/tsconfig.json b/packages/test/tsconfig.json index fa29ed3f1..d29de4f6d 100644 --- a/packages/test/tsconfig.json +++ b/packages/test/tsconfig.json @@ -35,6 +35,9 @@ { "path": "../plugin" }, + { + "path": "../test-helpers" + }, { "path": "../testing" }, diff --git a/packages/testing/package.json b/packages/testing/package.json index c2c4c7c2d..bf7b640fc 100644 --- a/packages/testing/package.json +++ b/packages/testing/package.json @@ -11,6 +11,15 @@ ], "author": "Temporal Technologies Inc. ", "license": "MIT", + "scripts": { + "build": "tsc --build", + "test": "ava ./lib/__tests__/test-*.js" + }, + "ava": { + "timeout": "60s", + "concurrency": 1, + "workerThreads": false + }, "dependencies": { "@temporalio/activity": "workspace:*", "@temporalio/client": "workspace:*", @@ -21,6 +30,10 @@ "@temporalio/workflow": "workspace:*", "abort-controller": "^3.0.0" }, + "devDependencies": { + "ava": "^5.3.1", + "uuid": "^11.1.0" + }, "bugs": { "url": "https://github.com/temporalio/sdk-typescript/issues" }, @@ -35,7 +48,9 @@ }, "files": [ "lib", - "src" + "src", + "!src/__tests__", + "!lib/__tests__" ], "publishConfig": { "access": "public" diff --git a/packages/test/src/test-mockactivityenv.ts b/packages/testing/src/__tests__/test-mockactivityenv.ts similarity index 95% rename from packages/test/src/test-mockactivityenv.ts rename to packages/testing/src/__tests__/test-mockactivityenv.ts index dd8916b25..78de6c5c8 100644 --- a/packages/test/src/test-mockactivityenv.ts +++ b/packages/testing/src/__tests__/test-mockactivityenv.ts @@ -1,7 +1,7 @@ import test from 'ava'; -import { MockActivityEnvironment } from '@temporalio/testing'; import * as activity from '@temporalio/activity'; import { Runtime } from '@temporalio/worker'; +import { MockActivityEnvironment } from '../index'; test("MockActivityEnvironment doesn't implicitly instantiate Runtime", async (t) => { t.is(Runtime._instance, undefined); diff --git a/packages/worker/package.json b/packages/worker/package.json index 0149efe81..48aadc077 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -12,6 +12,9 @@ ], "author": "Temporal Technologies Inc. ", "license": "MIT", + "scripts": { + "build": "tsc --build" + }, "dependencies": { "@grpc/grpc-js": "^1.12.4", "@swc/core": "^1.3.102", diff --git a/packages/workflow/package.json b/packages/workflow/package.json index 40b3424e1..024fc1596 100644 --- a/packages/workflow/package.json +++ b/packages/workflow/package.json @@ -20,13 +20,22 @@ "author": "Temporal Technologies Inc. ", "main": "lib/index.js", "types": "lib/index.d.ts", - "scripts": {}, + "scripts": { + "build": "tsc --build", + "test": "ava ./lib/__tests__/test-*.js" + }, + "ava": { + "timeout": "60s", + "concurrency": 1, + "workerThreads": false + }, "dependencies": { "@temporalio/common": "workspace:*", "@temporalio/proto": "workspace:*", "nexus-rpc": "^0.0.1" }, "devDependencies": { + "ava": "^5.3.1", "source-map": "^0.7.4" }, "publishConfig": { @@ -37,6 +46,8 @@ }, "files": [ "src", - "lib" + "lib", + "!src/__tests__", + "!lib/__tests__" ] } diff --git a/packages/test/src/test-flags.ts b/packages/workflow/src/__tests__/test-flags.ts similarity index 95% rename from packages/test/src/test-flags.ts rename to packages/workflow/src/__tests__/test-flags.ts index 4e59de516..e221a8e5c 100644 --- a/packages/test/src/test-flags.ts +++ b/packages/workflow/src/__tests__/test-flags.ts @@ -1,6 +1,6 @@ import test from 'ava'; -import { SdkFlags, type SdkFlag } from '@temporalio/workflow/lib/flags'; -import type { WorkflowInfo } from '@temporalio/workflow'; +import { SdkFlags, type SdkFlag } from '../flags'; +import type { WorkflowInfo } from '../index'; type Conditions = SdkFlag['alternativeConditions']; function composeConditions(conditions: Conditions): NonNullable[number] { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 376140013..397d7fa9d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,9 +5,9 @@ settings: excludeLinksFromLockfile: false overrides: + qs: ^6.14.1 hono@<4.11.4: '>=4.11.4' jws@<3.2.3: '>=3.2.3' - qs: ^6.14.1 tar@<=7.5.2: '>=7.5.3' importers: @@ -156,12 +156,6 @@ importers: packages/ai-sdk: dependencies: - '@ai-sdk/mcp': - specifier: ^1.0.0 - version: 1.0.0(zod@3.25.76) - '@ai-sdk/provider': - specifier: ^3.0.0 - version: 3.0.0 '@temporalio/common': specifier: workspace:* version: link:../common @@ -174,15 +168,67 @@ importers: '@ungap/structured-clone': specifier: ^1.3.0 version: 1.3.0 - ai: - specifier: ^6.0.0 - version: 6.0.2(zod@3.25.76) headers-polyfill: specifier: ^4.0.3 version: 4.0.3 web-streams-polyfill: specifier: ^4.2.0 version: 4.2.0 + devDependencies: + '@ai-sdk/mcp': + specifier: ^1.0.0 + version: 1.0.0(zod@3.25.76) + '@ai-sdk/openai': + specifier: ^3.0.0 + version: 3.0.0(zod@3.25.76) + '@ai-sdk/provider': + specifier: ^3.0.0 + version: 3.0.0 + '@modelcontextprotocol/sdk': + specifier: ^1.25.2 + version: 1.26.0(zod@3.25.76) + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.0 + '@opentelemetry/core': + specifier: ^1.25.1 + version: 1.25.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-node': + specifier: ^0.52.1 + version: 0.52.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': + specifier: ^1.25.1 + version: 1.25.1 + '@temporalio/client': + specifier: workspace:* + version: link:../client + '@temporalio/interceptors-opentelemetry': + specifier: workspace:* + version: link:../interceptors-opentelemetry + '@temporalio/proto': + specifier: workspace:* + version: link:../proto + '@temporalio/test-helpers': + specifier: workspace:* + version: link:../test-helpers + '@temporalio/testing': + specifier: workspace:* + version: link:../testing + '@temporalio/worker': + specifier: workspace:* + version: link:../worker + ai: + specifier: ^6.0.0 + version: 6.0.2(zod@3.25.76) + ava: + specifier: ^5.3.1 + version: 5.3.1 + uuid: + specifier: ^11.1.0 + version: 11.1.0 + zod: + specifier: ^3.25.76 + version: 3.25.76 packages/client: dependencies: @@ -252,6 +298,9 @@ importers: specifier: ^2.0.0 version: 2.0.0 devDependencies: + ava: + specifier: ^5.3.1 + version: 5.3.1 protobufjs: specifier: ^7.2.5 version: 7.5.1 @@ -342,9 +391,21 @@ importers: specifier: 1.4.2 version: 1.4.2 devDependencies: + '@temporalio/client': + specifier: workspace:* + version: link:../client + '@temporalio/testing': + specifier: workspace:* + version: link:../testing '@temporalio/worker': specifier: workspace:* version: link:../worker + ava: + specifier: ^5.3.1 + version: 5.3.1 + dedent: + specifier: ^1.5.1 + version: 1.5.3 packages/interceptors-opentelemetry: dependencies: @@ -364,6 +425,15 @@ importers: specifier: workspace:* version: link:../plugin devDependencies: + '@opentelemetry/exporter-trace-otlp-grpc': + specifier: ^0.52.1 + version: 0.52.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-node': + specifier: ^0.52.1 + version: 0.52.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': + specifier: ^1.25.1 + version: 1.25.1 '@temporalio/activity': specifier: workspace:* version: link:../activity @@ -373,12 +443,27 @@ importers: '@temporalio/common': specifier: workspace:* version: link:../common + '@temporalio/proto': + specifier: workspace:* + version: link:../proto + '@temporalio/test-helpers': + specifier: workspace:* + version: link:../test-helpers + '@temporalio/testing': + specifier: workspace:* + version: link:../testing '@temporalio/worker': specifier: workspace:* version: link:../worker '@temporalio/workflow': specifier: workspace:* version: link:../workflow + ava: + specifier: ^5.3.1 + version: 5.3.1 + uuid: + specifier: ^11.1.0 + version: 11.1.0 packages/meta: dependencies: @@ -436,6 +521,10 @@ importers: nexus-rpc: specifier: ^0.0.1 version: 0.0.1 + devDependencies: + ava: + specifier: ^5.3.1 + version: 5.3.1 packages/nyc-test-coverage: dependencies: @@ -450,7 +539,7 @@ importers: version: 6.0.1 ts-loader: specifier: ^9.5.1 - version: 9.5.1(typescript@5.9.3)(webpack@5.105.1) + version: 9.5.1(typescript@5.6.3)(webpack@5.105.1) devDependencies: '@temporalio/common': specifier: workspace:* @@ -473,6 +562,9 @@ importers: '@types/webpack': specifier: ^5.28.5 version: 5.28.5 + typescript: + specifier: ^5.6.3 + version: 5.6.3 webpack: specifier: ^5.104.1 version: 5.105.1 @@ -582,6 +674,9 @@ importers: '@temporalio/proto': specifier: workspace:* version: link:../proto + '@temporalio/test-helpers': + specifier: workspace:* + version: link:../test-helpers '@temporalio/testing': specifier: workspace:* version: link:../testing @@ -665,6 +760,37 @@ importers: specifier: ^3.0.2 version: 3.0.2 + packages/test-helpers: + dependencies: + '@temporalio/client': + specifier: workspace:* + version: link:../client + '@temporalio/common': + specifier: workspace:* + version: link:../common + '@temporalio/proto': + specifier: workspace:* + version: link:../proto + '@temporalio/testing': + specifier: workspace:* + version: link:../testing + '@temporalio/worker': + specifier: workspace:* + version: link:../worker + '@temporalio/workflow': + specifier: workspace:* + version: link:../workflow + ava: + specifier: ^5.3.1 + version: 5.3.1 + stack-utils: + specifier: ^2.0.6 + version: 2.0.6 + devDependencies: + '@types/stack-utils': + specifier: ^2.0.3 + version: 2.0.3 + packages/testing: dependencies: '@temporalio/activity': @@ -691,6 +817,13 @@ importers: abort-controller: specifier: ^3.0.0 version: 3.0.0 + devDependencies: + ava: + specifier: ^5.3.1 + version: 5.3.1 + uuid: + specifier: ^11.1.0 + version: 11.1.0 packages/worker: dependencies: @@ -777,6 +910,9 @@ importers: specifier: ^0.0.1 version: 0.0.1 devDependencies: + ava: + specifier: ^5.3.1 + version: 5.3.1 source-map: specifier: ^0.7.4 version: 0.7.4 @@ -4592,11 +4728,6 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - uc.micro@1.0.6: resolution: {integrity: sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==} @@ -8978,14 +9109,14 @@ snapshots: dependencies: typescript: 5.6.3 - ts-loader@9.5.1(typescript@5.9.3)(webpack@5.105.1): + ts-loader@9.5.1(typescript@5.6.3)(webpack@5.105.1): dependencies: chalk: 4.1.2 - enhanced-resolve: 5.17.1 + enhanced-resolve: 5.19.0 micromatch: 4.0.8 - semver: 7.7.2 + semver: 7.7.3 source-map: 0.7.4 - typescript: 5.9.3 + typescript: 5.6.3 webpack: 5.105.1 ts-morph@13.0.3: @@ -9093,8 +9224,6 @@ snapshots: typescript@5.6.3: {} - typescript@5.9.3: {} - uc.micro@1.0.6: {} uglify-js@3.17.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 790e3c460..b8c5bbea9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -14,6 +14,7 @@ packages: - packages/plugin - packages/proto - packages/test + - packages/test-helpers - packages/testing - packages/worker - packages/workflow