-
Notifications
You must be signed in to change notification settings - Fork 0
feat(build): adopt electron-vite for dev + build pipeline #228
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
CoderCoco
merged 24 commits into
main
from
claude/issue-144-feat-build-adopt-electron-vite-for-dev-build-pipel
Jun 3, 2026
Merged
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 9019311
feat(desktop-main): add Electron app entry with BrowserWindow and IPC…
CoderCoco 56c44ca
test(desktop-main): add electron-entry unit tests for BrowserWindow a…
CoderCoco 51b108d
build: add electron.vite.config.ts with main/preload/renderer pipelines
CoderCoco 7f6d494
build: wire desktop:dev and desktop:build scripts; drop Epic A stubs
CoderCoco 16536dc
docs: document desktop:dev and desktop:build in CLAUDE.md
CoderCoco fedacee
fix(desktop-main): Glenn — surface bootstrap() rejection instead of s…
CoderCoco 211b8f8
fix(desktop-main): Haise — correct test descriptions to reflect modul…
CoderCoco e02f782
fix: regenerate package-lock.json with Node 24/npm 11 to include cros…
CoderCoco 49fdc3e
fix: tighten engines.node to match electron-vite@5 requirement
CoderCoco 1648270
ci: cache Playwright browsers in e2e and integration workflows
CoderCoco 917b645
chore: drop Node 20 support — require >=22.12.0
CoderCoco 2c8fc40
chore: remove commented-out Claude Actions workflows
CoderCoco 27b038e
ci: split Playwright install into deps + binary steps with timeouts
CoderCoco a3698ca
ci: use system Chrome instead of downloading Chromium in CI
CoderCoco ba3b1d9
ci: add timeout-minutes: 5 to install-deps step
CoderCoco 93314d4
ci: install Playwright ffmpeg for video capture on test failure
CoderCoco b132b30
ci: disable video recording in CI; remove ffmpeg install step
CoderCoco 81f6126
fix(desktop-main): lazy-load electron-store to avoid requiring electr…
CoderCoco 18b774d
fix(build): address Copilot review — correct electron-vite script pat…
CoderCoco 850bdb6
fix(desktop-main): use module-level import type for electron-store cast
CoderCoco d2d8ca3
fix(desktop-main): handle loadURL/loadFile rejection in createWindow
CoderCoco 6d72d3b
fix(build): use dynamic import for electron-store; anchor config path…
CoderCoco 0414342
docs(CLAUDE.md): clarify that desktop:build produces the Electron bundle
CoderCoco File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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')); | ||
| } | ||
|
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(); | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.