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
2 changes: 2 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ignore:
- "tests/**"
8 changes: 7 additions & 1 deletion components/SettingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,17 @@ import { ViewErrorBoundary } from './ui/ViewErrorBoundary';

// --- SUB-COMPONENTS ---

// QNBS-v3: stable data-testid lets E2E recovery navigation target a category without matching translated label text
const NavButton: FC<{
id: string;
icon: React.ReactNode;
label: string;
isActive: boolean;
onClick: () => void;
}> = React.memo(({ icon, label, isActive, onClick }) => (
}> = React.memo(({ id, icon, label, isActive, onClick }) => (
<button
type="button"
data-testid={`settings-nav-${id}`}
onClick={onClick}
aria-current={isActive ? 'page' : undefined}
className={`flex items-center flex-shrink-0 md:flex-shrink md:w-full px-3 py-2 text-left rounded-md transition-colors whitespace-nowrap md:whitespace-normal ${isActive ? 'bg-[var(--nav-background-active)] text-[var(--nav-text-active)]' : 'hover:bg-[var(--nav-background-hover)] text-[var(--sc-text-secondary)] hover:text-[var(--sc-text-primary)]'}`}
Expand Down Expand Up @@ -365,6 +368,7 @@ const SettingsViewUI: FC = () => {
filteredNavCategories.map((cat) => (
<NavButton
key={cat.id}
id={cat.id}
icon={cat.icon}
label={cat.label}
isActive={activeCategory === cat.id}
Expand All @@ -379,6 +383,7 @@ const SettingsViewUI: FC = () => {
.map((cat) => (
<NavButton
key={cat.id}
id={cat.id}
icon={cat.icon}
label={cat.label}
isActive={activeCategory === cat.id}
Expand All @@ -396,6 +401,7 @@ const SettingsViewUI: FC = () => {
{groupCats.map((cat) => (
<NavButton
key={cat.id}
id={cat.id}
icon={cat.icon}
label={cat.label}
isActive={activeCategory === cat.id}
Expand Down
8 changes: 6 additions & 2 deletions components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@ const BottomTabItem: React.FC<{
isActive: boolean;
onClick: () => void;
sectionId?: string;
}> = React.memo(({ icon, label, isActive, onClick, sectionId }) => {
// QNBS-v3: stable data-tour anchor lets E2E recovery navigation find this button without matching translated label text
dataTour?: string;
}> = React.memo(({ icon, label, isActive, onClick, sectionId, dataTour }) => {
// QNBS-v3: colored icon dot for mobile tab bar via section SSOT
const sectionConfig = sectionId ? APP_SECTIONS[sectionId as keyof typeof APP_SECTIONS] : null;
const iconColor = sectionConfig && !isActive ? sectionConfig.textColor : '';
Expand All @@ -86,6 +88,7 @@ const BottomTabItem: React.FC<{
<button
type="button"
onClick={onClick}
data-tour={dataTour}
className={`relative flex flex-col items-center justify-center flex-1 min-h-[44px] py-2 transition-colors duration-200 touch-manipulation outline-none focus-visible:ring-2 focus-visible:ring-[var(--sc-ring-focus)] rounded-lg ${
isActive ? 'text-[var(--nav-text-active)]' : 'text-[var(--sc-text-muted)]'
}`}
Expand Down Expand Up @@ -201,6 +204,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
label={t('common.more')}
isActive={isSidebarOpen || !isTabBarView}
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
dataTour="nav-more"
/>
</nav>

Expand Down Expand Up @@ -299,4 +303,4 @@ export const Sidebar: React.FC<SidebarProps> = ({
</aside>
</>
);
};
};
2 changes: 2 additions & 0 deletions components/settings/DataSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -418,9 +418,11 @@ export const DataSection: FC = () => {
{t('settings.data.dangerZone.factoryReset.hint')}
</p>
</div>
{/* QNBS-v3: stable data-testid lets E2E recovery navigation target this button without matching translated label text */}
<Button
variant="danger"
size="sm"
data-testid="factory-reset-button"
onClick={() => setModal({ state: 'factoryReset', payload: {} })}
className="shrink-0"
>
Expand Down
7 changes: 6 additions & 1 deletion components/settings/SettingsModals.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,12 @@ export const SettingsModals: FC = () => {
<Button variant="secondary" onClick={() => setModal({ state: 'closed', payload: {} })}>
{t('common.cancel')}
</Button>
<Button variant="danger" onClick={() => void handleFactoryReset()}>
{/* QNBS-v3: stable data-testid lets E2E recovery navigation target this button without matching translated label text */}
<Button
variant="danger"
onClick={() => void handleFactoryReset()}
data-testid="factory-reset-confirm-button"
>
{t('settings.data.dangerZone.factoryReset.modalConfirm')}
</Button>
</div>
Expand Down
62 changes: 37 additions & 25 deletions tests/e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,21 @@ export async function clickNavItem(page: Page, name: RegExp): Promise<void> {
await page.locator('#sidebar-mobile').getByRole('button', { name }).click();
}

/** Locale-independent Settings navigation: same mobile-aware fallback as clickNavItem, keyed on the stable `data-tour="nav-settings"` anchor instead of translated visible text. */
async function clickSettingsNavItem(page: Page): Promise<void> {
const desktopBtn = page.locator('#sidebar [data-tour="nav-settings"]');
if (await desktopBtn.isVisible({ timeout: 1500 }).catch(() => false)) {
await desktopBtn.click();
return;
}
// QNBS-v3: data-tour="nav-more" (not a translated /More/i label) so this stays locale-independent on a non-English mobile boot.
const moreBtn = page.locator('[data-tour="nav-more"]');
await expect(moreBtn).toBeVisible({ timeout: 8000 });
await moreBtn.click();
await page.locator('#sidebar-mobile').waitFor({ state: 'visible' });
await page.locator('#sidebar-mobile [data-tour="nav-settings"]').click();
}

// QNBS-v3: Stable Writer `#writer-section-select` + option handling avoids Playwright strict-mode / native-<option> visibility pitfalls that broke CI E2E.

/** Writer section `<Select>` — stable id to avoid picking tone/tool comboboxes elsewhere on the page. */
Expand Down Expand Up @@ -147,6 +162,19 @@ export async function waitForMainChrome(page: Page): Promise<void> {
]);
}

/** QNBS-v3: explicit discriminated startup state, not boolean soup — repeatedly asking "is the portal visible?" via isVisible().catch(()=>false) can't distinguish "main chrome" from "still loading" and silently swallows genuine errors as false. */
export type StartupState = 'WELCOME_PORTAL' | 'MAIN_CHROME';

/** Resolves which of waitForSpaReady()'s two shapes the current document actually reached. */
export async function resolveStartupState(page: Page): Promise<StartupState> {
await waitForSpaReady(page);
const portal = page.getByTestId('welcome-portal');
if (await portal.isVisible().catch(() => false)) {
return 'WELCOME_PORTAL';
}
return 'MAIN_CHROME';
}

/** Language toggle on the welcome portal (EN must be active for English copy in assertions). */
export async function selectEnglish(page: Page): Promise<void> {
const enBtn = page.getByRole('button', { name: /^EN$/i }).first();
Expand Down Expand Up @@ -179,36 +207,20 @@ export async function ensureBlankProject(page: Page): Promise<void> {
* waitForSpaReady()'s two success shapes the app actually booted into. A cold CI boot has landed
* in an already-mounted main shell with a persisted project instead of the portal — a startup-
* state precondition gap distinct from the (fixed) portal-activation auto-seed race.
* Contract: guarantees the portal is reached, locale-independently — it does NOT guarantee
* English. A caller needing English selects it itself (export.spec.ts already does this for the
* fresh-boot case). Recovers via the real Settings → Data & Backups → Factory Reset flow when
* main chrome is active so no React/Redux/storage internals are touched — only supported app
* behavior.
* Contract: guarantees the portal is reached, locale-independently. Recovers via the real
* Settings → Data & Backups → Factory Reset flow when main chrome is active so no React/Redux/
* storage internals are touched — only supported app behavior.
*/
export async function ensureWelcomePortalEntry(page: Page): Promise<void> {
await waitForSpaReady(page);
const portal = page.getByTestId('welcome-portal');
if (await portal.isVisible({ timeout: 3000 }).catch(() => false)) {
if ((await resolveStartupState(page)) === 'WELCOME_PORTAL') {
return;
}
// QNBS-v3: force English before the locale-dependent recovery flow below, or a persisted non-EN/DE language would hang it.
await page.evaluate(() => localStorage.setItem('worldscript-language', 'en'));
await page.reload();
await waitForSpaReady(page);
// 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.
if (await portal.isVisible({ timeout: 3000 }).catch(() => false)) {
return;
}
await waitForMainChrome(page);
await clickNavItem(page, /Settings/i);
await page
.getByRole('button', { name: /Data & Backups|Daten & Backups/i })
.first()
.click();
await page.getByRole('button', { name: /Factory Reset|Werkseinstellungen/i }).click();
await page
.getByRole('button', { name: /Delete everything & restart|Alles löschen & neu starten/i })
.click();
// QNBS-v3: every step below uses a stable data-tour/data-testid anchor, never translated text — Playwright's own docs say addInitScript execution order across registrations is unspecified, so this cannot rely on forcing a language first.
await clickSettingsNavItem(page);
await page.getByTestId('settings-nav-data').click();
await page.getByTestId('factory-reset-button').click();
await page.getByTestId('factory-reset-confirm-button').click();
await waitForSpaReady(page);
await expect(portal).toBeVisible({ timeout: 15000 });
}
Expand Down
16 changes: 16 additions & 0 deletions tests/e2e/onboarding-entry-precondition.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,22 @@ test.describe('WelcomePortal entry precondition (CI-only)', () => {
await expect(page.getByRole('button', { name: /Start a New Project/i })).toBeVisible();
});

test('reaches the entry point via the recovery flow with a persisted non-English language, on Mobile Chrome and desktop alike', async ({
page,
}) => {
// QNBS-v3: a fresh boot lands on the portal regardless of locale — this combines a persisted main-chrome project with a non-English language so a mobile "More"-button locale regression actually fails, on every project including Mobile Chrome.
await page.goto('/');
await ensureBlankProject(page);
await expect(page.getByText(/All changes saved/i)).toBeVisible({ timeout: 10000 });
await page.addInitScript(() => localStorage.setItem('worldscript-language', 'es'));
await page.reload();
await waitForMainChrome(page);
// QNBS-v3: asserts the applied locale, not just the persisted seed — a broken addInitScript or a failed es bundle load could otherwise pass this test vacuously in English.
await expect(page.locator('html')).toHaveAttribute('lang', 'es');
await ensureWelcomePortalEntry(page);
Comment thread
qnbs marked this conversation as resolved.
await expect(page.getByTestId('welcome-portal')).toBeVisible();
});

test('reaches the entry point when its own internal reload can race a pending autosave', async ({
page,
}) => {
Expand Down
Loading