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,
|
"manifest_version": 3,
|
||||||
"name": "Playwriter MCP",
|
"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.",
|
"description": "Automate your Browser using Cursor, Claude, VS Code. More capable and context efficient than Playwright MCP.",
|
||||||
"permissions": ["debugger", "tabGroups", "contextMenus", "tabs"],
|
"permissions": ["debugger", "tabGroups", "contextMenus", "tabs"],
|
||||||
"host_permissions": ["<all_urls>"],
|
"host_permissions": ["<all_urls>"],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mcp-extension",
|
"name": "mcp-extension",
|
||||||
"version": "0.0.64",
|
"version": "0.0.65",
|
||||||
"description": "Playwright MCP Browser Extension",
|
"description": "Playwright MCP Browser Extension",
|
||||||
"private": true,
|
"private": true,
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
+164
-144
@@ -16,12 +16,12 @@ let ws: WebSocket | null = null
|
|||||||
let childSessions: Map<string, number> = new Map()
|
let childSessions: Map<string, number> = new Map()
|
||||||
let nextSessionId = 1
|
let nextSessionId = 1
|
||||||
let playwriterGroupId: number | null = null
|
let playwriterGroupId: number | null = null
|
||||||
let syncTabGroupQueue: Promise<void> = Promise.resolve()
|
let tabGroupQueue: Promise<void> = Promise.resolve()
|
||||||
let replacedRetryInterval: ReturnType<typeof setInterval> | null = null
|
let replacedRetryInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
const store = createStore<ExtensionState>(() => ({
|
const store = createStore<ExtensionState>(() => ({
|
||||||
tabs: new Map(),
|
tabs: new Map(),
|
||||||
connectionState: 'disconnected',
|
connectionState: 'idle',
|
||||||
currentTabId: undefined,
|
currentTabId: undefined,
|
||||||
errorText: undefined,
|
errorText: undefined,
|
||||||
}))
|
}))
|
||||||
@@ -131,6 +131,22 @@ async function syncTabGroup(): Promise<void> {
|
|||||||
|
|
||||||
const existingGroups = await chrome.tabGroups.query({ title: 'playwriter' })
|
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) {
|
if (existingGroups.length > 1) {
|
||||||
const [keep, ...duplicates] = existingGroups
|
const [keep, ...duplicates] = existingGroups
|
||||||
for (const group of duplicates) {
|
for (const group of duplicates) {
|
||||||
@@ -147,20 +163,6 @@ async function syncTabGroup(): Promise<void> {
|
|||||||
playwriterGroupId = existingGroups[0].id
|
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 allTabs = await chrome.tabs.query({})
|
||||||
const tabsInGroup = allTabs.filter((t) => t.groupId === playwriterGroupId && t.id !== undefined)
|
const tabsInGroup = allTabs.filter((t) => t.groupId === playwriterGroupId && t.id !== undefined)
|
||||||
const tabIdsInGroup = new Set(tabsInGroup.map((t) => t.id!))
|
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) {
|
if (reason === chrome.debugger.DetachReason.CANCELED_BY_USER) {
|
||||||
// Chrome's debugger info bar cancellation detaches ALL debugger sessions, not just one tab
|
// 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()
|
childSessions.clear()
|
||||||
|
|
||||||
if (ws) {
|
if (ws) {
|
||||||
@@ -491,7 +493,7 @@ function startReplacedRetryLoop(): void {
|
|||||||
if (replacedRetryInterval) return
|
if (replacedRetryInterval) return
|
||||||
logger.debug('Starting replaced retry loop (every 3 seconds)')
|
logger.debug('Starting replaced retry loop (every 3 seconds)')
|
||||||
replacedRetryInterval = setInterval(async () => {
|
replacedRetryInterval = setInterval(async () => {
|
||||||
if (ws?.readyState === WebSocket.OPEN || store.getState().connectionState !== 'error') {
|
if (ws?.readyState === WebSocket.OPEN || store.getState().connectionState !== 'extension-replaced') {
|
||||||
clearInterval(replacedRetryInterval!)
|
clearInterval(replacedRetryInterval!)
|
||||||
replacedRetryInterval = null
|
replacedRetryInterval = null
|
||||||
logger.debug('Stopped replaced retry loop')
|
logger.debug('Stopped replaced retry loop')
|
||||||
@@ -501,7 +503,7 @@ function startReplacedRetryLoop(): void {
|
|||||||
const response = await fetch(`http://localhost:${RELAY_PORT}/extension/status`)
|
const response = await fetch(`http://localhost:${RELAY_PORT}/extension/status`)
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
if (!data.connected) {
|
if (!data.connected) {
|
||||||
store.setState({ connectionState: 'disconnected', errorText: undefined })
|
store.setState({ connectionState: 'idle', errorText: undefined })
|
||||||
logger.debug('Extension slot is free, cleared error state')
|
logger.debug('Extension slot is free, cleared error state')
|
||||||
} else {
|
} else {
|
||||||
logger.debug('Extension slot still taken, will retry...')
|
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')
|
logger.debug('Connection replaced by another extension instance')
|
||||||
store.setState({
|
store.setState({
|
||||||
tabs: new Map(),
|
tabs: new Map(),
|
||||||
connectionState: 'error',
|
connectionState: 'extension-replaced',
|
||||||
errorText: 'Disconnected: Replaced by another extension',
|
errorText: 'Disconnected: Replaced by another extension',
|
||||||
})
|
})
|
||||||
startReplacedRetryLoop()
|
startReplacedRetryLoop()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tabs.size > 0) {
|
// For normal disconnects, set tabs to 'connecting' state and let maintainConnection handle reconnect
|
||||||
logger.debug('Tabs still connected, triggering reconnection')
|
store.setState((state) => {
|
||||||
store.setState((state) => {
|
const newTabs = new Map(state.tabs)
|
||||||
const newTabs = new Map(state.tabs)
|
for (const [tabId, tab] of newTabs) {
|
||||||
for (const [tabId, tab] of newTabs) {
|
newTabs.set(tabId, { ...tab, state: 'connecting' })
|
||||||
newTabs.set(tabId, { ...tab, state: 'connecting' })
|
}
|
||||||
}
|
return { tabs: newTabs, connectionState: 'idle', errorText: undefined }
|
||||||
return { tabs: newTabs }
|
})
|
||||||
})
|
|
||||||
void reconnect()
|
|
||||||
} else {
|
|
||||||
store.setState({ connectionState: 'disconnected', errorText: undefined })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureConnection(): Promise<void> {
|
async function ensureConnection(): Promise<void> {
|
||||||
@@ -562,14 +559,18 @@ async function ensureConnection(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(`Waiting for server at http://localhost:${RELAY_PORT}...`)
|
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 {
|
try {
|
||||||
await fetch(`http://localhost:${RELAY_PORT}`, { method: 'HEAD' })
|
await fetch(`http://localhost:${RELAY_PORT}`, { method: 'HEAD' })
|
||||||
logger.debug('Server is available')
|
logger.debug('Server is available')
|
||||||
break
|
break
|
||||||
} catch {
|
} catch {
|
||||||
|
if (attempt === maxAttempts - 1) {
|
||||||
|
throw new Error('Server not available after 30 seconds')
|
||||||
|
}
|
||||||
logger.debug('Server not available, retrying in 1 second...')
|
logger.debug('Server not available, retrying in 1 second...')
|
||||||
await sleep(1000)
|
await sleep(1000)
|
||||||
}
|
}
|
||||||
@@ -628,6 +629,25 @@ async function ensureConnection(): Promise<void> {
|
|||||||
return
|
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 }
|
const response: ExtensionResponseMessage = { id: message.id }
|
||||||
try {
|
try {
|
||||||
response.result = await handleCommand(message as ExtensionCommandMessage)
|
response.result = await handleCommand(message as ExtensionCommandMessage)
|
||||||
@@ -653,6 +673,53 @@ async function ensureConnection(): Promise<void> {
|
|||||||
logger.debug('Connection established')
|
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> {
|
async function connectTab(tabId: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
logger.debug(`Starting connection to tab ${tabId}`)
|
logger.debug(`Starting connection to tab ${tabId}`)
|
||||||
@@ -673,8 +740,7 @@ async function connectTab(tabId: number): Promise<void> {
|
|||||||
store.setState((state) => {
|
store.setState((state) => {
|
||||||
const newTabs = new Map(state.tabs)
|
const newTabs = new Map(state.tabs)
|
||||||
newTabs.set(tabId, { state: 'error', errorText: `Error: ${error.message}` })
|
newTabs.set(tabId, { state: 'error', errorText: `Error: ${error.message}` })
|
||||||
const nextConnectionState = state.connectionState === 'reconnecting' ? 'disconnected' : state.connectionState
|
return { tabs: newTabs }
|
||||||
return { tabs: newTabs, connectionState: nextConnectionState }
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -689,58 +755,7 @@ async function disconnectTab(tabId: number): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
detachTab(tabId, true)
|
detachTab(tabId, true)
|
||||||
|
// WS connection is maintained even with no tabs - maintainConnection handles it
|
||||||
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',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleExtensionForActiveTab(): Promise<{ isConnected: boolean; state: ExtensionState }> {
|
async function toggleExtensionForActiveTab(): Promise<{ isConnected: boolean; state: ExtensionState }> {
|
||||||
@@ -754,7 +769,7 @@ async function toggleExtensionForActiveTab(): Promise<{ isConnected: boolean; st
|
|||||||
const check = () => {
|
const check = () => {
|
||||||
const state = store.getState()
|
const state = store.getState()
|
||||||
const tabInfo = state.tabs.get(tab.id!)
|
const tabInfo = state.tabs.get(tab.id!)
|
||||||
if (tabInfo?.state === 'connecting' || state.connectionState === 'reconnecting') {
|
if (tabInfo?.state === 'connecting') {
|
||||||
setTimeout(check, 100)
|
setTimeout(check, 100)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -769,20 +784,16 @@ async function toggleExtensionForActiveTab(): Promise<{ isConnected: boolean; st
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function disconnectEverything(): Promise<void> {
|
async function disconnectEverything(): Promise<void> {
|
||||||
const { tabs } = store.getState()
|
// Queue disconnect operation to serialize with other tab group operations
|
||||||
|
tabGroupQueue = tabGroupQueue.then(async () => {
|
||||||
for (const tabId of tabs.keys()) {
|
playwriterGroupId = null
|
||||||
await disconnectTab(tabId)
|
const { tabs } = store.getState()
|
||||||
}
|
for (const tabId of tabs.keys()) {
|
||||||
|
await disconnectTab(tabId)
|
||||||
if (ws) {
|
}
|
||||||
closeConnection('Manual full disconnect')
|
})
|
||||||
store.setState({
|
await tabGroupQueue
|
||||||
connectionState: 'disconnected',
|
// WS connection is maintained - maintainConnection handles it
|
||||||
tabs: new Map(),
|
|
||||||
errorText: undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resetDebugger(): Promise<void> {
|
async function resetDebugger(): Promise<void> {
|
||||||
@@ -825,7 +836,7 @@ const icons = {
|
|||||||
badgeText: '...',
|
badgeText: '...',
|
||||||
badgeColor: [64, 64, 64, 255] as [number, number, number, number],
|
badgeColor: [64, 64, 64, 255] as [number, number, number, number],
|
||||||
},
|
},
|
||||||
disconnected: {
|
idle: {
|
||||||
path: {
|
path: {
|
||||||
'16': '/icons/icon-black-16.png',
|
'16': '/icons/icon-black-16.png',
|
||||||
'32': '/icons/icon-black-32.png',
|
'32': '/icons/icon-black-32.png',
|
||||||
@@ -847,7 +858,18 @@ const icons = {
|
|||||||
badgeText: '',
|
badgeText: '',
|
||||||
badgeColor: [64, 64, 64, 255] as [number, number, number, number],
|
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: {
|
path: {
|
||||||
'16': '/icons/icon-gray-16.png',
|
'16': '/icons/icon-gray-16.png',
|
||||||
'32': '/icons/icon-gray-32.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 tabUrl = tabId !== undefined ? tabUrlMap.get(tabId) : undefined
|
||||||
|
|
||||||
const iconConfig = (() => {
|
const iconConfig = (() => {
|
||||||
if (connectionState === 'error') return icons.error
|
if (connectionState === 'extension-replaced') return icons.extensionReplaced
|
||||||
if (tabId !== undefined && isRestrictedUrl(tabUrl)) return icons.restricted
|
if (tabId !== undefined && isRestrictedUrl(tabUrl)) return icons.restricted
|
||||||
if (connectionState === 'reconnecting') return icons.connecting
|
if (tabInfo?.state === 'error') return icons.tabError
|
||||||
if (tabInfo?.state === 'error') return icons.error
|
|
||||||
if (tabInfo?.state === 'connecting') return icons.connecting
|
if (tabInfo?.state === 'connecting') return icons.connecting
|
||||||
if (tabInfo?.state === 'connected') return icons.connected
|
if (tabInfo?.state === 'connected') return icons.connected
|
||||||
return icons.disconnected
|
return icons.idle
|
||||||
})()
|
})()
|
||||||
|
|
||||||
const title = (() => {
|
const title = (() => {
|
||||||
if (connectionState === 'error' && errorText) return errorText
|
if (connectionState === 'extension-replaced' && errorText) return errorText
|
||||||
if (tabInfo?.errorText) return tabInfo.errorText
|
if (tabInfo?.errorText) return tabInfo.errorText
|
||||||
return iconConfig.title
|
return iconConfig.title
|
||||||
})()
|
})()
|
||||||
|
|
||||||
const badgeText = (() => {
|
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 connectedCount > 0 ? String(connectedCount) : ''
|
||||||
}
|
}
|
||||||
return iconConfig.badgeText
|
return iconConfig.badgeText
|
||||||
@@ -929,22 +950,11 @@ async function onActionClicked(tab: chrome.tabs.Tab): Promise<void> {
|
|||||||
const { tabs, connectionState } = store.getState()
|
const { tabs, connectionState } = store.getState()
|
||||||
const tabInfo = tabs.get(tab.id)
|
const tabInfo = tabs.get(tab.id)
|
||||||
|
|
||||||
if (connectionState === 'error') {
|
// If in extension-replaced state, clear it to allow reconnection
|
||||||
logger.debug('Global error state - retrying reconnection')
|
if (connectionState === 'extension-replaced') {
|
||||||
await reconnect()
|
logger.debug('Clearing extension-replaced state, will retry connection')
|
||||||
return
|
store.setState({ connectionState: 'idle', errorText: undefined })
|
||||||
}
|
// maintainConnection will pick up and retry
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -967,6 +977,7 @@ async function onActionClicked(tab: chrome.tabs.Tab): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resetDebugger()
|
resetDebugger()
|
||||||
|
maintainConnection()
|
||||||
|
|
||||||
chrome.contextMenus.remove('playwriter-pin-element').catch(() => {}).finally(() => {
|
chrome.contextMenus.remove('playwriter-pin-element').catch(() => {}).finally(() => {
|
||||||
chrome.contextMenus.create({
|
chrome.contextMenus.create({
|
||||||
@@ -1000,7 +1011,7 @@ store.subscribe((state, prevState) => {
|
|||||||
updateContextMenuVisibility()
|
updateContextMenuVisibility()
|
||||||
const tabsChanged = serializeTabs(state.tabs) !== serializeTabs(prevState.tabs)
|
const tabsChanged = serializeTabs(state.tabs) !== serializeTabs(prevState.tabs)
|
||||||
if (tabsChanged) {
|
if (tabsChanged) {
|
||||||
syncTabGroupQueue = syncTabGroupQueue.then(syncTabGroup).catch((e) => {
|
tabGroupQueue = tabGroupQueue.then(syncTabGroup).catch((e) => {
|
||||||
logger.debug('syncTabGroup error:', e)
|
logger.debug('syncTabGroup error:', e)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1012,22 +1023,31 @@ chrome.tabs.onActivated.addListener(onTabActivated)
|
|||||||
chrome.action.onClicked.addListener(onActionClicked)
|
chrome.action.onClicked.addListener(onActionClicked)
|
||||||
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
|
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
|
||||||
void updateIcons()
|
void updateIcons()
|
||||||
if (changeInfo.groupId !== undefined && playwriterGroupId !== null) {
|
if (changeInfo.groupId !== undefined) {
|
||||||
const { tabs } = store.getState()
|
// Queue tab group operations to serialize with syncTabGroup and disconnectEverything
|
||||||
if (changeInfo.groupId === playwriterGroupId) {
|
tabGroupQueue = tabGroupQueue.then(async () => {
|
||||||
if (!tabs.has(tabId) && !isRestrictedUrl(tab.url)) {
|
// Re-check conditions after queue - state may have changed
|
||||||
logger.debug('Tab manually added to playwriter group:', tabId)
|
if (playwriterGroupId === null) {
|
||||||
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)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
logger.debug('Tab manually removed from playwriter group:', tabId)
|
const { tabs } = store.getState()
|
||||||
void disconnectTab(tabId)
|
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 type TabState = 'connecting' | 'connected' | 'error'
|
||||||
|
|
||||||
export interface TabInfo {
|
export interface TabInfo {
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
# Changelog
|
# 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
|
## 0.0.34
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "playwriter",
|
"name": "playwriter",
|
||||||
"description": "",
|
"description": "",
|
||||||
"version": "0.0.34",
|
"version": "0.0.35",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
|||||||
@@ -361,15 +361,40 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
|||||||
const clientId = c.req.param('clientId') || 'default'
|
const clientId = c.req.param('clientId') || 'default'
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onOpen(_event, ws) {
|
async onOpen(_event, ws) {
|
||||||
if (playwrightClients.has(clientId)) {
|
if (playwrightClients.has(clientId)) {
|
||||||
logger?.log(chalk.red(`Rejecting duplicate client ID: ${clientId}`))
|
logger?.log(chalk.red(`Rejecting duplicate client ID: ${clientId}`))
|
||||||
ws.close(1000, 'Client ID already connected')
|
ws.close(1000, 'Client ID already connected')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add client first so it can receive Target.attachedToTarget events
|
||||||
playwrightClients.set(clientId, { id: clientId, ws })
|
playwrightClients.set(clientId, { id: clientId, ws })
|
||||||
logger?.log(chalk.green(`Playwright client connected: ${clientId} (${playwrightClients.size} total)`))
|
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) {
|
async onMessage(event, ws) {
|
||||||
|
|||||||
@@ -217,8 +217,8 @@ describe('MCP Server Tests', () => {
|
|||||||
|
|
||||||
Return value:
|
Return value:
|
||||||
{
|
{
|
||||||
\"url\": \"https://example.com/\",
|
"url": "https://example.com/",
|
||||||
\"title\": \"Example Domain\"
|
"title": "Example Domain"
|
||||||
}",
|
}",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
},
|
},
|
||||||
@@ -1943,8 +1943,8 @@ describe('MCP Server Tests', () => {
|
|||||||
{
|
{
|
||||||
"text": "Return value:
|
"text": "Return value:
|
||||||
{
|
{
|
||||||
"error": "Page not found",
|
"matchesDark": false,
|
||||||
"urls": []
|
"matchesLight": true
|
||||||
}",
|
}",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
},
|
},
|
||||||
@@ -2881,3 +2881,71 @@ describe('CDP Session Tests', () => {
|
|||||||
await page.close()
|
await page.close()
|
||||||
}, 60000)
|
}, 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