feat(app): expose local sharing and verified transfers
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
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
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');
|
||||
});
|
||||
Reference in New Issue
Block a user