ReviewOS

also looking at this

stacks/wildloop

feat: native trail maps and complete native mobile screens

#1017
Draft chris wants to merge codex/trail-experience into main
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.

.maestro/native-trail/directions.yamladded+16-0
Changes to .maestro/native-trail/directions.yaml
@@ -0,0 +1,16 @@
1appId: ${APP_ID}
2name: Open trailhead directions from the native sticky actions
3---
4- launchApp
5- openLink: wildloop://trail/${TRAIL_ID}
6- extendedWaitUntil:
7 visible:
8 id: trail-directions
9 timeout: 30000
10- tapOn:
11 id: trail-directions
12- extendedWaitUntil:
13 notVisible:
14 id: trail-sticky-actions
15 timeout: 15000
16- takeScreenshot: android-native-directions-handoff
.maestro/native-trail/sharing.yamladded+28-0
Changes to .maestro/native-trail/sharing.yaml
@@ -0,0 +1,28 @@
1appId: ${APP_ID}
2name: Copy the trail link and paste it into a real native field
3---
4- launchApp
5- openLink: wildloop://trail/${TRAIL_ID}
6- extendedWaitUntil:
7 visible:
8 id: trail-share
9 timeout: 30000
10- tapOn:
11 id: trail-share
12- extendedWaitUntil:
13 visible: '^Copy$'
14 timeout: 15000
15- takeScreenshot: android-native-share-sheet
16- tapOn: '^Copy$'
17- openLink: wildloop://login
18- extendedWaitUntil:
19 visible:
20 id: login-email
21 timeout: 15000
22- longPressOn:
23 id: login-email
24- tapOn: '^Paste$'
25- assertVisible:
26 id: login-email
27 text: 'https://wildloop.org/trail/${TRAIL_ID}'
28- takeScreenshot: android-native-share-link-pasted
.maestro/native-trail/utilities.yamladded+43-0
Changes to .maestro/native-trail/utilities.yaml
@@ -0,0 +1,43 @@
1appId: ${APP_ID}
2name: Native location, route customization and GPX sharing
3---
4- launchApp
5- openLink: wildloop://trail/${TRAIL_ID}/map
6- extendedWaitUntil:
7 visible:
8 id: trail-full-map
9 timeout: 30000
10- assertNotVisible:
11 id: '.*permission_allow_foreground_only_button'
12- tapOn:
13 id: map-locate
14- extendedWaitUntil:
15 visible:
16 id: '.*permission_allow_foreground_only_button'
17 timeout: 15000
18- tapOn:
19 id: '.*permission_allow_foreground_only_button'
20- extendedWaitUntil:
21 visible: 'Your location is shown'
22 timeout: 30000
23- takeScreenshot: android-native-map-location
24- tapOn:
25 id: map-customize
26- extendedWaitUntil:
27 visible:
28 id: route-builder-map
29 timeout: 30000
30- assertVisible: ${TRAIL_NAME}
31- takeScreenshot: android-native-customize-route
32- back
33- assertVisible:
34 id: trail-full-map
35- tapOn:
36 id: map-export
37- extendedWaitUntil:
38 visible: ${GPX_NAME}
39 timeout: 15000
40- takeScreenshot: android-native-gpx-share-sheet
41- back
42- assertVisible:
43 id: trail-full-map
scripts/run-native-trail-android-e2e.tsmodified+25-4
Changes to scripts/run-native-trail-android-e2e.ts
@@ -4,6 +4,7 @@ import process from 'node:process'
44import mobile from '../config/mobile'
55import { QA_PORTS } from '../tests/browser/qa-ports'
66import { QA_SOCIAL_ACCOUNT } from '../tests/browser/qa-social-account'
7import { decodeRouteParts, primaryRoutePart } from '../resources/functions/trail-geometry'
78import { assertAndroidPlatformHealthy, maestroEnvironment, maestroReportSummary, parseAdbDevices, validateGeneratedNativeApp } from './run-mobile-e2e'
89
910interface PersistedReview { id: number, rating: number, content: string }
@@ -54,7 +55,7 @@ export async function runNativeTrailAndroidE2e(): Promise<void> {
5455
5556 const devices = parseAdbDevices(await command(['adb', 'devices'], 'devices'))
5657 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 if (!device || !/^emulator-\d+$/.test(device) || !devices.includes(device)) throw new Error('NATIVE_QA_ANDROID_DEVICE must name an owned, ready emulator.')
5859 const adb = (args: string[], name: string) => command(['adb', '-s', device, ...args], name)
5960 const platformEvents = async (phase: string) => {
6061 const events = await adb(['logcat', '-b', 'events', '-d', '-v', 'threadtime'], `platform-${phase}`)
@@ -84,6 +85,9 @@ export async function runNativeTrailAndroidE2e(): Promise<void> {
8485 if (detail.trail?.hasGeometry) { trail = detail.trail; break }
8586 }
8687 if (!trail) throw new Error('Native Android QA requires its seeded recorded route.')
88 const first = primaryRoutePart([...decodeRouteParts(trail.geometry), ...(Array.isArray(trail.pieceRoutes) ? trail.pieceRoutes : [])])[0]
89 if (!first) throw new Error('The seeded trail has no recorded route start.')
90 await adb(['emu', 'geo', 'fix', String(first[1]), String(first[0])], 'seed-location')
8791 const reviews = async (): Promise<PersistedReview[]> => {
8892 const response = await fetch(`${origin}/api/trails/${trail.id}/reviews`, { cache: 'no-store' })
8993 if (!response.ok) throw new Error(`Reading persisted reviews failed: ${response.status}`)
@@ -98,9 +102,10 @@ export async function runNativeTrailAndroidE2e(): Promise<void> {
98102 APP_ID: mobile.android.packageName, TRAIL_ID: String(trail.id), TRAIL_NAME: trail.name,
99103 E2E_EMAIL: QA_SOCIAL_ACCOUNT.email, E2E_PASSWORD: QA_SOCIAL_ACCOUNT.sessionSecret,
100104 REVIEW_TEXT: review, EDITED_REVIEW_TEXT: edit,
105 GPX_NAME: `${trail.name.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '').slice(0, 80)}.gpx`,
101106 }
102107 let reviewId: number | undefined
103 for (const phase of ['maps', 'review-create', 'review-edit', 'review-delete'] as const) {
108 for (const phase of ['maps', 'utilities', 'sharing', 'directions', 'review-create', 'review-edit', 'review-delete'] as const) {
104109 const report = join(root, `${phase}.xml`)
105110 await command([
106111 'maestro', `--device=${device}`, '--no-ansi', 'test',
@@ -110,15 +115,31 @@ export async function runNativeTrailAndroidE2e(): Promise<void> {
110115 ], phase, maestroEnvironment())
111116 const summary = maestroReportSummary(await readFile(report, 'utf8'))
112117 if (!summary.tests || summary.failures) throw new Error(`Native Android ${phase} did not pass.`)
113 if (phase !== 'maps') {
118 if (phase.startsWith('review-')) {
114119 const saved = await reviews()
115120 await writeFile(join(root, `${phase}-persistence.json`), JSON.stringify(saved, null, 2))
116121 const action = phase === 'review-create' ? 'create' : phase === 'review-edit' ? 'edit' : 'delete'
117122 reviewId = assertNativeReviewPersistence(saved, action, action === 'create' ? review : edit, reviewId)
118123 }
124 if (phase === 'utilities') {
125 const files = await adb(['shell', 'run-as', mobile.android.packageName, 'find', '.', '-type', 'f'], 'export-files')
126 const paths = files.split(/\r?\n/).filter(path => /^[a-zA-Z0-9_./-]+\.gpx$/.test(path))
127 let exported = false
128 for (const path of paths) {
129 const xml = await adb(['exec-out', 'run-as', mobile.android.packageName, 'cat', path], 'exported-gpx')
130 if (xml.includes('<gpx ') && xml.includes('<trkseg>') && xml.includes(`<trkpt lat="${first[0]}" lon="${first[1]}"`)) exported = true
131 }
132 if (!exported) throw new Error('The native GPX export did not contain the recorded route coordinates.')
133 }
134 if (phase === 'directions') {
135 const activities = await adb(['shell', 'dumpsys', 'activity', 'activities'], 'directions-activities')
136 const resumed = activities.split('\n').filter(line => /(?:mResumedActivity|topResumedActivity):/.test(line))
137 if (!resumed.length || resumed.some(line => line.includes(`${mobile.android.packageName}/`)))
138 throw new Error('Directions did not hand off to an external Android activity.')
139 }
119140 await platformEvents(phase)
120141 }
121 console.log('Native Android maps, sticky save and persisted review create/edit/delete passed.')
142 console.log('Native Android maps, sticky actions, location, GPX, route customization, actual share-link paste and persisted review create/edit/delete passed.')
122143 }
123144 finally {
124145 await adb(['logcat', '-d', '-s', 'CraftNativeAndroid:V', 'AndroidRuntime:E'], 'app-runtime').catch(() => {})