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.
| @@ -124,10 +124,15 @@ jobs: | ||
| 124 | 124 | if-no-files-found: warn |
| 125 | 125 | |
| 126 | 126 | native-trail-ios: |
| 127 | strategy: | |
| 128 | fail-fast: false | |
| 129 | matrix: | |
| 130 | group: [maps, reviews, utilities, navigation] | |
| 127 | 131 | runs-on: macos-latest |
| 128 | 132 | timeout-minutes: 35 |
| 129 | 133 | env: |
| 130 | 134 | QA_PORT_BASE: '6320' |
| 135 | NATIVE_TRAIL_GROUP: ${{ matrix.group }} | |
| 131 | 136 | steps: |
| 132 | 137 | - uses: actions/checkout@v5 |
| 133 | 138 | - uses: oven-sh/setup-bun@v2 |
| @@ -167,7 +172,7 @@ jobs: | ||
| 167 | 172 | if: always() |
| 168 | 173 | uses: actions/upload-artifact@v5 |
| 169 | 174 | with: |
| 170 | name: native-trail-ios | |
| 175 | name: native-trail-ios-${{ matrix.group }} | |
| 171 | 176 | path: | |
| 172 | 177 | storage/framework/runtime/e2e/native-trail/*.log |
| 173 | 178 | storage/framework/runtime/e2e/native-trail/*.txt |
| @@ -17,7 +17,7 @@ name: Native trail maps and sticky controls in system dark appearance | ||
| 17 | 17 | - scrollUntilVisible: |
| 18 | 18 | element: |
| 19 | 19 | id: trail-map-fullscreen |
| 20 | direction: DOWN | |
| 20 | direction: UP | |
| 21 | 21 | - tapOn: |
| 22 | 22 | id: trail-map-fullscreen |
| 23 | 23 | - extendedWaitUntil: |
| @@ -14,15 +14,17 @@ name: Copy the trail link and paste it into a real native field | ||
| 14 | 14 | timeout: 15000 |
| 15 | 15 | - takeScreenshot: android-native-share-sheet |
| 16 | 16 | - tapOn: '^Copy$' |
| 17 | - openLink: wildloop://login | |
| 17 | - openLink: wildloop://search | |
| 18 | 18 | - extendedWaitUntil: |
| 19 | 19 | visible: |
| 20 | id: login-email | |
| 20 | id: search-input | |
| 21 | 21 | timeout: 15000 |
| 22 | - tapOn: | |
| 23 | id: search-input | |
| 22 | 24 | - longPressOn: |
| 23 | id: login-email | |
| 25 | id: search-input | |
| 24 | 26 | - tapOn: '^Paste$' |
| 25 | 27 | - assertVisible: |
| 26 | id: login-email | |
| 28 | id: search-input | |
| 27 | 29 | text: 'https://wildloop.org/trail/${TRAIL_ID}' |
| 28 | 30 | - takeScreenshot: android-native-share-link-pasted |
| @@ -314,11 +314,11 @@ onMount(() => onNativeAccountChange(craft, () => { | ||
| 314 | 314 | <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)"> |
| 315 | 315 | <MapPolyline :for="(part, index) in parts()" :key="'route-outline-' + index" :id="'route-outline-' + index" :geometry="part" :strokeColor="trailRouteStyle.casing" :strokeWidth="9" /> |
| 316 | 316 | <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" /> | |
| 318 | 318 | <MapMarker id="trailhead" title="Trailhead" pinColor="#24724b" :geometry="[trailhead()?.lat, trailhead()?.lng]" /> |
| 319 | 319 | <MapMarker :show="inspected()" id="elevation-point" title="Elevation position" pinColor="#176bad" :geometry="[inspected()?.lat, inspected()?.lng]" /> |
| 320 | 320 | </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" /> | |
| 322 | 322 | <View class="absolute bottom-5 left-3 right-3 rounded-[20px] bg-white p-3 shadow-lg dark:bg-[#1b2c22]" testID="trail-floating-profile"> |
| 323 | 323 | <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> |
| 324 | 324 | <View :show="profileOpen"> |
| @@ -346,7 +346,6 @@ onMount(() => onNativeAccountChange(craft, () => { | ||
| 346 | 346 | <Button testID="trail-map-3d" :class="ui.secondary" @click="pitch.set(pitch() > 0 ? 0 : 45)" :text="pitch() > 0 ? '2D' : '3D'" /> |
| 347 | 347 | <Button :class="ui.secondary" @click="heading.set(0)">North</Button> |
| 348 | 348 | <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> | |
| 350 | 349 | </View> |
| 351 | 350 | <Button testID="trail-map-export" :disabled="!routeAvailable()" :class="ui.secondary" class="mt-2" @click="exportRoute()">Export GPX</Button> |
| 352 | 351 | <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))" /> |
| @@ -102,7 +102,7 @@ onMount(() => { void load(); return () => { alive = false } }) | ||
| 102 | 102 | @region-change-complete="camera($event)"> |
| 103 | 103 | <MapPolyline :for="(part,index) in parts()" :key="'route-outline-' + index" :id="'route-outline-' + index" :geometry="part" :strokeColor="trailRouteStyle.casing" :strokeWidth="9" /> |
| 104 | 104 | <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" /> | |
| 106 | 106 | <MapMarker id="trailhead" title="Trailhead" pinColor="#24724b" :geometry="[trailhead()?.lat, trailhead()?.lng]" /> |
| 107 | 107 | <MapMarker :show="inspected()" id="elevation-point" title="Elevation position" pinColor="#176bad" :geometry="[inspected()?.lat, inspected()?.lng]" /> |
| 108 | 108 | </Map> |
| @@ -4,7 +4,7 @@ import process from 'node:process' | ||
| 4 | 4 | import { generateXcodeProject, resolveXcodegen } from '@stacksjs/actions/build/ios-toolchain' |
| 5 | 5 | import { prepareIosSimulatorDriver, serializeSimulatorSteps } from '@stacksjs/mobile/testing' |
| 6 | 6 | import mobile from '../config/mobile' |
| 7 | import { nativeTrailJourneys } from '../tests/native/trail-journey' | |
| 7 | import { nativeTrailJourneys, selectNativeTrailJourneys } from '../tests/native/trail-journey' | |
| 8 | 8 | import { QA_PORTS } from '../tests/browser/qa-ports' |
| 9 | 9 | import { QA_SOCIAL_ACCOUNT } from '../tests/browser/qa-social-account' |
| 10 | 10 | import { decodeRouteParts, primaryRoutePart } from '../resources/functions/trail-geometry' |
| @@ -66,7 +66,7 @@ export async function runNativeTrailE2e(): Promise<void> { | ||
| 66 | 66 | } |
| 67 | 67 | if((await readReviews()).length)throw new Error('The isolated native trail must start without reviews.') |
| 68 | 68 | 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) | |
| 70 | 70 | const controlPort=QA_PORTS.app+15 |
| 71 | 71 | const driver=prepareIosSimulatorDriver({...mobile.ios,simulator:{output:join(root,'driver'),projectName:'WildloopTrailDriver',bundleIdPrefix:'org.wildloop.trailqa',controlUrl:`http://127.0.0.1:${controlPort}`}}) |
| 72 | 72 | 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> { | ||
| 180 | 180 | } |
| 181 | 181 | } |
| 182 | 182 | 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(', ')}.`) | |
| 184 | 184 | } |
| 185 | 185 | finally { |
| 186 | 186 | if(nativeRun && nativeRun.exitCode===null)nativeRun.kill() |
| @@ -1,5 +1,23 @@ | ||
| 1 | 1 | import type { SimulatorStep } from '@stacksjs/mobile/testing' |
| 2 | 2 | |
| 3 | export 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. */ | |
| 11 | export 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 | ||
| 3 | 21 | /** Real native UI interactions against the isolated QA database. */ |
| 4 | 22 | export 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}[] { |
| 5 | 23 | const arrival:SimulatorStep[] = [ |
| @@ -38,6 +56,7 @@ const maps:SimulatorStep[] = [ | ||
| 38 | 56 | {command:'expectexact',text:'Save',seconds:30}, |
| 39 | 57 | {command:'scrollto',text:'Toggle elevation profile'}, |
| 40 | 58 | {command:'shot',name:'native-floating-profile-inline'}, |
| 59 | {command:'scrollto',text:'Explore elevation and position along the route'}, | |
| 41 | 60 | {command:'sliderid',id:'trail-elevation-slider',position:1}, |
| 42 | 61 | {command:'expectexact',text:input.inlineProfileEndText,seconds:10}, |
| 43 | 62 | {command:'shot',name:'native-inline-elevation-endpoint'}, |
| @@ -144,7 +163,10 @@ const reviews:SimulatorStep[] = [ | ||
| 144 | 163 | {command:'tapid',text:'trail-write-review'}, |
| 145 | 164 | {command:'expectid',text:'trail-review-composer',seconds:10}, |
| 146 | 165 | {command:'tapid',text:'trail-review-star-4'}, |
| 166 | {command:'tapid',text:'trail-review-content'}, | |
| 147 | 167 | {command:'longpressid',text:'trail-review-content'}, |
| 168 | {command:'shot',name:'native-review-edit-menu'}, | |
| 169 | {command:'tree',name:'native-review-edit-menu'}, | |
| 148 | 170 | {command:'tap',text:'Select All'}, |
| 149 | 171 | {command:'type',text:input.edit}, |
| 150 | 172 | {command:'valueid',id:'trail-review-content',text:input.edit}, |
| @@ -182,12 +204,13 @@ const sharing:SimulatorStep[] = [ | ||
| 182 | 204 | {command:'shot',name:'native-share-sheet'}, |
| 183 | 205 | {command:'tree',name:'native-share-sheet'}, |
| 184 | 206 | {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'}, | |
| 189 | 212 | {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}`}, | |
| 191 | 214 | {command:'shot',name:'native-shared-link-pasted'}, |
| 192 | 215 | {command:'tree',name:'native-shared-link-pasted'}, |
| 193 | 216 | {command:'openurl',url:`wildloop://trail/${input.trailId}`}, |
| @@ -44,7 +44,7 @@ describe('native trail details', () => { | ||
| 44 | 44 | await screen.event('trail-profile-toggle','onPress') |
| 45 | 45 | expect(nativeNodeProps(screen,'trail-profile-toggle').accessibilityValue).toBe('Elevation profile collapsed') |
| 46 | 46 | 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') | |
| 48 | 48 | expect(screen.sent.some(message=>message.type==='NAVIGATE_OPEN' && message.payload.path==='/trail/7/map')).toBe(true) |
| 49 | 49 | }) |
| 50 | 50 | it('changes map layer and camera through native bindings', async () => { |