@tribe-nest/forge 3.31.0 → 3.35.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 +9 -3
- package/src/client/_tests/tenantHeaders.spec.ts +77 -0
- package/src/client/createForgeClient.ts +37 -0
- package/src/contexts/CartContext.tsx +17 -1
- package/src/contexts/_tests/CartContext.spec.tsx +36 -0
- package/src/data/queries/useFilmPlaybackSession.ts +224 -0
- package/src/data/queries/useFilms.ts +561 -0
- package/src/data/queries/useMyBookings.ts +9 -0
- package/src/i18n/_tests/translationKeys.spec.ts +15 -0
- package/src/i18n/de.json +140 -2
- package/src/i18n/en.json +140 -2
- package/src/index.ts +5 -0
- package/src/provider/ForgeAppProvider.tsx +10 -0
- package/src/provider/ForgeProvider.tsx +18 -3
- package/src/server/index.ts +76 -17
- package/src/ui/headless/film/FilmWatermark.tsx +180 -0
- package/src/ui/headless/film/_tests/filmRules.spec.ts +531 -0
- package/src/ui/headless/film/_tests/useStageFullscreen.spec.ts +167 -0
- package/src/ui/headless/film/index.ts +36 -0
- package/src/ui/headless/film/useFilmCatalog.ts +66 -0
- package/src/ui/headless/film/useFilmPlayback.ts +497 -0
- package/src/ui/headless/film/useFilmRentalFlow.ts +277 -0
- package/src/ui/headless/film/useStageFullscreen.ts +156 -0
- package/src/ui/headless/index.ts +6 -9
- package/src/ui/index.ts +43 -16
- package/src/ui/media/BookingCallScreen.tsx +33 -0
- package/src/ui/media/CallStage.tsx +212 -63
- package/src/ui/media/CallWindowNotice.tsx +94 -0
- package/src/ui/media/_tests/CallStage.spec.tsx +265 -19
- package/src/ui/media/_tests/CallWindowNotice.spec.tsx +83 -0
- package/src/ui/media/_tests/bookingSession.spec.tsx +48 -0
- package/src/ui/media/_tests/callState.spec.ts +63 -16
- package/src/ui/media/bookingSession.tsx +45 -57
- package/src/ui/media/bookingWindow.ts +81 -0
- package/src/ui/media/callState.ts +42 -23
- package/src/ui/media/index.ts +9 -0
- package/src/ui/styled/AccountDashboard.tsx +97 -5
- package/src/ui/styled/FilmCatalog.tsx +278 -0
- package/src/ui/styled/FilmDetail.tsx +661 -0
- package/src/ui/styled/FilmLibrary.tsx +254 -0
- package/src/ui/styled/FilmWatch.tsx +701 -0
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +194 -0
- package/src/ui/styled/forge-utilities.css +835 -0
- package/src/utils/structuredData.ts +88 -17
|
@@ -5,8 +5,10 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
|
5
5
|
|
|
6
6
|
import { MediaRoomProvider, type MediaRoomProviderProps } from "@tribe-nest/media-client/react";
|
|
7
7
|
import type { MediaDevice, MediaTransport } from "@tribe-nest/media-client";
|
|
8
|
+
import { MediaError } from "@tribe-nest/media-protocol";
|
|
8
9
|
|
|
9
10
|
import { CallStage } from "../CallStage";
|
|
11
|
+
import { ForgeI18nProvider } from "../../../i18n";
|
|
10
12
|
import { ForgeThemeProvider, type ForgeTheme } from "../../theme/ForgeThemeProvider";
|
|
11
13
|
import { FakeNode, settle } from "./fakeNode";
|
|
12
14
|
|
|
@@ -67,7 +69,14 @@ const fakeTransport = (): MediaTransport => ({
|
|
|
67
69
|
id: "t-1",
|
|
68
70
|
async produce(input) {
|
|
69
71
|
produced.push(String((input.appData as { source?: string } | undefined)?.source));
|
|
70
|
-
return {
|
|
72
|
+
return {
|
|
73
|
+
id: `p-local-${produced.length}`,
|
|
74
|
+
kind: "audio",
|
|
75
|
+
closed: false,
|
|
76
|
+
pause: vi.fn(),
|
|
77
|
+
resume: vi.fn(),
|
|
78
|
+
close: vi.fn(),
|
|
79
|
+
};
|
|
71
80
|
},
|
|
72
81
|
async consume(input) {
|
|
73
82
|
return {
|
|
@@ -105,27 +114,36 @@ const FAST_RECOVERY: Recovery = { baseMs: 1, maxMs: 4, factor: 1, jitter: "none"
|
|
|
105
114
|
const mount = (
|
|
106
115
|
node: FakeNode,
|
|
107
116
|
children: ReactNode,
|
|
108
|
-
options: { autoSubscribe?: boolean; reconnect?: Recovery } = {},
|
|
117
|
+
options: { autoSubscribe?: boolean; reconnect?: Recovery; locale?: string } = {},
|
|
109
118
|
) =>
|
|
110
119
|
render(
|
|
111
|
-
<
|
|
112
|
-
<
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
120
|
+
<ForgeI18nProvider locale={options.locale ?? "en"}>
|
|
121
|
+
<ForgeThemeProvider theme={theme}>
|
|
122
|
+
<MediaRoomProvider
|
|
123
|
+
getCredentials={() => ({ mediaUrl: "wss://media.example", token: "t" })}
|
|
124
|
+
device={fakeDevice}
|
|
125
|
+
webSocket={node.factory}
|
|
126
|
+
autoSubscribe={options.autoSubscribe ?? false}
|
|
127
|
+
reconnect={options.reconnect ?? NO_RECOVERY}
|
|
128
|
+
>
|
|
129
|
+
{children}
|
|
130
|
+
</MediaRoomProvider>
|
|
131
|
+
</ForgeThemeProvider>
|
|
132
|
+
</ForgeI18nProvider>,
|
|
122
133
|
);
|
|
123
134
|
|
|
124
|
-
/**
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
135
|
+
/**
|
|
136
|
+
* A permission prompt and a capture, as the browser would give them.
|
|
137
|
+
*
|
|
138
|
+
* The track is an `EventTarget`, as a real `MediaStreamTrack` is, so a spec can
|
|
139
|
+
* end it the way the browser's own "Stop sharing" bar does.
|
|
140
|
+
*/
|
|
141
|
+
const stubCapture = (kind: "audio" | "video" = "audio") => {
|
|
142
|
+
const target = new EventTarget() as EventTarget & { kind: string; stop: ReturnType<typeof vi.fn>; enabled: boolean };
|
|
143
|
+
target.kind = kind;
|
|
144
|
+
target.stop = vi.fn();
|
|
145
|
+
target.enabled = true;
|
|
146
|
+
const track = target as unknown as MediaStreamTrack & { stop: ReturnType<typeof vi.fn> };
|
|
129
147
|
const getUserMedia = vi.fn(async () => ({ getTracks: () => [track] }) as unknown as MediaStream);
|
|
130
148
|
Object.defineProperty(navigator, "mediaDevices", {
|
|
131
149
|
configurable: true,
|
|
@@ -381,7 +399,8 @@ describe("the grid", () => {
|
|
|
381
399
|
|
|
382
400
|
const { container, getByTestId } = mount(node, <CallStage />);
|
|
383
401
|
await waitFor(() => expect(getByTestId("tile-ben")).toBeTruthy());
|
|
384
|
-
const order = () =>
|
|
402
|
+
const order = () =>
|
|
403
|
+
[...container.querySelectorAll("[data-testid^='tile-']")].map((n) => n.getAttribute("data-testid"));
|
|
385
404
|
const before = order();
|
|
386
405
|
|
|
387
406
|
node.event({ event: "activeSpeakers", producerIds: ["p-ben-audio"] });
|
|
@@ -466,6 +485,47 @@ describe("the connection state, said honestly", () => {
|
|
|
466
485
|
expect(getByText("Try again")).toBeTruthy();
|
|
467
486
|
});
|
|
468
487
|
|
|
488
|
+
/**
|
|
489
|
+
* The ticket endpoint said no BEFORE any socket existed. This used to wedge
|
|
490
|
+
* the room on "Connecting" for ever (the SDK never ran its close path for a
|
|
491
|
+
* failure with no socket), and the docblocks promising the server's refusal
|
|
492
|
+
* would be shown verbatim could not be kept. Now it is a refusal like any
|
|
493
|
+
* other: the sentence, and a control.
|
|
494
|
+
*/
|
|
495
|
+
it("shows the credential endpoint's refusal verbatim, with a retry, rather than a spinner", async () => {
|
|
496
|
+
const node = new FakeNode({});
|
|
497
|
+
let attempts = 0;
|
|
498
|
+
const { getByRole, getByText } = render(
|
|
499
|
+
<ForgeThemeProvider theme={theme}>
|
|
500
|
+
<MediaRoomProvider
|
|
501
|
+
getCredentials={() => {
|
|
502
|
+
attempts += 1;
|
|
503
|
+
if (attempts === 1) {
|
|
504
|
+
throw new MediaError("bad_request", "The room opens 15 minutes before the session.");
|
|
505
|
+
}
|
|
506
|
+
return { mediaUrl: "wss://media.example", token: "t" };
|
|
507
|
+
}}
|
|
508
|
+
device={fakeDevice}
|
|
509
|
+
webSocket={node.factory}
|
|
510
|
+
autoSubscribe={false}
|
|
511
|
+
reconnect={NO_RECOVERY}
|
|
512
|
+
>
|
|
513
|
+
<CallStage />
|
|
514
|
+
</MediaRoomProvider>
|
|
515
|
+
</ForgeThemeProvider>,
|
|
516
|
+
);
|
|
517
|
+
|
|
518
|
+
await waitFor(() => expect(getByRole("status").textContent).toContain("Could not join this call"));
|
|
519
|
+
expect(getByRole("status").textContent).toContain("The room opens 15 minutes before the session.");
|
|
520
|
+
expect(getByRole("status").textContent).not.toContain("Connection lost");
|
|
521
|
+
|
|
522
|
+
// And Try again is not decoration: the next attempt fetches a ticket again
|
|
523
|
+
// and gets in.
|
|
524
|
+
fireEvent.click(getByText("Try again"));
|
|
525
|
+
await waitFor(() => expect(getByRole("status").textContent).toContain("Connected"));
|
|
526
|
+
expect(attempts).toBe(2);
|
|
527
|
+
});
|
|
528
|
+
|
|
469
529
|
it("says the call ended when the node closes the room", async () => {
|
|
470
530
|
const node = new FakeNode({});
|
|
471
531
|
const { getByRole } = mount(node, <CallStage />);
|
|
@@ -683,3 +743,189 @@ describe("the things that must stay visible", () => {
|
|
|
683
743
|
await waitFor(() => expect(queryByText(/means that person has their camera off/)).toBeTruthy());
|
|
684
744
|
});
|
|
685
745
|
});
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* The screen speaks the site's language.
|
|
749
|
+
*
|
|
750
|
+
* Every string on the call screen was English, written into the component,
|
|
751
|
+
* against Forge's own convention: a German site rendered a German account page
|
|
752
|
+
* with an English call in the middle of it, and `callStatus` returned sentences
|
|
753
|
+
* a translator could not reach. This pins that the SAME component, under the
|
|
754
|
+
* SAME provider every Forge component reads, is German when the site is.
|
|
755
|
+
*/
|
|
756
|
+
describe("the screen is translated", () => {
|
|
757
|
+
it("renders the status and the controls in the site's language", async () => {
|
|
758
|
+
const node = new FakeNode({});
|
|
759
|
+
const { getByRole, getByText, queryByText } = mount(node, <CallStage />, { locale: "de" });
|
|
760
|
+
|
|
761
|
+
await waitFor(() => expect(getByRole("status").textContent).toContain("Verbunden"));
|
|
762
|
+
expect(getByText("Stummschaltung aufheben")).toBeTruthy();
|
|
763
|
+
expect(getByText("Kamera starten")).toBeTruthy();
|
|
764
|
+
expect(getByText("Verlassen")).toBeTruthy();
|
|
765
|
+
expect(getByText(/In diesem Anruf:/).textContent).toBe("In diesem Anruf: 1");
|
|
766
|
+
expect(queryByText("Unmute")).toBeNull();
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
it("keeps the server's own sentence verbatim inside a translated status", async () => {
|
|
770
|
+
const node = new FakeNode({});
|
|
771
|
+
const { getByRole } = mount(node, <CallStage />, { locale: "de" });
|
|
772
|
+
await waitFor(() => expect(getByRole("status").textContent).toContain("Verbunden"));
|
|
773
|
+
|
|
774
|
+
node.event({ event: "roomClosed", reason: "the host ended it" });
|
|
775
|
+
|
|
776
|
+
// The headline is ours and is German; the reason is the server's and is not
|
|
777
|
+
// put through a translator, because a friendlier version of a refusal is a
|
|
778
|
+
// lie about why.
|
|
779
|
+
await waitFor(() => expect(getByRole("status").textContent).toContain("Der Anruf ist beendet"));
|
|
780
|
+
expect(getByRole("status").textContent).toContain("the host ended it");
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
it("falls back to English outside any i18n provider, so a site without one still gets a call", async () => {
|
|
784
|
+
const node = new FakeNode({});
|
|
785
|
+
const { getByRole } = render(
|
|
786
|
+
<ForgeThemeProvider theme={theme}>
|
|
787
|
+
<MediaRoomProvider
|
|
788
|
+
getCredentials={() => ({ mediaUrl: "wss://media.example", token: "t" })}
|
|
789
|
+
device={fakeDevice}
|
|
790
|
+
webSocket={node.factory}
|
|
791
|
+
autoSubscribe={false}
|
|
792
|
+
reconnect={NO_RECOVERY}
|
|
793
|
+
>
|
|
794
|
+
<CallStage />
|
|
795
|
+
</MediaRoomProvider>
|
|
796
|
+
</ForgeThemeProvider>,
|
|
797
|
+
);
|
|
798
|
+
|
|
799
|
+
await waitFor(() => expect(getByRole("status").textContent).toContain("Connected"));
|
|
800
|
+
});
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Mute is a pause, not a second permission prompt.
|
|
805
|
+
*
|
|
806
|
+
* The toggles were unpublish-then-publish, and every publish starts with
|
|
807
|
+
* `getUserMedia`. Safari asks permission on EVERY call to it, so a person on an
|
|
808
|
+
* iPad was asked whether the site may use the microphone each time they
|
|
809
|
+
* unmuted. Now the first press captures and every press after that pauses or
|
|
810
|
+
* resumes the publication the room already holds.
|
|
811
|
+
*/
|
|
812
|
+
describe("mute and camera-off keep the capture", () => {
|
|
813
|
+
it("mutes and unmutes with ONE capture, and tells the node each time", async () => {
|
|
814
|
+
const { getUserMedia } = stubCapture();
|
|
815
|
+
const node = new FakeNode({});
|
|
816
|
+
const { getByText } = mount(node, <CallStage />);
|
|
817
|
+
|
|
818
|
+
await waitFor(() => expect(getByText("Unmute")).toBeTruthy());
|
|
819
|
+
fireEvent.click(getByText("Unmute"));
|
|
820
|
+
await waitFor(() => expect(getByText("Mute")).toBeTruthy());
|
|
821
|
+
|
|
822
|
+
fireEvent.click(getByText("Mute"));
|
|
823
|
+
await waitFor(() => expect(getByText("Unmute")).toBeTruthy());
|
|
824
|
+
expect(node.received.filter((f) => f.method === "pauseProducer")).toHaveLength(1);
|
|
825
|
+
// Paused, not closed: the microphone is still published, just silent.
|
|
826
|
+
expect(node.received.filter((f) => f.method === "closeProducer")).toHaveLength(0);
|
|
827
|
+
|
|
828
|
+
fireEvent.click(getByText("Unmute"));
|
|
829
|
+
await waitFor(() => expect(getByText("Mute")).toBeTruthy());
|
|
830
|
+
expect(node.received.filter((f) => f.method === "resumeProducer")).toHaveLength(1);
|
|
831
|
+
// The whole point: one prompt for the whole call.
|
|
832
|
+
expect(getUserMedia).toHaveBeenCalledTimes(1);
|
|
833
|
+
expect(produced).toEqual(["microphone"]);
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
it("shows the name in the local preview while the camera is paused, and explains the light behind a ?", async () => {
|
|
837
|
+
stubCapture("video");
|
|
838
|
+
const node = new FakeNode({});
|
|
839
|
+
const { getByText, getByTestId, getByLabelText, queryByText } = mount(node, <CallStage />);
|
|
840
|
+
|
|
841
|
+
await waitFor(() => expect(getByText("Start camera")).toBeTruthy());
|
|
842
|
+
fireEvent.click(getByText("Start camera"));
|
|
843
|
+
await waitFor(() => expect(getByText("Stop camera")).toBeTruthy());
|
|
844
|
+
await waitFor(() => expect(getByTestId("tile-local").querySelector("video")).toBeTruthy());
|
|
845
|
+
|
|
846
|
+
fireEvent.click(getByText("Stop camera"));
|
|
847
|
+
|
|
848
|
+
// What the others see is the name, so that is what the preview shows.
|
|
849
|
+
await waitFor(() => expect(getByText("Start camera")).toBeTruthy());
|
|
850
|
+
await waitFor(() => expect(getByTestId("tile-local").querySelector("video")).toBeNull());
|
|
851
|
+
expect(node.received.filter((f) => f.method === "pauseProducer")).toHaveLength(1);
|
|
852
|
+
// The camera stays open (the light can stay on), and that is an explanation,
|
|
853
|
+
// so it lives behind a ? with a name that says what it explains.
|
|
854
|
+
expect(queryByText(/keeps the camera open/)).toBeNull();
|
|
855
|
+
fireEvent.click(getByLabelText("What does Stop camera do?"));
|
|
856
|
+
await waitFor(() => expect(queryByText(/keeps the camera open/)).toBeTruthy());
|
|
857
|
+
});
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* The browser can end a share without asking us.
|
|
862
|
+
*
|
|
863
|
+
* Chrome and Firefox draw their own "Stop sharing" bar over a screen share.
|
|
864
|
+
* Nothing about it went near `unpublish`, so the node kept a producer nobody
|
|
865
|
+
* was feeding, everybody else kept a frozen tile, and this button read "Stop
|
|
866
|
+
* sharing" about a share that had ended.
|
|
867
|
+
*/
|
|
868
|
+
describe("a screen share stopped from the browser's own bar", () => {
|
|
869
|
+
it("is unpublished, and the control follows", async () => {
|
|
870
|
+
const { track } = stubCapture("video");
|
|
871
|
+
const node = new FakeNode({});
|
|
872
|
+
const { getByText } = mount(node, <CallStage />);
|
|
873
|
+
|
|
874
|
+
await waitFor(() => expect(getByText("Share screen")).toBeTruthy());
|
|
875
|
+
fireEvent.click(getByText("Share screen"));
|
|
876
|
+
await waitFor(() => expect(getByText("Stop sharing")).toBeTruthy());
|
|
877
|
+
|
|
878
|
+
track.dispatchEvent(new Event("ended"));
|
|
879
|
+
|
|
880
|
+
await waitFor(() => expect(getByText("Share screen")).toBeTruthy());
|
|
881
|
+
expect(node.received.filter((f) => f.method === "closeProducer")).toHaveLength(1);
|
|
882
|
+
});
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* A recovered connection is not a recovered microphone, and the screen says so.
|
|
887
|
+
*
|
|
888
|
+
* The drop stops every local capture (right: a light left on over a dead
|
|
889
|
+
* session is retained capture) and the SDK does not turn it back on by itself
|
|
890
|
+
* (also right: capture is a permission, and it is the person's press). What
|
|
891
|
+
* was wrong is that nothing then told them. The room came back, the status
|
|
892
|
+
* read "Connected", and a coach who had been talking for ten minutes was
|
|
893
|
+
* talking to nobody until the client asked whether they were still there.
|
|
894
|
+
*/
|
|
895
|
+
describe("what a drop took away is said, once the room is back", () => {
|
|
896
|
+
it("names the microphone, and stops once it is back on", async () => {
|
|
897
|
+
stubCapture();
|
|
898
|
+
const node = new FakeNode({});
|
|
899
|
+
const { getByText, getByRole, queryByText } = mount(node, <CallStage />, { reconnect: FAST_RECOVERY });
|
|
900
|
+
|
|
901
|
+
await waitFor(() => expect(getByText("Unmute")).toBeTruthy());
|
|
902
|
+
fireEvent.click(getByText("Unmute"));
|
|
903
|
+
await waitFor(() => expect(getByText("Mute")).toBeTruthy());
|
|
904
|
+
|
|
905
|
+
node.socket.drop();
|
|
906
|
+
|
|
907
|
+
await waitFor(() => expect(node.sockets.length).toBe(2));
|
|
908
|
+
await waitFor(() => expect(getByRole("status").textContent).toContain("Connected"));
|
|
909
|
+
// Content, not a hint: this is the thing they have to act on.
|
|
910
|
+
await waitFor(() => expect(queryByText(/Your microphone was turned off/)).toBeTruthy());
|
|
911
|
+
expect(getByText("Unmute")).toBeTruthy();
|
|
912
|
+
|
|
913
|
+
fireEvent.click(getByText("Unmute"));
|
|
914
|
+
await waitFor(() => expect(getByText("Mute")).toBeTruthy());
|
|
915
|
+
await waitFor(() => expect(queryByText(/Your microphone was turned off/)).toBeNull());
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
it("says nothing after a deliberate leave", async () => {
|
|
919
|
+
stubCapture();
|
|
920
|
+
const node = new FakeNode({});
|
|
921
|
+
const { getByText, queryByText } = mount(node, <CallStage />, { reconnect: FAST_RECOVERY });
|
|
922
|
+
await waitFor(() => expect(getByText("Unmute")).toBeTruthy());
|
|
923
|
+
fireEvent.click(getByText("Unmute"));
|
|
924
|
+
await waitFor(() => expect(getByText("Mute")).toBeTruthy());
|
|
925
|
+
|
|
926
|
+
fireEvent.click(getByText("Leave"));
|
|
927
|
+
|
|
928
|
+
await settle(30);
|
|
929
|
+
expect(queryByText(/Your microphone was turned off/)).toBeNull();
|
|
930
|
+
});
|
|
931
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
|
|
4
|
+
import { ForgeI18nProvider } from "../../../i18n";
|
|
5
|
+
import { ForgeThemeProvider, type ForgeTheme } from "../../theme/ForgeThemeProvider";
|
|
6
|
+
import { CallWindowNotice } from "../CallWindowNotice";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The state a call page is in for most of a session's life: booked, not yet
|
|
10
|
+
* open, or long over.
|
|
11
|
+
*
|
|
12
|
+
* Every surface used to mount the call regardless and report "not yet
|
|
13
|
+
* connected", which is the connection's answer to a question nobody asked. The
|
|
14
|
+
* two questions somebody following a link from their confirmation email is
|
|
15
|
+
* actually asking are WHEN can I come in and HAVE I missed it, and the server
|
|
16
|
+
* sends both boundaries with the page.
|
|
17
|
+
*/
|
|
18
|
+
const theme: ForgeTheme = {
|
|
19
|
+
colors: { text: "#101010", background: "#fafafa", primary: "#0b6", surface: "#eeeeee" },
|
|
20
|
+
cornerRadius: 10,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const MINUTE = 60_000;
|
|
24
|
+
const HOUR = 60 * MINUTE;
|
|
25
|
+
|
|
26
|
+
const render = (props: { opensAt: Date; closesAt: Date; now: Date; locale?: string }) =>
|
|
27
|
+
renderToStaticMarkup(
|
|
28
|
+
<ForgeI18nProvider locale={props.locale ?? "en"}>
|
|
29
|
+
<ForgeThemeProvider theme={theme}>
|
|
30
|
+
<CallWindowNotice
|
|
31
|
+
title="1:1 Coaching"
|
|
32
|
+
opensAt={props.opensAt.toISOString()}
|
|
33
|
+
closesAt={props.closesAt.toISOString()}
|
|
34
|
+
now={props.now}
|
|
35
|
+
/>
|
|
36
|
+
</ForgeThemeProvider>
|
|
37
|
+
</ForgeI18nProvider>,
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
describe("CallWindowNotice", () => {
|
|
41
|
+
const now = new Date("2026-08-23T12:00:00.000Z");
|
|
42
|
+
|
|
43
|
+
it("says the call has not started, and when to come back", () => {
|
|
44
|
+
const html = render({ opensAt: new Date(now.getTime() + 6 * HOUR), closesAt: new Date(now.getTime() + 9 * HOUR), now });
|
|
45
|
+
|
|
46
|
+
expect(html).toContain("has not started yet");
|
|
47
|
+
expect(html).toContain("You can join from");
|
|
48
|
+
// The session it belongs to, so a reader with two bookings knows which.
|
|
49
|
+
expect(html).toContain("1:1 Coaching");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("says the call has ended once the window is past", () => {
|
|
53
|
+
const html = render({ opensAt: new Date(now.getTime() - 4 * HOUR), closesAt: new Date(now.getTime() - HOUR), now });
|
|
54
|
+
|
|
55
|
+
expect(html).toContain("has ended");
|
|
56
|
+
expect(html).not.toContain("You can join from");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("renders NOTHING while the room is open, so the call speaks for itself", () => {
|
|
60
|
+
// The one state this component must stay out of: a call that is open but
|
|
61
|
+
// unreachable is the connection status's business.
|
|
62
|
+
const html = render({ opensAt: new Date(now.getTime() - MINUTE), closesAt: new Date(now.getTime() + HOUR), now });
|
|
63
|
+
|
|
64
|
+
// The provider still emits its own <style> tag, so this asserts the absence
|
|
65
|
+
// of the NOTICE rather than of all output.
|
|
66
|
+
expect(html).not.toContain("has not started");
|
|
67
|
+
expect(html).not.toContain("has ended");
|
|
68
|
+
expect(html).not.toContain("1:1 Coaching");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("speaks the visitor's language", () => {
|
|
72
|
+
const html = render({
|
|
73
|
+
opensAt: new Date(now.getTime() + 6 * HOUR),
|
|
74
|
+
closesAt: new Date(now.getTime() + 9 * HOUR),
|
|
75
|
+
now,
|
|
76
|
+
locale: "de",
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// The reported symptom was a German visitor being told only "noch nicht
|
|
80
|
+
// verbunden". They now get the answer, in German.
|
|
81
|
+
expect(html).toContain("noch nicht begonnen");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
3
|
import { renderHook } from "@testing-library/react";
|
|
4
|
+
import { MediaError } from "@tribe-nest/media-protocol";
|
|
4
5
|
|
|
5
6
|
import {
|
|
6
7
|
BOOKING_CALL_CLOSES_MINUTES_AFTER,
|
|
@@ -167,6 +168,53 @@ describe("useBookingSessionCredentials", () => {
|
|
|
167
168
|
expect(credentials.role).toBe("guest");
|
|
168
169
|
});
|
|
169
170
|
|
|
171
|
+
/**
|
|
172
|
+
* The server's refusal must reach the screen as the server's SENTENCE. Left
|
|
173
|
+
* as an axios error, the SDK read it as a dropped socket and the screen said
|
|
174
|
+
* "Connection lost" about a call the person was never in.
|
|
175
|
+
*/
|
|
176
|
+
it("turns the server's refusal into a MediaError carrying its message", async () => {
|
|
177
|
+
post.mockRejectedValueOnce({
|
|
178
|
+
response: { status: 409, data: { message: "The room opens 15 minutes before the session." } },
|
|
179
|
+
});
|
|
180
|
+
const getCredentials = renderHook(() => useBookingSessionCredentials("booking-1")).result.current;
|
|
181
|
+
|
|
182
|
+
const error: unknown = await getCredentials().then(
|
|
183
|
+
() => undefined,
|
|
184
|
+
(e: unknown) => e,
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
expect(error).toBeInstanceOf(MediaError);
|
|
188
|
+
expect((error as MediaError).message).toBe("The room opens 15 minutes before the session.");
|
|
189
|
+
expect((error as MediaError).code).toBe("bad_request");
|
|
190
|
+
expect((error as MediaError).retryable).toBe(false);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("maps a 401 and a 404 to the SDK's own codes, and a 5xx to a retryable internal", async () => {
|
|
194
|
+
const getCredentials = renderHook(() => useBookingSessionCredentials("booking-1")).result.current;
|
|
195
|
+
const refusal = async (status: number) => {
|
|
196
|
+
post.mockRejectedValueOnce({ response: { status, data: { message: `status ${status}` } } });
|
|
197
|
+
return getCredentials().then(
|
|
198
|
+
() => undefined,
|
|
199
|
+
(e: unknown) => e as MediaError,
|
|
200
|
+
);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
expect((await refusal(401))?.code).toBe("unauthorized");
|
|
204
|
+
expect((await refusal(404))?.code).toBe("no_such_room");
|
|
205
|
+
const internal = await refusal(503);
|
|
206
|
+
expect(internal?.code).toBe("internal");
|
|
207
|
+
expect(internal?.retryable).toBe(true);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("leaves a network failure alone, because that one really is a connection problem", async () => {
|
|
211
|
+
const network = new Error("Network Error");
|
|
212
|
+
post.mockRejectedValueOnce(network);
|
|
213
|
+
const getCredentials = renderHook(() => useBookingSessionCredentials("booking-1")).result.current;
|
|
214
|
+
|
|
215
|
+
await expect(getCredentials()).rejects.toBe(network);
|
|
216
|
+
});
|
|
217
|
+
|
|
170
218
|
it("keeps ONE callback identity per booking", () => {
|
|
171
219
|
const { result, rerender } = renderHook(() => useBookingSessionCredentials("booking-1"));
|
|
172
220
|
const first = result.current;
|
|
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
|
|
3
3
|
import type { ProducerEntry } from "@tribe-nest/media-client";
|
|
4
4
|
import type { Peer } from "@tribe-nest/media-protocol";
|
|
5
5
|
|
|
6
|
+
import { FORGE_LOCALES, getForgeMessages, translateForge } from "../../../i18n";
|
|
6
7
|
import { callHeadCount, callStatus, callTiles, canPublishSource, remoteAudioProducerIds } from "../callState";
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -22,12 +23,12 @@ const peer = (identity: string, name?: string): Peer => ({
|
|
|
22
23
|
/** The recording process, which joins the room as a real peer. */
|
|
23
24
|
const recorder = (identity: string): Peer => ({ identity, kind: "egress" });
|
|
24
25
|
|
|
25
|
-
const producer = (
|
|
26
|
-
producerId
|
|
27
|
-
identity
|
|
28
|
-
kind
|
|
29
|
-
paused
|
|
30
|
-
|
|
26
|
+
const producer = (producerId: string, identity: string, kind: "audio" | "video", paused = false): ProducerEntry => ({
|
|
27
|
+
producerId,
|
|
28
|
+
identity,
|
|
29
|
+
kind,
|
|
30
|
+
paused,
|
|
31
|
+
});
|
|
31
32
|
|
|
32
33
|
describe("callTiles keeps the grid from collapsing", () => {
|
|
33
34
|
it("draws a tile for somebody with NO video at all", () => {
|
|
@@ -256,21 +257,67 @@ describe("callStatus is honest about what is happening", () => {
|
|
|
256
257
|
// other is worth pressing a button about.
|
|
257
258
|
expect(dropped.tone).toBe("reconnecting");
|
|
258
259
|
expect(refused.tone).toBe("failed");
|
|
259
|
-
expect(dropped.
|
|
260
|
+
expect(dropped.headlineKey).not.toBe(refused.headlineKey);
|
|
260
261
|
});
|
|
261
262
|
|
|
262
263
|
it("shows the server's own words for a refusal rather than a friendly lie", () => {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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();
|
|
266
273
|
expect(
|
|
267
274
|
callStatus({
|
|
268
275
|
connectionState: "reconnecting",
|
|
269
276
|
error: { type: "refused", code: "unauthorized", message: "this session has ended" },
|
|
270
|
-
}).
|
|
277
|
+
}).detailText,
|
|
271
278
|
).toBe("this session has ended");
|
|
272
279
|
});
|
|
273
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
|
+
|
|
274
321
|
it("does not offer a retry for something retrying cannot fix", () => {
|
|
275
322
|
expect(callStatus({ connectionState: "connected", error: undefined }).canRetry).toBe(false);
|
|
276
323
|
expect(callStatus({ connectionState: "connecting", error: undefined }).canRetry).toBe(false);
|
|
@@ -319,7 +366,7 @@ describe("callStatus is honest about what is happening", () => {
|
|
|
319
366
|
// ever opened.
|
|
320
367
|
expect(stranded.tone).toBe("failed");
|
|
321
368
|
expect(stranded.canRetry).toBe(true);
|
|
322
|
-
expect(stranded.
|
|
369
|
+
expect(stranded.headlineKey).not.toBe(moving.headlineKey);
|
|
323
370
|
});
|
|
324
371
|
|
|
325
372
|
it("stops saying `reconnecting` about a dropped socket nothing is retrying", () => {
|
|
@@ -330,8 +377,8 @@ describe("callStatus is honest about what is happening", () => {
|
|
|
330
377
|
});
|
|
331
378
|
const givenUp = callStatus({ connectionState: "reconnecting", error: { type: "socket_closed", code: 1006 } });
|
|
332
379
|
|
|
333
|
-
expect(retrying.
|
|
334
|
-
expect(givenUp.
|
|
380
|
+
expect(translateForge("en", retrying.headlineKey)).toBe("Connection lost, reconnecting");
|
|
381
|
+
expect(translateForge("en", givenUp.headlineKey)).toBe("Connection lost");
|
|
335
382
|
expect(givenUp.tone).toBe("failed");
|
|
336
383
|
expect(givenUp.canRetry).toBe(true);
|
|
337
384
|
});
|
|
@@ -359,7 +406,7 @@ describe("callStatus is honest about what is happening", () => {
|
|
|
359
406
|
for (const connectionState of ["closed", "reconnecting"] as const) {
|
|
360
407
|
const status = callStatus({ connectionState, error: { type: "room_closed", reason: "the host ended it" } });
|
|
361
408
|
expect(status.tone).toBe("ended");
|
|
362
|
-
expect(status.
|
|
409
|
+
expect(status.detailText).toBe("the host ended it");
|
|
363
410
|
}
|
|
364
411
|
});
|
|
365
412
|
|
|
@@ -376,7 +423,7 @@ describe("callStatus is honest about what is happening", () => {
|
|
|
376
423
|
});
|
|
377
424
|
|
|
378
425
|
expect(status.tone).toBe("ended");
|
|
379
|
-
expect(status.
|
|
426
|
+
expect(status.detailText).toBe("the host ended it");
|
|
380
427
|
});
|
|
381
428
|
|
|
382
429
|
it("is live only when the room says connected", () => {
|