@tribe-nest/forge 3.26.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 (129) 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/client/_tests/localeHeader.spec.tsx +98 -0
  6. package/src/client/activeLocale.ts +34 -0
  7. package/src/client/createForgeClient.ts +13 -0
  8. package/src/contexts/CartContext.tsx +76 -0
  9. package/src/contexts/PublicAuthContext.tsx +34 -5
  10. package/src/contexts/_tests/CartContext.spec.tsx +210 -0
  11. package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
  12. package/src/data/queries/useBroadcasts.ts +151 -0
  13. package/src/data/queries/useCheckouts.ts +83 -0
  14. package/src/data/queries/useMyBookings.ts +9 -1
  15. package/src/data/queries/useWebsite.ts +7 -0
  16. package/src/i18n/CONVENTION.md +184 -0
  17. package/src/i18n/_tests/translationKeys.spec.ts +153 -0
  18. package/src/i18n/de.json +1019 -0
  19. package/src/i18n/en.json +1019 -0
  20. package/src/i18n/index.ts +125 -0
  21. package/src/index.ts +21 -0
  22. package/src/provider/ForgeAppProvider.tsx +2 -1
  23. package/src/provider/SiteConfigProvider.tsx +11 -0
  24. package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
  25. package/src/ui/format/_tests/pwyw.spec.ts +65 -8
  26. package/src/ui/format/membershipPwyw.ts +164 -0
  27. package/src/ui/format/pwyw.ts +37 -0
  28. package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
  29. package/src/ui/headless/broadcast/broadcastState.ts +158 -0
  30. package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
  31. package/src/ui/headless/checkout/_tests/cartResume.spec.ts +291 -0
  32. package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +294 -0
  33. package/src/ui/headless/checkout/resumedCoupon.ts +61 -0
  34. package/src/ui/headless/checkout/useCartResume.ts +340 -0
  35. package/src/ui/headless/checkout/useCheckout.ts +9 -2
  36. package/src/ui/headless/event/useEventCheckout.ts +8 -13
  37. package/src/ui/headless/index.ts +32 -0
  38. package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
  39. package/src/ui/index.ts +38 -0
  40. package/src/ui/media/CallHelpHint.tsx +87 -0
  41. package/src/ui/media/CallStage.tsx +542 -0
  42. package/src/ui/media/_tests/CallStage.spec.tsx +685 -0
  43. package/src/ui/media/_tests/bookingSession.spec.tsx +179 -0
  44. package/src/ui/media/_tests/callState.spec.ts +452 -0
  45. package/src/ui/media/_tests/fakeNode.ts +178 -0
  46. package/src/ui/media/bookingSession.tsx +194 -0
  47. package/src/ui/media/callState.ts +341 -0
  48. package/src/ui/media/index.ts +135 -0
  49. package/src/ui/shell/TribeNestApp.tsx +36 -27
  50. package/src/ui/styled/AccountDashboard.tsx +289 -96
  51. package/src/ui/styled/AiAgentWidget.tsx +10 -6
  52. package/src/ui/styled/AudioPlayer.tsx +14 -6
  53. package/src/ui/styled/BlogCategory.tsx +4 -2
  54. package/src/ui/styled/BlogComments.tsx +27 -20
  55. package/src/ui/styled/BlogList.tsx +5 -3
  56. package/src/ui/styled/BlogPost.tsx +14 -11
  57. package/src/ui/styled/BroadcastWatch.tsx +107 -0
  58. package/src/ui/styled/BundleConfirmation.tsx +32 -23
  59. package/src/ui/styled/CancellationTerms.tsx +3 -1
  60. package/src/ui/styled/Cart.tsx +32 -14
  61. package/src/ui/styled/ChatRoom.tsx +65 -20
  62. package/src/ui/styled/Checkout.tsx +151 -89
  63. package/src/ui/styled/CheckoutConfirmation.tsx +66 -35
  64. package/src/ui/styled/CoachingBooking.tsx +71 -44
  65. package/src/ui/styled/CoachingConfirmation.tsx +56 -19
  66. package/src/ui/styled/CoachingDetail.tsx +15 -11
  67. package/src/ui/styled/CohortPage.tsx +10 -8
  68. package/src/ui/styled/ConfirmSubscription.tsx +14 -8
  69. package/src/ui/styled/ContactForm.tsx +31 -12
  70. package/src/ui/styled/CookieConsent.tsx +19 -15
  71. package/src/ui/styled/CourseAccess.tsx +54 -47
  72. package/src/ui/styled/CourseCheckout.tsx +56 -38
  73. package/src/ui/styled/CourseConfirmation.tsx +39 -17
  74. package/src/ui/styled/CourseDetail.tsx +21 -15
  75. package/src/ui/styled/CoursesGrid.tsx +4 -2
  76. package/src/ui/styled/CurrencySwitcher.tsx +4 -1
  77. package/src/ui/styled/DiscountCode.tsx +22 -18
  78. package/src/ui/styled/DonationButton.tsx +52 -17
  79. package/src/ui/styled/DonationPage.tsx +4 -4
  80. package/src/ui/styled/EmailListForm.tsx +22 -9
  81. package/src/ui/styled/EventConfirmation.tsx +37 -14
  82. package/src/ui/styled/EventCountdown.tsx +10 -6
  83. package/src/ui/styled/EventDetail.tsx +8 -6
  84. package/src/ui/styled/EventSeriesDetail.tsx +18 -13
  85. package/src/ui/styled/EventTickets.tsx +232 -108
  86. package/src/ui/styled/EventWaitlist.tsx +42 -33
  87. package/src/ui/styled/EventsList.tsx +15 -13
  88. package/src/ui/styled/ForgotPasswordForm.tsx +17 -12
  89. package/src/ui/styled/FormRenderer.tsx +12 -7
  90. package/src/ui/styled/InstallBanner.tsx +21 -18
  91. package/src/ui/styled/InvoiceConfirmation.tsx +13 -5
  92. package/src/ui/styled/InvoicePayment.tsx +37 -16
  93. package/src/ui/styled/LeadMagnet.tsx +9 -7
  94. package/src/ui/styled/LiveBroadcastList.tsx +171 -0
  95. package/src/ui/styled/Loading.tsx +7 -1
  96. package/src/ui/styled/LoginForm.tsx +32 -14
  97. package/src/ui/styled/MembershipCheckout.tsx +341 -49
  98. package/src/ui/styled/MembershipTiers.tsx +34 -18
  99. package/src/ui/styled/OfferButton.tsx +33 -13
  100. package/src/ui/styled/PaymentLinkConfirmation.tsx +11 -5
  101. package/src/ui/styled/PaymentLinkPayment.tsx +30 -10
  102. package/src/ui/styled/PodcastEpisode.tsx +20 -9
  103. package/src/ui/styled/PodcastList.tsx +9 -3
  104. package/src/ui/styled/PodcastShow.tsx +29 -7
  105. package/src/ui/styled/PostsFeed.tsx +10 -8
  106. package/src/ui/styled/PresaleCode.tsx +8 -11
  107. package/src/ui/styled/ProductBrowseNav.tsx +21 -11
  108. package/src/ui/styled/ProductDetail.tsx +58 -28
  109. package/src/ui/styled/ReactionBar.tsx +4 -2
  110. package/src/ui/styled/ReplayList.tsx +24 -13
  111. package/src/ui/styled/ResetPasswordForm.tsx +32 -15
  112. package/src/ui/styled/ResumeCart.tsx +289 -0
  113. package/src/ui/styled/ReviewForm.tsx +52 -58
  114. package/src/ui/styled/SignupForm.tsx +34 -16
  115. package/src/ui/styled/TicketTransfer.tsx +55 -54
  116. package/src/ui/styled/UserMenu.tsx +16 -14
  117. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
  118. package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
  119. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
  120. package/src/ui/styled/_tests/ResumeCart.spec.tsx +183 -0
  121. package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
  122. package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
  123. package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
  124. package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
  125. package/src/ui/styled/community/CommunityComposer.tsx +182 -3
  126. package/src/ui/styled/community/CommunityFeed.tsx +36 -51
  127. package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
  128. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
  129. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +175 -0
@@ -0,0 +1,685 @@
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
+
9
+ import { CallStage } from "../CallStage";
10
+ import { ForgeThemeProvider, type ForgeTheme } from "../../theme/ForgeThemeProvider";
11
+ import { FakeNode, settle } from "./fakeNode";
12
+
13
+ /**
14
+ * The call screen, rendered, against a real room.
15
+ *
16
+ * ## Why this counts renders
17
+ *
18
+ * `useSyncExternalStore` compares snapshots with `Object.is`, so a hook that
19
+ * builds a new value on every read reports a change on every check and React
20
+ * re-renders for ever. That bug has already shipped once in this repo (the
21
+ * room's `tracks` and `localPublications` getters spread a Map), and it is
22
+ * invisible to the type system, to a unit test and to a render assertion: a
23
+ * looping component renders exactly the right thing, several thousand times a
24
+ * second, until the tab heats up.
25
+ *
26
+ * A call screen is the worst place for it, because it reads FIVE of those hooks
27
+ * at once and derives arrays from all of them. So the first test here is not
28
+ * about what the screen shows; it is about the screen settling, and every other
29
+ * test in this file is worthless while that one is in doubt.
30
+ *
31
+ * ## Why it drives a real `<MediaRoomProvider>`
32
+ *
33
+ * Mocking the hooks would make the render-count test prove nothing at all: the
34
+ * loop lives in the join between the hooks and React, which a mock removes.
35
+ */
36
+
37
+ afterEach(() => {
38
+ cleanup();
39
+ produced.length = 0;
40
+ delete (navigator as { mediaDevices?: unknown }).mediaDevices;
41
+ });
42
+
43
+ beforeAll(() => {
44
+ // jsdom has no `MediaStream`, and the local preview builds one to hand to a
45
+ // `<video>`. A two-line stand-in is enough: nothing here asserts on it, the
46
+ // point is that the component may construct one without throwing.
47
+ if (!("MediaStream" in globalThis)) {
48
+ (globalThis as { MediaStream?: unknown }).MediaStream = class {
49
+ constructor(readonly tracks: unknown[] = []) {}
50
+ };
51
+ }
52
+ });
53
+
54
+ /** Every `produce` this device is asked for, by source. Reset per test. */
55
+ const produced: string[] = [];
56
+
57
+ const fakeDevice = (): MediaDevice => ({
58
+ async load() {},
59
+ loaded: true,
60
+ rtpCapabilities: { codecs: [] } as never,
61
+ canProduce: () => true,
62
+ createSendTransport: () => fakeTransport(),
63
+ createRecvTransport: () => fakeTransport(),
64
+ });
65
+
66
+ const fakeTransport = (): MediaTransport => ({
67
+ id: "t-1",
68
+ async produce(input) {
69
+ produced.push(String((input.appData as { source?: string } | undefined)?.source));
70
+ return { id: `p-local-${produced.length}`, kind: "audio", closed: false, pause: vi.fn(), resume: vi.fn(), close: vi.fn() };
71
+ },
72
+ async consume(input) {
73
+ return {
74
+ id: `c-${input.producerId}`,
75
+ producerId: input.producerId,
76
+ kind: input.kind,
77
+ track: { kind: input.kind, stop: vi.fn() } as unknown as MediaStreamTrack,
78
+ pause: vi.fn(),
79
+ resume: vi.fn(),
80
+ close: vi.fn(),
81
+ };
82
+ },
83
+ close: vi.fn(),
84
+ });
85
+
86
+ /** Every colour in the screen comes from here. Nothing is written in the component. */
87
+ const theme: ForgeTheme = {
88
+ colors: { text: "#101010", background: "#fafafa", primary: "#0b6", surface: "#eeeeee" },
89
+ cornerRadius: 10,
90
+ };
91
+
92
+ /**
93
+ * Reconnection off unless a test asks for it.
94
+ *
95
+ * The room now drives the reconnect ladder itself, so a spec that drops a
96
+ * socket and then asserts on the screen is racing a real recovery. `NO_RECOVERY`
97
+ * is the default here so that every test which is about something else stays
98
+ * deterministic, and the two tests that ARE about coming back turn it on and
99
+ * collapse the delays to something a real timer clears inside a test.
100
+ */
101
+ type Recovery = NonNullable<MediaRoomProviderProps["reconnect"]>;
102
+ const NO_RECOVERY: Recovery = { maxAttempts: 0 };
103
+ const FAST_RECOVERY: Recovery = { baseMs: 1, maxMs: 4, factor: 1, jitter: "none" };
104
+
105
+ const mount = (
106
+ node: FakeNode,
107
+ children: ReactNode,
108
+ options: { autoSubscribe?: boolean; reconnect?: Recovery } = {},
109
+ ) =>
110
+ render(
111
+ <ForgeThemeProvider theme={theme}>
112
+ <MediaRoomProvider
113
+ getCredentials={() => ({ mediaUrl: "wss://media.example", token: "t" })}
114
+ device={fakeDevice}
115
+ webSocket={node.factory}
116
+ autoSubscribe={options.autoSubscribe ?? false}
117
+ reconnect={options.reconnect ?? NO_RECOVERY}
118
+ >
119
+ {children}
120
+ </MediaRoomProvider>
121
+ </ForgeThemeProvider>,
122
+ );
123
+
124
+ /** A permission prompt and a capture, as the browser would give them. */
125
+ const stubCapture = () => {
126
+ const track = { kind: "audio", stop: vi.fn(), enabled: true } as unknown as MediaStreamTrack & {
127
+ stop: ReturnType<typeof vi.fn>;
128
+ };
129
+ const getUserMedia = vi.fn(async () => ({ getTracks: () => [track] }) as unknown as MediaStream);
130
+ Object.defineProperty(navigator, "mediaDevices", {
131
+ configurable: true,
132
+ value: { getUserMedia, getDisplayMedia: getUserMedia },
133
+ });
134
+ return { track, getUserMedia };
135
+ };
136
+
137
+ /**
138
+ * Commits of the WHOLE subtree, not renders of the top component.
139
+ *
140
+ * A counter in `CallStage` itself would have missed the bug this file is
141
+ * about. The loop lives wherever a hook is read, and the hooks are read in the
142
+ * tiles and the local preview as well: reintroducing the historical
143
+ * `[...room.localPublications]` selector spins `LocalPreview` for ever while
144
+ * `CallStage` renders exactly once and a per-component counter stays green.
145
+ * `<Profiler>` sees every commit underneath it, which is the property.
146
+ */
147
+ function CountedStage({ commits }: { commits: number[] }) {
148
+ return (
149
+ <Profiler id="call" onRender={() => commits.push(1)}>
150
+ <CallStage />
151
+ </Profiler>
152
+ );
153
+ }
154
+
155
+ describe("the call screen settles instead of looping", () => {
156
+ it("commits a bounded number of times and then stops", async () => {
157
+ const commits: number[] = [];
158
+ const node = new FakeNode({
159
+ peers: [
160
+ { identity: "anna", name: "Anna" },
161
+ { identity: "ben", name: "Ben" },
162
+ ],
163
+ producers: [
164
+ { producerId: "p-anna-video", identity: "anna", kind: "video" },
165
+ { producerId: "p-anna-audio", identity: "anna", kind: "audio" },
166
+ ],
167
+ });
168
+
169
+ mount(node, <CountedStage commits={commits} />);
170
+
171
+ await settle();
172
+ const settled = commits.length;
173
+ // A join is a handful of state transitions. Anything near this ceiling is
174
+ // already a bug; a loop reaches thousands.
175
+ expect(settled).toBeLessThan(20);
176
+
177
+ // And it STAYS settled with nothing happening. A hook whose snapshot is
178
+ // rebuilt per read keeps going after everything has arrived.
179
+ await settle();
180
+ expect(commits.length).toBe(settled);
181
+ });
182
+
183
+ /**
184
+ * ## Why this measures the join as well as the churn
185
+ *
186
+ * It used to measure only the DELTA across the churn, and that made it unable
187
+ * to fail. Reintroducing the exact historical bug (`room.tracks` spreading
188
+ * the Map on every read, `attach` memoised on `[producerId]`) turned the test
189
+ * above red at 55 commits and left this one green: React abandons a
190
+ * getSnapshot loop after about fifty forced re-renders, so by the time
191
+ * `afterJoin` was read the loop had already run and stopped, and the delta it
192
+ * then measured was the delta of a tree React had given up on. A budget taken
193
+ * from a baseline that is itself the bug measures nothing.
194
+ *
195
+ * So the baseline is asserted, the delta is asserted, and the churn is
196
+ * asserted to have actually LANDED - a tree React has stopped re-rendering is
197
+ * also a tree that commits nothing, and would otherwise pass this by being
198
+ * inert.
199
+ */
200
+ it("settles again after the room changes underneath it", async () => {
201
+ const commits: number[] = [];
202
+ const node = new FakeNode({ peers: [{ identity: "anna", name: "Anna" }] });
203
+
204
+ const { getByTestId } = mount(node, <CountedStage commits={commits} />);
205
+ await settle();
206
+ const afterJoin = commits.length;
207
+ // The baseline has to be a settled tree, or every number below is measured
208
+ // against a loop that has already burnt itself out.
209
+ expect(afterJoin).toBeLessThan(20);
210
+
211
+ node.event({ event: "peerJoined", peer: { identity: "ben", name: "Ben", kind: "human" } });
212
+ node.event({ event: "producerAppeared", producerId: "p-ben", identity: "ben", kind: "audio" });
213
+ node.event({ event: "activeSpeakers", producerIds: ["p-ben"] });
214
+
215
+ await settle();
216
+ const afterChurn = commits.length;
217
+ // The frames reached the screen. Without this, "few commits" is satisfied
218
+ // by a tree that is no longer rendering at all.
219
+ expect(getByTestId("tile-ben")).toBeTruthy();
220
+ // Three frames, a small number of commits. Not "a few more than before".
221
+ expect(afterChurn - afterJoin).toBeLessThan(12);
222
+
223
+ await settle();
224
+ expect(commits.length).toBe(afterChurn);
225
+ });
226
+ });
227
+
228
+ describe("the grid", () => {
229
+ it("keeps a tile for a participant with no video", async () => {
230
+ const node = new FakeNode({
231
+ peers: [
232
+ { identity: "anna", name: "Anna" },
233
+ { identity: "ben", name: "Ben" },
234
+ ],
235
+ producers: [{ producerId: "p-anna-video", identity: "anna", kind: "video" }],
236
+ });
237
+
238
+ const { getByTestId, getAllByText } = mount(node, <CallStage />);
239
+
240
+ await waitFor(() => expect(getByTestId("tile-ben")).toBeTruthy());
241
+ // Ben publishes nothing at all. He is still in the room, so he is still on
242
+ // screen, and his NAME is what the tile shows.
243
+ expect(getAllByText("Ben").length).toBeGreaterThan(0);
244
+ expect(getByTestId("tile-anna")).toBeTruthy();
245
+ expect(getByTestId("tile-local")).toBeTruthy();
246
+ });
247
+
248
+ it("does not collapse when somebody turns their camera off", async () => {
249
+ const node = new FakeNode({
250
+ peers: [
251
+ { identity: "anna", name: "Anna" },
252
+ { identity: "ben", name: "Ben" },
253
+ ],
254
+ producers: [
255
+ { producerId: "p-anna-video", identity: "anna", kind: "video" },
256
+ { producerId: "p-ben-video", identity: "ben", kind: "video" },
257
+ ],
258
+ });
259
+
260
+ const { container, getByTestId } = mount(node, <CallStage />);
261
+ await waitFor(() => expect(getByTestId("tile-ben")).toBeTruthy());
262
+ const before = container.querySelectorAll("[data-testid^='tile-']").length;
263
+
264
+ node.event({ event: "producerClosed", producerId: "p-ben-video" });
265
+
266
+ // Same tiles, one of them now showing a name instead of a picture. The bug
267
+ // this pins removes the tile entirely and re-flows the whole grid.
268
+ await settle();
269
+ expect(container.querySelectorAll("[data-testid^='tile-']").length).toBe(before);
270
+ expect(getByTestId("tile-ben")).toBeTruthy();
271
+ });
272
+
273
+ /**
274
+ * The head count is the one number a person acts on.
275
+ *
276
+ * "In this call: 2" is how a coach answers "has my client actually arrived",
277
+ * so it has to count PEOPLE. It was `tiles.length + 1`, and `callTiles` gives
278
+ * a screen sharer two tiles by design, so the moment the coach shared their
279
+ * screen the header announced a third party in a two-person room.
280
+ */
281
+ it("counts people, not video streams, while somebody shares a screen", async () => {
282
+ const node = new FakeNode({
283
+ peers: [{ identity: "anna", name: "Anna" }],
284
+ producers: [
285
+ { producerId: "p-anna-video", identity: "anna", kind: "video" },
286
+ { producerId: "p-anna-screen", identity: "anna", kind: "video" },
287
+ ],
288
+ });
289
+
290
+ const { container, getByText } = mount(node, <CallStage />);
291
+
292
+ await waitFor(() => expect(getByText(/In this call:/).textContent).toBe("In this call: 2"));
293
+ // Two tiles for her, and still two people. The count is derived from the
294
+ // grid, so this pins both halves at once.
295
+ expect(container.querySelectorAll("[data-testid='tile-anna']").length).toBe(2);
296
+ });
297
+
298
+ /**
299
+ * The recorder joins as a real peer.
300
+ *
301
+ * `session.ts` publishes `participantJoined` for the egress process and the
302
+ * join snapshot carries it, so a grid built from `peers` drew a third tile
303
+ * called `egress-9f2c` in a 1:1 session and the header read "In this call: 3"
304
+ * - at the one moment a coach is most likely to be reading that number, and
305
+ * it is the number they use to answer "has my client actually arrived".
306
+ */
307
+ it("does not draw the recording as a third person in the room", async () => {
308
+ const node = new FakeNode({
309
+ recording: true,
310
+ peers: [
311
+ { identity: "anna", name: "Anna" },
312
+ { identity: "egress-9f2c", kind: "egress" },
313
+ ],
314
+ });
315
+
316
+ const { getByTestId, queryByTestId, getByText } = mount(node, <CallStage />);
317
+
318
+ await waitFor(() => expect(getByTestId("tile-anna")).toBeTruthy());
319
+ expect(queryByTestId("tile-egress-9f2c")).toBeNull();
320
+ expect(getByText(/In this call:/).textContent).toBe("In this call: 2");
321
+ // The consent signal is still said, properly, in words.
322
+ expect(getByText("This call is being recorded")).toBeTruthy();
323
+ });
324
+
325
+ /**
326
+ * A barrier the node narrows mid-call has to reach the screen.
327
+ *
328
+ * `grantsChanged` pushes `subscribeRuleChanged`, and the reducer deliberately
329
+ * KEEPS the now-forbidden producers so that a rule which later widens is
330
+ * recoverable. Nothing cleans them up for us either: the node's
331
+ * `revokeNewlyForbidden` closes only producers this session had a live
332
+ * consumer for, so one that was announced and never consumed stays in the
333
+ * list for the rest of the call. A screen reading the raw list therefore
334
+ * keeps a tile carrying that person's identity and mounts an `<audio>` for
335
+ * them, and on a sealed consultation the other party's presence is most of
336
+ * what the barrier was hiding.
337
+ *
338
+ * The rule governs MEDIA, so this is about the media: the peer roster is the
339
+ * node's own account of who is in the room and is not filtered by a subscribe
340
+ * rule on either side.
341
+ */
342
+ it("stops rendering media the node has barred this seat from", async () => {
343
+ const node = new FakeNode({
344
+ peers: [
345
+ { identity: "anna", name: "Anna" },
346
+ { identity: "third-party", name: "Third Party" },
347
+ ],
348
+ producers: [
349
+ { producerId: "p-anna-audio", identity: "anna", kind: "audio" },
350
+ { producerId: "p-third-audio", identity: "third-party", kind: "audio" },
351
+ { producerId: "p-third-video", identity: "third-party", kind: "video" },
352
+ ],
353
+ });
354
+
355
+ const { container, getByTestId } = mount(node, <CallStage />, { autoSubscribe: true });
356
+ await waitFor(() => expect(getByTestId("tile-third-party")).toBeTruthy());
357
+ node.event({ event: "activeSpeakers", producerIds: ["p-anna-audio", "p-third-audio", "p-third-video"] });
358
+ await waitFor(() => expect(container.querySelectorAll("audio").length).toBe(2));
359
+ await waitFor(() => expect(container.querySelectorAll("video").length).toBe(1));
360
+
361
+ node.event({ event: "subscribeRuleChanged", subscribe: { mode: "allow", identities: ["anna"] } });
362
+
363
+ // An `<audio>` left mounted for a producer this seat may not receive is the
364
+ // barrier failing in the one place a person can observe it.
365
+ await waitFor(() => expect(container.querySelectorAll("audio").length).toBe(1));
366
+ expect(container.querySelectorAll("video").length).toBe(0);
367
+ expect(getByTestId("tile-anna")).toBeTruthy();
368
+ });
369
+
370
+ it("marks the active speaker without re-ordering the grid", async () => {
371
+ const node = new FakeNode({
372
+ peers: [
373
+ { identity: "anna", name: "Anna" },
374
+ { identity: "ben", name: "Ben" },
375
+ ],
376
+ producers: [
377
+ { producerId: "p-anna-audio", identity: "anna", kind: "audio" },
378
+ { producerId: "p-ben-audio", identity: "ben", kind: "audio" },
379
+ ],
380
+ });
381
+
382
+ const { container, getByTestId } = mount(node, <CallStage />);
383
+ await waitFor(() => expect(getByTestId("tile-ben")).toBeTruthy());
384
+ const order = () => [...container.querySelectorAll("[data-testid^='tile-']")].map((n) => n.getAttribute("data-testid"));
385
+ const before = order();
386
+
387
+ node.event({ event: "activeSpeakers", producerIds: ["p-ben-audio"] });
388
+
389
+ await waitFor(() => expect(getByTestId("tile-ben").getAttribute("data-speaking")).toBe("true"));
390
+ expect(getByTestId("tile-anna").getAttribute("data-speaking")).toBe("false");
391
+ // A grid that reshuffles on every "mm" moves the person you were watching
392
+ // out from under your eye.
393
+ expect(order()).toEqual(before);
394
+ });
395
+ });
396
+
397
+ /**
398
+ * The half of the screen that has no pixels.
399
+ *
400
+ * Nothing in this file used to look at an `<audio>` or a `<video>` element at
401
+ * all, and the whole media-attachment layer sat under that gap: remote audio
402
+ * elements were mounted the moment the node ANNOUNCED a producer, some
403
+ * milliseconds before the consume round trip produced a track, and their ref
404
+ * callback - memoised on values that never changed - was never invoked again.
405
+ * Every one of them kept `srcObject = null` for the whole call. Remote
406
+ * `<video>` carries `muted`, so there was no second audio path: the coach and
407
+ * the client saw each other and heard nothing, on a screen that passed every
408
+ * assertion here.
409
+ */
410
+ describe("the audio actually reaches an element", () => {
411
+ it("attaches a remote track to the element that was already on the page", async () => {
412
+ const node = new FakeNode({
413
+ peers: [{ identity: "anna", name: "Anna" }],
414
+ producers: [{ producerId: "p-anna-audio", identity: "anna", kind: "audio" }],
415
+ });
416
+
417
+ // autoSubscribe on: the room follows the node's active set, so the track
418
+ // arrives when the node says she is speaking and not before.
419
+ const { container } = mount(node, <CallStage />, { autoSubscribe: true });
420
+
421
+ await waitFor(() => expect(container.querySelector("audio")).toBeTruthy());
422
+ const element = container.querySelector("audio") as HTMLAudioElement;
423
+ expect(element.srcObject).toBeNull();
424
+
425
+ node.event({ event: "activeSpeakers", producerIds: ["p-anna-audio"] });
426
+
427
+ await waitFor(() => expect((container.querySelector("audio") as HTMLAudioElement).srcObject).toBeTruthy());
428
+ // The SAME element, never unmounted. That is what makes this a test of the
429
+ // ref being re-invoked rather than of a remount happening to fix it.
430
+ expect(container.querySelector("audio")).toBe(element);
431
+ });
432
+
433
+ it("attaches a remote camera to its tile", async () => {
434
+ const node = new FakeNode({
435
+ peers: [{ identity: "anna", name: "Anna" }],
436
+ producers: [{ producerId: "p-anna-video", identity: "anna", kind: "video" }],
437
+ });
438
+
439
+ const { container, getByTestId } = mount(node, <CallStage />, { autoSubscribe: true });
440
+ await waitFor(() => expect(getByTestId("tile-anna")).toBeTruthy());
441
+
442
+ node.event({ event: "activeSpeakers", producerIds: ["p-anna-video"] });
443
+
444
+ await waitFor(() => expect(container.querySelector("video")).toBeTruthy());
445
+ expect((container.querySelector("video") as HTMLVideoElement).srcObject).toBeTruthy();
446
+ });
447
+ });
448
+
449
+ describe("the connection state, said honestly", () => {
450
+ it("says it is connecting, then that it is connected", async () => {
451
+ const node = new FakeNode({});
452
+ const { getByRole } = mount(node, <CallStage />);
453
+
454
+ expect(getByRole("status").textContent).toContain("Connecting");
455
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Connected"));
456
+ });
457
+
458
+ it("names a refusal and offers a retry, rather than spinning for ever", async () => {
459
+ const node = new FakeNode({ refuseWith: "unauthorized" });
460
+ const { getByRole, getByText } = mount(node, <CallStage />);
461
+
462
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Could not join this call"));
463
+ // The server's own code, and a control. A spinner here is the failure: it
464
+ // says "wait" about something waiting will never fix.
465
+ expect(getByRole("status").textContent).toContain("unauthorized");
466
+ expect(getByText("Try again")).toBeTruthy();
467
+ });
468
+
469
+ it("says the call ended when the node closes the room", async () => {
470
+ const node = new FakeNode({});
471
+ const { getByRole } = mount(node, <CallStage />);
472
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Connected"));
473
+
474
+ node.event({ event: "roomClosed", reason: "the host ended it" });
475
+
476
+ await waitFor(() => expect(getByRole("status").textContent).toContain("The call has ended"));
477
+ expect(getByRole("status").textContent).toContain("the host ended it");
478
+ });
479
+
480
+ it("distinguishes a dropped socket from a refusal", async () => {
481
+ const node = new FakeNode({});
482
+ const { getByRole } = mount(node, <CallStage />);
483
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Connected"));
484
+
485
+ node.socket.drop();
486
+
487
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Connection lost"));
488
+ expect(getByRole("status").textContent).not.toContain("Could not join");
489
+ });
490
+ });
491
+
492
+ /**
493
+ * A spinner is a promise, and nothing here was keeping it.
494
+ *
495
+ * `superviseConnection` - the whole reconnect policy, built and tested - had no
496
+ * caller outside its own spec, so a drop set the connection to `reconnecting`
497
+ * and no second socket was ever opened. For a plain drop the screen at least
498
+ * offered "Try again"; for a DRAIN it offered nothing, because a drain is
499
+ * supposed to be handled for you. So an ordinary deploy or scale-down ended
500
+ * every call on that node under the words "Moving you to another server / This
501
+ * takes a few seconds and nobody else is dropped", with a spinner, no control,
502
+ * and no way back into a paid coaching hour short of guessing that a reload
503
+ * would work.
504
+ */
505
+ describe("the promise to come back is one somebody keeps", () => {
506
+ it("actually moves you when the node drains, rather than saying so", async () => {
507
+ const node = new FakeNode({});
508
+ const { getByRole } = mount(node, <CallStage />, { reconnect: FAST_RECOVERY });
509
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Connected"));
510
+
511
+ // What a deploy sends: the node asks to be left, then closes the socket.
512
+ node.event({ event: "draining", reconnectAfterMs: 1 });
513
+ await settle(20);
514
+ node.socket.drop();
515
+
516
+ // A SECOND socket, through `MEDIA_URL` with a fresh ticket, and back in the
517
+ // call. This is what the copy on screen has been claiming all along.
518
+ await waitFor(() => expect(node.sockets.length).toBe(2));
519
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Connected"));
520
+ });
521
+
522
+ it("offers a way back when a drain has nowhere to move you to", async () => {
523
+ const node = new FakeNode({});
524
+ const { getByRole, getByText } = mount(node, <CallStage />, { reconnect: NO_RECOVERY });
525
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Connected"));
526
+
527
+ node.event({ event: "draining", reconnectAfterMs: 1 });
528
+ await settle(20);
529
+ node.socket.drop();
530
+
531
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Could not move you"));
532
+ // The control the drained case never had. Spinner plus no button is the
533
+ // one combination that leaves somebody sitting there.
534
+ expect(getByText("Try again")).toBeTruthy();
535
+ });
536
+
537
+ it("comes back into the call when Try again is PRESSED, not merely drawn", async () => {
538
+ const node = new FakeNode({});
539
+ const { getByRole, getByText } = mount(node, <CallStage />, { reconnect: NO_RECOVERY });
540
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Connected"));
541
+
542
+ node.socket.drop();
543
+ await waitFor(() => expect(getByText("Try again")).toBeTruthy());
544
+
545
+ fireEvent.click(getByText("Try again"));
546
+
547
+ // Asserting the button exists proves nothing about whether pressing it
548
+ // does anything, and the retry path had no test that pressed it.
549
+ await waitFor(() => expect(node.sockets.length).toBe(2));
550
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Connected"));
551
+ });
552
+ });
553
+
554
+ describe("the controls", () => {
555
+ it("offers only what the token was minted for", async () => {
556
+ const node = new FakeNode({
557
+ grants: { canPublish: true, canSubscribe: true, canPublishData: false, publishKinds: ["audio"] },
558
+ });
559
+ const { getByText, queryByText } = mount(node, <CallStage />);
560
+
561
+ await waitFor(() => expect(getByText("Unmute")).toBeTruthy());
562
+ // A camera the node would refuse is not a button. The node refuses it
563
+ // anyway; this is about not offering it.
564
+ expect(queryByText("Start camera")).toBeNull();
565
+ expect(queryByText("Share screen")).toBeNull();
566
+ expect(getByText("Leave")).toBeTruthy();
567
+ });
568
+
569
+ it("offers the microphone and camera a booking token was minted for", async () => {
570
+ // The grants a coaching room actually mints. The UI asks about the SOURCE
571
+ // it is going to publish ("microphone", "camera") rather than about the
572
+ // grant's own word for it, because the source is what the node decides
573
+ // against - and this was the one call the UI and the node did not agree on.
574
+ const node = new FakeNode({
575
+ grants: {
576
+ canPublish: true,
577
+ canSubscribe: true,
578
+ canPublishData: false,
579
+ publishKinds: ["audio", "video", "screen"],
580
+ },
581
+ });
582
+ const { getByText } = mount(node, <CallStage />);
583
+
584
+ await waitFor(() => expect(getByText("Unmute")).toBeTruthy());
585
+ expect(getByText("Start camera")).toBeTruthy();
586
+ expect(getByText("Share screen")).toBeTruthy();
587
+ });
588
+
589
+ it("offers every control when the grants are open", async () => {
590
+ const node = new FakeNode({});
591
+ const { getByText } = mount(node, <CallStage />);
592
+
593
+ await waitFor(() => expect(getByText("Unmute")).toBeTruthy());
594
+ expect(getByText("Start camera")).toBeTruthy();
595
+ expect(getByText("Share screen")).toBeTruthy();
596
+ });
597
+
598
+ it("offers nothing to publish on a listen-only seat, and still lets them leave", async () => {
599
+ const node = new FakeNode({ grants: { canPublish: false, canSubscribe: true, canPublishData: false } });
600
+ const { getByText, queryByText } = mount(node, <CallStage />);
601
+
602
+ await waitFor(() => expect(getByText("Leave")).toBeTruthy());
603
+ expect(queryByText("Unmute")).toBeNull();
604
+ expect(queryByText("Start camera")).toBeNull();
605
+ });
606
+ });
607
+
608
+ /**
609
+ * Every control test above asks only which buttons are DRAWN.
610
+ *
611
+ * Not one of them pressed anything, so no test exercised a publish through the
612
+ * UI at all, and the two defects that live in pressing were both invisible: a
613
+ * second press while the permission prompt is open, and a capture that outlives
614
+ * the call it was consented to.
615
+ */
616
+ describe("the controls, pressed", () => {
617
+ it("publishes ONCE when Unmute is pressed twice in a row", async () => {
618
+ const { getUserMedia } = stubCapture();
619
+ const node = new FakeNode({});
620
+ const { getByText } = mount(node, <CallStage />);
621
+
622
+ await waitFor(() => expect(getByText("Unmute")).toBeTruthy());
623
+ const button = getByText("Unmute");
624
+ // Which is exactly what a person does when the first press produces no
625
+ // visible change - and it produces none, because `getUserMedia` is showing
626
+ // a permission prompt and the flag the handler reads comes from the
627
+ // publication that only exists once the publish has resolved.
628
+ fireEvent.click(button);
629
+ fireEvent.click(button);
630
+
631
+ await waitFor(() => expect(getByText("Mute")).toBeTruthy());
632
+ await settle(30);
633
+ // Two producers under one identity means everybody else mounts two
634
+ // `<audio>` elements and hears this person doubled.
635
+ expect(getUserMedia).toHaveBeenCalledTimes(1);
636
+ expect(produced).toEqual(["microphone"]);
637
+ });
638
+
639
+ it("releases the microphone when the call ends underneath the person", async () => {
640
+ const { track } = stubCapture();
641
+ const node = new FakeNode({});
642
+ const { getByText, getByRole } = mount(node, <CallStage />);
643
+
644
+ await waitFor(() => expect(getByText("Unmute")).toBeTruthy());
645
+ fireEvent.click(getByText("Unmute"));
646
+ await waitFor(() => expect(getByText("Mute")).toBeTruthy());
647
+
648
+ // A coach cancelling or refunding at minute ten: the room is ended on the
649
+ // plane and the node evicts. Nobody pressed Leave, which is the only path
650
+ // that used to reach `room.close()`.
651
+ node.event({ event: "roomClosed", reason: "the host ended it" });
652
+ await settle(20);
653
+ node.socket.drop();
654
+
655
+ await waitFor(() => expect(getByRole("status").textContent).toContain("The call has ended"));
656
+ // The OS recording indicator goes out. It stayed lit until the person
657
+ // happened to close the tab, with the button still reading "Mute" about a
658
+ // microphone publishing into a room that no longer existed.
659
+ await waitFor(() => expect(track.stop).toHaveBeenCalledTimes(1));
660
+ expect(getByText("Unmute")).toBeTruthy();
661
+ });
662
+ });
663
+
664
+ describe("the things that must stay visible", () => {
665
+ it("shows the recording indicator as content, not behind a hint", async () => {
666
+ const node = new FakeNode({ recording: true });
667
+ const { getByText } = mount(node, <CallStage />);
668
+
669
+ // Consent. It is never something a person has to click to find out.
670
+ await waitFor(() => expect(getByText("This call is being recorded")).toBeTruthy());
671
+ });
672
+
673
+ it("keeps explanations behind a `?` with a name that says what it explains", async () => {
674
+ const node = new FakeNode({});
675
+ const { getByLabelText, queryByText } = mount(node, <CallStage />);
676
+
677
+ await waitFor(() => expect(getByLabelText("Why can I hear someone but not see them?")).toBeTruthy());
678
+ // Not on screen until asked for. A permanent paragraph on a call screen is
679
+ // a paragraph in the way of the call.
680
+ expect(queryByText(/means that person has their camera off/)).toBeNull();
681
+
682
+ fireEvent.click(getByLabelText("Why can I hear someone but not see them?"));
683
+ await waitFor(() => expect(queryByText(/means that person has their camera off/)).toBeTruthy());
684
+ });
685
+ });