ReviewOS

also looking at this

craft-native/craft

fix(mobile): preserve native editing, navigation, and durable media requests

#369
Merged chris wants to merge codex/native-vertical-scroll-tab-swipes into main
36 files +864 -185

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-5
Changes to .github/workflows/mobile-e2e.yml
@@ -137,11 +137,11 @@ jobs:
137137 strategy:
138138 fail-fast: false
139139 matrix:
140 suite: ${{ fromJSON(inputs.scope == 'ios-render' && '["uikit"]' || inputs.scope == 'ios-navigation' && inputs.selectors != '' && '["navigation"]' || inputs.scope == 'ios-navigation' && '["navigation-unit","navigation-ui"]' || '["uikit","navigation-unit","navigation-ui"]') }}
140 suite: ${{ fromJSON(inputs.scope == 'ios-render' && '["uikit"]' || inputs.scope == 'ios-navigation' && inputs.selectors != '' && '["navigation"]' || inputs.scope == 'ios-navigation' && '["navigation-unit","navigation-ui-core","navigation-ui-controls"]' || '["uikit","navigation-unit","navigation-ui-core","navigation-ui-controls"]') }}
141141 runs-on: macos-15
142 # UIKit reconciliation and both navigation XCTest targets each get a fresh
143 # runner and result bundle. Combining unit and UI navigation exceeded the
144 # existing job budget; all targets remain required with the same backstop.
142 # Every navigation UI case belongs to exactly one required group. The large
143 # controls journey gets its own runner; core navigation, unit reconciliation
144 # and UIKit retain separate results and the same bounded backstop.
145145 timeout-minutes: 45
146146 env:
147147 CRAFT_EVIDENCE_LABEL: ios-native
@@ -196,7 +196,8 @@ jobs:
196196 if: ${{ !cancelled() && startsWith(matrix.suite, 'navigation') }}
197197 env:
198198 STX_CLI: ${{ github.workspace }}/.stx-compiler/packages/stx/bin/cli.ts
199 CRAFT_ONLY_TESTING: ${{ matrix.suite == 'navigation-unit' && 'NativeNavigationUnitTests' || matrix.suite == 'navigation-ui' && 'NativeNavigationUITests' || inputs.selectors }}
199 CRAFT_ONLY_TESTING: ${{ matrix.suite == 'navigation-unit' && 'NativeNavigationUnitTests' || inputs.selectors }}
200 CRAFT_NATIVE_NAVIGATION_GROUP: ${{ matrix.suite == 'navigation-ui-core' && 'core' || matrix.suite == 'navigation-ui-controls' && 'controls' || '' }}
200201 CRAFT_NATIVE_NAVIGATION_RESULT: ${{ github.workspace }}/artifacts/mobile-e2e/ios-native/navigation.xcresult
201202 run: |
202203 mkdir -p artifacts/mobile-e2e/ios-native
packages/ios/scripts/native-navigation-groups.tsadded+13-0
Changes to packages/ios/scripts/native-navigation-groups.ts
@@ -0,0 +1,13 @@
1const controlsCase = 'testPushNativeBackSwipeReplaceAndRetainedHomeState'
2
3/** Partition every UI case; new cases automatically join the core suite. */
4export function nativeNavigationGroup(source: string, group: string): string[] {
5 if (group !== 'core' && group !== 'controls')
6 throw new Error(`Unknown native navigation UI group: ${group}`)
7 const cases = [...source.matchAll(/^\s*func\s+(test\w+)\s*\(/gm)].map(match => match[1]!)
8 if (cases.length < 2 || new Set(cases).size !== cases.length || !cases.includes(controlsCase))
9 throw new Error('Native navigation UI coverage must contain unique cases and the controls journey')
10 const selected = cases.filter(name => group === 'controls' ? name === controlsCase : name !== controlsCase)
11 if (!selected.length) throw new Error(`Empty native navigation UI group: ${group}`)
12 return selected.map(name => `NativeNavigationUITests/NativeNavigationUITests/${name}`)
13}
packages/ios/scripts/test-native-navigation.tsmodified+13-8
Changes to packages/ios/scripts/test-native-navigation.ts
@@ -1,7 +1,8 @@
1import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
1import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
22import { dirname, join, resolve } from 'node:path'
33import { bootSimulator, build, init, pickSimulator } from '../src/index'
44import { addSchemeTestTargets, insertProjectTargetsBeforeSchemes } from './insert-project-targets'
5import { nativeNavigationGroup } from './native-navigation-groups'
56import { createTempDir, keepTempDir, removeTempDir } from '../../typescript/src/temp-dir'
67
78const fixture = join(import.meta.dir, '..', 'fixtures', 'native-navigation')
@@ -23,10 +24,6 @@ function runXcodeTests(args: string[], cwd: string, resultBundle: string): void
2324 console.log(args.join(' '))
2425 const result = Bun.spawnSync(args, { cwd, stdout: 'inherit', stderr: 'inherit' })
2526 const evidence = process.env.CRAFT_NATIVE_NAVIGATION_RESULT
26 if (evidence && existsSync(resultBundle)) {
27 mkdirSync(dirname(resolve(evidence)), { recursive: true })
28 cpSync(resultBundle, resolve(evidence), { recursive: true, force: false, errorOnExist: true })
29 }
3027 if (result.exitCode === 0) {
3128 const summary = Bun.spawnSync(['xcrun', 'xcresulttool', 'get', 'test-results', 'summary', '--path', resultBundle], { stdout: 'pipe', stderr: 'pipe' })
3229 if (summary.exitCode !== 0) throw new Error(`Could not read XCTest results: ${summary.stderr.toString()}`)
@@ -137,10 +134,17 @@ try {
137134 const device = requested ? { udid: requested, name: requested, state: 'Booted', runtime: 'iOS' } : await pickSimulator()
138135 if (!device) throw new Error('No iOS simulator is available for native navigation tests')
139136 await bootSimulator(device)
140 const resultBundle = join(workspace, 'NativeNavigation.xcresult')
137 // Write directly into retained evidence: a job timeout can terminate Bun
138 // before a post-test copy or finally block gets to run.
139 const resultBundle = process.env.CRAFT_NATIVE_NAVIGATION_RESULT
140 ? resolve(process.env.CRAFT_NATIVE_NAVIGATION_RESULT)
141 : join(workspace, 'NativeNavigation.xcresult')
142 mkdirSync(dirname(resultBundle), { recursive: true })
141143 const selectedTest = process.env.CRAFT_NATIVE_NAVIGATION_TEST
142144 const selectedTarget = process.env.CRAFT_ONLY_TESTING
143 const selection = selectedTarget ? selectedTarget.split(',').map(target => target.trim()).filter(Boolean).map(target => `-only-testing:${target}`) : selectedTest
145 const group = process.env.CRAFT_NATIVE_NAVIGATION_GROUP
146 if (group && (selectedTarget || selectedTest)) throw new Error('A required native UI group cannot be combined with focused selectors')
147 const selection = group ? nativeNavigationGroup(readFileSync(join(fixture, 'NativeNavigationUITests.swift'), 'utf8'), group).map(target => `-only-testing:${target}`) : selectedTarget ? selectedTarget.split(',').map(target => target.trim()).filter(Boolean).map(target => `-only-testing:${target}`) : selectedTest
144148 ? [`-only-testing:NativeNavigationUITests/NativeNavigationUITests/${selectedTest}`]
145149 : []
146150 const xcodeArgs = [
@@ -153,7 +157,8 @@ try {
153157 // launching a hosted XCTest bundle, even after compilation has finished.
154158 run([...xcodeArgs, 'build-for-testing'], output)
155159 runXcodeTests([
156 ...xcodeArgs,
160 // Retain live XCTest activity as well as the structured result bundle.
161 ...xcodeArgs.filter(argument => argument !== '-quiet'),
157162 '-resultBundlePath', resultBundle,
158163 ...selection,
159164 'test-without-building',
packages/ios/src/navigation-groups.test.tsadded+29-0
Changes to packages/ios/src/navigation-groups.test.ts
@@ -0,0 +1,29 @@
1import { expect, test } from 'bun:test'
2import { readFileSync } from 'node:fs'
3import { join } from 'node:path'
4import { nativeNavigationGroup } from '../scripts/native-navigation-groups'
5
6const source = readFileSync(join(import.meta.dir, '../fixtures/native-navigation/NativeNavigationUITests.swift'), 'utf8')
7
8test('required UI groups cover every actual navigation test exactly once', () => {
9 const all = [...source.matchAll(/^\s*func\s+(test\w+)\s*\(/gm)].map(match => `NativeNavigationUITests/NativeNavigationUITests/${match[1]}`)
10 const core = nativeNavigationGroup(source, 'core')
11 const controls = nativeNavigationGroup(source, 'controls')
12 expect(all.length).toBeGreaterThanOrEqual(19)
13 expect(core.length).toBe(all.length - 1)
14 expect(controls).toEqual(['NativeNavigationUITests/NativeNavigationUITests/testPushNativeBackSwipeReplaceAndRetainedHomeState'])
15 expect([...core, ...controls].sort()).toEqual(all.sort())
16 expect(new Set([...core, ...controls]).size).toBe(all.length)
17})
18
19test('new UI cases join required coverage without updating a selector list', () => {
20 expect(nativeNavigationGroup(`${source}\nfunc testFutureNativeControl() {}`, 'core'))
21 .toContain('NativeNavigationUITests/NativeNavigationUITests/testFutureNativeControl')
22})
23
24test('invalid, empty or ambiguous native UI coverage fails closed', () => {
25 expect(() => nativeNavigationGroup(source, 'optional')).toThrow('Unknown')
26 expect(() => nativeNavigationGroup('', 'core')).toThrow('coverage')
27 expect(() => nativeNavigationGroup(source.replace('func testPushNativeBackSwipeReplaceAndRetainedHomeState', 'func noControlsJourney'), 'controls')).toThrow('coverage')
28 expect(() => nativeNavigationGroup(`${source}\nfunc testTabSwipeSwitchesNativeRootsAndReturns() {}`, 'core')).toThrow('coverage')
29})
scripts/release-workflow.test.tsmodified+8-4
Changes to scripts/release-workflow.test.ts
@@ -129,15 +129,19 @@ test('navigation shards retain both complete XCTest targets and focused selectio
129129 startsWith: (text: string, prefix: string) => text.startsWith(prefix), cancelled: () => false,
130130 }, { timeout: 100 })
131131 for (const scope of ['', 'all'])
132 expect(evaluate(expression, scope)).toEqual(['uikit', 'navigation-unit', 'navigation-ui'])
133 expect(evaluate(expression, 'ios-navigation')).toEqual(['navigation-unit', 'navigation-ui'])
132 expect(evaluate(expression, scope)).toEqual(['uikit', 'navigation-unit', 'navigation-ui-core', 'navigation-ui-controls'])
133 expect(evaluate(expression, 'ios-navigation')).toEqual(['navigation-unit', 'navigation-ui-core', 'navigation-ui-controls'])
134134 expect(evaluate(expression, 'ios-render')).toEqual(['uikit'])
135135 const focused = 'NativeNavigationUITests/NativeNavigationUITests/testMultilineInputKeepsKeyboardFocusWhenTapped'
136136 expect(evaluate(expression, 'ios-navigation', focused)).toEqual(['navigation'])
137137 expect(evaluate(navigation!.env!.CRAFT_ONLY_TESTING, 'all', '', 'navigation-unit')).toBe('NativeNavigationUnitTests')
138 expect(evaluate(navigation!.env!.CRAFT_ONLY_TESTING, 'all', '', 'navigation-ui')).toBe('NativeNavigationUITests')
138 for (const [suite, group] of [['navigation-ui-core', 'core'], ['navigation-ui-controls', 'controls']]) {
139 expect(evaluate(navigation!.env!.CRAFT_ONLY_TESTING, 'all', '', suite)).toBe('')
140 expect(evaluate(navigation!.env!.CRAFT_NATIVE_NAVIGATION_GROUP, 'all', '', suite)).toBe(group)
141 }
139142 expect(evaluate(navigation!.env!.CRAFT_ONLY_TESTING, 'ios-navigation', focused, 'navigation')).toBe(focused)
140 for (const suite of ['navigation-unit', 'navigation-ui', 'navigation'])
143 expect(evaluate(navigation!.env!.CRAFT_NATIVE_NAVIGATION_GROUP, 'ios-navigation', focused, 'navigation')).toBe('')
144 for (const suite of ['navigation-unit', 'navigation-ui-core', 'navigation-ui-controls', 'navigation'])
141145 expect(evaluate(navigation!.if!, 'all', '', suite)).toBe(true)
142146 expect(evaluate(navigation!.if!, 'all', '', 'uikit')).toBe(false)
143147 expect(native.strategy?.['fail-fast']).toBe(false)