GenerAI / worldmonitor /e2e /prehydration-shell.spec.ts
amogaddy's picture
Fix build: ripristinate blog-site/tests/e2e/pro-test/convex (referenziate dagli script di build) (part 2)
97ee7cb verified
Raw
History Blame Contribute Delete
20.4 kB
import { devices, expect, test, type Page } from '@playwright/test';
type PaintEntrySnapshot = {
name: string;
startTime: number;
};
type LcpEntrySnapshot = {
className: string;
inShell: boolean;
size: number;
startTime: number;
text: string;
};
const { defaultBrowserType: mobileDefaultBrowserType, ...mobileDevice } = devices['iPhone 14 Pro Max'];
void mobileDefaultBrowserType;
const SHELL_LCP_TEXT = 'Dashboard is loading';
declare global {
interface Window {
__wmPaintEntries?: PaintEntrySnapshot[];
__wmLcpEntries?: LcpEntrySnapshot[];
__wmWelcomeHydrationDispatched?: boolean;
__wmWelcomeRootClearCount?: number;
}
}
const installPaintObservers = async (page: Page): Promise<void> => {
await page.addInitScript(() => {
localStorage.setItem('wm-layer-warning-dismissed', 'true');
localStorage.setItem('wm-pro-banner-launched-dismissed', String(Date.now()));
localStorage.setItem('worldmonitor-mission-preset-dismissed-v1', '1');
window.__wmPaintEntries = [];
window.__wmLcpEntries = [];
try {
const paintObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
window.__wmPaintEntries?.push({
name: entry.name,
startTime: entry.startTime,
});
}
});
paintObserver.observe({ type: 'paint', buffered: true });
} catch {
// Older engines without paint timing still exercise the DOM candidate below.
}
try {
const lcpObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries() as PerformanceEntry[]) {
const lcp = entry as PerformanceEntry & {
element?: Element;
size?: number;
};
window.__wmLcpEntries?.push({
className: typeof lcp.element?.className === 'string' ? lcp.element.className : '',
inShell: Boolean(lcp.element?.closest('.skeleton-shell')),
size: lcp.size ?? 0,
startTime: lcp.startTime,
text: lcp.element?.textContent?.replace(/\s+/g, ' ').trim().slice(0, 140) ?? '',
});
}
});
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
} catch {
// WebKit/older engines may not expose LCP, so FCP + visible candidate is the hard gate.
}
});
};
const delayDashboardMain = async (page: Page): Promise<{ release: () => void; requested: Promise<void> }> => {
let releaseMain!: () => void;
let resolveRequested!: () => void;
const releasePromise = new Promise<void>((resolve) => {
releaseMain = resolve;
});
const requested = new Promise<void>((resolve) => {
resolveRequested = resolve;
});
await page.route('**/src/main.ts', async (route) => {
resolveRequested();
await releasePromise;
await route.continue();
});
return { release: releaseMain, requested };
};
const delayWelcomeMain = async (page: Page): Promise<{ release: () => void; requested: Promise<void> }> => {
let releaseMain!: () => void;
let resolveRequested!: () => void;
const releasePromise = new Promise<void>((resolve) => {
releaseMain = resolve;
});
const requested = new Promise<void>((resolve) => {
resolveRequested = resolve;
});
await page.route('**/pro/assets/welcome-*.js', async (route) => {
resolveRequested();
await releasePromise;
await route.continue();
});
return { release: releaseMain, requested };
};
test.describe('pre-hydration dashboard shell', () => {
test.beforeEach(async ({ page }) => {
await installPaintObservers(page);
});
test('paints contentful HTML before the dashboard bundle hydrates', async ({ page }) => {
const pageErrors: string[] = [];
page.on('pageerror', (error) => pageErrors.push(error.message));
const delayedMain = await delayDashboardMain(page);
let released = false;
const releaseMain = () => {
if (released) return;
released = true;
delayedMain.release();
};
try {
await page.goto('/', { waitUntil: 'commit' });
await delayedMain.requested;
await expect(page.locator('.skeleton-shell')).toBeVisible();
await page.evaluate(() => new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}));
await expect.poll(async () => page.evaluate(() => (
performance.getEntriesByName('first-contentful-paint').length
+ (window.__wmPaintEntries ?? []).filter((entry) => entry.name === 'first-contentful-paint').length
)), {
message: 'first-contentful-paint should fire while the dashboard module is still blocked',
timeout: 5000,
}).toBeGreaterThan(0);
await expect.poll(async () => page.evaluate(() => {
if (document.documentElement.classList.contains('wm-layout-hydrated')) return -1;
return (window.__wmLcpEntries ?? []).filter((entry) => (
entry.inShell
&& entry.size > 0
&& entry.className.includes('skeleton-')
&& entry.text.length > 0
)).length;
}), {
message: 'largest-contentful-paint should be the server-delivered shell before hydration',
timeout: 5000,
}).toBeGreaterThan(0);
const preHydration = await page.evaluate(() => {
const shell = document.querySelector<HTMLElement>('.skeleton-shell');
const candidate = document.querySelector<HTMLElement>('.skeleton-lcp-copy');
const appHeading = document.querySelector<HTMLElement>('body > h1.app-heading');
const badgeGroup = document.querySelector<HTMLElement>('.skeleton-map-badges');
if (!shell || !candidate) {
throw new Error('missing pre-hydration shell content');
}
const rect = candidate.getBoundingClientRect();
const styles = getComputedStyle(candidate);
const fcp = performance.getEntriesByName('first-contentful-paint')[0];
const focusable = shell.querySelectorAll(
'a[href],button,input,select,textarea,[tabindex]:not([tabindex="-1"])',
);
return {
ariaBusy: shell.getAttribute('aria-busy'),
ariaHidden: shell.getAttribute('aria-hidden'),
appHeadingAriaHidden: appHeading?.getAttribute('aria-hidden') ?? null,
appHeadingText: appHeading?.textContent?.replace(/\s+/g, ' ').trim() ?? '',
appHeadingTag: appHeading?.tagName ?? '',
badgeAriaLabel: badgeGroup?.getAttribute('aria-label') ?? null,
candidateRect: {
height: rect.height,
width: rect.width,
x: rect.x,
y: rect.y,
},
candidateText: candidate.textContent?.replace(/\s+/g, ' ').trim() ?? '',
display: styles.display,
fcpStart: fcp?.startTime ?? null,
focusableCount: focusable.length,
hydrated: document.documentElement.classList.contains('wm-layout-hydrated'),
lcpEntries: window.__wmLcpEntries ?? [],
opacity: Number(styles.opacity),
shellText: shell.innerText.replace(/\s+/g, ' ').trim(),
visibility: styles.visibility,
};
});
expect(preHydration.hydrated).toBe(false);
expect(preHydration.ariaHidden).toBeNull();
expect(preHydration.ariaBusy).toBe('true');
expect(preHydration.appHeadingTag).toBe('H1');
expect(preHydration.appHeadingAriaHidden).toBeNull();
expect(preHydration.appHeadingText).toContain('World Monitor');
expect(preHydration.badgeAriaLabel).toBeNull();
expect(preHydration.focusableCount).toBe(0);
expect(preHydration.shellText).toContain('World Monitor');
expect(preHydration.shellText).toContain(SHELL_LCP_TEXT);
expect(preHydration.shellText).toContain('Primary View');
expect(preHydration.candidateText).toBe(SHELL_LCP_TEXT);
expect(preHydration.candidateRect.width).toBeGreaterThan(260);
expect(preHydration.candidateRect.height).toBeGreaterThan(18);
expect(preHydration.display).not.toBe('none');
expect(preHydration.visibility).toBe('visible');
expect(preHydration.opacity).toBeGreaterThan(0.9);
expect(preHydration.fcpStart).not.toBeNull();
const latestLcp = preHydration.lcpEntries.at(-1);
expect(latestLcp, JSON.stringify(preHydration.lcpEntries)).toBeTruthy();
expect(latestLcp!.inShell, JSON.stringify(preHydration.lcpEntries)).toBe(true);
expect(latestLcp!.className).toContain('skeleton-');
expect(latestLcp!.size).toBeGreaterThan(0);
expect(latestLcp!.text.length).toBeGreaterThan(0);
releaseMain();
await expect(page.locator('.header')).toBeVisible({ timeout: 30000 });
await expect(page.locator('.skeleton-shell')).toHaveCount(0);
await expect(page.locator('body > h1.app-heading')).toContainText('World Monitor');
for (const href of [
'/countries/',
'/chokepoints/',
'/crises/',
'/tools/',
'/pro#pricing',
'https://www.worldmonitor.app/blog/',
'https://www.worldmonitor.app/docs',
'https://github.com/koala73/worldmonitor',
]) {
await expect(page.locator(`.site-footer nav a[href="${href}"]`)).toHaveCount(1);
}
for (const href of ['/countries/', '/chokepoints/', '/crises/', '/tools/']) {
await expect(page.locator(`.mobile-menu-footer-links a[href="${href}"]`)).toHaveCount(1);
}
await expect.poll(async () => page.evaluate(() => (
document.documentElement.classList.contains('wm-layout-hydrated')
))).toBe(true);
expect(pageErrors.filter((message) => /hydration|skeleton|layout/i.test(message))).toHaveLength(0);
} finally {
releaseMain();
}
});
});
test.describe('pre-hydration dashboard shell on mobile', () => {
test.use({
...mobileDevice,
viewport: { width: 360, height: 780 },
deviceScaleFactor: 2.625,
});
test.beforeEach(async ({ page }) => {
await installPaintObservers(page);
});
test('keeps the contentful shell inside the mobile viewport before hydration', async ({ page }) => {
const delayedMain = await delayDashboardMain(page);
let released = false;
const releaseMain = () => {
if (released) return;
released = true;
delayedMain.release();
};
try {
await page.goto('/', { waitUntil: 'commit' });
await delayedMain.requested;
await expect(page.locator('.skeleton-shell')).toBeVisible();
await expect(page.locator('.skeleton-map-title')).toBeVisible();
await expect(page.locator('[data-shell-lcp]')).toHaveText(SHELL_LCP_TEXT);
await expect.poll(async () => page.evaluate(() => (
performance.getEntriesByName('first-contentful-paint').length
+ (window.__wmPaintEntries ?? []).filter((entry) => entry.name === 'first-contentful-paint').length
)), {
message: 'mobile first-contentful-paint should fire while the dashboard module is still blocked',
timeout: 5000,
}).toBeGreaterThan(0);
const mobileShell = await page.evaluate(() => {
const title = document.querySelector('.skeleton-map-title');
const panel = document.querySelector('.skeleton-panel');
const titleRect = title?.getBoundingClientRect();
const panelRect = panel?.getBoundingClientRect();
return {
hydrated: document.documentElement.classList.contains('wm-layout-hydrated'),
innerWidth: window.innerWidth,
scrollWidth: document.documentElement.scrollWidth,
titleRect: titleRect ? {
bottom: titleRect.bottom,
height: titleRect.height,
left: titleRect.left,
right: titleRect.right,
top: titleRect.top,
width: titleRect.width,
} : null,
panelRect: panelRect ? {
bottom: panelRect.bottom,
height: panelRect.height,
left: panelRect.left,
right: panelRect.right,
top: panelRect.top,
width: panelRect.width,
} : null,
};
});
expect(mobileShell.hydrated).toBe(false);
expect(mobileShell.scrollWidth).toBeLessThanOrEqual(mobileShell.innerWidth + 1);
expect(mobileShell.titleRect).not.toBeNull();
expect(mobileShell.titleRect!.left).toBeGreaterThanOrEqual(0);
expect(mobileShell.titleRect!.right).toBeLessThanOrEqual(mobileShell.innerWidth);
expect(mobileShell.titleRect!.height).toBeGreaterThan(20);
expect(mobileShell.panelRect).not.toBeNull();
expect(mobileShell.panelRect!.left).toBeGreaterThanOrEqual(0);
expect(mobileShell.panelRect!.right).toBeLessThanOrEqual(mobileShell.innerWidth);
releaseMain();
await expect(page.locator('.header')).toBeVisible({ timeout: 30000 });
await expect(page.locator('.skeleton-shell')).toHaveCount(0);
} finally {
releaseMain();
}
});
test('matches the persisted collapsed-map footprint through hydration', async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem('mobile-map-collapsed', 'true');
});
const delayedMain = await delayDashboardMain(page);
let released = false;
const releaseMain = () => {
if (released) return;
released = true;
delayedMain.release();
};
try {
await page.goto('/', { waitUntil: 'commit' });
await delayedMain.requested;
await expect(page.locator('.skeleton-map')).toBeVisible();
await expect(page.locator('.skeleton-map-body')).toBeHidden();
const shellHeight = await page.locator('.skeleton-map').evaluate((element) => element.getBoundingClientRect().height);
expect(shellHeight).toBeGreaterThan(0);
releaseMain();
await expect(page.locator('#mapSection.collapsed')).toBeVisible({ timeout: 30000 });
await expect(page.locator('.skeleton-shell')).toHaveCount(0);
const hydratedHeight = await page.locator('#mapSection').evaluate((element) => element.getBoundingClientRect().height);
expect(Math.abs(hydratedHeight - shellHeight), 'collapsed map footprint drift').toBeLessThanOrEqual(2);
await expect.poll(async () => page.evaluate(() => (
document.documentElement.classList.contains('wm-map-collapsed')
))).toBe(false);
} finally {
releaseMain();
}
});
});
test.describe('server-rendered welcome page', () => {
test('keeps one visible heading and discoverable navigation after hydration', async ({ page }) => {
await page.goto('/pro/welcome.html', { waitUntil: 'domcontentloaded' });
await expect(page.locator('#seo-prerender')).toHaveCount(0);
await expect(page.locator('#root[data-wm-prerendered="welcome"] h1')).toHaveCount(1);
await expect(page.locator('#root h1')).toBeVisible();
await expect(page.locator('#root a[href="/dashboard?ref=welcome-hero"]')).toBeVisible();
await expect(page.locator('#root footer a[href="/countries/"]')).toBeVisible();
await expect(page.locator('#root footer a[href="https://github.com/koala73/worldmonitor"]')).toBeVisible();
});
test('keeps the English prerender visible while its module is blocked', async ({ page }) => {
const delayedMain = await delayWelcomeMain(page);
try {
await page.goto('/pro/welcome.html?lang=en', { waitUntil: 'commit' });
await delayedMain.requested;
await expect(page.locator('#root[data-wm-prerender-lang="en"]')).toBeVisible();
} finally {
delayedMain.release();
}
});
for (const language of ['fr', 'ar']) {
test(`keeps the English prerender for ${language} when welcome copy falls back`, async ({ page }) => {
const pageErrors: string[] = [];
page.on('pageerror', (error) => pageErrors.push(error.message));
await page.addInitScript(() => {
const originalReplaceChildren = Element.prototype.replaceChildren;
Element.prototype.replaceChildren = function replaceChildren(...nodes) {
if (this.id === 'root') {
window.__wmWelcomeRootClearCount = (window.__wmWelcomeRootClearCount ?? 0) + 1;
}
return originalReplaceChildren.call(this, ...nodes);
};
window.requestIdleCallback = (callback) => window.setTimeout(() => {
callback({
didTimeout: false,
timeRemaining: () => 50,
});
window.__wmWelcomeHydrationDispatched = true;
}, 0);
});
const delayedMain = await delayWelcomeMain(page);
try {
await page.goto(`/pro/welcome.html?lang=${language}`, { waitUntil: 'commit' });
await delayedMain.requested;
const root = page.locator('#root[data-wm-prerender-lang="en"]');
await expect(root).toContainText("By the time it's news");
await expect(root).toBeVisible();
await expect.poll(async () => page.evaluate(() => ({
direction: getComputedStyle(document.documentElement).direction,
language: document.documentElement.lang,
}))).toEqual({
direction: 'ltr',
language: 'en',
});
expect(await page.evaluate(() => window.__wmWelcomeRootClearCount ?? 0)).toBe(0);
delayedMain.release();
await expect.poll(async () => page.evaluate(() => (
window.__wmWelcomeHydrationDispatched ?? false
))).toBe(true);
await page.evaluate(() => new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}));
await expect(root).toBeVisible();
await expect.poll(async () => page.evaluate(() => ({
direction: getComputedStyle(document.documentElement).direction,
language: document.documentElement.lang,
ogLocale: document.querySelector('meta[property="og:locale"]')?.getAttribute('content'),
}))).toEqual({
direction: 'ltr',
language: 'en',
ogLocale: 'en_US',
});
expect(await page.evaluate(() => window.__wmWelcomeRootClearCount ?? 0)).toBe(0);
expect(pageErrors).toEqual([]);
} finally {
delayedMain.release();
}
});
}
});
test.describe('dashboard shell without JavaScript', () => {
test.use({ javaScriptEnabled: false });
test('keeps the server-rendered welcome page visibly useful', async ({ page }) => {
await page.goto('/pro/welcome.html', { waitUntil: 'domcontentloaded' });
await expect(page.locator('#seo-prerender')).toHaveCount(0);
await expect(page.locator('#root[data-wm-prerendered="welcome"]')).toBeVisible();
await expect(page.locator('#root h1')).toHaveCount(1);
await expect(page.locator('#root h1')).toBeVisible();
await expect(page.locator('#root h1')).toContainText('you already knew');
await expect(page.locator('#root a[href="/dashboard?ref=welcome-hero"]')).toBeVisible();
await expect(page.locator('#root footer a[href="/countries/"]')).toBeVisible();
await expect(page.locator('#root footer a[href="https://github.com/koala73/worldmonitor"]')).toBeVisible();
});
test('hides the JS-only shell and keeps the no-JS content scrollable', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await expect(page.locator('.skeleton-shell')).toBeHidden();
await expect(page.locator('body > h1.app-heading')).toContainText('World Monitor');
await expect(page.locator('body > h1.app-heading')).not.toHaveAttribute('aria-hidden', 'true');
await expect(page.locator('#seo-prerender')).toHaveCount(0);
await expect(page.locator('#dashboard-noscript')).toBeVisible();
await expect(page.locator('#dashboard-noscript')).toContainText('requires JavaScript');
for (const href of [
'/countries/',
'/chokepoints/',
'/crises/',
'/tools/',
'/blog/',
'/docs',
'/pro#pricing',
'https://github.com/koala73/worldmonitor',
]) {
await expect(page.locator(`#dashboard-noscript a[href="${href}"]`)).toHaveCount(1);
}
const beforeScroll = await page.evaluate(() => ({
bodyOverflow: getComputedStyle(document.body).overflow,
docOverflow: getComputedStyle(document.documentElement).overflow,
}));
expect(beforeScroll.bodyOverflow).not.toBe('hidden');
expect(beforeScroll.docOverflow).not.toBe('hidden');
});
});