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.

tests/fixtures/nativeScreen.tsmodified+13-2
Changes to tests/fixtures/nativeScreen.ts
@@ -49,7 +49,7 @@ export function nodesByTestId(node: any, found = new Map<string, string>()): Map
4949 return found
5050}
5151
52export async function start(route: string, params: Record<string, unknown> = {}, capabilities = ['fetch', 'secureStorage', 'haptics', 'lifecycle', 'dialog']): Promise<NativeSession> {
52export async function start(route: string, params: Record<string, unknown> = {}, capabilities = ['fetch', 'secureStorage', 'haptics', 'lifecycle', 'dialog'], keychain?: Map<string, string>): Promise<NativeSession> {
5353 const sent: any[] = []
5454 let callback: (raw: string) => void = () => {}
5555 /*
@@ -103,7 +103,18 @@ export async function start(route: string, params: Record<string, unknown> = {},
103103 platform: 'ios',
104104 capabilityProtocolVersion: 1,
105105 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 },
107118 onMessage: (receiver: typeof callback) => { callback = receiver },
108119 },
109120 }
tests/unit/native-record-screen.test.tsmodified+16-5
Changes to tests/unit/native-record-screen.test.ts
@@ -3,9 +3,8 @@ import { answer, compileNativeBundle, nativeNodeStyle, nativeNodeProps, requests
33
44const 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'}}
55function ok(body:unknown,status=200){return {status,headers:{'content-type':'application/json'},body:JSON.stringify(body)}}
6async 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}}))
6async 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)
98 const stored=await waitForRequest(screen,0,r=>r.payload.module==='Storage' && r.payload.method==='get')
109 answer(screen,stored,checkpoint)
1110 const host=await waitForRequest(screen,0,r=>r.payload.method==='getRecordingState')
@@ -34,7 +33,6 @@ describe('native Record checkpoint recovery',()=>{
3433 const screen=await recover()
3534 await waitForText(screen,'Your finished recording is saved on this device. Retry the upload when connected.')
3635 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')
3836 const me=await waitForRequest(screen,0,r=>r.payload.module==='Network')
3937 answer(screen,me,ok({user:{id:7}}))
4038 const post=await waitForRequest(screen,requests(screen).indexOf(me)+1,r=>r.payload.module==='Network')
@@ -46,10 +44,23 @@ describe('native Record checkpoint recovery',()=>{
4644 expect(nativeNodeProps(screen,'record-retry').disabled).toBe(false)
4745 const before=requests(screen).length
4846 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')
5047 const againMe=await waitForRequest(screen,before,r=>r.payload.module==='Network')
5148 answer(screen,againMe,ok({user:{id:7}}))
5249 const again=await waitForRequest(screen,requests(screen).indexOf(againMe)+1,r=>r.payload.module==='Network')
5350 expect(again.payload.args[0].body).toBe(post.payload.args[0].body)
5451 })
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 })
5566})
tests/unit/native-trail-detail-screen.test.tsmodified+34-3
Changes to tests/unit/native-trail-detail-screen.test.ts
@@ -5,9 +5,8 @@ const LINE = [[37.7, -119.5], [37.71, -119.51], [37.72, -119.5]]
55const 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) }
66const 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"]' }
77function ok(body: unknown) { return { status: 200, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) } }
8async 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)
8async 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)
1110 answer(screen, await waitForRequest(screen, 0, r => r.payload?.module === 'Network'), ok({ success: true, trail: { ...TRAIL, geometry } }))
1211 const review = await waitForRequest(screen, 0, r => r.payload?.args?.[0]?.url?.endsWith('/reviews'))
1312 answer(screen, review, ok({ reviews: reviewRows }))
@@ -183,6 +182,38 @@ describe('native trail details', () => {
183182 answer(screen,await waitForRequest(screen,before,r=>r.payload?.args?.[0]?.url?.endsWith('/viewer')),ok({userId:12,saved:false,review:null}))
184183 await waitForText(screen, 'Your review has been deleted.')
185184 })
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 })
186217 it('shows the trailhead for trails without recorded geometry', async () => {
187218 const screen = await open(false, null)
188219 expect(shownText(screen)).toContain('No recorded route line. The map shows the trailhead.')