@tribe-nest/forge 3.29.0 → 3.34.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 (58) hide show
  1. package/package.json +11 -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/CartContext.tsx +17 -1
  6. package/src/contexts/PublicAuthContext.tsx +34 -5
  7. package/src/contexts/_tests/CartContext.spec.tsx +36 -0
  8. package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
  9. package/src/data/queries/useBroadcasts.ts +151 -0
  10. package/src/data/queries/useMyBookings.ts +18 -1
  11. package/src/i18n/_tests/translationKeys.spec.ts +15 -0
  12. package/src/i18n/de.json +97 -0
  13. package/src/i18n/en.json +97 -0
  14. package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
  15. package/src/ui/format/_tests/pwyw.spec.ts +65 -8
  16. package/src/ui/format/membershipPwyw.ts +164 -0
  17. package/src/ui/format/pwyw.ts +37 -0
  18. package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
  19. package/src/ui/headless/broadcast/broadcastState.ts +158 -0
  20. package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
  21. package/src/ui/headless/event/useEventCheckout.ts +8 -13
  22. package/src/ui/headless/index.ts +14 -0
  23. package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
  24. package/src/ui/index.ts +61 -0
  25. package/src/ui/media/BookingCallScreen.tsx +33 -0
  26. package/src/ui/media/CallHelpHint.tsx +87 -0
  27. package/src/ui/media/CallStage.tsx +633 -0
  28. package/src/ui/media/_tests/CallStage.spec.tsx +931 -0
  29. package/src/ui/media/_tests/bookingSession.spec.tsx +227 -0
  30. package/src/ui/media/_tests/callState.spec.ts +499 -0
  31. package/src/ui/media/_tests/fakeNode.ts +178 -0
  32. package/src/ui/media/bookingSession.tsx +182 -0
  33. package/src/ui/media/bookingWindow.ts +81 -0
  34. package/src/ui/media/callState.ts +360 -0
  35. package/src/ui/media/index.ts +135 -0
  36. package/src/ui/styled/AccountDashboard.tsx +193 -4
  37. package/src/ui/styled/BroadcastWatch.tsx +107 -0
  38. package/src/ui/styled/ForgotPasswordForm.tsx +5 -0
  39. package/src/ui/styled/LiveBroadcastList.tsx +171 -0
  40. package/src/ui/styled/LoginForm.tsx +10 -0
  41. package/src/ui/styled/MembershipCheckout.tsx +318 -45
  42. package/src/ui/styled/MembershipTiers.tsx +10 -3
  43. package/src/ui/styled/ResetPasswordForm.tsx +5 -0
  44. package/src/ui/styled/SignupForm.tsx +5 -0
  45. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +166 -0
  46. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
  47. package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
  48. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
  49. package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
  50. package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
  51. package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
  52. package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
  53. package/src/ui/styled/community/CommunityComposer.tsx +182 -3
  54. package/src/ui/styled/community/CommunityFeed.tsx +36 -51
  55. package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
  56. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
  57. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +175 -0
  58. package/src/ui/styled/forge-utilities.css +832 -0
@@ -0,0 +1,931 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
3
+ import { Profiler, type ReactNode } from "react";
4
+ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
5
+
6
+ import { MediaRoomProvider, type MediaRoomProviderProps } from "@tribe-nest/media-client/react";
7
+ import type { MediaDevice, MediaTransport } from "@tribe-nest/media-client";
8
+ import { MediaError } from "@tribe-nest/media-protocol";
9
+
10
+ import { CallStage } from "../CallStage";
11
+ import { ForgeI18nProvider } from "../../../i18n";
12
+ import { ForgeThemeProvider, type ForgeTheme } from "../../theme/ForgeThemeProvider";
13
+ import { FakeNode, settle } from "./fakeNode";
14
+
15
+ /**
16
+ * The call screen, rendered, against a real room.
17
+ *
18
+ * ## Why this counts renders
19
+ *
20
+ * `useSyncExternalStore` compares snapshots with `Object.is`, so a hook that
21
+ * builds a new value on every read reports a change on every check and React
22
+ * re-renders for ever. That bug has already shipped once in this repo (the
23
+ * room's `tracks` and `localPublications` getters spread a Map), and it is
24
+ * invisible to the type system, to a unit test and to a render assertion: a
25
+ * looping component renders exactly the right thing, several thousand times a
26
+ * second, until the tab heats up.
27
+ *
28
+ * A call screen is the worst place for it, because it reads FIVE of those hooks
29
+ * at once and derives arrays from all of them. So the first test here is not
30
+ * about what the screen shows; it is about the screen settling, and every other
31
+ * test in this file is worthless while that one is in doubt.
32
+ *
33
+ * ## Why it drives a real `<MediaRoomProvider>`
34
+ *
35
+ * Mocking the hooks would make the render-count test prove nothing at all: the
36
+ * loop lives in the join between the hooks and React, which a mock removes.
37
+ */
38
+
39
+ afterEach(() => {
40
+ cleanup();
41
+ produced.length = 0;
42
+ delete (navigator as { mediaDevices?: unknown }).mediaDevices;
43
+ });
44
+
45
+ beforeAll(() => {
46
+ // jsdom has no `MediaStream`, and the local preview builds one to hand to a
47
+ // `<video>`. A two-line stand-in is enough: nothing here asserts on it, the
48
+ // point is that the component may construct one without throwing.
49
+ if (!("MediaStream" in globalThis)) {
50
+ (globalThis as { MediaStream?: unknown }).MediaStream = class {
51
+ constructor(readonly tracks: unknown[] = []) {}
52
+ };
53
+ }
54
+ });
55
+
56
+ /** Every `produce` this device is asked for, by source. Reset per test. */
57
+ const produced: string[] = [];
58
+
59
+ const fakeDevice = (): MediaDevice => ({
60
+ async load() {},
61
+ loaded: true,
62
+ rtpCapabilities: { codecs: [] } as never,
63
+ canProduce: () => true,
64
+ createSendTransport: () => fakeTransport(),
65
+ createRecvTransport: () => fakeTransport(),
66
+ });
67
+
68
+ const fakeTransport = (): MediaTransport => ({
69
+ id: "t-1",
70
+ async produce(input) {
71
+ produced.push(String((input.appData as { source?: string } | undefined)?.source));
72
+ return {
73
+ id: `p-local-${produced.length}`,
74
+ kind: "audio",
75
+ closed: false,
76
+ pause: vi.fn(),
77
+ resume: vi.fn(),
78
+ close: vi.fn(),
79
+ };
80
+ },
81
+ async consume(input) {
82
+ return {
83
+ id: `c-${input.producerId}`,
84
+ producerId: input.producerId,
85
+ kind: input.kind,
86
+ track: { kind: input.kind, stop: vi.fn() } as unknown as MediaStreamTrack,
87
+ pause: vi.fn(),
88
+ resume: vi.fn(),
89
+ close: vi.fn(),
90
+ };
91
+ },
92
+ close: vi.fn(),
93
+ });
94
+
95
+ /** Every colour in the screen comes from here. Nothing is written in the component. */
96
+ const theme: ForgeTheme = {
97
+ colors: { text: "#101010", background: "#fafafa", primary: "#0b6", surface: "#eeeeee" },
98
+ cornerRadius: 10,
99
+ };
100
+
101
+ /**
102
+ * Reconnection off unless a test asks for it.
103
+ *
104
+ * The room now drives the reconnect ladder itself, so a spec that drops a
105
+ * socket and then asserts on the screen is racing a real recovery. `NO_RECOVERY`
106
+ * is the default here so that every test which is about something else stays
107
+ * deterministic, and the two tests that ARE about coming back turn it on and
108
+ * collapse the delays to something a real timer clears inside a test.
109
+ */
110
+ type Recovery = NonNullable<MediaRoomProviderProps["reconnect"]>;
111
+ const NO_RECOVERY: Recovery = { maxAttempts: 0 };
112
+ const FAST_RECOVERY: Recovery = { baseMs: 1, maxMs: 4, factor: 1, jitter: "none" };
113
+
114
+ const mount = (
115
+ node: FakeNode,
116
+ children: ReactNode,
117
+ options: { autoSubscribe?: boolean; reconnect?: Recovery; locale?: string } = {},
118
+ ) =>
119
+ render(
120
+ <ForgeI18nProvider locale={options.locale ?? "en"}>
121
+ <ForgeThemeProvider theme={theme}>
122
+ <MediaRoomProvider
123
+ getCredentials={() => ({ mediaUrl: "wss://media.example", token: "t" })}
124
+ device={fakeDevice}
125
+ webSocket={node.factory}
126
+ autoSubscribe={options.autoSubscribe ?? false}
127
+ reconnect={options.reconnect ?? NO_RECOVERY}
128
+ >
129
+ {children}
130
+ </MediaRoomProvider>
131
+ </ForgeThemeProvider>
132
+ </ForgeI18nProvider>,
133
+ );
134
+
135
+ /**
136
+ * A permission prompt and a capture, as the browser would give them.
137
+ *
138
+ * The track is an `EventTarget`, as a real `MediaStreamTrack` is, so a spec can
139
+ * end it the way the browser's own "Stop sharing" bar does.
140
+ */
141
+ const stubCapture = (kind: "audio" | "video" = "audio") => {
142
+ const target = new EventTarget() as EventTarget & { kind: string; stop: ReturnType<typeof vi.fn>; enabled: boolean };
143
+ target.kind = kind;
144
+ target.stop = vi.fn();
145
+ target.enabled = true;
146
+ const track = target as unknown as MediaStreamTrack & { stop: ReturnType<typeof vi.fn> };
147
+ const getUserMedia = vi.fn(async () => ({ getTracks: () => [track] }) as unknown as MediaStream);
148
+ Object.defineProperty(navigator, "mediaDevices", {
149
+ configurable: true,
150
+ value: { getUserMedia, getDisplayMedia: getUserMedia },
151
+ });
152
+ return { track, getUserMedia };
153
+ };
154
+
155
+ /**
156
+ * Commits of the WHOLE subtree, not renders of the top component.
157
+ *
158
+ * A counter in `CallStage` itself would have missed the bug this file is
159
+ * about. The loop lives wherever a hook is read, and the hooks are read in the
160
+ * tiles and the local preview as well: reintroducing the historical
161
+ * `[...room.localPublications]` selector spins `LocalPreview` for ever while
162
+ * `CallStage` renders exactly once and a per-component counter stays green.
163
+ * `<Profiler>` sees every commit underneath it, which is the property.
164
+ */
165
+ function CountedStage({ commits }: { commits: number[] }) {
166
+ return (
167
+ <Profiler id="call" onRender={() => commits.push(1)}>
168
+ <CallStage />
169
+ </Profiler>
170
+ );
171
+ }
172
+
173
+ describe("the call screen settles instead of looping", () => {
174
+ it("commits a bounded number of times and then stops", async () => {
175
+ const commits: number[] = [];
176
+ const node = new FakeNode({
177
+ peers: [
178
+ { identity: "anna", name: "Anna" },
179
+ { identity: "ben", name: "Ben" },
180
+ ],
181
+ producers: [
182
+ { producerId: "p-anna-video", identity: "anna", kind: "video" },
183
+ { producerId: "p-anna-audio", identity: "anna", kind: "audio" },
184
+ ],
185
+ });
186
+
187
+ mount(node, <CountedStage commits={commits} />);
188
+
189
+ await settle();
190
+ const settled = commits.length;
191
+ // A join is a handful of state transitions. Anything near this ceiling is
192
+ // already a bug; a loop reaches thousands.
193
+ expect(settled).toBeLessThan(20);
194
+
195
+ // And it STAYS settled with nothing happening. A hook whose snapshot is
196
+ // rebuilt per read keeps going after everything has arrived.
197
+ await settle();
198
+ expect(commits.length).toBe(settled);
199
+ });
200
+
201
+ /**
202
+ * ## Why this measures the join as well as the churn
203
+ *
204
+ * It used to measure only the DELTA across the churn, and that made it unable
205
+ * to fail. Reintroducing the exact historical bug (`room.tracks` spreading
206
+ * the Map on every read, `attach` memoised on `[producerId]`) turned the test
207
+ * above red at 55 commits and left this one green: React abandons a
208
+ * getSnapshot loop after about fifty forced re-renders, so by the time
209
+ * `afterJoin` was read the loop had already run and stopped, and the delta it
210
+ * then measured was the delta of a tree React had given up on. A budget taken
211
+ * from a baseline that is itself the bug measures nothing.
212
+ *
213
+ * So the baseline is asserted, the delta is asserted, and the churn is
214
+ * asserted to have actually LANDED - a tree React has stopped re-rendering is
215
+ * also a tree that commits nothing, and would otherwise pass this by being
216
+ * inert.
217
+ */
218
+ it("settles again after the room changes underneath it", async () => {
219
+ const commits: number[] = [];
220
+ const node = new FakeNode({ peers: [{ identity: "anna", name: "Anna" }] });
221
+
222
+ const { getByTestId } = mount(node, <CountedStage commits={commits} />);
223
+ await settle();
224
+ const afterJoin = commits.length;
225
+ // The baseline has to be a settled tree, or every number below is measured
226
+ // against a loop that has already burnt itself out.
227
+ expect(afterJoin).toBeLessThan(20);
228
+
229
+ node.event({ event: "peerJoined", peer: { identity: "ben", name: "Ben", kind: "human" } });
230
+ node.event({ event: "producerAppeared", producerId: "p-ben", identity: "ben", kind: "audio" });
231
+ node.event({ event: "activeSpeakers", producerIds: ["p-ben"] });
232
+
233
+ await settle();
234
+ const afterChurn = commits.length;
235
+ // The frames reached the screen. Without this, "few commits" is satisfied
236
+ // by a tree that is no longer rendering at all.
237
+ expect(getByTestId("tile-ben")).toBeTruthy();
238
+ // Three frames, a small number of commits. Not "a few more than before".
239
+ expect(afterChurn - afterJoin).toBeLessThan(12);
240
+
241
+ await settle();
242
+ expect(commits.length).toBe(afterChurn);
243
+ });
244
+ });
245
+
246
+ describe("the grid", () => {
247
+ it("keeps a tile for a participant with no video", async () => {
248
+ const node = new FakeNode({
249
+ peers: [
250
+ { identity: "anna", name: "Anna" },
251
+ { identity: "ben", name: "Ben" },
252
+ ],
253
+ producers: [{ producerId: "p-anna-video", identity: "anna", kind: "video" }],
254
+ });
255
+
256
+ const { getByTestId, getAllByText } = mount(node, <CallStage />);
257
+
258
+ await waitFor(() => expect(getByTestId("tile-ben")).toBeTruthy());
259
+ // Ben publishes nothing at all. He is still in the room, so he is still on
260
+ // screen, and his NAME is what the tile shows.
261
+ expect(getAllByText("Ben").length).toBeGreaterThan(0);
262
+ expect(getByTestId("tile-anna")).toBeTruthy();
263
+ expect(getByTestId("tile-local")).toBeTruthy();
264
+ });
265
+
266
+ it("does not collapse when somebody turns their camera off", async () => {
267
+ const node = new FakeNode({
268
+ peers: [
269
+ { identity: "anna", name: "Anna" },
270
+ { identity: "ben", name: "Ben" },
271
+ ],
272
+ producers: [
273
+ { producerId: "p-anna-video", identity: "anna", kind: "video" },
274
+ { producerId: "p-ben-video", identity: "ben", kind: "video" },
275
+ ],
276
+ });
277
+
278
+ const { container, getByTestId } = mount(node, <CallStage />);
279
+ await waitFor(() => expect(getByTestId("tile-ben")).toBeTruthy());
280
+ const before = container.querySelectorAll("[data-testid^='tile-']").length;
281
+
282
+ node.event({ event: "producerClosed", producerId: "p-ben-video" });
283
+
284
+ // Same tiles, one of them now showing a name instead of a picture. The bug
285
+ // this pins removes the tile entirely and re-flows the whole grid.
286
+ await settle();
287
+ expect(container.querySelectorAll("[data-testid^='tile-']").length).toBe(before);
288
+ expect(getByTestId("tile-ben")).toBeTruthy();
289
+ });
290
+
291
+ /**
292
+ * The head count is the one number a person acts on.
293
+ *
294
+ * "In this call: 2" is how a coach answers "has my client actually arrived",
295
+ * so it has to count PEOPLE. It was `tiles.length + 1`, and `callTiles` gives
296
+ * a screen sharer two tiles by design, so the moment the coach shared their
297
+ * screen the header announced a third party in a two-person room.
298
+ */
299
+ it("counts people, not video streams, while somebody shares a screen", async () => {
300
+ const node = new FakeNode({
301
+ peers: [{ identity: "anna", name: "Anna" }],
302
+ producers: [
303
+ { producerId: "p-anna-video", identity: "anna", kind: "video" },
304
+ { producerId: "p-anna-screen", identity: "anna", kind: "video" },
305
+ ],
306
+ });
307
+
308
+ const { container, getByText } = mount(node, <CallStage />);
309
+
310
+ await waitFor(() => expect(getByText(/In this call:/).textContent).toBe("In this call: 2"));
311
+ // Two tiles for her, and still two people. The count is derived from the
312
+ // grid, so this pins both halves at once.
313
+ expect(container.querySelectorAll("[data-testid='tile-anna']").length).toBe(2);
314
+ });
315
+
316
+ /**
317
+ * The recorder joins as a real peer.
318
+ *
319
+ * `session.ts` publishes `participantJoined` for the egress process and the
320
+ * join snapshot carries it, so a grid built from `peers` drew a third tile
321
+ * called `egress-9f2c` in a 1:1 session and the header read "In this call: 3"
322
+ * - at the one moment a coach is most likely to be reading that number, and
323
+ * it is the number they use to answer "has my client actually arrived".
324
+ */
325
+ it("does not draw the recording as a third person in the room", async () => {
326
+ const node = new FakeNode({
327
+ recording: true,
328
+ peers: [
329
+ { identity: "anna", name: "Anna" },
330
+ { identity: "egress-9f2c", kind: "egress" },
331
+ ],
332
+ });
333
+
334
+ const { getByTestId, queryByTestId, getByText } = mount(node, <CallStage />);
335
+
336
+ await waitFor(() => expect(getByTestId("tile-anna")).toBeTruthy());
337
+ expect(queryByTestId("tile-egress-9f2c")).toBeNull();
338
+ expect(getByText(/In this call:/).textContent).toBe("In this call: 2");
339
+ // The consent signal is still said, properly, in words.
340
+ expect(getByText("This call is being recorded")).toBeTruthy();
341
+ });
342
+
343
+ /**
344
+ * A barrier the node narrows mid-call has to reach the screen.
345
+ *
346
+ * `grantsChanged` pushes `subscribeRuleChanged`, and the reducer deliberately
347
+ * KEEPS the now-forbidden producers so that a rule which later widens is
348
+ * recoverable. Nothing cleans them up for us either: the node's
349
+ * `revokeNewlyForbidden` closes only producers this session had a live
350
+ * consumer for, so one that was announced and never consumed stays in the
351
+ * list for the rest of the call. A screen reading the raw list therefore
352
+ * keeps a tile carrying that person's identity and mounts an `<audio>` for
353
+ * them, and on a sealed consultation the other party's presence is most of
354
+ * what the barrier was hiding.
355
+ *
356
+ * The rule governs MEDIA, so this is about the media: the peer roster is the
357
+ * node's own account of who is in the room and is not filtered by a subscribe
358
+ * rule on either side.
359
+ */
360
+ it("stops rendering media the node has barred this seat from", async () => {
361
+ const node = new FakeNode({
362
+ peers: [
363
+ { identity: "anna", name: "Anna" },
364
+ { identity: "third-party", name: "Third Party" },
365
+ ],
366
+ producers: [
367
+ { producerId: "p-anna-audio", identity: "anna", kind: "audio" },
368
+ { producerId: "p-third-audio", identity: "third-party", kind: "audio" },
369
+ { producerId: "p-third-video", identity: "third-party", kind: "video" },
370
+ ],
371
+ });
372
+
373
+ const { container, getByTestId } = mount(node, <CallStage />, { autoSubscribe: true });
374
+ await waitFor(() => expect(getByTestId("tile-third-party")).toBeTruthy());
375
+ node.event({ event: "activeSpeakers", producerIds: ["p-anna-audio", "p-third-audio", "p-third-video"] });
376
+ await waitFor(() => expect(container.querySelectorAll("audio").length).toBe(2));
377
+ await waitFor(() => expect(container.querySelectorAll("video").length).toBe(1));
378
+
379
+ node.event({ event: "subscribeRuleChanged", subscribe: { mode: "allow", identities: ["anna"] } });
380
+
381
+ // An `<audio>` left mounted for a producer this seat may not receive is the
382
+ // barrier failing in the one place a person can observe it.
383
+ await waitFor(() => expect(container.querySelectorAll("audio").length).toBe(1));
384
+ expect(container.querySelectorAll("video").length).toBe(0);
385
+ expect(getByTestId("tile-anna")).toBeTruthy();
386
+ });
387
+
388
+ it("marks the active speaker without re-ordering the grid", async () => {
389
+ const node = new FakeNode({
390
+ peers: [
391
+ { identity: "anna", name: "Anna" },
392
+ { identity: "ben", name: "Ben" },
393
+ ],
394
+ producers: [
395
+ { producerId: "p-anna-audio", identity: "anna", kind: "audio" },
396
+ { producerId: "p-ben-audio", identity: "ben", kind: "audio" },
397
+ ],
398
+ });
399
+
400
+ const { container, getByTestId } = mount(node, <CallStage />);
401
+ await waitFor(() => expect(getByTestId("tile-ben")).toBeTruthy());
402
+ const order = () =>
403
+ [...container.querySelectorAll("[data-testid^='tile-']")].map((n) => n.getAttribute("data-testid"));
404
+ const before = order();
405
+
406
+ node.event({ event: "activeSpeakers", producerIds: ["p-ben-audio"] });
407
+
408
+ await waitFor(() => expect(getByTestId("tile-ben").getAttribute("data-speaking")).toBe("true"));
409
+ expect(getByTestId("tile-anna").getAttribute("data-speaking")).toBe("false");
410
+ // A grid that reshuffles on every "mm" moves the person you were watching
411
+ // out from under your eye.
412
+ expect(order()).toEqual(before);
413
+ });
414
+ });
415
+
416
+ /**
417
+ * The half of the screen that has no pixels.
418
+ *
419
+ * Nothing in this file used to look at an `<audio>` or a `<video>` element at
420
+ * all, and the whole media-attachment layer sat under that gap: remote audio
421
+ * elements were mounted the moment the node ANNOUNCED a producer, some
422
+ * milliseconds before the consume round trip produced a track, and their ref
423
+ * callback - memoised on values that never changed - was never invoked again.
424
+ * Every one of them kept `srcObject = null` for the whole call. Remote
425
+ * `<video>` carries `muted`, so there was no second audio path: the coach and
426
+ * the client saw each other and heard nothing, on a screen that passed every
427
+ * assertion here.
428
+ */
429
+ describe("the audio actually reaches an element", () => {
430
+ it("attaches a remote track to the element that was already on the page", async () => {
431
+ const node = new FakeNode({
432
+ peers: [{ identity: "anna", name: "Anna" }],
433
+ producers: [{ producerId: "p-anna-audio", identity: "anna", kind: "audio" }],
434
+ });
435
+
436
+ // autoSubscribe on: the room follows the node's active set, so the track
437
+ // arrives when the node says she is speaking and not before.
438
+ const { container } = mount(node, <CallStage />, { autoSubscribe: true });
439
+
440
+ await waitFor(() => expect(container.querySelector("audio")).toBeTruthy());
441
+ const element = container.querySelector("audio") as HTMLAudioElement;
442
+ expect(element.srcObject).toBeNull();
443
+
444
+ node.event({ event: "activeSpeakers", producerIds: ["p-anna-audio"] });
445
+
446
+ await waitFor(() => expect((container.querySelector("audio") as HTMLAudioElement).srcObject).toBeTruthy());
447
+ // The SAME element, never unmounted. That is what makes this a test of the
448
+ // ref being re-invoked rather than of a remount happening to fix it.
449
+ expect(container.querySelector("audio")).toBe(element);
450
+ });
451
+
452
+ it("attaches a remote camera to its tile", async () => {
453
+ const node = new FakeNode({
454
+ peers: [{ identity: "anna", name: "Anna" }],
455
+ producers: [{ producerId: "p-anna-video", identity: "anna", kind: "video" }],
456
+ });
457
+
458
+ const { container, getByTestId } = mount(node, <CallStage />, { autoSubscribe: true });
459
+ await waitFor(() => expect(getByTestId("tile-anna")).toBeTruthy());
460
+
461
+ node.event({ event: "activeSpeakers", producerIds: ["p-anna-video"] });
462
+
463
+ await waitFor(() => expect(container.querySelector("video")).toBeTruthy());
464
+ expect((container.querySelector("video") as HTMLVideoElement).srcObject).toBeTruthy();
465
+ });
466
+ });
467
+
468
+ describe("the connection state, said honestly", () => {
469
+ it("says it is connecting, then that it is connected", async () => {
470
+ const node = new FakeNode({});
471
+ const { getByRole } = mount(node, <CallStage />);
472
+
473
+ expect(getByRole("status").textContent).toContain("Connecting");
474
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Connected"));
475
+ });
476
+
477
+ it("names a refusal and offers a retry, rather than spinning for ever", async () => {
478
+ const node = new FakeNode({ refuseWith: "unauthorized" });
479
+ const { getByRole, getByText } = mount(node, <CallStage />);
480
+
481
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Could not join this call"));
482
+ // The server's own code, and a control. A spinner here is the failure: it
483
+ // says "wait" about something waiting will never fix.
484
+ expect(getByRole("status").textContent).toContain("unauthorized");
485
+ expect(getByText("Try again")).toBeTruthy();
486
+ });
487
+
488
+ /**
489
+ * The ticket endpoint said no BEFORE any socket existed. This used to wedge
490
+ * the room on "Connecting" for ever (the SDK never ran its close path for a
491
+ * failure with no socket), and the docblocks promising the server's refusal
492
+ * would be shown verbatim could not be kept. Now it is a refusal like any
493
+ * other: the sentence, and a control.
494
+ */
495
+ it("shows the credential endpoint's refusal verbatim, with a retry, rather than a spinner", async () => {
496
+ const node = new FakeNode({});
497
+ let attempts = 0;
498
+ const { getByRole, getByText } = render(
499
+ <ForgeThemeProvider theme={theme}>
500
+ <MediaRoomProvider
501
+ getCredentials={() => {
502
+ attempts += 1;
503
+ if (attempts === 1) {
504
+ throw new MediaError("bad_request", "The room opens 15 minutes before the session.");
505
+ }
506
+ return { mediaUrl: "wss://media.example", token: "t" };
507
+ }}
508
+ device={fakeDevice}
509
+ webSocket={node.factory}
510
+ autoSubscribe={false}
511
+ reconnect={NO_RECOVERY}
512
+ >
513
+ <CallStage />
514
+ </MediaRoomProvider>
515
+ </ForgeThemeProvider>,
516
+ );
517
+
518
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Could not join this call"));
519
+ expect(getByRole("status").textContent).toContain("The room opens 15 minutes before the session.");
520
+ expect(getByRole("status").textContent).not.toContain("Connection lost");
521
+
522
+ // And Try again is not decoration: the next attempt fetches a ticket again
523
+ // and gets in.
524
+ fireEvent.click(getByText("Try again"));
525
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Connected"));
526
+ expect(attempts).toBe(2);
527
+ });
528
+
529
+ it("says the call ended when the node closes the room", async () => {
530
+ const node = new FakeNode({});
531
+ const { getByRole } = mount(node, <CallStage />);
532
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Connected"));
533
+
534
+ node.event({ event: "roomClosed", reason: "the host ended it" });
535
+
536
+ await waitFor(() => expect(getByRole("status").textContent).toContain("The call has ended"));
537
+ expect(getByRole("status").textContent).toContain("the host ended it");
538
+ });
539
+
540
+ it("distinguishes a dropped socket from a refusal", async () => {
541
+ const node = new FakeNode({});
542
+ const { getByRole } = mount(node, <CallStage />);
543
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Connected"));
544
+
545
+ node.socket.drop();
546
+
547
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Connection lost"));
548
+ expect(getByRole("status").textContent).not.toContain("Could not join");
549
+ });
550
+ });
551
+
552
+ /**
553
+ * A spinner is a promise, and nothing here was keeping it.
554
+ *
555
+ * `superviseConnection` - the whole reconnect policy, built and tested - had no
556
+ * caller outside its own spec, so a drop set the connection to `reconnecting`
557
+ * and no second socket was ever opened. For a plain drop the screen at least
558
+ * offered "Try again"; for a DRAIN it offered nothing, because a drain is
559
+ * supposed to be handled for you. So an ordinary deploy or scale-down ended
560
+ * every call on that node under the words "Moving you to another server / This
561
+ * takes a few seconds and nobody else is dropped", with a spinner, no control,
562
+ * and no way back into a paid coaching hour short of guessing that a reload
563
+ * would work.
564
+ */
565
+ describe("the promise to come back is one somebody keeps", () => {
566
+ it("actually moves you when the node drains, rather than saying so", async () => {
567
+ const node = new FakeNode({});
568
+ const { getByRole } = mount(node, <CallStage />, { reconnect: FAST_RECOVERY });
569
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Connected"));
570
+
571
+ // What a deploy sends: the node asks to be left, then closes the socket.
572
+ node.event({ event: "draining", reconnectAfterMs: 1 });
573
+ await settle(20);
574
+ node.socket.drop();
575
+
576
+ // A SECOND socket, through `MEDIA_URL` with a fresh ticket, and back in the
577
+ // call. This is what the copy on screen has been claiming all along.
578
+ await waitFor(() => expect(node.sockets.length).toBe(2));
579
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Connected"));
580
+ });
581
+
582
+ it("offers a way back when a drain has nowhere to move you to", async () => {
583
+ const node = new FakeNode({});
584
+ const { getByRole, getByText } = mount(node, <CallStage />, { reconnect: NO_RECOVERY });
585
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Connected"));
586
+
587
+ node.event({ event: "draining", reconnectAfterMs: 1 });
588
+ await settle(20);
589
+ node.socket.drop();
590
+
591
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Could not move you"));
592
+ // The control the drained case never had. Spinner plus no button is the
593
+ // one combination that leaves somebody sitting there.
594
+ expect(getByText("Try again")).toBeTruthy();
595
+ });
596
+
597
+ it("comes back into the call when Try again is PRESSED, not merely drawn", async () => {
598
+ const node = new FakeNode({});
599
+ const { getByRole, getByText } = mount(node, <CallStage />, { reconnect: NO_RECOVERY });
600
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Connected"));
601
+
602
+ node.socket.drop();
603
+ await waitFor(() => expect(getByText("Try again")).toBeTruthy());
604
+
605
+ fireEvent.click(getByText("Try again"));
606
+
607
+ // Asserting the button exists proves nothing about whether pressing it
608
+ // does anything, and the retry path had no test that pressed it.
609
+ await waitFor(() => expect(node.sockets.length).toBe(2));
610
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Connected"));
611
+ });
612
+ });
613
+
614
+ describe("the controls", () => {
615
+ it("offers only what the token was minted for", async () => {
616
+ const node = new FakeNode({
617
+ grants: { canPublish: true, canSubscribe: true, canPublishData: false, publishKinds: ["audio"] },
618
+ });
619
+ const { getByText, queryByText } = mount(node, <CallStage />);
620
+
621
+ await waitFor(() => expect(getByText("Unmute")).toBeTruthy());
622
+ // A camera the node would refuse is not a button. The node refuses it
623
+ // anyway; this is about not offering it.
624
+ expect(queryByText("Start camera")).toBeNull();
625
+ expect(queryByText("Share screen")).toBeNull();
626
+ expect(getByText("Leave")).toBeTruthy();
627
+ });
628
+
629
+ it("offers the microphone and camera a booking token was minted for", async () => {
630
+ // The grants a coaching room actually mints. The UI asks about the SOURCE
631
+ // it is going to publish ("microphone", "camera") rather than about the
632
+ // grant's own word for it, because the source is what the node decides
633
+ // against - and this was the one call the UI and the node did not agree on.
634
+ const node = new FakeNode({
635
+ grants: {
636
+ canPublish: true,
637
+ canSubscribe: true,
638
+ canPublishData: false,
639
+ publishKinds: ["audio", "video", "screen"],
640
+ },
641
+ });
642
+ const { getByText } = mount(node, <CallStage />);
643
+
644
+ await waitFor(() => expect(getByText("Unmute")).toBeTruthy());
645
+ expect(getByText("Start camera")).toBeTruthy();
646
+ expect(getByText("Share screen")).toBeTruthy();
647
+ });
648
+
649
+ it("offers every control when the grants are open", async () => {
650
+ const node = new FakeNode({});
651
+ const { getByText } = mount(node, <CallStage />);
652
+
653
+ await waitFor(() => expect(getByText("Unmute")).toBeTruthy());
654
+ expect(getByText("Start camera")).toBeTruthy();
655
+ expect(getByText("Share screen")).toBeTruthy();
656
+ });
657
+
658
+ it("offers nothing to publish on a listen-only seat, and still lets them leave", async () => {
659
+ const node = new FakeNode({ grants: { canPublish: false, canSubscribe: true, canPublishData: false } });
660
+ const { getByText, queryByText } = mount(node, <CallStage />);
661
+
662
+ await waitFor(() => expect(getByText("Leave")).toBeTruthy());
663
+ expect(queryByText("Unmute")).toBeNull();
664
+ expect(queryByText("Start camera")).toBeNull();
665
+ });
666
+ });
667
+
668
+ /**
669
+ * Every control test above asks only which buttons are DRAWN.
670
+ *
671
+ * Not one of them pressed anything, so no test exercised a publish through the
672
+ * UI at all, and the two defects that live in pressing were both invisible: a
673
+ * second press while the permission prompt is open, and a capture that outlives
674
+ * the call it was consented to.
675
+ */
676
+ describe("the controls, pressed", () => {
677
+ it("publishes ONCE when Unmute is pressed twice in a row", async () => {
678
+ const { getUserMedia } = stubCapture();
679
+ const node = new FakeNode({});
680
+ const { getByText } = mount(node, <CallStage />);
681
+
682
+ await waitFor(() => expect(getByText("Unmute")).toBeTruthy());
683
+ const button = getByText("Unmute");
684
+ // Which is exactly what a person does when the first press produces no
685
+ // visible change - and it produces none, because `getUserMedia` is showing
686
+ // a permission prompt and the flag the handler reads comes from the
687
+ // publication that only exists once the publish has resolved.
688
+ fireEvent.click(button);
689
+ fireEvent.click(button);
690
+
691
+ await waitFor(() => expect(getByText("Mute")).toBeTruthy());
692
+ await settle(30);
693
+ // Two producers under one identity means everybody else mounts two
694
+ // `<audio>` elements and hears this person doubled.
695
+ expect(getUserMedia).toHaveBeenCalledTimes(1);
696
+ expect(produced).toEqual(["microphone"]);
697
+ });
698
+
699
+ it("releases the microphone when the call ends underneath the person", async () => {
700
+ const { track } = stubCapture();
701
+ const node = new FakeNode({});
702
+ const { getByText, getByRole } = mount(node, <CallStage />);
703
+
704
+ await waitFor(() => expect(getByText("Unmute")).toBeTruthy());
705
+ fireEvent.click(getByText("Unmute"));
706
+ await waitFor(() => expect(getByText("Mute")).toBeTruthy());
707
+
708
+ // A coach cancelling or refunding at minute ten: the room is ended on the
709
+ // plane and the node evicts. Nobody pressed Leave, which is the only path
710
+ // that used to reach `room.close()`.
711
+ node.event({ event: "roomClosed", reason: "the host ended it" });
712
+ await settle(20);
713
+ node.socket.drop();
714
+
715
+ await waitFor(() => expect(getByRole("status").textContent).toContain("The call has ended"));
716
+ // The OS recording indicator goes out. It stayed lit until the person
717
+ // happened to close the tab, with the button still reading "Mute" about a
718
+ // microphone publishing into a room that no longer existed.
719
+ await waitFor(() => expect(track.stop).toHaveBeenCalledTimes(1));
720
+ expect(getByText("Unmute")).toBeTruthy();
721
+ });
722
+ });
723
+
724
+ describe("the things that must stay visible", () => {
725
+ it("shows the recording indicator as content, not behind a hint", async () => {
726
+ const node = new FakeNode({ recording: true });
727
+ const { getByText } = mount(node, <CallStage />);
728
+
729
+ // Consent. It is never something a person has to click to find out.
730
+ await waitFor(() => expect(getByText("This call is being recorded")).toBeTruthy());
731
+ });
732
+
733
+ it("keeps explanations behind a `?` with a name that says what it explains", async () => {
734
+ const node = new FakeNode({});
735
+ const { getByLabelText, queryByText } = mount(node, <CallStage />);
736
+
737
+ await waitFor(() => expect(getByLabelText("Why can I hear someone but not see them?")).toBeTruthy());
738
+ // Not on screen until asked for. A permanent paragraph on a call screen is
739
+ // a paragraph in the way of the call.
740
+ expect(queryByText(/means that person has their camera off/)).toBeNull();
741
+
742
+ fireEvent.click(getByLabelText("Why can I hear someone but not see them?"));
743
+ await waitFor(() => expect(queryByText(/means that person has their camera off/)).toBeTruthy());
744
+ });
745
+ });
746
+
747
+ /**
748
+ * The screen speaks the site's language.
749
+ *
750
+ * Every string on the call screen was English, written into the component,
751
+ * against Forge's own convention: a German site rendered a German account page
752
+ * with an English call in the middle of it, and `callStatus` returned sentences
753
+ * a translator could not reach. This pins that the SAME component, under the
754
+ * SAME provider every Forge component reads, is German when the site is.
755
+ */
756
+ describe("the screen is translated", () => {
757
+ it("renders the status and the controls in the site's language", async () => {
758
+ const node = new FakeNode({});
759
+ const { getByRole, getByText, queryByText } = mount(node, <CallStage />, { locale: "de" });
760
+
761
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Verbunden"));
762
+ expect(getByText("Stummschaltung aufheben")).toBeTruthy();
763
+ expect(getByText("Kamera starten")).toBeTruthy();
764
+ expect(getByText("Verlassen")).toBeTruthy();
765
+ expect(getByText(/In diesem Anruf:/).textContent).toBe("In diesem Anruf: 1");
766
+ expect(queryByText("Unmute")).toBeNull();
767
+ });
768
+
769
+ it("keeps the server's own sentence verbatim inside a translated status", async () => {
770
+ const node = new FakeNode({});
771
+ const { getByRole } = mount(node, <CallStage />, { locale: "de" });
772
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Verbunden"));
773
+
774
+ node.event({ event: "roomClosed", reason: "the host ended it" });
775
+
776
+ // The headline is ours and is German; the reason is the server's and is not
777
+ // put through a translator, because a friendlier version of a refusal is a
778
+ // lie about why.
779
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Der Anruf ist beendet"));
780
+ expect(getByRole("status").textContent).toContain("the host ended it");
781
+ });
782
+
783
+ it("falls back to English outside any i18n provider, so a site without one still gets a call", async () => {
784
+ const node = new FakeNode({});
785
+ const { getByRole } = render(
786
+ <ForgeThemeProvider theme={theme}>
787
+ <MediaRoomProvider
788
+ getCredentials={() => ({ mediaUrl: "wss://media.example", token: "t" })}
789
+ device={fakeDevice}
790
+ webSocket={node.factory}
791
+ autoSubscribe={false}
792
+ reconnect={NO_RECOVERY}
793
+ >
794
+ <CallStage />
795
+ </MediaRoomProvider>
796
+ </ForgeThemeProvider>,
797
+ );
798
+
799
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Connected"));
800
+ });
801
+ });
802
+
803
+ /**
804
+ * Mute is a pause, not a second permission prompt.
805
+ *
806
+ * The toggles were unpublish-then-publish, and every publish starts with
807
+ * `getUserMedia`. Safari asks permission on EVERY call to it, so a person on an
808
+ * iPad was asked whether the site may use the microphone each time they
809
+ * unmuted. Now the first press captures and every press after that pauses or
810
+ * resumes the publication the room already holds.
811
+ */
812
+ describe("mute and camera-off keep the capture", () => {
813
+ it("mutes and unmutes with ONE capture, and tells the node each time", async () => {
814
+ const { getUserMedia } = stubCapture();
815
+ const node = new FakeNode({});
816
+ const { getByText } = mount(node, <CallStage />);
817
+
818
+ await waitFor(() => expect(getByText("Unmute")).toBeTruthy());
819
+ fireEvent.click(getByText("Unmute"));
820
+ await waitFor(() => expect(getByText("Mute")).toBeTruthy());
821
+
822
+ fireEvent.click(getByText("Mute"));
823
+ await waitFor(() => expect(getByText("Unmute")).toBeTruthy());
824
+ expect(node.received.filter((f) => f.method === "pauseProducer")).toHaveLength(1);
825
+ // Paused, not closed: the microphone is still published, just silent.
826
+ expect(node.received.filter((f) => f.method === "closeProducer")).toHaveLength(0);
827
+
828
+ fireEvent.click(getByText("Unmute"));
829
+ await waitFor(() => expect(getByText("Mute")).toBeTruthy());
830
+ expect(node.received.filter((f) => f.method === "resumeProducer")).toHaveLength(1);
831
+ // The whole point: one prompt for the whole call.
832
+ expect(getUserMedia).toHaveBeenCalledTimes(1);
833
+ expect(produced).toEqual(["microphone"]);
834
+ });
835
+
836
+ it("shows the name in the local preview while the camera is paused, and explains the light behind a ?", async () => {
837
+ stubCapture("video");
838
+ const node = new FakeNode({});
839
+ const { getByText, getByTestId, getByLabelText, queryByText } = mount(node, <CallStage />);
840
+
841
+ await waitFor(() => expect(getByText("Start camera")).toBeTruthy());
842
+ fireEvent.click(getByText("Start camera"));
843
+ await waitFor(() => expect(getByText("Stop camera")).toBeTruthy());
844
+ await waitFor(() => expect(getByTestId("tile-local").querySelector("video")).toBeTruthy());
845
+
846
+ fireEvent.click(getByText("Stop camera"));
847
+
848
+ // What the others see is the name, so that is what the preview shows.
849
+ await waitFor(() => expect(getByText("Start camera")).toBeTruthy());
850
+ await waitFor(() => expect(getByTestId("tile-local").querySelector("video")).toBeNull());
851
+ expect(node.received.filter((f) => f.method === "pauseProducer")).toHaveLength(1);
852
+ // The camera stays open (the light can stay on), and that is an explanation,
853
+ // so it lives behind a ? with a name that says what it explains.
854
+ expect(queryByText(/keeps the camera open/)).toBeNull();
855
+ fireEvent.click(getByLabelText("What does Stop camera do?"));
856
+ await waitFor(() => expect(queryByText(/keeps the camera open/)).toBeTruthy());
857
+ });
858
+ });
859
+
860
+ /**
861
+ * The browser can end a share without asking us.
862
+ *
863
+ * Chrome and Firefox draw their own "Stop sharing" bar over a screen share.
864
+ * Nothing about it went near `unpublish`, so the node kept a producer nobody
865
+ * was feeding, everybody else kept a frozen tile, and this button read "Stop
866
+ * sharing" about a share that had ended.
867
+ */
868
+ describe("a screen share stopped from the browser's own bar", () => {
869
+ it("is unpublished, and the control follows", async () => {
870
+ const { track } = stubCapture("video");
871
+ const node = new FakeNode({});
872
+ const { getByText } = mount(node, <CallStage />);
873
+
874
+ await waitFor(() => expect(getByText("Share screen")).toBeTruthy());
875
+ fireEvent.click(getByText("Share screen"));
876
+ await waitFor(() => expect(getByText("Stop sharing")).toBeTruthy());
877
+
878
+ track.dispatchEvent(new Event("ended"));
879
+
880
+ await waitFor(() => expect(getByText("Share screen")).toBeTruthy());
881
+ expect(node.received.filter((f) => f.method === "closeProducer")).toHaveLength(1);
882
+ });
883
+ });
884
+
885
+ /**
886
+ * A recovered connection is not a recovered microphone, and the screen says so.
887
+ *
888
+ * The drop stops every local capture (right: a light left on over a dead
889
+ * session is retained capture) and the SDK does not turn it back on by itself
890
+ * (also right: capture is a permission, and it is the person's press). What
891
+ * was wrong is that nothing then told them. The room came back, the status
892
+ * read "Connected", and a coach who had been talking for ten minutes was
893
+ * talking to nobody until the client asked whether they were still there.
894
+ */
895
+ describe("what a drop took away is said, once the room is back", () => {
896
+ it("names the microphone, and stops once it is back on", async () => {
897
+ stubCapture();
898
+ const node = new FakeNode({});
899
+ const { getByText, getByRole, queryByText } = mount(node, <CallStage />, { reconnect: FAST_RECOVERY });
900
+
901
+ await waitFor(() => expect(getByText("Unmute")).toBeTruthy());
902
+ fireEvent.click(getByText("Unmute"));
903
+ await waitFor(() => expect(getByText("Mute")).toBeTruthy());
904
+
905
+ node.socket.drop();
906
+
907
+ await waitFor(() => expect(node.sockets.length).toBe(2));
908
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Connected"));
909
+ // Content, not a hint: this is the thing they have to act on.
910
+ await waitFor(() => expect(queryByText(/Your microphone was turned off/)).toBeTruthy());
911
+ expect(getByText("Unmute")).toBeTruthy();
912
+
913
+ fireEvent.click(getByText("Unmute"));
914
+ await waitFor(() => expect(getByText("Mute")).toBeTruthy());
915
+ await waitFor(() => expect(queryByText(/Your microphone was turned off/)).toBeNull());
916
+ });
917
+
918
+ it("says nothing after a deliberate leave", async () => {
919
+ stubCapture();
920
+ const node = new FakeNode({});
921
+ const { getByText, queryByText } = mount(node, <CallStage />, { reconnect: FAST_RECOVERY });
922
+ await waitFor(() => expect(getByText("Unmute")).toBeTruthy());
923
+ fireEvent.click(getByText("Unmute"));
924
+ await waitFor(() => expect(getByText("Mute")).toBeTruthy());
925
+
926
+ fireEvent.click(getByText("Leave"));
927
+
928
+ await settle(30);
929
+ expect(queryByText(/Your microphone was turned off/)).toBeNull();
930
+ });
931
+ });