/** * Accepting a FabriChat direct room someone else created. The room's `about` * links the record its creator's manager wrote, labeled `authored-by` the * creator, and the accepting manager takes the counterpart from that label: it * refuses an event naming someone else, and with no counterpart named, records * the creator. Accepting the room lists it, and lists its other member among * its participants, while a manager whose user's profile hasn't * resolved accepts it without joining them. A room a host registered and * accepted on the user's behalf, as its share intake does, is found by a start * with its creator, again once it is forgotten, and an acceptance that keeps * an archived entry archived leaves it so. A later room accepted with the same * creator leaves the one `direct` holds in place. Joining a room the user's * chats list changes nothing, and records nothing unless it names a request; * joining a direct room the user created, which they can't accept, lists it * again once they forget it, even when the join asks to keep it archived. */ import { action, type AddIntegrity, assert, type Cell, currentPrincipal, type DID, equals, handler, multiUserTest, pattern, principalOf, spaceOf, type Stream, TESTS, Writable, } from "commonfabric"; import { readSharedSpaceCatalog, registerSharedSpace, type SharedSpaceCatalogStorage, } from "../system/shared-space-catalog.ts"; import { FabriChatManagerCore, type ManagerStreamEvent } from "./manager.tsx"; import { CHAT_ROOM_OFFER_KIND, CHAT_START_ACTION, CHAT_START_SURFACE, type ChatIndexEntry, type ChatManagerNotice, type ChatProfile, type ChatRequestOutcome, type ChatRoomLink, type ProfileCell, } from "./schemas.tsx"; type ManagerArg = Parameters[0]; // A stand-in for a person's `#profile`, labeled, as a Fabric profile is, // because a room's participants link only a document that carries a label. type TestProfile = AddIntegrity< ChatProfile, readonly ["fabrichat-test-profile"] >; /** The gesture a start takes, as a client's start control makes it. */ const startGesture = { surface: CHAT_START_SURFACE, action: CHAT_START_ACTION }; const CAROL = "did:key:z6MkiT3dKXX5dqUcbnpf1Ejp8hFVuMM9MN9eftydT9T4uurE"; /** A DID a participant has written, or `""` before it has. */ type MaybeDID = DID | ""; /** The room Alice created, as the test reads it: a link, and its participants. */ type HeldRoom = ChatRoomLink & { participants?: ProfileCell[] }; /** What every session receives from the setup. */ interface Setup { /** Alice's principal, as her own run of a handler finds it. */ aliceDid: Writable; /** Bob's principal, as his own run of a handler finds it. */ bobDid: Writable; /** The direct room Alice created, once she has. */ held: Writable<{ room?: Cell }>; /** * A second direct room Alice created with Bob, from another manager, once * she has. */ heldAgain: Writable<{ room?: Cell }>; } export const setup = pattern(() => ({ aliceDid: Writable.of(""), bobDid: Writable.of(""), held: Writable.of<{ room?: Cell }>({}), heldAgain: Writable.of<{ room?: Cell }>({}), })); /** What `introduce` is bound to. */ interface IntroduceState { /** Where the actor's principal goes. */ me: Writable; } /** Records the actor's principal in `me`. */ const introduce = handler((_event, { me }) => { me.set(currentPrincipal() ?? ""); }); /** An empty shared-space catalog, as a manager lists its rooms from. */ const emptyCatalog = () => Writable.of({ entries: {}, offers: {} }); /** Why the request `id` was refused, or its status if it wasn't. */ const reasonOf = ( requests: Cell>, id: string, ): string => { const outcome = requests.get()?.[id]; return outcome?.status === "refused" ? outcome.reason : outcome?.status ?? "none"; }; /** What `acceptAsHost` is bound to. */ interface AcceptAsHostState { /** The manager's `accept`. */ accept: Stream; /** The room to accept. */ held: Writable<{ room?: Cell }>; /** The request's id. */ requestId: string; /** Whether the acceptance leaves an archived entry archived. */ keepArchived: boolean; } /** * Has a manager accept the held room on the user's behalf, as a host's share * intake does when it sets `keepArchived`. */ const acceptAsHost = handler(( _event, { accept, held, requestId, keepArchived }, ) => { accept.send({ requestId, room: held.key("room").resolveAsCell(), ...(keepArchived ? { keepArchived } : {}), }); }); /** What `joinHeld` is bound to. */ interface JoinHeldState { /** The manager's `joinRoom`. */ joinRoom: Stream; /** The room to join. */ held: Writable<{ room?: Cell }>; /** The request's id; empty, the join names none. */ requestId: string; /** Whether the join asks to leave an archived entry archived. */ keepArchived: boolean; } /** Joins the held room, as a room does once its viewer writes in it. */ const joinHeld = handler(( _event, { joinRoom, held, requestId, keepArchived }, ) => { joinRoom.send({ room: held.key("room").resolveAsCell(), ...(requestId === "" ? {} : { requestId }), ...(keepArchived ? { keepArchived } : {}), }); }); /** What `forgetListed` is bound to. */ interface ForgetListedState { /** The manager's `forget`. */ forget: Stream; /** The room to forget. */ held: Writable<{ room?: Cell }>; /** The manager's catalog, whose entry for the room names its revision. */ catalog: Writable; /** The request's id. */ requestId: string; } /** Forgets the held room, at the revision its catalog entry holds. */ const forgetListed = handler(( _event, { forget, held, catalog, requestId }, ) => { const space = spaceOf(held.key("room")) ?? ""; forget.send({ requestId, room: held.key("room").resolveAsCell(), revision: readSharedSpaceCatalog(catalog).entries[space]?.revision, }); }); /** The state of `catalog`'s entry for the held room. */ const stateOf = ( catalog: Cell, held: Cell<{ room?: Cell }>, ): string | undefined => readSharedSpaceCatalog(catalog).entries[spaceOf(held.key("room")) ?? ""] ?.state; // Creates a direct room with Bob, and hands it to him through the setup, and // then a second one, from another manager. Joins her own room, which can't be // accepted, and which a join lists again once she forgets it. export const alice = pattern<{ setup: Setup }>(({ setup }) => { const requests = Writable.of>({}); const catalog = emptyCatalog(); const manager = FabriChatManagerCore({ myProfile: Writable.of({ name: "Alice" }), sharedSpaceCatalog: catalog, direct: Writable.of>({}), requests, outgoingNotices: Writable.of([]), } as ManagerArg); const action_hand_over = action(() => setup.held.key("room").set( requests.key("d-1").key("entry").key("room").resolveAsCell(), ) ); const againRequests = Writable.of>({}); const again = FabriChatManagerCore({ myProfile: Writable.of({ name: "Alice, again" }), sharedSpaceCatalog: emptyCatalog(), direct: Writable.of>({}), requests: againRequests, outgoingNotices: Writable.of([]), } as ManagerArg); const action_hand_over_again = action(() => setup.heldAgain.key("room").set( againRequests.key("d-2").key("entry").key("room").resolveAsCell(), ) ); const action_join_unnamed = joinHeld({ joinRoom: manager.joinRoom, held: setup.held, requestId: "", keepArchived: false, }); const action_join_named = joinHeld({ joinRoom: manager.joinRoom, held: setup.held, requestId: "j-listed", keepArchived: false, }); const action_join_keeping = joinHeld({ joinRoom: manager.joinRoom, held: setup.held, requestId: "", keepArchived: true, }); const action_accept_own = acceptAsHost({ accept: manager.accept, held: setup.held, requestId: "a-own", keepArchived: false, }); const action_forget_own = forgetListed({ forget: manager.forget, held: setup.held, catalog, requestId: "forget-own", }); const action_forget_own_again = forgetListed({ forget: manager.forget, held: setup.held, catalog, requestId: "forget-own-again", }); return { [TESTS]: [ { action: introduce({ me: setup.aliceDid }), event: {} }, { await: "bob-introduced" }, { action: manager.openDirect, event: { requestId: "d-1", counterpart: setup.bobDid }, trustedUi: startGesture, }, { action: action_hand_over }, { assertion: assert(() => manager.rooms[0]?.counterpart !== undefined), }, { action: again.openDirect, event: { requestId: "d-2", counterpart: setup.bobDid }, trustedUi: startGesture, }, { action: action_hand_over_again }, { assertion: assert(() => again.rooms.length === 1) }, // Joining a room her chats list already changes nothing, and, naming no // request, records nothing; naming one, it records the room's entry. { action: action_join_unnamed }, { assertion: assert(() => Object.keys(requests.get()).length === 1 && stateOf(catalog, setup.held) === "saved" ), }, { action: action_join_named }, { assertion: assert(() => { const outcome = requests.get()["j-listed"]; return outcome?.status === "done" && spaceOf(outcome.entry?.room) === spaceOf(setup.held.key("room")); }), }, // A room she created isn't hers to accept, but once she forgets it, // joining it lists it again, and leaves the room `direct` holds for Bob. { action: action_accept_own }, { assertion: assert(() => reasonOf(requests, "a-own") === "The room was created by this user." ), }, { action: action_forget_own }, { assertion: assert(() => stateOf(catalog, setup.held) === "archived" && manager.rooms.length === 0 ), }, { action: action_join_unnamed }, { assertion: assert(() => stateOf(catalog, setup.held) === "saved" && manager.rooms.length === 1 && manager.rooms[0]?.counterpart === setup.bobDid.get() ), }, // A join is always the user's own, so it lists a forgotten room again // even when it asks to keep it archived. { action: action_forget_own_again }, { assertion: assert(() => stateOf(catalog, setup.held) === "archived") }, { action: action_join_keeping }, { assertion: assert(() => stateOf(catalog, setup.held) === "saved") }, { label: "alice-created" }, { await: "bob-done" }, ], }; }); // Accepts the room, with a counterpart that isn't its creator and with none, // and starts chats with Alice that find rooms listed as hers. export const bob = pattern<{ setup: Setup }>(({ setup }) => { const requests = Writable.of>({}); const bobProfile = Writable.of({ name: "Bob" }); const catalog = emptyCatalog(); const manager = FabriChatManagerCore({ myProfile: bobProfile, sharedSpaceCatalog: catalog, direct: Writable.of>({}), requests, outgoingNotices: Writable.of([]), } as ManagerArg); const action_accept_naming_carol = action(() => manager.accept.send({ requestId: "a-1", room: setup.held.key("room").resolveAsCell(), counterpart: CAROL, }) ); // An acceptance with the room alone, naming no counterpart. const action_accept = acceptAsHost({ accept: manager.accept, held: setup.held, requestId: "a-2", keepArchived: false, }); // A manager whose user's profile hasn't resolved. const profilelessDirect = Writable.of>({}); const profilelessRequests = Writable.of>( {}, ); const profileless = FabriChatManagerCore({ myProfile: Writable.of(), sharedSpaceCatalog: emptyCatalog(), direct: profilelessDirect, requests: profilelessRequests, outgoingNotices: Writable.of([]), } as ManagerArg); const action_accept_profileless = acceptAsHost({ accept: profileless.accept, held: setup.held, requestId: "no-profile", keepArchived: true, }); // How many participants the room lists, as of the latest note. const rosterBefore = Writable.of(-1); const action_note_roster = action(() => rosterBefore.set( (setup.held.key("room").get()?.get()?.participants ?? []).length, ) ); // A manager whose catalog lists the room as a host registers an offered // one, without `accept`, so `direct` holds nothing for it. const offeredCatalog = emptyCatalog(); const offeredDirect = Writable.of>({}); const offeredRequests = Writable.of>({}); const offered = FabriChatManagerCore({ myProfile: Writable.of({ name: "Bob, offered" }), sharedSpaceCatalog: offeredCatalog, direct: offeredDirect, requests: offeredRequests, outgoingNotices: Writable.of([]), } as ManagerArg); const register = registerSharedSpace({ catalog: offeredCatalog }); const action_register_offered = action(() => register.send({ space: spaceOf(setup.held.key("room")) ?? "", host: "http://localhost", kind: CHAT_ROOM_OFFER_KIND, offer: { from: setup.aliceDid.get(), id: "d-1" }, }) ); const action_accept_offered = acceptAsHost({ accept: offered.accept, held: setup.held, requestId: "intake-1", keepArchived: true, }); const action_forget_offered = forgetListed({ forget: offered.forget, held: setup.held, catalog: offeredCatalog, requestId: "forget-1", }); // Alice's second room, registered in the same catalog as her first, and // archived before the host accepts it. const action_register_offered_again = action(() => register.send({ space: spaceOf(setup.heldAgain.key("room")) ?? "", host: "http://localhost", kind: CHAT_ROOM_OFFER_KIND, offer: { from: setup.aliceDid.get(), id: "d-2" }, }) ); const action_forget_offered_again = forgetListed({ forget: offered.forget, held: setup.heldAgain, catalog: offeredCatalog, requestId: "forget-2", }); const action_accept_offered_again = acceptAsHost({ accept: offered.accept, held: setup.heldAgain, requestId: "intake-2", keepArchived: true, }); const action_accept_offered_again_restoring = acceptAsHost({ accept: offered.accept, held: setup.heldAgain, requestId: "intake-2-restoring", keepArchived: false, }); // Alice's second room, registered as a host registers an offered one, in // the catalog of the manager that accepted her first, as admitted later. const registerAgain = registerSharedSpace({ catalog }); const action_register_again = action(() => registerAgain.send({ space: spaceOf(setup.heldAgain.key("room")) ?? "", host: "http://localhost", kind: CHAT_ROOM_OFFER_KIND, offer: { from: setup.aliceDid.get(), id: "d-2" }, since: Date.now() + 3_600_000, }) ); const action_accept_again = acceptAsHost({ accept: manager.accept, held: setup.heldAgain, requestId: "intake-3", keepArchived: true, }); return { [TESTS]: [ { action: introduce({ me: setup.bobDid }), event: {} }, { label: "bob-introduced" }, { await: "alice-created" }, // The room's record names Alice as its creator. { assertion: assert(() => setup.aliceDid.get() !== "" && principalOf( setup.held.key("room").key("about").key("record"), "authored-by", ) === setup.aliceDid.get() ), }, { action: action_accept_naming_carol }, { assertion: assert(() => reasonOf(requests, "a-1") === "The counterpart is not the room's creator." && manager.rooms.length === 0 && Object.keys(readSharedSpaceCatalog(catalog).entries).length === 0 ), }, // With no counterpart named, the acceptance records the room's creator. { action: action_accept }, { assertion: assert(() => requests.get()["a-2"]?.status === "done" && manager.rooms.length === 1 && manager.rooms[0]?.counterpart === setup.aliceDid.get() ), }, // Accepting it registers its space in Bob's catalog, as a saved // FabriChat room, from which the manager lists it. { assertion: assert(() => { const entries = Object.values( readSharedSpaceCatalog(catalog).entries, ); return entries.length === 1 && entries[0]?.kind === CHAT_ROOM_OFFER_KIND && entries[0]?.state === "saved"; }), }, // Accepting the room lists Bob among its participants, without a step // of his own. { assertion: assert(() => (setup.held.key("room").get()?.get()?.participants ?? []).some(( known, ) => equals(known, bobProfile)) ), }, // A manager whose user's profile hasn't resolved accepts the room all // the same, recording it in `direct`, and adds no one to the room's // participants. { action: action_note_roster }, { action: action_accept_profileless }, { assertion: assert(() => profilelessRequests.get()["no-profile"]?.status === "done" && spaceOf(profilelessDirect.get()[setup.aliceDid.get()]?.room) === spaceOf(setup.held.key("room")) && profileless.rooms.length === 1 && rosterBefore.get() > 0 && (setup.held.key("room").get()?.get()?.participants ?? []).length === rosterBefore.get() ), }, // A room listed only by the catalog names its labeled creator as its // counterpart. { action: action_register_offered }, { assertion: assert(() => offered.rooms.length === 1 && offered.rooms[0]?.kind === "direct" && offered.rooms[0]?.counterpart === setup.aliceDid.get() ), }, // The host accepts the room on Bob's behalf, which records it in // `direct`, and a start with Alice finds it there, creating none. { action: action_accept_offered }, { assertion: assert(() => offeredRequests.get()["intake-1"]?.status === "done" && spaceOf(offeredDirect.get()[setup.aliceDid.get()]?.room) === spaceOf(setup.held.key("room")) ), }, { action: offered.openDirect, event: { requestId: "found", counterpart: setup.aliceDid }, trustedUi: startGesture, }, { assertion: assert(() => { const outcome = offeredRequests.get()["found"]; const space = spaceOf(setup.held.key("room")); return space !== undefined && outcome?.status === "done" && spaceOf(outcome.entry?.room) === space && offered.rooms.length === 1 && Object.keys(readSharedSpaceCatalog(offeredCatalog).entries) .length === 1; }), }, // Once it is forgotten, a start with Alice finds it again, and lists it // again. { action: action_forget_offered }, { assertion: assert(() => offeredRequests.get()["forget-1"]?.status === "done" && offered.rooms.length === 0 ), }, { action: offered.openDirect, event: { requestId: "found-again", counterpart: setup.aliceDid }, trustedUi: startGesture, }, { assertion: assert(() => { const outcome = offeredRequests.get()["found-again"]; const space = spaceOf(setup.held.key("room")); return space !== undefined && outcome?.status === "done" && spaceOf(outcome.entry?.room) === space && offered.rooms.length === 1 && Object.keys(readSharedSpaceCatalog(offeredCatalog).entries) .length === 1; }), }, // A room archived between its registration and the host's acceptance // stays archived; an acceptance of the person's own restores it. { action: action_register_offered_again }, { action: action_forget_offered_again }, { assertion: assert(() => offeredRequests.get()["forget-2"]?.status === "done" && stateOf(offeredCatalog, setup.heldAgain) === "archived" ), }, { action: action_accept_offered_again }, { assertion: assert(() => offeredRequests.get()["intake-2"]?.status === "done" && stateOf(offeredCatalog, setup.heldAgain) === "archived" && offered.rooms.length === 1 ), }, { action: action_accept_offered_again_restoring }, { assertion: assert(() => offeredRequests.get()["intake-2-restoring"]?.status === "done" && stateOf(offeredCatalog, setup.heldAgain) === "saved" && offered.rooms.length === 2 ), }, // A second room of Alice's, registered and accepted on Bob's behalf, // leaves the one `direct` holds in place, and a start with her finds // that one. { action: action_register_again }, { action: action_accept_again }, { assertion: assert(() => manager.rooms.length === 2 && requests.get()["intake-3"]?.status === "done" && spaceOf(manager.rooms[0]?.room) === spaceOf(setup.heldAgain.key("room")) && manager.rooms.every((entry) => entry.counterpart === setup.aliceDid.get() ) ), }, { action: manager.openDirect, event: { requestId: "direct-wins", counterpart: setup.aliceDid }, trustedUi: startGesture, }, { assertion: assert(() => { const outcome = requests.get()["direct-wins"]; const space = spaceOf(setup.held.key("room")); return space !== undefined && outcome?.status === "done" && spaceOf(outcome.entry?.room) === space && manager.rooms.length === 2 && Object.keys(readSharedSpaceCatalog(catalog).entries).length === 2; }), }, { label: "bob-done" }, ], }; }); export default multiUserTest({ setup, participants: { alice, bob } });