Skip to content
Merged
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
54 changes: 38 additions & 16 deletions playwright-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');

const { program } = require('playwright-core/lib/tools/cli-client/program');
const coreBundle = require('playwright-core/lib/coreBundle');
Expand All @@ -28,6 +29,7 @@ const { checkInstalledSkills, frame } = require('./skillCheck');
const packageJson = require('./package.json');

const ONE_DAY_MS = 24 * 60 * 60 * 1000;
const UPDATE_CHECK_COMMANDS = new Set(['open', 'attach', 'list', 'install', 'install-browser']);

main();

Expand All @@ -40,13 +42,17 @@ async function checkForUpdates() {
if (process.env.NO_UPDATE_NOTIFIER || process.env.CI)
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('-'));
if (!command || !UPDATE_CHECK_COMMANDS.has(command))
return;

const cache = readCache();
const stale = !cache || (Date.now() - cache.lastCheck) > ONE_DAY_MS;
if (!stale)
return;
writeCache({ lastCheck: Date.now() });

const command = process.argv.slice(2).find(arg => !arg.startsWith('-'));
if (command !== 'install')
checkInstalledSkills();

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

async function fetchLatestVersion() {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 1500);
try {
const res = await fetch(`https://registry.npmjs.org/${packageJson.name}/latest`, { signal: controller.signal });
if (!res.ok)
return undefined;
const json = await res.json();
return typeof json.version === 'string' ? json.version : undefined;
} finally {
// 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'],
});
let stdout = '';
child.stdout.on('data', data => stdout += data);
// On Windows, killing the shell leaves npm running: stop listening to it
// so that the CLI does not wait for npm before exiting.
const timeout = setTimeout(() => {
child.kill();
child.unref();
child.stdout.destroy();
resolve(undefined);
}, 1500);
child.on('error', () => {
clearTimeout(timeout);
}
} catch {
return undefined;
}
resolve(undefined);
});
child.on('close', code => {
clearTimeout(timeout);
try {
const version = JSON.parse(stdout);
resolve(code === 0 && typeof version === 'string' ? version : undefined);
} catch {
resolve(undefined);
}
});
});
}

/**
Expand Down
127 changes: 116 additions & 11 deletions tests/integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,62 @@
*/

import fs from 'fs';
import http from 'http';
import path from 'path';
import { spawn } from 'child_process';
import { test, expect } from '@playwright/test';
import { test as baseTest, expect } from '@playwright/test';
import type { AddressInfo } from 'net';

type CliResult = {
output: string;
error: string;
exitCode: number | null;
};

type NpmRegistry = {
url: string;
status: number;
hang: boolean;
requests: string[];
};

const test = baseTest.extend<{ npmRegistry: NpmRegistry }>({
npmRegistry: async ({}, use) => {
const registry: NpmRegistry = { url: '', status: 200, hang: false, requests: [] };
const server = http.createServer((req, res) => {
registry.requests.push(req.url!);
if (registry.hang)
return;
res.statusCode = registry.status;
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({
'name': '@playwright/cli',
'dist-tags': { latest: '99.0.0' },
'versions': { '99.0.0': { name: '@playwright/cli', version: '99.0.0' } },
}));
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
registry.url = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
await use(registry);
server.closeAllConnections();
await new Promise(resolve => server.close(resolve));
},
});

// Enables the update check and points npm at the test registry, isolated from
// the npm config of the machine running the tests.
function updateCheckEnv(npmRegistry: NpmRegistry): Record<string, string> {
return {
'CI': '',
'NO_UPDATE_NOTIFIER': '',
'npm_config_registry': npmRegistry.url,
'npm_config_@playwright:registry': '',
'npm_config_userconfig': test.info().outputPath('user-npmrc'),
'npm_config_globalconfig': test.info().outputPath('global-npmrc'),
'npm_config_cache': test.info().outputPath('npm-cache'),
};
}

async function runCli(args: string[], env: Record<string, string> = {}): Promise<CliResult> {
const cliPath = path.join(__dirname, '../playwright-cli.js');

Expand Down Expand Up @@ -73,53 +119,52 @@ test('open data URL', async ({}) => {
}));
});

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

const skillFile = path.join(test.info().outputPath(), '.claude', 'skills', 'playwright-cli', 'SKILL.md');
fs.appendFileSync(skillFile, 'x');

const env = { CI: '', NO_UPDATE_NOTIFIER: '' };
expect(await runCli(['--help'], env)).toEqual(expect.objectContaining({
const env = updateCheckEnv(npmRegistry);
expect(await runCli(['list'], env)).toEqual(expect.objectContaining({
error: expect.stringContaining('does not match the tool version'),
}));

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

test('does not warn when installed skill only differs in line endings', async ({}) => {
test('does not warn when installed skill only differs in line endings', async ({ npmRegistry }) => {
expect(await runCli(['install', '--skills'], { NO_UPDATE_NOTIFIER: '1' })).toEqual(expect.objectContaining({
exitCode: 0,
}));

const skillFile = path.join(test.info().outputPath(), '.claude', 'skills', 'playwright-cli', 'SKILL.md');
fs.writeFileSync(skillFile, fs.readFileSync(skillFile, 'utf8').replace(/\n/g, '\r\n'));

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

test('caches the update check in the default registry directory', async ({}) => {
test('caches the update check in the default registry directory', async ({ npmRegistry }) => {
// Redirect the home/cache directories so the real user cache is untouched, and
// leave PLAYWRIGHT_CLI_INSTALLATION_FOR_TEST empty so the default path is used.
const home = test.info().outputPath('home');
fs.mkdirSync(home, { recursive: true });
const env = {
CI: '',
NO_UPDATE_NOTIFIER: '',
...updateCheckEnv(npmRegistry),
PLAYWRIGHT_CLI_INSTALLATION_FOR_TEST: '',
HOME: home,
USERPROFILE: home,
XDG_CACHE_HOME: path.join(home, '.cache'),
LOCALAPPDATA: path.join(home, 'AppData', 'Local'),
};

expect(await runCli(['--version'], env)).toEqual(expect.objectContaining({ exitCode: 0 }));
expect(await runCli(['list'], env)).toEqual(expect.objectContaining({ exitCode: 0 }));

const found: string[] = [];
const walk = (dir: string) => {
Expand All @@ -136,3 +181,63 @@ test('caches the update check in the default registry directory', async ({}) =>
expect(found).toHaveLength(1);
expect(JSON.parse(fs.readFileSync(found[0], 'utf8')).lastCheck).toEqual(expect.any(Number));
});

test('update check asks npm for the latest version on setup commands only', async ({ npmRegistry }) => {
const env = updateCheckEnv(npmRegistry);
expect(await runCli(['--version'], env)).toEqual(expect.objectContaining({
error: '',
exitCode: 0,
}));
expect(npmRegistry.requests).toEqual([]);

expect(await runCli(['list'], env)).toEqual(expect.objectContaining({
error: expect.stringContaining('Update available for @playwright/cli'),
exitCode: 0,
}));
expect(npmRegistry.requests).toEqual(['/@playwright%2fcli']);
});

test('update check on install does not warn about the skills being installed', async ({ npmRegistry }) => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we reduce number of the new tests?

expect(await runCli(['install', '--skills'], { NO_UPDATE_NOTIFIER: '1' })).toEqual(expect.objectContaining({
exitCode: 0,
}));

const skillFile = path.join(test.info().outputPath(), '.claude', 'skills', 'playwright-cli', 'SKILL.md');
fs.appendFileSync(skillFile, 'x');

const result = await runCli(['install', '--skills'], updateCheckEnv(npmRegistry));
expect(result.exitCode).toBe(0);
expect(result.error).toContain('Update available for @playwright/cli');
expect(result.error).not.toContain('does not match the tool version');
expect(npmRegistry.requests).toEqual(['/@playwright%2fcli']);
});

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/`);

expect(await runCli(['list'], updateCheckEnv(npmRegistry))).toEqual(expect.objectContaining({
error: expect.stringContaining('Update available for @playwright/cli'),
exitCode: 0,
}));
expect(npmRegistry.requests).toEqual(['/scoped/@playwright%2fcli']);
});

test('update check failure is silent', async ({ npmRegistry }) => {
npmRegistry.status = 401;
expect(await runCli(['list'], updateCheckEnv(npmRegistry))).toEqual(expect.objectContaining({
error: '',
exitCode: 0,
}));
expect(npmRegistry.requests).toEqual(['/@playwright%2fcli']);
});

test('update check does not wait for a hanging registry', async ({ npmRegistry }) => {
npmRegistry.hang = true;
const start = Date.now();
expect(await runCli(['list'], updateCheckEnv(npmRegistry))).toEqual(expect.objectContaining({
error: '',
exitCode: 0,
}));
expect(Date.now() - start).toBeLessThan(10000);
});
Loading