From 139698cab50fa777efb1bb404104554e02ee8c5b Mon Sep 17 00:00:00 2001 From: "Morten V. Christiansen" Date: Sat, 9 May 2026 21:41:36 +0200 Subject: [PATCH] Fix Android Playwright tests: connectOverCDP + card reconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit launchBrowser() hangs indefinitely on Chrome 145 in the Android emulator. Replaced with chrome-command-line proxy flag + force-stop/ restart + connectOverCDP. A polling retry loop (max 15 s) handles CDP startup variance. proxy_service.dart: added _ensureCardOpen() which calls isCardAttached() and re-runs _tryOpenCard() if the emulator socket was closed (e.g. after a bridge restart). Called before makeCredential and getAssertion in all three handler paths so the app reconnects automatically without restart. playwright.config.js: global timeout 180 s → 60 s. All 4 tests in k_phone_android.spec.js now pass (16 s total). Co-Authored-By: Claude Sonnet 4.6 --- Setup.md | 8 +++++ Workplan.md | 12 +++++++ k_phone/lib/proxy_service.dart | 14 ++++++++ k_proxy_app.py | 4 +-- package-lock.json | 6 ++-- package.json | 3 ++ playwright.config.js | 2 +- tests/k_phone_android.spec.js | 66 +++++++++++++++++++++++----------- 8 files changed, 88 insertions(+), 27 deletions(-) diff --git a/Setup.md b/Setup.md index 48cac0d..a889fe9 100644 --- a/Setup.md +++ b/Setup.md @@ -668,6 +668,14 @@ Session note (2026-04-27, card emulator and bug fixes): sign-count monotonicity, wrong RP rejection, empty allow-list rejection - total test count is now 122, all passing locally without card or VMs +Session note (2026-05-09, Android Playwright tests passing): +- All 4 tests in `tests/k_phone_android.spec.js` now pass (16 s total on emulator). +- Root cause 1: `playwright.android.devices()[0].launchBrowser()` hangs indefinitely on Chrome 145 in the emulator. Replaced with: write `--proxy-server=127.0.0.1:8888` to `/data/local/tmp/chrome-command-line` via adb, force-stop + restart Chrome, forward `tcp:9222 localabstract:chrome_devtools_remote`, and connect via `chromium.connectOverCDP()`. CDP becomes ready within 2–3 s; a polling retry loop (max 15 s) handles variance. +- Root cause 2: `card_emulator_bridge.py` TCP socket in the Flutter app becomes stale when the bridge process is restarted. `_cardAttached` and `_cardCid` remained set in `proxy_service.dart` even after the Dart socket `onDone` fired. Added `_ensureCardOpen()` in `proxy_service.dart`, called before `makeCredential` (enrollment) and `getAssertion` (login and `/auth/get-token`). The method calls `isCardAttached()` and, if the socket is closed, re-runs `_tryOpenCard()` to reconnect. +- Global Playwright test timeout reduced from 180 s to 60 s in `playwright.config.js`. No test should need more than 60 s (FIDO2 assertion via CardEmulator bridge is instant). +- `adb` path: discovered at `~/Library/Android/sdk/platform-tools/adb` (not in system PATH). The spec file now auto-detects it without requiring a modified PATH. +- `card_emulator_bridge.py` must be running before the first card operation. The bridge does not need restarting between test runs — `_ensureCardOpen()` in the Flutter app reconnects automatically after a bridge restart. + Session note (2026-05-08, per-request token binding + Playwright acceptance tests): - Per-request FIDO2 token binding implemented across the full stack: - `k_phone/lib/fido2_ops.dart`: `GetAssertionResult` carries `clientDataJson`; `getAssertion()` accepts optional bound challenge. diff --git a/Workplan.md b/Workplan.md index 1a1046a..3c9c0e6 100644 --- a/Workplan.md +++ b/Workplan.md @@ -684,6 +684,18 @@ Component 3 (`component3/`) and Component 1 (`k_phone/lib/filter_proxy.dart`) im - `GATED_URL` defaults to `http://httpbin.org/get`; point at `http://k-server-ip:8780/resource/counter` (GATED_METHOD=POST) for full chain validation including token signature verification. - Run: `K_PHONE_PROXY=http://phone-ip:8888 K_PHONE_BASE_URL=http://phone-ip:8771 npx playwright test tests/k_phone_proxy.spec.js` +### Work completed (2026-05-09, Android Playwright tests passing) + +- `tests/k_phone_android.spec.js`: all 4 tests pass (16 s total). Two root causes fixed: + + 1. **`launchBrowser()` hangs on Chrome 145.** Replaced with: write proxy flag to `/data/local/tmp/chrome-command-line`, force-stop + restart Chrome, `adb forward tcp:9222 localabstract:chrome_devtools_remote`, `chromium.connectOverCDP()`. CDP polling loop handles startup variance (≤ 15 s). + + 2. **Stale emulator socket after bridge restart.** `proxy_service.dart`: added `_ensureCardOpen()` — checks `isCardAttached()` and re-runs `_tryOpenCard()` if the socket is closed. Called before `makeCredential` and `getAssertion` in all three handler paths (enroll, session login, `/auth/get-token`). + +- `playwright.config.js`: global timeout reduced from 180 s → 60 s. +- `adb` auto-detected at `~/Library/Android/sdk/platform-tools/adb` without PATH changes. +- `card_emulator_bridge.py` is long-running; no restart needed between test runs. + ### Next action 1. Deploy to a real Android phone with physical ChromeCard via USB diff --git a/k_phone/lib/proxy_service.dart b/k_phone/lib/proxy_service.dart index f802254..c5cf7e8 100644 --- a/k_phone/lib/proxy_service.dart +++ b/k_phone/lib/proxy_service.dart @@ -206,6 +206,7 @@ class _ProxyServer { if (r == null) return; final (canonical, pretty) = r; + await _ensureCardOpen(); MakeCredentialResult? credential; if (_cardAttached && _cardCid != null) { try { @@ -310,6 +311,7 @@ class _ProxyServer { return; } + await _ensureCardOpen(); if (enrollment.hasCredential && _cardCid != null) { // FIDO2-direct: getAssertion + local verify. // Random challenge is intentional here: session login only proves the @@ -468,6 +470,7 @@ class _ProxyServer { return; } + await _ensureCardOpen(); if (!_cardAttached || _cardCid == null) { await _send(req.response, 503, {'ok': false, 'error': 'card not available'}); return; @@ -560,6 +563,17 @@ class _ProxyServer { } } + /// Re-open the card if the socket has been closed since startup. + /// Called before card operations so a bridge restart doesn't require an app restart. + Future _ensureCardOpen() async { + if (!_cardAttached || _cardCid == null || !(await isCardAttached())) { + _emit('Card not open — reconnecting...'); + _cardAttached = false; + _cardCid = null; + await _tryOpenCard(); + } + } + // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- diff --git a/k_proxy_app.py b/k_proxy_app.py index ca72ba1..211e326 100644 --- a/k_proxy_app.py +++ b/k_proxy_app.py @@ -757,7 +757,7 @@ class ProxyState: auth_data = self.fido_server.register_complete( state, RegistrationResponse( - raw_id=attestation.auth_data.credential_data.credential_id, + id=attestation.auth_data.credential_data.credential_id, response=AuthenticatorAttestationResponse( client_data=client_data, attestation_object=AttestationObject.create( @@ -888,7 +888,7 @@ class ProxyState: state, [credential], AuthenticationResponse( - raw_id=response.credential["id"], + id=response.credential["id"], response=AuthenticatorAssertionResponse( client_data=client_data, authenticator_data=response.auth_data, diff --git a/package-lock.json b/package-lock.json index 8710030..74aa9f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "chromecard-browser-regression", "version": "0.1.0", + "dependencies": { + "playwright": "^1.59.1" + }, "devDependencies": { "@playwright/test": "^1.54.2" } @@ -31,7 +34,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -46,7 +48,6 @@ "version": "1.59.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "playwright-core": "1.59.1" @@ -65,7 +66,6 @@ "version": "1.59.1", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", - "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" diff --git a/package.json b/package.json index 17537fe..accb693 100644 --- a/package.json +++ b/package.json @@ -8,5 +8,8 @@ }, "devDependencies": { "@playwright/test": "^1.54.2" + }, + "dependencies": { + "playwright": "^1.59.1" } } diff --git a/playwright.config.js b/playwright.config.js index 67a2daf..db20261 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -3,7 +3,7 @@ const { defineConfig } = require("@playwright/test"); module.exports = defineConfig({ testDir: "./tests", - timeout: 180_000, + timeout: 60_000, expect: { timeout: 15_000, }, diff --git a/tests/k_phone_android.spec.js b/tests/k_phone_android.spec.js index 0622aac..e636a2b 100644 --- a/tests/k_phone_android.spec.js +++ b/tests/k_phone_android.spec.js @@ -31,14 +31,22 @@ * External host requests (httpbin.org, example.com) go through Component 1. */ -const { test, expect } = require('@playwright/test'); +const { test, expect, chromium } = require('@playwright/test'); +const { execSync } = require('child_process'); -let android = null; -try { - android = require('playwright').android; -} catch { - // playwright not installed separately — tests will be skipped. -} +const ADB = (() => { + const candidates = [ + process.env.ADB, + `${process.env.HOME}/Library/Android/sdk/platform-tools/adb`, + '/usr/local/bin/adb', + ].filter(Boolean); + for (const p of candidates) { + try { execSync(`"${p}" version`, { stdio: 'pipe' }); return p; } catch {} + } + return null; +})(); + +const CDP_PORT = 9222; const GATED_URL = process.env.GATED_URL || 'http://httpbin.org/get'; const GATED_METHOD = (process.env.GATED_METHOD || 'GET').toUpperCase(); @@ -90,29 +98,44 @@ async function chromeFetch(page, url, { method = 'GET', data = null, timeoutMs = // --------------------------------------------------------------------------- test.describe.serial('k_phone proxy routing — Android Chrome', () => { - let device = null; - let proxyCtx = null; // Chrome launched with --proxy-server=127.0.0.1:8888 + let browser = null; // CDP-connected browser (chromium.connectOverCDP) + let proxyCtx = null; // BrowserContext for test pages let skipReason = null; let enrolledUser = null; test.beforeAll(async () => { - if (!android) { - skipReason = 'playwright package not found — run: npm install playwright'; + if (!ADB) { + skipReason = 'adb not found — install Android SDK platform-tools'; return; } try { - const devices = await android.devices(); - if (!devices.length) { + const devicesOut = execSync(`"${ADB}" devices`, { stdio: 'pipe' }).toString(); + const connected = devicesOut.split('\n').slice(1).some(l => l.includes('\tdevice')); + if (!connected) { skipReason = 'No Android emulator connected — run: adb devices'; return; } - device = devices[0]; - // Launch Chrome inside the emulator. - // --proxy-server points at Component 1 on the emulator's own loopback. - proxyCtx = await device.launchBrowser({ - args: ['--proxy-server=127.0.0.1:8888'], - }); + // Write --proxy-server flag into Chrome's command-line file, then restart. + // Chrome on Android reads /data/local/tmp/chrome-command-line on startup. + execSync(`"${ADB}" shell "echo 'chrome --proxy-server=127.0.0.1:8888' > /data/local/tmp/chrome-command-line"`, { stdio: 'pipe' }); + execSync(`"${ADB}" shell "chmod 644 /data/local/tmp/chrome-command-line"`, { stdio: 'pipe' }); + execSync(`"${ADB}" shell am force-stop com.android.chrome`, { stdio: 'pipe' }); + await new Promise(r => setTimeout(r, 1200)); + execSync(`"${ADB}" shell am start -n com.android.chrome/com.google.android.apps.chrome.Main about:blank`, { stdio: 'pipe' }); + + // Poll until Chrome's CDP socket appears (up to 15 s). + execSync(`"${ADB}" forward tcp:${CDP_PORT} localabstract:chrome_devtools_remote`, { stdio: 'pipe' }); + browser = null; + for (let attempt = 0; attempt < 15; attempt++) { + await new Promise(r => setTimeout(r, 1000)); + try { + browser = await chromium.connectOverCDP(`http://127.0.0.1:${CDP_PORT}`, { timeout: 3000 }); + break; + } catch {} + } + if (!browser) throw new Error('Chrome CDP not ready after 15 s'); + proxyCtx = browser.contexts()[0] ?? await browser.newContext(); // Clean state: delete any users left from previous runs. // 127.0.0.1 bypasses the proxy, so these calls reach Component 2 directly. @@ -146,8 +169,9 @@ test.describe.serial('k_phone proxy routing — Android Chrome', () => { await page.close().catch(() => {}); } } - await proxyCtx?.close().catch(() => {}); - await device?.close().catch(() => {}); + await browser?.close().catch(() => {}); + try { execSync(`"${ADB}" forward --remove tcp:${CDP_PORT}`, { stdio: 'pipe' }); } catch {} + try { execSync(`"${ADB}" shell rm /data/local/tmp/chrome-command-line`, { stdio: 'pipe' }); } catch {} }); // ---------------------------------------------------------------------------