From 62f3f87891f71c4ef6d057880d760f318b131191 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Wed, 13 Jul 2022 13:31:55 -0500 Subject: [PATCH] Add next.config.js validation with ajv (#38498) * Add next.config.js validation with ajv * update manifest * update lib type * remove old tests * update to pre-build validation code * ensure validate output is ncced * Apply suggestions from code review Co-authored-by: Steven * Add example of typing next.config.js Co-authored-by: Steven --- errors/invalid-next-config.md | 24 + errors/manifest.json | 4 + packages/next/index.d.ts | 2 +- packages/next/package.json | 2 + packages/next/server/config-schema.ts | 609 ++++++++++++++++++ packages/next/server/config-shared.ts | 10 + packages/next/server/config.ts | 36 +- packages/next/taskfile.js | 43 +- pnpm-lock.yaml | 15 +- .../test/index.test.js | 46 -- .../config-validation/pages/index.js | 3 + .../config-validation/test/index.test.ts | 62 ++ 12 files changed, 782 insertions(+), 74 deletions(-) create mode 100644 errors/invalid-next-config.md create mode 100644 packages/next/server/config-schema.ts create mode 100644 test/integration/config-validation/pages/index.js create mode 100644 test/integration/config-validation/test/index.test.ts diff --git a/errors/invalid-next-config.md b/errors/invalid-next-config.md new file mode 100644 index 000000000000..ffe6a08e491c --- /dev/null +++ b/errors/invalid-next-config.md @@ -0,0 +1,24 @@ +# Invalid next.config.js + +#### Why This Error Occurred + +In your `next.config.js` file you passed invalid options that either are the incorrect type or an unknown field. + +#### Possible Ways to Fix It + +Fixing the listed config errors will remove this warning. You can also leverage the `NextConfig` type by importing from `next` to help ensure your config is correct. + +```ts +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + /* config options here */ +} + +module.exports = nextConfig +``` + +### Useful Links + +- [`next.config.js`](https://nextjs.org/docs/api-reference/next.config.js/introduction) diff --git a/errors/manifest.json b/errors/manifest.json index 5f2f48eb5fee..9bc09eebbd1b 100644 --- a/errors/manifest.json +++ b/errors/manifest.json @@ -711,6 +711,10 @@ { "title": "node-module-in-edge-runtime", "path": "/errors/node-module-in-edge-runtime.md" + }, + { + "title": "invalid-next-config", + "path": "/errors/invalid-next-config.md" } ] } diff --git a/packages/next/index.d.ts b/packages/next/index.d.ts index 6a660719f6dd..dd922052fa49 100644 --- a/packages/next/index.d.ts +++ b/packages/next/index.d.ts @@ -1,4 +1,5 @@ /// +/// /// /// /// @@ -11,7 +12,6 @@ /// /// /// -/// export { default } from './types' export * from './types' diff --git a/packages/next/package.json b/packages/next/package.json index ed7955307f8d..f8c12b0c0624 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -50,6 +50,7 @@ "index.d.ts", "types/index.d.ts", "types/global.d.ts", + "types/compiled.d.ts", "image-types/global.d.ts" ], "bin": { @@ -168,6 +169,7 @@ "@vercel/ncc": "0.33.4", "@vercel/nft": "0.20.0", "acorn": "8.5.0", + "ajv": "8.11.0", "amphtml-validator": "1.0.35", "arg": "4.1.0", "assert": "2.0.0", diff --git a/packages/next/server/config-schema.ts b/packages/next/server/config-schema.ts new file mode 100644 index 000000000000..f9d18748f444 --- /dev/null +++ b/packages/next/server/config-schema.ts @@ -0,0 +1,609 @@ +import { NextConfig } from './config' +import type { JSONSchemaType } from 'ajv' +import { VALID_LOADERS } from '../shared/lib/image-config' + +const configSchema = { + type: 'object', + additionalProperties: false, + properties: { + amp: { + additionalProperties: false, + properties: { + canonicalBase: { + minLength: 1, + type: 'string', + }, + }, + type: 'object', + }, + assetPrefix: { + minLength: 1, + type: 'string', + }, + basePath: { + minLength: 1, + type: 'string', + }, + cleanDistDir: { + type: 'boolean', + }, + compiler: { + additionalProperties: false, + properties: { + emotion: { + oneOf: [ + { + type: 'boolean', + }, + { + type: 'object', + additionalProperties: false, + properties: { + sourceMap: { + type: 'boolean', + }, + autoLabel: { + type: 'string', + enum: ['always', 'dev-only', 'never'], + }, + labelFormat: { + type: 'string', + minLength: 1, + }, + }, + }, + ] as any, + }, + reactRemoveProperties: { + oneOf: [ + { + type: 'boolean', + }, + { + type: 'object', + additionalProperties: false, + properties: { + properties: { + type: 'array', + items: { + type: 'string', + }, + }, + }, + }, + ] as any, + }, + relay: { + additionalProperties: false, + properties: { + artifactDirectory: { + minLength: 1, + type: 'string', + }, + language: { + // automatic typing doesn't like enum + enum: ['flow', 'typescript'] as any, + type: 'string', + }, + src: { + minLength: 1, + type: 'string', + }, + }, + type: 'object', + }, + removeConsole: { + oneOf: [ + { + type: 'boolean', + }, + { + type: 'object', + additionalProperties: false, + properties: { + exclude: { + type: 'array', + items: { + type: 'string', + minLength: 1, + }, + }, + }, + }, + ] as any, + }, + styledComponents: { + oneOf: [ + { + type: 'boolean', + }, + { + type: 'object', + additionalProperties: false, + properties: { + displayName: { + type: 'boolean', + }, + topLevelImportPaths: { + type: 'array', + items: { + type: 'string', + minLength: 1, + }, + }, + ssr: { + type: 'boolean', + }, + fileName: { + type: 'boolean', + }, + meaninglessFileNames: { + type: 'boolean', + }, + minify: { + type: 'boolean', + }, + transpileTemplateLiterals: { + type: 'boolean', + }, + namespace: { + type: 'string', + minLength: 1, + }, + pure: { + type: 'boolean', + }, + cssProp: { + type: 'boolean', + }, + }, + }, + ] as any, + }, + }, + type: 'object', + }, + compress: { + type: 'boolean', + }, + crossOrigin: { + oneOf: [ + false, + { + enum: ['anonymous', 'use-credentials'], + type: 'string', + }, + ], // automatic typing does not like enum + } as any, + devIndicators: { + additionalProperties: false, + properties: { + buildActivity: { + type: 'boolean', + }, + buildActivityPosition: { + // automatic typing does not like enum + enum: ['bottom-left', 'bottom-right', 'top-left', 'top-right'] as any, + type: 'string', + }, + }, + type: 'object', + }, + distDir: { + minLength: 1, + type: 'string', + nullable: true, + }, + env: { + type: 'object', + }, + eslint: { + additionalProperties: false, + properties: { + dirs: { + items: { + minLength: 1, + type: 'string', + }, + type: 'array', + }, + ignoreDuringBuilds: { + type: 'boolean', + }, + }, + type: 'object', + }, + excludeDefaultMomentLocales: { + type: 'boolean', + }, + experimental: { + additionalProperties: false, + properties: { + amp: { + additionalProperties: false, + properties: { + optimizer: { + type: 'object', + }, + skipValidation: { + type: 'boolean', + }, + validator: { + type: 'string', + }, + }, + type: 'object', + }, + appDir: { + type: 'boolean', + }, + browsersListForSwc: { + type: 'boolean', + }, + cpus: { + type: 'number', + }, + craCompat: { + type: 'boolean', + }, + disableOptimizedLoading: { + type: 'boolean', + }, + disablePostcssPresetEnv: { + type: 'boolean', + }, + esmExternals: { + type: 'boolean', + }, + externalDir: { + type: 'boolean', + }, + forceSwcTransforms: { + type: 'boolean', + }, + fullySpecified: { + type: 'boolean', + }, + gzipSize: { + type: 'boolean', + }, + images: { + additionalProperties: false, + properties: { + allowFutureImage: { + type: 'boolean', + }, + remotePatterns: { + items: { + additionalProperties: false, + properties: { + hostname: { + minLength: 1, + type: 'string', + }, + pathname: { + minLength: 1, + type: 'string', + }, + port: { + minLength: 1, + type: 'string', + }, + protocol: { + // automatic typing doesn't like enum + enum: ['http', 'https'] as any, + type: 'string', + }, + }, + type: 'object', + }, + type: 'array', + }, + unoptimized: { + type: 'boolean', + }, + }, + type: 'object', + }, + incrementalCacheHandlerPath: { + type: 'string', + }, + isrFlushToDisk: { + type: 'boolean', + }, + isrMemoryCacheSize: { + type: 'number', + }, + largePageDataBytes: { + type: 'number', + }, + legacyBrowsers: { + type: 'boolean', + }, + manualClientBasePath: { + type: 'boolean', + }, + modularizeImports: { + type: 'object', + }, + newNextLinkBehavior: { + type: 'boolean', + }, + nextScriptWorkers: { + type: 'boolean', + }, + optimizeCss: { + type: 'boolean', + }, + outputFileTracingRoot: { + minLength: 1, + type: 'string', + }, + pageEnv: { + type: 'boolean', + }, + profiling: { + type: 'boolean', + }, + runtime: { + // automatic typing doesn't like enum + enum: ['experimental-edge', 'nodejs'] as any, + type: 'string', + }, + scrollRestoration: { + type: 'boolean', + }, + serverComponents: { + type: 'boolean', + }, + sharedPool: { + type: 'boolean', + }, + swcFileReading: { + type: 'boolean', + }, + swcMinify: { + type: 'boolean', + }, + swcMinifyDebugOptions: { + additionalProperties: false, + properties: { + compress: { + type: 'object', + }, + mangle: { + type: 'object', + }, + }, + type: 'object', + }, + swcPlugins: { + type: 'array', + }, + swcTraceProfiling: { + type: 'boolean', + }, + urlImports: { + items: { + type: 'string', + }, + type: 'array', + }, + workerThreads: { + type: 'boolean', + }, + }, + type: 'object', + }, + future: { + additionalProperties: false, + properties: {}, + type: 'object', + }, + generateBuildId: { + isFunction: true, + } as any, + generateEtags: { + isFunction: true, + } as any, + headers: { + isFunction: true, + } as any, + httpAgentOptions: { + additionalProperties: false, + properties: { + keepAlive: { + type: 'boolean', + }, + }, + type: 'object', + }, + i18n: { + additionalProperties: false, + properties: { + defaultLocale: { + minLength: 1, + type: 'string', + }, + domains: { + items: { + additionalProperties: false, + properties: { + defaultLocale: { + minLength: 1, + type: 'string', + }, + domain: { + minLength: 1, + type: 'string', + }, + http: { + type: 'boolean', + }, + locales: { + items: { + minLength: 1, + type: 'string', + }, + type: 'array', + }, + }, + type: 'object', + }, + type: 'array', + }, + localeDetection: { + type: 'boolean', + }, + locales: { + items: { + minLength: 1, + type: 'string', + }, + type: 'array', + }, + }, + type: 'object', + }, + images: { + additionalProperties: false, + properties: { + contentSecurityPolicy: { + minLength: 1, + type: 'string', + }, + dangerouslyAllowSVG: { + type: 'boolean', + }, + deviceSizes: { + items: { + type: 'number', + }, + minItems: 1, + type: 'array', + }, + disableStaticImages: { + type: 'boolean', + }, + domains: { + items: { + type: 'string', + }, + type: 'array', + }, + formats: { + items: { + enum: ['image/avif', 'image/webp'], // automatic typing does not like enum + type: 'string', + } as any, + type: 'array', + }, + imageSizes: { + items: { + type: 'number', + }, + minItems: 1, + type: 'array', + }, + loader: { + // automatic typing does not like enum + enum: VALID_LOADERS as any, + type: 'string', + }, + minimumCacheTTL: { + type: 'number', + }, + path: { + minLength: 1, + type: 'string', + }, + }, + type: 'object', + }, + onDemandEntries: { + additionalProperties: false, + properties: { + maxInactiveAge: { + type: 'number', + }, + pagesBufferLength: { + type: 'number', + }, + }, + type: 'object', + }, + optimizeFonts: { + type: 'boolean', + }, + output: { + // automatic typing doesn't like enum + enum: ['standalone'] as any, + type: 'string', + }, + outputFileTracing: { + type: 'boolean', + }, + pageExtensions: { + minItems: 1, + type: 'array', + }, + poweredByHeader: { + type: 'boolean', + }, + productionBrowserSourceMaps: { + type: 'boolean', + }, + publicRuntimeConfig: { + type: 'object', + }, + reactStrictMode: { + type: 'boolean', + }, + redirects: { + isFunction: true, + } as any, + rewrites: { + isFunction: true, + } as any, + sassOptions: { + type: 'object', + }, + serverRuntimeConfig: { + type: 'object', + }, + staticPageGenerationTimeout: { + type: 'number', + }, + swcMinify: { + type: 'boolean', + }, + trailingSlash: { + type: 'boolean', + }, + typescript: { + additionalProperties: false, + properties: { + ignoreBuildErrors: { + type: 'boolean', + }, + tsconfigPath: { + minLength: 1, + type: 'string', + }, + }, + type: 'object', + }, + useFileSystemPublicRoutes: { + type: 'boolean', + }, + webpack: { + isFunction: true, + } as any, + }, +} as JSONSchemaType + +// module.exports is used to get around an export bug with TypeScript +// and the Ajv automatic typing +module.exports = { + configSchema, +} diff --git a/packages/next/server/config-shared.ts b/packages/next/server/config-shared.ts index 3ca8aa5e75f4..a1dbbb5110e0 100644 --- a/packages/next/server/config-shared.ts +++ b/packages/next/server/config-shared.ts @@ -566,3 +566,13 @@ export function isServerRuntime(value?: string): value is ServerRuntime { value === undefined || value === 'nodejs' || value === 'experimental-edge' ) } + +export function validateConfig(userConfig: NextConfig): { + errors?: Array | null +} { + const configValidator = require('next/dist/next-config-validate.js') + configValidator(userConfig) + return { + errors: configValidator.errors, + } +} diff --git a/packages/next/server/config.ts b/packages/next/server/config.ts index 181282c00b2a..b982bbf20a62 100644 --- a/packages/next/server/config.ts +++ b/packages/next/server/config.ts @@ -12,6 +12,7 @@ import { normalizeConfig, ExperimentalConfig, NextConfigComplete, + validateConfig, } from './config-shared' import { loadWebpackHook } from './config-utils' import { @@ -44,23 +45,6 @@ const experimentalWarning = execOnce( } ) -const missingExperimentalWarning = execOnce( - (configFileName: string, features: string[]) => { - const s = features.length > 1 ? 's' : '' - const dont = features.length > 1 ? 'do not' : 'does not' - const them = features.length > 1 ? 'them' : 'it' - Log.warn( - chalk.bold( - `You have defined experimental feature${s} (${features.join( - ', ' - )}) in ${configFileName} that ${dont} exist in this version of Next.js.` - ) - ) - Log.warn(`Please remove ${them} from your configuration.`) - console.warn() - } -) - function assignDefaults(userConfig: { [key: string]: any }) { const configFileName = userConfig.configFileName if (typeof userConfig.exportTrailingSlash !== 'undefined') { @@ -83,7 +67,6 @@ function assignDefaults(userConfig: { [key: string]: any }) { } if (key === 'experimental' && typeof value === 'object') { - const enabledMissingExperiments: string[] = [] const enabledExperiments: (keyof ExperimentalConfig)[] = [] // defaultConfig.experimental is predefined and will never be undefined @@ -92,9 +75,7 @@ function assignDefaults(userConfig: { [key: string]: any }) { for (const featureName of Object.keys( value ) as (keyof ExperimentalConfig)[]) { - if (!(featureName in defaultConfig.experimental)) { - enabledMissingExperiments.push(featureName) - } else if ( + if ( value[featureName] !== defaultConfig.experimental[featureName] ) { enabledExperiments.push(featureName) @@ -102,9 +83,6 @@ function assignDefaults(userConfig: { [key: string]: any }) { } } - if (enabledMissingExperiments.length > 0) { - missingExperimentalWarning(configFileName, enabledMissingExperiments) - } if (enabledExperiments.length > 0) { experimentalWarning(configFileName, enabledExperiments) } @@ -796,6 +774,16 @@ export default async function loadConfig( userConfigModule.default || userConfigModule ) + const validateResult = validateConfig(userConfig) + + if (validateResult.errors) { + Log.warn(`Invalid next.config.js options detected: `) + console.error( + JSON.stringify(validateResult.errors, null, 2), + '\nSee more info here: https://nextjs.org/docs/messages/invalid-next-config' + ) + } + if (Object.keys(userConfig).length === 0) { Log.warn( `Detected ${configFileName}, no exported configuration found. https://nextjs.org/docs/messages/empty-configuration` diff --git a/packages/next/taskfile.js b/packages/next/taskfile.js index 785ef9efcf37..cd2ec074c83b 100644 --- a/packages/next/taskfile.js +++ b/packages/next/taskfile.js @@ -176,6 +176,47 @@ export async function ncc_node_fetch(task, opts) { .target('compiled/node-fetch') } +// eslint-disable-next-line camelcase +export async function compile_config_schema(task, opts) { + const { configSchema } = require('./dist/server/config-schema') + // eslint-disable-next-line + const Ajv = require('ajv') + // eslint-disable-next-line + const standaloneCode = require('ajv/dist/standalone').default + // eslint-disable-next-line + const ajv = new Ajv({ code: { source: true }, allErrors: true }) + ajv.addKeyword({ + keyword: 'isFunction', + schemaType: 'boolean', + compile() { + return (data) => data instanceof Function + }, + code(ctx) { + const { data } = ctx + ctx.fail(Ajv._`!(${data} instanceof Function)`) + }, + metaSchema: { + anyOf: [{ type: 'boolean' }], + }, + }) + + const compiled = ajv.compile(configSchema) + const validateCode = standaloneCode(ajv, compiled) + const preNccFilename = join(__dirname, 'dist', 'next-config-validate.js') + await fs.writeFile(preNccFilename, validateCode) + await task + .source(opts.src || './dist/next-config-validate.js') + .ncc({}) + .target('dist/next-config-validate') + + await fs.unlink(preNccFilename) + await fs.rename( + join(__dirname, 'dist/next-config-validate/next-config-validate.js'), + join(__dirname, 'dist/next-config-validate.js') + ) + await fs.rmdir(join(__dirname, 'dist/next-config-validate')) +} + // eslint-disable-next-line camelcase externals['acorn'] = 'next/dist/compiled/acorn' export async function ncc_acorn(task, opts) { @@ -1953,7 +1994,7 @@ export async function trace(task, opts) { } export async function build(task, opts) { - await task.serial(['precompile', 'compile'], opts) + await task.serial(['precompile', 'compile', 'compile_config_schema'], opts) } export default async function (task) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 249e876069f3..a8d876c1c7cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -468,6 +468,7 @@ importers: '@vercel/ncc': 0.33.4 '@vercel/nft': 0.20.0 acorn: 8.5.0 + ajv: 8.11.0 amphtml-validator: 1.0.35 arg: 4.1.0 assert: 2.0.0 @@ -660,6 +661,7 @@ importers: '@vercel/ncc': 0.33.4 '@vercel/nft': 0.20.0 acorn: 8.5.0 + ajv: 8.11.0 amphtml-validator: 1.0.35 arg: 4.1.0 assert: 2.0.0 @@ -6685,6 +6687,15 @@ packages: require-from-string: 2.0.2 uri-js: 4.2.2 + /ajv/8.11.0: + resolution: {integrity: sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==} + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js: 4.2.2 + dev: true + /alex/9.1.0: resolution: {integrity: sha512-mlNQ0CBGinzZj1pjiXaSLsihjZ4Kzq0U0EjR+DrZ3IQQfM4pf4OtxHI1agBIiEwv0tQUzimjgTk+5t9iHeT7Vw==} hasBin: true @@ -21921,7 +21932,7 @@ packages: dev: true /unset-value/1.0.0: - resolution: {integrity: sha1-g3aHP30jNRef+x5vw6jtDfyKtVk=} + resolution: {integrity: sha512-PcA2tsuGSF9cnySLHTLSh2qrQiJ70mn+r+Glzxv2TWZblxsxCC52BDlZoPCsz7STd9pN7EZetkWZBAvk4cgZdQ==} engines: {node: '>=0.10.0'} requiresBuild: true dependencies: @@ -22001,7 +22012,7 @@ packages: punycode: 2.1.1 /urix/0.1.0: - resolution: {integrity: sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=} + resolution: {integrity: sha512-Am1ousAhSLBeB9cG/7k7r2R0zj50uDRlZHPGbazid5s9rlF1F/QKYObEKSIunSjIOkJZqwRRLpvewjEkM7pSqg==} deprecated: Please see https://github.com/lydell/urix#deprecated requiresBuild: true diff --git a/test/integration/config-experimental-warning/test/index.test.js b/test/integration/config-experimental-warning/test/index.test.js index 151684dfb9ee..1f63e260e5c4 100644 --- a/test/integration/config-experimental-warning/test/index.test.js +++ b/test/integration/config-experimental-warning/test/index.test.js @@ -113,50 +113,4 @@ describe('Config Experimental Warning', () => { 'You have enabled experimental feature (newNextLinkBehavior) in next.config.mjs.' ) }) - - it('should show warning with next.config.js from object with non-exist experimental', async () => { - configFile.write(` - const config = { - experimental: { - foo: true - } - } - module.exports = config - `) - const { stderr } = await nextBuild(appDir, [], { stderr: true }) - expect(stderr).toMatch( - 'You have defined experimental feature (foo) in next.config.js that does not exist in this version' - ) - }) - - it('should show warning with next.config.mjs from object with non-exist experimental', async () => { - configFileMjs.write(` - const config = { - experimental: { - foo: true - } - } - export default config - `) - const { stderr } = await nextBuild(appDir, [], { stderr: true }) - expect(stderr).toMatch( - 'You have defined experimental feature (foo) in next.config.mjs that does not exist in this version' - ) - }) - - it('should show warning with next.config.js from object with multiple non-exist experimental', async () => { - configFile.write(` - const config = { - experimental: { - foo: true, - bar: false - } - } - module.exports = config - `) - const { stderr } = await nextBuild(appDir, [], { stderr: true }) - expect(stderr).toMatch( - 'You have defined experimental features (foo, bar) in next.config.js that do not exist in this version' - ) - }) }) diff --git a/test/integration/config-validation/pages/index.js b/test/integration/config-validation/pages/index.js new file mode 100644 index 000000000000..08263e34c35f --- /dev/null +++ b/test/integration/config-validation/pages/index.js @@ -0,0 +1,3 @@ +export default function Page() { + return

index page

+} diff --git a/test/integration/config-validation/test/index.test.ts b/test/integration/config-validation/test/index.test.ts new file mode 100644 index 000000000000..276998f9a9f1 --- /dev/null +++ b/test/integration/config-validation/test/index.test.ts @@ -0,0 +1,62 @@ +import path from 'path' +import { nextBuild } from 'next-test-utils' +import fs from 'fs-extra' + +const nextConfigPath = path.join(__dirname, '../next.config.js') + +describe('next.config.js validation', () => { + it.each([ + { + name: 'invalid config types', + configContent: ` + module.exports = { + swcMinify: 'hello', + rewrites: true, + images: { + loader: 'something' + } + } + `, + outputs: [ + '/images/loader', + 'must be equal to one of the allowed values', + 'imgix', + '/rewrites', + 'must pass \\"isFunction\\" keyword validation', + '/swcMinify', + 'must be boolean', + ], + }, + { + name: 'unexpected config fields', + configContent: ` + module.exports = { + nonExistent: true, + experimental: { + anotherNonExistent: true + } + } + `, + outputs: [ + 'nonExistent', + 'must NOT have additional properties', + 'anotherNonExistent', + 'must NOT have additional properties', + ], + }, + ])( + 'it should validate correctly for $name', + async ({ outputs, configContent }) => { + await fs.writeFile(nextConfigPath, configContent) + const result = await nextBuild(path.join(__dirname, '../'), undefined, { + stderr: true, + stdout: true, + }) + await fs.remove(nextConfigPath) + + for (const output of outputs) { + expect(result.stdout + result.stderr).toContain(output) + } + } + ) +})