diff --git a/README.md b/README.md index cb161f6..e8713ca 100644 --- a/README.md +++ b/README.md @@ -459,8 +459,11 @@ While a run goes on, the benchmarks page shows it live. Every run, published con - **ngrok.** With `NGROK_AUTHTOKEN` set, the server is tunnelled through the static domain in `live.domain` (or `NGROK_DOMAIN`) with the `@ngrok/ngrok` package, and the public URL is logged. Without it there is no tunnel. The tunnel and the server close when the run ends. -- **The page.** `project.live.url` (default `https:///live.json`) goes into - report.json, so the latest page (the site root) knows where to look. It polls every 15 +- **The page.** The live URL (`project.live.url`, else `live.url`, else + `https:///live.json`) goes into report.json and into the site's `data/site.json`, + so the latest page (the site root) knows where to look, even before the first run is + published (`perfkit render --config`, below). It reads `data/site.json` first, then the latest + report. It polls every 15 seconds; while a run is in progress it shows it live, marked preliminary. When the run ends it says the report is being published and shows it as soon as `data/runs.json` lists the run. With no run in progress the page is the latest report, unchanged. Run pages never go live. @@ -500,15 +503,31 @@ stopped, 1 for a usage error and 2 when it can't listen (the port is taken). ```sh perfkit render --report [--history ] --out +perfkit render --config [--history ] --out ``` Renders one run's `report.json` into the project's benchmarks site, a static site the caller publishes (for example under `/benchmarks` on GitHub Pages). It validates the report first, with the same error lines as `perfkit validate`. +With `--config` instead of `--report` it adds no run: it writes the site from the project's +`perfkit.yaml` (logo and favicon inlined) and the history as it is. Without `--history` that is +a site with no runs yet: the site root shows the project's title and lead and "No published runs +yet. When a run is in progress it appears here live.", and goes live as soon as a run starts. Use +it to publish (or clear) a project's site before its first published run: + +```console +$ npx perfkit render --config perf/perfkit.yaml --out out/benchmarks +wrote out/benchmarks/app/0.1.12/app.js (perfkit 0.1.12) +wrote out/benchmarks/data/runs.json (0 runs) +wrote out/benchmarks/data/site.json (live: https://perfkit.ngrok.app/live.json) +wrote out/benchmarks/index.html (no runs yet) +``` + | Flag | Meaning | | :--- | :--- | -| `--report ` | The run's `report.json`. Required. | +| `--report ` | The run's `report.json`. This or `--config` is required, not both. | +| `--config ` | Instead of `--report`: write the site from the project's perfkit.yaml with no run added. | | `--history ` | The published `data/runs.json`, as a file or an `https://` URL. A URL that answers 404 is treated as no history. Without it, this is the first run: the page has no deltas and perfkit prints `perfkit: no history: first run, deltas omitted`. | | `--out ` | The directory to write the site into. Required. | @@ -519,6 +538,7 @@ wrote out/benchmarks/app/0.2.0/app.js (perfkit 0.2.0) wrote out/benchmarks/data/runs/20261103T141210Z-bd3254a/report.json wrote out/benchmarks/runs/20261103T141210Z-bd3254a/report.json wrote out/benchmarks/data/runs.json (15 runs) +wrote out/benchmarks/data/site.json (live: https://perfkit.ngrok.app/live.json) wrote out/benchmarks/index.html (latest: 20261103T141210Z-bd3254a) wrote out/benchmarks/runs/20261103T141210Z-bd3254a/index.html wrote out/benchmarks/runs/20261027T093000Z-36bf8f8/index.html @@ -532,6 +552,10 @@ files on the same site. The output layout: position. A run already listed (same id) is replaced, so rendering twice is safe. Point `--history` at the published copy of this file (a `runs.json` of the old layout, at the site root, also works). +- `data/site.json` (`schema: perfkit/site/v1`, JSON Schema in `schemas/site-v1.schema.json`): + the project, as report.json carries it: name, repo, site URL, accent, logo and favicon SVGs, + title, lead and live URL. Every render rewrites it, from the report or from `--config`; the + app draws the no-runs page from it and reads the live URL from it first. - `data/runs//report.json`: this run's report, as read; the app draws the page from it. A copy also goes to `runs//report.json`, where the page's `report.json` link points. - `app//app.js`: this perfkit's browser app, the same renderer `perfkit` has always used, @@ -552,8 +576,8 @@ compare with, is in [docs/report-page.md](docs/report-page.md). | Exit code | Meaning | | :--- | :--- | | 0 | The site was written. | -| 1 | Usage error, such as a missing `--out`. | -| 2 | The report or the history is invalid, unreadable or of another schema. | +| 1 | Usage error, such as a missing `--out`, or neither or both of `--report` and `--config`. | +| 2 | The report, the config or the history is invalid, unreadable or of another schema. | | 3 | The history URL could not be fetched (any error but a 404). | ## Development diff --git a/docs/report-page.md b/docs/report-page.md index f7507fd..cc9c4d7 100644 --- a/docs/report-page.md +++ b/docs/report-page.md @@ -9,6 +9,9 @@ the history shows the runs before it. ## Pages +- A site with no published runs yet (`perfkit render --config`) has only `/benchmarks/`: the + project's title and lead and "No published runs yet. When a run is in progress it appears + here live.", drawn from `data/site.json`. It goes live like the latest page. - `/benchmarks/` is the latest run, the one with the newest `startedAt`. Its run picker marks it `latest`, and "Link to this run" points to its own page. - `/benchmarks/runs//` is one run's page. It starts with "You're viewing the run of …" and a diff --git a/package-lock.json b/package-lock.json index 3e08fb7..4dfc36c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "perfkit", - "version": "0.1.11", + "version": "0.1.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "perfkit", - "version": "0.1.11", + "version": "0.1.12", "license": "MIT", "dependencies": { "@ngrok/ngrok": "1.7.0", diff --git a/package.json b/package.json index 80eb1d0..33970ce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "perfkit", - "version": "0.1.11", + "version": "0.1.12", "description": "Benchmark harness for Fonoster projects: DigitalOcean runs, one report.json schema, one branded report page", "license": "MIT", "author": "Pedro Sanders ", diff --git a/schemas/site-v1.schema.json b/schemas/site-v1.schema.json new file mode 100644 index 0000000..1b676a6 --- /dev/null +++ b/schemas/site-v1.schema.json @@ -0,0 +1,76 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "schema": { + "type": "string", + "const": "perfkit/site/v1" + }, + "project": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "repo": { + "type": "string", + "pattern": "^[\\w.-]+\\/[\\w.-]+$" + }, + "siteUrl": { + "type": "string", + "format": "uri" + }, + "accent": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}$" + }, + "logoSvg": { + "type": "string", + "pattern": "]" + }, + "faviconSvg": { + "type": "string", + "pattern": "]" + }, + "title": { + "type": "string", + "minLength": 1 + }, + "lead": { + "type": "string", + "minLength": 1 + }, + "live": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "url" + ], + "additionalProperties": false + } + }, + "required": [ + "name", + "repo", + "siteUrl", + "accent", + "title", + "lead" + ], + "additionalProperties": false + } + }, + "required": [ + "schema", + "project" + ], + "additionalProperties": false, + "title": "site.json v1", + "description": "The project of a perfkit benchmarks site: its name, branding and live.json URL (schema: perfkit/site/v1)." +} diff --git a/src/cli/runCli.ts b/src/cli/runCli.ts index 9e77d39..f6c5059 100644 --- a/src/cli/runCli.ts +++ b/src/cli/runCli.ts @@ -100,15 +100,22 @@ Writes to --out: runs//report.json this run's report, for the page's link data/runs.json the history, newest first, with this run in it data/runs//report.json this run's report, which the app draws + data/site.json the project: name, branding, live.json URL app//app.js this perfkit's app, which draws every page +With --config instead of --report, no run is added: the project comes from +perfkit.yaml and the history is kept as it is. Without --history that is a +site with no runs yet, which says so and shows a run in progress live: + + perfkit render --config perf/perfkit.yaml --out out/benchmarks + The pages are drawn in the browser from data/, so older reports are never read or rewritten, and publishing with a newer perfkit redraws every run. Exit codes: 0 the site was written 1 usage error - 2 the report or the history is invalid, unreadable or of another schema + 2 the report, the config or the history is invalid, unreadable or of another schema 3 the history URL could not be fetched (a 404 means no history)`; const SERVE_HELP = ` @@ -305,9 +312,13 @@ export async function runCli(argv: string[], deps: CliDeps): Promise { program .command("render") .description( - "Add a report.json to the benchmarks site: its data, the page shells and the app that draws them." + "Add a report.json to the benchmarks site (or, with --config alone, write the site with no new run): its data, the page shells and the app that draws them." + ) + .option("--report ", "the run's report.json") + .option( + "--config ", + "instead of --report: write the site from the project's perfkit.yaml and the history as it is, with no run added (no runs yet without --history)" ) - .requiredOption("--report ", "the run's report.json") .option( "--history ", "the published data/runs.json (or a runs.json of the old layout), as a file or an https URL; without it, this is the first run" @@ -315,7 +326,19 @@ export async function runCli(argv: string[], deps: CliDeps): Promise { .requiredOption("--out ", "the directory to write the site into") .addHelpText("after", RENDER_HELP) .action( - async (options: { report: string; history?: string; out: string }) => { + async (options: { + report?: string; + config?: string; + history?: string; + out: string; + }) => { + if ((options.report === undefined) === (options.config === undefined)) { + deps.stderr( + "perfkit: render needs --report or --config , not both\n" + ); + exitCode = EXIT_USAGE; + return; + } const result = await renderSite(options); if (!result.ok) { result.errors.forEach((line) => deps.stderr(`${line}\n`)); diff --git a/src/orchestrate/toReportProject.ts b/src/orchestrate/toReportProject.ts index a2f95a1..9d15ebe 100644 --- a/src/orchestrate/toReportProject.ts +++ b/src/orchestrate/toReportProject.ts @@ -1,6 +1,7 @@ /** * Copyright (C) 2026 by Fonoster Inc (https://fonoster.com). MIT License. */ +import { projectFromConfig } from "../schemas/projectFromConfig.js"; import type { Report } from "../schemas/index.js"; import type { RunState } from "./runState.schema.js"; @@ -10,20 +11,8 @@ import type { RunState } from "./runState.schema.js"; * domain in `live.domain`, else there is none. */ export function toReportProject(state: RunState): Report["project"] { - const { project, live } = state.config; - const liveUrl = - project.live?.url ?? - live?.url ?? - (live?.domain ? `https://${live.domain}/live.json` : null); - return { - name: project.name, - repo: project.repo, - siteUrl: project.siteUrl, - accent: project.accent, + return projectFromConfig(state.config, { ...(state.logoSvg ? { logoSvg: state.logoSvg } : {}), - ...(state.faviconSvg ? { faviconSvg: state.faviconSvg } : {}), - title: project.title, - lead: project.lead, - ...(liveUrl ? { live: { url: liveUrl } } : {}) - }; + ...(state.faviconSvg ? { faviconSvg: state.faviconSvg } : {}) + }); } diff --git a/src/render/app/createDrawRun.ts b/src/render/app/createDrawRun.ts index 2dc2851..1bde9c2 100644 --- a/src/render/app/createDrawRun.ts +++ b/src/render/app/createDrawRun.ts @@ -4,7 +4,8 @@ import type { z } from "zod/v4"; import { toFieldErrors } from "../../errors/toFieldErrors.js"; import { withErrorHandlingAndValidation } from "../../errors/withErrorHandlingAndValidation.js"; -import { reportSchema, runsSchema } from "../../schemas/index.js"; +import { reportSchema, runsSchema, siteSchema } from "../../schemas/index.js"; +import { renderEmptyPage } from "../renderEmptyPage.js"; import { renderRunPage } from "../renderRunPage.js"; import { toPageContext } from "../toPageContext.js"; import { type DrawRunInput, drawRunInputSchema } from "./drawRun.schema.js"; @@ -12,7 +13,8 @@ import { resolveRunPath } from "./resolveRunPath.js"; import { SITE_PATHS } from "./sitePaths.js"; import type { DrawRunDeps, DrawRunResult } from "./types.js"; -type Fetched = { ok: true; value: T } | { ok: false; errors: string[] }; +type Fetched = + { ok: true; value: T } | { ok: false; errors: string[]; missing?: true }; /** * Creates the browser app's draw step: from the page's path it picks the run, @@ -20,7 +22,8 @@ type Fetched = { ok: true; value: T } | { ok: false; errors: string[] }; * the same schemas as `perfkit validate`, and returns the page HTML that * `perfkit render` used to write, from the same renderer. A report missing * from `data/runs//` is read from `runs//report.json`, where - * sites rendered before the app kept it. + * sites rendered before the app kept it. The latest page of a site with no + * runs yet is drawn from `data/site.json`, the project alone. * * @param deps - The app's version and a fetch relative to the site root * @returns A validated function that returns the page HTML, or error lines @@ -32,11 +35,27 @@ export function createDrawRun(deps: DrawRunDeps) { return { ok: false, errors: [`perfkit: no run page at ${path}`] }; } const runs = await fetchValid(SITE_PATHS.runs, runsSchema); - if (!runs.ok) return runs; + if (!runs.ok) return { ok: false, errors: runs.errors }; const current = target.page === "latest" ? runs.value.runs[0] : runs.value.runs.find((run) => run.id === target.id); + if (current === undefined && target.page === "latest") { + const site = await fetchValid(SITE_PATHS.site, siteSchema); + if (!site.ok && site.missing) { + return { ok: false, errors: [`${SITE_PATHS.runs}: no runs yet`] }; + } + if (!site.ok) return { ok: false, errors: site.errors }; + return { + ok: true, + runId: null, + html: renderEmptyPage({ + project: site.value.project, + perfkitVersion: deps.version + }), + liveUrl: site.value.project.live?.url ?? null + }; + } if (current === undefined) { return { ok: false, @@ -52,7 +71,7 @@ export function createDrawRun(deps: DrawRunDeps) { reportSchema, `${current.path}report.json` ); - if (!report.ok) return report; + if (!report.ok) return { ok: false, errors: report.errors }; const html = renderRunPage( toPageContext({ report: report.value, @@ -92,7 +111,8 @@ export function createDrawRun(deps: DrawRunDeps) { if (response.status < 200 || response.status > 299) { return { ok: false, - errors: [`${path}: cannot fetch: HTTP ${response.status}`] + errors: [`${path}: cannot fetch: HTTP ${response.status}`], + ...(response.status === 404 ? { missing: true as const } : {}) }; } let document: unknown; diff --git a/src/render/app/createReadSiteLiveUrl.ts b/src/render/app/createReadSiteLiveUrl.ts new file mode 100644 index 0000000..eb06d79 --- /dev/null +++ b/src/render/app/createReadSiteLiveUrl.ts @@ -0,0 +1,28 @@ +/** + * Copyright (C) 2026 by Fonoster Inc (https://fonoster.com). MIT License. + */ +import { siteSchema } from "../../schemas/index.js"; +import { SITE_PATHS } from "./sitePaths.js"; +import type { DrawRunDeps } from "./types.js"; + +/** + * Creates the app's read of the project's live.json URL from + * `data/site.json`. A site rendered before site.json existed, or an invalid + * or unreachable one, has none: the app then falls back to the latest + * report's. + * + * @param deps - A fetch relative to the site root + * @returns A function that returns the URL or null; it never rejects + */ +export function createReadSiteLiveUrl(deps: Pick) { + return async (): Promise => { + try { + const response = await deps.fetchText(SITE_PATHS.site); + if (response.status !== 200) return null; + const parsed = siteSchema.safeParse(JSON.parse(response.text)); + return parsed.success ? (parsed.data.project.live?.url ?? null) : null; + } catch { + return null; + } + }; +} diff --git a/src/render/app/main.ts b/src/render/app/main.ts index 6ff027e..3f8f62b 100644 --- a/src/render/app/main.ts +++ b/src/render/app/main.ts @@ -7,12 +7,14 @@ * with `defer`; it reads which run to draw from the page's URL, fetches * the data next to it and replaces the shell with the page. The latest page * also polls the project's live.json every 15 seconds and shows a run in - * progress live; a run page never does. + * progress live; a run page never does. The live.json URL is `?live=`, else + * the one in data/site.json, else the latest report's. */ import { LIVE_INTERVAL_S, runsSchema } from "../../schemas/index.js"; import { createDrawRun } from "./createDrawRun.js"; import { createLiveController } from "./createLiveController.js"; import { createLiveDomView } from "./createLiveDomView.js"; +import { createReadSiteLiveUrl } from "./createReadSiteLiveUrl.js"; import { mountPage } from "./mountPage.js"; import { resolveLiveUrl } from "./resolveLiveUrl.js"; import { resolveRunPath } from "./resolveRunPath.js"; @@ -86,10 +88,12 @@ const goLive = (liveUrl: string, sitePath: string) => { if (path === null) { showErrors(document, [`perfkit: ${location.href} is outside the site`]); } else { - draw(path).then((result) => { + const readSiteLiveUrl = createReadSiteLiveUrl({ fetchText }); + draw(path).then(async (result) => { if (resolveRunPath(path)?.page !== "latest") return; const liveUrl = resolveLiveUrl( location.href, + await readSiteLiveUrl(), result.ok ? result.liveUrl : null ); if (liveUrl) goLive(liveUrl, path); diff --git a/src/render/app/resolveLiveUrl.ts b/src/render/app/resolveLiveUrl.ts index 5c52671..a541149 100644 --- a/src/render/app/resolveLiveUrl.ts +++ b/src/render/app/resolveLiveUrl.ts @@ -4,15 +4,16 @@ /** * The live.json URL the latest page polls: `?live=` on the page (for * a local run, like `?live=http://localhost:8787/live.json`), else the - * project's `live.url` from the latest report, else none. Only http and + * first of `sources` that is set, in order: the project's `live.url` from + * `data/site.json`, then from the latest report; else none. Only http and * https URLs count. */ export function resolveLiveUrl( pageUrl: string, - fromReport: string | null + ...sources: Array ): string | null { const override = new URL(pageUrl).searchParams.get("live"); - const candidate = override ?? fromReport; + const candidate = override ?? sources.find((source) => !!source) ?? null; if (!candidate) return null; try { const url = new URL(candidate, pageUrl); diff --git a/src/render/app/sitePaths.ts b/src/render/app/sitePaths.ts index 779034d..fdd1575 100644 --- a/src/render/app/sitePaths.ts +++ b/src/render/app/sitePaths.ts @@ -7,6 +7,8 @@ */ export const SITE_PATHS = { runs: "data/runs.json", + /** The project: name, branding and live.json URL. */ + site: "data/site.json", report: (id: string) => `data/runs/${id}/report.json`, /** The run's page; also holds a copy of its report.json, for the page's link. */ runDir: (id: string) => `runs/${id}/`, diff --git a/src/render/app/types.ts b/src/render/app/types.ts index 0bfaa2c..c93ea85 100644 --- a/src/render/app/types.ts +++ b/src/render/app/types.ts @@ -15,9 +15,10 @@ export interface DrawRunDeps { export type DrawRunResult = | { ok: true; - runId: string; + /** The run drawn; null for the latest page of a site with no runs. */ + runId: string | null; html: string; - /** The project's live.json URL from the report, or null. */ + /** The project's live.json URL from the report (or site.json), or null. */ liveUrl: string | null; } | { ok: false; errors: string[] }; diff --git a/src/render/createRenderSite.ts b/src/render/createRenderSite.ts index ad8598c..4476470 100644 --- a/src/render/createRenderSite.ts +++ b/src/render/createRenderSite.ts @@ -1,16 +1,21 @@ /** * Copyright (C) 2026 by Fonoster Inc (https://fonoster.com). MIT License. */ -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { toFieldErrors } from "../errors/toFieldErrors.js"; import { withErrorHandlingAndValidation } from "../errors/withErrorHandlingAndValidation.js"; import { + projectFromConfig, + type Report, reportSchema, type RunEntry, type Runs, runsSchema, - SCHEMA_IDS + SCHEMA_IDS, + SITE_SCHEMA_ID, + type Site } from "../schemas/index.js"; +import { createLoadConfig } from "../validate/createLoadConfig.js"; import { createValidateFile } from "../validate/createValidateFile.js"; import { parseDocument } from "../validate/parseDocument.js"; import { SITE_PATHS } from "./app/sitePaths.js"; @@ -34,30 +39,44 @@ export const NO_HISTORY_WARNING = type History = { ok: true; runs: Runs | null } | Extract; +type SiteFile = WrittenFile & { text: string }; + /** * Creates the function behind `perfkit render`: it validates a report.json, * reads the history (a runs.json file or https URL; a 404 is no history), * and writes the data-driven site: this perfkit's browser app * (`app//app.js`), this run's report (`data/runs//report.json`, * and a copy in `runs//` for the page's report.json link), the history - * (`data/runs.json`), and the shells (`index.html` and one + * (`data/runs.json`), the project (`data/site.json`: its name, branding and + * live.json URL), and the shells (`index.html` and one * `runs//index.html` per run in the history), which load the app and * carry this report's favicon, so the tab has its icon before the app draws. The * app draws each page from the data when it is viewed, so older reports are * never read or rewritten, and a new perfkit redraws every run. * + * With `config` instead of `report` it adds no run: the project comes from + * perfkit.yaml (logo and favicon inlined) and the history is written as it + * is, empty without `history`. A project can so publish its site, and show + * a run in progress live, before its first run is published. + * * @param deps - File system, network and version (injected) * @returns A validated function that returns the files written, or the * error lines and exit code (2 invalid input, 3 history fetch failed) */ export function createRenderSite(deps: RenderSiteDeps) { const validateFile = createValidateFile(deps); + const loadConfig = createLoadConfig(deps); + + const fn = async (input: RenderSiteInput): Promise => + input.report === undefined + ? renderFromConfig(input.config ?? "", input.history, input.out) + : renderReport(input.report, input.history, input.out); - const fn = async ({ - report: reportFile, - history: historySource, - out - }: RenderSiteInput): Promise => { + async function renderReport( + reportFile: string, + historySource: string | undefined, + out: string + ): Promise { const checked = await validateFile({ file: reportFile }); if (!checked.valid) { return { @@ -82,53 +101,122 @@ export function createRenderSite(deps: RenderSiteDeps) { if (!history.ok) return history; const warnings = history.runs === null ? [NO_HISTORY_WARNING] : []; - const entry = toRunEntry(report); - const merged = mergeRuns(history.runs, entry, report.project.name); - const newest = merged.runs[0] ?? entry; - const shell = (run: RunEntry, page: PageKind) => + const merged = mergeRuns( + history.runs, + toRunEntry(report), + report.project.name + ); + const at = (path: string) => join(out, ...path.split("/")); + return writeSite( + [ + await appFile(out), + { + path: at(SITE_PATHS.report(report.run.id)), + note: null, + text: reportText + }, + { + path: at(`${SITE_PATHS.runDir(report.run.id)}report.json`), + note: null, + text: reportText + }, + ...siteFiles(out, report.project, merged) + ], + warnings + ); + } + + async function renderFromConfig( + configFile: string, + historySource: string | undefined, + out: string + ): Promise { + const loaded = await loadConfig({ file: configFile }); + if (!loaded.ok) { + return { + ok: false, + exitCode: 2, + errors: lines(configFile, loaded.errors) + }; + } + const { config } = loaded; + const inline = async (file: string | undefined) => + file === undefined + ? undefined + : (await deps.readFile(join(dirname(configFile), file))).trim(); + const logoSvg = await inline(config.project.logo); + const faviconSvg = await inline(config.project.favicon); + const project = projectFromConfig(config, { + ...(logoSvg ? { logoSvg } : {}), + ...(faviconSvg ? { faviconSvg } : {}) + }); + + const history = await readHistory(historySource); + if (!history.ok) return history; + const runs: Runs = history.runs ?? { + schema: SCHEMA_IDS.runs, + project: project.name, + runs: [] + }; + return writeSite( + [await appFile(out), ...siteFiles(out, project, runs)], + [] + ); + } + + async function appFile(out: string): Promise { + return { + path: join(out, ...SITE_PATHS.app(deps.version).split("/")), + note: `perfkit ${deps.version}`, + text: await deps.appBundle() + }; + } + + /** The history, the project and the shells: what every render writes. */ + function siteFiles( + out: string, + project: Report["project"], + runs: Runs + ): SiteFile[] { + const at = (path: string) => join(out, ...path.split("/")); + const shell = (run: RunEntry | null, page: PageKind) => renderShell({ - project: merged.project, + project: runs.project, run, page, perfkitVersion: deps.version, - ...(report.project.faviconSvg - ? { faviconSvg: report.project.faviconSvg } - : {}) + ...(project.faviconSvg ? { faviconSvg: project.faviconSvg } : {}) }); - const at = (path: string) => join(out, ...path.split("/")); - - const files: Array = [ - { - path: at(SITE_PATHS.app(deps.version)), - note: `perfkit ${deps.version}`, - text: await deps.appBundle() - }, + const site: Site = { schema: SITE_SCHEMA_ID, project }; + const newest = runs.runs[0] ?? null; + return [ { - path: at(SITE_PATHS.report(report.run.id)), - note: null, - text: reportText + path: at(SITE_PATHS.runs), + note: `${runs.runs.length} run${runs.runs.length === 1 ? "" : "s"}`, + text: `${JSON.stringify(runs, null, 2)}\n` }, { - path: at(`${SITE_PATHS.runDir(report.run.id)}report.json`), - note: null, - text: reportText - }, - { - path: at(SITE_PATHS.runs), - note: `${merged.runs.length} run${merged.runs.length === 1 ? "" : "s"}`, - text: `${JSON.stringify(merged, null, 2)}\n` + path: at(SITE_PATHS.site), + note: project.live ? `live: ${project.live.url}` : "no live URL", + text: `${JSON.stringify(site, null, 2)}\n` }, { path: at("index.html"), - note: `latest: ${newest.id}`, + note: newest ? `latest: ${newest.id}` : "no runs yet", text: shell(newest, "latest") }, - ...merged.runs.map((run) => ({ + ...runs.runs.map((run) => ({ path: at(`${SITE_PATHS.runDir(run.id)}index.html`), note: null, text: shell(run, "run") })) ]; + } + + async function writeSite( + files: SiteFile[], + warnings: string[] + ): Promise { await files.reduce( (previous, file) => previous.then(() => deps.writeFile(file.path, file.text)), @@ -139,7 +227,7 @@ export function createRenderSite(deps: RenderSiteDeps) { written: files.map(({ path, note }) => ({ path, note })), warnings }; - }; + } async function readHistory(source: string | undefined): Promise { if (source === undefined) return { ok: true, runs: null }; diff --git a/src/render/renderEmptyPage.ts b/src/render/renderEmptyPage.ts new file mode 100644 index 0000000..4e86861 --- /dev/null +++ b/src/render/renderEmptyPage.ts @@ -0,0 +1,73 @@ +/** + * Copyright (C) 2026 by Fonoster Inc (https://fonoster.com). MIT License. + */ +import type { Report } from "../schemas/index.js"; +import { esc } from "./html.js"; +import { pageStyles } from "./pageStyles.js"; +import { renderFaviconLink } from "./renderFaviconLink.js"; +import { FONTS } from "./renderRunPage.js"; +import { renderLogo } from "./sections/renderLogo.js"; + +export const NO_RUNS_TEXT = + "No published runs yet. When a run is in progress it appears here live."; + +/** + * The latest page of a site with no published runs: the nav's brand, the + * project's eyebrow, title and lead, one line saying there are no runs yet, + * and the footer, in the report page's styles. The app replaces it with the + * live view when a run is in progress. + */ +export function renderEmptyPage(params: { + project: Report["project"]; + perfkitVersion: string; +}): string { + const { project, perfkitVersion } = params; + const logo = renderLogo(project); + const site = new URL(project.siteUrl); + const methodology = `https://github.com/fonoster/perfkit/blob/v${perfkitVersion}/docs/report-page.md`; + return ` + + + + +${esc(`${project.name} benchmarks`)} +${renderFaviconLink(project.faviconSvg)} + + + + + + + + +
+
+
+

BENCHMARKS · ${esc(project.name.toUpperCase())}

+

${esc(project.title)}

+

${esc(project.lead)}

+

${esc(NO_RUNS_TEXT)}

+
+
+
+ + + +`; +} diff --git a/src/render/renderRunPage.ts b/src/render/renderRunPage.ts index 084d54a..a31deb5 100644 --- a/src/render/renderRunPage.ts +++ b/src/render/renderRunPage.ts @@ -15,7 +15,7 @@ import { renderSetup } from "./sections/renderSetup.js"; import { renderSoakSection } from "./sections/renderSoakSection.js"; import type { PageContext } from "./types.js"; -const FONTS = +export const FONTS = "https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600&family=Roboto+Mono:wght@400;500&display=swap"; /** diff --git a/src/render/renderShell.ts b/src/render/renderShell.ts index bcefab0..c5281f9 100644 --- a/src/render/renderShell.ts +++ b/src/render/renderShell.ts @@ -13,13 +13,14 @@ import { summaryText } from "./verdicts.js"; * A page's HTML before the app draws it: the run's title, description and * Open Graph tags, so a link preview and a crawler see the run, and the * pinned app version, which draws the page from the site's data. The latest - * page (`index.html`) and every `runs//index.html` are this shell. + * page (`index.html`) and every `runs//index.html` are this shell. A + * site with no runs yet has a latest page with no run. */ export function renderShell(params: { /** The project's name, from runs.json. */ project: string; - /** The run the page shows: the newest one on the latest page. */ - run: RunEntry; + /** The run the page shows: the newest one on the latest page; null with no runs yet. */ + run: RunEntry | null; page: PageKind; perfkitVersion: string; /** The project's favicon, from the report being rendered. */ @@ -27,12 +28,15 @@ export function renderShell(params: { }): string { const { project, run, page, perfkitVersion, faviconSvg } = params; const base = page === "latest" ? "" : "../../"; - const title = `${project} benchmarks · v${run.version}`; - const description = `${summaryText(run.verdict)}. ${project} v${run.version}, run of ${formatDate(run.startedAt)} ${formatTime(run.startedAt)} UTC, commit ${shortCommit(run.commit)}.`; - const reportHref = - page === "latest" - ? `${SITE_PATHS.runDir(run.id)}report.json` - : "report.json"; + const title = run + ? `${project} benchmarks · v${run.version}` + : `${project} benchmarks`; + const description = run + ? `${summaryText(run.verdict)}. ${project} v${run.version}, run of ${formatDate(run.startedAt)} ${formatTime(run.startedAt)} UTC, commit ${shortCommit(run.commit)}.` + : `${project} benchmarks. No published runs yet.`; + const noscript = run + ? `This page draws the benchmark results with JavaScript. The results are in report.json.` + : "This page draws the benchmark results with JavaScript. No runs are published yet."; return ` @@ -49,7 +53,7 @@ ${renderFaviconLink(faviconSvg)} - + `; diff --git a/src/render/renderSite.schema.ts b/src/render/renderSite.schema.ts index b6d4a0d..43a4452 100644 --- a/src/render/renderSite.schema.ts +++ b/src/render/renderSite.schema.ts @@ -3,10 +3,26 @@ */ import { z } from "zod/v4"; -export const renderSiteInputSchema = z.strictObject({ - report: z.string().min(1, { error: "report must not be empty" }), - history: z.string().min(1, { error: "history must not be empty" }).optional(), - out: z.string().min(1, { error: "out must not be empty" }) -}); +/** + * `--report` adds a run to the site; `--config` alone writes the site from + * perfkit.yaml with the history as it is (none: no runs yet). + */ +export const renderSiteInputSchema = z + .strictObject({ + report: z.string().min(1, { error: "report must not be empty" }).optional(), + config: z.string().min(1, { error: "config must not be empty" }).optional(), + history: z + .string() + .min(1, { error: "history must not be empty" }) + .optional(), + out: z.string().min(1, { error: "out must not be empty" }) + }) + .refine( + (input) => (input.report === undefined) !== (input.config === undefined), + { + error: "expected --report or --config, not both and not neither", + path: ["report"] + } + ); export type RenderSiteInput = z.infer; diff --git a/src/schemas/index.ts b/src/schemas/index.ts index b42e4a5..317ade2 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -91,3 +91,5 @@ export { type Runs } from "./runs.schema.js"; export { toJsonSchemaFiles } from "./toJsonSchemaFiles.js"; +export { projectFromConfig } from "./projectFromConfig.js"; +export { SITE_SCHEMA_ID, siteSchema, type Site } from "./site.schema.js"; diff --git a/src/schemas/projectFromConfig.ts b/src/schemas/projectFromConfig.ts new file mode 100644 index 0000000..25ab821 --- /dev/null +++ b/src/schemas/projectFromConfig.ts @@ -0,0 +1,33 @@ +/** + * Copyright (C) 2026 by Fonoster Inc (https://fonoster.com). MIT License. + */ +import type { PerfkitConfig } from "./perfkitConfig.schema.js"; +import type { Report } from "./report.schema.js"; + +/** + * The project as report.json, live.json and site.json carry it, from + * perfkit.yaml and its inlined logo and favicon. Its live URL is + * `project.live.url`, else `live.url`, else the live.json of the static + * ngrok domain in `live.domain`, else there is none. + */ +export function projectFromConfig( + config: Pick, + svgs: { logoSvg?: string; faviconSvg?: string } = {} +): Report["project"] { + const { project, live } = config; + const liveUrl = + project.live?.url ?? + live?.url ?? + (live?.domain ? `https://${live.domain}/live.json` : null); + return { + name: project.name, + repo: project.repo, + siteUrl: project.siteUrl, + accent: project.accent, + ...(svgs.logoSvg ? { logoSvg: svgs.logoSvg } : {}), + ...(svgs.faviconSvg ? { faviconSvg: svgs.faviconSvg } : {}), + title: project.title, + lead: project.lead, + ...(liveUrl ? { live: { url: liveUrl } } : {}) + }; +} diff --git a/src/schemas/site.schema.ts b/src/schemas/site.schema.ts new file mode 100644 index 0000000..ff9d144 --- /dev/null +++ b/src/schemas/site.schema.ts @@ -0,0 +1,26 @@ +/** + * Copyright (C) 2026 by Fonoster Inc (https://fonoster.com). MIT License. + */ +/** + * `data/site.json`, `schema: perfkit/site/v1`: the project the benchmarks + * site belongs to, as report.json carries it. `perfkit render` writes it + * with or without a report, so the app can draw a site with no runs yet and + * find the live.json URL before the first run is published. + */ +import { z } from "zod/v4"; +import { reportProjectSchema } from "./report.schema.js"; + +export const SITE_SCHEMA_ID = "perfkit/site/v1"; + +export const siteSchema = z + .strictObject({ + schema: z.literal(SITE_SCHEMA_ID), + project: reportProjectSchema + }) + .meta({ + title: "site.json v1", + description: + "The project of a perfkit benchmarks site: its name, branding and live.json URL (schema: perfkit/site/v1)." + }); + +export type Site = z.infer; diff --git a/src/schemas/toJsonSchemaFiles.ts b/src/schemas/toJsonSchemaFiles.ts index 0cdff40..4d5b62b 100644 --- a/src/schemas/toJsonSchemaFiles.ts +++ b/src/schemas/toJsonSchemaFiles.ts @@ -6,6 +6,7 @@ import { liveSchema } from "./live.schema.js"; import { perfkitConfigSchema } from "./perfkitConfig.schema.js"; import { reportSchema } from "./report.schema.js"; import { runsSchema } from "./runs.schema.js"; +import { siteSchema } from "./site.schema.js"; /** * The JSON Schema files perfkit ships, by file name under `schemas/`, with @@ -20,7 +21,8 @@ export function toJsonSchemaFiles(): Record { }), "report-v1.schema.json": z.toJSONSchema(reportSchema), "runs-v1.schema.json": z.toJSONSchema(runsSchema), - "live-v1.schema.json": z.toJSONSchema(liveSchema) + "live-v1.schema.json": z.toJSONSchema(liveSchema), + "site-v1.schema.json": z.toJSONSchema(siteSchema) }; return Object.fromEntries( Object.entries(files).map(([name, schema]) => [ diff --git a/test/render/app/bundle.unit.test.ts b/test/render/app/bundle.unit.test.ts index 18f5b41..cbee537 100644 --- a/test/render/app/bundle.unit.test.ts +++ b/test/render/app/bundle.unit.test.ts @@ -4,8 +4,10 @@ import { expect } from "chai"; import { join } from "node:path"; import { createDrawRun } from "../../../src/render/app/createDrawRun.js"; -import { loadReport, REPORT_FORMS } from "../../fixtures.js"; -import { loadHistory, RUNS_4 } from "../site.js"; +import { createRenderSite } from "../../../src/render/createRenderSite.js"; +import { NO_RUNS_TEXT } from "../../../src/render/renderEmptyPage.js"; +import { loadReport, REPORT_FORMS, ROUTR_CONFIG } from "../../fixtures.js"; +import { loadHistory, makeDeps, RUNS_4 } from "../site.js"; import { expectedPage, openPage, @@ -125,4 +127,28 @@ describe("the browser app", () => { !inNode.ok ? inNode.errors.join("\n") : null ); }); + + it("should draw the no-runs page of a site rendered from a config alone", async () => { + // Arrange + const { deps, written } = makeDeps({}, undefined, realApp); + const rendered = await createRenderSite(deps)({ + config: ROUTR_CONFIG, + out: "out" + }); + expect(rendered.ok).to.equal(true); + + // Act + const { document, errors } = await openPage(written, "out", ""); + + // Assert + expect(errors).to.deep.equal([]); + expect(document.title).to.equal("Routr benchmarks"); + expect(document.querySelector("h1")?.textContent).to.equal( + "How much SIP traffic Routr handles." + ); + expect(document.querySelector("[data-no-runs]")?.textContent).to.equal( + NO_RUNS_TEXT + ); + expect(document.querySelector(".nav .brand")).to.not.equal(null); + }); }); diff --git a/test/render/app/createDrawRun.unit.test.ts b/test/render/app/createDrawRun.unit.test.ts index 5f4ed49..ed8cd5a 100644 --- a/test/render/app/createDrawRun.unit.test.ts +++ b/test/render/app/createDrawRun.unit.test.ts @@ -4,6 +4,7 @@ import { expect } from "chai"; import { ValidationError } from "../../../src/errors/ValidationError.js"; import { createDrawRun } from "../../../src/render/app/createDrawRun.js"; +import { renderEmptyPage } from "../../../src/render/renderEmptyPage.js"; import { renderRunPage } from "../../../src/render/renderRunPage.js"; import { toPageContext } from "../../../src/render/toPageContext.js"; import { toRunEntry } from "../../../src/render/toRunEntry.js"; @@ -164,6 +165,47 @@ describe("createDrawRun", () => { expect(third).to.equal("data/runs.json: cannot fetch: offline"); }); + it("should draw the no-runs page from data/site.json when there are no runs", async () => { + // Arrange + const project = { + ...reportSchema.parse(report).project, + live: { url: "https://a.example/live.json" } + }; + const { drawRun, fetched } = drawWith({ + "data/runs.json": { ...runs, runs: [] }, + "data/site.json": { schema: "perfkit/site/v1", project } + }); + + // Act + const result = await drawRun({ path: "" }); + + // Assert + expect(result).to.deep.equal({ + ok: true, + runId: null, + html: renderEmptyPage({ project, perfkitVersion: "1.2.3" }), + liveUrl: "https://a.example/live.json" + }); + expect(fetched).to.deep.equal(["data/runs.json", "data/site.json"]); + }); + + it("should return site.json's errors for an invalid one", async () => { + // Arrange + const { drawRun } = drawWith({ + "data/runs.json": { ...runs, runs: [] }, + "data/site.json": { schema: "perfkit/site/v1" } + }); + + // Act + const result = await drawRun({ path: "" }); + + // Assert + expect(result.ok).to.equal(false); + expect(!result.ok && result.errors[0]).to.match( + /^data\/site.json: project: / + ); + }); + it("should say which run or page is missing", async () => { // Arrange const { drawRun } = drawWith({ diff --git a/test/render/app/createReadSiteLiveUrl.unit.test.ts b/test/render/app/createReadSiteLiveUrl.unit.test.ts new file mode 100644 index 0000000..f4f3e1f --- /dev/null +++ b/test/render/app/createReadSiteLiveUrl.unit.test.ts @@ -0,0 +1,55 @@ +/** + * Copyright (C) 2026 by Fonoster Inc (https://fonoster.com). MIT License. + */ +import { expect } from "chai"; +import { createReadSiteLiveUrl } from "../../../src/render/app/createReadSiteLiveUrl.js"; +import { reportSchema } from "../../../src/schemas/index.js"; +import { loadReport } from "../../fixtures.js"; + +const project = reportSchema.parse(loadReport("passed")).project; +const site = (live?: string) => + JSON.stringify({ + schema: "perfkit/site/v1", + project: { ...project, ...(live ? { live: { url: live } } : {}) } + }); + +describe("createReadSiteLiveUrl", () => { + it("should return the live URL in data/site.json", async () => { + // Arrange + const paths: string[] = []; + const read = createReadSiteLiveUrl({ + fetchText: async (path) => { + paths.push(path); + return { status: 200, text: site("https://a.example/live.json") }; + } + }); + + // Act + const url = await read(); + + // Assert + expect(url).to.equal("https://a.example/live.json"); + expect(paths).to.deep.equal(["data/site.json"]); + }); + + it("should be null with no live URL, no site.json, an invalid one or a network error", async () => { + // Arrange + const answers = [ + async () => ({ status: 200, text: site() }), + async () => ({ status: 404, text: "Not Found" }), + async () => ({ status: 200, text: "{" }), + async () => ({ status: 200, text: JSON.stringify({ schema: "x" }) }), + async () => { + throw new Error("offline"); + } + ]; + + // Act + const urls = await Promise.all( + answers.map((fetchText) => createReadSiteLiveUrl({ fetchText })()) + ); + + // Assert + expect(urls).to.deep.equal([null, null, null, null, null]); + }); +}); diff --git a/test/render/app/resolveLiveUrl.unit.test.ts b/test/render/app/resolveLiveUrl.unit.test.ts index 894cbda..2d1b2a4 100644 --- a/test/render/app/resolveLiveUrl.unit.test.ts +++ b/test/render/app/resolveLiveUrl.unit.test.ts @@ -19,6 +19,27 @@ describe("resolveLiveUrl", () => { ); }); + it("should take site.json's URL before the report's, and ?live= over both", () => { + expect( + resolveLiveUrl( + PAGE, + "https://site.example/live.json", + "https://report.example/live.json" + ) + ).to.equal("https://site.example/live.json"); + expect( + resolveLiveUrl(PAGE, null, "https://report.example/live.json") + ).to.equal("https://report.example/live.json"); + expect( + resolveLiveUrl( + `${PAGE}?live=http://localhost:8787/live.json`, + "https://site.example/live.json", + "https://report.example/live.json" + ) + ).to.equal("http://localhost:8787/live.json"); + expect(resolveLiveUrl(PAGE, null, null)).to.equal(null); + }); + it("should be null without one, or for a URL that is not http or https", () => { expect(resolveLiveUrl(PAGE, null)).to.equal(null); expect(resolveLiveUrl(`${PAGE}?live=javascript:alert(1)`, null)).to.equal( diff --git a/test/render/createRenderSite.unit.test.ts b/test/render/createRenderSite.unit.test.ts index 1ddb249..127da93 100644 --- a/test/render/createRenderSite.unit.test.ts +++ b/test/render/createRenderSite.unit.test.ts @@ -55,6 +55,7 @@ describe("perfkit render", () => { `wrote ${join("out/benchmarks/data/runs", PASSED_ID, "report.json")}`, `wrote ${join("out/benchmarks/runs", PASSED_ID, "report.json")}`, `wrote ${join("out/benchmarks/data/runs.json")} (14 runs)`, + `wrote ${join("out/benchmarks/data/site.json")} (no live URL)`, `wrote ${join("out/benchmarks", "index.html")} (latest: ${PASSED_ID})`, ...runIds.map( (id: string) => diff --git a/test/render/renderFromConfig.unit.test.ts b/test/render/renderFromConfig.unit.test.ts new file mode 100644 index 0000000..558301a --- /dev/null +++ b/test/render/renderFromConfig.unit.test.ts @@ -0,0 +1,165 @@ +/** + * Copyright (C) 2026 by Fonoster Inc (https://fonoster.com). MIT License. + */ +import { expect } from "chai"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { createRenderSite } from "../../src/render/createRenderSite.js"; +import { runsSchema, siteSchema } from "../../src/schemas/index.js"; +import { EXAMPLES, ROUTR_CONFIG } from "../fixtures.js"; +import { drawFrom, loadHistory, makeDeps, RUNS_4, runRender } from "./site.js"; + +const LOGO = readFileSync(join(EXAMPLES, "routr", "logo.svg"), "utf8").trim(); + +describe("perfkit render --config", () => { + it("should write a site with no runs: the app, an empty history, site.json and the shell", async () => { + // Act + const result = await runRender([ + "render", + "--config", + ROUTR_CONFIG, + "--out", + "out" + ]); + + // Assert + expect(result.code).to.equal(0); + expect(result.err).to.equal(""); + expect(result.out).to.equal( + [ + `wrote ${join("out/app/9.8.7/app.js")} (perfkit 9.8.7)`, + `wrote ${join("out/data/runs.json")} (0 runs)`, + `wrote ${join("out/data/site.json")} (no live URL)`, + `wrote ${join("out/index.html")} (no runs yet)`, + "" + ].join("\n") + ); + const runs = runsSchema.parse( + JSON.parse(result.written.get(join("out", "data", "runs.json")) ?? "") + ); + expect(runs).to.deep.equal({ + schema: "perfkit/runs/v1", + project: "Routr", + runs: [] + }); + const site = siteSchema.parse( + JSON.parse(result.written.get(join("out", "data", "site.json")) ?? "") + ); + expect(site.project.name).to.equal("Routr"); + expect(site.project.logoSvg).to.equal(LOGO); + expect(result.written.get(join("out", "index.html"))).to.contain( + "Routr benchmarks" + ); + }); + + it("should put the config's live URL in site.json", async () => { + // Arrange + const config = readFileSync(ROUTR_CONFIG, "utf8").replace( + /^project:\n/m, + "project:\n live:\n url: https://live.example/live.json\n" + ); + const path = join(EXAMPLES, "routr", "with-live.yaml"); + const { deps, written } = makeDeps({ [path]: config }); + + // Act + const result = await createRenderSite(deps)({ config: path, out: "o" }); + + // Assert + expect(result.ok).to.equal(true); + const site = JSON.parse(written.get(join("o", "data", "site.json")) ?? ""); + expect(site.project.live).to.deep.equal({ + url: "https://live.example/live.json" + }); + }); + + it("should keep a given history as it is, with a shell per run", async () => { + // Arrange + const history = loadHistory(RUNS_4); + const { deps, written } = makeDeps({ "runs.json": history }); + + // Act + const result = await createRenderSite(deps)({ + config: ROUTR_CONFIG, + history: "runs.json", + out: "o" + }); + + // Assert + expect(result.ok).to.equal(true); + expect( + JSON.parse(written.get(join("o", "data", "runs.json")) ?? "") + ).to.deep.equal(history); + history.runs.forEach((run: { id: string }) => { + expect(written.has(join("o", "runs", run.id, "index.html"))).to.equal( + true + ); + }); + }); + + it("should draw the no-runs page from what it wrote", async () => { + // Arrange + const { deps, written } = makeDeps(); + await createRenderSite(deps)({ config: ROUTR_CONFIG, out: "o" }); + const draw = await drawFrom(written, "o"); + + // Act + const result = await draw(""); + + // Assert + expect(result.ok && result.runId).to.equal(null); + expect(result.ok && result.html).to.contain("No published runs yet."); + }); + + it("should exit 2 with validate's lines for an invalid config", async () => { + // Act + const result = await runRender( + ["render", "--config", "bad.yaml", "--out", "out"], + { "bad.yaml": "apiVersion: perfkit/v1\nproject: {}\n" } + ); + + // Assert + expect(result.code).to.equal(2); + expect(result.err).to.match(/^bad\.yaml: /); + expect(result.written.size).to.equal(0); + }); + + it("should exit 1 with both --report and --config, or neither", async () => { + // Act + const both = await runRender([ + "render", + "--report", + "r.json", + "--config", + ROUTR_CONFIG, + "--out", + "out" + ]); + const neither = await runRender(["render", "--out", "out"]); + + // Assert + [both, neither].forEach((result) => { + expect(result.code).to.equal(1); + expect(result.err).to.contain("--report or --config"); + expect(result.written.size).to.equal(0); + }); + }); + + it("should throw ValidationError for both report and config", async () => { + // Arrange + const { deps } = makeDeps(); + + // Act + const error = await createRenderSite(deps)({ + report: "r.json", + config: ROUTR_CONFIG, + out: "o" + }).then( + () => null, + (err: unknown) => err + ); + + // Assert + expect(error).to.be.instanceOf(Error); + expect((error as Error).name).to.equal("ValidationError"); + }); +}); diff --git a/test/schemas/projectFromConfig.unit.test.ts b/test/schemas/projectFromConfig.unit.test.ts new file mode 100644 index 0000000..a27f5b3 --- /dev/null +++ b/test/schemas/projectFromConfig.unit.test.ts @@ -0,0 +1,69 @@ +/** + * Copyright (C) 2026 by Fonoster Inc (https://fonoster.com). MIT License. + */ +import { expect } from "chai"; +import { + perfkitConfigSchema, + projectFromConfig +} from "../../src/schemas/index.js"; +import { loadConfig } from "../fixtures.js"; + +const base = perfkitConfigSchema.parse(loadConfig()); + +function withLive(project: object, live: object | undefined) { + return perfkitConfigSchema.parse({ + ...loadConfig(), + project: { ...loadConfig().project, ...project }, + ...(live ? { live } : {}) + }); +} + +describe("projectFromConfig", () => { + it("should take project.live.url, then live.url, then https:///live.json", () => { + // Arrange + const both = withLive( + { live: { url: "https://project.example/live.json" } }, + { url: "https://live.example/live.json", domain: "perfkit.ngrok.app" } + ); + const liveOnly = withLive( + {}, + { url: "https://live.example/live.json", domain: "perfkit.ngrok.app" } + ); + const domainOnly = withLive({}, { domain: "perfkit.ngrok.app" }); + + // Act + const urls = [both, liveOnly, domainOnly].map( + (config) => projectFromConfig(config).live?.url + ); + + // Assert + expect(urls).to.deep.equal([ + "https://project.example/live.json", + "https://live.example/live.json", + "https://perfkit.ngrok.app/live.json" + ]); + }); + + it("should leave out live without one, and carry the inlined SVGs", () => { + // Arrange + const config = withLive({}, undefined); + + // Act + const project = projectFromConfig(config, { + logoSvg: "", + faviconSvg: "" + }); + + // Assert + expect(project).to.deep.equal({ + name: base.project.name, + repo: base.project.repo, + siteUrl: base.project.siteUrl, + accent: base.project.accent, + logoSvg: "", + faviconSvg: "", + title: base.project.title, + lead: base.project.lead + }); + }); +}); diff --git a/test/schemas/site.unit.test.ts b/test/schemas/site.unit.test.ts new file mode 100644 index 0000000..80327a9 --- /dev/null +++ b/test/schemas/site.unit.test.ts @@ -0,0 +1,37 @@ +/** + * Copyright (C) 2026 by Fonoster Inc (https://fonoster.com). MIT License. + */ +import { expect } from "chai"; +import { reportSchema, siteSchema } from "../../src/schemas/index.js"; +import { loadReport } from "../fixtures.js"; + +const project = reportSchema.parse(loadReport("passed")).project; + +describe("siteSchema", () => { + it("should accept a report's project", () => { + // Arrange + const site = { + schema: "perfkit/site/v1", + project: { ...project, live: { url: "https://a.example/live.json" } } + }; + + // Act + const result = siteSchema.safeParse(site); + + // Assert + expect(result.success).to.equal(true); + }); + + it("should reject another schema, a missing project or an unknown field", () => { + // Act + const results = [ + { schema: "perfkit/runs/v1", project }, + { schema: "perfkit/site/v1" }, + { schema: "perfkit/site/v1", project, runs: [] }, + { schema: "perfkit/site/v1", project: { ...project, accent: "teal" } } + ].map((site) => siteSchema.safeParse(site).success); + + // Assert + expect(results).to.deep.equal([false, false, false, false]); + }); +}); diff --git a/test/schemas/toJsonSchemaFiles.unit.test.ts b/test/schemas/toJsonSchemaFiles.unit.test.ts index a042e14..9b2ab0c 100644 --- a/test/schemas/toJsonSchemaFiles.unit.test.ts +++ b/test/schemas/toJsonSchemaFiles.unit.test.ts @@ -10,12 +10,13 @@ import { ROOT } from "../fixtures.js"; describe("toJsonSchemaFiles", () => { const files = toJsonSchemaFiles(); - it("should write the four v1 schema files", () => { + it("should write the five v1 schema files", () => { expect(Object.keys(files).sort()).to.deep.equal([ "live-v1.schema.json", "perfkit-v1.schema.json", "report-v1.schema.json", - "runs-v1.schema.json" + "runs-v1.schema.json", + "site-v1.schema.json" ]); });