diff --git a/dev-packages/deno-integration-tests/node-suites/excludes.ts b/dev-packages/deno-integration-tests/node-suites/excludes.ts new file mode 100644 index 000000000000..90e45b4971b6 --- /dev/null +++ b/dev-packages/deno-integration-tests/node-suites/excludes.ts @@ -0,0 +1,79 @@ +// Node suites that do not run on Deno, relative to `node-integration-tests`. A single test that +// fails on Deno is skipped with `test.skipIf` on `RUNTIME` in the Node suite, not listed here. + +// Node-only features: ANR and native thread watchdogs, child process and worker thread breadcrumbs. +const NODE_ONLY = ['suites/anr/test.ts', 'suites/breadcrumbs/**', 'suites/thread-blocked-native/test.ts']; + +// `@sentry/node` instruments `fetch` through undici's diagnostics channels, which Deno's `fetch` +// does not publish. `@sentry/deno` has its own `fetchIntegration` for this. These suites check +// spans, breadcrumbs or headers of outgoing `fetch` requests. +const NO_FETCH_INSTRUMENTATION = [ + 'suites/tracing/double-baggage/**', + 'suites/tracing/http-client-span-streamed/test.ts', + 'suites/tracing/http-client-spans/fetch-basic-streamed/test.ts', + 'suites/tracing/http-client-spans/fetch-basic/test.ts', + 'suites/tracing/http-client-spans/fetch-error/test.ts', + 'suites/tracing/http-client-spans/fetch-forward-request-hook/test.ts', + 'suites/tracing/http-client-spans/fetch-headers-to-span-attributes/test.ts', + 'suites/tracing/http-client-spans/fetch-strip-query/test.ts', + 'suites/tracing/ignoreSpans-streamed/continued-trace-child/test.ts', + 'suites/tracing/ignoreSpans-streamed/continued-trace-http-client/test.ts', + 'suites/tracing/ignoreSpans-streamed/continued-trace-segment/test.ts', + 'suites/tracing/no-parent-span-client-report/test.ts', + 'suites/tracing/requests/fetch-breadcrumbs/test.ts', + 'suites/tracing/requests/fetch-no-trace-propagation/test.ts', + 'suites/tracing/requests/fetch-no-tracing-no-spans/test.ts', + 'suites/tracing/requests/fetch-no-tracing/test.ts', + 'suites/tracing/requests/fetch-sampled-no-active-span/test.ts', + 'suites/tracing/requests/fetch-unsampled/test.ts', + 'suites/tracing/requests/traceparent/test.ts', + 'suites/tracing/sample-rand-propagation/test.ts', + 'suites/tracing/sample-rate-propagation/**', +]; + +// In the ESM tests Deno cannot find `PrismaClient`, a CommonJS export of `@prisma/client`. +const PRISMA_ESM_INTEROP = ['suites/tracing/prisma-orm-v5/test.ts', 'suites/tracing/prisma-orm-v6/test.ts']; + +// In the CJS tests Deno cannot `require()` a dependency that ships only ES modules: `graphql` 17, +// and `escape-string-regexp` under `mastra`. The ESM tests of `mastra` also check `fetch` spans. +const REQUIRE_OF_ESM_ONLY_DEPENDENCY = ['suites/tracing/graphql-tracing-channel/**', 'suites/tracing/mastra/test.ts']; + +// Some or all tests fail on Deno, cause not investigated yet. In most AI suites the span +// streaming test fails. `apollo-graphql` (CJS tests only) and `mongodb` fail on Deno 2.8.3 (the CI +// version) and pass on Deno 2.9.0. +const NOT_TRIAGED = [ + 'suites/tracing/anthropic/test.ts', + 'suites/tracing/apollo-graphql/**', + 'suites/tracing/fastify/test.ts', + 'suites/tracing/flue/test.ts', + 'suites/tracing/google-genai/test.ts', + 'suites/tracing/groq/test.ts', + 'suites/tracing/http-client-spans/http-strip-query/test.ts', + 'suites/tracing/ioredis-dc/test.ts', + 'suites/tracing/koa/test.ts', + 'suites/tracing/langchain/test.ts', + 'suites/tracing/langgraph/test.ts', + 'suites/tracing/mistral/test.ts', + 'suites/tracing/mongodb/test.ts', + 'suites/tracing/mongoose-v5/test.ts', + 'suites/tracing/mysql/test.ts', + 'suites/tracing/openai/test.ts', + 'suites/tracing/openai/v7/test.ts', + 'suites/tracing/orchestrion-lazy-registration/test.ts', + 'suites/tracing/prisma-orm-v7/test.ts', + 'suites/tracing/together-ai/test.ts', + 'suites/tracing/vercelai/v6_v7/test.ts', +]; + +// Passes on Deno when run alone, but failed in about 1 of 3 full runs of this package. +const FLAKY = ['suites/tracing/tracePropagationTargets/**']; + +export const NODE_SUITES_EXCLUDE = [ + '**/node_modules/**', + ...NODE_ONLY, + ...NO_FETCH_INSTRUMENTATION, + ...PRISMA_ESM_INTEROP, + ...REQUIRE_OF_ESM_ONLY_DEPENDENCY, + ...NOT_TRIAGED, + ...FLAKY, +]; diff --git a/dev-packages/deno-integration-tests/package.json b/dev-packages/deno-integration-tests/package.json index 9cfb31eef59a..f2b8cc9335e9 100644 --- a/dev-packages/deno-integration-tests/package.json +++ b/dev-packages/deno-integration-tests/package.json @@ -11,8 +11,8 @@ "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:node-suites", - "test:unit": "deno test --allow-net --allow-read --allow-run --allow-env --no-check", + "test": "node ./scripts/run-tests.mjs", + "test:unit": "deno test --allow-net --allow-read --allow-run --allow-env --no-check suites", "test:node-suites": "vitest run" }, "dependencies": { diff --git a/dev-packages/deno-integration-tests/scripts/run-tests.mjs b/dev-packages/deno-integration-tests/scripts/run-tests.mjs new file mode 100644 index 000000000000..dd6114395553 --- /dev/null +++ b/dev-packages/deno-integration-tests/scripts/run-tests.mjs @@ -0,0 +1,27 @@ +// `yarn test` runs the Deno-only suites (`deno test`) and then the shared Node suites (vitest), +// like the other integration test packages. +// +// `yarn test ` runs only the shared Node suites with that filter, e.g. `yarn test express`, +// because `deno test` does not take vitest filters. To filter the Deno-only suites by test name, +// run `yarn test:unit --filter `. +import { spawnSync } from 'node:child_process'; + +const filters = process.argv.slice(2); + +function run(script, args = []) { + const result = spawnSync('yarn', ['--silent', script, ...args], { + stdio: 'inherit', + shell: process.platform === 'win32', + }); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +if (filters.length === 0) { + run('install:deno'); + run('deno-types'); + run('test:unit'); +} + +run('test:node-suites', filters); diff --git a/dev-packages/deno-integration-tests/vite.config.mts b/dev-packages/deno-integration-tests/vite.config.mts index fd87eb1f2d26..6dccfdc74a5e 100644 --- a/dev-packages/deno-integration-tests/vite.config.mts +++ b/dev-packages/deno-integration-tests/vite.config.mts @@ -1,8 +1,9 @@ import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitest/config'; import baseConfig from '../../vite/vite.config'; +import { NODE_SUITES_EXCLUDE } from './node-suites/excludes'; -// Runs the Node suites below on Deno. The scenarios stay in `node-integration-tests`, and the +// Runs all Node suites on Deno. The scenarios stay in `node-integration-tests`, and the // Deno-only suites in `suites/` run with `deno test`. export default defineConfig({ ...baseConfig, @@ -13,16 +14,8 @@ export default defineConfig({ 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/**'], + include: ['suites/**/test.ts'], + exclude: NODE_SUITES_EXCLUDE, env: { RUNTIME: 'deno', DENO_IMPORT_MAP: fileURLToPath(new URL('./node-suites/import-map.json', import.meta.url)), diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts index 76c62fdf7bd3..afd5cbe927a6 100644 --- a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts @@ -2,6 +2,7 @@ import { createTestServer } from '@sentry-internal/test-utils'; import { URL_FULL, URL_PATH } from '@sentry/conventions/attributes'; import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests, createRunner } from '../../../utils/runner'; +import { RUNTIME } from '../../../utils'; function getCommonHttpRequestHeaders(): Record { return { @@ -173,7 +174,8 @@ describe('httpIntegration', () => { }); }); - describe('custom server.emit', () => { + // Deno: the requests sometimes get a 500 response when `server.emit` is overwritten. + describe.skipIf(RUNTIME === 'deno')('custom server.emit', () => { createEsmAndCjsTests( __dirname, 'scenario-overwrite-server-emit.mjs', diff --git a/dev-packages/node-integration-tests/suites/tracing/postgres-streamed/test.ts b/dev-packages/node-integration-tests/suites/tracing/postgres-streamed/test.ts index 645fe2ff8d12..92a48d809282 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgres-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/postgres-streamed/test.ts @@ -227,7 +227,8 @@ describeWithDockerCompose('postgres auto instrumentation (streamed)', { workingD }); }); - conditionalTest({ max: 25 })('pg-native', () => { + // Deno: with a module load hook installed, Deno compiles a native addon (`libpq`) as JavaScript. + conditionalTest({ max: 25, skipRuntimes: ['deno'] })('pg-native', () => { createEsmAndCjsTests( __dirname, 'scenario-native.mjs', diff --git a/dev-packages/node-integration-tests/suites/tracing/postgres/test.ts b/dev-packages/node-integration-tests/suites/tracing/postgres/test.ts index ce96a147a315..304af1aaffb0 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgres/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/postgres/test.ts @@ -276,7 +276,8 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [ }); }); - conditionalTest({ max: 25 })('pg-native', () => { + // Deno: with a module load hook installed, Deno compiles a native addon (`libpq`) as JavaScript. + conditionalTest({ max: 25, skipRuntimes: ['deno'] })('pg-native', () => { const EXPECTED_TRANSACTION = { transaction: 'Test Transaction', spans: expect.arrayContaining([ diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs/test.ts b/dev-packages/node-integration-tests/suites/tracing/postgresjs/test.ts index 7e99d58dedff..d893e5200004 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgresjs/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/test.ts @@ -488,7 +488,9 @@ describeWithDockerCompose('postgresjs auto instrumentation', { workingDirectory: createEsmAndCjsTests(__dirname, 'scenario-unsafe.mjs', 'instrument.mjs', (createTestRunner, test) => { test('should instrument sql.unsafe() queries', { timeout: 90_000 }, async () => { - await createTestRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); + // The last query fails on purpose, and its unhandled rejection also sends an error event, which can + // arrive before the transaction. + await createTestRunner().ignore('event').expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); }); }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/tracer-start-active-span-error/test.ts b/dev-packages/node-integration-tests/suites/tracing/tracer-start-active-span-error/test.ts index 64809c28aa24..c315cf866900 100644 --- a/dev-packages/node-integration-tests/suites/tracing/tracer-start-active-span-error/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/tracer-start-active-span-error/test.ts @@ -16,6 +16,9 @@ describe('tracer.startActiveSpan errors', () => { // `catch` inside the callback. test('does NOT mark span errored when uncaught error escapes raw tracer.startActiveSpan callback', async () => { await createRunner() + // The rejection is captured as an error event, which can reach the transport before the + // transaction. + .ignore('event') .expect({ transaction: { transaction: 'test span name', diff --git a/dev-packages/node-integration-tests/utils/index.ts b/dev-packages/node-integration-tests/utils/index.ts index 054fa9b28cc6..20dc8513b28e 100644 --- a/dev-packages/node-integration-tests/utils/index.ts +++ b/dev-packages/node-integration-tests/utils/index.ts @@ -42,20 +42,27 @@ export type DataCollectorOptions = { }; /** - * Returns`describe` or `describe.skip` depending on allowed major versions of Node. + * Returns`describe` or `describe.skip` depending on allowed major versions of Node and on the + * runtime that runs the scenarios. * - * @param {{ min?: number; max?: number }} allowedVersion + * @param options.min Lowest Node major version that runs the block. + * @param options.max Highest Node major version that runs the block. + * @param options.skipRuntimes Runtimes that skip the block, for a block that cannot run there. */ -export function conditionalTest(allowedVersion: { +export function conditionalTest(options: { min?: number; max?: number; + skipRuntimes?: Runtime[]; }): typeof describe | typeof describe.skip { + if (options.skipRuntimes?.includes(RUNTIME)) { + return describe.skip; + } // Vitest always runs on Node, so its Node version says nothing about Bun or Deno running the // scenario. Those runtimes list the suites they cannot run in their own exclude lists. if (RUNTIME !== 'node') { return describe; } - return describe.skipIf(!matchesNodeVersion(allowedVersion)); + return describe.skipIf(!matchesNodeVersion(options)); } function matchesNodeVersion({ min, max }: { min?: number; max?: number }): boolean { diff --git a/dev-packages/node-integration-tests/utils/runner/createRunner.ts b/dev-packages/node-integration-tests/utils/runner/createRunner.ts index 3312be42dd8c..e737cfa4be8e 100644 --- a/dev-packages/node-integration-tests/utils/runner/createRunner.ts +++ b/dev-packages/node-integration-tests/utils/runner/createRunner.ts @@ -19,6 +19,7 @@ import { existsSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { inspect } from 'util'; +import { onTestFailed } from 'vitest'; import type { DeepPartial } from './../assertions'; import { assertEnvelopeHeader, @@ -108,6 +109,10 @@ const NODE_MAJOR = Number(process.versions.node.split('.')[0]); const COMPILE_CACHE_ENV: Record = NODE_MAJOR >= 22 ? { NODE_COMPILE_CACHE: join(tmpdir(), 'sentry-node-it-compile-cache') } : {}; +// The Bun and Deno packages run these suites from their own folder. Scenarios read the working +// directory (e.g. `modulesIntegration` reads its `package.json`), so it is always this package. +const PACKAGE_ROOT = join(__dirname, '..', '..'); + /** Node flags that preload a module before the entry point. */ const PRELOAD_FLAGS = ['--import', '--require', '-r']; @@ -277,7 +282,11 @@ export function createRunner(...paths: string[]) { let envelopeCount = 0; let scenarioServerPort: number | undefined; let hasExited = false; + let exitStatus: string | undefined; let child: ReturnType | undefined; + let spawnedAt: number | undefined; + let lastOutputAt: number | undefined; + let logsDumped = false; // Resolved the moment `complete()` runs, so `completed()` can await the result directly // instead of polling — see the comment on `waitForEvent`. @@ -285,6 +294,14 @@ export function createRunner(...paths: string[]) { // Resolved once the scenario reports its server port, so `makeRequest` can await it directly. const portReady = createDeferred(); + // Vitest stops a test at its own timeout before `completed()` gives up, so print the child + // output then too. `completed()` prints it for the failures it reports itself. + onTestFailed(() => { + if (!isComplete) { + dumpCapturedLogs(); + } + }); + function complete(error?: Error): void { if (isComplete) { return; @@ -322,12 +339,23 @@ export function createRunner(...paths: string[]) { function dumpCapturedLogs(): void { // Skip when the failure is expected (`test.fails` variants) — the output would just be noise. // In debug mode the same lines are already streamed live, so skip then too. - if (process.env.DEBUG || suppressErrorLogs) { + if (process.env.DEBUG || suppressErrorLogs || logsDumped) { return; } + logsDumped = true; + const now = Date.now(); + const state = [ + `runtime=${getRuntime()}`, + `pid=${child?.pid ?? 'none'}`, + hasExited ? `exited (${exitStatus})` : 'running', + `envelopes=${envelopeCount}/${expectedEnvelopeCount}`, + `ms since spawn=${spawnedAt ? now - spawnedAt : 'not spawned'}`, + `ms since last output=${lastOutputAt ? now - lastOutputAt : 'no output'}`, + ].join(', '); + // eslint-disable-next-line no-console - console.log(`\n--- Captured child process output for ${testPath} ---`); + console.log(`\n--- Captured child process output for ${testPath} (${state}) ---`); if (logs.length === 0) { // eslint-disable-next-line no-console console.log('(no output captured)'); @@ -474,7 +502,8 @@ export function createRunner(...paths: string[]) { const runtime = getRuntime(); const childFlags = wantsAutoFlush ? [...buildAutoFlushFlags(flags, testPath, runtime), ...flags] : flags; - child = spawn(runtime, buildRuntimeArgs(runtime, childFlags, testPath), { env }); + child = spawn(runtime, buildRuntimeArgs(runtime, childFlags, testPath), { env, cwd: PACKAGE_ROOT }); + spawnedAt = Date.now(); child.on('error', e => { // eslint-disable-next-line no-console @@ -491,6 +520,7 @@ export function createRunner(...paths: string[]) { }); child.stderr?.on('data', (data: Buffer) => { + lastOutputAt = Date.now(); const output = data.toString(); logs.push(output.trim()); @@ -504,6 +534,7 @@ export function createRunner(...paths: string[]) { child.on('close', (code, signal) => { hasExited = true; + exitStatus = signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`; if (ensureNoErrorOutput) { complete(); @@ -562,6 +593,7 @@ export function createRunner(...paths: string[]) { let buffer = Buffer.alloc(0); child.stdout?.on('data', (data: Buffer) => { + lastOutputAt = Date.now(); // This is horribly memory inefficient but it's only for tests buffer = Buffer.concat([buffer, data]);