[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
12

Configure Feed

Select the types of activity you want to include in your feed.

feat(browser): introduce `toMatchScreenshot` for Visual Regression Testing (#8041)

authored by

Raul Macarie and committed by
GitHub
(Jul 22, 2025, 3:19 PM +0200) d45f964c e71a5d0e

+2955 -207
+120 -180
pnpm-lock.yaml
··· 453 453 magic-string: 454 454 specifier: 'catalog:' 455 455 version: 0.30.17 456 + pixelmatch: 457 + specifier: 7.1.0 458 + version: 7.1.0 459 + pngjs: 460 + specifier: ^7.0.0 461 + version: 7.0.0 456 462 sirv: 457 463 specifier: 'catalog:' 458 464 version: 3.0.1 ··· 463 469 specifier: 'catalog:' 464 470 version: 8.18.3 465 471 devDependencies: 472 + '@types/pngjs': 473 + specifier: ^6.0.5 474 + version: 6.0.5 466 475 '@types/ws': 467 476 specifier: 'catalog:' 468 477 version: 8.18.1 ··· 1502 1511 version: 9.0.8 1503 1512 inquirer: 1504 1513 specifier: ^12.8.0 1505 - version: 12.8.0(@types/node@22.16.5) 1514 + version: 12.8.2(@types/node@22.16.5) 1506 1515 vite-node: 1507 1516 specifier: workspace:* 1508 1517 version: link:../../packages/vite-node ··· 3038 3047 '@types/node': 3039 3048 optional: true 3040 3049 3041 - '@inquirer/prompts@7.7.0': 3042 - resolution: {integrity: sha512-8/7fOJA/Q5qqxznIvYjEvUCeRE6pncc1OHEmeA5JdNIKfOBC3a0EE5lFBofTWK/WXKfsjMkIz28y/uL6A3frYA==} 3050 + '@inquirer/prompts@7.7.1': 3051 + resolution: {integrity: sha512-XDxPrEWeWUBy8scAXzXuFY45r/q49R0g72bUzgQXZ1DY/xEFX+ESDMkTQolcb5jRBzaNJX2W8XQl6krMNDTjaA==} 3043 3052 engines: {node: '>=18'} 3044 3053 peerDependencies: 3045 3054 '@types/node': '>=18' ··· 3065 3074 '@types/node': 3066 3075 optional: true 3067 3076 3068 - '@inquirer/select@4.3.0': 3069 - resolution: {integrity: sha512-dY4sPBAfGH6Cv435gOSbNbMsLd6v1ItNmhjsLY+LTY4h8L+SAOERL2L/VtvjZFSVpVcY4dcu8fo6Uq8H8vNvzQ==} 3077 + '@inquirer/select@4.3.1': 3078 + resolution: {integrity: sha512-Gfl/5sqOF5vS/LIrSndFgOh7jgoe0UXEizDqahFRkq5aJBLegZ6WjuMh/hVEJwlFQjyLq1z9fRtvUMkb7jM1LA==} 3070 3079 engines: {node: '>=18'} 3071 3080 peerDependencies: 3072 3081 '@types/node': '>=18' ··· 3548 3557 '@oxc-transform/binding-win32-x64-msvc@0.75.1': 3549 3558 resolution: {integrity: sha512-llqatJ5Qucry0G1VgFpJNaVntEq9lV11gQeZX49Fhv/EeHJ/X/tCvRcxIPsW4TcrlfxAeHcQ7tSuDD6IbFtIEA==} 3550 3559 engines: {node: '>=14.0.0'} 3551 - cpu: [x64] 3552 - os: [win32] 3553 - 3554 - '@oxlint/darwin-arm64@1.6.0': 3555 - resolution: {integrity: sha512-m3wyqBh1TOHjpr/dXeIZY7OoX+MQazb+bMHQdDtwUvefrafUx+5YHRvulYh1sZSQ449nQ3nk3qj5qj535vZRjg==} 3556 - cpu: [arm64] 3557 - os: [darwin] 3558 - 3559 - '@oxlint/darwin-x64@1.6.0': 3560 - resolution: {integrity: sha512-75fJfF/9xNypr7cnOYoZBhfmG1yP7ex3pUOeYGakmtZRffO9z1i1quLYhjZsmaDXsAIZ3drMhenYHMmFKS3SRg==} 3561 - cpu: [x64] 3562 - os: [darwin] 3563 - 3564 - '@oxlint/linux-arm64-gnu@1.6.0': 3565 - resolution: {integrity: sha512-YhXGf0FXa72bEt4F7eTVKx5X3zWpbAOPnaA/dZ6/g8tGhw1m9IFjrabVHFjzcx3dQny4MgA59EhyElkDvpUe8A==} 3566 - cpu: [arm64] 3567 - os: [linux] 3568 - 3569 - '@oxlint/linux-arm64-musl@1.6.0': 3570 - resolution: {integrity: sha512-T3JDhx8mjGjvh5INsPZJrlKHmZsecgDYvtvussKRdkc1Nnn7WC+jH9sh5qlmYvwzvmetlPVNezAoNvmGO9vtMg==} 3571 - cpu: [arm64] 3572 - os: [linux] 3573 - 3574 - '@oxlint/linux-x64-gnu@1.6.0': 3575 - resolution: {integrity: sha512-Dx7ghtAl8aXBdqofJpi338At6lkeCtTfoinTYQXd9/TEJx+f+zCGNlQO6nJz3ydJBX48FDuOFKkNC+lUlWrd8w==} 3576 - cpu: [x64] 3577 - os: [linux] 3578 - 3579 - '@oxlint/linux-x64-musl@1.6.0': 3580 - resolution: {integrity: sha512-7KvMGdWmAZtAtg6IjoEJHKxTXdAcrHnUnqfgs0JpXst7trquV2mxBeRZusQXwxpu4HCSomKMvJfsp1qKaqSFDg==} 3581 - cpu: [x64] 3582 - os: [linux] 3583 - 3584 - '@oxlint/win32-arm64@1.6.0': 3585 - resolution: {integrity: sha512-iSGC9RwX+dl7o5KFr5aH7Gq3nFbkq/3Gda6mxNPMvNkWrgXdIyiINxpyD8hJu566M+QSv1wEAu934BZotFDyoQ==} 3586 - cpu: [arm64] 3587 - os: [win32] 3588 - 3589 - '@oxlint/win32-x64@1.6.0': 3590 - resolution: {integrity: sha512-jOj3L/gfLc0IwgOTkZMiZ5c673i/hbAmidlaylT0gE6H18hln9HxPgp5GCf4E4y6mwEJlW8QC5hQi221+9otdA==} 3591 3560 cpu: [x64] 3592 3561 os: [win32] 3593 3562 ··· 4110 4079 '@types/picomatch@4.0.1': 4111 4080 resolution: {integrity: sha512-dLqxmi5VJRC9XTvc/oaTtk+bDb4RRqxLZPZ3jIpYBHEnDXX8lu02w2yWI6NsPPsELuVK298Z2iR8jgoWKRdUVQ==} 4112 4081 4082 + '@types/pngjs@6.0.5': 4083 + resolution: {integrity: sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==} 4084 + 4113 4085 '@types/prompts@2.4.9': 4114 4086 resolution: {integrity: sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA==} 4115 4087 ··· 4167 4139 '@types/yauzl@2.10.3': 4168 4140 resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} 4169 4141 4170 - '@typescript-eslint/eslint-plugin@8.37.0': 4171 - resolution: {integrity: sha512-jsuVWeIkb6ggzB+wPCsR4e6loj+rM72ohW6IBn2C+5NCvfUVY8s33iFPySSVXqtm5Hu29Ne/9bnA0JmyLmgenA==} 4142 + '@typescript-eslint/eslint-plugin@8.38.0': 4143 + resolution: {integrity: sha512-CPoznzpuAnIOl4nhj4tRr4gIPj5AfKgkiJmGQDaq+fQnRJTYlcBjbX3wbciGmpoPf8DREufuPRe1tNMZnGdanA==} 4172 4144 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 4173 4145 peerDependencies: 4174 - '@typescript-eslint/parser': ^8.37.0 4146 + '@typescript-eslint/parser': ^8.38.0 4175 4147 eslint: ^8.57.0 || ^9.0.0 4176 4148 typescript: '>=4.8.4 <5.9.0' 4177 4149 4178 - '@typescript-eslint/parser@8.37.0': 4179 - resolution: {integrity: sha512-kVIaQE9vrN9RLCQMQ3iyRlVJpTiDUY6woHGb30JDkfJErqrQEmtdWH3gV0PBAfGZgQXoqzXOO0T3K6ioApbbAA==} 4150 + '@typescript-eslint/parser@8.38.0': 4151 + resolution: {integrity: sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==} 4180 4152 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 4181 4153 peerDependencies: 4182 4154 eslint: ^8.57.0 || ^9.0.0 ··· 4188 4160 peerDependencies: 4189 4161 typescript: '>=4.8.4 <5.9.0' 4190 4162 4191 - '@typescript-eslint/project-service@8.37.0': 4192 - resolution: {integrity: sha512-BIUXYsbkl5A1aJDdYJCBAo8rCEbAvdquQ8AnLb6z5Lp1u3x5PNgSSx9A/zqYc++Xnr/0DVpls8iQ2cJs/izTXA==} 4163 + '@typescript-eslint/project-service@8.38.0': 4164 + resolution: {integrity: sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg==} 4193 4165 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 4194 4166 peerDependencies: 4195 4167 typescript: '>=4.8.4 <5.9.0' ··· 4198 4170 resolution: {integrity: sha512-wCnapIKnDkN62fYtTGv2+RY8FlnBYA3tNm0fm91kc2BjPhV2vIjwwozJ7LToaLAyb1ca8BxrS7vT+Pvvf7RvqA==} 4199 4171 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 4200 4172 4201 - '@typescript-eslint/scope-manager@8.37.0': 4202 - resolution: {integrity: sha512-0vGq0yiU1gbjKob2q691ybTg9JX6ShiVXAAfm2jGf3q0hdP6/BruaFjL/ManAR/lj05AvYCH+5bbVo0VtzmjOA==} 4173 + '@typescript-eslint/scope-manager@8.38.0': 4174 + resolution: {integrity: sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ==} 4203 4175 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 4204 4176 4205 4177 '@typescript-eslint/tsconfig-utils@8.36.0': ··· 4208 4180 peerDependencies: 4209 4181 typescript: '>=4.8.4 <5.9.0' 4210 4182 4211 - '@typescript-eslint/tsconfig-utils@8.37.0': 4212 - resolution: {integrity: sha512-1/YHvAVTimMM9mmlPvTec9NP4bobA1RkDbMydxG8omqwJJLEW/Iy2C4adsAESIXU3WGLXFHSZUU+C9EoFWl4Zg==} 4183 + '@typescript-eslint/tsconfig-utils@8.38.0': 4184 + resolution: {integrity: sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ==} 4213 4185 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 4214 4186 peerDependencies: 4215 4187 typescript: '>=4.8.4 <5.9.0' 4216 4188 4217 - '@typescript-eslint/type-utils@8.37.0': 4218 - resolution: {integrity: sha512-SPkXWIkVZxhgwSwVq9rqj/4VFo7MnWwVaRNznfQDc/xPYHjXnPfLWn+4L6FF1cAz6e7dsqBeMawgl7QjUMj4Ow==} 4189 + '@typescript-eslint/type-utils@8.38.0': 4190 + resolution: {integrity: sha512-c7jAvGEZVf0ao2z+nnz8BUaHZD09Agbh+DY7qvBQqLiz8uJzRgVPj5YvOh8I8uEiH8oIUGIfHzMwUcGVco/SJg==} 4219 4191 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 4220 4192 peerDependencies: 4221 4193 eslint: ^8.57.0 || ^9.0.0 ··· 4225 4197 resolution: {integrity: sha512-xGms6l5cTJKQPZOKM75Dl9yBfNdGeLRsIyufewnxT4vZTrjC0ImQT4fj8QmtJK84F58uSh5HVBSANwcfiXxABQ==} 4226 4198 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 4227 4199 4228 - '@typescript-eslint/types@8.37.0': 4229 - resolution: {integrity: sha512-ax0nv7PUF9NOVPs+lmQ7yIE7IQmAf8LGcXbMvHX5Gm+YJUYNAl340XkGnrimxZ0elXyoQJuN5sbg6C4evKA4SQ==} 4200 + '@typescript-eslint/types@8.38.0': 4201 + resolution: {integrity: sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==} 4230 4202 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 4231 4203 4232 4204 '@typescript-eslint/typescript-estree@8.36.0': ··· 4235 4207 peerDependencies: 4236 4208 typescript: '>=4.8.4 <5.9.0' 4237 4209 4238 - '@typescript-eslint/typescript-estree@8.37.0': 4239 - resolution: {integrity: sha512-zuWDMDuzMRbQOM+bHyU4/slw27bAUEcKSKKs3hcv2aNnc/tvE/h7w60dwVw8vnal2Pub6RT1T7BI8tFZ1fE+yg==} 4210 + '@typescript-eslint/typescript-estree@8.38.0': 4211 + resolution: {integrity: sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==} 4240 4212 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 4241 4213 peerDependencies: 4242 4214 typescript: '>=4.8.4 <5.9.0' ··· 4248 4220 eslint: ^8.57.0 || ^9.0.0 4249 4221 typescript: '>=4.8.4 <5.9.0' 4250 4222 4251 - '@typescript-eslint/utils@8.37.0': 4252 - resolution: {integrity: sha512-TSFvkIW6gGjN2p6zbXo20FzCABbyUAuq6tBvNRGsKdsSQ6a7rnV6ADfZ7f4iI3lIiXc4F4WWvtUfDw9CJ9pO5A==} 4223 + '@typescript-eslint/utils@8.38.0': 4224 + resolution: {integrity: sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg==} 4253 4225 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 4254 4226 peerDependencies: 4255 4227 eslint: ^8.57.0 || ^9.0.0 ··· 4259 4231 resolution: {integrity: sha512-vZrhV2lRPWDuGoxcmrzRZyxAggPL+qp3WzUrlZD+slFueDiYHxeBa34dUXPuC0RmGKzl4lS5kFJYvKCq9cnNDA==} 4260 4232 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 4261 4233 4262 - '@typescript-eslint/visitor-keys@8.37.0': 4263 - resolution: {integrity: sha512-YzfhzcTnZVPiLfP/oeKtDp2evwvHLMe0LOy7oe+hb9KKIumLNohYS9Hgp1ifwpu42YWxhZE8yieggz6JpqO/1w==} 4234 + '@typescript-eslint/visitor-keys@8.38.0': 4235 + resolution: {integrity: sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g==} 4264 4236 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 4265 4237 4266 4238 '@typescript/vfs@1.6.1': ··· 5689 5661 peerDependencies: 5690 5662 eslint: '>=8.45.0' 5691 5663 5692 - eslint-plugin-pnpm@1.0.0: 5693 - resolution: {integrity: sha512-tyEA10k7psB9HFCx8R4/bU4JS2tSKfXaCnrCcis+1R4FucfMIc6HgcFl4msZbwY2I0D9Vec3xAEkXV0aPechhQ==} 5664 + eslint-plugin-pnpm@1.1.0: 5665 + resolution: {integrity: sha512-sL93w0muBtjnogzk/loDsxzMbmXQOLP5Blw3swLDBXZgfb+qQI73bPcUbjVR+ZL+K62vGJdErV+43i3r5DsZPg==} 5694 5666 peerDependencies: 5695 5667 eslint: ^9.0.0 5696 5668 ··· 6349 6321 ini@1.3.8: 6350 6322 resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} 6351 6323 6352 - inquirer@12.8.0: 6353 - resolution: {integrity: sha512-gPBPq0MpYIBxYFNbJqvoYGw/n8sgmsgmN36MSQn0yocCnCg09cFlFLLd9CZ13zdAm6S8en4nttMbuD1UjWjW4Q==} 6324 + inquirer@12.8.2: 6325 + resolution: {integrity: sha512-oBDL9f4+cDambZVJdfJu2M5JQfvaug9lbo6fKDlFV40i8t3FGA1Db67ov5Hp5DInG4zmXhHWTSnlXBntnJ7GMA==} 6354 6326 engines: {node: '>=18'} 6355 6327 peerDependencies: 6356 6328 '@types/node': '>=18' ··· 7349 7321 resolution: {integrity: sha512-kuyQEMzhz6cpBwFifTxgLTw8kgn68h5jP46ChIXVbxN+J75q7cTb95YNYh0FIcdWFmAJOZGhgYtQLPGKqxJN1Q==} 7350 7322 engines: {node: '>=14.0.0'} 7351 7323 7352 - oxlint@1.6.0: 7353 - resolution: {integrity: sha512-jtaD65PqzIa1udvSxxscTKBxYKuZoFXyKGLiU1Qjo1ulq3uv/fQDtoV1yey1FrQZrQjACGPi1Widsy1TucC7Jg==} 7354 - engines: {node: '>=8.*'} 7355 - hasBin: true 7356 - 7357 7324 p-limit@3.1.0: 7358 7325 resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} 7359 7326 engines: {node: '>=10'} ··· 7518 7485 resolution: {integrity: sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==} 7519 7486 hasBin: true 7520 7487 7488 + pixelmatch@7.1.0: 7489 + resolution: {integrity: sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==} 7490 + hasBin: true 7491 + 7521 7492 pkg-types@1.3.1: 7522 7493 resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} 7523 7494 ··· 7546 7517 resolution: {integrity: sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==} 7547 7518 engines: {node: '>=12.13.0'} 7548 7519 7549 - pnpm-workspace-yaml@1.0.0: 7550 - resolution: {integrity: sha512-2RKg3khFgX/oeKIQnxxlj+OUoKbaZjBt7EsmQiLfl8AHZKMIpLmXLRPptZ5eq2Rlumh2gILs6OWNky5dzP+f8A==} 7520 + pngjs@7.0.0: 7521 + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} 7522 + engines: {node: '>=14.19.0'} 7523 + 7524 + pnpm-workspace-yaml@1.1.0: 7525 + resolution: {integrity: sha512-OWUzBxtitpyUV0fBYYwLAfWxn3mSzVbVB7cwgNaHvTTU9P0V2QHjyaY5i7f1hEiT9VeKsNH1Skfhe2E3lx/zhA==} 7551 7526 7552 7527 possible-typed-array-names@1.0.0: 7553 7528 resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==} ··· 7571 7546 prelude-ls@1.2.1: 7572 7547 resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} 7573 7548 engines: {node: '>= 0.8.0'} 7574 - 7575 - prettier@3.6.2: 7576 - resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} 7577 - engines: {node: '>=14'} 7578 - hasBin: true 7579 7549 7580 7550 pretty-bytes@5.6.0: 7581 7551 resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==} ··· 7870 7840 resolution: {integrity: sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==} 7871 7841 engines: {node: '>=18'} 7872 7842 7873 - run-async@4.0.4: 7874 - resolution: {integrity: sha512-2cgeRHnV11lSXBEhq7sN7a5UVjTKm9JTb9x8ApIT//16D7QL96AgnNeWSGoB4gIHc0iYw/Ha0Z+waBaCYZVNhg==} 7843 + run-async@4.0.5: 7844 + resolution: {integrity: sha512-oN9GTgxUNDBumHTTDmQ8dep6VIJbgj9S3dPP+9XylVLIK4xB9XTXtKWROd5pnhdXR9k0EgO1JRcNh0T+Ny2FsA==} 7875 7845 engines: {node: '>=0.12.0'} 7876 7846 7877 7847 run-parallel@1.2.0: ··· 9386 9356 '@eslint-community/eslint-plugin-eslint-comments': 4.5.0(eslint@9.31.0(jiti@2.4.2)) 9387 9357 '@eslint/markdown': 7.0.0 9388 9358 '@stylistic/eslint-plugin': 5.1.0(eslint@9.31.0(jiti@2.4.2)) 9389 - '@typescript-eslint/eslint-plugin': 8.37.0(@typescript-eslint/parser@8.37.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3) 9390 - '@typescript-eslint/parser': 8.37.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3) 9359 + '@typescript-eslint/eslint-plugin': 8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3) 9360 + '@typescript-eslint/parser': 8.38.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3) 9391 9361 '@vitest/eslint-plugin': 1.3.4(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3)(vitest@packages+vitest) 9392 9362 ansis: 4.1.0 9393 9363 cac: 6.7.14(patch_hash=a8f0f3517a47ce716ed90c0cfe6ae382ab763b021a664ada2a608477d0621588) ··· 9403 9373 eslint-plugin-n: 17.21.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3) 9404 9374 eslint-plugin-no-only-tests: 3.3.0 9405 9375 eslint-plugin-perfectionist: 4.15.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3) 9406 - eslint-plugin-pnpm: 1.0.0(eslint@9.31.0(jiti@2.4.2)) 9376 + eslint-plugin-pnpm: 1.1.0(eslint@9.31.0(jiti@2.4.2)) 9407 9377 eslint-plugin-regexp: 2.9.0(eslint@9.31.0(jiti@2.4.2)) 9408 9378 eslint-plugin-toml: 0.12.0(eslint@9.31.0(jiti@2.4.2)) 9409 9379 eslint-plugin-unicorn: 59.0.1(eslint@9.31.0(jiti@2.4.2)) 9410 - eslint-plugin-unused-imports: 4.1.4(@typescript-eslint/eslint-plugin@8.37.0(@typescript-eslint/parser@8.37.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.31.0(jiti@2.4.2)) 9411 - eslint-plugin-vue: 10.3.0(@typescript-eslint/parser@8.37.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.31.0(jiti@2.4.2))(vue-eslint-parser@10.2.0(eslint@9.31.0(jiti@2.4.2))) 9380 + eslint-plugin-unused-imports: 4.1.4(@typescript-eslint/eslint-plugin@8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.31.0(jiti@2.4.2)) 9381 + eslint-plugin-vue: 10.3.0(@typescript-eslint/parser@8.38.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.31.0(jiti@2.4.2))(vue-eslint-parser@10.2.0(eslint@9.31.0(jiti@2.4.2))) 9412 9382 eslint-plugin-yml: 1.18.0(eslint@9.31.0(jiti@2.4.2)) 9413 9383 eslint-processor-vue-blocks: 2.0.0(@vue/compiler-sfc@3.5.17)(eslint@9.31.0(jiti@2.4.2)) 9414 9384 globals: 16.3.0 ··· 9545 9515 '@babel/parser': 7.27.5 9546 9516 '@babel/types': 7.27.6 9547 9517 '@jridgewell/gen-mapping': 0.3.5 9548 - '@jridgewell/trace-mapping': 0.3.29 9518 + '@jridgewell/trace-mapping': 0.3.25 9549 9519 jsesc: 3.1.0 9550 9520 9551 9521 '@babel/generator@7.28.0': ··· 10223 10193 '@babel/template@7.27.2': 10224 10194 dependencies: 10225 10195 '@babel/code-frame': 7.27.1 10226 - '@babel/parser': 7.28.0 10227 - '@babel/types': 7.28.1 10196 + '@babel/parser': 7.27.5 10197 + '@babel/types': 7.27.6 10228 10198 10229 10199 '@babel/traverse@7.27.0': 10230 10200 dependencies: ··· 10871 10841 optionalDependencies: 10872 10842 '@types/node': 22.16.5 10873 10843 10874 - '@inquirer/prompts@7.7.0(@types/node@22.16.5)': 10844 + '@inquirer/prompts@7.7.1(@types/node@22.16.5)': 10875 10845 dependencies: 10876 10846 '@inquirer/checkbox': 4.2.0(@types/node@22.16.5) 10877 10847 '@inquirer/confirm': 5.1.14(@types/node@22.16.5) ··· 10882 10852 '@inquirer/password': 4.0.17(@types/node@22.16.5) 10883 10853 '@inquirer/rawlist': 4.1.5(@types/node@22.16.5) 10884 10854 '@inquirer/search': 3.0.17(@types/node@22.16.5) 10885 - '@inquirer/select': 4.3.0(@types/node@22.16.5) 10855 + '@inquirer/select': 4.3.1(@types/node@22.16.5) 10886 10856 optionalDependencies: 10887 10857 '@types/node': 22.16.5 10888 10858 ··· 10903 10873 optionalDependencies: 10904 10874 '@types/node': 22.16.5 10905 10875 10906 - '@inquirer/select@4.3.0(@types/node@22.16.5)': 10876 + '@inquirer/select@4.3.1(@types/node@22.16.5)': 10907 10877 dependencies: 10908 10878 '@inquirer/core': 10.1.15(@types/node@22.16.5) 10909 10879 '@inquirer/figures': 1.0.13 ··· 11229 11199 optional: true 11230 11200 11231 11201 '@oxc-transform/binding-win32-x64-msvc@0.75.1': 11232 - optional: true 11233 - 11234 - '@oxlint/darwin-arm64@1.6.0': 11235 - optional: true 11236 - 11237 - '@oxlint/darwin-x64@1.6.0': 11238 - optional: true 11239 - 11240 - '@oxlint/linux-arm64-gnu@1.6.0': 11241 - optional: true 11242 - 11243 - '@oxlint/linux-arm64-musl@1.6.0': 11244 - optional: true 11245 - 11246 - '@oxlint/linux-x64-gnu@1.6.0': 11247 - optional: true 11248 - 11249 - '@oxlint/linux-x64-musl@1.6.0': 11250 - optional: true 11251 - 11252 - '@oxlint/win32-arm64@1.6.0': 11253 - optional: true 11254 - 11255 - '@oxlint/win32-x64@1.6.0': 11256 11202 optional: true 11257 11203 11258 11204 '@pkgjs/parseargs@0.11.0': ··· 11806 11752 11807 11753 '@types/picomatch@4.0.1': {} 11808 11754 11755 + '@types/pngjs@6.0.5': 11756 + dependencies: 11757 + '@types/node': 22.16.5 11758 + 11809 11759 '@types/prompts@2.4.9': 11810 11760 dependencies: 11811 11761 '@types/node': 22.16.5 ··· 11862 11812 '@types/node': 20.19.9 11863 11813 optional: true 11864 11814 11865 - '@typescript-eslint/eslint-plugin@8.37.0(@typescript-eslint/parser@8.37.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3)': 11815 + '@typescript-eslint/eslint-plugin@8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3)': 11866 11816 dependencies: 11867 11817 '@eslint-community/regexpp': 4.12.1 11868 - '@typescript-eslint/parser': 8.37.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3) 11869 - '@typescript-eslint/scope-manager': 8.37.0 11870 - '@typescript-eslint/type-utils': 8.37.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3) 11871 - '@typescript-eslint/utils': 8.37.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3) 11872 - '@typescript-eslint/visitor-keys': 8.37.0 11818 + '@typescript-eslint/parser': 8.38.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3) 11819 + '@typescript-eslint/scope-manager': 8.38.0 11820 + '@typescript-eslint/type-utils': 8.38.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3) 11821 + '@typescript-eslint/utils': 8.38.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3) 11822 + '@typescript-eslint/visitor-keys': 8.38.0 11873 11823 eslint: 9.31.0(jiti@2.4.2) 11874 11824 graphemer: 1.4.0 11875 11825 ignore: 7.0.4 ··· 11879 11829 transitivePeerDependencies: 11880 11830 - supports-color 11881 11831 11882 - '@typescript-eslint/parser@8.37.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3)': 11832 + '@typescript-eslint/parser@8.38.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3)': 11883 11833 dependencies: 11884 - '@typescript-eslint/scope-manager': 8.37.0 11885 - '@typescript-eslint/types': 8.37.0 11886 - '@typescript-eslint/typescript-estree': 8.37.0(typescript@5.8.3) 11887 - '@typescript-eslint/visitor-keys': 8.37.0 11834 + '@typescript-eslint/scope-manager': 8.38.0 11835 + '@typescript-eslint/types': 8.38.0 11836 + '@typescript-eslint/typescript-estree': 8.38.0(typescript@5.8.3) 11837 + '@typescript-eslint/visitor-keys': 8.38.0 11888 11838 debug: 4.4.1 11889 11839 eslint: 9.31.0(jiti@2.4.2) 11890 11840 typescript: 5.8.3 ··· 11900 11850 transitivePeerDependencies: 11901 11851 - supports-color 11902 11852 11903 - '@typescript-eslint/project-service@8.37.0(typescript@5.8.3)': 11853 + '@typescript-eslint/project-service@8.38.0(typescript@5.8.3)': 11904 11854 dependencies: 11905 - '@typescript-eslint/tsconfig-utils': 8.37.0(typescript@5.8.3) 11906 - '@typescript-eslint/types': 8.37.0 11855 + '@typescript-eslint/tsconfig-utils': 8.38.0(typescript@5.8.3) 11856 + '@typescript-eslint/types': 8.38.0 11907 11857 debug: 4.4.1 11908 11858 typescript: 5.8.3 11909 11859 transitivePeerDependencies: ··· 11914 11864 '@typescript-eslint/types': 8.36.0 11915 11865 '@typescript-eslint/visitor-keys': 8.36.0 11916 11866 11917 - '@typescript-eslint/scope-manager@8.37.0': 11867 + '@typescript-eslint/scope-manager@8.38.0': 11918 11868 dependencies: 11919 - '@typescript-eslint/types': 8.37.0 11920 - '@typescript-eslint/visitor-keys': 8.37.0 11869 + '@typescript-eslint/types': 8.38.0 11870 + '@typescript-eslint/visitor-keys': 8.38.0 11921 11871 11922 11872 '@typescript-eslint/tsconfig-utils@8.36.0(typescript@5.8.3)': 11923 11873 dependencies: 11924 11874 typescript: 5.8.3 11925 11875 11926 - '@typescript-eslint/tsconfig-utils@8.37.0(typescript@5.8.3)': 11876 + '@typescript-eslint/tsconfig-utils@8.38.0(typescript@5.8.3)': 11927 11877 dependencies: 11928 11878 typescript: 5.8.3 11929 11879 11930 - '@typescript-eslint/type-utils@8.37.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3)': 11880 + '@typescript-eslint/type-utils@8.38.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3)': 11931 11881 dependencies: 11932 - '@typescript-eslint/types': 8.37.0 11933 - '@typescript-eslint/typescript-estree': 8.37.0(typescript@5.8.3) 11934 - '@typescript-eslint/utils': 8.37.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3) 11882 + '@typescript-eslint/types': 8.38.0 11883 + '@typescript-eslint/typescript-estree': 8.38.0(typescript@5.8.3) 11884 + '@typescript-eslint/utils': 8.38.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3) 11935 11885 debug: 4.4.1 11936 11886 eslint: 9.31.0(jiti@2.4.2) 11937 11887 ts-api-utils: 2.1.0(typescript@5.8.3) ··· 11941 11891 11942 11892 '@typescript-eslint/types@8.36.0': {} 11943 11893 11944 - '@typescript-eslint/types@8.37.0': {} 11894 + '@typescript-eslint/types@8.38.0': {} 11945 11895 11946 11896 '@typescript-eslint/typescript-estree@8.36.0(typescript@5.8.3)': 11947 11897 dependencies: ··· 11959 11909 transitivePeerDependencies: 11960 11910 - supports-color 11961 11911 11962 - '@typescript-eslint/typescript-estree@8.37.0(typescript@5.8.3)': 11912 + '@typescript-eslint/typescript-estree@8.38.0(typescript@5.8.3)': 11963 11913 dependencies: 11964 - '@typescript-eslint/project-service': 8.37.0(typescript@5.8.3) 11965 - '@typescript-eslint/tsconfig-utils': 8.37.0(typescript@5.8.3) 11966 - '@typescript-eslint/types': 8.37.0 11967 - '@typescript-eslint/visitor-keys': 8.37.0 11914 + '@typescript-eslint/project-service': 8.38.0(typescript@5.8.3) 11915 + '@typescript-eslint/tsconfig-utils': 8.38.0(typescript@5.8.3) 11916 + '@typescript-eslint/types': 8.38.0 11917 + '@typescript-eslint/visitor-keys': 8.38.0 11968 11918 debug: 4.4.1 11969 11919 fast-glob: 3.3.3 11970 11920 is-glob: 4.0.3 ··· 11986 11936 transitivePeerDependencies: 11987 11937 - supports-color 11988 11938 11989 - '@typescript-eslint/utils@8.37.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3)': 11939 + '@typescript-eslint/utils@8.38.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3)': 11990 11940 dependencies: 11991 11941 '@eslint-community/eslint-utils': 4.7.0(eslint@9.31.0(jiti@2.4.2)) 11992 - '@typescript-eslint/scope-manager': 8.37.0 11993 - '@typescript-eslint/types': 8.37.0 11994 - '@typescript-eslint/typescript-estree': 8.37.0(typescript@5.8.3) 11942 + '@typescript-eslint/scope-manager': 8.38.0 11943 + '@typescript-eslint/types': 8.38.0 11944 + '@typescript-eslint/typescript-estree': 8.38.0(typescript@5.8.3) 11995 11945 eslint: 9.31.0(jiti@2.4.2) 11996 11946 typescript: 5.8.3 11997 11947 transitivePeerDependencies: ··· 12002 11952 '@typescript-eslint/types': 8.36.0 12003 11953 eslint-visitor-keys: 4.2.1 12004 11954 12005 - '@typescript-eslint/visitor-keys@8.37.0': 11955 + '@typescript-eslint/visitor-keys@8.38.0': 12006 11956 dependencies: 12007 - '@typescript-eslint/types': 8.37.0 11957 + '@typescript-eslint/types': 8.38.0 12008 11958 eslint-visitor-keys: 4.2.1 12009 11959 12010 11960 '@typescript/vfs@1.6.1(typescript@5.8.3)': ··· 13724 13674 - supports-color 13725 13675 - typescript 13726 13676 13727 - eslint-plugin-pnpm@1.0.0(eslint@9.31.0(jiti@2.4.2)): 13677 + eslint-plugin-pnpm@1.1.0(eslint@9.31.0(jiti@2.4.2)): 13728 13678 dependencies: 13729 13679 eslint: 9.31.0(jiti@2.4.2) 13730 13680 find-up-simple: 1.0.1 13731 13681 jsonc-eslint-parser: 2.4.0 13732 13682 pathe: 2.0.3 13733 - pnpm-workspace-yaml: 1.0.0 13683 + pnpm-workspace-yaml: 1.1.0 13734 13684 tinyglobby: 0.2.14 13735 13685 yaml-eslint-parser: 1.3.0 13736 13686 ··· 13776 13726 semver: 7.7.2 13777 13727 strip-indent: 4.0.0 13778 13728 13779 - eslint-plugin-unused-imports@4.1.4(@typescript-eslint/eslint-plugin@8.37.0(@typescript-eslint/parser@8.37.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.31.0(jiti@2.4.2)): 13729 + eslint-plugin-unused-imports@4.1.4(@typescript-eslint/eslint-plugin@8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.31.0(jiti@2.4.2)): 13780 13730 dependencies: 13781 13731 eslint: 9.31.0(jiti@2.4.2) 13782 13732 optionalDependencies: 13783 - '@typescript-eslint/eslint-plugin': 8.37.0(@typescript-eslint/parser@8.37.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3) 13733 + '@typescript-eslint/eslint-plugin': 8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3) 13784 13734 13785 - eslint-plugin-vue@10.3.0(@typescript-eslint/parser@8.37.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.31.0(jiti@2.4.2))(vue-eslint-parser@10.2.0(eslint@9.31.0(jiti@2.4.2))): 13735 + eslint-plugin-vue@10.3.0(@typescript-eslint/parser@8.38.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.31.0(jiti@2.4.2))(vue-eslint-parser@10.2.0(eslint@9.31.0(jiti@2.4.2))): 13786 13736 dependencies: 13787 13737 '@eslint-community/eslint-utils': 4.7.0(eslint@9.31.0(jiti@2.4.2)) 13788 13738 eslint: 9.31.0(jiti@2.4.2) ··· 13793 13743 vue-eslint-parser: 10.2.0(eslint@9.31.0(jiti@2.4.2)) 13794 13744 xml-name-validator: 4.0.0 13795 13745 optionalDependencies: 13796 - '@typescript-eslint/parser': 8.37.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3) 13746 + '@typescript-eslint/parser': 8.38.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3) 13797 13747 13798 13748 eslint-plugin-yml@1.18.0(eslint@9.31.0(jiti@2.4.2)): 13799 13749 dependencies: ··· 14547 14497 14548 14498 ini@1.3.8: {} 14549 14499 14550 - inquirer@12.8.0(@types/node@22.16.5): 14500 + inquirer@12.8.2(@types/node@22.16.5): 14551 14501 dependencies: 14552 14502 '@inquirer/core': 10.1.15(@types/node@22.16.5) 14553 - '@inquirer/prompts': 7.7.0(@types/node@22.16.5) 14503 + '@inquirer/prompts': 7.7.1(@types/node@22.16.5) 14554 14504 '@inquirer/type': 3.0.8(@types/node@22.16.5) 14555 14505 ansi-escapes: 4.3.2 14556 14506 mute-stream: 2.0.0 14557 - run-async: 4.0.4 14507 + run-async: 4.0.5 14558 14508 rxjs: 7.8.2 14559 14509 optionalDependencies: 14560 14510 '@types/node': 22.16.5 ··· 15747 15697 '@oxc-transform/binding-win32-arm64-msvc': 0.75.1 15748 15698 '@oxc-transform/binding-win32-x64-msvc': 0.75.1 15749 15699 15750 - oxlint@1.6.0: 15751 - optionalDependencies: 15752 - '@oxlint/darwin-arm64': 1.6.0 15753 - '@oxlint/darwin-x64': 1.6.0 15754 - '@oxlint/linux-arm64-gnu': 1.6.0 15755 - '@oxlint/linux-arm64-musl': 1.6.0 15756 - '@oxlint/linux-x64-gnu': 1.6.0 15757 - '@oxlint/linux-x64-musl': 1.6.0 15758 - '@oxlint/win32-arm64': 1.6.0 15759 - '@oxlint/win32-x64': 1.6.0 15760 - 15761 15700 p-limit@3.1.0: 15762 15701 dependencies: 15763 15702 yocto-queue: 0.1.0 ··· 15909 15848 dependencies: 15910 15849 pngjs: 6.0.0 15911 15850 15851 + pixelmatch@7.1.0: 15852 + dependencies: 15853 + pngjs: 7.0.0 15854 + 15912 15855 pkg-types@1.3.1: 15913 15856 dependencies: 15914 15857 confbox: 0.1.8 ··· 15935 15878 15936 15879 pngjs@6.0.0: {} 15937 15880 15938 - pnpm-workspace-yaml@1.0.0: 15881 + pngjs@7.0.0: {} 15882 + 15883 + pnpm-workspace-yaml@1.1.0: 15939 15884 dependencies: 15940 15885 yaml: 2.8.0 15941 15886 ··· 15961 15906 preact@10.21.0: {} 15962 15907 15963 15908 prelude-ls@1.2.1: {} 15964 - 15965 - prettier@3.6.2: {} 15966 15909 15967 15910 pretty-bytes@5.6.0: {} 15968 15911 ··· 16280 16223 16281 16224 run-applescript@7.0.0: {} 16282 16225 16283 - run-async@4.0.4: 16284 - dependencies: 16285 - oxlint: 1.6.0 16286 - prettier: 3.6.2 16226 + run-async@4.0.5: {} 16287 16227 16288 16228 run-parallel@1.2.0: 16289 16229 dependencies:
+5
docs/.vitepress/config.ts
··· 291 291 link: '/guide/browser/multiple-setups', 292 292 docFooterText: 'Multiple Setups | Browser Mode', 293 293 }, 294 + { 295 + text: 'Visual Regression Testing', 296 + link: '/guide/browser/visual-regression-testing', 297 + docFooterText: 'Visual Regression Testing | Browser Mode', 298 + }, 294 299 ], 295 300 }, 296 301 {
docs/public/vrt-gha-summary-no-update-dark.png

This is a binary file and will not be displayed.

docs/public/vrt-gha-summary-no-update-light.png

This is a binary file and will not be displayed.

docs/public/vrt-gha-summary-update-dark.png

This is a binary file and will not be displayed.

docs/public/vrt-gha-summary-update-light.png

This is a binary file and will not be displayed.

+123
packages/browser/context.d.ts
··· 44 44 save?: boolean 45 45 } 46 46 47 + export interface ScreenshotComparatorRegistry { 48 + pixelmatch: { 49 + /** 50 + * The maximum number of pixels that are allowed to differ between the captured 51 + * screenshot and the stored reference image. 52 + * 53 + * If set to `undefined`, any non-zero difference will cause the test to fail. 54 + * 55 + * For example, `allowedMismatchedPixels: 10` means the test will pass if 10 56 + * or fewer pixels differ, but fail if 11 or more differ. 57 + * 58 + * If both this and `allowedMismatchedPixelRatio` are set, the more restrictive 59 + * value (i.e., fewer allowed mismatches) will be used. 60 + * 61 + * @default undefined 62 + */ 63 + allowedMismatchedPixels?: number | undefined 64 + /** 65 + * The maximum allowed ratio of differing pixels between the captured screenshot 66 + * and the reference image. 67 + * 68 + * Must be a value between `0` and `1`. 69 + * 70 + * For example, `allowedMismatchedPixelRatio: 0.02` means the test will pass 71 + * if up to 2% of pixels differ, but fail if more than 2% differ. 72 + * 73 + * If both this and `allowedMismatchedPixels` are set, the more restrictive 74 + * value (i.e., fewer allowed mismatches) will be used. 75 + * 76 + * @default undefined 77 + */ 78 + allowedMismatchedPixelRatio?: number | undefined 79 + /** 80 + * Acceptable perceived color difference between the same pixel in two images. 81 + * 82 + * Value ranges from `0` (strict) to `1` (very lenient). Lower values mean 83 + * small differences will be detected. 84 + * 85 + * The comparison uses the {@link https://en.wikipedia.org/wiki/YIQ | YIQ color space}. 86 + * 87 + * @default 0.1 88 + */ 89 + threshold?: number | undefined 90 + /** 91 + * If `true`, disables detection and ignoring of anti-aliased pixels. 92 + * 93 + * @default false 94 + */ 95 + includeAA?: boolean | undefined 96 + /** 97 + * Blending level of unchanged pixels in the diff image. 98 + * 99 + * Ranges from `0` (white) to `1` (original brightness). 100 + * 101 + * @default 0.1 102 + */ 103 + alpha?: number | undefined 104 + /** 105 + * Color used for anti-aliased pixels in the diff image. 106 + * 107 + * Format: `[R, G, B]` 108 + * 109 + * @default [255, 255, 0] 110 + */ 111 + aaColor?: [r: number, g: number, b: number] | undefined 112 + /** 113 + * Color used for differing pixels in the diff image. 114 + * 115 + * Format: `[R, G, B]` 116 + * 117 + * @default [255, 0, 0] 118 + */ 119 + diffColor?: [r: number, g: number, b: number] | undefined 120 + /** 121 + * Optional alternative color for dark-on-light differences, to help show 122 + * what's added vs. removed. 123 + * 124 + * If not set, `diffColor` is used for all differences. 125 + * 126 + * Format: `[R, G, B]` 127 + * 128 + * @default undefined 129 + */ 130 + diffColorAlt?: [r: number, g: number, b: number] | undefined 131 + /** 132 + * If `true`, shows only the diff as a mask on a transparent background, 133 + * instead of overlaying it on the original image. 134 + * 135 + * Anti-aliased pixels won't be shown (if detected). 136 + * 137 + * @default false 138 + */ 139 + diffMask?: boolean | undefined 140 + } 141 + } 142 + 143 + export interface ScreenshotMatcherOptions< 144 + ComparatorName extends keyof ScreenshotComparatorRegistry = keyof ScreenshotComparatorRegistry 145 + > { 146 + /** 147 + * The name of the comparator to use for visual diffing. 148 + * 149 + * Must be one of the keys from {@linkcode ScreenshotComparatorRegistry}. 150 + * 151 + * @defaultValue `'pixelmatch'` 152 + */ 153 + comparatorName?: ComparatorName 154 + comparatorOptions?: ScreenshotComparatorRegistry[ComparatorName] 155 + screenshotOptions?: Omit< 156 + ScreenshotOptions, 157 + 'element' | 'base64' | 'path' | 'save' | 'type' 158 + > 159 + /** 160 + * Time to wait until a stable screenshot is found. 161 + * 162 + * Setting this value to `0` disables the timeout, but if a stable screenshot 163 + * can't be determined the process will not end. 164 + * 165 + * @default 5000 166 + */ 167 + timeout?: number 168 + } 169 + 47 170 export interface BrowserCommands { 48 171 readFile: ( 49 172 path: string,
+48
packages/browser/jest-dom.d.ts
··· 1 1 // Disable automatic exports. 2 2 3 3 import { ARIARole } from './aria-role.ts' 4 + import { ScreenshotComparatorRegistry, ScreenshotMatcherOptions } from './context.js' 4 5 5 6 export interface TestingLibraryMatchers<E, R> { 6 7 /** ··· 673 674 * @see https://vitest.dev/guide/browser/assertion-api#tohaveselection 674 675 */ 675 676 toHaveSelection(selection?: string): R 677 + 678 + /** 679 + * @description 680 + * This assertion allows you to perform visual regression testing by comparing 681 + * screenshots of elements or pages against stored reference images. 682 + * 683 + * When differences are detected beyond the configured threshold, the test fails. 684 + * To help identify the changes, the assertion generates: 685 + * 686 + * - The actual screenshot captured during the test 687 + * - The expected reference screenshot 688 + * - A diff image highlighting the differences (when possible) 689 + * 690 + * @example 691 + * <button data-testid="button">Fancy Button</button> 692 + * 693 + * // basic usage, auto-generates screenshot name 694 + * await expect.element(getByTestId('button')).toMatchScreenshot() 695 + * 696 + * // with custom name 697 + * await expect.element(getByTestId('button')).toMatchScreenshot('fancy-button') 698 + * 699 + * // with options 700 + * await expect.element(getByTestId('button')).toMatchScreenshot({ 701 + * comparatorName: 'pixelmatch', 702 + * comparatorOptions: { 703 + * allowedMismatchedPixelRatio: 0.01, 704 + * }, 705 + * }) 706 + * 707 + * // with both name and options 708 + * await expect.element(getByTestId('button')).toMatchScreenshot('fancy-button', { 709 + * comparatorName: 'pixelmatch', 710 + * comparatorOptions: { 711 + * allowedMismatchedPixelRatio: 0.01, 712 + * }, 713 + * }) 714 + * 715 + * @see https://vitest.dev/guide/browser/assertion-api#tomatchscreenshot 716 + */ 717 + toMatchScreenshot<ComparatorName extends keyof ScreenshotComparatorRegistry>( 718 + options?: ScreenshotMatcherOptions<ComparatorName>, 719 + ): Promise<R> 720 + toMatchScreenshot<ComparatorName extends keyof ScreenshotComparatorRegistry>( 721 + name?: string, 722 + options?: ScreenshotMatcherOptions<ComparatorName>, 723 + ): Promise<R> 676 724 }
+3
packages/browser/package.json
··· 95 95 "@vitest/mocker": "workspace:*", 96 96 "@vitest/utils": "workspace:*", 97 97 "magic-string": "catalog:", 98 + "pixelmatch": "7.1.0", 99 + "pngjs": "^7.0.0", 98 100 "sirv": "catalog:", 99 101 "tinyrainbow": "catalog:", 100 102 "ws": "catalog:" 101 103 }, 102 104 "devDependencies": { 105 + "@types/pngjs": "^6.0.5", 103 106 "@types/ws": "catalog:", 104 107 "@vitest/runner": "workspace:*", 105 108 "@vitest/ui": "workspace:*",
+15 -3
test/test-utils/index.ts
··· 8 8 import { Readable, Writable } from 'node:stream' 9 9 import { fileURLToPath } from 'node:url' 10 10 import { inspect } from 'node:util' 11 - import { dirname, resolve } from 'pathe' 11 + import { dirname, relative, resolve } from 'pathe' 12 12 import { x } from 'tinyexec' 13 13 import * as tinyrainbow from 'tinyrainbow' 14 14 import { afterEach, onTestFinished } from 'vitest' ··· 208 208 209 209 if (args[0] !== 'list' && (args.includes('--watch') || args[0] === 'watch')) { 210 210 if (command === 'vitest') { 211 - // Wait for initial test run to complete 212 - await cli.waitForStdout('Waiting for file changes') 211 + // Waiting for either success or failure 212 + await Promise.race([ 213 + cli.waitForStdout('Waiting for file changes'), 214 + cli.waitForStdout('Tests failed. Watching for file changes'), 215 + ]) 213 216 } 214 217 // make sure watcher is ready 215 218 await cli.waitForStdout('[debug] watcher is ready') ··· 336 339 throw new Error(`file ${file} already exists in the test file system`) 337 340 } 338 341 createFile(filepath, content) 342 + }, 343 + statFile: (file: string): fs.Stats => { 344 + const filepath = resolve(root, file) 345 + 346 + if (relative(root, filepath).startsWith('..')) { 347 + throw new Error(`file ${file} is outside of the test file system`) 348 + } 349 + 350 + return fs.statSync(filepath) 339 351 }, 340 352 } 341 353 }
+213
docs/guide/browser/assertion-api.md
··· 1067 1067 await expect.element(queryByTestId('prev')).not.toHaveSelection() 1068 1068 await expect.element(queryByTestId('next')).toHaveSelection('ne') 1069 1069 ``` 1070 + 1071 + ## toMatchScreenshot <Badge type="warning">experimental</Badge> 1072 + 1073 + ```ts 1074 + function toMatchScreenshot( 1075 + options?: ScreenshotMatcherOptions, 1076 + ): Promise<void> 1077 + function toMatchScreenshot( 1078 + name?: string, 1079 + options?: ScreenshotMatcherOptions, 1080 + ): Promise<void> 1081 + ``` 1082 + 1083 + ::: tip 1084 + The `toMatchScreenshot` assertion can be configured globally in your 1085 + [Vitest config](/guide/browser/config#browser-expect-tomatchscreenshot). 1086 + ::: 1087 + 1088 + This assertion allows you to perform visual regression testing by comparing 1089 + screenshots of elements or pages against stored reference images. 1090 + 1091 + When differences are detected beyond the configured threshold, the test fails. 1092 + To help identify the changes, the assertion generates: 1093 + 1094 + - The actual screenshot captured during the test 1095 + - The expected reference screenshot 1096 + - A diff image highlighting the differences (when possible) 1097 + 1098 + ::: warning Screenshots Stability 1099 + The assertion automatically retries taking screenshots until two consecutive 1100 + captures yield the same result. This helps reduce flakiness caused by 1101 + animations, loading states, or other dynamic content. You can control this 1102 + behavior with the `timeout` option. 1103 + 1104 + However, browser rendering can vary across: 1105 + 1106 + - Different browsers and browser versions 1107 + - Operating systems (Windows, macOS, Linux) 1108 + - Screen resolutions and pixel densities 1109 + - GPU drivers and hardware acceleration 1110 + - Font rendering and system fonts 1111 + 1112 + It is recommended to read the 1113 + [Visual Regression Testing guide](/guide/browser/visual-regression-testing) to 1114 + implement this testing strategy efficiently. 1115 + ::: 1116 + 1117 + ::: tip 1118 + When a screenshot comparison fails due to **intentional changes**, you can 1119 + update the reference screenshot by pressing the `u` key in watch mode, or by 1120 + running tests with the `-u` or `--update` flags. 1121 + ::: 1122 + 1123 + ```html 1124 + <button data-testid="button">Fancy Button</button> 1125 + ``` 1126 + 1127 + ```ts 1128 + // basic usage, auto-generates screenshot name 1129 + await expect.element(getByTestId('button')).toMatchScreenshot() 1130 + 1131 + // with custom name 1132 + await expect.element(getByTestId('button')).toMatchScreenshot('fancy-button') 1133 + 1134 + // with options 1135 + await expect.element(getByTestId('button')).toMatchScreenshot({ 1136 + comparatorName: 'pixelmatch', 1137 + comparatorOptions: { 1138 + allowedMismatchedPixelRatio: 0.01, 1139 + }, 1140 + }) 1141 + 1142 + // with both name and options 1143 + await expect.element(getByTestId('button')).toMatchScreenshot('fancy-button', { 1144 + comparatorName: 'pixelmatch', 1145 + comparatorOptions: { 1146 + allowedMismatchedPixelRatio: 0.01, 1147 + }, 1148 + }) 1149 + ``` 1150 + 1151 + ### Options 1152 + 1153 + - `comparatorName: "pixelmatch" = "pixelmatch"` 1154 + 1155 + The name of the algorithm/library used for comparing images. 1156 + 1157 + Currently, [`"pixelmatch"`](https://github.com/mapbox/pixelmatch) is the only 1158 + supported comparator. 1159 + 1160 + - `comparatorOptions: object` 1161 + 1162 + These options allow changing the behavior of the comparator. What properties 1163 + can be set depends on the chosen comparator algorithm. 1164 + 1165 + Vitest has set default values out of the box, but they can be overridden. 1166 + 1167 + - [`"pixelmatch"` options](#pixelmatch-comparator-options) 1168 + 1169 + ::: warning 1170 + **Always explicitly set `comparatorName` to get proper type inference for 1171 + `comparatorOptions`**. 1172 + 1173 + Without it, TypeScript won't know which options are valid: 1174 + 1175 + ```ts 1176 + // ❌ TypeScript can't infer the correct options 1177 + await expect.element(button).toMatchScreenshot({ 1178 + comparatorOptions: { 1179 + // might error when new comparators are added 1180 + allowedMismatchedPixelRatio: 0.01, 1181 + }, 1182 + }) 1183 + 1184 + // ✅ TypeScript knows these are pixelmatch options 1185 + await expect.element(button).toMatchScreenshot({ 1186 + comparatorName: 'pixelmatch', 1187 + comparatorOptions: { 1188 + allowedMismatchedPixelRatio: 0.01, 1189 + }, 1190 + }) 1191 + ``` 1192 + ::: 1193 + 1194 + - `screenshotOptions: object` 1195 + 1196 + The same options allowed by 1197 + [`locator.screenshot()`](/guide/browser/locators.html#screenshot), except for: 1198 + 1199 + - `'base64'` 1200 + - `'path'` 1201 + - `'save'` 1202 + - `'type'` 1203 + 1204 + - `timeout: number = 5_000` 1205 + 1206 + Time to wait until a stable screenshot is found. 1207 + 1208 + Setting this value to `0` disables the timeout, but if a stable screenshot 1209 + can't be determined the process will not end. 1210 + 1211 + #### `"pixelmatch"` comparator options 1212 + 1213 + The following options are available when using the `"pixelmatch"` comparator: 1214 + 1215 + - `allowedMismatchedPixelRatio: number | undefined = undefined` 1216 + 1217 + The maximum allowed ratio of differing pixels between the captured screenshot 1218 + and the reference image. 1219 + 1220 + Must be a value between `0` and `1`. 1221 + 1222 + For example, `allowedMismatchedPixelRatio: 0.02` means the test will pass 1223 + if up to 2% of pixels differ, but fail if more than 2% differ. 1224 + 1225 + - `allowedMismatchedPixels: number | undefined = undefined` 1226 + 1227 + The maximum number of pixels that are allowed to differ between the captured 1228 + screenshot and the stored reference image. 1229 + 1230 + If set to `undefined`, any non-zero difference will cause the test to fail. 1231 + 1232 + For example, `allowedMismatchedPixels: 10` means the test will pass if 10 or 1233 + fewer pixels differ, but fail if 11 or more differ. 1234 + 1235 + - `threshold: number = 0.1` 1236 + 1237 + Acceptable perceived color difference between the same pixel in two images. 1238 + 1239 + Value ranges from `0` (strict) to `1` (very lenient). Lower values mean small 1240 + differences will be detected. 1241 + 1242 + The comparison uses the [YIQ color space](https://en.wikipedia.org/wiki/YIQ). 1243 + 1244 + - `includeAA: boolean = false` 1245 + 1246 + If `true`, disables detection and ignoring of anti-aliased pixels. 1247 + 1248 + - `alpha: number = 0.1` 1249 + 1250 + Blending level of unchanged pixels in the diff image. 1251 + 1252 + Ranges from `0` (white) to `1` (original brightness). 1253 + 1254 + - `aaColor: [r: number, g: number, b: number] = [255, 255, 0]` 1255 + 1256 + Color used for anti-aliased pixels in the diff image. 1257 + 1258 + - `diffColor: [r: number, g: number, b: number] = [255, 0, 0]` 1259 + 1260 + Color used for differing pixels in the diff image. 1261 + 1262 + - `diffColorAlt: [r: number, g: number, b: number] | undefined = undefined` 1263 + 1264 + Optional alternative color for dark-on-light differences, to help show what's 1265 + added vs. removed. 1266 + 1267 + If not set, `diffColor` is used for all differences. 1268 + 1269 + - `diffMask: boolean = false` 1270 + 1271 + If `true`, shows only the diff as a mask on a transparent background, instead 1272 + of overlaying it on the original image. 1273 + 1274 + Anti-aliased pixels won't be shown (if detected). 1275 + 1276 + ::: warning 1277 + When both `allowedMismatchedPixels` and `allowedMismatchedPixelRatio` are set, 1278 + the more restrictive value is used. 1279 + 1280 + For example, if you allow 100 pixels or 2% ratio, and your image has 10,000 1281 + pixels, the effective limit would be 100 pixels instead of 200. 1282 + :::
+149
docs/guide/browser/config.md
··· 325 325 ::: info 326 326 This is the time it should take for the browser to establish the WebSocket connection with the Vitest server. In normal circumstances, this timeout should never be reached. 327 327 ::: 328 + 329 + ## browser.expect 330 + 331 + - **Type:** `ExpectOptions` 332 + 333 + ### browser.expect.toMatchScreenshot 334 + 335 + Default options for the 336 + [`toMatchScreenshot` assertion](/guide/browser/assertion-api.html#tomatchscreenshot). 337 + These options will be applied to all screenshot assertions. 338 + 339 + ::: tip 340 + Setting global defaults for screenshot assertions helps maintain consistency 341 + across your test suite and reduces repetition in individual tests. You can still 342 + override these defaults at the assertion level when needed for specific test cases. 343 + ::: 344 + 345 + ```ts 346 + import { defineConfig } from 'vitest/config' 347 + 348 + export default defineConfig({ 349 + test: { 350 + browser: { 351 + enabled: true, 352 + expect: { 353 + toMatchScreenshot: { 354 + comparatorName: 'pixelmatch', 355 + comparatorOptions: { 356 + threshold: 0.2, 357 + allowedMismatchedPixels: 100, 358 + }, 359 + resolveScreenshotPath: ({ arg, browserName, ext, testFileName }) => 360 + `custom-screenshots/${testFileName}/${arg}-${browserName}${ext}`, 361 + }, 362 + }, 363 + }, 364 + }, 365 + }) 366 + ``` 367 + 368 + [All options available in the `toMatchScreenshot` assertion](/guide/browser/assertion-api#options) 369 + can be configured here. Additionally, two path resolution functions are 370 + available: `resolveScreenshotPath` and `resolveDiffPath`. 371 + 372 + #### browser.expect.toMatchScreenshot.resolveScreenshotPath 373 + 374 + - **Type:** `(data: PathResolveData) => string` 375 + - **Default output:** `` `${root}/${testFileDirectory}/${screenshotDirectory}/${testFileName}/${arg}-${browserName}-${platform}${ext}` `` 376 + 377 + A function to customize where reference screenshots are stored. The function 378 + receives an object with the following properties: 379 + 380 + - `arg: string` 381 + 382 + Path **without** extension, sanitized and relative to the test file. 383 + 384 + This comes from the arguments passed to `toMatchScreenshot`; if called 385 + without arguments this will be the auto-generated name. 386 + 387 + ```ts 388 + test('calls `onClick`', () => { 389 + expect(locator).toMatchScreenshot() 390 + // arg = "calls-onclick-1" 391 + }) 392 + 393 + expect(locator).toMatchScreenshot('foo/bar/baz.png') 394 + // arg = "foo/bar/baz" 395 + 396 + expect(locator).toMatchScreenshot('../foo/bar/baz.png') 397 + // arg = "foo/bar/baz" 398 + ``` 399 + 400 + - `ext: string` 401 + 402 + Screenshot extension, with leading dot. 403 + 404 + This can be set through the arguments passed to `toMatchScreenshot`, but 405 + the value will fall back to `'.png'` if an unsupported extension is used. 406 + 407 + - `browserName: string` 408 + 409 + The instance's browser name. 410 + 411 + - `platform: NodeJS.Platform` 412 + 413 + The value of 414 + [`process.platform`](https://nodejs.org/docs/v22.16.0/api/process.html#processplatform). 415 + 416 + - `screenshotDirectory: string` 417 + 418 + The value provided to 419 + [`browser.screenshotDirectory`](/guide/browser/config#browser-screenshotdirectory), 420 + if none is provided, its default value. 421 + 422 + - `root: string` 423 + 424 + Absolute path to the project's [`root`](/config/#root). 425 + 426 + - `testFileDirectory: string` 427 + 428 + Path to the test file, relative to the project's [`root`](/config/#root). 429 + 430 + - `testFileName: string` 431 + 432 + The test's filename. 433 + 434 + - `testName: string` 435 + 436 + The [`test`](/api/#test)'s name, including parent 437 + [`describe`](/api/#describe), sanitized. 438 + 439 + - `attachmentsDir: string` 440 + 441 + The value provided to [`attachmentsDir`](/config/#attachmentsdir), if none is 442 + provided, its default value. 443 + 444 + For example, to group screenshots by browser: 445 + 446 + ```ts 447 + resolveScreenshotPath: ({ arg, browserName, ext, root, testFileName }) => 448 + `${root}/screenshots/${browserName}/${testFileName}/${arg}${ext}` 449 + ``` 450 + 451 + #### browser.expect.toMatchScreenshot.resolveDiffPath 452 + 453 + - **Type:** `(data: PathResolveData) => string` 454 + - **Default output:** `` `${root}/${attachmentsDir}/${testFileDirectory}/${testFileName}/${arg}-${browserName}-${platform}${ext}` `` 455 + 456 + A function to customize where diff images are stored when screenshot comparisons 457 + fail. Receives the same data object as 458 + [`resolveScreenshotPath`](#browser-expect-tomatchscreenshot-resolvescreenshotpath). 459 + 460 + For example, to store diffs in a subdirectory of attachments: 461 + 462 + ```ts 463 + resolveDiffPath: ({ arg, attachmentsDir, browserName, ext, root, testFileName }) => 464 + `${root}/${attachmentsDir}/screenshot-diffs/${testFileName}/${arg}-${browserName}${ext}` 465 + ``` 466 + 467 + ::: tip 468 + To have a better type safety when using built-in providers, you should reference 469 + one of these types (for provider that you are using) in your 470 + [config file](/config/): 471 + 472 + ```ts 473 + /// <reference types="@vitest/browser/providers/playwright" /> 474 + /// <reference types="@vitest/browser/providers/webdriverio" /> 475 + ``` 476 + :::
+715
docs/guide/browser/visual-regression-testing.md
··· 1 + --- 2 + title: Visual Regression Testing 3 + outline: [2, 3] 4 + --- 5 + 6 + # Visual Regression Testing 7 + 8 + Vitest can run visual regression tests out of the box. It captures screenshots 9 + of your UI components and pages, then compares them against reference images to 10 + detect unintended visual changes. 11 + 12 + Unlike functional tests that verify behavior, visual tests catch styling issues, 13 + layout shifts, and rendering problems that might otherwise go unnoticed without 14 + thorough manual testing. 15 + 16 + ## Why Visual Regression Testing? 17 + 18 + Visual bugs don’t throw errors, they just look wrong. That’s where visual 19 + testing comes in. 20 + 21 + - That button still submits the form... but why is it hot pink now? 22 + - The text fits perfectly... until someone views it on mobile 23 + - Everything works great... except those two containers are out of viewport 24 + - That careful CSS refactor works... but broke the layout on a page no one tests 25 + 26 + Visual regression testing acts as a safety net for your UI, automatically 27 + catching these visual changes before they reach production. 28 + 29 + ## Getting Started 30 + 31 + ::: warning Browser Rendering Differences 32 + Visual regression tests are **inherently unstable across different 33 + environments**. Screenshots will look different on different machines because 34 + of: 35 + 36 + - Font rendering (the big one. Windows, macOS, Linux, they all render text 37 + differently) 38 + - GPU drivers and hardware acceleration 39 + - Whether you're running headless or not 40 + - Browser settings and versions 41 + - ...and honestly, sometimes just the phase of the moon 42 + 43 + That's why Vitest includes the browser and platform in screenshot names (like 44 + `button-chromium-darwin.png`). 45 + 46 + For stable tests, use the same environment everywhere. We **strongly recommend** 47 + cloud services like 48 + [Microsoft Playwright Testing](https://azure.microsoft.com/en-us/products/playwright-testing) 49 + or [Docker containers](https://playwright.dev/docs/docker). 50 + ::: 51 + 52 + Visual regression testing in Vitest can be done through the 53 + [`toMatchScreenshot` assertion](/guide/browser/assertion-api.html#tomatchscreenshot): 54 + 55 + ```ts 56 + import { expect, test } from 'vitest' 57 + import { page } from '@vitest/browser/context' 58 + 59 + test('hero section looks correct', async () => { 60 + // ...the rest of the test 61 + 62 + // capture and compare screenshot 63 + await expect(page.getByTestId('hero')).toMatchScreenshot('hero-section') 64 + }) 65 + ``` 66 + 67 + ### Creating References 68 + 69 + When you run a visual test for the first time, Vitest creates a reference (also 70 + called baseline) screenshot and fails the test with the following error message: 71 + 72 + ``` 73 + expect(element).toMatchScreenshot() 74 + 75 + No existing reference screenshot found; a new one was created. Review it before running tests again. 76 + 77 + Reference screenshot: 78 + tests/__screenshots__/hero.test.ts/hero-section-chromium-darwin.png 79 + ``` 80 + 81 + This is normal. Check that the screenshot looks right, then run the test again. 82 + Vitest will now compare future runs against this baseline. 83 + 84 + ::: tip 85 + Reference screenshots live in `__screenshots__` folders next to your tests. 86 + **Don't forget to commit them!** 87 + ::: 88 + 89 + ### Screenshot Organization 90 + 91 + By default, screenshots are organized as: 92 + 93 + ``` 94 + . 95 + ├── __screenshots__ 96 + │ └── test-file.test.ts 97 + │ ├── test-name-chromium-darwin.png 98 + │ ├── test-name-firefox-linux.png 99 + │ └── test-name-webkit-win32.png 100 + └── test-file.test.ts 101 + ``` 102 + 103 + The naming convention includes: 104 + - **Test name**: either the first argument of the `toMatchScreenshot()` call, 105 + or automatically generated from the test's name. 106 + - **Browser name**: `chrome`, `chromium`, `firefox` or `webkit`. 107 + - **Platform**: `aix`, `darwin`, `freebsd`, `linux`, `openbsd`, `sunos`, or 108 + `win32`. 109 + 110 + This ensures screenshots from different environments don't overwrite each other. 111 + 112 + ### Updating References 113 + 114 + When you intentionally change your UI, you'll need to update the reference 115 + screenshots: 116 + 117 + ```bash 118 + $ vitest --update 119 + ``` 120 + 121 + Review updated screenshots before committing to make sure changes are 122 + intentional. 123 + 124 + ## Configuring Visual Tests 125 + 126 + ### Global Configuration 127 + 128 + Configure visual regression testing defaults in your 129 + [Vitest config](/guide/browser/config#browser-expect-tomatchscreenshot): 130 + 131 + ```ts [vitest.config.ts] 132 + import { defineConfig } from 'vitest/config' 133 + 134 + export default defineConfig({ 135 + test: { 136 + browser: { 137 + expect: { 138 + toMatchScreenshot: { 139 + comparatorName: 'pixelmatch', 140 + comparatorOptions: { 141 + // 0-1, how different can colors be? 142 + threshold: 0.2, 143 + // 1% of pixels can differ 144 + allowedMismatchedPixelRatio: 0.01, 145 + }, 146 + }, 147 + }, 148 + }, 149 + }, 150 + }) 151 + ``` 152 + 153 + ### Per-Test Configuration 154 + 155 + Override global settings for specific tests: 156 + 157 + ```ts 158 + await expect(element).toMatchScreenshot('button-hover', { 159 + comparatorName: 'pixelmatch', 160 + comparatorOptions: { 161 + // more lax comparison for text-heavy elements 162 + allowedMismatchedPixelRatio: 0.1, 163 + }, 164 + }) 165 + ``` 166 + 167 + ## Best Practices 168 + 169 + ### Test Specific Elements 170 + 171 + Unless you explicitly want to test the whole page, prefer capturing specific 172 + components to reduce false positives: 173 + 174 + ```ts 175 + // ❌ Captures entire page; prone to unrelated changes 176 + await expect(page).toMatchScreenshot() 177 + 178 + // ✅ Captures only the component under test 179 + await expect(page.getByTestId('product-card')).toMatchScreenshot() 180 + ``` 181 + 182 + ### Handle Dynamic Content 183 + 184 + Dynamic content like timestamps, user data, or random values will cause tests 185 + to fail. You can either mock the sources of dynamic content or mask them when 186 + using the Playwright provider by using the 187 + [`mask` option](https://playwright.dev/docs/api/class-page#page-screenshot-option-mask) 188 + in `screenshotOptions`. 189 + 190 + ```ts 191 + await expect(page.getByTestId('profile')).toMatchScreenshot({ 192 + screenshotOptions: { 193 + mask: [page.getByTestId('last-seen')], 194 + }, 195 + }) 196 + ``` 197 + 198 + ### Disable Animations 199 + 200 + Animations can cause flaky tests. Disable them during testing by injecting 201 + a custom CSS snippet: 202 + 203 + ```css 204 + *, *::before, *::after { 205 + animation-duration: 0s !important; 206 + animation-delay: 0s !important; 207 + transition-duration: 0s !important; 208 + transition-delay: 0s !important; 209 + } 210 + ``` 211 + 212 + ::: tip 213 + When using the Playwright provider, animations are automatically disabled 214 + when using the assertion: the `animations` option's value in `screenshotOptions` 215 + is set to `"disabled"` by default. 216 + ::: 217 + 218 + ### Set Appropriate Thresholds 219 + 220 + Tuning thresholds is tricky. It depends on the content, test environment, 221 + what's acceptable for your app, and might also change based on the test. 222 + 223 + Vitest does not set a default for the mismatching pixels, that's up for the 224 + user to decide based on their needs. The recommendation is to use 225 + `allowedMismatchedPixelRatio`, so that the threshold is computed on the size 226 + of the screenshot and not a fixed number. 227 + 228 + When setting both `allowedMismatchedPixelRatio` and 229 + `allowedMismatchedPixels`, Vitest uses whichever limit is stricter. 230 + 231 + ### Set consistent viewport sizes 232 + 233 + As the browser instance might have a different default size, it's best to 234 + set a specific viewport size, either on the test or the instance 235 + configuration: 236 + 237 + ```ts 238 + await page.viewport(1280, 720) 239 + ``` 240 + 241 + ```ts [vitest.config.ts] 242 + export default defineConfig({ 243 + test: { 244 + browser: { 245 + enabled: true, 246 + provider: 'playwright', 247 + instances: [ 248 + { 249 + browser: 'chromium', 250 + viewport: { width: 1280, height: 720 }, 251 + }, 252 + ], 253 + }, 254 + }, 255 + }) 256 + ``` 257 + 258 + ### Use Git LFS 259 + 260 + Store reference screenshots in 261 + [Git LFS](https://github.com/git-lfs/git-lfs?tab=readme-ov-file) if you plan to 262 + have a large test suite. 263 + 264 + ## Debugging Failed Tests 265 + 266 + When a visual test fails, Vitest provides three images to help debug: 267 + 268 + 1. **Reference screenshot**: the expected baseline image 269 + 1. **Actual screenshot**: what was captured during the test 270 + 1. **Diff image**: highlights the differences, but this might not get generated 271 + 272 + You'll see something like: 273 + 274 + ``` 275 + expect(element).toMatchScreenshot() 276 + 277 + Screenshot does not match the stored reference. 278 + 245 pixels (ratio 0.03) differ. 279 + 280 + Reference screenshot: 281 + tests/__screenshots__/button.test.ts/button-chromium-darwin.png 282 + 283 + Actual screenshot: 284 + tests/.vitest-attachments/button.test.ts/button-chromium-darwin-actual.png 285 + 286 + Diff image: 287 + tests/.vitest-attachments/button.test.ts/button-chromium-darwin-diff.png 288 + ``` 289 + 290 + ### Understanding the diff image 291 + 292 + - **Red pixels** are areas that differ between reference and actual 293 + - **Yellow pixels** are anti-aliasing differences (when anti-alias is not ignored) 294 + - **Transparent/original** are unchanged areas 295 + 296 + :::tip 297 + If the diff is mostly red, something's really wrong. If it's speckled with a 298 + few red pixels around text, you probably just need to bump your threshold. 299 + ::: 300 + 301 + ## Common Issues and Solutions 302 + 303 + ### False Positives from Font Rendering 304 + 305 + Font availability and rendering varies significantly between systems. Some 306 + possible solutions might be to: 307 + 308 + - Use web fonts and wait for them to load: 309 + 310 + ```ts 311 + // wait for fonts to load 312 + await document.fonts.ready 313 + 314 + // continue with your tests 315 + ``` 316 + 317 + - Increase comparison threshold for text-heavy areas: 318 + 319 + ```ts 320 + await expect(page.getByTestId('article-summary')).toMatchScreenshot({ 321 + comparatorName: 'pixelmatch', 322 + comparatorOptions: { 323 + // 10% of the pixels are allowed to change 324 + allowedMismatchedPixelRatio: 0.1, 325 + }, 326 + }) 327 + ``` 328 + 329 + - Use a cloud service or containerized environment for consistent font rendering. 330 + 331 + ### Flaky Tests or Different Screenshot Sizes 332 + 333 + If tests pass and fail randomly, or if screenshots have different dimensions 334 + between runs: 335 + 336 + - Wait for everything to load, including loading indicators 337 + - Set explicit viewport sizes: `await page.viewport(1920, 1080)` 338 + - Check for responsive behavior at viewport boundaries 339 + - Check for unintended animations or transitions 340 + - Increase test timeout for large screenshots 341 + - Use a cloud service or containerized environment 342 + 343 + ## Visual Regression Testing for Teams 344 + 345 + Remember when we mentioned visual tests need a stable environment? Well, here's 346 + the thing: your local machine isn't it. 347 + 348 + For teams, you've basically got three options: 349 + 350 + 1. **Self-hosted runners**, complex to set up, painful to maintain 351 + 1. **GitHub Actions**, free (for open source), works with any provider 352 + 1. **Cloud services**, like 353 + [Microsoft Playwright Testing](https://azure.microsoft.com/en-us/products/playwright-testing), 354 + built for this exact problem 355 + 356 + We'll focus on options 2 and 3 since they're the quickest to get running. 357 + 358 + To be upfront, the main trade-offs for each are: 359 + 360 + - **GitHub Actions**: visual tests only run in CI (developers can't run them 361 + locally) 362 + - **Microsoft's service**: works everywhere but costs money and only works 363 + with Playwright 364 + 365 + :::: tabs key:vrt-for-teams 366 + === GitHub Actions 367 + 368 + The trick here is keeping visual tests separate from your regular tests, 369 + otherwise, you'll waste hours checking failing logs of screenshot mismatches. 370 + 371 + #### Organizing Your Tests 372 + 373 + First, isolate your visual tests. Stick them in a `visual` folder (or whatever 374 + makes sense for your project): 375 + 376 + ```json [package.json] 377 + { 378 + "scripts": { 379 + "test:unit": "vitest --exclude tests/visual/*.test.ts", 380 + "test:visual": "vitest tests/visual/*.test.ts" 381 + } 382 + } 383 + ``` 384 + 385 + Now developers can run `npm run test:unit` locally without visual tests getting 386 + in the way. Visual tests stay in CI where the environment is consistent. 387 + 388 + ::: tip Alternative 389 + Not a fan of glob patterns? You could also use separate 390 + [Test Projects](/guide/projects) instead and run them using: 391 + 392 + - `vitest --project unit` 393 + - `vitest --project visual` 394 + ::: 395 + 396 + #### CI Setup 397 + 398 + Your CI needs browsers installed. How you do this depends on your provider: 399 + 400 + ::: tabs key:provider 401 + == Playwright 402 + 403 + [Playwright](https://npmjs.com/package/playwright) makes this easy. Just pin 404 + your version and add this before running tests: 405 + 406 + ```yaml [.github/workflows/ci.yml] 407 + # ...the rest of the workflow 408 + - name: Install Playwright Browsers 409 + run: npx --no playwright install --with-deps --only-shell 410 + ``` 411 + 412 + == WebdriverIO 413 + 414 + [WebdriverIO](https://www.npmjs.com/package/webdriverio) expects you to bring 415 + your own browsers. The folks at 416 + [@browser-actions](https://github.com/browser-actions) have your back: 417 + 418 + ```yaml [.github/workflows/ci.yml] 419 + # ...the rest of the workflow 420 + - uses: browser-actions/setup-chrome@v1 421 + with: 422 + chrome-version: 120 423 + ``` 424 + 425 + ::: 426 + 427 + Then run your visual tests: 428 + 429 + ```yaml [.github/workflows/ci.yml] 430 + # ...the rest of the workflow 431 + # ...browser setup 432 + - name: Visual Regression Testing 433 + run: npm run test:visual 434 + ``` 435 + 436 + #### The Update Workflow 437 + 438 + Here's where it gets interesting. You don't want to update screenshots on every 439 + PR automatically <small>*(chaos!)*</small>. Instead, create a 440 + manually-triggered workflow that developers can run when they intentionally 441 + change the UI. 442 + 443 + The workflow below: 444 + - Only runs on feature branches (never on main) 445 + - Credits the person who triggered it as co-author 446 + - Prevents concurrent runs on the same branch 447 + - Shows a nice summary: 448 + - **When screenshots changed**, it lists what changed 449 + 450 + <img alt="Action summary after updates" img-light src="/vrt-gha-summary-update-light.png"> 451 + <img alt="Action summary after updates" img-dark src="/vrt-gha-summary-update-dark.png"> 452 + 453 + - **When nothing changed**, well, it tells you that too 454 + 455 + <img alt="Action summary after no updates" img-light src="/vrt-gha-summary-no-update-light.png"> 456 + <img alt="Action summary after no updates" img-dark src="/vrt-gha-summary-no-update-dark.png"> 457 + 458 + ::: tip 459 + This is just one approach. Some teams prefer PR comments (`/update-screenshots`), 460 + others use labels. Adjust it to fit your workflow! 461 + 462 + The important part is having a controlled way to update baselines. 463 + ::: 464 + 465 + ```yaml [.github/workflows/update-screenshots.yml] 466 + name: Update Visual Regression Screenshots 467 + 468 + on: 469 + workflow_dispatch: # manual trigger only 470 + 471 + env: 472 + AUTHOR_NAME: 'github-actions[bot]' 473 + AUTHOR_EMAIL: '41898282+github-actions[bot]@users.noreply.github.com' 474 + COMMIT_MESSAGE: | 475 + test: update visual regression screenshots 476 + 477 + Co-authored-by: ${{ github.actor }} <${{ github.actor_id }}+${{ github.actor }}@users.noreply.github.com> 478 + 479 + jobs: 480 + update-screenshots: 481 + runs-on: ubuntu-24.04 482 + 483 + # safety first: don't run on main 484 + if: github.ref_name != github.event.repository.default_branch 485 + 486 + # one at a time per branch 487 + concurrency: 488 + group: visual-regression-screenshots@${{ github.ref_name }} 489 + cancel-in-progress: true 490 + 491 + permissions: 492 + contents: write # needs to push changes 493 + 494 + steps: 495 + - name: Checkout selected branch 496 + uses: actions/checkout@v4 497 + with: 498 + ref: ${{ github.ref_name }} 499 + # use PAT if triggering other workflows 500 + # token: ${{ secrets.GITHUB_TOKEN }} 501 + 502 + - name: Configure Git 503 + run: | 504 + git config --global user.name "${{ env.AUTHOR_NAME }}" 505 + git config --global user.email "${{ env.AUTHOR_EMAIL }}" 506 + 507 + # your setup steps here (node, pnpm, whatever) 508 + - name: Setup Node.js 509 + uses: actions/setup-node@v4 510 + with: 511 + node-version: 24 512 + 513 + - name: Install dependencies 514 + run: npm ci 515 + 516 + - name: Install Playwright Browsers 517 + run: npx --no playwright install --with-deps --only-shell 518 + 519 + # the magic happens below 🪄 520 + - name: Update Visual Regression Screenshots 521 + run: npm run test:visual --update 522 + 523 + # check what changed 524 + - name: Check for changes 525 + id: check_changes 526 + run: | 527 + CHANGED_FILES=$(git status --porcelain | awk '{print $2}') 528 + if [ "${CHANGED_FILES:+x}" ]; then 529 + echo "changes=true" >> $GITHUB_OUTPUT 530 + echo "Changes detected" 531 + 532 + # save the list for the summary 533 + echo "changed_files<<EOF" >> $GITHUB_OUTPUT 534 + echo "$CHANGED_FILES" >> $GITHUB_OUTPUT 535 + echo "EOF" >> $GITHUB_OUTPUT 536 + echo "changed_count=$(echo "$CHANGED_FILES" | wc -l)" >> $GITHUB_OUTPUT 537 + else 538 + echo "changes=false" >> $GITHUB_OUTPUT 539 + echo "No changes detected" 540 + fi 541 + 542 + # commit if there are changes 543 + - name: Commit changes 544 + if: steps.check_changes.outputs.changes == 'true' 545 + run: | 546 + git add -A 547 + git commit -m "${{ env.COMMIT_MESSAGE }}" 548 + 549 + - name: Push changes 550 + if: steps.check_changes.outputs.changes == 'true' 551 + run: git push origin ${{ github.ref_name }} 552 + 553 + # pretty summary for humans 554 + - name: Summary 555 + run: | 556 + if [[ "${{ steps.check_changes.outputs.changes }}" == "true" ]]; then 557 + echo "### 📸 Visual Regression Screenshots Updated" >> $GITHUB_STEP_SUMMARY 558 + echo "" >> $GITHUB_STEP_SUMMARY 559 + echo "Successfully updated **${{ steps.check_changes.outputs.changed_count }}** screenshot(s) on \`${{ github.ref_name }}\`" >> $GITHUB_STEP_SUMMARY 560 + echo "" >> $GITHUB_STEP_SUMMARY 561 + echo "#### Changed Files:" >> $GITHUB_STEP_SUMMARY 562 + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY 563 + echo "${{ steps.check_changes.outputs.changed_files }}" >> $GITHUB_STEP_SUMMARY 564 + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY 565 + echo "" >> $GITHUB_STEP_SUMMARY 566 + echo "✅ The updated screenshots have been committed and pushed. Your visual regression baseline is now up to date!" >> $GITHUB_STEP_SUMMARY 567 + else 568 + echo "### ℹ️ No Screenshot Updates Required" >> $GITHUB_STEP_SUMMARY 569 + echo "" >> $GITHUB_STEP_SUMMARY 570 + echo "The visual regression test command ran successfully but no screenshots needed updating." >> $GITHUB_STEP_SUMMARY 571 + echo "" >> $GITHUB_STEP_SUMMARY 572 + echo "All screenshots are already up to date! 🎉" >> $GITHUB_STEP_SUMMARY 573 + fi 574 + ``` 575 + 576 + === Microsoft Playwright Testing 577 + 578 + Your tests stay local, only the browsers run in the cloud. It's Playwright's 579 + remote browser feature, but Microsoft handles all the infrastructure. 580 + 581 + #### Organizing Your Tests 582 + 583 + Keep visual tests separate to control costs. Only tests that actually take 584 + screenshots should use the service. 585 + 586 + The cleanest approach is using [Test Projects](/guide/projects): 587 + 588 + ```ts [vitest.config.ts] 589 + import { env } from 'node:process' 590 + import { defineConfig } from 'vitest/config' 591 + 592 + export default defineConfig({ 593 + // ...global Vite config 594 + tests: { 595 + // ...global Vitest config 596 + projects: [ 597 + { 598 + extends: true, 599 + test: { 600 + name: 'unit', 601 + include: ['tests/**/*.test.ts'], 602 + // regular config, can use local browsers 603 + }, 604 + }, 605 + { 606 + extends: true, 607 + test: { 608 + name: 'visual', 609 + // or you could use a different suffix, e.g.,: `tests/**/*.visual.ts?(x)` 610 + include: ['visual-regression-tests/**/*.test.ts?(x)'], 611 + browser: { 612 + enabled: true, 613 + provider: 'playwright', 614 + headless: true, 615 + instances: [ 616 + { 617 + browser: 'chromium', 618 + viewport: { width: 2560, height: 1440 }, 619 + connect: { 620 + wsEndpoint: `${env.PLAYWRIGHT_SERVICE_URL}?cap=${JSON.stringify({ 621 + os: 'linux', // always use Linux for consistency 622 + // helps identifying runs in the service's dashboard 623 + runId: `Vitest ${env.CI ? 'CI' : 'local'} run @${new Date().toISOString()}`, 624 + })}`, 625 + options: { 626 + exposeNetwork: '<loopback>', 627 + headers: { 628 + 'x-mpt-access-key': env.PLAYWRIGHT_SERVICE_ACCESS_TOKEN, 629 + }, 630 + timeout: 30_000, 631 + }, 632 + }, 633 + }, 634 + ], 635 + }, 636 + }, 637 + }, 638 + ], 639 + }, 640 + }) 641 + ``` 642 + 643 + The service gives you two environment variables: 644 + 645 + - `PLAYWRIGHT_SERVICE_URL` tells Playwright where to connect 646 + - `PLAYWRIGHT_SERVICE_ACCESS_TOKEN` is your auth token 647 + 648 + ::: danger Keep that Token Secret! 649 + Never commit `PLAYWRIGHT_SERVICE_ACCESS_TOKEN` to your repository. Anyone with 650 + the token can rack up your bill. Use environment variables locally and secrets 651 + in CI. 652 + ::: 653 + 654 + Then split your `test` script like this: 655 + 656 + ```json [package.json] 657 + { 658 + "scripts": { 659 + "test:visual": "vitest --project visual", 660 + "test:unit": "vitest --project unit" 661 + } 662 + } 663 + ``` 664 + 665 + #### Running Tests 666 + 667 + ```bash 668 + # Local development 669 + npm run test:unit # free, runs locally 670 + npm run test:visual # uses cloud browsers 671 + 672 + # Update screenshots 673 + npm run test:visual -- --update 674 + ``` 675 + 676 + The best part of this approach is that it just works: 677 + 678 + - **Consistent screenshots**, everyone uses the same cloud browsers 679 + - **Works locally**, developers can run and update visual tests on their machines 680 + - **Pay for what you use**, only visual tests consume service minutes 681 + - **No Docker or workflow setups needed**, nothing to manage or maintain 682 + 683 + #### CI Setup 684 + 685 + In your CI, add the secrets: 686 + 687 + ```yaml 688 + env: 689 + PLAYWRIGHT_SERVICE_URL: ${{ vars.PLAYWRIGHT_SERVICE_URL }} 690 + PLAYWRIGHT_SERVICE_ACCESS_TOKEN: ${{ secrets.PLAYWRIGHT_SERVICE_ACCESS_TOKEN }} 691 + ``` 692 + 693 + Then run your tests like normal. The service handles the rest. 694 + 695 + :::: 696 + 697 + ### So Which One? 698 + 699 + Both approaches work. The real question is what pain points matter most to your 700 + team. 701 + 702 + If you're already deep in the GitHub ecosystem, GitHub Actions is hard to beat. 703 + Free for open source, works with any browser provider, and you control 704 + everything. 705 + 706 + The downside? That "works on my machine" conversation when someone generates 707 + screenshots locally and they don't match CI expectations anymore. 708 + 709 + The cloud service makes sense if developers need to run visual tests locally. 710 + 711 + Some teams have designers checking their work or developers who prefer catching 712 + issues before pushing. It allows skipping the push-wait-check-fix-push cycle. 713 + 714 + Still on the fence? Start with GitHub Actions. You can always add the cloud 715 + service later if local testing becomes a pain point.
+13
packages/browser/providers/playwright.d.ts
··· 11 11 import { Protocol } from 'playwright-core/types/protocol' 12 12 import '../matchers.js' 13 13 import type {} from "vitest/node" 14 + import type { 15 + ScreenshotComparatorRegistry, 16 + ScreenshotMatcherOptions, 17 + } from "@vitest/browser/context" 14 18 15 19 declare module 'vitest/node' { 16 20 export interface BrowserProviderOptions { ··· 37 41 iframe: FrameLocator 38 42 context: BrowserContext 39 43 } 44 + 45 + export interface ToMatchScreenshotOptions 46 + extends Omit< 47 + ScreenshotMatcherOptions, 48 + "comparatorName" | "comparatorOptions" 49 + > {} 50 + 51 + export interface ToMatchScreenshotComparators 52 + extends ScreenshotComparatorRegistry {} 40 53 } 41 54 42 55 type PWHoverOptions = NonNullable<Parameters<Page['hover']>[1]>
+13
packages/browser/providers/webdriverio.d.ts
··· 1 1 import type { remote, ClickOptions, DragAndDropOptions } from 'webdriverio' 2 2 import '../matchers.js' 3 3 import type {} from "vitest/node" 4 + import type { 5 + ScreenshotComparatorRegistry, 6 + ScreenshotMatcherOptions, 7 + } from "@vitest/browser/context"; 4 8 5 9 declare module 'vitest/node' { 6 10 export interface BrowserProviderOptions extends Partial< ··· 19 23 export interface BrowserCommandContext { 20 24 browser: WebdriverIO.Browser 21 25 } 26 + 27 + export interface ToMatchScreenshotOptions 28 + extends Omit< 29 + ScreenshotMatcherOptions, 30 + "comparatorName" | "comparatorOptions" 31 + > {} 32 + 33 + export interface ToMatchScreenshotComparators 34 + extends ScreenshotComparatorRegistry {} 22 35 }
+169
test/browser/specs/to-match-screenshot.test.ts
··· 1 + import type { ViteUserConfig } from 'vitest/config.js' 2 + import type { TestFsStructure } from '../../test-utils' 3 + import { platform } from 'node:os' 4 + import { resolve } from 'node:path' 5 + import { describe, expect, test } from 'vitest' 6 + import { runVitestCli, useFS } from '../../test-utils' 7 + import { extractToMatchScreenshotPaths } from '../fixtures/expect-dom/utils' 8 + import utilsContent from '../fixtures/expect-dom/utils?raw' 9 + 10 + const testFilename = 'basic.test.ts' 11 + const testName = 'screenshot-snapshot' 12 + const bgColor = '#fff' 13 + 14 + const testContent = /* ts */` 15 + import { page, server } from '@vitest/browser/context' 16 + import { describe, test } from 'vitest' 17 + import { render } from './utils' 18 + 19 + const dataTestId = 'inline-test' 20 + 21 + test('${testName}', async ({ expect }) => { 22 + render('<div data-testid="' + dataTestId + '" style="background-color: ${bgColor};">Inline Test</div>') 23 + 24 + await expect(page.getByTestId(dataTestId)).toMatchScreenshot() 25 + }) 26 + ` 27 + 28 + const browser = 'chromium' 29 + 30 + export async function runInlineTests( 31 + structure: TestFsStructure, 32 + config?: ViteUserConfig['test'], 33 + ) { 34 + const root = resolve(process.cwd(), `vitest-test-${crypto.randomUUID()}`) 35 + 36 + const fs = useFS(root, { 37 + ...structure, 38 + 'vitest.config.ts': { 39 + test: { 40 + browser: { 41 + enabled: true, 42 + screenshotFailures: false, 43 + provider: 'playwright', 44 + headless: true, 45 + instances: [{ browser }], 46 + }, 47 + reporters: ['verbose'], 48 + ...config, 49 + }, 50 + }, 51 + }) 52 + 53 + const vitest = await runVitestCli({ 54 + nodeOptions: { 55 + env: { 56 + CI: 'false', 57 + GITHUB_ACTIONS: undefined, 58 + NO_COLOR: 'true', 59 + }, 60 + }, 61 + }, '--root', root, '--watch') 62 + 63 + return { 64 + fs, 65 + root, 66 + ...vitest, 67 + } 68 + } 69 + 70 + describe('--watch', () => { 71 + test( 72 + 'fails when creating a snapshot for the first time and does NOT update it afterwards', 73 + async () => { 74 + const { fs, stderr, vitest } = await runInlineTests( 75 + { 76 + [testFilename]: testContent, 77 + 'utils.ts': utilsContent, 78 + }, 79 + ) 80 + 81 + const [referencePath] = extractToMatchScreenshotPaths(stderr, testName) 82 + 83 + expect(stderr).toContain(`No existing reference screenshot found; a new one was created. Review it before running tests again.\n\nReference screenshot:\n ${referencePath}`) 84 + 85 + const { atime: _1, atimeMs: _2, ...referenceStat } = fs.statFile(referencePath) 86 + 87 + fs.editFile(testFilename, content => `${content}\n`) 88 + 89 + vitest.resetOutput() 90 + await vitest.waitForStdout('Test Files 1 passed') 91 + 92 + expect(vitest.stdout).toContain('✓ |chromium| basic.test.ts > screenshot-snapshot') 93 + 94 + const { atime: _3, atimeMs: _4, ...newReferenceStat } = fs.statFile(referencePath) 95 + 96 + expect(referenceStat).toEqual(newReferenceStat) 97 + }, 98 + ) 99 + 100 + test( 101 + 'with --update creates snapshots and updates them on change', 102 + async () => { 103 + const { fs, stderr, vitest } = await runInlineTests( 104 + { 105 + [testFilename]: testContent, 106 + 'utils.ts': utilsContent, 107 + }, 108 + { 109 + update: true, 110 + }, 111 + ) 112 + 113 + expect(stderr).toMatchInlineSnapshot(`""`) 114 + 115 + const referencePath = `__screenshots__/${testFilename}/${testName}-1-${browser}-${platform()}.png` 116 + const referenceStat = fs.statFile(referencePath) 117 + 118 + fs.editFile(testFilename, content => `${content}\n`) 119 + 120 + vitest.resetOutput() 121 + await vitest.waitForStdout('Test Files 1 passed') 122 + 123 + expect(vitest.stdout).toContain('✓ |chromium| basic.test.ts > screenshot-snapshot') 124 + 125 + const { 126 + atime, 127 + atimeMs, 128 + ctime, 129 + ctimeMs, 130 + mtime, 131 + mtimeMs, 132 + ...diffs 133 + } = fs.statFile(referencePath) 134 + 135 + expect(referenceStat).toEqual(expect.objectContaining(diffs)) 136 + 137 + expect(atime.getTime()).toBeGreaterThan(referenceStat.atime.getTime()) 138 + expect(ctime.getTime()).toBeGreaterThan(referenceStat.ctime.getTime()) 139 + expect(mtime.getTime()).toBeGreaterThan(referenceStat.mtime.getTime()) 140 + 141 + expect(atimeMs).toBeGreaterThan(referenceStat.atimeMs) 142 + expect(ctimeMs).toBeGreaterThan(referenceStat.ctimeMs) 143 + expect(mtimeMs).toBeGreaterThan(referenceStat.mtimeMs) 144 + }, 145 + ) 146 + 147 + test( 148 + 'creates a reference and fails when changing the DOM content', 149 + async () => { 150 + const { fs, stderr, vitest } = await runInlineTests( 151 + { 152 + [testFilename]: testContent, 153 + 'utils.ts': utilsContent, 154 + }, 155 + ) 156 + 157 + expect(stderr).toContain(`No existing reference screenshot found; a new one was created. Review it before running tests again.\n\nReference screenshot:`) 158 + 159 + fs.editFile(testFilename, content => content.replace(bgColor, '#0ff')) 160 + 161 + vitest.resetOutput() 162 + await vitest.waitForStdout('Test Files 1 failed') 163 + 164 + expect(vitest.stdout).toContain('× |chromium| basic.test.ts > screenshot-snapshot') 165 + expect(vitest.stdout).toContain('Screenshot does not match the stored reference.') 166 + expect(vitest.stdout).toMatch(/\d+ pixels \(ratio 0.\d{2}\) differ\./) 167 + }, 168 + ) 169 + })
+41 -2
packages/browser/src/node/plugin.ts
··· 3 3 import type { Plugin } from 'vitest/config' 4 4 import type { Vitest } from 'vitest/node' 5 5 import type { ParentBrowserProject } from './projectParent' 6 - import { lstatSync, readFileSync } from 'node:fs' 6 + import { createReadStream, lstatSync, readFileSync } from 'node:fs' 7 7 import { createRequire } from 'node:module' 8 8 import { dynamicImportPlugin } from '@vitest/mocker/node' 9 9 import { toArray } from '@vitest/utils' ··· 12 12 import sirv from 'sirv' 13 13 import * as vite from 'vite' 14 14 import { coverageConfigDefaults } from 'vitest/config' 15 - import { resolveApiServerConfig, resolveFsAllow, distDir as vitestDist } from 'vitest/node' 15 + import { isFileServingAllowed, isValidApiRequest, resolveApiServerConfig, resolveFsAllow, distDir as vitestDist } from 'vitest/node' 16 16 import { distRoot } from './constants' 17 17 import { createOrchestratorMiddleware } from './middlewares/orchestratorMiddleware' 18 18 import { createTesterMiddleware } from './middlewares/testerMiddleware' ··· 155 155 } 156 156 } 157 157 next() 158 + }) 159 + // handle attachments the same way as in packages/ui/node/index.ts 160 + server.middlewares.use((req, res, next) => { 161 + if (!req.url) { 162 + return next() 163 + } 164 + 165 + const url = new URL(req.url, 'http://localhost') 166 + 167 + if (url.pathname !== '/__vitest_attachment__') { 168 + return next() 169 + } 170 + 171 + const path = url.searchParams.get('path') 172 + const contentType = url.searchParams.get('contentType') 173 + 174 + if (!isValidApiRequest(parentServer.config, req) || !contentType || !path) { 175 + return next() 176 + } 177 + 178 + const fsPath = decodeURIComponent(path) 179 + 180 + if (!isFileServingAllowed(parentServer.vite.config, fsPath)) { 181 + return next() 182 + } 183 + 184 + try { 185 + res.setHeader( 186 + 'content-type', 187 + contentType, 188 + ) 189 + 190 + return createReadStream(fsPath) 191 + .pipe(res) 192 + .on('close', () => res.end()) 193 + } 194 + catch (err) { 195 + return next(err) 196 + } 158 197 }) 159 198 }, 160 199 },
+2
packages/vitest/src/public/node.ts
··· 76 76 ParentProjectBrowser, 77 77 ProjectBrowser, 78 78 ResolvedBrowserOptions, 79 + ToMatchScreenshotComparators, 80 + ToMatchScreenshotOptions, 79 81 } from '../node/types/browser' 80 82 export const createViteServer: typeof vite.createServer = vite.createServer 81 83 export type {
+319
test/browser/fixtures/expect-dom/toMatchScreenshot.test.ts
··· 1 + import { afterEach, describe, expect, test } from 'vitest' 2 + import { extractToMatchScreenshotPaths, render } from './utils' 3 + import { page, server } from '@vitest/browser/context' 4 + import { join } from 'pathe' 5 + 6 + const blockSize = 19 7 + const blocks = 5 8 + const dataTestId = 'colors-box' 9 + 10 + const renderTestCase = (colors: readonly [string, string, string]) => 11 + render(` 12 + <div style="--size: ${blockSize}px; display: flex; justify-content: center; height: var(--size); width: calc(var(--size) * ${blocks});" data-testid="${dataTestId}"> 13 + <div style="background-color: ${colors[0]}; width: var(--size);"></div> 14 + <div style="background-color: ${colors[1]}; width: var(--size);"></div> 15 + <div style="background-color: ${colors[2]}; width: var(--size);"></div> 16 + </div> 17 + `) 18 + 19 + /** 20 + * ## Screenshot Testing Strategy 21 + * 22 + * Tests create reference screenshots on-the-fly on demand, then compare 23 + * against them. References are cleaned up after each test. 24 + * 25 + * Screenshot references are unstable across environments (headless vs UI mode, 26 + * different operating systems, different browsers). Storing references for 27 + * every environment combination would create a maintenance burden. 28 + */ 29 + describe('.toMatchScreenshot', () => { 30 + test('compares screenshots correctly', async ({ onTestFinished }) => { 31 + const filename = globalThis.crypto.randomUUID() 32 + const path = join( 33 + '__screenshots__', 34 + 'toMatchScreenshot.test.ts', 35 + `${filename}-${server.browser}-${server.platform}.png`, 36 + ) 37 + 38 + onTestFinished(async () => { 39 + await server.commands.removeFile(path) 40 + }) 41 + 42 + renderTestCase([ 43 + 'oklch(39.6% 0.141 25.723)', 44 + 'oklch(40.5% 0.101 131.063)', 45 + 'oklch(37.9% 0.146 265.522)', 46 + ]) 47 + 48 + const locator = page.getByTestId(dataTestId) 49 + 50 + // Create a reference screenshot by explicitly saving one 51 + await locator.screenshot({ 52 + save: true, 53 + path, 54 + }) 55 + 56 + // Test that `toMatchScreenshot()` correctly finds and compares against 57 + // this reference; since the element hasn't changed, it should match 58 + await expect(locator).toMatchScreenshot(filename) 59 + }) 60 + 61 + // Only run this test if snapshots aren't being updated 62 + test.runIf(server.config.snapshotOptions.updateSnapshot !== 'all')( 63 + "throws when screenshots don't match", 64 + async ({ onTestFinished }) => { 65 + const filename = globalThis.crypto.randomUUID() 66 + const path = join( 67 + '__screenshots__', 68 + 'toMatchScreenshot.test.ts', 69 + `${filename}-${server.browser}-${server.platform}.png`, 70 + ) 71 + 72 + onTestFinished(async () => { 73 + await server.commands.removeFile(path) 74 + }) 75 + 76 + // Create reference with first color set 77 + renderTestCase([ 78 + 'oklch(39.6% 0.141 25.723)', 79 + 'oklch(40.5% 0.101 131.063)', 80 + 'oklch(37.9% 0.146 265.522)', 81 + ]) 82 + 83 + const locator = page.getByTestId(dataTestId) 84 + 85 + await locator.screenshot({ 86 + save: true, 87 + path, 88 + }) 89 + 90 + // Change to different colors - this should cause comparison to fail 91 + renderTestCase([ 92 + 'oklch(84.1% 0.238 128.85)', 93 + 'oklch(84.1% 0.238 128.85)', 94 + 'oklch(84.1% 0.238 128.85)', 95 + ]) 96 + 97 + let errorMessage: string 98 + 99 + try { 100 + await expect(locator).toMatchScreenshot(filename) 101 + } catch (error) { 102 + errorMessage = error.message 103 + } 104 + 105 + const [referencePath, actualPath, diffPath] = extractToMatchScreenshotPaths( 106 + errorMessage, 107 + filename, 108 + ) 109 + 110 + expect(referencePath).toMatch(new RegExp(`${path}$`)) 111 + expect(typeof actualPath).toBe('string') 112 + expect(typeof diffPath).toBe('string') 113 + 114 + onTestFinished(async () => { 115 + await Promise.all([ 116 + server.commands.removeFile(actualPath), 117 + server.commands.removeFile(diffPath), 118 + ]) 119 + }) 120 + 121 + const { pixels, ratio } = 122 + /(?<pixels>\d+).*?ratio (?<ratio>[01]\.\d{2})/.exec(errorMessage) 123 + ?.groups ?? {} 124 + 125 + expect(pixels).toMatch(/\d+/) 126 + expect(ratio).toMatch(/[01]\.\d{2}/) 127 + 128 + expect(errorMessage).toMatchInlineSnapshot(` 129 + expect(element).toMatchScreenshot() 130 + 131 + Screenshot does not match the stored reference. 132 + ${pixels} pixels (ratio ${ratio}) differ. 133 + 134 + Reference screenshot: 135 + ${referencePath} 136 + 137 + Actual screenshot: 138 + ${actualPath} 139 + 140 + Diff image: 141 + ${diffPath} 142 + `) 143 + }, 144 + ) 145 + 146 + // Only run this test if snapshots aren't being updated 147 + test.runIf(server.config.snapshotOptions.updateSnapshot !== 'all')( 148 + 'throws when creating a screenshot for the first time', 149 + async ({ 150 + onTestFinished, 151 + }) => { 152 + const { queryByTestId } = renderTestCase([ 153 + 'oklch(37.9% 0.146 265.522)', 154 + 'oklch(40.5% 0.101 131.063)', 155 + 'oklch(39.6% 0.141 25.723)', 156 + ]) 157 + 158 + let errorMessage: string 159 + 160 + const filename = globalThis.crypto.randomUUID() 161 + 162 + try { 163 + await expect(queryByTestId(dataTestId)).toMatchScreenshot(filename) 164 + } catch (error) { 165 + errorMessage = error.message 166 + } 167 + 168 + const [referencePath] = extractToMatchScreenshotPaths(errorMessage, filename) 169 + 170 + expect(typeof referencePath).toBe('string') 171 + 172 + onTestFinished(async () => { 173 + await server.commands.removeFile(referencePath) 174 + }) 175 + 176 + expect(errorMessage).toMatchInlineSnapshot(` 177 + expect(element).toMatchScreenshot() 178 + 179 + No existing reference screenshot found${ 180 + server.config.snapshotOptions.updateSnapshot === 'none' 181 + ? '.' 182 + : '; a new one was created. Review it before running tests again.' 183 + } 184 + 185 + Reference screenshot: 186 + ${referencePath} 187 + `) 188 + }, 189 + ) 190 + 191 + test( 192 + 'throws when not able to capture a stable screenshot', 193 + async ({ onTestFailed }) => { 194 + const filename = globalThis.crypto.randomUUID() 195 + 196 + const { queryByTestId } = render(` 197 + <div style="--size: 20px; --blocks: 10; height: var(--size); width: calc(var(--size) * var(--blocks));" data-testid="${dataTestId}"> 198 + <div style="height: 100%; aspect-ratio: 1; transform: translateX(calc(var(--size) * (var(--blocks) - 1))); animation: pong 4.5ms linear infinite;"></div> 199 + </div> 200 + <style> 201 + @keyframes pong { 202 + 0% { 203 + --blocks: 0; 204 + background-color: oklch(0% 0 0); 205 + } 206 + 11.11% { 207 + --blocks: 1; 208 + background-color: oklch(100% 0 0); 209 + } 210 + 22.22% { 211 + --blocks: 2; 212 + background-color: oklch(0% 0 0); 213 + } 214 + 33.33% { 215 + --blocks: 3; 216 + background-color: oklch(100% 0 0); 217 + } 218 + 44.44% { 219 + --blocks: 4; 220 + background-color: oklch(0% 0 0); 221 + } 222 + 55.55% { 223 + --blocks: 5; 224 + background-color: oklch(100% 0 0); 225 + } 226 + 66.66% { 227 + --blocks: 6; 228 + background-color: oklch(0% 0 0); 229 + } 230 + 77.77% { 231 + --blocks: 7; 232 + background-color: oklch(100% 0 0); 233 + } 234 + 88.88% { 235 + --blocks: 8; 236 + background-color: oklch(0% 0 0); 237 + } 238 + 100% { 239 + --blocks: 9; 240 + background-color: oklch(100% 0 0); 241 + } 242 + } 243 + </style> 244 + `) 245 + 246 + let errorMessage: string 247 + 248 + try { 249 + await expect(queryByTestId(dataTestId)).toMatchScreenshot(filename, { 250 + screenshotOptions: { animations: 'allow' }, 251 + timeout: 1, 252 + }) 253 + } catch (error) { 254 + errorMessage = error.message 255 + } 256 + 257 + onTestFailed(async () => { 258 + const [referencePath] = extractToMatchScreenshotPaths(errorMessage, filename) 259 + 260 + if (typeof referencePath === 'string') { 261 + await server.commands.removeFile(referencePath) 262 + } 263 + }) 264 + 265 + expect(errorMessage).toMatchInlineSnapshot(` 266 + expect(element).toMatchScreenshot() 267 + 268 + Could not capture a stable screenshot within 1ms. 269 + `) 270 + }, 271 + ) 272 + 273 + test( 274 + 'creates correct automatic screenshot names', 275 + async ({ onTestFinished }) => { 276 + const basename = 'toMatchScreenshot-creates-correct-automatic-screenshot-names' 277 + const path = join( 278 + '__screenshots__', 279 + 'toMatchScreenshot.test.ts', 280 + ) 281 + 282 + const firstPath = join( 283 + path, 284 + `${basename}-1-${server.browser}-${server.platform}.png` 285 + ) 286 + const secondPath = join( 287 + path, 288 + `${basename}-2-${server.browser}-${server.platform}.png` 289 + ) 290 + 291 + onTestFinished(async () => { 292 + await Promise.all([ 293 + server.commands.removeFile(firstPath), 294 + server.commands.removeFile(secondPath), 295 + ]) 296 + }) 297 + 298 + renderTestCase([ 299 + 'oklch(39.6% 0.141 25.723)', 300 + 'oklch(40.5% 0.101 131.063)', 301 + 'oklch(37.9% 0.146 265.522)', 302 + ]) 303 + 304 + const locator = page.getByTestId(dataTestId) 305 + 306 + await locator.screenshot({ 307 + save: true, 308 + path: firstPath, 309 + }) 310 + await locator.screenshot({ 311 + save: true, 312 + path: secondPath, 313 + }) 314 + 315 + await expect(locator).toMatchScreenshot() 316 + await expect(locator).toMatchScreenshot() 317 + }, 318 + ) 319 + })
+11 -1
test/browser/fixtures/expect-dom/utils.ts
··· 1 + function extractToMatchScreenshotPaths (errorMessage: string, filename: string): string[] { 2 + // `map` on `Iterator` is only available in Node >= 22 3 + return Array.from( 4 + errorMessage.matchAll( 5 + new RegExp(`^.*?((?:[A-Z]:)?/.*?${filename}-[\\w-]+\\.png)`, 'gm'), 6 + ), 7 + ([_, path]) => path, 8 + ) 9 + } 10 + 1 11 function render(html: string) { 2 12 const container = document.createElement('div') 3 13 container.innerHTML = html ··· 16 26 return { container, queryByTestId, asFragment, getInputByTestId } 17 27 } 18 28 19 - export { render } 29 + export { extractToMatchScreenshotPaths, render }
+2
packages/browser/src/node/commands/index.ts
··· 11 11 import { hover } from './hover' 12 12 import { keyboard, keyboardCleanup } from './keyboard' 13 13 import { screenshot } from './screenshot' 14 + import { screenshotMatcher } from './screenshotMatcher' 14 15 import { selectOptions } from './select' 15 16 import { tab } from './tab' 16 17 import { type } from './type' ··· 37 38 __vitest_hover: hover as typeof hover, 38 39 __vitest_cleanup: keyboardCleanup as typeof keyboardCleanup, 39 40 __vitest_viewport: viewport as typeof viewport, 41 + __vitest_screenshotMatcher: screenshotMatcher as typeof screenshotMatcher, 40 42 }
+50 -20
packages/browser/src/node/commands/screenshot.ts
··· 1 - import type { BrowserCommand, ResolvedConfig } from 'vitest/node' 1 + import type { BrowserCommand, BrowserCommandContext, ResolvedConfig } from 'vitest/node' 2 2 import type { ScreenshotOptions } from '../../../context' 3 3 import { mkdir, rm } from 'node:fs/promises' 4 4 import { normalize } from 'node:path' ··· 6 6 import { PlaywrightBrowserProvider } from '../providers/playwright' 7 7 import { WebdriverBrowserProvider } from '../providers/webdriver' 8 8 9 - export const screenshot: BrowserCommand<[string, ScreenshotOptions]> = async ( 9 + interface ScreenshotCommandOptions extends Omit<ScreenshotOptions, 'element'> { 10 + element?: string 11 + } 12 + 13 + export const screenshot: BrowserCommand<[string, ScreenshotCommandOptions]> = async ( 10 14 context, 11 15 name: string, 12 16 options = {}, 13 17 ) => { 14 - if (!context.testPath) { 15 - throw new Error(`Cannot take a screenshot without a test path`) 16 - } 17 - 18 18 options.save ??= true 19 19 20 20 if (!options.save) { 21 21 options.base64 = true 22 22 } 23 23 24 - const path = options.path 25 - ? resolve(dirname(context.testPath), options.path) 26 - : resolveScreenshotPath( 27 - context.testPath, 28 - name, 29 - context.project.config, 30 - ) 24 + const { buffer, path } = await takeScreenshot(context, name, options) 25 + 26 + return returnResult(options, path, buffer) 27 + } 28 + 29 + /** 30 + * Takes a screenshot using the provided browser context and returns a buffer and the expected screenshot path. 31 + * 32 + * **Note**: the returned `path` indicates where the screenshot *might* be found. 33 + * It is not guaranteed to exist, especially if `options.save` is `false`. 34 + * 35 + * @throws {Error} If the function is not called within a test or if the browser provider does not support screenshots. 36 + */ 37 + export async function takeScreenshot( 38 + context: BrowserCommandContext, 39 + name: string, 40 + options: Omit<ScreenshotCommandOptions, 'base64'>, 41 + ): Promise<{ buffer: Buffer<ArrayBufferLike>; path: string }> { 42 + if (!context.testPath) { 43 + throw new Error(`Cannot take a screenshot without a test path`) 44 + } 45 + 46 + const path = resolveScreenshotPath( 47 + context.testPath, 48 + name, 49 + context.project.config, 50 + options.path, 51 + ) 31 52 const savePath = normalize(path) 32 53 await mkdir(dirname(path), { recursive: true }) 33 54 ··· 39 60 ...config, 40 61 path: options.save ? savePath : undefined, 41 62 }) 42 - return returnResult(options, path, buffer) 63 + return { buffer, path } 43 64 } 44 65 45 66 const buffer = await context.iframe.locator('body').screenshot({ 46 67 ...options, 47 68 path: options.save ? savePath : undefined, 48 69 }) 49 - return returnResult(options, path, buffer) 70 + return { buffer, path } 50 71 } 51 72 52 73 if (context.provider instanceof WebdriverBrowserProvider) { ··· 55 76 ? await page.$('body') 56 77 : await page.$(`${options.element}`) 57 78 58 - const buffer = await element.saveScreenshot(savePath) 79 + // webdriverio expects the path to contain the extension and only works with PNG files 80 + const savePathWithExtension = savePath.endsWith('.png') ? savePath : `${savePath}.png` 81 + 82 + const buffer = await element.saveScreenshot( 83 + savePathWithExtension, 84 + ) 59 85 if (!options.save) { 60 - await rm(savePath, { force: true }) 86 + await rm(savePathWithExtension, { force: true }) 61 87 } 62 - return returnResult(options, path, buffer) 88 + return { buffer, path } 63 89 } 64 90 65 91 throw new Error( ··· 71 97 testPath: string, 72 98 name: string, 73 99 config: ResolvedConfig, 74 - ) { 100 + customPath: string | undefined, 101 + ): string { 102 + if (customPath) { 103 + return resolve(dirname(testPath), customPath) 104 + } 75 105 const dir = dirname(testPath) 76 106 const base = basename(testPath) 77 107 if (config.browser.screenshotDirectory) { ··· 86 116 } 87 117 88 118 function returnResult( 89 - options: ScreenshotOptions, 119 + options: ScreenshotCommandOptions, 90 120 path: string, 91 121 buffer: Buffer, 92 122 ) {
+22
packages/browser/src/shared/screenshotMatcher/types.ts
··· 1 + import type { ScreenshotComparatorRegistry, ScreenshotMatcherOptions } from '../../../context' 2 + 3 + export type ScreenshotMatcherArguments< 4 + ComparatorName extends keyof ScreenshotComparatorRegistry = keyof ScreenshotComparatorRegistry, 5 + > = [ 6 + name: string, 7 + testName: string, 8 + options: ScreenshotMatcherOptions<ComparatorName> & { element: string }, 9 + ] 10 + 11 + export type ScreenshotMatcherOutput = Promise< 12 + { 13 + pass: false 14 + reference: string | null 15 + actual: string | null 16 + diff: string | null 17 + message: string 18 + } 19 + | { 20 + pass: true 21 + } 22 + >
+1
packages/vitest/src/node/cli/cli-config.ts
··· 420 420 locators: null, 421 421 testerHtmlPath: null, 422 422 instances: null, 423 + expect: null, 423 424 }, 424 425 }, 425 426 pool: {
+1 -1
packages/vitest/src/node/config/resolveConfig.ts
··· 57 57 return { host, port: Number(port) || defaultInspectPort } 58 58 } 59 59 60 - export function resolveApiServerConfig<Options extends ApiConfig & UserConfig>( 60 + export function resolveApiServerConfig<Options extends ApiConfig & Omit<UserConfig, 'expect'>>( 61 61 options: Options, 62 62 defaultPort: number, 63 63 ): ApiConfig | undefined {
+102
packages/vitest/src/node/types/browser.ts
··· 234 234 * @default 30000 235 235 */ 236 236 connectTimeout?: number 237 + 238 + expect?: { 239 + toMatchScreenshot?: { 240 + [ComparatorName in keyof ToMatchScreenshotComparators]: 241 + { 242 + /** 243 + * The name of the comparator to use for visual diffing. 244 + * 245 + * @defaultValue `'pixelmatch'` 246 + */ 247 + comparatorName?: ComparatorName 248 + comparatorOptions?: ToMatchScreenshotComparators[ComparatorName] 249 + } 250 + }[keyof ToMatchScreenshotComparators] & ToMatchScreenshotOptions 251 + } 237 252 } 238 253 239 254 export interface BrowserCommandContext { ··· 325 340 testIdAttribute: string 326 341 } 327 342 } 343 + 344 + type ToMatchScreenshotResolvePath = (data: { 345 + /** 346 + * Path **without** extension, sanitized and relative to the test file. 347 + * 348 + * This comes from the arguments passed to `toMatchScreenshot`; if called 349 + * without arguments this will be the auto-generated name. 350 + * 351 + * @example 352 + * test('calls `onClick`', () => { 353 + * expect(locator).toMatchScreenshot() 354 + * // arg = "calls-onclick-1" 355 + * }) 356 + * 357 + * @example 358 + * expect(locator).toMatchScreenshot('foo/bar/baz.png') 359 + * // arg = "foo/bar/baz" 360 + * 361 + * @example 362 + * expect(locator).toMatchScreenshot('../foo/bar/baz.png') 363 + * // arg = "foo/bar/baz" 364 + */ 365 + arg: string 366 + /** 367 + * Screenshot extension, with leading dot. 368 + * 369 + * This can be set through the arguments passed to `toMatchScreenshot`, but 370 + * the value will fall back to `'.png'` if an unsupported extension is used. 371 + */ 372 + ext: string 373 + /** 374 + * The instance's browser name. 375 + */ 376 + browserName: string 377 + /** 378 + * The value of {@linkcode process.platform}. 379 + */ 380 + platform: NodeJS.Platform 381 + /** 382 + * The value provided to 383 + * {@linkcode https://vitest.dev/guide/browser/config#browser-screenshotdirectory|browser.screenshotDirectory}, 384 + * if none is provided, its default value. 385 + */ 386 + screenshotDirectory: string 387 + /** 388 + * Absolute path to the project's 389 + * {@linkcode https://vitest.dev/config/#root|root}. 390 + */ 391 + root: string 392 + /** 393 + * Path to the test file, relative to the project's 394 + * {@linkcode https://vitest.dev/config/#root|root}. 395 + */ 396 + testFileDirectory: string 397 + /** 398 + * The test's filename. 399 + */ 400 + testFileName: string 401 + /** 402 + * The {@linkcode https://vitest.dev/api/#test|test}'s name, including 403 + * parent {@linkcode https://vitest.dev/api/#describe|describe}, sanitized. 404 + */ 405 + testName: string 406 + /** 407 + * The value provided to 408 + * {@linkcode https://vitest.dev/config/#attachmentsdir|attachmentsDir}, 409 + * if none is provided, its default value. 410 + */ 411 + attachmentsDir: string 412 + }) => string 413 + 414 + export interface ToMatchScreenshotOptions { 415 + /** 416 + * Overrides default reference screenshot path. 417 + * 418 + * @default `${root}/${testFileDirectory}/${screenshotDirectory}/${testFileName}/${arg}-${browserName}-${platform}${ext}` 419 + */ 420 + resolveScreenshotPath?: ToMatchScreenshotResolvePath 421 + /** 422 + * Overrides default screenshot path used for diffs. 423 + * 424 + * @default `${root}/${attachmentsDir}/${testFileDirectory}/${testFileName}/${arg}-${browserName}-${platform}${ext}` 425 + */ 426 + resolveDiffPath?: ToMatchScreenshotResolvePath 427 + } 428 + 429 + export interface ToMatchScreenshotComparators {}
+2
packages/browser/src/client/tester/expect/index.ts
··· 23 23 import toHaveStyle from './toHaveStyle' 24 24 import toHaveTextContent from './toHaveTextContent' 25 25 import toHaveValue from './toHaveValue' 26 + import toMatchScreenshot from './toMatchScreenshot' 26 27 27 28 export const matchers: MatchersObject = { 28 29 toBeDisabled, ··· 51 52 toBePartiallyChecked, 52 53 toHaveRole, 53 54 toHaveSelection, 55 + toMatchScreenshot, 54 56 }
+101
packages/browser/src/client/tester/expect/toMatchScreenshot.ts
··· 1 + import type { AsyncExpectationResult, MatcherState } from '@vitest/expect' 2 + import type { ScreenshotMatcherOptions } from '../../../../context' 3 + import type { ScreenshotMatcherArguments, ScreenshotMatcherOutput } from '../../../shared/screenshotMatcher/types' 4 + import type { Locator } from '../locators' 5 + import { getBrowserState, getWorkerState } from '../../utils' 6 + import { convertElementToCssSelector } from '../utils' 7 + import { getElementFromUserInput } from './utils' 8 + 9 + const counters = new Map<string, { current: number }>([]) 10 + 11 + export default async function toMatchScreenshot( 12 + this: MatcherState, 13 + actual: Element | Locator, 14 + nameOrOptions?: ScreenshotMatcherOptions | string, 15 + options: ScreenshotMatcherOptions = typeof nameOrOptions === 'object' 16 + ? nameOrOptions 17 + : {}, 18 + ): AsyncExpectationResult { 19 + if (this.isNot) { 20 + throw new Error('\'toMatchScreenshot\' cannot be used with "not"') 21 + } 22 + 23 + const currentTest = getWorkerState().current 24 + 25 + if (currentTest === undefined || this.currentTestName === undefined) { 26 + throw new Error('\'toMatchScreenshot\' cannot be used without test context') 27 + } 28 + 29 + const counterName = `${currentTest.result?.repeatCount ?? 0}${this.testPath}${this.currentTestName}` 30 + let counter = counters.get(counterName) 31 + 32 + if (counter === undefined) { 33 + counter = { current: 0 } 34 + 35 + counters.set(counterName, counter) 36 + } 37 + 38 + counter.current += 1 39 + 40 + const name = typeof nameOrOptions === 'string' 41 + ? nameOrOptions 42 + : `${this.currentTestName} ${counter.current}` 43 + 44 + const result = await 45 + getBrowserState().commands.triggerCommand<ScreenshotMatcherOutput>( 46 + '__vitest_screenshotMatcher', 47 + [ 48 + name, 49 + this.currentTestName, 50 + { 51 + element: convertElementToCssSelector( 52 + getElementFromUserInput(actual, toMatchScreenshot, this), 53 + ), 54 + ...options, 55 + }, 56 + ] satisfies ScreenshotMatcherArguments, 57 + ) 58 + 59 + if (result.pass === false && 'context' in currentTest) { 60 + const { annotate } = currentTest.context 61 + 62 + const annotations: ReturnType<typeof annotate>[] = [] 63 + 64 + if (result.reference) { 65 + annotations.push(annotate('Reference screenshot', { path: result.reference })) 66 + } 67 + 68 + if (result.actual) { 69 + annotations.push(annotate('Actual screenshot', { path: result.actual })) 70 + } 71 + 72 + if (result.diff) { 73 + annotations.push(annotate('Diff', { path: result.diff })) 74 + } 75 + 76 + await Promise.all(annotations) 77 + } 78 + 79 + return { 80 + pass: result.pass, 81 + message: () => 82 + result.pass 83 + ? '' 84 + : [ 85 + this.utils.matcherHint('toMatchScreenshot', 'element', ''), 86 + '', 87 + result.message, 88 + result.reference 89 + ? `\nReference screenshot:\n ${this.utils.EXPECTED_COLOR(result.reference)}` 90 + : null, 91 + result.actual 92 + ? `\nActual screenshot:\n ${this.utils.RECEIVED_COLOR(result.actual)}` 93 + : null, 94 + result.diff 95 + ? this.utils.DIM_COLOR(`\nDiff image:\n ${result.diff}`) 96 + : null, 97 + ] 98 + .filter(element => element !== null) 99 + .join('\n'), 100 + } 101 + }
+246
packages/browser/src/node/commands/screenshotMatcher/index.ts
··· 1 + import type { BrowserCommand, BrowserCommandContext } from 'vitest/node' 2 + import type { ScreenshotMatcherOptions } from '../../../../context' 3 + import type { ScreenshotMatcherArguments, ScreenshotMatcherOutput } from '../../../shared/screenshotMatcher/types' 4 + import type { AnyCodec } from './codecs' 5 + import type { AnyComparator } from './comparators' 6 + import type { TypedArray } from './types' 7 + import { mkdir, readFile, writeFile } from 'node:fs/promises' 8 + import { basename, dirname } from 'pathe' 9 + import { asyncTimeout, resolveOptions, takeDecodedScreenshot } from './utils' 10 + 11 + export const screenshotMatcher: BrowserCommand< 12 + ScreenshotMatcherArguments 13 + > = async (context, name, testName, options): ScreenshotMatcherOutput => { 14 + if (!context.testPath) { 15 + throw new Error(`Cannot compare screenshots without a test path`) 16 + } 17 + 18 + const { element } = options 19 + 20 + const { 21 + codec, 22 + comparator, 23 + paths, 24 + resolvedOptions: { comparatorOptions, screenshotOptions, timeout }, 25 + } = resolveOptions({ context, name, testName, options }) 26 + 27 + const referenceFile = await readFile(paths.reference).catch(() => null) 28 + const reference = referenceFile && await codec.decode(await readFile(paths.reference), {}) 29 + 30 + const abortController = new AbortController() 31 + const stableScreenshot = getStableScreenshots({ 32 + codec, 33 + comparator, 34 + comparatorOptions, 35 + context, 36 + element, 37 + name: `${Date.now()}-${basename(paths.reference)}`, 38 + reference, 39 + screenshotOptions, 40 + signal: abortController.signal, 41 + }) 42 + 43 + const value = await ( 44 + timeout === 0 45 + ? stableScreenshot 46 + : Promise.race([ 47 + stableScreenshot, 48 + asyncTimeout(timeout).finally(() => { abortController.abort() }), 49 + ]) 50 + ) 51 + 52 + // case #01 53 + // - impossible to get a stable screenshot to compare against 54 + // - fail 55 + if (value === null || value.actual === null) { 56 + return { 57 + pass: false, 58 + reference: referenceFile && paths.reference, 59 + actual: null, 60 + diff: null, 61 + message: `Could not capture a stable screenshot within ${timeout}ms.`, 62 + } 63 + } 64 + 65 + const { updateSnapshot } = context.project.serializedConfig.snapshotOptions 66 + 67 + // if there's no reference or if we want to update snapshots, we have to finish the comparison early 68 + if (reference === null || updateSnapshot === 'all') { 69 + const shouldCreateReference = updateSnapshot !== 'none' 70 + const referencePath = shouldCreateReference ? paths.reference : paths.diffs.reference 71 + 72 + await writeScreenshot( 73 + referencePath, 74 + await codec.encode(value.actual, {}), 75 + ) 76 + 77 + // case #02 78 + // - got a stable screenshot, but there is no reference and we don't want to update screenshots 79 + // - fail 80 + if (updateSnapshot !== 'all') { 81 + return { 82 + pass: false, 83 + reference: referencePath, 84 + actual: null, 85 + diff: null, 86 + message: `No existing reference screenshot found${ 87 + shouldCreateReference 88 + ? '; a new one was created. Review it before running tests again.' 89 + : '.' 90 + }`, 91 + } 92 + } 93 + 94 + // case #03 95 + // - got a stable screenshot, there is no reference, but we want to update screenshots 96 + // - pass 97 + return { 98 + pass: true, 99 + } 100 + } 101 + 102 + // case #04 103 + // - got a stable screenshot with no retries and there's a reference 104 + // - pass 105 + if (referenceFile && value.retries === 0) { 106 + return { 107 + pass: true, 108 + } 109 + } 110 + 111 + const finalResult = await comparator(reference, value.actual, { createDiff: true, ...comparatorOptions }) 112 + 113 + if (finalResult.pass === false && finalResult.diff !== null) { 114 + const diff = await codec.encode( 115 + { 116 + data: finalResult.diff, 117 + metadata: { 118 + height: reference.metadata.height, 119 + width: reference.metadata.width, 120 + }, 121 + }, 122 + {}, 123 + ) 124 + 125 + await writeScreenshot(paths.diffs.diff, diff) 126 + } 127 + 128 + // case #05 129 + // - reference matches stable screenshot 130 + // - pass 131 + if (finalResult.pass === true) { 132 + return { 133 + pass: true, 134 + } 135 + } 136 + 137 + const actual = await codec.encode(value.actual, {}) 138 + 139 + await writeScreenshot(paths.diffs.actual, actual) 140 + 141 + // case #06 142 + // - fallback, reference does NOT match stable screenshot 143 + // - fail 144 + return { 145 + pass: false, 146 + reference: paths.reference, 147 + actual: paths.diffs.actual, 148 + diff: finalResult.diff && paths.diffs.diff, 149 + message: `Screenshot does not match the stored reference.${ 150 + finalResult.message === null 151 + ? '' 152 + : `\n${finalResult.message}` 153 + }`, 154 + } 155 + } 156 + 157 + async function writeScreenshot(path: string, image: TypedArray) { 158 + try { 159 + await mkdir(dirname(path), { recursive: true }) 160 + await writeFile(path, image) 161 + } 162 + catch { 163 + throw new Error('Couldn\'t write file to fs') 164 + } 165 + } 166 + 167 + /** 168 + * Takes screenshots repeatedly until the page reaches a visually stable state. 169 + * 170 + * This function compares consecutive screenshots and continues taking new ones 171 + * until two consecutive screenshots match according to the provided comparator. 172 + * 173 + * The process works as follows: 174 + * 175 + * 1. Uses as baseline an optional reference screenshot or takes a new screenshot 176 + * 2. Takes a screenshot and compares with baseline 177 + * 3. If they match, the page is considered stable and the function returns 178 + * 4. If they don't match, it continues with the newer screenshot as the baseline 179 + * 5. Repeats until stability is achieved or the operation is aborted 180 + * 181 + * @returns `Promise` resolving to an object containing the retry count and 182 + * final screenshot 183 + */ 184 + async function getStableScreenshots({ 185 + codec, 186 + context, 187 + comparator, 188 + comparatorOptions, 189 + element, 190 + name, 191 + reference, 192 + screenshotOptions, 193 + signal, 194 + }: { 195 + codec: AnyCodec 196 + comparator: AnyComparator 197 + comparatorOptions: ScreenshotMatcherOptions['comparatorOptions'] 198 + context: BrowserCommandContext 199 + element: string 200 + name: string 201 + reference: ReturnType<AnyCodec['decode']> | null 202 + screenshotOptions: ScreenshotMatcherOptions['screenshotOptions'] 203 + signal: AbortSignal 204 + }) { 205 + const screenshotArgument = { 206 + codec, 207 + context, 208 + element, 209 + name, 210 + screenshotOptions, 211 + } satisfies Parameters<typeof takeDecodedScreenshot>[0] 212 + 213 + let retries = 0 214 + 215 + let decodedBaseline = reference 216 + 217 + while (signal.aborted === false) { 218 + if (decodedBaseline === null) { 219 + decodedBaseline = takeDecodedScreenshot(screenshotArgument) 220 + } 221 + 222 + const [image1, image2] = await Promise.all([ 223 + decodedBaseline, 224 + takeDecodedScreenshot(screenshotArgument), 225 + ]) 226 + 227 + const comparatorResult = (await comparator( 228 + image1, 229 + image2, 230 + { ...comparatorOptions, createDiff: false }, 231 + )).pass 232 + 233 + decodedBaseline = image2 234 + 235 + if (comparatorResult) { 236 + break 237 + } 238 + 239 + retries += 1 240 + } 241 + 242 + return { 243 + retries, 244 + actual: await decodedBaseline, 245 + } 246 + }
+43
packages/browser/src/node/commands/screenshotMatcher/types.ts
··· 1 + interface BaseMetadata { height: number; width: number } 2 + export type TypedArray 3 + = | Buffer<ArrayBufferLike> 4 + | Uint8Array<ArrayBufferLike> 5 + | Uint8ClampedArray<ArrayBufferLike> 6 + export type Promisable<T> = T | Promise<T> 7 + 8 + export interface Codec< 9 + DecoderOptions extends object, 10 + DecoderMetadata extends object, 11 + EncoderOptions extends object, 12 + > { 13 + decode: ( 14 + buffer: TypedArray, 15 + options: DecoderOptions 16 + ) => Promisable<{ 17 + data: TypedArray 18 + metadata: DecoderMetadata & BaseMetadata 19 + }> 20 + encode: ( 21 + image: { data: TypedArray; metadata: BaseMetadata }, 22 + options: EncoderOptions 23 + ) => Promisable<TypedArray> 24 + } 25 + 26 + export type Comparator<Options extends Record<string, unknown>> = ( 27 + reference: { 28 + metadata: BaseMetadata 29 + data: TypedArray 30 + }, 31 + actual: { 32 + metadata: BaseMetadata 33 + data: TypedArray 34 + }, 35 + options: { 36 + /** 37 + * Allows the comparator to create a diff image. 38 + * 39 + * Note that the comparator might choose to ignore the flag, so a diff image is not guaranteed. 40 + */ 41 + createDiff: boolean 42 + } & Options 43 + ) => Promisable<{ pass: boolean; diff: TypedArray | null; message: string | null }>
+264
packages/browser/src/node/commands/screenshotMatcher/utils.ts
··· 1 + import type { BrowserCommandContext, BrowserConfigOptions } from 'vitest/node' 2 + import type { ScreenshotMatcherOptions } from '../../../../context' 3 + import type { AnyCodec } from './codecs' 4 + import { platform } from 'node:os' 5 + import { deepMerge } from '@vitest/utils' 6 + import { basename, dirname, extname, join, relative, resolve } from 'pathe' 7 + import { takeScreenshot } from '../screenshot' 8 + import { getCodec } from './codecs' 9 + import { getComparator } from './comparators' 10 + 11 + type GlobalOptions = Required< 12 + NonNullable< 13 + NonNullable<BrowserConfigOptions['expect']>['toMatchScreenshot'] 14 + > 15 + > 16 + 17 + const defaultOptions = { 18 + comparatorName: 'pixelmatch', 19 + // these are handled by each comparator on its own 20 + comparatorOptions: {}, 21 + screenshotOptions: { 22 + animations: 'disabled', 23 + caret: 'hide', 24 + fullPage: false, 25 + maskColor: '#ff00ff', 26 + omitBackground: false, 27 + scale: 'device', 28 + }, 29 + timeout: 5_000, 30 + resolveDiffPath: ({ 31 + arg, 32 + ext, 33 + root, 34 + attachmentsDir, 35 + browserName, 36 + platform, 37 + testFileDirectory, 38 + testFileName, 39 + }) => resolve( 40 + root, 41 + attachmentsDir, 42 + testFileDirectory, 43 + testFileName, 44 + `${arg}-${browserName}-${platform}${ext}`, 45 + ), 46 + resolveScreenshotPath: ({ 47 + arg, 48 + ext, 49 + root, 50 + screenshotDirectory, 51 + testFileDirectory, 52 + testFileName, 53 + browserName, 54 + }) => resolve( 55 + root, 56 + testFileDirectory, 57 + screenshotDirectory, 58 + testFileName, 59 + `${arg}-${browserName}-${platform}${ext}`, 60 + ), 61 + } satisfies GlobalOptions 62 + 63 + type SupportedCodecs = Parameters<typeof getCodec>[0] 64 + 65 + const supportedExtensions = ['png'] satisfies SupportedCodecs[] 66 + 67 + export function resolveOptions( 68 + { 69 + context, 70 + name, 71 + options, 72 + testName, 73 + }: { 74 + context: BrowserCommandContext 75 + name: string 76 + testName: string 77 + options: ScreenshotMatcherOptions 78 + }, 79 + ): { 80 + codec: ReturnType<typeof getCodec> 81 + comparator: ReturnType<typeof getComparator> 82 + resolvedOptions: GlobalOptions 83 + paths: { 84 + reference: string 85 + diffs: { 86 + reference: string 87 + actual: string 88 + diff: string 89 + } 90 + } 91 + } { 92 + if (context.testPath === undefined) { 93 + throw new Error('`resolveOptions` has to be used in a test file') 94 + } 95 + 96 + const resolvedOptions = deepMerge<GlobalOptions>( 97 + Object.create(null), 98 + defaultOptions, 99 + context.project.config.browser.expect?.toMatchScreenshot ?? {}, 100 + options, 101 + ) 102 + 103 + const extensionFromName = extname(name) 104 + 105 + // technically the type is a lie, but we check beneath and reassign otherwise 106 + let extension = extensionFromName.replace(/^\./, '') as SupportedCodecs 107 + 108 + // when `type` will be supported in `screenshotOptions`: 109 + // - `'png'` should end up in `defaultOptions.screenshotOptions.type` 110 + // - this condition should be switched around 111 + // - the assignment should be `resolvedOptions.screenshotOptions.type = extension` 112 + // - everything using `extension` should use `resolvedOptions.screenshotOptions.type` 113 + if (supportedExtensions.includes(extension) === false) { 114 + extension = 'png' 115 + } 116 + 117 + const { root } = context.project.serializedConfig 118 + 119 + const resolvePathData = { 120 + arg: sanitizeArg( 121 + // remove the extension only if it ends up being used 122 + extensionFromName.endsWith(extension) 123 + ? basename(name, extensionFromName) 124 + : name, 125 + ), 126 + ext: `.${extension}`, 127 + platform: platform(), 128 + root, 129 + screenshotDirectory: relative( 130 + root, 131 + join(root, context.project.config.browser.screenshotDirectory ?? '__screenshots__'), 132 + ), 133 + attachmentsDir: relative(root, context.project.config.attachmentsDir), 134 + testFileDirectory: relative(root, dirname(context.testPath)), 135 + testFileName: basename(context.testPath), 136 + testName: sanitize(testName, false), 137 + browserName: context.project.config.browser.name, 138 + } satisfies Parameters<GlobalOptions['resolveDiffPath']>[0] 139 + 140 + return { 141 + codec: getCodec(extension), 142 + comparator: getComparator(resolvedOptions.comparatorName), 143 + resolvedOptions, 144 + paths: { 145 + reference: resolvedOptions.resolveScreenshotPath(resolvePathData), 146 + // lazily initialize this, as it might not be needed at all 147 + get diffs() { 148 + const diffs = { 149 + reference: resolvedOptions.resolveDiffPath({ 150 + ...resolvePathData, 151 + arg: `${resolvePathData.arg}-reference`, 152 + }), 153 + actual: resolvedOptions.resolveDiffPath({ 154 + ...resolvePathData, 155 + arg: `${resolvePathData.arg}-actual`, 156 + }), 157 + diff: resolvedOptions.resolveDiffPath({ 158 + ...resolvePathData, 159 + arg: `${resolvePathData.arg}-diff`, 160 + }), 161 + } 162 + 163 + Object.defineProperty(this, 'diffs', { value: diffs }) 164 + 165 + return diffs 166 + }, 167 + }, 168 + } 169 + } 170 + 171 + /** 172 + * Sanitizes a string by removing or transforming characters to ensure it is 173 + * safe for use as a filename or path segment. It supports two modes: 174 + * 175 + * 1. Non-path mode (`keepPaths === false`): 176 + * - Replaces one or more whitespace characters (`\s+`) with a single hyphen (`-`). 177 + * - Removes any character that is not a word character (`\w`) or a hyphen (`-`). 178 + * - Collapses multiple consecutive hyphens (`-{2,}`) into a single hyphen. 179 + * 180 + * 2. Path-preserving mode (`keepPaths === true`): 181 + * - Splits the input string on the path separator. 182 + * - Sanitizes each path segment individually in non-path mode. 183 + * - Joins the sanitized segments back together. 184 + * 185 + * @param input - The raw string to sanitize. 186 + * @param keepPaths - If `false`, performs a flat sanitization (drops path segments). 187 + * If `true`, treats `input` as a path: each segment is sanitized independently, 188 + * preserving separators. 189 + */ 190 + function sanitize(input: string, keepPaths: boolean): string { 191 + if (keepPaths === false) { 192 + return input 193 + .replace(/\s+/g, '-') 194 + .replace(/[^\w-]+/g, '') 195 + .replace(/-{2,}/g, '-') 196 + } 197 + 198 + return input.split('/').map(path => sanitize(path, false)).join('/') 199 + } 200 + 201 + /** 202 + * Takes a string, treats it as a potential path or filename, and ensures it cannot 203 + * escape the root directory or contain invalid characters. Internally, it: 204 + * 205 + * 1. Prepends the path separator to the raw input to form a path-like string. 206 + * 2. Uses {@linkcode relative|relative('/', <that-path>)} to compute a relative 207 + * path from the root, which effectively strips any leading separators and prevents 208 + * traversal above the root. 209 + * 3. Passes the resulting relative path into {@linkcode sanitize|sanitize(..., true)}, 210 + * preserving any path separators but sanitizing each segment. 211 + * 212 + * @param input - The raw string to clean. 213 + */ 214 + function sanitizeArg(input: string): string { 215 + return sanitize(relative('/', join('/', input)), true) 216 + } 217 + 218 + /** 219 + * Takes a screenshot and decodes it using the provided codec. 220 + * 221 + * The screenshot is taken as a base64 string and then decoded into the format 222 + * expected by the comparator. 223 + * 224 + * @returns `Promise` resolving to the decoded screenshot data 225 + */ 226 + export function takeDecodedScreenshot({ 227 + codec, 228 + context, 229 + element, 230 + name, 231 + screenshotOptions, 232 + }: { 233 + codec: AnyCodec 234 + context: BrowserCommandContext 235 + element: string 236 + name: string 237 + screenshotOptions: ScreenshotMatcherOptions['screenshotOptions'] 238 + }): ReturnType<AnyCodec['decode']> { 239 + return takeScreenshot( 240 + context, 241 + name, 242 + { ...screenshotOptions, save: false, element }, 243 + ).then( 244 + ({ buffer }) => codec.decode(buffer, {}), 245 + ) 246 + } 247 + 248 + /** 249 + * Creates a promise that resolves to `null` after the specified timeout. 250 + * If the timeout is `0`, the promise resolves immediately. 251 + * 252 + * @param timeout - The delay in milliseconds before the promise resolves 253 + * @returns `Promise` that resolves to `null` after the timeout 254 + */ 255 + export function asyncTimeout(timeout: number): Promise<null> { 256 + return new Promise((resolve) => { 257 + if (timeout === 0) { 258 + resolve(null) 259 + } 260 + else { 261 + setTimeout(() => resolve(null), timeout) 262 + } 263 + }) 264 + }
+15
packages/browser/src/node/commands/screenshotMatcher/codecs/index.ts
··· 1 + import png from './png' 2 + 3 + export function getCodec(type: 'png'): typeof png 4 + 5 + export function getCodec(type: string) { 6 + switch (type) { 7 + case 'png': 8 + return png 9 + 10 + default: 11 + throw new Error(`No codec found for type ${type}`) 12 + } 13 + } 14 + 15 + export type AnyCodec = typeof png
+50
packages/browser/src/node/commands/screenshotMatcher/codecs/png.ts
··· 1 + import type { Metadata, PackerOptions, ParserOptions } from 'pngjs' 2 + import type { Codec } from '../types' 3 + import { PNG } from 'pngjs' 4 + 5 + const codec: Codec<ParserOptions, Metadata, PackerOptions> = { 6 + decode: (buffer, options) => { 7 + const { 8 + data, 9 + alpha, 10 + bpp, 11 + color, 12 + colorType, 13 + depth, 14 + height, 15 + interlace, 16 + palette, 17 + width, 18 + } = PNG.sync.read( 19 + Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer), 20 + options, 21 + ) 22 + 23 + return { 24 + metadata: { 25 + alpha, 26 + bpp, 27 + color, 28 + colorType, 29 + depth, 30 + height, 31 + interlace, 32 + palette, 33 + width, 34 + }, 35 + data, 36 + } 37 + }, 38 + encode: ({ data, metadata: { height, width } }, options) => { 39 + const png = new PNG({ 40 + height, 41 + width, 42 + }) 43 + 44 + png.data = Buffer.isBuffer(data) ? data : Buffer.from(data) 45 + 46 + return PNG.sync.write(png, options) 47 + }, 48 + } 49 + 50 + export default codec
+23
packages/browser/src/node/commands/screenshotMatcher/comparators/index.ts
··· 1 + import type { ScreenshotComparatorRegistry } from '../../../../../context' 2 + import type { Comparator } from '../types' 3 + import { pixelmatch } from './pixelmatch' 4 + 5 + const comparators = new Map(Object.entries({ 6 + pixelmatch, 7 + } satisfies { 8 + [ComparatorName in keyof ScreenshotComparatorRegistry]: Comparator< 9 + ScreenshotComparatorRegistry[ComparatorName] 10 + > 11 + })) 12 + 13 + export function getComparator<ComparatorName extends keyof ScreenshotComparatorRegistry>( 14 + comparator: ComparatorName, 15 + ): Comparator<ScreenshotComparatorRegistry[ComparatorName]> { 16 + if (comparators.has(comparator)) { 17 + return comparators.get(comparator)! 18 + } 19 + 20 + throw new Error(`Unrecognized comparator ${comparator}`) 21 + } 22 + 23 + export type AnyComparator = Comparator<ScreenshotComparatorRegistry[keyof ScreenshotComparatorRegistry]>
+74
packages/browser/src/node/commands/screenshotMatcher/comparators/pixelmatch.ts
··· 1 + import type { ScreenshotComparatorRegistry } from '../../../../../context' 2 + import type { Comparator } from '../types' 3 + import pm from 'pixelmatch' 4 + 5 + const defaultOptions = { 6 + allowedMismatchedPixelRatio: undefined, 7 + allowedMismatchedPixels: undefined, 8 + threshold: 0.1, 9 + includeAA: false, 10 + alpha: 0.1, 11 + aaColor: [255, 255, 0], 12 + diffColor: [255, 0, 0], 13 + diffColorAlt: undefined, 14 + diffMask: false, 15 + } satisfies ScreenshotComparatorRegistry['pixelmatch'] 16 + 17 + export const pixelmatch: Comparator<ScreenshotComparatorRegistry['pixelmatch']> = ( 18 + reference, 19 + actual, 20 + { createDiff, ...options }, 21 + ) => { 22 + if (reference.metadata.height !== actual.metadata.height || reference.metadata.width !== actual.metadata.width) { 23 + return { 24 + pass: false, 25 + diff: null, 26 + message: `Expected image dimensions to be ${reference.metadata.width}×${ 27 + reference.metadata.height 28 + }px, but received ${actual.metadata.width}×${ 29 + actual.metadata.height 30 + }px.`, 31 + } 32 + } 33 + 34 + const optionsWithDefaults = { ...defaultOptions, ...options } 35 + const diffBuffer = createDiff 36 + ? new Uint8Array(reference.data.length) 37 + : undefined 38 + 39 + const mismatchedPixels = pm( 40 + reference.data, 41 + actual.data, 42 + diffBuffer, 43 + reference.metadata.width, 44 + reference.metadata.height, 45 + optionsWithDefaults, 46 + ) 47 + 48 + const imageArea = reference.metadata.width * reference.metadata.height 49 + 50 + let allowedMismatchedPixels = Math.min( 51 + optionsWithDefaults.allowedMismatchedPixels ?? Number.POSITIVE_INFINITY, 52 + (optionsWithDefaults.allowedMismatchedPixelRatio 53 + ?? Number.POSITIVE_INFINITY) 54 + * imageArea, 55 + ) 56 + 57 + if (allowedMismatchedPixels === Number.POSITIVE_INFINITY) { 58 + allowedMismatchedPixels = 0 59 + } 60 + 61 + const pass = mismatchedPixels <= allowedMismatchedPixels 62 + 63 + return { 64 + pass, 65 + diff: diffBuffer ?? null, 66 + message: pass 67 + ? null 68 + : `${mismatchedPixels} pixels (ratio ${( 69 + // as we compare using `<=`, use `Math.ceil` to ensure the reported ratio 70 + // doesn't appear equal to the allowed limit when it's a bit over 71 + Math.ceil((mismatchedPixels / imageArea) * 100) / 100 72 + ).toFixed(2)}) differ.`, 73 + } 74 + }