mirror of
https://github.com/expo/explore.git
synced 2026-11-14 10:08:17 -10:00
Co-authored-by: Brent Vatne <[email protected]> Co-authored-by: Szymon Dziedzic <[email protected]>
500 lines
31 KiB
JavaScript
500 lines
31 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { credentialSavePromptDecline, executeCaptureSetup, isUnreadableSystemSurface } from '../../dist/capture/setup-actions.js';
|
|
import { CiError } from '../../dist/shared/ci-errors.js';
|
|
import { CAPTURE_OBSERVER_IDENTIFIER } from '../../dist/capture/observer.js';
|
|
|
|
const node = (ref, type, label, rest = {}) => ({ ref, type, label, enabled: true, hittable: false, ...rest });
|
|
const snapshot = (generation, nodes) => ({ refsGeneration: generation, truncated: false, nodes });
|
|
const inputSnapshot = (generation, { keyboard = true, pathname = '/account', parameters = {}, input = {}, extra = [] } = {}) => snapshot(generation, [
|
|
{ identifier: CAPTURE_OBSERVER_IDENTIFIER, label: JSON.stringify({ provider: 'expo-router', pathname, segments: pathname.split('/').filter(Boolean), parameters }) },
|
|
node('e7', 'SecureTextField', 'Password', { identifier: 'password', rect: { x: 10, y: 100, width: 200, height: 40 }, ...input }),
|
|
...(keyboard ? [node('e8', 'Keyboard', 'Keyboard', { rect: { x: 0, y: 600, width: 400, height: 250 } })] : []), ...extra,
|
|
]);
|
|
|
|
function runtime(snapshots, overrides = {}) {
|
|
const events = [];
|
|
let index = 0;
|
|
return { events, options: {
|
|
command: args => { events.push(['command', args]); },
|
|
open: url => { events.push(['open', url]); },
|
|
snapshot: () => { events.push(['snapshot']); return snapshots[Math.min(index++, snapshots.length - 1)]; },
|
|
afterAction: event => { events.push(['afterAction', event.index, event.snapshot.refsGeneration]); },
|
|
readinessAttempts: 3,
|
|
pollMilliseconds: 100,
|
|
...overrides,
|
|
} };
|
|
}
|
|
|
|
test('caller-owned post-action observation preserves fresh selector resolution without a duplicate trailing read', async () => {
|
|
const { events, options } = runtime([snapshot(20, [node('e13', 'Button', 'Continue')])], {
|
|
afterAction: undefined, postActionObservation: 'caller',
|
|
});
|
|
await executeCaptureSetup([{ action: 'tap', label: 'Continue' }], options);
|
|
assert.deepEqual(events, [['snapshot'], ['command', ['press', '@e13~s20', '--level', 'full', '--json']]]);
|
|
});
|
|
|
|
test('caller-owned observation cannot skip explicit readiness or after-action evidence', async () => {
|
|
const { events, options } = runtime([snapshot(20, []), snapshot(21, [node('e13', 'Button', 'Continue')])], {
|
|
afterAction: undefined, postActionObservation: 'caller',
|
|
});
|
|
await executeCaptureSetup([{ action: 'open', url: 'sample://next', ready: { label: 'Continue' } }], options);
|
|
assert.equal(events.filter(event => event[0] === 'snapshot').length, 2);
|
|
const withCallback = runtime([snapshot(22, [])], { postActionObservation: 'caller' });
|
|
await executeCaptureSetup([{ action: 'wait', milliseconds: 1 }], withCallback.options);
|
|
assert.deepEqual(withCallback.events.slice(-2), [['snapshot'], ['afterAction', 0, 22]]);
|
|
const unmet = runtime([snapshot(23, [])], { afterAction: undefined, postActionObservation: 'caller' });
|
|
await assert.rejects(executeCaptureSetup([{ action: 'open', url: 'sample://next', ready: { label: 'Missing' } }], unmet.options),
|
|
{ code: 'SETUP_READINESS_TIMEOUT' });
|
|
});
|
|
|
|
test('fills the real input instead of its same-label wrapper and uses the fresh snapshot generation', async () => {
|
|
const first = snapshot(20, [node('e12', 'Other', 'Enter cat name'), node('e13', 'TextField', 'Enter cat name')]);
|
|
const second = snapshot(21, [node('e13', 'TextField', 'Enter cat name'), node('e18', 'Button', 'Continue')]);
|
|
const third = snapshot(22, [node('e11', 'Button', 'Male')]);
|
|
const { events, options } = runtime([first, second, second, third]);
|
|
await executeCaptureSetup([
|
|
{ action: 'fill', label: 'Enter cat name', text: 'Milo' },
|
|
{ action: 'tap', label: 'Continue' },
|
|
], options);
|
|
assert.deepEqual(events.filter(event => event[0] === 'command').map(event => event[1]), [
|
|
['fill', '@e13~s20', 'Milo', '--level', 'full', '--json'],
|
|
['press', '@e18~s21', '--level', 'full', '--json'],
|
|
]);
|
|
});
|
|
|
|
test('explicit input tap permits one native type from fresh observed focus without claiming verified contents', async () => {
|
|
const f = runtime([inputSnapshot(1, { keyboard: false }), inputSnapshot(2, { keyboard: false }), inputSnapshot(3), inputSnapshot(4)]);
|
|
await executeCaptureSetup([{ action: 'tap', identifier: 'password' }, { action: 'type', text: 'fixture-input' }], f.options);
|
|
assert.deepEqual(f.events.filter(event => event[0] === 'command').map(event => event[1]), [
|
|
['press', '@e7~s1', '--level', 'full', '--json'], ['type', 'fixture-input', '--level', 'full', '--json'],
|
|
]);
|
|
assert.deepEqual(f.events.filter(event => event[0] === 'afterAction').map(event => event.slice(1)), [[0, 2], [1, 4]]);
|
|
const repeated = runtime([inputSnapshot(1)]);
|
|
await assert.rejects(executeCaptureSetup([{ action: 'tap', identifier: 'password' },
|
|
{ action: 'type', text: 'first' }, { action: 'type', text: 'second' }], repeated.options), { code: 'SETUP_TYPE_FOCUS_REQUIRED' });
|
|
assert.equal(repeated.events.filter(event => event[0] === 'command' && event[1][0] === 'type').length, 1);
|
|
});
|
|
|
|
test('typing fails closed on changed focus evidence and any intervening setup action', async () => {
|
|
for (const changed of [
|
|
{ pathname: '/other' }, { parameters: { account: 'other' } }, { keyboard: false },
|
|
{ input: { enabled: false } }, { input: { type: 'TextField' } },
|
|
{ extra: [node('e9', 'SecureTextField', 'Password', { identifier: 'password' })] },
|
|
{ extra: [node('e10', 'Alert', 'Unexpected alert')] },
|
|
]) {
|
|
const f = runtime([inputSnapshot(1), inputSnapshot(2), inputSnapshot(3, changed)]);
|
|
await assert.rejects(executeCaptureSetup([{ action: 'tap', identifier: 'password' }, { action: 'type', text: 'private-input' }], f.options),
|
|
error => error.code === 'SETUP_TYPE_FOCUS_REQUIRED' && !JSON.stringify(error).includes('private-input'));
|
|
assert.equal(f.events.some(event => event[0] === 'command' && event[1][0] === 'type'), false);
|
|
}
|
|
for (const intervening of [{ action: 'wait', milliseconds: 1 }, { action: 'scroll', direction: 'bottom' },
|
|
{ action: 'fill', identifier: 'password', text: 'existing' }, { action: 'open', url: 'sample://account' }]) {
|
|
const f = runtime([inputSnapshot(1)]);
|
|
await assert.rejects(executeCaptureSetup([{ action: 'tap', identifier: 'password' }, intervening, { action: 'type', text: 'value' }], f.options),
|
|
{ code: 'SETUP_TYPE_FOCUS_REQUIRED' });
|
|
assert.equal(f.events.some(event => event[0] === 'command' && event[1][0] === 'type'), false);
|
|
}
|
|
});
|
|
|
|
test('deterministic typing waits for keyboard readiness and stops on changed input evidence', async () => {
|
|
for (const changed of [false, true]) {
|
|
const f = runtime([inputSnapshot(1, { keyboard: false }), inputSnapshot(2, { keyboard: false }),
|
|
inputSnapshot(3, { keyboard: false }), inputSnapshot(4, changed ? { pathname: '/other' } : {}), inputSnapshot(5)]);
|
|
const run = executeCaptureSetup([{ action: 'tap', identifier: 'password' }, { action: 'type', text: 'fixture-input' }], f.options);
|
|
if (changed) await assert.rejects(run, { code: 'SETUP_TYPE_FOCUS_REQUIRED' });
|
|
else await run;
|
|
assert.equal(f.events.filter(event => event[0] === 'command' && event[1][0] === 'wait').length, 1);
|
|
assert.equal(f.events.filter(event => event[0] === 'command' && event[1][0] === 'type').length, changed ? 0 : 1);
|
|
}
|
|
});
|
|
|
|
test('native typing failure is never replayed and invalid text is rejected before dispatch', async () => {
|
|
let types = 0;
|
|
const f = runtime([inputSnapshot(1)], { command: args => { if (args[0] === 'type') { types++; throw new Error('uncertain native result'); } } });
|
|
await assert.rejects(executeCaptureSetup([{ action: 'tap', identifier: 'password' }, { action: 'type', text: 'value' },
|
|
{ action: 'type', text: 'replay' }], f.options), /uncertain native result/);
|
|
assert.equal(types, 1);
|
|
await assert.rejects(executeCaptureSetup([{ action: 'type', text: 'new invocation' }], f.options), { code: 'SETUP_TYPE_FOCUS_REQUIRED' });
|
|
for (const text of ['', 'line\nbreak', 'x'.repeat(4097)]) {
|
|
await assert.rejects(executeCaptureSetup([{ action: 'tap', identifier: 'password' }, { action: 'type', text }], f.options), { code: 'SETUP_INPUT_INVALID' });
|
|
}
|
|
assert.equal(types, 1);
|
|
});
|
|
|
|
test('identifier selection prefers the enabled button over its wrapper without relying on hittable', async () => {
|
|
const nodes = [node('e2', 'Other', 'Continue', { identifier: 'continue' }),
|
|
node('e3', 'Button', 'Continue', { identifier: 'continue' })];
|
|
const { events, options } = runtime([snapshot(15, nodes)]);
|
|
await executeCaptureSetup([{ action: 'tap', identifier: 'continue' }], options);
|
|
assert.deepEqual(events.find(event => event[0] === 'command')[1], ['press', '@e3~s15', '--level', 'full', '--json']);
|
|
});
|
|
|
|
test('explicit input taps select the unique field over its same-selector label and wrapper', async () => {
|
|
for (const type of ['TextField', 'SecureTextField', 'TextView', 'SearchField']) {
|
|
for (const selector of [{ label: 'Account input' }, { identifier: 'account-input' }]) {
|
|
const nodes = [
|
|
node('e2', 'Other', 'Account input', { identifier: 'account-input', rect: { x: 0, y: 0, width: 402, height: 874 } }),
|
|
node('e3', 'StaticText', 'Account input', { identifier: 'account-input', rect: { x: 24, y: 80, width: 100, height: 20 } }),
|
|
node('e4', type, 'Account input', { identifier: 'account-input', rect: { x: 24, y: 110, width: 354, height: 50 } }),
|
|
];
|
|
const { events, options } = runtime([snapshot(15, nodes)]);
|
|
await executeCaptureSetup([{ action: 'tap', ...selector }], options);
|
|
assert.deepEqual(events.filter(event => event[0] === 'command').map(event => event[1]), [
|
|
['press', '@e4~s15', '--level', 'full', '--json'],
|
|
]);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('ambiguous input taps fail without choosing a field or its label', async () => {
|
|
const nodes = [node('e2', 'StaticText', 'Account input'), node('e3', 'TextField', 'Account input'),
|
|
node('e4', 'SecureTextField', 'Account input')];
|
|
const { events, options } = runtime([snapshot(15, nodes)]);
|
|
await assert.rejects(executeCaptureSetup([{ action: 'tap', label: 'Account input' }], options),
|
|
{ code: 'SETUP_SELECTOR_AMBIGUOUS' });
|
|
assert.equal(events.some(event => event[0] === 'command'), false);
|
|
});
|
|
|
|
test('a disabled input tap never falls back to its label or wrapper', async () => {
|
|
const nodes = [node('e2', 'Other', 'Account input'), node('e3', 'StaticText', 'Account input'),
|
|
node('e4', 'TextField', 'Account input', { enabled: false })];
|
|
const { events, options } = runtime([snapshot(15, nodes)]);
|
|
await assert.rejects(executeCaptureSetup([{ action: 'tap', label: 'Account input' }], options),
|
|
{ code: 'SETUP_CONTROL_NOT_READY' });
|
|
assert.deepEqual(events.filter(event => event[0] === 'command').map(event => event[1][0]), ['wait', 'wait']);
|
|
});
|
|
|
|
test('a unique Other control supports the app microphone button', async () => {
|
|
const { events, options } = runtime([snapshot(15, [node('e19', 'Other', 'Start recording')])]);
|
|
await executeCaptureSetup([{ action: 'tap', label: 'Start recording' }], options);
|
|
assert.equal(events.find(event => event[0] === 'command')[1][1], '@e19~s15');
|
|
});
|
|
|
|
test('Android View classes resolve like iOS element types: a ViewGroup row is pressed and an EditText is filled', async () => {
|
|
const row = { identifier: 'lane-row-title-0', rect: { x: 30, y: 217, width: 510, height: 105 }, hittable: true };
|
|
const pressed = runtime([snapshot(3, [
|
|
node('e40', 'android.view.ViewGroup', undefined, row),
|
|
node('e41', 'android.widget.TextView', 'Kick', { rect: { x: 56, y: 240, width: 60, height: 40 } }),
|
|
])]);
|
|
await executeCaptureSetup([{ action: 'tap', identifier: 'lane-row-title-0' }], pressed.options);
|
|
assert.deepEqual(pressed.events.find(event => event[0] === 'command')[1].slice(0, 2), ['press', '@e40~s3']);
|
|
const filled = runtime([snapshot(4, [
|
|
node('e1', 'android.view.ViewGroup', 'Name', { rect: { x: 0, y: 90, width: 400, height: 60 } }),
|
|
node('e2', 'android.widget.TextView', 'Name', { rect: { x: 10, y: 100, width: 80, height: 40 } }),
|
|
node('e3', 'android.widget.EditText', 'Name', { rect: { x: 100, y: 100, width: 200, height: 40 } }),
|
|
])]);
|
|
await executeCaptureSetup([{ action: 'fill', label: 'Name', text: 'Lead' }], filled.options);
|
|
assert.deepEqual(filled.events.find(event => event[0] === 'command')[1].slice(0, 3), ['fill', '@e3~s4', 'Lead']);
|
|
});
|
|
|
|
test('disabled controls are polled but never pressed until enabled', async () => {
|
|
const { events, options } = runtime([
|
|
snapshot(10, [node('e3', 'Button', 'Continue', { enabled: false })]),
|
|
snapshot(11, [node('e6', 'Button', 'Continue')]),
|
|
snapshot(12, []),
|
|
]);
|
|
await executeCaptureSetup([{ action: 'tap', label: 'Continue' }], options);
|
|
assert.deepEqual(events.filter(event => event[0] === 'command').map(event => event[1].slice(0, 2)), [
|
|
['wait', '100'], ['press', '@e6~s11'],
|
|
]);
|
|
});
|
|
|
|
test('ambiguous labels fail without tapping a guessed control', async () => {
|
|
const { events, options } = runtime([snapshot(10, [node('e3', 'Button', 'Continue'), node('e4', 'Button', 'Continue')])]);
|
|
await assert.rejects(executeCaptureSetup([{ action: 'tap', label: 'Continue' }], options), { code: 'SETUP_SELECTOR_AMBIGUOUS' });
|
|
assert.equal(events.some(event => event[0] === 'command'), false);
|
|
});
|
|
|
|
test('a nested text exposed twice with one frame is one control, but separate frames stay ambiguous', async () => {
|
|
// Observed on agent-device 0.21.15: a form title appeared as two StaticText nodes (e4, e5) with one frame.
|
|
const title = (ref, y = 94) => node(ref, 'StaticText', "What is your cat's name?", { rect: { x: 24, y, width: 354, height: 76 } });
|
|
const { events, options } = runtime([snapshot(12, [title('e4'), title('e5')])]);
|
|
await executeCaptureSetup([{ action: 'tap', label: "What is your cat's name?" }], options);
|
|
assert.deepEqual(events.filter(event => event[0] === 'command').map(event => event[1]), [['press', '@e5~s12', '--level', 'full', '--json']]);
|
|
const separate = runtime([snapshot(13, [title('e4'), title('e5', 300)])]);
|
|
await assert.rejects(executeCaptureSetup([{ action: 'tap', label: "What is your cat's name?" }], separate.options), { code: 'SETUP_SELECTOR_AMBIGUOUS' });
|
|
assert.equal(separate.events.some(event => event[0] === 'command'), false);
|
|
});
|
|
|
|
test('only a visible iOS password save or update prompt yields its unique Not Now button', () => {
|
|
const notNow = node('e6', 'Button', 'Not Now');
|
|
for (const title of ['Save Password?', 'Update Password?']) assert.equal(credentialSavePromptDecline([node('e3', 'StaticText', title), notNow, node('e7', 'Button', 'Save')]), notNow);
|
|
assert.equal(credentialSavePromptDecline([node('e3', 'StaticText', 'Turn on notifications?'), notNow]), undefined);
|
|
assert.equal(credentialSavePromptDecline([node('e3', 'StaticText', 'Save Password?', { visible: false }), notNow]), undefined);
|
|
assert.equal(credentialSavePromptDecline([node('e3', 'StaticText', 'Save Password?'), notNow, node('e8', 'Button', 'Not Now')]), undefined);
|
|
assert.equal(credentialSavePromptDecline([node('e3', 'StaticText', 'Save Password?'), node('e6', 'Button', 'Not Now', { enabled: false })]), undefined);
|
|
});
|
|
|
|
test('missing controls exhaust bounded waits, never execute subsequent actions, and do not expose fill text', async () => {
|
|
const { events, options } = runtime([snapshot(10, [])]);
|
|
await assert.rejects(executeCaptureSetup([
|
|
{ action: 'fill', identifier: 'email', text: '[email protected]' },
|
|
{ action: 'open', url: 'sample://unexpected' },
|
|
], options), error => {
|
|
assert.equal(error.code, 'SETUP_CONTROL_NOT_READY');
|
|
assert.equal(JSON.stringify(error).includes('private-input'), false);
|
|
return true;
|
|
});
|
|
assert.equal(events.filter(event => event[0] === 'snapshot').length, 3);
|
|
assert.equal(events.filter(event => event[0] === 'command').length, 2);
|
|
assert.equal(events.some(event => event[0] === 'open'), false);
|
|
});
|
|
|
|
test('post-action observation runs once after readiness succeeds and never relaunches', async () => {
|
|
const { events, options } = runtime([snapshot(10, []), snapshot(11, [node('e3', 'Button', 'Continue')])]);
|
|
await executeCaptureSetup([{ action: 'open', url: 'sample://scan/camera', ready: { label: 'Continue', enabled: true } }], options);
|
|
assert.deepEqual(events, [
|
|
['open', 'sample://scan/camera'], ['snapshot'],
|
|
['command', ['wait', '100', '--level', 'full', '--json']], ['snapshot'],
|
|
['afterAction', 0, 11],
|
|
]);
|
|
assert.equal(JSON.stringify(events).includes('relaunch'), false);
|
|
assert.deepEqual(events.filter(event => event[0] === 'afterAction'), [
|
|
['afterAction', 0, 11],
|
|
]);
|
|
});
|
|
|
|
test('actions without readiness expose transient destinations before the next action', async () => {
|
|
const { events, options } = runtime([snapshot(10, []), snapshot(11, [])]);
|
|
await executeCaptureSetup([
|
|
{ action: 'open', url: 'sample://scan/loading' },
|
|
{ action: 'open', url: 'sample://scan/result' },
|
|
], options);
|
|
assert.deepEqual(events, [
|
|
['open', 'sample://scan/loading'], ['snapshot'], ['afterAction', 0, 10],
|
|
['open', 'sample://scan/result'], ['snapshot'], ['afterAction', 1, 11],
|
|
]);
|
|
});
|
|
|
|
test('scroll and fixed waits share command budget and support enabled readiness', async () => {
|
|
const { events, options } = runtime([
|
|
snapshot(10, [node('e3', 'Button', 'Continue', { enabled: false })]),
|
|
snapshot(11, [node('e3', 'Button', 'Continue')]),
|
|
]);
|
|
await executeCaptureSetup([
|
|
{ action: 'scroll', direction: 'bottom', ready: { label: 'Continue', enabled: true } },
|
|
{ action: 'wait', milliseconds: 50 },
|
|
], options);
|
|
assert.deepEqual(events.filter(event => event[0] === 'command').map(event => event[1].slice(0, 2)), [
|
|
['scroll', 'bottom'], ['wait', '100'], ['wait', '50'],
|
|
]);
|
|
});
|
|
|
|
test('readiness failure is explicit and does not repeat the mutation', async () => {
|
|
const { events, options } = runtime([snapshot(10, [])]);
|
|
await assert.rejects(executeCaptureSetup([{ action: 'scroll', direction: 'bottom', ready: { label: 'Continue' } }], options),
|
|
{ code: 'SETUP_READINESS_TIMEOUT' });
|
|
assert.equal(events.filter(event => event[0] === 'command' && event[1][0] === 'scroll').length, 1);
|
|
assert.equal(events.some(event => event[0] === 'afterAction'), false);
|
|
});
|
|
|
|
test('caller budget errors survive unchanged and stop the sequence', async () => {
|
|
const error = new CiError({ code: 'CAPTURE_BUDGET_EXHAUSTED', stage: 'capture', message: 'Exhausted', nextAction: 'Increase capture budget.' });
|
|
const { events, options } = runtime([snapshot(10, [])], { command: () => { throw error; } });
|
|
await assert.rejects(executeCaptureSetup([{ action: 'wait', milliseconds: 10 }, { action: 'open', url: 'sample://next' }], options), value => value === error);
|
|
assert.equal(events.length, 0);
|
|
});
|
|
|
|
test('truncated snapshots and missing generations never produce unsafe unpinned presses', async () => {
|
|
for (const [value, code] of [
|
|
[{ ...snapshot(10, [node('e3', 'Button', 'Continue')]), truncated: true }, 'SETUP_SNAPSHOT_INVALID'],
|
|
[{ nodes: [node('e3', 'Button', 'Continue')] }, 'SETUP_REFERENCE_UNAVAILABLE'],
|
|
]) {
|
|
const { events, options } = runtime([value]);
|
|
await assert.rejects(executeCaptureSetup([{ action: 'tap', label: 'Continue' }], options), { code });
|
|
assert.equal(events.some(event => event[0] === 'command'), false);
|
|
}
|
|
});
|
|
|
|
const keyboard = ref => node(ref, 'Keyboard', 'Padding-Left', { rect: { x: 0, y: 583, width: 402, height: 233 } });
|
|
const covered = ref => node(ref, 'Button', 'Continue', { rect: { x: 80, y: 779, width: 300, height: 49 } });
|
|
|
|
test('dismisses a keyboard that covers the tap target and presses from a fresh snapshot', async () => {
|
|
const open = snapshot(30, [keyboard('e16'), covered('e71')]);
|
|
const closed = snapshot(31, [node('e9', 'Button', 'Continue', { rect: { x: 80, y: 779, width: 300, height: 49 } })]);
|
|
const { events, options } = runtime([open, closed]);
|
|
await executeCaptureSetup([{ action: 'tap', label: 'Continue' }], options);
|
|
assert.deepEqual(events.filter(event => event[0] === 'command').map(event => event[1]), [
|
|
['keyboard', 'dismiss', '--level', 'full', '--json'],
|
|
['press', '@e9~s31', '--level', 'full', '--json'],
|
|
]);
|
|
});
|
|
|
|
test('agent-device 0.21.15 keys without a Keyboard element still cover the tap target and enable typing', async () => {
|
|
// Shape of an agent-device 0.21.15 setup snapshot: Key rows and the input assistant only.
|
|
const keys = [node('e20', 'Key', 'q', { rect: { x: 5, y: 590, width: 36, height: 42 } }),
|
|
node('e21', 'Key', 'Done', { rect: { x: 300, y: 755, width: 94, height: 42 } })];
|
|
const assistant = node('e12', 'Other', undefined, { identifier: 'SystemInputAssistantView', rect: { x: 0, y: 539, width: 402, height: 44 } });
|
|
const open = snapshot(30, [...keys, assistant, covered('e10')]);
|
|
const closed = snapshot(31, [node('e9', 'Button', 'Continue', { rect: { x: 80, y: 779, width: 300, height: 49 } })]);
|
|
const { events, options } = runtime([open, closed]);
|
|
await executeCaptureSetup([{ action: 'tap', label: 'Continue' }], options);
|
|
assert.deepEqual(events.filter(event => event[0] === 'command').map(event => event[1]), [
|
|
['keyboard', 'dismiss', '--level', 'full', '--json'],
|
|
['press', '@e9~s31', '--level', 'full', '--json'],
|
|
]);
|
|
const withKeys = generation => snapshot(generation, [...inputSnapshot(generation, { keyboard: false }).nodes, ...keys]);
|
|
const f = runtime([inputSnapshot(1, { keyboard: false }), inputSnapshot(2, { keyboard: false }), withKeys(3), withKeys(4)]);
|
|
await executeCaptureSetup([{ action: 'tap', identifier: 'password' }, { action: 'type', text: 'fixture-input' }], f.options);
|
|
assert.deepEqual(f.events.filter(event => event[0] === 'command').map(event => event[1][0]), ['press', 'type']);
|
|
});
|
|
|
|
test('dismisses a keyboard covering an input tap or fill and reselects it from a fresh generation', async () => {
|
|
const open = snapshot(30, [keyboard('e16'), node('e71', 'TextField', 'Email', {
|
|
identifier: 'auth-email', rect: { x: 24, y: 620, width: 354, height: 50 },
|
|
})]);
|
|
const closed = snapshot(31, [node('e9', 'TextField', 'Email', {
|
|
identifier: 'auth-email', rect: { x: 24, y: 510, width: 354, height: 50 },
|
|
})]);
|
|
for (const action of [{ action: 'tap', identifier: 'auth-email' },
|
|
{ action: 'fill', identifier: 'auth-email', text: '[email protected]' }]) {
|
|
const { events, options } = runtime([open, closed]);
|
|
await executeCaptureSetup([action], options);
|
|
assert.deepEqual(events.filter(event => event[0] === 'command').map(event => event[1]), [
|
|
['keyboard', 'dismiss', '--level', 'full', '--json'],
|
|
action.action === 'tap' ? ['press', '@e9~s31', '--level', 'full', '--json']
|
|
: ['fill', '@e9~s31', '[email protected]', '--level', 'full', '--json'],
|
|
]);
|
|
}
|
|
});
|
|
|
|
test('dismisses the iOS prediction strip covering an input above the keyboard keys', async () => {
|
|
// Actual iPhone 17 AX geometry: the email center is in the assistant strip,
|
|
// while the Keyboard node begins below the entire input.
|
|
const email = ref => node(ref, 'TextField', 'Email address', {
|
|
identifier: 'auth-email', rect: { x: 46, y: 525.3333282470703, width: 310, height: 48 },
|
|
});
|
|
const assistant = node('e5', 'Other', 'Typing Predictions', {
|
|
identifier: 'SystemInputAssistantView', rect: { x: 0, y: 539, width: 402, height: 44 },
|
|
});
|
|
const { events, options } = runtime([
|
|
snapshot(30, [keyboard('e16'), assistant, email('e71')]),
|
|
snapshot(31, [email('e9')]),
|
|
]);
|
|
await executeCaptureSetup([{ action: 'fill', identifier: 'auth-email', text: '[email protected]' }], options);
|
|
assert.deepEqual(events.filter(event => event[0] === 'command').map(event => event[1]), [
|
|
['keyboard', 'dismiss', '--level', 'full', '--json'],
|
|
['fill', '@e9~s31', '[email protected]', '--level', 'full', '--json'],
|
|
]);
|
|
});
|
|
|
|
test('an input assistant is an occluder only while it and a keyboard are visible', async () => {
|
|
const assistant = node('e5', 'Other', 'Typing Predictions', {
|
|
identifier: 'SystemInputAssistantView', rect: { x: 0, y: 539, width: 402, height: 44 },
|
|
});
|
|
const input = node('e9', 'TextField', 'Email address', {
|
|
identifier: 'auth-email', rect: { x: 46, y: 525.3333282470703, width: 310, height: 48 },
|
|
});
|
|
for (const nodes of [
|
|
[assistant, input],
|
|
[{ ...keyboard('e16'), visible: false }, assistant, input],
|
|
[keyboard('e16'), { ...assistant, visible: false }, input],
|
|
]) {
|
|
const { events, options } = runtime([snapshot(40, nodes)]);
|
|
await executeCaptureSetup([{ action: 'fill', identifier: 'auth-email', text: '[email protected]' }], options);
|
|
assert.deepEqual(events.filter(event => event[0] === 'command').map(event => event[1]), [
|
|
['fill', '@e9~s40', '[email protected]', '--level', 'full', '--json'],
|
|
]);
|
|
}
|
|
});
|
|
|
|
test('an uncovered input tap or fill keeps the keyboard open', async () => {
|
|
const current = snapshot(40, [keyboard('e16'), node('e3', 'TextField', 'Email', {
|
|
identifier: 'auth-email', rect: { x: 24, y: 300, width: 354, height: 50 },
|
|
})]);
|
|
for (const action of [{ action: 'tap', identifier: 'auth-email' },
|
|
{ action: 'fill', identifier: 'auth-email', text: '[email protected]' }]) {
|
|
const { events, options } = runtime([current]);
|
|
await executeCaptureSetup([action], options);
|
|
assert.deepEqual(events.filter(event => event[0] === 'command').map(event => event[1]), [
|
|
action.action === 'tap' ? ['press', '@e3~s40', '--level', 'full', '--json']
|
|
: ['fill', '@e3~s40', '[email protected]', '--level', 'full', '--json'],
|
|
]);
|
|
}
|
|
});
|
|
|
|
test('an input tap or fill that remains covered fails after one dismissal without touching the field', async () => {
|
|
const current = snapshot(50, [keyboard('e16'), node('e71', 'SecureTextField', 'Password', {
|
|
identifier: 'auth-password', rect: { x: 24, y: 650, width: 354, height: 50 },
|
|
})]);
|
|
for (const action of [{ action: 'tap', identifier: 'auth-password' },
|
|
{ action: 'fill', identifier: 'auth-password', text: 'fixture-password' }]) {
|
|
const { events, options } = runtime([current]);
|
|
await assert.rejects(executeCaptureSetup([action], options),
|
|
error => error instanceof CiError && error.code === 'SETUP_KEYBOARD_BLOCKING');
|
|
assert.deepEqual(events.filter(event => event[0] === 'command').map(event => event[1]), [
|
|
['keyboard', 'dismiss', '--level', 'full', '--json'],
|
|
]);
|
|
}
|
|
});
|
|
|
|
test('a keyboard that does not cover the target is left open', async () => {
|
|
const { events, options } = runtime([snapshot(40, [keyboard('e16'), node('e3', 'Button', 'Continue', { rect: { x: 80, y: 300, width: 300, height: 49 } })])]);
|
|
await executeCaptureSetup([{ action: 'tap', label: 'Continue' }], options);
|
|
assert.deepEqual(events.filter(event => event[0] === 'command').map(event => event[1]), [['press', '@e3~s40', '--level', 'full', '--json']]);
|
|
});
|
|
|
|
test('a keyboard that stays over the target fails with an explicit next action and one dismissal', async () => {
|
|
const { events, options } = runtime([snapshot(50, [keyboard('e16'), covered('e71')])]);
|
|
await assert.rejects(executeCaptureSetup([{ action: 'tap', label: 'Continue' }], options),
|
|
error => error instanceof CiError && error.code === 'SETUP_KEYBOARD_BLOCKING' && /Done\/return/.test(error.nextAction));
|
|
assert.equal(events.filter(event => event[0] === 'command' && event[1][0] === 'keyboard').length, 1);
|
|
assert.equal(events.filter(event => event[0] === 'command' && event[1][0] === 'press').length, 0);
|
|
});
|
|
|
|
test('an unsupported keyboard dismissal is reported as the blocking cause', async () => {
|
|
const { options } = runtime([snapshot(60, [keyboard('e16'), covered('e71')])], {
|
|
command: args => { if (args[0] === 'keyboard') throw new Error('UNSUPPORTED_OPERATION'); },
|
|
});
|
|
await assert.rejects(executeCaptureSetup([{ action: 'tap', label: 'Continue' }], options),
|
|
error => error instanceof CiError && error.code === 'SETUP_KEYBOARD_BLOCKING' && /could not be dismissed/.test(error.message));
|
|
});
|
|
|
|
test('a same-label full-screen wrapper is not pressed in place of the text it contains', async () => {
|
|
const nodes = [
|
|
node('e61', 'Other', 'How old is your cat?', { rect: { x: 0, y: 0, width: 402, height: 874 } }),
|
|
node('e62', 'ScrollView', 'How old is your cat?', { rect: { x: 0, y: 0, width: 402, height: 874 } }),
|
|
node('e63', 'StaticText', 'How old is your cat?', { rect: { x: 24, y: 94, width: 354, height: 38 } }),
|
|
keyboard('e16'),
|
|
];
|
|
const { events, options } = runtime([snapshot(70, nodes)]);
|
|
await executeCaptureSetup([{ action: 'tap', label: 'How old is your cat?' }], options);
|
|
assert.deepEqual(events.filter(event => event[0] === 'command').map(event => event[1]), [['press', '@e63~s70', '--level', 'full', '--json']]);
|
|
});
|
|
|
|
test('a missing control names the on-screen label that differs only in punctuation', async () => {
|
|
const { options } = runtime([snapshot(80, [node('e14', 'Button', 'Check Mood, 3')])]);
|
|
await assert.rejects(executeCaptureSetup([{ action: 'tap', label: 'Check Mood 3' }], options),
|
|
error => error instanceof CiError && error.code === 'SETUP_CONTROL_NOT_READY'
|
|
&& error.message.includes('"Check Mood, 3"') && /exact label/.test(error.nextAction));
|
|
});
|
|
|
|
test('each emitted command shape passes the installed pinned controller parser without a device', async () => {
|
|
const { events, options } = runtime([snapshot(10, [node('e3', 'Button', 'Continue'), node('e4', 'TextField', 'Name')])]);
|
|
await executeCaptureSetup([
|
|
{ action: 'tap', label: 'Continue' }, { action: 'fill', label: 'Name', text: 'Milo' },
|
|
{ action: 'scroll', direction: 'bottom' }, { action: 'wait', milliseconds: 10 },
|
|
], options);
|
|
const typing = runtime([inputSnapshot(1)]);
|
|
await executeCaptureSetup([{ action: 'tap', identifier: 'password' }, { action: 'type', text: 'fixture-input' }], typing.options);
|
|
const commands = [...events.filter(event => event[0] === 'command').map(event => event[1]),
|
|
...typing.events.filter(event => event[0] === 'command').map(event => event[1]), ['keyboard', 'dismiss', '--level', 'full', '--json']];
|
|
for (const args of commands) {
|
|
const result = spawnSync('./node_modules/.bin/agent-device', [...args, '--help'], { encoding: 'utf8' });
|
|
assert.equal(result.status, 0, `${args[0]} CLI rejected its arguments: ${result.stderr || result.stdout}`);
|
|
}
|
|
});
|
|
|
|
test('a snapshot that cannot read the system surface over the app is recognized, and nothing else is', () => {
|
|
const failed = (message, nextAction) => new CiError({ code: 'COMMAND_FAILED', stage: 'controller', message, ...(nextAction ? { nextAction } : {}) });
|
|
assert.equal(isUnreadableSystemSurface(failed('snapshot: regular iOS snapshot presentation requires a valid viewport')), true);
|
|
assert.equal(isUnreadableSystemSurface(failed('snapshot failed', 'com.apple.SafariViewService hosts the surface presented over the app, and this capture targeted that surface.')), true);
|
|
assert.equal(isUnreadableSystemSurface(failed('snapshot failed', 'No snapshot backend could read this screen (XCTest-backed snapshot tiers were deferred after recent slow accessibility work on this screen).')), true);
|
|
assert.equal(isUnreadableSystemSurface(failed('Remote daemon is unavailable')), false);
|
|
assert.equal(isUnreadableSystemSurface(new CiError({ code: 'SETUP_FAILED', stage: 'setup', message: 'requires a valid viewport' })), false);
|
|
assert.equal(isUnreadableSystemSurface(new Error('requires a valid viewport')), false);
|
|
});
|
|
|