also looking at this
fix(mobile): preserve native editing, navigation, and durable media requests
#369
36 files
+864
-185
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | name: CI |
| 2 | 2 | |
| 3 | 3 | on: |
| 4 | workflow_call: {} | |
| 4 | 5 | workflow_dispatch: |
| 5 | 6 | inputs: |
| 6 | 7 | software_cold_provisioning_diagnostic: |
| @@ -15,7 +16,7 @@ on: | ||
| 15 | 16 | - main |
| 16 | 17 | |
| 17 | 18 | concurrency: |
| 18 | group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} | |
| 19 | group: source-ci-${{ github.workflow }}-${{ github.head_ref || github.ref }} | |
| 19 | 20 | # Finish Main acceptance; newer commits replace the single pending run. |
| 20 | 21 | cancel-in-progress: ${{ github.event_name == 'pull_request' }} |
| 21 | 22 | |
| @@ -24,6 +24,7 @@ name: Mobile E2E Testing | ||
| 24 | 24 | # GitHub Actions does not resolve YAML anchors, so the two path filters are |
| 25 | 25 | # written out rather than shared. Keep them identical. |
| 26 | 26 | on: |
| 27 | workflow_call: {} | |
| 27 | 28 | push: |
| 28 | 29 | branches: [main] |
| 29 | 30 | paths: |
| @@ -136,11 +137,11 @@ jobs: | ||
| 136 | 137 | strategy: |
| 137 | 138 | fail-fast: false |
| 138 | 139 | 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"]') }} | |
| 140 | 141 | 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. | |
| 144 | 145 | timeout-minutes: 45 |
| 145 | 146 | env: |
| 146 | 147 | CRAFT_EVIDENCE_LABEL: ios-native |
| @@ -167,15 +168,15 @@ jobs: | ||
| 167 | 168 | # @stacksjs/stx compiler (`stx native compile`) so this job cannot silently change |
| 168 | 169 | # underneath us. Both iOS jobs and Android use the same compiler revision. |
| 169 | 170 | - name: Checkout stx compiler |
| 170 | if: ${{ matrix.suite == 'navigation' }} | |
| 171 | if: ${{ startsWith(matrix.suite, 'navigation') }} | |
| 171 | 172 | uses: actions/checkout@v6 |
| 172 | 173 | with: |
| 173 | 174 | repository: stacksjs/stx |
| 174 | ref: 617826427dea89a203e7f6d254dffea211bad1ba | |
| 175 | ref: 9d003f90714dc5c36daf262aaaa285a9671245e7 | |
| 175 | 176 | path: .stx-compiler |
| 176 | 177 | |
| 177 | 178 | - name: Install stx compiler dependencies |
| 178 | if: ${{ matrix.suite == 'navigation' }} | |
| 179 | if: ${{ startsWith(matrix.suite, 'navigation') }} | |
| 179 | 180 | working-directory: .stx-compiler |
| 180 | 181 | run: bun install --frozen-lockfile |
| 181 | 182 | |
| @@ -192,10 +193,11 @@ jobs: | ||
| 192 | 193 | id: navigation |
| 193 | 194 | # Reconciliation and navigation are independent suites. Keep the |
| 194 | 195 | # navigation evidence when reconciliation fails; the job still fails. |
| 195 | if: ${{ !cancelled() && matrix.suite == 'navigation' }} | |
| 196 | if: ${{ !cancelled() && startsWith(matrix.suite, 'navigation') }} | |
| 196 | 197 | env: |
| 197 | 198 | 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' || '' }} | |
| 199 | 201 | CRAFT_NATIVE_NAVIGATION_RESULT: ${{ github.workspace }}/artifacts/mobile-e2e/ios-native/navigation.xcresult |
| 200 | 202 | run: | |
| 201 | 203 | mkdir -p artifacts/mobile-e2e/ios-native |
| @@ -272,6 +274,11 @@ jobs: | ||
| 272 | 274 | with: |
| 273 | 275 | version: '0.11.101' |
| 274 | 276 | |
| 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 | ||
| 275 | 282 | - name: Install XcodeGen |
| 276 | 283 | run: brew install xcodegen |
| 277 | 284 | |
| @@ -282,12 +289,12 @@ jobs: | ||
| 282 | 289 | # The summary requires both jobs, including every original test suite. |
| 283 | 290 | # The multi-screen fixture is compiled from real .stx sources. Pin the |
| 284 | 291 | # @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. | |
| 286 | 293 | - name: Checkout stx compiler |
| 287 | 294 | uses: actions/checkout@v6 |
| 288 | 295 | with: |
| 289 | 296 | repository: stacksjs/stx |
| 290 | ref: 617826427dea89a203e7f6d254dffea211bad1ba | |
| 297 | ref: 9d003f90714dc5c36daf262aaaa285a9671245e7 | |
| 291 | 298 | path: .stx-compiler |
| 292 | 299 | |
| 293 | 300 | - name: Install stx compiler dependencies |
| @@ -402,6 +409,11 @@ jobs: | ||
| 402 | 409 | with: |
| 403 | 410 | version: '0.11.101' |
| 404 | 411 | |
| 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 | ||
| 405 | 417 | - name: Setup Java |
| 406 | 418 | uses: actions/setup-java@v6 |
| 407 | 419 | with: |
| @@ -432,7 +444,7 @@ jobs: | ||
| 432 | 444 | uses: actions/checkout@v6 |
| 433 | 445 | with: |
| 434 | 446 | repository: stacksjs/stx |
| 435 | ref: 617826427dea89a203e7f6d254dffea211bad1ba | |
| 447 | ref: 9d003f90714dc5c36daf262aaaa285a9671245e7 | |
| 436 | 448 | path: .stx-compiler |
| 437 | 449 | |
| 438 | 450 | - name: Install Android stx compiler dependencies |
| @@ -639,7 +651,7 @@ jobs: | ||
| 639 | 651 | - uses: actions/checkout@v6 |
| 640 | 652 | with: |
| 641 | 653 | repository: stacksjs/stx |
| 642 | ref: 617826427dea89a203e7f6d254dffea211bad1ba | |
| 654 | ref: 9d003f90714dc5c36daf262aaaa285a9671245e7 | |
| 643 | 655 | path: .stx-compiler |
| 644 | 656 | - run: bun install --frozen-lockfile |
| 645 | 657 | working-directory: .stx-compiler |
| @@ -29,6 +29,22 @@ jobs: | ||
| 29 | 29 | - name: Review dependency advisories |
| 30 | 30 | run: bun scripts/advisory-policy.ts |
| 31 | 31 | |
| 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 | ||
| 32 | 48 | release-sbom: |
| 33 | 49 | needs: validate-release |
| 34 | 50 | uses: ./.github/workflows/sbom.yml |
| @@ -40,7 +56,7 @@ jobs: | ||
| 40 | 56 | pantry: |
| 41 | 57 | # The reusable SBOM workflow includes the vulnerability scan. Publishing |
| 42 | 58 | # 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] | |
| 44 | 60 | name: pantry (${{ matrix.platform.name }}) |
| 45 | 61 | strategy: |
| 46 | 62 | fail-fast: false |
| @@ -681,7 +697,7 @@ jobs: | ||
| 681 | 697 | |
| 682 | 698 | publish-release: |
| 683 | 699 | 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] | |
| 685 | 701 | runs-on: ubuntu-latest |
| 686 | 702 | permissions: |
| 687 | 703 | contents: write |
| @@ -73,13 +73,15 @@ renderer. | ||
| 73 | 73 | The maintained Mobile E2E workflow checks out one exact stx compiler commit for |
| 74 | 74 | both simulator hosts. Update the iOS and Android refs together; the workflow |
| 75 | 75 | contract test rejects compiler skew between them. |
| 76 | The current pair is pinned to `617826427dea89a203e7f6d254dffea211bad1ba`, | |
| 76 | The current pair is pinned to `9d003f90714dc5c36daf262aaaa285a9671245e7` (STX 0.2.593), | |
| 77 | 77 | which preserves authored reconciliation keys and explicit grid row tracks in |
| 78 | 78 | static and reactive native styles, and keeps template indentation out of native |
| 79 | 79 | leaf button labels. Touchable primitives preserve authored accessibility roles |
| 80 | 80 | and let the hosts infer link or button semantics from their current destination. |
| 81 | 81 | Prepared hidden screens start unfocused before their mount hooks run; their |
| 82 | appearance guards run when the host actually shows them. The native fixtures qualify its compiled | |
| 82 | appearance guards run when the host actually shows them. Scripted component | |
| 83 | clones receive independent setup, and retained keyed rows keep their handlers | |
| 84 | and local state after reordering. The native fixtures qualify its compiled | |
| 83 | 85 | properties and mutation metadata together with both native models. Application |
| 84 | 86 | releases consume the normally published compiler after qualification. |
| 85 | 87 | |
| @@ -3,6 +3,10 @@ package | ||
| 3 | 3 | import java.io.File |
| 4 | 4 | import java.net.InetAddress |
| 5 | 5 | import java.net.ServerSocket |
| 6 | import java.util.Collections | |
| 7 | import java.util.concurrent.CompletableFuture | |
| 8 | import java.util.concurrent.TimeUnit | |
| 9 | import kotlin.concurrent.thread | |
| 6 | 10 | import java.nio.file.Files |
| 7 | 11 | import org.junit.Assert.assertArrayEquals |
| 8 | 12 | import org.junit.Assert.assertEquals |
| @@ -19,46 +23,54 @@ import org.junit.Test | ||
| 19 | 23 | class CraftNativeResumableDownloadTest { |
| 20 | 24 | private val body = "0123456789abcdefghij".toByteArray() |
| 21 | 25 | |
| 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 | } | |
| 37 | 44 | seen.add(range) |
| 38 | 45 | 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() | |
| 53 | 56 | } |
| 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) | |
| 55 | 60 | } |
| 61 | served.complete(Unit) | |
| 62 | } catch (error: Throwable) { | |
| 63 | served.completeExceptionally(error) | |
| 56 | 64 | } |
| 57 | 65 | } |
| 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 | } | |
| 62 | 74 | } |
| 63 | 75 | |
| 64 | 76 | private fun part(check: (File) -> Unit) { |
| @@ -115,14 +127,21 @@ class CraftNativeResumableDownloadTest { | ||
| 115 | 127 | } |
| 116 | 128 | |
| 117 | 129 | @Test |
| 118 | fun refusesAPartNobodyAskedFor() = part { file -> | |
| 119 | serve(status = 206) { address, seen -> | |
| 130 | fun refusesAnUnsolicitedPartialResponseWithoutRetryingIt() = part { file -> | |
| 131 | serve(status = 206) { address, _ -> | |
| 120 | 132 | assertEquals(CraftResumableDownload.Fetched.Refused(206), CraftResumableDownload().fetch(address, emptyMap(), file) { false }) |
| 121 | assertEquals(listOf<String?>(null), seen) | |
| 122 | 133 | assertFalse(file.exists()) |
| 123 | 134 | } |
| 124 | 135 | } |
| 125 | 136 | |
| 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 | ||
| 126 | 145 | @Test |
| 127 | 146 | fun keepsWhatItHasWhenStopped() = part { file -> |
| 128 | 147 | serve { address, _ -> |
| @@ -36,6 +36,7 @@ export async function createAndroidEmulator(options: AndroidEmulatorOptions): Pr | ||
| 36 | 36 | if (options.acceleration === 'software' && process.arch !== 'x64') |
| 37 | 37 | throw new Error('Android software emulation requires an x86_64 host and system image') |
| 38 | 38 | const bootTimeoutMs = options.bootTimeoutMs ?? 600_000 |
| 39 | const requiresPlayStore = options.image.split(';')[2] === 'google_apis_playstore' | |
| 39 | 40 | const maximumBootTimeoutMs = options.acceleration === 'software' ? 1_200_000 : 600_000 |
| 40 | 41 | if (!Number.isSafeInteger(bootTimeoutMs) || bootTimeoutMs < 1000 || bootTimeoutMs > maximumBootTimeoutMs) |
| 41 | 42 | 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 | ||
| 159 | 160 | // boot_completed can precede SettingsProvider and System UI readiness. |
| 160 | 161 | const settings = await run([adb, '-s', serial, 'shell', 'settings', 'get', 'global', 'animator_duration_scale'], undefined, probeTimeout(), true) |
| 161 | 162 | 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 | |
| 162 | 168 | const systemUi = await run([adb, '-s', serial, 'shell', 'pidof', 'com.android.systemui'], undefined, probeTimeout(), true) |
| 163 | 169 | // Native acceptance must be able to observe platform crashes before |
| 164 | 170 | // launching any app. boot_completed/System UI can precede logd's ADB |
| @@ -171,9 +177,13 @@ export async function createAndroidEmulator(options: AndroidEmulatorOptions): Pr | ||
| 171 | 177 | // modifying device setup or relaxing native acceptance. |
| 172 | 178 | 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) |
| 173 | 179 | 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) | |
| 174 | 183 | booted = settings.code === 0 && packages.code === 0 && systemUi.code === 0 && events.code === 0 |
| 175 | 184 | && /^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 | |
| 177 | 187 | } |
| 178 | 188 | } |
| 179 | 189 | const checkedAt = Date.now() |
| @@ -2,6 +2,34 @@ | ||
| 2 | 2 | import { test } from 'bun:test' |
| 3 | 3 | import { runKotlinFixture } from './testing' |
| 4 | 4 | |
| 5 | test('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(` | |
| 11 | import kotlin.math.roundToInt | |
| 12 | ||
| 13 | class JSONObject(private val values: Map<String, Any?>) { | |
| 14 | fun opt(name: String): Any? = values[name] | |
| 15 | } | |
| 16 | ${number} | |
| 17 | ${padding} | |
| 18 | ||
| 19 | fun 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 | ||
| 5 | 33 | test('sizes occupied and empty explicit grid rows after padding and fractional gaps', async () => { |
| 6 | 34 | const template = await Bun.file(new URL('../templates/MainActivityNative.kt.template', import.meta.url)).text() |
| 7 | 35 | const allocation = template.match(/ val rowHeights = if \(gridRows != null\) \{[\s\S]*?\n \} else naturalRowHeights/)?.[0] |
| @@ -34,3 +62,31 @@ fun main() { | ||
| 34 | 62 | } |
| 35 | 63 | `) |
| 36 | 64 | }, 180_000) |
| 65 | ||
| 66 | ||
| 67 | test('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(` | |
| 74 | data class GridNode(val gridColumns: Int, val gridRows: Int?) | |
| 75 | ||
| 76 | fun 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) | |
| @@ -1524,8 +1524,9 @@ describe('Craft Android builder', () => { | ||
| 1524 | 1524 | expect(shell).toContain('val matching = pending.filter { it.generation == started }') |
| 1525 | 1525 | expect(shell).toContain('work.enqueueUniqueWork(name, if (pending) androidx.work.ExistingWorkPolicy.KEEP else androidx.work.ExistingWorkPolicy.REPLACE, request).result.get()') |
| 1526 | 1526 | // 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)') | |
| 1529 | 1530 | expect(shell).toContain('if (wifiOnly) androidx.work.NetworkType.UNMETERED else androidx.work.NetworkType.CONNECTED') |
| 1530 | 1531 | // Resumed from the part file an earlier attempt left. |
| 1531 | 1532 | expect(shell).toContain('connection.setRequestProperty("Range", "bytes=$have-")') |
| @@ -236,8 +236,7 @@ describe('the native app shell in a generated project', () => { | ||
| 236 | 236 | expect(shell).toContain('tabSwipeEnabled = options.optBoolean("tabSwipeEnabled", true)') |
| 237 | 237 | // Wi-Fi only: a kept video is not fetched on a metered connection, and says it is waiting. |
| 238 | 238 | 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)') | |
| 241 | 240 | expect(shell).toContain('wifiOnly && metered(app) -> "cellular"') |
| 242 | 241 | expect(shell).toContain('.put("reason", reason).put("queued", true)') |
| 243 | 242 | const manifest = readFileSync(join(output, 'app/src/main/AndroidManifest.xml'), 'utf8') |
| @@ -413,7 +413,6 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap | ||
| 413 | 413 | markers.values.forEach { builder.include(it.position); count++ } |
| 414 | 414 | polylines.values.flatMap { it.points }.forEach { builder.include(it); count++ } |
| 415 | 415 | polygons.values.flatMap { it.points }.forEach { builder.include(it); count++ } |
| 416 | polygons.values.flatMap { it.points }.forEach { builder.include(it); count++ } | |
| 417 | 416 | if (count == 0) return false |
| 418 | 417 | post { |
| 419 | 418 | if (width > 0 && height > 0) { |
| @@ -21,6 +21,9 @@ import android.widget.FrameLayout | ||
| 21 | 21 | import android.widget.ImageView |
| 22 | 22 | import android.widget.LinearLayout |
| 23 | 23 | import android.widget.TextView |
| 24 | import androidx.core.view.AccessibilityDelegateCompat | |
| 25 | import androidx.core.view.ViewCompat | |
| 26 | import androidx.core.view.accessibility.AccessibilityNodeInfoCompat | |
| 24 | 27 | import androidx.core.content.ContextCompat |
| 25 | 28 | import androidx.security.crypto.EncryptedSharedPreferences |
| 26 | 29 | import androidx.security.crypto.MasterKey |
| @@ -838,7 +841,17 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN | ||
| 838 | 841 | cell.background = rippleBackground() |
| 839 | 842 | cell.contentDescription = tab.title |
| 840 | 843 | 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 | }) | |
| 841 | 853 | val icon = TextView(context) |
| 854 | icon.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO | |
| 842 | 855 | icon.text = CraftNativeSymbols.glyph(tab.symbol) |
| 843 | 856 | icon.textSize = 22f |
| 844 | 857 | icon.minWidth = dp(26) |
| @@ -846,6 +859,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN | ||
| 846 | 859 | icon.gravity = Gravity.CENTER |
| 847 | 860 | icon.includeFontPadding = false |
| 848 | 861 | val label = TextView(context) |
| 862 | label.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO | |
| 849 | 863 | label.text = tab.title |
| 850 | 864 | label.textSize = 12f |
| 851 | 865 | label.fontFeatureSettings = "tnum" |
| @@ -1234,6 +1248,7 @@ internal object CraftNativeSymbols { | ||
| 1234 | 1248 | * transfer finishes on its own; the next keep answers from the copy. |
| 1235 | 1249 | */ |
| 1236 | 1250 | internal object CraftNativeMedia { |
| 1251 | const val ATTEMPT_SETTLED = "craft.media.attemptSettled" | |
| 1237 | 1252 | private const val TAG = "craft-media" |
| 1238 | 1253 | private const val PREFS = "craft.media" |
| 1239 | 1254 | private val lock = Any() |
| @@ -1301,7 +1316,7 @@ internal object CraftNativeMedia { | ||
| 1301 | 1316 | try { |
| 1302 | 1317 | if (generation(app) == started) { |
| 1303 | 1318 | 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) && | |
| 1305 | 1320 | (it.state == androidx.work.WorkInfo.State.RUNNING || |
| 1306 | 1321 | (it.state == androidx.work.WorkInfo.State.ENQUEUED && it.runAttemptCount == 0)) |
| 1307 | 1322 | } |
| @@ -1349,12 +1364,22 @@ internal object CraftNativeMedia { | ||
| 1349 | 1364 | * file where it stopped, moves it into place whole, and answers whoever |
| 1350 | 1365 | * waits. RETRY leaves the part file for the next attempt. |
| 1351 | 1366 | */ |
| 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 | } | |
| 1353 | 1378 | // clear() already answered the old generation's waiters. Never answer |
| 1354 | 1379 | // a new athlete's waiters from an obsolete worker for the same URL. |
| 1355 | 1380 | if (generation(context) != started) return Outcome.DONE |
| 1356 | 1381 | local(context, address)?.let { |
| 1357 | deliver(context, address, started, CraftNativeCapabilityResult(data = describe(address, it))) | |
| 1382 | answer(CraftNativeCapabilityResult(data = describe(address, it))) | |
| 1358 | 1383 | return Outcome.DONE |
| 1359 | 1384 | } |
| 1360 | 1385 | val writer = writers.getOrPut(address) { Any() } |
| @@ -1364,7 +1389,7 @@ internal object CraftNativeMedia { | ||
| 1364 | 1389 | if (generation(context) != started) return Outcome.DONE |
| 1365 | 1390 | if (stopped()) return Outcome.RETRY |
| 1366 | 1391 | local(context, address)?.let { |
| 1367 | deliver(context, address, started, CraftNativeCapabilityResult(data = describe(address, it))) | |
| 1392 | answer(CraftNativeCapabilityResult(data = describe(address, it))) | |
| 1368 | 1393 | return Outcome.DONE |
| 1369 | 1394 | } |
| 1370 | 1395 | val target = file(context, address) |
| @@ -1383,22 +1408,22 @@ internal object CraftNativeMedia { | ||
| 1383 | 1408 | } |
| 1384 | 1409 | if (full) { |
| 1385 | 1410 | 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")) | |
| 1387 | 1412 | return Outcome.DONE |
| 1388 | 1413 | } |
| 1389 | 1414 | if (stopped()) return Outcome.RETRY |
| 1390 | 1415 | // 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")) | |
| 1392 | 1417 | return if (attempt < 8) Outcome.RETRY else Outcome.DONE.also { staging.delete() } |
| 1393 | 1418 | } |
| 1394 | 1419 | when (fetched) { |
| 1395 | 1420 | is CraftResumableDownload.Fetched.Stopped, is CraftResumableDownload.Fetched.Restart -> return Outcome.RETRY |
| 1396 | 1421 | 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))) | |
| 1398 | 1423 | return Outcome.DONE |
| 1399 | 1424 | } |
| 1400 | 1425 | 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")) | |
| 1402 | 1427 | return if (attempt < 8) Outcome.RETRY else Outcome.DONE.also { staging.delete() } |
| 1403 | 1428 | } |
| 1404 | 1429 | is CraftResumableDownload.Fetched.Complete -> Unit |
| @@ -1410,11 +1435,11 @@ internal object CraftNativeMedia { | ||
| 1410 | 1435 | } |
| 1411 | 1436 | target.delete() |
| 1412 | 1437 | 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")) | |
| 1414 | 1439 | return Outcome.DONE |
| 1415 | 1440 | } |
| 1416 | 1441 | } |
| 1417 | deliver(context, address, started, CraftNativeCapabilityResult(data = describe(address, target))) | |
| 1442 | answer(CraftNativeCapabilityResult(data = describe(address, target))) | |
| 1418 | 1443 | return Outcome.DONE |
| 1419 | 1444 | } |
| 1420 | 1445 | } |
| @@ -1432,13 +1457,13 @@ internal object CraftNativeMedia { | ||
| 1432 | 1457 | capabilities?.hasCapability(android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET) == true |
| 1433 | 1458 | }.getOrDefault(true) |
| 1434 | 1459 | |
| 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. */ | |
| 1436 | 1461 | fun prune(context: Context, keep: List<String>): Int { |
| 1437 | 1462 | val wanted = keep.map { digest(it) }.toSet() |
| 1438 | 1463 | 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() } | |
| 1441 | 1464 | synchronized(lock) { |
| 1465 | // A keep still waiting owns its files until it is answered. | |
| 1466 | val active = waiting.keys.map { digest(it) }.toSet() | |
| 1442 | 1467 | root(context).listFiles()?.forEach { file -> |
| 1443 | 1468 | val owner = file.name.substringBefore('.') |
| 1444 | 1469 | // Part files from before transfers resumed (`<sha>.<ext>.<id>.part`) are never picked up again. |
| @@ -1451,8 +1476,16 @@ internal object CraftNativeMedia { | ||
| 1451 | 1476 | runCatching { |
| 1452 | 1477 | androidx.work.WorkManager.getInstance(app).getWorkInfosByTag(TAG).get() |
| 1453 | 1478 | .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 | } | |
| 1456 | 1489 | } |
| 1457 | 1490 | } |
| 1458 | 1491 | return removed |
| @@ -1470,9 +1503,10 @@ internal object CraftNativeMedia { | ||
| 1470 | 1503 | root(context).listFiles()?.forEach { file -> |
| 1471 | 1504 | if (file.delete()) removed++ else failure = java.io.IOException("The video cache could not be cleared") |
| 1472 | 1505 | } |
| 1506 | // Schedule cancellation before a new generation can enqueue its work. | |
| 1507 | androidx.work.WorkManager.getInstance(context.applicationContext).cancelAllWorkByTag(TAG) | |
| 1473 | 1508 | callbacks |
| 1474 | 1509 | } |
| 1475 | androidx.work.WorkManager.getInstance(context.applicationContext).cancelAllWorkByTag(TAG) | |
| 1476 | 1510 | main.post { answered.forEach { it.answer(CraftNativeCapabilityResult(code = "CANCELLED", message = "The video cache was cleared")) } } |
| 1477 | 1511 | failure?.let { throw it } |
| 1478 | 1512 | return removed |
| @@ -1511,12 +1545,10 @@ internal class CraftResumableDownload( | ||
| 1511 | 1545 | if (have > 0) connection.setRequestProperty("Range", "bytes=$have-") |
| 1512 | 1546 | try { |
| 1513 | 1547 | 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) | |
| 1516 | 1548 | val resumed = status == 206 && have > 0 && rangeStart(connection.getHeaderField("Content-Range")) == have |
| 1517 | 1549 | if (status == 416 || (status == 206 && !resumed)) { |
| 1518 | 1550 | part.delete() |
| 1519 | return Fetched.Restart | |
| 1551 | return if (have > 0) Fetched.Restart else Fetched.Refused(status) | |
| 1520 | 1552 | } |
| 1521 | 1553 | if (status != 200 && !resumed) { |
| 1522 | 1554 | part.delete() |
| @@ -1539,8 +1571,8 @@ internal class CraftResumableDownload( | ||
| 1539 | 1571 | // HttpURLConnection inflates gzip itself; a Content-Length is then the compressed size. |
| 1540 | 1572 | val encoded = connection.getHeaderField("Content-Encoding") != null |
| 1541 | 1573 | 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() | |
| 1544 | 1576 | return Fetched.Incomplete |
| 1545 | 1577 | } |
| 1546 | 1578 | return Fetched.Complete(size) |
| @@ -1563,8 +1595,21 @@ internal class CraftNativeMediaWorker(context: Context, parameters: androidx.wor | ||
| 1563 | 1595 | override fun doWork(): Result { |
| 1564 | 1596 | val address = inputData.getString("address") ?: return Result.failure() |
| 1565 | 1597 | 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 | } | |
| 1568 | 1613 | } |
| 1569 | 1614 | } |
| 1570 | 1615 | |
| @@ -1136,9 +1136,9 @@ internal class CraftNativeFlexLayout(context: Context) : LinearLayout(context) { | ||
| 1136 | 1136 | val rowHeights = if (gridRows != null) { |
| 1137 | 1137 | val count = max(gridRows ?: 0, naturalRowHeights.size) |
| 1138 | 1138 | 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) | |
| 1140 | 1140 | val equal = available / max(1, count).toFloat() |
| 1141 | (naturalRowHeights + List(count - naturalRowHeights.size) { equal }) | |
| 1141 | (naturalRowHeights + List(count - naturalRowHeights.size) { 0f }) | |
| 1142 | 1142 | .map { max(it, equal) } |
| 1143 | 1143 | } else naturalRowHeights |
| 1144 | 1144 | return GridLayout(rows, absolute, cellWidth, rowHeights) |
| @@ -1438,12 +1438,12 @@ internal class CraftNativeFlexLayout(context: Context) : LinearLayout(context) { | ||
| 1438 | 1438 | put(base, 26, node.padding[3].toFloat()) |
| 1439 | 1439 | put(base, 27, node.padding[0].toFloat()) |
| 1440 | 1440 | putEnum(base, 28, if (node.grid) 1 else 0) |
| 1441 | put(base, 29, node.gridColumns.toFloat()) | |
| 1441 | putEnum(base, 29, node.gridColumns) | |
| 1442 | 1442 | put(base, 30, node.rowGap) |
| 1443 | 1443 | put(base, 31, node.columnGap) |
| 1444 | 1444 | if (fields >= 33) putEnum(base, 32, (if (node.reverse) 1 else 0) or (if (node.wrapReverse) 2 else 0)) |
| 1445 | 1445 | 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) | |
| 1447 | 1447 | val firstChild = cursor |
| 1448 | 1448 | node.children.forEach { child -> write(child, contentWidth, contentHeight, false, node.direction) } |
| 1449 | 1449 | treeMeta[index * 2] = firstChild.toFloat() |
| @@ -1527,7 +1527,7 @@ internal class CraftNativeFlexLayout(context: Context) : LinearLayout(context) { | ||
| 1527 | 1527 | put(base, 26, padding[3].toFloat()) |
| 1528 | 1528 | put(base, 27, padding[0].toFloat()) |
| 1529 | 1529 | putEnum(base, 28, 0) |
| 1530 | put(base, 29, 1f) | |
| 1530 | putEnum(base, 29, 1) | |
| 1531 | 1531 | put(base, 30, if (root) rowGapValue else 0f) |
| 1532 | 1532 | put(base, 31, if (root) columnGapValue else 0f) |
| 1533 | 1533 | } |
| @@ -39,7 +39,14 @@ final class NativeRenderUITests: XCTestCase { | ||
| 39 | 39 | notes.tap() |
| 40 | 40 | XCTAssertTrue(keyboardIsVisible(app)) |
| 41 | 41 | 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)") | |
| 43 | 50 | } |
| 44 | 51 | |
| 45 | 52 | func testSwitchTapChangesUIKitStateAndDeliversValueChange() throws { |
| @@ -1942,6 +1942,72 @@ final class NativeRenderUnitTests: XCTestCase { | ||
| 1942 | 1942 | XCTAssertEqual(proof.font?.pointSize, 22) |
| 1943 | 1943 | } |
| 1944 | 1944 | |
| 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 | ||
| 1945 | 2011 | func testTextInputsPadTheirTextAndAskForAutofill() throws { |
| 1946 | 2012 | let controller = CraftNativeScreenController(config: CraftConfig()) |
| 1947 | 2013 | controller.loadViewIfNeeded() |
| @@ -306,6 +306,7 @@ describe('Craft iOS builder', () => { | ||
| 306 | 306 | expect(nativeSwift).toContain('case "cover": return .scaleAspectFill') |
| 307 | 307 | expect(nativeSwift).toContain('private func restoreFocus(_ focused: UIView?)') |
| 308 | 308 | expect(nativeSwift).toContain('if rootStyle["flex"] == nil, rootStyle["height"] == nil { rootStyle["flex"] = 1 }') |
| 309 | expect(nativeSwift).toContain('let lifecycleHandler = visible ? events["onShow"] : events["onDismiss"]') | |
| 309 | 310 | expect(nativeSwift).toContain('if let lifecycleHandler = nonEmptyHandler(lifecycleHandler) {') |
| 310 | 311 | expect(nativeSwift).not.toContain('consumeDismissSuppression') |
| 311 | 312 | expect(nativeSwift).toContain('_ = focused.becomeFirstResponder()') |
| @@ -2080,7 +2080,14 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg | ||
| 2080 | 2080 | /// The edge swipe goes ahead of the web view's own gestures, which |
| 2081 | 2081 | /// otherwise hold every touch over the page until the page has answered it. |
| 2082 | 2082 | 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 | |
| 2084 | 2091 | } |
| 2085 | 2092 | |
| 2086 | 2093 | func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool { |
| @@ -371,24 +371,9 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL | ||
| 371 | 371 | @objc private func keyboardTap() { |
| 372 | 372 | guard keyboardShouldPersistTaps != "always" else { return } |
| 373 | 373 | 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() | |
| 381 | 376 | } |
| 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) | |
| 392 | 377 | } |
| 393 | 378 | |
| 394 | 379 | func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { |
| @@ -3954,6 +3954,23 @@ func craftScrollGesture(_ gesture: UIGestureRecognizer, runsWith other: UIGestur | ||
| 3954 | 3954 | return false |
| 3955 | 3955 | } |
| 3956 | 3956 | |
| 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. | |
| 3960 | func 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 | ||
| 3957 | 3974 | private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDelegate { |
| 3958 | 3975 | let contentStack = CraftNativeFlowView() |
| 3959 | 3976 | private let keyboardTapGesture = UITapGestureRecognizer() |
| @@ -4226,24 +4243,9 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele | ||
| 4226 | 4243 | @objc private func keyboardTap() { |
| 4227 | 4244 | guard keyboardShouldPersistTaps != "always" else { return } |
| 4228 | 4245 | 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 | } | |
| 4247 | 4249 | } |
| 4248 | 4250 | |
| 4249 | 4251 | func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { |
| @@ -1,3 +1,14 @@ | ||
| 1 | /** Holds a completed attempt in RUNNING, before WorkManager commits its result. */ | |
| 2 | class 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 | ||
| 1 | 12 | class DownloadProbe : android.app.Instrumentation() { |
| 2 | 13 | override fun onCreate(arguments: android.os.Bundle?) { |
| 3 | 14 | super.onCreate(arguments) |
| @@ -23,6 +34,13 @@ class DownloadProbe : android.app.Instrumentation() { | ||
| 23 | 34 | check(done.await(25, java.util.concurrent.TimeUnit.SECONDS), "operation settled") |
| 24 | 35 | return ok |
| 25 | 36 | } |
| 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 | } | |
| 26 | 44 | override fun onStart() { |
| 27 | 45 | val result = android.os.Bundle() |
| 28 | 46 | try { |
| @@ -80,10 +98,11 @@ class DownloadProbe : android.app.Instrumentation() { | ||
| 80 | 98 | val until = System.currentTimeMillis() + 25000 |
| 81 | 99 | while (!partial() && System.currentTimeMillis() < until) Thread.sleep(25) |
| 82 | 100 | check(partial(), "download is in progress before process death") |
| 101 | check(CraftNativeMedia.local(targetContext, restart) == null, "interrupted process never published a partial video") | |
| 83 | 102 | android.os.Process.killProcess(android.os.Process.myPid()) |
| 84 | 103 | } |
| 85 | 104 | "recover" -> { |
| 86 | check(CraftNativeMedia.local(targetContext, restart) == null, "interrupted process never published a partial video") | |
| 105 | awaitCached(restart) | |
| 87 | 106 | check(keep(restart), "retry after process death downloads a complete video") |
| 88 | 107 | val media = android.media.MediaMetadataRetriever() |
| 89 | 108 | try { |
| @@ -98,12 +117,34 @@ class DownloadProbe : android.app.Instrumentation() { | ||
| 98 | 117 | check(!keep(broken), "truncated transfer fails") |
| 99 | 118 | check(CraftNativeMedia.local(targetContext, broken) == null, "truncated transfer is not playable cache") |
| 100 | 119 | 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") | |
| 106 | 127 | 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" | |
| 107 | 148 | val queuedName = "craft-media:${CraftNativeMedia.digest(queued)}:any" |
| 108 | 149 | val queuedRequest = androidx.work.OneTimeWorkRequestBuilder<CraftNativeMediaWorker>() |
| 109 | 150 | .setInitialDelay(10, java.util.concurrent.TimeUnit.SECONDS) |
| @@ -5,7 +5,7 @@ import { join } from 'node:path' | ||
| 5 | 5 | import { createAndroidEmulator } from '../../../android/src/emulator' |
| 6 | 6 | import type { AndroidEmulatorOptions } from '../../../android/src/emulator' |
| 7 | 7 | |
| 8 | function fixture(hardware: boolean, delayedSettings = false, delayedEvents = false, delayedHome = false, connection: 'normal' | 'stale' | 'offline' = 'normal'): { root: string, options: AndroidEmulatorOptions } { | |
| 8 | function 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 } { | |
| 9 | 9 | const root = mkdtempSync(join(tmpdir(), 'craft emulator fixture ')) |
| 10 | 10 | const sdk = join(root, 'managed sdk') |
| 11 | 11 | for (const directory of ['bin', 'platform-tools', 'emulator']) mkdirSync(join(sdk, directory), { recursive: true }) |
| @@ -41,11 +41,18 @@ else | ||
| 41 | 41 | fi |
| 42 | 42 | printf '1\\n' ;; |
| 43 | 43 | *'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' ;; | |
| 44 | 51 | *'pidof com.android.systemui') printf '1234\\n' ;; |
| 45 | 52 | *'cmd package resolve-activity --brief -a android.intent.action.MAIN -c android.intent.category.HOME') |
| 46 | 53 | if [ '${delayedHome ? 'yes' : 'no'}' = yes ] && [ ! -f "$ANDROID_HOME/home-ready" ]; then |
| 47 | 54 | touch "$ANDROID_HOME/home-ready" |
| 48 | printf 'com.android.sdksetup/.DefaultActivity\\n' | |
| 55 | printf '${bootstrapHome}\\n' | |
| 49 | 56 | else |
| 50 | 57 | printf 'com.android.launcher3/.Launcher\\n' |
| 51 | 58 | fi ;; |
| @@ -170,6 +177,48 @@ test('boot and System UI do not admit a device before its event log is readable' | ||
| 170 | 177 | finally { await session?.dispose(); rmSync(root, { recursive: true, force: true }) } |
| 171 | 178 | }, 10_000) |
| 172 | 179 | |
| 180 | test('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 | ||
| 195 | test('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 | ||
| 211 | test('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 | ||
| 173 | 222 | test('boot completion waits for the automatic SDK setup activity to release HOME', async () => { |
| 174 | 223 | const { root, options } = fixture(true, false, false, true) |
| 175 | 224 | let session: Awaited<ReturnType<typeof createAndroidEmulator>> | undefined |
| @@ -185,6 +234,18 @@ test('boot completion waits for the automatic SDK setup activity to release HOME | ||
| 185 | 234 | finally { await session?.dispose(); rmSync(root, { recursive: true, force: true }) } |
| 186 | 235 | }, 10_000) |
| 187 | 236 | |
| 237 | test.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 | ||
| 188 | 249 | test('the SDK process fixture records hardware mode and reaps only its owned process', async () => { |
| 189 | 250 | const { root, options } = fixture(true) |
| 190 | 251 | options.bootTimeoutMs = 42_000 |
| @@ -336,14 +336,17 @@ describe('bridge reply correlation', () => { | ||
| 336 | 336 | |
| 337 | 337 | it('puts a distinct id on every message, fire-and-forget included', async () => { |
| 338 | 338 | const h = loadBridge() |
| 339 | h.craft.tags.get('/a') | |
| 339 | const tags = h.craft.tags.get('/a') | |
| 340 | 340 | h.craft.keychain.set('svc', 'acct', 'pw') // _send, no reply expected |
| 341 | 341 | h.craft.window.show?.() |
| 342 | h.craft.autoLaunch.isEnabled() | |
| 342 | const autoLaunch = h.craft.autoLaunch.isEnabled() | |
| 343 | 343 | |
| 344 | 344 | const ids = h.sent.map(m => m.i) |
| 345 | 345 | expect(ids.every(i => typeof i === 'number' && i > 0)).toBe(true) |
| 346 | 346 | 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]) | |
| 347 | 350 | }) |
| 348 | 351 | |
| 349 | 352 | it('reports a fire-and-forget failure rather than rejecting a live call', async () => { |
| @@ -1,5 +1,16 @@ | ||
| 1 | 1 | import { expect, test } from 'bun:test' |
| 2 | 2 | import { verifyMobileMediaCache } from '../mobile-testing' |
| 3 | import { androidMediaProbeSource } from '../mobile-media-testing' | |
| 4 | ||
| 5 | test('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 | }) | |
| 3 | 14 | |
| 4 | 15 | test('media cache tests reject an unknown platform before creating a probe', async () => { |
| 5 | 16 | await expect(verifyMobileMediaCache({ platform: 'desktop' as any })).rejects.toThrow('Choose ios or android') |
| @@ -19,6 +19,15 @@ export interface MobileMediaCacheTestOptions { | ||
| 19 | 19 | log?: (line: string) => void |
| 20 | 20 | } |
| 21 | 21 | |
| 22 | /** Keep the probe on the complete shipped downloader, including its durable worker. */ | |
| 23 | export 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 | ||
| 22 | 31 | /** Real ENOSPC, cache cleanup, retry, coalescing and offline video decode on an isolated native probe. */ |
| 23 | 32 | export async function verifyMobileMediaCache(options: MobileMediaCacheTestOptions): Promise<{ platform: string, requests: Record<string, number> }> { |
| 24 | 33 | if (options.platform !== 'ios' && options.platform !== 'android') throw new Error('Choose ios or android') |
| @@ -81,7 +90,7 @@ export async function verifyMobileMediaCache(options: MobileMediaCacheTestOption | ||
| 81 | 90 | } |
| 82 | 91 | const empty = path.endsWith('/empty.mp4') |
| 83 | 92 | 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 | |
| 85 | 94 | const slow = path.endsWith('/slow.mp4') || (path.endsWith('/restart.mp4') && n === 1) |
| 86 | 95 | 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`) |
| 87 | 96 | if (empty) { socket.end(); |
| @@ -176,12 +185,7 @@ export async function verifyMobileMediaCache(options: MobileMediaCacheTestOption | ||
| 176 | 185 | serial = options.device || '' |
| 177 | 186 | if (!/^emulator-\d+$/.test(serial)) throw new Error('Select a disposable Android emulator with ANDROID_SERIAL') |
| 178 | 187 | 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) | |
| 185 | 189 | const android = join(scratch, 'android') |
| 186 | 190 | const app = join(android, 'app') |
| 187 | 191 | const sources = join(app, 'src/main/java/dev/craft/mediaprobe') |
| @@ -189,7 +193,7 @@ export async function verifyMobileMediaCache(options: MobileMediaCacheTestOption | ||
| 189 | 193 | await Bun.write(join(android, 'settings.gradle.kts'), 'pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } }\ndependencyResolutionManagement { repositories { google(); mavenCentral() } }\nrootProject.name = "HQDownloadProbe"\ninclude(":app")') |
| 190 | 194 | 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 }') |
| 191 | 195 | 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') | |
| 193 | 197 | await Bun.write(join(app, 'build.gradle.kts'), `plugins { id("com.android.application"); |
| 194 | 198 | id("org.jetbrains.kotlin.android") }\nandroid { namespace = "${packageName}"; |
| 195 | 199 | compileSdk = 36; |
| @@ -201,7 +205,7 @@ export async function verifyMobileMediaCache(options: MobileMediaCacheTestOption | ||
| 201 | 205 | kotlinOptions { jvmTarget = "17" } } |
| 202 | 206 | dependencies { implementation("androidx.work:work-runtime-ktx:2.9.1") }`) |
| 203 | 207 | 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()}`) | |
| 205 | 209 | await run([options.gradle || process.env.GRADLE_EXECUTABLE || 'gradle', ...(options.online ? [] : ['--offline']), '--no-daemon', ':app:assembleDebug'], android) |
| 206 | 210 | print(await run([adb, '-s', serial, 'install', '-r', join(app, 'build/outputs/apk/debug/app-debug.apk')])) |
| 207 | 211 | const base = `http://10.0.2.2:${server.port}/android` |
| @@ -231,6 +235,8 @@ dependencies { implementation("androidx.work:work-runtime-ktx:2.9.1") }`) | ||
| 231 | 235 | await probe('interrupt', true) |
| 232 | 236 | if (counts.get('/android/restart.mp4') !== 1) throw new Error('Android process did not start interrupted download') |
| 233 | 237 | 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') | |
| 234 | 240 | if (!resumed.has('/android/restart.mp4')) throw new Error('Android did not resume the interrupted download from its part file') |
| 235 | 241 | if (counts.get('/android/slow.mp4') !== 1) throw new Error('Android duplicate fetch: keep did not coalesce') |
| 236 | 242 | if (counts.get('/android/queued.mp4') !== 1) throw new Error('Android queued work was replaced instead of joined') |
| @@ -788,6 +788,19 @@ test "optional fields are type-checked when present" { | ||
| 788 | 788 | } |
| 789 | 789 | } |
| 790 | 790 | |
| 791 | test "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 | ||
| 791 | 804 | test "number fields take any JSON number and nothing else" { |
| 792 | 805 | // `Double` decodes an integer as readily as a fraction; a quoted number |
| 793 | 806 | // or a boolean throws. Checked against `swiftc`. |
| @@ -22,6 +22,8 @@ pub extern "c" fn gtk_init(argc: ?*c_int, argv: ?*anyopaque) void; | ||
| 22 | 22 | pub extern "c" fn gtk_application_new(application_id: [*:0]const u8, flags: c_int) ?*anyopaque; |
| 23 | 23 | pub extern "c" fn g_application_register(app: *anyopaque, cancellable: ?*anyopaque, err: ?*?*anyopaque) c_int; |
| 24 | 24 | pub extern "c" fn g_application_get_is_remote(app: *anyopaque) c_int; |
| 25 | pub extern "c" fn g_application_get_dbus_connection(app: *anyopaque) ?*anyopaque; | |
| 26 | pub extern "c" fn g_dbus_connection_flush_sync(connection: *anyopaque, cancellable: ?*anyopaque, err: ?*?*anyopaque) c_int; | |
| 25 | 27 | pub extern "c" fn g_application_open(app: *anyopaque, files: [*]*anyopaque, n_files: c_int, hint: [*:0]const u8) void; |
| 26 | 28 | pub extern "c" fn g_file_new_for_uri(uri: [*:0]const u8) ?*anyopaque; |
| 27 | 29 | pub extern "c" fn g_file_get_uri(file: *anyopaque) ?[*:0]u8; |
| @@ -832,6 +834,11 @@ pub fn forwardDeepLinkIfRunning(url: []const u8) !bool { | ||
| 832 | 834 | defer g_object_unref(file); |
| 833 | 835 | var files = [_]*anyopaque{file}; |
| 834 | 836 | 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; | |
| 835 | 842 | return true; |
| 836 | 843 | } |
| 837 | 844 | |
| @@ -2107,7 +2107,8 @@ describe('native renderer component contract', () => { | ||
| 2107 | 2107 | expect(android).toContain('private data class SharedTreeResult(') |
| 2108 | 2108 | expect(android).toContain('grid = nested?.grid ?: false') |
| 2109 | 2109 | 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)') | |
| 2111 | 2112 | expect(android).toContain('CraftNative.layoutTree(width.toFloat(), height.toFloat(), nodeData, treeMeta)') |
| 2112 | 2113 | expect(android).toContain('CraftNative.layoutTreeV2(width.toFloat(), height.toFloat(), nodeData, treeMeta)') |
| 2113 | 2114 | expect(android).toContain('CraftNative.layoutTreeV3(width.toFloat(), height.toFloat(), nodeData, treeMeta)') |
| @@ -3512,13 +3513,18 @@ describe('native renderer component contract', () => { | ||
| 3512 | 3513 | it('releases replaced iOS row roots when a keyed identity changes type', () => { |
| 3513 | 3514 | expect(ios).toContain('if let previous, previous.view !== next.view, !adopting {') |
| 3514 | 3515 | 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)') | |
| 3515 | 3519 | expect(ios).toContain('self.forgetHandlers(row, preservingInputDrafts: true)') |
| 3516 | 3520 | expect(ios).toContain('for child in rootStack.arrangedSubviews where child !== next.view') |
| 3517 | 3521 | expect(ios).toContain('if next.view.superview !== rootStack { rootStack.addArrangedSubview(next.view) }') |
| 3518 | 3522 | }) |
| 3519 | 3523 | |
| 3520 | 3524 | 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)') | |
| 3522 | 3528 | expect(androidFlatListTest).toContain('ActivityScenario.launch(MainActivity::class.java)') |
| 3523 | 3529 | expect(androidNavigationTest).toContain('awaitText(activity, "input-style-status", "input colors on")') |
| 3524 | 3530 | expect(androidNavigationTest).toContain('awaitText(activity, "native-link-status", "link pressed")') |
| @@ -20,7 +20,7 @@ type Job = { | ||
| 20 | 20 | } |
| 21 | 21 | const release = Bun.YAML.parse(readFileSync(join(import.meta.dir, '../.github/workflows/release.yml'), 'utf8')) as { jobs: Record<string, Job> } |
| 22 | 22 | const artifactDownload = 'actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093' |
| 23 | const stxCompilerCommit = '617826427dea89a203e7f6d254dffea211bad1ba' | |
| 23 | const stxCompilerCommit = '9d003f90714dc5c36daf262aaaa285a9671245e7' | |
| 24 | 24 | const needs = (job: Job) => typeof job.needs === 'string' ? [job.needs] : job.needs ?? [] |
| 25 | 25 | const steps = (job: Job) => job.steps?.map(step => step.run ?? '').join('\n') ?? '' |
| 26 | 26 | |
| @@ -115,6 +115,20 @@ test('mobile device jobs use the same pinned stx compiler', () => { | ||
| 115 | 115 | expect(step.run).toBe('bun install --frozen-lockfile') |
| 116 | 116 | }) |
| 117 | 117 | |
| 118 | test('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 | ||
| 118 | 132 | test('native iOS suites and protocol checks have independent required jobs', () => { |
| 119 | 133 | const workflow = Bun.YAML.parse(readFileSync(join(import.meta.dir, '../.github/workflows/mobile-e2e.yml'), 'utf8')) as { jobs: Record<string, Job> } |
| 120 | 134 | const native = workflow.jobs['ios-native'] |
| @@ -135,6 +149,35 @@ test('native iOS suites and protocol checks have independent required jobs', () | ||
| 135 | 149 | expect(workflow.jobs[name].env?.DEVELOPER_DIR).toBe('/Applications/Xcode_26.2.app/Contents/Developer') |
| 136 | 150 | }) |
| 137 | 151 | |
| 152 | test('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 | ||
| 138 | 181 | test('mobile device jobs preserve runner diagnostics when the harness fails', () => { |
| 139 | 182 | const workflow = Bun.YAML.parse(readFileSync(join(import.meta.dir, '../.github/workflows/mobile-e2e.yml'), 'utf8')) as { jobs: Record<string, Job> } |
| 140 | 183 | for (const [jobName, evidenceDir, expectedCommand] of [ |
| @@ -206,11 +249,48 @@ test('every publishing path depends on release identity validation', () => { | ||
| 206 | 249 | expect(release.jobs.npm.if).toBe("$") |
| 207 | 250 | }) |
| 208 | 251 | |
| 252 | test('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 | ||
| 281 | test('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 | ||
| 209 | 289 | test('registry indexing waits for the public release and all of its verification gates', () => { |
| 210 | 290 | const notify = release.jobs['notify-registry'] |
| 211 | 291 | expect(notify).toBeDefined() |
| 212 | 292 | 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']) | |
| 214 | 294 | expect(release.jobs['publish-release'].if).toBeUndefined() // A failed or skipped gate cannot publish a draft. |
| 215 | 295 | expect(notify.if).toBeUndefined() // Default success gating: never bypass failed prerequisites. |
| 216 | 296 | expect(steps(notify)).toContain('https://registry.pantry.dev/api/rebuild') |
| @@ -49,11 +49,14 @@ try { | ||
| 49 | 49 | env: { JAVA_HOME: javaHome }, |
| 50 | 50 | }) |
| 51 | 51 | async function adb(args: string[]): Promise<string> { |
| 52 | const startedAt = new Date().toISOString() | |
| 52 | 53 | const child = Bun.spawn([session!.adb, '-s', session!.serial, ...args], { |
| 53 | 54 | env: session!.env, stdout: 'pipe', stderr: 'pipe', signal: AbortSignal.timeout(30_000), |
| 54 | 55 | }) |
| 55 | 56 | 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)}`) | |
| 57 | 60 | return stdout.trim() |
| 58 | 61 | } |
| 59 | 62 | const api = await adb(['shell', 'getprop', 'ro.build.version.sdk']) |