Skip to content

Commit 3147c6d

Browse files
Web3NLclaude
andcommitted
fix(ci): pin network-launcher to v12.0.0-2026-03-02-11-09 in icp.yaml
The icp-cli-network-launcher silently updated on March 19–24, 2026, switching the bundled Internet Identity from internet_identity_test.wasm (DUMMY_AUTH compiled in) to the production split wasm (backend + frontend). The production wasm requires a runtime init arg to enable DUMMY_AUTH, but pocket-ic passes dummy_auth=opt null which the new II frontend JS misinterprets as disabled — causing credentials.create() to run on headless Chrome and throw NotSupportedError. Root cause is entirely external. Fix: pin the network in icp.yaml to the last launcher that bundled the test wasm with DUMMY_AUTH baked in. Also revert the now-unnecessary workarounds added while diagnosing: - Remove icp-cli version pin from scripts/constants.sh - Remove icp-cli auto-install block from validate-and-test-all.sh - Revert derive-principal.mjs bundle patch - Restore icp-cli install in shared-build.yml Keep: pocket-ic = "13.0.0" bump in Cargo.toml (binary is v13 regardless). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent c616d0c commit 3147c6d

5 files changed

Lines changed: 22 additions & 183 deletions

File tree

.github/workflows/shared-build.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@ jobs:
5353
~/.cache/dfinity/
5454
key: pocket-ic-${{ runner.os }}-v1
5555

56-
- name: Install icp-cli dependencies
57-
run: npm install -g @icp-sdk/ic-wasm
56+
- name: Install icp-cli
57+
run: npm install -g @icp-sdk/icp-cli @icp-sdk/ic-wasm
5858

5959
- name: Prefer IPv4 for localhost on runner
6060
run: |

icp.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ networks:
4646
ii: true
4747
gateway:
4848
port: 8080
49+
version: "v12.0.0-2026-03-02-11-09"
4950

5051
environments:
5152
- name: local

packages-rs/my-canister-dapp-cli/playwright-js/derive-principal.mjs

Lines changed: 19 additions & 171 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,15 @@
11
import { chromium } from '@playwright/test';
22
import { readFileSync } from 'fs';
3-
import http from 'http';
4-
import zlib from 'zlib';
53

64
const canisterOrigin = process.env.DAPP_CANISTER_ORIGIN;
75
const identityProvider = process.env.DAPP_II_PROVIDER;
86
const 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

7651
async 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();

scripts/constants.sh

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,6 @@
55
export REPO_ROOT
66
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
77

8-
# 🔧 Pinned tool versions
9-
export ICP_CLI_VERSION="0.2.0"
10-
118
# 💰 Cycle amounts
129
export CANISTER_INITIAL_CYCLES="1000000000000"
1310

validate-and-test-all.sh

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,6 @@ set -euo pipefail
1616

1717
source "$(dirname "$0")/scripts/constants.sh"
1818

19-
# 🔧 Enforce pinned icp-cli version (same locally and in CI)
20-
CURRENT_ICP_VERSION=$(icp --version 2>/dev/null | awk '{print $2}' || echo "not installed")
21-
if [ "$CURRENT_ICP_VERSION" != "$ICP_CLI_VERSION" ]; then
22-
echo "📦 Installing icp-cli@${ICP_CLI_VERSION} (current: ${CURRENT_ICP_VERSION})..."
23-
npm install -g "@icp-sdk/icp-cli@${ICP_CLI_VERSION}"
24-
fi
25-
2619
# ⏱️ Per-step timing
2720
SCRIPT_START_TIME=$(date +%s)
2821
T_STATIC=0

0 commit comments

Comments
 (0)