import { randomUUID } from "node:crypto"; import { access } from "node:fs/promises"; import { isAbsolute, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { ShimonError } from "./errors.ts"; import type { LoadedConfig, ProjectCheck, ShimonCase, ShimonConfig, Viewport } from "./types.ts"; const DEFAULT_CONFIG = "shimon.config.mjs"; const DEFAULT_VIEWPORT: Viewport = { width: 1200, height: 900 }; const DEFAULT_TIMEOUTS = { runMs: 120_000, caseMs: 20_000, navigationMs: 10_000 }; async function importFresh(path: string): Promise<{ default?: unknown }> { const url = pathToFileURL(path); url.searchParams.set("shimon_reload", randomUUID()); return (await import(url.href)) as { default?: unknown }; } function invalid(message: string, hint = "Check shimon.config.mjs."): never { throw new ShimonError("config_invalid", message, hint); } function validateViewport(value: unknown, path = "target.viewport"): Viewport { if (value === undefined) return DEFAULT_VIEWPORT; if (value === null || typeof value !== "object") invalid(`${path} must be an object.`); const viewport = value as Partial; if (!Number.isInteger(viewport.width) || (viewport.width ?? 0) <= 0) { invalid(`${path}.width must be a positive integer.`); } if (!Number.isInteger(viewport.height) || (viewport.height ?? 0) <= 0) { invalid(`${path}.height must be a positive integer.`); } return { width: viewport.width as number, height: viewport.height as number }; } function validateViewports(value: unknown): Record { if (value === undefined) return {}; if (value === null || typeof value !== "object" || Array.isArray(value)) { invalid("viewports must be an object."); } return Object.fromEntries( Object.entries(value as Record).map(([name, viewport]) => { if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(name)) { invalid(`viewport name ${JSON.stringify(name)} must use 1-64 letters, numbers, dots, dashes, or underscores.`); } return [name, validateViewport(viewport, `viewports.${name}`)]; }), ); } function validateOptionalText(value: unknown, path: string): string | undefined { if (value === undefined) return undefined; if (typeof value !== "string" || value.trim() === "") invalid(`${path} must be a non-empty string.`); return value; } function validateReview(value: unknown, path: string): string[] | undefined { if (value === undefined) return undefined; if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.trim() === "")) { invalid(`${path} must be an array of non-empty strings.`); } return value as string[]; } function validateProjectChecks(value: unknown, caseName: string, path: string): ProjectCheck[] | undefined { if (value === undefined) return undefined; if (!Array.isArray(value)) invalid(`${path} must be an array.`); const ids = new Set(); return value.map((candidate, index) => { if (candidate === null || typeof candidate !== "object") invalid(`${path}[${index}] must be an object.`); const check = candidate as Partial; if (typeof check.id !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(check.id)) { invalid(`${path}[${index}].id must use 1-64 letters, numbers, dots, dashes, or underscores.`); } if (ids.has(check.id)) invalid(`Duplicate check id in case ${caseName}: ${check.id}`); ids.add(check.id); if (typeof check.description !== "string" || check.description.trim() === "") { invalid(`${path}[${index}].description must be a non-empty string.`); } if (typeof check.evaluate !== "function") invalid(`${path}[${index}].evaluate must be a function.`); return check as ProjectCheck; }); } function validateCases(value: unknown, viewports: Record): ShimonCase[] { if (value === undefined) return []; if (!Array.isArray(value)) invalid("cases must be an array."); const names = new Set(); return value.map((candidate, index) => { if (candidate === null || typeof candidate !== "object") invalid(`cases[${index}] must be an object.`); const item = candidate as Partial; if (typeof item.name !== "string" || item.name.trim() === "") { invalid(`cases[${index}].name must be a non-empty string.`); } if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(item.name)) { invalid(`cases[${index}].name must use 1-64 letters, numbers, dots, dashes, or underscores.`); } if (names.has(item.name)) invalid(`Duplicate case name: ${item.name}`); names.add(item.name); if (item.prepare !== undefined && typeof item.prepare !== "function") { invalid(`cases[${index}].prepare must be a function.`); } const rawViewport = (candidate as Record).viewport; let viewport: Viewport | undefined; let viewportName: string | undefined; if (typeof rawViewport === "string") { viewport = viewports[rawViewport]; if (!viewport) { invalid(`cases[${index}].viewport references unknown viewport ${JSON.stringify(rawViewport)}.`); } viewportName = rawViewport; } else if (rawViewport !== undefined) { viewport = validateViewport(rawViewport, `cases[${index}].viewport`); } const path = validateOptionalText((candidate as Record).path, `cases[${index}].path`); if ( path !== undefined && (!path.startsWith("/") || path.startsWith("//") || path.startsWith("/\\")) ) { invalid(`cases[${index}].path must be a project-relative path starting with a single "/".`); } return { name: item.name, path, viewport, viewportName, intent: validateOptionalText((candidate as Record).intent, `cases[${index}].intent`), review: validateReview((candidate as Record).review, `cases[${index}].review`), checks: validateProjectChecks( (candidate as Record).checks, item.name, `cases[${index}].checks`, ), prepare: item.prepare, }; }); } function validateScreenshot(value: unknown): ShimonConfig["screenshot"] { if (value === undefined) return { mask: [] }; if (value === null || typeof value !== "object") invalid("screenshot must be an object."); const mask = (value as Record).mask; if (mask === undefined) return { mask: [] }; if (!Array.isArray(mask) || mask.some((selector) => typeof selector !== "string" || selector.trim() === "")) { invalid("screenshot.mask must be an array of non-empty selectors."); } return { mask: mask as string[] }; } function positiveInteger(value: unknown, fallback: number, path: string): number { if (value === undefined) return fallback; if (!Number.isInteger(value) || (value as number) <= 0) invalid(`${path} must be a positive integer.`); return value as number; } function validateTimeouts(value: unknown): NonNullable { if (value === undefined) return DEFAULT_TIMEOUTS; if (value === null || typeof value !== "object") invalid("timeouts must be an object."); const timeouts = value as Record; return { runMs: positiveInteger(timeouts.runMs, DEFAULT_TIMEOUTS.runMs, "timeouts.runMs"), caseMs: positiveInteger(timeouts.caseMs, DEFAULT_TIMEOUTS.caseMs, "timeouts.caseMs"), navigationMs: positiveInteger( timeouts.navigationMs, DEFAULT_TIMEOUTS.navigationMs, "timeouts.navigationMs", ), }; } function validateWebServer(value: unknown): ShimonConfig["webServer"] { if (value === undefined) return undefined; if (value === null || typeof value !== "object") invalid("webServer must be an object."); const server = value as Record; if (typeof server.command !== "string" || server.command.trim() === "") { invalid("webServer.command must be a non-empty string."); } if (typeof server.url !== "string") invalid("webServer.url must be a string."); try { const url = new URL(server.url as string); if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("unsupported protocol"); } catch { invalid("webServer.url must be an absolute HTTP(S) URL."); } if (server.reuseExisting !== undefined && typeof server.reuseExisting !== "boolean") { invalid("webServer.reuseExisting must be a boolean."); } return { command: server.command as string, url: server.url as string, reuseExisting: server.reuseExisting !== false, timeoutMs: positiveInteger(server.timeoutMs, 30_000, "webServer.timeoutMs"), }; } function validateConfig(value: unknown): ShimonConfig { if (value === null || typeof value !== "object") invalid("The default export must be an object."); const candidate = value as Record; const target = candidate.target; if (target === null || typeof target !== "object") invalid("target must be an object."); const targetValue = target as Record; if (typeof targetValue.url !== "string") invalid("target.url must be a string."); try { new URL(targetValue.url); } catch { invalid("target.url must be an absolute URL."); } if (candidate.stabilize !== undefined && typeof candidate.stabilize !== "function") { invalid("stabilize must be a function."); } if (candidate.freezeAnimations !== undefined && typeof candidate.freezeAnimations !== "boolean") { invalid("freezeAnimations must be a boolean."); } const viewports = validateViewports(candidate.viewports); return { target: { url: targetValue.url, viewport: validateViewport(targetValue.viewport), }, viewports, cases: validateCases(candidate.cases, viewports), stabilize: candidate.stabilize as ShimonConfig["stabilize"], freezeAnimations: candidate.freezeAnimations !== false, screenshot: validateScreenshot(candidate.screenshot), webServer: validateWebServer(candidate.webServer), timeouts: validateTimeouts(candidate.timeouts), }; } export async function loadConfig(options: { cwd: string; configPath?: string; taskPath?: string; }): Promise { const requested = options.configPath ?? DEFAULT_CONFIG; const path = isAbsolute(requested) ? requested : resolve(options.cwd, requested); try { await access(path); } catch (error) { throw new ShimonError( "config_not_found", `Config not found: ${path}`, "Create shimon.config.mjs or pass --config .", { cause: error }, ); } let module: { default?: unknown }; try { module = await importFresh(path); } catch (error) { throw new ShimonError("config_load_failed", `Could not load config: ${path}`, undefined, { cause: error, }); } const config = validateConfig(module.default); if (!options.taskPath) return { path, config }; const taskPath = isAbsolute(options.taskPath) ? options.taskPath : resolve(options.cwd, options.taskPath); try { await access(taskPath); } catch (error) { throw new ShimonError( "task_not_found", `Task config not found: ${taskPath}`, "Create a task module with a default export containing cases, or omit --task.", { cause: error }, ); } let taskModule: { default?: unknown }; try { taskModule = await importFresh(taskPath); } catch (error) { throw new ShimonError("task_load_failed", `Could not load task config: ${taskPath}`, undefined, { cause: error, }); } if (taskModule.default === null || typeof taskModule.default !== "object") { invalid("The task default export must be an object.", "Check the module passed to --task."); } const task = taskModule.default as Record; const unexpected = Object.keys(task).filter((key) => key !== "cases"); if (unexpected.length > 0) { invalid( `Task config only accepts cases; remove: ${unexpected.join(", ")}`, "Move project settings to shimon.config.mjs.", ); } const taskCases = validateCases(task.cases, config.viewports ?? {}); const names = new Set(config.cases.map((testCase) => testCase.name)); const duplicate = taskCases.find((testCase) => names.has(testCase.name)); if (duplicate) { invalid(`Task case duplicates a project case: ${duplicate.name}`, "Give the task case a unique name."); } return { path, taskPath, config: { ...config, cases: [...config.cases, ...taskCases] }, }; }