Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions packages/cli/src/commands/check.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { CheckProgress } from "../utils/checkProgress.js";
import { runCommand } from "citty";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
Expand Down Expand Up @@ -2280,6 +2281,40 @@ describe("check pipeline", () => {
}
});

it("slow analysis calls that each finish keep the check alive past 120 s", async () => {
vi.useFakeTimers();
try {
let analysing = false;
const slowWhenAnalysing = async () => {
if (analysing) await new Promise((resolve) => setTimeout(resolve, 100_000));
};
const hasNoTimelineDeclaration = vi.fn(async () => {
await slowWhenAnalysing();
return false;
});
const getCanvas = vi.fn(async () => {
await slowWhenAnalysing();
return { width: 1920, height: 1080 };
});
const driver = fakeDriver({ hasNoTimelineDeclaration, getCanvas });
const { deps } = dependencies(driver);
deps.runBrowserCheck = async (_project, options, motion, deadline) => {
deadline!.start();
const report: CheckProgress = (phase) => {
if (phase === "analyze") analysing = true;
};
return runAuditGrid(driver, options, motion, undefined, deadline!.enter, report);
};
const pending = runCheckPipeline(PROJECT, DEFAULT_CHECK_OPTIONS, deps);
await vi.advanceTimersByTimeAsync(2_000_000);
const codes = (await pending).runtime.findings.map((finding) => finding.code);
expect(codes).not.toContain("check_runtime_failure");
expect(hasNoTimelineDeclaration).toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});

it("a long overlap pass that keeps reporting progress finishes instead of hitting a wall clock", async () => {
vi.useFakeTimers();
try {
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/utils/checkDeadline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const PHASE_LABELS = {
load: "loading the page",
settle: "waiting for the page to be ready",
samples: "checking samples",
analysis: "analyzing the samples",
overlap: "running the overlap pass",
crops: "capturing finding crops",
frames: "capturing finding frames",
Expand Down
59 changes: 59 additions & 0 deletions packages/cli/src/utils/checkPipeline.reportingProgress.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { CheckDeadline, CheckDeadlineError } from "./checkDeadline.js";
import { reportingProgress } from "./checkPipeline.js";

afterEach(() => vi.useRealTimers());

const IDLE_MS = 300;
const STEP_MS = 200;

/** Three analysis steps that each finish inside the idle window but together outlast it. */
async function analyse(step: () => Promise<void>, deadline: CheckDeadline): Promise<void> {
const work = deadline.run(async () => {
for (let i = 0; i < 3; i++) await step();
});
const settled = work.then(
() => "finished",
(error: unknown) => error,
);
await vi.advanceTimersByTimeAsync(IDLE_MS * 4);
expect(await settled).toBe("finished");
}

describe("reportingProgress", () => {
it("restarts the idle window on every call the analysis makes", async () => {
vi.useFakeTimers();
const deadline = new CheckDeadline(IDLE_MS);
deadline.start();
const driver = { seek: () => new Promise<void>((done) => setTimeout(done, STEP_MS)) };
const watched = reportingProgress(driver, deadline.enter);

await analyse(() => watched.seek(), deadline);
});

it("ends a step that makes no call for the whole window, naming the analysis", async () => {
vi.useFakeTimers();
const deadline = new CheckDeadline(IDLE_MS);
deadline.start();
deadline.enter("analysis");
const silent = deadline.run(() => new Promise<void>(() => undefined));
const outcome = silent.catch((error: unknown) => error);

await vi.advanceTimersByTimeAsync(IDLE_MS + 1);

const error = await outcome;
expect(error).toBeInstanceOf(CheckDeadlineError);
expect((error as Error).message).toContain("analyzing the samples");
});

it("passes arguments and results through and leaves other members alone", async () => {
const enter = vi.fn();
const driver = { label: "x", add: async (a: number, b: number) => a + b, optional: undefined };
const watched = reportingProgress(driver, enter);

expect(await watched.add(2, 3)).toBe(5);
expect(watched.label).toBe("x");
expect(watched.optional).toBeUndefined();
expect(enter).toHaveBeenCalledWith("analysis", "add");
});
});
45 changes: 32 additions & 13 deletions packages/cli/src/utils/checkPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1505,6 +1505,23 @@ function disabledCraft(): CraftResult {
return { enabled: false, step: 0, samples: 0, findings: [] };
}

/** The analysis re-seeks and re-captures frames on its own; each call it makes restarts the idle window. */
export function reportingProgress<T extends object>(
driver: T,
enter: (phase: CheckPhase, detail?: string) => void,
): T {
return new Proxy(driver, {
get(target, name) {
const member = Reflect.get(target, name);
if (typeof member !== "function") return member;
return (...args: unknown[]) => {
enter("analysis", String(name));
return member.apply(target, args);
};
},
});
}

export async function runAuditGrid(
driver: CheckAuditDriver,
options: CheckOptions,
Expand Down Expand Up @@ -1536,19 +1553,21 @@ export async function runAuditGrid(
const seekLoopMs = Date.now() - seekLoopStart;

report("analyze", 0, 3);
enter("analysis");
const analysing = reportingProgress(driver, enter);
let motionIssues = plan.preflightIssues;
if (motion.kind === "valid" && plan.assertions.length > 0 && collected.motionFrames.length > 0) {
const canvas = await driver.getCanvas();
const canvas = await analysing.getCanvas();
let evaluated = evaluateMotion(collected.motionFrames, plan.assertions, canvas, grid.duration);
if (evaluated.some((issue) => issue.code === "motion_frozen")) {
const frames = await withFrameMotion(driver, collected.motionFrames, plan.assertions);
const frames = await withFrameMotion(analysing, collected.motionFrames, plan.assertions);
evaluated = evaluateMotion(frames, plan.assertions, canvas, grid.duration);
}
motionIssues = [...motionIssues, ...(await driver.anchorMotionIssues(evaluated))];
motionIssues = [...motionIssues, ...(await analysing.anchorMotionIssues(evaluated))];
}
report("analyze", 1, 3);
const userPicked = new Set(grid.userPickedSamples);
const noTimeline = await driver.hasNoTimelineDeclaration();
const noTimeline = await analysing.hasNoTimelineDeclaration();
const sweepSamples = collected.layoutStateSignatures.filter(
(sample) => !userPicked.has(sample.time),
);
Expand All @@ -1558,34 +1577,34 @@ export async function runAuditGrid(
const sweepFindings =
sweepVerdict.length > 0 &&
(await framesMoved(
driver,
analysing,
sweepSamples.map((sample) => sample.time),
))
? []
: sweepVerdict;
const rotationFindings = detectRotationPivotDrift(
collected.rotationSamples,
await driver.getCanvas(),
await analysing.getCanvas(),
);
const offPivotFindings = detectOffPivotRotation(collected.indicatorFrames);
const contrast = buildContrastResults(collected.contrastEntries);
report("analyze", 2, 3);
const craft = craftEnabled(driver, options)
const craft = craftEnabled(analysing, options)
? await auditCraft(
{
frames: collected.craftFrames,
duration: grid.duration,
canvas: await driver.getCanvas(),
soundSources: driver.collectCraftFilm
? (await driver.collectCraftFilm()).soundSources
canvas: await analysing.getCanvas(),
soundSources: analysing.collectCraftFilm
? (await analysing.collectCraftFilm()).soundSources
: null,
stillnessJudgedElsewhere:
noTimeline || sweepFindings.length > 0 || wholeCompositionKeepsMoving(plan.assertions),
},
async (time) => {
await driver.seekGeometry(time, { draws: true });
if (!driver.collectCraftFrame) throw new Error("craft sampling unavailable");
return driver.collectCraftFrame(time);
await analysing.seekGeometry(time, { draws: true });
if (!analysing.collectCraftFrame) throw new Error("craft sampling unavailable");
return analysing.collectCraftFrame(time);
},
)
: disabledCraft();
Expand Down
Loading