perf(ci): time WebUI gates without duplicate builds

This commit is contained in:
joshpatra committed 2026-08-02 15:37:43 -04:00
1 parent 209c53b9ad
commit 4a3b741b76
5 files changed
+377 -15

No files matched your search

+18 -7
View File
@@ -154,14 +154,25 @@ jobs:
npm ci
npx playwright install --with-deps chromium
- name: Verify Svelte WebUI
- name: WebUI check
working-directory: webui
run: |
npm run check
npm test
npm run build
npm run check:budgets
npm run test:e2e
run: python3 ../tools/tests/timing_report.py --name webui-check -- npm run check
- name: WebUI unit tests
working-directory: webui
run: python3 ../tools/tests/timing_report.py --name webui-unit -- npm test
- name: WebUI build
working-directory: webui
run: python3 ../tools/tests/timing_report.py --name webui-build -- npm run build
- name: WebUI budgets
working-directory: webui
run: python3 ../tools/tests/timing_report.py --name webui-budgets -- npm run check:budgets
- name: WebUI E2E
working-directory: webui
run: python3 ../tools/tests/timing_report.py --name webui-e2e -- npm run test:e2e:existing-build
release-manifest:
needs:
+329
View File
@@ -0,0 +1,329 @@
#!/usr/bin/env python3
"""Run one test gate, preserve its result, and print a small timing report."""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import time
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
@dataclass
class Counts:
discovered: int = 0
passed: int = 0
failed: int = 0
skipped: int = 0
@property
def executed(self) -> int:
return self.passed + self.failed
@dataclass
class TestTiming:
name: str
milliseconds: float
def _version(command: str, cwd: Path | None = None) -> str:
executable = command.split()[0]
if shutil.which(executable) is None:
return "unavailable"
try:
result = subprocess.run(
command.split(), cwd=cwd, check=False, capture_output=True, text=True, timeout=10
)
except (OSError, subprocess.TimeoutExpired):
return "unavailable"
value = (result.stdout or result.stderr).strip().splitlines()
return value[0] if value else "unavailable"
def _git_sha(cwd: Path) -> str:
try:
result = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=cwd,
check=False,
capture_output=True,
text=True,
timeout=10,
)
except (OSError, subprocess.TimeoutExpired):
return "unavailable"
return result.stdout.strip() if result.returncode == 0 else "unavailable"
def _merge_counts(target: Counts, values: Counts) -> None:
target.passed = max(target.passed, values.passed)
target.failed = max(target.failed, values.failed)
target.skipped = max(target.skipped, values.skipped)
target.discovered = max(target.discovered, values.discovered)
def _parse_counts(output: str) -> Counts:
counts = Counts()
# dotnet test's console summary and common TRX console summaries.
for pattern, order in (
(
r"Failed:\s*(\d+)\s*,\s*Passed:\s*(\d+)\s*,\s*Skipped:\s*(\d+)\s*,\s*Total:\s*(\d+)",
(0, 1, 2, 3),
),
(
r"Tests\s+run:\s*(\d+)\s*,\s*Passed:\s*(\d+)\s*,\s*Failed:\s*(\d+)\s*,\s*Skipped:\s*(\d+)",
(2, 1, 3, 0),
),
):
match = re.search(pattern, output, re.IGNORECASE)
if match:
values = [int(value) for value in match.groups()]
counts.failed, counts.passed, counts.skipped, counts.discovered = (
values[index] for index in order
)
break
# Vitest and Playwright summaries (the parenthesized value is discovered).
for pattern in (
r"(?:Tests|Test Files)\s+(?:(\d+)\s+failed,?\s*)?(?:(\d+)\s+passed,?\s*)?(?:(\d+)\s+skipped,?\s*)?(?:\((\d+)\))",
r"(?:(\d+)\s+failed,?\s*)?(?:(\d+)\s+passed,?\s*)?(?:(\d+)\s+skipped,?\s*)?\((\d+)\s+(?:tests?|specs?)\)",
):
for match in re.finditer(pattern, output, re.IGNORECASE):
failed, passed, skipped, discovered = match.groups()
parsed = Counts(
discovered=int(discovered),
passed=int(passed or 0),
failed=int(failed or 0),
skipped=int(skipped or 0),
)
_merge_counts(counts, parsed)
# Playwright's default/line reporters omit the discovered total.
summary = re.search(
r"(?:(\d+)\s+failed,?\s*)?(?:(\d+)\s+passed,?\s*)?(?:(\d+)\s+skipped,?\s*)?\([^\n)]*\)",
output,
re.IGNORECASE,
)
if summary:
failed, passed, skipped = summary.groups()
parsed = Counts(
passed=int(passed or 0), failed=int(failed or 0), skipped=int(skipped or 0)
)
parsed.discovered = parsed.passed + parsed.failed + parsed.skipped
_merge_counts(counts, parsed)
# Reporters may put each outcome on a separate line.
for value, outcome in re.findall(r"(?m)^\s*(\d+)\s+(failed|passed|skipped)\b", output, re.IGNORECASE):
parsed_value = int(value)
if outcome.lower() == "passed":
counts.passed = max(counts.passed, parsed_value)
elif outcome.lower() == "failed":
counts.failed = max(counts.failed, parsed_value)
else:
counts.skipped = max(counts.skipped, parsed_value)
if counts.discovered == 0 and counts.passed + counts.failed + counts.skipped:
counts.discovered = counts.passed + counts.failed + counts.skipped
return counts
def _parse_retries(output: str) -> int:
"""Count retry attempts reported by Playwright or a compatible reporter."""
attempts = len(re.findall(r"\bretry\s*#\s*\d+\b", output, re.IGNORECASE))
for pattern in (
r"\b(\d+)\s+(?:test\s+)?retries?\b",
r"\bretries?\s*[:=]\s*(\d+)\b",
r"\b(\d+)\s+flaky\b",
):
values = [int(value) for value in re.findall(pattern, output, re.IGNORECASE)]
if values:
attempts = max(attempts, max(values))
return attempts
def _duration(value: str) -> float | None:
value = value.strip()
if value.endswith("ms"):
return float(value[:-2])
if value.endswith("s"):
return float(value[:-1]) * 1000
if value.startswith("PT"):
seconds = re.fullmatch(r"PT(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?", value)
if seconds:
hours, minutes, seconds_value = (float(item or 0) for item in seconds.groups())
return (hours * 3600 + minutes * 60 + seconds_value) * 1000
return None
def _parse_trx(path: Path) -> tuple[Counts, list[TestTiming]]:
counts = Counts()
timings: list[TestTiming] = []
try:
root = ET.parse(path).getroot()
except (OSError, ET.ParseError):
return counts, timings
for result in root.iter():
if not result.tag.endswith("UnitTestResult"):
continue
outcome = result.attrib.get("outcome", "").lower()
counts.discovered += 1
if outcome == "passed":
counts.passed += 1
elif outcome == "skipped":
counts.skipped += 1
else:
counts.failed += 1
milliseconds = _duration(result.attrib.get("duration", ""))
if milliseconds is not None:
timings.append(TestTiming(result.attrib.get("testName", "unnamed"), milliseconds))
return counts, timings
def _walk_playwright_json(value: object, timings: list[TestTiming]) -> None:
if isinstance(value, dict):
if "title" in value and isinstance(value.get("title"), str):
duration = value.get("duration")
if isinstance(duration, (int, float)):
timings.append(TestTiming(value["title"], float(duration)))
for child in value.values():
_walk_playwright_json(child, timings)
elif isinstance(value, list):
for child in value:
_walk_playwright_json(child, timings)
def _parse_timings(output: str, trx_paths: Iterable[Path]) -> list[TestTiming]:
timings: list[TestTiming] = []
for path in trx_paths:
_, parsed = _parse_trx(path)
timings.extend(parsed)
# Verbose Vitest/Playwright reporters commonly end a test line with “12ms”.
for match in re.finditer(r"(?:✓|✗|×|PASS|FAIL)\s+(.+?)\s+(\d+(?:\.\d+)?(?:ms|s))\s*$", output, re.MULTILINE):
milliseconds = _duration(match.group(2))
if milliseconds is not None:
timings.append(TestTiming(match.group(1).strip(), milliseconds))
for candidate in re.findall(r"(?:PLAYWRIGHT_JSON_OUTPUT_FILE|playwright-report/[^\s]+\.json)=(\S+)", output):
try:
parsed_json = json.loads(Path(candidate).read_text())
except (OSError, json.JSONDecodeError):
continue
_walk_playwright_json(parsed_json, timings)
return sorted(timings, key=lambda item: item.milliseconds, reverse=True)
def _repository_root(cwd: Path) -> Path:
try:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
cwd=cwd,
check=False,
capture_output=True,
text=True,
timeout=10,
)
except (OSError, subprocess.TimeoutExpired):
return cwd
return Path(result.stdout.strip()) if result.returncode == 0 else cwd
def _report(
name: str,
command: list[str],
elapsed_ms: float,
returncode: int,
output: str,
trx_paths: list[Path],
cwd: Path,
) -> str:
counts = _parse_counts(output)
for path in trx_paths:
parsed, _ = _parse_trx(path)
_merge_counts(counts, parsed)
timings = _parse_timings(output, trx_paths)
retried = _parse_retries(output)
versions = {
"python": _version("python3 --version", cwd),
"node": _version("node --version", cwd),
"npm": _version("npm --version", cwd),
"dotnet": _version("dotnet --version", cwd),
"playwright": _version("npx --no-install playwright --version", cwd),
"vitest": _version("npx --no-install vitest --version", cwd),
}
lines = [
f"TIMING name={name} duration_ms={elapsed_ms:.0f} exit_code={returncode}",
f"SHA {_git_sha(_repository_root(cwd))}",
"TOOLS " + " ".join(f"{key}={value}" for key, value in versions.items()),
"COUNTS "
+ " ".join(
(
f"discovered={counts.discovered}",
f"executed={counts.executed}",
f"passed={counts.passed}",
f"failed={counts.failed}",
f"skipped={counts.skipped}",
f"retried={retried}",
)
),
]
lines.extend(
f"SLOWEST name={item.name} duration_ms={item.milliseconds:.0f}" for item in timings[:20]
)
if not timings:
lines.append("SLOWEST unavailable")
return "\n".join(lines) + "\n"
def _self_test() -> None:
counts = _parse_counts("Tests run: 4, Passed: 3, Failed: 1, Skipped: 0, Total: 4")
assert counts == Counts(discovered=4, passed=3, failed=1)
assert _parse_retries("34 passed (11.9s)") == 0
assert _parse_retries("Retry #1\n1 flaky") == 1
assert _parse_retries("Retries: 2") == 2
assert _duration("PT1.25S") == 1250
assert _duration("12ms") == 12
def main(argv: list[str] | None = None) -> int:
argv = list(sys.argv[1:] if argv is None else argv)
if argv == ["--self-test"]:
_self_test()
return 0
if "--" not in argv:
raise SystemExit("usage: timing_report.py [options] -- command [args…]")
separator = argv.index("--")
parser = argparse.ArgumentParser()
parser.add_argument("--name", required=True)
parser.add_argument("--cwd", default=".")
parser.add_argument("--trx", action="append", default=[])
options = parser.parse_args(argv[:separator])
command = argv[separator + 1 :]
if not command:
raise SystemExit("timing_report.py: a child command is required")
cwd = Path(options.cwd).resolve()
trx_paths = [Path(path).resolve() if os.path.isabs(path) else cwd / path for path in options.trx]
started = time.monotonic()
result = subprocess.run(command, cwd=cwd, check=False, text=True, capture_output=True)
elapsed_ms = (time.monotonic() - started) * 1000
output = (result.stdout or "") + (result.stderr or "")
sys.stdout.write(output)
report = _report(options.name, command, elapsed_ms, result.returncode, output, trx_paths, cwd)
sys.stdout.write(report)
summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
if summary_path:
with open(summary_path, "a", encoding="utf-8") as summary:
summary.write(f"### {options.name}\n\n```text\n{report}```\n")
return result.returncode
if __name__ == "__main__":
raise SystemExit(main())
+1
View File
@@ -11,6 +11,7 @@
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "vitest run",
"test:e2e": "playwright test",
"test:e2e:existing-build": "PLAYWRIGHT_SKIP_BUILD=1 playwright test",
"build": "vite build",
"check:budgets": "node scripts/check-budgets.mjs"
},
+7 -1
View File
@@ -1,5 +1,11 @@
import { existsSync } from "node:fs";
import { defineConfig } from "@playwright/test";
const preview = "npm run preview -- --host 127.0.0.1 --port 4173";
const webServerCommand = process.env.PLAYWRIGHT_SKIP_BUILD === "1" || existsSync("build/index.html")
? preview
: `npm run build && ${preview}`;
export default defineConfig({
testDir: "./tests",
testMatch: "**/*.e2e.ts",
@@ -11,7 +17,7 @@ export default defineConfig({
trace: "retain-on-failure",
},
webServer: {
command: "npm run build && npm run preview -- --host 127.0.0.1 --port 4173",
command: webServerCommand,
url: "http://127.0.0.1:4173/",
reuseExistingServer: !process.env.CI,
},
+22 -7
View File
@@ -259,7 +259,13 @@ const responses: Record<string, unknown> = {
},
};
async function mockApi(page: Page, options: { delay?: string; fail?: string[] } = {}) {
function routeRelease() {
let release!: () => void;
const promise = new Promise<void>((resolve) => { release = resolve; });
return { promise, release };
}
async function mockApi(page: Page, options: { releasePath?: string; release?: Promise<void>; fail?: string[] } = {}) {
await page.route("**/fonts/**", (route) => route.fulfill({ status: 204 }));
await page.route("**/images/providers/**", (route) => route.fulfill({
status: 200,
@@ -268,7 +274,7 @@ async function mockApi(page: Page, options: { delay?: string; fail?: string[] }
}));
await page.route("**/api/admin/**", async (route) => {
const url = new URL(route.request().url());
if (url.pathname === options.delay) await new Promise((resolve) => setTimeout(resolve, 500));
if (url.pathname === options.releasePath) await options.release;
if (options.fail?.includes(url.pathname)) {
await route.fulfill({
status: 503,
@@ -475,11 +481,13 @@ for (const viewport of viewports) {
});
}
for (const [route, heading, loadingLabel, delay, failures] of stateRoutes) {
for (const [route, heading, loadingLabel, releasePath, failures] of stateRoutes) {
test(`${route} exposes loading and error recovery`, async ({ page, context }) => {
await mockApi(page, { delay });
const delayed = routeRelease();
await mockApi(page, { releasePath, release: delayed.promise });
await page.goto(route);
await expect(page.getByLabel(loadingLabel)).toBeVisible();
delayed.release();
await expect(page.getByRole("heading", { name: heading, level: 1 })).toBeVisible();
const errorPage = await context.newPage();
@@ -536,12 +544,14 @@ for (const viewport of viewports) {
});
test("Intelligence results remain usable", async ({ page, context }) => {
await mockApi(page, { delay: "/api/admin/intelligence" });
const delayed = routeRelease();
await mockApi(page, { releasePath: "/api/admin/intelligence", release: delayed.promise });
await page.goto("#/intelligence");
await page.getByLabel("Backend instance").fill("main");
await page.getByLabel("Library scope").fill("music");
await page.getByRole("button", { name: "Open library" }).click();
await expect(page.getByLabel("Loading Intelligence")).toBeVisible();
delayed.release();
await expect(page.getByText("Future Song", { exact: true })).toBeVisible();
await expect(page.getByText("Morning discovery")).toBeVisible();
await expect(page.getByText("Private similarity source. · ready")).toBeVisible();
@@ -992,6 +1002,7 @@ test("Add playlist links a Jellyfin playlist before its Source on mobile", async
test("Tentative mappings sort by confidence and deep links open review", async ({ page }) => {
await mockApi(page);
const delayedProvider = routeRelease();
let localSearches = 0;
await page.route("**/api/admin/track-matches/targets/local?*", (route) => {
localSearches += 1;
@@ -1010,7 +1021,7 @@ test("Tentative mappings sort by confidence and deep links open review", async (
});
});
await page.route("**/api/admin/track-matches/targets/provider?*", async (route) => {
await new Promise((resolve) => setTimeout(resolve, 500));
await delayedProvider.promise;
return route.fulfill({
status: 200,
contentType: "application/json",
@@ -1044,6 +1055,7 @@ test("Tentative mappings sort by confidence and deep links open review", async (
await dialog.getByRole("button", { name: "Search", exact: true }).click();
await expect(dialog.getByRole("button", { name: "Searching…" })).toBeVisible();
await expect(dialog.locator(".provider-result-summary")).toHaveCount(0);
delayedProvider.release();
await expect.poll(() => localSearches).toBe(1);
await expect(dialog.getByRole("button", { name: /Jellyfin 1/ })).toBeVisible();
await expect(dialog.getByRole("button", { name: /Lumen Audio 1/ })).toBeVisible();
@@ -1408,7 +1420,8 @@ test("Maintenance previews, retries, and applies a legacy import on mobile", asy
test("Maintenance validates before selective import and reports applied rows", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await mockApi(page, { delay: "/api/admin/preview-selective-state" });
const delayed = routeRelease();
await mockApi(page, { releasePath: "/api/admin/preview-selective-state", release: delayed.promise });
await page.goto("#/settings/maintenance");
const card = page.locator(".transfer-card");
const archive = {
@@ -1422,7 +1435,9 @@ test("Maintenance validates before selective import and reports applied rows", a
await card.getByRole("button", { name: "Import behavior" }).click();
await page.getByRole("option", { name: "Merge compatible rows" }).click();
await card.getByRole("button", { name: "Validate archive" }).click();
await expect(card.getByLabel("preview in progress")).toBeVisible();
await card.getByRole("button", { name: "Cancel" }).click();
delayed.release();
await expect(card.getByText("State transfer cancelled.")).toBeVisible();
await card.getByRole("button", { name: "Validate archive" }).click();
await expect(card.getByRole("region", { name: "Selective transfer preview" })).toContainText("3 rows");