diff --git a/extension/src/background.ts b/extension/src/background.ts index 4548626..e876f5f 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -1,4 +1,7 @@ declare const process: { env: { PLAYWRITER_PORT: string } } +// Injected by vite at build time from playwriter/package.json version. +// CLI/MCP compare this against their own version to warn when the extension is outdated. +declare const __PLAYWRITER_VERSION__: string import { createStore } from 'zustand/vanilla' import type { ExtensionState, ConnectionState, TabState, TabInfo } from './types' @@ -206,6 +209,9 @@ class ConnectionManager { if (identity.id) { relayUrl.searchParams.set('id', identity.id) } + if (typeof __PLAYWRITER_VERSION__ !== 'undefined') { + relayUrl.searchParams.set('v', __PLAYWRITER_VERSION__) + } logger.debug('Creating WebSocket connection to:', relayUrl) const socket = new WebSocket(relayUrl.toString()) diff --git a/extension/vite.config.mts b/extension/vite.config.mts index 0b6576c..6072990 100644 --- a/extension/vite.config.mts +++ b/extension/vite.config.mts @@ -1,4 +1,5 @@ import { fileURLToPath } from 'node:url'; +import { readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { defineConfig } from 'vite'; import { viteStaticCopy } from 'vite-plugin-static-copy'; @@ -6,8 +7,16 @@ import { viteStaticCopy } from 'vite-plugin-static-copy'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); +// Bundle the playwriter package version into the extension so it can report +// which playwriter version it was built against. CLI/MCP use this to warn +// when the extension is outdated. +const playwriterPkg = JSON.parse( + readFileSync(resolve(__dirname, '../playwriter/package.json'), 'utf-8') +); + const defineEnv: Record = { 'process.env.PLAYWRITER_PORT': JSON.stringify(process.env.PLAYWRITER_PORT || '19988'), + '__PLAYWRITER_VERSION__': JSON.stringify(playwriterPkg.version), }; if (process.env.TESTING) { defineEnv['import.meta.env.TESTING'] = 'true'; diff --git a/playwriter/src/cdp-relay.ts b/playwriter/src/cdp-relay.ts index 0ba7b77..e308a60 100644 --- a/playwriter/src/cdp-relay.ts +++ b/playwriter/src/cdp-relay.ts @@ -71,6 +71,8 @@ type ExtensionInfo = { browser?: string email?: string id?: string + /** playwriter package version the extension was built with (sent as ?v= query param) */ + version?: string } type ExtensionConnection = { @@ -675,6 +677,7 @@ export async function startPlayWriterCDPRelayServer({ activeTargets, browser: info?.browser || null, profile: info ? { email: info.email || '', id: info.id || '' } : null, + playwriterVersion: info?.version || null, }) }) @@ -686,6 +689,7 @@ export async function startPlayWriterCDPRelayServer({ browser: extension.info.browser || null, profile: extension.info ? { email: extension.info.email || '', id: extension.info.id || '' } : null, activeTargets: extension.connectedTargets.size, + playwriterVersion: extension.info?.version || null, } }) return c.json({ extensions }) @@ -1002,10 +1006,12 @@ export async function startPlayWriterCDPRelayServer({ const browser = c.req.query('browser') const email = c.req.query('email') const id = c.req.query('id') + const version = c.req.query('v') return { browser: browser || undefined, email: email || undefined, id: id || undefined, + version: version || undefined, } } diff --git a/playwriter/src/cli.ts b/playwriter/src/cli.ts index 91d6fc5..e345406 100644 --- a/playwriter/src/cli.ts +++ b/playwriter/src/cli.ts @@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url' import { cac } from '@xmorse/cac' import { killPortProcess } from './kill-port.js' import { VERSION, LOG_FILE_PATH, LOG_CDP_FILE_PATH, parseRelayHost } from './utils.js' -import { ensureRelayServer, RELAY_PORT, waitForExtension } from './relay-client.js' +import { ensureRelayServer, RELAY_PORT, waitForExtension, getExtensionOutdatedWarning, getExtensionStatus } from './relay-client.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -20,6 +20,7 @@ type ExtensionStatus = { browser: string | null profile: { email: string; id: string } | null activeTargets: number + playwriterVersion?: string | null } cli @@ -74,22 +75,24 @@ async function fetchExtensionsStatus(host?: string): Promise activeTargets: number browser: string | null profile: { email: string; id: string } | null + playwriterVersion?: string | null } - if (!fallbackData.connected) { + if (!fallbackData?.connected) { return [] } return [{ extensionId: 'default', stableKey: undefined, - browser: fallbackData.browser, - profile: fallbackData.profile, - activeTargets: fallbackData.activeTargets, + browser: fallbackData?.browser, + profile: fallbackData?.profile, + activeTargets: fallbackData?.activeTargets, + playwriterVersion: fallbackData?.playwriterVersion || null, }] } const data = await response.json() as { extensions: ExtensionStatus[] } - return data.extensions + return data?.extensions || [] } catch { return [] } @@ -126,6 +129,13 @@ async function executeCode(options: { } } + // Warn once if extension is outdated + const extensionStatus = await getExtensionStatus() + const outdatedWarning = getExtensionOutdatedWarning(extensionStatus?.playwriterVersion) + if (outdatedWarning) { + console.error(outdatedWarning) + } + // Build request URL with token if provided const executeUrl = `${serverUrl}/cli/execute` @@ -197,6 +207,15 @@ cli process.exit(1) } + // Warn if any connected extension was built with an older playwriter version + for (const ext of extensions) { + const warning = getExtensionOutdatedWarning(ext.playwriterVersion) + if (warning) { + console.error(warning) + break + } + } + let selectedExtension: ExtensionStatus | null = null if (extensions.length === 1) { diff --git a/playwriter/src/executor.ts b/playwriter/src/executor.ts index 5aacf1f..be8e39a 100644 --- a/playwriter/src/executor.ts +++ b/playwriter/src/executor.ts @@ -15,6 +15,7 @@ import vm from 'node:vm' import * as acorn from 'acorn' import { createSmartDiff } from './diff-utils.js' import { getCdpUrl, parseRelayHost } from './utils.js' +import { getExtensionOutdatedWarning } from './relay-client.js' import { waitForPageLoad, WaitForPageLoadOptions, WaitForPageLoadResult } from './wait-for-page-load.js' import { ICDPSession, getCDPSessionForPage } from './cdp-session.js' import { Debugger } from './debugger.js' @@ -225,6 +226,7 @@ export class PlaywrightExecutor { private cdpConfig: CdpConfig private logger: ExecutorLogger private sessionMetadata: SessionMetadata + private hasWarnedExtensionOutdated = false constructor(options: ExecutorOptions) { this.cdpConfig = options.cdpConfig @@ -292,6 +294,17 @@ export class PlaywrightExecutor { this.context = null } + private warnIfExtensionOutdated(playwriterVersion: string | null) { + if (this.hasWarnedExtensionOutdated) { + return + } + const warning = getExtensionOutdatedWarning(playwriterVersion) + if (warning) { + this.logger.log(warning) + this.hasWarnedExtensionOutdated = true + } + } + private setupPageConsoleListener(page: Page) { // Use targetId() if available, fallback to internal _guid for CDP connections const targetId = page.targetId() || (page as any)._guid as string | undefined @@ -330,9 +343,10 @@ export class PlaywrightExecutor { }) } - private async checkExtensionStatus(): Promise<{ connected: boolean; activeTargets: number }> { + private async checkExtensionStatus(): Promise<{ connected: boolean; activeTargets: number; playwriterVersion: string | null }> { const { host = '127.0.0.1', port = 19988, extensionId } = this.cdpConfig const { httpBaseUrl } = parseRelayHost(host, port) + const notConnected = { connected: false, activeTargets: 0, playwriterVersion: null } try { if (extensionId) { const response = await fetch(`${httpBaseUrl}/extensions/status`, { @@ -343,31 +357,31 @@ export class PlaywrightExecutor { signal: AbortSignal.timeout(2000), }) if (!fallback.ok) { - return { connected: false, activeTargets: 0 } + return notConnected } - return (await fallback.json()) as { connected: boolean; activeTargets: number } + return (await fallback.json()) as { connected: boolean; activeTargets: number; playwriterVersion: string | null } } const data = await response.json() as { - extensions: Array<{ extensionId: string; stableKey?: string; activeTargets: number }> + extensions: Array<{ extensionId: string; stableKey?: string; activeTargets: number; playwriterVersion?: string | null }> } const extension = data.extensions.find((item) => { return item.extensionId === extensionId || item.stableKey === extensionId }) if (!extension) { - return { connected: false, activeTargets: 0 } + return notConnected } - return { connected: true, activeTargets: extension.activeTargets } + return { connected: true, activeTargets: extension.activeTargets, playwriterVersion: extension?.playwriterVersion || null } } const response = await fetch(`${httpBaseUrl}/extension/status`, { signal: AbortSignal.timeout(2000), }) if (!response.ok) { - return { connected: false, activeTargets: 0 } + return notConnected } - return (await response.json()) as { connected: boolean; activeTargets: number } + return (await response.json()) as { connected: boolean; activeTargets: number; playwriterVersion: string | null } } catch { - return { connected: false, activeTargets: 0 } + return notConnected } } @@ -381,6 +395,7 @@ export class PlaywrightExecutor { if (!extensionStatus.connected) { throw new Error(EXTENSION_NOT_CONNECTED_ERROR) } + this.warnIfExtensionOutdated(extensionStatus.playwriterVersion) // Generate a fresh unique URL for each Playwright connection const cdpUrl = getCdpUrl(this.cdpConfig) @@ -455,6 +470,7 @@ export class PlaywrightExecutor { if (!extensionStatus.connected) { throw new Error(EXTENSION_NOT_CONNECTED_ERROR) } + this.warnIfExtensionOutdated(extensionStatus.playwriterVersion) // Generate a fresh unique URL for each Playwright connection const cdpUrl = getCdpUrl(this.cdpConfig) diff --git a/playwriter/src/relay-client.ts b/playwriter/src/relay-client.ts index 5facf43..59f3d7e 100644 --- a/playwriter/src/relay-client.ts +++ b/playwriter/src/relay-client.ts @@ -30,7 +30,7 @@ export async function getRelayServerVersion(port: number = RELAY_PORT): Promise< } } -export async function getExtensionStatus(port: number = RELAY_PORT): Promise<{ connected: boolean; activeTargets: number } | null> { +export async function getExtensionStatus(port: number = RELAY_PORT): Promise<{ connected: boolean; activeTargets: number; playwriterVersion: string | null } | null> { try { const response = await fetch(`http://127.0.0.1:${port}/extension/status`, { signal: AbortSignal.timeout(500), @@ -38,7 +38,7 @@ export async function getExtensionStatus(port: number = RELAY_PORT): Promise<{ c if (!response.ok) { return null } - return await response.json() as { connected: boolean; activeTargets: number } + return await response.json() as { connected: boolean; activeTargets: number; playwriterVersion: string | null } } catch { return null } @@ -96,7 +96,7 @@ async function killRelayServer(options: { port: number; waitForFreeMs?: number } * - 0 if v1 === v2 * - positive if v1 > v2 */ -function compareVersions(v1: string, v2: string): number { +export function compareVersions(v1: string, v2: string): number { const parts1 = v1.split('.').map(Number) const parts2 = v2.split('.').map(Number) const len = Math.max(parts1.length, parts2.length) @@ -111,6 +111,22 @@ function compareVersions(v1: string, v2: string): number { return 0 } +/** + * Check if the running playwriter package is older than the version the extension was built with. + * The extension bundles the playwriter version at build time. If the extension reports a newer + * version, it means the user's CLI/MCP needs updating. + * Returns a warning message if outdated, null otherwise. + */ +export function getExtensionOutdatedWarning(extensionPlaywriterVersion: string | null | undefined): string | null { + if (!extensionPlaywriterVersion) { + return null + } + if (compareVersions(extensionPlaywriterVersion, VERSION) > 0) { + return `Playwriter ${VERSION} is outdated (extension requires ${extensionPlaywriterVersion}). Run \`npm install -g playwriter@latest\` or update the playwriter package in your project.` + } + return null +} + export interface EnsureRelayServerOptions { logger?: { log: (...args: any[]) => void } /** If true, will kill and restart server on version mismatch. Default: true */