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:
@@ -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())
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user