test: automated responsive-layout harness (closes #13)
All checks were successful
check / check (push) Successful in 22s
All checks were successful
check / check (push) Successful in 22s
Verifies the mobile layout from #5 with a real browser engine instead of by hand on a phone. make frontend-viewport-test builds dist/, serves it from the same digest-pinned nginx image and the same nginx.conf the shipping container uses, and drives a digest-pinned headless Chrome against it over CDP. Viewport widths are derived from the app's own CSS rather than from a list of phone models: the @media conditions in src/styles.css and any Tailwind responsive prefixes in the markup are parsed, and each breakpoint is tested one pixel below, exactly on, and one pixel above. max-width: 768px matches at 768, and a generic 375px test sails past that boundary entirely. Four anchor viewports are added with stated reasons: a 320px floor, a desktop baseline, and two phone-landscape sizes straddling the breakpoint. Assertions are on computed layout, not screenshots: horizontal overflow, elements past the viewport edge, clipped text (deliberate ellipsis truncation excluded), 44x44 minimum tap targets, and genuine reflow of the host rows checked on both flex-direction and geometry. Probing and gateway detection are asserted to still run at narrow widths, since the early-return mobile path rejected in #8 is what would silently regress. Screenshots are written to tmp/viewport/ as artifacts alongside the results, not as the evidence. puppeteer-core rather than playwright: it is the one variant of either that never downloads or bundles a browser, so the browser stays a digest-pinned image and the npm side is pinned by yarn.lock integrity. The browser container runs on an --internal docker network with no route off the host; the harness answers the app's latency probes itself from a fixed delay table so the rows render a realistic spread of value widths. Kept out of make check: it needs Docker and takes minutes, where make test has to stay under 20 seconds. Every check guards its own presence, so none can pass against a page it is not measuring. The tap-target check in particular would otherwise be inert: an empty undersized set means both "all controls are big enough" and "the selectors have gone stale", and the size comparison alone cannot tell those apart. Each selector therefore declares the minimum number of visible instances the page must contain, per selector rather than in total, so one stale selector out of four fails rather than only all four at once. Layout expectation is likewise refused rather than guessed. A width is narrow when a max-width block matches (desktop-first, what the app does today) or, for a min-width-only mobile-first set, when it falls below every breakpoint; a set mixing both cannot be resolved from the conditions alone, because which block owns the reflow is a property of the rules inside it, so the run fails with an explanation instead of testing the right widths against the wrong expectation. The harness was observed failing before being trusted, four times: a planted 900px fixed-width element in a host row; the mobile reflow rule neutered; the tap-target threshold lowered so nothing was undersized and .pin-btn then renamed, which took the check from 8/8 green at every touch viewport to failing at all six, naming the stale selector; and a second media block added so the breakpoint set mixed max and min, which aborted the run. All reverted. Against the current layout it reports two real defects, filed as #42 (horizontal overflow at 320px) and #43 (tap targets below 44x44).
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
|
tmp
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.log
|
*.log
|
||||||
.claude
|
.claude
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
tmp/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
backend/
|
backend/
|
||||||
dist/
|
dist/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
tmp/
|
||||||
yarn.lock
|
yarn.lock
|
||||||
.claude/
|
.claude/
|
||||||
|
|||||||
9
Makefile
9
Makefile
@@ -1,4 +1,5 @@
|
|||||||
.PHONY: bootstrap setup dev test lint fmt fmt-check check docker hooks
|
.PHONY: bootstrap setup dev test lint fmt fmt-check check \
|
||||||
|
frontend-viewport-test docker hooks
|
||||||
|
|
||||||
# Standard targets are thin shims; the implementations live in script/
|
# Standard targets are thin shims; the implementations live in script/
|
||||||
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
||||||
@@ -28,6 +29,12 @@ fmt-check:
|
|||||||
check:
|
check:
|
||||||
@script/check
|
@script/check
|
||||||
|
|
||||||
|
# Responsive-layout verification in a containerised browser. Kept out of
|
||||||
|
# check: it needs Docker and takes minutes, where make test has to stay
|
||||||
|
# under 20 seconds.
|
||||||
|
frontend-viewport-test:
|
||||||
|
@script/frontend-viewport-test
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
@script/docker
|
@script/docker
|
||||||
|
|
||||||
|
|||||||
14
README.md
14
README.md
@@ -41,11 +41,25 @@ provide:
|
|||||||
- `script/fmt` — format all files (writes)
|
- `script/fmt` — format all files (writes)
|
||||||
- `script/fmt-check` — check formatting (read-only)
|
- `script/fmt-check` — check formatting (read-only)
|
||||||
- `script/check` — run test, lint, and fmt-check
|
- `script/check` — run test, lint, and fmt-check
|
||||||
|
- `script/frontend-viewport-test` — responsive-layout verification of the built
|
||||||
|
frontend in a containerised headless Chrome (see
|
||||||
|
[test/viewport/README.md](test/viewport/README.md)). Not part of
|
||||||
|
`script/check`: it needs Docker and takes minutes.
|
||||||
- `script/docker` — build the Docker image tagged via `script/projectname`
|
- `script/docker` — build the Docker image tagged via `script/projectname`
|
||||||
- `script/cibuild` — CI entrypoint: plain `docker build .`
|
- `script/cibuild` — CI entrypoint: plain `docker build .`
|
||||||
- `script/precommit` — run by the git pre-commit hook; runs `script/check`
|
- `script/precommit` — run by the git pre-commit hook; runs `script/check`
|
||||||
- `script/install-precommit` — install the git pre-commit hook
|
- `script/install-precommit` — install the git pre-commit hook
|
||||||
|
|
||||||
|
## Responsive layout
|
||||||
|
|
||||||
|
The narrow-viewport layout lives in the `max-width: 768px` media block in
|
||||||
|
`src/styles.css`. It is verified automatically by `make frontend-viewport-test`,
|
||||||
|
which drives a digest-pinned headless Chrome against the built `dist/` and
|
||||||
|
asserts on computed layout at widths derived from that CSS — one pixel either
|
||||||
|
side of every breakpoint it declares, plus a 320px floor, a desktop baseline and
|
||||||
|
two landscape sizes. See [test/viewport/README.md](test/viewport/README.md) for
|
||||||
|
what it covers and what it genuinely cannot.
|
||||||
|
|
||||||
## Rationale
|
## Rationale
|
||||||
|
|
||||||
When debugging network issues, it's useful to have a persistent at-a-glance view
|
When debugging network issues, it's useful to have a persistent at-a-glance view
|
||||||
|
|||||||
9
TODO.md
9
TODO.md
@@ -22,6 +22,12 @@ files, so merging it also closes most compliance gaps.
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-08-09: automated responsive-layout harness
|
||||||
|
(`make frontend-viewport-test`): digest-pinned headless Chrome driven over CDP
|
||||||
|
against the built `dist/`, viewport widths derived from the breakpoints in
|
||||||
|
`src/styles.css` (#13). Every check carries a presence guard so none of them
|
||||||
|
can pass against a page it is not actually measuring. Found two real layout
|
||||||
|
defects, filed as #42 and #43
|
||||||
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
|
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
|
||||||
shims, README Entrypoints section
|
shims, README Entrypoints section
|
||||||
- 2026-02-27: backend with buffered zstd-compressed report storage; CI workflow
|
- 2026-02-27: backend with buffered zstd-compressed report storage; CI workflow
|
||||||
@@ -39,6 +45,9 @@ files, so merging it also closes most compliance gaps.
|
|||||||
|
|
||||||
# Future Steps
|
# Future Steps
|
||||||
|
|
||||||
|
- Fix the two layout defects the viewport harness found (#42 horizontal overflow
|
||||||
|
at 320px, #43 tap targets below 44x44), then wire
|
||||||
|
`script/frontend-viewport-test` into CI as its own step
|
||||||
- Compliance top-up as one small commit: add .editorconfig and add the hooks
|
- Compliance top-up as one small commit: add .editorconfig and add the hooks
|
||||||
target to the Makefile
|
target to the Makefile
|
||||||
- After merge, confirm .gitea/workflows/check.yml is on main and CI is green
|
- After merge, confirm .gitea/workflows/check.yml is on main and CI is green
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"autoprefixer": "^10.4.23",
|
"autoprefixer": "^10.4.23",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
|
"puppeteer-core": "25.5.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1"
|
||||||
}
|
}
|
||||||
|
|||||||
102
script/frontend-viewport-test
Executable file
102
script/frontend-viewport-test
Executable file
@@ -0,0 +1,102 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/frontend-viewport-test: verify the responsive layout of the built
|
||||||
|
# frontend in a real browser engine.
|
||||||
|
#
|
||||||
|
# Builds dist/, serves it with the same nginx image and the same nginx.conf
|
||||||
|
# the shipping container uses, points a containerised headless Chrome at it
|
||||||
|
# over CDP, and asserts on computed layout at every viewport width derived
|
||||||
|
# from the app's own CSS. See test/viewport/README.md for what this covers
|
||||||
|
# and what it cannot.
|
||||||
|
#
|
||||||
|
# Deliberately not part of script/check: it needs Docker and takes far
|
||||||
|
# longer than the 20s budget make test has to stay inside.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
|
# chromedp/headless-shell 151.0.7922.109, 2026-08-09
|
||||||
|
BROWSER_IMAGE="chromedp/headless-shell@sha256:2d349b544a1ea6b5b5fd7c0fe99215ff662339c57407ee2e8c0a11af93516b04"
|
||||||
|
# nginx:stable-alpine, 2026-02-22 (the digest Dockerfile ships)
|
||||||
|
SERVER_IMAGE="nginx@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab"
|
||||||
|
# node:22-alpine, 2026-02-22 (the digest Dockerfile builds with)
|
||||||
|
NODE_IMAGE="node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34"
|
||||||
|
|
||||||
|
RUN_ID="$$-$(date +%s)"
|
||||||
|
NETWORK="netwatch-viewport-$RUN_ID"
|
||||||
|
SERVER="netwatch-viewport-server-$RUN_ID"
|
||||||
|
BROWSER="netwatch-viewport-browser-$RUN_ID"
|
||||||
|
HARNESS="netwatch-viewport-harness-$RUN_ID"
|
||||||
|
ARTIFACT_DIR="$ROOT/tmp/viewport"
|
||||||
|
|
||||||
|
# Every container is named and removed here, including the harness itself:
|
||||||
|
# `timeout` below kills the `docker run` client, not the container it
|
||||||
|
# started, and an unnamed survivor keeps the --internal network in use so
|
||||||
|
# `docker network rm` fails too. This host runs many sessions at once and
|
||||||
|
# neither may be left behind.
|
||||||
|
cleanup() {
|
||||||
|
docker rm -f "$HARNESS" > /dev/null 2>&1 || true
|
||||||
|
docker rm -f "$BROWSER" > /dev/null 2>&1 || true
|
||||||
|
docker rm -f "$SERVER" > /dev/null 2>&1 || true
|
||||||
|
docker network rm "$NETWORK" > /dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
# Test what ships: the production build, not a dev server.
|
||||||
|
"$ROOT/script/test"
|
||||||
|
if [ ! -f "$ROOT/dist/index.html" ]; then
|
||||||
|
echo "frontend-viewport-test: dist/index.html missing after build" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$ARTIFACT_DIR"
|
||||||
|
|
||||||
|
# An --internal network has no route off the host, so the browser
|
||||||
|
# cannot reach the real internet no matter what the page asks for.
|
||||||
|
# Latency probes are answered by the harness instead. This also means
|
||||||
|
# no port can be published from it, which is why the harness itself
|
||||||
|
# runs as a third container on the same network rather than on the
|
||||||
|
# host.
|
||||||
|
docker network create --internal "$NETWORK" > /dev/null
|
||||||
|
|
||||||
|
docker run -d --rm --name "$SERVER" \
|
||||||
|
--network "$NETWORK" --network-alias netwatch \
|
||||||
|
-v "$ROOT/dist:/usr/share/nginx/html:ro" \
|
||||||
|
-v "$ROOT/nginx.conf:/etc/nginx/conf.d/default.conf:ro" \
|
||||||
|
"$SERVER_IMAGE" > /dev/null
|
||||||
|
|
||||||
|
# The image's own entrypoint already exposes CDP on 9222 and passes
|
||||||
|
# --no-sandbox, so only extra flags belong here; re-specifying the
|
||||||
|
# debugging port collides with it and leaves the endpoint bound to
|
||||||
|
# loopback only. --hide-scrollbars keeps innerWidth equal to
|
||||||
|
# clientWidth, so the overflow assertion has no scrollbar-sized slack
|
||||||
|
# to hide behind, and matches the overlay scrollbars phones use.
|
||||||
|
docker run -d --rm --name "$BROWSER" --init --shm-size=1g \
|
||||||
|
--network "$NETWORK" \
|
||||||
|
"$BROWSER_IMAGE" \
|
||||||
|
--hide-scrollbars \
|
||||||
|
> /dev/null
|
||||||
|
|
||||||
|
# Chrome refuses DevTools requests whose Host header is neither
|
||||||
|
# localhost nor an IP address, so dial the container by address rather
|
||||||
|
# than by its network alias.
|
||||||
|
browser_ip="$(docker inspect \
|
||||||
|
-f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' \
|
||||||
|
"$BROWSER")"
|
||||||
|
|
||||||
|
timeout 900 docker run --rm --init --name "$HARNESS" \
|
||||||
|
--network "$NETWORK" \
|
||||||
|
--user "$(id -u):$(id -g)" \
|
||||||
|
-v "$ROOT:/app" \
|
||||||
|
-w /app \
|
||||||
|
-e NETWATCH_ROOT=/app \
|
||||||
|
-e NETWATCH_BASE_URL=http://netwatch:8080 \
|
||||||
|
-e "NETWATCH_CDP_URL=http://$browser_ip:9222" \
|
||||||
|
-e NETWATCH_ARTIFACT_DIR=/app/tmp/viewport \
|
||||||
|
"$NODE_IMAGE" \
|
||||||
|
node test/viewport/harness.js
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
112
test/viewport/README.md
Normal file
112
test/viewport/README.md
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
# Responsive-layout harness
|
||||||
|
|
||||||
|
Automated verification of the responsive layout that landed in #5. Run it with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make frontend-viewport-test
|
||||||
|
```
|
||||||
|
|
||||||
|
It builds `dist/`, serves it from the same digest-pinned `nginx` image and the
|
||||||
|
same `nginx.conf` the shipping container uses, drives a digest-pinned headless
|
||||||
|
Chrome against it over CDP, and asserts on computed layout at every viewport
|
||||||
|
width derived from the app's own CSS. Screenshots land in `tmp/viewport/`
|
||||||
|
alongside a `results.json`; they are artifacts for a human to look at when
|
||||||
|
something fails, not the evidence. The assertions are the evidence.
|
||||||
|
|
||||||
|
The target is deliberately outside `make check`: it needs Docker and takes
|
||||||
|
minutes, and `make test` has to stay under 20 seconds.
|
||||||
|
|
||||||
|
## How the widths are chosen
|
||||||
|
|
||||||
|
Not from a list of phone models. `viewports.js` parses the `@media` conditions
|
||||||
|
out of `src/styles.css` and scans `src/main.js` and `index.html` for Tailwind
|
||||||
|
responsive prefixes, then tests every breakpoint it finds at one pixel below it,
|
||||||
|
exactly on it, and one pixel above it. A generic 375px "phone" test sails
|
||||||
|
straight past an off-by-one at a media query boundary; `max-width: 768px`
|
||||||
|
matches _at_ 768, and the sweep pins down which side of that line each layout is
|
||||||
|
on.
|
||||||
|
|
||||||
|
Nothing hardcodes 768. Add a second media block or start using `md:` classes and
|
||||||
|
the new breakpoint is covered without this directory being touched. The app is
|
||||||
|
desktop-first today (all narrow rules live in `max-width` blocks); a
|
||||||
|
`min-width`-only, mobile-first set is handled as its inverse, and a set that
|
||||||
|
mixes the two makes the run fail loudly rather than test the right widths with
|
||||||
|
the wrong expectation. Four further viewports are fixed anchors, each with a
|
||||||
|
stated reason: a 320px floor, a 1280px desktop baseline, and two phone-landscape
|
||||||
|
sizes straddling the breakpoint for the rotation case.
|
||||||
|
|
||||||
|
## What it asserts
|
||||||
|
|
||||||
|
- **app-rendered** — enough host rows exist and enough of them show a numeric
|
||||||
|
latency. This one exists so the rest cannot pass vacuously against a blank
|
||||||
|
page.
|
||||||
|
- **no-horizontal-overflow** — `documentElement.scrollWidth` fits the layout
|
||||||
|
viewport, with the widest offending element named.
|
||||||
|
- **nothing-past-viewport-edge** — no visible element's box extends past the
|
||||||
|
viewport edge.
|
||||||
|
- **no-clipped-text** — nothing hides text behind `overflow: hidden`. Deliberate
|
||||||
|
ellipsis truncation (Tailwind's `truncate`, used on host names and URLs) is
|
||||||
|
excluded: it is a design choice, not breakage.
|
||||||
|
- **tap-targets-44px** — every interactive control is at least 44x44 CSS px on
|
||||||
|
touch viewports, _and_ each selector in the control list matched at least the
|
||||||
|
number of visible elements it declares. The second half is what stops the
|
||||||
|
check passing vacuously: with size alone, a renamed class would take its
|
||||||
|
controls out of the measured set and the check would report "all 0 controls
|
||||||
|
are at least 44x44" and pass. See below.
|
||||||
|
- **host-rows-stacked / host-rows-side-by-side** — the rows genuinely reflow.
|
||||||
|
Computed `flex-direction` _and_ the actual geometry are checked, and in the
|
||||||
|
narrow layout the info block and the sparkline must each occupy essentially
|
||||||
|
the full row width. A row that merely shrank its 420px column would fail.
|
||||||
|
- **probing-still-runs / gateway-detection-still-runs** — narrow viewports keep
|
||||||
|
probing and keep detecting the gateway. The mobile early-return path proposed
|
||||||
|
in #8 was rejected; this is what would catch it coming back.
|
||||||
|
|
||||||
|
### The tap-target threshold
|
||||||
|
|
||||||
|
44x44 CSS px. That is the figure in Apple's Human Interface Guidelines and in
|
||||||
|
WCAG 2.2 SC 2.5.5 "Target Size (Enhanced)". WCAG 2.2 SC 2.5.8 (level AA) sets a
|
||||||
|
lower 24x24 floor, but that floor comes with a spacing exception these controls
|
||||||
|
do not qualify for — the pin buttons sit directly against the host name they
|
||||||
|
belong to.
|
||||||
|
|
||||||
|
## Determinism
|
||||||
|
|
||||||
|
The browser container runs on an `--internal` docker network and has no route to
|
||||||
|
the internet, so the app's latency probes cannot reach anything real. The
|
||||||
|
harness answers them itself from a fixed delay table, with a deterministic
|
||||||
|
fraction failed outright, so the rows render a realistic spread of one-, two-
|
||||||
|
and three-digit latencies plus some unreachable rows. That spread is what the
|
||||||
|
layout has to survive; 24 identical `---` placeholders would not exercise it.
|
||||||
|
|
||||||
|
## What this cannot verify
|
||||||
|
|
||||||
|
Real limits, so nobody re-parks this issue as needing hardware:
|
||||||
|
|
||||||
|
- **Non-Chromium engines.** This is Chrome. iOS Safari is WebKit and cannot be
|
||||||
|
emulated by it; Safari-specific bugs (viewport units under a collapsing URL
|
||||||
|
bar, `-webkit-fill-available`, form control metrics) will not show up here.
|
||||||
|
- **Real touch input.** `hasTouch` emulation changes what the page is told, not
|
||||||
|
how a finger behaves. Gesture handling, scroll momentum, double-tap zoom and
|
||||||
|
hover-state fallbacks on touch are out of scope.
|
||||||
|
- **Physical pixel density and rendering.** `deviceScaleFactor` is set, but
|
||||||
|
subpixel antialiasing, OLED colour rendering and actual legibility at a given
|
||||||
|
physical size are not measurable here.
|
||||||
|
- **Fonts.** The container has DejaVu, not the platform's own UI monospace. Text
|
||||||
|
metrics are therefore close to, but not identical to, a real device — a layout
|
||||||
|
that fits here by a few pixels might not there.
|
||||||
|
- **On-device performance.** Canvas sparkline redraw cost, battery, and
|
||||||
|
behaviour on a slow radio are not measured.
|
||||||
|
- **Browser chrome.** The address bar, safe-area insets and notch cutouts are
|
||||||
|
not simulated.
|
||||||
|
|
||||||
|
Everything else this issue was actually about — does the layout reflow, does
|
||||||
|
anything overflow, is content clipped, are the controls big enough — is a
|
||||||
|
function of viewport width and CSS, and is covered above.
|
||||||
|
|
||||||
|
## Relation to the unit test framework (#21)
|
||||||
|
|
||||||
|
Complementary layers, not two stacks. `vitest` (#21) will exercise module-level
|
||||||
|
logic in-process with no browser. This harness exercises rendered layout in a
|
||||||
|
real engine and is the only thing here that can see a media query. Neither
|
||||||
|
replaces the other; assertions about computed styles and element geometry belong
|
||||||
|
here, assertions about functions belong in `vitest`.
|
||||||
245
test/viewport/checks.js
Normal file
245
test/viewport/checks.js
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
// Pass/fail decisions for the responsive-layout harness.
|
||||||
|
//
|
||||||
|
// Kept in node rather than in the page so that a failure can be reported
|
||||||
|
// with the measurements that produced it. Every check runs at every
|
||||||
|
// viewport; none of them short-circuits, so one failure does not hide the
|
||||||
|
// rest.
|
||||||
|
|
||||||
|
// Minimum tap target, in CSS pixels. 44x44 is the figure in Apple's Human
|
||||||
|
// Interface Guidelines and in WCAG 2.2 SC 2.5.5 "Target Size (Enhanced)".
|
||||||
|
// WCAG 2.2 SC 2.5.8 (level AA) sets a lower 24x24 floor, but that floor
|
||||||
|
// comes with a spacing exception these controls do not qualify for: the
|
||||||
|
// pin buttons sit directly against the host name they belong to. Held at
|
||||||
|
// 44 deliberately.
|
||||||
|
export const MIN_TAP_TARGET_PX = 44;
|
||||||
|
|
||||||
|
// The controls named in the definition of done, plus the pause button.
|
||||||
|
// Each carries the smallest number of *visible* instances the page has to
|
||||||
|
// contain for the tap-target oracle to be measuring anything at all.
|
||||||
|
//
|
||||||
|
// Without those floors the check is inert: `undersized` is empty both when
|
||||||
|
// every control is large enough and when the selectors have gone stale and
|
||||||
|
// matched nothing, and the pass condition cannot tell those apart. A single
|
||||||
|
// combined floor would not be enough either — 26 pin buttons would cover
|
||||||
|
// for all three singleton controls vanishing at once — so the floor is per
|
||||||
|
// selector, and one stale selector out of four fails the check.
|
||||||
|
export const INTERACTIVE_CONTROLS = [
|
||||||
|
{ selector: "#pause-btn", minCount: 1 },
|
||||||
|
{ selector: "#interval-select", minCount: 1 },
|
||||||
|
// One per pinnable host row. `app-rendered` already requires at least
|
||||||
|
// 10 host rows, so a count below that means the pin buttons stopped
|
||||||
|
// being rendered per row rather than that there were fewer hosts.
|
||||||
|
{ selector: ".pin-btn", minCount: 10 },
|
||||||
|
{ selector: "#debug-toggle", minCount: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const INTERACTIVE_SELECTORS = INTERACTIVE_CONTROLS.map(
|
||||||
|
(control) => control.selector,
|
||||||
|
);
|
||||||
|
|
||||||
|
// A host row is only "reflowed" if it stacked *and* went full width.
|
||||||
|
// A row that merely shrank its 420px info column would keep
|
||||||
|
// flex-direction: row, and a row that stacked but left the info column at
|
||||||
|
// its fixed width would fail the width test.
|
||||||
|
const FULL_WIDTH_FRACTION = 0.9;
|
||||||
|
|
||||||
|
function summarise(items, format, limit = 3) {
|
||||||
|
const shown = items.slice(0, limit).map(format).join("; ");
|
||||||
|
const rest = items.length > limit ? ` (+${items.length - limit} more)` : "";
|
||||||
|
return shown + rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapse an overflow report to the elements actually responsible.
|
||||||
|
// Identical elements (24 host rows all doing the same thing) are counted
|
||||||
|
// rather than listed, and the deepest ones come first, since every
|
||||||
|
// ancestor of an overflowing element also reports as overflowing.
|
||||||
|
function deepestOffenders(entries) {
|
||||||
|
const byElement = new Map();
|
||||||
|
for (const entry of entries) {
|
||||||
|
const reach = entry.reach ?? entry.right;
|
||||||
|
const existing = byElement.get(entry.el);
|
||||||
|
if (existing) {
|
||||||
|
existing.count += 1;
|
||||||
|
existing.reach = Math.max(existing.reach, reach);
|
||||||
|
} else {
|
||||||
|
byElement.set(entry.el, { ...entry, reach, count: 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...byElement.values()].sort(
|
||||||
|
(a, b) => b.depth - a.depth || b.reach - a.reach,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkRowLayout(row, expectStacked) {
|
||||||
|
if (expectStacked) {
|
||||||
|
if (row.flexDirection !== "column") {
|
||||||
|
return `row ${row.index}: flex-direction is ${row.flexDirection}, expected column`;
|
||||||
|
}
|
||||||
|
if (row.sparkline.top < row.info.bottom - 1) {
|
||||||
|
return `row ${row.index}: sparkline top ${row.sparkline.top} is above info bottom ${row.info.bottom} — still side by side`;
|
||||||
|
}
|
||||||
|
const minWidth = row.containerWidth * FULL_WIDTH_FRACTION;
|
||||||
|
if (row.info.width < minWidth) {
|
||||||
|
return `row ${row.index}: info block is ${row.info.width}px of ${row.containerWidth}px — shrunk, not reflowed`;
|
||||||
|
}
|
||||||
|
if (row.sparkline.width < minWidth) {
|
||||||
|
return `row ${row.index}: sparkline is ${row.sparkline.width}px of ${row.containerWidth}px — shrunk, not reflowed`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.flexDirection !== "row") {
|
||||||
|
return `row ${row.index}: flex-direction is ${row.flexDirection}, expected row`;
|
||||||
|
}
|
||||||
|
if (row.sparkline.left < row.info.right - 1) {
|
||||||
|
return `row ${row.index}: sparkline left ${row.sparkline.left} overlaps info right ${row.info.right} — not side by side`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluateChecks(facts, viewport, probes) {
|
||||||
|
const checks = [];
|
||||||
|
const check = (name, ok, detail) => checks.push({ name, ok, detail });
|
||||||
|
|
||||||
|
// Guard against the whole harness passing vacuously because the page
|
||||||
|
// never rendered. Everything below is only meaningful if this holds.
|
||||||
|
check(
|
||||||
|
"app-rendered",
|
||||||
|
facts.rowCount >= 10 && facts.numericLatencies >= 5,
|
||||||
|
`${facts.rowCount} host rows, ${facts.numericLatencies} showing a numeric latency`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const viewportWidth = Math.min(facts.innerWidth, facts.documentClientWidth);
|
||||||
|
const culprits = deepestOffenders([
|
||||||
|
...facts.overflowing,
|
||||||
|
...facts.contentOverflowing,
|
||||||
|
]);
|
||||||
|
check(
|
||||||
|
"no-horizontal-overflow",
|
||||||
|
facts.documentScrollWidth <= viewportWidth,
|
||||||
|
`documentElement.scrollWidth ${facts.documentScrollWidth} vs viewport ${viewportWidth}` +
|
||||||
|
(culprits.length === 0
|
||||||
|
? ""
|
||||||
|
: "; widest content: " +
|
||||||
|
summarise(
|
||||||
|
culprits,
|
||||||
|
(c) =>
|
||||||
|
`${c.el} reaches ${Math.round(c.reach)}px${c.count > 1 ? ` (x${c.count})` : ""}`,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
|
||||||
|
check(
|
||||||
|
"nothing-past-viewport-edge",
|
||||||
|
facts.overflowing.length === 0,
|
||||||
|
facts.overflowing.length === 0
|
||||||
|
? "no element extends past the viewport"
|
||||||
|
: `${facts.overflowing.length} element(s) past the edge: ` +
|
||||||
|
summarise(
|
||||||
|
facts.overflowing,
|
||||||
|
(o) => `${o.el} spans ${o.left}..${o.right}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
check(
|
||||||
|
"no-clipped-text",
|
||||||
|
facts.clipped.length === 0,
|
||||||
|
facts.clipped.length === 0
|
||||||
|
? "no element hides text behind overflow (deliberate ellipsis excluded)"
|
||||||
|
: `${facts.clipped.length} element(s) clipping text: ` +
|
||||||
|
summarise(
|
||||||
|
facts.clipped,
|
||||||
|
(c) =>
|
||||||
|
`${c.el} scrollWidth ${c.scrollWidth} > clientWidth ${c.clientWidth}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (viewport.touch) {
|
||||||
|
// Presence first: a selector that matches nothing contributes no
|
||||||
|
// undersized targets, so without this the check would report
|
||||||
|
// "all 0 controls are at least 44x44" and pass.
|
||||||
|
const seen = new Map();
|
||||||
|
for (const target of facts.tapTargets) {
|
||||||
|
seen.set(target.selector, (seen.get(target.selector) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const missing = INTERACTIVE_CONTROLS.filter(
|
||||||
|
(control) => (seen.get(control.selector) ?? 0) < control.minCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
const undersized = facts.tapTargets.filter(
|
||||||
|
(t) => t.width < MIN_TAP_TARGET_PX || t.height < MIN_TAP_TARGET_PX,
|
||||||
|
);
|
||||||
|
const bySelector = new Map();
|
||||||
|
for (const target of undersized) {
|
||||||
|
const existing = bySelector.get(target.selector);
|
||||||
|
if (!existing || target.width * target.height < existing.area) {
|
||||||
|
bySelector.set(target.selector, {
|
||||||
|
...target,
|
||||||
|
area: target.width * target.height,
|
||||||
|
count: (existing?.count ?? 0) + 1,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
existing.count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const detail = [];
|
||||||
|
if (missing.length > 0) {
|
||||||
|
detail.push(
|
||||||
|
"oracle is not measuring the page: " +
|
||||||
|
summarise(
|
||||||
|
missing,
|
||||||
|
(c) =>
|
||||||
|
`${c.selector} matched ${seen.get(c.selector) ?? 0} visible element(s), expected at least ${c.minCount}`,
|
||||||
|
4,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
detail.push(
|
||||||
|
undersized.length === 0
|
||||||
|
? `${facts.tapTargets.length} controls measured, all at least ${MIN_TAP_TARGET_PX}x${MIN_TAP_TARGET_PX}`
|
||||||
|
: `${undersized.length} of ${facts.tapTargets.length} controls below ${MIN_TAP_TARGET_PX}x${MIN_TAP_TARGET_PX}: ` +
|
||||||
|
summarise(
|
||||||
|
[...bySelector.values()],
|
||||||
|
(t) =>
|
||||||
|
`${t.selector} ${t.width}x${t.height}${t.count > 1 ? ` (x${t.count})` : ""}`,
|
||||||
|
4,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
`tap-targets-${MIN_TAP_TARGET_PX}px`,
|
||||||
|
missing.length === 0 && undersized.length === 0,
|
||||||
|
detail.join("; "),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const badRows = facts.rows
|
||||||
|
.map((row) => checkRowLayout(row, viewport.expectStacked))
|
||||||
|
.filter(Boolean);
|
||||||
|
check(
|
||||||
|
viewport.expectStacked ? "host-rows-stacked" : "host-rows-side-by-side",
|
||||||
|
facts.rows.length > 0 && badRows.length === 0,
|
||||||
|
facts.rows.length === 0
|
||||||
|
? "no host rows were measured"
|
||||||
|
: badRows.length === 0
|
||||||
|
? `all ${facts.rows.length} rows laid out as expected`
|
||||||
|
: `${badRows.length} of ${facts.rows.length} rows wrong: ` +
|
||||||
|
summarise(badRows, (r) => r),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The mobile early-return path proposed in #8 was rejected: narrow
|
||||||
|
// viewports must keep probing and keep detecting the gateway, not
|
||||||
|
// quietly skip work.
|
||||||
|
check(
|
||||||
|
"probing-still-runs",
|
||||||
|
probes.attempted > 0,
|
||||||
|
`${probes.attempted} outbound probe requests issued`,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
"gateway-detection-still-runs",
|
||||||
|
facts.gatewayDetected,
|
||||||
|
facts.gatewayDetected
|
||||||
|
? "Local Gateway row present"
|
||||||
|
: "no Local Gateway row — gateway detection did not run or did not complete",
|
||||||
|
);
|
||||||
|
|
||||||
|
return checks;
|
||||||
|
}
|
||||||
184
test/viewport/facts.js
Normal file
184
test/viewport/facts.js
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
// Layout facts collected from inside the page.
|
||||||
|
//
|
||||||
|
// This function is serialised and evaluated in the browser, so it must be
|
||||||
|
// entirely self-contained: no imports, no closures over module scope. It
|
||||||
|
// only *measures*; every pass/fail decision is made back in node by
|
||||||
|
// checks.js, so failures can be reported with real numbers attached.
|
||||||
|
|
||||||
|
export function collectLayoutFacts(options) {
|
||||||
|
const describe = (el) => {
|
||||||
|
const id = el.id ? "#" + el.id : "";
|
||||||
|
const classes =
|
||||||
|
typeof el.className === "string" && el.className.trim()
|
||||||
|
? "." + el.className.trim().split(/\s+/).slice(0, 3).join(".")
|
||||||
|
: "";
|
||||||
|
return el.tagName.toLowerCase() + id + classes;
|
||||||
|
};
|
||||||
|
|
||||||
|
const round = (n) => Math.round(n * 10) / 10;
|
||||||
|
|
||||||
|
// Overflow propagates up every ancestor, so a single wide element
|
||||||
|
// reports as body, #app, the row, and so on. Depth lets the report
|
||||||
|
// name the deepest — that is, the actual — offender.
|
||||||
|
const depthOf = (el) => {
|
||||||
|
let depth = 0;
|
||||||
|
for (let node = el.parentElement; node; node = node.parentElement) {
|
||||||
|
depth++;
|
||||||
|
}
|
||||||
|
return depth;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isVisible = (el) => {
|
||||||
|
const style = getComputedStyle(el);
|
||||||
|
if (style.display === "none") return false;
|
||||||
|
if (style.visibility === "hidden") return false;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
return rect.width > 0 && rect.height > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const innerWidth = window.innerWidth;
|
||||||
|
const clientWidth = document.documentElement.clientWidth;
|
||||||
|
// Under mobile emulation Chrome lets window.innerWidth *grow* to the
|
||||||
|
// width of overflowing content, exactly as a phone zooms out to fit a
|
||||||
|
// too-wide page. Measuring against it would therefore hide the
|
||||||
|
// overflow it is supposed to expose: at a 320px device width a page
|
||||||
|
// that spills to 350 reports innerWidth 350 and looks clean. Every
|
||||||
|
// comparison below is against the layout viewport instead.
|
||||||
|
const viewportWidth = Math.min(innerWidth, clientWidth);
|
||||||
|
const elements = Array.from(document.querySelectorAll("body *"));
|
||||||
|
|
||||||
|
// Elements sticking out past the right (or left) edge of the viewport.
|
||||||
|
// The document-level scrollWidth check says *that* the page overflows;
|
||||||
|
// this says *what* is doing it.
|
||||||
|
const overflowing = [];
|
||||||
|
// Elements clipping their own text. Deliberate ellipsis truncation
|
||||||
|
// (Tailwind's `truncate`) is opt-in and excluded: it is a design
|
||||||
|
// choice, not breakage.
|
||||||
|
const clipped = [];
|
||||||
|
// Elements whose content spills out of their own box without being
|
||||||
|
// clipped, past the right edge of the viewport. A block element is
|
||||||
|
// only ever as wide as its container, so text overflowing it has no
|
||||||
|
// element rect of its own to catch — but it is exactly what drags
|
||||||
|
// documentElement.scrollWidth past the viewport width, so without
|
||||||
|
// this the page-level overflow failure has nothing to point at.
|
||||||
|
const contentOverflowing = [];
|
||||||
|
|
||||||
|
for (const el of elements) {
|
||||||
|
if (!isVisible(el)) continue;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
if (rect.right > viewportWidth + 1 || rect.left < -1) {
|
||||||
|
overflowing.push({
|
||||||
|
el: describe(el),
|
||||||
|
depth: depthOf(el),
|
||||||
|
left: round(rect.left),
|
||||||
|
right: round(rect.right),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const style = getComputedStyle(el);
|
||||||
|
const clips =
|
||||||
|
style.overflowX === "hidden" || style.overflowX === "clip";
|
||||||
|
const ellipsis = style.textOverflow === "ellipsis";
|
||||||
|
const hasText = el.textContent.trim().length > 0;
|
||||||
|
const spills =
|
||||||
|
el.clientWidth > 0 && el.scrollWidth > el.clientWidth + 1;
|
||||||
|
if (clips && !ellipsis && hasText && spills) {
|
||||||
|
clipped.push({
|
||||||
|
el: describe(el),
|
||||||
|
scrollWidth: el.scrollWidth,
|
||||||
|
clientWidth: el.clientWidth,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!clips &&
|
||||||
|
spills &&
|
||||||
|
rect.left + el.scrollWidth > viewportWidth + 1
|
||||||
|
) {
|
||||||
|
contentOverflowing.push({
|
||||||
|
el: describe(el),
|
||||||
|
depth: depthOf(el),
|
||||||
|
scrollWidth: el.scrollWidth,
|
||||||
|
clientWidth: el.clientWidth,
|
||||||
|
reach: round(rect.left + el.scrollWidth),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interactive controls. The measured target is the nearest thing that
|
||||||
|
// is genuinely tappable — for a checkbox that is the <label> wrapping
|
||||||
|
// it, which is larger than the box itself and is what a finger hits.
|
||||||
|
const tapTargets = [];
|
||||||
|
for (const selector of options.interactiveSelectors) {
|
||||||
|
for (const el of document.querySelectorAll(selector)) {
|
||||||
|
if (!isVisible(el)) continue;
|
||||||
|
const target = el.closest("button, a, label, select") || el;
|
||||||
|
const rect = target.getBoundingClientRect();
|
||||||
|
tapTargets.push({
|
||||||
|
selector,
|
||||||
|
el: describe(target),
|
||||||
|
width: round(rect.width),
|
||||||
|
height: round(rect.height),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Host rows. The question is not "did it get narrower" but "did it
|
||||||
|
// reflow": the info block and the sparkline must end up stacked
|
||||||
|
// vertically and full width in the narrow layout, and side by side in
|
||||||
|
// the wide one. Both the computed flex-direction and the actual
|
||||||
|
// geometry are recorded so a row that claims to be a column but is
|
||||||
|
// still laid out side by side cannot slip through.
|
||||||
|
const rows = [];
|
||||||
|
for (const row of document.querySelectorAll(".host-row")) {
|
||||||
|
const inner = row.firstElementChild;
|
||||||
|
if (!inner) continue;
|
||||||
|
const sparkline = inner.querySelector(".sparkline-container");
|
||||||
|
const info = sparkline ? sparkline.previousElementSibling : null;
|
||||||
|
if (!sparkline || !info) continue;
|
||||||
|
const innerStyle = getComputedStyle(inner);
|
||||||
|
const innerRect = inner.getBoundingClientRect();
|
||||||
|
const infoRect = info.getBoundingClientRect();
|
||||||
|
const sparkRect = sparkline.getBoundingClientRect();
|
||||||
|
rows.push({
|
||||||
|
index: row.dataset.index,
|
||||||
|
flexDirection: innerStyle.flexDirection,
|
||||||
|
containerWidth: round(innerRect.width),
|
||||||
|
info: {
|
||||||
|
left: round(infoRect.left),
|
||||||
|
right: round(infoRect.right),
|
||||||
|
bottom: round(infoRect.bottom),
|
||||||
|
width: round(infoRect.width),
|
||||||
|
},
|
||||||
|
sparkline: {
|
||||||
|
left: round(sparkRect.left),
|
||||||
|
top: round(sparkRect.top),
|
||||||
|
width: round(sparkRect.width),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const localRows = Array.from(
|
||||||
|
document.querySelectorAll("#local-hosts .host-row"),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
innerWidth,
|
||||||
|
// innerWidth includes any classic scrollbar, clientWidth does not.
|
||||||
|
// Reported separately so the overflow check can hold itself to the
|
||||||
|
// narrower of the two rather than to whichever one is more
|
||||||
|
// forgiving.
|
||||||
|
documentClientWidth: document.documentElement.clientWidth,
|
||||||
|
documentScrollWidth: document.documentElement.scrollWidth,
|
||||||
|
overflowing,
|
||||||
|
contentOverflowing,
|
||||||
|
clipped,
|
||||||
|
tapTargets,
|
||||||
|
rows,
|
||||||
|
rowCount: document.querySelectorAll(".host-row").length,
|
||||||
|
numericLatencies: Array.from(
|
||||||
|
document.querySelectorAll(".latency-value"),
|
||||||
|
).filter((el) => /\d/.test(el.textContent)).length,
|
||||||
|
gatewayDetected: localRows.some((row) =>
|
||||||
|
row.textContent.includes("Local Gateway"),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
258
test/viewport/harness.js
Normal file
258
test/viewport/harness.js
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
// Responsive-layout harness.
|
||||||
|
//
|
||||||
|
// Drives the built frontend in a real, containerised, digest-pinned Chrome
|
||||||
|
// over CDP and asserts on computed layout at every viewport width derived
|
||||||
|
// from the app's own CSS. Screenshots are written alongside as artifacts;
|
||||||
|
// they are not the evidence, the assertions are.
|
||||||
|
//
|
||||||
|
// This is not meant to be run by hand. `make frontend-viewport-test` brings
|
||||||
|
// up the browser and the web server and then runs this; every input it
|
||||||
|
// needs arrives in the environment.
|
||||||
|
|
||||||
|
import { mkdirSync, writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import puppeteer from "puppeteer-core";
|
||||||
|
import { collectLayoutFacts } from "./facts.js";
|
||||||
|
import { evaluateChecks, INTERACTIVE_SELECTORS } from "./checks.js";
|
||||||
|
import { deriveViewports } from "./viewports.js";
|
||||||
|
|
||||||
|
function required(name) {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(
|
||||||
|
`${name} is not set; run this via script/frontend-viewport-test`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROOT = required("NETWATCH_ROOT");
|
||||||
|
const BASE_URL = required("NETWATCH_BASE_URL");
|
||||||
|
const CDP_URL = required("NETWATCH_CDP_URL");
|
||||||
|
const ARTIFACT_DIR = required("NETWATCH_ARTIFACT_DIR");
|
||||||
|
|
||||||
|
const BROWSER_TIMEOUT_MS = 60000;
|
||||||
|
const PAGE_TIMEOUT_MS = 30000;
|
||||||
|
|
||||||
|
// Canned responses for the app's outbound latency probes. The browser
|
||||||
|
// container sits on an --internal docker network and physically cannot
|
||||||
|
// reach the internet, so nothing here is about blocking traffic; it is
|
||||||
|
// about determinism. Real probes would render 24 rows of whatever the
|
||||||
|
// network happened to be doing. These delays make the rows show a
|
||||||
|
// realistic spread of value widths — one, two and three digit latencies,
|
||||||
|
// plus some unreachable rows — because that spread is what the layout has
|
||||||
|
// to survive.
|
||||||
|
const PROBE_DELAYS_MS = [2, 45, 123, 456, 780];
|
||||||
|
// One in every UNREACHABLE_MODULUS probes is failed outright so that the
|
||||||
|
// offline row rendering is exercised too.
|
||||||
|
const UNREACHABLE_MODULUS = 7;
|
||||||
|
// The gateway candidate that "answers", so gateway detection succeeds and
|
||||||
|
// the Local Gateway row renders. Matches GATEWAY_CANDIDATES in src/main.js.
|
||||||
|
const RESPONSIVE_GATEWAY = "http://192.168.1.1";
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
function stableHash(text) {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
hash = (hash * 31 + text.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
return Math.abs(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectBrowser() {
|
||||||
|
const deadline = Date.now() + BROWSER_TIMEOUT_MS;
|
||||||
|
let lastError;
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${CDP_URL}/json/version`);
|
||||||
|
const info = await response.json();
|
||||||
|
// The endpoint advertises whatever Host it was reached on;
|
||||||
|
// pin it back to the address we actually dialled.
|
||||||
|
const endpoint = new URL(info.webSocketDebuggerUrl);
|
||||||
|
endpoint.host = new URL(CDP_URL).host;
|
||||||
|
const browser = await puppeteer.connect({
|
||||||
|
browserWSEndpoint: endpoint.toString(),
|
||||||
|
protocolTimeout: BROWSER_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
return { browser, version: info.Browser };
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
if (Date.now() > deadline) {
|
||||||
|
throw new Error(`browser never came up: ${lastError}`);
|
||||||
|
}
|
||||||
|
await sleep(250);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function installProbeResponder(page, probes) {
|
||||||
|
const respond = (request, delayMs) =>
|
||||||
|
sleep(delayMs).then(() =>
|
||||||
|
request.respond({
|
||||||
|
status: 200,
|
||||||
|
contentType: "text/plain",
|
||||||
|
body: "",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
page.on("request", (request) => {
|
||||||
|
const url = request.url();
|
||||||
|
const settle = async () => {
|
||||||
|
if (url.startsWith(BASE_URL) || url.startsWith("data:")) {
|
||||||
|
return request.continue();
|
||||||
|
}
|
||||||
|
probes.attempted++;
|
||||||
|
if (url.startsWith(RESPONSIVE_GATEWAY)) {
|
||||||
|
probes.fulfilled++;
|
||||||
|
return respond(request, 5);
|
||||||
|
}
|
||||||
|
const hash = stableHash(url);
|
||||||
|
if (hash % UNREACHABLE_MODULUS === 0) {
|
||||||
|
probes.failed++;
|
||||||
|
return request.abort("connectionfailed");
|
||||||
|
}
|
||||||
|
probes.fulfilled++;
|
||||||
|
return respond(
|
||||||
|
request,
|
||||||
|
PROBE_DELAYS_MS[hash % PROBE_DELAYS_MS.length],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
// The page may be torn down while a delayed response is pending;
|
||||||
|
// that is not a harness failure.
|
||||||
|
settle().catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runViewport(browser, viewport) {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const probes = { attempted: 0, fulfilled: 0, failed: 0 };
|
||||||
|
try {
|
||||||
|
page.setDefaultTimeout(PAGE_TIMEOUT_MS);
|
||||||
|
await page.setRequestInterception(true);
|
||||||
|
installProbeResponder(page, probes);
|
||||||
|
await page.setViewport({
|
||||||
|
width: viewport.width,
|
||||||
|
height: viewport.height,
|
||||||
|
deviceScaleFactor: viewport.deviceScaleFactor,
|
||||||
|
isMobile: viewport.touch,
|
||||||
|
hasTouch: viewport.touch,
|
||||||
|
isLandscape: viewport.width > viewport.height,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(BASE_URL, { waitUntil: "load" });
|
||||||
|
await page.waitForSelector(".host-row");
|
||||||
|
|
||||||
|
// The app discards its first tick as a cold start, so rows only
|
||||||
|
// carry real values from the second one. Changing the interval
|
||||||
|
// restarts the loop at 1s, which reaches a populated UI without
|
||||||
|
// waiting out two default 3s intervals — and exercises the
|
||||||
|
// interval dropdown while we are at it.
|
||||||
|
await page.select("#interval-select", "1000");
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
Array.from(document.querySelectorAll(".latency-value")).filter(
|
||||||
|
(el) => /\d/.test(el.textContent),
|
||||||
|
).length >= 5,
|
||||||
|
);
|
||||||
|
// Let the resize/redraw handlers settle before measuring.
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) =>
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const facts = await page.evaluate(collectLayoutFacts, {
|
||||||
|
interactiveSelectors: INTERACTIVE_SELECTORS,
|
||||||
|
});
|
||||||
|
|
||||||
|
const screenshot = join(
|
||||||
|
ARTIFACT_DIR,
|
||||||
|
`${viewport.width}x${viewport.height}-${viewport.name}.png`,
|
||||||
|
);
|
||||||
|
await page.screenshot({ path: screenshot, fullPage: true });
|
||||||
|
|
||||||
|
return {
|
||||||
|
viewport,
|
||||||
|
probes,
|
||||||
|
facts,
|
||||||
|
screenshot,
|
||||||
|
checks: evaluateChecks(facts, viewport, probes),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await page.close().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function report(results, conditions, browserVersion) {
|
||||||
|
const label = (viewport) =>
|
||||||
|
`${viewport.width}x${viewport.height}`.padEnd(9) +
|
||||||
|
" " +
|
||||||
|
viewport.name.padEnd(24);
|
||||||
|
|
||||||
|
console.log(`browser: ${browserVersion}`);
|
||||||
|
console.log(`served from: ${BASE_URL} (built dist/)`);
|
||||||
|
console.log(
|
||||||
|
"breakpoints: " +
|
||||||
|
conditions
|
||||||
|
.map((c) => `${c.type}-width ${c.px}px (${c.source})`)
|
||||||
|
.join(", "),
|
||||||
|
);
|
||||||
|
console.log("");
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
for (const result of results) {
|
||||||
|
const bad = result.checks.filter((c) => !c.ok);
|
||||||
|
passed += result.checks.length - bad.length;
|
||||||
|
failed += bad.length;
|
||||||
|
// Passing viewports get one line. Detail is for failures.
|
||||||
|
console.log(
|
||||||
|
`${bad.length === 0 ? "PASS" : "FAIL"} ${label(result.viewport)} ` +
|
||||||
|
`${result.checks.length - bad.length}/${result.checks.length} checks` +
|
||||||
|
`${result.viewport.expectStacked ? " [narrow layout expected]" : ""}`,
|
||||||
|
);
|
||||||
|
for (const check of bad) {
|
||||||
|
console.log(` ${check.name}: ${check.detail}`);
|
||||||
|
}
|
||||||
|
if (bad.length > 0) {
|
||||||
|
console.log(` why this width: ${result.viewport.why}`);
|
||||||
|
console.log(` screenshot: ${result.screenshot}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("");
|
||||||
|
console.log(
|
||||||
|
`${results.length} viewports, ${passed + failed} checks: ` +
|
||||||
|
`${passed} passed, ${failed} failed`,
|
||||||
|
);
|
||||||
|
console.log(`artifacts: ${ARTIFACT_DIR}`);
|
||||||
|
return failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const { conditions, viewports } = deriveViewports(ROOT);
|
||||||
|
mkdirSync(ARTIFACT_DIR, { recursive: true });
|
||||||
|
|
||||||
|
const { browser, version } = await connectBrowser();
|
||||||
|
const results = [];
|
||||||
|
try {
|
||||||
|
for (const viewport of viewports) {
|
||||||
|
results.push(await runViewport(browser, viewport));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await browser.disconnect().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
join(ARTIFACT_DIR, "results.json"),
|
||||||
|
JSON.stringify({ browser: version, conditions, results }, null, 2) +
|
||||||
|
"\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
const failed = report(results, conditions, version);
|
||||||
|
process.exitCode = failed === 0 ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
await main();
|
||||||
224
test/viewport/viewports.js
Normal file
224
test/viewport/viewports.js
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
// Viewport derivation for the responsive-layout harness.
|
||||||
|
//
|
||||||
|
// The widths tested are read out of the CSS the application actually
|
||||||
|
// ships, not taken from a list of popular phone models. A generic 375px
|
||||||
|
// "phone" test sails straight past an off-by-one error at a media query
|
||||||
|
// boundary, which is the classic way a responsive layout breaks, so
|
||||||
|
// every breakpoint found in the sources is probed three times: one pixel
|
||||||
|
// below it, exactly on it, and one pixel above it.
|
||||||
|
//
|
||||||
|
// Nothing here hardcodes 768. If someone adds a second media block or
|
||||||
|
// starts using Tailwind responsive prefixes, that breakpoint starts
|
||||||
|
// being covered without this file being edited.
|
||||||
|
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
// Tailwind CSS v4 default breakpoints, in rem. The app currently uses
|
||||||
|
// none of these prefixes, so the whole table is inert until someone
|
||||||
|
// writes an `md:`-prefixed utility class.
|
||||||
|
const TAILWIND_BREAKPOINT_REM = {
|
||||||
|
sm: 40,
|
||||||
|
md: 48,
|
||||||
|
lg: 64,
|
||||||
|
xl: 80,
|
||||||
|
"2xl": 96,
|
||||||
|
};
|
||||||
|
|
||||||
|
// The app does not override the root font size, so rem and em in media
|
||||||
|
// queries resolve against the browser default.
|
||||||
|
const ROOT_FONT_SIZE_PX = 16;
|
||||||
|
|
||||||
|
// Extract every min-width / max-width condition from the @media blocks in
|
||||||
|
// a stylesheet. Returns e.g. [{ type: "max", px: 768, source: "..." }].
|
||||||
|
export function mediaConditionsFromCss(css, source) {
|
||||||
|
const conditions = [];
|
||||||
|
for (const block of css.matchAll(/@media([^{]+)\{/g)) {
|
||||||
|
const features = block[1].matchAll(
|
||||||
|
/\(\s*(min|max)-width\s*:\s*([\d.]+)(px|rem|em)\s*\)/g,
|
||||||
|
);
|
||||||
|
for (const feature of features) {
|
||||||
|
const scale = feature[3] === "px" ? 1 : ROOT_FONT_SIZE_PX;
|
||||||
|
conditions.push({
|
||||||
|
type: feature[1],
|
||||||
|
px: Math.round(Number(feature[2]) * scale),
|
||||||
|
source,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return conditions;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the breakpoints implied by Tailwind responsive prefixes used in
|
||||||
|
// markup. A prefix only counts when it opens a utility class, so `text-sm`
|
||||||
|
// does not masquerade as the `sm:` breakpoint.
|
||||||
|
export function mediaConditionsFromMarkup(sources) {
|
||||||
|
const conditions = [];
|
||||||
|
for (const { path, text } of sources) {
|
||||||
|
for (const [name, rem] of Object.entries(TAILWIND_BREAKPOINT_REM)) {
|
||||||
|
const used = new RegExp(
|
||||||
|
`(^|["'\\s])${name}:[a-z0-9[\\](),_./%-]+`,
|
||||||
|
"m",
|
||||||
|
).test(text);
|
||||||
|
if (used) {
|
||||||
|
conditions.push({
|
||||||
|
type: "min",
|
||||||
|
px: rem * ROOT_FONT_SIZE_PX,
|
||||||
|
source: path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return conditions;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether a given width should be rendering the app's narrow (stacked)
|
||||||
|
// layout.
|
||||||
|
//
|
||||||
|
// Two breakpoint styles can be answered from the condition list alone:
|
||||||
|
//
|
||||||
|
// - Desktop-first, which is what the app ships today: the wide layout is
|
||||||
|
// unconditional and every narrow rule lives in a `max-width` block, so
|
||||||
|
// a width is narrow exactly when one of those blocks matches. Note that
|
||||||
|
// `max-width: 768px` matches *at* 768 — getting this inclusive boundary
|
||||||
|
// wrong in either direction is what the three-widths-per-breakpoint
|
||||||
|
// sweep exists to catch.
|
||||||
|
// - Mobile-first, which is what Tailwind's `sm:`/`md:` prefixes are: the
|
||||||
|
// stacked layout is the unconditional base and a `min-width` block is
|
||||||
|
// what widens it, so a width is narrow exactly when it sits below every
|
||||||
|
// `min-width` breakpoint.
|
||||||
|
//
|
||||||
|
// A mix of the two cannot be resolved from the breakpoints alone — which
|
||||||
|
// block owns the host-row reflow is a property of the rules inside it, not
|
||||||
|
// of the condition — so this throws rather than guessing. Guessing is how
|
||||||
|
// the wrong expectation gets applied at the right widths and the whole
|
||||||
|
// sweep quietly verifies nothing.
|
||||||
|
export function expectsStackedLayout(width, conditions) {
|
||||||
|
const kinds = new Set(conditions.map((c) => c.type));
|
||||||
|
for (const kind of kinds) {
|
||||||
|
if (kind !== "max" && kind !== "min") {
|
||||||
|
throw new Error(
|
||||||
|
`unsupported media condition type "${kind}" in ` +
|
||||||
|
"expectsStackedLayout (test/viewport/viewports.js)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (kinds.has("max") && kinds.has("min")) {
|
||||||
|
throw new Error(
|
||||||
|
"the app now mixes max-width and min-width breakpoints (" +
|
||||||
|
conditions
|
||||||
|
.map((c) => `${c.type}-width ${c.px}px in ${c.source}`)
|
||||||
|
.join(", ") +
|
||||||
|
"), so which layout a width should be showing can no longer " +
|
||||||
|
"be inferred from the breakpoint list; teach " +
|
||||||
|
"expectsStackedLayout in test/viewport/viewports.js which " +
|
||||||
|
"block owns the host-row reflow",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (kinds.has("min")) {
|
||||||
|
return !conditions.some((c) => width >= c.px);
|
||||||
|
}
|
||||||
|
return conditions.some((c) => width <= c.px);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Viewports that are not derived from a breakpoint. Each one is here for
|
||||||
|
// a stated reason; none of them is a stand-in for "a phone".
|
||||||
|
const ANCHOR_VIEWPORTS = [
|
||||||
|
{
|
||||||
|
name: "floor-portrait",
|
||||||
|
width: 320,
|
||||||
|
height: 568,
|
||||||
|
deviceScaleFactor: 2,
|
||||||
|
touch: true,
|
||||||
|
why: "320px is the narrowest viewport still in mainstream use; nothing has to work below it",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "phone-landscape-narrow",
|
||||||
|
width: 667,
|
||||||
|
height: 375,
|
||||||
|
deviceScaleFactor: 2,
|
||||||
|
touch: true,
|
||||||
|
why: "phone rotated to landscape, still inside the narrow layout",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "phone-landscape-wide",
|
||||||
|
width: 844,
|
||||||
|
height: 390,
|
||||||
|
deviceScaleFactor: 3,
|
||||||
|
touch: true,
|
||||||
|
why: "large phone rotated to landscape: crosses into the wide layout while still being a touch device",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "desktop",
|
||||||
|
width: 1280,
|
||||||
|
height: 800,
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
touch: false,
|
||||||
|
why: "desktop baseline",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function deriveViewports(root) {
|
||||||
|
const conditions = [
|
||||||
|
...mediaConditionsFromCss(
|
||||||
|
readFileSync(join(root, "src/styles.css"), "utf8"),
|
||||||
|
"src/styles.css",
|
||||||
|
),
|
||||||
|
...mediaConditionsFromMarkup([
|
||||||
|
{
|
||||||
|
path: "src/main.js",
|
||||||
|
text: readFileSync(join(root, "src/main.js"), "utf8"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "index.html",
|
||||||
|
text: readFileSync(join(root, "index.html"), "utf8"),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (conditions.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
"no responsive breakpoints found in src/styles.css, src/main.js or " +
|
||||||
|
"index.html — either the responsive layout was deleted or this " +
|
||||||
|
"derivation has stopped matching the sources",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewports = new Map();
|
||||||
|
const add = (viewport) => {
|
||||||
|
const key = `${viewport.width}x${viewport.height}`;
|
||||||
|
if (!viewports.has(key)) viewports.set(key, viewport);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const condition of conditions) {
|
||||||
|
for (const [offset, label] of [
|
||||||
|
[-1, "below"],
|
||||||
|
[0, "at"],
|
||||||
|
[+1, "above"],
|
||||||
|
]) {
|
||||||
|
const width = condition.px + offset;
|
||||||
|
add({
|
||||||
|
name: `${condition.type}-width-${condition.px}-${label}`,
|
||||||
|
width,
|
||||||
|
// Tall enough that the whole app is laid out in one column
|
||||||
|
// without the viewport height influencing wrapping.
|
||||||
|
height: 1024,
|
||||||
|
deviceScaleFactor: 2,
|
||||||
|
touch: true,
|
||||||
|
why: `${offset === 0 ? "exactly on" : `1px ${label}`} the ${condition.type}-width: ${condition.px}px breakpoint declared in ${condition.source}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const anchor of ANCHOR_VIEWPORTS) add(anchor);
|
||||||
|
|
||||||
|
return {
|
||||||
|
conditions,
|
||||||
|
viewports: [...viewports.values()]
|
||||||
|
.map((viewport) => ({
|
||||||
|
...viewport,
|
||||||
|
expectStacked: expectsStackedLayout(viewport.width, conditions),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.width - b.width || a.height - b.height),
|
||||||
|
};
|
||||||
|
}
|
||||||
154
yarn.lock
154
yarn.lock
@@ -197,6 +197,14 @@
|
|||||||
"@emnapi/runtime" "^1.7.1"
|
"@emnapi/runtime" "^1.7.1"
|
||||||
"@tybys/wasm-util" "^0.10.1"
|
"@tybys/wasm-util" "^0.10.1"
|
||||||
|
|
||||||
|
"@puppeteer/browsers@3.1.0":
|
||||||
|
version "3.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@puppeteer/browsers/-/browsers-3.1.0.tgz#5728ae0bc649263133ac1f8bd5d360eb75d11748"
|
||||||
|
integrity sha512-RDLpio3fH/qrj5k4DVY6eyiN8tCS0Zovd/6jW//n605oeqkWcUjn+3k+9ZtZBnbwMpsu0F7xDIiKXvVmG5c5Bw==
|
||||||
|
dependencies:
|
||||||
|
modern-tar "^0.7.6"
|
||||||
|
yargs "^18.0.0"
|
||||||
|
|
||||||
"@rollup/rollup-android-arm-eabi@4.57.0":
|
"@rollup/rollup-android-arm-eabi@4.57.0":
|
||||||
version "4.57.0"
|
version "4.57.0"
|
||||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.0.tgz#f762035679a6b168138c94c960fda0b0cdb00d98"
|
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.0.tgz#f762035679a6b168138c94c960fda0b0cdb00d98"
|
||||||
@@ -441,6 +449,16 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e"
|
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e"
|
||||||
integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
|
integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
|
||||||
|
|
||||||
|
ansi-regex@^6.2.2:
|
||||||
|
version "6.2.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.2.2.tgz#60216eea464d864597ce2832000738a0589650c1"
|
||||||
|
integrity sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==
|
||||||
|
|
||||||
|
ansi-styles@^6.2.1:
|
||||||
|
version "6.2.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041"
|
||||||
|
integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==
|
||||||
|
|
||||||
autoprefixer@^10.4.23:
|
autoprefixer@^10.4.23:
|
||||||
version "10.4.23"
|
version "10.4.23"
|
||||||
resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.23.tgz#c6aa6db8e7376fcd900f9fd79d143ceebad8c4e6"
|
resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.23.tgz#c6aa6db8e7376fcd900f9fd79d143ceebad8c4e6"
|
||||||
@@ -473,16 +491,43 @@ caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001760:
|
|||||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz#b6f6b55cb25a2d888d9393104d14751c6a7d6f7a"
|
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz#b6f6b55cb25a2d888d9393104d14751c6a7d6f7a"
|
||||||
integrity sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==
|
integrity sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==
|
||||||
|
|
||||||
|
chromium-bidi@17.0.2:
|
||||||
|
version "17.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/chromium-bidi/-/chromium-bidi-17.0.2.tgz#921a586deecd0c2d8b9242c4c1b73c3aa39ff77c"
|
||||||
|
integrity sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==
|
||||||
|
dependencies:
|
||||||
|
mitt "^3.0.1"
|
||||||
|
zod "^3.24.1"
|
||||||
|
|
||||||
|
cliui@^9.0.1:
|
||||||
|
version "9.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/cliui/-/cliui-9.0.1.tgz#6f7890f386f6f1f79953adc1f78dec46fcc2d291"
|
||||||
|
integrity sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==
|
||||||
|
dependencies:
|
||||||
|
string-width "^7.2.0"
|
||||||
|
strip-ansi "^7.1.0"
|
||||||
|
wrap-ansi "^9.0.0"
|
||||||
|
|
||||||
detect-libc@^2.0.3:
|
detect-libc@^2.0.3:
|
||||||
version "2.1.2"
|
version "2.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad"
|
resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad"
|
||||||
integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==
|
integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==
|
||||||
|
|
||||||
|
devtools-protocol@0.0.1653615:
|
||||||
|
version "0.0.1653615"
|
||||||
|
resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1653615.tgz#c600e0c619612156b2422a66d958ba188d87dbe8"
|
||||||
|
integrity sha512-pGVkY3T/qXxAp2nFPodwYqOevk6ncNMSmvL8QfRCx5ZWGd6Vor7AFNmyaA8Zs6uJyP1QAfjuLandCgvSix1BNA==
|
||||||
|
|
||||||
electron-to-chromium@^1.5.263:
|
electron-to-chromium@^1.5.263:
|
||||||
version "1.5.282"
|
version "1.5.282"
|
||||||
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.282.tgz#6695816e5b170210d6aa07561546ed7d97347630"
|
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.282.tgz#6695816e5b170210d6aa07561546ed7d97347630"
|
||||||
integrity sha512-FCPkJtpst28UmFzd903iU7PdeVTfY0KAeJy+Lk0GLZRwgwYHn/irRcaCbQQOmr5Vytc/7rcavsYLvTM8RiHYhQ==
|
integrity sha512-FCPkJtpst28UmFzd903iU7PdeVTfY0KAeJy+Lk0GLZRwgwYHn/irRcaCbQQOmr5Vytc/7rcavsYLvTM8RiHYhQ==
|
||||||
|
|
||||||
|
emoji-regex@^10.3.0:
|
||||||
|
version "10.6.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-10.6.0.tgz#bf3d6e8f7f8fd22a65d9703475bc0147357a6b0d"
|
||||||
|
integrity sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==
|
||||||
|
|
||||||
enhanced-resolve@^5.18.3:
|
enhanced-resolve@^5.18.3:
|
||||||
version "5.18.4"
|
version "5.18.4"
|
||||||
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz#c22d33055f3952035ce6a144ce092447c525f828"
|
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz#c22d33055f3952035ce6a144ce092447c525f828"
|
||||||
@@ -523,7 +568,7 @@ esbuild@^0.27.0:
|
|||||||
"@esbuild/win32-ia32" "0.27.2"
|
"@esbuild/win32-ia32" "0.27.2"
|
||||||
"@esbuild/win32-x64" "0.27.2"
|
"@esbuild/win32-x64" "0.27.2"
|
||||||
|
|
||||||
escalade@^3.2.0:
|
escalade@^3.1.1, escalade@^3.2.0:
|
||||||
version "3.2.0"
|
version "3.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5"
|
resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5"
|
||||||
integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==
|
integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==
|
||||||
@@ -543,6 +588,16 @@ fsevents@~2.3.2, fsevents@~2.3.3:
|
|||||||
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
|
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
|
||||||
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
|
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
|
||||||
|
|
||||||
|
get-caller-file@^2.0.5:
|
||||||
|
version "2.0.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
|
||||||
|
integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
|
||||||
|
|
||||||
|
get-east-asian-width@^1.0.0, get-east-asian-width@^1.5.0:
|
||||||
|
version "1.6.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz#216900f91df11a8b2c198c3e1d93d6c035a776b9"
|
||||||
|
integrity sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==
|
||||||
|
|
||||||
graceful-fs@^4.2.4:
|
graceful-fs@^4.2.4:
|
||||||
version "4.2.11"
|
version "4.2.11"
|
||||||
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
|
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
|
||||||
@@ -634,6 +689,16 @@ magic-string@^0.30.21:
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@jridgewell/sourcemap-codec" "^1.5.5"
|
"@jridgewell/sourcemap-codec" "^1.5.5"
|
||||||
|
|
||||||
|
mitt@^3.0.1:
|
||||||
|
version "3.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/mitt/-/mitt-3.0.1.tgz#ea36cf0cc30403601ae074c8f77b7092cdab36d1"
|
||||||
|
integrity sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==
|
||||||
|
|
||||||
|
modern-tar@^0.7.6:
|
||||||
|
version "0.7.7"
|
||||||
|
resolved "https://registry.yarnpkg.com/modern-tar/-/modern-tar-0.7.7.tgz#ca71d79603630076b10733b0751ccab284bbc1ef"
|
||||||
|
integrity sha512-t9VmxaqrmANnEOBhpSDI6HD192Ge48k8vmWqQQL7hSFEqHEYwZbbsu49+aKLWZeRvFs3j1pMhXOqqF4kPlvjkQ==
|
||||||
|
|
||||||
nanoid@^3.3.11:
|
nanoid@^3.3.11:
|
||||||
version "3.3.11"
|
version "3.3.11"
|
||||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b"
|
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b"
|
||||||
@@ -673,6 +738,18 @@ prettier@^3.8.1:
|
|||||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173"
|
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173"
|
||||||
integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==
|
integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==
|
||||||
|
|
||||||
|
puppeteer-core@25.5.0:
|
||||||
|
version "25.5.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-25.5.0.tgz#a41b14d582056b998e0bc3561ac47c7615d38a53"
|
||||||
|
integrity sha512-XPNT0dQJtphqQ4I29zxlG4IIPbg1iEHAQKWuQgtMJGXjACV77pZSmJvDi51IIIfd+DTKICcopJwUx4upVQ4XbA==
|
||||||
|
dependencies:
|
||||||
|
"@puppeteer/browsers" "3.1.0"
|
||||||
|
chromium-bidi "17.0.2"
|
||||||
|
devtools-protocol "0.0.1653615"
|
||||||
|
typed-query-selector "^2.12.2"
|
||||||
|
webdriver-bidi-protocol "0.4.2"
|
||||||
|
ws "^8.21.1"
|
||||||
|
|
||||||
rollup@^4.43.0:
|
rollup@^4.43.0:
|
||||||
version "4.57.0"
|
version "4.57.0"
|
||||||
resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.57.0.tgz#9fa13c1fb779d480038f45708b5e01b9449b6853"
|
resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.57.0.tgz#9fa13c1fb779d480038f45708b5e01b9449b6853"
|
||||||
@@ -712,6 +789,30 @@ source-map-js@^1.2.1:
|
|||||||
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
|
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
|
||||||
integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
|
integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
|
||||||
|
|
||||||
|
string-width@^7.0.0, string-width@^7.2.0:
|
||||||
|
version "7.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/string-width/-/string-width-7.2.0.tgz#b5bb8e2165ce275d4d43476dd2700ad9091db6dc"
|
||||||
|
integrity sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==
|
||||||
|
dependencies:
|
||||||
|
emoji-regex "^10.3.0"
|
||||||
|
get-east-asian-width "^1.0.0"
|
||||||
|
strip-ansi "^7.1.0"
|
||||||
|
|
||||||
|
string-width@^8.2.1:
|
||||||
|
version "8.2.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/string-width/-/string-width-8.2.2.tgz#7310516493df575742fe98af6fae87d85d5ed0ac"
|
||||||
|
integrity sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==
|
||||||
|
dependencies:
|
||||||
|
get-east-asian-width "^1.5.0"
|
||||||
|
strip-ansi "^7.1.2"
|
||||||
|
|
||||||
|
strip-ansi@^7.1.0, strip-ansi@^7.1.2:
|
||||||
|
version "7.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.2.0.tgz#d22a269522836a627af8d04b5c3fd2c7fa3e32e3"
|
||||||
|
integrity sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==
|
||||||
|
dependencies:
|
||||||
|
ansi-regex "^6.2.2"
|
||||||
|
|
||||||
tailwindcss@4.1.18, tailwindcss@^4.1.18:
|
tailwindcss@4.1.18, tailwindcss@^4.1.18:
|
||||||
version "4.1.18"
|
version "4.1.18"
|
||||||
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-4.1.18.tgz#f488ba47853abdb5354daf9679d3e7791fc4f4e3"
|
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-4.1.18.tgz#f488ba47853abdb5354daf9679d3e7791fc4f4e3"
|
||||||
@@ -735,6 +836,11 @@ tslib@^2.4.0:
|
|||||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
|
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
|
||||||
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
|
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
|
||||||
|
|
||||||
|
typed-query-selector@^2.12.2:
|
||||||
|
version "2.12.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/typed-query-selector/-/typed-query-selector-2.12.2.tgz#65e2462ac6b0aecfae1bfac1a4f3027070dbabaa"
|
||||||
|
integrity sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==
|
||||||
|
|
||||||
update-browserslist-db@^1.2.0:
|
update-browserslist-db@^1.2.0:
|
||||||
version "1.2.3"
|
version "1.2.3"
|
||||||
resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz#64d76db58713136acbeb4c49114366cc6cc2e80d"
|
resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz#64d76db58713136acbeb4c49114366cc6cc2e80d"
|
||||||
@@ -756,3 +862,49 @@ vite@^7.3.1:
|
|||||||
tinyglobby "^0.2.15"
|
tinyglobby "^0.2.15"
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents "~2.3.3"
|
fsevents "~2.3.3"
|
||||||
|
|
||||||
|
webdriver-bidi-protocol@0.4.2:
|
||||||
|
version "0.4.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz#f51bb71c2606e90e3d5727607c728b25d617b58b"
|
||||||
|
integrity sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==
|
||||||
|
|
||||||
|
wrap-ansi@^9.0.0:
|
||||||
|
version "9.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz#956832dea9494306e6d209eb871643bb873d7c98"
|
||||||
|
integrity sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==
|
||||||
|
dependencies:
|
||||||
|
ansi-styles "^6.2.1"
|
||||||
|
string-width "^7.0.0"
|
||||||
|
strip-ansi "^7.1.0"
|
||||||
|
|
||||||
|
ws@^8.21.1:
|
||||||
|
version "8.21.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/ws/-/ws-8.21.3.tgz#660b4faddb6a3e575c86e078126919961f4de4fc"
|
||||||
|
integrity sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==
|
||||||
|
|
||||||
|
y18n@^5.0.5:
|
||||||
|
version "5.0.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"
|
||||||
|
integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==
|
||||||
|
|
||||||
|
yargs-parser@^22.0.0:
|
||||||
|
version "22.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-22.0.0.tgz#87b82094051b0567717346ecd00fd14804b357c8"
|
||||||
|
integrity sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==
|
||||||
|
|
||||||
|
yargs@^18.0.0:
|
||||||
|
version "18.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/yargs/-/yargs-18.1.0.tgz#cd7e98c703ef51695bbbf062ed58f28e94291b56"
|
||||||
|
integrity sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==
|
||||||
|
dependencies:
|
||||||
|
cliui "^9.0.1"
|
||||||
|
escalade "^3.1.1"
|
||||||
|
get-caller-file "^2.0.5"
|
||||||
|
string-width "^8.2.1"
|
||||||
|
y18n "^5.0.5"
|
||||||
|
yargs-parser "^22.0.0"
|
||||||
|
|
||||||
|
zod@^3.24.1:
|
||||||
|
version "3.25.76"
|
||||||
|
resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34"
|
||||||
|
integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==
|
||||||
|
|||||||
Reference in New Issue
Block a user