Add a durable, acknowledged Local network sharing switch with fail-closed hydration, serialized mutation, and redacted ephemeral-identity diagnostics. Keep local Call-to-Play state available while gating every network action on the effective sharing generation. Render revisioned verification, invalid-source retry, and sticky source exhaustion states. Preserve opaque attempt IDs through progress delivery so out-of-order webview events cannot attach stale bytes to a successor transfer, and keep terminal exhaustion visible after the last source departs. Own listeners, native invokes, persistence, dialogs, and companion-window creation through webview close. Late creation is settled and cleaned before the parent realm is destroyed. Test Plan: - `just frontend-test` -- passed (91/91) - `just build` -- passed with TypeScript, Vite, and release Tauri compilation - `just test` -- passed on the completed stack (708 workspace tests) - `just clippy` -- passed on the completed stack - `git diff --cached --check` -- passed
258 lines
9.4 KiB
TypeScript
258 lines
9.4 KiB
TypeScript
import {
|
|
bootstrapFrontend,
|
|
type FrontendCloseRequest,
|
|
} from '../src/lib/frontendBootstrap.ts';
|
|
|
|
const assertEquals = <T>(actual: T, expected: T, message: string) => {
|
|
if (actual !== expected) {
|
|
throw new Error(`${message}: expected ${expected}, got ${actual}`);
|
|
}
|
|
};
|
|
|
|
const deferred = <T>() => {
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (reason: unknown) => void;
|
|
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
|
resolve = resolvePromise;
|
|
reject = rejectPromise;
|
|
});
|
|
return { promise, resolve, reject };
|
|
};
|
|
|
|
Deno.test('close-listener registration failure destroys the empty window without mounting hooks', async () => {
|
|
const registrationFailure = new Error('registration failed');
|
|
const destructionStarted = deferred<void>();
|
|
const destruction = deferred<void>();
|
|
const order: string[] = [];
|
|
const reported: unknown[] = [];
|
|
let renders = 0;
|
|
let settled = false;
|
|
|
|
const bootstrap = bootstrapFrontend({
|
|
registerCloseRequested: () => Promise.reject(registrationFailure),
|
|
disposeOwners: () => {
|
|
order.push('dispose owners');
|
|
return Promise.resolve();
|
|
},
|
|
drainPersistence: () => {
|
|
order.push('drain persistence');
|
|
return Promise.resolve();
|
|
},
|
|
destroyWindow: () => {
|
|
order.push('destroy window');
|
|
destructionStarted.resolve();
|
|
return destruction.promise;
|
|
},
|
|
render: () => {
|
|
renders += 1;
|
|
},
|
|
reportFailure: (_message, error) => reported.push(error),
|
|
}).then(result => {
|
|
settled = true;
|
|
return result;
|
|
});
|
|
|
|
await destructionStarted.promise;
|
|
assertEquals(renders, 0, 'React and its hooks must remain unmounted');
|
|
assertEquals(settled, false, 'bootstrap must explicitly await window destruction');
|
|
assertEquals(
|
|
JSON.stringify(order),
|
|
JSON.stringify(['dispose owners', 'drain persistence', 'destroy window']),
|
|
'admissions must close and drain before destroying the empty window',
|
|
);
|
|
|
|
destruction.resolve();
|
|
assertEquals(await bootstrap, false, 'registration failure must report no render');
|
|
assertEquals(renders, 0, 'hooks must never mount after failure cleanup');
|
|
assertEquals(reported.length, 1, 'the registration failure must be handled once');
|
|
assertEquals(reported[0], registrationFailure, 'the original failure must be reported');
|
|
});
|
|
|
|
Deno.test('bootstrap handles failure of the fail-closed destroy action', async () => {
|
|
const registrationFailure = new Error('registration failed');
|
|
const destructionFailure = new Error('destroy failed');
|
|
const reported: unknown[] = [];
|
|
let renders = 0;
|
|
|
|
const rendered = await bootstrapFrontend({
|
|
registerCloseRequested: () => Promise.reject(registrationFailure),
|
|
disposeOwners: () => Promise.resolve(),
|
|
drainPersistence: () => Promise.resolve(),
|
|
destroyWindow: () => Promise.reject(destructionFailure),
|
|
render: () => {
|
|
renders += 1;
|
|
},
|
|
reportFailure: (_message, error) => reported.push(error),
|
|
});
|
|
|
|
assertEquals(rendered, false, 'a failed registration must never render');
|
|
assertEquals(renders, 0, 'destroy failure must not fall back to mounting hooks');
|
|
assertEquals(reported.length, 2, 'both terminal failures must be handled');
|
|
assertEquals(reported[0], registrationFailure, 'registration failure must be preserved');
|
|
assertEquals(reported[1], destructionFailure, 'destroy failure must be observed');
|
|
});
|
|
|
|
Deno.test('successful bootstrap mounts only after close registration resolves', async () => {
|
|
const registered = deferred<(event: FrontendCloseRequest) => void | Promise<void>>();
|
|
const registrationAcknowledged = deferred<() => void>();
|
|
let renders = 0;
|
|
|
|
const bootstrap = bootstrapFrontend({
|
|
registerCloseRequested: handler => {
|
|
registered.resolve(handler);
|
|
return registrationAcknowledged.promise;
|
|
},
|
|
disposeOwners: () => Promise.resolve(),
|
|
drainPersistence: () => Promise.resolve(),
|
|
destroyWindow: () => Promise.resolve(),
|
|
render: () => {
|
|
renders += 1;
|
|
},
|
|
reportFailure: () => {},
|
|
});
|
|
|
|
await registered.promise;
|
|
assertEquals(renders, 0, 'render must wait for registration acknowledgement');
|
|
registrationAcknowledged.resolve(() => {});
|
|
assertEquals(await bootstrap, true, 'successful registration should render the app');
|
|
assertEquals(renders, 1, 'the app should mount once');
|
|
});
|
|
|
|
Deno.test('close before registration acknowledgement owns late cleanup and suppresses render', async () => {
|
|
const registered = deferred<(event: FrontendCloseRequest) => void | Promise<void>>();
|
|
const registrationAcknowledged = deferred<() => void>();
|
|
const cleanupDone = deferred<void>();
|
|
const order: string[] = [];
|
|
let renders = 0;
|
|
let prevented = 0;
|
|
|
|
const bootstrap = bootstrapFrontend({
|
|
registerCloseRequested: handler => {
|
|
registered.resolve(handler);
|
|
return registrationAcknowledged.promise;
|
|
},
|
|
disposeOwners: () => {
|
|
order.push('dispose owners');
|
|
return Promise.resolve();
|
|
},
|
|
drainPersistence: () => {
|
|
order.push('drain persistence');
|
|
return Promise.resolve();
|
|
},
|
|
destroyWindow: () => {
|
|
order.push('destroy window');
|
|
return Promise.resolve();
|
|
},
|
|
render: () => {
|
|
renders += 1;
|
|
},
|
|
reportFailure: () => {},
|
|
});
|
|
|
|
const close = (await registered.promise)({
|
|
preventDefault: () => {
|
|
prevented += 1;
|
|
},
|
|
});
|
|
assertEquals(prevented, 1, 'the pre-acknowledgement close must be intercepted');
|
|
assertEquals(renders, 0, 'React must remain unmounted while registration is unresolved');
|
|
|
|
registrationAcknowledged.resolve(async () => {
|
|
order.push('unlisten');
|
|
await cleanupDone.promise;
|
|
});
|
|
await Promise.resolve();
|
|
assertEquals(renders, 0, 'registration acknowledgement must not render after close began');
|
|
assertEquals(
|
|
order.includes('destroy window'),
|
|
false,
|
|
'the late listener cleanup must drain before destruction',
|
|
);
|
|
|
|
cleanupDone.resolve();
|
|
await close;
|
|
assertEquals(await bootstrap, false, 'a pre-render close must report no mounted app');
|
|
assertEquals(renders, 0, 'a closing realm must never mount React');
|
|
assertEquals(
|
|
JSON.stringify(order),
|
|
JSON.stringify(['dispose owners', 'drain persistence', 'unlisten', 'destroy window']),
|
|
'late listener ownership must join the same close finalizer',
|
|
);
|
|
});
|
|
|
|
Deno.test('render failure drains and unregisters before handled window destruction', async () => {
|
|
const renderFailure = new Error('render failed');
|
|
const unlistenFailure = new Error('unlisten failed');
|
|
const destroyFailure = new Error('destroy failed');
|
|
const ownerDrained = deferred<void>();
|
|
const persistenceDrained = deferred<void>();
|
|
const finalizerStarted = deferred<void>();
|
|
const order: string[] = [];
|
|
const reported: unknown[] = [];
|
|
let cleanups = 0;
|
|
|
|
const bootstrap = bootstrapFrontend({
|
|
registerCloseRequested: () => {
|
|
order.push('register');
|
|
return Promise.resolve(async () => {
|
|
order.push('unlisten');
|
|
cleanups += 1;
|
|
finalizerStarted.resolve();
|
|
throw unlistenFailure;
|
|
});
|
|
},
|
|
disposeOwners: async () => {
|
|
order.push('dispose owners');
|
|
await ownerDrained.promise;
|
|
},
|
|
drainPersistence: async () => {
|
|
order.push('drain persistence');
|
|
await persistenceDrained.promise;
|
|
},
|
|
destroyWindow: () => {
|
|
order.push('destroy window');
|
|
return Promise.reject(destroyFailure);
|
|
},
|
|
render: () => {
|
|
order.push('render');
|
|
throw renderFailure;
|
|
},
|
|
reportFailure: (_message, error) => reported.push(error),
|
|
});
|
|
|
|
await finalizerStarted.promise;
|
|
assertEquals(cleanups, 1, 'the installed close listener must unregister exactly once');
|
|
assertEquals(
|
|
JSON.stringify(order),
|
|
JSON.stringify(['register', 'render', 'dispose owners', 'drain persistence', 'unlisten']),
|
|
'render failure must synchronously start every frontend finalizer',
|
|
);
|
|
|
|
ownerDrained.resolve();
|
|
await Promise.resolve();
|
|
assertEquals(
|
|
order.includes('destroy window'),
|
|
false,
|
|
'window destruction must wait for persistence as well as owner cleanup',
|
|
);
|
|
persistenceDrained.resolve();
|
|
|
|
assertEquals(await bootstrap, false, 'a render failure must be fully handled');
|
|
assertEquals(
|
|
JSON.stringify(order),
|
|
JSON.stringify([
|
|
'register',
|
|
'render',
|
|
'dispose owners',
|
|
'drain persistence',
|
|
'unlisten',
|
|
'destroy window',
|
|
]),
|
|
'destruction must be the terminal finalizer',
|
|
);
|
|
assertEquals(reported.length, 3, 'render, cleanup, and destroy failures must be observed');
|
|
assertEquals(reported[0], renderFailure, 'the render failure must be reported first');
|
|
assertEquals(reported[1], unlistenFailure, 'listener cleanup rejection must be reported');
|
|
assertEquals(reported[2], destroyFailure, 'window destroy rejection must be reported');
|
|
});
|