Add vitest, add json validation tests, fix broken json files (#566)

This commit is contained in:
Håvard Gjøby Thom 2024-11-28 15:50:40 +01:00 committed by GitHub
parent 000f206d90
commit 03be08be63
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
208 changed files with 3312 additions and 928 deletions

View file

@ -0,0 +1,11 @@
import { screen } from "@testing-library/dom";
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import Page from "@/app/page";
describe("Page", () => {
it("should show button to view scripts", () => {
render(<Page />);
expect(screen.getByRole("button", { name: "View Scripts" })).toBeDefined();
});
});

View file

@ -0,0 +1,53 @@
import { describe, it, assert, beforeAll } from "vitest";
import { promises as fs } from "fs";
import path from "path";
import { ScriptSchema, type Script } from "@/app/json-editor/_schemas/schemas";
import { Metadata } from "@/lib/types";
const jsonDir = "public/json";
const metadataFileName = "metadata.json";
const encoding = "utf-8";
const fileNames = (await fs.readdir(jsonDir))
.filter((fileName) => fileName !== metadataFileName)
describe.each(fileNames)("%s", async (fileName) => {
let script: Script;
beforeAll(async () => {
const filePath = path.resolve(jsonDir, fileName);
const fileContent = await fs.readFile(filePath, encoding)
script = JSON.parse(fileContent);
})
it("should have valid json according to script schema", () => {
ScriptSchema.parse(script);
});
it("should have a corresponding script file", () => {
script.install_methods.forEach((method) => {
const scriptPath = path.resolve("..", method.script)
assert(fs.stat(scriptPath), `Script file not found: ${scriptPath}`)
})
});
})
describe(`${metadataFileName}`, async () => {
let metadata: Metadata;
beforeAll(async () => {
const filePath = path.resolve(jsonDir, metadataFileName);
const fileContent = await fs.readFile(filePath, encoding)
metadata = JSON.parse(fileContent);
})
it("should have valid json according to metadata schema", () => {
// TODO: create zod schema for metadata. Move zod schemas to /lib/types.ts
assert(metadata.categories.length > 0);
metadata.categories.forEach((category) => {
assert.isString(category.name)
assert.isNumber(category.id)
assert.isNumber(category.sort_order)
});
});
})

View file

@ -0,0 +1,4 @@
import { vi } from "vitest";
// Mock canvas getContext
HTMLCanvasElement.prototype.getContext = vi.fn();

View file

@ -9,11 +9,9 @@ import {
import { Category } from "@/lib/types";
import { cn } from "@/lib/utils";
import { z } from "zod";
import { ScriptSchema } from "../_schemas/schemas";
import { type Script } from "../_schemas/schemas";
import { memo } from "react";
type Script = z.infer<typeof ScriptSchema>;
type CategoryProps = {
script: Script;
setScript: (script: Script) => void;

View file

@ -9,11 +9,9 @@ import {
} from "@/components/ui/select";
import { OperatingSystems } from "@/config/siteConfig";
import { PlusCircle, Trash2 } from "lucide-react";
import { memo, useCallback, useEffect, useRef } from "react";
import { memo, useCallback, useRef } from "react";
import { z } from "zod";
import { InstallMethodSchema, ScriptSchema } from "../_schemas/schemas";
type Script = z.infer<typeof ScriptSchema>;
import { InstallMethodSchema, ScriptSchema, type Script } from "../_schemas/schemas";
type InstallMethodProps = {
script: Script;
@ -194,11 +192,11 @@ function InstallMethod({
</SelectContent>
</Select>
<Select
value={method.resources.version ? String(method.resources.version) : undefined}
value={method.resources.version || undefined}
onValueChange={(value) =>
updateInstallMethod(index, "resources", {
...method.resources,
version: value ? Number(value) : null,
version: value || null,
})
}
disabled={method.type === "alpine"}

View file

@ -11,11 +11,9 @@ import { AlertColors } from "@/config/siteConfig";
import { cn } from "@/lib/utils";
import { PlusCircle, Trash2 } from "lucide-react";
import { z } from "zod";
import { ScriptSchema } from "../_schemas/schemas";
import { ScriptSchema, type Script } from "../_schemas/schemas";
import { memo, useCallback } from "react";
type Script = z.infer<typeof ScriptSchema>;
type NoteProps = {
script: Script;
setScript: (script: Script) => void;

View file

@ -10,7 +10,7 @@ export const InstallMethodSchema = z.object({
ram: z.number().nullable(),
hdd: z.number().nullable(),
os: z.string().nullable(),
version: z.number().nullable(),
version: z.string().nullable(),
}),
});
@ -24,8 +24,8 @@ export const ScriptSchema = z.object({
slug: z.string().min(1, "Slug is required"),
categories: z.array(z.number()),
date_created: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in YYYY-MM-DD format").min(1, "Date is required"),
type: z.enum(["vm", "ct", "misc"], {
errorMap: () => ({ message: "Type must be either 'vm', 'ct', or 'misc'" })
type: z.enum(["vm", "ct", "misc", "turnkey"], {
errorMap: () => ({ message: "Type must be either 'vm', 'ct', 'misc' or 'turnkey'" })
}),
updateable: z.boolean(),
privileged: z.boolean(),
@ -41,3 +41,5 @@ export const ScriptSchema = z.object({
}),
notes: z.array(NoteSchema),
});
export type Script = z.infer<typeof ScriptSchema>;

View file

@ -5,7 +5,11 @@ import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
@ -26,9 +30,7 @@ import { z } from "zod";
import Categories from "./_components/Categories";
import InstallMethod from "./_components/InstallMethod";
import Note from "./_components/Note";
import { ScriptSchema } from "./_schemas/schemas";
type Script = z.infer<typeof ScriptSchema>;
import { ScriptSchema, type Script } from "./_schemas/schemas";
const initialScript: Script = {
name: "",
@ -64,25 +66,29 @@ export default function JSONGenerator() {
.catch((error) => console.error("Error fetching categories:", error));
}, []);
const updateScript = useCallback((key: keyof Script, value: Script[keyof Script]) => {
setScript((prev) => {
const updated = { ...prev, [key]: value };
const updateScript = useCallback(
(key: keyof Script, value: Script[keyof Script]) => {
setScript((prev) => {
const updated = { ...prev, [key]: value };
if (key === "type" || key === "slug") {
updated.install_methods = updated.install_methods.map((method) => ({
...method,
script: method.type === "alpine"
? `/${updated.type}/alpine-${updated.slug}.sh`
: `/${updated.type}/${updated.slug}.sh`,
}));
}
if (key === "type" || key === "slug") {
updated.install_methods = updated.install_methods.map((method) => ({
...method,
script:
method.type === "alpine"
? `/${updated.type}/alpine-${updated.slug}.sh`
: `/${updated.type}/${updated.slug}.sh`,
}));
}
const result = ScriptSchema.safeParse(updated);
setIsValid(result.success);
setZodErrors(result.success ? null : result.error);
return updated;
});
}, []);
const result = ScriptSchema.safeParse(updated);
setIsValid(result.success);
setZodErrors(result.success ? null : result.error);
return updated;
});
},
[],
);
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(JSON.stringify(script, null, 2));
@ -91,37 +97,43 @@ export default function JSONGenerator() {
toast.success("Copied metadata to clipboard");
}, [script]);
const handleDateSelect = useCallback((date: Date | undefined) => {
updateScript(
"date_created",
format(date || new Date(), "yyyy-MM-dd")
);
}, [updateScript]);
const formattedDate = useMemo(() =>
script.date_created ? format(script.date_created, "PPP") : undefined,
[script.date_created]
const handleDateSelect = useCallback(
(date: Date | undefined) => {
updateScript("date_created", format(date || new Date(), "yyyy-MM-dd"));
},
[updateScript],
);
const validationAlert = useMemo(() => (
<Alert className={cn("text-black", isValid ? "bg-green-100" : "bg-red-100")}>
<AlertTitle>{isValid ? "Valid JSON" : "Invalid JSON"}</AlertTitle>
<AlertDescription>
{isValid
? "The current JSON is valid according to the schema."
: "The current JSON does not match the required schema."}
</AlertDescription>
{zodErrors && (
<div className="mt-2 space-y-1">
{zodErrors.errors.map((error, index) => (
<AlertDescription key={index} className="p-1 text-red-500">
{error.path.join(".")} - {error.message}
</AlertDescription>
))}
</div>
)}
</Alert>
), [isValid, zodErrors]);
const formattedDate = useMemo(
() =>
script.date_created ? format(script.date_created, "PPP") : undefined,
[script.date_created],
);
const validationAlert = useMemo(
() => (
<Alert
className={cn("text-black", isValid ? "bg-green-100" : "bg-red-100")}
>
<AlertTitle>{isValid ? "Valid JSON" : "Invalid JSON"}</AlertTitle>
<AlertDescription>
{isValid
? "The current JSON is valid according to the schema."
: "The current JSON does not match the required schema."}
</AlertDescription>
{zodErrors && (
<div className="mt-2 space-y-1">
{zodErrors.errors.map((error, index) => (
<AlertDescription key={index} className="p-1 text-red-500">
{error.path.join(".")} - {error.message}
</AlertDescription>
))}
</div>
)}
</Alert>
),
[isValid, zodErrors],
);
return (
<div className="flex h-screen mt-20">
@ -222,14 +234,18 @@ export default function JSONGenerator() {
<div className="flex items-center space-x-2">
<Switch
checked={script.updateable}
onCheckedChange={(checked) => updateScript("updateable", checked)}
onCheckedChange={(checked) =>
updateScript("updateable", checked)
}
/>
<label>Updateable</label>
</div>
<div className="flex items-center space-x-2">
<Switch
checked={script.privileged}
onCheckedChange={(checked) => updateScript("privileged", checked)}
onCheckedChange={(checked) =>
updateScript("privileged", checked)
}
/>
<label>Privileged</label>
</div>
@ -238,7 +254,12 @@ export default function JSONGenerator() {
placeholder="Interface Port"
type="number"
value={script.interface_port || ""}
onChange={(e) => updateScript("interface_port", e.target.value ? Number(e.target.value) : null)}
onChange={(e) =>
updateScript(
"interface_port",
e.target.value ? Number(e.target.value) : null,
)
}
/>
<div className="flex gap-2">
<Input
@ -249,7 +270,9 @@ export default function JSONGenerator() {
<Input
placeholder="Documentation URL"
value={script.documentation || ""}
onChange={(e) => updateScript("documentation", e.target.value || null)}
onChange={(e) =>
updateScript("documentation", e.target.value || null)
}
/>
</div>
<InstallMethod
@ -262,18 +285,22 @@ export default function JSONGenerator() {
<Input
placeholder="Username"
value={script.default_credentials.username || ""}
onChange={(e) => updateScript("default_credentials", {
...script.default_credentials,
username: e.target.value || null,
})}
onChange={(e) =>
updateScript("default_credentials", {
...script.default_credentials,
username: e.target.value || null,
})
}
/>
<Input
placeholder="Password"
value={script.default_credentials.password || ""}
onChange={(e) => updateScript("default_credentials", {
...script.default_credentials,
password: e.target.value || null,
})}
onChange={(e) =>
updateScript("default_credentials", {
...script.default_credentials,
password: e.target.value || null,
})
}
/>
<Note
script={script}

View file

@ -21,7 +21,7 @@ export type Script = {
ram: number | null;
hdd: number | null;
os: string | null;
version: number | null;
version: string | null;
};
}[];
default_credentials: {