open-design/apps/daemon/tests/artifact-stub-guard.test.ts
Sebastian Westberg 8962088c75
feat(daemon): guard against agent-emitted stub artifact regressions (#1171)
* feat(daemon): guard against agent-emitted stub artifact regressions

When an agent emits an <artifact> block whose body is a placeholder
("see other-file.html in this project", a bare filename string, a tiny
fallback page) instead of the full document, the daemon writes the
placeholder to disk verbatim. Users see a 25-500 byte HTML file where
their previous version had tens of kilobytes of real markup.

Add a structural regression guard in writeProjectFile: before writing
an html/deck artifact whose manifest carries metadata.identifier, scan
the project dir for prior siblings matching <identifier>(-\d+)?\.html?
and compare sizes. If the new body is below minRetainedRatio (default
0.2) of the largest prior sibling >= minPriorBytes (default 4096),
flag a regression. Three modes via env:

- OD_ARTIFACT_STUB_GUARD=warn (default) writes the file and attaches
  stubGuardWarning to the response so the frontend can surface it.
- OD_ARTIFACT_STUB_GUARD=reject throws ArtifactRegressionError before
  fs.writeFile; the route returns 422 ARTIFACT_REGRESSION with the
  prior sibling's name and size in error.details.
- OD_ARTIFACT_STUB_GUARD=off skips the guard entirely.

Cross-agent by design: anchored on size delta + identifier match,
no agent-specific stub-phrase regex, so works for any agent backend
behind the agent-adapter abstraction.

The body-then-manifest write order pre-dates this change; the reject
path throws before fs.writeFile so rejections never leave a partial
state behind.

24 unit + 8 HTTP tests cover happy paths, all three modes, deck kind,
.htm extension sibling detection, ratio=1 edge case, and verify
rejected writes leave neither the html nor its manifest sidecar on
disk.

* fix(stub-guard): close same-name, nested-dir, and non-slug bypasses

Code review on PR #1171 (lefarcen, Codex, mrcfps) found three holes
where the stub guard could be silently bypassed. All three are now
closed with HTTP test coverage.

Same-name overwrite (lefarcen P1): the writer's prior-sibling scan
deliberately skipped the file at safeName, but for an in-session
overwrite (persistArtifact reuses the same fileName when
savedArtifactRef.current matches) that file is the prior content,
not the new entry. Drop the exclude-by-name filter; the current
on-disk size at scan time is always the prior because the overwrite
happens after this check.

Subdirectory scoping (Codex/mrcfps P2): writeProjectFile creates
parent directories for nested paths like reports/overview.html, but
the guard only scanned the project root. Pass path.dirname(target)
as scanDir so nested artifacts are evaluated against their real
sibling set.

Non-slug identifier (Codex/lefarcen/mrcfps P2): the web's
persistArtifact slugifies the filename basename but stores the raw
identifier in the manifest, so an identifier like "Landing Page"
yields filename landing-page.html with metadata.identifier="Landing
Page". Build the sibling regex from both the raw identifier and a
slugified variant (mirroring the frontend's slugifier) so either
form matches the same priors.

Also surface warn-mode warnings in the web UI: ProjectView now
checks file.stubGuardWarning after writeProjectTextFile and renders
the warning via setError. Reject-mode 422 surfacing requires
restructuring writeProjectTextFile's return contract and is
deferred.

API change inside the daemon: evaluateArtifactStubGuard /
findPriorArtifactSiblings drop excludeSafeName and rename projectDir
to scanDir. Tests updated.

Tests: 4 new HTTP cases (same-name overwrite preserves prior body,
nested subdir rejects, slug-form match rejects, plus the existing
warn/off/deck/.htm cases) and 1 new unit case (slug-form sibling
match). 44 tests pass.

* fix(stub-guard): empty-slug fallback + reject-mode UI surface

Round 3 review on PR #1171 (lefarcen, mrcfps) found two remaining
holes after 9cc82430 closed the same-name / subdir / non-slug bypasses.

Empty-slug fallback bypass (lefarcen P2): an identifier like "测试"
(all-non-ASCII) strips to empty through the web slugifier, and
persistArtifact's `slice(0,60) || 'artifact'` falls back to the
literal "artifact" basename. The guard searched for raw identifier +
slug only, so a later artifact-2.html stub bypassed the prior. Add
EMPTY_SLUG_FALLBACK_NAME = 'artifact' as a sibling-name candidate
when the slug is empty, mirroring the frontend fallback exactly.

Reject-mode UI silence (mrcfps P2 + lefarcen P2): writeProjectTextFile
collapses any non-OK response (including 422 ARTIFACT_REGRESSION) to
null, and persistArtifact previously had no else branch. Users in
reject mode saw the daemon log fire but the UI was silent. Add an
else branch that surfaces a generic banner pointing at the most
likely cause and mentions checking the daemon logs for structured
details. Also clear savedArtifactRef.current on failure so retries
re-enter the persistence path.

Plumbing the structured 422 details through writeProjectTextFile
itself remains out of scope (cross-cutting client contract change
affecting 5+ call sites). The generic banner is the "at minimum"
path mrcfps suggested.

Tests: 1 new unit case (artifact.html sibling discovery for non-ASCII
identifier) + 1 new HTTP case (empty-slug stub regression rejected
end-to-end). 46 tests pass across stub-guard suites (was 44).

* fix(stub-guard): verify sidecar identity to avoid cross-identifier false positives

Round 4 review on PR #1171 (mrcfps inline + lefarcen review) caught
a false-positive introduced by the round-3 empty-slug fallback. Two
distinct identifiers that both slugify to empty (e.g. "测试" and
"首页") share the artifact*.html basename, so a brand-new save under
the second identifier was being compared against — and falsely
rejected because of — the unrelated first.

The same shape exists symmetrically: a non-empty-slug identifier
literally named "artifact" would falsely match empty-slug fallback
files written under any other identifier.

Fix: filename pattern matching is now a candidate generator, not
the source of truth. For every candidate sibling, read its
.artifact.json sidecar and verify metadata.identifier matches the
input via artifactIdentifiersMatch (raw equality OR shared non-empty
slug). Files without a sidecar are skipped — they weren't written
through the artifact-tag path this guard targets, and treating them
as priors was always a stretch.

Empty-slug equivalence is intentionally NOT honored: 测试 != 首页
even though both slugify to empty. The whole bug was conflating
distinct identifiers via the fallback name; slug-equivalence kicks
in only for non-empty slugs (Landing Page <-> landing-page).

Tests: unit fixtures now write file+sidecar pairs (mirrors prod);
new artifactIdentifiersMatch suite covers the 5 equivalence cases;
new HTTP test does NOT cross-reject distinct empty-slug identifiers
asserts the second save returns 200 instead of 422; new unit test
skips files without a sidecar.

42 tests pass across stub-guard suites.

* fix(stub-guard): require canonical-form anchor in identifier match to avoid 60-char truncation collisions

Round 5 review on PR #1171 (mrcfps) caught another false-positive in
artifactIdentifiersMatch: slugifyArtifactIdentifier truncates at 60
chars, so two distinct >60-char identifiers that share their first
60 chars (e.g. "A...A1" and "A...A2", 70 chars each) slugify to the
same string and would falsely bridge. Same shape as the empty-slug
fallback bug from round 4, just at the other end of the input range.

Tighten the rule: slug-equivalence requires at least one input to BE
its own canonical slug form. That keeps the legitimate bridge
("Landing Page" <-> "landing-page" — second input IS the slug) but
rejects truncation collisions ("A...A1" <-> "A...A2" — neither is in
canonical form).

Side effect: two non-canonical forms that slugify to the same value
no longer bridge (e.g. "Landing Page" vs "LANDING-PAGE"). This is
correct: without one canonical anchor we can't safely call them the
same lineage. Updated the slug-equivalence test to assert the new
semantics explicitly with both directions and a negative case.

Tests: 2 new cases (no bridge for >60-char truncation collision; raw
70-char to its 60-char truncated slug still bridges) + 1 negative
test for the non-canonical-pair case. 45 tests pass.

* fix(stub-guard): cover legacy sidecar-less HTML priors

Round 6 review on PR #1171 (mrcfps, non-blocking) caught a real
legacy bypass: round 4's sidecar-required policy skipped any HTML
file without an .artifact.json companion, but readManifestForPath
(projects.ts) treats those same files as legitimate artifacts via
inferLegacyManifest. So a project with an older sidecar-less
dashboard.html (pre-sidecar era, Write-tool-emitted, paste-text,
manual import, etc.) let its first stub rewrite through as a
supposed "first emission".

Fix: when the sidecar is missing, derive a synthetic identifier
from the filename (strip the (-N)?\.html? suffix) and run it
through the same artifactIdentifiersMatch rules. Synthetic
identifiers come from already-slugified filenames, so they bridge
raw inputs only via the canonical-form rule established in round
5 — no truncation collisions, no empty-slug conflation, no
unrelated cross-identifier matches.

Tests: 3 new unit cases (legacy fallback finds the prior; bridges
raw->slug under the same rules; does NOT bridge unrelated slug
forms via inference) + 1 new HTTP test that seeds a sidecar-less
prior via the artifact-manifest-less write path and asserts the
stub rewrite is rejected with 422 ARTIFACT_REGRESSION.

48 tests pass across stub-guard suites (was 45).

* fix(stub-guard): try both interpretations for legacy filename inference

Round 7 review on PR #1171 (mrcfps, non-blocking) caught a real
ambiguity in the round-6 legacy fallback: a filename like
`phase-2.html` is genuinely ambiguous without a sidecar. It could
be the identifier "phase" with a -2 collision suffix, OR the
standalone identifier "phase-2". The round-6 helper only stripped
the suffix, so a sidecar-less `phase-2.html` followed by a stub
emission with metadata.identifier="phase-2" bypassed the guard
("phase-2" doesn't match the inferred "phase").

Fix: when the sidecar is missing, generate both candidate
identifiers (full basename and suffix-stripped basename) and
accept the file as a prior if either matches. Visible false
positives are preferable to silent false negatives — and the
canonical-form anchor in artifactIdentifiersMatch still rules out
truncation collisions and empty-slug conflations regardless of
which candidate matched.

Tests: 2 new unit cases (full-basename interpretation finds
"phase-2"; suffix-stripped interpretation also finds "phase") and
1 new HTTP test that seeds a sidecar-less `phase-2.html` and
asserts the stub rewrite is rejected with 422 ARTIFACT_REGRESSION.

51 tests pass across stub-guard suites (was 48).

---------

Co-authored-by: Sebastian Westberg <sebastianwestberg@users.noreply.github.com>
2026-05-11 19:59:37 +08:00

432 lines
17 KiB
TypeScript

import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
ArtifactRegressionError,
DEFAULT_ARTIFACT_STUB_GUARD_CONFIG,
artifactIdentifiersMatch,
classifyArtifactStubGuard,
evaluateArtifactStubGuard,
findPriorArtifactSiblings,
readArtifactStubGuardConfigFromEnv,
slugifyArtifactIdentifier,
type ArtifactStubGuardConfig,
} from '../src/artifact-stub-guard.js';
// Helper: write an artifact body + the sidecar manifest the production
// write path would produce. Sibling discovery requires the sidecar to
// verify the canonical identifier, so unit fixtures must include both.
async function writeArtifactPair(dir: string, name: string, body: string, identifier: string): Promise<void> {
await writeFile(path.join(dir, name), body);
const manifest = {
version: 1,
kind: 'html',
title: identifier,
entry: name,
renderer: 'html',
status: 'complete',
exports: ['html'],
metadata: { identifier, artifactType: 'text/html', inferred: false },
};
await writeFile(path.join(dir, `${name}.artifact.json`), JSON.stringify(manifest));
}
function rejectingConfig(overrides: Partial<ArtifactStubGuardConfig> = {}): ArtifactStubGuardConfig {
return { ...DEFAULT_ARTIFACT_STUB_GUARD_CONFIG, mode: 'reject', ...overrides };
}
function warningConfig(overrides: Partial<ArtifactStubGuardConfig> = {}): ArtifactStubGuardConfig {
return { ...DEFAULT_ARTIFACT_STUB_GUARD_CONFIG, mode: 'warn', ...overrides };
}
describe('classifyArtifactStubGuard', () => {
it('passes when no priors exist', () => {
const result = classifyArtifactStubGuard([], 'dashboard', 80, rejectingConfig());
expect(result.outcome).toBe('pass');
expect(result.warning).toBeUndefined();
});
it('passes when guard mode is off', () => {
const result = classifyArtifactStubGuard(
[{ name: 'dashboard.html', size: 80_000 }],
'dashboard',
120,
{ ...DEFAULT_ARTIFACT_STUB_GUARD_CONFIG, mode: 'off' },
);
expect(result.outcome).toBe('pass');
});
it('passes when identifier is empty', () => {
const result = classifyArtifactStubGuard(
[{ name: 'dashboard.html', size: 80_000 }],
'',
120,
rejectingConfig(),
);
expect(result.outcome).toBe('pass');
});
it('passes when largest prior is below the floor', () => {
const result = classifyArtifactStubGuard(
[{ name: 'dashboard.html', size: 1_024 }],
'dashboard',
32,
rejectingConfig({ minPriorBytes: 4_096 }),
);
expect(result.outcome).toBe('pass');
});
it('passes when the new body keeps at least minRetainedRatio of the prior', () => {
const result = classifyArtifactStubGuard(
[{ name: 'dashboard.html', size: 80_000 }],
'dashboard',
40_000,
rejectingConfig({ minRetainedRatio: 0.2 }),
);
expect(result.outcome).toBe('pass');
});
it('rejects when the new body collapses below the ratio of the largest prior', () => {
const result = classifyArtifactStubGuard(
[
{ name: 'dashboard.html', size: 80_000 },
{ name: 'dashboard-2.html', size: 95_000 },
],
'dashboard',
120,
rejectingConfig({ minRetainedRatio: 0.2, minPriorBytes: 4_096 }),
);
expect(result.outcome).toBe('reject');
expect(result.warning).toMatchObject({
code: 'ARTIFACT_REGRESSION',
identifier: 'dashboard',
newSize: 120,
priorSize: 95_000,
priorName: 'dashboard-2.html',
});
expect(result.warning?.message).toContain('dashboard-2.html');
});
it('warns instead of rejecting when mode is warn', () => {
const result = classifyArtifactStubGuard(
[{ name: 'report.html', size: 50_000 }],
'report',
300,
warningConfig(),
);
expect(result.outcome).toBe('warn');
expect(result.warning?.priorSize).toBe(50_000);
});
});
describe('findPriorArtifactSiblings', () => {
const tempDirs: string[] = [];
afterEach(async () => {
for (const dir of tempDirs.splice(0)) {
await rm(dir, { recursive: true, force: true });
}
});
async function makeDir() {
const dir = await mkdtemp(path.join(tmpdir(), 'od-stub-guard-'));
tempDirs.push(dir);
return dir;
}
it('finds bare and suffixed siblings, including the same-named target if it exists', async () => {
const dir = await makeDir();
await writeArtifactPair(dir, 'report.html', 'a'.repeat(20_000), 'report');
await writeArtifactPair(dir, 'report-2.html', 'b'.repeat(40_000), 'report');
await writeArtifactPair(dir, 'report-3.html', 'c'.repeat(60_000), 'report');
await writeArtifactPair(dir, 'unrelated.html', 'x'.repeat(50_000), 'unrelated');
// The target 'report-3.html' is included because it currently exists on
// disk and its current size is the prior content (the overwrite that
// would replace it has not happened yet at scan time). This is the
// same-name-overwrite case: see lefarcen P1.
const priors = await findPriorArtifactSiblings(dir, 'report');
const names = priors.map((p) => p.name).sort();
expect(names).toEqual(['report-2.html', 'report-3.html', 'report.html']);
});
it('returns an empty list when the directory does not exist', async () => {
const priors = await findPriorArtifactSiblings('/nonexistent/od/projects/missing', 'dashboard');
expect(priors).toEqual([]);
});
it('does not match identifiers that share a prefix', async () => {
const dir = await makeDir();
await writeArtifactPair(dir, 'landing.html', 'a'.repeat(1_000), 'landing');
await writeArtifactPair(dir, 'landing-page.html', 'b'.repeat(1_000), 'landing-page');
const priors = await findPriorArtifactSiblings(dir, 'landing');
const names = priors.map((p) => p.name).sort();
expect(names).toEqual(['landing.html']);
});
it('also matches .htm siblings', async () => {
const dir = await makeDir();
await writeArtifactPair(dir, 'overview-doc.htm', 'a'.repeat(20_000), 'overview-doc');
await writeArtifactPair(dir, 'overview-doc-2.html', 'b'.repeat(30_000), 'overview-doc');
const priors = await findPriorArtifactSiblings(dir, 'overview-doc');
const names = priors.map((p) => p.name).sort();
expect(names).toEqual(['overview-doc-2.html', 'overview-doc.htm']);
});
it('matches siblings using the slugified form of a non-slug identifier', async () => {
const dir = await makeDir();
// Frontend persistArtifact slugifies "Landing Page" -> "landing-page"
// for the filename but keeps the raw "Landing Page" in the manifest.
// Both forms refer to the same lineage; sidecar identity uses
// slug-equivalence to bridge them.
await writeArtifactPair(dir, 'landing-page.html', 'a'.repeat(40_000), 'Landing Page');
const priors = await findPriorArtifactSiblings(dir, 'Landing Page');
expect(priors.map((p) => p.name)).toEqual(['landing-page.html']);
});
it('falls back to the "artifact" basename when the identifier slugifies to empty', async () => {
const dir = await makeDir();
// Identifier like "测试" (or any all-non-ASCII / punctuation-only
// string) strips to "" through the web slugifier and persistArtifact's
// `|| 'artifact'` fallback writes it as artifact.html / artifact-2.html.
await writeArtifactPair(dir, 'artifact.html', 'a'.repeat(40_000), '测试');
await writeArtifactPair(dir, 'artifact-2.html', 'b'.repeat(60_000), '测试');
const priors = await findPriorArtifactSiblings(dir, '测试');
const names = priors.map((p) => p.name).sort();
expect(names).toEqual(['artifact-2.html', 'artifact.html']);
});
it('does NOT match a fallback sibling whose sidecar identifier differs (lefarcen/mrcfps round 4)', async () => {
const dir = await makeDir();
// Two distinct empty-slug identifiers both land in the artifact*.html
// namespace. A new save for "首页" must not be compared against the
// earlier "测试" sibling — they're unrelated artifacts that just
// happen to share a fallback basename.
await writeArtifactPair(dir, 'artifact.html', 'a'.repeat(40_000), '测试');
const priors = await findPriorArtifactSiblings(dir, '首页');
expect(priors).toEqual([]);
});
it('falls back to filename-derived identifier for legacy sidecar-less HTML (mrcfps R6)', async () => {
const dir = await makeDir();
// Pre-sidecar legacy file — `inferLegacyManifest` treats it as
// html-kind elsewhere, so the guard should too. Without this
// fallback, a stub overwrite of a legacy `dashboard.html` would
// bypass the guard as a "first emission".
await writeFile(path.join(dir, 'dashboard.html'), 'a'.repeat(40_000));
const priors = await findPriorArtifactSiblings(dir, 'dashboard');
expect(priors.map((p) => p.name)).toEqual(['dashboard.html']);
});
it('legacy fallback bridges raw <-> slug per artifactIdentifiersMatch rules', async () => {
const dir = await makeDir();
// Legacy file basename is the canonical slug form. Input identifier
// "Landing Page" must still bridge to it via slug-equivalence, just
// like the sidecar case.
await writeFile(path.join(dir, 'landing-page.html'), 'a'.repeat(40_000));
const priors = await findPriorArtifactSiblings(dir, 'Landing Page');
expect(priors.map((p) => p.name)).toEqual(['landing-page.html']);
});
it('legacy fallback does NOT bridge unrelated identifiers via filename inference', async () => {
const dir = await makeDir();
// Legacy file basename `dashboard` should not match identifier
// `legacy-dashboard` even though both are slug-form.
await writeFile(path.join(dir, 'dashboard.html'), 'a'.repeat(40_000));
const priors = await findPriorArtifactSiblings(dir, 'legacy-dashboard');
expect(priors).toEqual([]);
});
it('legacy fallback honors identifiers that legitimately end in -<digits> (mrcfps R7)', async () => {
const dir = await makeDir();
// `phase-2.html` is ambiguous without a sidecar: could be "phase"
// with -2 collision suffix, or the standalone identifier "phase-2".
// The guard tries both; here the input names "phase-2" so the full
// basename interpretation must match.
await writeFile(path.join(dir, 'phase-2.html'), 'a'.repeat(40_000));
const priors = await findPriorArtifactSiblings(dir, 'phase-2');
expect(priors.map((p) => p.name)).toEqual(['phase-2.html']);
});
it('legacy fallback also honors the suffix-stripped interpretation', async () => {
const dir = await makeDir();
// Same on-disk file, different input: this time the agent is
// emitting under the `phase` identifier (treating the -2 as a
// collision suffix). The suffix-stripped interpretation must match.
await writeFile(path.join(dir, 'phase-2.html'), 'a'.repeat(40_000));
const priors = await findPriorArtifactSiblings(dir, 'phase');
expect(priors.map((p) => p.name)).toEqual(['phase-2.html']);
});
});
describe('artifactIdentifiersMatch', () => {
it('matches identical raw identifiers', () => {
expect(artifactIdentifiersMatch('dashboard', 'dashboard')).toBe(true);
});
it('bridges raw form to its canonical slug form', () => {
expect(artifactIdentifiersMatch('Landing Page', 'landing-page')).toBe(true);
expect(artifactIdentifiersMatch('landing-page', 'Landing Page')).toBe(true);
});
it('does NOT bridge two non-canonical forms even if they slugify the same', () => {
// Both inputs slugify to "landing-page" but neither IS the canonical
// slug form, so we can't safely call them the same lineage. This is
// the same safety property that protects against truncation
// collisions for >60-char identifiers.
expect(artifactIdentifiersMatch('Landing Page', 'LANDING-PAGE')).toBe(false);
});
it('does not match distinct identifiers that both slugify to empty', () => {
expect(artifactIdentifiersMatch('测试', '首页')).toBe(false);
expect(artifactIdentifiersMatch('!!!', '???')).toBe(false);
});
it('matches a non-ASCII identifier with itself even when its slug is empty', () => {
expect(artifactIdentifiersMatch('测试', '测试')).toBe(true);
});
it('does not match unrelated identifiers with different slugs', () => {
expect(artifactIdentifiersMatch('dashboard', 'legacy-dashboard')).toBe(false);
});
it('does NOT bridge two long raw identifiers that share a 60-char truncated slug (mrcfps R5)', () => {
// Both >60 chars and identical for the first 60, differing only after.
// Their slugify outputs collide via truncation, but neither is the
// canonical slug form of itself, so they must not bridge.
const sixtyAs = 'a'.repeat(60);
const a = `${sixtyAs}-suffix-one`;
const b = `${sixtyAs}-suffix-two`;
expect(a).not.toBe(b);
expect(artifactIdentifiersMatch(a, b)).toBe(false);
});
it('still bridges raw form to truncated slug when the slug is the canonical second input', () => {
// The standard "Landing Page" <-> "landing-page" case must still work.
// Asserting via inputs that hit the truncation boundary: a 70-char raw
// identifier whose slug is the truncated-to-60 form, paired with that
// truncated form passed in directly, should still match.
const slug = 'a'.repeat(60);
const raw = 'a'.repeat(70);
expect(slugifyArtifactIdentifier(raw)).toBe(slug);
expect(artifactIdentifiersMatch(raw, slug)).toBe(true);
});
});
describe('evaluateArtifactStubGuard (integration with disk scan)', () => {
const tempDirs: string[] = [];
afterEach(async () => {
for (const dir of tempDirs.splice(0)) {
await rm(dir, { recursive: true, force: true });
}
});
async function makeDir() {
const dir = await mkdtemp(path.join(tmpdir(), 'od-stub-guard-eval-'));
tempDirs.push(dir);
return dir;
}
it('rejects a stub-sized rewrite of an existing identifier', async () => {
const dir = await makeDir();
await writeArtifactPair(dir, 'presentation.html', 'p'.repeat(60_000), 'presentation');
const result = await evaluateArtifactStubGuard({
scanDir: dir,
identifier: 'presentation',
newSize: 200,
config: rejectingConfig(),
});
expect(result.outcome).toBe('reject');
expect(result.warning?.priorName).toBe('presentation.html');
});
it('passes when the new body comparable in size to the prior', async () => {
const dir = await makeDir();
await writeArtifactPair(dir, 'presentation.html', 'p'.repeat(60_000), 'presentation');
const result = await evaluateArtifactStubGuard({
scanDir: dir,
identifier: 'presentation',
newSize: 50_000,
config: rejectingConfig(),
});
expect(result.outcome).toBe('pass');
});
});
describe('readArtifactStubGuardConfigFromEnv', () => {
it('returns defaults when env vars are absent', () => {
const config = readArtifactStubGuardConfigFromEnv({});
expect(config).toEqual(DEFAULT_ARTIFACT_STUB_GUARD_CONFIG);
});
it('parses recognised mode values', () => {
expect(readArtifactStubGuardConfigFromEnv({ OD_ARTIFACT_STUB_GUARD: 'reject' }).mode).toBe('reject');
expect(readArtifactStubGuardConfigFromEnv({ OD_ARTIFACT_STUB_GUARD: 'WARN' }).mode).toBe('warn');
expect(readArtifactStubGuardConfigFromEnv({ OD_ARTIFACT_STUB_GUARD: 'off' }).mode).toBe('off');
});
it('falls back to default when mode is unrecognised', () => {
expect(readArtifactStubGuardConfigFromEnv({ OD_ARTIFACT_STUB_GUARD: 'maybe' }).mode).toBe(
DEFAULT_ARTIFACT_STUB_GUARD_CONFIG.mode,
);
});
it('honours numeric overrides within range', () => {
const config = readArtifactStubGuardConfigFromEnv({
OD_ARTIFACT_STUB_GUARD_MIN_RATIO: '0.35',
OD_ARTIFACT_STUB_GUARD_MIN_PRIOR_BYTES: '8192',
});
expect(config.minRetainedRatio).toBeCloseTo(0.35);
expect(config.minPriorBytes).toBe(8_192);
});
it('accepts ratio = 1 to reject any shrinkage', () => {
const config = readArtifactStubGuardConfigFromEnv({
OD_ARTIFACT_STUB_GUARD_MIN_RATIO: '1',
});
expect(config.minRetainedRatio).toBe(1);
});
it('rejects out-of-range numeric overrides', () => {
const config = readArtifactStubGuardConfigFromEnv({
OD_ARTIFACT_STUB_GUARD_MIN_RATIO: '5',
OD_ARTIFACT_STUB_GUARD_MIN_PRIOR_BYTES: '-12',
});
expect(config.minRetainedRatio).toBe(DEFAULT_ARTIFACT_STUB_GUARD_CONFIG.minRetainedRatio);
expect(config.minPriorBytes).toBe(DEFAULT_ARTIFACT_STUB_GUARD_CONFIG.minPriorBytes);
});
});
describe('ArtifactRegressionError', () => {
it('carries identifier, sizes, and prior name in details', () => {
const err = new ArtifactRegressionError('regression', {
identifier: 'dashboard',
newSize: 100,
priorSize: 50_000,
priorName: 'dashboard.html',
});
expect(err.code).toBe('ARTIFACT_REGRESSION');
expect(err.name).toBe('ArtifactRegressionError');
expect(err.identifier).toBe('dashboard');
expect(err.newSize).toBe(100);
expect(err.priorSize).toBe(50_000);
expect(err.priorName).toBe('dashboard.html');
});
});