410 lines
20 KiB
TypeScript
410 lines
20 KiB
TypeScript
// Responsibility: Typed YAML profile parsing for repo-owned Cloud Console browser closeout.
|
|
|
|
export interface HwlabRuntimeWebProbeConsoleViewportSpec {
|
|
readonly id: string;
|
|
readonly width: number;
|
|
readonly height: number;
|
|
}
|
|
|
|
export interface HwlabRuntimeWebProbeConsoleRouteSpec {
|
|
readonly id: string;
|
|
readonly path: string;
|
|
readonly readySelector: string;
|
|
readonly screenshot: boolean;
|
|
}
|
|
|
|
export interface HwlabRuntimeWebProbeConsoleProjectsWorkflowSpec {
|
|
readonly routeId: string;
|
|
readonly listButtonName: string;
|
|
readonly viewQueryKey: string;
|
|
}
|
|
|
|
export interface HwlabRuntimeWebProbeConsoleHwpodWorkflowSpec {
|
|
readonly devicesRouteId: string;
|
|
readonly nodesRouteId: string;
|
|
readonly onboardingRouteId: string;
|
|
readonly listButtonName: string;
|
|
readonly cardButtonName: string;
|
|
readonly devicesListSelector: string;
|
|
readonly devicesCardSelector: string;
|
|
readonly nodesListSelector: string;
|
|
readonly pcbTraceSelector: string;
|
|
readonly detailButtonName: string;
|
|
readonly detailPathPrefix: string;
|
|
readonly onboardingCommandSelector: string;
|
|
readonly requiredCommandPrefixes: readonly string[];
|
|
readonly downloadSelector: string;
|
|
readonly forbiddenDownloadSubstrings: readonly string[];
|
|
readonly forbiddenInternalSelector: string;
|
|
}
|
|
|
|
export interface HwlabRuntimeWebProbeConsoleMdtodoWorkflowSpec {
|
|
readonly routeId: string;
|
|
readonly taskSelector: string;
|
|
readonly taskDeepLinkSegment: string;
|
|
readonly taskDetailSelector: string;
|
|
readonly reportLinkSelector: string;
|
|
readonly reportPreviewSelector: string;
|
|
readonly fullscreenButtonSelector: string;
|
|
readonly fullscreenDialogSelector: string;
|
|
}
|
|
|
|
export interface HwlabRuntimeWebProbeConsoleTasktreeWorkflowSpec {
|
|
readonly routeId: string;
|
|
readonly taskSelector: string;
|
|
readonly barSelector: string;
|
|
readonly milestoneSelector: string;
|
|
readonly ganttSelector: string;
|
|
readonly scrollSelector: string;
|
|
readonly labelPaneSelector: string;
|
|
readonly scaleSelector: string;
|
|
readonly tickSelector: string;
|
|
readonly zoomInButtonName: string;
|
|
readonly secondsScaleLabel: string;
|
|
readonly zoomScreenshotName: string;
|
|
readonly dialogSelector: string;
|
|
readonly reportSelector: string;
|
|
readonly detailScreenshotName: string;
|
|
}
|
|
|
|
export interface HwlabRuntimeWebProbeConsoleAgentViewSpec {
|
|
readonly buttonName: string;
|
|
readonly queryValue: string;
|
|
readonly screenshotName: string;
|
|
}
|
|
|
|
export interface HwlabRuntimeWebProbeConsoleAgentSelectionSpec {
|
|
readonly selector: string;
|
|
readonly inspectorSelector: string;
|
|
readonly screenshotName: string;
|
|
}
|
|
|
|
export interface HwlabRuntimeWebProbeConsoleAgentObserverWorkflowSpec {
|
|
readonly routeId: string;
|
|
readonly eventSourcePath: string;
|
|
readonly phaseSelector: string;
|
|
readonly blockedPhases: readonly string[];
|
|
readonly streamSettleMs: number;
|
|
readonly views: readonly HwlabRuntimeWebProbeConsoleAgentViewSpec[];
|
|
readonly selection?: HwlabRuntimeWebProbeConsoleAgentSelectionSpec;
|
|
}
|
|
|
|
export interface HwlabRuntimeWebProbeConsoleVerificationProfileSpec {
|
|
readonly navigationTimeoutMs: number;
|
|
readonly settleMs: number;
|
|
readonly commandTimeoutSeconds: number;
|
|
readonly minTextLength: number;
|
|
readonly maxHorizontalOverflowPx: number;
|
|
readonly outputLimits: {
|
|
readonly failures: number;
|
|
readonly consoleErrors: number;
|
|
readonly responseErrors: number;
|
|
readonly screenshots: number;
|
|
readonly textPreviewChars: number;
|
|
};
|
|
readonly viewports: readonly HwlabRuntimeWebProbeConsoleViewportSpec[];
|
|
readonly routes: readonly HwlabRuntimeWebProbeConsoleRouteSpec[];
|
|
readonly workflows: {
|
|
readonly projects?: HwlabRuntimeWebProbeConsoleProjectsWorkflowSpec;
|
|
readonly hwpod?: HwlabRuntimeWebProbeConsoleHwpodWorkflowSpec;
|
|
readonly mdtodo?: HwlabRuntimeWebProbeConsoleMdtodoWorkflowSpec;
|
|
readonly tasktree?: HwlabRuntimeWebProbeConsoleTasktreeWorkflowSpec;
|
|
readonly agentObserver?: HwlabRuntimeWebProbeConsoleAgentObserverWorkflowSpec;
|
|
};
|
|
}
|
|
|
|
export function parseWebProbeConsoleVerificationProfiles(
|
|
value: unknown,
|
|
path: string,
|
|
): Readonly<Record<string, HwlabRuntimeWebProbeConsoleVerificationProfileSpec>> {
|
|
const raw = record(value, path);
|
|
const entries = Object.entries(raw);
|
|
if (entries.length < 1 || entries.length > 20) throw new Error(`${path} must contain 1-20 profiles`);
|
|
return Object.fromEntries(entries.map(([name, profile]) => {
|
|
simpleId(name, `${path}.${name}`);
|
|
return [name, consoleProfile(profile, `${path}.${name}`)];
|
|
}));
|
|
}
|
|
|
|
function consoleProfile(value: unknown, path: string): HwlabRuntimeWebProbeConsoleVerificationProfileSpec {
|
|
const raw = record(value, path);
|
|
onlyKeys(raw, [
|
|
"navigationTimeoutMs",
|
|
"settleMs",
|
|
"commandTimeoutSeconds",
|
|
"minTextLength",
|
|
"maxHorizontalOverflowPx",
|
|
"outputLimits",
|
|
"viewports",
|
|
"routes",
|
|
"workflows",
|
|
], path);
|
|
const viewports = array(raw.viewports, `${path}.viewports`, 1, 8).map((item, index) => viewport(item, `${path}.viewports[${index}]`));
|
|
const routes = array(raw.routes, `${path}.routes`, 1, 100).map((item, index) => route(item, `${path}.routes[${index}]`));
|
|
unique(viewports.map((item) => item.id), `${path}.viewports[].id`);
|
|
unique(routes.map((item) => item.id), `${path}.routes[].id`);
|
|
unique(routes.map((item) => item.path), `${path}.routes[].path`);
|
|
const routeIds = new Set(routes.map((item) => item.id));
|
|
const workflows = workflowConfig(raw.workflows, `${path}.workflows`, routeIds);
|
|
const output = record(raw.outputLimits, `${path}.outputLimits`);
|
|
onlyKeys(output, ["failures", "consoleErrors", "responseErrors", "screenshots", "textPreviewChars"], `${path}.outputLimits`);
|
|
const screenshotBudget = integer(output.screenshots, `${path}.outputLimits.screenshots`, 1, 1000);
|
|
const matrixScreenshots = routes.filter((item) => item.screenshot).length * viewports.length;
|
|
const workflowScreenshots = (workflows.agentObserver?.views.length ?? 0)
|
|
+ (workflows.agentObserver?.selection === undefined ? 0 : 1)
|
|
+ (workflows.tasktree === undefined ? 0 : 1);
|
|
if (matrixScreenshots + workflowScreenshots > screenshotBudget) {
|
|
throw new Error(`${path}.outputLimits.screenshots=${screenshotBudget} is below the declared matrix/workflow screenshot count ${matrixScreenshots + workflowScreenshots}`);
|
|
}
|
|
return {
|
|
navigationTimeoutMs: integer(raw.navigationTimeoutMs, `${path}.navigationTimeoutMs`, 1000, 120_000),
|
|
settleMs: integer(raw.settleMs, `${path}.settleMs`, 0, 30_000),
|
|
commandTimeoutSeconds: integer(raw.commandTimeoutSeconds, `${path}.commandTimeoutSeconds`, 60, 3600),
|
|
minTextLength: integer(raw.minTextLength, `${path}.minTextLength`, 1, 100_000),
|
|
maxHorizontalOverflowPx: integer(raw.maxHorizontalOverflowPx, `${path}.maxHorizontalOverflowPx`, 0, 1000),
|
|
outputLimits: {
|
|
failures: integer(output.failures, `${path}.outputLimits.failures`, 1, 1000),
|
|
consoleErrors: integer(output.consoleErrors, `${path}.outputLimits.consoleErrors`, 1, 1000),
|
|
responseErrors: integer(output.responseErrors, `${path}.outputLimits.responseErrors`, 1, 1000),
|
|
screenshots: screenshotBudget,
|
|
textPreviewChars: integer(output.textPreviewChars, `${path}.outputLimits.textPreviewChars`, 20, 2000),
|
|
},
|
|
viewports,
|
|
routes,
|
|
workflows,
|
|
};
|
|
}
|
|
|
|
function viewport(value: unknown, path: string): HwlabRuntimeWebProbeConsoleViewportSpec {
|
|
const raw = record(value, path);
|
|
onlyKeys(raw, ["id", "width", "height"], path);
|
|
return {
|
|
id: simpleId(text(raw.id, `${path}.id`), `${path}.id`),
|
|
width: integer(raw.width, `${path}.width`, 240, 7680),
|
|
height: integer(raw.height, `${path}.height`, 240, 4320),
|
|
};
|
|
}
|
|
|
|
function route(value: unknown, path: string): HwlabRuntimeWebProbeConsoleRouteSpec {
|
|
const raw = record(value, path);
|
|
onlyKeys(raw, ["id", "path", "readySelector", "screenshot"], path);
|
|
return {
|
|
id: simpleId(text(raw.id, `${path}.id`), `${path}.id`),
|
|
path: absolutePath(text(raw.path, `${path}.path`), `${path}.path`),
|
|
readySelector: boundedText(raw.readySelector, `${path}.readySelector`, 500),
|
|
screenshot: boolean(raw.screenshot, `${path}.screenshot`),
|
|
};
|
|
}
|
|
|
|
function workflowConfig(
|
|
value: unknown,
|
|
path: string,
|
|
routeIds: ReadonlySet<string>,
|
|
): HwlabRuntimeWebProbeConsoleVerificationProfileSpec["workflows"] {
|
|
const raw = record(value, path);
|
|
onlyKeys(raw, ["projects", "hwpod", "mdtodo", "tasktree", "agentObserver"], path);
|
|
return {
|
|
...(raw.projects === undefined ? {} : { projects: projectsWorkflow(raw.projects, `${path}.projects`, routeIds) }),
|
|
...(raw.hwpod === undefined ? {} : { hwpod: hwpodWorkflow(raw.hwpod, `${path}.hwpod`, routeIds) }),
|
|
...(raw.mdtodo === undefined ? {} : { mdtodo: mdtodoWorkflow(raw.mdtodo, `${path}.mdtodo`, routeIds) }),
|
|
...(raw.tasktree === undefined ? {} : { tasktree: tasktreeWorkflow(raw.tasktree, `${path}.tasktree`, routeIds) }),
|
|
...(raw.agentObserver === undefined ? {} : { agentObserver: agentObserverWorkflow(raw.agentObserver, `${path}.agentObserver`, routeIds) }),
|
|
};
|
|
}
|
|
|
|
function tasktreeWorkflow(value: unknown, path: string, routeIds: ReadonlySet<string>): HwlabRuntimeWebProbeConsoleTasktreeWorkflowSpec {
|
|
const raw = record(value, path);
|
|
onlyKeys(raw, [
|
|
"routeId", "taskSelector", "barSelector", "milestoneSelector", "ganttSelector",
|
|
"scrollSelector", "labelPaneSelector",
|
|
"scaleSelector", "tickSelector", "zoomInButtonName", "secondsScaleLabel", "zoomScreenshotName",
|
|
"dialogSelector", "reportSelector", "detailScreenshotName",
|
|
], path);
|
|
const detailScreenshotName = boundedText(raw.detailScreenshotName, `${path}.detailScreenshotName`, 120);
|
|
if (!/^[A-Za-z0-9._-]+\.png$/u.test(detailScreenshotName)) throw new Error(`${path}.detailScreenshotName must be a safe PNG filename`);
|
|
const zoomScreenshotName = boundedText(raw.zoomScreenshotName, `${path}.zoomScreenshotName`, 120);
|
|
if (!/^[A-Za-z0-9._-]+\.png$/u.test(zoomScreenshotName)) throw new Error(`${path}.zoomScreenshotName must be a safe PNG filename`);
|
|
return {
|
|
routeId: routeReference(raw.routeId, `${path}.routeId`, routeIds),
|
|
taskSelector: boundedText(raw.taskSelector, `${path}.taskSelector`, 500),
|
|
barSelector: boundedText(raw.barSelector, `${path}.barSelector`, 500),
|
|
milestoneSelector: boundedText(raw.milestoneSelector, `${path}.milestoneSelector`, 500),
|
|
ganttSelector: boundedText(raw.ganttSelector, `${path}.ganttSelector`, 500),
|
|
scrollSelector: boundedText(raw.scrollSelector, `${path}.scrollSelector`, 500),
|
|
labelPaneSelector: boundedText(raw.labelPaneSelector, `${path}.labelPaneSelector`, 500),
|
|
scaleSelector: boundedText(raw.scaleSelector, `${path}.scaleSelector`, 500),
|
|
tickSelector: boundedText(raw.tickSelector, `${path}.tickSelector`, 500),
|
|
zoomInButtonName: boundedText(raw.zoomInButtonName, `${path}.zoomInButtonName`, 120),
|
|
secondsScaleLabel: boundedText(raw.secondsScaleLabel, `${path}.secondsScaleLabel`, 40),
|
|
zoomScreenshotName,
|
|
dialogSelector: boundedText(raw.dialogSelector, `${path}.dialogSelector`, 500),
|
|
reportSelector: boundedText(raw.reportSelector, `${path}.reportSelector`, 500),
|
|
detailScreenshotName,
|
|
};
|
|
}
|
|
|
|
function projectsWorkflow(value: unknown, path: string, routeIds: ReadonlySet<string>): HwlabRuntimeWebProbeConsoleProjectsWorkflowSpec {
|
|
const raw = record(value, path);
|
|
onlyKeys(raw, ["routeId", "listButtonName", "viewQueryKey"], path);
|
|
return {
|
|
routeId: routeReference(raw.routeId, `${path}.routeId`, routeIds),
|
|
listButtonName: boundedText(raw.listButtonName, `${path}.listButtonName`, 100),
|
|
viewQueryKey: simpleId(text(raw.viewQueryKey, `${path}.viewQueryKey`), `${path}.viewQueryKey`),
|
|
};
|
|
}
|
|
|
|
function hwpodWorkflow(value: unknown, path: string, routeIds: ReadonlySet<string>): HwlabRuntimeWebProbeConsoleHwpodWorkflowSpec {
|
|
const raw = record(value, path);
|
|
onlyKeys(raw, [
|
|
"devicesRouteId", "nodesRouteId", "onboardingRouteId", "listButtonName", "cardButtonName",
|
|
"devicesListSelector", "devicesCardSelector", "nodesListSelector", "pcbTraceSelector", "detailButtonName",
|
|
"detailPathPrefix", "onboardingCommandSelector", "requiredCommandPrefixes", "downloadSelector",
|
|
"forbiddenDownloadSubstrings", "forbiddenInternalSelector",
|
|
], path);
|
|
return {
|
|
devicesRouteId: routeReference(raw.devicesRouteId, `${path}.devicesRouteId`, routeIds),
|
|
nodesRouteId: routeReference(raw.nodesRouteId, `${path}.nodesRouteId`, routeIds),
|
|
onboardingRouteId: routeReference(raw.onboardingRouteId, `${path}.onboardingRouteId`, routeIds),
|
|
listButtonName: boundedText(raw.listButtonName, `${path}.listButtonName`, 100),
|
|
cardButtonName: boundedText(raw.cardButtonName, `${path}.cardButtonName`, 100),
|
|
devicesListSelector: boundedText(raw.devicesListSelector, `${path}.devicesListSelector`, 500),
|
|
devicesCardSelector: boundedText(raw.devicesCardSelector, `${path}.devicesCardSelector`, 500),
|
|
nodesListSelector: boundedText(raw.nodesListSelector, `${path}.nodesListSelector`, 500),
|
|
pcbTraceSelector: boundedText(raw.pcbTraceSelector, `${path}.pcbTraceSelector`, 500),
|
|
detailButtonName: boundedText(raw.detailButtonName, `${path}.detailButtonName`, 100),
|
|
detailPathPrefix: absolutePath(text(raw.detailPathPrefix, `${path}.detailPathPrefix`), `${path}.detailPathPrefix`),
|
|
onboardingCommandSelector: boundedText(raw.onboardingCommandSelector, `${path}.onboardingCommandSelector`, 500),
|
|
requiredCommandPrefixes: textArray(raw.requiredCommandPrefixes, `${path}.requiredCommandPrefixes`, 1, 20, 300),
|
|
downloadSelector: boundedText(raw.downloadSelector, `${path}.downloadSelector`, 500),
|
|
forbiddenDownloadSubstrings: textArray(raw.forbiddenDownloadSubstrings, `${path}.forbiddenDownloadSubstrings`, 1, 20, 300),
|
|
forbiddenInternalSelector: boundedText(raw.forbiddenInternalSelector, `${path}.forbiddenInternalSelector`, 500),
|
|
};
|
|
}
|
|
|
|
function mdtodoWorkflow(value: unknown, path: string, routeIds: ReadonlySet<string>): HwlabRuntimeWebProbeConsoleMdtodoWorkflowSpec {
|
|
const raw = record(value, path);
|
|
onlyKeys(raw, [
|
|
"routeId", "taskSelector", "taskDeepLinkSegment", "taskDetailSelector", "reportLinkSelector",
|
|
"reportPreviewSelector", "fullscreenButtonSelector", "fullscreenDialogSelector",
|
|
], path);
|
|
const segment = boundedText(raw.taskDeepLinkSegment, `${path}.taskDeepLinkSegment`, 100);
|
|
if (!segment.startsWith("/")) throw new Error(`${path}.taskDeepLinkSegment must start with /`);
|
|
return {
|
|
routeId: routeReference(raw.routeId, `${path}.routeId`, routeIds),
|
|
taskSelector: boundedText(raw.taskSelector, `${path}.taskSelector`, 500),
|
|
taskDeepLinkSegment: segment,
|
|
taskDetailSelector: boundedText(raw.taskDetailSelector, `${path}.taskDetailSelector`, 500),
|
|
reportLinkSelector: boundedText(raw.reportLinkSelector, `${path}.reportLinkSelector`, 500),
|
|
reportPreviewSelector: boundedText(raw.reportPreviewSelector, `${path}.reportPreviewSelector`, 500),
|
|
fullscreenButtonSelector: boundedText(raw.fullscreenButtonSelector, `${path}.fullscreenButtonSelector`, 500),
|
|
fullscreenDialogSelector: boundedText(raw.fullscreenDialogSelector, `${path}.fullscreenDialogSelector`, 500),
|
|
};
|
|
}
|
|
|
|
function agentObserverWorkflow(value: unknown, path: string, routeIds: ReadonlySet<string>): HwlabRuntimeWebProbeConsoleAgentObserverWorkflowSpec {
|
|
const raw = record(value, path);
|
|
onlyKeys(raw, ["routeId", "eventSourcePath", "phaseSelector", "blockedPhases", "streamSettleMs", "views", "selection"], path);
|
|
const views = array(raw.views, `${path}.views`, 1, 10).map((item, index) => agentView(item, `${path}.views[${index}]`));
|
|
const selection = raw.selection === undefined ? undefined : agentSelection(raw.selection, `${path}.selection`);
|
|
unique(views.map((item) => item.queryValue), `${path}.views[].queryValue`);
|
|
unique([...views.map((item) => item.screenshotName), ...(selection === undefined ? [] : [selection.screenshotName])], `${path}.screenshots`);
|
|
return {
|
|
routeId: routeReference(raw.routeId, `${path}.routeId`, routeIds),
|
|
eventSourcePath: absolutePath(text(raw.eventSourcePath, `${path}.eventSourcePath`), `${path}.eventSourcePath`),
|
|
phaseSelector: boundedText(raw.phaseSelector, `${path}.phaseSelector`, 500),
|
|
blockedPhases: textArray(raw.blockedPhases, `${path}.blockedPhases`, 1, 20, 100),
|
|
streamSettleMs: integer(raw.streamSettleMs, `${path}.streamSettleMs`, 0, 120_000),
|
|
views,
|
|
...(selection === undefined ? {} : { selection }),
|
|
};
|
|
}
|
|
|
|
function agentSelection(value: unknown, path: string): HwlabRuntimeWebProbeConsoleAgentSelectionSpec {
|
|
const raw = record(value, path);
|
|
onlyKeys(raw, ["selector", "inspectorSelector", "screenshotName"], path);
|
|
const screenshotName = boundedText(raw.screenshotName, `${path}.screenshotName`, 120);
|
|
if (!/^[A-Za-z0-9._-]+\.png$/u.test(screenshotName)) throw new Error(`${path}.screenshotName must be a safe PNG filename`);
|
|
return {
|
|
selector: boundedText(raw.selector, `${path}.selector`, 500),
|
|
inspectorSelector: boundedText(raw.inspectorSelector, `${path}.inspectorSelector`, 500),
|
|
screenshotName,
|
|
};
|
|
}
|
|
|
|
function agentView(value: unknown, path: string): HwlabRuntimeWebProbeConsoleAgentViewSpec {
|
|
const raw = record(value, path);
|
|
onlyKeys(raw, ["buttonName", "queryValue", "screenshotName"], path);
|
|
const screenshotName = boundedText(raw.screenshotName, `${path}.screenshotName`, 120);
|
|
if (!/^[A-Za-z0-9._-]+\.png$/u.test(screenshotName)) throw new Error(`${path}.screenshotName must be a safe PNG filename`);
|
|
return {
|
|
buttonName: boundedText(raw.buttonName, `${path}.buttonName`, 100),
|
|
queryValue: simpleId(text(raw.queryValue, `${path}.queryValue`), `${path}.queryValue`),
|
|
screenshotName,
|
|
};
|
|
}
|
|
|
|
function routeReference(value: unknown, path: string, routeIds: ReadonlySet<string>): string {
|
|
const id = simpleId(text(value, path), path);
|
|
if (!routeIds.has(id)) throw new Error(`${path} references unknown route ${id}`);
|
|
return id;
|
|
}
|
|
|
|
function record(value: unknown, path: string): Record<string, unknown> {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`);
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function array(value: unknown, path: string, min: number, max: number): unknown[] {
|
|
if (!Array.isArray(value) || value.length < min || value.length > max) throw new Error(`${path} must contain ${min}-${max} items`);
|
|
return value;
|
|
}
|
|
|
|
function text(value: unknown, path: string): string {
|
|
if (typeof value !== "string" || value.trim().length === 0 || value.includes("\0")) throw new Error(`${path} must be a non-empty string`);
|
|
return value;
|
|
}
|
|
|
|
function boundedText(value: unknown, path: string, max: number): string {
|
|
const result = text(value, path);
|
|
if (result.length > max) throw new Error(`${path} must be at most ${max} chars`);
|
|
return result;
|
|
}
|
|
|
|
function textArray(value: unknown, path: string, min: number, max: number, maxChars: number): readonly string[] {
|
|
return array(value, path, min, max).map((item, index) => boundedText(item, `${path}[${index}]`, maxChars));
|
|
}
|
|
|
|
function simpleId(value: string, path: string): string {
|
|
if (!/^[A-Za-z0-9._-]+$/u.test(value) || value.length > 100) throw new Error(`${path} must be a simple id up to 100 chars`);
|
|
return value;
|
|
}
|
|
|
|
function absolutePath(value: string, path: string): string {
|
|
if (!value.startsWith("/") || value.length > 300) throw new Error(`${path} must be an absolute path up to 300 chars`);
|
|
return value;
|
|
}
|
|
|
|
function integer(value: unknown, path: string, min: number, max: number): number {
|
|
if (!Number.isInteger(value) || (value as number) < min || (value as number) > max) throw new Error(`${path} must be an integer ${min}-${max}`);
|
|
return value as number;
|
|
}
|
|
|
|
function boolean(value: unknown, path: string): boolean {
|
|
if (typeof value !== "boolean") throw new Error(`${path} must be boolean`);
|
|
return value;
|
|
}
|
|
|
|
function onlyKeys(raw: Record<string, unknown>, allowed: readonly string[], path: string): void {
|
|
const allow = new Set(allowed);
|
|
const unknown = Object.keys(raw).filter((key) => !allow.has(key));
|
|
if (unknown.length > 0) throw new Error(`${path} has unsupported fields: ${unknown.join(", ")}`);
|
|
}
|
|
|
|
function unique(values: readonly string[], path: string): void {
|
|
const seen = new Set<string>();
|
|
for (const value of values) {
|
|
if (seen.has(value)) throw new Error(`${path} contains duplicate ${value}`);
|
|
seen.add(value);
|
|
}
|
|
}
|