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:
parent
c6294a46c7
commit
139698cab5
8
Setup.md
8
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.
|
||||
|
|
|
|||
12
Workplan.md
12
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
|
||||
|
|
|
|||
|
|
@ -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<void> _ensureCardOpen() async {
|
||||
if (!_cardAttached || _cardCid == null || !(await isCardAttached())) {
|
||||
_emit('Card not open — reconnecting...');
|
||||
_cardAttached = false;
|
||||
_cardCid = null;
|
||||
await _tryOpenCard();
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -8,5 +8,8 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.54.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"playwright": "^1.59.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ const { defineConfig } = require("@playwright/test");
|
|||
|
||||
module.exports = defineConfig({
|
||||
testDir: "./tests",
|
||||
timeout: 180_000,
|
||||
timeout: 60_000,
|
||||
expect: {
|
||||
timeout: 15_000,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in New Issue