// detox-compatible expectations for sootsim // expect(element).toExist(), .toBeVisible(), .toHaveText(), etc. // waitFor(element).toExist().withTimeout(ms) import { dragScrollNode } from './gestures' import type { SootSimElement } from './element-types' import type { Matcher } from './matchers' import type { SootSimNodeInfo } from '@rnx/globals' import type { Page } from 'playwright' type FindFn = (matcher: Matcher) => Promise type GetPageFn = () => Page const DEFAULT_VISIBILITY_THRESHOLD = 0.75 // one scroll step on the container, reporting how far it actually travelled. // the caller ends the search when the container stops moving, which is how // detox ends a `whileElement(...).scroll()` on a device: the native scroll // throws once it reaches the end of the content. async function scrollMatcherElement( page: Page, findNode: FindFn, matcher: Matcher, pixels: number, direction: 'up' | 'down' | 'left' | 'right', startPositionX?: number, startPositionY?: number, ): Promise { const node = await findNode(matcher) if (!node) { throw new Error(`scroll container not found: ${JSON.stringify(matcher)}`) } const before = scrollOffsetOf(node) await dragScrollNode(page, node, pixels, direction, startPositionX, startPositionY) const after = scrollOffsetOf(await findNode(matcher)) return Math.abs(after.x - before.x) + Math.abs(after.y - before.y) } function scrollOffsetOf(node: any): { x: number; y: number } { return { x: node?.scrollOffsetX ?? 0, y: node?.scrollOffsetY ?? 0 } } // the visible frame is the largest uncovered rectangle used for tapping. // visibility includes every exposed region around overlays, not just that rectangle. function visibleFraction(node: SootSimNodeInfo | null | undefined): number { const totalArea = (node?.layout?.width ?? 0) * (node?.layout?.height ?? 0) const frame = node?.visibleFrame if (totalArea <= 0 || typeof frame?.area !== 'number') return 0 return frame.area / totalArea } export function isNodeVisible(node: SootSimNodeInfo | null | undefined): boolean { return visibleFraction(node) >= DEFAULT_VISIBILITY_THRESHOLD } // the engine tags each rendered node with its id, so DOM focus is the source of // truth for both the `expect` and `waitFor` forms of toBeFocused. async function isNodeFocused(node: any, page: Page): Promise { const nodeId = typeof node?.nodeId === 'number' ? node.nodeId : typeof node?.id === 'number' ? node.id : null if (nodeId === null) return false return page.evaluate((id) => { const active = document.activeElement return active?.getAttribute('data-sootsim-id') === String(id) }, nodeId) } export function describeNodeVisibility(node: SootSimNodeInfo | null | undefined): string { if (!node) return 'not found' const absX = node.absolutePosition?.x ?? node.layout?.x ?? 0 const absY = node.absolutePosition?.y ?? node.layout?.y ?? 0 return JSON.stringify({ layout: node.layout, absolutePosition: node.absolutePosition ?? null, frame: { x: absX, y: absY, right: absX + (node.layout?.width ?? 0), bottom: absY + (node.layout?.height ?? 0), }, visibleFrame: node.visibleFrame ?? null, visibleFraction: visibleFraction(node), threshold: DEFAULT_VISIBILITY_THRESHOLD, style: node.style ?? null, }) } // expect(element) returns an object with assertion methods export function createExpect(findNode: FindFn, getPage: GetPageFn) { return function sootsimExpect(el: SootSimElement): SootSimExpectation { return new SootSimExpectation(el._matcher, findNode, getPage, false) } } class SootSimExpectation { private matcher: Matcher private findNode: FindFn private getPage: GetPageFn private negated: boolean constructor(matcher: Matcher, findNode: FindFn, getPage: GetPageFn, negated: boolean) { this.matcher = matcher this.findNode = findNode this.getPage = getPage this.negated = negated } get not(): SootSimExpectation { return new SootSimExpectation( this.matcher, this.findNode, this.getPage, !this.negated, ) } async toExist(): Promise { const node = await this.findNode(this.matcher) const exists = node !== null && node !== undefined if (this.negated) { if (exists) { throw new Error( `expected element NOT to exist but it does: ${JSON.stringify(this.matcher)}`, ) } } else { if (!exists) { throw new Error( `expected element to exist but it was not found: ${JSON.stringify(this.matcher)}`, ) } } } async toBeVisible(): Promise { const node = await this.findNode(this.matcher) const visible = isNodeVisible(node) if (this.negated) { if (visible) { throw new Error( `expected element NOT to be visible: ${JSON.stringify(this.matcher)}`, ) } } else { if (!visible) { throw new Error( `expected element to be visible but it is ${describeNodeVisibility(node)}: ${JSON.stringify(this.matcher)}`, ) } } } async toBeNotVisible(): Promise { const node = await this.findNode(this.matcher) const visible = isNodeVisible(node) if (visible) { throw new Error( `expected element NOT to be visible: ${JSON.stringify(this.matcher)}`, ) } } async toHaveText(expectedText: string): Promise { const node = await this.findNode(this.matcher) if (!node) { throw new Error(`element not found for toHaveText: ${JSON.stringify(this.matcher)}`) } const actualText = node.text || '' const matches = actualText === expectedText if (this.negated) { if (matches) { throw new Error( `expected text NOT to be "${expectedText}" but got "${actualText}": ${JSON.stringify(this.matcher)}`, ) } } else { if (!matches) { throw new Error( `expected text "${expectedText}" but got "${actualText}": ${JSON.stringify(this.matcher)}`, ) } } } async toHaveId(expectedId: string): Promise { const node = await this.findNode(this.matcher) if (!node) { throw new Error(`element not found for toHaveId: ${JSON.stringify(this.matcher)}`) } const hasId = node.testID === expectedId || node.id === expectedId if (this.negated) { if (hasId) throw new Error(`expected element NOT to have id "${expectedId}"`) } else { if (!hasId) throw new Error( `expected element to have id "${expectedId}" but got "${node.testID || node.id}"`, ) } } async toHaveLabel(expectedLabel: string): Promise { const node = await this.findNode(this.matcher) if (!node) { throw new Error( `element not found for toHaveLabel: ${JSON.stringify(this.matcher)}`, ) } // check explicit accessibilityLabel, then derived text content const actual = node.accessibilityLabel || node.text || '' const matches = actual === expectedLabel if (this.negated) { if (matches) throw new Error(`expected label NOT to be "${expectedLabel}" but it was`) } else { if (!matches) throw new Error( `expected label "${expectedLabel}" but got "${actual}": ${JSON.stringify(this.matcher)}`, ) } } async toHaveAccessibilityRole(expectedRole: string): Promise { const node = await this.findNode(this.matcher) if (!node) { throw new Error( `element not found for toHaveAccessibilityRole: ${JSON.stringify(this.matcher)}`, ) } const actual = node.accessibilityRole || '' const matches = actual === expectedRole if (this.negated) { if (matches) throw new Error(`expected role NOT to be "${expectedRole}" but it was`) } else { if (!matches) throw new Error( `expected role "${expectedRole}" but got "${actual}": ${JSON.stringify(this.matcher)}`, ) } } async toBeEnabled(): Promise { const node = await this.findNode(this.matcher) if (!node) { throw new Error( `element not found for toBeEnabled: ${JSON.stringify(this.matcher)}`, ) } const disabled = node.accessibilityState?.disabled ?? false if (this.negated) { if (!disabled) throw new Error(`expected element to be disabled but it is enabled`) } else { if (disabled) throw new Error(`expected element to be enabled but it is disabled`) } } async toBeFocused(): Promise { const node = await this.findNode(this.matcher) if (!node) { throw new Error( `element not found for toBeFocused: ${JSON.stringify(this.matcher)}`, ) } const focused = await isNodeFocused(node, this.getPage()) if (this.negated) { if (focused) { throw new Error( `expected element NOT to be focused: ${JSON.stringify(this.matcher)}`, ) } } else if (!focused) { throw new Error(`expected element to be focused: ${JSON.stringify(this.matcher)}`) } } async toHaveValue(value: string): Promise { const node = await this.findNode(this.matcher) if (!node) { throw new Error( `element not found for toHaveValue: ${JSON.stringify(this.matcher)}`, ) } // value could be in text or props const actual = node.text || '' if (this.negated) { if (actual.includes(value)) { throw new Error( `expected element NOT to have value "${value}" but got "${actual}"`, ) } } else { if (!actual.includes(value)) { throw new Error(`expected element to have value "${value}" but got "${actual}"`) } } } async toHaveSliderPosition( normalizedPosition: number, tolerance?: number, ): Promise { // stub for slider tests -- sootsim doesn't have native sliders yet await this.toExist() } async toHaveToggleValue(value: boolean): Promise { await this.toExist() } } // waitFor(element) returns a chainable object that polls until the assertion passes export function createWaitFor(findNode: FindFn, getPage: GetPageFn) { return function sootsimWaitFor(el: SootSimElement): SootSimWaitForChain { return new SootSimWaitForChain(el._matcher, findNode, getPage) } } class SootSimWaitForChain { private matcher: Matcher private findNode: FindFn private getPage: GetPageFn private assertionFn: (() => Promise) | null = null private _negated = false constructor(matcher: Matcher, findNode: FindFn, getPage: GetPageFn) { this.matcher = matcher this.findNode = findNode this.getPage = getPage } get not(): SootSimWaitForChain { this._negated = true return this } toExist(): SootSimWaitForAction { this.assertionFn = async () => { const node = await this.findNode(this.matcher) const exists = node !== null && node !== undefined if (this._negated ? exists : !exists) { throw new Error(`waitFor toExist failed: ${JSON.stringify(this.matcher)}`) } } return new SootSimWaitForAction(this.assertionFn, this.findNode, this.getPage) } toBeVisible(): SootSimWaitForAction { this.assertionFn = async () => { const node = await this.findNode(this.matcher) const visible = isNodeVisible(node) if (this._negated ? visible : !visible) { throw new Error( `waitFor toBeVisible failed (${describeNodeVisibility(node)}): ${JSON.stringify(this.matcher)}`, ) } } return new SootSimWaitForAction(this.assertionFn, this.findNode, this.getPage) } toBeNotVisible(): SootSimWaitForAction { this.assertionFn = async () => { const node = await this.findNode(this.matcher) const visible = isNodeVisible(node) if (visible) { throw new Error(`waitFor toBeNotVisible failed: ${JSON.stringify(this.matcher)}`) } } return new SootSimWaitForAction(this.assertionFn, this.findNode, this.getPage) } toHaveText(expectedText: string): SootSimWaitForAction { this.assertionFn = async () => { const node = await this.findNode(this.matcher) if (!node) throw new Error( `waitFor toHaveText: element not found: ${JSON.stringify(this.matcher)}`, ) const actual = node.text || '' const matches = actual === expectedText if (this._negated ? matches : !matches) { throw new Error( `waitFor toHaveText: expected "${expectedText}" but got "${actual}" ${JSON.stringify(this.matcher)}`, ) } } return new SootSimWaitForAction(this.assertionFn, this.findNode, this.getPage) } toHaveValue(value: string): SootSimWaitForAction { this.assertionFn = async () => { const node = await this.findNode(this.matcher) if (!node) throw new Error(`waitFor toHaveValue: element not found`) const actual = node.text || '' if (!actual.includes(value)) { throw new Error(`waitFor toHaveValue: expected "${value}" in "${actual}"`) } } return new SootSimWaitForAction(this.assertionFn, this.findNode, this.getPage) } toBeFocused(): SootSimWaitForAction { this.assertionFn = async () => { const node = await this.findNode(this.matcher) if (!node) throw new Error( `waitFor toBeFocused: element not found: ${JSON.stringify(this.matcher)}`, ) const focused = await isNodeFocused(node, this.getPage()) if (this._negated ? focused : !focused) { throw new Error(`waitFor toBeFocused failed: ${JSON.stringify(this.matcher)}`) } } return new SootSimWaitForAction(this.assertionFn, this.findNode, this.getPage) } } class SootSimWaitForAction { private assertionFn: () => Promise private findNode: FindFn private getPage: GetPageFn constructor(assertionFn: () => Promise, findNode: FindFn, getPage: GetPageFn) { this.assertionFn = assertionFn this.findNode = findNode this.getPage = getPage } async withTimeout(ms: number): Promise { const start = Date.now() const pollInterval = 100 let lastError: Error | null = null while (Date.now() - start < ms) { try { await this.assertionFn() return // assertion passed } catch (e) { lastError = e as Error await new Promise((r) => setTimeout(r, pollInterval)) } } throw lastError || new Error(`waitFor timed out after ${ms}ms`) } // if no withTimeout is called, just run the assertion once async then(resolve: (v: void) => void, reject: (e: any) => void): Promise { try { // default 5 second timeout await this.withTimeout(5000) resolve(undefined) } catch (e) { reject(e) } } whileElement(matcher: Matcher): SootSimWaitForScrollAction { return new SootSimWaitForScrollAction( this.assertionFn, this.findNode, this.getPage, matcher, ) } } class SootSimWaitForScrollAction { private assertionFn: () => Promise private findNode: FindFn private getPage: GetPageFn private matcher: Matcher constructor( assertionFn: () => Promise, findNode: FindFn, getPage: GetPageFn, matcher: Matcher, ) { this.assertionFn = assertionFn this.findNode = findNode this.getPage = getPage this.matcher = matcher } // detox scrolls the container until the expectation passes or the container // can no longer move. bounding this by wall clock instead made the result // depend on how fast the host dispatches pointer events: the same fixture and // the same assertion pass on an idle machine and fail on a loaded one, // because each 200px drag is a real pointer stream and fewer of them fit in // the budget. end on scroll exhaustion, which is a property of the content. async scroll( pixels: number, direction: 'up' | 'down' | 'left' | 'right', startPositionX?: number, startPositionY?: number, ): Promise { const page = this.getPage() let lastError: Error | null = null let stalledSteps = 0 // two consecutive steps, so one settling frame that reports the same offset // does not read as the end of the content. while (stalledSteps < 2) { try { await this.assertionFn() return } catch (error) { lastError = error as Error } const travelled = await scrollMatcherElement( page, this.findNode, this.matcher, pixels, direction, startPositionX, startPositionY, ) stalledSteps = travelled < 0.5 ? stalledSteps + 1 : 0 } throw ( lastError || new Error( `waitFor whileElement(...).scroll() reached the end of ${JSON.stringify(this.matcher)}`, ) ) } }