Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions docs/title-bar-platform-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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.
Expand Down
297 changes: 297 additions & 0 deletions frontend/e2e/desktop/native-harness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
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';
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<void>;
close: () => Promise<void>;
}

function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

async function isPortAvailable(port: number): Promise<boolean> {
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<NativeServicePorts> {
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<string, string>): Promise<void> {
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<void> {
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<void> {
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<boolean> {
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<boolean> {
const deadline = Date.now() + PORT_CLOSE_TIMEOUT_MS;
while (Date.now() < deadline) {
const closed = await new Promise<boolean>((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<void> {
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<void>((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<never>((_, 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('; '));
}
}

Comment thread
greptile-apps[bot] marked this conversation as resolved.
export async function createNativeElectronHarness(): Promise<NativeElectronHarness> {
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<string, string> = {
...inheritedEnv,
NODE_ENV: 'test',
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,
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(env.FLO_E2E_OWNER_EMAIL);
await page.locator('#password').fill(env.FLO_E2E_OWNER_PASSWORD);
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;
}
}
Loading
Loading