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
158 lines
5.5 KiB
TypeScript
158 lines
5.5 KiB
TypeScript
import {
|
|
acceptGameDirectory,
|
|
hydrateGameDirectory,
|
|
} from '../src/lib/gameDirectory.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('game directory is neither persisted nor used before backend acknowledgement', async () => {
|
|
const acknowledgement = deferred<unknown>();
|
|
const persisted: string[] = [];
|
|
let displayedPath = '/old/path';
|
|
|
|
const update = acceptGameDirectory('/picked/../path', {
|
|
updateBackend: requestedPath => {
|
|
assertEquals(
|
|
requestedPath,
|
|
'/picked/../path',
|
|
'backend should receive the requested path',
|
|
);
|
|
return acknowledgement.promise;
|
|
},
|
|
persist: acceptedPath => {
|
|
persisted.push(acceptedPath);
|
|
return Promise.resolve();
|
|
},
|
|
reportPersistenceError: () => {},
|
|
});
|
|
|
|
assertEquals(displayedPath, '/old/path', 'pending update should retain the old UI path');
|
|
assertEquals(persisted.length, 0, 'pending update should not write the requested path');
|
|
|
|
acknowledgement.resolve('/canonical/path');
|
|
displayedPath = await update;
|
|
|
|
assertEquals(displayedPath, '/canonical/path', 'UI should use the acknowledged path');
|
|
assertEquals(persisted.length, 1, 'acknowledged path should be persisted once');
|
|
assertEquals(persisted[0], '/canonical/path', 'store should receive the canonical path');
|
|
});
|
|
|
|
Deno.test('backend rejection leaves the old game directory untouched', async () => {
|
|
const rejected = new Error('directory rejected');
|
|
const persisted: string[] = [];
|
|
let displayedPath = '/old/path';
|
|
|
|
try {
|
|
displayedPath = await acceptGameDirectory('/rejected/path', {
|
|
updateBackend: () => Promise.reject(rejected),
|
|
persist: acceptedPath => {
|
|
persisted.push(acceptedPath);
|
|
return Promise.resolve();
|
|
},
|
|
reportPersistenceError: () => {},
|
|
});
|
|
throw new Error('expected backend rejection');
|
|
} catch (error) {
|
|
assertEquals(error, rejected, 'backend error should be preserved');
|
|
}
|
|
|
|
assertEquals(displayedPath, '/old/path', 'rejection should retain the old UI path');
|
|
assertEquals(persisted.length, 0, 'rejection should not change persistent state');
|
|
});
|
|
|
|
Deno.test('invalid backend acknowledgement is not persisted', async () => {
|
|
let persisted = false;
|
|
let rejected = false;
|
|
|
|
try {
|
|
await acceptGameDirectory('/picked/path', {
|
|
updateBackend: () => Promise.resolve(' '),
|
|
persist: () => {
|
|
persisted = true;
|
|
return Promise.resolve();
|
|
},
|
|
reportPersistenceError: () => {},
|
|
});
|
|
} catch {
|
|
rejected = true;
|
|
}
|
|
|
|
assertEquals(rejected, true, 'empty acknowledgement should be rejected');
|
|
assertEquals(persisted, false, 'invalid acknowledgement should not be persisted');
|
|
});
|
|
|
|
Deno.test('persistence failure does not undo an acknowledged backend update', async () => {
|
|
const persistenceError = new Error('store unavailable');
|
|
let reportedError: unknown;
|
|
|
|
const acceptedPath = await acceptGameDirectory('/picked/path', {
|
|
updateBackend: () => Promise.resolve('/canonical/path'),
|
|
persist: () => Promise.reject(persistenceError),
|
|
reportPersistenceError: error => {
|
|
reportedError = error;
|
|
},
|
|
});
|
|
|
|
assertEquals(acceptedPath, '/canonical/path', 'backend acknowledgement should remain accepted');
|
|
assertEquals(reportedError, persistenceError, 'persistence failure should be reported');
|
|
});
|
|
|
|
Deno.test('saved game-directory hydration settles only after backend acceptance', async () => {
|
|
const acceptance = deferred<void>();
|
|
let settled = false;
|
|
const hydration = hydrateGameDirectory({
|
|
loadSavedPath: () => Promise.resolve('/saved/path'),
|
|
acceptSavedPath: path => {
|
|
assertEquals(path, '/saved/path', 'saved path should be restored');
|
|
return acceptance.promise;
|
|
},
|
|
reportLoadError: () => {},
|
|
}).then(() => {
|
|
settled = true;
|
|
});
|
|
|
|
await Promise.resolve();
|
|
assertEquals(settled, false, 'hydration must wait for backend acceptance');
|
|
acceptance.resolve();
|
|
await hydration;
|
|
assertEquals(settled, true, 'hydration should settle after acceptance');
|
|
});
|
|
|
|
Deno.test('missing or failed game-directory state still completes hydration', async () => {
|
|
let accepts = 0;
|
|
await hydrateGameDirectory({
|
|
loadSavedPath: () => Promise.resolve(undefined),
|
|
acceptSavedPath: () => {
|
|
accepts += 1;
|
|
return Promise.resolve();
|
|
},
|
|
reportLoadError: () => {},
|
|
});
|
|
assertEquals(accepts, 0, 'missing state should not invoke the backend');
|
|
|
|
const failure = new Error('store unavailable');
|
|
let reported: unknown;
|
|
await hydrateGameDirectory({
|
|
loadSavedPath: () => Promise.reject(failure),
|
|
acceptSavedPath: () => Promise.resolve(),
|
|
reportLoadError: error => {
|
|
reported = error;
|
|
},
|
|
});
|
|
assertEquals(reported, failure, 'load failure should be reported before ready');
|
|
});
|