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
.github/workflows/ci.ymlmodified+2-1
Changes to .github/workflows/ci.yml
@@ -1,6 +1,7 @@
11name: CI
22
33on:
4 workflow_call: {}
45 workflow_dispatch:
56 inputs:
67 software_cold_provisioning_diagnostic:
@@ -15,7 +16,7 @@ on:
1516 - main
1617
1718concurrency:
18 group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
19 group: source-ci-${{ github.workflow }}-${{ github.head_ref || github.ref }}
1920 # Finish Main acceptance; newer commits replace the single pending run.
2021 cancel-in-progress: ${{ github.event_name == 'pull_request' }}
2122
.github/workflows/mobile-e2e.ymlmodified+25-13
Changes to .github/workflows/mobile-e2e.yml
@@ -24,6 +24,7 @@ name: Mobile E2E Testing
2424# GitHub Actions does not resolve YAML anchors, so the two path filters are
2525# written out rather than shared. Keep them identical.
2626on:
27 workflow_call: {}
2728 push:
2829 branches: [main]
2930 paths:
@@ -136,11 +137,11 @@ jobs:
136137 strategy:
137138 fail-fast: false
138139 matrix:
139 suite: ${{ fromJSON(inputs.scope == 'ios-render' && '["uikit"]' || inputs.scope == 'ios-navigation' && '["navigation"]' || '["uikit","navigation"]') }}
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"]') }}
140141 runs-on: macos-15
141 # Compile and run each native fixture on its own fresh runner. Combining
142 # both consumed the job budget before navigation finished on slower hosts.
143 # Each suite keeps the same bounded backstop and strict XCTest assertions.
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.
144145 timeout-minutes: 45
145146 env:
146147 CRAFT_EVIDENCE_LABEL: ios-native
@@ -167,15 +168,15 @@ jobs:
167168 # @stacksjs/stx compiler (`stx native compile`) so this job cannot silently change
168169 # underneath us. Both iOS jobs and Android use the same compiler revision.
169170 - name: Checkout stx compiler
170 if: ${{ matrix.suite == 'navigation' }}
171 if: ${{ startsWith(matrix.suite, 'navigation') }}
171172 uses: actions/checkout@v6
172173 with:
173174 repository: stacksjs/stx
174 ref: 617826427dea89a203e7f6d254dffea211bad1ba
175 ref: 9d003f90714dc5c36daf262aaaa285a9671245e7
175176 path: .stx-compiler
176177
177178 - name: Install stx compiler dependencies
178 if: ${{ matrix.suite == 'navigation' }}
179 if: ${{ startsWith(matrix.suite, 'navigation') }}
179180 working-directory: .stx-compiler
180181 run: bun install --frozen-lockfile
181182
@@ -192,10 +193,11 @@ jobs:
192193 id: navigation
193194 # Reconciliation and navigation are independent suites. Keep the
194195 # navigation evidence when reconciliation fails; the job still fails.
195 if: ${{ !cancelled() && matrix.suite == 'navigation' }}
196 if: ${{ !cancelled() && startsWith(matrix.suite, 'navigation') }}
196197 env:
197198 STX_CLI: ${{ github.workspace }}/.stx-compiler/packages/stx/bin/cli.ts
198 CRAFT_ONLY_TESTING: ${{ 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' || '' }}
199201 CRAFT_NATIVE_NAVIGATION_RESULT: ${{ github.workspace }}/artifacts/mobile-e2e/ios-native/navigation.xcresult
200202 run: |
201203 mkdir -p artifacts/mobile-e2e/ios-native
@@ -272,6 +274,11 @@ jobs:
272274 with:
273275 version: '0.11.101'
274276
277 - name: Setup Bun for the frozen stx compiler lockfile
278 uses: oven-sh/setup-bun@v2
279 with:
280 bun-version: '1.4.3'
281
275282 - name: Install XcodeGen
276283 run: brew install xcodegen
277284
@@ -282,12 +289,12 @@ jobs:
282289 # The summary requires both jobs, including every original test suite.
283290 # The multi-screen fixture is compiled from real .stx sources. Pin the
284291 # @stacksjs/stx compiler (`stx native compile`) so this job cannot silently change
285 # underneath us. This release also carries a Bun-compatible lockfile.
292 # underneath us. Bun 1.4.3 installs this release's frozen lockfile.
286293 - name: Checkout stx compiler
287294 uses: actions/checkout@v6
288295 with:
289296 repository: stacksjs/stx
290 ref: 617826427dea89a203e7f6d254dffea211bad1ba
297 ref: 9d003f90714dc5c36daf262aaaa285a9671245e7
291298 path: .stx-compiler
292299
293300 - name: Install stx compiler dependencies
@@ -402,6 +409,11 @@ jobs:
402409 with:
403410 version: '0.11.101'
404411
412 - name: Setup Bun for the frozen stx compiler lockfile
413 uses: oven-sh/setup-bun@v2
414 with:
415 bun-version: '1.4.3'
416
405417 - name: Setup Java
406418 uses: actions/setup-java@v6
407419 with:
@@ -432,7 +444,7 @@ jobs:
432444 uses: actions/checkout@v6
433445 with:
434446 repository: stacksjs/stx
435 ref: 617826427dea89a203e7f6d254dffea211bad1ba
447 ref: 9d003f90714dc5c36daf262aaaa285a9671245e7
436448 path: .stx-compiler
437449
438450 - name: Install Android stx compiler dependencies
@@ -639,7 +651,7 @@ jobs:
639651 - uses: actions/checkout@v6
640652 with:
641653 repository: stacksjs/stx
642 ref: 617826427dea89a203e7f6d254dffea211bad1ba
654 ref: 9d003f90714dc5c36daf262aaaa285a9671245e7
643655 path: .stx-compiler
644656 - run: bun install --frozen-lockfile
645657 working-directory: .stx-compiler
.github/workflows/release.ymlmodified+18-2
Changes to .github/workflows/release.yml
@@ -29,6 +29,22 @@ jobs:
2929 - name: Review dependency advisories
3030 run: bun scripts/advisory-policy.ts
3131
32 # Relative reusable workflows check the exact tagged commit. Main may keep
33 # moving while these full suites run; publication remains tied to this tag.
34 source-acceptance:
35 name: accept exact release source
36 needs: validate-release
37 uses: ./.github/workflows/ci.yml
38 permissions:
39 contents: read
40
41 native-acceptance:
42 name: accept exact native runtimes
43 needs: validate-release
44 uses: ./.github/workflows/mobile-e2e.yml
45 permissions:
46 contents: read
47
3248 release-sbom:
3349 needs: validate-release
3450 uses: ./.github/workflows/sbom.yml
@@ -40,7 +56,7 @@ jobs:
4056 pantry:
4157 # The reusable SBOM workflow includes the vulnerability scan. Publishing
4258 # must wait for its existing Critical gate, not merely attach it afterward.
43 needs: [validate-release, release-sbom]
59 needs: [validate-release, release-sbom, source-acceptance, native-acceptance]
4460 name: pantry (${{ matrix.platform.name }})
4561 strategy:
4662 fail-fast: false
@@ -681,7 +697,7 @@ jobs:
681697
682698 publish-release:
683699 name: publish complete verified release
684 needs: [validate-release, verify-release, verify-macos-downloads, verify-desktop-downloads, attach-release-sbom, prepare-npm]
700 needs: [validate-release, source-acceptance, native-acceptance, verify-release, verify-macos-downloads, verify-desktop-downloads, attach-release-sbom, prepare-npm]
685701 runs-on: ubuntu-latest
686702 permissions:
687703 contents: write
docs/guides/native-stx.mdmodified+4-2
Changes to docs/guides/native-stx.md
@@ -73,13 +73,15 @@ renderer.
7373The maintained Mobile E2E workflow checks out one exact stx compiler commit for
7474both simulator hosts. Update the iOS and Android refs together; the workflow
7575contract test rejects compiler skew between them.
76The current pair is pinned to `617826427dea89a203e7f6d254dffea211bad1ba`,
76The current pair is pinned to `9d003f90714dc5c36daf262aaaa285a9671245e7` (STX 0.2.593),
7777which preserves authored reconciliation keys and explicit grid row tracks in
7878static and reactive native styles, and keeps template indentation out of native
7979leaf button labels. Touchable primitives preserve authored accessibility roles
8080and let the hosts infer link or button semantics from their current destination.
8181Prepared hidden screens start unfocused before their mount hooks run; their
82appearance guards run when the host actually shows them. The native fixtures qualify its compiled
82appearance guards run when the host actually shows them. Scripted component
83clones receive independent setup, and retained keyed rows keep their handlers
84and local state after reordering. The native fixtures qualify its compiled
8385properties and mutation metadata together with both native models. Application
8486releases consume the normally published compiler after qualification.
8587
packages/android/compile-fixtures/CraftNativeResumableDownloadTest.ktmodified+56-37
Changes to packages/android/compile-fixtures/CraftNativeResumableDownloadTest.kt
@@ -3,6 +3,10 @@ package
33import java.io.File
44import java.net.InetAddress
55import java.net.ServerSocket
6import java.util.Collections
7import java.util.concurrent.CompletableFuture
8import java.util.concurrent.TimeUnit
9import kotlin.concurrent.thread
610import java.nio.file.Files
711import org.junit.Assert.assertArrayEquals
812import org.junit.Assert.assertEquals
@@ -19,46 +23,54 @@ import org.junit.Test
1923class CraftNativeResumableDownloadTest {
2024 private val body = "0123456789abcdefghij".toByteArray()
2125
22 /**
23 * Serves `body`, honouring `Range: bytes=N-` when `ranges` is on; records
24 * each request's Range. A plain socket: Android's unit-test classpath has
25 * no com.sun.net.httpserver.
26 */
27 private fun serve(ranges: Boolean = true, status: Int = 200, check: (String, MutableList<String?>) -> Unit) {
28 val seen = java.util.Collections.synchronizedList(mutableListOf<String?>())
29 val server = ServerSocket(0, 50, InetAddress.getByName("127.0.0.1"))
30 val thread = Thread {
31 while (!server.isClosed) {
32 val socket = runCatching { server.accept() }.getOrNull() ?: break
33 socket.use { client ->
34 val reader = client.getInputStream().bufferedReader(Charsets.ISO_8859_1)
35 val lines = generateSequence { reader.readLine() }.takeWhile { it.isNotEmpty() }.toList()
36 val range = lines.drop(1).firstOrNull { it.startsWith("Range:", ignoreCase = true) }?.substringAfter(':')?.trim()
26 /** Serves `body`, honouring `Range: bytes=N-` when `ranges` is on; records each request's Range. */
27 private fun serve(ranges: Boolean = true, status: Int = 200, payload: ByteArray = body, check: (String, MutableList<String?>) -> Unit) {
28 val seen = Collections.synchronizedList(mutableListOf<String?>())
29 val server = ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"))
30 server.soTimeout = 5000
31 val served = CompletableFuture<Unit>()
32 val worker = thread(name = "craft-media-http-fixture") {
33 try {
34 server.accept().use { socket ->
35 socket.soTimeout = 5000
36 val reader = socket.getInputStream().bufferedReader(Charsets.US_ASCII)
37 checkNotNull(reader.readLine()) { "Missing HTTP request" }
38 var range: String? = null
39 while (true) {
40 val header = reader.readLine() ?: break
41 if (header.isEmpty()) break
42 if (header.startsWith("Range:", ignoreCase = true)) range = header.substringAfter(':').trim()
43 }
3744 seen.add(range)
3845 val start = Regex("^bytes=(\\d+)-$").find(range.orEmpty())?.groupValues?.get(1)?.toInt()
39 val out = client.getOutputStream()
40 fun head(code: Int, length: Int, extra: String = "") =
41 out.write("HTTP/1.1 $code X\r\nContent-Length: $length\r\n${extra}Connection: close\r\n\r\n".toByteArray(Charsets.ISO_8859_1))
42 when {
43 status != 200 -> head(status, 0)
44 ranges && start != null && start >= body.size -> head(416, 0)
45 ranges && start != null -> {
46 head(206, body.size - start, "Content-Range: bytes $start-${body.size - 1}/${body.size}\r\n")
47 out.write(body, start, body.size - start)
48 }
49 else -> {
50 head(200, body.size)
51 out.write(body)
52 }
46 val code = when {
47 status != 200 -> status
48 ranges && start != null && start >= payload.size -> 416
49 ranges && start != null -> 206
50 else -> 200
51 }
52 val bytes = when (code) {
53 206 -> payload.copyOfRange(start ?: 0, payload.size)
54 200 -> payload
55 else -> byteArrayOf()
5356 }
54 out.flush()
57 val contentRange = if (code == 206 && start != null) "Content-Range: bytes $start-${payload.size - 1}/${payload.size}\r\n" else ""
58 val headers = "HTTP/1.1 $code Test\r\nContent-Length: ${bytes.size}\r\n${contentRange}Connection: close\r\n\r\n"
59 socket.getOutputStream().write(headers.toByteArray(Charsets.US_ASCII) + bytes)
5560 }
61 served.complete(Unit)
62 } catch (error: Throwable) {
63 served.completeExceptionally(error)
5664 }
5765 }
58 thread.isDaemon = true
59 thread.start()
60 try { check("http://127.0.0.1:${server.localPort}/clip.mp4", seen) }
61 finally { server.close(); thread.join(2000) }
66 try {
67 check("http://127.0.0.1:${server.localPort}/clip.mp4", seen)
68 served.get(5, TimeUnit.SECONDS)
69 } finally {
70 server.close()
71 worker.join(5000)
72 assertFalse("The local HTTP fixture must stop after each transfer", worker.isAlive)
73 }
6274 }
6375
6476 private fun part(check: (File) -> Unit) {
@@ -115,14 +127,21 @@ class CraftNativeResumableDownloadTest {
115127 }
116128
117129 @Test
118 fun refusesAPartNobodyAskedFor() = part { file ->
119 serve(status = 206) { address, seen ->
130 fun refusesAnUnsolicitedPartialResponseWithoutRetryingIt() = part { file ->
131 serve(status = 206) { address, _ ->
120132 assertEquals(CraftResumableDownload.Fetched.Refused(206), CraftResumableDownload().fetch(address, emptyMap(), file) { false })
121 assertEquals(listOf<String?>(null), seen)
122133 assertFalse(file.exists())
123134 }
124135 }
125136
137 @Test
138 fun refusesAnEmptyResponseWithoutRetainingAnUnresumablePart() = part { file ->
139 serve(payload = byteArrayOf()) { address, _ ->
140 assertEquals(CraftResumableDownload.Fetched.Incomplete, CraftResumableDownload().fetch(address, emptyMap(), file) { false })
141 assertFalse("An empty file has no bytes to resume", file.exists())
142 }
143 }
144
126145 @Test
127146 fun keepsWhatItHasWhenStopped() = part { file ->
128147 serve { address, _ ->
packages/android/fixtures/native-navigation/NativeFlatListTest.ktmodified+14-7
Changes to packages/android/fixtures/native-navigation/NativeFlatListTest.kt
@@ -12,10 +12,13 @@ import android.widget.LinearLayout
1212import android.widget.TextView
1313import androidx.recyclerview.widget.GridLayoutManager
1414import androidx.recyclerview.widget.LinearLayoutManager
15import androidx.recyclerview.widget.RecyclerView
1516import androidx.test.ext.junit.runners.AndroidJUnit4
1617import androidx.test.core.app.ActivityScenario
1718import androidx.test.platform.app.InstrumentationRegistry
1819import java.util.concurrent.atomic.AtomicInteger
20import java.util.concurrent.CountDownLatch
21import java.util.concurrent.TimeUnit
1922import org.json.JSONArray
2023import org.json.JSONObject
2124import org.junit.Assert.assertEquals
@@ -180,26 +183,30 @@ class NativeFlatListTest {
180183 val scrollCount = AtomicInteger()
181184 val momentumBeginCount = AtomicInteger()
182185 val momentumEndCount = AtomicInteger()
186 val momentumEnded = CountDownLatch(1)
183187 val rows = (0 until 100).map(::row)
184188 try {
185189 instrumentation.runOnMainSync {
186190 list.onScrollEvent = { scrollCount.incrementAndGet() }
187191 list.onMomentumScrollBegin = { momentumBeginCount.incrementAndGet() }
188 list.onMomentumScrollEnd = { momentumEndCount.incrementAndGet() }
192 list.onMomentumScrollEnd = {
193 momentumEndCount.incrementAndGet()
194 momentumEnded.countDown()
195 }
189196 list.apply(rows, false, 1, false, 0.1, renderer(context, AtomicInteger()), { _ -> }, { _ -> }, null)
190197 }
191198 awaitCount(list, 100)
192199 instrumentation.runOnMainSync {
193200 attach(list)
194 list.smoothScrollToPosition(99)
195 }
196 repeat(50) {
197 if (momentumEndCount.get() > 0) return@repeat
198 SystemClock.sleep(50)
201 // Exercise a real viewport-sized animation, rather than
202 // coupling callback delivery to a 99-row traversal's duration.
203 list.smoothScrollBy(0, list.height)
199204 }
205 assertTrue("RecyclerView did not finish its native momentum animation", momentumEnded.await(2500, TimeUnit.MILLISECONDS))
200206 assertTrue("RecyclerView did not emit scroll callbacks", scrollCount.get() > 0)
201207 assertEquals(1, momentumBeginCount.get())
202208 assertEquals(1, momentumEndCount.get())
209 instrumentation.runOnMainSync { assertEquals(RecyclerView.SCROLL_STATE_IDLE, list.scrollState) }
203210 } finally {
204211 scenario.close()
205212 }
formatting only · 9 hidden lines
Changes to packages/android/fixtures/native-navigation/NativeFlatListTest.kt
@@ -309,8 +316,8 @@ class NativeFlatListTest { formatting only
309316 View.MeasureSpec.makeMeasureSpec(1_000, View.MeasureSpec.AT_MOST),
310317 )
311318 list.layout(0, 0, list.measuredWidth, list.measuredHeight)
319 assertTrue("non-scrolling FlatList should include every row", list.measuredHeight >= rows.size * 44)
312320 }
313 assertTrue("non-scrolling FlatList should include every row", list.measuredHeight >= rows.size * 44)
314321 repeat(100) {
315322 if (contentSizeChanges.get() > 0) return@repeat
316323 SystemClock.sleep(10)
packages/android/fixtures/native-navigation/NativeNavigationTest.ktmodified+27-5
Changes to packages/android/fixtures/native-navigation/NativeNavigationTest.kt
@@ -783,6 +783,19 @@ class NativeNavigationTest {
783783 throw AssertionError("The native accessibility tree did not expose STX testID $id")
784784 }
785785 try {
786 awaitView(activity, "home-title")
787 val homeTab = node("craft-tab-/")
788 assertEquals("Home", homeTab.contentDescription.toString())
789 assertEquals(Button::class.java.name, homeTab.className.toString())
790 assertTrue(homeTab.isSelected)
791 val detailsTab = node("craft-tab-/details")
792 assertEquals("Details", detailsTab.contentDescription.toString())
793 assertFalse(detailsTab.isSelected)
794 assertTrue("The native tab did not accept its accessibility click",
795 detailsTab.performAction(AccessibilityNodeInfo.ACTION_CLICK))
796 awaitView(activity, "details-title")
797 assertTrue(node("craft-tab-/details").isSelected)
798 assertTrue(node("craft-tab-/").performAction(AccessibilityNodeInfo.ACTION_CLICK))
786799 awaitView(activity, "home-title")
787800 val title = node("home-title")
788801 assertEquals("Home", title.text.toString())
@@ -1113,7 +1126,7 @@ class NativeNavigationTest {
11131126 val templateFirst = awaitView(activity, "grid-template-rows-first")
11141127 val templateThird = awaitView(activity, "grid-template-rows-third")
11151128 assertEquals(templateFirst.height, templateThird.height)
1116 assertTrue("explicit grid rows did not fill the definite height", templateThird.top - templateFirst.top > (30 * density).toInt())
1129 assertEquals("explicit grid rows did not fill the definite height", 44.0 * density, (templateThird.top - templateFirst.top).toDouble(), 1.0)
11171130 val fractionalGrid = awaitView(activity, "grid-fractional-wrap")
11181131 val fractionalFirst = awaitView(activity, "grid-fractional-first")
11191132 val fractionalSecond = awaitView(activity, "grid-fractional-second")
@@ -1883,10 +1896,19 @@ class NativeNavigationTest {
18831896 people.scrollTo(0, (peopleContent?.height ?: 0) - people.height)
18841897 }
18851898 SystemClock.sleep(150)
1886 assertTrue(
1887 "ScrollView sticky header did not remain pinned",
1888 screenTop(peopleHeader) >= screenTop(people) + (8 * density).toInt() - 1,
1889 )
1899 instrumentation.runOnMainSync {
1900 // Sticky headers stay at the native host's effective viewport
1901 // edge, including its authored content inset.
1902 val headerTop = screenTop(peopleHeader)
1903 val viewportTop = screenTop(people) + peopleContent.paddingTop
1904 val firstChild = (peopleContent as? ViewGroup)?.getChildAt(0)
1905 val layout = "scrollY=${people.scrollY} headerTop=${peopleHeader.top} translationY=${peopleHeader.translationY} contentHeight=${peopleContent.height} viewportHeight=${people.height} firstChild=${firstChild?.tag} firstTranslation=${firstChild?.translationY} headerIsDirectChild=${peopleHeader.parent === peopleContent} focus=${activity.currentFocus?.tag} refreshing=${peopleHost.isRefreshing}"
1906 assertTrue("ScrollView did not retain the requested scroll: $layout", people.scrollY > 0)
1907 assertTrue(
1908 "ScrollView sticky header did not remain pinned: header=$headerTop viewport=$viewportTop $layout",
1909 kotlin.math.abs(headerTop - viewportTop) <= 1,
1910 )
1911 }
18901912 val peopleLayouts = awaitView(activity, "people-layout-status") as TextView
18911913 val initialRowLayouts = peopleLayouts.text.toString().substringAfterLast(": ").toInt()
18921914 assertTrue("ScrollView rows did not report layout", initialRowLayouts > 0)
packages/android/src/emulator.tsmodified+11-1
Changes to packages/android/src/emulator.ts
@@ -36,6 +36,7 @@ export async function createAndroidEmulator(options: AndroidEmulatorOptions): Pr
3636 if (options.acceleration === 'software' && process.arch !== 'x64')
3737 throw new Error('Android software emulation requires an x86_64 host and system image')
3838 const bootTimeoutMs = options.bootTimeoutMs ?? 600_000
39 const requiresPlayStore = options.image.split(';')[2] === 'google_apis_playstore'
3940 const maximumBootTimeoutMs = options.acceleration === 'software' ? 1_200_000 : 600_000
4041 if (!Number.isSafeInteger(bootTimeoutMs) || bootTimeoutMs < 1000 || bootTimeoutMs > maximumBootTimeoutMs)
4142 throw new Error(`Android bootTimeoutMs must be an integer between 1000 and ${maximumBootTimeoutMs} milliseconds`)
@@ -159,6 +160,11 @@ export async function createAndroidEmulator(options: AndroidEmulatorOptions): Pr
159160 // boot_completed can precede SettingsProvider and System UI readiness.
160161 const settings = await run([adb, '-s', serial, 'shell', 'settings', 'get', 'global', 'animator_duration_scale'], undefined, probeTimeout(), true)
161162 const packages = await run([adb, '-s', serial, 'shell', 'pm', 'path', 'android'], undefined, probeTimeout(), true)
163 // A stock Play image can expose the framework package and HOME while
164 // first-boot provisioning is still registering its bundled apps.
165 const playStore = requiresPlayStore
166 ? await run([adb, '-s', serial, 'shell', 'pm', 'path', 'com.android.vending'], undefined, probeTimeout(), true)
167 : undefined
162168 const systemUi = await run([adb, '-s', serial, 'shell', 'pidof', 'com.android.systemui'], undefined, probeTimeout(), true)
163169 // Native acceptance must be able to observe platform crashes before
164170 // launching any app. boot_completed/System UI can precede logd's ADB
@@ -171,9 +177,13 @@ export async function createAndroidEmulator(options: AndroidEmulatorOptions): Pr
171177 // modifying device setup or relaxing native acceptance.
172178 const home = await run([adb, '-s', serial, 'shell', 'cmd', 'package', 'resolve-activity', '--brief', '-a', 'android.intent.action.MAIN', '-c', 'android.intent.category.HOME'], undefined, probeTimeout(), true)
173179 const homeActivity = home.stdout.trim().split(/\r?\n/).at(-1) ?? ''
180 const temporaryHome = homeActivity.startsWith('com.android.sdksetup/')
181 || /^com\.android\.settings\/(?:\.|com\.android\.settings\.)FallbackHome$/.test(homeActivity)
182 || /^android\/.*(?:Resolver|Chooser)Activity$/.test(homeActivity)
174183 booted = settings.code === 0 && packages.code === 0 && systemUi.code === 0 && events.code === 0
175184 && /^package:\/\S+/m.test(packages.stdout) && /^\d+(?:\s+\d+)*$/.test(systemUi.stdout.trim())
176 && home.code === 0 && /^[\w.]+\/[\w.$]+$/.test(homeActivity) && !homeActivity.startsWith('com.android.sdksetup/')
185 && (!requiresPlayStore || (playStore?.code === 0 && /^package:\/\S+/m.test(playStore.stdout)))
186 && home.code === 0 && /^[\w.]+\/[\w.$]+$/.test(homeActivity) && !temporaryHome
177187 }
178188 }
179189 const checkedAt = Date.now()
packages/android/src/grid-rows.test.tsmodified+56-0
Changes to packages/android/src/grid-rows.test.ts
@@ -2,6 +2,34 @@
22import { test } from 'bun:test'
33import { runKotlinFixture } from './testing'
44
5test('snaps authored padding to the nearest physical pixel at fractional density', async () => {
6 const template = await Bun.file(new URL('../templates/MainActivityNative.kt.template', import.meta.url)).text()
7 const padding = template.match(/internal fun craftNativePadding\([^]*?\n\}/)?.[0]
8 const number = template.match(/private fun craftNativeNumber\([^]*?\n \?\.takeIf \{ it\.isFinite\(\) \}/)?.[0]
9 if (!padding || !number) throw new Error('Native padding normalization is missing')
10 await runKotlinFixture(`
11import kotlin.math.roundToInt
12
13class JSONObject(private val values: Map<String, Any?>) {
14 fun opt(name: String): Any? = values[name]
15}
16${number}
17${padding}
18
19fun main() {
20 val density = 2.625f
21 check(craftNativePadding(JSONObject(mapOf("padding" to 4)), "padding", 0, density) == 11)
22 check(craftNativePadding(JSONObject(mapOf("padding" to 8)), "padding", 0, density) == 21)
23 check(craftNativePadding(JSONObject(mapOf("padding" to 4)), "padding", 0, 1f) == 4)
24 for (invalid in listOf(null, -1, Double.NaN, Double.POSITIVE_INFINITY, true, "4")) {
25 check(craftNativePadding(JSONObject(mapOf("padding" to invalid)), "padding", 7, density) == 7)
26 }
27 check(craftNativePadding(null, "padding", 7, density) == 7)
28 println("PASS native padding rounds once to the nearest physical pixel and retains invalid-value defaults")
29}
30`)
31}, 180_000)
32
533test('sizes occupied and empty explicit grid rows after padding and fractional gaps', async () => {
634 const template = await Bun.file(new URL('../templates/MainActivityNative.kt.template', import.meta.url)).text()
735 const allocation = template.match(/ val rowHeights = if \(gridRows != null\) \{[\s\S]*?\n \} else naturalRowHeights/)?.[0]
@@ -34,3 +62,31 @@ fun main() {
3462}
3563`)
3664}, 180_000)
65
66
67test('encodes shared grid counts as the unsigned integer fields in the native ABI', async () => {
68 const template = await Bun.file(new URL('../templates/MainActivityNative.kt.template', import.meta.url)).text()
69 const columns = template.match(/^[^\n]*put(?:Enum)?\(base, 29, node\.gridColumns[^\n]*$/m)?.[0]
70 const rows = template.match(/^[^\n]*if \(explicitRowsAware\) put(?:Enum)?\(base, 34, [^\n]*$/m)?.[0]
71 const directColumns = template.match(/^[^\n]*put(?:Enum)?\(base, 29, 1f?\)[^\n]*$/m)?.[0]
72 if (!columns || !rows || !directColumns) throw new Error('Shared grid count serialization is missing')
73 await runKotlinFixture(`
74data class GridNode(val gridColumns: Int, val gridRows: Int?)
75
76fun main() {
77 val base = 0
78 val node = GridNode(2, 3)
79 val explicitRowsAware = true
80 val nodeData = FloatArray(35) { Float.NaN }
81 fun put(base: Int, index: Int, value: Float) { nodeData[base + index] = value }
82 fun putEnum(base: Int, index: Int, value: Int) { put(base, index, Float.fromBits(value)) }
83${columns}
84${rows}
85 check(nodeData[29].toRawBits() == 2) { "grid_columns is not encoded as u32" }
86 check(nodeData[34].toRawBits() == 3) { "grid_rows is not encoded as u32" }
87${directColumns}
88 check(nodeData[29].toRawBits() == 1) { "direct layout grid_columns default is not encoded as u32" }
89 println("PASS native grid counts retain the integer bit patterns consumed by Zig")
90}
91`)
92}, 180_000)
packages/android/src/index.test.tsmodified+3-2
Changes to packages/android/src/index.test.ts
@@ -1524,8 +1524,9 @@ describe('Craft Android builder', () => {
15241524 expect(shell).toContain('val matching = pending.filter { it.generation == started }')
15251525 expect(shell).toContain('work.enqueueUniqueWork(name, if (pending) androidx.work.ExistingWorkPolicy.KEEP else androidx.work.ExistingWorkPolicy.REPLACE, request).result.get()')
15261526 // A prune never stops the transfer a keep still waits on; a 206 nobody asked for is refused.
1527 expect(shell).toContain('owner in wanted || owner in active')
1528 expect(shell).toContain('if (status == 206 && have == 0L) return Fetched.Refused(status)')
1527 expect(shell).toContain('owner !in active && (owner !in wanted || stale)')
1528 expect(shell).toContain('if (status == 416 || (status == 206 && !resumed))')
1529 expect(shell).toContain('return if (have > 0) Fetched.Restart else Fetched.Refused(status)')
15291530 expect(shell).toContain('if (wifiOnly) androidx.work.NetworkType.UNMETERED else androidx.work.NetworkType.CONNECTED')
15301531 // Resumed from the part file an earlier attempt left.
15311532 expect(shell).toContain('connection.setRequestProperty("Range", "bytes=$have-")')
packages/android/src/native-shell.test.tsmodified+1-2
Changes to packages/android/src/native-shell.test.ts
@@ -236,8 +236,7 @@ describe('the native app shell in a generated project', () => {
236236 expect(shell).toContain('tabSwipeEnabled = options.optBoolean("tabSwipeEnabled", true)')
237237 // Wi-Fi only: a kept video is not fetched on a metered connection, and says it is waiting.
238238 expect(readFileSync(join(java, 'CraftNativeServices.kt'), 'utf8')).toContain('optBoolean("cellular", true)')
239 // The keep is a WorkManager job that waits for an unmetered network, and answers at once that it is queued.
240 expect(shell).toContain('if (wifiOnly) androidx.work.NetworkType.UNMETERED else androidx.work.NetworkType.CONNECTED')
239 expect(shell).toContain('setRequiredNetworkType(if (wifiOnly) androidx.work.NetworkType.UNMETERED else androidx.work.NetworkType.CONNECTED)')
241240 expect(shell).toContain('wifiOnly && metered(app) -> "cellular"')
242241 expect(shell).toContain('.put("reason", reason).put("queued", true)')
243242 const manifest = readFileSync(join(output, 'app/src/main/AndroidManifest.xml'), 'utf8')
packages/android/templates/CraftNativeMapGoogle.kt.templatemodified+0-1
Changes to packages/android/templates/CraftNativeMapGoogle.kt.template
@@ -413,7 +413,6 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
413413 markers.values.forEach { builder.include(it.position); count++ }
414414 polylines.values.flatMap { it.points }.forEach { builder.include(it); count++ }
415415 polygons.values.flatMap { it.points }.forEach { builder.include(it); count++ }
416 polygons.values.flatMap { it.points }.forEach { builder.include(it); count++ }
417416 if (count == 0) return false
418417 post {
419418 if (width > 0 && height > 0) {
packages/android/templates/CraftNativeShell.kt.templatemodified+68-23
Changes to packages/android/templates/CraftNativeShell.kt.template
@@ -21,6 +21,9 @@ import android.widget.FrameLayout
2121import android.widget.ImageView
2222import android.widget.LinearLayout
2323import android.widget.TextView
24import androidx.core.view.AccessibilityDelegateCompat
25import androidx.core.view.ViewCompat
26import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
2427import androidx.core.content.ContextCompat
2528import androidx.security.crypto.EncryptedSharedPreferences
2629import androidx.security.crypto.MasterKey
@@ -838,7 +841,17 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
838841 cell.background = rippleBackground()
839842 cell.contentDescription = tab.title
840843 cell.tag = "craft-tab-${tab.id}"
844 cell.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
845 ViewCompat.setAccessibilityDelegate(cell, object : AccessibilityDelegateCompat() {
846 override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) {
847 super.onInitializeAccessibilityNodeInfo(host, info)
848 info.viewIdResourceName = "craft-tab-${tab.id}"
849 info.className = android.widget.Button::class.java.name
850 info.isSelected = host.isSelected
851 }
852 })
841853 val icon = TextView(context)
854 icon.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
842855 icon.text = CraftNativeSymbols.glyph(tab.symbol)
843856 icon.textSize = 22f
844857 icon.minWidth = dp(26)
@@ -846,6 +859,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
846859 icon.gravity = Gravity.CENTER
847860 icon.includeFontPadding = false
848861 val label = TextView(context)
862 label.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
849863 label.text = tab.title
850864 label.textSize = 12f
851865 label.fontFeatureSettings = "tnum"
@@ -1234,6 +1248,7 @@ internal object CraftNativeSymbols {
12341248 * transfer finishes on its own; the next keep answers from the copy.
12351249 */
12361250internal object CraftNativeMedia {
1251 const val ATTEMPT_SETTLED = "craft.media.attemptSettled"
12371252 private const val TAG = "craft-media"
12381253 private const val PREFS = "craft.media"
12391254 private val lock = Any()
@@ -1301,7 +1316,7 @@ internal object CraftNativeMedia {
13011316 try {
13021317 if (generation(app) == started) {
13031318 val pending = work.getWorkInfosForUniqueWork(name).get().any {
1304 "$TAG:generation:$started" in it.tags &&
1319 "$TAG:generation:$started" in it.tags && !it.progress.getBoolean(ATTEMPT_SETTLED, false) &&
13051320 (it.state == androidx.work.WorkInfo.State.RUNNING ||
13061321 (it.state == androidx.work.WorkInfo.State.ENQUEUED && it.runAttemptCount == 0))
13071322 }
@@ -1349,12 +1364,22 @@ internal object CraftNativeMedia {
13491364 * file where it stopped, moves it into place whole, and answers whoever
13501365 * waits. RETRY leaves the part file for the next attempt.
13511366 */
1352 fun transfer(context: Context, address: String, headers: JSONObject, started: Int, attempt: Int, stopped: () -> Boolean): Outcome {
1367 fun transfer(context: Context, address: String, headers: JSONObject, started: Int, attempt: Int, beforeAnswer: () -> Unit = {}, stopped: () -> Boolean): Outcome {
1368 fun answer(result: CraftNativeCapabilityResult) {
1369 // The callback can immediately call keep again. Publish completion
1370 // before that callback, while WorkManager may still say RUNNING.
1371 synchronized(lock) {
1372 // A keep registered after this marker belongs to the next
1373 // attempt; it must not enter the old callback batch.
1374 beforeAnswer()
1375 deliver(context, address, started, result)
1376 }
1377 }
13531378 // clear() already answered the old generation's waiters. Never answer
13541379 // a new athlete's waiters from an obsolete worker for the same URL.
13551380 if (generation(context) != started) return Outcome.DONE
13561381 local(context, address)?.let {
1357 deliver(context, address, started, CraftNativeCapabilityResult(data = describe(address, it)))
1382 answer(CraftNativeCapabilityResult(data = describe(address, it)))
13581383 return Outcome.DONE
13591384 }
13601385 val writer = writers.getOrPut(address) { Any() }
@@ -1364,7 +1389,7 @@ internal object CraftNativeMedia {
13641389 if (generation(context) != started) return Outcome.DONE
13651390 if (stopped()) return Outcome.RETRY
13661391 local(context, address)?.let {
1367 deliver(context, address, started, CraftNativeCapabilityResult(data = describe(address, it)))
1392 answer(CraftNativeCapabilityResult(data = describe(address, it)))
13681393 return Outcome.DONE
13691394 }
13701395 val target = file(context, address)
@@ -1383,22 +1408,22 @@ internal object CraftNativeMedia {
13831408 }
13841409 if (full) {
13851410 staging.delete()
1386 deliver(context, address, started, CraftNativeCapabilityResult(code = "NO_SPACE", message = error.message ?: "The phone is out of space"))
1411 answer(CraftNativeCapabilityResult(code = "NO_SPACE", message = error.message ?: "The phone is out of space"))
13871412 return Outcome.DONE
13881413 }
13891414 if (stopped()) return Outcome.RETRY
13901415 // Lost the connection part way: the part waits for the next attempt.
1391 deliver(context, address, started, if (!online(context)) queued(address, "offline") else CraftNativeCapabilityResult(code = if (error is java.net.SocketTimeoutException) "TIMEOUT" else "NETWORK_ERROR", message = error.message ?: "media.keep failed"))
1416 answer(if (!online(context)) queued(address, "offline") else CraftNativeCapabilityResult(code = if (error is java.net.SocketTimeoutException) "TIMEOUT" else "NETWORK_ERROR", message = error.message ?: "media.keep failed"))
13921417 return if (attempt < 8) Outcome.RETRY else Outcome.DONE.also { staging.delete() }
13931418 }
13941419 when (fetched) {
13951420 is CraftResumableDownload.Fetched.Stopped, is CraftResumableDownload.Fetched.Restart -> return Outcome.RETRY
13961421 is CraftResumableDownload.Fetched.Refused -> {
1397 deliver(context, address, started, CraftNativeCapabilityResult(data = JSONObject().put("ok", false).put("url", address).put("status", fetched.status)))
1422 answer(CraftNativeCapabilityResult(data = JSONObject().put("ok", false).put("url", address).put("status", fetched.status)))
13981423 return Outcome.DONE
13991424 }
14001425 is CraftResumableDownload.Fetched.Incomplete -> {
1401 deliver(context, address, started, CraftNativeCapabilityResult(code = "NETWORK_ERROR", message = "media.keep received an incomplete file"))
1426 answer(CraftNativeCapabilityResult(code = "NETWORK_ERROR", message = "media.keep received an incomplete file"))
14021427 return if (attempt < 8) Outcome.RETRY else Outcome.DONE.also { staging.delete() }
14031428 }
14041429 is CraftResumableDownload.Fetched.Complete -> Unit
@@ -1410,11 +1435,11 @@ internal object CraftNativeMedia {
14101435 }
14111436 target.delete()
14121437 if (!staging.renameTo(target)) {
1413 deliver(context, address, started, CraftNativeCapabilityResult(code = "IO_ERROR", message = "media.keep could not store the file"))
1438 answer(CraftNativeCapabilityResult(code = "IO_ERROR", message = "media.keep could not store the file"))
14141439 return Outcome.DONE
14151440 }
14161441 }
1417 deliver(context, address, started, CraftNativeCapabilityResult(data = describe(address, target)))
1442 answer(CraftNativeCapabilityResult(data = describe(address, target)))
14181443 return Outcome.DONE
14191444 }
14201445 }
@@ -1432,13 +1457,13 @@ internal object CraftNativeMedia {
14321457 capabilities?.hasCapability(android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
14331458 }.getOrDefault(true)
14341459
1435 /** Removes every copy whose address is not in `keep`, and stops their transfers; answers how many files went. */
1460 /** Removes idle copies outside `keep` and cancels their jobs; pending keeps retain ownership. */
14361461 fun prune(context: Context, keep: List<String>): Int {
14371462 val wanted = keep.map { digest(it) }.toSet()
14381463 var removed = 0
1439 // A keep still waiting owns its address's files and its transfer until it is answered.
1440 val active = synchronized(lock) { waiting.keys.map { digest(it) }.toSet() }
14411464 synchronized(lock) {
1465 // A keep still waiting owns its files until it is answered.
1466 val active = waiting.keys.map { digest(it) }.toSet()
14421467 root(context).listFiles()?.forEach { file ->
14431468 val owner = file.name.substringBefore('.')
14441469 // Part files from before transfers resumed (`<sha>.<ext>.<id>.part`) are never picked up again.
@@ -1451,8 +1476,16 @@ internal object CraftNativeMedia {
14511476 runCatching {
14521477 androidx.work.WorkManager.getInstance(app).getWorkInfosByTag(TAG).get()
14531478 .filter { !it.state.isFinished }
1454 .filter { info -> info.tags.none { it.startsWith("$TAG:") && it.removePrefix("$TAG:").let { owner -> owner in wanted || owner in active } } }
1455 .forEach { androidx.work.WorkManager.getInstance(app).cancelWorkById(it.id) }
1479 .forEach { info ->
1480 synchronized(lock) {
1481 // The query runs asynchronously: a new keep may now
1482 // own an address that was idle when prune was called.
1483 val active = waiting.keys.map { digest(it) }.toSet()
1484 if (info.tags.none { it.startsWith("$TAG:") && it.removePrefix("$TAG:") in wanted + active }) {
1485 androidx.work.WorkManager.getInstance(app).cancelWorkById(info.id)
1486 }
1487 }
1488 }
14561489 }
14571490 }
14581491 return removed
@@ -1470,9 +1503,10 @@ internal object CraftNativeMedia {
14701503 root(context).listFiles()?.forEach { file ->
14711504 if (file.delete()) removed++ else failure = java.io.IOException("The video cache could not be cleared")
14721505 }
1506 // Schedule cancellation before a new generation can enqueue its work.
1507 androidx.work.WorkManager.getInstance(context.applicationContext).cancelAllWorkByTag(TAG)
14731508 callbacks
14741509 }
1475 androidx.work.WorkManager.getInstance(context.applicationContext).cancelAllWorkByTag(TAG)
14761510 main.post { answered.forEach { it.answer(CraftNativeCapabilityResult(code = "CANCELLED", message = "The video cache was cleared")) } }
14771511 failure?.let { throw it }
14781512 return removed
@@ -1511,12 +1545,10 @@ internal class CraftResumableDownload(
15111545 if (have > 0) connection.setRequestProperty("Range", "bytes=$have-")
15121546 try {
15131547 val status = connection.responseCode
1514 // Part of a file nobody asked a part of: not something to keep.
1515 if (status == 206 && have == 0L) return Fetched.Refused(status)
15161548 val resumed = status == 206 && have > 0 && rangeStart(connection.getHeaderField("Content-Range")) == have
15171549 if (status == 416 || (status == 206 && !resumed)) {
15181550 part.delete()
1519 return Fetched.Restart
1551 return if (have > 0) Fetched.Restart else Fetched.Refused(status)
15201552 }
15211553 if (status != 200 && !resumed) {
15221554 part.delete()
@@ -1539,8 +1571,8 @@ internal class CraftResumableDownload(
15391571 // HttpURLConnection inflates gzip itself; a Content-Length is then the compressed size.
15401572 val encoded = connection.getHeaderField("Content-Encoding") != null
15411573 if (size <= 0 || (!encoded && total >= 0 && size != total)) {
1542 // Longer than the file says it is: nothing to resume from.
1543 if (total in 0 until size) part.delete()
1574 // Empty or longer than the file says: nothing useful to resume.
1575 if (size <= 0 || total in 0 until size) part.delete()
15441576 return Fetched.Incomplete
15451577 }
15461578 return Fetched.Complete(size)
@@ -1563,8 +1595,21 @@ internal class CraftNativeMediaWorker(context: Context, parameters: androidx.wor
15631595 override fun doWork(): Result {
15641596 val address = inputData.getString("address") ?: return Result.failure()
15651597 val headers = runCatching { JSONObject(inputData.getString("headers") ?: "{}") }.getOrDefault(JSONObject())
1566 val outcome = CraftNativeMedia.transfer(applicationContext, address, headers, inputData.getInt("generation", 0), runAttemptCount) { isStopped }
1567 return if (outcome == CraftNativeMedia.Outcome.RETRY) Result.retry() else Result.success()
1598 val started = inputData.getInt("generation", 0)
1599 return try {
1600 setProgressAsync(androidx.work.workDataOf(CraftNativeMedia.ATTEMPT_SETTLED to false)).get()
1601 val outcome = CraftNativeMedia.transfer(applicationContext, address, headers, started, runAttemptCount,
1602 beforeAnswer = { setProgressAsync(androidx.work.workDataOf(CraftNativeMedia.ATTEMPT_SETTLED to true)).get() }
1603 ) { isStopped }
1604 if (outcome == CraftNativeMedia.Outcome.RETRY) Result.retry() else Result.success()
1605 } catch (error: Exception) {
1606 if (error is InterruptedException) Thread.currentThread().interrupt()
1607 // A replacement owns the address's new waiters. A real progress
1608 // storage failure still needs an answer instead of a lost callback.
1609 if (!isStopped) CraftNativeMedia.deliver(applicationContext, address, started,
1610 CraftNativeCapabilityResult(code = "IO_ERROR", message = error.message ?: "media.keep could not store worker progress"))
1611 if (isStopped) Result.retry() else Result.failure()
1612 }
15681613 }
15691614}
15701615
packages/android/templates/MainActivityNative.kt.templatemodified+5-5
Changes to packages/android/templates/MainActivityNative.kt.template
@@ -1136,9 +1136,9 @@ internal class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
11361136 val rowHeights = if (gridRows != null) {
11371137 val count = max(gridRows ?: 0, naturalRowHeights.size)
11381138 while (rows.size < count) rows.add(mutableListOf())
1139 val available = max(0f, height - paddingTop - paddingBottom - max(0, count - 1) * rowGap)
1139 val available = max(0f, (height - paddingTop - paddingBottom).toFloat() - max(0, count - 1) * rowGap)
11401140 val equal = available / max(1, count).toFloat()
1141 (naturalRowHeights + List(count - naturalRowHeights.size) { equal })
1141 (naturalRowHeights + List(count - naturalRowHeights.size) { 0f })
11421142 .map { max(it, equal) }
11431143 } else naturalRowHeights
11441144 return GridLayout(rows, absolute, cellWidth, rowHeights)
@@ -1438,12 +1438,12 @@ internal class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
14381438 put(base, 26, node.padding[3].toFloat())
14391439 put(base, 27, node.padding[0].toFloat())
14401440 putEnum(base, 28, if (node.grid) 1 else 0)
1441 put(base, 29, node.gridColumns.toFloat())
1441 putEnum(base, 29, node.gridColumns)
14421442 put(base, 30, node.rowGap)
14431443 put(base, 31, node.columnGap)
14441444 if (fields >= 33) putEnum(base, 32, (if (node.reverse) 1 else 0) or (if (node.wrapReverse) 2 else 0))
14451445 if (autoRowsAware) put(base, 33, sharedLength(node.gridAutoRows, contentHeight))
1446 if (explicitRowsAware) put(base, 34, (node.gridRows ?: 0).toFloat())
1446 if (explicitRowsAware) putEnum(base, 34, node.gridRows ?: 0)
14471447 val firstChild = cursor
14481448 node.children.forEach { child -> write(child, contentWidth, contentHeight, false, node.direction) }
14491449 treeMeta[index * 2] = firstChild.toFloat()
@@ -1527,7 +1527,7 @@ internal class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
15271527 put(base, 26, padding[3].toFloat())
15281528 put(base, 27, padding[0].toFloat())
15291529 putEnum(base, 28, 0)
1530 put(base, 29, 1f)
1530 putEnum(base, 29, 1)
15311531 put(base, 30, if (root) rowGapValue else 0f)
15321532 put(base, 31, if (root) columnGapValue else 0f)
15331533 }
packages/ios/fixtures/hybrid-navigation/HybridNavigationUITests.swiftmodified+4-1
Changes to packages/ios/fixtures/hybrid-navigation/HybridNavigationUITests.swift
@@ -28,7 +28,10 @@ final class HybridNavigationUITests: XCTestCase {
2828 let app = XCUIApplication()
2929 app.launch()
3030 let open = app.buttons["Open second"]
31 XCTAssertTrue(open.waitForExistence(timeout: 15))
31 // Cold WebKit accessibility initialization on a hosted simulator can
32 // lag the painted page. Match the unit fixture's initial readiness
33 // budget; the transition and destination deadlines below stay strict.
34 XCTAssertTrue(open.waitForExistence(timeout: 120))
3235 XCTAssertEqual(visiblePage(XCUIScreen.main.screenshot()), .home)
3336
3437 open.tap()
packages/ios/fixtures/native-navigation/NativeNavigationUITests.swiftmodified+76-17
Changes to packages/ios/fixtures/native-navigation/NativeNavigationUITests.swift
@@ -41,9 +41,23 @@ final class NativeNavigationUITests: XCTestCase {
4141 return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed
4242 }
4343
44 /// A scroll view can extend behind the software keyboard. Its covered
45 /// portion is not a surface on which a real page drag can begin.
46 private func visibleViewport(_ scroll: XCUIElement) -> CGRect {
47 var viewport = scroll.frame
48 let keyboard = XCUIApplication().keyboards.firstMatch
49 if keyboard.exists {
50 let covered = viewport.intersection(keyboard.frame)
51 if !covered.isNull && covered.width >= viewport.width - 1 {
52 viewport.size.height = max(0, covered.minY - viewport.minY)
53 }
54 }
55 return viewport
56 }
57
4458 private func inside(_ element: XCUIElement, _ scroll: XCUIElement) -> Bool {
4559 let frame = element.frame
46 let viewport = scroll.frame
60 let viewport = visibleViewport(scroll)
4761 return !frame.isEmpty && frame.minY >= viewport.minY - 0.5 && frame.maxY <= viewport.maxY + 0.5
4862 }
4963
@@ -193,25 +207,31 @@ final class NativeNavigationUITests: XCTestCase {
193207 file: StaticString = #filePath,
194208 line: UInt = #line
195209 ) {
196 XCTAssertTrue(element.waitForExistence(timeout: 10), "missing control before scrolling", file: file, line: line)
210 guard element.waitForExistence(timeout: 10) else {
211 XCTFail("missing control before scrolling", file: file, line: line)
212 return
213 }
197214 if let root { reveal(scroll, in: root, file: file, line: line) }
198215 let application = XCUIApplication()
199216 var activeScrollViews: Set<String> = [scroll.identifier]
200217 if let root { activeScrollViews.insert(root.identifier) }
201 for attempt in 0..<24 {
218 // Budget for actual distance while requiring the same fully visible,
219 // hittable target and retaining a hard bound on manual drags.
220 let distanceToTarget = abs(element.frame.midY - visibleViewport(scroll).midY)
221 let attempts = min(128, max(24, Int(ceil(distanceToTarget / 200)) + 8))
222 for attempt in 0..<attempts {
202223 if inside(element, scroll) && element.isHittable { return }
203 let viewport = scroll.frame
204 let competingTypes: [XCUIElement.ElementType] = [.picker, .pickerWheel, .datePicker, .slider, .switch]
205 let controls = competingTypes.flatMap { type in
206 scroll.descendants(matching: type).allElementsBoundByIndex.map { $0.frame }
207 }
208 // Keyboard avoidance insets content without changing the scroll
209 // frame. A drag in that covered area would type instead of scroll.
224 let scrollFrame = scroll.frame
225 let viewport = visibleViewport(scroll)
226 // Native value controls, selection and nested scrolling retain
227 // their gesture ownership; search the remaining visible gaps.
228 let trackingTypes: [XCUIElement.ElementType] = [.scrollView, .picker, .pickerWheel, .datePicker, .slider, .switch, .stepper, .textField, .secureTextField, .textView, .segmentedControl]
229 let controls = scroll.descendants(matching: .any)
230 .matching(NSPredicate(format: "elementType IN %@", trackingTypes.map { NSNumber(value: $0.rawValue) }))
231 .allElementsBoundByIndex.map { $0.frame }
210232 let keyboards = application.keyboards.allElementsBoundByIndex.map { $0.frame }
211 // The prediction strip is a sibling scroll surface above the
212 // keyboard's key-area AX frame, outside the app scroll's subtree.
213 // Include actual application-wide bounds, excluding the scroll
214 // receiving the drag and its explicitly supplied outer scroll.
233 // Prediction strips can be sibling scroll surfaces outside the
234 // app's subtree. Exclude only the active scroll and its root.
215235 let scrolling = application.scrollViews.allElementsBoundByIndex.map { (identifier: $0.identifier, frame: $0.frame) }
216236 let competing = manualScrollBlockers(
217237 controls: controls + keyboards,
@@ -219,11 +239,14 @@ final class NativeNavigationUITests: XCTestCase {
219239 excluding: activeScrollViews
220240 ).map { $0.insetBy(dx: -4, dy: -4) }
221241 .filter { !$0.isEmpty && $0.intersects(viewport) }
222 let start = manualScrollOrigin(in: viewport, avoiding: competing)
223 guard let start else {
242 guard let origin = manualScrollOrigin(in: viewport, avoiding: competing) else {
224243 XCTFail("no safe manual scroll origin: target=\(element.frame), viewport=\(viewport), competing=\(competing)", file: file, line: line)
225244 return
226245 }
246 let start = CGVector(
247 dx: (viewport.minX + origin.dx * viewport.width - scrollFrame.minX) / scrollFrame.width,
248 dy: (viewport.minY + origin.dy * viewport.height - scrollFrame.minY) / scrollFrame.height
249 )
227250 // Content moves the opposite way to the finger, which may leave
228251 // a short scroll view: the drag stays its own until it lifts.
229252 let distance = max(-240, min(240, element.frame.midY - viewport.midY))
@@ -308,6 +331,42 @@ final class NativeNavigationUITests: XCTestCase {
308331 XCTAssertEqual(home.frame.minX, baseline.minX, accuracy: 1)
309332 }
310333
334 func testDraggingFromAButtonScrollsWithoutActivatingIt() {
335 let app = XCUIApplication()
336 app.launch()
337 let scroll = app.scrollViews["native-root-scroll"]
338 let button = app.buttons["toggle-safe-area"]
339 let marker = app.staticTexts["home-title"]
340 XCTAssertTrue(scroll.waitForExistence(timeout: 10))
341 XCTAssertTrue(button.waitForExistence(timeout: 10))
342 let baseline = marker.frame.minY
343 let start = button.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
344 start.press(forDuration: 0.05, thenDragTo: start.withOffset(CGVector(dx: 0, dy: -120)), withVelocity: .slow, thenHoldForDuration: 0.3)
345 XCTAssertLessThan(marker.frame.minY, baseline - 30, "A drag that starts on a button must scroll the list")
346 XCTAssertEqual(app.staticTexts["safe-area-status"].label, "Safe edges: top,bottom", "Scrolling must cancel the button's tap")
347 }
348
349 func testMultilineInputKeepsKeyboardFocusWhenTapped() {
350 let app = XCUIApplication()
351 app.launch()
352 let root = app.scrollViews["native-root-scroll"]
353 XCTAssertTrue(root.waitForExistence(timeout: 10))
354 let notes = app.textViews["notes-input"]
355 reveal(notes, in: root)
356 notes.tap()
357 XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5), "The scroll container dismissed the text area's keyboard")
358 // A native tap can place the caret anywhere. Exercise the review edit
359 // interaction explicitly instead of assuming insertion is at the end.
360 notes.press(forDuration: 1)
361 let selectAll = app.descendants(matching: .any).matching(NSPredicate(format: "label == %@", "Select All")).firstMatch
362 XCTAssertTrue(selectAll.waitForExistence(timeout: 5), "The text area must retain its native selection menu")
363 selectAll.tap()
364 notes.typeText("Draft edited")
365 XCTAssertEqual(notes.value as? String, "Draft edited")
366 notes.tap()
367 XCTAssertTrue(app.keyboards.firstMatch.exists, "Tapping the text area again must preserve editing")
368 }
369
311370 func testHorizontalCarouselKeepsItsSwipe() {
312371 let app = XCUIApplication()
313372 app.launch()
@@ -751,7 +810,7 @@ final class NativeNavigationUITests: XCTestCase {
751810 let templateThird = app.otherElements["grid-template-rows-third"]
752811 XCTAssertTrue(templateRows.waitForExistence(timeout: 5) && templateFirst.exists && templateThird.exists)
753812 XCTAssertEqual(templateFirst.frame.height, templateThird.frame.height, accuracy: 2, "explicit grid rows did not use equal tracks")
754 XCTAssertGreaterThan(templateThird.frame.minY, templateFirst.frame.minY + 30, "explicit grid rows did not fill the definite height")
813 XCTAssertEqual(templateThird.frame.minY - templateFirst.frame.minY, 44, accuracy: 1, "explicit grid rows did not fill the definite height")
755814 let fractionalGrid = app.otherElements["grid-fractional-wrap"]
756815 let fractionalFirst = app.otherElements["grid-fractional-first"]
757816 let fractionalSecond = app.otherElements["grid-fractional-second"]
packages/ios/fixtures/native-navigation/NativeNavigationUnitTests.swiftmodified+59-0
Changes to packages/ios/fixtures/native-navigation/NativeNavigationUnitTests.swift
@@ -172,6 +172,25 @@ final class NativeNavigationUnitTests: XCTestCase {
172172 CraftChrome.shared.selected = "/"
173173 }
174174
175 func testVerticalNativeScrollDoesNotPreventTheHorizontalTabGesture() throws {
176 _ = shell.tabTapped("/details", reselect: false)
177 let details = try XCTUnwrap(stack("/details"))
178 details.view.layoutIfNeeded()
179 func scroll(in view: UIView) -> UIScrollView? {
180 if view.accessibilityIdentifier == "people-list" { return view as? UIScrollView }
181 return view.subviews.lazy.compactMap { scroll(in: $0) }.first
182 }
183 let people = try XCTUnwrap(scroll(in: details.view))
184 let tabPan = try XCTUnwrap(shell.view.gestureRecognizers?.compactMap { $0 as? UIPanGestureRecognizer }.first)
185 XCTAssertFalse(people.alwaysBounceHorizontal)
186 XCTAssertLessThanOrEqual(people.contentSize.width, people.bounds.width + 1)
187 XCTAssertTrue(shell.gestureRecognizer(tabPan, shouldRecognizeSimultaneouslyWith: people.panGestureRecognizer),
188 "the vertical list's pan must not win the horizontal tab gesture")
189 people.alwaysBounceHorizontal = true
190 XCTAssertFalse(shell.gestureRecognizer(tabPan, shouldRecognizeSimultaneouslyWith: people.panGestureRecognizer),
191 "a horizontal scroller must keep its own gesture")
192 }
193
175194 func testNativeLinkRetainsItsTapRecognizerAcrossUnrelatedUpdates() throws {
176195 let controller = CraftNativeScreenController(config: CraftConfig())
177196 controller.loadViewIfNeeded()
@@ -226,6 +245,46 @@ final class NativeNavigationUnitTests: XCTestCase {
226245 XCTAssertFalse(text.isScrollEnabled)
227246 }
228247
248 func testKeyedRowButtonKeepsItsNativeHitTargetAndDispatchAfterInputReordersRows() async throws {
249 let home = try XCTUnwrap(stack("/"))
250 let screen = try XCTUnwrap(home.topViewController as? CraftNativeScreenController)
251 func find(_ id: String, in view: UIView) -> UIView? {
252 if view.accessibilityIdentifier == id { return view }
253 return view.subviews.lazy.compactMap { find(id, in: $0) }.first
254 }
255 await displayWait("keyed input mounted") { find("navigation-input-b", in: screen.view) != nil }
256 let input = try XCTUnwrap(find("navigation-input-b", in: screen.view) as? UITextField)
257 XCTAssertTrue(input.becomeFirstResponder())
258 input.text = "draft"
259 input.sendActions(for: .editingChanged)
260 await displayWait("input-driven keyed reorder") {
261 find("navigation-input-c", in: screen.view) != nil
262 }
263 XCTAssertTrue(input === find("navigation-input-b", in: screen.view))
264 let button = try XCTUnwrap(find("navigation-select-b", in: screen.view) as? UIButton)
265 let scroll = try XCTUnwrap(find("native-root-scroll", in: screen.view) as? UIScrollView)
266 // Becoming first responder animates the scroll view's bounds. During
267 // that transition UIKit legitimately keeps the scroll view as the hit
268 // target. Sample the retained button after the native geometry settles.
269 await displayWait("keyed row keyboard geometry settled", diagnostic: {
270 "bounds=\(scroll.bounds), animations=\(scroll.layer.animationKeys() ?? [])"
271 }) {
272 screen.view.layoutIfNeeded()
273 return scroll.layer.animationKeys()?.isEmpty ?? true
274 }
275 scroll.scrollRectToVisible(button.convert(button.bounds, to: scroll), animated: false)
276 window.layoutIfNeeded()
277 await renderFrames()
278 let point = button.convert(CGPoint(x: button.bounds.midX, y: button.bounds.midY), to: window)
279 let hit = window.hitTest(point, with: nil)
280 XCTAssertTrue(hit === button || hit?.isDescendant(of: button) == true,
281 "The retained button's visible center hit \(String(describing: hit)) at \(point), button frame \(button.frame)")
282 button.sendActions(for: .touchUpInside)
283 await displayWait("retained row button callback") {
284 (find("keyed-list-status", in: screen.view) as? UILabel)?.text == "Selected: b"
285 }
286 }
287
229288 func testRepeatedSelectorsRetainDistinctProtocolViewsAcrossReorders() throws {
230289 let controller = CraftNativeScreenController(config: CraftConfig())
231290 window.rootViewController = controller
packages/ios/fixtures/native-screen/NativeRenderUITests.swiftmodified+8-1
Changes to packages/ios/fixtures/native-screen/NativeRenderUITests.swift
@@ -39,7 +39,14 @@ final class NativeRenderUITests: XCTestCase {
3939 notes.tap()
4040 XCTAssertTrue(keyboardIsVisible(app))
4141 notes.typeText("Easy run")
42 XCTAssertEqual(app.staticTexts["notes"].label, "Notes: Easy run")
42 // Read the original acknowledgement first: inspecting other UIKit
43 // elements must not hide a delayed bridge update from this assertion.
44 let acknowledgement = app.staticTexts["notes"].label
45 let typedValue = notes.value as? String
46 let keyboardVisible = app.keyboards.firstMatch.exists
47 XCTAssertEqual(typedValue, "Easy run", "the native text view lost typed characters")
48 XCTAssertTrue(keyboardVisible, "typing dismissed the native keyboard")
49 XCTAssertEqual(acknowledgement, "Notes: Easy run", "native value: \(typedValue ?? "nil"), keyboard visible: \(keyboardVisible)")
4350 }
4451
4552 func testSwitchTapChangesUIKitStateAndDeliversValueChange() throws {
packages/ios/fixtures/native-screen/NativeRenderUnitTests.swiftmodified+66-0
Changes to packages/ios/fixtures/native-screen/NativeRenderUnitTests.swift
@@ -1942,6 +1942,72 @@ final class NativeRenderUnitTests: XCTestCase {
19421942 XCTAssertEqual(proof.font?.pointSize, 22)
19431943 }
19441944
1945 func testRetainedRowButtonStillDispatchesAfterItsPreviousSiblingIsRemoved() throws {
1946 let controller = CraftNativeScreenController(config: CraftConfig())
1947 controller.loadViewIfNeeded()
1948 controller.evaluateScript("globalThis.__rowPress = ''; globalThis.__stxNativeCallback = function(json) { const message = JSON.parse(json); if (message.type === 'EVENT') globalThis.__rowPress = message.payload.handlerName; };")
1949 func row(_ id: String) -> [String: Any] {
1950 ["id": "row-\(id)", "type": "View", "props": ["key": id], "children": [
1951 ["id": "input-\(id)", "type": "TextInput", "props": ["key": "input-\(id)", "defaultValue": "Draft"]],
1952 ["id": "button-\(id)", "type": "Button", "props": ["key": "button-\(id)", "title": "Select row"], "events": ["onPress": "select-\(id)"]],
1953 ]]
1954 }
1955 controller.render(document([row("a"), row("b")]))
1956 let button = try XCTUnwrap(find(UIButton.self, key: "button-b", below: controller.view))
1957 controller.render(document([row("b"), row("c")]))
1958 XCTAssertTrue(button === find(UIButton.self, key: "button-b", below: controller.view))
1959 button.sendActions(for: .touchUpInside)
1960 XCTAssertEqual(controller.evaluateScript("__rowPress")?.toString(), "select-b")
1961 }
1962
1963 func testScrollCanCancelButtonAndFieldTrackingWithoutTakingValueControls() throws {
1964 let controller = CraftNativeScreenController(config: CraftConfig())
1965 controller.loadViewIfNeeded()
1966 controller.render(document([[
1967 "type": "ScrollView", "props": ["key": "form"], "style": ["height": 300], "children": [
1968 ["type": "Button", "props": ["key": "action", "title": "Save"]],
1969 ["type": "Slider", "props": ["key": "position", "value": 0.5]],
1970 ["type": "Switch", "props": ["key": "toggle", "value": false]],
1971 ["type": "TextInput", "props": ["key": "draft", "value": "Draft"]],
1972 ],
1973 ]]))
1974 let form = try XCTUnwrap(find(UIScrollView.self, key: "form", below: controller.view))
1975 XCTAssertTrue(form.touchesShouldCancel(in: try XCTUnwrap(find(UIButton.self, key: "action", below: controller.view))))
1976 XCTAssertFalse(form.touchesShouldCancel(in: try XCTUnwrap(find(UISlider.self, key: "position", below: controller.view))))
1977 XCTAssertFalse(form.touchesShouldCancel(in: try XCTUnwrap(find(UISwitch.self, key: "toggle", below: controller.view))))
1978 XCTAssertTrue(form.touchesShouldCancel(in: try XCTUnwrap(find(UITextField.self, key: "draft", below: controller.view))))
1979 }
1980
1981 func testScrollKeyboardTapPreservesTextAreaFocusAndSelectionButDismissesBackground() throws {
1982 let controller = CraftNativeScreenController(config: CraftConfig())
1983 let window = try hostWindow()
1984 window.rootViewController = controller
1985 window.makeKeyAndVisible()
1986 defer { window.isHidden = true }
1987 controller.render(document([
1988 ["type": "ScrollView", "props": ["key": "form"], "style": ["height": 300], "children": [
1989 ["type": "TextInput", "props": ["key": "review", "multiline": true, "value": "A beautiful ridge"], "style": ["height": 120]],
1990 ]],
1991 ]))
1992 let form = try XCTUnwrap(find(UIScrollView.self, key: "form", below: controller.view))
1993 let review = try XCTUnwrap(find(UITextView.self, key: "review", below: controller.view))
1994 XCTAssertTrue(review.becomeFirstResponder())
1995 review.selectedRange = NSRange(location: 2, length: 9)
1996 let selectionSurface = UIView()
1997 selectionSurface.addGestureRecognizer(UITapGestureRecognizer())
1998 review.addSubview(selectionSurface)
1999 var forgotFocus = false
2000 for policy in ["never", "handled", "always"] {
2001 craftHandleKeyboardTap(in: form, hit: selectionSurface, persistTaps: policy) { forgotFocus = true }
2002 XCTAssertTrue(review.isFirstResponder, policy)
2003 XCTAssertEqual(review.selectedRange, NSRange(location: 2, length: 9), policy)
2004 XCTAssertFalse(forgotFocus, policy)
2005 }
2006 craftHandleKeyboardTap(in: form, hit: form, persistTaps: "never") { forgotFocus = true }
2007 XCTAssertFalse(review.isFirstResponder)
2008 XCTAssertTrue(forgotFocus)
2009 }
2010
19452011 func testTextInputsPadTheirTextAndAskForAutofill() throws {
19462012 let controller = CraftNativeScreenController(config: CraftConfig())
19472013 controller.loadViewIfNeeded()
packages/ios/scripts/native-navigation-groups.tsadded+23-0
Changes to packages/ios/scripts/native-navigation-groups.ts
@@ -0,0 +1,23 @@
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}
14
15/** An ignored XCTest selector must not silently reduce required coverage. */
16export function verifyNativeNavigationResults(counts: Record<string, unknown>, expectedTestCount?: number): number {
17 const count = counts.totalTestCount
18 if (counts.result !== 'Passed' || typeof count !== 'number' || !Number.isInteger(count) || count < 1
19 || counts.passedTests !== count || counts.failedTests !== 0 || counts.skippedTests !== 0
20 || (expectedTestCount !== undefined && count !== expectedTestCount))
21 throw new Error(`Expected complete native navigation coverage (${expectedTestCount ?? 'nonempty'} tests): ${JSON.stringify(counts)}`)
22 return count
23}
packages/ios/scripts/test-native-navigation.tsmodified+9-5
Changes to packages/ios/scripts/test-native-navigation.ts
@@ -2,6 +2,7 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from
22import { join, resolve } from 'node:path'
33import { bootSimulator, build, init, pickSimulator } from '../src/index'
44import { addSchemeTestTargets, insertProjectTargetsBeforeSchemes } from './insert-project-targets'
5import { nativeNavigationGroup, verifyNativeNavigationResults } from './native-navigation-groups'
56import { nativeNavigationResultBundle } from './native-navigation-result'
67import { createTempDir, keepTempDir, removeTempDir } from '../../typescript/src/temp-dir'
78
@@ -20,7 +21,7 @@ function run(args: string[], cwd: string): void {
2021 throw new Error(`${args[0]} exited with ${result.exitCode}`)
2122}
2223
23function runXcodeTests(args: string[], cwd: string, resultBundle: string): void {
24function runXcodeTests(args: string[], cwd: string, resultBundle: string, expectedTestCount?: number): void {
2425 console.log(args.join(' '))
2526 const result = Bun.spawnSync(args, { cwd, stdout: 'inherit', stderr: 'inherit' })
2627 const evidence = process.env.CRAFT_NATIVE_NAVIGATION_RESULT
@@ -29,8 +30,7 @@ function runXcodeTests(args: string[], cwd: string, resultBundle: string): void
2930 if (summary.exitCode !== 0) throw new Error(`Could not read XCTest results: ${summary.stderr.toString()}`)
3031 const counts = JSON.parse(summary.stdout.toString())
3132 if (evidence) writeFileSync(`${resolve(evidence)}.summary.json`, summary.stdout)
32 if (counts.result !== 'Passed' || !Number.isInteger(counts.totalTestCount) || counts.totalTestCount < 1 || counts.passedTests !== counts.totalTestCount || counts.failedTests !== 0 || counts.skippedTests !== 0)
33 throw new Error(`Expected passed native navigation tests with no empty selection or skips: ${JSON.stringify(counts)}`)
33 verifyNativeNavigationResults(counts, expectedTestCount)
3434 console.log(`XCTest verified ${counts.passedTests} passed tests, zero failures and skips`)
3535 return
3636 }
@@ -140,7 +140,10 @@ try {
140140 const resultBundle = nativeNavigationResultBundle(workspace, process.env.CRAFT_NATIVE_NAVIGATION_RESULT)
141141 const selectedTest = process.env.CRAFT_NATIVE_NAVIGATION_TEST
142142 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
143 const group = process.env.CRAFT_NATIVE_NAVIGATION_GROUP
144 if (group && (selectedTarget || selectedTest)) throw new Error('A required native UI group cannot be combined with focused selectors')
145 const groupCases = group ? nativeNavigationGroup(readFileSync(join(fixture, 'NativeNavigationUITests.swift'), 'utf8'), group) : undefined
146 const selection = groupCases ? groupCases.map(target => `-only-testing:${target}`) : selectedTarget ? selectedTarget.split(',').map(target => target.trim()).filter(Boolean).map(target => `-only-testing:${target}`) : selectedTest
144147 ? [`-only-testing:NativeNavigationUITests/NativeNavigationUITests/${selectedTest}`]
145148 : []
146149 const xcodeArgs = [
@@ -153,11 +156,12 @@ try {
153156 // launching a hosted XCTest bundle, even after compilation has finished.
154157 run([...xcodeArgs, 'build-for-testing'], output)
155158 runXcodeTests([
159 // Retain live XCTest activity as well as the structured result bundle.
156160 ...xcodeArgs.filter(arg => arg !== '-quiet'),
157161 '-resultBundlePath', resultBundle,
158162 ...selection,
159163 'test-without-building',
160 ], output, resultBundle)
164 ], output, resultBundle, groupCases?.length)
161165 console.log('Native navigation simulator tests passed')
162166 }
163167}
packages/ios/src/index.test.tsmodified+1-0
Changes to packages/ios/src/index.test.ts
@@ -306,6 +306,7 @@ describe('Craft iOS builder', () => {
306306 expect(nativeSwift).toContain('case "cover": return .scaleAspectFill')
307307 expect(nativeSwift).toContain('private func restoreFocus(_ focused: UIView?)')
308308 expect(nativeSwift).toContain('if rootStyle["flex"] == nil, rootStyle["height"] == nil { rootStyle["flex"] = 1 }')
309 expect(nativeSwift).toContain('let lifecycleHandler = visible ? events["onShow"] : events["onDismiss"]')
309310 expect(nativeSwift).toContain('if let lifecycleHandler = nonEmptyHandler(lifecycleHandler) {')
310311 expect(nativeSwift).not.toContain('consumeDismissSuppression')
311312 expect(nativeSwift).toContain('_ = focused.becomeFirstResponder()')
packages/ios/src/navigation-groups.test.tsadded+43-0
Changes to packages/ios/src/navigation-groups.test.ts
@@ -0,0 +1,43 @@
1import { expect, test } from 'bun:test'
2import { readFileSync } from 'node:fs'
3import { join } from 'node:path'
4import { nativeNavigationGroup, verifyNativeNavigationResults } 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})
30
31test('XCTest must execute every selected case without failures or skips', () => {
32 const expected = nativeNavigationGroup(source, 'core').length
33 const passed = { result: 'Passed', totalTestCount: expected, passedTests: expected, failedTests: 0, skippedTests: 0 }
34 expect(verifyNativeNavigationResults(passed, expected)).toBe(expected)
35 expect(() => verifyNativeNavigationResults({ ...passed, totalTestCount: expected - 1, passedTests: expected - 1 }, expected)).toThrow('complete')
36 for (const incomplete of [
37 { ...passed, result: 'Failed' },
38 { ...passed, totalTestCount: 0, passedTests: 0 },
39 { ...passed, passedTests: expected - 1 },
40 { ...passed, failedTests: 1 },
41 { ...passed, skippedTests: 1 },
42 ]) expect(() => verifyNativeNavigationResults(incomplete, expected)).toThrow('complete')
43})
packages/ios/templates/CraftHybrid.swiftmodified+8-1
Changes to packages/ios/templates/CraftHybrid.swift
@@ -2080,7 +2080,14 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
20802080 /// The edge swipe goes ahead of the web view's own gestures, which
20812081 /// otherwise hold every touch over the page until the page has answered it.
20822082 func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
2083 gestureRecognizer === visibleStack?.interactivePopGestureRecognizer
2083 if gestureRecognizer === visibleStack?.interactivePopGestureRecognizer { return true }
2084 guard tabSwipeRecognizers.contains(where: { $0 === gestureRecognizer }),
2085 let scroll = otherGestureRecognizer.view as? UIScrollView,
2086 otherGestureRecognizer === scroll.panGestureRecognizer,
2087 scroll.isScrollEnabled else { return false }
2088 // A vertical list must not suppress the shell's horizontal tab pan.
2089 // Horizontal content keeps ownership of its own drag instead.
2090 return !scroll.alwaysBounceHorizontal && scroll.contentSize.width <= scroll.bounds.width + 1
20842091 }
20852092
20862093 func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool {
packages/ios/templates/CraftNativeFlatList.swiftmodified+2-17
Changes to packages/ios/templates/CraftNativeFlatList.swift
@@ -371,24 +371,9 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
371371 @objc private func keyboardTap() {
372372 guard keyboardShouldPersistTaps != "always" else { return }
373373 let point = keyboardTapGesture.location(in: self)
374 var hit = hitTest(point, with: nil)
375 var onControl = false
376 var onButton = false
377 while let view = hit, view !== self {
378 if view is UIButton { onButton = true }
379 if view is UIControl || !(view.gestureRecognizers ?? []).isEmpty { onControl = true; break }
380 hit = view.superview
374 craftHandleKeyboardTap(in: self, hit: hitTest(point, with: nil), persistTaps: keyboardShouldPersistTaps) {
375 self.craftScreenController?.forgetFocusedInput()
381376 }
382 if keyboardShouldPersistTaps == "handled", onControl { return }
383 // A button keeps the keyboard: its press gives the focus straight
384 // back. Ending editing first only let the keyboard's inset go while
385 // the finger was down, the button moved out from under it, and the
386 // press was lost.
387 if onButton { return }
388 // Otherwise the keyboard goes; after a tap on nothing, for good (the
389 // next render must not bring the last input back).
390 if !onControl { craftScreenController?.forgetFocusedInput() }
391 window?.endEditing(true)
392377 }
393378
394379 func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
packages/ios/templates/CraftNativeScreen.swiftmodified+20-18
Changes to packages/ios/templates/CraftNativeScreen.swift
@@ -3954,6 +3954,23 @@ func craftScrollGesture(_ gesture: UIGestureRecognizer, runsWith other: UIGestur
39543954 return false
39553955}
39563956
3957/// Scroll containers observe taps without cancelling native input focus or
3958/// selection. Text views are not UIControls, and their selection subviews may
3959/// have recognizers of their own, so inspect the whole ancestor chain.
3960func craftHandleKeyboardTap(in container: UIView, hit: UIView?, persistTaps: String, forgetFocus: () -> Void) {
3961 guard persistTaps != "always" else { return }
3962 var candidate = hit
3963 var onControl = false
3964 while let view = candidate, view !== container {
3965 if view is UITextField || view is UITextView || view is UIButton { return }
3966 if view is UIControl || !(view.gestureRecognizers ?? []).isEmpty { onControl = true }
3967 candidate = view.superview
3968 }
3969 if persistTaps == "handled", onControl { return }
3970 if !onControl { forgetFocus() }
3971 container.window?.endEditing(true)
3972}
3973
39573974private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDelegate {
39583975 let contentStack = CraftNativeFlowView()
39593976 private let keyboardTapGesture = UITapGestureRecognizer()
@@ -4226,24 +4243,9 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
42264243 @objc private func keyboardTap() {
42274244 guard keyboardShouldPersistTaps != "always" else { return }
42284245 let point = keyboardTapGesture.location(in: self)
4229 var hit = hitTest(point, with: nil)
4230 var onControl = false
4231 var onButton = false
4232 while let view = hit, view !== self {
4233 if view is UIButton { onButton = true }
4234 if view is UIControl || !(view.gestureRecognizers ?? []).isEmpty { onControl = true; break }
4235 hit = view.superview
4236 }
4237 if keyboardShouldPersistTaps == "handled", onControl { return }
4238 // A button keeps the keyboard: its press gives the focus straight
4239 // back. Ending editing first only let the keyboard's inset go while
4240 // the finger was down, the button moved out from under it, and the
4241 // press was lost.
4242 if onButton { return }
4243 // Otherwise the keyboard goes; after a tap on nothing, for good (the
4244 // next render must not bring the last input back).
4245 if !onControl { craftScreenController?.forgetFocusedInput() }
4246 window?.endEditing(true)
4246 craftHandleKeyboardTap(in: self, hit: hitTest(point, with: nil), persistTaps: keyboardShouldPersistTaps) {
4247 self.craftScreenController?.forgetFocusedInput()
4248 }
42474249 }
42484250
42494251 func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
packages/typescript/fixtures/media-cache/media-cache.ktmodified+47-6
Changes to packages/typescript/fixtures/media-cache/media-cache.kt
@@ -1,3 +1,14 @@
1/** Holds a completed attempt in RUNNING, before WorkManager commits its result. */
2class SettledMediaAttemptWorker(context: Context, parameters: androidx.work.WorkerParameters) : androidx.work.Worker(context, parameters) {
3 private val released = java.util.concurrent.CountDownLatch(1)
4 override fun doWork(): Result {
5 setProgressAsync(androidx.work.workDataOf("craft.media.attemptSettled" to true)).get()
6 released.await(30, java.util.concurrent.TimeUnit.SECONDS)
7 return Result.retry()
8 }
9 override fun onStopped() { released.countDown() }
10}
11
112class DownloadProbe : android.app.Instrumentation() {
213 override fun onCreate(arguments: android.os.Bundle?) {
314 super.onCreate(arguments)
@@ -23,6 +34,13 @@ class DownloadProbe : android.app.Instrumentation() {
2334 check(done.await(25, java.util.concurrent.TimeUnit.SECONDS), "operation settled")
2435 return ok
2536 }
37 private fun awaitCached(address: String) {
38 // The worker authors a 30-second first retry. Allow that backoff plus
39 // the existing 25-second transfer budget, without issuing another keep.
40 val until = System.currentTimeMillis() + 60000
41 while (CraftNativeMedia.local(targetContext, address) == null && System.currentTimeMillis() < until) Thread.sleep(25)
42 check(CraftNativeMedia.local(targetContext, address) != null, "the durable worker recovers the complete file without another keep")
43 }
2644 override fun onStart() {
2745 val result = android.os.Bundle()
2846 try {
@@ -80,10 +98,11 @@ class DownloadProbe : android.app.Instrumentation() {
8098 val until = System.currentTimeMillis() + 25000
8199 while (!partial() && System.currentTimeMillis() < until) Thread.sleep(25)
82100 check(partial(), "download is in progress before process death")
101 check(CraftNativeMedia.local(targetContext, restart) == null, "interrupted process never published a partial video")
83102 android.os.Process.killProcess(android.os.Process.myPid())
84103 }
85104 "recover" -> {
86 check(CraftNativeMedia.local(targetContext, restart) == null, "interrupted process never published a partial video")
105 awaitCached(restart)
87106 check(keep(restart), "retry after process death downloads a complete video")
88107 val media = android.media.MediaMetadataRetriever()
89108 try {
@@ -98,12 +117,34 @@ class DownloadProbe : android.app.Instrumentation() {
98117 check(!keep(broken), "truncated transfer fails")
99118 check(CraftNativeMedia.local(targetContext, broken) == null, "truncated transfer is not playable cache")
100119 val root = CraftNativeMedia.file(targetContext, broken).parentFile!!
101 val brokenTarget = CraftNativeMedia.file(targetContext, broken)
102 check(root.listFiles()?.none { it.name.endsWith(".part") && it.name != "${brokenTarget.name}.part" } != false, "a failed transfer leaves only its own part, to resume")
103 check(keep(broken), "same URL retries successfully, at once")
104 check(root.listFiles()?.none { it.name.endsWith(".part") } != false, "a finished transfer leaves no staging files")
105 val queued = "$base/queued.mp4"
120 check(CraftNativeMedia.part(targetContext, broken).length() > 0, "a truncated transfer retains bytes for resumption")
121 awaitCached(broken)
122 check(keep(broken), "same URL retries successfully")
123 val retry = "$base/retry.mp4"
124 check(!keep(retry), "a separate interrupted transfer reports its failure")
125 check(keep(retry), "an explicit retry runs immediately, without waiting for backoff")
126 check(root.listFiles()?.none { it.name.endsWith(".part") } != false, "finished retries leave no staging files")
106127 val work = androidx.work.WorkManager.getInstance(targetContext)
128 val settled = "$base/settled.mp4"
129 val settledName = "craft-media:${CraftNativeMedia.digest(settled)}:any"
130 val settledRequest = androidx.work.OneTimeWorkRequestBuilder<SettledMediaAttemptWorker>()
131 .addTag("craft-media").addTag("craft-media:${CraftNativeMedia.digest(settled)}")
132 .addTag("craft-media:generation:${CraftNativeMedia.generation(targetContext)}").build()
133 work.enqueueUniqueWork(settledName, androidx.work.ExistingWorkPolicy.KEEP, settledRequest).result.get()
134 val settledDeadline = System.currentTimeMillis() + 10000
135 var settledInfo = work.getWorkInfoById(settledRequest.id).get()!!
136 while ((settledInfo.state != androidx.work.WorkInfo.State.RUNNING || !settledInfo.progress.getBoolean("craft.media.attemptSettled", false)) && System.currentTimeMillis() < settledDeadline) {
137 Thread.sleep(25)
138 settledInfo = work.getWorkInfoById(settledRequest.id).get()!!
139 }
140 check(settledInfo.state == androidx.work.WorkInfo.State.RUNNING && settledInfo.progress.getBoolean("craft.media.attemptSettled", false), "the settled attempt still has WorkManager's running identity")
141 check(keep(settled), "an explicit keep replaces a settled running attempt immediately")
142 // REPLACE can prune the old WorkSpec. Inspect the current
143 // unique chain rather than requiring the removed row.
144 val replacement = work.getWorkInfosForUniqueWork(settledName).get()
145 check(replacement.size == 1 && replacement.single().id != settledRequest.id && replacement.single().state != androidx.work.WorkInfo.State.CANCELLED, "the settled work identity was replaced, not joined")
146
147 val queued = "$base/queued.mp4"
107148 val queuedName = "craft-media:${CraftNativeMedia.digest(queued)}:any"
108149 val queuedRequest = androidx.work.OneTimeWorkRequestBuilder<CraftNativeMediaWorker>()
109150 .setInitialDelay(10, java.util.concurrent.TimeUnit.SECONDS)
packages/typescript/src/__tests__/android-emulator.test.tsmodified+63-2
Changes to packages/typescript/src/__tests__/android-emulator.test.ts
@@ -5,7 +5,7 @@ import { join } from 'node:path'
55import { createAndroidEmulator } from '../../../android/src/emulator'
66import type { AndroidEmulatorOptions } from '../../../android/src/emulator'
77
8function fixture(hardware: boolean, delayedSettings = false, delayedEvents = false, delayedHome = false, connection: 'normal' | 'stale' | 'offline' = 'normal'): { root: string, options: AndroidEmulatorOptions } {
8function fixture(hardware: boolean, delayedSettings = false, delayedEvents = false, delayedHome = false, connection: 'normal' | 'stale' | 'offline' = 'normal', bootstrapHome = 'com.android.sdksetup/.DefaultActivity', playStore: 'ready' | 'delayed' | 'missing' = 'ready'): { root: string, options: AndroidEmulatorOptions } {
99 const root = mkdtempSync(join(tmpdir(), 'craft emulator fixture '))
1010 const sdk = join(root, 'managed sdk')
1111 for (const directory of ['bin', 'platform-tools', 'emulator']) mkdirSync(join(sdk, directory), { recursive: true })
@@ -41,11 +41,18 @@ else
4141 fi
4242 printf '1\\n' ;;
4343 *'pm path android') printf 'package:/system/framework/framework-res.apk\\n' ;;
44 *'pm path com.android.vending')
45 if [ '${playStore}' = missing ]; then exit 1; fi
46 if [ '${playStore}' = delayed ] && [ ! -f "$ANDROID_HOME/play-ready" ]; then
47 touch "$ANDROID_HOME/play-ready"
48 exit 1
49 fi
50 printf 'package:/product/priv-app/Phonesky/Phonesky.apk\\n' ;;
4451 *'pidof com.android.systemui') printf '1234\\n' ;;
4552 *'cmd package resolve-activity --brief -a android.intent.action.MAIN -c android.intent.category.HOME')
4653 if [ '${delayedHome ? 'yes' : 'no'}' = yes ] && [ ! -f "$ANDROID_HOME/home-ready" ]; then
4754 touch "$ANDROID_HOME/home-ready"
48 printf 'com.android.sdksetup/.DefaultActivity\\n'
55 printf '${bootstrapHome}\\n'
4956 else
5057 printf 'com.android.launcher3/.Launcher\\n'
5158 fi ;;
@@ -170,6 +177,48 @@ test('boot and System UI do not admit a device before its event log is readable'
170177 finally { await session?.dispose(); rmSync(root, { recursive: true, force: true }) }
171178}, 10_000)
172179
180test('a Play image waits for its bundled store after framework and HOME readiness', async () => {
181 const { root, options } = fixture(true, false, false, false, 'normal', 'com.android.sdksetup/.DefaultActivity', 'delayed')
182 let session: Awaited<ReturnType<typeof createAndroidEmulator>> | undefined
183 try {
184 session = await createAndroidEmulator(options)
185 const trace = readFileSync(join(options.artifactsDirectory, 'device-commands.jsonl'), 'utf8').trim().split('\n').map(line => JSON.parse(line))
186 const store = trace.filter(entry => entry.args.includes('com.android.vending'))
187 expect(store.map(entry => entry.code)).toEqual([1, 0])
188 const identity = JSON.parse(readFileSync(join(options.artifactsDirectory, 'android-emulator.json'), 'utf8'))
189 expect(Date.parse(store[1].finishedAt)).toBeLessThanOrEqual(Date.parse(identity.bootedAt))
190 expect(identity.bootTimeoutMs).toBe(600_000)
191 }
192 finally { await session?.dispose(); rmSync(root, { recursive: true, force: true }) }
193}, 10_000)
194
195test('a missing Play package fails the existing boot deadline and disposes its owned device', async () => {
196 const { root, options } = fixture(true, false, false, false, 'normal', 'com.android.sdksetup/.DefaultActivity', 'missing')
197 options.bootTimeoutMs = 5000
198 let session: Awaited<ReturnType<typeof createAndroidEmulator>> | undefined
199 try {
200 await expect((async () => { session = await createAndroidEmulator(options) })()).rejects.toThrow('did not boot within 5000ms')
201 const trace = readFileSync(join(options.artifactsDirectory, 'device-commands.jsonl'), 'utf8').trim().split('\n').map(line => JSON.parse(line))
202 expect(trace.filter(entry => entry.args.includes('com.android.vending')).length).toBeGreaterThan(0)
203 const identity = JSON.parse(readFileSync(join(options.artifactsDirectory, 'android-emulator.json'), 'utf8'))
204 expect(identity.status).toBe('failed')
205 expect(existsSync(identity.avdDirectory)).toBe(false)
206 expect(() => process.kill(identity.emulatorPid, 0)).toThrow()
207 }
208 finally { await session?.dispose(); rmSync(root, { recursive: true, force: true }) }
209}, 10_000)
210
211test('a non-Play image does not require an unrelated store package', async () => {
212 const { root, options } = fixture(true, false, false, false, 'normal', 'com.android.sdksetup/.DefaultActivity', 'missing')
213 options.image = options.image.replace(';google_apis_playstore;', ';google_apis;')
214 let session: Awaited<ReturnType<typeof createAndroidEmulator>> | undefined
215 try {
216 session = await createAndroidEmulator(options)
217 expect(readFileSync(join(options.sdkRoot, 'calls'), 'utf8')).not.toContain('com.android.vending')
218 }
219 finally { await session?.dispose(); rmSync(root, { recursive: true, force: true }) }
220}, 10_000)
221
173222test('boot completion waits for the automatic SDK setup activity to release HOME', async () => {
174223 const { root, options } = fixture(true, false, false, true)
175224 let session: Awaited<ReturnType<typeof createAndroidEmulator>> | undefined
@@ -185,6 +234,18 @@ test('boot completion waits for the automatic SDK setup activity to release HOME
185234 finally { await session?.dispose(); rmSync(root, { recursive: true, force: true }) }
186235}, 10_000)
187236
237test.each(['com.android.settings/.FallbackHome', 'android/com.android.internal.app.ResolverActivity'])('boot readiness rejects the temporary HOME destination %s', async (bootstrapHome) => {
238 const { root, options } = fixture(true, false, false, true, 'normal', bootstrapHome)
239 let session: Awaited<ReturnType<typeof createAndroidEmulator>> | undefined
240 try {
241 session = await createAndroidEmulator(options)
242 const trace = readFileSync(join(options.artifactsDirectory, 'device-commands.jsonl'), 'utf8').trim().split('\n').map(line => JSON.parse(line))
243 const home = trace.filter(entry => entry.args.includes('resolve-activity'))
244 expect(home.map(entry => entry.stdout.trim())).toEqual([bootstrapHome, 'com.android.launcher3/.Launcher'])
245 }
246 finally { await session?.dispose(); rmSync(root, { recursive: true, force: true }) }
247}, 10_000)
248
188249test('the SDK process fixture records hardware mode and reaps only its owned process', async () => {
189250 const { root, options } = fixture(true)
190251 options.bootTimeoutMs = 42_000
packages/typescript/src/__tests__/bridge-request-id.test.tsmodified+5-2
Changes to packages/typescript/src/__tests__/bridge-request-id.test.ts
@@ -336,14 +336,17 @@ describe('bridge reply correlation', () => {
336336
337337 it('puts a distinct id on every message, fire-and-forget included', async () => {
338338 const h = loadBridge()
339 h.craft.tags.get('/a')
339 const tags = h.craft.tags.get('/a')
340340 h.craft.keychain.set('svc', 'acct', 'pw') // _send, no reply expected
341341 h.craft.window.show?.()
342 h.craft.autoLaunch.isEnabled()
342 const autoLaunch = h.craft.autoLaunch.isEnabled()
343343
344344 const ids = h.sent.map(m => m.i)
345345 expect(ids.every(i => typeof i === 'number' && i > 0)).toBe(true)
346346 expect(new Set(ids).size).toBe(ids.length)
347 h.reply('get', { tags: [] }, h.sent[0].i!)
348 h.reply('isEnabled', { value: true }, h.sent.at(-1)!.i!)
349 await Promise.all([tags, autoLaunch])
347350 })
348351
349352 it('reports a fire-and-forget failure rather than rejecting a live call', async () => {
packages/typescript/src/__tests__/mobile-media-testing.test.tsmodified+11-0
Changes to packages/typescript/src/__tests__/mobile-media-testing.test.ts
@@ -1,5 +1,16 @@
11import { expect, test } from 'bun:test'
22import { verifyMobileMediaCache } from '../mobile-testing'
3import { androidMediaProbeSource } from '../mobile-media-testing'
4
5test('the Android media probe includes the shipped resumable downloader and durable worker', async () => {
6 const shell = await Bun.file(new URL('../../../android/templates/CraftNativeShell.kt.template', import.meta.url)).text()
7 const source = androidMediaProbeSource(shell)
8 expect(source).toContain('internal object CraftNativeMedia {')
9 expect(source).toContain('internal class CraftResumableDownload(')
10 expect(source).toContain('internal class CraftNativeMediaWorker(')
11 expect(source).not.toContain('internal class CraftNativeChunks')
12 expect(() => androidMediaProbeSource(source.replace('internal class CraftNativeMediaWorker', 'class MissingWorker'))).toThrow('CraftNativeMediaWorker')
13})
314
415test('media cache tests reject an unknown platform before creating a probe', async () => {
516 await expect(verifyMobileMediaCache({ platform: 'desktop' as any })).rejects.toThrow('Choose ios or android')
packages/typescript/src/mobile-media-testing.tsmodified+15-9
Changes to packages/typescript/src/mobile-media-testing.ts
@@ -19,6 +19,15 @@ export interface MobileMediaCacheTestOptions {
1919 log?: (line: string) => void
2020}
2121
22/** Keep the probe on the complete shipped downloader, including its durable worker. */
23export function androidMediaProbeSource(shell: string): string {
24 return ['internal object CraftNativeMedia', 'internal class CraftResumableDownload', 'internal class CraftNativeMediaWorker'].map(declaration => {
25 const source = shell.match(new RegExp(`${declaration}\\b[\\s\\S]*?\\n\\}`))?.[0]
26 if (!source) throw new Error(`Android media implementation missing: ${declaration}`)
27 return source
28 }).join('\n\n')
29}
30
2231/** Real ENOSPC, cache cleanup, retry, coalescing and offline video decode on an isolated native probe. */
2332export async function verifyMobileMediaCache(options: MobileMediaCacheTestOptions): Promise<{ platform: string, requests: Record<string, number> }> {
2433 if (options.platform !== 'ios' && options.platform !== 'android') throw new Error('Choose ios or android')
@@ -81,7 +90,7 @@ export async function verifyMobileMediaCache(options: MobileMediaCacheTestOption
8190 }
8291 const empty = path.endsWith('/empty.mp4')
8392 const partial = path.endsWith('/partial.mp4')
84 const cut = (path.endsWith('/broken.mp4') || path.endsWith('/restart.mp4')) && n === 1
93 const cut = (path.endsWith('/broken.mp4') || path.endsWith('/retry.mp4') || path.endsWith('/restart.mp4')) && n === 1
8594 const slow = path.endsWith('/slow.mp4') || (path.endsWith('/restart.mp4') && n === 1)
8695 socket.write(`HTTP/1.1 ${partial ? '206 Partial Content' : '200 OK'}\r\nContent-Type: video/mp4\r\nContent-Length: ${empty ? 0 : bytes.length}\r\nConnection: close\r\n\r\n`)
8796 if (empty) { socket.end();
@@ -176,12 +185,7 @@ export async function verifyMobileMediaCache(options: MobileMediaCacheTestOption
176185 serial = options.device || ''
177186 if (!/^emulator-\d+$/.test(serial)) throw new Error('Select a disposable Android emulator with ANDROID_SERIAL')
178187 const shell = await Bun.file(join(root, 'android/templates/CraftNativeShell.kt.template')).text()
179 // The media object, and what it hands transfers to: the WorkManager
180 // worker that runs each keep and the resumable fetch it makes.
181 const pieces = ['internal object CraftNativeMedia {', 'internal class CraftResumableDownload(', 'internal class CraftNativeMediaWorker(']
182 .map(start => shell.match(new RegExp(`${start.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?\\n\\}`))?.[0])
183 if (pieces.some(piece => !piece)) throw new Error('Android media implementation missing')
184 const media = pieces.join('\n\n')
188 const media = androidMediaProbeSource(shell)
185189 const android = join(scratch, 'android')
186190 const app = join(android, 'app')
187191 const sources = join(app, 'src/main/java/dev/craft/mediaprobe')
@@ -189,7 +193,7 @@ export async function verifyMobileMediaCache(options: MobileMediaCacheTestOption
189193 await Bun.write(join(android, 'settings.gradle.kts'), 'pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } }\ndependencyResolutionManagement { repositories { google(); mavenCentral() } }\nrootProject.name = "HQDownloadProbe"\ninclude(":app")')
190194 await Bun.write(join(android, 'build.gradle.kts'), 'plugins { id("com.android.application") version "8.9.1" apply false; id("org.jetbrains.kotlin.android") version "2.0.21" apply false }')
191195 await Bun.write(join(android, 'local.properties'), `sdk.dir=${sdk}\n`)
192 await Bun.write(join(android, 'gradle.properties'), 'org.gradle.jvmargs=-Xmx768m\norg.gradle.workers.max=2\nandroid.useAndroidX=true\n')
196 await Bun.write(join(android, 'gradle.properties'), 'android.useAndroidX=true\norg.gradle.jvmargs=-Xmx768m\norg.gradle.workers.max=2\n')
193197 await Bun.write(join(app, 'build.gradle.kts'), `plugins { id("com.android.application");
194198 id("org.jetbrains.kotlin.android") }\nandroid { namespace = "${packageName}";
195199 compileSdk = 36;
@@ -201,7 +205,7 @@ export async function verifyMobileMediaCache(options: MobileMediaCacheTestOption
201205 kotlinOptions { jvmTarget = "17" } }
202206dependencies { implementation("androidx.work:work-runtime-ktx:2.9.1") }`)
203207 await Bun.write(join(app, 'src/main/AndroidManifest.xml'), `<manifest xmlns:android="http://schemas.android.com/apk/res/android"><uses-permission android:name="android.permission.INTERNET"/><uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/><application android:usesCleartextTraffic="true" android:label="HQ Download Probe"/><instrumentation android:name=".DownloadProbe" android:targetPackage="${packageName}"/></manifest>`)
204 await Bun.write(join(sources, 'DownloadProbe.kt'), `package ${packageName}\nimport android.content.Context\nimport android.content.SharedPreferences\nimport android.net.Uri\nimport android.os.Handler\nimport android.os.Looper\nimport org.json.JSONObject\nimport java.io.File\nimport java.util.concurrent.ConcurrentHashMap\nimport java.util.concurrent.Executors\ninternal data class CraftNativeCapabilityResult(val data: Any? = null, val code: String? = null, val message: String? = null)\n${media}\n${await Bun.file(join(fixtures, 'media-cache.kt')).text()}`)
208 await Bun.write(join(sources, 'DownloadProbe.kt'), `package ${packageName}\nimport android.content.Context\nimport android.content.SharedPreferences\nimport android.net.Uri\nimport android.os.Handler\nimport android.os.Looper\nimport org.json.JSONObject\nimport java.io.File\nimport java.util.concurrent.Executors\nimport java.util.concurrent.ConcurrentHashMap\ninternal data class CraftNativeCapabilityResult(val data: Any? = null, val code: String? = null, val message: String? = null)\n${media}\n${await Bun.file(join(fixtures, 'media-cache.kt')).text()}`)
205209 await run([options.gradle || process.env.GRADLE_EXECUTABLE || 'gradle', ...(options.online ? [] : ['--offline']), '--no-daemon', ':app:assembleDebug'], android)
206210 print(await run([adb, '-s', serial, 'install', '-r', join(app, 'build/outputs/apk/debug/app-debug.apk')]))
207211 const base = `http://10.0.2.2:${server.port}/android`
@@ -231,6 +235,8 @@ dependencies { implementation("androidx.work:work-runtime-ktx:2.9.1") }`)
231235 await probe('interrupt', true)
232236 if (counts.get('/android/restart.mp4') !== 1) throw new Error('Android process did not start interrupted download')
233237 await probe('recover')
238 if (counts.get('/android/retry.mp4') !== 2) throw new Error('Android explicit retry did not fetch once more immediately')
239 if (counts.get('/android/settled.mp4') !== 1) throw new Error('Android explicit keep did not replace the settled running attempt exactly once')
234240 if (!resumed.has('/android/restart.mp4')) throw new Error('Android did not resume the interrupted download from its part file')
235241 if (counts.get('/android/slow.mp4') !== 1) throw new Error('Android duplicate fetch: keep did not coalesce')
236242 if (counts.get('/android/queued.mp4') !== 1) throw new Error('Android queued work was replaced instead of joined')
packages/zig/src/ios_config.zigmodified+13-0
Changes to packages/zig/src/ios_config.zig
@@ -788,6 +788,19 @@ test "optional fields are type-checked when present" {
788788 }
789789}
790790
791test "the native script thread matches Swift optional string decoding" {
792 inline for (.{ "\"nativeScriptThread\":\"main\"", "\"nativeScriptThread\":\"background\"", "\"nativeScriptThread\":null" }) |good| {
793 const json = try completeConfig(testing.allocator, good);
794 defer testing.allocator.free(json);
795 try testing.expectEqual(std.enums.values(Feature).len, parse(testing.allocator, json).count());
796 }
797 inline for (.{ "\"nativeScriptThread\":true", "\"nativeScriptThread\":1", "\"nativeScriptThread\":[]" }) |bad| {
798 const json = try completeConfig(testing.allocator, bad);
799 defer testing.allocator.free(json);
800 try testing.expectEqual(@as(usize, 0), parse(testing.allocator, json).count());
801 }
802}
803
791804test "number fields take any JSON number and nothing else" {
792805 // `Double` decodes an integer as readily as a fraction; a quoted number
793806 // or a boolean throws. Checked against `swiftc`.
packages/zig/src/linux.zigmodified+7-0
Changes to packages/zig/src/linux.zig
@@ -22,6 +22,8 @@ pub extern "c" fn gtk_init(argc: ?*c_int, argv: ?*anyopaque) void;
2222pub extern "c" fn gtk_application_new(application_id: [*:0]const u8, flags: c_int) ?*anyopaque;
2323pub extern "c" fn g_application_register(app: *anyopaque, cancellable: ?*anyopaque, err: ?*?*anyopaque) c_int;
2424pub extern "c" fn g_application_get_is_remote(app: *anyopaque) c_int;
25pub extern "c" fn g_application_get_dbus_connection(app: *anyopaque) ?*anyopaque;
26pub extern "c" fn g_dbus_connection_flush_sync(connection: *anyopaque, cancellable: ?*anyopaque, err: ?*?*anyopaque) c_int;
2527pub extern "c" fn g_application_open(app: *anyopaque, files: [*]*anyopaque, n_files: c_int, hint: [*:0]const u8) void;
2628pub extern "c" fn g_file_new_for_uri(uri: [*:0]const u8) ?*anyopaque;
2729pub extern "c" fn g_file_get_uri(file: *anyopaque) ?[*:0]u8;
@@ -832,6 +834,11 @@ pub fn forwardDeepLinkIfRunning(url: []const u8) !bool {
832834 defer g_object_unref(file);
833835 var files = [_]*anyopaque{file};
834836 g_application_open(remote, &files, 1, "");
837 // GApplication queues remote Open calls on D-Bus. This forwarding process
838 // does not run g_application_run(), whose shutdown normally flushes them.
839 // Flush before unref/exit so a warm link cannot die in the sender's queue.
840 const connection = g_application_get_dbus_connection(remote) orelse return error.ApplicationBusUnavailable;
841 if (g_dbus_connection_flush_sync(connection, null, null) == 0) return error.DeepLinkDispatchFailed;
835842 return true;
836843}
837844
scripts/native-renderer-contract.test.tsmodified+8-2
Changes to scripts/native-renderer-contract.test.ts
@@ -2107,7 +2107,8 @@ describe('native renderer component contract', () => {
21072107 expect(android).toContain('private data class SharedTreeResult(')
21082108 expect(android).toContain('grid = nested?.grid ?: false')
21092109 expect(android).toContain('putEnum(base, 28, if (node.grid) 1 else 0)')
2110 expect(android).toContain('put(base, 29, node.gridColumns.toFloat())')
2110 expect(android).toContain('putEnum(base, 29, node.gridColumns)')
2111 expect(android).toContain('if (explicitRowsAware) putEnum(base, 34, node.gridRows ?: 0)')
21112112 expect(android).toContain('CraftNative.layoutTree(width.toFloat(), height.toFloat(), nodeData, treeMeta)')
21122113 expect(android).toContain('CraftNative.layoutTreeV2(width.toFloat(), height.toFloat(), nodeData, treeMeta)')
21132114 expect(android).toContain('CraftNative.layoutTreeV3(width.toFloat(), height.toFloat(), nodeData, treeMeta)')
@@ -3512,13 +3513,18 @@ describe('native renderer component contract', () => {
35123513 it('releases replaced iOS row roots when a keyed identity changes type', () => {
35133514 expect(ios).toContain('if let previous, previous.view !== next.view, !adopting {')
35143515 expect(ios).toContain('self.forgetHandlers(previous)')
3516 expect(iosFlatList).toContain('guard hostedView !== view else { return }')
3517 expect(iosFlatList).toContain('hostedView?.removeFromSuperview()\n hostedView = view')
3518 expect(iosFlatList).toContain('contentView.addSubview(view)')
35153519 expect(ios).toContain('self.forgetHandlers(row, preservingInputDrafts: true)')
35163520 expect(ios).toContain('for child in rootStack.arrangedSubviews where child !== next.view')
35173521 expect(ios).toContain('if next.view.superview !== rootStack { rootStack.addArrangedSubview(next.view) }')
35183522 })
35193523
35203524 it('waits for asynchronous Android native fixture updates before asserting', () => {
3521 expect(androidFlatListTest).toContain('list.smoothScrollToPosition(99)')
3525 expect(androidFlatListTest).toContain('list.smoothScrollBy(0, list.height)')
3526 expect(androidFlatListTest).toContain('momentumEnded.await(2500, TimeUnit.MILLISECONDS)')
3527 expect(androidFlatListTest).toContain('assertEquals(RecyclerView.SCROLL_STATE_IDLE, list.scrollState)')
35223528 expect(androidFlatListTest).toContain('ActivityScenario.launch(MainActivity::class.java)')
35233529 expect(androidNavigationTest).toContain('awaitText(activity, "input-style-status", "input colors on")')
35243530 expect(androidNavigationTest).toContain('awaitText(activity, "native-link-status", "link pressed")')
scripts/release-workflow.test.tsmodified+82-2
Changes to scripts/release-workflow.test.ts
@@ -20,7 +20,7 @@ type Job = {
2020}
2121const release = Bun.YAML.parse(readFileSync(join(import.meta.dir, '../.github/workflows/release.yml'), 'utf8')) as { jobs: Record<string, Job> }
2222const artifactDownload = 'actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093'
23const stxCompilerCommit = '617826427dea89a203e7f6d254dffea211bad1ba'
23const stxCompilerCommit = '9d003f90714dc5c36daf262aaaa285a9671245e7'
2424const needs = (job: Job) => typeof job.needs === 'string' ? [job.needs] : job.needs ?? []
2525const steps = (job: Job) => job.steps?.map(step => step.run ?? '').join('\n') ?? ''
2626
@@ -115,6 +115,20 @@ test('mobile device jobs use the same pinned stx compiler', () => {
115115 expect(step.run).toBe('bun install --frozen-lockfile')
116116})
117117
118test('native compiler jobs use a supported Bun and preserve its frozen lockfile', () => {
119 const workflow = Bun.YAML.parse(readFileSync(join(import.meta.dir, '../.github/workflows/mobile-e2e.yml'), 'utf8')) as { jobs: Record<string, Job> }
120 for (const name of ['ios-native', 'ios-simulator', 'android-emulator', 'android-navigation']) {
121 const job = workflow.jobs[name]
122 const jobSteps = job.steps ?? []
123 const tool = jobSteps.findIndex(step => step.uses === 'oven-sh/setup-bun@v2' && step.with?.['bun-version'] === '1.4.3')
124 const install = jobSteps.findIndex(step => step['working-directory'] === '.stx-compiler')
125 expect(tool).toBeGreaterThanOrEqual(0)
126 expect(install).toBeGreaterThan(tool)
127 expect(jobSteps[install].run).toBe('bun install --frozen-lockfile')
128 expect(steps(job)).not.toContain('bun install --no-save')
129 }
130})
131
118132test('native iOS suites and protocol checks have independent required jobs', () => {
119133 const workflow = Bun.YAML.parse(readFileSync(join(import.meta.dir, '../.github/workflows/mobile-e2e.yml'), 'utf8')) as { jobs: Record<string, Job> }
120134 const native = workflow.jobs['ios-native']
@@ -135,6 +149,35 @@ test('native iOS suites and protocol checks have independent required jobs', ()
135149 expect(workflow.jobs[name].env?.DEVELOPER_DIR).toBe('/Applications/Xcode_26.2.app/Contents/Developer')
136150})
137151
152test('navigation shards retain both complete XCTest targets and focused selection', () => {
153 const workflow = Bun.YAML.parse(readFileSync(join(import.meta.dir, '../.github/workflows/mobile-e2e.yml'), 'utf8')) as { jobs: Record<string, Job> }
154 const native = workflow.jobs['ios-native']
155 const expression = (native.strategy?.matrix as unknown as { suite: string }).suite
156 const navigation = native.steps?.find(step => step.name === 'Test native multi-screen navigation')
157 const evaluate = (value: string, scope: string, selectors = '', suite = '') => runInNewContext(value.replace(/^\$\{\{\s*|\s*\}\}$/g, ''), {
158 inputs: { scope, selectors }, matrix: { suite }, fromJSON: JSON.parse,
159 startsWith: (text: string, prefix: string) => text.startsWith(prefix), cancelled: () => false,
160 }, { timeout: 100 })
161 for (const scope of ['', 'all'])
162 expect(evaluate(expression, scope)).toEqual(['uikit', 'navigation-unit', 'navigation-ui-core', 'navigation-ui-controls'])
163 expect(evaluate(expression, 'ios-navigation')).toEqual(['navigation-unit', 'navigation-ui-core', 'navigation-ui-controls'])
164 expect(evaluate(expression, 'ios-render')).toEqual(['uikit'])
165 const focused = 'NativeNavigationUITests/NativeNavigationUITests/testMultilineInputKeepsKeyboardFocusWhenTapped'
166 expect(evaluate(expression, 'ios-navigation', focused)).toEqual(['navigation'])
167 expect(evaluate(navigation!.env!.CRAFT_ONLY_TESTING, 'all', '', 'navigation-unit')).toBe('NativeNavigationUnitTests')
168 for (const [suite, group] of [['navigation-ui-core', 'core'], ['navigation-ui-controls', 'controls']]) {
169 expect(evaluate(navigation!.env!.CRAFT_ONLY_TESTING, 'all', '', suite)).toBe('')
170 expect(evaluate(navigation!.env!.CRAFT_NATIVE_NAVIGATION_GROUP, 'all', '', suite)).toBe(group)
171 }
172 expect(evaluate(navigation!.env!.CRAFT_ONLY_TESTING, 'ios-navigation', focused, 'navigation')).toBe(focused)
173 expect(evaluate(navigation!.env!.CRAFT_NATIVE_NAVIGATION_GROUP, 'ios-navigation', focused, 'navigation')).toBe('')
174 for (const suite of ['navigation-unit', 'navigation-ui-core', 'navigation-ui-controls', 'navigation'])
175 expect(evaluate(navigation!.if!, 'all', '', suite)).toBe(true)
176 expect(evaluate(navigation!.if!, 'all', '', 'uikit')).toBe(false)
177 expect(native.strategy?.['fail-fast']).toBe(false)
178 expect(workflow.jobs['mobile-summary'].steps?.find(step => step.name === 'Fail if any leg failed')?.if).toContain('needs.ios-native.result')
179})
180
138181test('mobile device jobs preserve runner diagnostics when the harness fails', () => {
139182 const workflow = Bun.YAML.parse(readFileSync(join(import.meta.dir, '../.github/workflows/mobile-e2e.yml'), 'utf8')) as { jobs: Record<string, Job> }
140183 for (const [jobName, evidenceDir, expectedCommand] of [
@@ -206,11 +249,48 @@ test('every publishing path depends on release identity validation', () => {
206249 expect(release.jobs.npm.if).toBe("$")
207250})
208251
252test('tag publication requires the complete source and native acceptance workflows', () => {
253 for (const [name, file] of [['source-acceptance', 'ci.yml'], ['native-acceptance', 'mobile-e2e.yml']]) {
254 const gate = release.jobs[name]
255 expect(gate.uses).toBe(`./.github/workflows/${file}`)
256 expect(needs(gate)).toEqual(['validate-release'])
257 expect(gate.if).toBeUndefined()
258 expect(gate.with).toBeUndefined() // A release cannot request a focused suite.
259 const called = Bun.YAML.parse(readFileSync(join(import.meta.dir, '../.github/workflows', file), 'utf8')) as { on: Record<string, unknown> }
260 expect(called.on.workflow_call).toEqual({}) // No selectors or diagnostic inputs are accepted by callers.
261 }
262 for (const name of ['pantry', 'publish-release']) {
263 expect(needs(release.jobs[name])).toContain('source-acceptance')
264 expect(needs(release.jobs[name])).toContain('native-acceptance')
265 expect(release.jobs[name].if).toBeUndefined()
266 }
267 function prerequisites(name: string, found = new Set<string>()): Set<string> {
268 for (const dependency of needs(release.jobs[name])) {
269 if (found.has(dependency)) continue
270 found.add(dependency)
271 prerequisites(dependency, found)
272 }
273 return found
274 }
275 for (const name of ['pantry', 'publish-release', 'publish-pantry', 'npm', 'notify-registry']) {
276 expect(prerequisites(name).has('source-acceptance')).toBe(true)
277 expect(prerequisites(name).has('native-acceptance')).toBe(true)
278 }
279})
280
281test('called source and native acceptance have independent concurrency groups', () => {
282 const parse = (file: string): { concurrency: { group: string } } => Bun.YAML.parse(readFileSync(join(import.meta.dir, '../.github/workflows', file), 'utf8'))
283 const source = parse('ci.yml').concurrency.group
284 const native = parse('mobile-e2e.yml').concurrency.group
285 expect(source.startsWith('source-ci-')).toBe(true)
286 expect(native.startsWith('mobile-e2e-')).toBe(true)
287})
288
209289test('registry indexing waits for the public release and all of its verification gates', () => {
210290 const notify = release.jobs['notify-registry']
211291 expect(notify).toBeDefined()
212292 expect(needs(notify)).toEqual(['publish-release', 'publish-pantry'])
213 expect(needs(release.jobs['publish-release']).sort()).toEqual(['attach-release-sbom', 'prepare-npm', 'validate-release', 'verify-desktop-downloads', 'verify-macos-downloads', 'verify-release'])
293 expect(needs(release.jobs['publish-release']).sort()).toEqual(['attach-release-sbom', 'native-acceptance', 'prepare-npm', 'source-acceptance', 'validate-release', 'verify-desktop-downloads', 'verify-macos-downloads', 'verify-release'])
214294 expect(release.jobs['publish-release'].if).toBeUndefined() // A failed or skipped gate cannot publish a draft.
215295 expect(notify.if).toBeUndefined() // Default success gating: never bypass failed prerequisites.
216296 expect(steps(notify)).toContain('https://registry.pantry.dev/api/rebuild')
scripts/verify-android-emulator.tsmodified+4-1
Changes to scripts/verify-android-emulator.ts
@@ -49,11 +49,14 @@ try {
4949 env: { JAVA_HOME: javaHome },
5050 })
5151 async function adb(args: string[]): Promise<string> {
52 const startedAt = new Date().toISOString()
5253 const child = Bun.spawn([session!.adb, '-s', session!.serial, ...args], {
5354 env: session!.env, stdout: 'pipe', stderr: 'pipe', signal: AbortSignal.timeout(30_000),
5455 })
5556 const [code, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()])
56 if (code !== 0) throw new Error(`ADB verification failed: ${args.join(' ')}: ${stderr}`)
57 const commands = (proof.adbCommands ??= []) as unknown[]
58 commands.push({ args, code, stdout, stderr, startedAt, finishedAt: new Date().toISOString() })
59 if (code !== 0) throw new Error(`ADB verification failed (exit ${code}): ${args.join(' ')}: stdout=${JSON.stringify(stdout)} stderr=${JSON.stringify(stderr)}`)
5760 return stdout.trim()
5861 }
5962 const api = await adb(['shell', 'getprop', 'ro.build.version.sdk'])