Persistent WS connection and auto-create initial tab
- Connect WS at extension startup via maintainConnection() loop - Auto-retry every 5 seconds silently (no connecting badge) - Never disconnect WS based on tab count - Rename ConnectionState 'disconnected' to 'idle', remove global 'connecting' - Fix race condition with tabGroupQueue for syncTabGroup/disconnectEverything/onTabUpdated - Add auto-create initial tab when Playwright connects (PLAYWRITER_AUTO_ENABLE env var) - Add test for auto-create feature
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Playwriter MCP",
|
||||
"version": "0.0.64",
|
||||
"version": "0.0.65",
|
||||
"description": "Automate your Browser using Cursor, Claude, VS Code. More capable and context efficient than Playwright MCP.",
|
||||
"permissions": ["debugger", "tabGroups", "contextMenus", "tabs"],
|
||||
"host_permissions": ["<all_urls>"],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mcp-extension",
|
||||
"version": "0.0.64",
|
||||
"version": "0.0.65",
|
||||
"description": "Playwright MCP Browser Extension",
|
||||
"private": true,
|
||||
"repository": {
|
||||
|
||||
+164
-144
@@ -16,12 +16,12 @@ let ws: WebSocket | null = null
|
||||
let childSessions: Map<string, number> = new Map()
|
||||
let nextSessionId = 1
|
||||
let playwriterGroupId: number | null = null
|
||||
let syncTabGroupQueue: Promise<void> = Promise.resolve()
|
||||
let tabGroupQueue: Promise<void> = Promise.resolve()
|
||||
let replacedRetryInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const store = createStore<ExtensionState>(() => ({
|
||||
tabs: new Map(),
|
||||
connectionState: 'disconnected',
|
||||
connectionState: 'idle',
|
||||
currentTabId: undefined,
|
||||
errorText: undefined,
|
||||
}))
|
||||
@@ -131,6 +131,22 @@ async function syncTabGroup(): Promise<void> {
|
||||
|
||||
const existingGroups = await chrome.tabGroups.query({ title: 'playwriter' })
|
||||
|
||||
// Check for no connected tabs FIRST, before setting playwriterGroupId
|
||||
// This prevents a race condition where onTabUpdated sees playwriterGroupId !== null
|
||||
if (connectedTabIds.length === 0) {
|
||||
for (const group of existingGroups) {
|
||||
const tabsInGroup = await chrome.tabs.query({ groupId: group.id })
|
||||
for (const tab of tabsInGroup) {
|
||||
if (tab.id) {
|
||||
await chrome.tabs.ungroup(tab.id)
|
||||
}
|
||||
}
|
||||
logger.debug('Cleared playwriter group:', group.id)
|
||||
}
|
||||
playwriterGroupId = null
|
||||
return
|
||||
}
|
||||
|
||||
if (existingGroups.length > 1) {
|
||||
const [keep, ...duplicates] = existingGroups
|
||||
for (const group of duplicates) {
|
||||
@@ -147,20 +163,6 @@ async function syncTabGroup(): Promise<void> {
|
||||
playwriterGroupId = existingGroups[0].id
|
||||
}
|
||||
|
||||
if (connectedTabIds.length === 0) {
|
||||
for (const group of existingGroups) {
|
||||
const tabsInGroup = await chrome.tabs.query({ groupId: group.id })
|
||||
for (const tab of tabsInGroup) {
|
||||
if (tab.id) {
|
||||
await chrome.tabs.ungroup(tab.id)
|
||||
}
|
||||
}
|
||||
logger.debug('Cleared playwriter group:', group.id)
|
||||
}
|
||||
playwriterGroupId = null
|
||||
return
|
||||
}
|
||||
|
||||
const allTabs = await chrome.tabs.query({})
|
||||
const tabsInGroup = allTabs.filter((t) => t.groupId === playwriterGroupId && t.id !== undefined)
|
||||
const tabIdsInGroup = new Set(tabsInGroup.map((t) => t.id!))
|
||||
@@ -372,7 +374,7 @@ function onDebuggerDetach(source: chrome.debugger.Debuggee, reason: `${chrome.de
|
||||
|
||||
if (reason === chrome.debugger.DetachReason.CANCELED_BY_USER) {
|
||||
// Chrome's debugger info bar cancellation detaches ALL debugger sessions, not just one tab
|
||||
store.setState({ connectionState: 'disconnected', errorText: undefined })
|
||||
store.setState({ connectionState: 'idle', errorText: undefined })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,7 +480,7 @@ function closeConnection(reason: string): void {
|
||||
})
|
||||
}
|
||||
|
||||
store.setState({ tabs: new Map(), connectionState: 'disconnected', errorText: undefined })
|
||||
store.setState({ tabs: new Map(), connectionState: 'idle', errorText: undefined })
|
||||
childSessions.clear()
|
||||
|
||||
if (ws) {
|
||||
@@ -491,7 +493,7 @@ function startReplacedRetryLoop(): void {
|
||||
if (replacedRetryInterval) return
|
||||
logger.debug('Starting replaced retry loop (every 3 seconds)')
|
||||
replacedRetryInterval = setInterval(async () => {
|
||||
if (ws?.readyState === WebSocket.OPEN || store.getState().connectionState !== 'error') {
|
||||
if (ws?.readyState === WebSocket.OPEN || store.getState().connectionState !== 'extension-replaced') {
|
||||
clearInterval(replacedRetryInterval!)
|
||||
replacedRetryInterval = null
|
||||
logger.debug('Stopped replaced retry loop')
|
||||
@@ -501,7 +503,7 @@ function startReplacedRetryLoop(): void {
|
||||
const response = await fetch(`http://localhost:${RELAY_PORT}/extension/status`)
|
||||
const data = await response.json()
|
||||
if (!data.connected) {
|
||||
store.setState({ connectionState: 'disconnected', errorText: undefined })
|
||||
store.setState({ connectionState: 'idle', errorText: undefined })
|
||||
logger.debug('Extension slot is free, cleared error state')
|
||||
} else {
|
||||
logger.debug('Extension slot still taken, will retry...')
|
||||
@@ -533,26 +535,21 @@ function handleConnectionClose(reason: string, code: number): void {
|
||||
logger.debug('Connection replaced by another extension instance')
|
||||
store.setState({
|
||||
tabs: new Map(),
|
||||
connectionState: 'error',
|
||||
connectionState: 'extension-replaced',
|
||||
errorText: 'Disconnected: Replaced by another extension',
|
||||
})
|
||||
startReplacedRetryLoop()
|
||||
return
|
||||
}
|
||||
|
||||
if (tabs.size > 0) {
|
||||
logger.debug('Tabs still connected, triggering reconnection')
|
||||
store.setState((state) => {
|
||||
const newTabs = new Map(state.tabs)
|
||||
for (const [tabId, tab] of newTabs) {
|
||||
newTabs.set(tabId, { ...tab, state: 'connecting' })
|
||||
}
|
||||
return { tabs: newTabs }
|
||||
})
|
||||
void reconnect()
|
||||
} else {
|
||||
store.setState({ connectionState: 'disconnected', errorText: undefined })
|
||||
}
|
||||
// For normal disconnects, set tabs to 'connecting' state and let maintainConnection handle reconnect
|
||||
store.setState((state) => {
|
||||
const newTabs = new Map(state.tabs)
|
||||
for (const [tabId, tab] of newTabs) {
|
||||
newTabs.set(tabId, { ...tab, state: 'connecting' })
|
||||
}
|
||||
return { tabs: newTabs, connectionState: 'idle', errorText: undefined }
|
||||
})
|
||||
}
|
||||
|
||||
async function ensureConnection(): Promise<void> {
|
||||
@@ -562,14 +559,18 @@ async function ensureConnection(): Promise<void> {
|
||||
}
|
||||
|
||||
logger.debug(`Waiting for server at http://localhost:${RELAY_PORT}...`)
|
||||
store.setState({ connectionState: 'reconnecting' })
|
||||
|
||||
while (true) {
|
||||
// Retry for up to 30 seconds, then give up (maintainConnection will retry later)
|
||||
const maxAttempts = 30
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
await fetch(`http://localhost:${RELAY_PORT}`, { method: 'HEAD' })
|
||||
logger.debug('Server is available')
|
||||
break
|
||||
} catch {
|
||||
if (attempt === maxAttempts - 1) {
|
||||
throw new Error('Server not available after 30 seconds')
|
||||
}
|
||||
logger.debug('Server not available, retrying in 1 second...')
|
||||
await sleep(1000)
|
||||
}
|
||||
@@ -628,6 +629,25 @@ async function ensureConnection(): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
// Handle createInitialTab - create a new tab when Playwright connects and no tabs exist
|
||||
if (message.method === 'createInitialTab') {
|
||||
try {
|
||||
logger.debug('Creating initial tab for Playwright client')
|
||||
const tab = await chrome.tabs.create({ url: 'about:blank', active: false })
|
||||
if (tab.id) {
|
||||
await connectTab(tab.id)
|
||||
logger.debug('Initial tab created and connected:', tab.id)
|
||||
sendMessage({ id: message.id, result: { success: true, tabId: tab.id } })
|
||||
} else {
|
||||
throw new Error('Failed to create tab - no tab ID returned')
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.debug('Failed to create initial tab:', error)
|
||||
sendMessage({ id: message.id, error: error.message })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const response: ExtensionResponseMessage = { id: message.id }
|
||||
try {
|
||||
response.result = await handleCommand(message as ExtensionCommandMessage)
|
||||
@@ -653,6 +673,53 @@ async function ensureConnection(): Promise<void> {
|
||||
logger.debug('Connection established')
|
||||
}
|
||||
|
||||
async function maintainConnection(): Promise<void> {
|
||||
while (true) {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
await sleep(1000)
|
||||
continue
|
||||
}
|
||||
|
||||
// Don't auto-reconnect if replaced by another extension
|
||||
if (store.getState().connectionState === 'extension-replaced') {
|
||||
await sleep(1000)
|
||||
continue
|
||||
}
|
||||
|
||||
// Try to connect silently in background - don't show 'connecting' badge
|
||||
// Individual tab states will show 'connecting' when user explicitly clicks
|
||||
try {
|
||||
await ensureConnection()
|
||||
store.setState({ connectionState: 'connected' })
|
||||
|
||||
// Re-attach any tabs that were in 'connecting' state (from a previous disconnect)
|
||||
const tabsToReattach = Array.from(store.getState().tabs.entries())
|
||||
.filter(([_, tab]) => tab.state === 'connecting')
|
||||
.map(([tabId]) => tabId)
|
||||
|
||||
for (const tabId of tabsToReattach) {
|
||||
try {
|
||||
await chrome.tabs.get(tabId)
|
||||
await attachTab(tabId)
|
||||
logger.debug('Successfully re-attached tab:', tabId)
|
||||
} catch (error: any) {
|
||||
logger.debug('Failed to re-attach tab:', tabId, error.message)
|
||||
store.setState((state) => {
|
||||
const newTabs = new Map(state.tabs)
|
||||
newTabs.delete(tabId)
|
||||
return { tabs: newTabs }
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.debug('Connection attempt failed:', error.message)
|
||||
store.setState({ connectionState: 'idle' })
|
||||
}
|
||||
|
||||
await sleep(5000)
|
||||
}
|
||||
}
|
||||
|
||||
async function connectTab(tabId: number): Promise<void> {
|
||||
try {
|
||||
logger.debug(`Starting connection to tab ${tabId}`)
|
||||
@@ -673,8 +740,7 @@ async function connectTab(tabId: number): Promise<void> {
|
||||
store.setState((state) => {
|
||||
const newTabs = new Map(state.tabs)
|
||||
newTabs.set(tabId, { state: 'error', errorText: `Error: ${error.message}` })
|
||||
const nextConnectionState = state.connectionState === 'reconnecting' ? 'disconnected' : state.connectionState
|
||||
return { tabs: newTabs, connectionState: nextConnectionState }
|
||||
return { tabs: newTabs }
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -689,58 +755,7 @@ async function disconnectTab(tabId: number): Promise<void> {
|
||||
}
|
||||
|
||||
detachTab(tabId, true)
|
||||
|
||||
const { tabs: updatedTabs } = store.getState()
|
||||
if (updatedTabs.size === 0 && ws) {
|
||||
logger.debug('No tabs remaining, closing connection')
|
||||
closeConnection('All tabs disconnected')
|
||||
}
|
||||
}
|
||||
|
||||
async function reconnect(): Promise<void> {
|
||||
logger.debug('Starting reconnection')
|
||||
store.setState({ connectionState: 'reconnecting', errorText: undefined })
|
||||
const { tabs } = store.getState()
|
||||
const tabsToReconnect = Array.from(tabs.keys())
|
||||
logger.debug('Tabs to reconnect:', tabsToReconnect)
|
||||
|
||||
try {
|
||||
await ensureConnection()
|
||||
|
||||
for (const tabId of tabsToReconnect) {
|
||||
if (!store.getState().tabs.has(tabId)) {
|
||||
logger.debug('Tab', tabId, 'was manually disconnected during reconnection, skipping')
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
await chrome.tabs.get(tabId)
|
||||
await attachTab(tabId)
|
||||
logger.debug('Successfully re-attached tab:', tabId)
|
||||
} catch (error: any) {
|
||||
logger.debug('Failed to re-attach tab:', tabId, error.message)
|
||||
store.setState((state) => {
|
||||
const newTabs = new Map(state.tabs)
|
||||
newTabs.delete(tabId)
|
||||
return { tabs: newTabs }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const { tabs: finalTabs } = store.getState()
|
||||
if (finalTabs.size > 0) {
|
||||
store.setState({ connectionState: 'connected', errorText: undefined })
|
||||
} else {
|
||||
store.setState({ connectionState: 'disconnected', errorText: undefined })
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.debug('Reconnection failed:', error)
|
||||
store.setState({
|
||||
tabs: new Map(),
|
||||
connectionState: 'error',
|
||||
errorText: 'Reconnection failed - Click to retry',
|
||||
})
|
||||
}
|
||||
// WS connection is maintained even with no tabs - maintainConnection handles it
|
||||
}
|
||||
|
||||
async function toggleExtensionForActiveTab(): Promise<{ isConnected: boolean; state: ExtensionState }> {
|
||||
@@ -754,7 +769,7 @@ async function toggleExtensionForActiveTab(): Promise<{ isConnected: boolean; st
|
||||
const check = () => {
|
||||
const state = store.getState()
|
||||
const tabInfo = state.tabs.get(tab.id!)
|
||||
if (tabInfo?.state === 'connecting' || state.connectionState === 'reconnecting') {
|
||||
if (tabInfo?.state === 'connecting') {
|
||||
setTimeout(check, 100)
|
||||
return
|
||||
}
|
||||
@@ -769,20 +784,16 @@ async function toggleExtensionForActiveTab(): Promise<{ isConnected: boolean; st
|
||||
}
|
||||
|
||||
async function disconnectEverything(): Promise<void> {
|
||||
const { tabs } = store.getState()
|
||||
|
||||
for (const tabId of tabs.keys()) {
|
||||
await disconnectTab(tabId)
|
||||
}
|
||||
|
||||
if (ws) {
|
||||
closeConnection('Manual full disconnect')
|
||||
store.setState({
|
||||
connectionState: 'disconnected',
|
||||
tabs: new Map(),
|
||||
errorText: undefined,
|
||||
})
|
||||
}
|
||||
// Queue disconnect operation to serialize with other tab group operations
|
||||
tabGroupQueue = tabGroupQueue.then(async () => {
|
||||
playwriterGroupId = null
|
||||
const { tabs } = store.getState()
|
||||
for (const tabId of tabs.keys()) {
|
||||
await disconnectTab(tabId)
|
||||
}
|
||||
})
|
||||
await tabGroupQueue
|
||||
// WS connection is maintained - maintainConnection handles it
|
||||
}
|
||||
|
||||
async function resetDebugger(): Promise<void> {
|
||||
@@ -825,7 +836,7 @@ const icons = {
|
||||
badgeText: '...',
|
||||
badgeColor: [64, 64, 64, 255] as [number, number, number, number],
|
||||
},
|
||||
disconnected: {
|
||||
idle: {
|
||||
path: {
|
||||
'16': '/icons/icon-black-16.png',
|
||||
'32': '/icons/icon-black-32.png',
|
||||
@@ -847,7 +858,18 @@ const icons = {
|
||||
badgeText: '',
|
||||
badgeColor: [64, 64, 64, 255] as [number, number, number, number],
|
||||
},
|
||||
error: {
|
||||
extensionReplaced: {
|
||||
path: {
|
||||
'16': '/icons/icon-gray-16.png',
|
||||
'32': '/icons/icon-gray-32.png',
|
||||
'48': '/icons/icon-gray-48.png',
|
||||
'128': '/icons/icon-gray-128.png',
|
||||
},
|
||||
title: 'Replaced by another extension - Click to retry',
|
||||
badgeText: '!',
|
||||
badgeColor: [220, 38, 38, 255] as [number, number, number, number],
|
||||
},
|
||||
tabError: {
|
||||
path: {
|
||||
'16': '/icons/icon-gray-16.png',
|
||||
'32': '/icons/icon-gray-32.png',
|
||||
@@ -875,23 +897,22 @@ async function updateIcons(): Promise<void> {
|
||||
const tabUrl = tabId !== undefined ? tabUrlMap.get(tabId) : undefined
|
||||
|
||||
const iconConfig = (() => {
|
||||
if (connectionState === 'error') return icons.error
|
||||
if (connectionState === 'extension-replaced') return icons.extensionReplaced
|
||||
if (tabId !== undefined && isRestrictedUrl(tabUrl)) return icons.restricted
|
||||
if (connectionState === 'reconnecting') return icons.connecting
|
||||
if (tabInfo?.state === 'error') return icons.error
|
||||
if (tabInfo?.state === 'error') return icons.tabError
|
||||
if (tabInfo?.state === 'connecting') return icons.connecting
|
||||
if (tabInfo?.state === 'connected') return icons.connected
|
||||
return icons.disconnected
|
||||
return icons.idle
|
||||
})()
|
||||
|
||||
const title = (() => {
|
||||
if (connectionState === 'error' && errorText) return errorText
|
||||
if (connectionState === 'extension-replaced' && errorText) return errorText
|
||||
if (tabInfo?.errorText) return tabInfo.errorText
|
||||
return iconConfig.title
|
||||
})()
|
||||
|
||||
const badgeText = (() => {
|
||||
if (iconConfig === icons.connected || iconConfig === icons.disconnected || iconConfig === icons.restricted) {
|
||||
if (iconConfig === icons.connected || iconConfig === icons.idle || iconConfig === icons.restricted) {
|
||||
return connectedCount > 0 ? String(connectedCount) : ''
|
||||
}
|
||||
return iconConfig.badgeText
|
||||
@@ -929,22 +950,11 @@ async function onActionClicked(tab: chrome.tabs.Tab): Promise<void> {
|
||||
const { tabs, connectionState } = store.getState()
|
||||
const tabInfo = tabs.get(tab.id)
|
||||
|
||||
if (connectionState === 'error') {
|
||||
logger.debug('Global error state - retrying reconnection')
|
||||
await reconnect()
|
||||
return
|
||||
}
|
||||
|
||||
if (connectionState === 'reconnecting') {
|
||||
logger.debug('User clicked during reconnection, canceling and disconnecting all tabs')
|
||||
for (const tabId of tabs.keys()) {
|
||||
detachTab(tabId, true)
|
||||
}
|
||||
store.setState({ connectionState: 'disconnected', tabs: new Map(), errorText: undefined })
|
||||
if (ws) {
|
||||
ws.close(1000, 'User cancelled reconnection')
|
||||
ws = null
|
||||
}
|
||||
// If in extension-replaced state, clear it to allow reconnection
|
||||
if (connectionState === 'extension-replaced') {
|
||||
logger.debug('Clearing extension-replaced state, will retry connection')
|
||||
store.setState({ connectionState: 'idle', errorText: undefined })
|
||||
// maintainConnection will pick up and retry
|
||||
return
|
||||
}
|
||||
|
||||
@@ -967,6 +977,7 @@ async function onActionClicked(tab: chrome.tabs.Tab): Promise<void> {
|
||||
}
|
||||
|
||||
resetDebugger()
|
||||
maintainConnection()
|
||||
|
||||
chrome.contextMenus.remove('playwriter-pin-element').catch(() => {}).finally(() => {
|
||||
chrome.contextMenus.create({
|
||||
@@ -1000,7 +1011,7 @@ store.subscribe((state, prevState) => {
|
||||
updateContextMenuVisibility()
|
||||
const tabsChanged = serializeTabs(state.tabs) !== serializeTabs(prevState.tabs)
|
||||
if (tabsChanged) {
|
||||
syncTabGroupQueue = syncTabGroupQueue.then(syncTabGroup).catch((e) => {
|
||||
tabGroupQueue = tabGroupQueue.then(syncTabGroup).catch((e) => {
|
||||
logger.debug('syncTabGroup error:', e)
|
||||
})
|
||||
}
|
||||
@@ -1012,22 +1023,31 @@ chrome.tabs.onActivated.addListener(onTabActivated)
|
||||
chrome.action.onClicked.addListener(onActionClicked)
|
||||
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
|
||||
void updateIcons()
|
||||
if (changeInfo.groupId !== undefined && playwriterGroupId !== null) {
|
||||
const { tabs } = store.getState()
|
||||
if (changeInfo.groupId === playwriterGroupId) {
|
||||
if (!tabs.has(tabId) && !isRestrictedUrl(tab.url)) {
|
||||
logger.debug('Tab manually added to playwriter group:', tabId)
|
||||
void connectTab(tabId)
|
||||
}
|
||||
} else if (tabs.has(tabId)) {
|
||||
const tabInfo = tabs.get(tabId)
|
||||
if (tabInfo?.state === 'connecting') {
|
||||
logger.debug('Tab removed from group while connecting, ignoring:', tabId)
|
||||
if (changeInfo.groupId !== undefined) {
|
||||
// Queue tab group operations to serialize with syncTabGroup and disconnectEverything
|
||||
tabGroupQueue = tabGroupQueue.then(async () => {
|
||||
// Re-check conditions after queue - state may have changed
|
||||
if (playwriterGroupId === null) {
|
||||
return
|
||||
}
|
||||
logger.debug('Tab manually removed from playwriter group:', tabId)
|
||||
void disconnectTab(tabId)
|
||||
}
|
||||
const { tabs } = store.getState()
|
||||
if (changeInfo.groupId === playwriterGroupId) {
|
||||
if (!tabs.has(tabId) && !isRestrictedUrl(tab.url)) {
|
||||
logger.debug('Tab manually added to playwriter group:', tabId)
|
||||
await connectTab(tabId)
|
||||
}
|
||||
} else if (tabs.has(tabId)) {
|
||||
const tabInfo = tabs.get(tabId)
|
||||
if (tabInfo?.state === 'connecting') {
|
||||
logger.debug('Tab removed from group while connecting, ignoring:', tabId)
|
||||
return
|
||||
}
|
||||
logger.debug('Tab manually removed from playwriter group:', tabId)
|
||||
await disconnectTab(tabId)
|
||||
}
|
||||
}).catch((e) => {
|
||||
logger.debug('onTabUpdated handler error:', e)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type ConnectionState = 'disconnected' | 'reconnecting' | 'connected' | 'error'
|
||||
export type ConnectionState = 'idle' | 'connected' | 'extension-replaced'
|
||||
export type TabState = 'connecting' | 'connected' | 'error'
|
||||
|
||||
export interface TabInfo {
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## 0.0.35
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- **Persistent WS connection**: Extension now connects to relay server at startup and maintains connection indefinitely, retrying every 5 seconds silently in background
|
||||
- **Silent background retry**: Connecting badge only shows when user explicitly clicks to attach a tab, not during background reconnection attempts
|
||||
- **Fixed tab group race condition**: All tab group operations now queue through `tabGroupQueue` to prevent race conditions between `syncTabGroup`, `disconnectEverything`, and `onTabUpdated`
|
||||
- **Simplified connection states**: Renamed `'disconnected'` to `'idle'`, removed global `'connecting'` state (only individual tabs show connecting state)
|
||||
- **Auto-create initial tab**: When `PLAYWRITER_AUTO_ENABLE` env var is set, automatically creates an about:blank tab when Playwright connects and no tabs exist
|
||||
|
||||
## 0.0.34
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "playwriter",
|
||||
"description": "",
|
||||
"version": "0.0.34",
|
||||
"version": "0.0.35",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -361,15 +361,40 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
||||
const clientId = c.req.param('clientId') || 'default'
|
||||
|
||||
return {
|
||||
onOpen(_event, ws) {
|
||||
async onOpen(_event, ws) {
|
||||
if (playwrightClients.has(clientId)) {
|
||||
logger?.log(chalk.red(`Rejecting duplicate client ID: ${clientId}`))
|
||||
ws.close(1000, 'Client ID already connected')
|
||||
return
|
||||
}
|
||||
|
||||
// Add client first so it can receive Target.attachedToTarget events
|
||||
playwrightClients.set(clientId, { id: clientId, ws })
|
||||
logger?.log(chalk.green(`Playwright client connected: ${clientId} (${playwrightClients.size} total)`))
|
||||
|
||||
// Auto-create initial tab if enabled and no targets exist
|
||||
if (process.env.PLAYWRITER_AUTO_ENABLE && extensionWs && connectedTargets.size === 0) {
|
||||
try {
|
||||
logger?.log(chalk.blue('Auto-creating initial tab for Playwright client'))
|
||||
await sendToExtension({ method: 'createInitialTab', timeout: 10000 })
|
||||
// Wait for Target.attachedToTarget event to populate connectedTargets
|
||||
await new Promise((resolve) => {
|
||||
const checkTargets = () => {
|
||||
if (connectedTargets.size > 0) {
|
||||
resolve(undefined)
|
||||
} else {
|
||||
setTimeout(checkTargets, 50)
|
||||
}
|
||||
}
|
||||
checkTargets()
|
||||
// Timeout after 5 seconds
|
||||
setTimeout(() => resolve(undefined), 5000)
|
||||
})
|
||||
logger?.log(chalk.blue(`Auto-created tab, now have ${connectedTargets.size} targets`))
|
||||
} catch (e) {
|
||||
logger?.error('Failed to auto-create initial tab:', e)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async onMessage(event, ws) {
|
||||
|
||||
@@ -217,8 +217,8 @@ describe('MCP Server Tests', () => {
|
||||
|
||||
Return value:
|
||||
{
|
||||
\"url\": \"https://example.com/\",
|
||||
\"title\": \"Example Domain\"
|
||||
"url": "https://example.com/",
|
||||
"title": "Example Domain"
|
||||
}",
|
||||
"type": "text",
|
||||
},
|
||||
@@ -1943,8 +1943,8 @@ describe('MCP Server Tests', () => {
|
||||
{
|
||||
"text": "Return value:
|
||||
{
|
||||
"error": "Page not found",
|
||||
"urls": []
|
||||
"matchesDark": false,
|
||||
"matchesLight": true
|
||||
}",
|
||||
"type": "text",
|
||||
},
|
||||
@@ -2881,3 +2881,71 @@ describe('CDP Session Tests', () => {
|
||||
await page.close()
|
||||
}, 60000)
|
||||
})
|
||||
|
||||
describe('Auto-enable Tests', () => {
|
||||
let testCtx: TestContext | null = null
|
||||
|
||||
// Set env var before any setup runs
|
||||
process.env.PLAYWRITER_AUTO_ENABLE = '1'
|
||||
|
||||
beforeAll(async () => {
|
||||
testCtx = await setupTestContext({ tempDirPrefix: 'pw-auto-test-' })
|
||||
|
||||
// Disconnect all tabs to start with a clean state
|
||||
const serviceWorker = await getExtensionServiceWorker(testCtx.browserContext)
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await globalThis.disconnectEverything()
|
||||
})
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
}, 600000)
|
||||
|
||||
afterAll(async () => {
|
||||
delete process.env.PLAYWRITER_AUTO_ENABLE
|
||||
await cleanupTestContext(testCtx)
|
||||
testCtx = null
|
||||
})
|
||||
|
||||
const getBrowserContext = () => {
|
||||
if (!testCtx?.browserContext) throw new Error('Browser not initialized')
|
||||
return testCtx.browserContext
|
||||
}
|
||||
|
||||
it('should auto-create a tab when Playwright connects and no tabs exist', async () => {
|
||||
const browserContext = getBrowserContext()
|
||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||
|
||||
// Verify no tabs are connected
|
||||
const tabCountBefore = await serviceWorker.evaluate(() => {
|
||||
const state = globalThis.getExtensionState()
|
||||
return state.tabs.size
|
||||
})
|
||||
expect(tabCountBefore).toBe(0)
|
||||
|
||||
// Connect Playwright - this should trigger auto-create
|
||||
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
|
||||
|
||||
// Wait for auto-create to complete (async onOpen may not be fully awaited)
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
|
||||
// Verify a page was auto-created
|
||||
const pages = browser.contexts()[0].pages()
|
||||
expect(pages.length).toBeGreaterThan(0)
|
||||
|
||||
const autoCreatedPage = pages[0]
|
||||
expect(autoCreatedPage.url()).toBe('about:blank')
|
||||
|
||||
// Verify extension state shows the tab as connected
|
||||
const tabCountAfter = await serviceWorker.evaluate(() => {
|
||||
const state = globalThis.getExtensionState()
|
||||
return state.tabs.size
|
||||
})
|
||||
expect(tabCountAfter).toBe(1)
|
||||
|
||||
// Verify we can interact with the auto-created page
|
||||
await autoCreatedPage.setContent('<h1>Auto-created page</h1>')
|
||||
const title = await autoCreatedPage.locator('h1').textContent()
|
||||
expect(title).toBe('Auto-created page')
|
||||
|
||||
await browser.close()
|
||||
}, 60000)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user