diff --git a/CLAUDE.md b/CLAUDE.md index de489e95..e2a7cbd0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -200,7 +200,7 @@ pnpm lint # Type check - Use ESM modules (`import`/`export`) - Use `.js` extension for relative imports -- Use `@/*` path alias to reference `src/` +- Use relative paths for imports (not `@/*` alias) ### TypeScript diff --git a/src/agents/analysis-agent.test.ts b/src/agents/analysis-agent.test.ts index 92ba0c2a..6f78faf0 100644 --- a/src/agents/analysis-agent.test.ts +++ b/src/agents/analysis-agent.test.ts @@ -1,6 +1,6 @@ import { runAnalysisAgent, extractRuleScores } from "./analysis-agent.js"; -import type { AnalysisResult, AnalysisIssue } from "@/core/engine/rule-engine.js"; -import type { AnalysisFile } from "@/core/contracts/figma-node.js"; +import type { AnalysisResult, AnalysisIssue } from "../core/engine/rule-engine.js"; +import type { AnalysisFile } from "../core/contracts/figma-node.js"; const mockFile = { fileKey: "test", diff --git a/src/agents/analysis-agent.ts b/src/agents/analysis-agent.ts index ce4ff8ad..f772f258 100644 --- a/src/agents/analysis-agent.ts +++ b/src/agents/analysis-agent.ts @@ -1,7 +1,7 @@ -import type { AnalysisResult } from "@/core/engine/rule-engine.js"; -import { calculateScores } from "@/core/engine/scoring.js"; -import type { RuleId } from "@/core/contracts/rule.js"; -import type { Severity } from "@/core/contracts/severity.js"; +import type { AnalysisResult } from "../core/engine/rule-engine.js"; +import { calculateScores } from "../core/engine/scoring.js"; +import type { RuleId } from "../core/contracts/rule.js"; +import type { Severity } from "../core/contracts/severity.js"; import type { AnalysisAgentInput, AnalysisAgentOutput, diff --git a/src/agents/code-renderer.ts b/src/agents/code-renderer.ts index f2a251ae..ecc4a19e 100644 --- a/src/agents/code-renderer.ts +++ b/src/agents/code-renderer.ts @@ -1,6 +1,6 @@ import { writeFileSync, readFileSync, mkdirSync } from "node:fs"; import { resolve } from "node:path"; -import { renderCodeScreenshot } from "@/core/engine/visual-compare.js"; +import { renderCodeScreenshot } from "../core/engine/visual-compare.js"; /** * Render generated HTML/CSS/React code to a PNG screenshot. diff --git a/src/agents/contracts/analysis-agent.ts b/src/agents/contracts/analysis-agent.ts index 8fa3ae7f..eb6aad5b 100644 --- a/src/agents/contracts/analysis-agent.ts +++ b/src/agents/contracts/analysis-agent.ts @@ -1,8 +1,8 @@ import { z } from "zod"; -import type { AnalysisResult } from "@/core/engine/rule-engine.js"; -import type { ScoreReport } from "@/core/engine/scoring.js"; -import type { RuleId } from "@/core/contracts/rule.js"; -import type { Severity } from "@/core/contracts/severity.js"; +import type { AnalysisResult } from "../../core/engine/rule-engine.js"; +import type { ScoreReport } from "../../core/engine/scoring.js"; +import type { RuleId } from "../../core/contracts/rule.js"; +import type { Severity } from "../../core/contracts/severity.js"; export const NodeIssueSummarySchema = z.object({ nodeId: z.string(), diff --git a/src/agents/contracts/evaluation-agent.ts b/src/agents/contracts/evaluation-agent.ts index a20b6b89..30c18687 100644 --- a/src/agents/contracts/evaluation-agent.ts +++ b/src/agents/contracts/evaluation-agent.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { DifficultySchema } from "./conversion-agent.js"; -import { SeveritySchema } from "@/core/contracts/severity.js"; +import { SeveritySchema } from "../../core/contracts/severity.js"; export const MismatchTypeSchema = z.enum([ "overscored", diff --git a/src/agents/contracts/tuning-agent.ts b/src/agents/contracts/tuning-agent.ts index d3e5a0bd..ac800004 100644 --- a/src/agents/contracts/tuning-agent.ts +++ b/src/agents/contracts/tuning-agent.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { SeveritySchema } from "@/core/contracts/severity.js"; +import { SeveritySchema } from "../../core/contracts/severity.js"; import type { CrossRunEvidence } from "./evidence.js"; export const ConfidenceSchema = z.enum(["high", "medium", "low"]); diff --git a/src/agents/evaluation-agent.ts b/src/agents/evaluation-agent.ts index a4b35d12..76fc7de1 100644 --- a/src/agents/evaluation-agent.ts +++ b/src/agents/evaluation-agent.ts @@ -5,7 +5,7 @@ import type { MismatchType, } from "./contracts/evaluation-agent.js"; import type { Difficulty } from "./contracts/conversion-agent.js"; -import type { Severity } from "@/core/contracts/severity.js"; +import type { Severity } from "../core/contracts/severity.js"; /** * Difficulty-to-score range mapping. diff --git a/src/agents/orchestrator.test.ts b/src/agents/orchestrator.test.ts index 349c86a9..cc239de5 100644 --- a/src/agents/orchestrator.test.ts +++ b/src/agents/orchestrator.test.ts @@ -1,8 +1,8 @@ -import type { AnalysisFile } from "@/core/contracts/figma-node.js"; +import type { AnalysisFile } from "../core/contracts/figma-node.js"; import { runCalibrationEvaluate, ENVIRONMENT_NOISE_PATTERNS } from "./orchestrator.js"; // Register rules so RULE_CONFIGS is populated -import "@/core/rules/index.js"; +import "../core/rules/index.js"; vi.mock("node:fs/promises", async (importOriginal) => { const original = await importOriginal(); diff --git a/src/agents/orchestrator.ts b/src/agents/orchestrator.ts index 9b4a78d9..d958e2d4 100644 --- a/src/agents/orchestrator.ts +++ b/src/agents/orchestrator.ts @@ -1,11 +1,11 @@ -import type { AnalysisFile, AnalysisNode, AnalysisNodeType } from "@/core/contracts/figma-node.js"; -import { analyzeFile } from "@/core/engine/rule-engine.js"; -import { RULE_CONFIGS } from "@/core/rules/rule-config.js"; +import type { AnalysisFile, AnalysisNode, AnalysisNodeType } from "../core/contracts/figma-node.js"; +import { analyzeFile } from "../core/engine/rule-engine.js"; +import { RULE_CONFIGS } from "../core/rules/rule-config.js"; import type { CalibrationConfig } from "./contracts/calibration.js"; import { CalibrationConfigSchema } from "./contracts/calibration.js"; import type { NodeIssueSummary } from "./contracts/analysis-agent.js"; -import type { ScoreReport } from "@/core/engine/scoring.js"; +import type { ScoreReport } from "../core/engine/scoring.js"; import { runAnalysisAgent, extractRuleScores } from "./analysis-agent.js"; import { runEvaluationAgent } from "./evaluation-agent.js"; @@ -89,7 +89,7 @@ const ELIGIBLE_NODE_TYPES: Set = new Set([ "INSTANCE", ]); -import { isExcludedName } from "@/core/rules/excluded-names.js"; +import { isExcludedName } from "../core/rules/excluded-names.js"; /** * Filter node summaries to meaningful conversion candidates. @@ -147,7 +147,7 @@ export function filterConversionCandidates( } // Reuse loader from core engine -import { loadFile as coreLoadFile } from "@/core/engine/loader.js"; +import { loadFile as coreLoadFile } from "../core/engine/loader.js"; async function loadFile( input: string, diff --git a/src/agents/report-generator.test.ts b/src/agents/report-generator.test.ts index 41216b97..38cad0b7 100644 --- a/src/agents/report-generator.test.ts +++ b/src/agents/report-generator.test.ts @@ -1,5 +1,5 @@ -import type { ScoreReport, CategoryScoreResult } from "@/core/engine/scoring.js"; -import type { Category } from "@/core/contracts/category.js"; +import type { ScoreReport, CategoryScoreResult } from "../core/engine/scoring.js"; +import type { Category } from "../core/contracts/category.js"; import type { MismatchCase } from "./contracts/evaluation-agent.js"; import type { ScoreAdjustment, NewRuleProposal } from "./contracts/tuning-agent.js"; import { diff --git a/src/agents/report-generator.ts b/src/agents/report-generator.ts index 18fbade4..6df811f6 100644 --- a/src/agents/report-generator.ts +++ b/src/agents/report-generator.ts @@ -1,4 +1,4 @@ -import type { ScoreReport } from "@/core/engine/scoring.js"; +import type { ScoreReport } from "../core/engine/scoring.js"; import type { MismatchCase } from "./contracts/evaluation-agent.js"; import type { ScoreAdjustment, NewRuleProposal } from "./contracts/tuning-agent.js"; diff --git a/src/agents/run-directory.test.ts b/src/agents/run-directory.test.ts index cdb063d6..d56d74a3 100644 --- a/src/agents/run-directory.test.ts +++ b/src/agents/run-directory.test.ts @@ -10,6 +10,8 @@ import { listCalibrationRuns, extractAppliedRuleIds, isConverged, + resolveLatestRunDir, + checkConvergence, } from "./run-directory.js"; describe("extractFixtureName", () => { @@ -202,6 +204,137 @@ describe("isConverged", () => { }); }); +describe("resolveLatestRunDir", () => { + const origCwd = process.cwd(); + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "run-dir-test-")); + process.chdir(tempDir); + }); + + afterEach(async () => { + process.chdir(origCwd); + await rm(tempDir, { recursive: true, force: true }); + }); + + it("returns latest run directory for a fixture", () => { + createCalibrationRunDir("my-fixture"); + const dir2 = createCalibrationRunDir("my-fixture"); + createCalibrationRunDir("other-fixture"); + + const latest = resolveLatestRunDir("my-fixture"); + expect(latest).toBe(dir2); + }); + + it("returns null when no matching runs exist", () => { + createCalibrationRunDir("other-fixture"); + expect(resolveLatestRunDir("nonexistent")).toBeNull(); + }); + + it("returns null when no runs at all", () => { + expect(resolveLatestRunDir("anything")).toBeNull(); + }); +}); + +describe("checkConvergence", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "converge-test-")); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it("returns detailed summary with decision counts", () => { + writeFileSync( + join(tempDir, "debate.json"), + JSON.stringify({ + arbitrator: { + summary: "test", + decisions: [ + { ruleId: "a", decision: "applied" }, + { ruleId: "b", decision: "revised" }, + { ruleId: "c", decision: "rejected" }, + { ruleId: "d", decision: "kept" }, + ], + }, + }), + ); + const summary = checkConvergence(tempDir); + expect(summary.converged).toBe(false); + expect(summary.mode).toBe("strict"); + expect(summary.applied).toBe(1); + expect(summary.revised).toBe(1); + expect(summary.rejected).toBe(1); + expect(summary.kept).toBe(1); + expect(summary.total).toBe(4); + expect(summary.reason).toContain("not converged"); + expect(summary.reason).toContain("1 applied"); + expect(summary.reason).toContain("1 revised"); + }); + + it("strict: not converged with rejections only", () => { + writeFileSync( + join(tempDir, "debate.json"), + JSON.stringify({ + arbitrator: { + summary: "test", + decisions: [{ ruleId: "x", decision: "rejected" }], + }, + }), + ); + const summary = checkConvergence(tempDir); + expect(summary.converged).toBe(false); + expect(summary.mode).toBe("strict"); + }); + + it("lenient: converged with rejections only", () => { + writeFileSync( + join(tempDir, "debate.json"), + JSON.stringify({ + arbitrator: { + summary: "test", + decisions: [{ ruleId: "x", decision: "rejected" }], + }, + }), + ); + const summary = checkConvergence(tempDir, { lenient: true }); + expect(summary.converged).toBe(true); + expect(summary.mode).toBe("lenient"); + expect(summary.reason).toContain("converged"); + expect(summary.reason).toContain("lenient"); + }); + + it("converged when skipped", () => { + writeFileSync( + join(tempDir, "debate.json"), + JSON.stringify({ skipped: "zero proposals" }), + ); + const summary = checkConvergence(tempDir); + expect(summary.converged).toBe(true); + expect(summary.reason).toBe("zero proposals"); + }); + + it("not converged when no debate.json", () => { + const summary = checkConvergence(tempDir); + expect(summary.converged).toBe(false); + expect(summary.reason).toBe("no debate.json found"); + }); + + it("not converged when no arbitrator", () => { + writeFileSync( + join(tempDir, "debate.json"), + JSON.stringify({ critic: null }), + ); + const summary = checkConvergence(tempDir); + expect(summary.converged).toBe(false); + expect(summary.reason).toBe("no arbitrator result"); + }); +}); + describe("listCalibrationRuns", () => { const origCwd = process.cwd(); let tempDir: string; diff --git a/src/agents/run-directory.ts b/src/agents/run-directory.ts index ab98a58b..f44133ce 100644 --- a/src/agents/run-directory.ts +++ b/src/agents/run-directory.ts @@ -231,6 +231,73 @@ export function extractAppliedRuleIds(debate: DebateResult): string[] { .filter((id) => id.length > 0); } +/** + * Resolve the latest calibration run directory for a given fixture name. + * Searches `logs/calibration/--*` and returns the most recent (last sorted). + * Returns null if no matching run exists. + */ +export function resolveLatestRunDir(fixtureName: string): string | null { + const runs = listCalibrationRuns(); + const matching = runs.filter((runPath) => { + const dirName = basename(runPath); + const parsed = parseRunDirName(dirName); + return parsed.name === fixtureName; + }); + return matching.length > 0 ? matching[matching.length - 1]! : null; +} + +/** Convergence summary with decision counts. */ +export interface ConvergenceSummary { + converged: boolean; + mode: "strict" | "lenient"; + applied: number; + revised: number; + rejected: number; + kept: number; + total: number; + reason: string; +} + +/** + * Check convergence and return a detailed summary with decision counts. + */ +export function checkConvergence(runDir: string, options?: ConvergenceOptions): ConvergenceSummary { + const mode = options?.lenient ? "lenient" : "strict"; + const debate = parseDebateResult(runDir); + + if (!debate) { + return { converged: false, mode, applied: 0, revised: 0, rejected: 0, kept: 0, total: 0, reason: "no debate.json found" }; + } + if (debate.skipped) { + return { converged: true, mode, applied: 0, revised: 0, rejected: 0, kept: 0, total: 0, reason: debate.skipped }; + } + if (!debate.arbitrator) { + return { converged: false, mode, applied: 0, revised: 0, rejected: 0, kept: 0, total: 0, reason: "no arbitrator result" }; + } + + const decisions = debate.arbitrator.decisions; + const applied = decisions.filter((d) => d.decision.trim().toLowerCase() === "applied").length; + const revised = decisions.filter((d) => d.decision.trim().toLowerCase() === "revised").length; + const rejected = decisions.filter((d) => d.decision.trim().toLowerCase() === "rejected").length; + const kept = decisions.length - applied - revised - rejected; + const total = decisions.length; + + const converged = options?.lenient + ? (applied + revised) === 0 + : (applied + revised) === 0 && rejected === 0; + + const parts: string[] = []; + if (applied > 0) parts.push(`${applied} applied`); + if (revised > 0) parts.push(`${revised} revised`); + if (rejected > 0) parts.push(`${rejected} rejected`); + if (kept > 0) parts.push(`${kept} kept`); + const countsStr = parts.length > 0 ? parts.join(", ") : "no decisions"; + const verdict = converged ? "converged" : "not converged"; + const reason = `${verdict} (${mode}) — ${countsStr} (${total} total)`; + + return { converged, mode, applied, revised, rejected, kept, total, reason }; +} + /** Options for convergence checking. */ export interface ConvergenceOptions { /** diff --git a/src/agents/tuning-agent.ts b/src/agents/tuning-agent.ts index 5945b6f4..a6726540 100644 --- a/src/agents/tuning-agent.ts +++ b/src/agents/tuning-agent.ts @@ -1,4 +1,4 @@ -import type { Severity } from "@/core/contracts/severity.js"; +import type { Severity } from "../core/contracts/severity.js"; import type { Confidence } from "./contracts/tuning-agent.js"; import type { TuningAgentInput, diff --git a/src/cli/index.ts b/src/cli/index.ts index 09485b1d..00ceb521 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -592,9 +592,11 @@ import { listActiveFixtures, listDoneFixtures, moveFixtureToDone, - isConverged, parseDebateResult, extractAppliedRuleIds, + extractFixtureName, + resolveLatestRunDir, + checkConvergence, } from "../agents/run-directory.js"; import { pruneCalibrationEvidence, @@ -633,7 +635,8 @@ cli ) .option("--fixtures-dir ", "Fixtures root directory", { default: "fixtures" }) .option("--force", "Skip convergence check") - .option("--run-dir ", "Run directory to check for convergence") + .option("--run-dir ", "Run directory to check for convergence (auto-resolves latest if omitted)") + .option("--dry-run", "Show convergence judgment without moving files") .option( "--lenient-convergence", "Converged when no applied/revised decisions (ignore rejected; see calibration issue #14)" @@ -643,24 +646,50 @@ cli fixturesDir?: string; force?: boolean; runDir?: string; + dryRun?: boolean; lenientConvergence?: boolean; }) => { + const fixtureName = extractFixtureName(fixturePath); + + // Resolve run directory: explicit --run-dir or auto-resolve latest + let runDir = options.runDir ? resolve(options.runDir) : null; + if (!runDir && !options.force) { + const latest = resolveLatestRunDir(fixtureName); + if (latest) { + runDir = latest; + console.log(`Auto-resolved latest run: ${runDir}`); + } + } + if (!options.force) { - if (!options.runDir) { - console.error("Error: --run-dir required to check convergence (or use --force to skip check)"); + if (!runDir) { + console.error(`Error: no run directory found for fixture "${fixtureName}". Specify --run-dir, or use --force to skip check.`); process.exit(1); } - if (!isConverged(resolve(options.runDir), { lenient: options.lenientConvergence })) { - const debate = parseDebateResult(resolve(options.runDir)); - const summary = debate?.arbitrator?.summary ?? debate?.skipped ?? "no debate.json found"; - console.error(`Error: fixture has not converged (${summary}). Use --force to override.`); + const summary = checkConvergence(runDir, { lenient: options.lenientConvergence }); + console.log(`\nConvergence check (${summary.mode}):`); + console.log(` ${summary.reason}`); + if (summary.total > 0) { + console.log(` applied=${summary.applied} revised=${summary.revised} rejected=${summary.rejected} kept=${summary.kept}`); + } + + if (options.dryRun) { + console.log(`\n[dry-run] Would ${summary.converged ? "move" : "NOT move"} fixture: ${fixturePath}`); + return; + } + + if (!summary.converged) { + console.error(`\nError: fixture has not converged. Use --force to override or --lenient-convergence.`); process.exit(1); } + } else if (options.dryRun) { + console.log(`[dry-run] --force: would move fixture without convergence check: ${fixturePath}`); + return; } const dest = moveFixtureToDone(fixturePath, options.fixturesDir ?? "fixtures"); if (dest) { - console.log(`Moved to: ${dest}`); + console.log(`\nMoved to: ${dest}`); } else { console.error(`Error: fixture not found: ${fixturePath}`); process.exit(1); diff --git a/src/core/engine/visual-compare-helpers.ts b/src/core/engine/visual-compare-helpers.ts new file mode 100644 index 00000000..224cdad5 --- /dev/null +++ b/src/core/engine/visual-compare-helpers.ts @@ -0,0 +1,144 @@ +/** + * Pure helper functions extracted from visual-compare.ts. + * These have no side effects beyond file I/O and can be tested directly. + */ + +import { writeFileSync, readFileSync, mkdirSync, statSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import pixelmatch from "pixelmatch"; +import { PNG } from "pngjs"; + +/** Directory used for caching Figma screenshots. */ +export const FIGMA_CACHE_DIR = "/tmp/canicode-figma-cache"; + +/** Cache time-to-live: 1 hour. */ +export const FIGMA_CACHE_TTL_MS = 60 * 60 * 1000; + +/** + * Tolerance for detecting integer scale factors (@2x, @3x). + * Broader tolerance because render/rounding errors accumulate at higher scales. + */ +export const SCALE_ROUNDING_TOLERANCE = 0.08; + +/** + * Tolerance for detecting 1x (unity) scale. + * Tighter to avoid false positives — misidentifying a scaled PNG as 1x. + */ +export const UNITY_SCALE_TOLERANCE = 0.02; + +/** + * Get the cache path for a given fileKey + nodeId combination. + */ +export function getFigmaCachePath(fileKey: string, nodeId: string, scale: number): string { + // Sanitize nodeId for use as filename (replace : with -) + const safeNodeId = nodeId.replace(/:/g, "-"); + return resolve(FIGMA_CACHE_DIR, `${fileKey}_${safeNodeId}@${scale}x.png`); +} + +/** + * Check if a cached Figma screenshot exists and is still fresh (within TTL). + */ +export function isCacheFresh(cachePath: string): boolean { + try { + const stats = statSync(cachePath); + return Date.now() - stats.mtimeMs < FIGMA_CACHE_TTL_MS; + } catch { + // File doesn't exist or was removed between check and stat (TOCTOU safe) + return false; + } +} + +/** + * Infer device pixel ratio so the Playwright screenshot matches Figma PNG pixel dimensions. + */ +export function inferDeviceScaleFactor( + pngW: number, + pngH: number, + logicalW: number, + logicalH: number, + fallback: number, +): number { + if (logicalW <= 0 || logicalH <= 0) return 1; + const sx = pngW / logicalW; + const sy = pngH / logicalH; + const rounded = Math.round((sx + sy) / 2); + if (rounded >= 2 && Math.abs(sx - rounded) < SCALE_ROUNDING_TOLERANCE && Math.abs(sy - rounded) < SCALE_ROUNDING_TOLERANCE) { + return rounded; + } + if (Math.abs(sx - 1) < UNITY_SCALE_TOLERANCE && Math.abs(sy - 1) < UNITY_SCALE_TOLERANCE) return 1; + return fallback >= 2 ? fallback : Math.max(1, Math.round(sx)); +} + +/** + * Pad a PNG to target dimensions with a high-contrast fill color (magenta #FF00FF). + * Unlike resize, padding preserves original pixels 1:1 and guarantees that + * any size difference is counted as mismatched pixels by pixelmatch. + * + * Note: If both images contain magenta in the padded area, those pixels + * will match — extremely rare in real designs but theoretically possible. + */ +export function padPng(png: PNG, targetWidth: number, targetHeight: number): PNG { + const padded = new PNG({ width: targetWidth, height: targetHeight }); + // Fill entire canvas with magenta (FF00FF) — guaranteed to differ from any real content + for (let i = 0; i < padded.data.length; i += 4) { + padded.data[i] = 255; // R + padded.data[i + 1] = 0; // G + padded.data[i + 2] = 255; // B + padded.data[i + 3] = 255; // A + } + // Copy original pixels into top-left corner + for (let y = 0; y < png.height; y++) { + for (let x = 0; x < png.width; x++) { + const srcIdx = (y * png.width + x) * 4; + const dstIdx = (y * targetWidth + x) * 4; + padded.data[dstIdx] = png.data[srcIdx]!; + padded.data[dstIdx + 1] = png.data[srcIdx + 1]!; + padded.data[dstIdx + 2] = png.data[srcIdx + 2]!; + padded.data[dstIdx + 3] = png.data[srcIdx + 3]!; + } + } + return padded; +} + +/** + * Compare two PNG files using pixelmatch. + */ +export function compareScreenshots( + path1: string, + path2: string, + diffOutputPath: string, +): { similarity: number; diffPixels: number; totalPixels: number; width: number; height: number } { + const raw1 = PNG.sync.read(readFileSync(path1)); + const raw2 = PNG.sync.read(readFileSync(path2)); + + // Size mismatch — pad smaller image with magenta so extra area counts as diff pixels + if (raw1.width !== raw2.width || raw1.height !== raw2.height) { + const width = Math.max(raw1.width, raw2.width); + const height = Math.max(raw1.height, raw2.height); + const img1 = padPng(raw1, width, height); + const img2 = padPng(raw2, width, height); + const diff = new PNG({ width, height }); + const diffPixels = pixelmatch(img1.data, img2.data, diff.data, width, height, { threshold: 0.1 }); + mkdirSync(dirname(diffOutputPath), { recursive: true }); + writeFileSync(diffOutputPath, PNG.sync.write(diff)); + + const totalPixels = width * height; + const similarity = diffPixels === 0 ? 100 : Math.floor((1 - diffPixels / totalPixels) * 100); + + return { similarity, diffPixels, totalPixels, width, height }; + } + + const { width, height } = raw1; + const diff = new PNG({ width, height }); + const diffPixels = pixelmatch(raw1.data, raw2.data, diff.data, width, height, { + threshold: 0.1, + }); + + mkdirSync(dirname(diffOutputPath), { recursive: true }); + writeFileSync(diffOutputPath, PNG.sync.write(diff)); + + const totalPixels = width * height; + const similarity = diffPixels === 0 ? 100 : Math.floor((1 - diffPixels / totalPixels) * 100); + + return { similarity, diffPixels, totalPixels, width, height }; +} diff --git a/src/core/engine/visual-compare.test.ts b/src/core/engine/visual-compare.test.ts index 1df47c10..afcfbc55 100644 --- a/src/core/engine/visual-compare.test.ts +++ b/src/core/engine/visual-compare.test.ts @@ -1,116 +1,23 @@ /** - * Tests for visual-compare.ts + * Tests for visual-compare helpers. * - * The core functions (getFigmaCachePath, isCacheFresh, compareScreenshots, resizePng) - * are module-private. We test the observable behaviour through integration-style tests - * that write real PNG files to a temp directory and exercise the logic indirectly via - * the exported `visualCompare` function — or, where Playwright / Figma API would be - * required, we test the underlying PNG arithmetic by re-implementing a thin slice of - * the same logic to confirm correctness. - * - * What we CAN test without Playwright or network: - * 1. Path generation logic (getFigmaCachePath) — verified by inspecting the cache - * path written during a cached run. - * 2. isCacheFresh — returns false for a non-existent file. - * 3. compareScreenshots size-mismatch branch — padded area counts as diff. - * 4. padPng — output dimensions match, original preserved, padding is magenta. - * 5. Same-image comparison → 100% similarity. - * 6. Different-image comparison → < 100% similarity. - * - * For (3)–(6) we build small PNGs in memory with pngjs and write them to a temp dir, - * then call compareScreenshots through a minimal re-export shim declared in this file. - * Because the module does not export the private functions we reproduce the exact same - * logic (padPng + compareScreenshots) so the tests validate - * the algorithm rather than just the export boundary. + * Pure helper functions are imported directly from visual-compare-helpers.ts, + * so tests exercise the real implementation rather than mirrored copies. */ import { mkdtempSync, writeFileSync, existsSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { join } from "node:path"; import { tmpdir } from "node:os"; import { rm } from "node:fs/promises"; import { PNG } from "pngjs"; -import pixelmatch from "pixelmatch"; - -// --------------------------------------------------------------------------- -// Helpers — mirror of the private functions in visual-compare.ts -// --------------------------------------------------------------------------- - -const FIGMA_CACHE_DIR = "/tmp/canicode-figma-cache"; - -/** Mirror of the private getFigmaCachePath in visual-compare.ts */ -function getFigmaCachePath(fileKey: string, nodeId: string, scale: number = 2): string { - const safeNodeId = nodeId.replace(/:/g, "-"); - return resolve(FIGMA_CACHE_DIR, `${fileKey}_${safeNodeId}@${scale}x.png`); -} - -/** Mirror of the private isCacheFresh in visual-compare.ts */ -function isCacheFresh(cachePath: string): boolean { - if (!existsSync(cachePath)) return false; - const { statSync } = require("node:fs") as typeof import("node:fs"); - const stats = statSync(cachePath); - const FIGMA_CACHE_TTL_MS = 60 * 60 * 1000; - return Date.now() - stats.mtimeMs < FIGMA_CACHE_TTL_MS; -} - -/** Mirror of the private padPng in visual-compare.ts */ -function padPng(png: PNG, targetWidth: number, targetHeight: number): PNG { - const padded = new PNG({ width: targetWidth, height: targetHeight }); - // Fill with magenta - for (let i = 0; i < padded.data.length; i += 4) { - padded.data[i] = 255; - padded.data[i + 1] = 0; - padded.data[i + 2] = 255; - padded.data[i + 3] = 255; - } - // Copy original pixels into top-left corner - for (let y = 0; y < png.height; y++) { - for (let x = 0; x < png.width; x++) { - const srcIdx = (y * png.width + x) * 4; - const dstIdx = (y * targetWidth + x) * 4; - padded.data[dstIdx] = png.data[srcIdx]!; - padded.data[dstIdx + 1] = png.data[srcIdx + 1]!; - padded.data[dstIdx + 2] = png.data[srcIdx + 2]!; - padded.data[dstIdx + 3] = png.data[srcIdx + 3]!; - } - } - return padded; -} - -/** Mirror of the private compareScreenshots in visual-compare.ts */ -function compareScreenshots( - path1: string, - path2: string, - diffOutputPath: string, -): { similarity: number; diffPixels: number; totalPixels: number; width: number; height: number } { - const { readFileSync, mkdirSync } = require("node:fs") as typeof import("node:fs"); - const { dirname } = require("node:path") as typeof import("node:path"); - - const raw1 = PNG.sync.read(readFileSync(path1)); - const raw2 = PNG.sync.read(readFileSync(path2)); - - if (raw1.width !== raw2.width || raw1.height !== raw2.height) { - const width = Math.max(raw1.width, raw2.width); - const height = Math.max(raw1.height, raw2.height); - const img1 = padPng(raw1, width, height); - const img2 = padPng(raw2, width, height); - const diff = new PNG({ width, height }); - const diffPixels = pixelmatch(img1.data, img2.data, diff.data, width, height, { threshold: 0.1 }); - mkdirSync(dirname(diffOutputPath), { recursive: true }); - writeFileSync(diffOutputPath, PNG.sync.write(diff)); - const totalPixels = width * height; - const similarity = Math.round((1 - diffPixels / totalPixels) * 100); - return { similarity, diffPixels, totalPixels, width, height }; - } - - const { width, height } = raw1; - const diff = new PNG({ width, height }); - const diffPixels = pixelmatch(raw1.data, raw2.data, diff.data, width, height, { threshold: 0.1 }); - mkdirSync(dirname(diffOutputPath), { recursive: true }); - writeFileSync(diffOutputPath, PNG.sync.write(diff)); - const totalPixels = width * height; - const similarity = Math.round((1 - diffPixels / totalPixels) * 100); - return { similarity, diffPixels, totalPixels, width, height }; -} +import { + getFigmaCachePath, + isCacheFresh, + padPng, + compareScreenshots, + inferDeviceScaleFactor, + FIGMA_CACHE_DIR, +} from "./visual-compare-helpers.js"; // --------------------------------------------------------------------------- // PNG factory helpers @@ -311,3 +218,33 @@ describe("compareScreenshots", () => { expect(result.height).toBe(4); }); }); + +describe("inferDeviceScaleFactor", () => { + it("detects 2x scale", () => { + expect(inferDeviceScaleFactor(800, 600, 400, 300, 2)).toBe(2); + }); + + it("detects 3x scale", () => { + expect(inferDeviceScaleFactor(1200, 900, 400, 300, 2)).toBe(3); + }); + + it("detects 1x scale", () => { + expect(inferDeviceScaleFactor(400, 300, 400, 300, 2)).toBe(1); + }); + + it("returns 1 when logical dimensions are zero", () => { + expect(inferDeviceScaleFactor(800, 600, 0, 0, 2)).toBe(1); + }); + + it("uses fallback for fractional scale when fallback >= 2", () => { + // 800 / 300 ≈ 2.67, 600 / 250 = 2.4 — not close to any integer + // fallback is 2, so it should return 2 + expect(inferDeviceScaleFactor(800, 600, 300, 250, 2)).toBe(2); + }); + + it("rounds to nearest integer when fallback < 2 for fractional scale", () => { + // 800 / 300 ≈ 2.67, 600 / 250 = 2.4 — not close to any integer + // fallback is 1 (< 2), so it uses Math.max(1, Math.round(sx)) = Math.round(2.67) = 3 + expect(inferDeviceScaleFactor(800, 600, 300, 250, 1)).toBe(3); + }); +}); diff --git a/src/core/engine/visual-compare.ts b/src/core/engine/visual-compare.ts index 9ac21bb7..083b0816 100644 --- a/src/core/engine/visual-compare.ts +++ b/src/core/engine/visual-compare.ts @@ -3,10 +3,16 @@ * and computes pixel-level similarity using pixelmatch. */ -import { writeFileSync, readFileSync, mkdirSync, existsSync, statSync, copyFileSync } from "node:fs"; +import { writeFileSync, readFileSync, mkdirSync, existsSync, copyFileSync } from "node:fs"; import { resolve, dirname } from "node:path"; -import pixelmatch from "pixelmatch"; import { PNG } from "pngjs"; +import { + FIGMA_CACHE_DIR, + getFigmaCachePath, + isCacheFresh, + inferDeviceScaleFactor, + compareScreenshots, +} from "./visual-compare-helpers.js"; /** Result of a visual comparison between Figma design and rendered code. */ export interface VisualCompareResult { @@ -38,27 +44,6 @@ export interface VisualCompareOptions { figmaExportScale?: number | undefined; } -const FIGMA_CACHE_DIR = "/tmp/canicode-figma-cache"; -const FIGMA_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour - -/** - * Get the cache path for a given fileKey + nodeId combination. - */ -function getFigmaCachePath(fileKey: string, nodeId: string, scale: number): string { - // Sanitize nodeId for use as filename (replace : with -) - const safeNodeId = nodeId.replace(/:/g, "-"); - return resolve(FIGMA_CACHE_DIR, `${fileKey}_${safeNodeId}@${scale}x.png`); -} - -/** - * Check if a cached Figma screenshot exists and is still fresh (within TTL). - */ -function isCacheFresh(cachePath: string): boolean { - if (!existsSync(cachePath)) return false; - const stats = statSync(cachePath); - return Date.now() - stats.mtimeMs < FIGMA_CACHE_TTL_MS; -} - /** * Fetch Figma node screenshot via REST API, with file-based caching. * Cache key: fileKey + nodeId. Cache location: /tmp/canicode-figma-cache/. TTL: 1 hour. @@ -103,39 +88,6 @@ async function fetchFigmaScreenshot( writeFileSync(cachePath, buffer); } -/** - * Tolerance for detecting integer scale factors (@2x, @3x). - * Broader tolerance because render/rounding errors accumulate at higher scales. - */ -const SCALE_ROUNDING_TOLERANCE = 0.08; - -/** - * Tolerance for detecting 1x (unity) scale. - * Tighter to avoid false positives — misidentifying a scaled PNG as 1x. - */ -const UNITY_SCALE_TOLERANCE = 0.02; - -/** - * Infer device pixel ratio so the Playwright screenshot matches Figma PNG pixel dimensions. - */ -function inferDeviceScaleFactor( - pngW: number, - pngH: number, - logicalW: number, - logicalH: number, - fallback: number, -): number { - if (logicalW <= 0 || logicalH <= 0) return 1; - const sx = pngW / logicalW; - const sy = pngH / logicalH; - const rounded = Math.round((sx + sy) / 2); - if (rounded >= 2 && Math.abs(sx - rounded) < SCALE_ROUNDING_TOLERANCE && Math.abs(sy - rounded) < SCALE_ROUNDING_TOLERANCE) { - return rounded; - } - if (Math.abs(sx - 1) < UNITY_SCALE_TOLERANCE && Math.abs(sy - 1) < UNITY_SCALE_TOLERANCE) return 1; - return fallback >= 2 ? fallback : Math.max(1, Math.round(sx)); -} - /** * Render HTML file with Playwright and take a screenshot. * @param deviceScaleFactor - Pass 2 when the Figma reference is @2x and `viewport` is logical CSS size. @@ -174,77 +126,6 @@ export async function renderCodeScreenshot( } } -/** - * Pad a PNG to target dimensions with a high-contrast fill color (magenta). - * Unlike resize, padding preserves original pixels 1:1 and guarantees that - * any size difference is counted as mismatched pixels by pixelmatch. - */ -function padPng(png: PNG, targetWidth: number, targetHeight: number): PNG { - const padded = new PNG({ width: targetWidth, height: targetHeight }); - // Fill entire canvas with magenta (FF00FF) — guaranteed to differ from any real content - for (let i = 0; i < padded.data.length; i += 4) { - padded.data[i] = 255; // R - padded.data[i + 1] = 0; // G - padded.data[i + 2] = 255; // B - padded.data[i + 3] = 255; // A - } - // Copy original pixels into top-left corner - for (let y = 0; y < png.height; y++) { - for (let x = 0; x < png.width; x++) { - const srcIdx = (y * png.width + x) * 4; - const dstIdx = (y * targetWidth + x) * 4; - padded.data[dstIdx] = png.data[srcIdx]!; - padded.data[dstIdx + 1] = png.data[srcIdx + 1]!; - padded.data[dstIdx + 2] = png.data[srcIdx + 2]!; - padded.data[dstIdx + 3] = png.data[srcIdx + 3]!; - } - } - return padded; -} - -/** - * Compare two PNG files using pixelmatch. - */ -function compareScreenshots( - path1: string, - path2: string, - diffOutputPath: string, -): { similarity: number; diffPixels: number; totalPixels: number; width: number; height: number } { - const raw1 = PNG.sync.read(readFileSync(path1)); - const raw2 = PNG.sync.read(readFileSync(path2)); - - // Size mismatch — pad smaller image with magenta so extra area counts as diff pixels - if (raw1.width !== raw2.width || raw1.height !== raw2.height) { - const width = Math.max(raw1.width, raw2.width); - const height = Math.max(raw1.height, raw2.height); - const img1 = padPng(raw1, width, height); - const img2 = padPng(raw2, width, height); - const diff = new PNG({ width, height }); - const diffPixels = pixelmatch(img1.data, img2.data, diff.data, width, height, { threshold: 0.1 }); - mkdirSync(dirname(diffOutputPath), { recursive: true }); - writeFileSync(diffOutputPath, PNG.sync.write(diff)); - - const totalPixels = width * height; - const similarity = Math.round((1 - diffPixels / totalPixels) * 100); - - return { similarity, diffPixels, totalPixels, width, height }; - } - - const { width, height } = raw1; - const diff = new PNG({ width, height }); - const diffPixels = pixelmatch(raw1.data, raw2.data, diff.data, width, height, { - threshold: 0.1, - }); - - mkdirSync(dirname(diffOutputPath), { recursive: true }); - writeFileSync(diffOutputPath, PNG.sync.write(diff)); - - const totalPixels = width * height; - const similarity = Math.round((1 - diffPixels / totalPixels) * 100); - - return { similarity, diffPixels, totalPixels, width, height }; -} - /** * Run full visual comparison pipeline. */