mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 01:56:04 +00:00
* feat(engine): render audio FX in an OfflineAudioContext Reads `data-fx-chain` off an audio element and runs the chain over the trimmed WAV before volume automation is baked in — effects should see the raw signal, and the envelope belongs on their output. The processing happens in an OfflineAudioContext inside the headless browser the engine already drives, running the same graph builders the studio previews with. That is the point of the approach: one implementation per effect, so the render agreeing with the preview is a property of the architecture rather than a tolerance to police. Reimplementing each effect as an FFmpeg filter would mean two implementations to keep in step, and for the dynamics processors and modulated delays there is no filter that behaves the same way. `build:audio-fx-runtime` bundles the graph builders into an injectable IIFE, following the same pattern as the existing runtime artifacts, so the browser runs exactly the code the studio does. The page loads from a file:// URL rather than about:blank because AudioWorklet is only exposed in a secure context — the compressor, limiter, gate and bitcrush processors would otherwise fail to register with an opaque error. file:// qualifies and needs no listening socket. The chain is serialised into the attribute the way colour grading carries its config, so there is no side-car file to resolve or lose. An FX failure is fatal for the whole mix rather than a per-track soft failure. Every other audio failure mode degrades gracefully — the track drops, siblings continue — but substituting the dry signal for a processed one ships a render that sounds plausible and is not what the author set up. Since the per-element work races under Promise.all, an internal AbortController chained off the caller's signal aborts in-flight siblings before workDir is removed. * feat(core): voiceover carve analysis Finds the bands a voice occupies so a music bed can be dipped there, letting the voice sit in front without ducking the whole track. Carve is a relationship between two tracks rather than an effect on one, so it stays out of the FX chain. What it emits is an ordinary chain of peaking filters, so a carve composes with whatever else is on the track and needs no separate rendering path. Selection is weighted toward intelligibility rather than raw voice energy. Ranking purely by power lands on the fundamental almost every time, because that is where a voice is loudest — but the masking that actually hurts a voiceover happens higher up, and dipping 160 Hz mostly just thins the bed. The bias is a control, not a constant: at 0 it follows raw energy, at 1 it weights toward 1-3 kHz. Ranking happens in dB, which matters more than it looks. Speech spreads 20-30 dB across these bands — it falls off roughly 6 dB per octave above the fundamental — so a weighting has to be on that scale to move anything at all. A multiplicative weight of `1 - bias + bias * shaped` is bounded below by `1 - bias`, capping its influence at 10*log10(1/(1 - bias)): 5.2 dB at the 0.7 default, 3 dB at 0.5. That is no influence against a real voice — every bias short of ~0.95 would rank exactly like bias 0 and carve the fundamental, the outcome the bias exists to prevent, while looking decisive against a fixture whose bands sit 2 dB apart. So the bias is a dB penalty, zero at 2 kHz and worth up to 30 dB at full strength, and relative cut depths come from a dB difference rather than a ratio of weighted linear powers. The bias reweights ranking without overriding the spectrum — a band the voice has no energy in is not worth carving, and scores -Infinity rather than competing — so a strongly low-pitched voice can still select low at full bias. What the tests hold is that biasing never selects lower than the unbiased ranking, that the DEFAULT bias reaches the presence region on a voice with a realistic tilt, and that bias 0 still follows raw power exactly. Includes a radix-2 FFT rather than a dependency; one Welch-style averaged spectrum over third-octave bands does not justify pulling in a DSP library. * fix(engine): keep the FX render 16-bit, stereo, and correctly sized Three defects in the offline FX path, none of which any test could see. **Float output silently disabled sample-accurate volume automation.** The writer emitted 32-bit IEEE float; the very next mixer step bakes the volume envelope into the samples and accepts only 16-bit PCM, returning null otherwise. So enabling any effect downgraded that track to the ffmpeg expression path — capped at 32 straight segments, quantising a curved envelope, and on a dense one falling back to base volume. It now writes 16-bit PCM, clamped rather than wrapped so a limiter at 0 dB or a resonant filter cannot turn overshoot into a click. A test asserts the baker accepts the writer's own output and actually fades it. **Everything was folded to mono.** `prepareAudioTrack` goes out of its way to emit stereo — its pan filter exists to dodge ffmpeg's 3 dB mono-to-stereo rematrix — and this folded it, then wrote one channel. So adding a single peaking EQ collapsed a bed's width and cost ~3 dB in the render, while preview stayed stereo. Channels now travel as one plane each, through an OfflineAudioContext of the same width, and come back interleaved. **Small results decoded the wrong length.** `new Float32Array(buf.buffer)` discards byteOffset and byteLength, and Node pools small allocations: a 400-byte payload sits at offset 8 inside an 8 KiB pool, so a clip under ~1024 samples decoded as 2048 samples of unrelated memory — and the empty-result guard could not see it. The reader has the mirror-image fix: a float data chunk on an odd boundary (ffmpeg's pcm_f32le writes fmt(18) + fact, landing `data` at 58) now copies instead of throwing RangeError on an unaligned view. The tail limitation is now stated rather than mis-stated: the context is exactly as long as the input, so a reverb or delay still ringing is cut there. The old comment claimed the opposite. How far a tail may run past a clip's end changes the clip's length in the mix, so it is a product decision, not one to make here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(producer): report an FX render failure as an audio error `processCompositionAudio` reports per-track failures in its result, but an FX failure it cannot degrade past — a browser that will not launch, a chain that will not build — rejects instead. `runAudioStage` had no try, so that rejection escaped to the orchestrator as an unclassified pipeline exception, losing the stage/owner/retryable classification this stage exists to attach, and skipping its abort check on the way out. It now lands in `audioError` alongside every other cause, while an abort still keeps its own shape rather than being reported as an audio problem. Not done here: committing the generated `audio-fx-runtime-inline.ts` so a fresh clone typechecks packages/engine without building first. The bundle is built from the stub, and the stub changes three times across this stack — so the artifact differs per branch and would conflict on every restack. Its model, position-edits-render-inline.ts, is committed only because it is stable. Building before testing is this monorepo's existing contract (studio's tests need core's dist too), so the gap is not specific to audio FX and is better closed by a build ordering gate than by committing a per-branch artifact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(engine): skip the browser FX render cases when there is no browser CI's `Test` job was red on this PR with four failures, all the same cause: Failed to launch the browser process: spawn /home/runner/.cache/hyperframes/chrome/chrome-headless-shell The job installs ffmpeg and no browser, deliberately — every other suite that needs an external binary already guards on it (`describe.skipIf(!HAS_FFMPEG)`). These cases were the only ones assuming a Chrome, so they failed on an absent dependency rather than on anything about the code. Guards on `resolveHeadlessShellPath()` — the same resolver `acquireBrowser` launches through, so the check cannot drift from the thing it guards the way a hard-coded cache path would. A configured path that does not exist throws; that is caught and read as "cannot run here". Checked both directions rather than just the green one: with a browser all 11 cases run and pass, and with `HYPERFRAMES_BROWSER_PATH` pointed at a missing binary exactly 3 skip and the other 8 still run. A guard that silently skipped everything would have looked identical in CI. They keep their value where it exists — every developer machine, and any job that has run `hyperframes browser ensure`. Not touched: the CodeQL failure on this PR is a run from 2026-08-07, five days and several force-pushes stale. None of the 17 open repo alerts are in files this PR changes; it re-runs on this push. * chore(engine): suppress the temp-file alert with the reason it is safe CodeQL flags `writeWav`'s `writeFileSync` as js/insecure-temporary-file (high) — the one new alert on #3021, and the reason its CodeQL check is red. It is a false positive, and the comment says why rather than just silencing it: `path` is always inside a directory made by `mkdtempSync`, never a name assembled directly under `tmpdir()`. Both callers are covered — the browser host page writes into `mkdtempSync(join(tmpdir(), "hf-fx-host-"))`, and the render output goes to the producer work dir, itself `mkdtempSync(join(tempRoot, "producer-project-"))`. mkdtemp picks the random suffix and creates the directory 0700 in one syscall, so the predictable filename inside it cannot be pre-created or symlinked by another user, which is the attack the rule is about. The analyzer sees the dataflow reach `tmpdir()` and not the mkdtemp in between. Suppressed inline rather than dismissed in the UI, so the justification lives next to the code and the rule stays live for anything added later in this file. Matches the repo's existing convention — `planV2.ts:222` carries an `lgtm[js/insecure-temporary-file]` for a different reason on the same rule. Correcting myself: I first reported this alert as not real, having intersected the PR's files against the default-branch alert list, which does not contain PR-ref alerts. Querying ?ref=refs/pull/3021/merge returns it straight away. * test(engine): probe ffmpeg and Chrome instead of assuming them Two failures on #3021's Test job, both about the environment rather than the code under test. **Bare `ffmpeg` is not on PATH in CI.** The 16-bit fixture shelled out to `execFileSync("ffmpeg", ...)` and died with ENOENT. The job does provide ffmpeg, through `prepare-ffmpeg-bin`, which is what `getFfmpegBinary()` resolves — every other ffmpeg-dependent suite in this package already goes through it. Now this one does too, and the case is `skipIf(!HAS_FFMPEG)` so a contributor without ffmpeg skips rather than fails. **The browser guard trusted the wrong thing.** It asked `resolveHeadlessShellPath()` and treated a returned path as "a browser is here". CI's cache holds a chrome-headless-shell that resolves and then fails to spawn — a partial download is indistinguishable from a working one by `existsSync`, which is all that resolver checks. So the three browser cases ran anyway and failed on the launch. It now runs `--version` and requires exit 0, which is the same probe the ffmpeg suites use: ask the binary, do not infer from the filesystem. Checked both directions rather than just the green one. With a working browser all 11 cases run and pass; with `HYPERFRAMES_BROWSER_PATH` pointed at a binary that exits non-zero — CI's exact situation — exactly 3 skip and the other 8 still run. A guard that quietly skipped everything would have looked identical on the CI summary. * feat(core): register the audio-fx-rack canary at 0% Lands the rollout switch dark, per the registry's own procedure: "Start at percentage: 0 and merge that — a canary at 0 is dead code you can land safely and ramp without a code review." Declared at the bottom of the stack so every branch above can read it. The gate itself goes in at wa-4-fx-panel, where the rack first appears. Scope is deliberate and stated in the description: it gates the AUTHORING surface only. A composition that already carries `data-fx-chain` still plays and renders it. A canary should stage who can REACH a feature, not make an attribute somebody already wrote silently inert — an agent that writes a chain through the skill would otherwise produce a file whose audio processing vanishes with no error. * feat(studio): audio FX panel generated from the registry Controls for the whole chain: add, remove, reorder, bypass, and every knob each effect declares. Nothing in the panel knows what a compressor is. The registry supplies each parameter's range, step, unit and scale and the panel renders what it finds, so adding an effect or a knob upstream needs no change here, and the panel cannot offer a value the renderer would reject — a typed-in figure is clamped into the declared range on the way through. Frequency and time controls span three or four decades, so those declare a log scale and the slider maps exponentially; a linear slider would spend most of its travel somewhere useless. Reorder is a first-class control because chain order changes the sound: a reverb before a compressor is not the same as after. Carve gets its own block rather than an entry in the add menu, with a picker for the voice track to listen to. It processes this track based on another one, which is how a sidechain control works — it lives on the track that changes, and names the source. * feat(studio): show the Audio FX section on audio tracks Adds `audioFx` to the editing-affordances contract and renders the FX panel in the inspector when an `<audio>` element is selected. The section is audio-only. A `<video>` carries its sound on a separate `<audio>` element, so an FX chain on the video would have nothing to process. Chain and carve settings are written straight back onto the element as serialised attributes, the way colour grading carries its config, so persistence is an ordinary attribute write and needs no new server route. A chain that cannot be parsed renders as empty rather than breaking the panel, and the attribute is left untouched until the user changes something. The collapsed group summarises what is on the track ("2 effects + carve") so the state is visible without expanding it. Wired into PropertyPanelFlat rather than PropertyPanel: STUDIO_FLAT_INSPECTOR_ENABLED defaults to true, so the flat inspector is what actually renders. * refactor(studio): lift audioFxSummary out of PropertyPanelFlat `PropertyPanelFlat.tsx` is 612 lines here against the repo's 600-line cap, so the required File size check is red — the sole reason this PR is blocked. The review says as much: "mechanical fix (~5 min), not a design problem. Code itself is LGTM." Moves `audioFxSummary` to `audioFxSummary.ts`, the same file a later branch creates for it. Deliberately the smallest cut that clears the cap rather than the whole `AudioFxGroup` extraction: every later commit in the stack edits AudioFxGroup, so moving it here would collide with each of them, while almost nothing touches this function. 595 lines. * feat(studio): put the audio FX rack behind its canary Gates the rack on `isCanaryEnabled("audio-fx-rack")`, which is registered at 0% — so the whole 47-PR stack can land without showing anyone a feature that has not been measured yet. The gate sits on the AUTHORING surface and nowhere else. The runtime and the render still honour a `data-fx-chain` already on an element, so a composition written through the skill or by `carve.mjs` keeps its processing rather than going silently dry for anyone outside the cohort. A canary should stage who can REACH a feature, not make an attribute somebody already wrote stop working with no error. Gated at the panel rather than in `resolveEditingSections`: the affordance resolver is a pure function in core describing what an element CAN support, and rollout state is not a property of an `<audio>` tag. Pinned the 0% with a test, and checked it fails at 25 — a ramp should have to break something that says "this ships dark" out loud. One gap, stated rather than papered over: the gate itself has no unit test. I wrote one and deleted it, because `PropertyPanel.test.tsx`'s harness never renders the Audio FX group for its audio fixture even with the gate removed — so the test passed for the wrong reason in the off case and could not pass at all in the on case. A test that cannot fail for the right reason is worse than none. Verifying the gate needs the panel harness to mount that section first, which is its own change. * fix(studio): drop the FX panel's dead __testables export Fallow audit flagged it — no test imports the module. * fix(core,studio): clear the remaining Fallow audit findings on the FX panel - Split FxSection's per-node row into FxNodeRow + FxNodeControls so the CRAP score (31.6, threshold 30) splits across two smaller units instead of moving wholesale with one extraction. - Dedupe the repeated "open the add menu, read its items" block in propertyPanelFxSection.test.tsx into openAddMenuItems(). - Merge build-audio-fx-runtime.ts and build-position-edits-render.ts into one build-inline-artifact.ts, config-selected by CLI arg — the two scripts were a byte-for-byte clone save for names. - Exempt canary.test.ts's rawFnv (a deliberate independent reimplementation used to cross-check canaryBucket, per its own docstring) and the property-panel test files' shared renderInto/mount scaffolding (pre-existing across 9 files, 2 outside this stack) in .fallowrc.jsonc, consistent with this file's existing exemptions for the same class of intentional/pre-existing duplication. * fix(ci): allowlist the build-script consolidation in the no-main-deletions guard build-audio-fx-runtime.ts and build-position-edits-render.ts were merged into build-inline-artifact.ts to kill a fallow duplication finding; the deletion guard flagged that as an accidental loss since main still has both originals. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
455 lines
19 KiB
TypeScript
455 lines
19 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { canaryBucket, evaluateCanary, parseCanaryOverride, type CanaryInput } from "./canary.js";
|
|
import { CANARIES, canaryEnvVar, findCanary, overdueCanaries } from "./canaryRegistry.js";
|
|
import {
|
|
CANARY_FEATURE_PREFIX,
|
|
canaryFeatureKey,
|
|
canaryFeatureProperties,
|
|
canaryReasonKey,
|
|
} from "./canary.js";
|
|
|
|
const base = (over: Partial<CanaryInput> = {}): CanaryInput => ({
|
|
feature: "test-feature",
|
|
unitId: "db0c1f4a-b95e-4c35-90c6-1a15bd76f717",
|
|
percentage: 10,
|
|
...over,
|
|
});
|
|
|
|
/**
|
|
* Recover the raw 32-bit hash from the module under test so the canonical
|
|
* vectors can be asserted without exporting internals: canaryBucket(f, u)
|
|
* hashes `${f}:${u}`, so an empty feature and a unitId of `x` hashes ":x".
|
|
* Instead of fighting that, re-derive here and cross-check that this local
|
|
* copy agrees with canaryBucket on real inputs (asserted below).
|
|
*/
|
|
function rawFnv(input: string): number {
|
|
let hash = 0x811c9dc5;
|
|
for (let i = 0; i < input.length; i++) {
|
|
hash ^= input.charCodeAt(i);
|
|
hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0;
|
|
}
|
|
return hash >>> 0;
|
|
}
|
|
|
|
/**
|
|
* A realistic population: v4-shaped UUIDs, but from a SEEDED PRNG.
|
|
*
|
|
* These ids feed statistical assertions (share within 1pp, chi-square
|
|
* uniformity) whose thresholds are tight enough to fail by chance on a
|
|
* genuinely random draw: measured at ~4 failures per 1500 runs for the share
|
|
* bound (the pct=50 case has a binomial SD of 0.354pp, so 1pp is only 2.8
|
|
* sigma) and 1 per 1000 for chi-square by its own construction. That made the
|
|
* whole @hyperframes/core suite flaky for unrelated PRs. Seeded means the
|
|
* population is fixed, so a failure is a real change in the hash — which is
|
|
* the only thing these tests are for.
|
|
*/
|
|
function uuids(n: number, seed = 0x9e3779b9): string[] {
|
|
let state = seed >>> 0;
|
|
const nextByte = (): number => {
|
|
// xorshift32 — deterministic, and uniform enough to stand in for a real
|
|
// id population. Not used for anything security-relevant.
|
|
state ^= state << 13;
|
|
state >>>= 0;
|
|
state ^= state >>> 17;
|
|
state ^= state << 5;
|
|
state >>>= 0;
|
|
return state & 0xff;
|
|
};
|
|
const hex = (count: number): string =>
|
|
Array.from({ length: count }, () => nextByte().toString(16).padStart(2, "0")).join("");
|
|
return Array.from(
|
|
{ length: n },
|
|
() => `${hex(4)}-${hex(2)}-4${hex(2).slice(1)}-a${hex(2).slice(1)}-${hex(6)}`,
|
|
);
|
|
}
|
|
|
|
describe("fnv1a32 (via canaryBucket)", () => {
|
|
it("matches canonical FNV-1a 32-bit vectors", () => {
|
|
// canaryBucket hashes `feature:unitId`, so feed the vector as the whole
|
|
// string by using an empty feature and reconstructing the separator.
|
|
// Guards against a well-meaning "optimization" silently changing the hash
|
|
// — which would reshuffle every live cohort mid-rollout.
|
|
const vectors: Array<[string, number]> = [
|
|
["", 0x811c9dc5],
|
|
["a", 0xe40c292c],
|
|
["b", 0xe70c2de5],
|
|
["foobar", 0xbf9cf968],
|
|
["hello", 0x4f9f2cab],
|
|
];
|
|
for (const [input, expected] of vectors) {
|
|
expect(rawFnv(input)).toBe(expected);
|
|
}
|
|
});
|
|
|
|
it("the shipped bucket function actually uses that hash", () => {
|
|
// Without this, the vector test above is tautological: it would only
|
|
// prove the TEST's copy of FNV-1a is correct, and canary.ts could drift
|
|
// to a different hash with every assertion still green.
|
|
for (const id of uuids(200)) {
|
|
for (const feature of ["de-parallel-router", "x", ""]) {
|
|
expect(canaryBucket(feature, id)).toBe(rawFnv(`${feature}:${id}`) % 100);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("evaluateCanary", () => {
|
|
it("is deterministic for the same feature + unit", () => {
|
|
const a = evaluateCanary(base());
|
|
const b = evaluateCanary(base());
|
|
expect(a).toEqual(b);
|
|
});
|
|
|
|
it("honours an explicit override in both directions, over any percentage", () => {
|
|
expect(evaluateCanary(base({ percentage: 0, override: true }))).toEqual({
|
|
enabled: true,
|
|
reason: "forced_on",
|
|
});
|
|
expect(evaluateCanary(base({ percentage: 100, override: false }))).toEqual({
|
|
enabled: false,
|
|
reason: "forced_off",
|
|
});
|
|
});
|
|
|
|
it("0% is off for everyone and 100% is on for everyone", () => {
|
|
for (const id of uuids(50)) {
|
|
expect(evaluateCanary(base({ unitId: id, percentage: 0 })).enabled).toBe(false);
|
|
expect(evaluateCanary(base({ unitId: id, percentage: 100 })).enabled).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("fails closed without a unit id — unknown must never mean a PARTIAL cohort", () => {
|
|
for (const id of [undefined, "", " "]) {
|
|
expect(evaluateCanary(base({ unitId: id, percentage: 50 }))).toEqual({
|
|
enabled: false,
|
|
reason: "no_unit_id",
|
|
});
|
|
}
|
|
});
|
|
|
|
it("excludes flagged units (CI) from percentage enrolment but not from an override", () => {
|
|
expect(evaluateCanary(base({ percentage: 50, exclude: true })).reason).toBe("excluded");
|
|
expect(evaluateCanary(base({ percentage: 50, exclude: true, override: true })).enabled).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
// 100 is the one percentage where "we don't know who this is" and "this is
|
|
// CI" stop mattering: the registry's step 4 says to delete the entry and the
|
|
// guard at 100-and-holding, so any population still resolving false here
|
|
// would take the new path for the FIRST time at deletion — unstaged, and
|
|
// invisible on the dashboard that said it was safe.
|
|
it.each([
|
|
["no unit id", { unitId: undefined }],
|
|
["blank unit id", { unitId: " " }],
|
|
["excluded (CI)", { exclude: true }],
|
|
])("at 100%% enrols %s, so deleting the guard changes nothing", (_label, extra) => {
|
|
expect(evaluateCanary(base({ percentage: 100, ...extra }))).toEqual({
|
|
enabled: true,
|
|
reason: "in_cohort",
|
|
});
|
|
});
|
|
|
|
it("an explicit off still wins at 100%", () => {
|
|
expect(evaluateCanary(base({ percentage: 100, override: false })).enabled).toBe(false);
|
|
});
|
|
|
|
it("clamps out-of-range and fractional percentages", () => {
|
|
expect(evaluateCanary(base({ percentage: -5 })).enabled).toBe(false);
|
|
expect(evaluateCanary(base({ percentage: 999 })).enabled).toBe(true);
|
|
// 10.9 truncates to 10 — same cohort as an even 10, no surprise widening.
|
|
const ids = uuids(300);
|
|
const at10 = ids.filter((id) => evaluateCanary(base({ unitId: id, percentage: 10 })).enabled);
|
|
const at109 = ids.filter(
|
|
(id) => evaluateCanary(base({ unitId: id, percentage: 10.9 })).enabled,
|
|
);
|
|
expect(at109).toEqual(at10);
|
|
});
|
|
});
|
|
|
|
describe("cohort properties", () => {
|
|
it("ramping is INCLUSIVE — widening never drops an already-enrolled install", () => {
|
|
// If a ramp reshuffled the cohort, before/after comparisons across the
|
|
// ramp would be meaningless and some users would flap in and out.
|
|
const ids = uuids(500);
|
|
const enrolledAt = (pct: number) =>
|
|
new Set(ids.filter((id) => evaluateCanary(base({ unitId: id, percentage: pct })).enabled));
|
|
const p5 = enrolledAt(5);
|
|
const p25 = enrolledAt(25);
|
|
const p100 = enrolledAt(100);
|
|
for (const id of p5) expect(p25.has(id)).toBe(true);
|
|
for (const id of p25) expect(p100.has(id)).toBe(true);
|
|
expect(p25.size).toBeGreaterThan(p5.size);
|
|
});
|
|
|
|
it("different features select INDEPENDENT slices of the same population", () => {
|
|
// The whole reason the hash includes the feature name: bucketing on the
|
|
// unit id alone would hand every simultaneous experiment to one unlucky
|
|
// cohort, and make two rollouts impossible to read apart.
|
|
const ids = uuids(2000);
|
|
const a = new Set(
|
|
ids.filter((id) => evaluateCanary({ feature: "feat-a", unitId: id, percentage: 10 }).enabled),
|
|
);
|
|
const b = new Set(
|
|
ids.filter((id) => evaluateCanary({ feature: "feat-b", unitId: id, percentage: 10 }).enabled),
|
|
);
|
|
const overlap = [...a].filter((id) => b.has(id)).length;
|
|
// Independent 10% slices overlap ~1% of the population (~20 of 2000).
|
|
// Identical slices would overlap ~200. Assert well below that.
|
|
expect(overlap).toBeLessThan(70);
|
|
expect(a.size).toBeGreaterThan(0);
|
|
expect(b.size).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("N concurrent canaries enrol installs binomially, not in lockstep", () => {
|
|
// The sharpest statement of independence. With 8 canaries at 10% each,
|
|
// independent slices give binomial(8, 0.1): ~43% of installs in none,
|
|
// ~38% in exactly one, and effectively nobody in all eight. If the slices
|
|
// were correlated, ~10% of installs would be in ALL of them — one cohort
|
|
// absorbing every experiment at once.
|
|
const ids = uuids(20000);
|
|
const features = ["a", "b", "c", "d", "e", "f", "g", "h"].map((f) => `feat-${f}`);
|
|
let inNone = 0;
|
|
let inAll = 0;
|
|
for (const id of ids) {
|
|
let n = 0;
|
|
for (const feature of features) {
|
|
if (evaluateCanary({ feature, unitId: id, percentage: 10 }).enabled) n++;
|
|
}
|
|
if (n === 0) inNone++;
|
|
if (n === features.length) inAll++;
|
|
}
|
|
// binomial: P(0) = 0.9^8 = 43.0%
|
|
expect(Math.abs((inNone / ids.length) * 100 - 43.0)).toBeLessThan(2);
|
|
// Correlated slices would put ~10% here; independent puts ~1e-8.
|
|
expect(inAll).toBe(0);
|
|
});
|
|
|
|
it("selects the requested share of a UUID population within 1 percentage point", () => {
|
|
// Measured against 60k synthetic and 101 real fleet ids: worst error was
|
|
// 0.16pp. A 1pp band is therefore a real guard, not a formality — the
|
|
// earlier 0.6x-1.4x band would have passed a badly skewed hash.
|
|
const ids = uuids(20000);
|
|
for (const pct of [1, 5, 10, 25, 50]) {
|
|
const hits = ids.filter(
|
|
(id) => evaluateCanary(base({ unitId: id, percentage: pct })).enabled,
|
|
).length;
|
|
const actual = (hits / ids.length) * 100;
|
|
expect(Math.abs(actual - pct)).toBeLessThan(1);
|
|
}
|
|
});
|
|
|
|
it("distributes uniformly across all 100 buckets (chi-square)", () => {
|
|
// The strongest available guard on the hash: a lumpy hash still yields
|
|
// roughly the right TOTAL share while over-loading some buckets, so the
|
|
// share test alone can't catch it.
|
|
const ids = uuids(30000);
|
|
const counts = new Array(100).fill(0);
|
|
for (const id of ids) counts[canaryBucket("chi-test", id)]++;
|
|
const expected = ids.length / 100;
|
|
const chi2 = counts.reduce((sum, c) => sum + (c - expected) ** 2 / expected, 0);
|
|
// df = 99; chi-square critical value at p=0.001 is 148.2.
|
|
expect(chi2).toBeLessThan(148.2);
|
|
expect(Math.min(...counts)).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe("parseCanaryOverride", () => {
|
|
it("accepts the spellings people actually type", () => {
|
|
for (const v of ["1", "true", "TRUE", "on", "yes", " On "]) {
|
|
expect(parseCanaryOverride(v)).toBe(true);
|
|
}
|
|
for (const v of ["0", "false", "FALSE", "off", "no", " Off "]) {
|
|
expect(parseCanaryOverride(v)).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("treats unset, empty and unrecognised values as 'no override'", () => {
|
|
// An exported-but-empty var must not force a feature on.
|
|
for (const v of [undefined, "", " ", "maybe"]) {
|
|
expect(parseCanaryOverride(v)).toBeUndefined();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("registry", () => {
|
|
// Also load-bearing for the hash, not just for tidiness: fnv1a32 walks
|
|
// charCodeAt, i.e. UTF-16 code units, while reference FNV-1a is byte
|
|
// oriented. The two agree only for ASCII. Names are hashed as
|
|
// `feature:unit`, so a non-ASCII name (an accented owner tag, an emoji, a
|
|
// full-width dash from autocorrect) would silently disagree with every
|
|
// other FNV-1a implementation — including any external tool that recomputes
|
|
// cohorts. This regex is what makes that unreachable; loosening it means
|
|
// fixing the hash first.
|
|
it("has unique, ASCII kebab-case names — the hash depends on this", () => {
|
|
const names = CANARIES.map((c) => c.name);
|
|
expect(new Set(names).size).toBe(names.length);
|
|
for (const n of names) {
|
|
expect(n).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
|
|
// eslint-disable-next-line no-control-regex -- explicit ASCII range check
|
|
expect(n).toMatch(/^[\x00-\x7F]*$/);
|
|
}
|
|
});
|
|
|
|
// The de-parallel-router wiring assertion that lived here was removed with
|
|
// the canary itself (registry entry + render.ts guard, same commit). The
|
|
// per-install circuit breaker it referenced is unchanged and is covered by
|
|
// the CLI's own render tests.
|
|
|
|
it("has in-range percentages and a parseable sunset date", () => {
|
|
for (const c of CANARIES) {
|
|
expect(c.percentage).toBeGreaterThanOrEqual(0);
|
|
expect(c.percentage).toBeLessThanOrEqual(100);
|
|
expect(Number.isNaN(Date.parse(`${c.sunsetAfter}T00:00:00Z`))).toBe(false);
|
|
expect(c.owner.length).toBeGreaterThan(0);
|
|
expect(c.description.length).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
it("derives the override env var from the name", () => {
|
|
expect(canaryEnvVar("de-parallel-router")).toBe("HF_CANARY_DE_PARALLEL_ROUTER");
|
|
expect(findCanary("calibration-10")?.name).toBe("calibration-10");
|
|
expect(findCanary("nope")).toBeUndefined();
|
|
});
|
|
|
|
// Deliberately NOT `overdueCanaries()` with the ambient date. That assertion
|
|
// reads wall-clock time, so it turns the entire @hyperframes/core suite red
|
|
// on a calendar date for every unrelated PR — a broken build nobody caused
|
|
// and whose fix is unrelated to the change under test.
|
|
//
|
|
// Enforcement against the CURRENT date is real, it just is not here: the
|
|
// scheduled `Canary sunset` workflow runs `scripts/check-canary-sunset.ts`
|
|
// weekly and fails on the rollout's owner rather than on a passing author.
|
|
// These two tests cover the pinned-date and boundary logic it depends on.
|
|
it("every canary carries a parseable sunset date in the future at authoring time", () => {
|
|
const authored = new Date("2026-07-31T00:00:00Z");
|
|
for (const c of CANARIES) {
|
|
const sunset = Date.parse(`${c.sunsetAfter}T00:00:00Z`);
|
|
expect(Number.isFinite(sunset), `${c.name} has an unparseable sunsetAfter`).toBe(true);
|
|
expect(sunset, `${c.name} was authored already-expired`).toBeGreaterThan(authored.getTime());
|
|
}
|
|
});
|
|
|
|
it("reports a canary as overdue only AFTER the whole sunset day has passed", () => {
|
|
const [first] = CANARIES;
|
|
if (!first) return;
|
|
const day = first.sunsetAfter;
|
|
expect(overdueCanaries(new Date(`${day}T00:00:00Z`))).not.toContain(first.name);
|
|
expect(overdueCanaries(new Date(`${day}T23:59:59Z`))).not.toContain(first.name);
|
|
const dayAfter = new Date(Date.parse(`${day}T00:00:00Z`) + 86_400_000);
|
|
expect(overdueCanaries(dayAfter)).toContain(first.name);
|
|
});
|
|
});
|
|
|
|
describe("PostHog flag-shaped properties", () => {
|
|
it("namespaces keys so a canary can never alias a real PostHog flag", () => {
|
|
// A real flag namespace already exists in this project, owned by the web
|
|
// app (e.g. `enable-chat-tab`). Without the `canary-` infix a canary named
|
|
// after a real flag would fight it for the same property.
|
|
expect(canaryFeatureKey("de-parallel-router")).toBe("$feature/canary-de-parallel-router");
|
|
expect(CANARY_FEATURE_PREFIX.startsWith("$feature/")).toBe(true);
|
|
});
|
|
|
|
it("emits every canary, not just enrolled ones", () => {
|
|
// Absent vs "false" are different facts: absent = this build predates the
|
|
// canary, "false" = this build has it and this install is control.
|
|
// Collapsing them makes a ramp unreadable.
|
|
const props = canaryFeatureProperties([
|
|
{ name: "a", enabled: true },
|
|
{ name: "b", enabled: false },
|
|
]);
|
|
expect(props).toEqual({
|
|
"$feature/canary-a": "true",
|
|
"$feature/canary-b": "false",
|
|
});
|
|
});
|
|
|
|
it("uses string values, matching how PostHog records boolean flags", () => {
|
|
const props = canaryFeatureProperties([{ name: "a", enabled: true }]);
|
|
expect(typeof props["$feature/canary-a"]).toBe("string");
|
|
});
|
|
|
|
it("is empty when nothing is registered", () => {
|
|
expect(canaryFeatureProperties([])).toEqual({});
|
|
});
|
|
});
|
|
|
|
// The attribution property. Without it, an install reporting both "true" and
|
|
// "false" for a canary whose percentage never moved is indistinguishable from
|
|
// a developer toggling HF_CANARY_*. The first calibration read hit exactly
|
|
// that: 304 installs reported both values and the anomalous ones could not be
|
|
// separated from deliberate overrides.
|
|
describe("canary reason property", () => {
|
|
it("rides alongside the assignment, outside the $feature namespace", () => {
|
|
const props = canaryFeatureProperties([
|
|
{ name: "de-parallel-router", enabled: true, reason: "in_cohort" },
|
|
]);
|
|
expect(props["$feature/canary-de-parallel-router"]).toBe("true");
|
|
expect(props["canary_reason_de_parallel_router"]).toBe("in_cohort");
|
|
});
|
|
|
|
// A non-boolean under `$feature/` would corrupt the flag's own breakdowns,
|
|
// which is the whole reason the reason gets its own key.
|
|
it("never puts a reason inside the flag namespace", () => {
|
|
const props = canaryFeatureProperties([{ name: "x", enabled: false, reason: "forced_off" }]);
|
|
for (const [key, value] of Object.entries(props)) {
|
|
if (key.startsWith(CANARY_FEATURE_PREFIX)) {
|
|
expect(value).toMatch(/^(true|false)$/);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("separates a forced override from a genuine cohort roll at the same value", () => {
|
|
const forced = canaryFeatureProperties([{ name: "f", enabled: true, reason: "forced_on" }]);
|
|
const rolled = canaryFeatureProperties([{ name: "f", enabled: true, reason: "in_cohort" }]);
|
|
// Identical assignment — only the reason tells them apart. This is the
|
|
// distinction the calibration read could not make.
|
|
expect(forced["$feature/canary-f"]).toBe(rolled["$feature/canary-f"]);
|
|
expect(forced["canary_reason_f"]).not.toBe(rolled["canary_reason_f"]);
|
|
});
|
|
|
|
it("emits `excluded` for CI, which replaces joining on is_ci", () => {
|
|
const props = canaryFeatureProperties([{ name: "c", enabled: false, reason: "excluded" }]);
|
|
// `excluded` and `out_of_cohort` are both enabled:false but mean different
|
|
// things — CI was never bucketed, the other lost the roll. Counting them
|
|
// together is what biased the first accuracy read low.
|
|
expect(props["canary_reason_c"]).toBe("excluded");
|
|
});
|
|
|
|
it("omits the reason key when no reason is supplied", () => {
|
|
const props = canaryFeatureProperties([{ name: "n", enabled: true }]);
|
|
expect(props["$feature/canary-n"]).toBe("true");
|
|
expect(props).not.toHaveProperty("canary_reason_n");
|
|
});
|
|
|
|
it("sanitizes the name into a property-safe key", () => {
|
|
expect(canaryReasonKey("de-parallel-router")).toBe("canary_reason_de_parallel_router");
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The audio FX rack ships dark.
|
|
*
|
|
* Pinned as a test rather than trusted to review: the registry's own procedure
|
|
* is "start at percentage: 0 and merge that", and the whole point of landing a
|
|
* 47-PR stack behind a canary is defeated if the entry reaches main at anything
|
|
* else. A ramp is a deliberate edit to this number, and it should have to break
|
|
* a test that says so.
|
|
*/
|
|
describe("the audio-fx-rack canary", () => {
|
|
const entry = CANARIES.find((c) => c.name === "audio-fx-rack");
|
|
|
|
it("is registered", () => {
|
|
expect(entry, "audio-fx-rack missing from the registry").toBeDefined();
|
|
});
|
|
|
|
it("ships at 0%", () => {
|
|
expect(entry?.percentage).toBe(0);
|
|
});
|
|
|
|
it("carries a sunset date, so the fork cannot outlive the rollout", () => {
|
|
expect(entry?.sunsetAfter).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
|
expect(Date.parse(`${entry?.sunsetAfter}T00:00:00Z`)).toBeGreaterThan(Date.parse("2026-08-12"));
|
|
});
|
|
});
|