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
6 changes: 5 additions & 1 deletion .github/actions/setup-fixture-app/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ inputs:
description: 'How long to poll for a concurrently produced artifact before building inline'
required: false
default: '0'
require-artifact:
description: 'Fail instead of building inline when no trusted same-head artifact is available'
required: false
default: 'false'
outputs:
app-path:
description: 'Path to the ready .app bundle or APK'
Expand Down Expand Up @@ -95,7 +99,7 @@ runs:
bash "$GITHUB_ACTION_PATH/fetch-artifact.sh" \
"${{ inputs.platform }}" "$DEST" "${{ inputs.wait-for-artifact-seconds }}" \
"${{ github.repository }}" "${{ github.event.pull_request.head.sha || github.sha }}" \
"$GITHUB_ACTION_PATH"
"$GITHUB_ACTION_PATH" "${{ inputs.require-artifact }}"

# Fallback only: the producer builds the same way. `--device generic` avoids
# needing a booted simulator; Release embeds the bundle so no Metro is needed.
Expand Down
16 changes: 16 additions & 0 deletions .github/actions/setup-fixture-app/fetch-artifact.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ WAIT_SECONDS="${3:?wait duration is required}"
REPOSITORY="${4:?repository is required}"
EXPECTED_HEAD_SHA="${5:?expected head SHA is required}"
ACTION_PATH="${6:?action path is required}"
REQUIRE_ARTIFACT="${7:-false}"

NAME="$(sh "$ACTION_PATH/resolve-artifact-name.sh" "$PLATFORM")"
TRUSTED_ARTIFACT="$ACTION_PATH/trusted-artifact.mjs"
Expand All @@ -18,6 +19,13 @@ case "$WAIT_SECONDS" in
exit 1
;;
esac
case "$REQUIRE_ARTIFACT" in
true|false) ;;
*)
echo "::error::require-artifact must be true or false."
exit 1
;;
esac

# A lookup failure (API outage, auth, transient 5xx) must not fail the caller
# or trigger a pointless wait: it takes the inline-build path.
Expand Down Expand Up @@ -89,11 +97,19 @@ if [ -n "$ART_ID" ]; then
SOURCE=artifact
echo "restored $NAME from the build cache"
else
if [ "$REQUIRE_ARTIFACT" = true ]; then
echo "::error::Could not restore required fixture artifact $NAME."
exit 1
fi
echo "::warning::Could not restore $NAME; building inline."
rm -rf "$DEST"/*
fi
rm -rf "$STAGE"
else
if [ "$REQUIRE_ARTIFACT" = true ]; then
echo "::error::Required fixture artifact $NAME is unavailable for this exact head."
exit 1
fi
echo "$NAME not in the cache after the configured wait; building inline."
fi
echo "source=$SOURCE" >> "$GITHUB_OUTPUT"
10 changes: 3 additions & 7 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,6 @@ jobs:
- name: Run Android emulator catalog coverage contract
run: node --test test/integration/smoke-android-emulator-coverage.test.ts

- name: Build agent-device CLI
run: pnpm build

- name: Reset daemon state
run: pnpm clean:daemon

- name: Run Android smoke checks
uses: reactivecircus/android-emulator-runner@b530d96654c385303d652368551fb075bc2f0b6b # v2.35.0
with:
Expand All @@ -72,7 +66,9 @@ jobs:
set -eu
ANDROID_SERIAL="$(adb devices | awk 'NR > 1 && $2 == "device" { print $1; exit }')"
test -n "$ANDROID_SERIAL"
AGENT_DEVICE_ANDROID_E2E=1 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
pnpm build
pnpm clean:daemon
AGENT_DEVICE_ANDROID_E2E=1 AGENT_DEVICE_ANDROID_E2E_TIER=smoke 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
node --experimental-strip-types src/bin.ts test test/integration/replays/android/01-settings.ad --retries 2 --report-junit test/artifacts/replays-android-smoke.junit.xml

- name: Upload Android artifacts
Expand Down
37 changes: 32 additions & 5 deletions .github/workflows/replays-nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,11 @@ jobs:
} >> "$GITHUB_STEP_SUMMARY"

nightly-android:
name: Android Replay Suite
name: Android Full Emulator Suite
runs-on: ubuntu-latest
timeout-minutes: 80
timeout-minutes: 15
env:
AGENT_DEVICE_STATE_DIR: ${{ github.workspace }}/.tmp/android-e2e-state
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
Expand All @@ -106,22 +108,47 @@ jobs:
with:
package-helpers: 'true'

- name: Run Android replay suite
- name: Restore Android fixture APK
id: fixture-app
uses: ./.github/actions/setup-fixture-app
with:
platform: android
install: 'false'
require-artifact: 'true'

- name: Mark Android emulator setup complete
id: android-setup
run: echo "seconds=$(date +%s)" >> "$GITHUB_OUTPUT"

- name: Run Android full emulator suite
uses: reactivecircus/android-emulator-runner@b530d96654c385303d652368551fb075bc2f0b6b # v2.35.0
with:
api-level: 36
arch: x86_64
profile: pixel_7
target: google_apis_playstore
emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -no-metrics
script: pnpm test:replay:android --retries 2 --report-junit test/artifacts/replays-android.junit.xml
script: |
set -euo pipefail
ANDROID_SERIAL="$(adb devices | awk 'NR > 1 && $2 == "device" { print $1; exit }')"
test -n "$ANDROID_SERIAL"
BOOT_FINISHED_AT="$(date +%s)"
pnpm build
pnpm clean:daemon
FULL_STARTED_AT="$(date +%s)"
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
FULL_FINISHED_AT="$(date +%s)"
echo "Android setup before emulator: $((BOOT_FINISHED_AT - ${{ steps.android-setup.outputs.seconds }}))s"
echo "Android full scenario wall time: $((FULL_FINISHED_AT - FULL_STARTED_AT))s"
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
node --experimental-strip-types src/bin.ts test test/integration/replays/android/02-deep-navigation.ad test/integration/replays/android/03-scroll-discovery.ad test/integration/replays/android/04-text-input-keyboard.ad test/integration/replays/android/05-app-lifecycle.ad test/integration/replays/android/06-swipe-gestures.ad --artifacts-dir test/artifacts/replays-android --report-junit test/artifacts/replays-android.junit.xml

- name: Upload Android artifacts
if: always()
uses: ./.github/actions/upload-agent-device-artifacts
with:
artifact-name: replay-nightly-android-artifacts
agent-state-dir: ${{ steps.android-replay-host.outputs.agent-state-dir }}
agent-state-dir: ${{ env.AGENT_DEVICE_STATE_DIR }}

nightly-ios:
name: iOS Replay Suite
Expand Down
2 changes: 1 addition & 1 deletion examples/test-app/app.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ module.exports = {
{
microphonePermission:
'Allow Agent Device Tester to exercise microphone permission recovery.',
recordAudioAndroid: false,
recordAudioAndroid: true,
},
],
],
Expand Down
15 changes: 15 additions & 0 deletions examples/test-app/modules/push-broadcast-lab/android/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
plugins {
id 'com.android.library'
id 'expo-module-gradle-plugin'
}

group = 'expo.modules.pushbroadcastlab'
version = '1.0.0'

android {
namespace 'expo.modules.pushbroadcastlab'
defaultConfig {
versionCode 1
versionName '1.0.0'
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<receiver
android:name=".PushBroadcastReceiver"
android:exported="true">
<intent-filter>
<action android:name="com.callstack.agentdevicelab.TEST_PUSH" />
</intent-filter>
</receiver>
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package expo.modules.pushbroadcastlab

import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition

class PushBroadcastLabModule : Module() {
override fun definition() = ModuleDefinition {
Name("PushBroadcastLab")

Function("lastPushBroadcast") {
appContext.reactContext
?.getSharedPreferences(PushBroadcastReceiver.PREFERENCES, 0)
?.getString(PushBroadcastReceiver.LAST_BROADCAST, null)
?: "none"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package expo.modules.pushbroadcastlab

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent

class PushBroadcastReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val source = intent.getStringExtra("source") ?: "none"
val count = intent.getIntExtra("count", 0)
context
.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
.edit()
.putString(LAST_BROADCAST, "source=$source,count=$count")
.apply()
}

companion object {
const val LAST_BROADCAST = "last_broadcast"
const val PREFERENCES = "agent_device_push_broadcast_lab"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"platforms": ["android"],
"android": {
"modules": ["expo.modules.pushbroadcastlab.PushBroadcastLabModule"]
}
}
39 changes: 38 additions & 1 deletion examples/test-app/src/screens/AutomationLabScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
Alert,
AppState,
Modal,
Platform,
Pressable,
ScrollView,
StyleSheet,
Expand All @@ -12,10 +13,20 @@ import {
View,
} from 'react-native';
import { getRecordingPermissionsAsync, requestRecordingPermissionsAsync } from 'expo-audio';
import { requireOptionalNativeModule } from 'expo-modules-core';

import { ActionButton, ScreenTitle, SectionCard } from '../components';
import { useAppColors, type AppColors } from '../theme';

type PushBroadcastLabModule = {
lastPushBroadcast(): string;
};

const pushBroadcastLab =
Platform.OS === 'android'
? requireOptionalNativeModule<PushBroadcastLabModule>('PushBroadcastLab')
: null;

export function AutomationLabScreen(props: {
eventName: string;
eventPayload: string;
Expand All @@ -31,6 +42,7 @@ export function AutomationLabScreen(props: {
const [lastInput, setLastInput] = useState('none');
const [longPressCount, setLongPressCount] = useState(0);
const [microphonePermission, setMicrophonePermission] = useState('checking');
const [lastPushBroadcast, setLastPushBroadcast] = useState('none');
const [sheetVisible, setSheetVisible] = useState(false);
const permissionReadGeneration = useRef(0);
const windowMode = dimensions.width > dimensions.height ? 'landscape' : 'portrait';
Expand All @@ -52,11 +64,18 @@ export function AutomationLabScreen(props: {
}
});
};
const refreshPushBroadcast = () => {
setLastPushBroadcast(pushBroadcastLab?.lastPushBroadcast() ?? 'unavailable');
};
refreshMicrophonePermission();
refreshPushBroadcast();
const subscription = AppState.addEventListener('change', (nextState) => {
setAppState(nextState);
if (nextState !== 'active') setLastNonActiveState(nextState);
if (nextState === 'active') refreshMicrophonePermission();
if (nextState === 'active') {
refreshMicrophonePermission();
refreshPushBroadcast();
}
});
return () => {
mounted = false;
Expand Down Expand Up @@ -94,6 +113,10 @@ export function AutomationLabScreen(props: {
}
}

function refreshPushBroadcast() {
setLastPushBroadcast(pushBroadcastLab?.lastPushBroadcast() ?? 'unavailable');
}

return (
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
<ScreenTitle
Expand Down Expand Up @@ -181,6 +204,20 @@ export function AutomationLabScreen(props: {
/>
</SectionCard>

<SectionCard title="Android push broadcast">
<ActionButton
kind="secondary"
label="Refresh push broadcast"
onPress={refreshPushBroadcast}
testID="automation-refresh-push-broadcast"
/>
<StateRow
label="Last push broadcast"
testID="automation-last-push-broadcast"
value={lastPushBroadcast}
/>
</SectionCard>

<Modal
animationType="slide"
onRequestClose={() => setSheetVisible(false)}
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/recording.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export type TraceCommandResult =
| {
trace: 'stopped';
outPath: string;
artifacts: DaemonArtifact[];
};

/**
Expand Down
18 changes: 17 additions & 1 deletion src/daemon/handlers/record-trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,23 @@ export async function handleRecordTraceCommands(params: {
}
session.trace = undefined;
recordSessionAction(sessionStore, session, req, command, { action: 'stop', outPath });
return { ok: true, data: { trace: 'stopped', outPath } satisfies TraceCommandResult };
const clientOutPath = req.meta?.clientArtifactPaths?.outPath ?? outPath;
return {
ok: true,
data: {
trace: 'stopped',
outPath,
artifacts: [
{
field: 'outPath',
artifactType: 'trace-log',
path: outPath,
localPath: clientOutPath,
fileName: path.basename(clientOutPath),
},
],
} satisfies TraceCommandResult,
};
}

return null;
Expand Down
25 changes: 25 additions & 0 deletions src/mcp/__tests__/command-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,31 @@ test('MCP prepare outputSchema stays complete for the typed non-exposed command'
assert.ok(prepareSchema.required?.includes('timing'));
});

test('MCP stopped trace schema advertises its downloadable trace artifact', () => {
const stoppedTrace = {
trace: 'stopped',
outPath: '/daemon/fixture.adtrace',
artifacts: [
{
field: 'outPath',
artifactType: 'trace-log',
path: '/daemon/fixture.adtrace',
localPath: '/client/fixture.adtrace',
fileName: 'fixture.adtrace',
},
],
};

assert.deepEqual(validateAgainstSchema(stoppedTrace, COMMAND_OUTPUT_SCHEMAS.trace), []);
assert.notDeepEqual(
validateAgainstSchema(
{ trace: 'stopped', outPath: stoppedTrace.outPath },
COMMAND_OUTPUT_SCHEMAS.trace,
),
[],
);
});

test('MCP untyped object tools stay byte-identical: no outputSchema key', () => {
const tools = listCommandTools();

Expand Down
Loading
Loading