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..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. | @@ -124,7 +125,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 }` | | @@ -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/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} 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..fa103f6 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, @@ -40,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() @@ -230,7 +236,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/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/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..bd7a32a 100644 --- a/test/schemas/perfkitConfig.unit.test.ts +++ b/test/schemas/perfkitConfig.unit.test.ts @@ -87,6 +87,22 @@ describe("perfkitConfigSchema", () => { delete raw.project.logo; 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"; + 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 +122,22 @@ 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"; + 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"; 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 = [