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.
| @@ -49,7 +49,7 @@ export function nodesByTestId(node: any, found = new Map<string, string>()): Map | ||
| 49 | 49 | return found |
| 50 | 50 | } |
| 51 | 51 | |
| 52 | export async function start(route: string, params: Record<string, unknown> = {}, capabilities = ['fetch', 'secureStorage', 'haptics', 'lifecycle', 'dialog']): Promise<NativeSession> { | |
| 52 | export async function start(route: string, params: Record<string, unknown> = {}, capabilities = ['fetch', 'secureStorage', 'haptics', 'lifecycle', 'dialog'], keychain?: Map<string, string>): Promise<NativeSession> { | |
| 53 | 53 | const sent: any[] = [] |
| 54 | 54 | let callback: (raw: string) => void = () => {} |
| 55 | 55 | /* |
| @@ -103,7 +103,18 @@ export async function start(route: string, params: Record<string, unknown> = {}, | ||
| 103 | 103 | platform: 'ios', |
| 104 | 104 | capabilityProtocolVersion: 1, |
| 105 | 105 | capabilities, |
| 106 | postMessage: (raw: string) => sent.push(JSON.parse(raw)), | |
| 106 | postMessage: (raw: string) => { | |
| 107 | const message = JSON.parse(raw) | |
| 108 | sent.push(message) | |
| 109 | // Keep real bridge requests observable while modelling repeated native | |
| 110 | // keychain reads and credential changes between async UI steps. | |
| 111 | if (keychain && message.type === 'API_REQUEST' && message.payload?.module === 'SecureStorage' && message.payload.method === 'get') { | |
| 112 | void Promise.resolve().then(() => callback(JSON.stringify({ | |
| 113 | type: 'API_RESPONSE', correlationId: message.id, | |
| 114 | payload: { version: 1, requestId: message.id, data: keychain.get(message.payload.args[0]) ?? null }, | |
| 115 | }))) | |
| 116 | } | |
| 117 | }, | |
| 107 | 118 | onMessage: (receiver: typeof callback) => { callback = receiver }, |
| 108 | 119 | }, |
| 109 | 120 | } |
| @@ -3,9 +3,8 @@ import { answer, compileNativeBundle, nativeNodeStyle, nativeNodeProps, requests | ||
| 3 | 3 | |
| 4 | 4 | const checkpoint={userId:7,id:'run-7',startedAt:100000,type:'Hike',visibility:'private',gameMode:'free',targetTerritoryId:null,pauses:[],samples:[],pending:{upload_id:'run:run-7',completed_at:'2026-10-17T12:00:00Z'}} |
| 5 | 5 | function ok(body:unknown,status=200){return {status,headers:{'content-type':'application/json'},body:JSON.stringify(body)}} |
| 6 | async function recover(userId=7) { | |
| 7 | const screen=await start('Record',{},['fetch','secureStorage','storage','geolocation','lifecycle','liveActivity']) | |
| 8 | for(const request of requests(screen).filter(r=>r.payload.module==='SecureStorage')) answer(screen,request,request.payload.args[0]==='auth_token' ? 'record-token' : JSON.stringify({token:'record-token',user:{id:userId}})) | |
| 6 | async function recover(userId=7, keychain=new Map([['auth_token','record-token'],['wildloop-account',JSON.stringify({token:'record-token',user:{id:userId}})]])) { | |
| 7 | const screen=await start('Record',{},['fetch','secureStorage','storage','geolocation','lifecycle','liveActivity'],keychain) | |
| 9 | 8 | const stored=await waitForRequest(screen,0,r=>r.payload.module==='Storage' && r.payload.method==='get') |
| 10 | 9 | answer(screen,stored,checkpoint) |
| 11 | 10 | const host=await waitForRequest(screen,0,r=>r.payload.method==='getRecordingState') |
| @@ -34,7 +33,6 @@ describe('native Record checkpoint recovery',()=>{ | ||
| 34 | 33 | const screen=await recover() |
| 35 | 34 | await waitForText(screen,'Your finished recording is saved on this device. Retry the upload when connected.') |
| 36 | 35 | await screen.event('record-retry','onPress') |
| 37 | answer(screen,await waitForRequest(screen,2,r=>r.payload.module==='SecureStorage' && r.payload.args[0]==='auth_token'),'record-token') | |
| 38 | 36 | const me=await waitForRequest(screen,0,r=>r.payload.module==='Network') |
| 39 | 37 | answer(screen,me,ok({user:{id:7}})) |
| 40 | 38 | const post=await waitForRequest(screen,requests(screen).indexOf(me)+1,r=>r.payload.module==='Network') |
| @@ -46,10 +44,23 @@ describe('native Record checkpoint recovery',()=>{ | ||
| 46 | 44 | expect(nativeNodeProps(screen,'record-retry').disabled).toBe(false) |
| 47 | 45 | const before=requests(screen).length |
| 48 | 46 | await screen.event('record-retry','onPress') |
| 49 | answer(screen,await waitForRequest(screen,before,r=>r.payload.module==='SecureStorage' && r.payload.args[0]==='auth_token'),'record-token') | |
| 50 | 47 | const againMe=await waitForRequest(screen,before,r=>r.payload.module==='Network') |
| 51 | 48 | answer(screen,againMe,ok({user:{id:7}})) |
| 52 | 49 | const again=await waitForRequest(screen,requests(screen).indexOf(againMe)+1,r=>r.payload.module==='Network') |
| 53 | 50 | expect(again.payload.args[0].body).toBe(post.payload.args[0].body) |
| 54 | 51 | }) |
| 52 | it('retains the pending track when the bearer changes while its owner is checked',async()=>{ | |
| 53 | const keychain=new Map([['auth_token','record-token'],['wildloop-account',JSON.stringify({token:'record-token',user:{id:7}})]]) | |
| 54 | const screen=await recover(7,keychain) | |
| 55 | await waitForText(screen,'Your finished recording is saved on this device. Retry the upload when connected.') | |
| 56 | const before=requests(screen).length | |
| 57 | await screen.event('record-retry','onPress') | |
| 58 | const me=await waitForRequest(screen,before,r=>r.payload.module==='Network') | |
| 59 | keychain.set('auth_token','replacement-token') | |
| 60 | answer(screen,me,ok({user:{id:7}})) | |
| 61 | await Bun.sleep(0) | |
| 62 | expect(requests(screen).slice(before).filter(r=>r.payload.module==='Network' && r.payload.args[0].method==='POST')).toHaveLength(0) | |
| 63 | expect(requests(screen).some(r=>r.payload.module==='Storage' && r.payload.method==='remove')).toBe(false) | |
| 64 | await waitForText(screen,'Your account changed. Please try again.') | |
| 65 | }) | |
| 55 | 66 | }) |
| @@ -5,9 +5,8 @@ const LINE = [[37.7, -119.5], [37.71, -119.51], [37.72, -119.5]] | ||
| 5 | 5 | const TRAIL = { id: 7, name: 'Native Ridge Loop', latitude: 37.75, longitude: -119.55, distance: 3, elevation: 600, location: 'Yosemite, CA', surface: 'dirt;rock', geometry: JSON.stringify(LINE) } |
| 6 | 6 | const OWN = { id: 5, userId: 12, userName: 'Tester', rating: 4, content: 'A quiet loop with great views.', visitDate: '2026-10-15', conditions: 'dry', photos: '["https://wildloop.org/photo.jpg"]' } |
| 7 | 7 | function ok(body: unknown) { return { status: 200, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) } } |
| 8 | async function open(signedIn = true, geometry: string | null = TRAIL.geometry, reviewRows: any[] = signedIn ? [OWN] : [], personalReview: any = reviewRows.find(row=>row.userId===12) ?? null, personalStatus = 200) { | |
| 9 | const screen = await start('TrailDetail', { id: '7' }, ['fetch', 'secureStorage', 'haptics', 'lifecycle', 'dialog', 'share', 'linking', 'files']) | |
| 10 | answer(screen, await waitForRequest(screen, 0, r => r.payload?.module === 'SecureStorage'), signedIn ? 'test_token' : null) | |
| 8 | async function open(signedIn = true, geometry: string | null = TRAIL.geometry, reviewRows: any[] = signedIn ? [OWN] : [], personalReview: any = reviewRows.find(row=>row.userId===12) ?? null, personalStatus = 200, keychain = new Map(signedIn ? [['auth_token', 'test_token']] : [])) { | |
| 9 | const screen = await start('TrailDetail', { id: '7' }, ['fetch', 'secureStorage', 'haptics', 'lifecycle', 'dialog', 'share', 'linking', 'files'], keychain) | |
| 11 | 10 | answer(screen, await waitForRequest(screen, 0, r => r.payload?.module === 'Network'), ok({ success: true, trail: { ...TRAIL, geometry } })) |
| 12 | 11 | const review = await waitForRequest(screen, 0, r => r.payload?.args?.[0]?.url?.endsWith('/reviews')) |
| 13 | 12 | answer(screen, review, ok({ reviews: reviewRows })) |
| @@ -183,6 +182,38 @@ describe('native trail details', () => { | ||
| 183 | 182 | answer(screen,await waitForRequest(screen,before,r=>r.payload?.args?.[0]?.url?.endsWith('/viewer')),ok({userId:12,saved:false,review:null})) |
| 184 | 183 | await waitForText(screen, 'Your review has been deleted.') |
| 185 | 184 | }) |
| 185 | it('does not delete another account’s review after an old confirmation is accepted', async () => { | |
| 186 | const keychain = new Map([['auth_token', 'test_token']]) | |
| 187 | const screen = await open(true, TRAIL.geometry, [OWN], OWN, 200, keychain) | |
| 188 | const before = requests(screen).length | |
| 189 | await screen.event('trail-delete-review', 'onPress') | |
| 190 | const confirm = await waitForRequest(screen, before, r => r.payload.module === 'Dialog') | |
| 191 | keychain.set('auth_token', 'replacement-token') | |
| 192 | await screen.scope.craft.snapshots.set('wildloop-session', {user:{id:13},version:2}) | |
| 193 | answer(screen, confirm, true) | |
| 194 | await Bun.sleep(0) | |
| 195 | expect(requests(screen).slice(before).filter(r => r.payload.module === 'Network' && r.payload.args[0].method === 'DELETE')).toHaveLength(0) | |
| 196 | }) | |
| 197 | it('rejects a stale review draft before a changed bearer’s snapshot arrives', async () => { | |
| 198 | const keychain = new Map([['auth_token', 'test_token']]) | |
| 199 | const screen = await open(true, TRAIL.geometry, [OWN], OWN, 200, keychain) | |
| 200 | await screen.event('trail-write-review', 'onPress') | |
| 201 | await screen.event('trail-review-content', 'onChangeText', {text:'This draft belongs to the original account.'}) | |
| 202 | keychain.set('auth_token', 'replacement-token') | |
| 203 | const before = requests(screen).length | |
| 204 | await screen.event('trail-review-submit', 'onPress') | |
| 205 | expect(requests(screen).slice(before).filter(r => r.payload.module === 'Network' && r.payload.args[0].method === 'POST')).toHaveLength(0) | |
| 206 | await waitForText(screen, 'Your account changed. Please try again.') | |
| 207 | }) | |
| 208 | it('clears a private composer draft when the native account changes', async () => { | |
| 209 | const screen = await open() | |
| 210 | await screen.event('trail-write-review', 'onPress') | |
| 211 | await screen.event('trail-review-content', 'onChangeText', {text:'A private draft for the original account.'}) | |
| 212 | await screen.scope.craft.snapshots.set('wildloop-session', {user:{id:13},version:2}) | |
| 213 | await Bun.sleep(0) | |
| 214 | expect(nativeNodeProps(screen, 'trail-review-composer').visible).toBe(false) | |
| 215 | expect(nativeNodeProps(screen, 'trail-review-content').value).toBe('') | |
| 216 | }) | |
| 186 | 217 | it('shows the trailhead for trails without recorded geometry', async () => { |
| 187 | 218 | const screen = await open(false, null) |
| 188 | 219 | expect(shownText(screen)).toContain('No recorded route line. The map shows the trailhead.') |