ReviewOS

also looking at this

craft-native/craft

fix(native): preserve workspace sources and native overlay lifecycle

#384
Merged chris wants to merge codex/windows-zig-cache-diagnostics into main
20 files +500 -227
.github/workflows/benchmarks.ymlmodified+2-2
Changes to .github/workflows/benchmarks.yml
@@ -35,9 +35,9 @@ jobs:
3535 fetch-depth: 0
3636
3737 - 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
3939 with:
40 version: '0.11.101'
40 version: '0.11.106'
4141 - name: Install Linux dependencies
4242 run: |
4343 sudo apt-get update
.github/workflows/binary-size.ymlmodified+2-2
Changes to .github/workflows/binary-size.yml
@@ -49,9 +49,9 @@ jobs:
4949 fetch-depth: 0
5050
5151 - 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
5353 with:
54 version: '0.11.101'
54 version: '0.11.106'
5555 - name: Install Linux dependencies
5656 run: |
5757 sudo apt-get update
.github/workflows/mobile-e2e.ymlmodified+11-8
Changes to .github/workflows/mobile-e2e.yml
@@ -124,9 +124,9 @@ jobs:
124124 - uses: actions/checkout@v6
125125
126126 - 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
128128 with:
129 version: '0.11.101'
129 version: '0.11.106'
130130
131131 - name: Install dependencies
132132 run: bun install --frozen-lockfile
@@ -276,9 +276,9 @@ jobs:
276276 - uses: actions/checkout@v6
277277
278278 - 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
280280 with:
281 version: '0.11.101'
281 version: '0.11.106'
282282
283283 - name: Setup Bun for the frozen stx compiler lockfile
284284 uses: oven-sh/setup-bun@v2
@@ -411,9 +411,9 @@ jobs:
411411 - uses: actions/checkout@v6
412412
413413 - 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
415415 with:
416 version: '0.11.101'
416 version: '0.11.106'
417417
418418 - name: Setup Bun for the frozen stx compiler lockfile
419419 uses: oven-sh/setup-bun@v2
@@ -581,9 +581,9 @@ jobs:
581581 - uses: actions/checkout@v6
582582
583583 - 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
585585 with:
586 version: '0.11.101'
586 version: '0.11.106'
587587
588588 - name: Cache Zig artifacts
589589 uses: actions/cache@v5
@@ -623,6 +623,9 @@ jobs:
623623 artifacts/mobile-e2e/ios-zig-slice/console.plain
624624 artifacts/mobile-e2e/ios-zig-slice/app-stdout.log
625625 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
626629 if-no-files-found: error
627630 retention-days: 30
628631
packages/android/README.mdmodified+13-2
Changes to packages/android/README.md
@@ -90,6 +90,7 @@ const device = await createAndroidEmulator({
9090 avdmanager: '/ci/pantry/.bin/avdmanager',
9191 image: 'system-images;android-35;google_apis_playstore;x86_64',
9292 acceleration: 'software',
93 memoryMb: 4096,
9394 artifactsDirectory: '/ci/artifacts/android',
9495 acceptLicenses: true,
9596})
@@ -106,13 +107,23 @@ the Android emulator's explicit `-accel off` mode on x86_64 hosts; it provides
106107functional test evidence, with no claim about hardware performance. The public
107108helper defaults to a ten-minute boot budget, and hardware mode cannot exceed
108109it. Software callers may explicitly set `bootTimeoutMs` up to twenty minutes.
110`memoryMb` selects the requested guest RAM, defaulting to 2048 MB. It accepts
111the 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
113from 1 through 8. The stock Play software verifier requests four guest CPUs:
114the four-thread Intel runners used for this acceptance profile otherwise starve
115SystemUI during TCG startup and surface a real platform ANR.
116The stock Play software verifier requests 4096 MB after retained cold boots
117showed low-memory/process-death churn and a SystemUI ANR. It keeps the same
118image, readiness checks and twenty-minute deadline.
109119The stock Play software CI verifier uses that cold-provisioning profile: the
110120API 35 image took 943,520 ms in the retained diagnostic run
111121[39113277828](https://github.com/craft-native/craft/actions/runs/39113277828).
112122This is OS provisioning before app installation; app and performance budgets
113are unchanged. Both modes require a readable event-log stream, SettingsProvider,
123are unchanged. Both modes require a complete event-history read, SettingsProvider,
114124the package manager and System UI to be ready, with HOME resolved beyond the
115automatic Android SDK setup activity. Logs record the budget, actual
125automatic Android SDK setup activity and its launcher package owning the current
126input-focus window. Logs record the budget, actual
116127boot duration, host CPU interval, selected image, acceleration
117128probe, process identity and boot result. Disposal reaps only the owned process
118129and deletes only its disposable AVD; the managed SDK and other devices remain
packages/android/src/emulator.tsmodified+23-4
Changes to packages/android/src/emulator.ts
@@ -14,6 +14,10 @@ export interface AndroidEmulatorOptions {
1414 acceptLicenses?: boolean
1515 /** Defaults to 10 minutes; hardware is capped there, software cold provisioning at 20 minutes. */
1616 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
1721 env?: Record<string, string | undefined>
1822}
1923
@@ -40,6 +44,12 @@ export async function createAndroidEmulator(options: AndroidEmulatorOptions): Pr
4044 const maximumBootTimeoutMs = options.acceleration === 'software' ? 1_200_000 : 600_000
4145 if (!Number.isSafeInteger(bootTimeoutMs) || bootTimeoutMs < 1000 || bootTimeoutMs > maximumBootTimeoutMs)
4246 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')
4353 const port = options.port ?? 5558
4454 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')
4555 const sdk = resolve(options.sdkRoot)
@@ -128,12 +138,12 @@ export async function createAndroidEmulator(options: AndroidEmulatorOptions): Pr
128138 const hostCpus = cpus()
129139 cpuStart = hostCpus.reduce((total, cpu) => total + Object.values(cpu.times).reduce((sum, value) => sum + value, 0), 0)
130140 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 })
132142 identity = {
133143 image: options.image, acceleration: options.acceleration, accelerationProbeExitCode: acceleration.code,
134144 bootTimeoutMs, bootStartedAt: new Date(bootStartedAt).toISOString(),
135145 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,
137147 }
138148 await Bun.write(join(artifacts, 'android-emulator.json'), JSON.stringify(identity, null, 2) + '\n')
139149 const deadline = bootStartedAt + bootTimeoutMs
@@ -168,8 +178,12 @@ export async function createAndroidEmulator(options: AndroidEmulatorOptions): Pr
168178 const systemUi = await run([adb, '-s', serial, 'shell', 'pidof', 'com.android.systemui'], undefined, probeTimeout(), true)
169179 // Native acceptance must be able to observe platform crashes before
170180 // 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
173187 // AOSP and stock Play first boots can report those services ready
174188 // while their automatic SDK setup activity still owns HOME. Launching
175189 // HOME then opens that transient activity; after it finishes UI automation has
@@ -181,10 +195,15 @@ export async function createAndroidEmulator(options: AndroidEmulatorOptions): Pr
181195 || homeActivity.startsWith('com.google.android.googlesdksetup/')
182196 || /^com\.android\.settings\/(?:\.|com\.android\.settings\.)FallbackHome$/.test(homeActivity)
183197 || /^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]
184202 booted = settings.code === 0 && packages.code === 0 && systemUi.code === 0 && events.code === 0
185203 && /^package:\/\S+/m.test(packages.stdout) && /^\d+(?:\s+\d+)*$/.test(systemUi.stdout.trim())
186204 && (!requiresPlayStore || (playStore?.code === 0 && /^package:\/\S+/m.test(playStore.stdout)))
187205 && home.code === 0 && /^[\w.]+\/[\w.$]+$/.test(homeActivity) && !temporaryHome
206 && windows.code === 0 && focusedPackage === homeActivity.split('/')[0]
188207 }
189208 }
190209 const checkedAt = Date.now()
packages/android/src/index.test.tsmodified+15-0
Changes to packages/android/src/index.test.ts
@@ -2630,6 +2630,21 @@ it('emits native Bluetooth GATT support and versioned permissions only when enab
26302630 finally { rmSync(output, { recursive: true, force: true }) }
26312631})
26322632
2633it('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
26332648it('reads a heart-rate strap on the page and on native screens alike, declared only when enabled', async () => {
26342649 for (const renderer of ['native', 'web'] as const) {
26352650 const output = createTempDir(`craft-android-heart-rate-${renderer}-`)
packages/android/src/retained-identity.test.tsmodified+101-0
Changes to packages/android/src/retained-identity.test.ts
@@ -74,3 +74,104 @@ fun main() {
7474}
7575`)
7676}, 180_000)
77
78test('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(`
84import kotlin.math.min
85
86open class View {
87 var parent: ViewGroup? = null
88 fun bringToFront() { val group = parent ?: return; group.children.remove(this); group.children.add(this) }
89}
90open 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}
99class CraftNativeModalView : ViewGroup() {
100 val content = ViewGroup().also(::addView)
101 var viewportStyle = JSONObject("popup-frame")
102 var declarationPath = ""
103}
104class DisplayMetrics { val density = 1f }
105class Resources { val displayMetrics = DisplayMetrics() }
106val resources = Resources()
107class JSONObject(val id: String = "") { fun optJSONObject(name: String): JSONObject? = null }
108class MutationDocument(val parents: Map<String, String>) {
109 fun parentId(id: String): String? = parents[id]
110 fun node(id: String): JSONObject = JSONObject(id)
111}
112class 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}
123fun drawnTarget(screen: Screen, id: String): String = id
124fun 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}
128fun updateParentLayoutStyle(view: View, style: JSONObject): Boolean = true
129fun scheduleLayoutEvents(screen: Screen) { screen.layoutEvents++ }
130${promote}
131${targeted}
132fun 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)
packages/ios/fixtures/native-navigation/Home.stxmodified+21-3
Changes to packages/ios/fixtures/native-navigation/Home.stx
@@ -17,6 +17,8 @@ const panelAccessibilityDisabled = state(false)
1717const longPressStatus = state('')
1818const longPressAccessibilityDisabled = state(false)
1919const switchValue = state(false)
20const nullableSwitchValue = state(true)
21const precedenceSwitchValue = state(false)
2022const invalidSwitchColors = state(false)
2123const pickerValue = state('one')
2224const itemsPickerValue = state('one')
@@ -25,6 +27,7 @@ const dateValue = state('2026-10-12')
2527const timeValue = state('07:30')
2628const countdownValue = state('01:30:45')
2729const checkboxValue = state(false)
30const checkboxPrecedenceValue = state(false)
2831const checkboxStatus = state('checkbox pending')
2932const invalidControlColors = state(false)
3033const invalidControlStyleStatus = state('')
@@ -410,6 +413,14 @@ function toggleSwitch(event) {
410413 status.set(switchValue() ? 'switch on' : 'switch off')
411414}
412415
416function toggleNullableSwitch(event) {
417 nullableSwitchValue.set(event.value)
418}
419
420function togglePrecedenceSwitch(event) {
421 precedenceSwitchValue.set(event.value)
422}
423
413424function choosePicker(event) {
414425 pickerValue.set(event.value)
415426 status.set(`picker ${pickerValue()}`)
@@ -444,6 +455,10 @@ function chooseCheckbox(event) {
444455 checkboxStatus.set(event.value ? 'checkbox on' : 'checkbox off')
445456}
446457
458function choosePrecedenceCheckbox(event) {
459 checkboxPrecedenceValue.set(event.value)
460}
461
447462function toggleInvalidControlColors() {
448463 invalidControlColors.update(value => !value)
449464 invalidControlStyleStatus.set(invalidControlColors() ? 'invalid control colors on' : 'invalid control colors off')
@@ -846,8 +861,10 @@ function openDetailsThenUpdateBackground() {
846861 <Switch testID="uncontrolled-switch" />
847862 <Button testID="toggle-invalid-switch-colors" @click="toggleInvalidSwitchColors()">Toggle invalid switch colors</Button>
848863 {{-- value-change selects the legacy native API when checked is also supplied. --}}
849 <Switch testID="nullable-switch" :value="null" :checked="true" @value-change="toggleSwitch($event)" />
850 <Switch testID="switch-value-precedence" :value="false" :checked="true" @value-change="toggleSwitch($event)" />
864 <Switch testID="nullable-switch" :value="null" :checked="true" @value-change="toggleNullableSwitch($event)" />
865 <Text testID="nullable-switch-status" :text="nullableSwitchValue() ? 'nullable switch on' : 'nullable switch off'" />
866 <Switch testID="switch-value-precedence" :value="false" :checked="true" @value-change="togglePrecedenceSwitch($event)" />
867 <Text testID="switch-precedence-status" :text="precedenceSwitchValue() ? 'precedence switch on' : 'precedence switch off'" />
851868 <select testID="native-picker" :value="pickerValue" @change="choosePicker($event)">
852869 <option value="one">One</option>
853870 <option value="two">Two</option>
@@ -865,7 +882,8 @@ function openDetailsThenUpdateBackground() {
865882 <Checkbox testID="native-checkbox" label="Accept terms" :value="checkboxValue" :color="invalidControlColors ? 'not-a-color' : '#22c55e'" @value-change="chooseCheckbox($event)" />
866883 <Text testID="checkbox-status" :text="checkboxStatus" />
867884 <Checkbox testID="native-checkbox-indeterminate" label="Mixed terms" indeterminate />
868 <Checkbox testID="checkbox-checked-precedence" :checked="false" :value="true" @value-change="chooseCheckbox($event)" />
885 <Checkbox testID="checkbox-checked-precedence" :checked="false" :value="true" @value-change="choosePrecedenceCheckbox($event)" />
886 <Text testID="checkbox-precedence-status" :text="checkboxPrecedenceValue() ? 'precedence checkbox on' : 'precedence checkbox off'" />
869887 <Button testID="toggle-invalid-control-colors" @click="toggleInvalidControlColors()">Toggle invalid control colors</Button>
870888 <Text testID="invalid-control-style-status" :text="invalidControlStyleStatus" />
871889 <Text testID="date-event-status" :text="dateEventStatus" />
packages/ios/fixtures/native-navigation/NativeNavigationUITests.swiftmodified+142-84
Changes to packages/ios/fixtures/native-navigation/NativeNavigationUITests.swift
@@ -1,8 +1,6 @@
11import XCTest
22
33final class NativeNavigationUITests: XCTestCase {
4 private let trackingTypes: Set<XCUIElement.ElementType> = [.scrollView, .picker, .pickerWheel, .datePicker, .slider, .switch, .stepper, .textField, .secureTextField, .textView, .segmentedControl]
5
64 private func layoutCount(_ element: XCUIElement) -> Int {
75 Int(String(element.label.split(separator: ":").last ?? "0").trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0
86 }
@@ -54,11 +52,10 @@ final class NativeNavigationUITests: XCTestCase {
5452
5553 /// A scroll view can extend behind the software keyboard. Its covered
5654 /// portion is not a surface on which a real page drag can begin.
57 private func visibleViewport(_ scroll: XCUIElement) -> CGRect {
58 var viewport = scroll.frame
59 let keyboard = XCUIApplication().keyboards.firstMatch
60 if keyboard.exists {
61 let covered = viewport.intersection(keyboard.frame)
55 private func visibleViewport(_ frame: CGRect, keyboards: [CGRect]) -> CGRect {
56 var viewport = frame
57 if let keyboard = keyboards.first {
58 let covered = viewport.intersection(keyboard)
6259 if !covered.isNull && covered.width >= viewport.width - 1 {
6360 viewport.size.height = max(0, covered.minY - viewport.minY)
6461 }
@@ -66,59 +63,93 @@ final class NativeNavigationUITests: XCTestCase {
6663 return viewport
6764 }
6865
69 private func inside(_ element: XCUIElement, _ scroll: XCUIElement) -> Bool {
70 let frame = element.frame
71 let viewport = visibleViewport(scroll)
66 private func inside(_ frame: CGRect, _ viewport: CGRect) -> Bool {
7267 return !frame.isEmpty && frame.minY >= viewport.minY - 0.5 && frame.maxY <= viewport.maxY + 0.5
7368 }
7469
75 private func manualScrollBlockers(
76 controls: [CGRect],
77 applicationScrollViews: [(identifier: String, frame: CGRect)],
78 excluding identifiers: Set<String>
79 ) -> [CGRect] {
80 controls + applicationScrollViews.filter { !identifiers.contains($0.identifier) }.map { $0.frame }
81 }
70 private let trackingTypes: Set<XCUIElement.ElementType> = [
71 .scrollView, .picker, .pickerWheel, .datePicker, .slider, .switch,
72 .stepper, .textField, .secureTextField, .textView, .segmentedControl,
73 ]
8274
83 private func descendants(of snapshot: XCUIElementSnapshot) -> [XCUIElementSnapshot] {
75 private func descendants(of snapshot: any XCUIElementSnapshot) -> [any XCUIElementSnapshot] {
8476 snapshot.children.flatMap { [$0] + descendants(of: $0) }
8577 }
8678
87 /// Capture fresh geometry per drag. Reading each index-bound element's
88 /// frame re-resolves the remote query for every control on every attempt.
89 private func manualScrollBlockers(in scroll: XCUIElement, application: XCUIApplication, excluding identifiers: Set<String>) throws -> [CGRect] {
90 let controls = descendants(of: try scroll.snapshot())
91 .filter { trackingTypes.contains($0.elementType) }.map { $0.frame }
92 let applicationElements = descendants(of: try application.snapshot())
93 let keyboards = applicationElements.filter { $0.elementType == .keyboard }.map { $0.frame }
94 let scrolling = applicationElements.filter { $0.elementType == .scrollView }
95 .map { (identifier: $0.identifier, frame: $0.frame) }
96 return manualScrollBlockers(controls: controls + keyboards, applicationScrollViews: scrolling, excluding: identifiers)
79 private struct ScrollCapture {
80 let target: CGRect
81 let frame: CGRect
82 let viewport: CGRect
83 let controls: [CGRect]
84 let keyboards: [CGRect]
85 let scrolling: [(identifier: String, frame: CGRect)]
9786 }
9887
99 func testManualScrollSnapshotsPreserveControlAndKeyboardBlockers() throws {
100 let application = XCUIApplication()
101 application.launch()
102 let scroll = application.scrollViews["native-root-scroll"]
103 XCTAssertTrue(scroll.waitForExistence(timeout: 15))
104 let input = application.textViews["notes-input"]
105 reveal(input, in: scroll)
106 input.tap()
107 XCTAssertTrue(application.keyboards.firstMatch.waitForExistence(timeout: 5))
108 let identifiers: Set<String> = [scroll.identifier]
109 let captured = try manualScrollBlockers(in: scroll, application: application, excluding: identifiers)
88 /// Capture each hierarchy once per gesture. Reading every XCUIElement's
89 /// frame separately performs another remote accessibility query per view.
90 private func captureScroll(
91 _ element: XCUIElement,
92 in scroll: XCUIElement,
93 application: XCUIApplication
94 ) throws -> ScrollCapture {
95 let scrollSnapshot = try scroll.snapshot()
96 let applicationNodes = descendants(of: try application.snapshot())
97 let keyboards = applicationNodes.filter { $0.elementType == .keyboard }.map { $0.frame }
98 return ScrollCapture(
99 target: element.frame,
100 frame: scrollSnapshot.frame,
101 viewport: visibleViewport(scrollSnapshot.frame, keyboards: keyboards),
102 controls: descendants(of: scrollSnapshot).filter { trackingTypes.contains($0.elementType) }.map { $0.frame },
103 keyboards: keyboards,
104 scrolling: applicationNodes.filter { $0.elementType == .scrollView }.map { (identifier: $0.identifier, frame: $0.frame) }
105 )
106 }
107
108 private func assertScrollCaptureMatchesLiveQueries(
109 _ element: XCUIElement,
110 in scroll: XCUIElement,
111 application: XCUIApplication
112 ) throws {
113 let capture = try captureScroll(element, in: scroll, application: application)
114 func assertFrames(_ actual: [CGRect], _ expected: [CGRect], _ name: String) {
115 // XCTest can report offscreen accessibility nodes as (inf, inf, 0, 0).
116 // They cannot participate in a gesture, and the snapshot correctly
117 // omits them, so compare only finite, non-empty geometry.
118 let valid: ([CGRect]) -> [CGRect] = { frames in
119 frames.filter { frame in
120 frame.minX.isFinite && frame.minY.isFinite
121 && frame.width.isFinite && frame.height.isFinite
122 && !frame.isEmpty
123 }
124 }
125 let actual = valid(actual)
126 let expected = valid(expected)
127 XCTAssertEqual(actual.count, expected.count, "snapshot lost \(name)")
128 for frame in expected {
129 XCTAssertTrue(actual.contains { candidate in
130 candidate == frame || (
131 abs(candidate.minX - frame.minX) <= 0.5 && abs(candidate.minY - frame.minY) <= 0.5
132 && abs(candidate.width - frame.width) <= 0.5 && abs(candidate.height - frame.height) <= 0.5
133 )
134 }, "snapshot lost a \(name) frame: \(frame)")
135 }
136 }
110137 let controls = scroll.descendants(matching: .any)
111138 .matching(NSPredicate(format: "elementType IN %@", trackingTypes.map { NSNumber(value: $0.rawValue) }))
112139 .allElementsBoundByIndex.map { $0.frame }
113 let keyboards = application.keyboards.allElementsBoundByIndex.map { $0.frame }
114 let scrolling = application.scrollViews.allElementsBoundByIndex.map { (identifier: $0.identifier, frame: $0.frame) }
115 let queried = manualScrollBlockers(controls: controls + keyboards, applicationScrollViews: scrolling, excluding: identifiers)
116 // Absent surfaces can have a zero snapshot frame or a null query
117 // frame. Neither occupies geometry or blocks a manual drag.
118 let capturedFrames = Set(captured.filter { !$0.isEmpty }.map { String(describing: $0) })
119 let queriedFrames = Set(queried.filter { !$0.isEmpty }.map { String(describing: $0) })
120 XCTAssertFalse(capturedFrames.isEmpty)
121 XCTAssertEqual(capturedFrames, queriedFrames)
140 assertFrames(capture.controls, controls, "tracking control")
141 assertFrames(capture.keyboards, application.keyboards.allElementsBoundByIndex.map { $0.frame }, "keyboard")
142 let scrolling = application.scrollViews.allElementsBoundByIndex
143 assertFrames(capture.scrolling.map { $0.frame }, scrolling.map { $0.frame }, "scroll view")
144 XCTAssertEqual(capture.scrolling.map { $0.identifier }.sorted(), scrolling.map { $0.identifier }.sorted())
145 }
146
147 private func manualScrollBlockers(
148 controls: [CGRect],
149 applicationScrollViews: [(identifier: String, frame: CGRect)],
150 excluding identifiers: Set<String>
151 ) -> [CGRect] {
152 controls + applicationScrollViews.filter { !identifiers.contains($0.identifier) }.map { $0.frame }
122153 }
123154
124155 private func manualScrollOrigin(in viewport: CGRect, avoiding frames: [CGRect]) -> CGVector? {
@@ -269,43 +300,47 @@ final class NativeNavigationUITests: XCTestCase {
269300 if let root { activeScrollViews.insert(root.identifier) }
270301 // Budget for actual distance while requiring the same fully visible,
271302 // hittable target and retaining a hard bound on manual drags.
272 let distanceToTarget = abs(element.frame.midY - visibleViewport(scroll).midY)
273 let attempts = min(128, max(24, Int(ceil(distanceToTarget / 200)) + 8))
274 for attempt in 0..<attempts {
275 if inside(element, scroll) && element.isHittable { return }
276 let scrollFrame = scroll.frame
277 let viewport = visibleViewport(scroll)
278 // Native value controls, selection and nested scrolling retain
279 // their gesture ownership; search the remaining visible gaps.
280 // Prediction strips can be sibling scroll surfaces outside the
281 // app's subtree. Exclude only the active scroll and its root.
282 let blockers: [CGRect]
283 do {
284 blockers = try manualScrollBlockers(in: scroll, application: application, excluding: activeScrollViews)
285 } catch {
286 XCTFail("unable to capture manual scroll geometry: \(error)", file: file, line: line)
287 return
288 }
289 let competing = blockers.filter { !$0.isEmpty }.map { $0.insetBy(dx: -4, dy: -4) }
290 .filter { !$0.isEmpty && $0.intersects(viewport) }
291 guard let origin = manualScrollOrigin(in: viewport, avoiding: competing) else {
292 XCTFail("no safe manual scroll origin: target=\(element.frame), viewport=\(viewport), competing=\(competing)", file: file, line: line)
293 return
294 }
295 let start = CGVector(
296 dx: (viewport.minX + origin.dx * viewport.width - scrollFrame.minX) / scrollFrame.width,
297 dy: (viewport.minY + origin.dy * viewport.height - scrollFrame.minY) / scrollFrame.height
298 )
299 // Content moves the opposite way to the finger, which may leave
300 // a short scroll view: the drag stays its own until it lifts.
301 let distance = max(-240, min(240, element.frame.midY - viewport.midY))
302 let from = scroll.coordinate(withNormalizedOffset: start)
303 let to = from.withOffset(CGVector(dx: 0, dy: -distance))
304 XCTContext.runActivity(named: "manual scroll \(attempt + 1): target=\(element.frame), viewport=\(viewport), origin=\(start), distance=\(distance), competing=\(competing)") { _ in
305 from.press(forDuration: 0.05, thenDragTo: to, withVelocity: .slow, thenHoldForDuration: 0.3)
303 do {
304 var capture = try captureScroll(element, in: scroll, application: application)
305 let distanceToTarget = abs(capture.target.midY - capture.viewport.midY)
306 let attempts = min(128, max(24, Int(ceil(distanceToTarget / 200)) + 8))
307 for attempt in 0..<attempts {
308 if attempt > 0 { capture = try captureScroll(element, in: scroll, application: application) }
309 if inside(capture.target, capture.viewport) && element.isHittable { return }
310 let scrollFrame = capture.frame
311 let viewport = capture.viewport
312 // Native value controls, selection and nested scrolling retain
313 // their gesture ownership; search the remaining visible gaps.
314 // Prediction strips can be sibling scroll surfaces outside the
315 // app's subtree. Exclude only the active scroll and its root.
316 let competing = manualScrollBlockers(
317 controls: capture.controls + capture.keyboards,
318 applicationScrollViews: capture.scrolling,
319 excluding: activeScrollViews
320 ).map { $0.insetBy(dx: -4, dy: -4) }
321 .filter { !$0.isEmpty && $0.intersects(viewport) }
322 guard let origin = manualScrollOrigin(in: viewport, avoiding: competing) else {
323 XCTFail("no safe manual scroll origin: target=\(capture.target), viewport=\(viewport), competing=\(competing)", file: file, line: line)
324 return
325 }
326 let start = CGVector(
327 dx: (viewport.minX + origin.dx * viewport.width - scrollFrame.minX) / scrollFrame.width,
328 dy: (viewport.minY + origin.dy * viewport.height - scrollFrame.minY) / scrollFrame.height
329 )
330 // Content moves the opposite way to the finger, which may leave
331 // a short scroll view: the drag stays its own until it lifts.
332 let distance = max(-240, min(240, capture.target.midY - viewport.midY))
333 let from = scroll.coordinate(withNormalizedOffset: start)
334 let to = from.withOffset(CGVector(dx: 0, dy: -distance))
335 XCTContext.runActivity(named: "manual scroll \(attempt + 1): target=\(capture.target), viewport=\(viewport), origin=\(start), distance=\(distance), competing=\(competing)") { _ in
336 from.press(forDuration: 0.05, thenDragTo: to, withVelocity: .slow, thenHoldForDuration: 0.3)
337 }
306338 }
339 capture = try captureScroll(element, in: scroll, application: application)
340 XCTAssertTrue(inside(capture.target, capture.viewport) && element.isHittable, "control remained outside the native scroll viewport: target=\(capture.target), viewport=\(capture.frame), hittable=\(element.isHittable)", file: file, line: line)
341 } catch {
342 XCTFail("could not capture the native scroll hierarchy: \(error)", file: file, line: line)
307343 }
308 XCTAssertTrue(inside(element, scroll) && element.isHittable, "control remained outside the native scroll viewport: target=\(element.frame), viewport=\(scroll.frame), hittable=\(element.isHittable)", file: file, line: line)
309344 }
310345
311346 private func openCapabilities(_ app: XCUIApplication) {
@@ -395,7 +430,7 @@ final class NativeNavigationUITests: XCTestCase {
395430 XCTAssertEqual(app.staticTexts["safe-area-status"].label, "Safe edges: top,bottom", "Scrolling must cancel the button's tap")
396431 }
397432
398 func testMultilineInputKeepsKeyboardFocusWhenTapped() {
433 func testMultilineInputKeepsKeyboardFocusWhenTapped() throws {
399434 let app = XCUIApplication()
400435 app.launch()
401436 let root = app.scrollViews["native-root-scroll"]
@@ -414,6 +449,7 @@ final class NativeNavigationUITests: XCTestCase {
414449 XCTAssertEqual(notes.value as? String, "Draft edited")
415450 notes.tap()
416451 XCTAssertTrue(app.keyboards.firstMatch.exists, "Tapping the text area again must preserve editing")
452 try assertScrollCaptureMatchesLiveQueries(notes, in: root, application: app)
417453 }
418454
419455 func testHorizontalCarouselKeepsItsSwipe() {
@@ -786,6 +822,14 @@ final class NativeNavigationUITests: XCTestCase {
786822 let uncheckedCheckbox = app.buttons["checkbox-checked-precedence"]
787823 XCTAssertFalse(uncheckedCheckbox.isSelected)
788824 XCTAssertEqual(uncheckedCheckbox.value as? String, "unchecked")
825 uncheckedCheckbox.tap()
826 XCTAssertTrue(waitForLabel(app.staticTexts["checkbox-precedence-status"], "precedence checkbox on"))
827 XCTAssertTrue(uncheckedCheckbox.isSelected)
828 XCTAssertEqual(uncheckedCheckbox.value as? String, "checked")
829 uncheckedCheckbox.tap()
830 XCTAssertTrue(waitForLabel(app.staticTexts["checkbox-precedence-status"], "precedence checkbox off"))
831 XCTAssertFalse(uncheckedCheckbox.isSelected)
832 XCTAssertEqual(uncheckedCheckbox.value as? String, "unchecked")
789833 XCTAssertTrue(app.buttons["disabled-button"].waitForExistence(timeout: 5))
790834 XCTAssertFalse(app.buttons["disabled-button"].isEnabled)
791835 XCTAssertEqual(image.label, "Native pixel")
@@ -1029,6 +1073,20 @@ final class NativeNavigationUITests: XCTestCase {
10291073 XCTAssertTrue(toggle.isEnabled)
10301074 XCTAssertEqual(app.switches["nullable-switch"].value as? String, "1")
10311075 XCTAssertEqual(app.switches["switch-value-precedence"].value as? String, "0")
1076 let nullableSwitch = app.switches["nullable-switch"]
1077 nullableSwitch.tap()
1078 XCTAssertTrue(waitForLabel(app.staticTexts["nullable-switch-status"], "nullable switch off"))
1079 XCTAssertEqual(nullableSwitch.value as? String, "0")
1080 nullableSwitch.tap()
1081 XCTAssertTrue(waitForLabel(app.staticTexts["nullable-switch-status"], "nullable switch on"))
1082 XCTAssertEqual(nullableSwitch.value as? String, "1")
1083 let precedenceSwitch = app.switches["switch-value-precedence"]
1084 precedenceSwitch.tap()
1085 XCTAssertTrue(waitForLabel(app.staticTexts["switch-precedence-status"], "precedence switch on"))
1086 XCTAssertEqual(precedenceSwitch.value as? String, "1")
1087 precedenceSwitch.tap()
1088 XCTAssertTrue(waitForLabel(app.staticTexts["switch-precedence-status"], "precedence switch off"))
1089 XCTAssertEqual(precedenceSwitch.value as? String, "0")
10321090 toggle.tap()
10331091 XCTAssertTrue(app.staticTexts["switch on"].waitForExistence(timeout: 5))
10341092 XCTAssertEqual(toggle.value as? String, "1")
packages/ios/fixtures/native-screen/NativeRenderUITests.swiftmodified+2-2
Changes to packages/ios/fixtures/native-screen/NativeRenderUITests.swift
@@ -16,9 +16,9 @@ final class NativeRenderUITests: XCTestCase {
1616 }
1717
1818 @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 {
2020 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)
2222 return matched
2323 }
2424
packages/ios/fixtures/zig-slice/build-and-run.shmodified+21-1
Changes to packages/ios/fixtures/zig-slice/build-and-run.sh
@@ -137,6 +137,26 @@ SIMCTL_CHILD_NSUnbufferedIO=YES xcrun simctl launch --terminate-running-process
137137
138138gather_log() { cat "$STDOUT_LOG" "$STDERR_LOG" > "$LOG" 2>/dev/null || true; }
139139
140collect_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'
144import pathlib, subprocess, sys
145device, output = sys.argv[1], pathlib.Path(sys.argv[2])
146commands = [
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]
150for 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")
157PY
158}
159
140160# The round trip is fast, but a cold simulator is not. Poll rather than sleep a
141161# fixed amount, so a slow boot does not read as a failure.
142162#
@@ -179,7 +199,7 @@ while :; do
179199 cat "$LOG" || true
180200 echo "FAIL: timed out after ${SLICE_TIMEOUT}s waiting for:$MISSING"
181201 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
183203 xcrun simctl terminate "$UDID" "$BUNDLE_ID" 2>/dev/null || true
184204 exit 1
185205 fi
packages/typescript/src/__tests__/android-emulator.test.tsmodified+96-2
Changes to packages/typescript/src/__tests__/android-emulator.test.ts
@@ -5,7 +5,7 @@ import { join } from 'node:path'
55import { createAndroidEmulator } from '../../../android/src/emulator'
66import type { AndroidEmulatorOptions } from '../../../android/src/emulator'
77
8function fixture(hardware: boolean, delayedSettings = false, delayedEvents = false, delayedHome = false, connection: 'normal' | 'stale' | 'offline' = 'normal', bootstrapHome = 'com.android.sdksetup/.DefaultActivity', playStore: 'ready' | 'delayed' | 'missing' = 'ready'): { root: string, options: AndroidEmulatorOptions } {
8function fixture(hardware: boolean, delayedSettings = false, delayedEvents = false, delayedHome = false, connection: 'normal' | 'stale' | 'offline' = 'normal', bootstrapHome = 'com.android.sdksetup/.DefaultActivity', playStore: 'ready' | 'delayed' | 'missing' = 'ready', homeFocus: 'ready' | 'delayed' | 'foreign' = 'ready'): { root: string, options: AndroidEmulatorOptions } {
99 const root = mkdtempSync(join(tmpdir(), 'craft emulator fixture '))
1010 const sdk = join(root, 'managed sdk')
1111 for (const directory of ['bin', 'platform-tools', 'emulator']) mkdirSync(join(sdk, directory), { recursive: true })
@@ -56,9 +56,20 @@ else
5656 else
5757 printf 'com.android.launcher3/.Launcher\\n'
5858 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')
6070 if [ '${delayedEvents ? 'yes' : 'no'}' = yes ] && [ ! -f "$ANDROID_HOME/events-ready" ]; then
6171 touch "$ANDROID_HOME/events-ready"
72 printf 'partial boot history\\n'
6273 exit 255
6374 fi
6475 printf 'event stream ready\\n' ;;
@@ -126,6 +137,31 @@ test('an offline guest that never connected still fails its deadline and is disp
126137 finally { rmSync(root, { recursive: true, force: true }) }
127138}, 10_000)
128139
140test('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
153test('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
129165test('invalid boot budgets fail before starting tools or creating owned device data', async () => {
130166 const { root, options } = fixture(true)
131167 try {
@@ -142,6 +178,30 @@ test('invalid boot budgets fail before starting tools or creating owned device d
142178 finally { rmSync(root, { recursive: true, force: true }) }
143179})
144180
181test('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
193test('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
145205test('boot completion alone does not admit a device before SettingsProvider is usable', async () => {
146206 const { root, options } = fixture(true, true)
147207 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'
170230 const trace = readFileSync(join(options.artifactsDirectory, 'device-commands.jsonl'), 'utf8').trim().split('\n').map(line => JSON.parse(line))
171231 const events = trace.filter(entry => entry.args.includes('logcat'))
172232 expect(events.map(entry => entry.code)).toEqual([255, 0])
233 expect(events[0].stdout).toContain('partial boot history')
173234 expect(events.every(entry => entry.args.includes('-d') && entry.args.includes('events'))).toBe(true)
174235 const identity = JSON.parse(readFileSync(join(options.artifactsDirectory, 'android-emulator.json'), 'utf8'))
175236 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
254315 session = await createAndroidEmulator(options)
255316 const identity = JSON.parse(readFileSync(join(options.artifactsDirectory, 'android-emulator.json'), 'utf8'))
256317 expect(identity.acceleration).toBe('hardware')
318 expect(identity.memoryMb).toBe(2048)
257319 expect(identity.bootedAt).toBeString()
258320 expect(identity.bootTimeoutMs).toBe(42_000)
259321 expect(identity.bootElapsedMs).toBeGreaterThanOrEqual(0)
@@ -293,3 +355,35 @@ test('software execution is explicit and cannot be mistaken for hardware acceler
293355 }
294356 finally { await session?.dispose(); rmSync(root, { recursive: true, force: true }) }
295357})
358
359
360test('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
378test('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
Changes to scripts/inspect-windows-source.ps1
@@ -1,31 +0,0 @@
1param([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}
12if ($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
18if (-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}
scripts/mobile-e2e/ios-uitests/NotificationTapColdStartTests.swiftmodified+9-1
Changes to scripts/mobile-e2e/ios-uitests/NotificationTapColdStartTests.swift
@@ -147,6 +147,14 @@ final class NotificationTapColdStartTests: XCTestCase {
147147 .firstMatch
148148 XCTAssertTrue(banner.waitForExistence(timeout: timeout), "no banner for \(text) ever appeared")
149149 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")
151159 }
152160}
scripts/native-renderer-contract.test.tsmodified+4-2
Changes to scripts/native-renderer-contract.test.ts
@@ -263,7 +263,8 @@ describe('native renderer component contract', () => {
263263 expect(android).toContain('else if (requestedChecked is Boolean)')
264264 expect(guide).toContain('`checked` is a compatibility alias')
265265 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"')
267268 expect(navigationHome).toContain('<Switch testID="uncontrolled-switch" />')
268269 expect(androidNavigationTest).toContain('non-null switch value should take precedence over checked')
269270 expect(androidNavigationTest).toContain('uncontrolled switch should keep its native toggle')
@@ -274,7 +275,8 @@ describe('native renderer component contract', () => {
274275 expect(componentTypes).toContain('checked?: boolean')
275276 expect(ios).toContain('props["checked"] as? Bool) ?? (props["value"] as? Bool) ?? false')
276277 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"')
278280 expect(androidNavigationTest).toContain('checked should take precedence over value')
279281 expect(iosNavigationTest).toContain('let uncheckedCheckbox = app.buttons["checkbox-checked-precedence"]')
280282 expect(iosNavigationTest).toContain('XCTAssertFalse(uncheckedCheckbox.isSelected)')
scripts/release-workflow.test.tsmodified+13-5
Changes to scripts/release-workflow.test.ts
@@ -203,11 +203,19 @@ test('workflow setup uses a known Pantry CLI instead of resolving latest', () =>
203203 for (const [name, job] of Object.entries(workflow.jobs)) {
204204 for (const step of job.steps ?? []) {
205205 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'
211219 expect(step.with?.version, `${file} ${name} must pin Pantry CLI`).toBe(version)
212220 expect(step.uses, `${file} ${name} must pin the matching action source`).toBe(`pantry-pm/pantry/packages/action@${source}`)
213221 }
scripts/verify-android-emulator.tsmodified+16-3
Changes to scripts/verify-android-emulator.ts
@@ -14,6 +14,15 @@ const diagnostic = args.length === 1
1414// measured API 35 image took 943520 ms; this explicit software-only profile
1515// leaves the public helper default and hardware acceptance at 600000 ms.
1616const 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.
20const 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.
25const virtualCpus = 4
1726const expectedFingerprint = diagnostic ? process.env.CRAFT_ANDROID_DIAGNOSTIC_FINGERPRINT : undefined
1827if (diagnostic && !expectedFingerprint)
1928 throw new Error('The cold-provisioning diagnostic requires the failed stock image fingerprint')
@@ -35,7 +44,7 @@ let session: Awaited<ReturnType<typeof createAndroidEmulator>> | undefined
3544const proof: Record<string, unknown> = {
3645 purpose: diagnostic ? 'software-cold-provisioning-diagnostic-not-release-acceptance' : 'software-emulator-acceptance',
3746 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,
3948 expectedFingerprint, runnerImage: process.env.ImageOS, runnerImageVersion: process.env.ImageVersion,
4049}
4150
@@ -44,7 +53,7 @@ try {
4453 sdkRoot: join(owned, 'sdk'), sdkmanager, avdmanager,
4554 image: 'system-images;android-35;google_apis_playstore;x86_64',
4655 acceleration: 'software', artifactsDirectory: artifacts,
47 bootTimeoutMs,
56 bootTimeoutMs, memoryMb, virtualCpus,
4857 temporaryDirectory: owned, acceptLicenses: true,
4958 env: { JAVA_HOME: javaHome },
5059 })
@@ -63,11 +72,15 @@ try {
6372 const abi = await adb(['shell', 'getprop', 'ro.product.cpu.abi'])
6473 const playStore = await adb(['shell', 'pm', 'path', 'com.android.vending'])
6574 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])
6677 if (api !== '35' || abi !== 'x86_64' || !/^package:\/\S+/.test(playStore) || !fingerprint)
6778 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')
6881 if (diagnostic && fingerprint !== expectedFingerprint)
6982 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() })
7184}
7285finally {
7386 try {
scripts/verify-create-craft.tsmodified+6-1
Changes to scripts/verify-create-craft.ts
@@ -35,7 +35,12 @@ try {
3535 pkg.dependencies['craft-native'] = `file:${join(root, 'packages/typescript')}`
3636 writeFileSync(packagePath, `${JSON.stringify(pkg, null, 2)}\n`)
3737
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)
3944 run(['bun', 'run', 'build'], appDir)
4045 run(['bun', 'run', 'doctor'], appDir, {
4146 ...process.env,
scripts/verify-zig-core-project.tsmodified+3-25
Changes to scripts/verify-zig-core-project.ts
@@ -1,42 +1,20 @@
1import { existsSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
2import { dirname, join, resolve } from 'node:path'
1import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2import { join, resolve } from 'node:path'
33import { resolveZig } from './zig'
4import { createHash } from 'node:crypto'
5import { cpus } from 'node:os'
64
75const source = resolve(import.meta.dir, '..')
86const zig = resolveZig(source)
9if (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}
13const trackedBuild = join(source, 'packages/zig/build.zig')
14console.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 }))
15const zigLibrary = join(dirname(realpathSync(zig.path)), 'lib/std/zig.zig')
16console.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 }))
7console.log('Tracked core project:', JSON.stringify({ file: join(source, 'packages/zig/build.zig'), exists: existsSync(join(source, 'packages/zig/build.zig')) }))
178// Keep the fixture on the checkout's volume. Hosted Windows keeps the
189// checkout on D: and the system temporary directory/compiler cache on C:.
1910const root = mkdtempSync(join(source, '.zig-core-project-'))
2011const core = join(root, 'packages', 'zig')
2112try {
2213 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.')
3014 writeFileSync(join(root, 'pantry.lock'), readFileSync(join(source, 'pantry.lock')))
3115 writeFileSync(join(root, 'package.json'), JSON.stringify({ version: '1.2.3' }))
3216 writeFileSync(join(root, 'build.zig'), 'const std=; pub fn build(b:*std.Build) void {_=b.standardOptimizeOption(.{});}\n')
3317 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`)
4018 const env = { ...process.env, CRAFT_ZIG: zig.path }
4119 const runner = join(root, 'runner.ts')
4220 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
Changes to scripts/zig-source-cache-probe.zig
@@ -1,49 +0,0 @@
1const std = ;
2const Io = std.Io;
3const Cache = std.Build.Cache;
4
5pub 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}