@tribe-nest/forge 3.37.0 → 3.39.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.
@@ -0,0 +1,316 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import type { ConnectionState, DisconnectCause, ProducerEntry } from "@tribe-nest/media-client";
4
+
5
+ import {
6
+ broadcastFellBack,
7
+ chooseBroadcastStage,
8
+ programTracks,
9
+ type BroadcastRoomState,
10
+ type BroadcastStageInput,
11
+ } from "../broadcast/broadcastStage";
12
+
13
+ /**
14
+ * Which stage a live broadcast plays on, and how a viewer gets back to a
15
+ * picture when the good one is not available.
16
+ *
17
+ * Tested as PURE FUNCTIONS rather than by rendering. The realtime stage is a
18
+ * mediasoup transport over a WebSocket, and standing that up in jsdom tests the
19
+ * mock rather than the rule: the mock would answer whatever the test wanted, and
20
+ * the thing worth being sure about here is the RULE, which is four independent
21
+ * signals arriving at four different times and every combination of them
22
+ * resolving to a viewer who can see the broadcast.
23
+ *
24
+ * The table below is written out combination by combination on purpose. The
25
+ * failure this guards against is not a wrong answer to one question, it is a
26
+ * gap: a state nobody thought about that falls through to `realtime` and leaves
27
+ * somebody looking at a black rectangle while the show goes on without them.
28
+ */
29
+
30
+ const HLS_ONLY: BroadcastStageInput = {
31
+ realtimeAvailable: false,
32
+ credentialsReady: false,
33
+ credentialsError: null,
34
+ };
35
+
36
+ /** A viewer who is on the plane, connected, with a live room. */
37
+ const CONNECTED: BroadcastStageInput = {
38
+ realtimeAvailable: true,
39
+ credentialsReady: true,
40
+ credentialsError: null,
41
+ roomState: { connectionState: "connected", phase: "joined", recovering: false },
42
+ };
43
+
44
+ const room = (overrides: Partial<BroadcastRoomState> = {}): BroadcastRoomState => ({
45
+ connectionState: "connected",
46
+ phase: "joined",
47
+ recovering: false,
48
+ ...overrides,
49
+ });
50
+
51
+ const on = (roomState: BroadcastRoomState): BroadcastStageInput => ({ ...CONNECTED, roomState });
52
+
53
+ describe("chooseBroadcastStage: what the broadcast offers", () => {
54
+ it("plays HLS when the broadcast does not advertise realtime at all", () => {
55
+ expect(chooseBroadcastStage(HLS_ONLY)).toBe("hls");
56
+ });
57
+
58
+ it("REGRESSION: does not take the plane on the flag alone, before a ticket exists", () => {
59
+ // `realtime.available` says the room is open, never that THIS viewer got in:
60
+ // the cap is enforced by the token endpoint and nothing else knows how full
61
+ // the room is. Mounting on the flag would put the five-hundred-and-first
62
+ // viewer on a stage that is never going to connect.
63
+ //
64
+ // It waits rather than falling to HLS, which is the second half of the
65
+ // same rule: answering "hls" here mounted an HLS engine for the moment the
66
+ // ticket was in flight, and that engine starts fetching segments as soon
67
+ // as it exists, so a realtime viewer downloaded the whole broadcast a
68
+ // second time for a stage about to be thrown away.
69
+ expect(chooseBroadcastStage({ realtimeAvailable: true, credentialsReady: false, credentialsError: null })).toBe(
70
+ "pending",
71
+ );
72
+ });
73
+
74
+ it("never waits on a broadcast that does not offer realtime", () => {
75
+ // `pending` is only ever reachable when there is something to wait FOR.
76
+ // An ordinary broadcast must reach its player on the first render.
77
+ expect(chooseBroadcastStage({ realtimeAvailable: false, credentialsReady: false, credentialsError: null })).toBe(
78
+ "hls",
79
+ );
80
+ });
81
+
82
+ it("takes the plane once the flag and a minted ticket agree", () => {
83
+ expect(chooseBroadcastStage({ realtimeAvailable: true, credentialsReady: true, credentialsError: null })).toBe(
84
+ "realtime",
85
+ );
86
+ });
87
+
88
+ it("stays on HLS when the flag is off even if a ticket somehow exists", () => {
89
+ expect(chooseBroadcastStage({ realtimeAvailable: false, credentialsReady: true, credentialsError: null })).toBe(
90
+ "hls",
91
+ );
92
+ });
93
+ });
94
+
95
+ describe("chooseBroadcastStage: the token endpoint said no", () => {
96
+ /**
97
+ * 409 is the designed refusal (room full, realtime off, nothing live) and it
98
+ * is deliberately not privileged over the others. A viewer refused for any
99
+ * reason watches the same stream, so a rule that treated one status specially
100
+ * would be a branch with no behaviour behind it and one more place to be
101
+ * wrong.
102
+ */
103
+ const REFUSALS = [
104
+ { name: "409, the room is full", error: { status: 409, message: "room is full" } },
105
+ { name: "409, realtime is switched off", error: { status: 409 } },
106
+ { name: "401, the pass was not accepted", error: { status: 401 } },
107
+ { name: "404, no live room for this broadcast", error: { status: 404 } },
108
+ { name: "500, the server fell over", error: { status: 500 } },
109
+ { name: "a network failure with no status at all", error: {} },
110
+ ];
111
+
112
+ for (const refusal of REFUSALS) {
113
+ it(`falls back on ${refusal.name}`, () => {
114
+ const input: BroadcastStageInput = { ...CONNECTED, credentialsError: refusal.error };
115
+ expect(chooseBroadcastStage(input)).toBe("hls");
116
+ expect(broadcastFellBack(input)).toBe(true);
117
+ });
118
+ }
119
+
120
+ it("REGRESSION: a refusal outranks a ticket that was already minted", () => {
121
+ // This is the RECONNECT case. The probe succeeded, the viewer watched, and
122
+ // the ticket minted for the next attempt was refused because the room
123
+ // filled up or ended while they were in it. Reading `credentialsReady`
124
+ // first would keep them on a room they can no longer enter.
125
+ const input: BroadcastStageInput = { ...CONNECTED, credentialsError: { status: 409 } };
126
+ expect(chooseBroadcastStage(input)).toBe("hls");
127
+ });
128
+ });
129
+
130
+ describe("chooseBroadcastStage: what the SDK is doing", () => {
131
+ it("stays on the plane while the first connection is still in flight", () => {
132
+ expect(chooseBroadcastStage(on(room({ connectionState: "idle", phase: "idle" })))).toBe("realtime");
133
+ expect(chooseBroadcastStage(on(room({ connectionState: "connecting", phase: "idle" })))).toBe("realtime");
134
+ });
135
+
136
+ it("stays on the plane while the SDK is genuinely coming back", () => {
137
+ // A blink. Tearing the room down to swap in an HLS player would turn a
138
+ // second of held frame into a restart, and the SDK reconnects on its own.
139
+ const input = on(room({ connectionState: "reconnecting", recovering: true }));
140
+ expect(chooseBroadcastStage(input)).toBe("realtime");
141
+ expect(broadcastFellBack(input)).toBe(false);
142
+ });
143
+
144
+ it("REGRESSION: falls back once the reconnect policy has given up", () => {
145
+ // `connectionState` is `reconnecting` both while an attempt is booked and
146
+ // after the SDK has stopped booking them. Reading it without `recovering`
147
+ // leaves a viewer on a frozen frame for the rest of the broadcast.
148
+ const input = on(room({ connectionState: "reconnecting", recovering: false }));
149
+ expect(chooseBroadcastStage(input)).toBe("hls");
150
+ expect(broadcastFellBack(input)).toBe(true);
151
+ });
152
+
153
+ it("stays on the plane while the node is moving this viewer to another one", () => {
154
+ const draining: DisconnectCause = { type: "draining", reconnectAfterMs: 500 };
155
+ expect(chooseBroadcastStage(on(room({ connectionState: "reconnecting", recovering: true, error: draining })))).toBe(
156
+ "realtime",
157
+ );
158
+ });
159
+
160
+ it("falls back when a drain is NOT being recovered from", () => {
161
+ const draining: DisconnectCause = { type: "draining", reconnectAfterMs: 500 };
162
+ const input = on(room({ connectionState: "reconnecting", recovering: false, error: draining }));
163
+ expect(chooseBroadcastStage(input)).toBe("hls");
164
+ expect(broadcastFellBack(input)).toBe(true);
165
+ });
166
+
167
+ it("falls back when the node refuses the token, however hopeful the SDK is", () => {
168
+ // A refusal is not a network problem and retrying it changes nothing, so
169
+ // `recovering` does not rescue it.
170
+ const refused: DisconnectCause = { type: "refused", code: "capacity", message: "room is full" };
171
+ const input = on(room({ connectionState: "reconnecting", recovering: true, error: refused }));
172
+ expect(chooseBroadcastStage(input)).toBe("hls");
173
+ expect(broadcastFellBack(input)).toBe(true);
174
+ });
175
+
176
+ it("falls back when the room closes under a socket that is still open", () => {
177
+ // The broadcast ended. `connectionState` is still `connected` for as long
178
+ // as it takes the node to hang up, so reading only the connection would
179
+ // hold a viewer on a room that has finished.
180
+ const input = on(room({ connectionState: "connected", phase: "closed" }));
181
+ expect(chooseBroadcastStage(input)).toBe("hls");
182
+ expect(broadcastFellBack(input)).toBe(true);
183
+ });
184
+
185
+ it("falls back when the room close arrives as a disconnect cause", () => {
186
+ const closed: DisconnectCause = { type: "room_closed", reason: "broadcast ended" };
187
+ const input = on(room({ connectionState: "reconnecting", recovering: true, error: closed }));
188
+ expect(chooseBroadcastStage(input)).toBe("hls");
189
+ });
190
+
191
+ it("falls back when the connection is closed for good", () => {
192
+ const input = on(room({ connectionState: "closed" }));
193
+ expect(chooseBroadcastStage(input)).toBe("hls");
194
+ expect(broadcastFellBack(input)).toBe(true);
195
+ });
196
+
197
+ it("covers every connection state the SDK can report", () => {
198
+ // A state added to the SDK and forgotten here is the gap this guards: the
199
+ // list is exhaustive by construction, so a new one breaks the type rather
200
+ // than falling through to `realtime` unnoticed.
201
+ const expected: Record<ConnectionState, "realtime" | "hls"> = {
202
+ idle: "realtime",
203
+ connecting: "realtime",
204
+ connected: "realtime",
205
+ // With nothing booked. The recovering half is asserted above.
206
+ reconnecting: "hls",
207
+ closed: "hls",
208
+ };
209
+
210
+ for (const [connectionState, stage] of Object.entries(expected)) {
211
+ expect(chooseBroadcastStage(on(room({ connectionState: connectionState as ConnectionState })))).toBe(stage);
212
+ }
213
+ });
214
+ });
215
+
216
+ describe("broadcastFellBack: what the viewer is told", () => {
217
+ it("says nothing on a broadcast that never offered realtime", () => {
218
+ // Most broadcasts. A line saying "watching the standard stream" on every
219
+ // ordinary stream teaches viewers to ignore the one time it matters.
220
+ expect(broadcastFellBack(HLS_ONLY)).toBe(false);
221
+ });
222
+
223
+ it("says nothing while the ticket is still in flight", () => {
224
+ // It resolves in a moment. Announcing a fallback that is about to be
225
+ // withdrawn is a line that flickers on every single page load.
226
+ expect(broadcastFellBack({ realtimeAvailable: true, credentialsReady: false, credentialsError: null })).toBe(false);
227
+ });
228
+
229
+ it("says nothing while the viewer is actually on the plane", () => {
230
+ expect(broadcastFellBack(CONNECTED)).toBe(false);
231
+ });
232
+
233
+ it("never claims a fallback the stage did not actually make", () => {
234
+ // The two functions have to agree, because the line explains the stage. A
235
+ // notice over a realtime stage is nonsense, and a silent fallback is a
236
+ // viewer wondering why the chat is ahead of the picture.
237
+ const cases: BroadcastStageInput[] = [
238
+ HLS_ONLY,
239
+ CONNECTED,
240
+ { realtimeAvailable: true, credentialsReady: false, credentialsError: null },
241
+ { ...CONNECTED, credentialsError: { status: 409 } },
242
+ on(room({ connectionState: "reconnecting", recovering: false })),
243
+ on(room({ connectionState: "reconnecting", recovering: true })),
244
+ on(room({ phase: "closed" })),
245
+ on(room({ connectionState: "closed" })),
246
+ ];
247
+
248
+ for (const input of cases) {
249
+ if (broadcastFellBack(input)) expect(chooseBroadcastStage(input)).toBe("hls");
250
+ }
251
+ });
252
+ });
253
+
254
+ describe("programTracks", () => {
255
+ const producer = (overrides: Partial<ProducerEntry> & Pick<ProducerEntry, "producerId" | "kind">): ProducerEntry => ({
256
+ identity: "program:b1",
257
+ paused: false,
258
+ ...overrides,
259
+ });
260
+
261
+ it("finds nothing before the node has announced anything", () => {
262
+ expect(programTracks([])).toEqual({ videoPaused: false, audioProducerIds: [] });
263
+ });
264
+
265
+ it("pairs the program's video with its audio", () => {
266
+ const tracks = programTracks([
267
+ producer({ producerId: "v1", kind: "video" }),
268
+ producer({ producerId: "a1", kind: "audio" }),
269
+ ]);
270
+ expect(tracks.videoProducerId).toBe("v1");
271
+ expect(tracks.audioProducerIds).toEqual(["a1"]);
272
+ });
273
+
274
+ it("REGRESSION: does not lose the audio when the video arrives second", () => {
275
+ // `producerAppeared` frames have no ordering guarantee, so a rule that
276
+ // depended on the video being first would produce a silent broadcast for
277
+ // whichever half of viewers got the frames the other way round.
278
+ const tracks = programTracks([
279
+ producer({ producerId: "a1", kind: "audio" }),
280
+ producer({ producerId: "v1", kind: "video" }),
281
+ ]);
282
+ expect(tracks.videoProducerId).toBe("v1");
283
+ expect(tracks.audioProducerIds).toEqual(["a1"]);
284
+ });
285
+
286
+ it("keeps the FIRST video when a republish briefly announces two", () => {
287
+ // Picking the newer one would swap the element's stream under a viewer
288
+ // mid-sentence, for a producer that is about to disappear again.
289
+ const tracks = programTracks([
290
+ producer({ producerId: "v1", kind: "video" }),
291
+ producer({ producerId: "v2", kind: "video" }),
292
+ ]);
293
+ expect(tracks.videoProducerId).toBe("v1");
294
+ });
295
+
296
+ it("reports a video the publisher paused at source rather than hiding it", () => {
297
+ const tracks = programTracks([producer({ producerId: "v1", kind: "video", paused: true })]);
298
+ expect(tracks.videoProducerId).toBe("v1");
299
+ expect(tracks.videoPaused).toBe(true);
300
+ });
301
+
302
+ it("plays ONE soundtrack when the room briefly holds two audio producers", () => {
303
+ // A guard rather than a fix for anything observed: a republish after a
304
+ // reconnect can overlap the old producer's teardown, and two soundtracks
305
+ // is never the right answer, so the newer publication wins.
306
+ //
307
+ // The doubled audio that was actually reported came from the player
308
+ // mounting the HLS stage while the ticket was in flight, which is pinned
309
+ // by the `pending` cases above.
310
+ const tracks = programTracks([
311
+ producer({ producerId: "a1", kind: "audio" }),
312
+ producer({ producerId: "a2", kind: "audio" }),
313
+ ]);
314
+ expect(tracks.audioProducerIds).toEqual(["a2"]);
315
+ });
316
+ });
@@ -0,0 +1,127 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { existsSync, readFileSync, statSync } from "fs";
3
+ import { dirname, join, relative } from "path";
4
+
5
+ /**
6
+ * Nothing on the `./ui` entry's module graph may pull in the media SDK.
7
+ *
8
+ * Forge renders inside TanStack Start on Cloudflare Workers, and `./ui` is the
9
+ * entry EVERY site loads on EVERY page, server side included.
10
+ * `@tribe-nest/media-client` (through `mediasoup-client`) touches
11
+ * `RTCPeerConnection` at module scope and does not exist on a server, so one
12
+ * static import anywhere in this graph takes down the server render of every
13
+ * page of every site, including the sites that have never held a broadcast.
14
+ *
15
+ * That is not a hypothetical shape. The graph already dodges it once on
16
+ * purpose: `AccountDashboard` decides whether to draw a Join button by
17
+ * importing `media/bookingWindow`, a module whose own docblock says it imports
18
+ * nothing from the SDK for exactly this reason, rather than the neighbouring
19
+ * `media/index.ts` that does. `BroadcastPlayer` now dodges it a second time, by
20
+ * reaching its realtime stage through a dynamic `import()` fired from an
21
+ * effect. Both are one careless auto-import away from being undone, and neither
22
+ * fails a type check, a lint or any other spec when it is.
23
+ *
24
+ * `./media` is the separate entry that a site opts into (`BookingCallProvider`,
25
+ * `CallStage`), and it may import the SDK freely. This walk never enters it
26
+ * except through the files `./ui` actually names.
27
+ *
28
+ * A dynamic `import()` is deliberately NOT followed. It is the mechanism this
29
+ * guards, and following it would report the very thing that makes the player
30
+ * safe as the defect.
31
+ */
32
+
33
+ /** `src`, three levels up from `src/ui/styled/_tests`. */
34
+ const SRC = join(__dirname, "..", "..", "..");
35
+
36
+ /** Packages that cannot be loaded on a server, whatever the bundler does. */
37
+ const BROWSER_ONLY = ["@tribe-nest/media-client", "mediasoup-client"];
38
+
39
+ /**
40
+ * Static imports only, and whether the compiler erases them.
41
+ *
42
+ * `import type ...` is gone before a bundler ever sees it, so it is free: the
43
+ * whole realtime path is typed against the SDK and none of that types reaches a
44
+ * server. Only the leading `import type` form counts as erased here. The
45
+ * per-specifier `import { type X }` form is erased too, but reading it
46
+ * correctly means parsing the clause, and being wrong in THAT direction is a
47
+ * guard that waves a real edge through. So it is treated as a value edge, which
48
+ * costs a walk of a few more files and nothing else.
49
+ */
50
+ const STATIC_IMPORT = /(?:^|\n)[ \t]*(?:import|export)\s+(type\s+)?[^;]*?\bfrom\s*["']([^"']+)["']/g;
51
+ const SIDE_EFFECT_IMPORT = /(?:^|\n)[ \t]*import\s*["']([^"']+)["']/g;
52
+
53
+ type Edge = { specifier: string; typeOnly: boolean };
54
+
55
+ function edgesIn(contents: string): Edge[] {
56
+ const edges: Edge[] = [];
57
+ for (const match of contents.matchAll(STATIC_IMPORT)) {
58
+ edges.push({ specifier: match[2]!, typeOnly: !!match[1] });
59
+ }
60
+ for (const match of contents.matchAll(SIDE_EFFECT_IMPORT)) {
61
+ edges.push({ specifier: match[1]!, typeOnly: false });
62
+ }
63
+ return edges;
64
+ }
65
+
66
+ /** `./x` to a real file, trying the extensions a bundler would try. */
67
+ function resolveRelative(fromFile: string, specifier: string): string | null {
68
+ const base = join(dirname(fromFile), specifier);
69
+ for (const candidate of [base, `${base}.ts`, `${base}.tsx`, join(base, "index.ts"), join(base, "index.tsx")]) {
70
+ if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
71
+ }
72
+ return null;
73
+ }
74
+
75
+ /** Every file reachable from an entry by STATIC value imports, and how. */
76
+ function valueGraph(entry: string): Map<string, string[]> {
77
+ const paths = new Map<string, string[]>([[entry, [relative(SRC, entry)]]]);
78
+ const queue = [entry];
79
+
80
+ while (queue.length > 0) {
81
+ const file = queue.shift()!;
82
+ const trail = paths.get(file)!;
83
+ for (const edge of edgesIn(readFileSync(file, "utf-8"))) {
84
+ if (edge.typeOnly) continue;
85
+ if (!edge.specifier.startsWith(".")) continue;
86
+ const resolved = resolveRelative(file, edge.specifier);
87
+ if (!resolved || paths.has(resolved)) continue;
88
+ paths.set(resolved, [...trail, relative(SRC, resolved)]);
89
+ queue.push(resolved);
90
+ }
91
+ }
92
+ return paths;
93
+ }
94
+
95
+ describe("the ./ui entry stays loadable on a server", () => {
96
+ const graph = valueGraph(join(SRC, "ui", "index.ts"));
97
+
98
+ it("reaches a real graph, so a pass here is not vacuous", () => {
99
+ expect(graph.size).toBeGreaterThan(50);
100
+ expect([...graph.keys()].some((file) => file.endsWith("BroadcastPlayer.tsx"))).toBe(true);
101
+ });
102
+
103
+ it("REGRESSION: imports no browser-only package anywhere on it", () => {
104
+ const offences: string[] = [];
105
+
106
+ for (const [file, trail] of graph) {
107
+ for (const edge of edgesIn(readFileSync(file, "utf-8"))) {
108
+ if (edge.typeOnly) continue;
109
+ if (!BROWSER_ONLY.some((name) => edge.specifier === name || edge.specifier.startsWith(`${name}/`))) continue;
110
+ offences.push(`${edge.specifier} via ${trail.join(" -> ")}`);
111
+ }
112
+ }
113
+
114
+ expect(offences).toEqual([]);
115
+ });
116
+
117
+ it("REGRESSION: keeps the realtime stage behind a dynamic import", () => {
118
+ // The player must never name this module in a static value import. It is
119
+ // the one file under `styled/` that loads the SDK, and the `import()` in
120
+ // the effect is what keeps it off every server render.
121
+ const player = readFileSync(join(SRC, "ui", "styled", "broadcast", "BroadcastPlayer.tsx"), "utf-8");
122
+ expect(player).toContain('import("./BroadcastRealtimeStage")');
123
+
124
+ const staticEdges = edgesIn(player).filter((edge) => edge.specifier.includes("BroadcastRealtimeStage"));
125
+ expect(staticEdges.every((edge) => edge.typeOnly)).toBe(true);
126
+ });
127
+ });