Skip to content

ref(test): Break up nextjs config tests #5541

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Aug 10, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
936 changes: 0 additions & 936 deletions packages/nextjs/test/config.test.ts

This file was deleted.

101 changes: 101 additions & 0 deletions packages/nextjs/test/config/fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import {
BuildContext,
EntryPropertyFunction,
ExportedNextConfig,
NextConfigObject,
NextConfigObjectWithSentry,
WebpackConfigObject,
} from '../../src/config/types';

export const SERVER_SDK_CONFIG_FILE = 'sentry.server.config.js';
export const CLIENT_SDK_CONFIG_FILE = 'sentry.client.config.js';

/** Mock next config object */
export const userNextConfig: NextConfigObject = {
publicRuntimeConfig: { location: 'dogpark', activities: ['fetch', 'chasing', 'digging'] },
webpack: (incomingWebpackConfig: WebpackConfigObject, _options: BuildContext) => ({
...incomingWebpackConfig,
mode: 'universal-sniffing',
entry: async () =>
Promise.resolve({
...(await (incomingWebpackConfig.entry as EntryPropertyFunction)()),
simulatorBundle: './src/simulator/index.ts',
}),
}),
};

/** Mocks of the arguments passed to `withSentryConfig` */
export const exportedNextConfig = userNextConfig as NextConfigObjectWithSentry;
export const userSentryWebpackPluginConfig = { org: 'squirrelChasers', project: 'simulator' };
process.env.SENTRY_AUTH_TOKEN = 'dogsarebadatkeepingsecrets';
process.env.SENTRY_RELEASE = 'doGsaREgReaT';

/** Mocks of the arguments passed to the result of `withSentryConfig` (when it's a function). */
export const runtimePhase = 'ball-fetching';
// `defaultConfig` is the defaults for all nextjs options (we don't use these at all in the tests, so for our purposes
// here the values don't matter)
export const defaultsObject = { defaultConfig: {} as NextConfigObject };

/** mocks of the arguments passed to `nextConfig.webpack` */
export const serverWebpackConfig: WebpackConfigObject = {
entry: () =>
Promise.resolve({
'pages/_error': 'private-next-pages/_error.js',
'pages/_app': ['./node_modules/smellOVision/index.js', 'private-next-pages/_app.js'],
'pages/api/_middleware': 'private-next-pages/api/_middleware.js',
'pages/api/simulator/dogStats/[name]': { import: 'private-next-pages/api/simulator/dogStats/[name].js' },
'pages/api/simulator/leaderboard': {
import: ['./node_modules/dogPoints/converter.js', 'private-next-pages/api/simulator/leaderboard.js'],
},
'pages/api/tricks/[trickName]': {
import: 'private-next-pages/api/tricks/[trickName].js',
dependOn: 'treats',
},
treats: './node_modules/dogTreats/treatProvider.js',
}),
output: { filename: '[name].js', path: '/Users/Maisey/projects/squirrelChasingSimulator/.next' },
target: 'node',
context: '/Users/Maisey/projects/squirrelChasingSimulator',
};
export const clientWebpackConfig: WebpackConfigObject = {
entry: () =>
Promise.resolve({
main: './src/index.ts',
'pages/_app': 'next-client-pages-loader?page=%2F_app',
'pages/_error': 'next-client-pages-loader?page=%2F_error',
}),
output: { filename: 'static/chunks/[name].js', path: '/Users/Maisey/projects/squirrelChasingSimulator/.next' },
target: 'web',
context: '/Users/Maisey/projects/squirrelChasingSimulator',
};

/**
* Return a mock build context, including the user's next config (which nextjs copies in in real life).
*
* @param buildTarget 'server' or 'client'
* @param materializedNextConfig The user's next config
* @param webpackVersion
* @returns A mock build context for the given target
*/
export function getBuildContext(
buildTarget: 'server' | 'client',
materializedNextConfig: ExportedNextConfig,
webpackVersion: string = '5.4.15',
): BuildContext {
return {
dev: false,
buildId: 'sItStAyLiEdOwN',
dir: '/Users/Maisey/projects/squirrelChasingSimulator',
config: {
// nextjs's default values
target: 'server',
distDir: '.next',
...materializedNextConfig,
} as NextConfigObject,
webpack: { version: webpackVersion },
isServer: buildTarget === 'server',
};
}

export const serverBuildContext = getBuildContext('server', exportedNextConfig);
export const clientBuildContext = getBuildContext('client', exportedNextConfig);
62 changes: 62 additions & 0 deletions packages/nextjs/test/config/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { defaultsObject, exportedNextConfig, runtimePhase, userNextConfig } from './fixtures';
import { materializeFinalNextConfig } from './testUtils';

describe('withSentryConfig', () => {
it('includes expected properties', () => {
const finalConfig = materializeFinalNextConfig(exportedNextConfig);

expect(finalConfig).toEqual(
expect.objectContaining({
webpack: expect.any(Function), // `webpack` is tested specifically elsewhere
}),
);
});

it('preserves unrelated next config options', () => {
const finalConfig = materializeFinalNextConfig(exportedNextConfig);

expect(finalConfig.publicRuntimeConfig).toEqual(userNextConfig.publicRuntimeConfig);
});

it("works when user's overall config is an object", () => {
const finalConfig = materializeFinalNextConfig(exportedNextConfig);

expect(finalConfig).toEqual(
expect.objectContaining({
...userNextConfig,
webpack: expect.any(Function), // `webpack` is tested specifically elsewhere
}),
);
});

it("works when user's overall config is a function", () => {
const exportedNextConfigFunction = () => userNextConfig;

const finalConfig = materializeFinalNextConfig(exportedNextConfigFunction);

expect(finalConfig).toEqual(
expect.objectContaining({
...exportedNextConfigFunction(),
webpack: expect.any(Function), // `webpack` is tested specifically elsewhere
}),
);
});

it('correctly passes `phase` and `defaultConfig` through to functional `userNextConfig`', () => {
const exportedNextConfigFunction = jest.fn().mockReturnValue(userNextConfig);

materializeFinalNextConfig(exportedNextConfigFunction);

expect(exportedNextConfigFunction).toHaveBeenCalledWith(runtimePhase, defaultsObject);
});

it('removes `sentry` property', () => {
// It's unclear why we need this cast -
const finalConfig = materializeFinalNextConfig({ ...exportedNextConfig, sentry: {} });
// const finalConfig = materializeFinalNextConfig({ ...exportedNextConfig, sentry: {} } as ExportedNextConfig);

// 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
// directly
expect('sentry' in finalConfig).toBe(false);
});
});
55 changes: 55 additions & 0 deletions packages/nextjs/test/config/loaders.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// mock helper functions not tested directly in this file
import './mocks';

import {
clientBuildContext,
clientWebpackConfig,
exportedNextConfig,
serverBuildContext,
serverWebpackConfig,
} from './fixtures';
import { materializeFinalWebpackConfig } from './testUtils';

describe('webpack loaders', () => {
it('adds loader to server config', async () => {
const finalWebpackConfig = await materializeFinalWebpackConfig({
exportedNextConfig,
incomingWebpackConfig: serverWebpackConfig,
incomingWebpackBuildContext: serverBuildContext,
});

expect(finalWebpackConfig.module!.rules).toEqual(
expect.arrayContaining([
{
test: expect.any(RegExp),
use: [
{
loader: expect.any(String),
// Having no criteria for what the object contains is better than using `expect.any(Object)`, because that
// could be anything
options: expect.objectContaining({}),
},
],
},
]),
);
});

it("doesn't add loader to client config", async () => {
const finalWebpackConfig = await materializeFinalWebpackConfig({
exportedNextConfig,
incomingWebpackConfig: clientWebpackConfig,
incomingWebpackBuildContext: clientBuildContext,
});

expect(finalWebpackConfig.module).toBeUndefined();
});
});

describe('`distDir` value in default server-side `RewriteFrames` integration', () => {
describe('`RewriteFrames` ends up with correct `distDir` value', () => {
// TODO: this, along with any number of other parts of the build process, should be tested with an integration
// test which actually runs webpack and inspects the resulting bundles (and that integration test should test
// custom `distDir` values with and without a `.`, to make sure the regex escaping is working)
});
});
56 changes: 56 additions & 0 deletions packages/nextjs/test/config/mocks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// TODO: This mocking is why we have to use `--runInBand` when we run tests, since there's only a single temp directory
// created

import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as rimraf from 'rimraf';

import { CLIENT_SDK_CONFIG_FILE, SERVER_SDK_CONFIG_FILE } from './fixtures';

// We use `fs.existsSync()` in `getUserConfigFile()`. When we're not testing `getUserConfigFile()` specifically, all we
// 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
// built-in, though, which jest itself uses, otherwise let it do the normal thing. Storing the real version of the
// function also lets us restore the original when we do want to test `getUserConfigFile()`.
export const realExistsSync = jest.requireActual('fs').existsSync;
export const mockExistsSync = (path: fs.PathLike): ReturnType<typeof realExistsSync> => {
if ((path as string).endsWith(SERVER_SDK_CONFIG_FILE) || (path as string).endsWith(CLIENT_SDK_CONFIG_FILE)) {
return true;
}

return realExistsSync(path);
};
export const exitsSync = jest.spyOn(fs, 'existsSync').mockImplementation(mockExistsSync);

/** Mocking of temporary directory creation (so that we have a place to stick files (like `sentry.client.config.js`) in
* order to test that we can find them) */

// Make it so that all temporary folders, either created directly by tests or by the code they're testing, will go into
// one spot that we know about, which we can then clean up when we're done
const realTmpdir = jest.requireActual('os').tmpdir;

// Including the random number ensures that even if multiple test files using these mocks are running at once, they have
// separate temporary folders
const TEMP_DIR_PATH = path.join(realTmpdir(), `sentry-nextjs-test-${Math.random()}`);

jest.spyOn(os, 'tmpdir').mockReturnValue(TEMP_DIR_PATH);
// In theory, we should always land in the `else` here, but this saves the cases where the prior run got interrupted and
// the `afterAll` below didn't happen.
if (fs.existsSync(TEMP_DIR_PATH)) {
rimraf.sync(path.join(TEMP_DIR_PATH, '*'));
} else {
fs.mkdirSync(TEMP_DIR_PATH);
}

afterAll(() => {
rimraf.sync(TEMP_DIR_PATH);
});
Comment on lines +45 to +47
Copy link
Member

@Lms24 Lms24 Aug 9, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wait, if I register a (before|after)(Each|All) call outside a describe closure, does that mean it runs for all tests in this directory?
If yes, that's totally fine, I just haven't seen this before, which is why I'm asking

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope, it only runs for the tests which import the file containing it. Neat, huh?

Honestly, before yesterday I didn't know that, either. I knew you could have a (before|after)(Each|All) call outside a describe in the same file (so, module level), but not that it could be in a totally separate file. But I took a flier and it ended up working out! (I tested it by logging inside the afterAll() function, and it only logged three times, once from inside of each file importing mocks.ts.)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting, but makes sense! Thanks for explaining!


// In order to know what to expect in the webpack config `entry` property, we need to know the path of the temporary
// directory created when doing the file injection, so wrap the real `mkdtempSync` and store the resulting path where we
// can access it
export const mkdtempSyncSpy = jest.spyOn(fs, 'mkdtempSync');

afterEach(() => {
mkdtempSyncSpy.mockClear();
});
120 changes: 120 additions & 0 deletions packages/nextjs/test/config/testUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { WebpackPluginInstance } from 'webpack';

import { withSentryConfig } from '../../src/config';
import {
BuildContext,
EntryPropertyFunction,
ExportedNextConfig,
NextConfigObject,
SentryWebpackPluginOptions,
WebpackConfigObject,
} from '../../src/config/types';
import { constructWebpackConfigFunction, SentryWebpackPlugin } from '../../src/config/webpack';
import { defaultsObject, runtimePhase } from './fixtures';

/**
* Derive the final values of all next config options, by first applying `withSentryConfig` and then, if it returns a
* function, running that function.
*
* @param exportedNextConfig Next config options provided by the user
* @param userSentryWebpackPluginConfig SentryWebpackPlugin options provided by the user
*
* @returns The config values next will receive directly from `withSentryConfig` or when it calls the function returned
* by `withSentryConfig`
*/
export function materializeFinalNextConfig(
exportedNextConfig: ExportedNextConfig,
userSentryWebpackPluginConfig?: Partial<SentryWebpackPluginOptions>,
): NextConfigObject {
const sentrifiedConfig = withSentryConfig(exportedNextConfig, userSentryWebpackPluginConfig);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lol "sentrified"
(We should make this a thing!)

let finalConfigValues = sentrifiedConfig;

if (typeof sentrifiedConfig === 'function') {
// for some reason TS won't recognize that `finalConfigValues` is now a NextConfigObject, which is why the cast
// below is necessary
finalConfigValues = sentrifiedConfig(runtimePhase, defaultsObject);
}

return finalConfigValues as NextConfigObject;
}

/**
* Derive the final values of all webpack config options, by first applying `constructWebpackConfigFunction` and then
* running the resulting function. Since the `entry` property of the resulting object is itself a function, also call
* that.
*
* @param options An object including the following:
* - `exportedNextConfig` Next config options provided by the user
* - `userSentryWebpackPluginConfig` SentryWebpackPlugin options provided by the user
* - `incomingWebpackConfig` The existing webpack config, passed to the function as `config`
* - `incomingWebpackBuildContext` The existing webpack build context, passed to the function as `options`
*
* @returns The webpack config values next will use when it calls the function that `createFinalWebpackConfig` returns
*/
export async function materializeFinalWebpackConfig(options: {
exportedNextConfig: ExportedNextConfig;
userSentryWebpackPluginConfig?: Partial<SentryWebpackPluginOptions>;
incomingWebpackConfig: WebpackConfigObject;
incomingWebpackBuildContext: BuildContext;
}): Promise<WebpackConfigObject> {
const { exportedNextConfig, userSentryWebpackPluginConfig, incomingWebpackConfig, incomingWebpackBuildContext } =
options;

// if the user's next config is a function, run it so we have access to the values
const materializedUserNextConfig =
typeof exportedNextConfig === 'function'
? exportedNextConfig('phase-production-build', defaultsObject)
: exportedNextConfig;

// extract the `sentry` property as we do in `withSentryConfig`
const { sentry: sentryConfig } = materializedUserNextConfig;
delete materializedUserNextConfig.sentry;

// get the webpack config function we'd normally pass back to next
const webpackConfigFunction = constructWebpackConfigFunction(
materializedUserNextConfig,
userSentryWebpackPluginConfig,
sentryConfig,
);

// call it to get concrete values for comparison
const finalWebpackConfigValue = webpackConfigFunction(incomingWebpackConfig, incomingWebpackBuildContext);
const webpackEntryProperty = finalWebpackConfigValue.entry as EntryPropertyFunction;
finalWebpackConfigValue.entry = await webpackEntryProperty();

return finalWebpackConfigValue;
}

// helper function to make sure we're checking the correct plugin's data

/**
* Given a webpack config, find a plugin (or the plugins) with the given name.
*
* Note that this function will error if more than one instance is found, unless the `allowMultiple` flag is passed.
*
* @param webpackConfig The webpack config object
* @param pluginName The name of the plugin's constructor
* @returns The plugin instance(s), or undefined if it's not found.
*/
export function findWebpackPlugin(
webpackConfig: WebpackConfigObject,
pluginName: string,
multipleAllowed: boolean = false,
): WebpackPluginInstance | SentryWebpackPlugin | WebpackPluginInstance[] | SentryWebpackPlugin[] | undefined {
const plugins = webpackConfig.plugins || [];
const matchingPlugins = plugins.filter(plugin => plugin.constructor.name === pluginName);

if (matchingPlugins.length > 1 && !multipleAllowed) {
throw new Error(
`More than one ${pluginName} instance found. Please use the \`multipleAllowed\` flag if this is intentional.\nExisting plugins: ${plugins.map(
plugin => plugin.constructor.name,
)}`,
);
}

if (matchingPlugins.length > 0) {
return multipleAllowed ? matchingPlugins : matchingPlugins[0];
}

return undefined;
}
Loading