@raydiant/playlist-window-manager 0.29.1 → 0.30.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/src/testUtils.ts CHANGED
@@ -11,6 +11,21 @@ import {
11
11
  Logger,
12
12
  CreateWindowOptions,
13
13
  } from './types';
14
+ import {
15
+ Rect,
16
+ SurfaceHost,
17
+ VideoEvent,
18
+ VideoOptions,
19
+ VideoSurface,
20
+ } from './surfaces';
21
+
22
+ // Orders calls recorded by the renderer and the fake surface host against
23
+ // each other.
24
+ let sequence = 0;
25
+ const nextSequence = () => {
26
+ sequence += 1;
27
+ return sequence;
28
+ };
14
29
 
15
30
  export function createPresentationWindow(
16
31
  overrides: Partial<PresentationWindowOptions> = {}
@@ -24,7 +39,7 @@ export function createPresentationWindow(
24
39
  overrides.playlistWindowManagerState ??
25
40
  createPlaylistWindowManagerState(),
26
41
  ruleEngineState: overrides.ruleEngineState ?? createRuleEngineState(),
27
- logger: createLogger(),
42
+ logger: overrides.logger ?? createLogger(),
28
43
  workerState: overrides.workerState ?? {},
29
44
  renderer: overrides.renderer ?? {
30
45
  createWindow() {},
@@ -41,6 +56,7 @@ export function createPresentationWindow(
41
56
  getTerminal: overrides.getTerminal,
42
57
  isPreview: overrides.isPreview,
43
58
  device: overrides.device,
59
+ surfaceBroker: overrides.surfaceBroker,
44
60
  });
45
61
  }
46
62
 
@@ -161,3 +177,321 @@ export function createRenderer() {
161
177
  },
162
178
  };
163
179
  }
180
+
181
+ export interface RecordedCall {
182
+ method: string;
183
+ windowId: string;
184
+ args: unknown[];
185
+ seq: number;
186
+ }
187
+
188
+ // A renderer that records every call in order, across every window, and lets
189
+ // a test play the app's side of the message channel through its hooks.
190
+ export function createRecordingRenderer() {
191
+ const calls: RecordedCall[] = [];
192
+ // Messages the apps sent the window manager, in the same order as calls.
193
+ const received: RecordedCall[] = [];
194
+ const callbacks: Record<string, (type: string, payload?: any) => void> = {};
195
+ const errorCallbacks: Record<string, (err: any) => void> = {};
196
+ const hooks: {
197
+ onLoad?: (windowId: string) => void;
198
+ onMessage?: (windowId: string, type: string, payload?: any) => void;
199
+ } = {};
200
+
201
+ const record = (method: string, windowId: string, ...args: unknown[]) => {
202
+ calls.push({ method, windowId, args, seq: nextSequence() });
203
+ };
204
+
205
+ const renderer = {
206
+ createWindow(opts: CreateWindowOptions) {
207
+ callbacks[opts.windowId] = opts.receiveMessageCallback || (() => null);
208
+ errorCallbacks[opts.windowId] = opts.errorCallback;
209
+ record('createWindow', opts.windowId, opts.backgroundColor);
210
+ },
211
+ async loadWindow(windowId: string) {
212
+ record('loadWindow', windowId);
213
+ if (hooks.onLoad) hooks.onLoad(windowId);
214
+ },
215
+ updateWindowBounds(windowId: string, bounds: unknown) {
216
+ record('updateWindowBounds', windowId, bounds);
217
+ },
218
+ showWindow(windowId: string, isFocused: boolean) {
219
+ record('showWindow', windowId, isFocused);
220
+ },
221
+ hideWindow(windowId: string) {
222
+ record('hideWindow', windowId);
223
+ },
224
+ async removeWindow(windowId: string) {
225
+ record('removeWindow', windowId);
226
+ delete callbacks[windowId];
227
+ },
228
+ sendMessageString(windowId: string, message: string) {
229
+ const { type, payload } = JSON.parse(message);
230
+ record('sendMessageString', windowId, type, payload);
231
+ if (hooks.onMessage) hooks.onMessage(windowId, type, payload);
232
+ },
233
+ sendMessageObject(windowId: string, type: string, payload?: unknown) {
234
+ record('sendMessageObject', windowId, type, payload);
235
+ },
236
+ muteWindow(windowId: string) {
237
+ record('muteWindow', windowId);
238
+ },
239
+ unMuteWindow(windowId: string) {
240
+ record('unMuteWindow', windowId);
241
+ },
242
+ setWindowBackground(windowId: string, color: string) {
243
+ record('setWindowBackground', windowId, color);
244
+ },
245
+ };
246
+
247
+ return {
248
+ renderer,
249
+ calls,
250
+ received,
251
+ hooks,
252
+ // Deliver a message from the app in a window to the window manager.
253
+ receive(windowId: string, type: string, payload?: any) {
254
+ const callback = callbacks[windowId];
255
+ if (!callback) return;
256
+ received.push({
257
+ method: type,
258
+ windowId,
259
+ args: [payload],
260
+ seq: nextSequence(),
261
+ });
262
+ callback(type, payload);
263
+ },
264
+ // Report a window as crashed or unresponsive, as a renderer would.
265
+ fail(windowId: string, err: Error) {
266
+ const callback = errorCallbacks[windowId];
267
+ if (callback && callbacks[windowId]) callback(err);
268
+ },
269
+ // Windows created and not yet removed, oldest first.
270
+ liveWindowIds() {
271
+ return Object.keys(callbacks);
272
+ },
273
+ };
274
+ }
275
+
276
+ export interface FakeSurfaceCall {
277
+ id?: number;
278
+ method: string;
279
+ args: unknown[];
280
+ seq: number;
281
+ }
282
+
283
+ interface FakeSurfaceState {
284
+ id: number;
285
+ live: boolean;
286
+ firstFrame: boolean;
287
+ muted: boolean;
288
+ shown: boolean;
289
+ listener: ((e: VideoEvent) => void) | null;
290
+ buffered: VideoEvent[];
291
+ }
292
+
293
+ // A stand-in for the Signage Stick host's video surface, typed to the video
294
+ // contract. It records every call in order and records a rule break, rather
295
+ // than throwing, so a test can play a whole playlist and then assert that no
296
+ // rule was broken.
297
+ export function createFakeSurfaceHost(
298
+ opts: {
299
+ decoders?: number; // default 4, as measured on the Stick
300
+ refuse?: 'no_decoder' | 'unsupported'; // the next open
301
+ hang?: boolean; // the next open never resolves, until releaseHung()
302
+ hangShow?: boolean; // the next show() never resolves
303
+ // Each play() raises playing this long after, as the Stick's host does
304
+ // 15-35 ms after; unset, playing is raised only by emit().
305
+ playingAfterMs?: number;
306
+ } = {}
307
+ ) {
308
+ const decoders = opts.decoders ?? 4;
309
+ let refuseNext = opts.refuse;
310
+ let hangNext = opts.hang ?? false;
311
+ let hangShowNext = opts.hangShow ?? false;
312
+ const calls: FakeSurfaceCall[] = [];
313
+ const violations: string[] = [];
314
+ const states: Record<number, FakeSurfaceState> = {};
315
+ const hung: Array<() => void> = [];
316
+ let lastId = 0;
317
+
318
+ const record = (method: string, args: unknown[], id?: number) => {
319
+ calls.push({ id, method, args, seq: nextSequence() });
320
+ };
321
+ const emit = (id: number, event: VideoEvent) => {
322
+ const state = states[id];
323
+ if (!state) throw new Error(`No fake surface ${id}`);
324
+ if (event.type === 'first_frame') state.firstFrame = true;
325
+ if (state.listener) {
326
+ state.listener(event);
327
+ } else {
328
+ state.buffered.push(event);
329
+ }
330
+ };
331
+ const live = () =>
332
+ Object.keys(states)
333
+ .map((key) => states[Number(key)])
334
+ .filter((s) => s.live);
335
+ const checkBounds = (bounds: Rect) => {
336
+ const values = [bounds.x, bounds.y, bounds.width, bounds.height];
337
+ if (values.some((v) => Math.round(v) !== v)) {
338
+ violations.push(`fractional bounds ${JSON.stringify(bounds)}`);
339
+ }
340
+ };
341
+
342
+ const createSurface = (): VideoSurface => {
343
+ lastId += 1;
344
+ const id = lastId;
345
+ const state: FakeSurfaceState = {
346
+ id,
347
+ live: true,
348
+ firstFrame: false,
349
+ muted: true,
350
+ shown: false,
351
+ listener: null,
352
+ buffered: [],
353
+ };
354
+ states[id] = state;
355
+
356
+ return {
357
+ id,
358
+ show(showOpts?: { fadeMs?: number }) {
359
+ record('show', [showOpts], id);
360
+ if (!state.live) return Promise.resolve();
361
+ if (!state.firstFrame) {
362
+ violations.push(`surface ${id} shown before first_frame`);
363
+ }
364
+ live()
365
+ .filter((s) => s !== state && s.shown && !s.muted)
366
+ .forEach((s) => {
367
+ violations.push(
368
+ `surface ${id} shown before surface ${s.id} was muted`
369
+ );
370
+ });
371
+ state.shown = true;
372
+ if (hangShowNext) {
373
+ hangShowNext = false;
374
+ return new Promise<void>(() => undefined);
375
+ }
376
+ return Promise.resolve();
377
+ },
378
+ hide(hideOpts?: { fadeMs?: number }) {
379
+ record('hide', [hideOpts], id);
380
+ state.shown = false;
381
+ return Promise.resolve();
382
+ },
383
+ play() {
384
+ record('play', [], id);
385
+ if (opts.playingAfterMs !== undefined) {
386
+ setTimeout(() => {
387
+ if (state.live) emit(id, { type: 'playing' });
388
+ }, opts.playingAfterMs);
389
+ }
390
+ return Promise.resolve();
391
+ },
392
+ pause() {
393
+ record('pause', [], id);
394
+ return Promise.resolve();
395
+ },
396
+ seek(seconds: number) {
397
+ record('seek', [seconds], id);
398
+ return Promise.resolve();
399
+ },
400
+ setBounds(r: Rect) {
401
+ record('setBounds', [r], id);
402
+ checkBounds(r);
403
+ return Promise.resolve();
404
+ },
405
+ setMuted(m: boolean) {
406
+ record('setMuted', [m], id);
407
+ if (!m && state.live) {
408
+ live()
409
+ .filter((s) => s !== state && !s.muted)
410
+ .forEach((s) => {
411
+ violations.push(
412
+ `surface ${id} unmuted while surface ${s.id} sounds`
413
+ );
414
+ });
415
+ }
416
+ if (state.live) state.muted = m;
417
+ return Promise.resolve();
418
+ },
419
+ close() {
420
+ record('close', [], id);
421
+ state.live = false;
422
+ return Promise.resolve();
423
+ },
424
+ onEvent(fn: (e: VideoEvent) => void) {
425
+ state.listener = fn;
426
+ const buffered = state.buffered;
427
+ state.buffered = [];
428
+ buffered.forEach((e) => fn(e));
429
+ },
430
+ };
431
+ };
432
+
433
+ const surfaces: SurfaceHost = {
434
+ openVideo(videoOpts: VideoOptions) {
435
+ record('openVideo', [videoOpts]);
436
+ checkBounds(videoOpts.bounds);
437
+ if (hangNext) {
438
+ hangNext = false;
439
+ return new Promise<VideoSurface>((resolve) => {
440
+ hung.push(() => resolve(createSurface()));
441
+ });
442
+ }
443
+ if (refuseNext) {
444
+ const reason = refuseNext;
445
+ refuseNext = undefined;
446
+ return Promise.reject(new Error(reason));
447
+ }
448
+ if (live().length >= decoders) {
449
+ return Promise.reject(new Error('no_decoder'));
450
+ }
451
+ if (live().length >= 2) {
452
+ violations.push('a third live surface');
453
+ }
454
+ const surface = createSurface();
455
+ calls[calls.length - 1].id = surface.id;
456
+ return Promise.resolve(surface);
457
+ },
458
+ onHole(open: boolean) {
459
+ record('onHole', [open]);
460
+ },
461
+ };
462
+
463
+ return {
464
+ surfaces,
465
+ calls,
466
+ violations,
467
+ // Raises an event on a surface, as the host would.
468
+ emit,
469
+ // Lets a hung open resolve, late.
470
+ releaseHung() {
471
+ hung.splice(0).forEach((release) => release());
472
+ },
473
+ liveIds() {
474
+ return live().map((s) => s.id);
475
+ },
476
+ lastId() {
477
+ return lastId;
478
+ },
479
+ // The next open is refused, as the host would refuse it.
480
+ refuseNext(reason: 'no_decoder' | 'unsupported') {
481
+ refuseNext = reason;
482
+ },
483
+ hangNextOpen() {
484
+ hangNext = true;
485
+ },
486
+ hangNextShow() {
487
+ hangShowNext = true;
488
+ },
489
+ };
490
+ }
491
+
492
+ // Lets pending promise callbacks and zero-delay timers run.
493
+ export async function flush(times = 5) {
494
+ for (let i = 0; i < times; i += 1) {
495
+ await new Promise((resolve) => setTimeout(resolve, 0));
496
+ }
497
+ }
package/src/types.ts CHANGED
@@ -144,6 +144,11 @@ export interface PresentationWindowRenderer {
144
144
  // the navigator.isOnline property in the inner window. This is only defined for Electron
145
145
  // renderers. Browser renderers do not need to override navigator.isOnline.
146
146
  sendOnlineStatus?(windowId: string, isOnline: boolean): void;
147
+
148
+ // Called to set a window's own background colour, so a video surface under
149
+ // the window can show through it. Only renderers that can be paired with a
150
+ // video surface host define it.
151
+ setWindowBackground?(windowId: string, color: string): void;
147
152
  }
148
153
 
149
154
  export interface OpenChildWindowOptions {