also looking at this
feat: native trail maps and complete native mobile screens
#1017
60 files
+2505
-823
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -8,6 +8,7 @@ on: | ||
| 8 | 8 | - '.maestro/**' |
| 9 | 9 | - 'tests/native/**' |
| 10 | 10 | - 'scripts/run-native-trail-e2e.ts' |
| 11 | - 'scripts/run-native-trail-android-e2e.ts' | |
| 11 | 12 | - 'app/**' |
| 12 | 13 | - 'config/mobile.ts' |
| 13 | 14 | # The app icon config/mobile.ts points at; a bad one fails the iOS build. |
| @@ -21,6 +22,7 @@ on: | ||
| 21 | 22 | - '.maestro/**' |
| 22 | 23 | - 'tests/native/**' |
| 23 | 24 | - 'scripts/run-native-trail-e2e.ts' |
| 25 | - 'scripts/run-native-trail-android-e2e.ts' | |
| 24 | 26 | - 'app/**' |
| 25 | 27 | - 'config/mobile.ts' |
| 26 | 28 | # The app icon config/mobile.ts points at; a bad one fails the iOS build. |
| @@ -175,3 +177,55 @@ jobs: | ||
| 175 | 177 | storage/framework/runtime/e2e/native-trail/*.png |
| 176 | 178 | storage/framework/runtime/e2e/native-trail/results-*.xcresult |
| 177 | 179 | if-no-files-found: warn |
| 180 | ||
| 181 | native-trail-android: | |
| 182 | runs-on: ubuntu-latest | |
| 183 | timeout-minutes: 35 | |
| 184 | env: | |
| 185 | QA_PORT_BASE: '7320' | |
| 186 | NATIVE_QA_ANDROID_DEVICE: emulator-5554 | |
| 187 | steps: | |
| 188 | - uses: actions/checkout@v5 | |
| 189 | - uses: oven-sh/setup-bun@v2 | |
| 190 | with: | |
| 191 | bun-version: '1.4.2' | |
| 192 | - uses: actions/setup-java@v5 | |
| 193 | with: | |
| 194 | distribution: temurin | |
| 195 | java-version: '17' | |
| 196 | - uses: gradle/actions/setup-gradle@v4 | |
| 197 | with: | |
| 198 | gradle-version: '8.11.1' | |
| 199 | - name: Install Maestro | |
| 200 | run: | | |
| 201 | curl -fsSL "https://github.com/mobile-dev-inc/Maestro/releases/download/${MAESTRO_VERSION}/maestro.zip" -o "$RUNNER_TEMP/maestro.zip" | |
| 202 | echo "$MAESTRO_SHA256 $RUNNER_TEMP/maestro.zip" | sha256sum --check | |
| 203 | unzip -q "$RUNNER_TEMP/maestro.zip" -d "$RUNNER_TEMP/maestro-cli" | |
| 204 | echo "$RUNNER_TEMP/maestro-cli/maestro/bin" >> "$GITHUB_PATH" | |
| 205 | - name: Install published dependencies | |
| 206 | run: bun install --frozen-lockfile | |
| 207 | - name: Build native MapLibre trail app | |
| 208 | run: bun scripts/run-native-trail-android-e2e.ts --build-only | |
| 209 | - name: Enable KVM | |
| 210 | run: | | |
| 211 | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules | |
| 212 | sudo udevadm control --reload-rules | |
| 213 | sudo udevadm trigger --name-match=kvm | |
| 214 | - name: Test native maps, sticky actions and persisted reviews | |
| 215 | uses: reactivecircus/android-emulator-runner@v2 | |
| 216 | with: | |
| 217 | api-level: 34 | |
| 218 | target: default | |
| 219 | arch: x86_64 | |
| 220 | profile: pixel_7 | |
| 221 | ram-size: 4096M | |
| 222 | emulator-options: -no-window -no-snapshot -gpu swiftshader_indirect -noaudio -no-boot-anim | |
| 223 | disable-animations: false | |
| 224 | script: bun scripts/run-native-trail-android-e2e.ts --skip-build | |
| 225 | - name: Upload Android native trail evidence | |
| 226 | if: always() | |
| 227 | uses: actions/upload-artifact@v5 | |
| 228 | with: | |
| 229 | name: native-trail-android | |
| 230 | path: storage/framework/runtime/e2e/native-trail-android | |
| 231 | if-no-files-found: warn | |
| @@ -0,0 +1,71 @@ | ||
| 1 | appId: ${APP_ID} | |
| 2 | name: Native Android trail maps and sticky save | |
| 3 | --- | |
| 4 | - runFlow: sign-in.yaml | |
| 5 | - tapOn: | |
| 6 | id: trail-save | |
| 7 | - extendedWaitUntil: | |
| 8 | visible: '^Saved$' | |
| 9 | timeout: 15000 | |
| 10 | - tapOn: | |
| 11 | id: trail-save | |
| 12 | - extendedWaitUntil: | |
| 13 | visible: '^Save$' | |
| 14 | timeout: 15000 | |
| 15 | - scrollUntilVisible: | |
| 16 | element: | |
| 17 | id: trail-map-fullscreen | |
| 18 | direction: DOWN | |
| 19 | - assertVisible: | |
| 20 | id: trail-sticky-actions | |
| 21 | - tapOn: | |
| 22 | id: trail-map-fullscreen | |
| 23 | - extendedWaitUntil: | |
| 24 | visible: | |
| 25 | id: trail-full-map | |
| 26 | timeout: 30000 | |
| 27 | - takeScreenshot: android-native-map-outdoors | |
| 28 | - tapOn: | |
| 29 | id: map-zoom-in | |
| 30 | - tapOn: | |
| 31 | id: map-zoom-out | |
| 32 | - tapOn: | |
| 33 | id: map-fit | |
| 34 | - tapOn: | |
| 35 | id: map-3d | |
| 36 | - assertVisible: '^2D$' | |
| 37 | - tapOn: | |
| 38 | id: map-north | |
| 39 | - tapOn: | |
| 40 | id: map-profile-toggle | |
| 41 | - assertVisible: 'Toggle elevation profile, Elevation profile collapsed' | |
| 42 | - takeScreenshot: android-native-map-profile-collapsed | |
| 43 | - tapOn: | |
| 44 | id: map-profile-toggle | |
| 45 | - assertVisible: 'Toggle elevation profile, Elevation profile expanded' | |
| 46 | - tapOn: | |
| 47 | id: map-layer | |
| 48 | - tapOn: '^Satellite$' | |
| 49 | - assertVisible: 'Map layer, Satellite' | |
| 50 | - takeScreenshot: android-native-map-satellite | |
| 51 | - tapOn: | |
| 52 | id: map-layer | |
| 53 | - tapOn: '^Hybrid$' | |
| 54 | - assertVisible: 'Map layer, Hybrid' | |
| 55 | - takeScreenshot: android-native-map-hybrid | |
| 56 | - tapOn: | |
| 57 | id: map-layer | |
| 58 | - tapOn: '^Street$' | |
| 59 | - assertVisible: 'Map layer, Street' | |
| 60 | - takeScreenshot: android-native-map-street | |
| 61 | - tapOn: | |
| 62 | id: map-layer | |
| 63 | - tapOn: '^Outdoors$' | |
| 64 | - assertVisible: 'Map layer, Outdoors' | |
| 65 | - tapOn: | |
| 66 | id: map-fit | |
| 67 | - takeScreenshot: android-native-map-outdoors-restored | |
| 68 | - back | |
| 69 | - assertVisible: | |
| 70 | id: trail-sticky-actions | |
| 71 | - takeScreenshot: android-native-sticky-actions | |
| @@ -0,0 +1,37 @@ | ||
| 1 | appId: ${APP_ID} | |
| 2 | name: Create a review with native controls | |
| 3 | --- | |
| 4 | - runFlow: sign-in.yaml | |
| 5 | - scrollUntilVisible: | |
| 6 | element: | |
| 7 | id: trail-write-review | |
| 8 | direction: DOWN | |
| 9 | - assertVisible: | |
| 10 | id: trail-sticky-actions | |
| 11 | - tapOn: | |
| 12 | id: trail-write-review | |
| 13 | - extendedWaitUntil: | |
| 14 | visible: | |
| 15 | id: trail-review-content | |
| 16 | timeout: 15000 | |
| 17 | - tapOn: | |
| 18 | id: trail-review-star-5 | |
| 19 | - tapOn: | |
| 20 | id: trail-review-content | |
| 21 | - inputText: ${REVIEW_TEXT} | |
| 22 | - hideKeyboard | |
| 23 | - scrollUntilVisible: | |
| 24 | element: | |
| 25 | id: trail-review-submit | |
| 26 | direction: DOWN | |
| 27 | - tapOn: | |
| 28 | id: trail-review-submit | |
| 29 | - extendedWaitUntil: | |
| 30 | visible: 'Edit your review' | |
| 31 | timeout: 15000 | |
| 32 | - scrollUntilVisible: | |
| 33 | element: | |
| 34 | text: ${REVIEW_TEXT} | |
| 35 | direction: DOWN | |
| 36 | - assertVisible: ${REVIEW_TEXT} | |
| 37 | - takeScreenshot: android-native-review-created | |
| @@ -0,0 +1,25 @@ | ||
| 1 | appId: ${APP_ID} | |
| 2 | name: Delete the persisted review through native confirmation | |
| 3 | --- | |
| 4 | - launchApp | |
| 5 | - openLink: wildloop://trail/${TRAIL_ID} | |
| 6 | - extendedWaitUntil: | |
| 7 | visible: ${TRAIL_NAME} | |
| 8 | timeout: 30000 | |
| 9 | - scrollUntilVisible: | |
| 10 | element: | |
| 11 | id: trail-delete-review | |
| 12 | direction: DOWN | |
| 13 | - tapOn: | |
| 14 | id: trail-delete-review | |
| 15 | - tapOn: '^Delete review$' | |
| 16 | - extendedWaitUntil: | |
| 17 | visible: 'Leave a review' | |
| 18 | timeout: 15000 | |
| 19 | - assertNotVisible: ${REVIEW_TEXT} | |
| 20 | - assertNotVisible: ${EDITED_REVIEW_TEXT} | |
| 21 | - assertNotVisible: | |
| 22 | id: trail-delete-review | |
| 23 | - assertVisible: | |
| 24 | id: trail-sticky-actions | |
| 25 | - takeScreenshot: android-native-review-deleted | |
| @@ -0,0 +1,41 @@ | ||
| 1 | appId: ${APP_ID} | |
| 2 | name: Reopen and edit the persisted native review | |
| 3 | --- | |
| 4 | - launchApp | |
| 5 | - openLink: wildloop://trail/${TRAIL_ID} | |
| 6 | - extendedWaitUntil: | |
| 7 | visible: ${TRAIL_NAME} | |
| 8 | timeout: 30000 | |
| 9 | - scrollUntilVisible: | |
| 10 | element: | |
| 11 | id: trail-write-review | |
| 12 | direction: DOWN | |
| 13 | - tapOn: | |
| 14 | id: trail-write-review | |
| 15 | - extendedWaitUntil: | |
| 16 | visible: | |
| 17 | id: trail-review-content | |
| 18 | timeout: 15000 | |
| 19 | - tapOn: | |
| 20 | id: trail-review-content | |
| 21 | - eraseText: 2000 | |
| 22 | - inputText: ${EDITED_REVIEW_TEXT} | |
| 23 | - hideKeyboard | |
| 24 | - tapOn: | |
| 25 | id: trail-review-star-4 | |
| 26 | - scrollUntilVisible: | |
| 27 | element: | |
| 28 | id: trail-review-submit | |
| 29 | direction: DOWN | |
| 30 | - tapOn: | |
| 31 | id: trail-review-submit | |
| 32 | - extendedWaitUntil: | |
| 33 | visible: 'Edit your review' | |
| 34 | timeout: 15000 | |
| 35 | - scrollUntilVisible: | |
| 36 | element: | |
| 37 | text: ${EDITED_REVIEW_TEXT} | |
| 38 | direction: DOWN | |
| 39 | - assertVisible: ${EDITED_REVIEW_TEXT} | |
| 40 | - assertNotVisible: ${REVIEW_TEXT} | |
| 41 | - takeScreenshot: android-native-review-edited | |
| @@ -0,0 +1,27 @@ | ||
| 1 | appId: ${APP_ID} | |
| 2 | --- | |
| 3 | - launchApp: | |
| 4 | clearState: true | |
| 5 | - extendedWaitUntil: | |
| 6 | visible: | |
| 7 | id: native-feed-scroll | |
| 8 | timeout: 30000 | |
| 9 | - openLink: wildloop://login?next=/trail/${TRAIL_ID} | |
| 10 | - extendedWaitUntil: | |
| 11 | visible: | |
| 12 | id: login-email | |
| 13 | timeout: 30000 | |
| 14 | - tapOn: | |
| 15 | id: login-email | |
| 16 | - inputText: ${E2E_EMAIL} | |
| 17 | - tapOn: | |
| 18 | id: login-password | |
| 19 | - inputText: ${E2E_PASSWORD} | |
| 20 | - hideKeyboard | |
| 21 | - tapOn: | |
| 22 | id: login-submit | |
| 23 | - extendedWaitUntil: | |
| 24 | visible: ${TRAIL_NAME} | |
| 25 | timeout: 30000 | |
| 26 | - assertVisible: | |
| 27 | id: trail-sticky-actions | |
| @@ -98,10 +98,9 @@ export function selectAndroidDeepLinkActivity(output: string, packageName: strin | ||
| 98 | 98 | return null |
| 99 | 99 | } |
| 100 | 100 | |
| 101 | export function selectAndroidHomePackage(output: string): string | null { | |
| 102 | const component = output.split(/\s+/).find(value => value.includes('/')) | |
| 103 | const packageName = component?.split('/')[0] | |
| 104 | return packageName || null | |
| 101 | export function assertAndroidPlatformHealthy(events: string): void { | |
| 102 | if (/am_(anr|crash)/.test(events)) | |
| 103 | throw new Error('Android platform crash/ANR invalidates native acceptance. Inspect the emulator diagnostics.') | |
| 105 | 104 | } |
| 106 | 105 | |
| 107 | 106 | export function selectIosSimulator(payload: SimctlDevices): IosDevice | null { |
| @@ -348,19 +347,10 @@ function seedTestLocation(platform: MobilePlatform, deviceId: string): void { | ||
| 348 | 347 | ]) |
| 349 | 348 | } |
| 350 | 349 | |
| 351 | /** | |
| 352 | * Google APIs images occasionally leave Pixel Launcher in an ANR state while | |
| 353 | * Wildloop is foregrounded. The launcher is not part of this journey, so stop | |
| 354 | * that background process before Maestro launches the app under test. | |
| 355 | */ | |
| 356 | function stopAndroidHomeLauncher(deviceId: string): void { | |
| 357 | const home = execute([ | |
| 358 | 'adb', '-s', deviceId, 'shell', 'cmd', 'package', 'resolve-activity', '--brief', | |
| 359 | '-a', 'android.intent.action.MAIN', '-c', 'android.intent.category.HOME', | |
| 360 | ], { capture: true }) | |
| 361 | const packageName = selectAndroidHomePackage(home) | |
| 362 | if (packageName) | |
| 363 | execute(['adb', '-s', deviceId, 'shell', 'am', 'force-stop', packageName]) | |
| 350 | function checkAndroidPlatform(deviceId: string): void { | |
| 351 | assertAndroidPlatformHealthy(execute([ | |
| 352 | 'adb', '-s', deviceId, 'logcat', '-b', 'events', '-d', '-v', 'threadtime', | |
| 353 | ], { capture: true })) | |
| 364 | 354 | } |
| 365 | 355 | |
| 366 | 356 | function appId(platform: MobilePlatform): string { |
| @@ -384,9 +374,10 @@ function runAndroid(preview: boolean): void { | ||
| 384 | 374 | console.log(`Wildloop is open on Android device ${devices[0]}.`) |
| 385 | 375 | } |
| 386 | 376 | else { |
| 387 | stopAndroidHomeLauncher(devices[0]) | |
| 377 | checkAndroidPlatform(devices[0]) | |
| 388 | 378 | seedTestLocation('android', devices[0]) |
| 389 | 379 | runMaestroJourneys('android', devices[0], null) |
| 380 | checkAndroidPlatform(devices[0]) | |
| 390 | 381 | } |
| 391 | 382 | } |
| 392 | 383 | |
| @@ -0,0 +1,133 @@ | ||
| 1 | import { mkdir, readFile, writeFile } from 'node:fs/promises' | |
| 2 | import { join, resolve } from 'node:path' | |
| 3 | import process from 'node:process' | |
| 4 | import mobile from '../config/mobile' | |
| 5 | import { QA_PORTS } from '../tests/browser/qa-ports' | |
| 6 | import { QA_SOCIAL_ACCOUNT } from '../tests/browser/qa-social-account' | |
| 7 | import { assertAndroidPlatformHealthy, maestroEnvironment, maestroReportSummary, parseAdbDevices, validateGeneratedNativeApp } from './run-mobile-e2e' | |
| 8 | ||
| 9 | interface PersistedReview { id: number, rating: number, content: string } | |
| 10 | ||
| 11 | /** Check server persistence independently of the native screen's local state. */ | |
| 12 | export function assertNativeReviewPersistence( | |
| 13 | reviews: PersistedReview[], phase: 'create' | 'edit' | 'delete', content: string, previousId?: number, | |
| 14 | ): number | undefined { | |
| 15 | if (phase === 'delete') { | |
| 16 | if (reviews.length) throw new Error('The native delete did not remove the persisted review.') | |
| 17 | return | |
| 18 | } | |
| 19 | const rating = phase === 'create' ? 5 : 4 | |
| 20 | if (reviews.length !== 1 || reviews[0].content !== content || reviews[0].rating !== rating) | |
| 21 | throw new Error(`The native ${phase} did not persist the expected review and rating.`) | |
| 22 | if (phase === 'edit' && reviews[0].id !== previousId) | |
| 23 | throw new Error('Editing created a second review instead of updating the original.') | |
| 24 | return reviews[0].id | |
| 25 | } | |
| 26 | ||
| 27 | /** Use only an owned emulator, a disposable database and normally published SDKs. */ | |
| 28 | export async function runNativeTrailAndroidE2e(): Promise<void> { | |
| 29 | const root = resolve('storage/framework/runtime/e2e/native-trail-android') | |
| 30 | const project = resolve('storage/framework/mobile/android') | |
| 31 | const origin = `http://127.0.0.1:${QA_PORTS.app}` | |
| 32 | await mkdir(root, { recursive: true }) | |
| 33 | async function command(args: string[], name: string, env: Record<string, string> = {}): Promise<string> { | |
| 34 | console.log(`Native Android trail QA: ${name}`) | |
| 35 | const child = Bun.spawn(args, { env: { ...process.env, ...env }, stdout: 'pipe', stderr: 'pipe' }) | |
| 36 | const [stdout, stderr, status] = await Promise.all([ | |
| 37 | new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited, | |
| 38 | ]) | |
| 39 | await writeFile(join(root, `${name}.log`), stdout + stderr) | |
| 40 | if (status) throw new Error(`${args[0]} exited ${status}; see ${name}.log`) | |
| 41 | return stdout | |
| 42 | } | |
| 43 | if (!process.argv.includes('--skip-build')) { | |
| 44 | await command(['bun', 'run', 'build:android'], 'app-build', { MOBILE_URL: `${origin}/feed` }) | |
| 45 | } | |
| 46 | validateGeneratedNativeApp(project) | |
| 47 | const config = JSON.parse(await readFile(join(project, 'craft.config.json'), 'utf8')) | |
| 48 | if (config.devServerURL !== `${origin}/feed` || config.mapProvider !== 'maplibre') | |
| 49 | throw new Error('Android trail QA requires its own loopback API and the native MapLibre provider.') | |
| 50 | const gradle = await readFile(join(project, 'app/build.gradle.kts'), 'utf8') | |
| 51 | if (!gradle.includes('org.maplibre.gl:android-sdk:') || gradle.includes('play-services-maps')) | |
| 52 | throw new Error('Android trail QA requires the real key-free native map SDK.') | |
| 53 | if (process.argv.includes('--build-only')) return | |
| 54 | ||
| 55 | const devices = parseAdbDevices(await command(['adb', 'devices'], 'devices')) | |
| 56 | const device = process.env.NATIVE_QA_ANDROID_DEVICE | |
| 57 | if (!device || !devices.includes(device)) throw new Error('NATIVE_QA_ANDROID_DEVICE must name an owned, ready emulator.') | |
| 58 | const adb = (args: string[], name: string) => command(['adb', '-s', device, ...args], name) | |
| 59 | const platformEvents = async (phase: string) => { | |
| 60 | const events = await adb(['logcat', '-b', 'events', '-d', '-v', 'threadtime'], `platform-${phase}`) | |
| 61 | assertAndroidPlatformHealthy(events) | |
| 62 | } | |
| 63 | await platformEvents('before') | |
| 64 | for (const port of [QA_PORTS.app, QA_PORTS.api]) { | |
| 65 | const occupied = await fetch(`http://127.0.0.1:${port}/api/health`, { signal: AbortSignal.timeout(1000) }).then(() => true).catch(() => false) | |
| 66 | if (occupied) throw new Error(`QA port ${port} is occupied; select a different QA_PORT_BASE.`) | |
| 67 | } | |
| 68 | const qa = Bun.spawn(['bun', 'scripts/start-recording-qa.ts'], { stdout: Bun.file(join(root, 'qa-server.log')), stderr: 'inherit' }) | |
| 69 | const forwards: number[] = [] | |
| 70 | try { | |
| 71 | const deadline = Date.now() + 180000 | |
| 72 | while (!await fetch(`${origin}/api/health`, { signal: AbortSignal.timeout(1000) }).then(r => r.ok).catch(() => false)) { | |
| 73 | if (qa.exitCode !== null || Date.now() > deadline) throw new Error('Isolated native QA server did not become ready.') | |
| 74 | await Bun.sleep(250) | |
| 75 | } | |
| 76 | for (const port of [QA_PORTS.app, QA_PORTS.api]) { | |
| 77 | await adb(['reverse', `tcp:${port}`, `tcp:${port}`], `reverse-${port}`) | |
| 78 | forwards.push(port) | |
| 79 | } | |
| 80 | const listed = await (await fetch(`${origin}/api/trails?q=Craggy&country=all&limit=10`)).json() as any | |
| 81 | let trail: any | |
| 82 | for (const candidate of listed.trails ?? []) { | |
| 83 | const detail = await (await fetch(`${origin}/api/trails/${candidate.id}`)).json() as any | |
| 84 | if (detail.trail?.hasGeometry) { trail = detail.trail; break } | |
| 85 | } | |
| 86 | if (!trail) throw new Error('Native Android QA requires its seeded recorded route.') | |
| 87 | const reviews = async (): Promise<PersistedReview[]> => { | |
| 88 | const response = await fetch(`${origin}/api/trails/${trail.id}/reviews`, { cache: 'no-store' }) | |
| 89 | if (!response.ok) throw new Error(`Reading persisted reviews failed: ${response.status}`) | |
| 90 | return ((await response.json()) as any).reviews | |
| 91 | } | |
| 92 | if ((await reviews()).length) throw new Error('The isolated trail must start without reviews.') | |
| 93 | await adb(['install', '-r', join(project, 'app/build/outputs/apk/debug/app-debug.apk')], 'app-install') | |
| 94 | const marker = crypto.randomUUID().slice(0, 8) | |
| 95 | const review = `Android QA ${marker}: clear signs and a beautiful ridge.` | |
| 96 | const edit = `Android QA ${marker}: updated after a return visit.` | |
| 97 | const variables = { | |
| 98 | APP_ID: mobile.android.packageName, TRAIL_ID: String(trail.id), TRAIL_NAME: trail.name, | |
| 99 | E2E_EMAIL: QA_SOCIAL_ACCOUNT.email, E2E_PASSWORD: QA_SOCIAL_ACCOUNT.sessionSecret, | |
| 100 | REVIEW_TEXT: review, EDITED_REVIEW_TEXT: edit, | |
| 101 | } | |
| 102 | let reviewId: number | undefined | |
| 103 | for (const phase of ['maps', 'review-create', 'review-edit', 'review-delete'] as const) { | |
| 104 | const report = join(root, `${phase}.xml`) | |
| 105 | await command([ | |
| 106 | 'maestro', `--device=${device}`, '--no-ansi', 'test', | |
| 107 | ...Object.entries(variables).flatMap(([key, value]) => ['--env', `${key}=${value}`]), | |
| 108 | '--format', 'junit', '--output', report, '--debug-output', join(root, 'debug', phase), | |
| 109 | '--test-output-dir', join(root, 'tests', phase), `.maestro/native-trail/${phase}.yaml`, | |
| 110 | ], phase, maestroEnvironment()) | |
| 111 | const summary = maestroReportSummary(await readFile(report, 'utf8')) | |
| 112 | if (!summary.tests || summary.failures) throw new Error(`Native Android ${phase} did not pass.`) | |
| 113 | if (phase !== 'maps') { | |
| 114 | const saved = await reviews() | |
| 115 | await writeFile(join(root, `${phase}-persistence.json`), JSON.stringify(saved, null, 2)) | |
| 116 | const action = phase === 'review-create' ? 'create' : phase === 'review-edit' ? 'edit' : 'delete' | |
| 117 | reviewId = assertNativeReviewPersistence(saved, action, action === 'create' ? review : edit, reviewId) | |
| 118 | } | |
| 119 | await platformEvents(phase) | |
| 120 | } | |
| 121 | console.log('Native Android maps, sticky save and persisted review create/edit/delete passed.') | |
| 122 | } | |
| 123 | finally { | |
| 124 | await adb(['logcat', '-d', '-s', 'CraftNativeAndroid:V', 'AndroidRuntime:E'], 'app-runtime').catch(() => {}) | |
| 125 | await adb(['shell', 'uiautomator', 'dump', '/sdcard/wildloop-trail-final.xml'], 'final-tree').then(() => | |
| 126 | adb(['pull', '/sdcard/wildloop-trail-final.xml', join(root, 'final-tree.xml')], 'pull-final-tree')).catch(() => {}) | |
| 127 | for (const port of forwards) await adb(['reverse', '--remove', `tcp:${port}`], `remove-reverse-${port}`).catch(() => {}) | |
| 128 | qa.kill('SIGTERM') | |
| 129 | await qa.exited | |
| 130 | } | |
| 131 | } | |
| 132 | ||
| 133 | if (import.meta.main) await runNativeTrailAndroidE2e() | |
| @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' | ||
| 2 | 2 | import { tmpdir } from 'node:os' |
| 3 | 3 | import { join } from 'node:path' |
| 4 | 4 | import { describe, expect, it } from 'bun:test' |
| 5 | import { deepLinkFlow, maestroEnvironment, maestroReportSummary, parseAdbDevices, requestedPlatform, resolveJavaHome, selectAndroidDeepLinkActivity, selectAndroidHomePackage, selectIosSimulator, signedInTargetURL, validateAnalyticsScriptCount, validateBundledFrontend, validateGeneratedNativeApp, validateIosAppBundle, validateNativeShellStyles, validateNativeTabLinks } from '../../scripts/run-mobile-e2e' | |
| 5 | import { assertAndroidPlatformHealthy, deepLinkFlow, maestroEnvironment, maestroReportSummary, parseAdbDevices, requestedPlatform, resolveJavaHome, selectAndroidDeepLinkActivity, selectIosSimulator, signedInTargetURL, validateAnalyticsScriptCount, validateBundledFrontend, validateGeneratedNativeApp, validateIosAppBundle, validateNativeShellStyles, validateNativeTabLinks } from '../../scripts/run-mobile-e2e' | |
| 6 | 6 | import { inferDevelopmentTeam, requiredIosAppPaths, requiresDevelopmentTeam, selectAvailableIphone } from '../../scripts/run-ios-device' |
| 7 | 7 | |
| 8 | 8 | function writeNativeNavigationFixtures(output: string, intercepted = ''): void { |
| @@ -118,9 +118,10 @@ describe('mobile E2E runner', () => { | ||
| 118 | 118 | expect(selectAndroidDeepLinkActivity(output, 'org.missing.app')).toBeNull() |
| 119 | 119 | }) |
| 120 | 120 | |
| 121 | it('selects the Android home package without assuming a launcher vendor', () => { | |
| 122 | expect(selectAndroidHomePackage('com.google.android.apps.nexuslauncher/.NexusLauncherActivity\n')).toBe('com.google.android.apps.nexuslauncher') | |
| 123 | expect(selectAndroidHomePackage('')).toBeNull() | |
| 121 | it('rejects emulator crashes and ANRs rather than hiding the system launcher', () => { | |
| 122 | expect(() => assertAndroidPlatformHealthy('am_resume_activity: org.wildloop.app')).not.toThrow() | |
| 123 | expect(() => assertAndroidPlatformHealthy('am_anr: com.android.launcher3')).toThrow('invalidates native acceptance') | |
| 124 | expect(() => assertAndroidPlatformHealthy('am_crash: org.wildloop.app')).toThrow('invalidates native acceptance') | |
| 124 | 125 | }) |
| 125 | 126 | |
| 126 | 127 | it('accepts only supported platform arguments', () => { |
| @@ -0,0 +1,20 @@ | ||
| 1 | import { describe, expect, it } from 'bun:test' | |
| 2 | import { assertNativeReviewPersistence } from '../../scripts/run-native-trail-android-e2e' | |
| 3 | ||
| 4 | describe('native review acceptance persistence checks', () => { | |
| 5 | it('requires the edited rating and text on the original review', () => { | |
| 6 | const created = { id: 7, content: 'First visit.', rating: 5 } | |
| 7 | const id = assertNativeReviewPersistence([created], 'create', created.content) | |
| 8 | expect(id).toBe(7) | |
| 9 | expect(() => assertNativeReviewPersistence([created], 'edit', 'Return visit.', id)).toThrow('expected review') | |
| 10 | expect(() => assertNativeReviewPersistence([{ ...created, id: 8, content: 'Return visit.', rating: 4 }], 'edit', 'Return visit.', id)).toThrow('second review') | |
| 11 | expect(assertNativeReviewPersistence([{ ...created, content: 'Return visit.', rating: 4 }], 'edit', 'Return visit.', id)).toBe(id) | |
| 12 | }) | |
| 13 | ||
| 14 | it('rejects duplicate creates and a review left behind after deletion', () => { | |
| 15 | const row = { id: 7, content: 'First visit.', rating: 5 } | |
| 16 | expect(() => assertNativeReviewPersistence([row, { ...row, id: 8 }], 'create', row.content)).toThrow('expected review') | |
| 17 | expect(() => assertNativeReviewPersistence([row], 'delete', row.content)).toThrow('did not remove') | |
| 18 | expect(assertNativeReviewPersistence([], 'delete', row.content)).toBeUndefined() | |
| 19 | }) | |
| 20 | }) | |
| @@ -79,6 +79,7 @@ | ||
| 79 | 79 | "scripts/test-recording-api.ts", |
| 80 | 80 | "scripts/start-recording-qa.ts", |
| 81 | 81 | "scripts/run-native-trail-e2e.ts", |
| 82 | "scripts/run-native-trail-android-e2e.ts", | |
| 82 | 83 | "tests/native/**/*.ts", |
| 83 | 84 | "tests/unit/account-deletion.test.ts", |
| 84 | 85 | "tests/unit/battle-rows.test.ts", |