Checkpoint web parity through Chromium input tasks
This commit is contained in:
168
web/protocol/library-negative-cases.ts
Normal file
168
web/protocol/library-negative-cases.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const LIBRARY_NEGATIVE_CASE_SCHEMA = 1 as const;
|
||||
|
||||
export interface LibraryNegativeLibraryIR {
|
||||
libraryId: string;
|
||||
dependencyIds: string[];
|
||||
}
|
||||
|
||||
export interface LibraryNegativeDataBlockIR {
|
||||
dataBlockId: string;
|
||||
sourceLibraryId: string;
|
||||
}
|
||||
|
||||
export interface LibraryNegativeCrossReferenceIR {
|
||||
fromLibraryId: string;
|
||||
toLibraryId: string;
|
||||
}
|
||||
|
||||
export interface LibraryNegativeReloadIR {
|
||||
sourceLibraryId: string;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
export interface LibraryNegativeInputIR {
|
||||
schemaVersion: typeof LIBRARY_NEGATIVE_CASE_SCHEMA;
|
||||
libraries: LibraryNegativeLibraryIR[];
|
||||
dataBlocks: LibraryNegativeDataBlockIR[];
|
||||
crossReferences: LibraryNegativeCrossReferenceIR[];
|
||||
reloads: LibraryNegativeReloadIR[];
|
||||
}
|
||||
|
||||
export interface LibraryNegativeValidationIR {
|
||||
status: "VALID";
|
||||
}
|
||||
|
||||
export class LibraryNegativeValidationError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
readonly path?: string;
|
||||
|
||||
constructor(code: ErrorCode, message: string, path?: string) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "LibraryNegativeValidationError";
|
||||
this.code = code;
|
||||
this.path = path;
|
||||
}
|
||||
}
|
||||
|
||||
const LIBRARY_ID = /^library:[a-f0-9]{64}$/;
|
||||
const DATA_BLOCK_ID = /^[A-Za-z0-9][A-Za-z0-9:._/ -]{0,255}$/;
|
||||
const MAX_ENTRIES = 10_000;
|
||||
|
||||
function record(value: unknown, path: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", `${path} must be an object`, path);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, expected: readonly string[], path: string): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const allowed = [...expected].sort();
|
||||
if (actual.length !== allowed.length || actual.some((key, index) => key !== allowed[index])) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", `${path} contains undeclared fields`, path);
|
||||
}
|
||||
|
||||
function libraryId(value: unknown, path: string): string {
|
||||
if (typeof value !== "string" || !LIBRARY_ID.test(value)) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", `${path} must be a library identity`, path);
|
||||
return value;
|
||||
}
|
||||
|
||||
function dataBlockId(value: unknown, path: string): string {
|
||||
if (typeof value !== "string" || !DATA_BLOCK_ID.test(value)) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", `${path} is invalid`, path);
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, path: string): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", `${path} must be a safe integer >= 0`, path);
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseLibrary(value: unknown, path: string): LibraryNegativeLibraryIR {
|
||||
const item = record(value, path);
|
||||
exactKeys(item, ["libraryId", "dependencyIds"], path);
|
||||
if (!Array.isArray(item.dependencyIds) || item.dependencyIds.length > MAX_ENTRIES) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", `${path}.dependencyIds exceeds its bound`, path);
|
||||
return { libraryId: libraryId(item.libraryId, `${path}.libraryId`), dependencyIds: item.dependencyIds.map((dependency, index) => libraryId(dependency, `${path}.dependencyIds[${index}]`)) };
|
||||
}
|
||||
|
||||
function parseDataBlock(value: unknown, path: string): LibraryNegativeDataBlockIR {
|
||||
const item = record(value, path);
|
||||
exactKeys(item, ["dataBlockId", "sourceLibraryId"], path);
|
||||
return { dataBlockId: dataBlockId(item.dataBlockId, `${path}.dataBlockId`), sourceLibraryId: libraryId(item.sourceLibraryId, `${path}.sourceLibraryId`) };
|
||||
}
|
||||
|
||||
function parseCrossReference(value: unknown, path: string): LibraryNegativeCrossReferenceIR {
|
||||
const item = record(value, path);
|
||||
exactKeys(item, ["fromLibraryId", "toLibraryId"], path);
|
||||
return { fromLibraryId: libraryId(item.fromLibraryId, `${path}.fromLibraryId`), toLibraryId: libraryId(item.toLibraryId, `${path}.toLibraryId`) };
|
||||
}
|
||||
|
||||
function parseReload(value: unknown, path: string): LibraryNegativeReloadIR {
|
||||
const item = record(value, path);
|
||||
exactKeys(item, ["sourceLibraryId", "generation"], path);
|
||||
return { sourceLibraryId: libraryId(item.sourceLibraryId, `${path}.sourceLibraryId`), generation: integer(item.generation, `${path}.generation`) };
|
||||
}
|
||||
|
||||
export function parseLibraryNegativeInput(value: unknown): LibraryNegativeInputIR {
|
||||
const input = record(value, "input");
|
||||
exactKeys(input, ["schemaVersion", "libraries", "dataBlocks", "crossReferences", "reloads"], "input");
|
||||
if (input.schemaVersion !== LIBRARY_NEGATIVE_CASE_SCHEMA) throw new LibraryNegativeValidationError("PROTOCOL_MISMATCH", "Unsupported library negative-case schema", "schemaVersion");
|
||||
const libraries = input.libraries;
|
||||
const dataBlocks = input.dataBlocks;
|
||||
const crossReferences = input.crossReferences;
|
||||
const reloads = input.reloads;
|
||||
if (!Array.isArray(libraries) || libraries.length > MAX_ENTRIES) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", "input.libraries exceeds its bound", "input.libraries");
|
||||
if (!Array.isArray(dataBlocks) || dataBlocks.length > MAX_ENTRIES) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", "input.dataBlocks exceeds its bound", "input.dataBlocks");
|
||||
if (!Array.isArray(crossReferences) || crossReferences.length > MAX_ENTRIES) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", "input.crossReferences exceeds its bound", "input.crossReferences");
|
||||
if (!Array.isArray(reloads) || reloads.length > MAX_ENTRIES) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", "input.reloads exceeds its bound", "input.reloads");
|
||||
return {
|
||||
schemaVersion: LIBRARY_NEGATIVE_CASE_SCHEMA,
|
||||
libraries: libraries.map((item, index) => parseLibrary(item, `libraries[${index}]`)),
|
||||
dataBlocks: dataBlocks.map((item, index) => parseDataBlock(item, `dataBlocks[${index}]`)),
|
||||
crossReferences: crossReferences.map((item, index) => parseCrossReference(item, `crossReferences[${index}]`)),
|
||||
reloads: reloads.map((item, index) => parseReload(item, `reloads[${index}]`)),
|
||||
};
|
||||
}
|
||||
|
||||
function assertNoCycle(nodes: Set<string>, edges: Map<string, string[]>, label: string): void {
|
||||
const active = new Set<string>();
|
||||
const complete = new Set<string>();
|
||||
const visit = (node: string): void => {
|
||||
if (active.has(node)) throw new LibraryNegativeValidationError("LIBRARY_DEPENDENCY_CYCLE", `${label} contains a cycle at ${node}`, label);
|
||||
if (complete.has(node)) return;
|
||||
active.add(node);
|
||||
for (const dependency of edges.get(node) ?? []) {
|
||||
if (!nodes.has(dependency)) throw new LibraryNegativeValidationError("ASSET_MANIFEST_INVALID", `${label} references a missing library ${dependency}`, label);
|
||||
visit(dependency);
|
||||
}
|
||||
active.delete(node);
|
||||
complete.add(node);
|
||||
};
|
||||
for (const node of nodes) visit(node);
|
||||
}
|
||||
|
||||
export function validateLibraryNegativeInput(value: unknown): LibraryNegativeValidationIR {
|
||||
const input = parseLibraryNegativeInput(value);
|
||||
const libraries = new Set(input.libraries.map((item) => item.libraryId));
|
||||
if (libraries.size !== input.libraries.length) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", "duplicate library ID", "libraries");
|
||||
const libraryEdges = new Map(input.libraries.map((item) => [item.libraryId, item.dependencyIds]));
|
||||
assertNoCycle(libraries, libraryEdges, "library dependencies");
|
||||
const crossEdges = new Map<string, string[]>();
|
||||
for (const item of input.crossReferences) {
|
||||
if (!libraries.has(item.fromLibraryId) || !libraries.has(item.toLibraryId)) throw new LibraryNegativeValidationError("ASSET_MANIFEST_INVALID", "cross-library reference names a missing library", "crossReferences");
|
||||
crossEdges.set(item.fromLibraryId, [...(crossEdges.get(item.fromLibraryId) ?? []), item.toLibraryId]);
|
||||
}
|
||||
assertNoCycle(libraries, crossEdges, "cross-library references");
|
||||
const dataBlocks = new Set<string>();
|
||||
for (const item of input.dataBlocks) {
|
||||
if (!libraries.has(item.sourceLibraryId)) throw new LibraryNegativeValidationError("ASSET_SOURCE_HASH_MISMATCH", "data-block source library is missing", "dataBlocks");
|
||||
if (dataBlocks.has(item.dataBlockId)) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", `duplicate data-block ID ${item.dataBlockId}`, "dataBlocks");
|
||||
dataBlocks.add(item.dataBlockId);
|
||||
}
|
||||
const reloads = new Set<string>();
|
||||
for (const item of input.reloads) {
|
||||
if (!libraries.has(item.sourceLibraryId)) throw new LibraryNegativeValidationError("ASSET_SOURCE_HASH_MISMATCH", "reload source library is missing", "reloads");
|
||||
const identity = `${item.sourceLibraryId}:${item.generation}`;
|
||||
if (reloads.has(identity)) throw new LibraryNegativeValidationError("REVISION_CONFLICT", `duplicate reload ${identity}`, "reloads");
|
||||
reloads.add(identity);
|
||||
}
|
||||
return { status: "VALID" };
|
||||
}
|
||||
Reference in New Issue
Block a user