mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
feat(cli): persistence-tiered findings, frozen-sweep guard, occlusion coverage
Layout findings now distinguish held defects from entrance/exit transients: a dynamic issue seen at a single grid sample demotes to info, while content_overlap held across two-plus samples (or 500ms+) promotes to error, resolving the long-standing re-promotion TODO. Static compositions keep their severity. check gains a sweep_static error when a 3s+ composition shows zero geometry change across every sample (a frozen timeline makes every green verdict unreliable); skipped when the motion sidecar already reported motion_frozen. text_occluded findings carry a coveredFraction; atomic labels (short, no whitespace) flag on any cover while prose needs 15%, since partial cover changes what a short label reads as. Deprecation-test scaffolding consolidates into deprecationTestHarness; tier logic and logger tests restructured under the complexity gate without suppression markers. Detection mechanics adapted from Adam Rosler's open-sourced visual-linter design (github.com/Adam-Rosler/hyperframes-visual-linter-design); the elementFromPoint paint model, opt-out attributes, and single-audit architecture are unchanged.
This commit is contained in:
@@ -108,10 +108,14 @@ function anchor(selector: string, time: number): CheckAnchor {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function layoutIssue(severity: "error" | "warning" | "info" = "error"): AnchoredLayoutIssue {
|
function layoutIssue(
|
||||||
|
severity: "error" | "warning" | "info" = "error",
|
||||||
|
overrides: { time?: number; code?: AnchoredLayoutIssue["code"] } = {},
|
||||||
|
): AnchoredLayoutIssue {
|
||||||
|
const time = overrides.time ?? 0.5;
|
||||||
return {
|
return {
|
||||||
...anchor("#hero", 0.5),
|
...anchor("#hero", time),
|
||||||
code: severity === "warning" ? "content_overlap" : "clipped_text",
|
code: overrides.code ?? (severity === "warning" ? "content_overlap" : "clipped_text"),
|
||||||
severity,
|
severity,
|
||||||
text: "Hero",
|
text: "Hero",
|
||||||
message: severity === "warning" ? "Text may overlap." : "Text is clipped.",
|
message: severity === "warning" ? "Text may overlap." : "Text is clipped.",
|
||||||
@@ -133,6 +137,10 @@ function contrastEntry(overrides: Partial<ContrastAuditEntry> = {}): ContrastAud
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fakeDriver(overrides: Partial<CheckAuditDriver> = {}): CheckAuditDriver {
|
function fakeDriver(overrides: Partial<CheckAuditDriver> = {}): CheckAuditDriver {
|
||||||
|
// A distinct string per call so the frozen-sweep guard (#U10) never fires
|
||||||
|
// by accident in unrelated scenarios — tests that want it force a constant
|
||||||
|
// via `collectLayoutGeometry: vi.fn(async () => "same")`.
|
||||||
|
let geometryCallCount = 0;
|
||||||
return {
|
return {
|
||||||
initialize: vi.fn(async (_contrast: boolean) => undefined),
|
initialize: vi.fn(async (_contrast: boolean) => undefined),
|
||||||
getDuration: vi.fn(async () => 9),
|
getDuration: vi.fn(async () => 9),
|
||||||
@@ -141,6 +149,7 @@ function fakeDriver(overrides: Partial<CheckAuditDriver> = {}): CheckAuditDriver
|
|||||||
findAmbiguousSelectors: vi.fn(async (_selectors: string[]) => []),
|
findAmbiguousSelectors: vi.fn(async (_selectors: string[]) => []),
|
||||||
seek: vi.fn(async (_time: number) => undefined),
|
seek: vi.fn(async (_time: number) => undefined),
|
||||||
collectLayout: vi.fn(async (_time: number, _tolerance: number) => []),
|
collectLayout: vi.fn(async (_time: number, _tolerance: number) => []),
|
||||||
|
collectLayoutGeometry: vi.fn(async () => `geometry-${geometryCallCount++}`),
|
||||||
collectGeometryCandidates: vi.fn(async () => []),
|
collectGeometryCandidates: vi.fn(async () => []),
|
||||||
collectMotionFrame: vi.fn(async (time: number) => ({ time, data: {}, liveness: {} })),
|
collectMotionFrame: vi.fn(async (time: number) => ({ time, data: {}, liveness: {} })),
|
||||||
anchorMotionIssues: vi.fn(async (issues: LayoutIssue[]) =>
|
anchorMotionIssues: vi.fn(async (issues: LayoutIssue[]) =>
|
||||||
@@ -883,7 +892,9 @@ describe("check pipeline", () => {
|
|||||||
|
|
||||||
it("preserves a resolving selector, source file, identity, bbox, and sample time", async () => {
|
it("preserves a resolving selector, source file, identity, bbox, and sample time", async () => {
|
||||||
const { report } = await runScenario(
|
const { report } = await runScenario(
|
||||||
fakeDriver({ collectLayout: vi.fn(async () => [layoutIssue()]) }),
|
fakeDriver({
|
||||||
|
collectLayout: vi.fn(async (time: number) => [layoutIssue("error", { time })]),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
expect(report.layout.findings[0]).toMatchObject({
|
expect(report.layout.findings[0]).toMatchObject({
|
||||||
selector: "#hero",
|
selector: "#hero",
|
||||||
@@ -896,7 +907,9 @@ describe("check pipeline", () => {
|
|||||||
|
|
||||||
it("reports layout and runtime errors from one browser session", async () => {
|
it("reports layout and runtime errors from one browser session", async () => {
|
||||||
const { report, browser } = await runScenario(
|
const { report, browser } = await runScenario(
|
||||||
fakeDriver({ collectLayout: vi.fn(async () => [layoutIssue()]) }),
|
fakeDriver({
|
||||||
|
collectLayout: vi.fn(async (time: number) => [layoutIssue("error", { time })]),
|
||||||
|
}),
|
||||||
{},
|
{},
|
||||||
{ runtime: [runtimeError()] },
|
{ runtime: [runtimeError()] },
|
||||||
);
|
);
|
||||||
@@ -961,7 +974,9 @@ describe("check pipeline", () => {
|
|||||||
) => ["snapshots/finding-00-clipped_text.png"],
|
) => ["snapshots/finding-00-clipped_text.png"],
|
||||||
);
|
);
|
||||||
const { report } = await runScenario(
|
const { report } = await runScenario(
|
||||||
fakeDriver({ collectLayout: vi.fn(async () => [layoutIssue()]) }),
|
fakeDriver({
|
||||||
|
collectLayout: vi.fn(async (time: number) => [layoutIssue("error", { time })]),
|
||||||
|
}),
|
||||||
{ snapshots: true },
|
{ snapshots: true },
|
||||||
{ captureFindingCrops: capture },
|
{ captureFindingCrops: capture },
|
||||||
);
|
);
|
||||||
@@ -978,7 +993,9 @@ describe("check pipeline", () => {
|
|||||||
|
|
||||||
const withoutSnapshots = vi.fn(async () => ["unused.png"]);
|
const withoutSnapshots = vi.fn(async () => ["unused.png"]);
|
||||||
await runScenario(
|
await runScenario(
|
||||||
fakeDriver({ collectLayout: vi.fn(async () => [layoutIssue()]) }),
|
fakeDriver({
|
||||||
|
collectLayout: vi.fn(async (time: number) => [layoutIssue("error", { time })]),
|
||||||
|
}),
|
||||||
{ snapshots: false },
|
{ snapshots: false },
|
||||||
{ captureFindingCrops: withoutSnapshots },
|
{ captureFindingCrops: withoutSnapshots },
|
||||||
);
|
);
|
||||||
@@ -986,7 +1003,15 @@ describe("check pipeline", () => {
|
|||||||
|
|
||||||
const noErrors = vi.fn(async () => ["unused.png"]);
|
const noErrors = vi.fn(async () => ["unused.png"]);
|
||||||
await runScenario(
|
await runScenario(
|
||||||
fakeDriver({ collectLayout: vi.fn(async () => [layoutIssue("warning")]) }),
|
fakeDriver({
|
||||||
|
collectLayout: vi.fn(
|
||||||
|
async (time: number) =>
|
||||||
|
// container_overflow, not content_overlap: this fixture wants a plain
|
||||||
|
// warning-severity finding held across the whole run, unaffected by
|
||||||
|
// content_overlap's #U10 held-duration re-promotion to error.
|
||||||
|
[layoutIssue("warning", { time, code: "container_overflow" })],
|
||||||
|
),
|
||||||
|
}),
|
||||||
{ snapshots: true },
|
{ snapshots: true },
|
||||||
{ captureFindingCrops: noErrors },
|
{ captureFindingCrops: noErrors },
|
||||||
);
|
);
|
||||||
@@ -995,7 +1020,15 @@ describe("check pipeline", () => {
|
|||||||
|
|
||||||
it("--strict flips a warnings-only result from exit 0 to exit 1", async () => {
|
it("--strict flips a warnings-only result from exit 0 to exit 1", async () => {
|
||||||
const warningDriver = () =>
|
const warningDriver = () =>
|
||||||
fakeDriver({ collectLayout: vi.fn(async () => [layoutIssue("warning")]) });
|
fakeDriver({
|
||||||
|
collectLayout: vi.fn(
|
||||||
|
async (time: number) =>
|
||||||
|
// container_overflow, not content_overlap: this fixture wants a plain
|
||||||
|
// warning-severity finding held across the whole run, unaffected by
|
||||||
|
// content_overlap's #U10 held-duration re-promotion to error.
|
||||||
|
[layoutIssue("warning", { time, code: "container_overflow" })],
|
||||||
|
),
|
||||||
|
});
|
||||||
const normal = await runScenario(warningDriver(), { strict: false });
|
const normal = await runScenario(warningDriver(), { strict: false });
|
||||||
const strict = await runScenario(warningDriver(), { strict: true });
|
const strict = await runScenario(warningDriver(), { strict: true });
|
||||||
|
|
||||||
@@ -1026,6 +1059,57 @@ describe("check pipeline", () => {
|
|||||||
);
|
);
|
||||||
expect(checkExitCode(report)).toBe(1);
|
expect(checkExitCode(report)).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("frozen-sweep guard (#U10)", () => {
|
||||||
|
it("fails with sweep_static when a 6s composition's geometry never changes across samples", async () => {
|
||||||
|
const driver = fakeDriver({
|
||||||
|
getDuration: vi.fn(async () => 6),
|
||||||
|
collectLayoutGeometry: vi.fn(async () => "frozen"),
|
||||||
|
});
|
||||||
|
const { report } = await runScenario(driver);
|
||||||
|
|
||||||
|
expect(report.ok).toBe(false);
|
||||||
|
expect(
|
||||||
|
report.layout.findings.some(
|
||||||
|
(finding) =>
|
||||||
|
finding.code === "sweep_static" &&
|
||||||
|
finding.severity === "error" &&
|
||||||
|
finding.message.includes("did not advance"),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not flag a 1.5s static title card — too short for the guard to apply", async () => {
|
||||||
|
const driver = fakeDriver({
|
||||||
|
getDuration: vi.fn(async () => 1.5),
|
||||||
|
collectLayoutGeometry: vi.fn(async () => "frozen"),
|
||||||
|
});
|
||||||
|
const { report } = await runScenario(driver);
|
||||||
|
|
||||||
|
expect(report.layout.findings.some((finding) => finding.code === "sweep_static")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not double-report when a motion_frozen finding already covers the same symptom", async () => {
|
||||||
|
const motion: MotionSpecResolution = {
|
||||||
|
kind: "valid",
|
||||||
|
path: "/project/index.motion.json",
|
||||||
|
spec: { assertions: [{ kind: "keepsMoving" }] },
|
||||||
|
};
|
||||||
|
const driver = fakeDriver({
|
||||||
|
getDuration: vi.fn(async () => 6),
|
||||||
|
collectLayoutGeometry: vi.fn(async () => "frozen"),
|
||||||
|
collectMotionFrame: vi.fn(async (time: number) => ({
|
||||||
|
time,
|
||||||
|
data: {},
|
||||||
|
liveness: { "*": "unchanging" },
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
const { report } = await runScenario(driver, {}, { motion });
|
||||||
|
|
||||||
|
expect(report.motion.findings.some((finding) => finding.code === "motion_frozen")).toBe(true);
|
||||||
|
expect(report.layout.findings.some((finding) => finding.code === "sweep_static")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("check report telemetry", () => {
|
describe("check report telemetry", () => {
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// Shared scaffolding for the U5 deprecation tests in inspect.test.ts,
|
||||||
|
// layout.test.ts, and validate.test.ts: those commands all fail fast (via a
|
||||||
|
// mocked dynamic import) so the tests can assert the shared deprecation
|
||||||
|
// envelope (stderr notice, JSON `_meta.deprecated`) without needing a real
|
||||||
|
// project or headless Chrome.
|
||||||
|
//
|
||||||
|
// vi.mock factories are hoisted above imports, so each test file keeps its
|
||||||
|
// own thin `vi.mock("<path>", () => someFactory())` call (mocking a module
|
||||||
|
// path can't itself be shared across files) but delegates the factory body
|
||||||
|
// here.
|
||||||
|
import type { ArgsDef, CommandDef } from "citty";
|
||||||
|
import { runCommand } from "citty";
|
||||||
|
import { expect, vi } from "vitest";
|
||||||
|
|
||||||
|
const FAKE_PROJECT = {
|
||||||
|
dir: "/fake-project",
|
||||||
|
name: "fake-project",
|
||||||
|
indexPath: "/fake-project/index.html",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveProjectMock() {
|
||||||
|
return { resolveProject: vi.fn(() => FAKE_PROJECT) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bundleToSingleHtmlFailureMock() {
|
||||||
|
return {
|
||||||
|
bundleToSingleHtml: vi.fn(async () => {
|
||||||
|
throw new Error("bundling failed (test double)");
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lintProjectFailureMock() {
|
||||||
|
return {
|
||||||
|
lintProject: vi.fn(async () => {
|
||||||
|
throw new Error("lint failed (test double)");
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* citty's `meta` is `Resolvable<CommandMeta>` (object | promise | thunk).
|
||||||
|
* These test files always define it as a synchronous object literal, so
|
||||||
|
* narrow to that shape instead of asserting it with `as`.
|
||||||
|
*/
|
||||||
|
export function metaDescription<T extends ArgsDef = ArgsDef>(command: CommandDef<T>): string {
|
||||||
|
const meta = command.meta;
|
||||||
|
if (meta && typeof meta === "object" && "description" in meta) {
|
||||||
|
return String(meta.description ?? "");
|
||||||
|
}
|
||||||
|
throw new Error("expected a synchronous meta object");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a command with stdout/stderr writes captured (and process.exit /
|
||||||
|
* console.log stubbed so the run stays silent and non-terminating), and
|
||||||
|
* return the captured text for the caller to assert on.
|
||||||
|
*/
|
||||||
|
export async function runAndCaptureStdio<T extends ArgsDef = ArgsDef>(
|
||||||
|
command: CommandDef<T>,
|
||||||
|
rawArgs: string[] = ["--json"],
|
||||||
|
): Promise<{ stderrText: string; stdoutText: string }> {
|
||||||
|
const stderrWrites: string[] = [];
|
||||||
|
const stdoutWrites: string[] = [];
|
||||||
|
vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
|
||||||
|
stderrWrites.push(String(chunk));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
|
||||||
|
stdoutWrites.push(String(chunk));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
|
||||||
|
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
|
||||||
|
await runCommand(command, { rawArgs });
|
||||||
|
|
||||||
|
return { stderrText: stderrWrites.join(""), stdoutText: stdoutWrites.join("") };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a command with process.exit stubbed and console.log spied, returning
|
||||||
|
* the first console.log call that looks like a JSON object (the `--json`
|
||||||
|
* failure envelope). Callers assert on definedness/shape themselves, since
|
||||||
|
* that differs slightly per call site.
|
||||||
|
*/
|
||||||
|
export async function runAndFindJsonLogCall<T extends ArgsDef = ArgsDef>(
|
||||||
|
command: CommandDef<T>,
|
||||||
|
rawArgs: string[] = ["--json"],
|
||||||
|
): Promise<unknown[] | undefined> {
|
||||||
|
vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||||
|
vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
|
||||||
|
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
|
||||||
|
await runCommand(command, { rawArgs });
|
||||||
|
|
||||||
|
return logSpy.mock.calls.find(([arg]) => typeof arg === "string" && arg.trim().startsWith("{"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience wrapper: parse the JSON envelope found by runAndFindJsonLogCall.
|
||||||
|
* `parsed` is intentionally left as JSON.parse's inferred `any` (matching
|
||||||
|
* every call site's prior inline `JSON.parse(...)` usage) rather than
|
||||||
|
* annotated `unknown`, since callers assert directly into its shape
|
||||||
|
* (`.ok`, `._meta.deprecated`) the same way the original inline tests did.
|
||||||
|
*/
|
||||||
|
export async function runAndParseJsonEnvelope<T extends ArgsDef = ArgsDef>(
|
||||||
|
command: CommandDef<T>,
|
||||||
|
rawArgs: string[] = ["--json"],
|
||||||
|
) {
|
||||||
|
const jsonCall = await runAndFindJsonLogCall(command, rawArgs);
|
||||||
|
expect(jsonCall).toBeDefined();
|
||||||
|
const parsed = JSON.parse(String(jsonCall?.[0]));
|
||||||
|
return { jsonCall, parsed };
|
||||||
|
}
|
||||||
@@ -1,37 +1,21 @@
|
|||||||
import type { CommandDef } from "citty";
|
|
||||||
import { runCommand } from "citty";
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
bundleToSingleHtmlFailureMock,
|
||||||
|
metaDescription,
|
||||||
|
resolveProjectMock,
|
||||||
|
runAndCaptureStdio,
|
||||||
|
runAndParseJsonEnvelope,
|
||||||
|
} from "./deprecationTestHarness.js";
|
||||||
|
|
||||||
// See layout.test.ts for why these two dynamic-import targets are mocked:
|
// See layout.test.ts for why these two dynamic-import targets are mocked:
|
||||||
// resolveProject skips real filesystem resolution, and bundleToSingleHtml
|
// resolveProject skips real filesystem resolution, and bundleToSingleHtml
|
||||||
// gives a fast, deterministic failure that exercises run()'s outer catch
|
// gives a fast, deterministic failure that exercises run()'s outer catch
|
||||||
// (the JSON failure envelope) without needing headless Chrome.
|
// (the JSON failure envelope) without needing headless Chrome.
|
||||||
const FAKE_PROJECT = {
|
vi.mock("../utils/project.js", () => resolveProjectMock());
|
||||||
dir: "/fake-project",
|
vi.mock("@hyperframes/core/compiler", () => bundleToSingleHtmlFailureMock());
|
||||||
name: "fake-project",
|
|
||||||
indexPath: "/fake-project/index.html",
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mock("../utils/project.js", () => ({
|
|
||||||
resolveProject: vi.fn(() => FAKE_PROJECT),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@hyperframes/core/compiler", () => ({
|
|
||||||
bundleToSingleHtml: vi.fn(async () => {
|
|
||||||
throw new Error("bundling failed (test double)");
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import inspectCommand from "./inspect.js";
|
import inspectCommand from "./inspect.js";
|
||||||
|
|
||||||
function metaDescription(command: CommandDef): string {
|
|
||||||
const meta = command.meta;
|
|
||||||
if (meta && typeof meta === "object" && "description" in meta) {
|
|
||||||
return String(meta.description ?? "");
|
|
||||||
}
|
|
||||||
throw new Error("expected a synchronous meta object");
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
@@ -42,39 +26,14 @@ describe("inspect command deprecation (U5)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("prints a one-line deprecation notice naming 'inspect' on stderr, never stdout", async () => {
|
it("prints a one-line deprecation notice naming 'inspect' on stderr, never stdout", async () => {
|
||||||
const stderrWrites: string[] = [];
|
const { stderrText, stdoutText } = await runAndCaptureStdio(inspectCommand);
|
||||||
const stdoutWrites: string[] = [];
|
|
||||||
vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
|
|
||||||
stderrWrites.push(String(chunk));
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
|
|
||||||
stdoutWrites.push(String(chunk));
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
|
|
||||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
||||||
|
|
||||||
await runCommand(inspectCommand, { rawArgs: ["--json"] });
|
|
||||||
|
|
||||||
const stderrText = stderrWrites.join("");
|
|
||||||
expect(stderrText).toContain("hyperframes inspect");
|
expect(stderrText).toContain("hyperframes inspect");
|
||||||
expect(stderrText).toContain("hyperframes check");
|
expect(stderrText).toContain("hyperframes check");
|
||||||
expect(stdoutWrites.join("")).toBe("");
|
expect(stdoutText).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("--json output is valid JSON with _meta.deprecated === true on failure", async () => {
|
it("--json output is valid JSON with _meta.deprecated === true on failure", async () => {
|
||||||
vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
const { parsed } = await runAndParseJsonEnvelope(inspectCommand);
|
||||||
vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
|
|
||||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
||||||
|
|
||||||
await runCommand(inspectCommand, { rawArgs: ["--json"] });
|
|
||||||
|
|
||||||
const jsonCall = logSpy.mock.calls.find(
|
|
||||||
([arg]) => typeof arg === "string" && arg.trim().startsWith("{"),
|
|
||||||
);
|
|
||||||
expect(jsonCall).toBeDefined();
|
|
||||||
const parsed = JSON.parse(String(jsonCall?.[0]));
|
|
||||||
expect(parsed.ok).toBe(false);
|
expect(parsed.ok).toBe(false);
|
||||||
expect(parsed._meta.deprecated).toBe(true);
|
expect(parsed._meta.deprecated).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -627,9 +627,12 @@
|
|||||||
const area = intersectionArea(a.rect, b.rect);
|
const area = intersectionArea(a.rect, b.rect);
|
||||||
if (area <= Math.min(rectArea(a.rect), rectArea(b.rect)) * 0.2) return null;
|
if (area <= Math.min(rectArea(a.rect), rectArea(b.rect)) * 0.2) return null;
|
||||||
return {
|
return {
|
||||||
// Warning, not error: must not fail the exit code (ok = errorCount === 0)
|
// Warning at the per-sample level: a single-sample overlap is usually an
|
||||||
// for compositions that intentionally layer text. Re-promote once the
|
// entrance/exit transient (two blocks crossing mid-animation), not a real
|
||||||
// data-layout-allow-overlap opt-out is widely adopted.
|
// collision. `collapseStaticLayoutIssues` (utils/layoutAudit.ts) re-promotes
|
||||||
|
// this to error once the SAME overlap is held across >= 2 adjacent samples
|
||||||
|
// (or ~500ms of timeline) — a persistence-tiered replacement for the old
|
||||||
|
// "re-promote once data-layout-allow-overlap is widely adopted" plan (#U10).
|
||||||
code: "content_overlap",
|
code: "content_overlap",
|
||||||
severity: "warning",
|
severity: "warning",
|
||||||
time,
|
time,
|
||||||
@@ -728,37 +731,66 @@
|
|||||||
return hit;
|
return hit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const OCCLUSION_PROBE_Y_FRACTIONS = [0.25, 0.5, 0.75];
|
||||||
|
const OCCLUSION_PROBE_X_FRACTIONS = [0.03, 0.1, 0.2, 0.35, 0.5, 0.65, 0.8, 0.9, 0.97];
|
||||||
|
const OCCLUSION_GRID_POINTS =
|
||||||
|
OCCLUSION_PROBE_Y_FRACTIONS.length * OCCLUSION_PROBE_X_FRACTIONS.length;
|
||||||
|
|
||||||
|
// Short, atomic text (a label/button/word, no whitespace) reads as a single
|
||||||
|
// unit — ANY covered probe point changes what it says, so flag at any hit
|
||||||
|
// (the pre-#U10 behaviour). Longer prose survives a nibbled edge; only flag
|
||||||
|
// once a real share of it is covered — see `occludedTextIssue`.
|
||||||
|
const ATOMIC_LABEL_MAX_CHARS = 16;
|
||||||
|
const PROSE_COVERAGE_FLOOR = 0.15;
|
||||||
|
|
||||||
|
function isAtomicLabel(text) {
|
||||||
|
return text.length > 0 && text.length <= ATOMIC_LABEL_MAX_CHARS && !/\s/.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
// Sweep a grid across the text box (three rows, not just the mid-line, so
|
// Sweep a grid across the text box (three rows, not just the mid-line, so
|
||||||
// overlays covering only part of a multi-line block are caught) and return
|
// overlays covering only part of a multi-line block are caught). Unlike a
|
||||||
// the first opaque element painted over any sample point.
|
// first-hit scan, this keeps sampling every point so it can report what
|
||||||
function firstOccluder(element, textRect) {
|
// fraction of the box is actually covered — a corner nibble on a paragraph
|
||||||
for (const yFraction of [0.25, 0.5, 0.75]) {
|
// reads very differently from a label buried under an overlay. Still
|
||||||
|
// returns the first opaque element found, for `containerSelector`.
|
||||||
|
function occlusionCoverage(element, textRect) {
|
||||||
|
let occluder = null;
|
||||||
|
let hits = 0;
|
||||||
|
for (const yFraction of OCCLUSION_PROBE_Y_FRACTIONS) {
|
||||||
const y = textRect.top + textRect.height * yFraction;
|
const y = textRect.top + textRect.height * yFraction;
|
||||||
for (const xFraction of [0.03, 0.1, 0.2, 0.35, 0.5, 0.65, 0.8, 0.9, 0.97]) {
|
for (const xFraction of OCCLUSION_PROBE_X_FRACTIONS) {
|
||||||
const occluder = occluderAt(element, textRect.left + textRect.width * xFraction, y);
|
const hit = occluderAt(element, textRect.left + textRect.width * xFraction, y);
|
||||||
if (occluder) return occluder;
|
if (!hit) continue;
|
||||||
|
hits += 1;
|
||||||
|
if (!occluder) occluder = hit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return { occluder, coveredFraction: round(hits / OCCLUSION_GRID_POINTS) };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Catches the blind spot the overflow checks miss: text that fits its box
|
// Catches the blind spot the overflow checks miss: text that fits its box
|
||||||
// perfectly but is covered by a later sibling/overlay.
|
// perfectly but is covered by a later sibling/overlay. An atomic label
|
||||||
|
// (short, no whitespace) flags at any coverage; ordinary prose only flags
|
||||||
|
// once coveredFraction clears PROSE_COVERAGE_FLOOR, since a sliver of edge
|
||||||
|
// cover on a paragraph is usually a styling artifact, not a reading defect.
|
||||||
function occludedTextIssue(element, time) {
|
function occludedTextIssue(element, time) {
|
||||||
if (hasAllowOcclusionFlag(element)) return null;
|
if (hasAllowOcclusionFlag(element)) return null;
|
||||||
const textRect = textRectFor(element);
|
const textRect = textRectFor(element);
|
||||||
if (!textRect) return null;
|
if (!textRect) return null;
|
||||||
const occluder = firstOccluder(element, textRect);
|
const text = textContentFor(element);
|
||||||
|
const { occluder, coveredFraction } = occlusionCoverage(element, textRect);
|
||||||
if (!occluder) return null;
|
if (!occluder) return null;
|
||||||
|
if (!isAtomicLabel(text) && coveredFraction < PROSE_COVERAGE_FLOOR) return null;
|
||||||
return {
|
return {
|
||||||
code: "text_occluded",
|
code: "text_occluded",
|
||||||
severity: "error",
|
severity: "error",
|
||||||
time,
|
time,
|
||||||
selector: selectorFor(element),
|
selector: selectorFor(element),
|
||||||
containerSelector: selectorFor(occluder),
|
containerSelector: selectorFor(occluder),
|
||||||
text: textContentFor(element),
|
text,
|
||||||
message: "Text is hidden beneath an opaque element.",
|
message: "Text is hidden beneath an opaque element.",
|
||||||
rect: textRect,
|
rect: textRect,
|
||||||
|
coveredFraction,
|
||||||
fixHint:
|
fixHint:
|
||||||
"Give the text its own zone, raise its stacking order above the covering element, or mark intentional layering with data-layout-allow-occlusion.",
|
"Give the text its own zone, raise its stacking order above the covering element, or mark intentional layering with data-layout-allow-occlusion.",
|
||||||
};
|
};
|
||||||
@@ -855,4 +887,28 @@
|
|||||||
issues.push(...contentOverlapIssues(root, time));
|
issues.push(...contentOverlapIssues(root, time));
|
||||||
return issues;
|
return issues;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Frozen-sweep guard (#U10, checkPipeline.ts): a compact per-sample
|
||||||
|
// fingerprint of every visible element's box + opacity, in DOM order. Node
|
||||||
|
// calls this once per seeked grid point and compares the strings across the
|
||||||
|
// whole run — if every sample produces the identical string, the seek never
|
||||||
|
// actually moved anything and the whole audit run is unreliable. Deliberately
|
||||||
|
// a single opaque string (not a structured array) since Node only ever needs
|
||||||
|
// equality, not per-element diffing.
|
||||||
|
window.__hyperframesLayoutGeometry = function collectLayoutGeometry() {
|
||||||
|
const root =
|
||||||
|
document.querySelector("[data-composition-id][data-width][data-height]") ||
|
||||||
|
document.querySelector("[data-composition-id]") ||
|
||||||
|
document.body;
|
||||||
|
const elements = Array.from(root.querySelectorAll("*")).filter((element) =>
|
||||||
|
isVisibleElement(element),
|
||||||
|
);
|
||||||
|
return elements
|
||||||
|
.map((element) => {
|
||||||
|
const rect = toRect(element.getBoundingClientRect());
|
||||||
|
const opacity = round(opacityChain(element));
|
||||||
|
return `${rect.left},${rect.top},${rect.width},${rect.height},${opacity}`;
|
||||||
|
})
|
||||||
|
.join("|");
|
||||||
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -651,8 +651,92 @@ describe("layout-audit.browser occlusion", () => {
|
|||||||
});
|
});
|
||||||
expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false);
|
expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("carries the fully-covered fraction when the occluder hits every probe point", () => {
|
||||||
|
const occluded = auditOcclusionScene({
|
||||||
|
overlayStyle: { backgroundColor: "rgb(10, 10, 10)" },
|
||||||
|
topmostId: "overlay",
|
||||||
|
}).find((issue) => issue.code === "text_occluded");
|
||||||
|
expect(occluded?.coveredFraction).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// #U10: a 2-point hit on the 27-point probe grid (3 rows x 9 columns) is a
|
||||||
|
// sliver of edge cover — reports ~0.07 coverage either way, but only GATES
|
||||||
|
// (produces a finding) for short atomic labels; ordinary prose survives it.
|
||||||
|
it("reports ~0.07 coverage for a 2-of-27 grid hit and flags an atomic label at that coverage", () => {
|
||||||
|
const issues = auditCoverageScene({ text: "SUBSCRIBE", hitCount: 2 });
|
||||||
|
const occluded = issues.find((issue) => issue.code === "text_occluded");
|
||||||
|
expect(occluded).toBeDefined();
|
||||||
|
expect(occluded?.coveredFraction).toBe(0.07);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not flag ordinary prose at the same ~0.07 coverage a label would flag at", () => {
|
||||||
|
const issues = auditCoverageScene({
|
||||||
|
text: "This paragraph is long enough to read as ordinary prose, not a label.",
|
||||||
|
hitCount: 2,
|
||||||
|
});
|
||||||
|
expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags prose once coverage clears the 0.15 floor", () => {
|
||||||
|
// 5/27 ≈ 0.185, comfortably over the ~0.15 prose floor.
|
||||||
|
const issues = auditCoverageScene({
|
||||||
|
text: "This paragraph is long enough to read as ordinary prose, not a label.",
|
||||||
|
hitCount: 5,
|
||||||
|
});
|
||||||
|
expect(issues.some((issue) => issue.code === "text_occluded")).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mirrors OCCLUSION_PROBE_Y_FRACTIONS / OCCLUSION_PROBE_X_FRACTIONS in
|
||||||
|
// layout-audit.browser.js, so a test can force an exact number of grid hits
|
||||||
|
// against the same probe coordinates the audit itself sweeps.
|
||||||
|
const OCCLUSION_PROBE_Y_FRACTIONS = [0.25, 0.5, 0.75];
|
||||||
|
const OCCLUSION_PROBE_X_FRACTIONS = [0.03, 0.1, 0.2, 0.35, 0.5, 0.65, 0.8, 0.9, 0.97];
|
||||||
|
|
||||||
|
function occlusionProbePoints(textRect: RectInput): Array<{ x: number; y: number }> {
|
||||||
|
const points: Array<{ x: number; y: number }> = [];
|
||||||
|
for (const yFraction of OCCLUSION_PROBE_Y_FRACTIONS) {
|
||||||
|
const y = textRect.top + textRect.height * yFraction;
|
||||||
|
for (const xFraction of OCCLUSION_PROBE_X_FRACTIONS) {
|
||||||
|
points.push({ x: textRect.left + textRect.width * xFraction, y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds an occlusion scene where exactly `hitCount` of the 27 probe points
|
||||||
|
// are covered by an opaque overlay and the rest hit the headline itself
|
||||||
|
// (self-hit — not foreign, so not counted as occluded).
|
||||||
|
function auditCoverageScene(options: {
|
||||||
|
text: string;
|
||||||
|
hitCount: number;
|
||||||
|
}): ReturnType<typeof runAudit> {
|
||||||
|
const textRect = { left: 200, top: 500, width: 600, height: 80 };
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||||
|
<div id="headline">${options.text}</div>
|
||||||
|
<div id="overlay"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
installOcclusionGeometry({
|
||||||
|
styleOverrides: { overlay: { backgroundColor: "rgb(10, 10, 10)" } },
|
||||||
|
headlineTextRect: rect(textRect),
|
||||||
|
topmostId: "headline",
|
||||||
|
});
|
||||||
|
const hitPoints = occlusionProbePoints(textRect).slice(0, options.hitCount);
|
||||||
|
(
|
||||||
|
document as unknown as { elementFromPoint: (x: number, y: number) => Element | null }
|
||||||
|
).elementFromPoint = (x, y) => {
|
||||||
|
const isHit = hitPoints.some(
|
||||||
|
(point) => Math.abs(point.x - x) < 0.01 && Math.abs(point.y - y) < 0.01,
|
||||||
|
);
|
||||||
|
return document.getElementById(isHit ? "overlay" : "headline");
|
||||||
|
};
|
||||||
|
installAuditScript();
|
||||||
|
return runAudit();
|
||||||
|
}
|
||||||
|
|
||||||
function auditOcclusionScene(options: {
|
function auditOcclusionScene(options: {
|
||||||
headlineAttrs?: string;
|
headlineAttrs?: string;
|
||||||
overlayStyle: Partial<Record<string, string>>;
|
overlayStyle: Partial<Record<string, string>>;
|
||||||
@@ -812,22 +896,19 @@ async function runContrastAudit(): Promise<Array<Record<string, unknown>>> {
|
|||||||
return w.__contrastAuditFinish("stub", 0, candidates);
|
return w.__contrastAuditFinish("stub", 0, candidates);
|
||||||
}
|
}
|
||||||
|
|
||||||
function runAudit(): Array<{
|
interface AuditIssue {
|
||||||
code: string;
|
code: string;
|
||||||
selector: string;
|
selector: string;
|
||||||
containerSelector?: string;
|
containerSelector?: string;
|
||||||
overflow?: Record<string, number>;
|
overflow?: Record<string, number>;
|
||||||
message?: string;
|
message?: string;
|
||||||
}> {
|
coveredFraction?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runAudit(): AuditIssue[] {
|
||||||
const audit = (
|
const audit = (
|
||||||
window as unknown as {
|
window as unknown as {
|
||||||
__hyperframesLayoutAudit: (options: { time: number; tolerance: number }) => Array<{
|
__hyperframesLayoutAudit: (options: { time: number; tolerance: number }) => AuditIssue[];
|
||||||
code: string;
|
|
||||||
selector: string;
|
|
||||||
containerSelector?: string;
|
|
||||||
overflow?: Record<string, number>;
|
|
||||||
message?: string;
|
|
||||||
}>;
|
|
||||||
}
|
}
|
||||||
).__hyperframesLayoutAudit;
|
).__hyperframesLayoutAudit;
|
||||||
return audit({ time: 1, tolerance: 2 });
|
return audit({ time: 1, tolerance: 2 });
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import type { CommandDef } from "citty";
|
|
||||||
import { runCommand } from "citty";
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
bundleToSingleHtmlFailureMock,
|
||||||
|
metaDescription,
|
||||||
|
resolveProjectMock,
|
||||||
|
runAndCaptureStdio,
|
||||||
|
runAndFindJsonLogCall,
|
||||||
|
runAndParseJsonEnvelope,
|
||||||
|
} from "./deprecationTestHarness.js";
|
||||||
|
|
||||||
// resolveProject and bundleToSingleHtml are both reached via a dynamic
|
// resolveProject and bundleToSingleHtml are both reached via a dynamic
|
||||||
// `await import(...)` inside layout.ts's run() / runLayoutAudit(), so
|
// `await import(...)` inside layout.ts's run() / runLayoutAudit(), so
|
||||||
@@ -9,37 +15,11 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|||||||
// bundleToSingleHtml gives a deterministic, fast failure well before any
|
// bundleToSingleHtml gives a deterministic, fast failure well before any
|
||||||
// real browser or network work — exercising run()'s outer catch (the JSON
|
// real browser or network work — exercising run()'s outer catch (the JSON
|
||||||
// failure envelope) without needing headless Chrome.
|
// failure envelope) without needing headless Chrome.
|
||||||
const FAKE_PROJECT = {
|
vi.mock("../utils/project.js", () => resolveProjectMock());
|
||||||
dir: "/fake-project",
|
vi.mock("@hyperframes/core/compiler", () => bundleToSingleHtmlFailureMock());
|
||||||
name: "fake-project",
|
|
||||||
indexPath: "/fake-project/index.html",
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mock("../utils/project.js", () => ({
|
|
||||||
resolveProject: vi.fn(() => FAKE_PROJECT),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@hyperframes/core/compiler", () => ({
|
|
||||||
bundleToSingleHtml: vi.fn(async () => {
|
|
||||||
throw new Error("bundling failed (test double)");
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { createInspectCommand } from "./layout.js";
|
import { createInspectCommand } from "./layout.js";
|
||||||
|
|
||||||
/**
|
|
||||||
* citty's `meta` is `Resolvable<CommandMeta>` (object | promise | thunk).
|
|
||||||
* This file's commands always define it as a synchronous object literal, so
|
|
||||||
* narrow to that shape instead of asserting it with `as`.
|
|
||||||
*/
|
|
||||||
function metaDescription(command: CommandDef): string {
|
|
||||||
const meta = command.meta;
|
|
||||||
if (meta && typeof meta === "object" && "description" in meta) {
|
|
||||||
return String(meta.description ?? "");
|
|
||||||
}
|
|
||||||
throw new Error("expected a synchronous meta object");
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
@@ -51,53 +31,20 @@ describe("layout command deprecation (U5)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("prints a one-line deprecation notice to stderr and never to stdout", async () => {
|
it("prints a one-line deprecation notice to stderr and never to stdout", async () => {
|
||||||
const stderrWrites: string[] = [];
|
const { stderrText, stdoutText } = await runAndCaptureStdio(createInspectCommand("layout"));
|
||||||
const stdoutWrites: string[] = [];
|
|
||||||
vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
|
|
||||||
stderrWrites.push(String(chunk));
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
|
|
||||||
stdoutWrites.push(String(chunk));
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
|
|
||||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
||||||
|
|
||||||
await runCommand(createInspectCommand("layout"), { rawArgs: ["--json"] });
|
|
||||||
|
|
||||||
const stderrText = stderrWrites.join("");
|
|
||||||
expect(stderrText).toContain("hyperframes layout");
|
expect(stderrText).toContain("hyperframes layout");
|
||||||
expect(stderrText).toContain("hyperframes check");
|
expect(stderrText).toContain("hyperframes check");
|
||||||
expect(stdoutWrites.join("")).toBe("");
|
expect(stdoutText).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("--json output is valid JSON with _meta.deprecated === true on failure", async () => {
|
it("--json output is valid JSON with _meta.deprecated === true on failure", async () => {
|
||||||
vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
const { parsed } = await runAndParseJsonEnvelope(createInspectCommand("layout"));
|
||||||
vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
|
|
||||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
||||||
|
|
||||||
await runCommand(createInspectCommand("layout"), { rawArgs: ["--json"] });
|
|
||||||
|
|
||||||
const jsonCall = logSpy.mock.calls.find(
|
|
||||||
([arg]) => typeof arg === "string" && arg.trim().startsWith("{"),
|
|
||||||
);
|
|
||||||
expect(jsonCall).toBeDefined();
|
|
||||||
const parsed = JSON.parse(String(jsonCall?.[0]));
|
|
||||||
expect(parsed.ok).toBe(false);
|
expect(parsed.ok).toBe(false);
|
||||||
expect(parsed._meta.deprecated).toBe(true);
|
expect(parsed._meta.deprecated).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("the inspect command name produces the same _meta.deprecated === true envelope", async () => {
|
it("the inspect command name produces the same _meta.deprecated === true envelope", async () => {
|
||||||
vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
const jsonCall = await runAndFindJsonLogCall(createInspectCommand("inspect"));
|
||||||
vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
|
|
||||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
||||||
|
|
||||||
await runCommand(createInspectCommand("inspect"), { rawArgs: ["--json"] });
|
|
||||||
|
|
||||||
const jsonCall = logSpy.mock.calls.find(
|
|
||||||
([arg]) => typeof arg === "string" && arg.trim().startsWith("{"),
|
|
||||||
);
|
|
||||||
const parsed = JSON.parse(String(jsonCall?.[0]));
|
const parsed = JSON.parse(String(jsonCall?.[0]));
|
||||||
expect(parsed._meta.deprecated).toBe(true);
|
expect(parsed._meta.deprecated).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -529,7 +529,7 @@ export function createInspectCommand(commandName: "inspect" | "layout") {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const allIssues = collapseStatic
|
const allIssues = collapseStatic
|
||||||
? collapseStaticLayoutIssues(result.rawIssues)
|
? collapseStaticLayoutIssues(result.rawIssues, result.samples.length)
|
||||||
: result.rawIssues;
|
: result.rawIssues;
|
||||||
const limited = limitLayoutIssues(allIssues, maxIssues);
|
const limited = limitLayoutIssues(allIssues, maxIssues);
|
||||||
const summary = summarizeLayoutIssues(allIssues);
|
const summary = summarizeLayoutIssues(allIssues);
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import type { CommandDef } from "citty";
|
|
||||||
import { runCommand } from "citty";
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
// Imported before "./validate.js" below: validate.js's own static import of
|
||||||
|
// ../utils/project.js triggers that mocked module's factory as soon as
|
||||||
|
// validate.js loads, so resolveProjectMock/lintProjectFailureMock must
|
||||||
|
// already be bound by then (see the vi.mock calls a few lines down).
|
||||||
|
import {
|
||||||
|
lintProjectFailureMock,
|
||||||
|
metaDescription,
|
||||||
|
resolveProjectMock,
|
||||||
|
runAndCaptureStdio,
|
||||||
|
runAndParseJsonEnvelope,
|
||||||
|
} from "./deprecationTestHarness.js";
|
||||||
import {
|
import {
|
||||||
extractCompositionErrorsFromLint,
|
extractCompositionErrorsFromLint,
|
||||||
navigationTimeoutHint,
|
navigationTimeoutHint,
|
||||||
@@ -37,21 +46,8 @@ vi.mock("../utils/producer.js", () => ({
|
|||||||
// (the first await inside validateInBrowser) gives a fast, deterministic
|
// (the first await inside validateInBrowser) gives a fast, deterministic
|
||||||
// failure well before any real browser or network work — exercising run()'s
|
// failure well before any real browser or network work — exercising run()'s
|
||||||
// outer catch (the JSON failure envelope) without needing headless Chrome.
|
// outer catch (the JSON failure envelope) without needing headless Chrome.
|
||||||
const FAKE_PROJECT = {
|
vi.mock("../utils/project.js", () => resolveProjectMock());
|
||||||
dir: "/fake-project",
|
vi.mock("../utils/lintProject.js", () => lintProjectFailureMock());
|
||||||
name: "fake-project",
|
|
||||||
indexPath: "/fake-project/index.html",
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mock("../utils/project.js", () => ({
|
|
||||||
resolveProject: vi.fn(() => FAKE_PROJECT),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../utils/lintProject.js", () => ({
|
|
||||||
lintProject: vi.fn(async () => {
|
|
||||||
throw new Error("lint failed (test double)");
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Regression for the validate audio-duration-probe timeout: a slow-loading
|
// Regression for the validate audio-duration-probe timeout: a slow-loading
|
||||||
// media element's duration was snapshotted once, at a fixed point in time,
|
// media element's duration was snapshotted once, at a fixed point in time,
|
||||||
@@ -308,14 +304,6 @@ describe("navigationTimeoutHint", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function metaDescription(command: CommandDef): string {
|
|
||||||
const meta = command.meta;
|
|
||||||
if (meta && typeof meta === "object" && "description" in meta) {
|
|
||||||
return String(meta.description ?? "");
|
|
||||||
}
|
|
||||||
throw new Error("expected a synchronous meta object");
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("validate command deprecation (U5)", () => {
|
describe("validate command deprecation (U5)", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
@@ -327,41 +315,16 @@ describe("validate command deprecation (U5)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("prints a one-line deprecation notice to stderr and never to stdout", async () => {
|
it("prints a one-line deprecation notice to stderr and never to stdout", async () => {
|
||||||
const stderrWrites: string[] = [];
|
|
||||||
const stdoutWrites: string[] = [];
|
|
||||||
vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
|
|
||||||
stderrWrites.push(String(chunk));
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
|
|
||||||
stdoutWrites.push(String(chunk));
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
|
|
||||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
||||||
|
|
||||||
const { default: validateCommand } = await import("./validate.js");
|
const { default: validateCommand } = await import("./validate.js");
|
||||||
await runCommand(validateCommand, { rawArgs: ["--json"] });
|
const { stderrText, stdoutText } = await runAndCaptureStdio(validateCommand);
|
||||||
|
|
||||||
const stderrText = stderrWrites.join("");
|
|
||||||
expect(stderrText).toContain("hyperframes validate");
|
expect(stderrText).toContain("hyperframes validate");
|
||||||
expect(stderrText).toContain("hyperframes check");
|
expect(stderrText).toContain("hyperframes check");
|
||||||
expect(stdoutWrites.join("")).toBe("");
|
expect(stdoutText).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("--json output is valid JSON with _meta.deprecated === true on failure", async () => {
|
it("--json output is valid JSON with _meta.deprecated === true on failure", async () => {
|
||||||
vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
|
||||||
vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
|
|
||||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
||||||
|
|
||||||
const { default: validateCommand } = await import("./validate.js");
|
const { default: validateCommand } = await import("./validate.js");
|
||||||
await runCommand(validateCommand, { rawArgs: ["--json"] });
|
const { parsed } = await runAndParseJsonEnvelope(validateCommand);
|
||||||
|
|
||||||
const jsonCall = logSpy.mock.calls.find(
|
|
||||||
([arg]) => typeof arg === "string" && arg.trim().startsWith("{"),
|
|
||||||
);
|
|
||||||
expect(jsonCall).toBeDefined();
|
|
||||||
const parsed = JSON.parse(String(jsonCall?.[0]));
|
|
||||||
expect(parsed.ok).toBe(false);
|
expect(parsed.ok).toBe(false);
|
||||||
expect(parsed._meta.deprecated).toBe(true);
|
expect(parsed._meta.deprecated).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -256,6 +256,7 @@ function createPageDriver(page: Page, setTime: (time: number) => void): CheckAud
|
|||||||
await seekCompositionTimeline(page, time, SEEK_OPTIONS);
|
await seekCompositionTimeline(page, time, SEEK_OPTIONS);
|
||||||
},
|
},
|
||||||
collectLayout: (time, tolerance) => collectLayout(page, time, tolerance),
|
collectLayout: (time, tolerance) => collectLayout(page, time, tolerance),
|
||||||
|
collectLayoutGeometry: () => collectLayoutGeometry(page),
|
||||||
collectGeometryCandidates: (time, request) => collectGeometryCandidates(page, time, request),
|
collectGeometryCandidates: (time, request) => collectGeometryCandidates(page, time, request),
|
||||||
collectMotionFrame: (time, selectors, scopes) =>
|
collectMotionFrame: (time, selectors, scopes) =>
|
||||||
collectMotionFrame(page, time, selectors, scopes),
|
collectMotionFrame(page, time, selectors, scopes),
|
||||||
@@ -368,6 +369,15 @@ async function collectLayout(
|
|||||||
return anchorLayoutIssues(page, raw.flatMap(parseLayoutIssue));
|
return anchorLayoutIssues(page, raw.flatMap(parseLayoutIssue));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function collectLayoutGeometry(page: Page): Promise<string> {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const geometry = Reflect.get(window, "__hyperframesLayoutGeometry");
|
||||||
|
if (typeof geometry !== "function") return "";
|
||||||
|
const result = Reflect.apply(geometry, window, []);
|
||||||
|
return typeof result === "string" ? result : "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function collectGeometryCandidates(
|
async function collectGeometryCandidates(
|
||||||
page: Page,
|
page: Page,
|
||||||
time: number,
|
time: number,
|
||||||
@@ -814,6 +824,8 @@ function assignOptionalLayoutFields(issue: LayoutIssue, value: Record<string, un
|
|||||||
if (containerRect) issue.containerRect = containerRect;
|
if (containerRect) issue.containerRect = containerRect;
|
||||||
const overflow = parseOverflow(Reflect.get(value, "overflow"));
|
const overflow = parseOverflow(Reflect.get(value, "overflow"));
|
||||||
if (overflow) issue.overflow = overflow;
|
if (overflow) issue.overflow = overflow;
|
||||||
|
const coveredFraction = numberValue(value, "coveredFraction");
|
||||||
|
if (coveredFraction !== null) issue.coveredFraction = coveredFraction;
|
||||||
}
|
}
|
||||||
|
|
||||||
function recordField(value: unknown, key: string): Record<string, unknown> | null {
|
function recordField(value: unknown, key: string): Record<string, unknown> | null {
|
||||||
|
|||||||
@@ -186,6 +186,8 @@ interface GridSamples {
|
|||||||
contrastEntries: ContrastAuditEntry[];
|
contrastEntries: ContrastAuditEntry[];
|
||||||
screenshots: CheckScreenshot[];
|
screenshots: CheckScreenshot[];
|
||||||
contrastMs: number;
|
contrastMs: number;
|
||||||
|
/** One geometry+opacity fingerprint per layout sample (#U10 frozen-sweep guard). */
|
||||||
|
geometrySignatures: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GeometrySeen {
|
interface GeometrySeen {
|
||||||
@@ -356,6 +358,7 @@ async function collectGridSamples(
|
|||||||
contrastEntries: [],
|
contrastEntries: [],
|
||||||
screenshots: [],
|
screenshots: [],
|
||||||
contrastMs: 0,
|
contrastMs: 0,
|
||||||
|
geometrySignatures: [],
|
||||||
};
|
};
|
||||||
for (const time of mergeSampleTimes(grid.layoutSamples, motion.times)) {
|
for (const time of mergeSampleTimes(grid.layoutSamples, motion.times)) {
|
||||||
await driver.seek(time);
|
await driver.seek(time);
|
||||||
@@ -367,6 +370,7 @@ async function collectGridSamples(
|
|||||||
const layoutIssues = await driver.collectLayout(time, options.tolerance);
|
const layoutIssues = await driver.collectLayout(time, options.tolerance);
|
||||||
collected.layoutIssues.push(...layoutIssues);
|
collected.layoutIssues.push(...layoutIssues);
|
||||||
issuesAtTime.push(...layoutIssues);
|
issuesAtTime.push(...layoutIssues);
|
||||||
|
collected.geometrySignatures.push(await driver.collectLayoutGeometry());
|
||||||
}
|
}
|
||||||
if (canvas) {
|
if (canvas) {
|
||||||
const geometryIssues = await collectGeometryAt(
|
const geometryIssues = await collectGeometryAt(
|
||||||
@@ -401,6 +405,55 @@ async function collectGridSamples(
|
|||||||
return collected;
|
return collected;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Frozen-sweep guard (#U10): compositions this short can legitimately hold a
|
||||||
|
// single static frame the whole time (a title card) — never flag those.
|
||||||
|
const SWEEP_STATIC_MIN_DURATION_SEC = 3;
|
||||||
|
const ZERO_LAYOUT_RECT: LayoutRect = {
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frozen-sweep guard (#U10): if every layout-grid sample produced the exact
|
||||||
|
* same geometry+opacity fingerprint (see layout-audit.browser.js), the seek
|
||||||
|
* never actually advanced the composition's timeline — every other green
|
||||||
|
* verdict from this run is meaningless, not just a missed defect. Skips
|
||||||
|
* short (<3s) compositions, single-sample runs (nothing to compare), and
|
||||||
|
* runs where a `motion_frozen` finding already reported the same underlying
|
||||||
|
* symptom (no double-reporting the one thing that's wrong).
|
||||||
|
*/
|
||||||
|
function detectSweepStatic(
|
||||||
|
duration: number,
|
||||||
|
geometrySignatures: string[],
|
||||||
|
motionIssues: AnchoredLayoutIssue[],
|
||||||
|
): AnchoredLayoutIssue[] {
|
||||||
|
if (duration < SWEEP_STATIC_MIN_DURATION_SEC) return [];
|
||||||
|
if (geometrySignatures.length < 2) return [];
|
||||||
|
if (motionIssues.some((issue) => issue.code === "motion_frozen")) return [];
|
||||||
|
const [first, ...rest] = geometrySignatures;
|
||||||
|
if (!first || rest.some((signature) => signature !== first)) return [];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
code: "sweep_static",
|
||||||
|
severity: "error",
|
||||||
|
time: 0,
|
||||||
|
selector: "[data-composition-id]",
|
||||||
|
dataAttributes: {},
|
||||||
|
sourceFile: "index.html",
|
||||||
|
bbox: ZERO_BBOX,
|
||||||
|
rect: ZERO_LAYOUT_RECT,
|
||||||
|
message:
|
||||||
|
"Timeline did not advance under seek; every green verdict on this run is unreliable.",
|
||||||
|
fixHint:
|
||||||
|
"Confirm the composition seeks a paused GSAP/CSS timeline under `data-*` timing attributes rather than only autoplaying.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/** Error-severity findings with real geometry become labeled overview boxes.
|
/** Error-severity findings with real geometry become labeled overview boxes.
|
||||||
* Contrast failures are annotated separately by the driver itself, since
|
* Contrast failures are annotated separately by the driver itself, since
|
||||||
* they're only known once contrast measurement for this sample completes. */
|
* they're only known once contrast measurement for this sample completes. */
|
||||||
@@ -431,6 +484,11 @@ export async function runAuditGrid(
|
|||||||
);
|
);
|
||||||
motionIssues = await driver.anchorMotionIssues(evaluated);
|
motionIssues = await driver.anchorMotionIssues(evaluated);
|
||||||
}
|
}
|
||||||
|
const sweepFindings = detectSweepStatic(
|
||||||
|
grid.duration,
|
||||||
|
collected.geometrySignatures,
|
||||||
|
motionIssues,
|
||||||
|
);
|
||||||
const contrast = buildContrastResults(collected.contrastEntries);
|
const contrast = buildContrastResults(collected.contrastEntries);
|
||||||
return {
|
return {
|
||||||
duration: grid.duration,
|
duration: grid.duration,
|
||||||
@@ -438,7 +496,7 @@ export async function runAuditGrid(
|
|||||||
transitionSamples: grid.transitionSamples,
|
transitionSamples: grid.transitionSamples,
|
||||||
transitionSamplesDropped: grid.transitionSamplesDropped,
|
transitionSamplesDropped: grid.transitionSamplesDropped,
|
||||||
runtimeFindings: [],
|
runtimeFindings: [],
|
||||||
layoutIssues: collected.layoutIssues,
|
layoutIssues: [...collected.layoutIssues, ...sweepFindings],
|
||||||
motionIssues,
|
motionIssues,
|
||||||
motionSampleCount: collected.motionFrames.length,
|
motionSampleCount: collected.motionFrames.length,
|
||||||
contrastSamples: grid.contrastSamples,
|
contrastSamples: grid.contrastSamples,
|
||||||
@@ -719,7 +777,7 @@ function shapeLayoutSection(
|
|||||||
browser: CheckBrowserResult,
|
browser: CheckBrowserResult,
|
||||||
options: CheckOptions,
|
options: CheckOptions,
|
||||||
): CheckReport["layout"] {
|
): CheckReport["layout"] {
|
||||||
const shaped = shapeLayoutFindings(issues, options);
|
const shaped = shapeLayoutFindings(issues, options, browser.layoutSamples.length);
|
||||||
return {
|
return {
|
||||||
...section(shaped.findings),
|
...section(shaped.findings),
|
||||||
duration: browser.duration,
|
duration: browser.duration,
|
||||||
@@ -735,9 +793,12 @@ function shapeLayoutSection(
|
|||||||
function shapeLayoutFindings(
|
function shapeLayoutFindings(
|
||||||
issues: AnchoredLayoutIssue[],
|
issues: AnchoredLayoutIssue[],
|
||||||
options: CheckOptions,
|
options: CheckOptions,
|
||||||
|
totalSampleCount?: number,
|
||||||
): { findings: AnchoredLayoutIssue[]; totalIssueCount: number; truncated: boolean } {
|
): { findings: AnchoredLayoutIssue[]; totalIssueCount: number; truncated: boolean } {
|
||||||
const deduped = dedupeLayoutIssues(issues);
|
const deduped = dedupeLayoutIssues(issues);
|
||||||
const all = options.collapseStatic ? collapseStaticLayoutIssues(deduped) : deduped;
|
const all = options.collapseStatic
|
||||||
|
? collapseStaticLayoutIssues(deduped, totalSampleCount)
|
||||||
|
: deduped;
|
||||||
const limited = limitLayoutIssues(all, options.maxIssues);
|
const limited = limitLayoutIssues(all, options.maxIssues);
|
||||||
return {
|
return {
|
||||||
findings: limited.issues.map(ensureAnchoredLayoutIssue),
|
findings: limited.issues.map(ensureAnchoredLayoutIssue),
|
||||||
|
|||||||
@@ -131,6 +131,10 @@ export interface CheckAuditDriver {
|
|||||||
findAmbiguousSelectors(selectors: string[]): Promise<AnchoredLayoutIssue[]>;
|
findAmbiguousSelectors(selectors: string[]): Promise<AnchoredLayoutIssue[]>;
|
||||||
seek(time: number): Promise<void>;
|
seek(time: number): Promise<void>;
|
||||||
collectLayout(time: number, tolerance: number): Promise<AnchoredLayoutIssue[]>;
|
collectLayout(time: number, tolerance: number): Promise<AnchoredLayoutIssue[]>;
|
||||||
|
/** Frozen-sweep guard (#U10): an opaque per-sample geometry+opacity
|
||||||
|
* fingerprint of the current seeked state, for detecting a timeline that
|
||||||
|
* never advances under seek. See layout-audit.browser.js. */
|
||||||
|
collectLayoutGeometry(): Promise<string>;
|
||||||
collectGeometryCandidates(
|
collectGeometryCandidates(
|
||||||
time: number,
|
time: number,
|
||||||
request: GeometryCandidateRequest,
|
request: GeometryCandidateRequest,
|
||||||
|
|||||||
@@ -199,6 +199,82 @@ describe("layoutAudit helpers", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #U10: held-duration severity tiering on top of the existing collapse step.
|
||||||
|
// Sample counts below (9) mirror the CLI's default grid so the "1 sample =
|
||||||
|
// entrance/exit transient, 2+ adjacent samples = held" framing in the
|
||||||
|
// approach doc lines up with the numbers used here.
|
||||||
|
describe("persistence-tiered severity (#U10)", () => {
|
||||||
|
it("demotes a content_overlap seen at only one sample among several to info", () => {
|
||||||
|
const collapsed = collapseStaticLayoutIssues(
|
||||||
|
[{ ...issue("content_overlap", "warning"), time: 3 }],
|
||||||
|
9,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(collapsed).toHaveLength(1);
|
||||||
|
expect(collapsed[0]).toMatchObject({ severity: "info", occurrences: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("promotes content_overlap held across >= 2 adjacent samples to error", () => {
|
||||||
|
const collapsed = collapseStaticLayoutIssues(
|
||||||
|
[
|
||||||
|
{ ...issue("content_overlap", "warning"), time: 3 },
|
||||||
|
{ ...issue("content_overlap", "warning"), time: 3.6 },
|
||||||
|
],
|
||||||
|
9,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(collapsed).toHaveLength(1);
|
||||||
|
expect(collapsed[0]).toMatchObject({ severity: "error", occurrences: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not demote a finding held at every sample — persistence, not a single hit", () => {
|
||||||
|
const collapsed = collapseStaticLayoutIssues(
|
||||||
|
[
|
||||||
|
{ ...issue("text_box_overflow", "error"), time: 1 },
|
||||||
|
{ ...issue("text_box_overflow", "error"), time: 3 },
|
||||||
|
{ ...issue("text_box_overflow", "error"), time: 5 },
|
||||||
|
],
|
||||||
|
9,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(collapsed).toHaveLength(1);
|
||||||
|
expect(collapsed[0]).toMatchObject({ severity: "error", occurrences: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only re-promotes content_overlap — other held codes keep their original severity", () => {
|
||||||
|
const collapsed = collapseStaticLayoutIssues(
|
||||||
|
[
|
||||||
|
{ ...issue("container_overflow", "warning"), time: 3 },
|
||||||
|
{ ...issue("container_overflow", "warning"), time: 3.6 },
|
||||||
|
],
|
||||||
|
9,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(collapsed[0]).toMatchObject({ severity: "warning" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips tiering entirely on a single-sample run — nothing to compare a transient against", () => {
|
||||||
|
const collapsed = collapseStaticLayoutIssues(
|
||||||
|
[{ ...issue("content_overlap", "warning"), time: 3 }],
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(collapsed[0]).toMatchObject({ severity: "warning" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("infers the sample count from distinct issue times when none is given", () => {
|
||||||
|
// Two distinct times among the raw issues imply a multi-sample run even
|
||||||
|
// without an explicit count, so the single-occurrence group still demotes.
|
||||||
|
const collapsed = collapseStaticLayoutIssues([
|
||||||
|
{ ...issue("content_overlap", "warning"), time: 3 },
|
||||||
|
{ ...issue("text_box_overflow", "error"), time: 5 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const overlap = collapsed.find((found) => found.code === "content_overlap");
|
||||||
|
expect(overlap).toMatchObject({ severity: "info" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function issue(code: LayoutIssue["code"], severity: LayoutIssue["severity"]): LayoutIssue {
|
function issue(code: LayoutIssue["code"], severity: LayoutIssue["severity"]): LayoutIssue {
|
||||||
return {
|
return {
|
||||||
code,
|
code,
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ export type LayoutIssueCode =
|
|||||||
| "text_occluded"
|
| "text_occluded"
|
||||||
| "caption_zone_collision"
|
| "caption_zone_collision"
|
||||||
| "frame_out_of_frame"
|
| "frame_out_of_frame"
|
||||||
|
// Frozen-sweep guard (#U10) — a whole-run meta-finding, not a per-sample
|
||||||
|
// geometry observation; never persistence-tiered (see `applyPersistenceTier`).
|
||||||
|
| "sweep_static"
|
||||||
// Motion-verification findings (#1437) — evaluated against the seeked timeline.
|
// Motion-verification findings (#1437) — evaluated against the seeked timeline.
|
||||||
| "motion_appears_late"
|
| "motion_appears_late"
|
||||||
| "motion_out_of_order"
|
| "motion_out_of_order"
|
||||||
@@ -42,6 +45,9 @@ export interface LayoutIssue {
|
|||||||
rect: LayoutRect;
|
rect: LayoutRect;
|
||||||
containerRect?: LayoutRect;
|
containerRect?: LayoutRect;
|
||||||
overflow?: LayoutOverflow;
|
overflow?: LayoutOverflow;
|
||||||
|
/** `text_occluded` only: approximate fraction (0-1) of the occlusion probe
|
||||||
|
* grid that hit an opaque occluder — see layout-audit.browser.js. */
|
||||||
|
coveredFraction?: number;
|
||||||
fixHint?: string;
|
fixHint?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +170,44 @@ export function dedupeLayoutIssues(issues: LayoutIssue[]): LayoutIssue[] {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function collapseStaticLayoutIssues(issues: LayoutIssue[]): LayoutIssue[] {
|
// Persistence-tier thresholds (#U10, adapted from Adam Rosler's visual-linter
|
||||||
|
// design). The approach doc frames these as held-duration floors — ignore
|
||||||
|
// under ~250ms, re-promote content_overlap at >= ~500ms — measured against
|
||||||
|
// the SAME firstSeen/lastSeen span this collapse step already tracks. At the
|
||||||
|
// default 9-sample grid over a multi-second composition, a single collapsed
|
||||||
|
// occurrence is held 0ms (one entrance/exit transient sample) and two
|
||||||
|
// collapsed occurrences are already >= one sample-to-sample gap, which is
|
||||||
|
// well past 500ms — so "held under 250ms" reduces to `occurrences <= 1` and
|
||||||
|
// "held >= 500ms" reduces to `occurrences >= 2`. Tiering below is written in
|
||||||
|
// those sample-count terms (the mapping the approach doc asks to document),
|
||||||
|
// with the literal ms span (CONTENT_OVERLAP_HELD_ERROR_MS) kept as a fallback
|
||||||
|
// for callers whose samples really are spaced close enough together for the
|
||||||
|
// ms floor to matter on its own (dense `--at`/`--at-transitions` runs). The
|
||||||
|
// ~250ms ignore floor needs no separate constant — see the occurrences <= 1
|
||||||
|
// branch below.
|
||||||
|
const CONTENT_OVERLAP_HELD_ERROR_MS = 500;
|
||||||
|
const HELD_ACROSS_SAMPLES_MIN_OCCURRENCES = 2;
|
||||||
|
|
||||||
|
// Tiering only applies to layout-audit.browser.js's own per-sample seek-grid
|
||||||
|
// findings — the ones this collapse step's firstSeen/lastSeen span was built
|
||||||
|
// to describe. `caption_zone_collision`/`frame_out_of_frame` (a different
|
||||||
|
// script, U3) and the `motion_*`/`sweep_static` codes (evaluated once over
|
||||||
|
// the whole run, not per grid sample) already carry their own singular
|
||||||
|
// dedupe/severity semantics; re-interpreting their occurrence count as a
|
||||||
|
// held-duration signal would misread it.
|
||||||
|
const PERSISTENCE_TIERED_CODES: ReadonlySet<LayoutIssueCode> = new Set([
|
||||||
|
"text_box_overflow",
|
||||||
|
"clipped_text",
|
||||||
|
"canvas_overflow",
|
||||||
|
"container_overflow",
|
||||||
|
"content_overlap",
|
||||||
|
"text_occluded",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function collapseStaticLayoutIssues(
|
||||||
|
issues: LayoutIssue[],
|
||||||
|
totalSampleCount?: number,
|
||||||
|
): LayoutIssue[] {
|
||||||
const groups = new Map<
|
const groups = new Map<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
@@ -193,13 +236,57 @@ export function collapseStaticLayoutIssues(issues: LayoutIssue[]): LayoutIssue[]
|
|||||||
existing.occurrences += 1;
|
existing.occurrences += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...groups.values()].map(({ issue, firstSeen, lastSeen, occurrences }) => ({
|
// A run that only ever sampled one point in time can't distinguish a
|
||||||
...issue,
|
// transient from a persistent finding — skip tiering entirely rather than
|
||||||
time: firstSeen,
|
// guess (see `applyPersistenceTier`).
|
||||||
firstSeen,
|
const sampleCount = totalSampleCount ?? new Set(issues.map((issue) => issue.time)).size;
|
||||||
lastSeen,
|
const multiSampleRun = sampleCount > 1;
|
||||||
occurrences,
|
|
||||||
}));
|
return [...groups.values()].map(({ issue, firstSeen, lastSeen, occurrences }) =>
|
||||||
|
applyPersistenceTier(
|
||||||
|
{ ...issue, time: firstSeen, firstSeen, lastSeen, occurrences },
|
||||||
|
multiSampleRun,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Held-duration severity tiering (#U10). A finding observed at only one
|
||||||
|
* sample among several (held 0ms) is an entrance/exit transient, not a held
|
||||||
|
* defect — demote to info so it stays in the data (verbose/--json output)
|
||||||
|
* without gating the run. `content_overlap` specifically re-promotes from
|
||||||
|
* warning to error once it's held long enough to be a real, sustained
|
||||||
|
* collision rather than a crossfade/transition blip (resolves the TODO in
|
||||||
|
* layout-audit.browser.js's `overlapIssue`). A finding held at every sample
|
||||||
|
* (a genuinely static defect) is well past both thresholds and is left
|
||||||
|
* untouched either way — persistence, not the code, decides the tier.
|
||||||
|
*/
|
||||||
|
function applyPersistenceTier(issue: LayoutIssue, multiSampleRun: boolean): LayoutIssue {
|
||||||
|
if (!multiSampleRun) return issue;
|
||||||
|
if (!PERSISTENCE_TIERED_CODES.has(issue.code)) return issue;
|
||||||
|
|
||||||
|
const occurrences = issue.occurrences ?? 1;
|
||||||
|
// A single collapsed occurrence is held 0ms by construction (firstSeen ===
|
||||||
|
// lastSeen) — always under the ignore floor, so occurrences <= 1 is a
|
||||||
|
// complete (not approximate) test for "held under 250ms".
|
||||||
|
if (occurrences <= 1) {
|
||||||
|
return { ...issue, severity: "info" };
|
||||||
|
}
|
||||||
|
if (issue.code === "content_overlap" && isContentOverlapHeldLongEnough(issue, occurrences)) {
|
||||||
|
return { ...issue, severity: "error" };
|
||||||
|
}
|
||||||
|
return issue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split out of applyPersistenceTier so the two independent "held long enough"
|
||||||
|
// signals (sample count vs. wall-clock span) read as one boolean question
|
||||||
|
// instead of adding a third compound branch to the tiering ladder above.
|
||||||
|
function isContentOverlapHeldLongEnough(issue: LayoutIssue, occurrences: number): boolean {
|
||||||
|
if (occurrences >= HELD_ACROSS_SAMPLES_MIN_OCCURRENCES) return true;
|
||||||
|
const firstSeen = issue.firstSeen ?? issue.time;
|
||||||
|
const lastSeen = issue.lastSeen ?? issue.time;
|
||||||
|
const heldMs = (lastSeen - firstSeen) * 1000;
|
||||||
|
return heldMs >= CONTENT_OVERLAP_HELD_ERROR_MS;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function limitLayoutIssues(
|
export function limitLayoutIssues(
|
||||||
|
|||||||
@@ -2,6 +2,52 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { createConsoleLogger, defaultLogger } from "./logger.js";
|
import { createConsoleLogger, defaultLogger } from "./logger.js";
|
||||||
import type { LogLevel, ProducerLogger } from "./logger.js";
|
import type { LogLevel, ProducerLogger } from "./logger.js";
|
||||||
|
|
||||||
|
// `isLevelEnabled` is optional on ProducerLogger, so every call site guards
|
||||||
|
// it with `?.`; pulled out once so the loops below stay single-branch.
|
||||||
|
function isLevelEnabledSafe(
|
||||||
|
log: Pick<ProducerLogger, "isLevelEnabled">,
|
||||||
|
level: LogLevel,
|
||||||
|
): boolean | undefined {
|
||||||
|
return log.isLevelEnabled?.(level);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared by the isLevelEnabled matrix cases below: assert a threshold's
|
||||||
|
// enabled levels report true and its disabled levels report false.
|
||||||
|
function assertLevelEnabledMatrix(
|
||||||
|
log: Pick<ProducerLogger, "isLevelEnabled">,
|
||||||
|
enabled: ReadonlyArray<LogLevel>,
|
||||||
|
disabled: ReadonlyArray<LogLevel>,
|
||||||
|
): void {
|
||||||
|
for (const lvl of enabled) {
|
||||||
|
expect(isLevelEnabledSafe(log, lvl)).toBe(true);
|
||||||
|
}
|
||||||
|
for (const lvl of disabled) {
|
||||||
|
expect(isLevelEnabledSafe(log, lvl)).toBe(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The `isLevelEnabled?.("debug") ?? true` call-site gate pattern itself,
|
||||||
|
// isolated so runGatedDebugLoop's own branch count stays at "loop + if".
|
||||||
|
function isDebugGated(log: Pick<ProducerLogger, "isLevelEnabled">): boolean {
|
||||||
|
return log.isLevelEnabled?.("debug") ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared by the call-site gate cases below: run the `isLevelEnabled?.("debug")
|
||||||
|
// ?? true` pattern callers use to skip expensive meta construction, the
|
||||||
|
// exact number of times the test needs, so each test asserts only the
|
||||||
|
// pattern's outcome (buildCount / logged calls) and not the loop mechanics.
|
||||||
|
function runGatedDebugLoop(
|
||||||
|
log: Pick<ProducerLogger, "debug" | "isLevelEnabled">,
|
||||||
|
iterations: number,
|
||||||
|
buildMeta: () => Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
for (let i = 0; i < iterations; i++) {
|
||||||
|
if (isDebugGated(log)) {
|
||||||
|
log.debug("evt", buildMeta());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe("createConsoleLogger", () => {
|
describe("createConsoleLogger", () => {
|
||||||
// We capture calls to console.{log,warn,error} via `vi.fn` so we can
|
// We capture calls to console.{log,warn,error} via `vi.fn` so we can
|
||||||
// assert what would have been printed without polluting test output.
|
// assert what would have been printed without polluting test output.
|
||||||
@@ -194,12 +240,7 @@ describe("createConsoleLogger", () => {
|
|||||||
for (const { threshold, enabled, disabled } of cases) {
|
for (const { threshold, enabled, disabled } of cases) {
|
||||||
it(`level=${threshold} reports enabled levels correctly`, () => {
|
it(`level=${threshold} reports enabled levels correctly`, () => {
|
||||||
const log = createConsoleLogger(threshold);
|
const log = createConsoleLogger(threshold);
|
||||||
for (const lvl of enabled) {
|
assertLevelEnabledMatrix(log, enabled, disabled);
|
||||||
expect(log.isLevelEnabled?.(lvl)).toBe(true);
|
|
||||||
}
|
|
||||||
for (const lvl of disabled) {
|
|
||||||
expect(log.isLevelEnabled?.(lvl)).toBe(false);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,11 +255,7 @@ describe("createConsoleLogger", () => {
|
|||||||
return { expensive: true };
|
return { expensive: true };
|
||||||
};
|
};
|
||||||
|
|
||||||
for (let i = 0; i < 100; i++) {
|
runGatedDebugLoop(log, 100, buildMeta);
|
||||||
if (log.isLevelEnabled?.("debug") ?? true) {
|
|
||||||
log.debug("hot-loop", buildMeta());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(buildCount).toBe(0);
|
expect(buildCount).toBe(0);
|
||||||
expect(errorSpy.mock.calls.length).toBe(0);
|
expect(errorSpy.mock.calls.length).toBe(0);
|
||||||
@@ -232,11 +269,7 @@ describe("createConsoleLogger", () => {
|
|||||||
return { iter: buildCount };
|
return { iter: buildCount };
|
||||||
};
|
};
|
||||||
|
|
||||||
for (let i = 0; i < 5; i++) {
|
runGatedDebugLoop(log, 5, buildMeta);
|
||||||
if (log.isLevelEnabled?.("debug") ?? true) {
|
|
||||||
log.debug("loop", buildMeta());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(buildCount).toBe(5);
|
expect(buildCount).toBe(5);
|
||||||
expect(errorSpy.mock.calls.length).toBe(5);
|
expect(errorSpy.mock.calls.length).toBe(5);
|
||||||
@@ -260,11 +293,7 @@ describe("createConsoleLogger", () => {
|
|||||||
return { i: buildCount };
|
return { i: buildCount };
|
||||||
};
|
};
|
||||||
|
|
||||||
for (let i = 0; i < 3; i++) {
|
runGatedDebugLoop(customLog, 3, buildMeta);
|
||||||
if (customLog.isLevelEnabled?.("debug") ?? true) {
|
|
||||||
customLog.debug("evt", buildMeta());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(buildCount).toBe(3);
|
expect(buildCount).toBe(3);
|
||||||
expect(calls).toHaveLength(3);
|
expect(calls).toHaveLength(3);
|
||||||
|
|||||||
Reference in New Issue
Block a user