ReviewOS

also looking at this

craft-native/craft

fix(ios): hand a cold-start link to the page that subscribes for it

#216
Merged glennmichael123 wants to merge fix/ios-cold-start-deep-links into main
8 files +361 -19

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+7-0
Changes to .github/workflows/README.md
@@ -34,6 +34,13 @@ ok ios-shim — 6/6 cases, no zig
3434ok ios-runtime — 6/6 cases, zig served checkPermission, clipboardRead, clipboardWrite, getCurrentPosition, getDeviceInfo, log, requestPermission, share
3535```
3636
37After the suite, each leg cold-starts the app through a link twice, from an
38XCUITest (`scripts/mobile-e2e/ios-uitests`). Only a UI test can answer the
39"Open in …?" prompt iOS shows for a custom scheme, and read the page back from
40an app SpringBoard launched, since that app has no console. One page only
41subscribes to `onLink`, seconds late, and must be handed the launch link once.
42The other also calls `getInitialURL`, and must not get the link twice.
43
3744Before launch the harness grants the app location permission with
3845`simctl privacy` and puts the simulator at a fixed coordinate with
3946`simctl location`. The page must report that coordinate back from
packages/ios/templates/CraftApp.swiftmodified+76-14
Changes to packages/ios/templates/CraftApp.swift
@@ -172,9 +172,16 @@ class DeepLinkManager {
172172 static let shared = DeepLinkManager()
173173
174174 private var initialURL: URL?
175 private var pendingURL: URL?
175 // Every link that arrived while no page could receive it, in order. This
176 // was a single slot, so a second link before the bridge was ready replaced
177 // the first; on a cold start, that could be the link that launched the app.
178 private var pendingURLs: [URL] = []
176179 private weak var webView: WKWebView?
177180 private var isReady = false
181 // Whether a page has ever become ready. A link that arrives before that is
182 // the one the app was opened with; a link that arrives later is not, and
183 // is no answer to getInitialURL.
184 private var hasBeenReady = false
178185
179186 private init() {}
180187
@@ -182,26 +189,33 @@ class DeepLinkManager {
182189 self.webView = webView
183190 }
184191
192 // A navigation started, so the page that would have received a link is
193 // going away. Without this a link arriving during a reload was dispatched
194 // into the page being torn down and lost.
195 func setLoading() {
196 isReady = false
197 }
198
185199 func setReady() {
186200 isReady = true
187 // If there's a pending URL, dispatch it now
188 if let url = pendingURL {
189 dispatchDeepLink(url)
190 pendingURL = nil
201 let firstPage = !hasBeenReady
202 hasBeenReady = true
203 let urls = pendingURLs
204 pendingURLs.removeAll()
205 for url in urls {
206 dispatchDeepLink(url, initial: firstPage && url == initialURL)
191207 }
192208 }
193209
194210 func handleURL(_ url: URL) {
195 // Store as initial URL if this is the first one
196 if initialURL == nil {
211 if initialURL == nil && !hasBeenReady {
197212 initialURL = url
198213 }
199214
200215 if isReady && webView != nil {
201 dispatchDeepLink(url)
216 dispatchDeepLink(url, initial: false)
202217 } else {
203 // Store for later when web view is ready
204 pendingURL = url
218 pendingURLs.append(url)
205219 }
206220 }
207221
@@ -209,7 +223,7 @@ class DeepLinkManager {
209223 return initialURL
210224 }
211225
212 private func dispatchDeepLink(_ url: URL) {
226 private func dispatchDeepLink(_ url: URL, initial: Bool) {
213227 guard let webView = webView else { return }
214228
215229 // Parse URL components
@@ -218,7 +232,10 @@ class DeepLinkManager {
218232 "scheme": url.scheme ?? "",
219233 "host": url.host ?? "",
220234 "path": url.path,
221 "query": url.query ?? ""
235 "query": url.query ?? "",
236 // Lets the page tell the launch link apart from later ones, so a
237 // page that reads getInitialURL is not also handed it again.
238 "initial": initial
222239 ]
223240
224241 // Parse query parameters
@@ -1400,6 +1417,7 @@ struct CraftWebView: UIViewRepresentable {
14001417
14011418 func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
14021419 CraftEventManager.shared.setLoading()
1420 DeepLinkManager.shared.setLoading()
14031421 }
14041422
14051423 func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
@@ -2433,7 +2451,7 @@ struct CraftWebView: UIViewRepresentable {
24332451
24342452 // Deep Links
24352453 onDeepLink: function(callback) {
2436 window.addEventListener('craftDeepLink', function(e) { callback(e.detail); });
2454 return window.craft._subscribeDeepLinks(callback);
24372455 },
24382456
24392457 // Background Tasks
@@ -2761,6 +2779,7 @@ struct CraftWebView: UIViewRepresentable {
27612779 deepLinks: {
27622780 getInitialURL: function() {
27632781 var self = window.craft;
2782 self._claimInitialDeepLink();
27642783 var id = 'cb_' + (++self._callbackId);
27652784 window.webkit.messageHandlers.craft.postMessage({action: 'getInitialURL', callbackId: id});
27662785 return new Promise(function(resolve, reject) {
@@ -2768,7 +2787,7 @@ struct CraftWebView: UIViewRepresentable {
27682787 });
27692788 },
27702789 onLink: function(callback) {
2771 window.addEventListener('craftDeepLink', function(e) { callback(e.detail); });
2790 return window.craft._subscribeDeepLinks(callback);
27722791 }
27732792 },
27742793
@@ -2831,6 +2850,49 @@ struct CraftWebView: UIViewRepresentable {
28312850 // Stable, versioned mobile contract consumed by craft-native/mobile.
28322851 // Legacy flat methods remain available while every public SDK method
28332852 // is routed through this nested contract.
2853 // Links that arrive before anything is listening (#198).
2854 //
2855 // The native side dispatches a link the moment this script has
2856 // run, and on a cold start that is the link the app was opened
2857 // with. No page code can have called onLink by then: onLink is
2858 // defined by this very script. So every such link was dispatched
2859 // to nobody, and a page that subscribes, rather than asking
2860 // getInitialURL, never learned how it was opened.
2861 //
2862 // Held here instead, and handed to the first subscriber on the
2863 // next turn, so an unsubscribe returned in the same tick still
2864 // applies. The launch link is withdrawn when the page calls
2865 // getInitialURL, its other way of receiving it, so a page that
2866 // does both in the same tick, in either order, gets it once.
2867 (function installDeepLinkReplay(craft) {
2868 var undelivered = [];
2869 var subscribed = false;
2870 window.addEventListener('craftDeepLink', function(e) {
2871 if (!subscribed) undelivered.push(e.detail);
2872 });
2873 craft._subscribeDeepLinks = function(callback) {
2874 var active = true;
2875 var listener = function(e) { callback(e.detail); };
2876 window.addEventListener('craftDeepLink', listener);
2877 if (!subscribed) {
2878 subscribed = true;
2879 setTimeout(function() {
2880 var pending = undelivered;
2881 undelivered = [];
2882 if (!active) return;
2883 pending.forEach(function(detail) { callback(detail); });
2884 }, 0);
2885 }
2886 return function() {
2887 active = false;
2888 window.removeEventListener('craftDeepLink', listener);
2889 };
2890 };
2891 craft._claimInitialDeepLink = function() {
2892 undelivered = undelivered.filter(function(detail) { return !(detail && detail.initial); });
2893 };
2894 })(window.craft);
2895
28342896 (function installCraftMobileContract(craft) {
28352897 var legacyShare = craft.share.bind(craft);
28362898 var legacyOpenCamera = craft.openCamera.bind(craft);
packages/typescript/types/craft.d.tsmodified+12-2
Changes to packages/typescript/types/craft.d.ts
@@ -819,10 +819,18 @@ export interface CraftBridge {
819819 getInitialURL(): Promise<DeepLinkData | null>;
820820
821821 /**
822 * Listen for deep link events while app is running
822 * Listen for deep links.
823 *
824 * On iOS the first subscriber is also handed, once, every link that
825 * arrived before anything was subscribed, including the link that
826 * launched the app (`initial: true`). A page that calls `getInitialURL()`
827 * before subscribing, or in the same tick, gets the launch link from there
828 * instead and not a second time here. Android does not replay yet (#215).
829 *
823830 * @param callback - Called when a deep link is received
831 * @returns On iOS, a function that unsubscribes
824832 */
825 onLink(callback: (data: DeepLinkData) => void): void;
833 onLink(callback: (data: DeepLinkData) => void): (() => void) | void;
826834 };
827835
828836 // ==================== OTA Updates ====================
@@ -1005,6 +1013,8 @@ export interface DeepLinkData {
10051013 query: string;
10061014 /** Parsed query parameters */
10071015 queryParams?: Record<string, string>;
1016 /** True for the link that launched the app (iOS) */
1017 initial?: boolean;
10081018}
10091019
10101020export interface ProfilingCallTiming {
scripts/mobile-e2e/driver.htmlmodified+37-0
Changes to scripts/mobile-e2e/driver.html
@@ -14,6 +14,7 @@
1414<body>
1515 <h1 id="headline">Craft mobile E2E</h1>
1616 <ul id="results"></ul>
17 <p id="deeplink"></p>
1718 <script>
1819 // The page drives itself and reports through the bridge's own `log` action,
1920 // which is the one channel both runtimes already forward off-device: iOS
@@ -28,6 +29,13 @@
2829 // "this case failed" apart from "this case never ran" — a suite that dies
2930 // halfway through is a failure, not a pass with fewer cases.
3031 (function () {
32 // Every deep link the native side dispatches, recorded from the first line
33 // of the page, before the bridge exists. This is how the page knows it was
34 // opened by a link without asking getInitialURL, which would claim the
35 // launch link and change what onLink receives.
36 var dispatchedLinks = []
37 window.addEventListener('craftDeepLink', function (event) { dispatchedLinks.push(event.detail || {}) })
38
3139 var NONCE = '__CRAFT_E2E_NONCE__'
3240 // Which platform the harness built this leg for. Substituted in from
3341 // outside, because the page comparing window.craft.platform against a
@@ -282,6 +290,34 @@
282290 }
283291 ]
284292
293 // #198: a cold-start link reaches a page that subscribes late.
294 //
295 // Runs after the suite, so the subscription is seconds after the bridge
296 // appeared, the way an app that hydrates before wiring its router
297 // subscribes. The harness cold-starts the app through a link from an
298 // XCUITest, which reads this paragraph back. The app's console is not
299 // captured on that launch; SpringBoard started it, not simctl.
300 //
301 // The link says which way to receive it. `receive=subscribe` only
302 // subscribes, and must get the launch link exactly once. `receive=both`
303 // calls getInitialURL and subscribes in the same tick, and must get it
304 // from getInitialURL alone, not twice. On an ordinary launch there is no
305 // link, and the paragraph says so.
306 function probeDeepLinks() {
307 var launch = dispatchedLinks.filter(function (detail) { return detail.initial === true })[0] || null
308 var receive = launch && launch.url.indexOf('receive=both') !== -1 ? 'both' : 'subscribe'
309 var seen = { launch: launch ? launch.url : null, receive: receive, onLink: [], initialURL: null }
310 if (receive === 'both') {
311 window.craft.deepLinks.getInitialURL().then(function (value) { seen.initialURL = value && value.url })
312 }
313 window.craft.deepLinks.onLink(function (detail) {
314 seen.onLink.push({ url: detail && detail.url, initial: detail && detail.initial })
315 })
316 setTimeout(function () {
317 document.getElementById('deeplink').textContent = 'CRAFT-E2E-DEEPLINK ' + JSON.stringify(seen)
318 }, 1500)
319 }
320
285321 function waitForBridge(attempt) {
286322 if (window.craft && window.craft.getDeviceInfo) return Promise.resolve(true)
287323 if (attempt >= 150) return Promise.resolve(false)
@@ -311,6 +347,7 @@
311347 if (index >= selected.length) {
312348 emit({ event: 'done', passed: passed, failed: failed })
313349 document.getElementById('headline').textContent = failed ? 'FAILED' : 'PASSED'
350 if (platform === 'ios') probeDeepLinks()
314351 return
315352 }
316353
scripts/mobile-e2e/ios-uitests/DeepLinkColdStartTests.swiftadded+49-0
Changes to scripts/mobile-e2e/ios-uitests/DeepLinkColdStartTests.swift
@@ -0,0 +1,49 @@
1import XCTest
2
3/// Cold-starts the probe app through a deep link and reports what its page saw.
4///
5/// An XCUITest rather than `simctl openurl`, for two reasons. iOS asks
6/// "Open in …?" before handing a custom scheme to an app, and simctl has no
7/// way to answer; a UI test can tap the button. And an app SpringBoard
8/// launches has no console the harness can read, while a UI test can read the
9/// page's own text.
10///
11/// No assertions about the answer live here. The test prints the page's report
12/// and `scripts/mobile-e2e/protocol.ts` judges it, where the rule is unit
13/// tested and shared with the rest of the suite.
14final class DeepLinkColdStartTests: XCTestCase {
15 private let bundleId = ProcessInfo.processInfo.environment["PROBE_BUNDLE_ID"] ?? ""
16 private let linkBase = ProcessInfo.processInfo.environment["PROBE_LINK"] ?? ""
17
18 func testSubscribeOnly() throws {
19 try coldStart(receive: "subscribe")
20 }
21
22 func testGetInitialURLAndSubscribe() throws {
23 try coldStart(receive: "both")
24 }
25
26 private func coldStart(receive: String) throws {
27 XCTAssertFalse(bundleId.isEmpty, "PROBE_BUNDLE_ID is not set")
28 XCTAssertFalse(linkBase.isEmpty, "PROBE_LINK is not set")
29
30 let app = XCUIApplication(bundleIdentifier: bundleId)
31 app.terminate()
32 XCTAssertTrue(app.wait(for: .notRunning, timeout: 15), "the app did not stop, so this would not be a cold start")
33
34 let link = "\(linkBase)&receive=\(receive)"
35 XCUIDevice.shared.system.open(URL(string: link)!)
36
37 // Only the first open of a scheme asks, so the prompt is optional.
38 let open = XCUIApplication(bundleIdentifier: "com.apple.springboard").buttons["Open"]
39 if open.waitForExistence(timeout: 5) { open.tap() }
40
41 XCTAssertTrue(app.wait(for: .runningForeground, timeout: 60), "the link did not start the app")
42
43 let report = app.staticTexts
44 .containing(NSPredicate(format: "label BEGINSWITH 'CRAFT-E2E-DEEPLINK '"))
45 .firstMatch
46 XCTAssertTrue(report.waitForExistence(timeout: 120), "the page never reported what it received")
47 print("CRAFT-E2E-DEEPLINK-RESULT \(receive) \(link) \(report.label)")
48 }
49}
scripts/mobile-e2e/ios.tsmodified+72-2
Changes to scripts/mobile-e2e/ios.ts
@@ -2,9 +2,12 @@ import type { LegOutcome, RunnerOptions } from './types'
22import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
33import { join } from 'node:path'
44import { bootSimulator, init, pickSimulator } from '../../packages/ios/src/index'
5import { evaluateRun, hasTerminated, ZIG_REFUSED_ACTIONS, ZIG_SERVED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigHandBacks, zigRefusals } from './protocol'
5import { deepLinkProblems, deepLinkResults, evaluateRun, hasTerminated, ZIG_REFUSED_ACTIONS, ZIG_SERVED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigHandBacks, zigRefusals } from './protocol'
66import { command, driverPage, waitForFile } from './support'
77
8/** The scheme the probe registers, and the one its cold-start link uses. */
9const DEEP_LINK_SCHEME = 'crafte2eprobe'
10
811/**
912 * The app the suite runs against.
1013 *
@@ -27,6 +30,9 @@ const CONFIG = {
2730 enableClipboard: true,
2831 enableGeolocation: true,
2932 enableShare: false,
33 // For the cold-start link (#198), which the harness opens from an XCUITest.
34 enableDeepLinks: true,
35 urlSchemes: [DEEP_LINK_SCHEME],
3036}
3137
3238/**
@@ -41,6 +47,16 @@ const POSITION_TOLERANCE = 0.001
4147const BUNDLE_ID = 'dev.craft.e2e.probe'
4248const APP_NAME = 'CraftE2EProbe'
4349
50/**
51 * The UI test target the harness adds to the generated project.
52 *
53 * Added here rather than by the generator, because no app wants it: it exists
54 * to cold-start the probe through a link, which is the one thing neither
55 * `simctl launch` nor `simctl openurl` can do unattended. See
56 * `ios-uitests/DeepLinkColdStartTests.swift` for why.
57 */
58const UI_TESTS = `${APP_NAME}UITests`
59
4460/**
4561 * One leg is one answer to "who served the call".
4662 *
@@ -93,6 +109,33 @@ async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> {
93109 if (!leg.requireZigDispatch && linksRuntime)
94110 failures.push(`the ${leg.name} leg was supposed to have no runtime but its project links one`)
95111
112 // The cold-start UI test, as a target in the generated project.
113 mkdirSync(join(project, 'UITests'), { recursive: true })
114 copyFileSync(join(import.meta.dir, 'ios-uitests', 'DeepLinkColdStartTests.swift'), join(project, 'UITests', 'DeepLinkColdStartTests.swift'))
115 writeFileSync(join(project, 'project.yml'), `${projectYml.trimEnd()}
116 ${UI_TESTS}:
117 type: bundle.ui-testing
118 platform: iOS
119 sources:
120 - UITests
121 settings:
122 TEST_TARGET_NAME: ${APP_NAME}
123 PRODUCT_BUNDLE_IDENTIFIER: ${BUNDLE_ID}.uitests
124 GENERATE_INFOPLIST_FILE: YES
125 SWIFT_VERSION: "5.0"
126 dependencies:
127 - target: ${APP_NAME}
128schemes:
129 ${APP_NAME}:
130 build:
131 targets:
132 ${APP_NAME}: all
133 ${UI_TESTS}: [test]
134 test:
135 targets:
136 - ${UI_TESTS}
137`)
138
96139 const nonce = `craft-e2e-${label}-${options.runId}`
97140 writeFileSync(join(project, 'dist', 'index.html'), driverPage(nonce, 'ios'))
98141 copyFileSync(join(project, 'dist', 'index.html'), join(evidence, 'index.html'))
@@ -111,9 +154,12 @@ async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> {
111154 // gate compiles -sdk iphoneos only. A generated app that links for a
112155 // device and not for a simulator would have gone unnoticed.
113156 '-sdk', 'iphonesimulator',
157 '-destination', 'generic/platform=iOS Simulator',
114158 '-derivedDataPath', derived,
115159 'CODE_SIGNING_ALLOWED=NO',
116 'build',
160 // The app and the UI test runner together, so the cold-start step below
161 // runs what was built here rather than building again.
162 'build-for-testing',
117163 ], { cwd: project, logPath: join(evidence, 'xcodebuild.log') })
118164
119165 const app = join(derived, 'Build', 'Products', 'Debug-iphonesimulator', `${APP_NAME}.app`)
@@ -225,6 +271,30 @@ async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> {
225271 }
226272 }
227273
274 // #198: cold-start the app through a link, twice, and judge what the page
275 // received. After the suite and its evidence, because the UI test
276 // terminates and relaunches the app.
277 const link = `${DEEP_LINK_SCHEME}://e2e/cold?run=${encodeURIComponent(nonce)}`
278 const coldStart = await command([
279 'xcodebuild',
280 '-project', `${APP_NAME}.xcodeproj`,
281 '-scheme', APP_NAME,
282 '-destination', `id=${device.udid}`,
283 '-derivedDataPath', derived,
284 'CODE_SIGNING_ALLOWED=NO',
285 'test-without-building',
286 ], {
287 cwd: project,
288 // xcodebuild hands TEST_RUNNER_-prefixed variables to the runner without
289 // the prefix.
290 env: { TEST_RUNNER_PROBE_BUNDLE_ID: BUNDLE_ID, TEST_RUNNER_PROBE_LINK: link },
291 logPath: join(evidence, 'xcodebuild-deeplink.log'),
292 allowFailure: true,
293 })
294 await command(['xcrun', 'simctl', 'io', device.udid, 'screenshot', join(evidence, 'deeplink-screen.png')], { allowFailure: true })
295 await command(['xcrun', 'simctl', 'terminate', device.udid, BUNDLE_ID], { allowFailure: true })
296 failures.push(...deepLinkProblems(deepLinkResults(`${coldStart.stdout}\n${coldStart.stderr}`), link))
297
228298 const dispatched = zigDispatchedActions(consoleText)
229299 const refused = zigRefusals(consoleText)
230300 const handedBack = zigHandBacks(consoleText)
scripts/mobile-e2e/protocol.test.tsmodified+39-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, DISMISS_SHARE_MENU, evaluateRun, hasTerminated, parseDriverOutput, REQUIRED_CASES, requiredCaseProblems, runtimePermissionGranted, shareMenuInFront, ZIG_REFUSED_ACTIONS, ZIG_SERVED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigHandBacks, zigRefusals } from './protocol'
4import { ANDROID_DECLINE_PHRASES, androidDeclines, awaitedNeeds, deepLinkProblems, deepLinkResults, DISMISS_SHARE_MENU, evaluateRun, hasTerminated, parseDriverOutput, REQUIRED_CASES, requiredCaseProblems, runtimePermissionGranted, shareMenuInFront, ZIG_REFUSED_ACTIONS, ZIG_SERVED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigHandBacks, zigRefusals } from './protocol'
55
66const ESC = String.fromCharCode(27)
77
@@ -334,6 +334,44 @@ describe('share menu', () => {
334334 })
335335})
336336
337describe('cold-start deep links', () => {
338 const LINK = 'crafte2eprobe://e2e/cold?run=craft-e2e-ios-shim-1'
339 const line = (receive: string, report: object) =>
340 `CRAFT-E2E-DEEPLINK-RESULT ${receive} ${LINK}&receive=${receive} CRAFT-E2E-DEEPLINK ${JSON.stringify(report)}`
341 const passing = [
342 'Test Case started.',
343 line('subscribe', { launch: `${LINK}&receive=subscribe`, receive: 'subscribe', onLink: [{ url: `${LINK}&receive=subscribe`, initial: true }], initialURL: null }),
344 line('both', { launch: `${LINK}&receive=both`, receive: 'both', onLink: [], initialURL: `${LINK}&receive=both` }),
345 ].join('\n')
346
347 it('passes a page that got the launch link once either way', () => {
348 expect(deepLinkProblems(deepLinkResults(passing), LINK)).toEqual([])
349 })
350
351 // The bug: the link was dispatched before onLink could exist.
352 it('fails a subscriber that never received the launch link', () => {
353 const text = line('subscribe', { launch: `${LINK}&receive=subscribe`, receive: 'subscribe', onLink: [], initialURL: null })
354 expect(deepLinkProblems(deepLinkResults(`${text}\n${passing.split('\n')[2]}`), LINK))
355 .toEqual(['a page that only subscribes received [] from onLink, expected the launch link once'])
356 })
357
358 // The guard on the fix: replaying to subscribers must not double-deliver.
359 it('fails a page handed the launch link by both getInitialURL and onLink', () => {
360 const text = line('both', { launch: `${LINK}&receive=both`, receive: 'both', onLink: [{ url: `${LINK}&receive=both`, initial: true }], initialURL: `${LINK}&receive=both` })
361 const problems = deepLinkProblems(deepLinkResults(`${passing.split('\n')[1]}\n${text}`), LINK)
362 expect(problems).toHaveLength(1)
363 expect(problems[0]).toContain('handed the launch link again by onLink')
364 })
365
366 it('names a cold start that never reported, and one launched by something else', () => {
367 const stale = line('subscribe', { launch: 'crafte2eprobe://e2e/cold?run=an-earlier-run&receive=subscribe', receive: 'subscribe', onLink: [], initialURL: null })
368 expect(deepLinkProblems(deepLinkResults(stale), LINK)).toEqual([
369 'the subscribe cold start was not launched by its link: the page saw "crafte2eprobe://e2e/cold?run=an-earlier-run&receive=subscribe"',
370 'the both cold start never reported; see xcodebuild-deeplink.log',
371 ])
372 })
373})
374
337375describe('runtime permission state', () => {
338376 // As `dumpsys package` prints a coarse-only grant on Android 14.
339377 const dumpsys = [
scripts/mobile-e2e/protocol.tsmodified+69-0
Changes to scripts/mobile-e2e/protocol.ts
@@ -319,6 +319,75 @@ export function evaluateRun(platform: MobilePlatform, text: string, expectedRun?
319319 return { ok: failures.length === 0, failures, planned, passed, failed }
320320}
321321
322/** What one cold start through a link saw, as the XCUITest printed it. */
323export interface DeepLinkResult {
324 receive: 'subscribe' | 'both'
325 link: string
326 launch: string | null
327 onLink: { url?: string, initial?: boolean }[]
328 initialURL: string | null
329}
330
331/**
332 * The page reports the UI test printed, read out of xcodebuild's output.
333 *
334 * `ios-uitests/DeepLinkColdStartTests.swift` writes one
335 * `CRAFT-E2E-DEEPLINK-RESULT <receive> <link> CRAFT-E2E-DEEPLINK {json}` line
336 * per cold start. A line whose JSON does not parse is skipped here and
337 * surfaces as that mode being missing.
338 */
339export function deepLinkResults(output: string): DeepLinkResult[] {
340 const results: DeepLinkResult[] = []
341 for (const line of output.split('\n')) {
342 const match = line.match(/CRAFT-E2E-DEEPLINK-RESULT (subscribe|both) (\S+) CRAFT-E2E-DEEPLINK (\{.*\})\s*$/)
343 if (!match) continue
344 try {
345 const report = JSON.parse(match[3]!) as Omit<DeepLinkResult, 'receive' | 'link'>
346 results.push({ receive: match[1] as DeepLinkResult['receive'], link: `${match[2]}`, ...report })
347 }
348 catch {}
349 }
350 return results
351}
352
353/**
354 * Why the cold-start runs are not a pass, one line per reason.
355 *
356 * `subscribe` is #198 itself: a page that only subscribes, seconds after the
357 * bridge appeared, must be handed the launch link once, marked initial.
358 * `both` is the guard on the fix: a page that also calls getInitialURL must
359 * get the link from there and not a second time from onLink.
360 */
361export function deepLinkProblems(results: DeepLinkResult[], link: string): string[] {
362 const problems: string[] = []
363 for (const receive of ['subscribe', 'both'] as const) {
364 const result = results.find(entry => entry.receive === receive)
365 const expectedLink = `${link}&receive=${receive}`
366 if (!result) {
367 problems.push(`the ${receive} cold start never reported; see xcodebuild-deeplink.log`)
368 continue
369 }
370 if (result.launch !== expectedLink) {
371 problems.push(`the ${receive} cold start was not launched by its link: the page saw ${JSON.stringify(result.launch)}`)
372 continue
373 }
374 if (receive === 'subscribe') {
375 const delivered = result.onLink.filter(entry => entry.url === expectedLink)
376 if (delivered.length !== 1 || result.onLink.length !== 1)
377 problems.push(`a page that only subscribes received ${JSON.stringify(result.onLink)} from onLink, expected the launch link once`)
378 else if (delivered[0]!.initial !== true)
379 problems.push('the launch link reached onLink without initial: true')
380 }
381 else {
382 if (result.initialURL !== expectedLink)
383 problems.push(`getInitialURL answered ${JSON.stringify(result.initialURL)}, expected the launch link`)
384 if (result.onLink.length)
385 problems.push(`a page that called getInitialURL was handed the launch link again by onLink: ${JSON.stringify(result.onLink)}`)
386 }
387 }
388 return problems
389}
390
322391/** The need the Android share case announces before it opens the menu. */
323392export const DISMISS_SHARE_MENU = 'dismiss-share-menu'
324393