diff --git a/packages/solidstart/README.md b/packages/solidstart/README.md index 28127c336c0d..68872f24e95a 100644 --- a/packages/solidstart/README.md +++ b/packages/solidstart/README.md @@ -24,6 +24,15 @@ SDK is for [SolidStart](https://start.solidjs.com/). If you're using [Solid](htt This package is a wrapper around `@sentry/node` for the server and `@sentry/solid` for the client side, with added functionality related to SolidStart. +## SolidStart version support + +The setup differs by SolidStart major, because SolidStart 2 dropped vinxi and `app.config.ts`: + +- **SolidStart 1** — configure the SDK with `withSentry` in `app.config.ts`. This is what the "Manual Setup" section + below describes. +- **SolidStart 2** — configure the SDK with the `sentrySolidStart` Vite plugin in `vite.config.ts`. See + [SolidStart 2 setup](#solidstart-2-setup). + ## Manual Setup If the setup through the wizard doesn't work for you, you can also set up the SDK manually. @@ -189,6 +198,62 @@ export default defineConfig( This has a **fundamental restriction**: It only supports limited performance instrumentation. **Only basic http instrumentation** will work, and no DB or framework-specific instrumentation will be available. +# SolidStart 2 setup + +SolidStart 2 has no `app.config.ts`, so `withSentry` does not apply. Configure the SDK with the `sentrySolidStart` +Vite plugin instead. Client-side setup (`Sentry.init` in `entry-client.tsx`) and the Solid Router and +`ErrorBoundary` wrappers below are unchanged. + +### 1. Add the Vite plugin + +```typescript +// vite.config.ts +import { sentrySolidStart } from '@sentry/solidstart/vite'; +import { solidStart } from '@solidjs/start/config'; +import { nitro } from 'nitro/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [ + solidStart(), + sentrySolidStart({ + org: process.env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT, + authToken: process.env.SENTRY_AUTH_TOKEN, + }), + // `serverDir` is required for Nitro to pick up the Sentry plugin below. + nitro({ serverDir: './server' }), + ], +}); +``` + +### 2. Initialize Sentry on the server + +Create a Nitro plugin. Nitro runs it once at server startup, before any request is handled: + +```typescript +// server/plugins/sentry.ts +import * as Sentry from '@sentry/solidstart'; +import { definePlugin } from 'nitro'; + +export default definePlugin(() => { + Sentry.init({ + dsn: '__PUBLIC_DSN__', + tracesSampleRate: 1.0, + }); +}); +``` + +Nitro's `serverDir` defaults to `false`, which disables plugin scanning entirely. If it is not set (step 1), this +file is silently ignored and Sentry never initializes on the server. + +Unlike SolidStart 1, there is **no `--import` flag and no instrumentation file to copy** into the build output. The +SDK instruments your server dependencies at build time, so a plain start command is all that is needed: + +```bash +node .output/server/index.mjs +``` + # Solid Router The Solid Router instrumentation uses the Solid Router library to create navigation spans to ensure you collect diff --git a/packages/solidstart/package.json b/packages/solidstart/package.json index 49974011fc08..1a8cf4b90f1a 100644 --- a/packages/solidstart/package.json +++ b/packages/solidstart/package.json @@ -37,6 +37,10 @@ "node": { "import": "./build/esm/index.server.js", "require": "./build/cjs/index.server.js" + }, + "default": { + "import": "./build/esm/index.server.js", + "require": "./build/cjs/index.server.js" } }, "./solidrouter": { @@ -50,15 +54,25 @@ "types": "./solidrouter.d.ts", "import": "./build/esm/solidrouter.server.js", "require": "./build/cjs/solidrouter.server.js" + }, + "default": { + "types": "./solidrouter.d.ts", + "import": "./build/esm/solidrouter.server.js", + "require": "./build/cjs/solidrouter.server.js" } + }, + "./vite": { + "types": "./build/types/vite/index.d.ts", + "import": "./build/esm/vite/index.js", + "require": "./build/cjs/vite/index.js" } }, "publishConfig": { "access": "public" }, "peerDependencies": { - "@solidjs/router": "^0.13.4 || ^0.14.0 || ^0.15.0", - "@solidjs/start": "^1.0.0" + "@solidjs/router": "^0.13.4 || ^0.14.0 || ^0.15.0 || ^1.0.0", + "@solidjs/start": "^1.0.0 || ^2.0.0" }, "peerDependenciesMeta": { "@solidjs/router": { @@ -67,11 +81,11 @@ }, "dependencies": { "@sentry/core": "10.67.0", + "@sentry/nitro": "10.67.0", "@sentry/node": "10.67.0", "@sentry/server-utils": "10.67.0", "@sentry/solid": "10.67.0", "@sentry/bundler-plugins": "10.67.0", - "@sentry/server-utils": "10.67.0", "@sentry/conventions": "0.16.0" }, "devDependencies": { diff --git a/packages/solidstart/rollup.npm.config.mjs b/packages/solidstart/rollup.npm.config.mjs index 6723ed69845b..d28b29896b10 100644 --- a/packages/solidstart/rollup.npm.config.mjs +++ b/packages/solidstart/rollup.npm.config.mjs @@ -12,6 +12,7 @@ export default makeNPMConfigVariants( 'src/solidrouter.server.ts', 'src/client/solidrouter.ts', 'src/server/solidrouter.ts', + 'src/vite/index.ts', ], // prevent this internal code from ending up in our built package (this doesn't happen automatically because // the name doesn't match an SDK dependency) diff --git a/packages/solidstart/src/server/withServerActionInstrumentation.ts b/packages/solidstart/src/server/withServerActionInstrumentation.ts index 985a44dcc6d0..a8894af4e3f4 100644 --- a/packages/solidstart/src/server/withServerActionInstrumentation.ts +++ b/packages/solidstart/src/server/withServerActionInstrumentation.ts @@ -6,7 +6,7 @@ import { } from '@sentry/core'; import { captureException, getActiveSpan, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, spanToJSON, startSpan } from '@sentry/node'; import { isRedirect } from './utils'; -import { HTTP_ROUTE, HTTP_TARGET } from '@sentry/conventions/attributes'; +import { HTTP_ROUTE, HTTP_TARGET, URL_PATH } from '@sentry/conventions/attributes'; import { setHttpServerSpanRouteAttribute } from '@sentry/server-utils'; /** @@ -27,8 +27,12 @@ export async function withServerActionInstrumentation; + +/** + * Vite plugins for the Sentry SolidStart SDK. Requires SolidStart 2. + * + * On SolidStart 1, use `withSentry` in `app.config.ts` instead. + * + * @example + * ```typescript + * // vite.config.ts + * import { sentrySolidStart } from '@sentry/solidstart/vite'; + * import { solidStart } from '@solidjs/start/config'; + * import { nitro } from 'nitro/vite'; + * import { defineConfig } from 'vite'; + * + * export default defineConfig({ + * plugins: [ + * solidStart(), + * sentrySolidStart({ + * org: 'your-org', + * project: 'your-project', + * }), + * nitro(), + * ], + * }); + * ``` + * + * @param options - Options to configure the Sentry Vite plugins + * @returns An array of Vite plugins + */ +export function sentrySolidStart(options: SentrySolidStartOptions = {}): Plugin[] { + const plugins: Plugin[] = [makeSentryNitroPlugin(options)]; + + // Only the Nitro plugin is dev-safe; its module handles `dev` itself. + if (process.env.NODE_ENV === 'development') { + return plugins; + } + + // Injects `diagnostics_channel` publishers into instrumented deps at build time, which is what + // lets `Sentry.init()` run from a bundled Nitro plugin rather than an `--import` preload. + plugins.push(sentryOrchestrionPlugin({ buildTimeInstrumentation: options.buildTimeInstrumentation })); + + if (options.sourcemaps?.disable !== true) { + plugins.push(...makeAddSentryVitePluginSolidStart2(options), ...makeEnableSourceMapsVitePlugin(options)); + } + + return plugins; +} + +// Nitro's `NitroConfig` only matches `@sentry/nitro`'s when both resolve the same `nitro` install, +// which is not guaranteed, so the key stays opaque rather than coupling the two copies. +type ViteConfigWithNitro = UserConfig & { nitro?: Record }; + +/** + * Delivers everything only Nitro can reach — server source maps, runtime hooks, the + * `sourcemapMinify` opt-out — through Vite's `nitro` key. + * + * `setupSentryNitroModule` is handed only the keys it reads, never the user's whole config, so what + * comes back is purely Sentry's additions. Vite concatenates arrays when merging a `config` return + * value, so echoing the user's own `modules`/`plugins` back would duplicate every entry. + * + * `enforce: 'pre'` is load-bearing: Nitro creates its instance inside its own `config` hook, so a + * normal-priority hook sorting after it would be read too late and silently ignored. + */ +function makeSentryNitroPlugin(options: SentrySolidStartOptions): Plugin { + return { + name: 'sentry-solidstart-nitro', + enforce: 'pre', + config(userConfig: ViteConfigWithNitro) { + const userNitro = userConfig.nitro; + + return { + nitro: setupSentryNitroModule( + // `sourcemap` decides whether Sentry enables its own; `tracingChannel` is left as the user set it. + { sourcemap: userNitro?.sourcemap, tracingChannel: userNitro?.tracingChannel } as Parameters< + typeof setupSentryNitroModule + >[0], + options, + ) as Record, + } as Omit; + }, + }; +} diff --git a/packages/solidstart/src/vite/sourceMaps.ts b/packages/solidstart/src/vite/sourceMaps.ts index 95ccfe3a658e..f9f11038f08a 100644 --- a/packages/solidstart/src/vite/sourceMaps.ts +++ b/packages/solidstart/src/vite/sourceMaps.ts @@ -1,7 +1,13 @@ import { sentryVitePlugin } from '@sentry/bundler-plugins/vite'; import type { Plugin, UserConfig } from 'vite'; +import type { SentrySolidStartOptions } from './sentrySolidStart'; import type { SentrySolidStartPluginOptions } from './types'; +// `debug` is all the source map setting logic needs, and all the two majors' option types share. +type SourceMapSettingOptions = { debug?: boolean }; + +type FilesToDeleteAfterUpload = string | string[] | undefined; + /** * A Sentry plugin for adding the @sentry/bundler-plugins/vite plugin to automatically upload source maps to Sentry. */ @@ -55,19 +61,106 @@ export function makeAddSentryVitePlugin(options: SentrySolidStartPluginOptions, } /** - * A Sentry plugin for SolidStart to enable "hidden" source maps if they are unset. + * SolidStart 2 counterpart of `makeAddSentryVitePlugin`, reading the flat `BuildTimeOptionsBase` + * fields rather than the nested `sourceMapsUploadOptions`. + * + * Covers the Vite-built client assets only; Nitro emits the server bundle outside Vite's output dir + * and the Sentry Nitro module uploads that. */ -export function makeEnableSourceMapsVitePlugin(options: SentrySolidStartPluginOptions): Plugin[] { +export function makeAddSentryVitePluginSolidStart2(options: SentrySolidStartOptions): Plugin[] { + // Everything not destructured out is field-for-field what `sentryVitePlugin` accepts, so it is + // spread through — a new shared option then reaches the plugin without editing a list here. + const { + authToken, + buildTimeInstrumentation: _buildTimeInstrumentation, + debug, + org, + project, + sentryUrl, + sourcemaps, + telemetry, + unstable_sentryVitePluginOptions, + ...passthroughOptions + } = options; + + // Deferred because the default depends on whether the user set `build.sourcemap` themselves, + // which is only known once Vite resolves its config. `PromiseLike` because the unstable spelling + // may itself be a promise. + let resolveFilesToDeleteAfterUpload: + | ((value: FilesToDeleteAfterUpload | PromiseLike) => void) + | undefined; + const filesToDeleteAfterUploadPromise = new Promise(resolve => { + resolveFilesToDeleteAfterUpload = resolve; + }); + + const configPlugin: Plugin = { + name: 'sentry-solidstart-files-to-delete-after-upload', + apply: 'build', + enforce: 'post', + config(config) { + // The promise always wins over the passed-in value, so the unstable spelling has to be read + // here too or it is silently replaced by the default below. + const userFilesToDelete = + sourcemaps?.filesToDeleteAfterUpload ?? unstable_sentryVitePluginOptions?.sourcemaps?.filesToDeleteAfterUpload; + + // Only clean up source maps we turned on ourselves. + if (typeof userFilesToDelete === 'undefined' && typeof config.build?.sourcemap === 'undefined') { + if (debug) { + // eslint-disable-next-line no-console + console.log( + '[Sentry] Automatically setting `sourcemaps.filesToDeleteAfterUpload: ["./**/*.map"]` to delete generated source maps after they were uploaded to Sentry.', + ); + } + resolveFilesToDeleteAfterUpload?.(['./**/*.map']); + } else { + resolveFilesToDeleteAfterUpload?.(userFilesToDelete); + } + }, + }; + + const sentryPlugins = sentryVitePlugin({ + ...passthroughOptions, + authToken: authToken ?? process.env.SENTRY_AUTH_TOKEN, + debug: debug ?? false, + org: org ?? process.env.SENTRY_ORG, + project: project ?? process.env.SENTRY_PROJECT, + telemetry: telemetry ?? true, + url: sentryUrl, + // Spread here so it overrides the plain options above, but not the objects merged below — + // spreading replaces whole keys rather than deep-merging. + ...unstable_sentryVitePluginOptions, + sourcemaps: { + ...sourcemaps, + ...unstable_sentryVitePluginOptions?.sourcemaps, + filesToDeleteAfterUpload: filesToDeleteAfterUploadPromise, + }, + _metaOptions: { + ...unstable_sentryVitePluginOptions?._metaOptions, + telemetry: { + ...unstable_sentryVitePluginOptions?._metaOptions?.telemetry, + metaFramework: 'solidstart', + }, + }, + }); + + return [configPlugin, ...sentryPlugins]; +} + +/** + * A Sentry plugin for SolidStart to enable "hidden" source maps if they are unset. Used by both + * SolidStart majors. + */ +export function makeEnableSourceMapsVitePlugin(options: SourceMapSettingOptions): Plugin[] { return [ { name: 'sentry-solidstart-update-source-map-setting', apply: 'build', enforce: 'post', config(viteConfig) { + // Return only what changed: Vite concatenates arrays when merging a `config` return value, + // so echoing the whole config back would duplicate every array the user had. return { - ...viteConfig, build: { - ...viteConfig.build, sourcemap: getUpdatedSourceMapSettings(viteConfig, options), }, }; @@ -93,10 +186,8 @@ export function makeEnableSourceMapsVitePlugin(options: SentrySolidStartPluginOp */ export function getUpdatedSourceMapSettings( viteConfig: UserConfig, - sentryPluginOptions?: SentrySolidStartPluginOptions, + sentryPluginOptions?: SourceMapSettingOptions, ): boolean | 'inline' | 'hidden' { - viteConfig.build = viteConfig.build || {}; - const viteSourceMap = viteConfig?.build?.sourcemap; let updatedSourceMapSetting = viteSourceMap; diff --git a/packages/solidstart/test/server/withServerActionInstrumentation.test.ts b/packages/solidstart/test/server/withServerActionInstrumentation.test.ts index e304e3425e2b..0c8188e02c90 100644 --- a/packages/solidstart/test/server/withServerActionInstrumentation.test.ts +++ b/packages/solidstart/test/server/withServerActionInstrumentation.test.ts @@ -150,6 +150,47 @@ describe('withServerActionInstrumentation', () => { expect(mockSpanSetAttribute).to.toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); }); + // `@sentry/node`'s HTTP spans only carry `url.path`, so gating on `http.target` alone silently + // skipped the rename. + it('sets a server action name on the active span when the path is on `url.path`', async () => { + const span = new SentryCore.SentrySpan({ + attributes: { + 'url.path': '/_server', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', + }, + }); + mockGetActiveSpan.mockReturnValue(span); + const mockSpanSetAttribute = vi.spyOn(span, 'setAttribute'); + + const getPrefecture = async function load() { + return withServerActionInstrumentation('getPrefecture', () => { + return { prefecture: 'Kagoshima' }; + }); + }; + + await getPrefecture(); + + expect(mockSpanSetAttribute).to.toHaveBeenCalledWith('http.route', 'getPrefecture'); + expect(mockSpanSetAttribute).to.toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + }); + + it('does not set a server action name if the active span had a non `/_server` `url.path`', async () => { + const span = new SentryCore.SentrySpan(); + span.setAttribute('url.path', '/users/5'); + mockGetActiveSpan.mockReturnValue(span); + const mockSpanSetAttribute = vi.spyOn(span, 'setAttribute'); + + const getPrefecture = async function load() { + return withServerActionInstrumentation('getPrefecture', () => { + return { prefecture: 'Kagoshima' }; + }); + }; + + await getPrefecture(); + + expect(mockSpanSetAttribute).not.toHaveBeenCalledWith('http.route', 'getPrefecture'); + }); + it('does not set a server action name if the active span had a non `/_server` target', async () => { const span = new SentryCore.SentrySpan(); span.setAttribute('http.target', '/users/5'); diff --git a/packages/solidstart/test/vite/sentrySolidStart.test.ts b/packages/solidstart/test/vite/sentrySolidStart.test.ts new file mode 100644 index 000000000000..75076c3ba99c --- /dev/null +++ b/packages/solidstart/test/vite/sentrySolidStart.test.ts @@ -0,0 +1,184 @@ +// @vitest-environment node +// Build-time plugin code, no DOM. `vite`'s runtime exports pull in esbuild, which cannot run under +// jsdom's `TextEncoder`. +import type { Plugin, UserConfig } from 'vite'; +import { mergeConfig } from 'vite'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { sentrySolidStart } from '../../src/vite/sentrySolidStart'; + +vi.spyOn(console, 'log').mockImplementation(() => { + /* noop */ +}); +vi.spyOn(console, 'warn').mockImplementation(() => { + /* noop */ +}); + +type ViteConfigWithNitro = UserConfig & { nitro?: Record }; + +/** Invokes a plugin's `config` hook the way Vite does, returning the partial config it contributes. */ +function callConfigHook(plugin: Plugin, config: ViteConfigWithNitro = {}): ViteConfigWithNitro | undefined { + const hook = plugin.config; + const handler = typeof hook === 'function' ? hook : hook?.handler; + return handler?.call({} as never, config as UserConfig, { + command: 'build', + mode: 'production', + }) as ViteConfigWithNitro | undefined; +} + +function getNitroPlugin(plugins: Plugin[]): Plugin { + const plugin = plugins.find(p => p.name === 'sentry-solidstart-nitro'); + if (!plugin) { + throw new Error('Expected a `sentry-solidstart-nitro` plugin'); + } + return plugin; +} + +const originalNodeEnv = process.env.NODE_ENV; + +beforeEach(() => { + process.env.NODE_ENV = 'production'; +}); + +afterEach(() => { + process.env.NODE_ENV = originalNodeEnv; + vi.clearAllMocks(); +}); + +describe('sentrySolidStart()', () => { + it('returns the nitro, orchestrion and source maps plugins', () => { + const names = sentrySolidStart({ org: 'org', project: 'project', authToken: 'token' }).map(plugin => plugin.name); + + expect(names).toEqual([ + 'sentry-solidstart-nitro', + // `sentryOrchestrionPlugin` spreads the upstream code-transformer plugin, which brings its name. + 'code-transformer', + 'sentry-solidstart-files-to-delete-after-upload', + 'sentry-vite-plugin', + 'sentry-solidstart-update-source-map-setting', + ]); + }); + + it('returns an inert orchestrion plugin when build-time instrumentation is disabled', () => { + const names = sentrySolidStart({ buildTimeInstrumentation: false }).map(plugin => plugin.name); + + expect(names).toContain('sentry-orchestrion-disabled'); + expect(names).not.toContain('code-transformer'); + }); + + it('omits the source maps plugins when source maps are disabled', () => { + const names = sentrySolidStart({ sourcemaps: { disable: true } }).map(plugin => plugin.name); + + expect(names).toEqual(['sentry-solidstart-nitro', 'code-transformer']); + }); + + // `'disable-upload'` still injects debug IDs, so the plugins have to run; the bundler plugin + // suppresses just the upload. + it('keeps the source maps plugins when only the upload is disabled', () => { + const names = sentrySolidStart({ sourcemaps: { disable: 'disable-upload' } }).map(plugin => plugin.name); + + expect(names).toContain('sentry-vite-plugin'); + expect(names).toContain('sentry-solidstart-update-source-map-setting'); + }); + + it('only returns the nitro plugin in development', () => { + process.env.NODE_ENV = 'development'; + + const names = sentrySolidStart({ org: 'org', project: 'project' }).map(plugin => plugin.name); + + expect(names).toEqual(['sentry-solidstart-nitro']); + }); + + describe('the nitro plugin', () => { + // Without this, placing `sentrySolidStart()` after `nitro()` would contribute the config too + // late and it would be silently ignored. + it("is enforced as 'pre' so it runs before nitro's own config hook", () => { + const plugin = getNitroPlugin(sentrySolidStart()); + + expect(plugin.enforce).toBe('pre'); + }); + + it('registers the Sentry nitro module and enables tracing channels', () => { + const contributed = callConfigHook(getNitroPlugin(sentrySolidStart())); + + expect(contributed?.nitro).toMatchObject({ + tracingChannel: true, + modules: [expect.objectContaining({ name: 'sentry' })], + }); + }); + + it('enables hidden source maps and opts out of nitro sourcemap minification', () => { + const contributed = callConfigHook(getNitroPlugin(sentrySolidStart())); + + // `sourcemapMinify` clears `mappings` for chunks touching `node_modules`, making the uploaded + // server source maps useless. + expect(contributed?.nitro).toMatchObject({ + sourcemap: 'hidden', + experimental: { sourcemapMinify: false }, + }); + }); + + it("keeps the user's explicit nitro source map setting", () => { + const contributed = callConfigHook(getNitroPlugin(sentrySolidStart()), { nitro: { sourcemap: false } }); + + expect(contributed?.nitro).toMatchObject({ sourcemap: false }); + }); + + // Vite concatenates arrays when merging a `config` return value, so echoing the user's own + // entries back duplicates every one of them. + it("does not duplicate the user's nitro arrays once Vite merges the result", () => { + const userConfig: ViteConfigWithNitro = { + nitro: { modules: [{ name: 'user-module' }], plugins: ['./server/plugins/user.ts'] }, + }; + + const merged = mergeConfig(userConfig, callConfigHook(getNitroPlugin(sentrySolidStart()), userConfig) ?? {}); + + expect((merged as ViteConfigWithNitro).nitro).toMatchObject({ + modules: [{ name: 'user-module' }, expect.objectContaining({ name: 'sentry' })], + plugins: ['./server/plugins/user.ts'], + }); + }); + + it('preserves unrelated nitro options the user set', () => { + const userConfig: ViteConfigWithNitro = { nitro: { preset: 'node-server' } }; + + const merged = mergeConfig(userConfig, callConfigHook(getNitroPlugin(sentrySolidStart()), userConfig) ?? {}); + + expect((merged as ViteConfigWithNitro).nitro).toMatchObject({ preset: 'node-server' }); + }); + + it('does not mutate the config object it is handed', () => { + const userConfig: ViteConfigWithNitro = { nitro: {} }; + + callConfigHook(getNitroPlugin(sentrySolidStart()), userConfig); + + expect(userConfig.nitro).toEqual({}); + }); + + // `setupSentryNitroModule` writes into `modules` and `experimental`, which a shallow copy leaves + // aliased to the user's objects. + it('does not mutate nested config the user already had', () => { + const userModules = [{ name: 'user-module' }]; + const userExperimental = { openAPI: true }; + const userConfig: ViteConfigWithNitro = { + nitro: { modules: userModules, experimental: userExperimental }, + }; + + callConfigHook(getNitroPlugin(sentrySolidStart()), userConfig); + + expect(userModules).toEqual([{ name: 'user-module' }]); + expect(userExperimental).toEqual({ openAPI: true }); + }); + + // Vite can call `config` more than once, e.g. across environments in a build. + it('registers the Sentry module once even if the config hook runs twice', () => { + const userConfig: ViteConfigWithNitro = { nitro: { modules: [{ name: 'user-module' }] } }; + const plugin = getNitroPlugin(sentrySolidStart()); + + callConfigHook(plugin, userConfig); + const second = callConfigHook(plugin, userConfig); + + const modules = (second?.nitro as { modules?: unknown[] } | undefined)?.modules ?? []; + expect(modules.filter(m => (m as { name?: string }).name === 'sentry')).toHaveLength(1); + }); + }); +}); diff --git a/packages/solidstart/test/vite/sourceMaps.test.ts b/packages/solidstart/test/vite/sourceMaps.test.ts index 7cf240c7003e..15dd15e04d61 100644 --- a/packages/solidstart/test/vite/sourceMaps.test.ts +++ b/packages/solidstart/test/vite/sourceMaps.test.ts @@ -1,8 +1,10 @@ import type { SentryVitePluginOptions } from '@sentry/bundler-plugins/vite'; +import type { Plugin, UserConfig } from 'vite'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getUpdatedSourceMapSettings, makeAddSentryVitePlugin, + makeAddSentryVitePluginSolidStart2, makeEnableSourceMapsVitePlugin, } from '../../src/vite/sourceMaps'; @@ -11,7 +13,21 @@ const mockedSentryVitePlugin = { writeBundle: vi.fn(), }; -const sentryVitePluginSpy = vi.fn((_options: SentryVitePluginOptions) => [mockedSentryVitePlugin]); +// Captured so tests can await the deferred `filesToDeleteAfterUpload` promise, which +// `toHaveBeenCalledWith` can only match by identity. +let lastPluginOptions: SentryVitePluginOptions | undefined; + +const sentryVitePluginSpy = vi.fn((options: SentryVitePluginOptions) => { + lastPluginOptions = options; + return [mockedSentryVitePlugin]; +}); + +/** Runs a plugin's `config` hook the way Vite does. */ +function runConfigHook(plugin: Plugin, config: UserConfig = {}): void { + const hook = plugin.config; + const handler = typeof hook === 'function' ? hook : hook?.handler; + handler?.call({} as never, config, { command: 'build', mode: 'production' }); +} vi.mock('@sentry/bundler-plugins/vite', async () => { const original = (await vi.importActual('@sentry/bundler-plugins/vite')) as any; @@ -38,6 +54,24 @@ describe('makeSourceMapsVitePlugin()', () => { expect(sourceMapsConfigPlugins).toHaveLength(1); }); + + // Vite concatenates arrays when merging, so echoing the config back duplicates the user's arrays. + it('contributes only the source map setting, not the whole config back', () => { + const plugin = makeEnableSourceMapsVitePlugin({})[0]!; + const hook = plugin.config; + const handler = typeof hook === 'function' ? hook : hook?.handler; + + const contributed = handler?.call( + {} as never, + { optimizeDeps: { include: ['x'] } }, + { + command: 'build', + mode: 'production', + }, + ); + + expect(contributed).toEqual({ build: { sourcemap: 'hidden' } }); + }); }); describe('makeAddSentryVitePlugin()', () => { @@ -171,6 +205,94 @@ describe('makeAddSentryVitePlugin()', () => { }); }); +describe('makeAddSentryVitePluginSolidStart2()', () => { + it('passes the shared build-time options through to the vite plugin', () => { + makeAddSentryVitePluginSolidStart2({ + org: 'my-org', + project: 'my-project', + authToken: 'my-token', + sentryUrl: 'https://my-sentry.io', + applicationKey: 'my-app', + silent: true, + bundleSizeOptimizations: { excludeTracing: true }, + }); + + expect(sentryVitePluginSpy).toHaveBeenCalledWith( + expect.objectContaining({ + org: 'my-org', + project: 'my-project', + authToken: 'my-token', + // `sentryUrl` is spelled `url` on the plugin. + url: 'https://my-sentry.io', + applicationKey: 'my-app', + silent: true, + bundleSizeOptimizations: { excludeTracing: true }, + }), + ); + }); + + // `buildTimeInstrumentation` configures the orchestrion plugin, not the bundler plugin. + it('does not forward `buildTimeInstrumentation` to the vite plugin', () => { + makeAddSentryVitePluginSolidStart2({ org: 'my-org', buildTimeInstrumentation: false }); + + expect(sentryVitePluginSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ buildTimeInstrumentation: expect.anything() }), + ); + }); + + it('lets `unstable_sentryVitePluginOptions` override what the SDK sets', () => { + makeAddSentryVitePluginSolidStart2({ + org: 'my-org', + unstable_sentryVitePluginOptions: { + org: 'unstable-org', + sourcemaps: { assets: ['unstable/*.js'] }, + }, + }); + + expect(sentryVitePluginSpy).toHaveBeenCalledWith( + expect.objectContaining({ + org: 'unstable-org', + sourcemaps: expect.objectContaining({ assets: ['unstable/*.js'] }), + }), + ); + }); + + // The deferred promise always wins, so ignoring the unstable value here would silently swap in + // the SDK's default and delete a different set of files. + it('respects `filesToDeleteAfterUpload` set through unstable options', async () => { + const plugins = makeAddSentryVitePluginSolidStart2({ + unstable_sentryVitePluginOptions: { sourcemaps: { filesToDeleteAfterUpload: ['custom/**/*.map'] } }, + }); + + runConfigHook(plugins[0]!); + + await expect(lastPluginOptions?.sourcemaps?.filesToDeleteAfterUpload).resolves.toEqual(['custom/**/*.map']); + }); + + it('prefers the stable `filesToDeleteAfterUpload` when both are set', async () => { + const plugins = makeAddSentryVitePluginSolidStart2({ + sourcemaps: { filesToDeleteAfterUpload: ['stable/**/*.map'] }, + unstable_sentryVitePluginOptions: { sourcemaps: { filesToDeleteAfterUpload: ['unstable/**/*.map'] } }, + }); + + runConfigHook(plugins[0]!); + + await expect(lastPluginOptions?.sourcemaps?.filesToDeleteAfterUpload).resolves.toEqual(['stable/**/*.map']); + }); + + it('always tags telemetry as solidstart, even when unstable options set _metaOptions', () => { + makeAddSentryVitePluginSolidStart2({ + unstable_sentryVitePluginOptions: { _metaOptions: { telemetry: { metaFramework: 'not-solidstart' } } }, + }); + + expect(sentryVitePluginSpy).toHaveBeenCalledWith( + expect.objectContaining({ + _metaOptions: { telemetry: { metaFramework: 'solidstart' } }, + }), + ); + }); +}); + describe('getUpdatedSourceMapSettings', () => { beforeEach(() => { vi.clearAllMocks();