Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
27 changes: 11 additions & 16 deletions apps/web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
});
}

Expand Down
36 changes: 20 additions & 16 deletions tests/e2e/tests/conflict-detection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>).__ZUSTAND_STORES as {
auth: { getState: () => { accessToken: string } };
vault: { getState: () => { rootIpnsName: string } };
const e2e = (window as unknown as Record<string, unknown>).__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;
Expand Down Expand Up @@ -145,28 +145,32 @@ test.describe.serial('Conflict Detection', () => {
* The token may be refreshed between tests.
*/
async function getAccessToken(): Promise<string> {
// 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<string, unknown>).__ZUSTAND_STORES as {
auth: { getState: () => { accessToken: string } };
};
return stores?.auth?.getState()?.accessToken ?? '';
const e2e = (window as unknown as Record<string, unknown>).__E2E as
| {
getAccessToken: () => string;
}
| undefined;
return e2e?.getAccessToken() ?? '';
});
if (liveToken) return liveToken;
// Fallback: use token captured during beforeAll
return accessToken;
}

/**
* Get the root IPNS name from the browser's Zustand vault store.
* Get the root IPNS name via __E2E helpers.
*/
async function getRootIpnsName(): Promise<string> {
const liveName = await page.evaluate(() => {
const stores = (window as unknown as Record<string, unknown>).__ZUSTAND_STORES as {
vault: { getState: () => { rootIpnsName: string } };
};
return stores?.vault?.getState()?.rootIpnsName ?? '';
const e2e = (window as unknown as Record<string, unknown>).__E2E as
| {
getRootIpnsName: () => string;
}
| undefined;
return e2e?.getRootIpnsName() ?? '';
});
if (liveName) return liveName;
return rootIpnsName;
Expand Down
25 changes: 4 additions & 21 deletions tests/e2e/tests/full-workflow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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<string, { getState: () => Record<string, unknown> }>
>
).__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)
Expand Down
78 changes: 31 additions & 47 deletions tests/e2e/utils/multi-account-wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>).__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<string> {
// 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;
Comment thread
FSM1 marked this conversation as resolved.
}

/**
Expand Down Expand Up @@ -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);
Expand Down
25 changes: 6 additions & 19 deletions tests/e2e/utils/wallet-login-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { getState: () => Record<string, unknown> }>
>
).__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;
Expand Down
Loading