diff --git a/packages/core/package.json b/packages/core/package.json index 041a8f7a4..88c6443c2 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -49,6 +49,8 @@ "@percy/logger": "1.31.11-beta.0", "@percy/monitoring": "1.31.11-beta.0", "@percy/webdriver-utils": "1.31.11-beta.0", + "@grpc/grpc-js": "^1.14.3", + "@grpc/proto-loader": "^0.8.0", "content-disposition": "^0.5.4", "cross-spawn": "^7.0.3", "extract-zip": "^2.0.1", diff --git a/packages/core/src/api.js b/packages/core/src/api.js index e910b4091..6223c551a 100644 --- a/packages/core/src/api.js +++ b/packages/core/src/api.js @@ -6,7 +6,7 @@ import { getPackageJSON, Server, percyAutomateRequestHandler, percyBuildEventHan import { ServerError } from './server.js'; import WebdriverUtils from '@percy/webdriver-utils'; import { handleSyncJob } from './snapshot.js'; -import { dump as adbDump, firstMatch as adbFirstMatch, SELECTOR_KEYS_WHITELIST } from './maestro-hierarchy.js'; +import { dump as adbDump, firstMatch as adbFirstMatch, SELECTOR_KEYS_WHITELIST, getSchemaDriftSeen as getMaestroHierarchyDrift } from './maestro-hierarchy.js'; import { PNG_MAGIC_BYTES, parsePngDimensions, isPortrait as isPortraitByAspect } from './png-dimensions.js'; import { resolveWdaSession } from './wda-session-resolver.js'; import { resolveIosRegions } from './wda-hierarchy.js'; @@ -90,20 +90,30 @@ export function createPercyServer(percy, port) { })); }) // healthcheck returns basic information - .route('get', '/percy/healthcheck', (req, res) => res.json(200, { - build: percy.testing?.build ?? percy.build, - loglevel: percy.loglevel(), - config: percy.config, - widths: { - // This is always needed even if width is passed - mobile: percy.deviceDetails ? percy.deviceDetails.map((d) => d.width) : [], - // This will only be used if width is not passed in options - config: percy.config.snapshot.widths - }, - deviceDetails: percy.deviceDetails || [], - success: true, - type: percy.client.tokenType() - })) + .route('get', '/percy/healthcheck', (req, res) => { + // Schema-drift dirty bit for the maestro view-hierarchy resolver. + // Set inside maestro-hierarchy.js on the first schema-class gRPC failure. + // Surfaced here (vs. only in the debug log) to close the silent-drift gap + // that bit PERCY_LABELS — see + // docs/solutions/integration-issues/percy-labels-cli-schema-rejection-2026-04-23.md. + const drift = getMaestroHierarchyDrift(); + const body = { + build: percy.testing?.build ?? percy.build, + loglevel: percy.loglevel(), + config: percy.config, + widths: { + // This is always needed even if width is passed + mobile: percy.deviceDetails ? percy.deviceDetails.map((d) => d.width) : [], + // This will only be used if width is not passed in options + config: percy.config.snapshot.widths + }, + deviceDetails: percy.deviceDetails || [], + success: true, + type: percy.client.tokenType() + }; + if (drift) body.maestroHierarchyDrift = drift; + return res.json(200, body); + }) // compute widths configuration with heights .route('get', '/percy/widths-config', (req, res) => { // Parse widths from query parameters (e.g., ?widths=375,1280) diff --git a/packages/core/src/maestro-hierarchy.js b/packages/core/src/maestro-hierarchy.js index ba332644f..5a0c1996d 100644 --- a/packages/core/src/maestro-hierarchy.js +++ b/packages/core/src/maestro-hierarchy.js @@ -25,8 +25,12 @@ // PERCY_IOS_DRIVER_HOST_PORT (iOS) — never accepts device addressing from user // input. Honors MAESTRO_BIN env var on both platforms. +import path from 'path'; +import url from 'url'; import spawn from 'cross-spawn'; import { XMLParser } from 'fast-xml-parser'; +import * as grpc from '@grpc/grpc-js'; +import * as protoLoader from '@grpc/proto-loader'; import logger from '@percy/logger'; const log = logger('core:maestro-hierarchy'); @@ -44,6 +48,200 @@ const BOUNDS_RE = /^\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]$/; const UNAVAILABLE_STDERR_RE = /no devices|unauthorized|device offline/i; const MAESTRO_UNAVAILABLE_STDERR_RE = /No connected devices|Device not found|Could not connect/i; +// gRPC tunables. Both deadlines are independent — see the plan's KTD section. +// Healthy-call deadline is the p50 budget; circuit breaker bounds the +// blast radius of grpc-node#2620 (~20s stuck CONNECTING) and #2285 +// (lying READY state after silent drop). Both are exposed for tests. +const GRPC_HEALTHY_DEADLINE_MS = 250; +const GRPC_CIRCUIT_BREAKER_MS = 2000; + +// Eager-load the maestro_android proto at module init so the ~15-40ms +// proto-parse cost is paid during CLI cold start, not on the first request +// path. The static-import chain percy.js → api.js → maestro-hierarchy.js +// is load-bearing; if any link converts to dynamic import() the parse cost +// lands on the first element-region screenshot per CLI process. +// +// Path resolution mirrors utils.js:553 (secretPatterns.yml) — works +// identically under src/ (dev) and dist/ (publish) because Babel CLI's +// copyFiles: true preserves the relative layout. +const protoFilePath = path.resolve( + url.fileURLToPath(import.meta.url), + '../proto/maestro_android.proto' +); +const protoPackageDef = protoLoader.loadSync(protoFilePath, { + keepCase: true, + longs: String, + enums: String, + defaults: true, + oneofs: true +}); +const MaestroDriverClient = grpc.loadPackageDefinition(protoPackageDef) + .maestro_android.MaestroDriver; + +// Module-scope client cache — single Client per (host, port). Eagerly +// closed + evicted on any connection-class failure (see runGrpcDump). +const grpcClientCache = new Map(); +// Healthcheck dirty bit. Set to a small object on first schema-class +// failure; read by api.js's /percy/healthcheck handler. Closes the silent +// schema-drift gap that bit PERCY_LABELS — see +// docs/solutions/integration-issues/percy-labels-cli-schema-rejection-2026-04-23.md. +let schemaDriftSeen = null; + +// Default factory: build a real gRPC client wrapping viewHierarchy in a +// promise so the resolver code can await it uniformly. Tests inject a +// factory that returns a stub with the same shape. +function defaultGrpcClientFactory(address) { + const inner = new MaestroDriverClient(address, grpc.credentials.createInsecure()); + return { + viewHierarchy: (req, options) => new Promise((resolve, reject) => { + inner.viewHierarchy(req, options || {}, (err, response) => { + if (err) reject(err); else resolve(response); + }); + }), + close: () => inner.close() + }; +} + +function getOrCreateGrpcClient(address, factory) { + let client = grpcClientCache.get(address); + if (!client) { + client = factory(address); + grpcClientCache.set(address, client); + } + return client; +} + +function evictGrpcClient(address) { + const client = grpcClientCache.get(address); + if (!client) return; + try { client.close(); } catch { /* swallow — already closed */ } + grpcClientCache.delete(address); +} + +// Schema-class status codes: error states that indicate a contract mismatch +// (request shape, response shape, or an unimplemented RPC). These bypass the +// maestro CLI fallback because retrying via a different transport would not +// fix the underlying schema problem. +// Codes 3, 9, 11, 12, 15 — see plan KTD decision table. +const GRPC_SCHEMA_CLASS_CODES = new Set([ + grpc.status.INVALID_ARGUMENT, + grpc.status.FAILED_PRECONDITION, + grpc.status.OUT_OF_RANGE, + grpc.status.UNIMPLEMENTED, + grpc.status.DATA_LOSS +]); + +function grpcStatusName(code) { + const entries = Object.entries(grpc.status); + for (const [name, value] of entries) { + if (value === code) return name.toLowerCase(); + } + return `code-${code}`; +} + +export function classifyGrpcFailure(err) { + if (!err) return null; + if (err.code === undefined) { + return { kind: 'dump-error', reason: 'grpc-decode' }; + } + const name = grpcStatusName(err.code); + if (GRPC_SCHEMA_CLASS_CODES.has(err.code)) { + return { kind: 'dump-error', reason: `grpc-schema-${name}` }; + } + return { kind: 'connection-fail', reason: `grpc-${name}` }; +} + +function recordSchemaDrift(code, reason) { + if (schemaDriftSeen) return; // first-seen wins + schemaDriftSeen = { + code, + reason, + firstSeenAt: new Date().toISOString() + }; +} + +export function getSchemaDriftSeen() { + return schemaDriftSeen; +} + +export async function runGrpcDump({ host, port, grpcClient = defaultGrpcClientFactory }) { + const address = `${host}:${port}`; + const client = getOrCreateGrpcClient(address, grpcClient); + const start = Date.now(); + + let breakerTimer; + const callPromise = client.viewHierarchy({}, { deadline: Date.now() + GRPC_HEALTHY_DEADLINE_MS }); + const breakerPromise = new Promise((_resolve, reject) => { + breakerTimer = setTimeout(() => { + const err = new Error('gRPC circuit-breaker fired'); + err.code = grpc.status.DEADLINE_EXCEEDED; + reject(err); + }, GRPC_CIRCUIT_BREAKER_MS); + }); + + let response; + try { + response = await Promise.race([callPromise, breakerPromise]); + } catch (err) { + log.debug(`gRPC viewHierarchy failed: name=${err.name} message=${err.message} code=${err.code}`); + const classification = classifyGrpcFailure(err); + if (classification.kind === 'dump-error') { + log.warn(`gRPC viewHierarchy schema-class failure (${classification.reason}); skipping element regions for this request`); + recordSchemaDrift(err.code, classification.reason); + } else { + log.debug(`gRPC viewHierarchy connection-class failure (${classification.reason}); evicting client + falling back to maestro CLI`); + evictGrpcClient(address); + } + return classification; + } finally { + clearTimeout(breakerTimer); + } + + const xml = response && typeof response.hierarchy === 'string' ? response.hierarchy : ''; + const slice = sliceXmlEnvelope(xml); + if (!slice) { + log.warn('gRPC viewHierarchy returned no XML envelope; skipping element regions for this request'); + recordSchemaDrift(undefined, 'grpc-no-xml-envelope'); + return { kind: 'dump-error', reason: 'grpc-no-xml-envelope' }; + } + let parsed; + try { + parsed = parser.parse(slice); + } catch (err) { + log.warn(`gRPC viewHierarchy parse error (${err.message}); skipping element regions for this request`); + recordSchemaDrift(undefined, 'grpc-parse-error'); + return { kind: 'dump-error', reason: `grpc-parse-error:${err.message}` }; + } + if (!parsed || !parsed.hierarchy) { + log.warn('gRPC viewHierarchy unexpected root tag; skipping element regions for this request'); + recordSchemaDrift(undefined, 'grpc-unexpected-root'); + return { kind: 'dump-error', reason: 'grpc-unexpected-root' }; + } + + const nodes = flattenNodes(parsed); + log.debug(`dump took ${Date.now() - start}ms via grpc (${nodes.length} nodes)`); + return { kind: 'hierarchy', nodes }; +} + +// Test seam — exposes module-scope state and reset hooks needed by +// maestro-hierarchy-grpc.test.js. Not part of the public API. +export const __testing__ = { + runGrpcDump, + classifyGrpcFailure, + getSchemaDriftSeen, + resetGrpcCacheForTests() { + for (const address of Array.from(grpcClientCache.keys())) { + evictGrpcClient(address); + } + }, + resetSchemaDriftForTests() { + schemaDriftSeen = null; + }, + discoverGrpcPort: (...args) => discoverGrpcPort(...args), + GRPC_HEALTHY_DEADLINE_MS, + GRPC_CIRCUIT_BREAKER_MS +}; + const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_', @@ -189,6 +387,39 @@ function classifyAdbFailure(result) { return null; } +// Discover the host-side TCP port forwarded to the device's gRPC server +// (`dev.mobile.maestro` on tcp:6790). MAESTRO_GRPC_PORT env var preferred — +// once mobile-repo's `cli_manager.rb` injects it, the probe shell-out is +// skipped (~50-100ms saved per first dump). Until then, parse +// `adb -s forward --list` for a `tcp: tcp:6790` line. +// +// Adb forward --list output format: `\t tcp: tcp:` per +// line. Whitespace between fields is tabs on most adb versions; allow any +// whitespace to defend against macOS/Linux drift. +async function discoverGrpcPort({ serial, execAdb, getEnv }) { + const fromEnv = getEnv('MAESTRO_GRPC_PORT'); + if (fromEnv) { + const port = Number.parseInt(fromEnv, 10); + if (Number.isInteger(port) && port > 0) return { port }; + // Non-positive integer or NaN — fall through to probe rather than crash. + } + + const probe = await execAdb(['-s', serial, 'forward', '--list']); + if (probe.spawnError || probe.timedOut) { + return { kind: 'unavailable', reason: 'grpc-port-not-found' }; + } + const stdout = probe.stdout || ''; + // Match the device port (tcp:6790); the line may or may not start with the + // serial depending on adb version. Anchor the device-port at the line end. + const re = /tcp:(\d+)\s+tcp:6790\s*$/m; + const match = stdout.match(re); + if (match) { + const port = Number.parseInt(match[1], 10); + if (Number.isInteger(port) && port > 0) return { port }; + } + return { kind: 'unavailable', reason: 'grpc-port-not-found' }; +} + // Resolve device serial: prefer ANDROID_SERIAL env; else probe `adb devices` // and require exactly one device. async function resolveSerial({ execAdb, getEnv }) { @@ -350,7 +581,8 @@ async function runMaestroDump(serial, execMaestro, getEnv) { export async function dump({ execAdb = defaultExecAdb, execMaestro = defaultExecMaestro, - getEnv = defaultGetEnv + getEnv = defaultGetEnv, + grpcClient = defaultGrpcClientFactory } = {}) { const started = Date.now(); @@ -360,8 +592,37 @@ export async function dump({ return classification; } - // Primary: `maestro --udid hierarchy`. Works during a live Maestro flow - // (maestro reuses its existing gRPC connection to dev.mobile.maestro on the device). + // Primary: direct gRPC to dev.mobile.maestro on tcp:6790 (host-side via + // adb forward). Drops per-screenshot resolver latency from ~9s to <100ms. + // Maestro CLI shell-out preserved as fallback for local dev, schema drift, + // and connection-class errors. + // + // Kill switch (R9): PERCY_MAESTRO_GRPC=0 short-circuits to the maestro CLI + // path. Logged loudly on every dump so the rollback state is observable. + const killSwitch = getEnv('PERCY_MAESTRO_GRPC') === '0'; + if (killSwitch) { + log.warn('PERCY_MAESTRO_GRPC kill switch active; using maestro CLI fallback'); + } else { + const portResult = await discoverGrpcPort({ serial, execAdb, getEnv }); + if (portResult.port) { + const grpcResult = await runGrpcDump({ host: '127.0.0.1', port: portResult.port, grpcClient }); + if (grpcResult.kind === 'hierarchy') { + return grpcResult; + } + if (grpcResult.kind === 'dump-error') { + // Schema-class — already logged + dirty bit set inside runGrpcDump. + // Returning as-is bypasses the maestro CLI fallback per R3. + return grpcResult; + } + // connection-fail — fall through to the maestro CLI path. + } else { + log.debug('gRPC port not found; using maestro CLI fallback'); + } + } + + // Fallback: `maestro --udid hierarchy`. Works during a live + // Maestro flow (maestro reuses its existing gRPC connection to + // dev.mobile.maestro on the device). const maestroResult = await runMaestroDump(serial, execMaestro, getEnv); if (maestroResult.kind === 'hierarchy') { log.debug(`dump took ${Date.now() - started}ms via maestro (${maestroResult.nodes.length} nodes)`); diff --git a/packages/core/src/proto/README.md b/packages/core/src/proto/README.md new file mode 100644 index 000000000..e6c5161ff --- /dev/null +++ b/packages/core/src/proto/README.md @@ -0,0 +1,47 @@ +# Vendored protobuf — `maestro_android.proto` + +Direct copy of the protobuf schema served by `dev.mobile.maestro` on Android +devices, used by `@percy/core`'s element-region resolver to call +`maestro_android.MaestroDriver/viewHierarchy` directly over gRPC instead of +spawning the full `maestro` CLI (~9s JVM cold start per call → <100ms direct +gRPC call). + +## Source + +- **Upstream file:** `maestro-proto/src/main/proto/maestro_android.proto` +- **Upstream repo:** [`mobile-dev-inc/Maestro`](https://github.com/mobile-dev-inc/Maestro) +- **Commit SHA at copy time:** `bc8bde1b5cb7f2d4076047c0a9db094ece47512f` (2025-05-26) +- **Closest CLI release:** `cli-2.5.1` +- **Copy date:** 2026-04-29 + +## What we use + +Only `MaestroDriver/viewHierarchy(ViewHierarchyRequest) returns (ViewHierarchyResponse)` +and the `string hierarchy = 1` field on the response. The rest of the proto +is included unchanged so future updates can be a clean upstream re-copy +without surgical edits. + +## Drift policy + +- The proto **must** be re-vendored from upstream whenever the Maestro CLI + version deployed on BrowserStack hosts is bumped past the version recorded + above. PRs that update this file must paste the upstream SHA and CLI tag. +- The runtime parser (`@grpc/proto-loader`) silently drops unknown fields. + If `viewHierarchy`'s response field is renumbered, retyped, or replaced, + decode errors surface as `dump-error (grpc-decode)` and a + `maestroHierarchyDrift` flag appears on the `/percy/healthcheck` response. + See `docs/solutions/integration-issues/percy-labels-cli-schema-rejection-2026-04-23.md` + for context on why we monitor schema drift loudly. + +## How to refresh + +```sh +curl -fsSL "https://raw.githubusercontent.com/mobile-dev-inc/Maestro/main/maestro-proto/src/main/proto/maestro_android.proto" \ + -o packages/core/src/proto/maestro_android.proto +# Update the SHA + CLI tag above; PR must show the diff and the new pin. +``` + +The file is loaded at module init via `@grpc/proto-loader`'s `loadSync` from +`packages/core/src/maestro-hierarchy.js`. Babel CLI's `copyFiles: true` +(scripts/build.js:26) preserves the relative layout so it lands at +`dist/proto/maestro_android.proto` after `yarn build`. diff --git a/packages/core/src/proto/maestro_android.proto b/packages/core/src/proto/maestro_android.proto new file mode 100644 index 000000000..51a65d552 --- /dev/null +++ b/packages/core/src/proto/maestro_android.proto @@ -0,0 +1,116 @@ +syntax = "proto3"; + +package maestro_android; + +service MaestroDriver { + + rpc deviceInfo(DeviceInfoRequest) returns (DeviceInfo) {} + + rpc viewHierarchy(ViewHierarchyRequest) returns (ViewHierarchyResponse) {} + + rpc screenshot(ScreenshotRequest) returns (ScreenshotResponse) {} + + rpc tap(TapRequest) returns (TapResponse) {} + + rpc inputText(InputTextRequest) returns (InputTextResponse) {} + + rpc eraseAllText(EraseAllTextRequest) returns (EraseAllTextResponse) {} + + rpc setLocation(SetLocationRequest) returns (SetLocationResponse) {} + + rpc isWindowUpdating(CheckWindowUpdatingRequest) returns (CheckWindowUpdatingResponse) {} + + rpc launchApp(LaunchAppRequest) returns (LaunchAppResponse) {} + + rpc addMedia(stream AddMediaRequest) returns (AddMediaResponse) {} + + rpc enableMockLocationProviders(EmptyRequest) returns (EmptyResponse) {} + + rpc disableLocationUpdates(EmptyRequest) returns (EmptyResponse) {} +} + +message EmptyRequest {} +message EmptyResponse {} + +message LaunchAppRequest { + + string packageName = 1; + repeated ArgumentValue arguments = 2; +} + +message ArgumentValue { + string key = 1; + string value = 2; + string type = 3; +} + +message LaunchAppResponse {} + +// Device info +message DeviceInfoRequest {} + +message DeviceInfo { + uint32 widthPixels = 1; + uint32 heightPixels = 2; +} + +message ScreenshotRequest {} + +message ScreenshotResponse { + bytes bytes = 1; +} + +// View hierarchy +message ViewHierarchyRequest {} + +message ViewHierarchyResponse { + string hierarchy = 1; +} + +// Interactions + +message TapRequest { + uint32 x = 1; + uint32 y = 2; +} + +message TapResponse {} + +message InputTextRequest { + string text = 1; +} +message InputTextResponse {} + + +message EraseAllTextRequest { + uint32 charactersToErase = 1; +} + +message EraseAllTextResponse {} + +message SetLocationRequest { + double latitude = 1; + double longitude = 2; +} + +message SetLocationResponse {} + +message CheckWindowUpdatingRequest { + string appId = 1; +} + +message CheckWindowUpdatingResponse { + bool isWindowUpdating = 1; +} + +message AddMediaRequest { + Payload payload = 1; + string media_name = 2; + string media_ext = 3; +} + +message AddMediaResponse { } + +message Payload { + bytes data = 1; +} \ No newline at end of file diff --git a/packages/core/test/fixtures/maestro-hierarchy/grpc-capture-notes.md b/packages/core/test/fixtures/maestro-hierarchy/grpc-capture-notes.md new file mode 100644 index 000000000..177c20591 --- /dev/null +++ b/packages/core/test/fixtures/maestro-hierarchy/grpc-capture-notes.md @@ -0,0 +1,49 @@ +# Maestro gRPC viewHierarchy fixture — capture notes + +This fixture is the wire-format `ViewHierarchyResponse.hierarchy` (string) that +`dev.mobile.maestro` returns for the +`maestro_android.MaestroDriver/viewHierarchy` RPC. + +## Status + +**Synthesized placeholder.** The shape mirrors `simple.xml` (the existing +uiautomator-dump fixture) plus the three Maestro-only attributes documented +upstream — `hintText`, `NAF`, `visible-to-user` — which the parser already +ignores because `flattenNodes` only reads the four selector attributes +(`resource-id`, `text`, `content-desc`, `class`) plus `bounds`. + +The structural assumption (Maestro's gRPC response is the same UIAutomator XML +format as `adb shell uiautomator dump`) is verified at the source level: +`mobile-dev-inc/maestro/maestro-android/.../ViewHierarchy.kt` says +"Logic largely copied from `AccessibilityNodeInfoDumper`" — the same AOSP +class behind `uiautomator dump`. + +## Empirical verification — deferred + +To capture the real wire payload from a running BrowserStack Maestro Android +session and replace this fixture: + +1. Drop a temporary `console.log('GRPC_RAW_HIERARCHY=' + response.hierarchy)` + immediately before `extractXmlEnvelope` in `runGrpcDump` + (`packages/core/src/maestro-hierarchy.js`). +2. Build (`yarn build`), package the overlay (per the host-overlay technique + in `project_e2e_validation_state.md`), deploy to a pinned BrowserStack + host (`POST /app-automate/maestro/v2/android/build` with + `"machine": ":"`), and run a Percy-Maestro flow with at least + one element-region snapshot. +3. Grep `GRPC_RAW_HIERARCHY=` in the percy CLI debug log on the host, copy the + value verbatim (it's a single line of escaped XML), unescape into a file, + replace `grpc-response.xml`. +4. Revert the temporary `console.log`. +5. Append a row to the table below. + +| Date | BS session URL | Device profile | Maestro CLI version | Captured by | +|------|----------------|----------------|---------------------|-------------| +| _deferred_ | — | — | — | — | + +## Drift policy + +If a real capture surfaces structural differences (different root tag, missing +` + + + + + + + + + + diff --git a/packages/core/test/integration/README.md b/packages/core/test/integration/README.md new file mode 100644 index 000000000..665a104b4 --- /dev/null +++ b/packages/core/test/integration/README.md @@ -0,0 +1,100 @@ +# Integration harness — `maestro-hierarchy-concurrent.harness.js` + +Documented merge gate for the Phase 2.2 gRPC view-hierarchy resolver +([R6 in the plan](../../../../percy-maestro-android/docs/plans/2026-04-29-001-feat-grpc-element-region-resolver-plan.md)). +Runs `dump()` against a real Android device while a parallel Maestro flow +holds the UiAutomator session, asserts `{ kind: 'hierarchy' }` on every +iteration, and confirms the Maestro flow stays alive. CI skips this +silently — it requires a connected device + Maestro CLI, which CI does not +have. + +The negative-control version of this harness reproduces the +`adb-uiautomator-dump` SIGKILL bug deterministically (see +[`maestro-view-hierarchy-uiautomator-lock-2026-04-22.md`](../../../../percy-maestro-android/docs/solutions/integration-issues/maestro-view-hierarchy-uiautomator-lock-2026-04-22.md)) +— that's the failure mode this harness exists to prevent regressing. + +## When to run + +Before merging the Phase 2.2 gRPC PR. Paste the harness output (including +p50/p95/p99 timings) into the PR description as the documented merge gate. + +Re-run after any change that touches: +- `packages/core/src/maestro-hierarchy.js` (resolver dispatch / classification) +- `@grpc/grpc-js` or `@grpc/proto-loader` version bumps +- `packages/core/src/proto/maestro_android.proto` (vendored proto refresh) + +## Prerequisites + +- A connected Android device (USB or `adb connect`) +- `MAESTRO_BIN` pointing to the Maestro CLI binary (or `maestro` on PATH) +- `adb forward tcp: tcp:6790` set up against the device, **OR** + `MAESTRO_GRPC_PORT=` exported in the environment +- Wikipedia sample app (`org.wikipedia.alpha`) installed, or override + `appId:` in `fixtures/pause-30s-flow.yaml` to match a different installed + package + +## Invocation + +From `cli/packages/core/`: + +```sh +MAESTRO_ANDROID_TEST_DEVICE= \ + MAESTRO_BIN=/path/to/maestro \ + ANDROID_SERIAL= \ + node test/integration/maestro-hierarchy-concurrent.harness.js +``` + +Optional knobs: + +- `PERCY_GRPC_HARNESS_ITERATIONS=N` — override the default 100 dump + iterations. +- `MAESTRO_GRPC_PORT=` — pin the gRPC port and skip the + `adb forward --list` probe. +- `PERCY_MAESTRO_GRPC=0` — flip the harness to exercise the maestro CLI + fallback path instead (negative control: the harness should still pass, + but timings will be ~9s p50 instead of <100ms). + +## Expected output (success) + +``` +harness: device=28201FDH300J1S iterations=100 maestro=/nix/store/.../maestro +harness: spawning maestro test fixtures/pause-30s-flow.yaml... +harness: PERCY_PAUSE_BEGIN seen — Maestro flow now holds UiAutomator session. +harness: completed 100/100 iterations +harness: timings p50=42ms p95=89ms p99=121ms +harness PASS +``` + +If `p99 ≥ 250ms × 0.9` the plan KTD calls for bumping +`GRPC_HEALTHY_DEADLINE_MS` (in `maestro-hierarchy.js`) to `p99 × 2` before +merge. The 2s circuit-breaker deadline is independent and stays as-is. + +## Expected output (skip — CI default) + +``` +skip: MAESTRO_ANDROID_TEST_DEVICE not set — harness requires a real Android device +``` + +Exit code 0 on skip — CI will not block on this script. + +## Expected output (negative control: regression detected) + +``` +harness FAIL: 87 iteration(s) failed: + - iter 0: kind=dump-error reason=fallback-dump-exit-137 (3502ms) + - iter 1: kind=dump-error reason=fallback-dump-exit-137 (3503ms) + ... +``` + +This is what running the same harness against the pre-Phase-2.1 build +(adb-uiautomator-dump as primary) produces — the deterministic SIGKILL +contention the gRPC primary path sidesteps. + +## Pause primitive — known imperfection + +`extendedWaitUntil` is not a hard mutex. There is a sub-millisecond gap +between Maestro's polling iterations where the UiAutomator session is +briefly released. Real-Android session acquisition takes 50–200ms, so the +gap is unexploitable in practice — but if the harness ever shows flaky +passes under contention, that's the suspect. The plan's risk treatment +(Risk 3) documents this trade-off. diff --git a/packages/core/test/integration/fixtures/pause-30s-flow.yaml b/packages/core/test/integration/fixtures/pause-30s-flow.yaml new file mode 100644 index 000000000..7d84f4690 --- /dev/null +++ b/packages/core/test/integration/fixtures/pause-30s-flow.yaml @@ -0,0 +1,27 @@ +# Maestro flow that holds the device's UiAutomator session continuously +# for ~30s by polling `dumpWindowHierarchy` against an impossible selector. +# The harness in ../../maestro-hierarchy-concurrent.harness.js runs against +# this flow on a real Android device + active Maestro session — exercises +# the concurrent-lock contention scenario that R6 guards against. +# +# Pause primitive: `extendedWaitUntil` with an impossible selector. Maestro's +# Android driver implements extendedWaitUntil as a tight polling loop that +# calls dumpWindowHierarchy on every iteration. With an unmatched selector +# the loop runs the full configured timeout — every iteration acquires the +# UiAutomator session (the contention we want). +# +# Maestro's expected behavior: the assertion fails on timeout, the flow +# exits non-zero. The harness treats that exit as the "pause window ended" +# signal and swallows it. +# +# DO NOT replace `extendedWaitUntil` with `waitForAnimationToEnd:30000` — +# that command exits early on screen-settle and silently on timeout, which +# is the wrong outcome for a regression test. + +appId: org.wikipedia.alpha # Override at runtime via --env appId= when needed +--- +- runScript: scripts/percy-pause-sentinel.js +- extendedWaitUntil: + visible: + id: "__percy_harness_never_matches__" + timeout: 30000 diff --git a/packages/core/test/integration/fixtures/scripts/percy-pause-sentinel.js b/packages/core/test/integration/fixtures/scripts/percy-pause-sentinel.js new file mode 100644 index 000000000..fb5f02159 --- /dev/null +++ b/packages/core/test/integration/fixtures/scripts/percy-pause-sentinel.js @@ -0,0 +1,5 @@ +// Maestro `runScript` step. Maestro flushes runScript stdout before +// advancing to the next command, so the harness uses this sentinel as +// the deterministic "the upcoming extendedWaitUntil step is now executing" +// signal. See ../pause-30s-flow.yaml + ../../README.md. +console.log('PERCY_PAUSE_BEGIN'); diff --git a/packages/core/test/integration/maestro-hierarchy-concurrent.harness.js b/packages/core/test/integration/maestro-hierarchy-concurrent.harness.js new file mode 100644 index 000000000..7157a81f5 --- /dev/null +++ b/packages/core/test/integration/maestro-hierarchy-concurrent.harness.js @@ -0,0 +1,142 @@ +#!/usr/bin/env node +// Concurrent-access regression harness for the maestro view-hierarchy +// resolver. Calls dump() while a real Maestro flow is actively holding the +// UiAutomator session via extendedWaitUntil + impossible selector, asserts +// `{ kind: 'hierarchy' }`, and confirms the parallel Maestro flow remains +// alive. Records p50/p95/p99 timing across N=100 iterations to feed the +// 250ms healthy-call deadline tuning decision in the plan KTD. +// +// Skipped when MAESTRO_ANDROID_TEST_DEVICE is unset (CI default → exit 0). +// Run on a dev machine or BrowserStack host before merging Phase 2.2. +// Paste the green output (including p50/p95/p99) into the PR description. +// +// Prerequisites + invocation: see ./README.md. + +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import url from 'node:url'; +import { dump } from '../../src/maestro-hierarchy.js'; + +const __filename = url.fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const FLOW_PATH = path.resolve(__dirname, 'fixtures/pause-30s-flow.yaml'); +const MAESTRO_BIN = process.env.MAESTRO_BIN || 'maestro'; +const SERIAL = process.env.MAESTRO_ANDROID_TEST_DEVICE; +const ITERATIONS = Number.parseInt(process.env.PERCY_GRPC_HARNESS_ITERATIONS || '100', 10); + +if (!SERIAL) { + console.log('skip: MAESTRO_ANDROID_TEST_DEVICE not set — harness requires a real Android device'); + process.exit(0); +} + +function percentile(sorted, p) { + if (sorted.length === 0) return NaN; + const idx = Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length)); + return sorted[idx]; +} + +function spawnMaestroFlow() { + return new Promise((resolve, reject) => { + const proc = spawn(MAESTRO_BIN, ['--udid', SERIAL, 'test', FLOW_PATH], { + stdio: ['ignore', 'pipe', 'pipe'] + }); + + let pauseStarted = false; + let stderrBuf = ''; + + proc.stdout.on('data', chunk => { + const text = chunk.toString(); + if (!pauseStarted && text.includes('PERCY_PAUSE_BEGIN')) { + pauseStarted = true; + resolve(proc); + } + }); + proc.stderr.on('data', chunk => { stderrBuf += chunk.toString(); }); + proc.on('error', reject); + proc.on('exit', code => { + if (!pauseStarted) { + reject(new Error(`Maestro flow exited (code ${code}) before PERCY_PAUSE_BEGIN sentinel was seen.\nstderr: ${stderrBuf}`)); + } + }); + + // Hard cap on the wait so a stuck Maestro flow doesn't hang the harness. + setTimeout(() => { + if (!pauseStarted) { + try { proc.kill('SIGKILL'); } catch { /* swallow */ } + reject(new Error('timed out waiting for PERCY_PAUSE_BEGIN sentinel (60s)')); + } + }, 60_000); + }); +} + +async function main() { + console.log(`harness: device=${SERIAL} iterations=${ITERATIONS} maestro=${MAESTRO_BIN}`); + console.log(`harness: spawning maestro test ${FLOW_PATH}...`); + + let maestroProc; + try { + maestroProc = await spawnMaestroFlow(); + } catch (err) { + console.error('harness FAIL:', err.message); + process.exit(1); + } + console.log('harness: PERCY_PAUSE_BEGIN seen — Maestro flow now holds UiAutomator session.'); + + const timings = []; + const failures = []; + + try { + for (let i = 0; i < ITERATIONS; i += 1) { + const start = Date.now(); + let result; + try { + result = await dump({ platform: 'android' }); + } catch (err) { + failures.push(`iter ${i}: dump threw: ${err.message}`); + continue; + } + const elapsed = Date.now() - start; + timings.push(elapsed); + + if (result.kind !== 'hierarchy') { + failures.push(`iter ${i}: kind=${result.kind} reason=${result.reason} (${elapsed}ms)`); + continue; + } + if (!Array.isArray(result.nodes) || result.nodes.length === 0) { + failures.push(`iter ${i}: hierarchy has no nodes (${elapsed}ms)`); + } + } + } finally { + // Confirm the parallel Maestro flow is still alive (our dump should + // not have killed it via SIGKILL contention) before tearing it down. + let stillAlive = true; + try { process.kill(maestroProc.pid, 0); } catch { stillAlive = false; } + if (!stillAlive) { + failures.push('Maestro flow was no longer alive after the dump iterations completed'); + } + try { maestroProc.kill('SIGTERM'); } catch { /* swallow */ } + } + + const sorted = [...timings].sort((a, b) => a - b); + const p50 = percentile(sorted, 50); + const p95 = percentile(sorted, 95); + const p99 = percentile(sorted, 99); + + console.log(`harness: completed ${timings.length}/${ITERATIONS} iterations`); + console.log(`harness: timings p50=${p50}ms p95=${p95}ms p99=${p99}ms`); + + if (failures.length > 0) { + console.error(`harness FAIL: ${failures.length} iteration(s) failed:`); + for (const f of failures.slice(0, 20)) console.error(` - ${f}`); + process.exit(1); + } + + console.log('harness PASS'); + process.exit(0); +} + +main().catch(err => { + console.error('harness FAIL (unhandled):', err); + process.exit(1); +}); diff --git a/packages/core/test/unit/maestro-hierarchy-grpc.test.js b/packages/core/test/unit/maestro-hierarchy-grpc.test.js new file mode 100644 index 000000000..8f0bd4f3a --- /dev/null +++ b/packages/core/test/unit/maestro-hierarchy-grpc.test.js @@ -0,0 +1,691 @@ +import fs from 'fs'; +import path from 'path'; +import url from 'url'; +import { + dump, + __testing__ +} from '../../src/maestro-hierarchy.js'; +import { setupTest } from '../helpers/index.js'; + +const fixtureDir = path.resolve(url.fileURLToPath(import.meta.url), '../../fixtures/maestro-hierarchy'); +const loadFixture = name => fs.readFileSync(path.join(fixtureDir, name), 'utf8'); + +const { + runGrpcDump, + classifyGrpcFailure, + resetGrpcCacheForTests, + getSchemaDriftSeen, + resetSchemaDriftForTests, + discoverGrpcPort, + GRPC_HEALTHY_DEADLINE_MS, + GRPC_CIRCUIT_BREAKER_MS +} = __testing__; + +// gRPC status codes (mirrors @grpc/grpc-js' enum). Kept inline in the test so +// regressions to classifyGrpcFailure surface even if the upstream enum drifts. +const GRPC_STATUS = { + OK: 0, + CANCELLED: 1, + UNKNOWN: 2, + INVALID_ARGUMENT: 3, + DEADLINE_EXCEEDED: 4, + NOT_FOUND: 5, + ALREADY_EXISTS: 6, + PERMISSION_DENIED: 7, + RESOURCE_EXHAUSTED: 8, + FAILED_PRECONDITION: 9, + ABORTED: 10, + OUT_OF_RANGE: 11, + UNIMPLEMENTED: 12, + INTERNAL: 13, + UNAVAILABLE: 14, + DATA_LOSS: 15, + UNAUTHENTICATED: 16 +}; + +function makeFakeFactory(impl) { + // impl: (address) → { viewHierarchy, close } + const created = []; + const factory = address => { + const client = impl(address); + created.push({ address, client }); + return client; + }; + factory.created = created; + return factory; +} + +function makeFixedClient({ response, error, closeSpy }) { + return { + viewHierarchy: () => error ? Promise.reject(error) : Promise.resolve(response), + close: () => { if (closeSpy) closeSpy(); } + }; +} + +describe('Unit / maestro-hierarchy gRPC', () => { + beforeEach(async () => { + await setupTest(); + resetGrpcCacheForTests(); + resetSchemaDriftForTests(); + }); + + describe('classifyGrpcFailure', () => { + it('returns null for falsy errors', () => { + expect(classifyGrpcFailure(null)).toBeNull(); + expect(classifyGrpcFailure(undefined)).toBeNull(); + }); + + it('classifies missing code as schema-class grpc-decode (no fallback)', () => { + expect(classifyGrpcFailure(new Error('boom'))).toEqual({ + kind: 'dump-error', + reason: 'grpc-decode' + }); + }); + + it('classifies INVALID_ARGUMENT (3) as schema-class', () => { + expect(classifyGrpcFailure({ code: GRPC_STATUS.INVALID_ARGUMENT })).toEqual({ + kind: 'dump-error', + reason: 'grpc-schema-invalid_argument' + }); + }); + + it('classifies FAILED_PRECONDITION (9) as schema-class', () => { + expect(classifyGrpcFailure({ code: GRPC_STATUS.FAILED_PRECONDITION })).toEqual({ + kind: 'dump-error', + reason: 'grpc-schema-failed_precondition' + }); + }); + + it('classifies OUT_OF_RANGE (11) as schema-class', () => { + expect(classifyGrpcFailure({ code: GRPC_STATUS.OUT_OF_RANGE })).toEqual({ + kind: 'dump-error', + reason: 'grpc-schema-out_of_range' + }); + }); + + it('classifies UNIMPLEMENTED (12) as schema-class', () => { + expect(classifyGrpcFailure({ code: GRPC_STATUS.UNIMPLEMENTED })).toEqual({ + kind: 'dump-error', + reason: 'grpc-schema-unimplemented' + }); + }); + + it('classifies DATA_LOSS (15) as schema-class', () => { + expect(classifyGrpcFailure({ code: GRPC_STATUS.DATA_LOSS })).toEqual({ + kind: 'dump-error', + reason: 'grpc-schema-data_loss' + }); + }); + + it('classifies CANCELLED (1) as connection-class', () => { + expect(classifyGrpcFailure({ code: GRPC_STATUS.CANCELLED })).toEqual({ + kind: 'connection-fail', + reason: 'grpc-cancelled' + }); + }); + + it('classifies DEADLINE_EXCEEDED (4) as connection-class', () => { + expect(classifyGrpcFailure({ code: GRPC_STATUS.DEADLINE_EXCEEDED })).toEqual({ + kind: 'connection-fail', + reason: 'grpc-deadline_exceeded' + }); + }); + + it('classifies NOT_FOUND (5) as connection-class', () => { + expect(classifyGrpcFailure({ code: GRPC_STATUS.NOT_FOUND })).toEqual({ + kind: 'connection-fail', + reason: 'grpc-not_found' + }); + }); + + it('classifies PERMISSION_DENIED (7) as connection-class', () => { + expect(classifyGrpcFailure({ code: GRPC_STATUS.PERMISSION_DENIED })).toEqual({ + kind: 'connection-fail', + reason: 'grpc-permission_denied' + }); + }); + + it('classifies RESOURCE_EXHAUSTED (8) as connection-class', () => { + expect(classifyGrpcFailure({ code: GRPC_STATUS.RESOURCE_EXHAUSTED })).toEqual({ + kind: 'connection-fail', + reason: 'grpc-resource_exhausted' + }); + }); + + it('classifies INTERNAL (13) as connection-class', () => { + expect(classifyGrpcFailure({ code: GRPC_STATUS.INTERNAL })).toEqual({ + kind: 'connection-fail', + reason: 'grpc-internal' + }); + }); + + it('classifies UNAVAILABLE (14) as connection-class', () => { + expect(classifyGrpcFailure({ code: GRPC_STATUS.UNAVAILABLE })).toEqual({ + kind: 'connection-fail', + reason: 'grpc-unavailable' + }); + }); + + it('classifies UNAUTHENTICATED (16) as connection-class', () => { + expect(classifyGrpcFailure({ code: GRPC_STATUS.UNAUTHENTICATED })).toEqual({ + kind: 'connection-fail', + reason: 'grpc-unauthenticated' + }); + }); + }); + + describe('runGrpcDump (happy path)', () => { + it('returns hierarchy with parsed nodes from gRPC response XML', async () => { + const grpcClient = makeFakeFactory(() => + makeFixedClient({ response: { hierarchy: loadFixture('grpc-response.xml') } }) + ); + + const res = await runGrpcDump({ host: '127.0.0.1', port: 8206, grpcClient }); + + expect(res.kind).toBe('hierarchy'); + expect(Array.isArray(res.nodes)).toBe(true); + // grpc-response.xml mirrors simple.xml's structure — clock node should be present + const clockNode = res.nodes.find(n => n['resource-id'] === 'com.example:id/clock'); + expect(clockNode).toBeTruthy(); + expect(clockNode.bounds).toBe('[40,50][500,150]'); + }); + + it('parity: gRPC path emits same nodes as adb path on equivalent fixtures', async () => { + // The structural equivalence check the plan calls out as the "strongest + // test that the XML-vs-JSON-flattener distinction doesn't leak." + const grpcClient = makeFakeFactory(() => + // Feed the gRPC path the *adb-style* simple.xml — same XML schema. + makeFixedClient({ response: { hierarchy: loadFixture('simple.xml') } }) + ); + + const res = await runGrpcDump({ host: '127.0.0.1', port: 8206, grpcClient }); + expect(res.kind).toBe('hierarchy'); + + // Resource IDs from simple.xml that have selector attributes + const ids = res.nodes.map(n => n['resource-id']).filter(Boolean).sort(); + expect(ids).toContain('com.example:id/clock'); + expect(ids).toContain('com.example:id/header'); + expect(ids).toContain('com.example:id/settings_btn'); + }); + }); + + describe('runGrpcDump (failure paths)', () => { + it('returns dump-error grpc-decode on rejection without code', async () => { + const grpcClient = makeFakeFactory(() => + makeFixedClient({ error: new Error('connection reset') }) + ); + const res = await runGrpcDump({ host: '127.0.0.1', port: 8206, grpcClient }); + expect(res).toEqual({ kind: 'dump-error', reason: 'grpc-decode' }); + }); + + it('returns dump-error on UNIMPLEMENTED (Maestro lacks the RPC)', async () => { + const grpcClient = makeFakeFactory(() => + makeFixedClient({ error: { code: GRPC_STATUS.UNIMPLEMENTED, message: 'no such RPC' } }) + ); + const res = await runGrpcDump({ host: '127.0.0.1', port: 8206, grpcClient }); + expect(res).toEqual({ kind: 'dump-error', reason: 'grpc-schema-unimplemented' }); + }); + + it('returns connection-fail on UNAVAILABLE (textbook)', async () => { + const grpcClient = makeFakeFactory(() => + makeFixedClient({ error: { code: GRPC_STATUS.UNAVAILABLE, message: 'transport down' } }) + ); + const res = await runGrpcDump({ host: '127.0.0.1', port: 8206, grpcClient }); + expect(res).toEqual({ kind: 'connection-fail', reason: 'grpc-unavailable' }); + }); + + it('returns dump-error grpc-no-xml-envelope on empty hierarchy field', async () => { + const grpcClient = makeFakeFactory(() => + makeFixedClient({ response: { hierarchy: '' } }) + ); + const res = await runGrpcDump({ host: '127.0.0.1', port: 8206, grpcClient }); + expect(res.kind).toBe('dump-error'); + expect(res.reason).toBe('grpc-no-xml-envelope'); + }); + + it('returns dump-error grpc-unexpected-root when XML root is not ', async () => { + // Synthesize a payload with a different root tag but otherwise well-formed. + // sliceXmlEnvelope still finds the close tag if present, so we + // construct an XML where is absent — sliceXmlEnvelope returns + // null and we hit the no-xml-envelope branch instead. To exercise the + // unexpected-root branch specifically, we wrap a fake inside + // a different-named root. + const malformedXml = ''; + const grpcClient = makeFakeFactory(() => + makeFixedClient({ response: { hierarchy: malformedXml } }) + ); + const res = await runGrpcDump({ host: '127.0.0.1', port: 8206, grpcClient }); + // Either no-xml-envelope (if slice stops at ) or unexpected-root. + // Our implementation slices to the FIRST , which yields a + // sub-envelope `` that fast-xml-parser + // parses as `{ root: { hierarchy: '' } }` — root tag check fails → unexpected-root. + expect(res.kind).toBe('dump-error'); + expect(['grpc-unexpected-root', 'grpc-no-xml-envelope']).toContain(res.reason); + }); + }); + + describe('runGrpcDump (caching + eviction)', () => { + it('reuses the same client for two calls to the same (host, port)', async () => { + const grpcClient = makeFakeFactory(() => + makeFixedClient({ response: { hierarchy: loadFixture('grpc-response.xml') } }) + ); + + await runGrpcDump({ host: '127.0.0.1', port: 8206, grpcClient }); + await runGrpcDump({ host: '127.0.0.1', port: 8206, grpcClient }); + + expect(grpcClient.created.length).toBe(1); + }); + + it('creates a new client when port changes', async () => { + const grpcClient = makeFakeFactory(() => + makeFixedClient({ response: { hierarchy: loadFixture('grpc-response.xml') } }) + ); + + await runGrpcDump({ host: '127.0.0.1', port: 8206, grpcClient }); + await runGrpcDump({ host: '127.0.0.1', port: 8207, grpcClient }); + + expect(grpcClient.created.length).toBe(2); + expect(grpcClient.created[0].address).toBe('127.0.0.1:8206'); + expect(grpcClient.created[1].address).toBe('127.0.0.1:8207'); + }); + + it('evicts and closes the client on connection-class failure', async () => { + let closeCalls = 0; + const grpcClient = makeFakeFactory(() => + makeFixedClient({ + error: { code: GRPC_STATUS.UNAVAILABLE, message: 'down' }, + closeSpy: () => { closeCalls += 1; } + }) + ); + + const res = await runGrpcDump({ host: '127.0.0.1', port: 8206, grpcClient }); + expect(res.kind).toBe('connection-fail'); + expect(closeCalls).toBe(1); + + // Subsequent call must lazy-create a fresh client (cache evicted). + await runGrpcDump({ host: '127.0.0.1', port: 8206, grpcClient }); + expect(grpcClient.created.length).toBe(2); + }); + + it('does NOT evict the client on schema-class failure', async () => { + let closeCalls = 0; + const grpcClient = makeFakeFactory(() => + makeFixedClient({ + error: { code: GRPC_STATUS.UNIMPLEMENTED, message: 'schema' }, + closeSpy: () => { closeCalls += 1; } + }) + ); + + const res = await runGrpcDump({ host: '127.0.0.1', port: 8206, grpcClient }); + expect(res.kind).toBe('dump-error'); + expect(closeCalls).toBe(0); + }); + }); + + describe('runGrpcDump (deadlines)', () => { + it('passes a healthy-call deadline ~Date.now() + GRPC_HEALTHY_DEADLINE_MS', async () => { + let observedOptions = null; + const grpcClient = makeFakeFactory(() => ({ + viewHierarchy: (req, options) => { + observedOptions = options; + return Promise.resolve({ hierarchy: loadFixture('grpc-response.xml') }); + }, + close: () => {} + })); + + const before = Date.now(); + await runGrpcDump({ host: '127.0.0.1', port: 8206, grpcClient }); + const after = Date.now(); + + expect(observedOptions).toBeTruthy(); + expect(typeof observedOptions.deadline).toBe('number'); + // Deadline is absolute ms-since-epoch ≈ start + HEALTHY budget (allow +/- jitter) + expect(observedOptions.deadline).toBeGreaterThanOrEqual(before + GRPC_HEALTHY_DEADLINE_MS - 5); + expect(observedOptions.deadline).toBeLessThanOrEqual(after + GRPC_HEALTHY_DEADLINE_MS + 5); + }); + + it('circuit-breaker fires near 2s when the call never settles', async () => { + const grpcClient = makeFakeFactory(() => ({ + viewHierarchy: () => new Promise(() => {}), // never resolves + close: () => {} + })); + + const start = Date.now(); + const res = await runGrpcDump({ host: '127.0.0.1', port: 8206, grpcClient }); + const elapsed = Date.now() - start; + + // Circuit breaker fires DEADLINE_EXCEEDED → connection-fail + expect(res.kind).toBe('connection-fail'); + expect(res.reason).toBe('grpc-deadline_exceeded'); + // Elapsed must be ≥ breaker time and not run forever (allow +/- 200ms slack) + expect(elapsed).toBeGreaterThanOrEqual(GRPC_CIRCUIT_BREAKER_MS - 50); + expect(elapsed).toBeLessThanOrEqual(GRPC_CIRCUIT_BREAKER_MS + 500); + }); + }); + + describe('discoverGrpcPort', () => { + function makeFakeExecAdb(handlers) { + const callLog = []; + const execAdb = async args => { + callLog.push(args); + for (const { match, result } of handlers) { + if (match(args)) return typeof result === 'function' ? result(args) : result; + } + throw new Error(`No fake handler matched adb args: ${JSON.stringify(args)}`); + }; + execAdb.calls = callLog; + return execAdb; + } + + it('returns env var when MAESTRO_GRPC_PORT is set to a positive integer', async () => { + const execAdb = makeFakeExecAdb([]); + const res = await discoverGrpcPort({ + serial: 'serial-1', + execAdb, + getEnv: k => (k === 'MAESTRO_GRPC_PORT' ? '8206' : undefined) + }); + expect(res).toEqual({ port: 8206 }); + expect(execAdb.calls.length).toBe(0); + }); + + it('falls back to adb forward --list when env var is absent', async () => { + const execAdb = makeFakeExecAdb([ + { + match: args => args.includes('forward') && args.includes('--list'), + result: { stdout: 'serial-1\ttcp:8206 tcp:6790\n', stderr: '', exitCode: 0 } + } + ]); + const res = await discoverGrpcPort({ + serial: 'serial-1', + execAdb, + getEnv: () => undefined + }); + expect(res).toEqual({ port: 8206 }); + expect(execAdb.calls[0]).toEqual(['-s', 'serial-1', 'forward', '--list']); + }); + + it('returns unavailable when probe stdout has no matching line', async () => { + const execAdb = makeFakeExecAdb([ + { + match: args => args.includes('forward'), + result: { stdout: 'serial-1\ttcp:5037 tcp:1234\n', stderr: '', exitCode: 0 } + } + ]); + const res = await discoverGrpcPort({ + serial: 'serial-1', + execAdb, + getEnv: () => undefined + }); + expect(res).toEqual({ kind: 'unavailable', reason: 'grpc-port-not-found' }); + }); + + it('returns unavailable on probe timeout', async () => { + const execAdb = makeFakeExecAdb([ + { + match: args => args.includes('forward'), + result: { stdout: '', stderr: '', exitCode: null, timedOut: true } + } + ]); + const res = await discoverGrpcPort({ + serial: 'serial-1', + execAdb, + getEnv: () => undefined + }); + expect(res).toEqual({ kind: 'unavailable', reason: 'grpc-port-not-found' }); + }); + + it('returns unavailable on probe spawn error (adb not found)', async () => { + const execAdb = async () => ({ + spawnError: Object.assign(new Error('not found'), { code: 'ENOENT' }) + }); + const res = await discoverGrpcPort({ + serial: 'serial-1', + execAdb, + getEnv: () => undefined + }); + expect(res).toEqual({ kind: 'unavailable', reason: 'grpc-port-not-found' }); + }); + + it('falls through to probe when MAESTRO_GRPC_PORT is non-numeric', async () => { + const execAdb = makeFakeExecAdb([ + { + match: args => args.includes('forward'), + result: { stdout: 'serial-1\ttcp:8206 tcp:6790\n', stderr: '', exitCode: 0 } + } + ]); + const res = await discoverGrpcPort({ + serial: 'serial-1', + execAdb, + getEnv: k => (k === 'MAESTRO_GRPC_PORT' ? 'not-a-number' : undefined) + }); + expect(res).toEqual({ port: 8206 }); + }); + + it('falls through to probe when MAESTRO_GRPC_PORT is non-positive', async () => { + const execAdb = makeFakeExecAdb([ + { + match: args => args.includes('forward'), + result: { stdout: 'serial-1\ttcp:8206 tcp:6790\n', stderr: '', exitCode: 0 } + } + ]); + const res = await discoverGrpcPort({ + serial: 'serial-1', + execAdb, + getEnv: k => (k === 'MAESTRO_GRPC_PORT' ? '-1' : undefined) + }); + expect(res).toEqual({ port: 8206 }); + }); + + it('picks the first tcp:6790 forward when multiple lines present', async () => { + const execAdb = makeFakeExecAdb([ + { + match: args => args.includes('forward'), + result: { + stdout: 'serial-1\ttcp:5555 tcp:5037\nserial-1\ttcp:8206 tcp:6790\nserial-1\ttcp:9000 tcp:6790\n', + stderr: '', + exitCode: 0 + } + } + ]); + const res = await discoverGrpcPort({ + serial: 'serial-1', + execAdb, + getEnv: () => undefined + }); + expect(res).toEqual({ port: 8206 }); + }); + }); + + describe('dump() — gRPC primary dispatch', () => { + const okMaestroResponse = { stdout: fs.readFileSync(path.join(fixtureDir, 'maestro-simple.json'), 'utf8'), stderr: '', exitCode: 0 }; + + function envFn(map) { + return key => map[key]; + } + + function adbWithForward(port) { + return async args => { + if (args.includes('forward') && args.includes('--list')) { + return port + ? { stdout: `serial\ttcp:${port} tcp:6790\n`, stderr: '', exitCode: 0 } + : { stdout: '', stderr: '', exitCode: 0 }; + } + if (args[0] === 'devices') { + return { stdout: 'List of devices attached\nserial\tdevice\n\n', stderr: '', exitCode: 0 }; + } + throw new Error('unexpected adb call: ' + args.join(' ')); + }; + } + + it('returns hierarchy from gRPC; never invokes maestro CLI or adb dump when port discovered + gRPC succeeds', async () => { + let maestroCalled = false; + const execMaestro = async () => { maestroCalled = true; return { stdout: '', stderr: '', exitCode: 1 }; }; + const grpcClient = makeFakeFactory(() => + makeFixedClient({ response: { hierarchy: loadFixture('grpc-response.xml') } }) + ); + + const res = await dump({ + execMaestro, + execAdb: adbWithForward(8206), + getEnv: envFn({ ANDROID_SERIAL: 'serial' }), + grpcClient + }); + + expect(res.kind).toBe('hierarchy'); + expect(maestroCalled).toBe(false); + expect(grpcClient.created.length).toBe(1); + expect(grpcClient.created[0].address).toBe('127.0.0.1:8206'); + }); + + it('falls back to maestro CLI on gRPC connection-class failure (UNAVAILABLE)', async () => { + let maestroCalled = false; + const execMaestro = async () => { maestroCalled = true; return okMaestroResponse; }; + const grpcClient = makeFakeFactory(() => + makeFixedClient({ error: { code: GRPC_STATUS.UNAVAILABLE, message: 'down' } }) + ); + + const res = await dump({ + execMaestro, + execAdb: adbWithForward(8206), + getEnv: envFn({ ANDROID_SERIAL: 'serial' }), + grpcClient + }); + + expect(res.kind).toBe('hierarchy'); + expect(maestroCalled).toBe(true); + }); + + it('returns dump-error directly on gRPC schema-class failure (UNIMPLEMENTED) — no maestro fallback', async () => { + let maestroCalled = false; + const execMaestro = async () => { maestroCalled = true; return okMaestroResponse; }; + const grpcClient = makeFakeFactory(() => + makeFixedClient({ error: { code: GRPC_STATUS.UNIMPLEMENTED, message: 'no rpc' } }) + ); + + const res = await dump({ + execMaestro, + execAdb: adbWithForward(8206), + getEnv: envFn({ ANDROID_SERIAL: 'serial' }), + grpcClient + }); + + expect(res.kind).toBe('dump-error'); + expect(res.reason).toBe('grpc-schema-unimplemented'); + expect(maestroCalled).toBe(false); + }); + + it('falls back to maestro CLI when gRPC port is not discoverable', async () => { + let maestroCalled = false; + let grpcCalled = false; + const execMaestro = async () => { maestroCalled = true; return okMaestroResponse; }; + const grpcClient = makeFakeFactory(() => { + grpcCalled = true; + return makeFixedClient({ response: { hierarchy: loadFixture('grpc-response.xml') } }); + }); + + const res = await dump({ + execMaestro, + execAdb: adbWithForward(null), // no matching tcp:6790 line + getEnv: envFn({ ANDROID_SERIAL: 'serial' }), + grpcClient + }); + + expect(res.kind).toBe('hierarchy'); + expect(maestroCalled).toBe(true); + expect(grpcCalled).toBe(false); + }); + + it('PERCY_MAESTRO_GRPC=0 kill switch routes directly to maestro CLI; gRPC and port probe are skipped', async () => { + let maestroCalled = false; + let grpcCalled = false; + let probeCalled = false; + const execMaestro = async () => { maestroCalled = true; return okMaestroResponse; }; + const execAdb = async args => { + if (args.includes('forward')) probeCalled = true; + // Even if the probe is somehow called, return no port so the test + // still detects the bug via the maestroCalled assertion below. + return { stdout: '', stderr: '', exitCode: 0 }; + }; + const grpcClient = makeFakeFactory(() => { + grpcCalled = true; + return makeFixedClient({ response: { hierarchy: loadFixture('grpc-response.xml') } }); + }); + + const res = await dump({ + execMaestro, + execAdb, + getEnv: envFn({ ANDROID_SERIAL: 'serial', PERCY_MAESTRO_GRPC: '0' }), + grpcClient + }); + + expect(res.kind).toBe('hierarchy'); + expect(maestroCalled).toBe(true); + expect(grpcCalled).toBe(false); + expect(probeCalled).toBe(false); + }); + + it('PERCY_MAESTRO_GRPC unset (or non-zero) takes the gRPC primary path normally', async () => { + const execMaestro = async () => { throw new Error('should not be called'); }; + const grpcClient = makeFakeFactory(() => + makeFixedClient({ response: { hierarchy: loadFixture('grpc-response.xml') } }) + ); + + const res = await dump({ + execMaestro, + execAdb: adbWithForward(8206), + getEnv: envFn({ ANDROID_SERIAL: 'serial', PERCY_MAESTRO_GRPC: '1' }), + grpcClient + }); + + expect(res.kind).toBe('hierarchy'); + expect(grpcClient.created.length).toBe(1); + }); + }); + + describe('schema-drift dirty bit', () => { + it('sets schemaDriftSeen on first schema-class failure', async () => { + expect(getSchemaDriftSeen()).toBeNull(); + + const grpcClient = makeFakeFactory(() => + makeFixedClient({ error: { code: GRPC_STATUS.UNIMPLEMENTED, message: 'no rpc' } }) + ); + await runGrpcDump({ host: '127.0.0.1', port: 8206, grpcClient }); + + const drift = getSchemaDriftSeen(); + expect(drift).toBeTruthy(); + expect(drift.code).toBe(GRPC_STATUS.UNIMPLEMENTED); + expect(drift.reason).toBe('grpc-schema-unimplemented'); + expect(typeof drift.firstSeenAt).toBe('string'); + // ISO 8601 timestamp + expect(drift.firstSeenAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it('does not overwrite the first-seen drift on later failures', async () => { + const grpcClientFirst = makeFakeFactory(() => + makeFixedClient({ error: { code: GRPC_STATUS.UNIMPLEMENTED, message: 'first' } }) + ); + await runGrpcDump({ host: '127.0.0.1', port: 8206, grpcClient: grpcClientFirst }); + const first = getSchemaDriftSeen(); + + const grpcClientSecond = makeFakeFactory(() => + makeFixedClient({ error: { code: GRPC_STATUS.OUT_OF_RANGE, message: 'second' } }) + ); + await runGrpcDump({ host: '127.0.0.1', port: 8207, grpcClient: grpcClientSecond }); + + const second = getSchemaDriftSeen(); + // First-seen is preserved + expect(second.code).toBe(first.code); + expect(second.reason).toBe(first.reason); + expect(second.firstSeenAt).toBe(first.firstSeenAt); + }); + + it('does not set drift on connection-class failure', async () => { + const grpcClient = makeFakeFactory(() => + makeFixedClient({ error: { code: GRPC_STATUS.UNAVAILABLE, message: 'down' } }) + ); + await runGrpcDump({ host: '127.0.0.1', port: 8206, grpcClient }); + + expect(getSchemaDriftSeen()).toBeNull(); + }); + }); +}); diff --git a/packages/core/test/unit/maestro-hierarchy.test.js b/packages/core/test/unit/maestro-hierarchy.test.js index d60495694..fae44fa5f 100644 --- a/packages/core/test/unit/maestro-hierarchy.test.js +++ b/packages/core/test/unit/maestro-hierarchy.test.js @@ -2,17 +2,25 @@ import fs from 'fs'; import path from 'path'; import url from 'url'; import { dump, firstMatch } from '../../src/maestro-hierarchy.js'; -import { logger, setupTest } from '../helpers/index.js'; +import { setupTest } from '../helpers/index.js'; const fixtureDir = path.resolve(url.fileURLToPath(import.meta.url), '../../fixtures/maestro-hierarchy'); const loadFixture = name => fs.readFileSync(path.join(fixtureDir, name), 'utf8'); function makeFakeExecAdb(handlers) { // handlers: Array<{ match: (args) => boolean, result }> — ordered; first match wins per call. + // A default `forward --list` handler is appended so tests focused on the + // maestro+adb fallback chain don't need to stub the new gRPC port probe. + // Tests that exercise the probe directly should pass their own handler + // earlier in the list. const callLog = []; + const fullHandlers = [ + ...handlers, + { match: args => args.includes('forward') && args.includes('--list'), result: { stdout: '', stderr: '', exitCode: 0 } } + ]; const execAdb = async args => { callLog.push(args); - for (const { match, result } of handlers) { + for (const { match, result } of fullHandlers) { if (match(args)) return typeof result === 'function' ? result(args) : result; } throw new Error(`No fake handler matched adb args: ${JSON.stringify(args)}`); @@ -201,13 +209,18 @@ describe('Unit / maestro-hierarchy', () => { await dump({ execMaestro: maestroNotFound, execAdb, getEnv: k => (k === 'ANDROID_SERIAL' ? 'env-serial-123' : undefined) }); // adb devices should NOT have been called since env serial was present expect(execAdb.calls.some(args => args[0] === 'devices')).toBe(false); - expect(execAdb.calls[0]).toEqual(['-s', 'env-serial-123', 'exec-out', 'uiautomator', 'dump', '/dev/tty']); + // Both the gRPC port probe and the dump call must carry -s . + const dumpCall = execAdb.calls.find(args => args.includes('exec-out') && args.includes('/dev/tty')); + expect(dumpCall).toEqual(['-s', 'env-serial-123', 'exec-out', 'uiautomator', 'dump', '/dev/tty']); + const probeCall = execAdb.calls.find(args => args.includes('forward') && args.includes('--list')); + expect(probeCall).toEqual(['-s', 'env-serial-123', 'forward', '--list']); }); it('invokes fallback on empty stdout and returns hierarchy when fallback succeeds', async () => { let primaryCalled = false; const execAdb = async args => { if (args[0] === 'devices') return okDevices; + if (args.includes('forward') && args.includes('--list')) return { stdout: '', stderr: '', exitCode: 0 }; if (args.includes('exec-out') && args.includes('/dev/tty')) { primaryCalled = true; return { stdout: '', stderr: '', exitCode: 0 }; @@ -229,6 +242,7 @@ describe('Unit / maestro-hierarchy', () => { it('returns dump-error when both primary and fallback yield no XML', async () => { const execAdb = async args => { if (args[0] === 'devices') return okDevices; + if (args.includes('forward') && args.includes('--list')) return { stdout: '', stderr: '', exitCode: 0 }; if (args.includes('exec-out') && args.includes('/dev/tty')) return { stdout: 'garbage not xml', stderr: '', exitCode: 0 }; if (args.includes('shell') && args.includes('uiautomator')) return { stdout: '', stderr: '', exitCode: 0 }; if (args.includes('exec-out') && args.includes('cat')) return { stdout: 'still garbage', stderr: '', exitCode: 0 }; @@ -242,6 +256,7 @@ describe('Unit / maestro-hierarchy', () => { let fileDumpCalls = 0; const execAdb = async args => { if (args[0] === 'devices') return okDevices; + if (args.includes('forward') && args.includes('--list')) return { stdout: '', stderr: '', exitCode: 0 }; if (args.includes('exec-out') && args.includes('/dev/tty')) return { stdout: '', stderr: '', exitCode: 1 }; if (args.includes('shell') && args.includes('uiautomator')) { fileDumpCalls += 1; @@ -261,6 +276,7 @@ describe('Unit / maestro-hierarchy', () => { let fileDumpCalls = 0; const execAdb = async args => { if (args[0] === 'devices') return okDevices; + if (args.includes('forward') && args.includes('--list')) return { stdout: '', stderr: '', exitCode: 0 }; if (args.includes('exec-out') && args.includes('/dev/tty')) return { stdout: '', stderr: '', exitCode: 1 }; if (args.includes('shell') && args.includes('uiautomator')) { fileDumpCalls += 1; @@ -278,6 +294,7 @@ describe('Unit / maestro-hierarchy', () => { // Non-zero exit triggers fallback; fallback also returns garbage → terminal dump-error. const execAdb = async args => { if (args[0] === 'devices') return okDevices; + if (args.includes('forward') && args.includes('--list')) return { stdout: '', stderr: '', exitCode: 0 }; if (args.includes('exec-out') && args.includes('/dev/tty')) return { stdout: 'garbage', stderr: '', exitCode: 1 }; if (args.includes('shell') && args.includes('uiautomator')) return { stdout: '', stderr: '', exitCode: 0 }; if (args.includes('exec-out') && args.includes('cat')) return { stdout: 'still garbage', stderr: '', exitCode: 0 }; @@ -332,13 +349,17 @@ describe('Unit / maestro-hierarchy', () => { const maestroSimple = loadFixture('maestro-simple.json'); const okMaestro = { stdout: maestroSimple, stderr: '', exitCode: 0 }; - it('uses maestro hierarchy when available, skips adb fallback entirely', async () => { + it('uses maestro hierarchy fallback when gRPC port is not discoverable; skips adb dump entirely', async () => { const execMaestro = async args => { expect(args).toEqual(['--udid', 'env-serial-123', 'hierarchy']); return okMaestro; }; - // execAdb should never be called when maestro succeeds — fail loud if it is. - const execAdb = async args => { throw new Error('execAdb should not be called: ' + args.join(' ')); }; + // adb is touched only for the gRPC port probe (returns no port → fall through); + // exec-out / shell uiautomator must never be called. + const execAdb = async args => { + if (args.includes('forward') && args.includes('--list')) return { stdout: '', stderr: '', exitCode: 0 }; + throw new Error('execAdb should only be called for forward --list: ' + args.join(' ')); + }; const res = await dump({ execMaestro, @@ -353,9 +374,13 @@ describe('Unit / maestro-hierarchy', () => { it('maps accessibilityText to content-desc selector', async () => { const execMaestro = async () => okMaestro; - const execAdb = async () => { throw new Error('should not hit adb'); }; + const execAdb = async args => { + if (args.includes('forward') && args.includes('--list')) return { stdout: '', stderr: '', exitCode: 0 }; + throw new Error('should not hit adb beyond forward probe: ' + args.join(' ')); + }; const res = await dump({ - execMaestro, execAdb, + execMaestro, + execAdb, getEnv: k => (k === 'ANDROID_SERIAL' ? 'serial' : undefined) }); const bbox = firstMatch(res.nodes, { 'content-desc': 'Open settings' }); @@ -380,6 +405,7 @@ describe('Unit / maestro-hierarchy', () => { const execMaestro = async () => ({ stdout: '', stderr: 'Error: No connected devices', exitCode: 1 }); // adb fallback: exec-out returns no xml, file dump also kills → dump-error const execAdb = async args => { + if (args.includes('forward') && args.includes('--list')) return { stdout: '', stderr: '', exitCode: 0 }; if (args.includes('exec-out')) return { stdout: '', stderr: '', exitCode: 1 }; if (args.includes('shell')) return { stdout: '', stderr: '', exitCode: 1 }; throw new Error('unexpected: ' + args.join(' ')); @@ -408,7 +434,10 @@ describe('Unit / maestro-hierarchy', () => { stderr: '', exitCode: 0 }); - const execAdb = async () => { throw new Error('should not hit adb'); }; + const execAdb = async args => { + if (args.includes('forward') && args.includes('--list')) return { stdout: '', stderr: '', exitCode: 0 }; + throw new Error('should not hit adb beyond forward probe: ' + args.join(' ')); + }; const res = await dump({ execMaestro, execAdb, getEnv: () => 'serial' }); expect(res.kind).toBe('hierarchy'); expect(firstMatch(res.nodes, { 'resource-id': 'com.example:id/clock' })).not.toBeNull(); diff --git a/yarn.lock b/yarn.lock index 79c189f63..a27f35fcb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1143,6 +1143,24 @@ resolved "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz" integrity sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw== +"@grpc/grpc-js@^1.14.3": + version "1.14.3" + resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.14.3.tgz#4c9b817a900ae4020ddc28515ae4b52c78cfb8da" + integrity sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA== + dependencies: + "@grpc/proto-loader" "^0.8.0" + "@js-sdsl/ordered-map" "^4.4.2" + +"@grpc/proto-loader@^0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@grpc/proto-loader/-/proto-loader-0.8.0.tgz#b6c324dd909c458a0e4aa9bfd3d69cf78a4b9bd8" + integrity sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ== + dependencies: + lodash.camelcase "^4.3.0" + long "^5.0.0" + protobufjs "^7.5.3" + yargs "^17.7.2" + "@humanwhocodes/config-array@^0.5.0": version "0.5.0" resolved "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.5.0.tgz" @@ -1227,6 +1245,11 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@js-sdsl/ordered-map@^4.4.2": + version "4.4.2" + resolved "https://registry.yarnpkg.com/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz#9299f82874bab9e4c7f9c48d865becbfe8d6907c" + integrity sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw== + "@lerna/add@6.0.1": version "6.0.1" resolved "https://registry.npmjs.org/@lerna/add/-/add-6.0.1.tgz" @@ -2237,6 +2260,59 @@ dependencies: esquery "^1.0.1" +"@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf" + integrity sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ== + +"@protobufjs/base64@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/base64/-/base64-1.1.2.tgz#4c85730e59b9a1f1f349047dbf24296034bb2735" + integrity sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg== + +"@protobufjs/codegen@^2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@protobufjs/codegen/-/codegen-2.0.5.tgz#d9315ad7cf3f30aac70bda3c068443dc6f143659" + integrity sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g== + +"@protobufjs/eventemitter@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz#355cbc98bafad5978f9ed095f397621f1d066b70" + integrity sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q== + +"@protobufjs/fetch@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/fetch/-/fetch-1.1.0.tgz#ba99fb598614af65700c1619ff06d454b0d84c45" + integrity sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ== + dependencies: + "@protobufjs/aspromise" "^1.1.1" + "@protobufjs/inquire" "^1.1.0" + +"@protobufjs/float@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@protobufjs/float/-/float-1.0.2.tgz#5e9e1abdcb73fc0a7cb8b291df78c8cbd97b87d1" + integrity sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ== + +"@protobufjs/inquire@^1.1.0", "@protobufjs/inquire@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@protobufjs/inquire/-/inquire-1.1.1.tgz#6cb936f4ac50965230af1e9d0bbfd57ea3675aa4" + integrity sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew== + +"@protobufjs/path@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/path/-/path-1.1.2.tgz#6cc2b20c5c9ad6ad0dccfd21ca7673d8d7fbf68d" + integrity sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA== + +"@protobufjs/pool@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/pool/-/pool-1.1.0.tgz#09fd15f2d6d3abfa9b65bc366506d6ad7846ff54" + integrity sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw== + +"@protobufjs/utf8@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.1.tgz#eaee5900122c110a3dbcb728c0597014a2621774" + integrity sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg== + "@rollup/plugin-alias@^5.1.1": version "5.1.1" resolved "https://registry.npmjs.org/@rollup/plugin-alias/-/plugin-alias-5.1.1.tgz" @@ -2385,6 +2461,13 @@ resolved "https://registry.npmjs.org/@types/node/-/node-16.11.4.tgz" integrity sha512-TMgXmy0v2xWyuCSCJM6NCna2snndD8yvQF67J29ipdzMcsPa9u+o0tjF5+EQNdhcuZplYuouYqpc4zcd5I6amQ== +"@types/node@>=13.7.0": + version "25.6.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-25.6.0.tgz#4e09bad9b469871f2d0f68140198cbd714f4edca" + integrity sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ== + dependencies: + undici-types "~7.19.0" + "@types/normalize-package-data@^2.4.0": version "2.4.0" resolved "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz" @@ -3128,6 +3211,15 @@ cliui@^7.0.2: strip-ansi "^6.0.0" wrap-ansi "^7.0.0" +cliui@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" + integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.1" + wrap-ansi "^7.0.0" + clone-deep@^4.0.1: version "4.0.1" resolved "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz" @@ -5862,6 +5954,11 @@ locate-path@^5.0.0: dependencies: p-locate "^4.1.0" +lodash.camelcase@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" + integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA== + lodash.clonedeep@^4.5.0: version "4.5.0" resolved "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz" @@ -5930,6 +6027,11 @@ log4js@^6.4.1: rfdc "^1.3.0" streamroller "^3.0.2" +long@^5.0.0: + version "5.3.2" + resolved "https://registry.yarnpkg.com/long/-/long-5.3.2.tgz#1d84463095999262d7d7b7f8bfd4a8cc55167f83" + integrity sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA== + lru-cache@^10.2.0: version "10.4.3" resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz" @@ -7128,6 +7230,24 @@ proto-list@~1.2.1: resolved "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz" integrity sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk= +protobufjs@^7.5.3: + version "7.5.6" + resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-7.5.6.tgz#11af832ebc4b4326f658a5b1308e6141eb57edfd" + integrity sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg== + dependencies: + "@protobufjs/aspromise" "^1.1.2" + "@protobufjs/base64" "^1.1.2" + "@protobufjs/codegen" "^2.0.5" + "@protobufjs/eventemitter" "^1.1.0" + "@protobufjs/fetch" "^1.1.0" + "@protobufjs/float" "^1.0.2" + "@protobufjs/inquire" "^1.1.1" + "@protobufjs/path" "^1.1.2" + "@protobufjs/pool" "^1.1.0" + "@protobufjs/utf8" "^1.1.1" + "@types/node" ">=13.7.0" + long "^5.0.0" + protocols@^2.0.0, protocols@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/protocols/-/protocols-2.0.1.tgz" @@ -8276,6 +8396,11 @@ unbox-primitive@^1.0.2: has-symbols "^1.0.3" which-boxed-primitive "^1.0.2" +undici-types@~7.19.0: + version "7.19.2" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.19.2.tgz#1b67fc26d0f157a0cba3a58a5b5c1e2276b8ba2a" + integrity sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg== + unicode-canonical-property-names-ecmascript@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz" @@ -8651,6 +8776,11 @@ yargs-parser@^18.1.2: camelcase "^5.0.0" decamelize "^1.2.0" +yargs-parser@^21.1.1: + version "21.1.1" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" + integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== + yargs@^15.0.2: version "15.4.1" resolved "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz" @@ -8694,6 +8824,19 @@ yargs@^17.4.0: y18n "^5.0.5" yargs-parser "^21.0.0" +yargs@^17.7.2: + version "17.7.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" + integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== + dependencies: + cliui "^8.0.1" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.3" + y18n "^5.0.5" + yargs-parser "^21.1.1" + yauzl@^2.10.0: version "2.10.0" resolved "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz"