also looking at this
fix(android): hand the launch link to the page that subscribes for it
#232
17 files
+776
-136
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.
| @@ -76,6 +76,14 @@ that part. It waits until `dumpsys window` shows the chooser holding input | ||
| 76 | 76 | focus, saves `share-menu.png`, presses Back, and fails the run if the page asked |
| 77 | 77 | for a dismissal and no menu ever appeared. |
| 78 | 78 | |
| 79 | After the suite, each leg also cold-starts the app through a link twice, the | |
| 80 | same two ways as iOS, with `adb shell am start -W -a android.intent.action.VIEW | |
| 81 | -d '<link>' <package>`. Naming the package rather than the activity means the | |
| 82 | link is resolved through the generated manifest's intent filter, and `-W` must | |
| 83 | report `LaunchState: COLD`, so a link delivered to an app that was still | |
| 84 | running cannot pass for one that launched it. The page writes its report to | |
| 85 | logcat, and each launch keeps its own `deeplink-<mode>-logcat.txt`. | |
| 86 | ||
| 79 | 87 | The runtime leg fails on any line where a Zig native gave up — "fell through |
| 80 | 88 | to the shim", "failed with no fallback" or "could not reach the page" — because |
| 81 | 89 | Kotlin answering in its place is invisible to the page, and a runtime that has |
| @@ -2,7 +2,8 @@ import type { LegOutcome, RunnerOptions } from './types' | ||
| 2 | 2 | import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' |
| 3 | 3 | import { join } from 'node:path' |
| 4 | 4 | import { init } from '../../packages/android/src/index' |
| 5 | import { androidDeclines, awaitedNeeds, DISMISS_SHARE_MENU, elfSectionNames, evaluateRun, hasTerminated, runtimePermissionGranted, shareMenuInFront, strippedLibraryProblems } from './protocol' | |
| 5 | import type { DeepLinkResult } from './protocol' | |
| 6 | import { androidDeclines, awaitedNeeds, deepLinkProblems, deepLinkReports, DISMISS_SHARE_MENU, elfSectionNames, evaluateRun, hasTerminated, runtimePermissionGranted, shareMenuInFront, strippedLibraryProblems } from './protocol' | |
| 6 | 7 | import { command, driverPage, waitForFile } from './support' |
| 7 | 8 | |
| 8 | 9 | /** |
| @@ -19,10 +20,17 @@ import { command, driverPage, waitForFile } from './support' | ||
| 19 | 20 | * Geolocation on, because that is what puts the location permissions in the |
| 20 | 21 | * manifest, and `pm grant` refuses a permission the manifest does not declare. |
| 21 | 22 | * The Kotlin does not otherwise read the flag (#209). |
| 23 | * | |
| 24 | * Deep links on, with a scheme of the probe's own, for the cold starts through | |
| 25 | * a link (#215). The generator refuses the flag without a scheme. | |
| 22 | 26 | */ |
| 27 | const DEEP_LINK_SCHEME = 'crafte2eprobe' | |
| 28 | ||
| 23 | 29 | const CONFIG = { |
| 24 | 30 | enablePushNotifications: false, |
| 25 | 31 | enableGeolocation: true, |
| 32 | enableDeepLinks: true, | |
| 33 | urlSchemes: [DEEP_LINK_SCHEME], | |
| 26 | 34 | } |
| 27 | 35 | |
| 28 | 36 | /** |
| @@ -74,6 +82,11 @@ function legs(runtimeDir: string | null): Leg[] { | ||
| 74 | 82 | /** What JNI_OnLoad logs once it has bound the natives. */ |
| 75 | 83 | const REGISTERED = /craft: registered (\d+) natives on com\/craft\/runtime\/CraftNative/ |
| 76 | 84 | |
| 85 | /** Whether the page gave up before it could report anything. */ | |
| 86 | function parseFatal(text: string): boolean { | |
| 87 | return hasTerminated(text) && /"event":"fatal"/.test(text) | |
| 88 | } | |
| 89 | ||
| 77 | 90 | async function adb(argv: string[], options: { serial: string, logPath?: string, allowFailure?: boolean }) { |
| 78 | 91 | return command(['adb', '-s', options.serial, ...argv], { logPath: options.logPath, allowFailure: options.allowFailure }) |
| 79 | 92 | } |
| @@ -116,6 +129,14 @@ async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> { | ||
| 116 | 129 | copyFileSync(join(assets, 'index.html'), join(evidence, 'index.html')) |
| 117 | 130 | copyFileSync(join(assets, 'craft.config.json'), join(evidence, 'craft.config.json')) |
| 118 | 131 | |
| 132 | // The intent filter the cold starts depend on, checked where it is written. | |
| 133 | // `am start` below names the package, not the activity, so it resolves the | |
| 134 | // link through this filter the way a browser would. | |
| 135 | const manifest = readFileSync(join(project, 'app', 'src', 'main', 'AndroidManifest.xml'), 'utf8') | |
| 136 | writeFileSync(join(evidence, 'AndroidManifest.xml'), manifest) | |
| 137 | if (!manifest.includes(`android:scheme="${DEEP_LINK_SCHEME}"`)) | |
| 138 | failures.push(`the generated manifest declares no ${DEEP_LINK_SCHEME}:// intent filter, so no link can open the app`) | |
| 139 | ||
| 119 | 140 | // The generator writes gradle-wrapper.properties but never gradlew, so there |
| 120 | 141 | // is no wrapper to invoke. Call Gradle directly, the way the templates gate |
| 121 | 142 | // already does, and honour the same override so both use one toolchain. |
| @@ -173,47 +194,78 @@ async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> { | ||
| 173 | 194 | if (runtimePermissionGranted(packageState, 'android.permission.ACCESS_FINE_LOCATION') === true) |
| 174 | 195 | failures.push('ACCESS_FINE_LOCATION is granted too, so the location case cannot tell approximate-only from precise') |
| 175 | 196 | |
| 176 | // Not best-effort: the ring buffer outlives an uninstall, and a transcript | |
| 177 | // left in it would otherwise be read as this run's. The plan event carries | |
| 178 | // this run's nonce as a second guard. | |
| 179 | await adb(['logcat', '-c'], { serial }) | |
| 180 | await adb(['shell', 'am', 'force-stop', PACKAGE], { serial, allowFailure: true }) | |
| 181 | await adb(['shell', 'am', 'start', '-n', `${PACKAGE}/.MainActivity`], { serial, logPath: join(evidence, 'adb.log') }) | |
| 182 | ||
| 183 | // The share case needs its menu dismissed, and nothing on the device will | |
| 184 | // do that: the harness plays the person. It acts only once the page has | |
| 185 | // asked and the menu is really in front, and it keeps a screenshot of the | |
| 186 | // menu, which is the evidence that there was one to dismiss. | |
| 187 | let shareMenu: 'not asked' | 'asked' | 'dismissed' = 'not asked' | |
| 188 | ||
| 189 | async function dismissShareMenuWhenAsked(log: string): Promise<void> { | |
| 190 | if (shareMenu === 'dismissed' || !awaitedNeeds(log).includes(DISMISS_SHARE_MENU)) return | |
| 191 | shareMenu = 'asked' | |
| 192 | ||
| 193 | const windows = await command(['adb', '-s', serial, 'shell', 'dumpsys', 'window'], { allowFailure: true }) | |
| 194 | if (!shareMenuInFront(windows.stdout)) return | |
| 195 | ||
| 196 | await command(['adb', '-s', serial, 'exec-out', 'screencap', '-p'], { allowFailure: true, outPath: join(evidence, 'share-menu.png') }) | |
| 197 | await adb(['shell', 'input', 'keyevent', 'KEYCODE_BACK'], { serial, logPath: join(evidence, 'adb.log') }) | |
| 198 | shareMenu = 'dismissed' | |
| 199 | } | |
| 197 | // One launch of the app: stopped first, then started with `start`, then | |
| 198 | // read until `isDone`. The suite runs on every launch, the share case | |
| 199 | // included, so every launch dismisses the menu when the page asks. | |
| 200 | async function launch(prefix: string, start: string[], isDone: (text: string) => boolean) { | |
| 201 | // Stopped and gone before the buffer is cleared, so nothing the previous | |
| 202 | // launch logs late lands in this one's. Not best-effort: the ring buffer | |
| 203 | // outlives an uninstall, and a transcript left in it would otherwise be | |
| 204 | // read as this run's. The plan event carries this run's nonce as a | |
| 205 | // second guard. | |
| 206 | await adb(['shell', 'am', 'force-stop', PACKAGE], { serial, allowFailure: true }) | |
| 207 | for (let attempt = 0; attempt < 20; attempt++) { | |
| 208 | const running = await adb(['shell', 'pidof', PACKAGE], { serial, allowFailure: true }) | |
| 209 | if (!running.stdout.trim()) break | |
| 210 | await Bun.sleep(500) | |
| 211 | } | |
| 212 | await adb(['logcat', '-c'], { serial }) | |
| 213 | const started = await adb(['shell', 'am', 'start', ...start], { serial, logPath: join(evidence, 'adb.log'), allowFailure: true }) | |
| 214 | const startedText = `${started.stdout}\n${started.stderr}` | |
| 215 | // `am start` exits 0 for most refusals and prints `Error:` instead, so | |
| 216 | // both are checked. Nothing to wait for when nothing started. | |
| 217 | const refused = started.exitCode !== 0 || /^Error:/m.test(startedText) | |
| 218 | ||
| 219 | // The share case needs its menu dismissed, and nothing on the device will | |
| 220 | // do that: the harness plays the person. It acts only once the page has | |
| 221 | // asked and the menu is really in front, and it keeps a screenshot of the | |
| 222 | // menu, which is the evidence that there was one to dismiss. | |
| 223 | let shareMenu: 'not asked' | 'asked' | 'dismissed' = 'not asked' | |
| 224 | ||
| 225 | async function dismissShareMenuWhenAsked(log: string): Promise<void> { | |
| 226 | if (shareMenu === 'dismissed' || !awaitedNeeds(log).includes(DISMISS_SHARE_MENU)) return | |
| 227 | shareMenu = 'asked' | |
| 228 | ||
| 229 | const windows = await command(['adb', '-s', serial, 'shell', 'dumpsys', 'window'], { allowFailure: true }) | |
| 230 | if (!shareMenuInFront(windows.stdout)) return | |
| 231 | ||
| 232 | await command(['adb', '-s', serial, 'exec-out', 'screencap', '-p'], { allowFailure: true, outPath: join(evidence, `${prefix}share-menu.png`) }) | |
| 233 | await adb(['shell', 'input', 'keyevent', 'KEYCODE_BACK'], { serial, logPath: join(evidence, 'adb.log') }) | |
| 234 | shareMenu = 'dismissed' | |
| 235 | } | |
| 200 | 236 | |
| 201 | // logcat -d dumps and exits, so poll it into the evidence file rather than | |
| 202 | // streaming: a stream would have to be killed at exactly the right moment, | |
| 203 | // and the dump is cheap. | |
| 204 | const logPath = join(evidence, 'logcat.txt') | |
| 205 | const finished = await waitForFile(logPath, options.timeoutMs, hasTerminated, async () => { | |
| 206 | const dumped = await command(['adb', '-s', serial, 'logcat', '-d', ...LOGCAT_FILTER], { allowFailure: true }) | |
| 207 | writeFileSync(logPath, dumped.stdout) | |
| 208 | await dismissShareMenuWhenAsked(dumped.stdout) | |
| 209 | }) | |
| 237 | // logcat -d dumps and exits, so poll it into the evidence file rather than | |
| 238 | // streaming: a stream would have to be killed at exactly the right moment, | |
| 239 | // and the dump is cheap. | |
| 240 | const logPath = join(evidence, `${prefix}logcat.txt`) | |
| 241 | const finished = !refused && await waitForFile(logPath, options.timeoutMs, isDone, async () => { | |
| 242 | const dumped = await command(['adb', '-s', serial, 'logcat', '-d', ...LOGCAT_FILTER], { allowFailure: true }) | |
| 243 | writeFileSync(logPath, dumped.stdout) | |
| 244 | await dismissShareMenuWhenAsked(dumped.stdout) | |
| 245 | }) | |
| 246 | ||
| 247 | await command(['adb', '-s', serial, 'exec-out', 'screencap', '-p'], { allowFailure: true, outPath: join(evidence, `${prefix}screen.png`) }) | |
| 248 | const full = await command(['adb', '-s', serial, 'logcat', '-d', '-b', 'all'], { allowFailure: true }) | |
| 249 | writeFileSync(join(evidence, `${prefix}logcat-full.txt`), full.stdout) | |
| 250 | await adb(['shell', 'am', 'force-stop', PACKAGE], { serial, allowFailure: true }) | |
| 251 | ||
| 252 | return { | |
| 253 | started: startedText, | |
| 254 | refused, | |
| 255 | finished, | |
| 256 | shareMenu, | |
| 257 | log: existsSync(logPath) ? readFileSync(logPath, 'utf8') : '', | |
| 258 | full: full.stdout, | |
| 259 | } | |
| 260 | } | |
| 210 | 261 | |
| 211 | await command(['adb', '-s', serial, 'exec-out', 'screencap', '-p'], { allowFailure: true, outPath: join(evidence, 'screen.png') }) | |
| 212 | const full = await command(['adb', '-s', serial, 'logcat', '-d', '-b', 'all'], { allowFailure: true }) | |
| 213 | writeFileSync(join(evidence, 'logcat-full.txt'), full.stdout) | |
| 214 | await adb(['shell', 'am', 'force-stop', PACKAGE], { serial, allowFailure: true }) | |
| 262 | const main = await launch('', ['-n', `${PACKAGE}/.MainActivity`], hasTerminated) | |
| 263 | if (main.refused) | |
| 264 | throw new Error(`am start refused to launch the app: ${main.started.trim()}`) | |
| 265 | const finished = main.finished | |
| 266 | const shareMenu = main.shareMenu | |
| 215 | 267 | |
| 216 | const logText = existsSync(logPath) ? readFileSync(logPath, 'utf8') : '' | |
| 268 | const logText = main.log | |
| 217 | 269 | if (!finished) |
| 218 | 270 | failures.push(`the app produced no terminating event within ${options.timeoutMs}ms; see ${label}/logcat.txt`) |
| 219 | 271 | |
| @@ -249,6 +301,55 @@ async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> { | ||
| 249 | 301 | failures.push(`the bridge reported sdkVersion ${observed[1]} but the device says ${sdk}`) |
| 250 | 302 | } |
| 251 | 303 | |
| 304 | // #215: cold-start the app through a link, twice, and judge what the page | |
| 305 | // received, with the same judgement the iOS legs use. Through | |
| 306 | // `am start -a VIEW -d`, which Android resolves through the manifest's | |
| 307 | // intent filter with no prompt; the package names which app, not which | |
| 308 | // activity, so the filter is what is tested. Quoted, because `adb shell` | |
| 309 | // hands the device shell one string and `&` would end the command there. | |
| 310 | const coldLink = `${DEEP_LINK_SCHEME}://e2e/cold?run=${encodeURIComponent(nonce)}` | |
| 311 | const coldStarts: DeepLinkResult[] = [] | |
| 312 | const coldLogs: string[] = [] | |
| 313 | // Modes that already failed for a reason of their own, so the verdict below | |
| 314 | // does not also call them "never reported". | |
| 315 | const coldFailed = new Set<DeepLinkResult['receive']>() | |
| 316 | for (const receive of ['subscribe', 'both'] as const) { | |
| 317 | const link = `${coldLink}&receive=${receive}` | |
| 318 | const prefix = `deeplink-${receive}-` | |
| 319 | const cold = await launch( | |
| 320 | prefix, | |
| 321 | ['-W', '-a', 'android.intent.action.VIEW', '-c', 'android.intent.category.BROWSABLE', '-d', `'${link}'`, PACKAGE], | |
| 322 | text => deepLinkReports(text).length > 0 || parseFatal(text), | |
| 323 | ) | |
| 324 | coldLogs.push(cold.full) | |
| 325 | writeFileSync(join(evidence, `${prefix}am-start.txt`), cold.started) | |
| 326 | ||
| 327 | // `am start -W` says how the activity started. Anything but a cold launch | |
| 328 | // means the link went to a running app through onNewIntent, which is the | |
| 329 | // warm path, not the one under test. | |
| 330 | if (cold.refused || !/Status: ok/.test(cold.started) || !/LaunchState: COLD/.test(cold.started)) { | |
| 331 | failures.push(`the ${receive} cold start did not launch the app cold: ${cold.started.trim().split('\n').slice(-3).join(' / ')}`) | |
| 332 | coldFailed.add(receive) | |
| 333 | continue | |
| 334 | } | |
| 335 | ||
| 336 | const reports = deepLinkReports(cold.log) | |
| 337 | if (reports.length > 1) { | |
| 338 | failures.push(`the ${receive} cold start reported ${reports.length} different results; see ${label}/${prefix}logcat.txt`) | |
| 339 | coldFailed.add(receive) | |
| 340 | } | |
| 341 | else if (reports.length === 1) | |
| 342 | coldStarts.push({ ...reports[0]!, receive, link }) | |
| 343 | ||
| 344 | const coldRegistered = REGISTERED.test(cold.log) | |
| 345 | if (leg.requireZig && !coldRegistered) | |
| 346 | failures.push(`the Zig runtime did not register its natives on the ${receive} cold start; see ${label}/${prefix}logcat.txt`) | |
| 347 | if (!leg.requireZig && coldRegistered) | |
| 348 | failures.push(`the shim leg registered natives on the ${receive} cold start; it was generated with no runtime`) | |
| 349 | } | |
| 350 | failures.push(...deepLinkProblems(coldStarts, coldLink, receive => `${label}/deeplink-${receive}-logcat.txt`) | |
| 351 | .filter(problem => ![...coldFailed].some(receive => problem.startsWith(`the ${receive} cold start never reported`)))) | |
| 352 | ||
| 252 | 353 | // Did the Zig runtime load, bind — and then actually answer? JNI_OnLoad |
| 253 | 354 | // reports the first two on the happy path as well as the failure ones, and |
| 254 | 355 | // every way a native can give up now says so too (android_dispatch.zig's |
| @@ -265,7 +366,9 @@ async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> { | ||
| 265 | 366 | // the part worth quoting back. |
| 266 | 367 | const registered = logText.match(REGISTERED) |
| 267 | 368 | const fullLog = existsSync(join(evidence, 'logcat-full.txt')) ? readFileSync(join(evidence, 'logcat-full.txt'), 'utf8') : logText |
| 268 | const declines = androidDeclines(fullLog) | |
| 369 | // The cold starts too: a link Zig failed to dispatch falls through to | |
| 370 | // Kotlin, and the page could not tell. | |
| 371 | const declines = [fullLog, ...coldLogs].flatMap(text => androidDeclines(text)) | |
| 269 | 372 | const zigActions: string[] = [] |
| 270 | 373 | |
| 271 | 374 | if (leg.requireZig) { |
| @@ -375,9 +375,11 @@ | ||
| 375 | 375 | // |
| 376 | 376 | // Runs after the suite, so the subscription is seconds after the bridge |
| 377 | 377 | // appeared, the way an app that hydrates before wiring its router |
| 378 | // subscribes. The harness cold-starts the app through a link from an | |
| 379 | // XCUITest, which reads this paragraph back. The app's console is not | |
| 380 | // captured on that launch; SpringBoard started it, not simctl. | |
| 378 | // subscribes. On iOS the harness cold-starts the app through a link from | |
| 379 | // an XCUITest, which reads this paragraph back: the app's console is not | |
| 380 | // captured on that launch, because SpringBoard started it, not simctl. On | |
| 381 | // Android (#215) `am start` opens the link and the harness reads the same | |
| 382 | // line from logcat. | |
| 381 | 383 | // |
| 382 | 384 | // The link says which way to receive it. `receive=subscribe` only |
| 383 | 385 | // subscribes, and must get the launch link exactly once. `receive=both` |
| @@ -387,16 +389,30 @@ | ||
| 387 | 389 | function probeDeepLinks() { |
| 388 | 390 | var launch = dispatchedLinks.filter(function (detail) { return detail.initial === true })[0] || null |
| 389 | 391 | var receive = launch && launch.url.indexOf('receive=both') !== -1 ? 'both' : 'subscribe' |
| 390 | var seen = { launch: launch ? launch.url : null, receive: receive, onLink: [], initialURL: null } | |
| 392 | var seen = { launch: launch ? launch.url : null, receive: receive, onLink: [], initialURL: null, unsubscribe: null } | |
| 393 | var answered = receive !== 'both' | |
| 391 | 394 | if (receive === 'both') { |
| 392 | window.craft.deepLinks.getInitialURL().then(function (value) { seen.initialURL = value && value.url }) | |
| 395 | window.craft.deepLinks.getInitialURL().then( | |
| 396 | function (value) { seen.initialURL = value && value.url; answered = true }, | |
| 397 | function () { answered = true } | |
| 398 | ) | |
| 393 | 399 | } |
| 394 | window.craft.deepLinks.onLink(function (detail) { | |
| 400 | var unsubscribe = window.craft.deepLinks.onLink(function (detail) { | |
| 395 | 401 | seen.onLink.push({ url: detail && detail.url, initial: detail && detail.initial }) |
| 396 | 402 | }) |
| 397 | setTimeout(function () { | |
| 398 | document.getElementById('deeplink').textContent = 'CRAFT-E2E-DEEPLINK ' + JSON.stringify(seen) | |
| 399 | }, 1500) | |
| 403 | seen.unsubscribe = typeof unsubscribe | |
| 404 | // Long enough for a replay, or a second delivery, to have arrived, and | |
| 405 | // for getInitialURL to answer on a slow emulator. | |
| 406 | var waited = 0 | |
| 407 | function report() { | |
| 408 | waited += 250 | |
| 409 | if ((!answered || waited < 1500) && waited < 6000) { setTimeout(report, 250); return } | |
| 410 | var line = 'CRAFT-E2E-DEEPLINK ' + JSON.stringify(seen) | |
| 411 | document.getElementById('deeplink').textContent = line | |
| 412 | try { console.log(line) } catch (ignored) {} | |
| 413 | try { if (window.craft && window.craft.log) window.craft.log(line) } catch (ignored) {} | |
| 414 | } | |
| 415 | setTimeout(report, 250) | |
| 400 | 416 | } |
| 401 | 417 | |
| 402 | 418 | function waitForBridge(attempt) { |
| @@ -428,7 +444,7 @@ | ||
| 428 | 444 | if (index >= selected.length) { |
| 429 | 445 | emit({ event: 'done', passed: passed, failed: failed }) |
| 430 | 446 | document.getElementById('headline').textContent = failed ? 'FAILED' : 'PASSED' |
| 431 | if (platform === 'ios') probeDeepLinks() | |
| 447 | probeDeepLinks() | |
| 432 | 448 | return |
| 433 | 449 | } |
| 434 | 450 | |
| @@ -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, awaitedNeeds, deepLinkProblems, deepLinkResults, DISMISS_SHARE_MENU, elfSectionNames, evaluateRun, hasTerminated, parseDriverOutput, REQUIRED_CASES, requiredCaseProblems, runtimePermissionGranted, shareMenuInFront, strippedLibraryProblems, ZIG_REFUSED_ACTIONS, ZIG_SERVED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigHandBacks, zigRefusals } from './protocol' | |
| 4 | import { ANDROID_DECLINE_PHRASES, androidDeclines, awaitedNeeds, deepLinkProblems, deepLinkReports, deepLinkResults, DISMISS_SHARE_MENU, elfSectionNames, evaluateRun, hasTerminated, parseDriverOutput, REQUIRED_CASES, requiredCaseProblems, runtimePermissionGranted, shareMenuInFront, strippedLibraryProblems, ZIG_REFUSED_ACTIONS, ZIG_SERVED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigHandBacks, zigRefusals } from './protocol' | |
| 5 | 5 | |
| 6 | 6 | const ESC = String.fromCharCode(27) |
| 7 | 7 | |
| @@ -373,6 +373,46 @@ describe('cold-start deep links', () => { | ||
| 373 | 373 | 'the both cold start never reported; see xcodebuild-deeplink.log', |
| 374 | 374 | ]) |
| 375 | 375 | }) |
| 376 | ||
| 377 | it('fails an onLink that returns no way to unsubscribe, and ignores a report that predates the field', () => { | |
| 378 | const noUnsubscribe = line('subscribe', { launch: `${LINK}&receive=subscribe`, receive: 'subscribe', onLink: [{ url: `${LINK}&receive=subscribe`, initial: true }], initialURL: null, unsubscribe: 'undefined' }) | |
| 379 | expect(deepLinkProblems(deepLinkResults(`${noUnsubscribe}\n${passing.split('\n')[2]}`), LINK)) | |
| 380 | .toEqual(['onLink returned undefined in the subscribe cold start, expected a function that unsubscribes']) | |
| 381 | expect(deepLinkProblems(deepLinkResults(passing), LINK)).toEqual([]) | |
| 382 | }) | |
| 383 | }) | |
| 384 | ||
| 385 | describe('cold-start deep links on Android', () => { | |
| 386 | const LINK = 'crafte2eprobe://e2e/cold?run=craft-e2e-android-shim-1' | |
| 387 | const report = { launch: `${LINK}&receive=subscribe`, receive: 'subscribe', onLink: [{ url: `${LINK}&receive=subscribe`, initial: true }], initialURL: null, unsubscribe: 'function' } | |
| 388 | const json = JSON.stringify(report) | |
| 389 | ||
| 390 | it('reads the report once, from either log channel', () => { | |
| 391 | // console.log reaches logcat under chromium, quoted, with its source; the | |
| 392 | // same line through craft.log arrives under the bridge's tag. | |
| 393 | const text = [ | |
| 394 | `09-17 11:29:25.000 2611 2611 I chromium: [INFO:CONSOLE(1)] "CRAFT-E2E-DEEPLINK ${json}", source: https://appassets.androidplatform.net/ (1)`, | |
| 395 | `09-17 11:29:25.001 2611 2611 D CraftBridge: CRAFT-E2E-DEEPLINK ${json}`, | |
| 396 | ].join('\n') | |
| 397 | ||
| 398 | expect(deepLinkReports(text)).toEqual([report]) | |
| 399 | }) | |
| 400 | ||
| 401 | it('keeps two different reports apart, so the runner can refuse them', () => { | |
| 402 | const other = JSON.stringify({ ...report, onLink: [] }) | |
| 403 | expect(deepLinkReports(`D CraftBridge: CRAFT-E2E-DEEPLINK ${json}\nD CraftBridge: CRAFT-E2E-DEEPLINK ${other}`)).toHaveLength(2) | |
| 404 | }) | |
| 405 | ||
| 406 | it('is not mistaken for a driver event, or a malformed one, by the run verdict', () => { | |
| 407 | const lines = parseDriverOutput(`D CraftBridge: CRAFT-E2E-DEEPLINK ${json}`) | |
| 408 | expect(lines.events).toEqual([]) | |
| 409 | expect(lines.malformed).toEqual([]) | |
| 410 | }) | |
| 411 | ||
| 412 | it('names the logcat file for a cold start that never reported', () => { | |
| 413 | expect(deepLinkProblems([{ ...report, receive: 'subscribe', link: `${LINK}&receive=subscribe` }], LINK, receive => `android-shim/deeplink-${receive}-logcat.txt`)) | |
| 414 | .toEqual(['the both cold start never reported; see android-shim/deeplink-both-logcat.txt']) | |
| 415 | }) | |
| 376 | 416 | }) |
| 377 | 417 | |
| 378 | 418 | /** A minimal ELF64 little-endian file whose section header table names `sections`. */ |
| @@ -325,13 +325,45 @@ export function evaluateRun(platform: MobilePlatform, text: string, expectedRun? | ||
| 325 | 325 | return { ok: failures.length === 0, failures, planned, passed, failed } |
| 326 | 326 | } |
| 327 | 327 | |
| 328 | /** What one cold start through a link saw, as the XCUITest printed it. */ | |
| 328 | /** What one cold start through a link saw, as the page reported it. */ | |
| 329 | 329 | export interface DeepLinkResult { |
| 330 | 330 | receive: 'subscribe' | 'both' |
| 331 | 331 | link: string |
| 332 | 332 | launch: string | null |
| 333 | 333 | onLink: { url?: string, initial?: boolean }[] |
| 334 | 334 | initialURL: string | null |
| 335 | /** `typeof` what onLink returned. Absent from reports older than #215. */ | |
| 336 | unsubscribe?: string | |
| 337 | } | |
| 338 | ||
| 339 | /** The report the page writes, without the label the harness adds. */ | |
| 340 | export type DeepLinkReport = Omit<DeepLinkResult, 'receive' | 'link'> | |
| 341 | ||
| 342 | /** | |
| 343 | * The page's deep-link reports in an Android log, one per distinct report. | |
| 344 | * | |
| 345 | * On Android the page logs its `CRAFT-E2E-DEEPLINK {json}` line through both | |
| 346 | * console.log, which logcat carries under `chromium` wrapped in quotes and a | |
| 347 | * source suffix, and craft.log, under the bridge's own tag. The same report | |
| 348 | * arriving twice is one report; two different ones means the page reported | |
| 349 | * twice, which the caller treats as a failure. | |
| 350 | */ | |
| 351 | export function deepLinkReports(text: string): DeepLinkReport[] { | |
| 352 | const reports: DeepLinkReport[] = [] | |
| 353 | const seen = new Set<string>() | |
| 354 | for (const line of text.replace(ANSI, '').split('\n')) { | |
| 355 | const at = line.indexOf('CRAFT-E2E-DEEPLINK ') | |
| 356 | if (at === -1 || line.includes('CRAFT-E2E-DEEPLINK-RESULT')) continue | |
| 357 | const json = firstJsonObject(line.slice(at + 'CRAFT-E2E-DEEPLINK '.length)) | |
| 358 | if (!json || seen.has(json)) continue | |
| 359 | seen.add(json) | |
| 360 | try { | |
| 361 | const report = JSON.parse(json) as DeepLinkReport | |
| 362 | if (Array.isArray(report.onLink)) reports.push(report) | |
| 363 | } | |
| 364 | catch {} | |
| 365 | } | |
| 366 | return reports | |
| 335 | 367 | } |
| 336 | 368 | |
| 337 | 369 | /** |
| @@ -364,15 +396,21 @@ export function deepLinkResults(output: string): DeepLinkResult[] { | ||
| 364 | 396 | * `both` is the guard on the fix: a page that also calls getInitialURL must |
| 365 | 397 | * get the link from there and not a second time from onLink. |
| 366 | 398 | */ |
| 367 | export function deepLinkProblems(results: DeepLinkResult[], link: string): string[] { | |
| 399 | export function deepLinkProblems( | |
| 400 | results: DeepLinkResult[], | |
| 401 | link: string, | |
| 402 | evidence: (receive: DeepLinkResult['receive']) => string = () => 'xcodebuild-deeplink.log', | |
| 403 | ): string[] { | |
| 368 | 404 | const problems: string[] = [] |
| 369 | 405 | for (const receive of ['subscribe', 'both'] as const) { |
| 370 | 406 | const result = results.find(entry => entry.receive === receive) |
| 371 | 407 | const expectedLink = `${link}&receive=${receive}` |
| 372 | 408 | if (!result) { |
| 373 | problems.push(`the ${receive} cold start never reported; see xcodebuild-deeplink.log`) | |
| 409 | problems.push(`the ${receive} cold start never reported; see ${evidence(receive)}`) | |
| 374 | 410 | continue |
| 375 | 411 | } |
| 412 | if (result.unsubscribe !== undefined && result.unsubscribe !== 'function') | |
| 413 | problems.push(`onLink returned ${result.unsubscribe} in the ${receive} cold start, expected a function that unsubscribes`) | |
| 376 | 414 | if (result.launch !== expectedLink) { |
| 377 | 415 | problems.push(`the ${receive} cold start was not launched by its link: the page saw ${JSON.stringify(result.launch)}`) |
| 378 | 416 | continue |