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 } }
|
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 { createStore } from 'zustand/vanilla'
|
||||||
import type { ExtensionState, ConnectionState, TabState, TabInfo } from './types'
|
import type { ExtensionState, ConnectionState, TabState, TabInfo } from './types'
|
||||||
@@ -206,6 +209,9 @@ class ConnectionManager {
|
|||||||
if (identity.id) {
|
if (identity.id) {
|
||||||
relayUrl.searchParams.set('id', 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)
|
logger.debug('Creating WebSocket connection to:', relayUrl)
|
||||||
const socket = new WebSocket(relayUrl.toString())
|
const socket = new WebSocket(relayUrl.toString())
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
import { dirname, resolve } from 'node:path';
|
import { dirname, resolve } from 'node:path';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import { viteStaticCopy } from 'vite-plugin-static-copy';
|
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 __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
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> = {
|
const defineEnv: Record<string, string> = {
|
||||||
'process.env.PLAYWRITER_PORT': JSON.stringify(process.env.PLAYWRITER_PORT || '19988'),
|
'process.env.PLAYWRITER_PORT': JSON.stringify(process.env.PLAYWRITER_PORT || '19988'),
|
||||||
|
'__PLAYWRITER_VERSION__': JSON.stringify(playwriterPkg.version),
|
||||||
};
|
};
|
||||||
if (process.env.TESTING) {
|
if (process.env.TESTING) {
|
||||||
defineEnv['import.meta.env.TESTING'] = 'true';
|
defineEnv['import.meta.env.TESTING'] = 'true';
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ type ExtensionInfo = {
|
|||||||
browser?: string
|
browser?: string
|
||||||
email?: string
|
email?: string
|
||||||
id?: string
|
id?: string
|
||||||
|
/** playwriter package version the extension was built with (sent as ?v= query param) */
|
||||||
|
version?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExtensionConnection = {
|
type ExtensionConnection = {
|
||||||
@@ -675,6 +677,7 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
activeTargets,
|
activeTargets,
|
||||||
browser: info?.browser || null,
|
browser: info?.browser || null,
|
||||||
profile: info ? { email: info.email || '', id: info.id || '' } : 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,
|
browser: extension.info.browser || null,
|
||||||
profile: extension.info ? { email: extension.info.email || '', id: extension.info.id || '' } : null,
|
profile: extension.info ? { email: extension.info.email || '', id: extension.info.id || '' } : null,
|
||||||
activeTargets: extension.connectedTargets.size,
|
activeTargets: extension.connectedTargets.size,
|
||||||
|
playwriterVersion: extension.info?.version || null,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return c.json({ extensions })
|
return c.json({ extensions })
|
||||||
@@ -1002,10 +1006,12 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
const browser = c.req.query('browser')
|
const browser = c.req.query('browser')
|
||||||
const email = c.req.query('email')
|
const email = c.req.query('email')
|
||||||
const id = c.req.query('id')
|
const id = c.req.query('id')
|
||||||
|
const version = c.req.query('v')
|
||||||
return {
|
return {
|
||||||
browser: browser || undefined,
|
browser: browser || undefined,
|
||||||
email: email || undefined,
|
email: email || undefined,
|
||||||
id: id || undefined,
|
id: id || undefined,
|
||||||
|
version: version || undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+25
-6
@@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url'
|
|||||||
import { cac } from '@xmorse/cac'
|
import { cac } from '@xmorse/cac'
|
||||||
import { killPortProcess } from './kill-port.js'
|
import { killPortProcess } from './kill-port.js'
|
||||||
import { VERSION, LOG_FILE_PATH, LOG_CDP_FILE_PATH, parseRelayHost } from './utils.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))
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ type ExtensionStatus = {
|
|||||||
browser: string | null
|
browser: string | null
|
||||||
profile: { email: string; id: string } | null
|
profile: { email: string; id: string } | null
|
||||||
activeTargets: number
|
activeTargets: number
|
||||||
|
playwriterVersion?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
cli
|
cli
|
||||||
@@ -74,22 +75,24 @@ async function fetchExtensionsStatus(host?: string): Promise<ExtensionStatus[]>
|
|||||||
activeTargets: number
|
activeTargets: number
|
||||||
browser: string | null
|
browser: string | null
|
||||||
profile: { email: string; id: string } | null
|
profile: { email: string; id: string } | null
|
||||||
|
playwriterVersion?: string | null
|
||||||
}
|
}
|
||||||
if (!fallbackData.connected) {
|
if (!fallbackData?.connected) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
return [{
|
return [{
|
||||||
extensionId: 'default',
|
extensionId: 'default',
|
||||||
stableKey: undefined,
|
stableKey: undefined,
|
||||||
browser: fallbackData.browser,
|
browser: fallbackData?.browser,
|
||||||
profile: fallbackData.profile,
|
profile: fallbackData?.profile,
|
||||||
activeTargets: fallbackData.activeTargets,
|
activeTargets: fallbackData?.activeTargets,
|
||||||
|
playwriterVersion: fallbackData?.playwriterVersion || null,
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
const data = await response.json() as {
|
const data = await response.json() as {
|
||||||
extensions: ExtensionStatus[]
|
extensions: ExtensionStatus[]
|
||||||
}
|
}
|
||||||
return data.extensions
|
return data?.extensions || []
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
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
|
// Build request URL with token if provided
|
||||||
const executeUrl = `${serverUrl}/cli/execute`
|
const executeUrl = `${serverUrl}/cli/execute`
|
||||||
|
|
||||||
@@ -197,6 +207,15 @@ cli
|
|||||||
process.exit(1)
|
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
|
let selectedExtension: ExtensionStatus | null = null
|
||||||
|
|
||||||
if (extensions.length === 1) {
|
if (extensions.length === 1) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import vm from 'node:vm'
|
|||||||
import * as acorn from 'acorn'
|
import * as acorn from 'acorn'
|
||||||
import { createSmartDiff } from './diff-utils.js'
|
import { createSmartDiff } from './diff-utils.js'
|
||||||
import { getCdpUrl, parseRelayHost } from './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 { waitForPageLoad, WaitForPageLoadOptions, WaitForPageLoadResult } from './wait-for-page-load.js'
|
||||||
import { ICDPSession, getCDPSessionForPage } from './cdp-session.js'
|
import { ICDPSession, getCDPSessionForPage } from './cdp-session.js'
|
||||||
import { Debugger } from './debugger.js'
|
import { Debugger } from './debugger.js'
|
||||||
@@ -225,6 +226,7 @@ export class PlaywrightExecutor {
|
|||||||
private cdpConfig: CdpConfig
|
private cdpConfig: CdpConfig
|
||||||
private logger: ExecutorLogger
|
private logger: ExecutorLogger
|
||||||
private sessionMetadata: SessionMetadata
|
private sessionMetadata: SessionMetadata
|
||||||
|
private hasWarnedExtensionOutdated = false
|
||||||
|
|
||||||
constructor(options: ExecutorOptions) {
|
constructor(options: ExecutorOptions) {
|
||||||
this.cdpConfig = options.cdpConfig
|
this.cdpConfig = options.cdpConfig
|
||||||
@@ -292,6 +294,17 @@ export class PlaywrightExecutor {
|
|||||||
this.context = null
|
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) {
|
private setupPageConsoleListener(page: Page) {
|
||||||
// Use targetId() if available, fallback to internal _guid for CDP connections
|
// Use targetId() if available, fallback to internal _guid for CDP connections
|
||||||
const targetId = page.targetId() || (page as any)._guid as string | undefined
|
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 { host = '127.0.0.1', port = 19988, extensionId } = this.cdpConfig
|
||||||
const { httpBaseUrl } = parseRelayHost(host, port)
|
const { httpBaseUrl } = parseRelayHost(host, port)
|
||||||
|
const notConnected = { connected: false, activeTargets: 0, playwriterVersion: null }
|
||||||
try {
|
try {
|
||||||
if (extensionId) {
|
if (extensionId) {
|
||||||
const response = await fetch(`${httpBaseUrl}/extensions/status`, {
|
const response = await fetch(`${httpBaseUrl}/extensions/status`, {
|
||||||
@@ -343,31 +357,31 @@ export class PlaywrightExecutor {
|
|||||||
signal: AbortSignal.timeout(2000),
|
signal: AbortSignal.timeout(2000),
|
||||||
})
|
})
|
||||||
if (!fallback.ok) {
|
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 {
|
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) => {
|
const extension = data.extensions.find((item) => {
|
||||||
return item.extensionId === extensionId || item.stableKey === extensionId
|
return item.extensionId === extensionId || item.stableKey === extensionId
|
||||||
})
|
})
|
||||||
if (!extension) {
|
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`, {
|
const response = await fetch(`${httpBaseUrl}/extension/status`, {
|
||||||
signal: AbortSignal.timeout(2000),
|
signal: AbortSignal.timeout(2000),
|
||||||
})
|
})
|
||||||
if (!response.ok) {
|
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 {
|
} catch {
|
||||||
return { connected: false, activeTargets: 0 }
|
return notConnected
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,6 +395,7 @@ export class PlaywrightExecutor {
|
|||||||
if (!extensionStatus.connected) {
|
if (!extensionStatus.connected) {
|
||||||
throw new Error(EXTENSION_NOT_CONNECTED_ERROR)
|
throw new Error(EXTENSION_NOT_CONNECTED_ERROR)
|
||||||
}
|
}
|
||||||
|
this.warnIfExtensionOutdated(extensionStatus.playwriterVersion)
|
||||||
|
|
||||||
// Generate a fresh unique URL for each Playwright connection
|
// Generate a fresh unique URL for each Playwright connection
|
||||||
const cdpUrl = getCdpUrl(this.cdpConfig)
|
const cdpUrl = getCdpUrl(this.cdpConfig)
|
||||||
@@ -455,6 +470,7 @@ export class PlaywrightExecutor {
|
|||||||
if (!extensionStatus.connected) {
|
if (!extensionStatus.connected) {
|
||||||
throw new Error(EXTENSION_NOT_CONNECTED_ERROR)
|
throw new Error(EXTENSION_NOT_CONNECTED_ERROR)
|
||||||
}
|
}
|
||||||
|
this.warnIfExtensionOutdated(extensionStatus.playwriterVersion)
|
||||||
|
|
||||||
// Generate a fresh unique URL for each Playwright connection
|
// Generate a fresh unique URL for each Playwright connection
|
||||||
const cdpUrl = getCdpUrl(this.cdpConfig)
|
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 {
|
try {
|
||||||
const response = await fetch(`http://127.0.0.1:${port}/extension/status`, {
|
const response = await fetch(`http://127.0.0.1:${port}/extension/status`, {
|
||||||
signal: AbortSignal.timeout(500),
|
signal: AbortSignal.timeout(500),
|
||||||
@@ -38,7 +38,7 @@ export async function getExtensionStatus(port: number = RELAY_PORT): Promise<{ c
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return await response.json() as { connected: boolean; activeTargets: number }
|
return await response.json() as { connected: boolean; activeTargets: number; playwriterVersion: string | null }
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -96,7 +96,7 @@ async function killRelayServer(options: { port: number; waitForFreeMs?: number }
|
|||||||
* - 0 if v1 === v2
|
* - 0 if v1 === v2
|
||||||
* - positive 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 parts1 = v1.split('.').map(Number)
|
||||||
const parts2 = v2.split('.').map(Number)
|
const parts2 = v2.split('.').map(Number)
|
||||||
const len = Math.max(parts1.length, parts2.length)
|
const len = Math.max(parts1.length, parts2.length)
|
||||||
@@ -111,6 +111,22 @@ function compareVersions(v1: string, v2: string): number {
|
|||||||
return 0
|
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 {
|
export interface EnsureRelayServerOptions {
|
||||||
logger?: { log: (...args: any[]) => void }
|
logger?: { log: (...args: any[]) => void }
|
||||||
/** If true, will kill and restart server on version mismatch. Default: true */
|
/** If true, will kill and restart server on version mismatch. Default: true */
|
||||||
|
|||||||
Reference in New Issue
Block a user