feat: F5.3a the simulation rooms on server F

One room per project for everyone in it, held by a few worker_threads,
each room with a replica of the document that Hocuspocus' direct
connection keeps open: changes go in, what the room writes comes back.
/sim?project=... signs in as /sync does and says refusals and partings
as the socket's closing; only a write grant changes the room. The state
names who started the simulation. After the last person the room stays
60 s, then stops, writes back and ends. Freezing stops a running
simulation and refuses to start one, deleting ends the room without
writing back, revoking lets the person go from the room too; a failed
thread ends its rooms, stopping the service stops them all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 14:29:11 +00:00
co-authored by Claude Opus 5.5
parent 5ded84cb33
commit 8cfc518f0c
17 changed files with 1033 additions and 43 deletions
+25 -3
View File
@@ -2,9 +2,15 @@
// test secrets, a project as the service makes it, a person signed in with
// a ticket as the dashboard signs one, and that person in a project with
// Hocuspocus' own provider over a real WebSocket (bringing the session's
// cookie, as the browser does).
// cookie, as the browser does). The rooms' thread is the real one, bundled
// by esbuild as the build bundles it.
import { randomUUID } from 'node:crypto';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join as joinPath } from 'node:path';
import { pathToFileURL } from 'node:url';
import { build } from 'esbuild';
import { HocuspocusProvider, HocuspocusProviderWebsocket } from '@hocuspocus/provider';
import { expect } from 'vitest';
import WebSocket from 'ws';
@@ -32,6 +38,18 @@ export function memoryLog(): MemoryLog {
export type TestServer = FiliusServer & { readonly log: MemoryLog; readonly base: string };
let roomThread: Promise<URL> | undefined;
/** src/simWorker.ts bundled into a directory of its own, once a test file. */
export function bundledRoomThread(): Promise<URL> {
roomThread ??= (async () => {
const outfile = joinPath(mkdtempSync(joinPath(tmpdir(), 'filius-room-thread-')), 'simWorker.mjs');
await build({ entryPoints: [new URL('../src/simWorker.ts', import.meta.url).pathname], bundle: true, platform: 'node', format: 'esm', target: 'node20', outfile, logLevel: 'error' });
return pathToFileURL(outfile);
})();
return roomThread;
}
export async function startTestServer(store: DocStore, sessions: SessionStore, options: Partial<ServerOptions> = {}): Promise<TestServer> {
const log = memoryLog();
const server = await startServer({
@@ -44,6 +62,8 @@ export async function startTestServer(store: DocStore, sessions: SessionStore, o
serviceKey: SERVICE_KEY,
origins: [EDITOR_ORIGIN],
secureCookie: false,
workerUrl: await bundledRoomThread(),
simThreads: 1,
...options,
});
return Object.assign(server, { log, base: `http://127.0.0.1:${server.port}` });
@@ -61,14 +81,16 @@ export async function newProject(store: DocStore): Promise<string> {
export interface Person {
readonly user?: string;
readonly login?: string;
/** The name the dashboard shows. */
readonly name?: string;
readonly level?: 'read' | 'write';
/** The cookie the browser brings to /__auth. */
readonly cookie?: string;
}
/** The answer of /__auth to a fresh ticket. */
export function exchange(server: TestServer, project: string, { user = randomUUID(), login = 'ab12', level = 'write', cookie }: Person = {}, next = '/'): Promise<Response> {
const ticket = signTicket({ u: user, n: login, d: 'Ada Beispiel', c: '#3366cc', lang: 'de', p: project, lvl: level }, TICKET_SECRET);
export function exchange(server: TestServer, project: string, { user = randomUUID(), login = 'ab12', name = 'Ada Beispiel', level = 'write', cookie }: Person = {}, next = '/'): Promise<Response> {
const ticket = signTicket({ u: user, n: login, d: name, c: '#3366cc', lang: 'de', p: project, lvl: level }, TICKET_SECRET);
return fetch(`${server.base}/__auth?ticket=${encodeURIComponent(ticket)}&next=${encodeURIComponent(next)}`, {
redirect: 'manual',
...(cookie === undefined ? {} : { headers: { cookie } }),
+389
View File
@@ -0,0 +1,389 @@
// The simulation rooms on F (F5.3a) with real sockets, the real room thread
// and a real Postgres, on FILIUS' example internetwork_komplett: one room
// for everyone in a project — started by one, running for all, named after
// who started it; a ping in one person's command line seen by the other;
// what the simulation leaves going into the shared document; a reader who
// watches and changes nothing; the refusals, with their reasons; the room
// staying a while after the last person left, then stopping and ending;
// freezing, deleting and revoking reaching the room; a failed thread
// ending its rooms; the service stopping with rooms running.
import { randomUUID } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import WebSocket from 'ws';
import * as Y from 'yjs';
import { changeFiles, nodeFiles, readProject, writeProject } from '@mdg-filius/doc';
import { openProjectArchive } from '@mdg-filius/fls';
import { SIM_CLOSED_CODE, SIM_REFUSED_CODE, SIM_ROOM_FAILED_CODE, WRITING_KINDS, type HostMessage } from '@mdg-filius/protocol';
import { LINGER_MILLISECONDS, PostgresSessions, PostgresStore } from '../src/index.js';
import { testDatabase, type TestDatabase } from './database.js';
import { EDITOR_ORIGIN, join, SERVICE_KEY, signIn, startTestServer, until, type TestServer } from './harness.js';
let database: TestDatabase;
let store: PostgresStore;
let sessions: PostgresSessions;
beforeAll(async () => {
database = await testDatabase();
store = PostgresStore.connect(database.url);
sessions = new PostgresSessions(store.pool);
await store.migrate();
await sessions.migrate();
}, 60_000);
afterAll(async () => {
await store?.close();
await database?.stop();
});
const EXAMPLE = new URL('../../../corpus/java-examples/internetwork_komplett.fls', import.meta.url);
/** FILIUS' example as a project of F; the id of a device by its name. */
async function exampleProject(): Promise<{ readonly project: string; readonly node: (name: string) => string }> {
const project = randomUUID();
const imported = openProjectArchive(new Uint8Array(readFileSync(EXAMPLE)), () => randomUUID());
const doc = new Y.Doc();
writeProject(doc, imported);
expect(await store.create(project, Y.encodeStateAsUpdate(doc))).toBe(true);
return { project, node: (name) => imported.graph.nodes.find((each) => each.displayName === name)!.id };
}
const start = (options: { readonly linger?: number } = {}) => startTestServer(store, sessions, options.linger === undefined ? {} : { lingerMilliseconds: options.linger });
interface SimClient {
readonly socket: WebSocket;
readonly got: HostMessage[];
/** The socket's closing: its code and reason. */
readonly closed: Promise<{ readonly code: number; readonly reason: string }>;
send(message: object): void;
/** The last state the room sent. */
state(): Extract<HostMessage, { kind: 'state' }> | undefined;
}
function enterRoom(server: TestServer, project: string, { cookie, origin }: { readonly cookie?: string; readonly origin?: string } = {}): Promise<SimClient> {
const socket = new WebSocket(`ws://127.0.0.1:${server.port}/sim?project=${project}`, { headers: { ...(cookie === undefined ? {} : { cookie }), ...(origin === undefined ? {} : { origin }) } });
const got: HostMessage[] = [];
socket.on('message', (data) => got.push(JSON.parse(data.toString()) as HostMessage));
const closed = new Promise<{ code: number; reason: string }>((resolve) => socket.on('close', (code, reason) => resolve({ code, reason: reason.toString() })));
return new Promise((resolve, reject) => {
socket.on('open', () =>
resolve({
socket,
got,
closed,
send: (message) => socket.send(JSON.stringify({ v: 1, ...message })),
state: () => got.filter((message) => message.kind === 'state').at(-1) as Extract<HostMessage, { kind: 'state' }> | undefined,
}),
);
socket.on('unexpected-response', (_request, response) => reject(new Error(`refused: ${response.statusCode}`)));
socket.on('error', reject);
});
}
/** Signed in with a fresh ticket (by name), then in the room. */
async function person(
server: TestServer,
project: string,
name: string,
level: 'read' | 'write' = 'write',
user = randomUUID(),
): Promise<SimClient & { readonly cookie: string; readonly user: string }> {
const cookie = await signIn(server, project, { user, login: name.toLowerCase(), level, name });
return Object.assign(await enterRoom(server, project, { cookie }), { cookie, user });
}
const appLines = (client: SimClient) =>
client.got.filter((message): message is Extract<HostMessage, { kind: 'app' }> => message.kind === 'app').flatMap((message) => message.lines as { text?: string }[]);
const sleep = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds));
function service(server: TestServer, method: string, path: string, body?: unknown): Promise<Response> {
return fetch(`${server.base}${path}`, { method, headers: { 'x-filius-service-key': SERVICE_KEY }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
}
describe('a simulation room on F', () => {
it('runs for everyone in the project once one starts it, names who did, and shows a ping in one command line to the other', async () => {
const server = await start();
const { project, node } = await exampleProject();
const mia = await person(server, project, 'Mia');
const ben = await person(server, project, 'Ben');
await until(() => mia.state() !== undefined && ben.state() !== undefined);
expect(ben.state()).toMatchObject({ mode: 'design' });
const notebook = node('Notebook 1.10');
ben.send({ kind: 'sub', topics: [`app:${notebook}/terminal`] });
mia.send({ kind: 'start' });
await until(() => ben.state()?.mode === 'simulation');
expect(ben.state()!.startedBy).toBe('Mia');
mia.send({ kind: 'input', node: notebook, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r1' });
await until(() => mia.got.some((message) => message.kind === 'ack' && message.reqId === 'r1'));
await until(() => appLines(ben).some((line) => /4 Paket\(e\) gesendet, 4 Paket\(e\) empfangen/.test(line.text ?? '')), 20_000);
ben.send({ kind: 'stop' });
await until(() => mia.state()?.mode === 'design');
expect(mia.state()).not.toHaveProperty('startedBy');
expect(server.log.lines.filter((line) => ['sim.start', 'sim.stop'].includes(line.event as string)).map(({ event, user }) => [event, user])).toEqual([
['sim.start', 'mia'],
['sim.stop', 'ben'],
]);
expect(server.sims.stats()).toEqual({ rooms: 1, people: 2, threads: [1] });
mia.socket.close();
ben.socket.close();
await server.close();
}, 30_000);
it('writes what the simulation leaves into the shared document, for whoever has it open', async () => {
const server = await start();
const { project, node } = await exampleProject();
const mia = await person(server, project, 'Mia');
const editor = await join(server, project, { cookie: mia.cookie });
const notebook = node('Notebook 1.10');
mia.send({ kind: 'start' });
await until(() => mia.state()?.mode === 'simulation');
mia.send({ kind: 'input', node: notebook, app: 'terminal', action: 'enter', args: { line: 'touch probe.txt' }, reqId: 'r1' });
await until(() => (nodeFiles(editor.doc, notebook)?.allEntries() ?? []).some((entry) => entry.path.endsWith('probe.txt')));
// And back: a file a person writes into the document is there for the command line.
mia.send({ kind: 'sub', topics: [`app:${notebook}/terminal`] });
changeFiles(editor.doc, notebook, 'person', (files) => files.writeTextFile('/from-doc.txt', 'x'));
await until(() => editor.provider.unsyncedChanges === 0);
mia.send({ kind: 'input', node: notebook, app: 'terminal', action: 'enter', args: { line: 'ls' }, reqId: 'r2' });
await until(() => appLines(mia).some((line) => (line.text ?? '').includes('from-doc.txt')));
mia.socket.close();
editor.close();
await server.close();
});
it('lets a reader watch and change nothing', async () => {
const server = await start();
const { project, node } = await exampleProject();
const reader = await person(server, project, 'Rita', 'read');
await until(() => reader.state() !== undefined);
for (const kind of WRITING_KINDS) reader.send({ kind, ...(kind === 'input' ? { node: node('Notebook 1.10'), app: 'terminal', action: 'enter', args: { line: 'ls' }, reqId: 'r' } : {}) });
await until(() => reader.got.filter((message) => message.kind === 'error').length === WRITING_KINDS.length);
expect(reader.got.filter((message) => message.kind === 'error')).toEqual([
...WRITING_KINDS.slice(0, -1).map(() => ({ v: 1, kind: 'error', code: 'readOnly' })),
{ v: 1, kind: 'error', code: 'readOnly', reqId: 'r' },
]);
expect(reader.state()).toMatchObject({ mode: 'design' });
reader.send({ kind: 'sub', topics: ['activity'] });
reader.socket.send('not json');
await until(() => reader.got.some((message) => message.kind === 'error' && message.code === 'badMessage'));
expect(server.log.lines.find((line) => line.event === 'sim.joined')).toMatchObject({ project, user: 'rita', write: false });
reader.socket.close();
await server.close();
});
it('refuses without a session, without a grant, a name that is no project, and a page of another origin', async () => {
const server = await start();
const { project } = await exampleProject();
const other = (await exampleProject()).project;
const cookie = await signIn(server, other, { login: 'ab12' });
expect(await (await enterRoom(server, project)).closed).toEqual({ code: SIM_REFUSED_CODE, reason: 'notSignedIn' });
expect(await (await enterRoom(server, project, { cookie })).closed).toEqual({ code: SIM_REFUSED_CODE, reason: 'noAccess' });
expect(await (await enterRoom(server, 'nothing', { cookie })).closed).toEqual({ code: SIM_REFUSED_CODE, reason: 'notAProject' });
await expect(enterRoom(server, project, { cookie, origin: 'https://elsewhere.test' })).rejects.toThrow('refused: 403');
expect(server.log.lines.filter((line) => line.event === 'sim.refused').map(({ reason, user }) => [reason, user])).toEqual([
['notSignedIn', undefined],
['noAccess', 'ab12'],
['notAProject', undefined],
]);
await server.close();
});
it('refuses a project its store no longer has when the room is made', async () => {
const server = await start();
const { project } = await exampleProject();
const mia = await person(server, project, 'Mia');
await until(() => mia.state() !== undefined);
mia.socket.close();
await server.close();
const forgetful = Object.assign(Object.create(store) as PostgresStore, { load: async () => undefined });
const again = await startTestServer(forgetful, sessions);
expect(await (await enterRoom(again, project, { cookie: mia.cookie })).closed).toEqual({ code: SIM_REFUSED_CODE, reason: 'unknownProject' });
expect(again.log.lines.find((line) => line.event === 'sim.openFailed')).toMatchObject({ project });
await again.close();
});
it(`stays ${LINGER_MILLISECONDS / 1000} s after the last person left, running, then stops, writes back and ends`, async () => {
expect(LINGER_MILLISECONDS).toBe(60_000);
const server = await start({ linger: 600 });
const { project, node } = await exampleProject();
const mia = await person(server, project, 'Mia');
mia.send({ kind: 'start' });
await until(() => mia.state()?.mode === 'simulation');
mia.socket.close();
await sleep(200);
// Back within the while: the same room, still running.
const again = await enterRoom(server, project, { cookie: mia.cookie });
await until(() => again.state() !== undefined);
expect(again.state()).toMatchObject({ mode: 'simulation', startedBy: 'Mia' });
// Staying past the while it had: coming back called it off.
await sleep(700);
expect(server.sims.stats().rooms).toBe(1);
again.send({ kind: 'input', node: node('Notebook 1.10'), app: 'terminal', action: 'enter', args: { line: 'touch left.txt' }, reqId: 'r' });
await until(() => again.got.some((message) => message.kind === 'ack'));
again.socket.close();
await until(() => server.sims.stats().rooms === 0, 5_000);
expect(server.log.lines.filter((line) => line.event === 'sim.roomClosed')).toEqual([{ level: 'info', event: 'sim.roomClosed', project, writeBack: true }]);
// The document let go once the room had written back.
await until(() => server.sync.hocuspocus.getDocumentsCount() === 0);
const stored = await store.load(project);
const doc = new Y.Doc();
Y.applyUpdate(doc, stored!.state);
for (const update of stored!.updates) Y.applyUpdate(doc, update);
expect(
nodeFiles(doc, node('Notebook 1.10'))!
.allEntries()
.some((entry) => entry.path.endsWith('left.txt')),
).toBe(true);
// Whoever comes later finds a new room, in design mode.
const later = await enterRoom(server, project, { cookie: mia.cookie });
await until(() => later.state() !== undefined);
expect(later.state()).toMatchObject({ mode: 'design' });
later.socket.close();
await server.close();
});
it('stops a running simulation when the project is frozen, refuses to start it then, and starts it once thawed', async () => {
const server = await start();
const { project, node } = await exampleProject();
const mia = await person(server, project, 'Mia');
mia.send({ kind: 'start' });
await until(() => mia.state()?.mode === 'simulation');
mia.send({ kind: 'input', node: node('Notebook 1.10'), app: 'terminal', action: 'enter', args: { line: 'touch before.txt' }, reqId: 'r' });
await until(() => mia.got.some((message) => message.kind === 'ack'));
expect((await service(server, 'POST', `/svc/projects/${project}/freeze`, { frozen: true })).status).toBe(200);
expect(mia.state()).toMatchObject({ mode: 'design' });
mia.send({ kind: 'start', reqId: 'x' });
await until(() => mia.got.some((message) => message.kind === 'error'));
expect(mia.got.filter((message) => message.kind === 'error')).toEqual([{ v: 1, kind: 'error', code: 'frozen', reqId: 'x' }]);
// A reader is told it reads, not that it is frozen.
const late = await person(server, project, 'Ben');
late.send({ kind: 'start' });
await until(() => late.got.some((message) => message.kind === 'error'));
expect(late.got.find((message) => message.kind === 'error')).toMatchObject({ code: 'frozen' });
expect((await service(server, 'POST', `/svc/projects/${project}/freeze`, { frozen: false })).status).toBe(200);
mia.send({ kind: 'start' });
await until(() => mia.state()?.mode === 'simulation');
mia.socket.close();
late.socket.close();
await server.close();
});
it('freezes a project whose room nobody made, and makes its room frozen', async () => {
const server = await start();
const { project } = await exampleProject();
expect((await service(server, 'POST', `/svc/projects/${project}/freeze`, { frozen: true })).status).toBe(200);
const mia = await person(server, project, 'Mia');
mia.send({ kind: 'start' });
await until(() => mia.got.some((message) => message.kind === 'error'));
expect(mia.got.find((message) => message.kind === 'error')).toMatchObject({ code: 'frozen' });
mia.socket.close();
await server.close();
});
it('lets everyone in the room go when the project is deleted, and writes nothing back', async () => {
const server = await start();
const { project } = await exampleProject();
const mia = await person(server, project, 'Mia');
mia.send({ kind: 'start' });
await until(() => mia.state()?.mode === 'simulation');
const response = await service(server, 'DELETE', `/svc/projects/${project}`);
expect(await response.json()).toEqual({ deleted: true, disconnected: 1 });
expect(await mia.closed).toEqual({ code: SIM_CLOSED_CODE, reason: 'deleted' });
expect(server.log.lines.find((line) => line.event === 'sim.roomClosed')).toMatchObject({ project, writeBack: false });
await until(() => server.sync.hocuspocus.getDocumentsCount() === 0);
expect(server.log.lines.filter((line) => line.event === 'store.failed')).toEqual([]);
expect(await store.load(project)).toBeUndefined();
await server.close();
});
it('lets a person go whose grant ends, to one project or to all, and no one else', async () => {
const server = await start();
const { project } = await exampleProject();
const other = (await exampleProject()).project;
const user = randomUUID();
const mia = await person(server, project, 'Mia', 'write', user);
const miaThere = await person(server, other, 'Mia', 'write', user);
const ben = await person(server, project, 'Ben');
expect(await (await service(server, 'POST', '/svc/revoke', { user, project })).json()).toEqual({ revoked: 1, disconnected: 1 });
expect(await mia.closed).toEqual({ code: SIM_CLOSED_CODE, reason: 'revoked' });
expect(miaThere.socket.readyState).toBe(WebSocket.OPEN);
// Two sessions: one ticket each.
expect(await (await service(server, 'POST', '/svc/revoke', { user })).json()).toEqual({ revoked: 2, disconnected: 1 });
expect(await miaThere.closed).toEqual({ code: SIM_CLOSED_CODE, reason: 'revoked' });
expect(ben.socket.readyState).toBe(WebSocket.OPEN);
ben.socket.close();
await server.close();
});
it('ends the rooms of a thread that fails, and gives whoever comes back a new room', async () => {
const server = await start();
const { project } = await exampleProject();
const mia = await person(server, project, 'Mia');
mia.send({ kind: 'start' });
await until(() => mia.state()?.mode === 'simulation');
await server.sims.workers[0]!.terminate();
expect(await mia.closed).toEqual({ code: SIM_ROOM_FAILED_CODE, reason: 'roomFailed' });
expect(server.log.lines.find((line) => line.event === 'sim.threadFailed')).toMatchObject({ rooms: 1 });
const again = await enterRoom(server, project, { cookie: mia.cookie });
await until(() => again.state() !== undefined);
expect(again.state()).toMatchObject({ mode: 'design' });
expect(server.sims.stats()).toEqual({ rooms: 1, people: 1, threads: [1] });
again.socket.close();
await server.close();
});
it('stops every room when the service stops, writing back what they left, and lets everyone go', async () => {
const server = await start();
const { project, node } = await exampleProject();
const mia = await person(server, project, 'Mia');
mia.send({ kind: 'start' });
await until(() => mia.state()?.mode === 'simulation');
mia.send({ kind: 'input', node: node('Notebook 1.10'), app: 'terminal', action: 'enter', args: { line: 'touch last.txt' }, reqId: 'r' });
await until(() => mia.got.some((message) => message.kind === 'ack'));
await server.close();
expect(await mia.closed).toEqual({ code: 1001, reason: 'service stopping' });
const stored = await store.load(project);
const doc = new Y.Doc();
Y.applyUpdate(doc, stored!.state);
for (const update of stored!.updates) Y.applyUpdate(doc, update);
expect(
nodeFiles(doc, node('Notebook 1.10'))!
.allEntries()
.some((entry) => entry.path.endsWith('last.txt')),
).toBe(true);
expect(readProject(doc).graph.nodes.length).toBeGreaterThan(0);
});
it('lets go a socket that answers no ping', async () => {
const server = await startTestServer(store, sessions, { pingMilliseconds: 100 });
const { project } = await exampleProject();
const mia = await person(server, project, 'Mia');
await until(() => mia.state() !== undefined);
// A socket that went away says nothing: ws answers pings by itself, so its answering is stopped.
(mia.socket as unknown as { _receiver: { removeAllListeners(name: string): void } })._receiver.removeAllListeners('ping');
expect((await mia.closed).code).toBe(1006);
await server.close();
});
it('gives a new room to the thread with the fewest', async () => {
const server = await startTestServer(store, sessions, { simThreads: 2 });
const [first, second, third] = [await exampleProject(), await exampleProject(), await exampleProject()];
const people = [await person(server, first.project, 'Mia'), await person(server, second.project, 'Ben')];
await until(() => people.every((each) => each.state() !== undefined));
expect(server.sims.stats().threads).toEqual([1, 1]);
people.push(await person(server, third.project, 'Rita'));
await until(() => people[2]!.state() !== undefined);
expect([...server.sims.stats().threads].sort()).toEqual([1, 2]);
for (const each of people) each.socket.close();
await server.close();
});
it('opens a room for the editor with its origin', async () => {
const server = await start();
const { project } = await exampleProject();
const cookie = await signIn(server, project);
const client = await enterRoom(server, project, { cookie, origin: EDITOR_ORIGIN });
await until(() => client.state() !== undefined);
client.socket.close();
await server.close();
});
});
+1 -1
View File
@@ -280,7 +280,7 @@ describe('server F keeps the shared documents', () => {
it('answers /healthz, refuses other ways in, and logs JSON', async () => {
const server = await start();
const health = await fetch(`http://127.0.0.1:${server.port}/healthz`);
expect([health.status, await health.json()]).toEqual([200, { status: 'ok', documents: 0, connections: 0 }]);
expect([health.status, await health.json()]).toEqual([200, { status: 'ok', documents: 0, connections: 0, rooms: 0 }]);
expect((await fetch(`http://127.0.0.1:${server.port}/nothing`)).status).toBe(404);
const refused = await new Promise<number>((resolve) => {
const socket = new WebSocket(`ws://127.0.0.1:${server.port}/elsewhere`);