feat: reuse Playwright's internal CDP session instead of creating new WebSocket connections

Switch from getCDPSessionForPage (creates a new WS via Target.attachToTarget)
to getExistingCDPSessionForPage which reuses Playwright's internal CRSession.
This is critical for the relay server where Target.attachToTarget is intercepted
and cannot create real new sessions.

Also add FrameLocator support in accessibilitySnapshot — FrameLocator (from
locator.contentFrame()) is now auto-resolved to the real Frame object needed
for OOPIF CDP session attachment.

- New PlaywrightCDPSessionAdapter wrapping Playwright's CDPSession as ICDPSession
- New getExistingCDPSessionForPage() using context.getExistingCDPSession()
- resolveFrame() helper converts FrameLocator to Frame via elementHandle().contentFrame()
- getAriaSnapshot() frame param now accepts Frame | FrameLocator
- Executor CDP cache uses ICDPSession, borrowed sessions detach as no-op
- Test for getExistingCDPSession through the relay
- Updated framer iframe guide with alternative page.frames() example
- Bump playwright submodule to @xmorse/playwright-core@1.59.2
- Fix styles-api.md import path
This commit is contained in:
Tommy D. Rossi
2026-02-06 18:03:26 +01:00
parent 78734bf1e8
commit 87188423b1
9 changed files with 179 additions and 29 deletions
+6 -1
View File
@@ -45,11 +45,16 @@ playwriter -s 1 -e "const iframe = page.locator(\"iframe[src*='plugins.framercdn
playwriter -s 1 -e "const iframe = page.locator(\"iframe[src*='plugins.framercdn.com']\"); console.log(await iframe.count());" playwriter -s 1 -e "const iframe = page.locator(\"iframe[src*='plugins.framercdn.com']\"); console.log(await iframe.count());"
``` ```
- Run the accessibility snapshot on that iframe: - Run the accessibility snapshot on that iframe using `contentFrame()` (FrameLocator is auto-resolved to Frame):
```bash ```bash
playwriter -s 1 -e "const frame = await page.locator(\"iframe[src*='plugins.framercdn.com']\").contentFrame(); console.log(await accessibilitySnapshot({ page, frame }));" playwriter -s 1 -e "const frame = await page.locator(\"iframe[src*='plugins.framercdn.com']\").contentFrame(); console.log(await accessibilitySnapshot({ page, frame }));"
``` ```
- Alternative: use `page.frames()` to get the Frame directly:
```bash
playwriter -s 1 -e "const frame = page.frames().find(f => f.url().includes('plugins.framercdn.com')); console.log(await accessibilitySnapshot({ page, frame }));"
```
- Validate the snapshot contains MCP UI text (confirms the panel is actually loaded): - Validate the snapshot contains MCP UI text (confirms the panel is actually loaded):
```bash ```bash
playwriter -s 1 -e "const frame = await page.locator(\"iframe[src*='plugins.framercdn.com']\").contentFrame(); console.log(await accessibilitySnapshot({ page, frame, search: /Control Framer with MCP|Login With Google/ }));" playwriter -s 1 -e "const frame = await page.locator(\"iframe[src*='plugins.framercdn.com']\").contentFrame(); console.log(await accessibilitySnapshot({ page, frame, search: /Control Framer with MCP|Login With Google/ }));"
+47 -4
View File
@@ -1,7 +1,7 @@
// Accessibility snapshot pipeline: build raw AX tree, filter to a // Accessibility snapshot pipeline: build raw AX tree, filter to a
// tree (interactive-only, labels/contexts, wrapper hoisting, ignored // tree (interactive-only, labels/contexts, wrapper hoisting, ignored
// indent preservation), then render lines and locators. // indent preservation), then render lines and locators.
import type { Page, Locator, ElementHandle, Frame } from '@xmorse/playwright-core' import type { Page, Locator, ElementHandle, Frame, FrameLocator } from '@xmorse/playwright-core'
import crypto from 'node:crypto' import crypto from 'node:crypto'
import fs from 'node:fs' import fs from 'node:fs'
import path from 'node:path' import path from 'node:path'
@@ -723,6 +723,44 @@ function isSubstringOfAny(needle: string, haystack: Set<string>): boolean {
return false return false
} }
// ============================================================================
// Frame Resolution
// ============================================================================
/**
* Resolve a FrameLocator to an actual Frame object. FrameLocator (returned by
* locator.contentFrame()) is a scoping helper that doesn't have CDP methods like
* frameId(). We need the real Frame from page.frames() for OOPIF session attachment.
*
* If a Frame is passed directly, it's returned as-is.
* If a FrameLocator is passed, we find the matching Frame by locating the iframe
* element and matching its src URL against page.frames().
*/
async function resolveFrame({ frame, page }: { frame?: Frame | FrameLocator; page: Page }): Promise<Frame | undefined> {
if (!frame) {
return undefined
}
// Frame has frameId(), FrameLocator does not
if (typeof (frame as Frame).frameId === 'function') {
return frame as Frame
}
// It's a FrameLocator — resolve to a Frame via the owner iframe element.
// Use elementHandle().contentFrame() which returns the actual Frame object
// directly from the <iframe> element, avoiding ambiguity when multiple
// frames share the same origin (e.g. framer plugin iframes).
const frameLocator = frame as FrameLocator
const owner = frameLocator.owner()
const handle = await owner.elementHandle()
if (!handle) {
throw new Error('Could not resolve FrameLocator to a Frame: iframe element not found')
}
const resolved = await handle.contentFrame()
if (!resolved) {
throw new Error('Could not resolve FrameLocator to a Frame: contentFrame() returned null')
}
return resolved
}
// ============================================================================ // ============================================================================
// Main Functions // Main Functions
// ============================================================================ // ============================================================================
@@ -748,7 +786,7 @@ function isSubstringOfAny(needle: string, haystack: Set<string>): boolean {
*/ */
export async function getAriaSnapshot({ page, frame, locator, refFilter, wsUrl, interactiveOnly = false, cdp }: { export async function getAriaSnapshot({ page, frame, locator, refFilter, wsUrl, interactiveOnly = false, cdp }: {
page: Page page: Page
frame?: Frame frame?: Frame | FrameLocator
locator?: Locator locator?: Locator
refFilter?: (info: { role: string; name: string }) => boolean refFilter?: (info: { role: string; name: string }) => boolean
wsUrl?: string wsUrl?: string
@@ -756,15 +794,20 @@ export async function getAriaSnapshot({ page, frame, locator, refFilter, wsUrl,
cdp?: ICDPSession cdp?: ICDPSession
}): Promise<AriaSnapshotResult> { }): Promise<AriaSnapshotResult> {
const session = cdp || await getCDPSessionForPage({ page, wsUrl }) const session = cdp || await getCDPSessionForPage({ page, wsUrl })
// Resolve FrameLocator to an actual Frame. FrameLocator (from locator.contentFrame())
// is a scoping helper without CDP access. We need the real Frame from page.frames()
// which has frameId() for OOPIF session attachment.
const resolvedFrame = await resolveFrame({ frame, page })
// For cross-origin iframes (OOPIFs), we need to attach to the iframe's target // For cross-origin iframes (OOPIFs), we need to attach to the iframe's target
// to get a separate CDP session. Same-origin iframes can use frameId directly. // to get a separate CDP session. Same-origin iframes can use frameId directly.
let oopifSessionId: string | null = null let oopifSessionId: string | null = null
const frameId = frame?.frameId() ?? null const frameId = resolvedFrame?.frameId() ?? null
if (frameId) { if (frameId) {
const { targetInfos } = await session.send('Target.getTargets') as Protocol.Target.GetTargetsResponse const { targetInfos } = await session.send('Target.getTargets') as Protocol.Target.GetTargetsResponse
const frameUrl = frame!.url() const frameUrl = resolvedFrame!.url()
const iframeTarget = targetInfos.find((t) => { const iframeTarget = targetInfos.find((t) => {
return t.type === 'iframe' && t.url === frameUrl return t.type === 'iframe' && t.url === frameUrl
}) })
+48 -1
View File
@@ -1,5 +1,5 @@
import WebSocket from 'ws' import WebSocket from 'ws'
import type { Page } from '@xmorse/playwright-core' import type { Page, CDPSession as PlaywrightCDPSession } from '@xmorse/playwright-core'
import type { ProtocolMapping } from 'devtools-protocol/types/protocol-mapping.js' import type { ProtocolMapping } from 'devtools-protocol/types/protocol-mapping.js'
import type { CDPResponseBase, CDPEventBase } from './cdp-types.js' import type { CDPResponseBase, CDPEventBase } from './cdp-types.js'
import { getCdpUrl } from './utils.js' import { getCdpUrl } from './utils.js'
@@ -233,3 +233,50 @@ export async function getCDPSessionForPage({ page, wsUrl }: { page: Page; wsUrl?
return cdp return cdp
} }
/**
* Wraps Playwright's CDPSession (from context.getExistingCDPSession) into an ICDPSession.
* This reuses Playwright's internal CDP WebSocket instead of creating a new one,
* which is important for the relay server where Target.attachToTarget is intercepted.
*
* The adapter maps between devtools-protocol's ProtocolMapping types (used by our CDPSession)
* and Playwright's Protocol types (used by their CDPSession). Both are compatible since
* ICDPSession uses loose string-based method names.
*/
export class PlaywrightCDPSessionAdapter implements ICDPSession {
private _playwrightSession: PlaywrightCDPSession
constructor(playwrightSession: PlaywrightCDPSession) {
this._playwrightSession = playwrightSession
}
async send(method: string, params?: object): Promise<unknown> {
return await this._playwrightSession.send(method as never, params as never)
}
on(event: string, callback: (params: unknown) => void): this {
this._playwrightSession.on(event as never, callback as never)
return this
}
off(event: string, callback: (params: unknown) => void): this {
this._playwrightSession.off(event as never, callback as never)
return this
}
async detach(): Promise<void> {
await this._playwrightSession.detach()
}
}
/**
* Gets a CDP session for a page by reusing Playwright's internal existing CDP session.
* Unlike getCDPSessionForPage which creates a new WebSocket, this uses the same WS
* Playwright already has. Works through the relay because it doesn't call
* Target.attachToTarget.
*/
export async function getExistingCDPSessionForPage({ page }: { page: Page }): Promise<PlaywrightCDPSessionAdapter> {
const context = page.context()
const playwrightSession = await context.getExistingCDPSession(page)
return new PlaywrightCDPSessionAdapter(playwrightSession)
}
+12 -9
View File
@@ -3,7 +3,7 @@
* Used by both MCP and CLI to execute Playwright code with persistent state. * Used by both MCP and CLI to execute Playwright code with persistent state.
*/ */
import { Page, Frame, Browser, BrowserContext, chromium, Locator } from '@xmorse/playwright-core' import { Page, Frame, Browser, BrowserContext, chromium, Locator, FrameLocator } from '@xmorse/playwright-core'
import crypto from 'node:crypto' import crypto from 'node:crypto'
import fs from 'node:fs' import fs from 'node:fs'
import path from 'node:path' import path from 'node:path'
@@ -16,7 +16,7 @@ import * as acorn from 'acorn'
import { createSmartDiff } from './diff-utils.js' import { createSmartDiff } from './diff-utils.js'
import { getCdpUrl } from './utils.js' import { getCdpUrl } from './utils.js'
import { waitForPageLoad, WaitForPageLoadOptions, WaitForPageLoadResult } from './wait-for-page-load.js' import { waitForPageLoad, WaitForPageLoadOptions, WaitForPageLoadResult } from './wait-for-page-load.js'
import { getCDPSessionForPage, CDPSession, ICDPSession } from './cdp-session.js' import { getCDPSessionForPage, CDPSession, ICDPSession, getExistingCDPSessionForPage } from './cdp-session.js'
import { Debugger } from './debugger.js' import { Debugger } from './debugger.js'
import { Editor } from './editor.js' import { Editor } from './editor.js'
import { getStylesForLocator, formatStylesAsText, type StylesResult } from './styles.js' import { getStylesForLocator, formatStylesAsText, type StylesResult } from './styles.js'
@@ -218,7 +218,7 @@ export class PlaywrightExecutor {
private browserLogs: Map<string, string[]> = new Map() private browserLogs: Map<string, string[]> = new Map()
private lastSnapshots: WeakMap<Page, string> = new WeakMap() private lastSnapshots: WeakMap<Page, string> = new WeakMap()
private lastRefToLocator: WeakMap<Page, Map<string, string>> = new WeakMap() private lastRefToLocator: WeakMap<Page, Map<string, string>> = new WeakMap()
private cdpSessionCache: WeakMap<Page, CDPSession> = new WeakMap() private cdpSessionCache: WeakMap<Page, ICDPSession> = new WeakMap()
private scopedFs: ScopedFS private scopedFs: ScopedFS
private sandboxedRequire: NodeRequire private sandboxedRequire: NodeRequire
@@ -533,8 +533,8 @@ export class PlaywrightExecutor {
const accessibilitySnapshot = async (options: { const accessibilitySnapshot = async (options: {
page?: Page page?: Page
/** Optional frame to scope the snapshot (e.g. from iframe.contentFrame()) */ /** Optional frame to scope the snapshot (e.g. from iframe.contentFrame() or page.frames()) */
frame?: Frame frame?: Frame | FrameLocator
/** Optional locator to scope the snapshot to a subtree */ /** Optional locator to scope the snapshot to a subtree */
locator?: Locator locator?: Locator
search?: string | RegExp search?: string | RegExp
@@ -723,9 +723,10 @@ export class PlaywrightExecutor {
return cached return cached
} }
// Generate a fresh unique URL for each CDP session to avoid client ID conflicts // Reuse Playwright's internal CDP session over the same WebSocket instead of
const wsUrl = getCdpUrl(this.cdpConfig) // creating a new WS connection. This is critical for the relay where
const session = await getCDPSessionForPage({ page: options.page, wsUrl }) // Target.attachToTarget is intercepted and can't create real new sessions.
const session = await getExistingCDPSessionForPage({ page: options.page })
this.cdpSessionCache.set(options.page, session) this.cdpSessionCache.set(options.page, session)
options.page.on('close', () => { options.page.on('close', () => {
@@ -734,7 +735,9 @@ export class PlaywrightExecutor {
return return
} }
this.cdpSessionCache.delete(options.page) this.cdpSessionCache.delete(options.page)
cachedSession.close() // Borrowed sessions: detach is a no-op since Playwright owns the underlying session.
// We just clean up the cache reference.
cachedSession.detach().catch(() => {})
}) })
return session return session
+1 -1
View File
@@ -1,6 +1,6 @@
export * from './cdp-relay.js' export * from './cdp-relay.js'
export * from './utils.js' export * from './utils.js'
export { CDPSession, getCDPSessionForPage } from './cdp-session.js' export { CDPSession, getCDPSessionForPage, getExistingCDPSessionForPage, PlaywrightCDPSessionAdapter } from './cdp-session.js'
export type { ICDPSession } from './cdp-session.js' export type { ICDPSession } from './cdp-session.js'
export { Editor } from './editor.js' export { Editor } from './editor.js'
export type { ReadResult, SearchMatch, EditResult } from './editor.js' export type { ReadResult, SearchMatch, EditResult } from './editor.js'
+38 -1
View File
@@ -8,7 +8,7 @@ import fs from 'node:fs'
import os from 'node:os' import os from 'node:os'
import { imageSize } from 'image-size' import { imageSize } from 'image-size'
import { getCdpUrl } from './utils.js' import { getCdpUrl } from './utils.js'
import { getCDPSessionForPage } from './cdp-session.js' import { getCDPSessionForPage, getExistingCDPSessionForPage } from './cdp-session.js'
import type { CDPCommand } from './cdp-types.js' import type { CDPCommand } from './cdp-types.js'
import { screenshotWithAccessibilityLabels } from './aria-snapshot.js' import { screenshotWithAccessibilityLabels } from './aria-snapshot.js'
import { setupTestContext, cleanupTestContext, getExtensionServiceWorker, type TestContext, js } from './test-utils.js' import { setupTestContext, cleanupTestContext, getExtensionServiceWorker, type TestContext, js } from './test-utils.js'
@@ -575,6 +575,43 @@ describe('Snapshot & Screenshot Tests', () => {
await page.close() await page.close()
}, 60000) }, 60000)
it('should support getExistingCDPSession through the relay (reusing Playwright WS)', async () => {
const browserContext = getBrowserContext()
const serviceWorker = await getExtensionServiceWorker(browserContext)
const page = await browserContext.newPage()
await page.goto('https://example.com/')
await page.bringToFront()
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 100))
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com'))
expect(cdpPage).toBeDefined()
// Use the new getExistingCDPSessionForPage which reuses Playwright's internal WS
const cdpClient = await getExistingCDPSessionForPage({ page: cdpPage! })
// Should be able to send CDP commands just like the regular getCDPSessionForPage
const layoutMetrics = await cdpClient.send('Page.getLayoutMetrics')
expect(layoutMetrics).toBeDefined()
const metrics = layoutMetrics as { cssVisualViewport?: { clientWidth?: number } }
expect(metrics.cssVisualViewport).toBeDefined()
expect(metrics.cssVisualViewport!.clientWidth).toBeGreaterThan(0)
// Test DOM access
const document = await cdpClient.send('DOM.getDocument')
expect(document).toBeDefined()
await cdpClient.detach()
await browser.close()
await page.close()
}, 60000)
it('should get aria ref for locator using getAriaSnapshot', async () => { it('should get aria ref for locator using getAriaSnapshot', async () => {
const browserContext = getBrowserContext() const browserContext = getBrowserContext()
const serviceWorker = await getExtensionServiceWorker(browserContext) const serviceWorker = await getExtensionServiceWorker(browserContext)
+25 -10
View File
@@ -251,6 +251,9 @@ importers:
dotenv: dotenv:
specifier: ^16.4.5 specifier: ^16.4.5
version: 16.6.1 version: 16.6.1
get-stream:
specifier: ^9.0.1
version: 9.0.1
https-proxy-agent: https-proxy-agent:
specifier: 7.0.6 specifier: 7.0.6
version: 7.0.6 version: 7.0.6
@@ -2449,6 +2452,9 @@ packages:
'@rtsao/scc@1.1.0': '@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
'@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
'@sentry-internal/browser-utils@9.47.1': '@sentry-internal/browser-utils@9.47.1':
resolution: {integrity: sha512-twv6YhrUlPkvKz4/iQDH4KHgcv9t4cMjmZPf4/dCSCXn4/GOjzjx2d74c1w+1KOdS7lcsQzI+MtbK6SeYLiGfQ==} resolution: {integrity: sha512-twv6YhrUlPkvKz4/iQDH4KHgcv9t4cMjmZPf4/dCSCXn4/GOjzjx2d74c1w+1KOdS7lcsQzI+MtbK6SeYLiGfQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -4229,6 +4235,10 @@ packages:
resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==}
engines: {node: '>=8'} engines: {node: '>=8'}
get-stream@9.0.1:
resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==}
engines: {node: '>=18'}
get-symbol-description@1.1.0: get-symbol-description@1.1.0:
resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -4567,6 +4577,10 @@ packages:
resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==} resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
is-stream@4.0.1:
resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==}
engines: {node: '>=18'}
is-string@1.1.1: is-string@1.1.1:
resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -8902,6 +8916,8 @@ snapshots:
'@rtsao/scc@1.1.0': {} '@rtsao/scc@1.1.0': {}
'@sec-ant/readable-stream@0.4.1': {}
'@sentry-internal/browser-utils@9.47.1': '@sentry-internal/browser-utils@9.47.1':
dependencies: dependencies:
'@sentry/core': 9.47.1 '@sentry/core': 9.47.1
@@ -9514,14 +9530,6 @@ snapshots:
optionalDependencies: optionalDependencies:
vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.6.0) vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.6.0)
'@vitest/mocker@4.0.8(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.6.0))':
dependencies:
'@vitest/spy': 4.0.8
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.6.0)
'@vitest/mocker@4.0.8(vite@7.2.2(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.6.0))': '@vitest/mocker@4.0.8(vite@7.2.2(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.6.0))':
dependencies: dependencies:
'@vitest/spy': 4.0.8 '@vitest/spy': 4.0.8
@@ -9573,7 +9581,7 @@ snapshots:
sirv: 3.0.2 sirv: 3.0.2
tinyglobby: 0.2.15 tinyglobby: 0.2.15
tinyrainbow: 3.0.3 tinyrainbow: 3.0.3
vitest: 4.0.8(@types/node@24.10.1)(@vitest/ui@4.0.8)(jiti@2.6.1)(jsdom@27.2.0(bufferutil@4.0.9))(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.6.0) vitest: 4.0.8(@types/node@25.2.0)(@vitest/ui@4.0.8)(jiti@2.6.1)(jsdom@27.2.0(bufferutil@4.0.9))(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.6.0)
'@vitest/utils@4.0.16': '@vitest/utils@4.0.16':
dependencies: dependencies:
@@ -11180,6 +11188,11 @@ snapshots:
dependencies: dependencies:
pump: 3.0.3 pump: 3.0.3
get-stream@9.0.1:
dependencies:
'@sec-ant/readable-stream': 0.4.1
is-stream: 4.0.1
get-symbol-description@1.1.0: get-symbol-description@1.1.0:
dependencies: dependencies:
call-bound: 1.0.4 call-bound: 1.0.4
@@ -11569,6 +11582,8 @@ snapshots:
is-stream@1.1.0: {} is-stream@1.1.0: {}
is-stream@4.0.1: {}
is-string@1.1.1: is-string@1.1.1:
dependencies: dependencies:
call-bound: 1.0.4 call-bound: 1.0.4
@@ -13655,7 +13670,7 @@ snapshots:
vitest@4.0.8(@types/node@24.10.1)(@vitest/ui@4.0.8)(jiti@2.6.1)(jsdom@27.2.0(bufferutil@4.0.9))(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.6.0): vitest@4.0.8(@types/node@24.10.1)(@vitest/ui@4.0.8)(jiti@2.6.1)(jsdom@27.2.0(bufferutil@4.0.9))(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.6.0):
dependencies: dependencies:
'@vitest/expect': 4.0.8 '@vitest/expect': 4.0.8
'@vitest/mocker': 4.0.8(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.6.0)) '@vitest/mocker': 4.0.8(vite@7.2.2(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.6.0))
'@vitest/pretty-format': 4.0.8 '@vitest/pretty-format': 4.0.8
'@vitest/runner': 4.0.8 '@vitest/runner': 4.0.8
'@vitest/snapshot': 4.0.8 '@vitest/snapshot': 4.0.8
+1 -1
View File
@@ -6,7 +6,7 @@ The getStylesForLocator function inspects CSS styles applied to an element, simi
```ts ```ts
import type { ICDPSession } from './cdp-session.js'; import type { ICDPSession } from './cdp-session.js';
import type { Locator } from 'playwright-core'; import type { Locator } from '@xmorse/playwright-core';
export interface StyleSource { export interface StyleSource {
url: string; url: string;
line: number; line: number;