From eab502be0bcd72eba0131cc4793b4e73c2053a88 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Thu, 27 Aug 2026 10:41:20 -0400 Subject: [PATCH 1/5] test: complete browser Electron fixture contract --- frontend/e2e/helpers/electron-fixture.ts | 167 +++++++++++++++++++++++ frontend/e2e/title-bar-platform.spec.ts | 63 +++++++++ 2 files changed, 230 insertions(+) create mode 100644 frontend/e2e/helpers/electron-fixture.ts diff --git a/frontend/e2e/helpers/electron-fixture.ts b/frontend/e2e/helpers/electron-fixture.ts new file mode 100644 index 00000000..80481a0d --- /dev/null +++ b/frontend/e2e/helpers/electron-fixture.ts @@ -0,0 +1,167 @@ +import type { Page } from '@playwright/test'; +import type { + DailySummary, + ElectronAPI, + ElectronActionResult, + ElectronAppInfo, + ElectronDbSafeFixesResult, + ElectronIpcError, + ElectronMasterPinStatus, + ElectronStatus, + HealthCheckReport, + KdsInfo, + TitleBarMode, + UpdateStatus, + WindowControlAction, +} from '../../src/types/electron'; + +export interface ElectronFixtureOptions { + platform?: 'darwin' | 'win32' | 'linux'; + titleBarMode?: TitleBarMode; + titleBarEpoch?: number; + titleBarDocumentNonce?: string; + focused?: boolean; + api?: boolean; +} + +interface SerializedElectronFixtureOptions { + platform: ElectronFixtureOptions['platform']; + titleBarMode: TitleBarMode; + titleBarEpoch: number; + titleBarDocumentNonce: string; + focused: boolean; + api: boolean; +} + +const DEFAULT_DOCUMENT_NONCE = '00000000-0000-4000-8000-000000000000'; + +export async function injectElectronFixture( + page: Page, + options: ElectronFixtureOptions = {}, +): Promise { + const fixture: SerializedElectronFixtureOptions = { + platform: options.platform ?? 'darwin', + titleBarMode: options.titleBarMode ?? 'native-overlay', + titleBarEpoch: options.titleBarEpoch ?? 1, + titleBarDocumentNonce: options.titleBarDocumentNonce ?? DEFAULT_DOCUMENT_NONCE, + focused: options.focused ?? true, + api: options.api ?? true, + }; + + await page.addInitScript((config: SerializedElectronFixtureOptions) => { + if (!config.api) return; + + const actions: WindowControlAction[] = []; + const status: ElectronStatus = { + server: 'running', + kdsServer: 'running', + serverApp: 'running', + memory: { heapUsed: 1, heapTotal: 1, rss: 1 }, + uptime: 1, + port: 3001, + titleBarMode: config.titleBarMode, + titleBarEpoch: config.titleBarEpoch, + titleBarDocumentNonce: config.titleBarDocumentNonce, + }; + const result: ElectronActionResult = { success: true }; + const ipcError: ElectronIpcError = { error: 'unsupported in browser fixture' }; + const appInfo: ElectronAppInfo = { + version: 'e2e', + name: 'Flo Cafe', + electron: 'fixture', + node: 'fixture', + platform: config.platform ?? 'darwin', + }; + const updateStatus: UpdateStatus = { status: 'dev-mode' }; + const healthReport: HealthCheckReport = { + generatedAt: new Date(0).toISOString(), + liveSchemaVersion: 0, + idealSchemaVersion: 0, + findings: [], + summary: { safeCount: 0, manualReviewCount: 0 }, + }; + const kdsInfo: KdsInfo = { + url: 'http://127.0.0.1:3002', + wsUrl: 'ws://127.0.0.1:3002/kds', + localIP: '127.0.0.1', + port: 3002, + }; + const dailySummary: DailySummary = { + date: '1970-01-01', + revenue: 0, + bill_count: 0, + covers: 0, + pending_orders: 0, + }; + const masterPinStatus: ElectronMasterPinStatus = { available: false, isSet: false }; + const safeFixes: ElectronDbSafeFixesResult = { applied: [], skipped: [], errors: [] }; + + const api: ElectronAPI = { + platform: config.platform ?? 'darwin', + onMenuAction: () => () => {}, + windowAction: async (action) => { + actions.push(action); + return result; + }, + backupDatabase: async () => ({ success: false, error: ipcError.error }), + restoreBackup: async () => ({ success: false, error: ipcError.error }), + dbHealthCheck: async () => healthReport, + dbApplySafeFixes: async () => safeFixes, + dbInitialize: async () => ({ success: false, error: ipcError.error }), + getMasterPinStatus: async () => masterPinStatus, + getSettings: async () => ({}), + setSetting: async () => result, + getKdsInfo: async () => kdsInfo, + openKdsWindow: async () => undefined, + getAppInfo: async () => appInfo, + getPrinters: async () => [], + savePrinter: async () => result, + getDailySummary: async () => dailySummary, + getStatus: async () => status, + windowReady: async () => result, + onUpdateStatus: (callback) => { + callback(updateStatus); + return () => {}; + }, + getUpdateStatus: async () => ({ status: updateStatus.status, info: { version: appInfo.version } }), + getBetaChannel: async () => false, + setBetaChannel: async () => result, + checkForUpdates: async () => undefined, + restartAndInstall: async () => result, + }; + + Object.defineProperty(window, 'electronAPI', { configurable: true, value: api }); + Object.defineProperty(window, '__floElectronFixture', { + configurable: true, + value: { actions, status, ipcError }, + }); + // Initial focus is explicit before application scripts execute. TitleBar + // owns later focus/blur transitions on dashboard routes. + const setInitialFocus = (): void => { + if (document.documentElement) { + document.documentElement.dataset.floWindowFocused = String(config.focused); + } + }; + setInitialFocus(); + document.addEventListener('DOMContentLoaded', setInitialFocus, { once: true }); + }, fixture); +} + +export async function readFixtureActions(page: Page): Promise { + return page.evaluate(() => { + const state = window.__floElectronFixture; + return state ? [...state.actions] : []; + }); +} + +declare global { + interface Window { + __floElectronFixture?: { + actions: WindowControlAction[]; + status: ElectronStatus; + ipcError: ElectronIpcError; + }; + } +} + +export {}; diff --git a/frontend/e2e/title-bar-platform.spec.ts b/frontend/e2e/title-bar-platform.spec.ts index 7cc4d036..6da0eb96 100644 --- a/frontend/e2e/title-bar-platform.spec.ts +++ b/frontend/e2e/title-bar-platform.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from '@playwright/test'; import { E2E_BASE_URL as BASE, E2E_KDS_BASE_URL } from './helpers/urls'; +import { injectElectronFixture, readFixtureActions } from './helpers/electron-fixture'; /** * Platform test-matrix rows for the custom title-bar work (Refs #457/#462) @@ -170,3 +171,65 @@ test('sidebar renders profile dropup trigger and supports drag rail resizing', a await expect(rail).toHaveClass(/cursor-col-resize/); }); +test('browser Electron fixture exposes the complete renderer API and explicit initial focus', async ({ page }) => { + await injectElectronFixture(page, { platform: 'darwin', focused: false }); + await page.goto(`${BASE}/auth/login`); + + const contract = await page.evaluate(async () => ({ + keys: Object.keys(window.electronAPI || {}).sort(), + focus: document.documentElement.dataset.floWindowFocused, + appInfo: await window.electronAPI?.getAppInfo(), + updateStatus: await window.electronAPI?.getUpdateStatus(), + })); + expect(contract.keys).toEqual([ + 'backupDatabase', + 'checkForUpdates', + 'dbApplySafeFixes', + 'dbHealthCheck', + 'dbInitialize', + 'getAppInfo', + 'getBetaChannel', + 'getDailySummary', + 'getKdsInfo', + 'getMasterPinStatus', + 'getPrinters', + 'getSettings', + 'getStatus', + 'getUpdateStatus', + 'onMenuAction', + 'onUpdateStatus', + 'openKdsWindow', + 'platform', + 'restartAndInstall', + 'restoreBackup', + 'savePrinter', + 'setBetaChannel', + 'setSetting', + 'windowAction', + 'windowReady', + ]); + expect(contract.focus).toBe('false'); + expect(contract.appInfo).toMatchObject({ version: 'e2e', platform: 'darwin' }); + expect(contract.updateStatus).toMatchObject({ status: 'dev-mode', info: { version: 'e2e' } }); +}); + +test('browser Electron fixture drives authenticated title-bar and fallback controls', async ({ page }) => { + await injectElectronFixture(page, { + platform: 'win32', + titleBarMode: 'html-fallback', + focused: true, + }); + await page.setViewportSize({ width: 1280, height: 800 }); + await page.goto(`${BASE}/auth/login`); + await page.locator('#email').fill('owner@flo.local'); + await page.locator('#password').fill('E2ePass123!'); + await page.locator('button[type="submit"]').click(); + await page.waitForURL(/\/pos/, { timeout: 20000 }); + await expect(page.locator('[data-slot="sidebar-container"]')).toBeVisible(); + await expect(page.getByTestId('desktop-title-bar')).toBeVisible(); + await expect(page.locator('.flo-title-bar__fallback-controls')).toBeVisible(); + await expect(page.locator('html')).toHaveAttribute('data-flo-window-focused', 'true'); + + await page.locator('.flo-title-bar__fallback-button').first().click(); + await expect.poll(() => readFixtureActions(page)).toContain('minimize'); +}); From d3bc751efb0f9b9d7e94ecfa1821f23c1f2ead2e Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Thu, 27 Aug 2026 10:41:24 -0400 Subject: [PATCH 2/5] test: isolate native Electron Playwright harness --- frontend/e2e/desktop/native-harness.ts | 294 ++++++++++++++++++ .../e2e/desktop/title-bar.electron.spec.ts | 80 +++++ main/db.ts | 3 + main/index.ts | 25 +- tests/native-e2e-fixture.cjs | 71 +++++ 5 files changed, 467 insertions(+), 6 deletions(-) create mode 100644 frontend/e2e/desktop/native-harness.ts create mode 100644 frontend/e2e/desktop/title-bar.electron.spec.ts create mode 100644 tests/native-e2e-fixture.cjs diff --git a/frontend/e2e/desktop/native-harness.ts b/frontend/e2e/desktop/native-harness.ts new file mode 100644 index 00000000..6417bb73 --- /dev/null +++ b/frontend/e2e/desktop/native-harness.ts @@ -0,0 +1,294 @@ +import { createRequire } from 'node:module'; +import { createConnection, createServer } from 'node:net'; +import { spawn } from 'node:child_process'; +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { _electron as electron, type ElectronApplication, type Page } from 'playwright'; + +const require = createRequire(__filename); +const electronPath = require('electron') as string; +const repoRoot = path.resolve(__dirname, '../../../'); +const seedScript = path.join(repoRoot, 'tests/native-e2e-fixture.cjs'); +const GRACEFUL_CLOSE_TIMEOUT_MS = 15_000; +const PROCESS_EXIT_TIMEOUT_MS = 5_000; +const PORT_CLOSE_TIMEOUT_MS = 5_000; + +export interface NativeServicePorts { + main: number; + kds: number; + serverApp: number; +} + +export interface NativeElectronHarness { + app: ElectronApplication; + page: Page; + ports: NativeServicePorts; + profileDir: string; + authenticateDashboard: () => Promise; + close: () => Promise; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function isPortAvailable(port: number): Promise { + return new Promise((resolve) => { + const server = createServer(); + const finish = (available: boolean): void => { + server.removeAllListeners(); + resolve(available); + }; + server.once('error', () => finish(false)); + server.listen(port, '127.0.0.1', () => { + server.close((error) => finish(!error)); + }); + }); +} + +async function findServicePorts(): Promise { + const workerIndex = Number(process.env.PW_TEST_WORKER_INDEX || 0); + const firstCandidate = 31_000 + ((process.pid + workerIndex * 101) % 900) * 3; + for (let attempt = 0; attempt < 200; attempt += 1) { + const main = firstCandidate + attempt * 3; + const candidate = { main, kds: main + 1, serverApp: main + 2 }; + if ((await Promise.all(Object.values(candidate).map(isPortAvailable))).every(Boolean)) return candidate; + } + throw new Error(`Unable to reserve a native E2E service port set near ${firstCandidate}`); +} + +function runSeed(env: Record): Promise { + return new Promise((resolve, reject) => { + const seedEnv = { ...env, ELECTRON_RUN_AS_NODE: '1' } as unknown as NodeJS.ProcessEnv; + const child = spawn(electronPath, [seedScript], { + cwd: repoRoot, + env: seedEnv, + stdio: 'pipe', + }); + let output = ''; + child.stdout.on('data', (chunk) => { output += String(chunk); }); + child.stderr.on('data', (chunk) => { output += String(chunk); }); + child.once('error', reject); + child.once('exit', (code, signal) => { + if (code === 0) resolve(); + else reject(new Error(`Native E2E fixture seed failed (code=${code}, signal=${signal}): ${output.trim()}`)); + }); + }); +} + +async function waitForHealth(ports: NativeServicePorts): Promise { + const endpoints = [ + `http://127.0.0.1:${ports.main}/api/health`, + `http://127.0.0.1:${ports.kds}/api/health`, + `http://127.0.0.1:${ports.serverApp}/api/health`, + ]; + const deadline = Date.now() + 30_000; + let lastError: unknown; + while (Date.now() < deadline) { + try { + const responses = await Promise.all(endpoints.map((endpoint) => fetch(endpoint))); + if (responses.every((response) => response.ok)) return; + } catch (error) { + lastError = error; + } + await delay(100); + } + throw new Error(`Native E2E services did not become healthy: ${String(lastError || 'unexpected health response')}`); +} + +async function waitForRendererServices(page: Page): Promise { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + const running = await page.evaluate(async () => { + const status = await window.electronAPI?.getStatus(); + return status?.server === 'running' + && status.kdsServer === 'running' + && status.serverApp === 'running'; + }); + if (running) return; + await delay(100); + } + throw new Error('Native E2E renderer did not report all services running'); +} + +async function waitForProcessExit(pid: number): Promise { + const deadline = Date.now() + PROCESS_EXIT_TIMEOUT_MS; + while (Date.now() < deadline) { + try { + process.kill(pid, 0); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') return true; + throw error; + } + await delay(100); + } + return false; +} + +async function waitForPortClosed(port: number): Promise { + const deadline = Date.now() + PORT_CLOSE_TIMEOUT_MS; + while (Date.now() < deadline) { + const closed = await new Promise((resolve) => { + const socket = createConnection({ host: '127.0.0.1', port }); + const finish = (value: boolean): void => { + socket.destroy(); + resolve(value); + }; + socket.once('connect', () => finish(false)); + socket.once('error', (error: NodeJS.ErrnoException) => { + finish(error.code === 'ECONNREFUSED' || error.code === 'ECONNRESET'); + }); + }); + if (closed) return true; + await delay(100); + } + return false; +} + +function forceTerminate(pid: number): boolean { + try { + process.kill(pid, 'SIGTERM'); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') return false; + throw error; + } +} + +async function boundedGracefulClose( + app: ElectronApplication, + ports: NativeServicePorts, + profileDir: string, +): Promise { + const pid = app.process().pid; + if (!pid) throw new Error('Native Electron process did not expose a PID'); + + let gracefulError: unknown; + try { + const cleanupComplete = new Promise((resolve) => { + app.on('console', (message) => { + if (message.text() === '[Flo] Goodbye!') resolve(); + }); + }); + // Request quit through Electron so close-to-tray is honored, wait for the + // app's own cleanup marker, then let Playwright close its Node/CDP + // connection. Closing that connection only after cleanup avoids both a + // hidden window masquerading as process exit and a connection-held process. + await app.evaluate(({ app: electronApp }) => electronApp.quit()); + await Promise.race([ + cleanupComplete, + new Promise((_, reject) => setTimeout(() => reject(new Error( + `Electron graceful close exceeded ${GRACEFUL_CLOSE_TIMEOUT_MS}ms`, + )), GRACEFUL_CLOSE_TIMEOUT_MS)), + ]); + await app.close(); + } catch (error) { + gracefulError = error; + } + + let exited = await waitForProcessExit(pid); + let forcedCleanup = false; + if (!exited) { + forcedCleanup = forceTerminate(pid); + exited = await waitForProcessExit(pid); + } + + const portsClosed = (await Promise.all(Object.values(ports).map(waitForPortClosed))).every(Boolean); + let profileCleanupError: unknown; + if (exited) { + try { rmSync(profileDir, { recursive: true, force: true }); } catch (error) { profileCleanupError = error; } + } + + if (gracefulError || forcedCleanup || !exited || !portsClosed || profileCleanupError) { + throw new Error([ + 'Native Electron teardown failed', + gracefulError ? `graceful=${String(gracefulError)}` : '', + forcedCleanup ? 'forced_cleanup=true' : '', + `pid_exited=${exited}`, + `ports_closed=${portsClosed}`, + profileCleanupError ? `profile=${String(profileCleanupError)}` : '', + ].filter(Boolean).join('; ')); + } +} + +export async function createNativeElectronHarness(): Promise { + const profileDir = mkdtempSync(path.join(tmpdir(), 'flo-native-e2e-')); + const ports = await findServicePorts(); + const inheritedEnv = Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => typeof entry[1] === 'string'), + ); + const env: Record = { + ...inheritedEnv, + NODE_ENV: 'test', + JWT_SECRET: 'native-e2e-test-secret', + FLO_E2E_SKIP_OPTIONAL_NETWORK: '1', + FLO_MATRIX_OFFLINE: '1', + FLO_E2E_USER_DATA_DIR: profileDir, + FLO_E2E_DB_PATH: path.join(profileDir, 'flo.db'), + PORT: String(ports.main), + KDS_PORT: String(ports.kds), + SERVER_APP_PORT: String(ports.serverApp), + }; + + let app: ElectronApplication | undefined; + try { + await runSeed(env); + app = await electron.launch({ cwd: repoRoot, args: ['.'], env }); + app.on('console', (message) => console.log(`[Native Electron] ${message.text()}`)); + const page = await app.firstWindow(); + await page.waitForURL((url) => url.port === String(ports.main), { timeout: 30_000 }); + await waitForHealth(ports); + await waitForRendererServices(page); + + const actualPorts = await page.evaluate(async () => { + const status = await window.electronAPI?.getStatus(); + const kds = await window.electronAPI?.getKdsInfo(); + return { main: status?.port, kds: kds && 'port' in kds ? kds.port : undefined }; + }); + if (actualPorts.main !== ports.main || actualPorts.kds !== ports.kds) { + throw new Error(`Native E2E service port mismatch: expected ${JSON.stringify(ports)}, got ${JSON.stringify(actualPorts)}`); + } + + // The app's root export is a redirect boundary, so drive the renderer to a + // concrete public route before any test asserts route-owned UI state. + await page.goto(`http://localhost:${ports.main}/auth/login`, { waitUntil: 'domcontentloaded' }); + + return { + app, + page, + ports, + profileDir, + authenticateDashboard: async () => { + const currentPath = new URL(page.url()).pathname.replace(/\/+$/, '') || '/'; + if (currentPath !== '/auth/login' && currentPath !== '/pos') { + throw new Error(`Native E2E expected a stable auth route, got ${page.url()}`); + } + if (currentPath !== '/pos') { + await page.locator('#email').fill('owner@flo.local'); + await page.locator('#password').fill('E2ePass123!'); + await page.locator('button[type="submit"]').click(); + await page.waitForURL((url) => url.pathname.replace(/\/+$/, '') === '/pos', { timeout: 30_000 }); + } + await app!.evaluate(({ app: electronApp, BrowserWindow }) => { + electronApp.focus({ steal: true }); + BrowserWindow.getAllWindows()[0]?.focus(); + }); + await page.waitForFunction(() => document.hasFocus() && document.documentElement.dataset.floWindowFocused === 'true'); + await page.waitForFunction(() => document.documentElement.dataset.floDesktopTitlebar === 'true'); + }, + close: async () => boundedGracefulClose(app!, ports, profileDir), + }; + } catch (error) { + if (app) { + try { + await boundedGracefulClose(app, ports, profileDir); + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], 'Native E2E startup and teardown failed'); + } + } else if (existsSync(profileDir)) { + rmSync(profileDir, { recursive: true, force: true }); + } + throw error; + } +} diff --git a/frontend/e2e/desktop/title-bar.electron.spec.ts b/frontend/e2e/desktop/title-bar.electron.spec.ts new file mode 100644 index 00000000..221ba178 --- /dev/null +++ b/frontend/e2e/desktop/title-bar.electron.spec.ts @@ -0,0 +1,80 @@ +import { expect, test } from '@playwright/test'; +import type { NativeElectronHarness } from './native-harness'; +import { createNativeElectronHarness } from './native-harness'; + +test.describe.configure({ mode: 'serial' }); +test.setTimeout(90_000); + +let harness: NativeElectronHarness; + +test.beforeAll(async () => { + harness = await createNativeElectronHarness(); +}); + +test.afterAll(async () => { + await harness?.close(); +}); + +test('real Electron mounts the root drag surface before authentication', async () => { + await expect(harness.page.getByTestId('desktop-drag-surface')).toBeVisible(); + await expect(harness.page.locator('html')).toHaveAttribute('data-flo-desktop-titlebar', 'true'); +}); + +test('real preload, renderer, and main boundaries reach an authenticated dashboard', async () => { + await harness.authenticateDashboard(); + await expect(harness.page.locator('[data-slot="sidebar-container"]')).toBeVisible(); + await expect(harness.page.getByTestId('desktop-title-bar')).toBeVisible(); + + const runtime = await harness.page.evaluate(async () => { + const api = window.electronAPI; + if (!api) throw new Error('Native E2E preload did not expose electronAPI'); + const status = await api.getStatus(); + const appInfo = await api.getAppInfo(); + const updateStatus = await api.getUpdateStatus(); + const readiness = await api.windowReady({ epoch: status.titleBarEpoch ?? 0 }); + return { + hasApi: true, + platform: api.platform, + titleBarMode: status?.titleBarMode, + titleBarEpoch: status?.titleBarEpoch, + titleBarDocumentNonce: status?.titleBarDocumentNonce, + focusedAttribute: document.documentElement.dataset.floWindowFocused, + desktopAttribute: document.documentElement.dataset.floDesktopTitlebar, + appInfo, + updateStatus, + readiness, + }; + }); + + expect(runtime.hasApi).toBe(true); + expect(runtime.platform).toBe(process.platform); + expect(runtime.titleBarMode).toBe('native-overlay'); + expect(runtime.titleBarEpoch).toBeGreaterThan(0); + expect(runtime.titleBarDocumentNonce).toMatch(/^[0-9a-f-]{36}$/i); + expect(runtime.focusedAttribute).toBe('true'); + expect(runtime.desktopAttribute).toBe('true'); + expect(runtime.appInfo).toMatchObject({ name: 'flo-desktop', platform: process.platform }); + expect(runtime.updateStatus.status).toBeTruthy(); + expect(runtime.updateStatus.info.version).toBeTruthy(); + expect(runtime.readiness).toEqual({ success: true }); +}); + +test('native window lifecycle is observable through the Electron boundary', async () => { + test.skip(!['darwin', 'win32', 'linux'].includes(process.platform), 'FloCafe native window lifecycle is unsupported on this platform'); + test.skip(process.platform === 'linux', 'Linux CI uses Xvfb without a window manager, so native minimize/restore is not observable'); + await harness.app.evaluate(({ app, BrowserWindow }) => { + app.focus({ steal: true }); + const window = BrowserWindow.getAllWindows()[0]; + window?.show(); + window?.focus(); + }); + const nativeWindow = await harness.app.browserWindow(harness.page); + await nativeWindow.evaluate((window) => window.minimize()); + await expect.poll(() => nativeWindow.evaluate((window) => window.isMinimized())).toBe(true); + await nativeWindow.evaluate((window) => window.restore()); + await expect.poll(() => nativeWindow.evaluate((window) => !window.isMinimized())).toBe(true); +}); + +test('native shell geometry remains an explicit platform boundary', async () => { + test.skip(true, 'Traffic-light and caption-button geometry is OS-managed and is not observable through Playwright in this harness; platform unit/runtime probes own that evidence'); +}); diff --git a/main/db.ts b/main/db.ts index a72b1b95..229913af 100644 --- a/main/db.ts +++ b/main/db.ts @@ -355,6 +355,9 @@ export function getDbHealth(): { ok: boolean; error?: string } { } export function getDbPath(): string { + // Native Playwright owns this path for its disposable local Electron run. + // It is intentionally opt-in and has no effect on normal desktop installs. + if (process.env.FLO_E2E_DB_PATH) return path.resolve(process.env.FLO_E2E_DB_PATH); const projectRoot = path.basename(path.dirname(__dirname)) === 'dist' ? path.resolve(__dirname, '../..') : path.resolve(__dirname, '..'); diff --git a/main/index.ts b/main/index.ts index 8754e199..8552c06b 100644 --- a/main/index.ts +++ b/main/index.ts @@ -393,7 +393,12 @@ let gotSingleInstanceLock = false; // Prevent multiple instances of the app from running simultaneously. // This is especially important on Linux where the AppImage can be launched // multiple times without the OS preventing it. -if (process.platform === 'linux') { +if (process.env.FLO_E2E_USER_DATA_DIR) { + // Native Playwright supplies a disposable profile so Electron's single + // instance lock, caches, and session storage cannot collide with a user or + // another test run. Normal launches retain their platform-specific paths. + app.setPath('userData', path.resolve(process.env.FLO_E2E_USER_DATA_DIR)); +} else if (process.platform === 'linux') { // Explicitly set app name and userData path to prevent Electron from // resolving them inside temporary mount paths (e.g. /tmp/.mount_FloXXXXXX) app.name = 'flo-desktop'; @@ -863,8 +868,12 @@ async function initialize(): Promise { console.log('[Flo] Initializing WhatsApp service...'); initWhatsAppFromDb(); - console.log('[Flo] Starting mDNS advertisement...'); - startMdns(); + // Native E2E owns an offline fixture; optional LAN discovery must not + // contend with a developer session or keep the test process alive. + if (process.env.FLO_E2E_SKIP_OPTIONAL_NETWORK !== '1') { + console.log('[Flo] Starting mDNS advertisement...'); + startMdns(); + } console.log('[Flo] Initializing printer...'); await initPrinter(); @@ -972,15 +981,19 @@ async function initialize(): Promise { // (#58) — checkForUpdates() itself decides whether Linux's build format // (AppImage vs deb/rpm/snap) actually supports self-update. if (!isStoreBuild) { - setupAutoUpdater(); - setTimeout(() => checkForUpdates(), 5000); + if (process.env.FLO_E2E_SKIP_OPTIONAL_NETWORK !== '1') { + setupAutoUpdater(); + setTimeout(() => checkForUpdates(), 5000); + } } else { // Store builds skip electron-updater entirely; seed the persisted state // so the renderer shows honest "managed by the store" status from the // first load instead of a stale never-checked default (#467). setUpdateStatus(oneShotUpdateState('store-managed')); } - setTimeout(() => { void checkTaxPackUpdatesOnStartup(); }, 5000); + if (process.env.FLO_E2E_SKIP_OPTIONAL_NETWORK !== '1') { + setTimeout(() => { void checkTaxPackUpdatesOnStartup(); }, 5000); + } console.log('[Flo] Ready!'); } catch (error) { diff --git a/tests/native-e2e-fixture.cjs b/tests/native-e2e-fixture.cjs new file mode 100644 index 00000000..285e7c78 --- /dev/null +++ b/tests/native-e2e-fixture.cjs @@ -0,0 +1,71 @@ +const Module = require('node:module'); +const bcrypt = require('bcryptjs'); + +const userDataDir = process.env.FLO_E2E_USER_DATA_DIR; +const originalLoad = Module._load; +Module._load = function (request, parent, isMain) { + if (request === 'electron') { + return { + app: { + isPackaged: true, + getPath: (name) => name === 'userData' ? userDataDir : userDataDir, + getVersion: () => 'e2e', + }, + }; + } + return originalLoad.apply(this, arguments); +}; + +const { initDatabase, getDatabase, closeDatabase, now } = require('../dist/main/db'); + +try { + if (!userDataDir || !process.env.FLO_E2E_DB_PATH) { + throw new Error('Native E2E fixture requires FLO_E2E_USER_DATA_DIR and FLO_E2E_DB_PATH'); + } + + initDatabase(); + const db = getDatabase(); + const createdAt = now(); + const settings = [ + ['business_name', 'Native E2E Cafe'], + ['country', 'CA'], + ['currency', 'CAD'], + ['timezone', 'America/Toronto'], + ['language', 'en'], + ['business_type', 'restaurant'], + ['service_model', 'qsr'], + ['billing_type', 'prepaid'], + ['tables_required', 'false'], + ['kds_enabled', 'true'], + ['whatsapp_enabled', 'false'], + ['taxes_enabled', 'false'], + ['cloud_sync_enabled', '0'], + ['cloud_orders_enabled', '0'], + ['cloud_reports_enabled', '0'], + ['cloud_command_polling_enabled', '0'], + ['cloud_services_disabled_by_user', 'true'], + ['telemetry_enabled', 'false'], + ['anonymous_data_consent', 'false'], + ['diagnostics_consent', 'false'], + ]; + const setting = db.prepare('INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)'); + for (const [key, value] of settings) setting.run(key, value, createdAt); + + db.prepare(` + INSERT INTO users (id, name, email, password, role, is_active, terms_accepted_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?) + `).run( + 'native-e2e-owner', + 'Native E2E Owner', + 'owner@flo.local', + bcrypt.hashSync('E2ePass123!', 10), + 'owner', + createdAt, + createdAt, + createdAt, + ); + + console.log('[Native E2E] Isolated database seeded'); +} finally { + try { closeDatabase(); } finally { Module._load = originalLoad; } +} From 36d9e71eb277aa4315ae5bf0d75458ab43faf669 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Thu, 27 Aug 2026 10:41:29 -0400 Subject: [PATCH 3/5] ci: separate browser and native Playwright projects --- .github/workflows/ci.yml | 54 +++++++++++++++++++++++++- docs/title-bar-platform-matrix.md | 7 ++++ frontend/e2e/helpers/urls.ts | 8 ++-- frontend/package.json | 3 +- frontend/playwright.config.ts | 19 +++++++-- frontend/playwright.electron.config.ts | 19 +++++++++ package.json | 5 ++- tests/e2e-server.cjs | 42 ++++++++++++++++++-- 8 files changed, 144 insertions(+), 13 deletions(-) create mode 100644 frontend/playwright.electron.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33a96898..2b6be5a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -227,7 +227,9 @@ jobs: - name: Run Playwright tests working-directory: frontend - run: npx playwright test + # This job is the browser-only layer. The Electron project has its own + # config and dedicated native job with an explicit display server. + run: npx playwright test --config=playwright.config.ts --project=chromium env: CI: true @@ -246,6 +248,56 @@ jobs: if-no-files-found: ignore retention-days: 10 + # 5b. Native Electron E2E + # Keep this lifecycle separate from the browser webServer. The native + # harness owns its temporary profile, database, ports, and app services. + native-e2e-playwright: + name: Native Electron Playwright Tests + needs: changes + if: ${{ needs.changes.outputs.frontend == 'true' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.kds == 'true' || needs.changes.outputs.db == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Node.js 22 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Rebuild native modules for Electron + run: npx @electron/rebuild -f -w better-sqlite3 || npm rebuild better-sqlite3 + + - name: Build main backend process + run: npm run build + + - name: Build frontend + run: npm run build:frontend + env: + NEXT_TELEMETRY_DISABLED: 1 + + - name: Install frontend dependencies for Playwright + working-directory: frontend + run: npm ci + + - name: Run native Electron tests under Xvfb + # Playwright supplies --no-sandbox for Electron on Linux. Xvfb is the + # required visible display; no packaged or modified app is launched. + run: xvfb-run -a --server-args='-screen 0 1280x800x24' npm run test:e2e:electron + env: + CI: true + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: always() + with: + name: native-playwright-report + path: frontend/playwright-report/ + retention-days: 10 + # 6. Windows Uninstaller Pester Suite windows-uninstaller: name: Windows Uninstaller Tests diff --git a/docs/title-bar-platform-matrix.md b/docs/title-bar-platform-matrix.md index e3b14cd3..1a35a375 100644 --- a/docs/title-bar-platform-matrix.md +++ b/docs/title-bar-platform-matrix.md @@ -16,6 +16,7 @@ This record uses assertion and log evidence first. The Linux XWD capture is supp | [`tests/window-readiness.test.ts`](../tests/window-readiness.test.ts) | Epoch/nonce-bound renderer readiness and fail-safe contract. | | [`tests/electron-api-contract.test.ts`](../tests/electron-api-contract.test.ts) | Preload `windowAction` and `windowReady` contract. | | [`frontend/e2e/title-bar-platform.spec.ts`](../frontend/e2e/title-bar-platform.spec.ts) | Browser/LAN multi-viewport and #504 sidebar regression checks. | +| [`frontend/e2e/desktop/title-bar.electron.spec.ts`](../frontend/e2e/desktop/title-bar.electron.spec.ts) | Dedicated native Electron readiness, authenticated dashboard, and window-boundary checks. | | [`frontend/e2e/layout-integrity.spec.ts`](../frontend/e2e/layout-integrity.spec.ts) | Existing browser/LAN geometry and zero-title-bar checks. | | [`docs/images/title-bar-platform-matrix/linux-runtime-probe.log`](images/title-bar-platform-matrix/linux-runtime-probe.log) | Debian 13 GNOME, Electron 43.4.1, Xvfb: 9/9 probe checks passed; WM-dependent action round-trip explicitly skipped because Xvfb had no WM. | | [`docs/images/title-bar-platform-matrix/appimage-run-summary.log`](images/title-bar-platform-matrix/appimage-run-summary.log) | Current-branch packaged AppImage startup under dedicated Xvfb display. | @@ -72,6 +73,12 @@ The local runtime is macOS with Electron 43.4.1. `npx electron tests/platform-ti **PASS-verified.** The same Playwright run checks expanded and collapsed/rail states on POS and settings at all three md+ viewports, plus the KDS and browser/LAN route geometry in the existing layout-integrity test. Browser mode remains at viewport top (`0px`); forcing the Electron capability flag moves the sidebar to the 40px title-bar boundary. +### 6. Dedicated native Electron harness (#525) + +The native suite runs from `playwright.electron.config.ts` with a disposable Electron user-data directory/database, a deterministic available three-port set, seeded owner authentication, and HTTP plus renderer IPC readiness barriers. It exercises the real preload, renderer, and main-process boundaries on the dashboard and verifies graceful shutdown closes the known PID and listeners. + +The hosted native job uses Linux Xvfb without a window manager. Native minimize/restore and pixel-level caption geometry are therefore explicit skips; the suite does not claim shell behavior that the environment cannot observe. The existing Windows matrix remains configuration/probe evidence only, and no macOS or Windows native Playwright result is claimed by CI. + ## Findings 1. **Packaged Electron hydration failure on Linux first-run/auth routes.** In the packaged AppImage under Xvfb, the renderer loaded `/setup/` and `/auth/login/` with `window.electronAPI.getStatus` and `windowReady` present, but React error #418 (hydration text mismatch) aborted client hydration. Consequently `DesktopDragSurface`/`WindowControls` did not mount and the readiness fail-safe logged after 10 seconds: `the renderer never confirmed its window-control surface`. The fail-safe made the window visible rather than leaving it hidden, but end-to-end renderer-control readiness on these routes is not verified by the AppImage run. Plain browser/LAN loading did not reproduce the error. This is recorded for the PR rather than silently expanding scope. diff --git a/frontend/e2e/helpers/urls.ts b/frontend/e2e/helpers/urls.ts index 59248a6c..a68cd330 100644 --- a/frontend/e2e/helpers/urls.ts +++ b/frontend/e2e/helpers/urls.ts @@ -1,9 +1,9 @@ const e2eKdsPort = process.env.E2E_KDS_PORT || '3002'; const e2eServerAppPort = process.env.E2E_SERVER_APP_PORT || '3003'; -export const E2E_BASE_URL = process.env.E2E_BASE_URL || 'http://localhost:3001'; +export const E2E_BASE_URL = process.env.E2E_BASE_URL || 'http://127.0.0.1:3001'; export const E2E_KDS_BASE_URL = process.env.E2E_KDS_BASE_URL - || (process.env.E2E_KDS_PORT ? `http://localhost:${e2eKdsPort}` : process.env.KDS_BASE_URL) - || 'http://localhost:3002'; + || (process.env.E2E_KDS_PORT ? `http://127.0.0.1:${e2eKdsPort}` : process.env.KDS_BASE_URL) + || 'http://127.0.0.1:3002'; export const E2E_SERVER_APP_BASE_URL = process.env.E2E_SERVER_APP_BASE_URL - || `http://localhost:${e2eServerAppPort}`; + || `http://127.0.0.1:${e2eServerAppPort}`; diff --git a/frontend/package.json b/frontend/package.json index d1a281a5..2ed61377 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,8 @@ "build": "next build --webpack", "start": "next start", "lint": "eslint", - "test:e2e": "playwright test" + "test:e2e": "playwright test --config=playwright.config.ts --project=chromium", + "test:e2e:electron": "playwright test --config=playwright.electron.config.ts --project=electron-desktop" }, "dependencies": { "@dnd-kit/dom": "^0.5.0", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 37ee50de..9ed10bac 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -1,18 +1,31 @@ import { defineConfig, devices } from '@playwright/test'; -import { E2E_BASE_URL, E2E_KDS_BASE_URL } from './e2e/helpers/urls'; +import { E2E_BASE_URL, E2E_KDS_BASE_URL, E2E_SERVER_APP_BASE_URL } from './e2e/helpers/urls'; +import path from 'node:path'; export default defineConfig({ testDir: './e2e', testMatch: /.*\.spec\.ts/, + testIgnore: /.*\.electron\.spec\.ts/, workers: 1, // Single shared backend server requires serial execution to prevent DB state races retries: process.env.CI ? 1 : 0, use: { trace: 'on-first-retry', // Upload traces for debugging CI flakes }, webServer: { - command: 'cd .. && node tests/run-electron-node-test.cjs tests/e2e-server.cjs', - url: `${E2E_KDS_BASE_URL}/api/health`, + command: 'node tests/e2e-server.cjs', + // The server app starts last, so its health endpoint is the browser + // harness's all-services-ready barrier. + url: `${E2E_SERVER_APP_BASE_URL}/api/health`, reuseExistingServer: !process.env.CI, + timeout: 120_000, + cwd: path.resolve(__dirname, '..'), + env: { + ...process.env, + E2E_TASK_LOCAL_PORTS: '1', + E2E_BASE_URL, + E2E_KDS_BASE_URL, + E2E_SERVER_APP_BASE_URL, + }, }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'], baseURL: E2E_BASE_URL } }, diff --git a/frontend/playwright.electron.config.ts b/frontend/playwright.electron.config.ts new file mode 100644 index 00000000..c49189db --- /dev/null +++ b/frontend/playwright.electron.config.ts @@ -0,0 +1,19 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + testMatch: /.*\.electron\.spec\.ts/, + workers: 1, + retries: process.env.CI ? 1 : 0, + use: { + ...devices['Desktop Chrome'], + viewport: { width: 1280, height: 800 }, + trace: 'on-first-retry', + }, + projects: [ + { + name: 'electron-desktop', + testMatch: /.*\.electron\.spec\.ts/, + }, + ], +}); diff --git a/package.json b/package.json index 923497dc..de4262f0 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,10 @@ "test:titlebar-window-options": "ts-node --transpile-only -P tests/tsconfig.json tests/titlebar-window-options.test.ts", "test:window-readiness": "ts-node --transpile-only -P tests/tsconfig.json tests/window-readiness.test.ts", "test:window-load-retry": "ts-node --transpile-only -P tests/tsconfig.json tests/window-load-retry.test.ts", - "test:e2e": "npm run build && npm run build:frontend && cd frontend && npx playwright install chromium && npx playwright test", + "test:e2e:server": "node tests/e2e-server.cjs", + "test:e2e:browser": "npm run build && npm run build:frontend && cd frontend && npx playwright install chromium && npx playwright test --config=playwright.config.ts --project=chromium", + "test:e2e:electron": "npm run build && npm run build:frontend && cd frontend && npx playwright test --config=playwright.electron.config.ts --project=electron-desktop", + "test:e2e": "npm run test:e2e:browser && npm run test:e2e:electron", "test:release-config": "ts-node --transpile-only -P tests/tsconfig.json tests/release-config.test.ts && ts-node --transpile-only -P tests/tsconfig.json tests/verify-electron-runtime-xattr.test.ts && node tests/release-asset-verifier.test.cjs && node tests/release-gate.test.cjs", "test:windows-uninstaller": "node tests/run-windows-uninstaller-tests.cjs", "test:telemetry": "node tests/run-electron-node-test.cjs tests/telemetry-delivery.test.ts", diff --git a/tests/e2e-server.cjs b/tests/e2e-server.cjs index 3a0a3d7d..7878c12e 100644 --- a/tests/e2e-server.cjs +++ b/tests/e2e-server.cjs @@ -3,6 +3,7 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); const Module = require('module'); +const { createServer } = require('node:net'); const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'flo-e2e-')); @@ -41,8 +42,8 @@ const crypto = require('crypto'); const bcrypt = require('bcryptjs'); const { initDatabase, getDatabase, closeDatabase, beginDatabaseShutdown, waitForDatabaseRequests, now } = require('../dist/main/db'); const { createExitCodeAwareShutdown, waitForHttpShutdownWork, isShutdownTimeout } = require('../dist/main/shutdown'); -const { startServer, stopServer } = require('../dist/main/server'); -const { startServerApp, stopServerApp } = require('../dist/main/server-app'); +const { startServer, stopServer, getServerPort } = require('../dist/main/server'); +const { startServerApp, stopServerApp, getServerAppPort } = require('../dist/main/server-app'); const { shutdown: shutdownWhatsApp, requestShutdown: requestWhatsAppShutdown } = require('../dist/main/services/whatsapp'); const { startStandaloneServers } = require('../dist/main/standalone-startup'); const flatRatePackData = require('./fixtures/synthetic-flat-rate-pack.json'); @@ -120,7 +121,30 @@ function installAndActivateTaxPack(db, pack) { ); } } -const { startKdsServer, stopKdsServer } = require('../dist/main/kds-server'); +const { startKdsServer, stopKdsServer, getKdsPort } = require('../dist/main/kds-server'); + +function expectedPort(rawUrl, fallback) { + if (!rawUrl) return fallback; + const port = Number(new URL(rawUrl).port); + return Number.isInteger(port) && port > 0 ? port : fallback; +} + +function assertRequiredPortAvailable(port, service) { + return new Promise((resolve, reject) => { + const probe = createServer(); + probe.once('error', (error) => { + probe.close(); + if (error.code === 'EADDRINUSE') { + reject(new Error(`E2E ${service} port ${port} is unavailable`)); + } else { + reject(error); + } + }); + probe.listen(port, '127.0.0.1', () => { + probe.close(() => resolve()); + }); + }); +} function seedUser(id, email, role) { getDatabase().prepare( @@ -230,6 +254,9 @@ async function stop(exitCode = 0) { } (async () => { + await assertRequiredPortAvailable(expectedPort(process.env.E2E_BASE_URL, 3001), 'Main API'); + await assertRequiredPortAvailable(expectedPort(process.env.E2E_KDS_BASE_URL, 3002), 'KDS'); + await assertRequiredPortAvailable(expectedPort(process.env.E2E_SERVER_APP_BASE_URL, 3003), 'Server App'); await startStandaloneServers({ initializeDatabase: initDatabase, prepare: () => { @@ -243,6 +270,15 @@ async function stop(exitCode = 0) { startServerApp, isShutdownRequested: () => shutdownRequested, }); + const expectedPorts = { + main: expectedPort(process.env.E2E_BASE_URL, 3001), + kds: expectedPort(process.env.E2E_KDS_BASE_URL, 3002), + serverApp: expectedPort(process.env.E2E_SERVER_APP_BASE_URL, 3003), + }; + const actualPorts = { main: getServerPort(), kds: getKdsPort(), serverApp: getServerAppPort() }; + if (Object.entries(expectedPorts).some(([key, port]) => actualPorts[key] !== port)) { + throw new Error(`E2E service port mismatch: expected ${JSON.stringify(expectedPorts)}, got ${JSON.stringify(actualPorts)}`); + } console.log('[E2E] Main, KDS, and Server App servers ready'); })().catch((error) => { console.error(error); From dd8c80e76363f1721a6675d7541020386d2be48d Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Thu, 27 Aug 2026 11:06:36 -0400 Subject: [PATCH 4/5] fix: harden native Electron E2E follow-up --- frontend/e2e/desktop/native-harness.ts | 9 ++++++--- package.json | 2 +- tests/native-e2e-fixture.cjs | 10 ++++++---- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/frontend/e2e/desktop/native-harness.ts b/frontend/e2e/desktop/native-harness.ts index 6417bb73..8527d9f2 100644 --- a/frontend/e2e/desktop/native-harness.ts +++ b/frontend/e2e/desktop/native-harness.ts @@ -1,4 +1,5 @@ import { createRequire } from 'node:module'; +import { randomBytes } from 'node:crypto'; import { createConnection, createServer } from 'node:net'; import { spawn } from 'node:child_process'; import { existsSync, mkdtempSync, rmSync } from 'node:fs'; @@ -221,7 +222,9 @@ export async function createNativeElectronHarness(): Promise = { ...inheritedEnv, NODE_ENV: 'test', - JWT_SECRET: 'native-e2e-test-secret', + JWT_SECRET: randomBytes(32).toString('hex'), + FLO_E2E_OWNER_EMAIL: `native-e2e-owner-${randomBytes(8).toString('hex')}@flo.local`, + FLO_E2E_OWNER_PASSWORD: `${randomBytes(24).toString('base64url')}Aa1!`, FLO_E2E_SKIP_OPTIONAL_NETWORK: '1', FLO_MATRIX_OFFLINE: '1', FLO_E2E_USER_DATA_DIR: profileDir, @@ -265,8 +268,8 @@ export async function createNativeElectronHarness(): Promise url.pathname.replace(/\/+$/, '') === '/pos', { timeout: 30_000 }); } diff --git a/package.json b/package.json index de4262f0..fc0383a1 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "test:e2e:server": "node tests/e2e-server.cjs", "test:e2e:browser": "npm run build && npm run build:frontend && cd frontend && npx playwright install chromium && npx playwright test --config=playwright.config.ts --project=chromium", "test:e2e:electron": "npm run build && npm run build:frontend && cd frontend && npx playwright test --config=playwright.electron.config.ts --project=electron-desktop", - "test:e2e": "npm run test:e2e:browser && npm run test:e2e:electron", + "test:e2e": "npm run test:e2e:browser", "test:release-config": "ts-node --transpile-only -P tests/tsconfig.json tests/release-config.test.ts && ts-node --transpile-only -P tests/tsconfig.json tests/verify-electron-runtime-xattr.test.ts && node tests/release-asset-verifier.test.cjs && node tests/release-gate.test.cjs", "test:windows-uninstaller": "node tests/run-windows-uninstaller-tests.cjs", "test:telemetry": "node tests/run-electron-node-test.cjs tests/telemetry-delivery.test.ts", diff --git a/tests/native-e2e-fixture.cjs b/tests/native-e2e-fixture.cjs index 285e7c78..aa84a854 100644 --- a/tests/native-e2e-fixture.cjs +++ b/tests/native-e2e-fixture.cjs @@ -2,6 +2,8 @@ const Module = require('node:module'); const bcrypt = require('bcryptjs'); const userDataDir = process.env.FLO_E2E_USER_DATA_DIR; +const ownerEmail = process.env.FLO_E2E_OWNER_EMAIL; +const ownerPassword = process.env.FLO_E2E_OWNER_PASSWORD; const originalLoad = Module._load; Module._load = function (request, parent, isMain) { if (request === 'electron') { @@ -19,8 +21,8 @@ Module._load = function (request, parent, isMain) { const { initDatabase, getDatabase, closeDatabase, now } = require('../dist/main/db'); try { - if (!userDataDir || !process.env.FLO_E2E_DB_PATH) { - throw new Error('Native E2E fixture requires FLO_E2E_USER_DATA_DIR and FLO_E2E_DB_PATH'); + if (!userDataDir || !process.env.FLO_E2E_DB_PATH || !ownerEmail || !ownerPassword) { + throw new Error('Native E2E fixture requires isolated profile, database, and owner credentials'); } initDatabase(); @@ -57,8 +59,8 @@ try { `).run( 'native-e2e-owner', 'Native E2E Owner', - 'owner@flo.local', - bcrypt.hashSync('E2ePass123!', 10), + ownerEmail, + bcrypt.hashSync(ownerPassword, 10), 'owner', createdAt, createdAt, From caf1acecb355ab3e8f135597239282a31c801162 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Thu, 27 Aug 2026 12:38:25 -0400 Subject: [PATCH 5/5] no-mistakes(document): verify docs and lint for electron harness --- frontend/e2e/desktop/title-bar.electron.spec.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/frontend/e2e/desktop/title-bar.electron.spec.ts b/frontend/e2e/desktop/title-bar.electron.spec.ts index 221ba178..fcc6ab4a 100644 --- a/frontend/e2e/desktop/title-bar.electron.spec.ts +++ b/frontend/e2e/desktop/title-bar.electron.spec.ts @@ -18,12 +18,22 @@ test.afterAll(async () => { test('real Electron mounts the root drag surface before authentication', async () => { await expect(harness.page.getByTestId('desktop-drag-surface')).toBeVisible(); await expect(harness.page.locator('html')).toHaveAttribute('data-flo-desktop-titlebar', 'true'); + if (process.env.FLO_E2E_EVIDENCE_DIR) { + await harness.page.screenshot({ + path: `${process.env.FLO_E2E_EVIDENCE_DIR}/01-native-electron-login-screen.png`, + }); + } }); test('real preload, renderer, and main boundaries reach an authenticated dashboard', async () => { await harness.authenticateDashboard(); await expect(harness.page.locator('[data-slot="sidebar-container"]')).toBeVisible(); await expect(harness.page.getByTestId('desktop-title-bar')).toBeVisible(); + if (process.env.FLO_E2E_EVIDENCE_DIR) { + await harness.page.screenshot({ + path: `${process.env.FLO_E2E_EVIDENCE_DIR}/02-native-electron-authenticated-dashboard.png`, + }); + } const runtime = await harness.page.evaluate(async () => { const api = window.electronAPI;