@roku-sdk/ads 0.0.0-stage → 2.18.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/LICENSE +21 -0
- package/README.md +20 -2
- package/package.json +47 -3
- package/src/__tests__/ads.test.tsx +735 -0
- package/src/ads.tsx +388 -0
- package/src/index.ts +22 -0
- package/src/xml-components/ads/ads.brs +198 -0
- package/src/xml-components/ads/ads.xml +35 -0
- package/src/xml-components/ads/ads.xml.d.ts +68 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,735 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roku, Inc.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { describe, expect, test } from "@jest/globals";
|
|
5
|
+
import { registerInternStringApi } from "@roku-sdk/common/string-interner";
|
|
6
|
+
import { transactionManager } from "@roku-sdk/common/transaction";
|
|
7
|
+
import type { DomNode, InternStringApi, StructuredDataApi, TreeManipulationApi } from "@roku-sdk/interfaces";
|
|
8
|
+
import type { Component } from "@roku-sdk/solid";
|
|
9
|
+
import { createSignal, makeSolidRenderer } from "@roku-sdk/solid";
|
|
10
|
+
import { createDomNode } from "@roku-sdk/solid/src/dom-node";
|
|
11
|
+
|
|
12
|
+
import { Ads, type AdsCommand, type IAdsProps } from "../ads";
|
|
13
|
+
|
|
14
|
+
type Captured = Record<string, CapturedEntry>;
|
|
15
|
+
|
|
16
|
+
type CapturedEntry =
|
|
17
|
+
| { kind: "bool"; value: boolean }
|
|
18
|
+
| { kind: "string"; value: string }
|
|
19
|
+
| { kind: "object"; value: Record<string, unknown> }
|
|
20
|
+
| { kind: "nodeRef"; value: string }
|
|
21
|
+
| { kind: "eventHandler" };
|
|
22
|
+
|
|
23
|
+
const commitEarly = transactionManager.commitEarly;
|
|
24
|
+
|
|
25
|
+
const SAMPLE_AD_URL =
|
|
26
|
+
"https://pubads.g.doubleclick.net/gampad/ads?iu=/21775744923/external/vmap_ad_samples&output=vmap";
|
|
27
|
+
|
|
28
|
+
function captureAdsProps(buildProps: () => IAdsProps): { tagName: string; props: Captured } {
|
|
29
|
+
const internForward = new Map<string, object>();
|
|
30
|
+
const internReverse = new Map<object, string>();
|
|
31
|
+
let internRef = 0;
|
|
32
|
+
const mockIntern: InternStringApi = {
|
|
33
|
+
internString: (str: string): object => {
|
|
34
|
+
const existing = internForward.get(str);
|
|
35
|
+
if (existing) return existing;
|
|
36
|
+
const ref = { id: ++internRef };
|
|
37
|
+
internForward.set(str, ref);
|
|
38
|
+
internReverse.set(ref, str);
|
|
39
|
+
return ref;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
registerInternStringApi(mockIntern);
|
|
43
|
+
|
|
44
|
+
const resolveInterned = (ref: unknown): string => {
|
|
45
|
+
if (ref && typeof ref === "object") {
|
|
46
|
+
const s = internReverse.get(ref as object);
|
|
47
|
+
if (s !== undefined) return s;
|
|
48
|
+
}
|
|
49
|
+
return String(ref);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const nodeTag = new WeakMap<object, string>();
|
|
53
|
+
let nodeCounter = 0;
|
|
54
|
+
|
|
55
|
+
const mockSd: StructuredDataApi = {
|
|
56
|
+
createObject: () => ({}),
|
|
57
|
+
createArray: () => [],
|
|
58
|
+
insertBool: (o, p, v) => {
|
|
59
|
+
(o as Record<string, unknown>)[resolveInterned(p)] = v;
|
|
60
|
+
},
|
|
61
|
+
insertNull: (o, p) => {
|
|
62
|
+
(o as Record<string, unknown>)[resolveInterned(p)] = null;
|
|
63
|
+
},
|
|
64
|
+
insertInt: (o, p, v) => {
|
|
65
|
+
(o as Record<string, unknown>)[resolveInterned(p)] = v;
|
|
66
|
+
},
|
|
67
|
+
insertFloat: (o, p, v) => {
|
|
68
|
+
(o as Record<string, unknown>)[resolveInterned(p)] = v;
|
|
69
|
+
},
|
|
70
|
+
insertString: (o, p, v) => {
|
|
71
|
+
(o as Record<string, unknown>)[resolveInterned(p)] = v;
|
|
72
|
+
},
|
|
73
|
+
insertObject: (o, p, v) => {
|
|
74
|
+
(o as Record<string, unknown>)[resolveInterned(p)] = v;
|
|
75
|
+
},
|
|
76
|
+
insertArray: (o, p, v) => {
|
|
77
|
+
(o as Record<string, unknown>)[resolveInterned(p)] = v;
|
|
78
|
+
},
|
|
79
|
+
insertNodeRef: () => undefined,
|
|
80
|
+
appendBool: (a, v) => {
|
|
81
|
+
(a as unknown[]).push(v);
|
|
82
|
+
},
|
|
83
|
+
appendNull: a => {
|
|
84
|
+
(a as unknown[]).push(null);
|
|
85
|
+
},
|
|
86
|
+
appendInt: (a, v) => {
|
|
87
|
+
(a as unknown[]).push(v);
|
|
88
|
+
},
|
|
89
|
+
appendFloat: (a, v) => {
|
|
90
|
+
(a as unknown[]).push(v);
|
|
91
|
+
},
|
|
92
|
+
appendString: (a, v) => {
|
|
93
|
+
(a as unknown[]).push(typeof v === "string" ? v : resolveInterned(v));
|
|
94
|
+
},
|
|
95
|
+
appendObject: (a, v) => {
|
|
96
|
+
(a as unknown[]).push(v);
|
|
97
|
+
},
|
|
98
|
+
appendArray: (a, v) => {
|
|
99
|
+
(a as unknown[]).push(v);
|
|
100
|
+
},
|
|
101
|
+
appendNodeRef: () => undefined,
|
|
102
|
+
getArray: () => undefined,
|
|
103
|
+
getObject: () => undefined
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
type Entry = { nodeRef: object; propName: string; entry: CapturedEntry };
|
|
107
|
+
const allCalls: Entry[] = [];
|
|
108
|
+
let rootTagName = "";
|
|
109
|
+
|
|
110
|
+
const push = (nodeRef: unknown, propRef: unknown, entry: CapturedEntry) => {
|
|
111
|
+
if (!nodeRef || typeof nodeRef !== "object") return;
|
|
112
|
+
allCalls.push({ nodeRef: nodeRef as object, propName: resolveInterned(propRef), entry });
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const mockTm: TreeManipulationApi = {
|
|
116
|
+
createNode: (_anchor, tagRef) => {
|
|
117
|
+
const ref: object = { id: ++nodeCounter };
|
|
118
|
+
const tagName = resolveInterned(tagRef);
|
|
119
|
+
nodeTag.set(ref, tagName);
|
|
120
|
+
if (!rootTagName) rootTagName = tagName;
|
|
121
|
+
return ref;
|
|
122
|
+
},
|
|
123
|
+
appendNode: () => undefined,
|
|
124
|
+
insertNodeBefore: () => undefined,
|
|
125
|
+
removeNode: () => undefined,
|
|
126
|
+
setNodePropNull: (_a, n, p) => push(n, p, { kind: "string", value: "null" }),
|
|
127
|
+
setNodePropBool: (_a, n, p, v) => push(n, p, { kind: "bool", value: v }),
|
|
128
|
+
setNodePropInt: (_a, n, p, v) => push(n, p, { kind: "string", value: String(v) }),
|
|
129
|
+
setNodePropFloat: (_a, n, p, v) => push(n, p, { kind: "string", value: String(v) }),
|
|
130
|
+
setNodePropString: (_a, n, p, v) => push(n, p, { kind: "string", value: v }),
|
|
131
|
+
setNodePropObject: (_a, n, p, v) => push(n, p, { kind: "object", value: v as Record<string, unknown> }),
|
|
132
|
+
setNodePropArray: () => undefined,
|
|
133
|
+
setNodePropNodeRef: (_a, n, p, v) => {
|
|
134
|
+
const refId = v && typeof v === "object" && "id" in v ? String((v as { id: number }).id) : "unknown";
|
|
135
|
+
push(n, p, { kind: "nodeRef", value: `<ref:${refId}>` });
|
|
136
|
+
},
|
|
137
|
+
removeNodeProp: () => undefined,
|
|
138
|
+
updateNodeProps: () => undefined,
|
|
139
|
+
removeNodeProps: () => undefined,
|
|
140
|
+
registerFieldChangeHandler: (_a, n, p) => push(n, p, { kind: "eventHandler" }),
|
|
141
|
+
unregisterFieldHandler: () => undefined,
|
|
142
|
+
setFocus: () => undefined
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const { renderer } = makeSolidRenderer(mockTm, mockSd, mockSd);
|
|
146
|
+
const anchorName = { id: 0 };
|
|
147
|
+
|
|
148
|
+
const anchorNode: DomNode = { r: {}, n: anchorName } as DomNode;
|
|
149
|
+
const props = buildProps();
|
|
150
|
+
const dispose = renderer.render((() => <Ads {...props} />) as unknown as () => DomNode, anchorNode);
|
|
151
|
+
commitEarly();
|
|
152
|
+
|
|
153
|
+
const rootDom = anchorNode.c && anchorNode.c[0];
|
|
154
|
+
const rootNodeRef = rootDom?.r as object | undefined;
|
|
155
|
+
|
|
156
|
+
dispose();
|
|
157
|
+
|
|
158
|
+
const captured: Captured = {};
|
|
159
|
+
if (rootNodeRef) {
|
|
160
|
+
for (const { nodeRef, propName, entry } of allCalls) {
|
|
161
|
+
if (nodeRef === rootNodeRef) captured[propName] = entry;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const sorted: Captured = {};
|
|
166
|
+
for (const key of Object.keys(captured).sort()) {
|
|
167
|
+
sorted[key] = captured[key];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { tagName: rootTagName, props: sorted };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function buildSampleProps(): IAdsProps {
|
|
174
|
+
const adView = createDomNode("rectangle", { id: 100 });
|
|
175
|
+
const contentView = createDomNode("video", { id: 101 });
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
debugOutput: true,
|
|
179
|
+
command: "getAds",
|
|
180
|
+
adUrl: SAMPLE_AD_URL,
|
|
181
|
+
adView,
|
|
182
|
+
contentView,
|
|
183
|
+
onAdsState: () => {},
|
|
184
|
+
onGetAdsResult: () => {},
|
|
185
|
+
onShowAdsResult: () => {}
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function buildSsaiProps(): IAdsProps {
|
|
190
|
+
const contentView = createDomNode("video", { id: 201 });
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
debugOutput: true,
|
|
194
|
+
command: "stitchedAdsInit",
|
|
195
|
+
adUrl: SAMPLE_AD_URL,
|
|
196
|
+
contentView,
|
|
197
|
+
onAdsState: () => {},
|
|
198
|
+
onGetAdsResult: () => {},
|
|
199
|
+
onStitchedAdResult: () => {}
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const SAMPLE_CONTENT_METADATA = {
|
|
204
|
+
url: "http://example.com/content.m3u8",
|
|
205
|
+
length: 600
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
function buildCsasProps(): IAdsProps {
|
|
209
|
+
const adView = createDomNode("rectangle", { id: 401 });
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
debugOutput: true,
|
|
213
|
+
command: "constructStitchedStream",
|
|
214
|
+
adUrl: SAMPLE_AD_URL,
|
|
215
|
+
adView,
|
|
216
|
+
contentMetadata: SAMPLE_CONTENT_METADATA,
|
|
217
|
+
onAdsState: () => {},
|
|
218
|
+
onGetAdsResult: () => {},
|
|
219
|
+
onConstructStitchedStreamResult: () => {},
|
|
220
|
+
onRenderStitchedStreamResult: () => {}
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const SAMPLE_CONTENT_GENRE = "Entertainment";
|
|
225
|
+
const SAMPLE_CONTENT_ID = "CSASAdSample";
|
|
226
|
+
|
|
227
|
+
function buildAudienceMeasurementProps(): IAdsProps {
|
|
228
|
+
return {
|
|
229
|
+
enableAdMeasurements: true,
|
|
230
|
+
contentGenre: SAMPLE_CONTENT_GENRE,
|
|
231
|
+
contentId: SAMPLE_CONTENT_ID
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
describe("Ads", () => {
|
|
236
|
+
test("renders the ads SceneGraph Task node", () => {
|
|
237
|
+
const { tagName } = captureAdsProps(buildSampleProps);
|
|
238
|
+
expect(tagName).toBe("ads");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("forwards scalar props to the underlying ads node", () => {
|
|
242
|
+
const { props } = captureAdsProps(buildSampleProps);
|
|
243
|
+
|
|
244
|
+
expect(props).toMatchObject({
|
|
245
|
+
adUrl: { kind: "string", value: SAMPLE_AD_URL },
|
|
246
|
+
command: { kind: "string", value: "getAds" },
|
|
247
|
+
debugOutput: { kind: "bool", value: true }
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("forwards node refs for adView and contentView", () => {
|
|
252
|
+
const { props } = captureAdsProps(buildSampleProps);
|
|
253
|
+
|
|
254
|
+
expect(props.adView).toEqual({ kind: "nodeRef", value: "<ref:100>" });
|
|
255
|
+
expect(props.contentView).toEqual({ kind: "nodeRef", value: "<ref:101>" });
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("registers RAF callback fields on the ads node", () => {
|
|
259
|
+
const { props } = captureAdsProps(buildSampleProps);
|
|
260
|
+
|
|
261
|
+
expect(props.adsState).toEqual({ kind: "eventHandler" });
|
|
262
|
+
expect(props.getAdsResult).toEqual({ kind: "eventHandler" });
|
|
263
|
+
expect(props.showAdsResult).toEqual({ kind: "eventHandler" });
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("forwards adCounter as the assocarray RAF reads its ad counter from", () => {
|
|
267
|
+
const { props } = captureAdsProps(() => ({
|
|
268
|
+
command: "showAds",
|
|
269
|
+
adCounter: { start: 1, total: 4 }
|
|
270
|
+
}));
|
|
271
|
+
|
|
272
|
+
expect(props.adCounter).toEqual({ kind: "object", value: { start: 1, total: 4 } });
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("omits adCounter when it is not given, so RAF keeps its own count", () => {
|
|
276
|
+
const { props } = captureAdsProps(() => ({ command: "showAds" }));
|
|
277
|
+
|
|
278
|
+
expect(props.adCounter).toBeUndefined();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe("CSAS", () => {
|
|
282
|
+
test("forwards constructStitchedStream command to the underlying ads node", () => {
|
|
283
|
+
const { props } = captureAdsProps(buildCsasProps);
|
|
284
|
+
|
|
285
|
+
expect(props.command).toEqual({ kind: "string", value: "constructStitchedStream" });
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("forwards contentMetadata for stitched stream construction", () => {
|
|
289
|
+
const { props } = captureAdsProps(buildCsasProps);
|
|
290
|
+
|
|
291
|
+
expect(props.contentMetadata).toEqual({ kind: "object", value: SAMPLE_CONTENT_METADATA });
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("forwards adView for stitched stream rendering", () => {
|
|
295
|
+
const { props } = captureAdsProps(buildCsasProps);
|
|
296
|
+
|
|
297
|
+
expect(props.adView).toEqual({ kind: "nodeRef", value: "<ref:401>" });
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("registers constructStitchedStreamResult callback on the ads node", () => {
|
|
301
|
+
const { props } = captureAdsProps(buildCsasProps);
|
|
302
|
+
|
|
303
|
+
expect(props.constructStitchedStreamResult).toEqual({ kind: "eventHandler" });
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("registers renderStitchedStreamResult callback on the ads node", () => {
|
|
307
|
+
const { props } = captureAdsProps(buildCsasProps);
|
|
308
|
+
|
|
309
|
+
expect(props.renderStitchedStreamResult).toEqual({ kind: "eventHandler" });
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("forwards renderStitchedStream command to the underlying ads node", () => {
|
|
313
|
+
const { props } = captureAdsProps(() => ({
|
|
314
|
+
...buildCsasProps(),
|
|
315
|
+
command: "renderStitchedStream"
|
|
316
|
+
}));
|
|
317
|
+
|
|
318
|
+
expect(props.command).toEqual({ kind: "string", value: "renderStitchedStream" });
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("forwards only CSAS-relevant props when contentView and showAds handlers are omitted", () => {
|
|
322
|
+
const { props } = captureAdsProps(() => ({
|
|
323
|
+
command: "constructStitchedStream",
|
|
324
|
+
adView: createDomNode("rectangle", { id: 501 }),
|
|
325
|
+
contentMetadata: SAMPLE_CONTENT_METADATA,
|
|
326
|
+
onConstructStitchedStreamResult: () => {},
|
|
327
|
+
onRenderStitchedStreamResult: () => {}
|
|
328
|
+
}));
|
|
329
|
+
|
|
330
|
+
expect(Object.keys(props).sort()).toEqual([
|
|
331
|
+
"adView",
|
|
332
|
+
"command",
|
|
333
|
+
"constructStitchedStreamResult",
|
|
334
|
+
"contentMetadata",
|
|
335
|
+
"renderStitchedStreamResult"
|
|
336
|
+
]);
|
|
337
|
+
expect(props.command).toEqual({ kind: "string", value: "constructStitchedStream" });
|
|
338
|
+
expect(props.adView).toEqual({ kind: "nodeRef", value: "<ref:501>" });
|
|
339
|
+
expect(props.contentMetadata).toEqual({ kind: "object", value: SAMPLE_CONTENT_METADATA });
|
|
340
|
+
expect(props.constructStitchedStreamResult).toEqual({ kind: "eventHandler" });
|
|
341
|
+
expect(props.renderStitchedStreamResult).toEqual({ kind: "eventHandler" });
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
describe("audience measurement", () => {
|
|
346
|
+
test("forwards enableAdMeasurements to the underlying ads node", () => {
|
|
347
|
+
const { props } = captureAdsProps(() => ({
|
|
348
|
+
...buildAudienceMeasurementProps(),
|
|
349
|
+
enableAdMeasurements: true
|
|
350
|
+
}));
|
|
351
|
+
|
|
352
|
+
expect(props.enableAdMeasurements).toEqual({ kind: "bool", value: true });
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("forwards enableAdMeasurements=false when measurement is disabled", () => {
|
|
356
|
+
const { props } = captureAdsProps(() => ({
|
|
357
|
+
...buildAudienceMeasurementProps(),
|
|
358
|
+
enableAdMeasurements: false
|
|
359
|
+
}));
|
|
360
|
+
|
|
361
|
+
expect(props.enableAdMeasurements).toEqual({ kind: "bool", value: false });
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("forwards contentGenre to the underlying ads node", () => {
|
|
365
|
+
const { props } = captureAdsProps(buildAudienceMeasurementProps);
|
|
366
|
+
|
|
367
|
+
expect(props.contentGenre).toEqual({ kind: "string", value: SAMPLE_CONTENT_GENRE });
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("forwards contentId to the underlying ads node", () => {
|
|
371
|
+
const { props } = captureAdsProps(buildAudienceMeasurementProps);
|
|
372
|
+
|
|
373
|
+
expect(props.contentId).toEqual({ kind: "string", value: SAMPLE_CONTENT_ID });
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("forwards all audience measurement props together", () => {
|
|
377
|
+
const { props } = captureAdsProps(buildAudienceMeasurementProps);
|
|
378
|
+
|
|
379
|
+
expect(props).toMatchObject({
|
|
380
|
+
enableAdMeasurements: { kind: "bool", value: true },
|
|
381
|
+
contentGenre: { kind: "string", value: SAMPLE_CONTENT_GENRE },
|
|
382
|
+
contentId: { kind: "string", value: SAMPLE_CONTENT_ID }
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("forwards only audience measurement props when other props are omitted", () => {
|
|
387
|
+
const { props } = captureAdsProps(buildAudienceMeasurementProps);
|
|
388
|
+
|
|
389
|
+
expect(Object.keys(props).sort()).toEqual(["contentGenre", "contentId", "enableAdMeasurements"]);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test("updates audience measurement props reactively when signals change", () => {
|
|
393
|
+
const internForward = new Map<string, object>();
|
|
394
|
+
const internReverse = new Map<object, string>();
|
|
395
|
+
let internRef = 0;
|
|
396
|
+
registerInternStringApi({
|
|
397
|
+
internString: (str: string): object => {
|
|
398
|
+
const existing = internForward.get(str);
|
|
399
|
+
if (existing) return existing;
|
|
400
|
+
const ref = { id: ++internRef };
|
|
401
|
+
internForward.set(str, ref);
|
|
402
|
+
internReverse.set(ref, str);
|
|
403
|
+
return ref;
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
const resolveInterned = (ref: unknown): string => {
|
|
408
|
+
if (ref && typeof ref === "object") {
|
|
409
|
+
const s = internReverse.get(ref as object);
|
|
410
|
+
if (s !== undefined) return s;
|
|
411
|
+
}
|
|
412
|
+
return String(ref);
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const boolValues: boolean[] = [];
|
|
416
|
+
const stringValues: Record<string, string[]> = {
|
|
417
|
+
contentGenre: [],
|
|
418
|
+
contentId: []
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
const mockSd: StructuredDataApi = {
|
|
422
|
+
createObject: () => ({}),
|
|
423
|
+
createArray: () => [],
|
|
424
|
+
insertBool: () => undefined,
|
|
425
|
+
insertNull: () => undefined,
|
|
426
|
+
insertInt: () => undefined,
|
|
427
|
+
insertFloat: () => undefined,
|
|
428
|
+
insertString: () => undefined,
|
|
429
|
+
insertObject: () => undefined,
|
|
430
|
+
insertArray: () => undefined,
|
|
431
|
+
insertNodeRef: () => undefined,
|
|
432
|
+
appendBool: () => undefined,
|
|
433
|
+
appendNull: () => undefined,
|
|
434
|
+
appendInt: () => undefined,
|
|
435
|
+
appendFloat: () => undefined,
|
|
436
|
+
appendString: () => undefined,
|
|
437
|
+
appendObject: () => undefined,
|
|
438
|
+
appendArray: () => undefined,
|
|
439
|
+
appendNodeRef: () => undefined,
|
|
440
|
+
getArray: () => undefined,
|
|
441
|
+
getObject: () => undefined
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
const mockTm: TreeManipulationApi = {
|
|
445
|
+
createNode: (_anchor, tagRef) => ({ id: 1, tag: resolveInterned(tagRef) }),
|
|
446
|
+
appendNode: () => undefined,
|
|
447
|
+
insertNodeBefore: () => undefined,
|
|
448
|
+
removeNode: () => undefined,
|
|
449
|
+
setNodePropNull: () => undefined,
|
|
450
|
+
setNodePropBool: (_a, _n, p, v) => {
|
|
451
|
+
if (resolveInterned(p) === "enableAdMeasurements") boolValues.push(v);
|
|
452
|
+
},
|
|
453
|
+
setNodePropInt: () => undefined,
|
|
454
|
+
setNodePropFloat: () => undefined,
|
|
455
|
+
setNodePropString: (_a, _n, p, v) => {
|
|
456
|
+
const prop = resolveInterned(p);
|
|
457
|
+
if (prop in stringValues) stringValues[prop].push(v);
|
|
458
|
+
},
|
|
459
|
+
setNodePropObject: () => undefined,
|
|
460
|
+
setNodePropArray: () => undefined,
|
|
461
|
+
setNodePropNodeRef: () => undefined,
|
|
462
|
+
removeNodeProp: () => undefined,
|
|
463
|
+
updateNodeProps: () => undefined,
|
|
464
|
+
removeNodeProps: () => undefined,
|
|
465
|
+
registerFieldChangeHandler: () => undefined,
|
|
466
|
+
unregisterFieldHandler: () => undefined,
|
|
467
|
+
setFocus: () => undefined
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
const { renderer } = makeSolidRenderer(mockTm, mockSd, mockSd);
|
|
471
|
+
const anchorName = { id: 0 };
|
|
472
|
+
|
|
473
|
+
const [enableAdMeasurements, setEnableAdMeasurements] = createSignal(false);
|
|
474
|
+
const [contentGenre, setContentGenre] = createSignal("");
|
|
475
|
+
const [contentId, setContentId] = createSignal("");
|
|
476
|
+
const anchorNode: DomNode = { r: {}, n: anchorName } as DomNode;
|
|
477
|
+
const dispose = renderer.render(
|
|
478
|
+
(() => (
|
|
479
|
+
<Ads
|
|
480
|
+
enableAdMeasurements={enableAdMeasurements()}
|
|
481
|
+
contentGenre={contentGenre()}
|
|
482
|
+
contentId={contentId()}
|
|
483
|
+
/>
|
|
484
|
+
)) as unknown as () => DomNode,
|
|
485
|
+
anchorNode
|
|
486
|
+
);
|
|
487
|
+
|
|
488
|
+
commitEarly();
|
|
489
|
+
expect(boolValues).toEqual([false]);
|
|
490
|
+
expect(stringValues.contentGenre).toEqual([""]);
|
|
491
|
+
expect(stringValues.contentId).toEqual([""]);
|
|
492
|
+
|
|
493
|
+
setEnableAdMeasurements(true);
|
|
494
|
+
setContentGenre(SAMPLE_CONTENT_GENRE);
|
|
495
|
+
setContentId(SAMPLE_CONTENT_ID);
|
|
496
|
+
commitEarly();
|
|
497
|
+
expect(boolValues).toEqual([false, true]);
|
|
498
|
+
expect(stringValues.contentGenre).toEqual(["", SAMPLE_CONTENT_GENRE]);
|
|
499
|
+
expect(stringValues.contentId).toEqual(["", SAMPLE_CONTENT_ID]);
|
|
500
|
+
|
|
501
|
+
dispose();
|
|
502
|
+
});
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
describe("SSAI", () => {
|
|
506
|
+
test("forwards stitchedAdsInit command to the underlying ads node", () => {
|
|
507
|
+
const { props } = captureAdsProps(buildSsaiProps);
|
|
508
|
+
|
|
509
|
+
expect(props.command).toEqual({ kind: "string", value: "stitchedAdsInit" });
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
test("forwards contentView without adView for stitched playback", () => {
|
|
513
|
+
const { props } = captureAdsProps(buildSsaiProps);
|
|
514
|
+
|
|
515
|
+
expect(props.contentView).toEqual({ kind: "nodeRef", value: "<ref:201>" });
|
|
516
|
+
expect(props.adView).toBeUndefined();
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test("registers stitchedAdResult callback on the ads node", () => {
|
|
520
|
+
const { props } = captureAdsProps(buildSsaiProps);
|
|
521
|
+
|
|
522
|
+
expect(props.stitchedAdResult).toEqual({ kind: "eventHandler" });
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test("registers stitchedAdsInitResult callback on the ads node", () => {
|
|
526
|
+
const { props } = captureAdsProps(() => ({
|
|
527
|
+
command: "stitchedAdsInit",
|
|
528
|
+
contentView: createDomNode("video", { id: 301 }),
|
|
529
|
+
onStitchedAdsInitResult: () => {}
|
|
530
|
+
}));
|
|
531
|
+
|
|
532
|
+
expect(props.stitchedAdsInitResult).toEqual({ kind: "eventHandler" });
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
test("forwards only SSAI-relevant props when adView and showAds handlers are omitted", () => {
|
|
536
|
+
const { props } = captureAdsProps(() => ({
|
|
537
|
+
command: "stitchedAdsInit",
|
|
538
|
+
contentView: createDomNode("video", { id: 301 }),
|
|
539
|
+
onStitchedAdResult: () => {}
|
|
540
|
+
}));
|
|
541
|
+
|
|
542
|
+
expect(Object.keys(props).sort()).toEqual(["command", "contentView", "stitchedAdResult"]);
|
|
543
|
+
expect(props.command).toEqual({ kind: "string", value: "stitchedAdsInit" });
|
|
544
|
+
expect(props.contentView).toEqual({ kind: "nodeRef", value: "<ref:301>" });
|
|
545
|
+
expect(props.stitchedAdResult).toEqual({ kind: "eventHandler" });
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
test("forwards only provided props", () => {
|
|
550
|
+
const { props } = captureAdsProps(() => ({ command: "showAds" }));
|
|
551
|
+
|
|
552
|
+
expect(Object.keys(props)).toEqual(["command"]);
|
|
553
|
+
expect(props.command).toEqual({ kind: "string", value: "showAds" });
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test("updates command reactively when the prop signal changes", () => {
|
|
557
|
+
const { commandValues, setCommand, commit, dispose } = renderWithCommandRecorder("getAds");
|
|
558
|
+
|
|
559
|
+
commit();
|
|
560
|
+
expect(commandValues).toEqual(["getAds"]);
|
|
561
|
+
|
|
562
|
+
setCommand("showAds");
|
|
563
|
+
commit();
|
|
564
|
+
expect(commandValues).toEqual(["getAds", "showAds"]);
|
|
565
|
+
|
|
566
|
+
setCommand("stitchedAdsInit");
|
|
567
|
+
commit();
|
|
568
|
+
expect(commandValues).toEqual(["getAds", "showAds", "stitchedAdsInit"]);
|
|
569
|
+
|
|
570
|
+
setCommand("constructStitchedStream");
|
|
571
|
+
commit();
|
|
572
|
+
expect(commandValues).toEqual(["getAds", "showAds", "stitchedAdsInit", "constructStitchedStream"]);
|
|
573
|
+
|
|
574
|
+
setCommand("renderStitchedStream");
|
|
575
|
+
commit();
|
|
576
|
+
expect(commandValues).toEqual([
|
|
577
|
+
"getAds",
|
|
578
|
+
"showAds",
|
|
579
|
+
"stitchedAdsInit",
|
|
580
|
+
"constructStitchedStream",
|
|
581
|
+
"renderStitchedStream"
|
|
582
|
+
]);
|
|
583
|
+
|
|
584
|
+
dispose();
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* The repeat is what `csai.tsx` guards by resetting through `"none"`, and
|
|
589
|
+
* until now nothing measured it. The first test is the control: without it,
|
|
590
|
+
* a harness that had lost its reactivity would pass the second one for the
|
|
591
|
+
* wrong reason.
|
|
592
|
+
*/
|
|
593
|
+
describe("a repeated command value", () => {
|
|
594
|
+
test("reaches the node again when the value differs", () => {
|
|
595
|
+
const { commandValues, setCommand, commit, dispose } = renderWithCommandRecorder("showAds");
|
|
596
|
+
|
|
597
|
+
commit();
|
|
598
|
+
setCommand("getAds");
|
|
599
|
+
commit();
|
|
600
|
+
|
|
601
|
+
expect(commandValues).toEqual(["showAds", "getAds"]);
|
|
602
|
+
|
|
603
|
+
dispose();
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
test("is not written a second time", () => {
|
|
607
|
+
const { commandValues, setCommand, commit, dispose } = renderWithCommandRecorder("showAds");
|
|
608
|
+
|
|
609
|
+
commit();
|
|
610
|
+
setCommand("showAds");
|
|
611
|
+
commit();
|
|
612
|
+
|
|
613
|
+
expect(commandValues).toEqual(["showAds"]);
|
|
614
|
+
|
|
615
|
+
dispose();
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
test("lands once it is reset through none", () => {
|
|
619
|
+
const { commandValues, setCommand, commit, dispose } = renderWithCommandRecorder("showAds");
|
|
620
|
+
|
|
621
|
+
commit();
|
|
622
|
+
setCommand("none");
|
|
623
|
+
commit();
|
|
624
|
+
setCommand("showAds");
|
|
625
|
+
commit();
|
|
626
|
+
|
|
627
|
+
expect(commandValues).toEqual(["showAds", "none", "showAds"]);
|
|
628
|
+
|
|
629
|
+
dispose();
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Renders `<Ads>` with a signal-driven `command` and records every value that
|
|
636
|
+
* reaches the underlying node, in order. Ordered writes are what distinguish
|
|
637
|
+
* "written twice" from "written once", which the prop-capture harness above
|
|
638
|
+
* cannot see because it keeps only the last value per prop.
|
|
639
|
+
*/
|
|
640
|
+
function renderWithCommandRecorder(initial: AdsCommand): {
|
|
641
|
+
commandValues: string[];
|
|
642
|
+
setCommand: (value: AdsCommand) => void;
|
|
643
|
+
commit: () => void;
|
|
644
|
+
dispose: () => void;
|
|
645
|
+
} {
|
|
646
|
+
const internForward = new Map<string, object>();
|
|
647
|
+
const internReverse = new Map<object, string>();
|
|
648
|
+
let internRef = 0;
|
|
649
|
+
registerInternStringApi({
|
|
650
|
+
internString: (str: string): object => {
|
|
651
|
+
const existing = internForward.get(str);
|
|
652
|
+
if (existing) return existing;
|
|
653
|
+
const ref = { id: ++internRef };
|
|
654
|
+
internForward.set(str, ref);
|
|
655
|
+
internReverse.set(ref, str);
|
|
656
|
+
return ref;
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
const resolveInterned = (ref: unknown): string => {
|
|
661
|
+
if (ref && typeof ref === "object") {
|
|
662
|
+
const s = internReverse.get(ref as object);
|
|
663
|
+
if (s !== undefined) return s;
|
|
664
|
+
}
|
|
665
|
+
return String(ref);
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
const commandValues: string[] = [];
|
|
669
|
+
const mockSd: StructuredDataApi = {
|
|
670
|
+
createObject: () => ({}),
|
|
671
|
+
createArray: () => [],
|
|
672
|
+
insertBool: () => undefined,
|
|
673
|
+
insertNull: () => undefined,
|
|
674
|
+
insertInt: () => undefined,
|
|
675
|
+
insertFloat: () => undefined,
|
|
676
|
+
insertString: () => undefined,
|
|
677
|
+
insertObject: () => undefined,
|
|
678
|
+
insertArray: () => undefined,
|
|
679
|
+
insertNodeRef: () => undefined,
|
|
680
|
+
appendBool: () => undefined,
|
|
681
|
+
appendNull: () => undefined,
|
|
682
|
+
appendInt: () => undefined,
|
|
683
|
+
appendFloat: () => undefined,
|
|
684
|
+
appendString: () => undefined,
|
|
685
|
+
appendObject: () => undefined,
|
|
686
|
+
appendArray: () => undefined,
|
|
687
|
+
appendNodeRef: () => undefined,
|
|
688
|
+
getArray: () => undefined,
|
|
689
|
+
getObject: () => undefined
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
const mockTm: TreeManipulationApi = {
|
|
693
|
+
createNode: (_anchor, tagRef) => ({ id: 1, tag: resolveInterned(tagRef) }),
|
|
694
|
+
appendNode: () => undefined,
|
|
695
|
+
insertNodeBefore: () => undefined,
|
|
696
|
+
removeNode: () => undefined,
|
|
697
|
+
setNodePropNull: () => undefined,
|
|
698
|
+
setNodePropBool: () => undefined,
|
|
699
|
+
setNodePropInt: () => undefined,
|
|
700
|
+
setNodePropFloat: () => undefined,
|
|
701
|
+
setNodePropString: (_a, _n, p, v) => {
|
|
702
|
+
if (resolveInterned(p) === "command") commandValues.push(v);
|
|
703
|
+
},
|
|
704
|
+
setNodePropObject: () => undefined,
|
|
705
|
+
setNodePropArray: () => undefined,
|
|
706
|
+
setNodePropNodeRef: () => undefined,
|
|
707
|
+
removeNodeProp: () => undefined,
|
|
708
|
+
updateNodeProps: () => undefined,
|
|
709
|
+
removeNodeProps: () => undefined,
|
|
710
|
+
registerFieldChangeHandler: () => undefined,
|
|
711
|
+
unregisterFieldHandler: () => undefined,
|
|
712
|
+
setFocus: () => undefined
|
|
713
|
+
};
|
|
714
|
+
|
|
715
|
+
const { renderer } = makeSolidRenderer(mockTm, mockSd, mockSd);
|
|
716
|
+
const anchorName = { id: 0 };
|
|
717
|
+
|
|
718
|
+
const [command, setCommand] = createSignal<AdsCommand>(initial);
|
|
719
|
+
const anchorNode: DomNode = { r: {}, n: anchorName } as DomNode;
|
|
720
|
+
const dispose = renderer.render((() => <Ads command={command()} />) as unknown as () => DomNode, anchorNode);
|
|
721
|
+
|
|
722
|
+
return {
|
|
723
|
+
commandValues,
|
|
724
|
+
setCommand: (value: AdsCommand) => setCommand(value),
|
|
725
|
+
commit: commitEarly,
|
|
726
|
+
dispose
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
describe("Ads exports", () => {
|
|
731
|
+
test("Ads is a SolidJS component function", () => {
|
|
732
|
+
expect(typeof Ads).toBe("function");
|
|
733
|
+
expect((Ads as Component<IAdsProps>).length).toBe(1);
|
|
734
|
+
});
|
|
735
|
+
});
|