also looking at this
fix(ios): hand a cold-start link to the page that subscribes for it
#216
8 files
+361
-19
| @@ -34,6 +34,13 @@ ok ios-shim — 6/6 cases, no zig | ||
| 34 | 34 | ok ios-runtime — 6/6 cases, zig served checkPermission, clipboardRead, clipboardWrite, getCurrentPosition, getDeviceInfo, log, requestPermission, share |
| 35 | 35 | ``` |
| 36 | 36 | |
| 37 | After the suite, each leg cold-starts the app through a link twice, from an | |
| 38 | XCUITest (`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 | |
| 40 | an app SpringBoard launched, since that app has no console. One page only | |
| 41 | subscribes to `onLink`, seconds late, and must be handed the launch link once. | |
| 42 | The other also calls `getInitialURL`, and must not get the link twice. | |
| 43 | ||
| 37 | 44 | Before launch the harness grants the app location permission with |
| 38 | 45 | `simctl privacy` and puts the simulator at a fixed coordinate with |
| 39 | 46 | `simctl location`. The page must report that coordinate back from |
| @@ -172,9 +172,16 @@ class DeepLinkManager { | ||
| 172 | 172 | static let shared = DeepLinkManager() |
| 173 | 173 | |
| 174 | 174 | 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] = [] | |
| 176 | 179 | private weak var webView: WKWebView? |
| 177 | 180 | 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 | |
| 178 | 185 | |
| 179 | 186 | private init() {} |
| 180 | 187 | |
| @@ -182,26 +189,33 @@ class DeepLinkManager { | ||
| 182 | 189 | self.webView = webView |
| 183 | 190 | } |
| 184 | 191 | |
| 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 | ||
| 185 | 199 | func setReady() { |
| 186 | 200 | 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) | |
| 191 | 207 | } |
| 192 | 208 | } |
| 193 | 209 | |
| 194 | 210 | func handleURL(_ url: URL) { |
| 195 | // Store as initial URL if this is the first one | |
| 196 | if initialURL == nil { | |
| 211 | if initialURL == nil && !hasBeenReady { | |
| 197 | 212 | initialURL = url |
| 198 | 213 | } |
| 199 | 214 | |
| 200 | 215 | if isReady && webView != nil { |
| 201 | dispatchDeepLink(url) | |
| 216 | dispatchDeepLink(url, initial: false) | |
| 202 | 217 | } else { |
| 203 | // Store for later when web view is ready | |
| 204 | pendingURL = url | |
| 218 | pendingURLs.append(url) | |
| 205 | 219 | } |
| 206 | 220 | } |
| 207 | 221 | |
| @@ -209,7 +223,7 @@ class DeepLinkManager { | ||
| 209 | 223 | return initialURL |
| 210 | 224 | } |
| 211 | 225 | |
| 212 | private func dispatchDeepLink(_ url: URL) { | |
| 226 | private func dispatchDeepLink(_ url: URL, initial: Bool) { | |
| 213 | 227 | guard let webView = webView else { return } |
| 214 | 228 | |
| 215 | 229 | // Parse URL components |
| @@ -218,7 +232,10 @@ class DeepLinkManager { | ||
| 218 | 232 | "scheme": url.scheme ?? "", |
| 219 | 233 | "host": url.host ?? "", |
| 220 | 234 | "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 | |
| 222 | 239 | ] |
| 223 | 240 | |
| 224 | 241 | // Parse query parameters |
| @@ -1400,6 +1417,7 @@ struct CraftWebView: UIViewRepresentable { | ||
| 1400 | 1417 | |
| 1401 | 1418 | func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { |
| 1402 | 1419 | CraftEventManager.shared.setLoading() |
| 1420 | DeepLinkManager.shared.setLoading() | |
| 1403 | 1421 | } |
| 1404 | 1422 | |
| 1405 | 1423 | func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { |
| @@ -2433,7 +2451,7 @@ struct CraftWebView: UIViewRepresentable { | ||
| 2433 | 2451 | |
| 2434 | 2452 | // Deep Links |
| 2435 | 2453 | onDeepLink: function(callback) { |
| 2436 | window.addEventListener('craftDeepLink', function(e) { callback(e.detail); }); | |
| 2454 | return window.craft._subscribeDeepLinks(callback); | |
| 2437 | 2455 | }, |
| 2438 | 2456 | |
| 2439 | 2457 | // Background Tasks |
| @@ -2761,6 +2779,7 @@ struct CraftWebView: UIViewRepresentable { | ||
| 2761 | 2779 | deepLinks: { |
| 2762 | 2780 | getInitialURL: function() { |
| 2763 | 2781 | var self = window.craft; |
| 2782 | self._claimInitialDeepLink(); | |
| 2764 | 2783 | var id = 'cb_' + (++self._callbackId); |
| 2765 | 2784 | window.webkit.messageHandlers.craft.postMessage({action: 'getInitialURL', callbackId: id}); |
| 2766 | 2785 | return new Promise(function(resolve, reject) { |
| @@ -2768,7 +2787,7 @@ struct CraftWebView: UIViewRepresentable { | ||
| 2768 | 2787 | }); |
| 2769 | 2788 | }, |
| 2770 | 2789 | onLink: function(callback) { |
| 2771 | window.addEventListener('craftDeepLink', function(e) { callback(e.detail); }); | |
| 2790 | return window.craft._subscribeDeepLinks(callback); | |
| 2772 | 2791 | } |
| 2773 | 2792 | }, |
| 2774 | 2793 | |
| @@ -2831,6 +2850,49 @@ struct CraftWebView: UIViewRepresentable { | ||
| 2831 | 2850 | // Stable, versioned mobile contract consumed by craft-native/mobile. |
| 2832 | 2851 | // Legacy flat methods remain available while every public SDK method |
| 2833 | 2852 | // 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 | ||
| 2834 | 2896 | (function installCraftMobileContract(craft) { |
| 2835 | 2897 | var legacyShare = craft.share.bind(craft); |
| 2836 | 2898 | var legacyOpenCamera = craft.openCamera.bind(craft); |
| @@ -819,10 +819,18 @@ export interface CraftBridge { | ||
| 819 | 819 | getInitialURL(): Promise<DeepLinkData | null>; |
| 820 | 820 | |
| 821 | 821 | /** |
| 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 | * | |
| 823 | 830 | * @param callback - Called when a deep link is received |
| 831 | * @returns On iOS, a function that unsubscribes | |
| 824 | 832 | */ |
| 825 | onLink(callback: (data: DeepLinkData) => void): void; | |
| 833 | onLink(callback: (data: DeepLinkData) => void): (() => void) | void; | |
| 826 | 834 | }; |
| 827 | 835 | |
| 828 | 836 | // ==================== OTA Updates ==================== |
| @@ -1005,6 +1013,8 @@ export interface DeepLinkData { | ||
| 1005 | 1013 | query: string; |
| 1006 | 1014 | /** Parsed query parameters */ |
| 1007 | 1015 | queryParams?: Record<string, string>; |
| 1016 | /** True for the link that launched the app (iOS) */ | |
| 1017 | initial?: boolean; | |
| 1008 | 1018 | } |
| 1009 | 1019 | |
| 1010 | 1020 | export interface ProfilingCallTiming { |
| @@ -14,6 +14,7 @@ | ||
| 14 | 14 | <body> |
| 15 | 15 | <h1 id="headline">Craft mobile E2E</h1> |
| 16 | 16 | <ul id="results"></ul> |
| 17 | <p id="deeplink"></p> | |
| 17 | 18 | <script> |
| 18 | 19 | // The page drives itself and reports through the bridge's own `log` action, |
| 19 | 20 | // which is the one channel both runtimes already forward off-device: iOS |
| @@ -28,6 +29,13 @@ | ||
| 28 | 29 | // "this case failed" apart from "this case never ran" — a suite that dies |
| 29 | 30 | // halfway through is a failure, not a pass with fewer cases. |
| 30 | 31 | (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 | ||
| 31 | 39 | var NONCE = '__CRAFT_E2E_NONCE__' |
| 32 | 40 | // Which platform the harness built this leg for. Substituted in from |
| 33 | 41 | // outside, because the page comparing window.craft.platform against a |
| @@ -282,6 +290,34 @@ | ||
| 282 | 290 | } |
| 283 | 291 | ] |
| 284 | 292 | |
| 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 | ||
| 285 | 321 | function waitForBridge(attempt) { |
| 286 | 322 | if (window.craft && window.craft.getDeviceInfo) return Promise.resolve(true) |
| 287 | 323 | if (attempt >= 150) return Promise.resolve(false) |
| @@ -311,6 +347,7 @@ | ||
| 311 | 347 | if (index >= selected.length) { |
| 312 | 348 | emit({ event: 'done', passed: passed, failed: failed }) |
| 313 | 349 | document.getElementById('headline').textContent = failed ? 'FAILED' : 'PASSED' |
| 350 | if (platform === 'ios') probeDeepLinks() | |
| 314 | 351 | return |
| 315 | 352 | } |
| 316 | 353 | |
| @@ -0,0 +1,49 @@ | ||
| 1 | import 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. | |
| 14 | final 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 | } | |
| @@ -2,9 +2,12 @@ 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 { bootSimulator, init, pickSimulator } from '../../packages/ios/src/index' |
| 5 | import { evaluateRun, hasTerminated, ZIG_REFUSED_ACTIONS, ZIG_SERVED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigHandBacks, zigRefusals } from './protocol' | |
| 5 | import { deepLinkProblems, deepLinkResults, evaluateRun, hasTerminated, ZIG_REFUSED_ACTIONS, ZIG_SERVED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigHandBacks, zigRefusals } from './protocol' | |
| 6 | 6 | import { command, driverPage, waitForFile } from './support' |
| 7 | 7 | |
| 8 | /** The scheme the probe registers, and the one its cold-start link uses. */ | |
| 9 | const DEEP_LINK_SCHEME = 'crafte2eprobe' | |
| 10 | ||
| 8 | 11 | /** |
| 9 | 12 | * The app the suite runs against. |
| 10 | 13 | * |
| @@ -27,6 +30,9 @@ const CONFIG = { | ||
| 27 | 30 | enableClipboard: true, |
| 28 | 31 | enableGeolocation: true, |
| 29 | 32 | enableShare: false, |
| 33 | // For the cold-start link (#198), which the harness opens from an XCUITest. | |
| 34 | enableDeepLinks: true, | |
| 35 | urlSchemes: [DEEP_LINK_SCHEME], | |
| 30 | 36 | } |
| 31 | 37 | |
| 32 | 38 | /** |
| @@ -41,6 +47,16 @@ const POSITION_TOLERANCE = 0.001 | ||
| 41 | 47 | const BUNDLE_ID = 'dev.craft.e2e.probe' |
| 42 | 48 | const APP_NAME = 'CraftE2EProbe' |
| 43 | 49 | |
| 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 | */ | |
| 58 | const UI_TESTS = `${APP_NAME}UITests` | |
| 59 | ||
| 44 | 60 | /** |
| 45 | 61 | * One leg is one answer to "who served the call". |
| 46 | 62 | * |
| @@ -93,6 +109,33 @@ async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> { | ||
| 93 | 109 | if (!leg.requireZigDispatch && linksRuntime) |
| 94 | 110 | failures.push(`the ${leg.name} leg was supposed to have no runtime but its project links one`) |
| 95 | 111 | |
| 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} | |
| 128 | schemes: | |
| 129 | ${APP_NAME}: | |
| 130 | build: | |
| 131 | targets: | |
| 132 | ${APP_NAME}: all | |
| 133 | ${UI_TESTS}: [test] | |
| 134 | test: | |
| 135 | targets: | |
| 136 | - ${UI_TESTS} | |
| 137 | `) | |
| 138 | ||
| 96 | 139 | const nonce = `craft-e2e-${label}-${options.runId}` |
| 97 | 140 | writeFileSync(join(project, 'dist', 'index.html'), driverPage(nonce, 'ios')) |
| 98 | 141 | copyFileSync(join(project, 'dist', 'index.html'), join(evidence, 'index.html')) |
| @@ -111,9 +154,12 @@ async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> { | ||
| 111 | 154 | // gate compiles -sdk iphoneos only. A generated app that links for a |
| 112 | 155 | // device and not for a simulator would have gone unnoticed. |
| 113 | 156 | '-sdk', 'iphonesimulator', |
| 157 | '-destination', 'generic/platform=iOS Simulator', | |
| 114 | 158 | '-derivedDataPath', derived, |
| 115 | 159 | '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', | |
| 117 | 163 | ], { cwd: project, logPath: join(evidence, 'xcodebuild.log') }) |
| 118 | 164 | |
| 119 | 165 | const app = join(derived, 'Build', 'Products', 'Debug-iphonesimulator', `${APP_NAME}.app`) |
| @@ -225,6 +271,30 @@ async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> { | ||
| 225 | 271 | } |
| 226 | 272 | } |
| 227 | 273 | |
| 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 | ||
| 228 | 298 | const dispatched = zigDispatchedActions(consoleText) |
| 229 | 299 | const refused = zigRefusals(consoleText) |
| 230 | 300 | const handedBack = zigHandBacks(consoleText) |
| @@ -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, 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' | |
| 4 | import { 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' | |
| 5 | 5 | |
| 6 | 6 | const ESC = String.fromCharCode(27) |
| 7 | 7 | |
| @@ -334,6 +334,44 @@ describe('share menu', () => { | ||
| 334 | 334 | }) |
| 335 | 335 | }) |
| 336 | 336 | |
| 337 | describe('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 | ||
| 337 | 375 | describe('runtime permission state', () => { |
| 338 | 376 | // As `dumpsys package` prints a coarse-only grant on Android 14. |
| 339 | 377 | const dumpsys = [ |
| @@ -319,6 +319,75 @@ export function evaluateRun(platform: MobilePlatform, text: string, expectedRun? | ||
| 319 | 319 | return { ok: failures.length === 0, failures, planned, passed, failed } |
| 320 | 320 | } |
| 321 | 321 | |
| 322 | /** What one cold start through a link saw, as the XCUITest printed it. */ | |
| 323 | export 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 | */ | |
| 339 | export 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 | */ | |
| 361 | export 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 | ||
| 322 | 391 | /** The need the Android share case announces before it opens the menu. */ |
| 323 | 392 | export const DISMISS_SHARE_MENU = 'dismiss-share-menu' |
| 324 | 393 | |