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..e1700b84c9cd --- /dev/null +++ b/dev-packages/deno-integration-tests/node-suites/excludes.ts @@ -0,0 +1,72 @@ +// 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']; + +// 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/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/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, + ...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 6e066e54ee1a..19f1c9cf11b2 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..c1bb8ef83855 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 @@ -2,7 +2,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP } from '@sentry/core'; import type { SerializedStreamedSpanContainer } from '@sentry/core'; import { SENTRY_TRACE_LIFECYCLE } from '@sentry/conventions/attributes'; import { afterAll, describe, expect } from 'vitest'; -import { conditionalTest } from '../../../utils'; +import { conditionalTest, RUNTIME } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests, describeWithDockerCompose } from '../../../utils/runner'; // Query-span origin depends on which instrumentation is active. Blocks driving the SDK's default @@ -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. + (RUNTIME === 'deno' ? describe.skip : conditionalTest({ max: 25 }))('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..0bba595a1c1d 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgres/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/postgres/test.ts @@ -1,5 +1,5 @@ import { afterAll, describe, expect } from 'vitest'; -import { conditionalTest } from '../../../utils'; +import { conditionalTest, RUNTIME } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests, describeWithDockerCompose } from '../../../utils/runner'; describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [__dirname] }, () => { @@ -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. + (RUNTIME === 'deno' ? describe.skip : conditionalTest({ max: 25 }))('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/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]);