183 lines
7.1 KiB
TypeScript
183 lines
7.1 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { describe, expect, it } from "vitest";
|
|
import { uiAdapterCategories } from "@toolctrl/ui";
|
|
import { layoutAdapters } from "@toolctrl/ui/layout";
|
|
import { navigationAdapters } from "@toolctrl/ui/navigation";
|
|
import { UI_ACTION_IDS, UI_NAV_IDS, UI_SURFACE_IDS } from "~/application/ui/uiRegistry";
|
|
import { readRepoDir, repoPathExists } from "~/utils/testing/repoPaths";
|
|
|
|
type UiInventoryComponent = {
|
|
stableName: string;
|
|
path: string;
|
|
category: string;
|
|
library: string;
|
|
status: string;
|
|
};
|
|
|
|
type UiInventory = {
|
|
schemaVersion: number;
|
|
notes: string[];
|
|
components: UiInventoryComponent[];
|
|
};
|
|
|
|
type UiSurfaceMapEntry = {
|
|
id: string;
|
|
kind: "surface" | "action" | "nav";
|
|
label: string;
|
|
sourceFiles: string[];
|
|
renderer: string;
|
|
notes: string[];
|
|
};
|
|
|
|
type UiSurfaceMap = {
|
|
schemaVersion: number;
|
|
surfaces: UiSurfaceMapEntry[];
|
|
};
|
|
|
|
const inventoryPath = path.join(process.cwd(), "docs/ui-inventory/components.json");
|
|
const schemaPath = path.join(process.cwd(), "docs/ui-inventory/schema.json");
|
|
const surfacesPath = path.join(process.cwd(), "docs/ui-inventory/surfaces.json");
|
|
const surfacesSchemaPath = path.join(process.cwd(), "docs/ui-inventory/surfaces.schema.json");
|
|
const inventory = JSON.parse(fs.readFileSync(inventoryPath, "utf8")) as UiInventory;
|
|
const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8")) as {
|
|
$defs: { component: { properties: { category: { enum: string[] }; status: { enum: string[] } } } };
|
|
};
|
|
const surfaceMap = JSON.parse(fs.readFileSync(surfacesPath, "utf8")) as UiSurfaceMap;
|
|
const surfaceSchema = JSON.parse(fs.readFileSync(surfacesSchemaPath, "utf8")) as {
|
|
$defs: { surface: { properties: { kind: { enum: string[] } } } };
|
|
};
|
|
|
|
const allowedCategories = schema.$defs.component.properties.category.enum;
|
|
const allowedStatuses = schema.$defs.component.properties.status.enum;
|
|
const allowedSurfaceKinds = surfaceSchema.$defs.surface.properties.kind.enum;
|
|
|
|
function physicalWebPath(sourcePath: string) {
|
|
if (sourcePath.startsWith("apps/web/app/")) {
|
|
return sourcePath;
|
|
}
|
|
return sourcePath.startsWith("app/") ? `apps/web/${sourcePath}` : sourcePath;
|
|
}
|
|
|
|
function expectStringArray(value: unknown) {
|
|
expect(Array.isArray(value)).toBe(true);
|
|
expect((value as unknown[]).every((item) => typeof item === "string")).toBe(true);
|
|
}
|
|
|
|
describe("UI inventory", () => {
|
|
it("matches the declared schema shape", () => {
|
|
expect(inventory.schemaVersion).toBe(1);
|
|
expect(Array.isArray(inventory.notes)).toBe(true);
|
|
expect(inventory.components.length).toBeGreaterThan(0);
|
|
|
|
inventory.components.forEach((component) => {
|
|
expect(component.stableName).toEqual(expect.any(String));
|
|
expect(component.path).toEqual(expect.any(String));
|
|
expect(component.library).toEqual(expect.any(String));
|
|
expect(allowedCategories).toContain(component.category);
|
|
expect(allowedStatuses).toContain(component.status);
|
|
});
|
|
});
|
|
|
|
it("uses the same category names as packages/ui", () => {
|
|
expect(new Set(allowedCategories)).toEqual(new Set(uiAdapterCategories));
|
|
});
|
|
|
|
it("contains inventory entries for every available shell/navigation adapter", () => {
|
|
const availableAdapterNames = [...layoutAdapters, ...navigationAdapters].map((adapter) => adapter.name);
|
|
const inventoryStableNames = new Set(inventory.components.map((component) => component.stableName));
|
|
|
|
availableAdapterNames.forEach((adapterName) => {
|
|
expect(inventoryStableNames.has(adapterName), adapterName).toBe(true);
|
|
});
|
|
});
|
|
|
|
it("points every inventory entry at an existing file or directory", () => {
|
|
inventory.components.forEach((component) => {
|
|
expect(repoPathExists(component.path), component.path).toBe(true);
|
|
});
|
|
});
|
|
|
|
it("covers root shadcn/custom UI primitive files", () => {
|
|
const inventoryPaths = new Set(inventory.components.map((component) => component.path));
|
|
const rootPrimitivePaths = readRepoDir("apps/web/app/components/ui")
|
|
.filter((file) => /^[a-z0-9-]+\.tsx$/.test(file))
|
|
.map((file) => `apps/web/app/components/ui/${file}`);
|
|
|
|
rootPrimitivePaths.forEach((primitivePath) => {
|
|
expect(inventoryPaths.has(primitivePath), primitivePath).toBe(true);
|
|
});
|
|
});
|
|
|
|
it("references every shell/navigation adapter source in the inventory or surface map", () => {
|
|
const inventoryPaths = new Set(inventory.components.map((component) => component.path));
|
|
const surfaceSourceFiles = new Set(surfaceMap.surfaces.flatMap((surface) => surface.sourceFiles));
|
|
const adapterSources = [...layoutAdapters, ...navigationAdapters].map((adapter) => physicalWebPath(adapter.source));
|
|
|
|
adapterSources.forEach((source) => {
|
|
expect(inventoryPaths.has(source) || surfaceSourceFiles.has(source), source).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("UI surface map", () => {
|
|
it("provides the UI agent playbooks and human-readable surface map", () => {
|
|
const requiredDocs = [
|
|
"docs/agent-playbooks/ui-changes.md",
|
|
"docs/agent-playbooks/ui-adapter-migration.md",
|
|
"docs/ui-inventory/surface-map.md",
|
|
"docs/ui-inventory/surfaces.json",
|
|
"docs/ui-inventory/surfaces.schema.json",
|
|
];
|
|
|
|
requiredDocs.forEach((docPath) => {
|
|
expect(fs.existsSync(path.join(process.cwd(), docPath)), docPath).toBe(true);
|
|
});
|
|
|
|
const uiPlaybook = fs.readFileSync(path.join(process.cwd(), "docs/agent-playbooks/ui-changes.md"), "utf8");
|
|
const adapterPlaybook = fs.readFileSync(path.join(process.cwd(), "docs/agent-playbooks/ui-adapter-migration.md"), "utf8");
|
|
expect(uiPlaybook).toContain("Identify the target by `data-ui-id`");
|
|
expect(adapterPlaybook).toContain("@toolctrl/ui/buttons");
|
|
});
|
|
|
|
it("matches the declared surface schema shape", () => {
|
|
expect(surfaceMap.schemaVersion).toBe(1);
|
|
expect(surfaceMap.surfaces.length).toBeGreaterThan(0);
|
|
|
|
surfaceMap.surfaces.forEach((surface) => {
|
|
expect(surface.id).toMatch(/^ui\.(surface|action|nav)\./);
|
|
expect(allowedSurfaceKinds).toContain(surface.kind);
|
|
expect(surface.label).toEqual(expect.any(String));
|
|
expect(surface.renderer).toEqual(expect.any(String));
|
|
expectStringArray(surface.sourceFiles);
|
|
expectStringArray(surface.notes);
|
|
});
|
|
});
|
|
|
|
it("points every surface map source file at an existing file", () => {
|
|
surfaceMap.surfaces.forEach((surface) => {
|
|
surface.sourceFiles.forEach((sourceFile) => {
|
|
expect(repoPathExists(sourceFile), `${surface.id} -> ${sourceFile}`).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
it("maps all stable registry surface, action and nav IDs", () => {
|
|
const mappedIds = new Set(surfaceMap.surfaces.map((surface) => surface.id));
|
|
const registryIds = [...Object.values(UI_SURFACE_IDS), ...Object.values(UI_ACTION_IDS), ...Object.values(UI_NAV_IDS)];
|
|
|
|
registryIds.forEach((id) => {
|
|
expect(mappedIds.has(id), id).toBe(true);
|
|
});
|
|
});
|
|
|
|
it("keeps mapped IDs unique and kind-aligned", () => {
|
|
const ids = surfaceMap.surfaces.map((surface) => surface.id);
|
|
expect(new Set(ids).size).toBe(ids.length);
|
|
|
|
surfaceMap.surfaces.forEach((surface) => {
|
|
expect(surface.id.startsWith(`ui.${surface.kind}.`), surface.id).toBe(true);
|
|
});
|
|
});
|
|
});
|