@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.
- package/package.json +11 -3
- package/src/_tests/publishedResolvability.spec.ts +184 -0
- package/src/_tests/specsRunWorkspaceSource.spec.ts +116 -0
- package/src/_tests/workspaceAliases.ts +40 -0
- package/src/contexts/CartContext.tsx +17 -1
- package/src/contexts/PublicAuthContext.tsx +34 -5
- package/src/contexts/_tests/CartContext.spec.tsx +36 -0
- package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
- package/src/data/queries/useBroadcasts.ts +151 -0
- package/src/data/queries/useMyBookings.ts +18 -1
- package/src/i18n/_tests/translationKeys.spec.ts +15 -0
- package/src/i18n/de.json +97 -0
- package/src/i18n/en.json +97 -0
- package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
- package/src/ui/format/_tests/pwyw.spec.ts +65 -8
- package/src/ui/format/membershipPwyw.ts +164 -0
- package/src/ui/format/pwyw.ts +37 -0
- package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
- package/src/ui/headless/broadcast/broadcastState.ts +158 -0
- package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
- package/src/ui/headless/event/useEventCheckout.ts +8 -13
- package/src/ui/headless/index.ts +14 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
- package/src/ui/index.ts +61 -0
- package/src/ui/media/BookingCallScreen.tsx +33 -0
- package/src/ui/media/CallHelpHint.tsx +87 -0
- package/src/ui/media/CallStage.tsx +633 -0
- package/src/ui/media/_tests/CallStage.spec.tsx +931 -0
- package/src/ui/media/_tests/bookingSession.spec.tsx +227 -0
- package/src/ui/media/_tests/callState.spec.ts +499 -0
- package/src/ui/media/_tests/fakeNode.ts +178 -0
- package/src/ui/media/bookingSession.tsx +182 -0
- package/src/ui/media/bookingWindow.ts +81 -0
- package/src/ui/media/callState.ts +360 -0
- package/src/ui/media/index.ts +135 -0
- package/src/ui/styled/AccountDashboard.tsx +193 -4
- package/src/ui/styled/BroadcastWatch.tsx +107 -0
- package/src/ui/styled/ForgotPasswordForm.tsx +5 -0
- package/src/ui/styled/LiveBroadcastList.tsx +171 -0
- package/src/ui/styled/LoginForm.tsx +10 -0
- package/src/ui/styled/MembershipCheckout.tsx +318 -45
- package/src/ui/styled/MembershipTiers.tsx +10 -3
- package/src/ui/styled/ResetPasswordForm.tsx +5 -0
- package/src/ui/styled/SignupForm.tsx +5 -0
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +166 -0
- package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
- package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
- package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
- package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
- package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
- package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
- package/src/ui/styled/community/CommunityComposer.tsx +182 -3
- package/src/ui/styled/community/CommunityFeed.tsx +36 -51
- package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
- package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
- package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +175 -0
- 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
|
+
};
|