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.

.github/workflows/mobile-e2e.ymlmodified+6-1
Changes to .github/workflows/mobile-e2e.yml
@@ -124,10 +124,15 @@ jobs:
124124 if-no-files-found: warn
125125
126126 native-trail-ios:
127 strategy:
128 fail-fast: false
129 matrix:
130 group: [maps, reviews, utilities, navigation]
127131 runs-on: macos-latest
128132 timeout-minutes: 35
129133 env:
130134 QA_PORT_BASE: '6320'
135 NATIVE_TRAIL_GROUP: ${{ matrix.group }}
131136 steps:
132137 - uses: actions/checkout@v5
133138 - uses: oven-sh/setup-bun@v2
@@ -167,7 +172,7 @@ jobs:
167172 if: always()
168173 uses: actions/upload-artifact@v5
169174 with:
170 name: native-trail-ios
175 name: native-trail-ios-${{ matrix.group }}
171176 path: |
172177 storage/framework/runtime/e2e/native-trail/*.log
173178 storage/framework/runtime/e2e/native-trail/*.txt
.maestro/native-trail/appearance.yamlmodified+1-1
Changes to .maestro/native-trail/appearance.yaml
@@ -17,7 +17,7 @@ name: Native trail maps and sticky controls in system dark appearance
1717- scrollUntilVisible:
1818 element:
1919 id: trail-map-fullscreen
20 direction: DOWN
20 direction: UP
2121- tapOn:
2222 id: trail-map-fullscreen
2323- extendedWaitUntil:
.maestro/native-trail/maps.yamlmodified+1-1
Changes to .maestro/native-trail/maps.yaml
@@ -102,7 +102,7 @@ name: Native Android trail maps and sticky save
102102- scrollUntilVisible:
103103 element:
104104 id: trail-map-fullscreen
105 direction: DOWN
105 direction: UP
106106- assertVisible:
107107 id: trail-sticky-actions
108108- tapOn:
.maestro/native-trail/sharing.yamlmodified+6-4
Changes to .maestro/native-trail/sharing.yaml
@@ -14,15 +14,17 @@ name: Copy the trail link and paste it into a real native field
1414 timeout: 15000
1515- takeScreenshot: android-native-share-sheet
1616- tapOn: '^Copy$'
17- openLink: wildloop://login
17- openLink: wildloop://search
1818- extendedWaitUntil:
1919 visible:
20 id: login-email
20 id: search-input
2121 timeout: 15000
22- tapOn:
23 id: search-input
2224- longPressOn:
23 id: login-email
25 id: search-input
2426- tapOn: '^Paste$'
2527- assertVisible:
26 id: login-email
28 id: search-input
2729 text: 'https://wildloop.org/trail/${TRAIL_ID}'
2830- takeScreenshot: android-native-share-link-pasted
resources/native/TrailDetail.stxmodified+2-3
Changes to resources/native/TrailDetail.stx
@@ -314,11 +314,11 @@ onMount(() => onNativeAccountChange(craft, () => {
314314 <Map testID="trail-native-map" class="absolute inset-0 h-full w-full" :region="routeAvailable() ? region() : mapRegion()" :fitToBounds="region() === null && routeAvailable()" :edgePadding="{top:32,right:32,bottom:profileOpen() ? 220 : 88,left:32}" :mapType="basemap().mapType" :tileOverlay="basemap().tileOverlay" :scrollEnabled="false" :zoomEnabled="false" :rotateEnabled="false" :showsCompass="true" :showsScale="true" :showsUserLocation="showLocation" :cameraPitch="pitch" :cameraHeading="heading" @region-change-complete="camera($event)">
315315 <MapPolyline :for="(part, index) in parts()" :key="'route-outline-' + index" :id="'route-outline-' + index" :geometry="part" :strokeColor="trailRouteStyle.casing" :strokeWidth="9" />
316316 <MapPolyline :for="(part, index) in parts()" :key="index" :id="'route-' + index" :geometry="part" :strokeColor="trailRouteStyle.line" :strokeWidth="5" />
317 <MapMarker :for="(sign,index) in directionSigns()" :key="index" :id="'direction-' + index" :geometry="sign.point" :bearing="sign.bearing" title="Recorded route direction" />
317 <MapMarker :for="(sign,index) in directionSigns()" :key="index" :id="'direction-' + index" :geometry="sign.point" :bearing="sign.bearing" :pinColor="trailRouteStyle.casing" title="Recorded route direction" />
318318 <MapMarker id="trailhead" title="Trailhead" pinColor="#24724b" :geometry="[trailhead()?.lat, trailhead()?.lng]" />
319319 <MapMarker :show="inspected()" id="elevation-point" title="Elevation position" pinColor="#176bad" :geometry="[inspected()?.lat, inspected()?.lng]" />
320320 </Map>
321 <Button testID="trail-map-preview-expand" accessibilityLabel="Open interactive map" :class="ui.floating" class="absolute top-3 right-3" @click="craft.navigation.open('/trail/' + wanted + '/map')" title="Expand map" />
321 <Button testID="trail-map-fullscreen" accessibilityLabel="Expand map" :class="ui.floating" class="absolute top-3 right-3" @click="craft.navigation.open('/trail/' + wanted + '/map')" title="Expand map" />
322322 <View class="absolute bottom-5 left-3 right-3 rounded-[20px] bg-white p-3 shadow-lg dark:bg-[#1b2c22]" testID="trail-floating-profile">
323323 <View class="flex-row items-center justify-between"><Text :class="ui.caption">Elevation profile</Text><Button testID="trail-profile-toggle" class="h-11 min-w-[44px] text-[15px] font-semibold text-[#253321] dark:text-[#e3ecdf]" accessibilityLabel="Toggle elevation profile" :accessibilityValue="profileOpen() ? 'Elevation profile expanded' : 'Elevation profile collapsed'" @click="profileOpen.set(!profileOpen())" :text="profileOpen() ? 'Hide' : 'Show'" /></View>
324324 <View :show="profileOpen">
@@ -346,7 +346,6 @@ onMount(() => onNativeAccountChange(craft, () => {
346346 <Button testID="trail-map-3d" :class="ui.secondary" @click="pitch.set(pitch() > 0 ? 0 : 45)" :text="pitch() > 0 ? '2D' : '3D'" />
347347 <Button :class="ui.secondary" @click="heading.set(0)">North</Button>
348348 <Button testID="trail-map-locate" :class="ui.secondary" accessibilityLabel="My location" :accessibilityValue="showLocation() ? 'Your location is shown' : ''" @click="locate()">My location</Button>
349 <Button testID="trail-map-fullscreen" :class="ui.secondary" @click="craft.navigation.open('/trail/' + wanted + '/map')">Expand map</Button>
350349 </View>
351350 <Button testID="trail-map-export" :disabled="!routeAvailable()" :class="ui.secondary" class="mt-2" @click="exportRoute()">Export GPX</Button>
352351 <Button testID="trail-map-layer" :class="ui.secondary" :title="'Map layer' + ': ' + nativeChoiceLabel(nativeBasemapOptions,mapType())" accessibilityLabel="Map layer" :accessibilityValue="nativeChoiceLabel(nativeBasemapOptions,mapType())" @click="chooseNativeChoice(craft,'Map layer',nativeBasemapOptions,choice=>{mapType.set(choice)},message=>error.set(message))" />
resources/native/TrailMap.stxmodified+1-1
Changes to resources/native/TrailMap.stx
@@ -102,7 +102,7 @@ onMount(() => { void load(); return () => { alive = false } })
102102 @region-change-complete="camera($event)">
103103 <MapPolyline :for="(part,index) in parts()" :key="'route-outline-' + index" :id="'route-outline-' + index" :geometry="part" :strokeColor="trailRouteStyle.casing" :strokeWidth="9" />
104104 <MapPolyline :for="(part,index) in parts()" :key="index" :id="'route-' + index" :geometry="part" :strokeColor="trailRouteStyle.line" :strokeWidth="5" />
105 <MapMarker :for="(sign,index) in signs()" :key="index" :id="'direction-' + index" :geometry="sign.point" :bearing="sign.bearing" title="Recorded route direction" />
105 <MapMarker :for="(sign,index) in signs()" :key="index" :id="'direction-' + index" :geometry="sign.point" :bearing="sign.bearing" :pinColor="trailRouteStyle.casing" title="Recorded route direction" />
106106 <MapMarker id="trailhead" title="Trailhead" pinColor="#24724b" :geometry="[trailhead()?.lat, trailhead()?.lng]" />
107107 <MapMarker :show="inspected()" id="elevation-point" title="Elevation position" pinColor="#176bad" :geometry="[inspected()?.lat, inspected()?.lng]" />
108108 </Map>
scripts/run-native-trail-e2e.tsmodified+3-3
Changes to scripts/run-native-trail-e2e.ts
@@ -4,7 +4,7 @@ import process from 'node:process'
44import { generateXcodeProject, resolveXcodegen } from '@stacksjs/actions/build/ios-toolchain'
55import { prepareIosSimulatorDriver, serializeSimulatorSteps } from '@stacksjs/mobile/testing'
66import mobile from '../config/mobile'
7import { nativeTrailJourneys } from '../tests/native/trail-journey'
7import { nativeTrailJourneys, selectNativeTrailJourneys } from '../tests/native/trail-journey'
88import { QA_PORTS } from '../tests/browser/qa-ports'
99import { QA_SOCIAL_ACCOUNT } from '../tests/browser/qa-social-account'
1010import { decodeRouteParts, primaryRoutePart } from '../resources/functions/trail-geometry'
@@ -66,7 +66,7 @@ export async function runNativeTrailE2e(): Promise<void> {
6666 }
6767 if((await readReviews()).length)throw new Error('The isolated native trail must start without reviews.')
6868 let createdReviewId:number|undefined
69 const journeys=nativeTrailJourneys({trailId:trail.id,trailName:trail.name,email:QA_SOCIAL_ACCOUNT.email,password:QA_SOCIAL_ACCOUNT.sessionSecret,review,edit,profileEndText:`${endpoint.mi.toFixed(2)} mi · ${endElevation}`,inlineProfileEndText:`${endpoint.mi.toFixed(2)} mi, ${endElevation}`})
69 const journeys=selectNativeTrailJourneys(nativeTrailJourneys({trailId:trail.id,trailName:trail.name,email:QA_SOCIAL_ACCOUNT.email,password:QA_SOCIAL_ACCOUNT.sessionSecret,review,edit,profileEndText:`${endpoint.mi.toFixed(2)} mi · ${endElevation}`,inlineProfileEndText:`${endpoint.mi.toFixed(2)} mi, ${endElevation}`}),process.env.NATIVE_TRAIL_GROUP)
7070 const controlPort=QA_PORTS.app+15
7171 const driver=prepareIosSimulatorDriver({...mobile.ios,simulator:{output:join(root,'driver'),projectName:'WildloopTrailDriver',bundleIdPrefix:'org.wildloop.trailqa',controlUrl:`http://127.0.0.1:${controlPort}`}})
7272 control=Bun.serve({hostname:'127.0.0.1',port:controlPort,idleTimeout:120,async fetch(request){
@@ -180,7 +180,7 @@ export async function runNativeTrailE2e(): Promise<void> {
180180 }
181181 }
182182 if(failures.length)throw new Error(`Native trail QA failed: ${failures.join(', ')}. Inspect the separate XCTest results, driver logs and screenshots.`)
183 console.log('Native trail maps, sticky save, review lifecycle, sharing, GPX export, route planner and location journeys passed.')
183 console.log(`Native trail journeys passed: ${journeys.map(journey=>journey.name).join(', ')}.`)
184184 }
185185 finally {
186186 if(nativeRun && nativeRun.exitCode===null)nativeRun.kill()
tests/native/trail-journey.tsmodified+28-5
Changes to tests/native/trail-journey.ts
@@ -1,5 +1,23 @@
11import type { SimulatorStep } from '@stacksjs/mobile/testing'
22
3export const nativeTrailJourneyGroups = {
4 maps: ['maps'],
5 reviews: ['reviews', 'review-delete'],
6 utilities: ['sharing', 'gpx', 'planner', 'location'],
7 navigation: ['directions', 'map-directions', 'appearance'],
8} as const
9
10/** Partition all required journeys while retaining review lifecycle state. */
11export function selectNativeTrailJourneys<T extends {name:string}>(journeys:T[],group?:string):T[] {
12 const grouped=Object.values(nativeTrailJourneyGroups).flat()
13 if(grouped.length!==journeys.length || new Set(grouped).size!==journeys.length || journeys.some(journey=>!grouped.includes(journey.name as typeof grouped[number])))
14 throw new Error('Every native trail journey must belong to exactly one CI group.')
15 if(!group)return journeys
16 if(!Object.hasOwn(nativeTrailJourneyGroups,group))throw new Error(`Unknown native trail journey group: ${group}`)
17 const names=nativeTrailJourneyGroups[group as keyof typeof nativeTrailJourneyGroups]
18 return journeys.filter(journey=>(names as readonly string[]).includes(journey.name))
19}
20
321/** Real native UI interactions against the isolated QA database. */
422export function nativeTrailJourneys(input:{trailId:number,trailName:string,email:string,password:string,review:string,edit:string,profileEndText:string,inlineProfileEndText:string}):{name:string,steps:SimulatorStep[],completion:string}[] {
523const arrival:SimulatorStep[] = [
@@ -38,6 +56,7 @@ const maps:SimulatorStep[] = [
3856 {command:'expectexact',text:'Save',seconds:30},
3957 {command:'scrollto',text:'Toggle elevation profile'},
4058 {command:'shot',name:'native-floating-profile-inline'},
59 {command:'scrollto',text:'Explore elevation and position along the route'},
4160 {command:'sliderid',id:'trail-elevation-slider',position:1},
4261 {command:'expectexact',text:input.inlineProfileEndText,seconds:10},
4362 {command:'shot',name:'native-inline-elevation-endpoint'},
@@ -144,7 +163,10 @@ const reviews:SimulatorStep[] = [
144163 {command:'tapid',text:'trail-write-review'},
145164 {command:'expectid',text:'trail-review-composer',seconds:10},
146165 {command:'tapid',text:'trail-review-star-4'},
166 {command:'tapid',text:'trail-review-content'},
147167 {command:'longpressid',text:'trail-review-content'},
168 {command:'shot',name:'native-review-edit-menu'},
169 {command:'tree',name:'native-review-edit-menu'},
148170 {command:'tap',text:'Select All'},
149171 {command:'type',text:input.edit},
150172 {command:'valueid',id:'trail-review-content',text:input.edit},
@@ -182,12 +204,13 @@ const sharing:SimulatorStep[] = [
182204 {command:'shot',name:'native-share-sheet'},
183205 {command:'tree',name:'native-share-sheet'},
184206 {command:'tapexact',text:'Copy'},
185 {command:'openurl',url:'wildloop://login'},
186 {command:'expectid',text:'login-email',seconds:10},
187 {command:'valueid',id:'login-email',text:''},
188 {command:'longpressid',text:'login-email'},
207 {command:'openurl',url:'wildloop://search'},
208 {command:'expectid',text:'search-input',seconds:10},
209 {command:'valueid',id:'search-input',text:''},
210 {command:'tapid',text:'search-input'},
211 {command:'longpressid',text:'search-input'},
189212 {command:'tap',text:'Paste'},
190 {command:'valueid',id:'login-email',text:`https://wildloop.org/trail/${input.trailId}`},
213 {command:'valueid',id:'search-input',text:`https://wildloop.org/trail/${input.trailId}`},
191214 {command:'shot',name:'native-shared-link-pasted'},
192215 {command:'tree',name:'native-shared-link-pasted'},
193216 {command:'openurl',url:`wildloop://trail/${input.trailId}`},
tests/unit/native-trail-detail-screen.test.tsmodified+1-1
Changes to tests/unit/native-trail-detail-screen.test.ts
@@ -44,7 +44,7 @@ describe('native trail details', () => {
4444 await screen.event('trail-profile-toggle','onPress')
4545 expect(nativeNodeProps(screen,'trail-profile-toggle').accessibilityValue).toBe('Elevation profile collapsed')
4646 expect(nativeNodeProps(screen,'trail-native-map').edgePadding.bottom).toBe(88)
47 await screen.event('trail-map-preview-expand','onPress')
47 await screen.event('trail-map-fullscreen','onPress')
4848 expect(screen.sent.some(message=>message.type==='NAVIGATE_OPEN' && message.payload.path==='/trail/7/map')).toBe(true)
4949 })
5050 it('changes map layer and camera through native bindings', async () => {