diff --git a/app/test/e2e/helpers/shared-flows.ts b/app/test/e2e/helpers/shared-flows.ts index 6db917189..0eef2d073 100644 --- a/app/test/e2e/helpers/shared-flows.ts +++ b/app/test/e2e/helpers/shared-flows.ts @@ -113,19 +113,92 @@ const HASH_TO_SIDEBAR_LABEL = { '/settings/intelligence': 'Intelligence', }; +function normalizeHash(value) { + const raw = String(value || ''); + const withPrefix = raw.startsWith('#') ? raw : `#${raw}`; + return withPrefix.replace(/\/$/, ''); +} + +function routeReadySelector(hash) { + const path = normalizeHash(hash).replace(/^#/, ''); + const selectors = { + '/notifications': '[data-testid="integration-notifications-section"]', + '/settings/cron-jobs': '[data-testid="cron-jobs-panel"]', + '/settings/privacy': '[data-testid="settings-privacy-panel"]', + '/settings/migration': '[data-testid="migration-form"]', + '/settings/voice': '[data-testid="voice-providers-section"]', + '/settings/memory-data': '[data-testid="memory-workspace"]', + '/settings/intelligence': '[data-testid="memory-workspace"]', + }; + return selectors[path] || null; +} + +async function routeSignature() { + return browser.execute(() => { + const root = document.getElementById('root'); + return (root?.innerText || root?.textContent || '').trim().slice(0, 500); + }); +} + +async function waitForHashRouteReady(hash, options = {}) { + const { timeout = 10_000, previousSignature = '', allowSameSignature = false } = options; + const expected = normalizeHash(hash); + const readySelector = routeReadySelector(hash); + await browser.waitUntil( + async () => + Boolean( + await browser.execute( + ({ target, selector, before, allowSame }) => { + if (document.readyState !== 'complete') return false; + const current = window.location.hash.replace(/\/$/, ''); + if (current !== target) return false; + const root = document.getElementById('root'); + if (!root) return false; + if (selector && root.querySelector(selector)) return true; + + const signature = (root.innerText || root.textContent || '').trim().slice(0, 500); + if (!signature) return false; + return allowSame || signature !== before; + }, + { + target: expected, + selector: readySelector, + before: previousSignature, + allowSame: allowSameSignature, + } + ) + ), + { + timeout, + interval: 250, + timeoutMsg: `hash route ${expected} did not become ready within ${timeout}ms`, + } + ); +} + export async function navigateViaHash(hash) { const normalized = String(hash).replace(/\/$/, '') || hash; if (supportsExecuteScript()) { + const beforeHash = normalizeHash(await browser.execute(() => window.location.hash)); + const beforeSignature = await routeSignature(); + const targetHash = normalizeHash(hash); try { await browser.execute(h => { window.location.hash = h; }, hash); - await browser.pause(2_000); + await waitForHashRouteReady(hash, { + previousSignature: beforeSignature, + allowSameSignature: beforeHash === targetHash, + }); const currentHash = await browser.execute(() => window.location.hash); console.log(`[E2E] Navigated to ${hash} (current: ${currentHash})`); } catch (err) { console.log(`[E2E] Hash navigation to ${hash} failed:`, err); + const detail = err instanceof Error ? err.message : String(err); + const wrapped = new Error(`[E2E] Hash navigation to ${hash} failed: ${detail}`); + wrapped.cause = err; + throw wrapped; } return; } @@ -378,6 +451,29 @@ export async function dismissBootCheckGateIfVisible(timeoutMs = 12_000): Promise return everSeen; } +async function waitForPostOnboardingHome(logPrefix, timeout = 20_000) { + if (supportsExecuteScript()) { + await browser.waitUntil( + async () => + Boolean(await browser.execute(() => window.location.hash.replace(/\/$/, '') === '#/home')), + { + timeout: Math.min(timeout, 10_000), + interval: 300, + timeoutMsg: 'onboarding completed but hash did not settle on #/home', + } + ); + } + + const homeText = await waitForHomePage(timeout); + if (!homeText) { + const tree = await dumpAccessibilityTree(); + console.log(`${logPrefix} Home page not ready after onboarding. Tree:\n`, tree.slice(0, 4000)); + throw new Error('Onboarding dismissed but Home page did not become ready'); + } + + console.log(`${logPrefix} Post-onboarding Home page confirmed: found "${homeText}"`); +} + /** * Walk through onboarding by advancing the `data-testid="onboarding-next-button"` * until it unmounts. The button is rendered on every step (see @@ -448,6 +544,7 @@ export async function walkOnboarding(logPrefix = '[E2E]', maxSteps = 12): Promis if (status === 'gone') { console.log(`${logPrefix} Onboarding dismissed after ${step} step(s)`); + await waitForPostOnboardingHome(logPrefix); return; } if (status === 'gone-but-onboarding-hash') { diff --git a/gitbooks/developing/e2e-testing.md b/gitbooks/developing/e2e-testing.md index affba06de..1a67782b0 100644 --- a/gitbooks/developing/e2e-testing.md +++ b/gitbooks/developing/e2e-testing.md @@ -126,6 +126,10 @@ Use `waitForTestId(testId)` and `clickTestId(testId)` from `element-helpers.ts` 3. **Use `hasAppChrome()`** instead of checking for `XCUIElementTypeMenuBar` 4. **Use `waitForWebView()`** instead of checking for `XCUIElementTypeWebView` 5. For macOS-only tests, use `process.platform` guards or separate spec files +6. Use `navigateViaHash(route)` for hash routes; it waits for the hash, + `document.readyState`, and a mounted React root before returning. After + onboarding, `walkOnboarding()` also waits for `#/home` plus a Home-page + marker before specs navigate elsewhere. ---