@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,179 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { renderHook } from "@testing-library/react";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
BOOKING_CALL_CLOSES_MINUTES_AFTER,
|
|
7
|
+
BOOKING_CALL_OPENS_MINUTES_BEFORE,
|
|
8
|
+
bookingCallWindow,
|
|
9
|
+
useBookingSessionCredentials,
|
|
10
|
+
type BookingCallSubject,
|
|
11
|
+
} from "../bookingSession";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The Forge context, stubbed at the seam the hook actually reads.
|
|
15
|
+
*
|
|
16
|
+
* `ForgeClientProvider` builds its Axios instance from an `apiUrl`, so there is
|
|
17
|
+
* no way to hand it a fake transport from outside; mocking `useForge` is the
|
|
18
|
+
* only join where a spec can see the request the hook makes.
|
|
19
|
+
*/
|
|
20
|
+
const { post, forgeContext } = vi.hoisted(() => {
|
|
21
|
+
const post = vi.fn();
|
|
22
|
+
// ONE object across renders, like the real provider's memoized client. A
|
|
23
|
+
// fresh one per render would change the hook's dependency and make the
|
|
24
|
+
// identity assertion below fail against a correct implementation.
|
|
25
|
+
return { post, forgeContext: { client: { post }, profileId: "11111111-1111-4111-8111-111111111111" } };
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
vi.mock("../../../provider/ForgeProvider", () => ({ useForge: () => forgeContext }));
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The booking half of the media barrel.
|
|
32
|
+
*
|
|
33
|
+
* Two things are worth testing here and nothing else is. `bookingCallWindow` is
|
|
34
|
+
* a total function that decides whether a Join control is drawn at all, and
|
|
35
|
+
* getting it wrong shows a client a button that 409s or hides one from a
|
|
36
|
+
* session that has started. `useBookingSessionCredentials` is a transport, and
|
|
37
|
+
* the property that matters is that it posts to the credential endpoint with
|
|
38
|
+
* NO body: the endpoint's schema is `.strict()`, so a field added here in a
|
|
39
|
+
* moment of helpfulness would 400 every call in production.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
const MINUTE = 60_000;
|
|
43
|
+
const HOUR = 60 * MINUTE;
|
|
44
|
+
|
|
45
|
+
const booking = (overrides: Partial<BookingCallSubject> = {}): BookingCallSubject => ({
|
|
46
|
+
status: "confirmed",
|
|
47
|
+
sessionStartTime: new Date("2026-08-20T10:00:00.000Z").toISOString(),
|
|
48
|
+
sessionEndTime: new Date("2026-08-20T11:00:00.000Z").toISOString(),
|
|
49
|
+
location: { type: "video" },
|
|
50
|
+
...overrides,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const at = (iso: string) => new Date(iso);
|
|
54
|
+
|
|
55
|
+
describe("the join window, as literal numbers", () => {
|
|
56
|
+
/**
|
|
57
|
+
* The one place in this file that does NOT compute its expectation from the
|
|
58
|
+
* constants.
|
|
59
|
+
*
|
|
60
|
+
* Everything below asserts `opensAt` against
|
|
61
|
+
* `BOOKING_CALL_OPENS_MINUTES_BEFORE`, which is correct for what those tests
|
|
62
|
+
* are about (the arithmetic) and completely blind to the value: changing 15 to
|
|
63
|
+
* 60 leaves the whole suite green. The server carries its OWN pair of literals
|
|
64
|
+
* in `apps/backend/src/services/public/coaching/commands/bookingSession.ts`
|
|
65
|
+
* (`SESSION_JOIN_OPENS_MINUTES_BEFORE` / `SESSION_JOIN_CLOSES_MINUTES_AFTER`),
|
|
66
|
+
* whose spec had exactly the same self-referential shape, so a change on
|
|
67
|
+
* either side was invisible to both suites.
|
|
68
|
+
*
|
|
69
|
+
* The server's answer is the one that counts. When these two disagree the
|
|
70
|
+
* visible result is a Join button drawn 45 minutes before the server will mint
|
|
71
|
+
* a ticket (a client clicks and gets a bare 409), or a button hidden while the
|
|
72
|
+
* room is open. So both sides pin the value, and moving it means editing two
|
|
73
|
+
* specs that name each other.
|
|
74
|
+
*/
|
|
75
|
+
it("mirrors the server: 15 minutes before, 120 minutes after", () => {
|
|
76
|
+
expect(BOOKING_CALL_OPENS_MINUTES_BEFORE).toBe(15);
|
|
77
|
+
expect(BOOKING_CALL_CLOSES_MINUTES_AFTER).toBe(120);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("bookingCallWindow", () => {
|
|
82
|
+
it("opens exactly the stated number of minutes before the session", () => {
|
|
83
|
+
const subject = booking();
|
|
84
|
+
const opensAt = new Date(new Date(subject.sessionStartTime).getTime() - BOOKING_CALL_OPENS_MINUTES_BEFORE * MINUTE);
|
|
85
|
+
|
|
86
|
+
expect(bookingCallWindow(subject).opensAt.toISOString()).toBe(opensAt.toISOString());
|
|
87
|
+
// One millisecond either side of the boundary, so an off-by-one in the
|
|
88
|
+
// comparison cannot pass: a client refreshing at the advertised minute must
|
|
89
|
+
// get in, and one refreshing a moment earlier must not.
|
|
90
|
+
expect(bookingCallWindow(subject, new Date(opensAt.getTime() - 1)).isOpen).toBe(false);
|
|
91
|
+
expect(bookingCallWindow(subject, opensAt).isOpen).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("stays open after the hour ends, then shuts", () => {
|
|
95
|
+
const subject = booking();
|
|
96
|
+
const closesAt = new Date(new Date(subject.sessionEndTime).getTime() + BOOKING_CALL_CLOSES_MINUTES_AFTER * MINUTE);
|
|
97
|
+
|
|
98
|
+
expect(bookingCallWindow(subject).closesAt.toISOString()).toBe(closesAt.toISOString());
|
|
99
|
+
// A session that runs over must not be cut off, which is what the window
|
|
100
|
+
// after the end is for.
|
|
101
|
+
expect(bookingCallWindow(subject, at("2026-08-20T11:30:00.000Z")).isOpen).toBe(true);
|
|
102
|
+
expect(bookingCallWindow(subject, closesAt).isOpen).toBe(true);
|
|
103
|
+
expect(bookingCallWindow(subject, new Date(closesAt.getTime() + 1)).isOpen).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("is not a video call when the location is anything else", () => {
|
|
107
|
+
const midSession = at("2026-08-20T10:30:00.000Z");
|
|
108
|
+
|
|
109
|
+
for (const location of [{ type: "online" }, { type: "in_person" }, null, undefined]) {
|
|
110
|
+
const window = bookingCallWindow(booking({ location }), midSession);
|
|
111
|
+
expect(window.isVideo).toBe(false);
|
|
112
|
+
// Never open, however good the timing is: there is no room to enter.
|
|
113
|
+
expect(window.isOpen).toBe(false);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("is not a video call when the booking is not confirmed", () => {
|
|
118
|
+
const midSession = at("2026-08-20T10:30:00.000Z");
|
|
119
|
+
// A cancelled session has no room. The server withholds `location`
|
|
120
|
+
// entirely for one, so this is belt and braces against a cached row.
|
|
121
|
+
const window = bookingCallWindow(booking({ status: "canceled" }), midSession);
|
|
122
|
+
expect(window.isVideo).toBe(false);
|
|
123
|
+
expect(window.isOpen).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("holds for a session whose window straddles a day boundary", () => {
|
|
127
|
+
const subject = booking({
|
|
128
|
+
sessionStartTime: "2026-08-20T23:50:00.000Z",
|
|
129
|
+
sessionEndTime: "2026-08-21T00:50:00.000Z",
|
|
130
|
+
});
|
|
131
|
+
expect(bookingCallWindow(subject, at("2026-08-20T23:40:00.000Z")).isOpen).toBe(true);
|
|
132
|
+
expect(bookingCallWindow(subject, at("2026-08-20T23:34:00.000Z")).isOpen).toBe(false);
|
|
133
|
+
expect(bookingCallWindow(subject, at("2026-08-21T02:49:00.000Z")).isOpen).toBe(true);
|
|
134
|
+
expect(bookingCallWindow(subject, at("2026-08-21T02:51:00.000Z")).isOpen).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("useBookingSessionCredentials", () => {
|
|
139
|
+
beforeEach(() => {
|
|
140
|
+
post.mockReset();
|
|
141
|
+
post.mockResolvedValue({
|
|
142
|
+
data: {
|
|
143
|
+
mediaUrl: "wss://media.test",
|
|
144
|
+
token: "tok",
|
|
145
|
+
expiresAt: "2026-08-20T10:10:00.000Z",
|
|
146
|
+
identity: "guest-acc-1",
|
|
147
|
+
roomId: "booking-booking-1",
|
|
148
|
+
grants: { canPublish: true, canSubscribe: true, canPublishData: true },
|
|
149
|
+
role: "guest",
|
|
150
|
+
roomOpensAt: "2026-08-20T09:45:00.000Z",
|
|
151
|
+
roomClosesAt: "2026-08-20T13:00:00.000Z",
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("posts an EMPTY body to this booking's credential endpoint", async () => {
|
|
157
|
+
const getCredentials = renderHook(() => useBookingSessionCredentials("booking-1")).result.current;
|
|
158
|
+
|
|
159
|
+
const credentials = await getCredentials();
|
|
160
|
+
|
|
161
|
+
expect(post).toHaveBeenCalledTimes(1);
|
|
162
|
+
expect(post).toHaveBeenCalledWith("/public/coaching/bookings/booking-1/session/join", {});
|
|
163
|
+
// The endpoint's schema is `.strict()` and declares no fields at all, so a
|
|
164
|
+
// `profileId` added here in a moment of helpfulness would 400 every call.
|
|
165
|
+
// Asserting the exact second argument is what catches that.
|
|
166
|
+
expect(credentials.roomId).toBe("booking-booking-1");
|
|
167
|
+
expect(credentials.role).toBe("guest");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("keeps ONE callback identity per booking", () => {
|
|
171
|
+
const { result, rerender } = renderHook(() => useBookingSessionCredentials("booking-1"));
|
|
172
|
+
const first = result.current;
|
|
173
|
+
rerender();
|
|
174
|
+
// `<MediaRoomProvider>` holds its options in a ref because an inline arrow
|
|
175
|
+
// would be a new function every render; a callback that changed identity on
|
|
176
|
+
// every parent render would defeat that and reconnect a live call.
|
|
177
|
+
expect(result.current).toBe(first);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -0,0 +1,452 @@
|
|
|
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 { callHeadCount, callStatus, callTiles, canPublishSource, remoteAudioProducerIds } from "../callState";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The two decisions a call screen gets wrong.
|
|
10
|
+
*
|
|
11
|
+
* Both of them are invisible in a component test that only checks "did it
|
|
12
|
+
* render", and both of them are the difference between a call that works and a
|
|
13
|
+
* call that looks broken while working perfectly.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const peer = (identity: string, name?: string): Peer => ({
|
|
17
|
+
identity,
|
|
18
|
+
kind: "human",
|
|
19
|
+
...(name === undefined ? {} : { name }),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/** The recording process, which joins the room as a real peer. */
|
|
23
|
+
const recorder = (identity: string): Peer => ({ identity, kind: "egress" });
|
|
24
|
+
|
|
25
|
+
const producer = (
|
|
26
|
+
producerId: string,
|
|
27
|
+
identity: string,
|
|
28
|
+
kind: "audio" | "video",
|
|
29
|
+
paused = false,
|
|
30
|
+
): ProducerEntry => ({ producerId, identity, kind, paused });
|
|
31
|
+
|
|
32
|
+
describe("callTiles keeps the grid from collapsing", () => {
|
|
33
|
+
it("draws a tile for somebody with NO video at all", () => {
|
|
34
|
+
const tiles = callTiles({
|
|
35
|
+
identity: "me",
|
|
36
|
+
peers: [peer("anna", "Anna")],
|
|
37
|
+
producers: [producer("p-audio", "anna", "audio")],
|
|
38
|
+
activeSpeakers: [],
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// The whole point. A participant with the camera off has not left the room,
|
|
42
|
+
// and a grid built by mapping over video tracks drops them silently.
|
|
43
|
+
expect(tiles).toHaveLength(1);
|
|
44
|
+
expect(tiles[0]!.name).toBe("Anna");
|
|
45
|
+
expect(tiles[0]!.videoProducerId).toBeUndefined();
|
|
46
|
+
expect(tiles[0]!.hasAudio).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("keeps the SAME number of tiles when a camera goes off", () => {
|
|
50
|
+
const peers = [peer("anna", "Anna"), peer("ben", "Ben")];
|
|
51
|
+
const withCamera = callTiles({
|
|
52
|
+
identity: "me",
|
|
53
|
+
peers,
|
|
54
|
+
producers: [producer("p-anna-video", "anna", "video"), producer("p-ben-audio", "ben", "audio")],
|
|
55
|
+
activeSpeakers: [],
|
|
56
|
+
});
|
|
57
|
+
const cameraOff = callTiles({
|
|
58
|
+
identity: "me",
|
|
59
|
+
peers,
|
|
60
|
+
producers: [producer("p-ben-audio", "ben", "audio")],
|
|
61
|
+
activeSpeakers: [],
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
expect(withCamera).toHaveLength(2);
|
|
65
|
+
// Two before, two after. This is the assertion that fails on the bug.
|
|
66
|
+
expect(cameraOff).toHaveLength(2);
|
|
67
|
+
expect(cameraOff.map((t) => t.identity)).toEqual(["anna", "ben"]);
|
|
68
|
+
// And the key is unchanged, so React updates the tile rather than
|
|
69
|
+
// unmounting and remounting it under the viewer's eyes.
|
|
70
|
+
expect(cameraOff[0]!.key).toBe(withCamera[0]!.key);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("never draws the local participant, who has their own preview", () => {
|
|
74
|
+
const tiles = callTiles({
|
|
75
|
+
identity: "me",
|
|
76
|
+
// The node may or may not include us in `peers`; both have to be safe.
|
|
77
|
+
peers: [peer("me", "Me"), peer("anna", "Anna")],
|
|
78
|
+
producers: [producer("p-mine", "me", "video")],
|
|
79
|
+
activeSpeakers: [],
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
expect(tiles.map((t) => t.identity)).toEqual(["anna"]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("gives a second video stream its own tile rather than replacing the face", () => {
|
|
86
|
+
const tiles = callTiles({
|
|
87
|
+
identity: "me",
|
|
88
|
+
peers: [peer("anna", "Anna")],
|
|
89
|
+
producers: [producer("p-cam", "anna", "video"), producer("p-screen", "anna", "video")],
|
|
90
|
+
activeSpeakers: [],
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(tiles).toHaveLength(2);
|
|
94
|
+
expect(tiles.map((t) => t.videoProducerId)).toEqual(["p-cam", "p-screen"]);
|
|
95
|
+
// Same person, so the same name on both. The wire does not label which is
|
|
96
|
+
// the screen, so neither does this.
|
|
97
|
+
expect(tiles.every((t) => t.name === "Anna")).toBe(true);
|
|
98
|
+
expect(new Set(tiles.map((t) => t.key)).size).toBe(2);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("draws a stream whose peer frame has not arrived yet", () => {
|
|
102
|
+
// `producerAppeared` and `peerJoined` have no ordering guarantee. Dropping
|
|
103
|
+
// the stream would blank a tile that has media in it.
|
|
104
|
+
const tiles = callTiles({
|
|
105
|
+
identity: "me",
|
|
106
|
+
peers: [],
|
|
107
|
+
producers: [producer("p-early", "carla", "video")],
|
|
108
|
+
activeSpeakers: [],
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(tiles).toHaveLength(1);
|
|
112
|
+
expect(tiles[0]!.identity).toBe("carla");
|
|
113
|
+
expect(tiles[0]!.videoProducerId).toBe("p-early");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("falls back to the identity, which tells two unnamed people apart", () => {
|
|
117
|
+
const tiles = callTiles({
|
|
118
|
+
identity: "me",
|
|
119
|
+
peers: [peer("guest-1"), peer("guest-2", " ")],
|
|
120
|
+
producers: [],
|
|
121
|
+
activeSpeakers: [],
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(tiles.map((t) => t.name)).toEqual(["guest-1", "guest-2"]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("marks the speaker from the node's producer ids, not from identities", () => {
|
|
128
|
+
const tiles = callTiles({
|
|
129
|
+
identity: "me",
|
|
130
|
+
peers: [peer("anna", "Anna"), peer("ben", "Ben")],
|
|
131
|
+
producers: [
|
|
132
|
+
producer("p-anna-audio", "anna", "audio"),
|
|
133
|
+
producer("p-anna-video", "anna", "video"),
|
|
134
|
+
producer("p-ben-audio", "ben", "audio"),
|
|
135
|
+
],
|
|
136
|
+
// The node sends PRODUCER ids. Matching these against identities would
|
|
137
|
+
// light up nobody, and the indication would simply never appear.
|
|
138
|
+
activeSpeakers: ["p-anna-audio"],
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(tiles.find((t) => t.identity === "anna")!.isSpeaking).toBe(true);
|
|
142
|
+
expect(tiles.find((t) => t.identity === "ben")!.isSpeaking).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("carries the publisher's own pause through", () => {
|
|
146
|
+
const tiles = callTiles({
|
|
147
|
+
identity: "me",
|
|
148
|
+
peers: [peer("anna", "Anna")],
|
|
149
|
+
producers: [producer("p-cam", "anna", "video", true)],
|
|
150
|
+
activeSpeakers: [],
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(tiles[0]!.videoPaused).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("callHeadCount counts PEOPLE, not tiles", () => {
|
|
158
|
+
it("does not gain a person when somebody shares their screen", () => {
|
|
159
|
+
// A coach and a client: two people. The coach shares a screen, which by
|
|
160
|
+
// `callTiles`' own design gives them a second tile, and a count taken from
|
|
161
|
+
// `tiles.length` announced a third party in a two-person call at exactly
|
|
162
|
+
// the moment a coaching call is most likely to have one.
|
|
163
|
+
const tiles = callTiles({
|
|
164
|
+
identity: "me",
|
|
165
|
+
peers: [peer("anna", "Anna")],
|
|
166
|
+
producers: [producer("p-cam", "anna", "video"), producer("p-screen", "anna", "video")],
|
|
167
|
+
activeSpeakers: [],
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
expect(tiles).toHaveLength(2);
|
|
171
|
+
expect(callHeadCount(tiles)).toBe(2);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("counts everyone once, including the viewer, and the viewer alone in an empty room", () => {
|
|
175
|
+
expect(callHeadCount([])).toBe(1);
|
|
176
|
+
|
|
177
|
+
const tiles = callTiles({
|
|
178
|
+
identity: "me",
|
|
179
|
+
peers: [peer("anna", "Anna"), peer("ben", "Ben")],
|
|
180
|
+
producers: [producer("p-anna-audio", "anna", "audio"), producer("p-ben-video", "ben", "video")],
|
|
181
|
+
activeSpeakers: [],
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
expect(callHeadCount(tiles)).toBe(3);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The recorder is not somebody who arrived.
|
|
189
|
+
*
|
|
190
|
+
* A recording joins as a real peer with `kind: "egress"`, so a grid built
|
|
191
|
+
* from `peers` drew a third tile called `egress-9f2c` in a 1:1 session and
|
|
192
|
+
* the header read "In this call: 3" - at exactly the moment a coach is most
|
|
193
|
+
* likely to be reading that number, and it is the number they use to answer
|
|
194
|
+
* "has my client actually arrived". `<RecordingIndicator>` already says the
|
|
195
|
+
* true thing, in words, as content.
|
|
196
|
+
*/
|
|
197
|
+
it("does not count the recording as a person in the room", () => {
|
|
198
|
+
const tiles = callTiles({
|
|
199
|
+
identity: "me",
|
|
200
|
+
peers: [peer("anna", "Anna"), recorder("egress-9f2c")],
|
|
201
|
+
producers: [producer("p-anna-audio", "anna", "audio")],
|
|
202
|
+
activeSpeakers: [],
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
expect(tiles.map((t) => t.identity)).toEqual(["anna"]);
|
|
206
|
+
// A coach and their client. Two.
|
|
207
|
+
expect(callHeadCount(tiles)).toBe(2);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("keeps a dial-in caller and an agent, which are somebody to talk to", () => {
|
|
211
|
+
const tiles = callTiles({
|
|
212
|
+
identity: "me",
|
|
213
|
+
peers: [
|
|
214
|
+
{ identity: "+4915112345678", kind: "sip" },
|
|
215
|
+
{ identity: "notetaker", kind: "agent", name: "Notes" },
|
|
216
|
+
],
|
|
217
|
+
producers: [],
|
|
218
|
+
activeSpeakers: [],
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// Only `egress` is excluded, and only because it is a recorder rather than
|
|
222
|
+
// a participant. Dropping every non-human would hide a caller who dialled in.
|
|
223
|
+
expect(tiles.map((t) => t.identity)).toEqual(["+4915112345678", "notetaker"]);
|
|
224
|
+
expect(callHeadCount(tiles)).toBe(3);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe("remoteAudioProducerIds", () => {
|
|
229
|
+
it("takes everyone else's audio and none of our own", () => {
|
|
230
|
+
const ids = remoteAudioProducerIds({
|
|
231
|
+
identity: "me",
|
|
232
|
+
producers: [
|
|
233
|
+
producer("p-mine", "me", "audio"),
|
|
234
|
+
producer("p-anna", "anna", "audio"),
|
|
235
|
+
producer("p-anna-video", "anna", "video"),
|
|
236
|
+
],
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// Our own audio played back is a feedback loop, and a video producer
|
|
240
|
+
// attached to an `<audio>` element is silence.
|
|
241
|
+
expect(ids).toEqual(["p-anna"]);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe("callStatus is honest about what is happening", () => {
|
|
246
|
+
it("separates a reconnect from a refusal", () => {
|
|
247
|
+
const dropped = callStatus({
|
|
248
|
+
connectionState: "reconnecting",
|
|
249
|
+
error: { type: "socket_closed", code: 1006 },
|
|
250
|
+
recovering: true,
|
|
251
|
+
});
|
|
252
|
+
const refused = callStatus({ connectionState: "reconnecting", error: { type: "refused", code: "unauthorized" } });
|
|
253
|
+
|
|
254
|
+
// The room reports ONE state for both. They are completely different things
|
|
255
|
+
// to the person looking at the screen: one is worth waiting through, the
|
|
256
|
+
// other is worth pressing a button about.
|
|
257
|
+
expect(dropped.tone).toBe("reconnecting");
|
|
258
|
+
expect(refused.tone).toBe("failed");
|
|
259
|
+
expect(dropped.headline).not.toBe(refused.headline);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("shows the server's own words for a refusal rather than a friendly lie", () => {
|
|
263
|
+
expect(callStatus({ connectionState: "reconnecting", error: { type: "refused", code: "room_closed" } }).detail).toBe(
|
|
264
|
+
"room_closed",
|
|
265
|
+
);
|
|
266
|
+
expect(
|
|
267
|
+
callStatus({
|
|
268
|
+
connectionState: "reconnecting",
|
|
269
|
+
error: { type: "refused", code: "unauthorized", message: "this session has ended" },
|
|
270
|
+
}).detail,
|
|
271
|
+
).toBe("this session has ended");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("does not offer a retry for something retrying cannot fix", () => {
|
|
275
|
+
expect(callStatus({ connectionState: "connected", error: undefined }).canRetry).toBe(false);
|
|
276
|
+
expect(callStatus({ connectionState: "connecting", error: undefined }).canRetry).toBe(false);
|
|
277
|
+
expect(callStatus({ connectionState: "closed", error: { type: "closed_by_client" } }).canRetry).toBe(false);
|
|
278
|
+
expect(
|
|
279
|
+
callStatus({ connectionState: "reconnecting", error: { type: "room_closed", reason: "host ended" } }).canRetry,
|
|
280
|
+
).toBe(false);
|
|
281
|
+
// A node moving us off itself reconnects on its own; a button would be a
|
|
282
|
+
// second connect racing the first. WHILE it is actually moving us: the
|
|
283
|
+
// promise and the control are two halves of one decision.
|
|
284
|
+
expect(
|
|
285
|
+
callStatus({
|
|
286
|
+
connectionState: "reconnecting",
|
|
287
|
+
error: { type: "draining", reconnectAfterMs: 500 },
|
|
288
|
+
recovering: true,
|
|
289
|
+
}).canRetry,
|
|
290
|
+
).toBe(false);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* A spinner is a promise, and it has to be one somebody is keeping.
|
|
295
|
+
*
|
|
296
|
+
* "Moving you to another server" and "Connection lost, reconnecting" both say
|
|
297
|
+
* "wait, this is being handled". Said over a stack where nothing is coming
|
|
298
|
+
* back, they are the worst screen in the product: an ordinary deploy drains a
|
|
299
|
+
* node, everyone on it reads that nobody was dropped, and the only way back
|
|
300
|
+
* into a paid coaching hour is working out for yourself that you should
|
|
301
|
+
* reload the page. So when `recovering` is false, the words change and a
|
|
302
|
+
* control appears.
|
|
303
|
+
*/
|
|
304
|
+
describe("it does not promise a recovery that is not happening", () => {
|
|
305
|
+
it("offers a way back when a drain has nowhere to move you to", () => {
|
|
306
|
+
const moving = callStatus({
|
|
307
|
+
connectionState: "reconnecting",
|
|
308
|
+
error: { type: "draining", reconnectAfterMs: 500 },
|
|
309
|
+
recovering: true,
|
|
310
|
+
});
|
|
311
|
+
const stranded = callStatus({
|
|
312
|
+
connectionState: "reconnecting",
|
|
313
|
+
error: { type: "draining", reconnectAfterMs: 500 },
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
expect(moving.tone).toBe("reconnecting");
|
|
317
|
+
expect(moving.canRetry).toBe(false);
|
|
318
|
+
// The one the review found: a spinner, no control, and no second socket
|
|
319
|
+
// ever opened.
|
|
320
|
+
expect(stranded.tone).toBe("failed");
|
|
321
|
+
expect(stranded.canRetry).toBe(true);
|
|
322
|
+
expect(stranded.headline).not.toBe(moving.headline);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("stops saying `reconnecting` about a dropped socket nothing is retrying", () => {
|
|
326
|
+
const retrying = callStatus({
|
|
327
|
+
connectionState: "reconnecting",
|
|
328
|
+
error: { type: "socket_closed", code: 1006 },
|
|
329
|
+
recovering: true,
|
|
330
|
+
});
|
|
331
|
+
const givenUp = callStatus({ connectionState: "reconnecting", error: { type: "socket_closed", code: 1006 } });
|
|
332
|
+
|
|
333
|
+
expect(retrying.headline).toBe("Connection lost, reconnecting");
|
|
334
|
+
expect(givenUp.headline).toBe("Connection lost");
|
|
335
|
+
expect(givenUp.tone).toBe("failed");
|
|
336
|
+
expect(givenUp.canRetry).toBe(true);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("reads an absent `recovering` as nothing coming, which is the safe way round", () => {
|
|
340
|
+
// A caller that has not been taught about it gets a control rather than a
|
|
341
|
+
// spinner. The wrong answer offers a button somebody did not need; the
|
|
342
|
+
// other wrong answer strands them under a promise.
|
|
343
|
+
expect(callStatus({ connectionState: "reconnecting", error: undefined }).canRetry).toBe(true);
|
|
344
|
+
expect(callStatus({ connectionState: "reconnecting", error: undefined }).tone).toBe("failed");
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("offers a retry when only a fresh ticket will do", () => {
|
|
349
|
+
// A join ticket expires in minutes, and an expired one comes back as
|
|
350
|
+
// `unauthorized`; a refusal is very often just that, and `retry()`
|
|
351
|
+
// re-fetches through `getCredentials`.
|
|
352
|
+
expect(
|
|
353
|
+
callStatus({ connectionState: "reconnecting", error: { type: "refused", code: "unauthorized" } }).canRetry,
|
|
354
|
+
).toBe(true);
|
|
355
|
+
expect(callStatus({ connectionState: "reconnecting", error: undefined }).canRetry).toBe(true);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("says the call ended when the room closed, on either connection state", () => {
|
|
359
|
+
for (const connectionState of ["closed", "reconnecting"] as const) {
|
|
360
|
+
const status = callStatus({ connectionState, error: { type: "room_closed", reason: "the host ended it" } });
|
|
361
|
+
expect(status.tone).toBe("ended");
|
|
362
|
+
expect(status.detail).toBe("the host ended it");
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("says the call ended the moment the ROOM says so, on a socket still open", () => {
|
|
367
|
+
// A `roomClosed` frame arrives on a live socket, so the connection is still
|
|
368
|
+
// "connected" until the node hangs up. Reading only the connection would
|
|
369
|
+
// show "Connected" over a call that has ended, and that is the one entry on
|
|
370
|
+
// this list somebody would act on wrongly.
|
|
371
|
+
const status = callStatus({
|
|
372
|
+
connectionState: "connected",
|
|
373
|
+
error: undefined,
|
|
374
|
+
phase: "closed",
|
|
375
|
+
closedReason: "the host ended it",
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
expect(status.tone).toBe("ended");
|
|
379
|
+
expect(status.detail).toBe("the host ended it");
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("is live only when the room says connected", () => {
|
|
383
|
+
expect(callStatus({ connectionState: "connected", error: undefined }).tone).toBe("live");
|
|
384
|
+
expect(callStatus({ connectionState: "connected", error: undefined, phase: "joined" }).tone).toBe("live");
|
|
385
|
+
expect(callStatus({ connectionState: "idle", error: undefined }).tone).toBe("connecting");
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
describe("canPublishSource", () => {
|
|
390
|
+
const sources = ["microphone", "camera", "screen"] as const;
|
|
391
|
+
|
|
392
|
+
it("offers nothing without canPublish", () => {
|
|
393
|
+
for (const source of sources) {
|
|
394
|
+
expect(canPublishSource({ canPublish: false, canSubscribe: true, canPublishData: false }, source)).toBe(false);
|
|
395
|
+
expect(canPublishSource(undefined, source)).toBe(false);
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("treats an absent publishKinds as every kind, which is what the node does", () => {
|
|
400
|
+
const grants = { canPublish: true, canSubscribe: true, canPublishData: false };
|
|
401
|
+
for (const source of sources) {
|
|
402
|
+
expect(canPublishSource(grants, source)).toBe(true);
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* The SOURCES a control publishes, against the KINDS a grant lists.
|
|
408
|
+
*
|
|
409
|
+
* These are two vocabularies and they agree on one word. Asking this function
|
|
410
|
+
* about "audio" - as the buttons used to - is asking about something no
|
|
411
|
+
* publish ever declares, so it answered a question the node was never going
|
|
412
|
+
* to be asked: a booking token minted `["audio", "video", "screen"]` drew a
|
|
413
|
+
* microphone button and a camera button, and the node refused both, because
|
|
414
|
+
* what reached it was "microphone" and "camera".
|
|
415
|
+
*/
|
|
416
|
+
it("offers the microphone and camera a booking token was minted for", () => {
|
|
417
|
+
const booking = {
|
|
418
|
+
canPublish: true,
|
|
419
|
+
canSubscribe: true,
|
|
420
|
+
canPublishData: false,
|
|
421
|
+
publishKinds: ["audio", "video", "screen"] as const,
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
for (const source of sources) {
|
|
425
|
+
expect(canPublishSource({ ...booking, publishKinds: [...booking.publishKinds] }, source)).toBe(true);
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it("honours a narrowed publishKinds", () => {
|
|
430
|
+
const listenAndTalk = {
|
|
431
|
+
canPublish: true,
|
|
432
|
+
canSubscribe: true,
|
|
433
|
+
canPublishData: false,
|
|
434
|
+
publishKinds: ["audio"] as const,
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
expect(canPublishSource({ ...listenAndTalk, publishKinds: ["audio"] }, "microphone")).toBe(true);
|
|
438
|
+
expect(canPublishSource({ ...listenAndTalk, publishKinds: ["audio"] }, "camera")).toBe(false);
|
|
439
|
+
expect(canPublishSource({ ...listenAndTalk, publishKinds: ["audio"] }, "screen")).toBe(false);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("does not offer a screen share on a token granted audio and video", () => {
|
|
443
|
+
const noPresenting = {
|
|
444
|
+
canPublish: true,
|
|
445
|
+
canSubscribe: true,
|
|
446
|
+
canPublishData: false,
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
expect(canPublishSource({ ...noPresenting, publishKinds: ["audio", "video"] }, "camera")).toBe(true);
|
|
450
|
+
expect(canPublishSource({ ...noPresenting, publishKinds: ["audio", "video"] }, "screen")).toBe(false);
|
|
451
|
+
});
|
|
452
|
+
});
|