From 3c2ef6eaca17aca3b938d54ad82bca67a4e1885f Mon Sep 17 00:00:00 2001 From: Pedro Sanders Date: Thu, 5 Nov 2026 08:50:47 -0400 Subject: [PATCH 1/3] fix(render): drop the claim that a run's page shows it exactly as it was Pages are drawn by the current app, so an older run is redrawn by a newer perfkit. --- src/render/sections/renderHistory.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/render/sections/renderHistory.ts b/src/render/sections/renderHistory.ts index 3e1040e..ff4202e 100644 --- a/src/render/sections/renderHistory.ts +++ b/src/render/sections/renderHistory.ts @@ -104,7 +104,7 @@ export function renderHistory(ctx: PageContext): string {

HISTORY

Previous runs

-

Every run keeps its own page and report.json. Open one to see it exactly as it was.

+

Every run keeps its own page and report.json.

${trend} From f989fa102412c2a05d5c270a2467b1580f8652b6 Mon Sep 17 00:00:00 2001 From: Pedro Sanders Date: Thu, 5 Nov 2026 08:50:58 -0400 Subject: [PATCH 2/3] fix(schemas): reject a capacity step that is not a multiple of 10 s SIPp writes its statistics every 10 s, so a step like 15s mixes the counters of two steps. perfkit.yaml validation now rejects it at load.rate.every. --- README.md | 3 +- docs/perfkit-yaml.md | 2 +- .../checkStepEveryMultipleOfSippInterval.ts | 29 +++++++++++++++++++ src/schemas/checkWarmupShorterThanStep.ts | 12 ++------ src/schemas/durationSeconds.ts | 13 +++++++++ src/schemas/perfkitConfig.schema.ts | 4 ++- test/schemas/durationSeconds.unit.test.ts | 17 +++++++++++ test/schemas/perfkitConfig.unit.test.ts | 16 ++++++++++ 8 files changed, 84 insertions(+), 12 deletions(-) create mode 100644 src/schemas/checkStepEveryMultipleOfSippInterval.ts create mode 100644 src/schemas/durationSeconds.ts create mode 100644 test/schemas/durationSeconds.unit.test.ts diff --git a/README.md b/README.md index f9258cf..cb161f6 100644 --- a/README.md +++ b/README.md @@ -390,7 +390,8 @@ SIPp adds load in steps, such as 50 more calls/s every minute. For each step: - Its latency counts the calls whose request was **sent** in the step, even when the answer came in the next one. - Its attempted, failed and retransmitted calls are SIPp's 10-second counters (`-trace_stat`) - that fall in the step. Failed % is failed / attempted. + that fall in the step. Failed % is failed / attempted. So `every` must be a multiple of 10 s: + `perfkit validate` rejects a `15s` step, whose counters would mix two steps. - **Warm-up.** With `warmup` set, the first step still runs its full `every`, but it is measured only from `warmup` after it starts: calls sent, and 10-second counters whose period mid-point falls, before that don't count. A cold JIT (EdgePort's JVM and GraalJS) makes the first seconds diff --git a/docs/perfkit-yaml.md b/docs/perfkit-yaml.md index f3b19b3..ec23b1d 100644 --- a/docs/perfkit-yaml.md +++ b/docs/perfkit-yaml.md @@ -124,7 +124,7 @@ the other kind rejects them. | `load.unit` | string | no | `calls` | What one SIPp call is, as a lowercase plural noun shown on the page (`calls`, `messages`). Copied into each report.json benchmark as `unit`. | | `load.rate.start` | number (calls/s) | *capacity*: yes | | The first step's rate. | | `load.rate.step` | number (calls/s) | *capacity*: yes | | Added at each step. | -| `load.rate.every` | duration | *capacity*: yes | | How long each step lasts. | +| `load.rate.every` | duration | *capacity*: yes | | How long each step lasts. A multiple of 10 s, because SIPp reports its counters every 10 s; `15s` is rejected (`expected a multiple of 10 s (SIPp reports every 10 s); got 15s`). | | `load.rate.max` | number (calls/s) | *capacity*: yes | | The ramp stops here. Reaching it without breaking a rule makes the limit a lower bound. | | `load.rate.cps` | number (calls/s) | *soak*: yes | | The fixed rate. Keep it well below saturation: a soak looks for leaks, not limits. | | `telemetry` | object | no | `{ otlp: false }` | | diff --git a/src/schemas/checkStepEveryMultipleOfSippInterval.ts b/src/schemas/checkStepEveryMultipleOfSippInterval.ts new file mode 100644 index 0000000..48fdae6 --- /dev/null +++ b/src/schemas/checkStepEveryMultipleOfSippInterval.ts @@ -0,0 +1,29 @@ +/** + * Copyright (C) 2026 by Fonoster Inc (https://fonoster.com). MIT License. + */ +import type { z } from "zod/v4"; +import { durationSeconds } from "./durationSeconds.js"; + +/** How often SIPp writes its statistics (`-trace_stat`), in seconds. */ +export const SIPP_STAT_INTERVAL_S = 10; + +/** + * Adds an issue at `load.rate.every` when a capacity step is not a whole + * number of SIPp's 10-second statistics periods: a period would then span + * two steps, and the step's counters would mix both rates. For use inside + * the capacity benchmark's `superRefine`. + */ +export function checkStepEveryMultipleOfSippInterval( + benchmark: { load: { rate: { every: string } } }, + ctx: z.core.$RefinementCtx +): void { + const { every } = benchmark.load.rate; + if (durationSeconds(every) % SIPP_STAT_INTERVAL_S !== 0) { + ctx.addIssue({ + code: "custom", + path: ["load", "rate", "every"], + message: `expected a multiple of ${SIPP_STAT_INTERVAL_S} s (SIPp reports every ${SIPP_STAT_INTERVAL_S} s); got ${every}`, + input: every + }); + } +} diff --git a/src/schemas/checkWarmupShorterThanStep.ts b/src/schemas/checkWarmupShorterThanStep.ts index ebe44a1..d2e950a 100644 --- a/src/schemas/checkWarmupShorterThanStep.ts +++ b/src/schemas/checkWarmupShorterThanStep.ts @@ -2,8 +2,7 @@ * Copyright (C) 2026 by Fonoster Inc (https://fonoster.com). MIT License. */ import type { z } from "zod/v4"; - -const UNIT_S = { s: 1, m: 60, h: 3600 } as const; +import { durationSeconds } from "./durationSeconds.js"; /** * Adds an issue at `warmup` when a capacity benchmark's warm-up is not @@ -15,8 +14,8 @@ export function checkWarmupShorterThanStep( benchmark: { warmup: string; load: { rate: { every: string } } }, ctx: z.core.$RefinementCtx ): void { - const warmupS = seconds(benchmark.warmup); - const everyS = seconds(benchmark.load.rate.every); + const warmupS = durationSeconds(benchmark.warmup); + const everyS = durationSeconds(benchmark.load.rate.every); if (warmupS > 0 && warmupS >= everyS) { ctx.addIssue({ code: "custom", @@ -26,8 +25,3 @@ export function checkWarmupShorterThanStep( }); } } - -function seconds(duration: string): number { - const match = /^(\d+)([smh])$/.exec(duration); - return match ? Number(match[1]) * UNIT_S[match[2] as keyof typeof UNIT_S] : 0; -} diff --git a/src/schemas/durationSeconds.ts b/src/schemas/durationSeconds.ts new file mode 100644 index 0000000..561f3f2 --- /dev/null +++ b/src/schemas/durationSeconds.ts @@ -0,0 +1,13 @@ +/** + * Copyright (C) 2026 by Fonoster Inc (https://fonoster.com). MIT License. + */ +const UNIT_S = { s: 1, m: 60, h: 3600 } as const; + +/** + * The seconds in a perfkit duration (`30s`, `5m`, `2h`), or 0 when it is not + * one; the schema has already rejected a bad duration by then. + */ +export function durationSeconds(duration: string): number { + const match = /^(\d+)([smh])$/.exec(duration); + return match ? Number(match[1]) * UNIT_S[match[2] as keyof typeof UNIT_S] : 0; +} diff --git a/src/schemas/perfkitConfig.schema.ts b/src/schemas/perfkitConfig.schema.ts index ecbc80b..32da5fe 100644 --- a/src/schemas/perfkitConfig.schema.ts +++ b/src/schemas/perfkitConfig.schema.ts @@ -10,6 +10,7 @@ import { z } from "zod/v4"; import { checkUniqueIds } from "./checkUniqueIds.js"; import { projectLiveSchema } from "./report.schema.js"; +import { checkStepEveryMultipleOfSippInterval } from "./checkStepEveryMultipleOfSippInterval.js"; import { checkWarmupShorterThanStep } from "./checkWarmupShorterThanStep.js"; import { benchmarkIdSchema, @@ -230,7 +231,8 @@ export const capacityBenchmarkConfigSchema = z telemetry: telemetrySchema, thresholds: capacityThresholdsSchema }) - .superRefine(checkWarmupShorterThanStep); + .superRefine(checkWarmupShorterThanStep) + .superRefine(checkStepEveryMultipleOfSippInterval); export const soakBenchmarkConfigSchema = z.strictObject({ id: benchmarkIdSchema, diff --git a/test/schemas/durationSeconds.unit.test.ts b/test/schemas/durationSeconds.unit.test.ts new file mode 100644 index 0000000..79e7c5a --- /dev/null +++ b/test/schemas/durationSeconds.unit.test.ts @@ -0,0 +1,17 @@ +/** + * Copyright (C) 2026 by Fonoster Inc (https://fonoster.com). MIT License. + */ +import { expect } from "chai"; +import { durationSeconds } from "../../src/schemas/durationSeconds.js"; + +describe("durationSeconds", () => { + it("should convert seconds, minutes and hours", () => { + expect(durationSeconds("15s")).to.equal(15); + expect(durationSeconds("2m")).to.equal(120); + expect(durationSeconds("1h")).to.equal(3600); + }); + + it("should return 0 for a string that is not a duration", () => { + expect(durationSeconds("1.5m")).to.equal(0); + }); +}); diff --git a/test/schemas/perfkitConfig.unit.test.ts b/test/schemas/perfkitConfig.unit.test.ts index 11370f8..faa78ab 100644 --- a/test/schemas/perfkitConfig.unit.test.ts +++ b/test/schemas/perfkitConfig.unit.test.ts @@ -87,6 +87,14 @@ describe("perfkitConfigSchema", () => { delete raw.project.logo; expect(errorsOf(perfkitConfigSchema, raw)).to.deep.equal([]); }); + + it("should accept a capacity step that is a multiple of 10 s", () => { + const raw = loadConfig(); + raw.benchmarks[0].load.rate.every = "30s"; + expect(errorsOf(perfkitConfigSchema, raw)).to.deep.equal([]); + raw.benchmarks[0].load.rate.every = "2m"; + expect(errorsOf(perfkitConfigSchema, raw)).to.deep.equal([]); + }); }); describe("with an invalid config", () => { @@ -106,6 +114,14 @@ describe("perfkitConfigSchema", () => { ]); }); + it("should reject a capacity step that is not a multiple of 10 s", () => { + const raw = loadConfig(); + raw.benchmarks[0].load.rate.every = "15s"; + expect(errorsOf(perfkitConfigSchema, raw)).to.deep.equal([ + "benchmarks[0].load.rate.every: expected a multiple of 10 s (SIPp reports every 10 s); got 15s" + ]); + }); + it("should reject a capacity warm-up as long as a step", () => { const raw = loadConfig(); raw.benchmarks[0].warmup = "1m"; From 325ec3a09a9db783c62f1ef1e13094683bfbb01e Mon Sep 17 00:00:00 2001 From: Pedro Sanders Date: Thu, 5 Nov 2026 08:50:59 -0400 Subject: [PATCH 3/3] feat: optional project.favicon, linked as every page's icon perfkit.yaml takes an optional project.favicon (.svg, relative to the file). The run reads it when it starts and inlines it as project.faviconSvg in report.json and live.json. The shells, the run page and the live page link it as a URL-encoded data:image/svg+xml icon; without one, the pages are unchanged. --- docs/perfkit-yaml.md | 2 ++ docs/report-page.md | 3 +- docs/report-v1.md | 1 + schemas/live-v1.schema.json | 4 +++ schemas/perfkit-v1.schema.json | 4 +++ schemas/report-v1.schema.json | 4 +++ src/cli/createStartExecutor.ts | 7 ++++ src/cli/runRunCommand.ts | 8 +++++ src/orchestrate/createStartRun.ts | 1 + src/orchestrate/runState.schema.ts | 1 + src/orchestrate/startRun.schema.ts | 1 + src/orchestrate/toReportProject.ts | 1 + src/render/createRenderSite.ts | 8 +++-- src/render/live/renderLivePage.ts | 3 +- src/render/renderFaviconLink.ts | 14 ++++++++ src/render/renderRunPage.ts | 3 +- src/render/renderShell.ts | 7 ++-- src/schemas/perfkitConfig.schema.ts | 5 +++ src/schemas/report.schema.ts | 4 +++ src/validate/checkConfigPaths.ts | 1 + test/orchestrate/toReportProject.unit.test.ts | 6 ++++ test/render/createRenderSite.unit.test.ts | 20 +++++++++++ test/render/live/renderLiveView.unit.test.ts | 10 ++++++ test/render/renderFaviconLink.unit.test.ts | 17 ++++++++++ test/render/renderRunPage.unit.test.ts | 16 +++++++++ test/render/renderShell.unit.test.ts | 34 +++++++++++++++++++ test/schemas/perfkitConfig.unit.test.ts | 16 +++++++++ test/schemas/report.unit.test.ts | 14 ++++++++ test/validate/checkConfigPaths.unit.test.ts | 21 ++++++++++++ 29 files changed, 229 insertions(+), 7 deletions(-) create mode 100644 src/render/renderFaviconLink.ts create mode 100644 test/render/renderFaviconLink.unit.test.ts diff --git a/docs/perfkit-yaml.md b/docs/perfkit-yaml.md index ec23b1d..40ec244 100644 --- a/docs/perfkit-yaml.md +++ b/docs/perfkit-yaml.md @@ -32,6 +32,7 @@ Rules for the whole file: | `project.siteUrl` | URL | yes | | Where the report pages are published, like `https://routr.io/benchmarks`. | | `project.accent` | string | yes | | 6-digit hex color, like `#5FCFCE`: the page's accent color. | | `project.logo` | path | no | none | An `.svg` file. Without it, the nav shows `project.name`. | +| `project.favicon` | path | no | none | An `.svg` file: the browser tab's icon on every page. Read when the run starts and inlined into report.json and live.json. Without it, the pages have no icon. | | `project.title` | string | yes | | The page's headline. | | `project.lead` | string | yes | | The paragraph under the headline. | | `project.live.url` | URL | no | `live.url`, else `https:///live.json` | Where the benchmarks page polls a run in progress. Goes into report.json, so the published page knows it. | @@ -156,6 +157,7 @@ project: siteUrl: https://routr.io/benchmarks accent: "#5FCFCE" # 6-digit hex; the page's accent color logo: logo.svg # optional SVG; without it the nav shows project.name + favicon: favicon.svg # optional SVG; the browser tab's icon title: How much SIP traffic Routr handles. lead: We run these tests after significant changes to the code, on dedicated DigitalOcean servers, one for Routr and one for the load generator. The EdgePort runs alone to find its limit; the whole stack runs for hours to catch leaks. infra: diff --git a/docs/report-page.md b/docs/report-page.md index 1e4d701..f7507fd 100644 --- a/docs/report-page.md +++ b/docs/report-page.md @@ -20,7 +20,8 @@ history and picker list every run published since, and a newer perfkit redraws i ## Nav and header -The nav has the project's logo, the run picker and a link to the run's `report.json`. The picker +With `project.faviconSvg` in report.json, every page and shell links it as the tab's icon. The +nav has the project's logo, the run picker and a link to the run's `report.json`. The picker lists up to 8 runs, newest first, each a link to its page; "See all" jumps to Previous runs. The header has the verdict pill, `N of M benchmarks passed`, followed by how many were not run, diff --git a/docs/report-v1.md b/docs/report-v1.md index 81d4fcd..924720b 100644 --- a/docs/report-v1.md +++ b/docs/report-v1.md @@ -41,6 +41,7 @@ Rules for both files: | :--- | :--- | :--- | | `project.name`, `repo`, `siteUrl`, `accent`, `title`, `lead` | string | Copied from perfkit.yaml. | | `project.logoSvg` | string | The SVG markup of `project.logo`, inlined. Present only when perfkit.yaml has `logo`. | +| `project.faviconSvg` | string | The SVG markup of `project.favicon`, inlined. Every page links it as its icon (a URL-encoded `data:image/svg+xml` URI). Present only when perfkit.yaml has `favicon`. | | `project.live.url` | URL | Where the latest page polls live.json: `project.live.url`, else `live.url`, else `https:///live.json` of perfkit.yaml. Absent without them. | ### run diff --git a/schemas/live-v1.schema.json b/schemas/live-v1.schema.json index 75622b7..1beb518 100644 --- a/schemas/live-v1.schema.json +++ b/schemas/live-v1.schema.json @@ -84,6 +84,10 @@ "type": "string", "pattern": "]" }, + "faviconSvg": { + "type": "string", + "pattern": "]" + }, "title": { "type": "string", "minLength": 1 diff --git a/schemas/perfkit-v1.schema.json b/schemas/perfkit-v1.schema.json index 9fbbba5..a84a7dd 100644 --- a/schemas/perfkit-v1.schema.json +++ b/schemas/perfkit-v1.schema.json @@ -29,6 +29,10 @@ "type": "string", "minLength": 1 }, + "favicon": { + "type": "string", + "minLength": 1 + }, "title": { "type": "string", "minLength": 1 diff --git a/schemas/report-v1.schema.json b/schemas/report-v1.schema.json index bdbf2e3..52b89f9 100644 --- a/schemas/report-v1.schema.json +++ b/schemas/report-v1.schema.json @@ -29,6 +29,10 @@ "type": "string", "pattern": "]" }, + "faviconSvg": { + "type": "string", + "pattern": "]" + }, "title": { "type": "string", "minLength": 1 diff --git a/src/cli/createStartExecutor.ts b/src/cli/createStartExecutor.ts index 4bd37ce..f05ac75 100644 --- a/src/cli/createStartExecutor.ts +++ b/src/cli/createStartExecutor.ts @@ -30,6 +30,12 @@ export function createStartExecutor(deps: CliDeps): BenchmarkExecutor { "utf8" ) : undefined; + const faviconSvg = config.project.favicon + ? await readFile( + join(dirname(options.configPath), config.project.favicon), + "utf8" + ) + : undefined; await createStartRun({ remote: createSshRemote({ ssh: infra.ssh, @@ -47,6 +53,7 @@ export function createStartExecutor(deps: CliDeps): BenchmarkExecutor { plan, config, ...(logoSvg ? { logoSvg: logoSvg.trim() } : {}), + ...(faviconSvg ? { faviconSvg: faviconSvg.trim() } : {}), hosts: { uutIp: run.uut.privateIp, loadgenIp: run.loadgen.privateIp, diff --git a/src/cli/runRunCommand.ts b/src/cli/runRunCommand.ts index da76d5b..38aab14 100644 --- a/src/cli/runRunCommand.ts +++ b/src/cli/runRunCommand.ts @@ -270,6 +270,13 @@ async function runLocal( await deps.readFile(join(dirname(options.config), config.project.logo)) ).trim() : undefined; + const favicon = config.project.favicon + ? ( + await deps.readFile( + join(dirname(options.config), config.project.favicon) + ) + ).trim() + : undefined; try { await createStartRun({ remote: createLocalRemote({ run: infra.runProgram }), @@ -284,6 +291,7 @@ async function runLocal( plan, config, ...(logo ? { logoSvg: logo } : {}), + ...(favicon ? { faviconSvg: favicon } : {}), hosts: { uutIp: "127.0.0.1", loadgenIp: "127.0.0.1", diff --git a/src/orchestrate/createStartRun.ts b/src/orchestrate/createStartRun.ts index facd991..13ebc9e 100644 --- a/src/orchestrate/createStartRun.ts +++ b/src/orchestrate/createStartRun.ts @@ -146,6 +146,7 @@ export function createStartRun(deps: StartRunDeps) { plan: input.plan, config: input.config, ...(input.logoSvg ? { logoSvg: input.logoSvg } : {}), + ...(input.faviconSvg ? { faviconSvg: input.faviconSvg } : {}), hosts: input.hosts, ...(input.live ? { live: input.live } : {}), machines: { uut, loadgen }, diff --git a/src/orchestrate/runState.schema.ts b/src/orchestrate/runState.schema.ts index 02e413b..54c1889 100644 --- a/src/orchestrate/runState.schema.ts +++ b/src/orchestrate/runState.schema.ts @@ -50,6 +50,7 @@ export const runStateSchema = z.strictObject({ plan: planSchema, config: perfkitConfigSchema, logoSvg: z.string().optional(), + faviconSvg: z.string().optional(), hosts: z.strictObject({ uutIp: z.ipv4(), loadgenIp: z.ipv4(), diff --git a/src/orchestrate/startRun.schema.ts b/src/orchestrate/startRun.schema.ts index 1889eda..885553f 100644 --- a/src/orchestrate/startRun.schema.ts +++ b/src/orchestrate/startRun.schema.ts @@ -15,6 +15,7 @@ export const startRunInputSchema = z.strictObject({ plan: true, config: true, logoSvg: true, + faviconSvg: true, hosts: true, live: true }).shape, diff --git a/src/orchestrate/toReportProject.ts b/src/orchestrate/toReportProject.ts index f025ca0..a2f95a1 100644 --- a/src/orchestrate/toReportProject.ts +++ b/src/orchestrate/toReportProject.ts @@ -21,6 +21,7 @@ export function toReportProject(state: RunState): Report["project"] { siteUrl: project.siteUrl, accent: project.accent, ...(state.logoSvg ? { logoSvg: state.logoSvg } : {}), + ...(state.faviconSvg ? { faviconSvg: state.faviconSvg } : {}), title: project.title, lead: project.lead, ...(liveUrl ? { live: { url: liveUrl } } : {}) diff --git a/src/render/createRenderSite.ts b/src/render/createRenderSite.ts index ded1562..ad8598c 100644 --- a/src/render/createRenderSite.ts +++ b/src/render/createRenderSite.ts @@ -41,7 +41,8 @@ type History = * (`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 - * `runs//index.html` per run in the history), which load the app. The + * `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. * @@ -89,7 +90,10 @@ export function createRenderSite(deps: RenderSiteDeps) { project: merged.project, run, page, - perfkitVersion: deps.version + perfkitVersion: deps.version, + ...(report.project.faviconSvg + ? { faviconSvg: report.project.faviconSvg } + : {}) }); const at = (path: string) => join(out, ...path.split("/")); diff --git a/src/render/live/renderLivePage.ts b/src/render/live/renderLivePage.ts index 7919caa..54e4f15 100644 --- a/src/render/live/renderLivePage.ts +++ b/src/render/live/renderLivePage.ts @@ -4,6 +4,7 @@ import { esc } from "../html.js"; import { pageStyles } from "../pageStyles.js"; import { PICKER_SCRIPT } from "../pickerScript.js"; +import { renderFaviconLink } from "../renderFaviconLink.js"; import { liveStyles } from "./liveStyles.js"; import { renderLiveView } from "./renderLiveView.js"; import type { LiveViewContext } from "./types.js"; @@ -25,7 +26,7 @@ export function renderLivePage(ctx: LiveViewContext): string { ${esc(`${live.project.name} benchmarks · running now · v${live.version}`)} - +${renderFaviconLink(live.project.faviconSvg)} diff --git a/src/render/renderFaviconLink.ts b/src/render/renderFaviconLink.ts new file mode 100644 index 0000000..040bcba --- /dev/null +++ b/src/render/renderFaviconLink.ts @@ -0,0 +1,14 @@ +/** + * Copyright (C) 2026 by Fonoster Inc (https://fonoster.com). MIT License. + */ + +/** + * The page head's icon link for `project.faviconSvg`, as a URL-encoded data + * URI and ending in a newline, or an empty string without a favicon, so a + * page without one is unchanged. + */ +export function renderFaviconLink(faviconSvg: string | undefined): string { + return faviconSvg + ? `\n` + : ""; +} diff --git a/src/render/renderRunPage.ts b/src/render/renderRunPage.ts index ad75fd2..084d54a 100644 --- a/src/render/renderRunPage.ts +++ b/src/render/renderRunPage.ts @@ -3,6 +3,7 @@ */ import { esc } from "./html.js"; import { pageStyles } from "./pageStyles.js"; +import { renderFaviconLink } from "./renderFaviconLink.js"; import { PICKER_SCRIPT } from "./pickerScript.js"; import { renderCapacitySection } from "./sections/renderCapacitySection.js"; import { renderFooter } from "./sections/renderFooter.js"; @@ -38,7 +39,7 @@ export function renderRunPage(ctx: PageContext): string { ${esc(title)} - +${renderFaviconLink(report.project.faviconSvg)} diff --git a/src/render/renderShell.ts b/src/render/renderShell.ts index 8d6196c..bcefab0 100644 --- a/src/render/renderShell.ts +++ b/src/render/renderShell.ts @@ -5,6 +5,7 @@ import type { RunEntry } from "../schemas/index.js"; import { SITE_PATHS } from "./app/sitePaths.js"; import { formatDate, formatTime, shortCommit } from "./format.js"; import { esc } from "./html.js"; +import { renderFaviconLink } from "./renderFaviconLink.js"; import type { PageKind } from "./types.js"; import { summaryText } from "./verdicts.js"; @@ -21,8 +22,10 @@ export function renderShell(params: { run: RunEntry; page: PageKind; perfkitVersion: string; + /** The project's favicon, from the report being rendered. */ + faviconSvg?: string; }): string { - const { project, run, page, perfkitVersion } = params; + 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)}.`; @@ -36,7 +39,7 @@ export function renderShell(params: { ${esc(title)} - +${renderFaviconLink(faviconSvg)} diff --git a/src/schemas/perfkitConfig.schema.ts b/src/schemas/perfkitConfig.schema.ts index 32da5fe..fa103f6 100644 --- a/src/schemas/perfkitConfig.schema.ts +++ b/src/schemas/perfkitConfig.schema.ts @@ -41,6 +41,11 @@ const configProjectSchema = z.strictObject({ error: "expected an .svg file" }) .optional(), + favicon: relativePathSchema + .refine((value) => value.toLowerCase().endsWith(".svg"), { + error: "expected an .svg file" + }) + .optional(), title: nonEmptyString, lead: nonEmptyString, live: projectLiveSchema.optional() diff --git a/src/schemas/report.schema.ts b/src/schemas/report.schema.ts index 2b47acf..d9eab93 100644 --- a/src/schemas/report.schema.ts +++ b/src/schemas/report.schema.ts @@ -46,6 +46,10 @@ export const reportProjectSchema = z.strictObject({ .string() .regex(/]/, { error: "expected inline SVG markup" }) .optional(), + faviconSvg: z + .string() + .regex(/]/, { error: "expected inline SVG markup" }) + .optional(), title: nonEmptyString, lead: nonEmptyString, live: projectLiveSchema.optional() diff --git a/src/validate/checkConfigPaths.ts b/src/validate/checkConfigPaths.ts index bcc1af2..4c724a6 100644 --- a/src/validate/checkConfigPaths.ts +++ b/src/validate/checkConfigPaths.ts @@ -17,6 +17,7 @@ export async function checkConfigPaths( ): Promise { const named: Array<[string, string | undefined]> = [ ["project.logo", config.project.logo], + ["project.favicon", config.project.favicon], ...config.benchmarks.flatMap( (benchmark, i): Array<[string, string | undefined]> => { const at = `benchmarks[${i}]`; diff --git a/test/orchestrate/toReportProject.unit.test.ts b/test/orchestrate/toReportProject.unit.test.ts index 4513e05..5b3966c 100644 --- a/test/orchestrate/toReportProject.unit.test.ts +++ b/test/orchestrate/toReportProject.unit.test.ts @@ -10,6 +10,12 @@ describe("toReportProject", () => { const project = toReportProject(runState()); expect(project.name).to.equal("Routr"); expect(project).to.not.have.property("live"); + expect(project).to.not.have.property("faviconSvg"); + }); + + it("should carry the favicon SVG when the run has one", () => { + const state = { ...runState(), faviconSvg: "" }; + expect(toReportProject(state).faviconSvg).to.equal(""); }); it("should take project.live.url first, then live.url, then the live domain's live.json", () => { diff --git a/test/render/createRenderSite.unit.test.ts b/test/render/createRenderSite.unit.test.ts index 1ac3b58..1ddb249 100644 --- a/test/render/createRenderSite.unit.test.ts +++ b/test/render/createRenderSite.unit.test.ts @@ -86,6 +86,26 @@ describe("perfkit render", () => { expect(shells).to.have.length(1 + site.runs().runs.length); }); + it("should put the report's favicon in every shell", async () => { + // Arrange + const report = loadReport("failed"); + report.project.faviconSvg = ''; + + // Act + const site = await renderSite(report, loadHistory(RUNS_4)); + + // Assert + const shells = [...site.written.entries()].filter(([path]) => + path.endsWith("index.html") + ); + expect(shells).to.have.length(1 + site.runs().runs.length); + shells.forEach(([, html]) => + expect(html).to.contain( + `` + ) + ); + }); + it("should print the warning and render without deltas when there is no history", async () => { // Act const result = await runRender( diff --git a/test/render/live/renderLiveView.unit.test.ts b/test/render/live/renderLiveView.unit.test.ts index d200b3d..4b98d17 100644 --- a/test/render/live/renderLiveView.unit.test.ts +++ b/test/render/live/renderLiveView.unit.test.ts @@ -193,5 +193,15 @@ describe("renderLivePage", () => { ); expect($("style").text()).to.contain("prefers-reduced-motion"); expect($("[data-picker]")).to.have.length(1); + expect($('link[rel="icon"]')).to.have.length(0); + }); + + it("should link the project's favicon in the head", () => { + const live = sampleLive(NOW - 8_000); + live.project = { ...live.project, faviconSvg: "" }; + const $ = cheerio.load(renderLivePage(ctx({ live }))); + expect($('head link[rel="icon"]').attr("href")).to.equal( + `data:image/svg+xml,${encodeURIComponent("")}` + ); }); }); diff --git a/test/render/renderFaviconLink.unit.test.ts b/test/render/renderFaviconLink.unit.test.ts new file mode 100644 index 0000000..46c4652 --- /dev/null +++ b/test/render/renderFaviconLink.unit.test.ts @@ -0,0 +1,17 @@ +/** + * Copyright (C) 2026 by Fonoster Inc (https://fonoster.com). MIT License. + */ +import { expect } from "chai"; +import { renderFaviconLink } from "../../src/render/renderFaviconLink.js"; + +describe("renderFaviconLink", () => { + it("should link the SVG as a URL-encoded data URI on its own line", () => { + expect(renderFaviconLink('')).to.equal( + '\n' + ); + }); + + it("should be empty without a favicon", () => { + expect(renderFaviconLink(undefined)).to.equal(""); + }); +}); diff --git a/test/render/renderRunPage.unit.test.ts b/test/render/renderRunPage.unit.test.ts index 38946bf..1d5cf5a 100644 --- a/test/render/renderRunPage.unit.test.ts +++ b/test/render/renderRunPage.unit.test.ts @@ -567,6 +567,22 @@ describe("the report page", () => { expect($(".nav svg circle[fill]")).to.have.length(0); }); + it("should link the favicon in the head, and none without one", async () => { + // Arrange + const report = loadReport("passed"); + report.project.faviconSvg = ''; + + // Act + const site = await renderSite(report); + const plain = (await renderSite(loadReport("passed"))).latest(); + + // Assert + const href = `data:image/svg+xml,${encodeURIComponent(report.project.faviconSvg)}`; + expect(site.latest()('head link[rel="icon"]').attr("href")).to.equal(href); + expect(site.runPage()('head link[rel="icon"]').attr("href")).to.equal(href); + expect(plain('link[rel="icon"]')).to.have.length(0); + }); + it("should fall back to the project name without a logo", async () => { // Arrange const report = loadReport("passed"); diff --git a/test/render/renderShell.unit.test.ts b/test/render/renderShell.unit.test.ts index 9df91c1..3cfd0d4 100644 --- a/test/render/renderShell.unit.test.ts +++ b/test/render/renderShell.unit.test.ts @@ -83,4 +83,38 @@ describe("renderShell", () => { '"Routr" benchmarks · v2.31.5' ); }); + + it("should link the favicon as a URL-encoded SVG data URI", () => { + // Act + const $ = load( + renderShell({ + project: "Routr", + run, + page: "run", + perfkitVersion: "1.2.3", + faviconSvg: '' + }) + ); + + // Assert + expect($('link[rel="icon"]').attr("type")).to.equal("image/svg+xml"); + expect($('link[rel="icon"]').attr("href")).to.equal( + `data:image/svg+xml,${encodeURIComponent('')}` + ); + }); + + it("should have no icon link without a favicon", () => { + // Act + const $ = load( + renderShell({ + project: "Routr", + run, + page: "run", + perfkitVersion: "1.2.3" + }) + ); + + // Assert + expect($('link[rel="icon"]')).to.have.length(0); + }); }); diff --git a/test/schemas/perfkitConfig.unit.test.ts b/test/schemas/perfkitConfig.unit.test.ts index faa78ab..bd7a32a 100644 --- a/test/schemas/perfkitConfig.unit.test.ts +++ b/test/schemas/perfkitConfig.unit.test.ts @@ -88,6 +88,14 @@ describe("perfkitConfigSchema", () => { expect(errorsOf(perfkitConfigSchema, raw)).to.deep.equal([]); }); + it("should accept an .svg favicon", () => { + const raw = loadConfig(); + raw.project.favicon = "favicon.svg"; + expect(perfkitConfigSchema.parse(raw).project.favicon).to.equal( + "favicon.svg" + ); + }); + it("should accept a capacity step that is a multiple of 10 s", () => { const raw = loadConfig(); raw.benchmarks[0].load.rate.every = "30s"; @@ -114,6 +122,14 @@ describe("perfkitConfigSchema", () => { ]); }); + it("should reject a favicon that is not an .svg file", () => { + const raw = loadConfig(); + raw.project.favicon = "favicon.png"; + expect(errorsOf(perfkitConfigSchema, raw)).to.deep.equal([ + "project.favicon: expected an .svg file" + ]); + }); + it("should reject a capacity step that is not a multiple of 10 s", () => { const raw = loadConfig(); raw.benchmarks[0].load.rate.every = "15s"; diff --git a/test/schemas/report.unit.test.ts b/test/schemas/report.unit.test.ts index 7d33b40..c647f9e 100644 --- a/test/schemas/report.unit.test.ts +++ b/test/schemas/report.unit.test.ts @@ -32,6 +32,12 @@ describe("reportSchema", () => { expect(errorsOf(reportSchema, report)).to.deep.equal([]); }); + it("should accept a report with a faviconSvg", () => { + const report = loadReport("passed"); + report.project.faviconSvg = ''; + expect(errorsOf(reportSchema, report)).to.deep.equal([]); + }); + it("should accept a report without logoSvg, hops or otelcol", () => { const report = loadReport("passed"); delete report.project.logoSvg; @@ -42,6 +48,14 @@ describe("reportSchema", () => { }); describe("with an invalid report", () => { + it("should reject a faviconSvg that is not SVG markup", () => { + const report = loadReport("passed"); + report.project.faviconSvg = "favicon.svg"; + expect(errorsOf(reportSchema, report)).to.deep.equal([ + 'project.faviconSvg: expected inline SVG markup; got "favicon.svg"' + ]); + }); + it('should reject verdict "ok" with the Contract\'s message', () => { const report = loadReport("passed"); report.benchmarks[1].verdict = "ok"; diff --git a/test/validate/checkConfigPaths.unit.test.ts b/test/validate/checkConfigPaths.unit.test.ts index 39bdb97..c1e7963 100644 --- a/test/validate/checkConfigPaths.unit.test.ts +++ b/test/validate/checkConfigPaths.unit.test.ts @@ -40,6 +40,27 @@ describe("checkConfigPaths", () => { ); }); + it("should check project.favicon when it is set", async () => { + // Arrange + const withFavicon = perfkitConfigSchema.parse({ + ...loadConfig(), + project: { ...loadConfig().project, favicon: "favicon.svg" } + }); + const isFile = sinon + .stub() + .callsFake( + async (path: string) => path !== resolve("/perf", "favicon.svg") + ); + + // Act + const errors = await checkConfigPaths(withFavicon, "/perf", isFile); + + // Assert + expect(errors).to.deep.equal([ + { path: "project.favicon", message: "file not found: favicon.svg" } + ]); + }); + it("should name the field of each missing file", async () => { // Arrange const missing = [