@ksp-gonogo/kerbcast-react 1.7.0 → 1.8.1

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.
@@ -40,9 +40,125 @@ import {
40
40
  CameraFeed,
41
41
  type CameraFeedHandle,
42
42
  type CameraStreamHook,
43
+ type FeedAction,
43
44
  } from "./CameraFeed";
44
45
  import { KerbcastProvider } from "./context";
45
46
 
47
+ /*
48
+ * The REC control reads/drives `useRecordings()` (see CameraFeed - REC below).
49
+ * The real hook sits on top of a real `RecordingController`, which under
50
+ * jsdom needs a track-bearing MediaStream the noise-pipeline stub doesn't
51
+ * produce (`canvas.captureStream()` is stubbed empty). Stand in a fake store
52
+ * that mirrors the real hook's shape (its own per-component useState, so
53
+ * start/stop naturally re-render the owning CameraFeed exactly like the real
54
+ * hook does) so the REC tests exercise CameraFeed's wiring to the store's
55
+ * contract without depending on a real MediaRecorder track.
56
+ */
57
+ const recordingsStoreSpies = vi.hoisted(() => ({
58
+ start: vi.fn(),
59
+ stop: vi.fn(),
60
+ /** When true, the fake store's stop() rejects instead of resolving -- lets
61
+ * a test drive CameraFeed's stop-failure path (see "CameraFeed - REC"). */
62
+ stopRejects: false,
63
+ /** Flips a still-arming fake recording out of arming, simulating the real
64
+ * controller's arm-and-wait resolving once the feed reaches full
65
+ * resolution. Given its real implementation by the mock factory below
66
+ * (it needs the factory's own closured resolver map, unavailable at
67
+ * hoist time). */
68
+ resolveArming: (_recordingId: string) => {},
69
+ }));
70
+
71
+ vi.mock("./hooks/useRecordings", async () => {
72
+ const { useCallback, useState: useReactState } = await import("react");
73
+
74
+ interface FakeActive {
75
+ recordingId: string;
76
+ flightId: number;
77
+ startedAt: number;
78
+ arming: boolean;
79
+ }
80
+
81
+ /* recordingId -> the setState callback that flips that entry's arming off,
82
+ invoked by test code via recordingsStoreSpies.resolveArming. */
83
+ const armingResolvers = new Map<string, () => void>();
84
+ recordingsStoreSpies.resolveArming = (recordingId: string) => {
85
+ armingResolvers.get(recordingId)?.();
86
+ armingResolvers.delete(recordingId);
87
+ };
88
+
89
+ function useRecordings() {
90
+ const [state, setState] = useReactState<{
91
+ active: FakeActive[];
92
+ recordings: unknown[];
93
+ }>({ active: [], recordings: [] });
94
+
95
+ const start = useCallback(
96
+ (flightId: number, opts?: { forceFullResolution?: boolean }): string => {
97
+ recordingsStoreSpies.start(flightId, opts);
98
+ const recordingId = `rec-${flightId}`;
99
+ const forced = opts?.forceFullResolution === true;
100
+ setState((s) => ({
101
+ ...s,
102
+ active: [
103
+ ...s.active,
104
+ { recordingId, flightId, startedAt: performance.now(), arming: forced },
105
+ ],
106
+ }));
107
+ if (forced) {
108
+ armingResolvers.set(recordingId, () => {
109
+ setState((s) => ({
110
+ ...s,
111
+ active: s.active.map((a) =>
112
+ a.recordingId === recordingId
113
+ ? { ...a, arming: false, startedAt: performance.now() }
114
+ : a,
115
+ ),
116
+ }));
117
+ });
118
+ }
119
+ return recordingId;
120
+ },
121
+ [],
122
+ );
123
+
124
+ const stop = useCallback(async (recordingId: string) => {
125
+ recordingsStoreSpies.stop(recordingId);
126
+ armingResolvers.delete(recordingId);
127
+ if (recordingsStoreSpies.stopRejects) {
128
+ throw new Error("stop failed");
129
+ }
130
+ const handle = {
131
+ recordingId,
132
+ blob: new Blob(),
133
+ mimeType: "video/webm",
134
+ utSamples: [],
135
+ byteSize: 0,
136
+ durationMs: 0,
137
+ };
138
+ setState((s) => ({
139
+ active: s.active.filter((a) => a.recordingId !== recordingId),
140
+ recordings: [...s.recordings, handle],
141
+ }));
142
+ return handle;
143
+ }, []);
144
+
145
+ return {
146
+ recordings: state.recordings,
147
+ groups: [],
148
+ active: state.active,
149
+ isRecording: (flightId: number) => state.active.some((a) => a.flightId === flightId),
150
+ start,
151
+ stop,
152
+ startGroup: vi.fn(),
153
+ stopGroup: vi.fn(),
154
+ discard: vi.fn(),
155
+ discardGroup: vi.fn(),
156
+ };
157
+ }
158
+
159
+ return { useRecordings };
160
+ });
161
+
46
162
  // ---------------------------------------------------------------------------
47
163
  // Camera-state fixture factory
48
164
  // ---------------------------------------------------------------------------
@@ -156,6 +272,12 @@ function renderFeed(
156
272
  renderSize?: "auto" | "none";
157
273
  enableQualityControl?: boolean;
158
274
  enableTracking?: boolean;
275
+ enableRecording?: boolean;
276
+ recordFullResolution?: boolean;
277
+ enableFullscreen?: boolean;
278
+ enablePictureInPicture?: boolean;
279
+ actions?: FeedAction[];
280
+ trailingActions?: FeedAction[];
159
281
  showStatic?: boolean;
160
282
  showStandbyIcon?: boolean;
161
283
  showActions?: boolean;
@@ -216,6 +338,9 @@ afterEach(() => {
216
338
  }
217
339
  createdClients.length = 0;
218
340
  vi.restoreAllMocks();
341
+ recordingsStoreSpies.start.mockClear();
342
+ recordingsStoreSpies.stop.mockClear();
343
+ recordingsStoreSpies.stopRejects = false;
219
344
  });
220
345
 
221
346
  // ---------------------------------------------------------------------------
@@ -1412,6 +1537,50 @@ describe("CameraFeed - showActions", () => {
1412
1537
  });
1413
1538
  });
1414
1539
 
1540
+ // ---------------------------------------------------------------------------
1541
+ // Action-bar overflow threshold crossing -- never-remount regression
1542
+ // ---------------------------------------------------------------------------
1543
+
1544
+ describe("CameraFeed - action bar overflow threshold crossing", () => {
1545
+ it("never remounts the <video> element when the actions count crosses the overflow threshold", async () => {
1546
+ const { client } = await buildConnectedSource();
1547
+
1548
+ function Harness() {
1549
+ const [count, setCount] = useState(3);
1550
+ const actions = Array.from({ length: count }, (_, i) => ({
1551
+ id: `extra-${i}`,
1552
+ label: `Extra ${i}`,
1553
+ icon: <span>{i}</span>,
1554
+ onClick: () => {},
1555
+ }));
1556
+ return (
1557
+ <KerbcastProvider client={client}>
1558
+ <CameraFeed flightId={42} actions={actions} />
1559
+ <button type="button" onClick={() => setCount(4)}>
1560
+ grow
1561
+ </button>
1562
+ </KerbcastProvider>
1563
+ );
1564
+ }
1565
+
1566
+ const { container } = render(<Harness />);
1567
+ const before = container.querySelector("video");
1568
+ expect(before).toBeTruthy();
1569
+ // Below the threshold (3 total): every action is inline, no ⋮ trigger.
1570
+ expect(screen.queryByRole("button", { name: /more actions/i })).toBeNull();
1571
+
1572
+ await act(async () => {
1573
+ fireEvent.click(screen.getByRole("button", { name: /^grow$/i }));
1574
+ });
1575
+
1576
+ // Crossed the threshold (4 total, all non-stateful >= 2): the ⋮ trigger
1577
+ // now appears -- but the SAME <video> node is still there, never remounted.
1578
+ expect(screen.getByRole("button", { name: /more actions/i })).toBeTruthy();
1579
+ const after = container.querySelector("video");
1580
+ expect(after).toBe(before);
1581
+ });
1582
+ });
1583
+
1415
1584
  // ---------------------------------------------------------------------------
1416
1585
  // Pan reticle
1417
1586
  // ---------------------------------------------------------------------------
@@ -1846,3 +2015,379 @@ describe("CameraFeed - tracking (crosshair)", () => {
1846
2015
  expect((getByLabelText("Pan left") as HTMLButtonElement).disabled).toBe(false);
1847
2016
  });
1848
2017
  });
2018
+
2019
+ // ---------------------------------------------------------------------------
2020
+ // REC (per-feed recording)
2021
+ // ---------------------------------------------------------------------------
2022
+
2023
+ describe("CameraFeed - REC", () => {
2024
+ it("does not render REC without enableRecording", async () => {
2025
+ const { client } = await buildConnectedSource();
2026
+ const { queryByLabelText } = renderFeed(client, { flightId: 42 });
2027
+ expect(queryByLabelText("Start recording")).toBeNull();
2028
+ });
2029
+
2030
+ it("renders an idle REC toggle when enableRecording", async () => {
2031
+ const { client } = await buildConnectedSource();
2032
+ const { getByLabelText, queryByLabelText } = renderFeed(client, {
2033
+ flightId: 42,
2034
+ enableRecording: true,
2035
+ });
2036
+ const rec = getByLabelText("Start recording");
2037
+ expect(rec.getAttribute("aria-pressed")).toBe("false");
2038
+ // No red-dot/timer badge while idle.
2039
+ expect(queryByLabelText("Recording")).toBeNull();
2040
+ });
2041
+
2042
+ it("clicking idle REC starts a recording via the store", async () => {
2043
+ const { client } = await buildConnectedSource();
2044
+ const { getByLabelText } = renderFeed(client, {
2045
+ flightId: 42,
2046
+ enableRecording: true,
2047
+ });
2048
+
2049
+ fireEvent.click(getByLabelText("Start recording"));
2050
+
2051
+ expect(recordingsStoreSpies.start).toHaveBeenCalledWith(42, { forceFullResolution: false });
2052
+ const rec = getByLabelText("Stop recording");
2053
+ expect(rec.getAttribute("aria-pressed")).toBe("true");
2054
+ expect(getByLabelText("Recording")).toBeTruthy();
2055
+ });
2056
+
2057
+ it("clicking active REC stops the recording via the store", async () => {
2058
+ const { client } = await buildConnectedSource();
2059
+ const { getByLabelText, queryByLabelText } = renderFeed(client, {
2060
+ flightId: 42,
2061
+ enableRecording: true,
2062
+ });
2063
+
2064
+ fireEvent.click(getByLabelText("Start recording"));
2065
+ await act(async () => {
2066
+ fireEvent.click(getByLabelText("Stop recording"));
2067
+ });
2068
+
2069
+ expect(recordingsStoreSpies.stop).toHaveBeenCalledWith("rec-42");
2070
+ expect(getByLabelText("Start recording").getAttribute("aria-pressed")).toBe("false");
2071
+ expect(queryByLabelText("Recording")).toBeNull();
2072
+ });
2073
+
2074
+ it("does not leave an unhandled rejection when the store's stop() fails", async () => {
2075
+ recordingsStoreSpies.stopRejects = true;
2076
+ const { client } = await buildConnectedSource();
2077
+ const { getByLabelText } = renderFeed(client, {
2078
+ flightId: 42,
2079
+ enableRecording: true,
2080
+ });
2081
+
2082
+ fireEvent.click(getByLabelText("Start recording"));
2083
+ await act(async () => {
2084
+ fireEvent.click(getByLabelText("Stop recording"));
2085
+ await Promise.resolve();
2086
+ await Promise.resolve();
2087
+ });
2088
+
2089
+ /* Reaching here without vitest reporting an unhandled rejection is the
2090
+ assertion: toggleRecording's void recordings.stop(...) must catch. */
2091
+ expect(recordingsStoreSpies.stop).toHaveBeenCalledWith("rec-42");
2092
+ });
2093
+
2094
+ it("reads recording state from the store, not local state", async () => {
2095
+ // Two feeds on independent flightIds: starting one's recording must not
2096
+ // flip the other's REC control -- isRecording(flightId) is per-feed.
2097
+ const { client } = await buildConnectedSource([
2098
+ makeCamera({ flightId: 42, cameraName: "Cam A" }),
2099
+ makeCamera({ flightId: 43, cameraName: "Cam B" }),
2100
+ ]);
2101
+ renderFeed(client, { flightId: 42, enableRecording: true });
2102
+ renderFeed(client, { flightId: 43, enableRecording: true });
2103
+
2104
+ fireEvent.click(screen.getAllByLabelText("Start recording")[0]);
2105
+
2106
+ const stopButtons = screen.queryAllByLabelText("Stop recording");
2107
+ const idleButtons = screen.queryAllByLabelText("Start recording");
2108
+ expect(stopButtons).toHaveLength(1);
2109
+ expect(idleButtons).toHaveLength(1);
2110
+ });
2111
+
2112
+ it("shows a live elapsed timer while recording", async () => {
2113
+ vi.useFakeTimers({
2114
+ toFake: ["setTimeout", "clearTimeout", "setInterval", "clearInterval", "Date", "performance"],
2115
+ });
2116
+ try {
2117
+ const { client } = await buildConnectedSource();
2118
+ const { getByLabelText } = renderFeed(client, {
2119
+ flightId: 42,
2120
+ enableRecording: true,
2121
+ });
2122
+
2123
+ act(() => {
2124
+ fireEvent.click(getByLabelText("Start recording"));
2125
+ });
2126
+ expect(getByLabelText("Recording").textContent).toBe("REC 0:00");
2127
+
2128
+ await act(async () => {
2129
+ await vi.advanceTimersByTimeAsync(3000);
2130
+ });
2131
+ expect(getByLabelText("Recording").textContent).toBe("REC 0:03");
2132
+ } finally {
2133
+ vi.useRealTimers();
2134
+ }
2135
+ });
2136
+
2137
+ it("REC is a stateful primary that never enters the ⋮ overflow", async () => {
2138
+ const { client } = await buildConnectedSource();
2139
+ const extraActions: FeedAction[] = Array.from({ length: 3 }, (_, i) => ({
2140
+ id: `extra-${i}`,
2141
+ label: `Extra ${i}`,
2142
+ icon: <span>{i}</span>,
2143
+ onClick: () => {},
2144
+ }));
2145
+ const { getByLabelText, getByRole } = renderFeed(client, {
2146
+ flightId: 42,
2147
+ enableRecording: true,
2148
+ actions: extraActions,
2149
+ });
2150
+
2151
+ // Crowded (>= 4 total, >= 2 non-stateful): the overflow trigger exists...
2152
+ expect(getByRole("button", { name: /more actions/i })).toBeTruthy();
2153
+ // ...but REC is directly queryable without opening it -- it never
2154
+ // collapsed into the menu.
2155
+ expect(getByLabelText("Start recording")).toBeTruthy();
2156
+ });
2157
+
2158
+ it("never remounts the <video> element across a start/stop cycle", async () => {
2159
+ const { client } = await buildConnectedSource();
2160
+ const { container, getByLabelText } = renderFeed(client, {
2161
+ flightId: 42,
2162
+ enableRecording: true,
2163
+ });
2164
+ const before = container.querySelector("video");
2165
+ expect(before).toBeTruthy();
2166
+
2167
+ fireEvent.click(getByLabelText("Start recording"));
2168
+ await act(async () => {
2169
+ fireEvent.click(getByLabelText("Stop recording"));
2170
+ });
2171
+
2172
+ const after = container.querySelector("video");
2173
+ expect(after).toBe(before);
2174
+ });
2175
+ });
2176
+
2177
+ describe("CameraFeed - recordFullResolution", () => {
2178
+ it("threads recordFullResolution: true into the store's start call", async () => {
2179
+ const { client } = await buildConnectedSource();
2180
+ const { getByLabelText } = renderFeed(client, {
2181
+ flightId: 42,
2182
+ enableRecording: true,
2183
+ recordFullResolution: true,
2184
+ });
2185
+
2186
+ fireEvent.click(getByLabelText("Start recording"));
2187
+
2188
+ expect(recordingsStoreSpies.start).toHaveBeenCalledWith(42, {
2189
+ forceFullResolution: true,
2190
+ });
2191
+ });
2192
+
2193
+ it("sends forceFullResolution: false when the prop is absent (default)", async () => {
2194
+ const { client } = await buildConnectedSource();
2195
+ const { getByLabelText } = renderFeed(client, {
2196
+ flightId: 42,
2197
+ enableRecording: true,
2198
+ });
2199
+
2200
+ fireEvent.click(getByLabelText("Start recording"));
2201
+
2202
+ expect(recordingsStoreSpies.start).toHaveBeenCalledWith(42, {
2203
+ forceFullResolution: false,
2204
+ });
2205
+ });
2206
+
2207
+ it("shows the ARMING pill instead of the REC badge while arming, then swaps to the REC badge", async () => {
2208
+ const { client } = await buildConnectedSource();
2209
+ const { getByLabelText, queryByLabelText } = renderFeed(client, {
2210
+ flightId: 42,
2211
+ enableRecording: true,
2212
+ recordFullResolution: true,
2213
+ });
2214
+
2215
+ fireEvent.click(getByLabelText("Start recording"));
2216
+
2217
+ expect(getByLabelText("Arming").textContent).toBe("ARMING");
2218
+ expect(queryByLabelText("Recording")).toBeNull();
2219
+
2220
+ await act(async () => {
2221
+ recordingsStoreSpies.resolveArming("rec-42");
2222
+ });
2223
+
2224
+ expect(queryByLabelText("Arming")).toBeNull();
2225
+ expect(getByLabelText("Recording").textContent).toMatch(/^REC /);
2226
+ });
2227
+
2228
+ it("never remounts the <video> element across the arming -> recording transition", async () => {
2229
+ const { client } = await buildConnectedSource();
2230
+ const { container, getByLabelText } = renderFeed(client, {
2231
+ flightId: 42,
2232
+ enableRecording: true,
2233
+ recordFullResolution: true,
2234
+ });
2235
+ const before = container.querySelector("video");
2236
+ expect(before).toBeTruthy();
2237
+
2238
+ fireEvent.click(getByLabelText("Start recording"));
2239
+ expect(getByLabelText("Arming")).toBeTruthy();
2240
+
2241
+ await act(async () => {
2242
+ recordingsStoreSpies.resolveArming("rec-42");
2243
+ });
2244
+ expect(getByLabelText("Recording")).toBeTruthy();
2245
+
2246
+ const after = container.querySelector("video");
2247
+ expect(after).toBe(before);
2248
+ });
2249
+
2250
+ it("does not show the ARMING pill when recordFullResolution is false (default)", async () => {
2251
+ const { client } = await buildConnectedSource();
2252
+ const { getByLabelText, queryByLabelText } = renderFeed(client, {
2253
+ flightId: 42,
2254
+ enableRecording: true,
2255
+ });
2256
+
2257
+ fireEvent.click(getByLabelText("Start recording"));
2258
+
2259
+ expect(queryByLabelText("Arming")).toBeNull();
2260
+ expect(getByLabelText("Recording")).toBeTruthy();
2261
+ });
2262
+ });
2263
+
2264
+ describe("CameraFeed - recording locks source and close", () => {
2265
+ it("disables the camera source dropdown while recording, with a hint, and re-enables on stop", async () => {
2266
+ const { client } = await buildConnectedSource();
2267
+ const { getByLabelText, getByRole } = renderFeed(client, {
2268
+ flightId: 42,
2269
+ enableRecording: true,
2270
+ });
2271
+
2272
+ const source = getByRole<HTMLButtonElement>("button", { name: /starboard cam/i });
2273
+ expect(source.disabled).toBe(false);
2274
+
2275
+ fireEvent.click(getByLabelText("Start recording"));
2276
+
2277
+ expect(source.disabled).toBe(true);
2278
+ expect(source.title).toBe("Stop recording to change camera");
2279
+
2280
+ await act(async () => {
2281
+ fireEvent.click(getByLabelText("Stop recording"));
2282
+ });
2283
+
2284
+ expect(source.disabled).toBe(false);
2285
+ });
2286
+
2287
+ it("does not open the camera menu when the source dropdown is clicked while recording", async () => {
2288
+ const { client } = await buildConnectedSource([
2289
+ makeCamera({ flightId: 42, cameraName: "Starboard Cam" }),
2290
+ makeCamera({ flightId: 43, cameraName: "Nose Cam" }),
2291
+ ]);
2292
+ const { getByLabelText, getByRole, queryAllByRole } = renderFeed(client, {
2293
+ flightId: 42,
2294
+ enableRecording: true,
2295
+ });
2296
+
2297
+ fireEvent.click(getByLabelText("Start recording"));
2298
+ fireEvent.click(getByRole("button", { name: /starboard cam/i }));
2299
+
2300
+ expect(queryAllByRole("menuitemradio")).toHaveLength(0);
2301
+ });
2302
+
2303
+ it("disables the pinned trailing close control while recording and re-enables on stop", async () => {
2304
+ const onClose = vi.fn();
2305
+ const { client } = await buildConnectedSource();
2306
+ const { getByLabelText } = renderFeed(client, {
2307
+ flightId: 42,
2308
+ enableRecording: true,
2309
+ trailingActions: [
2310
+ { id: "close", label: "Close", icon: <span>x</span>, onClick: onClose },
2311
+ ],
2312
+ });
2313
+
2314
+ const close = getByLabelText("Close") as HTMLButtonElement;
2315
+ expect(close.disabled).toBe(false);
2316
+
2317
+ fireEvent.click(getByLabelText("Start recording"));
2318
+
2319
+ expect(close.disabled).toBe(true);
2320
+ expect(close.title).toBe("Stop recording to close this feed");
2321
+ fireEvent.click(close);
2322
+ expect(onClose).not.toHaveBeenCalled();
2323
+
2324
+ await act(async () => {
2325
+ fireEvent.click(getByLabelText("Stop recording"));
2326
+ });
2327
+
2328
+ expect(close.disabled).toBe(false);
2329
+ fireEvent.click(close);
2330
+ expect(onClose).toHaveBeenCalledTimes(1);
2331
+ });
2332
+
2333
+ it("keeps tracking, PiP, fullscreen, and the REC(stop) button enabled while recording", async () => {
2334
+ const fsDoc = document as Document & { fullscreenEnabled?: boolean };
2335
+ const pipDoc = document as Document & { pictureInPictureEnabled?: boolean };
2336
+ Object.defineProperty(fsDoc, "fullscreenEnabled", { value: true, configurable: true });
2337
+ Object.defineProperty(pipDoc, "pictureInPictureEnabled", { value: true, configurable: true });
2338
+
2339
+ try {
2340
+ const { client } = await buildConnectedSource([
2341
+ makeCamera({
2342
+ flightId: 42,
2343
+ cameraName: "Launchpad Cam",
2344
+ supportsPan: true,
2345
+ supportsZoom: true,
2346
+ panPitchMin: -90,
2347
+ panPitchMax: 90,
2348
+ }),
2349
+ ]);
2350
+ const { getByLabelText, queryByLabelText } = renderFeed(client, {
2351
+ flightId: 42,
2352
+ enableRecording: true,
2353
+ enableTracking: true,
2354
+ enableFullscreen: true,
2355
+ enablePictureInPicture: true,
2356
+ });
2357
+
2358
+ fireEvent.click(getByLabelText("Start recording"));
2359
+
2360
+ // Tracking + REC are stateful primaries, always inline; PiP + fullscreen
2361
+ // (non-stateful) collapse into the ⋮ overflow once the row is crowded
2362
+ // (REC + tracking + PiP + fullscreen == 4), so open it to reach them.
2363
+ const overflowTrigger = queryByLabelText("More actions");
2364
+ if (overflowTrigger) fireEvent.click(overflowTrigger);
2365
+
2366
+ expect((getByLabelText("Auto-track") as HTMLButtonElement).disabled).toBe(false);
2367
+ expect((getByLabelText("Picture in picture") as HTMLButtonElement).disabled).toBe(false);
2368
+ expect((getByLabelText("Enter fullscreen") as HTMLButtonElement).disabled).toBe(false);
2369
+ expect((getByLabelText("Stop recording") as HTMLButtonElement).disabled).toBe(false);
2370
+ } finally {
2371
+ delete (fsDoc as { fullscreenEnabled?: boolean }).fullscreenEnabled;
2372
+ delete (pipDoc as { pictureInPictureEnabled?: boolean }).pictureInPictureEnabled;
2373
+ }
2374
+ });
2375
+
2376
+ it("leaves the source dropdown and close control enabled when not recording", async () => {
2377
+ const onClose = vi.fn();
2378
+ const { client } = await buildConnectedSource();
2379
+ const { getByLabelText, getByRole } = renderFeed(client, {
2380
+ flightId: 42,
2381
+ enableRecording: true,
2382
+ trailingActions: [
2383
+ { id: "close", label: "Close", icon: <span>x</span>, onClick: onClose },
2384
+ ],
2385
+ });
2386
+
2387
+ expect(getByRole<HTMLButtonElement>("button", { name: /starboard cam/i }).disabled).toBe(
2388
+ false,
2389
+ );
2390
+ fireEvent.click(getByLabelText("Close"));
2391
+ expect(onClose).toHaveBeenCalledTimes(1);
2392
+ });
2393
+ });