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
Add next experimental-test
command
#64352
Changes from 43 commits
852c078
c7881df
34b22bc
2a5934c
e47cdb3
5e71cda
abb1434
cd41ce2
2601aa5
bd63c40
99de698
424a2ed
0ebe076
932ea29
c44f927
2a3c8a3
73b3d0e
4c53c01
7637259
fdfcf25
ff24c14
4e2b506
100e40e
e93a408
1ad8fba
d3a7644
a4f42ff
bfa407d
ca86512
860fa64
593d8ad
aaa9478
03394d9
d4d04cf
5dca6ca
f03b956
b32e9fc
3d0b1d3
9249862
2687eb3
1276382
e947996
adcc378
6902027
c8afd70
3b7a17f
310ef22
1c93145
1b44741
fa4fef1
1596290
c4a36ce
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,195 @@ | ||
import { writeFileSync } from 'fs' | ||
import { getProjectDir } from '../lib/get-project-dir' | ||
import { printAndExit } from '../server/lib/utils' | ||
import loadConfig from '../server/config' | ||
import { PHASE_PRODUCTION_BUILD } from '../shared/lib/constants' | ||
import { | ||
hasNecessaryDependencies, | ||
type MissingDependency, | ||
} from '../lib/has-necessary-dependencies' | ||
import { installDependencies } from '../lib/install-dependencies' | ||
import type { NextConfigComplete } from '../server/config-shared' | ||
import findUp from 'next/dist/compiled/find-up' | ||
import { findPagesDir } from '../lib/find-pages-dir' | ||
import { verifyTypeScriptSetup } from '../lib/verify-typescript-setup' | ||
import path from 'path' | ||
import spawn from 'next/dist/compiled/cross-spawn' | ||
|
||
export interface NextTestOptions { | ||
testRunner?: string | ||
} | ||
|
||
export const SUPPORTED_TEST_RUNNERS_LIST = ['playwright'] as const | ||
export type SupportedTestRunners = (typeof SUPPORTED_TEST_RUNNERS_LIST)[number] | ||
|
||
const requiredPackagesByTestRunner: { | ||
[k in SupportedTestRunners]: MissingDependency[] | ||
} = { | ||
playwright: [ | ||
{ file: 'playwright', pkg: '@playwright/test', exportsRestrict: false }, | ||
], | ||
} | ||
|
||
export async function nextTest( | ||
directory?: string, | ||
testRunnerArgs: string[] = [], | ||
options: NextTestOptions = {} | ||
) { | ||
// The following mess is in order to support an existing Next.js CLI pattern of optionally, passing a project `directory` as the first argument to execute the command on. | ||
// This is problematic for `next test` because as a wrapper around a test runner's `test` command, it needs to pass through any additional arguments and options. | ||
// Thus, `directory` could either be a valid Next.js project directory (that the user intends to run `next test` on), or it is the first argument for the test runner. | ||
// Unfortunately, since many test runners support passing a path (to a test file or directory containing test files), we must check if `directory` is both a valid path and a valid Next.js project. | ||
|
||
let baseDir, nextConfig | ||
|
||
try { | ||
// if directory is `undefined` or a valid path this will succeed. | ||
baseDir = getProjectDir(directory, false) | ||
} catch (err) { | ||
// if that failed, then `directory` is not a valid path, so it must have meant to be the first item for `testRunnerArgs` | ||
// @ts-expect-error directory is a string here since `getProjectDir` will succeed if its undefined | ||
testRunnerArgs.unshift(directory) | ||
// intentionally set baseDir to the resolved '.' path | ||
baseDir = getProjectDir() | ||
} | ||
|
||
try { | ||
// but, `baseDir` might not be a Next.js project directory, it could be a path-like argument for the test runner (i.e. `playwright test test/foo.spec.js`) | ||
// if this succeeds, it means that `baseDir` is a Next.js project directory | ||
nextConfig = await loadConfig(PHASE_PRODUCTION_BUILD, baseDir) | ||
} catch (err) { | ||
// if it doesn't, then most likely `baseDir` is not a Next.js project directory | ||
// @ts-expect-error directory is a string here since `getProjectDir` will succeed if its undefined | ||
testRunnerArgs.unshift(directory) | ||
// intentionally set baseDir to the resolved '.' path | ||
baseDir = getProjectDir() | ||
nextConfig = await loadConfig(PHASE_PRODUCTION_BUILD, baseDir) // let this error bubble up if the `basePath` is still not a valid Next.js project | ||
} | ||
|
||
// set the test runner. priority is CLI option > next config > default 'playwright' | ||
const configuredTestRunner = | ||
options?.testRunner ?? // --test-runner='foo' | ||
nextConfig.experimental.defaultTestRunner ?? // { experimental: { defaultTestRunner: 'foo' }} | ||
'playwright' | ||
|
||
if (!nextConfig.experimental.testProxy) { | ||
return printAndExit( | ||
`\`next experimental-test\` requires the \`experimental.testProxy: true\` configuration option.` | ||
) | ||
} | ||
|
||
// execute test runner specific function | ||
switch (configuredTestRunner) { | ||
case 'playwright': | ||
return runPlaywright(baseDir, nextConfig, testRunnerArgs) | ||
default: | ||
return printAndExit( | ||
`Test runner ${configuredTestRunner} is not supported.` | ||
) | ||
} | ||
} | ||
|
||
async function checkRequiredDeps( | ||
baseDir: string, | ||
testRunner: SupportedTestRunners | ||
) { | ||
const deps = await hasNecessaryDependencies( | ||
baseDir, | ||
requiredPackagesByTestRunner[testRunner] | ||
) | ||
if (deps.missing.length > 0) { | ||
await installDependencies(baseDir, deps.missing, true) | ||
|
||
const playwright = spawn( | ||
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. Nit for future, maybe swap with execa, it already does promises! |
||
path.join(baseDir, 'node_modules', '.bin', 'playwright'), | ||
['install'], | ||
{ | ||
cwd: baseDir, | ||
shell: false, | ||
stdio: 'inherit', | ||
env: { | ||
...process.env, | ||
}, | ||
} | ||
) | ||
|
||
return new Promise((resolve, reject) => { | ||
playwright.on('close', (c) => resolve(c)) | ||
playwright.on('error', (err) => reject(err)) | ||
}) | ||
} | ||
} | ||
|
||
async function runPlaywright( | ||
baseDir: string, | ||
nextConfig: NextConfigComplete, | ||
testRunnerArgs: string[] | ||
) { | ||
await checkRequiredDeps(baseDir, 'playwright') | ||
|
||
const playwrightConfigFile = await findUp( | ||
['playwright.config.js', 'playwright.config.ts'], | ||
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. Does playwright also support 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. I don't think so. I can only find references to these two in the docs |
||
{ | ||
cwd: baseDir, | ||
} | ||
) | ||
|
||
if (!playwrightConfigFile) { | ||
const { pagesDir, appDir } = findPagesDir(baseDir) | ||
|
||
const { version: typeScriptVersion } = await verifyTypeScriptSetup({ | ||
dir: baseDir, | ||
distDir: nextConfig.distDir, | ||
intentDirs: [pagesDir, appDir].filter(Boolean) as string[], | ||
typeCheckPreflight: false, | ||
tsconfigPath: nextConfig.typescript.tsconfigPath, | ||
disableStaticImages: nextConfig.images.disableStaticImages, | ||
hasAppDir: !!appDir, | ||
hasPagesDir: !!pagesDir, | ||
}) | ||
|
||
const isUsingTypeScript = !!typeScriptVersion | ||
|
||
const playwrightConfigFilename = isUsingTypeScript | ||
? 'playwright.config.ts' | ||
: 'playwright.config.js' | ||
|
||
writeFileSync( | ||
path.join(baseDir, playwrightConfigFilename), | ||
defaultPlaywrightConfig(isUsingTypeScript) | ||
) | ||
|
||
return printAndExit( | ||
`Successfully generated ${playwrightConfigFilename}. Create your first test and then run \`next experimental-test\`.`, | ||
0 | ||
) | ||
} else { | ||
const playwright = spawn( | ||
path.join(baseDir, 'node_modules', '.bin', 'playwright'), | ||
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. This doesn't work in monorepos where the |
||
['test', ...testRunnerArgs], | ||
{ | ||
cwd: baseDir, | ||
shell: false, | ||
stdio: 'inherit', | ||
env: { | ||
...process.env, | ||
}, | ||
} | ||
) | ||
return new Promise((resolve, reject) => { | ||
playwright.on('close', (c) => resolve(c)) | ||
playwright.on('error', (err) => reject(err)) | ||
}) | ||
} | ||
} | ||
|
||
function defaultPlaywrightConfig(typescript: boolean) { | ||
const comment = `/* | ||
* Specify any additional Playwright config options here. | ||
* They will be deep merged with Next.js' default Playwright config. | ||
* You can access the default config by using a function: \`withNext((config) => {})\` | ||
*/` | ||
return typescript | ||
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. Do we do the same string concetanation for default tsconfig or eslint config? Reading from a file seems more robust and means existing validation tools in CI check that the config file is actually valid. 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. I mentioned this before, but our TS config fails when we make this standalone because the import path |
||
? `import { withNext } from 'next/experimental/testmode/playwright';\n\n${comment}\nexport default withNext();` | ||
: `const { withNext } = require('next/experimental/testmode/playwright');\n\n${comment}\nmodule.exports = withNext();` | ||
Ethan-Arrowood marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,43 @@ | ||
import { devices, type PlaywrightTestConfig } from '@playwright/test' | ||
import type { NextOptionsConfig } from './next-options' | ||
|
||
/** | ||
* This is the default configuration generated by Playwright as of v1.43.0 with some modifications. | ||
* | ||
* - the `testMatch` property is configured to match all `*.spec.js` or `*.spec.ts` files within the `app` and `pages` directories | ||
* - the `use` property is configured with a baseURL matching the expected dev server endpoint (http://127.0.0.1:3000) | ||
* - the `webserver` property is configured to run `next dev`. | ||
*/ | ||
export const defaultPlaywrightConfig: PlaywrightTestConfig<NextOptionsConfig> = | ||
{ | ||
testMatch: '{app,pages}/**/*.spec.{t,j}s', | ||
fullyParallel: true, | ||
forbidOnly: process.env.CI === 'true', | ||
retries: process.env.CI === 'true' ? 2 : 0, | ||
reporter: [['list'], ['html', { open: 'never' }]], | ||
use: { | ||
baseURL: 'http://127.0.0.1:3000', | ||
trace: 'on-first-retry', | ||
}, | ||
projects: [ | ||
{ | ||
name: 'chromium', | ||
use: { ...devices['Desktop Chrome'] }, | ||
}, | ||
|
||
{ | ||
name: 'firefox', | ||
use: { ...devices['Desktop Firefox'] }, | ||
}, | ||
|
||
{ | ||
name: 'webkit', | ||
use: { ...devices['Desktop Safari'] }, | ||
}, | ||
], | ||
webServer: { | ||
command: process.env.CI === 'true' ? 'next start' : 'next dev', | ||
url: 'http://127.0.0.1:3000', | ||
reuseExistingServer: process.env.CI !== 'true', | ||
}, | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,7 @@ | ||
export interface NextOptions { | ||
fetchLoopback?: boolean | ||
} | ||
|
||
export interface NextOptionsConfig { | ||
nextOptions?: NextOptions | ||
} |
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.
Can we just not support this pattern instead for now until we discussed internally if we shouldn't get rid of this pattern completely? It's an experimental feature after all so I'd rather get the base functionality right without having to support each edge case.
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.
imo its more important to support this pattern as the rest of the CLI does and then if we can make a change to it we can come through and remove it here as well. This is gross code, but it works. Same "its experimental" mentality applies I think