also looking at this
fix(android): resolve share() with whether the person picked an app
#210
12 files
+399
-56
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.
| @@ -49,10 +49,16 @@ bun run test:mobile-e2e:android | ||
| 49 | 49 | ``` |
| 50 | 50 | |
| 51 | 51 | ``` |
| 52 | ok android-shim — 4/4 cases, no zig | |
| 53 | ok android-runtime — 4/4 cases, zig served registered:103, declines:0 | |
| 52 | ok android-shim — 6/6 cases, no zig | |
| 53 | ok android-runtime — 6/6 cases, zig served registered:103, declines:0 | |
| 54 | 54 | ``` |
| 55 | 55 | |
| 56 | One Android case needs a person: `share.dismissed.resolvesFalse` opens the | |
| 57 | real share menu and expects `false` once it is dismissed. The harness plays | |
| 58 | that part. It waits until `dumpsys window` shows the chooser holding input | |
| 59 | focus, saves `share-menu.png`, presses Back, and fails the run if the page asked | |
| 60 | for a dismissal and no menu ever appeared. | |
| 61 | ||
| 56 | 62 | The runtime leg fails on any line where a Zig native gave up — "fell through |
| 57 | 63 | to the shim", "failed with no fallback" or "could not reach the page" — because |
| 58 | 64 | Kotlin answering in its place is invisible to the page, and a runtime that has |
| @@ -2,7 +2,7 @@ import type { LegOutcome, RunnerOptions } from './types' | ||
| 2 | 2 | import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' |
| 3 | 3 | import { join } from 'node:path' |
| 4 | 4 | import { init } from '../../packages/android/src/index' |
| 5 | import { androidDeclines, evaluateRun, hasTerminated } from './protocol' | |
| 5 | import { androidDeclines, awaitedNeeds, DISMISS_SHARE_MENU, evaluateRun, hasTerminated, shareMenuInFront } from './protocol' | |
| 6 | 6 | import { command, driverPage, waitForFile } from './support' |
| 7 | 7 | |
| 8 | 8 | /** |
| @@ -149,6 +149,24 @@ async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> { | ||
| 149 | 149 | await adb(['shell', 'am', 'force-stop', PACKAGE], { serial, allowFailure: true }) |
| 150 | 150 | await adb(['shell', 'am', 'start', '-n', `${PACKAGE}/.MainActivity`], { serial, logPath: join(evidence, 'adb.log') }) |
| 151 | 151 | |
| 152 | // The share case needs its menu dismissed, and nothing on the device will | |
| 153 | // do that: the harness plays the person. It acts only once the page has | |
| 154 | // asked and the menu is really in front, and it keeps a screenshot of the | |
| 155 | // menu, which is the evidence that there was one to dismiss. | |
| 156 | let shareMenu: 'not asked' | 'asked' | 'dismissed' = 'not asked' | |
| 157 | ||
| 158 | async function dismissShareMenuWhenAsked(log: string): Promise<void> { | |
| 159 | if (shareMenu === 'dismissed' || !awaitedNeeds(log).includes(DISMISS_SHARE_MENU)) return | |
| 160 | shareMenu = 'asked' | |
| 161 | ||
| 162 | const windows = await command(['adb', '-s', serial, 'shell', 'dumpsys', 'window'], { allowFailure: true }) | |
| 163 | if (!shareMenuInFront(windows.stdout)) return | |
| 164 | ||
| 165 | await command(['adb', '-s', serial, 'exec-out', 'screencap', '-p'], { allowFailure: true, outPath: join(evidence, 'share-menu.png') }) | |
| 166 | await adb(['shell', 'input', 'keyevent', 'KEYCODE_BACK'], { serial, logPath: join(evidence, 'adb.log') }) | |
| 167 | shareMenu = 'dismissed' | |
| 168 | } | |
| 169 | ||
| 152 | 170 | // logcat -d dumps and exits, so poll it into the evidence file rather than |
| 153 | 171 | // streaming: a stream would have to be killed at exactly the right moment, |
| 154 | 172 | // and the dump is cheap. |
| @@ -156,6 +174,7 @@ async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> { | ||
| 156 | 174 | const finished = await waitForFile(logPath, options.timeoutMs, hasTerminated, async () => { |
| 157 | 175 | const dumped = await command(['adb', '-s', serial, 'logcat', '-d', ...LOGCAT_FILTER], { allowFailure: true }) |
| 158 | 176 | writeFileSync(logPath, dumped.stdout) |
| 177 | await dismissShareMenuWhenAsked(dumped.stdout) | |
| 159 | 178 | }) |
| 160 | 179 | |
| 161 | 180 | await command(['adb', '-s', serial, 'exec-out', 'screencap', '-p'], { allowFailure: true, outPath: join(evidence, 'screen.png') }) |
| @@ -170,6 +189,16 @@ async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> { | ||
| 170 | 189 | const verdict = evaluateRun('android', logText, nonce) |
| 171 | 190 | failures.push(...verdict.failures) |
| 172 | 191 | |
| 192 | // The page's case only sees that share resolved false. That is also what a | |
| 193 | // share that never opened a menu at all would resolve, so the harness says | |
| 194 | // whether there was a menu to dismiss. | |
| 195 | if (verdict.planned.includes('share.dismissed.resolvesFalse')) { | |
| 196 | if (shareMenu === 'not asked') | |
| 197 | failures.push('the page never asked for the share menu to be dismissed, so the dismissal case did not reach the menu') | |
| 198 | else if (shareMenu === 'asked') | |
| 199 | failures.push('the page asked for the share menu to be dismissed, and no share menu ever came to the front') | |
| 200 | } | |
| 201 | ||
| 173 | 202 | // The page reported the SDK level the bridge told it; the host asked the |
| 174 | 203 | // device directly. Agreement means the value crossed the bridge rather than |
| 175 | 204 | // being invented in JavaScript - this leg's substitute for the pasteboard |
| @@ -64,14 +64,14 @@ | ||
| 64 | 64 | return { code: String(error.code || ''), message: String(error.message || error) } |
| 65 | 65 | } |
| 66 | 66 | |
| 67 | function withTimeout(run) { | |
| 67 | function withTimeout(run, timeoutMs) { | |
| 68 | 68 | return new Promise(function (resolve) { |
| 69 | 69 | var settled = false |
| 70 | 70 | var timer = setTimeout(function () { |
| 71 | 71 | if (settled) return |
| 72 | 72 | settled = true |
| 73 | 73 | resolve({ state: 'timeout' }) |
| 74 | }, CASE_TIMEOUT_MS) | |
| 74 | }, timeoutMs || CASE_TIMEOUT_MS) | |
| 75 | 75 | |
| 76 | 76 | function finish(value) { |
| 77 | 77 | if (settled) return |
| @@ -198,6 +198,43 @@ | ||
| 198 | 198 | }) |
| 199 | 199 | } |
| 200 | 200 | }, |
| 201 | { | |
| 202 | // Android does not gate sharing on a flag, so its refusal is the one | |
| 203 | // both platforms share: an empty string is nothing to share. It is | |
| 204 | // refused before any menu opens, and used to be refused by nobody - | |
| 205 | // the call returned undefined whatever happened (#203). | |
| 206 | name: 'share.empty.rejects', | |
| 207 | platforms: ['android'], | |
| 208 | run: function () { | |
| 209 | return withTimeout(function () { return window.craft.share('') }).then(function (outcome) { | |
| 210 | if (outcome.state === 'timeout') return 'share never settled' | |
| 211 | if (outcome.state === 'resolved') return 'share resolved with ' + JSON.stringify(outcome.value) + ' for an empty string' | |
| 212 | if (outcome.error.message.indexOf('Nothing to share') === -1) | |
| 213 | return 'message is ' + JSON.stringify(outcome.error.message) | |
| 214 | }) | |
| 215 | } | |
| 216 | }, | |
| 217 | { | |
| 218 | // The cancellation path, and the one case that needs a person. The | |
| 219 | // page announces that it is about to open the share menu, the harness | |
| 220 | // waits until the menu is really in front, screenshots it and presses | |
| 221 | // Back, and the promise has to resolve false. False, not a rejection: | |
| 222 | // dismissing the menu is a choice the person made, not an error. | |
| 223 | // | |
| 224 | // A share that resolved false on its own would pass this without a | |
| 225 | // menu ever appearing, which is why the harness also fails the run | |
| 226 | // when it was asked to dismiss a menu and never saw one. | |
| 227 | name: 'share.dismissed.resolvesFalse', | |
| 228 | platforms: ['android'], | |
| 229 | run: function () { | |
| 230 | emit({ event: 'awaiting', name: 'share.dismissed.resolvesFalse', need: 'dismiss-share-menu' }) | |
| 231 | return withTimeout(function () { return window.craft.share('craft e2e ' + NONCE, 'Craft E2E') }, 60000).then(function (outcome) { | |
| 232 | if (outcome.state === 'timeout') return 'share never settled after the menu was dismissed' | |
| 233 | if (outcome.state === 'rejected') return 'share rejected: ' + outcome.error.message | |
| 234 | if (outcome.value !== false) return 'share resolved with ' + JSON.stringify(outcome.value) + ', expected false for a dismissed menu' | |
| 235 | }) | |
| 236 | } | |
| 237 | }, | |
| 201 | 238 | { |
| 202 | 239 | // Android's equivalent gate. With push disabled the generator emits a |
| 203 | 240 | // literal reject call rather than a Firebase block, so the rejection |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { describe, expect, it } from 'bun:test' |
| 2 | 2 | import { readFileSync } from 'node:fs' |
| 3 | 3 | import { join } from 'node:path' |
| 4 | import { ANDROID_DECLINE_PHRASES, androidDeclines, evaluateRun, hasTerminated, parseDriverOutput, REQUIRED_CASES, requiredCaseProblems, ZIG_REFUSED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigRefusals } from './protocol' | |
| 4 | import { ANDROID_DECLINE_PHRASES, androidDeclines, awaitedNeeds, DISMISS_SHARE_MENU, evaluateRun, hasTerminated, parseDriverOutput, REQUIRED_CASES, requiredCaseProblems, shareMenuInFront, ZIG_REFUSED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigRefusals } from './protocol' | |
| 5 | 5 | |
| 6 | 6 | const ESC = String.fromCharCode(27) |
| 7 | 7 | |
| @@ -283,6 +283,42 @@ describe('zig attribution', () => { | ||
| 283 | 283 | }) |
| 284 | 284 | }) |
| 285 | 285 | |
| 286 | describe('share menu', () => { | |
| 287 | it('reads what the page is waiting for, once, from either log channel', () => { | |
| 288 | const awaiting = JSON.stringify({ event: 'awaiting', name: 'share.dismissed.resolvesFalse', need: DISMISS_SHARE_MENU }) | |
| 289 | const text = [ | |
| 290 | `09-16 11:29:25.000 2611 2611 I CraftBridge: CRAFT-E2E ${awaiting}`, | |
| 291 | `09-16 11:29:25.001 2611 2611 I chromium: [INFO:CONSOLE(1)] "CRAFT-E2E ${awaiting}", source: https://appassets.androidplatform.net/ (1)`, | |
| 292 | ].join('\n') | |
| 293 | ||
| 294 | expect(awaitedNeeds(text)).toEqual([DISMISS_SHARE_MENU]) | |
| 295 | expect(awaitedNeeds('CRAFT-E2E {"event":"case","name":"a","status":"pass"}')).toEqual([]) | |
| 296 | }) | |
| 297 | ||
| 298 | // Both spellings the chooser has had, as `dumpsys window` prints them. | |
| 299 | it('sees the menu holding focus on Android 14 and on 13', () => { | |
| 300 | expect(shareMenuInFront([ | |
| 301 | ' mCurrentFocus=Window{56c630c u0 com.android.intentresolver/com.android.intentresolver.ChooserActivity}', | |
| 302 | ' mFocusedApp=ActivityRecord{144007559 u0 com.android.intentresolver/.ChooserActivity t7}', | |
| 303 | ].join('\n'))).toBe(true) | |
| 304 | ||
| 305 | expect(shareMenuInFront( | |
| 306 | ' mCurrentFocus=Window{2b7a1f u0 android/com.android.internal.app.ChooserActivity}', | |
| 307 | )).toBe(true) | |
| 308 | }) | |
| 309 | ||
| 310 | it('does not see a menu that is resumed but not yet focused', () => { | |
| 311 | // The gap the first CI run fell into. The activity manager already calls | |
| 312 | // the chooser the focused app while the probe's window still has input | |
| 313 | // focus, so Back here would close the app rather than the menu. | |
| 314 | const dumpsys = [ | |
| 315 | ' mCurrentFocus=Window{77aa u0 dev.craft.e2e.probe/dev.craft.e2e.probe.MainActivity}', | |
| 316 | ' mFocusedApp=ActivityRecord{144007559 u0 com.android.intentresolver/.ChooserActivity t7}', | |
| 317 | ].join('\n') | |
| 318 | expect(shareMenuInFront(dumpsys)).toBe(false) | |
| 319 | }) | |
| 320 | }) | |
| 321 | ||
| 286 | 322 | describe('android declines', () => { |
| 287 | 323 | // The lines the first Android runtime run actually produced, plus the two |
| 288 | 324 | // decline kinds it did not hit, so all three are read the way they are |
| @@ -43,7 +43,19 @@ export interface ObservedEvent { | ||
| 43 | 43 | value: unknown |
| 44 | 44 | } |
| 45 | 45 | |
| 46 | export type DriverEvent = PlanEvent | CaseEvent | DoneEvent | FatalEvent | ObservedEvent | |
| 46 | /** | |
| 47 | * The page asking the harness to do what a person would. Emitted just before | |
| 48 | * the call that needs it, because the page cannot tap the device itself. | |
| 49 | */ | |
| 50 | export interface AwaitingEvent { | |
| 51 | event: 'awaiting' | |
| 52 | /** The case that is waiting. */ | |
| 53 | name: string | |
| 54 | /** What it is waiting for, one of the needs the harness knows how to meet. */ | |
| 55 | need: string | |
| 56 | } | |
| 57 | ||
| 58 | export type DriverEvent = PlanEvent | CaseEvent | DoneEvent | FatalEvent | ObservedEvent | AwaitingEvent | |
| 47 | 59 | |
| 48 | 60 | export type MobilePlatform = 'ios' | 'android' |
| 49 | 61 | |
| @@ -74,6 +86,8 @@ export const REQUIRED_CASES: Record<MobilePlatform, string[]> = { | ||
| 74 | 86 | 'deviceInfo.isEmulator', |
| 75 | 87 | 'clipboard.roundTrip', |
| 76 | 88 | 'push.disabled.rejects', |
| 89 | 'share.empty.rejects', | |
| 90 | 'share.dismissed.resolvesFalse', | |
| 77 | 91 | ], |
| 78 | 92 | } |
| 79 | 93 | |
| @@ -303,6 +317,42 @@ export function evaluateRun(platform: MobilePlatform, text: string, expectedRun? | ||
| 303 | 317 | return { ok: failures.length === 0, failures, planned, passed, failed } |
| 304 | 318 | } |
| 305 | 319 | |
| 320 | /** The need the Android share case announces before it opens the menu. */ | |
| 321 | export const DISMISS_SHARE_MENU = 'dismiss-share-menu' | |
| 322 | ||
| 323 | /** The needs the page has announced so far, in order, without repeats. */ | |
| 324 | export function awaitedNeeds(text: string): string[] { | |
| 325 | const needs: string[] = [] | |
| 326 | for (const event of parseDriverOutput(text).events) { | |
| 327 | if (event.event === 'awaiting' && !needs.includes(event.need)) | |
| 328 | needs.push(event.need) | |
| 329 | } | |
| 330 | return needs | |
| 331 | } | |
| 332 | ||
| 333 | /** | |
| 334 | * Whether Android's share menu holds input focus, read from | |
| 335 | * `adb shell dumpsys window`. | |
| 336 | * | |
| 337 | * The harness presses Back only once this is true, because Back goes to the | |
| 338 | * focused window. Focus rather than the activity being *resumed*, which is | |
| 339 | * what this first checked: the activity manager marks the chooser resumed | |
| 340 | * before its process has even created it. On the first CI run the chooser was | |
| 341 | * resumed at 50.37s and took focus at 52.63s. Back pressed in that gap goes to | |
| 342 | * the app, which closes, and the page never reports anything. That run passed | |
| 343 | * only because the harness happened to poll late. | |
| 344 | * | |
| 345 | * Matched by class name rather than by package, because the chooser moved: | |
| 346 | * `android/com.android.internal.app.ChooserActivity` up to Android 13, and | |
| 347 | * `com.android.intentresolver/com.android.intentresolver.ChooserActivity` from | |
| 348 | * 14. | |
| 349 | */ | |
| 350 | export function shareMenuInFront(dumpsysWindow: string): boolean { | |
| 351 | return dumpsysWindow | |
| 352 | .split('\n') | |
| 353 | .some(line => /mCurrentFocus=/.test(line) && /ChooserActivity/.test(line)) | |
| 354 | } | |
| 355 | ||
| 306 | 356 | /** |
| 307 | 357 | * The actions the Zig dispatcher saw, by name. |
| 308 | 358 | * |