Fix Android Playwright tests: connectOverCDP + card reconnect

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 <noreply@anthropic.com>
This commit is contained in:
Morten V. Christiansen 2026-05-09 21:41:36 +02:00
parent c6294a46c7
commit 139698cab5
8 changed files with 88 additions and 27 deletions

View File

@ -668,6 +668,14 @@ Session note (2026-04-27, card emulator and bug fixes):
sign-count monotonicity, wrong RP rejection, empty allow-list rejection sign-count monotonicity, wrong RP rejection, empty allow-list rejection
- total test count is now 122, all passing locally without card or VMs - 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 23 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): Session note (2026-05-08, per-request token binding + Playwright acceptance tests):
- Per-request FIDO2 token binding implemented across the full stack: - Per-request FIDO2 token binding implemented across the full stack:
- `k_phone/lib/fido2_ops.dart`: `GetAssertionResult` carries `clientDataJson`; `getAssertion()` accepts optional bound challenge. - `k_phone/lib/fido2_ops.dart`: `GetAssertionResult` carries `clientDataJson`; `getAssertion()` accepts optional bound challenge.

View File

@ -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. - `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` - 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 ### Next action
1. Deploy to a real Android phone with physical ChromeCard via USB 1. Deploy to a real Android phone with physical ChromeCard via USB

View File

@ -206,6 +206,7 @@ class _ProxyServer {
if (r == null) return; if (r == null) return;
final (canonical, pretty) = r; final (canonical, pretty) = r;
await _ensureCardOpen();
MakeCredentialResult? credential; MakeCredentialResult? credential;
if (_cardAttached && _cardCid != null) { if (_cardAttached && _cardCid != null) {
try { try {
@ -310,6 +311,7 @@ class _ProxyServer {
return; return;
} }
await _ensureCardOpen();
if (enrollment.hasCredential && _cardCid != null) { if (enrollment.hasCredential && _cardCid != null) {
// FIDO2-direct: getAssertion + local verify. // FIDO2-direct: getAssertion + local verify.
// Random challenge is intentional here: session login only proves the // Random challenge is intentional here: session login only proves the
@ -468,6 +470,7 @@ class _ProxyServer {
return; return;
} }
await _ensureCardOpen();
if (!_cardAttached || _cardCid == null) { if (!_cardAttached || _cardCid == null) {
await _send(req.response, 503, {'ok': false, 'error': 'card not available'}); await _send(req.response, 503, {'ok': false, 'error': 'card not available'});
return; 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<void> _ensureCardOpen() async {
if (!_cardAttached || _cardCid == null || !(await isCardAttached())) {
_emit('Card not open — reconnecting...');
_cardAttached = false;
_cardCid = null;
await _tryOpenCard();
}
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Helpers // Helpers
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------

View File

@ -757,7 +757,7 @@ class ProxyState:
auth_data = self.fido_server.register_complete( auth_data = self.fido_server.register_complete(
state, state,
RegistrationResponse( RegistrationResponse(
raw_id=attestation.auth_data.credential_data.credential_id, id=attestation.auth_data.credential_data.credential_id,
response=AuthenticatorAttestationResponse( response=AuthenticatorAttestationResponse(
client_data=client_data, client_data=client_data,
attestation_object=AttestationObject.create( attestation_object=AttestationObject.create(
@ -888,7 +888,7 @@ class ProxyState:
state, state,
[credential], [credential],
AuthenticationResponse( AuthenticationResponse(
raw_id=response.credential["id"], id=response.credential["id"],
response=AuthenticatorAssertionResponse( response=AuthenticatorAssertionResponse(
client_data=client_data, client_data=client_data,
authenticator_data=response.auth_data, authenticator_data=response.auth_data,

6
package-lock.json generated
View File

@ -7,6 +7,9 @@
"": { "": {
"name": "chromecard-browser-regression", "name": "chromecard-browser-regression",
"version": "0.1.0", "version": "0.1.0",
"dependencies": {
"playwright": "^1.59.1"
},
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.54.2" "@playwright/test": "^1.54.2"
} }
@ -31,7 +34,6 @@
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@ -46,7 +48,6 @@
"version": "1.59.1", "version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.59.1" "playwright-core": "1.59.1"
@ -65,7 +66,6 @@
"version": "1.59.1", "version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"playwright-core": "cli.js" "playwright-core": "cli.js"

View File

@ -8,5 +8,8 @@
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.54.2" "@playwright/test": "^1.54.2"
},
"dependencies": {
"playwright": "^1.59.1"
} }
} }

View File

@ -3,7 +3,7 @@ const { defineConfig } = require("@playwright/test");
module.exports = defineConfig({ module.exports = defineConfig({
testDir: "./tests", testDir: "./tests",
timeout: 180_000, timeout: 60_000,
expect: { expect: {
timeout: 15_000, timeout: 15_000,
}, },

View File

@ -31,14 +31,22 @@
* External host requests (httpbin.org, example.com) go through Component 1. * 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; const ADB = (() => {
try { const candidates = [
android = require('playwright').android; process.env.ADB,
} catch { `${process.env.HOME}/Library/Android/sdk/platform-tools/adb`,
// playwright not installed separately — tests will be skipped. '/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_URL = process.env.GATED_URL || 'http://httpbin.org/get';
const GATED_METHOD = (process.env.GATED_METHOD || 'GET').toUpperCase(); 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', () => { test.describe.serial('k_phone proxy routing — Android Chrome', () => {
let device = null; let browser = null; // CDP-connected browser (chromium.connectOverCDP)
let proxyCtx = null; // Chrome launched with --proxy-server=127.0.0.1:8888 let proxyCtx = null; // BrowserContext for test pages
let skipReason = null; let skipReason = null;
let enrolledUser = null; let enrolledUser = null;
test.beforeAll(async () => { test.beforeAll(async () => {
if (!android) { if (!ADB) {
skipReason = 'playwright package not found — run: npm install playwright'; skipReason = 'adb not found — install Android SDK platform-tools';
return; return;
} }
try { try {
const devices = await android.devices(); const devicesOut = execSync(`"${ADB}" devices`, { stdio: 'pipe' }).toString();
if (!devices.length) { const connected = devicesOut.split('\n').slice(1).some(l => l.includes('\tdevice'));
if (!connected) {
skipReason = 'No Android emulator connected — run: adb devices'; skipReason = 'No Android emulator connected — run: adb devices';
return; return;
} }
device = devices[0];
// Launch Chrome inside the emulator. // Write --proxy-server flag into Chrome's command-line file, then restart.
// --proxy-server points at Component 1 on the emulator's own loopback. // Chrome on Android reads /data/local/tmp/chrome-command-line on startup.
proxyCtx = await device.launchBrowser({ execSync(`"${ADB}" shell "echo 'chrome --proxy-server=127.0.0.1:8888' > /data/local/tmp/chrome-command-line"`, { stdio: 'pipe' });
args: ['--proxy-server=127.0.0.1:8888'], 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. // Clean state: delete any users left from previous runs.
// 127.0.0.1 bypasses the proxy, so these calls reach Component 2 directly. // 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 page.close().catch(() => {});
} }
} }
await proxyCtx?.close().catch(() => {}); await browser?.close().catch(() => {});
await device?.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 {}
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------