Route second-instance activation, tray clicks, tray menu activation, and drop-target restores through one startup reveal gate. External focus and restore requests now remain queued until the active renderer generation reports Ready or the coordinator exposes the branded failure surface. Block the gate and reset close-handshake readiness synchronously before every renderer recovery navigation and failure-document load. This keeps early loadFile rejection from leaving the terminal failure surface subject to the normal renderer close-preparation handshake. Add production-like event binding regressions for all four external reveal paths, authorized Ready and failsafe reveals, and direct closing of the safe failure surface after an early recovery rejection.
1086 lines
30 KiB
JavaScript
1086 lines
30 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,
|
|
createStartupCloseHandler,
|
|
createStartupExternalRevealBindings,
|
|
createStartupFailureDocument,
|
|
createStartupNavigationLoader,
|
|
createStartupRecoveryCoordinator,
|
|
createStartupRevealGate,
|
|
createStartupRendererHandlers,
|
|
createStartupWindow,
|
|
resolveStartupLanguage
|
|
} = require('../lib/startup-renderer');
|
|
|
|
test('startup failure document is a localized visible application surface', () => {
|
|
const english = createStartupFailureDocument('en');
|
|
const german = createStartupFailureDocument('de');
|
|
|
|
assert.match(english, /Multi Hoster Uploader/);
|
|
assert.match(english, /could not load/);
|
|
assert.match(english, /Close/);
|
|
assert.match(german, /konnte nicht geladen werden/);
|
|
assert.match(german, /Schließen/);
|
|
assert.doesNotMatch(english, /Electron/);
|
|
});
|
|
|
|
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');
|
|
}
|
|
}
|
|
|
|
function createManualScheduler() {
|
|
let nextId = 1;
|
|
const pending = new Map();
|
|
|
|
return {
|
|
schedule(callback, delay) {
|
|
const handle = { id: nextId++, unref() {} };
|
|
pending.set(handle, { callback, delay });
|
|
return handle;
|
|
},
|
|
cancel(handle) {
|
|
pending.delete(handle);
|
|
},
|
|
count() {
|
|
return pending.size;
|
|
},
|
|
delays() {
|
|
return Array.from(pending.values(), entry => entry.delay);
|
|
},
|
|
async fireNext() {
|
|
const entry = pending.entries().next().value;
|
|
assert.ok(entry);
|
|
const [handle, timer] = entry;
|
|
pending.delete(handle);
|
|
await timer.callback();
|
|
}
|
|
};
|
|
}
|
|
|
|
function createRendererFrame(frameToken, url = `file:///renderer/index.html?startupDocument=${frameToken}`) {
|
|
return {
|
|
detached: false,
|
|
frameToken,
|
|
processId: 1,
|
|
url,
|
|
isDestroyed() {
|
|
return false;
|
|
}
|
|
};
|
|
}
|
|
|
|
function createRevealWindow({ minimized = true, visible = false } = {}) {
|
|
const events = [];
|
|
return {
|
|
events,
|
|
webContents: {
|
|
isDestroyed() {
|
|
return false;
|
|
}
|
|
},
|
|
isDestroyed() {
|
|
return false;
|
|
},
|
|
isMinimized() {
|
|
return minimized;
|
|
},
|
|
isVisible() {
|
|
return visible;
|
|
},
|
|
restore() {
|
|
events.push('restore');
|
|
minimized = false;
|
|
},
|
|
show() {
|
|
events.push('show');
|
|
visible = true;
|
|
},
|
|
focus() {
|
|
events.push('focus');
|
|
}
|
|
};
|
|
}
|
|
|
|
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('ready-to-show cannot reveal the main window before renderer Ready', async () => {
|
|
const startup = createStartupWindow(TestBrowserWindow, {});
|
|
startup.window.loadError = null;
|
|
const loading = startup.load('renderer/index.html', () => {});
|
|
|
|
assert.deepEqual(startup.window.startupEvents, [
|
|
'load:renderer/index.html'
|
|
]);
|
|
|
|
startup.window.emit('ready-to-show');
|
|
await loading;
|
|
|
|
assert.equal(startup.window.showCalls, 0);
|
|
});
|
|
|
|
test('all production external reveal paths wait for an authorized startup surface', () => {
|
|
const paths = [
|
|
['second-instance', bindings => {
|
|
const app = new EventEmitter();
|
|
bindings.bindSecondInstance(app);
|
|
app.emit('second-instance');
|
|
}],
|
|
['tray click', bindings => {
|
|
const tray = new EventEmitter();
|
|
bindings.bindTrayClick(tray);
|
|
tray.emit('click');
|
|
}],
|
|
['tray menu', bindings => {
|
|
bindings.createTrayMenuItem('Open').click();
|
|
}],
|
|
['drop target files', bindings => {
|
|
const ipcMain = new EventEmitter();
|
|
bindings.bindDropTargetFiles(ipcMain);
|
|
ipcMain.emit('drop-target:files', {}, ['queued.mkv']);
|
|
}]
|
|
];
|
|
|
|
for (const [name, invoke] of paths) {
|
|
const window = createRevealWindow();
|
|
const revealGate = createStartupRevealGate(window);
|
|
const droppedFiles = [];
|
|
const bindings = createStartupExternalRevealBindings({
|
|
getWindow: () => window,
|
|
getRevealGate: () => revealGate,
|
|
sendDroppedFiles: paths => droppedFiles.push(paths)
|
|
});
|
|
|
|
invoke(bindings);
|
|
|
|
assert.deepEqual(window.events, [], name);
|
|
assert.deepEqual(droppedFiles, name === 'drop target files' ? [['queued.mkv']] : [], name);
|
|
|
|
revealGate.reveal();
|
|
|
|
assert.deepEqual(window.events, ['restore', 'show', 'focus'], name);
|
|
}
|
|
});
|
|
|
|
test('renderer Ready and the safe failure surface can authorize the gated window', async () => {
|
|
const readyWindow = createRevealWindow({ minimized: false });
|
|
const readyGate = createStartupRevealGate(readyWindow);
|
|
const readyCoordinator = createStartupRecoveryCoordinator({
|
|
load() {},
|
|
reload() {},
|
|
reveal: readyGate.reveal,
|
|
close() {}
|
|
});
|
|
const generation = readyCoordinator.rendererLoadStarted();
|
|
readyCoordinator.rendererLoaded(generation);
|
|
|
|
readyCoordinator.rendererReady(generation);
|
|
|
|
assert.deepEqual(readyWindow.events, ['show']);
|
|
|
|
const failureWindow = createRevealWindow({ minimized: false });
|
|
const failureGate = createStartupRevealGate(failureWindow);
|
|
let failureSurfaceLoads = 0;
|
|
const failureCoordinator = createStartupRecoveryCoordinator({
|
|
async load() {
|
|
throw new Error('navigation failed');
|
|
},
|
|
reload() {},
|
|
reveal: failureGate.reveal,
|
|
async showFailure() {
|
|
failureSurfaceLoads++;
|
|
},
|
|
close() {}
|
|
});
|
|
|
|
await failureCoordinator.loadInitial();
|
|
|
|
assert.equal(failureSurfaceLoads, 1);
|
|
assert.deepEqual(failureWindow.events, ['show']);
|
|
});
|
|
|
|
test('failed recovery navigation leaves the safe failure surface directly closable', async () => {
|
|
const window = createRevealWindow({ minimized: false });
|
|
let closeHandshakeReady = true;
|
|
const readinessDuringNavigation = [];
|
|
const revealGate = createStartupRevealGate(window, {
|
|
onBlock() {
|
|
closeHandshakeReady = false;
|
|
}
|
|
});
|
|
const coordinator = createStartupRecoveryCoordinator({
|
|
load() {},
|
|
reload() {
|
|
return revealGate.navigate(() => {
|
|
readinessDuringNavigation.push(closeHandshakeReady);
|
|
throw new Error('recovery load rejected before navigation');
|
|
});
|
|
},
|
|
reveal: revealGate.reveal,
|
|
showFailure() {
|
|
return revealGate.navigate(() => {
|
|
readinessDuringNavigation.push(closeHandshakeReady);
|
|
});
|
|
},
|
|
close() {}
|
|
});
|
|
|
|
await coordinator.rendererCrashed({ reason: 'crashed' });
|
|
|
|
let closePreparationCalls = 0;
|
|
let preventDefaultCalls = 0;
|
|
const closeHandler = createStartupCloseHandler({
|
|
window,
|
|
shouldPrepareClose: () => closeHandshakeReady,
|
|
requestClosePreparation() {
|
|
closePreparationCalls++;
|
|
}
|
|
});
|
|
const intercepted = closeHandler({
|
|
preventDefault() {
|
|
preventDefaultCalls++;
|
|
}
|
|
});
|
|
|
|
assert.deepEqual(readinessDuringNavigation, [false, false]);
|
|
assert.deepEqual(window.events, ['show']);
|
|
assert.equal(intercepted, false);
|
|
assert.equal(preventDefaultCalls, 0);
|
|
assert.equal(closePreparationCalls, 0);
|
|
});
|
|
|
|
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 navigation gives every main document a distinct generation URL', async () => {
|
|
const calls = [];
|
|
const window = {
|
|
loadFile(target, options) {
|
|
calls.push([target, options]);
|
|
return Promise.resolve('loaded');
|
|
}
|
|
};
|
|
const loadDocument = createStartupNavigationLoader(window, 'renderer/index.html', {
|
|
hash: 'uploads',
|
|
query: { language: 'de' }
|
|
});
|
|
|
|
await loadDocument();
|
|
await loadDocument();
|
|
|
|
assert.deepEqual(calls, [
|
|
['renderer/index.html', {
|
|
hash: 'uploads',
|
|
query: { language: 'de', startupDocument: '1' }
|
|
}],
|
|
['renderer/index.html', {
|
|
hash: 'uploads',
|
|
query: { language: 'de', startupDocument: '2' }
|
|
}]
|
|
]);
|
|
});
|
|
|
|
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('a crash during initial load joins the serialized initial retry without a third navigation', async () => {
|
|
let rejectFirstLoad;
|
|
const firstLoad = new Promise((_, reject) => {
|
|
rejectFirstLoad = reject;
|
|
});
|
|
let activeNavigations = 0;
|
|
let maxConcurrentNavigations = 0;
|
|
let loadCalls = 0;
|
|
let reloadCalls = 0;
|
|
const coordinator = createStartupRecoveryCoordinator({
|
|
async load() {
|
|
loadCalls++;
|
|
activeNavigations++;
|
|
maxConcurrentNavigations = Math.max(maxConcurrentNavigations, activeNavigations);
|
|
try {
|
|
if (loadCalls === 1) return await firstLoad;
|
|
return 'loaded';
|
|
} finally {
|
|
activeNavigations--;
|
|
}
|
|
},
|
|
async reload() {
|
|
reloadCalls++;
|
|
activeNavigations++;
|
|
maxConcurrentNavigations = Math.max(maxConcurrentNavigations, activeNavigations);
|
|
activeNavigations--;
|
|
return 'reloaded';
|
|
},
|
|
reveal() {},
|
|
close() {}
|
|
});
|
|
|
|
const initialLoading = coordinator.loadInitial('renderer/index.html');
|
|
const crashRecovery = coordinator.rendererCrashed({ reason: 'crashed', exitCode: 17 });
|
|
rejectFirstLoad(new Error('first navigation crashed'));
|
|
|
|
await Promise.all([initialLoading, crashRecovery]);
|
|
|
|
assert.equal(loadCalls, 2);
|
|
assert.equal(reloadCalls, 0);
|
|
assert.equal(maxConcurrentNavigations, 1);
|
|
});
|
|
|
|
test('the initial navigation retry consumes the single recovery navigation budget', async () => {
|
|
const scheduler = createManualScheduler();
|
|
const failures = [];
|
|
let loadCalls = 0;
|
|
let reloadCalls = 0;
|
|
const coordinator = createStartupRecoveryCoordinator({
|
|
async load() {
|
|
loadCalls++;
|
|
if (loadCalls === 1) throw new Error('first navigation failed');
|
|
},
|
|
async reload() {
|
|
reloadCalls++;
|
|
},
|
|
reveal() {},
|
|
async showFailure(failure) {
|
|
failures.push(failure);
|
|
},
|
|
close() {},
|
|
readyTimeoutMs: 25,
|
|
scheduleReadyDeadline: scheduler.schedule,
|
|
cancelReadyDeadline: scheduler.cancel
|
|
});
|
|
|
|
await coordinator.loadInitial('renderer/index.html');
|
|
const generation = coordinator.rendererLoadStarted();
|
|
coordinator.rendererLoaded(generation);
|
|
await scheduler.fireNext();
|
|
|
|
assert.equal(loadCalls, 2);
|
|
assert.equal(reloadCalls, 0);
|
|
assert.deepEqual(failures, [{
|
|
phase: 'renderer-ready-timeout',
|
|
attempt: 2,
|
|
details: { timeoutMs: 25 }
|
|
}]);
|
|
});
|
|
|
|
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('renderer initialization failures share the bounded recovery path', async () => {
|
|
const failures = [];
|
|
let reloadCalls = 0;
|
|
const coordinator = createStartupRecoveryCoordinator({
|
|
load() {},
|
|
async reload() {
|
|
reloadCalls++;
|
|
},
|
|
reveal() {},
|
|
async showFailure(failure) {
|
|
failures.push(failure);
|
|
},
|
|
close() {}
|
|
});
|
|
|
|
await coordinator.rendererInitializationFailed({ message: 'first failure' });
|
|
await coordinator.rendererInitializationFailed({ message: 'second failure' });
|
|
|
|
assert.equal(reloadCalls, 1);
|
|
assert.deepEqual(failures, [{
|
|
phase: 'renderer-initialization',
|
|
attempt: 2,
|
|
details: { message: 'second failure' }
|
|
}]);
|
|
});
|
|
|
|
test('production startup handlers route renderer initialization failure through bounded recovery', async () => {
|
|
const failures = [];
|
|
let reloadCalls = 0;
|
|
let revealCalls = 0;
|
|
const reportedFailures = [];
|
|
const ipcMain = new EventEmitter();
|
|
const webContents = new EventEmitter();
|
|
const firstFrame = createRendererFrame('initialization-1');
|
|
const secondFrame = createRendererFrame('initialization-2');
|
|
const coordinator = createStartupRecoveryCoordinator({
|
|
load() {},
|
|
async reload() {
|
|
reloadCalls++;
|
|
webContents.mainFrame = secondFrame;
|
|
webContents.emit('did-start-navigation', {
|
|
isMainFrame: true,
|
|
isSameDocument: false,
|
|
url: secondFrame.url,
|
|
frame: secondFrame
|
|
});
|
|
webContents.emit('did-finish-load');
|
|
},
|
|
reveal() {
|
|
revealCalls++;
|
|
},
|
|
async showFailure(failure) {
|
|
failures.push(failure);
|
|
},
|
|
close() {}
|
|
});
|
|
const handlers = createStartupRendererHandlers({
|
|
window: { isDestroyed: () => false, webContents },
|
|
ipcMain,
|
|
coordinator,
|
|
onReady() {},
|
|
onInitializationFailed(details) {
|
|
reportedFailures.push(details);
|
|
}
|
|
});
|
|
const firstDetails = { message: 'first top-level initialization failed' };
|
|
const secondDetails = { message: 'second top-level initialization failed' };
|
|
|
|
webContents.mainFrame = firstFrame;
|
|
webContents.emit('did-start-navigation', {
|
|
isMainFrame: true,
|
|
isSameDocument: false,
|
|
url: firstFrame.url,
|
|
frame: firstFrame
|
|
});
|
|
ipcMain.emit('app:renderer-initialization-failed', {
|
|
sender: webContents,
|
|
senderFrame: firstFrame
|
|
}, firstDetails);
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
ipcMain.emit('app:renderer-initialization-failed', {
|
|
sender: webContents,
|
|
senderFrame: secondFrame
|
|
}, secondDetails);
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
|
|
assert.equal(reloadCalls, 1);
|
|
assert.equal(revealCalls, 1);
|
|
assert.deepEqual(reportedFailures, [firstDetails, secondDetails]);
|
|
assert.deepEqual(failures, [{
|
|
phase: 'renderer-initialization',
|
|
attempt: 2,
|
|
details: secondDetails
|
|
}]);
|
|
handlers.dispose();
|
|
});
|
|
|
|
test('production startup handlers enforce the Ready deadline after every main document load', async () => {
|
|
const scheduler = createManualScheduler();
|
|
const webContents = new EventEmitter();
|
|
const firstFrame = createRendererFrame('timeout-1');
|
|
const secondFrame = createRendererFrame('timeout-2');
|
|
const failures = [];
|
|
let reloadCalls = 0;
|
|
const coordinator = createStartupRecoveryCoordinator({
|
|
load() {},
|
|
async reload() {
|
|
reloadCalls++;
|
|
},
|
|
reveal() {},
|
|
async showFailure(failure) {
|
|
failures.push(failure);
|
|
},
|
|
close() {},
|
|
readyTimeoutMs: 25,
|
|
scheduleReadyDeadline: scheduler.schedule,
|
|
cancelReadyDeadline: scheduler.cancel
|
|
});
|
|
const handlers = createStartupRendererHandlers({
|
|
window: { isDestroyed: () => false, webContents },
|
|
coordinator,
|
|
onReady() {},
|
|
onInitializationFailed() {}
|
|
});
|
|
|
|
webContents.mainFrame = firstFrame;
|
|
webContents.emit('did-start-navigation', {
|
|
isMainFrame: true,
|
|
isSameDocument: false,
|
|
url: firstFrame.url,
|
|
frame: firstFrame
|
|
});
|
|
webContents.emit('did-finish-load');
|
|
|
|
assert.deepEqual(scheduler.delays(), [25]);
|
|
await scheduler.fireNext();
|
|
assert.equal(reloadCalls, 1);
|
|
|
|
webContents.mainFrame = secondFrame;
|
|
webContents.emit('did-start-navigation', {
|
|
isMainFrame: true,
|
|
isSameDocument: false,
|
|
url: secondFrame.url,
|
|
frame: secondFrame
|
|
});
|
|
webContents.emit('did-finish-load');
|
|
await scheduler.fireNext();
|
|
|
|
assert.equal(reloadCalls, 1);
|
|
assert.deepEqual(failures, [{
|
|
phase: 'renderer-ready-timeout',
|
|
attempt: 2,
|
|
details: { timeoutMs: 25 }
|
|
}]);
|
|
handlers.dispose();
|
|
});
|
|
|
|
test('late Ready from an old renderer generation cannot clear the current deadline or recovery budget', async () => {
|
|
const scheduler = createManualScheduler();
|
|
const failures = [];
|
|
let reloadCalls = 0;
|
|
const coordinator = createStartupRecoveryCoordinator({
|
|
load() {},
|
|
async reload() {
|
|
reloadCalls++;
|
|
},
|
|
reveal() {},
|
|
async showFailure(failure) {
|
|
failures.push(failure);
|
|
},
|
|
close() {},
|
|
readyTimeoutMs: 25,
|
|
scheduleReadyDeadline: scheduler.schedule,
|
|
cancelReadyDeadline: scheduler.cancel
|
|
});
|
|
|
|
const oldGeneration = coordinator.rendererLoadStarted();
|
|
coordinator.rendererLoaded(oldGeneration);
|
|
await scheduler.fireNext();
|
|
|
|
const currentGeneration = coordinator.rendererLoadStarted();
|
|
coordinator.rendererLoaded(currentGeneration);
|
|
const accepted = coordinator.rendererReady(oldGeneration);
|
|
|
|
assert.equal(accepted, false);
|
|
assert.equal(scheduler.count(), 1);
|
|
|
|
await scheduler.fireNext();
|
|
|
|
assert.equal(reloadCalls, 1);
|
|
assert.deepEqual(failures, [{
|
|
phase: 'renderer-ready-timeout',
|
|
attempt: 2,
|
|
details: { timeoutMs: 25 }
|
|
}]);
|
|
});
|
|
|
|
test('Ready without a renderer generation cannot clear the active deadline', () => {
|
|
const scheduler = createManualScheduler();
|
|
let revealCalls = 0;
|
|
const coordinator = createStartupRecoveryCoordinator({
|
|
load() {},
|
|
reload() {},
|
|
reveal() {
|
|
revealCalls++;
|
|
},
|
|
close() {},
|
|
readyTimeoutMs: 25,
|
|
scheduleReadyDeadline: scheduler.schedule,
|
|
cancelReadyDeadline: scheduler.cancel
|
|
});
|
|
|
|
const generation = coordinator.rendererLoadStarted();
|
|
coordinator.rendererLoaded(generation);
|
|
const accepted = coordinator.rendererReady();
|
|
|
|
assert.equal(accepted, false);
|
|
assert.equal(revealCalls, 0);
|
|
assert.equal(scheduler.count(), 1);
|
|
});
|
|
|
|
test('production event wiring rejects an old document Ready and exposes the failsafe', async () => {
|
|
const scheduler = createManualScheduler();
|
|
const ipcMain = new EventEmitter();
|
|
const webContents = new EventEmitter();
|
|
const oldFrame = createRendererFrame('main-frame', 'file:///renderer/index.html?startupDocument=1');
|
|
const currentFrame = createRendererFrame('main-frame', 'file:///renderer/index.html?startupDocument=2');
|
|
const window = {
|
|
webContents,
|
|
isDestroyed() {
|
|
return false;
|
|
}
|
|
};
|
|
const failures = [];
|
|
let reloadCalls = 0;
|
|
let revealCalls = 0;
|
|
let readyCalls = 0;
|
|
const coordinator = createStartupRecoveryCoordinator({
|
|
load() {},
|
|
async reload() {
|
|
reloadCalls++;
|
|
webContents.mainFrame = currentFrame;
|
|
webContents.emit('did-start-navigation', {
|
|
isMainFrame: true,
|
|
isSameDocument: false,
|
|
url: currentFrame.url,
|
|
frame: currentFrame
|
|
});
|
|
webContents.emit('did-finish-load');
|
|
},
|
|
reveal() {
|
|
revealCalls++;
|
|
},
|
|
async showFailure(failure) {
|
|
failures.push(failure);
|
|
},
|
|
close() {},
|
|
readyTimeoutMs: 25,
|
|
scheduleReadyDeadline: scheduler.schedule,
|
|
cancelReadyDeadline: scheduler.cancel
|
|
});
|
|
const handlers = createStartupRendererHandlers({
|
|
window,
|
|
ipcMain,
|
|
coordinator,
|
|
onReady() {
|
|
readyCalls++;
|
|
},
|
|
onInitializationFailed() {}
|
|
});
|
|
|
|
webContents.mainFrame = oldFrame;
|
|
webContents.emit('did-start-navigation', {
|
|
isMainFrame: true,
|
|
isSameDocument: false,
|
|
url: oldFrame.url,
|
|
frame: oldFrame
|
|
});
|
|
webContents.emit('did-finish-load');
|
|
|
|
assert.equal(scheduler.count(), 1);
|
|
await scheduler.fireNext();
|
|
assert.equal(reloadCalls, 1);
|
|
assert.equal(scheduler.count(), 1);
|
|
|
|
ipcMain.emit('app:close-handshake-ready', {
|
|
sender: webContents,
|
|
senderFrame: oldFrame
|
|
});
|
|
|
|
assert.equal(readyCalls, 0);
|
|
assert.equal(scheduler.count(), 1);
|
|
|
|
await scheduler.fireNext();
|
|
|
|
assert.equal(reloadCalls, 1);
|
|
assert.equal(revealCalls, 1);
|
|
assert.deepEqual(failures, [{
|
|
phase: 'renderer-ready-timeout',
|
|
attempt: 2,
|
|
details: { timeoutMs: 25 }
|
|
}]);
|
|
handlers.dispose();
|
|
});
|
|
|
|
test('production startup handlers cancel the Ready deadline after a valid Ready signal', async () => {
|
|
const scheduler = createManualScheduler();
|
|
let reloadCalls = 0;
|
|
let readyCalls = 0;
|
|
const frame = createRendererFrame('ready');
|
|
const webContents = { mainFrame: frame };
|
|
const coordinator = createStartupRecoveryCoordinator({
|
|
load() {},
|
|
async reload() {
|
|
reloadCalls++;
|
|
},
|
|
reveal() {},
|
|
close() {},
|
|
readyTimeoutMs: 25,
|
|
scheduleReadyDeadline: scheduler.schedule,
|
|
cancelReadyDeadline: scheduler.cancel
|
|
});
|
|
const handlers = createStartupRendererHandlers({
|
|
window: { isDestroyed: () => false, webContents },
|
|
coordinator,
|
|
onReady() {
|
|
readyCalls++;
|
|
},
|
|
onInitializationFailed() {}
|
|
});
|
|
|
|
handlers.documentLoadStarted();
|
|
handlers.documentLoaded();
|
|
const ready = handlers.rendererReady({ sender: webContents, senderFrame: frame });
|
|
|
|
assert.equal(ready, true);
|
|
assert.equal(readyCalls, 1);
|
|
assert.equal(scheduler.count(), 0);
|
|
assert.equal(reloadCalls, 0);
|
|
});
|
|
|
|
test('Ready before did-finish-load is rejected and cannot suppress the current deadline', () => {
|
|
const scheduler = createManualScheduler();
|
|
const frame = createRendererFrame('not-finished');
|
|
const webContents = { mainFrame: frame };
|
|
const coordinator = createStartupRecoveryCoordinator({
|
|
load() {},
|
|
reload() {},
|
|
reveal() {},
|
|
close() {},
|
|
readyTimeoutMs: 25,
|
|
scheduleReadyDeadline: scheduler.schedule,
|
|
cancelReadyDeadline: scheduler.cancel
|
|
});
|
|
const handlers = createStartupRendererHandlers({
|
|
window: { isDestroyed: () => false, webContents },
|
|
coordinator,
|
|
onReady() {},
|
|
onInitializationFailed() {}
|
|
});
|
|
|
|
handlers.documentLoadStarted();
|
|
const ready = handlers.rendererReady({ sender: webContents, senderFrame: frame });
|
|
handlers.documentLoaded();
|
|
|
|
assert.equal(ready, false);
|
|
assert.equal(scheduler.count(), 1);
|
|
});
|
|
|
|
test('disposing startup handlers cancels a pending Ready deadline', () => {
|
|
const scheduler = createManualScheduler();
|
|
const coordinator = createStartupRecoveryCoordinator({
|
|
load() {},
|
|
reload() {},
|
|
reveal() {},
|
|
close() {},
|
|
readyTimeoutMs: 25,
|
|
scheduleReadyDeadline: scheduler.schedule,
|
|
cancelReadyDeadline: scheduler.cancel
|
|
});
|
|
const handlers = createStartupRendererHandlers({
|
|
window: { isDestroyed: () => false, webContents: {} },
|
|
coordinator,
|
|
onReady() {},
|
|
onInitializationFailed() {}
|
|
});
|
|
|
|
handlers.documentLoadStarted();
|
|
handlers.documentLoaded();
|
|
handlers.dispose();
|
|
|
|
assert.equal(scheduler.count(), 0);
|
|
});
|
|
|
|
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]);
|
|
const generation = coordinator.rendererLoadStarted();
|
|
coordinator.rendererLoaded(generation);
|
|
coordinator.rendererReady(generation);
|
|
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);
|
|
});
|