From 36b209742c968f16b9349a8d754a8d59adb402f9 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Fri, 6 Nov 2026 08:23:36 -0800 Subject: [PATCH 1/3] fix: overwrite prompt, option parsing, workflow and epilogue issues - Ask before overriding existing files in interactive mode again; the check compared relative paths against absolute ones (regression from #172). - Check for conflicting files before installing anything, skip files whose content is unchanged, report paths relative to the cwd and add the `--force` option the error message refers to. - Accept `--lang JavaScript`/`javascript`/`JS`, comma-separated `--browser` lists, and reject unknown values for both. - Install only the selected browsers in the generated workflow. - Only use `cache: "npm"` in the workflow for npm with a package-lock.json; actions/setup-node fails without a lock file. - Match whole `.gitignore` lines, with or without leading/trailing slashes. - Escape `--test-dir` in the generated config and tsconfig.json, and do not expand `$&`-style patterns in template values. - Only suggest an enabled project and existing files in the closing message. Fixes https://github.com/microsoft/playwright/issues/43950 Fixes https://github.com/microsoft/playwright/issues/43951 Fixes https://github.com/microsoft/playwright/issues/43952 Fixes https://github.com/microsoft/playwright/issues/43953 Fixes https://github.com/microsoft/playwright/issues/43954 Fixes https://github.com/microsoft/playwright/issues/43955 Fixes https://github.com/microsoft/playwright/issues/43956 Fixes https://github.com/microsoft/playwright/issues/43957 --- assets/github-actions.yml | 2 + src/cli.ts | 7 ++- src/generator.ts | 101 ++++++++++++++++++++++---------------- src/utils.ts | 85 ++++++++++++++++++++++++++++---- tests/generator.spec.ts | 53 +++++++++++++++++++- tests/integration.spec.ts | 100 ++++++++++++++++++++++++++++++++----- 6 files changed, 280 insertions(+), 68 deletions(-) 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..ada679c 100644 --- a/tests/integration.spec.ts +++ b/tests/integration.spec.ts @@ -196,6 +196,24 @@ 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'); @@ -205,6 +223,7 @@ test('should install with "npm ci" in GHA when using npm with package-lock enabl const workflowContent = fs.readFileSync(path.join(dir, '.github/workflows/playwright.yml'), 'utf8'); 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 }) => { @@ -217,18 +236,34 @@ test('should install with "npm i" in GHA when using npm with package-lock disabl const workflowContent = fs.readFileSync(path.join(dir, '.github/workflows/playwright.yml'), 'utf8'); 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 +291,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 = fs.readFileSync(path.join(dir, 'playwright.config.js'), 'utf8'); + expect(config).toContain(` // {\n // name: 'chromium',`); + expect(config).toContain(` {\n name: 'firefox',`); + expect(config).toContain(` {\n name: 'webkit',`); + + const workflow = fs.readFileSync(path.join(dir, '.github/workflows/playwright.yml'), 'utf8'); + 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 }) => { From 83a28b1fe4f0a3fde315e24bc265ba16be51a1f3 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Fri, 6 Nov 2026 14:34:31 -0800 Subject: [PATCH 2/3] test: normalize line endings in new tests on Windows Templates are checked out with CRLF on Windows runners. Also mark the tests that download browsers as slow, like the current directory test. --- tests/integration.spec.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/integration.spec.ts b/tests/integration.spec.ts index ada679c..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(); @@ -220,7 +227,7 @@ test('should install with "npm ci" in GHA when using npm with package-lock enabl 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'); @@ -233,7 +240,7 @@ 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. @@ -308,12 +315,12 @@ test('should accept --lang JavaScript and comma-separated --browser', async ({ r expect(fs.existsSync(path.join(dir, 'playwright.config.ts'))).toBeFalsy(); expect(fs.existsSync(path.join(dir, 'tsconfig.json'))).toBeFalsy(); - const config = fs.readFileSync(path.join(dir, 'playwright.config.js'), 'utf8'); + 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 = fs.readFileSync(path.join(dir, '.github/workflows/playwright.yml'), 'utf8'); + 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. From 5b03f40bec7ca7aad98f414d6ece6c81af594052 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Fri, 6 Nov 2026 14:34:31 -0800 Subject: [PATCH 3/3] chore(ci): make Node.js 22 the main version, drop Node.js 20 Node.js 24 and 26 jobs keep running but no longer fail the workflow. --- .github/workflows/ci.yml | 4 +++- .github/workflows/publish.yml | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) 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