Skip to content

Commit a2ef3d5

Browse files
committed
fix: harden Android nightly runtime evidence
1 parent 0baaa80 commit a2ef3d5

8 files changed

Lines changed: 97 additions & 41 deletions

File tree

.github/actions/setup-fixture-app/action.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ inputs:
3636
description: 'How long to poll for a concurrently produced artifact before building inline'
3737
required: false
3838
default: '0'
39+
require-artifact:
40+
description: 'Fail instead of building inline when no trusted same-head artifact is available'
41+
required: false
42+
default: 'false'
3943
outputs:
4044
app-path:
4145
description: 'Path to the ready .app bundle or APK'
@@ -95,7 +99,7 @@ runs:
9599
bash "$GITHUB_ACTION_PATH/fetch-artifact.sh" \
96100
"${{ inputs.platform }}" "$DEST" "${{ inputs.wait-for-artifact-seconds }}" \
97101
"${{ github.repository }}" "${{ github.event.pull_request.head.sha || github.sha }}" \
98-
"$GITHUB_ACTION_PATH"
102+
"$GITHUB_ACTION_PATH" "${{ inputs.require-artifact }}"
99103
100104
# Fallback only: the producer builds the same way. `--device generic` avoids
101105
# needing a booted simulator; Release embeds the bundle so no Metro is needed.

.github/actions/setup-fixture-app/fetch-artifact.sh

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ WAIT_SECONDS="${3:?wait duration is required}"
88
REPOSITORY="${4:?repository is required}"
99
EXPECTED_HEAD_SHA="${5:?expected head SHA is required}"
1010
ACTION_PATH="${6:?action path is required}"
11+
REQUIRE_ARTIFACT="${7:-false}"
1112

1213
NAME="$(sh "$ACTION_PATH/resolve-artifact-name.sh" "$PLATFORM")"
1314
TRUSTED_ARTIFACT="$ACTION_PATH/trusted-artifact.mjs"
@@ -18,6 +19,13 @@ case "$WAIT_SECONDS" in
1819
exit 1
1920
;;
2021
esac
22+
case "$REQUIRE_ARTIFACT" in
23+
true|false) ;;
24+
*)
25+
echo "::error::require-artifact must be true or false."
26+
exit 1
27+
;;
28+
esac
2129

2230
# A lookup failure (API outage, auth, transient 5xx) must not fail the caller
2331
# or trigger a pointless wait: it takes the inline-build path.
@@ -89,11 +97,19 @@ if [ -n "$ART_ID" ]; then
8997
SOURCE=artifact
9098
echo "restored $NAME from the build cache"
9199
else
100+
if [ "$REQUIRE_ARTIFACT" = true ]; then
101+
echo "::error::Could not restore required fixture artifact $NAME."
102+
exit 1
103+
fi
92104
echo "::warning::Could not restore $NAME; building inline."
93105
rm -rf "$DEST"/*
94106
fi
95107
rm -rf "$STAGE"
96108
else
109+
if [ "$REQUIRE_ARTIFACT" = true ]; then
110+
echo "::error::Required fixture artifact $NAME is unavailable for this exact head."
111+
exit 1
112+
fi
97113
echo "$NAME not in the cache after the configured wait; building inline."
98114
fi
99115
echo "source=$SOURCE" >> "$GITHUB_OUTPUT"

.github/workflows/replays-nightly.yml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ jobs:
9393
name: Android Full Emulator Suite
9494
runs-on: ubuntu-latest
9595
timeout-minutes: 15
96+
env:
97+
AGENT_DEVICE_STATE_DIR: ${{ github.workspace }}/.tmp/android-e2e-state
9698
steps:
9799
- name: Checkout
98100
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -112,6 +114,7 @@ jobs:
112114
with:
113115
platform: android
114116
install: 'false'
117+
require-artifact: 'true'
115118

116119
- name: Mark Android emulator setup complete
117120
id: android-setup
@@ -137,7 +140,7 @@ jobs:
137140
AGENT_DEVICE_ANDROID_E2E=1 AGENT_DEVICE_ANDROID_E2E_TIER=full AGENT_DEVICE_ANDROID_SERIAL="$ANDROID_SERIAL" AGENT_DEVICE_FIXTURE_APP_PATH="${{ steps.fixture-app.outputs.apk-path }}" AGENT_DEVICE_FIXTURE_APP_ID="${{ steps.fixture-app.outputs.app-id }}" node --test test/integration/smoke-android-emulator.test.ts
138141
FULL_FINISHED_AT="$(date +%s)"
139142
COVERAGE_REPORT="$(find test/artifacts/android-emulator -name coverage-report.json -print | tail -n 1)"
140-
node --input-type=module -e 'import fs from "node:fs"; const report = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); console.log(`Android full scenario body: ${report.timings.scenarioBodyDurationMs}ms`); for (const scenario of report.timings.scenarios) console.log(` ${scenario.id}: ${scenario.durationMs}ms`);' "$COVERAGE_REPORT"
143+
node --input-type=module -e 'import fs from "node:fs"; const report = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); console.log(`Android full scenario body: ${report.timings.runDurationMs}ms`); for (const scenario of report.timings.scenarios) console.log(` ${scenario.id}: ${scenario.durationMs}ms`);' "$COVERAGE_REPORT"
141144
echo "Android setup before emulator: $((BOOT_FINISHED_AT - ${{ steps.android-setup.outputs.seconds }}))s"
142145
echo "Android full scenario wall time: $((FULL_FINISHED_AT - FULL_STARTED_AT))s"
143146
node --experimental-strip-types src/bin.ts test test/integration/replays/android/01-settings.ad --retries 2 --artifacts-dir test/artifacts/replays-android-settings --report-junit test/artifacts/replays-android-settings.junit.xml
@@ -148,7 +151,7 @@ jobs:
148151
uses: ./.github/actions/upload-agent-device-artifacts
149152
with:
150153
artifact-name: replay-nightly-android-artifacts
151-
agent-state-dir: ${{ steps.android-replay-host.outputs.agent-state-dir }}
154+
agent-state-dir: ${{ env.AGENT_DEVICE_STATE_DIR }}
152155

153156
nightly-ios:
154157
name: iOS Replay Suite

examples/test-app/app.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ module.exports = {
2525
{
2626
microphonePermission:
2727
'Allow Agent Device Tester to exercise microphone permission recovery.',
28-
recordAudioAndroid: false,
28+
recordAudioAndroid: true,
2929
},
3030
],
3131
],

packages/contracts/src/recording.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export type TraceCommandResult =
4646
| {
4747
trace: 'stopped';
4848
outPath: string;
49+
artifacts: DaemonArtifact[];
4950
};
5051

5152
/**

src/daemon/handlers/record-trace.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,23 @@ export async function handleRecordTraceCommands(params: {
6363
}
6464
session.trace = undefined;
6565
recordSessionAction(sessionStore, session, req, command, { action: 'stop', outPath });
66-
return { ok: true, data: { trace: 'stopped', outPath } satisfies TraceCommandResult };
66+
const clientOutPath = req.meta?.clientArtifactPaths?.outPath ?? outPath;
67+
return {
68+
ok: true,
69+
data: {
70+
trace: 'stopped',
71+
outPath,
72+
artifacts: [
73+
{
74+
field: 'outPath',
75+
artifactType: 'trace-log',
76+
path: outPath,
77+
localPath: clientOutPath,
78+
fileName: path.basename(clientOutPath),
79+
},
80+
],
81+
} satisfies TraceCommandResult,
82+
};
6783
}
6884

6985
return null;

test/integration/android-emulator-e2e/live-lifecycle-scenario.ts

Lines changed: 37 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -23,22 +23,13 @@ export async function assertLifecycleAndSystem(context: LiveContext): Promise<vo
2323
}
2424

2525
async function assertPermissionRecovery(context: LiveContext): Promise<void> {
26-
await runStep(context, 'reset microphone permission before runtime prompt', [
27-
'settings',
28-
'permission',
29-
'reset',
30-
'microphone',
31-
]);
32-
await runStep(context, 'request Android microphone permission in fixture', [
33-
'click',
34-
'id="automation-request-microphone"',
35-
]);
36-
const prompt = await runStep(context, 'inspect Android microphone permission prompt', [
37-
'alert',
38-
'get',
39-
]);
40-
assert.equal(prompt.json?.data?.alert?.source, 'permission', JSON.stringify(prompt.json));
41-
await runStep(context, 'accept Android microphone permission prompt', ['alert', 'accept']);
26+
await resetAndRequestMicrophonePermission(context, 'accept');
27+
await assertElementText(context, 'id="automation-microphone-permission"', 'granted');
28+
29+
await resetAndRequestMicrophonePermission(context, 'deny');
30+
await assertElementText(context, 'id="automation-microphone-permission"', 'denied');
31+
32+
await resetAndRequestMicrophonePermission(context, 'accept after denial');
4233
await assertElementText(context, 'id="automation-microphone-permission"', 'granted');
4334

4435
await runStep(context, 'revoke Android microphone permission', [
@@ -54,15 +45,42 @@ async function assertPermissionRecovery(context: LiveContext): Promise<void> {
5445
verifyCommand(
5546
context,
5647
C.settings,
57-
'grant and deny runtime permission transitions are fixture-visible',
48+
'prompt accept, prompt deny, and adb revoke transitions are fixture-visible',
5849
);
5950
verifyBehavior(
6051
context,
6152
'runtime-permission-recovery',
62-
'fixture observed prompt acceptance then the app-active readback after pm revoke',
53+
'fixture observed native prompt accept and deny, then app-active readback after pm revoke',
6354
);
6455
}
6556

57+
async function resetAndRequestMicrophonePermission(
58+
context: LiveContext,
59+
action: 'accept' | 'deny' | 'accept after denial',
60+
): Promise<void> {
61+
await runStep(context, `reset microphone permission before ${action}`, [
62+
'settings',
63+
'permission',
64+
'reset',
65+
'microphone',
66+
]);
67+
await runStep(context, `scroll to microphone permission control before ${action}`, [
68+
'scroll',
69+
'bottom',
70+
]);
71+
await runStep(context, `request Android microphone permission for ${action}`, [
72+
'click',
73+
'id="automation-request-microphone"',
74+
]);
75+
const prompt = await runStep(context, `inspect Android microphone prompt for ${action}`, [
76+
'alert',
77+
'get',
78+
]);
79+
assert.equal(prompt.json?.data?.alert?.source, 'permission', JSON.stringify(prompt.json));
80+
const alertAction = action === 'deny' ? 'dismiss' : 'accept';
81+
await runStep(context, `${action} Android microphone permission prompt`, ['alert', alertAction]);
82+
}
83+
6684
async function assertPushBroadcast(context: LiveContext): Promise<void> {
6785
const payload = JSON.stringify({ extras: { count: 7, source: 'android-nightly' } });
6886
const pushed = await runStep(context, 'broadcast typed Android fixture push', [
@@ -72,6 +90,7 @@ async function assertPushBroadcast(context: LiveContext): Promise<void> {
7290
]);
7391
assert.equal(pushed.json?.data?.package, context.appId, JSON.stringify(pushed.json));
7492
assert.equal(pushed.json?.data?.extrasCount, 2, JSON.stringify(pushed.json));
93+
await runStep(context, 'scroll to Android push receiver state', ['scroll', 'bottom']);
7594
await runStep(context, 'refresh fixture push receiver state', [
7695
'click',
7796
'id="automation-refresh-push-broadcast"',

test/integration/android-emulator-e2e/live-observability-scenario.ts

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,7 @@ import { PUBLIC_COMMANDS } from '../../../src/command-catalog.ts';
66
import { readDaemonInfo } from '../../../src/daemon/client/daemon-client-metadata.ts';
77
import { resolveDaemonPaths } from '../../../src/daemon/config.ts';
88
import { collectPagedEventTimeline, type EventTimelinePage } from './event-timeline.ts';
9-
import {
10-
assertJsonContains,
11-
assertMp4File,
12-
assertNonEmptyFile,
13-
assertWaitText,
14-
} from './live-assertions.ts';
9+
import { assertMp4File, assertNonEmptyFile, assertWaitText } from './live-assertions.ts';
1510
import { type LiveContext, runStep, verifyCommand } from './live-harness.ts';
1611

1712
const C = PUBLIC_COMMANDS;
@@ -114,12 +109,16 @@ async function assertBatchAndEvents(context: LiveContext): Promise<void> {
114109
},
115110
]),
116111
]);
117-
assertJsonContains(
118-
batch,
119-
'Agent Device Tester',
120-
'batch should contain nested Android get result',
112+
const results = batch.json?.data?.results;
113+
assert.ok(
114+
Array.isArray(results),
115+
`batch must retain nested results: ${JSON.stringify(batch.json)}`,
121116
);
122-
assertJsonContains(batch, '"pass":true', 'batch should contain nested Android is result');
117+
assert.equal(results.length, 2, JSON.stringify(batch.json));
118+
assert.equal(results[0]?.command, 'get', JSON.stringify(batch.json));
119+
assert.equal(results[0]?.data?.text, 'Agent Device Tester', JSON.stringify(batch.json));
120+
assert.equal(results[1]?.command, 'is', JSON.stringify(batch.json));
121+
assert.equal(results[1]?.data?.pass, true, JSON.stringify(batch.json));
123122
verifyCommand(context, C.batch, 'Android batch retains nested get and is evidence');
124123

125124
const timeline = await collectPagedEventTimeline(async (cursor) => {
@@ -158,13 +157,13 @@ async function assertArtifactInventory(context: LiveContext): Promise<void> {
158157
);
159158
}
160159
const trace = artifacts.find(
161-
(artifact: { artifactId?: unknown; artifactType?: unknown; sizeBytes?: unknown }) =>
160+
(artifact: { artifactType?: unknown; id?: unknown; sizeBytes?: unknown }) =>
162161
artifact.artifactType === 'trace-log' &&
163-
typeof artifact.artifactId === 'string' &&
162+
typeof artifact.id === 'string' &&
164163
Number(artifact.sizeBytes) > 0,
165-
) as { artifactId: string; sizeBytes: number } | undefined;
164+
) as { id: string; sizeBytes: number } | undefined;
166165
assert.ok(trace, `trace artifact is not downloadable: ${JSON.stringify(artifacts)}`);
167-
const downloaded = await downloadDaemonArtifact(context, trace.artifactId);
166+
const downloaded = await downloadDaemonArtifact(context, trace.id);
168167
assert.equal(
169168
downloaded.byteLength,
170169
trace.sizeBytes,
@@ -177,9 +176,7 @@ async function assertArtifactInventory(context: LiveContext): Promise<void> {
177176
const remaining = afterDownload.json?.data?.artifacts;
178177
assert.ok(Array.isArray(remaining), JSON.stringify(afterDownload.json));
179178
assert.equal(
180-
remaining.some(
181-
(artifact: { artifactId?: unknown }) => artifact.artifactId === trace.artifactId,
182-
),
179+
remaining.some((artifact: { id?: unknown }) => artifact.id === trace.id),
183180
false,
184181
'artifact download must consume its inventory entry',
185182
);

0 commit comments

Comments
 (0)