diff --git a/cli/lib/exec/tap.ts b/cli/lib/exec/tap.ts index 5a8f74198d1..2e1258d624d 100644 --- a/cli/lib/exec/tap.ts +++ b/cli/lib/exec/tap.ts @@ -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' @@ -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() }) diff --git a/cli/lib/tap/aut-frame.ts b/cli/lib/tap/aut-frame.ts new file mode 100644 index 00000000000..45973fa08cc --- /dev/null +++ b/cli/lib/tap/aut-frame.ts @@ -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: ''`. 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 ?? '' } + } + + 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 => { + 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 } +} diff --git a/cli/lib/tap/build-program.ts b/cli/lib/tap/build-program.ts index 7631038f551..48005ade1ff 100644 --- a/cli/lib/tap/build-program.ts +++ b/cli/lib/tap/build-program.ts @@ -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 ') + .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 diff --git a/cli/lib/tap/frame/aria.ts b/cli/lib/tap/frame/aria.ts new file mode 100644 index 00000000000..7ddd55eeef2 --- /dev/null +++ b/cli/lib/tap/frame/aria.ts @@ -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, 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 => { + 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 => { + 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() + + 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 } : {}), + } +} diff --git a/cli/lib/tap/frame/dom.ts b/cli/lib/tap/frame/dom.ts new file mode 100644 index 00000000000..6ca2a620534 --- /dev/null +++ b/cli/lib/tap/frame/dom.ts @@ -0,0 +1,78 @@ +import type { TapSession } from '../tap-session' +import type { AutFrame } from '../aut-frame' +import { FrameCommandError } from '../aut-frame' + +export const DEFAULT_MAX_CHARS = 30000 + +// Runs in an isolated world in the AUT frame (shares the DOM, separate JS +// context — no page-global pollution). Caps output browser-side so a heavy +// page never ships megabytes across CDP. Returns a tagged object rather than +// throwing, so a bad selector round-trips as data instead of a CDP exception. +const DOM_FN = `function (selector, maxChars) { + if (selector === null) { + var html = document.documentElement ? document.documentElement.outerHTML : '' + return html.length > maxChars ? { html: html.slice(0, maxChars), truncated: true } : { html: html } + } + var els + try { els = Array.prototype.slice.call(document.querySelectorAll(selector)) } + catch (e) { return { invalidSelector: true } } + var out = [], remaining = maxChars, truncated = false + for (var i = 0; i < els.length; i++) { + var o = els[i].outerHTML + if (o.length > remaining) { if (remaining > 0) out.push(o.slice(0, remaining)); truncated = true; break } + out.push(o); remaining -= o.length + } + return { matches: { count: els.length, html: out }, truncated: truncated } +}` + +export interface FrameDomResult { + url?: string + html?: string + matches?: { count: number, html: string[] } + truncated?: true +} + +interface DomFnResult { + html?: string + matches?: { count: number, html: string[] } + truncated?: boolean + invalidSelector?: boolean +} + +export const extractDom = async ( + session: TapSession, + frame: AutFrame, + selector: string | undefined, + maxChars: number, +): Promise => { + 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: DOM_FN, + executionContextId, + arguments: [{ value: selector ?? null }, { value: maxChars }], + returnByValue: true, + }, sessionId) + + if (exceptionDetails) { + throw new FrameCommandError('FRAME_READ_FAILED', `reading the app-under-test DOM failed: ${exceptionDetails.exception?.description || exceptionDetails.text}`) + } + + const value = result.value as DomFnResult + + if (value.invalidSelector) { + throw new FrameCommandError('INVALID_SELECTOR', `"${selector}" is not a valid CSS selector`) + } + + return { + ...(frame.url ? { url: frame.url } : {}), + ...(value.matches ? { matches: value.matches } : {}), + ...(value.html !== undefined ? { html: value.html } : {}), + ...(value.truncated ? { truncated: true } : {}), + } +} diff --git a/cli/lib/tap/frame/index.ts b/cli/lib/tap/frame/index.ts new file mode 100644 index 00000000000..e969fb4a424 --- /dev/null +++ b/cli/lib/tap/frame/index.ts @@ -0,0 +1,155 @@ +import Debug from 'debug' +import commander from 'commander' + +import { CypressInstanceError, resolveInstance } from '../../cypress-instances' +import { withTapSession } from '../tap-session' +import { resolveAutFrame, FrameCommandError } from '../aut-frame' +import { extractDom, DEFAULT_MAX_CHARS } from './dom' +import { extractAria, DEFAULT_MAX_NODES } from './aria' +import { extractInspect } from './inspect' +import { renderResult, renderFailure, renderKnownFailure, renderFrameHelp } from '../output' + +const debug = Debug('cypress:cli:tap') + +// The frame commands (`dom`, `aria`, `inspect`) are CLI-native: the extractors +// run CDP domains the in-page binding cannot reach, so they are parsed and +// dispatched here rather than through the schema-driven binding program. +export const FRAME_COMMAND_NAMES = ['dom', 'aria', 'inspect'] as const + +interface FrameOptions { + instance?: number +} + +interface ParsedFrame { + sub: 'dom' | 'aria' | 'inspect' + selector?: string + maxChars?: string + maxNodes?: string +} + +const parsePositiveInt = (raw: string | undefined, fallback: number, label: string): number => { + if (raw === undefined) { + return fallback + } + + const value = Number(raw) + + if (!Number.isInteger(value) || value <= 0) { + throw new FrameCommandError('INVALID_LIMIT', `${label} must be a positive integer`) + } + + return value +} + +const buildFrameProgram = (capture: (parsed: ParsedFrame) => void): commander.Command => { + const program = new commander.Command('cypress tap') + + program.exitOverride() + program.addHelpCommand(false) + program.description('Inspect the app-under-test frame over CDP') + + program + .command('dom [selector]') + .description('read the app-under-test DOM: the page HTML, or just the elements matching a selector') + .option('--max-chars ', 'cap on returned HTML characters (default 30000)') + .action((selector, opts) => { + capture({ sub: 'dom', selector, maxChars: opts.maxChars }) + }) + + program + .command('aria [selector]') + .description('read the accessibility (ARIA) tree of the app-under-test frame, or the subtree at a selector') + .option('--max-nodes ', 'cap on the number of accessibility nodes returned (default 200)') + .action((selector, opts) => { + capture({ sub: 'aria', selector, maxNodes: opts.maxNodes }) + }) + + program + .command('inspect ') + .description('inspect one element: its tag, attributes, computed styles, box model, and accessibility node') + .action((selector) => { + capture({ sub: 'inspect', selector }) + }) + + return program +} + +export const runFrame = async (operands: string[], options: FrameOptions, wantsHelp: boolean): Promise => { + debug('tap frame command %o', operands) + + let parsed: ParsedFrame | undefined + const program = buildFrameProgram((value) => { + parsed = value + }) + + if (wantsHelp) { + // The commands are top-level, so help renders the one being asked about. + const subcommand = program.commands.find((command) => command.name() === operands[0]) + + renderFrameHelp(subcommand ?? program) + + return 0 + } + + try { + program.parse(operands, { from: 'user' }) + } catch (err: any) { + if (err instanceof commander.CommanderError) { + return 1 + } + + throw err + } + + if (!parsed) { + renderFrameHelp(program) + + return 1 + } + + try { + const selection = await resolveInstance({ instance: options.instance, cwd: process.cwd() }) + + return await withTapSession(selection.instance, async (session) => { + const frame = await resolveAutFrame(session.client, session.sessionId) + + try { + let result: unknown + + if (parsed!.sub === 'aria') { + result = await extractAria(session, frame, parsed!.selector, parsePositiveInt(parsed!.maxNodes, DEFAULT_MAX_NODES, 'max-nodes')) + } else if (parsed!.sub === 'inspect') { + result = await extractInspect(session, frame, parsed!.selector!) + } else { + result = await extractDom(session, frame, parsed!.selector, parsePositiveInt(parsed!.maxChars, DEFAULT_MAX_CHARS, 'max-chars')) + } + + renderResult(result) + + return 0 + } catch (err: any) { + if (err instanceof FrameCommandError) { + renderFailure({ code: err.code, message: err.message }) + + return 1 + } + + throw err + } + }) + } catch (err: any) { + if (err instanceof CypressInstanceError) { + renderFailure(err) + + return 1 + } + + if (err.known && err.details) { + renderKnownFailure(err) + + return 1 + } + + throw err + } +} diff --git a/cli/lib/tap/frame/inspect.ts b/cli/lib/tap/frame/inspect.ts new file mode 100644 index 00000000000..d7fd6475f85 --- /dev/null +++ b/cli/lib/tap/frame/inspect.ts @@ -0,0 +1,166 @@ +import type { TapSession } from '../tap-session' +import type { AutFrame } from '../aut-frame' +import { FrameCommandError } from '../aut-frame' + +// A full computed style is ~350 properties; this curated set answers the +// "why does it look/behave this way" questions (layout, visibility, box). +const REPORTED_STYLES = [ + 'display', 'visibility', 'opacity', 'position', 'top', 'right', 'bottom', 'left', + 'width', 'height', 'margin', 'padding', 'border', 'box-sizing', + 'color', 'background-color', 'font-size', 'font-weight', 'line-height', 'text-align', + 'z-index', 'overflow', 'pointer-events', 'cursor', +] + +// Read the element's tag, attributes, curated computed styles, and box rect in +// one isolated-world call on the element itself — avoiding the DOM/CSS node-id +// dance (requestNode needs a fetched document tree and is brittle across +// worlds). The accessibility node still comes from CDP (below). +const ELEMENT_INFO_FN = `function () { + var computed = getComputedStyle(this) + var styles = {} + var props = ${JSON.stringify(REPORTED_STYLES)} + for (var i = 0; i < props.length; i++) { + var value = computed.getPropertyValue(props[i]) + if (value) styles[props[i]] = value + } + var attributes = {} + for (var j = 0; j < this.attributes.length; j++) { + attributes[this.attributes[j].name] = this.attributes[j].value + } + var rect = this.getBoundingClientRect() + return { + tag: this.tagName.toLowerCase(), + attributes: attributes, + styles: styles, + box: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, + } +}` + +const REPORTED_STATES = new Set(['focused', 'disabled', 'required', 'invalid', 'checked', 'expanded', 'selected', 'pressed', 'readonly', 'hidden']) + +interface AXValue { value?: unknown } +interface AXNode { + role?: AXValue + name?: AXValue + properties?: Array<{ name: string, value?: AXValue }> + ignored?: boolean + backendDOMNodeId?: number +} + +export interface FrameInspectResult { + url?: string + selector: string + found: boolean + tag?: string + attributes?: Record + aria?: { role?: string, name?: string, states?: string[] } + box?: { x: number, y: number, width: number, height: number } + styles?: Record +} + +interface ElementInfo { + tag: string + attributes: Record + styles: Record + box: { x: number, y: number, width: number, height: number } +} + +const projectAria = (node: AXNode | undefined): FrameInspectResult['aria'] => { + if (!node || node.ignored) { + return undefined + } + + const aria: NonNullable = {} + const role = node.role?.value + + if (typeof role === 'string') { + aria.role = role + } + + const name = node.name?.value + + if (typeof name === 'string' && name.length > 0) { + aria.name = name + } + + const states = (node.properties ?? []) + .filter((property) => REPORTED_STATES.has(property.name) && property.value?.value === true) + .map((property) => property.name) + + if (states.length) { + aria.states = states + } + + return Object.keys(aria).length ? aria : undefined +} + +const readAriaNode = async (session: TapSession, objectId: string): Promise => { + const { client, sessionId } = session + + try { + const { node } = await client.DOM.describeNode({ objectId }, sessionId) + const { nodes } = await client.Accessibility.getPartialAXTree({ backendNodeId: node.backendNodeId, fetchRelatives: false }, sessionId) + const axNodes = nodes as AXNode[] + + return projectAria(axNodes.find((candidate) => candidate.backendDOMNodeId === node.backendNodeId) ?? axNodes[0]) + } catch { + return undefined + } +} + +export const extractInspect = async ( + session: TapSession, + frame: AutFrame, + selector: string, +): Promise => { + const { client, sessionId } = session + + await client.DOM.enable({}, sessionId) + await client.Accessibility.enable(sessionId) + + const { executionContextId } = await client.Page.createIsolatedWorld({ + frameId: frame.frameId, + worldName: 'cypress-tap', + }, sessionId) + + const query = await client.Runtime.callFunctionOn({ + functionDeclaration: 'function (selector) { return document.querySelector(selector) }', + executionContextId, + arguments: [{ value: selector }], + }, sessionId) + + if (query.exceptionDetails) { + throw new FrameCommandError('INVALID_SELECTOR', `"${selector}" is not a valid CSS selector`) + } + + const base: FrameInspectResult = { ...(frame.url ? { url: frame.url } : {}), selector, found: false } + + if (!query.result.objectId || query.result.subtype === 'null') { + return base + } + + const objectId = query.result.objectId + + const info = await client.Runtime.callFunctionOn({ + functionDeclaration: ELEMENT_INFO_FN, + objectId, + returnByValue: true, + }, sessionId) + + if (info.exceptionDetails) { + throw new FrameCommandError('FRAME_READ_FAILED', `inspecting the element failed: ${info.exceptionDetails.exception?.description || info.exceptionDetails.text}`) + } + + const { tag, attributes, styles, box } = info.result.value as ElementInfo + const aria = await readAriaNode(session, objectId) + + return { + ...base, + found: true, + tag, + attributes, + ...(aria ? { aria } : {}), + box, + styles, + } +} diff --git a/cli/lib/tap/output.ts b/cli/lib/tap/output.ts index bed7f7eaebf..c947e37ba10 100644 --- a/cli/lib/tap/output.ts +++ b/cli/lib/tap/output.ts @@ -23,6 +23,9 @@ Interacts with a running Cypress instance over its tap binding. Commands: instances list the running Cypress instances this CLI can reach status report where a running Cypress instance is in its lifecycle + dom read the app-under-test DOM over CDP, whole-page or by selector + aria read the accessibility (ARIA) tree of the app-under-test frame + inspect inspect one element: attributes, styles, box model, ARIA node Other commands are discovered from the running Cypress instance — start Cypress (e.g. \`cypress open\`), then run \`cypress tap\` to see them. @@ -59,6 +62,10 @@ export const renderStatusHelp = (): void => { logger.always(STATUS_USAGE) } +export const renderFrameHelp = (program: commander.Command): void => { + logger.always(program.helpInformation()) +} + const unknownCommandMessage = (schema: TapSchema, command: string): string => { return `"${command}" is not a command of this Cypress (v${schema.cypressVersion}). Available commands: ${schema.commands.filter(({ hidden }) => !hidden).map(({ name }) => name).join(', ')}.` } diff --git a/cli/lib/tap/status.ts b/cli/lib/tap/status.ts index 7bf76cd1d18..0254ad5465c 100644 --- a/cli/lib/tap/status.ts +++ b/cli/lib/tap/status.ts @@ -6,12 +6,18 @@ import { TAP_EXEC_METHOD } from '@packages/cypress-instances' import { errors } from '../errors' import type { TapCliOptions } from '../exec/tap' +interface PinnedRef { + command: string + at: { index: number, name?: string } +} + interface TapRunState { spec: string | null totalSpecs: number state?: 'running' | 'passed' | 'failed' totalTests?: number results?: { passed: number, failed: number, pending: number, skipped: number } + pinned?: PinnedRef } interface TapStatus { @@ -24,11 +30,14 @@ interface TapStatus { spec?: string totalTests?: number results?: { passed: number, failed: number, pending: number, skipped: number } + pinned?: PinnedRef } const mergeRunState = (base: TapStatus, runState: TapRunState): TapStatus => { + const pinned = runState.pinned ? { pinned: runState.pinned } : {} + if (runState.state === undefined) { - return { ...base, status: 'spec not selected', totalSpecs: runState.totalSpecs } + return { ...base, status: 'spec not selected', totalSpecs: runState.totalSpecs, ...pinned } } return { @@ -38,6 +47,7 @@ const mergeRunState = (base: TapStatus, runState: TapRunState): TapStatus => { ...(runState.spec !== null ? { spec: runState.spec } : {}), totalTests: runState.totalTests, results: runState.results, + ...pinned, } } diff --git a/cli/lib/tap/tap-session.ts b/cli/lib/tap/tap-session.ts index 203f3ff30b2..0d98af661b6 100644 --- a/cli/lib/tap/tap-session.ts +++ b/cli/lib/tap/tap-session.ts @@ -35,6 +35,13 @@ export const throwTapError = (details: { description: string, solution: string } export interface TapSession { call (method: string, args?: unknown[]): Promise + // Raw CDP access for the frame extractors (dom/aria/inspect), which run + // protocol domains + // (Page/DOM/CSS/Accessibility) against the runner page and its AUT child + // frame rather than routing through the binding. `sessionId` is a getter so + // it stays correct across the re-attach `call` performs on a lost session. + readonly client: CRI.Client + readonly sessionId: string } // Shape-checks the `exec` envelope; anything else is a transport failure, not a @@ -266,7 +273,15 @@ export const withTapSession = async ( return response.result.value } - return await fn({ call }) + const session: TapSession = { + call, + client, + get sessionId () { + return sessionId + }, + } + + return await fn(session) } finally { await client.close().catch(() => {}) } diff --git a/cli/test/lib/exec/tap.spec.ts b/cli/test/lib/exec/tap.spec.ts index c67e2ca975f..7389928d988 100644 --- a/cli/test/lib/exec/tap.spec.ts +++ b/cli/test/lib/exec/tap.spec.ts @@ -4,6 +4,7 @@ import logger from '../../../lib/logger' import { CypressInstanceError, listLiveInstances, resolveLiveInstance, resolveInstance } from '../../../lib/cypress-instances' import type { LiveInstanceSelection, LiveInstanceState, ReadyInstanceState, InstanceSelection } from '../../../lib/cypress-instances' import { withTapSession } from '../../../lib/tap/tap-session' +import type { TapSession } from '../../../lib/tap/tap-session' import type { TapExecResult, TapSchema } from '@packages/cypress-instances' import { errors } from '../../../lib/errors' import tap from '../../../lib/exec/tap' @@ -61,7 +62,10 @@ const mockSession = (sessionSchema: unknown = schema, execOutcome: unknown = { r return method === 'getSchema' ? sessionSchema : execOutcome }) - vi.mocked(withTapSession).mockImplementation(async (_runner, fn) => fn({ call })) + // These tests drive the binding exec/status paths, which use only `call`; + // the frame extractors (dom/aria/inspect, which use client/sessionId) are + // covered separately, so the session's CDP members are stubbed away here. + vi.mocked(withTapSession).mockImplementation(async (_runner, fn) => fn({ call } as unknown as TapSession)) return call } diff --git a/cli/test/lib/tap/build-program.spec.ts b/cli/test/lib/tap/build-program.spec.ts index 56c329b160b..40e1f0029ab 100644 --- a/cli/test/lib/tap/build-program.spec.ts +++ b/cli/test/lib/tap/build-program.spec.ts @@ -54,10 +54,10 @@ describe('lib/tap/build-program', () => { vi.spyOn(console, 'error').mockImplementation(() => {}) }) - it('registers the CLI-native `instances` and `status` commands first, then one subcommand per advertised command', () => { + it('registers the CLI-native commands first, then one subcommand per advertised command', () => { const program = buildTapProgram(schema, vi.fn()) - expect(program.commands.map((command) => command.name())).toEqual(['instances', 'status', 'health', 'run', 'open']) + expect(program.commands.map((command) => command.name())).toEqual(['instances', 'status', 'dom', 'aria', 'inspect', 'health', 'run', 'open']) }) it('omits commands flagged hidden from the program (still exec-able, just not advertised)', () => { diff --git a/cli/test/lib/tap/frame.spec.ts b/cli/test/lib/tap/frame.spec.ts new file mode 100644 index 00000000000..05b73074870 --- /dev/null +++ b/cli/test/lib/tap/frame.spec.ts @@ -0,0 +1,312 @@ +import { describe, expect, it, vi } from 'vitest' + +import { resolveAutFrame, FrameCommandError } from '../../../lib/tap/aut-frame' +import { extractDom } from '../../../lib/tap/frame/dom' +import { extractAria } from '../../../lib/tap/frame/aria' +import { extractInspect } from '../../../lib/tap/frame/inspect' +import type { TapSession } from '../../../lib/tap/tap-session' + +const SESSION_ID = 'S1' + +// A frame tree matching the real runner page: the AUT child frame plus the +// snapshot double-buffers and the spec bridge, which the resolver must ignore. +const frameTree = (autUrl = 'http://localhost:5555/index.html') => ({ + frame: { id: 'top', name: '', url: 'http://localhost:5555/__/' }, + childFrames: [ + { frame: { id: 'aut-frame-id', name: 'Your project: \'Test Project\'', url: autUrl } }, + { frame: { id: 'snap-0', name: 'AUT Snapshot - 0: \'Test Project\'', url: 'about:blank' } }, + { frame: { id: 'spec', name: 'Your Spec: \'/…/login.cy.js\'', url: 'http://localhost:5555/__cypress/iframes/x' } }, + ], +}) + +const makePageClient = (tree: ReturnType) => { + return { + Page: { + enable: vi.fn().mockResolvedValue(undefined), + getFrameTree: vi.fn().mockResolvedValue({ frameTree: tree }), + }, + } +} + +describe('lib/tap/aut-frame resolveAutFrame', () => { + it('resolves the AUT child frame by its name prefix, ignoring snapshot buffers and the spec bridge', async () => { + const client = makePageClient(frameTree()) + + const frame = await resolveAutFrame(client as any, SESSION_ID) + + expect(frame).to.deep.eq({ frameId: 'aut-frame-id', url: 'http://localhost:5555/index.html' }) + // getFrameTree takes no params — CRI routes the session id by type. + expect(client.Page.getFrameTree).toHaveBeenCalledWith(SESSION_ID) + }) + + it('throws NO_AUT_FRAME when no frame matches (no spec loaded yet)', async () => { + const client = makePageClient({ + frame: { id: 'top', name: '', url: 'http://localhost:5555/__/' }, + childFrames: [{ frame: { id: 'snap-0', name: 'AUT Snapshot - 0: \'x\'', url: 'about:blank' } }], + }) + + await expect(resolveAutFrame(client as any, SESSION_ID)).rejects.toMatchObject({ + name: 'FrameCommandError', + code: 'NO_AUT_FRAME', + }) + }) +}) + +describe('lib/tap/frame/dom extractDom', () => { + const makeSession = (fnValue: unknown, exceptionDetails?: unknown) => { + const createIsolatedWorld = vi.fn().mockResolvedValue({ executionContextId: 42 }) + const callFunctionOn = vi.fn().mockResolvedValue({ result: { value: fnValue }, exceptionDetails }) + + const session = { + call: vi.fn(), + client: { Page: { createIsolatedWorld }, Runtime: { callFunctionOn } }, + sessionId: SESSION_ID, + } as unknown as TapSession + + return { session, createIsolatedWorld, callFunctionOn } + } + + const frame = { frameId: 'aut-frame-id', url: 'http://localhost:5555/index.html' } + + it('returns the whole-document HTML with the frame url when no selector is given', async () => { + const { session, createIsolatedWorld, callFunctionOn } = makeSession({ html: 'hi' }) + + const result = await extractDom(session, frame, undefined, 30000) + + expect(result).to.deep.eq({ url: 'http://localhost:5555/index.html', html: 'hi' }) + // Isolated world is created for the resolved AUT frame, on its session. + expect(createIsolatedWorld).toHaveBeenCalledWith({ frameId: 'aut-frame-id', worldName: 'cypress-tap' }, SESSION_ID) + // selector (null when absent) and maxChars are forwarded as call arguments. + expect(callFunctionOn.mock.calls[0][0]).toMatchObject({ + executionContextId: 42, + arguments: [{ value: null }, { value: 30000 }], + returnByValue: true, + }) + + expect(callFunctionOn.mock.calls[0][1]).to.eq(SESSION_ID) + }) + + it('returns matches (and forwards the selector) in selector mode', async () => { + const { session, callFunctionOn } = makeSession({ matches: { count: 2, html: ['', ''] }, truncated: false }) + + const result = await extractDom(session, frame, '.x', 100) + + expect(result.matches).to.deep.eq({ count: 2, html: ['', ''] }) + expect(result.html).to.be.undefined + expect(callFunctionOn.mock.calls[0][0].arguments).to.deep.eq([{ value: '.x' }, { value: 100 }]) + }) + + it('reports truncation from the browser-side cap', async () => { + const { session } = makeSession({ html: ' { + const { session } = makeSession({ invalidSelector: true }) + + await expect(extractDom(session, frame, '>>bad', 100)).rejects.toMatchObject({ + name: 'FrameCommandError', + code: 'INVALID_SELECTOR', + }) + }) + + it('maps a CDP evaluation exception to FRAME_READ_FAILED', async () => { + const { session } = makeSession(undefined, { text: 'boom', exception: { description: 'boom' } }) + + await expect(extractDom(session, frame, undefined, 100)).rejects.toBeInstanceOf(FrameCommandError) + await expect(extractDom(session, frame, undefined, 100)).rejects.toMatchObject({ code: 'FRAME_READ_FAILED' }) + }) +}) + +describe('lib/tap/frame/aria extractAria', () => { + const ax = (nodeId: string, role: string, extra: Record = {}) => ({ + nodeId, + role: { value: role }, + ...extra, + }) + + // RootWebArea → (generic, noise) → heading + a disabled textbox with a value. + const tree = [ + ax('1', 'RootWebArea', { name: { value: 'Login' }, childIds: ['2'] }), + ax('2', 'generic', { parentId: '1', childIds: ['3', '4'] }), + ax('3', 'heading', { parentId: '2', name: { value: 'Sign in' } }), + ax('4', 'textbox', { + parentId: '2', + name: { value: 'Username' }, + value: { value: 'ada' }, + backendDOMNodeId: 99, + properties: [{ name: 'disabled', value: { value: true } }, { name: 'focusable', value: { value: true } }], + }), + ] + + const makeAxSession = (opts: { axNodes?: unknown[], selectorObjectId?: string | undefined, selectorSubtype?: string, backendNodeId?: number, throwOnEval?: boolean } = {}) => { + const callFunctionOn = vi.fn().mockImplementation(async () => { + if (opts.throwOnEval) { + return { exceptionDetails: { text: 'bad selector' } } + } + + return { result: { objectId: opts.selectorObjectId, subtype: opts.selectorSubtype } } + }) + + const client = { + DOM: { + enable: vi.fn().mockResolvedValue(undefined), + describeNode: vi.fn().mockResolvedValue({ node: { backendNodeId: opts.backendNodeId } }), + }, + Accessibility: { + enable: vi.fn().mockResolvedValue(undefined), + getFullAXTree: vi.fn().mockResolvedValue({ nodes: opts.axNodes ?? tree }), + }, + Page: { createIsolatedWorld: vi.fn().mockResolvedValue({ executionContextId: 7 }) }, + Runtime: { callFunctionOn }, + } + + return { session: { call: vi.fn(), client, sessionId: SESSION_ID } as unknown as TapSession, client } + } + + const frame = { frameId: 'aut-frame-id', url: 'http://localhost:5555/index.html' } + + it('projects the whole tree, collapsing noise roles and reporting states/value', async () => { + const { session } = makeAxSession() + + const result = await extractAria(session, frame, undefined, 200) + + expect(result.url).to.eq('http://localhost:5555/index.html') + expect(result.nodeCount).to.eq(3) + // The generic node is dropped; the heading and textbox promote under the root. + expect(result.nodes).to.deep.eq([ + { depth: 0, role: 'RootWebArea', name: 'Login' }, + { depth: 1, role: 'heading', name: 'Sign in' }, + { depth: 1, role: 'textbox', name: 'Username', value: 'ada', states: ['disabled'] }, + ]) + }) + + it('roots the tree at the selector match via its backend node id', async () => { + const { session } = makeAxSession({ selectorObjectId: 'obj-1', backendNodeId: 99 }) + + const result = await extractAria(session, frame, '[data-testid=username]', 200) + + expect(result.nodes).to.deep.eq([ + { depth: 0, role: 'textbox', name: 'Username', value: 'ada', states: ['disabled'] }, + ]) + }) + + it('returns an empty tree when the selector matches nothing', async () => { + const { session } = makeAxSession({ selectorObjectId: undefined, selectorSubtype: 'null' }) + + const result = await extractAria(session, frame, '.missing', 200) + + expect(result).to.deep.eq({ url: 'http://localhost:5555/index.html', nodes: [], nodeCount: 0 }) + }) + + it('maps a bad selector to INVALID_SELECTOR', async () => { + const { session } = makeAxSession({ throwOnEval: true }) + + await expect(extractAria(session, frame, '>>bad', 200)).rejects.toMatchObject({ code: 'INVALID_SELECTOR' }) + }) + + it('caps the tree at max-nodes and flags truncation', async () => { + const { session } = makeAxSession() + + const result = await extractAria(session, frame, undefined, 2) + + expect(result.nodeCount).to.eq(2) + expect(result.truncated).to.eq(true) + }) +}) + +describe('lib/tap/frame/inspect extractInspect', () => { + const frame = { frameId: 'aut-frame-id', url: 'http://localhost:5555/index.html' } + + const ELEMENT_INFO = { + tag: 'input', + attributes: { 'data-testid': 'username-input', name: 'username' }, + styles: { display: 'block', color: 'rgb(0, 0, 0)', 'font-size': '16px' }, + box: { x: 8, y: 40, width: 200, height: 30 }, + } + + const makeInspectSession = (opts: { + selectorObjectId?: string + selectorSubtype?: string + throwOnEval?: boolean + elementInfo?: unknown + axThrows?: boolean + axNodes?: unknown[] + } = {}) => { + // callFunctionOn is used twice: first to querySelector (returns the element + // objectId), then to read the element's info by value. + const callFunctionOn = vi.fn() + .mockImplementationOnce(async () => { + if (opts.throwOnEval) { + return { exceptionDetails: { text: 'bad selector' } } + } + + return { result: { objectId: opts.selectorObjectId, subtype: opts.selectorSubtype } } + }) + .mockImplementationOnce(async () => ({ result: { value: opts.elementInfo ?? ELEMENT_INFO } })) + + const getPartialAXTree = vi.fn().mockImplementation(async () => { + if (opts.axThrows) { + throw new Error('no ax') + } + + return { nodes: opts.axNodes ?? [{ role: { value: 'textbox' }, name: { value: 'Username' }, backendDOMNodeId: 40, properties: [{ name: 'disabled', value: { value: true } }] }] } + }) + + const client = { + DOM: { + enable: vi.fn().mockResolvedValue(undefined), + describeNode: vi.fn().mockResolvedValue({ node: { backendNodeId: 40 } }), + }, + Accessibility: { + enable: vi.fn().mockResolvedValue(undefined), + getPartialAXTree, + }, + Page: { createIsolatedWorld: vi.fn().mockResolvedValue({ executionContextId: 7 }) }, + Runtime: { callFunctionOn }, + } + + return { session: { call: vi.fn(), client, sessionId: SESSION_ID } as unknown as TapSession } + } + + it('returns tag, attributes, curated styles, box, and the ax node for a match', async () => { + const { session } = makeInspectSession({ selectorObjectId: 'obj-1' }) + + const result = await extractInspect(session, frame, '[data-testid=username-input]') + + expect(result.found).to.eq(true) + expect(result.tag).to.eq('input') + expect(result.attributes).to.deep.eq({ 'data-testid': 'username-input', name: 'username' }) + expect(result.box).to.deep.eq({ x: 8, y: 40, width: 200, height: 30 }) + expect(result.styles).to.deep.eq({ display: 'block', color: 'rgb(0, 0, 0)', 'font-size': '16px' }) + expect(result.aria).to.deep.eq({ role: 'textbox', name: 'Username', states: ['disabled'] }) + }) + + it('returns found:false when the selector matches nothing', async () => { + const { session } = makeInspectSession({ selectorObjectId: undefined, selectorSubtype: 'null' }) + + const result = await extractInspect(session, frame, '.missing') + + expect(result).to.deep.eq({ url: 'http://localhost:5555/index.html', selector: '.missing', found: false }) + }) + + it('maps a bad selector to INVALID_SELECTOR', async () => { + const { session } = makeInspectSession({ throwOnEval: true }) + + await expect(extractInspect(session, frame, '>>bad')).rejects.toMatchObject({ code: 'INVALID_SELECTOR' }) + }) + + it('still returns the element when the accessibility node is unavailable', async () => { + const { session } = makeInspectSession({ selectorObjectId: 'obj-1', axThrows: true }) + + const result = await extractInspect(session, frame, '.plain') + + expect(result.found).to.eq(true) + expect(result.aria).to.be.undefined + expect(result.tag).to.eq('input') + }) +}) diff --git a/knip.json b/knip.json index 9734c39e2a9..24073cd498b 100644 --- a/knip.json +++ b/knip.json @@ -779,6 +779,15 @@ "exports", "types" ], + "cli/lib/tap/frame/aria.ts": [ + "types" + ], + "cli/lib/tap/frame/dom.ts": [ + "types" + ], + "cli/lib/tap/frame/inspect.ts": [ + "types" + ], "cli/lib/tap/tap-session.ts": [ "exports", "types" @@ -837,9 +846,15 @@ "packages/app/src/prompt/prompt-app-types.ts": [ "types" ], + "packages/app/src/tap/commands/pin.ts": [ + "types" + ], "packages/app/src/tap/commands/run-state.ts": [ "types" ], + "packages/app/src/tap/commands/snapshot-pin.ts": [ + "types" + ], "packages/app/src/tap/commands/test-state.ts": [ "types" ], diff --git a/packages/app/src/runner/event-manager.ts b/packages/app/src/runner/event-manager.ts index 1383088e415..56ca7507849 100644 --- a/packages/app/src/runner/event-manager.ts +++ b/packages/app/src/runner/event-manager.ts @@ -1036,6 +1036,14 @@ export class EventManager { this.reporterBus.emit('reporter:snapshot:unpinned') } + // Sync the reporter's command-log pin state when a pin originates outside the + // reporter (e.g. the tap CLI). The AUT render is already done via the native + // `pin:snapshot` path, so — unlike snapshotUnpinned — this only notifies the + // reporter; it does not touch the snapshot store. + snapshotPinned (testId: string, logId: number | string) { + this.reporterBus.emit('reporter:snapshot:pinned', testId, logId) + } + _unpinSnapshot () { this.localBus.emit('unpin:snapshot') } diff --git a/packages/app/src/tap/commands/index.ts b/packages/app/src/tap/commands/index.ts index d3e142f9435..ae58aa6ebea 100644 --- a/packages/app/src/tap/commands/index.ts +++ b/packages/app/src/tap/commands/index.ts @@ -1,5 +1,6 @@ import { commandsCommand } from './commands' import type { TapCommandDefinition } from './definition' +import { pinCommand } from './pin' import { runCommand } from './run' import { runStateCommand } from './run-state' import { specsCommand } from './specs' @@ -12,5 +13,6 @@ export const tapCommands = { run: runCommand, tests: testsCommand, commands: commandsCommand, + pin: pinCommand, 'run-state': runStateCommand, } satisfies Record diff --git a/packages/app/src/tap/commands/pin.cy.ts b/packages/app/src/tap/commands/pin.cy.ts new file mode 100644 index 00000000000..03e1843941b --- /dev/null +++ b/packages/app/src/tap/commands/pin.cy.ts @@ -0,0 +1,269 @@ +import { TapManager } from '../tap-manager' +import { resetPinState } from './pin' +import { tapPinSource } from './snapshot-pin' + +const CYPRESS_VERSION = '15.0.0' + +describe('tap/commands/pin', () => { + const TESTS_STATE = { + r2: { + id: 'r2', + title: 'signs in', + state: 'passed', + commands: [ + { id: 'log-1', name: 'get', message: '#status', state: 'passed', type: 'parent' }, + { id: 'log-2', name: 'click', message: '', state: 'passed', type: 'child' }, + ], + }, + } + + // The snapshot entries are opaque to the command — it only reads `name` and + // hands the object to the app pin — so plain objects stand in for the bodies. + const SNAPSHOTS = [{ name: 'before' }, { name: 'after' }] + const SNAPSHOT_PROPS = { url: 'http://localhost:8080/index.html', snapshots: SNAPSHOTS } + + const stubSource = (over: { runner?: unknown, running?: boolean } = {}) => { + const runner = 'runner' in over ? over.runner : { + getTestState: (id: string) => TESTS_STATE[id as keyof typeof TESTS_STATE], + getSnapshotPropsForLog: () => SNAPSHOT_PROPS, + } + const getRunner = cy.stub(tapPinSource, 'getRunner').returns(runner) + const detachDom = cy.stub().returns('ORIGINAL-DOM') + const restoreDom = cy.stub() + + cy.stub(tapPinSource, 'getAutIframe').returns({ detachDom, restoreDom }) + cy.stub(tapPinSource, 'isRunning').returns(over.running ?? false) + + const pinSnapshot = cy.stub(tapPinSource, 'pinSnapshot') + const changeSnapshotState = cy.stub(tapPinSource, 'changeSnapshotState') + const unpinSnapshot = cy.stub(tapPinSource, 'unpinSnapshot') + const stopListening = cy.stub() + const onUnpinned = cy.stub(tapPinSource, 'onUnpinned').returns(stopListening) + + return { getRunner, detachDom, restoreDom, pinSnapshot, changeSnapshotState, unpinSnapshot, onUnpinned, stopListening } + } + + beforeEach(() => { + resetPinState() + }) + + it('pins the last snapshot by default: captures the original, drives the app pin, listens for unpin', async () => { + const { detachDom, pinSnapshot, onUnpinned, restoreDom } = stubSource() + + const outcome = await new TapManager(CYPRESS_VERSION).exec('pin', { test: 'r2', command: 'log-1' }) + + expect(detachDom).to.have.been.calledOnce + // Default --at is the last snapshot (the command's final state), index 1. + // The test/command ids are forwarded so the reporter can reflect the pin. + expect(pinSnapshot).to.have.been.calledOnceWith({ url: SNAPSHOT_PROPS.url, snapshots: SNAPSHOTS }, 1, 'r2', 'log-1') + expect(onUnpinned).to.have.been.calledOnce + // The render goes through the app pin, so we never restore directly here. + expect(restoreDom).not.to.have.been.called + + expect(outcome).to.deep.eq({ + result: { + pinned: { test: 'r2', command: 'log-1', at: { index: 2, name: 'after' } }, + url: 'http://localhost:8080/index.html', + }, + }) + + expect(JSON.parse(JSON.stringify(outcome))).to.deep.eq(outcome) + }) + + it('pins a named snapshot via --at', async () => { + const { pinSnapshot } = stubSource() + + const outcome = await new TapManager(CYPRESS_VERSION).exec('pin', { test: 'r2', command: 'log-1' }, { at: 'before' }) + + expect(pinSnapshot).to.have.been.calledOnceWith({ url: SNAPSHOT_PROPS.url, snapshots: SNAPSHOTS }, 0, 'r2', 'log-1') + expect((outcome as { result: any }).result.pinned.at).to.deep.eq({ index: 1, name: 'before' }) + }) + + it('fails with SNAPSHOT_NOT_FOUND when --at matches nothing, without touching the iframe', async () => { + const { detachDom, pinSnapshot } = stubSource() + + const outcome = await new TapManager(CYPRESS_VERSION).exec('pin', { test: 'r2', command: 'log-1' }, { at: 'during' }) + + expect(outcome).to.deep.eq({ + error: { code: 'SNAPSHOT_NOT_FOUND', message: 'no snapshot of this command matches "during" — available snapshots: "before" (1), "after" (2)' }, + }) + + expect(detachDom).not.to.have.been.called + expect(pinSnapshot).not.to.have.been.called + }) + + it('refuses to pin a different command over an existing pin with ALREADY_PINNED', async () => { + stubSource() + + const manager = new TapManager(CYPRESS_VERSION) + + await manager.exec('pin', { test: 'r2', command: 'log-1' }) + const outcome = await manager.exec('pin', { test: 'r2', command: 'log-2' }) + + expect((outcome as { error: { code: string } }).error.code).to.eq('ALREADY_PINNED') + }) + + it('re-pinning the same command with a new --at moves the pin in place, without re-detaching', async () => { + const { detachDom, pinSnapshot, changeSnapshotState, onUnpinned } = stubSource() + + const manager = new TapManager(CYPRESS_VERSION) + + // First pin lands on the last snapshot (index 1, "after"). + await manager.exec('pin', { test: 'r2', command: 'log-1' }) + const moved = await manager.exec('pin', { test: 'r2', command: 'log-1' }, { at: 'before' }) + + // The move re-selects the snapshot in place: no second capture, no re-pin, + // no new unpin listener — only a state switch to "before" (index 0). + expect(detachDom).to.have.been.calledOnce + expect(pinSnapshot).to.have.been.calledOnce + expect(onUnpinned).to.have.been.calledOnce + expect(changeSnapshotState).to.have.been.calledOnceWith(0) + + expect((moved as { result: any }).result.pinned).to.deep.eq({ + test: 'r2', command: 'log-1', at: { index: 1, name: 'before' }, + }) + }) + + it('rejects a bad --at on a move and leaves the existing pin untouched', async () => { + const { changeSnapshotState } = stubSource() + + const manager = new TapManager(CYPRESS_VERSION) + + await manager.exec('pin', { test: 'r2', command: 'log-1' }) + const outcome = await manager.exec('pin', { test: 'r2', command: 'log-1' }, { at: 'during' }) + + expect((outcome as { error: { code: string } }).error.code).to.eq('SNAPSHOT_NOT_FOUND') + // The pin never moved, so no state switch happened. + expect(changeSnapshotState).not.to.have.been.called + }) + + it('clears a pin: restores the original DOM, unpins, and stops listening, then a fresh pin works', async () => { + const { restoreDom, unpinSnapshot, stopListening } = stubSource() + + const manager = new TapManager(CYPRESS_VERSION) + + await manager.exec('pin', { test: 'r2', command: 'log-1' }) + const cleared = await manager.exec('pin', {}, { clear: 'true' }) + + expect(restoreDom).to.have.been.calledOnceWith('ORIGINAL-DOM') + expect(unpinSnapshot).to.have.been.calledOnce + expect(stopListening).to.have.been.calledOnce + expect(cleared).to.deep.eq({ result: { cleared: true } }) + + // Pin state is released, so a new pin succeeds rather than ALREADY_PINNED. + const outcome = await manager.exec('pin', { test: 'r2', command: 'log-1' }) + + expect((outcome as { result: any }).result.pinned.command).to.eq('log-1') + }) + + it('restores the captured DOM and drops the pin when the runner unpins externally (the ✕)', async () => { + const { restoreDom, unpinSnapshot, onUnpinned } = stubSource() + + const manager = new TapManager(CYPRESS_VERSION) + + await manager.exec('pin', { test: 'r2', command: 'log-1' }) + + // Fire the handler the pin registered, as the runner's ✕ unpin would. + const onExternalUnpin = onUnpinned.firstCall.args[0] as () => void + + onExternalUnpin() + + expect(restoreDom).to.have.been.calledOnceWith('ORIGINAL-DOM') + // The store already reset itself on the external unpin — we must not unpin again. + expect(unpinSnapshot).not.to.have.been.called + + // The pin is released, so a fresh pin succeeds rather than ALREADY_PINNED. + const outcome = await manager.exec('pin', { test: 'r2', command: 'log-1' }) + + expect((outcome as { result: any }).result.pinned.command).to.eq('log-1') + }) + + it('treats --clear with nothing pinned as an idempotent no-op', async () => { + const { restoreDom, unpinSnapshot } = stubSource() + + const outcome = await new TapManager(CYPRESS_VERSION).exec('pin', {}, { clear: 'true' }) + + expect(outcome).to.deep.eq({ result: { cleared: false } }) + expect(restoreDom).not.to.have.been.called + expect(unpinSnapshot).not.to.have.been.called + }) + + it('auto-releases a stale pin when its command no longer resolves (spec switch / re-run), without restoring stale DOM', async () => { + const getSnapshotPropsForLog = cy.stub().returns(SNAPSHOT_PROPS) + + cy.stub(tapPinSource, 'getRunner').returns({ getTestState: (id: string) => TESTS_STATE[id as keyof typeof TESTS_STATE], getSnapshotPropsForLog }) + + const restoreDom = cy.stub() + + cy.stub(tapPinSource, 'getAutIframe').returns({ detachDom: cy.stub().returns('ORIGINAL-DOM'), restoreDom }) + cy.stub(tapPinSource, 'isRunning').returns(false) + + const pinSnapshot = cy.stub(tapPinSource, 'pinSnapshot') + + cy.stub(tapPinSource, 'unpinSnapshot') + cy.stub(tapPinSource, 'onUnpinned').returns(cy.stub()) + + const manager = new TapManager(CYPRESS_VERSION) + + await manager.exec('pin', { test: 'r2', command: 'log-1' }) + expect(pinSnapshot).to.have.been.calledOnce // the pin was rendered via the app pin + + // Simulate a re-run: the command id is reused, but its snapshots are fresh + // objects — so identity no longer matches the object we pinned. + getSnapshotPropsForLog.returns({ url: SNAPSHOT_PROPS.url, snapshots: [{ name: 'before' }, { name: 'after' }] }) + + // Reconciliation drops the stale pin, so clear is a no-op that does NOT + // restore the now-stale original DOM. + const cleared = await manager.exec('pin', {}, { clear: 'true' }) + + expect(cleared).to.deep.eq({ result: { cleared: false } }) + expect(restoreDom).not.to.have.been.called // never a stale restore + }) + + it('requires a test and command (or --clear) before attempting a pin', async () => { + const { detachDom, pinSnapshot } = stubSource() + + const outcome = await new TapManager(CYPRESS_VERSION).exec('pin', { test: 'r2' }) + + expect((outcome as { error: { code: string } }).error.code).to.eq('PIN_TARGET_REQUIRED') + // A malformed pin never touches the iframe or drives the app pin. + expect(detachDom).not.to.have.been.called + expect(pinSnapshot).not.to.have.been.called + }) + + it('fails with NO_RUN when no runner has mounted', async () => { + stubSource({ runner: undefined }) + + const outcome = await new TapManager(CYPRESS_VERSION).exec('pin', { test: 'r2', command: 'log-1' }) + + expect((outcome as { error: { code: string } }).error.code).to.eq('NO_RUN') + }) + + it('refuses to pin while a spec is running', async () => { + stubSource({ running: true }) + + const outcome = await new TapManager(CYPRESS_VERSION).exec('pin', { test: 'r2', command: 'log-1' }) + + expect((outcome as { error: { code: string } }).error.code).to.eq('RUN_IN_PROGRESS') + }) + + it('fails with TEST_NOT_FOUND and COMMAND_NOT_FOUND for unknown ids', async () => { + stubSource() + + const manager = new TapManager(CYPRESS_VERSION) + + expect((await manager.exec('pin', { test: 'nope', command: 'log-1' }) as { error: { code: string } }).error.code).to.eq('TEST_NOT_FOUND') + expect((await manager.exec('pin', { test: 'r2', command: 'log-9' }) as { error: { code: string } }).error.code).to.eq('COMMAND_NOT_FOUND') + }) + + it('fails with SNAPSHOT_UNAVAILABLE when the command has no snapshot', async () => { + stubSource({ runner: { + getTestState: (id: string) => TESTS_STATE[id as keyof typeof TESTS_STATE], + getSnapshotPropsForLog: () => ({ snapshots: null }), + } }) + + const outcome = await new TapManager(CYPRESS_VERSION).exec('pin', { test: 'r2', command: 'log-1' }) + + expect((outcome as { error: { code: string } }).error.code).to.eq('SNAPSHOT_UNAVAILABLE') + }) +}) diff --git a/packages/app/src/tap/commands/pin.ts b/packages/app/src/tap/commands/pin.ts new file mode 100644 index 00000000000..f7f104b7da4 --- /dev/null +++ b/packages/app/src/tap/commands/pin.ts @@ -0,0 +1,267 @@ +import { defineCommand, TapCommandError } from './definition' +import { attemptSelectionError, selectTestAttempt, serializeTestCommands } from './test-state' +import { tapPinSource } from './snapshot-pin' +import type { PinSnapshotEntry, PinSnapshotProps } from './snapshot-pin' + +export interface SnapshotRef { + index: number + name?: string +} + +export interface PinResult { + pinned: { test: string, command: string, at: SnapshotRef } + url?: string +} + +export interface ClearResult { + cleared: boolean +} + +// The pin outlives a single tap call: `pin` mutates the live AUT, later `frame` +// commands read it, `pin --clear` restores. This module state (held in the +// running app between calls) remembers the pre-pin DOM so clear can put it back. +interface PinnedState { + test: string + command: string + at: SnapshotRef + original: unknown + // The exact snapshot object we pinned. A new run (spec switch or re-run) + // re-captures fresh snapshot objects — even when the command id is reused — + // so object identity is what reliably tells a live pin from a stale one. + snapshot: PinSnapshotEntry +} + +let pinned: PinnedState | undefined + +// Detaches the external-unpin listener wired while a pin is live. The runner's +// native pin exposes a ✕ that unpins through the app; that path can't restore +// our cold pin's DOM or clear our state, so we listen and do it ourselves. +let stopListeningForUnpin: (() => void) | undefined + +const releasePin = (): void => { + stopListeningForUnpin?.() + stopListeningForUnpin = undefined + pinned = undefined +} + +// Test-only: reset the module-level pin between component tests. +export const resetPinState = (): void => { + releasePin() +} + +// The runner's ✕ (or any app-side unpin) fired while we hold a pin: the store +// has already reset itself, so we only restore the DOM we captured and drop our +// state — never call unpinSnapshot here, or it would re-enter this handler. +const onExternalUnpin = (): void => { + if (!pinned) { + return + } + + const { original } = pinned + + releasePin() + tapPinSource.getAutIframe()?.restoreDom(original) +} + +// The current pin, for the run-state command to surface (so `status` can report +// a pin and a stranded one is always visible and recoverable). +export const getPinnedRef = (): { command: string, at: SnapshotRef } | undefined => { + return pinned ? { command: pinned.command, at: pinned.at } : undefined +} + +export interface PinReconcileRunner { + getSnapshotPropsForLog (testId: string, logId: string): PinSnapshotProps | undefined +} + +/** + * Drops a pin left over from a previous run — a spec switch or re-run — WITHOUT + * restoring its now-stale DOM (that run's page is gone). A pin is only valid + * while its command still resolves to a snapshot in the current run; a new run + * regenerates test and log ids, so the lookup misses and we release the pin. + * Safe to call anytime; a no-op when nothing is pinned. + */ +export const reconcilePin = (runner: PinReconcileRunner): void => { + if (!pinned) { + return + } + + // The pin is live only while the exact snapshot object we rendered is still + // the command's current snapshot. A re-run replaces it (same id, new object), + // and a spec switch drops the command entirely — both fail this identity check. + const stillLive = liveSnapshots(runner.getSnapshotPropsForLog(pinned.test, pinned.command)).includes(pinned.snapshot) + + if (!stillLive) { + // A new run already reset the snapshot store (run:start clears it) and its + // DOM is gone, so there is nothing to unpin or restore — just drop our own + // tracking and stop listening for that run's unpins. + releasePin() + } +} + +const liveSnapshots = (props: PinSnapshotProps | undefined): PinSnapshotEntry[] => { + return (props?.snapshots ?? []).filter((entry): entry is PinSnapshotEntry => Boolean(entry)) +} + +const toRef = (entry: PinSnapshotEntry, index: number): SnapshotRef => { + return { index: index + 1, ...(entry.name !== undefined ? { name: entry.name } : {}) } +} + +const resolveAt = (snapshots: PinSnapshotEntry[], at: string | undefined): number => { + if (at === undefined) { + return snapshots.length - 1 + } + + if (/^[0-9]+$/.test(at)) { + const index = Number(at) + + if (index >= 1 && index <= snapshots.length) { + return index - 1 + } + } else { + const index = snapshots.findIndex((entry) => entry.name === at) + + if (index !== -1) { + return index + } + } + + const available = snapshots.map((entry, index) => (entry.name !== undefined ? `"${entry.name}" (${index + 1})` : `${index + 1}`)).join(', ') + + throw new TapCommandError('SNAPSHOT_NOT_FOUND', `no snapshot of this command matches "${at}" — available snapshots: ${available}`) +} + +// Re-select which snapshot of the already-pinned command the runner shows, +// without a clear/re-pin round trip. Reached only when the target matches the +// live pin, so the DOM captured on the first pin and the unpin listener stay +// put and clear still restores correctly. Resolving `at` before switching means +// a bad `--at` leaves the current pin untouched. +const movePin = (runner: PinReconcileRunner, at: string | undefined): PinResult => { + const current = pinned! + const props = runner.getSnapshotPropsForLog(current.test, current.command) + const snapshots = liveSnapshots(props) + const index = resolveAt(snapshots, at) + + tapPinSource.changeSnapshotState(index) + + const at_ = toRef(snapshots[index], index) + + pinned = { ...current, at: at_, snapshot: snapshots[index] } + + return { + pinned: { test: current.test, command: current.command, at: at_ }, + ...(props?.url !== undefined ? { url: props.url } : {}), + } +} + +const clearPin = (): ClearResult => { + if (!pinned) { + return { cleared: false } + } + + const { original } = pinned + + // Stop listening before unpinning, or our own unpin would re-enter the + // external-unpin handler and restore twice. + releasePin() + tapPinSource.getAutIframe()?.restoreDom(original) + tapPinSource.unpinSnapshot() + + return { cleared: true } +} + +export const pinCommand = defineCommand({ + description: 'pin a command’s DOM snapshot into the live app-under-test frame so the dom/aria/inspect commands can read it; pass --clear to release', + params: [ + { name: 'test', type: 'string', required: false, description: 'test id, as listed by the tests command' }, + { name: 'command', type: 'string', required: false, description: 'command id, as listed by the commands command' }, + ], + options: [ + { name: 'at', type: 'string', required: false, description: 'which snapshot to pin: a name like "before"/"after" or a 1-based index; defaults to the last (the command’s final state). Re-run on the pinned command to switch snapshots without releasing the pin' }, + { name: 'clear', type: 'boolean', required: false, description: 'release the current pin and restore the app to its pre-pin state' }, + ], + handler: async ({ test, command }, { at, clear }): Promise => { + const runner = tapPinSource.getRunner() + + // Release a pin left over from a previous run before anything else, so stale + // state never blocks a new pin, is never reported by status, and is never + // restored over the current run's DOM. + if (runner) { + reconcilePin(runner) + } + + if (clear) { + return clearPin() + } + + if (test === undefined || command === undefined) { + throw new TapCommandError('PIN_TARGET_REQUIRED', 'provide a test id and command id to pin (as listed by the tests and commands commands), or pass --clear to release the current pin') + } + + if (!runner) { + throw new TapCommandError('NO_RUN', 'no spec has been run yet — use the run command to run a spec first') + } + + if (tapPinSource.isRunning()) { + throw new TapCommandError('RUN_IN_PROGRESS', 'a spec is currently running — wait for it to finish before pinning a snapshot') + } + + if (pinned) { + // Re-pinning the same command moves the pin to the requested snapshot in + // place; a different command must be released first so the single-pin + // invariant (one live pin, one captured DOM) holds. + if (pinned.test === test && pinned.command === command) { + return movePin(runner, at) + } + + throw new TapCommandError('ALREADY_PINNED', `command "${pinned.command}" is already pinned — release it with pin --clear before pinning another`) + } + + const selection = selectTestAttempt(runner, test) + + if ('error' in selection) { + throw attemptSelectionError(selection, test) + } + + const commands = serializeTestCommands(selection.attempt) + + if (!commands.some((entry) => entry.id === command)) { + throw new TapCommandError('COMMAND_NOT_FOUND', `no command of this test matches the id "${command}" — use the commands command to list this test’s commands`) + } + + const props = runner.getSnapshotPropsForLog(test, command) + const snapshots = liveSnapshots(props) + + if (snapshots.length === 0) { + throw new TapCommandError('SNAPSHOT_UNAVAILABLE', 'this command has no DOM snapshot to pin — snapshots are captured in open mode and kept only for the most recent tests (numTestsKeptInMemory)') + } + + const index = resolveAt(snapshots, at) + + const autIframe = tapPinSource.getAutIframe() + + if (!autIframe) { + throw new TapCommandError('NO_AUT', 'the app under test is not available to pin a snapshot into') + } + + // Capture the current DOM so we can restore it on release, then hand the + // chosen snapshot to the app's own pin so the runner renders it and shows + // the native banner/controls (synchronous for a same-origin AUT). Pass the + // filtered snapshots so the state toggle and our `index` stay aligned. + const original = autIframe.detachDom() + + tapPinSource.pinSnapshot({ ...props, snapshots }, index, test, command) + + // The native pin can be released from the runner's ✕; restore our captured + // DOM and drop our state when it is, so status never reports a phantom pin. + stopListeningForUnpin = tapPinSource.onUnpinned(onExternalUnpin) + + const at_ = toRef(snapshots[index], index) + + pinned = { test, command, at: at_, original, snapshot: snapshots[index] } + + return { + pinned: { test, command, at: at_ }, + ...(props?.url !== undefined ? { url: props.url } : {}), + } + }, +}) diff --git a/packages/app/src/tap/commands/run-state.ts b/packages/app/src/tap/commands/run-state.ts index 4d30c3f25f3..e0e113c3d91 100644 --- a/packages/app/src/tap/commands/run-state.ts +++ b/packages/app/src/tap/commands/run-state.ts @@ -1,6 +1,8 @@ import { useAutStore } from '../../store' import { tapManagerDataSource } from '../tap-manager-data-source' import { defineCommand } from './definition' +import { getPinnedRef, reconcilePin } from './pin' +import type { PinReconcileRunner } from './pin' import { aggregateResults } from './test-state' import type { RunResults } from './test-state' @@ -10,6 +12,8 @@ export interface RunStateResult { state?: 'running' | 'passed' | 'failed' totalTests?: number results?: RunResults + /** The currently pinned command's snapshot, if any (see the pin command). */ + pinned?: { command: string, at: { index: number, name?: string } } } export const tapRunStateSource = { @@ -39,9 +43,16 @@ export const runStateCommand = defineCommand({ const runner = tapManagerDataSource.getRunner() if (!runner) { - return { spec: null, totalSpecs } + const pinned = getPinnedRef() + + return { spec: null, totalSpecs, ...(pinned ? { pinned } : {}) } } + // Release a stale pin (from a previous run) so status never reports one that + // no longer exists. The runner is the driver's, which has this method. + reconcilePin(runner as unknown as PinReconcileRunner) + + const pinned = getPinnedRef() const { results, totalTests } = aggregateResults(runner) const state = tapRunStateSource.isRunning() ? 'running' : results.failed > 0 ? 'failed' : 'passed' @@ -51,6 +62,7 @@ export const runStateCommand = defineCommand({ state, totalTests, results, + ...(pinned ? { pinned } : {}), } }, }) diff --git a/packages/app/src/tap/commands/snapshot-pin.ts b/packages/app/src/tap/commands/snapshot-pin.ts new file mode 100644 index 00000000000..be4178c748e --- /dev/null +++ b/packages/app/src/tap/commands/snapshot-pin.ts @@ -0,0 +1,136 @@ +import { getAutIframeModel } from '../../runner' +import { useSnapshotStore } from '../../runner/snapshot-store' +import { useAutStore } from '../../store' +import type { SerializedTest } from '@packages/types' + +/** + * One snapshot on a command log, as `getSnapshotPropsForLog` exposes it: the + * cloned body sits behind an opaque object that `restoreDom` knows how to + * render. We read only the optional `name` (to select/label it) and otherwise + * treat the entry as opaque. + */ +export interface PinSnapshotEntry { + name?: string +} + +export interface PinSnapshotProps { + url?: string + snapshots?: Array | null +} + +export interface PinRunner { + getTestState (testId: string): SerializedTest | undefined + getSnapshotPropsForLog (testId: string, logId: string): PinSnapshotProps | undefined +} + +/** + * The slice of the AUT iframe the pin command drives directly. `detachDom` + * captures (and detaches) the current body so it can be put back on release — + * the reliable restore the app's own unpin can't give a cold pin (see below). + */ +export interface PinAutIframe { + detachDom (): unknown + restoreDom (snapshot: unknown): void +} + +// The runner-page event manager, reached through the app's own window binding — +// never `window.Cypress`, which is the outer driver in cypress-in-cypress. +const eventManager = () => { + try { + return window.getEventManager?.() + } catch { + return undefined + } +} + +/** + * Seam over the driver runner, the AUT iframe, the snapshot store, and the + * runner's own pin machinery the pin command drives. Component tests stub this + * object. + * + * The pin routes through the app's native pin (`pin:snapshot` → + * `iframe-model._pinSnapshot`) so the runner reflects it exactly like a + * user-clicked pin — the "Pinned" banner, the snapshot-state toggle, and the + * highlight controls — and the reporter is notified on release. We still + * capture and restore the pre-pin DOM ourselves via the AUT iframe: the app's + * unpin can only restore state captured during a hover, which a programmatic + * pin never performs. + */ +export const tapPinSource = { + getRunner (): PinRunner | undefined { + try { + return eventManager()?.getCypress()?.runner + } catch { + return undefined + } + }, + + getAutIframe (): PinAutIframe | undefined { + try { + return getAutIframeModel() as unknown as PinAutIframe + } catch { + return undefined + } + }, + + isRunning (): boolean { + try { + return useAutStore().isRunning + } catch { + return false + } + }, + + // Drive the app's own pin: `_pinSnapshot` renders `snapshots[0]` and sets the + // store (banner, controls, `isSnapshotPinned`). `--at` can pick any snapshot, + // so move to it afterwards — the app always starts on the first. Finally sync + // the reporter's command log, which a native (reporter-originated) pin sets + // itself but a programmatic pin must notify. + pinSnapshot (props: PinSnapshotProps, index: number, testId: string, logId: string): void { + // `iframe-model` listens for `pin:snapshot` on the localBus; emit there + // directly (the typed `EventManager.emit` overloads don't cover it). + eventManager()?.localBus.emit('pin:snapshot', props) + + // `_pinSnapshot` always renders the first snapshot, so only move off it. + if (index !== 0) { + tapPinSource.changeSnapshotState(index) + } + + eventManager()?.snapshotPinned(testId, logId) + }, + + // Switch which of the pinned command's snapshots the runner shows — the same + // state toggle the pinned banner drives. Lets `pin --at` re-select the state + // of an existing pin without a clear/re-pin round trip. + changeSnapshotState (index: number): void { + try { + useSnapshotStore().changeState(index, getAutIframeModel()) + } catch { + // No store/iframe in this context — nothing to switch. + } + }, + + // The app's canonical unpin: resets the snapshot store (clearing the banner + // and controls) and notifies the reporter. Does NOT restore a cold pin's DOM, + // so callers restore the captured body themselves via `restoreDom`. + unpinSnapshot (): void { + eventManager()?.snapshotUnpinned() + }, + + // Subscribe to the app's unpin so the runner's ✕ (or any other unpin) can + // trigger our DOM restore and drop our pin state. Returns a detacher; a no-op + // when there is no event manager to listen on. + onUnpinned (handler: () => void): () => void { + const em = eventManager() + + if (!em) { + return () => {} + } + + em.reporterBus.on('reporter:snapshot:unpinned', handler) + + return () => { + em.reporterBus.off('reporter:snapshot:unpinned', handler) + } + }, +} diff --git a/packages/reporter/src/lib/events.ts b/packages/reporter/src/lib/events.ts index f0f0b492586..e0cf9498920 100644 --- a/packages/reporter/src/lib/events.ts +++ b/packages/reporter/src/lib/events.ts @@ -134,6 +134,14 @@ const events: Events = { appState.pinnedSnapshotId = null })) + // A pin driven from outside the reporter (e.g. the tap CLI): reflect it the + // way a user click would — highlight the command and open its test so the + // command (and its pin icon) actually render in the log. + runner.on('reporter:snapshot:pinned', action('snapshot:pinned', (testId: string, logId: number | string) => { + appState.pinnedSnapshotId = logId + runnablesStore.testById(testId)?.setIsOpen(true) + })) + localBus.on('resume', action('resume', () => { appState.resume() statsStore.resume()