mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
347 lines
12 KiB
JavaScript
347 lines
12 KiB
JavaScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import assert from 'node:assert/strict';
|
|
import {readFileSync} from 'node:fs';
|
|
import {createRequire} from 'node:module';
|
|
import {beforeEach, describe, test} from 'node:test';
|
|
import {fileURLToPath} from 'node:url';
|
|
import {installElectronStub, installTestModuleStub} from './LocalAppTestSupport.test.mjs';
|
|
|
|
installElectronStub();
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const esbuild = require('esbuild');
|
|
|
|
const PROTOCOL_KEY = '__fluxerDesktopRuntimeTransactionTestProtocol__';
|
|
const DISCOVERY_KEY = '__fluxerDesktopRuntimeTransactionTestDiscovery__';
|
|
|
|
installTestModuleStub(
|
|
'@electron/main/LocalAppProtocol',
|
|
`export const getDesktopLocalAppProtocol = () => globalThis[${JSON.stringify(PROTOCOL_KEY)}];`,
|
|
);
|
|
installTestModuleStub(
|
|
'@electron/main/DesktopRuntimeDiscovery',
|
|
`export const resolveDesktopRuntimePlan = async (request) => await globalThis[${JSON.stringify(DISCOVERY_KEY)}](request);`,
|
|
);
|
|
|
|
const REGISTRY_SOURCE_PATH = fileURLToPath(new URL('./DesktopRuntimeTransactionRegistry.ts', import.meta.url));
|
|
|
|
const {DesktopRuntimeTransactionRegistry} = await import(
|
|
`data:text/javascript,${encodeURIComponent(
|
|
esbuild.transformSync(readFileSync(REGISTRY_SOURCE_PATH, 'utf8'), {
|
|
loader: 'ts',
|
|
format: 'esm',
|
|
platform: 'node',
|
|
target: 'node22',
|
|
}).code,
|
|
)}`
|
|
);
|
|
|
|
const MAX_RUNTIME_PREPARATIONS = 8;
|
|
|
|
const ONE = 'https://one.test/api';
|
|
const TWO = 'https://two.test/api';
|
|
const BASE = 'https://base.test/api';
|
|
|
|
function createPlan(instanceKey) {
|
|
return {
|
|
instanceKey,
|
|
document: {instance: instanceKey},
|
|
endpoints: {
|
|
apiEndpoint: instanceKey,
|
|
apiPublicEndpoint: null,
|
|
webAppEndpoint: null,
|
|
mediaEndpoint: null,
|
|
staticCdnEndpoint: null,
|
|
uploadRelayEndpoint: null,
|
|
gatewayEndpoint: null,
|
|
inviteEndpoint: null,
|
|
giftEndpoint: null,
|
|
},
|
|
selfHosted: true,
|
|
};
|
|
}
|
|
|
|
function createProtocol(activePlan = null) {
|
|
const shutdown = new AbortController();
|
|
let active = activePlan;
|
|
return {
|
|
activations: [],
|
|
getActivePlan: () => active,
|
|
findPlanForRoute: () => null,
|
|
getShutdownSignal: () => shutdown.signal,
|
|
activateRuntimePlan(plan) {
|
|
active = plan;
|
|
this.activations.push(plan.instanceKey);
|
|
},
|
|
deactivateRuntimePlan() {
|
|
active = null;
|
|
this.activations.push(null);
|
|
},
|
|
};
|
|
}
|
|
|
|
function createRendererDocument(name) {
|
|
const watchers = new Set();
|
|
const owner = {
|
|
sender: name,
|
|
frame: name,
|
|
isCurrent: () => true,
|
|
matchesEvent: (event) => event.document === owner,
|
|
requireCurrent: () => undefined,
|
|
watchInvalidation(onInvalidated) {
|
|
const watcher = {onInvalidated, dispose: () => watchers.delete(watcher)};
|
|
watchers.add(watcher);
|
|
return watcher;
|
|
},
|
|
};
|
|
return {owner, event: {document: owner}, liveWatchers: () => watchers.size};
|
|
}
|
|
|
|
const rendererDocuments = {capture: (event) => event.document};
|
|
|
|
function transactionRequest(preparation) {
|
|
return {
|
|
preparationId: preparation.preparationId,
|
|
instanceKey: preparation.instanceKey,
|
|
baseRevision: preparation.baseRevision,
|
|
};
|
|
}
|
|
|
|
function committedRequest(preparation, committedRevision) {
|
|
return {...transactionRequest(preparation), committedRevision};
|
|
}
|
|
|
|
function named(name) {
|
|
return (error) => {
|
|
assert.equal(error.name, name, `expected ${name}, got ${error.name}: ${error.message}`);
|
|
return true;
|
|
};
|
|
}
|
|
|
|
let protocol;
|
|
let discovered;
|
|
let document;
|
|
let registry;
|
|
|
|
beforeEach(() => {
|
|
protocol = createProtocol();
|
|
discovered = new Map([
|
|
[ONE, createPlan(ONE)],
|
|
[TWO, createPlan(TWO)],
|
|
]);
|
|
globalThis[PROTOCOL_KEY] = protocol;
|
|
globalThis[DISCOVERY_KEY] = async ({input}) => {
|
|
const plan = discovered.get(input);
|
|
if (plan == null) {
|
|
throw new Error(`no discovery fixture for ${input}`);
|
|
}
|
|
return plan;
|
|
};
|
|
document = createRendererDocument('document-one');
|
|
registry = new DesktopRuntimeTransactionRegistry(rendererDocuments);
|
|
});
|
|
|
|
describe('DesktopRuntimeTransactionRegistry.commit', () => {
|
|
test('a preparation cannot commit at a revision an earlier rollback already moved past', async () => {
|
|
const basePlan = createPlan(BASE);
|
|
protocol = createProtocol(basePlan);
|
|
globalThis[PROTOCOL_KEY] = protocol;
|
|
registry = new DesktopRuntimeTransactionRegistry(rendererDocuments);
|
|
registry.initialize();
|
|
const first = await registry.prepare(document.event, {preparationId: 'first', instanceKey: ONE});
|
|
const second = await registry.prepare(document.event, {preparationId: 'second', instanceKey: ONE});
|
|
assert.equal(second.baseRevision, first.baseRevision);
|
|
assert.equal(second.baseActiveInstanceKey, BASE);
|
|
registry.commit(document.event, transactionRequest(first));
|
|
registry.abort(document.event, {preparationId: 'first'});
|
|
assert.equal(protocol.getActivePlan(), basePlan);
|
|
|
|
assert.throws(
|
|
() => registry.commit(document.event, transactionRequest(second)),
|
|
named('DesktopRuntimePreparationSupersededError'),
|
|
);
|
|
assert.equal(protocol.getActivePlan(), basePlan);
|
|
assert.deepEqual(protocol.activations, [ONE, BASE]);
|
|
});
|
|
|
|
test('a preparation left behind by another commit is refused as superseded', async () => {
|
|
registry.initialize();
|
|
const first = await registry.prepare(document.event, {preparationId: 'first', instanceKey: ONE});
|
|
const second = await registry.prepare(document.event, {preparationId: 'second', instanceKey: TWO});
|
|
assert.equal(first.baseRevision, 0);
|
|
assert.equal(second.baseRevision, 0);
|
|
|
|
const commit = registry.commit(document.event, transactionRequest(first));
|
|
assert.deepEqual(commit, {preparationId: 'first', instanceKey: ONE, baseRevision: 0, revision: 1});
|
|
registry.finalize(document.event, committedRequest(first, 1));
|
|
|
|
assert.throws(
|
|
() => registry.commit(document.event, transactionRequest(second)),
|
|
named('DesktopRuntimePreparationSupersededError'),
|
|
);
|
|
assert.equal(protocol.getActivePlan().instanceKey, ONE);
|
|
assert.deepEqual(protocol.activations, [ONE]);
|
|
});
|
|
|
|
test('a commit whose base activation moved underneath it is refused on identity', async () => {
|
|
registry.initialize();
|
|
const prepared = await registry.prepare(document.event, {preparationId: 'first', instanceKey: ONE});
|
|
assert.equal(prepared.baseActiveInstanceKey, null);
|
|
|
|
protocol.activateRuntimePlan(createPlan(BASE));
|
|
|
|
assert.throws(
|
|
() => registry.commit(document.event, transactionRequest(prepared)),
|
|
named('DesktopRuntimePreparationIdentityError'),
|
|
);
|
|
assert.equal(protocol.getActivePlan().instanceKey, BASE);
|
|
assert.deepEqual(protocol.activations, [BASE]);
|
|
});
|
|
|
|
test('a second provisional commit is refused while the first is still open', async () => {
|
|
registry.initialize();
|
|
const first = await registry.prepare(document.event, {preparationId: 'first', instanceKey: ONE});
|
|
const second = await registry.prepare(document.event, {preparationId: 'second', instanceKey: TWO});
|
|
registry.commit(document.event, transactionRequest(first));
|
|
|
|
assert.throws(
|
|
() => registry.commit(document.event, transactionRequest(second)),
|
|
named('DesktopRuntimeCommitInProgressError'),
|
|
);
|
|
assert.equal(protocol.getActivePlan().instanceKey, ONE);
|
|
});
|
|
});
|
|
|
|
describe('DesktopRuntimeTransactionRegistry.abort', () => {
|
|
test('aborting a committed preparation restores the exact plan it replaced', async () => {
|
|
const basePlan = createPlan(BASE);
|
|
protocol = createProtocol(basePlan);
|
|
globalThis[PROTOCOL_KEY] = protocol;
|
|
registry = new DesktopRuntimeTransactionRegistry(rendererDocuments);
|
|
registry.initialize();
|
|
const prepared = await registry.prepare(document.event, {preparationId: 'first', instanceKey: ONE});
|
|
assert.equal(prepared.baseRevision, 1);
|
|
assert.equal(prepared.baseActiveInstanceKey, BASE);
|
|
registry.commit(document.event, transactionRequest(prepared));
|
|
assert.equal(protocol.getActivePlan().instanceKey, ONE);
|
|
|
|
const aborted = registry.abort(document.event, {preparationId: 'first'});
|
|
|
|
assert.deepEqual(aborted, {disposition: 'rolled-back', revision: 3, activeInstanceKey: BASE});
|
|
assert.equal(protocol.getActivePlan(), basePlan);
|
|
assert.deepEqual(protocol.activations, [ONE, BASE]);
|
|
assert.equal(document.liveWatchers(), 0);
|
|
});
|
|
|
|
test('aborting a committed preparation that replaced nothing deactivates the runtime again', async () => {
|
|
registry.initialize();
|
|
const prepared = await registry.prepare(document.event, {preparationId: 'first', instanceKey: ONE});
|
|
registry.commit(document.event, transactionRequest(prepared));
|
|
|
|
const aborted = registry.abort(document.event, {preparationId: 'first'});
|
|
|
|
assert.deepEqual(aborted, {disposition: 'rolled-back', revision: 2, activeInstanceKey: null});
|
|
assert.equal(protocol.getActivePlan(), null);
|
|
assert.deepEqual(protocol.activations, [ONE, null]);
|
|
});
|
|
|
|
test('aborting a prepared-but-uncommitted preparation leaves the runtime alone', async () => {
|
|
registry.initialize();
|
|
await registry.prepare(document.event, {preparationId: 'first', instanceKey: ONE});
|
|
|
|
const aborted = registry.abort(document.event, {preparationId: 'first'});
|
|
|
|
assert.deepEqual(aborted, {disposition: 'aborted', revision: 0, activeInstanceKey: null});
|
|
assert.deepEqual(protocol.activations, []);
|
|
assert.deepEqual(registry.abort(document.event, {preparationId: 'first'}), {
|
|
disposition: 'absent',
|
|
revision: 0,
|
|
activeInstanceKey: null,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('DesktopRuntimeTransactionRegistry.rollback', () => {
|
|
test('rolling back a committed preparation restores the exact plan it replaced', async () => {
|
|
const basePlan = createPlan(BASE);
|
|
protocol = createProtocol(basePlan);
|
|
globalThis[PROTOCOL_KEY] = protocol;
|
|
registry = new DesktopRuntimeTransactionRegistry(rendererDocuments);
|
|
registry.initialize();
|
|
const prepared = await registry.prepare(document.event, {preparationId: 'first', instanceKey: ONE});
|
|
const commit = registry.commit(document.event, transactionRequest(prepared));
|
|
|
|
const rollback = registry.rollback(document.event, committedRequest(prepared, commit.revision));
|
|
|
|
assert.deepEqual(rollback, {revision: commit.revision + 1, activeInstanceKey: BASE});
|
|
assert.equal(protocol.getActivePlan(), basePlan);
|
|
assert.throws(
|
|
() => registry.rollback(document.event, committedRequest(prepared, commit.revision)),
|
|
named('DesktopRuntimePreparationNotFoundError'),
|
|
);
|
|
});
|
|
|
|
test('a finalized preparation can no longer be rolled back', async () => {
|
|
registry.initialize();
|
|
const prepared = await registry.prepare(document.event, {preparationId: 'first', instanceKey: ONE});
|
|
const commit = registry.commit(document.event, transactionRequest(prepared));
|
|
registry.finalize(document.event, committedRequest(prepared, commit.revision));
|
|
|
|
assert.throws(
|
|
() => registry.rollback(document.event, committedRequest(prepared, commit.revision)),
|
|
named('DesktopRuntimePreparationNotFoundError'),
|
|
);
|
|
assert.equal(protocol.getActivePlan().instanceKey, ONE);
|
|
assert.deepEqual(protocol.activations, [ONE]);
|
|
});
|
|
});
|
|
|
|
describe('DesktopRuntimeTransactionRegistry.prepare', () => {
|
|
test('a renderer cannot hold more preparations than the registry admits', async () => {
|
|
registry.initialize();
|
|
for (let index = 0; index < MAX_RUNTIME_PREPARATIONS; index += 1) {
|
|
discovered.set(`https://instance-${index}.test/api`, createPlan(`https://instance-${index}.test/api`));
|
|
const prepared = await registry.prepare(document.event, {
|
|
preparationId: `preparation-${index}`,
|
|
instanceKey: `https://instance-${index}.test/api`,
|
|
});
|
|
assert.equal(prepared.instanceKey, `https://instance-${index}.test/api`);
|
|
}
|
|
|
|
await assert.rejects(
|
|
registry.prepare(document.event, {preparationId: 'overflow', instanceKey: ONE}),
|
|
named('DesktopRuntimePreparationCapacityError'),
|
|
);
|
|
assert.equal(document.liveWatchers(), MAX_RUNTIME_PREPARATIONS);
|
|
assert.deepEqual(registry.abort(document.event, {preparationId: 'overflow'}), {
|
|
disposition: 'absent',
|
|
revision: 0,
|
|
activeInstanceKey: null,
|
|
});
|
|
});
|
|
|
|
test('a duplicate preparation id is refused before it can shadow the live one', async () => {
|
|
registry.initialize();
|
|
await registry.prepare(document.event, {preparationId: 'first', instanceKey: ONE});
|
|
|
|
await assert.rejects(
|
|
registry.prepare(document.event, {preparationId: 'first', instanceKey: TWO}),
|
|
named('DesktopRuntimePreparationAlreadyExistsError'),
|
|
);
|
|
assert.equal(document.liveWatchers(), 1);
|
|
});
|
|
|
|
test('a preparation belongs to the renderer document that opened it', async () => {
|
|
registry.initialize();
|
|
const prepared = await registry.prepare(document.event, {preparationId: 'first', instanceKey: ONE});
|
|
const other = createRendererDocument('document-two');
|
|
|
|
assert.throws(
|
|
() => registry.commit(other.event, transactionRequest(prepared)),
|
|
named('DesktopRuntimePreparationOwnershipError'),
|
|
);
|
|
assert.deepEqual(protocol.activations, []);
|
|
});
|
|
});
|