diff --git a/packages/inspector/src/lib/locator-derivation.test.ts b/packages/inspector/src/lib/locator-derivation.test.ts index 9cd6101..686531f 100644 --- a/packages/inspector/src/lib/locator-derivation.test.ts +++ b/packages/inspector/src/lib/locator-derivation.test.ts @@ -269,3 +269,52 @@ test.describe('deriveElementList', () => { expect(roleName(result[2].locator)).toBe('Deep'); }); }); + +// ---- Duplicate test IDs ---- + +const SHARED_TEST_ID = 'PXGGridLayout-Info'; + +function primaryKinds(roots: ViewNode[]): (string | undefined)[] { + return deriveElementList(roots).map(entry => entry.locator?.kind); +} + +test.describe('deriveElementList — duplicate test IDs', () => { + test('shows role instead of testId when every duplicate has a unique role', () => { + const save = node({ type: 'button', identifier: SHARED_TEST_ID, label: 'Save' }); + const cancel = node({ type: 'button', identifier: SHARED_TEST_ID, label: 'Cancel' }); + + const result = deriveElementList([save, cancel]); + + expect(result.map(entry => roleName(entry.locator))).toEqual(['Save', 'Cancel']); + expect(result[0].locators.map(l => l.kind)).toEqual(['role', 'testId', 'label']); + }); + + test('keeps testId when two duplicates share the same role and name', () => { + const first = node({ type: 'button', identifier: SHARED_TEST_ID, label: 'Save' }); + const second = node({ type: 'button', identifier: SHARED_TEST_ID, label: 'Save' }); + const third = node({ type: 'button', identifier: SHARED_TEST_ID, label: 'Cancel' }); + + expect(primaryKinds([first, second, third])).toEqual(['testId', 'testId', 'testId']); + }); + + test('keeps testId when one duplicate has no role', () => { + const withRole = node({ type: 'button', identifier: SHARED_TEST_ID, label: 'Save' }); + const withoutRole = node({ type: 'unknownwidget', identifier: SHARED_TEST_ID }); + + expect(primaryKinds([withRole, withoutRole])).toEqual(['testId', 'testId']); + }); + + test('keeps testId when a role also matches an element outside the duplicate group', () => { + const save = node({ type: 'button', identifier: SHARED_TEST_ID, label: 'Save' }); + const cancel = node({ type: 'button', identifier: SHARED_TEST_ID, label: 'Cancel' }); + const otherSave = node({ type: 'button', identifier: 'other', label: 'Save' }); + + expect(primaryKinds([save, cancel, otherSave])).toEqual(['testId', 'testId', 'testId']); + }); + + test('leaves a unique testId alone', () => { + const only = node({ type: 'button', identifier: 'only-one', label: 'Save' }); + + expect(primaryKinds([only])).toEqual(['testId']); + }); +}); diff --git a/packages/inspector/src/lib/locator-derivation.ts b/packages/inspector/src/lib/locator-derivation.ts index 60e913d..65357d6 100644 --- a/packages/inspector/src/lib/locator-derivation.ts +++ b/packages/inspector/src/lib/locator-derivation.ts @@ -22,6 +22,12 @@ export type Locator = | { kind: 'label'; value: string } | { kind: 'text'; value: string }; +interface ElementEntry { + node: ViewNode; + locator: Locator | null; + locators: Locator[]; +} + /** Map node.type to a mobilewright role string. Returns null for unmapped types. */ function deriveRole(node: ViewNode): string | null { // Same normalization core applies before matching: strips the Android package @@ -75,14 +81,62 @@ export function deriveLocator(node: ViewNode): Locator | null { return deriveLocators(node)[0] ?? null; } +type RoleLocator = Extract; + +function roleKey(role: RoleLocator): string { + return `${role.value}:${role.name ?? ''}`; +} + +function roleLocatorOf(locators: Locator[]): RoleLocator | undefined { + return locators.find((l): l is RoleLocator => l.kind === 'role'); +} + +/** + * When several elements share a test ID, getByTestId cannot tell them apart. + * If every element of such a group has a role locator that matches nothing else + * in the tree, promote role to the primary locator for the whole group. + */ +function preferUniqueRolesOverDuplicateTestIds(entries: ElementEntry[]): ElementEntry[] { + const testIdCounts = new Map(); + const roleCounts = new Map(); + const groupCanUseRoles = new Map(); + + for (const { locator, locators } of entries) { + if (locator?.kind === 'testId') { + testIdCounts.set(locator.value, (testIdCounts.get(locator.value) ?? 0) + 1); + } + const role = roleLocatorOf(locators); + if (role) { + roleCounts.set(roleKey(role), (roleCounts.get(roleKey(role)) ?? 0) + 1); + } + } + + for (const { locator, locators } of entries) { + if (locator?.kind !== 'testId') { + continue; + } + const role = roleLocatorOf(locators); + const hasUniqueRole = role !== undefined && roleCounts.get(roleKey(role)) === 1; + groupCanUseRoles.set(locator.value, (groupCanUseRoles.get(locator.value) ?? true) && hasUniqueRole); + } + + return entries.map(entry => { + const { locator, locators } = entry; + const role = roleLocatorOf(locators); + const isDuplicateTestId = locator?.kind === 'testId' && (testIdCounts.get(locator.value) ?? 0) > 1; + if (!isDuplicateTestId || !role || !groupCanUseRoles.get(locator.value)) { + return entry; + } + return { ...entry, locator: role, locators: [role, ...locators.filter(l => l !== role)] }; + }); +} + /** * Flatten a ViewNode tree depth-first and annotate each node with its locators. * Nodes with no locatable field are included with locators: []. */ -export function deriveElementList( - roots: ViewNode[], -): Array<{ node: ViewNode; locator: Locator | null; locators: Locator[] }> { - const result: Array<{ node: ViewNode; locator: Locator | null; locators: Locator[] }> = []; +export function deriveElementList(roots: ViewNode[]): ElementEntry[] { + const result: ElementEntry[] = []; function walk(nodes: ViewNode[]): void { for (const node of nodes) { @@ -93,5 +147,5 @@ export function deriveElementList( } walk(roots); - return result; + return preferUniqueRolesOverDuplicateTestIds(result); }