Skip to content

Commit 8c94c29

Browse files
test(startup): add FOUC regression tests and backlog task (task-298)
Add 9 E2E tests verifying the three-stage reveal mechanism prevents unstyled content during app startup. Tests cover x-cloak CSS presence, visibility before Alpine init, removal timing, theme pre-application, and Tauri window config. Create backlog task-298 referencing prior fixes in task-250, task-254, task-256. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7fe23ad commit 8c94c29

2 files changed

Lines changed: 451 additions & 0 deletions

File tree

Lines changed: 372 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,372 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForAlpine } from './fixtures/helpers.js';
3+
import { createLibraryState, setupLibraryMocks } from './fixtures/mock-library.js';
4+
5+
/**
6+
* Startup FOUC (Flash of Unstyled Content) Regression Tests (task-298)
7+
*
8+
* Verifies that the three-stage reveal mechanism prevents any unstyled
9+
* content from being visible during app startup:
10+
* 1. body[x-cloak] hides content via visibility: hidden
11+
* 2. Theme classes pre-applied to <html> before Alpine starts
12+
* 3. x-cloak removed only after Alpine.start() completes
13+
*
14+
* Related: task-250, task-256, commits 1b78d94c, e564b540, 2f35425d
15+
*/
16+
test.describe('Startup FOUC Prevention (task-298)', () => {
17+
test.describe('x-cloak CSS rules', () => {
18+
test('body[x-cloak] rule must exist in inline styles before any external CSS loads', async ({ page }) => {
19+
// Intercept the page before it loads to check inline styles
20+
let inlineStyleContent = '';
21+
22+
await page.route('/', async (route) => {
23+
const response = await route.fetch();
24+
const html = await response.text();
25+
inlineStyleContent = html;
26+
await route.fulfill({ response });
27+
});
28+
29+
const libraryState = createLibraryState();
30+
await setupLibraryMocks(page, libraryState);
31+
32+
await page.route(/\/api\/lastfm\/settings/, async (route) => {
33+
await route.fulfill({
34+
status: 200,
35+
contentType: 'application/json',
36+
body: JSON.stringify({
37+
enabled: false,
38+
username: null,
39+
authenticated: false,
40+
configured: false,
41+
scrobble_threshold: 50,
42+
}),
43+
});
44+
});
45+
46+
await page.goto('/');
47+
48+
// Verify the inline <style> tag contains the critical x-cloak rules
49+
expect(inlineStyleContent).toContain('body[x-cloak]');
50+
expect(inlineStyleContent).toContain('visibility: hidden');
51+
52+
// The x-cloak CSS must appear in a <style> tag in <head>, not in an
53+
// external stylesheet, so it applies before any network requests complete
54+
const styleTagPosition = inlineStyleContent.indexOf('<style>');
55+
const linkTagPosition = inlineStyleContent.indexOf('<link');
56+
57+
// Inline style must exist
58+
expect(styleTagPosition).toBeGreaterThan(-1);
59+
60+
// If there are <link> tags for external CSS, inline style must come first
61+
if (linkTagPosition > -1) {
62+
expect(styleTagPosition).toBeLessThan(linkTagPosition);
63+
}
64+
});
65+
66+
test('body has x-cloak attribute in initial HTML', async ({ page }) => {
67+
const libraryState = createLibraryState();
68+
await setupLibraryMocks(page, libraryState);
69+
70+
await page.route(/\/api\/lastfm\/settings/, async (route) => {
71+
await route.fulfill({
72+
status: 200,
73+
contentType: 'application/json',
74+
body: JSON.stringify({
75+
enabled: false,
76+
username: null,
77+
authenticated: false,
78+
configured: false,
79+
scrobble_threshold: 50,
80+
}),
81+
});
82+
});
83+
84+
// Check body has x-cloak before JS runs by using addInitScript
85+
let hadCloakBeforeJS = false;
86+
await page.addInitScript(() => {
87+
// Store whether body has x-cloak at the earliest possible moment
88+
document.addEventListener('DOMContentLoaded', () => {
89+
window._testBodyHadCloak = document.body.hasAttribute('x-cloak');
90+
}, { once: true });
91+
});
92+
93+
await page.goto('/');
94+
await waitForAlpine(page);
95+
96+
hadCloakBeforeJS = await page.evaluate(() => window._testBodyHadCloak);
97+
expect(hadCloakBeforeJS).toBe(true);
98+
});
99+
100+
test('body[x-cloak] computed visibility is hidden before Alpine initializes', async ({ page }) => {
101+
const libraryState = createLibraryState();
102+
await setupLibraryMocks(page, libraryState);
103+
104+
await page.route(/\/api\/lastfm\/settings/, async (route) => {
105+
await route.fulfill({
106+
status: 200,
107+
contentType: 'application/json',
108+
body: JSON.stringify({
109+
enabled: false,
110+
username: null,
111+
authenticated: false,
112+
configured: false,
113+
scrobble_threshold: 50,
114+
}),
115+
});
116+
});
117+
118+
// Capture computed visibility before Alpine starts
119+
await page.addInitScript(() => {
120+
document.addEventListener('DOMContentLoaded', () => {
121+
const style = window.getComputedStyle(document.body);
122+
window._testBodyVisibilityBeforeAlpine = style.visibility;
123+
window._testBodyDisplayBeforeAlpine = style.display;
124+
}, { once: true });
125+
});
126+
127+
await page.goto('/');
128+
await waitForAlpine(page);
129+
130+
const visibility = await page.evaluate(() => window._testBodyVisibilityBeforeAlpine);
131+
const display = await page.evaluate(() => window._testBodyDisplayBeforeAlpine);
132+
133+
// Body must be hidden (visibility: hidden) but not removed from layout (display: block)
134+
expect(visibility).toBe('hidden');
135+
expect(display).toBe('block');
136+
});
137+
});
138+
139+
test.describe('x-cloak removal timing', () => {
140+
test.beforeEach(async ({ page }) => {
141+
const libraryState = createLibraryState();
142+
await setupLibraryMocks(page, libraryState);
143+
144+
await page.route(/\/api\/lastfm\/settings/, async (route) => {
145+
await route.fulfill({
146+
status: 200,
147+
contentType: 'application/json',
148+
body: JSON.stringify({
149+
enabled: false,
150+
username: null,
151+
authenticated: false,
152+
configured: false,
153+
scrobble_threshold: 50,
154+
}),
155+
});
156+
});
157+
});
158+
159+
test('x-cloak is removed after Alpine.start() completes', async ({ page }) => {
160+
// Track when x-cloak is removed relative to Alpine availability
161+
await page.addInitScript(() => {
162+
window._testCloakRemovedBeforeAlpine = null;
163+
164+
const observer = new MutationObserver((mutations) => {
165+
for (const mutation of mutations) {
166+
if (
167+
mutation.type === 'attributes' &&
168+
mutation.attributeName === 'x-cloak' &&
169+
mutation.target === document.body &&
170+
!document.body.hasAttribute('x-cloak')
171+
) {
172+
// Record whether Alpine was ready when x-cloak was removed
173+
window._testCloakRemovedBeforeAlpine = !(window.Alpine && window.Alpine.store);
174+
observer.disconnect();
175+
}
176+
}
177+
});
178+
179+
document.addEventListener('DOMContentLoaded', () => {
180+
observer.observe(document.body, { attributes: true });
181+
}, { once: true });
182+
});
183+
184+
await page.goto('/');
185+
await waitForAlpine(page);
186+
187+
// Wait for revealApp to have run (it uses setTimeout(0) after Alpine.start)
188+
await page.waitForFunction(() => !document.body.hasAttribute('x-cloak'));
189+
190+
const removedBeforeAlpine = await page.evaluate(() => window._testCloakRemovedBeforeAlpine);
191+
expect(removedBeforeAlpine).toBe(false);
192+
});
193+
194+
test('x-cloak is not present after app is fully loaded', async ({ page }) => {
195+
await page.goto('/');
196+
await waitForAlpine(page);
197+
198+
// Wait for reveal
199+
await page.waitForFunction(() => !document.body.hasAttribute('x-cloak'));
200+
201+
const hasCloak = await page.evaluate(() => document.body.hasAttribute('x-cloak'));
202+
expect(hasCloak).toBe(false);
203+
204+
// Body should be visible
205+
const visibility = await page.evaluate(() =>
206+
window.getComputedStyle(document.body).visibility
207+
);
208+
expect(visibility).toBe('visible');
209+
});
210+
});
211+
212+
test.describe('theme pre-application', () => {
213+
test.beforeEach(async ({ page }) => {
214+
const libraryState = createLibraryState();
215+
await setupLibraryMocks(page, libraryState);
216+
217+
await page.route(/\/api\/lastfm\/settings/, async (route) => {
218+
await route.fulfill({
219+
status: 200,
220+
contentType: 'application/json',
221+
body: JSON.stringify({
222+
enabled: false,
223+
username: null,
224+
authenticated: false,
225+
configured: false,
226+
scrobble_threshold: 50,
227+
}),
228+
});
229+
});
230+
});
231+
232+
test('html element has a theme class before x-cloak is removed', async ({ page }) => {
233+
// Track whether <html> has theme class at the moment x-cloak is removed
234+
await page.addInitScript(() => {
235+
window._testHtmlClassesAtReveal = null;
236+
237+
const observer = new MutationObserver((mutations) => {
238+
for (const mutation of mutations) {
239+
if (
240+
mutation.type === 'attributes' &&
241+
mutation.attributeName === 'x-cloak' &&
242+
mutation.target === document.body &&
243+
!document.body.hasAttribute('x-cloak')
244+
) {
245+
window._testHtmlClassesAtReveal = document.documentElement.className;
246+
observer.disconnect();
247+
}
248+
}
249+
});
250+
251+
document.addEventListener('DOMContentLoaded', () => {
252+
observer.observe(document.body, { attributes: true });
253+
}, { once: true });
254+
});
255+
256+
await page.goto('/');
257+
await waitForAlpine(page);
258+
await page.waitForFunction(() => !document.body.hasAttribute('x-cloak'));
259+
260+
const htmlClasses = await page.evaluate(() => window._testHtmlClassesAtReveal);
261+
// At reveal time, <html> must have either 'light' or 'dark' class
262+
const hasTheme = htmlClasses.includes('light') || htmlClasses.includes('dark');
263+
expect(hasTheme).toBe(true);
264+
});
265+
});
266+
267+
test.describe('no visible unstyled content', () => {
268+
test.beforeEach(async ({ page }) => {
269+
const libraryState = createLibraryState();
270+
await setupLibraryMocks(page, libraryState);
271+
272+
await page.route(/\/api\/lastfm\/settings/, async (route) => {
273+
await route.fulfill({
274+
status: 200,
275+
contentType: 'application/json',
276+
body: JSON.stringify({
277+
enabled: false,
278+
username: null,
279+
authenticated: false,
280+
configured: false,
281+
scrobble_threshold: 50,
282+
}),
283+
});
284+
});
285+
});
286+
287+
test('all x-cloak elements are hidden before Alpine initializes', async ({ page }) => {
288+
// Check that general [x-cloak] rule hides all cloaked elements
289+
await page.addInitScript(() => {
290+
document.addEventListener('DOMContentLoaded', () => {
291+
const cloaked = document.querySelectorAll('[x-cloak]');
292+
window._testCloakedElementsVisible = [];
293+
294+
cloaked.forEach((el) => {
295+
const style = window.getComputedStyle(el);
296+
// body uses visibility:hidden, other elements use display:none
297+
const isVisible = el === document.body
298+
? style.visibility !== 'hidden'
299+
: style.display !== 'none';
300+
301+
if (isVisible) {
302+
window._testCloakedElementsVisible.push({
303+
tag: el.tagName,
304+
id: el.id,
305+
display: style.display,
306+
visibility: style.visibility,
307+
});
308+
}
309+
});
310+
}, { once: true });
311+
});
312+
313+
await page.goto('/');
314+
await waitForAlpine(page);
315+
316+
const visibleCloaked = await page.evaluate(() => window._testCloakedElementsVisible);
317+
expect(visibleCloaked).toEqual([]);
318+
});
319+
320+
test('bg-background class produces a valid background color at reveal time', async ({ page }) => {
321+
// Ensure body has a real background color (not transparent/default white) when revealed
322+
await page.addInitScript(() => {
323+
window._testBgColorAtReveal = null;
324+
325+
const observer = new MutationObserver((mutations) => {
326+
for (const mutation of mutations) {
327+
if (
328+
mutation.type === 'attributes' &&
329+
mutation.attributeName === 'x-cloak' &&
330+
mutation.target === document.body &&
331+
!document.body.hasAttribute('x-cloak')
332+
) {
333+
window._testBgColorAtReveal = window.getComputedStyle(document.body).backgroundColor;
334+
observer.disconnect();
335+
}
336+
}
337+
});
338+
339+
document.addEventListener('DOMContentLoaded', () => {
340+
observer.observe(document.body, { attributes: true });
341+
}, { once: true });
342+
});
343+
344+
await page.goto('/');
345+
await waitForAlpine(page);
346+
await page.waitForFunction(() => !document.body.hasAttribute('x-cloak'));
347+
348+
const bgColor = await page.evaluate(() => window._testBgColorAtReveal);
349+
// Background must be a real color, not transparent or the browser default
350+
expect(bgColor).not.toBe('rgba(0, 0, 0, 0)');
351+
expect(bgColor).toBeTruthy();
352+
});
353+
});
354+
355+
test.describe('Tauri window configuration', () => {
356+
test('tauri.conf.json has visible: false for startup window', async () => {
357+
// This is a static config check - read the Tauri config directly
358+
// (runs in Node.js context via Playwright)
359+
const fs = await import('node:fs');
360+
const path = await import('node:path');
361+
362+
const configPath = path.resolve(
363+
import.meta.dirname,
364+
'../../../crates/mt-tauri/tauri.conf.json',
365+
);
366+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
367+
368+
const mainWindow = config.app.windows[0];
369+
expect(mainWindow.visible).toBe(false);
370+
});
371+
});
372+
});

0 commit comments

Comments
 (0)