-
Notifications
You must be signed in to change notification settings - Fork 7
Expand file tree
/
Copy pathhelpers.ts
More file actions
262 lines (245 loc) Β· 13.1 KB
/
Copy pathhelpers.ts
File metadata and controls
262 lines (245 loc) Β· 13.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
// QNBS-v3: clickNavItem β sidebar(page) targets #sidebar which is hidden md:flex; fails on Mobile Chrome (Pixel 5)
/**
* Mobile-aware navigation: tries desktop sidebar β mobile tab bar β mobile "More" sheet.
*/
export async function clickNavItem(page: Page, name: RegExp): Promise<void> {
// 1. Desktop sidebar (md+ viewports) β #sidebar is hidden md:flex, visible only on desktop
const desktopBtn = page.locator('#sidebar').getByRole('button', { name });
if (await desktopBtn.isVisible({ timeout: 1500 }).catch(() => false)) {
await desktopBtn.click();
return;
}
// 2. Mobile bottom tab bar (dashboard / manuscript / writer / sceneboard)
const mobileTabBtn = page.locator('[data-tour="nav-mobile"]').getByRole('button', { name });
if (await mobileTabBtn.isVisible({ timeout: 1500 }).catch(() => false)) {
await mobileTabBtn.click();
return;
}
// 3. Mobile "More" sheet (Settings, Outline, Export⦠not in the 5-item tab bar)
// QNBS-v3: assert the More button is actually clickable before clicking. A floating overlay
// (e.g. the Copilot FAB) sitting on top would make .click() time out with an opaque
// "intercepts pointer events" trace; toBeVisible gives a clear failure and absorbs small races.
const moreBtn = page.locator('[data-tour="nav-mobile"]').getByRole('button', { name: /More/i });
await expect(moreBtn).toBeVisible({ timeout: 8000 });
await moreBtn.click();
await page.locator('#sidebar-mobile').waitFor({ state: 'visible' });
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. */
export function writerSectionSelect(page: Page) {
// QNBS-v3: ContextPanel exists in both mobile tab panel and always-rendered desktop grid (hidden
// md:grid); first() picks the mobile-panel instance when context tab is active (it is first in DOM
// order), and the sole desktop instance otherwise β prevents strict-mode violation from duplicate IDs.
return page.locator('#writer-section-select').first();
}
/**
* QNBS-v3: Select is now a custom dropdown (button + listbox), not native <select>.
* Click trigger to open dropdown, then click first enabled option.
*/
export async function selectFirstEnabledWriterSection(page: Page): Promise<void> {
// Writer view is lazy-loaded; wait for writer-tab-context to attach before checking
// viewport visibility β 2s was too short for CI Mobile Chrome with a cold bundle load.
const contextTab = page.getByTestId('writer-tab-context');
await contextTab.waitFor({ state: 'attached', timeout: 20000 }).catch(() => {});
let sel: import('@playwright/test').Locator;
// md:hidden means visible on <768px (mobile) and hidden on β₯768px (desktop)
if (await contextTab.isVisible({ timeout: 500 }).catch(() => false)) {
if ((await contextTab.getAttribute('aria-selected')) !== 'true') {
await contextTab.click();
}
// After clicking context tab on mobile, ContextPanel renders in BOTH the mobile tab panel
// (#writer-panel-context, visible) and the always-rendered desktop grid (hidden md:grid, display:none).
// Wait for the mobile panel to mount, then scope the select to it to avoid picking the hidden one.
await page.locator('#writer-panel-context').waitFor({ state: 'visible' });
sel = page.locator('#writer-panel-context #writer-section-select');
} else {
sel = writerSectionSelect(page);
}
await expect(sel).toBeVisible();
// QNBS-v3: Select is a custom dropdown (button + listbox), not native <select>.
// Click trigger to open dropdown, then select first enabled option.
await sel.click();
const container = sel.locator('xpath=..');
const listbox = container.locator('[role="listbox"]');
await expect(listbox).toBeVisible({ timeout: 5000 });
const options = listbox.locator('[role="option"]:not([disabled])');
await expect.poll(async () => options.count()).toBeGreaterThan(0);
// Use direct DOM click to avoid "subtree intercepts pointer events" when
// the dropdown is overlapped by other elements (e.g. textarea in Writer view).
await options.first().evaluate((el) => (el as HTMLElement).click());
}
/** Outline / AI flows call Gemini only when a key exists in encrypted storage β seed before mocked HTTP in CI. */
/** DebouncedTextarea notifies Redux after ~750ms β flush before leaving Writer or export previews stay stale in CI. */
export async function flushWriterDebounce(page: Page): Promise<void> {
await page.waitForTimeout(850);
}
export async function seedGeminiApiKey(page: Page): Promise<void> {
// QNBS-v3: clickNavItem β sidebar(page) is hidden md:flex, fails on Mobile Chrome
await clickNavItem(page, /Settings/i);
// QNBS-v3: .first() β SettingsOverviewCard also renders an AI Configuration quick-access button
await page
.getByRole('button', { name: /AI Configuration|KI-Konfiguration/i })
.first()
.click();
await page
.getByLabel(/Enter your Gemini API Key|Geben Sie Ihren Gemini API-SchlΓΌssel ein/i)
.fill('AIzaTestKey123456789012345678901234');
await page.getByRole('button', { name: /Save Key|Speichern/i }).click();
// QNBS-v3: anchor the saved-state badge so API Key Configured cannot violate Playwright strict mode.
await expect(page.getByText(/^(Configured|Konfiguriert)$/i)).toBeVisible({ timeout: 15000 });
await page.keyboard.press('Escape');
// QNBS-v3: disable default local-only mode through role=switch so Gemini reaches the mocked cloud route.
await page.getByRole('button', { name: /Privacy & Security/i }).click();
const localOnlyToggle = page.getByRole('switch', { name: /Local Storage Only/i });
const isOn = await localOnlyToggle.getAttribute('aria-checked').catch(() => null);
if (isOn === 'true') {
await localOnlyToggle.click();
}
}
/**
* Vite dev server keeps the HMR/WebSocket busy β `networkidle` often never settles.
* Wait for either the welcome portal or the desktop/mobile sidebar shell.
* QNBS-v3: Also waits for the body theme class to be applied by the App useEffect so
* that CSS custom properties (--sc-text-primary, etc.) are fully resolved before
* axe or visual checks run β without this, variables resolve to intermediate values.
*/
export async function waitForSpaReady(page: Page): Promise<void> {
await page.waitForLoadState('domcontentloaded');
// QNBS-v3: welcome-portal is a stable data-testid, not the translated button label β a non-English boot no longer times out this race.
await Promise.race([
page.locator('#sidebar').waitFor({ state: 'visible', timeout: 25000 }),
page.locator('[data-tour="nav-mobile"]').waitFor({ state: 'visible', timeout: 25000 }),
page.getByTestId('welcome-portal').waitFor({ state: 'visible', timeout: 25000 }),
]);
// QNBS-v3: theme class is applied in App useEffect after first render β wait for it so
// CSS variable values are stable (avoids axe false-positives on mid-transition colors).
// appearance-sepia stacks with light-theme/dark-theme, not a standalone class.
await page
.waitForFunction(
() =>
document.body.classList.contains('light-theme') ||
document.body.classList.contains('dark-theme'),
{ timeout: 5000 },
)
.catch(() => {}); // best-effort: if no theme class, continue anyway
}
/**
* Main shell is ready (desktop sidebar or mobile bottom tab bar). Exported so callers proving
* "main shell is visible" don't reach for `locA.or(locB)` β both elements exist in the DOM at
* once (only one is CSS-visible per viewport), which trips Playwright's strict-mode violation.
*/
export async function waitForMainChrome(page: Page): Promise<void> {
await Promise.race([
page.locator('#sidebar').waitFor({ state: 'visible', timeout: 25000 }),
page.locator('[data-tour="nav-mobile"]').waitFor({ state: 'visible', timeout: 25000 }),
]);
}
/** 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();
if (await enBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
await enBtn.click();
}
}
/**
* Exit WelcomePortal with a blank project so `#sidebar` and main views are reachable.
*/
export async function ensureBlankProject(page: Page): Promise<void> {
await waitForSpaReady(page);
const startBtn = page.getByRole('button', { name: /Start a New Project/i });
if (await startBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
await startBtn.click();
await page
.getByRole('button', { name: /Blank Manuscript/i })
.first()
.click();
await waitForMainChrome(page);
return;
}
await waitForMainChrome(page);
}
// QNBS-v3: guarantees a deterministic WelcomePortal entry precondition when CI cold-boots into the main shell instead.
/**
* Deterministically reach the WelcomePortal entry point regardless of which of
* 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. 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> {
const portal = page.getByTestId('welcome-portal');
if ((await resolveStartupState(page)) === 'WELCOME_PORTAL') {
return;
}
// 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 });
}
/** Desktop sidebar (`md:`); avoids duplicate nav controls vs mobile tab bar. */
export function sidebar(page: Page) {
return page.locator('#sidebar');
}
/**
* Seed feature-flag overrides into localStorage so the Redux slice picks them up on init.
*
* MUST be called BEFORE page.goto() β uses addInitScript so it runs before any app JS.
* Only the specified keys are overridden; all other flags keep their slice defaults.
* The storage key is 'worldscript-feature-flags' (mirrors featureFlagsSlice.ts).
*
* Example:
* await setFeatureFlags(page, { enableProForge: true });
* await page.goto('/');
*/
export async function setFeatureFlags(
page: Page,
flags: Partial<Record<string, boolean>>,
): Promise<void> {
await page.addInitScript((overrides) => {
try {
const stored = localStorage.getItem('worldscript-feature-flags');
const existing: Record<string, boolean> = stored
? (JSON.parse(stored) as Record<string, boolean>)
: {};
localStorage.setItem(
'worldscript-feature-flags',
JSON.stringify({ ...existing, ...overrides }),
);
} catch {
/* storage unavailable β slice falls back to defaults */
}
}, flags);
}