@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.
@@ -19,6 +19,11 @@ import PlaylistWindowManagerState from './PlaylistWindowManagerState';
19
19
  import WindowUnresponsiveError from './WindowUnresponsiveError';
20
20
  import WindowTimeoutError from './WindowTimeoutError';
21
21
  import WindowLoadError from './WindowLoadError';
22
+ import SurfaceBroker, {
23
+ DEFAULT_SHOW_AFTER_REVEAL_MS,
24
+ DEFAULT_SURFACE_SWAP_DELAY_MS,
25
+ } from './SurfaceBroker';
26
+ import { SurfaceHost } from './surfaces';
22
27
 
23
28
  export interface PlaylistWindowManagerOptions {
24
29
  // The rule engine
@@ -83,6 +88,12 @@ export interface PlaylistWindowManagerOptions {
83
88
 
84
89
  // Called when a window needs to check if the device is online.
85
90
  getOnlineStatus?: () => boolean;
91
+
92
+ // The host's video surface, bound by the player, when the host can draw
93
+ // one. Absent, nothing changes: apps are offered no surface and keep
94
+ // <video>. A zone's manager is never given one. Its showAfterRevealMs is
95
+ // the device's calibration for a swap's reveal; see SurfaceHost.
96
+ surfaces?: SurfaceHost;
86
97
  }
87
98
 
88
99
  // Wait at least 3 seconds before restarting or moving onto the next window
@@ -118,6 +129,10 @@ export default class PlaylistWindowManager extends EventEmitter {
118
129
  private isFocused: boolean = false;
119
130
 
120
131
  private workerState: WorkerState = {};
132
+ private surfaceBroker: SurfaceBroker | null = null;
133
+ // The surface swap the next window's preload waits to finish. A newer
134
+ // preload or the next window's removal clears it, cancelling that preload.
135
+ private pendingPreload: Promise<void> | null = null;
121
136
 
122
137
  constructor(private options: PlaylistWindowManagerOptions) {
123
138
  super();
@@ -127,6 +142,9 @@ export default class PlaylistWindowManager extends EventEmitter {
127
142
  this.bounds = options.bounds;
128
143
  this.themeOverride = options.themeOverride;
129
144
  this.forceMute = options.forceMute || false;
145
+ if (options.surfaces) {
146
+ this.surfaceBroker = new SurfaceBroker(options.surfaces, options.logger);
147
+ }
130
148
  }
131
149
 
132
150
  private currentPresentationId(): string | null {
@@ -357,6 +375,10 @@ export default class PlaylistWindowManager extends EventEmitter {
357
375
  clearTimeout(this.currentPresentationCompleteTimeout);
358
376
  }
359
377
  await Promise.all([this.removeCurrentWindow(), this.removeNextWindow()]);
378
+ if (this.surfaceBroker) {
379
+ // Also closes a surface whose window is still leaving a swap.
380
+ await this.surfaceBroker.closeAll();
381
+ }
360
382
  }
361
383
 
362
384
  public updateBounds(bounds: WindowBounds) {
@@ -571,9 +593,29 @@ export default class PlaylistWindowManager extends EventEmitter {
571
593
  workerState: this.workerState,
572
594
  getTerminal: this.options.getTerminal,
573
595
  getOnlineStatus: this.options.getOnlineStatus,
596
+ ...(this.surfaceBroker
597
+ ? {
598
+ surfaceBroker: this.surfaceBroker,
599
+ surfaceShowDeadlineMs: this.surfaceSwapDelayMs(),
600
+ }
601
+ : {}),
574
602
  });
575
603
  }
576
604
 
605
+ private showAfterRevealMs() {
606
+ const { surfaces } = this.options;
607
+ return surfaces && surfaces.showAfterRevealMs !== undefined
608
+ ? surfaces.showAfterRevealMs
609
+ : DEFAULT_SHOW_AFTER_REVEAL_MS;
610
+ }
611
+
612
+ private surfaceSwapDelayMs() {
613
+ const { closeWindowDelayMs } = this.options;
614
+ return closeWindowDelayMs !== undefined
615
+ ? closeWindowDelayMs
616
+ : DEFAULT_SURFACE_SWAP_DELAY_MS;
617
+ }
618
+
577
619
  private async loadCurrentWindow() {
578
620
  const { logger } = this.options;
579
621
 
@@ -620,6 +662,10 @@ export default class PlaylistWindowManager extends EventEmitter {
620
662
  private async preloadNextWindow() {
621
663
  const { logger } = this.options;
622
664
 
665
+ // A publish, a preview change or a complete preloads now, in place of a
666
+ // preload still waiting for its surface swap.
667
+ this.pendingPreload = null;
668
+
623
669
  if (!this.nextPresentation) {
624
670
  logger.warn(
625
671
  `Attempted to preload next window but nextPresentation not set`
@@ -699,6 +745,7 @@ export default class PlaylistWindowManager extends EventEmitter {
699
745
  }
700
746
 
701
747
  private async removeNextWindow() {
748
+ this.pendingPreload = null;
702
749
  if (this.nextWindow) {
703
750
  await this.nextWindow.remove();
704
751
  }
@@ -742,33 +789,62 @@ export default class PlaylistWindowManager extends EventEmitter {
742
789
  // Clear the pending interrupt whenever we swap the current window for the next one.
743
790
  this.hasPendingInterrupt = false;
744
791
 
745
- // Next window is ready, show the next one, hide the current window, and
746
- // then remove the current window. Show the next window before hiding the
747
- // current to avoid flashes when rendering with BrowserWindows on device.
748
- if (this.nextWindow) {
792
+ // Only a manager given a surface host, swapping a window that holds a
793
+ // surface, takes the surface path; every other swap runs today's code.
794
+ const isSurfaceSwap =
795
+ !!this.surfaceBroker &&
796
+ ((!!this.currentWindow && this.currentWindow.holdsSurface()) ||
797
+ (!!this.nextWindow && this.nextWindow.holdsSurface()));
798
+
799
+ let closeWindowPromise;
800
+ if (isSurfaceSwap) {
801
+ closeWindowPromise = this.swapWithSurfaces(
802
+ this.currentWindow,
803
+ this.nextWindow
804
+ );
805
+ } else if (this.nextWindow) {
806
+ // Next window is ready, show the next one, hide the current window, and
807
+ // then remove the current window. Show the next window before hiding the
808
+ // current to avoid flashes when rendering with BrowserWindows on device.
749
809
  this.nextWindow.show(this.isFocused);
750
810
  }
751
811
 
752
812
  // If current window isn't defined we don't need to hide it but we still
753
813
  // want to make the next window the new current window.
754
- let closeWindowPromise;
755
- if (this.currentWindow) {
814
+ if (!isSurfaceSwap && this.currentWindow) {
756
815
  if (this.options.closeWindowDelayMs !== undefined) {
816
+ // Keep the outgoing window on screen until the incoming one reports
817
+ // its first painted frame. A newly shown window still needs a few
818
+ // frames to rasterize, and removing the outgoing window before that
819
+ // exposes the transparent window stack (a white flash on device).
820
+ // closeWindowDelayMs is the ceiling for windows that never report a
821
+ // paint (embedded apps, error states).
757
822
  // Don't await here because this will cause a slight delay when playing the next window.
758
823
  // Instead we want to wait for window close at the end of this method.
759
- closeWindowPromise = new Promise((resolve) => {
760
- // Capture the current window reference to close because this.currentWindow will be
761
- // set to the next window before the timeout fires.
762
- const windowToClose = this.currentWindow;
763
- setTimeout(async () => {
764
- if (windowToClose) {
765
- // We can't use this.removeCurrentWindow because the currentWindow reference has already
766
- // changed by the time the timeout fires.
767
- await windowToClose.remove();
768
- }
769
- resolve(undefined);
770
- }, this.options.closeWindowDelayMs);
771
- });
824
+ // Capture both window references because this.currentWindow and
825
+ // this.nextWindow change before the wait finishes.
826
+ const windowToClose = this.currentWindow;
827
+ const incomingWindow = this.nextWindow;
828
+ const closeWindowDelayMs = this.options.closeWindowDelayMs;
829
+
830
+ // The outgoing window stays on screen only as a visual backdrop from
831
+ // here on: stop delivering its events (a late complete or error must
832
+ // not end the incoming presentation) and mute it so audio does not
833
+ // bleed into the incoming presentation during the overlap.
834
+ windowToClose.detachCallbacks();
835
+ windowToClose.mute();
836
+ closeWindowPromise = (async () => {
837
+ if (incomingWindow) {
838
+ await incomingWindow.waitForFirstPaint(closeWindowDelayMs);
839
+ } else {
840
+ await new Promise((resolve) =>
841
+ setTimeout(resolve, closeWindowDelayMs)
842
+ );
843
+ }
844
+ // We can't use this.removeCurrentWindow because the currentWindow
845
+ // reference has already changed by the time the wait finishes.
846
+ await windowToClose.remove();
847
+ })();
772
848
  } else {
773
849
  this.currentWindow.hide();
774
850
  await this.removeCurrentWindow();
@@ -797,7 +873,16 @@ export default class PlaylistWindowManager extends EventEmitter {
797
873
  this.playCurrentWindow();
798
874
 
799
875
  // Preload the next presentation if there is one.
800
- if (shouldPreload) {
876
+ let preloadAfterSwap: Promise<void> | null = null;
877
+ if (shouldPreload && isSurfaceSwap) {
878
+ // Once the swap has finished, so the next surface's decoder does not
879
+ // start during it: on the Stick a preload started in the swap held the
880
+ // outgoing clip's last frame for about 70 ms. The next window is the
881
+ // current one now.
882
+ this.nextWindow = null;
883
+ preloadAfterSwap = closeWindowPromise as Promise<void>;
884
+ this.pendingPreload = preloadAfterSwap;
885
+ } else if (shouldPreload) {
801
886
  this.preloadNextWindow();
802
887
  } else {
803
888
  // Set next window to null to reset preload state. We don't want to
@@ -809,6 +894,58 @@ export default class PlaylistWindowManager extends EventEmitter {
809
894
  if (closeWindowPromise) {
810
895
  await closeWindowPromise;
811
896
  }
897
+
898
+ if (preloadAfterSwap && this.pendingPreload === preloadAfterSwap) {
899
+ this.preloadNextWindow();
900
+ }
901
+ }
902
+
903
+ // The swap when either window holds a video surface. The outgoing surface
904
+ // is muted and marked leaving here, in the swap's own task; the rest runs
905
+ // on the two windows held here, so a publish, a preview check or a preload
906
+ // error during its waits acts on the new next window, never these two.
907
+ private swapWithSurfaces(
908
+ outgoing: PresentationWindow | null,
909
+ incoming: PresentationWindow | null
910
+ ): Promise<void> {
911
+ const delayMs = this.surfaceSwapDelayMs();
912
+
913
+ if (outgoing) {
914
+ // A late complete or error from the outgoing window must not end the
915
+ // incoming presentation, and two surfaces never sound at once.
916
+ outgoing.detachCallbacks();
917
+ outgoing.muteSurfaceOrFrame();
918
+ }
919
+
920
+ return (async () => {
921
+ let isOutgoingHidden = false;
922
+ if (incoming) {
923
+ // The new is shown before the old is hidden: an incoming surface's
924
+ // frame is revealed over its open hole and the outgoing frame hidden
925
+ // in that same task, so neither the outgoing paint-over nor its theme
926
+ // shows through the incoming hole.
927
+ await incoming.show(
928
+ this.isFocused,
929
+ () => {
930
+ if (outgoing) {
931
+ outgoing.hide();
932
+ isOutgoingHidden = true;
933
+ }
934
+ },
935
+ this.showAfterRevealMs()
936
+ );
937
+ // A removed incoming window never paints.
938
+ if (outgoing && !incoming.wasRemoved()) {
939
+ await incoming.waitForFirstPaint(delayMs);
940
+ }
941
+ }
942
+ if (outgoing) {
943
+ if (!isOutgoingHidden) {
944
+ outgoing.hide();
945
+ }
946
+ await outgoing.remove();
947
+ }
948
+ })();
812
949
  }
813
950
 
814
951
  private evalRuleEngine(warnIfPreloadMismatch = true): EvalRuleEngineResult {
@@ -922,6 +1059,13 @@ export default class PlaylistWindowManager extends EventEmitter {
922
1059
  // Clear current window error flag.
923
1060
  this.currentWindowError = null;
924
1061
 
1062
+ // A complete while the next window's preload waits for its surface swap
1063
+ // preloads it now, and the current window restarts while it loads, as
1064
+ // for any next window not yet ready.
1065
+ if (this.pendingPreload && this.nextPresentation) {
1066
+ this.preloadNextWindow();
1067
+ }
1068
+
925
1069
  // If not locked or paused, check if we can move to the next presentation.
926
1070
  if (
927
1071
  !this.isLocked() &&
@@ -5,7 +5,12 @@ import {
5
5
  createRenderer,
6
6
  createPlaylistWindowManagerState,
7
7
  createRuleEngineState,
8
+ createFakeSurfaceHost,
9
+ createLogger,
8
10
  } from './testUtils';
11
+ import * as re from '@raydiant/playlist-rule-engine';
12
+ import SurfaceBroker from './SurfaceBroker';
13
+ import { ApplicationVersion } from './types';
9
14
 
10
15
  const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
11
16
 
@@ -454,3 +459,369 @@ describe('interrupt locks', () => {
454
459
  });
455
460
 
456
461
  // TODO: Backfill tests for all legacy and embedded apps.
462
+
463
+ describe('waitForFirstPaint', () => {
464
+ // The default (non-embedded) window resolves load() only after the app
465
+ // reports ready, so drive the handshake through the mock renderer.
466
+ const loadPresentationWindow = async (
467
+ testRenderer: ReturnType<typeof createRenderer>,
468
+ window: ReturnType<typeof createPresentationWindow>
469
+ ) => {
470
+ const loadPromise = window.load();
471
+ await delay(0);
472
+ testRenderer.runReceiveMessageCallback('loaded', undefined);
473
+ testRenderer.runReceiveMessageCallback('presentation_ready', undefined);
474
+ await loadPromise;
475
+ };
476
+
477
+ test('resolves when the window reports first_painted', async () => {
478
+ const testRenderer = createRenderer();
479
+ const window = createPresentationWindow({
480
+ windowId: 'test-first-paint',
481
+ renderer: testRenderer,
482
+ });
483
+ await loadPresentationWindow(testRenderer, window);
484
+
485
+ let resolved = false;
486
+ const wait = window.waitForFirstPaint(5000).then(() => {
487
+ resolved = true;
488
+ });
489
+
490
+ await delay(10);
491
+ expect(resolved).toBe(false);
492
+
493
+ testRenderer.runReceiveMessageCallback('first_painted', undefined);
494
+ await wait;
495
+ expect(resolved).toBe(true);
496
+ });
497
+
498
+ test('falls back to the timeout when no paint signal arrives', async () => {
499
+ const testRenderer = createRenderer();
500
+ const window = createPresentationWindow({
501
+ windowId: 'test-first-paint-timeout',
502
+ renderer: testRenderer,
503
+ });
504
+ await loadPresentationWindow(testRenderer, window);
505
+
506
+ // Resolves via the timeout even though first_painted never arrives.
507
+ await window.waitForFirstPaint(20);
508
+ });
509
+
510
+ test('resolves immediately when the window already painted since the last play', async () => {
511
+ const testRenderer = createRenderer();
512
+ const window = createPresentationWindow({
513
+ windowId: 'test-first-paint-immediate',
514
+ renderer: testRenderer,
515
+ });
516
+ await loadPresentationWindow(testRenderer, window);
517
+
518
+ testRenderer.runReceiveMessageCallback('first_painted', undefined);
519
+
520
+ let resolved = false;
521
+ window.waitForFirstPaint(5000).then(() => {
522
+ resolved = true;
523
+ });
524
+ await Promise.resolve();
525
+ expect(resolved).toBe(true);
526
+ });
527
+
528
+ test('play starts a new paint cycle', async () => {
529
+ const testRenderer = createRenderer();
530
+ const window = createPresentationWindow({
531
+ windowId: 'test-first-paint-replay',
532
+ renderer: testRenderer,
533
+ });
534
+ await loadPresentationWindow(testRenderer, window);
535
+
536
+ testRenderer.runReceiveMessageCallback('first_painted', undefined);
537
+ window.play();
538
+
539
+ let resolved = false;
540
+ const wait = window.waitForFirstPaint(5000).then(() => {
541
+ resolved = true;
542
+ });
543
+ await delay(10);
544
+ expect(resolved).toBe(false);
545
+
546
+ testRenderer.runReceiveMessageCallback('first_painted', undefined);
547
+ await wait;
548
+ expect(resolved).toBe(true);
549
+ });
550
+
551
+ test('resolves pending waiters when the window is removed', async () => {
552
+ const testRenderer = createRenderer();
553
+ const window = createPresentationWindow({
554
+ windowId: 'test-first-paint-removed',
555
+ renderer: testRenderer,
556
+ });
557
+ await loadPresentationWindow(testRenderer, window);
558
+
559
+ const wait = window.waitForFirstPaint(5000);
560
+ await window.remove();
561
+ await wait;
562
+ });
563
+ });
564
+
565
+ describe('detachCallbacks', () => {
566
+ test('stops delivering events to the window manager', async () => {
567
+ const testRenderer = createRenderer();
568
+ const window = createPresentationWindow({
569
+ windowId: 'test-detach',
570
+ renderer: testRenderer,
571
+ });
572
+ const loadPromise = window.load();
573
+ await delay(0);
574
+ testRenderer.runReceiveMessageCallback('loaded', undefined);
575
+ testRenderer.runReceiveMessageCallback('presentation_ready', undefined);
576
+ await loadPromise;
577
+
578
+ const onComplete = jest.fn();
579
+ window.onComplete(onComplete);
580
+ window.detachCallbacks();
581
+
582
+ testRenderer.runReceiveMessageCallback('presentation_complete', undefined);
583
+ expect(onComplete).not.toHaveBeenCalled();
584
+ });
585
+ });
586
+
587
+ describe('video surfaces', () => {
588
+ const CONTENT_URL = 'https://cdn.test/clip.mp4';
589
+
590
+ function setupVideoWindow(
591
+ opts: {
592
+ withHost?: boolean;
593
+ applicationVariables?: Record<string, unknown>;
594
+ applicationVersion?: Partial<ApplicationVersion>;
595
+ } = {}
596
+ ) {
597
+ const testRenderer = createRenderer();
598
+ const host = createFakeSurfaceHost();
599
+ const warn = jest.fn();
600
+ const logger = createLogger({ warn });
601
+ const window = createPresentationWindow({
602
+ windowId: 'video-window',
603
+ presentation: createPresentation({
604
+ id: 'p1',
605
+ applicationVariables: opts.applicationVariables ?? {
606
+ content: { url: CONTENT_URL, filename: 'clip.mp4' },
607
+ },
608
+ }),
609
+ applicationVersion: createApplicationVersion(opts.applicationVersion),
610
+ renderer: testRenderer,
611
+ logger,
612
+ surfaceBroker:
613
+ opts.withHost === false
614
+ ? undefined
615
+ : new SurfaceBroker(host.surfaces, logger),
616
+ });
617
+
618
+ const messages = () =>
619
+ testRenderer.sendMessageString.mock.calls.map(([, message]) =>
620
+ JSON.parse(message)
621
+ );
622
+ const refusals = () =>
623
+ messages()
624
+ .filter((m) => m.type === 'video_refused')
625
+ .map((m) => m.payload.reason);
626
+ const refusalsLogged = () =>
627
+ warn.mock.calls.filter(([message]) => /video_refused/.test(message))
628
+ .length;
629
+ const openVideo = (url: string) =>
630
+ testRenderer.runReceiveMessageCallback('open_video', {
631
+ url,
632
+ bounds: { x: 0.4, y: 10.6, width: 1919.6, height: 1080 },
633
+ muted: false,
634
+ });
635
+
636
+ return {
637
+ testRenderer,
638
+ host,
639
+ window,
640
+ messages,
641
+ refusals,
642
+ refusalsLogged,
643
+ openVideo,
644
+ };
645
+ }
646
+
647
+ test('open_video reaches the host, and render carries nativeVideo, only with a host', async () => {
648
+ const offered = setupVideoWindow();
649
+ offered.window.load();
650
+ await delay(0);
651
+ offered.testRenderer.runReceiveMessageCallback('loaded', undefined);
652
+ expect(
653
+ offered.messages().find((m) => m.type === 'render').payload.nativeVideo
654
+ ).toBe(true);
655
+
656
+ offered.openVideo(CONTENT_URL);
657
+ await delay(0);
658
+ expect(
659
+ offered.host.calls.filter((c) => c.method === 'openVideo')[0].args[0]
660
+ ).toEqual({
661
+ url: CONTENT_URL,
662
+ bounds: { x: 0, y: 11, width: 1920, height: 1080 },
663
+ muted: true,
664
+ label: 'p1',
665
+ });
666
+ expect(offered.messages().map((m) => m.type)).toContain('video_opened');
667
+ await offered.window.remove();
668
+
669
+ const unoffered = setupVideoWindow({ withHost: false });
670
+ unoffered.window.load();
671
+ await delay(0);
672
+ unoffered.testRenderer.runReceiveMessageCallback('loaded', undefined);
673
+ expect(
674
+ unoffered.messages().find((m) => m.type === 'render').payload
675
+ ).not.toHaveProperty('nativeVideo');
676
+
677
+ unoffered.openVideo(CONTENT_URL);
678
+ expect(unoffered.refusals()).toEqual(['not_offered']);
679
+ expect(unoffered.refusalsLogged()).toBe(1);
680
+ await unoffered.window.remove();
681
+ });
682
+
683
+ test('a rendition beside the presentation url opens a surface', async () => {
684
+ const { window, host, openVideo, refusals } = setupVideoWindow();
685
+ window.load();
686
+ await delay(0);
687
+
688
+ openVideo(`${CONTENT_URL}_720p`);
689
+ await delay(0);
690
+
691
+ expect(host.calls.filter((c) => c.method === 'openVideo')).toHaveLength(1);
692
+ expect(refusals()).toEqual([]);
693
+ await window.remove();
694
+ });
695
+
696
+ test.each([
697
+ ['a url the presentation does not hold', 'https://cdn.test/other.mp4'],
698
+ ['a url beside it with another suffix', `${CONTENT_URL}_1080p`],
699
+ ])('%s is refused not_offered', async (_, url) => {
700
+ const { window, host, openVideo, refusals, refusalsLogged } =
701
+ setupVideoWindow();
702
+ window.load();
703
+ await delay(0);
704
+
705
+ openVideo(url);
706
+ await delay(0);
707
+
708
+ expect(refusals()).toEqual(['not_offered']);
709
+ expect(refusalsLogged()).toBe(1);
710
+ expect(host.calls).toEqual([]);
711
+ await window.remove();
712
+ });
713
+
714
+ test('a url that is not http(s) is refused not_offered', async () => {
715
+ const { window, host, openVideo, refusals } = setupVideoWindow({
716
+ applicationVariables: { content: { url: 'blob:https://cdn.test/1' } },
717
+ });
718
+ window.load();
719
+ await delay(0);
720
+
721
+ openVideo('blob:https://cdn.test/1');
722
+ await delay(0);
723
+
724
+ expect(refusals()).toEqual(['not_offered']);
725
+ expect(host.calls).toEqual([]);
726
+ await window.remove();
727
+ });
728
+
729
+ test('an embedded app is refused not_offered', async () => {
730
+ const { window, host, testRenderer, refusals, refusalsLogged } =
731
+ setupVideoWindow({
732
+ applicationVersion: { embeddedUrlFormat: 'https://embedded.test/' },
733
+ });
734
+ await window.load();
735
+
736
+ testRenderer.runReceiveMessageCallback('open_video', {
737
+ url: CONTENT_URL,
738
+ bounds: { x: 0, y: 0, width: 1920, height: 1080 },
739
+ });
740
+ await delay(0);
741
+
742
+ expect(refusals()).toEqual(['not_offered']);
743
+ expect(refusalsLogged()).toBe(1);
744
+ expect(host.calls).toEqual([]);
745
+ await window.remove();
746
+ });
747
+
748
+ test("a zone's windows are offered no surface and refused not_offered", async () => {
749
+ const testRenderer = createRenderer();
750
+ const host = createFakeSurfaceHost();
751
+ const logger = createLogger();
752
+ const zonePresentation = createPresentation({
753
+ id: 'z1',
754
+ applicationDeploymentId: 'zone-app',
755
+ applicationVariables: { content: { url: CONTENT_URL } },
756
+ });
757
+ const window = createPresentationWindow({
758
+ windowId: 'multizone',
759
+ presentation: createPresentation({
760
+ id: 'mz',
761
+ r: {
762
+ resource: { updatedAt: 'updatedAt', createdAt: 'createdAt' },
763
+ presentationsPlaylists: [{ playlistId: 'zone-playlist' }],
764
+ },
765
+ }),
766
+ playlistWindowManagerState: createPlaylistWindowManagerState({
767
+ presentations: { z1: zonePresentation },
768
+ applicationVersions: {
769
+ 'zone-app': createApplicationVersion({ id: 'zone-app' }),
770
+ },
771
+ }),
772
+ ruleEngineState: createRuleEngineState(
773
+ {
774
+ 'zone-playlist': {
775
+ id: 'zone-playlist',
776
+ mode: re.PlaylistMode.Sequential,
777
+ items: [{ presentationId: 'z1' }],
778
+ },
779
+ },
780
+ { z1: { id: 'z1', applicationName: 'videos' } }
781
+ ),
782
+ renderer: testRenderer,
783
+ logger,
784
+ surfaceBroker: new SurfaceBroker(host.surfaces, logger),
785
+ });
786
+ window.load();
787
+ await delay(0);
788
+
789
+ testRenderer.runReceiveMessageCallback('open_child_window', {
790
+ windowId: 'zone1',
791
+ playlistId: 'zone-playlist',
792
+ x: 0,
793
+ y: 0,
794
+ width: 960,
795
+ height: 1080,
796
+ isMuted: false,
797
+ isFocused: false,
798
+ });
799
+ await delay(0);
800
+
801
+ // The renderer's callbacks now reach the zone's window.
802
+ const zoneWindowId = testRenderer.createWindow.mock.calls[1][0].windowId;
803
+ testRenderer.runReceiveMessageCallback('loaded', undefined);
804
+ const zoneMessages = () =>
805
+ testRenderer.sendMessageString.mock.calls
806
+ .filter(([windowId]) => windowId === zoneWindowId)
807
+ .map(([, message]) => JSON.parse(message));
808
+ expect(
809
+ zoneMessages().find((m) => m.type === 'render').payload
810
+ ).not.toHaveProperty('nativeVideo');
811
+
812
+ testRenderer.runReceiveMessageCallback('open_video', {
813
+ url: CONTENT_URL,
814
+ bounds: { x: 0, y: 0, width: 960, height: 1080 },
815
+ muted: false,
816
+ });
817
+ await delay(0);
818
+
819
+ expect(
820
+ zoneMessages()
821
+ .filter((m) => m.type === 'video_refused')
822
+ .map((m) => m.payload.reason)
823
+ ).toEqual(['not_offered']);
824
+ expect(host.calls).toEqual([]);
825
+ await window.remove();
826
+ });
827
+ });