Skip to content

Commit 8b67446

Browse files
mydeaclaude
andcommitted
test(e2e): Extract shared bundler instrumentation assertions into test-utils
Move the duplicated per-app assert.mjs into a single, module-parameterized `assertBundlerInstrumentation('graphql')` helper in @sentry-internal/test-utils, collapsing each app's assert to one line. Along the way: - Fix the Vite app to actually inline vs. externalize graphql via ssr.noExternal / ssr.external (rollupOptions.external is inert for Vite SSR builds), so the "inlined" variants exercise the build-time path they claim to. - Assert bundle shape (inlined vs external), scanning every emitted chunk since bundlers split the entry's dynamic import (webpack) into sibling files. - Hand the run result back through a file (SENTRY_E2E_RESULT_FILE) instead of stdout, so a piped, buffered write can't be truncated by the child's exit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2f16810 commit 8b67446

23 files changed

Lines changed: 363 additions & 625 deletions

File tree

Lines changed: 6 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -1,112 +1,7 @@
1-
/**
2-
* Runs the built bundles across the build-time and runtime instrumentation paths and asserts that
3-
* each instrumented scenario emits exactly one set of graphql spans — never zero, never double:
4-
*
5-
* - `plain` (inlined, no plugin, no `--import`): no graphql spans (negative control),
6-
* - `plugin` (inlined, plugin, no `--import`): one set, via build-time injection,
7-
* - `plain-external` (external, no plugin, `--import`): one set, via the runtime hook,
8-
* - `plugin-external` (external, plugin, `--import`): one set — the plugin can't instrument
9-
* an external module, so the runtime hook
10-
* is the sole injector and there is no
11-
* double instrumentation.
12-
*
13-
* "One set" is defined relative to the build-time run (`plugin`), so the count stays correct across
14-
* bundlers and graphql versions. A boot crash surfaces as a non-zero child exit / missing `__RESULT__`
15-
* line, which fails the assert rather than being silently swallowed.
16-
*
17-
* @module
18-
*/
19-
import { execFileSync } from 'node:child_process';
20-
import { existsSync, readFileSync } from 'node:fs';
21-
import { dirname, join } from 'node:path';
22-
import { fileURLToPath } from 'node:url';
1+
import { assertBundlerInstrumentation } from '@sentry-internal/test-utils';
232

24-
const __dirname = dirname(fileURLToPath(import.meta.url));
25-
26-
const GRAPHQL_ORIGIN = 'auto.graphql.diagnostic_channel';
27-
28-
// Entry filename varies by output format (`.mjs` for ESM bundlers, `.cjs` for esbuild's node/CJS output).
29-
function entryPath(name) {
30-
const dir = join(__dirname, 'dist', name);
31-
const entry = ['main.mjs', 'main.cjs', 'main.js'].map(f => join(dir, f)).find(existsSync);
32-
if (!entry) throw new Error(`no built entry (main.mjs/.cjs/.js) found in ${dir}`);
33-
return entry;
34-
}
35-
36-
// `withImport` preloads the SDK's runtime diagnostics-channel hook, so it transforms graphql as Node
37-
// loads it — the mechanism used for external (unbundled) dependencies.
38-
function run(name, { withImport = false } = {}) {
39-
const args = withImport ? ['--import', '@sentry/node/import', entryPath(name)] : [entryPath(name)];
40-
const stdout = execFileSync(process.execPath, args, { encoding: 'utf8', cwd: __dirname });
41-
const line = stdout.split('\n').find(l => l.startsWith('__RESULT__'));
42-
if (!line) {
43-
throw new Error(`${name}${withImport ? ' (--import)' : ''} did not print a __RESULT__ line. Output:\n${stdout}`);
44-
}
45-
return JSON.parse(line.slice('__RESULT__'.length));
46-
}
47-
48-
const graphqlSpanCount = result => result.spans.filter(s => s.origin === GRAPHQL_ORIGIN).length;
49-
50-
// Guards the build config, not just its runtime output. The span assertions below can pass by
51-
// accident when the externalization knob is a no-op (e.g. a Vite SSR build externalizes deps by
52-
// default, so a mis-set toggle silently ships an external graphql in every variant while the counts
53-
// still come out right). Assert the bundle SHAPE instead: an inlined build carries graphql's own
54-
// source (its exported `GraphQLSchema`) and has no bare `graphql` import left; an external build
55-
// keeps the bare `graphql` import/require and never inlines that source. Bundler-agnostic — matches
56-
// ESM `from 'graphql'` and CJS `require('graphql')` alike.
57-
const GRAPHQL_BARE_REFERENCE = /(?:from|require\()\s*['"]graphql['"]/;
58-
const GRAPHQL_SOURCE_MARKER = 'GraphQLSchema';
59-
60-
function assertBundleShape(name, { inlined }) {
61-
const bundle = readFileSync(entryPath(name), 'utf8');
62-
const hasBareReference = GRAPHQL_BARE_REFERENCE.test(bundle);
63-
const hasGraphqlSource = bundle.includes(GRAPHQL_SOURCE_MARKER);
64-
if (inlined) {
65-
check(!hasBareReference && hasGraphqlSource, `${name}: graphql is inlined into the bundle`);
66-
} else {
67-
check(hasBareReference && !hasGraphqlSource, `${name}: graphql is kept external to the bundle`);
68-
}
69-
}
70-
71-
const scenarios = {
72-
plain: run('plain'),
73-
plugin: run('plugin'),
74-
plainExternalImport: run('plain-external', { withImport: true }),
75-
pluginExternalImport: run('plugin-external', { withImport: true }),
76-
};
77-
78-
// One set of graphql spans, established by the build-time run.
79-
const oneSet = graphqlSpanCount(scenarios.plugin);
80-
81-
let failed = false;
82-
function check(condition, message) {
83-
// eslint-disable-next-line no-console
84-
console.log(`${condition ? 'ok ' : 'FAIL'} - ${message}`);
85-
if (!condition) failed = true;
86-
}
87-
88-
for (const [label, result] of Object.entries(scenarios)) {
89-
check(result.data?.hello === 'world', `${label}: graphql query works`);
90-
}
91-
92-
assertBundleShape('plain', { inlined: true });
93-
assertBundleShape('plugin', { inlined: true });
94-
assertBundleShape('plain-external', { inlined: false });
95-
assertBundleShape('plugin-external', { inlined: false });
96-
97-
check(oneSet > 0, 'plugin build (build-time) emits a set of graphql spans');
98-
check(graphqlSpanCount(scenarios.plain) === 0, 'plain build (no plugin, no --import) emits no graphql spans');
99-
check(
100-
graphqlSpanCount(scenarios.plainExternalImport) === oneSet,
101-
`external build + --import emits exactly one set of graphql spans (${oneSet}) via the runtime hook`,
102-
);
103-
check(
104-
graphqlSpanCount(scenarios.pluginExternalImport) === oneSet,
105-
`external build + plugin + --import emits exactly one set of graphql spans (${oneSet}), not double`,
106-
);
107-
108-
if (failed) {
109-
process.exit(1);
110-
}
111-
// eslint-disable-next-line no-console
112-
console.log('All bundle assertions passed.');
3+
// Drives the four built bundles (plain / plugin / plain-external / plugin-external) across the
4+
// build-time and runtime instrumentation paths and asserts exactly one set of graphql spans in each
5+
// instrumented scenario, plus the inlined-vs-external bundle shape. See `assertBundlerInstrumentation`
6+
// in `@sentry-internal/test-utils` for the full matrix.
7+
assertBundlerInstrumentation('graphql');

‎dev-packages/e2e-tests/test-applications/node-esbuild/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"@sentry/bundler-plugins": "file:../../packed/sentry-bundler-plugins-packed.tgz"
1616
},
1717
"devDependencies": {
18+
"@sentry-internal/test-utils": "link:../../../test-utils",
1819
"graphql": "16.9.0",
1920
"esbuild": "0.28.2"
2021
},
Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
// The real workload the bundle instruments: a `graphql` query. `graphql` is inlined into the bundle
2-
// (only node builtins stay external), so the `plugin` build's orchestrion transform can rewrite it.
3-
// graphql 16.x sits in the supported orchestrion range (`>=14.0.0 <17`).
1+
// The real workload the bundle instruments: a `graphql` query. Whether `graphql` is inlined into the
2+
// bundle or kept external is decided per-variant by `build.mjs`; the `plugin` build's orchestrion
3+
// transform rewrites the inlined copy. graphql 16.x sits in the supported orchestrion range
4+
// (`>=14.0.0 <17`). The conventional `runWorkload` export lets the shared `entry.mjs` stay
5+
// library-agnostic.
46
import { buildSchema, graphql } from 'graphql';
57

68
const schema = buildSchema('type Query { hello: String }');
79

8-
export async function runGraphqlQuery() {
10+
export async function runWorkload() {
911
return graphql({ schema, source: '{ hello }', rootValue: { hello: () => 'world' } });
1012
}

‎dev-packages/e2e-tests/test-applications/node-esbuild/src/entry.mjs‎

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
// Bundled entrypoint, run directly with `node` (no `--import` runtime hook). `Sentry.init` runs
2-
// first so the graphql channel subscriber is ready, then the workload is imported and run. Spans are
3-
// collected via the `spanEnd` hook (transport- and trace-lifecycle-independent) and printed as a
4-
// single machine-readable line for `assert.mjs`.
2+
// first so the instrumentation's channel subscriber is ready, then the workload is imported and run.
3+
// Spans are collected via the `spanEnd` hook (transport- and trace-lifecycle-independent) and written
4+
// to the file named by `SENTRY_E2E_RESULT_FILE` for `assert.mjs` to read back. The workload's return
5+
// value rides along as `result` so the assertion can check it without knowing what the workload does.
56
//
67
// The body is an async function rather than top-level await so the same source bundles to both ESM
78
// and CommonJS (esbuild emits CJS for a node target, which disallows top-level await).
9+
import { writeFileSync } from 'node:fs';
810
import * as Sentry from '@sentry/node';
911

1012
async function main() {
@@ -24,18 +26,22 @@ async function main() {
2426
spans.push({ name: json.name, origin: json.attributes?.['sentry.origin'] });
2527
});
2628

27-
const { runGraphqlQuery } = await import('./app.mjs');
29+
const { runWorkload } = await import('./app.mjs');
2830

29-
let data;
30-
await Sentry.startSpan({ name: 'graphql-work' }, async () => {
31-
const result = await runGraphqlQuery();
32-
data = result.data;
31+
let result;
32+
await Sentry.startSpan({ name: 'workload' }, async () => {
33+
result = await runWorkload();
3334
});
3435

3536
await Sentry.flush(2000);
3637

37-
// eslint-disable-next-line no-console
38-
console.log(`__RESULT__${JSON.stringify({ data, spans })}`);
38+
const resultFile = process.env.SENTRY_E2E_RESULT_FILE;
39+
if (!resultFile) {
40+
throw new Error('SENTRY_E2E_RESULT_FILE is required (assertBundlerInstrumentation sets it).');
41+
}
42+
// Write synchronously so the payload is fully flushed before `process.exit`. `console.log` + exit
43+
// can truncate or EPIPE when stdout is a pipe (the exit lands before the buffered write drains).
44+
writeFileSync(resultFile, JSON.stringify({ result, spans }));
3945
process.exit(0);
4046
}
4147

Lines changed: 6 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -1,112 +1,7 @@
1-
/**
2-
* Runs the built bundles across the build-time and runtime instrumentation paths and asserts that
3-
* each instrumented scenario emits exactly one set of graphql spans — never zero, never double:
4-
*
5-
* - `plain` (inlined, no plugin, no `--import`): no graphql spans (negative control),
6-
* - `plugin` (inlined, plugin, no `--import`): one set, via build-time injection,
7-
* - `plain-external` (external, no plugin, `--import`): one set, via the runtime hook,
8-
* - `plugin-external` (external, plugin, `--import`): one set — the plugin can't instrument
9-
* an external module, so the runtime hook
10-
* is the sole injector and there is no
11-
* double instrumentation.
12-
*
13-
* "One set" is defined relative to the build-time run (`plugin`), so the count stays correct across
14-
* bundlers and graphql versions. A boot crash surfaces as a non-zero child exit / missing `__RESULT__`
15-
* line, which fails the assert rather than being silently swallowed.
16-
*
17-
* @module
18-
*/
19-
import { execFileSync } from 'node:child_process';
20-
import { existsSync, readFileSync } from 'node:fs';
21-
import { dirname, join } from 'node:path';
22-
import { fileURLToPath } from 'node:url';
1+
import { assertBundlerInstrumentation } from '@sentry-internal/test-utils';
232

24-
const __dirname = dirname(fileURLToPath(import.meta.url));
25-
26-
const GRAPHQL_ORIGIN = 'auto.graphql.diagnostic_channel';
27-
28-
// Entry filename varies by output format (`.mjs` for ESM bundlers, `.cjs` for esbuild's node/CJS output).
29-
function entryPath(name) {
30-
const dir = join(__dirname, 'dist', name);
31-
const entry = ['main.mjs', 'main.cjs', 'main.js'].map(f => join(dir, f)).find(existsSync);
32-
if (!entry) throw new Error(`no built entry (main.mjs/.cjs/.js) found in ${dir}`);
33-
return entry;
34-
}
35-
36-
// `withImport` preloads the SDK's runtime diagnostics-channel hook, so it transforms graphql as Node
37-
// loads it — the mechanism used for external (unbundled) dependencies.
38-
function run(name, { withImport = false } = {}) {
39-
const args = withImport ? ['--import', '@sentry/node/import', entryPath(name)] : [entryPath(name)];
40-
const stdout = execFileSync(process.execPath, args, { encoding: 'utf8', cwd: __dirname });
41-
const line = stdout.split('\n').find(l => l.startsWith('__RESULT__'));
42-
if (!line) {
43-
throw new Error(`${name}${withImport ? ' (--import)' : ''} did not print a __RESULT__ line. Output:\n${stdout}`);
44-
}
45-
return JSON.parse(line.slice('__RESULT__'.length));
46-
}
47-
48-
const graphqlSpanCount = result => result.spans.filter(s => s.origin === GRAPHQL_ORIGIN).length;
49-
50-
// Guards the build config, not just its runtime output. The span assertions below can pass by
51-
// accident when the externalization knob is a no-op (e.g. a Vite SSR build externalizes deps by
52-
// default, so a mis-set toggle silently ships an external graphql in every variant while the counts
53-
// still come out right). Assert the bundle SHAPE instead: an inlined build carries graphql's own
54-
// source (its exported `GraphQLSchema`) and has no bare `graphql` import left; an external build
55-
// keeps the bare `graphql` import/require and never inlines that source. Bundler-agnostic — matches
56-
// ESM `from 'graphql'` and CJS `require('graphql')` alike.
57-
const GRAPHQL_BARE_REFERENCE = /(?:from|require\()\s*['"]graphql['"]/;
58-
const GRAPHQL_SOURCE_MARKER = 'GraphQLSchema';
59-
60-
function assertBundleShape(name, { inlined }) {
61-
const bundle = readFileSync(entryPath(name), 'utf8');
62-
const hasBareReference = GRAPHQL_BARE_REFERENCE.test(bundle);
63-
const hasGraphqlSource = bundle.includes(GRAPHQL_SOURCE_MARKER);
64-
if (inlined) {
65-
check(!hasBareReference && hasGraphqlSource, `${name}: graphql is inlined into the bundle`);
66-
} else {
67-
check(hasBareReference && !hasGraphqlSource, `${name}: graphql is kept external to the bundle`);
68-
}
69-
}
70-
71-
const scenarios = {
72-
plain: run('plain'),
73-
plugin: run('plugin'),
74-
plainExternalImport: run('plain-external', { withImport: true }),
75-
pluginExternalImport: run('plugin-external', { withImport: true }),
76-
};
77-
78-
// One set of graphql spans, established by the build-time run.
79-
const oneSet = graphqlSpanCount(scenarios.plugin);
80-
81-
let failed = false;
82-
function check(condition, message) {
83-
// eslint-disable-next-line no-console
84-
console.log(`${condition ? 'ok ' : 'FAIL'} - ${message}`);
85-
if (!condition) failed = true;
86-
}
87-
88-
for (const [label, result] of Object.entries(scenarios)) {
89-
check(result.data?.hello === 'world', `${label}: graphql query works`);
90-
}
91-
92-
assertBundleShape('plain', { inlined: true });
93-
assertBundleShape('plugin', { inlined: true });
94-
assertBundleShape('plain-external', { inlined: false });
95-
assertBundleShape('plugin-external', { inlined: false });
96-
97-
check(oneSet > 0, 'plugin build (build-time) emits a set of graphql spans');
98-
check(graphqlSpanCount(scenarios.plain) === 0, 'plain build (no plugin, no --import) emits no graphql spans');
99-
check(
100-
graphqlSpanCount(scenarios.plainExternalImport) === oneSet,
101-
`external build + --import emits exactly one set of graphql spans (${oneSet}) via the runtime hook`,
102-
);
103-
check(
104-
graphqlSpanCount(scenarios.pluginExternalImport) === oneSet,
105-
`external build + plugin + --import emits exactly one set of graphql spans (${oneSet}), not double`,
106-
);
107-
108-
if (failed) {
109-
process.exit(1);
110-
}
111-
// eslint-disable-next-line no-console
112-
console.log('All bundle assertions passed.');
3+
// Drives the four built bundles (plain / plugin / plain-external / plugin-external) across the
4+
// build-time and runtime instrumentation paths and asserts exactly one set of graphql spans in each
5+
// instrumented scenario, plus the inlined-vs-external bundle shape. See `assertBundlerInstrumentation`
6+
// in `@sentry-internal/test-utils` for the full matrix.
7+
assertBundlerInstrumentation('graphql');

‎dev-packages/e2e-tests/test-applications/node-rolldown/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"@sentry/bundler-plugins": "file:../../packed/sentry-bundler-plugins-packed.tgz"
1616
},
1717
"devDependencies": {
18+
"@sentry-internal/test-utils": "link:../../../test-utils",
1819
"graphql": "16.9.0",
1920
"rolldown": "1.2.5"
2021
},
Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
// The real workload the bundle instruments: a `graphql` query. `graphql` is inlined into the bundle
2-
// (only node builtins stay external), so the `plugin` build's orchestrion transform can rewrite it.
3-
// graphql 16.x sits in the supported orchestrion range (`>=14.0.0 <17`).
1+
// The real workload the bundle instruments: a `graphql` query. Whether `graphql` is inlined into the
2+
// bundle or kept external is decided per-variant by `build.mjs`; the `plugin` build's orchestrion
3+
// transform rewrites the inlined copy. graphql 16.x sits in the supported orchestrion range
4+
// (`>=14.0.0 <17`). The conventional `runWorkload` export lets the shared `entry.mjs` stay
5+
// library-agnostic.
46
import { buildSchema, graphql } from 'graphql';
57

68
const schema = buildSchema('type Query { hello: String }');
79

8-
export async function runGraphqlQuery() {
10+
export async function runWorkload() {
911
return graphql({ schema, source: '{ hello }', rootValue: { hello: () => 'world' } });
1012
}

‎dev-packages/e2e-tests/test-applications/node-rolldown/src/entry.mjs‎

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
// Bundled entrypoint, run directly with `node` (no `--import` runtime hook). `Sentry.init` runs
2-
// first so the graphql channel subscriber is ready, then the workload is imported and run. Spans are
3-
// collected via the `spanEnd` hook (transport- and trace-lifecycle-independent) and printed as a
4-
// single machine-readable line for `assert.mjs`.
2+
// first so the instrumentation's channel subscriber is ready, then the workload is imported and run.
3+
// Spans are collected via the `spanEnd` hook (transport- and trace-lifecycle-independent) and written
4+
// to the file named by `SENTRY_E2E_RESULT_FILE` for `assert.mjs` to read back. The workload's return
5+
// value rides along as `result` so the assertion can check it without knowing what the workload does.
56
//
67
// The body is an async function rather than top-level await so the same source bundles to both ESM
78
// and CommonJS (esbuild emits CJS for a node target, which disallows top-level await).
9+
import { writeFileSync } from 'node:fs';
810
import * as Sentry from '@sentry/node';
911

1012
async function main() {
@@ -24,18 +26,22 @@ async function main() {
2426
spans.push({ name: json.name, origin: json.attributes?.['sentry.origin'] });
2527
});
2628

27-
const { runGraphqlQuery } = await import('./app.mjs');
29+
const { runWorkload } = await import('./app.mjs');
2830

29-
let data;
30-
await Sentry.startSpan({ name: 'graphql-work' }, async () => {
31-
const result = await runGraphqlQuery();
32-
data = result.data;
31+
let result;
32+
await Sentry.startSpan({ name: 'workload' }, async () => {
33+
result = await runWorkload();
3334
});
3435

3536
await Sentry.flush(2000);
3637

37-
// eslint-disable-next-line no-console
38-
console.log(`__RESULT__${JSON.stringify({ data, spans })}`);
38+
const resultFile = process.env.SENTRY_E2E_RESULT_FILE;
39+
if (!resultFile) {
40+
throw new Error('SENTRY_E2E_RESULT_FILE is required (assertBundlerInstrumentation sets it).');
41+
}
42+
// Write synchronously so the payload is fully flushed before `process.exit`. `console.log` + exit
43+
// can truncate or EPIPE when stdout is a pipe (the exit lands before the buffered write drains).
44+
writeFileSync(resultFile, JSON.stringify({ result, spans }));
3945
process.exit(0);
4046
}
4147

0 commit comments

Comments
 (0)