Skip to content

Commit a02f396

Browse files
committed
feat: Phase 71 P5 — testID auto-discovery + CI headless mode (D587)
P5-1: cross_platform_verify gains scanDir param that auto-discovers testID="..." props from .tsx/.jsx/.ts/.js files via regex. Uses lstatSync to skip symlinks (Gemini review fix). Merges discovered IDs with manual elements[]. P5-2: bin/rn-verify — standalone CI script wrapping maestro-runner. Auto-detect platform, discover .maestro/flows, --timeout passed to runner (Codex review fix), --pattern, --stop-on-failure, exit codes. Multi-review fixes: lstatSync for symlinks, timeout passthrough, discoveredTestIDs count reports scan-only (not post-merge). 24 unit tests (4 new for discoverTestIDs). 7 bin/ commands.
1 parent 50fe9a4 commit a02f396

8 files changed

Lines changed: 322 additions & 10 deletions

File tree

bin/rn-verify

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../scripts/verify.sh

scripts/cdp-bridge/dist/index.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -335,8 +335,9 @@ trackedTool('maestro_test_all', 'Discover and run all Maestro flows in .maestro/
335335
timeoutPerFlow: z.number().int().min(5000).max(300000).default(120000).describe('Timeout per flow in ms'),
336336
stopOnFailure: z.boolean().default(false).describe('Stop after first failure'),
337337
}, createMaestroTestAllHandler());
338-
trackedTool('cross_platform_verify', 'Compare UI elements across iOS and Android. Reads cached accessibility snapshots from both platforms (populated by device_snapshot) and checks which elements are present on each. Workflow: test on iOS → device_snapshot → switch to Android → device_snapshot → cross_platform_verify. Returns a per-element comparison table with PASS/FAIL verdict.', {
339-
elements: z.array(z.string()).min(1).describe('List of testIDs or labels to check on both platforms'),
338+
trackedTool('cross_platform_verify', 'Compare UI elements across iOS and Android. Reads cached accessibility snapshots from both platforms (populated by device_snapshot) and checks which elements are present on each. Workflow: test on iOS → device_snapshot → switch to Android → device_snapshot → cross_platform_verify. Supports auto-discovery of testIDs from source via scanDir. Returns a per-element comparison table with PASS/FAIL verdict.', {
339+
elements: z.array(z.string()).optional().describe('List of testIDs or labels to check on both platforms. Optional if scanDir is provided.'),
340+
scanDir: z.string().optional().describe('Directory to scan for testID="..." props in .tsx/.jsx/.ts/.js files. Auto-discovers elements. Merges with elements[] if both provided.'),
340341
matchBy: z.enum(['testID', 'label', 'any']).default('any').describe('Match strategy: testID (exact identifier match), label (substring in accessibility label), any (try both)'),
341342
}, createCrossPlatformVerifyHandler());
342343
process.on('uncaughtException', (err) => {

scripts/cdp-bridge/dist/tools/cross-platform-verify.js

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { readFileSync, readdirSync, lstatSync } from 'node:fs';
2+
import { join, extname } from 'node:path';
13
import { getCachedSnapshot } from '../agent-device-wrapper.js';
24
import { okResult, failResult, warnResult } from '../utils.js';
35
export function findElement(nodes, query, matchBy) {
@@ -10,8 +12,60 @@ export function findElement(nodes, query, matchBy) {
1012
return (n.identifier?.toLowerCase() === q) || (n.label?.toLowerCase().includes(q) ?? false);
1113
});
1214
}
15+
const TESTID_RE = /testID\s*=\s*(?:"([^"]+)"|'([^']+)'|\{["']([^"']+)["']\})/g;
16+
const SCAN_EXTENSIONS = new Set(['.tsx', '.jsx', '.ts', '.js']);
17+
export function discoverTestIDs(dir) {
18+
const ids = new Set();
19+
function walk(d) {
20+
let entries;
21+
try {
22+
entries = readdirSync(d);
23+
}
24+
catch {
25+
return;
26+
}
27+
for (const entry of entries) {
28+
if (entry === 'node_modules' || entry.startsWith('.'))
29+
continue;
30+
const full = join(d, entry);
31+
try {
32+
const st = lstatSync(full);
33+
if (st.isSymbolicLink())
34+
continue;
35+
if (st.isDirectory()) {
36+
walk(full);
37+
continue;
38+
}
39+
if (!SCAN_EXTENSIONS.has(extname(entry)))
40+
continue;
41+
const src = readFileSync(full, 'utf8');
42+
for (const m of src.matchAll(TESTID_RE)) {
43+
const id = m[1] ?? m[2] ?? m[3];
44+
if (id)
45+
ids.add(id);
46+
}
47+
}
48+
catch { /* skip unreadable files */ }
49+
}
50+
}
51+
walk(dir);
52+
return [...ids].sort();
53+
}
1354
export function createCrossPlatformVerifyHandler() {
1455
return async (args) => {
56+
let elements = args.elements;
57+
let discoveredCount = 0;
58+
if (args.scanDir) {
59+
const discovered = discoverTestIDs(args.scanDir);
60+
if (discovered.length === 0) {
61+
return failResult(`No testIDs found in ${args.scanDir}. Ensure components use testID="..." props.`);
62+
}
63+
discoveredCount = discovered.length;
64+
elements = elements ? [...new Set([...elements, ...discovered])] : discovered;
65+
}
66+
if (!elements || elements.length === 0) {
67+
return failResult('Provide elements[] or scanDir to discover testIDs from source.');
68+
}
1569
const matchBy = args.matchBy ?? 'any';
1670
const iosSnap = getCachedSnapshot('ios');
1771
const androidSnap = getCachedSnapshot('android');
@@ -22,7 +76,7 @@ export function createCrossPlatformVerifyHandler() {
2276
const results = [];
2377
let iosFound = 0;
2478
let androidFound = 0;
25-
for (const el of args.elements) {
79+
for (const el of elements) {
2680
const iosStatus = !iosSnap ? 'NO_SNAPSHOT' : findElement(iosSnap.nodes, el, matchBy) ? 'FOUND' : 'MISSING';
2781
const androidStatus = !androidSnap ? 'NO_SNAPSHOT' : findElement(androidSnap.nodes, el, matchBy) ? 'FOUND' : 'MISSING';
2882
if (iosStatus === 'FOUND')
@@ -37,7 +91,7 @@ export function createCrossPlatformVerifyHandler() {
3791
});
3892
}
3993
const missing = results.filter(r => !r.match || r.ios === 'MISSING' || r.android === 'MISSING');
40-
const total = args.elements.length;
94+
const total = elements.length;
4195
const allMatch = missing.length === 0 && iosSnap != null && androidSnap != null;
4296
const summary = {
4397
verdict: allMatch ? 'PASS' : 'FAIL',
@@ -48,6 +102,7 @@ export function createCrossPlatformVerifyHandler() {
48102
iosCapturedAt: iosSnap?.capturedAt ?? null,
49103
androidCapturedAt: androidSnap?.capturedAt ?? null,
50104
matchBy,
105+
...(args.scanDir ? { scannedDir: args.scanDir, discoveredTestIDs: discoveredCount } : {}),
51106
results,
52107
};
53108
if (!iosSnap || !androidSnap) {

scripts/cdp-bridge/src/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -608,9 +608,10 @@ trackedTool(
608608

609609
trackedTool(
610610
'cross_platform_verify',
611-
'Compare UI elements across iOS and Android. Reads cached accessibility snapshots from both platforms (populated by device_snapshot) and checks which elements are present on each. Workflow: test on iOS → device_snapshot → switch to Android → device_snapshot → cross_platform_verify. Returns a per-element comparison table with PASS/FAIL verdict.',
611+
'Compare UI elements across iOS and Android. Reads cached accessibility snapshots from both platforms (populated by device_snapshot) and checks which elements are present on each. Workflow: test on iOS → device_snapshot → switch to Android → device_snapshot → cross_platform_verify. Supports auto-discovery of testIDs from source via scanDir. Returns a per-element comparison table with PASS/FAIL verdict.',
612612
{
613-
elements: z.array(z.string()).min(1).describe('List of testIDs or labels to check on both platforms'),
613+
elements: z.array(z.string()).optional().describe('List of testIDs or labels to check on both platforms. Optional if scanDir is provided.'),
614+
scanDir: z.string().optional().describe('Directory to scan for testID="..." props in .tsx/.jsx/.ts/.js files. Auto-discovers elements. Merges with elements[] if both provided.'),
614615
matchBy: z.enum(['testID', 'label', 'any']).default('any').describe('Match strategy: testID (exact identifier match), label (substring in accessibility label), any (try both)'),
615616
},
616617
createCrossPlatformVerifyHandler(),

scripts/cdp-bridge/src/tools/cross-platform-verify.ts

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
import { readFileSync, readdirSync, lstatSync } from 'node:fs';
2+
import { join, extname } from 'node:path';
13
import { getCachedSnapshot } from '../agent-device-wrapper.js';
24
import type { ToolResult } from '../utils.js';
35
import { okResult, failResult, warnResult } from '../utils.js';
46

57
interface VerifyArgs {
6-
elements: string[];
8+
elements?: string[];
9+
scanDir?: string;
710
matchBy?: 'testID' | 'label' | 'any';
811
}
912

@@ -27,8 +30,56 @@ export function findElement(
2730
});
2831
}
2932

33+
const TESTID_RE = /testID\s*=\s*(?:"([^"]+)"|'([^']+)'|\{["']([^"']+)["']\})/g;
34+
const SCAN_EXTENSIONS = new Set(['.tsx', '.jsx', '.ts', '.js']);
35+
36+
export function discoverTestIDs(dir: string): string[] {
37+
const ids = new Set<string>();
38+
39+
function walk(d: string): void {
40+
let entries: string[];
41+
try { entries = readdirSync(d); } catch { return; }
42+
for (const entry of entries) {
43+
if (entry === 'node_modules' || entry.startsWith('.')) continue;
44+
const full = join(d, entry);
45+
try {
46+
const st = lstatSync(full);
47+
if (st.isSymbolicLink()) continue;
48+
if (st.isDirectory()) { walk(full); continue; }
49+
if (!SCAN_EXTENSIONS.has(extname(entry))) continue;
50+
const src = readFileSync(full, 'utf8');
51+
for (const m of src.matchAll(TESTID_RE)) {
52+
const id = m[1] ?? m[2] ?? m[3];
53+
if (id) ids.add(id);
54+
}
55+
} catch { /* skip unreadable files */ }
56+
}
57+
}
58+
59+
walk(dir);
60+
return [...ids].sort();
61+
}
62+
3063
export function createCrossPlatformVerifyHandler(): (args: VerifyArgs) => Promise<ToolResult> {
3164
return async (args) => {
65+
let elements = args.elements;
66+
67+
let discoveredCount = 0;
68+
if (args.scanDir) {
69+
const discovered = discoverTestIDs(args.scanDir);
70+
if (discovered.length === 0) {
71+
return failResult(
72+
`No testIDs found in ${args.scanDir}. Ensure components use testID="..." props.`,
73+
);
74+
}
75+
discoveredCount = discovered.length;
76+
elements = elements ? [...new Set([...elements, ...discovered])] : discovered;
77+
}
78+
79+
if (!elements || elements.length === 0) {
80+
return failResult('Provide elements[] or scanDir to discover testIDs from source.');
81+
}
82+
3283
const matchBy = args.matchBy ?? 'any';
3384
const iosSnap = getCachedSnapshot('ios');
3485
const androidSnap = getCachedSnapshot('android');
@@ -45,7 +96,7 @@ export function createCrossPlatformVerifyHandler(): (args: VerifyArgs) => Promis
4596
let iosFound = 0;
4697
let androidFound = 0;
4798

48-
for (const el of args.elements) {
99+
for (const el of elements) {
49100
const iosStatus: ElementResult['ios'] = !iosSnap ? 'NO_SNAPSHOT' : findElement(iosSnap.nodes, el, matchBy) ? 'FOUND' : 'MISSING';
50101
const androidStatus: ElementResult['android'] = !androidSnap ? 'NO_SNAPSHOT' : findElement(androidSnap.nodes, el, matchBy) ? 'FOUND' : 'MISSING';
51102

@@ -61,7 +112,7 @@ export function createCrossPlatformVerifyHandler(): (args: VerifyArgs) => Promis
61112
}
62113

63114
const missing = results.filter(r => !r.match || r.ios === 'MISSING' || r.android === 'MISSING');
64-
const total = args.elements.length;
115+
const total = elements.length;
65116
const allMatch = missing.length === 0 && iosSnap != null && androidSnap != null;
66117

67118
const summary = {
@@ -73,6 +124,7 @@ export function createCrossPlatformVerifyHandler(): (args: VerifyArgs) => Promis
73124
iosCapturedAt: iosSnap?.capturedAt ?? null,
74125
androidCapturedAt: androidSnap?.capturedAt ?? null,
75126
matchBy,
127+
...(args.scanDir ? { scannedDir: args.scanDir, discoveredTestIDs: discoveredCount } : {}),
76128
results,
77129
};
78130

scripts/cdp-bridge/test/cross-platform-verify.test.js

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { test } from 'node:test';
22
import assert from 'node:assert/strict';
3-
import { findElement } from '../dist/tools/cross-platform-verify.js';
3+
import { join, dirname } from 'node:path';
4+
import { fileURLToPath } from 'node:url';
5+
import { findElement, discoverTestIDs } from '../dist/tools/cross-platform-verify.js';
6+
7+
const __dirname = dirname(fileURLToPath(import.meta.url));
8+
9+
// ── findElement ────────────────────────────────────────────────────────
410

511
const NODES = [
612
{ ref: '@1', label: 'Submit Button', identifier: 'submit-btn', type: 'Button' },
@@ -40,3 +46,32 @@ test('findElement on empty nodes returns false', () => {
4046
assert.equal(findElement([], 'anything', 'testID'), false);
4147
assert.equal(findElement([], 'anything', 'label'), false);
4248
});
49+
50+
// ── discoverTestIDs ────────────────────────────────────────────────────
51+
52+
test('discoverTestIDs extracts testIDs from .tsx fixture', () => {
53+
const fixtureDir = join(__dirname, 'fixtures', 'scan-test');
54+
const ids = discoverTestIDs(fixtureDir);
55+
assert.ok(ids.includes('example-screen'), 'should find testID="example-screen"');
56+
assert.ok(ids.includes('title-text'), 'should find testID="title-text"');
57+
assert.ok(ids.includes('submit-btn'), "should find testID='submit-btn'");
58+
assert.ok(ids.includes('dynamic-view'), 'should find testID={"dynamic-view"}');
59+
assert.equal(ids.length, 4);
60+
});
61+
62+
test('discoverTestIDs returns sorted results', () => {
63+
const fixtureDir = join(__dirname, 'fixtures', 'scan-test');
64+
const ids = discoverTestIDs(fixtureDir);
65+
const sorted = [...ids].sort();
66+
assert.deepEqual(ids, sorted);
67+
});
68+
69+
test('discoverTestIDs returns empty array for nonexistent dir', () => {
70+
const ids = discoverTestIDs('/nonexistent/path/that/does/not/exist');
71+
assert.deepEqual(ids, []);
72+
});
73+
74+
test('discoverTestIDs skips node_modules and dotfiles', () => {
75+
const ids = discoverTestIDs(join(__dirname, '..'));
76+
assert.ok(!ids.some(id => id.includes('node_modules')));
77+
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import React from 'react';
2+
import { View, Text, Pressable } from 'react-native';
3+
4+
export function Example() {
5+
return (
6+
<View testID="example-screen">
7+
<Text testID="title-text">Hello</Text>
8+
<Pressable testID='submit-btn' onPress={() => {}}>
9+
<Text>Submit</Text>
10+
</Pressable>
11+
<View testID={"dynamic-view"}>
12+
<Text>No testID here</Text>
13+
</View>
14+
</View>
15+
);
16+
}

0 commit comments

Comments
 (0)