diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 915f77d6f..ab417a0b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,7 +113,7 @@ jobs: strategy: fail-fast: false matrix: - node: [18, 22] # Min and max supported Node versions + node: [18, 22, 24] # Min and max officially supported Node versions + next max platform: [linux-x64, linux-arm, macos-x64, macos-arm, windows-x64] include: - platform: linux-x64 diff --git a/.github/workflows/conventions.yml b/.github/workflows/conventions.yml index 60288f307..20c9acd10 100644 --- a/.github/workflows/conventions.yml +++ b/.github/workflows/conventions.yml @@ -19,7 +19,7 @@ jobs: - name: Install Node uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 - name: Get NPM cache directory id: npm-cache-dir diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 97642527b..c6e39cc48 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -38,7 +38,7 @@ jobs: - name: Install Node uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 - name: Get NPM cache directory id: npm-cache-dir diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8bd26530d..38ba0f02e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -168,7 +168,7 @@ jobs: - name: Install Node uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 - name: Get NPM cache directory id: npm-cache-dir @@ -213,7 +213,7 @@ jobs: strategy: fail-fast: false matrix: - node: [18, 22] # Min and max supported Node versions + node: [18, 22, 24] # Min and max officially supported Node versions + next max platform: [linux-x64, linux-arm, macos-x64, macos-arm, windows-x64] sample: [hello-world, fetch-esm, hello-world-mtls] server: [cli, cloud] diff --git a/.github/workflows/stress.yml b/.github/workflows/stress.yml index de4254853..43006263d 100644 --- a/.github/workflows/stress.yml +++ b/.github/workflows/stress.yml @@ -62,7 +62,7 @@ jobs: - name: Install Node uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 - name: Get NPM cache directory id: npm-cache-dir diff --git a/.gitignore b/.gitignore index 1d0edde80..e20a6eebd 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ packages/*/package-lock.json # One test creates persisted SQLite DBs; they should normally be deleted automatically, # but may be left behind in some error scenarios. packages/test/temporal-db-*.sqlite +.DS_Store diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7e796fb32..beca69c93 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,7 +50,8 @@ See [sdk-structure.md](./docs/sdk-structure.md) ### Environment setup -The TS SDK can be executed on 18, 20 or 22. However, we recommend using Node 22 for SDK development. +TS SDK is officially supported on Node 18, 20, 22, or 24. However, we recommend using the +[Active LTS](https://nodejs.org/en/about/previous-releases#nodejs-releases) for SDK development. For easier testing during development you may want to use a version manager, such as [fnm](https://github.com/Schniz/fnm) or [nvm](https://github.com/nvm-sh/nvm/blob/master/README.md). diff --git a/package-lock.json b/package-lock.json index 5a35a5f73..921b96cd4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12424,7 +12424,8 @@ }, "node_modules/nexus-rpc": { "version": "0.0.1", - "resolved": "git+ssh://git@github.com/nexus-rpc/sdk-typescript.git#f594a7fd9e33bd14e5ce1ed04c5225fc708e7866", + "resolved": "https://registry.npmjs.org/nexus-rpc/-/nexus-rpc-0.0.1.tgz", + "integrity": "sha512-hAWn8Hh2eewpB5McXR5EW81R3pR/ziuGhKCF3wFyUVCklanPqrIgMNr7jKCbzXeNVad0nUDfWpFRqh2u+zxQtw==", "license": "MIT", "engines": { "node": ">= 18.0.0" diff --git a/packages/test/src/helpers.ts b/packages/test/src/helpers.ts index ead48b903..a8dd11995 100644 --- a/packages/test/src/helpers.ts +++ b/packages/test/src/helpers.ts @@ -3,7 +3,7 @@ import * as net from 'net'; import path from 'path'; import * as grpc from '@grpc/grpc-js'; import asyncRetry from 'async-retry'; -import ava, { TestFn } from 'ava'; +import ava, { TestFn, ExecutionContext } from 'ava'; import StackUtils from 'stack-utils'; import { v4 as uuid4 } from 'uuid'; import { Client, Connection } from '@temporalio/client'; @@ -98,6 +98,21 @@ export function cleanStackTrace(ostack: string): string { return normalizedStack ? `${firstLine}\n${normalizedStack}` : firstLine; } +/** + * Compare stack traces using $CLASS keyword to match any inconsistent identifiers + * + * 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) + */ +export function compareStackTrace(t: ExecutionContext, actual: string, expected: string): void { + const escapedTrace = expected + .replace(/[|\\{}()[\]^$+*?.]/g, '\\$&') + .replace(/-/g, '\\x2d') + .replaceAll('\\$CLASS', '(?:[A-Za-z]+)'); + t.regex(actual, RegExp(`^${escapedTrace}$`)); +} + function noopTest(): void { // eslint: this function body is empty and it's okay. } diff --git a/packages/test/src/test-integration-split-one.ts b/packages/test/src/test-integration-split-one.ts index f3df37222..68efcdcdf 100644 --- a/packages/test/src/test-integration-split-one.ts +++ b/packages/test/src/test-integration-split-one.ts @@ -36,7 +36,7 @@ import { } from '@temporalio/workflow'; import { configurableHelpers, createTestWorkflowBundle } from './helpers-integration'; import * as activities from './activities'; -import { cleanOptionalStackTrace, u8, Worker } from './helpers'; +import { cleanOptionalStackTrace, compareStackTrace, u8, Worker } from './helpers'; import { configMacro, makeTestFn } from './helpers-integration-multi-codec'; import * as workflows from './workflows'; @@ -204,11 +204,12 @@ test.serial('activity-failure with ApplicationFailure', configMacro, async (t, c t.is(err.cause.cause.message, 'Fail me'); t.is(err.cause.cause.type, 'Error'); t.deepEqual(err.cause.cause.details, ['details', 123, false]); - t.is( - cleanOptionalStackTrace(err.cause.cause.stack), + compareStackTrace( + t, + cleanOptionalStackTrace(err.cause.cause.stack)!, dedent` ApplicationFailure: Fail me - at Function.nonRetryable (common/src/failure.ts) + at $CLASS.nonRetryable (common/src/failure.ts) at throwAnError (test/src/activities/index.ts) ` ); @@ -258,11 +259,12 @@ test.serial('child-workflow-failure', configMacro, async (t, config) => { return t.fail('Expected err.cause.cause to be an instance of ApplicationFailure'); } t.is(err.cause.cause.message, 'failure'); - t.is( - cleanOptionalStackTrace(err.cause.cause.stack), + compareStackTrace( + t, + cleanOptionalStackTrace(err.cause.cause.stack)!, dedent` ApplicationFailure: failure - at Function.nonRetryable (common/src/failure.ts) + at $CLASS.nonRetryable (common/src/failure.ts) at throwAsync (test/src/workflows/throw-async.ts) ` ); diff --git a/packages/test/src/test-integration-split-three.ts b/packages/test/src/test-integration-split-three.ts index cf553c58f..c4af367f5 100644 --- a/packages/test/src/test-integration-split-three.ts +++ b/packages/test/src/test-integration-split-three.ts @@ -8,7 +8,7 @@ import { configMacro, makeTestFn } from './helpers-integration-multi-codec'; import { configurableHelpers } from './helpers-integration'; import { withZeroesHTTPServer } from './zeroes-http-server'; import * as activities from './activities'; -import { approximatelyEqual, cleanOptionalStackTrace } from './helpers'; +import { approximatelyEqual, cleanOptionalStackTrace, compareStackTrace } from './helpers'; import * as workflows from './workflows'; const test = makeTestFn(() => bundleWorkflowCode({ workflowsPath: require.resolve('./workflows') })); @@ -48,7 +48,6 @@ if ('promiseHooks' in v8) { t.true( stack1.endsWith( ` - at Function.all () at stackTracer (test/src/workflows/stack-tracer.ts) at stackTracer (test/src/workflows/stack-tracer.ts) @@ -91,11 +90,17 @@ if ('promiseHooks' in v8) { })); t.is(enhancedStack.sdk.name, 'typescript'); t.is(enhancedStack.sdk.version, pkg.version); // Expect workflow and worker versions to match + { + const functionName = stacks[0]!.locations[0]!.function_name!; + delete stacks[0]!.locations[0]!.function_name; + compareStackTrace(t, functionName, '$CLASS.all'); + } t.deepEqual(stacks, [ { locations: [ { - function_name: 'Function.all', + // Checked sperately above to handle Node 24 behavior change with respect to identifiers in stack traces + // function_name: 'Function.all', internal_code: false, }, { diff --git a/packages/test/src/test-integration-workflows.ts b/packages/test/src/test-integration-workflows.ts index 3c91b745c..56722cfd7 100644 --- a/packages/test/src/test-integration-workflows.ts +++ b/packages/test/src/test-integration-workflows.ts @@ -1075,7 +1075,7 @@ export function setAndClearTimeoutInterceptors(): workflow.WorkflowInterceptors } if (RUN_TIME_SKIPPING_TESTS) { - test('setTimeout and clearTimeout - works before and after 1.10.3', async (t) => { + test.serial('setTimeout and clearTimeout - works before and after 1.10.3', async (t) => { const env = await TestWorkflowEnvironment.createTimeSkipping(); const { createWorker, startWorkflow } = helpers(t, env); try { @@ -1292,7 +1292,7 @@ test('Count workflow executions', async (t) => { }); }); -test('can register search attributes to dev server', async (t) => { +test.serial('can register search attributes to dev server', async (t) => { const key = defineSearchAttributeKey('new-search-attr', SearchAttributeType.INT); const newSearchAttribute: SearchAttributePair = { key, value: 12 }; diff --git a/packages/test/src/test-interceptors.ts b/packages/test/src/test-interceptors.ts index 34b2a4b3d..5449ff585 100644 --- a/packages/test/src/test-interceptors.ts +++ b/packages/test/src/test-interceptors.ts @@ -12,7 +12,7 @@ import { WorkflowClient, WorkflowFailedError } from '@temporalio/client'; import { ApplicationFailure, TerminatedFailure } from '@temporalio/common'; import { DefaultLogger, Runtime } from '@temporalio/worker'; import { defaultPayloadConverter, WorkflowInfo } from '@temporalio/workflow'; -import { cleanOptionalStackTrace, RUN_INTEGRATION_TESTS, Worker } from './helpers'; +import { cleanOptionalStackTrace, compareStackTrace, RUN_INTEGRATION_TESTS, Worker } from './helpers'; import { defaultOptions } from './mock-native-worker'; import { continueAsNewToDifferentWorkflow, @@ -241,11 +241,12 @@ if (RUN_INTEGRATION_TESTS) { return; } t.deepEqual(err.cause.message, 'Expected anything other than 1'); - t.is( - cleanOptionalStackTrace(err.cause.stack), + compareStackTrace( + t, + cleanOptionalStackTrace(err.cause.stack)!, dedent` ApplicationFailure: Expected anything other than 1 - at Function.nonRetryable (common/src/failure.ts) + at $CLASS.nonRetryable (common/src/failure.ts) at Object.continueAsNew (test/src/workflows/interceptor-example.ts) at workflow/src/workflow.ts at continueAsNewToDifferentWorkflow (test/src/workflows/continue-as-new-to-different-workflow.ts) diff --git a/packages/test/src/test-nexus-handler.ts b/packages/test/src/test-nexus-handler.ts index 6cc7299fa..f13f2b427 100644 --- a/packages/test/src/test-nexus-handler.ts +++ b/packages/test/src/test-nexus-handler.ts @@ -19,7 +19,7 @@ import { convertWorkflowEventLinkToNexusLink, convertNexusLinkToWorkflowEventLink, } from '@temporalio/nexus/lib/link-converter'; -import { cleanStackTrace, getRandomPort } from './helpers'; +import { cleanStackTrace, compareStackTrace, getRandomPort } from './helpers'; export interface Context { httpPort: number; @@ -314,10 +314,11 @@ test('start Operation Handler errors', async (t) => { }); t.true(err instanceof ApplicationFailure); t.is(err.message, ''); - t.is( + compareStackTrace( + t, cleanStackTrace(err.stack!), `ApplicationFailure: deliberate failure - at Function.create (common/src/failure.ts) + at $CLASS.create (common/src/failure.ts) at op (test/src/test-nexus-handler.ts) at Object.start (nexus-rpc/src/handler/operation-handler.ts) at ServiceRegistry.start (nexus-rpc/src/handler/service-registry.ts)` @@ -460,10 +461,11 @@ test('cancel Operation Handler errors', async (t) => { }); t.true(err instanceof ApplicationFailure); t.is(err.message, ''); - t.is( + compareStackTrace( + t, cleanStackTrace(err.stack!), `ApplicationFailure: deliberate failure - at Function.create (common/src/failure.ts) + at $CLASS.create (common/src/failure.ts) at Object.cancel (test/src/test-nexus-handler.ts) at ServiceRegistry.cancel (nexus-rpc/src/handler/service-registry.ts)` ); diff --git a/packages/test/src/test-workflows.ts b/packages/test/src/test-workflows.ts index cc8ddab9a..7330e0bf9 100644 --- a/packages/test/src/test-workflows.ts +++ b/packages/test/src/test-workflows.ts @@ -22,7 +22,7 @@ import { SdkFlag, SdkFlags } from '@temporalio/workflow/lib/flags'; import { ReusableVMWorkflow, ReusableVMWorkflowCreator } from '@temporalio/worker/lib/workflow/reusable-vm'; import { parseWorkflowCode } from '@temporalio/worker/lib/worker'; import * as activityFunctions from './activities'; -import { cleanStackTrace, REUSE_V8_CONTEXT, u8 } from './helpers'; +import { cleanStackTrace, compareStackTrace, REUSE_V8_CONTEXT, u8 } from './helpers'; import { ProcessedSignal } from './workflows'; export interface Context { @@ -163,6 +163,7 @@ function compareCompletion( req: coresdk.workflow_completion.IWorkflowActivationCompletion, expected: coresdk.workflow_completion.IWorkflowActivationCompletion ) { + const stackTraces = extractFailureStackTraces(req, expected); t.deepEqual( coresdk.workflow_completion.WorkflowActivationCompletion.create(req).toJSON(), coresdk.workflow_completion.WorkflowActivationCompletion.create({ @@ -170,6 +171,43 @@ function compareCompletion( runId: t.context.runId, }).toJSON() ); + + if (stackTraces) { + for (const { actual, expected } of stackTraces) { + compareStackTrace(t, actual, expected); + } + } +} + +// Extracts failure stack traces from completions if structure matches, leaving them unchanged if structure differs. +// We leave them unchanged on structure differences as ava's `deepEqual` provides a better failure message. +function extractFailureStackTraces( + req: coresdk.workflow_completion.IWorkflowActivationCompletion, + expected: coresdk.workflow_completion.IWorkflowActivationCompletion +): { actual: string; expected: string }[] | undefined { + const reqCommands = req.successful?.commands; + const expectedCommands = expected.successful?.commands; + if (!reqCommands || !expectedCommands || reqCommands.length !== expectedCommands.length) { + return; + } + for (let commandIndex = 0; commandIndex < reqCommands.length; commandIndex++) { + const reqStack = reqCommands[commandIndex].failWorkflowExecution?.failure?.stackTrace; + const expectedStack = expectedCommands[commandIndex].failWorkflowExecution?.failure?.stackTrace; + if (typeof reqStack !== typeof expectedStack) { + return; + } + } + const stackTraces: { actual: string; expected: string }[] = []; + for (let commandIndex = 0; commandIndex < reqCommands.length; commandIndex++) { + const reqStack = reqCommands[commandIndex].failWorkflowExecution?.failure?.stackTrace; + const expectedStack = expectedCommands[commandIndex].failWorkflowExecution?.failure?.stackTrace; + if (reqStack && expectedStack) { + stackTraces.push({ actual: reqStack, expected: expectedStack }); + delete reqCommands[commandIndex].failWorkflowExecution?.failure?.stackTrace; + delete expectedCommands[commandIndex].failWorkflowExecution?.failure?.stackTrace; + } + } + return stackTraces; } function makeSuccess( @@ -464,7 +502,7 @@ test('throwAsync', async (t) => { 'failure', dedent` ApplicationFailure: failure - at Function.nonRetryable (common/src/failure.ts) + at $CLASS.nonRetryable (common/src/failure.ts) at throwAsync (test/src/workflows/throw-async.ts) ` ), @@ -779,7 +817,7 @@ test('interruptableWorkflow', async (t) => { // since the Error stack trace is generated in the constructor. dedent` ApplicationFailure: just because - at Function.retryable (common/src/failure.ts) + at $CLASS.retryable (common/src/failure.ts) at test/src/workflows/interrupt-signal.ts `, 'Error', @@ -809,7 +847,7 @@ test('failSignalWorkflow', async (t) => { 'Signal failed', dedent` ApplicationFailure: Signal failed - at Function.nonRetryable (common/src/failure.ts) + at $CLASS.nonRetryable (common/src/failure.ts) at test/src/workflows/fail-signal.ts `, 'Error' @@ -849,7 +887,7 @@ test('asyncFailSignalWorkflow', async (t) => { 'Signal failed', dedent` ApplicationFailure: Signal failed - at Function.nonRetryable (common/src/failure.ts) + at $CLASS.nonRetryable (common/src/failure.ts) at test/src/workflows/async-fail-signal.ts`, 'Error' ), @@ -1461,7 +1499,7 @@ test('cancellationErrorIsPropagated', async (t) => { at test/src/workflows/cancellation-error-is-propagated.ts at CancellationScope.runInContext (workflow/src/cancellation-scope.ts) at CancellationScope.run (workflow/src/cancellation-scope.ts) - at Function.cancellable (workflow/src/cancellation-scope.ts) + at $CLASS.cancellable (workflow/src/cancellation-scope.ts) at cancellationErrorIsPropagated (test/src/workflows/cancellation-error-is-propagated.ts) `, canceledFailureInfo: {}, @@ -1880,7 +1918,7 @@ test('tryToContinueAfterCompletion', async (t) => { 'fail before continue', dedent` ApplicationFailure: fail before continue - at Function.nonRetryable (common/src/failure.ts) + at $CLASS.nonRetryable (common/src/failure.ts) at tryToContinueAfterCompletion (test/src/workflows/try-to-continue-after-completion.ts) ` ), diff --git a/packages/worker/src/workflow/vm-shared.ts b/packages/worker/src/workflow/vm-shared.ts index 96b76fd44..6db33fbf6 100644 --- a/packages/worker/src/workflow/vm-shared.ts +++ b/packages/worker/src/workflow/vm-shared.ts @@ -250,7 +250,7 @@ export class GlobalHandlers { if ( currentAggregation && - /^\s+at\sPromise\.then \(\)\n\s+at Function\.(race|all|allSettled|any) \(\)\n/.test( + /^\s+at\sPromise\.then \(\)\n\s+at (Function|Promise)\.(race|all|allSettled|any) \(\)\n/.test( formatted ) ) { @@ -258,7 +258,7 @@ export class GlobalHandlers { promise = currentAggregation; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion stackTrace = store.promiseToStack.get(currentAggregation)!; // Must exist - } else if (/^\s+at Function\.(race|all|allSettled|any) \(\)\n/.test(formatted)) { + } else if (/^\s+at (Function|Promise)\.(race|all|allSettled|any) \(\)\n/.test(formatted)) { currentAggregation = promise; } else { currentAggregation = undefined;