-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
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
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
}); | ||
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
|
||
// 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(); | ||
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Lol "sentrified" |
||
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; | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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 adescribe
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
There was a problem hiding this comment.
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 adescribe
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 theafterAll()
function, and it only logged three times, once from inside of each file importingmocks.ts
.)There was a problem hiding this comment.
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!