Skip to content

Commit 9b7f432

Browse files
authored
ref(test): Break up nextjs config tests (#5541)
In anticipation of adding more tests for the code in `src/config` in the nextjs SDK, this breaks up the (very long) single file which had been covering the entire directory into multiple files, extracting some mocks, fixtures, and utils along they way into helper modules. The entire test suite could probably stand to be revisited/cleaned up, but for now this just distributes things into different files.
1 parent 64c7204 commit 9b7f432

File tree

8 files changed

+1025
-936
lines changed

8 files changed

+1025
-936
lines changed

packages/nextjs/test/config.test.ts

-936
This file was deleted.
+101
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import {
2+
BuildContext,
3+
EntryPropertyFunction,
4+
ExportedNextConfig,
5+
NextConfigObject,
6+
NextConfigObjectWithSentry,
7+
WebpackConfigObject,
8+
} from '../../src/config/types';
9+
10+
export const SERVER_SDK_CONFIG_FILE = 'sentry.server.config.js';
11+
export const CLIENT_SDK_CONFIG_FILE = 'sentry.client.config.js';
12+
13+
/** Mock next config object */
14+
export const userNextConfig: NextConfigObject = {
15+
publicRuntimeConfig: { location: 'dogpark', activities: ['fetch', 'chasing', 'digging'] },
16+
webpack: (incomingWebpackConfig: WebpackConfigObject, _options: BuildContext) => ({
17+
...incomingWebpackConfig,
18+
mode: 'universal-sniffing',
19+
entry: async () =>
20+
Promise.resolve({
21+
...(await (incomingWebpackConfig.entry as EntryPropertyFunction)()),
22+
simulatorBundle: './src/simulator/index.ts',
23+
}),
24+
}),
25+
};
26+
27+
/** Mocks of the arguments passed to `withSentryConfig` */
28+
export const exportedNextConfig = userNextConfig as NextConfigObjectWithSentry;
29+
export const userSentryWebpackPluginConfig = { org: 'squirrelChasers', project: 'simulator' };
30+
process.env.SENTRY_AUTH_TOKEN = 'dogsarebadatkeepingsecrets';
31+
process.env.SENTRY_RELEASE = 'doGsaREgReaT';
32+
33+
/** Mocks of the arguments passed to the result of `withSentryConfig` (when it's a function). */
34+
export const runtimePhase = 'ball-fetching';
35+
// `defaultConfig` is the defaults for all nextjs options (we don't use these at all in the tests, so for our purposes
36+
// here the values don't matter)
37+
export const defaultsObject = { defaultConfig: {} as NextConfigObject };
38+
39+
/** mocks of the arguments passed to `nextConfig.webpack` */
40+
export const serverWebpackConfig: WebpackConfigObject = {
41+
entry: () =>
42+
Promise.resolve({
43+
'pages/_error': 'private-next-pages/_error.js',
44+
'pages/_app': ['./node_modules/smellOVision/index.js', 'private-next-pages/_app.js'],
45+
'pages/api/_middleware': 'private-next-pages/api/_middleware.js',
46+
'pages/api/simulator/dogStats/[name]': { import: 'private-next-pages/api/simulator/dogStats/[name].js' },
47+
'pages/api/simulator/leaderboard': {
48+
import: ['./node_modules/dogPoints/converter.js', 'private-next-pages/api/simulator/leaderboard.js'],
49+
},
50+
'pages/api/tricks/[trickName]': {
51+
import: 'private-next-pages/api/tricks/[trickName].js',
52+
dependOn: 'treats',
53+
},
54+
treats: './node_modules/dogTreats/treatProvider.js',
55+
}),
56+
output: { filename: '[name].js', path: '/Users/Maisey/projects/squirrelChasingSimulator/.next' },
57+
target: 'node',
58+
context: '/Users/Maisey/projects/squirrelChasingSimulator',
59+
};
60+
export const clientWebpackConfig: WebpackConfigObject = {
61+
entry: () =>
62+
Promise.resolve({
63+
main: './src/index.ts',
64+
'pages/_app': 'next-client-pages-loader?page=%2F_app',
65+
'pages/_error': 'next-client-pages-loader?page=%2F_error',
66+
}),
67+
output: { filename: 'static/chunks/[name].js', path: '/Users/Maisey/projects/squirrelChasingSimulator/.next' },
68+
target: 'web',
69+
context: '/Users/Maisey/projects/squirrelChasingSimulator',
70+
};
71+
72+
/**
73+
* Return a mock build context, including the user's next config (which nextjs copies in in real life).
74+
*
75+
* @param buildTarget 'server' or 'client'
76+
* @param materializedNextConfig The user's next config
77+
* @param webpackVersion
78+
* @returns A mock build context for the given target
79+
*/
80+
export function getBuildContext(
81+
buildTarget: 'server' | 'client',
82+
materializedNextConfig: ExportedNextConfig,
83+
webpackVersion: string = '5.4.15',
84+
): BuildContext {
85+
return {
86+
dev: false,
87+
buildId: 'sItStAyLiEdOwN',
88+
dir: '/Users/Maisey/projects/squirrelChasingSimulator',
89+
config: {
90+
// nextjs's default values
91+
target: 'server',
92+
distDir: '.next',
93+
...materializedNextConfig,
94+
} as NextConfigObject,
95+
webpack: { version: webpackVersion },
96+
isServer: buildTarget === 'server',
97+
};
98+
}
99+
100+
export const serverBuildContext = getBuildContext('server', exportedNextConfig);
101+
export const clientBuildContext = getBuildContext('client', exportedNextConfig);
+62
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { defaultsObject, exportedNextConfig, runtimePhase, userNextConfig } from './fixtures';
2+
import { materializeFinalNextConfig } from './testUtils';
3+
4+
describe('withSentryConfig', () => {
5+
it('includes expected properties', () => {
6+
const finalConfig = materializeFinalNextConfig(exportedNextConfig);
7+
8+
expect(finalConfig).toEqual(
9+
expect.objectContaining({
10+
webpack: expect.any(Function), // `webpack` is tested specifically elsewhere
11+
}),
12+
);
13+
});
14+
15+
it('preserves unrelated next config options', () => {
16+
const finalConfig = materializeFinalNextConfig(exportedNextConfig);
17+
18+
expect(finalConfig.publicRuntimeConfig).toEqual(userNextConfig.publicRuntimeConfig);
19+
});
20+
21+
it("works when user's overall config is an object", () => {
22+
const finalConfig = materializeFinalNextConfig(exportedNextConfig);
23+
24+
expect(finalConfig).toEqual(
25+
expect.objectContaining({
26+
...userNextConfig,
27+
webpack: expect.any(Function), // `webpack` is tested specifically elsewhere
28+
}),
29+
);
30+
});
31+
32+
it("works when user's overall config is a function", () => {
33+
const exportedNextConfigFunction = () => userNextConfig;
34+
35+
const finalConfig = materializeFinalNextConfig(exportedNextConfigFunction);
36+
37+
expect(finalConfig).toEqual(
38+
expect.objectContaining({
39+
...exportedNextConfigFunction(),
40+
webpack: expect.any(Function), // `webpack` is tested specifically elsewhere
41+
}),
42+
);
43+
});
44+
45+
it('correctly passes `phase` and `defaultConfig` through to functional `userNextConfig`', () => {
46+
const exportedNextConfigFunction = jest.fn().mockReturnValue(userNextConfig);
47+
48+
materializeFinalNextConfig(exportedNextConfigFunction);
49+
50+
expect(exportedNextConfigFunction).toHaveBeenCalledWith(runtimePhase, defaultsObject);
51+
});
52+
53+
it('removes `sentry` property', () => {
54+
// It's unclear why we need this cast -
55+
const finalConfig = materializeFinalNextConfig({ ...exportedNextConfig, sentry: {} });
56+
// const finalConfig = materializeFinalNextConfig({ ...exportedNextConfig, sentry: {} } as ExportedNextConfig);
57+
58+
// We have to check using `in` because TS knows it shouldn't be there and throws a type error if we try to access it
59+
// directly
60+
expect('sentry' in finalConfig).toBe(false);
61+
});
62+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// mock helper functions not tested directly in this file
2+
import './mocks';
3+
4+
import {
5+
clientBuildContext,
6+
clientWebpackConfig,
7+
exportedNextConfig,
8+
serverBuildContext,
9+
serverWebpackConfig,
10+
} from './fixtures';
11+
import { materializeFinalWebpackConfig } from './testUtils';
12+
13+
describe('webpack loaders', () => {
14+
it('adds loader to server config', async () => {
15+
const finalWebpackConfig = await materializeFinalWebpackConfig({
16+
exportedNextConfig,
17+
incomingWebpackConfig: serverWebpackConfig,
18+
incomingWebpackBuildContext: serverBuildContext,
19+
});
20+
21+
expect(finalWebpackConfig.module!.rules).toEqual(
22+
expect.arrayContaining([
23+
{
24+
test: expect.any(RegExp),
25+
use: [
26+
{
27+
loader: expect.any(String),
28+
// Having no criteria for what the object contains is better than using `expect.any(Object)`, because that
29+
// could be anything
30+
options: expect.objectContaining({}),
31+
},
32+
],
33+
},
34+
]),
35+
);
36+
});
37+
38+
it("doesn't add loader to client config", async () => {
39+
const finalWebpackConfig = await materializeFinalWebpackConfig({
40+
exportedNextConfig,
41+
incomingWebpackConfig: clientWebpackConfig,
42+
incomingWebpackBuildContext: clientBuildContext,
43+
});
44+
45+
expect(finalWebpackConfig.module).toBeUndefined();
46+
});
47+
});
48+
49+
describe('`distDir` value in default server-side `RewriteFrames` integration', () => {
50+
describe('`RewriteFrames` ends up with correct `distDir` value', () => {
51+
// TODO: this, along with any number of other parts of the build process, should be tested with an integration
52+
// test which actually runs webpack and inspects the resulting bundles (and that integration test should test
53+
// custom `distDir` values with and without a `.`, to make sure the regex escaping is working)
54+
});
55+
});

packages/nextjs/test/config/mocks.ts

+56
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// TODO: This mocking is why we have to use `--runInBand` when we run tests, since there's only a single temp directory
2+
// created
3+
4+
import * as fs from 'fs';
5+
import * as os from 'os';
6+
import * as path from 'path';
7+
import * as rimraf from 'rimraf';
8+
9+
import { CLIENT_SDK_CONFIG_FILE, SERVER_SDK_CONFIG_FILE } from './fixtures';
10+
11+
// We use `fs.existsSync()` in `getUserConfigFile()`. When we're not testing `getUserConfigFile()` specifically, all we
12+
// need is for it to give us any valid answer, so make it always find what it's looking for. Since this is a core node
13+
// built-in, though, which jest itself uses, otherwise let it do the normal thing. Storing the real version of the
14+
// function also lets us restore the original when we do want to test `getUserConfigFile()`.
15+
export const realExistsSync = jest.requireActual('fs').existsSync;
16+
export const mockExistsSync = (path: fs.PathLike): ReturnType<typeof realExistsSync> => {
17+
if ((path as string).endsWith(SERVER_SDK_CONFIG_FILE) || (path as string).endsWith(CLIENT_SDK_CONFIG_FILE)) {
18+
return true;
19+
}
20+
21+
return realExistsSync(path);
22+
};
23+
export const exitsSync = jest.spyOn(fs, 'existsSync').mockImplementation(mockExistsSync);
24+
25+
/** Mocking of temporary directory creation (so that we have a place to stick files (like `sentry.client.config.js`) in
26+
* order to test that we can find them) */
27+
28+
// Make it so that all temporary folders, either created directly by tests or by the code they're testing, will go into
29+
// one spot that we know about, which we can then clean up when we're done
30+
const realTmpdir = jest.requireActual('os').tmpdir;
31+
32+
// Including the random number ensures that even if multiple test files using these mocks are running at once, they have
33+
// separate temporary folders
34+
const TEMP_DIR_PATH = path.join(realTmpdir(), `sentry-nextjs-test-${Math.random()}`);
35+
36+
jest.spyOn(os, 'tmpdir').mockReturnValue(TEMP_DIR_PATH);
37+
// In theory, we should always land in the `else` here, but this saves the cases where the prior run got interrupted and
38+
// the `afterAll` below didn't happen.
39+
if (fs.existsSync(TEMP_DIR_PATH)) {
40+
rimraf.sync(path.join(TEMP_DIR_PATH, '*'));
41+
} else {
42+
fs.mkdirSync(TEMP_DIR_PATH);
43+
}
44+
45+
afterAll(() => {
46+
rimraf.sync(TEMP_DIR_PATH);
47+
});
48+
49+
// In order to know what to expect in the webpack config `entry` property, we need to know the path of the temporary
50+
// directory created when doing the file injection, so wrap the real `mkdtempSync` and store the resulting path where we
51+
// can access it
52+
export const mkdtempSyncSpy = jest.spyOn(fs, 'mkdtempSync');
53+
54+
afterEach(() => {
55+
mkdtempSyncSpy.mockClear();
56+
});
+120
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { WebpackPluginInstance } from 'webpack';
2+
3+
import { withSentryConfig } from '../../src/config';
4+
import {
5+
BuildContext,
6+
EntryPropertyFunction,
7+
ExportedNextConfig,
8+
NextConfigObject,
9+
SentryWebpackPluginOptions,
10+
WebpackConfigObject,
11+
} from '../../src/config/types';
12+
import { constructWebpackConfigFunction, SentryWebpackPlugin } from '../../src/config/webpack';
13+
import { defaultsObject, runtimePhase } from './fixtures';
14+
15+
/**
16+
* Derive the final values of all next config options, by first applying `withSentryConfig` and then, if it returns a
17+
* function, running that function.
18+
*
19+
* @param exportedNextConfig Next config options provided by the user
20+
* @param userSentryWebpackPluginConfig SentryWebpackPlugin options provided by the user
21+
*
22+
* @returns The config values next will receive directly from `withSentryConfig` or when it calls the function returned
23+
* by `withSentryConfig`
24+
*/
25+
export function materializeFinalNextConfig(
26+
exportedNextConfig: ExportedNextConfig,
27+
userSentryWebpackPluginConfig?: Partial<SentryWebpackPluginOptions>,
28+
): NextConfigObject {
29+
const sentrifiedConfig = withSentryConfig(exportedNextConfig, userSentryWebpackPluginConfig);
30+
let finalConfigValues = sentrifiedConfig;
31+
32+
if (typeof sentrifiedConfig === 'function') {
33+
// for some reason TS won't recognize that `finalConfigValues` is now a NextConfigObject, which is why the cast
34+
// below is necessary
35+
finalConfigValues = sentrifiedConfig(runtimePhase, defaultsObject);
36+
}
37+
38+
return finalConfigValues as NextConfigObject;
39+
}
40+
41+
/**
42+
* Derive the final values of all webpack config options, by first applying `constructWebpackConfigFunction` and then
43+
* running the resulting function. Since the `entry` property of the resulting object is itself a function, also call
44+
* that.
45+
*
46+
* @param options An object including the following:
47+
* - `exportedNextConfig` Next config options provided by the user
48+
* - `userSentryWebpackPluginConfig` SentryWebpackPlugin options provided by the user
49+
* - `incomingWebpackConfig` The existing webpack config, passed to the function as `config`
50+
* - `incomingWebpackBuildContext` The existing webpack build context, passed to the function as `options`
51+
*
52+
* @returns The webpack config values next will use when it calls the function that `createFinalWebpackConfig` returns
53+
*/
54+
export async function materializeFinalWebpackConfig(options: {
55+
exportedNextConfig: ExportedNextConfig;
56+
userSentryWebpackPluginConfig?: Partial<SentryWebpackPluginOptions>;
57+
incomingWebpackConfig: WebpackConfigObject;
58+
incomingWebpackBuildContext: BuildContext;
59+
}): Promise<WebpackConfigObject> {
60+
const { exportedNextConfig, userSentryWebpackPluginConfig, incomingWebpackConfig, incomingWebpackBuildContext } =
61+
options;
62+
63+
// if the user's next config is a function, run it so we have access to the values
64+
const materializedUserNextConfig =
65+
typeof exportedNextConfig === 'function'
66+
? exportedNextConfig('phase-production-build', defaultsObject)
67+
: exportedNextConfig;
68+
69+
// extract the `sentry` property as we do in `withSentryConfig`
70+
const { sentry: sentryConfig } = materializedUserNextConfig;
71+
delete materializedUserNextConfig.sentry;
72+
73+
// get the webpack config function we'd normally pass back to next
74+
const webpackConfigFunction = constructWebpackConfigFunction(
75+
materializedUserNextConfig,
76+
userSentryWebpackPluginConfig,
77+
sentryConfig,
78+
);
79+
80+
// call it to get concrete values for comparison
81+
const finalWebpackConfigValue = webpackConfigFunction(incomingWebpackConfig, incomingWebpackBuildContext);
82+
const webpackEntryProperty = finalWebpackConfigValue.entry as EntryPropertyFunction;
83+
finalWebpackConfigValue.entry = await webpackEntryProperty();
84+
85+
return finalWebpackConfigValue;
86+
}
87+
88+
// helper function to make sure we're checking the correct plugin's data
89+
90+
/**
91+
* Given a webpack config, find a plugin (or the plugins) with the given name.
92+
*
93+
* Note that this function will error if more than one instance is found, unless the `allowMultiple` flag is passed.
94+
*
95+
* @param webpackConfig The webpack config object
96+
* @param pluginName The name of the plugin's constructor
97+
* @returns The plugin instance(s), or undefined if it's not found.
98+
*/
99+
export function findWebpackPlugin(
100+
webpackConfig: WebpackConfigObject,
101+
pluginName: string,
102+
multipleAllowed: boolean = false,
103+
): WebpackPluginInstance | SentryWebpackPlugin | WebpackPluginInstance[] | SentryWebpackPlugin[] | undefined {
104+
const plugins = webpackConfig.plugins || [];
105+
const matchingPlugins = plugins.filter(plugin => plugin.constructor.name === pluginName);
106+
107+
if (matchingPlugins.length > 1 && !multipleAllowed) {
108+
throw new Error(
109+
`More than one ${pluginName} instance found. Please use the \`multipleAllowed\` flag if this is intentional.\nExisting plugins: ${plugins.map(
110+
plugin => plugin.constructor.name,
111+
)}`,
112+
);
113+
}
114+
115+
if (matchingPlugins.length > 0) {
116+
return multipleAllowed ? matchingPlugins : matchingPlugins[0];
117+
}
118+
119+
return undefined;
120+
}

0 commit comments

Comments
 (0)