mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
fix(maestro): restore conformance invariants (#1889)
* fix(maestro): restore conformance invariants * fix(maestro): use exact iOS presentation mappings
This commit is contained in:
committed by
GitHub
parent
e65443d774
commit
2d7a310dd0
@@ -0,0 +1,6 @@
|
||||
.claude/**
|
||||
dist/**
|
||||
node_modules/**
|
||||
**/*.md
|
||||
scripts/maestro-conformance/corpus/**
|
||||
fallow-baselines/**
|
||||
@@ -213,8 +213,8 @@ test('iOS target resolution preserves distinct nested controls matched by one ex
|
||||
).toMatchObject({ ok: true, node: { index: 1 }, matches: 2 });
|
||||
});
|
||||
|
||||
test('iOS target resolution keeps raw geometry bound to the canonical source node', () => {
|
||||
const interactionSnapshot = makeSnapshot([
|
||||
test('iOS target resolution keeps semantic identity bound to presented geometry', () => {
|
||||
const semanticSnapshot = makeSnapshot([
|
||||
{
|
||||
index: 0,
|
||||
type: 'Application',
|
||||
@@ -235,31 +235,85 @@ test('iOS target resolution keeps raw geometry bound to the canonical source nod
|
||||
rect: { x: 200, y: 100, width: 80, height: 48 },
|
||||
},
|
||||
]);
|
||||
const canonicalSnapshot = makeSnapshot([
|
||||
interactionSnapshot.nodes[0]!,
|
||||
{ ...interactionSnapshot.nodes[2]!, index: 1 },
|
||||
const presentationSnapshot = makeSnapshot([
|
||||
semanticSnapshot.nodes[0]!,
|
||||
{ ...semanticSnapshot.nodes[2]!, index: 1 },
|
||||
]);
|
||||
|
||||
expect(
|
||||
resolveMaestroTargetFromSnapshot(
|
||||
canonicalSnapshot,
|
||||
semanticSnapshot,
|
||||
{ selector: { text: 'Save' }, allowAtomicSelectorDispatch: true },
|
||||
'ios',
|
||||
{
|
||||
interactiveBounds: true,
|
||||
interaction: {
|
||||
snapshot: interactionSnapshot,
|
||||
presentation: {
|
||||
snapshot: presentationSnapshot,
|
||||
sourceIndexes: new Map([
|
||||
[0, 0],
|
||||
[1, 2],
|
||||
]),
|
||||
presentedIndexesBySourceIndex: new Map([
|
||||
[0, [0]],
|
||||
[1, []],
|
||||
[2, [1]],
|
||||
]),
|
||||
},
|
||||
},
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
node: { index: 2 },
|
||||
rect: { x: 200, y: 100, width: 80, height: 48 },
|
||||
dispatchCandidates: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('iOS target resolution uses one presented candidate universe for geometry and dispatch', () => {
|
||||
const semanticSnapshot = makeSnapshot([
|
||||
{
|
||||
index: 0,
|
||||
type: 'Button',
|
||||
identifier: 'save',
|
||||
rect: { x: 0, y: 100, width: 80, height: 48 },
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
type: 'Button',
|
||||
identifier: 'save',
|
||||
rect: { x: 200, y: 100, width: 80, height: 48 },
|
||||
},
|
||||
]);
|
||||
const presentationSnapshot = makeSnapshot([
|
||||
{
|
||||
...semanticSnapshot.nodes[1]!,
|
||||
index: 0,
|
||||
rect: { x: 220, y: 120, width: 80, height: 48 },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
resolveMaestroTargetFromSnapshot(
|
||||
semanticSnapshot,
|
||||
{ selector: { id: 'save' }, allowAtomicSelectorDispatch: true },
|
||||
'ios',
|
||||
{
|
||||
interactiveBounds: true,
|
||||
presentation: {
|
||||
snapshot: presentationSnapshot,
|
||||
sourceIndexes: new Map([[0, 1]]),
|
||||
presentedIndexesBySourceIndex: new Map([
|
||||
[0, []],
|
||||
[1, [0]],
|
||||
]),
|
||||
},
|
||||
},
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
node: { index: 1 },
|
||||
rect: { x: 200, y: 100, width: 80, height: 48 },
|
||||
dispatchCandidates: 2,
|
||||
rect: { x: 220, y: 120, width: 80, height: 48 },
|
||||
matches: 1,
|
||||
dispatchCandidates: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,28 +17,37 @@ export type MaestroRankedCandidates = {
|
||||
readonly parentMatched: boolean;
|
||||
};
|
||||
|
||||
export type MaestroCandidateMatches = Pick<MaestroRankedCandidates, 'matches' | 'parentMatched'>;
|
||||
|
||||
export function rankMaestroCandidates(
|
||||
snapshot: SnapshotState,
|
||||
selector: MaestroSelector,
|
||||
platform: MaestroPlatform,
|
||||
childOf?: MaestroSelector,
|
||||
): MaestroRankedCandidates {
|
||||
const matches = snapshot.nodes.filter((node) => matchesMaestroTypedSelector(node, selector));
|
||||
const scoped = scopeMatchesByAncestor(snapshot, matches, childOf);
|
||||
const scoped = matchMaestroCandidates(snapshot, selector, childOf);
|
||||
const visible = filterVisibleMaestroMatches({
|
||||
nodes: snapshot.nodes,
|
||||
matches: scoped.matches,
|
||||
platform,
|
||||
});
|
||||
return {
|
||||
matches: scoped.matches,
|
||||
...scoped,
|
||||
visible,
|
||||
ranked: normalizeMaestroSnapshotMatches(snapshot.nodes, visible, selector, platform),
|
||||
parentMatched: scoped.parentMatched,
|
||||
ranked: rankVisibleMaestroMatches(snapshot.nodes, visible, selector, platform),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMaestroSnapshotMatches(
|
||||
export function matchMaestroCandidates(
|
||||
snapshot: SnapshotState,
|
||||
selector: MaestroSelector,
|
||||
childOf?: MaestroSelector,
|
||||
): MaestroCandidateMatches {
|
||||
const matches = snapshot.nodes.filter((node) => matchesMaestroTypedSelector(node, selector));
|
||||
return scopeMatchesByAncestor(snapshot, matches, childOf);
|
||||
}
|
||||
|
||||
export function rankVisibleMaestroMatches(
|
||||
nodes: SnapshotNode[],
|
||||
matches: SnapshotNode[],
|
||||
selector: MaestroSelector,
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import type { Rect, SnapshotNode, SnapshotState } from '@agent-device/kernel/snapshot';
|
||||
import type { MaestroSelector } from './program-ir.ts';
|
||||
import type { MaestroPlatform } from './runtime-target-policy.ts';
|
||||
import { rankMaestroCandidates, selectMaestroSnapshotMatch } from './runtime-target-ranking.ts';
|
||||
import {
|
||||
matchMaestroCandidates,
|
||||
rankMaestroCandidates,
|
||||
rankVisibleMaestroMatches,
|
||||
selectMaestroSnapshotMatch,
|
||||
} from './runtime-target-ranking.ts';
|
||||
import { pointInsideRect, stripUndefined } from './shared.ts';
|
||||
import { isDescendantOfSnapshotNode } from './snapshot-policy.ts';
|
||||
import { isMaestroNodeVisible } from './snapshot-policy.ts';
|
||||
import { buildSnapshotNodeMap } from '@agent-device/contracts/snapshot';
|
||||
|
||||
export type MaestroTargetQuery = {
|
||||
@@ -22,8 +27,9 @@ export type MaestroTargetEvidence = {
|
||||
ref?: string;
|
||||
};
|
||||
|
||||
type MaestroInteractionProjection = {
|
||||
type MaestroInteractivePresentation = {
|
||||
snapshot: SnapshotState;
|
||||
presentedIndexesBySourceIndex: ReadonlyMap<number, number[]>;
|
||||
sourceIndexes: ReadonlyMap<number, number>;
|
||||
};
|
||||
|
||||
@@ -44,10 +50,15 @@ export function resolveMaestroTargetFromSnapshot(
|
||||
platform: MaestroPlatform,
|
||||
options: {
|
||||
interactiveBounds?: boolean;
|
||||
interaction?: MaestroInteractionProjection;
|
||||
presentation?: MaestroInteractivePresentation;
|
||||
} = {},
|
||||
): MaestroTargetResolution {
|
||||
const candidates = rankMaestroCandidates(snapshot, query.selector, platform, query.childOf);
|
||||
const presentedNodes = options.presentation
|
||||
? createPresentedNodeLookup(options.presentation)
|
||||
: undefined;
|
||||
const candidates = presentedNodes
|
||||
? rankPresentedMaestroCandidates(snapshot, query, platform, presentedNodes)
|
||||
: rankMaestroCandidates(snapshot, query.selector, platform, query.childOf);
|
||||
if (!candidates.parentMatched) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -61,49 +72,58 @@ export function resolveMaestroTargetFromSnapshot(
|
||||
if (!target) {
|
||||
return failedTargetResolution(query, matches, rankedMatches, evidence);
|
||||
}
|
||||
const interactionCandidates = resolveInteractionCandidates(target.node, query, platform, options);
|
||||
const interactionTarget = interactionCandidates
|
||||
? selectMaestroSnapshotMatch(interactionCandidates.mapped, undefined)
|
||||
: undefined;
|
||||
const presentedTarget = presentedNodes
|
||||
? selectMaestroSnapshotMatch(presentedNodes.forSource(target.node), undefined)
|
||||
: null;
|
||||
const rect =
|
||||
options.interactiveBounds === true ? (interactionTarget?.rect ?? target.rect) : target.rect;
|
||||
options.interactiveBounds === true ? (presentedTarget?.rect ?? target.rect) : target.rect;
|
||||
return {
|
||||
ok: true,
|
||||
node: target.node,
|
||||
rect,
|
||||
matches: rankedMatches.length,
|
||||
dispatchCandidates:
|
||||
platform === 'ios' && query.allowAtomicSelectorDispatch && !query.childOf
|
||||
? countInteractionDispatchCandidates(target, interactionCandidates)
|
||||
platform === 'ios' && query.allowAtomicSelectorDispatch && !query.childOf && presentedNodes
|
||||
? countInteractionDispatchCandidates(target, rankedMatches, presentedTarget)
|
||||
: 0,
|
||||
evidence,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveInteractionCandidates(
|
||||
canonicalTarget: SnapshotNode,
|
||||
function createPresentedNodeLookup(presentation: MaestroInteractivePresentation): {
|
||||
isVisible: (node: SnapshotNode) => boolean;
|
||||
forSource: (node: SnapshotNode) => SnapshotNode[];
|
||||
} {
|
||||
const presentedByIndex = buildSnapshotNodeMap(presentation.snapshot.nodes);
|
||||
const forSource = (semanticNode: SnapshotNode): SnapshotNode[] => {
|
||||
return (presentation.presentedIndexesBySourceIndex.get(semanticNode.index) ?? []).flatMap(
|
||||
(presentedIndex) => {
|
||||
const node = presentedByIndex.get(presentedIndex);
|
||||
return node ? [node] : [];
|
||||
},
|
||||
);
|
||||
};
|
||||
return {
|
||||
forSource,
|
||||
isVisible: (node) =>
|
||||
forSource(node).some((presentedNode) =>
|
||||
isMaestroNodeVisible(presentedNode, presentation.snapshot.nodes, 'ios'),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function rankPresentedMaestroCandidates(
|
||||
snapshot: SnapshotState,
|
||||
query: MaestroTargetQuery,
|
||||
platform: MaestroPlatform,
|
||||
options: {
|
||||
interaction?: MaestroInteractionProjection;
|
||||
},
|
||||
): { all: SnapshotNode[]; mapped: SnapshotNode[] } | undefined {
|
||||
const interaction = options.interaction;
|
||||
if (!interaction) return undefined;
|
||||
const { snapshot, sourceIndexes } = interaction;
|
||||
const sourceIndex = sourceIndexes.get(canonicalTarget.index);
|
||||
if (sourceIndex === undefined) return undefined;
|
||||
const byIndex = buildSnapshotNodeMap(snapshot.nodes);
|
||||
const source = byIndex.get(sourceIndex);
|
||||
if (!source) return undefined;
|
||||
const all = rankMaestroCandidates(snapshot, query.selector, platform, query.childOf).ranked;
|
||||
presentedNodes: ReturnType<typeof createPresentedNodeLookup>,
|
||||
) {
|
||||
const scoped = matchMaestroCandidates(snapshot, query.selector, query.childOf);
|
||||
const visible = scoped.matches.filter(presentedNodes.isVisible);
|
||||
return {
|
||||
all,
|
||||
mapped: all.filter(
|
||||
(candidate) =>
|
||||
candidate.index === source.index ||
|
||||
isDescendantOfSnapshotNode(snapshot.nodes, candidate, source, byIndex),
|
||||
),
|
||||
...scoped,
|
||||
visible,
|
||||
ranked: rankVisibleMaestroMatches(snapshot.nodes, visible, query.selector, platform),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -126,14 +146,13 @@ function failedTargetResolution(
|
||||
|
||||
function countInteractionDispatchCandidates(
|
||||
target: { node: SnapshotNode; rect: Rect },
|
||||
interactionCandidates: { all: SnapshotNode[]; mapped: SnapshotNode[] } | undefined,
|
||||
rankedCandidates: SnapshotNode[],
|
||||
presentedTarget: { node: SnapshotNode; rect: Rect } | null,
|
||||
): number {
|
||||
if (!interactionCandidates) return 0;
|
||||
if (interactionCandidates.all.length !== 1) return interactionCandidates.all.length;
|
||||
const interactionTarget = selectMaestroSnapshotMatch(interactionCandidates.mapped, undefined);
|
||||
return interactionTarget &&
|
||||
interactionTarget.node.hittable !== false &&
|
||||
haveSameTapPoint(interactionTarget.rect, target.rect)
|
||||
if (rankedCandidates.length !== 1) return rankedCandidates.length;
|
||||
return presentedTarget &&
|
||||
presentedTarget.node.hittable !== false &&
|
||||
haveSameTapPoint(presentedTarget.rect, target.rect)
|
||||
? 1
|
||||
: 0;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
CONFORMANCE_DATA_DIR,
|
||||
CORPUS_DIR,
|
||||
checkCoverage,
|
||||
checkCorpusSeals,
|
||||
checkFixtureSeals,
|
||||
checkLayer2,
|
||||
classifyAllFlows,
|
||||
@@ -43,6 +44,15 @@ test('fixture content is sealed against hand editing', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('vendored upstream corpus content matches its manifest sha256', () => {
|
||||
const drifted = checkCorpusSeals().filter((result) => !result.sealed);
|
||||
assert.deepEqual(
|
||||
drifted,
|
||||
[],
|
||||
'upstream corpus content drifted — restore the vendored bytes or regenerate from the pinned upstream commit',
|
||||
);
|
||||
});
|
||||
|
||||
// The seal is only worth having if it actually catches an edit. Prove it does,
|
||||
// rather than trusting that a hash comparison must work.
|
||||
test('the seal rejects an edited capture (proof the check has teeth)', () => {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
// flow our engine parses, or be explicitly listed as unverified.
|
||||
// - Bug classes: the four #1217 regressions each assert against their fixture.
|
||||
import fs from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { AppError } from '@agent-device/kernel/errors';
|
||||
@@ -76,6 +77,13 @@ export function loadLayer2(): Layer2Fixture {
|
||||
|
||||
export type SealResult = { file: string; sealed: boolean; expected: string; actual?: string };
|
||||
|
||||
type CorpusManifest = {
|
||||
flows: Array<{
|
||||
file: string;
|
||||
origin: { kind: 'authored'; note: string } | { kind: 'upstream'; sha256: string };
|
||||
}>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Recompute each fixture's content seal. This is what makes "generated from
|
||||
* upstream" an enforced property rather than a claim in a README: editing a
|
||||
@@ -89,6 +97,25 @@ export function checkFixtureSeals(): SealResult[] {
|
||||
});
|
||||
}
|
||||
|
||||
/** Verify vendored upstream source bytes in normal, Java-free per-PR CI. */
|
||||
export function checkCorpusSeals(): SealResult[] {
|
||||
const manifest = readJson<CorpusManifest>(path.join(CORPUS_DIR, 'manifest.json'));
|
||||
return manifest.flows.flatMap((flow) => {
|
||||
if (flow.origin.kind !== 'upstream') return [];
|
||||
const actual = createHash('sha256')
|
||||
.update(fs.readFileSync(path.join(CORPUS_DIR, flow.file)))
|
||||
.digest('hex');
|
||||
return [
|
||||
{
|
||||
file: flow.file,
|
||||
sealed: actual === flow.origin.sha256,
|
||||
expected: flow.origin.sha256,
|
||||
actual,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Error codes that count as a deliberate parser rejection. Every rejection the
|
||||
* Maestro parser raises — unsupported command/option, bad value, and even a YAML
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
appId: com.example.app
|
||||
---
|
||||
- doubleTapOn: 'Button'
|
||||
- doubleTapOn: "Button"
|
||||
|
||||
@@ -2,9 +2,9 @@ appId: com.example.app
|
||||
---
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: 'Item'
|
||||
id: "Item"
|
||||
timeout: 1000
|
||||
- extendedWaitUntil:
|
||||
notVisible:
|
||||
id: 'Another'
|
||||
id: "Another"
|
||||
timeout: 1000
|
||||
|
||||
@@ -3,4 +3,4 @@ appId: com.example.app
|
||||
- repeat:
|
||||
times: 3
|
||||
commands:
|
||||
- tapOn: 'Button'
|
||||
- tapOn: "Button"
|
||||
|
||||
@@ -2,4 +2,4 @@ appId: com.example.include
|
||||
---
|
||||
- launchApp
|
||||
- tapOn:
|
||||
id: 'included-button'
|
||||
id: "included-button"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
appId: com.example.app
|
||||
---
|
||||
- tapOn: 'Before'
|
||||
- tapOn: "Before"
|
||||
- runFlow: runflow-child.yaml
|
||||
- tapOn: 'After'
|
||||
- tapOn: "After"
|
||||
|
||||
@@ -2,6 +2,6 @@ appId: com.example.app
|
||||
---
|
||||
- scrollUntilVisible:
|
||||
element:
|
||||
text: 'Test'
|
||||
text: "Test"
|
||||
direction: DOWN
|
||||
timeout: 10000
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
appId: com.example.app
|
||||
---
|
||||
- swipe:
|
||||
start: '50.5%, 50%'
|
||||
end: '10%, 50%'
|
||||
start: "50.5%, 50%"
|
||||
end: "10%, 50%"
|
||||
|
||||
@@ -6,4 +6,4 @@ appId: com.example.app
|
||||
- retry:
|
||||
maxRetries: 99
|
||||
commands:
|
||||
- tapOn: 'Retry'
|
||||
- tapOn: "Retry"
|
||||
|
||||
@@ -4,4 +4,4 @@
|
||||
# same name (no reflectable upstream constant exists).
|
||||
appId: com.example.app
|
||||
---
|
||||
- tapOn: 'Submit'
|
||||
- tapOn: "Submit"
|
||||
|
||||
@@ -4,4 +4,4 @@ appId: com.example.app
|
||||
---
|
||||
- swipe:
|
||||
from:
|
||||
id: 'row'
|
||||
id: "row"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# The command document must be a sequence.
|
||||
appId: com.example.app
|
||||
---
|
||||
tapOn: 'Button'
|
||||
tapOn: "Button"
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
appId: com.example.app
|
||||
---
|
||||
- tapOn:
|
||||
- text: 'Button'
|
||||
- text: "Button"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Upstream rejects an unknown command name (typo of tapOn).
|
||||
appId: com.example.app
|
||||
---
|
||||
- tapOnn: 'Button'
|
||||
- tapOnn: "Button"
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
appId: com.example.app
|
||||
---
|
||||
- tapOn:
|
||||
text: 'Button'
|
||||
text: "Button"
|
||||
bogusField: true
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
appId: com.example.app
|
||||
---
|
||||
- assertVisible:
|
||||
id: 'element_id'
|
||||
id: "element_id"
|
||||
@@ -1,4 +1,4 @@
|
||||
appId: com.example.app
|
||||
---
|
||||
- assertVisible:
|
||||
text: 'Element Text'
|
||||
text: "Element Text"
|
||||
@@ -1,4 +1,4 @@
|
||||
appId: com.example.app
|
||||
---
|
||||
- tapOn:
|
||||
text: '.*button.*'
|
||||
text: ".*button.*"
|
||||
@@ -1,8 +1,8 @@
|
||||
appId: com.example.app
|
||||
---
|
||||
- tapOn:
|
||||
text: 'Optional Element'
|
||||
text: "Optional Element"
|
||||
optional: true
|
||||
- assertVisible:
|
||||
text: 'Non Optional'
|
||||
optional: false
|
||||
text: "Non Optional"
|
||||
optional: false
|
||||
@@ -1,3 +1,3 @@
|
||||
appId: com.example.app
|
||||
---
|
||||
- scroll
|
||||
- scroll
|
||||
@@ -1,3 +1,3 @@
|
||||
appId: com.example.app
|
||||
---
|
||||
- back
|
||||
- back
|
||||
@@ -1,4 +1,4 @@
|
||||
appId: com.example.app
|
||||
---
|
||||
- inputText: 'Hello World'
|
||||
- inputText: user@example.com
|
||||
- inputText: "Hello World"
|
||||
- inputText: user@example.com
|
||||
@@ -1,3 +1,3 @@
|
||||
appId: com.example.app
|
||||
---
|
||||
- launchApp
|
||||
- launchApp
|
||||
@@ -1,4 +1,4 @@
|
||||
appId: com.example.app
|
||||
---
|
||||
- tapOn:
|
||||
point: 100,200
|
||||
point: 100,200
|
||||
@@ -3,4 +3,4 @@ appId: com.example.app
|
||||
- swipe:
|
||||
start: 100,500
|
||||
end: 100,200
|
||||
duration: 3000
|
||||
duration: 3000
|
||||
@@ -1,4 +1,4 @@
|
||||
appId: com.example.app
|
||||
---
|
||||
- launchApp:
|
||||
clearState: true
|
||||
clearState: true
|
||||
@@ -1,4 +1,4 @@
|
||||
appId: com.example.app
|
||||
---
|
||||
- assertNotVisible:
|
||||
id: 'element_id'
|
||||
id: "element_id"
|
||||
@@ -1,3 +1,3 @@
|
||||
appId: com.example.app
|
||||
---
|
||||
- openLink: https://example.com
|
||||
- openLink: https://example.com
|
||||
@@ -1,4 +1,4 @@
|
||||
appId: com.example.app
|
||||
---
|
||||
- longPressOn:
|
||||
text: '.*button.*'
|
||||
text: ".*button.*"
|
||||
@@ -7,4 +7,4 @@ appId: com.example.app
|
||||
- tapOn:
|
||||
text: Item.*
|
||||
index: ${0 + 1}
|
||||
retryTapIfNoChange: false
|
||||
retryTapIfNoChange: false
|
||||
@@ -29,3 +29,4 @@ appId: com.example.app
|
||||
- pressKey: TV Input HDMI 1
|
||||
- pressKey: TV Input HDMI 2
|
||||
- pressKey: TV Input HDMI 3
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
appId: com.example.app
|
||||
---
|
||||
- hideKeyboard
|
||||
- hideKeyboard
|
||||
@@ -1,6 +1,6 @@
|
||||
appId: com.example.app
|
||||
env:
|
||||
TIMEOUT: 1000
|
||||
TIMEOUT: 1000
|
||||
---
|
||||
- extendedWaitUntil:
|
||||
visible: Item
|
||||
|
||||
@@ -4,10 +4,10 @@ appId: com.other.app
|
||||
times: 3
|
||||
commands:
|
||||
- tapOn: Button
|
||||
- assertVisible: '3'
|
||||
- assertVisible: "3"
|
||||
- evalScript: ${output.list = [1, 2, 3]}
|
||||
- repeat:
|
||||
times: ${output.list.length}
|
||||
commands:
|
||||
- tapOn: Button
|
||||
- assertVisible: '6'
|
||||
- assertVisible: "6"
|
||||
@@ -2,4 +2,4 @@ appId: com.example.app
|
||||
---
|
||||
- swipe:
|
||||
direction: RIGHT
|
||||
duration: 500
|
||||
duration: 500
|
||||
@@ -1,4 +1,4 @@
|
||||
appId: com.example.app
|
||||
---
|
||||
- launchApp:
|
||||
stopApp: false
|
||||
stopApp: false
|
||||
@@ -1,5 +1,5 @@
|
||||
appId: com.example.app
|
||||
---
|
||||
- copyTextFrom:
|
||||
id: 'myId'
|
||||
id: "myId"
|
||||
- pasteText
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
appId: com.example.app
|
||||
---
|
||||
- assertTrue: ${1+1}
|
||||
- assertTrue: ${1+1}
|
||||
@@ -1,4 +1,4 @@
|
||||
appId: com.example.app
|
||||
---
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 500
|
||||
timeout: 500
|
||||
@@ -3,4 +3,4 @@ appId: com.example.app
|
||||
- swipe:
|
||||
direction: RIGHT
|
||||
from:
|
||||
text: 'swiping element'
|
||||
text: "swiping element"
|
||||
|
||||
@@ -3,16 +3,16 @@ appId: com.example.app
|
||||
- scrollUntilVisible:
|
||||
timeout: 1
|
||||
element:
|
||||
id: 'not_found'
|
||||
id: "not_found"
|
||||
optional: true
|
||||
- assertTrue:
|
||||
condition: 'false'
|
||||
condition: "false"
|
||||
optional: true
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: 'not_found'
|
||||
id: "not_found"
|
||||
timeout: 1
|
||||
optional: true
|
||||
- assertVisible:
|
||||
text: 'Button'
|
||||
text: "Button"
|
||||
optional: true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
appId: com.example.app
|
||||
---
|
||||
- swipe:
|
||||
start: '50%,30%'
|
||||
end: '50%,60%'
|
||||
duration: 3000
|
||||
start: "50%,30%"
|
||||
end: "50%,60%"
|
||||
duration: 3000
|
||||
@@ -2,8 +2,8 @@ appId: com.example.app
|
||||
---
|
||||
- scrollUntilVisible:
|
||||
element:
|
||||
text: 'Test'
|
||||
text: "Test"
|
||||
speed: 100
|
||||
visibilityPercentage: 100
|
||||
direction: DOWN
|
||||
timeout: 10
|
||||
timeout: 10
|
||||
@@ -1,10 +1,10 @@
|
||||
appId: com.example.app
|
||||
---
|
||||
- assertVisible:
|
||||
text: 'child_id'
|
||||
text: "child_id"
|
||||
childOf:
|
||||
text: 'parent_id_1'
|
||||
text: "parent_id_1"
|
||||
- assertNotVisible:
|
||||
text: 'child_id'
|
||||
text: "child_id"
|
||||
childOf:
|
||||
text: 'parent_id_3'
|
||||
text: "parent_id_3"
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
appId: com.example.app
|
||||
---
|
||||
- tapOn:
|
||||
text: '.*button.*'
|
||||
retryTapIfNoChange: true
|
||||
text: ".*button.*"
|
||||
retryTapIfNoChange: true
|
||||
@@ -3,4 +3,4 @@ appId: com.example.app
|
||||
- setPermissions:
|
||||
permissions:
|
||||
all: deny
|
||||
notifications: unset
|
||||
notifications: unset
|
||||
@@ -10,6 +10,7 @@ import { createDaemonMaestroRuntimePort } from '../daemon-runtime-port.ts';
|
||||
import {
|
||||
MAESTRO_OBSERVATION_POLL_MS,
|
||||
maestroSnapshotSignature,
|
||||
observeTypedMaestroCondition,
|
||||
resolveTypedMaestroTarget,
|
||||
waitForTypedSnapshotStability,
|
||||
} from '../daemon-runtime-port-observation.ts';
|
||||
@@ -130,6 +131,76 @@ test('computes expensive target evidence only for the command policies that cons
|
||||
expect(atomicRetry.dispatchSelector).toEqual({ key: 'id', value: 'continue' });
|
||||
});
|
||||
|
||||
test('matches iOS Maestro ids on semantic nodes suppressed from interactive presentation', async () => {
|
||||
const snapshot = makeSnapshot([
|
||||
{ index: 0, type: 'Application', rect: { x: 0, y: 0, width: 402, height: 874 } },
|
||||
{
|
||||
index: 1,
|
||||
parentIndex: 0,
|
||||
type: 'Other',
|
||||
identifier: 'inert-title',
|
||||
rect: { x: 20, y: 100, width: 180, height: 32 },
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
parentIndex: 1,
|
||||
type: 'StaticText',
|
||||
label: 'Inert surface',
|
||||
rect: { x: 20, y: 100, width: 180, height: 32 },
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
observeTypedMaestroCondition({
|
||||
condition: { kind: 'visible', selector: { id: 'inert-title' } },
|
||||
timeoutMs: 0,
|
||||
context: { generation: 0, env: {} },
|
||||
snapshot: async () => snapshot,
|
||||
dependencies: makeDependencies(),
|
||||
platform: 'ios',
|
||||
}),
|
||||
).resolves.toMatchObject({ matched: true, visible: true, candidateCount: 1, ref: 'e2' });
|
||||
});
|
||||
|
||||
test('reports iOS Maestro evidence refs in semantic snapshot space after earlier suppression', async () => {
|
||||
const snapshot = makeSnapshot([
|
||||
{ index: 0, type: 'Application', rect: { x: 0, y: 0, width: 402, height: 874 } },
|
||||
{ index: 1, parentIndex: 0, type: 'Other', identifier: 'discarded-wrapper' },
|
||||
{
|
||||
index: 2,
|
||||
parentIndex: 1,
|
||||
type: 'StaticText',
|
||||
label: 'Earlier content',
|
||||
rect: { x: 20, y: 40, width: 180, height: 32 },
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
parentIndex: 0,
|
||||
type: 'Other',
|
||||
identifier: 'target-wrapper',
|
||||
rect: { x: 20, y: 100, width: 180, height: 32 },
|
||||
},
|
||||
{
|
||||
index: 4,
|
||||
parentIndex: 3,
|
||||
type: 'StaticText',
|
||||
label: 'Target content',
|
||||
rect: { x: 20, y: 100, width: 180, height: 32 },
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
observeTypedMaestroCondition({
|
||||
condition: { kind: 'visible', selector: { id: 'target-wrapper' } },
|
||||
timeoutMs: 0,
|
||||
context: { generation: 0, env: {} },
|
||||
snapshot: async () => snapshot,
|
||||
dependencies: makeDependencies(),
|
||||
platform: 'ios',
|
||||
}),
|
||||
).resolves.toMatchObject({ matched: true, visible: true, candidateCount: 1, ref: 'e4' });
|
||||
});
|
||||
|
||||
test('compares snapshots before sleeping and captures once beyond a zero settle budget', async () => {
|
||||
const clock = { value: 0 };
|
||||
const captures = [
|
||||
|
||||
@@ -62,7 +62,7 @@ test('uses canonical iOS presentation only for atomic selector uniqueness', asyn
|
||||
expect(requests[1]?.positionals).toEqual(['text="First"']);
|
||||
});
|
||||
|
||||
test('uses resolved iOS geometry when canonical presentation changes target bounds', async () => {
|
||||
test('uses exact iOS representative geometry when presentation suppresses the semantic target', async () => {
|
||||
const requests: DaemonRequest[] = [];
|
||||
const port = createDaemonMaestroRuntimePort({
|
||||
baseReq: makeBaseRequest({ flags: { platform: 'ios', replayBackend: 'maestro' } }),
|
||||
@@ -134,7 +134,7 @@ test('uses resolved iOS geometry when canonical presentation changes target boun
|
||||
expect(requests.map(({ command }) => command)).toEqual(['snapshot', 'click']);
|
||||
expect(
|
||||
requests.filter(({ command }) => command === 'click').map(({ positionals }) => positionals),
|
||||
).toEqual([['56', '121']]);
|
||||
).toEqual([['266', '121']]);
|
||||
});
|
||||
|
||||
test('uses the selected iOS node interactive bounds without changing raw target matching', async () => {
|
||||
|
||||
@@ -104,17 +104,21 @@ function resolveTargetFromSnapshot(params: {
|
||||
params.platform === 'ios'
|
||||
? buildIosInteractiveSnapshotPresentation(params.snapshot.nodes)
|
||||
: undefined;
|
||||
const canonicalSnapshot = presentation
|
||||
const interactiveSnapshot = presentation
|
||||
? { ...params.snapshot, nodes: attachRefs(presentation.nodes) }
|
||||
: params.snapshot;
|
||||
const resolution = resolveMaestroTargetFromSnapshot(
|
||||
canonicalSnapshot,
|
||||
params.snapshot,
|
||||
params.query,
|
||||
params.platform,
|
||||
{
|
||||
interactiveBounds: params.mode === 'tap',
|
||||
interaction: presentation
|
||||
? { snapshot: params.snapshot, sourceIndexes: presentation.sourceIndexes }
|
||||
presentation: presentation
|
||||
? {
|
||||
snapshot: interactiveSnapshot,
|
||||
sourceIndexes: presentation.sourceIndexes,
|
||||
presentedIndexesBySourceIndex: presentation.presentedIndexesBySourceIndex,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -26,6 +26,8 @@ export function presentIosInteractiveSnapshot(nodes: RawSnapshotNode[]): RawSnap
|
||||
|
||||
export type IosInteractiveSnapshotPresentation = {
|
||||
nodes: RawSnapshotNode[];
|
||||
/** Presented node indexes for every source index; suppressed noise maps to an empty list. */
|
||||
presentedIndexesBySourceIndex: ReadonlyMap<number, number[]>;
|
||||
sourceIndexes: ReadonlyMap<number, number>;
|
||||
};
|
||||
|
||||
@@ -33,16 +35,18 @@ export function buildIosInteractiveSnapshotPresentation(
|
||||
nodes: RawSnapshotNode[],
|
||||
): IosInteractiveSnapshotPresentation {
|
||||
if (nodes.length === 0) {
|
||||
return { nodes, sourceIndexes: new Map() };
|
||||
return { nodes, presentedIndexesBySourceIndex: new Map(), sourceIndexes: new Map() };
|
||||
}
|
||||
|
||||
const sourceIndexes = new Map(nodes.map((node) => [node.index, node.index]));
|
||||
const replacements = new Map<number, RawSnapshotNode>();
|
||||
const representativeSourceIndexesBySourceIndex = new Map<number, Set<number>>();
|
||||
const semanticRepresentativeIndexes = new Set<number>();
|
||||
const sourceNodesByIndex = new Map(nodes.map((node) => [node.index, node]));
|
||||
const suppressedIndexes = new Set<number>();
|
||||
const ruleContext: SnapshotTreeRuleContext = {
|
||||
replacements,
|
||||
representativeSourceIndexesBySourceIndex,
|
||||
semanticRepresentativeIndexes,
|
||||
sourceNodesByIndex,
|
||||
suppressedIndexes,
|
||||
@@ -53,7 +57,11 @@ export function buildIosInteractiveSnapshotPresentation(
|
||||
}
|
||||
|
||||
if (suppressedIndexes.size === 0 && replacements.size === 0) {
|
||||
return { nodes, sourceIndexes };
|
||||
return {
|
||||
nodes,
|
||||
presentedIndexesBySourceIndex: new Map(nodes.map((node) => [node.index, [node.index]])),
|
||||
sourceIndexes,
|
||||
};
|
||||
}
|
||||
|
||||
const presentedSourceNodes = nodes
|
||||
@@ -64,13 +72,47 @@ export function buildIosInteractiveSnapshotPresentation(
|
||||
suppressedIndexes,
|
||||
nodes,
|
||||
);
|
||||
const presentedIndexBySourceIndex = new Map(
|
||||
presentedSourceNodes.map((node, position) => [node.index, presentedNodes[position]!.index]),
|
||||
);
|
||||
return {
|
||||
nodes: presentedNodes,
|
||||
sourceIndexes: new Map(
|
||||
presentedNodes.map((node, position) => [
|
||||
presentedIndexesBySourceIndex: new Map(
|
||||
nodes.map((node) => [
|
||||
node.index,
|
||||
sourceIndexes.get(presentedSourceNodes[position]!.index)!,
|
||||
resolvePresentedIndexes(
|
||||
node.index,
|
||||
presentedIndexBySourceIndex,
|
||||
representativeSourceIndexesBySourceIndex,
|
||||
),
|
||||
]),
|
||||
),
|
||||
sourceIndexes: new Map(
|
||||
presentedNodes.map((node, position) => [node.index, presentedSourceNodes[position]!.index]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePresentedIndexes(
|
||||
sourceIndex: number,
|
||||
presentedIndexBySourceIndex: ReadonlyMap<number, number>,
|
||||
representativesBySourceIndex: ReadonlyMap<number, ReadonlySet<number>>,
|
||||
visited = new Set<number>(),
|
||||
): number[] {
|
||||
const direct = presentedIndexBySourceIndex.get(sourceIndex);
|
||||
if (direct !== undefined) return [direct];
|
||||
if (visited.has(sourceIndex)) return [];
|
||||
visited.add(sourceIndex);
|
||||
const resolved = new Set<number>();
|
||||
for (const representative of representativesBySourceIndex.get(sourceIndex) ?? []) {
|
||||
for (const presentedIndex of resolvePresentedIndexes(
|
||||
representative,
|
||||
presentedIndexBySourceIndex,
|
||||
representativesBySourceIndex,
|
||||
visited,
|
||||
)) {
|
||||
resolved.add(presentedIndex);
|
||||
}
|
||||
}
|
||||
return [...resolved].sort((left, right) => left - right);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import type { RawSnapshotNode } from '@agent-device/kernel/snapshot';
|
||||
import { buildIosInteractiveSnapshotPresentation } from './index.ts';
|
||||
|
||||
test('publishes an exact representative for every semantic source index', () => {
|
||||
const nodes: RawSnapshotNode[] = [
|
||||
{ index: 0, depth: 0, type: 'Application', rect: { x: 0, y: 0, width: 320, height: 640 } },
|
||||
{
|
||||
index: 1,
|
||||
depth: 1,
|
||||
parentIndex: 0,
|
||||
type: 'Other',
|
||||
identifier: 'semantic-wrapper',
|
||||
rect: { x: 20, y: 80, width: 120, height: 40 },
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
depth: 2,
|
||||
parentIndex: 1,
|
||||
type: 'StaticText',
|
||||
label: 'Visible child',
|
||||
rect: { x: 20, y: 80, width: 120, height: 40 },
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
depth: 1,
|
||||
parentIndex: 0,
|
||||
type: 'StaticText',
|
||||
identifier: 'semantic-wrapper',
|
||||
label: 'Unrelated',
|
||||
rect: { x: 20, y: 180, width: 120, height: 40 },
|
||||
},
|
||||
{
|
||||
index: 4,
|
||||
depth: 1,
|
||||
parentIndex: 0,
|
||||
type: 'StaticText',
|
||||
identifier: 'shared-but-not-a-representative',
|
||||
rect: { x: 20, y: 240, width: 120, height: 40 },
|
||||
},
|
||||
{
|
||||
index: 5,
|
||||
depth: 2,
|
||||
parentIndex: 4,
|
||||
type: 'Other',
|
||||
identifier: 'shared-but-not-a-representative',
|
||||
rect: { x: 20, y: 240, width: 120, height: 40 },
|
||||
},
|
||||
];
|
||||
|
||||
const presentation = buildIosInteractiveSnapshotPresentation(nodes);
|
||||
|
||||
expect([...presentation.presentedIndexesBySourceIndex]).toEqual([
|
||||
[0, [0]],
|
||||
[1, [1]],
|
||||
[2, [1]],
|
||||
[3, [2]],
|
||||
[4, [3]],
|
||||
[5, []],
|
||||
]);
|
||||
expect(presentation.sourceIndexes).toEqual(
|
||||
new Map([
|
||||
[0, 0],
|
||||
[1, 2],
|
||||
[2, 3],
|
||||
[3, 4],
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -10,6 +10,8 @@ import { normalizeType } from '@agent-device/contracts/snapshot';
|
||||
import { collectIosScrollIndicatorPresentation } from './scroll.ts';
|
||||
import {
|
||||
areRectsApproximatelyEqual,
|
||||
associateSnapshotPresentation,
|
||||
collectDescendantsByParentIndex,
|
||||
findDescendant,
|
||||
findLargestViewportRect,
|
||||
forEachDescendant,
|
||||
@@ -26,17 +28,13 @@ export function collectIosPresentationNoiseSuppression(
|
||||
): void {
|
||||
const { suppressedIndexes } = context;
|
||||
collectIosOffscreenKeyboardSuppression(nodes, context.sourceNodesByIndex, suppressedIndexes);
|
||||
collectIosStructuralIdentifierSuppression(nodes, suppressedIndexes);
|
||||
collectIosStructuralIdentifierSuppression(nodes, context);
|
||||
collectIosScrollIndicatorPresentation(nodes, context);
|
||||
collectIosSearchToolbarSuppression(nodes, context.sourceNodesByIndex, suppressedIndexes);
|
||||
collectIosActionWrapperSuppression(nodes, suppressedIndexes);
|
||||
collectIosSearchToolbarSuppression(nodes, context);
|
||||
collectIosActionWrapperSuppression(nodes, context);
|
||||
collectIosReactNativeOverlayActionPresentation(nodes, context.replacements);
|
||||
collectIosReactNativeOverlayWrapperSuppression(nodes, suppressedIndexes);
|
||||
collectIosRepeatedStaticSuppression(
|
||||
nodes,
|
||||
suppressedIndexes,
|
||||
context.semanticRepresentativeIndexes,
|
||||
);
|
||||
collectIosReactNativeOverlayWrapperSuppression(nodes, context);
|
||||
collectIosRepeatedStaticSuppression(nodes, context);
|
||||
}
|
||||
|
||||
function collectIosReactNativeOverlayActionPresentation(
|
||||
@@ -111,7 +109,7 @@ function remainingHorizontalPartition(
|
||||
|
||||
function collectIosReactNativeOverlayWrapperSuppression(
|
||||
nodes: RawSnapshotNode[],
|
||||
suppressedIndexes: Set<number>,
|
||||
context: SnapshotTreeRuleContext,
|
||||
): void {
|
||||
forEachOtherNodeWithLabel(nodes, (node, _nodeLabel, position) => {
|
||||
if (!isReactNativeCollapsedWarningWrapperCandidate(node)) return;
|
||||
@@ -121,7 +119,10 @@ function collectIosReactNativeOverlayWrapperSuppression(
|
||||
collectDescendantNodes(nodes, position),
|
||||
)
|
||||
) {
|
||||
suppressedIndexes.add(node.index);
|
||||
context.suppressedIndexes.add(node.index);
|
||||
for (const descendant of collectDescendantNodes(nodes, position)) {
|
||||
associateSnapshotPresentation(context, node, descendant);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -136,24 +137,16 @@ function collectDescendantNodes(nodes: RawSnapshotNode[], position: number): Raw
|
||||
|
||||
function collectIosRepeatedStaticSuppression(
|
||||
nodes: RawSnapshotNode[],
|
||||
suppressedIndexes: Set<number>,
|
||||
semanticRepresentativeIndexes: ReadonlySet<number>,
|
||||
context: SnapshotTreeRuleContext,
|
||||
): void {
|
||||
for (let position = 0; position < nodes.length; position += 1) {
|
||||
const node = nodes[position];
|
||||
const nodeLabel = node?.label?.trim();
|
||||
if (!node || suppressedIndexes.has(node.index) || !nodeLabel) {
|
||||
if (!node || context.suppressedIndexes.has(node.index) || !nodeLabel) {
|
||||
continue;
|
||||
}
|
||||
|
||||
collectRepeatedStaticSuppressionForNode(
|
||||
nodes,
|
||||
position,
|
||||
node,
|
||||
nodeLabel,
|
||||
suppressedIndexes,
|
||||
semanticRepresentativeIndexes,
|
||||
);
|
||||
collectRepeatedStaticSuppressionForNode(nodes, position, node, nodeLabel, context);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,72 +155,60 @@ function collectRepeatedStaticSuppressionForNode(
|
||||
position: number,
|
||||
node: RawSnapshotNode,
|
||||
nodeLabel: string,
|
||||
suppressedIndexes: Set<number>,
|
||||
semanticRepresentativeIndexes: ReadonlySet<number>,
|
||||
context: SnapshotTreeRuleContext,
|
||||
): void {
|
||||
const type = normalizeType(node.type ?? '');
|
||||
if (type === 'statictext' || type === 'link') {
|
||||
suppressRepeatedStaticDescendants(
|
||||
nodes,
|
||||
position,
|
||||
nodeLabel,
|
||||
suppressedIndexes,
|
||||
semanticRepresentativeIndexes,
|
||||
);
|
||||
suppressRepeatedStaticDescendants(nodes, position, nodeLabel, node, context);
|
||||
return;
|
||||
}
|
||||
if (type !== 'other') {
|
||||
return;
|
||||
}
|
||||
if (hasEquivalentSemanticDescendant(nodes, position, nodeLabel)) {
|
||||
suppressedIndexes.add(node.index);
|
||||
const semanticDescendant = findEquivalentSemanticDescendant(nodes, position, nodeLabel);
|
||||
if (semanticDescendant) {
|
||||
context.suppressedIndexes.add(node.index);
|
||||
associateSnapshotPresentation(context, node, semanticDescendant);
|
||||
return;
|
||||
}
|
||||
suppressRepeatedStaticDescendants(
|
||||
nodes,
|
||||
position,
|
||||
nodeLabel,
|
||||
suppressedIndexes,
|
||||
semanticRepresentativeIndexes,
|
||||
);
|
||||
suppressRepeatedStaticDescendants(nodes, position, nodeLabel, node, context);
|
||||
}
|
||||
|
||||
function hasEquivalentSemanticDescendant(
|
||||
function findEquivalentSemanticDescendant(
|
||||
nodes: RawSnapshotNode[],
|
||||
position: number,
|
||||
nodeLabel: string,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
findDescendant(nodes, position, (descendant) => {
|
||||
const type = normalizeType(descendant.type ?? '');
|
||||
return (
|
||||
(type === 'link' || type === 'searchfield' || isScrollableSnapshotType(descendant.type)) &&
|
||||
descendant.label?.trim() === nodeLabel
|
||||
);
|
||||
}),
|
||||
);
|
||||
): RawSnapshotNode | undefined {
|
||||
return findDescendant(nodes, position, (descendant) => {
|
||||
const type = normalizeType(descendant.type ?? '');
|
||||
return (
|
||||
(type === 'link' || type === 'searchfield' || isScrollableSnapshotType(descendant.type)) &&
|
||||
descendant.label?.trim() === nodeLabel
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function suppressRepeatedStaticDescendants(
|
||||
nodes: RawSnapshotNode[],
|
||||
position: number,
|
||||
label: string,
|
||||
suppressedIndexes: Set<number>,
|
||||
semanticRepresentativeIndexes: ReadonlySet<number>,
|
||||
representative: RawSnapshotNode,
|
||||
context: SnapshotTreeRuleContext,
|
||||
): void {
|
||||
forEachDescendant(nodes, position, (descendant) => {
|
||||
if (
|
||||
!semanticRepresentativeIndexes.has(descendant.index) &&
|
||||
!context.semanticRepresentativeIndexes.has(descendant.index) &&
|
||||
isRepeatedStaticNode(descendant, label)
|
||||
) {
|
||||
suppressedIndexes.add(descendant.index);
|
||||
context.suppressedIndexes.add(descendant.index);
|
||||
associateSnapshotPresentation(context, descendant, representative);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function collectIosActionWrapperSuppression(
|
||||
nodes: RawSnapshotNode[],
|
||||
suppressedIndexes: Set<number>,
|
||||
context: SnapshotTreeRuleContext,
|
||||
): void {
|
||||
forEachOtherNodeWithLabel(nodes, (node, nodeLabel, position) => {
|
||||
const semanticDescendant = findDescendant(nodes, position, (descendant) => {
|
||||
@@ -239,7 +220,8 @@ function collectIosActionWrapperSuppression(
|
||||
);
|
||||
});
|
||||
if (semanticDescendant) {
|
||||
suppressedIndexes.add(node.index);
|
||||
context.suppressedIndexes.add(node.index);
|
||||
associateSnapshotPresentation(context, node, semanticDescendant);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -330,7 +312,7 @@ function suppressOffscreenKeyboardAncestors(
|
||||
|
||||
function collectIosStructuralIdentifierSuppression(
|
||||
nodes: RawSnapshotNode[],
|
||||
suppressedIndexes: Set<number>,
|
||||
context: SnapshotTreeRuleContext,
|
||||
): void {
|
||||
for (const node of nodes) {
|
||||
if (normalizeType(node.type ?? '') !== 'other') {
|
||||
@@ -342,20 +324,22 @@ function collectIosStructuralIdentifierSuppression(
|
||||
if (!node.identifier?.trim()) {
|
||||
continue;
|
||||
}
|
||||
suppressedIndexes.add(node.index);
|
||||
context.suppressedIndexes.add(node.index);
|
||||
for (const descendant of collectDescendantsByParentIndex(nodes, node.index)) {
|
||||
associateSnapshotPresentation(context, node, descendant);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectIosSearchToolbarSuppression(
|
||||
nodes: RawSnapshotNode[],
|
||||
sourceNodesByIndex: ReadonlyMap<number, RawSnapshotNode>,
|
||||
suppressedIndexes: Set<number>,
|
||||
context: SnapshotTreeRuleContext,
|
||||
): void {
|
||||
for (let position = 0; position < nodes.length; position += 1) {
|
||||
const node = nodes[position];
|
||||
if (!node) continue;
|
||||
if (isExposedSearchField(node)) {
|
||||
suppressSearchToolbarDescendants(nodes, position, null, suppressedIndexes);
|
||||
suppressSearchToolbarDescendants(nodes, position, node, context);
|
||||
continue;
|
||||
}
|
||||
if (!isSearchToolbar(node)) continue;
|
||||
@@ -370,9 +354,10 @@ function collectIosSearchToolbarSuppression(
|
||||
continue;
|
||||
}
|
||||
|
||||
suppressedIndexes.add(node.index);
|
||||
suppressToolbarAncestors(node, sourceNodesByIndex, suppressedIndexes);
|
||||
suppressSearchToolbarDescendants(nodes, position, innerSearch.index, suppressedIndexes);
|
||||
context.suppressedIndexes.add(node.index);
|
||||
associateSnapshotPresentation(context, node, innerSearch);
|
||||
suppressToolbarAncestors(node, innerSearch, context);
|
||||
suppressSearchToolbarDescendants(nodes, position, innerSearch, context);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,31 +373,33 @@ function isSearchToolbar(node: RawSnapshotNode): boolean {
|
||||
function suppressSearchToolbarDescendants(
|
||||
nodes: RawSnapshotNode[],
|
||||
position: number,
|
||||
keptSearchIndex: number | null,
|
||||
suppressedIndexes: Set<number>,
|
||||
keptSearch: RawSnapshotNode,
|
||||
context: SnapshotTreeRuleContext,
|
||||
): void {
|
||||
forEachDescendant(nodes, position, (descendant) => {
|
||||
if (descendant.index === keptSearchIndex) {
|
||||
if (descendant.index === keptSearch.index) {
|
||||
return;
|
||||
}
|
||||
if (shouldSuppressIosSearchToolbarDescendant(descendant)) {
|
||||
suppressedIndexes.add(descendant.index);
|
||||
context.suppressedIndexes.add(descendant.index);
|
||||
associateSnapshotPresentation(context, descendant, keptSearch);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function suppressToolbarAncestors(
|
||||
node: RawSnapshotNode,
|
||||
sourceNodesByIndex: ReadonlyMap<number, RawSnapshotNode>,
|
||||
suppressedIndexes: Set<number>,
|
||||
representative: RawSnapshotNode,
|
||||
context: SnapshotTreeRuleContext,
|
||||
): void {
|
||||
let current = node;
|
||||
while (typeof current.parentIndex === 'number') {
|
||||
const parent = sourceNodesByIndex.get(current.parentIndex);
|
||||
const parent = context.sourceNodesByIndex.get(current.parentIndex);
|
||||
if (!parent || parent.label !== 'Toolbar') {
|
||||
return;
|
||||
}
|
||||
suppressedIndexes.add(parent.index);
|
||||
context.suppressedIndexes.add(parent.index);
|
||||
associateSnapshotPresentation(context, parent, representative);
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { normalizeType } from '@agent-device/contracts/snapshot';
|
||||
import { isSystemScrollIndicatorLabel } from '../../../utils/scroll-indicator.ts';
|
||||
import {
|
||||
areRectsApproximatelyEqual,
|
||||
associateSnapshotPresentation,
|
||||
collectDescendants,
|
||||
isDisabledChevronButton,
|
||||
mergeReplacement,
|
||||
@@ -47,6 +48,7 @@ function resolveIosRowLabel(
|
||||
}
|
||||
mergeReplacement(context.replacements, row, { label: title });
|
||||
context.suppressedIndexes.add(titleNode.index);
|
||||
associateSnapshotPresentation(context, titleNode, row);
|
||||
return title;
|
||||
}
|
||||
|
||||
@@ -73,7 +75,7 @@ function collectIosRowPresentationForNode(
|
||||
const rowType = normalizeType(row.type ?? '');
|
||||
if (rowType === 'button') {
|
||||
const descendants = collectDescendants(nodes, position);
|
||||
suppressRepeatedRowDescendants(descendants, rowLabel, context.suppressedIndexes, row);
|
||||
suppressRepeatedRowDescendants(descendants, rowLabel, context, row);
|
||||
return;
|
||||
}
|
||||
if (rowType !== 'cell') {
|
||||
@@ -108,13 +110,8 @@ function collectSwitchRowPresentation(
|
||||
mergeReplacement(context.replacements, switchControl, { identifier: promotedIdentifier });
|
||||
}
|
||||
context.suppressedIndexes.add(row.index);
|
||||
suppressSwitchRowDescendants(
|
||||
descendants,
|
||||
row,
|
||||
rowLabel,
|
||||
switchControl,
|
||||
context.suppressedIndexes,
|
||||
);
|
||||
associateSnapshotPresentation(context, row, switchControl);
|
||||
suppressSwitchRowDescendants(descendants, row, rowLabel, switchControl, context);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -129,7 +126,7 @@ function collectButtonRowPresentation(
|
||||
);
|
||||
if (!rowButton) {
|
||||
if (descendants.some(isDisabledChevronButton)) {
|
||||
suppressRepeatedRowDescendants(descendants, rowLabel, context.suppressedIndexes, row);
|
||||
suppressRepeatedRowDescendants(descendants, rowLabel, context, row);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -139,10 +136,11 @@ function collectButtonRowPresentation(
|
||||
}
|
||||
|
||||
context.suppressedIndexes.add(rowButton.index);
|
||||
associateSnapshotPresentation(context, rowButton, row);
|
||||
suppressRepeatedRowDescendants(
|
||||
descendants.filter((descendant) => descendant.index !== rowButton.index),
|
||||
rowLabel,
|
||||
context.suppressedIndexes,
|
||||
context,
|
||||
row,
|
||||
);
|
||||
}
|
||||
@@ -152,7 +150,7 @@ function suppressSwitchRowDescendants(
|
||||
row: RawSnapshotNode,
|
||||
rowLabel: string,
|
||||
switchControl: RawSnapshotNode,
|
||||
suppressedIndexes: Set<number>,
|
||||
context: SnapshotTreeRuleContext,
|
||||
): void {
|
||||
for (const descendant of descendants) {
|
||||
if (descendant.index === switchControl.index) {
|
||||
@@ -164,7 +162,8 @@ function suppressSwitchRowDescendants(
|
||||
isIosSwitchValueDescendant(descendant, switchControl) ||
|
||||
shouldSuppressRepeatedTextDescendant(descendant, rowLabel)
|
||||
) {
|
||||
suppressedIndexes.add(descendant.index);
|
||||
context.suppressedIndexes.add(descendant.index);
|
||||
associateSnapshotPresentation(context, descendant, switchControl);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,7 +171,7 @@ function suppressSwitchRowDescendants(
|
||||
function suppressRepeatedRowDescendants(
|
||||
descendants: RawSnapshotNode[],
|
||||
rowLabel: string,
|
||||
suppressedIndexes: Set<number>,
|
||||
context: SnapshotTreeRuleContext,
|
||||
row?: RawSnapshotNode,
|
||||
): void {
|
||||
for (const descendant of descendants) {
|
||||
@@ -180,7 +179,8 @@ function suppressRepeatedRowDescendants(
|
||||
shouldSuppressRepeatedTextDescendant(descendant, rowLabel) ||
|
||||
(row && isEmptyRowButtonWrapper(descendant, row))
|
||||
) {
|
||||
suppressedIndexes.add(descendant.index);
|
||||
context.suppressedIndexes.add(descendant.index);
|
||||
if (row) associateSnapshotPresentation(context, descendant, row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
isSystemScrollIndicatorLabel,
|
||||
} from '../../../utils/scroll-indicator.ts';
|
||||
import {
|
||||
associateSnapshotPresentation,
|
||||
findNearestScrollableContainer,
|
||||
isScrollableSnapshotType,
|
||||
updateReplacement,
|
||||
@@ -47,6 +48,10 @@ function collectIosScrollIndicatorNodePresentation(
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.suppressedIndexes.has(node.index)) {
|
||||
associateSnapshotPresentation(context, node, container);
|
||||
}
|
||||
|
||||
applyScrollIndicatorReplacement(context, container, node, directions);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { RawSnapshotNode, Rect } from '@agent-device/kernel/snapshot';
|
||||
import { rectContains } from '@agent-device/kernel/rect';
|
||||
import { extractNodeText, normalizeType } from '@agent-device/contracts/snapshot';
|
||||
import {
|
||||
associateSnapshotPresentation,
|
||||
collectChildrenByParent,
|
||||
mergeReplacement,
|
||||
type SnapshotTreeRuleContext,
|
||||
@@ -41,6 +42,8 @@ function collectNavigationTitleAffordances(
|
||||
context.semanticRepresentativeIndexes.add(field.index);
|
||||
context.suppressedIndexes.add(title.index);
|
||||
context.suppressedIndexes.add(image.index);
|
||||
associateSnapshotPresentation(context, title, field);
|
||||
associateSnapshotPresentation(context, image, field);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import { normalizeType } from '@agent-device/contracts/snapshot';
|
||||
export { areRectsApproximatelyEqual } from '../../utils/rect-center.ts';
|
||||
|
||||
export type SnapshotTreeRuleContext = {
|
||||
/** Exact semantic-source relationships declared by the presentation rule that owns them. */
|
||||
representativeSourceIndexesBySourceIndex: Map<number, Set<number>>;
|
||||
replacements: Map<number, RawSnapshotNode>;
|
||||
/** Projected label owners that repeated-text suppression must retain. */
|
||||
semanticRepresentativeIndexes: Set<number>;
|
||||
@@ -10,6 +12,17 @@ export type SnapshotTreeRuleContext = {
|
||||
suppressedIndexes: Set<number>;
|
||||
};
|
||||
|
||||
export function associateSnapshotPresentation(
|
||||
context: SnapshotTreeRuleContext,
|
||||
source: RawSnapshotNode,
|
||||
representative: RawSnapshotNode,
|
||||
): void {
|
||||
const representatives =
|
||||
context.representativeSourceIndexesBySourceIndex.get(source.index) ?? new Set<number>();
|
||||
representatives.add(representative.index);
|
||||
context.representativeSourceIndexesBySourceIndex.set(source.index, representatives);
|
||||
}
|
||||
|
||||
export function collectChildrenByParent(nodes: RawSnapshotNode[]): Map<number, RawSnapshotNode[]> {
|
||||
const childrenByParent = new Map<number, RawSnapshotNode[]>();
|
||||
for (const node of nodes) {
|
||||
@@ -33,6 +46,23 @@ export function collectDescendants(
|
||||
return nodes.slice(startPosition + 1, endPosition);
|
||||
}
|
||||
|
||||
export function collectDescendantsByParentIndex(
|
||||
nodes: RawSnapshotNode[],
|
||||
ancestorIndex: number,
|
||||
): RawSnapshotNode[] {
|
||||
const byIndex = new Map(nodes.map((node) => [node.index, node]));
|
||||
return nodes.filter((node) => {
|
||||
let parentIndex = node.parentIndex;
|
||||
const visited = new Set<number>();
|
||||
while (typeof parentIndex === 'number' && !visited.has(parentIndex)) {
|
||||
if (parentIndex === ancestorIndex) return true;
|
||||
visited.add(parentIndex);
|
||||
parentIndex = byIndex.get(parentIndex)?.parentIndex;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
export function findDescendant(
|
||||
nodes: RawSnapshotNode[],
|
||||
startPosition: number,
|
||||
|
||||
Reference in New Issue
Block a user