11import { chromium } from '@playwright/test' ;
22import { readFileSync } from 'fs' ;
3- import http from 'http' ;
4- import zlib from 'zlib' ;
53
64const canisterOrigin = process . env . DAPP_CANISTER_ORIGIN ;
75const identityProvider = process . env . DAPP_II_PROVIDER ;
86const bundle = readFileSync ( process . env . DAPP_BUNDLE_PATH , 'utf8' ) ;
97
108/**
11- * Automate the II popup for the local icp-cli dev II canister.
12- *
13- * icp-cli 0.2.1 II bundle has DUMMY_AUTH (ga class) that avoids real WebAuthn,
14- * but the canister config sets dummy_auth=opt null instead of opt opt{...}, so
15- * the runtime check `Vr.dummy_auth[0]?.[0]!==void 0` evaluates false and the
16- * real WebAuthn path (ps class) is used. We force the ga path by patching the
17- * bundle in context.route() below — see the proxy handler comment.
18- *
19- * Scenarios:
20- * - First run (no stored identity): CWP → "Create new identity" → name form → done
21- * - Returning session (same context): CWP → "Continue" appears directly
9+ * Automate the II popup — ALWAYS creates a new identity.
10+ * Never attempts "Use existing identity" (fresh browser context has no stored key).
2211 */
23- async function handleIIPopup ( popup ) {
24- await popup
25- . waitForURL ( ( url ) => ! url . href . startsWith ( 'about:' ) , { timeout : 25000 } )
26- . catch ( ( ) => { } ) ;
27-
12+ async function handleIIPopupAlwaysNew ( popup ) {
2813 await popup . waitForLoadState ( 'domcontentloaded' ) ;
2914
3015 const continueWithPasskey = popup . getByRole ( 'button' , {
@@ -36,177 +21,40 @@ async function handleIIPopup(popup) {
3621 exact : true ,
3722 } ) ;
3823
39- try {
40- await Promise . race ( [
41- continueWithPasskey . waitFor ( { state : 'visible' , timeout : 30000 } ) ,
42- continueBtn . waitFor ( { state : 'visible' , timeout : 30000 } ) ,
43- ] ) ;
44- } catch ( e ) {
45- const html = await popup . content ( ) . catch ( ( ) => '<error reading content>' ) ;
46- process . stderr . write ( `[ii-popup] timeout. URL: ${ popup . url ( ) } \n` ) ;
47- process . stderr . write ( `[ii-popup] HTML:\n${ html . slice ( 0 , 3000 ) } \n` ) ;
48- throw e ;
49- }
24+ await Promise . race ( [
25+ continueWithPasskey . waitFor ( { state : 'visible' , timeout : 15000 } ) ,
26+ continueBtn . waitFor ( { state : 'visible' , timeout : 15000 } ) ,
27+ ] ) ;
5028
5129 if ( await continueWithPasskey . isVisible ( ) ) {
5230 await continueWithPasskey . click ( ) ;
5331
54- // Each call uses a unique random seed (patched into the bundle above), so
55- // every invocation registers a fresh identity — no credential conflicts.
56- // Go straight to "Create new identity".
32+ // Skip "Use existing identity" — go directly to "Create new identity"
5733 const createNewBtn = popup . getByRole ( 'button' , {
5834 name : 'Create new identity' ,
5935 exact : true ,
6036 } ) ;
61-
62- await createNewBtn . waitFor ( { state : 'visible' , timeout : 20000 } ) ;
37+ await createNewBtn . waitFor ( { state : 'visible' , timeout : 10000 } ) ;
6338 await createNewBtn . click ( ) ;
6439
6540 const nameInput = popup . locator ( 'input[placeholder="Identity name"]' ) ;
66- await nameInput . waitFor ( { state : 'visible' , timeout : 15000 } ) ;
41+ await nameInput . waitFor ( { state : 'visible' , timeout : 10000 } ) ;
6742 await nameInput . fill ( 'Test' ) ;
6843 await popup . getByRole ( 'button' , { name : 'Create identity' , exact : true } ) . click ( ) ;
6944
70- await continueBtn . waitFor ( { state : 'visible' , timeout : 30000 } ) ;
45+ await continueBtn . waitFor ( { state : 'visible' , timeout : 15000 } ) ;
7146 }
7247
7348 await continueBtn . click ( ) ;
7449}
7550
7651async function main ( ) {
77- const browser = await chromium . launch ( {
78- headless : true ,
79- // On Linux, map *.localhost to 127.0.0.1 at the DNS level so PocketIC
80- // canister subdomains resolve. This mirrors what playwright.config.ts does
81- // for the E2E tests. context.route() is kept below as a belt-and-suspenders
82- // backup that also handles gzip decompression.
83- args : [ '--host-resolver-rules=MAP *.localhost 127.0.0.1' ] ,
84- } ) ;
85- // Use Desktop Chrome user agent + viewport to avoid headless-detection by II.
86- const context = await browser . newContext ( {
87- userAgent :
88- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
89- '(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36' ,
90- viewport : { width : 1280 , height : 720 } ,
91- } ) ;
92-
93- // On Linux, Chromium's built-in DNS resolver does not reliably resolve
94- // *.localhost subdomains (the --host-resolver-rules flag is not propagated
95- // to Chrome's sandboxed network service). Intercept all *.localhost requests
96- // in Node.js and proxy them via localhost, preserving the Host header so
97- // PocketIC's HTTP gateway can route to the correct canister.
98- // macOS resolves *.localhost natively — this adds negligible overhead there.
99- //
100- // Additionally: icp-cli 0.2.1 deploys the II canister with dummy_auth=opt null
101- // (Some(None)) instead of opt opt{prompt_for_index:false} (Some(Some({...}))).
102- // The runtime check `Vr.dummy_auth[0]?.[0]!==void 0` therefore evaluates false,
103- // causing the real WebAuthn path (ps class, calls credentials.create()) to run
104- // instead of the DUMMY_AUTH path (ga class, pure software key). On headless
105- // Linux, credentials.create() throws NotSupportedError.
106- //
107- // Fix: detect the II JavaScript entry bundle and replace all dummy_auth checks
108- // with `true` so ga.createNew/ga.useExisting (no WebAuthn) is always used.
109- await context . route (
110- ( url ) => url . hostname !== 'localhost' && url . hostname . endsWith ( '.localhost' ) ,
111- ( route ) => {
112- const url = new URL ( route . request ( ) . url ( ) ) ;
113- const req = http . request (
114- {
115- hostname : '127.0.0.1' ,
116- port : parseInt ( url . port ) || 80 ,
117- path : url . pathname + url . search ,
118- method : route . request ( ) . method ( ) ,
119- headers : { ...route . request ( ) . headers ( ) , host : url . host } ,
120- } ,
121- ( res ) => {
122- const chunks = [ ] ;
123- res . on ( 'data' , ( c ) => chunks . push ( c ) ) ;
124- res . on ( 'end' , ( ) => {
125- const rawBody = Buffer . concat ( chunks ) ;
126- const encoding = ( res . headers [ 'content-encoding' ] || '' ) . toLowerCase ( ) ;
127-
128- // route.fulfill() passes the body directly to Chrome without applying
129- // content-encoding — decompress here or Chrome parses compressed bytes
130- // as HTML/JS and fails with "Invalid or unexpected token".
131- const HOP_BY_HOP = new Set ( [
132- 'content-encoding' , 'transfer-encoding' , 'connection' , 'keep-alive' ,
133- ] ) ;
134- const headers = { } ;
135- for ( const [ k , v ] of Object . entries ( res . headers ) ) {
136- if ( ! HOP_BY_HOP . has ( k ) ) headers [ k ] = Array . isArray ( v ) ? v . join ( '\n' ) : String ( v ) ;
137- }
138-
139- let body ;
140- try {
141- if ( encoding === 'gzip' || encoding === 'x-gzip' ) body = zlib . gunzipSync ( rawBody ) ;
142- else if ( encoding === 'br' ) body = zlib . brotliDecompressSync ( rawBody ) ;
143- else if ( encoding === 'deflate' ) body = zlib . inflateSync ( rawBody ) ;
144- else body = rawBody ;
145- } catch ( e ) {
146- process . stderr . write ( `[proxy] decompress error (${ encoding } ): ${ e . message } \n` ) ;
147- body = rawBody ;
148- }
149-
150- // Patch the II JavaScript entry bundle to force DUMMY_AUTH (ga class).
151- // The bundle path matches /_app/immutable/entry/start*.js.
152- const p = url . pathname ;
153- if ( p . includes ( '/_app/immutable/entry/start' ) && p . endsWith ( '.js' ) ) {
154- let src = body . toString ( 'utf8' ) ;
155- // Force every dummy_auth check to true so ga (DUMMY_AUTH, no real
156- // WebAuthn) is always used instead of ps (real credentials.create).
157- src = src . replaceAll ( 'Vr.dummy_auth[0]?.[0]!==void 0?' , 'true?' ) ;
158- // Use a random seed so each CLI invocation registers a unique identity
159- // and avoids credential-already-in-use conflicts when derive-ii-principal
160- // is called multiple times against the same running II canister.
161- // vz() normally returns BigInt(0); we replace the return value.
162- src = src . replace ( 'return BigInt(0)}' , 'return BigInt(Math.floor(Math.random()*Number.MAX_SAFE_INTEGER))}' ) ;
163- body = Buffer . from ( src , 'utf8' ) ;
164- }
165-
166- route . fulfill ( { status : res . statusCode , headers, body } )
167- . catch ( ( e ) => process . stderr . write ( `[proxy] fulfill error: ${ e . message } \n` ) ) ;
168- } ) ;
169- }
170- ) ;
171- req . on ( 'error' , ( e ) => {
172- process . stderr . write ( `[proxy] request error: ${ url . href } : ${ e . message } \n` ) ;
173- route . abort ( ) ;
174- } ) ;
175- const body = route . request ( ) . postDataBuffer ( ) ;
176- if ( body ) req . write ( body ) ;
177- req . end ( ) ;
178- }
179- ) ;
180-
181- // addInitScript runs on ALL pages (including the II popup) before any page JavaScript.
182- // Two patches are needed for headless Linux CI:
183- //
184- // 1. isUserVerifyingPlatformAuthenticatorAvailable() returns false on headless Linux.
185- // II uses this to decide whether to enable the "Create new identity" button.
186- //
187- // 2. credentials.get() with empty allowCredentials (discoverable/passkey autofill)
188- // throws NotSupportedError on headless Linux, blocking UI render. Rejecting with
189- // NotAllowedError instead lets II treat it as "no passkeys found" and show buttons.
190- await context . addInitScript ( ( ) => {
191- try {
192- if ( typeof PublicKeyCredential !== 'undefined' ) {
193- Object . defineProperty ( PublicKeyCredential , 'isUserVerifyingPlatformAuthenticatorAvailable' , {
194- value : ( ) => Promise . resolve ( true ) ,
195- writable : true , configurable : true ,
196- } ) ;
197- }
198- } catch ( _ ) { }
199- try {
200- navigator . credentials . get = function ( ) {
201- return Promise . reject ( new DOMException ( 'No credentials available' , 'NotAllowedError' ) ) ;
202- } ;
203- } catch ( _ ) { }
204- } ) ;
205-
52+ const browser = await chromium . launch ( { headless : true } ) ;
53+ // Fresh context = no stored passkey identity
54+ const context = await browser . newContext ( ) ;
20655 const page = await context . newPage ( ) ;
20756
208- // Intercept canister origin — the canister does not need to be running for
209- // principal derivation; we only need a page at that origin to trigger II auth.
57+ // Intercept canister origin — canister does not need to be running
21058 await page . route ( `${ canisterOrigin } /**` , ( route ) =>
21159 route . fulfill ( {
21260 status : 200 ,
@@ -220,19 +68,19 @@ async function main() {
22068 await page . evaluate ( ( ip ) => window . DeriveIIPrincipal . setup ( ip ) , identityProvider ) ;
22169 await page . waitForSelector ( '[data-tid="login-button"]' ) ;
22270
223- const popupPromise = page . waitForEvent ( 'popup' , { timeout : 30000 } ) ;
71+ const popupPromise = page . waitForEvent ( 'popup' , { timeout : 15000 } ) ;
22472 await page . click ( '[data-tid="login-button"]' ) ;
22573 const popup = await popupPromise ;
22674
227- await handleIIPopup ( popup ) ;
228- await popup . waitForEvent ( 'close' , { timeout : 30000 } ) ;
75+ await handleIIPopupAlwaysNew ( popup ) ;
76+ await popup . waitForEvent ( 'close' , { timeout : 15000 } ) ;
22977
23078 await page . waitForFunction (
23179 ( ) => {
23280 const el = document . getElementById ( 'principal' ) ;
23381 return el && el . textContent && el . textContent . length > 10 ;
23482 } ,
235- { timeout : 30000 }
83+ { timeout : 15000 }
23684 ) ;
23785
23886 const principal = await page . locator ( '#principal' ) . textContent ( ) ;
0 commit comments