Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions packages/inspector/src/lib/locator-derivation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
});
});
64 changes: 59 additions & 5 deletions packages/inspector/src/lib/locator-derivation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -75,14 +81,62 @@ export function deriveLocator(node: ViewNode): Locator | null {
return deriveLocators(node)[0] ?? null;
}

type RoleLocator = Extract<Locator, { kind: 'role' }>;

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<string, number>();
const roleCounts = new Map<string, number>();
const groupCanUseRoles = new Map<string, boolean>();

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) {
Expand All @@ -93,5 +147,5 @@ export function deriveElementList(
}

walk(roots);
return result;
return preferUniqueRolesOverDuplicateTestIds(result);
}
Loading