diff --git a/dev-packages/bun-integration-tests/node-suites/alias-sentry-bun.ts b/dev-packages/bun-integration-tests/node-suites/alias-sentry-bun.ts new file mode 100644 index 000000000000..ff75884b1c50 --- /dev/null +++ b/dev-packages/bun-integration-tests/node-suites/alias-sentry-bun.ts @@ -0,0 +1,32 @@ +import { plugin } from 'bun'; +import Module from 'node:module'; + +// Maps `@sentry/node` to `@sentry/bun` in the Node suite files. `@sentry/bun` imports +// `@sentry/node` itself, so imports from outside the Node suite files are not touched. +const NODE_SUITE_FILE = /[\\/]node-integration-tests[\\/](suites|utils)[\\/]/; + +// Bun's runtime `onResolve` does not see bare package specifiers, so ES modules are rewritten on +// load. `onLoad` output for a CommonJS file does not run, so those files keep their source. +const NODE_SUITE_ESM_FILE = /[\\/]node-integration-tests[\\/](suites|utils)[\\/].*\.(mjs|ts)$/; +const SENTRY_NODE_SPECIFIER = /(['"])@sentry\/node\1/g; + +plugin({ + name: 'alias-sentry-node-to-sentry-bun', + setup(build) { + build.onLoad({ filter: NODE_SUITE_ESM_FILE }, async args => { + const source = await Bun.file(args.path).text(); + return { + contents: source.replace(SENTRY_NODE_SPECIFIER, '$1@sentry/bun$1'), + loader: args.path.endsWith('.ts') ? 'ts' : 'js', + }; + }); + }, +}); + +type ResolveFilename = (request: string, parent: { filename?: string } | undefined, ...rest: unknown[]) => string; +const moduleWithResolve = Module as unknown as { _resolveFilename: ResolveFilename }; +const originalResolveFilename = moduleWithResolve._resolveFilename; +moduleWithResolve._resolveFilename = function (request, parent, ...rest) { + const aliased = request === '@sentry/node' && NODE_SUITE_FILE.test(parent?.filename ?? '') ? '@sentry/bun' : request; + return originalResolveFilename.call(this, aliased, parent, ...rest); +}; diff --git a/dev-packages/bun-integration-tests/package.json b/dev-packages/bun-integration-tests/package.json index 1844f06177cb..6c6895e774bd 100644 --- a/dev-packages/bun-integration-tests/package.json +++ b/dev-packages/bun-integration-tests/package.json @@ -21,6 +21,7 @@ "pg": "8.16.0" }, "devDependencies": { + "@sentry-internal/node-integration-tests": "11.0.0", "@sentry-internal/test-utils": "11.0.0", "bun-types": "^1.2.9", "vitest": "^3.2.7" diff --git a/dev-packages/bun-integration-tests/tsconfig.json b/dev-packages/bun-integration-tests/tsconfig.json index 8fe804d8c50d..ba0be38e72df 100644 --- a/dev-packages/bun-integration-tests/tsconfig.json +++ b/dev-packages/bun-integration-tests/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../tsconfig.json", - "include": ["suites/**/*.ts", "*.ts"], + "include": ["suites/**/*.ts", "node-suites/**/*.ts", "*.ts"], "compilerOptions": { "lib": ["ES2020"], diff --git a/dev-packages/bun-integration-tests/vite.config.mts b/dev-packages/bun-integration-tests/vite.config.mts index 23ea00e8c30a..ad05c64e8ece 100644 --- a/dev-packages/bun-integration-tests/vite.config.mts +++ b/dev-packages/bun-integration-tests/vite.config.mts @@ -1,6 +1,26 @@ +import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitest/config'; import baseConfig from '../../vite/vite.config'; +const NODE_SUITES_ROOT = fileURLToPath(new URL('../node-integration-tests', import.meta.url)); + +// Node suites that also run on Bun. The scenarios stay in `node-integration-tests`. +const NODE_SUITES = [ + 'suites/public-api/**/test.ts', + 'suites/client-reports/**/test.ts', + 'suites/featureFlags/**/test.ts', +]; + +// Single tests that fail on Bun are skipped with `test.skipIf` on `RUNTIME` in the Node suite. +const NODE_SUITES_EXCLUDE = ['**/node_modules/**']; + +const nodeSuitesTest = { + root: NODE_SUITES_ROOT, + include: NODE_SUITES, + exclude: NODE_SUITES_EXCLUDE, + testTimeout: 15_000, +}; + export default defineConfig({ ...baseConfig, test: { @@ -9,7 +29,6 @@ export default defineConfig({ enabled: false, }, isolate: false, - include: ['./suites/**/test.ts'], testTimeout: 20_000, ...(process.env.DEBUG ? { @@ -18,15 +37,49 @@ export default defineConfig({ } : {}), pool: 'threads', - poolOptions: { - threads: { - singleThread: true, - }, - }, reporters: process.env.DEBUG ? ['default', { summary: false }] : process.env.GITHUB_ACTIONS ? ['dot', 'github-actions'] : ['verbose'], + projects: [ + { + extends: true, + test: { + name: 'bun', + include: ['./suites/**/test.ts'], + poolOptions: { + threads: { + singleThread: true, + }, + }, + }, + }, + { + extends: true, + test: { + ...nodeSuitesTest, + name: 'node-suites', + env: { RUNTIME: 'bun' }, + }, + }, + { + extends: true, + test: { + ...nodeSuitesTest, + name: 'node-suites-sentry-bun', + exclude: [ + ...NODE_SUITES_EXCLUDE, + // The scenario creates a `NodeClient` itself, which sends `sentry.javascript.node`. + 'suites/public-api/logs/test.ts', + ], + env: { + RUNTIME: 'bun', + RUNTIME_PRELOAD: fileURLToPath(new URL('./node-suites/alias-sentry-bun.ts', import.meta.url)), + EXPECTED_SDK_NAME: 'sentry.javascript.bun', + }, + }, + }, + ], }, }); diff --git a/dev-packages/deno-integration-tests/node-suites/import-map.json b/dev-packages/deno-integration-tests/node-suites/import-map.json new file mode 100644 index 000000000000..a0776ec28c65 --- /dev/null +++ b/dev-packages/deno-integration-tests/node-suites/import-map.json @@ -0,0 +1,5 @@ +{ + "imports": { + "@sentry-internal/node-integration-tests": "../../node-integration-tests/build/esm/index.js" + } +} diff --git a/dev-packages/deno-integration-tests/package.json b/dev-packages/deno-integration-tests/package.json index 3fdf392ec21b..9cfb31eef59a 100644 --- a/dev-packages/deno-integration-tests/package.json +++ b/dev-packages/deno-integration-tests/package.json @@ -11,8 +11,9 @@ "install:deno": "node ./scripts/install-deno.mjs", "lint": "oxlint . --type-aware", "lint:fix": "oxlint . --fix --type-aware", - "test": "run-s install:deno deno-types test:unit", - "test:unit": "deno test --allow-net --allow-read --allow-run --allow-env --no-check" + "test": "run-s install:deno deno-types test:unit test:node-suites", + "test:unit": "deno test --allow-net --allow-read --allow-run --allow-env --no-check", + "test:node-suites": "vitest run" }, "dependencies": { "@sentry/core": "11.0.0", @@ -20,6 +21,10 @@ "mysql": "^2.18.1", "pg": "^8.22.0" }, + "devDependencies": { + "@sentry-internal/node-integration-tests": "11.0.0", + "vitest": "^3.2.7" + }, "volta": { "extends": "../../package.json" } diff --git a/dev-packages/deno-integration-tests/vite.config.mts b/dev-packages/deno-integration-tests/vite.config.mts new file mode 100644 index 000000000000..6e066e54ee1a --- /dev/null +++ b/dev-packages/deno-integration-tests/vite.config.mts @@ -0,0 +1,44 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; +import baseConfig from '../../vite/vite.config'; + +// Runs the Node suites below on Deno. The scenarios stay in `node-integration-tests`, and the +// Deno-only suites in `suites/` run with `deno test`. +export default defineConfig({ + ...baseConfig, + test: { + ...baseConfig.test, + root: fileURLToPath(new URL('../node-integration-tests', import.meta.url)), + coverage: { + enabled: false, + }, + isolate: false, + include: [ + 'suites/public-api/**/test.ts', + 'suites/client-reports/**/test.ts', + 'suites/featureFlags/**/test.ts', + 'suites/express/tracing/**/test.ts', + 'suites/tracing/httpIntegration/test.ts', + 'suites/tracing/httpIntegration-streamed/test.ts', + ], + // Single tests that fail on Deno are skipped with `test.skipIf` on `RUNTIME` in the Node suite. + exclude: ['**/node_modules/**'], + env: { + RUNTIME: 'deno', + DENO_IMPORT_MAP: fileURLToPath(new URL('./node-suites/import-map.json', import.meta.url)), + }, + testTimeout: 15_000, + ...(process.env.DEBUG + ? { + disableConsoleIntercept: true, + silent: false, + } + : {}), + pool: 'threads', + reporters: process.env.DEBUG + ? ['default', { summary: false }] + : process.env.GITHUB_ACTIONS + ? ['dot', 'github-actions'] + : ['verbose'], + }, +}); diff --git a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/test.ts b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/test.ts index 63683b9ab7fd..8873d746159a 100644 --- a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/test.ts @@ -2,6 +2,7 @@ import { mkdirSync, rmdirSync, unlinkSync, writeFileSync } from 'fs'; import * as path from 'path'; import { afterAll, beforeAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; +import { RUNTIME } from '../../../utils'; const EXPECTED_LOCAL_VARIABLES_EVENT = { exception: { @@ -79,14 +80,15 @@ module.exports = { out_of_app_function };`, .completed(); }); - test('Should include local variables when enabled', async () => { + // Bun and Deno: the error events have no local variables. + test.skipIf(RUNTIME !== 'node')('Should include local variables when enabled', async () => { await createRunner(__dirname, 'local-variables.js') .expect({ event: EXPECTED_LOCAL_VARIABLES_EVENT }) .start() .completed(); }); - test('Should include local variables when instrumenting via --import', async () => { + test.skipIf(RUNTIME !== 'node')('Should include local variables when instrumenting via --import', async () => { const instrumentPath = path.resolve(__dirname, 'local-variables-instrument.cjs'); await createRunner(__dirname, 'local-variables-no-sentry.js') @@ -96,7 +98,7 @@ module.exports = { out_of_app_function };`, .completed(); }); - test('Should include local variables with ESM', async () => { + test.skipIf(RUNTIME !== 'node')('Should include local variables with ESM', async () => { await createRunner(__dirname, 'local-variables-caught.mjs') .expect({ event: EXPECTED_LOCAL_VARIABLES_EVENT }) .start() @@ -107,36 +109,39 @@ module.exports = { out_of_app_function };`, await createRunner(__dirname, 'deny-inspector.mjs').ensureNoErrorOutput().start().completed(); }); - test('Should retain original local variables when error is re-thrown', async () => { + test.skipIf(RUNTIME !== 'node')('Should retain original local variables when error is re-thrown', async () => { await createRunner(__dirname, 'local-variables-rethrow.js') .expect({ event: EXPECTED_LOCAL_VARIABLES_EVENT }) .start() .completed(); }); - test('Includes local variables for caught exceptions when enabled', async () => { + test.skipIf(RUNTIME !== 'node')('Includes local variables for caught exceptions when enabled', async () => { await createRunner(__dirname, 'local-variables-caught.js') .expect({ event: EXPECTED_LOCAL_VARIABLES_EVENT }) .start() .completed(); }); - test('Filters local variables by name via dataCollection.stackFrameVariables', async () => { - await createRunner(__dirname, 'local-variables-filtered.js') - .expect({ - event: event => { - const frame = event.exception?.values?.[0]?.stacktrace?.frames?.find(frame => frame.function === 'one'); - - expect(frame?.vars).toEqual({ - name: 'some name', - keepVar: 'keep me', - secretVar: '[Filtered]', - }); - }, - }) - .start() - .completed(); - }); + test.skipIf(RUNTIME !== 'node')( + 'Filters local variables by name via dataCollection.stackFrameVariables', + async () => { + await createRunner(__dirname, 'local-variables-filtered.js') + .expect({ + event: event => { + const frame = event.exception?.values?.[0]?.stacktrace?.frames?.find(frame => frame.function === 'one'); + + expect(frame?.vars).toEqual({ + name: 'some name', + keepVar: 'keep me', + secretVar: '[Filtered]', + }); + }, + }) + .start() + .completed(); + }, + ); test('Does not attach local variables when dataCollection.stackFrameVariables is false', async () => { await createRunner(__dirname, 'local-variables-disabled.js') @@ -151,7 +156,7 @@ module.exports = { out_of_app_function };`, .completed(); }); - test('Should handle different function name formats', async () => { + test.skipIf(RUNTIME !== 'node')('Should handle different function name formats', async () => { await createRunner(__dirname, 'local-variables-name-matching.js') .expect({ event: { @@ -177,30 +182,33 @@ module.exports = { out_of_app_function };`, .completed(); }); - test('adds local variables to out of app frames when includeOutOfAppFrames is true', async () => { - await createRunner(__dirname, 'local-variables-out-of-app.js') - .expect({ - event: event => { - const frames = event.exception?.values?.[0]?.stacktrace?.frames || []; - - const inAppFrame = frames.find(frame => frame.function === 'in_app_function'); - const outOfAppFrame = frames.find(frame => frame.function === 'out_of_app_function'); - - expect(inAppFrame?.vars).toEqual({ inAppVar: 'in app value' }); - expect(inAppFrame?.in_app).toEqual(true); - - expect(outOfAppFrame?.vars).toEqual({ - outOfAppVar: 'out of app value modified value', - passedArg: 'in app value modified value', - }); - expect(outOfAppFrame?.in_app).toEqual(false); - }, - }) - .start() - .completed(); - }); + test.skipIf(RUNTIME !== 'node')( + 'adds local variables to out of app frames when includeOutOfAppFrames is true', + async () => { + await createRunner(__dirname, 'local-variables-out-of-app.js') + .expect({ + event: event => { + const frames = event.exception?.values?.[0]?.stacktrace?.frames || []; + + const inAppFrame = frames.find(frame => frame.function === 'in_app_function'); + const outOfAppFrame = frames.find(frame => frame.function === 'out_of_app_function'); + + expect(inAppFrame?.vars).toEqual({ inAppVar: 'in app value' }); + expect(inAppFrame?.in_app).toEqual(true); + + expect(outOfAppFrame?.vars).toEqual({ + outOfAppVar: 'out of app value modified value', + passedArg: 'in app value modified value', + }); + expect(outOfAppFrame?.in_app).toEqual(false); + }, + }) + .start() + .completed(); + }, + ); - test('does not add local variables to out of app frames by default', async () => { + test.skipIf(RUNTIME !== 'node')('does not add local variables to out of app frames by default', async () => { await createRunner(__dirname, 'local-variables-out-of-app-default.js') .expect({ event: event => { diff --git a/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/test.ts b/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/test.ts index 55e22ba46558..ef52edf7059d 100644 --- a/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/test.ts @@ -2,6 +2,7 @@ import * as childProcess from 'child_process'; import * as path from 'path'; import { describe, expect, test } from 'vitest'; import { createRunner } from '../../../utils/runner'; +import { RUNTIME } from '../../../utils'; describe('OnUncaughtException integration', () => { test('should close process on uncaught error with no additional listeners registered', () => @@ -122,7 +123,8 @@ describe('OnUncaughtException integration', () => { .completed(); }); - describe('Worker thread error handling', () => { + // Bun and Deno: the worker thread errors are not handled as on Node. + describe.skipIf(RUNTIME !== 'node')('Worker thread error handling', () => { test.each(['mjs', 'js'])('should not interfere with worker thread error handling ".%s"', async extension => { const runner = createRunner(__dirname, `worker-thread/caught-worker.${extension}`) .withFlags('--import', path.join(__dirname, `worker-thread/instrument.${extension}`)) diff --git a/dev-packages/node-integration-tests/suites/public-api/logger/test.ts b/dev-packages/node-integration-tests/suites/public-api/logger/test.ts index d81b602e0527..f02c1fc2d384 100644 --- a/dev-packages/node-integration-tests/suites/public-api/logger/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/logger/test.ts @@ -1,6 +1,7 @@ import type { SerializedLog } from '@sentry/core'; import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; +import { EXPECTED_SDK_NAME } from '../../../utils'; const commonAttributes: SerializedLog['attributes'] = { 'sentry.environment': { @@ -13,7 +14,7 @@ const commonAttributes: SerializedLog['attributes'] = { }, 'sentry.sdk.name': { type: 'string', - value: 'sentry.javascript.node', + value: EXPECTED_SDK_NAME, }, 'sentry.sdk.version': { type: 'string', diff --git a/dev-packages/node-integration-tests/suites/public-api/logs/test.ts b/dev-packages/node-integration-tests/suites/public-api/logs/test.ts index 8afc4402475d..cbbb6d309ce7 100644 --- a/dev-packages/node-integration-tests/suites/public-api/logs/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/logs/test.ts @@ -1,5 +1,6 @@ import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; +import { EXPECTED_SDK_NAME } from '../../../utils'; describe('logger public API', () => { afterAll(() => { @@ -21,7 +22,7 @@ describe('logger public API', () => { }, 'sentry.sdk.name': { type: 'string', - value: 'sentry.javascript.node', + value: EXPECTED_SDK_NAME, }, 'sentry.sdk.version': { type: 'string', @@ -62,7 +63,7 @@ describe('logger public API', () => { }, 'sentry.sdk.name': { type: 'string', - value: 'sentry.javascript.node', + value: EXPECTED_SDK_NAME, }, 'sentry.sdk.version': { type: 'string', @@ -103,7 +104,7 @@ describe('logger public API', () => { }, 'sentry.sdk.name': { type: 'string', - value: 'sentry.javascript.node', + value: EXPECTED_SDK_NAME, }, 'sentry.sdk.version': { type: 'string', diff --git a/dev-packages/node-integration-tests/suites/public-api/metrics/server-address-option/test.ts b/dev-packages/node-integration-tests/suites/public-api/metrics/server-address-option/test.ts index dfb3094f1bb9..05dc9da2ae0a 100644 --- a/dev-packages/node-integration-tests/suites/public-api/metrics/server-address-option/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/metrics/server-address-option/test.ts @@ -1,5 +1,6 @@ import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; +import { EXPECTED_SDK_NAME } from '../../../../utils'; describe('metrics server.address', () => { afterAll(() => { @@ -23,7 +24,7 @@ describe('metrics server.address', () => { 'server.address': { value: 'mi-servidor.com', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, }, diff --git a/dev-packages/node-integration-tests/suites/public-api/metrics/server-address/test.ts b/dev-packages/node-integration-tests/suites/public-api/metrics/server-address/test.ts index 86eb295e0c5d..4b59653855aa 100644 --- a/dev-packages/node-integration-tests/suites/public-api/metrics/server-address/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/metrics/server-address/test.ts @@ -1,5 +1,6 @@ import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; +import { EXPECTED_SDK_NAME } from '../../../../utils'; describe('metrics server.address', () => { afterAll(() => { @@ -23,7 +24,7 @@ describe('metrics server.address', () => { 'server.address': { value: expect.any(String), type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.timestamp.sequence': { value: expect.any(Number), type: 'integer' }, }, diff --git a/dev-packages/node-integration-tests/suites/public-api/metrics/test.ts b/dev-packages/node-integration-tests/suites/public-api/metrics/test.ts index 9b266552b052..1e4db38e3f3c 100644 --- a/dev-packages/node-integration-tests/suites/public-api/metrics/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/metrics/test.ts @@ -1,5 +1,6 @@ import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; +import { EXPECTED_SDK_NAME } from '../../../utils'; describe('metrics', () => { afterAll(() => { @@ -22,7 +23,7 @@ describe('metrics', () => { endpoint: { value: '/api/test', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, }, @@ -37,7 +38,7 @@ describe('metrics', () => { server: { value: 'test-1', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, }, @@ -52,7 +53,7 @@ describe('metrics', () => { priority: { value: 'high', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, }, @@ -66,7 +67,7 @@ describe('metrics', () => { operation: { value: 'test', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, }, @@ -83,7 +84,7 @@ describe('metrics', () => { 'user.name': { value: 'testuser', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, }, @@ -121,7 +122,7 @@ describe('metrics', () => { }, 'sentry.sdk.name': { type: 'string', - value: 'sentry.javascript.node', + value: EXPECTED_SDK_NAME, }, 'sentry.sdk.version': { type: 'string', diff --git a/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/test.ts b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/test.ts index 2b90612ed505..0cc15ce97173 100644 --- a/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/test.ts @@ -3,6 +3,7 @@ import * as childProcess from 'child_process'; import * as path from 'path'; import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; +import { RUNTIME } from '../../../utils'; describe('onUnhandledRejectionIntegration', () => { afterAll(() => { @@ -141,7 +142,8 @@ test rejection`); .completed(); }); - test('handles unhandled rejection in spans', async () => { + // Bun: the error event arrives before the transaction, or has a different span id. + test.skipIf(RUNTIME === 'bun')('handles unhandled rejection in spans', async () => { let transactionEvent: Event | undefined; let errorEvent: Event | undefined; @@ -168,7 +170,7 @@ test rejection`); expect(transactionEvent!.contexts!.trace!.span_id).toBe(errorEvent!.contexts!.trace!.span_id); }); - test('handles unhandled rejection in spans that are ended early', async () => { + test.skipIf(RUNTIME === 'bun')('handles unhandled rejection in spans that are ended early', async () => { let transactionEvent: Event | undefined; let errorEvent: Event | undefined; diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts index 3aef47e9c9ba..1d2bd3cd8bfc 100644 --- a/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts @@ -17,6 +17,7 @@ import { } from '@sentry/conventions/attributes'; import { expect, test } from 'vitest'; import { createRunner } from '../../../../utils/runner'; +import { EXPECTED_SDK_NAME } from '../../../../utils'; test('sends a streamed span envelope with correct envelope header', async () => { await createRunner(__dirname, 'scenario.ts') @@ -24,7 +25,7 @@ test('sends a streamed span envelope with correct envelope header', async () => span: { sent_at: expect.any(String), sdk: { - name: 'sentry.javascript.node', + name: EXPECTED_SDK_NAME, version: SDK_VERSION, }, trace: expect.objectContaining({ @@ -63,7 +64,7 @@ test('sends a streamed span envelope with correct spans for a manually started s type: 'string', value: 'test-child', }, - [SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node' }, + [SENTRY_SDK_NAME]: { type: 'string', value: EXPECTED_SDK_NAME }, [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, [SENTRY_SEGMENT_NAME]: { type: 'string', value: 'test-span' }, @@ -87,7 +88,7 @@ test('sends a streamed span envelope with correct spans for a manually started s attributes: { [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, 'sentry.is_localhost': { type: 'boolean', value: false }, - [SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node' }, + [SENTRY_SDK_NAME]: { type: 'string', value: EXPECTED_SDK_NAME }, [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, [SENTRY_SEGMENT_NAME]: { type: 'string', value: 'test-span' }, @@ -124,7 +125,7 @@ test('sends a streamed span envelope with correct spans for a manually started s attributes: { [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, 'sentry.is_localhost': { type: 'boolean', value: false }, - [SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node' }, + [SENTRY_SDK_NAME]: { type: 'string', value: EXPECTED_SDK_NAME }, [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, [SENTRY_SEGMENT_NAME]: { type: 'string', value: 'test-span' }, @@ -147,7 +148,7 @@ test('sends a streamed span envelope with correct spans for a manually started s 'sentry.is_localhost': { type: 'boolean', value: false }, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test' }, [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 }, - [SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node' }, + [SENTRY_SDK_NAME]: { type: 'string', value: EXPECTED_SDK_NAME }, [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, [SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS]: { type: 'array', diff --git a/dev-packages/node-integration-tests/utils/index.ts b/dev-packages/node-integration-tests/utils/index.ts index 6825b51f9452..0e9e09e43d0c 100644 --- a/dev-packages/node-integration-tests/utils/index.ts +++ b/dev-packages/node-integration-tests/utils/index.ts @@ -5,6 +5,18 @@ import { describe } from 'vitest'; export const NODE_VERSION = parseSemver(process.versions.node).major || 0; +/** + * The runtime that runs the scenarios (`node`, `bun` or `deno`), from the `RUNTIME` env var. + * Tests use it in `test.skipIf` for behavior that a runtime does not support. + */ +export const RUNTIME = process.env.RUNTIME || 'node'; + +/** + * The `sdk.name` the scenarios send. It is `sentry.javascript.node`, unless a runtime package + * maps `@sentry/node` to its own SDK and sets the `EXPECTED_SDK_NAME` env var to that SDK's name. + */ +export const EXPECTED_SDK_NAME = process.env.EXPECTED_SDK_NAME || 'sentry.javascript.node'; + export type TestServerConfig = { url: string; server: http.Server; diff --git a/dev-packages/node-integration-tests/utils/runner/createEsmAndCjsTests.ts b/dev-packages/node-integration-tests/utils/runner/createEsmAndCjsTests.ts index d6fea21637cc..94475d97c320 100644 --- a/dev-packages/node-integration-tests/utils/runner/createEsmAndCjsTests.ts +++ b/dev-packages/node-integration-tests/utils/runner/createEsmAndCjsTests.ts @@ -384,11 +384,15 @@ function wrapTestApi( return Reflect.apply(target, thisArg, args); }, - get: (target, prop: 'only' | 'skip' | 'each' | 'for') => { + get: (target, prop: 'only' | 'skip' | 'each' | 'for' | 'runIf' | 'skipIf') => { if (prop === 'only' || prop === 'skip') { return wrapTestApi(target[prop], suffix); } + if (prop === 'runIf' || prop === 'skipIf') { + return (condition: boolean) => wrapTestApi((target as TestAPI)[prop](condition), suffix); + } + if (prop === 'each' || prop === 'for') { return wrapTestEachApi(target[prop], suffix); } diff --git a/dev-packages/node-integration-tests/utils/runner/createRunner.ts b/dev-packages/node-integration-tests/utils/runner/createRunner.ts index bd237a36cae7..3312be42dd8c 100644 --- a/dev-packages/node-integration-tests/utils/runner/createRunner.ts +++ b/dev-packages/node-integration-tests/utils/runner/createRunner.ts @@ -111,9 +111,29 @@ const COMPILE_CACHE_ENV: Record = /** Node flags that preload a module before the entry point. */ const PRELOAD_FLAGS = ['--import', '--require', '-r']; +/** + * How long a scenario may take to report its server port. Bun and Deno start the SDK more slowly + * than Node, and under load they passed 10 seconds, which failed tests that were only slow. + */ +const PORT_TIMEOUT = process.env.RUNTIME && process.env.RUNTIME !== 'node' ? 30_000 : 10_000; + /** tsx's CommonJS require hook, preloaded for `.ts` scenarios. */ const TS_LOADER = 'tsx/cjs'; +type Runtime = 'node' | 'bun' | 'deno'; + +/** + * The runtime that runs the scenario child process, from the `RUNTIME` env var (`node` when unset). + * The Bun and Deno integration test packages set it to run selected Node suites on their runtime. + */ +function getRuntime(): Runtime { + const runtime = process.env.RUNTIME || 'node'; + if (runtime !== 'node' && runtime !== 'bun' && runtime !== 'deno') { + throw new Error(`Unsupported RUNTIME: ${runtime}`); + } + return runtime; +} + export const CLEANUP_STEPS = new Set(); export function cleanupChildProcesses(): void { @@ -272,10 +292,28 @@ export function createRunner(...paths: string[]) { isComplete = true; completeError = error || undefined; - child?.kill(); + stopChild(); completedDeferred.resolve(); } + /** + * Sends `SIGTERM`, and `SIGKILL` if the child still runs 2 seconds later. A scenario (or the + * SDK, e.g. on Vercel) can listen for `SIGTERM`, and on Deno a signal listener keeps the + * process alive, so `SIGTERM` alone left such children running after the test run. + */ + function stopChild(): void { + if (!child || hasExited) { + return; + } + child.kill(); + const killTimer = setTimeout(() => { + if (!hasExited) { + child?.kill('SIGKILL'); + } + }, 2_000); + killTimer.unref(); + } + /** * Print everything the child process wrote to stdout/stderr. Called when a test fails or * times out so the captured output is visible in CI logs. Skipped when `DEBUG` is set, since @@ -433,9 +471,10 @@ export function createRunner(...paths: string[]) { // requests to the fake DSN. const wantsAutoFlush = !ensureNoErrorOutput && (expectedEnvelopes.length > 0 || (expectedEnvelopeHeaders?.length ?? 0) > 0); - const childFlags = wantsAutoFlush ? [...buildAutoFlushFlags(flags, testPath), ...flags] : flags; + const runtime = getRuntime(); + const childFlags = wantsAutoFlush ? [...buildAutoFlushFlags(flags, testPath, runtime), ...flags] : flags; - child = spawn('node', [...childFlags, testPath], { env }); + child = spawn(runtime, buildRuntimeArgs(runtime, childFlags, testPath), { env }); child.on('error', e => { // eslint-disable-next-line no-console @@ -443,8 +482,12 @@ export function createRunner(...paths: string[]) { complete(e); }); + // Cleanup runs after the tests (and at process exit, where timers never fire), so there is no + // graceful stop to wait for. registerCleanupStep(() => { - child?.kill(); + if (!hasExited) { + child?.kill('SIGKILL'); + } }); child.stderr?.on('data', (data: Buffer) => { @@ -570,7 +613,7 @@ export function createRunner(...paths: string[]) { options: { headers?: Record; data?: BodyInit; expectError?: boolean } = {}, ): Promise { try { - await waitForEvent(portReady.promise, 10_000, 'Timed out waiting for server port'); + await waitForEvent(portReady.promise, PORT_TIMEOUT, 'Timed out waiting for server port'); } catch (e) { complete(e as Error); return; @@ -697,17 +740,68 @@ function getPreloadPaths(flags: readonly string[]): string[] { * The CJS loader stays on `--require`: any `--import` makes Node resolve the entry point * through the ESM loader, which rejects the `.ts` scenarios that `tsx/cjs` handles. Unlike * an instrument file it never calls `Sentry.init()`, so the loader thread is not a concern. + * + * Bun and Deno run `.ts` files natively as ES modules, so their `import` of the SDK resolves + * to the ESM build. */ -function buildAutoFlushFlags(existingFlags: readonly string[], testPath: string): string[] { +function buildAutoFlushFlags(existingFlags: readonly string[], testPath: string, runtime: Runtime): string[] { const initPath = getPreloadPaths(existingFlags) .filter(path => path !== TS_LOADER) .at(-1) ?? testPath; - const isEsm = initPath.endsWith('.mjs') || !/\.[cm]?[jt]s$/.test(initPath); + const isEsm = + initPath.endsWith('.mjs') || (runtime !== 'node' && initPath.endsWith('.ts')) || !/\.[cm]?[jt]s$/.test(initPath); return isEsm ? ['--import', join(__dirname, 'auto-flush.mjs')] : ['--require', join(__dirname, 'auto-flush.cjs')]; } +/** + * Returns the arguments for the runtime binary. Bun and Deno get Node's preload flags as + * `--preload`, and no tsx loader. `RUNTIME_PRELOAD` is a module path that Bun and Deno preload + * before all other modules. Deno also gets all permissions and the import map from + * `DENO_IMPORT_MAP`. Any other Node flag throws, so a suite that needs one fails with a clear + * message instead of running with different behavior. + */ +function buildRuntimeArgs(runtime: Runtime, flags: readonly string[], testPath: string): string[] { + if (runtime === 'node') { + return [...flags, testPath]; + } + + const args: string[] = process.env.RUNTIME_PRELOAD ? ['--preload', process.env.RUNTIME_PRELOAD] : []; + for (let i = 0; i < flags.length; i++) { + const flag = flags[i] as string; + const [name, ...rest] = flag.split('='); + + if (!PRELOAD_FLAGS.includes(name as string)) { + throw new Error(`Node flag "${flag}" has no ${runtime} equivalent in the test runner (${testPath})`); + } + + const path = rest.length ? rest.join('=') : (flags[++i] as string); + if (path !== TS_LOADER) { + args.push('--preload', path); + } + } + + if (runtime === 'bun') { + return [...args, testPath]; + } + + const importMap = process.env.DENO_IMPORT_MAP ? ['--import-map', process.env.DENO_IMPORT_MAP] : []; + // Deno loads a `.js` file as ESM unless its package.json has `"type": "commonjs"`, and this + // package sets no `"type"`, so the CJS scenarios need CommonJS detection. + // The workspace symlinks resolve the SDK packages outside `node_modules`, where Deno 2.8.3 + // (the CI version) rejects Node builtins without the `node:` prefix (e.g. `url` in `@sentry/node`). + return [ + 'run', + '--allow-all', + '--unstable-detect-cjs', + '--unstable-bare-node-builtins', + ...importMap, + ...args, + testPath, + ]; +} + function expectErrorEvent(item: Event, expected: ExpectedEvent): void { if (typeof expected === 'function') { expected(item);