Files
2026-07-13 12:58:18 +08:00

2360 lines
96 KiB
TypeScript

/**
* Tests for showcase parity validator.
*
* Builds fixture package trees inside a per-suite tmpdir instead of relying on
* committed fixtures under __tests__/fixtures/parity/. The committed tree is
* incomplete (missing src/app/demos/<id>/ entries because empty dirs don't
* survive git), so materialising the whole tree at setup keeps the tests
* self-contained and unaffected by what does / doesn't get committed.
*/
import {
describe,
it,
expect,
vi,
beforeAll,
afterAll,
beforeEach,
afterEach,
} from "vitest";
import fs from "fs";
import path from "path";
import os from "os";
import { fileURLToPath } from "url";
import { spawnSync } from "child_process";
import {
auditPackage,
loadManifest,
listFiles,
listDirs,
ManifestMalformedError,
ManifestUnreadableError,
runParity,
coerceBaseline,
deriveMessage,
HEADER_COLUMNS,
formatRow,
buildHeader,
routeToDirName,
type PackageIssue,
} from "../validate-parity.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const PARITY_SCRIPT = path.resolve(__dirname, "..", "validate-parity.ts");
// Shared tmpdir populated by beforeAll with all the "static" fixture
// packages (ok-pkg, missing-manifest, missing-demo-dir, missing-spec,
// spec-exceeds, spec-less). Malformed / empty / scalar fixtures are
// built in per-test tmpdirs because they exercise distinct crash paths.
let FIXTURES_DIR: string;
// Save cwd / env to restore in afterAll — avoids cross-file pollution if
// any individual test temporarily swaps them.
const ORIGINAL_CWD = process.cwd();
const ORIGINAL_PARITY_ROOT = process.env.VALIDATE_PARITY_REPO_ROOT;
const ORIGINAL_PARITY_BASELINE = process.env.VALIDATE_PARITY_BASELINE;
function writeFixturePackage(
root: string,
slug: string,
opts: {
manifest?: string;
demoDirs?: string[];
specFiles?: string[];
qaFiles?: string[];
},
) {
const pkgDir = path.join(root, slug);
fs.mkdirSync(pkgDir, { recursive: true });
if (opts.manifest !== undefined) {
fs.writeFileSync(
path.join(pkgDir, "manifest.yaml"),
opts.manifest,
"utf-8",
);
}
for (const id of opts.demoDirs ?? []) {
fs.mkdirSync(path.join(pkgDir, "src", "app", "demos", id), {
recursive: true,
});
}
if (opts.specFiles?.length) {
const e2eDir = path.join(pkgDir, "tests", "e2e");
fs.mkdirSync(e2eDir, { recursive: true });
for (const name of opts.specFiles) {
fs.writeFileSync(path.join(e2eDir, name), "", "utf-8");
}
}
if (opts.qaFiles?.length) {
const qaDir = path.join(pkgDir, "qa");
fs.mkdirSync(qaDir, { recursive: true });
for (const name of opts.qaFiles) {
fs.writeFileSync(path.join(qaDir, name), "", "utf-8");
}
}
}
function seedStaticFixtures(root: string) {
// ok-pkg: 1 demo, 1 spec, 1 qa, demo dir present → no errors, no warnings
// except baseline (1 != 9) which tests don't assert on here.
writeFixturePackage(root, "ok-pkg", {
manifest: "slug: ok-pkg\ndemos:\n - id: chat\n name: Chat\n",
demoDirs: ["chat"],
specFiles: ["chat.spec.ts"],
qaFiles: ["chat.md"],
});
// missing-manifest: directory exists but no manifest.yaml
fs.mkdirSync(path.join(root, "missing-manifest"), { recursive: true });
// missing-demo-dir: manifest declares chat but no src/app/demos/chat/
writeFixturePackage(root, "missing-demo-dir", {
manifest: "slug: missing-demo-dir\ndemos:\n - id: chat\n name: Chat\n",
});
// missing-spec: demo dir present, qa present, spec absent → WARNING only
writeFixturePackage(root, "missing-spec", {
manifest: "slug: missing-spec\ndemos:\n - id: chat\n name: Chat\n",
demoDirs: ["chat"],
qaFiles: ["chat.md"],
});
// spec-exceeds: spec count > demo count (legitimate cross-demo extras)
writeFixturePackage(root, "spec-exceeds", {
manifest: "slug: spec-exceeds\ndemos:\n - id: chat\n name: Chat\n",
demoDirs: ["chat"],
specFiles: ["chat.spec.ts", "renderer-selector.spec.ts"],
qaFiles: ["chat.md"],
});
// spec-less: 2 demos, only 1 spec → WARNING for under-coverage
writeFixturePackage(root, "spec-less", {
manifest:
"slug: spec-less\ndemos:\n - id: chat\n name: Chat\n - id: tools\n name: Tools\n",
demoDirs: ["chat", "tools"],
specFiles: ["chat.spec.ts"],
qaFiles: ["chat.md", "tools.md"],
});
// spec-equal-demos: exactly spec count == demo count → NO warning
writeFixturePackage(root, "spec-equal", {
manifest: "slug: spec-equal\ndemos:\n - id: chat\n name: Chat\n",
demoDirs: ["chat"],
specFiles: ["chat.spec.ts"],
qaFiles: ["chat.md"],
});
}
// Malformed-YAML fixtures are written to a throwaway tmpdir at test time
// rather than committed to the repo, because repo-level oxfmt / prettier /
// YAML linters would reject an intentionally broken .yaml fixture.
function makeMalformedManifestDir(): string {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "parity-malformed-"));
const pkgDir = path.join(tmp, "malformed");
fs.mkdirSync(path.join(pkgDir, "src", "app", "demos", "chat"), {
recursive: true,
});
// Invalid YAML: unclosed flow sequence inside a mapping value.
fs.writeFileSync(
path.join(pkgDir, "manifest.yaml"),
"slug: malformed\ndemos:\n - id: chat\n name: Chat\n invalid: [unclosed\n",
"utf-8",
);
return tmp;
}
// Build a tree that main() expects: <root>/integrations/<slug>/... so
// VALIDATE_PARITY_REPO_ROOT can point at the root.
function makeMainTree(): { root: string; packagesDir: string } {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "parity-main-"));
const packagesDir = path.join(root, "integrations");
fs.mkdirSync(packagesDir, { recursive: true });
return { root, packagesDir };
}
beforeAll(() => {
// Idempotent: only create the fixtures directory once. If a previous
// test file left one on disk (it shouldn't), we create a fresh one so
// the seedStaticFixtures contract isn't corrupted. Wrap seed in
// try/catch so a partial fixture tree is torn down before rethrowing
// — otherwise later tests inherit a FIXTURES_DIR with half a tree and
// produce mysterious failures.
FIXTURES_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "parity-fixtures-"));
try {
seedStaticFixtures(FIXTURES_DIR);
} catch (err) {
fs.rmSync(FIXTURES_DIR, { recursive: true, force: true });
throw err;
}
});
afterAll(() => {
if (FIXTURES_DIR) {
fs.rmSync(FIXTURES_DIR, { recursive: true, force: true });
}
// Restore any env / cwd state in case a subprocess test mutated the
// parent process's environment inadvertently.
if (process.cwd() !== ORIGINAL_CWD) {
process.chdir(ORIGINAL_CWD);
}
if (ORIGINAL_PARITY_ROOT === undefined) {
delete process.env.VALIDATE_PARITY_REPO_ROOT;
} else {
process.env.VALIDATE_PARITY_REPO_ROOT = ORIGINAL_PARITY_ROOT;
}
if (ORIGINAL_PARITY_BASELINE === undefined) {
delete process.env.VALIDATE_PARITY_BASELINE;
} else {
process.env.VALIDATE_PARITY_BASELINE = ORIGINAL_PARITY_BASELINE;
}
});
describe("validate-parity", () => {
// Belt-and-braces cleanup: each nested describe block below may mutate
// VALIDATE_PARITY_REPO_ROOT / VALIDATE_PARITY_BASELINE in subprocesses,
// but if any in-process test forgets to unset them, the next describe
// would inherit stale values. Clear before every test and restore
// after — afterAll still handles end-of-file restoration.
beforeEach(() => {
delete process.env.VALIDATE_PARITY_REPO_ROOT;
delete process.env.VALIDATE_PARITY_BASELINE;
});
afterEach(() => {
delete process.env.VALIDATE_PARITY_REPO_ROOT;
delete process.env.VALIDATE_PARITY_BASELINE;
});
describe("loadManifest", () => {
it("returns null when manifest.yaml is missing", () => {
const result = loadManifest("missing-manifest", FIXTURES_DIR);
expect(result).toBeNull();
});
it("throws ManifestMalformedError on malformed YAML", () => {
// loadManifest signals malformed input distinctly from "missing" by
// throwing a typed ManifestMalformedError; auditPackage converts
// that into a mustError.
const tmp = makeMalformedManifestDir();
try {
expect(() => loadManifest("malformed", tmp)).toThrow(
ManifestMalformedError,
);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
it("throws ManifestUnreadableError on read failure", () => {
// Simulate readFileSync throwing EACCES to exercise the
// "unreadable" branch of parseManifest, which loadManifest
// surfaces as a typed ManifestUnreadableError. Fall through to
// the real readFileSync for unrelated paths so Vitest / tsx
// internals aren't broken.
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "parity-unreadable-"));
try {
const pkgDir = path.join(tmp, "unreadable");
fs.mkdirSync(pkgDir, { recursive: true });
const manifestPath = path.join(pkgDir, "manifest.yaml");
fs.writeFileSync(manifestPath, "slug: x\n", "utf-8");
const realReadFileSync = fs.readFileSync;
const readSpy = vi.spyOn(fs, "readFileSync").mockImplementation(((
p: fs.PathOrFileDescriptor,
...rest: unknown[]
) => {
if (typeof p === "string" && p.endsWith("manifest.yaml")) {
const e: NodeJS.ErrnoException = Object.assign(
new Error("EACCES: permission denied"),
{ code: "EACCES" },
);
throw e;
}
// Fall through for unrelated reads to avoid breaking the
// runtime infrastructure mid-test.
return (
realReadFileSync as unknown as (
p: fs.PathOrFileDescriptor,
...rest: unknown[]
) => unknown
)(p, ...rest);
}) as typeof fs.readFileSync);
try {
expect(() => loadManifest("unreadable", tmp)).toThrow(
ManifestUnreadableError,
);
} finally {
readSpy.mockRestore();
}
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
it("parses a valid manifest", () => {
const result = loadManifest("ok-pkg", FIXTURES_DIR);
expect(result).not.toBeNull();
expect(result?.slug).toBe("ok-pkg");
expect(result?.demos?.[0].id).toBe("chat");
});
});
describe("auditPackage", () => {
it("returns no errors for a valid package", () => {
const report = auditPackage("ok-pkg", FIXTURES_DIR);
expect(report.mustErrors).toEqual([]);
});
it("flags missing manifest as a MUST error", () => {
const report = auditPackage("missing-manifest", FIXTURES_DIR);
const categories = report.mustErrors.map((e) => e.category);
expect(categories).toContain("missing-manifest");
const messages = report.mustErrors.map((e) => deriveMessage(e));
expect(messages).toContain("missing manifest.yaml");
});
it("flags malformed YAML as a MUST error instead of crashing", () => {
// The validator must survive a malformed manifest so sibling packages
// still get validated. This is the core "one bad apple" regression.
const tmp = makeMalformedManifestDir();
try {
const report = auditPackage("malformed", tmp);
expect(report.mustErrors.length).toBeGreaterThan(0);
expect(report.mustErrors[0].category).toBe("malformed-manifest");
expect(deriveMessage(report.mustErrors[0])).toMatch(
/unparseable manifest\.yaml/,
);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
it("distinguishes unreadable from malformed in the mustError category", () => {
// parseManifest reports "unreadable" when readFileSync throws
// (permissions, I/O). loadManifest wraps that in
// ManifestUnreadableError. auditPackage discriminates with
// `instanceof` and assigns the category "unreadable-manifest".
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "parity-unreadable-"));
try {
const pkgDir = path.join(tmp, "unreadable");
fs.mkdirSync(pkgDir, { recursive: true });
const manifestPath = path.join(pkgDir, "manifest.yaml");
fs.writeFileSync(manifestPath, "slug: x\n", "utf-8");
const realReadFileSync = fs.readFileSync;
const readSpy = vi.spyOn(fs, "readFileSync").mockImplementation(((
p: fs.PathOrFileDescriptor,
...rest: unknown[]
) => {
if (typeof p === "string" && p.endsWith("manifest.yaml")) {
const e: NodeJS.ErrnoException = Object.assign(
new Error("EACCES: permission denied"),
{ code: "EACCES" },
);
throw e;
}
return (
realReadFileSync as unknown as (
p: fs.PathOrFileDescriptor,
...rest: unknown[]
) => unknown
)(p, ...rest);
}) as typeof fs.readFileSync);
try {
const report = auditPackage("unreadable", tmp);
expect(report.mustErrors.length).toBeGreaterThan(0);
expect(report.mustErrors[0].category).toBe("unreadable-manifest");
expect(deriveMessage(report.mustErrors[0])).toMatch(
/unreadable manifest\.yaml/,
);
expect(deriveMessage(report.mustErrors[0])).not.toMatch(
/unparseable/,
);
} finally {
readSpy.mockRestore();
}
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
it("flags an empty manifest.yaml as a MUST error instead of crashing", () => {
// yaml.parse("") returns null. Without a type guard, reading
// `.demos` on the parsed value crashes auditPackage. This test pins
// the expected behaviour: empty YAML is reported as an invalid
// manifest rather than letting null propagate into `.demos?.length`.
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "parity-empty-"));
try {
const pkgDir = path.join(tmp, "empty");
fs.mkdirSync(pkgDir, { recursive: true });
fs.writeFileSync(path.join(pkgDir, "manifest.yaml"), "", "utf-8");
const report = auditPackage("empty", tmp);
expect(report.mustErrors.length).toBeGreaterThan(0);
// Pin the CATEGORY (structured field) instead of a regex against
// the rendered message — category-level assertions survive
// message-text refactors and can't be satisfied by an unrelated
// issue that merely contains the string "manifest.yaml".
expect(
report.mustErrors.some((e) => e.category === "malformed-manifest"),
).toBe(true);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
it("flags a non-object manifest.yaml (scalar) as a MUST error", () => {
// A YAML file whose top-level value is a scalar (e.g. 'hello') parses
// to the string 'hello' — truthy but not a valid Manifest. Without a
// type guard the validator silently treats it as a manifest with no
// demos. Pin stricter behaviour: reject non-object values.
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "parity-scalar-"));
try {
const pkgDir = path.join(tmp, "scalar");
fs.mkdirSync(pkgDir, { recursive: true });
fs.writeFileSync(
path.join(pkgDir, "manifest.yaml"),
"hello\n",
"utf-8",
);
const report = auditPackage("scalar", tmp);
expect(report.mustErrors.length).toBeGreaterThan(0);
// Category-level assertion — see rationale above.
expect(
report.mustErrors.some((e) => e.category === "malformed-manifest"),
).toBe(true);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
it("continues validating other packages after a malformed manifest", () => {
// Auditing the malformed fixture should NOT throw, so auditing a
// later package is unaffected.
const tmp = makeMalformedManifestDir();
try {
expect(() => auditPackage("malformed", tmp)).not.toThrow();
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
const ok = auditPackage("ok-pkg", FIXTURES_DIR);
expect(ok.mustErrors).toEqual([]);
});
it("flags a demo with no src/app/demos/<id>/ as a MUST error", () => {
const report = auditPackage("missing-demo-dir", FIXTURES_DIR);
expect(
report.mustErrors.some(
(e) =>
e.category === "missing-demo-dir" &&
/demos\/chat\//.test(deriveMessage(e)),
),
).toBe(true);
});
it("resolves demo dir from demo.route (not demo.id) when the two differ", () => {
// Regression guard: demo.id is the CATALOG identifier (matched to
// qa/spec filenames + shell registry); demo.route is the URL +
// filesystem path under src/app/demos/. They are DELIBERATELY
// separate — e.g. manifest id: "hitl-in-chat" with route:
// "/demos/hitl" lives at src/app/demos/hitl/. validate-parity
// used to resolve the demo directory from demo.id, which produced
// a spurious missing-demo-dir MUST for every such mapping. Fix:
// resolve the directory from demo.route (strip the /demos/ prefix).
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "parity-route-"));
try {
writeFixturePackage(tmp, "route-split", {
manifest:
"slug: route-split\ndemos:\n - id: hitl-in-chat\n name: HITL\n route: /demos/hitl\n",
demoDirs: ["hitl"],
specFiles: ["hitl-in-chat.spec.ts"],
qaFiles: ["hitl-in-chat.md"],
});
const report = auditPackage("route-split", tmp);
// No missing-demo-dir MUST error: route-resolved dir "hitl" exists.
expect(
report.mustErrors.filter((e) => e.category === "missing-demo-dir"),
).toEqual([]);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
it("reports missing-demo-dir whose expectedDir is route-derived (not demo.id) when route exists and dir is missing", () => {
// Negative-case regression for the route-based resolution: when the
// manifest specifies demo.route, the MUST error's expectedDir must be
// derived from the route (not from demo.id). Otherwise a rename where
// id and route diverge produces a misleading error pointing at a
// directory that was never the intended target.
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "parity-route-miss-"));
try {
writeFixturePackage(tmp, "route-missdir", {
manifest:
"slug: route-missdir\ndemos:\n - id: hitl-in-chat\n route: /demos/hitl\n",
// Intentionally create a directory that matches the catalog id but
// NOT the route. Without route-aware resolution, the walker would
// see "hitl-in-chat/" and pass; the fix makes it look for "hitl/"
// which is absent, surfacing the real drift.
demoDirs: ["hitl-in-chat"],
specFiles: ["hitl-in-chat.spec.ts"],
qaFiles: ["hitl-in-chat.md"],
});
const report = auditPackage("route-missdir", tmp);
const missing = report.mustErrors.filter(
(e) => e.category === "missing-demo-dir",
);
expect(missing.length).toBe(1);
// The issue carries the structured fields, not just a rendered line;
// assert the route-derived expectedDir is what we expect.
const issue = missing[0];
if (issue.category === "missing-demo-dir") {
expect(issue.demoId).toBe("hitl-in-chat");
expect(issue.expectedDir).toBe("hitl");
}
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
it("falls back to demo.id for directory resolution when route is omitted", () => {
// Fallback-path regression: without demo.route, the resolver must use
// demo.id as the expectedDir (pre-route-field behaviour). A demo whose
// id matches the on-disk dir must pass clean with no missing-demo-dir
// error even though no route field is declared.
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "parity-route-fall-"));
try {
writeFixturePackage(tmp, "route-fallback", {
manifest: "slug: route-fallback\ndemos:\n - id: agentic-chat\n",
demoDirs: ["agentic-chat"],
specFiles: ["agentic-chat.spec.ts"],
qaFiles: ["agentic-chat.md"],
});
const report = auditPackage("route-fallback", tmp);
expect(
report.mustErrors.filter((e) => e.category === "missing-demo-dir"),
).toEqual([]);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
it("flags a demo with no spec file as a WARNING, not a MUST error", () => {
const report = auditPackage("missing-spec", FIXTURES_DIR);
expect(report.mustErrors).toEqual([]);
expect(
report.warnings.some(
(w) =>
w.category === "missing-spec" &&
/no tests\/e2e\/chat\.spec\.ts/.test(deriveMessage(w)),
),
).toBe(true);
});
it("does NOT warn when spec count exceeds demo count (legitimate extras)", () => {
// A cross-demo spec (e.g. one covering renderer selection) is allowed.
const report = auditPackage("spec-exceeds", FIXTURES_DIR);
expect(
report.warnings.some((w) => w.category === "spec-under-coverage"),
).toBe(false);
});
it("does NOT warn when spec count equals demo count (equality boundary)", () => {
// Pin the boundary: the under-coverage warning uses strict `<`, so an
// exact match must not warn. Regression guard against accidentally
// tightening the comparison.
const report = auditPackage("spec-equal", FIXTURES_DIR);
expect(
report.warnings.some((w) => w.category === "spec-under-coverage"),
).toBe(false);
});
it("warns when spec count is less than demo count", () => {
const report = auditPackage("spec-less", FIXTURES_DIR);
expect(
report.warnings.some(
(w) =>
w.category === "spec-under-coverage" &&
/spec count.*<.*demo count/.test(deriveMessage(w)),
),
).toBe(true);
});
it("warns with qa-under-coverage when qa count < demo count", () => {
// Fixture: 2 demos but only 1 QA doc. Distinct from spec-less (which
// omits a spec). Exercises the qa-under-coverage branch via a
// direct in-process auditPackage call (no CLI subprocess).
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "parity-qaundercov-"));
try {
writeFixturePackage(tmp, "qa-less", {
manifest:
"slug: qa-less\ndemos:\n - id: chat\n name: Chat\n - id: tools\n name: Tools\n",
demoDirs: ["chat", "tools"],
specFiles: ["chat.spec.ts", "tools.spec.ts"],
qaFiles: ["chat.md"],
});
const report = auditPackage("qa-less", tmp);
expect(
report.warnings.some(
(w) =>
w.category === "qa-under-coverage" &&
/qa count.*<.*demo count/.test(deriveMessage(w)),
),
).toBe(true);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
it("warns with missing-qa category when a demo has spec but no qa file", () => {
// Fixture: demo has a spec but no qa — tests the per-demo
// missing-qa branch explicitly, distinct from the bucket-wide
// qa-under-coverage warning.
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "parity-missqa-"));
try {
writeFixturePackage(tmp, "no-qa", {
manifest: "slug: no-qa\ndemos:\n - id: chat\n name: Chat\n",
demoDirs: ["chat"],
specFiles: ["chat.spec.ts"],
});
const report = auditPackage("no-qa", tmp);
expect(
report.warnings.some(
(w) =>
w.category === "missing-qa" &&
w.demoId === "chat" &&
/no qa\/chat\.md/.test(deriveMessage(w)),
),
).toBe(true);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
it("emits baseline-deviation warning via direct auditPackage when demo count != baseline", () => {
// Direct in-process call (no CLI subprocess) asserting the
// structured baseline-deviation category, complementing the
// existing CLI-level coverage.
const report = auditPackage("ok-pkg", FIXTURES_DIR, 5);
expect(
report.warnings.some(
(w) =>
w.category === "baseline-deviation" &&
/demo count 1 deviates from baseline 5/.test(deriveMessage(w)),
),
).toBe(true);
});
it("uses structured warning category assertions instead of [WARN] prefix text", () => {
// Replaces brittle stderr [WARN] prefix scraping. Assert on the
// typed `warnings` array shape directly.
const report = auditPackage("missing-spec", FIXTURES_DIR);
const missingSpec = report.warnings.find(
(w) => w.category === "missing-spec",
);
expect(missingSpec).toBeDefined();
if (missingSpec && missingSpec.category === "missing-spec") {
expect(missingSpec.demoId).toBe("chat");
expect(deriveMessage(missingSpec)).toMatch(
/no tests\/e2e\/chat\.spec\.ts/,
);
}
});
it("elevates unreadable demos dir to MUST with unreadable-demos-dir and suppresses missing-demo-dir cascade", () => {
// When src/app/demos/ is unreadable, the legacy behaviour was to
// report a listing-failed WARNING plus N missing-demo-dir MUST
// errors (one per declared demo). The root cause (EACCES on demos/)
// was buried. New contract: one MUST error of category
// "unreadable-demos-dir", no missing-demo-dir cascade.
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "parity-demosunread-"));
try {
const pkgDir = path.join(tmp, "demos-unreadable");
fs.mkdirSync(path.join(pkgDir, "src", "app", "demos"), {
recursive: true,
});
fs.writeFileSync(
path.join(pkgDir, "manifest.yaml"),
"slug: demos-unreadable\ndemos:\n - id: a\n - id: b\n - id: c\n",
"utf-8",
);
const warnSpy = vi.spyOn(console, "error").mockImplementation(() => {});
// Capture the real readdirSync BEFORE spyOn replaces it. Any
// reference to `fs.readdirSync` after spyOn resolves to the spy
// itself, so using that as the fallback would recurse forever on
// unrelated reads.
const origReaddir = fs.readdirSync;
const readdirSpy = vi.spyOn(fs, "readdirSync").mockImplementation(((
p: fs.PathLike,
...rest: unknown[]
) => {
const s = typeof p === "string" ? p : p.toString();
if (
s.endsWith(`${path.sep}src${path.sep}app${path.sep}demos`) ||
s.endsWith("/src/app/demos")
) {
const e: NodeJS.ErrnoException = Object.assign(
new Error("EACCES: permission denied"),
{ code: "EACCES" },
);
throw e;
}
return (
origReaddir as unknown as (
p: fs.PathLike,
...rest: unknown[]
) => unknown
).call(fs, p, ...rest);
}) as unknown as typeof fs.readdirSync);
try {
const report = auditPackage("demos-unreadable", tmp);
// MUST contains exactly one unreadable-demos-dir, no per-demo cascade.
const unreadable = report.mustErrors.filter(
(e) => e.category === "unreadable-demos-dir",
);
expect(unreadable.length).toBe(1);
const missingDemoDir = report.mustErrors.filter(
(e) => e.category === "missing-demo-dir",
);
expect(missingDemoDir.length).toBe(0);
// And the warnings array must NOT still carry the listing-failed
// entry for demos dir (it was elevated, not duplicated).
const listingFailedDemos = report.warnings.filter(
(w) =>
w.category === "listing-failed" && /src\/app\/demos/.test(w.path),
);
expect(listingFailedDemos.length).toBe(0);
} finally {
readdirSpy.mockRestore();
warnSpy.mockRestore();
}
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
it("elevates unreadable tests/e2e dir to MUST with unreadable-specs-dir and suppresses missing-spec cascade", () => {
// When tests/e2e/ is unreadable (EACCES etc.), the legacy
// behaviour was to report a listing-failed WARNING plus N
// missing-spec WARNING entries (one per declared demo), burying
// the EACCES root cause. New contract: one MUST error of
// category "unreadable-specs-dir", no per-demo missing-spec
// cascade, and the listing-failed warning is elevated (not
// duplicated into the warnings array).
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "parity-specsunread-"));
try {
const pkgDir = path.join(tmp, "specs-unreadable");
fs.mkdirSync(path.join(pkgDir, "src", "app", "demos", "a"), {
recursive: true,
});
fs.mkdirSync(path.join(pkgDir, "src", "app", "demos", "b"), {
recursive: true,
});
fs.mkdirSync(path.join(pkgDir, "src", "app", "demos", "c"), {
recursive: true,
});
fs.mkdirSync(path.join(pkgDir, "tests", "e2e"), { recursive: true });
fs.mkdirSync(path.join(pkgDir, "qa"), { recursive: true });
fs.writeFileSync(path.join(pkgDir, "qa", "a.md"), "", "utf-8");
fs.writeFileSync(path.join(pkgDir, "qa", "b.md"), "", "utf-8");
fs.writeFileSync(path.join(pkgDir, "qa", "c.md"), "", "utf-8");
fs.writeFileSync(
path.join(pkgDir, "manifest.yaml"),
"slug: specs-unreadable\ndemos:\n - id: a\n - id: b\n - id: c\n",
"utf-8",
);
const warnSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const origReaddir = fs.readdirSync;
const readdirSpy = vi.spyOn(fs, "readdirSync").mockImplementation(((
p: fs.PathLike,
...rest: unknown[]
) => {
const s = typeof p === "string" ? p : p.toString();
if (
s.endsWith(`${path.sep}tests${path.sep}e2e`) ||
s.endsWith("/tests/e2e")
) {
const e: NodeJS.ErrnoException = Object.assign(
new Error("EACCES: permission denied"),
{ code: "EACCES" },
);
throw e;
}
return (
origReaddir as unknown as (
p: fs.PathLike,
...rest: unknown[]
) => unknown
).call(fs, p, ...rest);
}) as unknown as typeof fs.readdirSync);
try {
const report = auditPackage("specs-unreadable", tmp);
const unreadable = report.mustErrors.filter(
(e) => e.category === "unreadable-specs-dir",
);
expect(unreadable.length).toBe(1);
// No missing-spec cascade.
const missingSpec = report.warnings.filter(
(w) => w.category === "missing-spec",
);
expect(missingSpec.length).toBe(0);
// listing-failed for tests/e2e was elevated, not duplicated.
const listingFailedSpecs = report.warnings.filter(
(w) => w.category === "listing-failed" && /tests\/e2e/.test(w.path),
);
expect(listingFailedSpecs.length).toBe(0);
// Aggregate spec-under-coverage warning also suppressed —
// otherwise operators see "0 < 3" coverage noise on top of
// the EACCES root cause, burying the actionable signal.
const specUnderCoverage = report.warnings.filter(
(w) => w.category === "spec-under-coverage",
);
expect(specUnderCoverage.length).toBe(0);
// Sanity: qa cascade NOT suppressed (unrelated dir).
const missingQa = report.warnings.filter(
(w) => w.category === "missing-qa",
);
expect(missingQa.length).toBe(0); // all qa present
} finally {
readdirSpy.mockRestore();
warnSpy.mockRestore();
}
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
it("elevates unreadable qa dir to MUST with unreadable-qa-dir and suppresses missing-qa cascade", () => {
// Same contract as unreadable-specs-dir: one MUST error of
// category "unreadable-qa-dir", missing-qa cascade suppressed,
// listing-failed entry elevated (not duplicated).
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "parity-qaunread-"));
try {
const pkgDir = path.join(tmp, "qa-unreadable");
fs.mkdirSync(path.join(pkgDir, "src", "app", "demos", "a"), {
recursive: true,
});
fs.mkdirSync(path.join(pkgDir, "src", "app", "demos", "b"), {
recursive: true,
});
fs.mkdirSync(path.join(pkgDir, "tests", "e2e"), { recursive: true });
fs.writeFileSync(
path.join(pkgDir, "tests", "e2e", "a.spec.ts"),
"",
"utf-8",
);
fs.writeFileSync(
path.join(pkgDir, "tests", "e2e", "b.spec.ts"),
"",
"utf-8",
);
fs.mkdirSync(path.join(pkgDir, "qa"), { recursive: true });
fs.writeFileSync(
path.join(pkgDir, "manifest.yaml"),
"slug: qa-unreadable\ndemos:\n - id: a\n - id: b\n",
"utf-8",
);
const warnSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const origReaddir = fs.readdirSync;
const readdirSpy = vi.spyOn(fs, "readdirSync").mockImplementation(((
p: fs.PathLike,
...rest: unknown[]
) => {
const s = typeof p === "string" ? p : p.toString();
if (s.endsWith(`${path.sep}qa`) || s.endsWith("/qa")) {
const e: NodeJS.ErrnoException = Object.assign(
new Error("EACCES: permission denied"),
{ code: "EACCES" },
);
throw e;
}
return (
origReaddir as unknown as (
p: fs.PathLike,
...rest: unknown[]
) => unknown
).call(fs, p, ...rest);
}) as unknown as typeof fs.readdirSync);
try {
const report = auditPackage("qa-unreadable", tmp);
const unreadable = report.mustErrors.filter(
(e) => e.category === "unreadable-qa-dir",
);
expect(unreadable.length).toBe(1);
const missingQa = report.warnings.filter(
(w) => w.category === "missing-qa",
);
expect(missingQa.length).toBe(0);
const listingFailedQa = report.warnings.filter(
(w) => w.category === "listing-failed" && /\/qa$/.test(w.path),
);
expect(listingFailedQa.length).toBe(0);
// Aggregate qa-under-coverage warning also suppressed — an
// unreadable qa dir is the root cause; emitting "0 < 2"
// coverage noise on top would bury the actionable EACCES.
const qaUnderCoverage = report.warnings.filter(
(w) => w.category === "qa-under-coverage",
);
expect(qaUnderCoverage.length).toBe(0);
} finally {
readdirSpy.mockRestore();
warnSpy.mockRestore();
}
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
it("still lists spec/qa files after a manifest failure so reporter counts are accurate", () => {
// When manifest is malformed, MUST error still fires, but the
// reporter row benefits from knowing spec/qa counts. Early return
// was hiding those counts. New contract: specFiles / qaFiles
// populated with whatever is on disk.
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "parity-manfail-"));
try {
const pkgDir = path.join(tmp, "manifest-bad");
fs.mkdirSync(pkgDir, { recursive: true });
// Malformed manifest.
fs.writeFileSync(
path.join(pkgDir, "manifest.yaml"),
"slug: manifest-bad\ndemos:\n - id: a\n invalid: [unclosed\n",
"utf-8",
);
// But qa + spec files exist.
fs.mkdirSync(path.join(pkgDir, "tests", "e2e"), { recursive: true });
fs.writeFileSync(
path.join(pkgDir, "tests", "e2e", "a.spec.ts"),
"",
"utf-8",
);
fs.mkdirSync(path.join(pkgDir, "qa"), { recursive: true });
fs.writeFileSync(path.join(pkgDir, "qa", "a.md"), "", "utf-8");
const report = auditPackage("manifest-bad", tmp);
// MUST still gate the exit code.
expect(
report.mustErrors.some((e) => e.category === "malformed-manifest"),
).toBe(true);
// specFiles / qaFiles populated from disk, not empty.
expect(report.specFiles).toEqual(["a.spec.ts"]);
expect(report.qaFiles).toEqual(["a.md"]);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
it("auditPackage throws when baselineDemoCount is not a positive integer", () => {
// Defense-in-depth: callers outside runParity() can invoke
// auditPackage directly. Bad baseline values should fail fast, not
// silently warn or NaN-compare.
for (const bad of [0, -1, 1.5, NaN, Infinity, -Infinity]) {
expect(() => auditPackage("ok-pkg", FIXTURES_DIR, bad)).toThrow(
/baselineDemoCount/,
);
}
});
it("re-throws unknown (non-manifest) errors from loadManifest wrapped with the package slug for operator context", () => {
// If loadManifest throws an error that is NOT a
// ManifestMalformedError / ManifestUnreadableError — e.g. a
// TypeError surfaced from a bug — auditPackage must NOT silently
// bucket it as `malformed-manifest` (which would hide the real
// defect). The catch-all branch re-throws (wrapped with the
// slug for context, cause chain preserved) so the top-level CLI
// handler surfaces an [INTERNAL ERROR] with EXIT_INTERNAL and
// operators know which package triggered the crash.
//
// Targeted injection: use vi.spyOn on Object.freeze so vitest
// auto-restores at test end even if an assertion throws (the
// previous implementation assigned to `Object.freeze` directly
// and relied on a try/finally to unwind — a bug in the try block
// could leave Object.freeze broken for the whole process).
// parseManifest deep-freezes each validated demo object OUTSIDE
// the readFileSync / yaml.parse try/catch boundaries, so a
// synthetic TypeError thrown from freeze propagates past
// parseManifest's classifier and lands in auditPackage's
// catch-all re-throw branch — exactly the code path under test.
// We scope the freeze-throws to objects with a stringy `id`
// field, leaving unrelated freeze calls in the harness alone.
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "parity-unknown-err-"));
try {
const pkgDir = path.join(tmp, "ok-pkg");
fs.mkdirSync(pkgDir, { recursive: true });
fs.writeFileSync(
path.join(pkgDir, "manifest.yaml"),
"slug: ok-pkg\ndemos:\n - id: chat\n name: Chat\n",
"utf-8",
);
const sentinel = new TypeError("synthetic non-manifest failure");
const originalFreeze = Object.freeze;
const freezeSpy = vi.spyOn(Object, "freeze").mockImplementation(((
o: unknown,
) => {
// Narrow the predicate to only target manifest demo entries —
// objects that carry BOTH a stringy `id` and `name`. Matching
// on `id` alone would trip on any unrelated internal object
// that happens to expose an id field and cause spurious
// throws outside the code path under test.
if (
o !== null &&
typeof o === "object" &&
typeof (o as Record<string, unknown>).id === "string" &&
typeof (o as Record<string, unknown>).name === "string"
) {
throw sentinel;
}
return originalFreeze(o as object);
}) as typeof Object.freeze);
try {
// Behavioural guarantee: the error propagates (not bucketed as
// malformed-manifest) AND carries the slug for operator
// context, with the original sentinel preserved via `cause`.
let caught: unknown;
try {
auditPackage("ok-pkg", tmp);
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(Error);
expect((caught as Error).message).toMatch(/audit of ok-pkg crashed/);
// Outer message is context-only (no origMsg duplication);
// inner failure text is preserved via `cause`, which
// formatErrorChain unfurls for stderr.
const cause = (caught as Error & { cause?: unknown }).cause;
expect(cause).toBe(sentinel);
expect((cause as Error).message).toContain(
"synthetic non-manifest failure",
);
} finally {
freezeSpy.mockRestore();
}
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
it("rejects manifests whose inner slug disagrees with the directory slug (slug-mismatch guard wired via loadManifest)", () => {
// parseManifest accepts an optional dirSlug and returns
// a shape-malformed result when `slug:` in the YAML differs from
// the directory name on disk. loadManifest (and by extension
// auditPackage) must wire that guard in — otherwise a copy-paste
// or rename mistake silently keys into the wrong package.
const tmp = fs.mkdtempSync(
path.join(os.tmpdir(), "parity-slugmismatch-"),
);
try {
const pkgDir = path.join(tmp, "actual-slug");
fs.mkdirSync(pkgDir, { recursive: true });
fs.writeFileSync(
path.join(pkgDir, "manifest.yaml"),
"slug: declared-slug\ndemos: []\n",
"utf-8",
);
const report = auditPackage("actual-slug", tmp);
expect(
report.mustErrors.some(
(e) =>
e.category === "malformed-manifest" &&
/slug mismatch/.test(deriveMessage(e)),
),
JSON.stringify(report.mustErrors),
).toBe(true);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
});
describe("listDirs / listFiles graceful error handling", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "parity-test-"));
});
afterEach(() => {
// Restore permissions so cleanup succeeds, then rm. If chmod fails,
// let it throw — swallowing it silently would hide test-pollution.
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it("listDirs returns empty ListResult for a non-existent path without throwing", () => {
const result = listDirs(path.join(tmpDir, "does-not-exist"));
expect(result.entries).toEqual([]);
expect(result.warnings).toEqual([]);
});
it("listFiles returns empty ListResult for a non-existent path without throwing", () => {
const result = listFiles(path.join(tmpDir, "does-not-exist"), ".md");
expect(result.entries).toEqual([]);
expect(result.warnings).toEqual([]);
});
it("listDirs returns empty entries + a listing-failed warning on readdir failure without emitting stderr directly", () => {
// Mock readdirSync to throw — avoids chmod-based tests that fail
// as root or on Windows and that leave behind tmpdirs that
// cleanup can't remove.
//
// Contract: the helper does NOT write to stderr. Downstream
// (auditPackage → runParity) is responsible for emitting the
// single [WARN] line via deriveMessage. Helpers that log
// independently cause duplicate stderr output when the warning
// is also iterated by the caller.
const blocked = path.join(tmpDir, "blocked");
fs.mkdirSync(blocked);
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
// Filter by path so only reads of the synthetic `blocked` dir
// raise EACCES — vitest/tsx internals that may call readdirSync
// during module resolution fall through to the real impl.
const origReaddir = fs.readdirSync;
const readdirSpy = vi.spyOn(fs, "readdirSync").mockImplementation(((
p: fs.PathLike,
...rest: unknown[]
) => {
if (String(p) === blocked) {
const e: NodeJS.ErrnoException = Object.assign(
new Error("EACCES: permission denied"),
{ code: "EACCES" },
);
throw e;
}
return (origReaddir as (...a: unknown[]) => unknown)(
p,
...rest,
) as ReturnType<typeof fs.readdirSync>;
}) as unknown as typeof fs.readdirSync);
try {
const result = listDirs(blocked);
expect(result.entries).toEqual([]);
// Helper itself stays silent — caller owns the single emission.
expect(errSpy).not.toHaveBeenCalled();
// The warning is returned in the ListResult so the caller can
// merge it into the PackageReport and surface it exactly once.
expect(result.warnings.length).toBe(1);
expect(result.warnings[0].category).toBe("listing-failed");
expect(deriveMessage(result.warnings[0])).toMatch(
/failed to read directory .*blocked/,
);
} finally {
readdirSpy.mockRestore();
errSpy.mockRestore();
}
});
it("listFiles returns empty entries + a listing-failed warning on readdir failure without emitting stderr directly", () => {
const blocked = path.join(tmpDir, "blocked-files");
fs.mkdirSync(blocked);
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
// Filter by path so only reads of the synthetic `blocked` dir
// raise EACCES — vitest/tsx internals fall through to the real
// impl to avoid spurious failures during module resolution.
const origReaddir = fs.readdirSync;
const readdirSpy = vi.spyOn(fs, "readdirSync").mockImplementation(((
p: fs.PathLike,
...rest: unknown[]
) => {
if (String(p) === blocked) {
const e: NodeJS.ErrnoException = Object.assign(
new Error("EACCES: permission denied"),
{ code: "EACCES" },
);
throw e;
}
return (origReaddir as (...a: unknown[]) => unknown)(
p,
...rest,
) as ReturnType<typeof fs.readdirSync>;
}) as unknown as typeof fs.readdirSync);
try {
const result = listFiles(blocked, ".md");
expect(result.entries).toEqual([]);
// Helper itself stays silent — caller owns the single emission.
expect(errSpy).not.toHaveBeenCalled();
expect(result.warnings.length).toBe(1);
expect(result.warnings[0].category).toBe("listing-failed");
expect(deriveMessage(result.warnings[0])).toMatch(
/failed to read directory .*blocked-files/,
);
} finally {
readdirSpy.mockRestore();
errSpy.mockRestore();
}
});
it("runParity emits exactly one [WARN] line per listing failure (no duplicate from helper)", () => {
// End-to-end guarantee for the single-emission contract:
// auditPackage routes listing-failed through warnings[] and
// runParity iterates warnings to emit one line each. The helper
// must not also log, otherwise the operator sees N*2 lines.
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "parity-singleemit-"));
try {
// Build a package with a readable demos dir but unreadable
// tests/e2e — listDirs succeeds on demos, listFiles fails on
// tests/e2e producing ONE listing-failed warning (which then
// gets elevated to unreadable-specs-dir MUST — so we instead
// trigger a scenario that leaves listing-failed in warnings).
//
// Simpler: unreadable a non-elevated path by making demos dir
// exist but inaccessible at a non-target sub-location. The
// existing elevation only suppresses duplicates for the three
// known target paths, so to assert the no-duplicate property we
// pick a target path (tests/e2e) and verify elevation path ALSO
// emits no duplicate stderr line.
const pkgDir = path.join(tmp, "one-warn");
fs.mkdirSync(path.join(pkgDir, "src", "app", "demos", "a"), {
recursive: true,
});
fs.mkdirSync(path.join(pkgDir, "tests", "e2e"), { recursive: true });
fs.mkdirSync(path.join(pkgDir, "qa"), { recursive: true });
fs.writeFileSync(path.join(pkgDir, "qa", "a.md"), "", "utf-8");
fs.writeFileSync(
path.join(pkgDir, "manifest.yaml"),
"slug: one-warn\ndemos:\n - id: a\n",
"utf-8",
);
const origReaddir = fs.readdirSync;
const readdirSpy = vi.spyOn(fs, "readdirSync").mockImplementation(((
p: fs.PathLike,
...rest: unknown[]
) => {
const s = typeof p === "string" ? p : p.toString();
if (
s.endsWith(`${path.sep}tests${path.sep}e2e`) ||
s.endsWith("/tests/e2e")
) {
const e: NodeJS.ErrnoException = Object.assign(
new Error("EACCES: permission denied"),
{ code: "EACCES" },
);
throw e;
}
return (
origReaddir as unknown as (
p: fs.PathLike,
...rest: unknown[]
) => unknown
).call(fs, p, ...rest);
}) as unknown as typeof fs.readdirSync);
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
try {
runParity(tmp, 1);
// Count the number of stderr lines that reference the
// tests/e2e path — we want exactly one (elevated as
// unreadable-specs-dir MUST), not one from the helper + one
// from the caller iteration.
const lines = errSpy.mock.calls.map((c) => String(c[0]));
const e2eRefs = lines.filter((l) => /tests\/e2e/.test(l));
expect(e2eRefs.length, lines.join("\n")).toBe(1);
} finally {
readdirSpy.mockRestore();
errSpy.mockRestore();
logSpy.mockRestore();
}
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
it("listDirs surfaces EACCES (unreadable) as listing-failed, not as silent 'missing'", () => {
// Regression: fs.existsSync returns false for EACCES just as it
// does for ENOENT, so using existsSync as the "missing" gate
// silently swallows permission errors. Contract: missing paths
// return empty entries + NO warning, but unreadable paths return
// empty entries + a listing-failed warning so the caller can
// surface the real failure.
const target = path.join(tmpDir, "eacces-dir");
fs.mkdirSync(target);
// Spy statSync to throw EACCES for our target path only; fall
// through for every other path so tsx / vitest internals keep
// working. readdirSync isn't reached when the guard still uses
// existsSync (old code path), which is what makes this test fail
// on the old implementation.
const realStatSync = fs.statSync;
const statSpy = vi.spyOn(fs, "statSync").mockImplementation(((
p: fs.PathLike,
...rest: unknown[]
) => {
const s = typeof p === "string" ? p : p.toString();
if (s === target) {
const e: NodeJS.ErrnoException = Object.assign(
new Error("EACCES: permission denied"),
{ code: "EACCES" },
);
throw e;
}
return (
realStatSync as unknown as (
p: fs.PathLike,
...rest: unknown[]
) => unknown
)(p, ...rest);
}) as unknown as typeof fs.statSync);
try {
const result = listDirs(target);
expect(result.entries).toEqual([]);
// The key regression assertion: EACCES must NOT be silently
// collapsed into "missing" (warnings: []). It must surface as
// listing-failed so the caller can escalate to
// unreadable-demos-dir / unreadable-specs-dir / etc.
expect(result.warnings.length, JSON.stringify(result)).toBe(1);
expect(result.warnings[0].category).toBe("listing-failed");
expect(deriveMessage(result.warnings[0])).toMatch(/EACCES/);
} finally {
statSpy.mockRestore();
}
});
it("listFiles surfaces EACCES (unreadable) as listing-failed, not as silent 'missing'", () => {
// Same contract as listDirs above — existsSync returning false on
// EACCES masks the permission failure as a legitimately-missing
// directory, which silently drops an entire class of validation.
const target = path.join(tmpDir, "eacces-files");
fs.mkdirSync(target);
const realStatSync = fs.statSync;
const statSpy = vi.spyOn(fs, "statSync").mockImplementation(((
p: fs.PathLike,
...rest: unknown[]
) => {
const s = typeof p === "string" ? p : p.toString();
if (s === target) {
const e: NodeJS.ErrnoException = Object.assign(
new Error("EACCES: permission denied"),
{ code: "EACCES" },
);
throw e;
}
return (
realStatSync as unknown as (
p: fs.PathLike,
...rest: unknown[]
) => unknown
)(p, ...rest);
}) as unknown as typeof fs.statSync);
try {
const result = listFiles(target, ".md");
expect(result.entries).toEqual([]);
expect(result.warnings.length, JSON.stringify(result)).toBe(1);
expect(result.warnings[0].category).toBe("listing-failed");
expect(deriveMessage(result.warnings[0])).toMatch(/EACCES/);
} finally {
statSpy.mockRestore();
}
});
it("listDirs preserves ENOENT contract (missing path → empty entries + no warning)", () => {
// Regression guard for the OTHER side of the existsSync → statSync
// migration: genuinely-missing paths must still return silently
// so callers don't start seeing spurious warnings for packages
// that legitimately omit a tests/e2e or qa directory.
const result = listDirs(path.join(tmpDir, "definitely-not-there"));
expect(result.entries).toEqual([]);
expect(result.warnings).toEqual([]);
});
it("listFiles preserves ENOENT contract (missing path → empty entries + no warning)", () => {
const result = listFiles(
path.join(tmpDir, "definitely-not-there"),
".md",
);
expect(result.entries).toEqual([]);
expect(result.warnings).toEqual([]);
});
it("listDirs surfaces ENOTDIR (path component is a file) as listing-failed, not silent 'missing'", () => {
// Regression: ENOTDIR means "a component in the path is a
// regular file, not a directory" — e.g. someone committed a
// stray file at integrations/foo/tests and we try to walk into
// integrations/foo/tests/e2e. That's a misconfiguration (not a
// legitimately-absent directory), and collapsing it into
// { kind: "missing" } silently drops the entire subtree from
// parity checks with zero warning, zero anomaly surfaced.
// Contract: ENOTDIR must produce a listing-failed warning so
// the caller can escalate (unreadable-*-dir / listing-failed).
const stray = path.join(tmpDir, "stray-file");
fs.writeFileSync(stray, "not a directory", "utf-8");
// Probe a path that tries to descend *through* the regular
// file — statSync on "stray-file/e2e" throws ENOTDIR because
// a path component (stray-file) isn't a directory.
const target = path.join(stray, "e2e");
const result = listDirs(target);
expect(result.entries).toEqual([]);
expect(result.warnings.length, JSON.stringify(result)).toBe(1);
expect(result.warnings[0].category).toBe("listing-failed");
expect(result.warnings[0].path).toBe(target);
expect(deriveMessage(result.warnings[0])).toMatch(/ENOTDIR/);
});
it("listFiles surfaces ENOTDIR (path component is a file) as listing-failed, not silent 'missing'", () => {
// Same contract as listDirs above. ENOTDIR from a non-dir path
// component is a misconfiguration signal, not "missing".
const stray = path.join(tmpDir, "stray-file-files");
fs.writeFileSync(stray, "not a directory", "utf-8");
const target = path.join(stray, "specs");
const result = listFiles(target, ".md");
expect(result.entries).toEqual([]);
expect(result.warnings.length, JSON.stringify(result)).toBe(1);
expect(result.warnings[0].category).toBe("listing-failed");
expect(result.warnings[0].path).toBe(target);
expect(deriveMessage(result.warnings[0])).toMatch(/ENOTDIR/);
});
it("listDirs / listFiles return results in sorted order", () => {
// Filesystems don't guarantee iteration order, so auditPackage's
// reports could flake without explicit sorting. Pin sorted output.
const dir = path.join(tmpDir, "ordering");
fs.mkdirSync(dir, { recursive: true });
fs.mkdirSync(path.join(dir, "zeta"));
fs.mkdirSync(path.join(dir, "alpha"));
fs.mkdirSync(path.join(dir, "mu"));
fs.writeFileSync(path.join(dir, "zeta.md"), "", "utf-8");
fs.writeFileSync(path.join(dir, "alpha.md"), "", "utf-8");
fs.writeFileSync(path.join(dir, "mu.md"), "", "utf-8");
expect(listDirs(dir).entries).toEqual(["alpha", "mu", "zeta"]);
expect(listFiles(dir, ".md").entries).toEqual([
"alpha.md",
"mu.md",
"zeta.md",
]);
});
});
describe("module exports", () => {
it("does not invoke main() on import (isMain guard is in place)", () => {
// A dynamic `await import(...)` in-process is trivially
// non-throwing because the module was already loaded earlier in
// this test file, so we probe via a fresh subprocess that imports
// the script without invoking it (no direct argv match). Any
// exit code other than 0 indicates main() ran on import.
const probe = `
import("${PARITY_SCRIPT.replace(/\\/g, "\\\\")}").then(() => {
process.exit(0);
}).catch((err) => {
console.error(err?.stack ?? err);
process.exit(2);
});
`;
// Use NODE_OPTIONS to load tsx's ESM loader so the dynamic import
// of a .ts file succeeds. stdout/stderr captured for diagnostics.
const r = spawnSync("node", ["--import", "tsx", "-e", probe], {
encoding: "utf-8",
timeout: 30_000,
env: { ...process.env },
});
expect(r.status, `stdout=${r.stdout}\nstderr=${r.stderr}`).toBe(0);
// Importing the module for side effects only — stdout should be
// empty because main() was NOT invoked (no pass/summary table).
expect(r.stdout).not.toMatch(/package\(s\) checked/);
});
});
describe("main() exit codes via CLI subprocess", () => {
let tree: { root: string; packagesDir: string };
beforeEach(() => {
tree = makeMainTree();
});
afterEach(() => {
fs.rmSync(tree.root, { recursive: true, force: true });
});
function runCli(args: string[], opts: { env?: NodeJS.ProcessEnv } = {}) {
return spawnSync("npx", ["tsx", PARITY_SCRIPT, ...args], {
env: { ...process.env, ...opts.env },
encoding: "utf-8",
timeout: 30_000,
});
}
it("exits 0 when there are no MUST failures", () => {
writeFixturePackage(tree.packagesDir, "ok-pkg", {
manifest: "slug: ok-pkg\ndemos:\n - id: chat\n name: Chat\n",
demoDirs: ["chat"],
specFiles: ["chat.spec.ts"],
qaFiles: ["chat.md"],
});
const r = runCli([], {
env: { VALIDATE_PARITY_REPO_ROOT: tree.root },
});
expect(r.status, (r.stdout ?? "") + (r.stderr ?? "")).toBe(0);
});
it("exits 1 when a MUST failure is present", () => {
// missing demos/<id>/ dir triggers a MUST failure.
writeFixturePackage(tree.packagesDir, "bad", {
manifest: "slug: bad\ndemos:\n - id: chat\n name: Chat\n",
});
const r = runCli([], {
env: { VALIDATE_PARITY_REPO_ROOT: tree.root },
});
expect(r.status, (r.stdout ?? "") + (r.stderr ?? "")).toBe(1);
});
it("exits 3 (unreadable) when packages dir does not exist", () => {
// tree.root exists but integrations/ was just removed.
fs.rmSync(tree.packagesDir, { recursive: true, force: true });
const r = runCli([], {
env: { VALIDATE_PARITY_REPO_ROOT: tree.root },
});
expect(r.status, (r.stdout ?? "") + (r.stderr ?? "")).toBe(3);
// Tighten from /integrations/i — that would also match log lines that
// merely name the string "integrations". Pin the actual failure
// message so a regression that exits 3 for some other reason
// can't slip through.
expect(r.stderr).toMatch(/packages directory not found/);
});
it("exits 3 (unreadable) when readdirSync on packages dir throws", () => {
// Make integrations/ exist but unreadable by the current process. Rather
// than chmod 0 (flaky as root / on Windows), we invoke the script
// with a packages dir that resolves to a regular file instead of a
// directory — existsSync succeeds, readdirSync throws ENOTDIR. This
// exercises the post-existsSync readdirSync-throws branch.
fs.rmSync(tree.packagesDir, { recursive: true, force: true });
fs.writeFileSync(tree.packagesDir, "not a dir", "utf-8");
const r = runCli([], {
env: { VALIDATE_PARITY_REPO_ROOT: tree.root },
});
expect(r.status, (r.stdout ?? "") + (r.stderr ?? "")).toBe(3);
expect(r.stderr).toMatch(/could not read packages directory/);
});
it("respects VALIDATE_PARITY_REPO_ROOT env-var override", () => {
// Populate the fixture root and assert main() honors the env var
// rather than the default packages dir (which would walk real
// packages under showcase/integrations/).
writeFixturePackage(tree.packagesDir, "only-pkg", {
manifest: "slug: only-pkg\ndemos:\n - id: chat\n name: Chat\n",
demoDirs: ["chat"],
specFiles: ["chat.spec.ts"],
qaFiles: ["chat.md"],
});
const r = runCli([], {
env: { VALIDATE_PARITY_REPO_ROOT: tree.root },
});
expect(r.status, (r.stdout ?? "") + (r.stderr ?? "")).toBe(0);
// The summary line counts packages; with the env-var honored we should
// see exactly 1 (the fixture), not the real showcase slug count.
expect(r.stdout).toMatch(/1 package\(s\) checked/);
});
it("honors --baseline=N and emits a baseline-deviation warning at the given value", () => {
// One demo with baseline=5 → deviation warning
writeFixturePackage(tree.packagesDir, "only-pkg", {
manifest: "slug: only-pkg\ndemos:\n - id: chat\n name: Chat\n",
demoDirs: ["chat"],
specFiles: ["chat.spec.ts"],
qaFiles: ["chat.md"],
});
const r = runCli(["--baseline=5"], {
env: { VALIDATE_PARITY_REPO_ROOT: tree.root },
});
// Exit 0 because deviation is a SHOULD warning, not a MUST error.
expect(r.status, (r.stdout ?? "") + (r.stderr ?? "")).toBe(0);
expect(r.stderr).toMatch(/demo count 1 deviates from baseline 5/);
});
it("honors VALIDATE_PARITY_BASELINE env and emits a baseline-deviation warning", () => {
writeFixturePackage(tree.packagesDir, "only-pkg", {
manifest: "slug: only-pkg\ndemos:\n - id: chat\n name: Chat\n",
demoDirs: ["chat"],
specFiles: ["chat.spec.ts"],
qaFiles: ["chat.md"],
});
const r = runCli([], {
env: {
VALIDATE_PARITY_REPO_ROOT: tree.root,
VALIDATE_PARITY_BASELINE: "3",
},
});
expect(r.status, (r.stdout ?? "") + (r.stderr ?? "")).toBe(0);
expect(r.stderr).toMatch(/demo count 1 deviates from baseline 3/);
});
it.each([
["abc"],
["NaN"],
["0"],
["-1"],
["1.5"],
[" "],
["0x10"],
["1e2"],
])(
"rejects invalid VALIDATE_PARITY_BASELINE=%j with exit 2 (EXIT_INVALID_INPUT)",
(raw: string) => {
writeFixturePackage(tree.packagesDir, "only-pkg", {
manifest: "slug: only-pkg\ndemos:\n - id: chat\n name: Chat\n",
demoDirs: ["chat"],
specFiles: ["chat.spec.ts"],
qaFiles: ["chat.md"],
});
const r = runCli([], {
env: {
VALIDATE_PARITY_REPO_ROOT: tree.root,
VALIDATE_PARITY_BASELINE: raw,
},
});
// Invalid CLI input should be distinguishable from a legitimate
// MUST failure (exit 1). audit.ts uses exit 2 for the same
// category of error — keep the taxonomy aligned across tools.
expect(r.status, (r.stdout ?? "") + (r.stderr ?? "")).toBe(2);
expect(r.stderr).toMatch(/VALIDATE_PARITY_BASELINE/);
},
);
it("rejects invalid --baseline=abc with exit 2 (EXIT_INVALID_INPUT)", () => {
writeFixturePackage(tree.packagesDir, "only-pkg", {
manifest: "slug: only-pkg\ndemos:\n - id: chat\n name: Chat\n",
demoDirs: ["chat"],
specFiles: ["chat.spec.ts"],
qaFiles: ["chat.md"],
});
const r = runCli(["--baseline=abc"], {
env: { VALIDATE_PARITY_REPO_ROOT: tree.root },
});
expect(r.status, (r.stdout ?? "") + (r.stderr ?? "")).toBe(2);
expect(r.stderr).toMatch(/invalid --baseline value/);
});
it("rejects unrecognised args (e.g. typo --basline=10) with exit 2 (EXIT_INVALID_INPUT)", () => {
// Mirror audit.ts parseArgs: unknown arguments must be flagged
// loudly, not silently ignored. A typo like `--basline=10` (or a
// space-separated `--baseline 9` that accidentally splits) would
// previously run the validator with the default baseline while the
// user believed they had set one. Surface the mistake instead.
writeFixturePackage(tree.packagesDir, "only-pkg", {
manifest: "slug: only-pkg\ndemos:\n - id: chat\n name: Chat\n",
demoDirs: ["chat"],
specFiles: ["chat.spec.ts"],
qaFiles: ["chat.md"],
});
const r = runCli(["--baseline=9", "--basline=10"], {
env: { VALIDATE_PARITY_REPO_ROOT: tree.root },
});
expect(r.status, (r.stdout ?? "") + (r.stderr ?? "")).toBe(2);
expect(r.stderr).toMatch(/unrecognised argument|unknown argument/i);
expect(r.stderr).toMatch(/--basline=10/);
});
it("rejects duplicate --baseline= with exit 2 (EXIT_INVALID_INPUT)", () => {
// Mirror audit.ts parseArgs: CI shell concatenation is a common
// source of accidental duplicates and "last wins" hides the user's
// first intent. Reject duplicates explicitly so the mistake is
// visible.
writeFixturePackage(tree.packagesDir, "only-pkg", {
manifest: "slug: only-pkg\ndemos:\n - id: chat\n name: Chat\n",
demoDirs: ["chat"],
specFiles: ["chat.spec.ts"],
qaFiles: ["chat.md"],
});
const r = runCli(["--baseline=9", "--baseline=12"], {
env: { VALIDATE_PARITY_REPO_ROOT: tree.root },
});
expect(r.status, (r.stdout ?? "") + (r.stderr ?? "")).toBe(2);
expect(r.stderr).toMatch(/specified more than once|duplicate/i);
});
it("exits 4 (EXIT_INTERNAL) on unexpected exceptions surfaced from auditPackage", () => {
// Model: audit.test.ts T7 — preload a shim that forces a
// downstream TypeError so the top-level CLI catch routes it to
// EXIT_INTERNAL. Here we monkey-patch Object.freeze to throw
// when parseManifest tries to freeze a validated demo (stringy
// `id`). The TypeError is NOT a ManifestMalformed/Unreadable, so
// it escapes loadManifest's switch, lands in auditPackage's
// catch-all re-throw (which wraps with the package slug), and
// bubbles to validate-parity's top-level try/catch → exit 4.
writeFixturePackage(tree.packagesDir, "boom-pkg", {
manifest: "slug: boom-pkg\ndemos:\n - id: chat\n name: Chat\n",
demoDirs: ["chat"],
specFiles: ["chat.spec.ts"],
qaFiles: ["chat.md"],
});
const preload = fs.mkdtempSync(path.join(os.tmpdir(), "parity-preload-"));
const preloadScript = path.join(preload, "boom.cjs");
fs.writeFileSync(
preloadScript,
`const origFreeze = Object.freeze;
// Narrow the predicate to only target manifest demo entries —
// objects that carry BOTH a stringy \`id\` and \`name\`. Matching
// on \`id\` alone would trip on any unrelated internal object
// (V8 intrinsics, Node internals, tsx loader bits) that happens
// to expose an id field and cause spurious throws outside the
// code path under test. Keep this in sync with the in-process
// counterpart below.
Object.freeze = function(o) {
if (o !== null && typeof o === "object" &&
typeof o.id === "string" && typeof o.name === "string") {
throw new TypeError("synthetic non-manifest failure");
}
return origFreeze(o);
};
`,
);
try {
const r = spawnSync(
"npx",
["tsx", "--require", preloadScript, PARITY_SCRIPT],
{
env: { ...process.env, VALIDATE_PARITY_REPO_ROOT: tree.root },
encoding: "utf-8",
timeout: 30_000,
},
);
// EXIT_INTERNAL (4) — distinct from MUST failure (1) and
// unreadable infra (3), per the taxonomy in validate-parity.ts.
expect(r.status, (r.stdout ?? "") + (r.stderr ?? "")).toBe(4);
expect(r.stderr).toMatch(/\[INTERNAL ERROR\] validate-parity crashed/);
// Slug context — auditPackage's catch-all wraps the inner
// error so operators can identify which package triggered the
// crash without diff-ing stacks.
expect(r.stderr).toMatch(/audit of boom-pkg crashed/);
} finally {
fs.rmSync(preload, { recursive: true, force: true });
}
});
it("runParity returns EXIT_INTERNAL (4) instead of throwing when auditPackage surfaces an unexpected error", () => {
// In-process callers (tests, composed CLIs) must receive a
// numeric exit code, never a raw exception —
// otherwise callers have to duplicate the top-level try/catch that
// `main()` uses. Drive the same failure path as the CLI test above
// (Object.freeze throws a non-manifest TypeError), but call
// runParity directly in-process and assert on the return value.
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "parity-inproc-err-"));
try {
const pkgDir = path.join(tmp, "boom-pkg");
fs.mkdirSync(pkgDir, { recursive: true });
fs.writeFileSync(
path.join(pkgDir, "manifest.yaml"),
"slug: boom-pkg\ndemos:\n - id: chat\n name: Chat\n",
"utf-8",
);
const originalFreeze = Object.freeze;
const freezeSpy = vi.spyOn(Object, "freeze").mockImplementation(((
o: unknown,
) => {
// Narrow the predicate to only target manifest demo entries —
// objects that carry BOTH a stringy `id` and `name`. Matching
// on `id` alone would trip on any unrelated internal object
// that happens to expose an id field and cause spurious
// throws outside the code path under test.
if (
o !== null &&
typeof o === "object" &&
typeof (o as Record<string, unknown>).id === "string" &&
typeof (o as Record<string, unknown>).name === "string"
) {
throw new TypeError("synthetic non-manifest failure");
}
return originalFreeze(o as object);
}) as typeof Object.freeze);
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
try {
// Must return EXIT_INTERNAL (4) — runParity's outer try/catch
// should trap the wrapped Error thrown by auditPackage and
// convert it to the numeric taxonomy without letting the
// exception escape to the caller.
let returned: unknown;
let threw = false;
try {
returned = runParity(tmp, 1);
} catch {
threw = true;
}
expect(threw, "runParity must not throw to in-process callers").toBe(
false,
);
expect(returned).toBe(4);
// Stderr must carry both the outer wrapping context AND the
// root cause, surfaced by walking the error cause chain so
// operators see the underlying failure text.
const stderr = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
expect(stderr).toMatch(/\[INTERNAL ERROR\] validate-parity crashed/);
expect(stderr).toMatch(/audit of boom-pkg crashed/);
expect(stderr).toMatch(/caused by:/);
expect(stderr).toMatch(/synthetic non-manifest failure/);
} finally {
freezeSpy.mockRestore();
errSpy.mockRestore();
logSpy.mockRestore();
}
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
it("isolates a single crashing slug and continues auditing siblings (per-slug try/catch)", () => {
// Regression guard: prior to per-slug isolation, a single
// auditPackage throw aborted the entire `slugs.map(auditPackage)`
// call and silently dropped every later slug from the report —
// masking drift. Contract: one slug may crash, but siblings MUST
// still be audited, the crashed slug MUST appear in the report as
// a "crashed" PackageIssue, and the run MUST still exit with
// EXIT_INTERNAL (4) so the failure is visible to CI.
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "parity-batch-abort-"));
try {
// Package A: legitimate — should produce a pass row.
writeFixturePackage(tmp, "a-ok", {
manifest: "slug: a-ok\ndemos:\n - id: chat\n name: Chat\n",
demoDirs: ["chat"],
specFiles: ["chat.spec.ts"],
qaFiles: ["chat.md"],
});
// Package B: will be poisoned to throw via Object.freeze spy.
writeFixturePackage(tmp, "b-boom", {
manifest: "slug: b-boom\ndemos:\n - id: poison\n name: Poison\n",
demoDirs: ["poison"],
specFiles: ["poison.spec.ts"],
qaFiles: ["poison.md"],
});
// Package C: legitimate — should ALSO produce a pass row and
// prove that a crash in B didn't abort the batch.
writeFixturePackage(tmp, "c-ok", {
manifest: "slug: c-ok\ndemos:\n - id: chat\n name: Chat\n",
demoDirs: ["chat"],
specFiles: ["chat.spec.ts"],
qaFiles: ["chat.md"],
});
const originalFreeze = Object.freeze;
// Only poison the demo entry whose id is "poison" — the freeze
// spy runs for every manifest demo object, so narrowing on id
// keeps a-ok and c-ok auditable.
const freezeSpy = vi.spyOn(Object, "freeze").mockImplementation(((
o: unknown,
) => {
if (
o !== null &&
typeof o === "object" &&
(o as Record<string, unknown>).id === "poison" &&
typeof (o as Record<string, unknown>).name === "string"
) {
throw new TypeError("synthetic b-boom failure");
}
return originalFreeze(o as object);
}) as typeof Object.freeze);
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
try {
let returned: unknown;
let threw = false;
try {
returned = runParity(tmp, 1);
} catch {
threw = true;
}
expect(threw, "runParity must not throw").toBe(false);
// Single crashed slug → EXIT_INTERNAL so CI still fails loud.
expect(returned).toBe(4);
// All three slugs must show up in the pass/summary table —
// the crashed one with [FAIL], the others with [PASS].
const stdout = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
expect(stdout, stdout).toMatch(/a-ok\s+\[PASS\]/);
expect(stdout, stdout).toMatch(/c-ok\s+\[PASS\]/);
expect(stdout, stdout).toMatch(/b-boom\s+\[FAIL\]/);
// Crashed slug surfaces as a structured issue + emits a
// [FAIL] line to stderr (not just the top-level [INTERNAL
// ERROR] banner) so operators can scan the per-package list.
const stderr = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
expect(stderr, stderr).toMatch(/\[FAIL\] b-boom:/);
expect(stderr, stderr).toMatch(/synthetic b-boom failure/);
} finally {
freezeSpy.mockRestore();
errSpy.mockRestore();
logSpy.mockRestore();
}
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
});
describe("coerceBaseline (discriminated union)", () => {
// coerceBaseline returns { ok: true, value } | { ok: false, reason }
// so the caller can surface a specific diagnostic (hex vs float vs
// negative etc.) instead of one blanket "invalid" message.
it("returns ok:true for valid positive integers", () => {
expect(coerceBaseline(9)).toEqual({ ok: true, value: 9 });
expect(coerceBaseline("9")).toEqual({ ok: true, value: 9 });
expect(coerceBaseline("42")).toEqual({ ok: true, value: 42 });
});
it("rejects empty / whitespace with reason", () => {
expect(coerceBaseline("")).toEqual({ ok: false, reason: "empty" });
expect(coerceBaseline(" ")).toEqual({
ok: false,
reason: "whitespace",
});
});
it("rejects zero with reason", () => {
expect(coerceBaseline(0)).toEqual({ ok: false, reason: "zero" });
expect(coerceBaseline("0")).toEqual({ ok: false, reason: "zero" });
});
it("rejects negatives with reason", () => {
expect(coerceBaseline(-1)).toEqual({ ok: false, reason: "negative" });
expect(coerceBaseline("-1")).toEqual({ ok: false, reason: "negative" });
});
it("rejects floats with reason", () => {
expect(coerceBaseline(1.5)).toEqual({ ok: false, reason: "float" });
expect(coerceBaseline("1.5")).toEqual({ ok: false, reason: "float" });
});
it("rejects hex notation with reason", () => {
expect(coerceBaseline("0x10")).toEqual({ ok: false, reason: "hex" });
});
it("rejects non-numeric strings with reason", () => {
expect(coerceBaseline("abc")).toEqual({
ok: false,
reason: "non-numeric",
});
expect(coerceBaseline("NaN")).toEqual({
ok: false,
reason: "non-numeric",
});
expect(coerceBaseline("1e2")).toEqual({
ok: false,
reason: "non-numeric",
});
});
it("rejects NaN / Infinity (numeric) with reason", () => {
expect(coerceBaseline(NaN)).toEqual({
ok: false,
reason: "non-numeric",
});
expect(coerceBaseline(Infinity)).toEqual({
ok: false,
reason: "non-numeric",
});
});
it("includes the specific reason in CLI error output", () => {
// The CLI diagnostic should thread the reason through to the
// user-facing message so bad input is actionable, not opaque.
const { root, packagesDir } = makeMainTree();
try {
writeFixturePackage(packagesDir, "only-pkg", {
manifest: "slug: only-pkg\ndemos:\n - id: chat\n name: Chat\n",
demoDirs: ["chat"],
specFiles: ["chat.spec.ts"],
qaFiles: ["chat.md"],
});
const r = spawnSync("npx", ["tsx", PARITY_SCRIPT, "--baseline=1.5"], {
env: { ...process.env, VALIDATE_PARITY_REPO_ROOT: root },
encoding: "utf-8",
timeout: 30_000,
});
// Invalid input → exit 2 (EXIT_INVALID_INPUT), not 1 (MUST failure).
expect(r.status, (r.stdout ?? "") + (r.stderr ?? "")).toBe(2);
expect(r.stderr).toMatch(/float/i);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
});
describe("table formatting column widths", () => {
// Header and data rows must use the SAME column widths. Previously
// HEADER_COLUMNS declared status=6, demos=5, specs=5, qa=3 while
// rows hardcoded padStart(4)/padStart(4)/padStart(3) — producing
// misaligned output. Both must now be driven from a single source
// (HEADER_COLUMNS).
it("formatRow output length matches buildHeader output length", () => {
// The cell separator is " " (two spaces), but right-aligned cells
// can themselves begin with spaces, so splitting on " " can over-
// segment. Instead, assert the overall rendered lengths match —
// they can only match if EVERY column width matches char-for-char,
// because padCell enforces equal widths per cell and the separator
// count is identical on both sides.
const slugWidth = 10;
const header = buildHeader(slugWidth);
const row = formatRow(
{
slug: "short",
demoIds: ["a"],
specFiles: ["a.spec.ts"],
qaFiles: ["a.md"],
demoDirs: ["a"],
mustErrors: [],
warnings: [],
},
slugWidth,
);
expect(row.length).toBe(header.length);
});
it("formatRow widths are driven from HEADER_COLUMNS so header and rows can't drift", () => {
// Render a row with minimal content for each non-slug column and
// verify the rendered cell at position i is EXACTLY the declared
// width (or the slug width at index 0). This locks the contract
// without depending on how cells are joined into a line.
const slugWidth = 12;
const report = {
slug: "p".padEnd(slugWidth, "p"),
demoIds: ["a"],
specFiles: ["a.spec.ts"],
qaFiles: ["a.md"],
demoDirs: ["a"],
mustErrors: [],
warnings: [],
};
const row = formatRow(report, slugWidth);
const header = buildHeader(slugWidth);
// Sum of column widths + separators is identical on both sides.
const expectedLen =
slugWidth +
HEADER_COLUMNS.slice(1).reduce((acc, c) => acc + c.width, 0) +
// " " separator between N columns → (N-1) * 2
(HEADER_COLUMNS.length - 1) * 2;
expect(row.length).toBe(expectedLen);
expect(header.length).toBe(expectedLen);
});
});
describe("main() uses process.exitCode (stdout drain safety)", () => {
// validate-parity.ts's CLI entrypoint must NOT call process.exit(code)
// synchronously — doing so truncates the buffered pass/summary stdout
// table that runParity just wrote. audit.ts and validate-pins.ts both
// use `process.exitCode = N; return;` so stdout drains before the
// process is torn down. Pin that contract with a subprocess test that
// checks the full table is actually received on stdout.
let tree: { root: string; packagesDir: string };
beforeEach(() => {
tree = makeMainTree();
});
afterEach(() => {
fs.rmSync(tree.root, { recursive: true, force: true });
});
it("emits the full summary table on stdout even when exiting non-zero", () => {
// Two packages: one PASS, one FAIL. The summary line is the last
// thing runParity logs; if main() calls process.exit synchronously
// the FAIL row may still make it out but the summary can be
// truncated. We assert the summary line is present on stdout.
writeFixturePackage(tree.packagesDir, "ok-pkg", {
manifest: "slug: ok-pkg\ndemos:\n - id: chat\n name: Chat\n",
demoDirs: ["chat"],
specFiles: ["chat.spec.ts"],
qaFiles: ["chat.md"],
});
writeFixturePackage(tree.packagesDir, "bad-pkg", {
manifest: "slug: bad-pkg\ndemos:\n - id: chat\n name: Chat\n",
// no demoDirs → MUST failure
});
const r = spawnSync("npx", ["tsx", PARITY_SCRIPT], {
env: { ...process.env, VALIDATE_PARITY_REPO_ROOT: tree.root },
encoding: "utf-8",
timeout: 30_000,
});
expect(r.status, (r.stdout ?? "") + (r.stderr ?? "")).toBe(1);
// Summary line is the last thing written to stdout — if exit
// truncated stdout it would be missing.
expect(r.stdout).toMatch(/2 package\(s\) checked/);
expect(r.stdout).toMatch(/ok-pkg/);
expect(r.stdout).toMatch(/bad-pkg/);
});
// Note: the source-grep regex that previously asserted `main()`'s
// body uses `process.exitCode` and not synchronous `process.exit(N)`
// was removed. The regex (`/function main\([^)]*\)[^{]*\{([\s\S]*?)\n\}/`)
// matched the first `\n}` it saw and was brittle to ordinary
// reformatting (e.g. nested braces, trailing-brace-on-same-line
// style). The behavioural "emits the full summary table on stdout
// even when exiting non-zero" subprocess test above already
// end-to-end verifies the drain works — which is the actual
// property we care about. A source-level lint on top of a
// behavioural test is both redundant and fragile.
});
describe("listFiles guards against bare suffix filenames", () => {
// `d.name.endsWith(suffix)` alone would accept a file literally named
// ".spec.ts" (stem = "") or ".md" as a valid entry, producing a
// downstream demoId="" in auditPackage's specIdSet / qaIdSet. Guard
// with a stem non-empty check so malformed entries are quietly
// ignored instead of matching no declared demo.
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "parity-bare-"));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it("skips a file literally named '.spec.ts' (empty stem)", () => {
fs.writeFileSync(path.join(tmpDir, ".spec.ts"), "", "utf-8");
fs.writeFileSync(path.join(tmpDir, "chat.spec.ts"), "", "utf-8");
const result = listFiles(tmpDir, ".spec.ts");
expect(result.entries).toEqual(["chat.spec.ts"]);
});
it("skips a file literally named '.md' (empty stem)", () => {
fs.writeFileSync(path.join(tmpDir, ".md"), "", "utf-8");
fs.writeFileSync(path.join(tmpDir, "chat.md"), "", "utf-8");
const result = listFiles(tmpDir, ".md");
expect(result.entries).toEqual(["chat.md"]);
});
it("keeps dotfiles with a stem after the leading dot (e.g. '.hidden.md')", () => {
// Only the exact bare-suffix case is filtered. A dotfile with a
// real stem after the first dot still matches — this guard is
// narrow by design.
fs.writeFileSync(path.join(tmpDir, ".hidden.md"), "", "utf-8");
fs.writeFileSync(path.join(tmpDir, "chat.md"), "", "utf-8");
const result = listFiles(tmpDir, ".md");
expect(result.entries).toEqual([".hidden.md", "chat.md"]);
});
});
describe("deriveMessage renders PackageIssue at display time", () => {
// PackageIssue entries no longer pre-format a `message` field that
// duplicates the structured category/demoId/path. Callers render
// messages on demand via deriveMessage(), keeping structured data
// as the single source of truth.
it("renders missing-manifest without any extra fields", () => {
expect(deriveMessage({ category: "missing-manifest" })).toBe(
"missing manifest.yaml",
);
});
it("renders malformed-manifest including the parser error text", () => {
expect(
deriveMessage({
category: "malformed-manifest",
error: "YAML parse failed at line 3",
}),
).toBe("unparseable manifest.yaml: YAML parse failed at line 3");
});
it("renders unreadable-manifest including the OS error text", () => {
expect(
deriveMessage({
category: "unreadable-manifest",
error: "EACCES: permission denied",
}),
).toBe("unreadable manifest.yaml: EACCES: permission denied");
});
it("renders missing-demo-dir including the demo id when id equals expectedDir", () => {
expect(
deriveMessage({
category: "missing-demo-dir",
demoId: "chat",
expectedDir: "chat",
}),
).toBe(
"demo 'chat' declared in manifest but no demos/chat/ (or legacy src/app/demos/chat/) directory",
);
});
it("renders missing-demo-dir flagging the route-resolved dir when id and expectedDir differ", () => {
expect(
deriveMessage({
category: "missing-demo-dir",
demoId: "hitl-in-chat",
expectedDir: "hitl",
}),
).toBe(
"demo 'hitl-in-chat' declared in manifest but no demos/hitl/ (or legacy src/app/demos/hitl/) directory (resolved from route)",
);
});
it("renders unreadable-demos-dir including the failing path", () => {
expect(
deriveMessage({
category: "unreadable-demos-dir",
path: "/x/y/src/app/demos",
error: "EACCES",
}),
).toMatch(/unreadable demos directory/);
});
it("renders unreadable-specs-dir including the failing path", () => {
expect(
deriveMessage({
category: "unreadable-specs-dir",
path: "/x/y/tests/e2e",
error: "EACCES",
}),
).toMatch(/unreadable specs directory/);
});
it("renders unreadable-qa-dir including the failing path", () => {
expect(
deriveMessage({
category: "unreadable-qa-dir",
path: "/x/y/qa",
error: "EACCES",
}),
).toMatch(/unreadable qa directory/);
});
it("renders missing-spec including the demo id", () => {
expect(deriveMessage({ category: "missing-spec", demoId: "tools" })).toBe(
"demo 'tools' has no tests/e2e/tools.spec.ts",
);
});
it("renders missing-qa including the demo id", () => {
expect(deriveMessage({ category: "missing-qa", demoId: "tools" })).toBe(
"demo 'tools' has no qa/tools.md",
);
});
it("renders baseline-deviation with the counts", () => {
expect(
deriveMessage({
category: "baseline-deviation",
demoCount: 1,
baseline: 9,
}),
).toBe("demo count 1 deviates from baseline 9");
});
it("renders spec-under-coverage with the counts", () => {
expect(
deriveMessage({
category: "spec-under-coverage",
specCount: 1,
demoCount: 2,
}),
).toBe("spec count 1 < demo count 2");
});
it("renders qa-under-coverage with the counts", () => {
expect(
deriveMessage({
category: "qa-under-coverage",
qaCount: 1,
demoCount: 2,
}),
).toBe("qa count 1 < demo count 2");
});
it("renders listing-failed with path and error", () => {
expect(
deriveMessage({
category: "listing-failed",
path: "/x/y",
error: "EACCES: permission denied",
}),
).toBe("failed to read directory /x/y: EACCES: permission denied");
});
});
describe("PackageReport returns independent arrays for each audit call", () => {
// The `readonly T[]` annotation in PackageReport is a TypeScript-only
// enforcement (no runtime freeze). What matters at runtime is that
// each auditPackage call returns freshly-built arrays (no accidental
// shared module-level state leaking across calls). If two successive
// audits returned the same array instance, a caller mutating one
// would corrupt the next — exactly the failure mode the readonly
// type aims to prevent from the other direction. We verify that
// behaviourally: mutate the first report's arrays and assert the
// second report remains untouched.
it("mutations to one report's arrays do not leak into a subsequent audit", () => {
const r1 = auditPackage("ok-pkg", FIXTURES_DIR);
const r2 = auditPackage("ok-pkg", FIXTURES_DIR);
const fakeIssue: PackageIssue = {
category: "missing-manifest",
};
(r1.mustErrors as PackageIssue[]).push(fakeIssue);
(r1.warnings as PackageIssue[]).push(fakeIssue);
(r1.demoIds as string[]).push("__mutated-demo__");
(r1.specFiles as string[]).push("__mutated-spec__.ts");
(r1.qaFiles as string[]).push("__mutated-qa__.md");
(r1.demoDirs as string[]).push("__mutated-demo-dir__");
expect(r2.mustErrors).not.toContain(fakeIssue);
expect(r2.warnings).not.toContain(fakeIssue);
expect(r2.demoIds).not.toContain("__mutated-demo__");
expect(r2.specFiles).not.toContain("__mutated-spec__.ts");
expect(r2.qaFiles).not.toContain("__mutated-qa__.md");
expect(r2.demoDirs).not.toContain("__mutated-demo-dir__");
});
});
describe("runParity does not read process.argv when called programmatically", () => {
// Regression: programmatic callers (e.g. future aggregator scripts
// or tests) should be able to invoke runParity with an explicit
// baseline and NOT have process.argv parsed behind their backs.
it("ignores process.argv when baselineDemoCount is passed explicitly", () => {
const { root, packagesDir } = makeMainTree();
try {
writeFixturePackage(packagesDir, "only-pkg", {
manifest: "slug: only-pkg\ndemos:\n - id: chat\n name: Chat\n",
demoDirs: ["chat"],
specFiles: ["chat.spec.ts"],
qaFiles: ["chat.md"],
});
const originalArgv = process.argv;
try {
// Inject a bad --baseline flag that WOULD fail argv parsing.
// Mutation + spy setup live INSIDE the try so that a throw
// during spy installation cannot leave process.argv permanently
// clobbered — the outer finally always restores it.
process.argv = [
process.argv[0],
process.argv[1],
"--baseline=not-a-number",
];
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const errSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});
try {
const code = runParity(packagesDir, 5);
// If argv were parsed, we'd get exit 1 (invalid baseline).
// With explicit baseline=5 passed, argv must be skipped.
expect(code).toBe(0);
// And no "invalid --baseline value" must have been logged.
const errCalls = errSpy.mock.calls
.map((c) => c.join(" "))
.join("\n");
expect(errCalls).not.toMatch(/invalid --baseline value/);
} finally {
logSpy.mockRestore();
errSpy.mockRestore();
}
} finally {
process.argv = originalArgv;
}
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
});
});
describe("routeToDirName", () => {
// Minimal branch coverage for the exported helper. Paired with the
// fixture-based tests above that exercise it through auditPackage; these
// nail down the three input branches without the parity-walk overhead.
it("returns undefined when route is undefined", () => {
expect(routeToDirName(undefined)).toBeUndefined();
});
it("returns undefined for a bare /demos/ with no tail segment", () => {
expect(routeToDirName("/demos/")).toBeUndefined();
});
it("strips the /demos/ prefix and returns the tail segment", () => {
expect(routeToDirName("/demos/hitl")).toBe("hitl");
expect(routeToDirName("/demos/agentic-chat")).toBe("agentic-chat");
});
});