Skip to content

Commit 5d0d1ba

Browse files
authored
fix(e2e): establish a deterministic WelcomePortal entry precondition (#533)
* fix(e2e): establish a deterministic WelcomePortal entry precondition export.spec.ts's beforeEach assumed waitForSpaReady() always resolves via WelcomePortal and clicked "Start a New Project" unconditionally, but that helper's Promise.race also succeeds when the app boots straight into an already-mounted main shell. This caused all 3 retries to fail identically on a post-merge main run, distinct from the (already-fixed) #527 portal- activation auto-seed race. Adds ensureWelcomePortalEntry() (tests/e2e/helpers.ts), which recovers via the real Settings -> Data & Backups -> Factory Reset flow when the portal isn't already showing -- no storage/React internals touched, only supported app behavior. Regression-tested from both startup shapes in a new spec. See issue #532 for the full evidence trail and the still-open root-cause question of how a persisted project reached IndexedDB before the test's own actions ran. * fix(e2e): address review findings on the WelcomePortal precondition fix - Add the required QNBS-v3 marker to ensureWelcomePortalEntry (CodeRabbit). - Harden the persisted-main-shell regression case: wait for the real debounced autosave to land (observable "All changes saved" signal in Header.tsx) before reloading, and explicitly assert the reload landed back in the main shell with "Start a New Project" absent -- otherwise the scenario could pass via a fresh WelcomePortal without ever exercising ensureWelcomePortalEntry()'s Factory Reset fallback (chatgpt-codex-connector). * fix(e2e): normalize to English before the locale-dependent recovery flow ensureWelcomePortalEntry()'s Factory Reset fallback matches Settings/ Data & Backups/Factory Reset button text in English or German only. If a non-EN/DE language were persisted when the fallback is entered, it would hang instead of establishing the promised precondition. Force worldscript-language to 'en' via the same localStorage key the app's own language switcher writes, then reload, before navigating. * fix(e2e): fix strict-mode violation in the persisted-main-shell regression test The new post-reload main-shell assertion used locator('#sidebar').or( locator('[data-tour="nav-mobile"]')).toBeVisible(), which fails with a Playwright strict-mode violation: both elements exist in the DOM at once (only one is CSS-visible per viewport via Tailwind's responsive classes), so .or() resolves to 2 elements instead of 1. This caused the CI E2E job on the prior push to fail on all projects (chromium, Mobile Chrome). Export the existing waitForMainChrome() helper (already correct via Promise.race of two independent visibility waits, the same pattern ensureBlankProject()/waitForSpaReady() already use) and reuse it instead of duplicating the buggy pattern. * fix(e2e): make WelcomePortal readiness detection locale-independent waitForSpaReady() detected the welcome portal via the English-only "Start a New Project" button label. If the app booted into a non-English WelcomePortal, none of the race's three conditions would ever match, so the whole helper (and anything built on it, including ensureWelcomePortalEntry()'s own language-normalization step, which only runs after waitForSpaReady() resolves) would time out before ever getting a chance to fix the locale. Adds a stable data-testid="welcome-portal" to WelcomePortal's root and uses it in the readiness race instead of the translated button text. * fix(e2e): make ensureWelcomePortalEntry's own portal check locale-independent The stable-selector fix in 878c1e7 only updated waitForSpaReady()'s race. ensureWelcomePortalEntry()'s own early-return check still used the English-only button text, so a non-English WelcomePortal would incorrectly fall through into the main-chrome-only Settings recovery flow (which itself would then fail, since clickNavItem needs main- chrome nav that doesn't exist on WelcomePortal). Switch this check to the same welcome-portal testid. Adds a regression case seeding a non-English (Spanish) language before boot, per CodeRabbit's suggestion, proving the whole helper survives a non-English WelcomePortal end to end. * fix(e2e): collapse a two-line comment into the required single-line QNBS-v3 form * fix(e2e): handle the fallback's own internal reload racing autosave ensureWelcomePortalEntry()'s Settings/Factory Reset fallback forces English then reloads before waiting for main chrome rigidly. If that reload happens before a still-pending debounced autosave (~1s) lands, the reload discards the unpersisted project and lands back in WelcomePortal instead of main chrome -- exactly what was observed on CI (E2E Tests failed on 7513e37, 13m timeout in waitForMainChrome). Re-check both states after the internal reload instead of assuming main chrome: if the portal is already visible, the target state was reached via the race and no Settings navigation is needed. Also fixes the helper's contract to be explicit: it guarantees the portal is reached, locale-independently, not that it is English. Aligns both existing regression tests (which incorrectly asserted the English button after a locale-independent portal check) and adds a new regression proving the helper is deterministic when its own internal reload can land in either shape. * fix(e2e): replace export.spec.ts's fragile EN-button click with a locale seed ensureWelcomePortalEntry()'s contract is locale-independent (portal reached, not necessarily English), which makes it newly possible for export.spec.ts to actually return from a non-English WelcomePortal -- previously that state just timed out entirely, so this dead code path was never exercised. The existing "click the EN button" logic is unreliable there: the custom LanguageSelector's closed-state trigger is a listbox opener, not a direct language switch, and its accessible name comes from an aria-label that can itself contain "en" as a substring in another locale (Spanish's translation of the group label contains "bienvenida" -- "bienvenida" matches /en/i). Clicking it would just open the dropdown instead of selecting English. Seed worldscript-language to 'en' via page.addInitScript() before the first navigation instead, matching the existing setFeatureFlags() idiom -- deterministic, and removes the need for any in-page language switching in this test entirely.
1 parent e6aac07 commit 5d0d1ba

5 files changed

Lines changed: 119 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2828
new user. Fixed both: synchronous `isPortalActive` initialization, and the missing
2929
`isInitialLoad` guard (extracted into `hooks/useProjectBootstrapEffect.ts` for direct test
3030
coverage). See issue #527.
31+
- **`export.spec.ts` E2E precondition assumed WelcomePortal unconditionally:** `waitForSpaReady()`
32+
succeeds on either WelcomePortal or an already-mounted main shell, but the test's `beforeEach`
33+
clicked "Start a New Project" unconditionally, causing spurious CI failures whenever a cold boot
34+
landed in the main shell instead (a startup-state precondition gap, not a #527/#530 regression —
35+
see issue #532). Added `ensureWelcomePortalEntry()` (`tests/e2e/helpers.ts`), which recovers via
36+
the real Settings → Data & Backups → Factory Reset flow when needed, with regression coverage for
37+
both startup shapes.
3138

3239
## [1.28.2] — 2026-08-27
3340

components/WelcomePortal.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -366,11 +366,15 @@ export const WelcomePortal: React.FC<WelcomePortalProps> = ({ onExit }) => {
366366
};
367367

368368
return (
369-
<div className="fixed inset-0 z-50 overflow-y-auto overscroll-contain bg-[var(--sc-surface-base)] animate-fade-in">
369+
<div
370+
data-testid="welcome-portal"
371+
className="fixed inset-0 z-50 overflow-y-auto overscroll-contain bg-[var(--sc-surface-base)] animate-fade-in"
372+
>
370373
{/* QNBS-v3: shell is now scrollable (overflow-y-auto) with min-h-full centering — content
371374
taller than the viewport (e.g. the feature grid on mobile) scrolls instead of being
372375
clipped by the old fixed `flex items-center` shell. Fixes Mobile-Chrome E2E click
373376
timeouts where the bottom action buttons were unreachable. */}
377+
{/* QNBS-v3: stable, locale-independent selector for E2E readiness checks — the primary action button's label is translated, but this attribute is not. */}
374378
{/* LanguageSelector now imported from ui/LanguageSelector.tsx with search functionality */}
375379
<LanguageSelector value={language} onChange={setLanguage} />
376380
<div className="flex min-h-full items-center justify-center p-4 pt-16 sm:pt-4">

tests/e2e/export.spec.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ import { expect, type Route, test } from '@playwright/test';
22

33
import {
44
clickNavItem,
5+
ensureWelcomePortalEntry,
56
flushWriterDebounce,
67
seedGeminiApiKey,
78
selectFirstEnabledWriterSection,
8-
waitForSpaReady,
99
} from './helpers';
1010

1111
const isCI = process.env['CI'] === 'true';
@@ -52,16 +52,14 @@ test.describe('End-to-end project flow (CI-only)', () => {
5252
test.beforeEach(async ({ page }) => {
5353
test.skip(!isCI, 'CI-only E2E suite');
5454
await page.route('**/generativelanguage.googleapis.com/**', mockGemini);
55+
// QNBS-v3: seeds English before boot — ensureWelcomePortalEntry's contract is locale-independent, and the LanguageSelector trigger's accessible name isn't a reliable /EN/i click target (its aria-label can itself contain "en" as a substring in other locales, e.g. Spanish "bienvenida").
56+
await page.addInitScript(() => localStorage.setItem('worldscript-language', 'en'));
5557
await page.goto('/');
56-
await waitForSpaReady(page);
58+
// QNBS-v3: a cold CI boot can already be in the main shell instead of WelcomePortal — this guarantees the precondition below assumes.
59+
await ensureWelcomePortalEntry(page);
5760
});
5861

5962
test('full project flow navigates from AI outline to export and settings', async ({ page }) => {
60-
const englishButton = page.getByRole('button', { name: /EN/i }).first();
61-
if (await englishButton.isVisible()) {
62-
await englishButton.click();
63-
}
64-
6563
await page.getByRole('button', { name: /Start a New Project/i }).click();
6664
await page.getByRole('button', { name: /Generate with AI/i }).click();
6765

tests/e2e/helpers.ts

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -109,19 +109,18 @@ export async function seedGeminiApiKey(page: Page): Promise<void> {
109109

110110
/**
111111
* Vite dev server keeps the HMR/WebSocket busy → `networkidle` often never settles.
112-
* Wait for either the welcome portal primary action or the desktop sidebar shell.
112+
* Wait for either the welcome portal or the desktop/mobile sidebar shell.
113113
* QNBS-v3: Also waits for the body theme class to be applied by the App useEffect so
114114
* that CSS custom properties (--sc-text-primary, etc.) are fully resolved before
115115
* axe or visual checks run — without this, variables resolve to intermediate values.
116116
*/
117117
export async function waitForSpaReady(page: Page): Promise<void> {
118118
await page.waitForLoadState('domcontentloaded');
119+
// QNBS-v3: welcome-portal is a stable data-testid, not the translated button label — a non-English boot no longer times out this race.
119120
await Promise.race([
120121
page.locator('#sidebar').waitFor({ state: 'visible', timeout: 25000 }),
121122
page.locator('[data-tour="nav-mobile"]').waitFor({ state: 'visible', timeout: 25000 }),
122-
page
123-
.getByRole('button', { name: /Start a New Project/i })
124-
.waitFor({ state: 'visible', timeout: 25000 }),
123+
page.getByTestId('welcome-portal').waitFor({ state: 'visible', timeout: 25000 }),
125124
]);
126125
// QNBS-v3: theme class is applied in App useEffect after first render — wait for it so
127126
// CSS variable values are stable (avoids axe false-positives on mid-transition colors).
@@ -137,9 +136,11 @@ export async function waitForSpaReady(page: Page): Promise<void> {
137136
}
138137

139138
/**
140-
* Main shell is ready (desktop sidebar or mobile bottom tab bar).
139+
* Main shell is ready (desktop sidebar or mobile bottom tab bar). Exported so callers proving
140+
* "main shell is visible" don't reach for `locA.or(locB)` — both elements exist in the DOM at
141+
* once (only one is CSS-visible per viewport), which trips Playwright's strict-mode violation.
141142
*/
142-
async function waitForMainChrome(page: Page): Promise<void> {
143+
export async function waitForMainChrome(page: Page): Promise<void> {
143144
await Promise.race([
144145
page.locator('#sidebar').waitFor({ state: 'visible', timeout: 25000 }),
145146
page.locator('[data-tour="nav-mobile"]').waitFor({ state: 'visible', timeout: 25000 }),
@@ -172,6 +173,46 @@ export async function ensureBlankProject(page: Page): Promise<void> {
172173
await waitForMainChrome(page);
173174
}
174175

176+
// QNBS-v3: guarantees a deterministic WelcomePortal entry precondition when CI cold-boots into the main shell instead.
177+
/**
178+
* Deterministically reach the WelcomePortal entry point regardless of which of
179+
* waitForSpaReady()'s two success shapes the app actually booted into. A cold CI boot has landed
180+
* in an already-mounted main shell with a persisted project instead of the portal — a startup-
181+
* state precondition gap distinct from the (fixed) portal-activation auto-seed race.
182+
* Contract: guarantees the portal is reached, locale-independently — it does NOT guarantee
183+
* English. A caller needing English selects it itself (export.spec.ts already does this for the
184+
* fresh-boot case). Recovers via the real Settings → Data & Backups → Factory Reset flow when
185+
* main chrome is active so no React/Redux/storage internals are touched — only supported app
186+
* behavior.
187+
*/
188+
export async function ensureWelcomePortalEntry(page: Page): Promise<void> {
189+
await waitForSpaReady(page);
190+
const portal = page.getByTestId('welcome-portal');
191+
if (await portal.isVisible({ timeout: 3000 }).catch(() => false)) {
192+
return;
193+
}
194+
// QNBS-v3: force English before the locale-dependent recovery flow below, or a persisted non-EN/DE language would hang it.
195+
await page.evaluate(() => localStorage.setItem('worldscript-language', 'en'));
196+
await page.reload();
197+
await waitForSpaReady(page);
198+
// QNBS-v3: this reload can itself race a pending debounced autosave and land back in WelcomePortal instead of main chrome — accept either state again rather than assuming main chrome.
199+
if (await portal.isVisible({ timeout: 3000 }).catch(() => false)) {
200+
return;
201+
}
202+
await waitForMainChrome(page);
203+
await clickNavItem(page, /Settings/i);
204+
await page
205+
.getByRole('button', { name: /Data & Backups|Daten & Backups/i })
206+
.first()
207+
.click();
208+
await page.getByRole('button', { name: /Factory Reset|Werkseinstellungen/i }).click();
209+
await page
210+
.getByRole('button', { name: /Delete everything & restart|Alles löschen & neu starten/i })
211+
.click();
212+
await waitForSpaReady(page);
213+
await expect(portal).toBeVisible({ timeout: 15000 });
214+
}
215+
175216
/** Desktop sidebar (`md:`); avoids duplicate nav controls vs mobile tab bar. */
176217
export function sidebar(page: Page) {
177218
return page.locator('#sidebar');
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { expect, test } from '@playwright/test';
2+
import { ensureBlankProject, ensureWelcomePortalEntry, waitForMainChrome } from './helpers';
3+
4+
const isCI = process.env['CI'] === 'true';
5+
6+
// QNBS-v3: proves ensureWelcomePortalEntry's contract (portal reached, locale-independent) holds from every startup shape waitForSpaReady() accepts, including its own internal-reload race.
7+
test.describe('WelcomePortal entry precondition (CI-only)', () => {
8+
test.beforeEach(() => {
9+
test.skip(!isCI, 'CI-only E2E suite');
10+
});
11+
12+
test('reaches the entry point on a fresh WelcomePortal boot', async ({ page }) => {
13+
await page.goto('/');
14+
await ensureWelcomePortalEntry(page);
15+
await expect(page.getByTestId('welcome-portal')).toBeVisible();
16+
});
17+
18+
test('reaches the entry point when a non-English language is already persisted', async ({
19+
page,
20+
}) => {
21+
// QNBS-v3: the helper's contract is locale-independent portal-reached, not English — assert the stable testid, not the translated button label.
22+
await page.addInitScript(() => localStorage.setItem('worldscript-language', 'es'));
23+
await page.goto('/');
24+
await expect(page.getByTestId('welcome-portal')).toBeVisible();
25+
await ensureWelcomePortalEntry(page);
26+
await expect(page.getByTestId('welcome-portal')).toBeVisible();
27+
});
28+
29+
test('reaches the entry point from an already-mounted main shell with a persisted project', async ({
30+
page,
31+
}) => {
32+
await page.goto('/');
33+
await ensureBlankProject(page);
34+
// QNBS-v3: waits for the debounced autosave to land so this scenario specifically exercises the full Settings/Factory Reset fallback, which does normalize to English as an implementation detail.
35+
await expect(page.getByText(/All changes saved|Alle Änderungen gespeichert/i)).toBeVisible({
36+
timeout: 10000,
37+
});
38+
await page.reload();
39+
// QNBS-v3: proves the reload landed in the main shell, not a fresh WelcomePortal, or the assertion below would be vacuous.
40+
await waitForMainChrome(page);
41+
await expect(page.getByRole('button', { name: /Start a New Project/i })).not.toBeVisible();
42+
await ensureWelcomePortalEntry(page);
43+
await expect(page.getByRole('button', { name: /Start a New Project/i })).toBeVisible();
44+
});
45+
46+
test('reaches the entry point when its own internal reload can race a pending autosave', async ({
47+
page,
48+
}) => {
49+
// QNBS-v3: deliberately does not wait for "All changes saved" — the helper's own English-normalization reload can race the pending debounced save either way, and it must end in the portal regardless.
50+
await page.goto('/');
51+
await ensureBlankProject(page);
52+
await ensureWelcomePortalEntry(page);
53+
await expect(page.getByTestId('welcome-portal')).toBeVisible();
54+
});
55+
});

0 commit comments

Comments
 (0)