Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ node_modules/
.npmrc
# Playwright CLI output (may contain credentials)
.playwright-cli/
# Default config written by `install`, regenerated by scripts/update.js
/.playwright/
23 changes: 22 additions & 1 deletion playwright-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ async function checkForUpdates() {
return;

// The check spawns npm, so keep its cost to the commands that start a workflow.
const command = process.argv.slice(2).find(arg => !arg.startsWith('-'));
const command = currentCommand();
if (!command || !UPDATE_CHECK_COMMANDS.has(command))
return;

Expand All @@ -61,13 +61,34 @@ async function checkForUpdates() {
printNotice(packageJson.version, latest);
}

// Parses argv the same way program() does, so that an option value cannot be
// mistaken for the command: in `--config file.json open`, minimist consumes
// `file.json` as the value of `--config` and the command is still `open`.
// These files are internal to playwright-core and not in its "exports" map, so
// resolve them by path and fall back to the first positional-looking argument.
/** @returns {string | undefined} */
function currentCommand() {
const argv = process.argv.slice(2);
try {
const coreDir = path.dirname(require.resolve('playwright-core/package.json'));
const cliClientDir = path.join(coreDir, 'lib', 'tools', 'cli-client');
const { minimist } = require(path.join(cliClientDir, 'minimist.js'));
const help = require(path.join(cliClientDir, 'help.json'));
const boolean = [...help.booleanOptions, 'all', 'g', 'help', 'json', 'raw', 'version'];
return minimist(argv, { boolean, string: ['_'] })._?.[0];
} catch {
return argv.find(arg => !arg.startsWith('-'));
}
}

// Asks npm, so that the user's registry, proxy and auth configuration is respected.
/** @returns {Promise<string | undefined>} */
function fetchLatestVersion() {
return new Promise(resolve => {
const child = spawn(`npm view ${packageJson.name} version --json --no-update-notifier`, {
shell: true,
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
});
let stdout = '';
child.stdout.on('data', data => stdout += data);
Expand Down
66 changes: 53 additions & 13 deletions scripts/update.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

const { execSync } = require('child_process');
const fs = require('fs/promises');
const path = require('path');
Expand All @@ -10,29 +26,53 @@ function run(command, options = {}) {
execSync(command, { stdio: 'inherit', cwd: rootDir, ...options });
}

async function exists(file) {
try {
await fs.access(file);
return true;
} catch {
return false;
}
}

async function main() {
// 2. Run playwright-cli install-skills
// 1. Regenerate the skills from the installed Playwright version.
console.log('\n=== Running playwright-cli install --skills ===\n');
run('node playwright-cli.js install --skills');

// 3. Move generated skills into the existing skills folder
// 2. Move generated skills into the existing skills folder
console.log('\n=== Updating skills folder ===\n');
const generatedSkillsDir = path.join(rootDir, '.claude', 'skills', 'playwright-cli');
const targetSkillsDir = path.join(skillsDir, 'playwright-cli');

if (!await exists(generatedSkillsDir))
throw new Error(`Generated skills directory not found at ${generatedSkillsDir}`);

// Stage the copy next to the target and swap it in, so that a failure midway
// cannot leave the committed skills folder deleted.
const stagingDir = `${targetSkillsDir}.new`;
await fs.rm(stagingDir, { recursive: true, force: true });
await fs.cp(generatedSkillsDir, stagingDir, { recursive: true });

const backupDir = `${targetSkillsDir}.old`;
await fs.rm(backupDir, { recursive: true, force: true });
if (await exists(targetSkillsDir))
await fs.rename(targetSkillsDir, backupDir);
try {
await fs.access(generatedSkillsDir);
// Remove existing skills and copy new ones
await fs.rm(targetSkillsDir, { recursive: true, force: true });
await fs.cp(generatedSkillsDir, targetSkillsDir, { recursive: true });
console.log(`Copied skills from ${generatedSkillsDir} to ${targetSkillsDir}`);

// Clean up generated skills directory
await fs.rm(generatedSkillsDir, { recursive: true });
console.log('Cleaned up generated skills directory');
} catch {
console.warn('Warning: Generated skills directory not found at', generatedSkillsDir);
await fs.rename(stagingDir, targetSkillsDir);
} catch (error) {
// Put the previous skills back rather than leaving nothing behind.
if (await exists(backupDir))
await fs.rename(backupDir, targetSkillsDir);
throw error;
}
await fs.rm(backupDir, { recursive: true, force: true });
console.log(`Copied skills from ${generatedSkillsDir} to ${targetSkillsDir}`);

// 3. Clean up generated skills directory
await fs.rm(generatedSkillsDir, { recursive: true });
console.log('Cleaned up generated skills directory');

console.log('\n=== Update complete! ===\n');
}

Expand Down
43 changes: 38 additions & 5 deletions skillCheck.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,38 @@ function readSkill(file) {
return fs.existsSync(file) ? fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n') : null;
}

/**
* Lists the markdown files of a skill directory, relative to it.
* @param {string} dir
* @returns {string[]}
*/
function skillFiles(dir) {
/** @type {string[]} */
const files = [];
/**
* @param {string} current
* @param {string} prefix
*/
const walk = (current, prefix) => {
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const relativePath = prefix ? path.join(prefix, entry.name) : entry.name;
if (entry.isDirectory())
walk(path.join(current, entry.name), relativePath);
else if (entry.name.endsWith('.md'))
files.push(relativePath);
}
};
walk(dir, '');
return files;
}

/**
* @param {string[]} lines
* @returns {string}
*/
function frame(lines) {
if (!lines.length)
return '';
const width = Math.max(...lines.map(line => line.length));
const top = '╔' + '═'.repeat(width + 2) + '╗';
const bottom = '╚' + '═'.repeat(width + 2) + '╝';
Expand All @@ -55,14 +82,20 @@ function frame(lines) {

function checkInstalledSkills() {
try {
const bundled = readSkill(bundledSkillFile());
if (!bundled)
const bundledFile = bundledSkillFile();
if (!readSkill(bundledFile))
return;
const bundledDir = path.dirname(bundledFile);
// The skill is SKILL.md plus its reference guides, so compare them all: a
// stale reference file misleads the agent just as much as a stale SKILL.md.
const files = skillFiles(bundledDir);
for (const target of installedSkillTargets()) {
const installed = readSkill(path.join(target.dir, 'SKILL.md'));
if (installed === null)
if (readSkill(path.join(target.dir, 'SKILL.md')) === null)
continue;
if (installed !== bundled) {
// A missing file reads as null and therefore counts as out of date.
const outOfDate = files.some(file =>
readSkill(path.join(target.dir, file)) !== readSkill(path.join(bundledDir, file)));
if (outOfDate) {
process.stderr.write(frame([
`The playwright-cli skill at '${path.relative(process.cwd(), target.dir)}'`,
`does not match the tool version.`,
Expand Down
23 changes: 23 additions & 0 deletions tests/integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,29 @@ test('update check on install does not warn about the skills being installed', a
expect(npmRegistry.requests).toEqual(['/@playwright%2fcli']);
});

test('update check runs when a global option precedes the command', async ({ npmRegistry }) => {
// `--session` takes a value, so the command is the token after it, not `work`.
expect(await runCli(['--session', 'work', 'list'], updateCheckEnv(npmRegistry))).toEqual(expect.objectContaining({
error: expect.stringContaining('Update available for @playwright/cli'),
exitCode: 0,
}));
expect(npmRegistry.requests).toEqual(['/@playwright%2fcli']);
});

test('update check warns when an installed reference file is out of date', async ({ npmRegistry }) => {
expect(await runCli(['install', '--skills'], { NO_UPDATE_NOTIFIER: '1' })).toEqual(expect.objectContaining({
exitCode: 0,
}));

const referencesDir = path.join(test.info().outputPath(), '.claude', 'skills', 'playwright-cli', 'references');
const reference = fs.readdirSync(referencesDir).find(name => name.endsWith('.md'))!;
fs.appendFileSync(path.join(referencesDir, reference), 'x');

expect(await runCli(['list'], updateCheckEnv(npmRegistry))).toEqual(expect.objectContaining({
error: expect.stringContaining('does not match the tool version'),
}));
});

test('update check uses the scoped registry from project .npmrc', async ({ npmRegistry }) => {
fs.writeFileSync(test.info().outputPath('package.json'), '{}');
fs.writeFileSync(test.info().outputPath('.npmrc'), `@playwright:registry=${npmRegistry.url}/scoped/`);
Expand Down