From 2f86b4e2244d4f5d6e640c157bf3f50b300924c3 Mon Sep 17 00:00:00 2001 From: Akhileshwar Shriram <112577383+AkhilTheBoss@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:39:42 -0700 Subject: [PATCH 1/6] test(client): add Percy visual regression testing --- .github/workflows/visual_regression.yml | 39 + .gitignore | 2 + client/package-lock.json | 1777 +++++++++++++++++++++- client/package.json | 7 +- client/playwright.config.ts | 18 + client/visual-tests/login.visual.spec.ts | 39 + docs/visual-regression-testing.md | 49 + server/package-lock.json | 4 +- server/package.json | 4 +- 9 files changed, 1921 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/visual_regression.yml create mode 100644 client/playwright.config.ts create mode 100644 client/visual-tests/login.visual.spec.ts create mode 100644 docs/visual-regression-testing.md diff --git a/.github/workflows/visual_regression.yml b/.github/workflows/visual_regression.yml new file mode 100644 index 000000000..a63b0dbf7 --- /dev/null +++ b/.github/workflows/visual_regression.yml @@ -0,0 +1,39 @@ +name: Visual Regression + +on: + pull_request: + push: + branches: + - staging + - master + +jobs: + visual_regression: + name: visual_regression + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./client + env: + PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm + cache-dependency-path: client/package-lock.json + - name: Install dependencies + run: npm ci + - name: Install Chromium + run: npx playwright install --with-deps chromium + - name: Run visual regression test + run: | + if [ -n "$PERCY_TOKEN" ]; then + npm run test:visual + else + echo "PERCY_TOKEN is not configured; running the browser test without uploading snapshots." + npm run test:visual:local + fi diff --git a/.gitignore b/.gitignore index 9f765769b..6e93d9a92 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,8 @@ node_modules/ # Build / Dist dist build +playwright-report/ +test-results/ # macOS Metafiles .DS_Store diff --git a/client/package-lock.json b/client/package-lock.json index 35471e22b..c92e0ed82 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -93,6 +93,9 @@ "zod": "3.25.76" }, "devDependencies": { + "@percy/cli": "^1.32.6", + "@percy/playwright": "^1.1.2", + "@playwright/test": "^1.61.1", "@tailwindcss/forms": "0.5.11", "@tailwindcss/postcss": "4.2.4", "@tailwindcss/vite": "4.2.4", @@ -7417,6 +7420,39 @@ "node": ">=6" } }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", + "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@headlessui/react": { "version": "2.2.10", "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.10.tgz", @@ -7522,6 +7558,17 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, "node_modules/@lezer/common": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", @@ -7656,6 +7703,44 @@ "react-dom": ">=16.8.0" } }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/@parcel/watcher": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", @@ -7944,6 +8029,374 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@percy/cli": { + "version": "1.32.6", + "resolved": "https://registry.npmjs.org/@percy/cli/-/cli-1.32.6.tgz", + "integrity": "sha512-OKkL0Q9u1zkCrhQpBfVdIef8qj88n1O9fB4GQHyrz7Z61tAvGsBY9iwOluU5HfrJR7TiOqcFbWvsRa1E3+AHIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@percy/cli-app": "1.32.6", + "@percy/cli-build": "1.32.6", + "@percy/cli-command": "1.32.6", + "@percy/cli-config": "1.32.6", + "@percy/cli-doctor": "1.32.6", + "@percy/cli-exec": "1.32.6", + "@percy/cli-snapshot": "1.32.6", + "@percy/cli-upload": "1.32.6", + "@percy/client": "1.32.6", + "@percy/logger": "1.32.6" + }, + "bin": { + "percy": "bin/run.cjs" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/cli-app": { + "version": "1.32.6", + "resolved": "https://registry.npmjs.org/@percy/cli-app/-/cli-app-1.32.6.tgz", + "integrity": "sha512-t7+ti+JiLUPNAWKJitYKPaf4wHrJzmR+bfd6M1Uh41oQN67kYvRiweVWYuK7vST/mANr+36Re9VhXKoy+hiitg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@percy/cli-command": "1.32.6", + "@percy/cli-exec": "1.32.6" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/cli-build": { + "version": "1.32.6", + "resolved": "https://registry.npmjs.org/@percy/cli-build/-/cli-build-1.32.6.tgz", + "integrity": "sha512-xh/FA8IasbYwtmSe4ojWBVpUcgG3BixAPz9rabSzTfJPtG5TJEnWwb/9l4FYB8bTJXeGhVFbLMk9abNQVy4bxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@percy/cli-command": "1.32.6" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/cli-command": { + "version": "1.32.6", + "resolved": "https://registry.npmjs.org/@percy/cli-command/-/cli-command-1.32.6.tgz", + "integrity": "sha512-Y8rvlRUJSUcOI7vm5QMF41gNZBGIkuadPnoFIbYVqgFPs8G55tK/NBVfHrCJ4qn1JiUCiUr08alc+brQt319ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@percy/config": "1.32.6", + "@percy/core": "1.32.6", + "@percy/logger": "1.32.6" + }, + "bin": { + "percy-cli-readme": "bin/readme.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/cli-config": { + "version": "1.32.6", + "resolved": "https://registry.npmjs.org/@percy/cli-config/-/cli-config-1.32.6.tgz", + "integrity": "sha512-3zlvvgLJ182f3r56hFGLLSmhLaW9VY0FipHI2k9AzZUz2z0KDCBn8zPSm6ESBI/5G2F1kcLjGOTNvrXflVg4yA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@percy/cli-command": "1.32.6" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/cli-doctor": { + "version": "1.32.6", + "resolved": "https://registry.npmjs.org/@percy/cli-doctor/-/cli-doctor-1.32.6.tgz", + "integrity": "sha512-Eecqy8XO6VofxOEAdE6+qYAaN5310huIr2cB+Jvo1Pb/hALkPfr1Kf0fHZMcadwZvxxNGWTGnDjYq/mv1Vg0RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@percy/cli-command": "1.32.6", + "@percy/client": "1.32.6", + "@percy/config": "1.32.6", + "@percy/core": "1.32.6", + "@percy/env": "1.32.6", + "@percy/logger": "1.32.6", + "@percy/monitoring": "1.32.6", + "minimatch": "^9.0.0", + "ws": "^8.17.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/cli-exec": { + "version": "1.32.6", + "resolved": "https://registry.npmjs.org/@percy/cli-exec/-/cli-exec-1.32.6.tgz", + "integrity": "sha512-kZO+zaG5tKTpEgrs7eXsEOc/tvYm5O9gq+97BuNbjsFWN5/HnwwCkNNHadjMjVquh4dQ/F/UCeI1ff2TXz1s/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@percy/cli-command": "1.32.6", + "@percy/logger": "1.32.6", + "cross-spawn": "^7.0.3", + "which": "^2.0.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/cli-snapshot": { + "version": "1.32.6", + "resolved": "https://registry.npmjs.org/@percy/cli-snapshot/-/cli-snapshot-1.32.6.tgz", + "integrity": "sha512-sQvE/tls+Boa/OAT3Ma2BZZ86LxO54bgy9BBZOzZtNgfU245WlfgkMOmKYU023ykuzDRZbDKuGM5pPdIHORFYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@percy/cli-command": "1.32.6", + "yaml": "^2.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/cli-upload": { + "version": "1.32.6", + "resolved": "https://registry.npmjs.org/@percy/cli-upload/-/cli-upload-1.32.6.tgz", + "integrity": "sha512-btgyXK+lI1purljvGq41UBkB2xELSoWY0C2SAucsnhy8RJOez/3zwWKYhQ7PUCd5hIUyrJjX7fMR6uwxTrb48w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@percy/cli-command": "1.32.6", + "fast-glob": "^3.2.11", + "image-size": "~1.0.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/client": { + "version": "1.32.6", + "resolved": "https://registry.npmjs.org/@percy/client/-/client-1.32.6.tgz", + "integrity": "sha512-lvHEDmMSc4Y3+MseMZuhor0hzIPgdy4/q3GYi72OaBkWjHXsb603cZQQZD7qKP0gx2Mehowy/wsSubZY+0qDDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@percy/config": "1.32.6", + "@percy/env": "1.32.6", + "@percy/logger": "1.32.6", + "pac-proxy-agent": "^7.0.2", + "pako": "^2.1.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/config": { + "version": "1.32.6", + "resolved": "https://registry.npmjs.org/@percy/config/-/config-1.32.6.tgz", + "integrity": "sha512-7ZxtVy1ZZlqkG07vtftOp/WGsPoCXOJTeUCh9nG0vnQolDqshls2GZYwTaZxdmTTXzUkMcSUp3DxH39Rvo3Q0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@percy/logger": "1.32.6", + "ajv": "^8.6.2", + "cosmiconfig": "^8.0.0", + "yaml": "^2.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/config/node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@percy/core": { + "version": "1.32.6", + "resolved": "https://registry.npmjs.org/@percy/core/-/core-1.32.6.tgz", + "integrity": "sha512-tMbDU7D4XD1V/vE3E8f5UC7tBG++Mgcsk814knf+RND/6CcPfkVpfNJ1KiMq3nXu2kv+UvwhkUGgLUDZuqEdDQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@grpc/grpc-js": "^1.14.3", + "@grpc/proto-loader": "^0.8.0", + "@percy/client": "1.32.6", + "@percy/config": "1.32.6", + "@percy/dom": "1.32.6", + "@percy/logger": "1.32.6", + "@percy/monitoring": "1.32.6", + "@percy/webdriver-utils": "1.32.6", + "adm-zip": "^0.6.0", + "busboy": "^1.6.0", + "content-disposition": "^0.5.4", + "cross-spawn": "^7.0.3", + "fast-glob": "^3.2.11", + "fast-xml-parser": "^4.4.1", + "micromatch": "^4.0.8", + "mime-types": "^2.1.34", + "pako": "^2.1.0", + "path-to-regexp": "^6.3.0", + "rimraf": "^3.0.2", + "ws": "^8.17.1", + "yaml": "^2.4.1" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "@percy/cli-doctor": "1.32.6" + } + }, + "node_modules/@percy/core/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/@percy/dom": { + "version": "1.32.6", + "resolved": "https://registry.npmjs.org/@percy/dom/-/dom-1.32.6.tgz", + "integrity": "sha512-ODX3ATJIoor46qQ8h3nyTj/d9BTyXpxhjcCTdkxXFBvO9KYGTE9QeDlyceesco26KkO+TtGVHULLWkEr1R94sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@percy/env": { + "version": "1.32.6", + "resolved": "https://registry.npmjs.org/@percy/env/-/env-1.32.6.tgz", + "integrity": "sha512-RfjPagc/dVIWlIRfsi/P0bNtPx5jdkmZ6w527bJePr6Cs5Hg2se/vV7S3NSAPtf94EElVsCdzL6C9VzJdztl+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@percy/logger": "1.32.6" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/logger": { + "version": "1.32.6", + "resolved": "https://registry.npmjs.org/@percy/logger/-/logger-1.32.6.tgz", + "integrity": "sha512-9JwYKzIIcAdtOOc+VrV24DB49lZHIZhPR3Ihku3bQAcIcPhvwJwnaWzfkrcJ3r3AwZTNZAqTmtbPgE5xWJPrWg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/monitoring": { + "version": "1.32.6", + "resolved": "https://registry.npmjs.org/@percy/monitoring/-/monitoring-1.32.6.tgz", + "integrity": "sha512-4A+wDJ3HFyrwSjX8kz05fs03NfngJI8ydYuejSybWOpuSyZc9YMPJPl3OVK0auGOJSOqt0Fcfk6/1N8qidNy6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@percy/config": "1.32.6", + "@percy/logger": "1.32.6", + "@percy/sdk-utils": "1.32.6", + "systeminformation": "^5.25.11" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/playwright": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@percy/playwright/-/playwright-1.1.2.tgz", + "integrity": "sha512-IghZHmh1+R3gvneOd//+HbN2xg6e6qgnMy9yJ5V7vWJ49JpkHeU5Esjs1qI9EqFMHKAg0zOorDQ0mJRglQpkyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@percy/sdk-utils": "^1.32.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@percy/cli": ">=1.32.3", + "@playwright/test": ">=1.49.0", + "playwright-core": ">=1" + }, + "peerDependenciesMeta": { + "@percy/cli": { + "optional": true + }, + "@playwright/test": { + "optional": true + } + } + }, + "node_modules/@percy/sdk-utils": { + "version": "1.32.6", + "resolved": "https://registry.npmjs.org/@percy/sdk-utils/-/sdk-utils-1.32.6.tgz", + "integrity": "sha512-dtS4jFz55zQzd0tAtxpt77w4gMeySEjLZEDFHrs3mtmpmnjRlSD0koYl8pj4/X3f375SmXTXG4+xIRgPpSTgZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pac-proxy-agent": "^7.0.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/webdriver-utils": { + "version": "1.32.6", + "resolved": "https://registry.npmjs.org/@percy/webdriver-utils/-/webdriver-utils-1.32.6.tgz", + "integrity": "sha512-kUS3wimaBS35BSImbZIsitK4aHDy2hHBpoqYUv+i7SlcFRhIid+0LLRiy0KfPq6DklXEoYgVCgppYu+ymzXhOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@percy/config": "1.32.6", + "@percy/sdk-utils": "1.32.6" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -7954,6 +8407,72 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@react-aria/focus": { "version": "3.22.1", "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.22.1.tgz", @@ -9073,6 +9592,13 @@ "@testing-library/dom": ">=7.21.4" } }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "dev": true, + "license": "MIT" + }, "node_modules/@transloadit/prettier-bytes": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/@transloadit/prettier-bytes/-/prettier-bytes-0.3.5.tgz", @@ -10516,6 +11042,16 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/adm-zip": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz", + "integrity": "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -10528,6 +11064,23 @@ "node": ">= 6.0.0" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -10551,6 +11104,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/aria-hidden": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -10600,6 +11160,19 @@ "node": ">=12" } }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -10659,6 +11232,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.11.9", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.9.tgz", @@ -10672,6 +11252,16 @@ "node": ">=6.0.0" } }, + "node_modules/basic-ftp": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", + "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -10689,6 +11279,29 @@ "license": "MIT", "peer": true }, + "node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/browserslist": { "version": "4.28.7", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", @@ -10729,6 +11342,18 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dev": true, + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -11362,6 +11987,21 @@ "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", "license": "MIT" }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/clone": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", @@ -11462,6 +12102,26 @@ "node": ">= 10" } }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -11521,6 +12181,21 @@ "integrity": "sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==", "license": "MIT" }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/css-mediaquery": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/css-mediaquery/-/css-mediaquery-0.1.2.tgz", @@ -11973,6 +12648,16 @@ "node": ">=12" } }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -12158,6 +12843,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/delaunator": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", @@ -12319,6 +13019,13 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, "node_modules/enhanced-resolve": { "version": "5.24.5", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", @@ -12509,6 +13216,63 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/escodegen/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/estree-util-is-identifier-name": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", @@ -12529,6 +13293,16 @@ "@types/estree": "^1.0.0" } }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -12569,6 +13343,13 @@ "integrity": "sha512-0DX6gEWwfcDkglS7bdr6oH5jb6TR6BvsVvmOcsUzyl5ESqkze9XImyNZPLoZtaGi1kKGjH/+dNqd8O0SjD7KgQ==", "license": "MIT" }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-equals": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.1.tgz", @@ -12578,6 +13359,69 @@ "node": ">=6.0.0" } }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-uri": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-xml-parser": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.7.tgz", + "integrity": "sha512-a6Qh1RMCNbSrU1+sAyAAZH3rTe+OaWJbNZIq0S+ifZciUUOQtlVxBJwoTUE2bYhysmG/RYyI5WJFIKdBahJdrQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^1.0.5" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -12602,6 +13446,19 @@ "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==", "license": "MIT" }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/filter-obj": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", @@ -12699,6 +13556,13 @@ "integrity": "sha512-RAskVuBmnTcPJXh87oVhYmnNGy/9lvZlLHGui8QFB8yRBuUjzpZoZfZ+hKmDtBDmWNrE2/LRta06W5WmhTzzWQ==", "license": "MIT" }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -12763,6 +13627,16 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -12781,23 +13655,97 @@ "math-intrinsics": "^1.1.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" } }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, "license": "MIT", "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" }, "engines": { - "node": ">= 0.4" + "node": "*" } }, "node_modules/globalize": { @@ -13277,6 +14225,30 @@ "entities": "^3.0.1" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -13308,6 +14280,22 @@ "node": ">=0.10.0" } }, + "node_modules/image-size": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.0.2.tgz", + "integrity": "sha512-xfOoWjceHntRb3qFCrh5ZFORYH8XCdYpASltMhZ/Q0KZiOwjdE/Yl2QCiWdwD+lygV5bMCvauzgu5PxBX/Yerg==", + "dev": true, + "license": "MIT", + "dependencies": { + "queue": "6.0.2" + }, + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/immutable": { "version": "5.1.9", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", @@ -13341,6 +14329,25 @@ "node": ">=8" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, "node_modules/inline-style-parser": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.1.1.tgz", @@ -13380,6 +14387,16 @@ "loose-envify": "^1.0.0" } }, + "node_modules/ip-address": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", + "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -13562,18 +14579,26 @@ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "is-extglob": "^2.1.1" }, @@ -13622,6 +14647,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/is-number-object": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", @@ -13801,6 +14836,13 @@ "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", "license": "MIT" }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -13838,6 +14880,29 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsdom": { "version": "29.1.1", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", @@ -13933,6 +14998,13 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "license": "MIT" }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -14293,6 +15365,13 @@ "lodash._basetostring": "~4.12.0" } }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -14315,6 +15394,13 @@ "lodash._baseuniq": "~4.6.0" } }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -14735,6 +15821,16 @@ "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", "license": "MIT" }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -15305,6 +16401,33 @@ ], "license": "MIT" }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -15355,6 +16478,22 @@ "mini-svg-data-uri": "cli.js" } }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", @@ -15406,6 +16545,16 @@ "node": "^18 || >=20" } }, + "node_modules/netmask": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", + "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/node-addon-api": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", @@ -15519,6 +16668,16 @@ "node": ">=12.20.0" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/p-queue": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-8.1.1.tgz", @@ -15564,6 +16723,81 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-proxy-agent/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "dev": true, + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, "node_modules/papaparse": { "version": "5.5.3", "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", @@ -15649,6 +16883,26 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -15699,6 +16953,67 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "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==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright/node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -15862,6 +17177,30 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "dev": true, + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -15939,6 +17278,37 @@ "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", "license": "MIT" }, + "node_modules/queue": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "~2.0.3" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -16798,6 +18168,16 @@ "integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==", "license": "MIT" }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -16859,6 +18239,34 @@ "node": ">= 4" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/robust-predicates": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", @@ -16910,12 +18318,57 @@ "fsevents": "~2.3.2" } }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/rw": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", "license": "BSD-3-Clause" }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-regex-test": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", @@ -17081,6 +18534,29 @@ "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", "license": "MIT" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/side-channel": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", @@ -17166,6 +18642,57 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/socks-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -17238,6 +18765,15 @@ "node": ">= 0.4" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "dev": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/strict-uri-encode": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", @@ -17247,6 +18783,21 @@ "node": ">=4" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -17261,6 +18812,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -17294,6 +18858,19 @@ } } }, + "node_modules/strnum": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz", + "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/style-it": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/style-it/-/style-it-2.1.4.tgz", @@ -17380,6 +18957,33 @@ "dev": true, "license": "MIT" }, + "node_modules/systeminformation": { + "version": "5.33.6", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.33.6.tgz", + "integrity": "sha512-hMOQG/eRUzuopuYGGdl8ntkau0nEC7fOaRoTUg1RSr2GTQIk2VNa76DA0+ApajkGfzmcgAupgIP/vt+jtoe5EA==", + "dev": true, + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32", + "freebsd", + "openbsd", + "netbsd", + "sunos", + "android" + ], + "bin": { + "systeminformation": "lib/cli.js" + }, + "engines": { + "node": ">=10.0.0" + }, + "funding": { + "type": "Buy me a coffee", + "url": "https://www.buymeacoffee.com/systeminfo" + } + }, "node_modules/tabbable": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", @@ -17524,6 +19128,19 @@ "dev": true, "license": "MIT" }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/topojson-client": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", @@ -18240,6 +19857,22 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/which-boxed-primitive": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", @@ -18329,6 +19962,69 @@ "resolved": "https://registry.npmjs.org/world-countries/-/world-countries-5.1.0.tgz", "integrity": "sha512-CXR6EBvTbArDlDDIWU3gfKb7Qk0ck2WNZ234b/A0vuecPzIfzzxH+O6Ejnvg1sT8XuiZjVlzOH0h08ZtaO7g0w==" }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "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/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -18346,6 +20042,16 @@ "dev": true, "license": "MIT" }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -18353,6 +20059,51 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/client/package.json b/client/package.json index 7b158efa4..e1b98fd8a 100644 --- a/client/package.json +++ b/client/package.json @@ -8,7 +8,9 @@ "build": "tsc && vite build", "serve": "vite preview", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "test:visual": "percy exec -- playwright test", + "test:visual:local": "playwright test" }, "dependencies": { "@ckeditor/ckeditor5-react": "11.2.0", @@ -115,6 +117,9 @@ ] }, "devDependencies": { + "@percy/cli": "^1.32.6", + "@percy/playwright": "^1.1.2", + "@playwright/test": "^1.61.1", "@tailwindcss/forms": "0.5.11", "@tailwindcss/postcss": "4.2.4", "@tailwindcss/vite": "4.2.4", diff --git a/client/playwright.config.ts b/client/playwright.config.ts new file mode 100644 index 000000000..b56653575 --- /dev/null +++ b/client/playwright.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './visual-tests', + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI ? 'github' : 'list', + use: { + baseURL: 'http://127.0.0.1:3000', + trace: 'on-first-retry', + }, + webServer: { + command: 'npm run start -- --host 127.0.0.1', + url: 'http://127.0.0.1:3000', + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/client/visual-tests/login.visual.spec.ts b/client/visual-tests/login.visual.spec.ts new file mode 100644 index 000000000..2908f3c0d --- /dev/null +++ b/client/visual-tests/login.visual.spec.ts @@ -0,0 +1,39 @@ +import { expect, test } from '@playwright/test'; +import percySnapshot from '@percy/playwright'; + +test('login page', async ({ page }) => { + // Keep this first visual test independent of the API, database, and third-party widget. + await page.route('**/api/v1/org', async (route) => { + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + err: false, + org: { + orgID: 'libretexts', + name: 'LibreTexts', + shortName: 'LibreTexts', + abbreviation: 'LT', + largeLogo: '', + mediumLogo: '', + smallLogo: '', + aboutLink: '', + commonsHeader: '', + commonsMessage: '', + videoLengthLimit: 0, + defaultProjectLead: '', + addToLibreGridList: false, + }, + }), + }); + }); + await page.route('https://cdn.libretexts.net/**', (route) => route.abort()); + + // This state does not auto-redirect to LibreOne, so the screenshot is deterministic. + await page.goto('/login?src=accountrequest'); + await expect(page.getByText('Login Required', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Continue' })).toBeVisible(); + + await percySnapshot(page, 'Conductor login', { + widths: [375, 1280], + }); +}); diff --git a/docs/visual-regression-testing.md b/docs/visual-regression-testing.md new file mode 100644 index 000000000..ad8d5c13d --- /dev/null +++ b/docs/visual-regression-testing.md @@ -0,0 +1,49 @@ +# Visual regression testing + +The initial proof of concept starts the Vite client, opens the login page in +Playwright, and sends desktop and mobile snapshots to Percy. Percy compares a +pull request's snapshots with the baseline for that pull request's target branch. +In the normal release flow, feature pull requests compare against `staging`, and +the later `staging` to `master` pull request compares against `master`. + +## One-time Percy setup + +1. Open the existing Percy Web project (or create one). +2. Copy its write-only project token from the Percy project settings. +3. In GitHub, open **Settings > Secrets and variables > Actions** and create a + repository secret named `PERCY_TOKEN`. +4. Install the Percy GitHub app for this repository and link it to the Percy + project. +5. Run the Visual Regression workflow on `staging` once to establish its baseline. + Pushes to both `staging` and `master` refresh their respective baselines after + a pull request has been reviewed and merged. + +The token must never be committed. Pull requests from forks do not receive the +secret; their workflow still verifies that the browser scenario loads, but it +does not upload snapshots to Percy. + +## Run locally + +Install Chromium once: + +```sh +cd client +npx playwright install chromium +``` + +Run the browser scenario without uploading to Percy: + +```sh +npm run test:visual:local +``` + +To create a Percy build, expose `PERCY_TOKEN` in your shell and run: + +```sh +npm run test:visual +``` + +The first scenario mocks the organization endpoint and blocks the external +support widget. This makes the login snapshot stable and avoids requiring the +server, database, or a test account. Add authenticated scenarios only after a +repeatable test-data and authentication strategy is available. diff --git a/server/package-lock.json b/server/package-lock.json index 106b5b191..84fbf877f 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -63,7 +63,7 @@ "node-cache": "5.1.2", "openai": "6.35.0", "path": "0.12.7", - "pino": "10.3.1", + "pino": "^10.3.1", "pino-http": "11.0.0", "rate-limiter-flexible": "5.0.5", "react-timezone-select": "2.1.5", @@ -94,7 +94,7 @@ "eslint": "8.57.1", "eslint-config-airbnb-base": "15.0.0", "eslint-plugin-import": "2.32.0", - "pino-pretty": "13.1.3", + "pino-pretty": "^13.1.3", "tsc-alias": "1.8.16", "tsup": "8.5.1", "tsx": "4.21.0", diff --git a/server/package.json b/server/package.json index 434079485..b6a8eeccd 100644 --- a/server/package.json +++ b/server/package.json @@ -68,7 +68,7 @@ "node-cache": "5.1.2", "openai": "6.35.0", "path": "0.12.7", - "pino": "10.3.1", + "pino": "^10.3.1", "pino-http": "11.0.0", "rate-limiter-flexible": "5.0.5", "react-timezone-select": "2.1.5", @@ -100,7 +100,7 @@ "eslint": "8.57.1", "eslint-config-airbnb-base": "15.0.0", "eslint-plugin-import": "2.32.0", - "pino-pretty": "13.1.3", + "pino-pretty": "^13.1.3", "tsc-alias": "1.8.16", "tsup": "8.5.1", "tsx": "4.21.0", From fcc563b3bec041125e15c6848232591389ca535f Mon Sep 17 00:00:00 2001 From: Akhileshwar Shriram <112577383+AkhilTheBoss@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:12:20 -0700 Subject: [PATCH 2/6] test(client): restructure visual flows and gate Percy vs local snapshots --- client/package.json | 7 +- client/playwright.config.ts | 55 ++++++++++- client/visual-tests/base.ts | 97 +++++++++++++++++++ client/visual-tests/flows/commons/base.ts | 9 ++ .../visual-tests/flows/commons/books/base.ts | 9 ++ .../commons/books/openFirstCommonsBook.ts | 21 ++++ client/visual-tests/flows/home/base.ts | 9 ++ client/visual-tests/flows/home/home.ts | 15 +++ .../flows/loginWithFallbackAuth.ts | 29 ++++++ .../books/open-first-book.visual.spec.ts | 18 ++++ .../login/fallback-auth.visual.spec.ts | 18 ++++ client/visual-tests/journey.visual.spec.ts | 32 ++++++ client/visual-tests/login.visual.spec.ts | 39 -------- client/visual-tests/utils/actions.ts | 41 ++++++++ client/visual-tests/utils/index.ts | 11 +++ client/visual-tests/utils/navigation.ts | 19 ++++ client/visual-tests/utils/waits.ts | 32 ++++++ docs/visual-regression-testing.md | 54 +++++++---- 18 files changed, 451 insertions(+), 64 deletions(-) create mode 100644 client/visual-tests/base.ts create mode 100644 client/visual-tests/flows/commons/base.ts create mode 100644 client/visual-tests/flows/commons/books/base.ts create mode 100644 client/visual-tests/flows/commons/books/openFirstCommonsBook.ts create mode 100644 client/visual-tests/flows/home/base.ts create mode 100644 client/visual-tests/flows/home/home.ts create mode 100644 client/visual-tests/flows/loginWithFallbackAuth.ts create mode 100644 client/visual-tests/isolated/books/open-first-book.visual.spec.ts create mode 100644 client/visual-tests/isolated/login/fallback-auth.visual.spec.ts create mode 100644 client/visual-tests/journey.visual.spec.ts delete mode 100644 client/visual-tests/login.visual.spec.ts create mode 100644 client/visual-tests/utils/actions.ts create mode 100644 client/visual-tests/utils/index.ts create mode 100644 client/visual-tests/utils/navigation.ts create mode 100644 client/visual-tests/utils/waits.ts diff --git a/client/package.json b/client/package.json index e1b98fd8a..1acc51e8f 100644 --- a/client/package.json +++ b/client/package.json @@ -9,8 +9,11 @@ "serve": "vite preview", "test": "vitest run", "test:watch": "vitest", - "test:visual": "percy exec -- playwright test", - "test:visual:local": "playwright test" + "test:visual": "SKIP_LOCAL_VISUAL_SCREENSHOTS=1 percy exec -- playwright test --project=journey", + "test:visual:local": "SKIP_PERCY_SNAPSHOTS=1 playwright test --project=journey", + "test:visual:headed": "SKIP_PERCY_SNAPSHOTS=1 playwright test --project=journey --headed", + "test:visual:isolated": "SKIP_PERCY_SNAPSHOTS=1 playwright test --project=isolated", + "test:visual:isolated:headed": "SKIP_PERCY_SNAPSHOTS=1 playwright test --project=isolated --headed" }, "dependencies": { "@ckeditor/ckeditor5-react": "11.2.0", diff --git a/client/playwright.config.ts b/client/playwright.config.ts index b56653575..bc73d8e72 100644 --- a/client/playwright.config.ts +++ b/client/playwright.config.ts @@ -1,18 +1,65 @@ +import fs from 'node:fs'; +import path from 'node:path'; import { defineConfig } from '@playwright/test'; +/** Load client/.env into process.env for local visual-test credentials (does not override existing env). */ +function loadClientEnvFile(): void { + // Scripts are run from /client, so cwd is the project root for Playwright. + const envPath = path.join(process.cwd(), '.env'); + if (!fs.existsSync(envPath)) return; + + for (const rawLine of fs.readFileSync(envPath, 'utf8').split('\n')) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + const eq = line.indexOf('='); + if (eq <= 0) continue; + const key = line.slice(0, eq).trim(); + let value = line.slice(eq + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + if (process.env[key] === undefined) { + process.env[key] = value; + } + } +} + +loadClientEnvFile(); + +const headed = process.argv.includes('--headed'); + export default defineConfig({ testDir: './visual-tests', - fullyParallel: true, + fullyParallel: false, + workers: 1, forbidOnly: Boolean(process.env.CI), retries: process.env.CI ? 2 : 0, reporter: process.env.CI ? 'github' : 'list', use: { - baseURL: 'http://127.0.0.1:3000', + baseURL: 'http://localhost:3000', trace: 'on-first-retry', + ...(headed + ? { launchOptions: { slowMo: 300 } } + : {}), }, webServer: { - command: 'npm run start -- --host 127.0.0.1', - url: 'http://127.0.0.1:3000', + command: 'npm run start -- --host localhost --port 3000', + url: 'http://localhost:3000', reuseExistingServer: !process.env.CI, }, + projects: [ + { + // Default for CI + local headed/local: one end-to-end journey. + name: 'journey', + testMatch: /journey\.visual\.spec\.ts/, + }, + { + // Opt-in pieces (login-only, books-only). Not run unless selected. + name: 'isolated', + testMatch: /isolated\/.*\.visual\.spec\.ts/, + }, + ], }); diff --git a/client/visual-tests/base.ts b/client/visual-tests/base.ts new file mode 100644 index 000000000..f637d2d4e --- /dev/null +++ b/client/visual-tests/base.ts @@ -0,0 +1,97 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { Page } from '@playwright/test'; +import percySnapshot from '@percy/playwright'; + +/** Desktop + mobile widths used for Percy / local snapshots. */ +export const PERCY_WIDTHS = [375, 1280] as const; + +const LOCAL_SCREENSHOT_DIR = path.join('test-results', 'visual'); + +/** + * Stabilize the app shell for visual snapshots: mock org branding and block + * the external support widget so shots do not depend on API/CDN state. + */ +export async function stabilizeAppShell(page: Page): Promise { + await page.route('**/api/v1/org', async (route) => { + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + err: false, + org: { + orgID: 'libretexts', + name: 'LibreTexts', + shortName: 'LibreTexts', + abbreviation: 'LT', + largeLogo: '', + mediumLogo: '', + smallLogo: '', + aboutLink: '', + commonsHeader: '', + commonsMessage: '', + videoLengthLimit: 0, + defaultProjectLead: '', + addToLibreGridList: false, + }, + }), + }); + }); + await page.route('https://cdn.libretexts.net/**', (route) => route.abort()); +} + +function shouldCaptureLocal(): boolean { + return process.env.SKIP_LOCAL_VISUAL_SCREENSHOTS !== '1'; +} + +function shouldCapturePercy(): boolean { + // Set by `test:visual` / CI. Local `:headed` / `:local` set SKIP_PERCY_SNAPSHOTS=1. + return process.env.SKIP_PERCY_SNAPSHOTS !== '1'; +} + +/** + * One snapshot call for all flows. + * - Percy path (`npm run test:visual` / CI): uploads via Percy, skips local PNGs. + * - Local path (`test:visual:local` / `:headed`): writes PNGs, skips Percy. + */ +export async function captureVisualSnapshot( + page: Page, + name: string, +): Promise { + if (shouldCapturePercy()) { + await percySnapshot(page, name, { + widths: [...PERCY_WIDTHS], + }); + } + + if (shouldCaptureLocal()) { + await captureLocalSnapshots(page, name); + } +} + +/** + * Save PNGs under gitignored `test-results/visual/` so you can preview shots + * locally without Percy or a PR. No-ops when SKIP_LOCAL_VISUAL_SCREENSHOTS=1. + */ +export async function captureLocalSnapshots( + page: Page, + name: string, +): Promise { + if (!shouldCaptureLocal()) { + return; + } + + fs.mkdirSync(LOCAL_SCREENSHOT_DIR, { recursive: true }); + const slug = name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); + + for (const width of PERCY_WIDTHS) { + await page.setViewportSize({ width, height: 900 }); + await page.screenshot({ + path: path.join(LOCAL_SCREENSHOT_DIR, `${slug}-${width}.png`), + fullPage: true, + }); + } +} diff --git a/client/visual-tests/flows/commons/base.ts b/client/visual-tests/flows/commons/base.ts new file mode 100644 index 000000000..305e849e1 --- /dev/null +++ b/client/visual-tests/flows/commons/base.ts @@ -0,0 +1,9 @@ +import type { Page } from '@playwright/test'; +import { books } from './books/base'; + +/** + * Public entry for Commons flows. Outside callers import from here only. + */ +export async function commons(page: Page): Promise { + await books(page); +} diff --git a/client/visual-tests/flows/commons/books/base.ts b/client/visual-tests/flows/commons/books/base.ts new file mode 100644 index 000000000..8bba3b8b7 --- /dev/null +++ b/client/visual-tests/flows/commons/books/base.ts @@ -0,0 +1,9 @@ +import type { Page } from '@playwright/test'; +import { openFirstCommonsBook } from './openFirstCommonsBook'; + +/** + * Public entry for Commons books flows. Outside callers import from here only. + */ +export async function books(page: Page): Promise { + await openFirstCommonsBook(page); +} diff --git a/client/visual-tests/flows/commons/books/openFirstCommonsBook.ts b/client/visual-tests/flows/commons/books/openFirstCommonsBook.ts new file mode 100644 index 000000000..776e8b480 --- /dev/null +++ b/client/visual-tests/flows/commons/books/openFirstCommonsBook.ts @@ -0,0 +1,21 @@ +import type { Page } from '@playwright/test'; +import { expect } from '@playwright/test'; +import { captureVisualSnapshot } from '../../../base'; +import { clickOn, goToPage, wait0s, wait5s } from '../../../utils'; + +/** + * Isolated Commons books flow: always starts at `/` (no dependency on prior pages). + * Screenshots the catalog, opens the first book card, then screenshots again. + */ +export async function openFirstCommonsBook(page: Page): Promise { + await goToPage(page, '/', wait5s); + + const firstBookLink = page.locator('a[href^="/book/"]').first(); + await expect(firstBookLink).toBeVisible({ timeout: 30_000 }); + + await captureVisualSnapshot(page, 'Commons catalog before first book'); + + await clickOn(page, 'a[href^="/book/"]', wait0s, wait5s); + + await captureVisualSnapshot(page, 'Commons after opening first book'); +} diff --git a/client/visual-tests/flows/home/base.ts b/client/visual-tests/flows/home/base.ts new file mode 100644 index 000000000..c60c7be4d --- /dev/null +++ b/client/visual-tests/flows/home/base.ts @@ -0,0 +1,9 @@ +import type { Page } from '@playwright/test'; +import { visitHome } from './home'; + +/** + * Public entry for the home flow. Outside callers import from here only. + */ +export async function home(page: Page): Promise { + await visitHome(page); +} diff --git a/client/visual-tests/flows/home/home.ts b/client/visual-tests/flows/home/home.ts new file mode 100644 index 000000000..2a7e1d66b --- /dev/null +++ b/client/visual-tests/flows/home/home.ts @@ -0,0 +1,15 @@ +import type { Page } from '@playwright/test'; +import { expect } from '@playwright/test'; +import { captureVisualSnapshot } from '../../base'; +import { goToPage, wait5s } from '../../utils'; + +/** + * Conductor home (`/home`). Safe to call after login, or alone if already authed. + */ +export async function visitHome(page: Page): Promise { + await goToPage(page, '/home', wait5s); + + await expect(page).toHaveURL(/\/home/); + + await captureVisualSnapshot(page, 'Conductor home'); +} diff --git a/client/visual-tests/flows/loginWithFallbackAuth.ts b/client/visual-tests/flows/loginWithFallbackAuth.ts new file mode 100644 index 000000000..af2cba7da --- /dev/null +++ b/client/visual-tests/flows/loginWithFallbackAuth.ts @@ -0,0 +1,29 @@ +import type { Page } from '@playwright/test'; +import { expect } from '@playwright/test'; +import { captureVisualSnapshot } from '../base'; +import { clickOn, goToPage, typeIn, wait0s, wait5s } from '../utils'; + +const FALLBACK_AUTH_EMAIL = process.env.VISUAL_AUTH_EMAIL ?? ''; +const FALLBACK_AUTH_PASSWORD = process.env.VISUAL_AUTH_PASSWORD ?? ''; + +/** + * Isolated fallback-auth login: always starts at `/fallback-auth`. + */ +export async function loginWithFallbackAuth(page: Page): Promise { + if (!FALLBACK_AUTH_PASSWORD) { + throw new Error( + 'Set VISUAL_AUTH_PASSWORD in your environment (or client/.env) to run login steps.', + ); + } + + await goToPage(page, '/fallback-auth'); + await expect(page.getByLabel('Email')).toBeVisible(); + await expect(page.getByLabel('Password')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Login' })).toBeVisible(); + + await captureVisualSnapshot(page, 'Conductor fallback auth'); + + await typeIn(page, '#email', FALLBACK_AUTH_EMAIL); + await typeIn(page, '#password', FALLBACK_AUTH_PASSWORD); + await clickOn(page, 'button[type="submit"]', wait0s, wait5s); +} diff --git a/client/visual-tests/isolated/books/open-first-book.visual.spec.ts b/client/visual-tests/isolated/books/open-first-book.visual.spec.ts new file mode 100644 index 000000000..f7b77887e --- /dev/null +++ b/client/visual-tests/isolated/books/open-first-book.visual.spec.ts @@ -0,0 +1,18 @@ +import { test } from '@playwright/test'; +import { stabilizeAppShell } from '../../base'; +import { books } from '../../flows/commons/books/base'; + +/** + * Independent books scenario: navigates directly to `/` and opens the first book. + * Run with: npm run test:visual:isolated:headed + */ +test('open first commons book from catalog', async ({ page }) => { + await stabilizeAppShell(page); + + try { + await books(page); + } catch (error) { + console.error('open first commons book visual test failed:', error); + throw error; + } +}); diff --git a/client/visual-tests/isolated/login/fallback-auth.visual.spec.ts b/client/visual-tests/isolated/login/fallback-auth.visual.spec.ts new file mode 100644 index 000000000..3e88f8ee1 --- /dev/null +++ b/client/visual-tests/isolated/login/fallback-auth.visual.spec.ts @@ -0,0 +1,18 @@ +import { test } from '@playwright/test'; +import { stabilizeAppShell } from '../../base'; +import { loginWithFallbackAuth } from '../../flows/loginWithFallbackAuth'; + +/** + * Independent login scenario: always starts at `/fallback-auth`. + * Run with: npm run test:visual:isolated:headed + */ +test('fallback auth page', async ({ page }) => { + await stabilizeAppShell(page); + + try { + await loginWithFallbackAuth(page); + } catch (error) { + console.error('fallback auth visual test failed:', error); + throw error; + } +}); diff --git a/client/visual-tests/journey.visual.spec.ts b/client/visual-tests/journey.visual.spec.ts new file mode 100644 index 000000000..5ea3ecf42 --- /dev/null +++ b/client/visual-tests/journey.visual.spec.ts @@ -0,0 +1,32 @@ +import { test } from '@playwright/test'; +import { stabilizeAppShell } from './base'; +import { loginWithFallbackAuth } from './flows/loginWithFallbackAuth'; +import { home } from './flows/home/base'; +import { commons } from './flows/commons/base'; + +/** + * Default visual journey (what CI / `npm run test:visual*` runs). + * One browser, one flow, multiple Percy/local screenshots along the way: + * 1) fallback-auth login + * 2) Conductor home + * 3) Commons (books) + * + * Isolated login/books specs live under `isolated/` and only run with + * `--project=isolated`. + */ +test.describe.configure({ mode: 'serial' }); + +test('conductor visual journey: login then open first commons book', async ({ + page, +}) => { + await stabilizeAppShell(page); + + try { + await loginWithFallbackAuth(page); + // await home(page); + await commons(page); + } catch (error) { + console.error('conductor visual journey failed:', error); + throw error; + } +}); diff --git a/client/visual-tests/login.visual.spec.ts b/client/visual-tests/login.visual.spec.ts deleted file mode 100644 index 2908f3c0d..000000000 --- a/client/visual-tests/login.visual.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { expect, test } from '@playwright/test'; -import percySnapshot from '@percy/playwright'; - -test('login page', async ({ page }) => { - // Keep this first visual test independent of the API, database, and third-party widget. - await page.route('**/api/v1/org', async (route) => { - await route.fulfill({ - contentType: 'application/json', - body: JSON.stringify({ - err: false, - org: { - orgID: 'libretexts', - name: 'LibreTexts', - shortName: 'LibreTexts', - abbreviation: 'LT', - largeLogo: '', - mediumLogo: '', - smallLogo: '', - aboutLink: '', - commonsHeader: '', - commonsMessage: '', - videoLengthLimit: 0, - defaultProjectLead: '', - addToLibreGridList: false, - }, - }), - }); - }); - await page.route('https://cdn.libretexts.net/**', (route) => route.abort()); - - // This state does not auto-redirect to LibreOne, so the screenshot is deterministic. - await page.goto('/login?src=accountrequest'); - await expect(page.getByText('Login Required', { exact: true })).toBeVisible(); - await expect(page.getByRole('button', { name: 'Continue' })).toBeVisible(); - - await percySnapshot(page, 'Conductor login', { - widths: [375, 1280], - }); -}); diff --git a/client/visual-tests/utils/actions.ts b/client/visual-tests/utils/actions.ts new file mode 100644 index 000000000..d1256fdca --- /dev/null +++ b/client/visual-tests/utils/actions.ts @@ -0,0 +1,41 @@ +import type { Page } from '@playwright/test'; +import { wait0s, type WaitFn } from './waits'; + +/** + * Optional wait before click, click the first match, then optional wait after. + * Uses `.first()` so multi-match selectors (e.g. catalog cards) do not fail strict mode. + * + * @example + * await clickOn(page, 'button:has-text("Login")'); + * await clickOn(page, 'a[href^="/book/"]', wait0s, wait5s); + */ +export async function clickOn( + page: Page, + selector: string, + beforeActionWait: WaitFn = wait0s, + afterActionWait: WaitFn = wait0s, +): Promise { + await beforeActionWait(page); + await page.locator(selector).first().click(); + await afterActionWait(page); +} + +/** + * Optional wait first, then type into an input matched by selector. + * Clears existing value first. + * + * @example + * await typeIn(page, '#email', 'admin@example.com'); + * await typeIn(page, 'input[name="password"]', 'secret', wait5s); + */ +export async function typeIn( + page: Page, + selector: string, + text: string, + beforeActionWait: WaitFn = wait0s, +): Promise { + await beforeActionWait(page); + const field = page.locator(selector).first(); + await field.click(); + await field.fill(text); +} diff --git a/client/visual-tests/utils/index.ts b/client/visual-tests/utils/index.ts new file mode 100644 index 000000000..b78ec803f --- /dev/null +++ b/client/visual-tests/utils/index.ts @@ -0,0 +1,11 @@ +export { goToPage } from './navigation'; +export { clickOn, typeIn } from './actions'; +export { + WAITS, + wait0s, + wait5s, + wait10s, + wait30s, + wait45s, + type WaitFn, +} from './waits'; diff --git a/client/visual-tests/utils/navigation.ts b/client/visual-tests/utils/navigation.ts new file mode 100644 index 000000000..2114bed23 --- /dev/null +++ b/client/visual-tests/utils/navigation.ts @@ -0,0 +1,19 @@ +import type { Page } from '@playwright/test'; +import { wait0s, type WaitFn } from './waits'; + +/** + * Optional wait first, then navigate to a path (relative to Playwright baseURL) + * or absolute URL, then wait for the document to load. + * + * @example + * await goToPage(page, '/fallback-auth'); + * await goToPage(page, '/fallback-auth', wait5s); + */ +export async function goToPage( + page: Page, + pathOrUrl: string, + beforeActionWait: WaitFn = wait0s, +): Promise { + await beforeActionWait(page); + await page.goto(pathOrUrl, { waitUntil: 'load' }); +} diff --git a/client/visual-tests/utils/waits.ts b/client/visual-tests/utils/waits.ts new file mode 100644 index 000000000..6d3d5aaa1 --- /dev/null +++ b/client/visual-tests/utils/waits.ts @@ -0,0 +1,32 @@ +import type { Page } from '@playwright/test'; + +/** Fixed pause helpers — call by name so wait intent stays obvious in specs. */ +export async function wait0s(_page?: Page): Promise { + // no-op placeholder for a consistent API with the other waits +} + +export async function wait5s(page: Page): Promise { + await page.waitForTimeout(5_000); +} + +export async function wait10s(page: Page): Promise { + await page.waitForTimeout(10_000); +} + +export async function wait30s(page: Page): Promise { + await page.waitForTimeout(30_000); +} + +export async function wait45s(page: Page): Promise { + await page.waitForTimeout(45_000); +} + +export type WaitFn = (page: Page) => Promise; + +export const WAITS = { + none: wait0s, + '5s': wait5s, + '10s': wait10s, + '30s': wait30s, + '45s': wait45s, +} as const; diff --git a/docs/visual-regression-testing.md b/docs/visual-regression-testing.md index ad8d5c13d..b507ec5ed 100644 --- a/docs/visual-regression-testing.md +++ b/docs/visual-regression-testing.md @@ -1,10 +1,27 @@ # Visual regression testing -The initial proof of concept starts the Vite client, opens the login page in -Playwright, and sends desktop and mobile snapshots to Percy. Percy compares a -pull request's snapshots with the baseline for that pull request's target branch. -In the normal release flow, feature pull requests compare against `staging`, and -the later `staging` to `master` pull request compares against `master`. +Playwright + Percy. Shared helpers live in `client/visual-tests/base.ts` and +`client/visual-tests/utils/`. Reusable flows live in `client/visual-tests/flows/`. + +## What CI runs (default) + +`npm run test:visual` runs **one** journey (`--project=journey`): + +1. Login at `/fallback-auth` (screenshot) +2. Go to Commons `/`, screenshot catalog, open first book, screenshot again + +That is a single browser / single test with multiple Percy snapshots in one build — +not separate login/books tests. + +## Isolated specs (opt-in only) + +Login-only and books-only specs live under `visual-tests/isolated/` and run only when +you ask for them: + +```sh +npm run test:visual:isolated +npm run test:visual:isolated:headed +``` ## One-time Percy setup @@ -24,26 +41,25 @@ does not upload snapshots to Percy. ## Run locally -Install Chromium once: - ```sh cd client npx playwright install chromium -``` - -Run the browser scenario without uploading to Percy: -```sh +# Default journey (1 browser, full flow) — no Percy upload npm run test:visual:local -``` +npm run test:visual:headed -To create a Percy build, expose `PERCY_TOKEN` in your shell and run: +# Optional isolated pieces only +npm run test:visual:isolated:headed -```sh -npm run test:visual +# Percy upload (same journey CI uses) +PERCY_TOKEN=... npm run test:visual ``` -The first scenario mocks the organization endpoint and blocks the external -support widget. This makes the login snapshot stable and avoids requiring the -server, database, or a test account. Add authenticated scenarios only after a -repeatable test-data and authentication strategy is available. +Flows call one helper (`captureVisualSnapshot`): Percy on `npm run test:visual` / +CI, local PNGs on `:local` / `:headed`. Never both. + +Local PNGs land in gitignored `client/test-results/visual/`. +Credentials for login: `VISUAL_AUTH_EMAIL` / `VISUAL_AUTH_PASSWORD` in `client/.env`. +`PERCY_TOKEN` is a GitHub Actions secret (or set in the shell for local Percy runs) — +it is not stored in `client/.env`. From fb5d0d07f6f75311057c7cc76b78f3682d60b45c Mon Sep 17 00:00:00 2001 From: Akhileshwar Shriram <112577383+AkhilTheBoss@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:47:39 -0700 Subject: [PATCH 3/6] feat: added books in visual testing --- client/playwright.config.ts | 2 ++ .../visual-tests/flows/commons/books/base.ts | 4 +-- .../flows/commons/books/openCommonsBooks.ts | 34 +++++++++++++++++++ .../commons/books/openFirstCommonsBook.ts | 21 ------------ .../books/open-first-book.visual.spec.ts | 6 ++-- client/visual-tests/journey.visual.spec.ts | 2 +- client/visual-tests/utils/actions.ts | 8 +++-- docs/visual-regression-testing.md | 19 ++++++++--- 8 files changed, 61 insertions(+), 35 deletions(-) create mode 100644 client/visual-tests/flows/commons/books/openCommonsBooks.ts delete mode 100644 client/visual-tests/flows/commons/books/openFirstCommonsBook.ts diff --git a/client/playwright.config.ts b/client/playwright.config.ts index bc73d8e72..28434553a 100644 --- a/client/playwright.config.ts +++ b/client/playwright.config.ts @@ -35,6 +35,8 @@ export default defineConfig({ testDir: './visual-tests', fullyParallel: false, workers: 1, + // Login + 1st/3rd/5th books with waits + dual-width shots needs more than default 30s. + timeout: 180_000, forbidOnly: Boolean(process.env.CI), retries: process.env.CI ? 2 : 0, reporter: process.env.CI ? 'github' : 'list', diff --git a/client/visual-tests/flows/commons/books/base.ts b/client/visual-tests/flows/commons/books/base.ts index 8bba3b8b7..081a204cc 100644 --- a/client/visual-tests/flows/commons/books/base.ts +++ b/client/visual-tests/flows/commons/books/base.ts @@ -1,9 +1,9 @@ import type { Page } from '@playwright/test'; -import { openFirstCommonsBook } from './openFirstCommonsBook'; +import { openCommonsBooks } from './openCommonsBooks'; /** * Public entry for Commons books flows. Outside callers import from here only. */ export async function books(page: Page): Promise { - await openFirstCommonsBook(page); + await openCommonsBooks(page); } diff --git a/client/visual-tests/flows/commons/books/openCommonsBooks.ts b/client/visual-tests/flows/commons/books/openCommonsBooks.ts new file mode 100644 index 000000000..dab0e3151 --- /dev/null +++ b/client/visual-tests/flows/commons/books/openCommonsBooks.ts @@ -0,0 +1,34 @@ +import type { Page } from '@playwright/test'; +import { expect } from '@playwright/test'; +import { captureVisualSnapshot } from '../../../base'; +import { clickOn, goToPage, wait0s, wait5s } from '../../../utils'; + +/** 1-based catalog positions to open (same steps for each). */ +const BOOK_POSITIONS = [1, 3, 5] as const; + +/** + * Commons books flow: for each of the 1st, 3rd, and 5th catalog cards, + * screenshot the catalog, open the book, then screenshot the book page. + */ +export async function openCommonsBooks(page: Page): Promise { + for (const position of BOOK_POSITIONS) { + const index = position - 1; + + await goToPage(page, '/', wait5s); + + const bookLink = page.locator('a[href^="/book/"]').nth(index); + await expect(bookLink).toBeVisible({ timeout: 30_000 }); + + await captureVisualSnapshot( + page, + `Commons catalog before book ${position}`, + ); + + await clickOn(page, 'a[href^="/book/"]', wait0s, wait5s, index); + + await captureVisualSnapshot( + page, + `Commons after opening book ${position}`, + ); + } +} diff --git a/client/visual-tests/flows/commons/books/openFirstCommonsBook.ts b/client/visual-tests/flows/commons/books/openFirstCommonsBook.ts deleted file mode 100644 index 776e8b480..000000000 --- a/client/visual-tests/flows/commons/books/openFirstCommonsBook.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { Page } from '@playwright/test'; -import { expect } from '@playwright/test'; -import { captureVisualSnapshot } from '../../../base'; -import { clickOn, goToPage, wait0s, wait5s } from '../../../utils'; - -/** - * Isolated Commons books flow: always starts at `/` (no dependency on prior pages). - * Screenshots the catalog, opens the first book card, then screenshots again. - */ -export async function openFirstCommonsBook(page: Page): Promise { - await goToPage(page, '/', wait5s); - - const firstBookLink = page.locator('a[href^="/book/"]').first(); - await expect(firstBookLink).toBeVisible({ timeout: 30_000 }); - - await captureVisualSnapshot(page, 'Commons catalog before first book'); - - await clickOn(page, 'a[href^="/book/"]', wait0s, wait5s); - - await captureVisualSnapshot(page, 'Commons after opening first book'); -} diff --git a/client/visual-tests/isolated/books/open-first-book.visual.spec.ts b/client/visual-tests/isolated/books/open-first-book.visual.spec.ts index f7b77887e..aafc0a585 100644 --- a/client/visual-tests/isolated/books/open-first-book.visual.spec.ts +++ b/client/visual-tests/isolated/books/open-first-book.visual.spec.ts @@ -3,16 +3,16 @@ import { stabilizeAppShell } from '../../base'; import { books } from '../../flows/commons/books/base'; /** - * Independent books scenario: navigates directly to `/` and opens the first book. + * Independent books scenario: opens the 1st, 3rd, and 5th catalog books. * Run with: npm run test:visual:isolated:headed */ -test('open first commons book from catalog', async ({ page }) => { +test('open commons books from catalog', async ({ page }) => { await stabilizeAppShell(page); try { await books(page); } catch (error) { - console.error('open first commons book visual test failed:', error); + console.error('open commons books visual test failed:', error); throw error; } }); diff --git a/client/visual-tests/journey.visual.spec.ts b/client/visual-tests/journey.visual.spec.ts index 5ea3ecf42..e6639c46f 100644 --- a/client/visual-tests/journey.visual.spec.ts +++ b/client/visual-tests/journey.visual.spec.ts @@ -16,7 +16,7 @@ import { commons } from './flows/commons/base'; */ test.describe.configure({ mode: 'serial' }); -test('conductor visual journey: login then open first commons book', async ({ +test('conductor visual journey: login then open commons books', async ({ page, }) => { await stabilizeAppShell(page); diff --git a/client/visual-tests/utils/actions.ts b/client/visual-tests/utils/actions.ts index d1256fdca..e153b0124 100644 --- a/client/visual-tests/utils/actions.ts +++ b/client/visual-tests/utils/actions.ts @@ -2,21 +2,23 @@ import type { Page } from '@playwright/test'; import { wait0s, type WaitFn } from './waits'; /** - * Optional wait before click, click the first match, then optional wait after. - * Uses `.first()` so multi-match selectors (e.g. catalog cards) do not fail strict mode. + * Optional wait before click, click a match by 0-based index (default first), + * then optional wait after. Avoids strict-mode failures on multi-match selectors. * * @example * await clickOn(page, 'button:has-text("Login")'); * await clickOn(page, 'a[href^="/book/"]', wait0s, wait5s); + * await clickOn(page, 'a[href^="/book/"]', wait0s, wait5s, 2); // 3rd book */ export async function clickOn( page: Page, selector: string, beforeActionWait: WaitFn = wait0s, afterActionWait: WaitFn = wait0s, + index = 0, ): Promise { await beforeActionWait(page); - await page.locator(selector).first().click(); + await page.locator(selector).nth(index).click(); await afterActionWait(page); } diff --git a/docs/visual-regression-testing.md b/docs/visual-regression-testing.md index b507ec5ed..b797b1ba0 100644 --- a/docs/visual-regression-testing.md +++ b/docs/visual-regression-testing.md @@ -8,7 +8,8 @@ Playwright + Percy. Shared helpers live in `client/visual-tests/base.ts` and `npm run test:visual` runs **one** journey (`--project=journey`): 1. Login at `/fallback-auth` (screenshot) -2. Go to Commons `/`, screenshot catalog, open first book, screenshot again +2. Go to Commons `/`, then for the 1st, 3rd, and 5th books: screenshot catalog, + open the book, screenshot again That is a single browser / single test with multiple Percy snapshots in one build — not separate login/books tests. @@ -41,25 +42,33 @@ does not upload snapshots to Percy. ## Run locally +Best way to preview what the journey captures (visible browser): + ```sh cd client npx playwright install chromium +npm run test:visual:headed +``` + +Screenshots are written to **`client/test-results/visual/`** (gitignored). Open that +folder after the run to review PNGs at 375 and 1280 widths. No Percy upload. -# Default journey (1 browser, full flow) — no Percy upload +Other local commands: + +```sh +# Same journey, headless (still writes local PNGs) npm run test:visual:local -npm run test:visual:headed # Optional isolated pieces only npm run test:visual:isolated:headed -# Percy upload (same journey CI uses) +# Percy upload (same journey CI uses; no local PNGs) PERCY_TOKEN=... npm run test:visual ``` Flows call one helper (`captureVisualSnapshot`): Percy on `npm run test:visual` / CI, local PNGs on `:local` / `:headed`. Never both. -Local PNGs land in gitignored `client/test-results/visual/`. Credentials for login: `VISUAL_AUTH_EMAIL` / `VISUAL_AUTH_PASSWORD` in `client/.env`. `PERCY_TOKEN` is a GitHub Actions secret (or set in the shell for local Percy runs) — it is not stored in `client/.env`. From 8845ba15c8f34c8efcf964b7b5572073831d2891 Mon Sep 17 00:00:00 2001 From: Akhileshwar Shriram <112577383+AkhilTheBoss@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:41:27 -0700 Subject: [PATCH 4/6] feat: updated docs --- .github/workflows/visual_regression.yml | 2 ++ docs/visual-regression-testing.md | 16 ++++++++++------ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/workflows/visual_regression.yml b/.github/workflows/visual_regression.yml index a63b0dbf7..09e210c21 100644 --- a/.github/workflows/visual_regression.yml +++ b/.github/workflows/visual_regression.yml @@ -16,6 +16,8 @@ jobs: working-directory: ./client env: PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }} + VISUAL_AUTH_EMAIL: ${{ secrets.VISUAL_AUTH_EMAIL }} + VISUAL_AUTH_PASSWORD: ${{ secrets.VISUAL_AUTH_PASSWORD }} steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/docs/visual-regression-testing.md b/docs/visual-regression-testing.md index b797b1ba0..e29d706dc 100644 --- a/docs/visual-regression-testing.md +++ b/docs/visual-regression-testing.md @@ -28,17 +28,20 @@ npm run test:visual:isolated:headed 1. Open the existing Percy Web project (or create one). 2. Copy its write-only project token from the Percy project settings. -3. In GitHub, open **Settings > Secrets and variables > Actions** and create a - repository secret named `PERCY_TOKEN`. +3. In GitHub, open **Settings → Secrets and variables → Actions** and create + repository secrets: + - `PERCY_TOKEN` — Percy write token + - `VISUAL_AUTH_EMAIL` — fallback-auth email used by the journey + - `VISUAL_AUTH_PASSWORD` — fallback-auth password 4. Install the Percy GitHub app for this repository and link it to the Percy project. 5. Run the Visual Regression workflow on `staging` once to establish its baseline. Pushes to both `staging` and `master` refresh their respective baselines after a pull request has been reviewed and merged. -The token must never be committed. Pull requests from forks do not receive the -secret; their workflow still verifies that the browser scenario loads, but it -does not upload snapshots to Percy. +The tokens/passwords must never be committed. Pull requests from forks do not +receive secrets; their workflow still verifies that the browser scenario loads, +but it does not upload snapshots to Percy. ## Run locally @@ -69,6 +72,7 @@ PERCY_TOKEN=... npm run test:visual Flows call one helper (`captureVisualSnapshot`): Percy on `npm run test:visual` / CI, local PNGs on `:local` / `:headed`. Never both. -Credentials for login: `VISUAL_AUTH_EMAIL` / `VISUAL_AUTH_PASSWORD` in `client/.env`. +Credentials for login: `VISUAL_AUTH_EMAIL` / `VISUAL_AUTH_PASSWORD` in +`client/.env` locally, or the same-named GitHub Actions secrets in CI. `PERCY_TOKEN` is a GitHub Actions secret (or set in the shell for local Percy runs) — it is not stored in `client/.env`. From a78ba0d0699322d2c346fae7c4590716731b6ac8 Mon Sep 17 00:00:00 2001 From: Akhileshwar Shriram <112577383+AkhilTheBoss@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:53:50 -0700 Subject: [PATCH 5/6] feat: added mock backend --- .github/workflows/visual_regression.yml | 4 +- client/package.json | 4 + client/playwright.config.ts | 2 +- client/visual-tests/base.ts | 35 +-- .../flows/loginWithFallbackAuth.ts | 17 +- client/visual-tests/mocks/api.ts | 172 +++++++++++ client/visual-tests/mocks/fixtures/books.json | 126 ++++++++ .../visual-tests/mocks/fixtures/catalog.json | 286 ++++++++++++++++++ .../visual-tests/mocks/fixtures/filters.json | 165 ++++++++++ .../mocks/fixtures/libregrid.json | 60 ++++ docs/visual-regression-testing.md | 78 ++++- 11 files changed, 906 insertions(+), 43 deletions(-) create mode 100644 client/visual-tests/mocks/api.ts create mode 100644 client/visual-tests/mocks/fixtures/books.json create mode 100644 client/visual-tests/mocks/fixtures/catalog.json create mode 100644 client/visual-tests/mocks/fixtures/filters.json create mode 100644 client/visual-tests/mocks/fixtures/libregrid.json diff --git a/.github/workflows/visual_regression.yml b/.github/workflows/visual_regression.yml index 09e210c21..e0c6eb76c 100644 --- a/.github/workflows/visual_regression.yml +++ b/.github/workflows/visual_regression.yml @@ -16,8 +16,8 @@ jobs: working-directory: ./client env: PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }} - VISUAL_AUTH_EMAIL: ${{ secrets.VISUAL_AUTH_EMAIL }} - VISUAL_AUTH_PASSWORD: ${{ secrets.VISUAL_AUTH_PASSWORD }} + # No backend runs in CI; visual-tests/mocks/api.ts answers these API calls. + VITE_DEV_BASE_URL: http://localhost:5000 steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/client/package.json b/client/package.json index 1acc51e8f..4c5b3e943 100644 --- a/client/package.json +++ b/client/package.json @@ -12,6 +12,10 @@ "test:visual": "SKIP_LOCAL_VISUAL_SCREENSHOTS=1 percy exec -- playwright test --project=journey", "test:visual:local": "SKIP_PERCY_SNAPSHOTS=1 playwright test --project=journey", "test:visual:headed": "SKIP_PERCY_SNAPSHOTS=1 playwright test --project=journey --headed", + "test:visual:mock": "VISUAL_USE_REAL_BACKEND=0 VITE_DEV_BASE_URL=http://localhost:5000 npm run test:visual:local --", + "test:visual:mock:headed": "VISUAL_USE_REAL_BACKEND=0 VITE_DEV_BASE_URL=http://localhost:5000 npm run test:visual:headed --", + "test:visual:real": "VISUAL_USE_REAL_BACKEND=1 npm run test:visual:local --", + "test:visual:real:headed": "VISUAL_USE_REAL_BACKEND=1 npm run test:visual:headed --", "test:visual:isolated": "SKIP_PERCY_SNAPSHOTS=1 playwright test --project=isolated", "test:visual:isolated:headed": "SKIP_PERCY_SNAPSHOTS=1 playwright test --project=isolated --headed" }, diff --git a/client/playwright.config.ts b/client/playwright.config.ts index 28434553a..70ab97ac9 100644 --- a/client/playwright.config.ts +++ b/client/playwright.config.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { defineConfig } from '@playwright/test'; -/** Load client/.env into process.env for local visual-test credentials (does not override existing env). */ +/** Load client/.env for local configuration and real-backend login credentials. */ function loadClientEnvFile(): void { // Scripts are run from /client, so cwd is the project root for Playwright. const envPath = path.join(process.cwd(), '.env'); diff --git a/client/visual-tests/base.ts b/client/visual-tests/base.ts index f637d2d4e..a5072c4c3 100644 --- a/client/visual-tests/base.ts +++ b/client/visual-tests/base.ts @@ -2,40 +2,25 @@ import fs from 'node:fs'; import path from 'node:path'; import type { Page } from '@playwright/test'; import percySnapshot from '@percy/playwright'; +import { mockConductorApi } from './mocks/api'; /** Desktop + mobile widths used for Percy / local snapshots. */ export const PERCY_WIDTHS = [375, 1280] as const; const LOCAL_SCREENSHOT_DIR = path.join('test-results', 'visual'); +export function usesRealBackend(): boolean { + return process.env.VISUAL_USE_REAL_BACKEND === '1'; +} + /** - * Stabilize the app shell for visual snapshots: mock org branding and block - * the external support widget so shots do not depend on API/CDN state. + * Mock API calls by default (CI has no backend). Real mode leaves API requests + * untouched. Both modes block the external support widget for stable snapshots. */ export async function stabilizeAppShell(page: Page): Promise { - await page.route('**/api/v1/org', async (route) => { - await route.fulfill({ - contentType: 'application/json', - body: JSON.stringify({ - err: false, - org: { - orgID: 'libretexts', - name: 'LibreTexts', - shortName: 'LibreTexts', - abbreviation: 'LT', - largeLogo: '', - mediumLogo: '', - smallLogo: '', - aboutLink: '', - commonsHeader: '', - commonsMessage: '', - videoLengthLimit: 0, - defaultProjectLead: '', - addToLibreGridList: false, - }, - }), - }); - }); + if (!usesRealBackend()) { + await mockConductorApi(page); + } await page.route('https://cdn.libretexts.net/**', (route) => route.abort()); } diff --git a/client/visual-tests/flows/loginWithFallbackAuth.ts b/client/visual-tests/flows/loginWithFallbackAuth.ts index af2cba7da..7d218e5d0 100644 --- a/client/visual-tests/flows/loginWithFallbackAuth.ts +++ b/client/visual-tests/flows/loginWithFallbackAuth.ts @@ -1,18 +1,18 @@ import type { Page } from '@playwright/test'; import { expect } from '@playwright/test'; -import { captureVisualSnapshot } from '../base'; +import { captureVisualSnapshot, usesRealBackend } from '../base'; import { clickOn, goToPage, typeIn, wait0s, wait5s } from '../utils'; -const FALLBACK_AUTH_EMAIL = process.env.VISUAL_AUTH_EMAIL ?? ''; -const FALLBACK_AUTH_PASSWORD = process.env.VISUAL_AUTH_PASSWORD ?? ''; - /** * Isolated fallback-auth login: always starts at `/fallback-auth`. */ export async function loginWithFallbackAuth(page: Page): Promise { - if (!FALLBACK_AUTH_PASSWORD) { + const realBackend = usesRealBackend(); + const email = realBackend ? process.env.VISUAL_AUTH_EMAIL : 'visual-tests@example.com'; + const password = realBackend ? process.env.VISUAL_AUTH_PASSWORD : 'visual-tests-password'; + if (!email || !password) { throw new Error( - 'Set VISUAL_AUTH_PASSWORD in your environment (or client/.env) to run login steps.', + 'Real-backend login requires VISUAL_AUTH_EMAIL and VISUAL_AUTH_PASSWORD in your environment or client/.env.', ); } @@ -23,7 +23,8 @@ export async function loginWithFallbackAuth(page: Page): Promise { await captureVisualSnapshot(page, 'Conductor fallback auth'); - await typeIn(page, '#email', FALLBACK_AUTH_EMAIL); - await typeIn(page, '#password', FALLBACK_AUTH_PASSWORD); + await typeIn(page, '#email', email); + await typeIn(page, '#password', password); await clickOn(page, 'button[type="submit"]', wait0s, wait5s); + await expect(page).toHaveURL(/\/home/); } diff --git a/client/visual-tests/mocks/api.ts b/client/visual-tests/mocks/api.ts new file mode 100644 index 000000000..4a198722a --- /dev/null +++ b/client/visual-tests/mocks/api.ts @@ -0,0 +1,172 @@ +import type { Page, Route } from '@playwright/test'; +import books from './fixtures/books.json'; +import catalog from './fixtures/catalog.json'; +import filters from './fixtures/filters.json'; +import libregrid from './fixtures/libregrid.json'; + +/** + * Fake Conductor API for visual tests. CI has no backend or database, so every + * `/api/v1/*` request is answered here with fixed data. That keeps screenshots + * identical between runs (no live catalog order, covers, or user data). + * + * Fixtures were trimmed from a real local session; book thumbnails are replaced + * with a flat placeholder so Percy never diffs on remote images. + */ + +// Must match COOKIE_NAMES in src/components/util/AuthHelper.ts (APP_ENV "production" prefix). +const AUTH_COOKIES = ['conductor_access_v2', 'conductor_signed_v2']; + +const ORG = { + orgID: 'libretexts', + name: 'LibreTexts', + shortName: 'LibreTexts', + abbreviation: 'LT', + largeLogo: '', + mediumLogo: '', + smallLogo: '', + aboutLink: '', + commonsHeader: '', + commonsMessage: '', + videoLengthLimit: 0, + defaultProjectLead: '', + addToLibreGridList: false, +}; + +const USER = { + uuid: '00000000-0000-4000-8000-000000000000', + firstName: 'Visual', + lastName: 'Tester', + email: 'visual-tests@example.com', + avatar: '', + authType: 'traditional', + roles: [{ org: 'libretexts', role: 'superadmin' }], + verifiedInstructor: false, +}; + +type Json = Record; +type Handler = (match: RegExpMatchArray, route: Route, page: Page) => Json | Promise; + +const bookMap = books as Record; + +function bookDetail(bookID: string): Json | undefined { + if (bookMap[bookID]) return bookMap[bookID]; + const summary = catalog.books.find((b) => b.bookID === bookID); + return summary && { ...summary, readerResources: [], isbns: [] }; +} + +/** Routes are matched against the path after `/api/v1`, first match wins. */ +const ROUTES: Array<[method: string, path: RegExp, handler: Handler]> = [ + ['GET', /^\/org$/, () => ({ err: false, org: ORG })], + // Constant build ID so the "new version available" toast never appears. + ['GET', /^\/build$/, () => ({ version: 'visual-test', ref: 'visual-test' })], + [ + 'GET', + /^\/config$/, + () => ({ + err: false, + data: { + env: 'development', + main_commons_url: '', + instructor_verification_url: '', + central_identity_base_url: '', + }, + }), + ], + [ + 'POST', + /^\/auth\/fallback-auth$/, + async (_m, _route, page) => { + // The real server sets these cookies; the client only checks they exist. + await page.context().addCookies( + AUTH_COOKIES.map((name) => ({ + name, + value: 'visual-test', + url: new URL(page.url()).origin, + })), + ); + return { err: false }; + }, + ], + ['GET', /^\/user\/basicinfo$/, () => ({ err: false, user: USER })], + ['GET', /^\/announcements\/system$/, () => ({ err: false, sysAnnouncement: null })], + ['GET', /^\/announcements\/all$/, () => ({ err: false, announcements: [] })], + ['GET', /^\/projects\/recent$/, () => ({ err: false, projects: [] })], + ['GET', /^\/projects\/pinned$/, () => ({ err: false, pinned: [] })], + + // Commons catalog + ['GET', /^\/commons\/catalog$/, () => catalog], + ['GET', /^\/commons\/filters$/, () => filters], + ['GET', /^\/orgs\/libregrid$/, () => libregrid], + ['GET', /^\/central-identity\/licenses$/, () => ({ err: false, licenses: [] })], + ['GET', /^\/projects\/public$/, () => ({ err: false, projects: [], totalCount: 0 })], + ['GET', /^\/projects\/files\/public$/, () => ({ err: false, files: [], totalCount: 0 })], + ['GET', /^\/search\/authors$/, () => ({ err: false, numResults: 0, results: [] })], + ['GET', /^\/search\/minirepos$/, () => ({ err: false, numResults: 0, results: [] })], + + // Commons book page + [ + 'GET', + /^\/commons\/book\/([^/]+)$/, + ([, bookID]) => { + const book = bookDetail(bookID); + return book ? { err: false, book } : { err: true, errMsg: 'Book not found.' }; + }, + ], + [ + 'GET', + /^\/commons\/book\/([^/]+)\/toc$/, + ([, bookID]) => { + const book = bookDetail(bookID); + const url = (book?.links as Json | undefined)?.online ?? ''; + return { + err: false, + toc: { + id: bookID, + title: book?.title ?? '', + url, + children: ['Front Matter', 'Chapter 1', 'Chapter 2', 'Back Matter'].map((title, i) => ({ + id: `${bookID}-${i}`, + title, + url, + children: [], + })), + }, + }; + }, + ], + [ + 'GET', + /^\/commons\/book\/([^/]+)\/licensereport$/, + () => ({ err: false, found: false, msg: "Couldn't find a Content Licensing Report for that resource." }), + ], + [ + 'GET', + /^\/project\/[^/]+\/files\/content\/?$/, + () => ({ err: false, files: [], path: [{ fileID: '', name: '' }] }), + ], +]; + +/** + * Answer every Conductor API call from fixtures. Unmocked endpoints return a + * 404 and are logged, so a new API call on a snapshotted page is easy to spot. + */ +export async function mockConductorApi(page: Page): Promise { + await page.route(/\/api\/v1\//, async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const path = url.pathname.replace(/^.*?\/api\/v1/, ''); + + for (const [method, pattern, handler] of ROUTES) { + const match = request.method() === method && path.match(pattern); + if (match) { + return route.fulfill({ json: await handler(match, route, page) }); + } + } + + console.warn(`[visual mocks] unmocked API call: ${request.method()} ${path}`); + return route.fulfill({ + status: 404, + json: { err: true, errMsg: `No visual-test mock for ${request.method()} ${path}` }, + }); + }); +} diff --git a/client/visual-tests/mocks/fixtures/books.json b/client/visual-tests/mocks/fixtures/books.json new file mode 100644 index 000000000..c2fa7aa2c --- /dev/null +++ b/client/visual-tests/mocks/fixtures/books.json @@ -0,0 +1,126 @@ +{ + "socialsci-372438": { + "bookID": "socialsci-372438", + "affiliation": "Chabot College", + "author": "Jessica Gallucci", + "course": "Chabot College", + "lastUpdated": "Sun, 19 Jul 2026 01:18:10 GMT", + "library": "socialsci", + "libraryTags": [ + "article:topic-category", + "authorname:jscglc", + "coverpage:yes", + "license:ccby", + "lulu@Introduction to California Government and Politics (saved June 2026 version) by Steven Reti@Jessica Gallucci@Chabot College@Introduction to California Government and Politics (saved June 2026) by Steven Reti" + ], + "license": "ccby", + "links": { + "online": "https://socialsci.libretexts.org/Courses/Chabot_College/Introduction_to_California_Government_and_Politics_(saved_June_2026_version)_by_Steven_Reti", + "pdf": "https://downloads.libretexts.org/api/v1/download/socialsci-372438/pdf", + "buy": "https://commons.libretexts.org/store/product/socialsci-372438", + "zip": "https://downloads.libretexts.org/api/v1/download/socialsci-372438/pages", + "files": "https://downloads.libretexts.org/api/v1/download/socialsci-372438/publication", + "lms": "https://downloads.libretexts.org/api/v1/download/socialsci-372438/thincc" + }, + "location": "campus", + "program": "", + "readerResources": [], + "subject": "", + "summary": "", + "thumbnail": "data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22300%22%20height%3D%22400%22%3E%3Crect%20width%3D%22100%25%22%20height%3D%22100%25%22%20fill%3D%22%23d6dde5%22%2F%3E%3C%2Fsvg%3E", + "thumbnailIsAnimated": false, + "title": "Introduction to California Government and Politics (saved June 2026 version) by Steven Reti", + "coverID": "372438", + "hasReaderResources": false, + "allowAnonPR": false, + "hasPeerReviews": false, + "hasAdaptCourse": false, + "isbns": [] + }, + "phys-24413": { + "bookID": "phys-24413", + "title": "Classical Mechanics", + "author": "Peter Dourmashkin", + "affiliation": "Massachusetts Institute of Technology", + "library": "phys", + "subject": "Classical Mechanics", + "location": "central", + "course": "", + "program": "mitocw", + "license": "ccbyncsa", + "thumbnail": "data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22300%22%20height%3D%22400%22%3E%3Crect%20width%3D%22100%25%22%20height%3D%22100%25%22%20fill%3D%22%23d6dde5%22%2F%3E%3C%2Fsvg%3E", + "links": { + "online": "https://phys.libretexts.org/Bookshelves/Classical_Mechanics/Classical_Mechanics_(Dourmashkin)", + "pdf": "https://downloads.libretexts.org/api/v1/download/phys-24413/pdf", + "buy": "https://commons.libretexts.org/store/product/phys-24413", + "zip": "https://downloads.libretexts.org/api/v1/download/phys-24413/pages", + "files": "https://downloads.libretexts.org/api/v1/download/phys-24413/publication", + "lms": "https://downloads.libretexts.org/api/v1/download/phys-24413/thincc" + }, + "summary": "The principles of mechanics successfully described many phenomena encountered in the world. Conservation laws involving energy, momentum and angular momentum provided a second parallel approach to solving many of the same problems. This text investigate both approaches: Force and conservation laws to develop a conceptual understanding of the core concepts, a familiarity with the experimental verification of our theoretical laws, and an ability to predict the motions of bodies.", + "lastUpdated": "Tue, 02 Jul 2024 15:28:21 GMT", + "libraryTags": [ + "article:topic-category", + "authorname:pdourmashkin", + "coverpage:yes", + "license:ccbyncsa", + "licenseversion:40", + "lulu@Classical Mechanics@Peter Dourmashkin@Massachusetts Institute of Technology@Classical Mechanics", + "program:mitocw", + "showtoc:no", + "source@https://ocw.mit.edu/courses/8-01sc-classical-mechanics-fall-2016/" + ], + "rating": 4, + "thumbnailIsAnimated": false, + "coverID": "24413", + "projectID": "iwRt6mh65R", + "hasReaderResources": false, + "allowAnonPR": true, + "hasPeerReviews": false, + "hasAdaptCourse": false, + "isbns": [] + }, + "human-385563": { + "bookID": "human-385563", + "affiliation": "Plymouth State University", + "author": "George Matthews", + "course": "", + "lastUpdated": "Sat, 07 Mar 2026 04:41:12 GMT", + "library": "human", + "libraryTags": [ + "article:topic-category", + "authorname:gmtth", + "coverpage:yes", + "cssprint:default", + "license:ccby", + "licenseversion:40", + "lulu@Introduction to Philosophy- Ethics@George Matthews@Plymouth State University@Introduction to Philosophy- Ethics", + "printoptions:no-header-title", + "program:oeri", + "showtoc:yes" + ], + "license": "ccby", + "links": { + "online": "https://human.libretexts.org/Bookshelves/Philosophy/Ethics/Introduction_to_Philosophy%3A_Ethics_(Matthews_and_Hendricks)", + "pdf": "https://downloads.libretexts.org/api/v1/download/human-385563/pdf", + "buy": "https://commons.libretexts.org/store/product/human-385563", + "zip": "https://downloads.libretexts.org/api/v1/download/human-385563/pages", + "files": "https://downloads.libretexts.org/api/v1/download/human-385563/publication", + "lms": "https://downloads.libretexts.org/api/v1/download/human-385563/thincc" + }, + "location": "central", + "program": "oeri", + "readerResources": [], + "subject": "Philosophy", + "summary": "", + "thumbnail": "data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22300%22%20height%3D%22400%22%3E%3Crect%20width%3D%22100%25%22%20height%3D%22100%25%22%20fill%3D%22%23d6dde5%22%2F%3E%3C%2Fsvg%3E", + "title": "Introduction to Philosophy- Ethics", + "thumbnailIsAnimated": false, + "coverID": "385563", + "hasReaderResources": false, + "allowAnonPR": false, + "hasPeerReviews": false, + "hasAdaptCourse": false, + "isbns": [] + } +} diff --git a/client/visual-tests/mocks/fixtures/catalog.json b/client/visual-tests/mocks/fixtures/catalog.json new file mode 100644 index 000000000..35d9a86a0 --- /dev/null +++ b/client/visual-tests/mocks/fixtures/catalog.json @@ -0,0 +1,286 @@ +{ + "err": false, + "numTotal": 8, + "books": [ + { + "bookID": "socialsci-372438", + "title": "Introduction to California Government and Politics (saved June 2026 version) by Steven Reti", + "author": "Jessica Gallucci", + "affiliation": "Chabot College", + "library": "socialsci", + "subject": "", + "location": "campus", + "course": "Chabot College", + "program": "", + "license": "ccby", + "thumbnail": "data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22300%22%20height%3D%22400%22%3E%3Crect%20width%3D%22100%25%22%20height%3D%22100%25%22%20fill%3D%22%23d6dde5%22%2F%3E%3C%2Fsvg%3E", + "links": { + "online": "https://socialsci.libretexts.org/Courses/Chabot_College/Introduction_to_California_Government_and_Politics_(saved_June_2026_version)_by_Steven_Reti", + "pdf": "https://downloads.libretexts.org/api/v1/download/socialsci-372438/pdf", + "buy": "https://commons.libretexts.org/store/product/socialsci-372438", + "zip": "https://downloads.libretexts.org/api/v1/download/socialsci-372438/pages", + "files": "https://downloads.libretexts.org/api/v1/download/socialsci-372438/publication", + "lms": "https://downloads.libretexts.org/api/v1/download/socialsci-372438/thincc" + }, + "summary": "", + "lastUpdated": "Sun, 19 Jul 2026 01:18:10 GMT", + "libraryTags": [ + "article:topic-category", + "authorname:jscglc", + "coverpage:yes", + "license:ccby", + "lulu@Introduction to California Government and Politics (saved June 2026 version) by Steven Reti@Jessica Gallucci@Chabot College@Introduction to California Government and Politics (saved June 2026) by Steven Reti" + ], + "thumbnailIsAnimated": false, + "publicAssets": 0, + "instructorAssets": 0 + }, + { + "bookID": "chem-330276", + "title": "CHM 320: Advanced Inorganic Chemistry textbook", + "author": "", + "affiliation": "", + "library": "chem", + "subject": "", + "location": "campus", + "course": "Kutztown University of Pennsylvania", + "program": "", + "license": "", + "thumbnail": "data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22300%22%20height%3D%22400%22%3E%3Crect%20width%3D%22100%25%22%20height%3D%22100%25%22%20fill%3D%22%23d6dde5%22%2F%3E%3C%2Fsvg%3E", + "links": { + "online": "https://chem.libretexts.org/Courses/Kutztown_University_of_Pennsylvania/CHM_320%3A_Advanced_Inorganic_Chemistry_textbook", + "pdf": "https://downloads.libretexts.org/api/v1/download/chem-330276/pdf", + "buy": "https://commons.libretexts.org/store/product/chem-330276", + "zip": "https://downloads.libretexts.org/api/v1/download/chem-330276/pages", + "files": "https://downloads.libretexts.org/api/v1/download/chem-330276/publication", + "lms": "https://downloads.libretexts.org/api/v1/download/chem-330276/thincc" + }, + "summary": "", + "lastUpdated": "Tue, 03 Aug 2021 19:33:01 GMT", + "libraryTags": [ + "article:topic-category", + "coverpage:yes" + ], + "thumbnailIsAnimated": false, + "publicAssets": 0, + "instructorAssets": 0 + }, + { + "bookID": "phys-24413", + "title": "Classical Mechanics", + "author": "Peter Dourmashkin", + "affiliation": "Massachusetts Institute of Technology", + "library": "phys", + "subject": "Classical Mechanics", + "location": "central", + "course": "", + "program": "mitocw", + "license": "ccbyncsa", + "thumbnail": "data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22300%22%20height%3D%22400%22%3E%3Crect%20width%3D%22100%25%22%20height%3D%22100%25%22%20fill%3D%22%23d6dde5%22%2F%3E%3C%2Fsvg%3E", + "links": { + "online": "https://phys.libretexts.org/Bookshelves/Classical_Mechanics/Classical_Mechanics_(Dourmashkin)", + "pdf": "https://downloads.libretexts.org/api/v1/download/phys-24413/pdf", + "buy": "https://commons.libretexts.org/store/product/phys-24413", + "zip": "https://downloads.libretexts.org/api/v1/download/phys-24413/pages", + "files": "https://downloads.libretexts.org/api/v1/download/phys-24413/publication", + "lms": "https://downloads.libretexts.org/api/v1/download/phys-24413/thincc" + }, + "summary": "The principles of mechanics successfully described many phenomena encountered in the world. Conservation laws involving energy, momentum and angular momentum provided a second parallel approach to solving many of the same problems. This text investigate both approaches: Force and conservation laws to develop a conceptual understanding of the core concepts, a familiarity with the experimental verification of our theoretical laws, and an ability to predict the motions of bodies.", + "lastUpdated": "Tue, 02 Jul 2024 15:28:21 GMT", + "libraryTags": [ + "article:topic-category", + "authorname:pdourmashkin", + "coverpage:yes", + "license:ccbyncsa", + "licenseversion:40", + "lulu@Classical Mechanics@Peter Dourmashkin@Massachusetts Institute of Technology@Classical Mechanics", + "program:mitocw", + "showtoc:no", + "source@https://ocw.mit.edu/courses/8-01sc-classical-mechanics-fall-2016/" + ], + "thumbnailIsAnimated": false, + "publicAssets": 0, + "instructorAssets": 0 + }, + { + "bookID": "human-230520", + "title": "Para vivir con salud", + "author": "Kathryn Joy McKnight and Jill S. Kuhnheim", + "affiliation": "University of New Mexico", + "library": "human", + "subject": "Languages", + "location": "central", + "course": "", + "program": "", + "license": "ccbync", + "thumbnail": "data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22300%22%20height%3D%22400%22%3E%3Crect%20width%3D%22100%25%22%20height%3D%22100%25%22%20fill%3D%22%23d6dde5%22%2F%3E%3C%2Fsvg%3E", + "links": { + "online": "https://human.libretexts.org/Bookshelves/Languages/Spanish/Spanish_for_the_Professions_Bookshelf/Para_vivir_con_salud_-_leyendo_la_salud_y_la_literatura_(McKnight_y_Kuhnheim)", + "pdf": "https://downloads.libretexts.org/api/v1/download/human-230520/pdf", + "buy": "https://commons.libretexts.org/store/product/human-230520", + "zip": "https://downloads.libretexts.org/api/v1/download/human-230520/pages", + "files": "https://downloads.libretexts.org/api/v1/download/human-230520/publication", + "lms": "https://downloads.libretexts.org/api/v1/download/human-230520/thincc" + }, + "summary": "Para vivir offers an introduction to reading different literary and cultural texts from the Spanish-speaking world with a thematic focus on health. It can be used as an alternative to the standard Introduction to Hispanic Literature course texts, as it also teaches techniques of close reading. It incorporates authors from seventeen counties, has an almost even representation of male and female authors and diverse communities in the Hispanic world.", + "lastUpdated": "Sat, 28 Jun 2025 22:11:48 GMT", + "libraryTags": [ + "article:topic-category", + "authorname:mcknight-kuhnheim", + "coverpage:yes", + "license:ccbync", + "licenseversion:40", + "lulu@Para vivir con salud@Kathryn Joy McKnight and Jill S. Kuhnheim@University of New Mexico@Para vivir con salud", + "showtoc:no", + "source@https://opentext.ku.edu/paravivir" + ], + "thumbnailIsAnimated": false, + "publicAssets": 0, + "instructorAssets": 0 + }, + { + "bookID": "human-385563", + "title": "Introduction to Philosophy- Ethics", + "author": "George Matthews", + "affiliation": "Plymouth State University", + "library": "human", + "subject": "Philosophy", + "location": "central", + "course": "", + "program": "oeri", + "license": "ccby", + "thumbnail": "data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22300%22%20height%3D%22400%22%3E%3Crect%20width%3D%22100%25%22%20height%3D%22100%25%22%20fill%3D%22%23d6dde5%22%2F%3E%3C%2Fsvg%3E", + "links": { + "online": "https://human.libretexts.org/Bookshelves/Philosophy/Ethics/Introduction_to_Philosophy%3A_Ethics_(Matthews_and_Hendricks)", + "pdf": "https://downloads.libretexts.org/api/v1/download/human-385563/pdf", + "buy": "https://commons.libretexts.org/store/product/human-385563", + "zip": "https://downloads.libretexts.org/api/v1/download/human-385563/pages", + "files": "https://downloads.libretexts.org/api/v1/download/human-385563/publication", + "lms": "https://downloads.libretexts.org/api/v1/download/human-385563/thincc" + }, + "summary": "", + "lastUpdated": "Sat, 07 Mar 2026 04:41:12 GMT", + "libraryTags": [ + "article:topic-category", + "authorname:gmtth", + "coverpage:yes", + "cssprint:default", + "license:ccby", + "licenseversion:40", + "lulu@Introduction to Philosophy- Ethics@George Matthews@Plymouth State University@Introduction to Philosophy- Ethics", + "printoptions:no-header-title", + "program:oeri", + "showtoc:yes" + ], + "thumbnailIsAnimated": false, + "publicAssets": 0, + "instructorAssets": 0 + }, + { + "bookID": "socialsci-366888", + "title": "Digital Media Literacy in the Age of Artificial Intelligence", + "author": "Jae Sik Ha", + "affiliation": "University of Illinois Springfield", + "library": "socialsci", + "subject": "", + "location": "campus", + "course": "University of Illinois Springfield", + "program": "carli", + "license": "ccby", + "thumbnail": "data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22300%22%20height%3D%22400%22%3E%3Crect%20width%3D%22100%25%22%20height%3D%22100%25%22%20fill%3D%22%23d6dde5%22%2F%3E%3C%2Fsvg%3E", + "links": { + "online": "https://socialsci.libretexts.org/Courses/University_of_Illinois_Springfield/Digital_Media_Literacy_in_the_Age_of_Artificial_Intelligence%3A_Navigating_Truth_Power_and_Misinformation_v2", + "pdf": "https://downloads.libretexts.org/api/v1/download/socialsci-366888/pdf", + "buy": "https://commons.libretexts.org/store/product/socialsci-366888", + "zip": "https://downloads.libretexts.org/api/v1/download/socialsci-366888/pages", + "files": "https://downloads.libretexts.org/api/v1/download/socialsci-366888/publication", + "lms": "https://downloads.libretexts.org/api/v1/download/socialsci-366888/thincc" + }, + "summary": "", + "lastUpdated": "Fri, 05 Jun 2026 21:28:16 GMT", + "libraryTags": [ + "article:topic-category", + "authorname:jskh", + "coverpage:yes", + "license:ccby", + "licenseversion:40", + "lulu@Digital Media Literacy in the Age of Artificial Intelligence@Jae Sik Ha@University of Illinois Springfield@Digital Media Literacy in the Age of Artificial Intelligence", + "program:carli" + ], + "thumbnailIsAnimated": false, + "publicAssets": 0, + "instructorAssets": 0 + }, + { + "bookID": "geo-15555", + "title": "Engaging Adults in Science and Energy", + "author": "Pennsylvania State University", + "affiliation": "Pennsylvania State University", + "library": "geo", + "subject": "Geology", + "location": "central", + "course": "", + "program": "", + "license": "ccbyncsa", + "thumbnail": "data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22300%22%20height%3D%22400%22%3E%3Crect%20width%3D%22100%25%22%20height%3D%22100%25%22%20fill%3D%22%23d6dde5%22%2F%3E%3C%2Fsvg%3E", + "links": { + "online": "https://geo.libretexts.org/Bookshelves/Geology/Engaging_Adults_in_Science_and_Energy", + "pdf": "https://downloads.libretexts.org/api/v1/download/geo-15555/pdf", + "buy": "https://commons.libretexts.org/store/product/geo-15555", + "zip": "https://downloads.libretexts.org/api/v1/download/geo-15555/pages", + "files": "https://downloads.libretexts.org/api/v1/download/geo-15555/publication", + "lms": "https://downloads.libretexts.org/api/v1/download/geo-15555/thincc" + }, + "summary": "This text provided ten weekly educational classes along with field experiences to adults living in rural Pennsylvania where a majority of natural gas exploration and development was occurring producing large-scale change within their communities. The program curriculum focused on energy and energy systems, the scientific process, social and economic impacts, and communication skills to enhance citizens' abilities to engage in civil dialogue around contentious topics.", + "lastUpdated": "Sun, 06 Nov 2022 00:09:22 GMT", + "libraryTags": [ + "article:topic-category", + "authorname:marcellusmatters", + "coverpage:yes", + "license:ccbyncsa", + "licenseversion:40", + "lulu@Engaging Adults in Science and Energy@Pennsylvania State University@@Engaging Adults in Science and Energy", + "showtoc:no", + "source@https://www.e-education.psu.edu/marcellus" + ], + "thumbnailIsAnimated": false, + "publicAssets": 0, + "instructorAssets": 0 + }, + { + "bookID": "chem-476102", + "title": "CHEM 4110: Advanced Inorganic Chemistry", + "author": "author", + "affiliation": "Catherine McCusker", + "library": "chem", + "subject": "", + "location": "campus", + "course": "East Tennessee State University", + "program": "", + "license": "ccbyncsa", + "thumbnail": "data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22300%22%20height%3D%22400%22%3E%3Crect%20width%3D%22100%25%22%20height%3D%22100%25%22%20fill%3D%22%23d6dde5%22%2F%3E%3C%2Fsvg%3E", + "links": { + "online": "https://chem.libretexts.org/Courses/East_Tennessee_State_University/CHEM_4110%3A_Advanced_Inorganic_Chemistry", + "pdf": "https://downloads.libretexts.org/api/v1/download/chem-476102/pdf", + "buy": "https://commons.libretexts.org/store/product/chem-476102", + "zip": "https://downloads.libretexts.org/api/v1/download/chem-476102/pages", + "files": "https://downloads.libretexts.org/api/v1/download/chem-476102/publication", + "lms": "https://downloads.libretexts.org/api/v1/download/chem-476102/thincc" + }, + "summary": "", + "lastUpdated": "Wed, 22 Jan 2025 20:44:10 GMT", + "libraryTags": [ + "article:topic-category", + "author@Catherine McCusker, East Tennessee State University", + "authorname:cmccusker", + "coverpage:yes", + "inorganic", + "license:ccbyncsa", + "lulu@CHEM 4110: Advanced Inorganic Chemistry@author@Catherine McCusker@East Tennessee State University@CHEM 4110: Advanced Inorganic Chemistry" + ], + "thumbnailIsAnimated": false, + "publicAssets": 0, + "instructorAssets": 0 + } + ] +} diff --git a/client/visual-tests/mocks/fixtures/filters.json b/client/visual-tests/mocks/fixtures/filters.json new file mode 100644 index 000000000..460070f64 --- /dev/null +++ b/client/visual-tests/mocks/fixtures/filters.json @@ -0,0 +1,165 @@ +{ + "authors": [ + "Aaron Lee", + "Aaron Mattox", + "Abbey K. Elder, Stefanie Buck, Jeff Gallant, Marco Seiferle-Valencia, and Apurva Ashok", + "Abdullah Oguz", + "Adam Bledsoe", + "Adam Dastrup", + "Adam MacDougall", + "Adam Rex Pope", + "Adaptive Apparel Design", + "Addie Clark", + "Adrianne Kotecki", + "Adrienne Richards", + "Adrienne Watt", + "Aeschylus", + "A. Goodman", + "Aimee Samara Krouskop, Ben Cushing, Kimberly Puttman, and Avery Temple", + "AJ Barse and Justina Brown", + "Ajna Rivera", + "Alain Olavarrieta", + "Alaka Pradhan", + "Alan Keys", + "Alan Polansky", + "Al Doerr & Ken Levasseur", + "Alessandro Baldi", + "Alex A. G. Taub" + ], + "subjects": [ + "Abstract and Geometric Algebra", + "Accounting", + "Advanced Statistics", + "Aerospace Engineering", + "Agriculture and Horticulture", + "Algebra", + "Allied Health", + "Analysis", + "Analytical Chemistry", + "Anatomy and Physiology", + "Anthropology", + "Applied Mathematics", + "Applied Statistics", + "Arithmetic and Basic Math", + "Art", + "Artificial Intelligence", + "Arts Audio Visual Technology and Communications", + "Astronomy Cosmology", + "Basic Science", + "Biochemistry", + "Biological Chemistry", + "Biological Engineering", + "Biotechnology", + "Botany", + "Business" + ], + "affiliations": [ + "Academic Senate of California Community Colleges Open Education Resources Initiative", + "Achieving the Dream", + "Adler University", + "African Virtual University", + "Al Akhawayn University", + "Albuquerque Public Schools & George Mason University", + "Alfred State College of Technology SUNY", + "Allan Hancock College", + "Alma College", + "Alqua", + "American Meteorological Society", + "American Public University", + "American River College", + "American University", + "American University of Beirut", + "Angelina College", + "Angelo State University", + "Anoka-Ramsey Community College", + "Antelope Valley College", + "Arapahoe Community College", + "Archabbey of St. Meinrad in Indiana", + "Arizona State University", + "Arkansas Northeastern College", + "Arkansas State University-Beebe", + "Arkansas State University- Beebe" + ], + "courses": [ + "Achieving the Dream", + "Adler University", + "Al Akhawayn University", + "Allan Hancock College", + "Alma College", + "American Meteorological Society", + "American Public University", + "American River College", + "Angelina College", + "Angelo State University", + "Anoka-Ramsey Community College", + "Antelope Valley College", + "Arapahoe Community College", + "Arkansas Northeastern College", + "Arkansas State University-Beebe", + "Arkansas Tech University", + "Athabasca University", + "Aurora University", + "Austin Peay State University", + "Baker College", + "Bakersfield College", + "Barry University", + "Barstow Community College", + "Barton Community College", + "Bellarmine University" + ], + "programs": [ + "athabasca", + "bcc", + "cali", + "carli", + "ck12", + "clc-innovators", + "ecampusontario", + "echo", + "evc", + "galileo", + "hidden", + "ilc", + "lumen", + "mitocw", + "oeri", + "openoregon", + "openrn", + "openstax", + "openstaxcnx", + "opensuny", + "opsuny", + "pdxopen", + "rotel", + "virginiatech", + "wac" + ], + "cids": [ + "ACCT 110", + "ACCT 120", + "ADS 110 X", + "ADS 120 X", + "ADS 130 X", + "ADS 140 X", + "ADS 150 X", + "ADS 160 X", + "ADS 170 X", + "ADS 180 X", + "ADS 190 X", + "ADS 195 X", + "ADS 200 X", + "ADS 210 X", + "AG-AB 104", + "AG-AB 108 108", + "AG-AB 112", + "AG-AB 124", + "AG-AB 128", + "AG-AS 104", + "AG-AS 108 L", + "AG-AS 112 L", + "AG-AS 116 L", + "AG-AS 120 L", + "AG-AS 124 L" + ], + "err": false +} diff --git a/client/visual-tests/mocks/fixtures/libregrid.json b/client/visual-tests/mocks/fixtures/libregrid.json new file mode 100644 index 000000000..95b4224d5 --- /dev/null +++ b/client/visual-tests/mocks/fixtures/libregrid.json @@ -0,0 +1,60 @@ +{ + "orgs": [ + { + "orgID": "oeri", + "name": "ASCCC Open Educational Resources Initiative", + "domain": "https://oeri.commons.libretexts.org" + }, + { + "name": "California State University, Sacramento", + "orgID": "csusacramento", + "domain": "https://csus.commons.libretexts.org" + }, + { + "orgID": "chabot", + "name": "Chabot College", + "domain": "https://chabot.commons.libretexts.org" + }, + { + "orgID": "canyons", + "name": "College of the Canyons", + "domain": "https://canyons.commons.libretexts.org" + }, + { + "domain": "https://deanza.commons.libretexts.org", + "name": "De Anza College", + "orgID": "deanza" + }, + { + "orgID": "hacc", + "name": "HACC", + "domain": "https://hacc.commons.libretexts.org" + }, + { + "orgID": "highline", + "name": "Highline College", + "domain": "https://highline.commons.libretexts.org" + }, + { + "orgID": "kstate", + "name": "Kansas State University", + "domain": "https://k-state.commons.libretexts.org" + }, + { + "orgID": "pgcc", + "name": "Prince George's Community College", + "domain": "https://pgcc.commons.libretexts.org" + }, + { + "orgID": "reedley", + "name": "Reedley College", + "domain": "https://reedley.commons.libretexts.org" + }, + { + "orgID": "ucdavis", + "name": "University of California, Davis", + "domain": "https://ucdavis.commons.libretexts.org" + } + ], + "err": false +} diff --git a/docs/visual-regression-testing.md b/docs/visual-regression-testing.md index e29d706dc..25e711ab1 100644 --- a/docs/visual-regression-testing.md +++ b/docs/visual-regression-testing.md @@ -14,6 +14,28 @@ Playwright + Percy. Shared helpers live in `client/visual-tests/base.ts` and That is a single browser / single test with multiple Percy snapshots in one build — not separate login/books tests. +## Mocked API (no backend needed) + +The visual tests check how pages **look**, not whether the backend works. CI has +no server or database, so `client/visual-tests/mocks/api.ts` answers every +`/api/v1/*` request with fixed data from `client/visual-tests/mocks/fixtures/`. +Local runs use the same mocks by default; the explicit `:real` commands below +use your running backend instead. + +`VITE_DEV_BASE_URL=http://localhost:5000` gives the frontend an address for +constructing API URLs. Playwright intercepts those requests before they reach a +server; nothing needs to listen on port 5000. The GitHub workflow already sets +this value directly, so **do not add it as a GitHub secret**. Only `PERCY_TOKEN` +is needed as a secret for Percy uploads. + +- Login is faked: the mocked `POST /auth/fallback-auth` sets the auth cookies the + client checks for. Any email/password works; no credentials are needed. +- The catalog always returns the same books in the same order, with a flat + placeholder instead of remote cover images, so Percy only diffs on UI changes. +- An endpoint without a mock returns 404 and logs + `[visual mocks] unmocked API call: ...`. When you snapshot a new page, add a + route for each call it logs. + ## Isolated specs (opt-in only) Login-only and books-only specs live under `visual-tests/isolated/` and run only when @@ -31,15 +53,13 @@ npm run test:visual:isolated:headed 3. In GitHub, open **Settings → Secrets and variables → Actions** and create repository secrets: - `PERCY_TOKEN` — Percy write token - - `VISUAL_AUTH_EMAIL` — fallback-auth email used by the journey - - `VISUAL_AUTH_PASSWORD` — fallback-auth password 4. Install the Percy GitHub app for this repository and link it to the Percy project. 5. Run the Visual Regression workflow on `staging` once to establish its baseline. Pushes to both `staging` and `master` refresh their respective baselines after a pull request has been reviewed and merged. -The tokens/passwords must never be committed. Pull requests from forks do not +The token must never be committed. Pull requests from forks do not receive secrets; their workflow still verifies that the browser scenario loads, but it does not upload snapshots to Percy. @@ -50,17 +70,24 @@ Best way to preview what the journey captures (visible browser): ```sh cd client npx playwright install chromium -npm run test:visual:headed +npm run test:visual:mock:headed ``` Screenshots are written to **`client/test-results/visual/`** (gitignored). Open that folder after the run to review PNGs at 375 and 1280 widths. No Percy upload. +The `:mock` commands explicitly set the same API address as CI and run the same +mocked journey. Playwright starts the frontend at `http://localhost:3000` (or +reuses an existing frontend there). No backend, database, or login credentials +are needed. The older `:local` and `:headed` commands also use mocks; they do not +test your real local backend or database. Local PNG rendering can differ from +Percy's rendering, but the journey and API fixtures are shared. + Other local commands: ```sh # Same journey, headless (still writes local PNGs) -npm run test:visual:local +npm run test:visual:mock # Optional isolated pieces only npm run test:visual:isolated:headed @@ -72,7 +99,44 @@ PERCY_TOKEN=... npm run test:visual Flows call one helper (`captureVisualSnapshot`): Percy on `npm run test:visual` / CI, local PNGs on `:local` / `:headed`. Never both. -Credentials for login: `VISUAL_AUTH_EMAIL` / `VISUAL_AUTH_PASSWORD` in -`client/.env` locally, or the same-named GitHub Actions secrets in CI. +No backend or login credentials are needed (see **Mocked API** above). `PERCY_TOKEN` is a GitHub Actions secret (or set in the shell for local Percy runs) — it is not stored in `client/.env`. + +## Run locally against your real backend and database + +Start your local backend and its database as usual. In `client/.env`, set: + +```dotenv +VITE_DEV_BASE_URL=http://localhost:5000 +VISUAL_AUTH_EMAIL=your-local-test-account@example.com +VISUAL_AUTH_PASSWORD=your-local-test-account-password +``` + +Use the backend's actual URL if it runs on a different port. These must be valid +fallback-auth credentials for an account in the database your backend uses. +Keep credentials in your uncommitted `.env`; do not prefix them with `VITE_`. + +From `client`, run either: + +```sh +# Real API and login, headless browser +npm run test:visual:real + +# Real API and login, visible browser +npm run test:visual:real:headed +``` + +These commands set `VISUAL_USE_REAL_BACKEND=1`, skip API mocks and fake cookies, +and read `VISUAL_AUTH_EMAIL` / `VISUAL_AUTH_PASSWORD` from your environment or +`client/.env`. They start or reuse the frontend on port 3000, but do not start +the backend or database. A reused frontend must already use the correct API URL; +restart it if you changed its configuration. + +The journey logs in and opens the 1st, 3rd, and 5th Commons books, so your database +must provide at least five visible catalog books. Screenshots are saved to +`client/test-results/visual/` with no Percy upload. Live data can change between +runs. The external support widget remains blocked in both modes. + +Use `:mock` / `:mock:headed` to return to the fixed data used by CI. Avoid setting +`VISUAL_USE_REAL_BACKEND` in `.env`; the commands select the mode for you. From ee004c2d45d63bb1242501a90d2cf52b831d4e85 Mon Sep 17 00:00:00 2001 From: jakeaturner Date: Mon, 19 Oct 2026 16:07:56 -0700 Subject: [PATCH 6/6] chore(deps): pin deps in /client and /server --- client/package-lock.json | 6 +++--- client/package.json | 6 +++--- server/package-lock.json | 4 ++-- server/package.json | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index c92e0ed82..ed2eae7b4 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -93,9 +93,9 @@ "zod": "3.25.76" }, "devDependencies": { - "@percy/cli": "^1.32.6", - "@percy/playwright": "^1.1.2", - "@playwright/test": "^1.61.1", + "@percy/cli": "1.32.6", + "@percy/playwright": "1.1.2", + "@playwright/test": "1.61.1", "@tailwindcss/forms": "0.5.11", "@tailwindcss/postcss": "4.2.4", "@tailwindcss/vite": "4.2.4", diff --git a/client/package.json b/client/package.json index 4c5b3e943..4e4ea3448 100644 --- a/client/package.json +++ b/client/package.json @@ -124,9 +124,9 @@ ] }, "devDependencies": { - "@percy/cli": "^1.32.6", - "@percy/playwright": "^1.1.2", - "@playwright/test": "^1.61.1", + "@percy/cli": "1.32.6", + "@percy/playwright": "1.1.2", + "@playwright/test": "1.61.1", "@tailwindcss/forms": "0.5.11", "@tailwindcss/postcss": "4.2.4", "@tailwindcss/vite": "4.2.4", diff --git a/server/package-lock.json b/server/package-lock.json index 84fbf877f..106b5b191 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -63,7 +63,7 @@ "node-cache": "5.1.2", "openai": "6.35.0", "path": "0.12.7", - "pino": "^10.3.1", + "pino": "10.3.1", "pino-http": "11.0.0", "rate-limiter-flexible": "5.0.5", "react-timezone-select": "2.1.5", @@ -94,7 +94,7 @@ "eslint": "8.57.1", "eslint-config-airbnb-base": "15.0.0", "eslint-plugin-import": "2.32.0", - "pino-pretty": "^13.1.3", + "pino-pretty": "13.1.3", "tsc-alias": "1.8.16", "tsup": "8.5.1", "tsx": "4.21.0", diff --git a/server/package.json b/server/package.json index b6a8eeccd..434079485 100644 --- a/server/package.json +++ b/server/package.json @@ -68,7 +68,7 @@ "node-cache": "5.1.2", "openai": "6.35.0", "path": "0.12.7", - "pino": "^10.3.1", + "pino": "10.3.1", "pino-http": "11.0.0", "rate-limiter-flexible": "5.0.5", "react-timezone-select": "2.1.5", @@ -100,7 +100,7 @@ "eslint": "8.57.1", "eslint-config-airbnb-base": "15.0.0", "eslint-plugin-import": "2.32.0", - "pino-pretty": "^13.1.3", + "pino-pretty": "13.1.3", "tsc-alias": "1.8.16", "tsup": "8.5.1", "tsx": "4.21.0",