From 3a8cac25a11ed207192d443562f899cecb54aede Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:14:28 +0200 Subject: [PATCH] fix(electron): restore tested startup splash window --- .github/workflows/electron-build-ci.yml | 48 +++++++++ electron/README.md | 24 +++++ electron/SplashWindow.test.ts | 130 ++++++++++++++++++++++++ electron/SplashWindow.ts | 101 ++++++++++++++++++ electron/jest.splash.config.cjs | 11 ++ electron/tsconfig.splash-test.json | 9 ++ package.json | 1 + scripts/test-electron-splash.cjs | 50 +++++++++ 8 files changed, 374 insertions(+) create mode 100644 .github/workflows/electron-build-ci.yml create mode 100644 electron/SplashWindow.test.ts create mode 100644 electron/SplashWindow.ts create mode 100644 electron/jest.splash.config.cjs create mode 100644 electron/tsconfig.splash-test.json create mode 100644 scripts/test-electron-splash.cjs diff --git a/.github/workflows/electron-build-ci.yml b/.github/workflows/electron-build-ci.yml new file mode 100644 index 00000000..5b0b38e3 --- /dev/null +++ b/.github/workflows/electron-build-ci.yml @@ -0,0 +1,48 @@ +name: Electron Launcher CI + +on: + pull_request: + paths: + - 'electron/**' + - 'package.json' + - 'package-lock.json' + - 'scripts/test-electron-splash.cjs' + - '.github/workflows/electron-build-ci.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + node: ['22.12.0', '24.19.0'] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: npm + - run: npm ci --ignore-scripts --no-audit --no-fund + - run: npm run test:electron:splash + - run: npm run electron:build + + splash-smoke: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24.19.0' + cache: npm + - run: npm ci --ignore-scripts --no-audit --no-fund + - run: npm run electron:build + - run: npm rebuild electron + # Xvfb/--no-sandbox apply only to this bounded CI harness, not the launcher. + # This check does not establish runtime sandbox enforcement or full-app startup. + - run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox scripts/test-electron-splash.cjs diff --git a/electron/README.md b/electron/README.md index 4cb251c2..96ac7a79 100644 --- a/electron/README.md +++ b/electron/README.md @@ -20,6 +20,30 @@ npm run electron:build npm run electron:build:watch ``` +Run these commands from the repository root. `config/package.json` is a legacy +snapshot with paths such as `electron/tsconfig.json` and `src/ui` that are not +relative to `config/`; it is not a second launcher entry point. + +### Startup status checks + +`SplashWindow.ts` implements the startup status window used by `main.ts`. It +buffers updates until its local document loads, renders messages as text, and +closes when the main window is ready. It exposes no renderer Node bridge. + +```bash +npm run test:electron:splash +npm run electron:build +# Requires the installed Electron binary and a graphical display: +./node_modules/.bin/electron scripts/test-electron-splash.cjs +``` + +The focused CI checks compilation on Node 22.12.0 and 24.19.0 plus an actual +Electron splash-window smoke on Linux/Xvfb. The smoke harness disables the process +sandbox only for CI; it does not validate sandbox enforcement, complete launcher +startup, backend/provider integration, the Creative Studio renderer, packaging, +or macOS/Windows behavior. The launcher's BrowserWindow sandbox settings remain +enabled. + ### Running ```bash diff --git a/electron/SplashWindow.test.ts b/electron/SplashWindow.test.ts new file mode 100644 index 00000000..17af24b2 --- /dev/null +++ b/electron/SplashWindow.test.ts @@ -0,0 +1,130 @@ +import { EventEmitter } from 'events'; +import { runInNewContext } from 'vm'; +import { BrowserWindow } from 'electron'; +import { SplashWindow } from './SplashWindow'; + +jest.mock('electron', () => ({ BrowserWindow: jest.fn() })); +jest.mock('./defaultPaths', () => ({ getAppIconPath: () => undefined })); + +class FakeWindow extends EventEmitter { + destroyed = false; + statusElement = { textContent: '' }; + progressElement = { value: 0 }; + renderer = { + document: { + getElementById: (id: string) => id === 'status' ? this.statusElement : this.progressElement, + }, + injected: false, + }; + webContents = Object.assign(new EventEmitter(), { + setWindowOpenHandler: jest.fn(), + executeJavaScript: jest.fn(async (script: string) => runInNewContext(script, this.renderer)), + }); + loadURL = jest.fn().mockResolvedValue(undefined); + show = jest.fn(); + isDestroyed = (): boolean => this.destroyed; + close = jest.fn(() => { + this.destroyed = true; + this.emit('closed'); + }); +} + +describe('SplashWindow startup lifecycle', () => { + let window: FakeWindow; + let splash: SplashWindow; + + beforeEach(() => { + jest.clearAllMocks(); + window = new FakeWindow(); + (BrowserWindow as unknown as jest.Mock).mockReturnValue(window); + splash = new SplashWindow(); + }); + + it('uses a sandboxed local document without a renderer Node bridge', () => { + expect(BrowserWindow).toHaveBeenCalledWith(expect.objectContaining({ + show: false, + webPreferences: { nodeIntegration: false, contextIsolation: true, sandbox: true }, + })); + const url = window.loadURL.mock.calls[0][0] as string; + expect(url).toMatch(/^data:text\/html;charset=utf-8,/); + expect(decodeURIComponent(url)).toContain("default-src 'none'"); + expect(window.show).not.toHaveBeenCalled(); + window.emit('ready-to-show'); + expect(window.show).toHaveBeenCalledTimes(1); + }); + + it('keeps the latest update sent before the document has loaded', () => { + splash.update('Initializing services...', 10); + splash.update('Connecting to backend engine...', 30); + expect(window.webContents.executeJavaScript).not.toHaveBeenCalled(); + window.webContents.emit('did-finish-load'); + expect(window.statusElement.textContent).toBe('Connecting to backend engine...'); + expect(window.progressElement.value).toBe(30); + }); + + it('renders quotes, markup and code-like status strings as literal text', () => { + window.webContents.emit('did-finish-load'); + const message = `"; globalThis.injected = true; // \nÉté`; + splash.update(message, 70); + expect(window.statusElement.textContent).toBe(message); + expect(window.renderer.injected).toBe(false); + expect(window.progressElement.value).toBe(70); + }); + + it.each([[-10, 0], [120, 100], [NaN, 0], [Infinity, 0], [-Infinity, 0]])( + 'bounds progress %s to %s', (input, expected) => { + window.webContents.emit('did-finish-load'); + splash.update('Loading interface...', input); + expect(window.progressElement.value).toBe(expected); + }, + ); + + it('ignores late load, show and update events after an early close', () => { + splash.close(); + splash.close(); + window.webContents.emit('did-finish-load'); + window.emit('ready-to-show'); + splash.update('Too late', 100); + expect(window.close).toHaveBeenCalledTimes(1); + expect(window.show).not.toHaveBeenCalled(); + expect(window.webContents.executeJavaScript).not.toHaveBeenCalled(); + }); + + it('ignores updates after the native window is closed', () => { + window.close(); + splash.update('Too late', 100); + splash.close(); + expect(window.close).toHaveBeenCalledTimes(1); + expect(window.webContents.executeJavaScript).not.toHaveBeenCalled(); + }); + + it('denies new windows and navigation away from the splash document', () => { + const openHandler = window.webContents.setWindowOpenHandler.mock.calls[0][0]; + expect(openHandler({ url: 'https://example.invalid/' })).toEqual({ action: 'deny' }); + const event = { preventDefault: jest.fn() }; + window.webContents.emit('will-navigate', event); + expect(event.preventDefault).toHaveBeenCalledTimes(1); + }); + + it('handles a document load failure without leaving the splash window open', async () => { + const warning = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + splash.close(); + window = new FakeWindow(); + window.loadURL.mockRejectedValue(new Error('load failed')); + (BrowserWindow as unknown as jest.Mock).mockReturnValue(window); + new SplashWindow(); + await Promise.resolve(); + expect(window.close).toHaveBeenCalledTimes(1); + expect(warning).toHaveBeenCalled(); + warning.mockRestore(); + }); + + it('handles a renderer update failure without an unhandled rejection', async () => { + const warning = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + window.webContents.executeJavaScript.mockRejectedValue(new Error('renderer stopped')); + window.webContents.emit('did-finish-load'); + await Promise.resolve(); + expect(warning).toHaveBeenCalled(); + warning.mockRestore(); + }); +}); diff --git a/electron/SplashWindow.ts b/electron/SplashWindow.ts new file mode 100644 index 00000000..0691b1db --- /dev/null +++ b/electron/SplashWindow.ts @@ -0,0 +1,101 @@ +import { BrowserWindow } from 'electron'; +import { getAppIconPath } from './defaultPaths'; + +const SPLASH_HTML = ` + + + + + StoryCore Creative Studio + + + +

StoryCore Creative Studio

+

Starting...

+ + +`; + +/** Startup status window used by main.ts until the main renderer is ready. */ +export class SplashWindow { + private window: BrowserWindow | null; + private loaded = false; + private status = { message: 'Starting...', progress: 0 }; + + constructor() { + const window = new BrowserWindow({ + width: 420, + height: 240, + frame: false, + resizable: false, + center: true, + alwaysOnTop: true, + show: false, + backgroundColor: '#1a1a2e', + icon: getAppIconPath(), + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + }, + }); + this.window = window; + + window.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); + window.webContents.on('will-navigate', (event) => event.preventDefault()); + window.webContents.once('did-finish-load', () => { + if (this.window === window && !window.isDestroyed()) { + this.loaded = true; + this.renderStatus(); + } + }); + window.once('ready-to-show', () => { + if (this.window === window && !window.isDestroyed()) window.show(); + }); + window.on('closed', () => { + this.window = null; + this.loaded = false; + }); + + void window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(SPLASH_HTML)}`) + .catch((error: unknown) => { + console.warn('Could not load startup status window:', error); + this.close(); + }); + } + + update(message: string, progress: number): void { + this.status = { + message, + progress: Number.isFinite(progress) ? Math.min(100, Math.max(0, progress)) : 0, + }; + this.renderStatus(); + } + + close(): void { + const window = this.window; + this.window = null; + this.loaded = false; + if (window && !window.isDestroyed()) window.close(); + } + + private renderStatus(): void { + const window = this.window; + if (!this.loaded || !window || window.isDestroyed()) return; + + // JSON quoting and textContent keep status messages as text, never markup/code. + const script = `(() => { + document.getElementById('status').textContent = ${JSON.stringify(this.status.message)}; + document.getElementById('progress').value = ${this.status.progress}; + })()`; + void window.webContents.executeJavaScript(script).catch((error: unknown) => { + if (!window.isDestroyed()) console.warn('Could not update startup status:', error); + }); + } +} diff --git a/electron/jest.splash.config.cjs b/electron/jest.splash.config.cjs new file mode 100644 index 00000000..953da1a7 --- /dev/null +++ b/electron/jest.splash.config.cjs @@ -0,0 +1,11 @@ +module.exports = { + rootDir: '..', + roots: ['/electron'], + testEnvironment: 'node', + testMatch: ['/electron/SplashWindow.test.ts'], + transform: { + '^.+\\.tsx?$': ['ts-jest', { + tsconfig: '/electron/tsconfig.splash-test.json', + }], + }, +}; diff --git a/electron/tsconfig.splash-test.json b/electron/tsconfig.splash-test.json new file mode 100644 index 00000000..92640392 --- /dev/null +++ b/electron/tsconfig.splash-test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["node", "jest"], + "noEmit": true + }, + "include": ["SplashWindow.ts", "SplashWindow.test.ts"], + "exclude": [] +} diff --git a/package.json b/package.json index 9e3f27ee..b8051e9d 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ }, "scripts": { "test": "jest", + "test:electron:splash": "jest --config electron/jest.splash.config.cjs --runInBand", "test:ui": "jest \"./src/ui/**/*.test.tsx\"", "cli:build": "tsc -p cli/tsconfig.json", "cli:dev": "tsc -p cli/tsconfig.json --watch", diff --git a/scripts/test-electron-splash.cjs b/scripts/test-electron-splash.cjs new file mode 100644 index 00000000..8022b37a --- /dev/null +++ b/scripts/test-electron-splash.cjs @@ -0,0 +1,50 @@ +// Run after electron:build, with Electron and a display (Xvfb on Linux CI). +const assert = require('node:assert/strict'); +const { once } = require('node:events'); +const { app, BrowserWindow } = require('electron'); +const { SplashWindow } = require('../dist/electron/SplashWindow.js'); + +const timeout = setTimeout(() => { + console.error('Splash smoke test timed out'); + app.exit(1); +}, 15000); + +app.whenReady().then(async () => { + const splash = new SplashWindow(); + const [window] = BrowserWindow.getAllWindows(); + assert.ok(window, 'Splash creates a native BrowserWindow'); + const loaded = once(window.webContents, 'did-finish-load'); + splash.update('Initializing services...', 10); + splash.update('Connecting to backend engine...', 30); + await loaded; + + const readStatus = () => window.webContents.executeJavaScript(`({ + message: document.getElementById('status').textContent, + progress: document.getElementById('progress').value, + hasNode: typeof require !== 'undefined' + })`); + assert.deepEqual(await readStatus(), { + message: 'Connecting to backend engine...', progress: 30, hasNode: false, + }); + const message = `Loading interface... É " `; + splash.update(message, 90); + assert.deepEqual(await readStatus(), { message, progress: 90, hasNode: false }); + + const closed = once(window, 'closed'); + splash.close(); + await closed; + splash.close(); + splash.update('Too late', 100); + assert.equal(BrowserWindow.getAllWindows().length, 0); + console.log('STORYCORE_SPLASH_SMOKE_PASS', JSON.stringify({ + electron: process.versions.electron, + node: process.versions.node, + scope: 'splash document, progress and close lifecycle', + })); + clearTimeout(timeout); + app.exit(0); +}).catch((error) => { + console.error(error); + clearTimeout(timeout); + app.exit(1); +});