feat: bundle playwriter version in extension, warn when CLI/MCP is outdated

The extension now reports which playwriter version it was built with via a
`?v=` query param on its WebSocket connection to the relay. The relay stores
this and exposes it in `/extension/status` and `/extensions/status` endpoints.

CLI and MCP compare the extension's bundled version against their own VERSION.
If the extension was built with a newer playwriter, a warning is shown:

  Playwriter 0.0.60 is outdated (extension requires 0.0.62).
  Run `npm install -g playwriter@latest` or update the playwriter package.

This is fully backward compatible:
- Old extensions without `?v=` get `playwriterVersion: null`, no warning
- No WS protocol changes (version is in URL query params)
- Warning fires once per executor/session lifetime

Flow:
  vite build reads playwriter/package.json → injects __PLAYWRITER_VERSION__
  → extension sends ?v=X.Y.Z on WS connect → relay stores in ExtensionInfo
  → /extension/status returns playwriterVersion → CLI/executor checks & warns
This commit is contained in:
Tommy D. Rossi
2026-02-16 19:42:58 +01:00
parent 387149c649
commit 44370833f7
6 changed files with 90 additions and 18 deletions
+6
View File
@@ -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())
+9
View File
@@ -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<string, string> = {
'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';
+6
View File
@@ -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,
}
}
+25 -6
View File
@@ -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<ExtensionStatus[]>
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) {
+25 -9
View File
@@ -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)
+19 -3
View File
@@ -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 */