super-productivity/e2e/store-screenshots/fixture.ts
2026-05-10 00:58:01 +02:00

602 lines
24 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Screenshot test fixture — works in two modes selected by env var:
*
* SCREENSHOT_MODE=web (default) — Playwright Chromium context
* Outputs to dist/screenshots/_master/
* SCREENSHOT_MODE=electron — Real Electron build via Playwright `_electron`
* Outputs to dist/screenshots/_master_electron/
* Captures macOS via renderer + deterministic
* traffic-light overlay, Linux via OS-level
* region tool (grim / import) for GTK chrome.
*
* Same `seededPage` / `screenshotMaster` API in both modes, so scenarios
* import from a single fixture file regardless of mode.
*/
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import {
_electron,
test as base,
type ElectronApplication,
type Page,
} from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
import { ImportPage } from '../pages/import.page';
import { waitForAppReady } from '../utils/waits';
import {
MASTER_DIR_ELECTRON,
MASTER_DIR_WEB,
MOBILE_VIEWPORTS,
SCREENSHOT_BASE_DATE,
type Locale,
type Theme,
type ViewportName,
} from './matrix';
import { compositeMacTrafficLights } from './composite-mac-chrome';
import { writeSeedFile } from './seed/build-seed';
const run = promisify(execFile);
const MODE: 'web' | 'electron' =
process.env.SCREENSHOT_MODE === 'electron' ? 'electron' : 'web';
const SEED_DIR = path.resolve(__dirname, '..', '..', '.tmp', 'screenshot-seeds');
const MASTER_DIR = MODE === 'electron' ? MASTER_DIR_ELECTRON : MASTER_DIR_WEB;
const ELECTRON_MAIN = path.resolve(__dirname, '..', '..', 'electron', 'main.js');
const ELECTRON_USER_DATA = path.resolve(
__dirname,
'..',
'..',
'.tmp',
'electron-user-data',
);
/**
* Sandboxes / restricted shells often deny writes to `~/.config/` (Qt/KDE
* libs, electron-log, Chromium SingletonLock all hit it). Redirect every
* tool's config directory into a project-local path so they're happy.
*/
const SANDBOX_HOME_OVERRIDES = (() => {
const cfg = path.resolve(__dirname, '..', '..', '.tmp', 'electron-xdg');
fs.mkdirSync(cfg, { recursive: true });
return {
XDG_CONFIG_HOME: cfg,
XDG_CACHE_HOME: cfg,
XDG_DATA_HOME: cfg,
} satisfies NodeJS.ProcessEnv;
})();
type ScreenshotFixtures = {
locale: Locale;
theme: Theme;
customTheme: string | undefined;
seedFile: string;
electronApp: ElectronApplication | null;
seededPage: Page;
screenshotMaster: (scenario: string, name: string) => Promise<void>;
};
/**
* Module-level latch so the noisy permission warning fires once per run, not
* once per scene. Reset via `resetChromeFallbackWarning()` if useful in tests.
*/
let osChromeCaptureWarned = false;
/**
* Capture the focused Electron window.
*
* macOS: use renderer capture and add hiddenInset traffic lights
* deterministically. Playwright's `_electron.launch` does not always get the
* same AppKit treatment as a LaunchServices-started `.app`, and OS-level
* capture can miss the button overlay even when the content is correct.
*
* Linux: capture the full screen-space rect, including native GTK decoration.
*
* On Linux, `BrowserWindow.getBounds()` returns the outer frame in pixels, and
* the matching OS tool produces 1:1 output.
*
* If the Linux OS-level capture fails, we fall back to `page.screenshot()` so
* the rest of the single-session run still produces output. The fallback PNG
* won't include native GTK chrome, but every other scene downstream of the
* failure is salvaged.
*/
const captureWindowWithChrome = async (
electronApp: ElectronApplication,
page: Page,
outPath: string,
): Promise<void> => {
if (process.platform === 'darwin') {
await page.screenshot({
path: outPath,
type: 'png',
fullPage: false,
animations: 'disabled',
caret: 'hide',
});
await compositeMacTrafficLights(outPath);
return;
}
try {
// Beat for focus + paint to settle before the OS-level capture fires.
const settleAndCapture = async (bin: string, args: string[]): Promise<void> => {
await new Promise((r) => setTimeout(r, 300));
console.log(`[screenshot] ${path.basename(outPath)}${bin} ${args.join(' ')}`);
try {
// execFile bypasses the shell — no quoting concerns, no metachar escapes.
await run(bin, args, { env: { ...process.env, ...SANDBOX_HOME_OVERRIDES } });
} catch (err) {
// execFile throws { message, code, stdout, stderr }; surface stderr
// because the default Error message just says "Command failed: …" and
// hides the actual cause from the OS capture tool.
const e = err as { stderr?: string; stdout?: string; message?: string };
const stderr = (e.stderr ?? '').toString().trim();
const stdout = (e.stdout ?? '').toString().trim();
const tail = [stderr && `stderr: ${stderr}`, stdout && `stdout: ${stdout}`]
.filter(Boolean)
.join('; ');
throw new Error(
tail
? `${e.message ?? 'capture failed'} (${tail})`
: (e.message ?? String(err)),
);
}
};
// ─── Linux: capture by screen rect (no per-window equivalent
// that's guaranteed to be on PATH). Output dims will match the
// window outer bounds. ─────────────────────────────────────────
const b = await electronApp.evaluate(({ BrowserWindow }) => {
const w = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0];
if (!w) throw new Error('No Electron window to capture');
w.show();
w.focus();
return w.getBounds();
});
for (const v of [b.x, b.y, b.width, b.height]) {
if (typeof v !== 'number' || !Number.isFinite(v)) {
throw new Error(`Non-finite window bound: ${JSON.stringify(b)}`);
}
}
const isWayland =
process.env.XDG_SESSION_TYPE === 'wayland' || !!process.env.WAYLAND_DISPLAY;
const [bin, args] = isWayland
? ['grim', ['-g', `${b.x},${b.y} ${b.width}x${b.height}`, outPath]]
: [
'import',
['-window', 'root', '-crop', `${b.width}x${b.height}+${b.x}+${b.y}`, outPath],
];
await settleAndCapture(bin, args);
} catch (err) {
const msg = (err as Error).message ?? String(err);
if (!osChromeCaptureWarned) {
osChromeCaptureWarned = true;
const hint =
process.platform === 'linux'
? '\n ↳ Linux: ensure `grim` (Wayland) or ImageMagick `import` (X11) ' +
'is installed and on PATH.'
: '';
console.warn(
'\n' +
'════════════════════════════════════════════════════════════════════\n' +
'⚠ OS-LEVEL SCREEN CAPTURE FAILED — RENDERER FALLBACK ACTIVE\n' +
'════════════════════════════════════════════════════════════════════\n' +
` ${msg.split('\n')[0]}\n` +
' Captures will NOT include native GTK window chrome. The Flathub\n' +
' deliverables from this run are therefore NOT submission-ready.' +
hint +
'\n' +
'════════════════════════════════════════════════════════════════════\n',
);
// Marker so the globalTeardown can re-surface this at the end of the
// run — the warning above scrolls off in long runs and the user
// notices the missing chrome only when reviewing the PNGs.
try {
fs.mkdirSync(MASTER_DIR, { recursive: true });
fs.writeFileSync(
path.join(MASTER_DIR, '.os-capture-failed'),
`${new Date().toISOString()}\n${msg}\n`,
);
} catch {
/* marker is best-effort */
}
}
await page.screenshot({
path: outPath,
type: 'png',
fullPage: false,
animations: 'disabled',
caret: 'hide',
});
}
};
const ONBOARDING_INIT = (): void => {
localStorage.setItem('SUP_ONBOARDING_PRESET_DONE', 'true');
localStorage.setItem('SUP_ONBOARDING_HINTS_DONE', 'true');
localStorage.setItem('SUP_IS_SHOW_TOUR', 'true');
localStorage.setItem('SUP_EXAMPLE_TASKS_CREATED', 'true');
};
export const test = base.extend<ScreenshotFixtures>({
locale: ['en', { option: true }] as never,
theme: ['light', { option: true }] as never,
customTheme: [
process.env.SCREENSHOT_CUSTOM_THEME?.trim() || undefined,
{ option: true },
] as never,
seedFile: async ({ locale, customTheme }, use) => {
const file = writeSeedFile(SCREENSHOT_BASE_DATE, SEED_DIR, {
locale,
customTheme,
});
await use(file);
},
// Electron app is launched only in electron mode; web mode resolves to null.
electronApp: async ({}, use, testInfo) => {
if (MODE !== 'electron') {
await use(null);
return;
}
if (!fs.existsSync(ELECTRON_MAIN)) {
throw new Error(`${ELECTRON_MAIN} missing. Run \`npm run electron:build\` first.`);
}
// Per-test user-data dir avoids the singleton-lock collision and lets us
// run inside sandboxes where ~/.config/Electron is not writable.
const userDataDir = path.join(
ELECTRON_USER_DATA,
`worker-${testInfo.workerIndex}-${Date.now()}`,
);
fs.mkdirSync(userDataDir, { recursive: true });
// NODE_ENV is intentionally NOT set to 'DEV': start-app.js binds
// `isShowDevTools = IS_DEV`, and electron/debug.ts auto-opens DevTools on
// every `dom-ready` (so reloads between scenarios re-open them too).
// Loading the dev server is governed by --custom-url, not IS_DEV, so we
// can safely run with IS_DEV=false and keep the same URL.
// `--no-sandbox` and `--disable-dev-shm-usage` are Linux/CI helper
// flags. On macOS they're not needed, and `--no-sandbox` in particular
// appears to suppress the hiddenInset traffic-lights when Electron is
// launched as a child process (the same binary launched via `npm start`
// shows them fine). Only pass them on Linux.
const isMac = process.platform === 'darwin';
const deviceScaleFactor =
(testInfo.project.use as { deviceScaleFactor?: number }).deviceScaleFactor ?? 1;
const electronApp = await _electron.launch({
args: [
ELECTRON_MAIN,
`--custom-url=http://localhost:4242/`,
`--user-data-dir=${userDataDir}`,
...(isMac ? [`--force-device-scale-factor=${deviceScaleFactor}`] : []),
...(isMac ? [] : ['--disable-dev-shm-usage', '--no-sandbox']),
],
env: {
...process.env,
NODE_ENV: 'PROD',
// Tells main-window.ts to set `enableLargerThanScreen: true` so the
// configured 1280×800 outer bounds aren't silently clamped to fit
// available screen area below menu bar + dock. Without this, on
// laptop displays setBounds(800) ends up as 780 and the captured
// PNG fails Mac App Store dimension validation.
SP_SCREENSHOT_MODE: '1',
...SANDBOX_HOME_OVERRIDES,
},
timeout: 60_000,
});
try {
await use(electronApp);
} finally {
// electronApp.close() can hang on apps with pending async work. Race it
// with a force-kill, but cancel the timer if close() finishes first so
// we don't SIGKILL a recycled PID after the process is already gone.
const proc = electronApp.process();
let timer: NodeJS.Timeout | undefined;
const killAfter = new Promise<void>((resolve) => {
timer = setTimeout(() => {
try {
proc?.kill('SIGKILL');
} catch {
/* ignore */
}
resolve();
}, 8_000);
});
await Promise.race([electronApp.close().catch(() => undefined), killAfter]);
if (timer) clearTimeout(timer);
}
},
page: async (
{ browser, baseURL, theme, locale, customTheme, electronApp },
use,
testInfo,
) => {
// ─── electron mode ────────────────────────────────────────────────
if (MODE === 'electron' && electronApp) {
const page = await electronApp.firstWindow();
await page.waitForLoadState('domcontentloaded');
// Pin time so day-relative seed data ("today") renders against the
// SCREENSHOT_BASE_DATE — same behavior as the web branch below.
await page.clock.install({ time: SCREENSHOT_BASE_DATE }).catch(() => undefined);
// Force window size + close auto-opened DevTools now that the window
// exists. The app's `electron-window-state` defaults to 800×800.
// Wrapped defensively — closeDevTools throws if DevTools isn't open,
// and any throw inside an evaluate has been observed to take down the
// renderer.
const targetSize = (testInfo.project.use.viewport ?? {
width: 1440,
height: 900,
}) as { width: number; height: number };
const achieved = await electronApp
.evaluate(
({ BrowserWindow }, size) => {
try {
const w =
BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0];
if (!w) return null;
w.setBounds({ x: 0, y: 0, width: size.w, height: size.h });
if (w.webContents.isDevToolsOpened()) {
w.webContents.closeDevTools();
}
// Report what setBounds actually achieved vs. requested. On
// macOS the OS may push y below the menu bar / shrink to fit
// the screen; the result tells us whether 1280×800 stuck.
return {
bounds: w.getBounds(),
contentBounds: w.getContentBounds(),
};
} catch {
/* swallow — bounds/devtools are nice-to-have, not critical */
return null;
}
},
{ w: targetSize.width, h: targetSize.height },
)
.catch(() => null);
if (achieved) {
const b = achieved.bounds;
const c = achieved.contentBounds;
console.log(
`[screenshot] setBounds requested=${targetSize.width}x${targetSize.height}` +
`outer=${b.x},${b.y} ${b.width}x${b.height}; ` +
`content=${c.x},${c.y} ${c.width}x${c.height}`,
);
}
await page.waitForTimeout(400);
// Electron pages have no Playwright-level baseURL, so `page.goto('/#/x')`
// fails with "invalid URL". Wrap goto to prepend the dev server origin
// when given a relative path — keeps shared helpers (ImportPage,
// gotoAndSettle) working unchanged in both modes.
const origGoto = page.goto.bind(page);
const electronBaseURL = 'http://localhost:4242';
(page as unknown as { goto: typeof origGoto }).goto = ((
url: string,
opts?: Parameters<typeof origGoto>[1],
) =>
origGoto(
url.startsWith('http') ? url : electronBaseURL + url,
opts,
)) as typeof origGoto;
await page.evaluate(
({ darkMode, customThemeId }) => {
try {
localStorage.setItem('DARK_MODE', darkMode);
localStorage.setItem('SUP_ONBOARDING_PRESET_DONE', 'true');
localStorage.setItem('SUP_ONBOARDING_HINTS_DONE', 'true');
localStorage.setItem('SUP_IS_SHOW_TOUR', 'true');
localStorage.setItem('SUP_EXAMPLE_TASKS_CREATED', 'true');
if (customThemeId) {
localStorage.setItem('CUSTOM_THEME', `builtin:${customThemeId}`);
}
} catch {
/* localStorage may be unavailable until renderer ready */
}
},
{ darkMode: theme, customThemeId: customTheme },
);
await page.evaluate(() => location.reload());
await page.waitForLoadState('domcontentloaded');
await waitForAppReady(page);
// Suppress Material tooltips for the rest of the session (mirrors the
// web-mode addInitScript). Electron has no addInitScript hook on the
// first window, so inject after each navigation via page.on('load').
const injectTooltipSuppress = async (): Promise<void> => {
await page
.evaluate(() => {
const id = '__sp-screenshot-tooltip-suppress';
if (document.getElementById(id)) return;
const style = document.createElement('style');
style.id = id;
style.textContent =
'mat-tooltip-component,.mat-mdc-tooltip,.cdk-overlay-container .mat-mdc-tooltip,.cdk-overlay-container .mat-tooltip,.cdk-overlay-container [role="tooltip"]{visibility:hidden!important;opacity:0!important}';
document.head.appendChild(style);
})
.catch(() => undefined);
};
await injectTooltipSuppress();
page.on('load', () => void injectTooltipSuppress());
// Stamp the initial locale on window so screenshotMaster can route
// captures into the correct `<locale>/` subdir even when applyLocale
// hasn't run yet. Re-applied on every load so reloads don't lose it.
const stampLocale = async (): Promise<void> => {
await page
.evaluate((l) => {
(window as unknown as { __spCurrentLocale?: string }).__spCurrentLocale = l;
}, locale)
.catch(() => undefined);
};
await stampLocale();
page.on('load', () => void stampLocale());
page.on('pageerror', (err) => {
console.error('[electron pageerror]', err.message);
});
await use(page);
return;
}
// ─── web mode (default) ───────────────────────────────────────────
const isMobileProject = (MOBILE_VIEWPORTS as readonly string[]).includes(
testInfo.project.name,
);
const context = await browser.newContext({
baseURL: baseURL ?? 'http://localhost:4242',
userAgent: `PLAYWRIGHT PLAYWRIGHT-WORKER-${testInfo.workerIndex}`,
storageState: undefined,
// Pin navigator.language to en-US so ImportPage's English text matchers
// ("Import/Export") work regardless of host locale. The actual UI locale
// gets switched after seed import via globalConfig.localization.lng.
locale: 'en-US',
});
const page = await context.newPage();
await page.clock.install({ time: SCREENSHOT_BASE_DATE });
await page.addInitScript(ONBOARDING_INIT);
if (isMobileProject) {
await page.addInitScript(() => {
localStorage.setItem('SUP_NAV_SIDEBAR_EXPANDED', 'false');
});
}
await page.addInitScript((darkMode) => {
try {
localStorage.setItem('DARK_MODE', darkMode);
} catch {
/* noop */
}
}, theme);
// Mirror the DARK_MODE init script for the custom-theme picker. The
// CustomThemeService reads LS.CUSTOM_THEME at construction; for E2E
// screenshots we point it at a built-in theme by id (e.g. 'dracula').
if (customTheme) {
await page.addInitScript((id) => {
try {
localStorage.setItem('CUSTOM_THEME', `builtin:${id}`);
} catch {
/* noop */
}
}, customTheme);
}
// Stamp the initial locale on `window.__spCurrentLocale` so
// screenshotMaster can route captures into the right `<locale>/` dir.
// Updated by `helpers.applyLocale()` when scenes flip languages.
await page.addInitScript((initialLocale) => {
(window as unknown as { __spCurrentLocale?: string }).__spCurrentLocale =
initialLocale;
}, locale);
// Suppress Material tooltips and CDK overlay tooltips for the duration of
// capture. Cursor lingering at the last click position would otherwise pop
// a tooltip into the screenshot. Setting visibility:hidden (not display)
// keeps Angular's overlay refs valid so the app doesn't trip on missing
// host elements.
await page.addInitScript(() => {
const style = document.createElement('style');
style.id = '__sp-screenshot-tooltip-suppress';
style.textContent = `
mat-tooltip-component,
.mat-mdc-tooltip,
.cdk-overlay-container .mat-mdc-tooltip,
.cdk-overlay-container .mat-tooltip,
.cdk-overlay-container [role="tooltip"] {
visibility: hidden !important;
opacity: 0 !important;
}
`;
const attach = (): void => {
if (!document.getElementById(style.id)) document.head.appendChild(style);
};
if (document.head) attach();
else document.addEventListener('DOMContentLoaded', attach, { once: true });
});
page.on('pageerror', (err) => {
console.error('[screenshot pageerror]', err.message);
});
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await waitForAppReady(page);
try {
await use(page);
} finally {
if (!page.isClosed()) await page.close();
await context.close();
}
},
seededPage: async ({ page, seedFile }, use) => {
const importPage = new ImportPage(page);
await importPage.navigateToImportPage();
await importPage.importBackupFile(seedFile);
await waitForAppReady(page);
await use(page);
},
screenshotMaster: async ({ page, electronApp, locale, theme }, use, testInfo) => {
const viewport = testInfo.project.name as ViewportName;
const showSyncReadyState = async (): Promise<void> => {
await page.evaluate(() => {
const syncBtn = document.querySelector('.sync-btn');
if (!syncBtn || syncBtn.querySelector('.__sp-sync-ready-check')) return;
const icon = document.createElement('span');
icon.className =
'mat-icon notranslate material-icons mat-ligature-font mat-icon-no-color sync-state-ico __sp-sync-ready-check';
icon.setAttribute('aria-hidden', 'true');
icon.textContent = 'check';
syncBtn.appendChild(icon);
});
};
const fn = async (scenario: string, name: string): Promise<void> => {
// Read live theme + locale from the page so one test can span multiple
// (locale × theme) variants. helpers.applyTheme flips DARK_MODE in
// localStorage; helpers.applyLocale stamps the current locale on
// `window.__spCurrentLocale` (set initially in fixture init scripts).
// Both fall back to the test.use() values if the lookup fails.
const live = await page
.evaluate(() => ({
theme: localStorage.getItem('DARK_MODE'),
locale: (window as unknown as { __spCurrentLocale?: string }).__spCurrentLocale,
}))
.catch(() => ({ theme: null as string | null, locale: undefined }));
const currentTheme =
live.theme === 'light' || live.theme === 'dark' ? live.theme : theme;
const currentLocale =
typeof live.locale === 'string' && live.locale ? live.locale : locale;
const dir = path.join(MASTER_DIR, viewport, currentLocale, currentTheme, scenario);
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, `${name}.png`);
// Park the cursor at (0,0) so any Material tooltip from the last click
// dismisses (matTooltip hides on mouseleave). Also any cdk-overlay
// tooltip that's already open is force-removed via the screenshot CSS.
try {
await showSyncReadyState();
await page.mouse.move(0, 0);
await page.waitForTimeout(120);
} catch {
/* electron pages with no mouse host shouldn't ever fail this */
}
if (MODE === 'electron' && electronApp) {
await captureWindowWithChrome(electronApp, page, file);
} else {
await page.screenshot({
path: file,
type: 'png',
fullPage: false,
animations: 'disabled',
caret: 'hide',
});
}
};
await use(fn);
},
});
export { expect } from '@playwright/test';