From 13923b1022917041a8caedee66396ae1ad7ea1ab Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Sat, 1 Aug 2026 02:29:03 +0800 Subject: [PATCH] feat: add strict verification suite; release 4.9.20 --- CHANGELOG.md | 8 ++ app-release.json | 13 +- package-lock.json | 4 +- package.json | 2 +- verification/.gitignore | 4 + verification/README.md | 50 +++++++ verification/package-lock.json | 1 + verification/package.json | 18 +++ verification/run | 19 +++ verification/src/browser/action-state.mjs | 53 ++++++++ verification/src/browser/actions.mjs | 105 +++++++++++++++ verification/src/browser/axe.mjs | 24 ++++ verification/src/browser/init.mjs | 30 +++++ verification/src/browser/metrics.mjs | 28 ++++ verification/src/browser/mocks.mjs | 63 +++++++++ verification/src/browser/routes.mjs | 17 +++ verification/src/browser/session.mjs | 54 ++++++++ verification/src/checks/api-contracts.mjs | 49 +++++++ verification/src/checks/api-discovery.mjs | 55 ++++++++ verification/src/checks/ast-metrics.mjs | 42 ++++++ verification/src/checks/ast-walk.mjs | 59 +++++++++ verification/src/checks/deployment.mjs | 39 ++++++ verification/src/checks/docker-local.mjs | 43 ++++++ verification/src/checks/duplicates.mjs | 30 +++++ verification/src/checks/harness-self.mjs | 16 +++ verification/src/checks/import-graph.mjs | 78 +++++++++++ verification/src/checks/latency.mjs | 36 +++++ verification/src/checks/performance.mjs | 56 ++++++++ verification/src/checks/preflight.mjs | 46 +++++++ verification/src/checks/proxy.mjs | 45 +++++++ verification/src/checks/runtime-start.mjs | 32 +++++ verification/src/checks/security-headers.mjs | 43 ++++++ verification/src/checks/security-scan.mjs | 45 +++++++ verification/src/checks/source-policy.mjs | 37 ++++++ verification/src/checks/static-tools.mjs | 53 ++++++++ verification/src/checks/ui-actions.mjs | 130 +++++++++++++++++++ verification/src/checks/ui-pages.mjs | 59 +++++++++ verification/src/checks/video.mjs | 79 +++++++++++ verification/src/checks/visual.mjs | 84 ++++++++++++ verification/src/config.mjs | 53 ++++++++ verification/src/core/command.mjs | 56 ++++++++ verification/src/core/context.mjs | 32 +++++ verification/src/core/files.mjs | 36 +++++ verification/src/core/finding.mjs | 34 +++++ verification/src/core/http.mjs | 32 +++++ verification/src/core/log.mjs | 25 ++++ verification/src/core/redact.mjs | 22 ++++ verification/src/core/service.mjs | 53 ++++++++ verification/src/core/stage.mjs | 25 ++++ verification/src/core/xml.mjs | 8 ++ verification/src/fixture/media.mjs | 33 +++++ verification/src/fixture/server.mjs | 34 +++++ verification/src/fixture/source.mjs | 31 +++++ verification/src/fixture/static.mjs | 25 ++++ verification/src/main.mjs | 68 ++++++++++ verification/src/report/html.mjs | 27 ++++ verification/src/report/write.mjs | 45 +++++++ verification/tests/core.test.mjs | 43 ++++++ verification/tests/files.test.mjs | 19 +++ verification/tests/http.test.mjs | 18 +++ 60 files changed, 2364 insertions(+), 4 deletions(-) create mode 100644 verification/.gitignore create mode 100644 verification/README.md create mode 100644 verification/package-lock.json create mode 100644 verification/package.json create mode 100755 verification/run create mode 100644 verification/src/browser/action-state.mjs create mode 100644 verification/src/browser/actions.mjs create mode 100644 verification/src/browser/axe.mjs create mode 100644 verification/src/browser/init.mjs create mode 100644 verification/src/browser/metrics.mjs create mode 100644 verification/src/browser/mocks.mjs create mode 100644 verification/src/browser/routes.mjs create mode 100644 verification/src/browser/session.mjs create mode 100644 verification/src/checks/api-contracts.mjs create mode 100644 verification/src/checks/api-discovery.mjs create mode 100644 verification/src/checks/ast-metrics.mjs create mode 100644 verification/src/checks/ast-walk.mjs create mode 100644 verification/src/checks/deployment.mjs create mode 100644 verification/src/checks/docker-local.mjs create mode 100644 verification/src/checks/duplicates.mjs create mode 100644 verification/src/checks/harness-self.mjs create mode 100644 verification/src/checks/import-graph.mjs create mode 100644 verification/src/checks/latency.mjs create mode 100644 verification/src/checks/performance.mjs create mode 100644 verification/src/checks/preflight.mjs create mode 100644 verification/src/checks/proxy.mjs create mode 100644 verification/src/checks/runtime-start.mjs create mode 100644 verification/src/checks/security-headers.mjs create mode 100644 verification/src/checks/security-scan.mjs create mode 100644 verification/src/checks/source-policy.mjs create mode 100644 verification/src/checks/static-tools.mjs create mode 100644 verification/src/checks/ui-actions.mjs create mode 100644 verification/src/checks/ui-pages.mjs create mode 100644 verification/src/checks/video.mjs create mode 100644 verification/src/checks/visual.mjs create mode 100644 verification/src/config.mjs create mode 100644 verification/src/core/command.mjs create mode 100644 verification/src/core/context.mjs create mode 100644 verification/src/core/files.mjs create mode 100644 verification/src/core/finding.mjs create mode 100644 verification/src/core/http.mjs create mode 100644 verification/src/core/log.mjs create mode 100644 verification/src/core/redact.mjs create mode 100644 verification/src/core/service.mjs create mode 100644 verification/src/core/stage.mjs create mode 100644 verification/src/core/xml.mjs create mode 100644 verification/src/fixture/media.mjs create mode 100644 verification/src/fixture/server.mjs create mode 100644 verification/src/fixture/source.mjs create mode 100644 verification/src/fixture/static.mjs create mode 100644 verification/src/main.mjs create mode 100644 verification/src/report/html.mjs create mode 100644 verification/src/report/write.mjs create mode 100644 verification/tests/core.test.mjs create mode 100644 verification/tests/files.test.mjs create mode 100644 verification/tests/http.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index cc004b9..369480b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 4.9.20 - 2026-07-31 + +- 新增独立 `verification/` 严格验证套件,可通过 `./verification/run` 一键执行源码行数、AST 复杂度、函数长度、嵌套、依赖环、重复代码、secret、危险构造、ESLint、TypeScript、单元测试、依赖完整性与漏洞审计。 +- 覆盖 Next.js、Cloudflare Pages 和 Docker 构建/运行,自动发现 API 方法,并验证异常输入、SSE、代理 Range/HLS/CORS/重定向/SSRF、延迟、并发、安全响应头、PWA 与公开发布版本一致性。 +- 新增四视口页面渲染、Axe 可访问性、递归 UI 状态与全部发现控件交互;每条动作记录完整恢复步骤和匹配依据,并输出截图、像素 diff 与 Playwright trace。 +- 新增确定性 MP4/HLS、分辨率、掉帧和 200ms 卡顿检测;报告同时提供 HTML、Markdown、JSON、JUnit、NDJSON 与完整原始日志,并自动脱敏。 +- 验证目录所有自有文件均不超过 150 行。本版本只加入验证基础设施与发布元数据,不修改现有业务代码;套件发现的项目缺陷会以非零退出码和详细证据保留,不伪装为通过。 + ## 4.9.19 - 2026-07-31 - 修复 Docker / Node 自托管下 `/api/user/sync` 与 `/api/user/config` 无法从 `process.env` 读取 Upstash 凭据、持续返回 500 的问题;Redis 客户端改为按请求环境惰性创建,未配置同步时明确返回 503(#226 / PR #227)。 diff --git a/app-release.json b/app-release.json index cb28c16..ce22812 100644 --- a/app-release.json +++ b/app-release.json @@ -4,8 +4,19 @@ "name": "KVideo", "branch": "main" }, - "currentVersion": "4.9.19", + "currentVersion": "4.9.20", "releases": [ + { + "version": "4.9.20", + "publishedAt": "2026-07-31", + "title": "新增严格全链路验证套件", + "notes": [ + "新增独立 verification 目录与一键运行命令,覆盖源码结构、复杂度、依赖、安全、构建、Docker、API、代理、性能、PWA 与发布一致性检查。", + "新增四视口页面渲染、Axe 可访问性、递归 UI 状态与控件交互验证,并记录每次动作的恢复步骤、匹配依据、异常、截图和 Playwright trace。", + "新增确定性 MP4/HLS 播放、分辨率、掉帧与 200ms 卡顿检测,以及本地和 Cloudflare 的像素差异报告。", + "验证结果同时输出 HTML、Markdown、JSON、JUnit、NDJSON 和原始命令日志;验证器自有文件全部限制在 150 行以内,且不会修改业务源码。" + ] + }, { "version": "4.9.19", "publishedAt": "2026-07-31", diff --git a/package-lock.json b/package-lock.json index 0d3f2fb..8c5a230 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kvideo", - "version": "4.9.19", + "version": "4.9.20", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "kvideo", - "version": "4.9.19", + "version": "4.9.20", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", diff --git a/package.json b/package.json index be2e709..417dd13 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kvideo", - "version": "4.9.19", + "version": "4.9.20", "private": true, "scripts": { "dev": "node scripts/next-with-lan-access.mjs dev", diff --git a/verification/.gitignore b/verification/.gitignore new file mode 100644 index 0000000..e10a3d5 --- /dev/null +++ b/verification/.gitignore @@ -0,0 +1,4 @@ +artifacts/ +node_modules/ +tmp/ +*.log diff --git a/verification/README.md b/verification/README.md new file mode 100644 index 0000000..cf35105 --- /dev/null +++ b/verification/README.md @@ -0,0 +1,50 @@ +# KVideo strict verification + +Run the complete read-only validation chain from the repository root: + +```sh +./verification/run +``` + +The runner installs its pinned tools inside this directory, builds and starts +KVideo locally, exercises APIs and UI, and writes evidence under +`verification/artifacts//`. It does not edit application source. + +Primary outputs: + +- `report.html`: browsable report with every finding and evidence link. +- `summary.md`: compact human-readable result and coverage gaps. +- `summary.json`: machine-readable run metadata and totals. +- `findings.json`: every pass, failure, warning, skip, and explanation. +- `junit.xml`: CI-compatible result file. +- `events.ndjson`: chronological structured event log. +- `run.log`: chronological plain-text log. +- `raw/`: complete command output and remote response evidence. +- `screenshots/`: viewport, action, and visual-difference images. +- `traces/`: browser traces for failed flows. + +Useful options: + +```sh +./verification/run --quick +./verification/run --offline +./verification/run --reference-url https://kvideo.pages.dev +./verification/run --keep-server +./verification/run --max-actions 10000 --max-action-depth 10 +``` + +Full mode explores up to 5,000 unique control-state operations per route and +eight same-route state transitions. Repeated controls with identical structure +and state are tested once; changed checked, expanded, pressed, value, and +disabled states are separate operations. Reaching either limit is reported as +a coverage failure, never as a pass. + +Default mode is deliberately strict. Existing source files over 150 lines, +lint/type/build failures, uncaught browser errors, severe accessibility +violations, API contract failures, deployment drift, and threshold breaches +produce a non-zero exit code. Generated reports and third-party files are not +source code and are excluded from the 150-line source policy. + +The suite cannot prove the absence of every defect. It reports exactly what it +enumerated, what it executed, what it skipped, and why. A green result means all +declared checks passed, not that arbitrary undiscovered states are impossible. diff --git a/verification/package-lock.json b/verification/package-lock.json new file mode 100644 index 0000000..9bd3bff --- /dev/null +++ b/verification/package-lock.json @@ -0,0 +1 @@ +{"name":"kvideo-strict-verification","version":"1.0.0","lockfileVersion":3,"requires":true,"packages":{"":{"name":"kvideo-strict-verification","version":"1.0.0","dependencies":{"@typescript-eslint/typescript-estree":"8.65.0","axe-core":"4.12.1","jscpd":"5.0.14","pixelmatch":"7.2.0","playwright":"1.62.1","pngjs":"7.0.0","wrangler":"4.118.0"}},"node_modules/@cloudflare/kv-asset-handler":{"version":"0.5.0","resolved":"https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz","integrity":"sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==","license":"MIT OR Apache-2.0","engines":{"node":">=22.0.0"}},"node_modules/@cloudflare/unenv-preset":{"version":"2.16.1","resolved":"https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz","integrity":"sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==","license":"MIT OR Apache-2.0","peerDependencies":{"unenv":"2.0.0-rc.24","workerd":">1.20260305.0 <2.0.0-0"},"peerDependenciesMeta":{"workerd":{"optional":true}}},"node_modules/@cloudflare/workerd-darwin-64":{"version":"1.20260730.1","resolved":"https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260730.1.tgz","integrity":"sha512-+MBHmPaiTe2KajryW0T24rZvWFxb41hD3d8anNzQqHzft6vSEb18+sp0znSwxgij7ApPhSM1+vhkNg4f3YMguA==","cpu":["x64"],"license":"Apache-2.0","optional":true,"os":["darwin"],"engines":{"node":">=16"}},"node_modules/@cloudflare/workerd-darwin-arm64":{"version":"1.20260730.1","resolved":"https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260730.1.tgz","integrity":"sha512-SBHKntPkKvNPgaCrTe99xC1CAl8ygJDzlYfK0LbuJ1muKadIw35WnhO0wu894fKBtllsVQdNzDLee+cm0ppLSQ==","cpu":["arm64"],"license":"Apache-2.0","optional":true,"os":["darwin"],"engines":{"node":">=16"}},"node_modules/@cloudflare/workerd-linux-64":{"version":"1.20260730.1","resolved":"https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260730.1.tgz","integrity":"sha512-ouyPOSMbiKPeSwUJUvxtMcxGAXs2J4aPE4T5ABIYX5ClcQx5j5bbHTmnqOQEY8sAuLTPjH7dY+iB6UI5ISlwwA==","cpu":["x64"],"license":"Apache-2.0","optional":true,"os":["linux"],"engines":{"node":">=16"}},"node_modules/@cloudflare/workerd-linux-arm64":{"version":"1.20260730.1","resolved":"https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260730.1.tgz","integrity":"sha512-YQ+Mi78U3TPdgBPtwq+Sm6rJU+Ihl2y0pjYtuuKkdmUbYzL7oLR6Xqq9wljhasnuCFICssDJaqhMep5WizYoEQ==","cpu":["arm64"],"license":"Apache-2.0","optional":true,"os":["linux"],"engines":{"node":">=16"}},"node_modules/@cloudflare/workerd-windows-64":{"version":"1.20260730.1","resolved":"https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260730.1.tgz","integrity":"sha512-27fAN+vUECW1oYVc1KOcHYpkL8COM2Uxtxql7TL595kxbjoqS5yckw7NLz7bTf2pALFCZWjqXDjZGJ/xbG4ZKQ==","cpu":["x64"],"license":"Apache-2.0","optional":true,"os":["win32"],"engines":{"node":">=16"}},"node_modules/@cspotcode/source-map-support":{"version":"0.8.1","resolved":"https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz","integrity":"sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==","license":"MIT","dependencies":{"@jridgewell/trace-mapping":"0.3.9"},"engines":{"node":">=12"}},"node_modules/@emnapi/runtime":{"version":"1.11.3","resolved":"https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz","integrity":"sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==","license":"MIT","optional":true,"dependencies":{"tslib":"^2.4.0"}},"node_modules/@esbuild/aix-ppc64":{"version":"0.28.1","resolved":"https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz","integrity":"sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==","cpu":["ppc64"],"license":"MIT","optional":true,"os":["aix"],"engines":{"node":">=18"}},"node_modules/@esbuild/android-arm":{"version":"0.28.1","resolved":"https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz","integrity":"sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==","cpu":["arm"],"license":"MIT","optional":true,"os":["android"],"engines":{"node":">=18"}},"node_modules/@esbuild/android-arm64":{"version":"0.28.1","resolved":"https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz","integrity":"sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==","cpu":["arm64"],"license":"MIT","optional":true,"os":["android"],"engines":{"node":">=18"}},"node_modules/@esbuild/android-x64":{"version":"0.28.1","resolved":"https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz","integrity":"sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==","cpu":["x64"],"license":"MIT","optional":true,"os":["android"],"engines":{"node":">=18"}},"node_modules/@esbuild/darwin-arm64":{"version":"0.28.1","resolved":"https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz","integrity":"sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==","cpu":["arm64"],"license":"MIT","optional":true,"os":["darwin"],"engines":{"node":">=18"}},"node_modules/@esbuild/darwin-x64":{"version":"0.28.1","resolved":"https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz","integrity":"sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==","cpu":["x64"],"license":"MIT","optional":true,"os":["darwin"],"engines":{"node":">=18"}},"node_modules/@esbuild/freebsd-arm64":{"version":"0.28.1","resolved":"https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz","integrity":"sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==","cpu":["arm64"],"license":"MIT","optional":true,"os":["freebsd"],"engines":{"node":">=18"}},"node_modules/@esbuild/freebsd-x64":{"version":"0.28.1","resolved":"https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz","integrity":"sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==","cpu":["x64"],"license":"MIT","optional":true,"os":["freebsd"],"engines":{"node":">=18"}},"node_modules/@esbuild/linux-arm":{"version":"0.28.1","resolved":"https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz","integrity":"sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==","cpu":["arm"],"license":"MIT","optional":true,"os":["linux"],"engines":{"node":">=18"}},"node_modules/@esbuild/linux-arm64":{"version":"0.28.1","resolved":"https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz","integrity":"sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==","cpu":["arm64"],"license":"MIT","optional":true,"os":["linux"],"engines":{"node":">=18"}},"node_modules/@esbuild/linux-ia32":{"version":"0.28.1","resolved":"https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz","integrity":"sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==","cpu":["ia32"],"license":"MIT","optional":true,"os":["linux"],"engines":{"node":">=18"}},"node_modules/@esbuild/linux-loong64":{"version":"0.28.1","resolved":"https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz","integrity":"sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==","cpu":["loong64"],"license":"MIT","optional":true,"os":["linux"],"engines":{"node":">=18"}},"node_modules/@esbuild/linux-mips64el":{"version":"0.28.1","resolved":"https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz","integrity":"sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==","cpu":["mips64el"],"license":"MIT","optional":true,"os":["linux"],"engines":{"node":">=18"}},"node_modules/@esbuild/linux-ppc64":{"version":"0.28.1","resolved":"https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz","integrity":"sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==","cpu":["ppc64"],"license":"MIT","optional":true,"os":["linux"],"engines":{"node":">=18"}},"node_modules/@esbuild/linux-riscv64":{"version":"0.28.1","resolved":"https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz","integrity":"sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==","cpu":["riscv64"],"license":"MIT","optional":true,"os":["linux"],"engines":{"node":">=18"}},"node_modules/@esbuild/linux-s390x":{"version":"0.28.1","resolved":"https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz","integrity":"sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==","cpu":["s390x"],"license":"MIT","optional":true,"os":["linux"],"engines":{"node":">=18"}},"node_modules/@esbuild/linux-x64":{"version":"0.28.1","resolved":"https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz","integrity":"sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==","cpu":["x64"],"license":"MIT","optional":true,"os":["linux"],"engines":{"node":">=18"}},"node_modules/@esbuild/netbsd-arm64":{"version":"0.28.1","resolved":"https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz","integrity":"sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==","cpu":["arm64"],"license":"MIT","optional":true,"os":["netbsd"],"engines":{"node":">=18"}},"node_modules/@esbuild/netbsd-x64":{"version":"0.28.1","resolved":"https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz","integrity":"sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==","cpu":["x64"],"license":"MIT","optional":true,"os":["netbsd"],"engines":{"node":">=18"}},"node_modules/@esbuild/openbsd-arm64":{"version":"0.28.1","resolved":"https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz","integrity":"sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==","cpu":["arm64"],"license":"MIT","optional":true,"os":["openbsd"],"engines":{"node":">=18"}},"node_modules/@esbuild/openbsd-x64":{"version":"0.28.1","resolved":"https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz","integrity":"sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==","cpu":["x64"],"license":"MIT","optional":true,"os":["openbsd"],"engines":{"node":">=18"}},"node_modules/@esbuild/openharmony-arm64":{"version":"0.28.1","resolved":"https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz","integrity":"sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==","cpu":["arm64"],"license":"MIT","optional":true,"os":["openharmony"],"engines":{"node":">=18"}},"node_modules/@esbuild/sunos-x64":{"version":"0.28.1","resolved":"https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz","integrity":"sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==","cpu":["x64"],"license":"MIT","optional":true,"os":["sunos"],"engines":{"node":">=18"}},"node_modules/@esbuild/win32-arm64":{"version":"0.28.1","resolved":"https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz","integrity":"sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==","cpu":["arm64"],"license":"MIT","optional":true,"os":["win32"],"engines":{"node":">=18"}},"node_modules/@esbuild/win32-ia32":{"version":"0.28.1","resolved":"https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz","integrity":"sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==","cpu":["ia32"],"license":"MIT","optional":true,"os":["win32"],"engines":{"node":">=18"}},"node_modules/@esbuild/win32-x64":{"version":"0.28.1","resolved":"https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz","integrity":"sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==","cpu":["x64"],"license":"MIT","optional":true,"os":["win32"],"engines":{"node":">=18"}},"node_modules/@img/colour":{"version":"1.1.0","resolved":"https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz","integrity":"sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==","license":"MIT","engines":{"node":">=18"}},"node_modules/@img/sharp-darwin-arm64":{"version":"0.35.2","resolved":"https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz","integrity":"sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==","cpu":["arm64"],"license":"Apache-2.0","optional":true,"os":["darwin"],"engines":{"node":">=20.9.0"},"funding":{"url":"https://opencollective.com/libvips"},"optionalDependencies":{"@img/sharp-libvips-darwin-arm64":"1.3.1"}},"node_modules/@img/sharp-darwin-x64":{"version":"0.35.2","resolved":"https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz","integrity":"sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==","cpu":["x64"],"license":"Apache-2.0","optional":true,"os":["darwin"],"engines":{"node":">=20.9.0"},"funding":{"url":"https://opencollective.com/libvips"},"optionalDependencies":{"@img/sharp-libvips-darwin-x64":"1.3.1"}},"node_modules/@img/sharp-freebsd-wasm32":{"version":"0.35.2","resolved":"https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz","integrity":"sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==","license":"Apache-2.0","optional":true,"os":["freebsd"],"dependencies":{"@img/sharp-wasm32":"0.35.2"},"engines":{"node":">=20.9.0"},"funding":{"url":"https://opencollective.com/libvips"}},"node_modules/@img/sharp-libvips-darwin-arm64":{"version":"1.3.1","resolved":"https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz","integrity":"sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==","cpu":["arm64"],"license":"LGPL-3.0-or-later","optional":true,"os":["darwin"],"funding":{"url":"https://opencollective.com/libvips"}},"node_modules/@img/sharp-libvips-darwin-x64":{"version":"1.3.1","resolved":"https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz","integrity":"sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==","cpu":["x64"],"license":"LGPL-3.0-or-later","optional":true,"os":["darwin"],"funding":{"url":"https://opencollective.com/libvips"}},"node_modules/@img/sharp-libvips-linux-arm":{"version":"1.3.1","resolved":"https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz","integrity":"sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==","cpu":["arm"],"libc":["glibc"],"license":"LGPL-3.0-or-later","optional":true,"os":["linux"],"funding":{"url":"https://opencollective.com/libvips"}},"node_modules/@img/sharp-libvips-linux-arm64":{"version":"1.3.1","resolved":"https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz","integrity":"sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==","cpu":["arm64"],"libc":["glibc"],"license":"LGPL-3.0-or-later","optional":true,"os":["linux"],"funding":{"url":"https://opencollective.com/libvips"}},"node_modules/@img/sharp-libvips-linux-ppc64":{"version":"1.3.1","resolved":"https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz","integrity":"sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==","cpu":["ppc64"],"libc":["glibc"],"license":"LGPL-3.0-or-later","optional":true,"os":["linux"],"funding":{"url":"https://opencollective.com/libvips"}},"node_modules/@img/sharp-libvips-linux-riscv64":{"version":"1.3.1","resolved":"https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz","integrity":"sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==","cpu":["riscv64"],"libc":["glibc"],"license":"LGPL-3.0-or-later","optional":true,"os":["linux"],"funding":{"url":"https://opencollective.com/libvips"}},"node_modules/@img/sharp-libvips-linux-s390x":{"version":"1.3.1","resolved":"https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz","integrity":"sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==","cpu":["s390x"],"libc":["glibc"],"license":"LGPL-3.0-or-later","optional":true,"os":["linux"],"funding":{"url":"https://opencollective.com/libvips"}},"node_modules/@img/sharp-libvips-linux-x64":{"version":"1.3.1","resolved":"https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz","integrity":"sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==","cpu":["x64"],"libc":["glibc"],"license":"LGPL-3.0-or-later","optional":true,"os":["linux"],"funding":{"url":"https://opencollective.com/libvips"}},"node_modules/@img/sharp-libvips-linuxmusl-arm64":{"version":"1.3.1","resolved":"https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz","integrity":"sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==","cpu":["arm64"],"libc":["musl"],"license":"LGPL-3.0-or-later","optional":true,"os":["linux"],"funding":{"url":"https://opencollective.com/libvips"}},"node_modules/@img/sharp-libvips-linuxmusl-x64":{"version":"1.3.1","resolved":"https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz","integrity":"sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==","cpu":["x64"],"libc":["musl"],"license":"LGPL-3.0-or-later","optional":true,"os":["linux"],"funding":{"url":"https://opencollective.com/libvips"}},"node_modules/@img/sharp-linux-arm":{"version":"0.35.2","resolved":"https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz","integrity":"sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==","cpu":["arm"],"libc":["glibc"],"license":"Apache-2.0","optional":true,"os":["linux"],"engines":{"node":">=20.9.0"},"funding":{"url":"https://opencollective.com/libvips"},"optionalDependencies":{"@img/sharp-libvips-linux-arm":"1.3.1"}},"node_modules/@img/sharp-linux-arm64":{"version":"0.35.2","resolved":"https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz","integrity":"sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==","cpu":["arm64"],"libc":["glibc"],"license":"Apache-2.0","optional":true,"os":["linux"],"engines":{"node":">=20.9.0"},"funding":{"url":"https://opencollective.com/libvips"},"optionalDependencies":{"@img/sharp-libvips-linux-arm64":"1.3.1"}},"node_modules/@img/sharp-linux-ppc64":{"version":"0.35.2","resolved":"https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz","integrity":"sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==","cpu":["ppc64"],"libc":["glibc"],"license":"Apache-2.0","optional":true,"os":["linux"],"engines":{"node":">=20.9.0"},"funding":{"url":"https://opencollective.com/libvips"},"optionalDependencies":{"@img/sharp-libvips-linux-ppc64":"1.3.1"}},"node_modules/@img/sharp-linux-riscv64":{"version":"0.35.2","resolved":"https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz","integrity":"sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==","cpu":["riscv64"],"libc":["glibc"],"license":"Apache-2.0","optional":true,"os":["linux"],"engines":{"node":">=20.9.0"},"funding":{"url":"https://opencollective.com/libvips"},"optionalDependencies":{"@img/sharp-libvips-linux-riscv64":"1.3.1"}},"node_modules/@img/sharp-linux-s390x":{"version":"0.35.2","resolved":"https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz","integrity":"sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==","cpu":["s390x"],"libc":["glibc"],"license":"Apache-2.0","optional":true,"os":["linux"],"engines":{"node":">=20.9.0"},"funding":{"url":"https://opencollective.com/libvips"},"optionalDependencies":{"@img/sharp-libvips-linux-s390x":"1.3.1"}},"node_modules/@img/sharp-linux-x64":{"version":"0.35.2","resolved":"https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz","integrity":"sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==","cpu":["x64"],"libc":["glibc"],"license":"Apache-2.0","optional":true,"os":["linux"],"engines":{"node":">=20.9.0"},"funding":{"url":"https://opencollective.com/libvips"},"optionalDependencies":{"@img/sharp-libvips-linux-x64":"1.3.1"}},"node_modules/@img/sharp-linuxmusl-arm64":{"version":"0.35.2","resolved":"https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz","integrity":"sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==","cpu":["arm64"],"libc":["musl"],"license":"Apache-2.0","optional":true,"os":["linux"],"engines":{"node":">=20.9.0"},"funding":{"url":"https://opencollective.com/libvips"},"optionalDependencies":{"@img/sharp-libvips-linuxmusl-arm64":"1.3.1"}},"node_modules/@img/sharp-linuxmusl-x64":{"version":"0.35.2","resolved":"https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz","integrity":"sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==","cpu":["x64"],"libc":["musl"],"license":"Apache-2.0","optional":true,"os":["linux"],"engines":{"node":">=20.9.0"},"funding":{"url":"https://opencollective.com/libvips"},"optionalDependencies":{"@img/sharp-libvips-linuxmusl-x64":"1.3.1"}},"node_modules/@img/sharp-wasm32":{"version":"0.35.2","resolved":"https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz","integrity":"sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==","license":"Apache-2.0 AND LGPL-3.0-or-later AND MIT","optional":true,"dependencies":{"@emnapi/runtime":"^1.11.1"},"engines":{"node":">=20.9.0"},"funding":{"url":"https://opencollective.com/libvips"}},"node_modules/@img/sharp-webcontainers-wasm32":{"version":"0.35.2","resolved":"https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz","integrity":"sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==","cpu":["wasm32"],"license":"Apache-2.0","optional":true,"dependencies":{"@img/sharp-wasm32":"0.35.2"},"engines":{"node":">=20.9.0"},"funding":{"url":"https://opencollective.com/libvips"}},"node_modules/@img/sharp-win32-arm64":{"version":"0.35.2","resolved":"https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz","integrity":"sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==","cpu":["arm64"],"license":"Apache-2.0 AND LGPL-3.0-or-later","optional":true,"os":["win32"],"engines":{"node":">=20.9.0"},"funding":{"url":"https://opencollective.com/libvips"}},"node_modules/@img/sharp-win32-ia32":{"version":"0.35.2","resolved":"https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz","integrity":"sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==","cpu":["ia32"],"license":"Apache-2.0 AND LGPL-3.0-or-later","optional":true,"os":["win32"],"engines":{"node":"^20.9.0"},"funding":{"url":"https://opencollective.com/libvips"}},"node_modules/@img/sharp-win32-x64":{"version":"0.35.2","resolved":"https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz","integrity":"sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==","cpu":["x64"],"license":"Apache-2.0 AND LGPL-3.0-or-later","optional":true,"os":["win32"],"engines":{"node":">=20.9.0"},"funding":{"url":"https://opencollective.com/libvips"}},"node_modules/@jridgewell/resolve-uri":{"version":"3.1.2","resolved":"https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz","integrity":"sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==","license":"MIT","engines":{"node":">=6.0.0"}},"node_modules/@jridgewell/sourcemap-codec":{"version":"1.5.5","resolved":"https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz","integrity":"sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==","license":"MIT"},"node_modules/@jridgewell/trace-mapping":{"version":"0.3.9","resolved":"https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz","integrity":"sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==","license":"MIT","dependencies":{"@jridgewell/resolve-uri":"^3.0.3","@jridgewell/sourcemap-codec":"^1.4.10"}},"node_modules/@poppinss/colors":{"version":"4.1.6","resolved":"https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz","integrity":"sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==","license":"MIT","dependencies":{"kleur":"^4.1.5"}},"node_modules/@poppinss/dumper":{"version":"0.6.5","resolved":"https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz","integrity":"sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==","license":"MIT","dependencies":{"@poppinss/colors":"^4.1.5","@sindresorhus/is":"^7.0.2","supports-color":"^10.0.0"}},"node_modules/@poppinss/exception":{"version":"1.2.3","resolved":"https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz","integrity":"sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==","license":"MIT"},"node_modules/@sindresorhus/is":{"version":"7.2.0","resolved":"https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz","integrity":"sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==","license":"MIT","engines":{"node":">=18"},"funding":{"url":"https://github.com/sindresorhus/is?sponsor=1"}},"node_modules/@speed-highlight/core":{"version":"1.2.17","resolved":"https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.17.tgz","integrity":"sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==","license":"CC0-1.0"},"node_modules/@typescript-eslint/project-service":{"version":"8.65.0","resolved":"https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz","integrity":"sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==","license":"MIT","dependencies":{"@typescript-eslint/tsconfig-utils":"^8.65.0","@typescript-eslint/types":"^8.65.0","debug":"^4.4.3"},"engines":{"node":"^18.18.0 || ^20.9.0 || >=21.1.0"},"funding":{"type":"opencollective","url":"https://opencollective.com/typescript-eslint"},"peerDependencies":{"typescript":">=4.8.4 <6.1.0"}},"node_modules/@typescript-eslint/tsconfig-utils":{"version":"8.65.0","resolved":"https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz","integrity":"sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==","license":"MIT","engines":{"node":"^18.18.0 || ^20.9.0 || >=21.1.0"},"funding":{"type":"opencollective","url":"https://opencollective.com/typescript-eslint"},"peerDependencies":{"typescript":">=4.8.4 <6.1.0"}},"node_modules/@typescript-eslint/types":{"version":"8.65.0","resolved":"https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz","integrity":"sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==","license":"MIT","engines":{"node":"^18.18.0 || ^20.9.0 || >=21.1.0"},"funding":{"type":"opencollective","url":"https://opencollective.com/typescript-eslint"}},"node_modules/@typescript-eslint/typescript-estree":{"version":"8.65.0","resolved":"https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz","integrity":"sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==","license":"MIT","dependencies":{"@typescript-eslint/project-service":"8.65.0","@typescript-eslint/tsconfig-utils":"8.65.0","@typescript-eslint/types":"8.65.0","@typescript-eslint/visitor-keys":"8.65.0","debug":"^4.4.3","minimatch":"^10.2.2","semver":"^7.7.3","tinyglobby":"^0.2.15","ts-api-utils":"^2.5.0"},"engines":{"node":"^18.18.0 || ^20.9.0 || >=21.1.0"},"funding":{"type":"opencollective","url":"https://opencollective.com/typescript-eslint"},"peerDependencies":{"typescript":">=4.8.4 <6.1.0"}},"node_modules/@typescript-eslint/visitor-keys":{"version":"8.65.0","resolved":"https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz","integrity":"sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==","license":"MIT","dependencies":{"@typescript-eslint/types":"8.65.0","eslint-visitor-keys":"^5.0.0"},"engines":{"node":"^18.18.0 || ^20.9.0 || >=21.1.0"},"funding":{"type":"opencollective","url":"https://opencollective.com/typescript-eslint"}},"node_modules/axe-core":{"version":"4.12.1","resolved":"https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz","integrity":"sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==","license":"MPL-2.0","engines":{"node":">=4"}},"node_modules/balanced-match":{"version":"4.0.4","resolved":"https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz","integrity":"sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==","license":"MIT","engines":{"node":"18 || 20 || >=22"}},"node_modules/blake3-wasm":{"version":"2.1.5","resolved":"https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz","integrity":"sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==","license":"MIT"},"node_modules/brace-expansion":{"version":"5.0.9","resolved":"https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz","integrity":"sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==","license":"MIT","dependencies":{"balanced-match":"^4.0.2"},"engines":{"node":"20 || >=22"}},"node_modules/cookie":{"version":"1.1.1","resolved":"https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz","integrity":"sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==","license":"MIT","engines":{"node":">=18"},"funding":{"type":"opencollective","url":"https://opencollective.com/express"}},"node_modules/debug":{"version":"4.4.3","resolved":"https://registry.npmjs.org/debug/-/debug-4.4.3.tgz","integrity":"sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==","license":"MIT","dependencies":{"ms":"^2.1.3"},"engines":{"node":">=6.0"},"peerDependenciesMeta":{"supports-color":{"optional":true}}},"node_modules/detect-libc":{"version":"2.1.2","resolved":"https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz","integrity":"sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==","license":"Apache-2.0","engines":{"node":">=8"}},"node_modules/error-stack-parser-es":{"version":"1.0.5","resolved":"https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz","integrity":"sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==","license":"MIT","funding":{"url":"https://github.com/sponsors/antfu"}},"node_modules/esbuild":{"version":"0.28.1","resolved":"https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz","integrity":"sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==","hasInstallScript":true,"license":"MIT","bin":{"esbuild":"bin/esbuild"},"engines":{"node":">=18"},"optionalDependencies":{"@esbuild/aix-ppc64":"0.28.1","@esbuild/android-arm":"0.28.1","@esbuild/android-arm64":"0.28.1","@esbuild/android-x64":"0.28.1","@esbuild/darwin-arm64":"0.28.1","@esbuild/darwin-x64":"0.28.1","@esbuild/freebsd-arm64":"0.28.1","@esbuild/freebsd-x64":"0.28.1","@esbuild/linux-arm":"0.28.1","@esbuild/linux-arm64":"0.28.1","@esbuild/linux-ia32":"0.28.1","@esbuild/linux-loong64":"0.28.1","@esbuild/linux-mips64el":"0.28.1","@esbuild/linux-ppc64":"0.28.1","@esbuild/linux-riscv64":"0.28.1","@esbuild/linux-s390x":"0.28.1","@esbuild/linux-x64":"0.28.1","@esbuild/netbsd-arm64":"0.28.1","@esbuild/netbsd-x64":"0.28.1","@esbuild/openbsd-arm64":"0.28.1","@esbuild/openbsd-x64":"0.28.1","@esbuild/openharmony-arm64":"0.28.1","@esbuild/sunos-x64":"0.28.1","@esbuild/win32-arm64":"0.28.1","@esbuild/win32-ia32":"0.28.1","@esbuild/win32-x64":"0.28.1"}},"node_modules/eslint-visitor-keys":{"version":"5.0.1","resolved":"https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz","integrity":"sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==","license":"Apache-2.0","engines":{"node":"^20.19.0 || ^22.13.0 || >=24"},"funding":{"url":"https://opencollective.com/eslint"}},"node_modules/fdir":{"version":"6.5.0","resolved":"https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz","integrity":"sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==","license":"MIT","engines":{"node":">=12.0.0"},"peerDependencies":{"picomatch":"^3 || ^4"},"peerDependenciesMeta":{"picomatch":{"optional":true}}},"node_modules/fsevents":{"version":"2.3.2","resolved":"https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz","integrity":"sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==","hasInstallScript":true,"license":"MIT","optional":true,"os":["darwin"],"engines":{"node":"^8.16.0 || ^10.6.0 || >=11.0.0"}},"node_modules/jscpd":{"version":"5.0.14","resolved":"https://registry.npmjs.org/jscpd/-/jscpd-5.0.14.tgz","integrity":"sha512-zge+FPZZAymt2Do5Z0+QHyIn4/XcUhrO/W7of9HcHZfx2AK8++dYhLA1uWtwXj47ml3Of8PbcUW4wUWvYMCc3w==","license":"MIT","bin":{"jscpd":"run-jscpd.js"},"engines":{"node":">=18"},"optionalDependencies":{"jscpd-darwin-arm64":"5.0.14","jscpd-darwin-x64":"5.0.14","jscpd-linux-arm64-gnu":"5.0.14","jscpd-linux-x64-gnu":"5.0.14","jscpd-linux-x64-musl":"5.0.14","jscpd-windows-x64-msvc":"5.0.14"}},"node_modules/jscpd-darwin-arm64":{"version":"5.0.14","resolved":"https://registry.npmjs.org/jscpd-darwin-arm64/-/jscpd-darwin-arm64-5.0.14.tgz","integrity":"sha512-Ojjl79SBuj9tEW6WbjZ1a/1ZOR89dneH9yLQYQu8WyWaQownttnx7RYFEHU6aGhS4jIvwUEbr+1wxzFTb37cwg==","cpu":["arm64"],"license":"MIT","optional":true,"os":["darwin"]},"node_modules/jscpd-darwin-x64":{"version":"5.0.14","resolved":"https://registry.npmjs.org/jscpd-darwin-x64/-/jscpd-darwin-x64-5.0.14.tgz","integrity":"sha512-DxFg5XvjMZ81iVeqillnM5apqcGCfNTbroNF+mPLr7RkHLGH6mudLgtO+ILL/hfpZXy1bF9oIY5BSudPmN/k9A==","cpu":["x64"],"license":"MIT","optional":true,"os":["darwin"]},"node_modules/jscpd-linux-arm64-gnu":{"version":"5.0.14","resolved":"https://registry.npmjs.org/jscpd-linux-arm64-gnu/-/jscpd-linux-arm64-gnu-5.0.14.tgz","integrity":"sha512-1uw+XBHEt9pONXNICSp5HpaVWPjG6mQ6deDXaq9Yb0xCNJkX4/8gmn0vhzekIyZD2DspRYKPUolbDsqm/HEdYg==","cpu":["arm64"],"libc":["glibc"],"license":"MIT","optional":true,"os":["linux"]},"node_modules/jscpd-linux-x64-gnu":{"version":"5.0.14","resolved":"https://registry.npmjs.org/jscpd-linux-x64-gnu/-/jscpd-linux-x64-gnu-5.0.14.tgz","integrity":"sha512-dFTbyyrm+Z9pcXIVzJQCw8QAgiNqIiO69sm4AfA7/wFdPoizoVzjhaXsYXcSV4bs0aoPiWbNazg0J0HgslT/5A==","cpu":["x64"],"libc":["glibc"],"license":"MIT","optional":true,"os":["linux"]},"node_modules/jscpd-linux-x64-musl":{"version":"5.0.14","resolved":"https://registry.npmjs.org/jscpd-linux-x64-musl/-/jscpd-linux-x64-musl-5.0.14.tgz","integrity":"sha512-SayS7qQJvixyy9eR0+UjepkTsUUwqvlsiuSxfIdHgG2qzqoh/thnkgiu4By8fsiiDpQONsQrRrZDwHRQ3GDrBQ==","cpu":["x64"],"libc":["musl"],"license":"MIT","optional":true,"os":["linux"]},"node_modules/jscpd-windows-x64-msvc":{"version":"5.0.14","resolved":"https://registry.npmjs.org/jscpd-windows-x64-msvc/-/jscpd-windows-x64-msvc-5.0.14.tgz","integrity":"sha512-DqjxlVkUanlahGgY2lY7Zkrau4BUTI+AwWky+bPGK4kSK2AIOaUziY9Q19u8b58idXmJA9FKK98Fuu4ajNXVjQ==","cpu":["x64"],"license":"MIT","optional":true,"os":["win32"]},"node_modules/kleur":{"version":"4.1.5","resolved":"https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz","integrity":"sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==","license":"MIT","engines":{"node":">=6"}},"node_modules/miniflare":{"version":"5.20260730.0-alpha","resolved":"https://registry.npmjs.org/miniflare/-/miniflare-5.20260730.0-alpha.tgz","integrity":"sha512-8/dspSXDshP6nSkCpjKO7BYc2qZoYSXm7iM+QxY7qJyJpAB3onnQSaiu0cvKJlfuMGwULl55hG69FJCcCMXU1Q==","license":"MIT","dependencies":{"@cspotcode/source-map-support":"0.8.1","sharp":"0.35.2","undici":"7.28.0","workerd":"1.20260730.1","ws":"8.21.0","youch":"4.1.0-beta.10"},"engines":{"node":">=22.0.0"}},"node_modules/minimatch":{"version":"10.2.6","resolved":"https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz","integrity":"sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==","license":"BlueOak-1.0.0","dependencies":{"brace-expansion":"^5.0.8"},"engines":{"node":"18 || 20 || >=22"},"funding":{"url":"https://github.com/sponsors/isaacs"}},"node_modules/ms":{"version":"2.1.3","resolved":"https://registry.npmjs.org/ms/-/ms-2.1.3.tgz","integrity":"sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==","license":"MIT"},"node_modules/path-to-regexp":{"version":"6.3.0","resolved":"https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz","integrity":"sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==","license":"MIT"},"node_modules/pathe":{"version":"2.0.3","resolved":"https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz","integrity":"sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==","license":"MIT"},"node_modules/picomatch":{"version":"4.0.5","resolved":"https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz","integrity":"sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==","license":"MIT","engines":{"node":">=12"},"funding":{"url":"https://github.com/sponsors/jonschlinkert"}},"node_modules/pixelmatch":{"version":"7.2.0","resolved":"https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.2.0.tgz","integrity":"sha512-xhcb4yHu9sM/G7foGzoLtXYcC0zHEaOXXjRKhGup0fw78Nf2Tkiapv4EQyMzrbcmQPsllAI7DbFY2UT7PlI9Pg==","license":"ISC","dependencies":{"pngjs":"^7.0.0"},"bin":{"pixelmatch":"bin/pixelmatch"}},"node_modules/playwright":{"version":"1.62.1","resolved":"https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz","integrity":"sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==","license":"Apache-2.0","dependencies":{"playwright-core":"1.62.1"},"bin":{"playwright":"cli.js"},"engines":{"node":">=20"},"optionalDependencies":{"fsevents":"2.3.2"}},"node_modules/playwright-core":{"version":"1.62.1","resolved":"https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz","integrity":"sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==","license":"Apache-2.0","bin":{"playwright-core":"cli.js"},"engines":{"node":">=20"}},"node_modules/pngjs":{"version":"7.0.0","resolved":"https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz","integrity":"sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==","license":"MIT","engines":{"node":">=14.19.0"}},"node_modules/semver":{"version":"7.8.5","resolved":"https://registry.npmjs.org/semver/-/semver-7.8.5.tgz","integrity":"sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==","license":"ISC","bin":{"semver":"bin/semver.js"},"engines":{"node":">=10"}},"node_modules/sharp":{"version":"0.35.2","resolved":"https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz","integrity":"sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==","license":"Apache-2.0","dependencies":{"@img/colour":"^1.1.0","detect-libc":"^2.1.2","semver":"^7.8.4"},"engines":{"node":">=20.9.0"},"funding":{"url":"https://opencollective.com/libvips"},"optionalDependencies":{"@img/sharp-darwin-arm64":"0.35.2","@img/sharp-darwin-x64":"0.35.2","@img/sharp-freebsd-wasm32":"0.35.2","@img/sharp-libvips-darwin-arm64":"1.3.1","@img/sharp-libvips-darwin-x64":"1.3.1","@img/sharp-libvips-linux-arm":"1.3.1","@img/sharp-libvips-linux-arm64":"1.3.1","@img/sharp-libvips-linux-ppc64":"1.3.1","@img/sharp-libvips-linux-riscv64":"1.3.1","@img/sharp-libvips-linux-s390x":"1.3.1","@img/sharp-libvips-linux-x64":"1.3.1","@img/sharp-libvips-linuxmusl-arm64":"1.3.1","@img/sharp-libvips-linuxmusl-x64":"1.3.1","@img/sharp-linux-arm":"0.35.2","@img/sharp-linux-arm64":"0.35.2","@img/sharp-linux-ppc64":"0.35.2","@img/sharp-linux-riscv64":"0.35.2","@img/sharp-linux-s390x":"0.35.2","@img/sharp-linux-x64":"0.35.2","@img/sharp-linuxmusl-arm64":"0.35.2","@img/sharp-linuxmusl-x64":"0.35.2","@img/sharp-webcontainers-wasm32":"0.35.2","@img/sharp-win32-arm64":"0.35.2","@img/sharp-win32-ia32":"0.35.2","@img/sharp-win32-x64":"0.35.2"}},"node_modules/supports-color":{"version":"10.2.2","resolved":"https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz","integrity":"sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==","license":"MIT","engines":{"node":">=18"},"funding":{"url":"https://github.com/chalk/supports-color?sponsor=1"}},"node_modules/tinyglobby":{"version":"0.2.17","resolved":"https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz","integrity":"sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==","license":"MIT","dependencies":{"fdir":"^6.5.0","picomatch":"^4.0.4"},"engines":{"node":">=12.0.0"},"funding":{"url":"https://github.com/sponsors/SuperchupuDev"}},"node_modules/ts-api-utils":{"version":"2.5.0","resolved":"https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz","integrity":"sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==","license":"MIT","engines":{"node":">=18.12"},"peerDependencies":{"typescript":">=4.8.4"}},"node_modules/tslib":{"version":"2.8.1","resolved":"https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz","integrity":"sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==","license":"0BSD","optional":true},"node_modules/typescript":{"version":"6.0.3","resolved":"https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz","integrity":"sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==","license":"Apache-2.0","peer":true,"bin":{"tsc":"bin/tsc","tsserver":"bin/tsserver"},"engines":{"node":">=14.17"}},"node_modules/undici":{"version":"7.28.0","resolved":"https://registry.npmjs.org/undici/-/undici-7.28.0.tgz","integrity":"sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==","license":"MIT","engines":{"node":">=20.18.1"}},"node_modules/unenv":{"version":"2.0.0-rc.24","resolved":"https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz","integrity":"sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==","license":"MIT","dependencies":{"pathe":"^2.0.3"}},"node_modules/workerd":{"version":"1.20260730.1","resolved":"https://registry.npmjs.org/workerd/-/workerd-1.20260730.1.tgz","integrity":"sha512-zmfNIjwYSWFY5chGBOjWtH3xAE7p97FTC6vR4Ep98290ho6AeAR/NVcBD274YCLEUYzqm8yxdtZlxMybU8a3jA==","hasInstallScript":true,"license":"Apache-2.0","bin":{"workerd":"bin/workerd"},"engines":{"node":">=16"},"optionalDependencies":{"@cloudflare/workerd-darwin-64":"1.20260730.1","@cloudflare/workerd-darwin-arm64":"1.20260730.1","@cloudflare/workerd-linux-64":"1.20260730.1","@cloudflare/workerd-linux-arm64":"1.20260730.1","@cloudflare/workerd-windows-64":"1.20260730.1"}},"node_modules/wrangler":{"version":"4.118.0","resolved":"https://registry.npmjs.org/wrangler/-/wrangler-4.118.0.tgz","integrity":"sha512-9pkBw/b8zWqGx2S+oLhgHMR1M/4VOE8SynUFABnGWiSFGlcOQ4xiI/B71Xf66RYP2xzngU37IQFPtUruij3lYw==","license":"MIT OR Apache-2.0","dependencies":{"@cloudflare/kv-asset-handler":"0.5.0","@cloudflare/unenv-preset":"2.16.1","blake3-wasm":"2.1.5","esbuild":"0.28.1","miniflare":"5.20260730.0-alpha","path-to-regexp":"6.3.0","unenv":"2.0.0-rc.24","workerd":"1.20260730.1"},"bin":{"cf-wrangler":"bin/cf-wrangler.js","wrangler":"bin/wrangler.js","wrangler2":"bin/wrangler.js"},"engines":{"node":">=22.0.0"},"optionalDependencies":{"fsevents":"2.3.3"},"peerDependencies":{"@cloudflare/workers-types":"^5.20260730.1"},"peerDependenciesMeta":{"@cloudflare/workers-types":{"optional":true}}},"node_modules/wrangler/node_modules/fsevents":{"version":"2.3.3","resolved":"https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz","integrity":"sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==","hasInstallScript":true,"license":"MIT","optional":true,"os":["darwin"],"engines":{"node":"^8.16.0 || ^10.6.0 || >=11.0.0"}},"node_modules/ws":{"version":"8.21.0","resolved":"https://registry.npmjs.org/ws/-/ws-8.21.0.tgz","integrity":"sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==","license":"MIT","engines":{"node":">=10.0.0"},"peerDependencies":{"bufferutil":"^4.0.1","utf-8-validate":">=5.0.2"},"peerDependenciesMeta":{"bufferutil":{"optional":true},"utf-8-validate":{"optional":true}}},"node_modules/youch":{"version":"4.1.0-beta.10","resolved":"https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz","integrity":"sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==","license":"MIT","dependencies":{"@poppinss/colors":"^4.1.5","@poppinss/dumper":"^0.6.4","@speed-highlight/core":"^1.2.7","cookie":"^1.0.2","youch-core":"^0.3.3"}},"node_modules/youch-core":{"version":"0.3.3","resolved":"https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz","integrity":"sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==","license":"MIT","dependencies":{"@poppinss/exception":"^1.2.2","error-stack-parser-es":"^1.0.5"}}}} diff --git a/verification/package.json b/verification/package.json new file mode 100644 index 0000000..56d3781 --- /dev/null +++ b/verification/package.json @@ -0,0 +1,18 @@ +{ + "name": "kvideo-strict-verification", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "verify": "node src/main.mjs" + }, + "dependencies": { + "@typescript-eslint/typescript-estree": "8.65.0", + "axe-core": "4.12.1", + "jscpd": "5.0.14", + "pixelmatch": "7.2.0", + "playwright": "1.62.1", + "pngjs": "7.0.0", + "wrangler": "4.118.0" + } +} diff --git a/verification/run b/verification/run new file mode 100755 index 0000000..480d5e1 --- /dev/null +++ b/verification/run @@ -0,0 +1,19 @@ +#!/bin/sh +set -eu + +verify_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +repo_dir=$(CDPATH= cd -- "$verify_dir/.." && pwd) + +if ! command -v node >/dev/null 2>&1; then + printf '%s\n' 'ERROR: Node.js is required.' >&2 + exit 127 +fi + +if ! command -v npm >/dev/null 2>&1; then + printf '%s\n' 'ERROR: npm is required.' >&2 + exit 127 +fi + +export PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 +npm ci --prefix "$verify_dir" --no-audit --no-fund --ignore-scripts +exec node "$verify_dir/src/main.mjs" --root "$repo_dir" "$@" diff --git a/verification/src/browser/action-state.mjs b/verification/src/browser/action-state.mjs new file mode 100644 index 0000000..bb70dfa --- /dev/null +++ b/verification/src/browser/action-state.mjs @@ -0,0 +1,53 @@ +function finite(value, fallback) { + if (value === '' || value === 'any') return fallback; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +export function numericCandidate({ min, max, value, step }) { + const lower = finite(min, 0); + const upper = Math.max(lower, finite(max, lower + 100)); + const increment = Math.max(Number.EPSILON, finite(step, 1)); + const midpoint = lower + ((upper - lower) / 2); + const snapped = Math.min(upper, lower + (Math.round((midpoint - lower) / increment) * increment)); + const current = finite(value, lower); + const candidate = Math.abs(snapped - current) > Number.EPSILON ? snapped : lower; + return String(Number(candidate.toFixed(10))); +} + +async function numericValue(locator) { + const bounds = await locator.evaluate((element) => ({ + min: element.min, max: element.max, value: element.value, step: element.step, + })); + return numericCandidate(bounds); +} + +export async function fillInput(locator, type) { + if (['number', 'range'].includes(type)) return locator.fill(await numericValue(locator)); + if (type === 'url') return locator.fill('https://example.com/verification'); + if (type === 'email') return locator.fill('verification@example.com'); + if (type === 'tel') return locator.fill('0123456789'); + return locator.fill('验证'); +} + +export async function toggleInput(locator, type) { + const label = locator.locator('xpath=ancestor::label[1]'); + if (await label.count()) { + await label.click({ timeout: 5000 }); + return 'clickLabel'; + } + const checked = type === 'radio' ? true : !(await locator.isChecked()); + await locator.setChecked(checked, { force: true }); + return 'setChecked'; +} + +export async function prepareActionState(page, action) { + const label = action.aria.trim().toLowerCase(); + const mode = /^(播放|play)$/.test(label) ? 'paused' : /^(暂停|pause)$/.test(label) ? 'playing' : null; + if (!mode) return; + await page.evaluate(async (expected) => { + const videos = [...document.querySelectorAll('video')]; + if (expected === 'paused') videos.forEach((video) => video.pause()); + else await Promise.all(videos.map((video) => video.play().catch(() => {}))); + }, mode); +} diff --git a/verification/src/browser/actions.mjs b/verification/src/browser/actions.mjs new file mode 100644 index 0000000..9c12bec --- /dev/null +++ b/verification/src/browser/actions.mjs @@ -0,0 +1,105 @@ +import crypto from 'node:crypto'; +import { fillInput, prepareActionState, toggleInput } from './action-state.mjs'; + +export async function scanActions(page) { + return page.evaluate(() => { + const selector = 'button,a[href],input,select,textarea,[role="button"],[data-focusable]'; + document.querySelectorAll('[data-kv-verify]').forEach((element) => element.removeAttribute('data-kv-verify')); + const visible = (element) => { + const style = getComputedStyle(element); + const box = element.getBoundingClientRect(); + if (element.closest('[aria-hidden="true"],[hidden],[inert]')) return false; + if (style.visibility === 'hidden' || style.display === 'none' || style.pointerEvents === 'none' || Number(style.opacity) === 0) return false; + if (box.width <= 0 || box.height <= 0) return false; + const fixed = style.position === 'fixed' || getComputedStyle(element.parentElement || element).position === 'fixed'; + if (fixed && (box.bottom <= 0 || box.top >= innerHeight || box.right <= 0 || box.left >= innerWidth)) return false; + const intersects = box.bottom > 0 && box.top < innerHeight && box.right > 0 && box.left < innerWidth; + if (!intersects) return true; + const x = Math.max(0, Math.min(innerWidth - 1, box.left + box.width / 2)); + const y = Math.max(0, Math.min(innerHeight - 1, box.top + box.height / 2)); + const top = document.elementFromPoint(x, y); + return !top || top === element || element.contains(top); + }; + const identity = (element) => { + const explicit = ['id', 'name', 'data-testid', 'aria-controls'].map((name) => element.getAttribute(name)).find(Boolean); + if (explicit) return `${element.tagName.toLowerCase()}#${explicit}`; + const parts = []; + for (let current = element; current?.parentElement && current !== document.body && parts.length < 6; current = current.parentElement) { + const siblings = [...current.parentElement.children].filter((item) => item.tagName === current.tagName); + parts.unshift(`${current.tagName.toLowerCase()}:${siblings.indexOf(current) + 1}`); + } + return parts.join('>'); + }; + const counts = new Map(); + return [...document.querySelectorAll(selector)].filter(visible).map((element, id) => { + const text = (element.innerText || element.value || '').trim().replace(/\s+/g, ' ').slice(0, 120); + const disabled = element.matches(':disabled') || Boolean(element.closest('[aria-disabled="true"]')); + const state = [element.value || '', element.checked ?? '', element.getAttribute('aria-expanded') || '', + element.getAttribute('aria-pressed') || '', element.getAttribute('data-state') || '', disabled].join(':'); + const path = identity(element); + const base = [path, element.getAttribute('role') || '', element.getAttribute('aria-label') || '', text, element.getAttribute('href') || '', element.getAttribute('type') || '', state].join('|'); + const occurrence = counts.get(base) || 0; + counts.set(base, occurrence + 1); + element.setAttribute('data-kv-verify', String(id)); + return { + id, key: `${base}|${occurrence}`, path, tag: element.tagName.toLowerCase(), text, + aria: element.getAttribute('aria-label') || '', href: element.getAttribute('href') || '', + type: element.getAttribute('type') || '', state, disabled, + }; + }); + }); +} + +export function stateHash(url, actions) { + return crypto.createHash('sha256').update(`${url}\n${actions.map((item) => item.key).join('\n')}`).digest('hex').slice(0, 20); +} + +async function findCurrent(page, action) { + for (let attempt = 0; attempt < 6; attempt += 1) { + const actions = await scanActions(page); + const exact = actions.find((item) => item.key === action.key); + if (exact) return { current: exact, matchedBy: 'key' }; + if (['input', 'textarea', 'select'].includes(action.tag)) { + const fields = ['path', 'tag', 'aria', 'href', 'type', 'disabled']; + const structural = actions.filter((item) => fields.every((field) => item[field] === action[field])); + if (structural.length === 1) return { current: structural[0], matchedBy: 'structure' }; + } + const semantic = actions.filter((item) => ['tag', 'aria', 'text', 'href', 'type', 'state'].every((field) => item[field] === action[field])); + if (semantic.length === 1) return { current: semantic[0], matchedBy: 'semantic' }; + await prepareActionState(page, action); + const viewport = page.viewportSize(); + if (viewport) { + await page.mouse.move(1, 1); + await page.mouse.move(Math.floor(viewport.width / 2), Math.floor(viewport.height / 2)); + } + await page.waitForTimeout(200); + } + return null; +} + +export async function performAction(page, action, fixtureFile) { + const match = await findCurrent(page, action); + if (!match) return { ok: false, reason: 'action missing during replay' }; + const { current, matchedBy } = match; + if (current.disabled) return { ok: true, skipped: true, matchedBy, reason: 'control is disabled in this state' }; + const locator = page.locator(`[data-kv-verify="${current.id}"]`); + if (current.tag === 'input' && current.type === 'file') { + await locator.setInputFiles(fixtureFile); return { ok: true, operation: 'setInputFiles', matchedBy }; + } + if (current.tag === 'input' && ['checkbox', 'radio'].includes(current.type)) { + return { ok: true, operation: await toggleInput(locator, current.type), matchedBy }; + } + if (current.tag === 'input' && ['button', 'submit'].includes(current.type)) { + await locator.click({ timeout: 5000 }); return { ok: true, operation: 'click', matchedBy }; + } + if (current.tag === 'input' || current.tag === 'textarea') { + await fillInput(locator, current.type); return { ok: true, operation: 'fill', matchedBy }; + } + if (current.tag === 'select') { + const values = await locator.locator('option').evaluateAll((options) => options.map((option) => option.value)); + if (!values.length) return { ok: true, skipped: true, matchedBy, reason: 'select has no options' }; + await locator.selectOption(values[Math.min(1, values.length - 1)]); return { ok: true, operation: 'select', matchedBy }; + } + await locator.click({ timeout: 5000 }); + return { ok: true, operation: 'click', matchedBy }; +} diff --git a/verification/src/browser/axe.mjs b/verification/src/browser/axe.mjs new file mode 100644 index 0000000..3e3ba5f --- /dev/null +++ b/verification/src/browser/axe.mjs @@ -0,0 +1,24 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +let axeSource; + +function source(ctx) { + if (!axeSource) axeSource = fs.readFileSync(path.join(ctx.config.verifyDir, 'node_modules', 'axe-core', 'axe.min.js'), 'utf8'); + return axeSource; +} + +export async function scanAxe(page, ctx) { + await page.addScriptTag({ content: source(ctx) }); + return page.evaluate(async () => { + const result = await window.axe.run(document, { + resultTypes: ['violations', 'incomplete'], + rules: { 'color-contrast': { enabled: true } }, + }); + const compact = (item) => ({ + id: item.id, impact: item.impact, description: item.description, help: item.help, + helpUrl: item.helpUrl, nodes: item.nodes.map((node) => ({ target: node.target, failureSummary: node.failureSummary, html: node.html.slice(0, 500) })), + }); + return { violations: result.violations.map(compact), incomplete: result.incomplete.map(compact) }; + }); +} diff --git a/verification/src/browser/init.mjs b/verification/src/browser/init.mjs new file mode 100644 index 0000000..7193467 --- /dev/null +++ b/verification/src/browser/init.mjs @@ -0,0 +1,30 @@ +export function browserInit() { + return ({ sourceConfig }) => { + localStorage.clear(); + sessionStorage.clear(); + try { Object.defineProperty(document, 'startViewTransition', { value: undefined, configurable: true }); } catch {} + const settings = { + sources: [sourceConfig], premiumSources: [sourceConfig], subscriptions: [], sortBy: 'default', searchHistory: true, + watchHistory: true, autoNextEpisode: true, autoSkipIntro: false, skipIntroSeconds: 0, autoSkipOutro: false, + skipOutroSeconds: 0, seekStepSeconds: 10, showModeIndicator: true, adFilter: false, adFilterMode: 'off', + adKeywords: [], realtimeLatency: true, searchDisplayMode: 'normal', episodeReverseOrder: false, + fullscreenType: 'auto', proxyMode: 'none', rememberScrollPosition: false, personalizedRecommendations: false, + videoTogetherEnabled: false, danmakuEnabled: false, danmakuApiUrl: '', danmakuOpacity: .7, + danmakuFontSize: 20, danmakuDisplayArea: .5, locale: 'zh-CN', blockedCategories: [], + }; + localStorage.setItem('kvideo-settings', JSON.stringify(settings)); + localStorage.setItem('theme', 'dark'); + const serviceWorker = { register: async () => ({ update: async () => {} }) }; + try { Object.defineProperty(navigator, 'serviceWorker', { value: serviceWorker, configurable: true }); } catch { /* browser restriction */ } + window.__kvMetrics = { errors: [], rejections: [], longTasks: [], lcp: 0, cls: 0 }; + addEventListener('error', (event) => window.__kvMetrics.errors.push(String(event.error?.stack || event.message))); + addEventListener('unhandledrejection', (event) => window.__kvMetrics.rejections.push(String(event.reason?.stack || event.reason))); + try { new PerformanceObserver((list) => list.getEntries().forEach((entry) => window.__kvMetrics.longTasks.push(entry.duration))).observe({ type: 'longtask', buffered: true }); } catch {} + try { new PerformanceObserver((list) => list.getEntries().forEach((entry) => { window.__kvMetrics.lcp = entry.startTime; })).observe({ type: 'largest-contentful-paint', buffered: true }); } catch {} + try { new PerformanceObserver((list) => list.getEntries().forEach((entry) => { if (!entry.hadRecentInput) window.__kvMetrics.cls += entry.value; })).observe({ type: 'layout-shift', buffered: true }); } catch {} + }; +} + +export const sourceArgument = (fixtureUrl) => ({ + sourceConfig: { id: 'fixture', name: 'Fixture', baseUrl: fixtureUrl, searchPath: '/source', detailPath: '/source', enabled: true }, +}); diff --git a/verification/src/browser/metrics.mjs b/verification/src/browser/metrics.mjs new file mode 100644 index 0000000..0423fbb --- /dev/null +++ b/verification/src/browser/metrics.mjs @@ -0,0 +1,28 @@ +export async function pageMetrics(page) { + return page.evaluate(() => { + const navigation = performance.getEntriesByType('navigation')[0]; + const resources = performance.getEntriesByType('resource'); + const metrics = window.__kvMetrics || {}; + return { + url: location.href, + title: document.title, + scrollWidth: document.documentElement.scrollWidth, + clientWidth: document.documentElement.clientWidth, + scrollHeight: document.documentElement.scrollHeight, + interactive: document.querySelectorAll('button,a[href],input,select,textarea,[role="button"],[data-focusable]').length, + navigation: navigation ? { + domContentLoaded: navigation.domContentLoadedEventEnd, + load: navigation.loadEventEnd, + response: navigation.responseEnd, + transferSize: navigation.transferSize, + } : null, + lcp: metrics.lcp || 0, + cls: metrics.cls || 0, + longTasks: metrics.longTasks || [], + errors: metrics.errors || [], + rejections: metrics.rejections || [], + resourceCount: resources.length, + resourceBytes: resources.reduce((sum, item) => sum + (item.transferSize || 0), 0), + }; + }); +} diff --git a/verification/src/browser/mocks.mjs b/verification/src/browser/mocks.mjs new file mode 100644 index 0000000..2ece99c --- /dev/null +++ b/verification/src/browser/mocks.mjs @@ -0,0 +1,63 @@ +function json(route, body, status = 200) { + return route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }); +} + +function searchStream(fixtureUrl) { + const video = { + vod_id: 'fixture-video-1', vod_name: '验证视频 1', vod_pic: `${fixtureUrl}/poster.svg?item=1`, + vod_remarks: '全2集', vod_year: '2026', type_name: '测试', source: 'fixture', sourceDisplayName: 'Fixture', latency: 20, + }; + return [ + { type: 'start', totalSources: 1 }, + { type: 'videos', videos: [video], source: 'fixture', completedSources: 1, totalSources: 1, latency: 20 }, + { type: 'progress', completedSources: 1, totalSources: 1, totalVideosFound: 1 }, + { type: 'complete', totalVideosFound: 1, totalSources: 1, maxPageCount: 1 }, + ].map((item) => `data: ${JSON.stringify(item)}\n\n`).join(''); +} + +export async function installMocks(page, ctx) { + await page.route('**/*', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const pathname = url.pathname; + if (url.origin === ctx.config.fixtureUrl) { + const response = await route.fetch(); + return route.fulfill({ response }); + } + if (pathname === '/api/auth/session') return json(route, { authenticated: false, session: null }); + if (pathname === '/api/auth' && request.method() === 'GET') return json(route, { + hasAuth: false, persistSession: true, loginMode: 'none', subscriptionSources: '', iptvSources: '', mergeSources: '', + }); + if (pathname === '/api/config') return json(route, { subscriptionSources: '' }); + if (pathname === '/api/app-update') { + const release = { version: ctx.state.version, publishedAt: '2026-07-31', title: 'Verification fixture', notes: ['Deterministic browser response'] }; + return json(route, { + currentVersion: ctx.state.version, currentRelease: release, latestVersion: ctx.state.version, latestRelease: release, + status: 'up-to-date', updateAvailable: false, checkedAt: new Date().toISOString(), checkedRemotely: true, + usedRemoteManifest: true, source: { repository: 'KuekHaoYang/KVideo', branch: 'main', + manifestUrl: 'https://raw.githubusercontent.com/KuekHaoYang/KVideo/main/app-release.json', + changelogUrl: 'https://github.com/KuekHaoYang/KVideo/blob/main/CHANGELOG.md', repositoryUrl: 'https://github.com/KuekHaoYang/KVideo' }, + }); + } + if (pathname === '/api/search-parallel') return route.fulfill({ status: 200, contentType: 'text/event-stream', body: searchStream(ctx.config.fixtureUrl) }); + if (pathname === '/api/detail') return json(route, { success: true, data: { + vod_id: 'fixture-video-1', vod_name: '验证视频 1', vod_pic: `${ctx.config.fixtureUrl}/poster.svg?item=1`, + vod_content: 'Deterministic browser fixture', vod_year: '2026', type_name: '测试', + episodes: [{ name: '第1集', url: `${ctx.config.fixtureUrl}/test.mp4` }, { name: '第2集', url: `${ctx.config.fixtureUrl}/hls/master.m3u8` }], + } }); + if (pathname === '/api/ping') return json(route, { latency: 20, success: true, timeout: false, method: 'HEAD' }); + if (pathname === '/api/probe-resolution') return json(route, { width: 640, height: 360, label: '360p' }); + if (pathname.startsWith('/api/user/')) return json(route, { history: [], favorites: [], config: null, success: true }); + if (pathname === '/api/premium/category') return json(route, { videos: [] }); + if (pathname === '/api/premium/types') return json(route, { tags: [{ id: 'recommend', label: '今日推荐', value: '' }] }); + if (pathname === '/api/danmaku') return json(route, []); + if (pathname.startsWith('/api/douban/')) return json(route, { tags: [], subjects: [] }); + if (pathname.startsWith('/api/') && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method())) { + return json(route, { error: 'verification mutation blocked' }, 403); + } + if (['www.gstatic.com', 'fastly.jsdelivr.net'].includes(url.hostname)) { + return route.fulfill({ status: 200, contentType: 'application/javascript', body: '' }); + } + return route.continue(); + }); +} diff --git a/verification/src/browser/routes.mjs b/verification/src/browser/routes.mjs new file mode 100644 index 0000000..23dd7eb --- /dev/null +++ b/verification/src/browser/routes.mjs @@ -0,0 +1,17 @@ +import path from 'node:path'; +import { relative, walk } from '../core/files.mjs'; + +function routeFromFile(root, file) { + const part = relative(path.join(root, 'app'), file).replace(/(^|\/)page\.tsx$/, ''); + const route = `/${part}`.replace(/\/\([^/]+\)/g, '').replace(/\/+/g, '/'); + return route === '/.' ? '/' : route; +} + +export function discoverPages(ctx) { + const files = walk(path.join(ctx.config.root, 'app'), (file) => file.endsWith('/page.tsx')); + const routes = files.map((file) => routeFromFile(ctx.config.root, file)).filter((route) => !route.includes('[')); + return [...new Set(routes)].map((route) => { + if (route === '/player') return '/player?id=fixture-video-1&source=fixture&title=%E9%AA%8C%E8%AF%81%E8%A7%86%E9%A2%91&episode=0'; + return route; + }).sort(); +} diff --git a/verification/src/browser/session.mjs b/verification/src/browser/session.mjs new file mode 100644 index 0000000..413150e --- /dev/null +++ b/verification/src/browser/session.mjs @@ -0,0 +1,54 @@ +import { chromium } from 'playwright'; +import { browserInit, sourceArgument } from './init.mjs'; +import { installMocks } from './mocks.mjs'; + +export async function launchBrowser(ctx) { + const browser = await chromium.launch({ + executablePath: ctx.state.chromePath, + headless: true, + args: ['--autoplay-policy=no-user-gesture-required', '--disable-background-timer-throttling'], + }); + ctx.services.push({ name: 'playwright-browser', close: () => browser.close() }); + return browser; +} + +export async function newPage(browser, ctx, viewport) { + const context = await browser.newContext({ viewport, colorScheme: 'dark', locale: 'zh-CN', reducedMotion: 'reduce' }); + await context.addInitScript(browserInit(), sourceArgument(ctx.config.fixtureUrl)); + const page = await context.newPage(); + const observed = { consoleErrors: [], consoleWarnings: [], pageErrors: [], failedRequests: [], httpErrors: [], dialogs: [], downloads: [], popups: [] }; + page.on('console', (message) => { + if (message.type() === 'error') observed.consoleErrors.push(message.text()); + if (message.type() === 'warning') observed.consoleWarnings.push(message.text()); + }); + page.on('pageerror', (error) => observed.pageErrors.push(error.stack || error.message)); + page.on('requestfailed', (request) => observed.failedRequests.push({ url: request.url(), error: request.failure()?.errorText })); + page.on('response', (response) => { + if (response.status() >= 400) observed.httpErrors.push({ method: response.request().method(), status: response.status(), url: response.url() }); + }); + page.on('dialog', async (dialog) => { + observed.dialogs.push({ type: dialog.type(), message: dialog.message(), defaultValue: dialog.defaultValue() }); + await dialog.dismiss().catch(() => {}); + }); + page.on('download', (download) => observed.downloads.push({ filename: download.suggestedFilename(), url: download.url() })); + page.on('popup', (popup) => { + observed.popups.push({ url: popup.url() }); + popup.close().catch(() => {}); + }); + await installMocks(page, ctx); + return { context, page, observed }; +} + +export async function stabilize(page) { + const css = '*,*::before,*::after{animation-duration:0s!important;transition-duration:0s!important;caret-color:transparent!important}::view-transition-old(root),::view-transition-new(root){animation:none!important}'; + await page.addStyleTag({ content: css }); + await page.evaluate(async () => { + await document.fonts?.ready; + const images = Promise.all([...document.images].map((image) => image.complete ? null : new Promise((resolve) => { + image.addEventListener('load', resolve, { once: true }); + image.addEventListener('error', resolve, { once: true }); + }))); + await Promise.race([images, new Promise((resolve) => setTimeout(resolve, 1000))]); + }); + await page.waitForTimeout(250); +} diff --git a/verification/src/checks/api-contracts.mjs b/verification/src/checks/api-contracts.mjs new file mode 100644 index 0000000..7a0444a --- /dev/null +++ b/verification/src/checks/api-contracts.mjs @@ -0,0 +1,49 @@ +import path from 'node:path'; +import { finding } from '../core/finding.mjs'; +import { jsonBody, request } from '../core/http.mjs'; +import { writeJson } from '../core/files.mjs'; + +export async function checkApiContracts(ctx) { + if (!ctx.state.appReady) return; + const source = { id: 'fixture', name: 'Fixture', baseUrl: ctx.config.fixtureUrl, searchPath: '/source', detailPath: '/source', enabled: true }; + const cases = [ + ['config', '/api/config', { method: 'GET' }, [200]], + ['app-update', '/api/app-update', { method: 'GET' }, [200]], + ['detail-missing', '/api/detail', { method: 'GET' }, [400]], + ['detail-fixture', '/api/detail', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: 'fixture-video-1', source }) }, [200]], + ['search-invalid', '/api/search-parallel', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }, [200]], + ['search-fixture', '/api/search-parallel', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ query: '验证视频', sources: [source] }) }, [200]], + ['ping-invalid', '/api/ping', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }, [400]], + ]; + const results = []; + for (const [name, route, options, expected] of cases) { + const response = await request(`${ctx.config.localUrl}${route}`, options); + results.push({ name, route, expected, response, parsed: jsonBody(response) }); + } + const target = path.join(ctx.dirs.raw, 'api-contracts.json'); + writeJson(target, results); + const failed = results.filter((item) => !item.expected.includes(item.response.status)); + const search = results.find((item) => item.name === 'search-fixture'); + const streamOk = search?.response.body.includes('"type":"videos"') && search.response.body.includes('"type":"complete"'); + const detail = results.find((item) => item.name === 'detail-fixture'); + const detailOk = detail?.parsed?.success && detail.parsed?.data?.episodes?.length === 2; + finding(ctx, { + id: 'api.contract-status', category: 'api', title: 'Core API status contracts match expectations', + status: failed.length ? 'FAIL' : 'PASS', severity: 'critical', expected: 'Every core contract returns its declared status', + actual: failed.length ? JSON.stringify(failed.map((item) => ({ name: item.name, status: item.response.status, expected: item.expected }))) : `${results.length} cases matched`, + reason: failed.length ? 'A core endpoint changed or failed its response contract.' : 'Core status-code contracts are stable.', evidence: [target], + remediation: 'Repair the endpoint or intentionally update the declared contract and consumers.', + }); + finding(ctx, { + id: 'api.search-stream', category: 'api', title: 'Streaming search emits video and completion events', + status: streamOk ? 'PASS' : 'FAIL', severity: 'critical', expected: 'SSE videos event followed by complete', actual: search?.response.body || 'missing', + reason: streamOk ? 'The deterministic source traversed the full search stream.' : 'The stream omitted results or completion.', evidence: [target], + remediation: 'Inspect streaming serialization, source parsing, and completion handling.', + }); + finding(ctx, { + id: 'api.detail-fixture', category: 'api', title: 'Detail parsing returns both MP4 and HLS episodes', + status: detailOk ? 'PASS' : 'FAIL', severity: 'critical', expected: 'success=true with 2 episodes', actual: JSON.stringify(detail?.parsed), + reason: detailOk ? 'The real detail route parsed deterministic upstream data.' : 'The detail route or episode parser lost fixture data.', evidence: [target], + remediation: 'Fix source lookup, upstream parsing, or episode normalization.', + }); +} diff --git a/verification/src/checks/api-discovery.mjs b/verification/src/checks/api-discovery.mjs new file mode 100644 index 0000000..adc0f82 --- /dev/null +++ b/verification/src/checks/api-discovery.mjs @@ -0,0 +1,55 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { finding } from '../core/finding.mjs'; +import { relative, walk, writeJson } from '../core/files.mjs'; +import { request } from '../core/http.mjs'; + +const methodPattern = /export\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\b/g; + +function routePath(root, file) { + return `/${relative(root, file).replace(/^app\//, '').replace(/\/route\.ts$/, '').replace(/\[([^\]]+)\]/g, 'verification-$1')}`; +} + +export async function checkApiDiscovery(ctx) { + if (!ctx.state.appReady) return finding(ctx, { + id: 'api.route-coverage', category: 'api', title: 'Every API method is exercised', status: 'SKIP', severity: 'critical', + expected: 'Local server ready', actual: 'server unavailable', reason: 'API calls cannot execute.', remediation: 'Fix local startup.', + }); + const files = walk(path.join(ctx.config.root, 'app', 'api'), (file) => file.endsWith('/route.ts')); + const inventory = files.map((file) => ({ + file: relative(ctx.config.root, file), + path: routePath(ctx.config.root, file), + methods: [...fs.readFileSync(file, 'utf8').matchAll(methodPattern)].map((match) => match[1]), + })); + const results = []; + for (const route of inventory) { + for (const method of route.methods) { + const options = { method, timeoutMs: 12_000, headers: {} }; + if (['POST', 'PUT', 'PATCH'].includes(method)) { + options.headers['content-type'] = 'application/json'; + options.body = '{}'; + } + const response = await request(`${ctx.config.localUrl}${route.path}`, options); + results.push({ ...route, methods: undefined, method, response }); + } + } + const target = path.join(ctx.dirs.raw, 'api-route-matrix.json'); + writeJson(target, { inventory, results }); + const unexercised = inventory.flatMap((route) => route.methods.map((method) => `${method} ${route.path}`)) + .filter((key) => !results.some((item) => `${item.method} ${item.path}` === key)); + const crashes = results.filter((item) => !item.response.ok || item.response.status >= 500); + finding(ctx, { + id: 'api.route-coverage', category: 'api', title: 'Every statically exported API method receives a smoke request', + status: unexercised.length ? 'FAIL' : 'PASS', severity: 'critical', expected: '100% exported method invocation', + actual: `${results.length} methods invoked; ${unexercised.length} missing`, reason: unexercised.length ? 'Some exported API methods were not reached.' : 'Every discovered method was invoked with a safe anonymous payload.', + evidence: [target], remediation: 'Add a safe contract case for every missing method.', + }); + finding(ctx, { + id: 'api.anonymous-crashes', category: 'api', title: 'Anonymous malformed requests do not crash API routes', + status: crashes.length ? 'FAIL' : 'PASS', severity: 'high', expected: 'No network error or HTTP 5xx for empty safe probes', + actual: crashes.length ? JSON.stringify(crashes.map((item) => ({ method: item.method, path: item.path, status: item.response.status, error: item.response.error }))) : 'No crashes', + reason: crashes.length ? 'Malformed or anonymous input reaches an internal failure instead of a controlled 4xx response.' : 'All routes rejected or handled generic probes without server failure.', + evidence: [target], remediation: 'Validate request inputs and convert expected missing configuration/auth states to explicit 4xx responses.', + }); + ctx.state.apiInventory = inventory; +} diff --git a/verification/src/checks/ast-metrics.mjs b/verification/src/checks/ast-metrics.mjs new file mode 100644 index 0000000..e45b9d0 --- /dev/null +++ b/verification/src/checks/ast-metrics.mjs @@ -0,0 +1,42 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { parse } from '@typescript-eslint/typescript-estree'; +import { finding } from '../core/finding.mjs'; +import { relative, walk, writeJson } from '../core/files.mjs'; +import { collectFunctions } from './ast-walk.mjs'; + +const extensions = new Set(['.ts', '.tsx']); + +export async function checkAstMetrics(ctx) { + const files = walk(ctx.config.root, (file) => extensions.has(path.extname(file)) && !file.includes('/verification/')); + const metrics = []; + const parseErrors = []; + for (const file of files) { + try { + const ast = parse(fs.readFileSync(file, 'utf8'), { loc: true, jsx: file.endsWith('.tsx'), errorOnUnknownASTType: false }); + const functions = collectFunctions(ast); + metrics.push({ file: relative(ctx.config.root, file), functions }); + } catch (error) { + parseErrors.push({ file: relative(ctx.config.root, file), error: error instanceof Error ? error.message : String(error) }); + } + } + const offenders = metrics.flatMap((item) => item.functions.map((fn) => ({ file: item.file, ...fn }))) + .filter((fn) => fn.lines > 80 || fn.complexity > 15 || fn.maxNesting > 4 || fn.params > 5) + .sort((a, b) => (b.complexity + b.lines / 10) - (a.complexity + a.lines / 10)); + const target = path.join(ctx.dirs.metrics, 'ast-metrics.json'); + writeJson(target, { parseErrors, offenders, files: metrics }); + finding(ctx, { + id: 'quality.ast-parse', category: 'quality', title: 'TypeScript source is structurally analyzable', + status: parseErrors.length ? 'FAIL' : 'PASS', severity: 'high', expected: 'All TS/TSX files parse', + actual: parseErrors.length ? JSON.stringify(parseErrors.slice(0, 20)) : `${files.length} files parsed`, + reason: parseErrors.length ? 'Unparseable files invalidate structural quality metrics.' : 'AST metrics cover all TS/TSX files.', + evidence: [target], remediation: 'Fix syntax/parser incompatibilities before relying on complexity results.', + }); + finding(ctx, { + id: 'quality.spaghetti-risk', category: 'quality', title: 'Functions stay within complexity and cohesion limits', + status: offenders.length ? 'FAIL' : 'PASS', severity: 'high', expected: 'lines <=80, complexity <=15, nesting <=4, params <=5', + actual: offenders.length ? `${offenders.length} risky functions; worst: ${JSON.stringify(offenders.slice(0, 12))}` : 'No threshold breaches', + reason: offenders.length ? 'Long, branch-heavy, deeply nested functions are concrete spaghetti-code indicators.' : 'No configured structural risk threshold was exceeded.', + evidence: [target], remediation: 'Extract cohesive functions and replace nested conditionals with explicit domain operations.', + }); +} diff --git a/verification/src/checks/ast-walk.mjs b/verification/src/checks/ast-walk.mjs new file mode 100644 index 0000000..b12f030 --- /dev/null +++ b/verification/src/checks/ast-walk.mjs @@ -0,0 +1,59 @@ +const functionTypes = new Set([ + 'FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression', + 'TSDeclareFunction', 'TSFunctionType', 'ObjectMethod', 'ClassMethod', +]); +const branchTypes = new Set([ + 'IfStatement', 'ForStatement', 'ForInStatement', 'ForOfStatement', + 'WhileStatement', 'DoWhileStatement', 'CatchClause', 'ConditionalExpression', +]); +const nestTypes = new Set([...branchTypes, 'SwitchStatement', 'TryStatement']); + +export function children(node) { + const output = []; + for (const [key, value] of Object.entries(node || {})) { + if (key === 'parent' || key === 'tokens' || key === 'comments') continue; + if (Array.isArray(value)) output.push(...value.filter((item) => item?.type)); + else if (value?.type) output.push(value); + } + return output; +} + +function displayName(node, parent) { + if (node.id?.name) return node.id.name; + if (parent?.id?.name) return parent.id.name; + if (parent?.key?.name) return parent.key.name; + return ''; +} + +function functionMetric(node, parent) { + let complexity = 1; + let maxNesting = 0; + const visit = (current, depth) => { + if (current !== node && functionTypes.has(current.type)) return; + if (branchTypes.has(current.type)) complexity += 1; + if (current.type === 'LogicalExpression' && ['&&', '||', '??'].includes(current.operator)) complexity += 1; + if (current.type === 'SwitchCase' && current.test) complexity += 1; + const nextDepth = nestTypes.has(current.type) ? depth + 1 : depth; + maxNesting = Math.max(maxNesting, nextDepth); + for (const child of children(current)) visit(child, nextDepth); + }; + visit(node, 0); + return { + name: displayName(node, parent), + line: node.loc?.start.line || 0, + lines: (node.loc?.end.line || 0) - (node.loc?.start.line || 0) + 1, + params: node.params?.length || 0, + complexity, + maxNesting, + }; +} + +export function collectFunctions(ast) { + const output = []; + const visit = (node, parent) => { + if (functionTypes.has(node.type)) output.push(functionMetric(node, parent)); + for (const child of children(node)) visit(child, node); + }; + visit(ast, null); + return output; +} diff --git a/verification/src/checks/deployment.mjs b/verification/src/checks/deployment.mjs new file mode 100644 index 0000000..3b334fb --- /dev/null +++ b/verification/src/checks/deployment.mjs @@ -0,0 +1,39 @@ +import path from 'node:path'; +import { runCommand } from '../core/command.mjs'; +import { finding } from '../core/finding.mjs'; +import { jsonBody, request } from '../core/http.mjs'; +import { writeJson } from '../core/files.mjs'; + +function digest(output) { + return output?.match(/^Digest:\s+(sha256:[a-f0-9]+)/m)?.[1] || null; +} + +export async function checkDeployment(ctx) { + if (ctx.config.offline) return finding(ctx, { + id: 'deploy.consistency', category: 'deployment', title: 'Local, GitHub, Cloudflare, and Docker release consistency', status: 'SKIP', severity: 'critical', + expected: 'Online verification', actual: '--offline', reason: 'Remote state cannot be verified offline.', remediation: 'Rerun online before release.', + }); + const localSha = (await runCommand(ctx, 'git-head', 'git', ['rev-parse', 'HEAD'], { timeoutMs: 30_000 })).tail.trim(); + const remoteShaResult = await runCommand(ctx, 'git-remote-main', 'git', ['ls-remote', 'origin', 'refs/heads/main'], { timeoutMs: 30_000 }); + const remoteSha = remoteShaResult.tail.trim().split(/\s+/)[0] || null; + const githubPackage = await request('https://raw.githubusercontent.com/KuekHaoYang/KVideo/main/package.json'); + const cloudflare = await request(`${ctx.config.referenceUrl}/api/app-update`); + const latest = await runCommand(ctx, 'dockerhub-latest', 'docker', ['buildx', 'imagetools', 'inspect', 'kuekhaoyang/kvideo:latest'], { timeoutMs: 120_000 }); + const versioned = await runCommand(ctx, 'dockerhub-version', 'docker', ['buildx', 'imagetools', 'inspect', `kuekhaoyang/kvideo:${ctx.state.version}`], { timeoutMs: 120_000 }); + const wrangler = path.join(ctx.config.verifyDir, 'node_modules', '.bin', 'wrangler'); + const pages = await runCommand(ctx, 'cloudflare-deployments', wrangler, ['pages', 'deployment', 'list', '--project-name', 'kvideo'], { timeoutMs: 120_000 }); + const githubVersion = jsonBody(githubPackage)?.version; + const cloudVersion = jsonBody(cloudflare)?.currentVersion; + const latestDigest = digest(latest.tail); + const versionDigest = digest(versioned.tail); + const facts = { localSha, remoteSha, localVersion: ctx.state.version, githubVersion, cloudVersion, latestDigest, versionDigest, pagesMentionsSha: pages.tail.includes(localSha.slice(0, 7)) }; + const target = path.join(ctx.dirs.raw, 'deployment-consistency.json'); + writeJson(target, { facts, githubPackage, cloudflare, evidence: { latest: latest.outputPath, versioned: versioned.outputPath, pages: pages.outputPath } }); + const ok = localSha === remoteSha && githubVersion === ctx.state.version && cloudVersion === ctx.state.version && latestDigest && latestDigest === versionDigest; + finding(ctx, { + id: 'deploy.consistency', category: 'deployment', title: 'Local, GitHub main, Cloudflare, and both Docker tags agree', + status: ok ? 'PASS' : 'FAIL', severity: 'critical', expected: 'Same Git commit/version; Docker latest and version tags share one digest', actual: JSON.stringify(facts), + reason: ok ? 'Every public release surface resolves to the declared release.' : 'At least one release surface is stale, missing, or points at a different artifact.', + evidence: [target, latest.outputPath, versioned.outputPath, pages.outputPath], remediation: 'Merge/push main, wait for Pages and Docker workflows, then verify version and digest convergence.', + }); +} diff --git a/verification/src/checks/docker-local.mjs b/verification/src/checks/docker-local.mjs new file mode 100644 index 0000000..ce0dde8 --- /dev/null +++ b/verification/src/checks/docker-local.mjs @@ -0,0 +1,43 @@ +import path from 'node:path'; +import { runCommand } from '../core/command.mjs'; +import { finding } from '../core/finding.mjs'; +import { jsonBody, request } from '../core/http.mjs'; +import { waitForUrl } from '../core/service.mjs'; +import { writeJson } from '../core/files.mjs'; + +export async function checkDockerLocal(ctx) { + if (ctx.config.quick) return finding(ctx, { + id: 'docker.local-image', category: 'docker', title: 'Local Docker image build and runtime smoke', status: 'SKIP', severity: 'critical', + expected: 'Full run', actual: '--quick', reason: 'Quick mode omits the expensive image build.', remediation: 'Run ./verification/run without --quick before release.', + }); + const image = `kvideo-verification:${ctx.state.version}`; + const name = `kvideo-verification-${Date.now()}`; + const build = await runCommand(ctx, 'docker-build', 'docker', ['build', '--pull', '--tag', image, '.'], { timeoutMs: 1_800_000 }); + if (build.code !== 0) return finding(ctx, { + id: 'docker.local-image', category: 'docker', title: 'Local Docker image builds and runs', status: 'FAIL', severity: 'critical', + expected: 'docker build exit 0', actual: `exit ${build.code}`, reason: 'The release container cannot be produced from this checkout.', + evidence: [build.outputPath], remediation: 'Fix Dockerfile, dependency installation, or standalone build output.', durationMs: build.durationMs, + }); + const run = await runCommand(ctx, 'docker-run', 'docker', ['run', '--detach', '--rm', '--name', name, '--publish', `127.0.0.1:${ctx.config.containerPort}:3000`, image], { timeoutMs: 60_000 }); + let ready = { ok: false, error: 'container did not start' }; + let response = null; + let inspect = null; + if (run.code === 0) { + ready = await waitForUrl(ctx.config.containerUrl, 120_000); + response = ready.ok ? await request(`${ctx.config.containerUrl}/api/app-update`) : null; + inspect = await runCommand(ctx, 'docker-inspect-local', 'docker', ['image', 'inspect', image], { timeoutMs: 30_000 }); + } + const logs = await runCommand(ctx, 'docker-container-logs', 'docker', ['logs', name], { timeoutMs: 30_000 }); + await runCommand(ctx, 'docker-stop', 'docker', ['stop', name], { timeoutMs: 60_000 }); + const parsed = response ? jsonBody(response) : null; + const ok = run.code === 0 && ready.ok && response?.status === 200 && parsed?.currentVersion === ctx.state.version; + const target = path.join(ctx.dirs.raw, 'docker-local.json'); + writeJson(target, { image, name, build, run, ready, response, parsed, inspect: inspect?.outputPath, logs: logs.outputPath }); + finding(ctx, { + id: 'docker.local-image', category: 'docker', title: 'Local Docker image builds, starts, and reports the expected version', + status: ok ? 'PASS' : 'FAIL', severity: 'critical', expected: `HTTP 200 and version ${ctx.state.version}`, actual: JSON.stringify({ run: run.code, ready, status: response?.status, version: parsed?.currentVersion }), + reason: ok ? 'The real standalone container passed a runtime smoke test.' : 'The container failed to start, respond, or expose the expected release version.', + evidence: [build.outputPath, run.outputPath, logs.outputPath, target], remediation: 'Inspect build and container logs, then correct the Docker release path.', + durationMs: build.durationMs + run.durationMs, + }); +} diff --git a/verification/src/checks/duplicates.mjs b/verification/src/checks/duplicates.mjs new file mode 100644 index 0000000..456dd38 --- /dev/null +++ b/verification/src/checks/duplicates.mjs @@ -0,0 +1,30 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { runCommand } from '../core/command.mjs'; +import { finding } from '../core/finding.mjs'; + +function findReport(dir) { + if (!fs.existsSync(dir)) return null; + return fs.readdirSync(dir).map((name) => path.join(dir, name)).find((file) => file.endsWith('.json')) || null; +} + +export async function checkDuplicates(ctx) { + const output = path.join(ctx.dirs.metrics, 'jscpd'); + const bin = path.join(ctx.config.verifyDir, 'node_modules', '.bin', 'jscpd'); + const args = ['--min-lines', '8', '--min-tokens', '60', '--reporters', 'json', '--output', output, 'app', 'components', 'lib']; + const result = await runCommand(ctx, 'jscpd', bin, args, { cwd: ctx.config.root }); + const report = findReport(output); + let data = null; + try { data = report ? JSON.parse(fs.readFileSync(report, 'utf8')) : null; } catch { /* report parse failure */ } + const percentage = data?.statistics?.total?.percentage ?? data?.statistics?.total?.percentageTokens ?? null; + const clones = data?.duplicates?.length ?? null; + const ok = result.code === 0 && data && Number(percentage || 0) <= 5; + finding(ctx, { + id: 'quality.duplication', category: 'quality', title: 'Copy-paste duplication stays below threshold', + status: ok ? 'PASS' : 'FAIL', severity: 'medium', expected: 'jscpd completes and duplicated lines <= 5%', + actual: data ? `${percentage}% duplication; ${clones ?? 'unknown'} clone groups` : `jscpd exit ${result.code}; no report`, + reason: ok ? 'Token-based clone analysis is below the configured limit.' : 'High duplication or a failed clone scan hides inconsistent parallel implementations.', + evidence: [result.outputPath, ...(report ? [report] : [])], remediation: 'Extract shared domain logic and remove copied branches with divergent behavior.', + durationMs: result.durationMs, + }); +} diff --git a/verification/src/checks/harness-self.mjs b/verification/src/checks/harness-self.mjs new file mode 100644 index 0000000..adb7c4b --- /dev/null +++ b/verification/src/checks/harness-self.mjs @@ -0,0 +1,16 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { runCommand } from '../core/command.mjs'; +import { finding } from '../core/finding.mjs'; + +export async function checkHarnessSelf(ctx) { + const testDir = path.join(ctx.config.verifyDir, 'tests'); + const tests = fs.readdirSync(testDir).filter((name) => name.endsWith('.test.mjs')).map((name) => path.join(testDir, name)); + const result = await runCommand(ctx, 'verification-self-tests', 'node', ['--test', ...tests], { cwd: ctx.config.root, timeoutMs: 60_000 }); + finding(ctx, { + id: 'harness.self-tests', category: 'harness', title: 'Verification framework self-tests pass', + status: result.code === 0 ? 'PASS' : 'FAIL', severity: 'critical', expected: 'node --test exit 0', actual: `exit ${result.code}`, + reason: result.code === 0 ? 'Core redaction, HTTP parsing, graph helpers, and report escaping are verified.' : 'The validation framework failed its own tests.', + evidence: [result.outputPath], remediation: 'Fix the harness before treating any project result as authoritative.', durationMs: result.durationMs, + }); +} diff --git a/verification/src/checks/import-graph.mjs b/verification/src/checks/import-graph.mjs new file mode 100644 index 0000000..fe6e57d --- /dev/null +++ b/verification/src/checks/import-graph.mjs @@ -0,0 +1,78 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { finding } from '../core/finding.mjs'; +import { lineCount, relative, walk, writeJson } from '../core/files.mjs'; + +const ext = ['.ts', '.tsx', '.js', '.jsx', '.mjs']; +const importPattern = /(?:import|export)\s+(?:[^'";]+?\s+from\s+)?['"]([^'"]+)['"]|import\(\s*['"]([^'"]+)['"]\s*\)/g; + +function resolveImport(root, from, specifier) { + if (!specifier.startsWith('.') && !specifier.startsWith('@/')) return null; + const base = specifier.startsWith('@/') ? path.join(root, specifier.slice(2)) : path.resolve(path.dirname(from), specifier); + const candidates = [base, ...ext.map((item) => base + item), ...ext.map((item) => path.join(base, `index${item}`))]; + return candidates.find((item) => fs.existsSync(item) && fs.statSync(item).isFile()) || null; +} + +function findCycles(graph) { + const cycles = new Set(); + const active = []; + const done = new Set(); + const visit = (node) => { + const index = active.indexOf(node); + if (index >= 0) { cycles.add(active.slice(index).concat(node).join(' -> ')); return; } + if (done.has(node)) return; + active.push(node); + for (const child of graph.get(node) || []) visit(child); + active.pop(); + done.add(node); + }; + for (const node of graph.keys()) visit(node); + return [...cycles]; +} + +function closure(graph, start) { + const seen = new Set(); + const visit = (node) => { + if (seen.has(node)) return; + seen.add(node); + for (const child of graph.get(node) || []) visit(child); + }; + visit(start); + return seen; +} + +export async function checkImportGraph(ctx) { + const root = ctx.config.root; + const files = walk(root, (file) => ext.includes(path.extname(file)) && !file.includes('/verification/')); + const graph = new Map(files.map((file) => [relative(root, file), []])); + const unresolved = []; + for (const file of files) { + const source = fs.readFileSync(file, 'utf8'); + for (const match of source.matchAll(importPattern)) { + const specifier = match[1] || match[2]; + const resolved = resolveImport(root, file, specifier); + if (resolved) graph.get(relative(root, file)).push(relative(root, resolved)); + else if (specifier.startsWith('.') || specifier.startsWith('@/')) unresolved.push({ file: relative(root, file), specifier }); + } + } + const cycles = findCycles(graph); + const pages = [...graph.keys()].filter((file) => /(^|\/)app\/.*page\.tsx$/.test(file)); + const features = pages.map((entry) => { + const reached = closure(graph, entry); + return { entry, files: reached.size, lines: [...reached].reduce((sum, file) => sum + lineCount(path.join(root, file)), 0) }; + }).sort((a, b) => b.lines - a.lines); + const target = path.join(ctx.dirs.metrics, 'import-graph.json'); + writeJson(target, { cycles, unresolved, features, graph: Object.fromEntries(graph) }); + finding(ctx, { + id: 'quality.import-cycles', category: 'quality', title: 'Internal dependency graph has no cycles', + status: cycles.length ? 'FAIL' : 'PASS', severity: 'high', expected: '0 dependency cycles', actual: cycles.length ? cycles.slice(0, 20).join('\n') : '0', + reason: cycles.length ? 'Cycles create order-dependent initialization and make feature boundaries unreliable.' : 'No static import cycles were found.', + evidence: [target], remediation: 'Extract shared contracts or invert dependencies to break each cycle.', + }); + finding(ctx, { + id: 'quality.feature-footprint', category: 'quality', title: 'Feature transitive code footprint is quantified', + status: features.some((item) => item.lines > 10_000) ? 'WARN' : 'PASS', severity: 'medium', expected: 'No page transitively owns more than 10,000 lines', + actual: JSON.stringify(features), reason: 'Transitive page footprints expose features that accumulate excessive code through dependencies.', + evidence: [target], remediation: 'Split oversized feature graphs into explicit bounded modules and lazy boundaries.', + }); +} diff --git a/verification/src/checks/latency.mjs b/verification/src/checks/latency.mjs new file mode 100644 index 0000000..b93b432 --- /dev/null +++ b/verification/src/checks/latency.mjs @@ -0,0 +1,36 @@ +import path from 'node:path'; +import { finding } from '../core/finding.mjs'; +import { jsonBody, request } from '../core/http.mjs'; +import { writeJson } from '../core/files.mjs'; + +async function ping(ctx, url) { + const response = await request(`${ctx.config.localUrl}/api/ping`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ url }), timeoutMs: 10_000, + }); + return { response, parsed: jsonBody(response) }; +} + +export async function checkLatency(ctx) { + if (!ctx.state.appReady) return; + const fast = await ping(ctx, `${ctx.config.fixtureUrl}/fast`); + const slow = await ping(ctx, `${ctx.config.fixtureUrl}/slow?ms=300`); + const burst = await Promise.all(Array.from({ length: 12 }, () => ping(ctx, `${ctx.config.fixtureUrl}/slow?ms=80`))); + const values = burst.map((item) => item.response.durationMs).sort((a, b) => a - b); + const p95 = values[Math.ceil(values.length * 0.95) - 1]; + const target = path.join(ctx.dirs.raw, 'latency-contracts.json'); + writeJson(target, { fast, slow, burst: burst.map((item) => item.response), p95 }); + const accurate = fast.response.status === 200 && slow.response.status === 200 && fast.parsed?.success && slow.parsed?.success && + slow.parsed.latency >= 250 && slow.parsed.latency <= 1500 && slow.parsed.latency > fast.parsed.latency; + finding(ctx, { + id: 'latency.accuracy', category: 'performance', title: 'Latency probe distinguishes fast and delayed sources', + status: accurate ? 'PASS' : 'FAIL', severity: 'high', expected: '300ms fixture reports 250-1500ms and exceeds fast fixture', + actual: JSON.stringify({ fast: fast.parsed, slow: slow.parsed }), reason: accurate ? 'Measured latency tracks controlled upstream delay.' : 'Latency values are missing, inverted, or outside tolerance.', + evidence: [target], remediation: 'Inspect HEAD/GET fallback timing and timeout accounting.', + }); + finding(ctx, { + id: 'latency.concurrent-p95', category: 'performance', title: 'Concurrent latency requests stay responsive', + status: p95 <= 2000 ? 'PASS' : 'FAIL', severity: 'medium', expected: '12-request p95 <= 2000ms', actual: `${p95}ms`, + reason: p95 <= 2000 ? 'The local latency endpoint handled the burst within threshold.' : 'Concurrent probes produced excessive queueing or stalls.', + evidence: [target], remediation: 'Bound outbound concurrency and remove serial bottlenecks.', + }); +} diff --git a/verification/src/checks/performance.mjs b/verification/src/checks/performance.mjs new file mode 100644 index 0000000..df4338e --- /dev/null +++ b/verification/src/checks/performance.mjs @@ -0,0 +1,56 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { finding } from '../core/finding.mjs'; +import { walk, writeJson } from '../core/files.mjs'; +import { newPage, stabilize } from '../browser/session.mjs'; +import { pageMetrics } from '../browser/metrics.mjs'; + +async function scrollFrames(page) { + return page.evaluate(async () => { + const frames = []; + const started = performance.now(); + let previous = started; + await new Promise((resolve) => { + const step = (now) => { + frames.push(now - previous); previous = now; + const progress = Math.min(1, (now - started) / 2500); + scrollTo(0, (document.documentElement.scrollHeight - innerHeight) * progress); + if (progress < 1) requestAnimationFrame(step); else resolve(); + }; + requestAnimationFrame(step); + }); + frames.sort((a, b) => a - b); + return { count: frames.length, p95: frames[Math.ceil(frames.length * .95) - 1] || 0, over34: frames.filter((item) => item > 34).length }; + }); +} + +export async function checkPerformance(ctx) { + if (!ctx.state.browser || !ctx.state.appReady) return; + const session = await newPage(ctx.state.browser, ctx, { width: 1440, height: 1000 }); + const cdp = await session.context.newCDPSession(session.page); + await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 }); + await session.page.goto(`${ctx.config.localUrl}/settings`, { waitUntil: 'domcontentloaded', timeout: ctx.config.navigationTimeoutMs }); + await stabilize(session.page); + const frames = await scrollFrames(session.page); + const metrics = await pageMetrics(session.page); + await session.context.close(); + const bundles = walk(path.join(ctx.config.root, '.next', 'static'), (file) => file.endsWith('.js')).map((file) => ({ file, bytes: fs.statSync(file).size })); + const bundle = { count: bundles.length, totalBytes: bundles.reduce((sum, item) => sum + item.bytes, 0), largest: bundles.sort((a, b) => b.bytes - a.bytes).slice(0, 20) }; + const target = path.join(ctx.dirs.metrics, 'performance.json'); + writeJson(target, { frames, metrics, bundle }); + const longTaskTotal = metrics.longTasks.reduce((sum, item) => sum + item, 0); + const good = frames.p95 <= 34 && frames.over34 / Math.max(frames.count, 1) <= .05 && longTaskTotal <= ctx.config.maxLongTaskMs; + finding(ctx, { + id: 'performance.scroll-jank', category: 'performance', title: 'Settings scrolling remains smooth under 4× CPU throttling', + status: good ? 'PASS' : 'FAIL', severity: 'high', expected: 'frame p95 <=34ms, >34ms frames <=5%, long tasks <=500ms total', + actual: JSON.stringify({ frames, longTaskTotal }), reason: good ? 'Throttled scroll animation stayed within the frame budget.' : 'Measured frame gaps or main-thread long tasks exceed the budget.', + evidence: [target], remediation: 'Profile long tasks, reduce render scope, virtualize long lists, and remove layout thrashing.', + }); + const largest = bundle.largest[0]?.bytes || 0; + finding(ctx, { + id: 'performance.bundle-size', category: 'performance', title: 'Client JavaScript bundle size is bounded', + status: largest <= 1_000_000 ? 'PASS' : 'WARN', severity: 'medium', expected: 'Largest emitted JS asset <= 1,000,000 raw bytes', actual: JSON.stringify(bundle), + reason: largest <= 1_000_000 ? 'No single emitted asset exceeds the guardrail.' : 'A very large asset increases parse, compile, and low-end device latency.', + evidence: [target], remediation: 'Split heavy dependencies and defer feature code outside initial routes.', + }); +} diff --git a/verification/src/checks/preflight.mjs b/verification/src/checks/preflight.mjs new file mode 100644 index 0000000..5e2d253 --- /dev/null +++ b/verification/src/checks/preflight.mjs @@ -0,0 +1,46 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { runCommand } from '../core/command.mjs'; +import { finding } from '../core/finding.mjs'; +import { readJson } from '../core/files.mjs'; + +function major(version) { + return Number(String(version).replace(/^v/, '').split('.')[0]); +} + +export async function checkPreflight(ctx) { + const pkg = readJson(path.join(ctx.config.root, 'package.json')); + const release = readJson(path.join(ctx.config.root, 'app-release.json')); + const versionsMatch = pkg.version === release.currentVersion && pkg.version === release.releases?.[0]?.version; + finding(ctx, { + id: 'preflight.version-consistency', category: 'preflight', title: 'Local version metadata agrees', + status: versionsMatch ? 'PASS' : 'FAIL', severity: 'high', expected: 'package.json, currentVersion, and first release match', + actual: JSON.stringify({ package: pkg.version, current: release.currentVersion, release: release.releases?.[0]?.version }), + reason: versionsMatch ? 'All local release sources agree.' : 'Release sources disagree and can publish ambiguous artifacts.', + remediation: 'Update all version sources atomically before release.', + }); + const nodeMajor = major(process.version); + finding(ctx, { + id: 'preflight.node', category: 'preflight', title: 'Node.js runtime is supported', + status: nodeMajor >= 20 && nodeMajor <= 26 ? 'PASS' : 'WARN', severity: 'medium', expected: 'Node.js 20 through 26', + actual: process.version, reason: nodeMajor >= 20 && nodeMajor <= 26 ? 'Runtime is within the tested range.' : 'Runtime is outside the declared range.', + remediation: 'Use an LTS version supported by Next.js and the repository.', + }); + const chrome = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + finding(ctx, { + id: 'preflight.chrome', category: 'preflight', title: 'Chrome executable is available', + status: fs.existsSync(chrome) ? 'PASS' : 'FAIL', severity: 'high', expected: chrome, actual: fs.existsSync(chrome), + reason: fs.existsSync(chrome) ? 'UI checks can launch an isolated browser.' : 'UI checks cannot launch the required browser.', + remediation: 'Install Google Chrome or configure a supported executable.', + }); + ctx.state.chromePath = chrome; + const git = await runCommand(ctx, 'git-status', 'git', ['status', '--porcelain=v1'], { timeoutMs: 30_000 }); + const businessChanges = git.tail.split('\n').filter(Boolean).filter((line) => !line.slice(3).startsWith('verification/')); + finding(ctx, { + id: 'preflight.business-tree', category: 'preflight', title: 'Business source tree has no unrelated edits', + status: businessChanges.length ? 'FAIL' : 'PASS', severity: 'high', expected: 'No changes outside verification/', + actual: businessChanges.length ? businessChanges.join('\n') : 'clean', + reason: businessChanges.length ? 'Unrelated edits make attribution and safe publishing ambiguous.' : 'Only the validation scope is changed.', + evidence: [git.outputPath], remediation: 'Separate or explicitly approve unrelated changes before publishing.', + }); +} diff --git a/verification/src/checks/proxy.mjs b/verification/src/checks/proxy.mjs new file mode 100644 index 0000000..0c74fad --- /dev/null +++ b/verification/src/checks/proxy.mjs @@ -0,0 +1,45 @@ +import path from 'node:path'; +import { finding } from '../core/finding.mjs'; +import { request } from '../core/http.mjs'; +import { writeJson } from '../core/files.mjs'; + +export async function checkProxy(ctx) { + if (!ctx.state.appReady) return; + const proxy = (url) => `${ctx.config.localUrl}/api/proxy?url=${encodeURIComponent(url)}`; + const cases = { + missing: await request(`${ctx.config.localUrl}/api/proxy`), + mp4: await request(proxy(`${ctx.config.fixtureUrl}/test.mp4`)), + range: await request(proxy(`${ctx.config.fixtureUrl}/test.mp4`), { headers: { range: 'bytes=0-99' } }), + hls: await request(proxy(`${ctx.config.fixtureUrl}/hls/master.m3u8`)), + notFound: await request(proxy(`${ctx.config.fixtureUrl}/status/404`)), + redirect: await request(proxy(`${ctx.config.fixtureUrl}/redirect`)), + fileProtocol: await request(proxy('file:///etc/hosts')), + }; + const target = path.join(ctx.dirs.raw, 'proxy-contracts.json'); + writeJson(target, cases); + const functional = cases.missing.status === 400 && cases.mp4.status === 200 && cases.range.status === 206 && + cases.range.bytes === 100 && cases.hls.status === 200 && cases.hls.body.includes('/api/proxy?url=') && + cases.notFound.status === 404 && cases.redirect.status === 200; + finding(ctx, { + id: 'proxy.functional', category: 'proxy', title: 'Media proxy preserves errors, ranges, redirects, CORS, and rewrites HLS', + status: functional ? 'PASS' : 'FAIL', severity: 'critical', expected: 'All seven proxy contracts pass', + actual: JSON.stringify(Object.fromEntries(Object.entries(cases).map(([key, value]) => [key, { status: value.status, bytes: value.bytes, headers: value.headers }]))), + reason: functional ? 'Deterministic upstream behavior survived the proxy contract.' : 'One or more core proxy behaviors are broken.', evidence: [target], + remediation: 'Fix forwarding, range/header preservation, redirect handling, or playlist rewriting.', + }); + const blocksUnsupported = cases.fileProtocol.status >= 400 && cases.fileProtocol.status < 500; + finding(ctx, { + id: 'proxy.protocol-validation', category: 'security', title: 'Proxy rejects unsupported protocols before fetching', + status: blocksUnsupported ? 'PASS' : 'FAIL', severity: 'high', expected: 'Controlled HTTP 4xx for file://', actual: cases.fileProtocol.status, + reason: blocksUnsupported ? 'Unsupported protocols are rejected as client input.' : 'Unsupported protocols fall into a server error instead of explicit validation.', + evidence: [target], remediation: 'Allow only http: and https: before invoking fetch.', + }); + const loopbackFetched = cases.mp4.status === 200; + finding(ctx, { + id: 'proxy.private-network-ssrf', category: 'security', title: 'Proxy blocks loopback and private-network targets', + status: loopbackFetched ? 'FAIL' : 'PASS', severity: 'critical', expected: 'Loopback target rejected', actual: `loopback HTTP ${cases.mp4.status}`, + reason: loopbackFetched ? 'The public proxy route can reach 127.0.0.1, demonstrating an SSRF primitive on self-hosted deployments.' : 'Private address access was blocked.', + impact: 'An exposed self-hosted instance may reach internal services available to the application host.', evidence: [target], + remediation: 'Resolve DNS safely and reject loopback, link-local, private, multicast, and metadata-service address ranges across redirects.', + }); +} diff --git a/verification/src/checks/runtime-start.mjs b/verification/src/checks/runtime-start.mjs new file mode 100644 index 0000000..48ec08d --- /dev/null +++ b/verification/src/checks/runtime-start.mjs @@ -0,0 +1,32 @@ +import { finding } from '../core/finding.mjs'; +import { startProcess, waitForUrl } from '../core/service.mjs'; +import { createMedia } from '../fixture/media.mjs'; +import { startFixtureServer } from '../fixture/server.mjs'; + +export async function startRuntime(ctx) { + await createMedia(ctx); + await startFixtureServer(ctx); + const fixture = await waitForUrl(`${ctx.config.fixtureUrl}/health`, 5000); + finding(ctx, { + id: 'runtime.fixture-server', category: 'harness', title: 'Local deterministic fixture server is reachable', + status: fixture.ok ? 'PASS' : 'FAIL', severity: 'critical', expected: 'HTTP 200', actual: fixture.ok ? fixture.status : fixture.error, + reason: fixture.ok ? 'API, proxy, latency, and media tests can use controlled upstream behavior.' : 'Controlled integration tests cannot run.', + remediation: 'Free port 34174 and rerun.', + }); + const production = ctx.state.buildOk; + const args = production ? ['start'] : ['run', 'dev']; + const service = await startProcess(ctx, production ? 'next-start' : 'next-dev', 'npm', args, { + env: { PORT: String(ctx.config.localPort), HOSTNAME: '127.0.0.1', NEXT_TELEMETRY_DISABLED: '1' }, + url: ctx.config.localUrl, timeoutMs: 120_000, + }); + const ready = service.ready?.ok; + ctx.state.appReady = ready; + finding(ctx, { + id: 'runtime.local-server', category: 'runtime', title: 'KVideo local server starts and responds', + status: ready ? 'PASS' : 'FAIL', severity: 'critical', expected: `Reachable ${production ? 'production' : 'development fallback'} server`, + actual: ready ? `HTTP ${service.ready.status}` : service.ready?.error || 'not ready', + reason: ready ? 'Browser and API integration checks can execute.' : 'No local application endpoint became ready.', + impact: production ? '' : 'Production build failed, so UI evidence uses a development fallback.', + evidence: [service.outputPath], remediation: 'Fix startup/build errors or release port 34173.', + }); +} diff --git a/verification/src/checks/security-headers.mjs b/verification/src/checks/security-headers.mjs new file mode 100644 index 0000000..4d705d0 --- /dev/null +++ b/verification/src/checks/security-headers.mjs @@ -0,0 +1,43 @@ +import path from 'node:path'; +import { finding } from '../core/finding.mjs'; +import { request } from '../core/http.mjs'; +import { writeJson } from '../core/files.mjs'; + +function evaluate(headers, remote) { + const checks = { + contentTypeOptions: headers['x-content-type-options'] === 'nosniff', + referrerPolicy: Boolean(headers['referrer-policy']), + framing: Boolean(headers['x-frame-options'] || headers['content-security-policy']?.includes('frame-ancestors')), + permissionsPolicy: Boolean(headers['permissions-policy']), + contentSecurityPolicy: Boolean(headers['content-security-policy']), + hsts: !remote || Boolean(headers['strict-transport-security']), + noPoweredBy: !headers['x-powered-by'], + }; + return { checks, missing: Object.entries(checks).filter(([, ok]) => !ok).map(([name]) => name) }; +} + +export async function checkSecurityHeaders(ctx) { + if (!ctx.state.appReady) return; + const local = await request(ctx.config.localUrl); + const remote = ctx.config.offline ? null : await request(ctx.config.referenceUrl); + const localEval = evaluate(local.headers, false); + const remoteEval = remote ? evaluate(remote.headers, true) : null; + const assets = await Promise.all(['/manifest.json', '/sw.js', '/icon.png'].map(async (route) => ({ route, response: await request(`${ctx.config.localUrl}${route}`) }))); + const target = path.join(ctx.dirs.raw, 'security-headers.json'); + writeJson(target, { local, remote, localEval, remoteEval, assets }); + finding(ctx, { + id: 'security.response-headers', category: 'security', title: 'Application responses set a complete browser security-header baseline', + status: localEval.missing.length || remoteEval?.missing.length ? 'FAIL' : 'PASS', severity: 'high', expected: 'nosniff, referrer, framing, permissions, CSP, HSTS remotely, no powered-by', + actual: JSON.stringify({ localMissing: localEval.missing, remoteMissing: remoteEval?.missing || [] }), + reason: localEval.missing.length || remoteEval?.missing.length ? 'One or more standard browser defenses are absent.' : 'Both surfaces provide the configured defense-in-depth headers.', + evidence: [target], remediation: 'Define headers in Next.js/self-hosted responses and Cloudflare configuration; use a restrictive CSP tested against required scripts.', + }); + const assetFailures = assets.filter((item) => item.response.status !== 200); + finding(ctx, { + id: 'runtime.pwa-assets', category: 'runtime', title: 'PWA manifest, service worker, and icon are reachable', + status: assetFailures.length ? 'FAIL' : 'PASS', severity: 'medium', expected: 'HTTP 200 for all required PWA assets', + actual: JSON.stringify(assets.map((item) => ({ route: item.route, status: item.response.status, bytes: item.response.bytes }))), + reason: assetFailures.length ? 'At least one install/offline asset is missing.' : 'All declared PWA assets are served.', evidence: [target], + remediation: 'Restore the missing public asset and verify its content type and cache behavior.', + }); +} diff --git a/verification/src/checks/security-scan.mjs b/verification/src/checks/security-scan.mjs new file mode 100644 index 0000000..6acc0a6 --- /dev/null +++ b/verification/src/checks/security-scan.mjs @@ -0,0 +1,45 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { finding } from '../core/finding.mjs'; +import { relative, walk, writeJson } from '../core/files.mjs'; + +const textExt = new Set(['.ts', '.tsx', '.js', '.mjs', '.json', '.yml', '.yaml', '.toml', '.md']); +const patterns = [ + ['private-key', /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/], + ['github-token', /\bgh[opusr]_[A-Za-z0-9_]{20,}\b/], + ['aws-access-key', /\bAKIA[0-9A-Z]{16}\b/], + ['generic-secret-assignment', /(?:secret|token|password|api[_-]?key)\s*[:=]\s*['"][^'"\n]{12,}['"]/i], +]; + +export async function checkSecurityScan(ctx) { + const files = walk(ctx.config.root, (file) => textExt.has(path.extname(file)) && !file.includes('/verification/artifacts/')); + const hits = []; + for (const file of files) { + const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/); + lines.forEach((line, index) => { + for (const [name, pattern] of patterns) if (pattern.test(line)) hits.push({ file: relative(ctx.config.root, file), line: index + 1, pattern: name }); + }); + } + const target = path.join(ctx.dirs.metrics, 'secret-scan.json'); + writeJson(target, hits); + finding(ctx, { + id: 'security.static-secrets', category: 'security', title: 'Repository contains no obvious committed secrets', + status: hits.length ? 'FAIL' : 'PASS', severity: 'critical', expected: '0 credential-pattern matches', + actual: hits.length ? JSON.stringify(hits) : '0', + reason: hits.length ? 'Credential-like material appears in repository text. Values are intentionally omitted from logs.' : 'No configured credential signature was found.', + evidence: [target], remediation: 'Remove and rotate confirmed credentials; replace false positives with safe fixtures.', + }); + const dangerous = files.filter((file) => ['.ts', '.tsx', '.js', '.mjs'].includes(path.extname(file))).flatMap((file) => { + const text = fs.readFileSync(file, 'utf8'); + return [ + ...(text.includes('dangerouslySetInnerHTML') ? [{ file: relative(ctx.config.root, file), construct: 'dangerouslySetInnerHTML' }] : []), + ...(text.match(/\beval\s*\(/) ? [{ file: relative(ctx.config.root, file), construct: 'eval' }] : []), + ]; + }); + finding(ctx, { + id: 'security.dangerous-constructs', category: 'security', title: 'Dangerous runtime constructs are inventoried', + status: dangerous.length ? 'WARN' : 'PASS', severity: 'medium', expected: 'No eval or unreviewed raw HTML injection', actual: JSON.stringify(dangerous), + reason: dangerous.length ? 'These constructs expand injection risk and require contextual review.' : 'No configured dangerous construct was found.', + remediation: 'Verify sanitization and replace raw execution or HTML injection where possible.', + }); +} diff --git a/verification/src/checks/source-policy.mjs b/verification/src/checks/source-policy.mjs new file mode 100644 index 0000000..d8d40ec --- /dev/null +++ b/verification/src/checks/source-policy.mjs @@ -0,0 +1,37 @@ +import path from 'node:path'; +import { finding } from '../core/finding.mjs'; +import { lineCount, relative, walk, writeJson } from '../core/files.mjs'; + +const codeExt = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.css', '.swift', '.kt', '.kts']); + +function inventory(root, files) { + return files.map((file) => ({ file: relative(root, file), lines: lineCount(file) })).sort((a, b) => b.lines - a.lines); +} + +export async function checkSourcePolicy(ctx) { + const root = ctx.config.root; + const projectFiles = walk(root, (file) => codeExt.has(path.extname(file)) && !file.includes('/verification/')); + const project = inventory(root, projectFiles); + const oversized = project.filter((item) => item.lines > ctx.config.maxSourceLines); + const totalLines = project.reduce((sum, item) => sum + item.lines, 0); + writeJson(path.join(ctx.dirs.metrics, 'source-inventory.json'), { totalLines, files: project, oversized }); + finding(ctx, { + id: 'source.project-line-policy', category: 'source', title: 'Project source files respect the 150-line policy', + status: oversized.length ? 'FAIL' : 'PASS', severity: 'high', expected: `Every source file <= ${ctx.config.maxSourceLines} lines`, + actual: `${oversized.length}/${project.length} files exceed the limit; ${totalLines} total lines`, + reason: oversized.length ? 'Large files concentrate responsibilities and materially increase review and regression risk.' : 'All source files meet the limit.', + impact: oversized.slice(0, 20).map((item) => `${item.file}:${item.lines}`).join(', '), + evidence: [path.join(ctx.dirs.metrics, 'source-inventory.json')], remediation: 'Split oversized business files by cohesive responsibility in a separate change.', + }); + const authored = walk(ctx.config.verifyDir, (file) => !file.includes('/node_modules/') && !file.includes('/artifacts/') && path.basename(file) !== 'package-lock.json'); + const validator = inventory(ctx.config.verifyDir, authored); + const validatorOversized = validator.filter((item) => item.lines > ctx.config.maxSourceLines); + writeJson(path.join(ctx.dirs.metrics, 'verification-inventory.json'), validator); + finding(ctx, { + id: 'source.verification-line-policy', category: 'harness', title: 'Verification files respect the 150-line policy', + status: validatorOversized.length ? 'FAIL' : 'PASS', severity: 'critical', expected: `Every authored verification file <= ${ctx.config.maxSourceLines} lines`, + actual: validatorOversized.length ? JSON.stringify(validatorOversized) : `${validator.length} files comply`, + reason: validatorOversized.length ? 'The delivered validation code violates the explicit file-size constraint.' : 'The validation implementation is partitioned within the limit.', + evidence: [path.join(ctx.dirs.metrics, 'verification-inventory.json')], remediation: 'Split the listed verification files before trusting or publishing the suite.', + }); +} diff --git a/verification/src/checks/static-tools.mjs b/verification/src/checks/static-tools.mjs new file mode 100644 index 0000000..cddb3e4 --- /dev/null +++ b/verification/src/checks/static-tools.mjs @@ -0,0 +1,53 @@ +import fs from 'node:fs'; +import { runCommand, runNpm } from '../core/command.mjs'; +import { finding } from '../core/finding.mjs'; + +function commandFinding(ctx, id, title, result, severity, expected = 'exit code 0') { + const ok = result.code === 0 && !result.timedOut; + finding(ctx, { + id, category: 'static', title, status: ok ? 'PASS' : 'FAIL', severity, expected, + actual: result.timedOut ? `timed out after ${result.durationMs}ms` : `exit code ${result.code}`, + reason: ok ? 'The command completed successfully.' : 'The authoritative project command failed; the raw output contains exact diagnostics.', + impact: ok ? '' : result.tail.slice(-2000), evidence: [result.outputPath], + remediation: 'Resolve every diagnostic in the raw output, then rerun the complete chain.', durationMs: result.durationMs, + }); + return ok; +} + +export async function checkStaticTools(ctx) { + const test = await runNpm(ctx, 'npm-test', ['test']); + ctx.state.testsOk = commandFinding(ctx, 'static.unit-tests', 'Repository unit tests pass', test, 'critical'); + const lint = await runNpm(ctx, 'npm-lint', ['run', 'lint']); + ctx.state.lintOk = commandFinding(ctx, 'static.eslint', 'ESLint reports no errors or warnings', lint, 'high'); + const types = await runCommand(ctx, 'typescript', 'npx', ['--no-install', 'tsc', '--noEmit', '--incremental', 'false']); + ctx.state.typesOk = commandFinding(ctx, 'static.typescript', 'Full TypeScript check passes', types, 'high'); + const integrity = await runNpm(ctx, 'npm-ls', ['ls', '--all', '--json']); + commandFinding(ctx, 'static.dependency-integrity', 'Installed dependency graph is valid', integrity, 'high'); + if (!ctx.config.offline) await checkAudit(ctx); + else finding(ctx, { + id: 'static.npm-audit', category: 'static', title: 'Dependency vulnerability audit', status: 'SKIP', severity: 'high', + expected: 'Online npm audit', actual: '--offline', reason: 'The run explicitly disabled network checks.', remediation: 'Rerun without --offline.', + }); + const build = await runNpm(ctx, 'next-build', ['run', 'build'], { timeoutMs: ctx.config.commandTimeoutMs }); + ctx.state.buildOk = commandFinding(ctx, 'static.production-build', 'Production Next.js build succeeds', build, 'critical'); + if (!ctx.config.quick) { + const pages = await runNpm(ctx, 'cloudflare-pages-build', ['run', 'pages:build'], { timeoutMs: ctx.config.commandTimeoutMs }); + ctx.state.pagesBuildOk = commandFinding(ctx, 'static.cloudflare-build', 'Cloudflare Pages build succeeds', pages, 'critical'); + } +} + +async function checkAudit(ctx) { + const result = await runNpm(ctx, 'npm-audit', ['audit', '--omit=dev', '--json']); + let audit = null; + try { audit = JSON.parse(fs.readFileSync(result.outputPath, 'utf8')); } catch { /* malformed audit output */ } + const vulnerabilities = audit?.metadata?.vulnerabilities || {}; + const severe = (vulnerabilities.critical || 0) + (vulnerabilities.high || 0); + const ok = result.code === 0 && severe === 0; + finding(ctx, { + id: 'static.npm-audit', category: 'security', title: 'Production dependencies have no known high/critical vulnerabilities', + status: ok ? 'PASS' : 'FAIL', severity: 'critical', expected: '0 high and 0 critical vulnerabilities', + actual: audit ? JSON.stringify(vulnerabilities) : `unparseable output; exit ${result.code}`, + reason: ok ? 'npm advisory data reports no severe production vulnerability.' : 'The dependency audit failed or reports severe vulnerabilities.', + evidence: [result.outputPath], remediation: 'Upgrade, replace, or explicitly mitigate every severe advisory.', durationMs: result.durationMs, + }); +} diff --git a/verification/src/checks/ui-actions.mjs b/verification/src/checks/ui-actions.mjs new file mode 100644 index 0000000..2216236 --- /dev/null +++ b/verification/src/checks/ui-actions.mjs @@ -0,0 +1,130 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { finding } from '../core/finding.mjs'; +import { walk, relative, writeJson } from '../core/files.mjs'; +import { log } from '../core/log.mjs'; +import { newPage, stabilize } from '../browser/session.mjs'; +import { performAction, scanActions, stateHash } from '../browser/actions.mjs'; + +async function navigate(page, ctx, route, pathSteps, fixtureFile) { + await page.goto(`${ctx.config.localUrl}${route}`, { waitUntil: 'domcontentloaded', timeout: ctx.config.navigationTimeoutMs }); + await stabilize(page); + for (const action of pathSteps) { + const result = await performAction(page, action, fixtureFile); + if (!result.ok) return result; + await page.waitForTimeout(250); + } + return { ok: true }; +} + +function staysWithinRoute(ctx, route, action, urlAfter) { + const start = new URL(route, ctx.config.localUrl); + const target = action.href ? new URL(action.href, start) : new URL(urlAfter); + return target.origin === start.origin && target.pathname === start.pathname; +} + +export async function checkUiActions(ctx) { + if (!ctx.state.browser || !ctx.state.pageRoutes) return; + const session = await newPage(ctx.state.browser, ctx, { width: 1440, height: 1000 }); + await session.context.tracing.start({ screenshots: true, snapshots: true, sources: true }); + const fixtureFile = path.join(ctx.dirs.raw, 'import-fixture.json'); + fs.writeFileSync(fixtureFile, JSON.stringify({ settings: { sources: [] } })); + const results = []; + const cappedRoutes = []; + for (const route of ctx.state.pageRoutes) { + const routeStart = results.length; + const queue = [[]]; + const seenStates = new Set(); + const testedActions = new Set(); + let routeActions = 0; + let hitCap = false; + log(ctx, 'info', 'ui.action-route.start', 'Starting recursive runtime control exploration', { route }); + while (queue.length && (ctx.config.quick ? results.length : routeActions) < ctx.config.maxActionStates) { + const steps = queue.shift(); + const stepKeys = steps.map((item) => item.key); + const replay = await navigate(session.page, ctx, route, steps, fixtureFile); + if (!replay.ok) { + results.push({ route, depth: steps.length, steps: stepKeys, phase: 'state-replay', result: replay }); + log(ctx, 'error', 'ui.action-failure', 'State path replay failed', { route, steps: stepKeys, result: replay }); + await session.page.screenshot({ path: path.join(ctx.dirs.screenshots, `action-failure-${results.length}.png`), fullPage: true }).catch(() => {}); + continue; + } + const actions = await scanActions(session.page); + const hash = stateHash(session.page.url(), actions); + if (seenStates.has(hash)) continue; + seenStates.add(hash); + for (const action of actions) { + if (testedActions.has(action.key)) continue; + if ((ctx.config.quick ? results.length : routeActions) >= ctx.config.maxActionStates) { + hitCap = true; + break; + } + testedActions.add(action.key); + routeActions += 1; + const reset = await navigate(session.page, ctx, route, steps, fixtureFile); + if (!reset.ok) { + results.push({ route, state: hash, depth: steps.length, steps: stepKeys, phase: 'action-reset', action, result: reset }); + log(ctx, 'error', 'ui.action-failure', 'Action state reset failed', { route, state: hash, steps: stepKeys, action, result: reset }); + continue; + } + let result; + try { result = await performAction(session.page, action, fixtureFile); await session.page.waitForTimeout(300); } + catch (error) { result = { ok: false, reason: error instanceof Error ? error.message : String(error) }; } + const after = result.ok ? await scanActions(session.page) : []; + const changed = result.ok && stateHash(session.page.url(), after) !== hash; + results.push({ route, state: hash, depth: steps.length, steps: stepKeys, action, result, changed, urlAfter: session.page.url() }); + if (!result.ok) { + log(ctx, 'error', 'ui.action-failure', 'Runtime control interaction failed', { route, state: hash, depth: steps.length, steps: stepKeys, action, result, urlAfter: session.page.url() }); + await session.page.screenshot({ path: path.join(ctx.dirs.screenshots, `action-failure-${results.length}.png`), fullPage: true }).catch(() => {}); + } + if (changed && steps.length < ctx.config.maxActionDepth && staysWithinRoute(ctx, route, action, session.page.url())) { + queue.push([...steps, action]); + } + } + } + const capped = hitCap || queue.length > 0; + if (capped) cappedRoutes.push(route); + log(ctx, 'info', 'ui.action-route.end', 'Finished recursive runtime control exploration', { + route, actions: results.length - routeStart, uniqueActions: testedActions.size, + uniqueStates: seenStates.size, pendingStates: queue.length, capped, + }); + if (ctx.config.quick && results.length >= ctx.config.maxActionStates) break; + } + const trace = path.join(ctx.dirs.traces, 'ui-actions.zip'); + await session.context.tracing.stop({ path: trace }); + await session.context.close(); + const declared = sourceActionInventory(ctx); + const target = path.join(ctx.dirs.raw, 'ui-actions.json'); + writeJson(target, { results, declared, cappedRoutes, observed: session.observed }); + const failures = results.filter((item) => item.result && !item.result.ok); + const skipped = results.filter((item) => item.result?.skipped); + const uniqueRuntime = new Set(results.filter((item) => item.action).map((item) => `${item.route}|${item.action.key}`)).size; + finding(ctx, { + id: 'ui.action-execution', category: 'ui', title: 'Every discovered runtime control accepts its intended interaction', + status: failures.length ? 'FAIL' : 'PASS', severity: 'critical', expected: '0 click/fill/select/upload failures', + actual: failures.length ? JSON.stringify(failures.slice(0, 30)) : `${results.length - skipped.length} operated; ${skipped.length} disabled/skipped`, + reason: failures.length ? 'A visible runtime control could not be replayed or operated.' : 'All discovered controls were exercised without automation failure.', + evidence: [target, trace], remediation: 'Repair unstable selectors, disabled-state logic, click handlers, or the underlying UI exception.', + }); + finding(ctx, { + id: 'ui.action-state-coverage', category: 'ui', title: 'Recursive UI state exploration exhausts its queue', + status: cappedRoutes.length ? (ctx.config.quick ? 'SKIP' : 'FAIL') : 'PASS', severity: 'high', expected: `Queue exhausted below ${ctx.config.maxActionStates} actions per route and depth ${ctx.config.maxActionDepth}`, + actual: cappedRoutes.length ? `Coverage cap reached on ${cappedRoutes.join(', ')} after ${results.length} actions` : `${results.length} actions; queue exhausted`, + reason: cappedRoutes.length ? (ctx.config.quick ? 'Quick mode intentionally limits exploration.' : 'Unexplored reachable states remain, so “all buttons” cannot be claimed.') : 'No additional state-changing action remained within the discovered graph.', + evidence: [target], remediation: 'Raise limits or split workflows until every reachable state is exhausted.', + }); + finding(ctx, { + id: 'ui.action-inventory', category: 'ui', title: 'Static and runtime interaction inventory is recorded', + status: 'INFO', severity: 'info', expected: 'Every source declaration and discovered runtime control remains auditable', + actual: JSON.stringify({ staticDeclarationSites: declared.length, runtimeActionInstances: results.length, uniqueRuntimeControls: uniqueRuntime, disabledOrSkipped: skipped.length, failures: failures.length, cappedRoutes }), + reason: 'Static declaration sites and runtime controls are different populations; the evidence preserves both without claiming a false one-to-one mapping.', + evidence: [target], remediation: 'Use file/line declarations and trace-backed runtime states to investigate controls absent from reachable test states.', + }); +} + +function sourceActionInventory(ctx) { + const files = walk(ctx.config.root, (file) => /\.(tsx|jsx)$/.test(file) && !file.includes('/verification/')); + return files.flatMap((file) => fs.readFileSync(file, 'utf8').split(/\r?\n/).flatMap((line, index) => + /<(button|input|select|textarea)|onClick=|role=["']button/.test(line) ? [{ file: relative(ctx.config.root, file), line: index + 1 }] : [], + )); +} diff --git a/verification/src/checks/ui-pages.mjs b/verification/src/checks/ui-pages.mjs new file mode 100644 index 0000000..6a44cac --- /dev/null +++ b/verification/src/checks/ui-pages.mjs @@ -0,0 +1,59 @@ +import path from 'node:path'; +import { finding } from '../core/finding.mjs'; +import { writeJson } from '../core/files.mjs'; +import { launchBrowser, newPage, stabilize } from '../browser/session.mjs'; +import { discoverPages } from '../browser/routes.mjs'; +import { pageMetrics } from '../browser/metrics.mjs'; +import { scanAxe } from '../browser/axe.mjs'; + +function safeName(value) { + return value.replace(/^\//, '').replace(/[^a-zA-Z0-9]+/g, '-') || 'home'; +} + +export async function checkUiPages(ctx) { + if (!ctx.state.appReady) return; + const browser = ctx.state.browser || await launchBrowser(ctx); + ctx.state.browser = browser; + const routes = discoverPages(ctx); + ctx.state.pageRoutes = routes; + const results = []; + for (const viewport of ctx.config.viewports) { + for (const route of routes) { + const session = await newPage(browser, ctx, viewport); + let response = null; + let axe = { violations: [], incomplete: [] }; + try { + response = await session.page.goto(`${ctx.config.localUrl}${route}`, { waitUntil: 'domcontentloaded', timeout: ctx.config.navigationTimeoutMs }); + await stabilize(session.page); + axe = await scanAxe(session.page, ctx); + const screenshot = path.join(ctx.dirs.screenshots, `${viewport.name}-${safeName(route)}.png`); + await session.page.screenshot({ path: screenshot, fullPage: true }); + results.push({ viewport, route, status: response?.status() || 0, metrics: await pageMetrics(session.page), axe, observed: session.observed, screenshot }); + } catch (error) { + results.push({ viewport, route, status: response?.status() || 0, error: error instanceof Error ? error.stack || error.message : String(error), axe, observed: session.observed }); + } finally { + await session.context.close(); + } + } + } + const target = path.join(ctx.dirs.raw, 'ui-pages.json'); + writeJson(target, results); + const renderFailures = results.filter((item) => item.error || item.status >= 400 || item.status === 0); + const runtimeErrors = results.filter((item) => item.metrics?.errors?.length || item.metrics?.rejections?.length || item.observed.pageErrors.length || item.observed.consoleErrors.length); + const overflow = results.filter((item) => item.metrics && item.metrics.scrollWidth > item.metrics.clientWidth + 1); + const severeA11y = results.flatMap((item) => item.axe.violations.filter((violation) => ['critical', 'serious'].includes(violation.impact)).map((violation) => ({ route: item.route, viewport: item.viewport.name, ...violation }))); + aggregate(ctx, 'ui.route-render', 'Every page renders in every target viewport', renderFailures, results.length, 'critical', target); + aggregate(ctx, 'ui.runtime-errors', 'Pages emit no uncaught or console errors', runtimeErrors, results.length, 'critical', target); + aggregate(ctx, 'ui.horizontal-overflow', 'Pages do not overflow target viewports horizontally', overflow, results.length, 'high', target); + aggregate(ctx, 'ui.accessibility', 'Pages have no serious or critical automated accessibility violations', severeA11y, results.length, 'high', target); + ctx.state.uiPages = results; +} + +function aggregate(ctx, id, title, failures, total, severity, evidence) { + finding(ctx, { + id, category: 'ui', title, status: failures.length ? 'FAIL' : 'PASS', severity, expected: `0 failures across ${total} page/viewport cases`, + actual: failures.length ? JSON.stringify(failures.slice(0, 30)) : `${total} cases passed`, + reason: failures.length ? 'At least one enumerated page state violated the declared UI contract.' : 'Every enumerated page state met the contract.', + evidence: [evidence], remediation: 'Open the named screenshot and evidence record, reproduce the exact route/viewport, and repair the underlying component.', + }); +} diff --git a/verification/src/checks/video.mjs b/verification/src/checks/video.mjs new file mode 100644 index 0000000..a783f6b --- /dev/null +++ b/verification/src/checks/video.mjs @@ -0,0 +1,79 @@ +import path from 'node:path'; +import { finding } from '../core/finding.mjs'; +import { writeJson } from '../core/files.mjs'; +import { newPage, stabilize } from '../browser/session.mjs'; + +async function playCase(ctx, episode) { + const session = await newPage(ctx.state.browser, ctx, { width: 1440, height: 1000 }); + const route = `/player?id=fixture-video-1&source=fixture&title=%E9%AA%8C%E8%AF%81%E8%A7%86%E9%A2%91&episode=${episode}`; + try { + await session.page.goto(`${ctx.config.localUrl}${route}`, { waitUntil: 'domcontentloaded', timeout: ctx.config.navigationTimeoutMs }); + await stabilize(session.page); + await session.page.locator('video').waitFor({ state: 'attached', timeout: 20_000 }); + const before = await session.page.locator('video').evaluate(async (video) => { + video.muted = true; await video.play(); + return { currentTime: video.currentTime, readyState: video.readyState, networkState: video.networkState }; + }); + await session.page.waitForTimeout(2600); + const after = await session.page.locator('video').evaluate((video) => { + const quality = video.getVideoPlaybackQuality?.(); + return { currentTime: video.currentTime, duration: video.duration, paused: video.paused, readyState: video.readyState, + width: video.videoWidth, height: video.videoHeight, totalFrames: quality?.totalVideoFrames, droppedFrames: quality?.droppedVideoFrames, error: video.error?.message || null }; + }); + await session.page.screenshot({ path: path.join(ctx.dirs.screenshots, `video-episode-${episode}.png`), fullPage: true }); + return { before, after, observed: session.observed }; + } catch (error) { return { error: error instanceof Error ? error.stack || error.message : String(error), observed: session.observed }; } + finally { await session.context.close(); } +} + +async function stallCase(ctx) { + const session = await newPage(ctx.state.browser, ctx, { width: 1440, height: 1000 }); + try { + await session.page.goto(`${ctx.config.localUrl}/player?id=fixture-video-1&source=fixture&title=stall&episode=0`, { waitUntil: 'domcontentloaded' }); + await stabilize(session.page); + const video = session.page.locator('video'); + await video.waitFor({ state: 'attached', timeout: 20_000 }); + await video.evaluate(async (element) => { element.muted = true; await element.play(); }); + await session.page.waitForTimeout(600); + await video.evaluate((element) => { + const descriptor = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'currentTime'); + const frozen = descriptor.get.call(element); + window.__kvFrozen = true; + Object.defineProperty(element, 'currentTime', { configurable: true, get() { return window.__kvFrozen ? frozen : descriptor.get.call(this); }, set(value) { descriptor.set.call(this, value); } }); + }); + await session.page.waitForTimeout(600); + const detected = await session.page.locator('.loading-overlay-glass').isVisible().catch(() => false); + await video.evaluate((element) => { window.__kvFrozen = false; delete element.currentTime; }); + await session.page.waitForTimeout(500); + const recovered = !(await session.page.locator('.loading-overlay-glass').isVisible().catch(() => false)); + return { detected, recovered }; + } catch (error) { return { error: error instanceof Error ? error.message : String(error) }; } + finally { await session.context.close(); } +} + +export async function checkVideo(ctx) { + if (!ctx.state.browser || !ctx.state.mediaOk) return; + const mp4 = await playCase(ctx, 0); + const hls = await playCase(ctx, 1); + const stall = await stallCase(ctx); + const target = path.join(ctx.dirs.raw, 'video-playback.json'); + writeJson(target, { mp4, hls, stall }); + for (const [name, result] of Object.entries({ mp4, hls })) { + const advance = result.after ? result.after.currentTime - result.before.currentTime : 0; + const dropped = result.after?.droppedFrames || 0; + const total = result.after?.totalFrames || 0; + const ok = !result.error && advance >= ctx.config.minVideoAdvanceSeconds && result.after?.width === 640 && result.after?.height === 360 && (!total || dropped / total <= .05); + finding(ctx, { + id: `video.${name}`, category: 'video', title: `${name.toUpperCase()} fixture plays with correct resolution and low frame loss`, + status: ok ? 'PASS' : 'FAIL', severity: 'critical', expected: `advance >=${ctx.config.minVideoAdvanceSeconds}s, 640x360, dropped <=5%`, + actual: JSON.stringify({ advance, result }), reason: ok ? 'Real browser media playback met timing and quality thresholds.' : 'Playback stalled, decoded incorrectly, errored, or dropped excessive frames.', + evidence: [target], remediation: 'Inspect player events, HLS configuration, proxy mode, codec support, and browser console evidence.', + }); + } + finding(ctx, { + id: 'video.stall-detector', category: 'video', title: '200ms stall detector shows and clears the loading overlay', + status: stall.detected && stall.recovered ? 'PASS' : 'FAIL', severity: 'high', expected: 'Overlay appears while currentTime is frozen and disappears after recovery', + actual: JSON.stringify(stall), reason: stall.detected && stall.recovered ? 'The live player responded to a controlled playback freeze.' : 'Stall detection or recovery UI did not transition correctly.', + evidence: [target], remediation: 'Repair currentTime polling, loading state ownership, or recovery clearing logic.', + }); +} diff --git a/verification/src/checks/visual.mjs b/verification/src/checks/visual.mjs new file mode 100644 index 0000000..2de50da --- /dev/null +++ b/verification/src/checks/visual.mjs @@ -0,0 +1,84 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import pixelmatch from 'pixelmatch'; +import { PNG } from 'pngjs'; +import { finding } from '../core/finding.mjs'; +import { writeJson } from '../core/files.mjs'; +import { newPage, stabilize } from '../browser/session.mjs'; + +function safe(value) { + return value.replace(/^\//, '').replace(/[^a-zA-Z0-9]+/g, '-') || 'home'; +} + +function providerSpecific(route) { + return route === '/iptv' || route.endsWith('/settings'); +} + +function padded(image, width, height) { + const output = new PNG({ width, height, fill: true }); + PNG.bitblt(image, output, 0, 0, image.width, image.height, 0, 0); + return output; +} + +function compare(leftFile, rightFile, diffFile) { + const leftRaw = PNG.sync.read(fs.readFileSync(leftFile)); + const rightRaw = PNG.sync.read(fs.readFileSync(rightFile)); + const width = Math.max(leftRaw.width, rightRaw.width); + const height = Math.max(leftRaw.height, rightRaw.height); + const left = padded(leftRaw, width, height); + const right = padded(rightRaw, width, height); + const diff = new PNG({ width, height }); + const pixels = pixelmatch(left.data, right.data, diff.data, width, height, { threshold: 0.12, includeAA: false }); + fs.writeFileSync(diffFile, PNG.sync.write(diff)); + return { pixels, total: width * height, ratio: pixels / (width * height), dimensions: { left: [leftRaw.width, leftRaw.height], right: [rightRaw.width, rightRaw.height] } }; +} + +async function signature(page) { + const value = await page.evaluate(() => [...document.body.querySelectorAll('*')].map((element) => { + const text = element.children.length ? '' : (element.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 80); + return `${element.tagName}:${element.getAttribute('role') || ''}:${element.getAttribute('aria-label') || ''}:${text}`; + }).join('\n')); + return crypto.createHash('sha256').update(value).digest('hex'); +} + +async function capture(browser, ctx, base, route, viewport, file) { + const session = await newPage(browser, ctx, viewport); + try { + const response = await session.page.goto(`${base}${route}`, { waitUntil: 'domcontentloaded', timeout: ctx.config.navigationTimeoutMs }); + await stabilize(session.page); + await session.page.screenshot({ path: file, fullPage: true }); + return { status: response?.status() || 0, signature: await signature(session.page), observed: session.observed }; + } finally { await session.context.close(); } +} + +export async function checkVisual(ctx) { + if (ctx.config.offline || !ctx.state.browser || !ctx.state.pageRoutes) return finding(ctx, { + id: 'visual.deployment-diff', category: 'visual', title: 'Local and Cloudflare UI visual comparison', status: 'SKIP', severity: 'high', + expected: 'Online reference and browser available', actual: ctx.config.offline ? '--offline' : 'browser unavailable', reason: 'Pixel comparison requires both surfaces.', remediation: 'Rerun online after local startup.', + }); + const routes = ctx.config.quick ? ['/'] : ctx.state.pageRoutes; + const viewports = ctx.config.quick ? [ctx.config.viewports[0]] : ctx.config.viewports.filter((item) => ['mobile', 'desktop'].includes(item.name)); + const results = []; + for (const viewport of viewports) for (const route of routes) { + const stem = `${viewport.name}-${safe(route)}`; + const local = path.join(ctx.dirs.screenshots, `visual-local-${stem}.png`); + const remote = path.join(ctx.dirs.screenshots, `visual-remote-${stem}.png`); + const diff = path.join(ctx.dirs.diffs, `visual-diff-${stem}.png`); + try { + const localMeta = await capture(ctx.state.browser, ctx, ctx.config.localUrl, route, viewport, local); + const remoteMeta = await capture(ctx.state.browser, ctx, ctx.config.referenceUrl, route, viewport, remote); + results.push({ route, viewport: viewport.name, localMeta, remoteMeta, comparison: compare(local, remote, diff), local, remote, diff }); + } catch (error) { results.push({ route, viewport: viewport.name, error: error instanceof Error ? error.message : String(error) }); } + } + const target = path.join(ctx.dirs.raw, 'visual-comparison.json'); + writeJson(target, results); + const unexpected = results.filter((item) => item.error || (!providerSpecific(item.route) && item.comparison?.ratio > ctx.config.visualDiffRatio)); + finding(ctx, { + id: 'visual.deployment-diff', category: 'visual', title: 'Local and Cloudflare UI stay within visual deviation threshold', + status: unexpected.length ? 'FAIL' : 'PASS', severity: 'high', expected: `Pixel difference <= ${ctx.config.visualDiffRatio * 100}% outside provider-specific routes`, + actual: JSON.stringify(results.map((item) => ({ route: item.route, viewport: item.viewport, ratio: item.comparison?.ratio, signaturesMatch: item.localMeta?.signature === item.remoteMeta?.signature, error: item.error }))), + reason: unexpected.length ? 'Unexpected deployment-specific visual drift exceeds the configured threshold.' : 'Common UI surfaces match; provider-specific differences remain recorded.', + evidence: [target, ctx.dirs.diffs], remediation: 'Inspect diff images, stabilize dynamic content, and reconcile deployment build/runtime differences.', + }); +} diff --git a/verification/src/config.mjs b/verification/src/config.mjs new file mode 100644 index 0000000..a7021ee --- /dev/null +++ b/verification/src/config.mjs @@ -0,0 +1,53 @@ +import path from 'node:path'; + +const viewports = [ + { name: 'mobile', width: 390, height: 844 }, + { name: 'tablet', width: 820, height: 1180 }, + { name: 'desktop', width: 1440, height: 1000 }, + { name: 'tv', width: 1920, height: 1080 }, +]; + +function valueAfter(args, flag) { + const index = args.indexOf(flag); + return index >= 0 ? args[index + 1] : undefined; +} + +function positive(value, fallback) { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +export function getConfig(argv) { + const args = argv.slice(2); + const root = path.resolve(valueAfter(args, '--root') || process.cwd()); + const verifyDir = path.join(root, 'verification'); + const quick = args.includes('--quick'); + const offline = args.includes('--offline'); + return { + args, + root, + verifyDir, + quick, + offline, + keepServer: args.includes('--keep-server'), + strict: !args.includes('--non-strict'), + referenceUrl: valueAfter(args, '--reference-url') || 'https://kvideo.pages.dev', + localUrl: valueAfter(args, '--base-url') || 'http://127.0.0.1:34173', + fixtureUrl: 'http://127.0.0.1:34174', + containerUrl: 'http://127.0.0.1:34175', + localPort: 34173, + fixturePort: 34174, + containerPort: 34175, + maxSourceLines: 150, + maxActionStates: quick ? 30 : positive(valueAfter(args, '--max-actions'), 5000), + maxActionDepth: quick ? 1 : positive(valueAfter(args, '--max-action-depth'), 8), + commandTimeoutMs: quick ? 180_000 : 900_000, + navigationTimeoutMs: 30_000, + visualDiffRatio: 0.02, + maxLcpMs: 2500, + maxCls: 0.1, + maxLongTaskMs: 500, + minVideoAdvanceSeconds: 1.2, + viewports: quick ? [viewports[2]] : viewports, + }; +} diff --git a/verification/src/core/command.mjs b/verification/src/core/command.mjs new file mode 100644 index 0000000..f3494f0 --- /dev/null +++ b/verification/src/core/command.mjs @@ -0,0 +1,56 @@ +import fs from 'node:fs'; +import { spawn } from 'node:child_process'; +import { rawPath } from './log.mjs'; + +function terminate(child, signal) { + if (!child.pid || child.killed) return; + try { + process.kill(-child.pid, signal); + } catch { + try { child.kill(signal); } catch { /* process already ended */ } + } +} + +export function runCommand(ctx, name, command, args = [], options = {}) { + const started = Date.now(); + const outputPath = rawPath(ctx, `${name}.log`); + const stream = fs.createWriteStream(outputPath, { flags: 'w' }); + const env = { ...process.env, ...(options.env || {}) }; + return new Promise((resolve) => { + const child = spawn(command, args, { + cwd: options.cwd || ctx.config.root, + env, + detached: process.platform !== 'win32', + stdio: ['ignore', 'pipe', 'pipe'], + }); + let tail = ''; + let timedOut = false; + const capture = (chunk) => { + const text = chunk.toString(); + stream.write(text); + tail = `${tail}${text}`.slice(-12_000); + if (options.live) process.stdout.write(text); + }; + child.stdout.on('data', capture); + child.stderr.on('data', capture); + const timeout = setTimeout(() => { + timedOut = true; + terminate(child, 'SIGTERM'); + setTimeout(() => terminate(child, 'SIGKILL'), 3000).unref(); + }, options.timeoutMs || ctx.config.commandTimeoutMs); + child.on('error', (error) => { + clearTimeout(timeout); + stream.end(); + resolve({ code: 127, error: error.message, tail, outputPath, timedOut, durationMs: Date.now() - started }); + }); + child.on('exit', (code, signal) => { + clearTimeout(timeout); + stream.end(); + resolve({ code: code ?? 1, signal, tail, outputPath, timedOut, durationMs: Date.now() - started }); + }); + }); +} + +export async function runNpm(ctx, name, args, options = {}) { + return runCommand(ctx, name, 'npm', args, options); +} diff --git a/verification/src/core/context.mjs b/verification/src/core/context.mjs new file mode 100644 index 0000000..c54e620 --- /dev/null +++ b/verification/src/core/context.mjs @@ -0,0 +1,32 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { redact } from './redact.mjs'; + +function safeRunId() { + return new Date().toISOString().replace(/[:.]/g, '-'); +} + +export function createContext(config) { + const runId = safeRunId(); + const artifacts = path.join(config.verifyDir, 'artifacts', runId); + const dirs = Object.fromEntries( + ['raw', 'screenshots', 'diffs', 'traces', 'metrics', 'media'].map((name) => { + const target = path.join(artifacts, name); + fs.mkdirSync(target, { recursive: true }); + return [name, target]; + }), + ); + const ctx = { + config, + runId, + artifacts, + dirs, + startedAt: new Date().toISOString(), + findings: [], + events: [], + services: [], + state: {}, + }; + fs.writeFileSync(path.join(artifacts, 'config.json'), JSON.stringify(redact(config), null, 2)); + return ctx; +} diff --git a/verification/src/core/files.mjs b/verification/src/core/files.mjs new file mode 100644 index 0000000..a35b680 --- /dev/null +++ b/verification/src/core/files.mjs @@ -0,0 +1,36 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const ignored = new Set(['.git', '.next', '.vercel', '.wrangler', 'node_modules', 'artifacts']); + +export function walk(root, predicate = () => true) { + const output = []; + const visit = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (ignored.has(entry.name)) continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) visit(full); + else if (predicate(full)) output.push(full); + } + }; + visit(root); + return output; +} + +export function relative(root, file) { + return path.relative(root, file).split(path.sep).join('/'); +} + +export function lineCount(file) { + const text = fs.readFileSync(file, 'utf8'); + return text.length === 0 ? 0 : text.split(/\r?\n/).length; +} + +export function writeJson(file, value) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(value, null, 2)); +} + +export function readJson(file) { + return JSON.parse(fs.readFileSync(file, 'utf8')); +} diff --git a/verification/src/core/finding.mjs b/verification/src/core/finding.mjs new file mode 100644 index 0000000..efb5e13 --- /dev/null +++ b/verification/src/core/finding.mjs @@ -0,0 +1,34 @@ +import { log } from './log.mjs'; + +const validStatus = new Set(['PASS', 'FAIL', 'WARN', 'SKIP', 'INFO']); +const validSeverity = new Set(['critical', 'high', 'medium', 'low', 'info']); + +export function finding(ctx, input) { + const item = { + id: input.id, + category: input.category || 'general', + title: input.title, + status: validStatus.has(input.status) ? input.status : 'INFO', + severity: validSeverity.has(input.severity) ? input.severity : 'info', + expected: input.expected ?? null, + actual: input.actual ?? null, + reason: input.reason || '', + impact: input.impact || '', + remediation: input.remediation || '', + evidence: input.evidence || [], + durationMs: Math.round(input.durationMs || 0), + at: new Date().toISOString(), + }; + ctx.findings.push(item); + log(ctx, item.status === 'FAIL' ? 'error' : 'info', item.id, item.title, { + status: item.status, + severity: item.severity, + actual: item.actual, + reason: item.reason, + }); + return item; +} + +export function hasFailures(ctx) { + return ctx.findings.some((item) => item.status === 'FAIL'); +} diff --git a/verification/src/core/http.mjs b/verification/src/core/http.mjs new file mode 100644 index 0000000..56a1023 --- /dev/null +++ b/verification/src/core/http.mjs @@ -0,0 +1,32 @@ +export async function request(url, options = {}) { + const started = performance.now(); + const { timeoutMs = 20_000, ...fetchOptions } = options; + try { + const response = await fetch(url, { + redirect: fetchOptions.redirect || 'follow', + ...fetchOptions, + signal: AbortSignal.timeout(timeoutMs), + }); + const buffer = Buffer.from(await response.arrayBuffer()); + return { + ok: true, + status: response.status, + durationMs: Math.round(performance.now() - started), + headers: Object.fromEntries(response.headers), + bytes: buffer.length, + body: buffer.toString('utf8', 0, Math.min(buffer.length, 100_000)), + }; + } catch (error) { + return { + ok: false, + status: 0, + durationMs: Math.round(performance.now() - started), + error: error instanceof Error ? error.message : String(error), + headers: {}, bytes: 0, body: '', + }; + } +} + +export function jsonBody(result) { + try { return JSON.parse(result.body); } catch { return null; } +} diff --git a/verification/src/core/log.mjs b/verification/src/core/log.mjs new file mode 100644 index 0000000..d28c720 --- /dev/null +++ b/verification/src/core/log.mjs @@ -0,0 +1,25 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { redact, redactText } from './redact.mjs'; + +export function log(ctx, level, event, message, data = {}) { + const entry = { + at: new Date().toISOString(), + level, + event, + message: redactText(message), + data: redact(data), + }; + ctx.events.push(entry); + fs.appendFileSync(path.join(ctx.artifacts, 'events.ndjson'), `${JSON.stringify(entry)}\n`); + const suffix = Object.keys(entry.data).length ? ` ${JSON.stringify(entry.data)}` : ''; + fs.appendFileSync( + path.join(ctx.artifacts, 'run.log'), + `${entry.at} ${level.toUpperCase()} ${event} ${entry.message}${suffix}\n`, + ); + process.stdout.write(`[${level.toUpperCase()}] ${message}\n`); +} + +export function rawPath(ctx, name) { + return path.join(ctx.dirs.raw, name.replace(/[^a-zA-Z0-9_.-]+/g, '-')); +} diff --git a/verification/src/core/redact.mjs b/verification/src/core/redact.mjs new file mode 100644 index 0000000..63c5409 --- /dev/null +++ b/verification/src/core/redact.mjs @@ -0,0 +1,22 @@ +const secretKey = /(password|passwd|token|secret|authorization|cookie|api[-_]?key)/i; +const bearer = /\b(Bearer\s+)[A-Za-z0-9._~+/=-]+/gi; +const githubToken = /\b(gh[opusr]_[A-Za-z0-9_]{20,})\b/g; +const jwt = /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g; + +export function redactText(value) { + return String(value) + .replace(bearer, '$1[REDACTED]') + .replace(githubToken, '[REDACTED_GITHUB_TOKEN]') + .replace(jwt, '[REDACTED_JWT]') + .replace(/([?&](?:token|key|secret|password)=)[^&#\s]+/gi, '$1[REDACTED]'); +} + +export function redact(value, key = '') { + if (secretKey.test(key)) return '[REDACTED]'; + if (typeof value === 'string') return redactText(value); + if (Array.isArray(value)) return value.map((item) => redact(item)); + if (!value || typeof value !== 'object') return value; + return Object.fromEntries( + Object.entries(value).map(([childKey, child]) => [childKey, redact(child, childKey)]), + ); +} diff --git a/verification/src/core/service.mjs b/verification/src/core/service.mjs new file mode 100644 index 0000000..422e871 --- /dev/null +++ b/verification/src/core/service.mjs @@ -0,0 +1,53 @@ +import fs from 'node:fs'; +import { spawn } from 'node:child_process'; +import { rawPath } from './log.mjs'; + +export async function waitForUrl(url, timeoutMs = 60_000) { + const deadline = Date.now() + timeoutMs; + let lastError = ''; + while (Date.now() < deadline) { + try { + const response = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(3000) }); + if (response.status < 500) return { ok: true, status: response.status }; + lastError = `HTTP ${response.status}`; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + return { ok: false, error: lastError }; +} + +export async function startProcess(ctx, name, command, args, options = {}) { + const outputPath = rawPath(ctx, `${name}.log`); + const stream = fs.createWriteStream(outputPath, { flags: 'w' }); + const child = spawn(command, args, { + cwd: options.cwd || ctx.config.root, + env: { ...process.env, ...(options.env || {}) }, + detached: process.platform !== 'win32', + stdio: ['ignore', 'pipe', 'pipe'], + }); + child.stdout.pipe(stream); + child.stderr.pipe(stream); + const service = { name, child, outputPath, stopped: false }; + ctx.services.push(service); + if (options.url) service.ready = await waitForUrl(options.url, options.timeoutMs); + return service; +} + +export function stopProcess(service) { + if (!service?.child?.pid || service.stopped) return; + service.stopped = true; + try { + process.kill(-service.child.pid, 'SIGTERM'); + } catch { + try { service.child.kill('SIGTERM'); } catch { /* already stopped */ } + } +} + +export function stopAll(ctx) { + for (const service of [...ctx.services].reverse()) { + if (typeof service.close === 'function') service.close(); + else stopProcess(service); + } +} diff --git a/verification/src/core/stage.mjs b/verification/src/core/stage.mjs new file mode 100644 index 0000000..3e03231 --- /dev/null +++ b/verification/src/core/stage.mjs @@ -0,0 +1,25 @@ +import { finding } from './finding.mjs'; +import { log } from './log.mjs'; + +export async function runStage(ctx, id, title, task) { + const started = Date.now(); + log(ctx, 'info', `stage.${id}.start`, `Starting ${title}`); + try { + await task(ctx); + log(ctx, 'info', `stage.${id}.end`, `Finished ${title}`, { durationMs: Date.now() - started }); + } catch (error) { + finding(ctx, { + id: `stage.${id}.crash`, + category: 'harness', + title: `${title} crashed`, + status: 'FAIL', + severity: 'critical', + expected: 'Stage completes and records granular findings', + actual: error instanceof Error ? error.stack || error.message : String(error), + reason: 'The verification harness encountered an unhandled exception.', + impact: 'Checks in this stage may be incomplete.', + remediation: 'Inspect the stack trace and repair the harness before trusting this run.', + durationMs: Date.now() - started, + }); + } +} diff --git a/verification/src/core/xml.mjs b/verification/src/core/xml.mjs new file mode 100644 index 0000000..df86fcc --- /dev/null +++ b/verification/src/core/xml.mjs @@ -0,0 +1,8 @@ +export function escapeXml(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} diff --git a/verification/src/fixture/media.mjs b/verification/src/fixture/media.mjs new file mode 100644 index 0000000..35dd062 --- /dev/null +++ b/verification/src/fixture/media.mjs @@ -0,0 +1,33 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { runCommand } from '../core/command.mjs'; +import { finding } from '../core/finding.mjs'; + +export async function createMedia(ctx) { + const mp4 = path.join(ctx.dirs.media, 'test.mp4'); + const hls = path.join(ctx.dirs.media, 'hls'); + fs.mkdirSync(hls, { recursive: true }); + const generate = await runCommand(ctx, 'fixture-media', 'ffmpeg', [ + '-hide_banner', '-loglevel', 'error', '-y', + '-f', 'lavfi', '-i', 'testsrc=size=640x360:rate=30', + '-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=44100', + '-t', '8', '-c:v', 'libx264', '-preset', 'veryfast', '-pix_fmt', 'yuv420p', + '-c:a', 'aac', '-b:a', '96k', '-movflags', '+faststart', mp4, + ], { timeoutMs: 120_000 }); + const segment = generate.code === 0 ? await runCommand(ctx, 'fixture-hls', 'ffmpeg', [ + '-hide_banner', '-loglevel', 'error', '-y', '-i', mp4, + '-c', 'copy', '-hls_time', '2', '-hls_list_size', '0', + '-hls_segment_filename', path.join(hls, 'segment-%03d.seg'), path.join(hls, 'master.m3u8'), + ], { timeoutMs: 120_000 }) : { code: 1, outputPath: generate.outputPath, durationMs: 0 }; + const ok = generate.code === 0 && segment.code === 0 && fs.existsSync(mp4); + finding(ctx, { + id: 'fixture.media', category: 'harness', title: 'Deterministic MP4 and HLS fixtures were generated', + status: ok ? 'PASS' : 'FAIL', severity: 'critical', expected: 'Playable 8-second MP4 and HLS assets', + actual: ok ? `${fs.statSync(mp4).size} bytes` : `ffmpeg exits ${generate.code}/${segment.code}`, + reason: ok ? 'Video checks use locally generated media and do not depend on third-party streams.' : 'Video evidence cannot be produced without deterministic fixtures.', + evidence: [generate.outputPath, segment.outputPath], remediation: 'Install a working ffmpeg with H.264/AAC support.', + durationMs: generate.durationMs + segment.durationMs, + }); + ctx.state.mediaOk = ok; + return { mp4, hls }; +} diff --git a/verification/src/fixture/server.mjs b/verification/src/fixture/server.mjs new file mode 100644 index 0000000..f793c39 --- /dev/null +++ b/verification/src/fixture/server.mjs @@ -0,0 +1,34 @@ +import http from 'node:http'; +import path from 'node:path'; +import { posterSvg, sourceResponse } from './source.mjs'; +import { sendFile } from './static.mjs'; + +function json(response, status, value, headers = {}) { + response.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', ...headers }); + response.end(JSON.stringify(value)); +} + +export async function startFixtureServer(ctx) { + const baseUrl = ctx.config.fixtureUrl; + const server = http.createServer(async (request, response) => { + const url = new URL(request.url || '/', baseUrl); + if (request.method === 'OPTIONS') { response.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': '*' }); response.end(); return; } + if (url.pathname === '/health' || url.pathname === '/fast') { json(response, 200, { ok: true, at: Date.now() }); return; } + if (url.pathname === '/slow') { + const delay = Math.min(Number(url.searchParams.get('ms')) || 250, 5000); + await new Promise((resolve) => setTimeout(resolve, delay)); json(response, 200, { ok: true, delay }); return; + } + if (url.pathname.startsWith('/status/')) { const status = Number(url.pathname.split('/').pop()) || 500; json(response, status, { status }); return; } + if (url.pathname === '/redirect') { response.writeHead(302, { Location: `${baseUrl}/fast` }); response.end(); return; } + if (url.pathname === '/headers') { json(response, 200, { method: request.method, headers: request.headers }); return; } + if (url.pathname === '/source') { json(response, 200, sourceResponse(baseUrl, url.searchParams)); return; } + if (url.pathname === '/poster.svg') { response.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Access-Control-Allow-Origin': '*' }); response.end(posterSvg(url.searchParams.get('item'))); return; } + if (url.pathname === '/test.mp4') { sendFile(request, response, path.join(ctx.dirs.media, 'test.mp4')); return; } + if (url.pathname.startsWith('/hls/')) { sendFile(request, response, path.join(ctx.dirs.media, url.pathname.slice(1))); return; } + json(response, 404, { error: 'fixture route not found', path: url.pathname }); + }); + await new Promise((resolve, reject) => server.once('error', reject).listen(ctx.config.fixturePort, '127.0.0.1', resolve)); + const service = { name: 'fixture-server', close: () => server.close() }; + ctx.services.push(service); + return service; +} diff --git a/verification/src/fixture/source.mjs b/verification/src/fixture/source.mjs new file mode 100644 index 0000000..b0d0736 --- /dev/null +++ b/verification/src/fixture/source.mjs @@ -0,0 +1,31 @@ +export function sourceResponse(baseUrl, searchParams) { + const episodes = `第1集$${baseUrl}/test.mp4#第2集$${baseUrl}/hls/master.m3u8`; + const id = searchParams.get('ids') || 'fixture-video-1'; + const query = searchParams.get('wd') || '验证视频'; + return { + code: 1, + msg: 'ok', + page: 1, + pagecount: 1, + total: 3, + list: [1, 2, 3].map((number) => ({ + vod_id: number === 1 ? id : `fixture-video-${number}`, + vod_name: `${query} ${number}`, + vod_pic: `${baseUrl}/poster.svg?item=${number}`, + vod_remarks: number === 1 ? '全2集' : '测试内容', + vod_year: '2026', + vod_area: 'Fixture', + vod_actor: 'Automated Validator', + vod_director: 'KVideo Verification', + vod_content: 'Deterministic content used only by the local verification suite.', + type_name: '测试', + vod_play_from: 'm3u8', + vod_play_url: episodes, + })), + }; +} + +export function posterSvg(item = '1') { + const safe = String(item).replace(/[^0-9A-Za-z_-]/g, ''); + return `KVideo ${safe}`; +} diff --git a/verification/src/fixture/static.mjs b/verification/src/fixture/static.mjs new file mode 100644 index 0000000..875697c --- /dev/null +++ b/verification/src/fixture/static.mjs @@ -0,0 +1,25 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const types = { + '.mp4': 'video/mp4', '.m3u8': 'application/vnd.apple.mpegurl', '.seg': 'video/mp2t', +}; + +export function sendFile(request, response, file) { + if (!fs.existsSync(file)) { response.writeHead(404); response.end('not found'); return; } + const size = fs.statSync(file).size; + const range = request.headers.range; + const headers = { 'Content-Type': types[path.extname(file)] || 'application/octet-stream', 'Accept-Ranges': 'bytes', 'Access-Control-Allow-Origin': '*' }; + if (!range) { + response.writeHead(200, { ...headers, 'Content-Length': size }); + fs.createReadStream(file).pipe(response); + return; + } + const match = /^bytes=(\d*)-(\d*)$/.exec(range); + if (!match) { response.writeHead(416, { 'Content-Range': `bytes */${size}` }); response.end(); return; } + const start = match[1] ? Number(match[1]) : 0; + const end = match[2] ? Math.min(Number(match[2]), size - 1) : size - 1; + if (start > end || start >= size) { response.writeHead(416, { 'Content-Range': `bytes */${size}` }); response.end(); return; } + response.writeHead(206, { ...headers, 'Content-Length': end - start + 1, 'Content-Range': `bytes ${start}-${end}/${size}` }); + fs.createReadStream(file, { start, end }).pipe(response); +} diff --git a/verification/src/main.mjs b/verification/src/main.mjs new file mode 100644 index 0000000..aca1adf --- /dev/null +++ b/verification/src/main.mjs @@ -0,0 +1,68 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import { getConfig } from './config.mjs'; +import { createContext } from './core/context.mjs'; +import { hasFailures } from './core/finding.mjs'; +import { log } from './core/log.mjs'; +import { runStage } from './core/stage.mjs'; +import { stopAll } from './core/service.mjs'; +import { writeReports } from './report/write.mjs'; +import { checkPreflight } from './checks/preflight.mjs'; +import { checkHarnessSelf } from './checks/harness-self.mjs'; +import { checkSourcePolicy } from './checks/source-policy.mjs'; +import { checkAstMetrics } from './checks/ast-metrics.mjs'; +import { checkImportGraph } from './checks/import-graph.mjs'; +import { checkSecurityScan } from './checks/security-scan.mjs'; +import { checkDuplicates } from './checks/duplicates.mjs'; +import { checkStaticTools } from './checks/static-tools.mjs'; +import { checkDockerLocal } from './checks/docker-local.mjs'; +import { startRuntime } from './checks/runtime-start.mjs'; +import { checkApiDiscovery } from './checks/api-discovery.mjs'; +import { checkApiContracts } from './checks/api-contracts.mjs'; +import { checkProxy } from './checks/proxy.mjs'; +import { checkLatency } from './checks/latency.mjs'; +import { checkSecurityHeaders } from './checks/security-headers.mjs'; +import { checkUiPages } from './checks/ui-pages.mjs'; +import { checkUiActions } from './checks/ui-actions.mjs'; +import { checkPerformance } from './checks/performance.mjs'; +import { checkVideo } from './checks/video.mjs'; +import { checkVisual } from './checks/visual.mjs'; +import { checkDeployment } from './checks/deployment.mjs'; + +const config = getConfig(process.argv); +const ctx = createContext(config); +ctx.state.version = JSON.parse(fs.readFileSync(path.join(config.root, 'package.json'), 'utf8')).version; +process.on('SIGINT', () => { stopAll(ctx); process.exit(130); }); +process.on('SIGTERM', () => { stopAll(ctx); process.exit(143); }); + +async function main() { + log(ctx, 'info', 'run.start', 'KVideo strict verification started', { runId: ctx.runId, root: config.root }); + await runStage(ctx, 'preflight', 'preflight and harness integrity', async () => { + await checkPreflight(ctx); await checkHarnessSelf(ctx); await checkSourcePolicy(ctx); + }); + await runStage(ctx, 'quality', 'structural quality and security analysis', async () => { + await checkAstMetrics(ctx); await checkImportGraph(ctx); await checkSecurityScan(ctx); await checkDuplicates(ctx); + }); + await runStage(ctx, 'static', 'unit, lint, type, dependency, and production builds', checkStaticTools); + await runStage(ctx, 'docker', 'local release container', checkDockerLocal); + await runStage(ctx, 'runtime', 'deterministic fixtures and local application', startRuntime); + await runStage(ctx, 'api', 'API, proxy, latency, headers, and PWA contracts', async () => { + await checkApiDiscovery(ctx); await checkApiContracts(ctx); await checkProxy(ctx); await checkLatency(ctx); await checkSecurityHeaders(ctx); + }); + await runStage(ctx, 'browser', 'UI, accessibility, actions, performance, video, and visual parity', async () => { + await checkUiPages(ctx); await checkUiActions(ctx); await checkPerformance(ctx); await checkVideo(ctx); await checkVisual(ctx); + }); + await runStage(ctx, 'deployment', 'public release consistency', checkDeployment); +} + +try { await main(); } +finally { + if (!config.keepServer) stopAll(ctx); + const counts = writeReports(ctx); + const latest = path.join(config.verifyDir, 'artifacts', 'latest'); + try { if (fs.lstatSync(latest).isSymbolicLink()) fs.unlinkSync(latest); } catch {} + try { fs.symlinkSync(ctx.runId, latest, 'dir'); } catch {} + log(ctx, 'info', 'run.end', 'KVideo strict verification finished', { counts, report: path.join(ctx.artifacts, 'report.html') }); + process.exitCode = hasFailures(ctx) ? 1 : 0; +} diff --git a/verification/src/report/html.mjs b/verification/src/report/html.mjs new file mode 100644 index 0000000..c98eb2c --- /dev/null +++ b/verification/src/report/html.mjs @@ -0,0 +1,27 @@ +import { escapeXml } from '../core/xml.mjs'; + +function findingCard(item) { + const evidence = item.evidence.map((entry) => `
  • ${escapeXml(entry)}
  • `).join(''); + return `
    +
    ${escapeXml(item.id)}${item.status}${item.severity}
    +

    ${escapeXml(item.title)}

    +
    Expected
    ${escapeXml(item.expected)}
    Actual
    ${escapeXml(item.actual)}
    +
    Reason
    ${escapeXml(item.reason)}
    Impact
    ${escapeXml(item.impact)}
    +
    Remediation
    ${escapeXml(item.remediation)}
      ${evidence}
    `; +} + +export function renderHtml(ctx, totals) { + const cards = ctx.findings.map(findingCard).join('\n'); + return ` +KVideo verification ${escapeXml(ctx.runId)} + +

    KVideo strict verification

    Run ${escapeXml(ctx.runId)} · started ${escapeXml(ctx.startedAt)}

    +
    ${Object.entries(totals).map(([key,value]) => `
    ${value}${key}
    `).join('')}
    +

    A pass means the declared check passed. It is not a proof that undiscovered states cannot fail.

    ${cards}
    `; +} diff --git a/verification/src/report/write.mjs b/verification/src/report/write.mjs new file mode 100644 index 0000000..d369ff7 --- /dev/null +++ b/verification/src/report/write.mjs @@ -0,0 +1,45 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { writeJson } from '../core/files.mjs'; +import { escapeXml } from '../core/xml.mjs'; +import { renderHtml } from './html.mjs'; + +function totals(findings) { + return findings.reduce((sum, item) => { + sum[item.status] = (sum[item.status] || 0) + 1; + return sum; + }, { PASS: 0, FAIL: 0, WARN: 0, SKIP: 0, INFO: 0 }); +} + +function renderJunit(ctx, counts) { + const cases = ctx.findings.map((item) => { + const body = item.status === 'FAIL' + ? `${escapeXml(JSON.stringify(item, null, 2))}` + : item.status === 'SKIP' ? '' : ''; + return `${body}`; + }).join('\n'); + return `${cases}`; +} + +function renderMarkdown(ctx, counts) { + const failures = ctx.findings.filter((item) => item.status === 'FAIL'); + const warnings = ctx.findings.filter((item) => item.status === 'WARN'); + const rows = [...failures, ...warnings].map((item) => + `| ${item.status} | ${item.severity} | \`${item.id}\` | ${item.title.replaceAll('|', '\\|')} |`, + ); + return `# KVideo verification ${ctx.runId}\n\n` + + `PASS ${counts.PASS} · FAIL ${counts.FAIL} · WARN ${counts.WARN} · SKIP ${counts.SKIP} · INFO ${counts.INFO}\n\n` + + `A green run proves only the declared checks. Coverage gaps are explicit findings.\n\n` + + `| Status | Severity | Check | Result |\n|---|---|---|---|\n${rows.join('\n') || '| PASS | info | — | No failures or warnings |'}\n`; +} + +export function writeReports(ctx) { + const counts = totals(ctx.findings); + const finishedAt = new Date().toISOString(); + writeJson(path.join(ctx.artifacts, 'findings.json'), ctx.findings); + writeJson(path.join(ctx.artifacts, 'summary.json'), { runId: ctx.runId, startedAt: ctx.startedAt, finishedAt, counts }); + fs.writeFileSync(path.join(ctx.artifacts, 'junit.xml'), renderJunit(ctx, counts)); + fs.writeFileSync(path.join(ctx.artifacts, 'summary.md'), renderMarkdown(ctx, counts)); + fs.writeFileSync(path.join(ctx.artifacts, 'report.html'), renderHtml(ctx, counts)); + return counts; +} diff --git a/verification/tests/core.test.mjs b/verification/tests/core.test.mjs new file mode 100644 index 0000000..01fdf05 --- /dev/null +++ b/verification/tests/core.test.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { numericCandidate, prepareActionState } from '../src/browser/action-state.mjs'; +import { getConfig } from '../src/config.mjs'; +import { redact, redactText } from '../src/core/redact.mjs'; +import { escapeXml } from '../src/core/xml.mjs'; + +test('redacts keyed secrets recursively', () => { + assert.deepEqual(redact({ nested: { password: 'value', safe: 'visible' } }), { + nested: { password: '[REDACTED]', safe: 'visible' }, + }); +}); + +test('redacts bearer, GitHub, JWT, and query credentials', () => { + const token = ['gho', '_abcdefghijklmnopqrstuvwxyz123456'].join(''); + const raw = `Bearer abc.def ${token} ?token=secret-value`; + const result = redactText(raw); + assert.doesNotMatch(result, /abcdefghijklmnopqrstuvwxyz|secret-value/); +}); + +test('escapes XML metacharacters', () => { + assert.equal(escapeXml(`Tom & 'Ada'`), '<a x="1">Tom & 'Ada'</a>'); +}); + +test('chooses valid alternative values for numeric and range inputs', () => { + assert.equal(numericCandidate({ min: '0', max: '1', value: '0.5', step: '0.01' }), '0'); + assert.equal(numericCandidate({ min: '10', max: '100', value: '70', step: '1' }), '55'); +}); + +test('maps play and pause controls to deterministic media preconditions', async () => { + const modes = []; + const page = { evaluate: async (_callback, mode) => modes.push(mode) }; + await prepareActionState(page, { aria: '播放' }); + await prepareActionState(page, { aria: 'Pause' }); + await prepareActionState(page, { aria: '搜索' }); + assert.deepEqual(modes, ['paused', 'playing']); +}); + +test('accepts explicit full-run action budgets', () => { + const config = getConfig(['node', 'verify', '--root', process.cwd(), '--max-actions', '1234', '--max-action-depth', '7']); + assert.equal(config.maxActionStates, 1234); + assert.equal(config.maxActionDepth, 7); +}); diff --git a/verification/tests/files.test.mjs b/verification/tests/files.test.mjs new file mode 100644 index 0000000..3ad4250 --- /dev/null +++ b/verification/tests/files.test.mjs @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { lineCount, readJson, relative, walk, writeJson } from '../src/core/files.mjs'; + +test('file helpers inventory and serialize deterministic fixtures', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kvideo-verify-')); + fs.mkdirSync(path.join(root, 'nested')); + fs.writeFileSync(path.join(root, 'nested', 'file.txt'), 'one\ntwo\n'); + const json = path.join(root, 'result.json'); + writeJson(json, { ok: true }); + assert.equal(lineCount(path.join(root, 'nested', 'file.txt')), 3); + assert.equal(relative(root, path.join(root, 'nested', 'file.txt')), 'nested/file.txt'); + assert.deepEqual(readJson(json), { ok: true }); + assert.equal(walk(root, (file) => file.endsWith('.txt')).length, 1); + fs.rmSync(root, { recursive: true, force: true }); +}); diff --git a/verification/tests/http.test.mjs b/verification/tests/http.test.mjs new file mode 100644 index 0000000..8a7aa6d --- /dev/null +++ b/verification/tests/http.test.mjs @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import http from 'node:http'; +import test from 'node:test'; +import { jsonBody, request } from '../src/core/http.mjs'; + +test('HTTP evidence captures status, headers, body, and timing', async () => { + const server = http.createServer((_request, response) => { + response.writeHead(201, { 'content-type': 'application/json', 'x-test': 'yes' }); + response.end('{"ok":true}'); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + const result = await request(`http://127.0.0.1:${address.port}`); + assert.equal(result.status, 201); + assert.equal(result.headers['x-test'], 'yes'); + assert.deepEqual(jsonBody(result), { ok: true }); + await new Promise((resolve) => server.close(resolve)); +});