;
+
+ const writeBlob = (column: string, value: string) => {
+ const slot = AE_COLUMNS[column];
+ if (!slot) throw new Error(`toAePoint: no AE column for "${column}"`);
+ blobs[slotOffset(slot)] = value;
+ };
+ const checkAllowed = (column: string, value: string) => {
+ const allowed = def.values?.[column] ?? CLOSED_ATTRIBUTE_SETS[column];
+ if (allowed && !allowed.includes(value)) {
+ throw new Error(
+ `toAePoint: "${value}" is not an allowed "${column}" for metric "${metric}" ` +
+ `(allowed: ${allowed.join(", ")})`,
+ );
+ }
+ };
+
+ // blob1–3: universal resource attrs, on every record.
+ for (const column of ["service_name", "service_version", "environment"] as const) {
+ const value = attrBag[column];
+ if (value === undefined) continue;
+ checkAllowed(column, value);
+ writeBlob(column, value);
+ }
+
+ // `outcome`/`reason` set for a metric that does not list them: a caller bug,
+ // not silently dropped input.
+ for (const column of ["outcome", "reason"] as const) {
+ if (attrBag[column] !== undefined && !def.blobs.includes(column)) {
+ throw new Error(`toAePoint: metric "${metric}" has no "${column}" slot`);
+ }
+ }
+
+ for (const column of def.blobs) {
+ const value = attrBag[column];
+ if (value === undefined) continue;
+ checkAllowed(column, value);
+ writeBlob(column, value);
+ }
+
+ // double1 = count: universal, "1 per point unless pre-aggregated" (§4's
+ // reading rule — `SUM(_sample_interval * double1)` is how every count is
+ // read, so a point that never sets it reads back as zero regardless of how
+ // many really happened). This holds for every metric, not only the ones
+ // whose own §5 row happens to list "count" among its Doubles — the
+ // double-side analogue of blob1–3 being universal.
+ doubles[slotOffset(AE_COLUMNS["count"] as string)] = valueBag["count"] ?? 1;
+
+ for (const column of def.doubles) {
+ if (column === "count") continue; // handled above, universally
+ const value = valueBag[column];
+ if (value === undefined) continue;
+ const slot = AE_COLUMNS[column];
+ if (!slot) throw new Error(`toAePoint: no AE column for "${column}"`);
+ doubles[slotOffset(slot)] = value;
+ }
+
+ return { indexes: [metric], blobs, doubles };
+}
diff --git a/runner/packages/runtime/src/telemetry/scrub.ts b/runner/packages/runtime/src/telemetry/scrub.ts
new file mode 100644
index 0000000000..9557e03693
--- /dev/null
+++ b/runner/packages/runtime/src/telemetry/scrub.ts
@@ -0,0 +1,314 @@
+// Observability contract §3 / ADR §E.4 — the one scrubber, run in the browser
+// (Faro's `beforeSend`) and authoritatively again at ingest, on the normalised
+// OTLP record (ADR §B.2 step 1). Structural types only — no `@grafana/faro-core`
+// import, so this module stays DOM/Cloudflare-free and importable from
+// `pipeline/` under plain Node. Typechecked against the real
+// `@grafana/faro-web-sdk` types with a temporary probe.
+
+import { redactPreviewHosts, type MonitorKind } from "../monitor.js";
+import { stripCodeFrame } from "./fingerprint.js";
+import { browserOf, deviceOf } from "./classify.js";
+import { ALLOWED_ATTRIBUTE_KEYS } from "./attrs.js";
+import { INBOX_RECORD_MAX_BYTES } from "./inbox.js";
+
+// ---- Structural mirrors of the Faro shapes we scrub ---------------------
+// Match `@grafana/faro-core`'s `TransportItem`/`Meta` closely enough that
+// a real Faro item satisfies them structurally, without importing the
+// package (verified against the real `@grafana/faro-web-sdk` types with a
+// temporary probe). No `[key: string]: unknown` index signature: a real
+// `TransportItem` carries none, and TS requires the source type to match.
+// `type` is `string`, not `TransportItemType`'s literal union: TS does not
+// consider an enum member assignable to an unrelated literal union.
+export interface ScrubbableFaroStackFrame {
+ filename?: string;
+ /** ADR §C.3 drain-time symbolication: the real `ExceptionStackFrame`
+ * already carries these at runtime; `structuredClone` copies them
+ * through unchanged, only the type never declared them. `function` is a
+ * JS identifier, never a URL — no new redaction rule needed. */
+ function?: string;
+ lineno?: number;
+ colno?: number;
+}
+
+export interface ScrubbableFaroPayload {
+ /** `LogEvent.message` */
+ message?: string;
+ /** `ExceptionEvent.value` */
+ value?: string;
+ /** `ExceptionEvent.type` (the error class name, e.g. `TypeError`) */
+ type?: string;
+ /** `EventEvent.name` */
+ name?: string;
+ /** `MeasurementEvent.values` */
+ values?: Record;
+ /** ISO 8601 — every Faro event shape carries its own `timestamp`. */
+ timestamp?: string;
+ /** `ExceptionEvent.stacktrace` */
+ stacktrace?: { frames?: ScrubbableFaroStackFrame[] };
+ /** `LogEvent.context` / `ExceptionEvent.context` / `MeasurementEvent.context` */
+ context?: Record;
+ /** `EventEvent.attributes` */
+ attributes?: Record;
+}
+
+export interface ScrubbableFaroMeta {
+ user?: unknown;
+ page?: { url?: string };
+ /** Faro's raw `userAgent` in; `reduceBrowserMeta` replaces the whole object
+ * with just `browser`/`device` on the way out (§3, "reduce any browser
+ * meta to the device and browser classes"). */
+ browser?: { userAgent?: string; browser?: string; device?: string };
+ /** Faro's `app` config — `name`/`version`/`environment` are exactly `service.name`
+ * / `service.version` / `deployment.environment.name` (§3) under Faro's own
+ * naming, set once at `initTelemetry()`. */
+ app?: { name?: string; version?: string; environment?: string };
+ os?: unknown;
+ device?: unknown;
+}
+
+export interface ScrubbableFaroItem {
+ /** `TransportItemType`'s runtime values (`"exception"`, `"log"`,
+ * `"measurement"`, `"trace"`, `"event"`), typed `string` rather than that
+ * literal union — see the file header. */
+ type: string;
+ payload: ScrubbableFaroPayload;
+ meta: ScrubbableFaroMeta;
+}
+
+/** A normalised OTLP log record (§8), or near enough — the ingest-time shape
+ * produced by `convert.ts` before it is packed into the inbox. */
+export interface ScrubbableOtlpRecord {
+ body?: string;
+ attributes?: Record;
+ resourceAttributes?: Record;
+}
+
+export type Scrubbable = ScrubbableFaroItem | ScrubbableOtlpRecord;
+
+function isFaroItem(record: Scrubbable): record is ScrubbableFaroItem {
+ return (
+ typeof (record as ScrubbableFaroItem).type === "string" &&
+ typeof (record as ScrubbableFaroItem).payload === "object" &&
+ (record as ScrubbableFaroItem).payload !== null &&
+ typeof (record as ScrubbableFaroItem).meta === "object" &&
+ (record as ScrubbableFaroItem).meta !== null
+ );
+}
+
+/** §3: strip the query string and fragment off a URL-valued field. Absolute
+ * URLs are parsed properly; anything else (a bare path, or not a URL at all)
+ * falls back to cutting at the first `?`/`#`, so a malformed value from
+ * untrusted input degrades safely instead of throwing. */
+export function stripQueryAndFragment(value: string): string {
+ try {
+ const url = new URL(value);
+ url.search = "";
+ url.hash = "";
+ return url.toString();
+ } catch {
+ const cut = value.search(/[?#]/);
+ return cut === -1 ? value : value.slice(0, cut);
+ }
+}
+
+/** Faro's console instrumentation is disabled (ADR §E.4), but a demo-runtime
+ * `console-error`/`console-warn` relay (`monitor.ts`) can surface as a Faro
+ * log item, tagged via `context["hot.relay"]`. §3 forbids console output
+ * outright, so a matching item is dropped, not scrubbed. */
+const CONSOLE_KINDS: ReadonlySet = new Set(["console-error", "console-warn"]);
+
+function isConsoleItem(item: ScrubbableFaroItem): boolean {
+ if (item.type !== "log") return false;
+ const kind = item.payload.context?.["hot.relay"];
+ return typeof kind === "string" && CONSOLE_KINDS.has(kind as MonitorKind);
+}
+
+/** §3: "reduce any browser meta to the device and browser classes" —
+ * replaces the rich `MetaBrowser`/`MetaOS`/`MetaDevice` objects with the
+ * same two coarse classes `classify.ts` computes for anonymous analytics. */
+function reduceBrowserMeta(meta: ScrubbableFaroMeta): void {
+ const ua = meta.browser?.userAgent;
+ if (ua !== undefined || meta.browser !== undefined || meta.os !== undefined || meta.device !== undefined) {
+ meta.browser = { browser: browserOf(ua ?? ""), device: deviceOf(ua ?? "") };
+ }
+ delete meta.os;
+ delete meta.device;
+}
+
+function allowlistAttributes(attrs: Record | undefined): Record | undefined {
+ if (!attrs) return attrs;
+ const out: Record = {};
+ for (const [key, value] of Object.entries(attrs)) {
+ if (ALLOWED_ATTRIBUTE_KEYS.has(key)) out[key] = value;
+ }
+ return out;
+}
+
+/** §3: strips a query/fragment off a URL *embedded* inside a message/value
+ * string (unlike `stripQueryAndFragment`, which only handles a field that
+ * IS a URL). Exported so `text-scrub.ts` runs the same rule server-side.
+ * Matches after `redactPreviewHosts` (`scrubText` runs this last), so the
+ * pattern optionally consumes the `` placeholder first. */
+const EMBEDDED_URL_PATTERN = /\bhttps?:\/\/(?:)?[^\s"'<>)]*/gi;
+
+export function stripUrlQueriesInText(text: string): string {
+ return text.replace(EMBEDDED_URL_PATTERN, (url) => {
+ const cut = url.search(/[?#]/);
+ return cut === -1 ? url : url.slice(0, cut);
+ });
+}
+
+/**
+ * Contract §3's "never sent" list includes "an IP" — applied browser-side
+ * too, exported for `text-scrub.ts`'s server-side pass. Bounded quantifiers
+ * throughout: no ReDoS backtrack regardless of input shape
+ * (`pipeline/o11y-redos.test.mjs` pins the timing).
+ *
+ * The START boundary is a capturing alternation, never a lookbehind:
+ * `new RegExp` with `(? `${prefix}`)
+ .replace(IPV6_PATTERN, (_match, prefix: string) => `${prefix}`);
+}
+
+/**
+ * ReDoS defense-in-depth: bound a free-text string to this length BEFORE any
+ * scrub/redact regex in this module ever sees it, so an unidentified pattern
+ * still has a bounded worst case.
+ *
+ * Set to {@link INBOX_RECORD_MAX_BYTES} (contract §8's 256 KB drop limit),
+ * not a smaller number: `pipeline/o11y-normalise.test.mjs`'s 300 KB-message
+ * oversize tests rely on the untruncated length surviving scrub far enough
+ * that the record-level size check (which runs AFTER scrubbing) still
+ * measures over the limit.
+ */
+export const SCRUB_TEXT_MAX_CHARS = INBOX_RECORD_MAX_BYTES;
+
+export function truncateForScrub(value: string): string {
+ return value.length > SCRUB_TEXT_MAX_CHARS ? value.slice(0, SCRUB_TEXT_MAX_CHARS) : value;
+}
+
+function scrubText(value: string | undefined): string | undefined {
+ if (value === undefined) return value;
+ return redactIpInText(stripUrlQueriesInText(stripCodeFrame(redactPreviewHosts(truncateForScrub(value)))));
+}
+
+/**
+ * §3: "`redactPreviewHosts` on every string" — not only the fields the
+ * targeted rules above cover. A preview URL is a session credential, so it
+ * must never survive in an allowlisted attribute or resource attribute
+ * (`hot.framework` becomes a Loki label). Walks every string leaf in place;
+ * idempotent, so it can safely run last, after every targeted rule.
+ */
+function redactStringsDeep(value: V): V {
+ if (typeof value === "string") return redactPreviewHosts(truncateForScrub(value)) as V;
+ if (Array.isArray(value)) {
+ for (let i = 0; i < value.length; i++) value[i] = redactStringsDeep(value[i]);
+ return value;
+ }
+ if (value !== null && typeof value === "object") {
+ const obj = value as Record;
+ // Keys too, not only values: a `MeasurementEvent.values` object's keys
+ // are metric names, JSON.stringify'd straight into the OTLP body
+ // (`convert.ts#faroBody`) without ever passing back through a value
+ // position this walk would otherwise reach.
+ for (const key of Object.keys(obj)) {
+ const redactedKey = redactPreviewHosts(truncateForScrub(key));
+ const redactedValue = redactStringsDeep(obj[key]);
+ if (redactedKey !== key) delete obj[key];
+ obj[redactedKey] = redactedValue;
+ }
+ return value;
+ }
+ return value;
+}
+
+/**
+ * The one scrubber (ADR §E.4). Never mutates its argument; returns a scrubbed
+ * clone, or `null` when the whole record must be dropped (a console item).
+ *
+ * Applies, in this order: drop console items; drop `meta.user`; reduce browser
+ * meta to device/browser classes; strip query/fragment and redact preview hosts
+ * on the page URL and every stack-frame filename; redact preview hosts and strip
+ * Babel code frames from message-bearing text; allowlist `attributes` /
+ * `resourceAttributes` / `context` (§3's forbidden attributes — `url.full`, geo,
+ * ASN, the user pseudonym, an email, an IP, a user-agent string — are simply
+ * never on the allowlist); finally, `redactPreviewHosts` on every
+ * remaining string in the record, not only the fields named above — an
+ * allowlisted attribute value (`session.id`, `hot.framework`) is still
+ * client-supplied and can carry a preview host too.
+ */
+export function scrubTelemetry(record: T): T | null {
+ const clone = structuredClone(record) as T;
+
+ if (isFaroItem(clone)) {
+ if (isConsoleItem(clone)) return null;
+
+ delete clone.meta.user;
+ reduceBrowserMeta(clone.meta);
+
+ if (clone.meta.page?.url !== undefined) {
+ clone.meta.page.url = redactPreviewHosts(stripQueryAndFragment(clone.meta.page.url));
+ }
+
+ for (const frame of clone.payload.stacktrace?.frames ?? []) {
+ // An untrusted client can send a `null`/non-object entry inside
+ // `stacktrace.frames` (`{"stacktrace": {"frames":[null]}}` is valid
+ // JSON) — `frame.filename` on a `null` would throw a `TypeError` that
+ // escapes as an uncaught `500`, contradicting this module's own
+ // "never a 500" contract. Skipped, not scrubbed: there is nothing in
+ // a non-object frame to redact.
+ if (!frame || typeof frame !== "object") continue;
+ // A stack frame's `filename` is a URL-valued field too (a bundler's
+ // cache-busting `?t=`/`?v=` query string shows up here as often as on
+ // `meta.page.url`), so it gets the same two rules.
+ if (frame.filename !== undefined) {
+ frame.filename = redactPreviewHosts(stripQueryAndFragment(frame.filename));
+ }
+ }
+
+ clone.payload.message = scrubText(clone.payload.message);
+ clone.payload.value = scrubText(clone.payload.value);
+ clone.payload.context = allowlistAttributes(clone.payload.context);
+ clone.payload.attributes = allowlistAttributes(clone.payload.attributes);
+
+ redactStringsDeep(clone.payload);
+ redactStringsDeep(clone.meta);
+ return clone;
+ }
+
+ const otlp = clone as ScrubbableOtlpRecord;
+ otlp.body = scrubText(otlp.body);
+ otlp.attributes = allowlistAttributes(otlp.attributes);
+ otlp.resourceAttributes = allowlistAttributes(otlp.resourceAttributes);
+ redactStringsDeep(otlp);
+ return clone;
+}
diff --git a/runner/packages/runtime/src/telemetry/sink.ts b/runner/packages/runtime/src/telemetry/sink.ts
new file mode 100644
index 0000000000..5855a8333f
--- /dev/null
+++ b/runner/packages/runtime/src/telemetry/sink.ts
@@ -0,0 +1,124 @@
+// One write surface for an `AePoint` (`metrics.ts#toAePoint`), so the o11y worker,
+// the API worker and a local dev/test run all call the same shape. Structural
+// only: `AnalyticsEngineDatasetLike` mirrors the real Workers binding without
+// importing `@cloudflare/workers-types` (this package stays Cloudflare-free).
+
+import type { AePoint } from "./metrics.js";
+
+export interface AeSink {
+ /** Mirrors the real `AnalyticsEngineDataset#writeDataPoint` signature: normally
+ * fire-and-forget (`void`), but `clickhouseSink`'s HTTP write returns a promise
+ * a caller that cares about local-dev delivery may await. */
+ writeDataPoint(point: AePoint): void | Promise;
+}
+
+/** Structural mirror of Cloudflare's `AnalyticsEngineDataset` binding. */
+export interface AnalyticsEngineDatasetLike {
+ writeDataPoint(point: { indexes: string[]; blobs?: string[]; doubles?: number[] }): void;
+}
+
+/** Production sink: the real Analytics Engine binding (§4, `RUNNER_EVENTS`). */
+export function bindingSink(dataset: AnalyticsEngineDatasetLike): AeSink {
+ return {
+ writeDataPoint(point: AePoint): void {
+ dataset.writeDataPoint(point);
+ },
+ };
+}
+
+/**
+ * `date` as raw epoch milliseconds — the value to send a `DateTime64(3)`
+ * column over JSONEachRow (see `clickhouseSink`'s doc comment for the
+ * measured reasoning). Exported for
+ * `pipeline/telemetry-sink.test.mjs`.
+ */
+export function clickhouseTimestamp(date: Date): number {
+ return date.getTime();
+}
+
+export interface ClickhouseSinkOptions {
+ /** Table name — `runner_events` (§10) by default. */
+ table?: string;
+ /** Injectable for tests; defaults to the global `fetch`. */
+ fetchImpl?: typeof fetch;
+ /** ClickHouse HTTP user (`X-ClickHouse-User`). `"default"` if omitted, the
+ * same default `containers/o11y/compose.yml` uses. */
+ user?: string;
+ /** ClickHouse HTTP password (`X-ClickHouse-Key`) — the local container
+ * always requires one (`CLICKHOUSE_PASSWORD`, defaulting to
+ * `local-dev-token` from `AE_SQL_TOKEN`); the API worker passes
+ * `env.AE_SQL_TOKEN`.
+ * Measured: with no credentials sent at all, the local
+ * container answers the insert with a non-2xx auth error, which a version
+ * of this sink that only checked "did `fetch` throw" swallowed —
+ * `writeDataPoint` resolved, `SELECT count()` on the table read `0`. Omit
+ * only against a ClickHouse that genuinely has no auth configured. */
+ password?: string;
+}
+
+/**
+ * Local-mode sink (§10): ClickHouse at `http://localhost:8123`, table
+ * `runner_events` with the §4 columns plus `timestamp`/`_sample_interval`
+ * (DDL in `containers/o11y/local/clickhouse-init.sql`).
+ *
+ * `timestamp` is sent as a raw epoch-millisecond integer
+ * (`clickhouseTimestamp`), not a formatted string: a `DateTime64(3)` column
+ * reads a plain integer as milliseconds (not seconds), and a formatted
+ * string is parsed in the SERVER's configured timezone — measured 9 hours
+ * off under a `session_timezone` override. A unit-less epoch-ms integer is
+ * immune to both.
+ *
+ * Authenticates and rejects on a non-2xx response (measured: no
+ * credentials gets `403` from the local container); credentials are sent
+ * whenever `options.user`/`.password` are given.
+ */
+export function clickhouseSink(url: string, options: ClickhouseSinkOptions = {}): AeSink {
+ const table = options.table ?? "runner_events";
+ const doFetch = options.fetchImpl ?? fetch;
+ const headers: Record = { "Content-Type": "application/json" };
+ if (options.user !== undefined) headers["X-ClickHouse-User"] = options.user;
+ if (options.password !== undefined) headers["X-ClickHouse-Key"] = options.password;
+
+ return {
+ writeDataPoint(point: AePoint): Promise {
+ const row: Record = {
+ timestamp: clickhouseTimestamp(new Date()),
+ _sample_interval: 1,
+ index1: point.indexes[0] ?? "",
+ };
+ point.blobs.forEach((value, i) => {
+ row[`blob${i + 1}`] = value;
+ });
+ point.doubles.forEach((value, i) => {
+ row[`double${i + 1}`] = value;
+ });
+ const endpoint = `${url.replace(/\/$/, "")}/?query=${encodeURIComponent(
+ `INSERT INTO ${table} FORMAT JSONEachRow`,
+ )}`;
+ return doFetch(endpoint, { method: "POST", headers, body: `${JSON.stringify(row)}\n` }).then(
+ async (res: { ok: boolean; status: number; text?: () => Promise }) => {
+ if (!res.ok) {
+ const body = (await res.text?.()) ?? "";
+ throw new Error(`clickhouseSink: insert failed, ${res.status}: ${body.slice(0, 200)}`);
+ }
+ },
+ );
+ },
+ };
+}
+
+export interface MemorySink extends AeSink {
+ /** Every point written so far, in write order. Tests read this directly. */
+ readonly points: AePoint[];
+}
+
+/** Test sink: collects every point, writes nothing anywhere. */
+export function memorySink(): MemorySink {
+ const points: AePoint[] = [];
+ return {
+ points,
+ writeDataPoint(point: AePoint): void {
+ points.push(point);
+ },
+ };
+}
diff --git a/runner/packages/runtime/src/transpile.ts b/runner/packages/runtime/src/transpile.ts
index 68216c0a2e..28cb36f1f1 100644
--- a/runner/packages/runtime/src/transpile.ts
+++ b/runner/packages/runtime/src/transpile.ts
@@ -114,6 +114,29 @@ export function isCompilerUnavailable(e: unknown): boolean {
return e instanceof Error && (e as { compilerUnavailable?: boolean }).compilerUnavailable === true;
}
+/**
+ * The visitor's own source failed to parse: babel threw inside
+ * `transpileFilesForParcel`. This is the parcel Tier-1 compile error — the bundler never
+ * sees these sources, so it is the only place the failure exists as an error object.
+ *
+ * A plain `Error` marked after construction, not a subclass or a factory, and deliberately
+ * so. The same error reaches Sentry as the `cause` of the constant-titled
+ * `Tier1CompileError` on the mount path (`tier1Report`), and the linked-errors integration
+ * serialises its `name`, message and stack: a subclass would rename it or add a
+ * constructor frame, a factory would add its own frame, and this classification must not
+ * move a byte of what Sentry receives. The marker (not `instanceof`) also matches
+ * `CompilerUnavailableError`'s cross-bundle reasoning above.
+ */
+function markTranspileFailure(error: Error): void {
+ (error as { transpileFailed?: boolean }).transpileFailed = true;
+}
+
+/** Whether an error is `transpileFilesForParcel`'s own parse failure (see `markTranspileFailure`)
+ * — never the compiler chunk failing to load, which is `isCompilerUnavailable`. */
+export function isTranspileFailure(e: unknown): boolean {
+ return e instanceof Error && (e as { transpileFailed?: boolean }).transpileFailed === true;
+}
+
/**
* Retry a failed chunk load once — against a *different URL* — then stop asking
* (DEV-2569).
@@ -376,7 +399,10 @@ export async function transpileFilesForParcel(files: FilesMap): Promise;
writeFile(path: string, contents: string, opts?: WriteFileOptions): void;
@@ -145,6 +196,19 @@ export interface DemoRuntime {
reload?(): Promise | void;
onReady(cb: () => void): void;
onError(cb: (e: Error) => void): void;
+ /** §5 `sandpack.compile_ms` (`SandpackRuntime` only). */
+ onCompileTiming?(cb: (e: SandpackCompileTimingEvent) => void): void;
+ /** §5 `sandpack.compile_error` (`SandpackRuntime` only). */
+ onCompileError?(cb: (e: SandpackCompileErrorEvent) => void): void;
+ /** §5 `sandpack.bundler_unreachable` (`SandpackRuntime` only). */
+ onBundlerUnreachable?(cb: (e: SandpackBundlerUnreachableEvent) => void): void;
+ /** The newest push's outcome (`SandpackRuntime` only): `rerun` when the bundler starts
+ * running a new sandbox, `unchanged` when it matched the running one and nothing re-runs. */
+ onPushOutcome?(cb: (outcome: "rerun" | "unchanged") => void): void;
+ /** §5 `session.start_ms` (`ContainerRuntime` only). */
+ onSessionStart?(cb: (e: SessionStartTimingEvent) => void): void;
+ /** §5 `hmr.roundtrip_ms` (`ContainerRuntime` only). */
+ onHmr?(cb: (e: HmrRoundtripEvent) => void): void;
dispose(): void;
}
diff --git a/runner/pipeline/api-cors.test.mjs b/runner/pipeline/api-cors.test.mjs
new file mode 100644
index 0000000000..b47609a68e
--- /dev/null
+++ b/runner/pipeline/api-cors.test.mjs
@@ -0,0 +1,45 @@
+// `index.ts#cors()`'s `Access-Control-Allow-Headers` list
+// (`Content-Type, Authorization`) must include `x-hot-session`, the header
+// `apps/authoring/src/telemetry/index.ts#apiHeaders()` sets on nearly every
+// fetch call site (ADR-0041 contract §6's session join). Production and
+// the vite dev proxy are both same-origin, so no browser ever preflights
+// this — a cross-origin local dev setup (`VITE_API_BASE` pointed straight
+// at the API worker) is the only place a missing header here is visible at
+// all, and it fails closed (the browser refuses the request before it is
+// ever sent). Driven through the real router (`workers/api/src/index.ts`'s
+// default export), not a re-declared copy of the header list.
+// Run: node --experimental-strip-types --test pipeline/api-cors.test.mjs
+
+import test from "node:test";
+import assert from "node:assert/strict";
+import { register } from "node:module";
+import { ctx, makeEnv } from "./fixtures/worker-harness.mjs";
+
+register("./fixtures/worker-hooks.mjs", import.meta.url);
+
+const { default: worker } = await import("../workers/api/src/index.ts");
+
+function req(method, path, init = {}) {
+ return new Request(`https://demos.handsontable.com${path}`, { method, ...init });
+}
+
+test("an OPTIONS preflight allows x-hot-session, the telemetry session-join header", async () => {
+ const env = makeEnv();
+ const res = await worker.fetch(req("OPTIONS", "/api/versions"), env, ctx);
+ assert.equal(res.status, 204);
+ const allowed = res.headers.get("Access-Control-Allow-Headers") ?? "";
+ assert.ok(
+ allowed.split(",").map((s) => s.trim().toLowerCase()).includes("x-hot-session"),
+ `expected "x-hot-session" in Access-Control-Allow-Headers, got: "${allowed}"`,
+ );
+});
+
+test("a real GET response also carries x-hot-session in Access-Control-Allow-Headers", async () => {
+ const env = makeEnv();
+ const res = await worker.fetch(req("GET", "/api/versions"), env, ctx);
+ const allowed = res.headers.get("Access-Control-Allow-Headers") ?? "";
+ assert.ok(
+ allowed.split(",").map((s) => s.trim().toLowerCase()).includes("x-hot-session"),
+ `expected "x-hot-session" in Access-Control-Allow-Headers, got: "${allowed}"`,
+ );
+});
diff --git a/runner/pipeline/api-cron-wiring.test.mjs b/runner/pipeline/api-cron-wiring.test.mjs
new file mode 100644
index 0000000000..ee78727d59
--- /dev/null
+++ b/runner/pipeline/api-cron-wiring.test.mjs
@@ -0,0 +1,87 @@
+// Structural pins for the API worker's cron wiring
+// (`workers/api/src/index.ts`). `runNightlyCron`/`runFiveMinuteCron` are
+// private to that file and pull in the whole Worker's dependency graph
+// (D1/KV/Sandbox/Sentry bindings), so — same rationale as
+// `pipeline/master-workflow.test.mjs`'s structural pins on `master.yml` —
+// these read the source as text and assert its exact shape rather than
+// importing and executing it.
+// Run: node --experimental-strip-types --test pipeline/api-cron-wiring.test.mjs
+
+import test from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const indexPath = join(__dirname, "..", "workers/api/src/index.ts");
+const source = readFileSync(indexPath, "utf8");
+
+function bodyOf(fnName) {
+ const start = source.indexOf(`async function ${fnName}(`);
+ assert.ok(start > -1, `${fnName} must exist in index.ts`);
+ const braceStart = source.indexOf("{", start);
+ // Find the matching closing brace by depth-counting — the bodies below
+ // never contain a template literal with an unbalanced `{`, so this is safe.
+ let depth = 0;
+ let i = braceStart;
+ for (; i < source.length; i++) {
+ if (source[i] === "{") depth++;
+ else if (source[i] === "}") {
+ depth--;
+ if (depth === 0) break;
+ }
+ }
+ return source.slice(braceStart, i + 1);
+}
+
+// `rollupExampleDaily` must run under its own `cronStep` call, outside the
+// billing chain's callback body — inside the same
+// `cronStep(env, "cron:nightly", ...)` chain, an upstream throw (e.g.
+// `reconcileBilling`) would skip it for the whole night.
+test("index.ts: the nightly example_daily rollup has its own independent cronStep, not nested in the billing chain", () => {
+ const nightlyBody = bodyOf("runNightlyCron");
+
+ const billingStart = nightlyBody.indexOf('cronStep(env, "cron:nightly",');
+ assert.ok(billingStart > -1, "the billing cronStep call must exist");
+ const billingCallbackStart = nightlyBody.indexOf("{", nightlyBody.indexOf("=>", billingStart));
+ let depth = 0;
+ let i = billingCallbackStart;
+ for (; i < nightlyBody.length; i++) {
+ if (nightlyBody[i] === "{") depth++;
+ else if (nightlyBody[i] === "}") {
+ depth--;
+ if (depth === 0) break;
+ }
+ }
+ const billingCallbackBody = nightlyBody.slice(billingCallbackStart, i + 1);
+
+ assert.doesNotMatch(
+ billingCallbackBody,
+ /rollupExampleDaily/,
+ "rollupExampleDaily must NOT be called inside the billing chain's cronStep callback",
+ );
+ assert.match(
+ nightlyBody.slice(i + 1),
+ /await cronStep\(env, "cron:nightly:rollup", async \(\) => \{\s*await rollupExampleDaily\(env\);\s*\}\);/,
+ "rollupExampleDaily must run under its own cronStep call, after the billing chain's cronStep has returned",
+ );
+});
+
+// The API worker's own `*/5` cron must write one structured
+// `log.kind: "cron.tick"` line (via `telemetry/lines.ts`'s shared helper)
+// so o11y's `heartbeat.lastIngest` watchdog check is a true end-to-end
+// signal, not just "did the ingest pipeline exist".
+test("index.ts: the five-minute cron writes a cron.tick line through logCronTickLine", () => {
+ const fiveMinuteBody = bodyOf("runFiveMinuteCron");
+ assert.match(
+ fiveMinuteBody,
+ /await cronStep\(env, "cron:five-minute:tick", \(\) => \{\s*logCronTickLine\(env\);/,
+ "runFiveMinuteCron must call logCronTickLine(env) under its own cronStep",
+ );
+ assert.match(
+ source,
+ /\blogCronTickLine\b/,
+ "logCronTickLine must be imported from telemetry/index.js",
+ );
+});
diff --git a/runner/pipeline/api-error.test.mjs b/runner/pipeline/api-error.test.mjs
index bfb78f8a5a..e5a66672a5 100644
--- a/runner/pipeline/api-error.test.mjs
+++ b/runner/pipeline/api-error.test.mjs
@@ -255,3 +255,24 @@ test("other 409s stay unclassified", () => {
assert.equal(failure.kind, "other");
assert.equal(failure.reportable, true);
});
+
+test("a build_failed 422 says nothing was saved and shows the build error, never the wire text", () => {
+ const detail = 'src/index.tsx:1:10: ERROR: Unexpected ";"';
+ const failure = describeApiFailure(
+ 422,
+ { error: `build failed: ${detail}`, code: "build_failed", detail },
+ "save failed (422)",
+ );
+ assert.equal(failure.message, `The build failed, so nothing was saved. ${detail}`);
+ assert.doesNotMatch(failure.message, /build_failed|build failed:/);
+ assert.equal(failure.reportable, false, "the author's own build error is not a Sentry issue");
+ assert.equal(
+ describeApiFailure(422, { error: "build failed: x", code: "build_failed" }, "x").message,
+ "The build failed, so nothing was saved.",
+ );
+ assert.equal(
+ describeApiFailure(422, { error: "build_failed", detail }, "x").message,
+ `The build failed, so nothing was saved. ${detail}`,
+ "the older API's bare code still reads as the sentence",
+ );
+});
diff --git a/runner/pipeline/api-telemetry-config.test.mjs b/runner/pipeline/api-telemetry-config.test.mjs
new file mode 100644
index 0000000000..d643c7738c
--- /dev/null
+++ b/runner/pipeline/api-telemetry-config.test.mjs
@@ -0,0 +1,134 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import { fileURLToPath } from "node:url";
+
+// API worker signals, error lines, the Sentry scope switch (ADR-0041 §D,
+// §E.1, §E.3; contract §2). Pins the exact config the ADR names, so a
+// revert of any one value goes red: full-fidelity head sampling with
+// invocation logs off, no export destination yet (`o11y-logs` can only be
+// created after this Worker's first deploy — run-and-deploy.md "First
+// deploy, in order" — and is added back by a follow-up), the sampled 1%
+// trace rate with no destination (no trace is ever exported), the `*/5`
+// observability cron beside the unchanged nightly one, and
+// `SERVICE_VERSION` wired into the deploy script.
+//
+// No JSON5 dependency: a small string-aware `//`-comment stripper is
+// enough for this repo's actual `.jsonc` style (line comments only, no
+// trailing commas).
+
+function stripLineComments(text) {
+ let out = "";
+ let inString = false;
+ let escape = false;
+ for (let i = 0; i < text.length; i += 1) {
+ const c = text[i];
+ if (inString) {
+ out += c;
+ if (escape) escape = false;
+ else if (c === "\\") escape = true;
+ else if (c === "\"") inString = false;
+ continue;
+ }
+ if (c === "\"") {
+ inString = true;
+ out += c;
+ continue;
+ }
+ if (c === "/" && text[i + 1] === "/") {
+ while (i < text.length && text[i] !== "\n") i += 1;
+ out += "\n";
+ continue;
+ }
+ out += c;
+ }
+ return out;
+}
+
+const workersApiDir = fileURLToPath(new URL("../workers/api/", import.meta.url));
+const wranglerJsonc = fs.readFileSync(`${workersApiDir}wrangler.jsonc`, "utf8");
+const wrangler = JSON.parse(stripLineComments(wranglerJsonc));
+const pkg = JSON.parse(fs.readFileSync(`${workersApiDir}package.json`, "utf8"));
+
+test("stripLineComments does not corrupt a string containing //", () => {
+ // wrangler.jsonc's own vars carry URLs — a naive per-line stripper would
+ // truncate them. Guards the parser this file's own assertions depend on.
+ assert.equal(
+ JSON.parse(stripLineComments('{"a": "https://example.com/x"}')).a,
+ "https://example.com/x",
+ );
+});
+
+test("observability.logs: full fidelity, invocation logs off, persisted, no export destination yet", () => {
+ assert.deepEqual(wrangler.observability.logs, {
+ enabled: true,
+ head_sampling_rate: 1.0,
+ invocation_logs: false,
+ persist: true,
+ destinations: [],
+ });
+});
+
+test("observability.traces: 1% sampled, persisted, no destination (ADR §C.4 — no trace export)", () => {
+ assert.deepEqual(wrangler.observability.traces, {
+ enabled: true,
+ head_sampling_rate: 0.01,
+ persist: true,
+ });
+ assert.equal("destinations" in wrangler.observability.traces, false);
+});
+
+test("the */5 cron runs alongside the unchanged nightly one", () => {
+ assert.deepEqual(wrangler.triggers.crons, ["17 4 * * *", "*/5 * * * *"]);
+});
+
+test("RUNNER_EVENTS and O11Y bindings are wired (not just declared)", () => {
+ assert.equal(wrangler.analytics_engine_datasets[0].binding, "RUNNER_EVENTS");
+ assert.equal(wrangler.analytics_engine_datasets[0].dataset, "runner_events");
+ assert.equal(wrangler.services[0].binding, "O11Y");
+ assert.equal(wrangler.services[0].service, "handsontable-demos-o11y");
+});
+
+// `env.O11Y` must bind to the named `O11yHeartbeat` RPC entrypoint, not the
+// o11y worker's default export, which has no HTTP route for this report.
+// Also checks every o11y-service binding's declared entrypoint is a real
+// named export of the target worker's `index.ts`.
+test("every o11y service binding declares a real entrypoint, and O11Y's is O11yHeartbeat", () => {
+ const o11yIndexPath = fileURLToPath(new URL("../workers/o11y/src/index.ts", import.meta.url));
+ const o11yIndexSrc = fs.readFileSync(o11yIndexPath, "utf8");
+ const o11y = wrangler.services.find((svc) => svc.binding === "O11Y");
+ assert.equal(
+ o11y?.entrypoint,
+ "O11yHeartbeat",
+ "env.O11Y must bind to entrypoint: \"O11yHeartbeat\" — without it, env.O11Y resolves to the o11y " +
+ "worker's default export, which has no HTTP route for the heartbeat report",
+ );
+ for (const svc of wrangler.services) {
+ if (svc.service !== "handsontable-demos-o11y") continue;
+ assert.ok(svc.entrypoint, `services[] entry for "${svc.service}" (binding ${svc.binding}) must declare an entrypoint`);
+ const exportRe = new RegExp(`export\\s*\\{[^}]*\\b${svc.entrypoint}\\b[^}]*\\}|export\\s+class\\s+${svc.entrypoint}\\b`);
+ assert.match(
+ o11yIndexSrc,
+ exportRe,
+ `workers/o11y/src/index.ts does not export "${svc.entrypoint}", which ${svc.binding}'s entrypoint names`,
+ );
+ }
+});
+
+test("SENTRY_SCOPE defaults to full (contract §11)", () => {
+ assert.equal(wrangler.vars.SENTRY_SCOPE, "full");
+});
+
+test("the deploy script sets SERVICE_VERSION from GITHUB_SHA, alongside every existing --routes flag", () => {
+ const deploy = pkg.scripts.deploy;
+ for (const route of [
+ "*.demos.handsontable.com/*",
+ "demos.handsontable.com/api/*",
+ "demos.handsontable.com/d/*",
+ "demos.handsontable.com/embed/*",
+ ]) {
+ assert.ok(deploy.includes(`--routes '${route}'`), `missing --routes '${route}'`);
+ }
+ assert.ok(deploy.includes("--var SENTRY_ENVIRONMENT:api-production"));
+ assert.ok(deploy.includes("--var SERVICE_VERSION:$GITHUB_SHA"));
+});
diff --git a/runner/pipeline/api-telemetry-cron-step.test.mjs b/runner/pipeline/api-telemetry-cron-step.test.mjs
new file mode 100644
index 0000000000..e5006b7562
--- /dev/null
+++ b/runner/pipeline/api-telemetry-cron-step.test.mjs
@@ -0,0 +1,62 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+
+// `cronStep` (`workers/api/src/telemetry/cron-step.ts`) needs a direct
+// test: its explicit, ungated `Sentry.captureException` for a failed cron
+// step is necessary because a throw inside `ctx.waitUntil(...)` is
+// invisible to `Sentry.withSentry`'s own `scheduled` auto-capture.
+//
+// `cron-step.ts` is a leaf on purpose (only `lines.ts` -> `resource.ts`,
+// plus the real `@sentry/cloudflare` package) so it can be copied the same
+// way `api-telemetry-diagnostic.test.mjs` copies `diagnostic.ts`'s chain —
+// see that file's header comment for why the copy lands inside
+// `workers/api/` rather than the OS temp dir.
+//
+// `cronStep` takes an injectable `capture` function — the test below
+// passes a recorder instead of the real `@sentry/cloudflare` call.
+
+const workersApiDir = join(import.meta.dirname, "..", "workers/api");
+const telemetrySrc = join(workersApiDir, "src/telemetry");
+const envSrc = join(workersApiDir, "src/env.ts");
+const dir = mkdtempSync(join(workersApiDir, ".hot-cron-step-"));
+for (const file of ["cron-step.ts", "lines.ts", "resource.ts"]) {
+ writeFileSync(join(dir, file), readFileSync(join(telemetrySrc, file), "utf8").replaceAll('.js"', '.ts"'));
+}
+writeFileSync(join(dir, "env.ts"), readFileSync(envSrc, "utf8"));
+const { cronStep } = await import(join(dir, "cron-step.ts"));
+rmSync(dir, { recursive: true, force: true });
+
+const ENV = { PREVIEW_HOST: "demos.handsontable.com" };
+
+function recorder() {
+ const calls = [];
+ const capture = (err, context) => calls.push({ err, context });
+ return { calls, capture };
+}
+
+test("cronStep: a throwing step is captured, and the step's own error is swallowed (does not rethrow)", async () => {
+ const { calls, capture } = recorder();
+ const err = new Error("cron step failed");
+ await assert.doesNotReject(() => cronStep(ENV, "cron:test-step", () => { throw err; }, capture));
+ assert.equal(calls.length, 1);
+ assert.equal(calls[0].err, err);
+ assert.deepEqual(calls[0].context, { tags: { context: "cron:test-step" } });
+});
+
+test("cronStep: a succeeding step never calls capture", async () => {
+ const { calls, capture } = recorder();
+ await cronStep(ENV, "cron:test-step", async () => { /* ok */ }, capture);
+ assert.equal(calls.length, 0);
+});
+
+test("cronStep: an async rejection is captured the same way a sync throw is", async () => {
+ const { calls, capture } = recorder();
+ await cronStep(ENV, "cron:test-step", () => Promise.reject(new Error("async boom")), capture);
+ assert.equal(calls.length, 1);
+});
+
+test("cronStep: with no capture argument, does not throw (falls back to the real Sentry call, inert with no client configured)", async () => {
+ await assert.doesNotReject(() => cronStep(ENV, "cron:test-step", () => { throw new Error("boom"); }));
+});
diff --git a/runner/pipeline/api-telemetry-cron-tick.test.mjs b/runner/pipeline/api-telemetry-cron-tick.test.mjs
new file mode 100644
index 0000000000..82a93e7a77
--- /dev/null
+++ b/runner/pipeline/api-telemetry-cron-tick.test.mjs
@@ -0,0 +1,45 @@
+// `telemetry/lines.ts#logCronTickLine` — the one structured line the API
+// worker's `*/5` cron writes so o11y's `heartbeat.lastIngest` watchdog
+// check is a true end-to-end signal, even during a real quiet period with
+// no user traffic. See `lines.ts`'s own doc comment for why
+// `normalise/otlp.ts` needed no change for this line to still count as an
+// ingest event.
+//
+// `lines.ts` imports `./resource.js` relatively — the loader hook below
+// remaps that to `resource.ts` on disk, the same way every other spec that
+// imports straight from `workers/api/src/` does.
+// Run: node --experimental-strip-types --test pipeline/api-telemetry-cron-tick.test.mjs
+
+import test from "node:test";
+import assert from "node:assert/strict";
+import { register } from "node:module";
+
+register("./fixtures/worker-hooks.mjs", import.meta.url);
+
+const { logCronTickLine } = await import("../workers/api/src/telemetry/lines.ts");
+
+function captureConsoleLog(fn) {
+ const original = console.log;
+ const lines = [];
+ console.log = (line) => lines.push(line);
+ try {
+ fn();
+ } finally {
+ console.log = original;
+ }
+ return lines;
+}
+
+test("logCronTickLine: emits exactly one console.log line shaped log.kind=cron.tick", () => {
+ const lines = captureConsoleLog(() => logCronTickLine({ SERVICE_VERSION: "abc123" }));
+ assert.equal(lines.length, 1, "must write exactly one line per call");
+ const parsed = JSON.parse(lines[0]);
+ assert.equal(parsed["log.kind"], "cron.tick");
+ assert.equal(parsed["service.version"], "abc123");
+});
+
+test("logCronTickLine: falls back to the same service.version resolution every other line uses", () => {
+ const lines = captureConsoleLog(() => logCronTickLine({}));
+ const parsed = JSON.parse(lines[0]);
+ assert.equal(parsed["service.version"], "dev", "matches resource.ts#serviceVersion's own documented fallback");
+});
diff --git a/runner/pipeline/api-telemetry-diagnostic.test.mjs b/runner/pipeline/api-telemetry-diagnostic.test.mjs
new file mode 100644
index 0000000000..db84bcf8c0
--- /dev/null
+++ b/runner/pipeline/api-telemetry-diagnostic.test.mjs
@@ -0,0 +1,185 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+// `reportDiagnostic`'s `sentryScopeIsFull` gate
+// (`workers/api/src/telemetry/diagnostic.ts`) needs a direct test, not just
+// the decision function (`sentryScopeIsFull` itself, in
+// `api-telemetry-signals.test.mjs`) — the wiring that actually calls
+// `Sentry.captureException` behind it needs coverage too. `diagnostic.ts`
+// imports `./lines.js` and `./points.js` (sibling `.ts` files), which
+// `--experimental-strip-types` cannot resolve through a `.js` specifier
+// from a single-file import (see `resource.ts`'s own doc comment) — so
+// this file uses the same copy-and-rewrite harness
+// `pipeline/chat-sanitise.test.mjs` uses for `chat.ts`, applied to the
+// `telemetry/` subtree instead.
+//
+// Unlike `chat.ts`, `diagnostic.ts` also has two bare package specifiers
+// (`@sentry/cloudflare`, `@handsontable/demo-runtime/telemetry`), so the copy
+// has to land somewhere Node's module resolution still walks up into
+// `workers/api/node_modules` (both are real symlinks there) — an OS temp
+// dir does not have that ancestry (confirmed:
+// `Cannot find package '@sentry/cloudflare'`). The copy below lands inside
+// `workers/api/` itself instead, and is removed after.
+//
+// `reportDiagnostic` takes an injectable `capture` function — the test
+// below passes a recorder instead of the real `@sentry/cloudflare` call.
+
+const workersApiDir = join(import.meta.dirname, "..", "workers/api");
+const telemetrySrc = join(workersApiDir, "src/telemetry");
+const envSrc = join(workersApiDir, "src/env.ts");
+const dir = mkdtempSync(join(workersApiDir, ".hot-diagnostic-"));
+// The copy-and-import below must not run as a plain top-level sequence
+// with `rmSync` last — a failing import (a typo in one of the copied
+// files, a resolution error) would skip the cleanup and leave the scratch
+// directory behind for `git add -A` to pick up (see `.gitignore`'s own
+// `workers/api/.hot-*` entry). `try/finally` guarantees the directory is
+// always removed, whether the import below succeeds or throws.
+let reportDiagnostic;
+try {
+ // diagnostic.ts's own chain: diagnostic.ts -> lines.ts, points.ts, scope.ts
+ // -> resource.ts. `env.ts` is imported everywhere as `import type` only
+ // (erased by strip-types, never resolved), but is copied too so a stray
+ // value use would fail loudly instead of silently resolving to nothing.
+ for (const file of ["diagnostic.ts", "lines.ts", "points.ts", "scope.ts", "resource.ts"]) {
+ writeFileSync(join(dir, file), readFileSync(join(telemetrySrc, file), "utf8").replaceAll('.js"', '.ts"'));
+ }
+ writeFileSync(join(dir, "env.ts"), readFileSync(envSrc, "utf8"));
+ ({ reportDiagnostic } = await import(join(dir, "diagnostic.ts")));
+} finally {
+ rmSync(dir, { recursive: true, force: true });
+}
+
+// `getSink`/`emitPoint` inside `reportDiagnostic` would otherwise reach for
+// the local ClickHouse sink (a real `fetch` to localhost:8123) — production
+// env shape with no `RUNNER_EVENTS` binding hits `resource.ts`'s documented
+// no-op sink instead, so this test touches the network not at all.
+const ENV_FULL = { PREVIEW_HOST: "demos.handsontable.com", SENTRY_SCOPE: "full" };
+const ENV_UNCAUGHT = { PREVIEW_HOST: "demos.handsontable.com", SENTRY_SCOPE: "uncaught" };
+
+function recorder() {
+ const calls = [];
+ const capture = (err, context) => calls.push({ err, context });
+ return { calls, capture };
+}
+
+test("reportDiagnostic: SENTRY_SCOPE=full calls the injected capture once", () => {
+ const { calls, capture } = recorder();
+ const err = new Error("boom");
+ reportDiagnostic(ENV_FULL, err, { context: "test-site", routeClass: "api/test" }, capture);
+ assert.equal(calls.length, 1);
+ assert.equal(calls[0].err, err);
+});
+
+test("reportDiagnostic: SENTRY_SCOPE=uncaught calls the injected capture zero times", () => {
+ const { calls, capture } = recorder();
+ reportDiagnostic(ENV_UNCAUGHT, new Error("boom"), { context: "test-site", routeClass: "api/test" }, capture);
+ assert.equal(calls.length, 0);
+});
+
+test("reportDiagnostic: absent SENTRY_SCOPE defaults to full (calls the capture)", () => {
+ const { calls, capture } = recorder();
+ reportDiagnostic({ PREVIEW_HOST: "demos.handsontable.com" }, new Error("boom"), { context: "test-site", routeClass: "api/test" }, capture);
+ assert.equal(calls.length, 1);
+});
+
+test("reportDiagnostic: the captured context carries tags/fingerprint/level through", () => {
+ const { calls, capture } = recorder();
+ reportDiagnostic(ENV_FULL, new Error("boom"), {
+ context: "test-site",
+ routeClass: "api/test",
+ tags: { upstream: "npm-registry" },
+ sentryFingerprint: ["a", "b"],
+ level: "warning",
+ }, capture);
+ assert.deepEqual(calls[0].context, {
+ level: "warning",
+ tags: { upstream: "npm-registry" },
+ fingerprint: ["a", "b"],
+ });
+});
+
+test("reportDiagnostic: with no capture argument, does not throw (falls back to the real Sentry call)", () => {
+ // The default path (every real call site in index.ts/chat.ts/theme-ai.ts)
+ // — under `uncaught` scope the gate is closed before the real
+ // `Sentry.captureException` would ever run, so this is safe to exercise
+ // without an active Sentry client.
+ assert.doesNotThrow(() => reportDiagnostic(ENV_UNCAUGHT, new Error("boom"), { context: "test-site", routeClass: "api/test" }));
+});
+
+// The structured error line `reportDiagnostic` writes via `logErrorLine`
+// must carry the contract-fingerprint under `hot.fingerprint` (contract
+// §3 AE-only key) — otherwise the API worker's handled errors have no way
+// to reach the §F.3 new-fingerprint registry once they arrive at the o11y
+// worker as a worker-tenant OTLP export.
+test("reportDiagnostic: the structured error line carries hot.fingerprint = fingerprint(context, message)", () => {
+ const lines = [];
+ const realConsoleError = console.error;
+ console.error = (...args) => lines.push(args.join(" "));
+ try {
+ const { capture } = recorder();
+ reportDiagnostic(ENV_UNCAUGHT, new Error("boom"), { context: "test-site", routeClass: "api/test" }, capture);
+ } finally {
+ console.error = realConsoleError;
+ }
+ assert.equal(lines.length, 1);
+ const parsed = JSON.parse(lines[0]);
+ assert.equal(parsed["log.kind"], "error");
+ assert.equal(parsed.context, "test-site");
+ assert.match(parsed["hot.fingerprint"], /^test-site:[0-9a-f]{16}$/);
+});
+
+// `index.ts`'s chat-gateway and theme-gateway `reportDiagnostic` calls
+// live inside the main worker's `fetch` handler (a route match deep inside
+// a ~2000-line switch), not something this suite can invoke directly
+// without a full request/env — same constraint
+// `pipeline/mcp-create.test.mjs`'s own "the update route calls
+// isMcpCreated()" test documents for the same file. Structural, same
+// style: the route source is read as text and the exact
+// `sentryFingerprint` shape is asserted for each call site — a passing
+// `reportDiagnostic` fingerprint-passthrough test elsewhere proves the
+// function honours `sentryFingerprint` when given one; this proves each
+// call site actually passes one.
+test("the chat-gateway and theme-gateway reportDiagnostic calls set a status-grouped sentryFingerprint", () => {
+ const root = join(import.meta.dirname, "..");
+ const source = readFileSync(join(root, "workers/api/src/index.ts"), "utf8");
+
+ const chatStart = source.indexOf('context: "chat-gateway"');
+ assert.ok(chatStart > -1, "the chat-gateway reportDiagnostic call exists in index.ts");
+ const chatCall = source.slice(chatStart, source.indexOf("});", chatStart));
+ assert.match(
+ chatCall,
+ /sentryFingerprint:\s*\["litellm-gateway",\s*String\(err\.status\)\]/,
+ "chat-gateway must fingerprint by gateway + status, not by the default message-based grouping (which carries a unique request_id)",
+ );
+
+ const themeStart = source.indexOf('context: "theme-gateway"');
+ assert.ok(themeStart > -1, "the theme-gateway reportDiagnostic call exists in index.ts");
+ const themeCall = source.slice(themeStart, source.indexOf("});", themeStart));
+ assert.match(
+ themeCall,
+ /sentryFingerprint:\s*\["litellm-gateway",\s*String\(err\.status\)\]/,
+ "theme-gateway must fingerprint by gateway + status too",
+ );
+});
+
+// The scratch-directory copy+import above must not be a plain top-level
+// sequence ending in a bare `rmSync` — a failing import would leave
+// `workers/api/.hot-diagnostic-*` behind, ungitignored, for `git add -A`
+// to pick up. Structural (the fix is the shape of this file's own
+// top-level code, not something a runtime assertion can observe after the
+// fact — the directory from a real run is already gone by the time any
+// test() body runs, success or failure).
+test("scratch-dir cleanup is wrapped in try/finally, and workers/api/.hot-* is gitignored", () => {
+ const selfSource = readFileSync(fileURLToPath(import.meta.url), "utf8");
+ assert.match(
+ selfSource,
+ /try\s*\{[\s\S]*await import\(join\(dir, "diagnostic\.ts"\)\)[\s\S]*\}\s*finally\s*\{\s*rmSync\(dir,/,
+ "the copy+import block must be wrapped in try/finally, with rmSync(dir, ...) in the finally",
+ );
+
+ const gitignore = readFileSync(join(import.meta.dirname, "..", ".gitignore"), "utf8");
+ assert.match(gitignore, /^workers\/api\/\.hot-\*$/m, "runner/.gitignore must ignore workers/api/.hot-* scratch dirs");
+});
diff --git a/runner/pipeline/api-telemetry-pool-gauge.test.mjs b/runner/pipeline/api-telemetry-pool-gauge.test.mjs
new file mode 100644
index 0000000000..9c45b34654
--- /dev/null
+++ b/runner/pipeline/api-telemetry-pool-gauge.test.mjs
@@ -0,0 +1,111 @@
+// `pool.gauge` (`workers/api/src/telemetry/cron.ts`, reason `live`) must not
+// count every `session-meter:` key in KV: a meter key outlives the
+// container it fronts by `KV_METER_TTL_SECONDS` (24h, `budget.ts`), so a
+// single stale 24h tail would read as pool pressure. This file proves
+// `countLiveSessionMeters` shares the same awake/slept split
+// `admin.ts#liveSessions`'s `awakeCount` uses (`session-listing.ts
+// #classifyMeter`, via `admin.ts#readMeters`, the same KV scan the panel
+// runs) rather than trusting key existence or inventing a second window.
+// Run: node --experimental-strip-types --test pipeline/api-telemetry-pool-gauge.test.mjs
+
+import test from "node:test";
+import assert from "node:assert/strict";
+import { register } from "node:module";
+import fs from "node:fs";
+import { fileURLToPath } from "node:url";
+import { fakeKV } from "./fixtures/worker-harness.mjs";
+
+register("./fixtures/worker-hooks.mjs", import.meta.url);
+
+const { countLiveSessionMeters, LIVE_POOL_MAX_INSTANCES } = await import("../workers/api/src/telemetry/cron.ts");
+const { AWAKE_WINDOW_SECONDS } = await import("../workers/api/src/session-listing.ts");
+
+const now = 1_800_000_000_000;
+const sec = (n) => n * 1000;
+
+/** Writes a meter the way `budget.ts#startSessionMeter`/`meterSessionUnsafe` do:
+ * value plus mirrored list metadata, so `admin.ts#readMeters` takes the fast
+ * (metadata-only) path instead of its legacy per-row `get` fallback. */
+async function seedMeter(cache, sessionId, startedAt, meteredThrough) {
+ await cache.put(
+ `session-meter:${sessionId}`,
+ JSON.stringify({ startedAt, meteredThrough, instanceType: "standard-1" }),
+ { metadata: { s: startedAt, m: meteredThrough, i: "standard-1" } },
+ );
+}
+
+test("countLiveSessionMeters: a stale 24h meter plus one awake meter counts only the awake one", async () => {
+ const cache = fakeKV();
+ // Stale: last ticked an hour ago, long past the idle window — the KV key is
+ // still alive (well inside its 24h TTL) but the container behind it slept.
+ await seedMeter(cache, "vue-stale00001", now - sec(20 * 3600), now - sec(3600));
+ // Awake: the one live Tier-2 lane, ticked 30s ago.
+ await seedMeter(cache, "vue-awake00001", now - sec(120), now - sec(30));
+ const env = { CACHE: cache };
+ assert.equal(await countLiveSessionMeters(env, now), 1);
+});
+
+// Boundary: one meter exactly at the idle window (must count, inclusive —
+// matches `classifyMeter`'s own `quietSeconds <= AWAKE_WINDOW_SECONDS` rule)
+// and one meter one second past it (must not). A key-count-only
+// implementation answers 2; a classifier with the boundary flipped to
+// exclusive (`<` instead of `<=`) answers 0. Only the fix under test
+// answers 1.
+test("countLiveSessionMeters: the idle-window boundary is inclusive, same rule as classifyMeter", async () => {
+ const cache = fakeKV();
+ await seedMeter(cache, "astro-atwindow1", now - sec(900), now - sec(AWAKE_WINDOW_SECONDS));
+ await seedMeter(cache, "astro-pastwindow", now - sec(900), now - sec(AWAKE_WINDOW_SECONDS + 1));
+ const env = { CACHE: cache };
+ assert.equal(await countLiveSessionMeters(env, now), 1);
+});
+
+// ---------------------------------------------------------------------------
+// `pool.gauge`'s `cap` (`LIVE_POOL_MAX_INSTANCES`) is a hard-coded constant,
+// not read from config at runtime (wrangler does not expose
+// `containers[].max_instances` to `env`), so it can drift silently from
+// `Sandbox.max_instances` in wrangler.jsonc — the "Pool gauge vs cap" panel
+// would then compare live sessions against the wrong ceiling with no error
+// anywhere. This test parses the real wrangler.jsonc and pins the two
+// together: change either number alone and this goes red.
+// ---------------------------------------------------------------------------
+
+function stripLineComments(text) {
+ let out = "";
+ let inString = false;
+ let escape = false;
+ for (let i = 0; i < text.length; i += 1) {
+ const c = text[i];
+ if (inString) {
+ out += c;
+ if (escape) escape = false;
+ else if (c === "\\") escape = true;
+ else if (c === "\"") inString = false;
+ continue;
+ }
+ if (c === "\"") {
+ inString = true;
+ out += c;
+ continue;
+ }
+ if (c === "/" && text[i + 1] === "/") {
+ while (i < text.length && text[i] !== "\n") i += 1;
+ out += "\n";
+ continue;
+ }
+ out += c;
+ }
+ return out;
+}
+
+test("drift: LIVE_POOL_MAX_INSTANCES equals workers/api/wrangler.jsonc's Sandbox max_instances", () => {
+ const workersApiDir = fileURLToPath(new URL("../workers/api/", import.meta.url));
+ const wranglerJsonc = fs.readFileSync(`${workersApiDir}wrangler.jsonc`, "utf8");
+ const wrangler = JSON.parse(stripLineComments(wranglerJsonc));
+ const sandbox = wrangler.containers.find((c) => c.class_name === "Sandbox");
+ assert.ok(sandbox, "wrangler.jsonc must declare a Sandbox container");
+ assert.equal(
+ LIVE_POOL_MAX_INSTANCES,
+ sandbox.max_instances,
+ "cron.ts's LIVE_POOL_MAX_INSTANCES must be updated in the same commit as wrangler.jsonc's Sandbox max_instances",
+ );
+});
diff --git a/runner/pipeline/api-telemetry-signals.test.mjs b/runner/pipeline/api-telemetry-signals.test.mjs
new file mode 100644
index 0000000000..44fce6ba4f
--- /dev/null
+++ b/runner/pipeline/api-telemetry-signals.test.mjs
@@ -0,0 +1,147 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { demoIdFromPath, routeClassOf, validSessionId } from "../workers/api/src/telemetry/route-class.ts";
+import { serviceEnvironment, serviceVersion } from "../workers/api/src/telemetry/resource.ts";
+import { sentryScopeIsFull } from "../workers/api/src/telemetry/scope.ts";
+
+// Route classification (blob10 `route_class`, contract §4) and the
+// service.version / deployment.environment.name resource attrs (contract §3).
+//
+// `route-class.ts` and `resource.ts` are deliberately import-free of their
+// sibling `.ts` files (see resource.ts's own doc comment) so this file can
+// import them directly under `--experimental-strip-types` — a sibling `.ts`
+// file's compiled `.js` specifier does not resolve that way.
+
+test("routeClassOf: the exact case the acceptance criteria names", () => {
+ assert.equal(routeClassOf("GET", "/api/versions"), "api/versions");
+});
+
+test("routeClassOf: dynamic ids collapse, not the whole path", () => {
+ assert.equal(routeClassOf("POST", "/api/session"), "api/session");
+ assert.equal(routeClassOf("DELETE", "/api/session/react-18-abc123"), "api/session/:id");
+ assert.equal(routeClassOf("GET", "/api/session/react-18-abc123/status"), "api/session/:id/status");
+ assert.equal(routeClassOf("POST", "/api/session/react-18-abc123/file"), "api/session/:id/file");
+});
+
+test("routeClassOf: shared demos and embeds", () => {
+ assert.equal(routeClassOf("GET", "/d/abc123"), "d/:id");
+ assert.equal(routeClassOf("GET", "/embed/abc123"), "embed/:id");
+});
+
+test("routeClassOf: versions/exists is distinct from versions", () => {
+ assert.equal(routeClassOf("GET", "/api/versions/exists"), "api/versions/exists");
+});
+
+test("routeClassOf: chat/event is distinct from chat", () => {
+ assert.equal(routeClassOf("POST", "/api/chat"), "api/chat");
+ assert.equal(routeClassOf("POST", "/api/chat/event"), "api/chat/event");
+});
+
+test("routeClassOf: root and an unmatched top-level path", () => {
+ assert.equal(routeClassOf("GET", "/"), "root");
+ assert.equal(routeClassOf("GET", "/robots.txt"), "other");
+});
+
+test("routeClassOf: never throws on a malformed path", () => {
+ assert.doesNotThrow(() => routeClassOf("GET", ""));
+ assert.doesNotThrow(() => routeClassOf("GET", "//api//"));
+});
+
+test("demoIdFromPath: extracted for the routes that name one, empty otherwise", () => {
+ assert.equal(demoIdFromPath("/d/abc123"), "abc123");
+ assert.equal(demoIdFromPath("/embed/abc123"), "abc123");
+ assert.equal(demoIdFromPath("/api/versions"), "");
+ assert.equal(demoIdFromPath("/api/session/sess-1/status"), "");
+});
+
+// A raw, unresolved path segment must never reach `hot.demo_id` on the
+// per-request log line — a crawler probing `/d/` must not stuff
+// arbitrary strings straight into Loki structured metadata. The
+// `DEMO_ID_SHAPE_RE` check in `route-class.ts#demoIdFromPath` must reject
+// it, not return the raw segment unconditionally.
+test("demoIdFromPath: rejects a path segment that isn't shaped like a real demo id", () => {
+ assert.equal(demoIdFromPath("/d/';DROP TABLE demos;--"), "");
+ assert.equal(demoIdFromPath("/d/"), "");
+ assert.equal(demoIdFromPath("/d/has spaces"), "");
+ assert.equal(demoIdFromPath(`/d/${"a".repeat(200)}`), "", "implausibly long guesses are rejected");
+ assert.equal(demoIdFromPath("/embed/../../etc/passwd"), "", "a literal '..' segment (path-traversal-shaped) is rejected");
+});
+
+test("demoIdFromPath: still accepts every real id shape (shortId's own alphabet, and a hyphenated legacy fixed id)", () => {
+ assert.equal(demoIdFromPath("/d/abc123defg"), "abc123defg", "share.ts#shortId()'s own lowercase-base36 alphabet");
+ assert.equal(demoIdFromPath("/d/react-18-legacy-id"), "react-18-legacy-id", "a hyphenated legacy fixed id");
+ assert.equal(demoIdFromPath("/api/demos/abc123"), "abc123");
+ assert.equal(demoIdFromPath("/api/mcp/demos/abc123"), "abc123");
+});
+
+// `validSessionId` is what `index.ts`'s per-request log line calls instead
+// of trusting the raw `x-hot-session` header. The `SESSION_ID_RE` check in
+// `telemetry/lines.ts#validSessionId` must reject an arbitrary header
+// value, not pass it through unchanged (`raw ?? ""`).
+test("validSessionId: accepts a real crypto.randomUUID() page-load id", () => {
+ assert.equal(validSessionId("3fa85f64-5717-4562-b3fc-2c963f66afa6"), "3fa85f64-5717-4562-b3fc-2c963f66afa6");
+});
+
+test("validSessionId: accepts the defensive plid-- fallback shape", () => {
+ assert.equal(validSessionId("plid-l3x9k2-9f2h1q"), "plid-l3x9k2-9f2h1q");
+});
+
+test("validSessionId: rejects null, empty, and arbitrary client-controlled values", () => {
+ assert.equal(validSessionId(null), "");
+ assert.equal(validSessionId(""), "");
+ assert.equal(validSessionId("not-a-real-session-id"), "");
+ assert.equal(validSessionId("'; DROP TABLE sessions;--"), "");
+ assert.equal(validSessionId("a".repeat(500)), "", "an implausibly long header value is rejected");
+});
+
+test("serviceEnvironment: production only under the real host", () => {
+ assert.equal(serviceEnvironment({ PREVIEW_HOST: "demos.handsontable.com" }), "production");
+ assert.equal(serviceEnvironment({ PREVIEW_HOST: "localhost:8787" }), "local");
+ assert.equal(serviceEnvironment({}), "local");
+});
+
+test("serviceEnvironment: the check is equality, not a prefix or a suffix test", () => {
+ // Same rule sentry-gate.ts's apiSentryDsn already pins, both directions: a
+ // `.startsWith(PRODUCTION_HOST)` relaxation would still fail this first
+ // case, and (measured, not assumed — this exact case did NOT catch a
+ // `.endsWith(PRODUCTION_HOST)` mutation on a first draft of this test,
+ // caught only by adding the second case) an `.endsWith(PRODUCTION_HOST)`
+ // relaxation would pass the first case but fail the second.
+ assert.equal(serviceEnvironment({ PREVIEW_HOST: "demos.handsontable.com.evil.test" }), "local");
+ assert.equal(serviceEnvironment({ PREVIEW_HOST: "evil-demos.handsontable.com" }), "local");
+});
+
+test("serviceVersion: SERVICE_VERSION wins, then CF_VERSION_METADATA, then a literal fallback", () => {
+ assert.equal(serviceVersion({ SERVICE_VERSION: "abc123" }), "abc123");
+ assert.equal(serviceVersion({ CF_VERSION_METADATA: { id: "v-id", tag: "" } }), "v-id");
+ assert.equal(serviceVersion({}), "dev");
+});
+
+test("serviceVersion: an empty --var (wrangler's own empty-string shape) still falls through", () => {
+ // `wrangler deploy --var SERVICE_VERSION:` yields "", exactly like
+ // SENTRY_ENVIRONMENT's documented empty-string case in sentry-gate.ts.
+ assert.equal(serviceVersion({ SERVICE_VERSION: "", CF_VERSION_METADATA: { id: "v-id", tag: "" } }), "v-id");
+});
+
+// ── contract §11: the Sentry scope switch's decision ─────────────────────────
+//
+// `diagnostic.ts#reportDiagnostic` gates its `Sentry.captureException` call
+// on this function's result
+// (`if (sentryScopeIsFull(env)) { Sentry.captureException(...) }` — read
+// directly in the source, since `diagnostic.ts` itself pulls in the real
+// `@sentry/cloudflare` package). This is the decision alone; a live
+// transport-spy integration check needs a running `wrangler dev`, where
+// Sentry deliberately never initialises at all (sentry-gate.ts's own
+// local-dev gate).
+
+test("sentryScopeIsFull: full (the default) is true", () => {
+ assert.equal(sentryScopeIsFull({ SENTRY_SCOPE: "full" }), true);
+});
+
+test("sentryScopeIsFull: uncaught is false", () => {
+ assert.equal(sentryScopeIsFull({ SENTRY_SCOPE: "uncaught" }), false);
+});
+
+test("sentryScopeIsFull: absent means full, exactly like leaving the wrangler.jsonc var out", () => {
+ assert.equal(sentryScopeIsFull({}), true);
+});
diff --git a/runner/pipeline/browser-metrics.test.mjs b/runner/pipeline/browser-metrics.test.mjs
new file mode 100644
index 0000000000..abc612e73b
--- /dev/null
+++ b/runner/pipeline/browser-metrics.test.mjs
@@ -0,0 +1,531 @@
+// Observability contract §5 browser metric catalogue (ADR-0041 §F.2).
+// Drives `apps/authoring/src/telemetry/metrics.ts` against a fake
+// `DemoRuntime` and a `recordingTelemetry()`, replaying every call through
+// the real `toAePoint` since `recordingTelemetry` validates nothing on its
+// own. Real timing hooks are exercised separately in
+// `sandpack-reload.test.mjs`/`session-start-failure.test.mjs`.
+// Build prerequisite: `pnpm --filter @handsontable/demo-runtime build`.
+
+import test from "node:test";
+import assert from "node:assert/strict";
+import { recordingTelemetry, toAePoint } from "../packages/runtime/dist/telemetry/index.js";
+import {
+ COMPILE_TIMING_SETTLE_MS,
+ emitBucketResolve,
+ emitVersionSwitch,
+ flushCompileTimings,
+ htMajorOf,
+ startClock,
+ trackPreviewReady,
+ wireRuntimeMetrics,
+} from "../apps/authoring/src/telemetry/metrics.ts";
+
+const SERVICE = { service_name: "demos-authoring", service_version: "abc123", environment: "production" };
+
+/** Replay every recorded `.metric()` call through the real `toAePoint` — the
+ * producer-contract check `recordingTelemetry` itself does not perform. Throws
+ * (failing the test) on a misspelled outcome, an attribute outside its metric's
+ * closed set, or an attribute with no AE slot. */
+function assertValidAgainstRegistry(telemetry) {
+ for (const { name, values, attrs } of telemetry.metrics) {
+ toAePoint(name, values, { ...SERVICE, ...attrs });
+ }
+}
+
+/** A minimal `DemoRuntime` stand-in: only `onReady`/`onError`, which is all
+ * `trackPreviewReady` reads. Exposes `fireReady`/`fireError` for the test to
+ * drive it, matching the real runtimes' "replay ready to a late subscriber"
+ * behaviour is NOT modelled here on purpose — `trackPreviewReady` subscribes
+ * once, synchronously, before either engine could have already settled. */
+function fakeRuntime() {
+ const readyCbs = [];
+ const errorCbs = [];
+ return {
+ onReady(cb) {
+ readyCbs.push(cb);
+ },
+ onError(cb) {
+ errorCbs.push(cb);
+ },
+ fireReady() {
+ for (const cb of readyCbs) cb();
+ },
+ fireError(e) {
+ for (const cb of errorCbs) cb(e);
+ },
+ };
+}
+
+const CTX = { surface: "authoring", tier: 1, framework: "react", versionRef: "18.1.0", bucket: "18.1" };
+
+test("preview.ready_ms: one point on ready, with the right attributes", () => {
+ const telemetry = recordingTelemetry();
+ const runtime = fakeRuntime();
+ trackPreviewReady(runtime, CTX, telemetry, { timeoutMs: 50_000 });
+
+ runtime.fireReady();
+
+ assert.equal(telemetry.metrics.length, 1);
+ const [call] = telemetry.metrics;
+ assert.equal(call.name, "preview.ready_ms");
+ assert.equal(call.attrs.outcome, "ready");
+ assert.equal(call.attrs.surface, "authoring");
+ assert.equal(call.attrs.tier, "1");
+ assert.equal(call.attrs.framework, "react");
+ assert.equal(call.attrs.ht_major, "18");
+ assert.equal(call.attrs.bucket, "18.1");
+ assert.ok(typeof call.values.duration_ms === "number" && call.values.duration_ms >= 0);
+ assertValidAgainstRegistry(telemetry);
+});
+
+test("preview.ready_ms: a second onReady (a later recompile) does not re-emit", () => {
+ const telemetry = recordingTelemetry();
+ const runtime = fakeRuntime();
+ trackPreviewReady(runtime, CTX, telemetry, { timeoutMs: 50_000 });
+
+ runtime.fireReady();
+ runtime.fireReady();
+ runtime.fireReady();
+
+ assert.equal(telemetry.metrics.length, 1, "guard: settled must latch after the first ready");
+});
+
+test("preview.ready_ms: abandon() before ready emits outcome abandoned", () => {
+ const telemetry = recordingTelemetry();
+ const runtime = fakeRuntime();
+ const tracker = trackPreviewReady(runtime, CTX, telemetry, { timeoutMs: 50_000 });
+
+ tracker.abandon();
+
+ assert.equal(telemetry.metrics.length, 1);
+ assert.equal(telemetry.metrics[0].attrs.outcome, "abandoned");
+ assertValidAgainstRegistry(telemetry);
+});
+
+test("preview.ready_ms: abandon() after ready is a no-op (no second point, no outcome flip)", () => {
+ const telemetry = recordingTelemetry();
+ const runtime = fakeRuntime();
+ const tracker = trackPreviewReady(runtime, CTX, telemetry, { timeoutMs: 50_000 });
+
+ runtime.fireReady();
+ tracker.abandon();
+
+ assert.equal(telemetry.metrics.length, 1, "guard: abandon() must not fire once ready already settled");
+ assert.equal(telemetry.metrics[0].attrs.outcome, "ready");
+});
+
+test("preview.ready_ms: a mount() rejection observed via observe() reports outcome error, not abandoned", async () => {
+ // The case that motivates observe() at all (DEV-2130 / ContainerRuntime's
+ // dispose()-before-rethrow): a rejection that never reaches onError.
+ const telemetry = recordingTelemetry();
+ const runtime = fakeRuntime();
+ const tracker = trackPreviewReady(runtime, CTX, telemetry, { timeoutMs: 50_000 });
+
+ const mountPromise = Promise.reject(new Error("Setup failed"));
+ tracker.observe(mountPromise);
+ await mountPromise.catch(() => {});
+ // Let the .catch() microtask inside trackPreviewReady settle too.
+ await Promise.resolve();
+
+ assert.equal(telemetry.metrics.length, 1);
+ assert.equal(telemetry.metrics[0].attrs.outcome, "error");
+
+ // A cleanup that runs after the rejection (the effect unmounting, or a version
+ // switch) must not turn this into "abandoned" — the guard is what this proves.
+ tracker.abandon();
+ assert.equal(telemetry.metrics.length, 1, "guard: observe()'s error must latch before abandon() can fire");
+ assertValidAgainstRegistry(telemetry);
+});
+
+test("preview.ready_ms: onError reports outcome error", () => {
+ const telemetry = recordingTelemetry();
+ const runtime = fakeRuntime();
+ trackPreviewReady(runtime, CTX, telemetry, { timeoutMs: 50_000 });
+
+ runtime.fireError(new Error("boom"));
+
+ assert.equal(telemetry.metrics.length, 1);
+ assert.equal(telemetry.metrics[0].attrs.outcome, "error");
+});
+
+test("preview.ready_ms: a timeout reports outcome timeout, and a later ready is ignored", async () => {
+ const telemetry = recordingTelemetry();
+ const runtime = fakeRuntime();
+ trackPreviewReady(runtime, CTX, telemetry, { timeoutMs: 5 });
+
+ await new Promise((resolve) => setTimeout(resolve, 40));
+ assert.equal(telemetry.metrics.length, 1);
+ assert.equal(telemetry.metrics[0].attrs.outcome, "timeout");
+
+ runtime.fireReady();
+ assert.equal(telemetry.metrics.length, 1, "guard: a ready arriving after the timeout must not re-emit");
+});
+
+test("preview.ready_ms: a version with no ref attached reads ht_major as none", () => {
+ const telemetry = recordingTelemetry();
+ const runtime = fakeRuntime();
+ trackPreviewReady(runtime, { ...CTX, versionRef: "" }, telemetry, { timeoutMs: 50_000 });
+
+ runtime.fireReady();
+
+ assert.equal(telemetry.metrics[0].attrs.ht_major, "none");
+});
+
+// ---- htMajorOf --------------------------------------------------------------------
+
+test("htMajorOf reads a release version's major, a next prerelease as next, and a pkg.pr.new ref as next", () => {
+ assert.equal(htMajorOf("18.1.0"), "18");
+ assert.equal(htMajorOf("19.0.0-next.1"), "next");
+ assert.equal(htMajorOf("0.0.0-next-abc123-20260101"), "next");
+ assert.equal(htMajorOf("https://pkg.pr.new/handsontable/handsontable@7940"), "next");
+ assert.equal(htMajorOf(null), "none");
+ assert.equal(htMajorOf(undefined), "none");
+});
+
+// ---- sandpack.compile_ms / compile_error / bundler_unreachable --------------------
+
+function fakeSandpackRuntime() {
+ const compileTimingCbs = [];
+ const compileErrorCbs = [];
+ const bundlerUnreachableCbs = [];
+ return {
+ onCompileTiming(cb) {
+ compileTimingCbs.push(cb);
+ },
+ onCompileError(cb) {
+ compileErrorCbs.push(cb);
+ },
+ onBundlerUnreachable(cb) {
+ bundlerUnreachableCbs.push(cb);
+ },
+ fireCompileTiming(e) {
+ for (const cb of compileTimingCbs) cb(e);
+ },
+ fireCompileError(e) {
+ for (const cb of compileErrorCbs) cb(e);
+ },
+ fireBundlerUnreachable(e) {
+ for (const cb of bundlerUnreachableCbs) cb(e);
+ },
+ };
+}
+
+const SANDPACK_CTX = { framework: "vue", versionRef: "17.1.0" };
+
+test("sandpack.compile_ms: reports the hook's own duration and outcome, tier fixed to 1", () => {
+ const telemetry = recordingTelemetry();
+ const runtime = fakeSandpackRuntime();
+ const clock = manualTimers();
+ wireRuntimeMetrics(runtime, SANDPACK_CTX, telemetry, clock);
+
+ runtime.fireCompileTiming({ durationMs: 900, outcome: "ok" }); // the mount
+ runtime.fireCompileTiming({ durationMs: 123, outcome: "ok" });
+ clock.advance(COMPILE_TIMING_SETTLE_MS);
+
+ assert.equal(telemetry.metrics.length, 1);
+ const [call] = telemetry.metrics;
+ assert.equal(call.name, "sandpack.compile_ms");
+ assert.equal(call.values.duration_ms, 123);
+ assert.equal(call.attrs.tier, "1");
+ assert.equal(call.attrs.outcome, "ok");
+ assert.equal(call.attrs.ht_major, "17");
+ assertValidAgainstRegistry(telemetry);
+});
+
+/** A manual clock for the held compile point: `advance(ms)` fires what is due. */
+function manualTimers() {
+ let now = 0;
+ const timers = new Map();
+ let nextId = 1;
+ return {
+ setTimer(fn, ms) {
+ const id = nextId++;
+ timers.set(id, { fn, at: now + ms });
+ return id;
+ },
+ clearTimer(id) {
+ timers.delete(id);
+ },
+ advance(ms) {
+ now += ms;
+ for (const [id, t] of [...timers]) {
+ if (t.at <= now) {
+ timers.delete(id);
+ t.fn();
+ }
+ }
+ },
+ };
+}
+
+const compileTimes = (telemetry) =>
+ telemetry.metrics.filter((m) => m.name === "sandpack.compile_ms").map((m) => [m.values.duration_ms, m.attrs.outcome]);
+
+test("sandpack.compile_ms: the compiles of one edit burst send one point, the burst's last", () => {
+ const telemetry = recordingTelemetry();
+ const runtime = fakeSandpackRuntime();
+ const clock = manualTimers();
+ wireRuntimeMetrics(runtime, SANDPACK_CTX, telemetry, clock);
+
+ runtime.fireCompileTiming({ durationMs: 900, outcome: "ok" }); // the mount
+ for (let i = 1; i <= 20; i += 1) {
+ runtime.fireCompileTiming({ durationMs: 100 + i, outcome: i === 7 ? "error" : "ok" });
+ clock.advance(200); // one keystroke's compile every 200 ms
+ }
+ assert.deepEqual(compileTimes(telemetry), [], "the mount sends nothing, and the burst is held while compiles keep coming");
+
+ clock.advance(COMPILE_TIMING_SETTLE_MS);
+ assert.deepEqual(compileTimes(telemetry), [[120, "ok"]]);
+ assertValidAgainstRegistry(telemetry);
+
+ runtime.fireCompileTiming({ durationMs: 77, outcome: "error" });
+ clock.advance(COMPILE_TIMING_SETTLE_MS);
+ assert.deepEqual(compileTimes(telemetry).at(-1), [77, "error"], "a later burst gets its own point");
+ assert.equal(compileTimes(telemetry).length, 2);
+});
+
+test("sandpack.compile_ms: keystrokes that fail to transpile keep the burst open", () => {
+ const telemetry = recordingTelemetry();
+ const runtime = fakeSandpackRuntime();
+ const clock = manualTimers();
+ wireRuntimeMetrics(runtime, SANDPACK_CTX, telemetry, clock);
+
+ runtime.fireCompileTiming({ durationMs: 900, outcome: "ok" });
+ runtime.fireCompileTiming({ durationMs: 31, outcome: "ok" });
+ for (let i = 0; i < 5; i += 1) {
+ clock.advance(1500);
+ runtime.fireCompileError({ message: "Unexpected token (1:7)", origin: "transpile" });
+ }
+ clock.advance(1500);
+ runtime.fireCompileTiming({ durationMs: 42, outcome: "ok" });
+ clock.advance(COMPILE_TIMING_SETTLE_MS);
+ assert.deepEqual(compileTimes(telemetry), [[42, "ok"]]);
+});
+
+test("sandpack.compile_ms: a held point is sent at once when the page is hidden, and only once", () => {
+ const telemetry = recordingTelemetry();
+ const runtime = fakeSandpackRuntime();
+ const clock = manualTimers();
+ wireRuntimeMetrics(runtime, SANDPACK_CTX, telemetry, clock);
+
+ runtime.fireCompileTiming({ durationMs: 900, outcome: "ok" });
+ runtime.fireCompileTiming({ durationMs: 55, outcome: "ok" });
+ flushCompileTimings();
+ assert.deepEqual(compileTimes(telemetry), [[55, "ok"]]);
+
+ clock.advance(COMPILE_TIMING_SETTLE_MS);
+ flushCompileTimings();
+ assert.equal(compileTimes(telemetry).length, 1);
+});
+
+test("sandpack.compile_ms: the mount's compile sends no point, whatever its outcome", () => {
+ for (const outcome of ["ok", "error"]) {
+ const telemetry = recordingTelemetry();
+ const runtime = fakeSandpackRuntime();
+ const clock = manualTimers();
+ wireRuntimeMetrics(runtime, SANDPACK_CTX, telemetry, clock);
+
+ runtime.fireCompileTiming({ durationMs: 900, outcome });
+ clock.advance(COMPILE_TIMING_SETTLE_MS * 5);
+ flushCompileTimings();
+ assert.deepEqual(compileTimes(telemetry), [], outcome);
+ }
+});
+
+test("sandpack.compile_ms: after a mount that failed the pre-transpile, the first compile is an edit's", () => {
+ const telemetry = recordingTelemetry();
+ const runtime = fakeSandpackRuntime();
+ const clock = manualTimers();
+ wireRuntimeMetrics(runtime, SANDPACK_CTX, telemetry, clock);
+
+ runtime.fireCompileError({ message: "Unexpected token (1:7)", origin: "transpile" });
+ runtime.fireCompileTiming({ durationMs: 64, outcome: "ok" });
+ clock.advance(COMPILE_TIMING_SETTLE_MS);
+ assert.deepEqual(compileTimes(telemetry), [[64, "ok"]]);
+});
+
+test("sandpack.compile_error: fingerprinted, no authored text in the recorded attrs", () => {
+ const telemetry = recordingTelemetry();
+ const runtime = fakeSandpackRuntime();
+ wireRuntimeMetrics(runtime, SANDPACK_CTX, telemetry);
+
+ const message = "SyntaxError: Unexpected token (2:7) in /src/App.vue";
+ runtime.fireCompileError({ message });
+
+ assert.equal(telemetry.metrics.length, 1);
+ const [call] = telemetry.metrics;
+ assert.equal(call.name, "sandpack.compile_error");
+ assert.match(call.attrs.fingerprint, /^sandpack\.compile_error:[0-9a-f]{16}$/);
+ // `HotAttrs` has no free-text field, so the message itself cannot travel even by
+ // accident — asserted anyway, against every attr value, as the guard for it.
+ for (const value of Object.values(call.attrs)) {
+ assert.ok(!String(value).includes("Unexpected token"), "no authored code in the recorded attrs");
+ }
+ assertValidAgainstRegistry(telemetry);
+});
+
+test("sandpack.compile_error: the same fingerprint (a keystroke ladder) reports once, not once per keystroke", () => {
+ const telemetry = recordingTelemetry();
+ const runtime = fakeSandpackRuntime();
+ wireRuntimeMetrics(runtime, SANDPACK_CTX, telemetry);
+
+ runtime.fireCompileError({ message: "'t' is not defined" });
+ runtime.fireCompileError({ message: "'tr' is not defined" });
+ runtime.fireCompileError({ message: "'tru' is not defined" });
+
+ assert.equal(
+ telemetry.metrics.length,
+ 1,
+ "guard: the demo-runtime ladder collapse must dedupe these to one fingerprint",
+ );
+});
+
+test("sandpack.compile_error: a genuinely different message gets its own point", () => {
+ const telemetry = recordingTelemetry();
+ const runtime = fakeSandpackRuntime();
+ wireRuntimeMetrics(runtime, SANDPACK_CTX, telemetry);
+
+ runtime.fireCompileError({ message: "'t' is not defined" });
+ runtime.fireCompileError({ message: "Unexpected token }" });
+
+ assert.equal(telemetry.metrics.length, 2);
+ assert.notEqual(telemetry.metrics[0].attrs.fingerprint, telemetry.metrics[1].attrs.fingerprint);
+});
+
+test("sandpack.bundler_unreachable: reports duration, ht_major only", () => {
+ const telemetry = recordingTelemetry();
+ const runtime = fakeSandpackRuntime();
+ wireRuntimeMetrics(runtime, SANDPACK_CTX, telemetry);
+
+ runtime.fireBundlerUnreachable({ durationMs: 9001 });
+
+ assert.equal(telemetry.metrics.length, 1);
+ const [call] = telemetry.metrics;
+ assert.equal(call.name, "sandpack.bundler_unreachable");
+ assert.equal(call.values.duration_ms, 9001);
+ assert.equal(call.attrs.ht_major, "17");
+ assertValidAgainstRegistry(telemetry);
+});
+
+// ---- session.start_ms / hmr.roundtrip_ms -----------------------------------------
+
+function fakeContainerRuntime() {
+ const sessionStartCbs = [];
+ const hmrCbs = [];
+ return {
+ onSessionStart(cb) {
+ sessionStartCbs.push(cb);
+ },
+ onHmr(cb) {
+ hmrCbs.push(cb);
+ },
+ fireSessionStart(e) {
+ for (const cb of sessionStartCbs) cb(e);
+ },
+ fireHmr(e) {
+ for (const cb of hmrCbs) cb(e);
+ },
+ };
+}
+
+const CONTAINER_CTX = { framework: "next", versionRef: "18.2.0" };
+
+test("session.start_ms: reports elapsed/outcome, and never sets reason (no cold/warm signal exists)", () => {
+ const telemetry = recordingTelemetry();
+ const runtime = fakeContainerRuntime();
+ wireRuntimeMetrics(runtime, CONTAINER_CTX, telemetry);
+
+ runtime.fireSessionStart({ elapsedMs: 4200, outcome: "ready" });
+
+ assert.equal(telemetry.metrics.length, 1);
+ const [call] = telemetry.metrics;
+ assert.equal(call.name, "session.start_ms");
+ assert.equal(call.values.duration_ms, 4200);
+ assert.equal(call.attrs.outcome, "ready");
+ assert.equal(call.attrs.reason, undefined, "guard: reason must stay unset, not a guessed cold/warm");
+ assertValidAgainstRegistry(telemetry);
+});
+
+test("session.start_ms: every session.start outcome value round-trips through toAePoint", () => {
+ const telemetry = recordingTelemetry();
+ const runtime = fakeContainerRuntime();
+ wireRuntimeMetrics(runtime, CONTAINER_CTX, telemetry);
+
+ for (const outcome of ["ready", "at_capacity", "container_starting", "boot_timeout", "budget_denied", "error"]) {
+ runtime.fireSessionStart({ elapsedMs: 1, outcome });
+ }
+
+ assert.equal(telemetry.metrics.length, 6);
+ assertValidAgainstRegistry(telemetry);
+});
+
+test("hmr.roundtrip_ms: reports the hook's own duration", () => {
+ const telemetry = recordingTelemetry();
+ const runtime = fakeContainerRuntime();
+ wireRuntimeMetrics(runtime, CONTAINER_CTX, telemetry);
+
+ runtime.fireHmr({ durationMs: 87 });
+
+ assert.equal(telemetry.metrics.length, 1);
+ const [call] = telemetry.metrics;
+ assert.equal(call.name, "hmr.roundtrip_ms");
+ assert.equal(call.values.duration_ms, 87);
+ assert.equal(call.attrs.framework, "next");
+ assert.equal(call.attrs.ht_major, "18");
+ assertValidAgainstRegistry(telemetry);
+});
+
+// ---- version.switch / bucket.resolve_ms ------------------------------------------
+
+test("version.switch: both to- and from-version go through the ht_major closed set, not the raw ref", () => {
+ const telemetry = recordingTelemetry();
+ emitVersionSwitch(telemetry, { framework: "react", toRef: "18.1.0", fromRef: "17.1.0", bucket: "18.1" });
+
+ assert.equal(telemetry.metrics.length, 1);
+ const [call] = telemetry.metrics;
+ assert.equal(call.name, "version.switch");
+ assert.equal(call.attrs.ht_major, "18");
+ assert.equal(call.attrs.reason, "17");
+ assert.equal(call.attrs.bucket, "18.1");
+ assertValidAgainstRegistry(telemetry);
+});
+
+test("version.switch: a pkg.pr.new fromRef never lands raw in the reason blob (guard against unbounded AE data)", () => {
+ const telemetry = recordingTelemetry();
+ emitVersionSwitch(telemetry, {
+ framework: "react",
+ toRef: "18.1.0",
+ fromRef: "https://pkg.pr.new/handsontable/handsontable@7940",
+ });
+
+ assert.equal(telemetry.metrics[0].attrs.reason, "next");
+ assertValidAgainstRegistry(telemetry);
+});
+
+test("version.switch: an absent fromRef reads reason as none", () => {
+ const telemetry = recordingTelemetry();
+ emitVersionSwitch(telemetry, { framework: "react", toRef: "18.1.0" });
+
+ assert.equal(telemetry.metrics[0].attrs.reason, "none");
+ assertValidAgainstRegistry(telemetry);
+});
+
+test("bucket.resolve_ms: ok and error outcomes both round-trip", () => {
+ const telemetry = recordingTelemetry();
+ emitBucketResolve(telemetry, { bucket: "18.1", outcome: "ok", durationMs: 12 });
+ emitBucketResolve(telemetry, { bucket: "18.1", outcome: "error", durationMs: 34 });
+
+ assert.equal(telemetry.metrics.length, 2);
+ assert.equal(telemetry.metrics[0].values.duration_ms, 12);
+ assert.equal(telemetry.metrics[1].attrs.outcome, "error");
+ assertValidAgainstRegistry(telemetry);
+});
+
+// ---- startClock ---------------------------------------------------------------
+
+test("startClock reports elapsed time against an injected clock", () => {
+ let now = 1000;
+ const elapsed = startClock(() => now);
+ now = 1042;
+ assert.equal(elapsed(), 42);
+});
diff --git a/runner/pipeline/chat-answer-network-error.test.mjs b/runner/pipeline/chat-answer-network-error.test.mjs
new file mode 100644
index 0000000000..64d5d902a5
--- /dev/null
+++ b/runner/pipeline/chat-answer-network-error.test.mjs
@@ -0,0 +1,88 @@
+// A network-level throw from the LiteLLM fetch in `/api/chat` must not
+// vanish: `requestAnswer` (chat.ts) can reject with a real `TypeError` (DNS,
+// refused, reset — never a `ChatUnavailableError`), and the route's catch
+// must emit the `chat.answer` point for that throw too, not only inside its
+// `if (err instanceof ChatUnavailableError)` branch — contract §5 promises a
+// point on every outcome, `error` included, matching `theme.ai`'s twin catch.
+//
+// Driven through the real router (`workers/api/src/index.ts`'s default
+// export) — a re-declared copy of the catch would not catch this regressing.
+// Run: node --experimental-strip-types --test pipeline/chat-answer-network-error.test.mjs
+
+import test from "node:test";
+import assert from "node:assert/strict";
+import { register } from "node:module";
+import { ctx, makeEnv } from "./fixtures/worker-harness.mjs";
+
+register("./fixtures/worker-hooks.mjs", import.meta.url);
+
+const { default: worker } = await import("../workers/api/src/index.ts");
+
+const HOST = "https://demos.handsontable.com";
+
+// blob/double slot positions, §4 (AE_COLUMNS, packages/runtime/src/telemetry/metrics.ts):
+// model=blob13 (index 12), outcome=blob8 (index 7), count=double1 (index 0).
+const MODEL_SLOT = 12;
+const OUTCOME_SLOT = 7;
+const COUNT_SLOT = 0;
+
+function envWithPointCapture() {
+ const points = [];
+ const { env, ...rest } = makeEnv();
+ env.RUNNER_EVENTS = { writeDataPoint: (p) => points.push(p) };
+ // Routes AE writes through the in-memory sink instead of the local-ClickHouse
+ // HTTP fallback `serviceEnvironment` selects for a non-production host — see
+ // the identical comment on `snapshot-build-point.test.mjs`'s own helper.
+ env.PREVIEW_HOST = "demos.handsontable.com";
+ env.LITELLM_API_KEY = "test-key";
+ return { env, points, ...rest };
+}
+
+function chatAnswerPoints(points) {
+ return points.filter((p) => p.indexes[0] === "chat.answer");
+}
+
+test("a network-level throw from the LiteLLM fetch still emits chat.answer outcome=error", async () => {
+ const { env, points } = envWithPointCapture();
+
+ const realFetch = globalThis.fetch;
+ globalThis.fetch = async (input) => {
+ const url = typeof input === "string" ? input : input.url;
+ if (url.includes("litellm")) {
+ // The exact failure mode this test exists for: `fetch()` itself
+ // rejects (connection refused / DNS / reset), never resolving to a
+ // Response — so `requestAnswer`'s `if (!res.ok)` branch (the one that
+ // throws `ChatUnavailableError`) is never reached at all.
+ throw new TypeError("fetch failed: network connection lost");
+ }
+ throw new Error(`unexpected network fetch in chat-answer-network-error.test.mjs: ${url}`);
+ };
+
+ try {
+ const res = await worker.fetch(
+ new Request(`${HOST}/api/chat`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ messages: [{ role: "user", content: "what does this option do?" }],
+ framework: "react",
+ files: { "App.jsx": "export default function App() { return null; }" },
+ }),
+ }),
+ env,
+ ctx,
+ );
+ // The raw throw is not a ChatUnavailableError, so it still falls through
+ // to the generic fetch catch-all (a 500) — that part of the behaviour is
+ // pre-existing and out of scope here. The point is what to fix.
+ assert.equal(res.status, 500);
+
+ const points_ = chatAnswerPoints(points);
+ assert.equal(points_.length, 1, `expected exactly 1 chat.answer point, got ${points_.length}`);
+ assert.equal(points_[0].blobs[OUTCOME_SLOT], "error");
+ assert.equal(points_[0].blobs[MODEL_SLOT], "unknown");
+ assert.equal(points_[0].doubles[COUNT_SLOT], 1);
+ } finally {
+ globalThis.fetch = realFetch;
+ }
+});
diff --git a/runner/pipeline/ci-workflow.test.mjs b/runner/pipeline/ci-workflow.test.mjs
new file mode 100644
index 0000000000..3ec4498506
--- /dev/null
+++ b/runner/pipeline/ci-workflow.test.mjs
@@ -0,0 +1,69 @@
+// Structural pins for `ci.yml`'s `e2e-telemetry` job: the telemetry leak
+// checks and actionlint must run in PR CI, not only post-merge, plus the
+// negative control that proves `check:telemetry-leak` can actually fail.
+// Same rationale/pattern as `pipeline/master-workflow.test.mjs`'s
+// structural pins on `master.yml`.
+// Run: node --experimental-strip-types --test pipeline/ci-workflow.test.mjs
+
+import test from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const workflowPath = join(__dirname, "..", "..", ".github", "workflows", "ci.yml");
+const source = readFileSync(workflowPath, "utf8");
+
+function jobBody(name) {
+ const start = source.indexOf(`\n ${name}:`);
+ assert.ok(start > -1, `the ${name} job must exist`);
+ // Next top-level job starts at a line beginning with exactly two spaces
+ // then a word then ':' — find the next such line after this job's header.
+ const rest = source.slice(start + 1);
+ const nextMatch = /\n [a-zA-Z0-9_-]+:\n/.exec(rest.slice(1));
+ return nextMatch ? rest.slice(0, nextMatch.index + 1) : rest;
+}
+
+const e2eTelemetry = jobBody("e2e-telemetry");
+
+test("ci.yml: e2e-telemetry runs actionlint", () => {
+ // B-4: matching bare /actionlint/i passes even if the real step is
+ // deleted, because the step's own preceding comment ("actionlint here
+ // too, so a workflow-YAML mistake...") also contains the word
+ // "actionlint" — this only pins the STEP itself (its `uses:` line),
+ // which a comment can never satisfy.
+ assert.match(
+ e2eTelemetry,
+ /uses:\s*reviewdog\/action-actionlint@v1/,
+ "the job must run the reviewdog/action-actionlint step, not just mention it in a comment",
+ );
+});
+
+test("ci.yml: e2e-telemetry builds a production-mode bundle and runs both leak checks against it, before building the flag bundle", () => {
+ const prodBuildIdx = e2eTelemetry.indexOf("Build authoring in production mode");
+ assert.ok(prodBuildIdx > -1, "a production-mode build step must exist");
+
+ const devBypassIdx = e2eTelemetry.indexOf("dev-login bypass must not reach the production bundle");
+ assert.ok(devBypassIdx > prodBuildIdx, "the AGENTS.md dev-bypass leak check must run after the production build");
+
+ const telemetryLeakIdx = e2eTelemetry.indexOf("pnpm check:telemetry-leak");
+ assert.ok(telemetryLeakIdx > prodBuildIdx, "check:telemetry-leak must run against the production build");
+
+ const flagBuildIdx = e2eTelemetry.indexOf("VITE_TELEMETRY_LOCAL: '1'");
+ assert.ok(flagBuildIdx > telemetryLeakIdx, "the flag build must come after the production-mode leak checks");
+});
+
+test("ci.yml: e2e-telemetry has a negative control asserting check:telemetry-leak FAILS against the flag build", () => {
+ const flagBuildIdx = e2eTelemetry.indexOf("VITE_TELEMETRY_LOCAL: '1'");
+ const negativeControlIdx = e2eTelemetry.indexOf("Leak check negative control");
+ assert.ok(negativeControlIdx > flagBuildIdx, "the negative control step must run after the flag build");
+
+ const stepEnd = e2eTelemetry.indexOf("\n - name:", negativeControlIdx + 1);
+ const step = e2eTelemetry.slice(negativeControlIdx, stepEnd > -1 ? stepEnd : undefined);
+ assert.match(
+ step,
+ /if pnpm check:telemetry-leak; then[\s\S]*exit 1/,
+ "the negative control must fail the job (exit 1) if check:telemetry-leak PASSES against the flag build",
+ );
+});
diff --git a/runner/pipeline/container-boot-ms.test.mjs b/runner/pipeline/container-boot-ms.test.mjs
new file mode 100644
index 0000000000..900faf33da
--- /dev/null
+++ b/runner/pipeline/container-boot-ms.test.mjs
@@ -0,0 +1,165 @@
+// `container.boot_ms` must be emitted from `POST /api/session`'s own
+// create path, not only for `outcome: "window_exceeded"` from a DO fetch
+// override reachable on a later proxied preview request — otherwise the
+// `tier2-sessions` dashboard panel ("container.boot_ms p95 by outcome")
+// stays permanently empty even on a healthy deploy, since its
+// `blob6 IN (${framework:sqlstring})` filter has nothing to match when
+// every point carries `blob6 = ''`.
+//
+// This file pins the create path's two outcomes — `ready` (the
+// `withSpan("container.boot", …)` block resolves) and `error` (it throws,
+// past the `at_capacity`/`container_starting` refusals, which already have
+// their own `session.start` outcome and must not be double-counted here) —
+// and that a refusal or an early throw emits neither.
+//
+// Route-tested with the same harness `session-create-container-starting
+// .test.mjs` established for `POST /api/session`. `container.boot_ms`
+// requires flipping `getSink()` to its `bindingSink` branch (see
+// `pipeline/lite-inject.test.mjs#makeCountingEnv`'s own doc comment):
+// `worker-harness.mjs#makeEnv` otherwise routes Analytics Engine points at
+// a local ClickHouse HTTP fetch with nothing listening, which `emitPoint`
+// — by design — swallows on failure.
+
+import test from "node:test";
+import assert from "node:assert/strict";
+import { register } from "node:module";
+
+import { ctx, makeEnv } from "./fixtures/worker-harness.mjs";
+import { setSandboxFactory } from "./fixtures/cloudflare-sandbox-stub.mjs";
+
+register("./fixtures/worker-hooks.mjs", import.meta.url);
+
+const { default: worker } = await import("../workers/api/src/index.ts");
+
+const FILES = {
+ "package.json": JSON.stringify({ name: "demo" }),
+ "src/App.jsx": "export default function App() { return null; }",
+};
+
+const sessionRequest = (body) =>
+ new Request("https://demos.handsontable.com/api/session", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+
+/** See `pipeline/lite-inject.test.mjs#makeCountingEnv`: flips `getSink()` to
+ * its `bindingSink` branch (a real `RUNNER_EVENTS` fake) instead of the
+ * local-mode ClickHouse HTTP fetch, which has nothing to talk to here. */
+function countingEnv() {
+ const { env } = makeEnv();
+ const points = [];
+ env.RUNNER_EVENTS = { writeDataPoint: (p) => points.push(p) };
+ env.PREVIEW_HOST = "demos.handsontable.com";
+ return { env, points };
+}
+
+const bootPoints = (points) => points.filter((p) => p.indexes[0] === "container.boot_ms");
+
+function fakeSandbox({ startProcessError, mkdirError, writeFileError } = {}) {
+ const calls = { mkdir: 0, writeFile: 0, startProcess: 0, exposePort: 0, setFramework: 0 };
+ const frameworks = [];
+ return {
+ calls,
+ frameworks,
+ async mkdir() {
+ calls.mkdir += 1;
+ if (mkdirError) throw mkdirError;
+ },
+ async writeFile() {
+ calls.writeFile += 1;
+ if (writeFileError) throw writeFileError;
+ },
+ deleteFile: async () => {},
+ exec: async () => ({ success: true, stdout: "", stderr: "" }),
+ async startProcess() {
+ calls.startProcess += 1;
+ if (startProcessError) throw startProcessError;
+ },
+ async exposePort() {
+ calls.exposePort += 1;
+ return { url: "https://preview.test/session" };
+ },
+ async setFramework(framework) {
+ calls.setFramework += 1;
+ frameworks.push(framework);
+ },
+ destroy: async () => {},
+ };
+}
+
+test("the happy path emits exactly one container.boot_ms ready, with the framework", async () => {
+ const { env, points } = countingEnv();
+ const sandbox = fakeSandbox();
+ setSandboxFactory(() => sandbox);
+
+ const res = await worker.fetch(sessionRequest({ framework: "react-js", files: FILES }), env, ctx);
+ assert.equal(res.status, 200);
+
+ const boot = bootPoints(points);
+ assert.equal(boot.length, 1, "expected exactly one container.boot_ms point");
+ assert.equal(boot[0].blobs[7], "ready", "blob8 outcome");
+ assert.equal(boot[0].blobs[5], "react-js", "blob6 framework — what the tier2-sessions panel filters on");
+ assert.ok(boot[0].doubles[1] >= 0, "double2 duration_ms");
+});
+
+test("a startProcess throw emits exactly one container.boot_ms error, with the framework", async () => {
+ const { env, points } = countingEnv();
+ const sandbox = fakeSandbox({ startProcessError: new Error("boom: disk full") });
+ setSandboxFactory(() => sandbox);
+
+ const res = await worker.fetch(sessionRequest({ framework: "react-js", files: FILES }), env, ctx);
+ assert.equal(res.status, 500, "an unrecognised throw is not degraded");
+
+ const boot = bootPoints(points);
+ assert.equal(boot.length, 1, "expected exactly one container.boot_ms point");
+ assert.equal(boot[0].blobs[7], "error", "blob8 outcome");
+ assert.equal(boot[0].blobs[5], "react-js", "blob6 framework");
+});
+
+test("a throw before the boot span even starts (writeFiles) emits no container.boot_ms point", async () => {
+ const { env, points } = countingEnv();
+ // `writeFile` itself throws — still upstream of
+ // `bootStartedAt = Date.now()` / `withSpan("container.boot", …)`, which
+ // only run once every file write has succeeded.
+ const sandbox = fakeSandbox({ writeFileError: new Error("disk quota exceeded") });
+ setSandboxFactory(() => sandbox);
+
+ const res = await worker.fetch(sessionRequest({ framework: "react-js", files: FILES }), env, ctx);
+ assert.equal(res.status, 500);
+ assert.equal(sandbox.calls.startProcess, 0, "the boot span must never have started");
+
+ assert.equal(bootPoints(points).length, 0, "a pre-boot failure is not a container.boot_ms outcome");
+});
+
+test("a container-starting refusal emits session.start's own outcome, not a second container.boot_ms error", async () => {
+ const { env, points } = countingEnv();
+ // The SDK's own retry-exhausted 503, surfaced through mkdir (same fixture
+ // shape as session-create-container-starting.test.mjs).
+ const sandbox = fakeSandbox({
+ mkdirError: new Error("Container is starting. Please retry in a moment."),
+ });
+ setSandboxFactory(() => sandbox);
+
+ const res = await worker.fetch(sessionRequest({ framework: "react-js", files: FILES }), env, ctx);
+ assert.equal(res.status, 503);
+
+ const sessionStart = points.filter((p) => p.indexes[0] === "session.start");
+ assert.equal(sessionStart.length, 1);
+ assert.equal(sessionStart[0].blobs[7], "container_starting");
+
+ // The at-capacity/container-starting refusals are classified from the
+ // create's OWN try, before `withSpan("container.boot", …)` — `bootStartedAt`
+ // is still null, so no `container.boot_ms` point of either outcome exists.
+ // Double-counting a refusal already carried by `session.start` would corrupt
+ // the panel's error rate.
+ assert.equal(bootPoints(points).length, 0, "container-starting must not also emit container.boot_ms");
+});
+
+// A budget denial (`budgetGate`) returns before `startSessionMeter`/
+// `writeFiles` even run — earlier than the mkdir/EACCES throw above, which
+// already proves the general invariant this depends on: `bootStartedAt` is
+// only ever set immediately before `withSpan("container.boot", …)`, so any
+// return/throw upstream of it — a denial included — emits no
+// `container.boot_ms` point of either outcome. Not pinned as its own case:
+// out of scope here.
diff --git a/runner/pipeline/demo-event-collapse.test.mjs b/runner/pipeline/demo-event-collapse.test.mjs
new file mode 100644
index 0000000000..8973bb9d8b
--- /dev/null
+++ b/runner/pipeline/demo-event-collapse.test.mjs
@@ -0,0 +1,477 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import {
+ createDemoEventCollapse,
+ DEMO_EDIT_SETTLE_MS,
+ DEMO_COLLAPSE_CEILING,
+} from "../apps/authoring/src/demoEventCollapse.ts";
+import { demoEventReport } from "../apps/authoring/src/demoEventReport.ts";
+import { fingerprint, fingerprintShape } from "../packages/runtime/dist/telemetry/index.js";
+
+// Build prerequisite: `pnpm --filter @handsontable/demo-runtime build` (for
+// the real §7 `fingerprint`/`fingerprintShape`, so the ladder below is keyed
+// exactly as `sentry.ts` keys it).
+//
+// Typing one throwing line into a Tier-1 editor must not relay ~20
+// `preview.runtime_error` points (one per half-typed prefix) — the collapse
+// turns that into one point per edit burst, while a non-edit error still
+// counts immediately.
+
+/** A hand-driven timer: `advance(ms)` fires whatever came due. */
+function fakeTimers() {
+ let now = 0;
+ let nextId = 1;
+ const timers = new Map();
+ return {
+ setTimer(fn, ms) {
+ const id = nextId++;
+ timers.set(id, { at: now + ms, fn });
+ return id;
+ },
+ clearTimer(id) {
+ timers.delete(id);
+ },
+ advance(ms) {
+ now += ms;
+ for (const [id, t] of [...timers]) {
+ if (t.at <= now) {
+ timers.delete(id);
+ t.fn();
+ }
+ }
+ },
+ pending: () => timers.size,
+ };
+}
+
+function harness(extra = {}) {
+ const clock = fakeTimers();
+ const emitted = [];
+ const collapse = createDemoEventCollapse({
+ emit: (item) => emitted.push(item),
+ setTimer: clock.setTimer,
+ clearTimer: clock.clearTimer,
+ ...extra,
+ });
+ /** Relay a message the way `sentry.ts` does: keyed by its real fingerprint. */
+ const relay = (message) => collapse.report(fingerprint("demo-runtime", message), message);
+ return { clock, emitted, collapse, relay };
+}
+
+// The real ladder shape for `setTimeout(() => { throw new Error('R5RUNTIME'); }, 100);`
+// typed one key at a time: several DIFFERENT fingerprints (a ReferenceError
+// ladder, syntax errors, the final throw), which is why fingerprint dedupe
+// alone cannot collapse it.
+const LADDER = [
+ "s is not defined",
+ "se is not defined",
+ "set is not defined",
+ "setT is not defined",
+ "setTi is not defined",
+ "setTim is not defined",
+ "setTime is not defined",
+ "setTimeo is not defined",
+ "setTimeou is not defined",
+ "Unexpected end of input",
+ "Unexpected token ')'",
+ "Unexpected end of input",
+ "Unterminated string constant",
+ "missing ) after argument list",
+ "Unexpected end of input",
+];
+const FINAL = "R5RUNTIME boom";
+
+test("a keystroke prefix ladder emits exactly one point: the error the finished line throws", () => {
+ const { clock, emitted, collapse, relay } = harness();
+ for (const message of LADDER) {
+ collapse.noteEdit();
+ clock.advance(120); // typing speed, well inside the settle window
+ relay(message);
+ }
+ collapse.noteEdit(); // the last keystroke
+ clock.advance(150);
+ relay(FINAL);
+ assert.equal(emitted.length, 0, "nothing is emitted while the user is still typing");
+ clock.advance(DEMO_EDIT_SETTLE_MS);
+ assert.deepEqual(emitted, [FINAL]);
+ // The distinct fingerprints above prove it is the burst rule, not fingerprint
+ // dedupe, that removed the rungs.
+ assert.ok(new Set(LADDER.map((m) => fingerprint("demo-runtime", m))).size > 3);
+});
+
+test("a ladder whose finished line is clean emits nothing", () => {
+ const { clock, emitted, collapse, relay } = harness();
+ for (const message of LADDER) {
+ collapse.noteEdit();
+ clock.advance(100);
+ relay(message);
+ }
+ collapse.noteEdit(); // the keystroke that completes a valid line: no error follows
+ clock.advance(DEMO_EDIT_SETTLE_MS * 3);
+ assert.deepEqual(emitted, []);
+});
+
+test("a first-load error (no edit) emits one point immediately, and repeats of it do not add more", () => {
+ const { clock, emitted, relay } = harness();
+ relay("Cannot read properties of undefined (reading 'getData')");
+ assert.equal(emitted.length, 1, "no settle wait outside an edit burst");
+ relay("Cannot read properties of undefined (reading 'getData')");
+ clock.advance(DEMO_EDIT_SETTLE_MS * 2);
+ relay("Cannot read properties of undefined (reading 'getData')");
+ assert.equal(emitted.length, 1);
+});
+
+test("two distinct persistent errors emit two points — one burst each", () => {
+ const { clock, emitted, collapse, relay } = harness();
+ collapse.noteEdit();
+ relay("first broken thing");
+ clock.advance(DEMO_EDIT_SETTLE_MS);
+ collapse.noteEdit();
+ relay("second broken thing");
+ clock.advance(DEMO_EDIT_SETTLE_MS);
+ assert.deepEqual(emitted, ["first broken thing", "second broken thing"]);
+});
+
+test("two distinct persistent errors from the same final run emit two points", () => {
+ const { clock, emitted, collapse, relay } = harness();
+ collapse.noteEdit();
+ relay("first broken thing");
+ relay("second broken thing");
+ relay("first broken thing"); // a re-render of the same fault
+ clock.advance(DEMO_EDIT_SETTLE_MS);
+ assert.deepEqual(emitted, ["first broken thing", "second broken thing"]);
+});
+
+test("a persistent error is counted once per edit burst, again after the next burst", () => {
+ const { clock, emitted, collapse, relay } = harness();
+ relay("still broken"); // first load
+ collapse.noteEdit(); // an edit elsewhere in the file, the fault survives it
+ relay("still broken");
+ clock.advance(DEMO_EDIT_SETTLE_MS);
+ relay("still broken"); // a click re-throwing it, same burst window
+ assert.equal(emitted.length, 2);
+});
+
+test("an error landing after the burst closed (a slow Tier-2 rebuild) is emitted immediately, once", () => {
+ const { clock, emitted, collapse, relay } = harness();
+ collapse.noteEdit();
+ clock.advance(DEMO_EDIT_SETTLE_MS + 5000);
+ relay("vite build failed");
+ relay("vite build failed");
+ assert.deepEqual(emitted, ["vite build failed"]);
+});
+
+test("reset counts the outgoing preview's last run and re-arms first-load counting", () => {
+ const { clock, emitted, collapse, relay } = harness();
+ relay("same shape"); // first load of example A
+ collapse.noteEdit();
+ relay("pending at switch");
+ collapse.reset(); // switch to example B before the burst closed
+ assert.deepEqual(emitted, ["same shape", "pending at switch"]);
+ assert.equal(clock.pending(), 0, "the settle timer is cancelled, not left to double-fire");
+ relay("same shape"); // example B's first load, same fingerprint as A's
+ assert.equal(emitted.length, 3);
+});
+
+test("reset alone (no edit in between) re-arms first-load counting for the next preview", () => {
+ const { emitted, collapse, relay } = harness();
+ relay("theme not found"); // example A's first load
+ relay("theme not found"); // A re-renders: same burst window, not counted again
+ collapse.reset(); // switch to example B
+ relay("theme not found"); // B's first load hits the same fault
+ assert.deepEqual(emitted, ["theme not found", "theme not found"]);
+});
+
+test("the ceiling bounds a demo posting ever-different payloads with no edit", () => {
+ const { emitted, relay } = harness();
+ for (let i = 0; i < DEMO_COLLAPSE_CEILING + 30; i++) relay(`crafted ${"x".repeat(i)}`);
+ assert.equal(emitted.length, DEMO_COLLAPSE_CEILING);
+});
+
+test("fingerprintShape is what fingerprint() hashes, so the Faro record and the metric agree", () => {
+ for (const message of [
+ ...LADDER,
+ FINAL,
+ "Cannot read properties of undefined (reading 'getData') at https://x.test/a.js?t=1",
+ "Invalid language tag: zh-c",
+ "hot.getData is not a function",
+ "Unexpected token (2:11)\n 1 | function f() {\n> 2 | return x +;\n | ^\n 3 | }",
+ ]) {
+ const shape = fingerprintShape(message);
+ assert.equal(fingerprint("demo-runtime", shape), fingerprint("demo-runtime", message), message);
+ }
+ // The shape is not the raw message: quoted text, numbers, URLs and code
+ // frames (authored code, contract §3) are gone.
+ const shape = fingerprintShape(
+ "Unexpected token (2:11)\n> 2 | return secretVar +;\n | ^ at 'literal' https://x.test/?k=1",
+ );
+ assert.ok(!shape.includes("secretVar"), shape);
+ assert.ok(!shape.includes("literal"), shape);
+ assert.ok(!shape.includes("x.test"), shape);
+});
+
+test("demoEventReport names the Faro record by kind, and gives a console warning none", () => {
+ const base = { message: "m", tier: 1, framework: "react", htMajor: "18" };
+ assert.equal(demoEventReport({ ...base, kind: "error" }).recordName, "DemoError");
+ assert.equal(demoEventReport({ ...base, kind: "rejection" }).recordName, "DemoUnhandledRejection");
+ assert.equal(demoEventReport({ ...base, kind: "console-error" }).recordName, "DemoConsoleError");
+ assert.equal(demoEventReport({ ...base, kind: "console-warn" }).recordName, null);
+ assert.equal(demoEventReport({ ...base, kind: "network" }).recordName, "DemoNetworkError");
+ assert.equal(demoEventReport({ ...base, kind: "stderr" }).recordName, "DemoStderr");
+});
+
+// ---- a compile failure replaces the burst's run ----------------------------
+//
+// The key `sentry.ts#collapseCompileError` uses: by kind, not by message.
+const COMPILE_KEY = "compile:sandpack.compile_error";
+
+function compileHarness() {
+ const h = harness();
+ /** A compile failure of the newest edit, the way `collapseCompileError` reports it. */
+ const compileError = (diagnostic) => h.collapse.report(COMPILE_KEY, `compile: ${diagnostic}`, { replacesRun: true });
+ return { ...h, compileError };
+}
+
+test("a typed syntax-error ladder is one compile error and no runtime error, stale relays included", () => {
+ const { clock, emitted, collapse, relay, compileError } = compileHarness();
+ // `const X = ;` typed key by key. `c`..`cons` parse and run (and throw);
+ // from `const` on, every prefix fails the pre-transpile.
+ for (const prefix of ["c", "co", "con", "cons"]) {
+ collapse.noteEdit();
+ relay(`${prefix} is not defined`);
+ }
+ collapse.noteEdit(); // `const`
+ // The `cons` run's relay was still in flight at this keystroke (compile
+ // slower than the typist) — a known imprecision.
+ relay("cons is not defined");
+ compileError("Unexpected token (1:5)");
+ for (const diagnostic of ["Unexpected token (1:6)", "Missing initializer in const declaration", "Unexpected token (1:12)"]) {
+ collapse.noteEdit();
+ compileError(diagnostic);
+ // A re-render warning / late rung that lands after the compile failure.
+ relay('Theme "main" is already registered.');
+ }
+ clock.advance(DEMO_EDIT_SETTLE_MS);
+ assert.deepEqual(emitted, ["compile: Unexpected token (1:12)"], "the final state's compile error, alone");
+});
+
+test("a compile error replaces an earlier one of the same burst, so the final state's diagnostic is the one counted", () => {
+ const { clock, emitted, collapse, compileError } = compileHarness();
+ collapse.noteEdit();
+ compileError("stale diagnostic from the previous push");
+ compileError("the newest push's diagnostic");
+ clock.advance(DEMO_EDIT_SETTLE_MS);
+ assert.deepEqual(emitted, ["compile: the newest push's diagnostic"]);
+});
+
+test("a burst that ends compiling cleanly counts its run's runtime error, not the earlier compile error", () => {
+ const { clock, emitted, collapse, relay, compileError } = compileHarness();
+ collapse.noteEdit();
+ compileError("Unexpected token");
+ collapse.noteEdit(); // the line is finished and parses; it throws when it runs
+ relay(FINAL);
+ clock.advance(DEMO_EDIT_SETTLE_MS);
+ assert.deepEqual(emitted, [FINAL]);
+});
+
+test("a runtime SyntaxError (JSON.parse) stays a runtime error — only the compile signal replaces a run", () => {
+ const { clock, emitted, collapse, relay } = compileHarness();
+ const jsonParse = "SyntaxError: Unexpected token } in JSON at position 1";
+ collapse.noteEdit();
+ relay(jsonParse);
+ // The same run's next fault: were the SyntaxError taken for a compile
+ // failure (message-shape detection), it would suppress this one.
+ relay(FINAL);
+ clock.advance(DEMO_EDIT_SETTLE_MS);
+ assert.deepEqual(emitted, [jsonParse, FINAL]);
+ // And outside a burst (a click that parses bad JSON), at once.
+ relay("SyntaxError: Unexpected end of JSON input");
+ assert.deepEqual(emitted, [jsonParse, FINAL, "SyntaxError: Unexpected end of JSON input"]);
+});
+
+test("a first-load compile failure counts at once, and only once until the next edit", () => {
+ const { emitted, collapse, compileError } = compileHarness();
+ compileError("Unexpected token");
+ assert.deepEqual(emitted, ["compile: Unexpected token"], "no burst open: not held back");
+ compileError("Unexpected token"); // a refresh of the same broken demo
+ assert.equal(emitted.length, 1);
+ collapse.reset(); // the next preview mount
+ compileError("Unexpected token");
+ assert.equal(emitted.length, 2, "a new mount counts its own first-load failure");
+});
+
+test("the next edit re-arms runtime reports after a compile failure", () => {
+ const { clock, emitted, collapse, relay, compileError } = compileHarness();
+ collapse.noteEdit();
+ compileError("Unexpected token");
+ clock.advance(DEMO_EDIT_SETTLE_MS);
+ // Burst closed: a click in the stale preview that throws still counts.
+ relay("stale preview click");
+ assert.deepEqual(emitted, ["compile: Unexpected token", "stale preview click"]);
+});
+
+test("a stale relay held before the final keystroke's compile failure is dropped by it", () => {
+ const { clock, emitted, collapse, relay, compileError } = compileHarness();
+ collapse.noteEdit(); // the last keystroke of the line
+ relay("cons is not defined"); // the previous run, still in flight
+ compileError("Unexpected token (1:12)");
+ clock.advance(DEMO_EDIT_SETTLE_MS);
+ assert.deepEqual(emitted, ["compile: Unexpected token (1:12)"]);
+});
+
+// An edit whose sandbox matches the running one (a closing `;`, a space, a
+// trailing comma) re-runs nothing, so no later report replaces what that
+// edit's `noteEdit` discarded.
+
+test("a burst ending on an edit that re-runs nothing counts the running sandbox's error once", () => {
+ const { clock, emitted, collapse, relay } = harness();
+ for (const message of LADDER) {
+ collapse.noteEdit();
+ collapse.pushOutcome("rerun");
+ relay(message);
+ }
+ collapse.noteEdit();
+ collapse.pushOutcome("rerun");
+ relay(FINAL); // the finished line's run
+ collapse.noteEdit(); // the closing `;`
+ collapse.pushOutcome("unchanged");
+ clock.advance(DEMO_EDIT_SETTLE_MS);
+ assert.deepEqual(emitted, [FINAL], "the final run's error, and none of the rungs");
+});
+
+test("a compile failure undone back to the running sandbox counts that sandbox's error, not the compile error", () => {
+ const { clock, emitted, collapse, relay, compileError } = compileHarness();
+ collapse.noteEdit();
+ collapse.pushOutcome("rerun");
+ relay(FINAL);
+ collapse.noteEdit(); // a stray `(`
+ compileError("Unexpected token");
+ relay(FINAL); // the running sandbox, still throwing: suppressed while the newest edit is broken
+ collapse.noteEdit(); // deleted again: identical to what runs
+ collapse.pushOutcome("unchanged");
+ relay("the running sandbox's next fault"); // no longer suppressed: the newest edit compiles
+ clock.advance(DEMO_EDIT_SETTLE_MS);
+ assert.deepEqual(emitted, [FINAL, "the running sandbox's next fault"]);
+});
+
+test("an edit that re-runs nothing does not count the running sandbox's already-counted error again", () => {
+ const { clock, emitted, collapse, relay } = harness();
+ relay(FINAL); // first load, counted at once
+ collapse.noteEdit(); // a space
+ collapse.pushOutcome("unchanged");
+ clock.advance(DEMO_EDIT_SETTLE_MS);
+ collapse.noteEdit();
+ collapse.pushOutcome("rerun");
+ relay("next run's error");
+ clock.advance(DEMO_EDIT_SETTLE_MS);
+ collapse.noteEdit();
+ collapse.pushOutcome("unchanged");
+ clock.advance(DEMO_EDIT_SETTLE_MS);
+ assert.deepEqual(emitted, [FINAL, "next run's error"]);
+});
+
+test("a rerun forgets the previous sandbox's reports, so 'unchanged' brings back only the new run's", () => {
+ const { clock, emitted, collapse, relay } = harness();
+ collapse.noteEdit();
+ collapse.pushOutcome("rerun");
+ relay("old run's error");
+ collapse.noteEdit();
+ collapse.pushOutcome("rerun"); // the finished line runs clean
+ collapse.noteEdit();
+ collapse.pushOutcome("unchanged");
+ clock.advance(DEMO_EDIT_SETTLE_MS);
+ assert.deepEqual(emitted, []);
+});
+
+test("a newest edit that fails to compile still counts one compile error, and 'unchanged' is never its outcome", () => {
+ const { clock, emitted, collapse, relay, compileError } = compileHarness();
+ collapse.noteEdit();
+ collapse.pushOutcome("rerun");
+ relay("cons is not defined");
+ collapse.noteEdit();
+ compileError("Unexpected token (1:12)");
+ relay("cons is not defined"); // the running sandbox's late relay
+ clock.advance(DEMO_EDIT_SETTLE_MS);
+ assert.deepEqual(emitted, ["compile: Unexpected token (1:12)"]);
+});
+
+test("reset forgets the outgoing preview's running sandbox", () => {
+ const { clock, emitted, collapse, relay } = harness();
+ collapse.noteEdit();
+ collapse.pushOutcome("rerun");
+ relay("outgoing preview's error");
+ collapse.noteEdit(); // typed past, then the preview is switched away mid-burst
+ collapse.reset();
+ collapse.noteEdit(); // the new preview's first edit matches its mounted sandbox
+ collapse.pushOutcome("unchanged");
+ clock.advance(DEMO_EDIT_SETTLE_MS);
+ assert.deepEqual(emitted, []);
+});
+
+test("a report already counted before a rerun is not brought back by a later unchanged edit", () => {
+ const { clock, emitted, collapse, relay } = harness();
+ collapse.noteEdit();
+ relay(FINAL); // the previous run's relay
+ clock.advance(DEMO_EDIT_SETTLE_MS); // counted: the burst closes before the new run starts (a slow install)
+ collapse.pushOutcome("rerun");
+ relay(FINAL); // the new run's own copy: already counted
+ collapse.noteEdit(); // a space
+ collapse.pushOutcome("unchanged");
+ clock.advance(DEMO_EDIT_SETTLE_MS);
+ assert.deepEqual(emitted, [FINAL]);
+});
+
+test("a bundler compile error of the running sandbox survives an unchanged edit and still replaces its run", () => {
+ const { clock, emitted, collapse, relay } = harness();
+ const bundlerError = (d) => collapse.report(COMPILE_KEY, `compile: ${d}`, { replacesRun: true, fromBundler: true });
+ collapse.noteEdit();
+ collapse.pushOutcome("rerun");
+ relay("imp is not defined"); // a stale rung that lands after the dispatch
+ bundlerError("Could not find module './missing.css'");
+ relay("im is not defined"); // an older rung, later still
+ collapse.noteEdit(); // the closing `;`
+ collapse.pushOutcome("unchanged");
+ relay("imp is not defined"); // still stale: the rejected sandbox never evaluated
+ clock.advance(DEMO_EDIT_SETTLE_MS);
+ assert.deepEqual(emitted, ["compile: Could not find module './missing.css'"]);
+});
+
+test("a rerun forgets the previous sandbox's bundler compile error, and records the new run's reports", () => {
+ const { clock, emitted, collapse, relay } = harness();
+ collapse.noteEdit();
+ collapse.pushOutcome("rerun");
+ collapse.report(COMPILE_KEY, "compile: bundler", { replacesRun: true, fromBundler: true });
+ collapse.noteEdit();
+ collapse.pushOutcome("rerun"); // the fixed import builds, runs and throws
+ relay(FINAL);
+ collapse.noteEdit();
+ collapse.pushOutcome("unchanged");
+ clock.advance(DEMO_EDIT_SETTLE_MS);
+ assert.deepEqual(emitted, [FINAL]);
+});
+
+// The bundler runs one compile at a time and signals `rerun` when it starts the next,
+// so a run's relay can land after the next keystroke but before that keystroke's run.
+
+test("a previous run's relay that lands after the newest edit, before that edit's run starts, is not counted", () => {
+ const { clock, emitted, collapse, relay } = harness();
+ collapse.noteEdit();
+ collapse.pushOutcome("rerun");
+ collapse.noteEdit(); // the last keystroke
+ relay("typed er"); // the previous run evaluates only now
+ collapse.pushOutcome("rerun"); // the bundler starts the finished line
+ relay("typed err");
+ clock.advance(DEMO_EDIT_SETTLE_MS);
+ assert.deepEqual(emitted, ["typed err"]);
+});
+
+test("the start of an older run does not drop the newest edit's compile failure", () => {
+ const { clock, emitted, collapse, relay, compileError } = compileHarness();
+ collapse.noteEdit(); // `cons`, dispatched
+ collapse.noteEdit(); // `const`, which does not parse
+ compileError("Unexpected token (1:5)");
+ collapse.pushOutcome("rerun"); // the bundler starts `cons`
+ relay("cons is not defined");
+ clock.advance(DEMO_EDIT_SETTLE_MS);
+ assert.deepEqual(emitted, ["compile: Unexpected token (1:5)"]);
+});
diff --git a/runner/pipeline/demo-event-report.test.mjs b/runner/pipeline/demo-event-report.test.mjs
new file mode 100644
index 0000000000..976e6f3541
--- /dev/null
+++ b/runner/pipeline/demo-event-report.test.mjs
@@ -0,0 +1,141 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { demoEventReport } from "../apps/authoring/src/demoEventReport.ts";
+import { fingerprint } from "../packages/runtime/dist/telemetry/index.js";
+
+// Build prerequisite: `pnpm --filter @handsontable/demo-runtime build` (for
+// the `fingerprint` import used to prove the ladder-collapsing claim below).
+//
+// ADR §E.1 "Moves to the new stack only": demo-runtime preview events must
+// leave Sentry entirely and become one `preview.runtime_error` count
+// through the facade. `demoEventReport.ts` is the pure decision
+// `sentry.ts#reportDemoEvent` delegates to — see its own header for why it
+// is import-free.
+
+test("error and rejection both map to reason 'uncaught'", () => {
+ const facts = { kind: "error", message: "boom", tier: 1, framework: "react", htMajor: "18" };
+ assert.equal(demoEventReport(facts).reason, "uncaught");
+ assert.equal(demoEventReport({ ...facts, kind: "rejection" }).reason, "uncaught");
+});
+
+test("console-error maps to reason 'console'; a console warning is not a runtime error at all", () => {
+ const facts = { kind: "console-error", message: "a real console.error", tier: 1, framework: "react", htMajor: "18" };
+ assert.equal(demoEventReport(facts).reason, "console");
+ // Handsontable's load-time notices (18: theme already registered; 17: `date` deprecation).
+ for (const message of ['Theme "main" is already registered. Registration skipped.', "Deprecated: The `date` cell type ..."]) {
+ assert.equal(demoEventReport({ ...facts, kind: "console-warn", message }).reason, null, message);
+ }
+});
+
+test("network maps to 'network', stderr maps to 'stderr'", () => {
+ assert.equal(
+ demoEventReport({ kind: "network", message: "m", tier: 1, framework: "react", htMajor: "18" }).reason,
+ "network",
+ );
+ assert.equal(
+ demoEventReport({ kind: "stderr", message: "m", tier: 2, framework: "angular", htMajor: "17" }).reason,
+ "stderr",
+ );
+});
+
+test("only console-warn is budgeted against the looser breadcrumb cap", () => {
+ const kinds = ["error", "rejection", "console-error", "network", "stderr"];
+ for (const kind of kinds) {
+ assert.equal(
+ demoEventReport({ kind, message: "m", tier: 1, framework: "react", htMajor: "18" }).budget,
+ "relay",
+ `kind=${kind}`,
+ );
+ }
+ assert.equal(
+ demoEventReport({ kind: "console-warn", message: "m", tier: 1, framework: "react", htMajor: "18" }).budget,
+ "breadcrumb",
+ );
+});
+
+test("attrs carry surface=demo-runtime, the stringified tier, framework, and ht_major", () => {
+ const report = demoEventReport({ kind: "error", message: "m", tier: 2, framework: "vue", htMajor: "17" });
+ assert.deepEqual(report.attrs, { surface: "demo-runtime", tier: "2", framework: "vue", ht_major: "17" });
+});
+
+// The `tier1-playground` dashboard's "runtime_error rate by reason" panel
+// filters `blob7 IN (${ht_major:sqlstring})`; without `ht_major` on the
+// attrs every row lands blob7 = '' and the panel is permanently empty.
+test("ht_major carries the caller's actual value through to attrs (contract §5 / F10a)", () => {
+ for (const htMajor of ["15", "16", "17", "18", "19", "next", "none"]) {
+ const report = demoEventReport({ kind: "error", message: "m", tier: 1, framework: "react", htMajor });
+ assert.equal(report.attrs.ht_major, htMajor, `htMajor=${htMajor}`);
+ }
+});
+
+test("demoId, when present, becomes attrs.demo_id — and is omitted when absent/null", () => {
+ const withId = demoEventReport({
+ kind: "error",
+ message: "m",
+ tier: 1,
+ framework: "react",
+ htMajor: "18",
+ demoId: "abc123",
+ });
+ assert.equal(withId.attrs.demo_id, "abc123");
+
+ const withoutId = demoEventReport({ kind: "error", message: "m", tier: 1, framework: "react", htMajor: "18" });
+ assert.equal("demo_id" in withoutId.attrs, false);
+
+ const nullId = demoEventReport({
+ kind: "error",
+ message: "m",
+ tier: 1,
+ framework: "react",
+ htMajor: "18",
+ demoId: null,
+ });
+ assert.equal("demo_id" in nullId.attrs, false);
+});
+
+test("fingerprintContext is always 'demo-runtime' — the surface, never the kind", () => {
+ for (const kind of ["error", "rejection", "console-error", "console-warn", "network", "stderr"]) {
+ assert.equal(
+ demoEventReport({ kind, message: "m", tier: 1, framework: "react", htMajor: "18" }).fingerprintContext,
+ "demo-runtime",
+ );
+ }
+});
+
+test("fingerprintMessage is the raw message, unnormalised — the caller runs fingerprint()", () => {
+ const report = demoEventReport({
+ kind: "error",
+ message: "licenseKey is not defined",
+ tier: 1,
+ framework: "react",
+ htMajor: "18",
+ });
+ assert.equal(report.fingerprintMessage, "licenseKey is not defined");
+});
+
+// "A demo-runtime keystroke ladder becomes one deduplicated count in
+// Faro" — proven here as "the same fingerprint," via the real contract
+// `fingerprint()`, not a re-implementation.
+test("a keystroke ladder collapses to one fingerprint (the actual contract dedupe)", () => {
+ const ladder = ["l is not defined", "li is not defined", "lic is not defined", "licenseKey is not defined"];
+ const fingerprints = ladder.map((message) => {
+ const report = demoEventReport({ kind: "error", message, tier: 1, framework: "react", htMajor: "18" });
+ return fingerprint(report.fingerprintContext, report.fingerprintMessage);
+ });
+ assert.equal(new Set(fingerprints).size, 1, "every ladder rung must fingerprint identically");
+
+ // A genuinely different failure must NOT collapse into the same bucket —
+ // guards against a fingerprint function that has gone trivial (e.g. a
+ // constant), which would make the assertion above pass for the wrong reason.
+ const different = demoEventReport({
+ kind: "error",
+ message: "Cannot read properties of undefined (reading 'foo')",
+ tier: 1,
+ framework: "react",
+ htMajor: "18",
+ });
+ assert.notEqual(
+ fingerprint(different.fingerprintContext, different.fingerprintMessage),
+ fingerprints[0],
+ );
+});
diff --git a/runner/pipeline/demo-routes-version.test.mjs b/runner/pipeline/demo-routes-version.test.mjs
index d1add1c80d..d261dd8a3d 100644
--- a/runner/pipeline/demo-routes-version.test.mjs
+++ b/runner/pipeline/demo-routes-version.test.mjs
@@ -203,7 +203,7 @@ test("a browser rebuild derives from the payload pin and replaces a stale sentin
const { env, writes, artifacts } = makeEnv([demoRow({ ht_version: "latest" })]);
const res = await worker.fetch(patchRequest("abc123", { files: filesWith("16.0.2") }), env, ctx);
assert.equal(res.status, 200);
- assert.deepEqual(await res.json(), { ok: true, htVersion: "16.0.2" });
+ assert.deepEqual(await res.json(), { ok: true, htVersion: "16.0.2", exampleSaved: false });
const update = findVersionUpdate(writes);
assert.ok(update, "the rebuild must update the demos row");
assert.equal(update.binds[0], "16.0.2", "the sentinel row is repaired to the derived ref");
diff --git a/runner/pipeline/demo-save-build-error.test.mjs b/runner/pipeline/demo-save-build-error.test.mjs
new file mode 100644
index 0000000000..4209b998bc
--- /dev/null
+++ b/runner/pipeline/demo-save-build-error.test.mjs
@@ -0,0 +1,205 @@
+// A Save or create whose build rejects the demo's own input is client input: 422 with
+// the build error, an `api.request` 4xx, a `snapshot.build failed` point, and the stored
+// demo untouched. A failure that is ours stays a 5xx. Driven through the real router
+// with a scripted builder container.
+// Build prerequisite: `pnpm --filter @handsontable/demo-runtime build`.
+// Run: node --experimental-strip-types --test pipeline/demo-save-build-error.test.mjs
+
+import test, { after } from "node:test";
+import assert from "node:assert/strict";
+import { register } from "node:module";
+import { AUTHOR, SECRET, demoRow, makeEnv, seedCatalog } from "./fixtures/worker-harness.mjs";
+import { setSandboxFactory } from "./fixtures/cloudflare-sandbox-stub.mjs";
+
+register("./fixtures/worker-hooks.mjs", import.meta.url);
+
+const { default: worker } = await import("../workers/api/src/index.ts");
+
+const REAL_FETCH = globalThis.fetch;
+globalThis.fetch = async (input, init) => {
+ const url = typeof input === "string" ? input : input.url;
+ if (url.startsWith("https://login.invalid") && init?.headers?.Authorization === "Bearer test-token") {
+ return Response.json({ email: AUTHOR, sub: "u1" });
+ }
+ throw new Error(`unexpected network fetch in demo-save-build-error.test.mjs: ${url}`);
+};
+after(() => {
+ globalThis.fetch = REAL_FETCH;
+ setSandboxFactory(null);
+});
+
+const DEMO_ID = "abc123";
+const INDEX_HTML =
+ ''
+ + '';
+const FILES = {
+ "/package.json": JSON.stringify({ name: "demo", dependencies: { handsontable: "16.0.2" }, devDependencies: { vite: "^5.4.0" } }),
+ "/index.html": INDEX_HTML,
+ "/src/index.tsx": "const X = ;\n",
+};
+
+/** The stderr of a `vite build` that rejects a syntax error. Its announced cause is
+ * only headings, so the useful line is the one after them. */
+const SYNTAX_ERROR_LOG =
+ "vite v7.1.0 building for production...\ntransforming...\nerror during build:\n"
+ + "Build failed with 1 error:\n/app/src/index.tsx:1:10: ERROR: Unexpected \";\"\n";
+
+/** A builder whose install succeeds and whose build command answers `build`. */
+function builder(build) {
+ return () => ({
+ mkdir: async () => {},
+ writeFile: async () => {},
+ readFile: async () => "",
+ destroy: async () => {},
+ async exec(cmd) {
+ if (cmd.includes("pnpm install")) return { success: true, exitCode: 0, stdout: "", stderr: "" };
+ return build(cmd);
+ },
+ });
+}
+
+const rejectsCode = builder(() => ({ success: false, exitCode: 1, stdout: "", stderr: SYNTAX_ERROR_LOG }));
+
+/** The route's env with a build-cache miss (so the builder runs) and points in memory. */
+function setup(rows = [demoRow({ id: DEMO_ID, framework: "react", ht_version: "16.0.2" })]) {
+ const harness = makeEnv(rows, [], {}, { buildCacheHit: false });
+ const { env } = harness;
+ const points = [];
+ env.RUNNER_EVENTS = { writeDataPoint: (p) => points.push(p) };
+ env.PREVIEW_HOST = "demos.handsontable.com";
+ const pending = [];
+ const ctx = { waitUntil: (p) => pending.push(Promise.resolve(p)), passThroughOnException() {} };
+ /** Every point of `metric`, once the work scheduled past the response (which
+ * itself schedules more) has settled. */
+ const pointsOf = async (metric) => {
+ for (let seen = -1; seen !== pending.length;) {
+ seen = pending.length;
+ await Promise.allSettled(pending);
+ }
+ return points.filter((p) => p.indexes[0] === metric);
+ };
+ return { ...harness, ctx, pointsOf };
+}
+
+const authed = { "Content-Type": "application/json", Authorization: "Bearer test-token" };
+const mcpHeaders = { "Content-Type": "application/json", "X-MCP-Secret": SECRET, "X-Demo-Author": AUTHOR };
+
+const request = (method, path, headers, body) =>
+ new Request(`https://demos.handsontable.com${path}`, { method, headers, body: JSON.stringify(body) });
+
+const ROUTES = [
+ ["PATCH /api/demos/:id (editor Save)", () => request("PATCH", `/api/demos/${DEMO_ID}`, authed, { files: FILES, htVersion: "16.0.2" })],
+ ["POST /api/demos (fork, embed)", () => request("POST", "/api/demos", authed, { framework: "react", files: FILES, title: "Grid", htVersion: "16.0.2" })],
+ ["PATCH /api/mcp/demos/:id", () => request("PATCH", `/api/mcp/demos/${DEMO_ID}`, mcpHeaders, { files: FILES, htVersion: "16.0.2" })],
+ ["POST /api/mcp/demos", () => request("POST", "/api/mcp/demos", mcpHeaders, { framework: "react", files: FILES, title: "Grid", description: "A grid", htVersion: "16.0.2" })],
+];
+
+/** What `api.request` recorded for the one request: its outcome blob. */
+async function requestOutcomes(pointsOf) {
+ return (await pointsOf("api.request")).map((p) => p.blobs.find((b) => /^[2-5]xx$/.test(b)));
+}
+
+for (const [name, makeRequest] of ROUTES) {
+ test(`${name}: code the build rejects is a 422 build_failed with the build error, recorded as 4xx`, async () => {
+ setSandboxFactory(rejectsCode);
+ const { env, ctx, pointsOf, writes, artifacts, demos } = setup();
+ await seedCatalog(env);
+ const before = JSON.stringify(demos.get(DEMO_ID));
+
+ const res = await worker.fetch(makeRequest(), env, ctx);
+
+ assert.equal(res.status, 422);
+ const detail = 'error during build: Build failed with 1 error: src/index.tsx:1:10: ERROR: Unexpected ";"';
+ // `error` carries the diagnostic too: MCP clients (hot-mcp) read only that field.
+ assert.deepEqual(await res.json(), { error: `build failed: ${detail}`, code: "build_failed", detail });
+ assert.deepEqual(await requestOutcomes(pointsOf), ["4xx"], "one api.request point, and it is not a 5xx");
+ const builds = await pointsOf("snapshot.build");
+ assert.equal(builds.length, 1);
+ assert.ok(builds[0].blobs.includes("failed"), "snapshot.build keeps its failed outcome");
+ // The demo is unchanged: no artifact, no source snapshot, no row written.
+ assert.deepEqual(artifacts.puts.filter((p) => p.key.startsWith("demos/")), []);
+ assert.deepEqual(writes.filter((w) => /\bdemos\b/.test(w.sql) && !/build_cache/.test(w.sql)), []);
+ assert.equal(JSON.stringify(demos.get(DEMO_ID)), before);
+ });
+}
+
+/** A builder whose install answers `install` (the frozen install and its retry alike). */
+function installer(install) {
+ return () => ({
+ mkdir: async () => {},
+ writeFile: async () => {},
+ readFile: async () => "",
+ destroy: async () => {},
+ exec: async () => install(),
+ });
+}
+
+const failedInstall = (stderr) => installer(() => ({ success: false, exitCode: 1, stdout: "", stderr }));
+
+const USER_INSTALL_FAILURES = [
+ ["ERR_PNPM_NO_MATCHING_VERSION", " ERR_PNPM_NO_MATCHING_VERSION No matching version found for dayjs@^99\n"],
+ ["ERR_PNPM_FETCH_404", " ERR_PNPM_FETCH_404 GET https://registry.npmjs.org/dayjss: Not Found - 404\n"],
+ ["ERR_PNPM_SPEC_NOT_SUPPORTED_BY_ANY_RESOLVER", " ERR_PNPM_SPEC_NOT_SUPPORTED_BY_ANY_RESOLVER dayjs@latest-ish isn't supported by any available resolver.\n"],
+ ["ERR_PNPM_BAD_PM_VERSION", " ERR_PNPM_BAD_PM_VERSION This project is configured to use v8 of pnpm. Your current pnpm is v10.34.5\n"],
+];
+
+for (const [code, stderr] of USER_INSTALL_FAILURES) {
+ test(`an install refused with ${code} (the author's dependency) is a 422 build_failed with the pnpm error`, async () => {
+ setSandboxFactory(failedInstall(stderr));
+ const { env, ctx, pointsOf } = setup();
+ await seedCatalog(env);
+ const res = await worker.fetch(ROUTES[0][1](), env, ctx);
+ assert.equal(res.status, 422);
+ const body = await res.json();
+ assert.equal(body.code, "build_failed");
+ assert.ok(body.detail.includes(code), body.detail);
+ assert.equal(body.error, `install failed: ${body.detail}`);
+ assert.deepEqual(await requestOutcomes(pointsOf), ["4xx"]);
+ });
+}
+
+test("an MCP client reading only `error` gets the build diagnostic", async () => {
+ setSandboxFactory(rejectsCode);
+ const { env, ctx } = setup();
+ await seedCatalog(env);
+ for (const [, makeRequest] of ROUTES.filter(([name]) => name.includes("/api/mcp/"))) {
+ const res = await worker.fetch(makeRequest(), env, ctx);
+ assert.equal(res.status, 422);
+ assert.match((await res.json()).error, /src\/index\.tsx:1:10: ERROR: Unexpected ";"/);
+ }
+});
+
+const INFRA_FAILURES = [
+ ["a build killed by a signal (OOM)", builder(() => ({ success: false, exitCode: 137, stdout: "", stderr: "Killed\n" }))],
+ ["a build command that is not executable (126)", builder(() => ({ success: false, exitCode: 126, stdout: "", stderr: "sh: vite: Permission denied\n" }))],
+ ["a build command that is not found (127)", builder(() => ({ success: false, exitCode: 127, stdout: "", stderr: "sh: vite: not found\n" }))],
+ ["a build that exits 1 on a network failure", builder(() => ({
+ success: false,
+ exitCode: 1,
+ stdout: "",
+ stderr: "`next/font` error:\nFailed to fetch `Inter` from Google Fonts.\nTypeError: fetch failed\n",
+ }))],
+ ["a build that exits 1 after its worker ran out of memory", builder(() => ({
+ success: false,
+ exitCode: 1,
+ stdout: "",
+ stderr: "FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory\nerror during build:\nBuild failed\n",
+ }))],
+ ["an install that fails on a registry 5xx", failedInstall(" ERR_PNPM_FETCH_503 GET https://registry.npmjs.org/dayjs: Service Unavailable - 503\n")],
+ ["an install that fails on a reset connection", failedInstall(" ERR_PNPM_META_FETCH_FAIL GET https://registry.npmjs.org/dayjs: request to https://registry.npmjs.org/dayjs failed, reason: read ECONNRESET\n")],
+ ["an install that times out", failedInstall(" ERR_PNPM_META_FETCH_FAIL GET https://registry.npmjs.org/dayjs: request to https://registry.npmjs.org/dayjs failed, reason: connect ETIMEDOUT 104.16.0.35:443\n")],
+ ["a build result without an exit code", builder(() => ({ success: false, stdout: "", stderr: "error during build:\nsomething\n" }))],
+ ["an exec that throws (container lost)", builder(() => { throw new Error("container is not running"); })],
+];
+
+for (const [name, factory] of INFRA_FAILURES) {
+ test(`an editor Save that fails on ${name} stays a 5xx`, async () => {
+ setSandboxFactory(factory);
+ const { env, ctx, pointsOf } = setup();
+ await seedCatalog(env);
+ const res = await worker.fetch(ROUTES[0][1](), env, ctx);
+ assert.equal(res.status, 500);
+ assert.notEqual((await res.json()).error, "build_failed");
+ assert.deepEqual(await requestOutcomes(pointsOf), ["5xx"]);
+ });
+}
diff --git a/runner/pipeline/demos-malformed-json.test.mjs b/runner/pipeline/demos-malformed-json.test.mjs
new file mode 100644
index 0000000000..ba9f034e19
--- /dev/null
+++ b/runner/pipeline/demos-malformed-json.test.mjs
@@ -0,0 +1,102 @@
+// `POST /api/demos` and `PATCH /api/demos/:id` must not call
+// `request.json()` with no `.catch()`: an unparseable body (or a JSON
+// array where an object is expected) would throw a raw `SyntaxError`/hit
+// `body.framework` on a non-object past the handler, into the generic
+// fetch catch-all — a 500 on ordinary client garbage that pollutes the
+// `api.request` 5xx rate and the `api-5xx-rate` alert, same class the
+// `/api/session` fix (session-malformed-json.test.mjs) closed for the
+// public, unauthenticated routes. These two are authenticated, but the
+// body itself is still ordinary client input reachable by anything with a
+// token. Reuses the same helper (`isPlainRecord`) and error shape as the
+// session fix.
+//
+// Driven through the real router (`workers/api/src/index.ts`'s default
+// export) — a hand-rolled re-check of the body would not catch a
+// regression in the actual route.
+// Run: node --experimental-strip-types --test pipeline/demos-malformed-json.test.mjs
+
+import test, { after } from "node:test";
+import assert from "node:assert/strict";
+import { register } from "node:module";
+import { AUTHOR, ctx, demoRow, makeEnv } from "./fixtures/worker-harness.mjs";
+
+register("./fixtures/worker-hooks.mjs", import.meta.url);
+
+const { default: worker } = await import("../workers/api/src/index.ts");
+
+// ---- the broker stub / network tripwire (same shape as demo-routes-version.test.mjs) ----
+
+const REAL_FETCH = globalThis.fetch;
+
+globalThis.fetch = async (input, init) => {
+ const url = typeof input === "string" ? input : input.url;
+ if (url.startsWith("https://login.invalid") && init?.headers?.Authorization === "Bearer test-token") {
+ return Response.json({ email: AUTHOR, sub: "u1" });
+ }
+ throw new Error(`unexpected network fetch in demos-malformed-json.test.mjs: ${url}`);
+};
+
+after(() => {
+ globalThis.fetch = REAL_FETCH;
+});
+
+const authHeaders = {
+ "Content-Type": "application/json",
+ Authorization: "Bearer test-token",
+};
+
+function malformedJsonRequest(method, path) {
+ return new Request(`https://demos.handsontable.com${path}`, {
+ method,
+ headers: authHeaders,
+ // Not valid JSON — `request.json()` rejects on this body.
+ body: "{ this is not json",
+ });
+}
+
+function arrayBodyRequest(method, path) {
+ return new Request(`https://demos.handsontable.com${path}`, {
+ method,
+ headers: authHeaders,
+ // Valid JSON — parses fine — but not a plain record: `isPlainRecord`
+ // must still reject it, same as the session fix's own array-body case.
+ body: JSON.stringify([1, 2, 3]),
+ });
+}
+
+// ---- POST /api/demos --------------------------------------------------------------
+
+test("a malformed POST /api/demos body is a 400, not the fetch catch-all's 500", async () => {
+ const { env } = makeEnv();
+ const res = await worker.fetch(malformedJsonRequest("POST", "/api/demos"), env, ctx);
+ assert.equal(res.status, 400, "must not reach the generic 500 catch-all");
+ const body = await res.json();
+ // Same error shape the /api/session fix uses (isPlainRecord's own 400).
+ assert.equal(body.error, "request body must be a plain record");
+});
+
+test("a JSON array body on POST /api/demos is a 400, not a 500", async () => {
+ const { env } = makeEnv();
+ const res = await worker.fetch(arrayBodyRequest("POST", "/api/demos"), env, ctx);
+ assert.equal(res.status, 400, "an array is valid JSON but not a plain record");
+ const body = await res.json();
+ assert.equal(body.error, "request body must be a plain record");
+});
+
+// ---- PATCH /api/demos/:id ----------------------------------------------------------
+
+test("a malformed PATCH /api/demos/:id body is a 400, not the fetch catch-all's 500", async () => {
+ const { env } = makeEnv([demoRow()]);
+ const res = await worker.fetch(malformedJsonRequest("PATCH", "/api/demos/abc123"), env, ctx);
+ assert.equal(res.status, 400, "must not reach the generic 500 catch-all");
+ const body = await res.json();
+ assert.equal(body.error, "request body must be a plain record");
+});
+
+test("a JSON array body on PATCH /api/demos/:id is a 400, not a 500", async () => {
+ const { env } = makeEnv([demoRow()]);
+ const res = await worker.fetch(arrayBodyRequest("PATCH", "/api/demos/abc123"), env, ctx);
+ assert.equal(res.status, 400, "an array is valid JSON but not a plain record");
+ const body = await res.json();
+ assert.equal(body.error, "request body must be a plain record");
+});
diff --git a/runner/pipeline/dev-script.test.mjs b/runner/pipeline/dev-script.test.mjs
new file mode 100644
index 0000000000..c6937cdc35
--- /dev/null
+++ b/runner/pipeline/dev-script.test.mjs
@@ -0,0 +1,2119 @@
+// Unit tests for `runner/scripts/dev-lib.mjs` (the shared logic behind
+// `pnpm dev`/`dev:live`/`dev:full` and `pnpm o11y:dev`) plus a small
+// CLI-level test for the Docker-missing failure path and a drift test
+// pinning the script's own env-var/flag surface to
+// docs/run-and-deploy.md's "Run locally" section.
+//
+// No real `wrangler`/`docker`/`vite` is spawned here — port resolution,
+// bootstrap, and migration-tracking logic are exercised directly (with a
+// real temp directory for file I/O, and injected stub functions for
+// anything that would otherwise shell out), following this repo's own
+// `pipeline/fixtures/stub-bin` pattern for the one place a real subprocess
+// is worth spawning (the Docker-missing CLI message).
+// Run: node --experimental-strip-types --test pipeline/*.test.mjs
+
+import test from "node:test";
+import assert from "node:assert/strict";
+import { mkdtempSync, rmSync, writeFileSync, readFileSync, mkdirSync, existsSync, utimesSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { spawnSync } from "node:child_process";
+
+import {
+ HELP_TEXT,
+ parseArgs,
+ resolvePorts,
+ PORT_DEFAULTS,
+ assertNoPortCollisions,
+ bootstrapDevVars,
+ o11yDevVarsPatch,
+ O11Y_DEVVARS_STRIP_KEYS,
+ fillEmptyDevVarsSecrets,
+ O11Y_DEVVARS_AUTOFILL_SECRET_KEYS,
+ resolveReplaySecret,
+ checkDevVarsPortDrift,
+ resolveDevVarsPortAdoption,
+ checkO11yDevVarsStaleness,
+ readDevVarsLine,
+ migrationRecordPath,
+ planMigrations,
+ applyMigrations,
+ readAppliedMigrations,
+ parseMigrationTargets,
+ isMigrationAlreadyApplied,
+ snapshotLocalSchema,
+ MigrationError,
+ formatMigrationError,
+ resetLocalD1,
+ isDockerAvailable,
+ DOCKER_NOT_RUNNING_MESSAGE,
+ isRuntimeDistStale,
+ isPnpmInstallNeeded,
+ PNPM_INSTALL_NEEDED_MESSAGE,
+ wranglerBuildErrorLine,
+ waitForServer,
+ buildPlan,
+ ephemeralSecret,
+ redactArgsForLog,
+ possiblyLeftoverContainers,
+ reportLeftoverContainers,
+ SHUTDOWN_SIGNALS,
+ o11yLocalPublicOrigin,
+} from "../scripts/dev-lib.mjs";
+// dev-persist task's own additions — a separate import statement so a
+// parallel edit to the block above merges cleanly.
+import {
+ composeDownArgs,
+ o11yDevDataModeLine,
+ resetO11yLocalState,
+ bringUpO11yCompose,
+ o11yLedgerCommittedKeyCount,
+ findComposeVolume,
+ detectO11yStateDivergence,
+ formatO11yDivergenceWarning,
+ defaultComposeProjectName,
+ resolveComposeProjectName,
+} from "../scripts/dev-lib.mjs";
+// dev-prepull task's own additions — a separate import statement so a
+// parallel edit to the blocks above merges cleanly.
+import {
+ readContainerDockerfilePaths,
+ parseDockerfileBaseImages,
+ containerWranglerConfigsForTier,
+ collectTierBaseImages,
+ shouldCheckContainerImages,
+ isImagePresent,
+ pullImageWithRetry,
+ ensureContainerImagesPresent,
+ formatImagePullFailure,
+} from "../scripts/dev-lib.mjs";
+import { DatabaseSync } from "node:sqlite";
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const RUNNER_ROOT = path.join(HERE, "..");
+
+function withTmpDir(fn) {
+ const dir = mkdtempSync(path.join(tmpdir(), "dev-script-test-"));
+ const cleanup = () => rmSync(dir, { recursive: true, force: true });
+ let result;
+ try {
+ result = fn(dir);
+ } catch (err) {
+ cleanup();
+ throw err;
+ }
+ if (result && typeof result.then === "function") {
+ // `fn` is async — cleanup must wait for it to settle, or the temp dir
+ // gets deleted out from under a still-pending write (the `finally`
+ // version of this helper deletes as soon as `fn(dir)` RETURNS a
+ // promise, not once it resolves).
+ return result.then(
+ (value) => {
+ cleanup();
+ return value;
+ },
+ (err) => {
+ cleanup();
+ throw err;
+ },
+ );
+ }
+ cleanup();
+ return result;
+}
+
+// ---------------------------------------------------------------------------
+// arg parsing
+// ---------------------------------------------------------------------------
+
+test("parseArgs: --tier=1|2|full are accepted", () => {
+ assert.equal(parseArgs(["--tier=1"]).tier, "1");
+ assert.equal(parseArgs(["--tier=2"]).tier, "2");
+ assert.equal(parseArgs(["--tier=full"]).tier, "full");
+});
+
+test("parseArgs: an invalid --tier value is a parse error, not a silent fallback", () => {
+ const { tier, errors } = parseArgs(["--tier=3"]);
+ assert.equal(tier, null);
+ assert.ok(errors.some((e) => e.includes("--tier")));
+});
+
+test("parseArgs: --tier is required unless --help is passed", () => {
+ const noTier = parseArgs([]);
+ assert.ok(noTier.errors.some((e) => e.includes("required")));
+ const help = parseArgs(["--help"]);
+ assert.equal(help.help, true);
+ assert.deepEqual(help.errors, []);
+});
+
+test("parseArgs: -h is recognized the same as --help", () => {
+ assert.equal(parseArgs(["-h"]).help, true);
+});
+
+test("parseArgs: --replay is only valid with --tier=full", () => {
+ assert.deepEqual(parseArgs(["--tier=full", "--replay"]).errors, []);
+ const withTier1 = parseArgs(["--tier=1", "--replay"]);
+ assert.ok(withTier1.errors.some((e) => e.includes("--replay")));
+});
+
+test("parseArgs: an unrecognized flag is an error", () => {
+ const { errors } = parseArgs(["--tier=1", "--bogus"]);
+ assert.ok(errors.some((e) => e.includes("--bogus")));
+});
+
+// ---------------------------------------------------------------------------
+// port resolution
+// ---------------------------------------------------------------------------
+
+test("resolvePorts: tier=1 resolves only AUTHORING_DEV_PORT, at its documented default", () => {
+ const ports = resolvePorts("1", {});
+ assert.deepEqual(ports, { AUTHORING_DEV_PORT: PORT_DEFAULTS.AUTHORING_DEV_PORT });
+});
+
+test("resolvePorts: env overrides win over defaults", () => {
+ const ports = resolvePorts("2", { API_DEV_PORT: "6250" });
+ assert.equal(ports.API_DEV_PORT, 6250);
+ assert.equal(ports.AUTHORING_DEV_PORT, PORT_DEFAULTS.AUTHORING_DEV_PORT);
+});
+
+test("resolvePorts: tier=full resolves a distinct inspector port for both api and o11y", () => {
+ const ports = resolvePorts("full", {});
+ assert.notEqual(ports.API_DEV_INSPECTOR_PORT, ports.O11Y_DEV_INSPECTOR_PORT);
+ assert.notEqual(ports.API_DEV_INSPECTOR_PORT, ports.API_DEV_PORT);
+ assert.notEqual(ports.O11Y_DEV_INSPECTOR_PORT, ports.O11Y_DEV_PORT);
+});
+
+test("resolvePorts: throws on a port collision (two keys resolving to the same number)", () => {
+ assert.throws(
+ () => resolvePorts("full", { API_DEV_PORT: "6200", O11Y_DEV_PORT: "6200" }),
+ /collision/,
+ );
+});
+
+test("resolvePorts: throws on a non-numeric port override", () => {
+ assert.throws(() => resolvePorts("1", { AUTHORING_DEV_PORT: "not-a-port" }), /invalid port/);
+});
+
+// ---------------------------------------------------------------------------
+// .dev.vars bootstrap
+// ---------------------------------------------------------------------------
+
+test("bootstrapDevVars: copies the example when .dev.vars is absent", () => {
+ withTmpDir((dir) => {
+ const examplePath = path.join(dir, ".dev.vars.example");
+ const devVarsPath = path.join(dir, ".dev.vars");
+ writeFileSync(examplePath, "DEV_AUTH_EMAIL=\"dev@handsontable.com\"\nPREVIEW_HOST=\"localhost:8787\"\n");
+ const result = bootstrapDevVars({ examplePath, devVarsPath });
+ assert.equal(result.created, true);
+ assert.equal(readFileSync(devVarsPath, "utf8"), readFileSync(examplePath, "utf8"));
+ });
+});
+
+test("bootstrapDevVars: never overwrites an existing .dev.vars", () => {
+ withTmpDir((dir) => {
+ const examplePath = path.join(dir, ".dev.vars.example");
+ const devVarsPath = path.join(dir, ".dev.vars");
+ writeFileSync(examplePath, "DEV_AUTH_EMAIL=\"dev@handsontable.com\"\n");
+ writeFileSync(devVarsPath, "DEV_AUTH_EMAIL=\"someone-else@handsontable.com\"\n# my own edits\n");
+ const result = bootstrapDevVars({ examplePath, devVarsPath });
+ assert.equal(result.created, false);
+ assert.equal(readFileSync(devVarsPath, "utf8"), "DEV_AUTH_EMAIL=\"someone-else@handsontable.com\"\n# my own edits\n");
+ });
+});
+
+test("bootstrapDevVars: throws a clear error when the example itself is missing", () => {
+ withTmpDir((dir) => {
+ const examplePath = path.join(dir, ".dev.vars.example");
+ const devVarsPath = path.join(dir, ".dev.vars");
+ assert.throws(() => bootstrapDevVars({ examplePath, devVarsPath }), /missing/);
+ });
+});
+
+test("bootstrapDevVars: patch fills in an empty placeholder line, only on fresh creation", () => {
+ withTmpDir((dir) => {
+ const examplePath = path.join(dir, ".dev.vars.example");
+ const devVarsPath = path.join(dir, ".dev.vars");
+ writeFileSync(examplePath, "DEV_ADMIN=\nO11Y_EXPORT_SECRET=\nO11Y_ENV=local\n");
+ const result = bootstrapDevVars({
+ examplePath,
+ devVarsPath,
+ patch: { DEV_ADMIN: "dev@handsontable.com" },
+ });
+ assert.equal(result.created, true);
+ assert.deepEqual(result.patched, ["DEV_ADMIN"]);
+ const text = readFileSync(devVarsPath, "utf8");
+ assert.match(text, /^DEV_ADMIN=dev@handsontable\.com$/m);
+ // A secret this task must never auto-create stays untouched (empty).
+ assert.match(text, /^O11Y_EXPORT_SECRET=\s*$/m);
+ });
+});
+
+test("bootstrapDevVars: patch never touches a NON-empty line (a real value already there)", () => {
+ withTmpDir((dir) => {
+ const examplePath = path.join(dir, ".dev.vars.example");
+ const devVarsPath = path.join(dir, ".dev.vars");
+ writeFileSync(examplePath, "DEV_ADMIN=someone@handsontable.com\n");
+ const result = bootstrapDevVars({ examplePath, devVarsPath, patch: { DEV_ADMIN: "dev@handsontable.com" } });
+ assert.deepEqual(result.patched, []);
+ assert.match(readFileSync(devVarsPath, "utf8"), /^DEV_ADMIN=someone@handsontable\.com$/m);
+ });
+});
+
+test("bootstrapDevVars: stripKeys removes an empty declared line so a later --var isn't silently shadowed", () => {
+ withTmpDir((dir) => {
+ const examplePath = path.join(dir, ".dev.vars.example");
+ const devVarsPath = path.join(dir, ".dev.vars");
+ writeFileSync(examplePath, "O11Y_SESSION_SECRET=\nO11Y_ENV=local\n");
+ const result = bootstrapDevVars({ examplePath, devVarsPath, stripKeys: ["O11Y_SESSION_SECRET"] });
+ assert.deepEqual(result.stripped, ["O11Y_SESSION_SECRET"]);
+ const text = readFileSync(devVarsPath, "utf8");
+ assert.doesNotMatch(text, /O11Y_SESSION_SECRET/);
+ assert.match(text, /O11Y_ENV=local/);
+ });
+});
+
+test("bootstrapDevVars: stripKeys is a no-op when the key isn't declared in the example at all", () => {
+ withTmpDir((dir) => {
+ const examplePath = path.join(dir, ".dev.vars.example");
+ const devVarsPath = path.join(dir, ".dev.vars");
+ writeFileSync(examplePath, "O11Y_ENV=local\n");
+ const result = bootstrapDevVars({ examplePath, devVarsPath, stripKeys: ["O11Y_SESSION_SECRET"] });
+ assert.deepEqual(result.stripped, []);
+ assert.equal(readFileSync(devVarsPath, "utf8"), "O11Y_ENV=local\n");
+ });
+});
+
+test("o11yDevVarsPatch: never includes O11Y_EXPORT_SECRET, SENTRY_HOOK_SECRET, or O11Y_SESSION_SECRET (never auto-create a real secret)", () => {
+ const patch = o11yDevVarsPatch({ O11Y_SLACK_CAPTURE_PORT: 4210 });
+ assert.equal("O11Y_EXPORT_SECRET" in patch, false);
+ assert.equal("SENTRY_HOOK_SECRET" in patch, false);
+ assert.equal("O11Y_SESSION_SECRET" in patch, false);
+});
+
+// ---- O11Y_EXPORT_SECRET/SENTRY_HOOK_SECRET must stay empty locally, or
+// scripts/o11y-replay-fixtures.mjs 401s on every OTLP/deploy/Sentry
+// fixture and the worker-tenant/Sentry panels never fill. -------------------
+
+test("fillEmptyDevVarsSecrets: fills an empty declared line with a generated value", () => {
+ withTmpDir((dir) => {
+ const devVarsPath = path.join(dir, ".dev.vars");
+ writeFileSync(devVarsPath, "DEV_ADMIN=dev@handsontable.com\nO11Y_EXPORT_SECRET=\nSENTRY_HOOK_SECRET=\nO11Y_ENV=local\n");
+ const result = fillEmptyDevVarsSecrets({
+ devVarsPath,
+ keys: O11Y_DEVVARS_AUTOFILL_SECRET_KEYS,
+ generate: () => "ephemeral-value",
+ });
+ assert.deepEqual(result.filled, ["O11Y_EXPORT_SECRET", "SENTRY_HOOK_SECRET"]);
+ const text = readFileSync(devVarsPath, "utf8");
+ assert.match(text, /^O11Y_EXPORT_SECRET=ephemeral-value$/m);
+ assert.match(text, /^SENTRY_HOOK_SECRET=ephemeral-value$/m);
+ // Untouched lines stay untouched.
+ assert.match(text, /^DEV_ADMIN=dev@handsontable\.com$/m);
+ assert.match(text, /^O11Y_ENV=local$/m);
+ });
+});
+
+test("fillEmptyDevVarsSecrets: runs on a PRE-EXISTING file, unlike bootstrapDevVars's own patch", () => {
+ withTmpDir((dir) => {
+ const devVarsPath = path.join(dir, ".dev.vars");
+ // Simulate a `.dev.vars` bootstrapped by an OLDER dev.mjs, before this
+ // fix shipped: real values everywhere except the two secrets.
+ writeFileSync(
+ devVarsPath,
+ "DEV_ADMIN=dev@handsontable.com\nAE_SQL_TOKEN=local-dev-token\nO11Y_EXPORT_SECRET=\nSENTRY_HOOK_SECRET=\nO11Y_ENV=local\n",
+ );
+ // bootstrapDevVars alone is a no-op here (the file already exists) —
+ // pinning that this really is the gap `fillEmptyDevVarsSecrets` closes.
+ const bootstrapResult = bootstrapDevVars({
+ examplePath: path.join(dir, ".dev.vars.example"), // never read; existsSync(devVarsPath) short-circuits
+ devVarsPath,
+ patch: o11yDevVarsPatch({ O11Y_SLACK_CAPTURE_PORT: 4210 }),
+ stripKeys: O11Y_DEVVARS_STRIP_KEYS,
+ });
+ assert.equal(bootstrapResult.created, false);
+ assert.match(readFileSync(devVarsPath, "utf8"), /^O11Y_EXPORT_SECRET=\s*$/m, "still empty after bootstrapDevVars alone");
+
+ const result = fillEmptyDevVarsSecrets({ devVarsPath, keys: O11Y_DEVVARS_AUTOFILL_SECRET_KEYS });
+ assert.deepEqual(result.filled, ["O11Y_EXPORT_SECRET", "SENTRY_HOOK_SECRET"]);
+ const text = readFileSync(devVarsPath, "utf8");
+ assert.doesNotMatch(text, /^O11Y_EXPORT_SECRET=\s*$/m);
+ assert.doesNotMatch(text, /^SENTRY_HOOK_SECRET=\s*$/m);
+ });
+});
+
+test("fillEmptyDevVarsSecrets: never touches a key that already holds a real (non-empty) value", () => {
+ withTmpDir((dir) => {
+ const devVarsPath = path.join(dir, ".dev.vars");
+ writeFileSync(devVarsPath, "O11Y_EXPORT_SECRET=a-real-pasted-value\nSENTRY_HOOK_SECRET=\n");
+ const result = fillEmptyDevVarsSecrets({
+ devVarsPath,
+ keys: O11Y_DEVVARS_AUTOFILL_SECRET_KEYS,
+ generate: () => "generated",
+ });
+ assert.deepEqual(result.filled, ["SENTRY_HOOK_SECRET"]);
+ const text = readFileSync(devVarsPath, "utf8");
+ assert.match(text, /^O11Y_EXPORT_SECRET=a-real-pasted-value$/m);
+ assert.match(text, /^SENTRY_HOOK_SECRET=generated$/m);
+ });
+});
+
+test("fillEmptyDevVarsSecrets: no-op (no throw, filled: []) when the file does not exist", () => {
+ withTmpDir((dir) => {
+ const result = fillEmptyDevVarsSecrets({
+ devVarsPath: path.join(dir, "does-not-exist", ".dev.vars"),
+ keys: O11Y_DEVVARS_AUTOFILL_SECRET_KEYS,
+ });
+ assert.deepEqual(result.filled, []);
+ });
+});
+
+test("fillEmptyDevVarsSecrets: generates a real ephemeral value by default (not a fixed placeholder) — same shape as ephemeralSecret()", () => {
+ withTmpDir((dir) => {
+ const devVarsPath = path.join(dir, ".dev.vars");
+ writeFileSync(devVarsPath, "O11Y_EXPORT_SECRET=\n");
+ fillEmptyDevVarsSecrets({ devVarsPath, keys: ["O11Y_EXPORT_SECRET"] });
+ const text = readFileSync(devVarsPath, "utf8");
+ const m = /^O11Y_EXPORT_SECRET=([0-9a-f]{64})$/m.exec(text);
+ assert.ok(m, `expected a 32-byte hex value, got: ${text}`);
+ });
+});
+
+test("resolveReplaySecret: env value wins when both env and .dev.vars have it", () => {
+ const value = resolveReplaySecret("from-env", "/irrelevant", "O11Y_EXPORT_SECRET", () => "from-devvars");
+ assert.equal(value, "from-env");
+});
+
+test("resolveReplaySecret: falls back to .dev.vars when env is unset (the standalone-invocation case)", () => {
+ const value = resolveReplaySecret(undefined, "/irrelevant", "O11Y_EXPORT_SECRET", () => "from-devvars");
+ assert.equal(value, "from-devvars");
+});
+
+test("resolveReplaySecret: falls back to .dev.vars when env is an empty string too", () => {
+ const value = resolveReplaySecret("", "/irrelevant", "O11Y_EXPORT_SECRET", () => "from-devvars");
+ assert.equal(value, "from-devvars");
+});
+
+test("resolveReplaySecret: empty string when neither source has a value (never undefined/null — callers compare it with a header string)", () => {
+ assert.equal(resolveReplaySecret(undefined, "/irrelevant", "O11Y_EXPORT_SECRET", () => undefined), "");
+});
+
+test("resolveReplaySecret: reads the REAL workers/o11y/.dev.vars shape via readDevVarsLine (no injected readLine) — proves the wiring, not just the arithmetic", () => {
+ withTmpDir((dir) => {
+ const devVarsPath = path.join(dir, ".dev.vars");
+ writeFileSync(devVarsPath, "O11Y_EXPORT_SECRET=abc123\nSENTRY_HOOK_SECRET=\n");
+ assert.equal(resolveReplaySecret(undefined, devVarsPath, "O11Y_EXPORT_SECRET"), "abc123");
+ assert.equal(resolveReplaySecret(undefined, devVarsPath, "SENTRY_HOOK_SECRET"), "");
+ });
+});
+
+test("readDevVarsLine / checkDevVarsPortDrift: detects a port mismatch and reports none when it matches", () => {
+ withTmpDir((dir) => {
+ const devVarsPath = path.join(dir, ".dev.vars");
+ writeFileSync(devVarsPath, 'PREVIEW_HOST="localhost:8787"\n');
+ assert.equal(checkDevVarsPortDrift(devVarsPath, "PREVIEW_HOST", 8787), null);
+ const drift = checkDevVarsPortDrift(devVarsPath, "PREVIEW_HOST", 6250);
+ assert.match(drift, /8787/);
+ assert.match(drift, /6250/);
+ });
+});
+
+test("resolveDevVarsPortAdoption: PREVIEW_HOST port is ADOPTED when API_DEV_PORT was not explicitly set (bug 2's fix)", () => {
+ withTmpDir((dir) => {
+ const devVarsPath = path.join(dir, ".dev.vars");
+ // The user's exact real .dev.vars: PREVIEW_HOST pinned to 8799 while
+ // this run's own default/resolved port is 8787.
+ writeFileSync(devVarsPath, 'PREVIEW_HOST="localhost:8799"\n');
+
+ const adoption = resolveDevVarsPortAdoption({ devVarsPath, key: "PREVIEW_HOST", currentPort: 8787, explicit: false });
+ assert.equal(adoption.port, 8799, "the .dev.vars port is adopted, since .dev.vars always wins anyway");
+ assert.equal(adoption.adopted, true);
+ assert.match(adoption.message, /8799/);
+ assert.match(adoption.message, /adopting/);
+ });
+});
+
+test("resolveDevVarsPortAdoption: WARNS instead (does not override) when API_DEV_PORT was explicitly set and conflicts", () => {
+ withTmpDir((dir) => {
+ const devVarsPath = path.join(dir, ".dev.vars");
+ writeFileSync(devVarsPath, 'PREVIEW_HOST="localhost:8799"\n');
+
+ const adoption = resolveDevVarsPortAdoption({ devVarsPath, key: "PREVIEW_HOST", currentPort: 6450, explicit: true });
+ assert.equal(adoption.port, 6450, "an explicit override is never silently discarded");
+ assert.equal(adoption.adopted, false);
+ assert.match(adoption.message, /8799/);
+ assert.match(adoption.message, /6450/);
+ });
+});
+
+test("resolveDevVarsPortAdoption: no message and no change when the port already matches, or the key is undeclared", () => {
+ withTmpDir((dir) => {
+ const devVarsPath = path.join(dir, ".dev.vars");
+ writeFileSync(devVarsPath, 'PREVIEW_HOST="localhost:8787"\n');
+ assert.deepEqual(resolveDevVarsPortAdoption({ devVarsPath, key: "PREVIEW_HOST", currentPort: 8787, explicit: false }), {
+ port: 8787,
+ adopted: false,
+ message: null,
+ });
+ assert.deepEqual(resolveDevVarsPortAdoption({ devVarsPath, key: "SLACK_WEBHOOK_URL", currentPort: 4210, explicit: false }), {
+ port: 4210,
+ adopted: false,
+ message: null,
+ });
+ });
+});
+
+test("assertNoPortCollisions: throws for a duplicate port value, passes for all-distinct ports", () => {
+ assert.doesNotThrow(() => assertNoPortCollisions({ A: 1, B: 2 }));
+ assert.throws(() => assertNoPortCollisions({ A: 1, B: 1 }), /port collision/);
+});
+
+test("checkO11yDevVarsStaleness: warns when a pre-existing .dev.vars declares DEV_ADMIN or O11Y_SESSION_SECRET empty", () => {
+ withTmpDir((dir) => {
+ const devVarsPath = path.join(dir, ".dev.vars");
+
+ // A healthy, freshly-bootstrapped file: no warnings.
+ writeFileSync(devVarsPath, "O11Y_ENV=local\nDEV_ADMIN=dev@handsontable.com\n");
+ assert.deepEqual(checkO11yDevVarsStaleness(devVarsPath), []);
+
+ // An old .dev.vars from before this file's own key set was added
+ // DEV_ADMIN/O11Y_SESSION_SECRET handling existed, both declared empty.
+ writeFileSync(devVarsPath, "O11Y_ENV=local\nDEV_ADMIN=\nO11Y_SESSION_SECRET=\n");
+ const warnings = checkO11yDevVarsStaleness(devVarsPath);
+ assert.equal(warnings.length, 2, "both the bypass and the secret must each get their own warning");
+ assert.ok(warnings.some((w) => w.includes("DEV_ADMIN")));
+ assert.ok(warnings.some((w) => w.includes("O11Y_SESSION_SECRET")));
+
+ // O11Y_SESSION_SECRET simply ABSENT (the normal, freshly-stripped case)
+ // must never warn — only DECLARED-but-empty is the problem.
+ writeFileSync(devVarsPath, "O11Y_ENV=local\nDEV_ADMIN=dev@handsontable.com\n");
+ assert.deepEqual(checkO11yDevVarsStaleness(devVarsPath), []);
+ });
+});
+
+test("checkO11yDevVarsStaleness: warns when a pre-existing .dev.vars declares SLACK_WEBHOOK_URL or AE_SQL_TOKEN empty", () => {
+ withTmpDir((dir) => {
+ const devVarsPath = path.join(dir, ".dev.vars");
+
+ // A healthy, freshly-bootstrapped file: no warnings.
+ writeFileSync(
+ devVarsPath,
+ "O11Y_ENV=local\nDEV_ADMIN=dev@handsontable.com\nSLACK_WEBHOOK_URL=http://localhost:4210/slack\nAE_SQL_TOKEN=local-dev-token\n",
+ );
+ assert.deepEqual(checkO11yDevVarsStaleness(devVarsPath), []);
+
+ // The stale-bootstrap shape: an old .dev.vars from before
+ // o11yDevVarsPatch grew these two keys, both declared empty (exactly
+ // what workers/o11y/.dev.vars.example still declares them as before a
+ // fresh bootstrap patches them).
+ writeFileSync(
+ devVarsPath,
+ "O11Y_ENV=local\nDEV_ADMIN=dev@handsontable.com\nSLACK_WEBHOOK_URL=\nAE_SQL_TOKEN=\n",
+ );
+ const warnings = checkO11yDevVarsStaleness(devVarsPath);
+ assert.equal(warnings.length, 2, "both the Slack webhook and the ClickHouse token must each get their own warning");
+ assert.ok(warnings.some((w) => w.includes("SLACK_WEBHOOK_URL")));
+ assert.ok(warnings.some((w) => w.includes("AE_SQL_TOKEN")));
+
+ // Absent entirely must never warn — only DECLARED-but-empty is the
+ // problem (same rule as DEV_ADMIN/O11Y_SESSION_SECRET above).
+ writeFileSync(devVarsPath, "O11Y_ENV=local\nDEV_ADMIN=dev@handsontable.com\n");
+ assert.deepEqual(checkO11yDevVarsStaleness(devVarsPath), []);
+ });
+});
+
+test("o11yDevVarsPatch + bootstrapDevVars: a FRESH bootstrap never leaves SLACK_WEBHOOK_URL or AE_SQL_TOKEN declared empty", () => {
+ withTmpDir((dir) => {
+ const examplePath = path.join(dir, ".dev.vars.example");
+ const devVarsPath = path.join(dir, ".dev.vars");
+ // The real workers/o11y/.dev.vars.example shape for these two keys.
+ writeFileSync(examplePath, "DEV_ADMIN=\nAE_SQL_TOKEN=\nSLACK_WEBHOOK_URL=\nO11Y_SESSION_SECRET=\nO11Y_ENV=local\n");
+
+ const result = bootstrapDevVars({
+ examplePath,
+ devVarsPath,
+ patch: o11yDevVarsPatch({ O11Y_SLACK_CAPTURE_PORT: 4210 }),
+ stripKeys: O11Y_DEVVARS_STRIP_KEYS,
+ });
+ assert.ok(result.patched.includes("AE_SQL_TOKEN"));
+ assert.ok(result.patched.includes("SLACK_WEBHOOK_URL"));
+
+ // The staleness check must find nothing to warn about on this fresh file.
+ assert.deepEqual(checkO11yDevVarsStaleness(devVarsPath), []);
+ });
+});
+
+test("ephemeralSecret: never the same value twice, and never written by bootstrapDevVars", () => {
+ assert.notEqual(ephemeralSecret(), ephemeralSecret());
+ assert.equal(ephemeralSecret().length, 64); // 32 bytes, hex
+});
+
+test("redactArgsForLog: O11Y_SESSION_SECRET's --var value is redacted for dev.mjs's own log line", () => {
+ const secret = ephemeralSecret();
+ const args = [
+ "dev",
+ "--port",
+ "4200",
+ "--var",
+ `O11Y_SESSION_SECRET:${secret}`,
+ "--var",
+ "O11Y_LOCAL_MINIO_PORT:9000",
+ ];
+ const redacted = redactArgsForLog(args);
+ assert.ok(!redacted.join(" ").includes(secret), "the secret value must never appear in the redacted args");
+ assert.deepEqual(redacted, ["dev", "--port", "4200", "--var", "O11Y_SESSION_SECRET:", "--var", "O11Y_LOCAL_MINIO_PORT:9000"]);
+ // The real spawn() args are untouched — only a copy for display is redacted.
+ assert.ok(args.join(" ").includes(secret), "the original args array passed to spawn() must be unaffected");
+});
+
+// ---------------------------------------------------------------------------
+// migration tracking
+// ---------------------------------------------------------------------------
+
+function makeMigrationsDir(dir, files) {
+ const migrationsDir = path.join(dir, "migrations");
+ mkdirSync(migrationsDir);
+ for (const f of files) writeFileSync(path.join(migrationsDir, f), "-- sql\n");
+ return migrationsDir;
+}
+
+test("planMigrations: with no record, every file on disk is pending", () => {
+ withTmpDir((dir) => {
+ const migrationsDir = makeMigrationsDir(dir, ["0001_init.sql", "0002_more.sql"]);
+ const recordPath = path.join(dir, "record.json");
+ const plan = planMigrations({ migrationsDir, recordPath });
+ assert.deepEqual(plan.pending, ["0001_init.sql", "0002_more.sql"]);
+ });
+});
+
+test("applyMigrations: a second run applies nothing once the first run recorded every file", async () => {
+ await withTmpDir(async (dir) => {
+ const migrationsDir = makeMigrationsDir(dir, ["0001_init.sql", "0002_more.sql", "0003_third.sql"]);
+ const recordPath = migrationRecordPath(dir);
+ const calls = [];
+ const run = async (args) => {
+ calls.push(args);
+ };
+
+ const first = await applyMigrations({ migrationsDir, recordPath, dbName: "handsontable-demos", run });
+ assert.deepEqual(first.applied, ["0001_init.sql", "0002_more.sql", "0003_third.sql"]);
+ assert.equal(calls.length, 3);
+ assert.deepEqual(calls[0], ["d1", "execute", "handsontable-demos", "--local", "--file=migrations/0001_init.sql", "-y"]);
+
+ const second = await applyMigrations({ migrationsDir, recordPath, dbName: "handsontable-demos", run });
+ assert.deepEqual(second.applied, []);
+ assert.equal(calls.length, 3, "no new wrangler calls on the second run");
+ });
+});
+
+test("applyMigrations: a new migration file added after the first run is the only one applied on the second run", async () => {
+ await withTmpDir(async (dir) => {
+ const migrationsDir = makeMigrationsDir(dir, ["0001_init.sql"]);
+ const recordPath = migrationRecordPath(dir);
+ const calls = [];
+ const run = async (args) => {
+ calls.push(args);
+ };
+ await applyMigrations({ migrationsDir, recordPath, dbName: "handsontable-demos", run });
+
+ writeFileSync(path.join(migrationsDir, "0002_new.sql"), "-- sql\n");
+ const second = await applyMigrations({ migrationsDir, recordPath, dbName: "handsontable-demos", run });
+ assert.deepEqual(second.applied, ["0002_new.sql"]);
+ });
+});
+
+test("applyMigrations: records each file as it succeeds, so a failure partway through doesn't lose earlier progress", async () => {
+ await withTmpDir(async (dir) => {
+ const migrationsDir = makeMigrationsDir(dir, ["0001_init.sql", "0002_boom.sql"]);
+ const recordPath = migrationRecordPath(dir);
+ const run = async (args) => {
+ if (args.includes("--file=migrations/0002_boom.sql")) throw new Error("simulated d1 failure");
+ };
+ await assert.rejects(() => applyMigrations({ migrationsDir, recordPath, dbName: "handsontable-demos", run }));
+ const plan = planMigrations({ migrationsDir, recordPath });
+ assert.deepEqual(plan.applied, ["0001_init.sql"]);
+ assert.deepEqual(plan.pending, ["0002_boom.sql"]);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// migration schema probe — adopting a pre-existing local D1 with no record:
+// a hand-migrated local D1, no dev-migrations-applied.json, re-applying
+// from 0001 must not die on 0003's non-idempotent `ALTER TABLE ... ADD
+// COLUMN` with a raw `duplicate column name` failure.
+// ---------------------------------------------------------------------------
+
+/** A stub `query` (the injectable `applyMigrations`/`snapshotLocalSchema`
+ * takes in place of a real `wrangler d1 execute ... --json`) backed by an
+ * in-memory {tables: Set, indexes: Set, columns: {[table]:
+ * Set}} — enough to answer both queries `snapshotLocalSchema`
+ * issues, shaped like wrangler's own `--json` output. */
+function stubD1Query(state) {
+ return async (args) => {
+ const command = args[args.indexOf("--command") + 1];
+ if (command.includes("sqlite_master")) {
+ const results = [
+ ...[...state.tables].map((name) => ({ type: "table", name })),
+ ...[...state.indexes].map((name) => ({ type: "index", name })),
+ ];
+ return JSON.stringify([{ results, success: true, meta: { duration: 0 } }]);
+ }
+ const m = /PRAGMA table_info\((\w+)\)/.exec(command);
+ if (m) {
+ const cols = state.columns[m[1]] ?? new Set();
+ return JSON.stringify([{ results: [...cols].map((name) => ({ name })), success: true, meta: { duration: 0 } }]);
+ }
+ throw new Error(`stubD1Query: unrecognized --command: ${command}`);
+ };
+}
+
+test("parseMigrationTargets: CREATE TABLE / CREATE INDEX / ALTER TABLE ADD COLUMN are checkable; any other statement makes the file non-checkable", () => {
+ assert.deepEqual(parseMigrationTargets("CREATE TABLE IF NOT EXISTS demos (id TEXT);\nCREATE INDEX IF NOT EXISTS idx_x ON demos(id);"), {
+ checkable: true,
+ targets: [
+ { type: "table", name: "demos" },
+ { type: "index", name: "idx_x" },
+ ],
+ });
+ assert.deepEqual(parseMigrationTargets("ALTER TABLE demos ADD COLUMN foo TEXT;"), {
+ checkable: true,
+ targets: [{ type: "column", table: "demos", name: "foo" }],
+ });
+ assert.deepEqual(parseMigrationTargets("ALTER TABLE demos ADD foo TEXT;"), {
+ checkable: true,
+ targets: [{ type: "column", table: "demos", name: "foo" }],
+ });
+ // DROP INDEX (0002_buildkey_nonunique.sql's real shape) is not a
+ // recognized "additive, checkable" statement — the whole file must fall
+ // through to "always run it" rather than risk skipping the DROP because
+ // the CREATE INDEX that follows it happens to already exist.
+ assert.deepEqual(parseMigrationTargets("DROP INDEX IF EXISTS idx_x;\nCREATE INDEX IF NOT EXISTS idx_x ON demos(id);"), {
+ checkable: false,
+ targets: [],
+ });
+ // Empty file: never a vacuous "already applied".
+ assert.deepEqual(parseMigrationTargets("-- just a comment\n"), { checkable: false, targets: [] });
+});
+
+test("parseMigrationTargets: pinned against every real workers/api/migrations/*.sql file", () => {
+ const migrationsDir = path.join(RUNNER_ROOT, "workers", "api", "migrations");
+ const files = readFileSync(path.join(migrationsDir, "0001_init.sql"), "utf8"); // sanity: file exists
+ assert.ok(files.length > 0);
+
+ const read = (name) => readFileSync(path.join(migrationsDir, name), "utf8");
+ assert.deepEqual(parseMigrationTargets(read("0001_init.sql")), {
+ checkable: true,
+ targets: [
+ { type: "table", name: "demos" },
+ { type: "index", name: "idx_demos_framework" },
+ { type: "index", name: "idx_demos_created_by" },
+ { type: "index", name: "idx_demos_forked_from" },
+ { type: "index", name: "idx_demos_buildkey" },
+ { type: "table", name: "build_cache" },
+ ],
+ });
+ assert.equal(parseMigrationTargets(read("0002_buildkey_nonunique.sql")).checkable, false, "0002's DROP INDEX must not be treated as checkable");
+ assert.deepEqual(parseMigrationTargets(read("0003_cost_ledger.sql")), {
+ checkable: true,
+ targets: [
+ { type: "table", name: "cost_ledger" },
+ { type: "index", name: "idx_cost_ledger_day" },
+ { type: "table", name: "usage_daily" },
+ { type: "index", name: "idx_usage_daily_day" },
+ { type: "column", table: "demos", name: "artifacts_purged_at" },
+ ],
+ });
+ assert.deepEqual(parseMigrationTargets(read("0007_build_status.sql")), {
+ checkable: true,
+ targets: [
+ { type: "column", table: "demos", name: "build_status" },
+ { type: "column", table: "demos", name: "build_error" },
+ ],
+ });
+ // 0009_example_daily_downloaded.sql: the second real
+ // ALTER TABLE ... ADD COLUMN file in this migrations dir (after 0003/0007,
+ // both against `demos`) — pinned explicitly, not just swept into the
+ // "checkable with >=1 target" loop below, because it is the one that
+ // exercises a table OTHER than `demos` going through the same adoption
+ // path (see the `applyMigrations` adoption test further down).
+ assert.deepEqual(parseMigrationTargets(read("0009_example_daily_downloaded.sql")), {
+ checkable: true,
+ targets: [{ type: "column", table: "example_daily", name: "downloaded" }],
+ });
+ // Every file must at least parse without throwing and either be checkable
+ // with >=1 target, or explicitly non-checkable — never checkable with zero
+ // targets (that would be silently skippable).
+ for (const name of ["0004_settings_and_analytics.sql", "0005_profiles.sql", "0006_api_tokens.sql", "0008_example_daily.sql"]) {
+ const parsed = parseMigrationTargets(read(name));
+ assert.ok(parsed.checkable, `${name} expected checkable`);
+ assert.ok(parsed.targets.length > 0, `${name} expected at least one target`);
+ }
+});
+
+test("isMigrationAlreadyApplied: true only when every target is present; false for an empty/unchecked target list", () => {
+ const snapshot = { tableNames: new Set(["demos"]), indexNames: new Set(["idx_x"]), columns: { demos: new Set(["id", "artifacts_purged_at"]) } };
+ assert.equal(isMigrationAlreadyApplied([{ type: "table", name: "demos" }], snapshot), true);
+ assert.equal(isMigrationAlreadyApplied([{ type: "column", table: "demos", name: "artifacts_purged_at" }], snapshot), true);
+ assert.equal(isMigrationAlreadyApplied([{ type: "column", table: "demos", name: "build_status" }], snapshot), false);
+ assert.equal(isMigrationAlreadyApplied([{ type: "table", name: "demos" }, { type: "table", name: "nope" }], snapshot), false);
+ assert.equal(isMigrationAlreadyApplied([], snapshot), false, "an empty target list must never read as already applied");
+});
+
+test("snapshotLocalSchema: one sqlite_master query plus one PRAGMA per requested table, parsed from wrangler --json shape", async () => {
+ const calls = [];
+ const query = async (args) => {
+ calls.push(args);
+ return stubD1Query({ tables: new Set(["demos"]), indexes: new Set(["idx_x"]), columns: { demos: new Set(["id", "artifacts_purged_at"]) } })(args);
+ };
+ const snapshot = await snapshotLocalSchema({ dbName: "handsontable-demos", tables: ["demos"], query });
+ assert.deepEqual([...snapshot.tableNames], ["demos"]);
+ assert.deepEqual([...snapshot.indexNames], ["idx_x"]);
+ assert.deepEqual([...snapshot.columns.demos].sort(), ["artifacts_purged_at", "id"]);
+ assert.equal(calls.length, 2, "one sqlite_master query + one PRAGMA for the one requested table");
+});
+
+test("applyMigrations: a hand-migrated local D1 with NO record — every pending file whose targets already exist is adopted, not re-applied", async () => {
+ await withTmpDir(async (dir) => {
+ const migrationsDir = path.join(dir, "migrations");
+ mkdirSync(migrationsDir);
+ writeFileSync(path.join(migrationsDir, "0001_init.sql"), "CREATE TABLE IF NOT EXISTS demos (id TEXT PRIMARY KEY);\n");
+ writeFileSync(
+ path.join(migrationsDir, "0003_cost_ledger.sql"),
+ "CREATE TABLE IF NOT EXISTS cost_ledger (day TEXT);\nALTER TABLE demos ADD COLUMN artifacts_purged_at TEXT;\n",
+ );
+ writeFileSync(path.join(migrationsDir, "0007_build_status.sql"), "ALTER TABLE demos ADD COLUMN build_status TEXT;\n");
+ const recordPath = migrationRecordPath(dir); // no record file at all — the exact bug precondition
+
+ const state = {
+ tables: new Set(["demos", "cost_ledger"]), // 0001, 0003's table: already there
+ indexes: new Set(),
+ columns: { demos: new Set(["id", "artifacts_purged_at"]) }, // 0003's column exists; 0007's build_status does NOT
+ };
+ const runCalls = [];
+ const run = async (args) => {
+ runCalls.push(args);
+ // Applying 0007 for real adds the column this run's own snapshot didn't have yet.
+ if (args.includes("--file=migrations/0007_build_status.sql")) state.columns.demos.add("build_status");
+ };
+ const query = stubD1Query(state);
+
+ const result = await applyMigrations({ migrationsDir, recordPath, dbName: "handsontable-demos", run, query, log: () => {} });
+
+ assert.deepEqual(result.adopted, ["0001_init.sql", "0003_cost_ledger.sql"], "both fully-pre-existing files are adopted, not re-run");
+ assert.deepEqual(result.applied, ["0007_build_status.sql"], "the file whose column is genuinely missing still runs for real");
+ assert.deepEqual(runCalls, [["d1", "execute", "handsontable-demos", "--local", "--file=migrations/0007_build_status.sql", "-y"]]);
+ assert.deepEqual(readAppliedMigrations(recordPath), ["0001_init.sql", "0003_cost_ledger.sql", "0007_build_status.sql"].sort());
+ });
+});
+
+// The same adoption path, exercised against the real
+// 0008/0009_example_daily_downloaded.sql pair — a table (`example_daily`)
+// that is not `demos`, proving `applyMigrations`' `alterTables` derivation
+// is not hardcoded to the one table every earlier migration in this dir
+// happens to alter.
+test("applyMigrations: a local D1 that already has example_daily.downloaded (dev stack migrated by hand) adopts 0009 instead of re-running it", async () => {
+ await withTmpDir(async (dir) => {
+ const migrationsDir = path.join(dir, "migrations");
+ mkdirSync(migrationsDir);
+ const realMigrationsDir = path.join(RUNNER_ROOT, "workers", "api", "migrations");
+ writeFileSync(
+ path.join(migrationsDir, "0008_example_daily.sql"),
+ readFileSync(path.join(realMigrationsDir, "0008_example_daily.sql"), "utf8"),
+ );
+ writeFileSync(
+ path.join(migrationsDir, "0009_example_daily_downloaded.sql"),
+ readFileSync(path.join(realMigrationsDir, "0009_example_daily_downloaded.sql"), "utf8"),
+ );
+ // 0008 was recorded as applied by an earlier run; 0009 is pending, and a
+ // developer's local D1 already carries the `downloaded` column (e.g.
+ // adopted by hand, or applied once before the applied-migrations record
+ // existed — the same class of drift the adjacent `demos` test above
+ // covers).
+ mkdirSync(path.dirname(migrationRecordPath(dir)), { recursive: true });
+ writeFileSync(migrationRecordPath(dir), JSON.stringify(["0008_example_daily.sql"]));
+
+ const state = {
+ tables: new Set(["example_daily"]),
+ indexes: new Set(["idx_example_daily_day"]),
+ columns: { example_daily: new Set(["day", "kind", "ref", "area", "framework", "ht_major", "opens", "engaged", "forked", "saved", "shared", "downloaded"]) },
+ };
+ const runCalls = [];
+ const run = async (args) => runCalls.push(args);
+ const query = stubD1Query(state);
+
+ const result = await applyMigrations({ migrationsDir, recordPath: migrationRecordPath(dir), dbName: "handsontable-demos", run, query, log: () => {} });
+
+ assert.deepEqual(result.adopted, ["0009_example_daily_downloaded.sql"], "the column already exists — 0009 must be adopted, not re-run");
+ assert.deepEqual(result.applied, [], "never a real d1 execute for a file whose only target is already present");
+ assert.deepEqual(runCalls, [], "no wrangler d1 execute call at all — this is what avoids the 'duplicate column name' failure");
+ assert.deepEqual(readAppliedMigrations(migrationRecordPath(dir)), ["0008_example_daily.sql", "0009_example_daily_downloaded.sql"].sort());
+ });
+});
+
+test("applyMigrations: without `query`, behavior is unchanged — every pending file is always re-run (no probing)", async () => {
+ await withTmpDir(async (dir) => {
+ const migrationsDir = makeMigrationsDir(dir, ["0001_init.sql"]);
+ const recordPath = migrationRecordPath(dir);
+ const calls = [];
+ const run = async (args) => calls.push(args);
+ const result = await applyMigrations({ migrationsDir, recordPath, dbName: "handsontable-demos", run });
+ assert.deepEqual(result.applied, ["0001_init.sql"]);
+ assert.deepEqual(result.adopted, []);
+ assert.equal(calls.length, 1);
+ });
+});
+
+test("applyMigrations: a genuinely failing migration throws a MigrationError with the file and the SQLite message, never swallowed as adopted", async () => {
+ await withTmpDir(async (dir) => {
+ const migrationsDir = makeMigrationsDir(dir, ["0001_init.sql", "0002_boom.sql"]);
+ const recordPath = migrationRecordPath(dir);
+ // Shaped exactly like a real execFileSync failure: wrangler's ANSI-wrapped
+ // "duplicate column name" text on stderr (captured against wrangler 4.108).
+ const stderr = Buffer.from(
+ "\u001b[31m✘ \u001b[41;31m[\u001b[41;97mERROR\u001b[41;31m]\u001b[0m \u001b[1mduplicate column name: artifacts_purged_at: SQLITE_ERROR\u001b[0m\n",
+ );
+ const run = async (args) => {
+ if (args.includes("--file=migrations/0002_boom.sql")) {
+ const err = new Error("Command failed");
+ err.stderr = stderr;
+ err.stdout = Buffer.from("");
+ err.status = 1;
+ throw err;
+ }
+ };
+ await assert.rejects(
+ () => applyMigrations({ migrationsDir, recordPath, dbName: "handsontable-demos", run, log: () => {} }),
+ (err) => {
+ assert.ok(err instanceof MigrationError);
+ assert.equal(err.file, "0002_boom.sql");
+ assert.equal(err.sqliteMessage, "duplicate column name: artifacts_purged_at: SQLITE_ERROR");
+ assert.equal(err.recordPath, recordPath);
+ return true;
+ },
+ );
+ // Not swallowed: 0002 must NOT be recorded as applied/adopted.
+ assert.deepEqual(readAppliedMigrations(recordPath), ["0001_init.sql"]);
+ });
+});
+
+test("applyMigrations: a genuinely DIFFERENT SQL error is reported as itself, not misread as a duplicate-column adoption case", async () => {
+ await withTmpDir(async (dir) => {
+ const migrationsDir = makeMigrationsDir(dir, ["0001_typo.sql"]);
+ const recordPath = migrationRecordPath(dir);
+ const run = async () => {
+ const err = new Error("Command failed");
+ err.stderr = Buffer.from("\u001b[31m✘ \u001b[41;31m[\u001b[41;97mERROR\u001b[41;31m]\u001b[0m \u001b[1mno such table: nope: SQLITE_ERROR\u001b[0m\n");
+ err.stdout = Buffer.from("");
+ throw err;
+ };
+ await assert.rejects(
+ () => applyMigrations({ migrationsDir, recordPath, dbName: "handsontable-demos", run, log: () => {} }),
+ (err) => {
+ assert.equal(err.sqliteMessage, "no such table: nope: SQLITE_ERROR");
+ return true;
+ },
+ );
+ });
+});
+
+test("formatMigrationError: one clean line naming the file, the SQLite message, the record path, and --reset-local-db — never a raw stack trace", () => {
+ const err = new MigrationError({
+ file: "0003_cost_ledger.sql",
+ sqliteMessage: "duplicate column name: artifacts_purged_at: SQLITE_ERROR",
+ recordPath: "/x/workers/api/.wrangler/state/dev-migrations-applied.json",
+ action: "applying",
+ });
+ const formatted = formatMigrationError(err);
+ assert.match(formatted, /^error:/);
+ assert.match(formatted, /0003_cost_ledger\.sql/);
+ assert.match(formatted, /duplicate column name: artifacts_purged_at: SQLITE_ERROR/);
+ assert.match(formatted, /dev-migrations-applied\.json/);
+ assert.match(formatted, /--reset-local-db/);
+ assert.ok(!formatted.includes("\n at "), "must not include a stack-trace-shaped line");
+});
+
+test("resetLocalD1: deletes local D1 state and the applied-migrations record, and logs what it deleted", () => {
+ withTmpDir((dir) => {
+ const apiDir = path.join(dir, "workers", "api");
+ const stateDir = path.join(apiDir, ".wrangler", "state", "v3", "d1");
+ const recordPath = migrationRecordPath(apiDir);
+ mkdirSync(stateDir, { recursive: true });
+ writeFileSync(path.join(stateDir, "some.sqlite"), "fake");
+ mkdirSync(path.dirname(recordPath), { recursive: true });
+ writeFileSync(recordPath, "[]\n");
+
+ const lines = [];
+ resetLocalD1(apiDir, undefined, (l) => lines.push(l));
+
+ assert.equal(existsSync(stateDir), false);
+ assert.equal(existsSync(recordPath), false);
+ assert.equal(lines.length, 1);
+ assert.match(lines[0], /--reset-local-db/);
+ assert.match(lines[0], /deleted/);
+
+ // Second call, nothing left: says so, doesn't throw.
+ const lines2 = [];
+ resetLocalD1(apiDir, undefined, (l) => lines2.push(l));
+ assert.match(lines2[0], /nothing to delete/);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Docker
+// ---------------------------------------------------------------------------
+
+test("isDockerAvailable: true when `docker info` succeeds", () => {
+ assert.equal(isDockerAvailable(() => {}), true);
+});
+
+test("isDockerAvailable: false when `docker info` throws", () => {
+ assert.equal(
+ isDockerAvailable(() => {
+ throw new Error("Cannot connect to the Docker daemon");
+ }),
+ false,
+ );
+});
+
+test("DOCKER_NOT_RUNNING_MESSAGE: names the fix (start Docker), not just the symptom", () => {
+ assert.match(DOCKER_NOT_RUNNING_MESSAGE, /docker info/);
+ assert.match(DOCKER_NOT_RUNNING_MESSAGE, /Start Docker/);
+});
+
+// `--wait` makes `docker compose ... up -d --wait minio clickhouse`
+// block-and-fail on a real condition (a named service's healthcheck never
+// goes green). That throw must not happen before this call's own teardown
+// step is pushed onto `teardownSteps`, or it propagates straight past the
+// try/catch around the readiness wait further down to `main().catch`,
+// which only logs and `process.exit(1)`s — no cleanup, no `docker compose
+// down`, orphaning whichever of minio/clickhouse did start under `up -d`.
+// `bringUpO11yCompose` (extracted to dev-lib.mjs so this is unit-testable
+// with a stub, matching `resetO11yLocalState`'s own pattern) needs a
+// try/catch around the up call, not a bare
+// `execFileSyncImpl("docker", [...up...])`.
+test("bringUpO11yCompose: tears down (no -v, data kept) when the compose up itself throws, then rethrows", () => {
+ const calls = [];
+ const execFileSyncImpl = (cmd, args, opts) => {
+ calls.push({ cmd, args, opts });
+ if (args[3] === "up") throw new Error("container minio did not become healthy");
+ return "";
+ };
+ assert.throws(
+ () => bringUpO11yCompose({ composeFile: "/x/compose.yml", composeEnv: { COMPOSE_PROJECT_NAME: "o11y-test" }, execFileSyncImpl }),
+ /container minio did not become healthy/,
+ "must rethrow the original up failure, not swallow it",
+ );
+ assert.equal(calls.length, 2, "must call docker exactly twice: the failed up, then a down");
+ assert.deepEqual(calls[0].args, ["compose", "-f", "/x/compose.yml", "up", "-d", "--wait", "minio", "clickhouse"]);
+ assert.deepEqual(calls[1].args, composeDownArgs("/x/compose.yml"), "the teardown must be composeDownArgs' own no -v shape");
+ assert.doesNotMatch(calls[1].args.join(" "), /-v/, "must never pass -v here — only --fresh's own explicit wipe does that");
+});
+
+test("bringUpO11yCompose: a successful up calls docker exactly once, no teardown", () => {
+ const calls = [];
+ const execFileSyncImpl = (cmd, args, opts) => {
+ calls.push({ cmd, args, opts });
+ return "";
+ };
+ bringUpO11yCompose({ composeFile: "/x/compose.yml", composeEnv: {}, execFileSyncImpl });
+ assert.equal(calls.length, 1, "must not attempt a teardown when up itself succeeds");
+});
+
+test("bringUpO11yCompose: still rethrows the original up error even if the teardown attempt ALSO fails", () => {
+ const execFileSyncImpl = (cmd, args) => {
+ if (args[3] === "up") throw new Error("up failed");
+ throw new Error("down also failed");
+ };
+ assert.throws(() => bringUpO11yCompose({ composeFile: "/x/compose.yml", composeEnv: {}, execFileSyncImpl }), /up failed/);
+});
+
+// `--reset-local-db` must run after the Docker-availability check: `dev.mjs
+// --tier=2 --reset-local-db` with Docker not running must not delete
+// workers/api's local D1 state before exiting on the Docker-not-running
+// error. `resetLocalD1`'s call site in dev.mjs (unlike its unit-tested
+// form above) is hardcoded to the real `workers/api` dir, so this test
+// seeds and inspects that real (gitignored, disposable)
+// `.wrangler/state/v3/d1` directory directly rather than a temp one.
+test("CLI: `dev.mjs --tier=2 --reset-local-db` does NOT wipe local D1 state when Docker is not running (Docker check runs first)", () => {
+ const stubBinDir = path.join(HERE, "fixtures", "stub-bin");
+ const devScript = path.join(RUNNER_ROOT, "scripts", "dev.mjs");
+ const apiDir = path.join(RUNNER_ROOT, "workers", "api");
+ const d1StateDir = path.join(apiDir, ".wrangler", "state", "v3", "d1");
+ const markerPath = path.join(d1StateDir, "b9-test-marker.txt");
+ // SAFETY: `d1StateDir` is REAL local wrangler/D1 state for this worktree
+ // (this worktree may genuinely have some already, e.g. from an earlier
+ // `--tier=2`/`--tier=full` run or migrations applied by another test) —
+ // this must never destroy it. Record whether it pre-existed; the
+ // `finally` below removes only what THIS test itself added (the marker
+ // file, or — only if the whole directory did not exist before — the
+ // directory this test's own `mkdirSync` created).
+ const preExisted = existsSync(d1StateDir);
+ mkdirSync(d1StateDir, { recursive: true });
+ writeFileSync(markerPath, "b9 marker\n");
+ try {
+ const result = spawnSync(process.execPath, [devScript, "--tier=2", "--reset-local-db"], {
+ cwd: RUNNER_ROOT,
+ encoding: "utf8",
+ timeout: 10000,
+ env: {
+ ...process.env,
+ PATH: `${stubBinDir}:${process.env.PATH}`,
+ STUB_DOCKER_MODE: "fail",
+ },
+ });
+ const output = `${result.stdout}${result.stderr}`;
+ assert.notEqual(result.status, 0);
+ assert.match(output, /Start Docker/, "must fail on the Docker check");
+ assert.doesNotMatch(output, /reset-local-db: deleted/, "must never actually run the reset once Docker is confirmed unavailable");
+ assert.equal(existsSync(markerPath), true, "local D1 state must survive a run that fails the Docker check");
+ } finally {
+ // Only remove what this test added — never the real pre-existing state.
+ if (preExisted) {
+ rmSync(markerPath, { force: true });
+ } else {
+ rmSync(d1StateDir, { recursive: true, force: true });
+ }
+ }
+});
+
+test("CLI: `dev.mjs --tier=2` fails fast with the Docker message when `docker info` fails, before spawning anything else", () => {
+ const stubBinDir = path.join(HERE, "fixtures", "stub-bin");
+ const devScript = path.join(RUNNER_ROOT, "scripts", "dev.mjs");
+ const result = spawnSync(process.execPath, [devScript, "--tier=2"], {
+ cwd: RUNNER_ROOT,
+ encoding: "utf8",
+ env: {
+ ...process.env,
+ PATH: `${stubBinDir}:${process.env.PATH}`,
+ STUB_DOCKER_MODE: "fail",
+ },
+ });
+ assert.notEqual(result.status, 0);
+ const output = `${result.stdout}${result.stderr}`;
+ assert.match(output, /docker info/);
+ assert.match(output, /Start Docker/);
+});
+
+// ---------------------------------------------------------------------------
+// Container base-image pre-pull (dev-prepull task)
+// ---------------------------------------------------------------------------
+
+test("parseDockerfileBaseImages: single-stage FROM", () => {
+ const dockerfile = `FROM docker.io/cloudflare/sandbox:0.12.3\nWORKDIR /app\n`;
+ assert.deepEqual(parseDockerfileBaseImages(dockerfile), ["docker.io/cloudflare/sandbox:0.12.3"]);
+});
+
+test("parseDockerfileBaseImages: multi-stage build — a later FROM referencing an earlier stage's alias is excluded", () => {
+ const dockerfile = [
+ "FROM golang:1.20 AS build",
+ "RUN go build ./...",
+ "FROM build AS test",
+ "RUN go test ./...",
+ "FROM alpine:3.19",
+ "COPY --from=test /bin/app /app",
+ ].join("\n");
+ assert.deepEqual(parseDockerfileBaseImages(dockerfile), ["golang:1.20", "alpine:3.19"]);
+});
+
+test("parseDockerfileBaseImages: matches containers/o11y/Dockerfile's real shape — two real images, no stage-name leakage", () => {
+ const dockerfile = ["FROM grafana/loki:3.3.2 AS loki", "FROM grafana/grafana:11.4.0", "COPY --from=loki /usr/bin/loki /usr/bin/loki"].join("\n");
+ assert.deepEqual(parseDockerfileBaseImages(dockerfile), ["grafana/loki:3.3.2", "grafana/grafana:11.4.0"]);
+});
+
+test("parseDockerfileBaseImages: FROM scratch is excluded (never pulled)", () => {
+ const dockerfile = ["FROM golang:1.20 AS build", "RUN go build -o /app", "FROM scratch", "COPY --from=build /app /app"].join("\n");
+ assert.deepEqual(parseDockerfileBaseImages(dockerfile), ["golang:1.20"]);
+});
+
+test("parseDockerfileBaseImages: ARG-based FROM resolves against the ARG's own default", () => {
+ const dockerfile = ["ARG BASE_IMAGE=alpine:3.19", "FROM ${BASE_IMAGE}", "RUN echo hi"].join("\n");
+ assert.deepEqual(parseDockerfileBaseImages(dockerfile), ["alpine:3.19"]);
+});
+
+test("parseDockerfileBaseImages: dedupes an image reused across stages", () => {
+ const dockerfile = ["FROM node:20 AS a", "FROM node:20 AS b", "FROM node:20"].join("\n");
+ assert.deepEqual(parseDockerfileBaseImages(dockerfile), ["node:20"]);
+});
+
+test("containerWranglerConfigsForTier: tier=1 needs none, tier=2 needs only the API worker, tier=full needs API + o11y", () => {
+ const root = "/runner";
+ assert.deepEqual(containerWranglerConfigsForTier("1", root), []);
+ assert.deepEqual(containerWranglerConfigsForTier("2", root), [path.join(root, "workers", "api", "wrangler.jsonc")]);
+ assert.deepEqual(containerWranglerConfigsForTier("full", root), [
+ path.join(root, "workers", "api", "wrangler.jsonc"),
+ path.join(root, "workers", "o11y", "wrangler.jsonc"),
+ ]);
+});
+
+test("readContainerDockerfilePaths: reads containers[].image from the real workers/api/wrangler.jsonc", () => {
+ const paths = readContainerDockerfilePaths(path.join(RUNNER_ROOT, "workers", "api", "wrangler.jsonc"));
+ assert.equal(paths.length, 2);
+ assert.ok(paths.some((p) => p.endsWith(path.join("containers", "live", "Dockerfile"))));
+ assert.ok(paths.some((p) => p.endsWith(path.join("containers", "builder", "Dockerfile"))));
+ for (const p of paths) assert.equal(existsSync(p), true);
+});
+
+test("collectTierBaseImages: tier=2 against the real repo resolves the shared sandbox base image once", () => {
+ const refs = collectTierBaseImages("2", RUNNER_ROOT);
+ assert.deepEqual(refs, ["docker.io/cloudflare/sandbox:0.12.3"]);
+});
+
+test("collectTierBaseImages: tier=full also pulls in the o11y worker's two real base images", () => {
+ const refs = collectTierBaseImages("full", RUNNER_ROOT);
+ assert.deepEqual(refs, ["docker.io/cloudflare/sandbox:0.12.3", "grafana/loki:3.3.2", "grafana/grafana:11.4.0"]);
+});
+
+test("shouldCheckContainerImages: true for tier 2/full unless --skip-image-check; always false for tier 1", () => {
+ assert.equal(shouldCheckContainerImages("2", false), true);
+ assert.equal(shouldCheckContainerImages("full", false), true);
+ assert.equal(shouldCheckContainerImages("2", true), false);
+ assert.equal(shouldCheckContainerImages("full", true), false);
+ assert.equal(shouldCheckContainerImages("1", false), false);
+ assert.equal(shouldCheckContainerImages("1", true), false);
+});
+
+test("parseArgs: --skip-image-check is only valid with --tier=2 or --tier=full", () => {
+ assert.equal(parseArgs(["--tier=2", "--skip-image-check"]).errors.length, 0);
+ assert.equal(parseArgs(["--tier=2", "--skip-image-check"]).skipImageCheck, true);
+ assert.equal(parseArgs(["--tier=full", "--skip-image-check"]).errors.length, 0);
+ assert.equal(parseArgs(["--tier=1", "--skip-image-check"]).errors.length, 1);
+ assert.equal(parseArgs(["--help", "--skip-image-check"]).errors.length, 0);
+});
+
+test("parseArgs: --skip-image-check defaults to false", () => {
+ assert.equal(parseArgs(["--tier=2"]).skipImageCheck, false);
+});
+
+test("isImagePresent: true when `docker image inspect` succeeds", () => {
+ assert.equal(
+ isImagePresent("alpine:3.19", () => {}),
+ true,
+ );
+});
+
+test("isImagePresent: false when `docker image inspect` throws (image missing locally)", () => {
+ assert.equal(
+ isImagePresent("alpine:3.19", () => {
+ throw new Error("No such image");
+ }),
+ false,
+ );
+});
+
+test("ensureContainerImagesPresent: a PRESENT image is never pulled", async () => {
+ const calls = [];
+ const result = await ensureContainerImagesPresent({
+ refs: ["alpine:3.19"],
+ execFileSyncImpl: (cmd, args) => {
+ calls.push(args);
+ if (args[0] === "image" && args[1] === "inspect") return "ok";
+ throw new Error(`unexpected call: docker ${args.join(" ")}`);
+ },
+ sleep: () => Promise.resolve(),
+ });
+ assert.equal(result.ok, true);
+ assert.equal(
+ calls.some((a) => a[0] === "pull"),
+ false,
+ "a present image must never trigger docker pull",
+ );
+});
+
+test("ensureContainerImagesPresent: a MISSING image is pulled exactly once (succeeds first try)", async () => {
+ const calls = [];
+ const result = await ensureContainerImagesPresent({
+ refs: ["alpine:3.19"],
+ execFileSyncImpl: (cmd, args) => {
+ calls.push(args);
+ if (args[0] === "image" && args[1] === "inspect") throw new Error("No such image");
+ if (args[0] === "pull") return "ok";
+ throw new Error(`unexpected call: docker ${args.join(" ")}`);
+ },
+ sleep: () => Promise.resolve(),
+ });
+ assert.equal(result.ok, true);
+ const pullCalls = calls.filter((a) => a[0] === "pull");
+ assert.equal(pullCalls.length, 1);
+ assert.deepEqual(pullCalls[0], ["pull", "alpine:3.19"]);
+});
+
+test("pullImageWithRetry: retries up to maxAttempts with backoff, then reports the last error line", async () => {
+ let attempts = 0;
+ const sleeps = [];
+ const result = await pullImageWithRetry({
+ ref: "alpine:3.19",
+ execFileSyncImpl: () => {
+ attempts += 1;
+ const err = new Error("pull failed");
+ err.stderr = Buffer.from(`Error response from daemon: Get "https://registry-1.docker.io/v2/": net/http: TLS handshake timeout\n`);
+ throw err;
+ },
+ maxAttempts: 3,
+ backoffMs: 10,
+ sleep: (ms) => {
+ sleeps.push(ms);
+ return Promise.resolve();
+ },
+ });
+ assert.equal(attempts, 3);
+ assert.equal(sleeps.length, 2); // no sleep after the last attempt
+ assert.equal(result.ok, false);
+ assert.match(result.lastErrorLine, /TLS handshake timeout/);
+});
+
+test("pullImageWithRetry: a real `docker pull` writes progress to stdout and the actual failure to stderr — the stderr line must win, not stdout's later one", async () => {
+ const result = await pullImageWithRetry({
+ ref: "docker.io/cloudflare/sandbox:0.12.3",
+ execFileSyncImpl: () => {
+ const err = new Error("pull failed");
+ // Matches real `docker pull` output shape: per-layer progress on
+ // stdout keeps writing lines AFTER stderr's own last write (the
+ // process failing mid-pull, not at the very start) — a naive
+ // "concat stdout after stderr, take the last line" extraction would
+ // report the harmless stdout progress line instead of a spurious error.
+ err.stdout = Buffer.from("0.12.3: Pulling from cloudflare/sandbox\nabc123: Downloading [==> ] 12MB/48MB\n");
+ err.stderr = Buffer.from("error pulling image configuration: download failed after attempts=6: context deadline exceeded\n");
+ throw err;
+ },
+ maxAttempts: 1,
+ sleep: () => Promise.resolve(),
+ });
+ assert.equal(result.ok, false);
+ assert.match(result.lastErrorLine, /context deadline exceeded/);
+ assert.doesNotMatch(result.lastErrorLine, /Downloading/);
+});
+
+test("ensureContainerImagesPresent: a pull that fails every attempt stops before checking any later ref", async () => {
+ const calls = [];
+ const result = await ensureContainerImagesPresent({
+ refs: ["alpine:3.19", "busybox:1.36"],
+ execFileSyncImpl: (cmd, args) => {
+ calls.push(args);
+ if (args[0] === "image" && args[1] === "inspect") throw new Error("No such image");
+ if (args[0] === "pull") {
+ const err = new Error("pull failed");
+ err.stderr = Buffer.from("Error response from daemon: some network error\n");
+ throw err;
+ }
+ throw new Error(`unexpected call: docker ${args.join(" ")}`);
+ },
+ maxAttempts: 3,
+ backoffMs: 5,
+ sleep: () => Promise.resolve(),
+ });
+ assert.equal(result.ok, false);
+ assert.equal(result.ref, "alpine:3.19");
+ assert.match(result.lastErrorLine, /some network error/);
+ assert.ok(
+ calls.every((a) => a[a.length - 1] !== "busybox:1.36"),
+ "the second ref must never be checked once the first one exhausts its retries",
+ );
+});
+
+test("formatImagePullFailure: names the image, the last error line, the retry command, and the escape hatch", () => {
+ const message = formatImagePullFailure({ ref: "docker.io/cloudflare/sandbox:0.12.3", lastErrorLine: "TLS handshake timeout" });
+ assert.match(message, /docker\.io\/cloudflare\/sandbox:0\.12\.3/);
+ assert.match(message, /TLS handshake timeout/);
+ assert.match(message, /docker pull docker\.io\/cloudflare\/sandbox:0\.12\.3/);
+ assert.match(message, /--skip-image-check/);
+});
+
+test("CLI: `dev.mjs --tier=2` stops before spawning any worker when a required base image fails to pull after every retry", () => {
+ const stubBinDir = path.join(HERE, "fixtures", "stub-bin");
+ const devScript = path.join(RUNNER_ROOT, "scripts", "dev.mjs");
+ const result = spawnSync(process.execPath, [devScript, "--tier=2"], {
+ cwd: RUNNER_ROOT,
+ encoding: "utf8",
+ timeout: 30000,
+ env: {
+ ...process.env,
+ PATH: `${stubBinDir}:${process.env.PATH}`,
+ STUB_DOCKER_MODE: "ok",
+ STUB_DOCKER_IMAGE_PRESENT: "0",
+ STUB_DOCKER_PULL_MODE: "fail",
+ },
+ });
+ assert.notEqual(result.status, 0);
+ const output = `${result.stdout}${result.stderr}`;
+ assert.match(output, /could not pull required container base image/);
+ assert.match(output, /docker pull docker\.io\/cloudflare\/sandbox:0\.12\.3/);
+ assert.match(output, /attempt 3\/3/, "the bounded retry must actually run through the real CLI, not just report ok:false");
+ assert.doesNotMatch(output, /spawning:/, "no worker should ever be spawned once the image pull gate fails");
+ // The stub docker has no "ps" handler (the very next docker call after
+ // the image gate, listing containers) — its catch-all reply is "stub
+ // docker: unsupported subcommand ps". Its ABSENCE here is what actually
+ // proves this run stopped at the image gate and never reached that next
+ // step, not merely that it exited non-zero for some other reason.
+ assert.doesNotMatch(output, /unsupported subcommand/, "the run must stop at the image gate, never reaching the next docker call (docker ps)");
+});
+
+test("CLI: `dev.mjs --tier=2 --skip-image-check` never calls `docker image inspect`/`pull` even when they'd fail", () => {
+ const stubBinDir = path.join(HERE, "fixtures", "stub-bin");
+ const devScript = path.join(RUNNER_ROOT, "scripts", "dev.mjs");
+ // `docker info` (the tier's own Docker-availability check, ahead of the
+ // image gate this test targets) succeeds via STUB_DOCKER_MODE=ok. The
+ // stub doesn't implement `docker ps` (the leftover-container baseline
+ // that runs right after the image gate), so this run dies there — fine,
+ // and fast: everything this test needs to observe (the skip line, and
+ // the absence of any image inspect/pull attempt) has already happened by
+ // then, and it proves nothing past the gate got anywhere near a real
+ // `wrangler`/pnpm build.
+ const result = spawnSync(process.execPath, [devScript, "--tier=2", "--skip-image-check"], {
+ cwd: RUNNER_ROOT,
+ encoding: "utf8",
+ timeout: 10000,
+ env: {
+ ...process.env,
+ PATH: `${stubBinDir}:${process.env.PATH}`,
+ STUB_DOCKER_MODE: "ok",
+ STUB_DOCKER_IMAGE_PRESENT: "0",
+ STUB_DOCKER_PULL_MODE: "fail",
+ },
+ });
+ const output = `${result.stdout}${result.stderr}`;
+ assert.doesNotMatch(output, /could not pull required container base image/);
+ assert.match(output, /--skip-image-check: skipping/);
+ // Proves this run DID proceed past the (skipped) gate, all the way to
+ // the next docker call the stub doesn't implement (`docker ps`) — the
+ // control for the test above: same env (a pull would fail if attempted),
+ // but with --skip-image-check the run gets past the gate instead of
+ // stopping at it.
+ assert.match(output, /unsupported subcommand/, "the run must proceed past the (skipped) gate to the next docker call");
+});
+
+// ---------------------------------------------------------------------------
+// runtime staleness
+// ---------------------------------------------------------------------------
+
+test("isRuntimeDistStale: true when dist/ is missing", () => {
+ withTmpDir((dir) => {
+ mkdirSync(path.join(dir, "src"));
+ writeFileSync(path.join(dir, "src", "index.ts"), "export {}\n");
+ assert.equal(isRuntimeDistStale(dir), true);
+ });
+});
+
+test("isRuntimeDistStale: false when dist/ is newer than every src file", async () => {
+ await withTmpDir(async (dir) => {
+ mkdirSync(path.join(dir, "src"));
+ mkdirSync(path.join(dir, "dist"));
+ writeFileSync(path.join(dir, "src", "index.ts"), "export {}\n");
+ await new Promise((r) => setTimeout(r, 20));
+ writeFileSync(path.join(dir, "dist", "index.js"), "export {};\n");
+ assert.equal(isRuntimeDistStale(dir), false);
+ });
+});
+
+test("isRuntimeDistStale: true when a src file was edited after the last dist build", async () => {
+ await withTmpDir(async (dir) => {
+ mkdirSync(path.join(dir, "src"));
+ mkdirSync(path.join(dir, "dist"));
+ writeFileSync(path.join(dir, "dist", "index.js"), "export {};\n");
+ await new Promise((r) => setTimeout(r, 20));
+ writeFileSync(path.join(dir, "src", "index.ts"), "export {}\n");
+ assert.equal(isRuntimeDistStale(dir), true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// pnpm install staleness ("stale dependencies after a pull" dev-stack note)
+// ---------------------------------------------------------------------------
+
+/** Sets an exact, controllable mtime — successive `writeFileSync` calls can
+ * land on the same filesystem-clock tick, which would make a real ordering
+ * bug read as a pass here just as easily as a real fix. */
+function touch(filePath, mtimeMs) {
+ const seconds = mtimeMs / 1000;
+ utimesSync(filePath, seconds, seconds);
+}
+
+test("isPnpmInstallNeeded: false when there is no lockfile at all (nothing to detect drift against)", () => {
+ withTmpDir((dir) => {
+ assert.equal(isPnpmInstallNeeded(dir), false);
+ });
+});
+
+test("isPnpmInstallNeeded: true when node_modules/.modules.yaml is missing outright (never installed)", () => {
+ withTmpDir((dir) => {
+ writeFileSync(path.join(dir, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n");
+ assert.equal(isPnpmInstallNeeded(dir), true);
+ });
+});
+
+test("isPnpmInstallNeeded: false when node_modules/.modules.yaml is newer than the lockfile (installed after the last lockfile change)", () => {
+ withTmpDir((dir) => {
+ const lockfilePath = path.join(dir, "pnpm-lock.yaml");
+ writeFileSync(lockfilePath, "lockfileVersion: '9.0'\n");
+ mkdirSync(path.join(dir, "node_modules"));
+ const modulesYamlPath = path.join(dir, "node_modules", ".modules.yaml");
+ writeFileSync(modulesYamlPath, "hoistedDependencies: {}\n");
+ touch(lockfilePath, 1_000_000);
+ touch(modulesYamlPath, 2_000_000);
+ assert.equal(isPnpmInstallNeeded(dir), false);
+ });
+});
+
+// This is the exact "stale dependencies after a pull" shape: a pull changed
+// the lockfile (new dependency), and node_modules was never reinstalled
+// against it — the one case this check exists to catch.
+test("isPnpmInstallNeeded: true when the lockfile is newer than node_modules/.modules.yaml (a pull changed dependencies, never reinstalled)", () => {
+ withTmpDir((dir) => {
+ const lockfilePath = path.join(dir, "pnpm-lock.yaml");
+ writeFileSync(lockfilePath, "lockfileVersion: '9.0'\n");
+ mkdirSync(path.join(dir, "node_modules"));
+ const modulesYamlPath = path.join(dir, "node_modules", ".modules.yaml");
+ writeFileSync(modulesYamlPath, "hoistedDependencies: {}\n");
+ touch(modulesYamlPath, 1_000_000);
+ touch(lockfilePath, 2_000_000);
+ assert.equal(isPnpmInstallNeeded(dir), true);
+ });
+});
+
+test("PNPM_INSTALL_NEEDED_MESSAGE names the exact fix command", () => {
+ assert.match(PNPM_INSTALL_NEEDED_MESSAGE, /pnpm install --frozen-lockfile/);
+});
+
+// ---------------------------------------------------------------------------
+// Surfacing a wrangler build failure instead of waiting out the full
+// readiness timeout ("stale dependencies after a pull" dev-stack note)
+// ---------------------------------------------------------------------------
+
+test("wranglerBuildErrorLine: matches a real esbuild-shaped build failure", () => {
+ assert.equal(
+ wranglerBuildErrorLine('✘ [ERROR] Could not resolve "@jridgewell/trace-mapping"'),
+ 'Could not resolve "@jridgewell/trace-mapping"',
+ );
+});
+
+test("wranglerBuildErrorLine: strips ANSI color codes before matching (wrangler 4.108's real output shape)", () => {
+ assert.equal(
+ wranglerBuildErrorLine("\x1b[31m✘ [ERROR]\x1b[0m Could not resolve \"@jridgewell/trace-mapping\""),
+ 'Could not resolve "@jridgewell/trace-mapping"',
+ );
+});
+
+test("wranglerBuildErrorLine: null for an ordinary log line", () => {
+ assert.equal(wranglerBuildErrorLine("[o11y] Ready on http://localhost:4200"), null);
+});
+
+// Two real examples that must not be treated as a build failure: wrangler's
+// runtime uncaught-exception logging reuses the identical `✘ [ERROR]`
+// prefix for a request handler throwing at runtime — the worker came up
+// fine and is already serving traffic — which is the opposite of "never
+// came up".
+test("wranglerBuildErrorLine: null for wrangler's own runtime uncaught-exception logging, not a build failure", () => {
+ assert.equal(
+ wranglerBuildErrorLine("✘ [ERROR] Uncaught Error: No such image available named cloudflare-dev/sandbox:f01d8965"),
+ null,
+ );
+ assert.equal(
+ wranglerBuildErrorLine("✘ [ERROR] Uncaught Error: ReadableStream received over RPC disconnected prematurely."),
+ null,
+ );
+});
+
+// ---------------------------------------------------------------------------
+// waitForServer
+// ---------------------------------------------------------------------------
+
+test("waitForServer: resolves once fetchImpl stops rejecting", async () => {
+ let calls = 0;
+ const fetchImpl = async () => {
+ calls += 1;
+ if (calls < 3) throw new Error("connection refused");
+ };
+ await waitForServer("http://localhost:1", 10_000, "test worker", {
+ fetchImpl,
+ sleepImpl: async () => {}, // instant — this test proves the retry, not real timing
+ });
+ assert.equal(calls, 3);
+});
+
+test("waitForServer: times out with the generic message when nothing signals an early failure", async () => {
+ await assert.rejects(
+ waitForServer("http://localhost:1", 20, "test worker", {
+ fetchImpl: async () => {
+ throw new Error("connection refused");
+ },
+ sleepImpl: async () => {}, // instant — the deadline is real time (Date.now()), not sleep count
+ }),
+ /test worker on http:\/\/localhost:1 never came up within 20ms/,
+ );
+});
+
+// Proves the actual race dev.mjs depends on: an early failure must win EVEN
+// WHEN timeoutMs is huge and no time has elapsed yet — this is what makes it
+// "immediately" rather than "eventually, once the timeout would have fired
+// anyway". `sleepImpl` throws if called at all: a `waitForServer` that only
+// checked `getEarlyFailure` AFTER sleeping (instead of on every failed
+// attempt, before sleeping again) would call `sleepImpl` at least once
+// before ever reporting the build error, and this assertion would catch
+// that revert.
+test("waitForServer: an early failure throws immediately, without ever sleeping, however large timeoutMs is", async () => {
+ await assert.rejects(
+ waitForServer("http://localhost:1", 120_000, "o11y worker", {
+ fetchImpl: async () => {
+ throw new Error("connection refused");
+ },
+ getEarlyFailure: () => 'Could not resolve "@jridgewell/trace-mapping"',
+ sleepImpl: () => {
+ throw new Error("must not sleep once an early failure is reported");
+ },
+ }),
+ /o11y worker on http:\/\/localhost:1 failed to build: Could not resolve "@jridgewell\/trace-mapping"/,
+ );
+});
+
+// ---------------------------------------------------------------------------
+// spawn plan
+// ---------------------------------------------------------------------------
+
+test("buildPlan: tier=1 spawns only the authoring app", () => {
+ const ports = resolvePorts("1", {});
+ assert.deepEqual(
+ buildPlan("1", ports).map((p) => p.name),
+ ["app"],
+ );
+});
+
+test("buildPlan: tier=2 spawns the authoring app and the api worker, in that order", () => {
+ const ports = resolvePorts("2", {});
+ assert.deepEqual(
+ buildPlan("2", ports).map((p) => p.name),
+ ["app", "api"],
+ );
+});
+
+test("buildPlan: tier=full spawns app, api, o11y, and the local Slack capture server", () => {
+ const ports = resolvePorts("full", {});
+ assert.deepEqual(
+ buildPlan("full", ports).map((p) => p.name),
+ ["app", "api", "o11y", "slack"],
+ );
+});
+
+test("buildPlan: api and o11y each get their own, distinct --port and --inspector-port flags", () => {
+ const ports = resolvePorts("full", {});
+ const plan = buildPlan("full", ports);
+ const api = plan.find((p) => p.name === "api");
+ const o11y = plan.find((p) => p.name === "o11y");
+ const flagValue = (args, flag) => args[args.indexOf(flag) + 1];
+ assert.equal(flagValue(api.args, "--port"), String(ports.API_DEV_PORT));
+ assert.equal(flagValue(api.args, "--inspector-port"), String(ports.API_DEV_INSPECTOR_PORT));
+ assert.equal(flagValue(o11y.args, "--port"), String(ports.O11Y_DEV_PORT));
+ assert.equal(flagValue(o11y.args, "--inspector-port"), String(ports.O11Y_DEV_INSPECTOR_PORT));
+ assert.notEqual(flagValue(api.args, "--inspector-port"), flagValue(o11y.args, "--inspector-port"));
+});
+
+test("buildPlan: o11y's spawn injects O11Y_SESSION_SECRET via --var, not a fixed/predictable value", () => {
+ const ports = resolvePorts("full", {});
+ const planA = buildPlan("full", ports);
+ const planB = buildPlan("full", ports);
+ const secretArg = (plan) => {
+ const o11y = plan.find((p) => p.name === "o11y");
+ const idx = o11y.args.indexOf("--var");
+ for (let i = idx; i < o11y.args.length; i += 2) {
+ if (o11y.args[i] === "--var" && o11y.args[i + 1].startsWith("O11Y_SESSION_SECRET:")) return o11y.args[i + 1];
+ }
+ return undefined;
+ };
+ const a = secretArg(planA);
+ const b = secretArg(planB);
+ assert.ok(a && a.startsWith("O11Y_SESSION_SECRET:"));
+ assert.notEqual(a, b, "each build gets a fresh ephemeral secret unless one is pinned via opts.sessionSecret");
+});
+
+test("buildPlan: tier=1's app process gets no VITE_DEV_USER/VITE_API_BASE (no API worker running to point at)", () => {
+ const ports = resolvePorts("1", {});
+ const app = buildPlan("1", ports).find((p) => p.name === "app");
+ assert.equal(app.env.VITE_DEV_USER, undefined);
+ assert.equal(app.env.VITE_API_BASE, undefined);
+});
+
+test("buildPlan: tier=2/full inject VITE_DEV_USER/VITE_API_BASE as env (never written to a file) for the app process", () => {
+ for (const tier of ["2", "full"]) {
+ const ports = resolvePorts(tier, {});
+ const app = buildPlan(tier, ports).find((p) => p.name === "app");
+ assert.equal(app.env.VITE_DEV_USER, "dev@handsontable.com");
+ assert.equal(app.env.VITE_API_BASE, `http://localhost:${ports.AUTHORING_DEV_PORT}`);
+ }
+});
+
+test("buildPlan: tier=full additionally injects VITE_TELEMETRY_LOCAL=1 for the app process", () => {
+ const ports = resolvePorts("full", {});
+ const app = buildPlan("full", ports).find((p) => p.name === "app");
+ assert.equal(app.env.VITE_TELEMETRY_LOCAL, "1");
+ const tier2App = buildPlan("2", resolvePorts("2", {})).find((p) => p.name === "app");
+ assert.equal(tier2App.env.VITE_TELEMETRY_LOCAL, undefined);
+});
+
+test("buildPlan: tier=full injects VITE_GRAFANA_URL for the app process, at the o11y worker's own origin (not AUTHORING/O11Y_GRAFANA_PORT)", () => {
+ const ports = resolvePorts("full", { O11Y_DEV_PORT: "6223", AUTHORING_DEV_PORT: "6220" });
+ const app = buildPlan("full", ports).find((p) => p.name === "app");
+ assert.equal(app.env.VITE_GRAFANA_URL, "http://localhost:6223/grafana/");
+});
+
+test("buildPlan: only tier=full injects VITE_GRAFANA_URL — tier=1 and tier=2 leave it unset so a build without dev.mjs falls back to the app's own default", () => {
+ for (const tier of ["1", "2"]) {
+ const ports = resolvePorts(tier, {});
+ const app = buildPlan(tier, ports).find((p) => p.name === "app");
+ assert.equal(app.env.VITE_GRAFANA_URL, undefined, `tier=${tier} must not set VITE_GRAFANA_URL`);
+ }
+});
+
+test("buildPlan: never spawns wrangler via npx (spawns node_modules/.bin/wrangler directly)", () => {
+ const ports = resolvePorts("full", {});
+ for (const proc of buildPlan("full", ports)) {
+ assert.notEqual(proc.bin, "npx");
+ assert.doesNotMatch(proc.bin, /^npx\b/);
+ }
+});
+
+// ---------------------------------------------------------------------------
+// container reporting, never stopping: Ctrl-C does not make wrangler's own
+// Sandbox-container orchestration tear itself down synchronously, and
+// several worktrees running `wrangler dev` on this same machine at once is
+// the normal case — a "new since my own snapshot" + name-match container
+// can just as easily be another worktree's session as this run's own, so
+// this module must never `docker stop` one on a guess.
+// ---------------------------------------------------------------------------
+
+test("possiblyLeftoverContainers: only a container absent from `before` AND matching this run's own worker names counts", () => {
+ const before = new Set(["existing-1"]);
+ const after = [
+ { id: "existing-1", name: "workerd-handsontable-demos-api-Sandbox-xyz-proxy" }, // pre-existing — not a candidate
+ { id: "new-1", name: "workerd-handsontable-demos-api-Sandbox-abc-proxy" }, // new + matches — a candidate
+ { id: "new-2", name: "workerd-handsontable-demos-o11y-GrafanaBox-def-proxy" }, // new + matches — a candidate
+ { id: "new-3", name: "some-unrelated-container" }, // new but does not match — never a candidate
+ ];
+ const candidates = possiblyLeftoverContainers(before, after);
+ assert.deepEqual(
+ candidates.map((c) => c.id).sort(),
+ ["new-1", "new-2"],
+ );
+});
+
+test("possiblyLeftoverContainers: empty when nothing new appeared", () => {
+ const before = new Set(["a", "b"]);
+ const after = [
+ { id: "a", name: "workerd-handsontable-demos-api-Sandbox-1-proxy" },
+ { id: "b", name: "workerd-handsontable-demos-api-Sandbox-2-proxy" },
+ ];
+ assert.deepEqual(possiblyLeftoverContainers(before, after), []);
+});
+
+test("possiblyLeftoverContainers: never flags an unrelated container even if it's new (backend-postgres, mongodb, another worktree's own service)", () => {
+ const before = new Set();
+ const after = [
+ { id: "x", name: "backend-postgres-1" },
+ { id: "y", name: "myhandsontable-mongodb" },
+ ];
+ assert.deepEqual(possiblyLeftoverContainers(before, after), []);
+});
+
+test("reportLeftoverContainers (the required stubbed-docker test): a foreign container that appears new during the session, matching this run's own worker-name pattern, is REPORTED but never stopped", () => {
+ // Simulates a false positive: worktree B
+ // starts its own `wrangler dev`/Tier-2 session partway through worktree
+ // A's (this run's) session. B's `workerd-handsontable-demos-api-Sandbox-*`
+ // container is "new since A's snapshot" and matches the name pattern —
+ // indistinguishable, by this signal alone, from a container A actually
+ // started itself.
+ const before = new Set(["existing-1"]);
+ const dockerCalls = [];
+ const execFileSyncImpl = (cmd, args) => {
+ dockerCalls.push([cmd, ...args]);
+ if (cmd !== "docker") throw new Error(`unexpected command: ${cmd}`);
+ if (args[0] === "stop") {
+ // The regression this test guards against: naive code
+ // ran `docker stop` on a container it could not prove was its own.
+ throw new Error("docker stop must NEVER be called by reportLeftoverContainers — NB2 regression");
+ }
+ if (args[0] === "ps") {
+ return [
+ "existing-1\tworkerd-handsontable-demos-api-Sandbox-preexisting-proxy",
+ "foreign-1\tworkerd-handsontable-demos-api-Sandbox-foreign-worktree-proxy",
+ ].join("\n");
+ }
+ throw new Error(`unexpected docker subcommand: ${args.join(" ")}`);
+ };
+ const logLines = [];
+ const candidates = reportLeftoverContainers(before, execFileSyncImpl, (msg) => logLines.push(msg));
+
+ assert.deepEqual(candidates.map((c) => c.id), ["foreign-1"], "the foreign container is still correctly IDENTIFIED as a candidate");
+ assert.ok(
+ !dockerCalls.some(([, sub]) => sub === "stop"),
+ "docker stop must never be invoked, even for a container that looks exactly like this run's own",
+ );
+ assert.equal(logLines.length, 1, "exactly one informational log line, no automatic action");
+ assert.match(logLines[0], /NOT stopping/);
+ assert.match(logLines[0], /docker stop foreign-1/, "the manual cleanup command is printed for a human to run");
+});
+
+test("SHUTDOWN_SIGNALS: includes SIGHUP alongside SIGINT/SIGTERM, so closing the terminal a detached session was started from still triggers cleanup", () => {
+ assert.deepEqual([...SHUTDOWN_SIGNALS].sort(), ["SIGHUP", "SIGINT", "SIGTERM"]);
+});
+
+// ---------------------------------------------------------------------------
+// o11yLocalPublicOrigin
+// ---------------------------------------------------------------------------
+
+test("o11yLocalPublicOrigin: tracks O11Y_DEV_PORT, not AUTHORING_DEV_PORT — Grafana is served from the o11y worker's own origin", () => {
+ assert.equal(o11yLocalPublicOrigin({ O11Y_DEV_PORT: 4200, AUTHORING_DEV_PORT: 5173 }), "http://localhost:4200");
+ assert.equal(o11yLocalPublicOrigin({ O11Y_DEV_PORT: 6223, AUTHORING_DEV_PORT: 6220 }), "http://localhost:6223");
+});
+
+test("buildPlan: tier=full's o11y spawn injects O11Y_LOCAL_PUBLIC_ORIGIN matching the resolved O11Y_DEV_PORT", () => {
+ const ports = resolvePorts("full", { O11Y_DEV_PORT: "6223" });
+ const o11y = buildPlan("full", ports).find((p) => p.name === "o11y");
+ assert.ok(o11y.args.includes("O11Y_LOCAL_PUBLIC_ORIGIN:http://localhost:6223"));
+});
+
+// ---------------------------------------------------------------------------
+// --fresh (dev-persist task): compose.yml's minio/clickhouse now use named
+// volumes; --fresh wipes them + workers/o11y/.wrangler/state together.
+// ---------------------------------------------------------------------------
+
+test("parseArgs: --fresh is only valid with --tier=full", () => {
+ assert.equal(parseArgs(["--tier=full", "--fresh"]).errors.length, 0);
+ assert.equal(parseArgs(["--tier=full", "--fresh"]).fresh, true);
+ assert.equal(parseArgs(["--tier=1", "--fresh"]).errors.length, 1);
+ assert.equal(parseArgs(["--tier=2", "--fresh"]).errors.length, 1);
+ // Allowed with --help and no --tier (mirrors --replay/--reset-local-db).
+ assert.equal(parseArgs(["--help", "--fresh"]).errors.length, 0);
+});
+
+test("parseArgs: --fresh defaults to false", () => {
+ assert.equal(parseArgs(["--tier=full"]).fresh, false);
+});
+
+test("composeDownArgs: no -v by default (the Ctrl-C/kept-data path); -v only when fresh", () => {
+ const plain = composeDownArgs("/x/compose.yml");
+ assert.deepEqual(plain, ["compose", "-f", "/x/compose.yml", "down"]);
+ assert.ok(!plain.includes("-v"));
+
+ const fresh = composeDownArgs("/x/compose.yml", { fresh: true });
+ assert.deepEqual(fresh, ["compose", "-f", "/x/compose.yml", "down", "-v"]);
+});
+
+test("o11yDevDataModeLine: exact startup mode line for both cases", () => {
+ assert.equal(o11yDevDataModeLine(false), "o11y local data: kept (MinIO/ClickHouse volumes + o11y worker state)");
+ assert.equal(o11yDevDataModeLine(true), "o11y local data: fresh");
+});
+
+test("resetO11yLocalState: runs `docker compose down -v` scoped to the given project, and removes only /.wrangler/state", () => {
+ withTmpDir((dir) => {
+ const o11yDir = path.join(dir, "workers", "o11y");
+ const otherDir = path.join(dir, "workers", "api"); // must never be touched
+ mkdirSync(path.join(o11yDir, ".wrangler", "state", "v3", "do"), { recursive: true });
+ writeFileSync(path.join(o11yDir, ".wrangler", "state", "v3", "do", "marker.txt"), "x");
+ // A file directly under o11yDir (a sibling of .wrangler/, not under it)
+ // — this is what actually catches a rm-path widened to o11yDir itself
+ // (or to `dir`): the `.wrangler/state` assertion below stays trivially
+ // true either way (a deleted parent takes every child path down with
+ // it), this one does not.
+ writeFileSync(path.join(o11yDir, ".dev.vars"), "O11Y_ENV=local\n");
+ mkdirSync(path.join(otherDir, ".wrangler", "state"), { recursive: true });
+ writeFileSync(path.join(otherDir, ".wrangler", "state", "keep-me.txt"), "x");
+
+ const calls = [];
+ const execFileSyncImpl = (cmd, args, opts) => calls.push({ cmd, args, opts });
+ const composeFile = "/x/compose.yml";
+ const composeEnv = { COMPOSE_PROJECT_NAME: "o11y-q1-test" };
+
+ const result = resetO11yLocalState({ o11yDir, composeFile, composeEnv, execFileSyncImpl });
+
+ assert.equal(calls.length, 1, "exactly one docker invocation");
+ assert.equal(calls[0].cmd, "docker");
+ assert.ok(calls[0].args.includes("-v"), "down -v (the whole point of --fresh)");
+ assert.deepEqual(calls[0].args, ["compose", "-f", composeFile, "down", "-v"]);
+ assert.equal(calls[0].opts.env.COMPOSE_PROJECT_NAME, "o11y-q1-test", "scoped to the right project only");
+
+ assert.equal(result.composeDownRan, true);
+ assert.equal(result.stateDirRemoved, true);
+ assert.equal(existsSync(path.join(o11yDir, ".wrangler", "state")), false, "o11y worker state dir removed");
+ assert.equal(existsSync(path.join(o11yDir, ".dev.vars")), true, "rm scoped to .wrangler/state, not all of o11yDir");
+ assert.equal(existsSync(path.join(otherDir, ".wrangler", "state", "keep-me.txt")), true, "workers/api's own state untouched");
+ });
+});
+
+test("resetO11yLocalState: without composeFile/composeEnv (o11y:dev's own --fresh), no docker call is made at all", () => {
+ withTmpDir((dir) => {
+ const o11yDir = path.join(dir, "workers", "o11y");
+ mkdirSync(path.join(o11yDir, ".wrangler", "state"), { recursive: true });
+ const execFileSyncImpl = () => {
+ throw new Error("must not be called — o11y:dev never runs docker compose");
+ };
+ const result = resetO11yLocalState({ o11yDir, execFileSyncImpl });
+ assert.equal(result.composeDownRan, false);
+ assert.equal(result.stateDirRemoved, true);
+ assert.equal(existsSync(path.join(o11yDir, ".wrangler", "state")), false);
+ });
+});
+
+test("resetO11yLocalState: logs 'nothing to delete' when there is no o11y worker state at all (never throws)", () => {
+ withTmpDir((dir) => {
+ const o11yDir = path.join(dir, "workers", "o11y");
+ const lines = [];
+ const result = resetO11yLocalState({ o11yDir, log: (l) => lines.push(l) });
+ assert.equal(result.stateDirRemoved, false);
+ assert.ok(lines.some((l) => l.includes("nothing to delete")));
+ });
+});
+
+// ---------------------------------------------------------------------------
+// per-worktree default COMPOSE_PROJECT_NAME: a fixed literal "o11y-dev"
+// would make every worktree's `--tier=full` resolve to the same compose
+// project, so one worktree's `--fresh` (or even a plain Ctrl-C) could
+// wipe/stop another worktree's stack. See `defaultComposeProjectName`'s
+// own doc comment in dev-lib.mjs.
+// ---------------------------------------------------------------------------
+
+test("defaultComposeProjectName: two different worktree roots give two different names", () => {
+ const a = defaultComposeProjectName("/Users/dev/Code/examples/runner");
+ const b = defaultComposeProjectName("/Users/dev/Code/examples-wt/Q1-persist/runner");
+ assert.notEqual(a, b, "two distinct worktrees must never resolve to the same compose project");
+ assert.match(a, /^o11y-dev-[0-9a-f]+$/, "must still look like a compose project name (lowercase, hyphen, hex)");
+ assert.match(b, /^o11y-dev-[0-9a-f]+$/);
+});
+
+test("defaultComposeProjectName: the same root gives the same (stable) name every time", () => {
+ const root = "/Users/dev/Code/examples-wt/Y1-final/runner";
+ assert.equal(defaultComposeProjectName(root), defaultComposeProjectName(root), "must be stable across calls/restarts, not randomly generated");
+});
+
+// `defaultComposeProjectName` must derive from `runnerRoot`, not a fixed
+// `"o11y-dev"` literal, or two different worktree paths would produce the
+// same project name — the collision this exists to prevent.
+
+test("resolveComposeProjectName: an explicit COMPOSE_PROJECT_NAME env override always wins over the derived default", () => {
+ assert.equal(
+ resolveComposeProjectName({ COMPOSE_PROJECT_NAME: "my-shared-project" }, "/Users/dev/Code/examples/runner"),
+ "my-shared-project",
+ );
+});
+
+test("resolveComposeProjectName: falls back to defaultComposeProjectName(runnerRoot) when unset", () => {
+ const root = "/Users/dev/Code/examples/runner";
+ assert.equal(resolveComposeProjectName({}, root), defaultComposeProjectName(root));
+ // An empty string is "unset" too (matches every other env-override check in
+ // this module, e.g. resolvePorts' own `raw !== undefined && raw !== ""`).
+ assert.equal(resolveComposeProjectName({ COMPOSE_PROJECT_NAME: "" }, root), defaultComposeProjectName(root));
+});
+
+// Revert evidence: reverting `resolveComposeProjectName` to always return
+// `defaultComposeProjectName(runnerRoot)` (dropping the `env.COMPOSE_PROJECT_NAME
+// ||` short-circuit) makes the override test above fail (`"my-shared-project"`
+// vs a derived `o11y-dev-`).
+
+test("dev.mjs's own --tier=full compose section resolves COMPOSE_PROJECT_NAME via resolveComposeProjectName, not a hardcoded literal (drift guard)", () => {
+ const devSrc = readFileSync(path.join(RUNNER_ROOT, "scripts", "dev.mjs"), "utf8");
+ assert.match(
+ devSrc,
+ /const composeProjectName = resolveComposeProjectName\(process\.env\)/,
+ "dev.mjs must derive composeProjectName through the shared helper (Z-D-H2) so --fresh's own down -v (which reads composeEnv.COMPOSE_PROJECT_NAME straight from this variable) targets THIS worktree's derived project, not a value shared across worktrees",
+ );
+ assert.doesNotMatch(
+ devSrc,
+ /process\.env\.COMPOSE_PROJECT_NAME \|\| "o11y-dev"/,
+ "must not reintroduce the old fixed-literal fallback that collided across worktrees",
+ );
+});
+
+// Behavioural companion to the source-grep drift guard above: reproduces
+// dev.mjs's own `--tier=full --fresh` wiring end to end (resolve the
+// project name the same way dev.mjs does -> build composeEnv from it ->
+// call resetO11yLocalState with it) and asserts the actual docker call
+// `resetO11yLocalState` makes carries this worktree's derived project
+// name, not a value shared across worktrees. This also catches a
+// caller-side mistake (e.g. building `composeEnv` from a different/stale
+// variable) that a pure source-text match cannot see.
+test("--tier=full's own wiring: --fresh's docker compose down -v carries THIS worktree's derived COMPOSE_PROJECT_NAME (behavioural)", () => {
+ withTmpDir((dir) => {
+ const o11yDir = path.join(dir, "workers", "o11y");
+ const root = path.join(dir, "some-worktree", "runner");
+ // No override — exactly dev.mjs's own `resolveComposeProjectName(process.env)`
+ // call when COMPOSE_PROJECT_NAME is unset.
+ const composeProjectName = resolveComposeProjectName({}, root);
+ const composeFile = path.join(dir, "compose.yml");
+ const composeEnv = { COMPOSE_PROJECT_NAME: composeProjectName };
+ const calls = [];
+ const execFileSyncImpl = (cmd, args, opts) => calls.push({ cmd, args, opts });
+
+ resetO11yLocalState({ o11yDir, composeFile, composeEnv, execFileSyncImpl });
+
+ assert.equal(calls.length, 1);
+ assert.deepEqual(calls[0].args, composeDownArgs(composeFile, { fresh: true }), "must be the real --fresh down -v shape");
+ assert.equal(
+ calls[0].opts.env.COMPOSE_PROJECT_NAME,
+ defaultComposeProjectName(root),
+ "the docker call actually made must carry this worktree's derived name, not a shared/stale one",
+ );
+ });
+});
+
+test("stop-roundtrip.mjs's own dev-stack collision guard imports its default from defaultComposeProjectName, not a second hardcoded literal (drift guard)", () => {
+ const src = readFileSync(path.join(RUNNER_ROOT, "containers", "o11y", "local", "stop-roundtrip.mjs"), "utf8");
+ assert.match(
+ src,
+ /import\s*\{\s*defaultComposeProjectName\s*\}\s*from\s*"\.\.\/\.\.\/\.\.\/scripts\/dev-lib\.mjs"/,
+ "stop-roundtrip.mjs must import the ONE shared helper rather than keeping its own copy of the dev-stack default literal, so the two can never drift apart again",
+ );
+ assert.match(src, /const DEV_STACK_DEFAULT_PROJECT = defaultComposeProjectName\(\)/);
+});
+
+// `stop-roundtrip.mjs`'s `DEV_STACK_DEFAULT_PROJECT` must not be the fixed
+// literal `"o11y-dev"`, or a worktree-derived dev.mjs default would
+// silently never match this script's guard, so a `stop-roundtrip.mjs` run
+// under this worktree's real dev-stack project name would no longer be
+// refused.
+
+test("run-and-deploy.md documents the per-worktree derivation and what happens to an existing single-worktree user's old volumes (they are NOT renamed — orphaned, not migrated)", () => {
+ const doc = readFileSync(path.join(RUNNER_ROOT, "docs", "run-and-deploy.md"), "utf8");
+ assert.match(doc, /derived PER WORKTREE/i);
+ assert.match(doc, /defaultComposeProjectName/);
+ assert.match(doc, /does not rename them/i, "must correctly say docker does NOT rename the old volumes (they are orphaned; the new project starts empty)");
+ assert.match(doc, /orphaned/i);
+ assert.match(doc, /divergence warning/i, "must call out that the existing-ledger case prints detectO11yStateDivergence's warning on the first run under the new project");
+});
+
+test("o11yLedgerCommittedKeyCount: counts only 'done:' keys in the InboxWriter DO's real SQLite storage, across multiple .sqlite files", async () => {
+ await withTmpDir(async (dir) => {
+ const o11yDir = path.join(dir, "workers", "o11y");
+ const inboxDir = path.join(o11yDir, ".wrangler", "state", "v3", "do", "handsontable-demos-o11y-InboxWriter");
+ mkdirSync(inboxDir, { recursive: true });
+
+ function makeKvSqlite(fileName, rows) {
+ const db = new DatabaseSync(path.join(inboxDir, fileName));
+ db.exec("CREATE TABLE _cf_KV (key TEXT PRIMARY KEY, value BLOB) WITHOUT ROWID");
+ for (const key of rows) db.prepare("INSERT INTO _cf_KV (key, value) VALUES (?, ?)").run(key, Buffer.from("1"));
+ db.close();
+ }
+ makeKvSqlite("aaa.sqlite", ["done:inbox/tenant/2026-09-24/one", "done:inbox/tenant/2026-09-24/two", "hash:20260924:abc", "wake:xyz"]);
+ makeKvSqlite("bbb.sqlite", ["done:inbox/tenant/2026-09-24/three"]);
+ // metadata.sqlite (real wrangler layout) never has a _cf_KV table — must
+ // be skipped, not counted as an error.
+ const metaDb = new DatabaseSync(path.join(inboxDir, "metadata.sqlite"));
+ metaDb.exec("CREATE TABLE something_else (id INTEGER)");
+ metaDb.close();
+
+ const count = await o11yLedgerCommittedKeyCount(o11yDir);
+ assert.equal(count, 3);
+ });
+});
+
+test("o11yLedgerCommittedKeyCount: 0 (never throws) when there's no o11y worker state yet", async () => {
+ await withTmpDir(async (dir) => {
+ const count = await o11yLedgerCommittedKeyCount(path.join(dir, "workers", "o11y"));
+ assert.equal(count, 0);
+ });
+});
+
+test("findComposeVolume: null when docker finds nothing for that project+key; the resolved name otherwise", () => {
+ const found = findComposeVolume({
+ composeProjectName: "o11y-q1",
+ volumeKey: "minio-data",
+ execFileSyncImpl: () => "o11y-q1_minio-data\n",
+ });
+ assert.equal(found, "o11y-q1_minio-data");
+
+ const missing = findComposeVolume({
+ composeProjectName: "o11y-q1",
+ volumeKey: "minio-data",
+ execFileSyncImpl: () => "",
+ });
+ assert.equal(missing, null);
+});
+
+test("detectO11yStateDivergence: never touches docker when the ledger has zero committed keys (cheap path first)", async () => {
+ const result = await detectO11yStateDivergence({
+ composeProjectName: "o11y-q1",
+ o11yDir: "/does/not/matter",
+ execFileSyncImpl: () => {
+ throw new Error("must not be called — nothing to warn about");
+ },
+ countCommittedLedgerKeys: async () => 0,
+ });
+ assert.deepEqual(result, { divergent: false, committedCount: 0 });
+});
+
+test("detectO11yStateDivergence: divergent when the ledger has committed keys but the MinIO volume is gone (the warning fires)", async () => {
+ const result = await detectO11yStateDivergence({
+ composeProjectName: "o11y-q1",
+ o11yDir: "/does/not/matter",
+ execFileSyncImpl: () => "", // docker volume ls -q finds nothing
+ countCommittedLedgerKeys: async () => 7,
+ });
+ assert.equal(result.divergent, true);
+ assert.equal(result.committedCount, 7);
+ assert.match(formatO11yDivergenceWarning(result.committedCount), /--fresh/);
+ assert.match(formatO11yDivergenceWarning(result.committedCount), /7/);
+});
+
+test("detectO11yStateDivergence: NOT divergent when committed keys exist but the MinIO volume also exists (normal case, not just fewer bytes)", async () => {
+ const result = await detectO11yStateDivergence({
+ composeProjectName: "o11y-q1",
+ o11yDir: "/does/not/matter",
+ execFileSyncImpl: () => "o11y-q1_minio-data\n",
+ countCommittedLedgerKeys: async () => 7,
+ });
+ assert.equal(result.divergent, false);
+});
+
+// ---------------------------------------------------------------------------
+// drift: every env var / flag the script reads is documented
+// ---------------------------------------------------------------------------
+
+function envVarNames(src) {
+ const names = new Set();
+ const re = /process\.env\.([A-Z][A-Z0-9_]*)/g;
+ let m;
+ while ((m = re.exec(src))) names.add(m[1]);
+ return names;
+}
+
+function extractSection(doc, heading) {
+ const start = doc.indexOf(heading);
+ assert.notEqual(start, -1, `heading not found: ${heading}`);
+ const rest = doc.slice(start + heading.length);
+ const next = rest.search(/\n## /);
+ return next === -1 ? rest : rest.slice(0, next);
+}
+
+test("drift: every env var read by dev.mjs/dev-lib.mjs/o11y-dev.mjs is documented in run-and-deploy.md's Run locally section", () => {
+ const devLibSrc = readFileSync(path.join(RUNNER_ROOT, "scripts", "dev-lib.mjs"), "utf8");
+ const devSrc = readFileSync(path.join(RUNNER_ROOT, "scripts", "dev.mjs"), "utf8");
+ const o11yDevSrc = readFileSync(path.join(RUNNER_ROOT, "scripts", "o11y-dev.mjs"), "utf8");
+ const doc = readFileSync(path.join(RUNNER_ROOT, "docs", "run-and-deploy.md"), "utf8");
+ const section = extractSection(doc, "## Run locally");
+
+ const names = new Set([
+ ...envVarNames(devLibSrc),
+ ...envVarNames(devSrc),
+ ...envVarNames(o11yDevSrc),
+ ...Object.keys(PORT_DEFAULTS),
+ ]);
+ assert.ok(names.size >= 10, `expected a real set of env var names, got ${names.size}`);
+
+ const missing = [...names].filter((name) => !section.includes(`\`${name}\``));
+ assert.deepEqual(missing, [], `env var(s) not documented (as a backtick-wrapped name) in run-and-deploy.md's Run locally section: ${missing.join(", ")}`);
+});
+
+test("drift: --tier, --replay, --fresh, and --help are documented in run-and-deploy.md's Run locally section", () => {
+ const doc = readFileSync(path.join(RUNNER_ROOT, "docs", "run-and-deploy.md"), "utf8");
+ const section = extractSection(doc, "## Run locally");
+ for (const flag of ["--tier", "--replay", "--fresh", "--help"]) {
+ assert.match(section, new RegExp(flag.replace("-", "\\-")), `${flag} not documented in the Run locally section`);
+ }
+});
+
+test("drift: --fresh is documented in dev.mjs --help (HELP_TEXT)", () => {
+ assert.match(HELP_TEXT, /--fresh/);
+});
+
+test("drift: pnpm dev / dev:live / dev:full / o11y:dev are all documented in run-and-deploy.md's Run locally section", () => {
+ const doc = readFileSync(path.join(RUNNER_ROOT, "docs", "run-and-deploy.md"), "utf8");
+ const section = extractSection(doc, "## Run locally");
+ for (const cmd of ["pnpm dev", "pnpm dev:live", "pnpm dev:full", "pnpm o11y:dev"]) {
+ assert.ok(section.includes(cmd), `${cmd} not mentioned in the Run locally section`);
+ }
+});
diff --git a/runner/pipeline/example-analytics-ingest.test.mjs b/runner/pipeline/example-analytics-ingest.test.mjs
new file mode 100644
index 0000000000..5c25e62d82
--- /dev/null
+++ b/runner/pipeline/example-analytics-ingest.test.mjs
@@ -0,0 +1,146 @@
+// ADR-0042 — proves the full round trip for `example.*`, not just the
+// server-side half `o11y-normalise.test.mjs`'s "Faro example.open" case
+// covers.
+//
+// That existing case feeds `processFaroBody` a fixture whose Faro item
+// already carries the raw `hot.metric_kind`/`hot.ref`/`hot.area` keys — it
+// never proves the browser actually sends them. The browser's own
+// `beforeSend` hook runs the same `scrubTelemetry` allowlist
+// (`attrs.ts#ALLOWED_ATTRIBUTE_KEYS`) before the request ever leaves the
+// tab, so that allowlist must have an entry for
+// `hot.metric_kind`/`hot.ref`/`hot.area` — a browser build sending the
+// plain `HotAttrs` bag without it would have every one of
+// `kind`/`ref`/`area` silently stripped client-side, long before
+// `processFaroBody`'s own internal (re-run) scrub or `readAeOnlyAttrs`'s
+// pre-scrub read ever gets a chance. `o11y-normalise.test.mjs`'s
+// fixture-only test cannot see that, because it starts downstream of the
+// browser.
+//
+// This file: (1) runs the exact item shape `apps/authoring/src/telemetry/
+// faro.ts#attrsToContext` + Faro's own `pushEvent` would produce through
+// `scrubTelemetry` — simulating the browser's `beforeSend` — and asserts the
+// three ADR-0042 keys survive; (2) feeds the resulting wire body through the
+// real o11y ingest path and asserts zero inbox items and exactly one
+// Analytics Engine point with blob17/18/19 filled. The inbox is Loki's only
+// feed (§8), so "never reaches the inbox" is the same claim as "never
+// reaches Loki."
+// Run: node --experimental-strip-types --test pipeline/example-analytics-ingest.test.mjs
+
+import test from "node:test";
+import assert from "node:assert/strict";
+import { register } from "node:module";
+
+register("./fixtures/o11y-worker-hooks.mjs", import.meta.url);
+
+const { processFaroBody } = await import("../workers/o11y/src/normalise/faro.ts");
+const { scrubTelemetry, AE_COLUMNS } = await import("../packages/runtime/dist/telemetry/index.js");
+
+const ENV = { O11Y_ENV: "production" };
+const SERVICE = { name: "demos-authoring", version: "deadbeef1234", environment: "production" };
+
+/** Mirrors `apps/authoring/src/telemetry/faro.ts#attrsToContext`'s mapping
+ * for the fields this test exercises — a `HotAttrs`-shaped call becomes
+ * this raw wire `context`, dotted-key-remapped, before `beforeSend` runs.
+ * If that module's `DOTTED_ATTR_KEY` table changes, this literal has to
+ * change with it (faro.ts itself cannot be imported under
+ * `--experimental-strip-types`: it pulls in `@grafana/faro-web-sdk`, a real
+ * browser package this harness does not resolve — the same constraint
+ * `sentry.ts`/`demoEventReport.ts` document for their own files). */
+function rawExampleOpenItem() {
+ return {
+ type: "event",
+ payload: {
+ name: "example.open",
+ attributes: {
+ "hot.metric_kind": "docs",
+ "hot.ref": "guides/accessibility/accessibility/accessibility.md",
+ "hot.area": "Accessibility",
+ "hot.framework": "typescript",
+ "hot.ht_major": "18",
+ "hot.bucket": "18.1",
+ "hot.reason": "entry",
+ },
+ },
+ meta: { app: { name: "demos-authoring", version: "deadbeef1234" } },
+ };
+}
+
+test("browser scrub (beforeSend) keeps hot.metric_kind/hot.ref/hot.area — this task's own ALLOWED_ATTRIBUTE_KEYS addition", () => {
+ const scrubbed = scrubTelemetry(rawExampleOpenItem());
+ assert.ok(scrubbed, "the item must survive scrubbing at all (not a console-dropped kind)");
+ const attrs = scrubbed.payload.attributes;
+ assert.equal(attrs["hot.metric_kind"], "docs");
+ assert.equal(attrs["hot.ref"], "guides/accessibility/accessibility/accessibility.md");
+ assert.equal(attrs["hot.area"], "Accessibility");
+ // Not asserted here: `hot.bucket`/`hot.reason` survival is a separate
+ // `ATTR_HOT_BUCKET`/`ATTR_HOT_REASON` addition to this same AE-only
+ // category (`attrs.ts#AE_ONLY_ATTRIBUTE_KEYS`) — a separate concern owns
+ // proving those two, this file owns `kind`/`ref`/`area`.
+ //
+ // Sanity: an attribute genuinely outside every allowlist category is still
+ // dropped — this test is not accidentally passing because the allowlist
+ // has become a no-op.
+ const withForbidden = rawExampleOpenItem();
+ withForbidden.payload.attributes["url.full"] = "https://example.com/secret?token=abc";
+ const scrubbedForbidden = scrubTelemetry(withForbidden);
+ assert.equal(scrubbedForbidden.payload.attributes["url.full"], undefined);
+});
+
+test("example.open: end to end from a scrubbed browser payload to one AE point, zero inbox items", async () => {
+ const scrubbed = scrubTelemetry(rawExampleOpenItem());
+ // The real Faro transport body shape: one shared `meta` plus
+ // separate typed arrays, `events` here.
+ const wireBody = { meta: scrubbed.meta, events: [{ name: scrubbed.payload.name, attributes: scrubbed.payload.attributes }] };
+
+ const [item] = await processFaroBody(wireBody, ENV, SERVICE, Date.now());
+
+ // An example.* event
+ // now gets a hash-only ingestItem (no `record`) so a redelivered batch
+ // can't double-count this AE point — but it must still never reach the
+ // inbox/Loki (§6 unchanged): `record` stays absent.
+ assert.ok(item.ingestItem, "example.* still needs a hash to dedupe on (A-I4 remainder)");
+ assert.equal(item.ingestItem.record, undefined, "example.* is never stored (§6) — never reaches the inbox, so never Loki");
+ assert.equal(item.invalid, undefined);
+ assert.equal(item.aePoints.length, 1);
+ const point = item.aePoints[0];
+ assert.equal(point.indexes[0], "example.open");
+
+ const blobAt = (column) => {
+ const slot = AE_COLUMNS[column];
+ const n = Number(/^blob(\d+)$/.exec(slot)[1]);
+ return point.blobs[n - 1];
+ };
+ assert.equal(blobAt("kind"), "docs", "blob17");
+ assert.equal(blobAt("ref"), "guides/accessibility/accessibility/accessibility.md", "blob18");
+ assert.equal(blobAt("area"), "Accessibility", "blob19");
+ assert.equal(blobAt("framework"), "typescript");
+ assert.equal(blobAt("ht_major"), "18");
+ // `bucket` (blob16) / `reason` (blob9) are T07's own AE-only keys — see the
+ // comment on the scrub test above for why they are not asserted here.
+});
+
+test("example.engaged: same taxonomy channel, no reason blob", async () => {
+ const item = {
+ type: "event",
+ payload: {
+ name: "example.engaged",
+ attributes: {
+ "hot.metric_kind": "starter",
+ "hot.ref": "react",
+ "hot.framework": "react",
+ "hot.ht_major": "18",
+ "hot.bucket": "18.1",
+ },
+ },
+ meta: { app: { name: "demos-authoring", version: "deadbeef1234" } },
+ };
+ const scrubbed = scrubTelemetry(item);
+ const wireBody = { meta: scrubbed.meta, events: [{ name: scrubbed.payload.name, attributes: scrubbed.payload.attributes }] };
+ const [result] = await processFaroBody(wireBody, ENV, SERVICE, Date.now());
+ // Hash-only ingestItem, still never stored — see the
+ // "example.open" test above for the full reasoning.
+ assert.ok(result.ingestItem);
+ assert.equal(result.ingestItem.record, undefined);
+ assert.equal(result.aePoints.length, 1);
+ assert.equal(result.aePoints[0].indexes[0], "example.engaged");
+});
diff --git a/runner/pipeline/example-analytics-taxonomy.test.mjs b/runner/pipeline/example-analytics-taxonomy.test.mjs
new file mode 100644
index 0000000000..c4f3b361e4
--- /dev/null
+++ b/runner/pipeline/example-analytics-taxonomy.test.mjs
@@ -0,0 +1,181 @@
+// ADR-0042 — pins `apps/authoring/src/exampleAnalytics.ts`'s pure taxonomy
+// logic: which `loadWorkspace` lineage maps to which `kind`, and what `ref`/
+// `area`/`framework` a resolved example carries — read from the loaded
+// docs-example entry, never from the URL (the task's own Traps).
+//
+// Run: node --experimental-strip-types --test pipeline/example-analytics-taxonomy.test.mjs
+
+import test from "node:test";
+import assert from "node:assert/strict";
+import {
+ browserCountsSave,
+ consumeForkMarker,
+ exampleActionAttrs,
+ exampleOpenAttrs,
+ exampleOpenKey,
+ exampleTaxonomy,
+ kindOfLineage,
+} from "../apps/authoring/src/exampleAnalytics.ts";
+
+// ---- kindOfLineage -------------------------------------------------------------
+
+test("kindOfLineage: every loadWorkspace lineage prefix", () => {
+ assert.equal(kindOfLineage("catalog:react"), "starter");
+ assert.equal(kindOfLineage("docs:18.1:guides/accessibility/accessibility/react/example1.tsx"), "docs");
+ assert.equal(kindOfLineage("import:jsfiddle"), "import");
+ assert.equal(kindOfLineage("payload:theme-builder"), "payload");
+ // A saved demo's id carries no colon at all (DEV-2859's own redaction rule).
+ assert.equal(kindOfLineage("dem0-abc123"), "saved");
+ assert.equal(kindOfLineage(""), "saved");
+});
+
+// ---- exampleTaxonomy: docs -----------------------------------------------------
+
+test("exampleTaxonomy(docs): ref is the guide, not docsPath or the URL", () => {
+ const taxonomy = exampleTaxonomy({
+ lineage: "docs:18.1:guides/accessibility/accessibility/react/example1.tsx",
+ framework: "react", // the app's own catalog framework — must be overridden below
+ htMajor: "18",
+ bucket: "18.1",
+ docs: {
+ guide: "guides/accessibility/accessibility/accessibility.md",
+ area: "Accessibility",
+ framework: "reactts",
+ },
+ });
+ assert.deepEqual(taxonomy, {
+ kind: "docs",
+ ref: "guides/accessibility/accessibility/accessibility.md",
+ area: "Accessibility",
+ framework: "reactts", // read from the loaded entry, not the app's own `framework`
+ ht_major: "18",
+ bucket: "18.1",
+ });
+});
+
+test("exampleTaxonomy(docs): falls back to the lineage suffix when no entry is given", () => {
+ const taxonomy = exampleTaxonomy({
+ lineage: "docs:18.1:guides/x/x/react/example1.tsx",
+ framework: "react",
+ htMajor: "18",
+ });
+ assert.equal(taxonomy.ref, "18.1:guides/x/x/react/example1.tsx");
+ assert.equal(taxonomy.area, undefined);
+ assert.equal(taxonomy.framework, "react");
+});
+
+// ---- exampleTaxonomy: starter/saved/import/payload -----------------------------
+
+test("exampleTaxonomy(starter): ref is the framework id, no area", () => {
+ const taxonomy = exampleTaxonomy({ lineage: "catalog:vue3", framework: "vue3", htMajor: "18", bucket: "18.1" });
+ assert.deepEqual(taxonomy, { kind: "starter", ref: "vue3", framework: "vue3", ht_major: "18", bucket: "18.1" });
+});
+
+test("exampleTaxonomy(saved): ref is the demo id", () => {
+ const taxonomy = exampleTaxonomy({ lineage: "dem0-xyz", framework: "react", htMajor: "next" });
+ assert.equal(taxonomy.kind, "saved");
+ assert.equal(taxonomy.ref, "dem0-xyz");
+ assert.equal(taxonomy.area, undefined);
+ assert.equal(taxonomy.bucket, undefined);
+});
+
+test("exampleTaxonomy(import/payload): ref is the lineage's own suffix", () => {
+ assert.equal(
+ exampleTaxonomy({ lineage: "import:jsfiddle", framework: "react", htMajor: "18" }).ref,
+ "jsfiddle",
+ );
+ assert.equal(
+ exampleTaxonomy({ lineage: "payload:theme-builder", framework: "react", htMajor: "18" }).ref,
+ "theme-builder",
+ );
+});
+
+// ---- exampleOpenAttrs / exampleActionAttrs -------------------------------------
+
+test("exampleOpenAttrs: carries reason, exampleActionAttrs does not", () => {
+ const taxonomy = exampleTaxonomy({
+ lineage: "docs:18.1:guides/x/x/react/example1.tsx",
+ framework: "react",
+ htMajor: "18",
+ bucket: "18.1",
+ docs: { guide: "guides/x/x/x.md", area: "Columns", framework: "react" },
+ });
+ const open = exampleOpenAttrs(taxonomy, "deep-link");
+ assert.equal(open.reason, "deep-link");
+ assert.equal(open.kind, "docs");
+ assert.equal(open.ref, "guides/x/x/x.md");
+ assert.equal(open.area, "Columns");
+ assert.equal(open.bucket, "18.1");
+
+ const action = exampleActionAttrs(taxonomy);
+ assert.equal("reason" in action, false, "example.engaged/forked/saved/shared/downloaded carry no reason");
+ assert.equal(action.kind, "docs");
+});
+
+test("exampleActionAttrs: omits area/bucket when the taxonomy has none, never sends an empty string", () => {
+ const taxonomy = exampleTaxonomy({ lineage: "catalog:react", framework: "react", htMajor: "18" });
+ const attrs = exampleActionAttrs(taxonomy);
+ assert.equal("area" in attrs, false);
+ assert.equal("bucket" in attrs, false);
+});
+
+// ---- exampleOpenKey (dedup) -----------------------------------------------------
+
+test("exampleOpenKey: same lineage + same version is the same key; a version change is a different key", () => {
+ const a = exampleOpenKey("docs:18.1:guides/x/x/react/example1.tsx", "18.1.2");
+ const b = exampleOpenKey("docs:18.1:guides/x/x/react/example1.tsx", "18.1.2");
+ const c = exampleOpenKey("docs:18.1:guides/x/x/react/example1.tsx", "18.1.3");
+ assert.equal(a, b);
+ assert.notEqual(a, c);
+});
+
+// ---- consumeForkMarker: entry=fork ------------------------------------------
+//
+// onFork navigates with a full `location.href` reload (App.tsx's own
+// established pattern for every route change, never client-side routing),
+// which destroys every in-memory flag, so the one-shot signal must survive
+// in the URL itself, stripped on read. Never localStorage/sessionStorage
+// (the contract keeps this path off browser storage).
+
+test("consumeForkMarker: detects the marker and strips it down to an empty search", () => {
+ const { isFork, search } = consumeForkMarker("?fork=1");
+ assert.equal(isFork, true);
+ assert.equal(search, "");
+});
+
+test("consumeForkMarker: strips only the marker, keeps other params", () => {
+ const { isFork, search } = consumeForkMarker("?v=18.0.0&fork=1");
+ assert.equal(isFork, true);
+ assert.equal(search, "?v=18.0.0");
+});
+
+test("consumeForkMarker: no marker present -> isFork false, search returned unchanged", () => {
+ const { isFork, search } = consumeForkMarker("?v=18.0.0");
+ assert.equal(isFork, false);
+ assert.equal(search, "?v=18.0.0");
+});
+
+test("consumeForkMarker: empty search -> isFork false, still an empty search", () => {
+ const { isFork, search } = consumeForkMarker("");
+ assert.equal(isFork, false);
+ assert.equal(search, "");
+});
+
+test("consumeForkMarker: one-shot -- reading the stripped search a second time no longer counts as fork", () => {
+ const first = consumeForkMarker("?fork=1");
+ const second = consumeForkMarker(first.search);
+ assert.equal(first.isFork, true);
+ assert.equal(second.isFork, false, "a manual reload of the same (already-stripped) URL must not re-count as a fork");
+});
+
+// ---- browserCountsSave ---------------------------------------------------------
+
+test("browserCountsSave: a Save response carrying the API's exampleSaved marker is never counted by the browser", () => {
+ assert.equal(browserCountsSave({ ok: true, htVersion: "18.0.0", exampleSaved: true }), false);
+ assert.equal(browserCountsSave({ ok: true, htVersion: "18.0.0", exampleSaved: false }), false);
+});
+
+test("browserCountsSave: a Save response without the marker (an API that does not count saves) is counted once by the browser", () => {
+ assert.equal(browserCountsSave({ ok: true, htVersion: "18.0.0" }), true);
+ assert.equal(browserCountsSave(null), true);
+});
diff --git a/runner/pipeline/example-daily-rollup.test.mjs b/runner/pipeline/example-daily-rollup.test.mjs
new file mode 100644
index 0000000000..30a00ca770
--- /dev/null
+++ b/runner/pipeline/example-daily-rollup.test.mjs
@@ -0,0 +1,322 @@
+// ADR-0042 §5 — the nightly `example_daily` rollup (`workers/api/src/reconcile.ts`).
+//
+// `pivotExampleDaily` and `previousUtcDay` are pure — tested directly, no I/O.
+//
+// `writeExampleDaily` is tested against a real SQLite database created from
+// the real migration files (`workers/api/migrations/0008_example_daily.sql`,
+// then `0009_example_daily_downloaded.sql`), via Node's built-in `node:sqlite`
+// — not a hand-rolled regex fake of `env.DB`, so "running the rollup twice
+// for one day yields identical rows" is a claim about the actual
+// `PRIMARY KEY (day, kind, ref, framework, ht_major)` constraint. The
+// dedicated "0009" test section applies 0008 alone, writes a row, then
+// applies 0009 — proving the ADD COLUMN is additive against data that
+// predates it, the real production ordering.
+//
+// `queryExampleEventTotals`'s live AE/ClickHouse HTTP read is not
+// exercised here (no Analytics Engine credentials, no live ClickHouse in
+// this run). Its production pre-flight config guard (a missing
+// AE_SQL_TOKEN/CF_ACCOUNT_ID throws before any `fetch` happens) is
+// exercised below, since it needs no credential or network access at all.
+// Run: node --experimental-strip-types --test pipeline/example-daily-rollup.test.mjs
+
+import test from "node:test";
+import assert from "node:assert/strict";
+import { DatabaseSync } from "node:sqlite";
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { register } from "node:module";
+
+register("./fixtures/worker-hooks.mjs", import.meta.url);
+const { pivotExampleDaily, previousUtcDay, writeExampleDaily, queryExampleEventTotals, rollupExampleDaily } = await import(
+ "../workers/api/src/reconcile.ts"
+);
+const { captures } = await import("./fixtures/sentry-cloudflare-stub.mjs");
+
+const MIGRATION = readFileSync(
+ fileURLToPath(new URL("../workers/api/migrations/0008_example_daily.sql", import.meta.url)),
+ "utf8",
+);
+const MIGRATION_0009 = readFileSync(
+ fileURLToPath(new URL("../workers/api/migrations/0009_example_daily_downloaded.sql", import.meta.url)),
+ "utf8",
+);
+
+/** A `node:sqlite`-backed fake of the two `env.DB` methods `writeExampleDaily`
+ * uses (`prepare().bind()` returning something `batch` can run) — enough
+ * surface for this file, not a general D1 fake. */
+function fakeD1(db) {
+ return {
+ prepare(sql) {
+ return {
+ bind(...args) {
+ return {
+ async run() {
+ db.prepare(sql).run(...args);
+ return { success: true };
+ },
+ };
+ },
+ };
+ },
+ async batch(statements) {
+ const results = [];
+ for (const stmt of statements) results.push(await stmt.run());
+ return results;
+ },
+ };
+}
+
+// Every test in this file runs against 0008 THEN 0009 applied in sequence —
+// the real migration order production runs, not a single hand-merged schema
+// — so a bug in 0009's ADD COLUMN (wrong type, wrong default, wrong table)
+// would show up here exactly as it would against a real D1.
+function freshDb() {
+ const db = new DatabaseSync(":memory:");
+ db.exec(MIGRATION);
+ db.exec(MIGRATION_0009);
+ return db;
+}
+
+/** Plain objects — `node:sqlite`'s `.all()` returns null-prototype rows,
+ * which `assert.deepEqual` treats as unequal to a literal object even when
+ * every field matches. */
+function allRows(db) {
+ return db
+ .prepare("SELECT * FROM example_daily ORDER BY kind, ref, framework, ht_major")
+ .all()
+ .map((row) => ({ ...row }));
+}
+
+// ---- pivotExampleDaily (pure) ---------------------------------------------------
+
+test("pivotExampleDaily: one row per (kind, ref, area, framework, ht_major), one column per metric", () => {
+ const rows = pivotExampleDaily("2026-09-22", [
+ { metric: "example.open", kind: "docs", ref: "guides/x/x.md", area: "Columns", framework: "react", ht_major: "18", total: 12 },
+ { metric: "example.engaged", kind: "docs", ref: "guides/x/x.md", area: "Columns", framework: "react", ht_major: "18", total: 5 },
+ { metric: "example.saved", kind: "docs", ref: "guides/x/x.md", area: "Columns", framework: "react", ht_major: "18", total: 1 },
+ // A distinct taxonomy tuple (different framework) must not merge with the one above.
+ { metric: "example.open", kind: "docs", ref: "guides/x/x.md", area: "Columns", framework: "vue3", ht_major: "18", total: 3 },
+ ]);
+ assert.equal(rows.length, 2);
+ const react = rows.find((r) => r.framework === "react");
+ assert.deepEqual(react, {
+ day: "2026-09-22",
+ kind: "docs",
+ ref: "guides/x/x.md",
+ area: "Columns",
+ framework: "react",
+ ht_major: "18",
+ opens: 12,
+ engaged: 5,
+ forked: 0,
+ saved: 1,
+ shared: 0,
+ downloaded: 0,
+ });
+ const vue = rows.find((r) => r.framework === "vue3");
+ assert.equal(vue.opens, 3);
+ assert.equal(vue.engaged, 0);
+});
+
+test("pivotExampleDaily: rounds a fractional (sampled) AE total to an integer count", () => {
+ const [row] = pivotExampleDaily("2026-09-22", [
+ { metric: "example.open", kind: "starter", ref: "react", area: "", framework: "react", ht_major: "18", total: 7.6 },
+ ]);
+ assert.equal(row.opens, 8);
+});
+
+test("pivotExampleDaily: an unrecognised index1 value is ignored, not thrown on", () => {
+ const rows = pivotExampleDaily("2026-09-22", [
+ { metric: "o11y.ingest", kind: "docs", ref: "x", area: "", framework: "react", ht_major: "18", total: 99 },
+ ]);
+ assert.equal(rows.length, 0);
+});
+
+// ADR-0042 §2 names `example.downloaded` as one of the six `example.*`
+// metrics; this pins that it is now pivoted into its own `downloaded`
+// column (0009_example_daily_downloaded.sql), not silently dropped the way
+// an unrecognised metric is above.
+test("pivotExampleDaily: example.downloaded is pivoted into its own `downloaded` column", () => {
+ const [row] = pivotExampleDaily("2026-09-22", [
+ { metric: "example.downloaded", kind: "starter", ref: "react", area: "", framework: "react", ht_major: "18", total: 6 },
+ ]);
+ assert.equal(row.downloaded, 6);
+ assert.equal(row.opens, 0);
+});
+
+// ---- previousUtcDay (pure) -------------------------------------------------------
+
+test("previousUtcDay: the day before `now`, UTC, half-open [start, end)", () => {
+ const { day, start, end } = previousUtcDay(new Date("2026-09-23T11:38:00Z"));
+ assert.equal(day, "2026-09-22");
+ assert.equal(start, "2026-09-22 00:00:00");
+ assert.equal(end, "2026-09-23 00:00:00");
+});
+
+test("previousUtcDay: a `now` right at UTC midnight still resolves the FULL prior day, not the current one", () => {
+ const { day, start, end } = previousUtcDay(new Date("2026-09-23T00:00:00Z"));
+ assert.equal(day, "2026-09-22");
+ assert.equal(start, "2026-09-22 00:00:00");
+ assert.equal(end, "2026-09-23 00:00:00");
+});
+
+// ---- writeExampleDaily against a real SQLite DB, the real migration -------------
+
+test("writeExampleDaily: writes rows honouring the real PRIMARY KEY", async () => {
+ const db = freshDb();
+ const env = { DB: fakeD1(db) };
+ await writeExampleDaily(env, "2026-09-22", [
+ { day: "2026-09-22", kind: "docs", ref: "guides/x/x.md", area: "Columns", framework: "react", ht_major: "18", opens: 10, engaged: 3, forked: 0, saved: 1, shared: 0, downloaded: 0 },
+ { day: "2026-09-22", kind: "starter", ref: "vue3", area: "", framework: "vue3", ht_major: "18", opens: 4, engaged: 0, forked: 0, saved: 0, shared: 0, downloaded: 0 },
+ ]);
+ const rows = allRows(db);
+ assert.equal(rows.length, 2);
+ assert.equal(rows.find((r) => r.kind === "docs").opens, 10);
+ assert.equal(rows.find((r) => r.kind === "starter").opens, 4);
+});
+
+test("writeExampleDaily: running it TWICE for the same day yields identical rows (idempotency)", async () => {
+ const db = freshDb();
+ const env = { DB: fakeD1(db) };
+ const rows = [
+ { day: "2026-09-22", kind: "docs", ref: "guides/x/x.md", area: "Columns", framework: "react", ht_major: "18", opens: 10, engaged: 3, forked: 0, saved: 1, shared: 0, downloaded: 0 },
+ ];
+ await writeExampleDaily(env, "2026-09-22", rows);
+ await writeExampleDaily(env, "2026-09-22", rows);
+ assert.deepEqual(allRows(db), [{ ...rows[0] }]);
+});
+
+test("writeExampleDaily: `downloaded` round-trips through the real column (0009), and stays identical on a re-run", async () => {
+ const db = freshDb();
+ const env = { DB: fakeD1(db) };
+ const rows = [
+ { day: "2026-09-22", kind: "docs", ref: "guides/x/x.md", area: "Columns", framework: "react", ht_major: "18", opens: 10, engaged: 3, forked: 0, saved: 1, shared: 0, downloaded: 7 },
+ ];
+ await writeExampleDaily(env, "2026-09-22", rows);
+ assert.equal(allRows(db)[0].downloaded, 7);
+ await writeExampleDaily(env, "2026-09-22", rows);
+ assert.deepEqual(allRows(db), [{ ...rows[0] }]);
+});
+
+test("writeExampleDaily: a group that disappears on a re-run is REMOVED, not left stale (why a bare INSERT OR REPLACE is not enough)", async () => {
+ const db = freshDb();
+ const env = { DB: fakeD1(db) };
+ await writeExampleDaily(env, "2026-09-22", [
+ { day: "2026-09-22", kind: "docs", ref: "guides/x/x.md", area: "Columns", framework: "react", ht_major: "18", opens: 10, engaged: 0, forked: 0, saved: 0, shared: 0, downloaded: 0 },
+ { day: "2026-09-22", kind: "starter", ref: "vue3", area: "", framework: "vue3", ht_major: "18", opens: 4, engaged: 0, forked: 0, saved: 0, shared: 0, downloaded: 0 },
+ ]);
+ // Re-run: the vue3 starter had zero example.* events this time, so the
+ // pivot never produces a row for it at all.
+ await writeExampleDaily(env, "2026-09-22", [
+ { day: "2026-09-22", kind: "docs", ref: "guides/x/x.md", area: "Columns", framework: "react", ht_major: "18", opens: 11, engaged: 1, forked: 0, saved: 0, shared: 0, downloaded: 0 },
+ ]);
+ const rows = allRows(db);
+ assert.equal(rows.length, 1, "the vue3 row from the first run must be gone");
+ assert.equal(rows[0].kind, "docs");
+ assert.equal(rows[0].opens, 11);
+});
+
+// ---- a misconfigured production AE SQL read must not silently wipe the day -
+
+test("queryExampleEventTotals: production with no AE_SQL_TOKEN/CF_ACCOUNT_ID THROWS, never returns []", async () => {
+ const env = { PREVIEW_HOST: "demos.handsontable.com" }; // production, both secrets absent
+ await assert.rejects(
+ () => queryExampleEventTotals(env, "2026-09-22 00:00:00", "2026-09-23 00:00:00"),
+ /AE_SQL_TOKEN|CF_ACCOUNT_ID/,
+ );
+});
+
+test("queryExampleEventTotals: production, a 200 response with no data array THROWS, never degrades to []", async () => {
+ // A response shape change or a truncated body must not read as "zero
+ // events today" either — same rule as the missing-credential case
+ // above, one step further down the same function.
+ const realFetch = globalThis.fetch;
+ globalThis.fetch = async () => new Response(JSON.stringify({ meta: [], rows: 0 }), { status: 200 });
+ try {
+ const env = { PREVIEW_HOST: "demos.handsontable.com", AE_SQL_TOKEN: "tok", CF_ACCOUNT_ID: "acct" };
+ await assert.rejects(
+ () => queryExampleEventTotals(env, "2026-09-22 00:00:00", "2026-09-23 00:00:00"),
+ /no "data" array/,
+ );
+ } finally {
+ globalThis.fetch = realFetch;
+ }
+});
+
+test("rollupExampleDaily: a misconfigured production read is refused loudly and never deletes the day's rows", async () => {
+ const db = freshDb();
+ // `rollupExampleDaily` computes its own `previousUtcDay()` internally, from
+ // the real clock — seed the row under THAT day, not a hardcoded literal,
+ // or this test would prove nothing on any date but the one it was written
+ // on (the DELETE would target a different day than the seeded row, so
+ // "the row survives" would pass whether or not the fix is present).
+ const { day } = previousUtcDay();
+ await writeExampleDaily({ DB: fakeD1(db) }, day, [
+ { day, kind: "docs", ref: "guides/x/x.md", area: "Columns", framework: "react", ht_major: "18", opens: 10, engaged: 3, forked: 0, saved: 1, shared: 0, downloaded: 0 },
+ ]);
+ let batchCalls = 0;
+ const spyD1 = { ...fakeD1(db), batch: (...args) => { batchCalls += 1; return fakeD1(db).batch(...args); } };
+ const capturesBefore = captures.length;
+
+ const env = { PREVIEW_HOST: "demos.handsontable.com", DB: spyD1 }; // production, no AE_SQL_TOKEN/CF_ACCOUNT_ID
+ const result = await rollupExampleDaily(env);
+
+ assert.equal(batchCalls, 0, "writeExampleDaily's DELETE must never run when the read was refused");
+ assert.equal(result.rows, 0);
+ assert.equal(allRows(db).length, 1, "the prior run's row for the day must survive untouched");
+ assert.equal(allRows(db)[0].opens, 10);
+
+ const newCaptures = captures.slice(capturesBefore);
+ assert.equal(newCaptures.length, 1, "the refusal must be reported loudly (Sentry)");
+ assert.equal(newCaptures[0].kind, "exception");
+ assert.deepEqual(newCaptures[0].context, { tags: { context: "example-daily-rollup" } });
+});
+
+test("writeExampleDaily: never touches a DIFFERENT day's rows", async () => {
+ const db = freshDb();
+ const env = { DB: fakeD1(db) };
+ await writeExampleDaily(env, "2026-09-21", [
+ { day: "2026-09-21", kind: "docs", ref: "guides/x/x.md", area: "Columns", framework: "react", ht_major: "18", opens: 5, engaged: 0, forked: 0, saved: 0, shared: 0, downloaded: 0 },
+ ]);
+ await writeExampleDaily(env, "2026-09-22", [
+ { day: "2026-09-22", kind: "docs", ref: "guides/x/x.md", area: "Columns", framework: "react", ht_major: "18", opens: 9, engaged: 0, forked: 0, saved: 0, shared: 0, downloaded: 0 },
+ ]);
+ const rows = allRows(db);
+ assert.equal(rows.length, 2);
+ assert.equal(rows.find((r) => r.day === "2026-09-21").opens, 5);
+ assert.equal(rows.find((r) => r.day === "2026-09-22").opens, 9);
+});
+
+// ---- 0009_example_daily_downloaded.sql: additive, safe on production data ----
+
+test("0009: applying it AFTER 0008 against a row already written adds `downloaded` defaulted to 0, every other column untouched", () => {
+ // Deliberately does NOT go through `freshDb()` (which already applies both
+ // migrations) — this test's whole point is the ORDER production runs in:
+ // 0008 ships first (already applied against real data), a row is written
+ // under the five-counter schema, and ONLY THEN does 0009 land. This is the
+ // "safe on production data" claim from the migration file's own header,
+ // proven against a real SQLite schema change, not asserted in prose.
+ const db = new DatabaseSync(":memory:");
+ db.exec(MIGRATION); // 0008 only
+ db.exec(
+ `INSERT INTO example_daily (day, kind, ref, area, framework, ht_major, opens, engaged, forked, saved, shared)
+ VALUES ('2026-09-22', 'docs', 'guides/x/x.md', 'Columns', 'react', '18', 10, 3, 0, 1, 0)`,
+ );
+ // Pre-migration sanity: the column genuinely does not exist yet.
+ assert.throws(() => db.prepare("SELECT downloaded FROM example_daily").get(), /no such column/);
+
+ db.exec(MIGRATION_0009); // 0009, applied after real data already exists
+
+ const row = { ...db.prepare("SELECT * FROM example_daily").get() };
+ assert.equal(row.downloaded, 0, "a pre-existing row backfills to downloaded = 0, never null or an error");
+ assert.equal(row.opens, 10, "every pre-existing column is untouched by the ADD COLUMN");
+ assert.equal(row.engaged, 3);
+ assert.equal(row.forked, 0);
+ assert.equal(row.saved, 1);
+ assert.equal(row.shared, 0);
+
+ // A fresh write after 0009 lands can now populate a real downloaded count
+ // on the SAME row, exactly like any other counter.
+ db.exec("UPDATE example_daily SET downloaded = 4 WHERE day = '2026-09-22'");
+ assert.equal(db.prepare("SELECT downloaded FROM example_daily").get().downloaded, 4);
+});
diff --git a/runner/pipeline/example-saved-point.test.mjs b/runner/pipeline/example-saved-point.test.mjs
new file mode 100644
index 0000000000..7c351f44e3
--- /dev/null
+++ b/runner/pipeline/example-saved-point.test.mjs
@@ -0,0 +1,185 @@
+// `example.saved` (contract §5, ADR-0042 §2) is written by the API worker when
+// an editor Save's rebuild succeeds, with the values the browser's saved-demo
+// taxonomy produces. Driven through the real router with the shared fakes.
+//
+// Build prerequisite: `pnpm --filter @handsontable/demo-runtime build`.
+// Run: node --experimental-strip-types --test pipeline/*.test.mjs
+
+import test, { after } from "node:test";
+import assert from "node:assert/strict";
+import { register } from "node:module";
+import { AUTHOR, demoRow, makeEnv } from "./fixtures/worker-harness.mjs";
+import { exampleActionAttrs, exampleTaxonomy } from "../apps/authoring/src/exampleAnalytics.ts";
+import { toAePoint } from "../packages/runtime/dist/telemetry/index.js";
+
+register("./fixtures/worker-hooks.mjs", import.meta.url);
+
+const { default: worker } = await import("../workers/api/src/index.ts");
+
+const REAL_FETCH = globalThis.fetch;
+globalThis.fetch = async (input, init) => {
+ const url = typeof input === "string" ? input : input.url;
+ if (url.startsWith("https://login.invalid") && init?.headers?.Authorization === "Bearer test-token") {
+ return Response.json({ email: AUTHOR, sub: "u1" });
+ }
+ throw new Error(`unexpected network fetch in example-saved-point.test.mjs: ${url}`);
+};
+after(() => {
+ globalThis.fetch = REAL_FETCH;
+});
+
+const DEMO_ID = "abc123";
+const FILES = {
+ "/package.json": JSON.stringify({ name: "demo", dependencies: { handsontable: "16.0.2" } }),
+ "/index.js": "console.log(1)",
+};
+
+/** Points land in memory through the production `bindingSink`; `waitUntil`
+ * promises are kept so a test can wait for work scheduled past the response. */
+function setup(rows = [demoRow({ id: DEMO_ID, framework: "react", ht_version: "16.0.2" })]) {
+ const { env } = makeEnv(rows);
+ const points = [];
+ env.RUNNER_EVENTS = { writeDataPoint: (p) => points.push(p) };
+ env.PREVIEW_HOST = "demos.handsontable.com";
+ env.SERVICE_VERSION = "api-sha";
+ const pending = [];
+ const ctx = { waitUntil: (p) => pending.push(Promise.resolve(p)), passThroughOnException() {} };
+ const saved = async () => {
+ await Promise.all(pending);
+ return points.filter((p) => p.indexes[0] === "example.saved");
+ };
+ return { env, ctx, saved, pending, points };
+}
+
+const patch = (body, { auth = true } = {}) =>
+ new Request(`https://demos.handsontable.com/api/demos/${DEMO_ID}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json", ...(auth ? { Authorization: "Bearer test-token" } : {}) },
+ body: JSON.stringify(body),
+ });
+
+test("an editor Save writes exactly one example.saved point carrying the browser's saved-demo taxonomy", async () => {
+ const { env, ctx, saved } = setup();
+ const res = await worker.fetch(patch({ files: FILES, htVersion: "16.0.2", exampleHtMajor: "16" }), env, ctx);
+ assert.equal(res.status, 200);
+ const points = await saved();
+ assert.equal(points.length, 1, `expected exactly 1 example.saved point, got ${points.length}`);
+
+ // The row the browser path produced for this save: `App.tsx` opens a saved
+ // demo with its id as the lineage, and its facade attrs become the AE point.
+ const browser = toAePoint(
+ "example.saved",
+ { count: 1 },
+ {
+ service_name: "demos-authoring",
+ service_version: "authoring-sha",
+ environment: "production",
+ ...exampleActionAttrs(exampleTaxonomy({ lineage: DEMO_ID, framework: "react", htMajor: "16" })),
+ },
+ );
+ const [point] = points;
+ assert.deepEqual(point.indexes, browser.indexes);
+ assert.deepEqual(point.blobs.slice(2), browser.blobs.slice(2), "blob3..blob20 match the browser's row");
+ assert.deepEqual(point.doubles, browser.doubles);
+ assert.equal(point.blobs[0], "demos-api");
+ assert.equal(point.blobs[1], "api-sha");
+ assert.equal(point.blobs[16], "saved");
+ assert.equal(point.blobs[17], DEMO_ID);
+});
+
+test("the point's ht_major is the major the editor opened the demo at, not the version the Save pins", async () => {
+ const { env, ctx, saved } = setup();
+ const res = await worker.fetch(patch({ files: FILES, htVersion: "16.0.2", exampleHtMajor: "15" }), env, ctx);
+ assert.equal(res.status, 200);
+ const points = await saved();
+ assert.equal(points.length, 1);
+ assert.equal(points[0].blobs[6], "15");
+});
+
+test("an unauthenticated Save writes no example.saved point", async () => {
+ const { env, ctx, saved } = setup();
+ const res = await worker.fetch(patch({ files: FILES, exampleHtMajor: "16" }, { auth: false }), env, ctx);
+ assert.equal(res.status, 401);
+ assert.equal((await saved()).length, 0);
+});
+
+test("a Save of someone else's demo writes no example.saved point", async () => {
+ const { env, ctx, saved } = setup([demoRow({ id: DEMO_ID, created_by: "other@handsontable.com" })]);
+ const res = await worker.fetch(patch({ files: FILES, exampleHtMajor: "16" }), env, ctx);
+ assert.equal(res.status, 403);
+ assert.equal((await saved()).length, 0);
+});
+
+test("a Save refused while a build is running writes no example.saved point", async () => {
+ const { env, ctx, saved } = setup([
+ demoRow({ id: DEMO_ID, build_status: "building", updated_at: new Date().toISOString() }),
+ ]);
+ const res = await worker.fetch(patch({ files: FILES, exampleHtMajor: "16" }), env, ctx);
+ assert.equal(res.status, 409);
+ assert.equal((await saved()).length, 0);
+});
+
+test("a Save whose rebuild fails writes no example.saved point", async () => {
+ const { env, ctx, saved } = setup();
+ env.ARTIFACTS.put = async () => {
+ throw new Error("R2 unavailable");
+ };
+ const res = await worker.fetch(patch({ files: FILES, exampleHtMajor: "16" }), env, ctx);
+ assert.ok(res.status >= 500, `expected a 5xx, got ${res.status}`);
+ assert.equal((await saved()).length, 0);
+});
+
+test("a metadata-only PATCH (the Edit info dialog) writes no example.saved point", async () => {
+ const { env, ctx, saved } = setup();
+ const res = await worker.fetch(patch({ title: "Renamed", exampleHtMajor: "16" }), env, ctx);
+ assert.equal(res.status, 200);
+ assert.equal((await saved()).length, 0);
+});
+
+test("a Save without a valid exampleHtMajor (closed telemetry gate, a caller that omits it) writes no point", async () => {
+ for (const exampleHtMajor of [undefined, "", "20", 16, "latest"]) {
+ const { env, ctx, saved } = setup();
+ const res = await worker.fetch(patch({ files: FILES, exampleHtMajor }), env, ctx);
+ assert.equal(res.status, 200, `status for ${JSON.stringify(exampleHtMajor)}`);
+ assert.equal((await saved()).length, 0, `points for ${JSON.stringify(exampleHtMajor)}`);
+ }
+});
+
+test("every rebuild response carries the exampleSaved marker, true only when the point is written", async () => {
+ for (const [exampleHtMajor, expected] of [["16", true], [undefined, false], ["20", false]]) {
+ const { env, ctx } = setup();
+ const res = await worker.fetch(patch({ files: FILES, exampleHtMajor }), env, ctx);
+ assert.equal(res.status, 200);
+ const body = await res.json();
+ assert.equal(body.exampleSaved, expected, `exampleSaved for ${JSON.stringify(exampleHtMajor)}`);
+ }
+});
+
+test("a metadata-only PATCH carries no exampleSaved marker", async () => {
+ const { env, ctx } = setup();
+ const res = await worker.fetch(patch({ title: "Renamed", exampleHtMajor: "16" }), env, ctx);
+ assert.equal(res.status, 200);
+ assert.equal("exampleSaved" in (await res.json()), false);
+});
+
+test("the rebuild and its point are handed to waitUntil, so a client disconnect cannot cancel them", async () => {
+ // The oracle is the D1 write and the point completing through `waitUntil`
+ // alone: the rebuild is held until the handler has registered its work.
+ const { env, ctx, pending, points } = setup();
+ let release;
+ const gate = new Promise((r) => { release = r; });
+ const put = env.ARTIFACTS.put.bind(env.ARTIFACTS);
+ env.ARTIFACTS.put = async (...args) => { await gate; return put(...args); };
+ const writes = [];
+ const prepare = env.DB.prepare.bind(env.DB);
+ env.DB.prepare = (sql) => { if (/UPDATE demos SET ht_version=/.test(sql)) writes.push(sql); return prepare(sql); };
+
+ const response = worker.fetch(patch({ files: FILES, exampleHtMajor: "16" }), env, ctx);
+ await new Promise((r) => setTimeout(r, 50));
+ assert.ok(pending.length >= 1, "the save must be registered with waitUntil before it settles");
+ release();
+ await Promise.all(pending);
+ assert.equal(writes.length, 1, "the waitUntil promise covers the D1 update");
+ assert.equal(points.filter((p) => p.indexes[0] === "example.saved").length, 1, "and the point");
+ assert.equal((await response).status, 200);
+});
diff --git a/runner/pipeline/faro-config.test.mjs b/runner/pipeline/faro-config.test.mjs
new file mode 100644
index 0000000000..e1f7133c4b
--- /dev/null
+++ b/runner/pipeline/faro-config.test.mjs
@@ -0,0 +1,120 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { resolveTelemetryEnabled, telemetryEnvironment } from "../apps/authoring/src/telemetry/gate.ts";
+
+// Contract §10 "Local telemetry gate in the browser" + ADR §E.4's last
+// bullet. `telemetry/gate.ts` is import-free for the same reason as
+// `reportingGate.ts` — this file imports it directly under
+// `--experimental-strip-types`.
+//
+// The actual Faro wiring (`telemetry/faro.ts`) cannot be tested here: it
+// pulls in `@grafana/faro-web-sdk` and reads `import.meta.env`, so
+// `node --test` cannot import it. This file pins the decision `faro.ts`
+// delegates to `gate.ts`, and the settings it takes from `faroConfig.ts`.
+
+test("production leg: reuses resolveReporting's decision verbatim, regardless of the local flag/host", () => {
+ assert.equal(
+ resolveTelemetryEnabled({ productionReportingEnabled: true, localFlag: undefined, hostname: undefined }),
+ true,
+ );
+ // Even a WRONG local flag/host does not close a production-open gate — the
+ // production leg is unconditional once `productionReportingEnabled` is true.
+ assert.equal(
+ resolveTelemetryEnabled({ productionReportingEnabled: true, localFlag: "0", hostname: "evil.test" }),
+ true,
+ );
+});
+
+test("production closed + no local flag: closed", () => {
+ assert.equal(
+ resolveTelemetryEnabled({ productionReportingEnabled: false, localFlag: undefined, hostname: "localhost" }),
+ false,
+ );
+});
+
+test("local leg: opens on localhost with the exact flag '1'", () => {
+ assert.equal(
+ resolveTelemetryEnabled({ productionReportingEnabled: false, localFlag: "1", hostname: "localhost" }),
+ true,
+ );
+});
+
+test("local leg: opens on 127.0.0.1 too", () => {
+ assert.equal(
+ resolveTelemetryEnabled({ productionReportingEnabled: false, localFlag: "1", hostname: "127.0.0.1" }),
+ true,
+ );
+});
+
+test("local leg: any other flag value stays closed (only the literal '1' opens it)", () => {
+ for (const localFlag of [undefined, "", "true", "0", "yes"]) {
+ assert.equal(
+ resolveTelemetryEnabled({ productionReportingEnabled: false, localFlag, hostname: "localhost" }),
+ false,
+ `localFlag=${JSON.stringify(localFlag)}`,
+ );
+ }
+});
+
+test("local leg: any other host stays closed, even with the flag set", () => {
+ for (const hostname of [undefined, "demos.handsontable.com", "example.com", "0.0.0.0"]) {
+ assert.equal(
+ resolveTelemetryEnabled({ productionReportingEnabled: false, localFlag: "1", hostname }),
+ false,
+ `hostname=${JSON.stringify(hostname)}`,
+ );
+ }
+});
+
+// The contract's explicit negative: neither `import.meta.env.DEV` nor
+// `navigator.webdriver` are inputs to this function at all — Playwright serves
+// a production `vite preview` build under automation (contract §10), so a
+// DEV/webdriver check would make `e2e/telemetry-faro.spec.ts` unable to ever
+// see Faro fire against its own built dist. This is a structural guard: the
+// function's parameter list itself has no such field, so there is nothing a
+// future edit could "helpfully" wire up without changing the signature this
+// test imports.
+test("the gate takes no DEV/webdriver input — only productionReportingEnabled, localFlag, hostname", () => {
+ assert.deepEqual(resolveTelemetryEnabled.length, 1); // one destructured object param
+});
+
+test("telemetryEnvironment: production when the production leg opened the gate", () => {
+ assert.equal(telemetryEnvironment(true), "production");
+});
+
+test("telemetryEnvironment: local otherwise", () => {
+ assert.equal(telemetryEnvironment(false), "local");
+});
+
+// ---- batching and delivery (`telemetry/faroConfig.ts`) ---------------------------
+
+const { FARO_BATCHING, FARO_RETRY, FARO_BUFFER_SIZE } = await import("../apps/authoring/src/telemetry/faroConfig.ts");
+// The 429's Retry-After is the limiter window (`o11y-routes.test.mjs` pins the two equal).
+const wranglerText = readFileSync(fileURLToPath(new URL("../workers/o11y/wrangler.jsonc", import.meta.url)), "utf8");
+const RATE_LIMIT_PERIOD_SECONDS = Number(/"ratelimits"[\s\S]*?"period":\s*(\d+)/.exec(wranglerText)?.[1]);
+
+test("batching: one flush per 5 s, 50 items per batch", () => {
+ assert.deepEqual(FARO_BATCHING, { enabled: true, sendTimeout: 5_000, itemLimit: 50 });
+});
+
+test("retry: a 429's Retry-After (the limiter window) plus Faro's 20 % jitter fits under maxBackoffMs", () => {
+ assert.equal(FARO_RETRY.maxBackoffMs, 75_000);
+ assert.equal(RATE_LIMIT_PERIOD_SECONDS, 60);
+ // Faro drops a batch whose Retry-After exceeds maxBackoffMs, and caps the jittered wait at it.
+ assert.ok(FARO_RETRY.maxBackoffMs >= RATE_LIMIT_PERIOD_SECONDS * 1000 * 1.2);
+ assert.ok(FARO_RETRY.maxAttempts >= 2, "a 429'd batch gets at least one retry");
+});
+
+test("the delivery queue stays bounded", () => {
+ assert.equal(FARO_BUFFER_SIZE, 30);
+});
+
+test("faro.ts hands these settings to initializeFaro and its FetchTransport", () => {
+ const source = readFileSync(fileURLToPath(new URL("../apps/authoring/src/telemetry/faro.ts", import.meta.url)), "utf8");
+ assert.match(source, /batching:\s*\{\s*\.\.\.FARO_BATCHING\s*\}/);
+ assert.match(source, /new FetchTransport\(\{[^}]*bufferSize:\s*FARO_BUFFER_SIZE[^}]*retry:\s*\{\s*\.\.\.FARO_RETRY\s*\}/);
+ // With `transports` given, a top-level `url` would make Faro log a config error.
+ assert.doesNotMatch(source, /initializeFaro\(\{\s*url:/);
+});
diff --git a/runner/pipeline/fixtures/cloudflare-containers-stub.mjs b/runner/pipeline/fixtures/cloudflare-containers-stub.mjs
new file mode 100644
index 0000000000..2120576fa7
--- /dev/null
+++ b/runner/pipeline/fixtures/cloudflare-containers-stub.mjs
@@ -0,0 +1,196 @@
+// Structural stand-in for `@cloudflare/containers` under plain `node --test`
+// (the real package imports `cloudflare:workers` at load time, which only
+// exists inside workerd — the same reason worker-hooks.mjs stubs
+// `@cloudflare/sandbox`). Provides just the `Container` surface
+// `workers/o11y/src/box.ts` actually uses: a constructor storing
+// `ctx`/`env`, `getState()`, `start()`, `containerFetch()`, and the
+// lifecycle hooks, all routed through the mutable `hooks` registry below so
+// a test can install its own spy/behaviour per call without a mocking
+// library. Reset `hooks` to `defaultHooks()` in a `beforeEach`/`afterEach` —
+// this module is shared (imported once) across every test in a file.
+
+export function defaultHooks() {
+ return {
+ // (self, startOptions, waitOptions) -> void
+ async start(self, _startOptions, _waitOptions) {
+ self._state = { status: "running", lastChange: Date.now() };
+ },
+ // (self, port|undefined, cancellationOptions|undefined, startOptions|undefined) -> void
+ async startAndWaitForPorts(self) {
+ self._state = { status: "healthy", lastChange: Date.now() };
+ },
+ // (self, requestOrUrl, portOrInit, portParam) -> Response
+ async containerFetch(_self, _requestOrUrl, _portOrInit, _portParam) {
+ return new Response("stub: containerFetch not configured for this call", { status: 500 });
+ },
+ // (self, signal) -> void
+ //
+ // The real `Container.prototype.stop` only
+ // signals the process (SIGTERM) and awaits `syncPendingStoppedEvents` —
+ // it never sets `status: "stopping"` (that status exists in the
+ // library's types and its `ContainerState.setStopping()` method exists,
+ // but nothing in the package ever calls it). `getState()` keeps
+ // reporting whatever it reported before `stop()` was called
+ // (`"running"`/`"healthy"`) until the container process actually exits
+ // and the real `onStop` path (or, here, a test explicitly setting
+ // `self._state = { status: "stopped", ... }`) reflects that. This used
+ // to fabricate `"stopping"`, which is exactly why `box.ts`'s own
+ // a `state.status === "stopping"` branch would have a test that
+ // passed against a state the real library never produces.
+ async stop(_self, _signal) {},
+ };
+}
+
+export const hooks = defaultHooks();
+
+/** `@cloudflare/containers` `dist/lib/helpers.js#parseTimeExpression`:
+ * seconds, from a number or an `"s|m|h"` string. */
+function parseTimeExpression(expr) {
+ if (typeof expr === "number") return expr;
+ const match = /^(\d+)([smh])$/.exec(expr);
+ if (!match) throw new Error(`invalid time expression ${expr}`);
+ const value = parseInt(match[1], 10);
+ return match[2] === "s" ? value : match[2] === "m" ? value * 60 : value * 3600;
+}
+
+export class Container {
+ constructor(ctx, env, options) {
+ this.ctx = ctx;
+ this.env = env;
+ this.options = options;
+ // See box.ts's `containerFetch` override: a real
+ // `DurableObjectState.container` (public, unlike the base library's own
+ // private `this.container` field) is what `box.ts` now ALSO checks
+ // before letting the base class's own auto-start path run, because a
+ // persisted `getState()` status can lag the real container by a few
+ // minutes after a host loss (its own doc comment). Every test in this
+ // repo simulates container lifecycle purely by assigning `_state`
+ // (directly, or via a `hooks.start`/`hooks.stop` override) — the setter
+ // below keeps `ctx.container.running` in lockstep with whatever `_state`
+ // a test sets, so every EXISTING test (where the two never actually
+ // diverge) keeps passing unchanged. A test that wants to model the
+ // desync itself (a stale "healthy" `_state` after the real process
+ // already exited) sets `box.ctx.container.running = false` AFTER
+ // setting `_state`, deliberately breaking the lockstep for that one
+ // assertion.
+ if (!this.ctx.container) this.ctx.container = { running: false };
+ this._state = { status: "stopped", lastChange: Date.now() };
+ }
+
+ get _state() {
+ return this.__state;
+ }
+
+ set _state(value) {
+ this.__state = value;
+ this.ctx.container.running = value?.status === "running" || value?.status === "healthy";
+ }
+
+ async getState() {
+ return { ...this._state };
+ }
+
+ async start(startOptions, waitOptions) {
+ return hooks.start(this, startOptions, waitOptions);
+ }
+
+ async startAndWaitForPorts(portsOrArgs, cancellationOptions, startOptions) {
+ return hooks.startAndWaitForPorts(this, portsOrArgs, cancellationOptions, startOptions);
+ }
+
+ // ---- in-flight accounting and the idle clock ------------------------------
+ //
+ // Mirrors `@cloudflare/containers@0.3.7` `dist/lib/container.js`, because
+ // this is what decides whether the `sleepAfter` idle stop can ever fire:
+ // - `containerFetch` does `inflightRequests++` (:887) before proxying;
+ // - a response WITH a body is returned as `new Response(readable, res)`
+ // after `res.body.pipeTo(writable).finally(() => decrementInflight())`
+ // through an `IdentityTransformStream` (:955-960), so the count drops
+ // only once the CALLER consumes or cancels that body;
+ // - a body-less response, or a throw, decrements at once (:962, :966);
+ // - `isActivityExpired()` (:1687-1692) returns false and renews the clock
+ // while `inflightRequests > 0`; the base `alarm()` loop calls it and
+ // `onActivityExpired()` → `stop()` only when it returns true (:1566).
+ // Before this, `renewActivityTimeout()` was a no-op and nothing counted,
+ // so a caller that never released a response body (box.ts `isReady()`
+ // did exactly that on every probe) looked idle here and pinned the real
+ // container awake until the 4-hour cap. Deviations: a hook that THROWS
+ // still throws (the real library turns it into a 500 response), so
+ // existing tests that inject a throw keep their meaning; WebSocket
+ // responses are not modelled (nothing here proxies one).
+ inflightRequests = 0;
+ sleepAfterMs = 0;
+
+ async containerFetch(requestOrUrl, portOrInit, portParam) {
+ this.inflightRequests++;
+ let res;
+ try {
+ this.renewActivityTimeout();
+ res = await hooks.containerFetch(this, requestOrUrl, portOrInit, portParam);
+ } catch (e) {
+ this.decrementInflight();
+ throw e;
+ }
+ if (res.body !== null) {
+ const { readable, writable } = new TransformStream();
+ res.body
+ .pipeTo(writable)
+ .finally(() => this.decrementInflight())
+ // The library leaves this rejection (a cancelled body) unhandled;
+ // under node it would crash the test process instead.
+ .catch(() => {});
+ return new Response(readable, res);
+ }
+ this.decrementInflight();
+ return res;
+ }
+
+ decrementInflight() {
+ this.inflightRequests = Math.max(0, this.inflightRequests - 1);
+ if (this.inflightRequests === 0) this.renewActivityTimeout();
+ }
+
+ renewActivityTimeout() {
+ this.sleepAfterMs = Date.now() + parseTimeExpression(this.sleepAfter ?? "10m") * 1000;
+ }
+
+ isActivityExpired() {
+ if (this.inflightRequests > 0) {
+ this.renewActivityTimeout();
+ return false;
+ }
+ return this.sleepAfterMs <= Date.now();
+ }
+
+ async stop(signal) {
+ return hooks.stop(this, signal);
+ }
+
+ async destroy() {
+ this._state = { status: "stopped_with_code", exitCode: 137, lastChange: Date.now() };
+ }
+
+ onStart() {}
+ onStop(_params) {}
+ onActivityExpired() {
+ return this.stop();
+ }
+ onError(error) {
+ throw error;
+ }
+
+ /** The real `Container.schedule()` persists to SQLite and is later
+ * invoked by the base class's own `alarm()` loop — machinery this stub
+ * does not reimplement (see `box.ts`'s own tests, `o11y-wake.test.mjs`,
+ * which monkey-patch `instance.schedule` per test instead, to observe
+ * what gets scheduled without needing real timing). This default just
+ * records the call and never auto-invokes it — a harmless no-op for
+ * every test that calls `wake()`/`#doWake` (which schedules the 4-hour
+ * hard cap) without caring about scheduling at all. */
+ async schedule(when, callback, payload) {
+ this._scheduled ??= [];
+ const entry = { taskId: `stub-${this._scheduled.length}`, when, callback, payload };
+ this._scheduled.push(entry);
+ return entry;
+ }
+}
diff --git a/runner/pipeline/fixtures/cost-ledger-fake.mjs b/runner/pipeline/fixtures/cost-ledger-fake.mjs
new file mode 100644
index 0000000000..ad5b61d40f
--- /dev/null
+++ b/runner/pipeline/fixtures/cost-ledger-fake.mjs
@@ -0,0 +1,109 @@
+// A minimal in-memory `env.DB` fake covering exactly the `cost_ledger` and
+// `runner_settings` SQL shapes `budget.ts`/`settings.ts`/`reconcile.ts` issue
+// — not a general SQL engine, the same "cover exactly what the routes touch"
+// scope `worker-harness.mjs#fakeD1` documents for the demos/tokens tables.
+// Used by `o11y-cost.test.mjs`/`o11y-alerts.test.mjs` (the o11y spend cap
+// rule reads through `budget.ts#computeO11ySpend`).
+
+/** @returns {{ DB: object, _ledger: Map, _settings: Map }} */
+export function fakeCostD1(seedLedgerRows = []) {
+ // key: `${day}|${sku}|${source}`
+ const ledger = new Map();
+ for (const row of seedLedgerRows) {
+ ledger.set(`${row.day}|${row.sku}|${row.source}`, { ...row, updated_at: row.updated_at ?? Date.now() });
+ }
+ const settings = new Map(); // key -> { value, updated_at, updated_by }
+
+ function upsertEstimateRow(day, sku, units, usd, updatedAt) {
+ const key = `${day}|${sku}|estimate`;
+ const existing = ledger.get(key);
+ if (existing) {
+ ledger.set(key, { day, sku, source: "estimate", units: existing.units + units, usd: existing.usd + usd, updated_at: updatedAt });
+ } else {
+ ledger.set(key, { day, sku, source: "estimate", units, usd, updated_at: updatedAt });
+ }
+ }
+
+ function setBillingRow(day, sku, units, usd, updatedAt) {
+ ledger.set(`${day}|${sku}|billing`, { day, sku, source: "billing", units, usd, updated_at: updatedAt });
+ }
+
+ const DB = {
+ prepare(sql) {
+ let binds = [];
+ const stmt = {
+ bind(...args) {
+ binds = args;
+ return stmt;
+ },
+ async run() {
+ if (/INSERT INTO cost_ledger/.test(sql) && /'estimate'/.test(sql)) {
+ const [day, sku, units, usd, updatedAt] = binds;
+ upsertEstimateRow(day, sku, units, usd, updatedAt);
+ return { success: true };
+ }
+ if (/INSERT INTO cost_ledger/.test(sql) && /'billing'/.test(sql)) {
+ const [day, sku, units, usd, updatedAt] = binds;
+ setBillingRow(day, sku, units, usd, updatedAt);
+ return { success: true };
+ }
+ if (/DELETE FROM cost_ledger/.test(sql)) {
+ const [cutoff] = binds;
+ for (const [key, row] of [...ledger]) if (row.day < cutoff) ledger.delete(key);
+ return { success: true };
+ }
+ if (/INSERT INTO runner_settings/.test(sql)) {
+ const [key, value, updatedAt, updatedBy] = binds;
+ settings.set(key, { value, updated_at: updatedAt, updated_by: updatedBy });
+ return { success: true };
+ }
+ if (/DELETE FROM runner_settings/.test(sql)) {
+ const [key] = binds;
+ settings.delete(key);
+ return { success: true };
+ }
+ throw new Error(`fakeCostD1: unhandled run() SQL: ${sql}`);
+ },
+ async all() {
+ if (/FROM cost_ledger WHERE day >= /.test(sql)) {
+ const [since] = binds;
+ const rows = [...ledger.values()].filter((r) => r.day >= since).sort((a, b) => (a.day < b.day ? 1 : -1));
+ return { results: rows };
+ }
+ if (/FROM cost_ledger/.test(sql) && /GROUP BY day, sku/.test(sql)) {
+ const [dayLike] = binds;
+ const prefix = dayLike.replace(/%$/, "");
+ const skuMatch = /sku IN \(([^)]+)\)/.exec(sql);
+ const allowedSkus = skuMatch ? skuMatch[1].split(",").map((s) => s.trim().replace(/'/g, "")) : null;
+ const byDaySku = new Map();
+ for (const row of ledger.values()) {
+ if (!row.day.startsWith(prefix)) continue;
+ if (allowedSkus && !allowedSkus.includes(row.sku)) continue;
+ const key = `${row.day}|${row.sku}`;
+ const existing = byDaySku.get(key);
+ // COALESCE(billing, estimate, 0), same precedence as the real query.
+ if (!existing || row.source === "billing") byDaySku.set(key, row);
+ else if (existing.source !== "billing") byDaySku.set(key, row);
+ }
+ return { results: [...byDaySku.values()].map((r) => ({ usd: r.usd, reconciled: r.source === "billing" ? 1 : 0 })) };
+ }
+ throw new Error(`fakeCostD1: unhandled all() SQL: ${sql}`);
+ },
+ async first() {
+ if (/FROM runner_settings WHERE key = /.test(sql)) {
+ const [key] = binds;
+ const row = settings.get(key);
+ return row ? { value: row.value, updated_at: row.updated_at, updated_by: row.updated_by } : null;
+ }
+ throw new Error(`fakeCostD1: unhandled first() SQL: ${sql}`);
+ },
+ };
+ return stmt;
+ },
+ batch(stmts) {
+ return Promise.all(stmts.map((s) => s.run()));
+ },
+ };
+
+ return { DB, _ledger: ledger, _settings: settings };
+}
diff --git a/runner/pipeline/fixtures/fake-ae-query.mjs b/runner/pipeline/fixtures/fake-ae-query.mjs
new file mode 100644
index 0000000000..2d6fe6a2f9
--- /dev/null
+++ b/runner/pipeline/fixtures/fake-ae-query.mjs
@@ -0,0 +1,102 @@
+// A tiny fake Analytics Engine query engine for `pipeline/o11y-alerts.test.mjs`:
+// recognises exactly the SQL shapes `workers/o11y/src/alerts/rules.ts`'s
+// shared helpers generate — grouped `sum(_sample_interval * )`
+// counts and a `quantileExactWeighted` read — and answers them from a
+// plain JS array of seeded rows, so each rule's threshold/comparison logic
+// is testable without a live ClickHouse/AE endpoint. Column slots are
+// resolved generically via `AE_COLUMNS`, never a hand-numbered
+// `blob8`/`double1` literal, so this fixture stays correct if the contract
+// ever renumbers a slot.
+//
+// Deliberately narrow: throws on any SQL shape it does not recognise,
+// rather than silently answering `[]`.
+
+import { AE_COLUMNS } from "@handsontable/demo-runtime/telemetry";
+
+const SLOT_TO_NAME = Object.fromEntries(Object.entries(AE_COLUMNS).map(([k, v]) => [v, k]));
+
+/**
+ * @param {Array & { metric: string; ageMs?: number }>} rows
+ * Each row is a logical record — `metric`, plus whichever contract column
+ * names (`outcome`, `tier`, `surface`, `demo_id`, `ht_major`,
+ * `duration_ms`, `count`) the rule under test filters/groups/sums on.
+ * `ageMs` (default 0 = "now") is how old the row is, for window filtering.
+ */
+export function makeFakeAeQuery(rows) {
+ const calls = [];
+
+ async function queryFn(_env, sql) {
+ calls.push(sql);
+
+ const metricMatch = /index1 = '([^']*)'/.exec(sql);
+ const metric = metricMatch?.[1];
+ let candidates = rows.filter((r) => r.metric === metric);
+
+ // Window bounds: `timestamp >= now() - INTERVAL 'S' SECOND` (always
+ // present) and, for a day-over-day comparison, an upper bound too:
+ // `AND timestamp < now() - INTERVAL 'E' SECOND`.
+ const startMatch = /timestamp >= now\(\) - INTERVAL '(\d+)' SECOND/.exec(sql);
+ const windowStartS = startMatch ? Number(startMatch[1]) : Infinity;
+ const endMatch = /timestamp < now\(\) - INTERVAL '(\d+)' SECOND/.exec(sql);
+ const windowEndS = endMatch ? Number(endMatch[1]) : -Infinity;
+ candidates = candidates.filter((r) => {
+ const ageS = (r.ageMs ?? 0) / 1000;
+ return ageS <= windowStartS && ageS > windowEndS;
+ });
+
+ // Extra equality filters: `AND blobN = 'value'` / `AND doubleN = 'value'`.
+ const filterRe = /AND (blob\d+|double\d+) = '([^']*)'/g;
+ let fm;
+ while ((fm = filterRe.exec(sql))) {
+ const logical = SLOT_TO_NAME[fm[1]];
+ if (!logical) throw new Error(`fake-ae-query: unknown slot in filter: ${fm[1]}`);
+ const value = fm[2];
+ candidates = candidates.filter((r) => String(r[logical] ?? "") === value);
+ }
+
+ // Set-exclusion filters: `AND blobN NOT IN ('a', 'b', ...)` — minor
+ // triage item 6 (`fiveXxRateRule`'s route-class exclusion).
+ const notInRe = /AND (blob\d+|double\d+) NOT IN \(([^)]*)\)/g;
+ let nim;
+ while ((nim = notInRe.exec(sql))) {
+ const logical = SLOT_TO_NAME[nim[1]];
+ if (!logical) throw new Error(`fake-ae-query: unknown slot in NOT IN filter: ${nim[1]}`);
+ const excluded = new Set([...nim[2].matchAll(/'([^']*)'/g)].map((m) => m[1]));
+ candidates = candidates.filter((r) => !excluded.has(String(r[logical] ?? "")));
+ }
+
+ // `quantileExactWeighted(q)(, toUInt32(_sample_interval)) AS p`
+ const qm = /quantileExactWeighted\(([\d.]+)\)\((double\d+),/.exec(sql);
+ if (qm) {
+ const valueLogical = SLOT_TO_NAME[qm[2]];
+ if (!valueLogical) throw new Error(`fake-ae-query: unknown slot in quantile: ${qm[2]}`);
+ const values = candidates
+ .map((r) => r[valueLogical])
+ .filter((v) => typeof v === "number")
+ .sort((a, b) => a - b);
+ if (values.length === 0) return [];
+ const q = Number(qm[1]);
+ const idx = Math.min(values.length - 1, Math.max(0, Math.ceil(q * values.length) - 1));
+ return [{ p: values[idx] }];
+ }
+
+ // `SELECT AS , sum(_sample_interval * ) AS c ... GROUP BY `
+ const groupMatch = /SELECT (blob\d+) AS (\w+), sum\(_sample_interval \* (double\d+)\) AS c/.exec(sql);
+ if (groupMatch) {
+ const groupLogical = SLOT_TO_NAME[groupMatch[1]];
+ const countLogical = SLOT_TO_NAME[groupMatch[3]];
+ if (!groupLogical || !countLogical) throw new Error(`fake-ae-query: unknown slot in SELECT: ${sql}`);
+ const totals = new Map();
+ for (const r of candidates) {
+ const key = String(r[groupLogical] ?? "");
+ totals.set(key, (totals.get(key) ?? 0) + Number(r[countLogical] ?? 1));
+ }
+ const alias = groupMatch[2];
+ return [...totals.entries()].map(([k, c]) => ({ [alias]: k, c }));
+ }
+
+ throw new Error(`fake-ae-query: unrecognised SQL shape: ${sql}`);
+ }
+
+ return { queryFn, calls };
+}
diff --git a/runner/pipeline/fixtures/faro/example-open.json b/runner/pipeline/fixtures/faro/example-open.json
new file mode 100644
index 0000000000..e95acb687f
--- /dev/null
+++ b/runner/pipeline/fixtures/faro/example-open.json
@@ -0,0 +1,21 @@
+{
+ "meta": {
+ "app": { "name": "demos-authoring", "version": "deadbeef1234" }
+ },
+ "events": [
+ {
+ "name": "example.open",
+ "timestamp": "2026-01-01T00:00:00.000Z",
+ "attributes": {
+ "hot.surface": "authoring",
+ "hot.framework": "react",
+ "hot.ht_major": "18",
+ "hot.bucket": "18.1",
+ "hot.metric_kind": "docs",
+ "hot.ref": "/guide/getting-started",
+ "hot.area": "getting-started",
+ "hot.reason": "entry"
+ }
+ }
+ ]
+}
diff --git a/runner/pipeline/fixtures/faro/exception-code-frame.json b/runner/pipeline/fixtures/faro/exception-code-frame.json
new file mode 100644
index 0000000000..677a09e600
--- /dev/null
+++ b/runner/pipeline/fixtures/faro/exception-code-frame.json
@@ -0,0 +1,26 @@
+{
+ "meta": {
+ "app": { "name": "demos-authoring", "version": "deadbeef1234" },
+ "browser": { "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" }
+ },
+ "exceptions": [
+ {
+ "type": "ReferenceError",
+ "value": "x is not defined\n\n 1 | function f() {\n> 2 | return x + 1;\n | ^\n 3 | }",
+ "timestamp": "2026-01-01T00:00:00.000Z",
+ "stacktrace": {
+ "frames": [
+ { "filename": "https://8787-abc123-tok3n.demos.handsontable.com/assets/index-abc123.js?t=1700000000" },
+ { "filename": "https://demos.handsontable.com/assets/main-def456.js" }
+ ]
+ },
+ "context": {
+ "hot.surface": "authoring",
+ "hot.tier": "1",
+ "hot.framework": "react",
+ "hot.ht_major": "18",
+ "handled": "false"
+ }
+ }
+ ]
+}
diff --git a/runner/pipeline/fixtures/faro/log.json b/runner/pipeline/fixtures/faro/log.json
new file mode 100644
index 0000000000..0ccf4bb79b
--- /dev/null
+++ b/runner/pipeline/fixtures/faro/log.json
@@ -0,0 +1,15 @@
+{
+ "meta": {
+ "app": { "name": "demos-authoring", "version": "deadbeef1234" }
+ },
+ "logs": [
+ {
+ "message": "share link copied",
+ "timestamp": "2026-01-01T00:00:00.000Z",
+ "context": {
+ "hot.surface": "share",
+ "hot.demo_id": "r-react-18-0-0"
+ }
+ }
+ ]
+}
diff --git a/runner/pipeline/fixtures/faro/measurement.json b/runner/pipeline/fixtures/faro/measurement.json
new file mode 100644
index 0000000000..791fc6bec4
--- /dev/null
+++ b/runner/pipeline/fixtures/faro/measurement.json
@@ -0,0 +1,20 @@
+{
+ "meta": {
+ "app": { "name": "demos-authoring", "version": "deadbeef1234" }
+ },
+ "measurements": [
+ {
+ "type": "preview.ready_ms",
+ "values": { "duration_ms": 842 },
+ "timestamp": "2026-01-01T00:00:00.000Z",
+ "context": {
+ "hot.surface": "authoring",
+ "hot.tier": "1",
+ "hot.framework": "react",
+ "hot.ht_major": "18",
+ "hot.outcome": "ready",
+ "hot.bucket": "18.1"
+ }
+ }
+ ]
+}
diff --git a/runner/pipeline/fixtures/faro/web-vitals.json b/runner/pipeline/fixtures/faro/web-vitals.json
new file mode 100644
index 0000000000..338c56122f
--- /dev/null
+++ b/runner/pipeline/fixtures/faro/web-vitals.json
@@ -0,0 +1,19 @@
+{
+ "meta": {
+ "app": { "name": "demos-authoring", "version": "deadbeef1234" }
+ },
+ "measurements": [
+ {
+ "type": "web-vitals",
+ "values": { "lcp": 1234.5, "inp": 45, "cls": 0.02, "fcp": 900 },
+ "timestamp": "2026-01-01T00:00:00.000Z",
+ "context": {
+ "hot.surface": "share",
+ "hot.framework": "react",
+ "hot.ht_major": "18",
+ "hot.device": "mobile",
+ "hot.demo_id": "r-react-18-0-0"
+ }
+ }
+ ]
+}
diff --git a/runner/pipeline/fixtures/o11y-cloudflare-workers-stub.mjs b/runner/pipeline/fixtures/o11y-cloudflare-workers-stub.mjs
new file mode 100644
index 0000000000..4e63139a62
--- /dev/null
+++ b/runner/pipeline/fixtures/o11y-cloudflare-workers-stub.mjs
@@ -0,0 +1,23 @@
+// Structural stand-in for `cloudflare:workers` (only exists inside
+// workerd), used by `o11y-worker-hooks.mjs` and `worker-hooks.mjs`.
+// `InboxWriter` (writer.ts) extends `DurableObject` and reads only
+// `this.ctx`/`this.env` — the real base class's constructor signature is
+// `(ctx, env)`, mirrored exactly. `WorkerEntrypoint` covers
+// `workers/o11y/src/heartbeat.ts` (`O11yHeartbeat`) and
+// `workers/api/src/o11y-usage.ts` (`O11yUsage`) the same way, since both
+// read only `this.ctx`/`this.env` too. Shared across both hook files
+// rather than a second stub.
+
+export class DurableObject {
+ constructor(ctx, env) {
+ this.ctx = ctx;
+ this.env = env;
+ }
+}
+
+export class WorkerEntrypoint {
+ constructor(ctx, env) {
+ this.ctx = ctx;
+ this.env = env;
+ }
+}
diff --git a/runner/pipeline/fixtures/o11y-harness.mjs b/runner/pipeline/fixtures/o11y-harness.mjs
new file mode 100644
index 0000000000..df8f25357a
--- /dev/null
+++ b/runner/pipeline/fixtures/o11y-harness.mjs
@@ -0,0 +1,191 @@
+// Shared in-memory env for the o11y worker's tests — the same pattern
+// `worker-harness.mjs` uses for `workers/api`'s D1/KV/R2 fakes (TESTING.md:
+// "in-memory fakes for worker bindings"). Imports nothing from
+// `workers/o11y/src/`, so it is safe to import before `o11y-worker-hooks.mjs`
+// is registered (mirrors `worker-harness.mjs`'s own header note).
+
+export const ctx = {
+ waitUntil(promise) {
+ // Tests await route handlers directly and then drain this queue, so a
+ // `writePoint`'s `ctx.waitUntil` write lands before assertions run.
+ this._pending.push(Promise.resolve(promise).catch(() => {}));
+ },
+ _pending: [],
+ async drain() {
+ await Promise.all(this._pending);
+ this._pending.length = 0;
+ },
+};
+
+/** A real `DurableObjectStorage`-shaped `Map` fake — the overloaded `get`
+ * branches on `Array.isArray` at runtime (plain JS, no TS overload
+ * wrangling needed here). `transaction()` calls the closure with itself, so
+ * nested `txn.get`/`.put`/`.delete`/`.list` calls hit the same backing
+ * `Map` atomically-in-spirit (single-threaded Node, no real concurrency to
+ * guard against). */
+// The real SQLite-backed DO storage API caps `get`/`put`/`delete` at 128
+// keys/pairs per call (see `workers/o11y/src/inbox/storage.ts`'s
+// `DO_STORAGE_MAX_KEYS_PER_CALL` doc comment for the exact Cloudflare docs
+// quote and URL). Every multi-key call below throws past the real limit,
+// the same as `inbox/storage.ts#memoryStorage()`, so a caller that forgets
+// to chunk is caught here rather than in production.
+const DO_STORAGE_MAX_KEYS_PER_CALL = 128;
+
+export function makeDurableObjectStorage(seed = new Map()) {
+ const data = seed;
+ let alarm = null;
+ const storage = {
+ async get(keyOrKeys) {
+ if (Array.isArray(keyOrKeys)) {
+ if (keyOrKeys.length > DO_STORAGE_MAX_KEYS_PER_CALL) {
+ throw new Error(`makeDurableObjectStorage().get: ${keyOrKeys.length} keys exceeds the DO storage limit of ${DO_STORAGE_MAX_KEYS_PER_CALL}`);
+ }
+ const out = new Map();
+ for (const k of keyOrKeys) if (data.has(k)) out.set(k, data.get(k));
+ return out;
+ }
+ return data.get(keyOrKeys);
+ },
+ async put(entries) {
+ const keys = Object.keys(entries);
+ if (keys.length > DO_STORAGE_MAX_KEYS_PER_CALL) {
+ throw new Error(`makeDurableObjectStorage().put: ${keys.length} keys exceeds the DO storage limit of ${DO_STORAGE_MAX_KEYS_PER_CALL}`);
+ }
+ for (const [k, v] of Object.entries(entries)) data.set(k, v);
+ },
+ async delete(keys) {
+ if (keys.length > DO_STORAGE_MAX_KEYS_PER_CALL) {
+ throw new Error(`makeDurableObjectStorage().delete: ${keys.length} keys exceeds the DO storage limit of ${DO_STORAGE_MAX_KEYS_PER_CALL}`);
+ }
+ let n = 0;
+ for (const k of keys) if (data.delete(k)) n++;
+ return n;
+ },
+ // A real `DurableObjectStorage.list`
+ // also accepts `start`/`end`/`limit` (ledger.ts's bounded-range prune
+ // sweeps use exactly these, never an unbounded `prefix`-only scan — see
+ // `storage.ts`'s `ListOptions` doc comment); this fake must not silently
+ // ignore all three: a call with `start`/`end` but no `prefix` fell
+ // through the `!options?.prefix` check and returned the WHOLE storage
+ // Map, so a prune call's `storage.delete([...matches])` deleted
+ // EVERYTHING in the DO, not just the intended stale range (caught by
+ // `o11y-cap-wake.test.mjs`'s backlog test going from a real `written`
+ // key to `undefined`). Always sorts ascending, matching the real
+ // binding's default (`reverse` is never requested by this codebase).
+ async list(options) {
+ const matches = [];
+ for (const [k, v] of data) {
+ if (options?.prefix && !k.startsWith(options.prefix)) continue;
+ if (options?.start !== undefined && k < options.start) continue;
+ if (options?.end !== undefined && k >= options.end) continue;
+ matches.push([k, v]);
+ }
+ matches.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
+ const limited = options?.limit !== undefined ? matches.slice(0, options.limit) : matches;
+ return new Map(limited);
+ },
+ async transaction(closure) {
+ return closure(storage);
+ },
+ async getAlarm() {
+ return alarm;
+ },
+ async setAlarm(t) {
+ alarm = t instanceof Date ? t.getTime() : t;
+ },
+ async deleteAlarm() {
+ alarm = null;
+ },
+ _data: data,
+ };
+ return storage;
+}
+
+export function makeR2Bucket() {
+ const objects = new Map();
+ return {
+ objects,
+ async put(key, value) {
+ objects.set(key, value instanceof Uint8Array ? value : new Uint8Array(value));
+ },
+ async get(key) {
+ const v = objects.get(key);
+ if (!v) return null;
+ return { body: v, async arrayBuffer() { return v.buffer; } };
+ },
+ };
+}
+
+export function makeAnalyticsEngine() {
+ const points = [];
+ return {
+ points,
+ writeDataPoint(point) {
+ points.push(point);
+ },
+ };
+}
+
+const SECRET = "test-export-secret";
+const SENTRY_SECRET = "test-sentry-secret";
+
+/**
+ * `InboxWriterClass` is the real `InboxWriter` (`workers/o11y/src/inbox/writer.ts`),
+ * dynamically imported by the caller **after** `o11y-worker-hooks.mjs` is
+ * registered (this module must not import it itself — see the header). One
+ * shared `DurableObjectStorage` fake backs the constructed instance, so a
+ * caller that wants to simulate a restart just constructs a second
+ * `InboxWriterClass` instance over the same `doStorage`.
+ */
+export function makeEnv(InboxWriterClass, overrides = {}) {
+ const doStorage = overrides.doStorage ?? makeDurableObjectStorage();
+ const r2 = overrides.r2 ?? makeR2Bucket();
+ const ae = overrides.ae ?? makeAnalyticsEngine();
+
+ const env = {
+ O11Y_ENV: "production",
+ // Replaces ACCESS_TEAM_DOMAIN/ACCESS_AUD (Cloudflare Access).
+ LOGIN_BROKER_URL: "https://mcp-auth-proxy.example.test",
+ O11Y_SESSION_SECRET: "test-session-secret-at-least-32-bytes-long",
+ GITHUB_OIDC_REPOSITORY: "handsontable/examples",
+ GITHUB_OIDC_WORKFLOW_REF: "handsontable/examples/.github/workflows/master.yml@refs/heads/master",
+ O11Y_EXPORT_SECRET: SECRET,
+ SENTRY_HOOK_SECRET: SENTRY_SECRET,
+ AE_SQL_TOKEN: "test-ae-token",
+ O11Y_INBOX: r2,
+ O11Y_LOKI_STATE: makeR2Bucket(),
+ O11Y_MAPS: makeR2Bucket(),
+ RUNNER_EVENTS: ae,
+ API: { fetch: async () => new Response(null, { status: 204 }) },
+ RATE_LIMITER: { limit: async () => ({ success: true }) },
+ ...overrides.env,
+ };
+
+ const doState = { storage: doStorage };
+ const inboxWriterInstance = new InboxWriterClass(doState, env);
+
+ env.INBOX_WRITER = {
+ jurisdiction() {
+ return this;
+ },
+ idFromName(name) {
+ return { toString: () => name, name };
+ },
+ get() {
+ return inboxWriterInstance;
+ },
+ };
+ env.GRAFANA_BOX = {
+ jurisdiction() {
+ return this;
+ },
+ idFromName(name) {
+ return { toString: () => name, name };
+ },
+ get() {
+ throw new Error("GrafanaBox not constructed in this harness (T01's class)");
+ },
+ };
+
+ return { env, doStorage, r2, ae, inboxWriterInstance };
+}
diff --git a/runner/pipeline/fixtures/o11y-inbox-helpers.mjs b/runner/pipeline/fixtures/o11y-inbox-helpers.mjs
new file mode 100644
index 0000000000..7be2cf8a45
--- /dev/null
+++ b/runner/pipeline/fixtures/o11y-inbox-helpers.mjs
@@ -0,0 +1,23 @@
+// Test-only helper for `pipeline/o11y-inbox.test.mjs`: production reads
+// pending rows only through the bounded `pack.ts#collectRowBatch`.
+
+const ROW_PREFIX = "row:";
+
+function rowNumber(key) {
+ return Number(key.slice(ROW_PREFIX.length));
+}
+
+/** All pending rows, grouped by tenant, in row-insertion order (numeric sort
+ * in memory — unbounded, so this is for tests only, over a small, known row
+ * count). */
+export async function pendingRowsByTenant(storage) {
+ const rows = await storage.list({ prefix: ROW_PREFIX });
+ const sorted = [...rows.entries()].sort(([a], [b]) => rowNumber(a) - rowNumber(b));
+ const byTenant = new Map();
+ for (const entry of sorted) {
+ const list = byTenant.get(entry[1].tenant) ?? [];
+ list.push(entry);
+ byTenant.set(entry[1].tenant, list);
+ }
+ return byTenant;
+}
diff --git a/runner/pipeline/fixtures/o11y-symbolicate-drain-child.mjs b/runner/pipeline/fixtures/o11y-symbolicate-drain-child.mjs
new file mode 100644
index 0000000000..bdb8bfc22b
--- /dev/null
+++ b/runner/pipeline/fixtures/o11y-symbolicate-drain-child.mjs
@@ -0,0 +1,66 @@
+// Child process for `pipeline/o11y-symbolicate-drain.test.mjs`.
+//
+// The parent spawns this with `--disallow-code-generation-from-strings`,
+// the V8 policy workerd applies to every Worker (`eval` / `new Function`
+// throw `EvalError: Code generation from strings disallowed for this
+// context`). A `node --test` file cannot set that flag for itself, and
+// without it Node happily runs a map library that generates code — a
+// symbolication test could pass in Node while every lookup in the real
+// Worker throws.
+//
+// Runs the real `drain.ts#drainBatch` with the real
+// `symbolicate.ts#symbolicateResourceLogs` over one gzipped inbox object
+// the parent wrote, and prints one JSON line to stdout: whether code
+// generation really was blocked in this process, the batch outcome, the
+// decoded Loki push bodies, and every skip report `onSkip` received.
+//
+// argv: (holds `inbox.ndjson.gz`, `inbox-key.txt` and `maps/`)
+
+import { register } from "node:module";
+import { readFileSync, existsSync } from "node:fs";
+import path from "node:path";
+
+register("./o11y-worker-hooks.mjs", import.meta.url);
+
+const workdir = process.argv[2];
+if (!workdir) throw new Error("usage: o11y-symbolicate-drain-child.mjs ");
+
+let codegenBlocked = false;
+try {
+ new Function("return 1");
+} catch (err) {
+ codegenBlocked = err instanceof EvalError;
+}
+
+const { drainBatch } = await import("../../workers/o11y/src/drain/drain.ts");
+const { symbolicateResourceLogs } = await import("../../workers/o11y/src/drain/symbolicate.ts");
+
+const inboxKey = readFileSync(path.join(workdir, "inbox-key.txt"), "utf8").trim();
+const inboxObject = new Uint8Array(readFileSync(path.join(workdir, "inbox.ndjson.gz")));
+
+async function gunzip(bytes) {
+ const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip"));
+ return new Response(stream).text();
+}
+
+const pushes = [];
+const skips = [];
+const mapReads = [];
+const result = await drainBatch([inboxKey], new Set(), {
+ fetchObject: async (key) => (key === inboxKey ? inboxObject : null),
+ pushToLoki: async (tenant, gzippedBody) => {
+ pushes.push({ tenant, body: JSON.parse(await gunzip(gzippedBody)) });
+ return { status: 204 };
+ },
+ symbolicate: (records) =>
+ symbolicateResourceLogs(records, {
+ getMap: async (key) => {
+ mapReads.push(key);
+ const file = path.join(workdir, "maps", key);
+ return existsSync(file) ? readFileSync(file, "utf8") : null;
+ },
+ onSkip: (reported, suppressed, overCap) => skips.push({ reported, suppressed, overCap }),
+ }),
+});
+
+process.stdout.write(`${JSON.stringify({ codegenBlocked, result, pushes, skips, mapReads })}\n`);
diff --git a/runner/pipeline/fixtures/o11y-worker-hooks.mjs b/runner/pipeline/fixtures/o11y-worker-hooks.mjs
new file mode 100644
index 0000000000..00d64ff9d8
--- /dev/null
+++ b/runner/pipeline/fixtures/o11y-worker-hooks.mjs
@@ -0,0 +1,57 @@
+// Module hooks that make the real o11y worker (workers/o11y/src/index.ts,
+// re-exporting `InboxWriter` and `GrafanaBox`) loadable under plain
+// `node --experimental-strip-types --test`, registered via
+// `module.register()` before the worker is imported. One shared file, not
+// two, since both specs import through `index.ts`.
+//
+// Three obstacles: the worker's modules import each other by `.js`
+// specifier (the Workers bundler's shape) but the files on disk are `.ts`
+// — map the extension for relative imports inside `workers/o11y/src/`.
+// `cloudflare:workers` (`InboxWriter`'s DurableObject base) only exists
+// inside workerd, and needs only an inert stub
+// (`o11y-cloudflare-workers-stub.mjs`) since `InboxWriter` touches nothing
+// beyond `this.ctx`/`this.env`. `@cloudflare/containers` (`GrafanaBox`'s
+// base) also only exists inside workerd; `GrafanaBox`'s own
+// container-lifecycle specs drive a real instance, so its stub
+// (`cloudflare-containers-stub.mjs`) is a fuller structural double.
+
+const CLOUDFLARE_WORKERS_STUB = new URL("./o11y-cloudflare-workers-stub.mjs", import.meta.url).href;
+const CLOUDFLARE_CONTAINERS_STUB = new URL("./cloudflare-containers-stub.mjs", import.meta.url).href;
+
+// `jose`, `source-map-js` (a `workers/o11y` devDependency, borrowed the same
+// way for `pipeline/o11y-symbolicate.test.mjs`, which needs to build a real
+// source map with `SourceMapGenerator` to test against — `drain/symbolicate.ts`
+// itself resolves maps with `@jridgewell/trace-mapping`) and
+// `@handsontable/demo-runtime` (any subpath) are
+// `workers/o11y`'s dependencies, not the pipeline's — a plain node resolve
+// only succeeds when the importing file lives under `workers/o11y/`, which
+// every gate/normalise module does. A test file under `pipeline/` that also
+// wants to sign a test JWT, build a source map, or read
+// `decodeNdjson`/`toAePoint` directly to assert on inbox output, has no such
+// ancestor `node_modules` entry; borrow one by resolving as if the request
+// came from inside `workers/o11y/src/` instead.
+const WORKERS_O11Y_SRC_URL = new URL("../../workers/o11y/src/index.ts", import.meta.url).href;
+const BORROWED_SPECIFIERS = ["jose", "source-map-js", "@handsontable/demo-runtime"];
+
+export async function resolve(specifier, context, nextResolve) {
+ if (specifier === "cloudflare:workers") {
+ return { url: CLOUDFLARE_WORKERS_STUB, shortCircuit: true };
+ }
+ if (specifier === "@cloudflare/containers") {
+ return { url: CLOUDFLARE_CONTAINERS_STUB, shortCircuit: true };
+ }
+ if (
+ BORROWED_SPECIFIERS.some((s) => specifier === s || specifier.startsWith(`${s}/`))
+ && !context.parentURL?.includes("/workers/o11y/")
+ ) {
+ return nextResolve(specifier, { ...context, parentURL: WORKERS_O11Y_SRC_URL });
+ }
+ if (
+ specifier.startsWith(".")
+ && specifier.endsWith(".js")
+ && context.parentURL?.includes("/workers/o11y/src/")
+ ) {
+ return nextResolve(`${specifier.slice(0, -3)}.ts`, context);
+ }
+ return nextResolve(specifier, context);
+}
diff --git a/runner/pipeline/fixtures/otlp/build-protobuf-fixtures.mjs b/runner/pipeline/fixtures/otlp/build-protobuf-fixtures.mjs
new file mode 100644
index 0000000000..3daa6df6e2
--- /dev/null
+++ b/runner/pipeline/fixtures/otlp/build-protobuf-fixtures.mjs
@@ -0,0 +1,122 @@
+#!/usr/bin/env node
+// Hand-encodes the OTLP `ExportLogsServiceRequest` protobuf fixtures the
+// tests replay (`pipeline/o11y-normalise.test.mjs`,
+// `scripts/o11y-replay-fixtures.mjs`) — the binary-wire mirror of
+// `pipeline/fixtures/otlp/json/basic.json` and `.../zero-timestamp.json`,
+// same field values, so the two decoders
+// (`workers/o11y/src/normalise/otlp.ts`'s `decodeOtlpJson` /
+// `otlp-protobuf.ts`'s `decodeOtlpProtobuf`) can be tested against
+// equivalent inputs. Uses `@bufbuild/protobuf/wire`'s `BinaryWriter` only.
+// Every `repeated` field must be written as one tag+length-prefix per
+// element (protobuf's actual wire rule) — wrapping a whole loop's worth of
+// elements in one shared frame round-trips as garbage.
+//
+// Regenerate: `node --experimental-strip-types
+// pipeline/fixtures/otlp/build-protobuf-fixtures.mjs`, run with a `cwd`
+// inside `workers/o11y` (or `NODE_PATH` pointing at its `node_modules`) so
+// `@bufbuild/protobuf` resolves. Output committed — these are fixtures,
+// not build artifacts.
+
+import { writeFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { BinaryWriter, WireType } from "@bufbuild/protobuf/wire";
+
+/** Writes an `AnyValue` message's contents (just `string_value = 1`, the
+ * only kind these fixtures need). */
+function anyValueString(w, value) {
+ w.tag(1, WireType.LengthDelimited).string(value);
+}
+
+/** Writes a `KeyValue` message's contents: `key = 1`, `value = 2` (AnyValue). */
+function keyValueContents(w, key, value) {
+ w.tag(1, WireType.LengthDelimited).string(key);
+ w.tag(2, WireType.LengthDelimited).fork();
+ anyValueString(w, value);
+ w.join();
+}
+
+/** Writes one `repeated KeyValue` element at `fieldNo` — each element of a
+ * repeated message field gets its **own** tag + length prefix; this is the
+ * bug the file header describes fixing. */
+function writeKeyValue(w, fieldNo, key, value) {
+ w.tag(fieldNo, WireType.LengthDelimited).fork();
+ keyValueContents(w, key, value);
+ w.join();
+}
+
+function writeLogRecord(w, fieldNo, { timeUnixNano, observedTimeUnixNano, body, attrs }) {
+ w.tag(fieldNo, WireType.LengthDelimited).fork();
+ if (timeUnixNano !== undefined) w.tag(1, WireType.Bit64).fixed64(BigInt(timeUnixNano));
+ if (observedTimeUnixNano !== undefined) w.tag(11, WireType.Bit64).fixed64(BigInt(observedTimeUnixNano));
+ w.tag(5, WireType.LengthDelimited).fork(); // body = 5 (AnyValue)
+ anyValueString(w, body);
+ w.join();
+ for (const [k, v] of attrs) writeKeyValue(w, 6, k, v); // attributes = 6
+ w.join();
+}
+
+function writeScopeLogs(w, fieldNo, records) {
+ w.tag(fieldNo, WireType.LengthDelimited).fork();
+ for (const record of records) writeLogRecord(w, 2, record); // log_records = 2
+ w.join();
+}
+
+function writeResource(w, fieldNo, attrs) {
+ w.tag(fieldNo, WireType.LengthDelimited).fork();
+ for (const [k, v] of attrs) writeKeyValue(w, 1, k, v); // attributes = 1
+ w.join();
+}
+
+function writeResourceLogs(w, fieldNo, { resourceAttrs, records }) {
+ w.tag(fieldNo, WireType.LengthDelimited).fork();
+ writeResource(w, 1, resourceAttrs); // resource = 1
+ writeScopeLogs(w, 2, records); // scope_logs = 2
+ w.join();
+}
+
+function build(resourceAttrs, records) {
+ const w = new BinaryWriter();
+ writeResourceLogs(w, 1, { resourceAttrs, records }); // ExportLogsServiceRequest.resource_logs = 1
+ return w.finish();
+}
+
+const basic = build(
+ [
+ ["service.name", "demos-api"],
+ ["service.version", "cafef00d"],
+ ["deployment.environment.name", "production"],
+ ],
+ [
+ {
+ timeUnixNano: "1735689600000000000",
+ body: "api.request route=api/demos status=200 (protobuf)",
+ attrs: [
+ ["hot.surface", "api"],
+ ["hot.outcome", "2xx"],
+ ["cf.ray", "8a1b2c3d4e5f6789"],
+ ],
+ },
+ ],
+);
+
+const zeroTimestamp = build(
+ [
+ ["service.name", "demos-api"],
+ ["service.version", "cafef00d"],
+ ["deployment.environment.name", "production"],
+ ],
+ [
+ {
+ timeUnixNano: "0",
+ observedTimeUnixNano: "0",
+ body: "a protobuf record with no real timestamp, exit criterion 3",
+ attrs: [["hot.surface", "api"]],
+ },
+ ],
+);
+
+const dir = fileURLToPath(new URL(".", import.meta.url));
+writeFileSync(`${dir}protobuf/basic.bin`, basic);
+writeFileSync(`${dir}protobuf/zero-timestamp.bin`, zeroTimestamp);
+console.log(`wrote ${basic.byteLength} bytes to protobuf/basic.bin`);
+console.log(`wrote ${zeroTimestamp.byteLength} bytes to protobuf/zero-timestamp.bin`);
diff --git a/runner/pipeline/fixtures/otlp/deploy-event.json b/runner/pipeline/fixtures/otlp/deploy-event.json
new file mode 100644
index 0000000000..23c0d626a1
--- /dev/null
+++ b/runner/pipeline/fixtures/otlp/deploy-event.json
@@ -0,0 +1,5 @@
+{
+ "service": "handsontable-demos-api",
+ "sha": "abc123def456abc123def456abc123def456abc1",
+ "cf_version_id": "01234567-89ab-cdef-0123-456789abcdef"
+}
diff --git a/runner/pipeline/fixtures/otlp/json/basic.json b/runner/pipeline/fixtures/otlp/json/basic.json
new file mode 100644
index 0000000000..daf8884e85
--- /dev/null
+++ b/runner/pipeline/fixtures/otlp/json/basic.json
@@ -0,0 +1,29 @@
+{
+ "resourceLogs": [
+ {
+ "resource": {
+ "attributes": [
+ { "key": "service.name", "value": { "stringValue": "demos-api" } },
+ { "key": "service.version", "value": { "stringValue": "cafef00d" } },
+ { "key": "deployment.environment.name", "value": { "stringValue": "production" } }
+ ]
+ },
+ "scopeLogs": [
+ {
+ "logRecords": [
+ {
+ "timeUnixNano": "1735689600000000000",
+ "severityText": "INFO",
+ "body": { "stringValue": "api.request route=api/demos status=200" },
+ "attributes": [
+ { "key": "hot.surface", "value": { "stringValue": "api" } },
+ { "key": "hot.outcome", "value": { "stringValue": "2xx" } },
+ { "key": "cf.ray", "value": { "stringValue": "8a1b2c3d4e5f6789" } }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}
diff --git a/runner/pipeline/fixtures/otlp/json/cloudflare-invocation-log.json b/runner/pipeline/fixtures/otlp/json/cloudflare-invocation-log.json
new file mode 100644
index 0000000000..e4cd5c310f
--- /dev/null
+++ b/runner/pipeline/fixtures/otlp/json/cloudflare-invocation-log.json
@@ -0,0 +1,257 @@
+{
+ "resourceLogs": [
+ {
+ "resource": {
+ "attributes": [
+ {
+ "key": "cloudflare.colo",
+ "value": {
+ "stringValue": "FRA"
+ }
+ },
+ {
+ "key": "faas.invoked_region",
+ "value": {
+ "stringValue": "EEUR"
+ }
+ },
+ {
+ "key": "cloudflare.script_name",
+ "value": {
+ "stringValue": "handsontable-demos-api"
+ }
+ },
+ {
+ "key": "cloud.provider",
+ "value": {
+ "stringValue": "cloudflare"
+ }
+ },
+ {
+ "key": "cloud.platform",
+ "value": {
+ "stringValue": "cloudflare.workers"
+ }
+ },
+ {
+ "key": "faas.name",
+ "value": {
+ "stringValue": "handsontable-demos-api"
+ }
+ },
+ {
+ "key": "faas.version",
+ "value": {
+ "stringValue": "00000000-0000-0000-0000-000000000000"
+ }
+ },
+ {
+ "key": "telemetry.sdk.language",
+ "value": {
+ "stringValue": "js"
+ }
+ },
+ {
+ "key": "telemetry.sdk.name",
+ "value": {
+ "stringValue": "workers-observability"
+ }
+ },
+ {
+ "key": "service.name",
+ "value": {
+ "stringValue": "handsontable-demos-api"
+ }
+ },
+ {
+ "key": "cloudflare.script_version.id",
+ "value": {
+ "stringValue": "00000000-0000-0000-0000-000000000000"
+ }
+ }
+ ],
+ "droppedAttributesCount": 0
+ },
+ "scopeLogs": [
+ {
+ "scope": {
+ "name": "workers-observability"
+ },
+ "logRecords": [
+ {
+ "timeUnixNano": "1790166300617000000",
+ "observedTimeUnixNano": "1790166300622000000",
+ "severityNumber": 9,
+ "body": {
+ "stringValue": "GET https://demos.handsontable.com/api/demos/r-react-18-0-0"
+ },
+ "attributes": [
+ {
+ "key": "cloudflare.execution_model",
+ "value": {
+ "stringValue": "stateless"
+ }
+ },
+ {
+ "key": "cloudflare.handler_type",
+ "value": {
+ "stringValue": "fetch"
+ }
+ },
+ {
+ "key": "faas.invocation_id",
+ "value": {
+ "stringValue": "00000000000000000000000000000000"
+ }
+ },
+ {
+ "key": "cloudflare.ray_id",
+ "value": {
+ "stringValue": "0000000000000000"
+ }
+ },
+ {
+ "key": "faas.trigger",
+ "value": {
+ "stringValue": "http"
+ }
+ },
+ {
+ "key": "url.full",
+ "value": {
+ "stringValue": "https://demos.handsontable.com/api/demos/r-react-18-0-0"
+ }
+ },
+ {
+ "key": "http.request.method",
+ "value": {
+ "stringValue": "GET"
+ }
+ },
+ {
+ "key": "http.request.header.accept",
+ "value": {
+ "stringValue": "*/*"
+ }
+ },
+ {
+ "key": "http.request.header.accept-encoding",
+ "value": {
+ "stringValue": "gzip, br"
+ }
+ },
+ {
+ "key": "user_agent.original",
+ "value": {
+ "stringValue": "Mozilla/5.0 (compatible; example)"
+ }
+ },
+ {
+ "key": "cloudflare.colo",
+ "value": {
+ "stringValue": "XXX"
+ }
+ },
+ {
+ "key": "cloudflare.verified_bot_category",
+ "value": {
+ "stringValue": ""
+ }
+ },
+ {
+ "key": "cloudflare.asn",
+ "value": {
+ "intValue": 0
+ }
+ },
+ {
+ "key": "geo.timezone",
+ "value": {
+ "stringValue": "Etc/UTC"
+ }
+ },
+ {
+ "key": "geo.continent.code",
+ "value": {
+ "stringValue": "EU"
+ }
+ },
+ {
+ "key": "geo.country.code",
+ "value": {
+ "stringValue": "XX"
+ }
+ },
+ {
+ "key": "geo.locality.name",
+ "value": {
+ "stringValue": ""
+ }
+ },
+ {
+ "key": "geo.locality.region",
+ "value": {
+ "stringValue": ""
+ }
+ },
+ {
+ "key": "server.port",
+ "value": {
+ "stringValue": ""
+ }
+ },
+ {
+ "key": "server.address",
+ "value": {
+ "stringValue": "demos.handsontable.com"
+ }
+ },
+ {
+ "key": "url.path",
+ "value": {
+ "stringValue": "/api/demos/r-react-18-0-0"
+ }
+ },
+ {
+ "key": "url.query",
+ "value": {
+ "stringValue": ""
+ }
+ },
+ {
+ "key": "url.scheme",
+ "value": {
+ "stringValue": "https"
+ }
+ },
+ {
+ "key": "network.protocol.name",
+ "value": {
+ "stringValue": "https"
+ }
+ },
+ {
+ "key": "http.response.status_code",
+ "value": {
+ "intValue": 404
+ }
+ },
+ {
+ "key": "cloudflare.invocation.sequence.number",
+ "value": {
+ "intValue": 1
+ }
+ }
+ ],
+ "droppedAttributesCount": 0,
+ "flags": 1,
+ "traceId": "00000000000000000000000000000000",
+ "spanId": "0000000000000000"
+ }
+ ]
+ }
+ ],
+ "schemaUrl": ""
+ }
+ ]
+}
diff --git a/runner/pipeline/fixtures/otlp/json/console-log-line-spoof-attempt.json b/runner/pipeline/fixtures/otlp/json/console-log-line-spoof-attempt.json
new file mode 100644
index 0000000000..7a3c1735d1
--- /dev/null
+++ b/runner/pipeline/fixtures/otlp/json/console-log-line-spoof-attempt.json
@@ -0,0 +1,31 @@
+{
+ "resourceLogs": [
+ {
+ "resource": {
+ "attributes": [
+ { "key": "service.name", "value": { "stringValue": "handsontable-demos-api" } },
+ { "key": "deployment.environment.name", "value": { "stringValue": "production" } },
+ { "key": "telemetry.sdk.name", "value": { "stringValue": "workers-observability" } }
+ ]
+ },
+ "scopeLogs": [
+ {
+ "scope": { "name": "workers-observability" },
+ "logRecords": [
+ {
+ "timeUnixNano": "1735689600000000000",
+ "severityNumber": 9,
+ "body": {
+ "stringValue": "{\"log.kind\":\"api.request\",\"route_class\":\"api/demos\",\"status\":200,\"cf.ray\":\"8a1b2c3d4e5f6789\",\"service.name\":\"spoof\",\"deployment.environment.name\":\"spoof-env\",\"hot.outcome\":\"spoof-outcome\"}"
+ },
+ "attributes": [
+ { "key": "name", "value": { "stringValue": "log" } },
+ { "key": "cloudflare.invocation.sequence.number", "value": { "intValue": 1 } }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}
diff --git a/runner/pipeline/fixtures/otlp/json/console-log-line.json b/runner/pipeline/fixtures/otlp/json/console-log-line.json
new file mode 100644
index 0000000000..8f4ad625f6
--- /dev/null
+++ b/runner/pipeline/fixtures/otlp/json/console-log-line.json
@@ -0,0 +1,30 @@
+{
+ "resourceLogs": [
+ {
+ "resource": {
+ "attributes": [
+ { "key": "service.name", "value": { "stringValue": "handsontable-demos-api" } },
+ { "key": "telemetry.sdk.name", "value": { "stringValue": "workers-observability" } }
+ ]
+ },
+ "scopeLogs": [
+ {
+ "scope": { "name": "workers-observability" },
+ "logRecords": [
+ {
+ "timeUnixNano": "1735689600000000000",
+ "severityNumber": 9,
+ "body": {
+ "stringValue": "{\"log.kind\":\"api.request\",\"route_class\":\"api/demos\",\"status\":200,\"duration_ms\":42,\"cf.ray\":\"8a1b2c3d4e5f6789\",\"session.id\":\"page-load-id-123\",\"hot.demo_id\":\"r-react-18-0-0\",\"service.version\":\"cafef00d\"}"
+ },
+ "attributes": [
+ { "key": "name", "value": { "stringValue": "log" } },
+ { "key": "cloudflare.invocation.sequence.number", "value": { "intValue": 1 } }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}
diff --git a/runner/pipeline/fixtures/otlp/json/forbidden-attrs.json b/runner/pipeline/fixtures/otlp/json/forbidden-attrs.json
new file mode 100644
index 0000000000..599fb4581e
--- /dev/null
+++ b/runner/pipeline/fixtures/otlp/json/forbidden-attrs.json
@@ -0,0 +1,35 @@
+{
+ "resourceLogs": [
+ {
+ "resource": {
+ "attributes": [
+ { "key": "service.name", "value": { "stringValue": "demos-api" } },
+ { "key": "service.version", "value": { "stringValue": "cafef00d" } },
+ { "key": "deployment.environment.name", "value": { "stringValue": "production" } },
+ { "key": "url.full", "value": { "stringValue": "https://demos.handsontable.com/d/abc?token=secret123" } },
+ { "key": "user_agent.original", "value": { "stringValue": "Mozilla/5.0 (Windows NT 10.0) Chrome/119.0" } },
+ { "key": "geo.city", "value": { "stringValue": "Warsaw" } },
+ { "key": "asn.number", "value": { "stringValue": "12345" } }
+ ]
+ },
+ "scopeLogs": [
+ {
+ "logRecords": [
+ {
+ "timeUnixNano": "1735689600000000000",
+ "body": {
+ "stringValue": "stale preview request from https://8787-abc123-tok3n.demos.handsontable.com/src/main.js?t=1700000000 user-agent Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"
+ },
+ "attributes": [
+ { "key": "url.full", "value": { "stringValue": "https://demos.handsontable.com/d/abc?token=secret123" } },
+ { "key": "http.user_agent", "value": { "stringValue": "Mozilla/5.0 (Windows NT 10.0) Chrome/119.0" } },
+ { "key": "geo.country", "value": { "stringValue": "PL" } },
+ { "key": "hot.surface", "value": { "stringValue": "api" } }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}
diff --git a/runner/pipeline/fixtures/otlp/json/zero-timestamp.json b/runner/pipeline/fixtures/otlp/json/zero-timestamp.json
new file mode 100644
index 0000000000..3bc99ffe66
--- /dev/null
+++ b/runner/pipeline/fixtures/otlp/json/zero-timestamp.json
@@ -0,0 +1,25 @@
+{
+ "resourceLogs": [
+ {
+ "resource": {
+ "attributes": [
+ { "key": "service.name", "value": { "stringValue": "demos-api" } },
+ { "key": "service.version", "value": { "stringValue": "cafef00d" } },
+ { "key": "deployment.environment.name", "value": { "stringValue": "production" } }
+ ]
+ },
+ "scopeLogs": [
+ {
+ "logRecords": [
+ {
+ "timeUnixNano": "0",
+ "observedTimeUnixNano": "0",
+ "body": { "stringValue": "a record with no real timestamp, exit criterion 3" },
+ "attributes": [{ "key": "hot.surface", "value": { "stringValue": "api" } }]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}
diff --git a/runner/pipeline/fixtures/otlp/protobuf/basic.bin b/runner/pipeline/fixtures/otlp/protobuf/basic.bin
new file mode 100644
index 0000000000..cdda63ab84
Binary files /dev/null and b/runner/pipeline/fixtures/otlp/protobuf/basic.bin differ
diff --git a/runner/pipeline/fixtures/otlp/protobuf/zero-timestamp.bin b/runner/pipeline/fixtures/otlp/protobuf/zero-timestamp.bin
new file mode 100644
index 0000000000..6b0fe72d6c
Binary files /dev/null and b/runner/pipeline/fixtures/otlp/protobuf/zero-timestamp.bin differ
diff --git a/runner/pipeline/fixtures/otlp/sentry-issue.json b/runner/pipeline/fixtures/otlp/sentry-issue.json
new file mode 100644
index 0000000000..1d6ff50196
--- /dev/null
+++ b/runner/pipeline/fixtures/otlp/sentry-issue.json
@@ -0,0 +1,15 @@
+{
+ "action": "created",
+ "installation": { "uuid": "11111111-2222-3333-4444-555555555555" },
+ "data": {
+ "issue": {
+ "id": "987654321",
+ "shortId": "DEMOS-42",
+ "title": "TypeError: cannot read properties of undefined",
+ "level": "error",
+ "permalink": "https://handsoncode.sentry.io/issues/987654321/?query=is%3Aunresolved",
+ "lastRelease": { "version": "abc123def456abc123def456abc123def456abc1" }
+ }
+ },
+ "actor": { "type": "application", "id": "sentry" }
+}
diff --git a/runner/pipeline/fixtures/stub-bin/curl b/runner/pipeline/fixtures/stub-bin/curl
new file mode 100755
index 0000000000..aa90004c60
--- /dev/null
+++ b/runner/pipeline/fixtures/stub-bin/curl
@@ -0,0 +1,72 @@
+#!/bin/bash
+# A minimal `curl` stand-in for pipeline/o11y-shutdown-snapshot.test.mjs.
+# Ignores its real arguments entirely (the test only needs to control the
+# RESPONSE `r2_list_prefix`/`snapshot_index_keys` see, not exercise real
+# S3-sigv4 request construction — that is exercised for real by
+# `containers/o11y/local/stop-roundtrip.mjs`). Behaviour is driven by two
+# env vars the test sets:
+# STUB_CURL_MODES comma-separated modes, one per successive invocation
+# (clamped to the last entry once exhausted):
+# fail -> exit 22, no output (curl -f on a
+# network/5xx failure)
+# empty -> a valid, empty
+# haskey -> a valid listing with one
+# pre-existing uploader-named
+# malformed -> HTTP 200 but not real S3 XML (a proxy
+# error page)
+# code200 -> bare `200` on stdout, nothing else — F2
+# fix (B-I2, second wave): `r2_put_and_verify`
+# (lib.sh) calls curl with
+# `-o /dev/null -w '%{http_code}'` for its
+# PUT and its HEAD confirmation, so its
+# whole stdout IS the status-code text,
+# never S3 list XML — a REAL
+# `run_stop_protocol()` marker write needs
+# this mode for both of those calls, after
+# whatever `empty`/`haskey` listing modes
+# precede them in the same MODES list.
+# STUB_CURL_COUNTER_FILE a file this script increments (one line per
+# call) so the test can also assert call counts.
+set -u
+counter_file="${STUB_CURL_COUNTER_FILE:?STUB_CURL_COUNTER_FILE not set}"
+call_index="$(wc -l < "$counter_file" 2>/dev/null || echo 0)"
+printf 'x\n' >> "$counter_file"
+
+modes="${STUB_CURL_MODES:-empty}"
+IFS=',' read -r -a mode_array <<< "$modes"
+mode_count=${#mode_array[@]}
+if [ "$call_index" -ge "$mode_count" ]; then
+ mode="${mode_array[$((mode_count - 1))]}"
+else
+ mode="${mode_array[$call_index]}"
+fi
+
+case "$mode" in
+ fail)
+ exit 22
+ ;;
+ empty)
+ printf 'lokifalse'
+ exit 0
+ ;;
+ haskey)
+ printf 'falseindex/index/19999/1700000000-uploaderA-abc.tsdb.gz'
+ exit 0
+ ;;
+ truncated)
+ printf 'trueindex/index/19999/1700000000-uploaderA-abc.tsdb.gz'
+ exit 0
+ ;;
+ malformed)
+ printf '502 Bad Gateway'
+ exit 0
+ ;;
+ code200)
+ printf '200'
+ exit 0
+ ;;
+ *)
+ echo "stub curl: unknown STUB_CURL_MODES entry '$mode'" >&2
+ exit 99
+ ;;
+esac
diff --git a/runner/pipeline/fixtures/stub-bin/docker b/runner/pipeline/fixtures/stub-bin/docker
new file mode 100755
index 0000000000..0a76fe41bf
--- /dev/null
+++ b/runner/pipeline/fixtures/stub-bin/docker
@@ -0,0 +1,35 @@
+#!/bin/bash
+# A minimal `docker` stand-in for pipeline/dev-script.test.mjs's CLI-level
+# tests. Understands `docker info` (dev-lib.mjs#isDockerAvailable),
+# `docker image inspect [` and `docker pull ][` (dev-lib.mjs's
+# container base-image pre-pull gate — isImagePresent/pullImageWithRetry) —
+# anything else exits 1 unconditionally, since no test here should ever get
+# that far (each CLI test is designed to have observed everything it needs
+# before reaching an unstubbed subcommand).
+set -u
+if [ "${1:-}" = "info" ]; then
+ if [ "${STUB_DOCKER_MODE:-ok}" = "fail" ]; then
+ echo "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?" >&2
+ exit 1
+ fi
+ echo "stub docker info: ok"
+ exit 0
+fi
+if [ "${1:-}" = "image" ] && [ "${2:-}" = "inspect" ]; then
+ if [ "${STUB_DOCKER_IMAGE_PRESENT:-1}" = "1" ]; then
+ echo "stub docker image inspect: present (${3:-})"
+ exit 0
+ fi
+ echo "Error: No such image: ${3:-}" >&2
+ exit 1
+fi
+if [ "${1:-}" = "pull" ]; then
+ if [ "${STUB_DOCKER_PULL_MODE:-ok}" = "fail" ]; then
+ echo "Error response from daemon: Get \"https://registry-1.docker.io/v2/\": net/http: TLS handshake timeout" >&2
+ exit 1
+ fi
+ echo "stub docker pull: ok for ${2:-}"
+ exit 0
+fi
+echo "stub docker: unsupported subcommand $*" >&2
+exit 1
diff --git a/runner/pipeline/fixtures/worker-harness.mjs b/runner/pipeline/fixtures/worker-harness.mjs
index 3271604800..2b77733c4d 100644
--- a/runner/pipeline/fixtures/worker-harness.mjs
+++ b/runner/pipeline/fixtures/worker-harness.mjs
@@ -194,7 +194,10 @@ export function fakeR2(seed = {}) {
async get(key) {
const value = store.get(key);
if (value === undefined) return null;
- return { body: value, async text() { return value; } };
+ // `size` mirrors a real R2Object's byte length — `share.ts#serveDemoAsset`
+ // reads it for the `serve.d`/`serve.embed` AE point's `bytes` column on a
+ // non-HTML asset, where the body is streamed rather than re-encoded.
+ return { body: value, size: Buffer.byteLength(String(value), "utf8"), async text() { return value; } };
},
async delete(key) {
store.delete(key);
diff --git a/runner/pipeline/fixtures/worker-hooks.mjs b/runner/pipeline/fixtures/worker-hooks.mjs
index 43e10f3868..e6cd828ba7 100644
--- a/runner/pipeline/fixtures/worker-hooks.mjs
+++ b/runner/pipeline/fixtures/worker-hooks.mjs
@@ -23,9 +23,15 @@
// untestable. Additive only: every symbol used in workers/api/src passes
// through or no-ops, so specs that assert nothing about Sentry are
// unaffected.
+//
+// - `cloudflare:workers`: `index.ts` re-exports `O11yUsage`
+// (`o11y-usage.ts`), a `WorkerEntrypoint` — reuses the same structural
+// stub `o11y-worker-hooks.mjs` uses for `workers/o11y/src`, rather than
+// a second copy.
const SANDBOX_STUB = new URL("./cloudflare-sandbox-stub.mjs", import.meta.url).href;
const SENTRY_STUB = new URL("./sentry-cloudflare-stub.mjs", import.meta.url).href;
+const CLOUDFLARE_WORKERS_STUB = new URL("./o11y-cloudflare-workers-stub.mjs", import.meta.url).href;
export async function resolve(specifier, context, nextResolve) {
if (specifier === "@cloudflare/sandbox") {
@@ -34,6 +40,9 @@ export async function resolve(specifier, context, nextResolve) {
if (specifier === "@sentry/cloudflare") {
return { url: SENTRY_STUB, shortCircuit: true };
}
+ if (specifier === "cloudflare:workers") {
+ return { url: CLOUDFLARE_WORKERS_STUB, shortCircuit: true };
+ }
if (
specifier.startsWith(".")
&& specifier.endsWith(".js")
diff --git a/runner/pipeline/lite-beacon.test.mjs b/runner/pipeline/lite-beacon.test.mjs
new file mode 100644
index 0000000000..61577752ab
--- /dev/null
+++ b/runner/pipeline/lite-beacon.test.mjs
@@ -0,0 +1,901 @@
+// The lite beacon: the standalone ES5 reporter (`monitor.ts`'s
+// `injectLiteReporterIntoHtml`, contract §9, ADR §C.5) and the o11y worker's
+// `POST /telemetry/lite` route (`workers/o11y/src/lite.ts`).
+//
+// The reporter half is executed, not read (the DEV-2129 lesson every other
+// ES5-reporter test in this repo follows) — a transpiler/output test that
+// only inspects the string would pass over a script that cannot actually run.
+//
+// Build prerequisite: `pnpm --filter @handsontable/demo-runtime build`.
+// Run: node --experimental-strip-types --test pipeline/*.test.mjs
+
+import test from "node:test";
+import assert from "node:assert/strict";
+import { register } from "node:module";
+import { Parser } from "acorn";
+import {
+ LITE_CLIENT_MESSAGE_MAX,
+ LITE_CLIENT_STACK_MAX,
+ LITE_ENDPOINT,
+ LITE_REPORTER_MARKER,
+ LITE_REPORTER_MAX_BYTES,
+ LITE_VITALS_SAMPLE_RATE,
+ MONITOR_EVENT_CEILING,
+ injectLiteReporterIntoHtml,
+} from "../packages/runtime/dist/monitor.js";
+import { isValidLitePayload, LITE_PAYLOAD_MAX_BYTES } from "../packages/runtime/dist/telemetry/index.js";
+
+register("./fixtures/o11y-worker-hooks.mjs", import.meta.url);
+
+const { default: worker } = await import("../workers/o11y/src/index.ts");
+const { InboxWriter } = await import("../workers/o11y/src/inbox/writer.ts");
+const { hashRecord } = await import("../workers/o11y/src/normalise/hash.ts");
+const { makeEnv, ctx } = await import("./fixtures/o11y-harness.mjs");
+// Dynamic, not a static top-level import: `@handsontable/demo-runtime` only
+// resolves through `o11y-worker-hooks.mjs`'s own `resolve()` hook (registered
+// above via `register()`), and static imports are hoisted ahead of that
+// call — the same reason every other borrowed-specifier import in this repo's
+// `pipeline/*.test.mjs` files is `await import(...)` placed after `register()`,
+// never a plain `import … from`.
+const { AE_COLUMNS } = await import("@handsontable/demo-runtime/telemetry");
+
+/** Reads a numeric metric field (`count`, etc.) out of a fake AE point via
+ * the real contract slot (`AE_COLUMNS.count`, e.g. `"double1"`) — the same
+ * helper `o11y-routes.test.mjs` uses, never a hardcoded array index. */
+function metricValue(point, name) {
+ const m = /^double(\d+)$/.exec(AE_COLUMNS[name]);
+ return point.doubles[Number(m[1]) - 1];
+}
+
+// ---- the reporter, built for a representative config --------------------------
+
+const CONFIG = { surface: "d", demo: "r-react-18-0-0", ht: "18", fw: "react" };
+
+function reporterScriptSource(config = CONFIG) {
+ const html = injectLiteReporterIntoHtml("", config);
+ const match = /]