Retry the initial renderer load once, then resolve repeated failures through a visible safe surface or a controlled close. Bound renderer crash reloads until a successful ready signal resets recovery, keep terminal handling idempotent, and cover retries, fallback failures, reload failures, ready resets, and late events with focused tests.
363 lines
10 KiB
JavaScript
363 lines
10 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { EventEmitter } = require('node:events');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const {
|
|
configureStartupRenderer,
|
|
createStartupRecoveryCoordinator,
|
|
createStartupWindow,
|
|
resolveStartupLanguage
|
|
} = require('../lib/startup-renderer');
|
|
|
|
class TestBrowserWindow extends EventEmitter {
|
|
constructor(options) {
|
|
super();
|
|
this.options = options;
|
|
this.showCalls = 0;
|
|
this.startupEvents = [];
|
|
this.loadError = new Error('renderer load failed');
|
|
this.loadOptions = null;
|
|
}
|
|
|
|
once(eventName, listener) {
|
|
this.startupEvents.push(`listen:${eventName}`);
|
|
return super.once(eventName, listener);
|
|
}
|
|
|
|
show() {
|
|
this.showCalls++;
|
|
}
|
|
|
|
loadFile(target, options) {
|
|
this.startupEvents.push(`load:${target}`);
|
|
this.loadOptions = options;
|
|
return this.loadError ? Promise.reject(this.loadError) : Promise.resolve('loaded');
|
|
}
|
|
}
|
|
|
|
test('configureStartupRenderer leaves hardware acceleration enabled for a local Windows session', () => {
|
|
let calls = 0;
|
|
configureStartupRenderer({ disableHardwareAcceleration() { calls++; } }, { SESSIONNAME: 'Console' }, 'win32');
|
|
assert.equal(calls, 0);
|
|
});
|
|
|
|
test('configureStartupRenderer disables hardware acceleration for a Windows Remote Desktop session', () => {
|
|
let calls = 0;
|
|
configureStartupRenderer({ disableHardwareAcceleration() { calls++; } }, { SESSIONNAME: 'RDP-Tcp#12' }, 'win32');
|
|
assert.equal(calls, 1);
|
|
});
|
|
|
|
test('resolveStartupLanguage accepts only the supported persisted language', () => {
|
|
assert.equal(resolveStartupLanguage({ globalSettings: { language: 'de' } }), 'de');
|
|
assert.equal(resolveStartupLanguage({ globalSettings: { language: 'en' } }), 'en');
|
|
assert.equal(resolveStartupLanguage({ globalSettings: { language: 'fr' } }), 'en');
|
|
assert.equal(resolveStartupLanguage(null), 'en');
|
|
});
|
|
|
|
test('createStartupWindow forces the main window to start hidden', () => {
|
|
const startup = createStartupWindow(TestBrowserWindow, { width: 1100, show: true });
|
|
|
|
assert.equal(startup.window.options.width, 1100);
|
|
assert.equal(startup.window.options.show, false);
|
|
});
|
|
|
|
test('main window uses the branded application icon', () => {
|
|
const projectRoot = path.join(__dirname, '..');
|
|
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
|
|
const createWindowStart = mainSource.indexOf('function createWindow()');
|
|
const createWindowEnd = mainSource.indexOf('\nfunction createTray()', createWindowStart);
|
|
const createWindowSource = mainSource.slice(createWindowStart, createWindowEnd);
|
|
|
|
assert.equal(fs.existsSync(path.join(projectRoot, 'assets', 'app_icon.ico')), true);
|
|
assert.match(createWindowSource, /icon:\s*path\.join\(__dirname, ['"]assets['"], ['"]app_icon\.ico['"]\)/u);
|
|
});
|
|
|
|
test('startup load registers visibility before navigation and shows only once', async () => {
|
|
const startup = createStartupWindow(TestBrowserWindow, {});
|
|
startup.window.loadError = null;
|
|
const loading = startup.load('renderer/index.html', () => {});
|
|
|
|
assert.deepEqual(startup.window.startupEvents, [
|
|
'listen:ready-to-show',
|
|
'load:renderer/index.html'
|
|
]);
|
|
|
|
startup.window.emit('ready-to-show');
|
|
startup.window.emit('ready-to-show');
|
|
await loading;
|
|
|
|
assert.equal(startup.window.showCalls, 1);
|
|
});
|
|
|
|
test('startup load forwards a rejected navigation to the error handler', async () => {
|
|
const startup = createStartupWindow(TestBrowserWindow, {});
|
|
let handledError;
|
|
|
|
await startup.load('renderer/index.html', (err) => {
|
|
handledError = err;
|
|
});
|
|
|
|
assert.equal(handledError, startup.window.loadError);
|
|
});
|
|
|
|
test('startup load forwards navigation options before the renderer becomes visible', async () => {
|
|
const startup = createStartupWindow(TestBrowserWindow, {});
|
|
const options = { query: { language: 'de' } };
|
|
|
|
await startup.load('renderer/index.html', () => {}, options);
|
|
|
|
assert.deepEqual(startup.window.loadOptions, options);
|
|
});
|
|
|
|
test('startup recovery retries the initial load exactly once before succeeding', async () => {
|
|
const attempts = [];
|
|
const options = { query: { language: 'de' } };
|
|
const coordinator = createStartupRecoveryCoordinator({
|
|
async load(...args) {
|
|
attempts.push(args);
|
|
if (attempts.length === 1) throw new Error('first load failed');
|
|
return 'loaded';
|
|
},
|
|
reload() {},
|
|
reveal() {},
|
|
close() {}
|
|
});
|
|
|
|
const result = await coordinator.loadInitial('renderer/index.html', options);
|
|
|
|
assert.equal(result, 'loaded');
|
|
assert.deepEqual(attempts, [
|
|
['renderer/index.html', options],
|
|
['renderer/index.html', options]
|
|
]);
|
|
});
|
|
|
|
test('startup recovery reveals a safe failure surface after both initial loads fail', async () => {
|
|
const loadErrors = [new Error('first load failed'), new Error('second load failed')];
|
|
const safeFailures = [];
|
|
let loadCalls = 0;
|
|
let revealCalls = 0;
|
|
let closeCalls = 0;
|
|
const coordinator = createStartupRecoveryCoordinator({
|
|
async load() {
|
|
throw loadErrors[loadCalls++];
|
|
},
|
|
reload() {},
|
|
reveal() {
|
|
revealCalls++;
|
|
},
|
|
async showFailure(failure) {
|
|
safeFailures.push(failure);
|
|
},
|
|
close() {
|
|
closeCalls++;
|
|
}
|
|
});
|
|
|
|
await coordinator.loadInitial('renderer/index.html');
|
|
|
|
assert.equal(loadCalls, 2);
|
|
assert.deepEqual(safeFailures, [{
|
|
phase: 'initial-load',
|
|
attempt: 2,
|
|
error: loadErrors[1]
|
|
}]);
|
|
assert.equal(revealCalls, 1);
|
|
assert.equal(closeCalls, 0);
|
|
});
|
|
|
|
test('startup recovery closes when the safe failure surface cannot be shown', async () => {
|
|
const loadError = new Error('renderer remains unavailable');
|
|
const surfaceError = new Error('failure surface failed');
|
|
const closeFailures = [];
|
|
let revealCalls = 0;
|
|
const coordinator = createStartupRecoveryCoordinator({
|
|
async load() {
|
|
throw loadError;
|
|
},
|
|
reload() {},
|
|
reveal() {
|
|
revealCalls++;
|
|
},
|
|
async showFailure() {
|
|
throw surfaceError;
|
|
},
|
|
async close(failure) {
|
|
closeFailures.push(failure);
|
|
}
|
|
});
|
|
|
|
await coordinator.loadInitial('renderer/index.html');
|
|
|
|
assert.equal(revealCalls, 0);
|
|
assert.deepEqual(closeFailures, [{
|
|
phase: 'initial-load',
|
|
attempt: 2,
|
|
error: loadError,
|
|
surfaceError
|
|
}]);
|
|
});
|
|
|
|
test('startup recovery uses a controlled close when no failure surface is configured', async () => {
|
|
const loadError = new Error('renderer unavailable');
|
|
const closeFailures = [];
|
|
let revealCalls = 0;
|
|
const coordinator = createStartupRecoveryCoordinator({
|
|
async load() {
|
|
throw loadError;
|
|
},
|
|
reload() {},
|
|
reveal() {
|
|
revealCalls++;
|
|
},
|
|
async close(failure) {
|
|
closeFailures.push(failure);
|
|
}
|
|
});
|
|
|
|
await coordinator.loadInitial('renderer/index.html');
|
|
|
|
assert.equal(revealCalls, 0);
|
|
assert.deepEqual(closeFailures, [{
|
|
phase: 'initial-load',
|
|
attempt: 2,
|
|
error: loadError
|
|
}]);
|
|
});
|
|
|
|
test('renderer crash recovery reloads once and cannot enter an infinite reload loop', async () => {
|
|
const crashes = [
|
|
{ reason: 'crashed', exitCode: 11 },
|
|
{ reason: 'crashed', exitCode: 12 },
|
|
{ reason: 'crashed', exitCode: 13 }
|
|
];
|
|
const safeFailures = [];
|
|
let reloadCalls = 0;
|
|
let revealCalls = 0;
|
|
const coordinator = createStartupRecoveryCoordinator({
|
|
load() {},
|
|
async reload() {
|
|
reloadCalls++;
|
|
},
|
|
reveal() {
|
|
revealCalls++;
|
|
},
|
|
async showFailure(failure) {
|
|
safeFailures.push(failure);
|
|
},
|
|
close() {}
|
|
});
|
|
|
|
await coordinator.rendererCrashed(crashes[0]);
|
|
await coordinator.rendererCrashed(crashes[1]);
|
|
await coordinator.rendererCrashed(crashes[2]);
|
|
|
|
assert.equal(reloadCalls, 1);
|
|
assert.deepEqual(safeFailures, [{
|
|
phase: 'renderer-crash',
|
|
attempt: 2,
|
|
details: crashes[1]
|
|
}]);
|
|
assert.equal(revealCalls, 1);
|
|
});
|
|
|
|
test('a successful renderer ready event reveals content and resets crash recovery', async () => {
|
|
const crashes = [
|
|
{ reason: 'crashed', exitCode: 21 },
|
|
{ reason: 'crashed', exitCode: 22 }
|
|
];
|
|
let reloadCalls = 0;
|
|
let revealCalls = 0;
|
|
let safeFailureCalls = 0;
|
|
const coordinator = createStartupRecoveryCoordinator({
|
|
load() {},
|
|
async reload() {
|
|
reloadCalls++;
|
|
},
|
|
reveal() {
|
|
revealCalls++;
|
|
},
|
|
async showFailure() {
|
|
safeFailureCalls++;
|
|
},
|
|
close() {}
|
|
});
|
|
|
|
await coordinator.rendererCrashed(crashes[0]);
|
|
coordinator.rendererReady();
|
|
await coordinator.rendererCrashed(crashes[1]);
|
|
|
|
assert.equal(reloadCalls, 2);
|
|
assert.equal(revealCalls, 1);
|
|
assert.equal(safeFailureCalls, 0);
|
|
});
|
|
|
|
test('a failed crash reload enters the safe failure state without another reload', async () => {
|
|
const crash = { reason: 'launch-failed', exitCode: 31 };
|
|
const reloadError = new Error('reload failed');
|
|
const safeFailures = [];
|
|
let reloadCalls = 0;
|
|
let revealCalls = 0;
|
|
const coordinator = createStartupRecoveryCoordinator({
|
|
load() {},
|
|
async reload() {
|
|
reloadCalls++;
|
|
throw reloadError;
|
|
},
|
|
reveal() {
|
|
revealCalls++;
|
|
},
|
|
async showFailure(failure) {
|
|
safeFailures.push(failure);
|
|
},
|
|
close() {}
|
|
});
|
|
|
|
await coordinator.rendererCrashed(crash);
|
|
await coordinator.rendererCrashed({ reason: 'crashed', exitCode: 32 });
|
|
|
|
assert.equal(reloadCalls, 1);
|
|
assert.deepEqual(safeFailures, [{
|
|
phase: 'renderer-reload',
|
|
attempt: 1,
|
|
details: crash,
|
|
error: reloadError
|
|
}]);
|
|
assert.equal(revealCalls, 1);
|
|
});
|
|
|
|
test('terminal recovery ignores late ready, load, and crash events', async () => {
|
|
let loadCalls = 0;
|
|
let reloadCalls = 0;
|
|
let revealCalls = 0;
|
|
let safeFailureCalls = 0;
|
|
const coordinator = createStartupRecoveryCoordinator({
|
|
async load() {
|
|
loadCalls++;
|
|
},
|
|
async reload() {
|
|
reloadCalls++;
|
|
},
|
|
reveal() {
|
|
revealCalls++;
|
|
},
|
|
async showFailure() {
|
|
safeFailureCalls++;
|
|
},
|
|
close() {}
|
|
});
|
|
|
|
await coordinator.rendererCrashed({ reason: 'crashed', exitCode: 41 });
|
|
await coordinator.rendererCrashed({ reason: 'crashed', exitCode: 42 });
|
|
const readyResult = coordinator.rendererReady();
|
|
await coordinator.loadInitial('renderer/index.html');
|
|
await coordinator.rendererCrashed({ reason: 'crashed', exitCode: 43 });
|
|
|
|
assert.equal(readyResult, false);
|
|
assert.equal(loadCalls, 0);
|
|
assert.equal(reloadCalls, 1);
|
|
assert.equal(revealCalls, 1);
|
|
assert.equal(safeFailureCalls, 1);
|
|
});
|