diff --git a/.github/workflows/sdk-e2e.yml b/.github/workflows/sdk-e2e.yml index 107d3b2..37690f1 100644 --- a/.github/workflows/sdk-e2e.yml +++ b/.github/workflows/sdk-e2e.yml @@ -145,34 +145,44 @@ jobs: working-directory: ./clients/storybook run: npm install + - name: Build Storybook client + working-directory: ./clients/storybook + run: npm run build + - name: Get Playwright version working-directory: ./clients/storybook id: playwright-version - run: echo "version=$(npx playwright --version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+')" >> $GITHUB_OUTPUT + run: echo "version=$(node -p "require('playwright-core/package.json').version")" >> $GITHUB_OUTPUT - name: Cache Playwright browsers uses: actions/cache@v4 id: playwright-cache with: path: ~/.cache/ms-playwright - key: playwright-${{ steps.playwright-version.outputs.version }}-chromium + key: playwright-${{ steps.playwright-version.outputs.version }}-storybook-chromium - name: Install Playwright browsers if: steps.playwright-cache.outputs.cache-hit != 'true' working-directory: ./clients/storybook - run: npx playwright install chromium --with-deps + run: npx playwright-core install chromium --with-deps + + - name: Build example-storybook + working-directory: ./clients/storybook/example-storybook + run: npm install && npm run build-storybook - name: Run E2E tests (TDD mode) working-directory: ./clients/storybook run: ../../bin/vizzly.js tdd run "npm run test:e2e" env: CI: true + VIZZLY_LOG_LEVEL: debug - name: Run E2E tests (Cloud mode) working-directory: ./clients/storybook run: ../../bin/vizzly.js run "npm run test:e2e" env: CI: true + VIZZLY_LOG_LEVEL: debug VIZZLY_TOKEN: ${{ secrets.VIZZLY_STORYBOOK_CLIENT_TOKEN }} VIZZLY_COMMIT_MESSAGE: ${{ github.event.pull_request.head.commit.message || github.event.head_commit.message }} VIZZLY_COMMIT_SHA: ${{ github.event.pull_request.head.sha || github.event.head_commit.id }} @@ -202,22 +212,26 @@ jobs: working-directory: ./clients/static-site run: npm install + - name: Build Static-Site client + working-directory: ./clients/static-site + run: npm run build + - name: Get Playwright version working-directory: ./clients/static-site id: playwright-version - run: echo "version=$(npx playwright --version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+')" >> $GITHUB_OUTPUT + run: echo "version=$(node -p "require('playwright-core/package.json').version")" >> $GITHUB_OUTPUT - name: Cache Playwright browsers uses: actions/cache@v4 id: playwright-cache with: path: ~/.cache/ms-playwright - key: playwright-${{ steps.playwright-version.outputs.version }}-chromium + key: playwright-${{ steps.playwright-version.outputs.version }}-static-site-chromium - name: Install Playwright browsers if: steps.playwright-cache.outputs.cache-hit != 'true' working-directory: ./clients/static-site - run: npx playwright install chromium --with-deps + run: npx playwright-core install chromium --with-deps - name: Run E2E tests (TDD mode) working-directory: ./clients/static-site diff --git a/clients/static-site/.vizzlyrc.js b/clients/static-site/.vizzlyrc.js new file mode 100644 index 0000000..7e709f6 --- /dev/null +++ b/clients/static-site/.vizzlyrc.js @@ -0,0 +1,19 @@ +/** + * Vizzly config for Static-Site SDK development + * + * This config explicitly loads the local plugin since it's not installed + * in node_modules during development. + */ +export default { + // Load the local plugin directly + plugins: ['./dist/plugin.js'], + + // Default static-site config for E2E tests + staticSite: { + viewports: [{ name: 'default', width: 1280, height: 720 }], + concurrency: 3, + browser: { + headless: true, + }, + }, +}; diff --git a/clients/static-site/package-lock.json b/clients/static-site/package-lock.json index d390b13..f5e1489 100644 --- a/clients/static-site/package-lock.json +++ b/clients/static-site/package-lock.json @@ -1,17 +1,17 @@ { "name": "@vizzly-testing/static-site", - "version": "0.0.10", + "version": "0.0.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vizzly-testing/static-site", - "version": "0.0.10", + "version": "0.0.11", "license": "MIT", "dependencies": { "cosmiconfig": "^9.0.0", "fast-xml-parser": "^4.5.0", - "puppeteer": "^24.5.0", + "playwright-core": "^1.50.0", "serve-handler": "^6.1.5", "zod": "^4.1.12" }, @@ -1707,55 +1707,6 @@ "license": "MIT", "optional": true }, - "node_modules/@puppeteer/browsers": { - "version": "2.10.11", - "license": "Apache-2.0", - "dependencies": { - "debug": "^4.4.3", - "extract-zip": "^2.0.1", - "progress": "^2.0.3", - "proxy-agent": "^6.5.0", - "semver": "^7.7.2", - "tar-fs": "^3.1.1", - "yargs": "^17.7.2" - }, - "bin": { - "browsers": "lib/cjs/main-cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@puppeteer/browsers/node_modules/semver": { - "version": "7.7.3", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@tootallnate/quickjs-emscripten": { - "version": "0.23.0", - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "24.7.1", - "license": "MIT", - "optional": true, - "dependencies": { - "undici-types": "~7.14.0" - } - }, - "node_modules/@types/yauzl": { - "version": "2.10.3", - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@vizzly-testing/cli": { "version": "0.22.0", "resolved": "https://registry.npmjs.org/@vizzly-testing/cli/-/cli-0.22.0.tgz", @@ -1826,15 +1777,9 @@ "node": ">=22.0.0" } }, - "node_modules/agent-base": { - "version": "7.1.4", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -1842,6 +1787,7 @@ }, "node_modules/ansi-styles": { "version": "4.3.0", + "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -1879,32 +1825,10 @@ "version": "2.0.1", "license": "Python-2.0" }, - "node_modules/ast-types": { - "version": "0.13.4", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.1" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/asynckit": { "version": "0.4.0", "license": "MIT" }, - "node_modules/b4a": { - "version": "1.7.3", - "license": "Apache-2.0", - "peerDependencies": { - "react-native-b4a": "*" - }, - "peerDependenciesMeta": { - "react-native-b4a": { - "optional": true - } - } - }, "node_modules/babel-plugin-polyfill-corejs2": { "version": "0.4.14", "dev": true, @@ -1945,85 +1869,6 @@ "version": "1.0.2", "license": "MIT" }, - "node_modules/bare-events": { - "version": "2.8.0", - "license": "Apache-2.0", - "peerDependencies": { - "bare-abort-controller": "*" - }, - "peerDependenciesMeta": { - "bare-abort-controller": { - "optional": true - } - } - }, - "node_modules/bare-fs": { - "version": "4.4.10", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-events": "^2.5.4", - "bare-path": "^3.0.0", - "bare-stream": "^2.6.4", - "bare-url": "^2.2.2", - "fast-fifo": "^1.3.2" - }, - "engines": { - "bare": ">=1.16.0" - }, - "peerDependencies": { - "bare-buffer": "*" - }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - } - } - }, - "node_modules/bare-os": { - "version": "3.6.2", - "license": "Apache-2.0", - "optional": true, - "engines": { - "bare": ">=1.14.0" - } - }, - "node_modules/bare-path": { - "version": "3.0.0", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-os": "^3.0.1" - } - }, - "node_modules/bare-stream": { - "version": "2.7.0", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "streamx": "^2.21.0" - }, - "peerDependencies": { - "bare-buffer": "*", - "bare-events": "*" - }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - }, - "bare-events": { - "optional": true - } - } - }, - "node_modules/bare-url": { - "version": "2.2.2", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-path": "^3.0.0" - } - }, "node_modules/baseline-browser-mapping": { "version": "2.8.16", "dev": true, @@ -2032,13 +1877,6 @@ "baseline-browser-mapping": "dist/cli.js" } }, - "node_modules/basic-ftp": { - "version": "5.0.5", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/binary-extensions": { "version": "2.3.0", "dev": true, @@ -2104,13 +1942,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/bytes": { "version": "3.0.0", "license": "MIT", @@ -2179,38 +2010,9 @@ "fsevents": "~2.3.2" } }, - "node_modules/chromium-bidi": { - "version": "9.1.0", - "license": "Apache-2.0", - "dependencies": { - "mitt": "^3.0.1", - "zod": "^3.24.1" - }, - "peerDependencies": { - "devtools-protocol": "*" - } - }, - "node_modules/chromium-bidi/node_modules/zod": { - "version": "3.25.76", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/cliui": { - "version": "8.0.1", - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/color-convert": { "version": "2.0.1", + "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -2221,6 +2023,7 @@ }, "node_modules/color-name": { "version": "1.1.4", + "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -2306,15 +2109,9 @@ "node": ">= 8" } }, - "node_modules/data-uri-to-buffer": { - "version": "6.0.2", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/debug": { "version": "4.4.3", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2328,18 +2125,6 @@ } } }, - "node_modules/degenerator": { - "version": "5.0.1", - "license": "MIT", - "dependencies": { - "ast-types": "^0.13.4", - "escodegen": "^2.1.0", - "esprima": "^4.0.1" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "license": "MIT", @@ -2347,11 +2132,6 @@ "node": ">=0.4.0" } }, - "node_modules/devtools-protocol": { - "version": "0.0.1508733", - "license": "BSD-3-Clause", - "peer": true - }, "node_modules/dotenv": { "version": "17.2.3", "license": "BSD-2-Clause", @@ -2386,15 +2166,9 @@ }, "node_modules/emoji-regex": { "version": "8.0.0", + "dev": true, "license": "MIT" }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, "node_modules/env-paths": { "version": "2.2.1", "license": "MIT", @@ -2448,84 +2222,20 @@ }, "node_modules/escalade": { "version": "3.2.0", + "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, - "node_modules/escodegen": { - "version": "2.1.0", - "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/esprima": { - "version": "4.0.1", - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, "node_modules/esutils": { "version": "2.0.3", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" } }, - "node_modules/events-universal": { - "version": "1.0.1", - "license": "Apache-2.0", - "dependencies": { - "bare-events": "^2.7.0" - } - }, - "node_modules/extract-zip": { - "version": "2.0.1", - "license": "BSD-2-Clause", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, - "node_modules/fast-fifo": { - "version": "1.3.2", - "license": "MIT" - }, "node_modules/fast-xml-parser": { "version": "4.5.3", "funding": [ @@ -2542,13 +2252,6 @@ "fxparser": "src/cli/cli.js" } }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - } - }, "node_modules/fill-range": { "version": "7.1.1", "dev": true, @@ -2627,13 +2330,6 @@ "node": ">=6.9.0" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, "node_modules/get-intrinsic": { "version": "1.3.0", "license": "MIT", @@ -2667,31 +2363,6 @@ "node": ">= 0.4" } }, - "node_modules/get-stream": { - "version": "5.2.0", - "license": "MIT", - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-uri": { - "version": "6.0.5", - "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", "dev": true, @@ -2766,28 +2437,6 @@ "node": ">= 0.4" } }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/import-fresh": { "version": "3.3.1", "license": "MIT", @@ -2816,13 +2465,6 @@ "dev": true, "license": "ISC" }, - "node_modules/ip-address": { - "version": "10.0.1", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/is-arrayish": { "version": "0.2.1", "license": "MIT" @@ -2864,6 +2506,7 @@ }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3027,21 +2670,11 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/mitt": { - "version": "3.0.1", - "license": "MIT" - }, "node_modules/ms": { "version": "2.1.3", + "dev": true, "license": "MIT" }, - "node_modules/netmask": { - "version": "2.0.2", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/node-releases": { "version": "2.0.23", "dev": true, @@ -3058,39 +2691,12 @@ }, "node_modules/once": { "version": "1.4.0", + "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" } }, - "node_modules/pac-proxy-agent": { - "version": "7.2.0", - "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-resolver": { - "version": "7.0.1", - "license": "MIT", - "dependencies": { - "degenerator": "^5.0.0", - "netmask": "^2.0.2" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/package-json-from-dist": { "version": "1.0.1", "dev": true, @@ -3172,10 +2778,6 @@ "version": "3.3.0", "license": "MIT" }, - "node_modules/pend": { - "version": "1.2.0", - "license": "MIT" - }, "node_modules/picocolors": { "version": "1.1.1", "license": "ISC" @@ -3200,79 +2802,13 @@ "node": ">=6" } }, - "node_modules/progress": { - "version": "2.0.3", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/proxy-agent": { - "version": "6.5.0", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "http-proxy-agent": "^7.0.1", - "https-proxy-agent": "^7.0.6", - "lru-cache": "^7.14.1", - "pac-proxy-agent": "^7.1.0", - "proxy-from-env": "^1.1.0", - "socks-proxy-agent": "^8.0.5" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/proxy-agent/node_modules/lru-cache": { - "version": "7.18.3", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "license": "MIT" - }, - "node_modules/pump": { - "version": "3.0.3", - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/puppeteer": { - "version": "24.24.0", - "hasInstallScript": true, + "node_modules/playwright-core": { + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz", + "integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==", "license": "Apache-2.0", - "dependencies": { - "@puppeteer/browsers": "2.10.11", - "chromium-bidi": "9.1.0", - "cosmiconfig": "^9.0.0", - "devtools-protocol": "0.0.1508733", - "puppeteer-core": "24.24.0", - "typed-query-selector": "^2.12.0" - }, "bin": { - "puppeteer": "lib/cjs/puppeteer/node/cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/puppeteer-core": { - "version": "24.24.0", - "license": "Apache-2.0", - "dependencies": { - "@puppeteer/browsers": "2.10.11", - "chromium-bidi": "9.1.0", - "debug": "^4.4.3", - "devtools-protocol": "0.0.1508733", - "typed-query-selector": "^2.12.0", - "webdriver-bidi-protocol": "0.3.6", - "ws": "^8.18.3" + "playwright-core": "cli.js" }, "engines": { "node": ">=18" @@ -3345,13 +2881,6 @@ "regjsparser": "bin/parser" } }, - "node_modules/require-directory": { - "version": "2.1.1", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/resolve": { "version": "1.22.10", "dev": true, @@ -3508,57 +3037,9 @@ "node": ">=6" } }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks": { - "version": "2.8.7", - "license": "MIT", - "dependencies": { - "ip-address": "^10.0.1", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "8.0.5", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "license": "BSD-3-Clause", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/streamx": { - "version": "2.23.0", - "license": "MIT", - "dependencies": { - "events-universal": "^1.0.0", - "fast-fifo": "^1.3.2", - "text-decoder": "^1.1.0" - } - }, "node_modules/string-width": { "version": "4.2.3", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -3585,6 +3066,7 @@ }, "node_modules/strip-ansi": { "version": "6.0.1", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -3626,34 +3108,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tar-fs": { - "version": "3.1.1", - "license": "MIT", - "dependencies": { - "pump": "^3.0.0", - "tar-stream": "^3.1.5" - }, - "optionalDependencies": { - "bare-fs": "^4.0.1", - "bare-path": "^3.0.0" - } - }, - "node_modules/tar-stream": { - "version": "3.1.7", - "license": "MIT", - "dependencies": { - "b4a": "^1.6.4", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" - } - }, - "node_modules/text-decoder": { - "version": "1.2.3", - "license": "Apache-2.0", - "dependencies": { - "b4a": "^1.6.4" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "dev": true, @@ -3666,19 +3120,6 @@ "node": ">=8.0" } }, - "node_modules/tslib": { - "version": "2.8.1", - "license": "0BSD" - }, - "node_modules/typed-query-selector": { - "version": "2.12.0", - "license": "MIT" - }, - "node_modules/undici-types": { - "version": "7.14.0", - "license": "MIT", - "optional": true - }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.1", "dev": true, @@ -3744,10 +3185,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/webdriver-bidi-protocol": { - "version": "0.3.6", - "license": "Apache-2.0" - }, "node_modules/which": { "version": "2.0.2", "dev": true, @@ -3762,21 +3199,6 @@ "node": ">= 8" } }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "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-cjs": { "name": "wrap-ansi", "version": "7.0.0", @@ -3796,70 +3218,14 @@ }, "node_modules/wrappy": { "version": "1.0.2", + "dev": true, "license": "ISC" }, - "node_modules/ws": { - "version": "8.18.3", - "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/y18n": { - "version": "5.0.8", - "license": "ISC", - "engines": { - "node": ">=10" - } - }, "node_modules/yallist": { "version": "3.1.1", "dev": true, "license": "ISC" }, - "node_modules/yargs": { - "version": "17.7.2", - "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", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/yauzl": { - "version": "2.10.0", - "license": "MIT", - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, "node_modules/zod": { "version": "4.1.12", "license": "MIT", diff --git a/clients/static-site/package.json b/clients/static-site/package.json index 0c456cf..491a9db 100644 --- a/clients/static-site/package.json +++ b/clients/static-site/package.json @@ -31,9 +31,7 @@ } }, "main": "./dist/index.js", - "vizzly": { - "plugin": "./dist/plugin.js" - }, + "vizzlyPlugin": "./dist/plugin.js", "files": [ "dist", "README.md", @@ -45,10 +43,9 @@ "clean": "rimraf dist", "compile": "babel src --out-dir dist --ignore '**/*.test.js'", "prepublishOnly": "npm run lint && npm run build", - "test": "node --test --test-reporter=spec $(find tests -name '*.test.js' ! -name 'e2e.test.js')", - "test:e2e": "VIZZLY_E2E=1 node --test --test-reporter=spec tests/e2e.test.js", - "test:e2e:tdd": "../../bin/vizzly.js tdd run 'VIZZLY_E2E=1 node --test --test-reporter=spec tests/e2e.test.js'", - "test:e2e:cloud": "../../bin/vizzly.js run 'VIZZLY_E2E=1 node --test --test-reporter=spec tests/e2e.test.js'", + "test": "node --test --test-reporter=spec $(find tests -name '*.test.js' ! -name 'sdk-integration.test.js')", + "test:sdk-integration": "VIZZLY_E2E=1 node --test --test-reporter=spec tests/sdk-integration.test.js", + "test:e2e": "../../bin/vizzly.js static-site ../../test-site", "test:watch": "node --test --test-reporter=spec --watch $(find tests -name '*.test.js')", "lint": "biome lint src", "lint:fix": "biome lint --write src", @@ -70,7 +67,7 @@ "dependencies": { "cosmiconfig": "^9.0.0", "fast-xml-parser": "^4.5.0", - "puppeteer": "^24.5.0", + "playwright-core": "^1.50.0", "serve-handler": "^6.1.5", "zod": "^4.1.12" }, diff --git a/clients/static-site/src/browser.js b/clients/static-site/src/browser.js index aae6785..bbc36bf 100644 --- a/clients/static-site/src/browser.js +++ b/clients/static-site/src/browser.js @@ -1,51 +1,12 @@ /** - * Browser management with Puppeteer + * Browser management with Playwright * Core functions for launching and managing browsers */ -import puppeteer from 'puppeteer'; +import { chromium } from 'playwright-core'; /** - * Default browser args optimized for CI environments - * These reduce memory usage and improve stability in resource-constrained environments - */ -let CI_OPTIMIZED_ARGS = [ - // Required for running in containers/CI - '--no-sandbox', - '--disable-setuid-sandbox', - - // Reduce memory usage - '--disable-dev-shm-usage', // Use /tmp instead of /dev/shm (often too small in Docker) - '--disable-gpu', // No GPU in CI - '--disable-software-rasterizer', - - // Disable unnecessary features - '--disable-extensions', - '--disable-background-networking', - '--disable-background-timer-throttling', - '--disable-backgrounding-occluded-windows', - '--disable-breakpad', // Crash reporting - '--disable-component-update', - '--disable-default-apps', - '--disable-hang-monitor', - '--disable-ipc-flooding-protection', - '--disable-popup-blocking', - '--disable-prompt-on-repost', - '--disable-renderer-backgrounding', - '--disable-sync', - '--disable-translate', - - // Reduce resource usage - '--metrics-recording-only', - '--no-first-run', - '--safebrowsing-disable-auto-update', - - // Memory optimizations - '--js-flags=--max-old-space-size=512', // Limit V8 heap -]; - -/** - * Launch a Puppeteer browser instance + * Launch a Playwright browser instance * @param {Object} options - Browser launch options * @param {boolean} [options.headless=true] - Run in headless mode * @param {Array} [options.args=[]] - Additional browser arguments @@ -54,11 +15,49 @@ let CI_OPTIMIZED_ARGS = [ export async function launchBrowser(options = {}) { let { headless = true, args = [] } = options; - let browser = await puppeteer.launch({ + let browser = await chromium.launch({ headless, - args: [...CI_OPTIMIZED_ARGS, ...args], - // Reduce protocol timeout for faster failure detection - protocolTimeout: 60_000, // 60s instead of default 180s + args: [ + // Required for running in containers/CI + '--no-sandbox', + '--disable-setuid-sandbox', + + // Reduce memory usage + '--disable-dev-shm-usage', + + // Disable unnecessary features + '--disable-extensions', + '--disable-background-networking', + '--disable-background-timer-throttling', + '--disable-backgrounding-occluded-windows', + '--disable-breakpad', + '--disable-component-update', + '--disable-default-apps', + '--disable-hang-monitor', + '--disable-ipc-flooding-protection', + '--disable-popup-blocking', + '--disable-prompt-on-repost', + '--disable-renderer-backgrounding', + '--disable-sync', + + // Disable features via --disable-features (modern approach) + '--disable-features=Translate,OptimizationHints,MediaRouter', + + // Reduce resource usage + '--metrics-recording-only', + '--no-first-run', + + // Screenshot consistency + '--hide-scrollbars', + '--mute-audio', + '--force-color-profile=srgb', + + // Memory optimizations + '--js-flags=--max-old-space-size=512', + + // User-provided args + ...args, + ], }); return browser; @@ -77,7 +76,7 @@ export async function closeBrowser(browser) { /** * Navigate to a URL and wait for the page to load - * @param {Object} page - Puppeteer page instance + * @param {Object} page - Playwright page instance * @param {string} url - URL to navigate to * @param {Object} [options] - Navigation options * @returns {Promise} @@ -85,15 +84,15 @@ export async function closeBrowser(browser) { export async function navigateToUrl(page, url, options = {}) { try { await page.goto(url, { - waitUntil: 'networkidle2', + waitUntil: 'networkidle', timeout: 30000, ...options, }); } catch (error) { - // Fallback to domcontentloaded if networkidle2 times out + // Fallback to domcontentloaded if networkidle times out if ( error.message.includes('timeout') || - error.message.includes('Navigation timeout') + error.message.includes('Timeout') ) { await page.goto(url, { waitUntil: 'domcontentloaded', diff --git a/clients/static-site/src/pool.js b/clients/static-site/src/pool.js index c9e56f9..c18216b 100644 --- a/clients/static-site/src/pool.js +++ b/clients/static-site/src/pool.js @@ -1,134 +1,120 @@ /** - * Functional tab pool for browser tab management - * Uses closures instead of classes for a more functional approach + * Functional context pool for browser context management + * Uses Playwright's BrowserContext for isolation between parallel workers */ /** - * Default number of uses before recycling a tab - * After this many uses, the tab is closed and a fresh one created + * Default number of uses before recycling a context + * After this many uses, the context is closed and a fresh one created * This prevents memory leaks from accumulating */ let DEFAULT_RECYCLE_AFTER = 10; /** - * Create a tab pool that manages browser tabs with reuse and recycling - * @param {Object} browser - Puppeteer browser instance - * @param {number} size - Maximum number of concurrent tabs + * Create a context pool that manages browser contexts with reuse and recycling + * Uses Playwright's BrowserContext for proper isolation between parallel workers + * @param {Object} browser - Playwright browser instance + * @param {number} size - Maximum number of concurrent contexts * @param {Object} [options] - Pool options - * @param {number} [options.recycleAfter=10] - Recycle tab after N uses + * @param {number} [options.recycleAfter=10] - Recycle context after N uses + * @param {Object} [options.viewport] - Default viewport for new contexts * @returns {Object} Pool operations: { acquire, release, drain, stats } */ export function createTabPool(browser, size, options = {}) { - let { recycleAfter = DEFAULT_RECYCLE_AFTER } = options; + let { recycleAfter = DEFAULT_RECYCLE_AFTER, viewport } = options; - // Track tabs with their use counts: { tab, useCount } + // Track contexts with their use counts and pages let available = []; let waiting = []; - let totalTabs = 0; + let totalContexts = 0; let recycledCount = 0; /** - * Create a fresh tab entry - * @returns {Promise} Tab entry { tab, useCount } + * Create a fresh context entry with a page + * @returns {Promise} Context entry { context, page, useCount } */ - let createTabEntry = async () => { - let tab = await browser.newPage(); - return { tab, useCount: 0 }; + let createContextEntry = async () => { + let contextOptions = {}; + if (viewport) { + contextOptions.viewport = { width: viewport.width, height: viewport.height }; + } + let context = await browser.newContext(contextOptions); + let page = await context.newPage(); + return { context, page, useCount: 0 }; }; /** - * Acquire a tab from the pool - * Returns an existing tab if available, creates new if under limit, + * Acquire a page from the pool + * Returns an existing page if available, creates new if under limit, * or waits for one to become available. * * IMPORTANT: If drain() is called while waiting, this returns null. - * Callers MUST check for null before using the tab. + * Callers MUST check for null before using the page. * - * @returns {Promise} Puppeteer page instance, or null if pool was drained + * @returns {Promise} Playwright page instance, or null if pool was drained */ let acquire = async () => { - // Reuse existing tab if available + // Reuse existing context/page if available if (available.length > 0) { let entry = available.pop(); entry.useCount++; - return entry.tab; + return entry.page; } - // Create new tab if under limit - if (totalTabs < size) { - totalTabs++; - let entry = await createTabEntry(); + // Create new context if under limit + if (totalContexts < size) { + totalContexts++; + let entry = await createContextEntry(); entry.useCount = 1; - // Store entry reference on tab for release lookup - entry.tab._poolEntry = entry; - return entry.tab; + // Store entry reference on page for release lookup + entry.page._poolEntry = entry; + return entry.page; } - // Wait for a tab to become available + // Wait for a context to become available return new Promise(resolve => { waiting.push(resolve); }); }; /** - * Reset tab state to prevent cross-contamination between tasks - * Clears cookies, localStorage, and resets to about:blank - * @param {Object} tab - Puppeteer page instance - * @returns {Promise} - */ - let resetTab = async tab => { - try { - // Clear cookies for this page's context - let client = await tab.createCDPSession(); - await client.send('Network.clearBrowserCookies'); - await client.detach(); - - // Clear localStorage/sessionStorage by navigating to blank page - await tab.goto('about:blank', { waitUntil: 'domcontentloaded' }); - } catch { - // Ignore reset errors - tab may be in a bad state but still usable - } - }; - - /** - * Release a tab back to the pool - * Resets tab state before reuse to prevent cross-contamination. - * Recycles (closes and replaces) tabs that have been used too many times. + * Release a page back to the pool + * Recycles (closes and replaces) contexts that have been used too many times. * If workers are waiting, hand off directly; otherwise add to available. - * @param {Object} tab - Puppeteer page instance to release + * @param {Object} page - Playwright page instance to release */ - let release = async tab => { - if (!tab) return; + let release = async page => { + if (!page) return; - let entry = tab._poolEntry; + let entry = page._poolEntry; - // Check if tab needs recycling + // Check if context needs recycling if (entry && entry.useCount >= recycleAfter) { recycledCount++; - // Close the old tab + // Close the old context (this also closes all its pages) try { - await tab.close(); + await entry.context.close(); } catch { // Ignore close errors } // Create a fresh replacement try { - let newEntry = await createTabEntry(); - newEntry.tab._poolEntry = newEntry; + let newEntry = await createContextEntry(); + newEntry.page._poolEntry = newEntry; // Hand off to waiting worker or add to available if (waiting.length > 0) { newEntry.useCount = 1; let next = waiting.shift(); - next(newEntry.tab); + next(newEntry.page); } else { available.push(newEntry); } } catch { - // Failed to create new tab - reduce total count and notify waiting worker - totalTabs--; + // Failed to create new context - reduce total count and notify waiting worker + totalContexts--; if (waiting.length > 0) { let next = waiting.shift(); next(null); // Signal failure so task can handle it @@ -137,23 +123,28 @@ export function createTabPool(browser, size, options = {}) { return; } - // Reset tab state before reuse - await resetTab(tab); + // Clear context state by navigating to blank page + try { + await page.goto('about:blank', { waitUntil: 'domcontentloaded' }); + } catch { + // Ignore reset errors + } - // If someone is waiting, give them the tab directly + // If someone is waiting, give them the page directly if (waiting.length > 0) { if (entry) entry.useCount++; let next = waiting.shift(); - next(tab); + next(page); } else if (entry) { available.push(entry); } else { - available.push({ tab, useCount: 0 }); + // Orphaned page - shouldn't happen but handle gracefully + available.push({ page, useCount: 0 }); } }; /** - * Close all tabs and reset pool state + * Close all contexts and reset pool state * Call this when done with the pool. * * Any pending acquire() calls will resolve with null. @@ -162,17 +153,17 @@ export function createTabPool(browser, size, options = {}) { * @returns {Promise} */ let drain = async () => { - // Close all available tabs + // Close all available contexts await Promise.all( available.map(entry => - entry.tab.close().catch(() => { - // Ignore close errors (tab may already be closed) + entry.context.close().catch(() => { + // Ignore close errors (context may already be closed) }) ) ); available = []; - totalTabs = 0; + totalContexts = 0; // Resolve any waiting acquires with null for (let resolve of waiting) { @@ -188,7 +179,7 @@ export function createTabPool(browser, size, options = {}) { let stats = () => ({ available: available.length, waiting: waiting.length, - total: totalTabs, + total: totalContexts, size, recycled: recycledCount, }); diff --git a/clients/static-site/src/screenshot.js b/clients/static-site/src/screenshot.js index dbca150..2c69868 100644 --- a/clients/static-site/src/screenshot.js +++ b/clients/static-site/src/screenshot.js @@ -63,10 +63,10 @@ let DEFAULT_SCREENSHOT_TIMEOUT = 45_000; /** * Capture a screenshot from a page - * @param {Object} page - Puppeteer page instance + * @param {Object} page - Playwright page instance * @param {Object} options - Screenshot options * @param {boolean} [options.fullPage=false] - Capture full page - * @param {boolean} [options.omitBackground=false] - Omit background + * @param {boolean} [options.omitBackground=false] - Omit background (transparent) * @param {number} [options.timeout=45000] - Screenshot timeout in ms * @returns {Promise} Screenshot buffer */ @@ -77,6 +77,7 @@ export async function captureScreenshot(page, options = {}) { timeout = DEFAULT_SCREENSHOT_TIMEOUT, } = options; + // Playwright has built-in timeout support let screenshot = await page.screenshot({ fullPage, omitBackground, diff --git a/clients/static-site/src/utils/viewport.js b/clients/static-site/src/utils/viewport.js index 33c6e74..2e01a01 100644 --- a/clients/static-site/src/utils/viewport.js +++ b/clients/static-site/src/utils/viewport.js @@ -44,13 +44,13 @@ export function formatViewport(viewport) { } /** - * Set viewport on Puppeteer page - * @param {Object} page - Puppeteer page instance + * Set viewport on Playwright page + * @param {Object} page - Playwright page instance * @param {Object} viewport - Viewport object { width, height } * @returns {Promise} */ export async function setViewport(page, viewport) { - await page.setViewport({ + await page.setViewportSize({ width: viewport.width, height: viewport.height, }); diff --git a/clients/static-site/tests/pool.test.js b/clients/static-site/tests/pool.test.js index 96605be..16d692f 100644 --- a/clients/static-site/tests/pool.test.js +++ b/clients/static-site/tests/pool.test.js @@ -7,35 +7,43 @@ import { describe, it, mock } from 'node:test'; import { createTabPool } from '../src/pool.js'; /** - * Create a mock browser for testing + * Create a mock page for testing */ -function createMockBrowser() { - let pageCount = 0; - let newPageCalls = 0; - +function createMockPage(id) { return { - newPage: mock.fn(async () => { - pageCount++; - newPageCalls++; - return createMockTab(pageCount); - }), - getPageCount: () => pageCount, - getNewPageCalls: () => newPageCalls, + id, + goto: mock.fn(async () => {}), }; } /** - * Create a mock tab/page for testing + * Create a mock context for testing (Playwright BrowserContext) */ -function createMockTab(id) { +function createMockContext(id) { + let page = createMockPage(id); return { id, + page, + newPage: mock.fn(async () => page), close: mock.fn(async () => {}), - goto: mock.fn(async () => {}), - createCDPSession: mock.fn(async () => ({ - send: mock.fn(async () => {}), - detach: mock.fn(async () => {}), - })), + }; +} + +/** + * Create a mock browser for testing (Playwright style) + */ +function createMockBrowser() { + let contextCount = 0; + let newContextCalls = 0; + + return { + newContext: mock.fn(async () => { + contextCount++; + newContextCalls++; + return createMockContext(contextCount); + }), + getPageCount: () => contextCount, + getNewPageCalls: () => newContextCalls, }; } @@ -200,21 +208,22 @@ describe('createTabPool', () => { }); describe('drain', () => { - it('closes all available tabs', async () => { + it('closes all available contexts', async () => { let browser = createMockBrowser(); let pool = createTabPool(browser, 3); - let tab1 = await pool.acquire(); - let tab2 = await pool.acquire(); - await pool.release(tab1); - await pool.release(tab2); + let page1 = await pool.acquire(); + let page2 = await pool.acquire(); + await pool.release(page1); + await pool.release(page2); assert.strictEqual(pool.stats().available, 2); await pool.drain(); - assert.strictEqual(tab1.close.mock.callCount(), 1); - assert.strictEqual(tab2.close.mock.callCount(), 1); + // Context close is called, not page close + assert.strictEqual(page1._poolEntry.context.close.mock.callCount(), 1); + assert.strictEqual(page2._poolEntry.context.close.mock.callCount(), 1); assert.strictEqual(pool.stats().available, 0); assert.strictEqual(pool.stats().total, 0); }); @@ -239,22 +248,22 @@ describe('createTabPool', () => { let browser = createMockBrowser(); let pool = createTabPool(browser, 2); - let tab1 = await pool.acquire(); - let tab2 = await pool.acquire(); + let page1 = await pool.acquire(); + let page2 = await pool.acquire(); - // Make first tab throw on close - tab1.close = mock.fn(async () => { + // Make first context throw on close + page1._poolEntry.context.close = mock.fn(async () => { throw new Error('Close failed'); }); - await pool.release(tab1); - await pool.release(tab2); + await pool.release(page1); + await pool.release(page2); // Should not throw await pool.drain(); - assert.strictEqual(tab1.close.mock.callCount(), 1); - assert.strictEqual(tab2.close.mock.callCount(), 1); + assert.strictEqual(page1._poolEntry.context.close.mock.callCount(), 1); + assert.strictEqual(page2._poolEntry.context.close.mock.callCount(), 1); }); }); @@ -333,19 +342,19 @@ describe('createTabPool', () => { assert.strictEqual(pool.stats().recycled, 2); }); - it('closes old tab during recycling', async () => { + it('closes old context during recycling', async () => { let browser = createMockBrowser(); let pool = createTabPool(browser, 1, { recycleAfter: 2 }); - let tab = await pool.acquire(); - await pool.release(tab); // use 1 + let page = await pool.acquire(); + await pool.release(page); // use 1 - tab = await pool.acquire(); - assert.strictEqual(tab.close.mock.callCount(), 0); + page = await pool.acquire(); + assert.strictEqual(page._poolEntry.context.close.mock.callCount(), 0); - await pool.release(tab); // use 2 - triggers recycle + await pool.release(page); // use 2 - triggers recycle - assert.strictEqual(tab.close.mock.callCount(), 1); + assert.strictEqual(page._poolEntry.context.close.mock.callCount(), 1); }); it('hands off fresh tab to waiting acquirer during recycling', async () => { @@ -368,27 +377,27 @@ describe('createTabPool', () => { assert.notStrictEqual(newTab.id, originalId); }); - it('reduces total count when new tab creation fails during recycling', async () => { + it('reduces total count when new context creation fails during recycling', async () => { let callCount = 0; let browser = { - newPage: mock.fn(async () => { + newContext: mock.fn(async () => { callCount++; if (callCount === 2) { - throw new Error('Failed to create tab'); + throw new Error('Failed to create context'); } - return createMockTab(callCount); + return createMockContext(callCount); }), }; let pool = createTabPool(browser, 1, { recycleAfter: 2 }); - let tab = await pool.acquire(); + let page = await pool.acquire(); assert.strictEqual(pool.stats().total, 1); - await pool.release(tab); // use 1 + await pool.release(page); // use 1 - tab = await pool.acquire(); - await pool.release(tab); // use 2 - triggers recycle, new tab fails + page = await pool.acquire(); + await pool.release(page); // use 2 - triggers recycle, new context fails // Total should be reduced since we couldn't create replacement assert.strictEqual(pool.stats().total, 0); @@ -398,35 +407,35 @@ describe('createTabPool', () => { let browser = createMockBrowser(); let pool = createTabPool(browser, 1, { recycleAfter: 2 }); - let tab = await pool.acquire(); - tab.close = mock.fn(async () => { + let page = await pool.acquire(); + page._poolEntry.context.close = mock.fn(async () => { throw new Error('Close failed'); }); - await pool.release(tab); // use 1 + await pool.release(page); // use 1 - tab = await pool.acquire(); - tab.close = mock.fn(async () => { + page = await pool.acquire(); + page._poolEntry.context.close = mock.fn(async () => { throw new Error('Close failed'); }); // Should not throw despite close error - await pool.release(tab); // use 2 - triggers recycle + await pool.release(page); // use 2 - triggers recycle assert.strictEqual(pool.stats().recycled, 1); }); }); describe('_poolEntry metadata', () => { - it('preserves _poolEntry reference on tab', async () => { + it('preserves _poolEntry reference on page', async () => { let browser = createMockBrowser(); let pool = createTabPool(browser, 2); - let tab = await pool.acquire(); + let page = await pool.acquire(); - assert.ok(tab._poolEntry); - assert.strictEqual(tab._poolEntry.tab, tab); - assert.strictEqual(tab._poolEntry.useCount, 1); + assert.ok(page._poolEntry); + assert.strictEqual(page._poolEntry.page, page); + assert.strictEqual(page._poolEntry.useCount, 1); }); it('increments useCount on each acquire', async () => { diff --git a/clients/static-site/tests/e2e.test.js b/clients/static-site/tests/sdk-integration.test.js similarity index 100% rename from clients/static-site/tests/e2e.test.js rename to clients/static-site/tests/sdk-integration.test.js diff --git a/clients/static-site/tests/utils/viewport.test.js b/clients/static-site/tests/utils/viewport.test.js index 794babc..aabc4f6 100644 --- a/clients/static-site/tests/utils/viewport.test.js +++ b/clients/static-site/tests/utils/viewport.test.js @@ -86,10 +86,10 @@ describe('viewport', () => { }); describe('setViewport', () => { - it('calls page.setViewport with width and height', async () => { + it('calls page.setViewportSize with width and height', async () => { let calledWith = null; let mockPage = { - setViewport: async opts => { + setViewportSize: async opts => { calledWith = opts; }, }; diff --git a/clients/storybook/.vizzlyrc.js b/clients/storybook/.vizzlyrc.js new file mode 100644 index 0000000..88ed027 --- /dev/null +++ b/clients/storybook/.vizzlyrc.js @@ -0,0 +1,19 @@ +/** + * Vizzly config for Storybook SDK development + * + * This config explicitly loads the local plugin since it's not installed + * in node_modules during development. + */ +export default { + // Load the local plugin directly + plugins: ['./dist/plugin.js'], + + // Default storybook config for E2E tests + storybook: { + viewports: [{ name: 'default', width: 1280, height: 720 }], + // concurrency auto-detected from CPU cores (half cores, max 4) + browser: { + headless: true, + }, + }, +}; diff --git a/clients/storybook/example-storybook/package-lock.json b/clients/storybook/example-storybook/package-lock.json index c364264..1b194be 100644 --- a/clients/storybook/example-storybook/package-lock.json +++ b/clients/storybook/example-storybook/package-lock.json @@ -11,6 +11,7 @@ "@storybook/addon-essentials": "^8.5.0", "@storybook/react": "^8.5.0", "@storybook/react-vite": "^8.5.0", + "@vitejs/plugin-react": "^5.1.2", "react": "^18.3.1", "react-dom": "^18.3.1", "storybook": "^8.5.0", @@ -18,13 +19,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", + "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -33,9 +34,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", - "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", + "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", "dev": true, "license": "MIT", "engines": { @@ -43,22 +44,22 @@ } }, "node_modules/@babel/core": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", - "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", + "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.3", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.4", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.4", - "@babel/types": "^7.28.4", + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -75,14 +76,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", - "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", + "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.3", - "@babel/types": "^7.28.2", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -92,13 +93,13 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.2", + "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", @@ -129,29 +130,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -160,6 +161,16 @@ "@babel/core": "^7.0.0" } }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -171,9 +182,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, "license": "MIT", "engines": { @@ -191,27 +202,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", - "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", + "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.4" + "@babel/types": "^7.28.6" }, "bin": { "parser": "bin/babel-parser.js" @@ -220,6 +231,38 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/runtime": { "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", @@ -231,33 +274,33 @@ } }, "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", - "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", + "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.3", + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.4", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6", "debug": "^4.3.1" }, "engines": { @@ -265,14 +308,14 @@ } }, "node_modules/@babel/types": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", - "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", + "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" @@ -851,6 +894,13 @@ "node": ">=14" } }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", + "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/pluginutils": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", @@ -1742,6 +1792,27 @@ "dev": true, "license": "MIT" }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", + "integrity": "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.5", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.53", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -1818,9 +1889,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.12", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.12.tgz", - "integrity": "sha512-vAPMQdnyKCBtkmQA6FMCBvU9qFIppS3nzyXnEM+Lo2IAhG4Mpjv9cCxMudhgV3YdNNJv6TNqXy97dfRVL2LmaQ==", + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1857,9 +1928,9 @@ "dev": true }, "node_modules/browserslist": { - "version": "4.26.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", - "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, "funding": [ { @@ -1878,11 +1949,11 @@ "license": "MIT", "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.8.9", - "caniuse-lite": "^1.0.30001746", - "electron-to-chromium": "^1.5.227", - "node-releases": "^2.0.21", - "update-browserslist-db": "^1.1.3" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -1942,9 +2013,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001748", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001748.tgz", - "integrity": "sha512-5P5UgAr0+aBmNiplks08JLw+AW/XG/SurlgZLgB1dDLfAw7EfRGxIwzPHxdSCGY/BTKDqIVyJL87cCN6s0ZR0w==", + "version": "1.0.30001766", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", + "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==", "dev": true, "funding": [ { @@ -2103,9 +2174,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.232", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.232.tgz", - "integrity": "sha512-ENirSe7wf8WzyPCibqKUG1Cg43cPaxH4wRR7AJsX7MCABCHBIOFqvaYODSLKUuZdraxUTHRE/0A2Aq8BYKEHOg==", + "version": "1.5.282", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.282.tgz", + "integrity": "sha512-FCPkJtpst28UmFzd903iU7PdeVTfY0KAeJy+Lk0GLZRwgwYHn/irRcaCbQQOmr5Vytc/7rcavsYLvTM8RiHYhQ==", "dev": true, "license": "ISC" }, @@ -2836,9 +2907,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.23", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz", - "integrity": "sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==", + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "dev": true, "license": "MIT" }, @@ -3086,6 +3157,16 @@ "react": "^18.3.1" } }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/recast": { "version": "0.23.11", "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", @@ -3533,9 +3614,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { diff --git a/clients/storybook/example-storybook/package.json b/clients/storybook/example-storybook/package.json index 3828496..764ed20 100644 --- a/clients/storybook/example-storybook/package.json +++ b/clients/storybook/example-storybook/package.json @@ -11,6 +11,7 @@ "@storybook/addon-essentials": "^8.5.0", "@storybook/react": "^8.5.0", "@storybook/react-vite": "^8.5.0", + "@vitejs/plugin-react": "^5.1.2", "react": "^18.3.1", "react-dom": "^18.3.1", "storybook": "^8.5.0", diff --git a/clients/storybook/example-storybook/vite.config.js b/clients/storybook/example-storybook/vite.config.js new file mode 100644 index 0000000..fabde1a --- /dev/null +++ b/clients/storybook/example-storybook/vite.config.js @@ -0,0 +1,6 @@ +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [react()], +}); diff --git a/clients/storybook/package-lock.json b/clients/storybook/package-lock.json index 2a32223..d6f2a41 100644 --- a/clients/storybook/package-lock.json +++ b/clients/storybook/package-lock.json @@ -1,15 +1,15 @@ { "name": "@vizzly-testing/storybook", - "version": "0.1.2", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vizzly-testing/storybook", - "version": "0.1.2", + "version": "0.2.0", "license": "MIT", "dependencies": { - "puppeteer": "^24.5.0", + "playwright-core": "^1.50.0", "serve-handler": "^6.1.5" }, "devDependencies": { @@ -1907,65 +1907,6 @@ "license": "MIT", "optional": true }, - "node_modules/@puppeteer/browsers": { - "version": "2.10.10", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.10.tgz", - "integrity": "sha512-3ZG500+ZeLql8rE0hjfhkycJjDj0pI/btEh3L9IkWUYcOrgP0xCNRq3HbtbqOPbvDhFaAWD88pDFtlLv8ns8gA==", - "license": "Apache-2.0", - "dependencies": { - "debug": "^4.4.3", - "extract-zip": "^2.0.1", - "progress": "^2.0.3", - "proxy-agent": "^6.5.0", - "semver": "^7.7.2", - "tar-fs": "^3.1.0", - "yargs": "^17.7.2" - }, - "bin": { - "browsers": "lib/cjs/main-cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@puppeteer/browsers/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "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==", - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "24.7.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.0.tgz", - "integrity": "sha512-IbKooQVqUBrlzWTi79E8Fw78l8k1RNtlDDNWsFZs7XonuQSJ8oNYfEeclhprUldXISRMLzBpILuKgPlIxm+/Yw==", - "license": "MIT", - "optional": true, - "dependencies": { - "undici-types": "~7.14.0" - } - }, - "node_modules/@types/yauzl": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", - "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@vizzly-testing/cli": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/@vizzly-testing/cli/-/cli-0.9.1.tgz", @@ -2033,15 +1974,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "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==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -2087,38 +2019,12 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, - "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==", - "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", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, - "node_modules/b4a": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", - "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", - "license": "Apache-2.0", - "peerDependencies": { - "react-native-b4a": "*" - }, - "peerDependenciesMeta": { - "react-native-b4a": { - "optional": true - } - } - }, "node_modules/babel-plugin-polyfill-corejs2": { "version": "0.4.14", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz", @@ -2167,89 +2073,6 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, - "node_modules/bare-events": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.7.0.tgz", - "integrity": "sha512-b3N5eTW1g7vXkw+0CXh/HazGTcO5KYuu/RCNaJbDMPI6LHDi+7qe8EmxKUVe1sUbY2KZOVZFyj62x0OEz9qyAA==", - "license": "Apache-2.0" - }, - "node_modules/bare-fs": { - "version": "4.4.5", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.4.5.tgz", - "integrity": "sha512-TCtu93KGLu6/aiGWzMr12TmSRS6nKdfhAnzTQRbXoSWxkbb9eRd53jQ51jG7g1gYjjtto3hbBrrhzg6djcgiKg==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-events": "^2.5.4", - "bare-path": "^3.0.0", - "bare-stream": "^2.6.4", - "bare-url": "^2.2.2", - "fast-fifo": "^1.3.2" - }, - "engines": { - "bare": ">=1.16.0" - }, - "peerDependencies": { - "bare-buffer": "*" - }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - } - } - }, - "node_modules/bare-os": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", - "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", - "license": "Apache-2.0", - "optional": true, - "engines": { - "bare": ">=1.14.0" - } - }, - "node_modules/bare-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", - "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-os": "^3.0.1" - } - }, - "node_modules/bare-stream": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz", - "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "streamx": "^2.21.0" - }, - "peerDependencies": { - "bare-buffer": "*", - "bare-events": "*" - }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - }, - "bare-events": { - "optional": true - } - } - }, - "node_modules/bare-url": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.2.2.tgz", - "integrity": "sha512-g+ueNGKkrjMazDG3elZO1pNs3HY5+mMmOet1jtKyhOaCnkLzitxf26z7hoAEkDNgdNmnc1KIlt/dw6Po6xZMpA==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-path": "^3.0.0" - } - }, "node_modules/baseline-browser-mapping": { "version": "2.8.12", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.12.tgz", @@ -2260,15 +2083,6 @@ "baseline-browser-mapping": "dist/cli.js" } }, - "node_modules/basic-ftp": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz", - "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -2342,15 +2156,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/bytes": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", @@ -2429,33 +2234,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/chromium-bidi": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-9.1.0.tgz", - "integrity": "sha512-rlUzQ4WzIAWdIbY/viPShhZU2n21CxDUgazXVbw4Hu1MwaeUSEksSeM6DqPgpRjCLXRk702AVRxJxoOz0dw4OA==", - "license": "Apache-2.0", - "dependencies": { - "mitt": "^3.0.1", - "zod": "^3.24.1" - }, - "peerDependencies": { - "devtools-protocol": "*" - } - }, - "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==", - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2572,19 +2350,11 @@ "node": ">= 8" } }, - "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==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2598,20 +2368,6 @@ } } }, - "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==", - "license": "MIT", - "dependencies": { - "ast-types": "^0.13.4", - "escodegen": "^2.1.0", - "esprima": "^4.0.1" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -2621,13 +2377,6 @@ "node": ">=0.4.0" } }, - "node_modules/devtools-protocol": { - "version": "0.0.1508733", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1508733.tgz", - "integrity": "sha512-QJ1R5gtck6nDcdM+nlsaJXcelPEI7ZxSMw1ujHpO1c4+9l+Nue5qlebi9xO1Z2MGr92bFOQTW7/rrheh5hHxDg==", - "license": "BSD-3-Clause", - "peer": true - }, "node_modules/dotenv": { "version": "17.2.3", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", @@ -2673,15 +2422,6 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -2749,107 +2489,22 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, - "node_modules/escodegen": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", - "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", - "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/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "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==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.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/events-universal": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", - "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", - "license": "Apache-2.0", - "dependencies": { - "bare-events": "^2.7.0" - } - }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "license": "BSD-2-Clause", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, - "node_modules/fast-fifo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", - "license": "MIT" - }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - } - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -2944,15 +2599,6 @@ "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==", - "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", @@ -2990,35 +2636,6 @@ "node": ">= 0.4" } }, - "node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "license": "MIT", - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-uri": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", - "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", - "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", @@ -3106,32 +2723,6 @@ "node": ">= 0.4" } }, - "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==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "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==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -3167,15 +2758,6 @@ "dev": true, "license": "ISC" }, - "node_modules/ip-address": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", - "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -3426,27 +3008,13 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/mitt": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", - "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", - "license": "MIT" - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, - "node_modules/netmask": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", - "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/node-releases": { "version": "2.0.23", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz", @@ -3479,43 +3047,12 @@ "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/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==", - "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-resolver": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", - "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", - "license": "MIT", - "dependencies": { - "degenerator": "^5.0.0", - "netmask": "^2.0.2" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -3615,12 +3152,6 @@ "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==", "license": "MIT" }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "license": "MIT" - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3651,93 +3182,13 @@ "node": ">=6" } }, - "node_modules/progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/proxy-agent": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", - "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "http-proxy-agent": "^7.0.1", - "https-proxy-agent": "^7.0.6", - "lru-cache": "^7.14.1", - "pac-proxy-agent": "^7.1.0", - "proxy-from-env": "^1.1.0", - "socks-proxy-agent": "^8.0.5" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/proxy-agent/node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, - "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/puppeteer": { - "version": "24.23.0", - "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.23.0.tgz", - "integrity": "sha512-BVR1Lg8sJGKXY79JARdIssFWK2F6e1j+RyuJP66w4CUmpaXjENicmA3nNpUXA8lcTdDjAndtP+oNdni3T/qQqA==", - "hasInstallScript": true, + "node_modules/playwright-core": { + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz", + "integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==", "license": "Apache-2.0", - "dependencies": { - "@puppeteer/browsers": "2.10.10", - "chromium-bidi": "9.1.0", - "cosmiconfig": "^9.0.0", - "devtools-protocol": "0.0.1508733", - "puppeteer-core": "24.23.0", - "typed-query-selector": "^2.12.0" - }, "bin": { - "puppeteer": "lib/cjs/puppeteer/node/cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/puppeteer-core": { - "version": "24.23.0", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.23.0.tgz", - "integrity": "sha512-yl25C59gb14sOdIiSnJ08XiPP+O2RjuyZmEG+RjYmCXO7au0jcLf7fRiyii96dXGUBW7Zwei/mVKfxMx/POeFw==", - "license": "Apache-2.0", - "dependencies": { - "@puppeteer/browsers": "2.10.10", - "chromium-bidi": "9.1.0", - "debug": "^4.4.3", - "devtools-protocol": "0.0.1508733", - "typed-query-selector": "^2.12.0", - "webdriver-bidi-protocol": "0.3.6", - "ws": "^8.18.3" + "playwright-core": "cli.js" }, "engines": { "node": ">=18" @@ -3824,15 +3275,6 @@ "regjsparser": "bin/parser" } }, - "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==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -4012,65 +3454,6 @@ "node": ">=6" } }, - "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==", - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks": { - "version": "2.8.7", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", - "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", - "license": "MIT", - "dependencies": { - "ip-address": "^10.0.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==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, - "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==", - "license": "BSD-3-Clause", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/streamx": { - "version": "2.23.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", - "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", - "license": "MIT", - "dependencies": { - "events-universal": "^1.0.0", - "fast-fifo": "^1.3.2", - "text-decoder": "^1.1.0" - } - }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -4138,40 +3521,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tar-fs": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", - "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", - "license": "MIT", - "dependencies": { - "pump": "^3.0.0", - "tar-stream": "^3.1.5" - }, - "optionalDependencies": { - "bare-fs": "^4.0.1", - "bare-path": "^3.0.0" - } - }, - "node_modules/tar-stream": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", - "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", - "license": "MIT", - "dependencies": { - "b4a": "^1.6.4", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" - } - }, - "node_modules/text-decoder": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", - "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", - "license": "Apache-2.0", - "dependencies": { - "b4a": "^1.6.4" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -4186,25 +3535,6 @@ "node": ">=8.0" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/typed-query-selector": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", - "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", - "license": "MIT" - }, - "node_modules/undici-types": { - "version": "7.14.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz", - "integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==", - "license": "MIT", - "optional": true - }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", @@ -4280,12 +3610,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/webdriver-bidi-protocol": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.6.tgz", - "integrity": "sha512-mlGndEOA9yK9YAbvtxaPTqdi/kaCWYYfwrZvGzcmkr/3lWM+tQj53BxtpVd6qbC6+E5OnHXgCcAhre6AkXzxjA==", - "license": "Apache-2.0" - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4301,23 +3625,6 @@ "node": ">= 8" } }, - "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==", - "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-cjs": { "name": "wrap-ansi", "version": "7.0.0", @@ -4340,90 +3647,15 @@ "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.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "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/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "license": "ISC", - "engines": { - "node": ">=10" - } - }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" - }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "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==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "license": "MIT", - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, - "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } } } } diff --git a/clients/storybook/package.json b/clients/storybook/package.json index c837714..6527263 100644 --- a/clients/storybook/package.json +++ b/clients/storybook/package.json @@ -27,9 +27,7 @@ } }, "main": "./dist/index.js", - "vizzly": { - "plugin": "./dist/plugin.js" - }, + "vizzlyPlugin": "./dist/plugin.js", "files": [ "dist", "README.md", @@ -41,10 +39,9 @@ "clean": "rimraf dist", "compile": "babel src --out-dir dist --ignore '**/*.test.js'", "prepublishOnly": "npm run lint && npm run build", - "test": "node --test --test-reporter=spec $(find tests -name '*.test.js' ! -name 'e2e.test.js')", - "test:e2e": "VIZZLY_E2E=1 node --test --test-reporter=spec tests/e2e.test.js", - "test:e2e:tdd": "../../bin/vizzly.js tdd run 'VIZZLY_E2E=1 node --test --test-reporter=spec tests/e2e.test.js'", - "test:e2e:cloud": "../../bin/vizzly.js run 'VIZZLY_E2E=1 node --test --test-reporter=spec tests/e2e.test.js'", + "test": "node --test --test-reporter=spec $(find tests -name '*.test.js' ! -name 'sdk-integration.test.js')", + "test:sdk-integration": "VIZZLY_E2E=1 node --test --test-reporter=spec tests/sdk-integration.test.js", + "test:e2e": "../../bin/vizzly.js storybook ./example-storybook/dist", "test:watch": "node --test --test-reporter=spec --watch 'tests/**/*.test.js'", "lint": "biome lint src tests", "lint:fix": "biome lint --write src tests", @@ -64,7 +61,7 @@ "@vizzly-testing/cli": ">=0.9.0" }, "dependencies": { - "puppeteer": "^24.5.0", + "playwright-core": "^1.50.0", "serve-handler": "^6.1.5" }, "devDependencies": { diff --git a/clients/storybook/src/browser.js b/clients/storybook/src/browser.js index 6646aea..6fefcb3 100644 --- a/clients/storybook/src/browser.js +++ b/clients/storybook/src/browser.js @@ -1,52 +1,12 @@ /** - * Browser management with Puppeteer + * Browser management with Playwright * Core functions for launching and managing browsers */ -import puppeteer from 'puppeteer'; +import { chromium } from 'playwright-core'; /** - * Browser args optimized for stability and consistency - * These are used in both local dev and CI to ensure identical behavior. - * Disabling GPU, extensions, etc. reduces flakiness and memory usage. - */ -let CI_OPTIMIZED_ARGS = [ - // Required for running in containers/CI - '--no-sandbox', - '--disable-setuid-sandbox', - - // Reduce memory usage - '--disable-dev-shm-usage', // Use /tmp instead of /dev/shm (often too small in Docker) - '--disable-gpu', // No GPU in CI - '--disable-software-rasterizer', - - // Disable unnecessary features - '--disable-extensions', - '--disable-background-networking', - '--disable-background-timer-throttling', - '--disable-backgrounding-occluded-windows', - '--disable-breakpad', // Crash reporting - '--disable-component-update', - '--disable-default-apps', - '--disable-hang-monitor', - '--disable-ipc-flooding-protection', - '--disable-popup-blocking', - '--disable-prompt-on-repost', - '--disable-renderer-backgrounding', - '--disable-sync', - '--disable-translate', - - // Reduce resource usage - '--metrics-recording-only', - '--no-first-run', - '--safebrowsing-disable-auto-update', - - // Memory optimizations (1GB for larger Storybooks) - '--js-flags=--max-old-space-size=1024', -]; - -/** - * Launch a Puppeteer browser instance + * Launch a Playwright browser instance * @param {Object} options - Browser launch options * @param {boolean} [options.headless=true] - Run in headless mode * @param {Array} [options.args=[]] - Additional browser arguments @@ -55,11 +15,49 @@ let CI_OPTIMIZED_ARGS = [ export async function launchBrowser(options = {}) { let { headless = true, args = [] } = options; - let browser = await puppeteer.launch({ + let browser = await chromium.launch({ headless, - args: [...CI_OPTIMIZED_ARGS, ...args], - // Reduce protocol timeout for faster failure detection - protocolTimeout: 60_000, // 60s instead of default 180s + args: [ + // Required for running in containers/CI + '--no-sandbox', + '--disable-setuid-sandbox', + + // Reduce memory usage + '--disable-dev-shm-usage', + + // Disable unnecessary features + '--disable-extensions', + '--disable-background-networking', + '--disable-background-timer-throttling', + '--disable-backgrounding-occluded-windows', + '--disable-breakpad', + '--disable-component-update', + '--disable-default-apps', + '--disable-hang-monitor', + '--disable-ipc-flooding-protection', + '--disable-popup-blocking', + '--disable-prompt-on-repost', + '--disable-renderer-backgrounding', + '--disable-sync', + + // Disable features via --disable-features (modern approach) + '--disable-features=Translate,OptimizationHints,MediaRouter', + + // Reduce resource usage + '--metrics-recording-only', + '--no-first-run', + + // Screenshot consistency + '--hide-scrollbars', + '--mute-audio', + '--force-color-profile=srgb', + + // Memory optimizations (1GB for larger Storybooks) + '--js-flags=--max-old-space-size=1024', + + // User-provided args + ...args, + ], }); return browser; @@ -78,7 +76,7 @@ export async function closeBrowser(browser) { /** * Navigate to a URL and wait for the page to load - * @param {Object} page - Puppeteer page instance + * @param {Object} page - Playwright page instance * @param {string} url - URL to navigate to * @param {Object} [options] - Navigation options * @returns {Promise} @@ -86,15 +84,15 @@ export async function closeBrowser(browser) { export async function navigateToUrl(page, url, options = {}) { try { await page.goto(url, { - waitUntil: 'networkidle2', + waitUntil: 'networkidle', timeout: 30000, ...options, }); } catch (error) { - // Fallback to domcontentloaded if networkidle2 times out + // Fallback to domcontentloaded if networkidle times out if ( error.message.includes('timeout') || - error.message.includes('Navigation timeout') + error.message.includes('Timeout') ) { await page.goto(url, { waitUntil: 'domcontentloaded', diff --git a/clients/storybook/src/config.js b/clients/storybook/src/config.js index e55e45f..1f4fe3b 100644 --- a/clients/storybook/src/config.js +++ b/clients/storybook/src/config.js @@ -4,8 +4,19 @@ * Reads from config.storybook section of main vizzly.config.js */ +import { cpus } from 'node:os'; import { parseViewport } from './utils/viewport.js'; +/** + * Calculate sensible default concurrency + * Uses half of CPU cores, capped at 8, minimum 2 + * Matches static-site SDK behavior + */ +function getDefaultConcurrency() { + let cores = cpus().length; + return Math.max(2, Math.min(8, Math.floor(cores / 2))); +} + /** * Default configuration values */ @@ -23,7 +34,7 @@ export let defaultConfig = { fullPage: false, omitBackground: false, }, - concurrency: 3, + concurrency: getDefaultConcurrency(), include: null, exclude: null, interactions: {}, diff --git a/clients/storybook/src/navigation.js b/clients/storybook/src/navigation.js new file mode 100644 index 0000000..36230f2 --- /dev/null +++ b/clients/storybook/src/navigation.js @@ -0,0 +1,175 @@ +/** + * Smart navigation for Storybook + * Uses client-side navigation when possible to avoid full page reloads + * This dramatically improves performance by not reloading the Storybook bundle for each story + */ + +/** + * Generate iframe URL for a story + * @param {string} baseUrl - Base Storybook URL + * @param {string} storyId - Story ID + * @returns {string} Full iframe URL + */ +export function generateStoryUrl(baseUrl, storyId) { + return `${baseUrl}/iframe.html?id=${encodeURIComponent(storyId)}&viewMode=story`; +} + +/** + * Navigate to a story using client-side navigation when possible + * First visit per page does a full page load, subsequent visits use Storybook's internal API + * + * @param {Object} page - Playwright page instance + * @param {string} storyId - Story ID to navigate to + * @param {string} baseUrl - Base Storybook URL + * @param {Object} [options] - Navigation options + * @param {number} [options.timeout=30000] - Navigation timeout in ms + * @returns {Promise} + */ +export async function navigateToStory(page, storyId, baseUrl, options = {}) { + let { timeout = 30000 } = options; + let entry = page._poolEntry; + let verbose = process.env.VIZZLY_LOG_LEVEL === 'debug'; + + // Debug: log navigation mode + let navMode = !entry?.storybookInitialized + ? 'full-page-init' + : entry.currentStoryId === storyId + ? 'skip-same-story' + : 'client-side'; + + if (verbose) { + console.error(` [nav] ${storyId}: ${navMode} (poolEntry: ${!!entry})`); + } + + // First time this tab visits Storybook: full page load + if (!entry?.storybookInitialized) { + let start = Date.now(); + await fullPageNavigation(page, storyId, baseUrl, timeout); + + if (verbose) { + console.error(` [nav] ${storyId}: full-page took ${Date.now() - start}ms`); + } + + if (entry) { + entry.storybookInitialized = true; + entry.currentStoryId = storyId; + } + return; + } + + // Same story (maybe different viewport) - no navigation needed + if (entry.currentStoryId === storyId) { + if (verbose) { + console.error(` [nav] ${storyId}: skip (same story)`); + } + return; + } + + // Subsequent visit: use client-side navigation + try { + let start = Date.now(); + await clientSideNavigation(page, storyId, timeout); + + if (verbose) { + console.error(` [nav] ${storyId}: client-side took ${Date.now() - start}ms`); + } + entry.currentStoryId = storyId; + } catch (error) { + // Log fallback - always show since this is unexpected behavior + console.error( + ` [nav] ${storyId}: client-side failed, falling back to full-page: ${error.message}` + ); + let start = Date.now(); + await fullPageNavigation(page, storyId, baseUrl, timeout); + + if (verbose) { + console.error(` [nav] ${storyId}: fallback full-page took ${Date.now() - start}ms`); + } + entry.currentStoryId = storyId; + } +} + +/** + * Perform full page navigation (initial load) + * @param {Object} page - Playwright page instance + * @param {string} storyId - Story ID + * @param {string} baseUrl - Base URL + * @param {number} timeout - Timeout in ms + */ +async function fullPageNavigation(page, storyId, baseUrl, timeout) { + let url = generateStoryUrl(baseUrl, storyId); + + try { + await page.goto(url, { + waitUntil: 'networkidle', + timeout, + }); + } catch (error) { + // Fallback to domcontentloaded if networkidle times out + if ( + error.message.includes('timeout') || + error.message.includes('Timeout') + ) { + await page.goto(url, { + waitUntil: 'domcontentloaded', + timeout, + }); + } else { + throw error; + } + } +} + +/** + * Perform client-side navigation using Storybook's internal API + * This is much faster as it doesn't reload the entire bundle + * @param {Object} page - Playwright page instance + * @param {string} storyId - Story ID + * @param {number} timeout - Timeout in ms + */ +async function clientSideNavigation(page, storyId, timeout) { + // Navigate using Storybook's preview API and wait for story to render + // Playwright requires passing arguments as an object in the second parameter + await page.evaluate( + ({ id, timeoutMs }) => { + return new Promise((resolve, reject) => { + let preview = window.__STORYBOOK_PREVIEW__; + if (!preview?.channel) { + reject(new Error('Storybook preview API not available')); + return; + } + + let timeoutId; + + // Listen for story render completion + let handleRendered = () => { + clearTimeout(timeoutId); + preview.channel.off('storyRendered', handleRendered); + resolve(); + }; + preview.channel.on('storyRendered', handleRendered); + + // Navigate to the story + preview.channel.emit('setCurrentStory', { storyId: id }); + + // Timeout fallback - use configured timeout + timeoutId = setTimeout(() => { + preview.channel.off('storyRendered', handleRendered); + resolve(); // Resolve anyway - story might have rendered + }, timeoutMs); + }); + }, + { id: storyId, timeoutMs: timeout } + ); +} + +/** + * Reset tab's Storybook state (called on tab recycle) + * @param {Object} entry - Pool entry for the tab + */ +export function resetStorybookState(entry) { + if (entry) { + entry.storybookInitialized = false; + entry.currentStoryId = null; + } +} diff --git a/clients/storybook/src/pool.js b/clients/storybook/src/pool.js index c9e56f9..1732d2e 100644 --- a/clients/storybook/src/pool.js +++ b/clients/storybook/src/pool.js @@ -1,134 +1,120 @@ /** - * Functional tab pool for browser tab management - * Uses closures instead of classes for a more functional approach + * Functional context pool for browser context management + * Uses Playwright's BrowserContext for isolation between parallel workers */ /** - * Default number of uses before recycling a tab - * After this many uses, the tab is closed and a fresh one created + * Default number of uses before recycling a context + * After this many uses, the context is closed and a fresh one created * This prevents memory leaks from accumulating */ let DEFAULT_RECYCLE_AFTER = 10; /** - * Create a tab pool that manages browser tabs with reuse and recycling - * @param {Object} browser - Puppeteer browser instance - * @param {number} size - Maximum number of concurrent tabs + * Create a context pool that manages browser contexts with reuse and recycling + * Uses Playwright's BrowserContext for proper isolation between parallel workers + * @param {Object} browser - Playwright browser instance + * @param {number} size - Maximum number of concurrent contexts * @param {Object} [options] - Pool options - * @param {number} [options.recycleAfter=10] - Recycle tab after N uses + * @param {number} [options.recycleAfter=10] - Recycle context after N uses + * @param {Object} [options.viewport] - Default viewport for new contexts * @returns {Object} Pool operations: { acquire, release, drain, stats } */ export function createTabPool(browser, size, options = {}) { - let { recycleAfter = DEFAULT_RECYCLE_AFTER } = options; + let { recycleAfter = DEFAULT_RECYCLE_AFTER, viewport } = options; - // Track tabs with their use counts: { tab, useCount } + // Track contexts with their use counts and pages let available = []; let waiting = []; - let totalTabs = 0; + let totalContexts = 0; let recycledCount = 0; /** - * Create a fresh tab entry - * @returns {Promise} Tab entry { tab, useCount } + * Create a fresh context entry with a page + * @returns {Promise} Context entry { context, page, useCount } */ - let createTabEntry = async () => { - let tab = await browser.newPage(); - return { tab, useCount: 0 }; + let createContextEntry = async () => { + let contextOptions = {}; + if (viewport) { + contextOptions.viewport = { width: viewport.width, height: viewport.height }; + } + let context = await browser.newContext(contextOptions); + let page = await context.newPage(); + return { context, page, useCount: 0 }; }; /** - * Acquire a tab from the pool - * Returns an existing tab if available, creates new if under limit, + * Acquire a page from the pool + * Returns an existing page if available, creates new if under limit, * or waits for one to become available. * * IMPORTANT: If drain() is called while waiting, this returns null. - * Callers MUST check for null before using the tab. + * Callers MUST check for null before using the page. * - * @returns {Promise} Puppeteer page instance, or null if pool was drained + * @returns {Promise} Playwright page instance, or null if pool was drained */ let acquire = async () => { - // Reuse existing tab if available + // Reuse existing context/page if available if (available.length > 0) { let entry = available.pop(); entry.useCount++; - return entry.tab; + return entry.page; } - // Create new tab if under limit - if (totalTabs < size) { - totalTabs++; - let entry = await createTabEntry(); + // Create new context if under limit + if (totalContexts < size) { + totalContexts++; + let entry = await createContextEntry(); entry.useCount = 1; - // Store entry reference on tab for release lookup - entry.tab._poolEntry = entry; - return entry.tab; + // Store entry reference on page for release lookup + entry.page._poolEntry = entry; + return entry.page; } - // Wait for a tab to become available + // Wait for a context to become available return new Promise(resolve => { waiting.push(resolve); }); }; /** - * Reset tab state to prevent cross-contamination between tasks - * Clears cookies, localStorage, and resets to about:blank - * @param {Object} tab - Puppeteer page instance - * @returns {Promise} - */ - let resetTab = async tab => { - try { - // Clear cookies for this page's context - let client = await tab.createCDPSession(); - await client.send('Network.clearBrowserCookies'); - await client.detach(); - - // Clear localStorage/sessionStorage by navigating to blank page - await tab.goto('about:blank', { waitUntil: 'domcontentloaded' }); - } catch { - // Ignore reset errors - tab may be in a bad state but still usable - } - }; - - /** - * Release a tab back to the pool - * Resets tab state before reuse to prevent cross-contamination. - * Recycles (closes and replaces) tabs that have been used too many times. + * Release a page back to the pool + * Recycles (closes and replaces) contexts that have been used too many times. * If workers are waiting, hand off directly; otherwise add to available. - * @param {Object} tab - Puppeteer page instance to release + * @param {Object} page - Playwright page instance to release */ - let release = async tab => { - if (!tab) return; + let release = async page => { + if (!page) return; - let entry = tab._poolEntry; + let entry = page._poolEntry; - // Check if tab needs recycling + // Check if context needs recycling if (entry && entry.useCount >= recycleAfter) { recycledCount++; - // Close the old tab + // Close the old context (this also closes all its pages) try { - await tab.close(); + await entry.context.close(); } catch { // Ignore close errors } // Create a fresh replacement try { - let newEntry = await createTabEntry(); - newEntry.tab._poolEntry = newEntry; + let newEntry = await createContextEntry(); + newEntry.page._poolEntry = newEntry; // Hand off to waiting worker or add to available if (waiting.length > 0) { newEntry.useCount = 1; let next = waiting.shift(); - next(newEntry.tab); + next(newEntry.page); } else { available.push(newEntry); } } catch { - // Failed to create new tab - reduce total count and notify waiting worker - totalTabs--; + // Failed to create new context - reduce total count and notify waiting worker + totalContexts--; if (waiting.length > 0) { let next = waiting.shift(); next(null); // Signal failure so task can handle it @@ -137,23 +123,21 @@ export function createTabPool(browser, size, options = {}) { return; } - // Reset tab state before reuse - await resetTab(tab); - - // If someone is waiting, give them the tab directly + // If someone is waiting, give them the page directly if (waiting.length > 0) { if (entry) entry.useCount++; let next = waiting.shift(); - next(tab); + next(page); } else if (entry) { available.push(entry); } else { - available.push({ tab, useCount: 0 }); + // Orphaned page - shouldn't happen but handle gracefully + available.push({ page, useCount: 0 }); } }; /** - * Close all tabs and reset pool state + * Close all contexts and reset pool state * Call this when done with the pool. * * Any pending acquire() calls will resolve with null. @@ -162,17 +146,17 @@ export function createTabPool(browser, size, options = {}) { * @returns {Promise} */ let drain = async () => { - // Close all available tabs + // Close all available contexts await Promise.all( available.map(entry => - entry.tab.close().catch(() => { - // Ignore close errors (tab may already be closed) + entry.context.close().catch(() => { + // Ignore close errors (context may already be closed) }) ) ); available = []; - totalTabs = 0; + totalContexts = 0; // Resolve any waiting acquires with null for (let resolve of waiting) { @@ -188,7 +172,7 @@ export function createTabPool(browser, size, options = {}) { let stats = () => ({ available: available.length, waiting: waiting.length, - total: totalTabs, + total: totalContexts, size, recycled: recycledCount, }); diff --git a/clients/storybook/src/screenshot.js b/clients/storybook/src/screenshot.js index 7195c57..55069c5 100644 --- a/clients/storybook/src/screenshot.js +++ b/clients/storybook/src/screenshot.js @@ -33,20 +33,28 @@ export function generateScreenshotName(story, viewport) { return `${sanitizedTitle}-${name}@${viewportName}`; } +/** + * Default timeout for screenshot capture (45 seconds) + * Normal screenshots take 25-150ms; this matches static-site SDK + */ +const SCREENSHOT_TIMEOUT_MS = 45_000; + /** * Capture a screenshot from a page - * @param {Object} page - Puppeteer page instance + * @param {Object} page - Playwright page instance * @param {Object} options - Screenshot options * @param {boolean} [options.fullPage=false] - Capture full page - * @param {boolean} [options.omitBackground=false] - Omit background + * @param {boolean} [options.omitBackground=false] - Omit background (transparent) * @returns {Promise} Screenshot buffer */ export async function captureScreenshot(page, options = {}) { let { fullPage = false, omitBackground = false } = options; + // Playwright has built-in timeout support let screenshot = await page.screenshot({ fullPage, omitBackground, + timeout: SCREENSHOT_TIMEOUT_MS, }); return screenshot; @@ -54,7 +62,7 @@ export async function captureScreenshot(page, options = {}) { /** * Capture and send screenshot to Vizzly - * @param {Object} page - Puppeteer page instance + * @param {Object} page - Playwright page instance * @param {Object} story - Story object * @param {Object} viewport - Viewport object * @param {Object} screenshotOptions - Screenshot options @@ -67,7 +75,19 @@ export async function captureAndSendScreenshot( screenshotOptions = {} ) { let name = generateScreenshotName(story, viewport); + let verbose = process.env.VIZZLY_LOG_LEVEL === 'debug'; + + let t0 = Date.now(); let screenshot = await captureScreenshot(page, screenshotOptions); + let captureTime = Date.now() - t0; + let t1 = Date.now(); await vizzlyScreenshot(name, screenshot); + let sendTime = Date.now() - t1; + + if (verbose) { + console.error( + ` [screenshot] ${name}: capture=${captureTime}ms send=${sendTime}ms` + ); + } } diff --git a/clients/storybook/src/tasks.js b/clients/storybook/src/tasks.js index 5adee8c..45e9e6d 100644 --- a/clients/storybook/src/tasks.js +++ b/clients/storybook/src/tasks.js @@ -3,12 +3,12 @@ * Functional approach: tasks are (story, viewport) tuples processed through a tab pool */ -import { navigateToUrl as defaultNavigateToUrl } from './browser.js'; import { generateStoryUrl as defaultGenerateStoryUrl } from './crawler.js'; import { getBeforeScreenshotHook as defaultGetBeforeScreenshotHook, getStoryConfig as defaultGetStoryConfig, } from './hooks.js'; +import { navigateToStory as defaultNavigateToStory } from './navigation.js'; import { captureAndSendScreenshot as defaultCaptureAndSendScreenshot } from './screenshot.js'; import { setViewport as defaultSetViewport } from './utils/viewport.js'; @@ -21,12 +21,13 @@ let defaultDeps = { getBeforeScreenshotHook: defaultGetBeforeScreenshotHook, captureAndSendScreenshot: defaultCaptureAndSendScreenshot, setViewport: defaultSetViewport, - navigateToUrl: defaultNavigateToUrl, + navigateToStory: defaultNavigateToStory, }; /** * Generate all tasks from stories and config * Flattens stories × viewports into individual work items + * Tasks are sorted by viewport to minimize viewport changes per tab * @param {Array} stories - Array of story objects * @param {string} baseUrl - Base URL for the Storybook server * @param {Object} config - Configuration object @@ -34,54 +35,83 @@ let defaultDeps = { * @returns {Array} Array of task objects */ export function generateTasks(stories, baseUrl, config, deps = {}) { - let { getStoryConfig, generateStoryUrl, getBeforeScreenshotHook } = { + let { getStoryConfig, getBeforeScreenshotHook } = { ...defaultDeps, ...deps, }; - return stories.flatMap(story => { + let tasks = stories.flatMap(story => { let storyConfig = getStoryConfig(story, config); - let url = generateStoryUrl(baseUrl, story.id); let hook = getBeforeScreenshotHook(story, config); return storyConfig.viewports.map(viewport => ({ story, viewport, hook, - url, + storyId: story.id, + baseUrl, screenshotOptions: storyConfig.screenshot || {}, })); }); + + // Sort by viewport to minimize viewport changes when processing sequentially per tab + // This groups same-viewport tasks together, reducing resize operations + tasks.sort((a, b) => { + let viewportKey = v => `${v.width}x${v.height}`; + return viewportKey(a.viewport).localeCompare(viewportKey(b.viewport)); + }); + + return tasks; } /** * Process a single task with a tab + * Uses smart navigation: first visit loads Storybook, subsequent visits use client-side routing * @param {Object} tab - Puppeteer page instance - * @param {Object} task - Task object { story, viewport, hook, url, screenshotOptions } + * @param {Object} task - Task object { story, viewport, hook, storyId, baseUrl, screenshotOptions } * @param {Object} [deps] - Optional dependencies for testing * @returns {Promise} */ export async function processTask(tab, task, deps = {}) { - let { setViewport, navigateToUrl, captureAndSendScreenshot } = { + let { setViewport, navigateToStory, captureAndSendScreenshot } = { ...defaultDeps, ...deps, }; - let { story, viewport, hook, url, screenshotOptions } = task; + let { story, viewport, hook, storyId, baseUrl, screenshotOptions } = task; + let verbose = process.env.VIZZLY_LOG_LEVEL === 'debug'; + let timings = {}; // Set viewport (tab is reused, so always set) + let t0 = Date.now(); await setViewport(tab, viewport); + timings.viewport = Date.now() - t0; - // Navigate to the story - await navigateToUrl(tab, url); + // Navigate to the story (smart: uses client-side navigation when possible) + let t1 = Date.now(); + await navigateToStory(tab, storyId, baseUrl); + timings.navigate = Date.now() - t1; // Run interaction hook if provided if (hook && typeof hook === 'function') { + let t2 = Date.now(); await hook(tab); + timings.hook = Date.now() - t2; } // Capture and send screenshot + let t3 = Date.now(); await captureAndSendScreenshot(tab, story, viewport, screenshotOptions); + timings.screenshot = Date.now() - t3; + + // Log timing breakdown in verbose mode + if (verbose) { + let total = timings.viewport + timings.navigate + (timings.hook || 0) + timings.screenshot; + let hookStr = timings.hook ? ` hook=${timings.hook}ms` : ''; + console.error( + ` [timing] ${storyId}@${viewport.name}: viewport=${timings.viewport}ms nav=${timings.navigate}ms${hookStr} screenshot=${timings.screenshot}ms (total=${total}ms)` + ); + } } /** diff --git a/clients/storybook/src/utils/viewport.js b/clients/storybook/src/utils/viewport.js index 33c6e74..2e01a01 100644 --- a/clients/storybook/src/utils/viewport.js +++ b/clients/storybook/src/utils/viewport.js @@ -44,13 +44,13 @@ export function formatViewport(viewport) { } /** - * Set viewport on Puppeteer page - * @param {Object} page - Puppeteer page instance + * Set viewport on Playwright page + * @param {Object} page - Playwright page instance * @param {Object} viewport - Viewport object { width, height } * @returns {Promise} */ export async function setViewport(page, viewport) { - await page.setViewport({ + await page.setViewportSize({ width: viewport.width, height: viewport.height, }); diff --git a/clients/storybook/tests/navigation.test.js b/clients/storybook/tests/navigation.test.js new file mode 100644 index 0000000..7a92f01 --- /dev/null +++ b/clients/storybook/tests/navigation.test.js @@ -0,0 +1,177 @@ +/** + * Tests for smart Storybook navigation + */ + +import assert from 'node:assert'; +import { describe, it, mock } from 'node:test'; +import { + generateStoryUrl, + navigateToStory, + resetStorybookState, +} from '../src/navigation.js'; + +describe('generateStoryUrl', () => { + it('generates correct iframe URL', () => { + let url = generateStoryUrl('http://localhost:6006', 'button--primary'); + assert.strictEqual( + url, + 'http://localhost:6006/iframe.html?id=button--primary&viewMode=story' + ); + }); + + it('encodes special characters in story ID', () => { + let url = generateStoryUrl('http://localhost:6006', 'button/with spaces'); + assert.strictEqual( + url, + 'http://localhost:6006/iframe.html?id=button%2Fwith%20spaces&viewMode=story' + ); + }); +}); + +describe('navigateToStory', () => { + it('does full page navigation on first visit', async () => { + let gotoCalls = []; + + let tab = { + _poolEntry: {}, + goto: mock.fn(async url => { + gotoCalls.push(url); + }), + }; + + await navigateToStory(tab, 'button--primary', 'http://localhost:6006'); + + assert.strictEqual(gotoCalls.length, 1); + assert.ok(gotoCalls[0].includes('button--primary')); + assert.strictEqual(tab._poolEntry.storybookInitialized, true); + assert.strictEqual(tab._poolEntry.currentStoryId, 'button--primary'); + }); + + it('uses client-side navigation on subsequent visits', async () => { + let gotoCalls = []; + + let tab = { + _poolEntry: { + storybookInitialized: true, + currentStoryId: 'button--primary', + }, + goto: mock.fn(async url => { + gotoCalls.push(url); + }), + evaluate: mock.fn(async () => true), + waitForFunction: mock.fn(async () => {}), + }; + + await navigateToStory(tab, 'button--secondary', 'http://localhost:6006'); + + assert.strictEqual(gotoCalls.length, 0); // No full page navigation + // Single evaluate call that handles navigation + waiting + assert.strictEqual(tab.evaluate.mock.callCount(), 1); + assert.strictEqual(tab._poolEntry.currentStoryId, 'button--secondary'); + }); + + it('skips navigation if same story', async () => { + let tab = { + _poolEntry: { + storybookInitialized: true, + currentStoryId: 'button--primary', + }, + goto: mock.fn(), + evaluate: mock.fn(), + }; + + await navigateToStory(tab, 'button--primary', 'http://localhost:6006'); + + assert.strictEqual(tab.goto.mock.callCount(), 0); + assert.strictEqual(tab.evaluate.mock.callCount(), 0); + }); + + it('falls back to full navigation if client-side fails', async () => { + let gotoCalls = []; + + let tab = { + _poolEntry: { + storybookInitialized: true, + currentStoryId: 'button--primary', + }, + goto: mock.fn(async url => { + gotoCalls.push(url); + }), + evaluate: mock.fn(async () => { + throw new Error('Storybook API not available'); + }), + }; + + await navigateToStory(tab, 'button--secondary', 'http://localhost:6006'); + + assert.strictEqual(gotoCalls.length, 1); + assert.ok(gotoCalls[0].includes('button--secondary')); + assert.strictEqual(tab._poolEntry.currentStoryId, 'button--secondary'); + }); + + it('falls back to domcontentloaded on timeout', async () => { + let gotoCalls = []; + let callCount = 0; + + let tab = { + _poolEntry: {}, + goto: mock.fn(async (url, options) => { + callCount++; + gotoCalls.push({ url, waitUntil: options.waitUntil }); + if (callCount === 1 && options.waitUntil === 'networkidle') { + throw new Error('Navigation timeout exceeded'); + } + }), + }; + + await navigateToStory(tab, 'button--primary', 'http://localhost:6006'); + + assert.strictEqual(gotoCalls.length, 2); + assert.strictEqual(gotoCalls[0].waitUntil, 'networkidle'); + assert.strictEqual(gotoCalls[1].waitUntil, 'domcontentloaded'); + }); + + it('propagates non-timeout errors', async () => { + let tab = { + _poolEntry: {}, + goto: mock.fn(async () => { + throw new Error('Network error'); + }), + }; + + await assert.rejects( + () => navigateToStory(tab, 'button--primary', 'http://localhost:6006'), + { message: 'Network error' } + ); + }); + + it('handles tab without _poolEntry', async () => { + let tab = { + goto: mock.fn(async () => {}), + }; + + await navigateToStory(tab, 'button--primary', 'http://localhost:6006'); + + assert.strictEqual(tab.goto.mock.callCount(), 1); + }); +}); + +describe('resetStorybookState', () => { + it('clears storybookInitialized flag', () => { + let entry = { + storybookInitialized: true, + currentStoryId: 'button--primary', + }; + + resetStorybookState(entry); + + assert.strictEqual(entry.storybookInitialized, false); + assert.strictEqual(entry.currentStoryId, null); + }); + + it('handles null entry gracefully', () => { + // Should not throw + resetStorybookState(null); + resetStorybookState(undefined); + }); +}); diff --git a/clients/storybook/tests/pool.test.js b/clients/storybook/tests/pool.test.js index 96605be..16d692f 100644 --- a/clients/storybook/tests/pool.test.js +++ b/clients/storybook/tests/pool.test.js @@ -7,35 +7,43 @@ import { describe, it, mock } from 'node:test'; import { createTabPool } from '../src/pool.js'; /** - * Create a mock browser for testing + * Create a mock page for testing */ -function createMockBrowser() { - let pageCount = 0; - let newPageCalls = 0; - +function createMockPage(id) { return { - newPage: mock.fn(async () => { - pageCount++; - newPageCalls++; - return createMockTab(pageCount); - }), - getPageCount: () => pageCount, - getNewPageCalls: () => newPageCalls, + id, + goto: mock.fn(async () => {}), }; } /** - * Create a mock tab/page for testing + * Create a mock context for testing (Playwright BrowserContext) */ -function createMockTab(id) { +function createMockContext(id) { + let page = createMockPage(id); return { id, + page, + newPage: mock.fn(async () => page), close: mock.fn(async () => {}), - goto: mock.fn(async () => {}), - createCDPSession: mock.fn(async () => ({ - send: mock.fn(async () => {}), - detach: mock.fn(async () => {}), - })), + }; +} + +/** + * Create a mock browser for testing (Playwright style) + */ +function createMockBrowser() { + let contextCount = 0; + let newContextCalls = 0; + + return { + newContext: mock.fn(async () => { + contextCount++; + newContextCalls++; + return createMockContext(contextCount); + }), + getPageCount: () => contextCount, + getNewPageCalls: () => newContextCalls, }; } @@ -200,21 +208,22 @@ describe('createTabPool', () => { }); describe('drain', () => { - it('closes all available tabs', async () => { + it('closes all available contexts', async () => { let browser = createMockBrowser(); let pool = createTabPool(browser, 3); - let tab1 = await pool.acquire(); - let tab2 = await pool.acquire(); - await pool.release(tab1); - await pool.release(tab2); + let page1 = await pool.acquire(); + let page2 = await pool.acquire(); + await pool.release(page1); + await pool.release(page2); assert.strictEqual(pool.stats().available, 2); await pool.drain(); - assert.strictEqual(tab1.close.mock.callCount(), 1); - assert.strictEqual(tab2.close.mock.callCount(), 1); + // Context close is called, not page close + assert.strictEqual(page1._poolEntry.context.close.mock.callCount(), 1); + assert.strictEqual(page2._poolEntry.context.close.mock.callCount(), 1); assert.strictEqual(pool.stats().available, 0); assert.strictEqual(pool.stats().total, 0); }); @@ -239,22 +248,22 @@ describe('createTabPool', () => { let browser = createMockBrowser(); let pool = createTabPool(browser, 2); - let tab1 = await pool.acquire(); - let tab2 = await pool.acquire(); + let page1 = await pool.acquire(); + let page2 = await pool.acquire(); - // Make first tab throw on close - tab1.close = mock.fn(async () => { + // Make first context throw on close + page1._poolEntry.context.close = mock.fn(async () => { throw new Error('Close failed'); }); - await pool.release(tab1); - await pool.release(tab2); + await pool.release(page1); + await pool.release(page2); // Should not throw await pool.drain(); - assert.strictEqual(tab1.close.mock.callCount(), 1); - assert.strictEqual(tab2.close.mock.callCount(), 1); + assert.strictEqual(page1._poolEntry.context.close.mock.callCount(), 1); + assert.strictEqual(page2._poolEntry.context.close.mock.callCount(), 1); }); }); @@ -333,19 +342,19 @@ describe('createTabPool', () => { assert.strictEqual(pool.stats().recycled, 2); }); - it('closes old tab during recycling', async () => { + it('closes old context during recycling', async () => { let browser = createMockBrowser(); let pool = createTabPool(browser, 1, { recycleAfter: 2 }); - let tab = await pool.acquire(); - await pool.release(tab); // use 1 + let page = await pool.acquire(); + await pool.release(page); // use 1 - tab = await pool.acquire(); - assert.strictEqual(tab.close.mock.callCount(), 0); + page = await pool.acquire(); + assert.strictEqual(page._poolEntry.context.close.mock.callCount(), 0); - await pool.release(tab); // use 2 - triggers recycle + await pool.release(page); // use 2 - triggers recycle - assert.strictEqual(tab.close.mock.callCount(), 1); + assert.strictEqual(page._poolEntry.context.close.mock.callCount(), 1); }); it('hands off fresh tab to waiting acquirer during recycling', async () => { @@ -368,27 +377,27 @@ describe('createTabPool', () => { assert.notStrictEqual(newTab.id, originalId); }); - it('reduces total count when new tab creation fails during recycling', async () => { + it('reduces total count when new context creation fails during recycling', async () => { let callCount = 0; let browser = { - newPage: mock.fn(async () => { + newContext: mock.fn(async () => { callCount++; if (callCount === 2) { - throw new Error('Failed to create tab'); + throw new Error('Failed to create context'); } - return createMockTab(callCount); + return createMockContext(callCount); }), }; let pool = createTabPool(browser, 1, { recycleAfter: 2 }); - let tab = await pool.acquire(); + let page = await pool.acquire(); assert.strictEqual(pool.stats().total, 1); - await pool.release(tab); // use 1 + await pool.release(page); // use 1 - tab = await pool.acquire(); - await pool.release(tab); // use 2 - triggers recycle, new tab fails + page = await pool.acquire(); + await pool.release(page); // use 2 - triggers recycle, new context fails // Total should be reduced since we couldn't create replacement assert.strictEqual(pool.stats().total, 0); @@ -398,35 +407,35 @@ describe('createTabPool', () => { let browser = createMockBrowser(); let pool = createTabPool(browser, 1, { recycleAfter: 2 }); - let tab = await pool.acquire(); - tab.close = mock.fn(async () => { + let page = await pool.acquire(); + page._poolEntry.context.close = mock.fn(async () => { throw new Error('Close failed'); }); - await pool.release(tab); // use 1 + await pool.release(page); // use 1 - tab = await pool.acquire(); - tab.close = mock.fn(async () => { + page = await pool.acquire(); + page._poolEntry.context.close = mock.fn(async () => { throw new Error('Close failed'); }); // Should not throw despite close error - await pool.release(tab); // use 2 - triggers recycle + await pool.release(page); // use 2 - triggers recycle assert.strictEqual(pool.stats().recycled, 1); }); }); describe('_poolEntry metadata', () => { - it('preserves _poolEntry reference on tab', async () => { + it('preserves _poolEntry reference on page', async () => { let browser = createMockBrowser(); let pool = createTabPool(browser, 2); - let tab = await pool.acquire(); + let page = await pool.acquire(); - assert.ok(tab._poolEntry); - assert.strictEqual(tab._poolEntry.tab, tab); - assert.strictEqual(tab._poolEntry.useCount, 1); + assert.ok(page._poolEntry); + assert.strictEqual(page._poolEntry.page, page); + assert.strictEqual(page._poolEntry.useCount, 1); }); it('increments useCount on each acquire', async () => { diff --git a/clients/storybook/tests/screenshot.test.js b/clients/storybook/tests/screenshot.test.js index 85c9232..beb9613 100644 --- a/clients/storybook/tests/screenshot.test.js +++ b/clients/storybook/tests/screenshot.test.js @@ -52,6 +52,7 @@ describe('captureScreenshot', () => { assert.deepEqual(mockScreenshot.mock.calls[0].arguments[0], { fullPage: false, omitBackground: false, + timeout: 45000, }); }); @@ -65,6 +66,7 @@ describe('captureScreenshot', () => { assert.deepEqual(mockScreenshot.mock.calls[0].arguments[0], { fullPage: true, omitBackground: false, + timeout: 45000, }); }); @@ -78,6 +80,7 @@ describe('captureScreenshot', () => { assert.deepEqual(mockScreenshot.mock.calls[0].arguments[0], { fullPage: false, omitBackground: true, + timeout: 45000, }); }); }); @@ -110,6 +113,7 @@ describe('captureAndSendScreenshot', () => { assert.deepEqual(mockScreenshot.mock.calls[0].arguments[0], { fullPage: true, omitBackground: false, + timeout: 45000, }); }); }); diff --git a/clients/storybook/tests/e2e.test.js b/clients/storybook/tests/sdk-integration.test.js similarity index 100% rename from clients/storybook/tests/e2e.test.js rename to clients/storybook/tests/sdk-integration.test.js diff --git a/clients/storybook/tests/tasks.test.js b/clients/storybook/tests/tasks.test.js index 89a0cc3..531dac5 100644 --- a/clients/storybook/tests/tasks.test.js +++ b/clients/storybook/tests/tasks.test.js @@ -21,33 +21,86 @@ describe('generateTasks', () => { }; let deps = { - getStoryConfig: (story, cfg) => ({ + getStoryConfig: (_story, cfg) => ({ viewports: cfg.viewports, screenshot: {}, }), - generateStoryUrl: (base, storyId) => `${base}/iframe.html?id=${storyId}`, getBeforeScreenshotHook: () => null, }; let tasks = generateTasks(stories, baseUrl, config, deps); assert.strictEqual(tasks.length, 4); // 2 stories × 2 viewports - assert.deepStrictEqual(tasks[0], { + + // Tasks are sorted by viewport, so find specific tasks by story+viewport + let primaryMobile = tasks.find( + t => t.story.id === 'button--primary' && t.viewport.name === 'mobile' + ); + let primaryDesktop = tasks.find( + t => t.story.id === 'button--primary' && t.viewport.name === 'desktop' + ); + + assert.deepStrictEqual(primaryMobile, { story: { id: 'button--primary', title: 'Button', name: 'Primary' }, viewport: { name: 'mobile', width: 375, height: 667 }, hook: null, - url: 'http://localhost:6006/iframe.html?id=button--primary', + storyId: 'button--primary', + baseUrl: 'http://localhost:6006', screenshotOptions: {}, }); - assert.deepStrictEqual(tasks[1], { + assert.deepStrictEqual(primaryDesktop, { story: { id: 'button--primary', title: 'Button', name: 'Primary' }, viewport: { name: 'desktop', width: 1920, height: 1080 }, hook: null, - url: 'http://localhost:6006/iframe.html?id=button--primary', + storyId: 'button--primary', + baseUrl: 'http://localhost:6006', screenshotOptions: {}, }); - assert.strictEqual(tasks[2].story.id, 'button--secondary'); - assert.strictEqual(tasks[3].story.id, 'button--secondary'); + // Check secondary stories exist + assert.ok(tasks.some(t => t.story.id === 'button--secondary')); + }); + + it('sorts tasks by viewport to minimize viewport changes', () => { + let stories = [ + { id: 'button--primary', title: 'Button', name: 'Primary' }, + { id: 'button--secondary', title: 'Button', name: 'Secondary' }, + ]; + let baseUrl = 'http://localhost:6006'; + let config = { + viewports: [ + { name: 'mobile', width: 375, height: 667 }, + { name: 'desktop', width: 1920, height: 1080 }, + ], + }; + + let deps = { + getStoryConfig: (_story, cfg) => ({ + viewports: cfg.viewports, + screenshot: {}, + }), + getBeforeScreenshotHook: () => null, + }; + + let tasks = generateTasks(stories, baseUrl, config, deps); + + // Tasks should be grouped by viewport + let viewportOrder = tasks.map(t => `${t.viewport.width}x${t.viewport.height}`); + // Same viewports should be adjacent + let desktopIndices = viewportOrder + .map((v, i) => (v === '1920x1080' ? i : -1)) + .filter(i => i >= 0); + let mobileIndices = viewportOrder + .map((v, i) => (v === '375x667' ? i : -1)) + .filter(i => i >= 0); + + // All desktop tasks should be contiguous (indices are consecutive) + assert.ok( + desktopIndices.every((idx, i) => i === 0 || idx === desktopIndices[i - 1] + 1) + ); + // All mobile tasks should be contiguous + assert.ok( + mobileIndices.every((idx, i) => i === 0 || idx === mobileIndices[i - 1] + 1) + ); }); it('handles single story with single viewport', () => { @@ -58,21 +111,18 @@ describe('generateTasks', () => { }; let deps = { - getStoryConfig: (story, cfg) => ({ + getStoryConfig: (_story, cfg) => ({ viewports: cfg.viewports, screenshot: {}, }), - generateStoryUrl: (base, storyId) => `${base}/iframe.html?id=${storyId}`, getBeforeScreenshotHook: () => null, }; let tasks = generateTasks(stories, baseUrl, config, deps); assert.strictEqual(tasks.length, 1); - assert.strictEqual( - tasks[0].url, - 'http://localhost:6006/iframe.html?id=card--default' - ); + assert.strictEqual(tasks[0].storyId, 'card--default'); + assert.strictEqual(tasks[0].baseUrl, 'http://localhost:6006'); }); it('handles empty stories array', () => { @@ -84,7 +134,6 @@ describe('generateTasks', () => { let deps = { getStoryConfig: () => ({ viewports: [], screenshot: {} }), - generateStoryUrl: () => '', getBeforeScreenshotHook: () => null, }; @@ -104,8 +153,8 @@ describe('processTask', () => { setViewport: async (tab, viewport) => { setViewportCalls.push({ tab, viewport }); }, - navigateToUrl: async (tab, url) => { - navigateCalls.push({ tab, url }); + navigateToStory: async (tab, storyId, baseUrl) => { + navigateCalls.push({ tab, storyId, baseUrl }); }, captureAndSendScreenshot: async (tab, story, viewport, opts) => { screenshotCalls.push({ tab, story, viewport, opts }); @@ -117,7 +166,8 @@ describe('processTask', () => { story: { id: 'button--primary', title: 'Button', name: 'Primary' }, viewport: { name: 'desktop', width: 1920, height: 1080 }, hook: null, - url: 'http://localhost:6006/iframe.html?id=button--primary', + storyId: 'button--primary', + baseUrl: 'http://localhost:6006', screenshotOptions: { fullPage: true }, }; @@ -128,7 +178,8 @@ describe('processTask', () => { assert.deepStrictEqual(setViewportCalls[0].viewport, task.viewport); assert.strictEqual(navigateCalls.length, 1); - assert.strictEqual(navigateCalls[0].url, task.url); + assert.strictEqual(navigateCalls[0].storyId, 'button--primary'); + assert.strictEqual(navigateCalls[0].baseUrl, 'http://localhost:6006'); assert.strictEqual(screenshotCalls.length, 1); assert.deepStrictEqual(screenshotCalls[0].opts, { fullPage: true }); @@ -139,7 +190,7 @@ describe('processTask', () => { let deps = { setViewport: async () => {}, - navigateToUrl: async () => {}, + navigateToStory: async () => {}, captureAndSendScreenshot: async () => {}, }; @@ -150,7 +201,8 @@ describe('processTask', () => { hook: async t => { hookCalls.push(t); }, - url: 'http://localhost:6006/iframe.html?id=button--primary', + storyId: 'button--primary', + baseUrl: 'http://localhost:6006', screenshotOptions: {}, }; @@ -177,11 +229,12 @@ describe('processAllTasks', () => { let logger = { info: mock.fn(), error: mock.fn(), + warn: mock.fn(), }; let deps = { setViewport: async () => {}, - navigateToUrl: async () => {}, + navigateToStory: async () => {}, captureAndSendScreenshot: async () => {}, }; @@ -190,14 +243,16 @@ describe('processAllTasks', () => { story: { id: 'button--primary', title: 'Button', name: 'Primary' }, viewport: { name: 'desktop', width: 1920, height: 1080 }, hook: null, - url: 'http://localhost:6006/iframe.html?id=button--primary', + storyId: 'button--primary', + baseUrl: 'http://localhost:6006', screenshotOptions: {}, }, { story: { id: 'button--secondary', title: 'Button', name: 'Secondary' }, viewport: { name: 'mobile', width: 375, height: 667 }, hook: null, - url: 'http://localhost:6006/iframe.html?id=button--secondary', + storyId: 'button--secondary', + baseUrl: 'http://localhost:6006', screenshotOptions: {}, }, ]; @@ -226,7 +281,7 @@ describe('processAllTasks', () => { let deps = { setViewport: async () => {}, - navigateToUrl: async () => {}, + navigateToStory: async () => {}, captureAndSendScreenshot: async () => { throw new Error('Screenshot failed'); }, @@ -237,7 +292,8 @@ describe('processAllTasks', () => { story: { id: 'button--broken', title: 'Button', name: 'Broken' }, viewport: { name: 'desktop', width: 1920, height: 1080 }, hook: null, - url: 'http://localhost:6006/iframe.html?id=button--broken', + storyId: 'button--broken', + baseUrl: 'http://localhost:6006', screenshotOptions: {}, }, ]; @@ -270,7 +326,7 @@ describe('processAllTasks', () => { let deps = { setViewport: async () => {}, - navigateToUrl: async () => {}, + navigateToStory: async () => {}, captureAndSendScreenshot: async () => {}, }; @@ -279,7 +335,8 @@ describe('processAllTasks', () => { story: { id: 'button--primary', title: 'Button', name: 'Primary' }, viewport: { name: 'desktop', width: 1920, height: 1080 }, hook: null, - url: 'http://localhost:6006/iframe.html?id=button--primary', + storyId: 'button--primary', + baseUrl: 'http://localhost:6006', screenshotOptions: {}, }, ]; @@ -309,11 +366,12 @@ describe('processAllTasks', () => { let logger = { info: mock.fn(), error: mock.fn(), + warn: mock.fn(), }; let deps = { setViewport: async () => {}, - navigateToUrl: async () => {}, + navigateToStory: async () => {}, captureAndSendScreenshot: async () => {}, }; @@ -321,7 +379,8 @@ describe('processAllTasks', () => { story: { id: `story--${i}`, title: 'Story', name: `${i}` }, viewport: { name: 'desktop', width: 1920, height: 1080 }, hook: null, - url: `http://localhost:6006/iframe.html?id=story--${i}`, + storyId: `story--${i}`, + baseUrl: 'http://localhost:6006', screenshotOptions: {}, })); @@ -354,11 +413,12 @@ describe('processAllTasks', () => { let logger = { info: mock.fn(), error: mock.fn(), + warn: mock.fn(), }; let deps = { setViewport: async () => {}, - navigateToUrl: async () => {}, + navigateToStory: async () => {}, captureAndSendScreenshot: async () => { screenshotAttempts++; if (screenshotAttempts === 1) { @@ -373,7 +433,8 @@ describe('processAllTasks', () => { story: { id: 'button--primary', title: 'Button', name: 'Primary' }, viewport: { name: 'desktop', width: 1920, height: 1080 }, hook: null, - url: 'http://localhost:6006/iframe.html?id=button--primary', + storyId: 'button--primary', + baseUrl: 'http://localhost:6006', screenshotOptions: {}, }, ]; @@ -404,7 +465,7 @@ describe('processAllTasks', () => { let deps = { setViewport: async () => {}, - navigateToUrl: async () => {}, + navigateToStory: async () => {}, captureAndSendScreenshot: async () => { screenshotAttempts++; throw new Error('Network error: DNS resolution failed'); @@ -416,7 +477,8 @@ describe('processAllTasks', () => { story: { id: 'button--primary', title: 'Button', name: 'Primary' }, viewport: { name: 'desktop', width: 1920, height: 1080 }, hook: null, - url: 'http://localhost:6006/iframe.html?id=button--primary', + storyId: 'button--primary', + baseUrl: 'http://localhost:6006', screenshotOptions: {}, }, ]; @@ -449,7 +511,7 @@ describe('processAllTasks', () => { let deps = { setViewport: async () => {}, - navigateToUrl: async () => {}, + navigateToStory: async () => {}, captureAndSendScreenshot: async () => { screenshotAttempts++; throw new Error('Navigation timeout exceeded'); @@ -461,7 +523,8 @@ describe('processAllTasks', () => { story: { id: 'button--primary', title: 'Button', name: 'Primary' }, viewport: { name: 'desktop', width: 1920, height: 1080 }, hook: null, - url: 'http://localhost:6006/iframe.html?id=button--primary', + storyId: 'button--primary', + baseUrl: 'http://localhost:6006', screenshotOptions: {}, }, ]; @@ -489,7 +552,7 @@ describe('processAllTasks', () => { let deps = { setViewport: async () => {}, - navigateToUrl: async () => {}, + navigateToStory: async () => {}, captureAndSendScreenshot: async () => { throw new Error('Target closed'); }, @@ -500,7 +563,8 @@ describe('processAllTasks', () => { story: { id: 'button--primary', title: 'Button', name: 'Primary' }, viewport: { name: 'desktop', width: 1920, height: 1080 }, hook: null, - url: 'http://localhost:6006/iframe.html?id=button--primary', + storyId: 'button--primary', + baseUrl: 'http://localhost:6006', screenshotOptions: {}, }, ]; @@ -524,11 +588,12 @@ describe('processAllTasks', () => { let logger = { info: mock.fn(), error: mock.fn(), + warn: mock.fn(), }; let deps = { setViewport: async () => {}, - navigateToUrl: async () => {}, + navigateToStory: async () => {}, captureAndSendScreenshot: async () => { screenshotAttempts++; if (screenshotAttempts === 1) { @@ -542,7 +607,8 @@ describe('processAllTasks', () => { story: { id: 'button--primary', title: 'Button', name: 'Primary' }, viewport: { name: 'desktop', width: 1920, height: 1080 }, hook: null, - url: 'http://localhost:6006/iframe.html?id=button--primary', + storyId: 'button--primary', + baseUrl: 'http://localhost:6006', screenshotOptions: {}, }, ]; @@ -571,12 +637,13 @@ describe('processAllTasks', () => { let logger = { info: mock.fn(), error: mock.fn(), + warn: mock.fn(), }; let firstAttempt = true; let deps = { setViewport: async () => {}, - navigateToUrl: async () => {}, + navigateToStory: async () => {}, captureAndSendScreenshot: async () => { if (firstAttempt) { firstAttempt = false; @@ -590,7 +657,8 @@ describe('processAllTasks', () => { story: { id: 'button--primary', title: 'Button', name: 'Primary' }, viewport: { name: 'desktop', width: 1920, height: 1080 }, hook: null, - url: 'http://localhost:6006/iframe.html?id=button--primary', + storyId: 'button--primary', + baseUrl: 'http://localhost:6006', screenshotOptions: {}, }, ]; @@ -627,7 +695,7 @@ describe('processAllTasks', () => { let deps = { setViewport: async () => {}, - navigateToUrl: async () => {}, + navigateToStory: async () => {}, captureAndSendScreenshot: async () => { throw new Error('Navigation timeout'); }, @@ -638,7 +706,8 @@ describe('processAllTasks', () => { story: { id: 'button--primary', title: 'Button', name: 'Primary' }, viewport: { name: 'desktop', width: 1920, height: 1080 }, hook: null, - url: 'http://localhost:6006/iframe.html?id=button--primary', + storyId: 'button--primary', + baseUrl: 'http://localhost:6006', screenshotOptions: {}, }, ]; @@ -669,12 +738,13 @@ describe('processAllTasks', () => { let logger = { info: mock.fn(), error: mock.fn(), + warn: mock.fn(), }; let firstAttempt = true; let deps = { setViewport: async () => {}, - navigateToUrl: async () => {}, + navigateToStory: async () => {}, captureAndSendScreenshot: async () => { if (firstAttempt) { firstAttempt = false; @@ -688,7 +758,8 @@ describe('processAllTasks', () => { story: { id: 'button--primary', title: 'Button', name: 'Primary' }, viewport: { name: 'desktop', width: 1920, height: 1080 }, hook: null, - url: 'http://localhost:6006/iframe.html?id=button--primary', + storyId: 'button--primary', + baseUrl: 'http://localhost:6006', screenshotOptions: {}, }, ]; diff --git a/clients/storybook/tests/viewport.test.js b/clients/storybook/tests/viewport.test.js index d60552d..5252ba5 100644 --- a/clients/storybook/tests/viewport.test.js +++ b/clients/storybook/tests/viewport.test.js @@ -85,15 +85,15 @@ describe('formatViewport', () => { }); describe('setViewport', () => { - it('should call page.setViewport with correct dimensions', async () => { - let mockSetViewport = mock.fn(); - let mockPage = { setViewport: mockSetViewport }; + it('should call page.setViewportSize with correct dimensions', async () => { + let mockSetViewportSize = mock.fn(); + let mockPage = { setViewportSize: mockSetViewportSize }; let viewport = { width: 375, height: 667 }; await setViewport(mockPage, viewport); - assert.equal(mockSetViewport.mock.calls.length, 1); - assert.deepEqual(mockSetViewport.mock.calls[0].arguments[0], { + assert.equal(mockSetViewportSize.mock.calls.length, 1); + assert.deepEqual(mockSetViewportSize.mock.calls[0].arguments[0], { width: 375, height: 667, }); diff --git a/docs/browser-flags.md b/docs/browser-flags.md new file mode 100644 index 0000000..fa9b918 --- /dev/null +++ b/docs/browser-flags.md @@ -0,0 +1,117 @@ +# Chrome Browser Flags + +This document covers the Chrome command-line flags used by the Storybook and Static-Site SDKs when launching browsers via Playwright. + +## Why Playwright? + +We migrated from Puppeteer to Playwright because: +- Puppeteer's new headless mode has known issues with parallel screenshot capture causing timeouts +- Playwright's BrowserContext provides better isolation for parallel workers +- Playwright is designed specifically for automation and handles edge cases better + +## Source of Truth + +The authoritative reference for Chrome flags is maintained by the Chrome team: + +- **Primary**: [Chrome Flags for Tools](https://github.com/GoogleChrome/chrome-launcher/blob/main/docs/chrome-flags-for-tools.md) - Curated list for automation tools +- **Complete list**: [peter.sh/experiments/chromium-command-line-switches](https://peter.sh/experiments/chromium-command-line-switches/) - All Chromium switches + +When auditing or updating flags, always check these sources. Flags get deprecated/removed over time. + +## Current Flags + +Located in: +- `clients/storybook/src/browser.js` +- `clients/static-site/src/browser.js` + +### Container/CI Requirements + +| Flag | Purpose | +|------|---------| +| `--no-sandbox` | Required for running in containers without root | +| `--disable-setuid-sandbox` | Disable setuid sandbox (Linux only) | +| `--disable-dev-shm-usage` | Use /tmp instead of /dev/shm (often too small in Docker) | + +### Disable Unnecessary Features + +| Flag | Purpose | +|------|---------| +| `--disable-extensions` | Disable all Chrome extensions | +| `--disable-default-apps` | Disable installation of default apps | +| `--disable-sync` | Disable syncing to Google account | +| `--disable-breakpad` | Disable crash reporting | +| `--disable-component-update` | Don't update components after startup | + +### Task Throttling + +These prevent Chrome from throttling background tabs/processes, ensuring consistent behavior: + +| Flag | Purpose | +|------|---------| +| `--disable-background-timer-throttling` | Don't throttle timers in background pages | +| `--disable-backgrounding-occluded-windows` | Don't background occluded windows | +| `--disable-renderer-backgrounding` | Prevent renderer process backgrounding | +| `--disable-hang-monitor` | Suppress hang monitor dialogs | +| `--disable-ipc-flooding-protection` | Disable IPC rate limiting | +| `--disable-background-networking` | Disable background network requests | + +### Interactivity Suppression + +| Flag | Purpose | +|------|---------| +| `--disable-popup-blocking` | Allow popups (for testing) | +| `--disable-prompt-on-repost` | Don't prompt on form resubmission | + +### Feature Flags (Modern Approach) + +Use `--disable-features=` for toggling Chrome features: + +| Feature | Purpose | +|---------|---------| +| `Translate` | Disable translation prompts | +| `OptimizationHints` | Disable Chrome Optimization Guide networking | +| `MediaRouter` | Disable Cast/media router networking | + +### Resource Reduction + +| Flag | Purpose | +|------|---------| +| `--metrics-recording-only` | Record but don't send metrics | +| `--no-first-run` | Skip first-run wizards and dialogs | + +### Screenshot Consistency + +| Flag | Purpose | +|------|---------| +| `--hide-scrollbars` | Hide scrollbars from screenshots | +| `--mute-audio` | Mute any audio | +| `--force-color-profile=srgb` | Consistent color rendering across machines | + +### Memory + +| Flag | Purpose | +|------|---------| +| `--js-flags=--max-old-space-size=N` | Limit V8 heap (512MB static-site, 1024MB storybook) | + +## Removed/Deprecated Flags + +These flags were removed from Chromium and should NOT be used: + +| Flag | Removed | Replacement | +|------|---------|-------------| +| `--disable-gpu` | 2021 | Not needed in headless mode | +| `--disable-software-rasterizer` | - | Causes hangs with --disable-gpu | +| `--disable-translate` | April 2017 | `--disable-features=Translate` | +| `--safebrowsing-disable-auto-update` | Nov 2017 | None needed | +| `--disable-infobars` | May 2019 | None | +| `--headless=new` | Jan 2025 | Just use `--headless` | + +## Auditing Flags + +Periodically audit flags against the source of truth: + +1. Check [chrome-flags-for-tools.md](https://github.com/GoogleChrome/chrome-launcher/blob/main/docs/chrome-flags-for-tools.md) for the "Removed flags" section +2. Verify each flag still exists on [peter.sh](https://peter.sh/experiments/chromium-command-line-switches/) +3. Check Playwright's default args for any new recommendations + +Last audited: January 2026 diff --git a/src/client/index.js b/src/client/index.js index 1ad4ba1..4ba708e 100644 --- a/src/client/index.js +++ b/src/client/index.js @@ -199,6 +199,7 @@ function createSimpleClient(serverUrl) { let image = isFilePath ? imageBuffer : imageBuffer.toString('base64'); let type = isFilePath ? 'file-path' : 'base64'; + let httpStart = Date.now(); const { status, json } = await httpPost( `${serverUrl}/screenshot`, { @@ -211,6 +212,13 @@ function createSimpleClient(serverUrl) { }, DEFAULT_TIMEOUT_MS ); + let httpMs = Date.now() - httpStart; + + if (shouldLogClient('debug')) { + console.debug( + `[vizzly-client] ${name} HTTP completed in ${httpMs}ms` + ); + } if (status < 200 || status >= 300) { // In TDD mode, if we get 422 (visual difference), don't throw diff --git a/src/plugin-loader.js b/src/plugin-loader.js index d3874e0..71e1c98 100644 --- a/src/plugin-loader.js +++ b/src/plugin-loader.js @@ -85,8 +85,11 @@ async function discoverInstalledPlugins() { const packageJson = JSON.parse(readFileSync(pkgPath, 'utf-8')); // Check if package has a plugin field - if (packageJson.vizzly?.plugin) { - const pluginRelativePath = packageJson.vizzly.plugin; + // Support both new `vizzlyPlugin` and legacy `vizzly.plugin` for backwards compatibility + const pluginField = + packageJson.vizzlyPlugin || packageJson.vizzly?.plugin; + if (pluginField) { + const pluginRelativePath = pluginField; // Security: Ensure plugin path is relative and doesn't traverse up if ( @@ -215,12 +218,17 @@ function resolvePluginPath(pluginSpec, configPath) { ); const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); - if (packageJson.vizzly?.plugin) { + // Support both new `vizzlyPlugin` and legacy `vizzly.plugin` + const pluginField = + packageJson.vizzlyPlugin || packageJson.vizzly?.plugin; + if (pluginField) { const packageDir = dirname(packageJsonPath); - return resolve(packageDir, packageJson.vizzly.plugin); + return resolve(packageDir, pluginField); } - throw new Error('Package does not specify a vizzly.plugin field'); + throw new Error( + 'Package does not specify a vizzlyPlugin or vizzly.plugin field' + ); } catch (error) { throw new Error( `Cannot resolve plugin package ${pluginSpec}: ${error.message}` diff --git a/src/server/handlers/api-handler.js b/src/server/handlers/api-handler.js index 73c724e..51c1693 100644 --- a/src/server/handlers/api-handler.js +++ b/src/server/handlers/api-handler.js @@ -56,6 +56,11 @@ export const createApiHandler = ( properties = {}, type ) => { + let handlerStart = Date.now(); + output.debug('upload', `${name} received`, { + buildId: buildId?.slice(0, 8), + }); + if (vizzlyDisabled) { output.debug('upload', `${name} (disabled)`); return { @@ -137,6 +142,7 @@ export const createApiHandler = ( }; } screenshotCount++; + let uploadStart = Date.now(); // Fire upload in background - DON'T AWAIT! let uploadPromise = uploadScreenshot( @@ -147,26 +153,34 @@ export const createApiHandler = ( properties ?? {} ) .then(result => { + let duration = Date.now() - uploadStart; if (!result.skipped) { - output.debug('upload', name); + output.debug('upload', `${name} completed`, { ms: duration }); + } else { + output.debug('upload', `${name} skipped (dedup)`, { ms: duration }); } - return { success: true, name, result }; + return { success: true, name, result, duration }; }) .catch(uploadError => { + let duration = Date.now() - uploadStart; output.debug('upload', `${name} failed`, { error: uploadError.message, + ms: duration, }); vizzlyDisabled = true; output.warn( 'Vizzly disabled due to upload error - continuing tests without visual testing' ); - return { success: false, name, error: uploadError }; + return { success: false, name, error: uploadError, duration }; }); // Collect promise for later flushing uploadPromises.push(uploadPromise); // Return immediately - test continues without waiting! + let handlerMs = Date.now() - handlerStart; + output.debug('upload', `${name} handler returning`, { ms: handlerMs }); + return { statusCode: 200, body: { diff --git a/src/server/handlers/tdd-handler.js b/src/server/handlers/tdd-handler.js index 514b0b8..ef8a90b 100644 --- a/src/server/handlers/tdd-handler.js +++ b/src/server/handlers/tdd-handler.js @@ -332,6 +332,9 @@ export const createTddHandler = ( properties = {}, type ) => { + let handlerStart = Date.now(); + output.debug('tdd', `${name} received`); + // Validate and sanitize screenshot name let sanitizedName; try { @@ -522,6 +525,12 @@ export const createTddHandler = ( } // Match or new baseline + let handlerMs = Date.now() - handlerStart; + output.debug('tdd', `${name} handler returning`, { + ms: handlerMs, + status: comparison.status, + }); + return { statusCode: 200, body: {