also looking at this
fix(native): preserve workspace sources and native overlay lifecycle
#384
20 files
+500
-227
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -110,9 +110,10 @@ The stock Play software CI verifier uses that cold-provisioning profile: the | ||
| 110 | 110 | API 35 image took 943,520 ms in the retained diagnostic run |
| 111 | 111 | [39113277828](https://github.com/craft-native/craft/actions/runs/39113277828). |
| 112 | 112 | This is OS provisioning before app installation; app and performance budgets |
| 113 | are unchanged. Both modes require a readable event-log stream, SettingsProvider, | |
| 113 | are unchanged. Both modes require a complete event-history read, SettingsProvider, | |
| 114 | 114 | 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 | |
| 115 | automatic Android SDK setup activity and its launcher package owning the current | |
| 116 | input-focus window. Logs record the budget, actual | |
| 116 | 117 | boot duration, host CPU interval, selected image, acceleration |
| 117 | 118 | probe, process identity and boot result. Disposal reaps only the owned process |
| 118 | 119 | and deletes only its disposable AVD; the managed SDK and other devices remain |
| @@ -168,8 +168,9 @@ export async function createAndroidEmulator(options: AndroidEmulatorOptions): Pr | ||
| 168 | 168 | const systemUi = await run([adb, '-s', serial, 'shell', 'pidof', 'com.android.systemui'], undefined, probeTimeout(), true) |
| 169 | 169 | // Native acceptance must be able to observe platform crashes before |
| 170 | 170 | // 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) | |
| 171 | // event stream. Require a complete history read so a partial transport | |
| 172 | // read cannot admit the device before its first health check. | |
| 173 | const events = await run([adb, '-s', serial, 'logcat', '-b', 'events', '-d', '-v', 'threadtime'], undefined, probeTimeout(), true) | |
| 173 | 174 | // AOSP's first boot can report all those services ready while its |
| 174 | 175 | // automatic SDK setup activity still owns HOME. Launching HOME then |
| 175 | 176 | // opens that transient activity; after it finishes UI automation has |
| @@ -180,10 +181,15 @@ export async function createAndroidEmulator(options: AndroidEmulatorOptions): Pr | ||
| 180 | 181 | const temporaryHome = homeActivity.startsWith('com.android.sdksetup/') |
| 181 | 182 | || /^com\.android\.settings\/(?:\.|com\.android\.settings\.)FallbackHome$/.test(homeActivity) |
| 182 | 183 | || /^android\/.*(?:Resolver|Chooser)Activity$/.test(homeActivity) |
| 184 | // Resolving HOME can precede its first input window during cold | |
| 185 | // provisioning. Hand off only once the launcher actually has focus. | |
| 186 | const windows = await run([adb, '-s', serial, 'shell', 'dumpsys', 'window'], undefined, probeTimeout(), true) | |
| 187 | const focusedPackage = windows.stdout.match(/^\s*mCurrentFocus\s*=\s*Window\{[^}\r\n]*\bu\d+\s+([\w.]+)\//m)?.[1] | |
| 183 | 188 | booted = settings.code === 0 && packages.code === 0 && systemUi.code === 0 && events.code === 0 |
| 184 | 189 | && /^package:\/\S+/m.test(packages.stdout) && /^\d+(?:\s+\d+)*$/.test(systemUi.stdout.trim()) |
| 185 | 190 | && (!requiresPlayStore || (playStore?.code === 0 && /^package:\/\S+/m.test(playStore.stdout))) |
| 186 | 191 | && home.code === 0 && /^[\w.]+\/[\w.$]+$/.test(homeActivity) && !temporaryHome |
| 192 | && windows.code === 0 && focusedPackage === homeActivity.split('/')[0] | |
| 187 | 193 | } |
| 188 | 194 | } |
| 189 | 195 | const checkedAt = Date.now() |
| @@ -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' ;; |
| @@ -170,6 +181,7 @@ test('boot and System UI do not admit a device before its event log is readable' | ||
| 170 | 181 | const trace = readFileSync(join(options.artifactsDirectory, 'device-commands.jsonl'), 'utf8').trim().split('\n').map(line => JSON.parse(line)) |
| 171 | 182 | const events = trace.filter(entry => entry.args.includes('logcat')) |
| 172 | 183 | expect(events.map(entry => entry.code)).toEqual([255, 0]) |
| 184 | expect(events[0].stdout).toContain('partial boot history') | |
| 173 | 185 | expect(events.every(entry => entry.args.includes('-d') && entry.args.includes('events'))).toBe(true) |
| 174 | 186 | const identity = JSON.parse(readFileSync(join(options.artifactsDirectory, 'android-emulator.json'), 'utf8')) |
| 175 | 187 | expect(Date.parse(events[1].finishedAt)).toBeLessThanOrEqual(Date.parse(identity.bootedAt)) |
| @@ -293,3 +305,35 @@ test('software execution is explicit and cannot be mistaken for hardware acceler | ||
| 293 | 305 | } |
| 294 | 306 | finally { await session?.dispose(); rmSync(root, { recursive: true, force: true }) } |
| 295 | 307 | }) |
| 308 | ||
| 309 | ||
| 310 | test('a resolved HOME waits for its actual input window before handing off the device', async () => { | |
| 311 | const { root, options } = fixture(true, false, false, false, 'normal', 'com.android.sdksetup/.DefaultActivity', 'ready', 'delayed') | |
| 312 | let session: Awaited<ReturnType<typeof createAndroidEmulator>> | undefined | |
| 313 | try { | |
| 314 | session = await createAndroidEmulator(options) | |
| 315 | expect(existsSync(join(options.sdkRoot, 'home-focused'))).toBe(true) | |
| 316 | const trace = readFileSync(join(options.artifactsDirectory, 'device-commands.jsonl'), 'utf8').trim().split('\n').map(line => JSON.parse(line)) | |
| 317 | const focus = trace.filter(entry => entry.args.includes('window')) | |
| 318 | expect(focus).toHaveLength(2) | |
| 319 | expect(focus[0].stdout.trim()).toBe('mCurrentFocus=null') | |
| 320 | expect(focus[1].stdout).toContain('com.android.launcher3/com.android.launcher3.Launcher') | |
| 321 | const identity = JSON.parse(readFileSync(join(options.artifactsDirectory, 'android-emulator.json'), 'utf8')) | |
| 322 | expect(Date.parse(focus[1].finishedAt)).toBeLessThanOrEqual(Date.parse(identity.bootedAt)) | |
| 323 | expect(identity.bootTimeoutMs).toBe(600_000) | |
| 324 | } | |
| 325 | finally { await session?.dispose(); rmSync(root, { recursive: true, force: true }) } | |
| 326 | }, 10_000) | |
| 327 | ||
| 328 | test('a foreign focused window cannot admit an otherwise ready emulator', async () => { | |
| 329 | const { root, options } = fixture(true, false, false, false, 'normal', 'com.android.sdksetup/.DefaultActivity', 'ready', 'foreign') | |
| 330 | options.bootTimeoutMs = 5000 | |
| 331 | let session: Awaited<ReturnType<typeof createAndroidEmulator>> | undefined | |
| 332 | try { | |
| 333 | await expect(createAndroidEmulator(options).then(value => { session = value; return value })).rejects.toThrow('did not boot within 5000ms') | |
| 334 | const identity = JSON.parse(readFileSync(join(options.artifactsDirectory, 'android-emulator.json'), 'utf8')) | |
| 335 | expect(identity.status).toBe('failed') | |
| 336 | expect(existsSync(join(options.sdkRoot, 'deleted'))).toBe(true) | |
| 337 | } | |
| 338 | finally { await session?.dispose(); rmSync(root, { recursive: true, force: true }) } | |
| 339 | }, 10_000) | |