Skip to content

Commit a34a46b

Browse files
Web3NLclaude
andcommitted
fix(e2e): apply II headless Linux patches to all Playwright test contexts
Create tests/fixtures.ts that extends the Playwright test with the same addInitScript + Node.js HTTP proxy + II bundle patches used in derive-principal.mjs — forcing DUMMY_AUTH and using a unique random seed per test context to avoid credential conflicts between tests. Simplify ii-helpers.ts to go CWP → "Create new identity" directly (dropping the fragile "Use existing identity" → error-detection fallback). All spec files that use handleIIPopup now import test/expect from tests/fixtures.ts instead of @playwright/test. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent c1551db commit a34a46b

11 files changed

Lines changed: 135 additions & 48 deletions

File tree

tests/canister-dashboard-frontend/alternative-origins.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { test, expect } from '@playwright/test';
1+
import { test, expect } from '../fixtures.js';
22
import { login } from './login';
33
import {
44
TEST_ORIGINS,

tests/canister-dashboard-frontend/controllers.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { test, expect } from '@playwright/test';
1+
import { test, expect } from '../fixtures.js';
22
import { login } from './login';
33
import {
44
TEST_CONTROLLER,

tests/canister-dashboard-frontend/logs.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { test, expect } from '@playwright/test';
1+
import { test, expect } from '../fixtures.js';
22
import { login } from './login';
33
import { setupConsoleErrorMonitoring } from './shared';
44

tests/canister-dashboard-frontend/theme.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { test, expect } from '@playwright/test';
1+
import { test, expect } from '../fixtures.js';
22
import { login } from './login';
33
import { setupConsoleErrorMonitoring } from './shared';
44

tests/canister-dashboard-frontend/topup-rule.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { test, expect } from '@playwright/test';
1+
import { test, expect } from '../fixtures.js';
22
import { login } from './login';
33
import { setupConsoleErrorMonitoring } from './shared';
44

tests/canister-dashboard-frontend/topup.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { test, expect } from '@playwright/test';
1+
import { test, expect } from '../fixtures.js';
22
import { login, checkPrincipal } from './login';
33
import { formatIcpBalance } from '../../packages-rs/my-canister-dashboard/frontend/src/helpers';
44
import {

tests/fixtures.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/**
2+
* Playwright test fixtures with II headless Linux CI patches.
3+
*
4+
* Applies the same fixes as derive-principal.mjs to every test context:
5+
* 1. addInitScript: isUserVerifyingPlatformAuthenticatorAvailable → true
6+
* 2. addInitScript: credentials.get → NotAllowedError (stops UI from blocking)
7+
* 3. context.route: Node.js HTTP proxy for *.localhost (DNS + gzip + bundle patch)
8+
* 4. II bundle patch: force DUMMY_AUTH path (Vr.dummy_auth check → true)
9+
* 5. II bundle patch: unique seed per context (avoids credential conflicts between tests)
10+
*/
11+
import { test as base } from '@playwright/test';
12+
import type { BrowserContext } from '@playwright/test';
13+
import http from 'http';
14+
import zlib from 'zlib';
15+
16+
const HOP_BY_HOP = new Set([
17+
'content-encoding', 'transfer-encoding', 'connection', 'keep-alive',
18+
]);
19+
20+
async function applyIIPatches(context: BrowserContext, seed: number): Promise<void> {
21+
await context.addInitScript(() => {
22+
try {
23+
if (typeof PublicKeyCredential !== 'undefined') {
24+
Object.defineProperty(
25+
PublicKeyCredential,
26+
'isUserVerifyingPlatformAuthenticatorAvailable',
27+
{ value: () => Promise.resolve(true), writable: true, configurable: true }
28+
);
29+
}
30+
} catch (_) {}
31+
try {
32+
navigator.credentials.get = function () {
33+
return Promise.reject(new DOMException('No credentials available', 'NotAllowedError'));
34+
};
35+
} catch (_) {}
36+
});
37+
38+
await context.route(
39+
(url) => url.hostname !== 'localhost' && url.hostname.endsWith('.localhost'),
40+
(route) => {
41+
const url = new URL(route.request().url());
42+
const req = http.request(
43+
{
44+
hostname: '127.0.0.1',
45+
port: parseInt(url.port) || 80,
46+
path: url.pathname + url.search,
47+
method: route.request().method(),
48+
headers: { ...route.request().headers(), host: url.host },
49+
},
50+
(res) => {
51+
const chunks: Buffer[] = [];
52+
res.on('data', (c: Buffer) => chunks.push(c));
53+
res.on('end', () => {
54+
const rawBody = Buffer.concat(chunks);
55+
const encoding = (res.headers['content-encoding'] ?? '').toLowerCase();
56+
57+
const headers: Record<string, string> = {};
58+
for (const [k, v] of Object.entries(res.headers)) {
59+
if (!HOP_BY_HOP.has(k) && v !== undefined) {
60+
headers[k] = Array.isArray(v) ? v.join('\n') : String(v);
61+
}
62+
}
63+
64+
let body: Buffer;
65+
try {
66+
if (encoding === 'gzip' || encoding === 'x-gzip') body = zlib.gunzipSync(rawBody);
67+
else if (encoding === 'br') body = zlib.brotliDecompressSync(rawBody);
68+
else if (encoding === 'deflate') body = zlib.inflateSync(rawBody);
69+
else body = rawBody;
70+
} catch (_) {
71+
body = rawBody;
72+
}
73+
74+
const p = url.pathname;
75+
if (p.includes('/_app/immutable/entry/start') && p.endsWith('.js')) {
76+
let src = body.toString('utf8');
77+
src = src.replaceAll('Vr.dummy_auth[0]?.[0]!==void 0?', 'true?');
78+
src = src.replace('return BigInt(0)}', `return BigInt(${seed})}`);
79+
body = Buffer.from(src, 'utf8');
80+
}
81+
82+
route
83+
.fulfill({ status: res.statusCode ?? 200, headers, body })
84+
.catch(() => {});
85+
});
86+
}
87+
);
88+
req.on('error', () => route.abort());
89+
const postBody = route.request().postDataBuffer();
90+
if (postBody) req.write(postBody);
91+
req.end();
92+
}
93+
);
94+
}
95+
96+
export const test = base.extend<object>({
97+
context: async ({ context }, use) => {
98+
const seed = Math.floor(Math.random() * 2 ** 32);
99+
await applyIIPatches(context, seed);
100+
await use(context);
101+
},
102+
});
103+
104+
export { expect } from '@playwright/test';

tests/icp-dapp-launcher/install-dapp.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { test, expect } from '@playwright/test';
1+
import { test, expect } from '../fixtures.js';
22
import {
33
icpDappLauncherUrl,
44
loadTestEnv,

tests/icp-dapp-launcher/install-demo.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { test, expect } from '@playwright/test';
1+
import { test, expect } from '../fixtures.js';
22
import {
33
icpDappLauncherUrl,
44
loadTestEnv,

tests/ii-helpers.ts

Lines changed: 22 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,19 @@ import type { Page } from '@playwright/test';
33
/**
44
* Handle the II v2 popup authentication flow.
55
*
6-
* Supports three scenarios:
7-
* - Existing canister: "Continue with passkey" → "Use existing identity" → "Continue"
8-
* - Fresh canister (no identity yet): above path errors → "Create new identity" fallback
9-
* - Returning session (same browser context): "Continue" directly
6+
* icp-cli's bundled II uses DUMMY_AUTH — no real WebAuthn is needed. The fixture
7+
* in fixtures.ts patches the II JS bundle to force the DUMMY_AUTH path and sets
8+
* a unique random seed per test context to avoid credential conflicts between tests.
9+
*
10+
* Scenarios:
11+
* - First popup (no stored identity): CWP → "Create new identity" → name form → done
12+
* - Returning popup (same context): "Continue" appears directly (II session persists)
1013
*/
1114
export async function handleIIPopup(popup: Page): Promise<void> {
15+
await popup
16+
.waitForURL((url) => !url.href.startsWith('about:'), { timeout: 25000 })
17+
.catch(() => {});
18+
1219
await popup.waitForLoadState('domcontentloaded');
1320

1421
const continueWithPasskey = popup.getByRole('button', {
@@ -20,51 +27,27 @@ export async function handleIIPopup(popup: Page): Promise<void> {
2027
exact: true,
2128
});
2229

23-
// Wait for either button to appear
2430
await Promise.race([
25-
continueWithPasskey.waitFor({ state: 'visible', timeout: 15000 }),
26-
continueBtn.waitFor({ state: 'visible', timeout: 15000 }),
31+
continueWithPasskey.waitFor({ state: 'visible', timeout: 30000 }),
32+
continueBtn.waitFor({ state: 'visible', timeout: 30000 }),
2733
]);
2834

2935
if (await continueWithPasskey.isVisible()) {
3036
await continueWithPasskey.click();
3137

32-
// Try "Use existing identity" (works when canister already has a dummy identity)
33-
const useExistingBtn = popup.getByRole('button', {
34-
name: 'Use existing identity',
38+
const createNewBtn = popup.getByRole('button', {
39+
name: 'Create new identity',
3540
exact: true,
3641
});
37-
await useExistingBtn.waitFor({ state: 'visible', timeout: 10000 });
38-
await useExistingBtn.click();
39-
40-
// Check if existing identity was found (Continue appears) or not (error appears)
41-
const errorText = popup.getByText(
42-
'Cannot read properties of undefined'
43-
);
44-
const hasError = await errorText
45-
.waitFor({ state: 'visible', timeout: 3000 })
46-
.then(() => true)
47-
.catch(() => false);
48-
49-
if (hasError) {
50-
// Fresh canister — no identity exists yet, create one
51-
const createNewBtn = popup.getByRole('button', {
52-
name: 'Create new identity',
53-
exact: true,
54-
});
55-
await createNewBtn.waitFor({ state: 'visible', timeout: 10000 });
56-
await createNewBtn.click();
42+
await createNewBtn.waitFor({ state: 'visible', timeout: 20000 });
43+
await createNewBtn.click();
5744

58-
const nameInput = popup.locator('input[placeholder="Identity name"]');
59-
await nameInput.waitFor({ state: 'visible', timeout: 10000 });
60-
await nameInput.fill('Test');
61-
await popup
62-
.getByRole('button', { name: 'Create identity', exact: true })
63-
.click();
64-
}
45+
const nameInput = popup.locator('input[placeholder="Identity name"]');
46+
await nameInput.waitFor({ state: 'visible', timeout: 15000 });
47+
await nameInput.fill('Test');
48+
await popup.getByRole('button', { name: 'Create identity', exact: true }).click();
6549

66-
// Wait for the authorization "Continue" button
67-
await continueBtn.waitFor({ state: 'visible', timeout: 15000 });
50+
await continueBtn.waitFor({ state: 'visible', timeout: 30000 });
6851
}
6952

7053
await continueBtn.click();

0 commit comments

Comments
 (0)