diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4aaf8b2..483f38e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,10 +7,12 @@ on: jobs: build: runs-on: ${{ matrix.os }} + # Node.js 22 is the main version, newer versions are optional. + continue-on-error: ${{ matrix.node-version != '22.x' }} strategy: fail-fast: false matrix: - node-version: [20.x, 22.x, 24.x, 26.x] + node-version: [22.x, 24.x, 26.x] os: [ubuntu-latest, windows-latest, macos-latest] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 99415d7..6a3a7e3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -13,7 +13,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: 20 + node-version: 22 registry-url: https://registry.npmjs.org/ # Ensure npm 11.5.1 or later is installed (for OIDC npm publishing) - name: Update npm diff --git a/assets/github-actions.yml b/assets/github-actions.yml index 5885ac5..ceec2e1 100644 --- a/assets/github-actions.yml +++ b/assets/github-actions.yml @@ -12,7 +12,9 @@ jobs: - uses: actions/setup-node@v6 with: node-version: lts/* + //--begin-npm-cache cache: "npm" + //--end-npm-cache - name: Install dependencies run: {{installDepsCommand}} - name: Install Playwright Browsers diff --git a/src/cli.ts b/src/cli.ts index 5cb8f27..b8137ed 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -17,6 +17,7 @@ import path from 'path'; import { Command } from 'commander'; import { CliOptions, Generator } from './generator'; +import { parseBrowsers, parseLanguage } from './utils'; const program = new Command(); @@ -24,7 +25,7 @@ program .name('create-playwright') .description('Getting started with writing end-to-end tests with Playwright.') .argument('[rootDir]', 'Target directory for the Playwright project', '.') - .option('--browser ', `browsers to use in default config (default: 'chromium,firefox,webkit')`) + .option('--browser ', `browsers to use in default config (default: 'chromium,firefox,webkit')`, parseBrowsers) .option('--no-browsers', 'do not download browsers (can be done manually via \'npx playwright install\')') .option('--no-examples', 'do not create example test files') .option('--install-deps', 'install dependencies') @@ -33,8 +34,9 @@ program .option('--quiet', 'do not ask for interactive input prompts') .option('--gha', 'install GitHub Actions') .option('--no-gha', 'do not install GitHub Actions') - .option('--lang ', 'language to use (js, TypeScript)') + .option('--lang ', 'language to use (js, TypeScript)', parseLanguage) .option('--test-dir ', 'directory for test files (default: "tests" if it does not exist, otherwise "e2e")') + .option('--force', 'override existing files without asking') .action(async (rootDir, options) => { const cliOptions: CliOptions = { @@ -49,6 +51,7 @@ program gha: options.gha, lang: options.lang, testDir: options.testDir, + force: options.force, }; const resolvedRootDir = path.resolve(process.cwd(), rootDir || '.'); const generator = new Generator(resolvedRootDir, cliOptions); diff --git a/src/generator.ts b/src/generator.ts index 52e2d8b..80240f0 100644 --- a/src/generator.ts +++ b/src/generator.ts @@ -21,7 +21,7 @@ import { prompt } from 'enquirer'; import ini from 'ini'; import { type PackageManager, determinePackageManager } from './packageManager'; -import { Command, createFiles, executeCommands, executeTemplate, languageToFileExtension } from './utils'; +import { type BrowserName, browserNames, Command, escapeJSONString, escapeJSString, executeCommands, executeTemplate, languageToFileExtension, resolveFileConflicts, writeFiles } from './utils'; export type PromptOptions = { testDir: string, @@ -34,8 +34,8 @@ export type PromptOptions = { const assetsDir = path.join(__dirname, '..', 'assets'); export type CliOptions = { - lang?: string; - browser?: string[]; + lang?: PromptOptions['language']; + browser?: BrowserName[]; noBrowsers?: boolean; noExamples?: boolean; installDeps?: boolean; @@ -44,6 +44,13 @@ export type CliOptions = { quiet?: boolean; gha?: boolean; testDir?: string; + force?: boolean; +}; + +const browserDevices: Record = { + chromium: 'Desktop Chrome', + firefox: 'Desktop Firefox', + webkit: 'Desktop Safari', }; export class Generator { @@ -61,14 +68,16 @@ export class Generator { this._printPrologue(); const answers = await this._askQuestions(); const { files, commands: allCommands } = await this._identifyChanges(answers); + // Resolve conflicts with existing files before installing anything. + const filesToWrite = await resolveFileConflicts(this.rootDir, files, { force: !!this.options.force, quiet: !!this.options.quiet }); const [preCommands, postCommands] = allCommands.reduce((acc, command) => { acc[command.phase === 'pre' ? 0 : 1].push(command); return acc; }, [[] as Command[], [] as Command[]]); executeCommands(this.rootDir, preCommands); - await createFiles(this.rootDir, files, false, !!this.options.quiet); + writeFiles(this.rootDir, filesToWrite); this._patchGitIgnore(); - await this._patchPackageJSON(); + this._patchPackageJSON(); executeCommands(this.rootDir, postCommands); this._printEpilogue(answers); } @@ -87,7 +96,7 @@ export class Generator { if (this.options.quiet) { return { installGitHubActions: !!this.options.gha, - language: this.options.lang === 'js' ? 'JavaScript' : 'TypeScript', + language: this.options.lang ?? 'TypeScript', installPlaywrightDependencies: !!this.options.installDeps, testDir, installPlaywrightBrowsers: !this.options.noBrowsers, @@ -146,7 +155,7 @@ export class Generator { if (isDefinitelyTS) result.language = 'TypeScript'; else if (this.options.lang) - result.language = this.options.lang === 'js' ? 'JavaScript' : 'TypeScript'; + result.language = this.options.lang; return result; } @@ -156,27 +165,30 @@ export class Generator { const fileExtension = languageToFileExtension(answers.language); const sections = new Map(); - for (const browserName of ['chromium', 'firefox', 'webkit']) - sections.set(browserName, !this.options.browser || this.options.browser.includes(browserName) ? 'show' : 'comment'); + for (const browserName of browserNames) + sections.set(browserName, this._isBrowserEnabled(browserName) ? 'show' : 'comment'); + const browsersSuffix = this.options.browser ? ' ' + this.options.browser.join(' ') : ''; if (answers.language === 'TypeScript' && !fs.existsSync(path.join(this.rootDir, 'tsconfig.json'))) { files.set('tsconfig.json', executeTemplate(this._readAsset('tsconfig.json'), { - testDir: answers.testDir || '.', + testDir: escapeJSONString(answers.testDir || '.'), }, new Map())); } files.set(`playwright.config.${fileExtension}`, executeTemplate(this._readAsset(`playwright.config.${fileExtension}`), { - testDir: answers.testDir || '', + testDir: escapeJSString(answers.testDir || ''), }, sections)); if (answers.installGitHubActions) { const npmrcExists = fs.existsSync(path.join(this.rootDir, '.npmrc')); const packageLockDisabled = npmrcExists && ini.parse(fs.readFileSync(path.join(this.rootDir, '.npmrc'), 'utf-8'))['package-lock'] === false; + // actions/setup-node fails when the lock file for its cache is missing, so only cache npm with a package-lock.json. + const useNpmCache = this.packageManager.name === 'NPM' && !packageLockDisabled; const githubActionsScript = executeTemplate(this._readAsset('github-actions.yml'), { installDepsCommand: packageLockDisabled ? this.packageManager.i() : this.packageManager.ci(), - installPlaywrightCommand: this.packageManager.npx('playwright', 'install --with-deps'), + installPlaywrightCommand: this.packageManager.npx('playwright', 'install --with-deps' + browsersSuffix), runTestsCommand: this.packageManager.runPlaywrightTest(), - }, new Map()); + }, new Map([['npm-cache', useNpmCache ? 'show' : 'hide']])); files.set('.github/workflows/playwright.yml', githubActionsScript); } @@ -211,7 +223,6 @@ export class Generator { }); } - const browsersSuffix = this.options.browser ? ' ' + this.options.browser.join(' ') : ''; if (answers.installPlaywrightBrowsers) { commands.push({ name: 'Downloading browsers', @@ -226,6 +237,10 @@ export class Generator { return { files, commands }; } + private _isBrowserEnabled(browserName: BrowserName) { + return !this.options.browser || this.options.browser.includes(browserName); + } + private _hasDependency(pkg: string) { try { const packageJSON = JSON.parse(fs.readFileSync(path.join(this.rootDir, 'package.json'), 'utf-8')); @@ -242,12 +257,13 @@ export class Generator { gitIgnore = fs.readFileSync(gitIgnorePath, 'utf-8').trimEnd() + '\n'; let thisIsTheFirstLineWeAreAdding = true; + // Match whole lines, with or without the leading and trailing slashes. const valuesToAdd = { - 'node_modules/': /^node_modules\/?/m, - '/test-results/': /^\/?test-results\/?$/m, - '/playwright-report/': /^\/playwright-report\/?$/m, - '/blob-report/': /^\/blob-report\/?$/m, - '/playwright/.auth/': /^\/playwright\/\.auth\/?$/m, + 'node_modules/': /^\/?node_modules\/?\s*$/m, + '/test-results/': /^\/?test-results\/?\s*$/m, + '/playwright-report/': /^\/?playwright-report\/?\s*$/m, + '/blob-report/': /^\/?blob-report\/?\s*$/m, + '/playwright/.auth/': /^\/?playwright\/\.auth\/?\s*$/m, }; Object.entries(valuesToAdd).forEach(([value, regex]) => { if (!gitIgnore.match(regex)) { @@ -266,52 +282,51 @@ export class Generator { return fs.readFileSync(path.isAbsolute(asset) ? asset : path.join(assetsDir, asset), 'utf-8'); } - private async _patchPackageJSON() { + private _patchPackageJSON() { const packageJSON = JSON.parse(fs.readFileSync(path.join(this.rootDir, 'package.json'), 'utf-8')); if (!packageJSON.scripts) packageJSON.scripts = {}; if (packageJSON.scripts['test']?.includes('no test specified')) delete packageJSON.scripts['test']; - const files = new Map(); - files.set('package.json', JSON.stringify(packageJSON, null, 2) + '\n'); // NPM keeps a trailing new-line - await createFiles(this.rootDir, files, true, false); + writeFiles(this.rootDir, new Map([['package.json', JSON.stringify(packageJSON, null, 2) + '\n']])); // NPM keeps a trailing new-line } private _printEpilogue(answers: PromptOptions) { console.log(colors.green('✔ Success!') + ' ' + colors.bold(`Created a Playwright Test project at ${this.rootDir}`)); const pathToNavigate = path.relative(process.cwd(), this.rootDir); const prefix = pathToNavigate !== '' ? ` cd ${pathToNavigate}\n` : ''; - const exampleSpecPath = path.join(answers.testDir, `example.spec.${languageToFileExtension(answers.language)}`); - const playwrightConfigPath = `playwright.config.${languageToFileExtension(answers.language)}`; - console.log(` -Inside that directory, you can run several commands: - - ${colors.cyan(this.packageManager.runPlaywrightTest())} - Runs the end-to-end tests. - - ${colors.cyan(this.packageManager.runPlaywrightTest('--ui'))} - Starts the interactive UI mode. + const fileExtension = languageToFileExtension(answers.language); + const project = browserNames.find(b => this._isBrowserEnabled(b)) ?? 'chromium'; - ${colors.cyan(this.packageManager.runPlaywrightTest('--project=chromium'))} - Runs the tests only on Desktop Chrome. + const commands: [string, string][] = [ + [this.packageManager.runPlaywrightTest(), 'Runs the end-to-end tests.'], + [this.packageManager.runPlaywrightTest('--ui'), 'Starts the interactive UI mode.'], + [this.packageManager.runPlaywrightTest(`--project=${project}`), `Runs the tests only on ${browserDevices[project]}.`], + ]; + if (!this.options.noExamples) + commands.push([this.packageManager.runPlaywrightTest('example'), 'Runs the tests in a specific file.']); + commands.push( + [this.packageManager.runPlaywrightTest('--debug'), 'Runs the tests in debug mode.'], + [this.packageManager.npx('playwright', 'codegen'), 'Auto generate tests with Codegen.'], + ); - ${colors.cyan(this.packageManager.runPlaywrightTest('example'))} - Runs the tests in a specific file. + const filesToCheck: [string, string][] = []; + if (!this.options.noExamples) + filesToCheck.push([path.join(answers.testDir, `example.spec.${fileExtension}`), 'Example end-to-end test']); + filesToCheck.push([`playwright.config.${fileExtension}`, 'Playwright Test configuration']); - ${colors.cyan(this.packageManager.runPlaywrightTest('--debug'))} - Runs the tests in debug mode. + console.log(` +Inside that directory, you can run several commands: - ${colors.cyan(this.packageManager.npx('playwright', 'codegen'))} - Auto generate tests with Codegen. +${commands.map(([command, description]) => ` ${colors.cyan(command)}\n ${description}`).join('\n\n')} We suggest that you begin by typing: ${colors.cyan(prefix + ' ' + this.packageManager.runPlaywrightTest())} And check out the following files: - - .${path.sep}${pathToNavigate ? path.join(pathToNavigate, exampleSpecPath) : exampleSpecPath} - Example end-to-end test - - .${path.sep}${pathToNavigate ? path.join(pathToNavigate, playwrightConfigPath) : playwrightConfigPath} - Playwright Test configuration +${filesToCheck.map(([file, description]) => ` - .${path.sep}${pathToNavigate ? path.join(pathToNavigate, file) : file} - ${description}`).join('\n')} Visit https://playwright.dev/docs/intro for more information. ✨ diff --git a/src/utils.ts b/src/utils.ts index 4e74c68..6d5fe39 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -20,6 +20,7 @@ import path from 'path'; import { prompt } from 'enquirer'; import colors from 'ansi-colors'; +import { InvalidArgumentError } from 'commander'; import { PromptOptions } from './generator'; export type Command = { @@ -37,29 +38,54 @@ export function executeCommands(cwd: string, commands: Command[]) { } } -export async function createFiles(rootDir: string, files: Map, force: boolean, quiet: boolean) { - const existingFiles = [...files.keys()].filter(f => fs.existsSync(path.join(rootDir, f))); - if (quiet && !force && existingFiles.length) { +/** + * Returns the files that need to be written. Existing files with the same content are skipped. + * Existing files with different content are overridden with `force` or after confirmation; + * in quiet mode, they abort the run before anything is installed or written. + */ +export async function resolveFileConflicts(rootDir: string, files: Map, options: { force: boolean, quiet: boolean }): Promise> { + const unchangedFiles: string[] = []; + const conflictingFiles: string[] = []; + for (const [relativeFilePath, value] of files) { + const absoluteFilePath = path.join(rootDir, relativeFilePath); + if (!fs.existsSync(absoluteFilePath)) + continue; + if (fs.readFileSync(absoluteFilePath, 'utf-8') === value) + unchangedFiles.push(relativeFilePath); + else + conflictingFiles.push(relativeFilePath); + } + + if (options.quiet && !options.force && conflictingFiles.length) { console.log('These files already exist:'); - for (const f of existingFiles) - console.log(` ${path.relative(process.cwd(), f)}`); + for (const f of conflictingFiles) + console.log(` ${path.relative(process.cwd(), path.join(rootDir, f))}`); console.log('If you want to override them, run again with --force.'); process.exit(1); - return; } + const result = new Map(); for (const [relativeFilePath, value] of files) { - const absoluteFilePath = path.join(rootDir, relativeFilePath); - if (existingFiles.includes(absoluteFilePath) && !force) { + if (unchangedFiles.includes(relativeFilePath)) + continue; + if (conflictingFiles.includes(relativeFilePath) && !options.force) { const { override } = await prompt<{ override: boolean }>({ type: 'confirm', name: 'override', - message: `${absoluteFilePath} already exists. Override it?`, + message: `${path.relative(process.cwd(), path.join(rootDir, relativeFilePath))} already exists. Override it?`, initial: false }); if (!override) continue; } + result.set(relativeFilePath, value); + } + return result; +} + +export function writeFiles(rootDir: string, files: Map) { + for (const [relativeFilePath, value] of files) { + const absoluteFilePath = path.join(rootDir, relativeFilePath); console.log(colors.gray(`Writing ${path.relative(process.cwd(), absoluteFilePath)}.`)); fs.mkdirSync(path.dirname(absoluteFilePath), { recursive: true }); fs.writeFileSync(absoluteFilePath, value, 'utf-8'); @@ -68,7 +94,7 @@ export async function createFiles(rootDir: string, files: Map, f export function executeTemplate(input: string, args: Record, sections: Map): string { for (const key in args) - input = input.replace(new RegExp('{{' + key + '}}', 'g'), args[key]!); + input = input.replace(new RegExp('{{' + key + '}}', 'g'), () => args[key]!); const result: string[] = []; let mode: 'show' | 'hide' | 'comment' = 'show'; let indent = ''; @@ -91,6 +117,45 @@ export function executeTemplate(input: string, args: Record, sec return result.join('\n'); } +// Escapes a value for use inside a double-quoted JSON string. +export function escapeJSONString(value: string): string { + return JSON.stringify(value).slice(1, -1); +} + +// Escapes a value for use inside a single-quoted JavaScript string. +export function escapeJSString(value: string): string { + return escapeJSONString(value).replace(/'/g, `\\'`); +} + +export const browserNames = ['chromium', 'firefox', 'webkit'] as const; +export type BrowserName = typeof browserNames[number]; + +export function parseBrowsers(value: string, previous: BrowserName[] = []): BrowserName[] { + const result = [...previous]; + for (const name of value.split(',').map(b => b.trim().toLowerCase()).filter(Boolean)) { + const browser = browserNames.find(b => b === name); + if (!browser) + throw new InvalidArgumentError(`Expected a comma-separated list of: ${browserNames.join(', ')}.`); + if (!result.includes(browser)) + result.push(browser); + } + if (!result.length) + throw new InvalidArgumentError(`Expected a comma-separated list of: ${browserNames.join(', ')}.`); + return result; +} + +export function parseLanguage(value: string): PromptOptions['language'] { + switch (value.toLowerCase()) { + case 'js': + case 'javascript': + return 'JavaScript'; + case 'ts': + case 'typescript': + return 'TypeScript'; + } + throw new InvalidArgumentError('Expected one of: js, ts, JavaScript, TypeScript.'); +} + export function languageToFileExtension(language: PromptOptions['language']): 'js' | 'ts' { return language === 'JavaScript' ? 'js' : 'ts'; } diff --git a/tests/generator.spec.ts b/tests/generator.spec.ts index 2dd4079..bdb187b 100644 --- a/tests/generator.spec.ts +++ b/tests/generator.spec.ts @@ -13,14 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import fs from 'fs'; +import path from 'path'; import { expect, test } from './baseFixtures'; import { Generator } from '../src/generator'; +import { escapeJSString, executeTemplate, parseBrowsers, parseLanguage, resolveFileConflicts } from '../src/utils'; // The CLI falls back to --quiet without a TTY, so the interactive path is exercised in-process. // A question that is not skipped would wait for input and time out the test. test('should not prompt for --lang js and --no-gha', async ({ dir }) => { - const generator = new Generator(dir, { lang: 'js', gha: false, testDir: 'specs', noBrowsers: true, installDeps: true }); + const generator = new Generator(dir, { lang: 'JavaScript', gha: false, testDir: 'specs', noBrowsers: true, installDeps: true }); expect(await generator['_askQuestions']()).toMatchObject({ language: 'JavaScript', installGitHubActions: false, @@ -30,7 +33,7 @@ test('should not prompt for --lang js and --no-gha', async ({ dir }) => { }); test('should not prompt for --lang ts and --gha', async ({ dir }) => { - const generator = new Generator(dir, { lang: 'ts', gha: true, testDir: 'specs', noBrowsers: true, installDeps: true }); + const generator = new Generator(dir, { lang: 'TypeScript', gha: true, testDir: 'specs', noBrowsers: true, installDeps: true }); expect(await generator['_askQuestions']()).toMatchObject({ language: 'TypeScript', installGitHubActions: true, @@ -38,3 +41,49 @@ test('should not prompt for --lang ts and --gha', async ({ dir }) => { installPlaywrightBrowsers: false, }); }); + +test('should ask before overriding existing files that differ', async ({ dir }) => { + fs.writeFileSync(path.join(dir, 'changed.txt'), 'old'); + fs.writeFileSync(path.join(dir, 'unchanged.txt'), 'same'); + const messages: string[] = []; + // Interactive prompts need a TTY, so answer them in-process. + const enquirer = require('enquirer'); + const descriptor = Object.getOwnPropertyDescriptor(enquirer, 'prompt')!; + Object.defineProperty(enquirer, 'prompt', { + configurable: true, + get: () => async (question: { message: string }) => { + messages.push(question.message); + return { override: false }; + }, + }); + try { + const files = new Map([['changed.txt', 'new'], ['unchanged.txt', 'same'], ['added.txt', 'new']]); + const result = await resolveFileConflicts(dir, files, { force: false, quiet: false }); + expect(messages).toEqual([`${path.relative(process.cwd(), path.join(dir, 'changed.txt'))} already exists. Override it?`]); + expect([...result.keys()]).toEqual(['added.txt']); + expect([...(await resolveFileConflicts(dir, files, { force: true, quiet: false })).keys()]).toEqual(['changed.txt', 'added.txt']); + expect(messages).toHaveLength(1); + } finally { + Object.defineProperty(enquirer, 'prompt', descriptor); + } +}); + +test('should parse --lang', async () => { + for (const value of ['js', 'JS', 'javascript', 'JavaScript']) + expect(parseLanguage(value)).toBe('JavaScript'); + for (const value of ['ts', 'TS', 'typescript', 'TypeScript']) + expect(parseLanguage(value)).toBe('TypeScript'); + expect(() => parseLanguage('python')).toThrow('Expected one of'); +}); + +test('should parse --browser', async () => { + expect(parseBrowsers('chromium,Firefox')).toEqual(['chromium', 'firefox']); + expect(parseBrowsers('webkit', parseBrowsers('chromium'))).toEqual(['chromium', 'webkit']); + expect(parseBrowsers('firefox, firefox')).toEqual(['firefox']); + expect(() => parseBrowsers('chrome')).toThrow('Expected a comma-separated list of: chromium, firefox, webkit.'); + expect(() => parseBrowsers(',')).toThrow('Expected a comma-separated list'); +}); + +test('should insert template values verbatim', async () => { + expect(executeTemplate(`testDir: '{{testDir}}'`, { testDir: escapeJSString(`it's $& $1 \\`) }, new Map())).toBe(`testDir: 'it\\'s $& $1 \\\\'`); +}); diff --git a/tests/integration.spec.ts b/tests/integration.spec.ts index 9e019b2..5ed0f04 100644 --- a/tests/integration.spec.ts +++ b/tests/integration.spec.ts @@ -17,6 +17,11 @@ import fs from 'fs'; import path from 'path'; import { assertLockFilesExist, expect, packageManagerToNpxCommand, test } from './baseFixtures'; +// Templates are checked out with CRLF line endings on Windows. +function readFile(file: string): string { + return fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n'); +} + const validGitignore = [ '# Playwright', 'node_modules/', @@ -73,6 +78,7 @@ test('should generate a project in the current directory', async ({ run, dir, pa test('should generate a project in a given directory', async ({ run, dir, packageManager }) => { test.skip(packageManager === 'yarn-classic' || packageManager === 'yarn-berry'); + test.slow(); await run(['foobar'], { installGitHubActions: true, testDir: 'tests', language: 'TypeScript', installPlaywrightDependencies: false, installPlaywrightBrowsers: true }); expect(fs.existsSync(path.join(dir, 'foobar/tsconfig.json'))).toBeTruthy(); expect(fs.existsSync(path.join(dir, 'foobar/tests/example.spec.ts'))).toBeTruthy(); @@ -83,6 +89,7 @@ test('should generate a project in a given directory', async ({ run, dir, packag }); test('should generate a project with JavaScript and without GHA', async ({ run, dir, packageManager }) => { + test.slow(); await run([], { installGitHubActions: false, testDir: 'tests', language: 'JavaScript', installPlaywrightDependencies: false, installPlaywrightBrowsers: true }); expect(fs.existsSync(path.join(dir, 'tests/example.spec.js'))).toBeTruthy(); expect(fs.existsSync(path.join(dir, 'package.json'))).toBeTruthy(); @@ -196,15 +203,34 @@ test('should not duplicate gitignore entries', async ({ run, dir }) => { expect(fs.readFileSync(path.join(dir, '.gitignore'), { encoding: 'utf8' }).trim()).toBe(validGitignore); }) +test('should match whole gitignore entries', async ({ run, dir, packageManager }) => { + test.skip(packageManager !== 'npm'); + fs.writeFileSync(path.join(dir, '.gitignore'), 'node_modules_cache/\r\nplaywright-report/\r\n/test-results\n'); + + await run([], { installGitHubActions: false, testDir: 'tests', language: 'TypeScript', installPlaywrightDependencies: false, installPlaywrightBrowsers: false }); + expect(fs.readFileSync(path.join(dir, '.gitignore'), { encoding: 'utf8' })).toBe([ + 'node_modules_cache/\r', + 'playwright-report/\r', + '/test-results', + '', + '# Playwright', + 'node_modules/', + '/blob-report/', + '/playwright/.auth/', + '', + ].join('\n')); +}) + test('should install with "npm ci" in GHA when using npm with package-lock enabled', async ({ dir, run, packageManager }) => { test.skip(packageManager !== 'npm'); await run([], { installGitHubActions: true, testDir: 'tests', language: 'JavaScript', installPlaywrightDependencies: false, installPlaywrightBrowsers: true }); expect(fs.existsSync(path.join(dir, '.github/workflows/playwright.yml'))).toBeTruthy(); - const workflowContent = fs.readFileSync(path.join(dir, '.github/workflows/playwright.yml'), 'utf8'); + const workflowContent = readFile(path.join(dir, '.github/workflows/playwright.yml')); expect(workflowContent).not.toContain('run: npm i'); expect(workflowContent).toContain('run: npm ci'); + expect(workflowContent).toContain(' node-version: lts/*\n cache: "npm"\n - name: Install dependencies'); }); test('should install with "npm i" in GHA when using npm with package-lock disabled', async ({ dir, run, packageManager }) => { @@ -214,21 +240,37 @@ test('should install with "npm i" in GHA when using npm with package-lock disabl await run([], { installGitHubActions: true, testDir: 'tests', language: 'JavaScript', installPlaywrightDependencies: false, installPlaywrightBrowsers: true }); expect(fs.existsSync(path.join(dir, '.github/workflows/playwright.yml'))).toBeTruthy(); - const workflowContent = fs.readFileSync(path.join(dir, '.github/workflows/playwright.yml'), 'utf8'); + const workflowContent = readFile(path.join(dir, '.github/workflows/playwright.yml')); expect(workflowContent).toContain('run: npm i'); expect(workflowContent).not.toContain('run: npm ci'); + // actions/setup-node fails to cache without a lock file. + expect(workflowContent).toContain(' node-version: lts/*\n - name: Install dependencies'); +}); + +test('should not use the npm cache in GHA for other package managers', async ({ dir, run, packageManager }) => { + test.skip(packageManager === 'npm'); + + await run([], { installGitHubActions: true, testDir: 'tests', language: 'JavaScript', installPlaywrightDependencies: false, installPlaywrightBrowsers: false }); + const workflowContent = fs.readFileSync(path.join(dir, '.github/workflows/playwright.yml'), 'utf8'); + expect(workflowContent).not.toContain('cache:'); + expect(workflowContent).not.toContain('//'); }); test('should not prompt and skip existing files in --quiet mode', async ({ run, dir }) => { + const options = { installGitHubActions: false, testDir: 'tests', language: 'TypeScript', installPlaywrightDependencies: false, installPlaywrightBrowsers: false } as const; // First run: generate the project normally - await run([], { installGitHubActions: false, testDir: 'tests', language: 'TypeScript', installPlaywrightDependencies: false, installPlaywrightBrowsers: false }); - const originalConfig = fs.readFileSync(path.join(dir, 'playwright.config.ts'), 'utf8'); - - // Second run: use --quiet, existing files should be skipped without prompting - await expect(run(['--quiet'], { installGitHubActions: false, testDir: 'tests', language: 'TypeScript', installPlaywrightDependencies: false, installPlaywrightBrowsers: false })).rejects.toThrowError("run again with --force"); - - // Verify the existing file was not overwritten - expect(fs.readFileSync(path.join(dir, 'playwright.config.ts'), 'utf8')).toBe(originalConfig); + await run([], options); + // Second run: unchanged files are not conflicts. + await run(['--quiet'], options); + + // Third run: modified files are not overwritten without prompting. + fs.writeFileSync(path.join(dir, 'playwright.config.ts'), '// modified'); + await expect(run(['--quiet'], options)).rejects.toThrowError("run again with --force"); + expect(fs.readFileSync(path.join(dir, 'playwright.config.ts'), 'utf8')).toBe('// modified'); + + // Fourth run: --force overwrites them. + await run(['--quiet', '--force'], options); + expect(fs.readFileSync(path.join(dir, 'playwright.config.ts'), 'utf8')).toContain('defineConfig'); }); test('should install GHA workflow when --gha is passed', async ({ run, dir }) => { @@ -256,10 +298,53 @@ test('should not prompt in non-interactive terminals without --quiet', async ({ }); test('should not overwrite existing files in non-interactive terminals without --quiet', async ({ run, dir }) => { - fs.writeFileSync(path.join(dir, 'playwright.config.js'), '// existing'); + fs.mkdirSync(path.join(dir, 'project')); + fs.writeFileSync(path.join(dir, 'project', 'playwright.config.js'), '// existing'); + + const error = await run(['project', '--lang', 'js', '--no-browsers']).catch(e => e); + expect(error.message).toContain(`These files already exist:\n ${path.join('project', 'playwright.config.js')}\nIf you want to override them, run again with --force.`); + expect(fs.readFileSync(path.join(dir, 'project', 'playwright.config.js'), 'utf8')).toBe('// existing'); + // Nothing is installed before the conflict is reported. + expect(error.message).not.toContain('Installing'); + expect(fs.existsSync(path.join(dir, 'project', 'package.json'))).toBeFalsy(); +}); + +test('should accept --lang JavaScript and comma-separated --browser', async ({ run, dir, packageManager }) => { + test.skip(packageManager !== 'npm'); + const { stdout } = await run(['--quiet', '--gha', '--lang', 'JavaScript', '--browser', 'firefox,WebKit', '--no-browsers', '--no-examples']); + + expect(fs.existsSync(path.join(dir, 'playwright.config.ts'))).toBeFalsy(); + expect(fs.existsSync(path.join(dir, 'tsconfig.json'))).toBeFalsy(); + const config = readFile(path.join(dir, 'playwright.config.js')); + expect(config).toContain(` // {\n // name: 'chromium',`); + expect(config).toContain(` {\n name: 'firefox',`); + expect(config).toContain(` {\n name: 'webkit',`); + + const workflow = readFile(path.join(dir, '.github/workflows/playwright.yml')); + expect(workflow).toContain('run: npx playwright install --with-deps firefox webkit\n'); + + // The epilogue only suggests commands and files that exist. + expect(stdout).toContain('npx playwright test --project=firefox'); + expect(stdout).not.toContain('--project=chromium'); + expect(stdout).not.toContain('example.spec'); + expect(stdout).not.toContain('Runs the tests in a specific file.'); + expect(stdout).toContain(`.${path.sep}playwright.config.js - Playwright Test configuration`); +}); + +test('should reject unknown --lang and --browser values', async ({ run, dir }) => { + await expect(run(['--quiet', '--lang', 'python'])).rejects.toThrowError(`argument 'python' is invalid. Expected one of: js, ts, JavaScript, TypeScript.`); + await expect(run(['--quiet', '--browser', 'chrome'])).rejects.toThrowError(`argument 'chrome' is invalid. Expected a comma-separated list of: chromium, firefox, webkit.`); + expect(fs.existsSync(path.join(dir, 'package.json'))).toBeFalsy(); +}); + +test('should escape --test-dir in the generated config', async ({ run, exec, dir, packageManager }) => { + test.skip(packageManager !== 'npm'); + await run(['--quiet', '--no-browsers', '--test-dir', `it's $&`]); - await expect(run(['--lang', 'js', '--no-browsers'])).rejects.toThrowError('run again with --force'); - expect(fs.readFileSync(path.join(dir, 'playwright.config.js'), 'utf8')).toBe('// existing'); + expect(fs.readFileSync(path.join(dir, 'playwright.config.ts'), 'utf8')).toContain(`testDir: './it\\'s $&',`); + expect(fs.existsSync(path.join(dir, `it's $&`, 'example.spec.ts'))).toBeTruthy(); + const tsc = require.resolve('typescript/bin/tsc'); + await exec('node', [tsc, '-p', '.']); }); test('is proper yarn classic', async ({ packageManager, exec }) => {