ReviewOS

also looking at this

craft-native/craft

fix(android): hand the launch link to the page that subscribes for it

#232
Merged glennmichael123 wants to merge fix/android-deep-link-replay into main
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.

.github/workflows/README.mdmodified+8-0
Changes to .github/workflows/README.md
@@ -76,6 +76,14 @@ that part. It waits until `dumpsys window` shows the chooser holding input
7676focus, saves `share-menu.png`, presses Back, and fails the run if the page asked
7777for a dismissal and no menu ever appeared.
7878
79After the suite, each leg also cold-starts the app through a link twice, the
80same 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
82link is resolved through the generated manifest's intent filter, and `-W` must
83report `LaunchState: COLD`, so a link delivered to an app that was still
84running cannot pass for one that launched it. The page writes its report to
85logcat, and each launch keeps its own `deeplink-<mode>-logcat.txt`.
86
7987The runtime leg fails on any line where a Zig native gave up — "fell through
8088to the shim", "failed with no fallback" or "could not reach the page" — because
8189Kotlin answering in its place is invisible to the page, and a runtime that has
scripts/mobile-e2e/android.tsmodified+143-40
Changes to scripts/mobile-e2e/android.ts
@@ -2,7 +2,8 @@ import type { LegOutcome, RunnerOptions } from './types'
22import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
33import { join } from 'node:path'
44import { init } from '../../packages/android/src/index'
5import { androidDeclines, awaitedNeeds, DISMISS_SHARE_MENU, elfSectionNames, evaluateRun, hasTerminated, runtimePermissionGranted, shareMenuInFront, strippedLibraryProblems } from './protocol'
5import type { DeepLinkResult } from './protocol'
6import { androidDeclines, awaitedNeeds, deepLinkProblems, deepLinkReports, DISMISS_SHARE_MENU, elfSectionNames, evaluateRun, hasTerminated, runtimePermissionGranted, shareMenuInFront, strippedLibraryProblems } from './protocol'
67import { command, driverPage, waitForFile } from './support'
78
89/**
@@ -19,10 +20,17 @@ import { command, driverPage, waitForFile } from './support'
1920 * Geolocation on, because that is what puts the location permissions in the
2021 * manifest, and `pm grant` refuses a permission the manifest does not declare.
2122 * 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.
2226 */
27const DEEP_LINK_SCHEME = 'crafte2eprobe'
28
2329const CONFIG = {
2430 enablePushNotifications: false,
2531 enableGeolocation: true,
32 enableDeepLinks: true,
33 urlSchemes: [DEEP_LINK_SCHEME],
2634}
2735
2836/**
@@ -74,6 +82,11 @@ function legs(runtimeDir: string | null): Leg[] {
7482/** What JNI_OnLoad logs once it has bound the natives. */
7583const REGISTERED = /craft: registered (\d+) natives on com\/craft\/runtime\/CraftNative/
7684
85/** Whether the page gave up before it could report anything. */
86function parseFatal(text: string): boolean {
87 return hasTerminated(text) && /"event":"fatal"/.test(text)
88}
89
7790async function adb(argv: string[], options: { serial: string, logPath?: string, allowFailure?: boolean }) {
7891 return command(['adb', '-s', options.serial, ...argv], { logPath: options.logPath, allowFailure: options.allowFailure })
7992}
@@ -116,6 +129,14 @@ async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> {
116129 copyFileSync(join(assets, 'index.html'), join(evidence, 'index.html'))
117130 copyFileSync(join(assets, 'craft.config.json'), join(evidence, 'craft.config.json'))
118131
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
119140 // The generator writes gradle-wrapper.properties but never gradlew, so there
120141 // is no wrapper to invoke. Call Gradle directly, the way the templates gate
121142 // 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> {
173194 if (runtimePermissionGranted(packageState, 'android.permission.ACCESS_FINE_LOCATION') === true)
174195 failures.push('ACCESS_FINE_LOCATION is granted too, so the location case cannot tell approximate-only from precise')
175196
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 }
200236
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 }
210261
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
215267
216 const logText = existsSync(logPath) ? readFileSync(logPath, 'utf8') : ''
268 const logText = main.log
217269 if (!finished)
218270 failures.push(`the app produced no terminating event within ${options.timeoutMs}ms; see ${label}/logcat.txt`)
219271
@@ -249,6 +301,55 @@ async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> {
249301 failures.push(`the bridge reported sdkVersion ${observed[1]} but the device says ${sdk}`)
250302 }
251303
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
252353 // Did the Zig runtime load, bind — and then actually answer? JNI_OnLoad
253354 // reports the first two on the happy path as well as the failure ones, and
254355 // 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> {
265366 // the part worth quoting back.
266367 const registered = logText.match(REGISTERED)
267368 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))
269372 const zigActions: string[] = []
270373
271374 if (leg.requireZig) {
scripts/mobile-e2e/driver.htmlmodified+26-10
Changes to scripts/mobile-e2e/driver.html
@@ -375,9 +375,11 @@
375375 //
376376 // Runs after the suite, so the subscription is seconds after the bridge
377377 // 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.
381383 //
382384 // The link says which way to receive it. `receive=subscribe` only
383385 // subscribes, and must get the launch link exactly once. `receive=both`
@@ -387,16 +389,30 @@
387389 function probeDeepLinks() {
388390 var launch = dispatchedLinks.filter(function (detail) { return detail.initial === true })[0] || null
389391 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'
391394 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 )
393399 }
394 window.craft.deepLinks.onLink(function (detail) {
400 var unsubscribe = window.craft.deepLinks.onLink(function (detail) {
395401 seen.onLink.push({ url: detail && detail.url, initial: detail && detail.initial })
396402 })
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)
400416 }
401417
402418 function waitForBridge(attempt) {
@@ -428,7 +444,7 @@
428444 if (index >= selected.length) {
429445 emit({ event: 'done', passed: passed, failed: failed })
430446 document.getElementById('headline').textContent = failed ? 'FAILED' : 'PASSED'
431 if (platform === 'ios') probeDeepLinks()
447 probeDeepLinks()
432448 return
433449 }
434450
scripts/mobile-e2e/protocol.test.tsmodified+41-1
Changes to scripts/mobile-e2e/protocol.test.ts
@@ -1,7 +1,7 @@
11import { describe, expect, it } from 'bun:test'
22import { readFileSync } from 'node:fs'
33import { join } from 'node:path'
4import { 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'
4import { 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'
55
66const ESC = String.fromCharCode(27)
77
@@ -373,6 +373,46 @@ describe('cold-start deep links', () => {
373373 'the both cold start never reported; see xcodebuild-deeplink.log',
374374 ])
375375 })
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
385describe('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 })
376416})
377417
378418/** A minimal ELF64 little-endian file whose section header table names `sections`. */
scripts/mobile-e2e/protocol.tsmodified+41-3
Changes to scripts/mobile-e2e/protocol.ts
@@ -325,13 +325,45 @@ export function evaluateRun(platform: MobilePlatform, text: string, expectedRun?
325325 return { ok: failures.length === 0, failures, planned, passed, failed }
326326}
327327
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. */
329329export interface DeepLinkResult {
330330 receive: 'subscribe' | 'both'
331331 link: string
332332 launch: string | null
333333 onLink: { url?: string, initial?: boolean }[]
334334 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. */
340export 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 */
351export 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
335367}
336368
337369/**
@@ -364,15 +396,21 @@ export function deepLinkResults(output: string): DeepLinkResult[] {
364396 * `both` is the guard on the fix: a page that also calls getInitialURL must
365397 * get the link from there and not a second time from onLink.
366398 */
367export function deepLinkProblems(results: DeepLinkResult[], link: string): string[] {
399export function deepLinkProblems(
400 results: DeepLinkResult[],
401 link: string,
402 evidence: (receive: DeepLinkResult['receive']) => string = () => 'xcodebuild-deeplink.log',
403): string[] {
368404 const problems: string[] = []
369405 for (const receive of ['subscribe', 'both'] as const) {
370406 const result = results.find(entry => entry.receive === receive)
371407 const expectedLink = `${link}&receive=${receive}`
372408 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)}`)
374410 continue
375411 }
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`)
376414 if (result.launch !== expectedLink) {
377415 problems.push(`the ${receive} cold start was not launched by its link: the page saw ${JSON.stringify(result.launch)}`)
378416 continue