chore: dev-stack switches, shared e2e harness, and desktop shell rules

- `LIVE_RELOAD=false npm run dev` keeps the renderer alive across a machine
  suspend; the reload client otherwise destroys the session under test.
- `dev-peer.sh` plus a separate userdata dir runs a second local peer.
- `tools/voice-probe.js` samples peer state and RTP counters from a live
  window, persisting to localStorage so a renderer reload cannot erase it.
- e2e helpers for voice pairs, peer-role election, and a TURN relay.
- Electron single-instance and dev-client-load decisions move into rules
  files with colocated specs.
This commit is contained in:
2026-08-14 03:19:29 +02:00
parent d71e3a98da
commit e49b3ec112
41 changed files with 35947 additions and 50 deletions
+28 -7
View File
@@ -1,11 +1,11 @@
import { app } from 'electron';
import * as path from 'path';
import { createWindow, getMainWindow } from '../window/create-window';
import { resolveSecondInstanceAction } from './second-instance.rules';
const CUSTOM_PROTOCOL = 'toju';
const DEEP_LINK_PREFIX = `${CUSTOM_PROTOCOL}://`;
const DEV_SINGLE_INSTANCE_EXIT_CODE_ENV = 'METOYOU_SINGLE_INSTANCE_EXIT_CODE';
const DEV_RELOAD_EXISTING_ARG = '--metoyou-dev-reload-existing';
let pendingDeepLink: string | null = null;
@@ -42,6 +42,24 @@ function focusMainWindow(): void {
mainWindow.focus();
}
function reloadMainWindow(): void {
const mainWindow = getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) {
void createWindow();
return;
}
focusMainWindow();
if (mainWindow.webContents.isLoadingMainFrame()) {
return;
}
mainWindow.webContents.reloadIgnoringCache();
}
function forwardDeepLink(url: string): void {
const mainWindow = getMainWindow();
@@ -96,13 +114,16 @@ export function initializeDeepLinkHandling(): boolean {
}
app.on('second-instance', (_event, argv) => {
if (resolveDevSingleInstanceExitCode() != null && argv.includes(DEV_RELOAD_EXISTING_ARG)) {
app.relaunch();
app.exit(0);
return;
}
const action = resolveSecondInstanceAction({
argv,
devSingleInstanceExitCode: resolveDevSingleInstanceExitCode()
});
focusMainWindow();
if (action === 'reload-existing') {
reloadMainWindow();
} else {
focusMainWindow();
}
const deepLink = extractDeepLink(argv);
@@ -0,0 +1,44 @@
import {
describe,
expect,
it
} from 'vitest';
import { DEV_RELOAD_EXISTING_ARG, resolveSecondInstanceAction } from './second-instance.rules';
describe('resolveSecondInstanceAction', () => {
it('reloads the open window when a dev launch asks to reuse it', () => {
const action = resolveSecondInstanceAction({
argv: [
'electron',
'.',
DEV_RELOAD_EXISTING_ARG
],
devSingleInstanceExitCode: 23
});
expect(action).toBe('reload-existing');
});
it('never asks a packaged instance to reload, even with the dev argument', () => {
const action = resolveSecondInstanceAction({
argv: ['metoyou', DEV_RELOAD_EXISTING_ARG],
devSingleInstanceExitCode: null
});
expect(action).toBe('focus');
});
it('focuses the open window for an ordinary second launch', () => {
const action = resolveSecondInstanceAction({
argv: [
'electron',
'.',
'toju://invite/abc'
],
devSingleInstanceExitCode: 23
});
expect(action).toBe('focus');
});
});
+23
View File
@@ -0,0 +1,23 @@
export const DEV_RELOAD_EXISTING_ARG = '--metoyou-dev-reload-existing';
export type SecondInstanceAction = 'reload-existing' | 'focus';
export interface SecondInstanceInput {
argv: string[];
devSingleInstanceExitCode: number | null;
}
/**
* A dev launch always carries `--metoyou-dev-reload-existing`, so the running
* instance reloads in place. It must never answer by relaunching itself: the
* successor inherits the same argument and asks for the single-instance lock
* while the dying parent still holds it, so the refused successor fires
* `second-instance` again and the pair respawns forever.
*/
export function resolveSecondInstanceAction(input: SecondInstanceInput): SecondInstanceAction {
const isDevelopmentLaunch = input.devSingleInstanceExitCode != null;
return isDevelopmentLaunch && input.argv.includes(DEV_RELOAD_EXISTING_ARG)
? 'reload-existing'
: 'focus';
}
+48 -1
View File
@@ -11,6 +11,7 @@ import * as fs from 'fs';
import * as path from 'path';
import { DESKTOP_APP_DISPLAY_NAME } from '../app/desktop-branding.rules';
import { readDesktopSettings } from '../desktop-settings';
import { DEV_CLIENT_LOAD_ATTEMPTS, loadDevelopmentClientWithRetry } from './dev-client-load.rules';
import { resolveDevelopmentClientUrl } from './dev-client-url.rules';
import { shouldRegisterDisplayMediaHandler } from './display-media-handler.rules';
@@ -261,6 +262,52 @@ function ensureDisplayMediaRequestHandler(): void {
);
}
function describeError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function buildDevClientFailurePage(url: string, reason: string): string {
const escapedReason = reason.replace(/&/g, '&amp;').replace(/</g, '&lt;');
return `<!doctype html>
<html><body style="background:#0a0a0f;color:#e5e7eb;font:14px system-ui;padding:48px">
<h1 style="font-size:18px">The dev client did not load</h1>
<p>Could not load <code>${url}</code> after ${DEV_CLIENT_LOAD_ATTEMPTS} attempts.</p>
<p style="color:#f87171"><code>${escapedReason}</code></p>
<p>Check that <code>npm run dev</code> is still running, then reload with Ctrl+R.</p>
</body></html>`;
}
async function loadDevelopmentClient(window: BrowserWindow, url: string): Promise<void> {
let lastError: unknown = null;
const outcome = await loadDevelopmentClientWithRetry({
isAborted: () => window.isDestroyed(),
load: () => window.loadURL(url),
onGiveUp: (attempts, error) => {
lastError = error;
console.error(`[Window] Dev client at ${url} failed after ${attempts} attempts: ${describeError(error)}`);
},
onRetry: (attempt, error) => {
lastError = error;
console.warn(`[Window] Dev client at ${url} not ready (attempt ${attempt}): ${describeError(error)}. Retrying.`);
},
wait: (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs))
});
if (outcome !== 'failed' || window.isDestroyed()) {
return;
}
const failurePage = buildDevClientFailurePage(url, describeError(lastError));
try {
await window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(failurePage)}`);
} catch (error) {
console.error(`[Window] Could not show the dev client failure page: ${describeError(error)}`);
}
}
export async function createWindow(): Promise<void> {
const windowIconPath = getWindowIconPath();
@@ -290,7 +337,7 @@ export async function createWindow(): Promise<void> {
ensureDisplayMediaRequestHandler();
if (process.env['NODE_ENV'] === 'development') {
await mainWindow.loadURL(resolveDevelopmentClientUrl(process.env['SSL'] === 'true'));
await loadDevelopmentClient(mainWindow, resolveDevelopmentClientUrl(process.env['SSL'] === 'true'));
if (process.env['DEBUG_DEVTOOLS'] === '1') {
mainWindow.webContents.openDevTools();
@@ -0,0 +1,72 @@
import {
describe,
expect,
it,
vi
} from 'vitest';
import {
DEV_CLIENT_LOAD_ATTEMPTS,
DevClientLoadDeps,
loadDevelopmentClientWithRetry
} from './dev-client-load.rules';
function createDeps(overrides: Partial<DevClientLoadDeps> = {}): DevClientLoadDeps {
return {
isAborted: () => false,
load: vi.fn().mockResolvedValue(undefined),
onGiveUp: vi.fn(),
onRetry: vi.fn(),
wait: vi.fn().mockResolvedValue(undefined),
...overrides
};
}
describe('loadDevelopmentClientWithRetry', () => {
it('loads once when the dev server answers', async () => {
const deps = createDeps();
await expect(loadDevelopmentClientWithRetry(deps)).resolves.toBe('loaded');
expect(deps.load).toHaveBeenCalledTimes(1);
expect(deps.onRetry).not.toHaveBeenCalled();
});
it('retries a rebuild gap and reports the recovered load', async () => {
const load = vi.fn()
.mockRejectedValueOnce(new Error('ERR_CONNECTION_REFUSED (-102)'))
.mockResolvedValue(undefined);
const deps = createDeps({ load });
await expect(loadDevelopmentClientWithRetry(deps)).resolves.toBe('loaded');
expect(load).toHaveBeenCalledTimes(2);
expect(deps.onRetry).toHaveBeenCalledTimes(1);
expect(deps.wait).toHaveBeenCalledTimes(1);
});
it('reports failure instead of throwing so the caller still wires the window', async () => {
const error = new Error('ERR_FAILED (-2)');
const deps = createDeps({ load: vi.fn().mockRejectedValue(error) });
await expect(loadDevelopmentClientWithRetry(deps)).resolves.toBe('failed');
expect(deps.load).toHaveBeenCalledTimes(DEV_CLIENT_LOAD_ATTEMPTS);
expect(deps.onGiveUp).toHaveBeenCalledWith(DEV_CLIENT_LOAD_ATTEMPTS, error);
});
it('stops retrying once the window is gone', async () => {
let windowAlive = true;
const load = vi.fn().mockImplementation(() => {
windowAlive = false;
return Promise.reject(new Error('ERR_FAILED (-2)'));
});
const deps = createDeps({
isAborted: () => !windowAlive,
load
});
await expect(loadDevelopmentClientWithRetry(deps)).resolves.toBe('aborted');
expect(load).toHaveBeenCalledTimes(1);
expect(deps.onGiveUp).not.toHaveBeenCalled();
});
});
+49
View File
@@ -0,0 +1,49 @@
export const DEV_CLIENT_LOAD_ATTEMPTS = 10;
export const DEV_CLIENT_RETRY_DELAY_MS = 500;
export type DevClientLoadOutcome = 'loaded' | 'aborted' | 'failed';
export interface DevClientLoadDeps {
load: () => Promise<void>;
isAborted: () => boolean;
wait: (delayMs: number) => Promise<void>;
onRetry: (attempt: number, error: unknown) => void;
onGiveUp: (attempts: number, error: unknown) => void;
}
/**
* The dev client is served by a watch-mode build, so a load can fail for
* reasons that resolve on their own: a rebuild in flight, or a shutdown that
* aborted the navigation. Failing hard skipped every window listener
* registered after the load and left a blank window with no message, so this
* retries and always reports instead of throwing.
*/
export async function loadDevelopmentClientWithRetry(deps: DevClientLoadDeps): Promise<DevClientLoadOutcome> {
for (let attempt = 1; attempt <= DEV_CLIENT_LOAD_ATTEMPTS; attempt += 1) {
if (deps.isAborted()) {
return 'aborted';
}
try {
await deps.load();
return 'loaded';
} catch (error) {
if (deps.isAborted()) {
return 'aborted';
}
if (attempt === DEV_CLIENT_LOAD_ATTEMPTS) {
deps.onGiveUp(attempt, error);
return 'failed';
}
deps.onRetry(attempt, error);
await deps.wait(DEV_CLIENT_RETRY_DELAY_MS);
}
}
return 'failed';
}