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 038bddde99..e986b3d367 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -54,22 +54,17 @@ 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 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.VITE_E2E === 'true') { + import('./stores/vault.store').then(({ useVaultStore }) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (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; 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..f7ea3a6a56 100644 --- a/tests/e2e/utils/multi-account-wallet.ts +++ b/tests/e2e/utils/multi-account-wallet.ts @@ -5,56 +5,47 @@ 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 } }; - }; - - 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; - } +async function extractPublicKeyFromUI(page: Page): Promise { + // Navigate to settings + await page.evaluate(() => { + window.location.hash = '#/settings'; + }); + await page.waitForURL('**/settings', { timeout: 15_000 }); + + // 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()) ?? '').trim(); + if (!publicKey || !publicKey.startsWith('0x')) { + throw new Error( + `Failed to extract public key from Settings page (got: "${publicKey.slice(0, 20)}")` + ); + } - 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; } /** @@ -89,23 +80,16 @@ 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 }), - ]); - - // Extract auth state from browser - const authState = await extractAuthState(page); + // 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); 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;