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
1 change: 1 addition & 0 deletions examples/test-app/app.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ module.exports = {
android: {
package: 'com.callstack.agentdevicelab',
predictiveBackGestureEnabled: false,
softwareKeyboardLayoutMode: 'pan',
},
},
};
27 changes: 27 additions & 0 deletions packages/ad-script/src/internal/__tests__/script.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,33 @@ test('formatPortableActionLine preserves inline open runtime hints', () => {
);
});

test('open replay script round-trips explicit Android test IME selection', () => {
const actions: SessionAction[] = [
{
ts: Date.now(),
command: 'open',
positionals: ['Demo'],
flags: { testIme: false },
},
{
ts: Date.now(),
command: 'open',
positionals: ['Demo'],
flags: { testIme: true },
},
];

const script = formatReplayScriptForTest(actions);
assert.match(script, /open "Demo" --no-test-ime/);
assert.match(script, /open "Demo" --test-ime/);

const parsed = parseReplayScriptDetailed(script).actions;
assert.equal(parsed[0]?.flags.testIme, false);
assert.equal(parsed[1]?.flags.testIme, true);
assert.deepEqual(parsed[0]?.positionals, ['Demo']);
assert.deepEqual(parsed[1]?.positionals, ['Demo']);
});

test('record replay script parses fps, quality, and hide-touches flags', () => {
const script = 'record start "./capture.mp4" --fps 24 --quality high --hide-touches\n';
const parsed = parseReplayScriptDetailed(script).actions;
Expand Down
13 changes: 13 additions & 0 deletions packages/ad-script/src/internal/open-script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ export function appendOpenActionScriptArgs(
if (action.flags?.relaunch) {
parts.push('--relaunch');
}
if (action.flags?.testIme === true) {
parts.push('--test-ime');
} else if (action.flags?.testIme === false) {
parts.push('--no-test-ime');
}
appendRuntimeHintFlags(parts, action.runtime);
}

Expand All @@ -61,6 +66,14 @@ export function parseReplayOpenFlags(args: string[]): {
flags.relaunch = true;
continue;
}
if (token === '--test-ime') {
flags.testIme = true;
continue;
}
if (token === '--no-test-ime') {
flags.testIme = false;
continue;
}
argsWithoutRelaunch.push(token);
}
const parsedRuntime = parseReplayRuntimeFlags(argsWithoutRelaunch);
Expand Down
45 changes: 37 additions & 8 deletions packages/selectors/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import type { SnapshotNode } from '@agent-device/kernel/snapshot';
import * as selectorsFacade from './index.ts';
import {
buildSelectorCandidates,
readReplaySelectorDisplayValue,
readSelectorExpression,
resolveRecordedTarget,
resolveReplaySuggestionCandidate,
resolveSelectorChain,
resolveSelectorChainWithPolicy,
SELECTOR_RESOLUTION_POLICIES,
} from './index.ts';

const saveNode: SnapshotNode = {
Expand Down Expand Up @@ -177,11 +179,38 @@ test('replay suggestion resolution and display values stay string-only at the fa
assert.equal(readReplaySelectorDisplayValue('label="Save"'), 'Save');
assert.equal(readReplaySelectorDisplayValue('label="Save" || label="Draft"'), undefined);

const resolved = resolveSelectorChain([saveNode], 'id="save"', {
platform: 'ios',
requireRect: true,
requireUnique: true,
});
assert.equal(resolved?.selector, 'id="save"');
assert.equal(typeof resolved?.selector, 'string');
const resolved = resolveSelectorChainWithPolicy(
[saveNode],
'id="save"',
SELECTOR_RESOLUTION_POLICIES.act,
{ platform: 'ios' },
);
assert.equal(resolved.kind, 'resolved');
assert.equal(resolved.kind === 'resolved' ? resolved.resolution.selector : null, 'id="save"');
});

/**
* #1630 made `resolveSelectorChainWithPolicy` the façade's ONLY resolution
* entry: a native caller states its ambiguity contract by naming a policy row
* because there is no knob-taking or count-only resolver here to state it
* inline with instead.
*
* This guard is structural on purpose. Restoring either removed lookup is
* BEHAVIOURALLY invisible — `findSelectorChainMatch` is equivalent to the
* `readAny` row it was migrated to, which is exactly why that migration
* preserved semantics — so no fixture-tree assertion can catch a revert
* (#1715 review). The absence of the symbol is the only observable.
*/
test('the façade exposes no resolver that bypasses the policy matrix', () => {
const exported = Object.keys(selectorsFacade);
assert.ok(exported.includes('resolveSelectorChainWithPolicy'));
assert.ok(!exported.includes('resolveSelectorChain'), 'knob-taking resolver must stay private');
assert.ok(
!exported.includes('findSelectorChainMatch'),
'count-only existence lookup must stay private; `is exists` names the readAny row',
);
assert.ok(
!exported.includes('selectorResolutionKnobs'),
'knob derivation must stay private so a call site cannot rebuild a contract from knobs',
);
});
48 changes: 9 additions & 39 deletions packages/selectors/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,8 @@ import type { SnapshotState } from '@agent-device/kernel/snapshot';
import type { Selector } from './internal/parse.ts';
import type {
PolicyResolutionOutcome,
SelectorChainMatch,
SelectorChainMatchList,
SelectorMatchOptions,
SelectorResolution,
SelectorResolutionOptions,
} from './internal/public-resolution-types.ts';
import { resolveSelectorChainWithPolicy as resolveSelectorChainWithPolicyAst } from './internal/resolve-with-policy.ts';
import {
Expand Down Expand Up @@ -34,9 +31,7 @@ import {
normalizeIsPositionals,
} from './internal/predicates.ts';
import {
findSelectorChainMatch as findSelectorChainMatchAst,
listSelectorChainMatches as listSelectorChainMatchesAst,
resolveSelectorChain as resolveSelectorChainAst,
selectorFailureHint,
STALE_REF_HINT,
} from './internal/resolve.ts';
Expand Down Expand Up @@ -64,7 +59,6 @@ export type { IsPredicate } from './internal/predicates.ts';
export type {
PolicyResolutionOutcome,
SelectorChainMatchList,
SelectorChainMatch,
SelectorResolution,
} from './internal/public-resolution-types.ts';
export { formatSelectorFailure } from './internal/resolve.ts';
Expand All @@ -81,7 +75,6 @@ export {
detectUnknownSelectorKeyToken,
evaluateIsPredicate,
findBestMatchesByLocator,
findSelectorChainMatch,
isReadOnlyFindAction,
normalizeFindActionToken,
isRoleHintWord,
Expand All @@ -99,7 +92,6 @@ export {
readSelectorExpression,
resolveRecordedTarget,
resolveReplaySuggestionCandidate,
resolveSelectorChain,
selectorFailureHint,
selectorContainsValue,
splitSelectorFromArgs,
Expand Down Expand Up @@ -233,16 +225,6 @@ function validateSelectorExpression(expression: string): void {
parseSelectorChain(expression);
}

/** Public façade wrapper that accepts/returns selector text, never an AST. */
function findSelectorChainMatch(
nodes: SnapshotState['nodes'],
expression: string,
options: SelectorMatchOptions,
): SelectorChainMatch | null {
const result = findSelectorChainMatchAst(nodes, parseSelectorChain(expression), options);
return result ? { ...result, selector: result.selector.raw } : null;
}

/** Public façade wrapper that accepts/returns selector text, never an AST. */
function listSelectorChainMatches(
nodes: SnapshotState['nodes'],
Expand All @@ -253,37 +235,25 @@ function listSelectorChainMatches(
return result ? { ...result, selector: result.selector.raw } : null;
}

/** Public façade wrapper that accepts/returns selector text, never an AST. */
function resolveSelectorChain(
nodes: SnapshotState['nodes'],
expression: string,
options: SelectorResolutionOptions,
): SelectorResolution | null {
const result = resolveSelectorChainAst(nodes, parseSelectorChain(expression), options);
return result ? { ...result, selector: result.selector.raw } : null;
}
export {
SELECTOR_RESOLUTION_POLICIES,
selectorResolutionKnobs,
} from './internal/resolution-policy.ts';
export type {
KnobBackedSelectorAmbiguity,
SelectorResolutionPolicy,
} from './internal/resolution-policy.ts';
export { SELECTOR_RESOLUTION_POLICIES } from './internal/resolution-policy.ts';
export type { SelectorResolutionPolicy } from './internal/resolution-policy.ts';
import type { SelectorResolutionPolicy } from './internal/resolution-policy.ts';

/**
* Public façade wrapper that accepts selector text and returns selector text —
* never an AST, in either direction.
* The façade's ONLY selector-resolution entry (#1630): every native consumer
* of "resolve a selector against the screen" states its contract by naming a
* `SELECTOR_RESOLUTION_POLICIES` row, because there is no knob-taking resolver
* here to state it inline with instead. Accepts selector text and returns
* selector text — never an AST, in either direction.
*
* The return leg is the half that is easy to miss: the parser-side outcome
* carries the winning `Selector` node inside `resolution`, and returning it
* unchanged would put a package-private parser object back in every caller's
* hands through a nested field. The façade's own boundary gate reads exported
* *names*, so it cannot see that; `selector-wait.ts` reading
* `outcome.resolution.selector.raw` was the runtime proof it had happened.
* Flattening here is the same treatment `resolveSelectorChain` above gives
* `AstSelectorResolution` (#1589).
* Flattening here is the same treatment `listSelectorChainMatches` above gives
* its own selector node (#1589).
*/
function resolveSelectorChainWithPolicy(
nodes: SnapshotState['nodes'],
Expand Down
21 changes: 8 additions & 13 deletions packages/selectors/src/internal/public-resolution-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,14 @@ export type PolicyResolutionOutcome =
/** No selector alternative matched anything. */
| { kind: 'none' }
/**
* The node this policy authorizes acting on, plus the full candidate set of
* the alternative it came from. Callers that verify identity across
* candidates (wait's #1349 landmark check) need the whole set — a policy
* that picks one winner must not throw the rest away, or a first impostor
* would hide a later genuine match.
* The node this policy authorizes acting on, plus the candidate set of the
* first matching alternative. Callers that verify identity across candidates
* (wait's #1349 landmark check) need the whole set — a policy that picks one
* winner must not throw the rest away, or a first impostor would hide a
* later genuine match. Those callers use `first-match` rows, where the two
* fields describe the same alternative by construction; a uniqueness row can
* resolve from a LATER alternative, so pair `matchedNodes` with
* `resolution.selector` before reading them as one set.
*/
| { kind: 'resolved'; resolution: SelectorResolution; matchedNodes: SnapshotNode[] }
/**
Expand All @@ -66,14 +69,6 @@ export type SelectorChainMatchList = {
matchedNodes: SnapshotNode[];
};

/** A first-match lookup used by existence checks. */
export type SelectorChainMatch = {
selectorIndex: number;
selector: string;
matches: number;
diagnostics: SelectorDiagnostics[];
};

/**
* The options every selector lookup takes. Stated once here rather than inline
* per function so a façade wrapper and the parser-side function it forwards to
Expand Down
32 changes: 20 additions & 12 deletions packages/selectors/src/internal/replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { splitIsSelectorArgs, splitSelectorFromArgs } from './arguments.ts';
import { buildSelectorChainForNode } from './build.ts';
import { matchesSelector } from './match.ts';
import { tryParseSelectorChain } from './parse.ts';
import { selectorResolutionKnobs } from './resolution-policy.ts';
import { listSelectorChainMatches, resolveSelectorChain } from './resolve.ts';

/**
Expand Down Expand Up @@ -78,6 +79,23 @@ export function readSelectorExpression(
return { kind: 'expression', expression: split.selectorExpression, rest: split.rest };
}

/**
* A replay policy states its ambiguity contract per request rather than per
* caller, so it names an ambiguity KIND from the same vocabulary the static
* matrix uses and derives its engine knobs through the same function (#1630) —
* `selectorResolutionKnobs` stays the only place `requireUnique` and
* `disambiguateAmbiguous` are named.
*/
function recordedTargetResolutionOptions(policy: ReplayRecordedTargetPolicy) {
return {
platform: policy.platform,
...selectorResolutionKnobs({
ambiguity: policy.allowDisambiguation ? 'disambiguate' : 'fail-closed',
requireRect: policy.requireRect,
}),
};
}

/** Resolve a recorded target and return the winning node plus its same-alternative domain. */
export function resolveRecordedTarget(
expression: string,
Expand All @@ -86,12 +104,7 @@ export function resolveRecordedTarget(
): ReplayRecordedTargetResolution {
const chain = tryParseSelectorChain(expression);
if (!chain) return { kind: 'unresolved', reason: 'parse-invalid', matchedNodes: [] };
const resolved = resolveSelectorChain(nodes, chain, {
platform: policy.platform,
requireRect: policy.requireRect,
requireUnique: true,
disambiguateAmbiguous: policy.allowDisambiguation,
});
const resolved = resolveSelectorChain(nodes, chain, recordedTargetResolutionOptions(policy));
if (resolved) {
const matchedNodes = nodes.filter((node) => {
if (policy.requireRect && !node.rect) return false;
Expand Down Expand Up @@ -147,12 +160,7 @@ export function resolveReplaySuggestionCandidate(
): ReplaySuggestionCandidateMatch | undefined {
const chain = tryParseSelectorChain(candidate);
if (!chain) return undefined;
const resolved = resolveSelectorChain(nodes, chain, {
platform: policy.platform,
requireRect: policy.requireRect,
requireUnique: true,
disambiguateAmbiguous: policy.allowDisambiguation,
});
const resolved = resolveSelectorChain(nodes, chain, recordedTargetResolutionOptions(policy));
if (!resolved) return undefined;
return { node: resolved.node, basis: classifySuggestionBasis(resolved.selector) };
}
Expand Down
25 changes: 25 additions & 0 deletions packages/selectors/src/internal/resolution-policy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import { SELECTOR_RESOLUTION_POLICIES, selectorResolutionKnobs } from './resolution-policy.ts';

/**
* `selectorResolutionKnobs` is package-private (#1630): the façade exports no
* resolver that accepts `requireUnique`/`disambiguateAmbiguous`, so this is the
* only place in the repo that names them and the only place a knob-vs-row
* mismatch can be introduced. The row-level BEHAVIOR each mapping produces is
* pinned separately, through the public interface, in
* src/commands/interaction/runtime/__tests__/resolution-policy-parity.test.ts.
*/
test('knobs stay consistent with the ambiguity each knob-backed row names', () => {
for (const [name, policy] of Object.entries(SELECTOR_RESOLUTION_POLICIES)) {
if (policy.ambiguity === 'reject-candidates') continue;
const knobs = selectorResolutionKnobs(policy);
assert.equal(knobs.requireRect, policy.requireRect, name);
if (policy.ambiguity === 'first-match') {
assert.equal(knobs.requireUnique, false, name);
} else {
assert.equal(knobs.requireUnique, true, name);
assert.equal(knobs.disambiguateAmbiguous, policy.ambiguity === 'disambiguate', name);
}
}
});
12 changes: 8 additions & 4 deletions packages/selectors/src/internal/resolution-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ import type { SelectorResolutionOptions } from './public-resolution-types.ts';
*
* Scope, deliberately narrow: this matrix declares the **ambiguity contract
* and the rect requirement**, and nothing else. Both are consumed by
* `resolveSelectorChainWithPolicy` and pinned behaviorally in
* resolution-policy-parity.test.ts, so a row that stops matching its
* `resolveSelectorChainWithPolicy` — the one door native callers have, since
* the package façade exports no knob-taking resolver — and pinned behaviorally
* in resolution-policy-parity.test.ts, so a row that stops matching its
* documented semantics fails a test.
*
* The surrounding pipeline stages — occlusion, the off-screen guard,
Expand Down Expand Up @@ -82,8 +83,11 @@ export const SELECTOR_RESOLUTION_POLICIES = {
} as const satisfies Record<string, SelectorResolutionPolicy>;

/**
* The engine knobs a knob-backed policy row stands for. `reject-candidates`
* rows are rejected at the type level — that contract is enforced by caller
* The engine knobs a knob-backed policy row stands for, and the only place in
* the repo that names `requireUnique`/`disambiguateAmbiguous` (#1630): the
* façade exports no resolver that accepts them, so a caller cannot restate an
* ambiguity contract as knobs even by accident. `reject-candidates` rows are
* rejected at the type level — that contract is enforced by caller
* classification, not by these knobs.
*/
export function selectorResolutionKnobs(
Expand Down
Loading
Loading