From e9e9910296fc02b0fc45262b17cbda85ac480a42 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 22 Oct 2026 16:44:17 +0900 Subject: [PATCH] test: verify native lifecycle recovery and Android accessibility --- demo/tests/native-embedding/android/README.md | 81 ++- .../android/accessibility-run.mjs | 259 ++++++++++ .../native-embedding/android/app/build.gradle | 6 +- .../java/probe/AccessibilityProbe.java | 124 +++++ .../android/app/src/debug/AndroidManifest.xml | 3 + .../src/debug/java/probe/WebViewControl.java | 36 ++ .../app/src/main/java/probe/Launcher.java | 7 +- demo/tests/native-embedding/android/run.mjs | 473 +++++++++++++----- demo/tests/native-embedding/ios/README.md | 19 + .../ios/RuntimeProbe.swift.example | 23 + demo/tests/native-embedding/ios/run.mjs | 74 +++ .../tests/native-embedding/network-server.mjs | 16 + demo/tests/native-embedding/worker.js | 15 + 13 files changed, 997 insertions(+), 139 deletions(-) create mode 100644 demo/tests/native-embedding/android/accessibility-run.mjs create mode 100644 demo/tests/native-embedding/android/app/src/androidTest/java/probe/AccessibilityProbe.java create mode 100644 demo/tests/native-embedding/android/app/src/debug/AndroidManifest.xml create mode 100644 demo/tests/native-embedding/android/app/src/debug/java/probe/WebViewControl.java create mode 100644 demo/tests/native-embedding/network-server.mjs diff --git a/demo/tests/native-embedding/android/README.md b/demo/tests/native-embedding/android/README.md index ebe224f..7912378 100644 --- a/demo/tests/native-embedding/android/README.md +++ b/demo/tests/native-embedding/android/README.md @@ -10,7 +10,7 @@ node run.mjs emulator-5554 The runner uses the repository's Gradle wrapper to publish the SDK into `build/maven`, then builds the consumer without including the SDK source project. Dependencies resolve from the published AAR, POM/Gradle metadata, and Maven Central. Set `JAVA_HOME` to Java 21 and configure `ANDROID_HOME` (or `local.properties`) for your SDK installation. -After a release reaches Maven Central, run `node run.mjs emulator-5554 --central` +After a release reaches Maven Central, run `WORKERS_ANDROID_VERSION=0.1.2 node run.mjs emulator-5554 --central` to verify the released coordinate using the same acceptance checks. This skips local SDK publication and removes the local Maven repository from dependency resolution; only Google and Maven Central are used. @@ -26,3 +26,82 @@ To verify the published JavaScript alongside the Maven SDK, set package before running with `--central`. The fixture uses that package's runtime and esbuild plugin instead of local `dist`. Match its source revision to the Maven artifact using the [dependency matrix](../../../../docs/native-embedding.md#dependency-setup). + +## Lifecycle and connection recovery + +Start `node ../network-server.mjs` in a separate terminal, then add `--lifecycle` +to the runner invocation. The test sends the app to Home and restores its existing +task by tapping its card in Android Recents, checks the Worker page, then verifies native HTTP +success, a connection closed before response headers, and successful retry. +ADB reverse temporarily exposes loopback port 18764 to the emulator and is removed +after the run. Reserve that port for the fixture. Stop the server after testing. +This models connection loss, not airplane mode or an offline cloud binding. + +For published npm 0.1.3, set `WORKERS_ANDROID_VERSION=0.1.2` with `--central` to +select the compatible released Maven artifact. Without an override, local SDK +publication and the consumer follow the repository package version. + +`build/acceptance/startup.json` records the selected build configuration and elapsed time including +accessibility polling and tap dispatch; it is not a precise startup benchmark. +`memory-ready.txt` is a `dumpsys meminfo` snapshot of the host application process. +It does not include the entire WebView renderer process tree. Neither measurement +establishes physical-device or production performance. + +If UIAutomator exposes an empty WebView hierarchy after navigation, the runner +checks the visible DOM element through WebView debugging and uses its viewport +bounds for the native tap. Each use is logged and `dom-visibility.png` records the +screen. Native host/isolation checks continue to require Android accessibility +nodes. This fallback is functional verification, not an accessibility acceptance. + +For a separate Release startup/memory sample, run with +`WORKERS_BUILD_CONFIGURATION=release` and `--measure-only` (without `--lifecycle`). +The fixture signs this local Release APK with its debug key for emulator install; +it does not enable application debugging. This mode checks initial Worker Home, +saves `measurement.png` and the metrics, then closes back to the host. It is not +the full acceptance suite, which requires Debug WebView inspection. Preserve +`build/acceptance/` from the full run before this measurement overwrites its files. + +## Investigate Android accessibility separately + +`accessibility-run.mjs` distinguishes the rendered DOM from the Android +accessibility tree. A missing accessibility heading is a failure even when its +DOM or screenshot is correct; it never uses the functional runner's DOM fallback +to pass this test. + +Build and install the debug-only plain WebView comparator and instrumentation: + +```sh +# Run from the repository root with Java 21 and ANDROID_HOME configured. +demo/android/gradlew -p demo/tests/native-embedding/android -PcentralOnly -PsdkVersion=0.1.2 :app:assembleDebug :app:assembleDebugAndroidTest +adb -s emulator-5556 install -r demo/tests/native-embedding/android/app/build/outputs/apk/debug/app-debug.apk +adb -s emulator-5556 install -r demo/tests/native-embedding/android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk + +# One persistent UiAutomation connection, native taps, strict accessibility checks. +node demo/tests/native-embedding/android/accessibility-run.mjs emulator-5556 sdk 20 --persistent +node demo/tests/native-embedding/android/accessibility-run.mjs emulator-5556 default 20 --persistent + +# A fresh `uiautomator dump` connection at each observation; DOM checked separately. +node demo/tests/native-embedding/android/accessibility-run.mjs emulator-5556 sdk 10 +``` + +Modes are `sdk`, `default` (plain WebView, default profile), and `profile` (plain +WebView, separate profile). The comparator has no Workers bridge or Service Worker. +It is included only in Debug APKs. Each round visits Next and submits a form, so +20 rounds assert 40 transitions. `WORKERS_A11Y_OUTPUT` selects the evidence directory; +the default is `artifacts/android-accessibility/-` relative to the +current directory. The runner validates the instrumentation result rather than +trusting `am instrument`'s shell exit status. Failure evidence includes the native +tree, visible DOM and screenshot. DOM capture is diagnostic, never a success oracle. + +### WebView provider requirements for verification + +Record the provider version with every run (`webview.txt`). WebView 134.0.6998.135 +can expose an empty native accessibility tree even in the plain WebView comparator. +Use the verified 155.0.8059.43 provider to reproduce this environment's acceptance. +Updating the verification environment resolved the observed failure; this does not +introduce a new SDK minimum WebView version or certify TalkBack. Android remains +in the verification scope. If the failure recurs on the verified provider, compare +plain WebView and SDK results before attributing it to the SDK. + +Keep run results and investigation evidence in the pull request and generated +artifacts, as required by the repository's contribution guidelines. diff --git a/demo/tests/native-embedding/android/accessibility-run.mjs b/demo/tests/native-embedding/android/accessibility-run.mjs new file mode 100644 index 0000000..0331d42 --- /dev/null +++ b/demo/tests/native-embedding/android/accessibility-run.mjs @@ -0,0 +1,259 @@ +import { execFileSync } from "node:child_process"; +import { mkdir, writeFile } from "node:fs/promises"; +import { setTimeout as delay } from "node:timers/promises"; +import path from "node:path"; + +// Run after assembling/installing the Debug comparator app. This deliberately +// reconnects UiAutomation for every dump; DOM reads are a separate diagnostic. +const [device, mode = "sdk", count = "10"] = process.argv.slice(2); +if ( + !device?.startsWith("emulator-") || + !["sdk", "default", "profile"].includes(mode) +) + throw new Error( + "Usage: node accessibility-run.mjs emulator-ID sdk|default|profile [rounds]", + ); +const rounds = Number(count); +if (!Number.isInteger(rounds) || rounds < 1 || rounds > 100) + throw new Error("Invalid rounds"); +const connection = process.argv.includes("--persistent") + ? "persistent" + : "stateless"; +const output = path.resolve( + process.env.WORKERS_A11Y_OUTPUT || + `artifacts/android-accessibility/${connection}-${mode}`, +); +await mkdir(output, { recursive: true }); +const adb = (...args) => + execFileSync("adb", ["-s", device, ...args], { + encoding: "utf8", + timeout: 30000, + }); +if (connection === "persistent") { + const log = execFileSync( + "adb", + [ + "-s", + device, + "shell", + "am", + "instrument", + "-w", + "-e", + "mode", + mode, + "-e", + "rounds", + String(rounds), + "probe.workersembed.test/probe.AccessibilityProbe", + ], + { encoding: "utf8", timeout: 180000 }, + ); + await writeFile(path.join(output, "run.log"), log); + await writeFile( + path.join(output, "webview.txt"), + adb("shell", "dumpsys", "webviewupdate"), + ); + console.log(log); + const passed = log + .split("\n") + .filter((line) => line.startsWith("{")) + .map((line) => JSON.parse(line)) + .some( + (result) => + result.mode === mode && + result.status === "passed" && + result.rounds === rounds, + ); + if (!passed) { + for (const name of [ + "accessibility-failure.json", + "accessibility-failure-dom.json", + "accessibility-failure.png", + ]) { + const data = execFileSync("adb", [ + "-s", + device, + "exec-out", + "run-as", + "probe.workersembed", + "cat", + `files/${name}`, + ]); + if (name.endsWith(".json")) JSON.parse(data.toString()); + else if ( + !data + .subarray(0, 8) + .equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) + ) + throw new Error("Invalid screenshot evidence"); + await writeFile(path.join(output, name), data); + } + } + process.exit(passed ? 0 : 1); +} +async function dump(label) { + const file = `/sdcard/workers-a11y-${label}.xml`; + adb("shell", "rm", "-f", file); + const log = adb("shell", "uiautomator", "dump", file); + if (!log.includes("dumped to:")) + throw new Error(`UIAutomator failed: ${log}`); + const xml = adb("exec-out", "cat", file); + await writeFile(path.join(output, `${label}.xml`), xml); + return xml; +} +adb("shell", "am", "force-stop", "probe.workersembed"); +adb("logcat", "-c"); +adb( + "shell", + "am", + "start", + "-W", + "-n", + `probe.workersembed/probe.${mode === "sdk" ? "Launcher" : "WebViewControl"}`, + "--ez", + "profile", + String(mode === "profile"), +); +if (mode === "sdk") { + let button; + for (let i = 0; i < 10 && !button; i++) { + const xml = await dump(`host-${i}`); + button = [...xml.matchAll(/]*>/g)].find(([node]) => + node.includes('text="Open Worker"'), + )?.[0]; + } + const bounds = button?.match(/bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/); + if (!bounds) throw new Error("No native Open Worker button"); + adb( + "shell", + "input", + "tap", + String((+bounds[1] + +bounds[3]) / 2), + String((+bounds[2] + +bounds[4]) / 2), + ); +} +const pid = adb("shell", "pidof", "probe.workersembed").trim(); +const port = adb( + "forward", + "tcp:0", + `localabstract:webview_devtools_remote_${pid}`, +).trim(); +async function evaluate(expression) { + const targets = await ( + await fetch(`http://127.0.0.1:${port}/json`, { + signal: AbortSignal.timeout(3000), + }) + ).json(); + const target = targets.find( + (t) => + t.type === "page" && + (t.url.startsWith("https://control.invalid/") || + t.url.startsWith("https://workers-sdk.local/")) && + !t.url.includes("__workers_capacitor_host"), + ); + if (!target) return null; + return new Promise((resolve, reject) => { + const socket = new WebSocket(target.webSocketDebuggerUrl); + const timer = setTimeout(() => { + socket.close(); + reject(new Error("CDP timeout")); + }, 5000); + socket.onopen = () => + socket.send( + JSON.stringify({ + id: 1, + method: "Runtime.evaluate", + params: { expression, returnByValue: true }, + }), + ); + socket.onmessage = ({ data }) => { + const message = JSON.parse(data); + if (message.id === 1) { + clearTimeout(timer); + socket.close(); + if (message.error || message.result?.exceptionDetails) + reject(new Error(JSON.stringify(message))); + else resolve(message.result?.result?.value); + } + }; + socket.onerror = () => { + clearTimeout(timer); + socket.close(); + reject(new Error("CDP connection failed")); + }; + }); +} +async function awaitHeading(expected) { + for (let i = 0; i < 100; i++) { + let actual; + try { + actual = await evaluate("document.querySelector('h1')?.textContent"); + } catch { + await delay(100); + continue; + } + if (actual === expected) return; + await delay(100); + } + throw new Error(`DOM heading missing: ${expected}`); +} +const results = []; +try { + await awaitHeading("Worker Home"); + for (let round = 0; round < rounds; round++) { + for (const [action, expected] of [ + ["document.querySelector('a').click()", "Worker Next"], + ["document.querySelector('form').requestSubmit()", "Posted verified"], + ]) { + await evaluate(action); + await awaitHeading(expected); + const label = `${round}-${expected.split(" ")[0]}`; + const xml = await dump(label); + const visible = xml.includes(`text="${expected}"`); + const result = { + round, + expected, + domPresent: true, + accessibilityPresent: visible, + retries: [], + }; + if (!visible) { + await writeFile( + path.join(output, `${label}.png`), + execFileSync("adb", ["-s", device, "exec-out", "screencap", "-p"]), + ); + for (let retry = 0; retry < 3; retry++) { + const recovered = (await dump(`${label}-retry-${retry}`)).includes( + `text="${expected}"`, + ); + result.retries.push(recovered); + if (recovered) break; + } + } + results.push(result); + console.log(JSON.stringify(result)); + await writeFile( + path.join(output, "results.json"), + JSON.stringify(results, null, 2), + ); + } + } +} finally { + adb("forward", "--remove", `tcp:${port}`); + await writeFile(path.join(output, "runtime.log"), adb("logcat", "-d")); + await writeFile( + path.join(output, "webview.txt"), + adb("shell", "dumpsys", "webviewupdate"), + ); +} +console.log( + JSON.stringify({ + mode, + observations: results.length, + missing: results.filter((r) => !r.accessibilityPresent).length, + }), +); + +if (results.some((result) => !result.accessibilityPresent)) + process.exitCode = 1; diff --git a/demo/tests/native-embedding/android/app/build.gradle b/demo/tests/native-embedding/android/app/build.gradle index 94c59ac..9c1dccd 100644 --- a/demo/tests/native-embedding/android/app/build.gradle +++ b/demo/tests/native-embedding/android/app/build.gradle @@ -2,12 +2,14 @@ apply plugin: 'com.android.application' android { namespace 'probe' compileSdk 36 - defaultConfig { applicationId 'probe.workersembed'; minSdk 24; targetSdk 36 } + buildTypes { release { signingConfig signingConfigs.debug } } + defaultConfig { applicationId 'probe.workersembed'; minSdk 24; targetSdk 36; testInstrumentationRunner 'probe.AccessibilityProbe' } compileOptions { sourceCompatibility JavaVersion.VERSION_21; targetCompatibility JavaVersion.VERSION_21 } packaging.resources { excludes += 'META-INF/INDEX.LIST'; merges += 'META-INF/io.netty.versions.properties' } } dependencies { - def sdkVersion = new groovy.json.JsonSlurper().parse(rootProject.file('../../../../package.json')).version + def sdkVersion = providers.gradleProperty('sdkVersion').getOrElse(new groovy.json.JsonSlurper().parse(rootProject.file('../../../../package.json')).version) implementation "io.github.rdlabo:cf-workers-native-android:${sdkVersion}" + debugImplementation 'androidx.webkit:webkit:1.14.0' implementation 'androidx.activity:activity:1.10.1' } diff --git a/demo/tests/native-embedding/android/app/src/androidTest/java/probe/AccessibilityProbe.java b/demo/tests/native-embedding/android/app/src/androidTest/java/probe/AccessibilityProbe.java new file mode 100644 index 0000000..2d89cb1 --- /dev/null +++ b/demo/tests/native-embedding/android/app/src/androidTest/java/probe/AccessibilityProbe.java @@ -0,0 +1,124 @@ +package probe; + +import android.app.*; +import android.content.Intent; +import android.os.*; +import android.view.*; +import android.view.accessibility.AccessibilityNodeInfo; +import org.json.*; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.*; + +/** One UiAutomation connection, native taps, and strict accessibility assertions. */ +public final class AccessibilityProbe extends Instrumentation { + private Bundle arguments; + private ActivityMonitor monitor; + private Activity launched; + @Override public void onCreate(Bundle args) { super.onCreate(args); arguments = args; start(); } + private void report(String message) { Bundle b = new Bundle(); b.putString("stream", message + "\n"); sendStatus(0, b); } + private AccessibilityNodeInfo find(AccessibilityNodeInfo node, String text) { + if (node == null) return null; + if (text.contentEquals(node.getText() == null ? "" : node.getText())) return node; + for (int i = 0; i < node.getChildCount(); i++) { + AccessibilityNodeInfo found = find(node.getChild(i), text); + if (found != null) return found; + } + return null; + } + private AccessibilityNodeInfo awaitText(UiAutomation ui, String text) { + long deadline = SystemClock.elapsedRealtime() + 10000; + do { + if ("clear".equals(arguments.getString("cache"))) ui.clearCache(); + AccessibilityNodeInfo node = find(ui.getRootInActiveWindow(), text); + if (node != null && node.isVisibleToUser()) return node; + SystemClock.sleep(100); + } while (SystemClock.elapsedRealtime() < deadline); + throw new AssertionError("Accessibility text missing: " + text); + } + private void click(UiAutomation ui, String text) { + AccessibilityNodeInfo node = awaitText(ui, text); + while (node != null && !node.isClickable()) node = node.getParent(); + if (node == null) throw new AssertionError("No clickable node: " + text); + if ("action".equals(arguments.getString("input"))) { + if (!node.performAction(AccessibilityNodeInfo.ACTION_CLICK)) throw new AssertionError("Click failed: " + text); + } else { + android.graphics.Rect bounds = new android.graphics.Rect(); node.getBoundsInScreen(bounds); + long time = SystemClock.uptimeMillis(); + for (int action : new int[] {MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP}) { + MotionEvent event = MotionEvent.obtain(time, SystemClock.uptimeMillis(), action, bounds.centerX(), bounds.centerY(), 0); + event.setSource(InputDevice.SOURCE_TOUCHSCREEN); + boolean injected = ui.injectInputEvent(event, true); event.recycle(); + if (!injected) throw new AssertionError("Input injection failed: " + text); + } + } + } + private JSONObject tree(AccessibilityNodeInfo node) throws JSONException { + if (node == null) return new JSONObject(); + JSONArray children = new JSONArray(); + for (int i = 0; i < node.getChildCount(); i++) children.put(tree(node.getChild(i))); + return new JSONObject().put("text", String.valueOf(node.getText())).put("class", String.valueOf(node.getClassName())).put("visible", node.isVisibleToUser()).put("children", children); + } + private android.webkit.WebView webView(View view) { + if (view instanceof android.webkit.WebView) return (android.webkit.WebView)view; + if (view instanceof ViewGroup) { + ViewGroup group = (ViewGroup)view; + for (int i = 0; i < group.getChildCount(); i++) { + android.webkit.WebView found = webView(group.getChildAt(i)); + if (found != null) return found; + } + } + return null; + } + private void save(String name, String value) throws IOException { + try (FileOutputStream stream = new FileOutputStream(new File(getTargetContext().getFilesDir(), name))) { stream.write(value.getBytes(StandardCharsets.UTF_8)); } + } + private void captureFailure() throws Exception { + save("accessibility-failure.json", tree(getUiAutomation().getRootInActiveWindow()).toString(2)); + android.graphics.Bitmap bitmap = getUiAutomation().takeScreenshot(); + if (bitmap != null) try (FileOutputStream file = new FileOutputStream(new File(getTargetContext().getFilesDir(), "accessibility-failure.png"))) { bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, file); } + CountDownLatch captured = new CountDownLatch(1); + runOnMainSync(() -> { + Activity activity = monitor.getLastActivity(); + if (activity == null) activity = launched; + android.webkit.WebView view = webView(activity.getWindow().getDecorView()); + if (view == null) { report("No diagnostic WebView"); captured.countDown(); return; } + view.evaluateJavascript("JSON.stringify({url:location.href,heading:document.querySelector('h1')?.textContent,body:document.body.innerText})", value -> { + try { save("accessibility-failure-dom.json", value); } catch (Exception error) { report("DOM capture failed: " + error); } + captured.countDown(); + }); + }); + if (!captured.await(5, TimeUnit.SECONDS)) throw new AssertionError("DOM capture timed out"); + } + @Override public void onStart() { + int passed = 0; + Bundle output = new Bundle(); + try { + for (String name : new String[] {"accessibility-failure.json", "accessibility-failure-dom.json", "accessibility-failure.png"}) { + File previous = new File(getTargetContext().getFilesDir(), name); + if (previous.exists() && !previous.delete()) throw new IOException("Cannot clear prior evidence: " + name); + } + String mode = arguments.getString("mode", "sdk"); + int rounds = Integer.parseInt(arguments.getString("rounds", "20")); + UiAutomation ui = getUiAutomation(); + monitor = addMonitor(mode.equals("sdk") ? "jp.rdlabo.workerscapacitor.WorkersNativeActivity" : "probe.WebViewControl", null, false); + Intent intent = new Intent().setClassName("probe.workersembed", mode.equals("sdk") ? "probe.Launcher" : "probe.WebViewControl").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + intent.putExtra("profile", mode.equals("profile")); + launched = startActivitySync(intent); + if (mode.equals("sdk")) click(ui, "Open Worker"); + awaitText(ui, "Worker Home"); + for (int i = 0; i < rounds; i++) { + click(ui, "Next"); awaitText(ui, "Worker Next"); + click(ui, "Submit"); awaitText(ui, "Posted verified"); + passed++; + report(new JSONObject().put("round", i).put("status", "passed").toString()); + } + output.putString("stream", new JSONObject().put("mode", mode).put("status", "passed").put("rounds", passed).toString()+"\n"); + finish(Activity.RESULT_OK, output); + } catch (Throwable failure) { + try { captureFailure(); } catch (Throwable error) { report("Evidence capture failed: " + error); } + output.putString("stream", "FAILED after " + passed + " rounds: " + failure + "\n"); + finish(Activity.RESULT_CANCELED, output); + } + } +} diff --git a/demo/tests/native-embedding/android/app/src/debug/AndroidManifest.xml b/demo/tests/native-embedding/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..dc62627 --- /dev/null +++ b/demo/tests/native-embedding/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/demo/tests/native-embedding/android/app/src/debug/java/probe/WebViewControl.java b/demo/tests/native-embedding/android/app/src/debug/java/probe/WebViewControl.java new file mode 100644 index 0000000..e115c8f --- /dev/null +++ b/demo/tests/native-embedding/android/app/src/debug/java/probe/WebViewControl.java @@ -0,0 +1,36 @@ +package probe; + +import android.app.Activity; +import android.os.Bundle; +import android.webkit.*; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; + +/** Debug-only comparator: no Workers runtime, bridge, or Service Worker. */ +public final class WebViewControl extends Activity { + private WebView view; + @Override public void onCreate(Bundle state) { + super.onCreate(state); + WebView.setWebContentsDebuggingEnabled(true); + view = new WebView(this); + if (getIntent().getBooleanExtra("profile", false)) + androidx.webkit.WebViewCompat.setProfile(view, "accessibility-control"); + view.getSettings().setJavaScriptEnabled(true); + view.setWebViewClient(new WebViewClient() { + @Override public WebResourceResponse shouldInterceptRequest(WebView web, WebResourceRequest request) { + String route = request.getUrl().getPath(); + String title = route.equals("/next") ? "Worker Next" : route.equals("/submit") ? "Posted verified" : "Worker Home"; + String html = "

" + title + "

NextHome
"; + return new WebResourceResponse("text/html", "UTF-8", new ByteArrayInputStream(html.getBytes(StandardCharsets.UTF_8))); + } + }); + setContentView(view); + view.loadUrl("https://control.invalid/"); + } + @Override public void onDestroy() { + if (view.getParent() instanceof android.view.ViewGroup) + ((android.view.ViewGroup)view.getParent()).removeView(view); + view.destroy(); + super.onDestroy(); + } +} diff --git a/demo/tests/native-embedding/android/app/src/main/java/probe/Launcher.java b/demo/tests/native-embedding/android/app/src/main/java/probe/Launcher.java index 7f25157..80e7854 100644 --- a/demo/tests/native-embedding/android/app/src/main/java/probe/Launcher.java +++ b/demo/tests/native-embedding/android/app/src/main/java/probe/Launcher.java @@ -21,6 +21,7 @@ public final class Launcher extends androidx.activity.ComponentActivity { private boolean recreateNextWorker; private final android.app.Application.ActivityLifecycleCallbacks recreationProbe = new android.app.Application.ActivityLifecycleCallbacks() { @Override public void onActivityResumed(android.app.Activity activity) { + if (activity.getClass().getName().endsWith("WorkersNativeActivity")) Log.i("HostIsolation", "SDK Activity resumed " + System.identityHashCode(activity)); if (recreateNextWorker && activity.getClass().getName().endsWith("WorkersNativeActivity")) { recreateNextWorker = false; new android.os.Handler(getMainLooper()).postDelayed(() -> { @@ -34,7 +35,11 @@ public final class Launcher extends androidx.activity.ComponentActivity { } @Override public void onActivityStarted(android.app.Activity a) {} @Override public void onActivityPaused(android.app.Activity a) {} - @Override public void onActivityStopped(android.app.Activity a) {} + @Override public void onActivityStopped(android.app.Activity a) { + if (a.getClass().getName().endsWith("WorkersNativeActivity")) { + Log.i("HostIsolation", "SDK Activity stopped " + System.identityHashCode(a)); + } + } @Override public void onActivitySaveInstanceState(android.app.Activity a, Bundle b) {} @Override public void onActivityDestroyed(android.app.Activity a) { if (a.getClass().getName().endsWith("WorkersNativeActivity")) Log.i("HostIsolation", "SDK Activity destroyed " + System.identityHashCode(a)); diff --git a/demo/tests/native-embedding/android/run.mjs b/demo/tests/native-embedding/android/run.mjs index c022748..1686460 100644 --- a/demo/tests/native-embedding/android/run.mjs +++ b/demo/tests/native-embedding/android/run.mjs @@ -6,6 +6,26 @@ import path from "node:path"; const here = fileURLToPath(new URL("./", import.meta.url)); const device = process.argv[2]; +const lifecycle = process.argv.includes("--lifecycle"); +if (lifecycle) { + const response = await fetch("http://127.0.0.1:18764/ok", { + signal: AbortSignal.timeout(3000), + }); + if (!response.ok || (await response.text()) !== "network-ok") + throw new Error( + "Start the native-embedding/network-server.mjs fixture first", + ); +} +const configuration = process.env.WORKERS_BUILD_CONFIGURATION || "debug"; +const measureOnly = process.argv.includes("--measure-only"); +if (!["debug", "release"].includes(configuration)) + throw new Error("Invalid build configuration"); +if (configuration === "release" && !measureOnly) + throw new Error( + "Release supports --measure-only; full acceptance requires Debug WebView inspection", + ); +if (measureOnly && lifecycle) + throw new Error("Run lifecycle acceptance separately from --measure-only"); const centralOnly = process.argv.includes("--central"); if (!device) throw new Error("Usage: node run.mjs "); if (!device.startsWith("emulator-")) @@ -25,6 +45,10 @@ const run = (command, args, options = {}) => }); const adb = (...args) => run("adb", ["-s", device, ...args], { timeout: 60_000 }); +await writeFile( + path.join(artifacts, "webview.txt"), + adb("shell", "dumpsys", "webviewupdate"), +); for (const scenario of ["normal", "startup-failure", "broken-entry"]) run(process.execPath, ["../build-web.mjs", "android", scenario]); if (!centralOnly) @@ -40,10 +64,17 @@ run(gradle, [ "-p", here, ...(centralOnly ? ["-PcentralOnly", "--refresh-dependencies"] : []), - ":app:assembleDebug", + ...(process.env.WORKERS_ANDROID_VERSION + ? [`-PsdkVersion=${process.env.WORKERS_ANDROID_VERSION}`] + : []), + `:app:assemble${configuration === "release" ? "Release" : "Debug"}`, "--console=plain", ]); -adb("install", "-r", "app/build/outputs/apk/debug/app-debug.apk"); +adb( + "install", + "-r", + `app/build/outputs/apk/${configuration}/app-${configuration}.apk`, +); adb("shell", "am", "force-stop", "probe.workersembed"); adb("logcat", "-c"); adb("shell", "am", "start", "-n", "probe.workersembed/probe.Launcher"); @@ -70,19 +101,120 @@ function labelledNode(xml, text) { ), )?.[0]; } +// Some emulator WebViews stop exposing descendants to UIAutomator after navigation. +// Use the rendered DOM only for fixture web elements; native host checks stay UI-based. +async function renderedWebNode(text, xml) { + if ( + !/^(Worker Home|Worker Next|Posted verified|Network recovered|Network interrupted|Next|Submit|Check network|Interrupt network)$/.test( + text, + ) + ) + return null; + const webView = [...xml.matchAll(/]*>/g)].find( + ([tag]) => + tag.includes('class="android.webkit.WebView"') && + tag.includes('package="probe.workersembed"'), + )?.[0]; + const bounds = webView?.match(/bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/); + if (!bounds) return null; + const pid = adb("shell", "pidof", "probe.workersembed").trim(); + const port = adb( + "forward", + "tcp:0", + `localabstract:webview_devtools_remote_${pid}`, + ).trim(); + try { + const targets = await ( + await fetch(`http://127.0.0.1:${port}/json`, { + signal: AbortSignal.timeout(3000), + }) + ).json(); + const page = targets.find( + (target) => + target.type === "page" && + target.url.startsWith("https://workers-sdk.local/") && + !target.url.includes("__workers_capacitor_host"), + ); + if (!page) return null; + const result = await new Promise((resolve, reject) => { + const socket = new WebSocket(page.webSocketDebuggerUrl); + const timer = setTimeout(() => { + socket.close(); + reject(new Error("DOM probe timeout")); + }, 5000); + socket.addEventListener("open", () => + socket.send( + JSON.stringify({ + id: 1, + method: "Runtime.evaluate", + params: { + expression: `(() => { const element = [...document.querySelectorAll('h1,a,button')].find(el => el.textContent.trim() === ${JSON.stringify(text)}); if (!element) return null; const r = element.getBoundingClientRect(); const s = getComputedStyle(element); if (!r.width || !r.height || r.bottom <= 0 || r.top >= innerHeight || s.visibility !== 'visible' || s.display === 'none') return null; return { x: r.x+r.width/2, y: r.y+r.height/2, width: innerWidth, height: innerHeight, url: location.href }; })()`, + returnByValue: true, + }, + }), + ), + ); + socket.addEventListener("message", (event) => { + const message = JSON.parse(event.data); + if (message.id === 1) { + clearTimeout(timer); + socket.close(); + if (message.error || message.result?.exceptionDetails) + reject(new Error(JSON.stringify(message))); + else resolve(message.result?.result?.value); + } + }); + socket.addEventListener( + "error", + () => { + clearTimeout(timer); + socket.close(); + reject(new Error("DOM probe connection failed")); + }, + { once: true }, + ); + }); + if (!result) return null; + console.log(`DOM visibility verified: ${text} at ${result.url}`); + await writeFile( + path.join(artifacts, "dom-visibility.png"), + run("adb", ["-s", device, "exec-out", "screencap", "-p"], { + encoding: null, + }), + ); + return { + x: +bounds[1] + (result.x * (+bounds[3] - +bounds[1])) / result.width, + y: +bounds[2] + (result.y * (+bounds[4] - +bounds[2])) / result.height, + }; + } finally { + adb("forward", "--remove", `tcp:${port}`); + } +} async function waitFor(text, timeout = 30_000) { const deadline = Date.now() + timeout; let xml = ""; while (Date.now() < deadline) { xml = await screen(); - if (labelledNode(xml, text)) return xml; + if (labelledNode(xml, text)) return { xml }; + const dom = await renderedWebNode(text, xml); + if (dom) return { xml, dom }; await delay(300); } await writeFile(path.join(artifacts, "failure.xml"), xml); throw new Error(`Screen did not contain ${text}`); } async function tap(text) { - const xml = await waitFor(text); + const { xml, dom } = await waitFor(text); + if (dom) { + adb( + "shell", + "input", + "tap", + String(Math.round(dom.x)), + String(Math.round(dom.y)), + ); + return; + } const node = labelledNode(xml, text); const bounds = node?.match(/bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/); if (!bounds) throw new Error(`No bounds for ${text}`); @@ -145,6 +277,7 @@ async function crashRenderer() { adb("forward", "--remove", `tcp:${port}`); } } +let reverseInstalled = false; const autoRotate = adb( "shell", "settings", @@ -159,141 +292,211 @@ const rotation = adb( "system", "user_rotation", ).trim(); -try { - await waitFor("Native host visits 0"); - await waitFor("Host service worker host-sw-retained"); - await tap("Open Worker"); - await waitFor("Worker Home"); - // Healthy ready must cancel the native 30-second startup deadline. - await delay(35_000); - await waitFor("Worker Home"); - if ( - !adb("logcat", "-d", "-s", "HostIsolation:I", "*:S").includes( - "Host SW result during SDK open: host-sw-retained", - ) - ) - throw new Error("Host service-worker routing failed while SDK was open"); - await tap("Next"); - await waitFor("Worker Next"); - await tap("Submit"); - await waitFor("Posted verified"); - adb("shell", "settings", "put", "system", "accelerometer_rotation", "0"); - adb("shell", "settings", "put", "system", "user_rotation", "1"); - await delay(1500); - await waitFor("Posted verified"); - adb("shell", "input", "keyevent", "4"); - await waitFor("Native host visits 1"); - await tap("Verify host isolation"); - const isolationScreen = await screen(); - await writeFile(path.join(artifacts, "host-isolation.xml"), isolationScreen); - await writeFile( - path.join(artifacts, "host-isolation.log"), - adb("logcat", "-d", "-s", "HostIsolation:I", "*:S"), - ); - if ( - !isolationScreen.includes( - 'text="Host isolation session=true persistent=true handler=true"', +async function acceptance() { + try { + await waitFor("Native host visits 0"); + await waitFor("Host service worker host-sw-retained"); + const startedAt = Date.now(); + await tap("Open Worker"); + await waitFor("Worker Home"); + await writeFile( + path.join(artifacts, "startup.json"), + JSON.stringify({ + configuration, + tapAndAccessibilityReadyMs: Date.now() - startedAt, + }), + ); + await writeFile( + path.join(artifacts, "memory-ready.txt"), + adb("shell", "dumpsys", "meminfo", "probe.workersembed"), + ); + if (measureOnly) { + await writeFile( + path.join(artifacts, "measurement.png"), + run("adb", ["-s", device, "exec-out", "screencap", "-p"], { + encoding: null, + }), + ); + adb("shell", "input", "keyevent", "4"); + await waitFor("Native host visits 1"); + console.log( + `PASS ${configuration} measurement: Worker Home displayed and closed`, + ); + return; + } + if (lifecycle) { + adb("reverse", "tcp:18764", "tcp:18764"); + reverseInstalled = true; + adb("shell", "input", "keyevent", "KEYCODE_HOME"); + await delay(2000); + adb("shell", "input", "keyevent", "KEYCODE_APP_SWITCH"); + await tap("probe.workersembed probe.Launcher"); + await waitFor("Worker Home"); + const lifecycleEvidence = adb( + "logcat", + "-d", + "-s", + "HostIsolation:I", + "*:S", + ); + const stopped = lifecycleEvidence.match( + /SDK Activity stopped (\d+)/, + )?.[1]; + if ( + !stopped || + !lifecycleEvidence + .slice(lifecycleEvidence.indexOf(`SDK Activity stopped ${stopped}`)) + .includes(`SDK Activity resumed ${stopped}`) + ) + throw new Error("Same SDK Activity did not stop and resume"); + await tap("Check network"); + await waitFor("Network recovered"); + await tap("Interrupt network"); + await waitFor("Network interrupted"); + await tap("Check network"); + await waitFor("Network recovered"); + console.log( + "PASS background resume and connection interruption/recovery", + ); + await tap("Next"); + await waitFor("Worker Next"); + } + // Healthy ready must cancel the native 30-second startup deadline. + await delay(35_000); + await waitFor(lifecycle ? "Worker Next" : "Worker Home"); + if ( + !adb("logcat", "-d", "-s", "HostIsolation:I", "*:S").includes( + "Host SW result during SDK open: host-sw-retained", + ) ) - ) - throw new Error( - "SDK open/close changed host cookies or process CookieHandler; see host-isolation.log", + throw new Error("Host service-worker routing failed while SDK was open"); + await tap("Next"); + await waitFor("Worker Next"); + await tap("Submit"); + await waitFor("Posted verified"); + adb("shell", "settings", "put", "system", "accelerometer_rotation", "0"); + adb("shell", "settings", "put", "system", "user_rotation", "1"); + await delay(1500); + await waitFor("Posted verified"); + adb("shell", "input", "keyevent", "4"); + await waitFor("Native host visits 1"); + await tap("Verify host isolation"); + const isolationScreen = await screen(); + await writeFile( + path.join(artifacts, "host-isolation.xml"), + isolationScreen, ); - await tap("Verify host service worker"); - await waitFor("Host service worker host-sw-retained"); - await tap("Open Worker"); - await waitFor("Worker Home"); - adb("shell", "input", "keyevent", "4"); - await waitFor("Native host visits 2"); - await tap("Open failed Worker"); - // Failure must return without user Back and release the singleton for retry. - await waitFor("Native host visits 3"); - let log = ""; - const failureDeadline = Date.now() + 30_000; - while (Date.now() < failureDeadline) { - log = adb("logcat", "-d", "-s", "WorkersNative:E"); - if (log.includes("Native embedding startup failure probe")) break; - await delay(300); - } - if (!log.includes("Native embedding startup failure probe")) - throw new Error(`Expected SDK startup failure notification:\n${log}`); - await tap("Open Worker"); - await waitFor("Worker Home"); - await crashRenderer(); - await waitFor("Native host visits 4"); - await tap("Open Worker"); - await waitFor("Worker Home"); - adb("shell", "input", "keyevent", "4"); - await waitFor("Native host visits 5"); - const lifecycleLog = () => - adb("logcat", "-d", "-s", "HostIsolation:I", "*:S"); - const createdBefore = (lifecycleLog().match(/SDK Activity created /g) ?? []) - .length; - await tap("Recreate Worker"); - const recreateDeadline = Date.now() + 30_000; - while ( - !adb("logcat", "-d", "-s", "HostIsolation:I", "*:S").includes( - "Recreating SDK Activity", + await writeFile( + path.join(artifacts, "host-isolation.log"), + adb("logcat", "-d", "-s", "HostIsolation:I", "*:S"), + ); + if ( + !isolationScreen.includes( + 'text="Host isolation session=true persistent=true handler=true"', + ) ) - ) { - if (Date.now() > recreateDeadline) - throw new Error("SDK Activity recreation not triggered"); - await delay(300); - } - while ( - (lifecycleLog().match(/SDK Activity created /g) ?? []).length < - createdBefore + 2 - ) { - if (Date.now() > recreateDeadline) - throw new Error("SDK Activity was not recreated"); - await delay(300); - } - await waitFor("Worker Home"); - await tap("Next"); - await waitFor("Worker Next"); - await tap("Submit"); - await waitFor("Posted verified"); - adb("shell", "input", "keyevent", "4"); - await waitFor("Native host visits 6"); - await tap("Open broken Worker"); - await waitFor("Native host visits 7", 60_000); - if ( - !adb("logcat", "-d", "-s", "WorkersNative:E").includes( - "Runtime startup timed out before ready", + throw new Error( + "SDK open/close changed host cookies or process CookieHandler; see host-isolation.log", + ); + await tap("Verify host service worker"); + await waitFor("Host service worker host-sw-retained"); + await tap("Open Worker"); + await waitFor("Worker Home"); + adb("shell", "input", "keyevent", "4"); + await waitFor("Native host visits 2"); + await tap("Open failed Worker"); + // Failure must return without user Back and release the singleton for retry. + await waitFor("Native host visits 3"); + let log = ""; + const failureDeadline = Date.now() + 30_000; + while (Date.now() < failureDeadline) { + log = adb("logcat", "-d", "-s", "WorkersNative:E"); + if (log.includes("Native embedding startup failure probe")) break; + await delay(300); + } + if (!log.includes("Native embedding startup failure probe")) + throw new Error(`Expected SDK startup failure notification:\n${log}`); + await tap("Open Worker"); + await waitFor("Worker Home"); + await crashRenderer(); + await waitFor("Native host visits 4"); + await tap("Open Worker"); + await waitFor("Worker Home"); + adb("shell", "input", "keyevent", "4"); + await waitFor("Native host visits 5"); + const lifecycleLog = () => + adb("logcat", "-d", "-s", "HostIsolation:I", "*:S"); + const createdBefore = (lifecycleLog().match(/SDK Activity created /g) ?? []) + .length; + await tap("Recreate Worker"); + const recreateDeadline = Date.now() + 30_000; + while ( + !adb("logcat", "-d", "-s", "HostIsolation:I", "*:S").includes( + "Recreating SDK Activity", + ) + ) { + if (Date.now() > recreateDeadline) + throw new Error("SDK Activity recreation not triggered"); + await delay(300); + } + while ( + (lifecycleLog().match(/SDK Activity created /g) ?? []).length < + createdBefore + 2 + ) { + if (Date.now() > recreateDeadline) + throw new Error("SDK Activity was not recreated"); + await delay(300); + } + await waitFor("Worker Home"); + await tap("Next"); + await waitFor("Worker Next"); + await tap("Submit"); + await waitFor("Posted verified"); + adb("shell", "input", "keyevent", "4"); + await waitFor("Native host visits 6"); + await tap("Open broken Worker"); + await waitFor("Native host visits 7", 60_000); + if ( + !adb("logcat", "-d", "-s", "WorkersNative:E").includes( + "Runtime startup timed out before ready", + ) ) - ) - throw new Error( - "Missing JavaScript entry did not report the native startup deadline", + throw new Error( + "Missing JavaScript entry did not report the native startup deadline", + ); + // A deadline failure must also release the runtime so a normal open works. + await tap("Open Worker"); + await waitFor("Worker Home"); + adb("shell", "input", "keyevent", "4"); + await waitFor("Native host visits 8"); + await tap("Verify host isolation"); + await waitFor("Host isolation session=true persistent=true handler=true"); + await tap("Verify host service worker"); + await waitFor("Host service worker host-sw-retained"); + await writeFile(path.join(artifacts, "passed.xml"), await screen()); + await writeFile( + path.join(artifacts, "passed.png"), + run("adb", ["-s", device, "exec-out", "screencap", "-p"], { + encoding: null, + }), + ); + console.log( + "PASS Maven consumer: navigation, POST, rotation, close/reopen, startup failure, renderer loss, Activity recreation, broken entry, cookies, CookieHandler and host service-worker routing", + ); + } finally { + if (reverseInstalled) adb("reverse", "--remove", "tcp:18764"); + for (const [name, value] of [ + ["accelerometer_rotation", autoRotate], + ["user_rotation", rotation], + ]) { + if (value === "null") adb("shell", "settings", "delete", "system", name); + else adb("shell", "settings", "put", "system", name, value); + } + await writeFile(path.join(artifacts, "runtime.log"), adb("logcat", "-d")); + await writeFile( + path.join(artifacts, "host-isolation.log"), + adb("logcat", "-d", "-s", "HostIsolation:I", "*:S"), ); - // A deadline failure must also release the runtime so a normal open works. - await tap("Open Worker"); - await waitFor("Worker Home"); - adb("shell", "input", "keyevent", "4"); - await waitFor("Native host visits 8"); - await tap("Verify host isolation"); - await waitFor("Host isolation session=true persistent=true handler=true"); - await tap("Verify host service worker"); - await waitFor("Host service worker host-sw-retained"); - await writeFile(path.join(artifacts, "passed.xml"), await screen()); - await writeFile( - path.join(artifacts, "passed.png"), - run("adb", ["-s", device, "exec-out", "screencap", "-p"], { - encoding: null, - }), - ); - console.log( - "PASS Maven consumer: navigation, POST, rotation, close/reopen, startup failure, renderer loss, Activity recreation, broken entry, cookies, CookieHandler and host service-worker routing", - ); -} finally { - for (const [name, value] of [ - ["accelerometer_rotation", autoRotate], - ["user_rotation", rotation], - ]) { - if (value === "null") adb("shell", "settings", "delete", "system", name); - else adb("shell", "settings", "put", "system", name, value); } - await writeFile(path.join(artifacts, "runtime.log"), adb("logcat", "-d")); - await writeFile( - path.join(artifacts, "host-isolation.log"), - adb("logcat", "-d", "-s", "HostIsolation:I", "*:S"), - ); } +await acceptance(); diff --git a/demo/tests/native-embedding/ios/README.md b/demo/tests/native-embedding/ios/README.md index af42a15..adfe33e 100644 --- a/demo/tests/native-embedding/ios/README.md +++ b/demo/tests/native-embedding/ios/README.md @@ -28,3 +28,22 @@ same source revision; see the [published dependency matrix](../../../../docs/nat Use a fresh `WORKERS_IOS_PROBE_DIR` for a release verification so the build evidence is separate from local SDK runs. Run all three scenarios above with those variables. Without these variables the runner keeps using the local SDK and local `dist`. + +## Lifecycle and connection recovery + +Start `node ../network-server.mjs` in a separate terminal, then add +`--lifecycle` to the **normal** runner invocation. The runner opens Settings, +returns to the same application, and requires actual scene background/active +callbacks before testing navigation. It then checks a successful native HTTP +request, a connection closed before response headers, and a successful retry. +The server binds only loopback port 18764. Stop it after testing. This models +connection loss, not airplane mode or an offline cloud binding. + +Set `WORKERS_BUILD_CONFIGURATION=release` to compile the Swift package and probe +with optimization. Probe assertions remain enabled explicitly. `SDK_READY_MS` +measures from the start of the probe task to observed Worker Home content; it is +not OS launch time. The separate `launchElapsedMs` includes simulator launch. + +`memory-ready.txt` records the host process RSS in KiB at the first observed +Worker Home marker. It excludes WebKit subprocesses and is a single simulator +sample, not total application memory or a physical-device performance claim. diff --git a/demo/tests/native-embedding/ios/RuntimeProbe.swift.example b/demo/tests/native-embedding/ios/RuntimeProbe.swift.example index bbea244..966c78d 100644 --- a/demo/tests/native-embedding/ios/RuntimeProbe.swift.example +++ b/demo/tests/native-embedding/ios/RuntimeProbe.swift.example @@ -49,6 +49,10 @@ import WebKit @objc class AppDelegate: UIResponder, UIApplicationDelegate {} @objc(SceneDelegate) class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? + var enteredBackground = false + var resumed = false + func sceneDidEnterBackground(_ scene: UIScene) { enteredBackground = true } + func sceneDidBecomeActive(_ scene: UIScene) { if enteredBackground { resumed = true } } func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { print("SDK_SCENE_STARTED") fflush(stdout) @@ -93,8 +97,27 @@ import WebKit return } Task { @MainActor in + let startedAt = Date() let (runtime, page) = try! await waitForWorkerHome(in: child) print("SDK_DOCUMENT Worker Home") + print("SDK_READY_MS", Date().timeIntervalSince(startedAt) * 1000) + if CommandLine.arguments.contains("--lifecycle") { + print("SDK_BACKGROUND_READY"); fflush(stdout) + for _ in 0..<200 { + if self.resumed { break } + try! await Task.sleep(nanoseconds: 100_000_000) + } + assert(self.enteredBackground && self.resumed) + try! await waitForText("Worker Home", in: page) + print("SDK_BACKGROUND_RESUMED") + for (route, expected, marker) in [("/network-ok", "Network recovered", "SDK_NETWORK_ONLINE"), ("/network-drop", "Network interrupted", "SDK_NETWORK_INTERRUPTED"), ("/network-ok", "Network recovered", "SDK_NETWORK_RECOVERED")] { + _ = try! await page.evaluateJavaScript("document.querySelector('a').href='\(route)'; document.querySelector('a').click()") + try! await waitForText(expected, in: page) + print(marker) + } + _ = try! await page.evaluateJavaScript("document.querySelector('a').href='/'; document.querySelector('a').click()") + try! await waitForText("Worker Home", in: page) + } let hook = try! await runtime.webView!.evaluateJavaScript("typeof globalThis.__workersNativeDispose") assert(hook as? String == "function") print("SDK_HOOK function") diff --git a/demo/tests/native-embedding/ios/run.mjs b/demo/tests/native-embedding/ios/run.mjs index 02bd7cc..7858f6f 100644 --- a/demo/tests/native-embedding/ios/run.mjs +++ b/demo/tests/native-embedding/ios/run.mjs @@ -17,6 +17,21 @@ const scratch = process.env.WORKERS_IOS_PROBE_DIR || "/private/tmp/workers-ios-acceptance"; const device = process.argv[2]; const scenario = process.argv[3] || "normal"; +const lifecycle = process.argv.includes("--lifecycle"); +if (lifecycle && scenario !== "normal") + throw new Error("--lifecycle requires normal scenario"); +if (lifecycle) { + const response = await fetch("http://127.0.0.1:18764/ok", { + signal: AbortSignal.timeout(3000), + }); + if (!response.ok || (await response.text()) !== "network-ok") + throw new Error( + "Start the native-embedding/network-server.mjs fixture first", + ); +} +const configuration = process.env.WORKERS_BUILD_CONFIGURATION || "debug"; +if (!["debug", "release"].includes(configuration)) + throw new Error("Invalid build configuration"); const finalMarker = scenario === "broken-entry" ? "SDK_BROKEN_ENTRY_CLEANED 0" @@ -41,6 +56,8 @@ const sdk = run("xcrun", ["--sdk", "iphonesimulator", "--show-sdk-path"]); const arch = process.arch === "arm64" ? "arm64" : "x86_64"; const buildArgs = [ "build", + "-c", + configuration, ...(process.env.WORKERS_IOS_BUILD_SYSTEM ? ["--build-system", process.env.WORKERS_IOS_BUILD_SYSTEM] : []), @@ -118,6 +135,7 @@ await writeFile( ); run("xcrun", [ "swiftc", + ...(configuration === "release" ? ["-O", "-assert-config", "Debug"] : []), "-target", `${arch}-apple-ios15.0-simulator`, "-sdk", @@ -153,13 +171,17 @@ const output = await new Promise((resolve, reject) => { device, "jp.rdlabo.workers.sdkacceptance", scenario, + ...(lifecycle ? ["--lifecycle"] : []), ]); let log = ""; let settled = false; + let resumeTimer; + let memoryRecorded = false; const finish = (error) => { if (settled) return; settled = true; deadline.stop(); + clearTimeout(resumeTimer); if (error) log += `\nSDK_PROBE_FAILURE ${error.message.split("\n")[0]}\n`; writeFileSync(`${scratch}/${scenario}.log`, log); child.kill(); @@ -173,9 +195,52 @@ const output = await new Promise((resolve, reject) => { log += `\n${message}\n`; }, }); + let backgroundStarted = false; const receive = (chunk) => { log += chunk.toString(); if (log.includes("SDK_SCENE_STARTED")) deadline.sceneStarted(); + if (!memoryRecorded && log.includes("SDK_DOCUMENT Worker Home")) { + memoryRecorded = true; + const snapshot = spawnSync("ps", ["-axo", "pid,rss,command"], { + encoding: "utf8", + }); + writeFileSync( + `${scratch}/memory-ready.txt`, + "PID RSS_KiB COMMAND (host only; excludes WebKit subprocesses)\n" + + (snapshot.stdout || "") + .split("\n") + .filter( + (line) => + line.includes(device) && line.includes("/Probe.app/Probe"), + ) + .join("\n"), + ); + } + if ( + lifecycle && + !backgroundStarted && + log.includes("SDK_BACKGROUND_READY") + ) { + backgroundStarted = true; + try { + run("xcrun", ["simctl", "launch", device, "com.apple.Preferences"]); + } catch (error) { + finish(error); + return; + } + resumeTimer = setTimeout(() => { + try { + run("xcrun", [ + "simctl", + "launch", + device, + "jp.rdlabo.workers.sdkacceptance", + ]); + } catch (error) { + finish(error); + } + }, 2000); + } if (log.includes(finalMarker)) { finish(); } @@ -201,6 +266,15 @@ if (scenario === "normal") ]) if (!output.includes(marker)) throw new Error(`Missing ${marker}\n${output}`); +if (lifecycle) { + for (const marker of [ + "SDK_BACKGROUND_RESUMED", + "SDK_NETWORK_RECOVERED", + "SDK_NETWORK_INTERRUPTED", + ]) + if (!output.includes(marker)) + throw new Error(`Missing ${marker}\n${output}`); +} if (scenario === "broken-entry") { for (const marker of [ "SDK_MANUAL_CLOSE_NO_LATE_ERROR 0", diff --git a/demo/tests/native-embedding/network-server.mjs b/demo/tests/native-embedding/network-server.mjs new file mode 100644 index 0000000..8f6cfe2 --- /dev/null +++ b/demo/tests/native-embedding/network-server.mjs @@ -0,0 +1,16 @@ +import { createServer } from "node:http"; +// Deliberately drop an established connection; never change host/device networking. +const server = createServer((request, response) => { + if (request.url === "/drop") { + console.log("NETWORK_DROP"); + request.socket.destroy(); + } else { + console.log("NETWORK_OK"); + response.writeHead(200, { + "Content-Type": "text/plain", + "Content-Length": "10", + }); + response.end("network-ok"); + } +}); +server.listen(18764, "127.0.0.1", () => console.log("NETWORK_SERVER_READY")); diff --git a/demo/tests/native-embedding/worker.js b/demo/tests/native-embedding/worker.js index bc92ec5..efa6c79 100644 --- a/demo/tests/native-embedding/worker.js +++ b/demo/tests/native-embedding/worker.js @@ -2,6 +2,20 @@ export default { async fetch(request) { const path = new URL(request.url).pathname; let title = path === "/next" ? "Worker Next" : "Worker Home"; + if (path.startsWith("/network-")) { + try { + const response = await fetch( + `http://127.0.0.1:18764/${path === "/network-drop" ? "drop" : "ok"}`, + { signal: AbortSignal.timeout(5000) }, + ); + title = + (await response.text()) === "network-ok" + ? "Network recovered" + : "Network unexpected"; + } catch { + title = "Network interrupted"; + } + } if (request.method === "POST") { const form = await request.formData(); title = `Posted ${form.get("value") === "verified" ? "verified" : "invalid"}`; @@ -9,6 +23,7 @@ export default { return new Response( `

${title}

${path}

Next +

Check network Interrupt network

`, { headers: { "content-type": "text/html; charset=utf-8" } }, );