@ksp-gonogo/kerbcast-react 1.6.3 → 1.8.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +19 -0
  3. package/dist/CameraFeed.d.ts +63 -19
  4. package/dist/CameraFeed.js +329 -202
  5. package/dist/FeedActionBar.d.ts +133 -0
  6. package/dist/FeedActionBar.js +225 -0
  7. package/dist/KerbalFaceFeed.d.ts +82 -0
  8. package/dist/KerbalFaceFeed.js +111 -0
  9. package/dist/RecordingsTray.d.ts +37 -0
  10. package/dist/RecordingsTray.js +279 -0
  11. package/dist/context.d.ts +27 -1
  12. package/dist/context.js +68 -2
  13. package/dist/hooks/useRecordings.d.ts +35 -0
  14. package/dist/hooks/useRecordings.js +33 -0
  15. package/dist/hooks/useReportDisplaySize.d.ts +23 -0
  16. package/dist/hooks/useReportDisplaySize.js +71 -0
  17. package/dist/index.d.ts +12 -2
  18. package/dist/index.js +13 -1
  19. package/dist/menuPositioning.d.ts +20 -0
  20. package/dist/menuPositioning.js +98 -0
  21. package/dist/timing.d.ts +4 -0
  22. package/dist/timing.js +19 -0
  23. package/package.json +2 -2
  24. package/src/CameraFeed.test.tsx +719 -10
  25. package/src/CameraFeed.tsx +586 -334
  26. package/src/FeedActionBar.test.ts +196 -0
  27. package/src/FeedActionBar.test.tsx +215 -0
  28. package/src/FeedActionBar.tsx +391 -0
  29. package/src/KerbalFaceFeed.test.tsx +203 -0
  30. package/src/KerbalFaceFeed.tsx +214 -0
  31. package/src/RecordingsTray.test.tsx +288 -0
  32. package/src/RecordingsTray.tsx +475 -0
  33. package/src/context.test.tsx +94 -0
  34. package/src/context.tsx +98 -1
  35. package/src/hooks/useRecordings.sharedState.test.tsx +117 -0
  36. package/src/hooks/useRecordings.test.tsx +217 -0
  37. package/src/hooks/useRecordings.ts +110 -0
  38. package/src/hooks/useReportDisplaySize.test.tsx +166 -0
  39. package/src/hooks/useReportDisplaySize.ts +91 -0
  40. package/src/index.ts +27 -1
  41. package/src/menuPositioning.ts +126 -0
  42. package/src/timing.test.ts +34 -0
  43. package/src/timing.ts +21 -0
@@ -0,0 +1,214 @@
1
+ /**
2
+ * KerbalFaceFeed — the shared single-kerbal-face primitive.
3
+ *
4
+ * Renders ONE kerbal's face-camera stream in a fixed SQUARE frame. It is the
5
+ * reusable building block a crew bar (kerbcast web) or a mission-control
6
+ * surface (gonogo) composes: identity-keyed by `flightId`, it binds the live
7
+ * `MediaStream` to a persistent `<video>` and NEVER remounts that element on
8
+ * re-layout (the "never remount a live feed" rule — the same srcObject-via-ref
9
+ * technique `CameraFeed` uses).
10
+ *
11
+ * Deliberately minimal chrome: just the square live face plus a standby glyph
12
+ * when there is no live signal. Everything else — name label, IVA/EVA badge,
13
+ * click behaviour, a "SIGNAL LOST" treatment — is layered OVER it by the
14
+ * wrapper via `children`, driven by the `onFeedStateChange` signal. A thin
15
+ * `actions` bar (the shared `FeedAction` shape) is rendered top-right when a
16
+ * wrapper supplies one, so declarative controls compose the same way they do
17
+ * on `CameraFeed`.
18
+ */
19
+
20
+ import { useEffect, useRef, useState } from "react";
21
+ import styled from "styled-components";
22
+
23
+ import { CameraStreamHook, FeedAction } from "./CameraFeed";
24
+ import { FeedActionBar, feedActionToEntry } from "./FeedActionBar";
25
+ import { useKerbcastStream } from "./hooks/useKerbcastStream";
26
+ import { useKerbcastCameras } from "./hooks/useKerbcastCameras";
27
+ import { useReportDisplaySize } from "./hooks/useReportDisplaySize";
28
+ import { isCameraDestroyed } from "./lifecycle";
29
+ import { StandbyIcon } from "./StandbyIcon";
30
+
31
+ /**
32
+ * Coarse presentation state a wrapper reacts to for its own fallback/badge:
33
+ * - `live`: a stream is bound and playing.
34
+ * - `no-signal`: no stream yet (connecting, out of flight, or not visible).
35
+ * - `destroyed`: the camera's part/kerbal is gone (the wrapper renders SIGNAL
36
+ * LOST; the last frame stays on-screen because the `<video>` is not cleared).
37
+ */
38
+ export type KerbalFeedState = "live" | "no-signal" | "destroyed";
39
+
40
+ export interface KerbalFaceFeedProps {
41
+ /** Wire-id (`CameraState.flightId`) of the kerbal face camera to display. */
42
+ flightId: number;
43
+ /**
44
+ * Explicit square side in px, for LAYOUT only. When omitted the feed fills
45
+ * its parent's width at a 1:1 aspect ratio. This does NOT drive resolution:
46
+ * the primitive always self-measures its rendered box and reports that to the
47
+ * sidecar (auto-resolution). Pass it to fix the element's size, not to
48
+ * request a stream resolution.
49
+ */
50
+ size?: number;
51
+ /**
52
+ * Report the self-measured display size to the sidecar to drive
53
+ * auto-resolution. Default true. Set false for a fixed-resolution feed that
54
+ * should not participate in auto-res. Independent of {@link showActions}
55
+ * (a resolution concern, not a UI one).
56
+ */
57
+ reportSize?: boolean;
58
+ /**
59
+ * Consumer action buttons, rendered as a minimal top-right bar (the shared
60
+ * {@link FeedAction} shape). Omit for no bar — the common case, since a crew
61
+ * bar usually composes its controls in `children` instead.
62
+ */
63
+ actions?: FeedAction[];
64
+ /**
65
+ * Render the feed's action UI (the top-right action bar). Default true. Set
66
+ * false to suppress the bar entirely (e.g. a tiny avatar where hover controls
67
+ * add nothing). Does not affect display-size reporting.
68
+ */
69
+ showActions?: boolean;
70
+ /**
71
+ * Overlay content layered OVER the square face: name label, IVA/EVA badge,
72
+ * click target, a custom destroyed treatment. Absolutely positioned to fill
73
+ * the frame; the primitive keeps its own chrome out of the way.
74
+ */
75
+ children?: React.ReactNode;
76
+ /**
77
+ * Fired whenever the derived feed state changes, so the wrapper can swap its
78
+ * fallback/badge without polling. Fires once on mount with the initial state.
79
+ */
80
+ onFeedStateChange?: (state: KerbalFeedState) => void;
81
+ /**
82
+ * Draw the built-in standby glyph while not `live`. Default true. Set false
83
+ * when the wrapper renders its own out-of-signal overlay (so it isn't drawn
84
+ * twice); the frame then just stays dark / holds the last frame.
85
+ */
86
+ showStandby?: boolean;
87
+ /**
88
+ * Override how the stream is sourced (delayed playout, alternate transport).
89
+ * Must be a stable reference passed consistently across renders — it is
90
+ * called as a hook. Omit to use the built-in {@link useKerbcastStream}.
91
+ */
92
+ useStream?: CameraStreamHook;
93
+ }
94
+
95
+ const Frame = styled.div<{ $size?: number }>`
96
+ position: relative;
97
+ ${(p) => (p.$size != null ? `width: ${p.$size}px; height: ${p.$size}px;` : "width: 100%;")}
98
+ aspect-ratio: 1 / 1;
99
+ overflow: hidden;
100
+ background: #000;
101
+ border-radius: 4px;
102
+ `;
103
+
104
+ const Face = styled.video`
105
+ width: 100%;
106
+ height: 100%;
107
+ /* Cover the square: the portrait fills the frame, cropping the excess,
108
+ rather than letterboxing — a face tile reads best edge-to-edge. */
109
+ object-fit: cover;
110
+ display: block;
111
+ `;
112
+
113
+ const StandbyLayer = styled.div`
114
+ position: absolute;
115
+ inset: 0;
116
+ display: flex;
117
+ align-items: center;
118
+ justify-content: center;
119
+ color: rgba(255, 255, 255, 0.35);
120
+ pointer-events: none;
121
+ `;
122
+
123
+ const DestroyedScrim = styled.div`
124
+ position: absolute;
125
+ inset: 0;
126
+ background: rgba(0, 0, 0, 0.45);
127
+ pointer-events: none;
128
+ `;
129
+
130
+ const OverlayLayer = styled.div`
131
+ position: absolute;
132
+ inset: 0;
133
+ `;
134
+
135
+ /*
136
+ * The shared FeedActionBar is hover-revealed by default (CameraFeed's larger
137
+ * frame fades it in on hover/focus). A tiny face tile has no such affordance,
138
+ * so this override keeps the original always-visible, corner-hugging look;
139
+ * layout/button styling (flex row, gap, the shared OverlayIconButton) still
140
+ * comes from FeedActionBar itself.
141
+ */
142
+ const KerbalActionBarRow = styled(FeedActionBar)`
143
+ top: 4px;
144
+ right: 4px;
145
+ opacity: 1;
146
+ pointer-events: auto;
147
+ `;
148
+
149
+ export function KerbalFaceFeed({
150
+ flightId,
151
+ size,
152
+ reportSize = true,
153
+ actions,
154
+ showActions = true,
155
+ children,
156
+ onFeedStateChange,
157
+ showStandby = true,
158
+ useStream,
159
+ }: KerbalFaceFeedProps) {
160
+ // Match CameraFeed: the built-in hook (which acquires/releases the
161
+ // subscription slot) runs only when no override is supplied.
162
+ const resolveStream = useStream ?? useKerbcastStream;
163
+ const stream = resolveStream(flightId);
164
+
165
+ // The camera's lifecycle comes from the registry snapshot; destroyed cameras
166
+ // linger in the list so the wrapper can show SIGNAL LOST.
167
+ const cameras = useKerbcastCameras();
168
+ const destroyed = cameras.some((c) => c.flightId === flightId && isCameraDestroyed(c));
169
+
170
+ const state: KerbalFeedState = destroyed
171
+ ? "destroyed"
172
+ : stream
173
+ ? "live"
174
+ : "no-signal";
175
+
176
+ // Self-measure the square frame and report it to the sidecar (auto-res).
177
+ // Reporting follows the mount lifecycle; the registry collapses multiple
178
+ // feeds of the same kerbal to one MAX report.
179
+ const frameRef = useRef<HTMLDivElement>(null);
180
+ useReportDisplaySize(flightId, frameRef, { square: true, enabled: reportSize });
181
+
182
+ // Bind the stream to the persistent <video> without remounting it — the
183
+ // element stays mounted across every re-layout / prop change, only its
184
+ // srcObject is (re)assigned. A stall (stream -> null) leaves the last frame.
185
+ const videoRef = useRef<HTMLVideoElement>(null);
186
+ useEffect(() => {
187
+ if (videoRef.current && stream) {
188
+ videoRef.current.srcObject = stream;
189
+ }
190
+ }, [stream]);
191
+
192
+ // Notify the wrapper on every state transition (and once on mount).
193
+ const notify = useRef(onFeedStateChange);
194
+ notify.current = onFeedStateChange;
195
+ useEffect(() => {
196
+ notify.current?.(state);
197
+ }, [state]);
198
+
199
+ return (
200
+ <Frame ref={frameRef} $size={size} data-testid="kerbal-face-feed" data-feed-state={state}>
201
+ <Face ref={videoRef} autoPlay playsInline muted controls={false} />
202
+ {state === "destroyed" && <DestroyedScrim />}
203
+ {showStandby && state !== "live" && (
204
+ <StandbyLayer>
205
+ <StandbyIcon size={Math.round((size ?? 96) * 0.32)} />
206
+ </StandbyLayer>
207
+ )}
208
+ {showActions && actions && actions.length > 0 && (
209
+ <KerbalActionBarRow entries={actions.map(feedActionToEntry)} />
210
+ )}
211
+ {children && <OverlayLayer>{children}</OverlayLayer>}
212
+ </Frame>
213
+ );
214
+ }
@@ -0,0 +1,288 @@
1
+ /*
2
+ * RecordingsTray.
3
+ *
4
+ * `useRecordings()` is mocked with a store that mirrors the real hook's shape
5
+ * (its own per-mount useState, so `discard`/`discardGroup` naturally re-render
6
+ * the tray exactly like the real hook does) seeded per test via
7
+ * `fakeStoreSeed` (shared with the mock factory through `vi.hoisted`, the
8
+ * documented way to reach into a hoisted `vi.mock`). This keeps the tray's
9
+ * rendering/formatting/object-URL logic under test without a real
10
+ * MediaRecorder track (jsdom's noise-pipeline stub has none -- see the same
11
+ * note in CameraFeed.test.tsx).
12
+ *
13
+ * jsdom has no working `URL.createObjectURL`/`revokeObjectURL`, so both are
14
+ * stubbed per test (same pattern as web/src/mock/driver.test.ts).
15
+ */
16
+
17
+ import type { GroupedRecordingHandle, RecordingHandle } from "@ksp-gonogo/kerbcast";
18
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
19
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
20
+ import type { ActiveRecordingInfo } from "./hooks/useRecordings";
21
+ import { RecordingsTray } from "./RecordingsTray";
22
+
23
+ const fakeStoreSeed = vi.hoisted(() => ({
24
+ recordings: [] as unknown[],
25
+ groups: [] as unknown[],
26
+ active: [] as unknown[],
27
+ }));
28
+
29
+ const storeSpies = vi.hoisted(() => ({
30
+ discard: vi.fn(),
31
+ discardGroup: vi.fn(),
32
+ }));
33
+
34
+ vi.mock("./hooks/useRecordings", async () => {
35
+ const { useState } = await import("react");
36
+
37
+ function useRecordings() {
38
+ const [state, setState] = useState(() => ({
39
+ recordings: fakeStoreSeed.recordings,
40
+ groups: fakeStoreSeed.groups,
41
+ active: fakeStoreSeed.active,
42
+ }));
43
+
44
+ return {
45
+ recordings: state.recordings,
46
+ groups: state.groups,
47
+ active: state.active,
48
+ isRecording: () => false,
49
+ start: vi.fn(),
50
+ stop: vi.fn(),
51
+ startGroup: vi.fn(),
52
+ stopGroup: vi.fn(),
53
+ discard: (recordingId: string) => {
54
+ storeSpies.discard(recordingId);
55
+ setState((s) => ({
56
+ ...s,
57
+ recordings: (s.recordings as { recordingId: string }[]).filter(
58
+ (r) => r.recordingId !== recordingId,
59
+ ),
60
+ }));
61
+ },
62
+ discardGroup: (groupId: string) => {
63
+ storeSpies.discardGroup(groupId);
64
+ setState((s) => ({
65
+ ...s,
66
+ groups: (s.groups as { groupId: string }[]).filter((g) => g.groupId !== groupId),
67
+ }));
68
+ },
69
+ };
70
+ }
71
+
72
+ return { useRecordings };
73
+ });
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Fixtures
77
+ // ---------------------------------------------------------------------------
78
+
79
+ function makeRecording(overrides: Partial<RecordingHandle> = {}): RecordingHandle {
80
+ return {
81
+ recordingId: "rec-1",
82
+ flightId: 42,
83
+ blob: new Blob(["clip"], { type: "video/webm" }),
84
+ mimeType: "video/webm",
85
+ utStart: 100,
86
+ utEnd: 160,
87
+ utSamples: [],
88
+ byteSize: 2048,
89
+ durationMs: 65_000,
90
+ ...overrides,
91
+ };
92
+ }
93
+
94
+ function makeGroup(overrides: Partial<GroupedRecordingHandle> = {}): GroupedRecordingHandle {
95
+ return {
96
+ groupId: "grp-1",
97
+ recordings: [
98
+ makeRecording({ recordingId: "rec-a" }),
99
+ makeRecording({ recordingId: "rec-b" }),
100
+ ],
101
+ commonUtWindow: [100, 150],
102
+ ...overrides,
103
+ };
104
+ }
105
+
106
+ function activeInfo(overrides: Partial<ActiveRecordingInfo> = {}): ActiveRecordingInfo {
107
+ return { recordingId: "rec-1", flightId: 42, startedAt: 0, ...overrides };
108
+ }
109
+
110
+ let urlCounter = 0;
111
+
112
+ beforeEach(() => {
113
+ urlCounter = 0;
114
+ vi.spyOn(URL, "createObjectURL").mockImplementation(() => `blob:mock-${urlCounter++}`);
115
+ vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
116
+ });
117
+
118
+ afterEach(() => {
119
+ cleanup();
120
+ vi.restoreAllMocks();
121
+ storeSpies.discard.mockClear();
122
+ storeSpies.discardGroup.mockClear();
123
+ fakeStoreSeed.recordings = [];
124
+ fakeStoreSeed.groups = [];
125
+ fakeStoreSeed.active = [];
126
+ });
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Tests
130
+ // ---------------------------------------------------------------------------
131
+
132
+ describe("RecordingsTray - empty state", () => {
133
+ it("shows a placeholder when there are no clips", () => {
134
+ render(<RecordingsTray />);
135
+ expect(screen.getByText(/no recordings yet/i)).toBeTruthy();
136
+ expect(screen.queryByText(/unsaved clips/i)).toBeNull();
137
+ });
138
+ });
139
+
140
+ describe("RecordingsTray - single clips", () => {
141
+ it("renders a clip's camera name, UT range, duration, and size", () => {
142
+ fakeStoreSeed.active = [activeInfo({ recordingId: "rec-1", flightId: 42 })];
143
+ fakeStoreSeed.recordings = [
144
+ makeRecording({ recordingId: "rec-1", utStart: 100, utEnd: 160, durationMs: 65_000, byteSize: 2048 }),
145
+ ];
146
+
147
+ render(<RecordingsTray cameraLabel={(flightId) => `Cam ${flightId}`} />);
148
+
149
+ expect(screen.getByText("Cam 42")).toBeTruthy();
150
+ expect(screen.getByText(/T\+100s.*T\+160s/)).toBeTruthy();
151
+ expect(screen.getByText(/1:05/)).toBeTruthy(); // 65s
152
+ expect(screen.getByText(/2\.0 KB/)).toBeTruthy();
153
+ expect(screen.getByText(/unsaved clips/i)).toBeTruthy();
154
+ });
155
+
156
+ it("falls back to 'Camera <flightId>' when no cameraLabel prop is given", () => {
157
+ fakeStoreSeed.recordings = [makeRecording({ recordingId: "rec-1", flightId: 7 })];
158
+ render(<RecordingsTray />);
159
+ expect(screen.getByText("Camera 7")).toBeTruthy();
160
+ });
161
+
162
+ it("labels a finished clip from its own flightId, even when the tray mounts after the recording finished (never in `active`)", () => {
163
+ fakeStoreSeed.active = []; // the tray mounted after the clip finished
164
+ fakeStoreSeed.recordings = [makeRecording({ recordingId: "rec-1", flightId: 99 })];
165
+ render(<RecordingsTray cameraLabel={(flightId) => `Cam ${flightId}`} />);
166
+ expect(screen.getByText("Cam 99")).toBeTruthy();
167
+ });
168
+
169
+ it("shows 'no UT' when the clip has no UT range", () => {
170
+ fakeStoreSeed.recordings = [
171
+ makeRecording({ recordingId: "rec-1", utStart: undefined, utEnd: undefined }),
172
+ ];
173
+ render(<RecordingsTray />);
174
+ expect(screen.getByText(/no ut \(out of flight\)/i)).toBeTruthy();
175
+ });
176
+
177
+ it("download uses an object URL created from the clip's blob", () => {
178
+ const blob = new Blob(["specific"], { type: "video/webm" });
179
+ fakeStoreSeed.recordings = [makeRecording({ recordingId: "rec-1", blob })];
180
+ render(<RecordingsTray />);
181
+
182
+ expect(URL.createObjectURL).toHaveBeenCalledWith(blob);
183
+ const link = screen.getByText("Download").closest("a");
184
+ expect(link?.getAttribute("href")).toBe("blob:mock-0");
185
+ expect(link?.hasAttribute("download")).toBe(true);
186
+ });
187
+
188
+ it("mounts a <video> preview sourced from the clip's object URL", () => {
189
+ fakeStoreSeed.recordings = [makeRecording({ recordingId: "rec-1" })];
190
+ const { container } = render(<RecordingsTray />);
191
+ const video = container.querySelector("video");
192
+ expect(video).toBeTruthy();
193
+ expect(video?.getAttribute("src")).toBe("blob:mock-0");
194
+ });
195
+
196
+ it("discard calls the store and revokes the clip's object URL", () => {
197
+ fakeStoreSeed.recordings = [makeRecording({ recordingId: "rec-1" })];
198
+ render(<RecordingsTray />);
199
+
200
+ fireEvent.click(screen.getByText("Discard"));
201
+
202
+ expect(storeSpies.discard).toHaveBeenCalledWith("rec-1");
203
+ expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:mock-0");
204
+ expect(screen.queryByText("Discard")).toBeNull();
205
+ });
206
+ });
207
+
208
+ describe("RecordingsTray - grouped sets", () => {
209
+ it("renders a group's clips together with a download-all", () => {
210
+ fakeStoreSeed.groups = [
211
+ makeGroup({
212
+ groupId: "grp-1",
213
+ recordings: [
214
+ makeRecording({ recordingId: "rec-a" }),
215
+ makeRecording({ recordingId: "rec-b" }),
216
+ ],
217
+ }),
218
+ ];
219
+ render(<RecordingsTray />);
220
+
221
+ expect(screen.getByText(/grouped recording/i)).toBeTruthy();
222
+ expect(screen.getByText(/2 feeds/i)).toBeTruthy();
223
+ expect(screen.getAllByText("Download")).toHaveLength(2);
224
+ expect(screen.getByText("Download all")).toBeTruthy();
225
+ });
226
+
227
+ it("download-all clicks one <a> per member", () => {
228
+ fakeStoreSeed.groups = [
229
+ makeGroup({
230
+ groupId: "grp-1",
231
+ recordings: [
232
+ makeRecording({ recordingId: "rec-a" }),
233
+ makeRecording({ recordingId: "rec-b" }),
234
+ ],
235
+ }),
236
+ ];
237
+ const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
238
+ render(<RecordingsTray />);
239
+
240
+ fireEvent.click(screen.getByText("Download all"));
241
+
242
+ expect(clickSpy).toHaveBeenCalledTimes(2);
243
+ });
244
+
245
+ it("labels grouped clips from their own flightId, with no active info needed", () => {
246
+ fakeStoreSeed.active = [];
247
+ fakeStoreSeed.groups = [
248
+ makeGroup({
249
+ groupId: "grp-1",
250
+ recordings: [
251
+ makeRecording({ recordingId: "rec-a", flightId: 10 }),
252
+ makeRecording({ recordingId: "rec-b", flightId: 11 }),
253
+ ],
254
+ }),
255
+ ];
256
+ render(<RecordingsTray cameraLabel={(flightId) => `Cam ${flightId}`} />);
257
+
258
+ expect(screen.getByText("Cam 10")).toBeTruthy();
259
+ expect(screen.getByText("Cam 11")).toBeTruthy();
260
+ });
261
+
262
+ it("discard set calls the store's discardGroup, not a per-clip discard", () => {
263
+ fakeStoreSeed.groups = [makeGroup({ groupId: "grp-1" })];
264
+ render(<RecordingsTray />);
265
+
266
+ fireEvent.click(screen.getByText("Discard set"));
267
+
268
+ expect(storeSpies.discardGroup).toHaveBeenCalledWith("grp-1");
269
+ expect(storeSpies.discard).not.toHaveBeenCalled();
270
+ expect(screen.queryByText(/grouped recording/i)).toBeNull();
271
+ });
272
+ });
273
+
274
+ describe("RecordingsTray - object URL lifecycle", () => {
275
+ it("revokes every outstanding object URL on unmount", () => {
276
+ fakeStoreSeed.recordings = [
277
+ makeRecording({ recordingId: "rec-1" }),
278
+ makeRecording({ recordingId: "rec-2" }),
279
+ ];
280
+ const { unmount } = render(<RecordingsTray />);
281
+ expect(URL.createObjectURL).toHaveBeenCalledTimes(2);
282
+
283
+ unmount();
284
+
285
+ expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:mock-0");
286
+ expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:mock-1");
287
+ });
288
+ });