@tribe-nest/forge 3.29.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.
- package/package.json +6 -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/PublicAuthContext.tsx +34 -5
- package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
- package/src/data/queries/useBroadcasts.ts +151 -0
- package/src/data/queries/useMyBookings.ts +9 -1
- package/src/i18n/de.json +59 -0
- package/src/i18n/en.json +59 -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 +36 -0
- package/src/ui/media/CallHelpHint.tsx +87 -0
- package/src/ui/media/CallStage.tsx +542 -0
- package/src/ui/media/_tests/CallStage.spec.tsx +685 -0
- package/src/ui/media/_tests/bookingSession.spec.tsx +179 -0
- package/src/ui/media/_tests/callState.spec.ts +452 -0
- package/src/ui/media/_tests/fakeNode.ts +178 -0
- package/src/ui/media/bookingSession.tsx +194 -0
- package/src/ui/media/callState.ts +341 -0
- package/src/ui/media/index.ts +135 -0
- package/src/ui/styled/AccountDashboard.tsx +92 -3
- 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/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
|
@@ -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
|
+
});
|