@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/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 +186 -65
- package/build/PlaylistWindowManager.test.d.ts +1 -0
- package/build/PlaylistWindowManager.test.js +1763 -0
- package/build/PresentationWindow.d.ts +37 -1
- package/build/PresentationWindow.js +601 -31
- package/build/PresentationWindow.test.js +509 -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 +164 -20
- package/src/PresentationWindow.test.ts +371 -0
- package/src/PresentationWindow.ts +712 -4
- 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
|
@@ -0,0 +1,1466 @@
|
|
|
1
|
+
import * as RE from '@raydiant/playlist-rule-engine';
|
|
2
|
+
import MockDate from 'mockdate';
|
|
3
|
+
import PlaylistWindowManager, {
|
|
4
|
+
PlaylistWindowManagerOptions,
|
|
5
|
+
} from './PlaylistWindowManager';
|
|
6
|
+
import {
|
|
7
|
+
createApplicationVersion,
|
|
8
|
+
createFakeSurfaceHost,
|
|
9
|
+
createLogger,
|
|
10
|
+
createPlaylistWindowManagerState,
|
|
11
|
+
createPresentation,
|
|
12
|
+
createRecordingRenderer,
|
|
13
|
+
flush,
|
|
14
|
+
} from './testUtils';
|
|
15
|
+
import { VideoOptions } from './surfaces';
|
|
16
|
+
|
|
17
|
+
const CLOSE_WINDOW_DELAY_MS = 50;
|
|
18
|
+
|
|
19
|
+
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
20
|
+
|
|
21
|
+
// Long enough for every scripted app reply and any surface show deadline.
|
|
22
|
+
const settle = async () => {
|
|
23
|
+
await flush(20);
|
|
24
|
+
await wait(CLOSE_WINDOW_DELAY_MS * 2);
|
|
25
|
+
await flush(20);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
let now = Date.parse('2026-10-28T12:00:00Z');
|
|
29
|
+
|
|
30
|
+
// The manager throttles completes that arrive within three seconds of each
|
|
31
|
+
// other; moving the clock past that keeps every test on the unthrottled path.
|
|
32
|
+
const passTime = () => {
|
|
33
|
+
now += 5000;
|
|
34
|
+
MockDate.set(now);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
MockDate.set(now);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
MockDate.reset();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
interface ManagerSetup {
|
|
46
|
+
presentationIds: string[];
|
|
47
|
+
options?: Partial<PlaylistWindowManagerOptions>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function createManager({ presentationIds, options = {} }: ManagerSetup) {
|
|
51
|
+
const presentations: Record<
|
|
52
|
+
string,
|
|
53
|
+
ReturnType<typeof createPresentation>
|
|
54
|
+
> = {};
|
|
55
|
+
const rePresentations: Record<string, RE.Presentation> = {};
|
|
56
|
+
presentationIds.forEach((id) => {
|
|
57
|
+
presentations[id] = createPresentation({
|
|
58
|
+
id,
|
|
59
|
+
applicationDeploymentId: 'app',
|
|
60
|
+
applicationVariables: {
|
|
61
|
+
content: { url: `https://cdn.test/${id}.mp4` },
|
|
62
|
+
muted: false,
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
rePresentations[id] = { id, applicationName: 'videos' };
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const state = createPlaylistWindowManagerState({
|
|
69
|
+
presentations,
|
|
70
|
+
applicationVersions: { app: createApplicationVersion({ id: 'app' }) },
|
|
71
|
+
});
|
|
72
|
+
const reState = new RE.State(
|
|
73
|
+
{
|
|
74
|
+
main: {
|
|
75
|
+
id: 'main',
|
|
76
|
+
mode: RE.PlaylistMode.Sequential,
|
|
77
|
+
items: presentationIds.map((presentationId) => ({ presentationId })),
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
rePresentations,
|
|
81
|
+
{}
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const recording = createRecordingRenderer();
|
|
85
|
+
const manager = new PlaylistWindowManager({
|
|
86
|
+
ruleEngine: new RE.RuleEngine('main', reState),
|
|
87
|
+
state,
|
|
88
|
+
logger: createLogger(),
|
|
89
|
+
closeWindowDelayMs: CLOSE_WINDOW_DELAY_MS,
|
|
90
|
+
...options,
|
|
91
|
+
});
|
|
92
|
+
manager.setRenderer(recording.renderer);
|
|
93
|
+
|
|
94
|
+
return { manager, recording };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Plays the app and app renderer the way a still-image app does: ready on
|
|
98
|
+
// render, and a first paint shortly after each play.
|
|
99
|
+
function scriptImageApp(recording: ReturnType<typeof createRecordingRenderer>) {
|
|
100
|
+
recording.hooks.onLoad = (windowId) => recording.receive(windowId, 'loaded');
|
|
101
|
+
recording.hooks.onMessage = (windowId, type) => {
|
|
102
|
+
if (type === 'render') recording.receive(windowId, 'presentation_ready');
|
|
103
|
+
if (type === 'play') {
|
|
104
|
+
setTimeout(() => recording.receive(windowId, 'first_painted'), 0);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// The window most recently shown, which is the one playing.
|
|
110
|
+
function lastShown(recording: ReturnType<typeof createRecordingRenderer>) {
|
|
111
|
+
const shows = recording.calls.filter((c) => c.method === 'showWindow');
|
|
112
|
+
return shows[shows.length - 1].windowId;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Names windows w1, w2, … in creation order so a sequence reads the same on
|
|
116
|
+
// every run.
|
|
117
|
+
function describeCalls(recording: ReturnType<typeof createRecordingRenderer>) {
|
|
118
|
+
const aliases: Record<string, string> = {};
|
|
119
|
+
return recording.calls.map(({ method, windowId, args }) => {
|
|
120
|
+
if (!aliases[windowId]) {
|
|
121
|
+
aliases[windowId] = `w${Object.keys(aliases).length + 1}`;
|
|
122
|
+
}
|
|
123
|
+
const detail =
|
|
124
|
+
method === 'sendMessageString' || method === 'sendMessageObject'
|
|
125
|
+
? ` ${args[0]}`
|
|
126
|
+
: '';
|
|
127
|
+
return `${method} ${aliases[windowId]}${detail}`;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
describe('no surface host', () => {
|
|
132
|
+
test('a two-presentation swap keeps the renderer call sequence', async () => {
|
|
133
|
+
const { manager, recording } = createManager({
|
|
134
|
+
presentationIds: ['a', 'b'],
|
|
135
|
+
});
|
|
136
|
+
scriptImageApp(recording);
|
|
137
|
+
|
|
138
|
+
await manager.load();
|
|
139
|
+
await flush();
|
|
140
|
+
manager.show();
|
|
141
|
+
manager.play();
|
|
142
|
+
await flush();
|
|
143
|
+
|
|
144
|
+
const current = lastShown(recording);
|
|
145
|
+
passTime();
|
|
146
|
+
recording.receive(current, 'presentation_complete');
|
|
147
|
+
await flush();
|
|
148
|
+
await new Promise((resolve) => setTimeout(resolve, CLOSE_WINDOW_DELAY_MS));
|
|
149
|
+
await flush();
|
|
150
|
+
|
|
151
|
+
expect(describeCalls(recording)).toMatchSnapshot();
|
|
152
|
+
|
|
153
|
+
await manager.destroy();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
type Recording = ReturnType<typeof createRecordingRenderer>;
|
|
158
|
+
type FakeHost = ReturnType<typeof createFakeSurfaceHost>;
|
|
159
|
+
|
|
160
|
+
interface AppScript {
|
|
161
|
+
// Presentations whose app is a still image and never asks for a surface.
|
|
162
|
+
images?: string[];
|
|
163
|
+
// Presentations whose first frame waits for the test to emit it.
|
|
164
|
+
holdFirstFrame?: string[];
|
|
165
|
+
// Presentations whose first window's app never answers video_shown.
|
|
166
|
+
holdHole?: string[];
|
|
167
|
+
// Presentations whose app opens its hole once sized, while still hidden,
|
|
168
|
+
// and again on video_shown.
|
|
169
|
+
preopenHole?: string[];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Plays the Videos app and app renderer the way the Videos plan describes,
|
|
173
|
+
// replying a task later as postMessage does: ask for a surface on render when
|
|
174
|
+
// offered one, size the hole and become ready on the first frame, open the
|
|
175
|
+
// hole on video_shown, complete on ended, fall back to <video> on a refusal.
|
|
176
|
+
function scriptApps(recording: Recording, host: FakeHost, script: AppScript) {
|
|
177
|
+
const images = script.images || [];
|
|
178
|
+
const holdFirstFrame = new Set(script.holdFirstFrame || []);
|
|
179
|
+
const holdHole = new Set(script.holdHole || []);
|
|
180
|
+
const preopenHole = new Set(script.preopenHole || []);
|
|
181
|
+
const presentationOf: Record<string, string> = {};
|
|
182
|
+
const isReady: Record<string, boolean> = {};
|
|
183
|
+
|
|
184
|
+
const reply = (windowId: string, type: string, payload?: any) => {
|
|
185
|
+
setTimeout(() => recording.receive(windowId, type, payload), 0);
|
|
186
|
+
};
|
|
187
|
+
const ready = (windowId: string) => {
|
|
188
|
+
if (isReady[windowId]) return;
|
|
189
|
+
isReady[windowId] = true;
|
|
190
|
+
reply(windowId, 'presentation_ready');
|
|
191
|
+
};
|
|
192
|
+
const surfaceOf = (presentationId: string) => {
|
|
193
|
+
const opens = host.calls.filter(
|
|
194
|
+
(c) =>
|
|
195
|
+
c.method === 'openVideo' &&
|
|
196
|
+
(c.args[0] as VideoOptions).label === presentationId &&
|
|
197
|
+
c.id !== undefined
|
|
198
|
+
);
|
|
199
|
+
return opens[opens.length - 1].id as number;
|
|
200
|
+
};
|
|
201
|
+
const emitFirstFrame = (presentationId: string) => {
|
|
202
|
+
host.emit(surfaceOf(presentationId), {
|
|
203
|
+
type: 'first_frame',
|
|
204
|
+
width: 1920,
|
|
205
|
+
height: 1080,
|
|
206
|
+
durationS: 10,
|
|
207
|
+
});
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
recording.hooks.onLoad = (windowId) => reply(windowId, 'loaded');
|
|
211
|
+
recording.hooks.onMessage = (windowId, type, payload) => {
|
|
212
|
+
const presentationId = presentationOf[windowId];
|
|
213
|
+
switch (type) {
|
|
214
|
+
case 'render': {
|
|
215
|
+
const id = payload.presentation.id;
|
|
216
|
+
presentationOf[windowId] = id;
|
|
217
|
+
if (payload.nativeVideo && images.indexOf(id) === -1) {
|
|
218
|
+
reply(windowId, 'open_video', {
|
|
219
|
+
url: `https://cdn.test/${id}.mp4`,
|
|
220
|
+
bounds: { x: 0, y: 0, width: 1920, height: 1080 },
|
|
221
|
+
muted: false,
|
|
222
|
+
});
|
|
223
|
+
} else {
|
|
224
|
+
ready(windowId);
|
|
225
|
+
}
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
case 'video_refused':
|
|
229
|
+
ready(windowId);
|
|
230
|
+
break;
|
|
231
|
+
case 'video_opened':
|
|
232
|
+
if (!holdFirstFrame.has(presentationId)) {
|
|
233
|
+
setTimeout(() => emitFirstFrame(presentationId), 0);
|
|
234
|
+
}
|
|
235
|
+
break;
|
|
236
|
+
case 'video_event':
|
|
237
|
+
if (payload.type === 'first_frame') {
|
|
238
|
+
reply(windowId, 'video_bounds', {
|
|
239
|
+
bounds: { x: 0, y: 0, width: 1920, height: 1080 },
|
|
240
|
+
});
|
|
241
|
+
ready(windowId);
|
|
242
|
+
if (preopenHole.has(presentationId)) {
|
|
243
|
+
reply(windowId, 'video_hole_open');
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (payload.type === 'ended') {
|
|
247
|
+
reply(windowId, 'presentation_complete');
|
|
248
|
+
}
|
|
249
|
+
break;
|
|
250
|
+
case 'video_shown':
|
|
251
|
+
if (holdHole.has(presentationId)) {
|
|
252
|
+
holdHole.delete(presentationId);
|
|
253
|
+
} else {
|
|
254
|
+
reply(windowId, 'video_hole_open');
|
|
255
|
+
}
|
|
256
|
+
break;
|
|
257
|
+
case 'play':
|
|
258
|
+
reply(windowId, 'first_painted');
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const windowOf = (presentationId: string) => {
|
|
264
|
+
const ids = Object.keys(presentationOf).filter(
|
|
265
|
+
(w) => presentationOf[w] === presentationId
|
|
266
|
+
);
|
|
267
|
+
return ids[ids.length - 1];
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
return { presentationOf, surfaceOf, emitFirstFrame, windowOf };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function createSurfaceManager(
|
|
274
|
+
presentationIds: string[],
|
|
275
|
+
script: AppScript = {},
|
|
276
|
+
hostOpts?: Parameters<typeof createFakeSurfaceHost>[0],
|
|
277
|
+
managerOptions: Partial<PlaylistWindowManagerOptions> = {},
|
|
278
|
+
showAfterRevealMs?: number
|
|
279
|
+
) {
|
|
280
|
+
const host = createFakeSurfaceHost(hostOpts);
|
|
281
|
+
const warn = jest.fn();
|
|
282
|
+
const { manager, recording } = createManager({
|
|
283
|
+
presentationIds,
|
|
284
|
+
options: {
|
|
285
|
+
surfaces: { ...host.surfaces, showAfterRevealMs },
|
|
286
|
+
logger: createLogger({ warn }),
|
|
287
|
+
...managerOptions,
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
const apps = scriptApps(recording, host, script);
|
|
291
|
+
|
|
292
|
+
// Every call and app message in order. A window and its surface are named
|
|
293
|
+
// by their presentation, and a later window of the same presentation gets
|
|
294
|
+
// a number: a, then a2.
|
|
295
|
+
const entries = () => {
|
|
296
|
+
const nameWith = (counts: Record<string, number>, label: string) => {
|
|
297
|
+
counts[label] = (counts[label] || 0) + 1;
|
|
298
|
+
return counts[label] === 1 ? label : `${label}${counts[label]}`;
|
|
299
|
+
};
|
|
300
|
+
const windowCounts: Record<string, number> = {};
|
|
301
|
+
const windowName: Record<string, string> = {};
|
|
302
|
+
Object.keys(apps.presentationOf).forEach((windowId) => {
|
|
303
|
+
windowName[windowId] = nameWith(
|
|
304
|
+
windowCounts,
|
|
305
|
+
apps.presentationOf[windowId]
|
|
306
|
+
);
|
|
307
|
+
});
|
|
308
|
+
const surfaceCounts: Record<string, number> = {};
|
|
309
|
+
const labelOf: Record<number, string> = {};
|
|
310
|
+
host.calls.forEach((c) => {
|
|
311
|
+
if (c.method === 'openVideo' && c.id !== undefined) {
|
|
312
|
+
labelOf[c.id] = nameWith(
|
|
313
|
+
surfaceCounts,
|
|
314
|
+
(c.args[0] as VideoOptions).label as string
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
const rendererEntries = recording.calls.map((c) => {
|
|
319
|
+
const name = windowName[c.windowId] || '?';
|
|
320
|
+
const detail =
|
|
321
|
+
c.method === 'sendMessageString' || c.method === 'setWindowBackground'
|
|
322
|
+
? ` ${c.args[0]}`
|
|
323
|
+
: '';
|
|
324
|
+
return { seq: c.seq, text: `${c.method} ${name}${detail}` };
|
|
325
|
+
});
|
|
326
|
+
const appEntries = recording.received.map((c) => ({
|
|
327
|
+
seq: c.seq,
|
|
328
|
+
text: `app ${windowName[c.windowId] || '?'} ${c.method}`,
|
|
329
|
+
}));
|
|
330
|
+
const hostEntries = host.calls.map((c) => {
|
|
331
|
+
const name = c.id !== undefined ? labelOf[c.id] : '';
|
|
332
|
+
const detail = c.method === 'setMuted' ? ` ${c.args[0]}` : '';
|
|
333
|
+
const onHole = c.method === 'onHole' ? ` ${c.args[0]}` : '';
|
|
334
|
+
return {
|
|
335
|
+
seq: c.seq,
|
|
336
|
+
text: `host ${c.method}${name ? ` ${name}` : ''}${detail}${onHole}`,
|
|
337
|
+
};
|
|
338
|
+
});
|
|
339
|
+
return rendererEntries
|
|
340
|
+
.concat(appEntries, hostEntries)
|
|
341
|
+
.sort((x, y) => x.seq - y.seq);
|
|
342
|
+
};
|
|
343
|
+
const timeline = () => entries().map((e) => e.text);
|
|
344
|
+
|
|
345
|
+
const sent = (presentationId: string, type: string) =>
|
|
346
|
+
recording.calls.filter(
|
|
347
|
+
(c) =>
|
|
348
|
+
c.method === 'sendMessageString' &&
|
|
349
|
+
c.args[0] === type &&
|
|
350
|
+
apps.presentationOf[c.windowId] === presentationId
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
const hostCalls = (method: string, presentationId?: string) => {
|
|
354
|
+
const ids =
|
|
355
|
+
presentationId === undefined
|
|
356
|
+
? null
|
|
357
|
+
: host.calls
|
|
358
|
+
.filter(
|
|
359
|
+
(c) =>
|
|
360
|
+
c.method === 'openVideo' &&
|
|
361
|
+
(c.args[0] as VideoOptions).label === presentationId
|
|
362
|
+
)
|
|
363
|
+
.map((c) => c.id);
|
|
364
|
+
return host.calls.filter(
|
|
365
|
+
(c) => c.method === method && (ids === null || ids.indexOf(c.id) !== -1)
|
|
366
|
+
);
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const complete = async (presentationId: string) => {
|
|
370
|
+
passTime();
|
|
371
|
+
recording.receive(apps.windowOf(presentationId), 'presentation_complete');
|
|
372
|
+
await settle();
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const start = async () => {
|
|
376
|
+
await manager.load();
|
|
377
|
+
manager.show();
|
|
378
|
+
manager.play();
|
|
379
|
+
await settle();
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
manager,
|
|
384
|
+
recording,
|
|
385
|
+
host,
|
|
386
|
+
warn,
|
|
387
|
+
apps,
|
|
388
|
+
entries,
|
|
389
|
+
timeline,
|
|
390
|
+
sent,
|
|
391
|
+
hostCalls,
|
|
392
|
+
complete,
|
|
393
|
+
start,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// The calls and messages a swap's order is read from.
|
|
398
|
+
const swapSteps = [
|
|
399
|
+
'host setMuted',
|
|
400
|
+
'muteWindow',
|
|
401
|
+
'host show',
|
|
402
|
+
'sendMessageString',
|
|
403
|
+
'app',
|
|
404
|
+
'setWindowBackground',
|
|
405
|
+
'showWindow',
|
|
406
|
+
'hideWindow',
|
|
407
|
+
'host close',
|
|
408
|
+
'removeWindow',
|
|
409
|
+
];
|
|
410
|
+
// Leaves out the next window's preload, which runs alongside the swap, and
|
|
411
|
+
// messages that are not part of the swap's order.
|
|
412
|
+
const isSwapStep = (line: string) =>
|
|
413
|
+
swapSteps.some((step) => line.startsWith(step)) &&
|
|
414
|
+
!/ (\?|[a-z]\d)( |$)/.test(line) &&
|
|
415
|
+
!/ (render|play|log_\w+|loaded|video_opened|video_event)$/.test(line) &&
|
|
416
|
+
!/app \S+ (presentation_ready|video_bounds|open_video)/.test(line);
|
|
417
|
+
|
|
418
|
+
describe('a manager given a surface host', () => {
|
|
419
|
+
test('an app asks through miraVideo and render carries nativeVideo', async () => {
|
|
420
|
+
const { manager, sent, hostCalls, start } = createSurfaceManager(['a']);
|
|
421
|
+
await start();
|
|
422
|
+
|
|
423
|
+
expect(sent('a', 'render')[0].args[1]).toMatchObject({
|
|
424
|
+
nativeVideo: true,
|
|
425
|
+
});
|
|
426
|
+
expect(hostCalls('openVideo')).toHaveLength(1);
|
|
427
|
+
expect(hostCalls('openVideo')[0].args[0]).toEqual({
|
|
428
|
+
url: 'https://cdn.test/a.mp4',
|
|
429
|
+
bounds: { x: 0, y: 0, width: 1920, height: 1080 },
|
|
430
|
+
muted: true,
|
|
431
|
+
label: 'a',
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
await manager.destroy();
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test('the first presentation shows its surface before its frame, and plays after the hole opens', async () => {
|
|
438
|
+
const { manager, host, timeline, start } = createSurfaceManager(['a']);
|
|
439
|
+
await start();
|
|
440
|
+
|
|
441
|
+
const steps = timeline().filter(
|
|
442
|
+
(line) =>
|
|
443
|
+
line.startsWith('host show') ||
|
|
444
|
+
line.startsWith('host play') ||
|
|
445
|
+
line.startsWith('host onHole') ||
|
|
446
|
+
line.startsWith('showWindow') ||
|
|
447
|
+
line === 'sendMessageString a video_shown' ||
|
|
448
|
+
line === 'sendMessageString a play' ||
|
|
449
|
+
line === 'app a video_hole_open'
|
|
450
|
+
);
|
|
451
|
+
expect(steps).toEqual([
|
|
452
|
+
'host show a',
|
|
453
|
+
'sendMessageString a video_shown',
|
|
454
|
+
'app a video_hole_open',
|
|
455
|
+
'host onHole true',
|
|
456
|
+
'showWindow a',
|
|
457
|
+
'sendMessageString a play',
|
|
458
|
+
'host play a',
|
|
459
|
+
]);
|
|
460
|
+
expect(host.violations).toEqual([]);
|
|
461
|
+
|
|
462
|
+
await manager.destroy();
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test('the next presentation preloads hidden and is ready on its first frame, then its bounds', async () => {
|
|
466
|
+
const { manager, recording, host, apps, hostCalls, start } =
|
|
467
|
+
createSurfaceManager(['a', 'b'], { holdFirstFrame: ['b'] });
|
|
468
|
+
await start();
|
|
469
|
+
|
|
470
|
+
const next = (manager as any).nextWindow;
|
|
471
|
+
expect(hostCalls('openVideo', 'b')).toHaveLength(1);
|
|
472
|
+
expect(next.isReady()).toBe(false);
|
|
473
|
+
|
|
474
|
+
// The first frame alone does not make it ready; the bounds that follow do.
|
|
475
|
+
recording.hooks.onMessage = ((original) => (w, type, payload) => {
|
|
476
|
+
if (type === 'video_event' && payload.type === 'first_frame') return;
|
|
477
|
+
if (original) original(w, type, payload);
|
|
478
|
+
})(recording.hooks.onMessage);
|
|
479
|
+
apps.emitFirstFrame('b');
|
|
480
|
+
await settle();
|
|
481
|
+
expect(next.isReady()).toBe(false);
|
|
482
|
+
|
|
483
|
+
recording.receive(apps.windowOf('b'), 'video_bounds', {
|
|
484
|
+
bounds: { x: 0, y: 0.4, width: 1920.4, height: 1080 },
|
|
485
|
+
});
|
|
486
|
+
recording.receive(apps.windowOf('b'), 'presentation_ready');
|
|
487
|
+
expect(next.isReady()).toBe(true);
|
|
488
|
+
|
|
489
|
+
expect(hostCalls('show', 'b')).toHaveLength(0);
|
|
490
|
+
expect(hostCalls('setBounds', 'b')[0].args[0]).toEqual({
|
|
491
|
+
x: 0,
|
|
492
|
+
y: 0,
|
|
493
|
+
width: 1920,
|
|
494
|
+
height: 1080,
|
|
495
|
+
});
|
|
496
|
+
expect(host.violations).toEqual([]);
|
|
497
|
+
|
|
498
|
+
await manager.destroy();
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test('a video to video swap shows the new before it hides the old', async () => {
|
|
502
|
+
const { manager, recording, host, apps, timeline, complete, start } =
|
|
503
|
+
createSurfaceManager(['a', 'b']);
|
|
504
|
+
await start();
|
|
505
|
+
|
|
506
|
+
// The reveal and the outgoing hide land in one task.
|
|
507
|
+
const a = apps.windowOf('a');
|
|
508
|
+
const b = apps.windowOf('b');
|
|
509
|
+
let hiddenInRevealTask: boolean | null = null;
|
|
510
|
+
const showWindow = recording.renderer.showWindow;
|
|
511
|
+
recording.renderer.showWindow = (windowId: string, isFocused: boolean) => {
|
|
512
|
+
showWindow(windowId, isFocused);
|
|
513
|
+
if (windowId === b) {
|
|
514
|
+
Promise.resolve().then(() => {
|
|
515
|
+
hiddenInRevealTask = recording.calls.some(
|
|
516
|
+
(c) => c.method === 'hideWindow' && c.windowId === a
|
|
517
|
+
);
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
const before = timeline().length;
|
|
523
|
+
await complete('a');
|
|
524
|
+
const swap = timeline().slice(before).filter(isSwapStep);
|
|
525
|
+
|
|
526
|
+
expect(swap).toEqual([
|
|
527
|
+
'app a presentation_complete',
|
|
528
|
+
'host setMuted a true',
|
|
529
|
+
'host show b',
|
|
530
|
+
'host setMuted b false',
|
|
531
|
+
'sendMessageString b video_shown',
|
|
532
|
+
'app b video_hole_open',
|
|
533
|
+
'setWindowBackground b transparent',
|
|
534
|
+
'showWindow b',
|
|
535
|
+
'hideWindow a',
|
|
536
|
+
'app b first_painted',
|
|
537
|
+
'sendMessageString a video_closing',
|
|
538
|
+
'host close a',
|
|
539
|
+
'removeWindow a',
|
|
540
|
+
]);
|
|
541
|
+
expect(hiddenInRevealTask).toBe(true);
|
|
542
|
+
expect(host.violations).toEqual([]);
|
|
543
|
+
|
|
544
|
+
await manager.destroy();
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
test('a video to image swap reveals the image at once and closes the surface after its paint', async () => {
|
|
548
|
+
const { manager, host, timeline, complete, start } = createSurfaceManager(
|
|
549
|
+
['a', 'b'],
|
|
550
|
+
{ images: ['b'] }
|
|
551
|
+
);
|
|
552
|
+
await start();
|
|
553
|
+
|
|
554
|
+
const before = timeline().length;
|
|
555
|
+
await complete('a');
|
|
556
|
+
const swap = timeline().slice(before).filter(isSwapStep);
|
|
557
|
+
|
|
558
|
+
expect(swap).toEqual([
|
|
559
|
+
'app a presentation_complete',
|
|
560
|
+
'host setMuted a true',
|
|
561
|
+
'showWindow b',
|
|
562
|
+
'app b first_painted',
|
|
563
|
+
'hideWindow a',
|
|
564
|
+
'sendMessageString a video_closing',
|
|
565
|
+
'host close a',
|
|
566
|
+
'removeWindow a',
|
|
567
|
+
]);
|
|
568
|
+
expect(timeline()).toContain('host onHole false');
|
|
569
|
+
expect(host.violations).toEqual([]);
|
|
570
|
+
|
|
571
|
+
await manager.destroy();
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
test('an image to video swap holds the image up until the surface is on screen', async () => {
|
|
575
|
+
const { manager, host, timeline, complete, start } = createSurfaceManager(
|
|
576
|
+
['a', 'b'],
|
|
577
|
+
{ images: ['a'] }
|
|
578
|
+
);
|
|
579
|
+
await start();
|
|
580
|
+
|
|
581
|
+
const before = timeline().length;
|
|
582
|
+
await complete('a');
|
|
583
|
+
const swap = timeline().slice(before).filter(isSwapStep);
|
|
584
|
+
|
|
585
|
+
expect(swap).toEqual([
|
|
586
|
+
'app a presentation_complete',
|
|
587
|
+
'muteWindow a',
|
|
588
|
+
'host show b',
|
|
589
|
+
'host setMuted b false',
|
|
590
|
+
'sendMessageString b video_shown',
|
|
591
|
+
'app b video_hole_open',
|
|
592
|
+
'setWindowBackground b transparent',
|
|
593
|
+
'showWindow b',
|
|
594
|
+
'hideWindow a',
|
|
595
|
+
'app b first_painted',
|
|
596
|
+
'removeWindow a',
|
|
597
|
+
]);
|
|
598
|
+
expect(host.violations).toEqual([]);
|
|
599
|
+
|
|
600
|
+
await manager.destroy();
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
test.each([
|
|
604
|
+
['a hung show', { hangShow: true }, {}],
|
|
605
|
+
['a missing hole acknowledgement', {}, { holdHole: ['a'] }],
|
|
606
|
+
])(
|
|
607
|
+
'the first presentation with %s falls back to <video> and plays',
|
|
608
|
+
async (_, hostOpts, script) => {
|
|
609
|
+
const { manager, host, warn, sent, hostCalls, timeline, start } =
|
|
610
|
+
createSurfaceManager(['a'], script as AppScript, hostOpts);
|
|
611
|
+
await start();
|
|
612
|
+
|
|
613
|
+
expect(sent('a', 'video_refused').map((c) => c.args[1])).toEqual([
|
|
614
|
+
{ reason: 'deadline' },
|
|
615
|
+
]);
|
|
616
|
+
expect(hostCalls('close', 'a')).toHaveLength(1);
|
|
617
|
+
expect(timeline()).toContain('showWindow a');
|
|
618
|
+
expect(sent('a', 'play')).toHaveLength(1);
|
|
619
|
+
expect(timeline()).not.toContain('host onHole true');
|
|
620
|
+
expect(
|
|
621
|
+
warn.mock.calls.filter(([message]) =>
|
|
622
|
+
/video_refused.*deadline/.test(message)
|
|
623
|
+
)
|
|
624
|
+
).toHaveLength(1);
|
|
625
|
+
expect(host.violations).toEqual([]);
|
|
626
|
+
|
|
627
|
+
await manager.destroy();
|
|
628
|
+
}
|
|
629
|
+
);
|
|
630
|
+
|
|
631
|
+
test.each([
|
|
632
|
+
['a hung show', 'hangShow'],
|
|
633
|
+
['a missing hole acknowledgement', 'holdHole'],
|
|
634
|
+
])(
|
|
635
|
+
'a swap with %s falls back to <video> and the playlist advances',
|
|
636
|
+
async (_, fault) => {
|
|
637
|
+
const script: AppScript = fault === 'holdHole' ? { holdHole: ['b'] } : {};
|
|
638
|
+
const { manager, host, sent, hostCalls, timeline, complete, start } =
|
|
639
|
+
createSurfaceManager(['a', 'b'], script);
|
|
640
|
+
await start();
|
|
641
|
+
if (fault === 'hangShow') host.hangNextShow();
|
|
642
|
+
|
|
643
|
+
await complete('a');
|
|
644
|
+
|
|
645
|
+
expect(sent('b', 'video_refused').map((c) => c.args[1])).toEqual([
|
|
646
|
+
{ reason: 'deadline' },
|
|
647
|
+
]);
|
|
648
|
+
expect(hostCalls('close', 'b')).toHaveLength(1);
|
|
649
|
+
expect(timeline()).toContain('showWindow b');
|
|
650
|
+
expect(sent('b', 'play')).toHaveLength(1);
|
|
651
|
+
expect(
|
|
652
|
+
timeline().filter((line) => line === 'host onHole true')
|
|
653
|
+
).toHaveLength(1);
|
|
654
|
+
expect(timeline()).toContain('removeWindow a');
|
|
655
|
+
|
|
656
|
+
// The playlist advances to a again.
|
|
657
|
+
await complete('b');
|
|
658
|
+
expect(sent('a', 'play').length).toBeGreaterThanOrEqual(2);
|
|
659
|
+
|
|
660
|
+
await manager.destroy();
|
|
661
|
+
}
|
|
662
|
+
);
|
|
663
|
+
|
|
664
|
+
test('a window shown before open_video keeps <video>', async () => {
|
|
665
|
+
const { manager, recording, host, apps, sent, hostCalls, timeline } =
|
|
666
|
+
createSurfaceManager(['a']);
|
|
667
|
+
// The app becomes ready before it asks, and asks only after the show.
|
|
668
|
+
const script = recording.hooks.onMessage;
|
|
669
|
+
recording.hooks.onMessage = (w, type, payload) => {
|
|
670
|
+
if (type === 'render') {
|
|
671
|
+
apps.presentationOf[w] = payload.presentation.id;
|
|
672
|
+
setTimeout(() => recording.receive(w, 'presentation_ready'), 0);
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
if (script) script(w, type, payload);
|
|
676
|
+
};
|
|
677
|
+
|
|
678
|
+
await manager.load();
|
|
679
|
+
manager.show();
|
|
680
|
+
manager.play();
|
|
681
|
+
recording.receive(apps.windowOf('a'), 'open_video', {
|
|
682
|
+
url: 'https://cdn.test/a.mp4',
|
|
683
|
+
bounds: { x: 0, y: 0, width: 1920, height: 1080 },
|
|
684
|
+
muted: false,
|
|
685
|
+
});
|
|
686
|
+
await settle();
|
|
687
|
+
|
|
688
|
+
expect(timeline()).toContain('showWindow a');
|
|
689
|
+
expect(hostCalls('openVideo')).toHaveLength(0);
|
|
690
|
+
expect(sent('a', 'video_refused').map((c) => c.args[1])).toEqual([
|
|
691
|
+
{ reason: 'not_offered' },
|
|
692
|
+
]);
|
|
693
|
+
expect(sent('a', 'play')).toHaveLength(1);
|
|
694
|
+
expect(host.violations).toEqual([]);
|
|
695
|
+
|
|
696
|
+
await manager.destroy();
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
test('a window shown between video_opened and first_frame keeps <video>', async () => {
|
|
700
|
+
const { manager, recording, host, apps, sent, hostCalls, timeline } =
|
|
701
|
+
createSurfaceManager(['a'], { holdFirstFrame: ['a'] });
|
|
702
|
+
// The app sends ready without waiting for its first frame, so only the
|
|
703
|
+
// surface holds the window back, as at the player's first-screen cap.
|
|
704
|
+
const script = recording.hooks.onMessage;
|
|
705
|
+
recording.hooks.onMessage = (w, type, payload) => {
|
|
706
|
+
if (script) script(w, type, payload);
|
|
707
|
+
if (type === 'video_opened') {
|
|
708
|
+
setTimeout(() => recording.receive(w, 'presentation_ready'), 0);
|
|
709
|
+
}
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
const loaded = manager.load();
|
|
713
|
+
await settle();
|
|
714
|
+
expect(sent('a', 'video_opened')).toHaveLength(1);
|
|
715
|
+
|
|
716
|
+
manager.show();
|
|
717
|
+
await loaded;
|
|
718
|
+
manager.play();
|
|
719
|
+
await settle();
|
|
720
|
+
|
|
721
|
+
expect(timeline()).toContain('showWindow a');
|
|
722
|
+
expect(hostCalls('show')).toHaveLength(0);
|
|
723
|
+
expect(sent('a', 'video_refused').map((c) => c.args[1])).toEqual([
|
|
724
|
+
{ reason: 'not_offered' },
|
|
725
|
+
]);
|
|
726
|
+
expect(hostCalls('close', 'a')).toHaveLength(1);
|
|
727
|
+
expect(sent('a', 'play')).toHaveLength(1);
|
|
728
|
+
expect(hostCalls('onHole')).toHaveLength(0);
|
|
729
|
+
expect(host.violations).toEqual([]);
|
|
730
|
+
|
|
731
|
+
// A later first frame and ask change nothing.
|
|
732
|
+
recording.receive(apps.windowOf('a'), 'open_video', {
|
|
733
|
+
url: 'https://cdn.test/a.mp4',
|
|
734
|
+
bounds: { x: 0, y: 0, width: 1920, height: 1080 },
|
|
735
|
+
muted: false,
|
|
736
|
+
});
|
|
737
|
+
await settle();
|
|
738
|
+
expect(hostCalls('openVideo')).toHaveLength(1);
|
|
739
|
+
|
|
740
|
+
await manager.destroy();
|
|
741
|
+
expect(host.liveIds()).toEqual([]);
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
test('a second show() before the hole opens does nothing', async () => {
|
|
745
|
+
const { manager, recording, apps, sent, hostCalls, timeline } =
|
|
746
|
+
createSurfaceManager(['a'], { holdHole: ['a'] });
|
|
747
|
+
await manager.load();
|
|
748
|
+
manager.show();
|
|
749
|
+
manager.show();
|
|
750
|
+
manager.play();
|
|
751
|
+
await flush(20);
|
|
752
|
+
recording.receive(apps.windowOf('a'), 'video_hole_open');
|
|
753
|
+
await settle();
|
|
754
|
+
|
|
755
|
+
expect(hostCalls('show')).toHaveLength(1);
|
|
756
|
+
expect(sent('a', 'video_shown')).toHaveLength(1);
|
|
757
|
+
expect(
|
|
758
|
+
timeline().filter((line) => line === 'host onHole true')
|
|
759
|
+
).toHaveLength(1);
|
|
760
|
+
|
|
761
|
+
await manager.destroy();
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
test('a one-video playlist replays by seeking, and destroy closes every surface', async () => {
|
|
765
|
+
const { manager, host, hostCalls, complete, start } = createSurfaceManager([
|
|
766
|
+
'a',
|
|
767
|
+
]);
|
|
768
|
+
await start();
|
|
769
|
+
|
|
770
|
+
await complete('a');
|
|
771
|
+
|
|
772
|
+
expect(hostCalls('seek', 'a').map((c) => c.args[0])).toEqual([0]);
|
|
773
|
+
const plays = host.calls.filter(
|
|
774
|
+
(c) => c.method === 'seek' || c.method === 'play'
|
|
775
|
+
);
|
|
776
|
+
expect(plays.map((c) => c.method)).toEqual(['play', 'seek', 'play']);
|
|
777
|
+
expect(hostCalls('close')).toHaveLength(0);
|
|
778
|
+
|
|
779
|
+
await manager.destroy();
|
|
780
|
+
expect(host.liveIds()).toEqual([]);
|
|
781
|
+
expect(
|
|
782
|
+
host.calls.filter((c) => c.method === 'onHole').map((c) => c.args[0])
|
|
783
|
+
).toEqual([true, false]);
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
test('a short clip replays once, then advances when the next is ready', async () => {
|
|
787
|
+
const { manager, host, apps, sent, hostCalls, complete, start } =
|
|
788
|
+
createSurfaceManager(['a', 'b'], { holdFirstFrame: ['b'] });
|
|
789
|
+
await start();
|
|
790
|
+
|
|
791
|
+
await complete('a');
|
|
792
|
+
expect(
|
|
793
|
+
hostCalls('seek', 'a')
|
|
794
|
+
.map((c) => c.method)
|
|
795
|
+
.concat(hostCalls('play', 'a').map((c) => c.method))
|
|
796
|
+
).toEqual(['seek', 'play', 'play']);
|
|
797
|
+
expect(hostCalls('close', 'a')).toHaveLength(0);
|
|
798
|
+
expect(hostCalls('show', 'b')).toHaveLength(0);
|
|
799
|
+
|
|
800
|
+
apps.emitFirstFrame('b');
|
|
801
|
+
await settle();
|
|
802
|
+
await complete('a');
|
|
803
|
+
|
|
804
|
+
expect(hostCalls('show', 'b')).toHaveLength(1);
|
|
805
|
+
expect(sent('b', 'play')).toHaveLength(1);
|
|
806
|
+
expect(hostCalls('close', 'a')).toHaveLength(1);
|
|
807
|
+
expect(host.violations).toEqual([]);
|
|
808
|
+
|
|
809
|
+
await manager.destroy();
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
test('a window whose surface closed before its turn swaps as today', async () => {
|
|
813
|
+
const {
|
|
814
|
+
manager,
|
|
815
|
+
recording,
|
|
816
|
+
host,
|
|
817
|
+
apps,
|
|
818
|
+
hostCalls,
|
|
819
|
+
timeline,
|
|
820
|
+
complete,
|
|
821
|
+
start,
|
|
822
|
+
} = createSurfaceManager(['a', 'b']);
|
|
823
|
+
await start();
|
|
824
|
+
|
|
825
|
+
recording.receive(apps.windowOf('b'), 'close_video');
|
|
826
|
+
await settle();
|
|
827
|
+
expect(hostCalls('close', 'b')).toHaveLength(1);
|
|
828
|
+
|
|
829
|
+
await complete('a');
|
|
830
|
+
|
|
831
|
+
expect(hostCalls('show', 'b')).toHaveLength(0);
|
|
832
|
+
expect(timeline()).toContain('showWindow b');
|
|
833
|
+
expect(timeline()).toContain('removeWindow a');
|
|
834
|
+
expect(host.violations).toEqual([]);
|
|
835
|
+
|
|
836
|
+
await manager.destroy();
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
test('an error event is forwarded, then the surface closes and the frame background returns', async () => {
|
|
840
|
+
const { manager, host, apps, sent, hostCalls, timeline, start } =
|
|
841
|
+
createSurfaceManager(['a']);
|
|
842
|
+
await start();
|
|
843
|
+
|
|
844
|
+
host.emit(apps.surfaceOf('a'), { type: 'error', reason: 'decode' });
|
|
845
|
+
await settle();
|
|
846
|
+
|
|
847
|
+
expect(sent('a', 'video_event').map((c) => c.args[1])).toContainEqual({
|
|
848
|
+
type: 'error',
|
|
849
|
+
reason: 'decode',
|
|
850
|
+
});
|
|
851
|
+
expect(hostCalls('close', 'a')).toHaveLength(1);
|
|
852
|
+
expect(
|
|
853
|
+
host.calls.filter((c) => c.method === 'onHole').map((c) => c.args[0])
|
|
854
|
+
).toEqual([true, false]);
|
|
855
|
+
expect(
|
|
856
|
+
timeline().filter((line) => line.startsWith('setWindowBackground'))
|
|
857
|
+
).toEqual([
|
|
858
|
+
'setWindowBackground a transparent',
|
|
859
|
+
'setWindowBackground a #000000',
|
|
860
|
+
]);
|
|
861
|
+
|
|
862
|
+
await manager.destroy();
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
test('stalled is forwarded and leaves the surface open', async () => {
|
|
866
|
+
const { manager, host, apps, sent, hostCalls, start } =
|
|
867
|
+
createSurfaceManager(['a']);
|
|
868
|
+
await start();
|
|
869
|
+
|
|
870
|
+
host.emit(apps.surfaceOf('a'), { type: 'stalled' });
|
|
871
|
+
host.emit(apps.surfaceOf('a'), { type: 'playing' });
|
|
872
|
+
await settle();
|
|
873
|
+
|
|
874
|
+
expect(
|
|
875
|
+
sent('a', 'video_event').map((c) => (c.args[1] as any).type)
|
|
876
|
+
).toEqual(['first_frame', 'stalled', 'playing']);
|
|
877
|
+
expect(hostCalls('close', 'a')).toHaveLength(0);
|
|
878
|
+
|
|
879
|
+
await manager.destroy();
|
|
880
|
+
});
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
describe('a swap survives what runs during its wait', () => {
|
|
884
|
+
// A three-video playlist whose second app holds its hole closed, so the
|
|
885
|
+
// swap from a to b waits; its deadline outlasts the test's steps.
|
|
886
|
+
async function startWaitingSwap(images: string[] = []) {
|
|
887
|
+
const setup = createSurfaceManager(
|
|
888
|
+
['a', 'b', 'c'],
|
|
889
|
+
{ holdHole: ['b'], images },
|
|
890
|
+
undefined,
|
|
891
|
+
{ closeWindowDelayMs: 5000 }
|
|
892
|
+
);
|
|
893
|
+
await setup.manager.load();
|
|
894
|
+
setup.manager.show();
|
|
895
|
+
setup.manager.play();
|
|
896
|
+
await settle();
|
|
897
|
+
const a = setup.apps.windowOf('a');
|
|
898
|
+
passTime();
|
|
899
|
+
setup.recording.receive(a, 'presentation_complete');
|
|
900
|
+
await settle();
|
|
901
|
+
const b = setup.apps.windowOf('b');
|
|
902
|
+
expect(setup.sent('b', 'video_shown')).toHaveLength(1);
|
|
903
|
+
const shown = (windowId: string) =>
|
|
904
|
+
setup.recording.calls.some(
|
|
905
|
+
(c) => c.method === 'showWindow' && c.windowId === windowId
|
|
906
|
+
);
|
|
907
|
+
const played = (windowId: string) =>
|
|
908
|
+
setup.recording.calls.filter(
|
|
909
|
+
(c) =>
|
|
910
|
+
c.method === 'sendMessageString' &&
|
|
911
|
+
c.args[0] === 'play' &&
|
|
912
|
+
c.windowId === windowId
|
|
913
|
+
).length;
|
|
914
|
+
const removed = (windowId: string) =>
|
|
915
|
+
setup.recording.calls.some(
|
|
916
|
+
(c) => c.method === 'removeWindow' && c.windowId === windowId
|
|
917
|
+
);
|
|
918
|
+
return { ...setup, a, b, shown, played, removed };
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
test('a late complete from the outgoing window starts no second swap', async () => {
|
|
922
|
+
const { manager, recording, a, b, hostCalls, shown, played } =
|
|
923
|
+
await startWaitingSwap();
|
|
924
|
+
|
|
925
|
+
passTime();
|
|
926
|
+
recording.receive(a, 'presentation_complete');
|
|
927
|
+
await settle();
|
|
928
|
+
|
|
929
|
+
expect(hostCalls('show')).toHaveLength(2);
|
|
930
|
+
expect((manager as any).currentWindow).not.toBeNull();
|
|
931
|
+
expect(shown(b)).toBe(false);
|
|
932
|
+
|
|
933
|
+
recording.receive(b, 'video_hole_open');
|
|
934
|
+
await settle();
|
|
935
|
+
expect(shown(b)).toBe(true);
|
|
936
|
+
expect(played(b)).toBe(1);
|
|
937
|
+
|
|
938
|
+
await manager.destroy();
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
test('the next window is preloaded only once the swap has finished', async () => {
|
|
942
|
+
const { manager, recording, apps, b, shown, removed } =
|
|
943
|
+
await startWaitingSwap();
|
|
944
|
+
|
|
945
|
+
expect(apps.windowOf('c')).toBeUndefined();
|
|
946
|
+
expect((manager as any).nextWindow).toBeNull();
|
|
947
|
+
|
|
948
|
+
recording.receive(b, 'video_hole_open');
|
|
949
|
+
await settle();
|
|
950
|
+
expect(shown(b)).toBe(true);
|
|
951
|
+
expect(removed(apps.windowOf('a'))).toBe(true);
|
|
952
|
+
const c = apps.windowOf('c');
|
|
953
|
+
expect(c).toBeDefined();
|
|
954
|
+
expect((manager as any).nextWindow.isReady()).toBe(true);
|
|
955
|
+
|
|
956
|
+
await manager.destroy();
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
test('the incoming window failing while its show waits preloads the next at once, and the playlist moves on', async () => {
|
|
960
|
+
const { manager, recording, host, apps, a, b, shown, played, removed } =
|
|
961
|
+
await startWaitingSwap(['a']);
|
|
962
|
+
|
|
963
|
+
// b fails mid-swap: c preloads now and b restarts while it loads.
|
|
964
|
+
passTime();
|
|
965
|
+
recording.fail(b, new Error('crashed'));
|
|
966
|
+
await settle();
|
|
967
|
+
const c = apps.windowOf('c');
|
|
968
|
+
expect(c).toBeDefined();
|
|
969
|
+
expect((manager as any).nextWindow.isReady()).toBe(true);
|
|
970
|
+
|
|
971
|
+
// At b's next complete the manager moves on to c; b is never revealed.
|
|
972
|
+
passTime();
|
|
973
|
+
recording.receive(b, 'presentation_complete');
|
|
974
|
+
await settle();
|
|
975
|
+
|
|
976
|
+
expect(removed(b)).toBe(true);
|
|
977
|
+
expect(shown(b)).toBe(false);
|
|
978
|
+
expect(played(b)).toBe(0);
|
|
979
|
+
expect(shown(c)).toBe(true);
|
|
980
|
+
expect(played(c)).toBe(1);
|
|
981
|
+
expect(removed(a)).toBe(true);
|
|
982
|
+
expect(host.violations).toEqual([]);
|
|
983
|
+
|
|
984
|
+
await manager.destroy();
|
|
985
|
+
await settle();
|
|
986
|
+
expect(host.liveIds()).toEqual([]);
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
test('a publish during the wait preloads its next window at once, and the swap preloads none', async () => {
|
|
990
|
+
const { manager, recording, host, apps, b, shown, played, removed } =
|
|
991
|
+
await startWaitingSwap();
|
|
992
|
+
const created = () =>
|
|
993
|
+
recording.calls.filter((call) => call.method === 'createWindow').length;
|
|
994
|
+
const createdBefore = created();
|
|
995
|
+
|
|
996
|
+
// Not awaited here: the publish's own preload opens a surface, which
|
|
997
|
+
// waits for the outgoing close, which waits for b's hole.
|
|
998
|
+
const ruleEngine = manager.getRuleEngine() as RE.RuleEngine;
|
|
999
|
+
const published = manager.setRuleEngine(
|
|
1000
|
+
new RE.RuleEngine(
|
|
1001
|
+
'main',
|
|
1002
|
+
new RE.State(
|
|
1003
|
+
{
|
|
1004
|
+
main: {
|
|
1005
|
+
id: 'main',
|
|
1006
|
+
mode: RE.PlaylistMode.Sequential,
|
|
1007
|
+
items: [{ presentationId: 'b' }, { presentationId: 'a' }],
|
|
1008
|
+
},
|
|
1009
|
+
},
|
|
1010
|
+
ruleEngine.state.presentations,
|
|
1011
|
+
{}
|
|
1012
|
+
)
|
|
1013
|
+
)
|
|
1014
|
+
);
|
|
1015
|
+
await settle();
|
|
1016
|
+
expect(created()).toBe(createdBefore + 1);
|
|
1017
|
+
expect(removed(b)).toBe(false);
|
|
1018
|
+
|
|
1019
|
+
recording.receive(b, 'video_hole_open');
|
|
1020
|
+
await settle();
|
|
1021
|
+
await published;
|
|
1022
|
+
expect(shown(b)).toBe(true);
|
|
1023
|
+
expect(played(b)).toBe(1);
|
|
1024
|
+
expect(created()).toBe(createdBefore + 1);
|
|
1025
|
+
expect(apps.windowOf('c')).toBeUndefined();
|
|
1026
|
+
expect(host.violations).toEqual([]);
|
|
1027
|
+
|
|
1028
|
+
await manager.destroy();
|
|
1029
|
+
await settle();
|
|
1030
|
+
expect(host.liveIds()).toEqual([]);
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
test('a preview change during the wait preloads its next window at once, and the swap preloads none', async () => {
|
|
1034
|
+
const { manager, recording, apps, b, shown, played } =
|
|
1035
|
+
await startWaitingSwap();
|
|
1036
|
+
const created = () =>
|
|
1037
|
+
recording.calls.filter((call) => call.method === 'createWindow').length;
|
|
1038
|
+
const createdBefore = created();
|
|
1039
|
+
|
|
1040
|
+
const ruleEngine = manager.getRuleEngine() as RE.RuleEngine;
|
|
1041
|
+
jest.spyOn(ruleEngine, 'previewNext').mockReturnValue({
|
|
1042
|
+
presentationId: 'a',
|
|
1043
|
+
shouldInterrupt: false,
|
|
1044
|
+
playlistId: 'main',
|
|
1045
|
+
rule: null,
|
|
1046
|
+
});
|
|
1047
|
+
// Not awaited here, for the same reason as a publish.
|
|
1048
|
+
const checked = (manager as any).checkPreviewAction();
|
|
1049
|
+
await settle();
|
|
1050
|
+
expect(created()).toBe(createdBefore + 1);
|
|
1051
|
+
|
|
1052
|
+
recording.receive(b, 'video_hole_open');
|
|
1053
|
+
await settle();
|
|
1054
|
+
await checked;
|
|
1055
|
+
expect(shown(b)).toBe(true);
|
|
1056
|
+
expect(played(b)).toBe(1);
|
|
1057
|
+
expect(created()).toBe(createdBefore + 1);
|
|
1058
|
+
expect(apps.windowOf('c')).toBeUndefined();
|
|
1059
|
+
|
|
1060
|
+
await manager.destroy();
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
test('destroy during the wait leaves no window and no surface', async () => {
|
|
1064
|
+
const { manager, recording, host } = await startWaitingSwap();
|
|
1065
|
+
|
|
1066
|
+
await manager.destroy();
|
|
1067
|
+
const callsAtDestroy = recording.calls.length;
|
|
1068
|
+
await settle();
|
|
1069
|
+
|
|
1070
|
+
expect(
|
|
1071
|
+
recording.calls
|
|
1072
|
+
.slice(callsAtDestroy)
|
|
1073
|
+
.filter((c) => c.method === 'createWindow')
|
|
1074
|
+
).toHaveLength(0);
|
|
1075
|
+
expect(host.liveIds()).toEqual([]);
|
|
1076
|
+
expect(recording.liveWindowIds()).toEqual([]);
|
|
1077
|
+
});
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
describe('swap timing, on fake timers', () => {
|
|
1081
|
+
beforeEach(() => {
|
|
1082
|
+
jest.useFakeTimers();
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
afterEach(() => {
|
|
1086
|
+
jest.useRealTimers();
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
// Lets promise callbacks run between fake timers.
|
|
1090
|
+
const microtasks = async () => {
|
|
1091
|
+
for (let i = 0; i < 50; i += 1) {
|
|
1092
|
+
await Promise.resolve();
|
|
1093
|
+
}
|
|
1094
|
+
};
|
|
1095
|
+
|
|
1096
|
+
// A surface manager driven a millisecond at a time, whose host reports
|
|
1097
|
+
// playing 25 ms after each play. Every call and app message is stamped
|
|
1098
|
+
// with the millisecond it happened in.
|
|
1099
|
+
async function startTimed(
|
|
1100
|
+
presentationIds: string[],
|
|
1101
|
+
script: AppScript,
|
|
1102
|
+
hostOpts: Parameters<typeof createFakeSurfaceHost>[0] = {
|
|
1103
|
+
playingAfterMs: 25,
|
|
1104
|
+
},
|
|
1105
|
+
showAfterRevealMs?: number
|
|
1106
|
+
) {
|
|
1107
|
+
const setup = createSurfaceManager(
|
|
1108
|
+
presentationIds,
|
|
1109
|
+
script,
|
|
1110
|
+
hostOpts,
|
|
1111
|
+
{},
|
|
1112
|
+
showAfterRevealMs
|
|
1113
|
+
);
|
|
1114
|
+
let clock = 0;
|
|
1115
|
+
let mostLive = 0;
|
|
1116
|
+
const stamps: Record<number, number> = {};
|
|
1117
|
+
const stamp = () => {
|
|
1118
|
+
setup.entries().forEach((e) => {
|
|
1119
|
+
if (stamps[e.seq] === undefined) stamps[e.seq] = clock;
|
|
1120
|
+
});
|
|
1121
|
+
mostLive = Math.max(mostLive, setup.host.liveIds().length);
|
|
1122
|
+
};
|
|
1123
|
+
const tick = async (ms: number) => {
|
|
1124
|
+
for (let i = 0; i < ms; i += 1) {
|
|
1125
|
+
jest.advanceTimersByTime(1);
|
|
1126
|
+
clock += 1;
|
|
1127
|
+
await microtasks();
|
|
1128
|
+
stamp();
|
|
1129
|
+
}
|
|
1130
|
+
};
|
|
1131
|
+
|
|
1132
|
+
const loading = setup.manager.load();
|
|
1133
|
+
await tick(10);
|
|
1134
|
+
await loading;
|
|
1135
|
+
setup.manager.show();
|
|
1136
|
+
setup.manager.play();
|
|
1137
|
+
await tick(200);
|
|
1138
|
+
|
|
1139
|
+
// Completes the playing presentation; returns where its swap starts.
|
|
1140
|
+
const completeAt = async (presentationId: string) => {
|
|
1141
|
+
passTime();
|
|
1142
|
+
const entries = setup.entries();
|
|
1143
|
+
const mark = {
|
|
1144
|
+
t0: clock,
|
|
1145
|
+
fromSeq: entries.length ? entries[entries.length - 1].seq : 0,
|
|
1146
|
+
};
|
|
1147
|
+
setup.recording.receive(
|
|
1148
|
+
setup.apps.windowOf(presentationId),
|
|
1149
|
+
'presentation_complete'
|
|
1150
|
+
);
|
|
1151
|
+
await microtasks();
|
|
1152
|
+
stamp();
|
|
1153
|
+
return mark;
|
|
1154
|
+
};
|
|
1155
|
+
// Each line since the mark, after the milliseconds since its swap began.
|
|
1156
|
+
const since = (mark: { t0: number; fromSeq: number }) => {
|
|
1157
|
+
stamp();
|
|
1158
|
+
return setup
|
|
1159
|
+
.entries()
|
|
1160
|
+
.filter((e) => e.seq > mark.fromSeq)
|
|
1161
|
+
.map((e) => `${stamps[e.seq] - mark.t0} ${e.text}`);
|
|
1162
|
+
};
|
|
1163
|
+
|
|
1164
|
+
// Stamps what promise callbacks did since the last millisecond.
|
|
1165
|
+
const settleNow = async () => {
|
|
1166
|
+
await microtasks();
|
|
1167
|
+
stamp();
|
|
1168
|
+
};
|
|
1169
|
+
|
|
1170
|
+
return {
|
|
1171
|
+
...setup,
|
|
1172
|
+
tick,
|
|
1173
|
+
settleNow,
|
|
1174
|
+
completeAt,
|
|
1175
|
+
since,
|
|
1176
|
+
mostLive: () => mostLive,
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// The swap's steps: surface calls, the frames shown, hidden, created and
|
|
1181
|
+
// removed, and the messages that start, show and close a surface.
|
|
1182
|
+
const isTimedStep = (line: string) =>
|
|
1183
|
+
/ (host (?!setBounds)|showWindow|hideWindow|removeWindow|createWindow|setWindowBackground)/.test(
|
|
1184
|
+
line
|
|
1185
|
+
) ||
|
|
1186
|
+
/ sendMessageString \S+ (play|video_shown|video_closing|video_refused)$/.test(
|
|
1187
|
+
line
|
|
1188
|
+
) ||
|
|
1189
|
+
/ app \S+ video_hole_open$/.test(line);
|
|
1190
|
+
|
|
1191
|
+
test('an app that opened its hole while hidden plays, is revealed on playing with the outgoing hidden, then is shown and unmuted', async () => {
|
|
1192
|
+
const {
|
|
1193
|
+
manager,
|
|
1194
|
+
recording,
|
|
1195
|
+
host,
|
|
1196
|
+
apps,
|
|
1197
|
+
tick,
|
|
1198
|
+
completeAt,
|
|
1199
|
+
since,
|
|
1200
|
+
mostLive,
|
|
1201
|
+
} = await startTimed(['a', 'b'], { preopenHole: ['a', 'b'] });
|
|
1202
|
+
|
|
1203
|
+
// The reveal and the outgoing hide land in one task.
|
|
1204
|
+
const a = apps.windowOf('a');
|
|
1205
|
+
const b = apps.windowOf('b');
|
|
1206
|
+
let hiddenInRevealTask: boolean | null = null;
|
|
1207
|
+
const showWindow = recording.renderer.showWindow;
|
|
1208
|
+
recording.renderer.showWindow = (windowId: string, isFocused: boolean) => {
|
|
1209
|
+
showWindow(windowId, isFocused);
|
|
1210
|
+
if (windowId === b) {
|
|
1211
|
+
Promise.resolve().then(() => {
|
|
1212
|
+
hiddenInRevealTask = recording.calls.some(
|
|
1213
|
+
(c) => c.method === 'hideWindow' && c.windowId === a
|
|
1214
|
+
);
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
};
|
|
1218
|
+
|
|
1219
|
+
const mark = await completeAt('a');
|
|
1220
|
+
await tick(300);
|
|
1221
|
+
|
|
1222
|
+
expect(since(mark).filter(isTimedStep)).toEqual([
|
|
1223
|
+
'0 host setMuted a true',
|
|
1224
|
+
'0 host play b',
|
|
1225
|
+
'25 host onHole true',
|
|
1226
|
+
'25 setWindowBackground b transparent',
|
|
1227
|
+
'25 showWindow b',
|
|
1228
|
+
'25 hideWindow a',
|
|
1229
|
+
'25 sendMessageString b play',
|
|
1230
|
+
'105 host show b',
|
|
1231
|
+
'105 host setMuted b false',
|
|
1232
|
+
'105 sendMessageString b video_shown',
|
|
1233
|
+
'105 sendMessageString a video_closing',
|
|
1234
|
+
'105 host close a',
|
|
1235
|
+
'105 host onHole false',
|
|
1236
|
+
'105 removeWindow a',
|
|
1237
|
+
'105 createWindow a2',
|
|
1238
|
+
'106 app b video_hole_open',
|
|
1239
|
+
'106 host openVideo a2',
|
|
1240
|
+
'107 app a2 video_hole_open',
|
|
1241
|
+
]);
|
|
1242
|
+
expect(hiddenInRevealTask).toBe(true);
|
|
1243
|
+
expect(mostLive()).toBeLessThanOrEqual(2);
|
|
1244
|
+
expect(host.violations).toEqual([]);
|
|
1245
|
+
|
|
1246
|
+
await manager.destroy();
|
|
1247
|
+
});
|
|
1248
|
+
|
|
1249
|
+
test('an app that has not opened its hole keeps the show, hole, reveal, play order', async () => {
|
|
1250
|
+
const { manager, host, tick, completeAt, since, mostLive } =
|
|
1251
|
+
await startTimed(['a', 'b'], {});
|
|
1252
|
+
|
|
1253
|
+
const mark = await completeAt('a');
|
|
1254
|
+
await tick(300);
|
|
1255
|
+
|
|
1256
|
+
expect(since(mark).filter(isTimedStep)).toEqual([
|
|
1257
|
+
'0 host setMuted a true',
|
|
1258
|
+
'0 host show b',
|
|
1259
|
+
'0 host setMuted b false',
|
|
1260
|
+
'0 sendMessageString b video_shown',
|
|
1261
|
+
'1 app b video_hole_open',
|
|
1262
|
+
'1 host onHole true',
|
|
1263
|
+
'1 setWindowBackground b transparent',
|
|
1264
|
+
'1 showWindow b',
|
|
1265
|
+
'1 hideWindow a',
|
|
1266
|
+
'1 sendMessageString b play',
|
|
1267
|
+
'1 host play b',
|
|
1268
|
+
'2 sendMessageString a video_closing',
|
|
1269
|
+
'2 host close a',
|
|
1270
|
+
'2 host onHole false',
|
|
1271
|
+
'2 removeWindow a',
|
|
1272
|
+
'2 createWindow a2',
|
|
1273
|
+
'3 host openVideo a2',
|
|
1274
|
+
]);
|
|
1275
|
+
expect(mostLive()).toBeLessThanOrEqual(2);
|
|
1276
|
+
expect(host.violations).toEqual([]);
|
|
1277
|
+
|
|
1278
|
+
await manager.destroy();
|
|
1279
|
+
});
|
|
1280
|
+
|
|
1281
|
+
test('a surface that never reports playing is revealed at the show deadline', async () => {
|
|
1282
|
+
const { manager, host, warn, tick, completeAt, since } = await startTimed(
|
|
1283
|
+
['a', 'b'],
|
|
1284
|
+
{ preopenHole: ['b'] },
|
|
1285
|
+
{}
|
|
1286
|
+
);
|
|
1287
|
+
|
|
1288
|
+
const mark = await completeAt('a');
|
|
1289
|
+
await tick(300);
|
|
1290
|
+
|
|
1291
|
+
const steps = since(mark).filter(isTimedStep);
|
|
1292
|
+
expect(steps).toContain('0 host play b');
|
|
1293
|
+
expect(steps).toContain(`${CLOSE_WINDOW_DELAY_MS} showWindow b`);
|
|
1294
|
+
expect(steps).toContain(`${CLOSE_WINDOW_DELAY_MS + 80} host show b`);
|
|
1295
|
+
expect(
|
|
1296
|
+
warn.mock.calls.filter(([message]) => /No 'playing'/.test(message))
|
|
1297
|
+
).toHaveLength(1);
|
|
1298
|
+
expect(host.violations).toEqual([]);
|
|
1299
|
+
|
|
1300
|
+
await manager.destroy();
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
test('surfaces.showAfterRevealMs sets the show after the reveal', async () => {
|
|
1304
|
+
const { manager, tick, completeAt, since } = await startTimed(
|
|
1305
|
+
['a', 'b'],
|
|
1306
|
+
{ preopenHole: ['b'] },
|
|
1307
|
+
undefined,
|
|
1308
|
+
30
|
|
1309
|
+
);
|
|
1310
|
+
|
|
1311
|
+
const mark = await completeAt('a');
|
|
1312
|
+
await tick(300);
|
|
1313
|
+
|
|
1314
|
+
const steps = since(mark).filter(isTimedStep);
|
|
1315
|
+
expect(steps).toContain('25 showWindow b');
|
|
1316
|
+
expect(steps).toContain('55 host show b');
|
|
1317
|
+
expect(steps).toContain('55 host setMuted b false');
|
|
1318
|
+
|
|
1319
|
+
await manager.destroy();
|
|
1320
|
+
});
|
|
1321
|
+
|
|
1322
|
+
test('over a playlist of swaps, two surfaces never sound at once and never more than two are live', async () => {
|
|
1323
|
+
const { manager, host, tick, completeAt, hostCalls, mostLive } =
|
|
1324
|
+
await startTimed(['a', 'b', 'c'], { preopenHole: ['a', 'b', 'c'] });
|
|
1325
|
+
|
|
1326
|
+
for (const id of ['a', 'b', 'c', 'a']) {
|
|
1327
|
+
await completeAt(id);
|
|
1328
|
+
await tick(1000);
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
expect(hostCalls('show')).toHaveLength(5);
|
|
1332
|
+
expect(mostLive()).toBe(2);
|
|
1333
|
+
expect(host.violations).toEqual([]);
|
|
1334
|
+
|
|
1335
|
+
await manager.destroy();
|
|
1336
|
+
expect(host.liveIds()).toEqual([]);
|
|
1337
|
+
});
|
|
1338
|
+
|
|
1339
|
+
test('a publish while the next preload waits for the swap preloads at once, and once', async () => {
|
|
1340
|
+
const { manager, apps, tick, settleNow, completeAt, since, host, sent } =
|
|
1341
|
+
await startTimed(['a', 'b', 'c'], { preopenHole: ['a', 'b', 'c'] });
|
|
1342
|
+
|
|
1343
|
+
const mark = await completeAt('a');
|
|
1344
|
+
await tick(10);
|
|
1345
|
+
const ruleEngine = manager.getRuleEngine() as RE.RuleEngine;
|
|
1346
|
+
manager.setRuleEngine(
|
|
1347
|
+
new RE.RuleEngine(
|
|
1348
|
+
'main',
|
|
1349
|
+
new RE.State(
|
|
1350
|
+
{
|
|
1351
|
+
main: {
|
|
1352
|
+
id: 'main',
|
|
1353
|
+
mode: RE.PlaylistMode.Sequential,
|
|
1354
|
+
items: [{ presentationId: 'a' }, { presentationId: 'c' }],
|
|
1355
|
+
},
|
|
1356
|
+
},
|
|
1357
|
+
ruleEngine.state.presentations,
|
|
1358
|
+
{}
|
|
1359
|
+
)
|
|
1360
|
+
)
|
|
1361
|
+
);
|
|
1362
|
+
await settleNow();
|
|
1363
|
+
await tick(300);
|
|
1364
|
+
|
|
1365
|
+
// The publish's window opens its surface once the outgoing has closed.
|
|
1366
|
+
const steps = since(mark).filter(isTimedStep);
|
|
1367
|
+
expect(steps.filter((line) => / createWindow /.test(line))).toEqual([
|
|
1368
|
+
'10 createWindow a2',
|
|
1369
|
+
]);
|
|
1370
|
+
expect(steps).toContain('105 host close a');
|
|
1371
|
+
expect(steps).toContain('105 host openVideo a2');
|
|
1372
|
+
expect(sent('b', 'video_shown')).toHaveLength(1);
|
|
1373
|
+
expect(apps.windowOf('a')).not.toBe(undefined);
|
|
1374
|
+
expect(host.violations).toEqual([]);
|
|
1375
|
+
|
|
1376
|
+
await manager.destroy();
|
|
1377
|
+
});
|
|
1378
|
+
|
|
1379
|
+
test('a show that hangs after the reveal falls back to <video> in the revealed frame', async () => {
|
|
1380
|
+
const { manager, host, tick, completeAt, since, sent, hostCalls } =
|
|
1381
|
+
await startTimed(['a', 'b'], { preopenHole: ['b'] });
|
|
1382
|
+
host.hangNextShow();
|
|
1383
|
+
|
|
1384
|
+
const mark = await completeAt('a');
|
|
1385
|
+
await tick(300);
|
|
1386
|
+
|
|
1387
|
+
expect(sent('b', 'video_refused').map((c) => c.args[1])).toEqual([
|
|
1388
|
+
{ reason: 'deadline' },
|
|
1389
|
+
]);
|
|
1390
|
+
const deadlineAt = 105 + CLOSE_WINDOW_DELAY_MS;
|
|
1391
|
+
const steps = since(mark).filter(isTimedStep);
|
|
1392
|
+
expect(steps).toContain('105 host show b');
|
|
1393
|
+
expect(steps).toContain(`${deadlineAt} host close b`);
|
|
1394
|
+
expect(steps).toContain(`${deadlineAt} setWindowBackground b #000000`);
|
|
1395
|
+
expect(steps.filter((line) => / showWindow b$/.test(line))).toEqual([
|
|
1396
|
+
'25 showWindow b',
|
|
1397
|
+
]);
|
|
1398
|
+
expect(sent('b', 'play')).toHaveLength(1);
|
|
1399
|
+
expect(sent('b', 'video_shown')).toHaveLength(0);
|
|
1400
|
+
expect(hostCalls('setMuted', 'b')).toHaveLength(0);
|
|
1401
|
+
expect(steps).toContain(`${deadlineAt} removeWindow a`);
|
|
1402
|
+
expect(host.violations).toEqual([]);
|
|
1403
|
+
|
|
1404
|
+
await manager.destroy();
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
test('a swap of two still images in a surface manager preloads in the swap and removes the outgoing at its paint', async () => {
|
|
1408
|
+
const { manager, tick, completeAt, since } = await startTimed(['a', 'b'], {
|
|
1409
|
+
images: ['a', 'b'],
|
|
1410
|
+
});
|
|
1411
|
+
|
|
1412
|
+
const mark = await completeAt('a');
|
|
1413
|
+
await tick(100);
|
|
1414
|
+
|
|
1415
|
+
expect(since(mark).filter(isTimedStep)).toEqual([
|
|
1416
|
+
'0 showWindow b',
|
|
1417
|
+
'0 sendMessageString b play',
|
|
1418
|
+
'0 createWindow a2',
|
|
1419
|
+
'1 removeWindow a',
|
|
1420
|
+
]);
|
|
1421
|
+
|
|
1422
|
+
await manager.destroy();
|
|
1423
|
+
});
|
|
1424
|
+
|
|
1425
|
+
test('a manager with no surface host preloads in the swap and removes the outgoing at its paint', async () => {
|
|
1426
|
+
const { manager, recording } = createManager({
|
|
1427
|
+
presentationIds: ['a', 'b'],
|
|
1428
|
+
});
|
|
1429
|
+
scriptImageApp(recording);
|
|
1430
|
+
const tick = async (ms: number) => {
|
|
1431
|
+
for (let i = 0; i < ms; i += 1) {
|
|
1432
|
+
jest.advanceTimersByTime(1);
|
|
1433
|
+
await microtasks();
|
|
1434
|
+
}
|
|
1435
|
+
};
|
|
1436
|
+
|
|
1437
|
+
const loading = manager.load();
|
|
1438
|
+
await tick(10);
|
|
1439
|
+
await loading;
|
|
1440
|
+
manager.show();
|
|
1441
|
+
manager.play();
|
|
1442
|
+
await tick(10);
|
|
1443
|
+
|
|
1444
|
+
const before = recording.calls.length;
|
|
1445
|
+
passTime();
|
|
1446
|
+
recording.receive(lastShown(recording), 'presentation_complete');
|
|
1447
|
+
await microtasks();
|
|
1448
|
+
const inSwapTask = describeCalls(recording).slice(before);
|
|
1449
|
+
await tick(5);
|
|
1450
|
+
|
|
1451
|
+
// w1 is the incoming window, preloaded before w2 at load.
|
|
1452
|
+
expect(inSwapTask).toEqual([
|
|
1453
|
+
'showWindow w1',
|
|
1454
|
+
'unMuteWindow w1',
|
|
1455
|
+
'muteWindow w2',
|
|
1456
|
+
'sendMessageString w1 play',
|
|
1457
|
+
'createWindow w3',
|
|
1458
|
+
'muteWindow w3',
|
|
1459
|
+
'loadWindow w3',
|
|
1460
|
+
'sendMessageString w3 render',
|
|
1461
|
+
]);
|
|
1462
|
+
expect(describeCalls(recording).slice(before)).toContain('removeWindow w2');
|
|
1463
|
+
|
|
1464
|
+
await manager.destroy();
|
|
1465
|
+
});
|
|
1466
|
+
});
|