mirror of
https://github.com/video-dev/hls.js.git
synced 2026-06-16 13:34:44 +00:00
Adds `cmcd.reporterCallback` config option, enabling custom key and custom event reporting for CMCD v2.
1382 lines
48 KiB
TypeScript
1382 lines
48 KiB
TypeScript
import { CmcdEventType, CmcdHeaderField, CmcdObjectType } from '@svta/cml-cmcd';
|
||
import { expect } from 'chai';
|
||
import CMCDController from '../../../src/controller/cmcd-controller';
|
||
import { Events } from '../../../src/events';
|
||
import Hls from '../../../src/hls';
|
||
import M3U8Parser from '../../../src/loader/m3u8-parser';
|
||
import { PlaylistLevelType } from '../../../src/types/loader';
|
||
import type { CMCDControllerConfig } from '../../../src/config';
|
||
import type { Fragment, Part } from '../../../src/loader/fragment';
|
||
|
||
let cmcdController;
|
||
|
||
const url = 'https://dummy.url.com/playlist.m3u8';
|
||
const playlist = `#EXTM3U
|
||
#EXT-X-VERSION:7
|
||
#EXT-X-TARGETDURATION:2
|
||
#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=2.171
|
||
#EXT-X-PART-INF:PART-TARGET=1.034
|
||
#EXT-X-MAP:URI="https://dummy.url.com/18446744073709551615.m4s"
|
||
#EXT-X-MEDIA-SEQUENCE:10902
|
||
#EXT-X-PROGRAM-DATE-TIME:2024-05-02T18:03:57.020+00:00
|
||
#EXTINF:2,
|
||
https://dummy.url.com/10902.m4s
|
||
#EXT-X-PROGRAM-DATE-TIME:2024-05-02T18:03:59.020+00:00
|
||
#EXTINF:2,
|
||
https://dummy.url.com/10903.m4s
|
||
#EXT-X-PROGRAM-DATE-TIME:2024-05-02T18:04:01.020+00:00
|
||
#EXT-X-PART:DURATION=1,URI="https://dummy.url.com/10904.0.m4s"
|
||
#EXT-X-PART:DURATION=1,URI="https://dummy.url.com/10904.1.m4s"
|
||
#EXTINF:2,
|
||
https://dummy.url.com/10904.m4s
|
||
#EXT-X-PROGRAM-DATE-TIME:2024-05-02T18:04:03.020+00:00
|
||
#EXT-X-PART:DURATION=1,URI="https://dummy.url.com/10905.0.m4s",INDEPENDENT=YES
|
||
#EXT-X-PART:DURATION=1,URI="https://dummy.url.com/10905.1.m4s"
|
||
#EXTINF:2,
|
||
https://dummy.url.com/10905.m4s
|
||
#EXT-X-PROGRAM-DATE-TIME:2024-05-02T18:04:05.020+00:00
|
||
#EXT-X-PART:DURATION=1,URI="https://dummy.url.com/10906.0.m4s",INDEPENDENT=YES
|
||
#EXT-X-PART:DURATION=1,URI="https://dummy.url.com/10906.1.m4s"
|
||
#EXTINF:2,
|
||
https://dummy.url.com/10906.m4s`;
|
||
|
||
const uuidRegex =
|
||
/[a-f\d]{8}-[a-f\d]{4}-4[a-f\d]{3}-[89ab][a-f\d]{3}-[a-f\d]{12}/;
|
||
|
||
const makeMockMedia = (autoplay: boolean): HTMLMediaElement =>
|
||
({
|
||
autoplay,
|
||
addEventListener: () => {},
|
||
removeEventListener: () => {},
|
||
}) as unknown as HTMLMediaElement;
|
||
|
||
const setupEach = (
|
||
cmcd?: CMCDControllerConfig,
|
||
// Pass `attachBefore` to simulate `hls.attachMedia()` running *before*
|
||
// `hls.loadSource()` — i.e., MEDIA_ATTACHING fires before MANIFEST_LOADING.
|
||
// Omit it for the load-before-attach order (the default in tests).
|
||
attachBefore?: { autoplay: boolean },
|
||
) => {
|
||
const details = M3U8Parser.parseLevelPlaylist(
|
||
playlist,
|
||
url,
|
||
0,
|
||
PlaylistLevelType.MAIN,
|
||
0,
|
||
null,
|
||
);
|
||
|
||
const level = {
|
||
bitrate: 1000,
|
||
details,
|
||
audioCodec: 'mp4a.40.2',
|
||
videoCodec: 'avc1.640028',
|
||
};
|
||
|
||
const hls = new Hls({ cmcd }) as any;
|
||
hls.networkControllers.forEach((component) => component.destroy());
|
||
hls.networkControllers.length = 0;
|
||
hls.coreComponents.forEach((component) => component.destroy());
|
||
hls.coreComponents.length = 0;
|
||
hls.levelController = {
|
||
levels: [level],
|
||
level: 0,
|
||
};
|
||
hls.streamController = {
|
||
getLevelDetails: () => details,
|
||
// Tests that fire MEDIA_ATTACHING will exercise getBufferLength, which
|
||
// calls hls.mainForwardBufferInfo → streamController.getMainFwdBufferInfo.
|
||
// Default to null (no buffer info known); individual tests override.
|
||
getMainFwdBufferInfo: () => null,
|
||
};
|
||
// hls.audioTracks = [];
|
||
|
||
cmcdController = new CMCDController(hls);
|
||
|
||
if (attachBefore) {
|
||
hls.trigger(Events.MEDIA_ATTACHING, {
|
||
media: makeMockMedia(attachBefore.autoplay),
|
||
});
|
||
}
|
||
|
||
hls.trigger(Events.MANIFEST_LOADING, { url });
|
||
|
||
return details;
|
||
};
|
||
|
||
// Trigger MEDIA_ATTACHING after setupEach to simulate `hls.attachMedia()`
|
||
// being called *after* `hls.loadSource()`.
|
||
const attachMedia = (autoplay = false) => {
|
||
cmcdController.hls.trigger(Events.MEDIA_ATTACHING, {
|
||
media: makeMockMedia(autoplay),
|
||
});
|
||
};
|
||
|
||
const base = {
|
||
url,
|
||
headers: undefined,
|
||
};
|
||
|
||
const applyPlaylistData = (data = {}) => {
|
||
const context = Object.assign(data, base);
|
||
cmcdController.applyPlaylistData(context);
|
||
return context;
|
||
};
|
||
|
||
const applyFragmentData = (frag: Fragment, part?: Part | undefined) => {
|
||
const context = Object.assign({ url: frag.url, frag, part });
|
||
cmcdController.applyFragmentData(context);
|
||
return context;
|
||
};
|
||
|
||
const expectField = (result, expected) => {
|
||
const regex = new RegExp(expected);
|
||
expect(regex.test(result)).to.equal(true);
|
||
};
|
||
|
||
describe('CMCDController', function () {
|
||
describe('cmcdController instance', function () {
|
||
describe('configuration', function () {
|
||
it('does not modify requests when disabled', function () {
|
||
setupEach();
|
||
|
||
const { config } = cmcdController.hls;
|
||
expect(config.pLoader).to.equal(undefined);
|
||
expect(config.fLoader).to.equal(undefined);
|
||
});
|
||
|
||
it('uses the session id if provided', function () {
|
||
const sessionId = 'SESSION_ID';
|
||
setupEach({ sessionId });
|
||
|
||
const { url } = applyPlaylistData();
|
||
expectField(url, `sid%3D%22${sessionId}%22`);
|
||
});
|
||
|
||
it('uses the Hls instance session id if not provided', function () {
|
||
setupEach({});
|
||
|
||
const sessionId = cmcdController.hls.sessionId;
|
||
const { url } = applyPlaylistData();
|
||
expectField(url, `sid%3D%22${sessionId}%22`);
|
||
expect(sessionId).to.match(uuidRegex);
|
||
});
|
||
|
||
it('uses the content id if provided', function () {
|
||
const contentId = 'CONTENT_ID';
|
||
setupEach({ contentId });
|
||
|
||
const { url } = applyPlaylistData();
|
||
expectField(url, `cid%3D%22${contentId}%22`);
|
||
});
|
||
|
||
it('uses headers if configured', function () {
|
||
const contentId = 'CONTENT_ID';
|
||
setupEach({ contentId, useHeaders: true });
|
||
|
||
const { url, headers = {} } = applyPlaylistData();
|
||
|
||
expect(url).to.equal(base.url);
|
||
expectField(headers[CmcdHeaderField.SESSION], `cid="${contentId}"`);
|
||
});
|
||
|
||
it('uses includeKeys if configured', function () {
|
||
const contentId = 'CONTENT_ID';
|
||
setupEach({ includeKeys: ['cid'], contentId });
|
||
|
||
const { url } = applyPlaylistData();
|
||
|
||
expect(url).to.equal(`${base.url}?CMCD=cid%3D%22${contentId}%22`);
|
||
});
|
||
|
||
it('uses fragment data', function () {
|
||
const details = setupEach({});
|
||
|
||
const { url } = applyFragmentData(details.fragments[0]);
|
||
|
||
expectField(url, `nor%3D%2210903.m4s%22`);
|
||
expectField(url, `br%3D1`);
|
||
expectField(url, `d%3D2000`);
|
||
expectField(url, `ot%3Dav`);
|
||
});
|
||
|
||
it('uses part data when available', function () {
|
||
const details = setupEach({});
|
||
|
||
const { url } = applyFragmentData(
|
||
details.fragments[2],
|
||
details.partList?.[0],
|
||
);
|
||
|
||
expectField(url, `nor%3D%2210904.1.m4s%22`);
|
||
expectField(url, `br%3D1`);
|
||
expectField(url, `d%3D1000`);
|
||
expectField(url, `ot%3Dav`);
|
||
});
|
||
|
||
it('emits br/tb/bl for single-rendition main fragments where ot is undefined', function () {
|
||
// Regression: single-rendition streams (e.g. muxed-fmp4 or mp3 audio-only with
|
||
// no master playlist) carry no codec metadata on the variant, and the fragment
|
||
// hasn't been parsed by the time the request fires — so getObjectType returns
|
||
// undefined. The br/tb/bl block must still emit, since the buffer for the
|
||
// segment exists regardless of whether ot resolved.
|
||
const details = setupEach({});
|
||
const hls = cmcdController.hls;
|
||
hls.levelController.levels[0].audioCodec = undefined;
|
||
hls.levelController.levels[0].videoCodec = undefined;
|
||
(cmcdController as any).media = {} as unknown as HTMLMediaElement;
|
||
Object.defineProperty(hls, 'mainForwardBufferInfo', {
|
||
configurable: true,
|
||
get: () => ({ len: 8.0 }),
|
||
});
|
||
|
||
const frag = details.fragments[0];
|
||
// Sanity: ot really is undefined for this fragment.
|
||
expect(
|
||
(cmcdController as any).getObjectType(
|
||
frag,
|
||
hls.levelController.levels[0],
|
||
),
|
||
).to.equal(undefined);
|
||
|
||
const { url } = applyFragmentData(frag);
|
||
|
||
// br: level.bitrate / 1000 = 1
|
||
expectField(url, `br%3D1`);
|
||
// tb: top bandwidth from hls.levels (sole level at 1000) = 1
|
||
expectField(url, `tb%3D1`);
|
||
// bl: 8.0s * 1000 = 8000ms
|
||
expectField(url, `bl%3D8000`);
|
||
});
|
||
});
|
||
|
||
describe('v2 configuration', function () {
|
||
it('defaults to version 1', function () {
|
||
setupEach({});
|
||
|
||
const { url } = applyPlaylistData();
|
||
// v=1 is the default and is omitted per CMCD spec
|
||
expect(url).to.not.include('v%3D');
|
||
// st is a v1 key and should be included when available
|
||
expectField(url, `st%3D`);
|
||
// sta is NOT a v1 key and should not be included
|
||
expect(url).to.not.include('sta%3D');
|
||
});
|
||
|
||
it('uses version 2 when configured', function () {
|
||
setupEach({ version: 2 });
|
||
|
||
const { url } = applyPlaylistData();
|
||
expectField(url, `v%3D2`);
|
||
});
|
||
|
||
// The first manifest request's `sta` depends on which of attachMedia
|
||
// and loadSource was called first. The 4 combinations below cover the
|
||
// matrix:
|
||
// attach→load + autoplay=T → STARTING ("s")
|
||
// attach→load + autoplay=F → PRELOADING ("d")
|
||
// load→attach (or no attach) → PRELOADING ("d") via the no-media
|
||
// branch in onManifestLoading; autoplay only affects subsequent
|
||
// requests after attach completes.
|
||
describe('initial sta matrix (attach order × autoplay)', function () {
|
||
it('attach→load, autoplay=true → STARTING on first manifest', function () {
|
||
setupEach({ version: 2 }, { autoplay: true });
|
||
|
||
expect((cmcdController as any).playerState).to.equal('s');
|
||
const { url } = applyPlaylistData();
|
||
expectField(url, `sta%3Ds`);
|
||
});
|
||
|
||
it('attach→load, autoplay=false → PRELOADING on first manifest', function () {
|
||
setupEach({ version: 2 }, { autoplay: false });
|
||
|
||
expect((cmcdController as any).playerState).to.equal('d');
|
||
const { url } = applyPlaylistData();
|
||
expectField(url, `sta%3Dd`);
|
||
});
|
||
|
||
it('load→attach (no media yet at load) → PRELOADING on first manifest', function () {
|
||
setupEach({ version: 2 });
|
||
|
||
// onManifestLoading sets PRELOADING because this.media is undefined
|
||
expect((cmcdController as any).playerState).to.equal('d');
|
||
const { url } = applyPlaylistData();
|
||
expectField(url, `sta%3Dd`);
|
||
});
|
||
|
||
it('load→attach, autoplay=true → first manifest is PRELOADING, then transitions to STARTING', function () {
|
||
setupEach({ version: 2 });
|
||
|
||
const { url: first } = applyPlaylistData();
|
||
expectField(first, `sta%3Dd`);
|
||
|
||
attachMedia(true);
|
||
expect((cmcdController as any).playerState).to.equal('s');
|
||
|
||
const { url: second } = applyPlaylistData();
|
||
expectField(second, `sta%3Ds`);
|
||
});
|
||
|
||
it('load→attach, autoplay=false → stays PRELOADING after attach', function () {
|
||
setupEach({ version: 2 });
|
||
|
||
const { url: first } = applyPlaylistData();
|
||
expectField(first, `sta%3Dd`);
|
||
|
||
attachMedia(false);
|
||
expect((cmcdController as any).playerState).to.equal('d');
|
||
|
||
const { url: second } = applyPlaylistData();
|
||
expectField(second, `sta%3Dd`);
|
||
});
|
||
});
|
||
|
||
it('includes stream type (st) for v2 when level details are available', function () {
|
||
setupEach({ version: 2 });
|
||
// The test playlist has EXT-X-SERVER-CONTROL which makes it live with LL features
|
||
// but details.live defaults to true from parsing
|
||
|
||
const { url } = applyPlaylistData();
|
||
// Should include st field (live since playlist has no ENDLIST)
|
||
expectField(url, `st%3D`);
|
||
});
|
||
|
||
it('detects VOD stream type', function () {
|
||
const details = setupEach({ version: 2 });
|
||
// Mark level details as VOD
|
||
details.live = false;
|
||
|
||
const { url } = applyPlaylistData();
|
||
// VOD = "v"
|
||
expectField(url, `st%3Dv`);
|
||
});
|
||
|
||
it('detects low-latency stream type', function () {
|
||
const details = setupEach({ version: 2 });
|
||
details.live = true;
|
||
details.canBlockReload = true;
|
||
|
||
const { url } = applyPlaylistData();
|
||
// LOW_LATENCY = "ll"
|
||
expectField(url, `st%3Dll`);
|
||
});
|
||
|
||
it('detects live stream type', function () {
|
||
const details = setupEach({ version: 2 });
|
||
details.live = true;
|
||
details.canBlockReload = false;
|
||
details.canSkipUntil = 0;
|
||
|
||
const { url } = applyPlaylistData();
|
||
// LIVE = "l" (negative lookahead to avoid matching "ll")
|
||
expectField(url, `st%3Dl(?!l)`);
|
||
});
|
||
|
||
it('includes v2 fields in fragment data', function () {
|
||
const details = setupEach({ version: 2 });
|
||
attachMedia(false);
|
||
details.live = false;
|
||
|
||
const { url } = applyFragmentData(details.fragments[0]);
|
||
// Should contain v2 version, stream type, and player state
|
||
expectField(url, `v%3D2`);
|
||
expectField(url, `st%3Dv`);
|
||
expectField(url, `sta%3Dd`);
|
||
// Standard fragment fields still present (inner list format in v2)
|
||
expectField(url, `br%3D%281%29`);
|
||
expectField(url, `ot%3Dav`);
|
||
});
|
||
|
||
it('includes v2 fields in headers mode', function () {
|
||
const details = setupEach({ version: 2, useHeaders: true });
|
||
attachMedia(false);
|
||
details.live = false;
|
||
|
||
const { url, headers = {} } = applyPlaylistData();
|
||
expect(url).to.equal(base.url);
|
||
// v2 fields should appear in appropriate CMCD headers
|
||
const allHeaders = Object.values(headers).join(',');
|
||
expect(allHeaders).to.include('v=2');
|
||
expect(allHeaders).to.include('st=v');
|
||
expect(allHeaders).to.include('sta=d');
|
||
});
|
||
});
|
||
|
||
describe('v2 event reporting', function () {
|
||
it('creates reporter without eventTargets (no event reporting)', function () {
|
||
setupEach({ version: 2 });
|
||
expect((cmcdController as any).reporter).to.not.equal(undefined);
|
||
});
|
||
|
||
it('creates reporter for v1 (without event targets)', function () {
|
||
setupEach({
|
||
version: 1,
|
||
eventTargets: [{ url: 'https://analytics.example.com/cmcd' }],
|
||
});
|
||
expect((cmcdController as any).reporter).to.not.equal(undefined);
|
||
});
|
||
|
||
it('creates reporter with v2 and eventTargets', function () {
|
||
setupEach({
|
||
version: 2,
|
||
eventTargets: [{ url: 'https://analytics.example.com/cmcd' }],
|
||
});
|
||
expect((cmcdController as any).reporter).to.not.equal(undefined);
|
||
});
|
||
|
||
it('emits a single TIME_INTERVAL event at session start (no duplicate sn=0)', function () {
|
||
const requests: any[] = [];
|
||
const captureLoader = (req: any) => {
|
||
requests.push(req);
|
||
return Promise.resolve({ status: 204 });
|
||
};
|
||
setupEach({
|
||
version: 2,
|
||
loader: captureLoader as any,
|
||
eventTargets: [
|
||
{
|
||
url: 'https://analytics.example.com/cmcd',
|
||
events: [CmcdEventType.TIME_INTERVAL],
|
||
interval: 30,
|
||
},
|
||
],
|
||
});
|
||
|
||
const tEvents = requests
|
||
.flatMap((r) =>
|
||
String(r.body || '')
|
||
.trim()
|
||
.split('\n'),
|
||
)
|
||
.filter((line) => /(^|,)e=t(,|$)/.test(line));
|
||
expect(tEvents).to.have.lengthOf(1);
|
||
expect(tEvents[0]).to.match(/(^|,)sn=0(,|$)/);
|
||
});
|
||
|
||
it('does not create the reporter in the constructor (deferred to MANIFEST_LOADING)', function () {
|
||
const hls = new Hls({ cmcd: { version: 2 } }) as any;
|
||
hls.networkControllers.forEach((c) => c.destroy());
|
||
hls.networkControllers.length = 0;
|
||
hls.coreComponents.forEach((c) => c.destroy());
|
||
hls.coreComponents.length = 0;
|
||
|
||
const controller = new CMCDController(hls);
|
||
expect((controller as any).reporter).to.equal(undefined);
|
||
});
|
||
|
||
it('creates a fresh reporter on each MANIFEST_LOADING (one per session)', function () {
|
||
const requests: any[] = [];
|
||
const captureLoader = (req: any) => {
|
||
requests.push(req);
|
||
return Promise.resolve({ status: 204 });
|
||
};
|
||
setupEach({
|
||
version: 2,
|
||
loader: captureLoader as any,
|
||
eventTargets: [
|
||
{
|
||
url: 'https://analytics.example.com/cmcd',
|
||
events: [CmcdEventType.TIME_INTERVAL],
|
||
interval: 30,
|
||
},
|
||
],
|
||
});
|
||
|
||
const reporter1 = (cmcdController as any).reporter;
|
||
expect(reporter1).to.not.equal(undefined);
|
||
|
||
// Simulate a second loadSource on the same Hls instance.
|
||
cmcdController.hls.trigger(Events.MANIFEST_LOADING, { url });
|
||
|
||
const reporter2 = (cmcdController as any).reporter;
|
||
expect(reporter2).to.not.equal(undefined);
|
||
expect(reporter2).to.not.equal(reporter1);
|
||
|
||
const tEvents = requests
|
||
.flatMap((r) =>
|
||
String(r.body || '')
|
||
.trim()
|
||
.split('\n'),
|
||
)
|
||
.filter((line) => /(^|,)e=t(,|$)/.test(line));
|
||
expect(tEvents).to.have.lengthOf(2);
|
||
});
|
||
|
||
it('stops reporter on destroy', function () {
|
||
setupEach({
|
||
version: 2,
|
||
eventTargets: [{ url: 'https://analytics.example.com/cmcd' }],
|
||
});
|
||
const reporter = (cmcdController as any).reporter;
|
||
const stopCalls: any[][] = [];
|
||
const origStop = reporter.stop.bind(reporter);
|
||
reporter.stop = (...args: any[]) => {
|
||
stopCalls.push(args);
|
||
return origStop(...args);
|
||
};
|
||
|
||
cmcdController.destroy();
|
||
|
||
expect(stopCalls).to.have.lengthOf(1);
|
||
expect(stopCalls[0][0]).to.equal(true);
|
||
expect((cmcdController as any).reporter).to.equal(undefined);
|
||
});
|
||
|
||
it('records play state events on state transitions', function () {
|
||
setupEach({
|
||
version: 2,
|
||
eventTargets: [{ url: 'https://analytics.example.com/cmcd' }],
|
||
});
|
||
|
||
// Simulate playing event via the arrow function
|
||
(cmcdController as any).onPlaying();
|
||
|
||
// Player state should transition to PLAYING
|
||
expect((cmcdController as any).playerState).to.equal('p');
|
||
|
||
// Verify sta=p appears in subsequent CMCD data
|
||
const { url } = applyPlaylistData();
|
||
expectField(url, `sta%3Dp`);
|
||
|
||
cmcdController.destroy();
|
||
});
|
||
|
||
it('records error events on fatal errors', function () {
|
||
const requests: any[] = [];
|
||
const captureLoader = (req: any) => {
|
||
requests.push(req);
|
||
return Promise.resolve({ status: 204 });
|
||
};
|
||
setupEach({
|
||
version: 2,
|
||
loader: captureLoader as any,
|
||
eventTargets: [
|
||
{
|
||
url: 'https://analytics.example.com/cmcd',
|
||
events: [CmcdEventType.ERROR],
|
||
},
|
||
],
|
||
});
|
||
|
||
// Trigger fatal error via hls event
|
||
(cmcdController as any).hls.trigger(Events.ERROR, {
|
||
type: 'networkError',
|
||
details: 'fragLoadError',
|
||
fatal: true,
|
||
error: new Error('test'),
|
||
});
|
||
|
||
// Player state should transition to FATAL_ERROR
|
||
expect((cmcdController as any).playerState).to.equal('f');
|
||
|
||
const eventRequests = requests.filter(
|
||
(r) => r.url === 'https://analytics.example.com/cmcd',
|
||
);
|
||
const body = String(eventRequests[0].body || '');
|
||
expect(eventRequests.length).to.be.greaterThan(0);
|
||
expect(body).to.include('e=e');
|
||
expect(body).to.include('ec=("fragLoadError")');
|
||
expect(body).to.include('ts=');
|
||
|
||
cmcdController.destroy();
|
||
});
|
||
|
||
it('resolves SEEKING to PAUSED via onSeeked when paused', function () {
|
||
setupEach({ version: 2 });
|
||
|
||
(cmcdController as any).media = {
|
||
paused: true,
|
||
removeEventListener: () => {},
|
||
} as unknown as HTMLMediaElement;
|
||
(cmcdController as any).initialized = true;
|
||
(cmcdController as any).onSeeking();
|
||
expect((cmcdController as any).playerState).to.equal('k');
|
||
|
||
(cmcdController as any).onSeeked();
|
||
expect((cmcdController as any).playerState).to.equal('a');
|
||
|
||
cmcdController.destroy();
|
||
});
|
||
|
||
it('keeps SEEKING via onSeeked when not paused', function () {
|
||
setupEach({ version: 2 });
|
||
|
||
(cmcdController as any).media = {
|
||
paused: false,
|
||
removeEventListener: () => {},
|
||
} as unknown as HTMLMediaElement;
|
||
(cmcdController as any).initialized = true;
|
||
(cmcdController as any).onSeeking();
|
||
expect((cmcdController as any).playerState).to.equal('k');
|
||
|
||
(cmcdController as any).onSeeked();
|
||
expect((cmcdController as any).playerState).to.equal('k');
|
||
|
||
cmcdController.destroy();
|
||
});
|
||
|
||
it('stays in PRELOADING when onSeeking fires before play()', function () {
|
||
// Per CTA-5004-B: SEEKING is for playhead moves "after starting".
|
||
// Before play() the player is PRELOADING; seeks during preload
|
||
// should not transition out of it.
|
||
setupEach({ version: 2 });
|
||
attachMedia(false);
|
||
|
||
(cmcdController as any).media = {
|
||
paused: true,
|
||
removeEventListener: () => {},
|
||
} as unknown as HTMLMediaElement;
|
||
expect((cmcdController as any).playerState).to.equal('d');
|
||
|
||
(cmcdController as any).onSeeking();
|
||
expect((cmcdController as any).playerState).to.equal('d');
|
||
|
||
(cmcdController as any).onSeeked();
|
||
expect((cmcdController as any).playerState).to.equal('d');
|
||
|
||
cmcdController.destroy();
|
||
});
|
||
|
||
it('transitions PRELOADING to STARTING on first play()', function () {
|
||
setupEach({ version: 2 });
|
||
attachMedia(false);
|
||
expect((cmcdController as any).playerState).to.equal('d');
|
||
|
||
(cmcdController as any).onPlay();
|
||
expect((cmcdController as any).playerState).to.equal('s');
|
||
|
||
cmcdController.destroy();
|
||
});
|
||
|
||
it('does not re-enter STARTING on subsequent play() after PLAYING', function () {
|
||
setupEach({ version: 2 });
|
||
|
||
(cmcdController as any).onPlay();
|
||
(cmcdController as any).onPlaying();
|
||
expect((cmcdController as any).playerState).to.equal('p');
|
||
|
||
// After pause/play cycle, 'play' fires again but we stay on
|
||
// the prior state until 'playing' resolves to PLAYING.
|
||
(cmcdController as any).media = {
|
||
paused: true,
|
||
ended: false,
|
||
removeEventListener: () => {},
|
||
} as unknown as HTMLMediaElement;
|
||
(cmcdController as any).onPause();
|
||
expect((cmcdController as any).playerState).to.equal('a');
|
||
|
||
(cmcdController as any).onPlay();
|
||
// initialized is now true; onPlay must not transition back to STARTING.
|
||
expect((cmcdController as any).playerState).to.equal('a');
|
||
|
||
cmcdController.destroy();
|
||
});
|
||
|
||
it('does not record duplicate play state events', function () {
|
||
setupEach({
|
||
version: 2,
|
||
eventTargets: [{ url: 'https://analytics.example.com/cmcd' }],
|
||
});
|
||
const reporter = (cmcdController as any).reporter;
|
||
const recordCalls: any[][] = [];
|
||
const origRecord = reporter.recordEvent.bind(reporter);
|
||
reporter.recordEvent = (...args: any[]) => {
|
||
recordCalls.push(args);
|
||
return origRecord(...args);
|
||
};
|
||
|
||
// Call onPlaying twice — second call should be deduplicated
|
||
(cmcdController as any).onPlaying();
|
||
(cmcdController as any).onPlaying();
|
||
|
||
// Only one recordEvent call for the state change (deduplicated)
|
||
expect(recordCalls).to.have.lengthOf(1);
|
||
|
||
cmcdController.destroy();
|
||
});
|
||
});
|
||
|
||
describe('getObjectType', function () {
|
||
it('returns MUXED for main fragments when variant has both audio and video codecs', function () {
|
||
const details = setupEach({});
|
||
const frag = details.fragments[0];
|
||
const variant = { audioCodec: 'mp4a.40.2', videoCodec: 'avc1.640028' };
|
||
const result = (cmcdController as any).getObjectType(frag, variant);
|
||
expect(result).to.equal(CmcdObjectType.MUXED);
|
||
});
|
||
|
||
it('returns VIDEO for main fragments when variant has only a video codec', function () {
|
||
const details = setupEach({});
|
||
const frag = details.fragments[0];
|
||
const variant = { videoCodec: 'avc1.640028' };
|
||
const result = (cmcdController as any).getObjectType(frag, variant);
|
||
expect(result).to.equal(CmcdObjectType.VIDEO);
|
||
});
|
||
|
||
it('returns AUDIO for main fragments when variant has only an audio codec (audio-only main playlist)', function () {
|
||
const details = setupEach({});
|
||
const frag = details.fragments[0];
|
||
const variant = { audioCodec: 'mp4a.40.2' };
|
||
const result = (cmcdController as any).getObjectType(frag, variant);
|
||
expect(result).to.equal(CmcdObjectType.AUDIO);
|
||
});
|
||
|
||
it('falls back to fragment.elementaryStreams.audiovideo when variant codecs are absent', function () {
|
||
const details = setupEach({});
|
||
const frag = details.fragments[0];
|
||
frag.elementaryStreams.audiovideo = {
|
||
startPTS: 0,
|
||
endPTS: 2,
|
||
startDTS: 0,
|
||
endDTS: 2,
|
||
};
|
||
const result = (cmcdController as any).getObjectType(frag);
|
||
expect(result).to.equal(CmcdObjectType.MUXED);
|
||
});
|
||
|
||
it('falls back to fragment.elementaryStreams.video when only video stream present', function () {
|
||
const details = setupEach({});
|
||
const frag = details.fragments[0];
|
||
frag.elementaryStreams.video = {
|
||
startPTS: 0,
|
||
endPTS: 2,
|
||
startDTS: 0,
|
||
endDTS: 2,
|
||
};
|
||
const result = (cmcdController as any).getObjectType(frag);
|
||
expect(result).to.equal(CmcdObjectType.VIDEO);
|
||
});
|
||
|
||
it('falls back to fragment.elementaryStreams.audio when only audio stream present', function () {
|
||
const details = setupEach({});
|
||
const frag = details.fragments[0];
|
||
frag.elementaryStreams.audio = {
|
||
startPTS: 0,
|
||
endPTS: 2,
|
||
startDTS: 0,
|
||
endDTS: 2,
|
||
};
|
||
const result = (cmcdController as any).getObjectType(frag);
|
||
expect(result).to.equal(CmcdObjectType.AUDIO);
|
||
});
|
||
|
||
it('returns undefined for main fragments when neither variant codecs nor elementary streams are known', function () {
|
||
const details = setupEach({});
|
||
const frag = details.fragments[0];
|
||
// No variant, no elementary streams populated
|
||
const result = (cmcdController as any).getObjectType(frag);
|
||
expect(result).to.equal(undefined);
|
||
});
|
||
|
||
it('does not infer MUXED from hls.audioTracks.length === 0 alone', function () {
|
||
// Regression: previous logic returned MUXED whenever there were no alt audio renditions,
|
||
// misclassifying video-only variants.
|
||
const details = setupEach({});
|
||
const frag = details.fragments[0];
|
||
// hls.audioTracks is empty by default, mimicking a video-only main variant.
|
||
const variant = { videoCodec: 'avc1.640028' };
|
||
const result = (cmcdController as any).getObjectType(frag, variant);
|
||
expect(result).to.equal(CmcdObjectType.VIDEO);
|
||
});
|
||
|
||
it('returns VIDEO (not MUXED) when alt audio renditions exist and variant has both codecs', function () {
|
||
// STREAM-INF CODECS describes the variant plus its alternate renditions, so audioCodec
|
||
// here belongs to the alt audio track, not the main variant.
|
||
const details = setupEach({});
|
||
const frag = details.fragments[0];
|
||
Object.defineProperty(cmcdController.hls, 'audioTracks', {
|
||
configurable: true,
|
||
get: () => [{ bitrate: 128000 }],
|
||
});
|
||
const variant = { audioCodec: 'mp4a.40.2', videoCodec: 'avc1.640028' };
|
||
const result = (cmcdController as any).getObjectType(frag, variant);
|
||
expect(result).to.equal(CmcdObjectType.VIDEO);
|
||
});
|
||
|
||
it('returns undefined when alt audio renditions exist and variant has only an audio codec', function () {
|
||
// Same reasoning: audioCodec alone is not enough to call the main variant audio-only
|
||
// when alternate audio renditions are present.
|
||
const details = setupEach({});
|
||
const frag = details.fragments[0];
|
||
Object.defineProperty(cmcdController.hls, 'audioTracks', {
|
||
configurable: true,
|
||
get: () => [{ bitrate: 128000 }],
|
||
});
|
||
const variant = { audioCodec: 'mp4a.40.2' };
|
||
const result = (cmcdController as any).getObjectType(frag, variant);
|
||
expect(result).to.equal(undefined);
|
||
});
|
||
|
||
it('prefers parsed elementary streams over variant codecs', function () {
|
||
// Elementary streams reflect what was actually parsed; trust them over the variant hint.
|
||
const details = setupEach({});
|
||
const frag = details.fragments[0];
|
||
frag.elementaryStreams.video = {
|
||
startPTS: 0,
|
||
endPTS: 2,
|
||
startDTS: 0,
|
||
endDTS: 2,
|
||
};
|
||
const variant = { audioCodec: 'mp4a.40.2', videoCodec: 'avc1.640028' };
|
||
const result = (cmcdController as any).getObjectType(frag, variant);
|
||
expect(result).to.equal(CmcdObjectType.VIDEO);
|
||
});
|
||
|
||
it('uses the fragment loader call site to derive ot from the active level', function () {
|
||
// Audio-only main playlist: level carries only an audioCodec.
|
||
// applyFragmentData should pass the level into getObjectType and produce ot=a (not ot=av).
|
||
const details = setupEach({});
|
||
const hls = cmcdController.hls;
|
||
hls.levelController.levels[0].audioCodec = 'mp4a.40.2';
|
||
hls.levelController.levels[0].videoCodec = undefined;
|
||
|
||
const { url } = applyFragmentData(details.fragments[0]);
|
||
// ot=a but not ot=av (negative lookahead, since 'a' is a prefix of 'av').
|
||
expect(url).to.match(/ot%3Da(?!v)/);
|
||
expect(url).not.to.include('ot%3Dav');
|
||
});
|
||
|
||
it('derives ot from LevelSwitchingData in the BITRATE_CHANGE event payload', function () {
|
||
// Video-only variant: only videoCodec set. Must resolve to VIDEO, not MUXED.
|
||
const details = setupEach({
|
||
version: 2,
|
||
eventTargets: [{ url: 'https://x' }],
|
||
});
|
||
const reporter = (cmcdController as any).reporter;
|
||
const recordCalls: any[][] = [];
|
||
reporter.recordEvent = (...args: any[]) => {
|
||
recordCalls.push(args);
|
||
};
|
||
|
||
(cmcdController as any).hls.trigger(Events.LEVEL_SWITCHING, {
|
||
level: 0,
|
||
bitrate: 2000000,
|
||
details,
|
||
videoCodec: 'avc1.640028',
|
||
});
|
||
|
||
const bitrateCalls = recordCalls.filter((c) => c[0] === 'bc');
|
||
expect(bitrateCalls).to.have.lengthOf(1);
|
||
expect(bitrateCalls[0][1]?.ot).to.equal(CmcdObjectType.VIDEO);
|
||
});
|
||
});
|
||
|
||
describe('getBufferLength', function () {
|
||
const stubBufferInfo = (
|
||
hls: any,
|
||
prop: 'mainForwardBufferInfo' | 'audioForwardBufferInfo',
|
||
info: { len: number } | null,
|
||
) => {
|
||
Object.defineProperty(hls, prop, {
|
||
configurable: true,
|
||
get: () => info,
|
||
});
|
||
};
|
||
|
||
const fragWithType = (type: PlaylistLevelType): any => ({ type });
|
||
|
||
it('uses hls.audioForwardBufferInfo for fragments with type=audio', function () {
|
||
setupEach({});
|
||
const hls = cmcdController.hls;
|
||
(cmcdController as any).media = {
|
||
removeEventListener: () => {},
|
||
} as unknown as HTMLMediaElement;
|
||
stubBufferInfo(hls, 'audioForwardBufferInfo', { len: 12.5 });
|
||
// Set main to a sentinel that would fail the assertion if used by mistake.
|
||
stubBufferInfo(hls, 'mainForwardBufferInfo', { len: 999 });
|
||
|
||
const result = (cmcdController as any).getBufferLength(
|
||
fragWithType(PlaylistLevelType.AUDIO),
|
||
);
|
||
expect(result).to.equal(12500);
|
||
});
|
||
|
||
it('returns NaN for type=audio when audioForwardBufferInfo is null', function () {
|
||
setupEach({});
|
||
const hls = cmcdController.hls;
|
||
(cmcdController as any).media = {
|
||
removeEventListener: () => {},
|
||
} as unknown as HTMLMediaElement;
|
||
stubBufferInfo(hls, 'audioForwardBufferInfo', null);
|
||
stubBufferInfo(hls, 'mainForwardBufferInfo', { len: 999 });
|
||
|
||
const result = (cmcdController as any).getBufferLength(
|
||
fragWithType(PlaylistLevelType.AUDIO),
|
||
);
|
||
expect(Number.isNaN(result)).to.equal(true);
|
||
});
|
||
|
||
it('uses hls.mainForwardBufferInfo for fragments with type=main', function () {
|
||
setupEach({});
|
||
const hls = cmcdController.hls;
|
||
(cmcdController as any).media = {
|
||
removeEventListener: () => {},
|
||
} as unknown as HTMLMediaElement;
|
||
stubBufferInfo(hls, 'mainForwardBufferInfo', { len: 8.0 });
|
||
stubBufferInfo(hls, 'audioForwardBufferInfo', { len: 999 });
|
||
|
||
expect(
|
||
(cmcdController as any).getBufferLength(
|
||
fragWithType(PlaylistLevelType.MAIN),
|
||
),
|
||
).to.equal(8000);
|
||
});
|
||
|
||
it('uses hls.mainForwardBufferInfo for audio-only main playlists (type=main, ot=audio)', function () {
|
||
// Regression: previous logic switched on CmcdObjectType, which would have read
|
||
// audioForwardBufferInfo for an audio-only main playlist even though the buffer
|
||
// lives on the main source buffer.
|
||
setupEach({});
|
||
const hls = cmcdController.hls;
|
||
(cmcdController as any).media = {
|
||
removeEventListener: () => {},
|
||
} as unknown as HTMLMediaElement;
|
||
stubBufferInfo(hls, 'mainForwardBufferInfo', { len: 8.0 });
|
||
stubBufferInfo(hls, 'audioForwardBufferInfo', { len: 999 });
|
||
|
||
expect(
|
||
(cmcdController as any).getBufferLength(
|
||
fragWithType(PlaylistLevelType.MAIN),
|
||
),
|
||
).to.equal(8000);
|
||
});
|
||
|
||
it('returns NaN when no media is attached', function () {
|
||
setupEach({});
|
||
const hls = cmcdController.hls;
|
||
(cmcdController as any).media = undefined;
|
||
stubBufferInfo(hls, 'audioForwardBufferInfo', { len: 12.5 });
|
||
stubBufferInfo(hls, 'mainForwardBufferInfo', { len: 8.0 });
|
||
|
||
expect(
|
||
Number.isNaN(
|
||
(cmcdController as any).getBufferLength(
|
||
fragWithType(PlaylistLevelType.AUDIO),
|
||
),
|
||
),
|
||
).to.equal(true);
|
||
expect(
|
||
Number.isNaN(
|
||
(cmcdController as any).getBufferLength(
|
||
fragWithType(PlaylistLevelType.MAIN),
|
||
),
|
||
),
|
||
).to.equal(true);
|
||
});
|
||
});
|
||
|
||
describe('event bl propagation', function () {
|
||
const stubBufferInfo = (
|
||
hls: any,
|
||
prop: 'mainForwardBufferInfo' | 'audioForwardBufferInfo',
|
||
info: { len: number } | null,
|
||
) => {
|
||
Object.defineProperty(hls, prop, {
|
||
configurable: true,
|
||
get: () => info,
|
||
});
|
||
};
|
||
|
||
it('updates persistent bl on BUFFER_APPENDED', function () {
|
||
setupEach({ version: 2 });
|
||
const hls = cmcdController.hls;
|
||
(cmcdController as any).media = {
|
||
removeEventListener: () => {},
|
||
} as unknown as HTMLMediaElement;
|
||
stubBufferInfo(hls, 'mainForwardBufferInfo', { len: 10 });
|
||
stubBufferInfo(hls, 'audioForwardBufferInfo', null);
|
||
|
||
const reporter = (cmcdController as any).reporter;
|
||
expect(reporter.data.bl).to.equal(undefined);
|
||
|
||
hls.trigger(Events.BUFFER_APPENDED, {
|
||
type: 'video',
|
||
frag: null,
|
||
part: null,
|
||
chunkMeta: {},
|
||
parent: 'main',
|
||
timeRanges: {},
|
||
});
|
||
|
||
expect(reporter.data.bl).to.deep.equal([10000]);
|
||
|
||
cmcdController.destroy();
|
||
});
|
||
|
||
it('updates persistent bl on BUFFER_FLUSHED (covers seek-to-empty)', function () {
|
||
setupEach({ version: 2 });
|
||
const hls = cmcdController.hls;
|
||
(cmcdController as any).media = {
|
||
removeEventListener: () => {},
|
||
} as unknown as HTMLMediaElement;
|
||
stubBufferInfo(hls, 'mainForwardBufferInfo', { len: 0 });
|
||
stubBufferInfo(hls, 'audioForwardBufferInfo', null);
|
||
|
||
const reporter = (cmcdController as any).reporter;
|
||
hls.trigger(Events.BUFFER_FLUSHED, {
|
||
type: 'video',
|
||
start: 0,
|
||
end: 0,
|
||
});
|
||
|
||
expect(reporter.data.bl).to.deep.equal([0]);
|
||
|
||
cmcdController.destroy();
|
||
});
|
||
|
||
it('uses min(main, audio) when both buffer sources are available', function () {
|
||
setupEach({ version: 2 });
|
||
const hls = cmcdController.hls;
|
||
(cmcdController as any).media = {
|
||
removeEventListener: () => {},
|
||
} as unknown as HTMLMediaElement;
|
||
stubBufferInfo(hls, 'mainForwardBufferInfo', { len: 10 });
|
||
stubBufferInfo(hls, 'audioForwardBufferInfo', { len: 7 });
|
||
|
||
const reporter = (cmcdController as any).reporter;
|
||
hls.trigger(Events.BUFFER_APPENDED, {
|
||
type: 'audio',
|
||
frag: null,
|
||
part: null,
|
||
chunkMeta: {},
|
||
parent: 'main',
|
||
timeRanges: {},
|
||
});
|
||
|
||
expect(reporter.data.bl).to.deep.equal([7000]);
|
||
|
||
cmcdController.destroy();
|
||
});
|
||
|
||
it('does not update bl when no media is attached', function () {
|
||
setupEach({ version: 2 });
|
||
const hls = cmcdController.hls;
|
||
(cmcdController as any).media = undefined;
|
||
stubBufferInfo(hls, 'mainForwardBufferInfo', { len: 10 });
|
||
stubBufferInfo(hls, 'audioForwardBufferInfo', null);
|
||
|
||
const reporter = (cmcdController as any).reporter;
|
||
hls.trigger(Events.BUFFER_APPENDED, {
|
||
type: 'video',
|
||
frag: null,
|
||
part: null,
|
||
chunkMeta: {},
|
||
parent: 'main',
|
||
timeRanges: {},
|
||
});
|
||
|
||
expect(reporter.data.bl).to.equal(undefined);
|
||
|
||
cmcdController.destroy();
|
||
});
|
||
|
||
it('does not update bl when both buffer info sources are null', function () {
|
||
setupEach({ version: 2 });
|
||
const hls = cmcdController.hls;
|
||
(cmcdController as any).media = {
|
||
removeEventListener: () => {},
|
||
} as unknown as HTMLMediaElement;
|
||
stubBufferInfo(hls, 'mainForwardBufferInfo', null);
|
||
stubBufferInfo(hls, 'audioForwardBufferInfo', null);
|
||
|
||
const reporter = (cmcdController as any).reporter;
|
||
hls.trigger(Events.BUFFER_APPENDED, {
|
||
type: 'video',
|
||
frag: null,
|
||
part: null,
|
||
chunkMeta: {},
|
||
parent: 'main',
|
||
timeRanges: {},
|
||
});
|
||
|
||
expect(reporter.data.bl).to.equal(undefined);
|
||
|
||
cmcdController.destroy();
|
||
});
|
||
|
||
it('emits bl in queued event payloads after buffer info is known', function () {
|
||
setupEach({
|
||
version: 2,
|
||
eventTargets: [
|
||
{
|
||
url: 'https://analytics.example.com/cmcd',
|
||
events: [CmcdEventType.PLAY_STATE],
|
||
batchSize: 100,
|
||
},
|
||
],
|
||
});
|
||
const hls = cmcdController.hls;
|
||
(cmcdController as any).media = {
|
||
removeEventListener: () => {},
|
||
} as unknown as HTMLMediaElement;
|
||
stubBufferInfo(hls, 'mainForwardBufferInfo', { len: 10 });
|
||
stubBufferInfo(hls, 'audioForwardBufferInfo', null);
|
||
|
||
hls.trigger(Events.BUFFER_APPENDED, {
|
||
type: 'video',
|
||
frag: null,
|
||
part: null,
|
||
chunkMeta: {},
|
||
parent: 'main',
|
||
timeRanges: {},
|
||
});
|
||
|
||
(cmcdController as any).onPlaying();
|
||
|
||
const reporter = (cmcdController as any).reporter;
|
||
const targetStates = Array.from(
|
||
reporter.eventTargets.values(),
|
||
) as any[];
|
||
const queue = targetStates[0].queue as any[];
|
||
// The PLAY_STATE event for the PLAYING transition (sta=p) should
|
||
// carry the bl we populated above. Earlier PRELOADING transitions
|
||
// queued by onManifestLoading do not have bl.
|
||
const playingEvents = queue.filter(
|
||
(e: any) => e.e === CmcdEventType.PLAY_STATE && e.sta === 'p',
|
||
);
|
||
expect(playingEvents.length).to.be.greaterThan(0);
|
||
expect(playingEvents[0].bl).to.deep.equal([10000]);
|
||
|
||
cmcdController.destroy();
|
||
});
|
||
});
|
||
|
||
describe('getTopBandwidth', function () {
|
||
const fragWithType = (type: PlaylistLevelType): any => ({ type });
|
||
|
||
const stubAudioTracks = (hls: any, tracks: any[]) => {
|
||
Object.defineProperty(hls, 'audioTracks', {
|
||
configurable: true,
|
||
get: () => tracks,
|
||
});
|
||
};
|
||
|
||
it('uses hls.audioTracks for fragments with type=audio', function () {
|
||
setupEach({});
|
||
const hls = cmcdController.hls;
|
||
stubAudioTracks(hls, [{ bitrate: 96000 }, { bitrate: 128000 }]);
|
||
// hls.levels has the test playlist's level at bitrate 1000; should NOT be used here.
|
||
const result = (cmcdController as any).getTopBandwidth(
|
||
fragWithType(PlaylistLevelType.AUDIO),
|
||
);
|
||
expect(result).to.equal(128000);
|
||
});
|
||
|
||
it('uses hls.levels for fragments with type=main (including audio-only main playlists)', function () {
|
||
// Regression: previous logic switched on CmcdObjectType, which would have read
|
||
// audioTracks for an audio-only main playlist even though the renditions live in hls.levels.
|
||
setupEach({});
|
||
const hls = cmcdController.hls;
|
||
hls.levelController.levels = [
|
||
{ bitrate: 500000 },
|
||
{ bitrate: 2000000 },
|
||
];
|
||
stubAudioTracks(hls, [{ bitrate: 999999 }]);
|
||
const result = (cmcdController as any).getTopBandwidth(
|
||
fragWithType(PlaylistLevelType.MAIN),
|
||
);
|
||
expect(result).to.equal(2000000);
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('reporterCallback', function () {
|
||
it('reporterCallback is called with a CmcdCustomReporter facade on MANIFEST_LOADING', function () {
|
||
const reporters: any[] = [];
|
||
setupEach({ version: 2, reporterCallback: (r) => reporters.push(r) });
|
||
expect(reporters).to.have.lengthOf(1);
|
||
expect(reporters[0])
|
||
.to.have.property('updateCustomData')
|
||
.that.is.a('function');
|
||
expect(reporters[0])
|
||
.to.have.property('recordCustomEvent')
|
||
.that.is.a('function');
|
||
});
|
||
|
||
it('reporterCallback is called again on each MANIFEST_LOADING (new facade per session)', function () {
|
||
const reporters: any[] = [];
|
||
setupEach({ version: 2, reporterCallback: (r) => reporters.push(r) });
|
||
cmcdController.hls.trigger(Events.MANIFEST_LOADING, { url });
|
||
expect(reporters).to.have.lengthOf(2);
|
||
expect(reporters[0]).to.not.equal(reporters[1]);
|
||
});
|
||
|
||
it('reporterCallback is not called before MANIFEST_LOADING', function () {
|
||
const reporters: any[] = [];
|
||
const hls = new Hls({
|
||
cmcd: { version: 2, reporterCallback: (r) => reporters.push(r) },
|
||
}) as any;
|
||
hls.networkControllers.forEach((c) => c.destroy());
|
||
hls.networkControllers.length = 0;
|
||
hls.coreComponents.forEach((c) => c.destroy());
|
||
hls.coreComponents.length = 0;
|
||
new CMCDController(hls);
|
||
expect(reporters).to.have.lengthOf(0);
|
||
});
|
||
|
||
it('recordCustomEvent fires CUSTOM_EVENT to the event target', function () {
|
||
const requests: any[] = [];
|
||
let reporter: any;
|
||
setupEach({
|
||
version: 2,
|
||
loader: ((req: any) => {
|
||
requests.push(req);
|
||
return Promise.resolve({ status: 204 });
|
||
}) as any,
|
||
eventTargets: [
|
||
{
|
||
url: 'https://analytics.example.com/cmcd',
|
||
events: [CmcdEventType.CUSTOM_EVENT],
|
||
},
|
||
],
|
||
reporterCallback: (r) => {
|
||
reporter = r;
|
||
},
|
||
});
|
||
|
||
reporter.recordCustomEvent('test-event');
|
||
|
||
const eventRequests = requests.filter(
|
||
(r) => r.url === 'https://analytics.example.com/cmcd',
|
||
);
|
||
expect(eventRequests.length).to.be.greaterThan(0);
|
||
expect(String(eventRequests[0].body || '')).to.include(
|
||
'cen="test-event"',
|
||
);
|
||
});
|
||
|
||
it('updateCustomData() in reporterCallback seeds custom keys in request reports', function () {
|
||
setupEach({
|
||
includeKeys: ['sid', 'sf', 'com.acme-label'] as any,
|
||
reporterCallback: (r) => {
|
||
r.updateCustomData({ 'com.acme-label': 'hello' } as any);
|
||
},
|
||
});
|
||
expectField(applyPlaylistData().url, 'com.acme-label%3D%22hello%22');
|
||
});
|
||
|
||
it('updateCustomData() at runtime takes effect on subsequent request reports', function () {
|
||
let reporter: any;
|
||
setupEach({
|
||
includeKeys: ['sid', 'sf', 'com.acme-label'] as any,
|
||
reporterCallback: (r) => {
|
||
reporter = r;
|
||
r.updateCustomData({ 'com.acme-label': 'cold' } as any);
|
||
},
|
||
});
|
||
expectField(applyPlaylistData().url, 'com.acme-label%3D%22cold%22');
|
||
|
||
reporter.updateCustomData({ 'com.acme-label': 'warm' } as any);
|
||
expectField(applyPlaylistData().url, 'com.acme-label%3D%22warm%22');
|
||
});
|
||
|
||
it('updateCustomData() in reporterCallback seeds custom keys in event reports', function () {
|
||
const requests: any[] = [];
|
||
let reporter: any;
|
||
setupEach({
|
||
version: 2,
|
||
loader: ((req: any) => {
|
||
requests.push(req);
|
||
return Promise.resolve({ status: 204 });
|
||
}) as any,
|
||
eventTargets: [
|
||
{
|
||
url: 'https://analytics.example.com/cmcd',
|
||
events: [CmcdEventType.CUSTOM_EVENT],
|
||
includeKeys: ['cen', 'com.myco-chapter'] as any,
|
||
},
|
||
],
|
||
reporterCallback: (r) => {
|
||
reporter = r;
|
||
r.updateCustomData({ 'com.myco-chapter': 'intro' } as any);
|
||
},
|
||
});
|
||
|
||
reporter.recordCustomEvent('test');
|
||
|
||
const body = String(
|
||
requests.filter(
|
||
(r) => r.url === 'https://analytics.example.com/cmcd',
|
||
)[0]?.body || '',
|
||
);
|
||
expect(body).to.include('com.myco-chapter="intro"');
|
||
});
|
||
|
||
it('updateCustomData() at runtime takes effect on subsequent event reports', function () {
|
||
const requests: any[] = [];
|
||
let reporter: any;
|
||
setupEach({
|
||
version: 2,
|
||
loader: ((req: any) => {
|
||
requests.push(req);
|
||
return Promise.resolve({ status: 204 });
|
||
}) as any,
|
||
eventTargets: [
|
||
{
|
||
url: 'https://analytics.example.com/cmcd',
|
||
events: [CmcdEventType.CUSTOM_EVENT],
|
||
includeKeys: ['cen', 'com.myco-chapter'] as any,
|
||
},
|
||
],
|
||
reporterCallback: (r) => {
|
||
reporter = r;
|
||
r.updateCustomData({ 'com.myco-chapter': 'intro' } as any);
|
||
},
|
||
});
|
||
|
||
reporter.recordCustomEvent('test');
|
||
const firstBody = String(
|
||
requests.filter(
|
||
(r) => r.url === 'https://analytics.example.com/cmcd',
|
||
)[0]?.body || '',
|
||
);
|
||
expect(firstBody).to.include('com.myco-chapter="intro"');
|
||
|
||
reporter.updateCustomData({ 'com.myco-chapter': 'chapter-2' } as any);
|
||
requests.length = 0;
|
||
|
||
reporter.recordCustomEvent('test');
|
||
const secondBody = String(
|
||
requests.filter(
|
||
(r) => r.url === 'https://analytics.example.com/cmcd',
|
||
)[0]?.body || '',
|
||
);
|
||
expect(secondBody).to.include('com.myco-chapter="chapter-2"');
|
||
});
|
||
|
||
it('recordCustomEvent routes by events array: fires to matching target, not to non-matching target', function () {
|
||
const reqA: any[] = [];
|
||
const reqB: any[] = [];
|
||
let reporter: any;
|
||
setupEach({
|
||
version: 2,
|
||
loader: ((req: any) => {
|
||
if (req.url === 'https://custom-events.example.com') reqA.push(req);
|
||
if (req.url === 'https://bitrate-events.example.com') reqB.push(req);
|
||
return Promise.resolve({ status: 204 });
|
||
}) as any,
|
||
eventTargets: [
|
||
{
|
||
url: 'https://custom-events.example.com',
|
||
events: [CmcdEventType.CUSTOM_EVENT],
|
||
includeKeys: ['cen'] as any,
|
||
},
|
||
{
|
||
url: 'https://bitrate-events.example.com',
|
||
events: [CmcdEventType.BITRATE_CHANGE],
|
||
},
|
||
],
|
||
reporterCallback: (r) => {
|
||
reporter = r;
|
||
},
|
||
});
|
||
|
||
reporter.recordCustomEvent('chapter-change');
|
||
|
||
expect(reqA.length).to.be.greaterThan(0);
|
||
expect(String(reqA[0].body || '')).to.include('cen="chapter-change"');
|
||
expect(reqB.length).to.equal(0);
|
||
});
|
||
});
|
||
});
|