Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d90c517
build: add electron-vite devDependency at workspace root
CoderCoco May 31, 2026
9019311
feat(desktop-main): add Electron app entry with BrowserWindow and IPC…
CoderCoco Jun 1, 2026
56c44ca
test(desktop-main): add electron-entry unit tests for BrowserWindow a…
CoderCoco Jun 1, 2026
51b108d
build: add electron.vite.config.ts with main/preload/renderer pipelines
CoderCoco Jun 1, 2026
7f6d494
build: wire desktop:dev and desktop:build scripts; drop Epic A stubs
CoderCoco Jun 1, 2026
16536dc
docs: document desktop:dev and desktop:build in CLAUDE.md
CoderCoco Jun 1, 2026
fedacee
fix(desktop-main): Glenn — surface bootstrap() rejection instead of s…
CoderCoco Jun 1, 2026
211b8f8
fix(desktop-main): Haise — correct test descriptions to reflect modul…
CoderCoco Jun 1, 2026
e02f782
fix: regenerate package-lock.json with Node 24/npm 11 to include cros…
CoderCoco Jun 1, 2026
49fdc3e
fix: tighten engines.node to match electron-vite@5 requirement
CoderCoco Jun 1, 2026
1648270
ci: cache Playwright browsers in e2e and integration workflows
CoderCoco Jun 1, 2026
917b645
chore: drop Node 20 support — require >=22.12.0
CoderCoco Jun 1, 2026
2c8fc40
chore: remove commented-out Claude Actions workflows
CoderCoco Jun 1, 2026
27b038e
ci: split Playwright install into deps + binary steps with timeouts
CoderCoco Jun 1, 2026
a3698ca
ci: use system Chrome instead of downloading Chromium in CI
CoderCoco Jun 1, 2026
ba3b1d9
ci: add timeout-minutes: 5 to install-deps step
CoderCoco Jun 1, 2026
93314d4
ci: install Playwright ffmpeg for video capture on test failure
CoderCoco Jun 1, 2026
b132b30
ci: disable video recording in CI; remove ffmpeg install step
CoderCoco Jun 1, 2026
81f6126
fix(desktop-main): lazy-load electron-store to avoid requiring electr…
CoderCoco Jun 1, 2026
18b774d
fix(build): address Copilot review — correct electron-vite script pat…
CoderCoco Jun 1, 2026
850bdb6
fix(desktop-main): use module-level import type for electron-store cast
CoderCoco Jun 3, 2026
d2d8ca3
fix(desktop-main): handle loadURL/loadFile rejection in createWindow
CoderCoco Jun 3, 2026
6d72d3b
fix(build): use dynamic import for electron-store; anchor config path…
CoderCoco Jun 3, 2026
0414342
docs(CLAUDE.md): clarify that desktop:build produces the Electron bundle
CoderCoco Jun 3, 2026
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
6 changes: 6 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ jobs:

- run: npm ci

- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-chromium-${{ runner.os }}-${{ hashFiles('app/packages/web/package-lock.json', 'package-lock.json') }}

- name: Install Playwright browsers
run: npx playwright install chromium --with-deps
working-directory: app/packages/web
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ jobs:

- run: npm ci

- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-chromium-${{ runner.os }}-${{ hashFiles('app/packages/web/package-lock.json', 'package-lock.json') }}

- name: Install Playwright browsers
run: npx playwright install chromium --with-deps
working-directory: app/packages/web
Expand Down
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ npm run app:dev
# Production build (shared → server → web)
npm run app:build && npm run app:start # http://localhost:3001
Comment thread
CoderCoco marked this conversation as resolved.
Outdated
Comment thread
CoderCoco marked this conversation as resolved.
Outdated

# Electron desktop app — electron-vite drives three pipelines (main/preload/renderer)
# configured in electron.vite.config.ts; outputs land in out/main, out/preload, out/renderer
npm run desktop:dev # electron-vite dev: HMR on renderer saves, auto-restarts main+preload
npm run desktop:build # electron-vite build: produces out/main, out/preload, out/renderer

# Build all Lambda bundles (required before `terraform apply`)
npm run app:build:lambdas

Expand Down
1 change: 1 addition & 0 deletions app/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export default tseslint.config(
{
ignores: [
'**/dist/**',
'**/out/**',
'**/node_modules/**',
'**/*.d.ts',
'packages/web/vite.config.ts',
Expand Down
4 changes: 2 additions & 2 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
"type": "module",
"scripts": {
"predev": "node scripts/embed-tfstate.mjs",
"dev": "echo 'app:dev requires the Electron launcher from Epic A (#136). Run npm run dev -w @hyveon/web for the web-only dev server.' && exit 1",
"dev": "electron-vite dev",
"prebuild": "node scripts/embed-tfstate.mjs",
"build": "npm run build -w @hyveon/shared && npm run build -w @hyveon/desktop-main && npm run build -w @hyveon/web",
"build:lambdas": "npm run build -w @hyveon/shared && npm run build -w @hyveon/lambda-interactions -w @hyveon/lambda-followup -w @hyveon/lambda-update-dns -w @hyveon/lambda-watchdog -w @hyveon/lambda-efs-seeder",
"start": "echo 'app:start requires the Electron launcher from Epic A (#136).' && exit 1",
"start": "electron-vite build",
Comment thread
CoderCoco marked this conversation as resolved.
Outdated
Comment thread
CoderCoco marked this conversation as resolved.
Outdated
Comment thread
CoderCoco marked this conversation as resolved.
Outdated
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:watch": "vitest",
Expand Down
4 changes: 2 additions & 2 deletions app/packages/desktop-main/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
"type": "module",
"main": "./dist/main.js",
"scripts": {
"dev": "echo 'desktop-main now runs as an Electron IPC microservice. The Electron launcher is wired in Epic A (#136).' && exit 1",
"dev": "electron-vite dev",
"build": "tsc -b",
"start": "echo 'desktop-main now runs as an Electron IPC microservice. The Electron launcher is wired in Epic A (#136).' && exit 1"
"start": "electron-vite build"
Comment thread
CoderCoco marked this conversation as resolved.
Outdated
Comment thread
CoderCoco marked this conversation as resolved.
Outdated
Comment thread
CoderCoco marked this conversation as resolved.
Outdated
},
"dependencies": {
"@aws-sdk/client-cloudwatch-logs": "^3.600.0",
Expand Down
241 changes: 241 additions & 0 deletions app/packages/desktop-main/src/electron-entry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

/*
* Spy variables must be hoisted before vi.mock() factories run, because
* vi.mock() calls are lifted to the top of the compiled output above regular
* declarations.
*/
const {
mockLoadURL,
mockLoadFile,
mockQuit,
mockOn,
mockWhenReady,
MockBrowserWindow,
mockGetAllWindows,
bootstrapMock,
whenReadyCallbacks,
onCallbacks,
} = vi.hoisted(() => {
const mockLoadURL = vi.fn().mockResolvedValue(undefined);
const mockLoadFile = vi.fn().mockResolvedValue(undefined);
const mockQuit = vi.fn();
const mockGetAllWindows = vi.fn().mockReturnValue([]);

/**
* Collects every callback passed to `app.whenReady().then(cb)`.
* Tests can fire them on demand by calling `whenReadyCallbacks[n]()`.
*/
const whenReadyCallbacks: Array<() => void> = [];

/**
* Collects every callback registered via `app.on(event, cb)` keyed by
* event name, so tests can trigger lifecycle events synchronously.
*/
const onCallbacks: Record<string, () => void> = {};

const mockOn = vi.fn((event: string, cb: () => void) => {
onCallbacks[event] = cb;
});

/**
* Returns a thenable that stores the `.then()` callback instead of
* resolving it, giving tests full control over when the ready handler fires.
*/
const mockWhenReady = vi.fn(() => ({
then: (cb: () => void) => {
whenReadyCallbacks.push(cb);
return { then: vi.fn() };
},
}));

/** Spy BrowserWindow constructor whose instances expose controlled load fns. */
const MockBrowserWindow = vi.fn().mockImplementation(() => ({
loadURL: mockLoadURL,
loadFile: mockLoadFile,
}));

/** `BrowserWindow.getAllWindows()` static method used by the activate handler. */
MockBrowserWindow.getAllWindows = mockGetAllWindows;

/** Spy for `bootstrap` imported from `./main.js`. */
const bootstrapMock = vi.fn().mockResolvedValue(undefined);

return {
mockLoadURL,
mockLoadFile,
mockQuit,
mockOn,
mockWhenReady,
MockBrowserWindow,
mockGetAllWindows,
bootstrapMock,
whenReadyCallbacks,
onCallbacks,
};
});

vi.mock('electron', () => ({
app: {
whenReady: mockWhenReady,
on: mockOn,
quit: mockQuit,
},
BrowserWindow: MockBrowserWindow,
}));

vi.mock('./main.js', () => ({
bootstrap: bootstrapMock,
}));

/** Flush the micro-task / timer queue so async chains fully settle. */
async function flushPromises(): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}

describe('electron-entry', () => {
beforeEach(() => {
mockLoadURL.mockResolvedValue(undefined);
mockLoadFile.mockResolvedValue(undefined);
mockQuit.mockImplementation(() => undefined);
bootstrapMock.mockResolvedValue(undefined);
mockGetAllWindows.mockReturnValue([]);

// Re-apply the BrowserWindow constructor implementation in case clearMocks
// cleared it between tests (clearMocks resets call history and return value
// queues; mockImplementation persists, but we re-set to be defensive).
MockBrowserWindow.mockImplementation(() => ({
loadURL: mockLoadURL,
loadFile: mockLoadFile,
}));

// Re-apply mockOn and mockWhenReady implementations so callback capturing
// works correctly after clearMocks resets the call history.
mockOn.mockImplementation((event: string, cb: () => void) => {
onCallbacks[event] = cb;
});
mockWhenReady.mockImplementation(() => ({
then: (cb: () => void) => {
whenReadyCallbacks.push(cb);
return { then: vi.fn() };
},
}));

// Reset the callback queues so each test starts clean.
whenReadyCallbacks.length = 0;
for (const key of Object.keys(onCallbacks)) {
delete onCallbacks[key];
}
});

afterEach(() => {
vi.unstubAllEnvs();
});

it('should call bootstrap() inside the app.whenReady() callback', async () => {
vi.resetModules();
delete process.env['ELECTRON_RENDERER_URL'];

await import('./electron-entry.js');
await flushPromises();

// Fire the whenReady callback that the module registered at import time.
expect(whenReadyCallbacks).toHaveLength(1);
whenReadyCallbacks[0]!();
await flushPromises();

expect(bootstrapMock).toHaveBeenCalledOnce();
});

it('should call win.loadURL() with the dev server URL when ELECTRON_RENDERER_URL is set', async () => {
vi.resetModules();
process.env['ELECTRON_RENDERER_URL'] = 'http://localhost:5173';

await import('./electron-entry.js');
await flushPromises();

expect(whenReadyCallbacks).toHaveLength(1);
whenReadyCallbacks[0]!();
await flushPromises();

expect(mockLoadURL).toHaveBeenCalledOnce();
expect(mockLoadURL).toHaveBeenCalledWith('http://localhost:5173');
expect(mockLoadFile).not.toHaveBeenCalled();
});

it('should call win.loadFile() with the production renderer path when ELECTRON_RENDERER_URL is not set', async () => {
vi.resetModules();
delete process.env['ELECTRON_RENDERER_URL'];

await import('./electron-entry.js');
await flushPromises();

expect(whenReadyCallbacks).toHaveLength(1);
whenReadyCallbacks[0]!();
await flushPromises();

expect(mockLoadFile).toHaveBeenCalledOnce();
expect(mockLoadURL).not.toHaveBeenCalled();

// The path must end with the standard electron-vite renderer bundle location.
const calledPath = mockLoadFile.mock.calls[0]?.[0] as string;
expect(calledPath).toMatch(/renderer[/\\]index\.html$/);
});

it('should call app.quit() on window-all-closed for non-macOS platforms', async () => {
vi.resetModules();
delete process.env['ELECTRON_RENDERER_URL'];

await import('./electron-entry.js');
await flushPromises();

const handler = onCallbacks['window-all-closed'];
expect(handler).toBeDefined();

const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });

handler!();

expect(mockQuit).toHaveBeenCalledOnce();

Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
});

it('should NOT call app.quit() on window-all-closed on macOS', async () => {
vi.resetModules();
delete process.env['ELECTRON_RENDERER_URL'];

await import('./electron-entry.js');
await flushPromises();

const handler = onCallbacks['window-all-closed'];
expect(handler).toBeDefined();

const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });

handler!();

expect(mockQuit).not.toHaveBeenCalled();

Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
});

it('should call app.quit() and not open a window when bootstrap() rejects', async () => {
vi.resetModules();
delete process.env['ELECTRON_RENDERER_URL'];

bootstrapMock.mockRejectedValueOnce(new Error('IPC init failure'));

await import('./electron-entry.js');
await flushPromises();

expect(whenReadyCallbacks).toHaveLength(1);
whenReadyCallbacks[0]!();
await flushPromises();

expect(mockQuit).toHaveBeenCalledOnce();
expect(MockBrowserWindow).not.toHaveBeenCalled();
});
});
55 changes: 55 additions & 0 deletions app/packages/desktop-main/src/electron-entry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { app, BrowserWindow } from 'electron';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { bootstrap } from './main.js';

// electron-vite injects __dirname for main-process entries, but we also
// compute it explicitly via import.meta.url so the file is valid plain ESM.
const __dirname = path.dirname(fileURLToPath(import.meta.url));

/**
* Creates the main application window with the preload script wired in and
* loads either the dev server URL or the production renderer bundle.
*/
function createWindow(): void {
const win = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
// electron-vite outputs the preload bundle to out/preload/index.js by
// default. __dirname here resolves to out/main, so we go one level up.
preload: path.join(__dirname, '../preload/index.js'),
contextIsolation: true,
sandbox: true,
},
});

if (process.env.ELECTRON_RENDERER_URL) {
void win.loadURL(process.env.ELECTRON_RENDERER_URL);
} else {
void win.loadFile(path.join(__dirname, '../renderer/index.html'));
}
Comment thread
CoderCoco marked this conversation as resolved.
Outdated
}

app.whenReady().then(() => {
bootstrap()
.then(() => {
createWindow();

// On macOS re-create the window when the dock icon is clicked and there
// are no other windows open (standard macOS behaviour).
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
})
.catch((err: unknown) => {
console.error('[desktop-main] NestJS IPC bootstrap failed — quitting:', err);
app.quit();
});
});

// Quit the app when all windows are closed, except on macOS where the app and
// its menu bar conventionally stay active until the user explicitly quits.
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
Loading
Loading