From be355b0bb3b53af46b96ef47261498196e5de6be Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Sat, 21 Mar 2026 02:54:19 +0100 Subject: [PATCH 1/5] fix(web): expose Zustand stores in all builds, not just dev mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit __ZUSTAND_STORES was behind import.meta.env.DEV, so it was never set in CI production builds. This caused the vault readiness check in E2E test 3.7 (and other tests using __ZUSTAND_STORES) to hang forever waiting for stores that don't exist. The stores are read-only Zustand hooks — no secrets beyond what the browser already holds in memory. Multiple E2E tests depend on them for auth state extraction, vault readiness checks, and public key reads. Co-Authored-By: Claude Opus 4.6 (1M context) Entire-Checkpoint: ada05ad0948b --- apps/web/src/main.tsx | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 038bddde99..05524e3182 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -54,24 +54,20 @@ useAuthStore.subscribe((state, prevState) => { } }); -// Expose Zustand stores for E2E test state injection (dev mode only) -if (import.meta.env.DEV) { - import('./stores/auth.store').then(({ useAuthStore }) => { - import('./stores/vault.store').then(({ useVaultStore }) => { - import('./stores/folder.store').then(({ useFolderStore }) => { - import('./stores/sync.store').then(({ useSyncStore }) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (window as any).__ZUSTAND_STORES = { - auth: useAuthStore, - vault: useVaultStore, - folder: useFolderStore, - sync: useSyncStore, - }; - }); - }); - }); - }); -} +// Expose Zustand stores for E2E test state extraction. +// Stores are read-only hooks — no secrets are exposed beyond what the +// browser already has in memory. Needed by Playwright tests that check +// vault readiness, auth state, or extract public keys after login. +import { useVaultStore } from './stores/vault.store'; +import { useFolderStore } from './stores/folder.store'; +import { useSyncStore } from './stores/sync.store'; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(window as any).__ZUSTAND_STORES = { + auth: useAuthStore, + vault: useVaultStore, + folder: useFolderStore, + sync: useSyncStore, +}; const queryClient = new QueryClient({ defaultOptions: { From 507a9fcccb6e83497d4cf1101e8efb24b18adb3a Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Sat, 21 Mar 2026 03:03:38 +0100 Subject: [PATCH 2/5] fix(e2e): gate Zustand store exposure behind VITE_E2E flag Replace unconditional store exposure with a VITE_E2E env var check. Production deploys never set this flag, so the code is tree-shaken out. CI E2E workflow sets VITE_E2E=true at build time. - main.tsx: expose __ZUSTAND_STORES only when DEV or VITE_E2E is set - e2e.yml: add VITE_E2E=true to web build env vars Co-Authored-By: Claude Opus 4.6 (1M context) Entire-Checkpoint: 5d735eafde14 --- .github/workflows/e2e.yml | 1 + apps/web/src/main.tsx | 31 +++++++++++++++++-------------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 05186174dc..881f6d3cb4 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -95,6 +95,7 @@ jobs: env: VITE_WEB3AUTH_CLIENT_ID: ${{ vars.VITE_WEB3AUTH_CLIENT_ID }} VITE_API_URL: http://localhost:3000 + VITE_E2E: 'true' - name: Create .env files for E2E run: | diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 05524e3182..d1df6003da 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -54,20 +54,23 @@ useAuthStore.subscribe((state, prevState) => { } }); -// Expose Zustand stores for E2E test state extraction. -// Stores are read-only hooks — no secrets are exposed beyond what the -// browser already has in memory. Needed by Playwright tests that check -// vault readiness, auth state, or extract public keys after login. -import { useVaultStore } from './stores/vault.store'; -import { useFolderStore } from './stores/folder.store'; -import { useSyncStore } from './stores/sync.store'; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -(window as any).__ZUSTAND_STORES = { - auth: useAuthStore, - vault: useVaultStore, - folder: useFolderStore, - sync: useSyncStore, -}; +// Expose Zustand stores for E2E test state extraction (dev or CI E2E builds only). +// Gated behind VITE_E2E so production deploys never include this code. +if (import.meta.env.DEV || import.meta.env.VITE_E2E) { + Promise.all([ + import('./stores/vault.store'), + import('./stores/folder.store'), + import('./stores/sync.store'), + ]).then(([{ useVaultStore }, { useFolderStore }, { useSyncStore }]) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).__ZUSTAND_STORES = { + auth: useAuthStore, + vault: useVaultStore, + folder: useFolderStore, + sync: useSyncStore, + }; + }); +} const queryClient = new QueryClient({ defaultOptions: { From 41d4f6f3798b6b109b9f8cc568022497678dfc9f Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Sat, 21 Mar 2026 03:12:01 +0100 Subject: [PATCH 3/5] fix(e2e): replace Zustand store access with UI-based checks - wallet-login-helpers: replace vault readiness poll via __ZUSTAND_STORES with waiting for file list / empty state - full-workflow test 3.7: remove __ZUSTAND_STORES vault poll, rely on file list appearing as proof of vault + SDK readiness - multi-account-wallet: extract public key from Settings page UI instead of reading Zustand auth store. Remove accessToken and rootIpnsName from WalletTestAccount (only used by conflict detection which reads stores directly) Remaining __ZUSTAND_STORES usages (gated by VITE_E2E): - conflict-detection: needs accessToken + rootIpnsName for direct API calls that simulate another device (no UI equivalent) - web3auth-helpers: old test-login state injection infrastructure - recycle-bin: best-effort quota check with null fallback Co-Authored-By: Claude Opus 4.6 (1M context) Entire-Checkpoint: 17fcef0f2c15 --- tests/e2e/tests/full-workflow.spec.ts | 25 ++-------- tests/e2e/utils/multi-account-wallet.ts | 64 ++++++++++--------------- tests/e2e/utils/wallet-login-helpers.ts | 25 +++------- 3 files changed, 34 insertions(+), 80 deletions(-) diff --git a/tests/e2e/tests/full-workflow.spec.ts b/tests/e2e/tests/full-workflow.spec.ts index 4d577a3697..164270d3e4 100644 --- a/tests/e2e/tests/full-workflow.spec.ts +++ b/tests/e2e/tests/full-workflow.spec.ts @@ -530,7 +530,7 @@ test.describe.serial('Full Workflow', () => { test('3.7 Page reload preserves session and reloads root folder', async () => { // On staging, IPNS resolution after a page reload can take >60s due to // cache propagation delays. Allow generous timeout with a retry cycle. - test.setTimeout(360_000); // 120s auth restore + 60s vault + 120s root sync + 30s file + buffer + test.setTimeout(300_000); // 120s auth restore + 120s root sync + 30s file + buffer // Navigate to root before reload so we start from a clean state await navigateToRoot(); @@ -551,27 +551,10 @@ test.describe.serial('Full Workflow', () => { timeout: 120000, }); - // Wait for vault + SDK to be fully initialized after session restore. - // Without this, navigating into subfolders may fail because the SDK client - // isn't ready to decrypt subfolder IPNS records. - await page.waitForFunction( - () => { - const stores = ( - window as unknown as Record< - string, - Record Record }> - > - ).__ZUSTAND_STORES; - if (!stores?.vault) return false; - const vault = stores.vault.getState(); - return !!vault.rootFolderKey && !!vault.rootIpnsKeypair && !!vault.rootIpnsName; - }, - { timeout: 60_000 } - ); - // Wait for initial sync to complete — all root items must appear. - // This proves IPNS root metadata was re-fetched and decrypted. - // Use 120s timeout — staging IPNS resolution can exceed 60s. + // This proves vault loaded, SDK initialized, and IPNS root metadata + // was re-fetched and decrypted. No Zustand store polling needed — + // the file list appearing is the definitive UI signal. await fileList.waitForItemToAppear(workspaceFolder, { timeout: 120000 }); // Wait for files to appear too (sync may render folders before files) diff --git a/tests/e2e/utils/multi-account-wallet.ts b/tests/e2e/utils/multi-account-wallet.ts index 4edacf6707..99b0ade293 100644 --- a/tests/e2e/utils/multi-account-wallet.ts +++ b/tests/e2e/utils/multi-account-wallet.ts @@ -5,56 +5,42 @@ import { setupMockWallet, loginViaWallet } from './wallet-login-helpers'; /** * A fully authenticated test account using wallet-based login. * Each account has an isolated browser context, a random EVM identity, - * and its public key extracted from the Zustand auth store after login. + * and its public key extracted from the Settings page UI after login. */ export interface WalletTestAccount { name: string; context: BrowserContext; page: Page; account: PrivateKeyAccount; - /** 0x04-prefixed secp256k1 public key (for sharing — extracted from auth store) */ + /** 0x04-prefixed secp256k1 public key (for sharing — extracted from Settings page) */ publicKey: string; - /** JWT access token (extracted from auth store — for direct API calls) */ - accessToken: string; - /** Root IPNS name (extracted from vault store — for conflict detection API calls) */ - rootIpnsName: string; } /** - * Extract auth state from Zustand stores in the browser. + * Extract the user's public key from the Settings page UI. * - * After wallet login, Core Kit manages the secp256k1 identity in-browser. - * This function extracts the public key, access token, and vault metadata - * needed by tests that make direct API calls (conflict detection, sharing). + * Navigates to /settings, reads the key from the visible element, + * then navigates back to /files. This avoids reading Zustand internals. */ -async function extractAuthState( - page: Page -): Promise<{ publicKey: string; accessToken: string; rootIpnsName: string }> { - return page.evaluate(() => { - const stores = (window as unknown as Record).__ZUSTAND_STORES as { - auth: { getState: () => { accessToken: string; vaultKeypair?: { publicKey: Uint8Array } } }; - vault: { getState: () => { rootIpnsName: string } }; - }; +async function extractPublicKeyFromUI(page: Page): Promise { + // Navigate to settings + await page.evaluate(() => { + window.location.hash = '#/settings'; + }); + await page.waitForURL('**/settings', { timeout: 15_000 }); - const authState = stores.auth.getState(); - const vaultState = stores.vault.getState(); - - // Convert Uint8Array public key to 0x-prefixed hex - const pubKeyBytes = authState.vaultKeypair?.publicKey; - let publicKey = ''; - if (pubKeyBytes) { - const hex = Array.from(pubKeyBytes) - .map((b: number) => b.toString(16).padStart(2, '0')) - .join(''); - publicKey = '0x' + hex; - } + // Read public key from the Settings page + const pubKeyElement = page.locator('.settings-pubkey-value'); + await pubKeyElement.waitFor({ state: 'visible', timeout: 10_000 }); + const publicKey = (await pubKeyElement.textContent()) ?? ''; - return { - publicKey, - accessToken: authState.accessToken, - rootIpnsName: vaultState.rootIpnsName, - }; + // Navigate back to files + await page.evaluate(() => { + window.location.hash = '#/files'; }); + await page.waitForURL('**/files', { timeout: 15_000 }); + + return publicKey; } /** @@ -95,17 +81,15 @@ export async function createWalletTestAccount( page.locator('[data-testid="empty-state"]').waitFor({ state: 'visible', timeout: 30_000 }), ]); - // Extract auth state from browser - const authState = await extractAuthState(page); + // Extract public key from the Settings page UI (no Zustand access needed) + const publicKey = await extractPublicKeyFromUI(page); return { name, context, page, account, - publicKey: authState.publicKey, - accessToken: authState.accessToken, - rootIpnsName: authState.rootIpnsName, + publicKey, }; } catch (err) { await context.close().catch(() => undefined); diff --git a/tests/e2e/utils/wallet-login-helpers.ts b/tests/e2e/utils/wallet-login-helpers.ts index 1007669a58..d64ea148f6 100644 --- a/tests/e2e/utils/wallet-login-helpers.ts +++ b/tests/e2e/utils/wallet-login-helpers.ts @@ -150,26 +150,13 @@ export async function loginViaWallet( ]); // 6. If login succeeded, wait for vault + SDK initialization to complete. - // There's a race condition in the web app: Login.tsx redirects to /files - // when isAuthenticated becomes true (access token set), but vault init - // and SDK client init may still be in progress. Wait for vault keys to - // appear in the Zustand store, which indicates initializeOrLoadVault() - // has completed and the SDK client is ready for file operations. + // The file list or empty state appearing proves that vault keys loaded, + // IPNS metadata resolved, and the SDK client is ready for operations. if (result.outcome === 'success') { - await page.waitForFunction( - () => { - const stores = ( - window as unknown as Record< - string, - Record Record }> - > - ).__ZUSTAND_STORES; - if (!stores?.vault) return false; - const vault = stores.vault.getState(); - return !!vault.rootFolderKey && !!vault.rootIpnsKeypair && !!vault.rootIpnsName; - }, - { timeout: 30_000 } - ); + await Promise.race([ + page.locator('.file-list[role="grid"]').waitFor({ state: 'visible', timeout: 30_000 }), + page.locator('[data-testid="empty-state"]').waitFor({ state: 'visible', timeout: 30_000 }), + ]); } return result; From e3683ed92fd013f922a95d4b363a5c3098c1f6c6 Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Sat, 21 Mar 2026 03:24:04 +0100 Subject: [PATCH 4/5] fix(e2e): replace full Zustand store exposure with minimal __E2E helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of exposing all Zustand store hooks (which include setState, private keys, and folder keys), expose only two getter functions via window.__E2E: - getAccessToken() — for conflict-detection API calls - getRootIpnsName() — for conflict-detection sequence bumps This is the only active test that needs in-browser state. All other tests now use UI elements (file list, Settings page public key). Removed DEV guard — __E2E is exclusively behind VITE_E2E which is only set in the CI E2E workflow build. Co-Authored-By: Claude Opus 4.6 (1M context) Entire-Checkpoint: 8b7cf8245b9e --- apps/web/src/main.tsx | 20 +++++------- tests/e2e/tests/conflict-detection.spec.ts | 36 ++++++++++++---------- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index d1df6003da..16dfaee618 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -54,20 +54,16 @@ useAuthStore.subscribe((state, prevState) => { } }); -// Expose Zustand stores for E2E test state extraction (dev or CI E2E builds only). +// Expose minimal E2E test helpers (CI E2E builds only). +// Only exposes accessToken and rootIpnsName — needed by conflict-detection +// tests that make direct API calls to simulate concurrent device publishes. // Gated behind VITE_E2E so production deploys never include this code. -if (import.meta.env.DEV || import.meta.env.VITE_E2E) { - Promise.all([ - import('./stores/vault.store'), - import('./stores/folder.store'), - import('./stores/sync.store'), - ]).then(([{ useVaultStore }, { useFolderStore }, { useSyncStore }]) => { +if (import.meta.env.VITE_E2E) { + import('./stores/vault.store').then(({ useVaultStore }) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any - (window as any).__ZUSTAND_STORES = { - auth: useAuthStore, - vault: useVaultStore, - folder: useFolderStore, - sync: useSyncStore, + (window as any).__E2E = { + getAccessToken: () => useAuthStore.getState().accessToken, + getRootIpnsName: () => useVaultStore.getState().rootIpnsName, }; }); } diff --git a/tests/e2e/tests/conflict-detection.spec.ts b/tests/e2e/tests/conflict-detection.spec.ts index dfb1df3348..6d82ff9a76 100644 --- a/tests/e2e/tests/conflict-detection.spec.ts +++ b/tests/e2e/tests/conflict-detection.spec.ts @@ -77,15 +77,15 @@ test.describe.serial('Conflict Detection', () => { // Login via wallet (mock wallet auto-approves connect + SIWE) await loginViaWallet(page, { timeout: 60_000 }); - // Extract auth state from Zustand stores for bumpServerSequence API calls + // Extract auth state via __E2E helpers for bumpServerSequence API calls const authData = await page.evaluate(() => { - const stores = (window as unknown as Record).__ZUSTAND_STORES as { - auth: { getState: () => { accessToken: string } }; - vault: { getState: () => { rootIpnsName: string } }; + const e2e = (window as unknown as Record).__E2E as { + getAccessToken: () => string; + getRootIpnsName: () => string; }; return { - accessToken: stores.auth.getState().accessToken, - rootIpnsName: stores.vault.getState().rootIpnsName, + accessToken: e2e.getAccessToken(), + rootIpnsName: e2e.getRootIpnsName(), }; }); accessToken = authData.accessToken; @@ -145,13 +145,15 @@ test.describe.serial('Conflict Detection', () => { * The token may be refreshed between tests. */ async function getAccessToken(): Promise { - // Always read from the live Zustand store to get the freshest token + // Read from __E2E helpers to get the freshest token // (tokens may be refreshed between tests) const liveToken = await page.evaluate(() => { - const stores = (window as unknown as Record).__ZUSTAND_STORES as { - auth: { getState: () => { accessToken: string } }; - }; - return stores?.auth?.getState()?.accessToken ?? ''; + const e2e = (window as unknown as Record).__E2E as + | { + getAccessToken: () => string; + } + | undefined; + return e2e?.getAccessToken() ?? ''; }); if (liveToken) return liveToken; // Fallback: use token captured during beforeAll @@ -159,14 +161,16 @@ test.describe.serial('Conflict Detection', () => { } /** - * Get the root IPNS name from the browser's Zustand vault store. + * Get the root IPNS name via __E2E helpers. */ async function getRootIpnsName(): Promise { const liveName = await page.evaluate(() => { - const stores = (window as unknown as Record).__ZUSTAND_STORES as { - vault: { getState: () => { rootIpnsName: string } }; - }; - return stores?.vault?.getState()?.rootIpnsName ?? ''; + const e2e = (window as unknown as Record).__E2E as + | { + getRootIpnsName: () => string; + } + | undefined; + return e2e?.getRootIpnsName() ?? ''; }); if (liveName) return liveName; return rootIpnsName; From 9a544680cbf2f4b0b5c33389f82676764d582edd Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Sat, 21 Mar 2026 03:26:42 +0100 Subject: [PATCH 5/5] =?UTF-8?q?fix(e2e):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20strict=20env=20check,=20key=20validation,=20dedup?= =?UTF-8?q?=20wait?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Compare VITE_E2E === 'true' explicitly (not truthy) to prevent accidental exposure when set to 'false' or other strings - Validate extracted public key: trim whitespace, assert non-empty and starts with 0x, throw descriptive error on failure - Remove duplicate file-list wait in createWalletTestAccount since loginViaWallet already performs the same readiness check Co-Authored-By: Claude Opus 4.6 (1M context) Entire-Checkpoint: c03f99b91c5f --- apps/web/src/main.tsx | 2 +- tests/e2e/utils/multi-account-wallet.ts | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 16dfaee618..e986b3d367 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -58,7 +58,7 @@ useAuthStore.subscribe((state, prevState) => { // Only exposes accessToken and rootIpnsName — needed by conflict-detection // tests that make direct API calls to simulate concurrent device publishes. // Gated behind VITE_E2E so production deploys never include this code. -if (import.meta.env.VITE_E2E) { +if (import.meta.env.VITE_E2E === 'true') { import('./stores/vault.store').then(({ useVaultStore }) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any (window as any).__E2E = { diff --git a/tests/e2e/utils/multi-account-wallet.ts b/tests/e2e/utils/multi-account-wallet.ts index 99b0ade293..f7ea3a6a56 100644 --- a/tests/e2e/utils/multi-account-wallet.ts +++ b/tests/e2e/utils/multi-account-wallet.ts @@ -32,7 +32,12 @@ async function extractPublicKeyFromUI(page: Page): Promise { // Read public key from the Settings page const pubKeyElement = page.locator('.settings-pubkey-value'); await pubKeyElement.waitFor({ state: 'visible', timeout: 10_000 }); - const publicKey = (await pubKeyElement.textContent()) ?? ''; + const publicKey = ((await pubKeyElement.textContent()) ?? '').trim(); + if (!publicKey || !publicKey.startsWith('0x')) { + throw new Error( + `Failed to extract public key from Settings page (got: "${publicKey.slice(0, 20)}")` + ); + } // Navigate back to files await page.evaluate(() => { @@ -75,12 +80,7 @@ export async function createWalletTestAccount( ); } - // Wait for file list or empty state to confirm vault is accessible - await Promise.race([ - page.locator('.file-list[role="grid"]').waitFor({ state: 'visible', timeout: 30_000 }), - page.locator('[data-testid="empty-state"]').waitFor({ state: 'visible', timeout: 30_000 }), - ]); - + // loginViaWallet already waits for file list / empty state on success. // Extract public key from the Settings page UI (no Zustand access needed) const publicKey = await extractPublicKeyFromUI(page);