/// /** * Multi-user pattern test for the lunch poll. * * main.test.tsx documents the single-identity caveat (CT-1598): one runtime * cannot simulate a second user, so admin gating against a real non-host and * host takeover were untestable. This runs ONE shared poll across two * worker-isolated runtimes and covers exactly that gap: * - second user join (PerUser identity isolation), * - admin gating rejecting a genuinely different non-host user, * - votes from two users tallied and visible cross-runtime, * - open host takeover (claimHost) observed from the deposed host's runtime. * * Joins go through the `joinAs` event-name override (the headless seam kept * by the profile migration); the profile-wish UI path needs a browser. * * Cross-runtime reads use INLINE literal accesses (users[0].name) — `.map()`, * loop-variable indexing, and helper calls over another runtime's arrays do * not resolve before a local write (see scrabble/multi-user.test.tsx). */ import { action, computed, multiUserTest, pattern } from "commonfabric"; import LunchPoll, { type CozyPollOutput } from "./main.tsx"; interface Setup { poll: CozyPollOutput; } export const setup = pattern(() => ({ poll: LunchPoll({}), })); export const alice = pattern<{ setup: Setup }>(({ setup }) => { const poll = setup.poll; const action_join = action(() => { poll.joinAs.send({ name: "Alice" }); }); const action_add_sushi = action(() => { poll.addOption.send({ title: "Sushi" }); }); const action_vote_green = action(() => { const first = poll.options?.[0]; if (first) poll.castVote.send({ optionId: first.id, voteType: "green" }); }); // First joiner becomes the host. const assert_joined_as_host = computed(() => poll.myName === "Alice" && poll.adminName === "Alice" && poll.isJoined === true && poll.isAdmin === true && (poll.users ?? []).length === 1 && poll.users?.[0]?.name === "Alice" ); const assert_option_added = computed(() => (poll.options ?? []).length === 1 && poll.options?.[0]?.title === "Sushi" ); const assert_own_vote = computed(() => (poll.votes ?? []).length === 1 && poll.votes?.[0]?.voterName === "Alice" && poll.votes?.[0]?.voteType === "green" ); // Bob joined and voted; his two gated addOption attempts left no trace. const assert_sees_bob = computed(() => (poll.users ?? []).length === 2 && poll.users?.[1]?.name === "Bob" && (poll.votes ?? []).length === 2 && poll.votes?.[1]?.voterName === "Bob" && (poll.options ?? []).length === 1 && poll.myName === "Alice" ); // Host takeover observed from the deposed host's runtime. const assert_deposed = computed(() => poll.adminName === "Bob" && poll.isAdmin === false ); return { tests: [ { action: action_join }, { assertion: assert_joined_as_host }, { action: action_add_sushi }, { assertion: assert_option_added }, { action: action_vote_green }, { assertion: assert_own_vote }, { label: "alice-set-up" }, { await: "bob-voted" }, { assertion: assert_sees_bob }, { await: "bob-claimed-host" }, { assertion: assert_deposed }, ], }; }); export const bob = pattern<{ setup: Setup }>(({ setup }) => { const poll = setup.poll; const action_try_add_before_join = action(() => { poll.addOption.send({ title: "Pizza" }); }); const action_join = action(() => { poll.joinAs.send({ name: "Bob" }); }); const action_try_add_as_non_host = action(() => { poll.addOption.send({ title: "Pizza" }); }); const action_vote_green = action(() => { const first = poll.options?.[0]; if (first) poll.castVote.send({ optionId: first.id, voteType: "green" }); }); const action_claim_host = action(() => { poll.claimHost.send({}); }); // Alice's setup propagated from her runtime. const assert_sees_alice_setup = computed(() => (poll.users ?? []).length === 1 && poll.users?.[0]?.name === "Alice" && poll.adminName === "Alice" && (poll.options ?? []).length === 1 && poll.options?.[0]?.title === "Sushi" && (poll.votes ?? []).length === 1 ); // PerUser isolation: Alice's join must not leak into Bob's identity. const assert_not_joined_yet = computed(() => poll.myName === "" && poll.isJoined === false ); const assert_joined_not_host = computed(() => poll.myName === "Bob" && poll.isJoined === true && poll.isAdmin === false && (poll.users ?? []).length === 2 && poll.users?.[1]?.name === "Bob" ); // Both gated attempts (pre-join AND joined-but-not-host) left no trace — // the CT-1598 gap: a real second user is rejected by the host gate. const assert_gating_held = computed(() => (poll.options ?? []).length === 1); const assert_both_votes = computed(() => (poll.votes ?? []).length === 2 && poll.votes?.[0]?.voterName === "Alice" && poll.votes?.[1]?.voterName === "Bob" ); const assert_is_host_now = computed(() => poll.adminName === "Bob" && poll.isAdmin === true ); return { tests: [ { await: "alice-set-up" }, { assertion: assert_sees_alice_setup }, { assertion: assert_not_joined_yet }, { action: action_try_add_before_join }, { action: action_join }, { assertion: assert_joined_not_host }, { action: action_try_add_as_non_host }, { assertion: assert_gating_held }, { action: action_vote_green }, { assertion: assert_both_votes }, { label: "bob-voted" }, { action: action_claim_host }, { assertion: assert_is_host_now }, { label: "bob-claimed-host" }, ], }; }); export default multiUserTest({ setup, participants: { alice, bob } });