also looking at this
fix(native): preserve workspace sources and native overlay lifecycle
#384
20 files
+500
-227
| @@ -35,9 +35,9 @@ jobs: | ||
| 35 | 35 | fetch-depth: 0 |
| 36 | 36 | |
| 37 | 37 | - name: Setup Zig |
| 38 | uses: pantry-pm/pantry/packages/action@ae59a7b7fc5f4e6f7b8041a75e6b5772680bebdf # v0.11.101 | |
| 38 | uses: pantry-pm/pantry/packages/action@e62755c21d946ed0f8dc8e4af39f6ea5f084537a # v0.11.106 | |
| 39 | 39 | with: |
| 40 | version: '0.11.101' | |
| 40 | version: '0.11.106' | |
| 41 | 41 | - name: Install Linux dependencies |
| 42 | 42 | run: | |
| 43 | 43 | sudo apt-get update |
| @@ -49,9 +49,9 @@ jobs: | ||
| 49 | 49 | fetch-depth: 0 |
| 50 | 50 | |
| 51 | 51 | - name: Setup Zig |
| 52 | uses: pantry-pm/pantry/packages/action@ae59a7b7fc5f4e6f7b8041a75e6b5772680bebdf # v0.11.101 | |
| 52 | uses: pantry-pm/pantry/packages/action@e62755c21d946ed0f8dc8e4af39f6ea5f084537a # v0.11.106 | |
| 53 | 53 | with: |
| 54 | version: '0.11.101' | |
| 54 | version: '0.11.106' | |
| 55 | 55 | - name: Install Linux dependencies |
| 56 | 56 | run: | |
| 57 | 57 | sudo apt-get update |
| @@ -124,9 +124,9 @@ jobs: | ||
| 124 | 124 | - uses: actions/checkout@v6 |
| 125 | 125 | |
| 126 | 126 | - name: Setup Pantry (provides Bun, Zig, etc.) |
| 127 | uses: pantry-pm/pantry/packages/action@ae59a7b7fc5f4e6f7b8041a75e6b5772680bebdf # v0.11.101 | |
| 127 | uses: pantry-pm/pantry/packages/action@e62755c21d946ed0f8dc8e4af39f6ea5f084537a # v0.11.106 | |
| 128 | 128 | with: |
| 129 | version: '0.11.101' | |
| 129 | version: '0.11.106' | |
| 130 | 130 | |
| 131 | 131 | - name: Install dependencies |
| 132 | 132 | run: bun install --frozen-lockfile |
| @@ -276,9 +276,9 @@ jobs: | ||
| 276 | 276 | - uses: actions/checkout@v6 |
| 277 | 277 | |
| 278 | 278 | - name: Setup Pantry (provides Bun, Zig, etc.) |
| 279 | uses: pantry-pm/pantry/packages/action@ae59a7b7fc5f4e6f7b8041a75e6b5772680bebdf # v0.11.101 | |
| 279 | uses: pantry-pm/pantry/packages/action@e62755c21d946ed0f8dc8e4af39f6ea5f084537a # v0.11.106 | |
| 280 | 280 | with: |
| 281 | version: '0.11.101' | |
| 281 | version: '0.11.106' | |
| 282 | 282 | |
| 283 | 283 | - name: Setup Bun for the frozen stx compiler lockfile |
| 284 | 284 | uses: oven-sh/setup-bun@v2 |
| @@ -411,9 +411,9 @@ jobs: | ||
| 411 | 411 | - uses: actions/checkout@v6 |
| 412 | 412 | |
| 413 | 413 | - name: Setup Pantry (provides Bun, Zig, etc.) |
| 414 | uses: pantry-pm/pantry/packages/action@ae59a7b7fc5f4e6f7b8041a75e6b5772680bebdf # v0.11.101 | |
| 414 | uses: pantry-pm/pantry/packages/action@e62755c21d946ed0f8dc8e4af39f6ea5f084537a # v0.11.106 | |
| 415 | 415 | with: |
| 416 | version: '0.11.101' | |
| 416 | version: '0.11.106' | |
| 417 | 417 | |
| 418 | 418 | - name: Setup Bun for the frozen stx compiler lockfile |
| 419 | 419 | uses: oven-sh/setup-bun@v2 |
| @@ -581,9 +581,9 @@ jobs: | ||
| 581 | 581 | - uses: actions/checkout@v6 |
| 582 | 582 | |
| 583 | 583 | - name: Setup Pantry (provides Bun, Zig, etc.) |
| 584 | uses: pantry-pm/pantry/packages/action@ae59a7b7fc5f4e6f7b8041a75e6b5772680bebdf # v0.11.101 | |
| 584 | uses: pantry-pm/pantry/packages/action@e62755c21d946ed0f8dc8e4af39f6ea5f084537a # v0.11.106 | |
| 585 | 585 | with: |
| 586 | version: '0.11.101' | |
| 586 | version: '0.11.106' | |
| 587 | 587 | |
| 588 | 588 | - name: Cache Zig artifacts |
| 589 | 589 | uses: actions/cache@v5 |
| @@ -623,6 +623,9 @@ jobs: | ||
| 623 | 623 | artifacts/mobile-e2e/ios-zig-slice/console.plain |
| 624 | 624 | artifacts/mobile-e2e/ios-zig-slice/app-stdout.log |
| 625 | 625 | artifacts/mobile-e2e/ios-zig-slice/app-stderr.log |
| 626 | artifacts/mobile-e2e/ios-zig-slice/failure.png | |
| 627 | artifacts/mobile-e2e/ios-zig-slice/failure-screenshot.log | |
| 628 | artifacts/mobile-e2e/ios-zig-slice/failure-location.log | |
| 626 | 629 | if-no-files-found: error |
| 627 | 630 | retention-days: 30 |
| 628 | 631 | |
| @@ -90,6 +90,7 @@ const device = await createAndroidEmulator({ | ||
| 90 | 90 | avdmanager: '/ci/pantry/.bin/avdmanager', |
| 91 | 91 | image: 'system-images;android-35;google_apis_playstore;x86_64', |
| 92 | 92 | acceleration: 'software', |
| 93 | memoryMb: 4096, | |
| 93 | 94 | artifactsDirectory: '/ci/artifacts/android', |
| 94 | 95 | acceptLicenses: true, |
| 95 | 96 | }) |
| @@ -106,13 +107,23 @@ the Android emulator's explicit `-accel off` mode on x86_64 hosts; it provides | ||
| 106 | 107 | functional test evidence, with no claim about hardware performance. The public |
| 107 | 108 | helper defaults to a ten-minute boot budget, and hardware mode cannot exceed |
| 108 | 109 | it. Software callers may explicitly set `bootTimeoutMs` up to twenty minutes. |
| 110 | `memoryMb` selects the requested guest RAM, defaulting to 2048 MB. It accepts | |
| 111 | the emulator's documented [1536–8192 MB range](https://developer.android.com/studio/run/emulator-commandline#startup-options). | |
| 112 | `virtualCpus` selects the guest CPU count, defaulting to 2 and accepting values | |
| 113 | from 1 through 8. The stock Play software verifier requests four guest CPUs: | |
| 114 | the four-thread Intel runners used for this acceptance profile otherwise starve | |
| 115 | SystemUI during TCG startup and surface a real platform ANR. | |
| 116 | The stock Play software verifier requests 4096 MB after retained cold boots | |
| 117 | showed low-memory/process-death churn and a SystemUI ANR. It keeps the same | |
| 118 | image, readiness checks and twenty-minute deadline. | |
| 109 | 119 | The stock Play software CI verifier uses that cold-provisioning profile: the |
| 110 | 120 | API 35 image took 943,520 ms in the retained diagnostic run |
| 111 | 121 | [39113277828](https://github.com/craft-native/craft/actions/runs/39113277828). |
| 112 | 122 | This is OS provisioning before app installation; app and performance budgets |
| 113 | are unchanged. Both modes require a readable event-log stream, SettingsProvider, | |
| 123 | are unchanged. Both modes require a complete event-history read, SettingsProvider, | |
| 114 | 124 | the package manager and System UI to be ready, with HOME resolved beyond the |
| 115 | automatic Android SDK setup activity. Logs record the budget, actual | |
| 125 | automatic Android SDK setup activity and its launcher package owning the current | |
| 126 | input-focus window. Logs record the budget, actual | |
| 116 | 127 | boot duration, host CPU interval, selected image, acceleration |
| 117 | 128 | probe, process identity and boot result. Disposal reaps only the owned process |
| 118 | 129 | and deletes only its disposable AVD; the managed SDK and other devices remain |
| @@ -14,6 +14,10 @@ export interface AndroidEmulatorOptions { | ||
| 14 | 14 | acceptLicenses?: boolean |
| 15 | 15 | /** Defaults to 10 minutes; hardware is capped there, software cold provisioning at 20 minutes. */ |
| 16 | 16 | bootTimeoutMs?: number |
| 17 | /** Requested guest RAM, 1536–8192 MB (Android emulator limits); defaults to 2048. */ | |
| 18 | memoryMb?: number | |
| 19 | /** Requested guest CPU count, 1–8; defaults to 2. */ | |
| 20 | virtualCpus?: number | |
| 17 | 21 | env?: Record<string, string | undefined> |
| 18 | 22 | } |
| 19 | 23 | |
| @@ -40,6 +44,12 @@ export async function createAndroidEmulator(options: AndroidEmulatorOptions): Pr | ||
| 40 | 44 | const maximumBootTimeoutMs = options.acceleration === 'software' ? 1_200_000 : 600_000 |
| 41 | 45 | if (!Number.isSafeInteger(bootTimeoutMs) || bootTimeoutMs < 1000 || bootTimeoutMs > maximumBootTimeoutMs) |
| 42 | 46 | throw new Error(`Android bootTimeoutMs must be an integer between 1000 and ${maximumBootTimeoutMs} milliseconds`) |
| 47 | const memoryMb = options.memoryMb ?? 2048 | |
| 48 | if (!Number.isSafeInteger(memoryMb) || memoryMb < 1536 || memoryMb > 8192) | |
| 49 | throw new Error('Android memoryMb must be an integer between 1536 and 8192 megabytes') | |
| 50 | const virtualCpus = options.virtualCpus ?? 2 | |
| 51 | if (!Number.isSafeInteger(virtualCpus) || virtualCpus < 1 || virtualCpus > 8) | |
| 52 | throw new Error('Android virtualCpus must be an integer between 1 and 8') | |
| 43 | 53 | const port = options.port ?? 5558 |
| 44 | 54 | if (!Number.isInteger(port) || port < 5554 || port > 5682 || port % 2 !== 0) throw new Error('Android console ports must be even and between 5554 and 5682') |
| 45 | 55 | const sdk = resolve(options.sdkRoot) |
| @@ -128,12 +138,12 @@ export async function createAndroidEmulator(options: AndroidEmulatorOptions): Pr | ||
| 128 | 138 | const hostCpus = cpus() |
| 129 | 139 | cpuStart = hostCpus.reduce((total, cpu) => total + Object.values(cpu.times).reduce((sum, value) => sum + value, 0), 0) |
| 130 | 140 | idleStart = hostCpus.reduce((total, cpu) => total + cpu.times.idle, 0) |
| 131 | child = Bun.spawn([executable, '-avd', name, '-port', String(port), '-no-window', '-no-snapshot', '-no-audio', '-no-boot-anim', '-gpu', 'software', '-memory', '2048', '-cores', '2', '-accel', options.acceleration === 'software' ? 'off' : 'on'], { env, stdout: log, stderr: log }) | |
| 141 | child = Bun.spawn([executable, '-avd', name, '-port', String(port), '-no-window', '-no-snapshot', '-no-audio', '-no-boot-anim', '-gpu', 'software', '-memory', String(memoryMb), '-cores', String(virtualCpus), '-accel', options.acceleration === 'software' ? 'off' : 'on'], { env, stdout: log, stderr: log }) | |
| 132 | 142 | identity = { |
| 133 | 143 | image: options.image, acceleration: options.acceleration, accelerationProbeExitCode: acceleration.code, |
| 134 | 144 | bootTimeoutMs, bootStartedAt: new Date(bootStartedAt).toISOString(), |
| 135 | 145 | host: { cpuModel: hostCpus[0]?.model, logicalCpus: hostCpus.length, availableParallelism: availableParallelism(), memoryBytes: totalmem() }, |
| 136 | virtualCpus: 2, memoryMb: 2048, serial, avdDirectory: env.ANDROID_AVD_HOME, emulatorPid: child.pid, | |
| 146 | virtualCpus, memoryMb, serial, avdDirectory: env.ANDROID_AVD_HOME, emulatorPid: child.pid, | |
| 137 | 147 | } |
| 138 | 148 | await Bun.write(join(artifacts, 'android-emulator.json'), JSON.stringify(identity, null, 2) + '\n') |
| 139 | 149 | const deadline = bootStartedAt + bootTimeoutMs |
| @@ -168,8 +178,12 @@ export async function createAndroidEmulator(options: AndroidEmulatorOptions): Pr | ||
| 168 | 178 | const systemUi = await run([adb, '-s', serial, 'shell', 'pidof', 'com.android.systemui'], undefined, probeTimeout(), true) |
| 169 | 179 | // Native acceptance must be able to observe platform crashes before |
| 170 | 180 | // launching any app. boot_completed/System UI can precede logd's ADB |
| 171 | // event stream; declaring readiness then loses that first health check. | |
| 172 | const events = await run([adb, '-s', serial, 'logcat', '-b', 'events', '-d', '-t', '1'], undefined, probeTimeout(), true) | |
| 181 | // event stream. Require a complete history read so a partial transport | |
| 182 | // read cannot admit the device before its first health check. | |
| 183 | const events = await run([adb, '-s', serial, 'logcat', '-b', 'events', '-d', '-v', 'threadtime'], undefined, probeTimeout(), true) | |
| 184 | // AOSP's first boot can report all those services ready while its | |
| 185 | // automatic SDK setup activity still owns HOME. Launching HOME then | |
| 186 | // opens that transient activity; after it finishes UI automation has | |
| 173 | 187 | // AOSP and stock Play first boots can report those services ready |
| 174 | 188 | // while their automatic SDK setup activity still owns HOME. Launching |
| 175 | 189 | // HOME then opens that transient activity; after it finishes UI automation has |
| @@ -181,10 +195,15 @@ export async function createAndroidEmulator(options: AndroidEmulatorOptions): Pr | ||
| 181 | 195 | || homeActivity.startsWith('com.google.android.googlesdksetup/') |
| 182 | 196 | || /^com\.android\.settings\/(?:\.|com\.android\.settings\.)FallbackHome$/.test(homeActivity) |
| 183 | 197 | || /^android\/.*(?:Resolver|Chooser)Activity$/.test(homeActivity) |
| 198 | // Resolving HOME can precede its first input window during cold | |
| 199 | // provisioning. Hand off only once the launcher actually has focus. | |
| 200 | const windows = await run([adb, '-s', serial, 'shell', 'dumpsys', 'window'], undefined, probeTimeout(), true) | |
| 201 | const focusedPackage = windows.stdout.match(/^\s*mCurrentFocus\s*=\s*Window\{[^}\r\n]*\bu\d+\s+([\w.]+)\//m)?.[1] | |
| 184 | 202 | booted = settings.code === 0 && packages.code === 0 && systemUi.code === 0 && events.code === 0 |
| 185 | 203 | && /^package:\/\S+/m.test(packages.stdout) && /^\d+(?:\s+\d+)*$/.test(systemUi.stdout.trim()) |
| 186 | 204 | && (!requiresPlayStore || (playStore?.code === 0 && /^package:\/\S+/m.test(playStore.stdout))) |
| 187 | 205 | && home.code === 0 && /^[\w.]+\/[\w.$]+$/.test(homeActivity) && !temporaryHome |
| 206 | && windows.code === 0 && focusedPackage === homeActivity.split('/')[0] | |
| 188 | 207 | } |
| 189 | 208 | } |
| 190 | 209 | const checkedAt = Date.now() |
| @@ -2630,6 +2630,21 @@ it('emits native Bluetooth GATT support and versioned permissions only when enab | ||
| 2630 | 2630 | finally { rmSync(output, { recursive: true, force: true }) } |
| 2631 | 2631 | }) |
| 2632 | 2632 | |
| 2633 | it('declares location once when Bluetooth overlaps unrestricted location capabilities', () => { | |
| 2634 | for (const bluetooth of ['enableBluetooth', 'enableHeartRate'] as const) { | |
| 2635 | for (const location of ['enableGeolocation', 'enableBackgroundLocation', 'enableHealthServices'] as const) { | |
| 2636 | const permissions = renderAndroidPermissions({ | |
| 2637 | appName: 'Combined capabilities', | |
| 2638 | packageName: 'dev.craft.combined', | |
| 2639 | [bluetooth]: true, | |
| 2640 | [location]: true, | |
| 2641 | }) | |
| 2642 | const fineLocation = permissions.split('\n').filter(line => line.includes('"android.permission.ACCESS_FINE_LOCATION"')) | |
| 2643 | expect(fineLocation).toEqual([' <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />']) | |
| 2644 | } | |
| 2645 | } | |
| 2646 | }) | |
| 2647 | ||
| 2633 | 2648 | it('reads a heart-rate strap on the page and on native screens alike, declared only when enabled', async () => { |
| 2634 | 2649 | for (const renderer of ['native', 'web'] as const) { |
| 2635 | 2650 | const output = createTempDir(`craft-android-heart-rate-${renderer}-`) |
| @@ -74,3 +74,104 @@ fun main() { | ||
| 74 | 74 | } |
| 75 | 75 | `) |
| 76 | 76 | }, 180_000) |
| 77 | ||
| 78 | test('preserves modal viewport promotion and ordering across targeted reconciliation', async () => { | |
| 79 | const template = await Bun.file(new URL('../templates/MainActivityNative.kt.template', import.meta.url)).text() | |
| 80 | const promote = template.match(/ private fun promoteModalOverlays\(screen: Screen\) \{[\s\S]*?\n \}/)?.[0] | |
| 81 | const targeted = template.match(/ private fun applyTargetedUpdates\(screen: Screen, ids: List<String>\): Boolean \{[\s\S]*?\n \}/)?.[0] | |
| 82 | if (!promote || !targeted) throw new Error('Native modal reconciliation implementation is missing') | |
| 83 | await runKotlinFixture(` | |
| 84 | import kotlin.math.min | |
| 85 | ||
| 86 | open class View { | |
| 87 | var parent: ViewGroup? = null | |
| 88 | fun bringToFront() { val group = parent ?: return; group.children.remove(this); group.children.add(this) } | |
| 89 | } | |
| 90 | open class ViewGroup : View() { | |
| 91 | val children = mutableListOf<View>() | |
| 92 | val childStyles = mutableMapOf<View, JSONObject>() | |
| 93 | val childCount: Int get() = children.size | |
| 94 | fun getChildAt(index: Int): View = children[index] | |
| 95 | fun removeView(view: View) { children.remove(view); childStyles.remove(view); view.parent = null } | |
| 96 | fun setChildStyle(view: View, style: JSONObject, density: Float) { childStyles[view] = style } | |
| 97 | fun addView(view: View) { view.parent?.removeView(view); children.add(view); view.parent = this } | |
| 98 | } | |
| 99 | class CraftNativeModalView : ViewGroup() { | |
| 100 | val content = ViewGroup().also(::addView) | |
| 101 | var viewportStyle = JSONObject("popup-frame") | |
| 102 | var declarationPath = "" | |
| 103 | } | |
| 104 | class DisplayMetrics { val density = 1f } | |
| 105 | class Resources { val displayMetrics = DisplayMetrics() } | |
| 106 | val resources = Resources() | |
| 107 | class JSONObject(val id: String = "") { fun optJSONObject(name: String): JSONObject? = null } | |
| 108 | class MutationDocument(val parents: Map<String, String>) { | |
| 109 | fun parentId(id: String): String? = parents[id] | |
| 110 | fun node(id: String): JSONObject = JSONObject(id) | |
| 111 | } | |
| 112 | class Screen { | |
| 113 | val view = ViewGroup() | |
| 114 | val controls = linkedMapOf<String, View>() | |
| 115 | val nodeViews = mutableMapOf<String, View>() | |
| 116 | val nodePaths = mutableMapOf<String, String>() | |
| 117 | val protocolIds = mutableMapOf<View, String>() | |
| 118 | val lastLayoutBounds = mutableMapOf<View, String>() | |
| 119 | val modalVisibility = mutableMapOf<View, Boolean>() | |
| 120 | val mutations = MutationDocument(mapOf("parent" to "root", "nested" to "parent", "standalone" to "root")) | |
| 121 | var layoutEvents = 0 | |
| 122 | } | |
| 123 | fun drawnTarget(screen: Screen, id: String): String = id | |
| 124 | fun makeView(screen: Screen, document: JSONObject, path: String, controls: MutableMap<String, View>) { | |
| 125 | // Targeted parent reconciliation restores its logical child before promotion. | |
| 126 | if (document.id == "parent") (screen.nodeViews.getValue("parent") as CraftNativeModalView).content.addView(screen.nodeViews.getValue("nested")) | |
| 127 | } | |
| 128 | fun updateParentLayoutStyle(view: View, style: JSONObject): Boolean = true | |
| 129 | fun scheduleLayoutEvents(screen: Screen) { screen.layoutEvents++ } | |
| 130 | ${promote} | |
| 131 | ${targeted} | |
| 132 | fun main() { | |
| 133 | val screen = Screen() | |
| 134 | val root = ViewGroup() | |
| 135 | val parent = CraftNativeModalView() | |
| 136 | val nested = CraftNativeModalView() | |
| 137 | val standalone = CraftNativeModalView() | |
| 138 | screen.view.addView(root) | |
| 139 | root.addView(parent) | |
| 140 | parent.content.addView(nested) | |
| 141 | root.addView(standalone) | |
| 142 | for (modal in listOf(parent, nested, standalone)) screen.view.setChildStyle(modal, modal.viewportStyle, 1f) | |
| 143 | parent.declarationPath = "0" | |
| 144 | nested.declarationPath = "0.0" | |
| 145 | standalone.declarationPath = "1" | |
| 146 | for ((id, view) in listOf("root" to root, "nested" to nested, "parent" to parent, "standalone" to standalone)) { | |
| 147 | screen.nodeViews[id] = view | |
| 148 | screen.nodePaths[id] = id | |
| 149 | screen.protocolIds[view] = id | |
| 150 | screen.controls[id] = view | |
| 151 | screen.controls["id:" + id] = view | |
| 152 | if (view is CraftNativeModalView) screen.modalVisibility[view] = true | |
| 153 | } | |
| 154 | fun verify() { | |
| 155 | check(parent.parent === screen.view && nested.parent === screen.view && standalone.parent === screen.view) { "An overlay escaped its viewport" } | |
| 156 | for (modal in listOf(parent, nested, standalone)) check(screen.view.childStyles[modal] === modal.viewportStyle) { "A promoted overlay lost its viewport layout style" } | |
| 157 | val children = screen.view.children | |
| 158 | check(children.indexOf(parent) < children.indexOf(nested)) { "The parent scrim covered its nested popup" } | |
| 159 | check(children.indexOf(nested) < children.indexOf(standalone)) { "Sibling overlay order changed" } | |
| 160 | } | |
| 161 | promoteModalOverlays(screen) | |
| 162 | verify() | |
| 163 | check(applyTargetedUpdates(screen, listOf("parent"))) | |
| 164 | verify() | |
| 165 | check(screen.layoutEvents == 1) | |
| 166 | check(applyTargetedUpdates(screen, listOf("standalone"))) | |
| 167 | verify() | |
| 168 | root.addView(parent) | |
| 169 | parent.content.addView(nested) | |
| 170 | root.addView(standalone) | |
| 171 | screen.protocolIds.clear() | |
| 172 | promoteModalOverlays(screen) | |
| 173 | verify() | |
| 174 | println("PASS modal viewport, nested scrim ordering, sibling ordering, targeted updates and legacy renders") | |
| 175 | } | |
| 176 | `) | |
| 177 | }, 180_000) | |
| @@ -16,9 +16,9 @@ final class NativeRenderUITests: XCTestCase { | ||
| 16 | 16 | } |
| 17 | 17 | |
| 18 | 18 | @discardableResult |
| 19 | private func waitForLabel(_ element: XCUIElement, _ expected: String, timeout: TimeInterval = 3, file: StaticString = #filePath, line: UInt = #line) -> Bool { | |
| 19 | private func waitForLabel(_ element: XCUIElement, _ expected: String, timeout: TimeInterval = 10, file: StaticString = #filePath, line: UInt = #line) -> Bool { | |
| 20 | 20 | let matched = XCTWaiter().wait(for: [XCTNSPredicateExpectation(predicate: NSPredicate(format: "label == %@", expected), object: element)], timeout: timeout) == .completed |
| 21 | XCTAssertTrue(matched, "\(element.identifier) reads \"\(element.label)\", not \"\(expected)\", \(timeout)s after typing", file: file, line: line) | |
| 21 | XCTAssertTrue(matched, "\(element.identifier) reads \(String(reflecting: element.label)), not \(String(reflecting: expected)), \(timeout)s after typing", file: file, line: line) | |
| 22 | 22 | return matched |
| 23 | 23 | } |
| 24 | 24 | |
| @@ -137,6 +137,26 @@ SIMCTL_CHILD_NSUnbufferedIO=YES xcrun simctl launch --terminate-running-process | ||
| 137 | 137 | |
| 138 | 138 | gather_log() { cat "$STDOUT_LOG" "$STDERR_LOG" > "$LOG" 2>/dev/null || true; } |
| 139 | 139 | |
| 140 | collect_failure_diagnostics() { | |
| 141 | # Read-only evidence from this simulator, bounded independently of the | |
| 142 | # unchanged assertion deadline. Never dismiss alerts or change permissions. | |
| 143 | python3 - "$UDID" "$OUT" <<'PY' | |
| 144 | import pathlib, subprocess, sys | |
| 145 | device, output = sys.argv[1], pathlib.Path(sys.argv[2]) | |
| 146 | commands = [ | |
| 147 | ("failure-screenshot.log", ["xcrun", "simctl", "io", device, "screenshot", str(output / "failure.png")]), | |
| 148 | ("failure-location.log", ["xcrun", "simctl", "spawn", device, "log", "show", "--style", "compact", "--last", "20m", "--predicate", 'process == "locationd" OR process == "tccd"']), | |
| 149 | ] | |
| 150 | for name, command in commands: | |
| 151 | with (output / name).open("w") as evidence: | |
| 152 | try: | |
| 153 | result = subprocess.run(command, stdout=evidence, stderr=subprocess.STDOUT, timeout=10) | |
| 154 | evidence.write(f"\nDiagnostic exit: {result.returncode}\n") | |
| 155 | except (OSError, subprocess.TimeoutExpired) as error: | |
| 156 | evidence.write(f"\nDiagnostic unavailable: {error}\n") | |
| 157 | PY | |
| 158 | } | |
| 159 | ||
| 140 | 160 | # The round trip is fast, but a cold simulator is not. Poll rather than sleep a |
| 141 | 161 | # fixed amount, so a slow boot does not read as a failure. |
| 142 | 162 | # |
| @@ -179,7 +199,7 @@ while :; do | ||
| 179 | 199 | cat "$LOG" || true |
| 180 | 200 | echo "FAIL: timed out after ${SLICE_TIMEOUT}s waiting for:$MISSING" |
| 181 | 201 | echo " The app never got that far — this is a timeout, not a wrong answer." |
| 182 | echo " Raise CRAFT_SLICE_TIMEOUT if the machine is simply slow." | |
| 202 | collect_failure_diagnostics | |
| 183 | 203 | xcrun simctl terminate "$UDID" "$BUNDLE_ID" 2>/dev/null || true |
| 184 | 204 | exit 1 |
| 185 | 205 | fi |
| @@ -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', bootstrapHome = 'com.android.sdksetup/.DefaultActivity', playStore: 'ready' | 'delayed' | 'missing' = 'ready'): { 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', homeFocus: 'ready' | 'delayed' | 'foreign' = '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 }) |
| @@ -56,9 +56,20 @@ else | ||
| 56 | 56 | else |
| 57 | 57 | printf 'com.android.launcher3/.Launcher\\n' |
| 58 | 58 | fi ;; |
| 59 | *'logcat -b events -d -t 1') | |
| 59 | *'dumpsys window') | |
| 60 | if [ '${homeFocus}' = foreign ]; then | |
| 61 | printf 'mCurrentFocus=Window{abc u0 com.android.settings/com.android.settings.FallbackHome}\\nmFocusedApp=ActivityRecord{com.android.launcher3/.Launcher}\\n' | |
| 62 | elif [ '${homeFocus}' = delayed ] && [ ! -f "$ANDROID_HOME/home-focused" ]; then | |
| 63 | touch "$ANDROID_HOME/home-focused" | |
| 64 | printf 'mCurrentFocus=null\\n' | |
| 65 | else | |
| 66 | printf 'mCurrentFocus=Window{abc u0 com.android.launcher3/com.android.launcher3.Launcher}\\n' | |
| 67 | fi ;; | |
| 68 | *'logcat -b events -d -t 1') printf 'latest event ready\\n' ;; | |
| 69 | *'logcat -b events -d -v threadtime') | |
| 60 | 70 | if [ '${delayedEvents ? 'yes' : 'no'}' = yes ] && [ ! -f "$ANDROID_HOME/events-ready" ]; then |
| 61 | 71 | touch "$ANDROID_HOME/events-ready" |
| 72 | printf 'partial boot history\\n' | |
| 62 | 73 | exit 255 |
| 63 | 74 | fi |
| 64 | 75 | printf 'event stream ready\\n' ;; |
| @@ -126,6 +137,31 @@ test('an offline guest that never connected still fails its deadline and is disp | ||
| 126 | 137 | finally { rmSync(root, { recursive: true, force: true }) } |
| 127 | 138 | }, 10_000) |
| 128 | 139 | |
| 140 | test('explicit guest memory reaches the emulator process and its retained identity', async () => { | |
| 141 | const { root, options } = fixture(true) | |
| 142 | let session: Awaited<ReturnType<typeof createAndroidEmulator>> | undefined | |
| 143 | try { | |
| 144 | session = await createAndroidEmulator({ ...options, memoryMb: 4096 }) | |
| 145 | expect(readFileSync(join(options.sdkRoot, 'calls'), 'utf8')).toContain('-memory 4096') | |
| 146 | const identity = JSON.parse(readFileSync(join(options.artifactsDirectory, 'android-emulator.json'), 'utf8')) | |
| 147 | expect(identity.memoryMb).toBe(4096) | |
| 148 | expect(identity.virtualCpus).toBe(2) | |
| 149 | } | |
| 150 | finally { await session?.dispose(); rmSync(root, { recursive: true, force: true }) } | |
| 151 | }, 10_000) | |
| 152 | ||
| 153 | test('explicit guest CPU count reaches the emulator process and its retained identity', async () => { | |
| 154 | const { root, options } = fixture(true) | |
| 155 | let session: Awaited<ReturnType<typeof createAndroidEmulator>> | undefined | |
| 156 | try { | |
| 157 | session = await createAndroidEmulator({ ...options, virtualCpus: 4 }) | |
| 158 | expect(readFileSync(join(options.sdkRoot, 'calls'), 'utf8')).toContain('-cores 4') | |
| 159 | const identity = JSON.parse(readFileSync(join(options.artifactsDirectory, 'android-emulator.json'), 'utf8')) | |
| 160 | expect(identity.virtualCpus).toBe(4) | |
| 161 | } | |
| 162 | finally { await session?.dispose(); rmSync(root, { recursive: true, force: true }) } | |
| 163 | }, 10_000) | |
| 164 | ||
| 129 | 165 | test('invalid boot budgets fail before starting tools or creating owned device data', async () => { |
| 130 | 166 | const { root, options } = fixture(true) |
| 131 | 167 | try { |
| @@ -142,6 +178,30 @@ test('invalid boot budgets fail before starting tools or creating owned device d | ||
| 142 | 178 | finally { rmSync(root, { recursive: true, force: true }) } |
| 143 | 179 | }) |
| 144 | 180 | |
| 181 | test('invalid guest memory fails before starting tools or creating owned device data', async () => { | |
| 182 | const { root, options } = fixture(true) | |
| 183 | try { | |
| 184 | for (const memoryMb of [NaN, Infinity, -Infinity, 0, 1535, 8193, 4096.5]) { | |
| 185 | await expect(createAndroidEmulator({ ...options, memoryMb })).rejects.toThrow('memoryMb') | |
| 186 | expect(existsSync(join(options.sdkRoot, 'calls'))).toBe(false) | |
| 187 | expect(existsSync(options.temporaryDirectory!)).toBe(false) | |
| 188 | } | |
| 189 | } | |
| 190 | finally { rmSync(root, { recursive: true, force: true }) } | |
| 191 | }) | |
| 192 | ||
| 193 | test('invalid guest CPU counts fail before starting tools or creating owned device data', async () => { | |
| 194 | const { root, options } = fixture(true) | |
| 195 | try { | |
| 196 | for (const virtualCpus of [NaN, Infinity, -Infinity, 0, 0.5, 9, 8.5]) { | |
| 197 | await expect(createAndroidEmulator({ ...options, virtualCpus })).rejects.toThrow('virtualCpus') | |
| 198 | expect(existsSync(join(options.sdkRoot, 'calls'))).toBe(false) | |
| 199 | expect(existsSync(options.temporaryDirectory!)).toBe(false) | |
| 200 | } | |
| 201 | } | |
| 202 | finally { rmSync(root, { recursive: true, force: true }) } | |
| 203 | }) | |
| 204 | ||
| 145 | 205 | test('boot completion alone does not admit a device before SettingsProvider is usable', async () => { |
| 146 | 206 | const { root, options } = fixture(true, true) |
| 147 | 207 | let session: Awaited<ReturnType<typeof createAndroidEmulator>> | undefined |
| @@ -170,6 +230,7 @@ test('boot and System UI do not admit a device before its event log is readable' | ||
| 170 | 230 | const trace = readFileSync(join(options.artifactsDirectory, 'device-commands.jsonl'), 'utf8').trim().split('\n').map(line => JSON.parse(line)) |
| 171 | 231 | const events = trace.filter(entry => entry.args.includes('logcat')) |
| 172 | 232 | expect(events.map(entry => entry.code)).toEqual([255, 0]) |
| 233 | expect(events[0].stdout).toContain('partial boot history') | |
| 173 | 234 | expect(events.every(entry => entry.args.includes('-d') && entry.args.includes('events'))).toBe(true) |
| 174 | 235 | const identity = JSON.parse(readFileSync(join(options.artifactsDirectory, 'android-emulator.json'), 'utf8')) |
| 175 | 236 | expect(Date.parse(events[1].finishedAt)).toBeLessThanOrEqual(Date.parse(identity.bootedAt)) |
| @@ -254,6 +315,7 @@ test('the SDK process fixture records hardware mode and reaps only its owned pro | ||
| 254 | 315 | session = await createAndroidEmulator(options) |
| 255 | 316 | const identity = JSON.parse(readFileSync(join(options.artifactsDirectory, 'android-emulator.json'), 'utf8')) |
| 256 | 317 | expect(identity.acceleration).toBe('hardware') |
| 318 | expect(identity.memoryMb).toBe(2048) | |
| 257 | 319 | expect(identity.bootedAt).toBeString() |
| 258 | 320 | expect(identity.bootTimeoutMs).toBe(42_000) |
| 259 | 321 | expect(identity.bootElapsedMs).toBeGreaterThanOrEqual(0) |
| @@ -293,3 +355,35 @@ test('software execution is explicit and cannot be mistaken for hardware acceler | ||
| 293 | 355 | } |
| 294 | 356 | finally { await session?.dispose(); rmSync(root, { recursive: true, force: true }) } |
| 295 | 357 | }) |
| 358 | ||
| 359 | ||
| 360 | test('a resolved HOME waits for its actual input window before handing off the device', async () => { | |
| 361 | const { root, options } = fixture(true, false, false, false, 'normal', 'com.android.sdksetup/.DefaultActivity', 'ready', 'delayed') | |
| 362 | let session: Awaited<ReturnType<typeof createAndroidEmulator>> | undefined | |
| 363 | try { | |
| 364 | session = await createAndroidEmulator(options) | |
| 365 | expect(existsSync(join(options.sdkRoot, 'home-focused'))).toBe(true) | |
| 366 | const trace = readFileSync(join(options.artifactsDirectory, 'device-commands.jsonl'), 'utf8').trim().split('\n').map(line => JSON.parse(line)) | |
| 367 | const focus = trace.filter(entry => entry.args.includes('window')) | |
| 368 | expect(focus).toHaveLength(2) | |
| 369 | expect(focus[0].stdout.trim()).toBe('mCurrentFocus=null') | |
| 370 | expect(focus[1].stdout).toContain('com.android.launcher3/com.android.launcher3.Launcher') | |
| 371 | const identity = JSON.parse(readFileSync(join(options.artifactsDirectory, 'android-emulator.json'), 'utf8')) | |
| 372 | expect(Date.parse(focus[1].finishedAt)).toBeLessThanOrEqual(Date.parse(identity.bootedAt)) | |
| 373 | expect(identity.bootTimeoutMs).toBe(600_000) | |
| 374 | } | |
| 375 | finally { await session?.dispose(); rmSync(root, { recursive: true, force: true }) } | |
| 376 | }, 10_000) | |
| 377 | ||
| 378 | test('a foreign focused window cannot admit an otherwise ready emulator', async () => { | |
| 379 | const { root, options } = fixture(true, false, false, false, 'normal', 'com.android.sdksetup/.DefaultActivity', 'ready', 'foreign') | |
| 380 | options.bootTimeoutMs = 5000 | |
| 381 | let session: Awaited<ReturnType<typeof createAndroidEmulator>> | undefined | |
| 382 | try { | |
| 383 | await expect(createAndroidEmulator(options).then(value => { session = value; return value })).rejects.toThrow('did not boot within 5000ms') | |
| 384 | const identity = JSON.parse(readFileSync(join(options.artifactsDirectory, 'android-emulator.json'), 'utf8')) | |
| 385 | expect(identity.status).toBe('failed') | |
| 386 | expect(existsSync(join(options.sdkRoot, 'deleted'))).toBe(true) | |
| 387 | } | |
| 388 | finally { await session?.dispose(); rmSync(root, { recursive: true, force: true }) } | |
| 389 | }, 10_000) | |
scripts/inspect-windows-source.ps1deleted+0-31
| @@ -1,31 +0,0 @@ | ||
| 1 | param([Parameter(Mandatory = $true)][string]$Stage) | |
| 2 | ||
| 3 | $source = Join-Path $PSScriptRoot '../packages/zig/build.zig' | |
| 4 | $present = Test-Path -LiteralPath $source | |
| 5 | $metadata = @{ | |
| 6 | stage = $Stage | |
| 7 | source = $source | |
| 8 | present = $present | |
| 9 | tracked = (& git ls-files --stage -- packages/zig/build.zig) | |
| 10 | status = (& git status --porcelain -- packages/zig/build.zig) | |
| 11 | } | |
| 12 | if ($present) { | |
| 13 | $metadata.sha256 = (Get-FileHash -LiteralPath $source -Algorithm SHA256).Hash | |
| 14 | $metadata.attributes = (Get-Item -LiteralPath $source).Attributes.ToString() | |
| 15 | } | |
| 16 | $metadata | ConvertTo-Json -Compress | |
| 17 | ||
| 18 | if (-not $present -and (Get-Command Get-MpThreatDetection -ErrorAction SilentlyContinue)) { | |
| 19 | try { | |
| 20 | Get-MpThreatDetection | Where-Object { $_.Resources -match 'build\.zig' } | ForEach-Object { | |
| 21 | @{ | |
| 22 | threatId = $_.ThreatID | |
| 23 | actionSucceeded = $_.ActionSuccess | |
| 24 | detectedAt = $_.InitialDetectionTime | |
| 25 | resources = @($_.Resources | Where-Object { $_ -match 'build\.zig' }) | |
| 26 | } | ConvertTo-Json -Compress | |
| 27 | } | |
| 28 | } catch { | |
| 29 | Write-Output "Source security-event inspection unavailable: $($_.Exception.Message)" | |
| 30 | } | |
| 31 | } | |
| @@ -147,6 +147,14 @@ final class NotificationTapColdStartTests: XCTestCase { | ||
| 147 | 147 | .firstMatch |
| 148 | 148 | XCTAssertTrue(banner.waitForExistence(timeout: timeout), "no banner for \(text) ever appeared") |
| 149 | 149 | banner.tap() |
| 150 | XCTAssertTrue(app.wait(for: .runningForeground, timeout: 60), "tapping the banner did not start the app") | |
| 150 | // SpringBoard can leave an app launched by a notification in the | |
| 151 | // background while it finishes dismissing the banner. Give that | |
| 152 | // launch a short chance to reach the foreground itself, then activate | |
| 153 | // the already-running process. Activating a not-running app is not a | |
| 154 | // fallback: the final wait still proves the notification tap launched | |
| 155 | // it rather than hiding a missed banner behind a fresh launch. | |
| 156 | if app.wait(for: .runningForeground, timeout: 10) { return } | |
| 157 | if app.state == .runningBackground { app.activate() } | |
| 158 | XCTAssertTrue(app.wait(for: .runningForeground, timeout: 50), "tapping the banner did not start the app") | |
| 151 | 159 | } |
| 152 | 160 | } |
| @@ -263,7 +263,8 @@ describe('native renderer component contract', () => { | ||
| 263 | 263 | expect(android).toContain('else if (requestedChecked is Boolean)') |
| 264 | 264 | expect(guide).toContain('`checked` is a compatibility alias') |
| 265 | 265 | expect(guide).toContain('`value` is null, and `value` takes precedence') |
| 266 | expect(navigationHome).toContain('<Switch testID="switch-value-precedence" :value="false" :checked="true" @value-change="toggleSwitch($event)" />') | |
| 266 | expect(navigationHome).toContain('<Switch testID="switch-value-precedence" :value="false" :checked="true" @value-change="togglePrecedenceSwitch($event)" />') | |
| 267 | expect(navigationHome).toContain('testID="switch-precedence-status"') | |
| 267 | 268 | expect(navigationHome).toContain('<Switch testID="uncontrolled-switch" />') |
| 268 | 269 | expect(androidNavigationTest).toContain('non-null switch value should take precedence over checked') |
| 269 | 270 | expect(androidNavigationTest).toContain('uncontrolled switch should keep its native toggle') |
| @@ -274,7 +275,8 @@ describe('native renderer component contract', () => { | ||
| 274 | 275 | expect(componentTypes).toContain('checked?: boolean') |
| 275 | 276 | expect(ios).toContain('props["checked"] as? Bool) ?? (props["value"] as? Bool) ?? false') |
| 276 | 277 | expect(android).toContain('checkboxChecked is Boolean -> checkboxChecked') |
| 277 | expect(navigationHome).toContain('<Checkbox testID="checkbox-checked-precedence" :checked="false" :value="true" @value-change="chooseCheckbox($event)" />') | |
| 278 | expect(navigationHome).toContain('<Checkbox testID="checkbox-checked-precedence" :checked="false" :value="true" @value-change="choosePrecedenceCheckbox($event)" />') | |
| 279 | expect(navigationHome).toContain('testID="checkbox-precedence-status"') | |
| 278 | 280 | expect(androidNavigationTest).toContain('checked should take precedence over value') |
| 279 | 281 | expect(iosNavigationTest).toContain('let uncheckedCheckbox = app.buttons["checkbox-checked-precedence"]') |
| 280 | 282 | expect(iosNavigationTest).toContain('XCTAssertFalse(uncheckedCheckbox.isSelected)') |
| @@ -203,11 +203,19 @@ test('workflow setup uses a known Pantry CLI instead of resolving latest', () => | ||
| 203 | 203 | for (const [name, job] of Object.entries(workflow.jobs)) { |
| 204 | 204 | for (const step of job.steps ?? []) { |
| 205 | 205 | if (step.uses?.startsWith('pantry-pm/pantry/packages/action@')) { |
| 206 | // Windows needs the published fix that removes workspace links | |
| 207 | // without following them into the tracked source checkout. | |
| 208 | const preservesWindowsSources = file === 'windows-source-provenance.yml' || (file === 'ci.yml' && name === 'windows-multi-window') | |
| 209 | const version = preservesWindowsSources ? '0.11.109' : '0.11.101' | |
| 210 | const source = preservesWindowsSources ? 'd160044a6fe4ad7e8463828f80dfbc55bcdc07d9' : 'ae59a7b7fc5f4e6f7b8041a75e6b5772680bebdf' | |
| 206 | // Keep each workflow on the released Pantry version it declares. | |
| 207 | // Windows uses the newer workspace-link fix; the native acceptance | |
| 208 | // workflows intentionally stay on their current toolchain pin. | |
| 209 | const version = file === 'windows-source-provenance.yml' || (file === 'ci.yml' && name === 'windows-multi-window') | |
| 210 | ? '0.11.109' | |
| 211 | : ['mobile-e2e.yml', 'benchmarks.yml', 'binary-size.yml'].includes(file) | |
| 212 | ? '0.11.106' | |
| 213 | : '0.11.101' | |
| 214 | const source = version === '0.11.109' | |
| 215 | ? 'd160044a6fe4ad7e8463828f80dfbc55bcdc07d9' | |
| 216 | : version === '0.11.106' | |
| 217 | ? 'e62755c21d946ed0f8dc8e4af39f6ea5f084537a' | |
| 218 | : 'ae59a7b7fc5f4e6f7b8041a75e6b5772680bebdf' | |
| 211 | 219 | expect(step.with?.version, `${file} ${name} must pin Pantry CLI`).toBe(version) |
| 212 | 220 | expect(step.uses, `${file} ${name} must pin the matching action source`).toBe(`pantry-pm/pantry/packages/action@${source}`) |
| 213 | 221 | } |
| @@ -14,6 +14,15 @@ const diagnostic = args.length === 1 | ||
| 14 | 14 | // measured API 35 image took 943520 ms; this explicit software-only profile |
| 15 | 15 | // leaves the public helper default and hardware acceptance at 600000 ms. |
| 16 | 16 | const bootTimeoutMs = 1_200_000 |
| 17 | // Both retained 2 GB-profile failures show low-memory/process-death churn and | |
| 18 | // a SystemUI ANR. Give the unmodified stock image an explicit 4 GB guest; | |
| 19 | // readiness, software acceleration and the boot deadline stay intact. | |
| 20 | const memoryMb = 4096 | |
| 21 | // Intel software-emulation runners have the same four host threads as the | |
| 22 | // passing AMD profile but starve SystemUI with the helper's two-CPU default. | |
| 23 | // Keep the public helper default unchanged while giving this cold profile all | |
| 24 | // four guest CPUs so startup ANRs remain a real failure rather than a timeout. | |
| 25 | const virtualCpus = 4 | |
| 17 | 26 | const expectedFingerprint = diagnostic ? process.env.CRAFT_ANDROID_DIAGNOSTIC_FINGERPRINT : undefined |
| 18 | 27 | if (diagnostic && !expectedFingerprint) |
| 19 | 28 | throw new Error('The cold-provisioning diagnostic requires the failed stock image fingerprint') |
| @@ -35,7 +44,7 @@ let session: Awaited<ReturnType<typeof createAndroidEmulator>> | undefined | ||
| 35 | 44 | const proof: Record<string, unknown> = { |
| 36 | 45 | purpose: diagnostic ? 'software-cold-provisioning-diagnostic-not-release-acceptance' : 'software-emulator-acceptance', |
| 37 | 46 | platform: process.platform, architecture: process.arch, acceleration: 'software', sdkmanager, avdmanager, javaHome, |
| 38 | provisioningProfile: 'stock-play-software-cold', bootTimeoutMs, | |
| 47 | provisioningProfile: 'stock-play-software-cold', bootTimeoutMs, memoryMb, virtualCpus, | |
| 39 | 48 | expectedFingerprint, runnerImage: process.env.ImageOS, runnerImageVersion: process.env.ImageVersion, |
| 40 | 49 | } |
| 41 | 50 | |
| @@ -44,7 +53,7 @@ try { | ||
| 44 | 53 | sdkRoot: join(owned, 'sdk'), sdkmanager, avdmanager, |
| 45 | 54 | image: 'system-images;android-35;google_apis_playstore;x86_64', |
| 46 | 55 | acceleration: 'software', artifactsDirectory: artifacts, |
| 47 | bootTimeoutMs, | |
| 56 | bootTimeoutMs, memoryMb, virtualCpus, | |
| 48 | 57 | temporaryDirectory: owned, acceptLicenses: true, |
| 49 | 58 | env: { JAVA_HOME: javaHome }, |
| 50 | 59 | }) |
| @@ -63,11 +72,15 @@ try { | ||
| 63 | 72 | const abi = await adb(['shell', 'getprop', 'ro.product.cpu.abi']) |
| 64 | 73 | const playStore = await adb(['shell', 'pm', 'path', 'com.android.vending']) |
| 65 | 74 | const fingerprint = await adb(['shell', 'getprop', 'ro.build.fingerprint']) |
| 75 | const meminfo = await adb(['shell', 'cat', '/proc/meminfo']) | |
| 76 | const guestMemTotalKb = Number(/^MemTotal:\s+(\d+)\s+kB$/m.exec(meminfo)?.[1]) | |
| 66 | 77 | if (api !== '35' || abi !== 'x86_64' || !/^package:\/\S+/.test(playStore) || !fingerprint) |
| 67 | 78 | throw new Error('The owned emulator must run the requested stock Play image') |
| 79 | if (!Number.isSafeInteger(guestMemTotalKb) || guestMemTotalKb <= 0) | |
| 80 | throw new Error('The owned emulator must report its actual guest memory') | |
| 68 | 81 | if (diagnostic && fingerprint !== expectedFingerprint) |
| 69 | 82 | throw new Error('The stock Play image changed; this diagnostic cannot explain the retained cold-boot failures') |
| 70 | Object.assign(proof, { serial: session.serial, api, abi, playStore, fingerprint, verifiedAt: new Date().toISOString() }) | |
| 83 | Object.assign(proof, { serial: session.serial, api, abi, playStore, fingerprint, guestMemTotalKb, verifiedAt: new Date().toISOString() }) | |
| 71 | 84 | } |
| 72 | 85 | finally { |
| 73 | 86 | try { |
| @@ -35,7 +35,12 @@ try { | ||
| 35 | 35 | pkg.dependencies['craft-native'] = `file:${join(root, 'packages/typescript')}` |
| 36 | 36 | writeFileSync(packagePath, `${JSON.stringify(pkg, null, 2)}\n`) |
| 37 | 37 | |
| 38 | run(['bun', 'install'], appDir) | |
| 38 | // The local `craft-native` file dependency exposes the SDK checkout while | |
| 39 | // the smoke app is installed. A regular install makes Bun traverse the | |
| 40 | // SDK's development tool graph as well, which can ask the public registry | |
| 41 | // for an unpublished compiler version. The generated app only exercises | |
| 42 | // its runtime/build surface, so keep this check on production dependencies. | |
| 43 | run(['bun', 'install', '--production'], appDir) | |
| 39 | 44 | run(['bun', 'run', 'build'], appDir) |
| 40 | 45 | run(['bun', 'run', 'doctor'], appDir, { |
| 41 | 46 | ...process.env, |
| @@ -1,42 +1,20 @@ | ||
| 1 | import { existsSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' | |
| 2 | import { dirname, join, resolve } from 'node:path' | |
| 1 | import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' | |
| 2 | import { join, resolve } from 'node:path' | |
| 3 | 3 | import { resolveZig } from './zig' |
| 4 | import { createHash } from 'node:crypto' | |
| 5 | import { cpus } from 'node:os' | |
| 6 | 4 | |
| 7 | 5 | const source = resolve(import.meta.dir, '..') |
| 8 | 6 | const zig = resolveZig(source) |
| 9 | if (process.platform === 'win32') { | |
| 10 | const provenance = Bun.spawnSync(['pwsh', '-NoProfile', '-NonInteractive', '-File', join(source, 'scripts/inspect-windows-source.ps1'), '-Stage', 'compiler'], { cwd: source, stdout: 'pipe', stderr: 'pipe' }) | |
| 11 | console.log('Source after compiler:', provenance.stdout.toString(), provenance.stderr.toString()) | |
| 12 | } | |
| 13 | const trackedBuild = join(source, 'packages/zig/build.zig') | |
| 14 | console.log('Tracked core project:', JSON.stringify({ file: trackedBuild, exists: existsSync(trackedBuild), sha256: existsSync(trackedBuild) ? createHash('sha256').update(readFileSync(trackedBuild)).digest('hex') : null, bun: Bun.version, executable: process.execPath, cpu: cpus()[0]?.model, platform: process.platform })) | |
| 15 | const zigLibrary = join(dirname(realpathSync(zig.path)), 'lib/std/zig.zig') | |
| 16 | console.log('Pinned compiler bytes:', JSON.stringify({ executable: zig.path, sha256: createHash('sha256').update(readFileSync(zig.path)).digest('hex'), library: zigLibrary, librarySha256: existsSync(zigLibrary) ? createHash('sha256').update(readFileSync(zigLibrary)).digest('hex') : null })) | |
| 7 | console.log('Tracked core project:', JSON.stringify({ file: join(source, 'packages/zig/build.zig'), exists: existsSync(join(source, 'packages/zig/build.zig')) })) | |
| 17 | 8 | // Keep the fixture on the checkout's volume. Hosted Windows keeps the |
| 18 | 9 | // checkout on D: and the system temporary directory/compiler cache on C:. |
| 19 | 10 | const root = mkdtempSync(join(source, '.zig-core-project-')) |
| 20 | 11 | const core = join(root, 'packages', 'zig') |
| 21 | 12 | try { |
| 22 | 13 | mkdirSync(core, { recursive: true }) |
| 23 | if (process.platform === 'win32') { | |
| 24 | const volumes = Bun.spawnSync(['pwsh', '-NoProfile', '-NonInteractive', '-Command', 'Get-Volume | Where-Object { $_.DriveLetter -in @("C", "D") } | Select-Object DriveLetter,FileSystemType,HealthStatus | ConvertTo-Json -Compress'], { stdout: 'pipe', stderr: 'pipe' }) | |
| 25 | console.log('Native source/cache volumes:', volumes.stdout.toString(), volumes.stderr.toString()) | |
| 26 | } | |
| 27 | const native = Bun.spawnSync([zig.path, 'run', join(source, 'scripts/zig-source-cache-probe.zig'), '--cache-dir', join(root, 'io-build-cache'), '--', join(source, 'packages/zig'), join(root, 'io-manifest-cache')], { cwd: join(source, 'packages/zig'), stdout: 'pipe', stderr: 'pipe' }) | |
| 28 | console.log('Native source/cache probe:', native.exitCode, native.stdout.toString(), native.stderr.toString()) | |
| 29 | if (native.exitCode !== 0) throw new Error('The pinned native source/cache I/O probe failed.') | |
| 30 | 14 | writeFileSync(join(root, 'pantry.lock'), readFileSync(join(source, 'pantry.lock'))) |
| 31 | 15 | writeFileSync(join(root, 'package.json'), JSON.stringify({ version: '1.2.3' })) |
| 32 | 16 | writeFileSync(join(root, 'build.zig'), 'const std=; pub fn build(b:*std.Build) void {_=b.standardOptimizeOption(.{});}\n') |
| 33 | 17 | writeFileSync(join(core, 'build.zig'), 'const std=; pub fn build(b:*std.Build) void {_=b.standardOptimizeOption(.{}); const version=b.option([]const u8,"version","version") orelse "missing"; std.debug.print("PROJECT_VERSION={s}\\n",.{version});}\n') |
| 34 | const manifest = { name: 'craft', fingerprint: '0xf45c4a848bf58c1c' } | |
| 35 | writeFileSync(join(core, 'build.zig.zon'), `.{ .name=.${manifest.name}, .fingerprint=${manifest.fingerprint}, .version="1.2.3", .dependencies=.{ .fixture=.{ .path="../../dependency" } }, .paths=.{"build.zig","build.zig.zon"} }\n`) | |
| 36 | const dependency = join(root, 'dependency') | |
| 37 | mkdirSync(dependency) | |
| 38 | writeFileSync(join(dependency, 'build.zig'), 'const std=; pub fn build(b:*std.Build) void {_=b;}\n') | |
| 39 | writeFileSync(join(dependency, 'build.zig.zon'), `.{ .name=.${manifest.name}, .fingerprint=${manifest.fingerprint}, .version="0.0.1", .paths=.{"build.zig","build.zig.zon"} }\n`) | |
| 40 | 18 | const env = { ...process.env, CRAFT_ZIG: zig.path } |
| 41 | 19 | const runner = join(root, 'runner.ts') |
| 42 | 20 | writeFileSync(runner, `import {runZig} from ${JSON.stringify(join(source, 'scripts/zig.ts'))};process.exitCode=runZig(${JSON.stringify(root)},['--core','--versioned','build']);\n`) |
scripts/zig-source-cache-probe.zigdeleted+0-49
| @@ -1,49 +0,0 @@ | ||
| 1 | const std = ; | |
| 2 | const Io = std.Io; | |
| 3 | const Cache = std.Build.Cache; | |
| 4 | ||
| 5 | pub fn main(init: std.process.Init) !void { | |
| 6 | const arena = init.arena.allocator(); | |
| 7 | const io = init.io; | |
| 8 | const args = try init.minimal.args.toSlice(arena); | |
| 9 | const cwd = try std.process.currentPathAlloc(io, arena); | |
| 10 | std.debug.print("NATIVE_CWD={s}\n", .{cwd}); | |
| 11 | const source = try Io.Dir.cwd().openDir(io, args[1], .{}); | |
| 12 | defer source.close(io); | |
| 13 | const file = source.openFile(io, "build.zig", .{}) catch |err| { | |
| 14 | std.debug.print("SOURCE_OPEN={t}\n", .{err}); | |
| 15 | return err; | |
| 16 | }; | |
| 17 | defer file.close(io); | |
| 18 | const stat = file.stat(io) catch |err| { | |
| 19 | std.debug.print("SOURCE_STAT={t}\n", .{err}); | |
| 20 | return err; | |
| 21 | }; | |
| 22 | const contents = source.readFileAlloc(io, "build.zig", arena, .limited(1024 * 1024)) catch |err| { | |
| 23 | std.debug.print("SOURCE_READ={t}\n", .{err}); | |
| 24 | return err; | |
| 25 | }; | |
| 26 | std.debug.print("SOURCE_SIZE={d} READ_BYTES={d}\n", .{ stat.size, contents.len }); | |
| 27 | var cache: Cache = .{ | |
| 28 | .io = io, | |
| 29 | .gpa = arena, | |
| 30 | .manifest_dir = try Io.Dir.cwd().createDirPathOpen(io, args[2], .{}), | |
| 31 | .cwd = cwd, | |
| 32 | }; | |
| 33 | defer cache.manifest_dir.close(io); | |
| 34 | cache.addPrefix(.{ .path = null, .handle = Io.Dir.cwd() }); | |
| 35 | for (0..4) |_| cache.addPrefix(.{ .path = args[1], .handle = source }); | |
| 36 | var manifest = cache.obtain(); | |
| 37 | defer manifest.deinit(); | |
| 38 | var check_diagnostic: Cache.Manifest.CheckDiagnostic = undefined; | |
| 39 | _ = try manifest.check(&check_diagnostic, .none); | |
| 40 | var diagnostic: Cache.Manifest.AddDiscoveredPathDiagnostic = .none; | |
| 41 | manifest.addDiscoveredPath(.{ | |
| 42 | .discovered_path = .{ .prefixed = .{ .prefix = 4, .sub_path = "build.zig" } }, | |
| 43 | .diagnostic = &diagnostic, | |
| 44 | }) catch |err| { | |
| 45 | std.debug.print("SOURCE_CACHE={t} DETAILS={f}\n", .{ err, diagnostic }); | |
| 46 | return err; | |
| 47 | }; | |
| 48 | std.debug.print("SOURCE_CACHE=passed\n", .{}); | |
| 49 | } | |