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
35 changes: 33 additions & 2 deletions packages/contracts/src/application-lifecycle-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,39 @@ export type OpenApplicationTiming = Readonly<{
openDispatchDurationMs?: number;
launchUrlDurationMs?: number;
postOpenSettleDurationMs?: number;
/** What a Simulator open learned about the launched app before returning (see the Apple owner). */
postOpenObservation?: 'observable' | 'unobservable' | 'not-eligible';
/** Unset when the open had no launched app to observe, such as a URL or deep-link target. */
postOpenObservation?: PostOpenObservation;
/** Why the observation could not run; present exactly when it is `probe-failed`. */
postOpenObservationFailure?: PostOpenObservationFailure;
}>;

/**
* What an app open learned about the launched app before it returned. A local iOS Simulator asks
* its host AX bridge; a local Android device captures the app through the snapshot helper. Each
* owner bounds the observation, and the open succeeds whatever the value is.
*
* - `observable`: the launched app's tree was readable.
* - `unobservable`: the app stayed unreadable within the owner's bounded window: a launch transition
* or AX-server state that did not clear, a system surface over the app, a content verdict after
* the capture's own re-captures, or the window ran out.
* - `probe-failed`: the observation could not run (Android: the helper is not installed at the
* current version, or adb or the accessibility service failed). `postOpenObservationFailure`
* carries the typed failure.
* - `app-unidentified`: the open targeted an app, but the owner could not read which package it
* launched, so nothing was observed.
* - `not-eligible`: the device has no observation path.
*/
export type PostOpenObservation =
| 'observable'
| 'unobservable'
| 'probe-failed'
| 'app-unidentified'
| 'not-eligible';

/** The typed failure of a `probe-failed` observation: the error code and its typed reason. */
export type PostOpenObservationFailure = Readonly<{
code: string;
reason?: string;
}>;

export type OpenApplicationOutcome = Readonly<{
Expand Down
6 changes: 6 additions & 0 deletions packages/contracts/src/interactor-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,12 @@ export type SnapshotOptions = BaseSnapshotOptions & {
surface?: SessionSurface;
/** Internal capture purpose; action outcomes always require the full tree. */
acquisitionIntent?: 'full' | 'surface-observation';
/**
* A one-off read, such as an open's launch observation. It may use a capture host the session
* already keeps warm, but it never installs one or leaves running one that it started. It starts
* no re-capture after `settleBy` (epoch ms); only `signal` cancels work already started.
*/
transient?: Readonly<{ settleBy: number }>;
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,31 @@ test('an exec-shaped helper install timeout names the dialog, not a wedged adb s
);
});

test('a current-only helper check refuses a missing helper without installing it', async () => {
const apkPath = await writeHelperApk('snapshot-helper-current-only-');
let installs = 0;
const adbProvider = missingPackageProvider(async () => {
installs += 1;
return { exitCode: 0, stdout: 'Success', stderr: '' };
});

await assert.rejects(
() =>
ensureAndroidSnapshotHelper({
adb: adbProvider.exec,
adbProvider,
artifact: { apkPath, manifest: { ...manifest, sha256: sha256Text('helper-apk') } },
deviceKey: 'android:emulator-5554',
installPolicy: 'current-only',
}),
(error) => {
assert.equal((error as AppError).details?.reason, 'android-snapshot-helper-not-current');
return true;
},
);
assert.equal(installs, 0);
});

function adbInstallTimeout(): AppError {
// An unattended first install on ColorOS: adb blocks on the system install-confirmation dialog
// and the exec layer kills the command, so stdout/stderr stay empty.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ export type PersistentSnapshotHelperProviderOptions = {
oneShotXml?: string;
/** Make the device-side stop fail the way an unhealthy transport answers. */
runtimeStopFailure?: boolean;
/** How long a started session takes to report ready. */
sessionReadyDelayMs?: number;
/** How long a session takes to answer its `snapshotCount`th capture. */
captureResponseDelayMs?: (snapshotCount: number) => number;
};

export function createPersistentSnapshotHelperProvider(
Expand Down Expand Up @@ -104,36 +108,40 @@ export function createPersistentSnapshotHelperProvider(
const body = options.sessionXml
? options.sessionXml(sessionIndex, snapshotCount)
: `<hierarchy><node text="persistent helper snapshot ${snapshotCount}" bounds="[0,0][10,10]" /></hierarchy>`;
socket.end(
sessionResponse({
requestId,
body,
metadata: {
waitForIdleTimeoutMs: '500',
waitForIdleQuietMs: '100',
timeoutMs: '5000',
maxDepth: '128',
maxNodes: '5000',
rootPresent: 'true',
captureMode: 'interactive-windows',
windowCount: '1',
nodeCount: '1',
truncated: 'false',
elapsedMs: '8',
},
}),
);
const answer = () =>
socket.end(
sessionResponse({
requestId,
body,
metadata: {
waitForIdleTimeoutMs: '500',
waitForIdleQuietMs: '100',
timeoutMs: '5000',
maxDepth: '128',
maxNodes: '5000',
rootPresent: 'true',
captureMode: 'interactive-windows',
windowCount: '1',
nodeCount: '1',
truncated: 'false',
elapsedMs: '8',
},
}),
);
setTimeout(answer, options.captureResponseDelayMs?.(snapshotCount) ?? 0);
});
});
server.listen(port, '127.0.0.1', () => {
process.stdout.write(
[
'INSTRUMENTATION_STATUS: agentDeviceProtocol=android-snapshot-helper-v1',
'INSTRUMENTATION_STATUS: sessionReady=true',
'INSTRUMENTATION_STATUS_CODE: 2',
'',
].join('\n'),
);
setTimeout(() => {
process.stdout.write(
[
'INSTRUMENTATION_STATUS: agentDeviceProtocol=android-snapshot-helper-v1',
'INSTRUMENTATION_STATUS: sessionReady=true',
'INSTRUMENTATION_STATUS_CODE: 2',
'',
].join('\n'),
);
}, options.sessionReadyDelayMs ?? 0);
});
process.onKill = () => {
server.close(() => process.emitExit(0, null));
Expand Down Expand Up @@ -513,3 +521,16 @@ function readSessionPort(args: readonly string[]): number {
assert.notEqual(index, -1);
return Number(args[index + 1]);
}

/** A capture holding only system-UI chrome: the helper answered, but no app content. */
export function androidSystemWindowOnlyXml(): string {
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<hierarchy rotation="0">',
' <node window-index="0" window-type="3" window-layer="30" window-active="true" window-focused="true" class="android.widget.FrameLayout" package="com.android.systemui" bounds="[0,0][390,844]" enabled="true" visible-to-user="true">',
' <node content-desc="Back" class="android.widget.ImageButton" package="com.android.systemui" bounds="[0,792][96,844]" clickable="true" enabled="true" focusable="true" visible-to-user="true" />',
' <node content-desc="Home" class="android.widget.ImageButton" package="com.android.systemui" bounds="[147,792][243,844]" clickable="true" enabled="true" focusable="true" visible-to-user="true" />',
' </node>',
'</hierarchy>',
].join('\n');
}
Loading
Loading