@tribe-nest/forge 3.29.0 → 3.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/package.json +6 -3
  2. package/src/_tests/publishedResolvability.spec.ts +184 -0
  3. package/src/_tests/specsRunWorkspaceSource.spec.ts +116 -0
  4. package/src/_tests/workspaceAliases.ts +40 -0
  5. package/src/contexts/PublicAuthContext.tsx +34 -5
  6. package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
  7. package/src/data/queries/useBroadcasts.ts +151 -0
  8. package/src/data/queries/useMyBookings.ts +9 -1
  9. package/src/i18n/de.json +59 -0
  10. package/src/i18n/en.json +59 -0
  11. package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
  12. package/src/ui/format/_tests/pwyw.spec.ts +65 -8
  13. package/src/ui/format/membershipPwyw.ts +164 -0
  14. package/src/ui/format/pwyw.ts +37 -0
  15. package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
  16. package/src/ui/headless/broadcast/broadcastState.ts +158 -0
  17. package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
  18. package/src/ui/headless/event/useEventCheckout.ts +8 -13
  19. package/src/ui/headless/index.ts +14 -0
  20. package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
  21. package/src/ui/index.ts +36 -0
  22. package/src/ui/media/CallHelpHint.tsx +87 -0
  23. package/src/ui/media/CallStage.tsx +542 -0
  24. package/src/ui/media/_tests/CallStage.spec.tsx +685 -0
  25. package/src/ui/media/_tests/bookingSession.spec.tsx +179 -0
  26. package/src/ui/media/_tests/callState.spec.ts +452 -0
  27. package/src/ui/media/_tests/fakeNode.ts +178 -0
  28. package/src/ui/media/bookingSession.tsx +194 -0
  29. package/src/ui/media/callState.ts +341 -0
  30. package/src/ui/media/index.ts +135 -0
  31. package/src/ui/styled/AccountDashboard.tsx +92 -3
  32. package/src/ui/styled/BroadcastWatch.tsx +107 -0
  33. package/src/ui/styled/ForgotPasswordForm.tsx +5 -0
  34. package/src/ui/styled/LiveBroadcastList.tsx +171 -0
  35. package/src/ui/styled/LoginForm.tsx +10 -0
  36. package/src/ui/styled/MembershipCheckout.tsx +318 -45
  37. package/src/ui/styled/MembershipTiers.tsx +10 -3
  38. package/src/ui/styled/ResetPasswordForm.tsx +5 -0
  39. package/src/ui/styled/SignupForm.tsx +5 -0
  40. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
  41. package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
  42. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
  43. package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
  44. package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
  45. package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
  46. package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
  47. package/src/ui/styled/community/CommunityComposer.tsx +182 -3
  48. package/src/ui/styled/community/CommunityFeed.tsx +36 -51
  49. package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
  50. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
  51. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +175 -0
@@ -0,0 +1,179 @@
1
+ // @vitest-environment jsdom
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { renderHook } from "@testing-library/react";
4
+
5
+ import {
6
+ BOOKING_CALL_CLOSES_MINUTES_AFTER,
7
+ BOOKING_CALL_OPENS_MINUTES_BEFORE,
8
+ bookingCallWindow,
9
+ useBookingSessionCredentials,
10
+ type BookingCallSubject,
11
+ } from "../bookingSession";
12
+
13
+ /**
14
+ * The Forge context, stubbed at the seam the hook actually reads.
15
+ *
16
+ * `ForgeClientProvider` builds its Axios instance from an `apiUrl`, so there is
17
+ * no way to hand it a fake transport from outside; mocking `useForge` is the
18
+ * only join where a spec can see the request the hook makes.
19
+ */
20
+ const { post, forgeContext } = vi.hoisted(() => {
21
+ const post = vi.fn();
22
+ // ONE object across renders, like the real provider's memoized client. A
23
+ // fresh one per render would change the hook's dependency and make the
24
+ // identity assertion below fail against a correct implementation.
25
+ return { post, forgeContext: { client: { post }, profileId: "11111111-1111-4111-8111-111111111111" } };
26
+ });
27
+
28
+ vi.mock("../../../provider/ForgeProvider", () => ({ useForge: () => forgeContext }));
29
+
30
+ /**
31
+ * The booking half of the media barrel.
32
+ *
33
+ * Two things are worth testing here and nothing else is. `bookingCallWindow` is
34
+ * a total function that decides whether a Join control is drawn at all, and
35
+ * getting it wrong shows a client a button that 409s or hides one from a
36
+ * session that has started. `useBookingSessionCredentials` is a transport, and
37
+ * the property that matters is that it posts to the credential endpoint with
38
+ * NO body: the endpoint's schema is `.strict()`, so a field added here in a
39
+ * moment of helpfulness would 400 every call in production.
40
+ */
41
+
42
+ const MINUTE = 60_000;
43
+ const HOUR = 60 * MINUTE;
44
+
45
+ const booking = (overrides: Partial<BookingCallSubject> = {}): BookingCallSubject => ({
46
+ status: "confirmed",
47
+ sessionStartTime: new Date("2026-08-20T10:00:00.000Z").toISOString(),
48
+ sessionEndTime: new Date("2026-08-20T11:00:00.000Z").toISOString(),
49
+ location: { type: "video" },
50
+ ...overrides,
51
+ });
52
+
53
+ const at = (iso: string) => new Date(iso);
54
+
55
+ describe("the join window, as literal numbers", () => {
56
+ /**
57
+ * The one place in this file that does NOT compute its expectation from the
58
+ * constants.
59
+ *
60
+ * Everything below asserts `opensAt` against
61
+ * `BOOKING_CALL_OPENS_MINUTES_BEFORE`, which is correct for what those tests
62
+ * are about (the arithmetic) and completely blind to the value: changing 15 to
63
+ * 60 leaves the whole suite green. The server carries its OWN pair of literals
64
+ * in `apps/backend/src/services/public/coaching/commands/bookingSession.ts`
65
+ * (`SESSION_JOIN_OPENS_MINUTES_BEFORE` / `SESSION_JOIN_CLOSES_MINUTES_AFTER`),
66
+ * whose spec had exactly the same self-referential shape, so a change on
67
+ * either side was invisible to both suites.
68
+ *
69
+ * The server's answer is the one that counts. When these two disagree the
70
+ * visible result is a Join button drawn 45 minutes before the server will mint
71
+ * a ticket (a client clicks and gets a bare 409), or a button hidden while the
72
+ * room is open. So both sides pin the value, and moving it means editing two
73
+ * specs that name each other.
74
+ */
75
+ it("mirrors the server: 15 minutes before, 120 minutes after", () => {
76
+ expect(BOOKING_CALL_OPENS_MINUTES_BEFORE).toBe(15);
77
+ expect(BOOKING_CALL_CLOSES_MINUTES_AFTER).toBe(120);
78
+ });
79
+ });
80
+
81
+ describe("bookingCallWindow", () => {
82
+ it("opens exactly the stated number of minutes before the session", () => {
83
+ const subject = booking();
84
+ const opensAt = new Date(new Date(subject.sessionStartTime).getTime() - BOOKING_CALL_OPENS_MINUTES_BEFORE * MINUTE);
85
+
86
+ expect(bookingCallWindow(subject).opensAt.toISOString()).toBe(opensAt.toISOString());
87
+ // One millisecond either side of the boundary, so an off-by-one in the
88
+ // comparison cannot pass: a client refreshing at the advertised minute must
89
+ // get in, and one refreshing a moment earlier must not.
90
+ expect(bookingCallWindow(subject, new Date(opensAt.getTime() - 1)).isOpen).toBe(false);
91
+ expect(bookingCallWindow(subject, opensAt).isOpen).toBe(true);
92
+ });
93
+
94
+ it("stays open after the hour ends, then shuts", () => {
95
+ const subject = booking();
96
+ const closesAt = new Date(new Date(subject.sessionEndTime).getTime() + BOOKING_CALL_CLOSES_MINUTES_AFTER * MINUTE);
97
+
98
+ expect(bookingCallWindow(subject).closesAt.toISOString()).toBe(closesAt.toISOString());
99
+ // A session that runs over must not be cut off, which is what the window
100
+ // after the end is for.
101
+ expect(bookingCallWindow(subject, at("2026-08-20T11:30:00.000Z")).isOpen).toBe(true);
102
+ expect(bookingCallWindow(subject, closesAt).isOpen).toBe(true);
103
+ expect(bookingCallWindow(subject, new Date(closesAt.getTime() + 1)).isOpen).toBe(false);
104
+ });
105
+
106
+ it("is not a video call when the location is anything else", () => {
107
+ const midSession = at("2026-08-20T10:30:00.000Z");
108
+
109
+ for (const location of [{ type: "online" }, { type: "in_person" }, null, undefined]) {
110
+ const window = bookingCallWindow(booking({ location }), midSession);
111
+ expect(window.isVideo).toBe(false);
112
+ // Never open, however good the timing is: there is no room to enter.
113
+ expect(window.isOpen).toBe(false);
114
+ }
115
+ });
116
+
117
+ it("is not a video call when the booking is not confirmed", () => {
118
+ const midSession = at("2026-08-20T10:30:00.000Z");
119
+ // A cancelled session has no room. The server withholds `location`
120
+ // entirely for one, so this is belt and braces against a cached row.
121
+ const window = bookingCallWindow(booking({ status: "canceled" }), midSession);
122
+ expect(window.isVideo).toBe(false);
123
+ expect(window.isOpen).toBe(false);
124
+ });
125
+
126
+ it("holds for a session whose window straddles a day boundary", () => {
127
+ const subject = booking({
128
+ sessionStartTime: "2026-08-20T23:50:00.000Z",
129
+ sessionEndTime: "2026-08-21T00:50:00.000Z",
130
+ });
131
+ expect(bookingCallWindow(subject, at("2026-08-20T23:40:00.000Z")).isOpen).toBe(true);
132
+ expect(bookingCallWindow(subject, at("2026-08-20T23:34:00.000Z")).isOpen).toBe(false);
133
+ expect(bookingCallWindow(subject, at("2026-08-21T02:49:00.000Z")).isOpen).toBe(true);
134
+ expect(bookingCallWindow(subject, at("2026-08-21T02:51:00.000Z")).isOpen).toBe(false);
135
+ });
136
+ });
137
+
138
+ describe("useBookingSessionCredentials", () => {
139
+ beforeEach(() => {
140
+ post.mockReset();
141
+ post.mockResolvedValue({
142
+ data: {
143
+ mediaUrl: "wss://media.test",
144
+ token: "tok",
145
+ expiresAt: "2026-08-20T10:10:00.000Z",
146
+ identity: "guest-acc-1",
147
+ roomId: "booking-booking-1",
148
+ grants: { canPublish: true, canSubscribe: true, canPublishData: true },
149
+ role: "guest",
150
+ roomOpensAt: "2026-08-20T09:45:00.000Z",
151
+ roomClosesAt: "2026-08-20T13:00:00.000Z",
152
+ },
153
+ });
154
+ });
155
+
156
+ it("posts an EMPTY body to this booking's credential endpoint", async () => {
157
+ const getCredentials = renderHook(() => useBookingSessionCredentials("booking-1")).result.current;
158
+
159
+ const credentials = await getCredentials();
160
+
161
+ expect(post).toHaveBeenCalledTimes(1);
162
+ expect(post).toHaveBeenCalledWith("/public/coaching/bookings/booking-1/session/join", {});
163
+ // The endpoint's schema is `.strict()` and declares no fields at all, so a
164
+ // `profileId` added here in a moment of helpfulness would 400 every call.
165
+ // Asserting the exact second argument is what catches that.
166
+ expect(credentials.roomId).toBe("booking-booking-1");
167
+ expect(credentials.role).toBe("guest");
168
+ });
169
+
170
+ it("keeps ONE callback identity per booking", () => {
171
+ const { result, rerender } = renderHook(() => useBookingSessionCredentials("booking-1"));
172
+ const first = result.current;
173
+ rerender();
174
+ // `<MediaRoomProvider>` holds its options in a ref because an inline arrow
175
+ // would be a new function every render; a callback that changed identity on
176
+ // every parent render would defeat that and reconnect a live call.
177
+ expect(result.current).toBe(first);
178
+ });
179
+ });
@@ -0,0 +1,452 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import type { ProducerEntry } from "@tribe-nest/media-client";
4
+ import type { Peer } from "@tribe-nest/media-protocol";
5
+
6
+ import { callHeadCount, callStatus, callTiles, canPublishSource, remoteAudioProducerIds } from "../callState";
7
+
8
+ /**
9
+ * The two decisions a call screen gets wrong.
10
+ *
11
+ * Both of them are invisible in a component test that only checks "did it
12
+ * render", and both of them are the difference between a call that works and a
13
+ * call that looks broken while working perfectly.
14
+ */
15
+
16
+ const peer = (identity: string, name?: string): Peer => ({
17
+ identity,
18
+ kind: "human",
19
+ ...(name === undefined ? {} : { name }),
20
+ });
21
+
22
+ /** The recording process, which joins the room as a real peer. */
23
+ const recorder = (identity: string): Peer => ({ identity, kind: "egress" });
24
+
25
+ const producer = (
26
+ producerId: string,
27
+ identity: string,
28
+ kind: "audio" | "video",
29
+ paused = false,
30
+ ): ProducerEntry => ({ producerId, identity, kind, paused });
31
+
32
+ describe("callTiles keeps the grid from collapsing", () => {
33
+ it("draws a tile for somebody with NO video at all", () => {
34
+ const tiles = callTiles({
35
+ identity: "me",
36
+ peers: [peer("anna", "Anna")],
37
+ producers: [producer("p-audio", "anna", "audio")],
38
+ activeSpeakers: [],
39
+ });
40
+
41
+ // The whole point. A participant with the camera off has not left the room,
42
+ // and a grid built by mapping over video tracks drops them silently.
43
+ expect(tiles).toHaveLength(1);
44
+ expect(tiles[0]!.name).toBe("Anna");
45
+ expect(tiles[0]!.videoProducerId).toBeUndefined();
46
+ expect(tiles[0]!.hasAudio).toBe(true);
47
+ });
48
+
49
+ it("keeps the SAME number of tiles when a camera goes off", () => {
50
+ const peers = [peer("anna", "Anna"), peer("ben", "Ben")];
51
+ const withCamera = callTiles({
52
+ identity: "me",
53
+ peers,
54
+ producers: [producer("p-anna-video", "anna", "video"), producer("p-ben-audio", "ben", "audio")],
55
+ activeSpeakers: [],
56
+ });
57
+ const cameraOff = callTiles({
58
+ identity: "me",
59
+ peers,
60
+ producers: [producer("p-ben-audio", "ben", "audio")],
61
+ activeSpeakers: [],
62
+ });
63
+
64
+ expect(withCamera).toHaveLength(2);
65
+ // Two before, two after. This is the assertion that fails on the bug.
66
+ expect(cameraOff).toHaveLength(2);
67
+ expect(cameraOff.map((t) => t.identity)).toEqual(["anna", "ben"]);
68
+ // And the key is unchanged, so React updates the tile rather than
69
+ // unmounting and remounting it under the viewer's eyes.
70
+ expect(cameraOff[0]!.key).toBe(withCamera[0]!.key);
71
+ });
72
+
73
+ it("never draws the local participant, who has their own preview", () => {
74
+ const tiles = callTiles({
75
+ identity: "me",
76
+ // The node may or may not include us in `peers`; both have to be safe.
77
+ peers: [peer("me", "Me"), peer("anna", "Anna")],
78
+ producers: [producer("p-mine", "me", "video")],
79
+ activeSpeakers: [],
80
+ });
81
+
82
+ expect(tiles.map((t) => t.identity)).toEqual(["anna"]);
83
+ });
84
+
85
+ it("gives a second video stream its own tile rather than replacing the face", () => {
86
+ const tiles = callTiles({
87
+ identity: "me",
88
+ peers: [peer("anna", "Anna")],
89
+ producers: [producer("p-cam", "anna", "video"), producer("p-screen", "anna", "video")],
90
+ activeSpeakers: [],
91
+ });
92
+
93
+ expect(tiles).toHaveLength(2);
94
+ expect(tiles.map((t) => t.videoProducerId)).toEqual(["p-cam", "p-screen"]);
95
+ // Same person, so the same name on both. The wire does not label which is
96
+ // the screen, so neither does this.
97
+ expect(tiles.every((t) => t.name === "Anna")).toBe(true);
98
+ expect(new Set(tiles.map((t) => t.key)).size).toBe(2);
99
+ });
100
+
101
+ it("draws a stream whose peer frame has not arrived yet", () => {
102
+ // `producerAppeared` and `peerJoined` have no ordering guarantee. Dropping
103
+ // the stream would blank a tile that has media in it.
104
+ const tiles = callTiles({
105
+ identity: "me",
106
+ peers: [],
107
+ producers: [producer("p-early", "carla", "video")],
108
+ activeSpeakers: [],
109
+ });
110
+
111
+ expect(tiles).toHaveLength(1);
112
+ expect(tiles[0]!.identity).toBe("carla");
113
+ expect(tiles[0]!.videoProducerId).toBe("p-early");
114
+ });
115
+
116
+ it("falls back to the identity, which tells two unnamed people apart", () => {
117
+ const tiles = callTiles({
118
+ identity: "me",
119
+ peers: [peer("guest-1"), peer("guest-2", " ")],
120
+ producers: [],
121
+ activeSpeakers: [],
122
+ });
123
+
124
+ expect(tiles.map((t) => t.name)).toEqual(["guest-1", "guest-2"]);
125
+ });
126
+
127
+ it("marks the speaker from the node's producer ids, not from identities", () => {
128
+ const tiles = callTiles({
129
+ identity: "me",
130
+ peers: [peer("anna", "Anna"), peer("ben", "Ben")],
131
+ producers: [
132
+ producer("p-anna-audio", "anna", "audio"),
133
+ producer("p-anna-video", "anna", "video"),
134
+ producer("p-ben-audio", "ben", "audio"),
135
+ ],
136
+ // The node sends PRODUCER ids. Matching these against identities would
137
+ // light up nobody, and the indication would simply never appear.
138
+ activeSpeakers: ["p-anna-audio"],
139
+ });
140
+
141
+ expect(tiles.find((t) => t.identity === "anna")!.isSpeaking).toBe(true);
142
+ expect(tiles.find((t) => t.identity === "ben")!.isSpeaking).toBe(false);
143
+ });
144
+
145
+ it("carries the publisher's own pause through", () => {
146
+ const tiles = callTiles({
147
+ identity: "me",
148
+ peers: [peer("anna", "Anna")],
149
+ producers: [producer("p-cam", "anna", "video", true)],
150
+ activeSpeakers: [],
151
+ });
152
+
153
+ expect(tiles[0]!.videoPaused).toBe(true);
154
+ });
155
+ });
156
+
157
+ describe("callHeadCount counts PEOPLE, not tiles", () => {
158
+ it("does not gain a person when somebody shares their screen", () => {
159
+ // A coach and a client: two people. The coach shares a screen, which by
160
+ // `callTiles`' own design gives them a second tile, and a count taken from
161
+ // `tiles.length` announced a third party in a two-person call at exactly
162
+ // the moment a coaching call is most likely to have one.
163
+ const tiles = callTiles({
164
+ identity: "me",
165
+ peers: [peer("anna", "Anna")],
166
+ producers: [producer("p-cam", "anna", "video"), producer("p-screen", "anna", "video")],
167
+ activeSpeakers: [],
168
+ });
169
+
170
+ expect(tiles).toHaveLength(2);
171
+ expect(callHeadCount(tiles)).toBe(2);
172
+ });
173
+
174
+ it("counts everyone once, including the viewer, and the viewer alone in an empty room", () => {
175
+ expect(callHeadCount([])).toBe(1);
176
+
177
+ const tiles = callTiles({
178
+ identity: "me",
179
+ peers: [peer("anna", "Anna"), peer("ben", "Ben")],
180
+ producers: [producer("p-anna-audio", "anna", "audio"), producer("p-ben-video", "ben", "video")],
181
+ activeSpeakers: [],
182
+ });
183
+
184
+ expect(callHeadCount(tiles)).toBe(3);
185
+ });
186
+
187
+ /**
188
+ * The recorder is not somebody who arrived.
189
+ *
190
+ * A recording joins as a real peer with `kind: "egress"`, so a grid built
191
+ * from `peers` drew a third tile called `egress-9f2c` in a 1:1 session and
192
+ * the header read "In this call: 3" - at exactly the moment a coach is most
193
+ * likely to be reading that number, and it is the number they use to answer
194
+ * "has my client actually arrived". `<RecordingIndicator>` already says the
195
+ * true thing, in words, as content.
196
+ */
197
+ it("does not count the recording as a person in the room", () => {
198
+ const tiles = callTiles({
199
+ identity: "me",
200
+ peers: [peer("anna", "Anna"), recorder("egress-9f2c")],
201
+ producers: [producer("p-anna-audio", "anna", "audio")],
202
+ activeSpeakers: [],
203
+ });
204
+
205
+ expect(tiles.map((t) => t.identity)).toEqual(["anna"]);
206
+ // A coach and their client. Two.
207
+ expect(callHeadCount(tiles)).toBe(2);
208
+ });
209
+
210
+ it("keeps a dial-in caller and an agent, which are somebody to talk to", () => {
211
+ const tiles = callTiles({
212
+ identity: "me",
213
+ peers: [
214
+ { identity: "+4915112345678", kind: "sip" },
215
+ { identity: "notetaker", kind: "agent", name: "Notes" },
216
+ ],
217
+ producers: [],
218
+ activeSpeakers: [],
219
+ });
220
+
221
+ // Only `egress` is excluded, and only because it is a recorder rather than
222
+ // a participant. Dropping every non-human would hide a caller who dialled in.
223
+ expect(tiles.map((t) => t.identity)).toEqual(["+4915112345678", "notetaker"]);
224
+ expect(callHeadCount(tiles)).toBe(3);
225
+ });
226
+ });
227
+
228
+ describe("remoteAudioProducerIds", () => {
229
+ it("takes everyone else's audio and none of our own", () => {
230
+ const ids = remoteAudioProducerIds({
231
+ identity: "me",
232
+ producers: [
233
+ producer("p-mine", "me", "audio"),
234
+ producer("p-anna", "anna", "audio"),
235
+ producer("p-anna-video", "anna", "video"),
236
+ ],
237
+ });
238
+
239
+ // Our own audio played back is a feedback loop, and a video producer
240
+ // attached to an `<audio>` element is silence.
241
+ expect(ids).toEqual(["p-anna"]);
242
+ });
243
+ });
244
+
245
+ describe("callStatus is honest about what is happening", () => {
246
+ it("separates a reconnect from a refusal", () => {
247
+ const dropped = callStatus({
248
+ connectionState: "reconnecting",
249
+ error: { type: "socket_closed", code: 1006 },
250
+ recovering: true,
251
+ });
252
+ const refused = callStatus({ connectionState: "reconnecting", error: { type: "refused", code: "unauthorized" } });
253
+
254
+ // The room reports ONE state for both. They are completely different things
255
+ // to the person looking at the screen: one is worth waiting through, the
256
+ // other is worth pressing a button about.
257
+ expect(dropped.tone).toBe("reconnecting");
258
+ expect(refused.tone).toBe("failed");
259
+ expect(dropped.headline).not.toBe(refused.headline);
260
+ });
261
+
262
+ it("shows the server's own words for a refusal rather than a friendly lie", () => {
263
+ expect(callStatus({ connectionState: "reconnecting", error: { type: "refused", code: "room_closed" } }).detail).toBe(
264
+ "room_closed",
265
+ );
266
+ expect(
267
+ callStatus({
268
+ connectionState: "reconnecting",
269
+ error: { type: "refused", code: "unauthorized", message: "this session has ended" },
270
+ }).detail,
271
+ ).toBe("this session has ended");
272
+ });
273
+
274
+ it("does not offer a retry for something retrying cannot fix", () => {
275
+ expect(callStatus({ connectionState: "connected", error: undefined }).canRetry).toBe(false);
276
+ expect(callStatus({ connectionState: "connecting", error: undefined }).canRetry).toBe(false);
277
+ expect(callStatus({ connectionState: "closed", error: { type: "closed_by_client" } }).canRetry).toBe(false);
278
+ expect(
279
+ callStatus({ connectionState: "reconnecting", error: { type: "room_closed", reason: "host ended" } }).canRetry,
280
+ ).toBe(false);
281
+ // A node moving us off itself reconnects on its own; a button would be a
282
+ // second connect racing the first. WHILE it is actually moving us: the
283
+ // promise and the control are two halves of one decision.
284
+ expect(
285
+ callStatus({
286
+ connectionState: "reconnecting",
287
+ error: { type: "draining", reconnectAfterMs: 500 },
288
+ recovering: true,
289
+ }).canRetry,
290
+ ).toBe(false);
291
+ });
292
+
293
+ /**
294
+ * A spinner is a promise, and it has to be one somebody is keeping.
295
+ *
296
+ * "Moving you to another server" and "Connection lost, reconnecting" both say
297
+ * "wait, this is being handled". Said over a stack where nothing is coming
298
+ * back, they are the worst screen in the product: an ordinary deploy drains a
299
+ * node, everyone on it reads that nobody was dropped, and the only way back
300
+ * into a paid coaching hour is working out for yourself that you should
301
+ * reload the page. So when `recovering` is false, the words change and a
302
+ * control appears.
303
+ */
304
+ describe("it does not promise a recovery that is not happening", () => {
305
+ it("offers a way back when a drain has nowhere to move you to", () => {
306
+ const moving = callStatus({
307
+ connectionState: "reconnecting",
308
+ error: { type: "draining", reconnectAfterMs: 500 },
309
+ recovering: true,
310
+ });
311
+ const stranded = callStatus({
312
+ connectionState: "reconnecting",
313
+ error: { type: "draining", reconnectAfterMs: 500 },
314
+ });
315
+
316
+ expect(moving.tone).toBe("reconnecting");
317
+ expect(moving.canRetry).toBe(false);
318
+ // The one the review found: a spinner, no control, and no second socket
319
+ // ever opened.
320
+ expect(stranded.tone).toBe("failed");
321
+ expect(stranded.canRetry).toBe(true);
322
+ expect(stranded.headline).not.toBe(moving.headline);
323
+ });
324
+
325
+ it("stops saying `reconnecting` about a dropped socket nothing is retrying", () => {
326
+ const retrying = callStatus({
327
+ connectionState: "reconnecting",
328
+ error: { type: "socket_closed", code: 1006 },
329
+ recovering: true,
330
+ });
331
+ const givenUp = callStatus({ connectionState: "reconnecting", error: { type: "socket_closed", code: 1006 } });
332
+
333
+ expect(retrying.headline).toBe("Connection lost, reconnecting");
334
+ expect(givenUp.headline).toBe("Connection lost");
335
+ expect(givenUp.tone).toBe("failed");
336
+ expect(givenUp.canRetry).toBe(true);
337
+ });
338
+
339
+ it("reads an absent `recovering` as nothing coming, which is the safe way round", () => {
340
+ // A caller that has not been taught about it gets a control rather than a
341
+ // spinner. The wrong answer offers a button somebody did not need; the
342
+ // other wrong answer strands them under a promise.
343
+ expect(callStatus({ connectionState: "reconnecting", error: undefined }).canRetry).toBe(true);
344
+ expect(callStatus({ connectionState: "reconnecting", error: undefined }).tone).toBe("failed");
345
+ });
346
+ });
347
+
348
+ it("offers a retry when only a fresh ticket will do", () => {
349
+ // A join ticket expires in minutes, and an expired one comes back as
350
+ // `unauthorized`; a refusal is very often just that, and `retry()`
351
+ // re-fetches through `getCredentials`.
352
+ expect(
353
+ callStatus({ connectionState: "reconnecting", error: { type: "refused", code: "unauthorized" } }).canRetry,
354
+ ).toBe(true);
355
+ expect(callStatus({ connectionState: "reconnecting", error: undefined }).canRetry).toBe(true);
356
+ });
357
+
358
+ it("says the call ended when the room closed, on either connection state", () => {
359
+ for (const connectionState of ["closed", "reconnecting"] as const) {
360
+ const status = callStatus({ connectionState, error: { type: "room_closed", reason: "the host ended it" } });
361
+ expect(status.tone).toBe("ended");
362
+ expect(status.detail).toBe("the host ended it");
363
+ }
364
+ });
365
+
366
+ it("says the call ended the moment the ROOM says so, on a socket still open", () => {
367
+ // A `roomClosed` frame arrives on a live socket, so the connection is still
368
+ // "connected" until the node hangs up. Reading only the connection would
369
+ // show "Connected" over a call that has ended, and that is the one entry on
370
+ // this list somebody would act on wrongly.
371
+ const status = callStatus({
372
+ connectionState: "connected",
373
+ error: undefined,
374
+ phase: "closed",
375
+ closedReason: "the host ended it",
376
+ });
377
+
378
+ expect(status.tone).toBe("ended");
379
+ expect(status.detail).toBe("the host ended it");
380
+ });
381
+
382
+ it("is live only when the room says connected", () => {
383
+ expect(callStatus({ connectionState: "connected", error: undefined }).tone).toBe("live");
384
+ expect(callStatus({ connectionState: "connected", error: undefined, phase: "joined" }).tone).toBe("live");
385
+ expect(callStatus({ connectionState: "idle", error: undefined }).tone).toBe("connecting");
386
+ });
387
+ });
388
+
389
+ describe("canPublishSource", () => {
390
+ const sources = ["microphone", "camera", "screen"] as const;
391
+
392
+ it("offers nothing without canPublish", () => {
393
+ for (const source of sources) {
394
+ expect(canPublishSource({ canPublish: false, canSubscribe: true, canPublishData: false }, source)).toBe(false);
395
+ expect(canPublishSource(undefined, source)).toBe(false);
396
+ }
397
+ });
398
+
399
+ it("treats an absent publishKinds as every kind, which is what the node does", () => {
400
+ const grants = { canPublish: true, canSubscribe: true, canPublishData: false };
401
+ for (const source of sources) {
402
+ expect(canPublishSource(grants, source)).toBe(true);
403
+ }
404
+ });
405
+
406
+ /**
407
+ * The SOURCES a control publishes, against the KINDS a grant lists.
408
+ *
409
+ * These are two vocabularies and they agree on one word. Asking this function
410
+ * about "audio" - as the buttons used to - is asking about something no
411
+ * publish ever declares, so it answered a question the node was never going
412
+ * to be asked: a booking token minted `["audio", "video", "screen"]` drew a
413
+ * microphone button and a camera button, and the node refused both, because
414
+ * what reached it was "microphone" and "camera".
415
+ */
416
+ it("offers the microphone and camera a booking token was minted for", () => {
417
+ const booking = {
418
+ canPublish: true,
419
+ canSubscribe: true,
420
+ canPublishData: false,
421
+ publishKinds: ["audio", "video", "screen"] as const,
422
+ };
423
+
424
+ for (const source of sources) {
425
+ expect(canPublishSource({ ...booking, publishKinds: [...booking.publishKinds] }, source)).toBe(true);
426
+ }
427
+ });
428
+
429
+ it("honours a narrowed publishKinds", () => {
430
+ const listenAndTalk = {
431
+ canPublish: true,
432
+ canSubscribe: true,
433
+ canPublishData: false,
434
+ publishKinds: ["audio"] as const,
435
+ };
436
+
437
+ expect(canPublishSource({ ...listenAndTalk, publishKinds: ["audio"] }, "microphone")).toBe(true);
438
+ expect(canPublishSource({ ...listenAndTalk, publishKinds: ["audio"] }, "camera")).toBe(false);
439
+ expect(canPublishSource({ ...listenAndTalk, publishKinds: ["audio"] }, "screen")).toBe(false);
440
+ });
441
+
442
+ it("does not offer a screen share on a token granted audio and video", () => {
443
+ const noPresenting = {
444
+ canPublish: true,
445
+ canSubscribe: true,
446
+ canPublishData: false,
447
+ };
448
+
449
+ expect(canPublishSource({ ...noPresenting, publishKinds: ["audio", "video"] }, "camera")).toBe(true);
450
+ expect(canPublishSource({ ...noPresenting, publishKinds: ["audio", "video"] }, "screen")).toBe(false);
451
+ });
452
+ });