@raydiant/playlist-window-manager 0.29.2 → 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/build/IFrameRenderer.d.ts +1 -0
- package/build/IFrameRenderer.js +14 -0
- package/build/IFrameRenderer.test.d.ts +1 -0
- package/build/IFrameRenderer.test.js +70 -0
- package/build/PlaylistWindowManager.d.ts +7 -0
- package/build/PlaylistWindowManager.js +150 -39
- package/build/PlaylistWindowManager.test.d.ts +1 -0
- package/build/PlaylistWindowManager.test.js +1763 -0
- package/build/PresentationWindow.d.ts +31 -1
- package/build/PresentationWindow.js +538 -30
- package/build/PresentationWindow.test.js +323 -0
- package/build/SurfaceBroker.d.ts +30 -0
- package/build/SurfaceBroker.js +311 -0
- package/build/SurfaceBroker.test.d.ts +1 -0
- package/build/SurfaceBroker.test.js +432 -0
- package/build/index.d.ts +1 -0
- package/build/index.js +1 -0
- package/build/surfaces.d.ts +52 -0
- package/build/surfaces.js +15 -0
- package/build/testUtils.d.ts +56 -0
- package/build/testUtils.js +327 -5
- package/build/types.d.ts +1 -0
- package/package.json +2 -1
- package/src/IFrameRenderer.test.ts +88 -0
- package/src/IFrameRenderer.ts +21 -0
- package/src/PlaylistWindowManager.test.ts +1466 -0
- package/src/PlaylistWindowManager.ts +134 -7
- package/src/PresentationWindow.test.ts +247 -0
- package/src/PresentationWindow.ts +644 -3
- package/src/SurfaceBroker.test.ts +278 -0
- package/src/SurfaceBroker.ts +281 -0
- package/src/__snapshots__/PlaylistWindowManager.test.ts.snap +26 -0
- package/src/index.ts +1 -0
- package/src/surfaces.ts +82 -0
- package/src/testUtils.ts +335 -1
- package/src/types.ts +5 -0
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 {
|