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
|
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 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):
|
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.
|
||||||
|
|
|
||||||
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.
|
- `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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -8,5 +8,8 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.54.2"
|
"@playwright/test": "^1.54.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "^1.59.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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 {}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue