Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions cli/lib/exec/tap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { TapSession } from '../tap/tap-session'
import { buildTapProgram } from '../tap/build-program'
import { renderFailure, renderKnownFailure, renderInstancesHelp, renderResult, renderGenericHelp, renderSchemaHelp } from '../tap/output'
import { reportStatus } from '../tap/status'
import { runFrame, FRAME_COMMAND_NAMES } from '../tap/frame'
import { TAP_EXEC_METHOD, TAP_SCHEMA_VERSION, TAP_SCHEMA_METHOD } from '@packages/cypress-instances'
import type { TapSchema } from '@packages/cypress-instances'
import { errors } from '../errors'
Expand Down Expand Up @@ -100,6 +101,13 @@ const tapModule = {
return reportStatus(options, wantsHelp)
}

// The frame commands are CLI-native too: they run CDP domains against the
// AUT frame, which the in-page binding cannot reach, so they parse and
// dispatch here rather than going through the schema program.
if (command && (FRAME_COMMAND_NAMES as readonly string[]).includes(command)) {
return runFrame(positionals, options, wantsHelp)
}

try {
const selection = await resolveInstance({ instance: options.instance, cwd: process.cwd() })

Expand Down
76 changes: 76 additions & 0 deletions cli/lib/tap/aut-frame.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import Debug from 'debug'
import type CRI from 'chrome-remote-interface'

const debug = Debug('cypress:cli:tap')

// The app names the AUT iframe deterministically (packages/app/src/runner/
// aut-iframe.ts): its name is `Your project: '<name>'`. That prefix is how we
// pick it out of the runner page's frame tree — distinct from the snapshot
// double-buffer frames (`AUT Snapshot - N`) and the spec bridge (`Your Spec`).
const AUT_FRAME_NAME_PREFIX = 'Your project:'

/**
* A failure in a frame command (dom/aria/inspect), surfaced to the user as `{ code, message }`
* via `renderFailure`. The frame commands are CLI-native and never cross the
* binding's `exec` envelope, so this mirrors the binding's `TapCommandError`
* on the CLI side.
*/
export class FrameCommandError extends Error {
code: string

constructor (code: string, message: string) {
super(message)
this.name = 'FrameCommandError'
this.code = code
}
}

export interface AutFrame {
/** CDP frameId — scopes `Accessibility.getFullAXTree` and `Page.createIsolatedWorld`. */
frameId: string
url: string
}

interface FrameNode {
frame: { id: string, name?: string, url?: string }
childFrames?: FrameNode[]
}

const findAutFrame = (node: FrameNode): { id: string, url: string } | undefined => {
if ((node.frame.name ?? '').startsWith(AUT_FRAME_NAME_PREFIX)) {
return { id: node.frame.id, url: node.frame.url ?? '' }
}
Comment thread
cursor[bot] marked this conversation as resolved.

for (const child of node.childFrames ?? []) {
const found = findAutFrame(child)

if (found) {
return found
}
}

return undefined
}

/**
* Locates the app-under-test frame within the runner page. Verified empirically:
* the AUT is a same-process child frame of the runner-page target, so one
* attached session reaches it — no separate target attach. A pinned snapshot is
* always same-super-domain, so it is reliably this child frame too.
*/
export const resolveAutFrame = async (client: CRI.Client, sessionId: string): Promise<AutFrame> => {
await client.Page.enable({}, sessionId)

// getFrameTree takes no params, so its typed signature is (sessionId?); CRI
// routes the string arg to sessionId by type, not position.
const { frameTree } = await client.Page.getFrameTree(sessionId)
const found = findAutFrame(frameTree as FrameNode)

if (!found) {
throw new FrameCommandError('NO_AUT_FRAME', 'no app under test is loaded — run a spec first (and, to read a past command, pin it with the pin command)')
}

debug('resolved AUT frame %o', found)

return { frameId: found.id, url: found.url }
}
18 changes: 18 additions & 0 deletions cli/lib/tap/build-program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,24 @@ export const buildTapProgram = (schema: TapSchema, dispatch: TapDispatch): comma
.description('report where a running Cypress instance is in its lifecycle')
.action(() => {})

// The frame commands are CLI-native (see `../frame`); these entries only make
// them show up in the connected help listing — routing intercepts them before
// this program ever parses.
program
.command('dom [selector]')
.description('read the app-under-test DOM: the page HTML, or just the elements matching a selector')
.action(() => {})

program
.command('aria [selector]')
.description('read the accessibility (ARIA) tree of the app-under-test frame, or the subtree at a selector')
.action(() => {})

program
.command('inspect <selector>')
.description('inspect one element: its tag, attributes, computed styles, box model, and accessibility node')
.action(() => {})

for (const { name, description, params = [], options = [], hidden } of schema.commands) {
if (hidden) {
continue
Expand Down
206 changes: 206 additions & 0 deletions cli/lib/tap/frame/aria.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import type { TapSession } from '../tap-session'
import type { AutFrame } from '../aut-frame'
import { FrameCommandError } from '../aut-frame'

// The accessibility tree of a real app is deep; cap the projection so it stays
// affordable for an LLM. A selector roots it at a subtree for finer reads.
export const DEFAULT_MAX_NODES = 200

// Structural/text roles carry no semantic signal on their own — dropping them
// yields the compact role/name tree DevTools shows, not the raw render tree.
const NOISE_ROLES = new Set(['InlineTextBox', 'StaticText', 'LineBreak', 'generic', 'none', 'GenericContainer', 'paragraph'])

// The boolean states worth reporting when true; the rest are rarely actionable.
const REPORTED_STATES = new Set(['focused', 'disabled', 'required', 'invalid', 'checked', 'expanded', 'selected', 'pressed', 'readonly', 'hidden', 'modal', 'busy'])

interface AXValue { value?: unknown }
interface AXProperty { name: string, value?: AXValue }
interface AXNode {
nodeId: string
parentId?: string
role?: AXValue
name?: AXValue
value?: AXValue
properties?: AXProperty[]
childIds?: string[]
ignored?: boolean
backendDOMNodeId?: number
}

export interface AriaNodeOut {
depth: number
role: string
name?: string
value?: string
states?: string[]
}

export interface FrameAriaResult {
url?: string
nodes: AriaNodeOut[]
nodeCount: number
truncated?: true
}

const projectNode = (node: AXNode, depth: number): AriaNodeOut | undefined => {
const role = node.role?.value

if (typeof role !== 'string' || node.ignored || NOISE_ROLES.has(role)) {
return undefined
}

const out: AriaNodeOut = { depth, role }
const name = node.name?.value

if (typeof name === 'string' && name.length > 0) {
out.name = name
}

const value = node.value?.value

if (value !== undefined && value !== null && value !== '') {
out.value = String(value)
}

const states = (node.properties ?? [])
.filter((property) => REPORTED_STATES.has(property.name) && property.value?.value === true)
.map((property) => property.name)

if (states.length) {
out.states = states
}

return out
}

/**
* Walks the AX forest from `rootId` depth-first. A node that projects to a
* meaningful role deepens the indent for its descendants; a noise node is
* skipped but its children keep flowing, so the output is a clean semantic
* tree rather than the raw render tree. Stops at `maxNodes`.
*/
const projectTree = (byId: Map<string, AXNode>, rootId: string, maxNodes: number): { nodes: AriaNodeOut[], truncated: boolean } => {
const nodes: AriaNodeOut[] = []
let truncated = false

const walk = (id: string, depth: number): void => {
if (truncated) {
return
}

const node = byId.get(id)

if (!node) {
return
}

const projected = projectNode(node, depth)

if (projected) {
if (nodes.length >= maxNodes) {
truncated = true

return
}

nodes.push(projected)
}

const childDepth = projected ? depth + 1 : depth

for (const childId of node.childIds ?? []) {
walk(childId, childDepth)
}
}

walk(rootId, 0)

return { nodes, truncated }
}

const resolveSelectorBackendNodeId = async (
session: TapSession,
frame: AutFrame,
selector: string,
): Promise<number | undefined> => {
const { client, sessionId } = session

const { executionContextId } = await client.Page.createIsolatedWorld({
frameId: frame.frameId,
worldName: 'cypress-tap',
}, sessionId)

const { result, exceptionDetails } = await client.Runtime.callFunctionOn({
functionDeclaration: 'function (selector) { return document.querySelector(selector) }',
executionContextId,
arguments: [{ value: selector }],
}, sessionId)

if (exceptionDetails) {
throw new FrameCommandError('INVALID_SELECTOR', `"${selector}" is not a valid CSS selector`)
}

// querySelector returned null — a real "nothing matched" answer, not an error.
if (!result.objectId || result.subtype === 'null') {
return undefined
}

const { node } = await client.DOM.describeNode({ objectId: result.objectId }, sessionId)

return node.backendNodeId
}

export const extractAria = async (
session: TapSession,
frame: AutFrame,
selector: string | undefined,
maxNodes: number,
): Promise<FrameAriaResult> => {
const { client, sessionId } = session

await client.DOM.enable({}, sessionId)
await client.Accessibility.enable(sessionId)

const { nodes: axNodes } = await client.Accessibility.getFullAXTree({ frameId: frame.frameId }, sessionId)

const byId = new Map<string, AXNode>()

for (const node of axNodes as AXNode[]) {
byId.set(node.nodeId, node)
}

const base: FrameAriaResult = { ...(frame.url ? { url: frame.url } : {}), nodes: [], nodeCount: 0 }

let rootId: string | undefined

if (selector !== undefined) {
const backendNodeId = await resolveSelectorBackendNodeId(session, frame, selector)

if (backendNodeId === undefined) {
// Selector matched nothing, or the match is absent from the a11y tree.
return base
}

rootId = (axNodes as AXNode[]).find((node) => node.backendDOMNodeId === backendNodeId)?.nodeId

if (rootId === undefined) {
return base
}
} else {
// The frame root is the sole node with no parent (the RootWebArea).
rootId = (axNodes as AXNode[]).find((node) => node.parentId === undefined)?.nodeId ?? (axNodes as AXNode[])[0]?.nodeId
}

if (rootId === undefined) {
return base
}

const { nodes, truncated } = projectTree(byId, rootId, maxNodes)

return {
...base,
nodes,
nodeCount: nodes.length,
...(truncated ? { truncated: true } : {}),
}
}
Loading