@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,499 @@
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 { FORGE_LOCALES, getForgeMessages, translateForge } from "../../../i18n";
7
+ import { callHeadCount, callStatus, callTiles, canPublishSource, remoteAudioProducerIds } from "../callState";
8
+
9
+ /**
10
+ * The two decisions a call screen gets wrong.
11
+ *
12
+ * Both of them are invisible in a component test that only checks "did it
13
+ * render", and both of them are the difference between a call that works and a
14
+ * call that looks broken while working perfectly.
15
+ */
16
+
17
+ const peer = (identity: string, name?: string): Peer => ({
18
+ identity,
19
+ kind: "human",
20
+ ...(name === undefined ? {} : { name }),
21
+ });
22
+
23
+ /** The recording process, which joins the room as a real peer. */
24
+ const recorder = (identity: string): Peer => ({ identity, kind: "egress" });
25
+
26
+ const producer = (producerId: string, identity: string, kind: "audio" | "video", paused = false): ProducerEntry => ({
27
+ producerId,
28
+ identity,
29
+ kind,
30
+ paused,
31
+ });
32
+
33
+ describe("callTiles keeps the grid from collapsing", () => {
34
+ it("draws a tile for somebody with NO video at all", () => {
35
+ const tiles = callTiles({
36
+ identity: "me",
37
+ peers: [peer("anna", "Anna")],
38
+ producers: [producer("p-audio", "anna", "audio")],
39
+ activeSpeakers: [],
40
+ });
41
+
42
+ // The whole point. A participant with the camera off has not left the room,
43
+ // and a grid built by mapping over video tracks drops them silently.
44
+ expect(tiles).toHaveLength(1);
45
+ expect(tiles[0]!.name).toBe("Anna");
46
+ expect(tiles[0]!.videoProducerId).toBeUndefined();
47
+ expect(tiles[0]!.hasAudio).toBe(true);
48
+ });
49
+
50
+ it("keeps the SAME number of tiles when a camera goes off", () => {
51
+ const peers = [peer("anna", "Anna"), peer("ben", "Ben")];
52
+ const withCamera = callTiles({
53
+ identity: "me",
54
+ peers,
55
+ producers: [producer("p-anna-video", "anna", "video"), producer("p-ben-audio", "ben", "audio")],
56
+ activeSpeakers: [],
57
+ });
58
+ const cameraOff = callTiles({
59
+ identity: "me",
60
+ peers,
61
+ producers: [producer("p-ben-audio", "ben", "audio")],
62
+ activeSpeakers: [],
63
+ });
64
+
65
+ expect(withCamera).toHaveLength(2);
66
+ // Two before, two after. This is the assertion that fails on the bug.
67
+ expect(cameraOff).toHaveLength(2);
68
+ expect(cameraOff.map((t) => t.identity)).toEqual(["anna", "ben"]);
69
+ // And the key is unchanged, so React updates the tile rather than
70
+ // unmounting and remounting it under the viewer's eyes.
71
+ expect(cameraOff[0]!.key).toBe(withCamera[0]!.key);
72
+ });
73
+
74
+ it("never draws the local participant, who has their own preview", () => {
75
+ const tiles = callTiles({
76
+ identity: "me",
77
+ // The node may or may not include us in `peers`; both have to be safe.
78
+ peers: [peer("me", "Me"), peer("anna", "Anna")],
79
+ producers: [producer("p-mine", "me", "video")],
80
+ activeSpeakers: [],
81
+ });
82
+
83
+ expect(tiles.map((t) => t.identity)).toEqual(["anna"]);
84
+ });
85
+
86
+ it("gives a second video stream its own tile rather than replacing the face", () => {
87
+ const tiles = callTiles({
88
+ identity: "me",
89
+ peers: [peer("anna", "Anna")],
90
+ producers: [producer("p-cam", "anna", "video"), producer("p-screen", "anna", "video")],
91
+ activeSpeakers: [],
92
+ });
93
+
94
+ expect(tiles).toHaveLength(2);
95
+ expect(tiles.map((t) => t.videoProducerId)).toEqual(["p-cam", "p-screen"]);
96
+ // Same person, so the same name on both. The wire does not label which is
97
+ // the screen, so neither does this.
98
+ expect(tiles.every((t) => t.name === "Anna")).toBe(true);
99
+ expect(new Set(tiles.map((t) => t.key)).size).toBe(2);
100
+ });
101
+
102
+ it("draws a stream whose peer frame has not arrived yet", () => {
103
+ // `producerAppeared` and `peerJoined` have no ordering guarantee. Dropping
104
+ // the stream would blank a tile that has media in it.
105
+ const tiles = callTiles({
106
+ identity: "me",
107
+ peers: [],
108
+ producers: [producer("p-early", "carla", "video")],
109
+ activeSpeakers: [],
110
+ });
111
+
112
+ expect(tiles).toHaveLength(1);
113
+ expect(tiles[0]!.identity).toBe("carla");
114
+ expect(tiles[0]!.videoProducerId).toBe("p-early");
115
+ });
116
+
117
+ it("falls back to the identity, which tells two unnamed people apart", () => {
118
+ const tiles = callTiles({
119
+ identity: "me",
120
+ peers: [peer("guest-1"), peer("guest-2", " ")],
121
+ producers: [],
122
+ activeSpeakers: [],
123
+ });
124
+
125
+ expect(tiles.map((t) => t.name)).toEqual(["guest-1", "guest-2"]);
126
+ });
127
+
128
+ it("marks the speaker from the node's producer ids, not from identities", () => {
129
+ const tiles = callTiles({
130
+ identity: "me",
131
+ peers: [peer("anna", "Anna"), peer("ben", "Ben")],
132
+ producers: [
133
+ producer("p-anna-audio", "anna", "audio"),
134
+ producer("p-anna-video", "anna", "video"),
135
+ producer("p-ben-audio", "ben", "audio"),
136
+ ],
137
+ // The node sends PRODUCER ids. Matching these against identities would
138
+ // light up nobody, and the indication would simply never appear.
139
+ activeSpeakers: ["p-anna-audio"],
140
+ });
141
+
142
+ expect(tiles.find((t) => t.identity === "anna")!.isSpeaking).toBe(true);
143
+ expect(tiles.find((t) => t.identity === "ben")!.isSpeaking).toBe(false);
144
+ });
145
+
146
+ it("carries the publisher's own pause through", () => {
147
+ const tiles = callTiles({
148
+ identity: "me",
149
+ peers: [peer("anna", "Anna")],
150
+ producers: [producer("p-cam", "anna", "video", true)],
151
+ activeSpeakers: [],
152
+ });
153
+
154
+ expect(tiles[0]!.videoPaused).toBe(true);
155
+ });
156
+ });
157
+
158
+ describe("callHeadCount counts PEOPLE, not tiles", () => {
159
+ it("does not gain a person when somebody shares their screen", () => {
160
+ // A coach and a client: two people. The coach shares a screen, which by
161
+ // `callTiles`' own design gives them a second tile, and a count taken from
162
+ // `tiles.length` announced a third party in a two-person call at exactly
163
+ // the moment a coaching call is most likely to have one.
164
+ const tiles = callTiles({
165
+ identity: "me",
166
+ peers: [peer("anna", "Anna")],
167
+ producers: [producer("p-cam", "anna", "video"), producer("p-screen", "anna", "video")],
168
+ activeSpeakers: [],
169
+ });
170
+
171
+ expect(tiles).toHaveLength(2);
172
+ expect(callHeadCount(tiles)).toBe(2);
173
+ });
174
+
175
+ it("counts everyone once, including the viewer, and the viewer alone in an empty room", () => {
176
+ expect(callHeadCount([])).toBe(1);
177
+
178
+ const tiles = callTiles({
179
+ identity: "me",
180
+ peers: [peer("anna", "Anna"), peer("ben", "Ben")],
181
+ producers: [producer("p-anna-audio", "anna", "audio"), producer("p-ben-video", "ben", "video")],
182
+ activeSpeakers: [],
183
+ });
184
+
185
+ expect(callHeadCount(tiles)).toBe(3);
186
+ });
187
+
188
+ /**
189
+ * The recorder is not somebody who arrived.
190
+ *
191
+ * A recording joins as a real peer with `kind: "egress"`, so a grid built
192
+ * from `peers` drew a third tile called `egress-9f2c` in a 1:1 session and
193
+ * the header read "In this call: 3" - at exactly the moment a coach is most
194
+ * likely to be reading that number, and it is the number they use to answer
195
+ * "has my client actually arrived". `<RecordingIndicator>` already says the
196
+ * true thing, in words, as content.
197
+ */
198
+ it("does not count the recording as a person in the room", () => {
199
+ const tiles = callTiles({
200
+ identity: "me",
201
+ peers: [peer("anna", "Anna"), recorder("egress-9f2c")],
202
+ producers: [producer("p-anna-audio", "anna", "audio")],
203
+ activeSpeakers: [],
204
+ });
205
+
206
+ expect(tiles.map((t) => t.identity)).toEqual(["anna"]);
207
+ // A coach and their client. Two.
208
+ expect(callHeadCount(tiles)).toBe(2);
209
+ });
210
+
211
+ it("keeps a dial-in caller and an agent, which are somebody to talk to", () => {
212
+ const tiles = callTiles({
213
+ identity: "me",
214
+ peers: [
215
+ { identity: "+4915112345678", kind: "sip" },
216
+ { identity: "notetaker", kind: "agent", name: "Notes" },
217
+ ],
218
+ producers: [],
219
+ activeSpeakers: [],
220
+ });
221
+
222
+ // Only `egress` is excluded, and only because it is a recorder rather than
223
+ // a participant. Dropping every non-human would hide a caller who dialled in.
224
+ expect(tiles.map((t) => t.identity)).toEqual(["+4915112345678", "notetaker"]);
225
+ expect(callHeadCount(tiles)).toBe(3);
226
+ });
227
+ });
228
+
229
+ describe("remoteAudioProducerIds", () => {
230
+ it("takes everyone else's audio and none of our own", () => {
231
+ const ids = remoteAudioProducerIds({
232
+ identity: "me",
233
+ producers: [
234
+ producer("p-mine", "me", "audio"),
235
+ producer("p-anna", "anna", "audio"),
236
+ producer("p-anna-video", "anna", "video"),
237
+ ],
238
+ });
239
+
240
+ // Our own audio played back is a feedback loop, and a video producer
241
+ // attached to an `<audio>` element is silence.
242
+ expect(ids).toEqual(["p-anna"]);
243
+ });
244
+ });
245
+
246
+ describe("callStatus is honest about what is happening", () => {
247
+ it("separates a reconnect from a refusal", () => {
248
+ const dropped = callStatus({
249
+ connectionState: "reconnecting",
250
+ error: { type: "socket_closed", code: 1006 },
251
+ recovering: true,
252
+ });
253
+ const refused = callStatus({ connectionState: "reconnecting", error: { type: "refused", code: "unauthorized" } });
254
+
255
+ // The room reports ONE state for both. They are completely different things
256
+ // to the person looking at the screen: one is worth waiting through, the
257
+ // other is worth pressing a button about.
258
+ expect(dropped.tone).toBe("reconnecting");
259
+ expect(refused.tone).toBe("failed");
260
+ expect(dropped.headlineKey).not.toBe(refused.headlineKey);
261
+ });
262
+
263
+ it("shows the server's own words for a refusal rather than a friendly lie", () => {
264
+ // As TEXT, not as a key: a refusal is the one thing on the screen that must
265
+ // never be put through a translator, because the server's sentence is the
266
+ // reason and a friendlier version of it is a lie about why.
267
+ const refusedByCode = callStatus({
268
+ connectionState: "reconnecting",
269
+ error: { type: "refused", code: "room_closed" },
270
+ });
271
+ expect(refusedByCode.detailText).toBe("room_closed");
272
+ expect(refusedByCode.detailKey).toBeUndefined();
273
+ expect(
274
+ callStatus({
275
+ connectionState: "reconnecting",
276
+ error: { type: "refused", code: "unauthorized", message: "this session has ended" },
277
+ }).detailText,
278
+ ).toBe("this session has ended");
279
+ });
280
+
281
+ /**
282
+ * Keys, not sentences. `callStatus` has no locale, and every English string
283
+ * it used to return was rendered as-is on sites whose every other word was
284
+ * German. What it names has to exist in every bundle, or a German visitor
285
+ * reads a raw dotted key at the one moment they are being told the call is
286
+ * over.
287
+ */
288
+ it("names copy by key, and every key it can produce exists in every bundle", () => {
289
+ const seen = new Set<string>();
290
+ const inputs: Parameters<typeof callStatus>[0][] = [
291
+ { connectionState: "idle", error: undefined },
292
+ { connectionState: "connecting", error: undefined },
293
+ { connectionState: "connected", error: undefined },
294
+ { connectionState: "connected", error: undefined, phase: "closed" },
295
+ { connectionState: "closed", error: { type: "closed_by_client" } },
296
+ { connectionState: "closed", error: { type: "room_closed", reason: "x" } },
297
+ { connectionState: "reconnecting", error: { type: "refused", code: "unauthorized" } },
298
+ { connectionState: "reconnecting", error: { type: "room_closed", reason: "x" } },
299
+ { connectionState: "reconnecting", error: { type: "draining", reconnectAfterMs: 1 }, recovering: true },
300
+ { connectionState: "reconnecting", error: { type: "draining", reconnectAfterMs: 1 } },
301
+ { connectionState: "reconnecting", error: { type: "closed_by_client" } },
302
+ { connectionState: "reconnecting", error: { type: "socket_closed" }, recovering: true },
303
+ { connectionState: "reconnecting", error: { type: "socket_closed" } },
304
+ ];
305
+ for (const input of inputs) {
306
+ const status = callStatus(input);
307
+ seen.add(status.headlineKey);
308
+ if (status.detailKey) seen.add(status.detailKey);
309
+ }
310
+ for (const locale of FORGE_LOCALES) {
311
+ const bundle = getForgeMessages(locale);
312
+ const missing = [...seen].filter((key) => !key.startsWith("forge.call_stage.") || bundle[key] === undefined);
313
+ expect(missing, `${locale} is missing: ${missing.join(", ")}`).toEqual([]);
314
+ }
315
+ // And translated English reads as the sentences the screen specs assert on.
316
+ expect(translateForge("en", callStatus({ connectionState: "connected", error: undefined }).headlineKey)).toBe(
317
+ "Connected",
318
+ );
319
+ });
320
+
321
+ it("does not offer a retry for something retrying cannot fix", () => {
322
+ expect(callStatus({ connectionState: "connected", error: undefined }).canRetry).toBe(false);
323
+ expect(callStatus({ connectionState: "connecting", error: undefined }).canRetry).toBe(false);
324
+ expect(callStatus({ connectionState: "closed", error: { type: "closed_by_client" } }).canRetry).toBe(false);
325
+ expect(
326
+ callStatus({ connectionState: "reconnecting", error: { type: "room_closed", reason: "host ended" } }).canRetry,
327
+ ).toBe(false);
328
+ // A node moving us off itself reconnects on its own; a button would be a
329
+ // second connect racing the first. WHILE it is actually moving us: the
330
+ // promise and the control are two halves of one decision.
331
+ expect(
332
+ callStatus({
333
+ connectionState: "reconnecting",
334
+ error: { type: "draining", reconnectAfterMs: 500 },
335
+ recovering: true,
336
+ }).canRetry,
337
+ ).toBe(false);
338
+ });
339
+
340
+ /**
341
+ * A spinner is a promise, and it has to be one somebody is keeping.
342
+ *
343
+ * "Moving you to another server" and "Connection lost, reconnecting" both say
344
+ * "wait, this is being handled". Said over a stack where nothing is coming
345
+ * back, they are the worst screen in the product: an ordinary deploy drains a
346
+ * node, everyone on it reads that nobody was dropped, and the only way back
347
+ * into a paid coaching hour is working out for yourself that you should
348
+ * reload the page. So when `recovering` is false, the words change and a
349
+ * control appears.
350
+ */
351
+ describe("it does not promise a recovery that is not happening", () => {
352
+ it("offers a way back when a drain has nowhere to move you to", () => {
353
+ const moving = callStatus({
354
+ connectionState: "reconnecting",
355
+ error: { type: "draining", reconnectAfterMs: 500 },
356
+ recovering: true,
357
+ });
358
+ const stranded = callStatus({
359
+ connectionState: "reconnecting",
360
+ error: { type: "draining", reconnectAfterMs: 500 },
361
+ });
362
+
363
+ expect(moving.tone).toBe("reconnecting");
364
+ expect(moving.canRetry).toBe(false);
365
+ // The one the review found: a spinner, no control, and no second socket
366
+ // ever opened.
367
+ expect(stranded.tone).toBe("failed");
368
+ expect(stranded.canRetry).toBe(true);
369
+ expect(stranded.headlineKey).not.toBe(moving.headlineKey);
370
+ });
371
+
372
+ it("stops saying `reconnecting` about a dropped socket nothing is retrying", () => {
373
+ const retrying = callStatus({
374
+ connectionState: "reconnecting",
375
+ error: { type: "socket_closed", code: 1006 },
376
+ recovering: true,
377
+ });
378
+ const givenUp = callStatus({ connectionState: "reconnecting", error: { type: "socket_closed", code: 1006 } });
379
+
380
+ expect(translateForge("en", retrying.headlineKey)).toBe("Connection lost, reconnecting");
381
+ expect(translateForge("en", givenUp.headlineKey)).toBe("Connection lost");
382
+ expect(givenUp.tone).toBe("failed");
383
+ expect(givenUp.canRetry).toBe(true);
384
+ });
385
+
386
+ it("reads an absent `recovering` as nothing coming, which is the safe way round", () => {
387
+ // A caller that has not been taught about it gets a control rather than a
388
+ // spinner. The wrong answer offers a button somebody did not need; the
389
+ // other wrong answer strands them under a promise.
390
+ expect(callStatus({ connectionState: "reconnecting", error: undefined }).canRetry).toBe(true);
391
+ expect(callStatus({ connectionState: "reconnecting", error: undefined }).tone).toBe("failed");
392
+ });
393
+ });
394
+
395
+ it("offers a retry when only a fresh ticket will do", () => {
396
+ // A join ticket expires in minutes, and an expired one comes back as
397
+ // `unauthorized`; a refusal is very often just that, and `retry()`
398
+ // re-fetches through `getCredentials`.
399
+ expect(
400
+ callStatus({ connectionState: "reconnecting", error: { type: "refused", code: "unauthorized" } }).canRetry,
401
+ ).toBe(true);
402
+ expect(callStatus({ connectionState: "reconnecting", error: undefined }).canRetry).toBe(true);
403
+ });
404
+
405
+ it("says the call ended when the room closed, on either connection state", () => {
406
+ for (const connectionState of ["closed", "reconnecting"] as const) {
407
+ const status = callStatus({ connectionState, error: { type: "room_closed", reason: "the host ended it" } });
408
+ expect(status.tone).toBe("ended");
409
+ expect(status.detailText).toBe("the host ended it");
410
+ }
411
+ });
412
+
413
+ it("says the call ended the moment the ROOM says so, on a socket still open", () => {
414
+ // A `roomClosed` frame arrives on a live socket, so the connection is still
415
+ // "connected" until the node hangs up. Reading only the connection would
416
+ // show "Connected" over a call that has ended, and that is the one entry on
417
+ // this list somebody would act on wrongly.
418
+ const status = callStatus({
419
+ connectionState: "connected",
420
+ error: undefined,
421
+ phase: "closed",
422
+ closedReason: "the host ended it",
423
+ });
424
+
425
+ expect(status.tone).toBe("ended");
426
+ expect(status.detailText).toBe("the host ended it");
427
+ });
428
+
429
+ it("is live only when the room says connected", () => {
430
+ expect(callStatus({ connectionState: "connected", error: undefined }).tone).toBe("live");
431
+ expect(callStatus({ connectionState: "connected", error: undefined, phase: "joined" }).tone).toBe("live");
432
+ expect(callStatus({ connectionState: "idle", error: undefined }).tone).toBe("connecting");
433
+ });
434
+ });
435
+
436
+ describe("canPublishSource", () => {
437
+ const sources = ["microphone", "camera", "screen"] as const;
438
+
439
+ it("offers nothing without canPublish", () => {
440
+ for (const source of sources) {
441
+ expect(canPublishSource({ canPublish: false, canSubscribe: true, canPublishData: false }, source)).toBe(false);
442
+ expect(canPublishSource(undefined, source)).toBe(false);
443
+ }
444
+ });
445
+
446
+ it("treats an absent publishKinds as every kind, which is what the node does", () => {
447
+ const grants = { canPublish: true, canSubscribe: true, canPublishData: false };
448
+ for (const source of sources) {
449
+ expect(canPublishSource(grants, source)).toBe(true);
450
+ }
451
+ });
452
+
453
+ /**
454
+ * The SOURCES a control publishes, against the KINDS a grant lists.
455
+ *
456
+ * These are two vocabularies and they agree on one word. Asking this function
457
+ * about "audio" - as the buttons used to - is asking about something no
458
+ * publish ever declares, so it answered a question the node was never going
459
+ * to be asked: a booking token minted `["audio", "video", "screen"]` drew a
460
+ * microphone button and a camera button, and the node refused both, because
461
+ * what reached it was "microphone" and "camera".
462
+ */
463
+ it("offers the microphone and camera a booking token was minted for", () => {
464
+ const booking = {
465
+ canPublish: true,
466
+ canSubscribe: true,
467
+ canPublishData: false,
468
+ publishKinds: ["audio", "video", "screen"] as const,
469
+ };
470
+
471
+ for (const source of sources) {
472
+ expect(canPublishSource({ ...booking, publishKinds: [...booking.publishKinds] }, source)).toBe(true);
473
+ }
474
+ });
475
+
476
+ it("honours a narrowed publishKinds", () => {
477
+ const listenAndTalk = {
478
+ canPublish: true,
479
+ canSubscribe: true,
480
+ canPublishData: false,
481
+ publishKinds: ["audio"] as const,
482
+ };
483
+
484
+ expect(canPublishSource({ ...listenAndTalk, publishKinds: ["audio"] }, "microphone")).toBe(true);
485
+ expect(canPublishSource({ ...listenAndTalk, publishKinds: ["audio"] }, "camera")).toBe(false);
486
+ expect(canPublishSource({ ...listenAndTalk, publishKinds: ["audio"] }, "screen")).toBe(false);
487
+ });
488
+
489
+ it("does not offer a screen share on a token granted audio and video", () => {
490
+ const noPresenting = {
491
+ canPublish: true,
492
+ canSubscribe: true,
493
+ canPublishData: false,
494
+ };
495
+
496
+ expect(canPublishSource({ ...noPresenting, publishKinds: ["audio", "video"] }, "camera")).toBe(true);
497
+ expect(canPublishSource({ ...noPresenting, publishKinds: ["audio", "video"] }, "screen")).toBe(false);
498
+ });
499
+ });
@@ -0,0 +1,178 @@
1
+ /**
2
+ * An in-process media node, small enough to read in one sitting.
3
+ *
4
+ * The SDK has a fuller one (`media-client/src/core/_tests/fakeSignalServer.ts`,
5
+ * which validates every frame against the contract schema), and this is
6
+ * deliberately NOT that: it cannot be imported here. `@tribe-nest/media-client`
7
+ * publishes an exports map with four subpaths and none of them reaches into
8
+ * `src/core/_tests`, so an import of it would resolve inside this monorepo
9
+ * (npm workspaces symlink everything) and nowhere else. The same trap
10
+ * `_tests/publishedResolvability.spec.ts` exists for.
11
+ *
12
+ * What it has to do is exactly enough to get `<MediaRoomProvider>` to
13
+ * "connected" and then push frames at it: accept a join, answer a transport,
14
+ * answer a consume, and deliver whatever event a spec wants.
15
+ */
16
+
17
+ /**
18
+ * Restated rather than imported: `SocketEventMap` is internal to
19
+ * `media-client/src/core/socket.ts` and no published subpath reaches it. The
20
+ * shape has to match structurally or `webSocket={node.factory}` does not
21
+ * type-check, which is the point of writing it out.
22
+ */
23
+ type SocketEventMap = {
24
+ open: { type: "open" };
25
+ message: { type: "message"; data: unknown };
26
+ error: { type: "error"; error?: unknown };
27
+ close: { type: "close"; code?: number; reason?: string };
28
+ };
29
+
30
+ type SocketEvent = SocketEventMap[keyof SocketEventMap];
31
+ type Listener = (event: never) => void;
32
+
33
+ const OPEN = 1;
34
+ const CLOSED = 3;
35
+
36
+ export class FakeSocket {
37
+ readyState = 0;
38
+ private readonly listeners = new Map<string, Set<Listener>>();
39
+
40
+ constructor(private readonly node: FakeNode) {}
41
+
42
+ addEventListener<K extends keyof SocketEventMap>(type: K, listener: (event: SocketEventMap[K]) => void): void {
43
+ const set = this.listeners.get(type) ?? new Set<Listener>();
44
+ set.add(listener as Listener);
45
+ this.listeners.set(type, set);
46
+ }
47
+
48
+ removeEventListener<K extends keyof SocketEventMap>(type: K, listener: (event: SocketEventMap[K]) => void): void {
49
+ this.listeners.get(type)?.delete(listener as Listener);
50
+ }
51
+
52
+ send(data: string): void {
53
+ this.node.receive(this, JSON.parse(data) as Record<string, unknown>);
54
+ }
55
+
56
+ close(): void {
57
+ if (this.readyState === CLOSED) return;
58
+ this.readyState = CLOSED;
59
+ this.emit("close", { type: "close", code: 1000 });
60
+ }
61
+
62
+ open(): void {
63
+ this.readyState = OPEN;
64
+ this.emit("open", { type: "open" });
65
+ }
66
+
67
+ deliver(frame: unknown): void {
68
+ if (this.readyState !== OPEN) return;
69
+ this.emit("message", { type: "message", data: JSON.stringify(frame) });
70
+ }
71
+
72
+ /** The node going away underneath us, which is a reconnect and not a leave. */
73
+ drop(code = 1006): void {
74
+ if (this.readyState === CLOSED) return;
75
+ this.readyState = CLOSED;
76
+ this.emit("close", { type: "close", code, reason: "node closed" });
77
+ }
78
+
79
+ private emit(type: keyof SocketEventMap, event: SocketEvent): void {
80
+ for (const listener of [...(this.listeners.get(type) ?? [])]) {
81
+ queueMicrotask(() => (listener as (e: SocketEvent) => void)(event));
82
+ }
83
+ }
84
+ }
85
+
86
+ export type FakeProducer = { producerId: string; identity: string; kind: "audio" | "video" };
87
+
88
+ export type FakeNodeOptions = {
89
+ identity?: string;
90
+ peers?: { identity: string; name?: string; kind?: "human" | "agent" | "egress" | "sip" | "ingress" }[];
91
+ producers?: FakeProducer[];
92
+ recording?: boolean;
93
+ grants?: Record<string, unknown>;
94
+ /** Refuse the join with this code instead of accepting it. */
95
+ refuseWith?: string;
96
+ };
97
+
98
+ export class FakeNode {
99
+ readonly sockets: FakeSocket[] = [];
100
+ readonly received: Record<string, unknown>[] = [];
101
+
102
+ constructor(private readonly options: FakeNodeOptions = {}) {}
103
+
104
+ /** Pass as `webSocket` to `<MediaRoomProvider>`. */
105
+ readonly factory = (): FakeSocket => {
106
+ const socket = new FakeSocket(this);
107
+ this.sockets.push(socket);
108
+ queueMicrotask(() => socket.open());
109
+ return socket;
110
+ };
111
+
112
+ get socket(): FakeSocket {
113
+ const socket = this.sockets[this.sockets.length - 1];
114
+ if (!socket) throw new Error("no socket has been opened");
115
+ return socket;
116
+ }
117
+
118
+ receive(socket: FakeSocket, frame: Record<string, unknown>): void {
119
+ this.received.push(frame);
120
+ const id = frame.id as number;
121
+
122
+ if (frame.method === "join") {
123
+ if (this.options.refuseWith) {
124
+ socket.deliver({ id, ok: false, code: this.options.refuseWith });
125
+ return;
126
+ }
127
+ socket.deliver({ id, ok: true, data: { accepted: true } });
128
+ socket.deliver(this.joinedFrame());
129
+ return;
130
+ }
131
+
132
+ if (frame.method === "createTransport") {
133
+ socket.deliver({
134
+ id,
135
+ ok: true,
136
+ data: { transportId: "t-1", iceParameters: {}, iceCandidates: [], dtlsParameters: {} },
137
+ });
138
+ return;
139
+ }
140
+
141
+ if (frame.method === "consume") {
142
+ const producerId = frame.producerId as string;
143
+ const known = (this.options.producers ?? []).find((p) => p.producerId === producerId);
144
+ socket.deliver({
145
+ id,
146
+ ok: true,
147
+ data: { consumerId: `c-${producerId}`, producerId, kind: known?.kind ?? "video", rtpParameters: {} },
148
+ });
149
+ return;
150
+ }
151
+
152
+ socket.deliver({ id, ok: true, data: {} });
153
+ }
154
+
155
+ joinedFrame(): Record<string, unknown> {
156
+ return {
157
+ event: "joined",
158
+ identity: this.options.identity ?? "me",
159
+ room: "booking-1",
160
+ routerRtpCapabilities: { codecs: [] },
161
+ peers: (this.options.peers ?? []).map((p) => ({ kind: "human", ...p })),
162
+ producers: this.options.producers ?? [],
163
+ iceServers: [],
164
+ recording: this.options.recording ?? false,
165
+ grants: this.options.grants ?? { canPublish: true, canSubscribe: true, canPublishData: false },
166
+ };
167
+ }
168
+
169
+ /** Push any event frame at the live socket. */
170
+ event(frame: Record<string, unknown>): void {
171
+ this.socket.deliver(frame);
172
+ }
173
+ }
174
+
175
+ /** Let the fake socket's queued microtasks land. */
176
+ export const settle = async (ms = 150): Promise<void> => {
177
+ await new Promise((resolve) => setTimeout(resolve, ms));
178
+ };