CmcdReportRecorder is a test helper that captures CMCD-bearing HTTP requests emitted by a video player under test. It patches XMLHttpRequest and fetch in the current realm, normalizes captured reports to a single shape regardless of which transport produced them, and exposes ergonomic APIs for both post-hoc assertions and live inspection.
Use it when you need to:
validateCmcdRequest from the same packageThe recorder is shipped as part of @svta/cml-cmcd so adopters get a tested, documented helper alongside the encoder they already depend on.
import { CmcdReportRecorder } from "@svta/cml-cmcd";
const recorder = new CmcdReportRecorder();
recorder.attach();
// ... configure and start the player under test ...
const segments = await recorder.waitForSegments({ count: 3 });
console.log(`Recorded ${segments.length} segment reports with CMCD data`);
recorder.detach();
That's the whole loop: attach() installs the transport patches, the player runs and emits requests, the test asserts on whatever was recorded, detach() restores the original transports.
A request is recorded when it carries CMCD data in any of three forms:
| Reporting mode | Trigger |
|---|---|
'query' |
URL contains the CMCD=... query parameter |
'header' |
Request includes any Cmcd-* header |
'event' |
POST to a URL registered in eventTargetUrls option |
Requests without CMCD data pass through untouched and are not stored.
Each recorded report is classified by URL extension and HTTP method:
| Type | Heuristic |
|---|---|
CmcdRecordedRequestType.MANIFEST |
URL ends in .m3u8 or .mpd |
CmcdRecordedRequestType.SEGMENT |
URL ends in .m4s, .m4v, .m4a, .mp4, .ts, .aac |
CmcdRecordedRequestType.EVENT |
POST to a URL in eventTargetUrls |
CmcdRecordedRequestType.UNKNOWN |
Anything else carrying CMCD data |
Recorded entries have a uniform shape:
type CmcdRecordedReport = {
readonly request: HttpRequest; // normalized; headers lowercase, body as string
readonly type: CmcdRecordedRequestType;
readonly reportingMode: CmcdRecordedReportMode;
readonly timestamp: number; // Date.now() at capture time
};
This is the same shape that validateCmcdRequest from @svta/cml-cmcd accepts, so you can pipe recorded reports straight into validation.
There are three ways to observe recorded reports, suited to different test shapes.
getReports(): snapshotReturns a defensive copy of everything recorded so far. Best for tests that fully drive the player to completion before asserting. Filter the result yourself if you need a subset:
import { CmcdReportRecorder, CmcdRecordedRequestType } from "@svta/cml-cmcd";
const recorder = new CmcdReportRecorder();
recorder.attach();
// ... player runs to completion ...
const all = recorder.getReports();
const manifests = all.filter((r) => r.type === CmcdRecordedRequestType.MANIFEST);
const segments = all.filter((r) => r.type === CmcdRecordedRequestType.SEGMENT);
console.log(`Total: ${all.length}, manifests: ${manifests.length}, segments: ${segments.length}`);
recorder.detach();
waitFor*({ count?, timeout? }): positive assertionResolves once at least count matching reports have been recorded. Rejects with a diagnostic error on timeout. Use for "expect N to happen" assertions where the test is racing the player. There is one method per request type, plus a generic waitForReports that matches any type:
| Method | Matches |
|---|---|
waitForReports(options?) |
any recorded report |
waitForManifest(options?) |
reports with type === MANIFEST |
waitForSegments(options?) |
reports with type === SEGMENT |
waitForEvents(options?) |
reports with type === EVENT (POST) |
Each method takes a single CmcdReportRecorderWaitOptions object with two optional fields. count defaults to 1. timeout defaults to the recorder's waitTimeout option (15 seconds if unset).
import { CmcdReportRecorder } from "@svta/cml-cmcd";
const recorder = new CmcdReportRecorder();
recorder.attach();
// Kick off playback in the background...
startPlayer();
// Wait for the first three segment reports
const segments = await recorder.waitForSegments({ count: 3 });
for (const report of segments) {
console.log(report.request.url, report.reportingMode);
}
recorder.detach();
Shorten the timeout per call or globally on the recorder:
// Per call: fail in 2 seconds rather than the recorder default
await recorder.waitForManifest({ timeout: 2000 });
// All wait* calls on this recorder default to 2 seconds
const fastRecorder = new CmcdReportRecorder();
fastRecorder.attach({ waitTimeout: 2000 });
await fastRecorder.waitForManifest();
onReportFor test harness pages that show a streaming view of CMCD activity, pass an onReport callback to attach(). The callback fires synchronously for each recorded report, immediately after it is appended to the buffer and before any pending waitFor* promises resolve.
import {
CmcdReportRecorder,
type CmcdRecordedReport,
} from "@svta/cml-cmcd";
const tableBody = document.querySelector<HTMLTableSectionElement>("#cmcd-log tbody")!;
const recorder = new CmcdReportRecorder();
recorder.attach({
onReport: (report: CmcdRecordedReport) => {
const row = tableBody.insertRow();
row.insertCell().textContent = new Date(report.timestamp).toISOString();
row.insertCell().textContent = report.type;
row.insertCell().textContent = report.reportingMode;
row.insertCell().textContent = report.request.url;
},
});
// ... player runs; the table updates in real time as reports arrive ...
The callback receives the same object reference that getReports() returns, so a single panel can subscribe to all reports and branch on report.type / report.reportingMode to dispatch into separate UI surfaces:
recorder.attach({
onReport: (report) => {
switch (report.type) {
case CmcdRecordedRequestType.MANIFEST:
manifestPanel.append(report);
break;
case CmcdRecordedRequestType.SEGMENT:
segmentPanel.append(report);
break;
case CmcdRecordedRequestType.EVENT:
eventPanel.append(report);
break;
}
},
});
The listener is bound to the attach lifecycle: it is cleared automatically on detach(). To resume notification after a detach/reattach cycle, pass the callback again on the next attach() call.
CMCD v2 event reports are POSTed to a configured target URL. In tests, you usually don't want those requests to hit a real endpoint. The eventTargetUrls option intercepts matching POSTs and responds with a synthetic 204 No Content, while still recording the request body for inspection.
import { CmcdReportRecorder } from "@svta/cml-cmcd";
const recorder = new CmcdReportRecorder();
recorder.attach({
eventTargetUrls: [
"https://events.example.com",
"https://analytics.example.com",
],
});
startPlayer();
const events = await recorder.waitForEvents();
console.log("Event report body:", events[0].request.body);
recorder.detach();
A request matches if its URL starts with any entry in the list. Non-event POSTs (those whose URLs don't match) are not stubbed and pass through to the underlying transport, but they are still recorded if they carry CMCD data.
CmcdRecordedReport.request is an HttpRequest, which is exactly what validateCmcdRequest and the other validators in this package accept. Pipe one straight into the other:
import {
CmcdReportRecorder,
validateCmcdRequest,
} from "@svta/cml-cmcd";
const recorder = new CmcdReportRecorder();
recorder.attach();
startPlayer();
const segments = await recorder.waitForSegments({ count: 3 });
for (const report of segments) {
const result = validateCmcdRequest(report.request);
if (!result.valid) {
console.error(`Bad CMCD on ${report.request.url}:`, result.issues);
}
}
recorder.detach();
For event-mode reports, use validateCmcdEventReport:
import {
CmcdReportRecorder,
validateCmcdEventReport,
} from "@svta/cml-cmcd";
const recorder = new CmcdReportRecorder();
recorder.attach({ eventTargetUrls: ["https://events.example.com"] });
startPlayer();
const events = await recorder.waitForEvents();
const result = validateCmcdEventReport(events[0].request);
if (!result.valid) {
console.error("Bad event report:", result.issues);
}
recorder.detach();
By default, the recorder patches globalThis.XMLHttpRequest and globalThis.fetch. If the player under test uses a different HTTP client (a custom wrapper, node-fetch, undici, etc.), supply a custom CmcdTransportAdapter via the transports option.
A transport adapter installs its hook, calls the supplied deliver function for every outbound request, and returns a teardown function that uninstalls the hook:
import {
CmcdReportRecorder,
type CmcdTransportAdapter,
type CmcdRequestDeliver,
} from "@svta/cml-cmcd";
import type { HttpRequest } from "@svta/cml-utils";
function createMyClientTransport(): CmcdTransportAdapter {
return {
attach(deliver: CmcdRequestDeliver): () => void {
const original = myClient.send;
myClient.send = (req: MyRequest) => {
const httpRequest: HttpRequest = normalize(req);
const stub = deliver(httpRequest);
if (stub) {
// Event-target match: return the synthetic 204 response
return stub;
}
return original.call(myClient, req);
};
return () => {
myClient.send = original;
};
},
};
}
const recorder = new CmcdReportRecorder();
recorder.attach({
transports: [createMyClientTransport()],
});
The deliver function returns a Response only when the request matched eventTargetUrls; in that case the adapter must short-circuit and use the synthetic response instead of forwarding to the underlying client.
attach() is a no-op when the recorder is already attached, so it is safe to call once per test setup. detach():
XMLHttpRequest/fetch references)onReport listenerwaitFor* promises with Error('Recorder detached while waiting')clear() for thatAlways pair attach() with detach() in your test teardown (afterEach or equivalent). A leaked attached recorder continues to patch XMLHttpRequest/fetch for the rest of the process, which corrupts subsequent tests.
getReports() once after attach() to populate the table, then rely on onReport for incremental updates.onReport. The listener does not accept a type filter directly. Branch on report.type or report.reportingMode inside the callback if you only care about a subset.clear() between test phases. When a single test exercises multiple playback scenarios, call recorder.clear() between phases to reset the buffer without re-attaching.waitTimeout attach option, or pass an explicit timeout to a single waitFor* call to override.report.request.body is always a string (or undefined), never a ReadableStream. Pass it directly to validateCmcdEventReport.