diff --git a/package-lock.json b/package-lock.json
index 09774d44..5b5ee38c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,16 +1,17 @@
{
"name": "@vizzly-testing/cli",
- "version": "0.29.3",
+ "version": "0.29.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@vizzly-testing/cli",
- "version": "0.29.3",
+ "version": "0.29.6",
"license": "MIT",
"dependencies": {
"@vizzly-testing/honeydiff": "^0.10.0",
"ansis": "^4.2.0",
+ "better-sqlite3": "^12.6.2",
"commander": "^14.0.0",
"cosmiconfig": "^9.0.0",
"dotenv": "^17.2.1",
@@ -196,6 +197,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -3454,6 +3456,7 @@
"integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -3807,7 +3810,6 @@
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
- "dev": true,
"funding": [
{
"type": "github",
@@ -3822,8 +3824,7 @@
"url": "https://feross.org/support"
}
],
- "license": "MIT",
- "optional": true
+ "license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.9.7",
@@ -3835,6 +3836,20 @@
"baseline-browser-mapping": "dist/cli.js"
}
},
+ "node_modules/better-sqlite3": {
+ "version": "12.6.2",
+ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz",
+ "integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "bindings": "^1.5.0",
+ "prebuild-install": "^7.1.1"
+ },
+ "engines": {
+ "node": "20.x || 22.x || 23.x || 24.x || 25.x"
+ }
+ },
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -3849,13 +3864,20 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/bindings": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
+ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
+ "license": "MIT",
+ "dependencies": {
+ "file-uri-to-path": "1.0.0"
+ }
+ },
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
- "dev": true,
"license": "MIT",
- "optional": true,
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
@@ -3931,6 +3953,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -3949,7 +3972,6 @@
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
- "dev": true,
"funding": [
{
"type": "github",
@@ -3965,7 +3987,6 @@
}
],
"license": "MIT",
- "optional": true,
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
@@ -4132,9 +4153,7 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
- "dev": true,
- "license": "ISC",
- "optional": true
+ "license": "ISC"
},
"node_modules/color-convert": {
"version": "2.0.1",
@@ -4370,9 +4389,7 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
- "dev": true,
"license": "MIT",
- "optional": true,
"dependencies": {
"mimic-response": "^3.1.0"
},
@@ -4387,9 +4404,7 @@
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
- "dev": true,
"license": "MIT",
- "optional": true,
"engines": {
"node": ">=4.0.0"
}
@@ -4417,7 +4432,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.1.tgz",
"integrity": "sha512-ecqj/sy1jcK1uWrwpR67UhYrIFQ+5WlGxth34WquCbamhFA6hkkwiu37o6J5xCHdo1oixJRfVRw+ywV+Hq/0Aw==",
- "dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@@ -4524,9 +4538,7 @@
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
- "dev": true,
"license": "MIT",
- "optional": true,
"dependencies": {
"once": "^1.4.0"
}
@@ -4755,9 +4767,7 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
- "dev": true,
"license": "(MIT OR WTFPL)",
- "optional": true,
"engines": {
"node": ">=6"
}
@@ -4903,6 +4913,12 @@
"reusify": "^1.0.4"
}
},
+ "node_modules/file-uri-to-path": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
+ "license": "MIT"
+ },
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -4992,9 +5008,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
- "dev": true,
- "license": "MIT",
- "optional": true
+ "license": "MIT"
},
"node_modules/fs-readdir-recursive": {
"version": "1.1.0",
@@ -5085,9 +5099,7 @@
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
- "dev": true,
- "license": "MIT",
- "optional": true
+ "license": "MIT"
},
"node_modules/glob": {
"version": "13.0.3",
@@ -5234,6 +5246,7 @@
"integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -5313,7 +5326,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
- "dev": true,
"funding": [
{
"type": "github",
@@ -5328,8 +5340,7 @@
"url": "https://feross.org/support"
}
],
- "license": "BSD-3-Clause",
- "optional": true
+ "license": "BSD-3-Clause"
},
"node_modules/ignore": {
"version": "5.3.2",
@@ -5383,16 +5394,13 @@
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
- "dev": true,
"license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
- "dev": true,
- "license": "ISC",
- "optional": true
+ "license": "ISC"
},
"node_modules/ip-address": {
"version": "10.0.1",
@@ -6125,9 +6133,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
- "dev": true,
"license": "MIT",
- "optional": true,
"engines": {
"node": ">=10"
},
@@ -6188,9 +6194,7 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
- "dev": true,
"license": "MIT",
- "optional": true,
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
@@ -6230,9 +6234,7 @@
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
- "dev": true,
- "license": "MIT",
- "optional": true
+ "license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
@@ -6272,9 +6274,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
- "dev": true,
- "license": "MIT",
- "optional": true
+ "license": "MIT"
},
"node_modules/negotiator": {
"version": "1.0.0",
@@ -6290,9 +6290,7 @@
"version": "3.85.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz",
"integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==",
- "dev": true,
"license": "MIT",
- "optional": true,
"dependencies": {
"semver": "^7.3.5"
},
@@ -6304,9 +6302,7 @@
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
- "dev": true,
"license": "ISC",
- "optional": true,
"bin": {
"semver": "bin/semver.js"
},
@@ -6421,7 +6417,6 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
- "dev": true,
"license": "ISC",
"dependencies": {
"wrappy": "1"
@@ -6696,6 +6691,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -6716,9 +6712,7 @@
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
- "dev": true,
"license": "MIT",
- "optional": true,
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
@@ -6812,9 +6806,7 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
"integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
- "dev": true,
"license": "MIT",
- "optional": true,
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
@@ -6897,9 +6889,7 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
- "dev": true,
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
- "optional": true,
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
@@ -6916,6 +6906,7 @@
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -6926,6 +6917,7 @@
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -7078,9 +7070,7 @@
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
- "dev": true,
"license": "MIT",
- "optional": true,
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
@@ -7342,7 +7332,6 @@
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
- "dev": true,
"funding": [
{
"type": "github",
@@ -7357,8 +7346,7 @@
"url": "https://feross.org/support"
}
],
- "license": "MIT",
- "optional": true
+ "license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
@@ -7568,7 +7556,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
- "dev": true,
"funding": [
{
"type": "github",
@@ -7583,14 +7570,12 @@
"url": "https://feross.org/support"
}
],
- "license": "MIT",
- "optional": true
+ "license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
- "dev": true,
"funding": [
{
"type": "github",
@@ -7606,7 +7591,6 @@
}
],
"license": "MIT",
- "optional": true,
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
@@ -7683,9 +7667,7 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
- "dev": true,
"license": "MIT",
- "optional": true,
"dependencies": {
"safe-buffer": "~5.2.0"
}
@@ -7735,9 +7717,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
- "dev": true,
"license": "MIT",
- "optional": true,
"engines": {
"node": ">=0.10.0"
}
@@ -7787,7 +7767,8 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/tapable": {
"version": "2.3.0",
@@ -7807,9 +7788,7 @@
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
- "dev": true,
"license": "MIT",
- "optional": true,
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
@@ -7821,9 +7800,7 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
- "dev": true,
"license": "MIT",
- "optional": true,
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
@@ -7876,6 +7853,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -7978,9 +7956,7 @@
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
- "dev": true,
"license": "Apache-2.0",
- "optional": true,
"dependencies": {
"safe-buffer": "^5.0.1"
},
@@ -8049,6 +8025,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -8163,9 +8140,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
- "dev": true,
- "license": "MIT",
- "optional": true
+ "license": "MIT"
},
"node_modules/validate-npm-package-license": {
"version": "3.0.4",
@@ -8194,6 +8169,7 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -8287,6 +8263,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -8329,7 +8306,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
- "dev": true,
"license": "ISC"
},
"node_modules/yallist": {
@@ -8354,6 +8330,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
+ "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/package.json b/package.json
index d2e7aff9..608fb667 100644
--- a/package.json
+++ b/package.json
@@ -89,6 +89,7 @@
"dependencies": {
"@vizzly-testing/honeydiff": "^0.10.0",
"ansis": "^4.2.0",
+ "better-sqlite3": "^12.6.2",
"commander": "^14.0.0",
"cosmiconfig": "^9.0.0",
"dotenv": "^17.2.1",
diff --git a/src/cli.js b/src/cli.js
index 58f50313..589e8fdd 100644
--- a/src/cli.js
+++ b/src/cli.js
@@ -53,6 +53,7 @@ import {
generateStaticReport,
getReportFileUrl,
} from './services/static-report-generator.js';
+import { bootstrapLegacyStateIfNeeded } from './tdd/state-store.js';
import { openBrowser } from './utils/browser.js';
import { colors } from './utils/colors.js';
import { loadConfig } from './utils/config-loader.js';
@@ -331,6 +332,11 @@ output.configure({
json: jsonArg,
});
+bootstrapLegacyStateIfNeeded({
+ workingDir: process.cwd(),
+ output,
+});
+
const config = await loadConfig(configPath, {});
const services = createServices(config);
const pluginServices = createPluginServices(services);
diff --git a/src/server/handlers/tdd-handler.js b/src/server/handlers/tdd-handler.js
index 863d0e52..3c571963 100644
--- a/src/server/handlers/tdd-handler.js
+++ b/src/server/handlers/tdd-handler.js
@@ -3,10 +3,10 @@ import {
existsSync as defaultExistsSync,
readFileSync as defaultReadFileSync,
unlinkSync as defaultUnlinkSync,
- writeFileSync as defaultWriteFileSync,
} from 'node:fs';
import { join as defaultJoin, resolve as defaultResolve } from 'node:path';
import { getDimensionsSync as defaultGetDimensionsSync } from '@vizzly-testing/honeydiff';
+import { createStateStore } from '../../tdd/state-store.js';
import { TddService as DefaultTddService } from '../../tdd/tdd-service.js';
import { detectImageInputType as defaultDetectImageInputType } from '../../utils/image-input-detector.js';
import * as defaultOutput from '../../utils/output.js';
@@ -184,7 +184,6 @@ export const createTddHandler = (
existsSync = defaultExistsSync,
readFileSync = defaultReadFileSync,
unlinkSync = defaultUnlinkSync,
- writeFileSync = defaultWriteFileSync,
join = defaultJoin,
resolve = defaultResolve,
Buffer = defaultBuffer,
@@ -194,29 +193,41 @@ export const createTddHandler = (
sanitizeScreenshotName = defaultSanitizeScreenshotName,
validateScreenshotProperties = defaultValidateScreenshotProperties,
output = defaultOutput,
+ stateStore: injectedStateStore = null,
} = deps;
const tddService = new TddService(config, workingDir, setBaseline);
- const reportPath = join(workingDir, '.vizzly', 'report-data.json');
- const detailsPath = join(workingDir, '.vizzly', 'comparison-details.json');
+ const stateStore =
+ injectedStateStore ||
+ createStateStore({
+ workingDir,
+ output,
+ mode: 'write',
+ });
/**
- * Read heavy comparison details from comparison-details.json
- * Returns a map of comparison ID -> heavy fields
+ * Read report data from state store.
+ * Returns an empty shape for backward compatibility with call sites.
*/
- const readComparisonDetails = () => {
+ const readReportData = () => {
try {
- if (!existsSync(detailsPath)) return {};
- return JSON.parse(readFileSync(detailsPath, 'utf8'));
+ let data = stateStore.readReportData();
+ if (data) {
+ return data;
+ }
} catch (error) {
- output.debug('Failed to read comparison details:', error);
- return {};
+ output.error('Failed to read report data:', error);
}
+
+ return {
+ timestamp: Date.now(),
+ comparisons: [],
+ summary: { total: 0, passed: 0, failed: 0, errors: 0 },
+ };
};
/**
- * Persist heavy fields for a comparison to comparison-details.json
- * This file is NOT watched by SSE, so writes here don't trigger broadcasts
+ * Persist heavy fields for a comparison.
* Skips writing if all heavy fields are empty (passed comparisons)
*/
const updateComparisonDetails = (id, heavyFields) => {
@@ -225,91 +236,12 @@ export const createTddHandler = (
);
if (!hasData) return;
- let details = readComparisonDetails();
- details[id] = heavyFields;
- writeFileSync(detailsPath, JSON.stringify(details));
- };
-
- /**
- * Remove a comparison's heavy fields from comparison-details.json
- */
- const removeComparisonDetails = id => {
- let details = readComparisonDetails();
- delete details[id];
- writeFileSync(detailsPath, JSON.stringify(details));
- };
-
- const readReportData = () => {
- try {
- if (!existsSync(reportPath)) {
- return {
- timestamp: Date.now(),
- comparisons: [],
- summary: { total: 0, passed: 0, failed: 0, errors: 0 },
- };
- }
- const data = readFileSync(reportPath, 'utf8');
- return JSON.parse(data);
- } catch (error) {
- output.error('Failed to read report data:', error);
- return {
- timestamp: Date.now(),
- comparisons: [],
- summary: { total: 0, passed: 0, failed: 0, errors: 0 },
- };
- }
+ stateStore.upsertComparisonDetails(id, heavyFields);
};
const updateComparison = newComparison => {
try {
- const reportData = readReportData();
-
- // Ensure comparisons array exists (backward compatibility)
- if (!reportData.comparisons) {
- reportData.comparisons = [];
- }
-
- // Find existing comparison by unique ID
- // This ensures we update the correct variant even with same name
- const existingIndex = reportData.comparisons.findIndex(
- c => c.id === newComparison.id
- );
-
- if (existingIndex >= 0) {
- // Preserve initialStatus from the original comparison
- // This keeps sort order stable when status changes (e.g., after approval)
- const initialStatus =
- reportData.comparisons[existingIndex].initialStatus;
- reportData.comparisons[existingIndex] = {
- ...newComparison,
- initialStatus: initialStatus || newComparison.status,
- };
- } else {
- // New comparison - set initialStatus to current status
- reportData.comparisons.push({
- ...newComparison,
- initialStatus: newComparison.status,
- });
- }
-
- // Update summary (groups computed client-side from comparisons)
- reportData.timestamp = Date.now();
- reportData.summary = {
- total: reportData.comparisons.length,
- passed: reportData.comparisons.filter(
- c =>
- c.status === 'passed' ||
- c.status === 'baseline-created' ||
- c.status === 'new'
- ).length,
- failed: reportData.comparisons.filter(c => c.status === 'failed')
- .length,
- rejected: reportData.comparisons.filter(c => c.status === 'rejected')
- .length,
- errors: reportData.comparisons.filter(c => c.status === 'error').length,
- };
-
- writeFileSync(reportPath, JSON.stringify(reportData));
+ stateStore.upsertComparison(newComparison);
} catch (error) {
output.error('Failed to update comparison:', error);
}
@@ -526,7 +458,7 @@ export const createTddHandler = (
hasConfirmedRegions: comparison.confirmedRegions?.length > 0,
};
- // Update lightweight comparison in report-data.json (triggers SSE broadcast)
+ // Update lightweight comparison in state store (triggers SSE broadcast)
updateComparison(newComparison);
// Persist heavy fields separately (NOT broadcast via SSE)
@@ -796,34 +728,18 @@ export const createTddHandler = (
}
}
- // Delete baseline metadata
- const metadataPath = join(
- workingDir,
- '.vizzly',
- 'baselines',
- 'metadata.json'
- );
- if (existsSync(metadataPath)) {
- try {
- const { unlinkSync } = await import('node:fs');
- unlinkSync(metadataPath);
- output.debug('Deleted baseline metadata');
- } catch (error) {
- output.warn(`Failed to delete baseline metadata: ${error.message}`);
- }
- }
+ // Clear metadata state
+ stateStore.clearBaselineMetadata();
+ stateStore.clearBaselineBuildMetadata();
+ stateStore.clearHotspotMetadata();
+ stateStore.clearRegionMetadata();
- // Clear the report data entirely - fresh start
- const freshReportData = {
- timestamp: Date.now(),
- comparisons: [],
- summary: { total: 0, passed: 0, failed: 0, errors: 0 },
- };
- writeFileSync(reportPath, JSON.stringify(freshReportData));
+ // Clear state store data entirely - fresh start
+ stateStore.resetReportData();
- // Clear comparison details
- if (existsSync(detailsPath)) {
- writeFileSync(detailsPath, JSON.stringify({}));
+ // Reset in-memory TDD runtime caches so the current process is also fresh.
+ if (typeof tddService.resetRuntimeState === 'function') {
+ tddService.resetRuntimeState();
}
output.info(
@@ -878,65 +794,27 @@ export const createTddHandler = (
safeDeleteFile(comparison.current, 'current', comparison.name);
safeDeleteFile(comparison.diff, 'diff', comparison.name);
- // Remove from baseline metadata if it exists
- try {
- const metadataPath = safePath(
- workingDir,
- '.vizzly',
- 'baselines',
- 'metadata.json'
- );
- if (existsSync(metadataPath) && comparison.signature) {
- const metadata = JSON.parse(readFileSync(metadataPath, 'utf8'));
- if (metadata.screenshots) {
- const originalLength = metadata.screenshots.length;
- metadata.screenshots = metadata.screenshots.filter(
- s => s.signature !== comparison.signature
+ if (comparison.signature) {
+ try {
+ let removed = stateStore.removeBaselineScreenshot(comparison.signature);
+ if (removed) {
+ output.debug(
+ `Removed ${comparison.signature} from baseline metadata`
);
- if (metadata.screenshots.length < originalLength) {
- writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
- output.debug(
- `Removed ${comparison.signature} from baseline metadata`
- );
- }
}
+ } catch (error) {
+ output.warn(`Failed to update baseline metadata: ${error.message}`);
}
- } catch (error) {
- output.warn(`Failed to update baseline metadata: ${error.message}`);
}
- // Remove heavy fields from comparison-details.json
- removeComparisonDetails(comparisonId);
-
- // Remove comparison from report data
- reportData.comparisons = reportData.comparisons.filter(
- c => c.id !== comparisonId
- );
-
- // Regenerate summary (groups computed client-side)
- reportData.timestamp = Date.now();
- reportData.summary = {
- total: reportData.comparisons.length,
- passed: reportData.comparisons.filter(
- c =>
- c.status === 'passed' ||
- c.status === 'baseline-created' ||
- c.status === 'new'
- ).length,
- failed: reportData.comparisons.filter(c => c.status === 'failed').length,
- rejected: reportData.comparisons.filter(c => c.status === 'rejected')
- .length,
- errors: reportData.comparisons.filter(c => c.status === 'error').length,
- };
-
- writeFileSync(reportPath, JSON.stringify(reportData));
+ stateStore.deleteComparison(comparisonId);
output.info(`Deleted comparison ${comparisonId} (${comparison.name})`);
return { success: true, id: comparisonId };
};
const cleanup = () => {
- // Report data is persisted to file, no in-memory cleanup needed
+ stateStore.close();
};
return {
diff --git a/src/server/routers/dashboard.js b/src/server/routers/dashboard.js
index acec69b4..34c69af9 100644
--- a/src/server/routers/dashboard.js
+++ b/src/server/routers/dashboard.js
@@ -3,13 +3,12 @@
* Serves the React SPA for all dashboard routes
*/
-import { existsSync, readFileSync } from 'node:fs';
-import { join } from 'node:path';
+import { createStateStore } from '../../tdd/state-store.js';
import * as output from '../../utils/output.js';
import { sendError, sendHtml, sendSuccess } from '../middleware/response.js';
// SPA routes that should serve the dashboard HTML
-const SPA_ROUTES = ['/', '/stats', '/settings', '/projects', '/builds'];
+let SPA_ROUTES = ['/', '/stats', '/settings', '/projects', '/builds'];
/**
* Create dashboard router
@@ -18,27 +17,7 @@ const SPA_ROUTES = ['/', '/stats', '/settings', '/projects', '/builds'];
* @returns {Function} Route handler
*/
export function createDashboardRouter(context) {
- const { workingDir = process.cwd() } = context || {};
-
- /**
- * Read baseline metadata from baselines/metadata.json
- */
- const readBaselineMetadata = () => {
- const metadataPath = join(
- workingDir,
- '.vizzly',
- 'baselines',
- 'metadata.json'
- );
- if (!existsSync(metadataPath)) {
- return null;
- }
- try {
- return JSON.parse(readFileSync(metadataPath, 'utf8'));
- } catch {
- return null;
- }
- };
+ let { workingDir = process.cwd() } = context || {};
return async function handleDashboardRoute(req, res, pathname) {
if (req.method !== 'GET') {
@@ -47,26 +26,26 @@ export function createDashboardRouter(context) {
// API endpoint for fetching report data
if (pathname === '/api/report-data') {
- const reportDataPath = join(workingDir, '.vizzly', 'report-data.json');
-
- if (existsSync(reportDataPath)) {
- try {
- const data = JSON.parse(readFileSync(reportDataPath, 'utf8'));
- // Include baseline metadata for stats view
- data.baseline = readBaselineMetadata();
- res.setHeader('Content-Type', 'application/json');
- res.statusCode = 200;
- res.end(JSON.stringify(data));
- return true;
- } catch (error) {
- output.debug('Error reading report data:', { error: error.message });
- res.statusCode = 500;
- res.end(JSON.stringify({ error: 'Failed to read report data' }));
+ let stateStore = createStateStore({ workingDir, output, mode: 'read' });
+ try {
+ let data = stateStore.readReportData();
+ if (!data) {
+ sendSuccess(res, null);
return true;
}
- } else {
- sendSuccess(res, null);
+
+ data.baseline = stateStore.getBaselineMetadata();
+ res.setHeader('Content-Type', 'application/json');
+ res.statusCode = 200;
+ res.end(JSON.stringify(data));
+ return true;
+ } catch (error) {
+ output.debug('Error reading report data:', { error: error.message });
+ res.statusCode = 500;
+ res.end(JSON.stringify({ error: 'Failed to read report data' }));
return true;
+ } finally {
+ stateStore.close();
}
}
@@ -79,44 +58,24 @@ export function createDashboardRouter(context) {
return true;
}
- let reportDataPath = join(workingDir, '.vizzly', 'report-data.json');
- if (!existsSync(reportDataPath)) {
- sendError(res, 404, 'No report data found');
- return true;
- }
-
+ let stateStore = createStateStore({ workingDir, output, mode: 'read' });
try {
- let reportData = JSON.parse(readFileSync(reportDataPath, 'utf8'));
- let comparison = (reportData.comparisons || []).find(
- c =>
- c.id === comparisonId ||
- c.signature === comparisonId ||
- c.name === comparisonId
- );
+ let reportData = stateStore.readReportData();
+ if (!reportData) {
+ sendError(res, 404, 'No report data found');
+ return true;
+ }
+ let comparison =
+ stateStore.getComparisonByIdOrSignatureOrName(comparisonId);
if (!comparison) {
sendError(res, 404, 'Comparison not found');
return true;
}
- // Merge with heavy fields from comparison-details.json
- let detailsPath = join(
- workingDir,
- '.vizzly',
- 'comparison-details.json'
- );
- if (existsSync(detailsPath)) {
- try {
- let details = JSON.parse(readFileSync(detailsPath, 'utf8'));
- let heavy = details[comparison.id];
- if (heavy) {
- comparison = { ...comparison, ...heavy };
- }
- } catch (error) {
- output.debug('Failed to read comparison details:', {
- error: error.message,
- });
- }
+ let heavy = stateStore.getComparisonDetails(comparison.id);
+ if (heavy) {
+ comparison = { ...comparison, ...heavy };
}
sendSuccess(res, comparison);
@@ -125,27 +84,29 @@ export function createDashboardRouter(context) {
error: error.message,
});
sendError(res, 500, 'Failed to read comparison data');
+ } finally {
+ stateStore.close();
}
return true;
}
// Serve React SPA for dashboard routes
if (SPA_ROUTES.includes(pathname) || pathname.startsWith('/comparison/')) {
- const reportDataPath = join(workingDir, '.vizzly', 'report-data.json');
let reportData = null;
- if (existsSync(reportDataPath)) {
- try {
- const data = readFileSync(reportDataPath, 'utf8');
- reportData = JSON.parse(data);
- // Include baseline metadata for stats view
- reportData.baseline = readBaselineMetadata();
- } catch (error) {
- output.debug('Could not read report data:', { error: error.message });
+ let stateStore = createStateStore({ workingDir, output, mode: 'read' });
+ try {
+ reportData = stateStore.readReportData();
+ if (reportData) {
+ reportData.baseline = stateStore.getBaselineMetadata();
}
+ } catch (error) {
+ output.debug('Could not read report data:', { error: error.message });
+ } finally {
+ stateStore.close();
}
- const dashboardHtml = `
+ let dashboardHtml = `
diff --git a/src/server/routers/events.js b/src/server/routers/events.js
index 386bc717..5803fe6e 100644
--- a/src/server/routers/events.js
+++ b/src/server/routers/events.js
@@ -3,8 +3,7 @@
* Server-Sent Events endpoint for real-time dashboard updates
*/
-import { existsSync, readFileSync, watch } from 'node:fs';
-import { join } from 'node:path';
+import { createStateStore } from '../../tdd/state-store.js';
/**
* Create events router for SSE
@@ -13,50 +12,30 @@ import { join } from 'node:path';
* @returns {Function} Route handler
*/
export function createEventsRouter(context) {
- const { workingDir = process.cwd() } = context || {};
- const reportDataPath = join(workingDir, '.vizzly', 'report-data.json');
- const baselineMetadataPath = join(
- workingDir,
- '.vizzly',
- 'baselines',
- 'metadata.json'
- );
-
- /**
- * Read and parse baseline metadata, returning null on error
- */
- const readBaselineMetadata = () => {
- if (!existsSync(baselineMetadataPath)) {
- return null;
- }
- try {
- return JSON.parse(readFileSync(baselineMetadataPath, 'utf8'));
- } catch {
- return null;
- }
- };
+ let { workingDir = process.cwd() } = context || {};
/**
* Read and parse report data with baseline metadata included
*/
- const readReportData = () => {
- if (!existsSync(reportDataPath)) {
- return null;
- }
+ let readReportData = () => {
+ let snapshotStore = createStateStore({ workingDir, mode: 'read' });
try {
- const data = JSON.parse(readFileSync(reportDataPath, 'utf8'));
- // Include baseline metadata for stats view
- data.baseline = readBaselineMetadata();
+ let data = snapshotStore.readReportData();
+ if (!data) {
+ return null;
+ }
+
+ data.baseline = snapshotStore.getBaselineMetadata();
return data;
- } catch {
- return null;
+ } finally {
+ snapshotStore.close();
}
};
/**
* Send SSE event to response
*/
- const sendEvent = (res, eventType, data) => {
+ let sendEvent = (res, eventType, data) => {
if (res.writableEnded) return;
res.write(`event: ${eventType}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
@@ -65,7 +44,7 @@ export function createEventsRouter(context) {
/**
* Build a lookup map from comparisons array keyed by id
*/
- const buildComparisonMap = comparisons => {
+ let buildComparisonMap = comparisons => {
let map = new Map();
for (let c of comparisons) {
map.set(c.id, c);
@@ -73,14 +52,14 @@ export function createEventsRouter(context) {
return map;
};
- const comparisonChanged = (oldComp, newComp) => {
+ let comparisonChanged = (oldComp, newComp) => {
return JSON.stringify(oldComp) !== JSON.stringify(newComp);
};
/**
* Extract summary fields (everything except comparisons) for diffing
*/
- const extractSummary = data => {
+ let extractSummary = data => {
let { comparisons: _c, ...summary } = data;
return summary;
};
@@ -88,7 +67,7 @@ export function createEventsRouter(context) {
/**
* Check if summary-level fields changed between old and new data
*/
- const summaryChanged = (oldData, newData) => {
+ let summaryChanged = (oldData, newData) => {
let oldSummary = extractSummary(oldData);
let newSummary = extractSummary(newData);
return JSON.stringify(oldSummary) !== JSON.stringify(newSummary);
@@ -98,7 +77,7 @@ export function createEventsRouter(context) {
* Send incremental updates by diffing old vs new report data.
* Returns true if any events were sent.
*/
- const sendIncrementalUpdates = (res, oldData, newData) => {
+ let sendIncrementalUpdates = (res, oldData, newData) => {
let sent = false;
let oldComparisons = oldData.comparisons || [];
let newComparisons = newData.comparisons || [];
@@ -137,6 +116,8 @@ export function createEventsRouter(context) {
return false;
}
+ let subscriptionStore = createStateStore({ workingDir, mode: 'read' });
+
// Set SSE headers
res.writeHead(200, {
'Content-Type': 'text/event-stream',
@@ -151,12 +132,11 @@ export function createEventsRouter(context) {
sendEvent(res, 'reportData', lastSentData);
}
- // Debounce file change events (fs.watch can fire multiple times)
- let debounceTimer = null;
- let watcher = null;
+ let closed = false;
+ let updateQueued = false;
- const sendUpdate = () => {
- const newData = readReportData();
+ let sendUpdate = () => {
+ let newData = readReportData();
if (!newData) return;
if (!lastSentData) {
@@ -171,45 +151,34 @@ export function createEventsRouter(context) {
lastSentData = newData;
};
- // Watch for file changes
- const vizzlyDir = join(workingDir, '.vizzly');
- if (existsSync(vizzlyDir)) {
- try {
- watcher = watch(
- vizzlyDir,
- { recursive: false },
- (_eventType, filename) => {
- // Only react to report-data.json changes
- if (filename === 'report-data.json') {
- // Debounce: wait 100ms after last change before sending
- if (debounceTimer) {
- clearTimeout(debounceTimer);
- }
- debounceTimer = setTimeout(sendUpdate, 100);
- }
- }
- );
- } catch {
- // File watching not available, client will fall back to polling
+ let queueUpdate = () => {
+ if (closed || updateQueued) {
+ return;
}
- }
+
+ updateQueued = true;
+ queueMicrotask(() => {
+ updateQueued = false;
+ if (closed) return;
+ sendUpdate();
+ });
+ };
+
+ let unsubscribe = subscriptionStore.subscribe(queueUpdate);
// Heartbeat to keep connection alive (every 30 seconds)
- const heartbeatInterval = setInterval(() => {
+ let heartbeatInterval = setInterval(() => {
if (!res.writableEnded) {
sendEvent(res, 'heartbeat', { timestamp: Date.now() });
}
}, 30000);
// Cleanup on connection close
- const cleanup = () => {
- if (debounceTimer) {
- clearTimeout(debounceTimer);
- }
+ let cleanup = () => {
+ closed = true;
clearInterval(heartbeatInterval);
- if (watcher) {
- watcher.close();
- }
+ unsubscribe();
+ subscriptionStore.close();
};
req.on('close', cleanup);
diff --git a/src/server/routers/health.js b/src/server/routers/health.js
index 64515528..84e64616 100644
--- a/src/server/routers/health.js
+++ b/src/server/routers/health.js
@@ -3,8 +3,7 @@
* Health check endpoint with diagnostics
*/
-import { existsSync, readFileSync } from 'node:fs';
-import { join } from 'node:path';
+import { createStateStore } from '../../tdd/state-store.js';
import { sendSuccess } from '../middleware/response.js';
/**
@@ -12,39 +11,29 @@ import { sendSuccess } from '../middleware/response.js';
* @param {Object} context - Router context
* @param {number} context.port - Server port
* @param {Object} context.screenshotHandler - Screenshot handler
+ * @param {string} context.workingDir - Working directory for report data
* @returns {Function} Route handler
*/
-export function createHealthRouter({ port, screenshotHandler }) {
+export function createHealthRouter({
+ port,
+ screenshotHandler,
+ workingDir = process.cwd(),
+}) {
return async function handleHealthRoute(req, res, pathname) {
if (req.method !== 'GET' || pathname !== '/health') {
return false;
}
- const reportDataPath = join(process.cwd(), '.vizzly', 'report-data.json');
- const baselineMetadataPath = join(
- process.cwd(),
- '.vizzly',
- 'baselines',
- 'metadata.json'
- );
-
let reportData = null;
let baselineInfo = null;
-
- if (existsSync(reportDataPath)) {
- try {
- reportData = JSON.parse(readFileSync(reportDataPath, 'utf8'));
- } catch {
- // Ignore read errors
- }
- }
-
- if (existsSync(baselineMetadataPath)) {
- try {
- baselineInfo = JSON.parse(readFileSync(baselineMetadataPath, 'utf8'));
- } catch {
- // Ignore read errors
- }
+ let stateStore = createStateStore({ workingDir, mode: 'read' });
+ try {
+ reportData = stateStore.readReportData();
+ baselineInfo = stateStore.getBaselineMetadata();
+ } catch {
+ // Ignore read errors
+ } finally {
+ stateStore.close();
}
sendSuccess(res, {
diff --git a/src/services/static-report-generator.js b/src/services/static-report-generator.js
index fc22c646..e7135689 100644
--- a/src/services/static-report-generator.js
+++ b/src/services/static-report-generator.js
@@ -16,6 +16,7 @@ import {
} from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
+import { createStateStore } from '../tdd/state-store.js';
let __dirname = dirname(fileURLToPath(import.meta.url));
@@ -154,9 +155,17 @@ export async function generateStaticReport(workingDir, options = {}) {
let vizzlyDir = join(workingDir, '.vizzly');
try {
- // Read report data
- let reportDataPath = join(vizzlyDir, 'report-data.json');
- if (!existsSync(reportDataPath)) {
+ let reportData = null;
+ let baselineMetadata = null;
+ let stateStore = createStateStore({ workingDir, mode: 'read' });
+ try {
+ reportData = stateStore.readReportData();
+ baselineMetadata = stateStore.getBaselineMetadata();
+ } finally {
+ stateStore.close();
+ }
+
+ if (!reportData) {
return {
success: false,
reportPath: null,
@@ -164,17 +173,7 @@ export async function generateStaticReport(workingDir, options = {}) {
};
}
- let reportData = JSON.parse(readFileSync(reportDataPath, 'utf8'));
-
- // Read baseline metadata if available
- let metadataPath = join(vizzlyDir, 'baselines', 'metadata.json');
- if (existsSync(metadataPath)) {
- try {
- reportData.baseline = JSON.parse(readFileSync(metadataPath, 'utf8'));
- } catch {
- // Ignore metadata read errors
- }
- }
+ reportData.baseline = baselineMetadata;
// Transform image URLs to relative paths
let transformedData = transformImageUrls(reportData);
diff --git a/src/tdd/index.js b/src/tdd/index.js
index 3d18a274..00748be0 100644
--- a/src/tdd/index.js
+++ b/src/tdd/index.js
@@ -19,7 +19,9 @@ export {
export {
createEmptyBaselineMetadata,
findScreenshotBySignature,
+ loadBaselineBuildMetadata,
loadBaselineMetadata,
+ saveBaselineBuildMetadata,
saveBaselineMetadata,
upsertScreenshotInMetadata,
} from './metadata/baseline-metadata.js';
diff --git a/src/tdd/metadata/baseline-metadata.js b/src/tdd/metadata/baseline-metadata.js
index 18c0aa3e..aabcdacd 100644
--- a/src/tdd/metadata/baseline-metadata.js
+++ b/src/tdd/metadata/baseline-metadata.js
@@ -1,50 +1,101 @@
/**
* Baseline Metadata I/O
*
- * Functions for reading and writing baseline metadata.json files.
- * These handle the local storage of baseline information.
+ * Functions for reading and writing baseline metadata in state storage.
*/
-import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
-import { join } from 'node:path';
+import { basename, dirname, resolve } from 'node:path';
+import { createStateStore } from '../state-store.js';
+
+function resolveWorkingDirFromBaselinePath(baselinePath) {
+ let resolvedPath = resolve(baselinePath);
+ let parent = dirname(resolvedPath);
+
+ if (
+ basename(resolvedPath) === 'baselines' &&
+ basename(parent) === '.vizzly'
+ ) {
+ return dirname(parent);
+ }
+
+ return resolvedPath;
+}
+
+function withStateStore(workingDir, mode, operation) {
+ let store = createStateStore({ workingDir, mode });
+
+ try {
+ return operation(store);
+ } finally {
+ store.close();
+ }
+}
/**
- * Load baseline metadata from disk
+ * Load baseline metadata from state storage
*
* @param {string} baselinePath - Path to baselines directory
* @returns {Object|null} Baseline metadata or null if not found
*/
-export function loadBaselineMetadata(baselinePath) {
- let metadataPath = join(baselinePath, 'metadata.json');
+export function loadBaselineMetadata(baselinePath, options = {}) {
+ let { mode = 'read' } = options;
+ let workingDir = resolveWorkingDirFromBaselinePath(baselinePath);
- if (!existsSync(metadataPath)) {
- return null;
- }
-
- try {
- let content = readFileSync(metadataPath, 'utf8');
- return JSON.parse(content);
- } catch (error) {
- // Log for debugging but return null - caller can handle missing metadata
- console.debug?.(`Failed to parse baseline metadata: ${error.message}`);
- return null;
- }
+ return withStateStore(workingDir, mode, store => {
+ try {
+ return store.getBaselineMetadata();
+ } catch (error) {
+ console.debug?.(`Failed to read baseline metadata: ${error.message}`);
+ return null;
+ }
+ });
}
/**
- * Save baseline metadata to disk
+ * Save baseline metadata to state storage
*
* @param {string} baselinePath - Path to baselines directory
* @param {Object} metadata - Metadata object to save
*/
export function saveBaselineMetadata(baselinePath, metadata) {
- // Ensure directory exists
- if (!existsSync(baselinePath)) {
- mkdirSync(baselinePath, { recursive: true });
- }
+ let workingDir = resolveWorkingDirFromBaselinePath(baselinePath);
+
+ withStateStore(workingDir, 'write', store => {
+ store.setBaselineMetadata(metadata);
+ });
+}
+
+/**
+ * Load baseline build metadata from state storage
+ *
+ * @param {string} workingDir - Working directory containing .vizzly
+ * @returns {Object|null} Baseline build metadata or null
+ */
+export function loadBaselineBuildMetadata(workingDir, options = {}) {
+ let { mode = 'read' } = options;
- let metadataPath = join(baselinePath, 'metadata.json');
- writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
+ return withStateStore(workingDir, mode, store => {
+ try {
+ return store.getBaselineBuildMetadata();
+ } catch (error) {
+ console.debug?.(
+ `Failed to read baseline build metadata: ${error.message}`
+ );
+ return null;
+ }
+ });
+}
+
+/**
+ * Save baseline build metadata to state storage
+ *
+ * @param {string} workingDir - Working directory containing .vizzly
+ * @param {Object} metadata - Metadata object to save
+ */
+export function saveBaselineBuildMetadata(workingDir, metadata) {
+ withStateStore(workingDir, 'write', store => {
+ store.setBaselineBuildMetadata(metadata);
+ });
}
/**
@@ -86,7 +137,7 @@ export function upsertScreenshotInMetadata(
}
let existingIndex = metadata.screenshots.findIndex(
- s => s.signature === signature
+ screenshot => screenshot.signature === signature
);
if (existingIndex >= 0) {
@@ -110,5 +161,9 @@ export function findScreenshotBySignature(metadata, signature) {
return null;
}
- return metadata.screenshots.find(s => s.signature === signature) || null;
+ return (
+ metadata.screenshots.find(
+ screenshot => screenshot.signature === signature
+ ) || null
+ );
}
diff --git a/src/tdd/metadata/hotspot-metadata.js b/src/tdd/metadata/hotspot-metadata.js
index 620dcbaa..08c9bb9d 100644
--- a/src/tdd/metadata/hotspot-metadata.js
+++ b/src/tdd/metadata/hotspot-metadata.js
@@ -1,60 +1,52 @@
/**
* Hotspot Metadata I/O
*
- * Functions for reading and writing hotspot data files.
+ * Functions for reading and writing hotspot metadata in state storage.
* Hotspots identify regions of screenshots that frequently change
* due to dynamic content (timestamps, animations, etc.).
*/
-import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
-import { join } from 'node:path';
+import { createStateStore } from '../state-store.js';
+
+function withStateStore(workingDir, mode, operation) {
+ let store = createStateStore({ workingDir, mode });
+
+ try {
+ return operation(store);
+ } finally {
+ store.close();
+ }
+}
/**
- * Load hotspot data from disk
+ * Load hotspot data from state storage
*
* @param {string} workingDir - Working directory containing .vizzly folder
* @returns {Object|null} Hotspot data keyed by screenshot name, or null if not found
*/
-export function loadHotspotMetadata(workingDir) {
- let hotspotsPath = join(workingDir, '.vizzly', 'hotspots.json');
-
- if (!existsSync(hotspotsPath)) {
- return null;
- }
+export function loadHotspotMetadata(workingDir, options = {}) {
+ let { mode = 'read' } = options;
- try {
- let content = readFileSync(hotspotsPath, 'utf8');
- let data = JSON.parse(content);
- return data.hotspots || null;
- } catch {
- // Return null for parse/read errors
- return null;
- }
+ return withStateStore(workingDir, mode, store => {
+ try {
+ return store.getHotspotMetadata();
+ } catch {
+ return null;
+ }
+ });
}
/**
- * Save hotspot data to disk
+ * Save hotspot data to state storage
*
* @param {string} workingDir - Working directory containing .vizzly folder
* @param {Object} hotspotData - Hotspot data keyed by screenshot name
* @param {Object} summary - Summary information about the hotspots
*/
export function saveHotspotMetadata(workingDir, hotspotData, summary = {}) {
- let vizzlyDir = join(workingDir, '.vizzly');
-
- // Ensure directory exists
- if (!existsSync(vizzlyDir)) {
- mkdirSync(vizzlyDir, { recursive: true });
- }
-
- let hotspotsPath = join(vizzlyDir, 'hotspots.json');
- let content = {
- downloadedAt: new Date().toISOString(),
- summary,
- hotspots: hotspotData,
- };
-
- writeFileSync(hotspotsPath, JSON.stringify(content, null, 2));
+ withStateStore(workingDir, 'write', store => {
+ store.setHotspotMetadata(hotspotData, summary);
+ });
}
/**
@@ -69,12 +61,10 @@ export function saveHotspotMetadata(workingDir, hotspotData, summary = {}) {
* @returns {Object|null} Hotspot analysis or null if not available
*/
export function getHotspotForScreenshot(cache, workingDir, screenshotName) {
- // Check cache first
if (cache.data?.[screenshotName]) {
return cache.data[screenshotName];
}
- // Load from disk if not yet loaded
if (!cache.loaded) {
cache.data = loadHotspotMetadata(workingDir);
cache.loaded = true;
diff --git a/src/tdd/metadata/region-metadata.js b/src/tdd/metadata/region-metadata.js
index b639e08a..bb219ad5 100644
--- a/src/tdd/metadata/region-metadata.js
+++ b/src/tdd/metadata/region-metadata.js
@@ -3,58 +3,49 @@
*
* Functions for reading and writing user-defined hotspot region data.
* Regions are 2D bounding boxes that users have confirmed as dynamic content areas.
- * Unlike historical hotspots (1D Y-bands), these are explicit definitions.
*/
-import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
-import { join } from 'node:path';
+import { createStateStore } from '../state-store.js';
+
+function withStateStore(workingDir, mode, operation) {
+ let store = createStateStore({ workingDir, mode });
+
+ try {
+ return operation(store);
+ } finally {
+ store.close();
+ }
+}
/**
- * Load region data from disk
+ * Load region data from state storage
*
* @param {string} workingDir - Working directory containing .vizzly folder
* @returns {Object|null} Region data keyed by screenshot name, or null if not found
*/
-export function loadRegionMetadata(workingDir) {
- let regionsPath = join(workingDir, '.vizzly', 'regions.json');
-
- if (!existsSync(regionsPath)) {
- return null;
- }
+export function loadRegionMetadata(workingDir, options = {}) {
+ let { mode = 'read' } = options;
- try {
- let content = readFileSync(regionsPath, 'utf8');
- let data = JSON.parse(content);
- return data.regions || null;
- } catch {
- // Return null for parse/read errors
- return null;
- }
+ return withStateStore(workingDir, mode, store => {
+ try {
+ return store.getRegionMetadata();
+ } catch {
+ return null;
+ }
+ });
}
/**
- * Save region data to disk
+ * Save region data to state storage
*
* @param {string} workingDir - Working directory containing .vizzly folder
* @param {Object} regionData - Region data keyed by screenshot name
* @param {Object} summary - Summary information about the regions
*/
export function saveRegionMetadata(workingDir, regionData, summary = {}) {
- let vizzlyDir = join(workingDir, '.vizzly');
-
- // Ensure directory exists
- if (!existsSync(vizzlyDir)) {
- mkdirSync(vizzlyDir, { recursive: true });
- }
-
- let regionsPath = join(vizzlyDir, 'regions.json');
- let content = {
- downloadedAt: new Date().toISOString(),
- summary,
- regions: regionData,
- };
-
- writeFileSync(regionsPath, JSON.stringify(content, null, 2));
+ withStateStore(workingDir, 'write', store => {
+ store.setRegionMetadata(regionData, summary);
+ });
}
/**
@@ -69,12 +60,10 @@ export function saveRegionMetadata(workingDir, regionData, summary = {}) {
* @returns {Object|null} Region data or null if not available
*/
export function getRegionsForScreenshot(cache, workingDir, screenshotName) {
- // Check cache first
if (cache.data?.[screenshotName]) {
return cache.data[screenshotName];
}
- // Load from disk if not yet loaded
if (!cache.loaded) {
cache.data = loadRegionMetadata(workingDir);
cache.loaded = true;
diff --git a/src/tdd/server-registry.js b/src/tdd/server-registry.js
index 4ab495e2..059d845c 100644
--- a/src/tdd/server-registry.js
+++ b/src/tdd/server-registry.js
@@ -1,18 +1,99 @@
import { execSync } from 'node:child_process';
import { randomBytes } from 'node:crypto';
-import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
+import { existsSync, mkdirSync, readFileSync } from 'node:fs';
import { createServer } from 'node:net';
import { homedir } from 'node:os';
import { join } from 'node:path';
+import BetterSqlite3 from 'better-sqlite3';
+
+let REGISTRY_MIGRATIONS = [
+ {
+ version: 1,
+ name: 'registry_servers',
+ sql: `
+ CREATE TABLE IF NOT EXISTS registry_meta (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL,
+ updated_at INTEGER NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS registry_servers (
+ id TEXT PRIMARY KEY,
+ port INTEGER NOT NULL UNIQUE,
+ pid INTEGER NOT NULL,
+ directory TEXT NOT NULL UNIQUE,
+ started_at TEXT NOT NULL,
+ config_path TEXT,
+ name TEXT,
+ log_file TEXT
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_registry_servers_started_at
+ ON registry_servers(started_at DESC);
+ `,
+ },
+];
+
+function applyRegistryMigrations(db) {
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS schema_migrations (
+ version INTEGER PRIMARY KEY,
+ name TEXT NOT NULL,
+ applied_at INTEGER NOT NULL
+ );
+ `);
+
+ let appliedRows = db
+ .prepare('SELECT version FROM schema_migrations ORDER BY version ASC')
+ .all();
+ let appliedVersions = new Set(appliedRows.map(row => Number(row.version)));
+
+ for (let migration of REGISTRY_MIGRATIONS) {
+ if (appliedVersions.has(migration.version)) {
+ continue;
+ }
+
+ let transaction = db.transaction(() => {
+ db.exec(migration.sql);
+ db.prepare(
+ `
+ INSERT INTO schema_migrations (version, name, applied_at)
+ VALUES (?, ?, ?)
+ `
+ ).run(migration.version, migration.name, Date.now());
+ });
+
+ transaction();
+ }
+}
+
+function mapServerRow(row) {
+ if (!row) {
+ return undefined;
+ }
+
+ return {
+ id: row.id,
+ port: row.port,
+ pid: row.pid,
+ directory: row.directory,
+ startedAt: row.started_at,
+ configPath: row.config_path,
+ name: row.name,
+ logFile: row.log_file,
+ };
+}
/**
- * Manages a global registry of running TDD servers at ~/.vizzly/servers.json
+ * Manages a global registry of running TDD servers at ~/.vizzly/servers.db
* Enables the menubar app to discover and manage multiple concurrent servers.
*/
export class ServerRegistry {
constructor() {
this.vizzlyHome = process.env.VIZZLY_HOME || join(homedir(), '.vizzly');
this.registryPath = join(this.vizzlyHome, 'servers.json');
+ this.dbPath = join(this.vizzlyHome, 'servers.db');
+ this.db = null;
}
/**
@@ -24,38 +105,186 @@ export class ServerRegistry {
}
}
- /**
- * Read the current registry, returning empty if it doesn't exist
- */
- read() {
+ openDb() {
+ if (this.db) {
+ return this.db;
+ }
+
+ this.ensureDirectory();
+ this.db = new BetterSqlite3(this.dbPath);
+
+ this.db.pragma('journal_mode = WAL');
+ this.db.pragma('synchronous = NORMAL');
+ this.db.pragma('busy_timeout = 5000');
+
+ applyRegistryMigrations(this.db);
+ this.maybeMigrateLegacyJson();
+
+ return this.db;
+ }
+
+ getMeta(key) {
+ let db = this.openDb();
+ let row = db
+ .prepare('SELECT value FROM registry_meta WHERE key = ?')
+ .get(key);
+ return row?.value ?? null;
+ }
+
+ setMeta(key, value) {
+ let db = this.openDb();
+ db.prepare(
+ `
+ INSERT INTO registry_meta (key, value, updated_at)
+ VALUES (?, ?, ?)
+ ON CONFLICT(key) DO UPDATE SET
+ value = excluded.value,
+ updated_at = excluded.updated_at
+ `
+ ).run(key, String(value), Date.now());
+ }
+
+ maybeMigrateLegacyJson() {
+ let db = this.db;
+ if (!db) {
+ return;
+ }
+
+ if (this.getMeta('legacy_json_migrated') === '1') {
+ return;
+ }
+
try {
- if (existsSync(this.registryPath)) {
- let data = JSON.parse(readFileSync(this.registryPath, 'utf8'));
- return {
- version: data.version || 1,
- servers: data.servers || [],
- };
+ let count = db
+ .prepare('SELECT COUNT(*) AS count FROM registry_servers')
+ .get().count;
+
+ if (count === 0 && existsSync(this.registryPath)) {
+ let raw = readFileSync(this.registryPath, 'utf8');
+ let legacy = JSON.parse(raw);
+ let servers = Array.isArray(legacy?.servers) ? legacy.servers : [];
+
+ if (servers.length > 0) {
+ let insert = db.prepare(`
+ INSERT INTO registry_servers (
+ id, port, pid, directory, started_at, config_path, name, log_file
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(id) DO UPDATE SET
+ port = excluded.port,
+ pid = excluded.pid,
+ directory = excluded.directory,
+ started_at = excluded.started_at,
+ config_path = excluded.config_path,
+ name = excluded.name,
+ log_file = excluded.log_file
+ `);
+ let removeExisting = db.prepare(
+ 'DELETE FROM registry_servers WHERE port = ? OR directory = ?'
+ );
+
+ let transaction = db.transaction(() => {
+ for (let server of servers) {
+ if (!server?.port || !server?.pid || !server?.directory) {
+ continue;
+ }
+
+ removeExisting.run(Number(server.port), server.directory);
+
+ insert.run(
+ server.id || randomBytes(8).toString('hex'),
+ Number(server.port),
+ Number(server.pid),
+ server.directory,
+ server.startedAt || new Date().toISOString(),
+ server.configPath || null,
+ server.name || null,
+ server.logFile || null
+ );
+ }
+ });
+
+ transaction();
+ }
}
- } catch (_err) {
- // Corrupted file, start fresh
+ } catch {
console.warn('Warning: Could not read server registry, starting fresh');
+ } finally {
+ this.setMeta('legacy_json_migrated', '1');
}
- return { version: 1, servers: [] };
}
/**
- * Write the registry to disk
+ * Read the current registry
+ */
+ read() {
+ return {
+ version: 1,
+ servers: this.list(),
+ };
+ }
+
+ /**
+ * Replace the registry entries
*/
write(registry) {
- this.ensureDirectory();
- writeFileSync(this.registryPath, JSON.stringify(registry, null, 2));
+ let db = this.openDb();
+ let servers = Array.isArray(registry?.servers) ? registry.servers : [];
+
+ let insert = db.prepare(`
+ INSERT INTO registry_servers (
+ id, port, pid, directory, started_at, config_path, name, log_file
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(id) DO UPDATE SET
+ port = excluded.port,
+ pid = excluded.pid,
+ directory = excluded.directory,
+ started_at = excluded.started_at,
+ config_path = excluded.config_path,
+ name = excluded.name,
+ log_file = excluded.log_file
+ `);
+ let removeExisting = db.prepare(
+ 'DELETE FROM registry_servers WHERE port = ? OR directory = ?'
+ );
+
+ let transaction = db.transaction(() => {
+ db.prepare('DELETE FROM registry_servers').run();
+
+ for (let server of servers) {
+ if (!server?.port || !server?.pid || !server?.directory) {
+ continue;
+ }
+
+ let port = Number(server.port);
+ let pid = Number(server.pid);
+ if (Number.isNaN(port) || Number.isNaN(pid)) {
+ continue;
+ }
+
+ // Ensure uniqueness within the incoming batch; later rows win.
+ removeExisting.run(port, server.directory);
+
+ insert.run(
+ server.id || randomBytes(8).toString('hex'),
+ port,
+ pid,
+ server.directory,
+ server.startedAt || new Date().toISOString(),
+ server.configPath || null,
+ server.name || null,
+ server.logFile || null
+ );
+ }
+ });
+
+ transaction();
+ this.notifyMenubar();
}
/**
* Register a new server in the registry
*/
register(serverInfo) {
- // Validate required fields
if (!serverInfo.pid || !serverInfo.port || !serverInfo.directory) {
throw new Error('Missing required fields: pid, port, directory');
}
@@ -67,29 +296,33 @@ export class ServerRegistry {
throw new Error('Invalid port or pid - must be numbers');
}
- let registry = this.read();
-
- // Remove any existing entry for this port or directory (shouldn't happen, but be safe)
- registry.servers = registry.servers.filter(
- s => s.port !== port && s.directory !== serverInfo.directory
- );
-
- // Add the new server
- registry.servers.push({
- id: serverInfo.id || randomBytes(8).toString('hex'),
- port,
- pid,
- directory: serverInfo.directory,
- startedAt: serverInfo.startedAt || new Date().toISOString(),
- configPath: serverInfo.configPath || null,
- name: serverInfo.name || null,
- logFile: serverInfo.logFile || null,
+ let db = this.openDb();
+
+ let transaction = db.transaction(() => {
+ db.prepare(
+ 'DELETE FROM registry_servers WHERE port = ? OR directory = ?'
+ ).run(port, serverInfo.directory);
+
+ db.prepare(`
+ INSERT INTO registry_servers (
+ id, port, pid, directory, started_at, config_path, name, log_file
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ `).run(
+ serverInfo.id || randomBytes(8).toString('hex'),
+ port,
+ pid,
+ serverInfo.directory,
+ serverInfo.startedAt || new Date().toISOString(),
+ serverInfo.configPath || null,
+ serverInfo.name || null,
+ serverInfo.logFile || null
+ );
});
- this.write(registry);
+ transaction();
this.notifyMenubar();
- return registry;
+ return this.read();
}
/**
@@ -98,84 +331,103 @@ export class ServerRegistry {
* When only one is provided, matches servers with that criteria
*/
unregister({ port, directory }) {
- let registry = this.read();
- let initialCount = registry.servers.length;
+ let db = this.openDb();
+ let result = { changes: 0 };
if (port && directory) {
- // Both specified - match servers with both port AND directory
- registry.servers = registry.servers.filter(
- s => !(s.port === port && s.directory === directory)
- );
+ result = db
+ .prepare(
+ 'DELETE FROM registry_servers WHERE port = ? AND directory = ?'
+ )
+ .run(Number(port), directory);
} else if (port) {
- registry.servers = registry.servers.filter(s => s.port !== port);
+ result = db
+ .prepare('DELETE FROM registry_servers WHERE port = ?')
+ .run(Number(port));
} else if (directory) {
- registry.servers = registry.servers.filter(
- s => s.directory !== directory
- );
+ result = db
+ .prepare('DELETE FROM registry_servers WHERE directory = ?')
+ .run(directory);
}
- if (registry.servers.length !== initialCount) {
- this.write(registry);
+ if (result.changes > 0) {
this.notifyMenubar();
}
- return registry;
+ return this.read();
}
/**
* Find a server by port or directory
*/
find({ port, directory }) {
- let registry = this.read();
+ let db = this.openDb();
if (port) {
- return registry.servers.find(s => s.port === port);
+ let row = db
+ .prepare('SELECT * FROM registry_servers WHERE port = ? LIMIT 1')
+ .get(Number(port));
+ return mapServerRow(row);
}
+
if (directory) {
- return registry.servers.find(s => s.directory === directory);
+ let row = db
+ .prepare('SELECT * FROM registry_servers WHERE directory = ? LIMIT 1')
+ .get(directory);
+ return mapServerRow(row);
}
- return null;
+
+ return undefined;
}
/**
* Get all registered servers
*/
list() {
- return this.read().servers;
+ let db = this.openDb();
+ let rows = db
+ .prepare('SELECT * FROM registry_servers ORDER BY started_at ASC')
+ .all();
+
+ return rows.map(mapServerRow);
}
/**
* Remove servers whose PIDs no longer exist (stale entries)
*/
cleanupStale() {
- let registry = this.read();
- let initialCount = registry.servers.length;
+ let db = this.openDb();
+ let servers = this.list();
+ let staleIds = [];
- registry.servers = registry.servers.filter(server => {
+ for (let server of servers) {
try {
- // Signal 0 doesn't kill, just checks if process exists
process.kill(server.pid, 0);
- return true;
- } catch (err) {
- // ESRCH = process doesn't exist, EPERM = exists but no permission (still valid)
- return err.code === 'EPERM';
+ } catch (error) {
+ if (error.code !== 'EPERM') {
+ staleIds.push(server.id);
+ }
}
- });
+ }
- if (registry.servers.length !== initialCount) {
- this.write(registry);
- this.notifyMenubar();
- return initialCount - registry.servers.length;
+ if (staleIds.length === 0) {
+ return 0;
}
- return 0;
+ let deleteById = db.prepare('DELETE FROM registry_servers WHERE id = ?');
+ let transaction = db.transaction(() => {
+ for (let id of staleIds) {
+ deleteById.run(id);
+ }
+ });
+
+ transaction();
+ this.notifyMenubar();
+ return staleIds.length;
}
/**
* Notify the menubar app that the registry changed
- *
- * Uses macOS notifyutil for instant Darwin notification delivery.
- * The menubar app listens for this in addition to file watching.
*/
notifyMenubar() {
if (process.platform !== 'darwin') return;
@@ -186,7 +438,7 @@ export class ServerRegistry {
timeout: 500,
});
} catch {
- // Non-fatal - menubar will still see changes via file watching
+ // Non-fatal
}
}
@@ -196,7 +448,7 @@ export class ServerRegistry {
*/
getUsedPorts() {
let registry = this.read();
- return new Set(registry.servers.map(s => s.port));
+ return new Set(registry.servers.map(server => server.port));
}
/**
@@ -206,7 +458,6 @@ export class ServerRegistry {
* @returns {Promise} Available port
*/
async findAvailablePort(startPort = 47392, maxAttempts = 100) {
- // Clean up stale entries first
this.cleanupStale();
let usedPorts = this.getUsedPorts();
@@ -214,19 +465,30 @@ export class ServerRegistry {
for (let i = 0; i < maxAttempts; i++) {
let port = startPort + i;
- // Skip if registered in our registry
if (usedPorts.has(port)) continue;
- // Check if port is actually free (not used by other apps)
let isFree = await isPortFree(port);
if (isFree) {
return port;
}
}
- // Fallback to default if nothing found (will fail later with clear error)
return startPort;
}
+
+ close() {
+ if (!this.db) {
+ return;
+ }
+
+ try {
+ this.db.close();
+ } catch {
+ // Ignore close failures
+ } finally {
+ this.db = null;
+ }
+ }
}
/**
@@ -242,7 +504,6 @@ async function isPortFree(port) {
if (err.code === 'EADDRINUSE') {
resolve(false);
} else {
- // Other errors - assume port is free
resolve(true);
}
});
diff --git a/src/tdd/services/hotspot-service.js b/src/tdd/services/hotspot-service.js
index 3ffa48a9..0578526a 100644
--- a/src/tdd/services/hotspot-service.js
+++ b/src/tdd/services/hotspot-service.js
@@ -30,7 +30,7 @@ export async function downloadHotspots(options) {
return { success: false, error: 'API returned no hotspot data' };
}
- // Save hotspots to disk
+ // Save hotspots to state storage
saveHotspotMetadata(workingDir, response.hotspots, response.summary);
// Calculate stats
diff --git a/src/tdd/services/region-service.js b/src/tdd/services/region-service.js
index 51059cf8..55132332 100644
--- a/src/tdd/services/region-service.js
+++ b/src/tdd/services/region-service.js
@@ -31,7 +31,7 @@ export async function downloadRegions(options) {
return { success: false, error: 'API returned no region data' };
}
- // Save regions to disk
+ // Save regions to state storage
saveRegionMetadata(workingDir, response.regions, response.summary);
// Calculate stats
diff --git a/src/tdd/state-store.js b/src/tdd/state-store.js
new file mode 100644
index 00000000..4b70092d
--- /dev/null
+++ b/src/tdd/state-store.js
@@ -0,0 +1,75 @@
+/**
+ * TDD State Store
+ *
+ * Public API facade for reporter state persistence.
+ *
+ * SQLite is the only supported backend.
+ */
+
+import { existsSync } from 'node:fs';
+import { join } from 'node:path';
+import { STATE_METADATA_KEYS } from './state-store/constants.js';
+import {
+ createSqliteStateStore,
+ getStateDbPath,
+} from './state-store/sqlite-store.js';
+
+export { STATE_METADATA_KEYS, createSqliteStateStore, getStateDbPath };
+
+export function createStateStore(options = {}) {
+ return createSqliteStateStore(options);
+}
+
+let LEGACY_STATE_FILES = [
+ 'report-data.json',
+ 'comparison-details.json',
+ join('baselines', 'metadata.json'),
+ 'hotspots.json',
+ 'regions.json',
+ 'baseline-metadata.json',
+];
+
+/**
+ * Bootstrap legacy JSON state into SQLite once, the first time CLI runs
+ * in a project that has legacy files and no state.db yet.
+ *
+ * @returns {boolean} true when bootstrap migration was executed
+ */
+export function bootstrapLegacyStateIfNeeded(options = {}) {
+ let {
+ workingDir = process.cwd(),
+ output = {},
+ createStore = createStateStore,
+ fs = {},
+ joinPath = join,
+ } = options;
+
+ let { existsSync: existsSyncImpl = existsSync } = fs;
+ let vizzlyDir = joinPath(workingDir, '.vizzly');
+ let stateDbPath = joinPath(vizzlyDir, 'state.db');
+
+ if (!existsSyncImpl(vizzlyDir) || existsSyncImpl(stateDbPath)) {
+ return false;
+ }
+
+ let hasLegacyFiles = LEGACY_STATE_FILES.some(relativePath =>
+ existsSyncImpl(joinPath(vizzlyDir, relativePath))
+ );
+
+ if (!hasLegacyFiles) {
+ return false;
+ }
+
+ try {
+ let store = createStore({ workingDir, output, mode: 'write' });
+ store.close();
+ output.debug?.('state', 'bootstrapped legacy JSON state to SQLite');
+ return true;
+ } catch (error) {
+ output.debug?.(
+ 'state',
+ `legacy bootstrap migration skipped: ${error.message}`
+ );
+ return false;
+ }
+}
diff --git a/src/tdd/state-store/constants.js b/src/tdd/state-store/constants.js
new file mode 100644
index 00000000..84f2c0f8
--- /dev/null
+++ b/src/tdd/state-store/constants.js
@@ -0,0 +1,6 @@
+export let STATE_METADATA_KEYS = {
+ baseline: 'baseline_metadata',
+ hotspot: 'hotspot_metadata',
+ region: 'region_metadata',
+ baselineBuild: 'baseline_build_metadata',
+};
diff --git a/src/tdd/state-store/events.js b/src/tdd/state-store/events.js
new file mode 100644
index 00000000..99fb1abe
--- /dev/null
+++ b/src/tdd/state-store/events.js
@@ -0,0 +1,34 @@
+import { EventEmitter } from 'node:events';
+
+let stateEmitters = new Map();
+
+function getStateEmitter(workingDir) {
+ let emitter = stateEmitters.get(workingDir);
+ if (!emitter) {
+ emitter = new EventEmitter();
+ emitter.setMaxListeners(100);
+ stateEmitters.set(workingDir, emitter);
+ }
+ return emitter;
+}
+
+export function emitStateChanged(workingDir) {
+ let emitter = stateEmitters.get(workingDir);
+ if (!emitter) {
+ return;
+ }
+
+ emitter.emit('changed');
+}
+
+export function subscribeToStateChanges(workingDir, listener) {
+ let emitter = getStateEmitter(workingDir);
+ emitter.on('changed', listener);
+ return () => {
+ emitter.off('changed', listener);
+
+ if (emitter.listenerCount('changed') === 0) {
+ stateEmitters.delete(workingDir);
+ }
+ };
+}
diff --git a/src/tdd/state-store/migrations.js b/src/tdd/state-store/migrations.js
new file mode 100644
index 00000000..1bb1589c
--- /dev/null
+++ b/src/tdd/state-store/migrations.js
@@ -0,0 +1,100 @@
+let STATE_SCHEMA_MIGRATIONS = [
+ {
+ version: 1,
+ name: 'core_report_state',
+ sql: `
+ CREATE TABLE IF NOT EXISTS kv (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL,
+ updated_at INTEGER NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS comparisons (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ status TEXT NOT NULL,
+ initial_status TEXT,
+ signature TEXT,
+ baseline TEXT,
+ current TEXT,
+ diff TEXT,
+ properties_json TEXT NOT NULL,
+ threshold REAL,
+ min_cluster_size INTEGER,
+ diff_percentage REAL,
+ diff_count INTEGER,
+ reason TEXT,
+ total_pixels INTEGER,
+ aa_pixels_ignored INTEGER,
+ aa_percentage REAL,
+ height_diff INTEGER,
+ error TEXT,
+ original_name TEXT,
+ has_diff_clusters INTEGER NOT NULL DEFAULT 0,
+ has_confirmed_regions INTEGER NOT NULL DEFAULT 0,
+ timestamp INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_comparisons_status
+ ON comparisons(status);
+
+ CREATE INDEX IF NOT EXISTS idx_comparisons_signature
+ ON comparisons(signature);
+
+ CREATE TABLE IF NOT EXISTS comparison_details (
+ id TEXT PRIMARY KEY REFERENCES comparisons(id) ON DELETE CASCADE,
+ details_json TEXT NOT NULL,
+ updated_at INTEGER NOT NULL
+ );
+ `,
+ },
+ {
+ version: 2,
+ name: 'metadata_state',
+ sql: `
+ CREATE TABLE IF NOT EXISTS state_metadata (
+ key TEXT PRIMARY KEY,
+ value_json TEXT NOT NULL,
+ updated_at INTEGER NOT NULL
+ );
+ `,
+ },
+];
+
+export function applySchemaMigrations(db, output = {}) {
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS schema_migrations (
+ version INTEGER PRIMARY KEY,
+ name TEXT NOT NULL,
+ applied_at INTEGER NOT NULL
+ );
+ `);
+
+ let applied = db
+ .prepare('SELECT version FROM schema_migrations ORDER BY version ASC')
+ .all();
+ let appliedVersions = new Set(applied.map(row => Number(row.version)));
+
+ for (let migration of STATE_SCHEMA_MIGRATIONS) {
+ if (appliedVersions.has(migration.version)) {
+ continue;
+ }
+
+ let transaction = db.transaction(() => {
+ db.exec(migration.sql);
+ db.prepare(
+ `
+ INSERT INTO schema_migrations (version, name, applied_at)
+ VALUES (?, ?, ?)
+ `
+ ).run(migration.version, migration.name, Date.now());
+ });
+
+ transaction();
+ output.debug?.(
+ 'state',
+ `applied migration v${migration.version}: ${migration.name}`
+ );
+ }
+}
diff --git a/src/tdd/state-store/sqlite-store.js b/src/tdd/state-store/sqlite-store.js
new file mode 100644
index 00000000..3a6cd0ef
--- /dev/null
+++ b/src/tdd/state-store/sqlite-store.js
@@ -0,0 +1,845 @@
+import { existsSync, mkdirSync, readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import BetterSqlite3 from 'better-sqlite3';
+import { STATE_METADATA_KEYS } from './constants.js';
+import { emitStateChanged, subscribeToStateChanges } from './events.js';
+import { applySchemaMigrations } from './migrations.js';
+import {
+ buildSummary,
+ hasReportData,
+ mapComparisonRow,
+ normalizeComparison,
+ normalizeHotspotBundle,
+ normalizeRegionBundle,
+ parseJson,
+} from './utils.js';
+
+export function getStateDbPath(workingDir) {
+ return join(workingDir, '.vizzly', 'state.db');
+}
+
+export function createSqliteStateStore(options = {}) {
+ let {
+ workingDir = process.cwd(),
+ output = {},
+ mode = 'read',
+ Database,
+ fs = {},
+ joinPath = join,
+ dbPath = null,
+ } = options;
+
+ if (mode !== 'read' && mode !== 'write') {
+ throw new Error(`Invalid state store mode: ${mode}`);
+ }
+
+ let isWriteMode = mode === 'write';
+
+ let {
+ existsSync: existsSyncImpl = existsSync,
+ mkdirSync: mkdirSyncImpl = mkdirSync,
+ readFileSync: readFileSyncImpl = readFileSync,
+ } = fs;
+
+ let vizzlyDir = joinPath(workingDir, '.vizzly');
+ let resolvedDbPath = dbPath || joinPath(vizzlyDir, 'state.db');
+ let DatabaseImpl = Database || BetterSqlite3;
+ let db = null;
+ let shouldRunLegacyMigration = false;
+
+ function assertWriteMode(methodName) {
+ if (isWriteMode) {
+ return;
+ }
+
+ throw new Error(
+ `State store is read-only. '${methodName}' requires mode: 'write'.`
+ );
+ }
+
+ if (isWriteMode) {
+ let dbExistedBeforeOpen = existsSyncImpl(resolvedDbPath);
+
+ if (!existsSyncImpl(vizzlyDir)) {
+ mkdirSyncImpl(vizzlyDir, { recursive: true });
+ }
+
+ let dbDirectory = dirname(resolvedDbPath);
+ if (!existsSyncImpl(dbDirectory)) {
+ mkdirSyncImpl(dbDirectory, { recursive: true });
+ }
+
+ db = new DatabaseImpl(resolvedDbPath);
+ db.pragma('journal_mode = WAL');
+ db.pragma('synchronous = NORMAL');
+ db.pragma('foreign_keys = ON');
+ db.pragma('busy_timeout = 5000');
+ applySchemaMigrations(db, output);
+ // Legacy JSON is a one-time bootstrap into a freshly created state.db.
+ shouldRunLegacyMigration = !dbExistedBeforeOpen;
+ } else if (existsSyncImpl(resolvedDbPath)) {
+ db = new DatabaseImpl(resolvedDbPath, {
+ readonly: true,
+ fileMustExist: true,
+ });
+ db.pragma('busy_timeout = 5000');
+ }
+
+ if (!db) {
+ return {
+ backend: 'sqlite',
+ mode,
+
+ readReportData() {
+ return null;
+ },
+
+ replaceReportData() {
+ assertWriteMode('replaceReportData');
+ },
+
+ upsertComparison() {
+ assertWriteMode('upsertComparison');
+ },
+
+ getComparisonByIdOrSignatureOrName() {
+ return null;
+ },
+
+ upsertComparisonDetails() {
+ assertWriteMode('upsertComparisonDetails');
+ },
+
+ getComparisonDetails() {
+ return null;
+ },
+
+ removeComparisonDetails() {
+ assertWriteMode('removeComparisonDetails');
+ },
+
+ deleteComparison() {
+ assertWriteMode('deleteComparison');
+ },
+
+ resetReportData() {
+ assertWriteMode('resetReportData');
+ },
+
+ getMetadata(_key, fallback = null) {
+ return fallback;
+ },
+
+ getSchemaVersion() {
+ return 0;
+ },
+
+ setMetadata() {
+ assertWriteMode('setMetadata');
+ },
+
+ removeMetadata() {
+ assertWriteMode('removeMetadata');
+ return false;
+ },
+
+ getBaselineMetadata() {
+ return null;
+ },
+
+ setBaselineMetadata() {
+ assertWriteMode('setBaselineMetadata');
+ },
+
+ clearBaselineMetadata() {
+ assertWriteMode('clearBaselineMetadata');
+ return false;
+ },
+
+ removeBaselineScreenshot() {
+ assertWriteMode('removeBaselineScreenshot');
+ return false;
+ },
+
+ getHotspotBundle() {
+ return null;
+ },
+
+ getHotspotMetadata() {
+ return null;
+ },
+
+ setHotspotMetadata() {
+ assertWriteMode('setHotspotMetadata');
+ },
+
+ clearHotspotMetadata() {
+ assertWriteMode('clearHotspotMetadata');
+ return false;
+ },
+
+ getRegionBundle() {
+ return null;
+ },
+
+ getRegionMetadata() {
+ return null;
+ },
+
+ setRegionMetadata() {
+ assertWriteMode('setRegionMetadata');
+ },
+
+ clearRegionMetadata() {
+ assertWriteMode('clearRegionMetadata');
+ return false;
+ },
+
+ getBaselineBuildMetadata() {
+ return null;
+ },
+
+ setBaselineBuildMetadata() {
+ assertWriteMode('setBaselineBuildMetadata');
+ },
+
+ clearBaselineBuildMetadata() {
+ assertWriteMode('clearBaselineBuildMetadata');
+ return false;
+ },
+
+ subscribe(listener) {
+ return subscribeToStateChanges(workingDir, listener);
+ },
+
+ close() {
+ // No-op
+ },
+ };
+ }
+
+ let getKvStmt = db.prepare('SELECT value FROM kv WHERE key = ?');
+ let setKvStmt = db.prepare(`
+ INSERT INTO kv (key, value, updated_at)
+ VALUES (?, ?, ?)
+ ON CONFLICT(key) DO UPDATE SET
+ value = excluded.value,
+ updated_at = excluded.updated_at
+ `);
+
+ let listComparisonsStmt = db.prepare(`
+ SELECT * FROM comparisons
+ ORDER BY timestamp ASC, updated_at ASC, id ASC
+ `);
+
+ let getComparisonByIdStmt = db.prepare(
+ 'SELECT * FROM comparisons WHERE id = ?'
+ );
+ let getComparisonBySignatureStmt = db.prepare(
+ 'SELECT * FROM comparisons WHERE signature = ? LIMIT 1'
+ );
+ let getComparisonByNameStmt = db.prepare(
+ 'SELECT * FROM comparisons WHERE name = ? LIMIT 1'
+ );
+
+ let upsertComparisonStmt = db.prepare(`
+ INSERT INTO comparisons (
+ id, name, status, initial_status, signature, baseline, current, diff,
+ properties_json, threshold, min_cluster_size, diff_percentage, diff_count,
+ reason, total_pixels, aa_pixels_ignored, aa_percentage, height_diff, error,
+ original_name, has_diff_clusters, has_confirmed_regions, timestamp, updated_at
+ ) VALUES (
+ @id, @name, @status, @initial_status, @signature, @baseline, @current, @diff,
+ @properties_json, @threshold, @min_cluster_size, @diff_percentage, @diff_count,
+ @reason, @total_pixels, @aa_pixels_ignored, @aa_percentage, @height_diff, @error,
+ @original_name, @has_diff_clusters, @has_confirmed_regions, @timestamp, @updated_at
+ )
+ ON CONFLICT(id) DO UPDATE SET
+ name = excluded.name,
+ status = excluded.status,
+ initial_status = excluded.initial_status,
+ signature = excluded.signature,
+ baseline = excluded.baseline,
+ current = excluded.current,
+ diff = excluded.diff,
+ properties_json = excluded.properties_json,
+ threshold = excluded.threshold,
+ min_cluster_size = excluded.min_cluster_size,
+ diff_percentage = excluded.diff_percentage,
+ diff_count = excluded.diff_count,
+ reason = excluded.reason,
+ total_pixels = excluded.total_pixels,
+ aa_pixels_ignored = excluded.aa_pixels_ignored,
+ aa_percentage = excluded.aa_percentage,
+ height_diff = excluded.height_diff,
+ error = excluded.error,
+ original_name = excluded.original_name,
+ has_diff_clusters = excluded.has_diff_clusters,
+ has_confirmed_regions = excluded.has_confirmed_regions,
+ timestamp = excluded.timestamp,
+ updated_at = excluded.updated_at
+ `);
+
+ let clearComparisonsStmt = db.prepare('DELETE FROM comparisons');
+ let deleteComparisonStmt = db.prepare('DELETE FROM comparisons WHERE id = ?');
+
+ let getDetailsStmt = db.prepare(
+ 'SELECT details_json FROM comparison_details WHERE id = ?'
+ );
+ let upsertDetailsStmt = db.prepare(`
+ INSERT INTO comparison_details (id, details_json, updated_at)
+ VALUES (?, ?, ?)
+ ON CONFLICT(id) DO UPDATE SET
+ details_json = excluded.details_json,
+ updated_at = excluded.updated_at
+ `);
+ let deleteDetailsStmt = db.prepare(
+ 'DELETE FROM comparison_details WHERE id = ?'
+ );
+ let clearDetailsStmt = db.prepare('DELETE FROM comparison_details');
+
+ let countComparisonsStmt = db.prepare(
+ 'SELECT COUNT(*) AS count FROM comparisons'
+ );
+
+ let getMetadataStmt = db.prepare(
+ 'SELECT value_json FROM state_metadata WHERE key = ?'
+ );
+ let setMetadataStmt = db.prepare(`
+ INSERT INTO state_metadata (key, value_json, updated_at)
+ VALUES (?, ?, ?)
+ ON CONFLICT(key) DO UPDATE SET
+ value_json = excluded.value_json,
+ updated_at = excluded.updated_at
+ `);
+ let removeMetadataStmt = db.prepare(
+ 'DELETE FROM state_metadata WHERE key = ?'
+ );
+ let getSchemaVersionStmt = db.prepare(`
+ SELECT COALESCE(MAX(version), 0) AS version
+ FROM schema_migrations
+ `);
+
+ function getKey(key) {
+ let row = getKvStmt.get(key);
+ return row?.value ?? null;
+ }
+
+ function setKey(key, value) {
+ assertWriteMode('setKey');
+ setKvStmt.run(key, String(value), Date.now());
+ }
+
+ function setReportInitialized(timestamp = Date.now()) {
+ setKey('report_initialized', '1');
+ setKey('report_timestamp', String(timestamp));
+ }
+
+ function getMetadataInternal(key, fallback = null) {
+ let row = getMetadataStmt.get(key);
+ if (!row) {
+ return fallback;
+ }
+
+ return parseJson(row.value_json, fallback);
+ }
+
+ function setMetadataInternal(key, value, emit = true) {
+ let serialized = JSON.stringify(value == null ? null : value);
+ setMetadataStmt.run(key, serialized, Date.now());
+ if (emit) {
+ emitStateChanged(workingDir);
+ }
+ }
+
+ function removeMetadataInternal(key, emit = true) {
+ let result = removeMetadataStmt.run(key);
+ if (emit && result.changes > 0) {
+ emitStateChanged(workingDir);
+ }
+ return result.changes > 0;
+ }
+
+ function getBaselineMetadata() {
+ return getMetadataInternal(STATE_METADATA_KEYS.baseline, null);
+ }
+
+ function setBaselineMetadata(metadata, emit = true) {
+ setMetadataInternal(STATE_METADATA_KEYS.baseline, metadata, emit);
+ }
+
+ function clearBaselineMetadata(emit = true) {
+ return removeMetadataInternal(STATE_METADATA_KEYS.baseline, emit);
+ }
+
+ function removeBaselineScreenshot(signature) {
+ if (!signature) {
+ return false;
+ }
+
+ let metadata = getBaselineMetadata();
+ if (!metadata || !Array.isArray(metadata.screenshots)) {
+ return false;
+ }
+
+ let originalLength = metadata.screenshots.length;
+ metadata.screenshots = metadata.screenshots.filter(
+ screenshot => screenshot.signature !== signature
+ );
+
+ if (metadata.screenshots.length === originalLength) {
+ return false;
+ }
+
+ setBaselineMetadata(metadata, true);
+ return true;
+ }
+
+ function getHotspotBundle() {
+ return getMetadataInternal(STATE_METADATA_KEYS.hotspot, null);
+ }
+
+ function getHotspotMetadata() {
+ let bundle = getHotspotBundle();
+ return bundle?.hotspots || null;
+ }
+
+ function setHotspotMetadata(hotspotData, summary = {}, emit = true) {
+ setMetadataInternal(
+ STATE_METADATA_KEYS.hotspot,
+ {
+ downloadedAt: new Date().toISOString(),
+ summary,
+ hotspots: hotspotData || {},
+ },
+ emit
+ );
+ }
+
+ function clearHotspotMetadata(emit = true) {
+ return removeMetadataInternal(STATE_METADATA_KEYS.hotspot, emit);
+ }
+
+ function getRegionBundle() {
+ return getMetadataInternal(STATE_METADATA_KEYS.region, null);
+ }
+
+ function getRegionMetadata() {
+ let bundle = getRegionBundle();
+ return bundle?.regions || null;
+ }
+
+ function setRegionMetadata(regionData, summary = {}, emit = true) {
+ setMetadataInternal(
+ STATE_METADATA_KEYS.region,
+ {
+ downloadedAt: new Date().toISOString(),
+ summary,
+ regions: regionData || {},
+ },
+ emit
+ );
+ }
+
+ function clearRegionMetadata(emit = true) {
+ return removeMetadataInternal(STATE_METADATA_KEYS.region, emit);
+ }
+
+ function getBaselineBuildMetadata() {
+ return getMetadataInternal(STATE_METADATA_KEYS.baselineBuild, null);
+ }
+
+ function setBaselineBuildMetadata(metadata, emit = true) {
+ setMetadataInternal(STATE_METADATA_KEYS.baselineBuild, metadata, emit);
+ }
+
+ function clearBaselineBuildMetadata(emit = true) {
+ return removeMetadataInternal(STATE_METADATA_KEYS.baselineBuild, emit);
+ }
+
+ function replaceReportDataInternal(
+ reportData,
+ detailsById = null,
+ emit = true
+ ) {
+ let comparisons = Array.isArray(reportData?.comparisons)
+ ? reportData.comparisons
+ : [];
+ let timestamp = Number(reportData?.timestamp) || Date.now();
+
+ let transaction = db.transaction(() => {
+ clearDetailsStmt.run();
+ clearComparisonsStmt.run();
+
+ for (let comparison of comparisons) {
+ if (!comparison?.id || !comparison?.name || !comparison?.status) {
+ continue;
+ }
+
+ let normalized = normalizeComparison(
+ comparison,
+ comparison.initialStatus || comparison.status
+ );
+ upsertComparisonStmt.run(normalized);
+ }
+
+ if (detailsById && typeof detailsById === 'object') {
+ for (let [id, details] of Object.entries(detailsById)) {
+ upsertDetailsStmt.run(id, JSON.stringify(details || {}), Date.now());
+ }
+ }
+
+ setReportInitialized(timestamp);
+ });
+
+ transaction();
+
+ if (emit) {
+ emitStateChanged(workingDir);
+ }
+ }
+
+ function maybeMigrateLegacyJson() {
+ let hasRows = countComparisonsStmt.get().count > 0;
+ let initialized = getKey('report_initialized') === '1';
+ if (hasRows || initialized) {
+ return;
+ }
+
+ let reportPath = joinPath(vizzlyDir, 'report-data.json');
+ let detailsPath = joinPath(vizzlyDir, 'comparison-details.json');
+
+ if (!existsSyncImpl(reportPath)) {
+ return;
+ }
+
+ try {
+ let reportData = parseJson(readFileSyncImpl(reportPath, 'utf8'), null);
+ if (!hasReportData(reportData)) {
+ return;
+ }
+
+ let details = {};
+ if (existsSyncImpl(detailsPath)) {
+ details = parseJson(readFileSyncImpl(detailsPath, 'utf8'), {});
+ }
+
+ replaceReportDataInternal(reportData, details, false);
+ output.debug?.('state', 'migrated legacy report state JSON to SQLite');
+ } catch (error) {
+ output.debug?.(
+ 'state',
+ `legacy report JSON migration skipped: ${error.message}`
+ );
+ }
+ }
+
+ function maybeMigrateLegacyMetadataJson() {
+ let baselineMetadataPath = joinPath(
+ vizzlyDir,
+ 'baselines',
+ 'metadata.json'
+ );
+ let hotspotMetadataPath = joinPath(vizzlyDir, 'hotspots.json');
+ let regionMetadataPath = joinPath(vizzlyDir, 'regions.json');
+ let baselineBuildMetadataPath = joinPath(
+ vizzlyDir,
+ 'baseline-metadata.json'
+ );
+
+ if (existsSyncImpl(baselineMetadataPath) && !getBaselineMetadata()) {
+ try {
+ let baselineMetadata = parseJson(
+ readFileSyncImpl(baselineMetadataPath, 'utf8'),
+ null
+ );
+ if (baselineMetadata) {
+ setBaselineMetadata(baselineMetadata, false);
+ output.debug?.(
+ 'state',
+ 'migrated baselines/metadata.json to SQLite metadata state'
+ );
+ }
+ } catch (error) {
+ output.debug?.(
+ 'state',
+ `legacy baselines/metadata.json migration skipped: ${error.message}`
+ );
+ }
+ }
+
+ if (existsSyncImpl(hotspotMetadataPath) && !getHotspotBundle()) {
+ try {
+ let rawHotspots = parseJson(
+ readFileSyncImpl(hotspotMetadataPath, 'utf8'),
+ null
+ );
+ let hotspotBundle = normalizeHotspotBundle(rawHotspots);
+ if (hotspotBundle) {
+ setMetadataInternal(
+ STATE_METADATA_KEYS.hotspot,
+ hotspotBundle,
+ false
+ );
+ output.debug?.(
+ 'state',
+ 'migrated hotspots.json to SQLite metadata state'
+ );
+ }
+ } catch (error) {
+ output.debug?.(
+ 'state',
+ `legacy hotspots.json migration skipped: ${error.message}`
+ );
+ }
+ }
+
+ if (existsSyncImpl(regionMetadataPath) && !getRegionBundle()) {
+ try {
+ let rawRegions = parseJson(
+ readFileSyncImpl(regionMetadataPath, 'utf8'),
+ null
+ );
+ let regionBundle = normalizeRegionBundle(rawRegions);
+ if (regionBundle) {
+ setMetadataInternal(STATE_METADATA_KEYS.region, regionBundle, false);
+ output.debug?.(
+ 'state',
+ 'migrated regions.json to SQLite metadata state'
+ );
+ }
+ } catch (error) {
+ output.debug?.(
+ 'state',
+ `legacy regions.json migration skipped: ${error.message}`
+ );
+ }
+ }
+
+ if (
+ existsSyncImpl(baselineBuildMetadataPath) &&
+ !getBaselineBuildMetadata()
+ ) {
+ try {
+ let baselineBuildMetadata = parseJson(
+ readFileSyncImpl(baselineBuildMetadataPath, 'utf8'),
+ null
+ );
+ if (baselineBuildMetadata) {
+ setBaselineBuildMetadata(baselineBuildMetadata, false);
+ output.debug?.(
+ 'state',
+ 'migrated baseline-metadata.json to SQLite metadata state'
+ );
+ }
+ } catch (error) {
+ output.debug?.(
+ 'state',
+ `legacy baseline-metadata.json migration skipped: ${error.message}`
+ );
+ }
+ }
+ }
+
+ if (shouldRunLegacyMigration) {
+ maybeMigrateLegacyJson();
+ maybeMigrateLegacyMetadataJson();
+ }
+
+ return {
+ backend: 'sqlite',
+ mode,
+
+ readReportData() {
+ let comparisons = listComparisonsStmt.all().map(mapComparisonRow);
+ let initialized = getKey('report_initialized') === '1';
+
+ if (!initialized && comparisons.length === 0) {
+ return null;
+ }
+
+ let timestamp = Number(getKey('report_timestamp')) || Date.now();
+
+ return {
+ timestamp,
+ comparisons,
+ summary: buildSummary(comparisons),
+ };
+ },
+
+ replaceReportData(reportData, detailsById = null) {
+ assertWriteMode('replaceReportData');
+ replaceReportDataInternal(reportData, detailsById, true);
+ },
+
+ upsertComparison(comparison) {
+ assertWriteMode('upsertComparison');
+ if (!comparison?.id || !comparison?.name || !comparison?.status) {
+ throw new Error('Comparison must include id, name, and status');
+ }
+
+ let transaction = db.transaction(() => {
+ let existing = getComparisonByIdStmt.get(comparison.id);
+ let normalized = normalizeComparison(
+ comparison,
+ existing?.initial_status || comparison.initialStatus
+ );
+ upsertComparisonStmt.run(normalized);
+ setReportInitialized(Date.now());
+ });
+
+ transaction();
+ emitStateChanged(workingDir);
+ },
+
+ getComparisonByIdOrSignatureOrName(value) {
+ let row = getComparisonByIdStmt.get(value);
+ if (!row) {
+ row = getComparisonBySignatureStmt.get(value);
+ }
+ if (!row) {
+ row = getComparisonByNameStmt.get(value);
+ }
+ return mapComparisonRow(row);
+ },
+
+ upsertComparisonDetails(id, details) {
+ assertWriteMode('upsertComparisonDetails');
+ upsertDetailsStmt.run(id, JSON.stringify(details || {}), Date.now());
+ },
+
+ getComparisonDetails(id) {
+ let row = getDetailsStmt.get(id);
+ if (!row) return null;
+ return parseJson(row.details_json, null);
+ },
+
+ removeComparisonDetails(id) {
+ assertWriteMode('removeComparisonDetails');
+ deleteDetailsStmt.run(id);
+ },
+
+ deleteComparison(id) {
+ assertWriteMode('deleteComparison');
+ let transaction = db.transaction(() => {
+ deleteDetailsStmt.run(id);
+ deleteComparisonStmt.run(id);
+ setReportInitialized(Date.now());
+ });
+
+ transaction();
+ emitStateChanged(workingDir);
+ },
+
+ resetReportData() {
+ assertWriteMode('resetReportData');
+ let transaction = db.transaction(() => {
+ clearDetailsStmt.run();
+ clearComparisonsStmt.run();
+ setReportInitialized(Date.now());
+ });
+
+ transaction();
+ emitStateChanged(workingDir);
+ },
+
+ getMetadata(key, fallback = null) {
+ return getMetadataInternal(key, fallback);
+ },
+
+ getSchemaVersion() {
+ return Number(getSchemaVersionStmt.get().version) || 0;
+ },
+
+ setMetadata(key, value) {
+ assertWriteMode('setMetadata');
+ setMetadataInternal(key, value, true);
+ },
+
+ removeMetadata(key) {
+ assertWriteMode('removeMetadata');
+ return removeMetadataInternal(key, true);
+ },
+
+ getBaselineMetadata() {
+ return getBaselineMetadata();
+ },
+
+ setBaselineMetadata(metadata) {
+ assertWriteMode('setBaselineMetadata');
+ setBaselineMetadata(metadata, true);
+ },
+
+ clearBaselineMetadata() {
+ assertWriteMode('clearBaselineMetadata');
+ return clearBaselineMetadata(true);
+ },
+
+ removeBaselineScreenshot(signature) {
+ assertWriteMode('removeBaselineScreenshot');
+ return removeBaselineScreenshot(signature);
+ },
+
+ getHotspotBundle() {
+ return getHotspotBundle();
+ },
+
+ getHotspotMetadata() {
+ return getHotspotMetadata();
+ },
+
+ setHotspotMetadata(hotspotData, summary = {}) {
+ assertWriteMode('setHotspotMetadata');
+ setHotspotMetadata(hotspotData, summary, true);
+ },
+
+ clearHotspotMetadata() {
+ assertWriteMode('clearHotspotMetadata');
+ return clearHotspotMetadata(true);
+ },
+
+ getRegionBundle() {
+ return getRegionBundle();
+ },
+
+ getRegionMetadata() {
+ return getRegionMetadata();
+ },
+
+ setRegionMetadata(regionData, summary = {}) {
+ assertWriteMode('setRegionMetadata');
+ setRegionMetadata(regionData, summary, true);
+ },
+
+ clearRegionMetadata() {
+ assertWriteMode('clearRegionMetadata');
+ return clearRegionMetadata(true);
+ },
+
+ getBaselineBuildMetadata() {
+ return getBaselineBuildMetadata();
+ },
+
+ setBaselineBuildMetadata(metadata) {
+ assertWriteMode('setBaselineBuildMetadata');
+ setBaselineBuildMetadata(metadata, true);
+ },
+
+ clearBaselineBuildMetadata() {
+ assertWriteMode('clearBaselineBuildMetadata');
+ return clearBaselineBuildMetadata(true);
+ },
+
+ subscribe(listener) {
+ return subscribeToStateChanges(workingDir, listener);
+ },
+
+ close() {
+ try {
+ db.close();
+ } catch {
+ // Ignore close errors
+ }
+ },
+ };
+}
diff --git a/src/tdd/state-store/utils.js b/src/tdd/state-store/utils.js
new file mode 100644
index 00000000..70ea1014
--- /dev/null
+++ b/src/tdd/state-store/utils.js
@@ -0,0 +1,163 @@
+export function parseJson(value, fallback = null) {
+ if (value == null || value === '') {
+ return fallback;
+ }
+
+ try {
+ return JSON.parse(value);
+ } catch {
+ return fallback;
+ }
+}
+
+export function hasReportData(reportData) {
+ if (!reportData || typeof reportData !== 'object') {
+ return false;
+ }
+
+ if (!Array.isArray(reportData.comparisons)) {
+ return false;
+ }
+
+ return true;
+}
+
+export function buildSummary(comparisons) {
+ return {
+ total: comparisons.length,
+ passed: comparisons.filter(
+ comparison =>
+ comparison.status === 'passed' ||
+ comparison.status === 'baseline-created' ||
+ comparison.status === 'new'
+ ).length,
+ failed: comparisons.filter(comparison => comparison.status === 'failed')
+ .length,
+ rejected: comparisons.filter(comparison => comparison.status === 'rejected')
+ .length,
+ errors: comparisons.filter(comparison => comparison.status === 'error')
+ .length,
+ };
+}
+
+export function mapComparisonRow(row) {
+ if (!row) return null;
+
+ return {
+ id: row.id,
+ name: row.name,
+ status: row.status,
+ initialStatus: row.initial_status,
+ signature: row.signature,
+ baseline: row.baseline,
+ current: row.current,
+ diff: row.diff,
+ properties: parseJson(row.properties_json, {}),
+ threshold: row.threshold,
+ minClusterSize: row.min_cluster_size,
+ diffPercentage: row.diff_percentage,
+ diffCount: row.diff_count,
+ reason: row.reason,
+ totalPixels: row.total_pixels,
+ aaPixelsIgnored: row.aa_pixels_ignored,
+ aaPercentage: row.aa_percentage,
+ heightDiff: row.height_diff,
+ error: row.error,
+ originalName: row.original_name,
+ timestamp: row.timestamp,
+ hasDiffClusters: Boolean(row.has_diff_clusters),
+ hasConfirmedRegions: Boolean(row.has_confirmed_regions),
+ };
+}
+
+export function normalizeComparison(comparison, initialStatus) {
+ let normalized = comparison || {};
+ let now = Date.now();
+
+ return {
+ id: normalized.id,
+ name: normalized.name,
+ status: normalized.status,
+ initial_status:
+ initialStatus ||
+ normalized.initialStatus ||
+ normalized.initial_status ||
+ normalized.status ||
+ null,
+ signature: normalized.signature ?? null,
+ baseline: normalized.baseline ?? null,
+ current: normalized.current ?? null,
+ diff: normalized.diff ?? null,
+ properties_json: JSON.stringify(normalized.properties || {}),
+ threshold:
+ normalized.threshold == null ? null : Number(normalized.threshold),
+ min_cluster_size:
+ normalized.minClusterSize == null
+ ? null
+ : Number(normalized.minClusterSize),
+ diff_percentage:
+ normalized.diffPercentage == null
+ ? null
+ : Number(normalized.diffPercentage),
+ diff_count:
+ normalized.diffCount == null ? null : Number(normalized.diffCount),
+ reason: normalized.reason ?? null,
+ total_pixels:
+ normalized.totalPixels == null ? null : Number(normalized.totalPixels),
+ aa_pixels_ignored:
+ normalized.aaPixelsIgnored == null
+ ? null
+ : Number(normalized.aaPixelsIgnored),
+ aa_percentage:
+ normalized.aaPercentage == null ? null : Number(normalized.aaPercentage),
+ height_diff:
+ normalized.heightDiff == null ? null : Number(normalized.heightDiff),
+ error: normalized.error ?? null,
+ original_name: normalized.originalName ?? null,
+ has_diff_clusters: Number(Boolean(normalized.hasDiffClusters)),
+ has_confirmed_regions: Number(Boolean(normalized.hasConfirmedRegions)),
+ timestamp:
+ normalized.timestamp == null ? now : Number(normalized.timestamp),
+ updated_at: now,
+ };
+}
+
+export function normalizeHotspotBundle(value) {
+ if (!value || typeof value !== 'object') {
+ return null;
+ }
+
+ if (value.hotspots && typeof value.hotspots === 'object') {
+ return {
+ downloadedAt: value.downloadedAt || new Date().toISOString(),
+ summary: value.summary || {},
+ hotspots: value.hotspots,
+ };
+ }
+
+ return {
+ downloadedAt: new Date().toISOString(),
+ summary: {},
+ hotspots: value,
+ };
+}
+
+export function normalizeRegionBundle(value) {
+ if (!value || typeof value !== 'object') {
+ return null;
+ }
+
+ if (value.regions && typeof value.regions === 'object') {
+ return {
+ downloadedAt: value.downloadedAt || new Date().toISOString(),
+ summary: value.summary || {},
+ regions: value.regions,
+ };
+ }
+
+ return {
+ downloadedAt: new Date().toISOString(),
+ summary: {},
+ regions: value,
+ };
+}
diff --git a/src/tdd/tdd-service.js b/src/tdd/tdd-service.js
index 0b6a0d58..bc23a9b1 100644
--- a/src/tdd/tdd-service.js
+++ b/src/tdd/tdd-service.js
@@ -42,7 +42,9 @@ import {
import {
createEmptyBaselineMetadata as defaultCreateEmptyBaselineMetadata,
+ loadBaselineBuildMetadata as defaultLoadBaselineBuildMetadata,
loadBaselineMetadata as defaultLoadBaselineMetadata,
+ saveBaselineBuildMetadata as defaultSaveBaselineBuildMetadata,
saveBaselineMetadata as defaultSaveBaselineMetadata,
upsertScreenshotInMetadata as defaultUpsertScreenshotInMetadata,
} from './metadata/baseline-metadata.js';
@@ -164,7 +166,9 @@ export class TddService {
};
let metadataOps = {
+ loadBaselineBuildMetadata: defaultLoadBaselineBuildMetadata,
loadBaselineMetadata: defaultLoadBaselineMetadata,
+ saveBaselineBuildMetadata: defaultSaveBaselineBuildMetadata,
saveBaselineMetadata: defaultSaveBaselineMetadata,
createEmptyBaselineMetadata: defaultCreateEmptyBaselineMetadata,
upsertScreenshotInMetadata: defaultUpsertScreenshotInMetadata,
@@ -260,7 +264,7 @@ export class TddService {
this.minClusterSize = config.comparison?.minClusterSize ?? 2;
this.signatureProperties = config.signatureProperties ?? [];
- // Hotspot data (loaded lazily from disk or downloaded from cloud)
+ // Hotspot data (loaded lazily from state storage or downloaded from cloud)
this.hotspotData = null;
// Region data (user-defined 2D bounding boxes, loaded lazily)
@@ -276,6 +280,21 @@ export class TddService {
}
}
+ /**
+ * Reset in-memory runtime state so a baseline reset is truly fresh.
+ * Persisted state is handled separately by the state store.
+ */
+ resetRuntimeState() {
+ this.baselineData = null;
+ this.comparisons = [];
+ this.threshold = this.config.comparison?.threshold || 2.0;
+ this.minClusterSize = this.config.comparison?.minClusterSize ?? 2;
+ this.signatureProperties = this.config.signatureProperties ?? [];
+ this.hotspotData = null;
+ this.regionData = null;
+ this._resultsPrinted = false;
+ }
+
/**
* Download baselines from cloud
*/
@@ -301,6 +320,7 @@ export class TddService {
existsSync,
fetchWithTimeout,
writeFileSync,
+ saveBaselineBuildMetadata,
saveBaselineMetadata,
} = this._deps;
@@ -674,29 +694,17 @@ export class TddService {
// and saved earlier when processing the API response
// Save baseline build metadata for MCP plugin
- let baselineMetadataPath = safePath(
- this.workingDir,
- '.vizzly',
- 'baseline-metadata.json'
- );
- writeFileSync(
- baselineMetadataPath,
- JSON.stringify(
- {
- buildId: baselineBuild.id,
- buildName: baselineBuild.name,
- branch,
- environment,
- commitSha: baselineBuild.commit_sha,
- commitMessage: baselineBuild.commit_message,
- approvalStatus: baselineBuild.approval_status,
- completedAt: baselineBuild.completed_at,
- downloadedAt: new Date().toISOString(),
- },
- null,
- 2
- )
- );
+ saveBaselineBuildMetadata(this.workingDir, {
+ buildId: baselineBuild.id,
+ buildName: baselineBuild.name,
+ branch,
+ environment,
+ commitSha: baselineBuild.commit_sha,
+ commitMessage: baselineBuild.commit_message,
+ approvalStatus: baselineBuild.approval_status,
+ completedAt: baselineBuild.completed_at,
+ downloadedAt: new Date().toISOString(),
+ });
// Summary
let actualDownloads = downloadedCount - skippedCount;
@@ -743,6 +751,7 @@ export class TddService {
existsSync,
fetchWithTimeout,
writeFileSync,
+ saveBaselineBuildMetadata,
saveBaselineMetadata,
} = this._deps;
@@ -978,29 +987,17 @@ export class TddService {
// and saved earlier when processing the API response
// Save baseline build metadata for MCP plugin
- let baselineMetadataPath = safePath(
- this.workingDir,
- '.vizzly',
- 'baseline-metadata.json'
- );
- writeFileSync(
- baselineMetadataPath,
- JSON.stringify(
- {
- buildId: baselineBuild.id,
- buildName: baselineBuild.name,
- branch: null,
- environment: 'test',
- commitSha: baselineBuild.commit_sha,
- commitMessage: baselineBuild.commit_message,
- approvalStatus: baselineBuild.approval_status,
- completedAt: baselineBuild.completed_at,
- downloadedAt: new Date().toISOString(),
- },
- null,
- 2
- )
- );
+ saveBaselineBuildMetadata(this.workingDir, {
+ buildId: baselineBuild.id,
+ buildName: baselineBuild.name,
+ branch: null,
+ environment: 'test',
+ commitSha: baselineBuild.commit_sha,
+ commitMessage: baselineBuild.commit_message,
+ approvalStatus: baselineBuild.approval_status,
+ completedAt: baselineBuild.completed_at,
+ downloadedAt: new Date().toISOString(),
+ });
// Summary
let actualDownloads = downloadedCount - skippedCount;
@@ -1060,7 +1057,7 @@ export class TddService {
// Update memory cache
this.hotspotData = response.hotspots;
- // Save to disk using extracted module
+ // Save to state storage using extracted module
saveHotspotMetadata(this.workingDir, response.hotspots, response.summary);
let hotspotCount = Object.keys(response.hotspots).length;
@@ -1081,17 +1078,17 @@ export class TddService {
}
/**
- * Load hotspot data from disk
+ * Load hotspot data from state storage
*/
loadHotspots() {
let { loadHotspotMetadata } = this._deps;
- return loadHotspotMetadata(this.workingDir);
+ return loadHotspotMetadata(this.workingDir, { mode: 'write' });
}
/**
* Get hotspot for a specific screenshot
*
- * Note: Once hotspotData is loaded (from disk or cloud), we don't reload.
+ * Note: Once hotspotData is loaded (from state or cloud), we don't reload.
* This is intentional - hotspots are downloaded once per session and cached.
* If a screenshot isn't in the cache, it means no hotspot data exists for it.
*/
@@ -1101,7 +1098,7 @@ export class TddService {
return this.hotspotData[screenshotName];
}
- // Try loading from disk (only if we haven't loaded yet)
+ // Try loading from state storage (only if we haven't loaded yet)
if (!this.hotspotData) {
this.hotspotData = this.loadHotspots();
}
@@ -1110,17 +1107,17 @@ export class TddService {
}
/**
- * Load region data from disk
+ * Load region data from state storage
*/
loadRegions() {
let { loadRegionMetadata } = this._deps;
- return loadRegionMetadata(this.workingDir);
+ return loadRegionMetadata(this.workingDir, { mode: 'write' });
}
/**
* Get user-defined regions for a specific screenshot
*
- * Note: Once regionData is loaded (from disk or cloud), we don't reload.
+ * Note: Once regionData is loaded (from state or cloud), we don't reload.
* This is intentional - regions are downloaded once per session and cached.
* If a screenshot isn't in the cache, it means no region data exists for it.
*
@@ -1133,7 +1130,7 @@ export class TddService {
return this.regionData[screenshotName];
}
- // Try loading from disk (only if we haven't loaded yet)
+ // Try loading from state storage (only if we haven't loaded yet)
if (!this.regionData) {
this.regionData = this.loadRegions();
}
@@ -1192,7 +1189,9 @@ export class TddService {
return null;
}
- let metadata = loadBaselineMetadata(this.baselinePath);
+ let metadata = loadBaselineMetadata(this.baselinePath, {
+ mode: 'write',
+ });
if (!metadata) {
return null;
diff --git a/src/utils/context.js b/src/utils/context.js
index 7d665b21..c4ee0496 100644
--- a/src/utils/context.js
+++ b/src/utils/context.js
@@ -11,6 +11,7 @@
import { existsSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
+import { createStateStore } from '../tdd/state-store.js';
/**
* Get dynamic context about the current Vizzly state
@@ -44,10 +45,22 @@ export function getContext() {
// Check for .vizzly directory (TDD baselines)
let baselineCount = 0;
try {
- let metaPath = join(cwd, '.vizzly', 'baselines', 'metadata.json');
- if (existsSync(metaPath)) {
- let meta = JSON.parse(readFileSync(metaPath, 'utf8'));
- baselineCount = meta.screenshots?.length || 0;
+ let stateDbPath = join(cwd, '.vizzly', 'state.db');
+ let legacyBaselinePath = join(
+ cwd,
+ '.vizzly',
+ 'baselines',
+ 'metadata.json'
+ );
+
+ if (existsSync(stateDbPath) || existsSync(legacyBaselinePath)) {
+ let stateStore = createStateStore({ workingDir: cwd, mode: 'read' });
+ try {
+ let metadata = stateStore.getBaselineMetadata();
+ baselineCount = metadata?.screenshots?.length || 0;
+ } finally {
+ stateStore.close();
+ }
}
} catch {
// Ignore
@@ -171,11 +184,25 @@ export function getDetailedContext() {
// Check for baselines
try {
- let metaPath = join(cwd, '.vizzly', 'baselines', 'metadata.json');
- if (existsSync(metaPath)) {
- let meta = JSON.parse(readFileSync(metaPath, 'utf8'));
- context.baselines.count = meta.screenshots?.length || 0;
- context.baselines.path = join(cwd, '.vizzly', 'baselines');
+ let stateDbPath = join(cwd, '.vizzly', 'state.db');
+ let legacyBaselinePath = join(
+ cwd,
+ '.vizzly',
+ 'baselines',
+ 'metadata.json'
+ );
+
+ if (existsSync(stateDbPath) || existsSync(legacyBaselinePath)) {
+ let stateStore = createStateStore({ workingDir: cwd, mode: 'read' });
+ try {
+ let metadata = stateStore.getBaselineMetadata();
+ context.baselines.count = metadata?.screenshots?.length || 0;
+ if (metadata) {
+ context.baselines.path = join(cwd, '.vizzly', 'baselines');
+ }
+ } finally {
+ stateStore.close();
+ }
}
} catch {
// Ignore
diff --git a/tests/server/handlers/tdd-handler.test.js b/tests/server/handlers/tdd-handler.test.js
index 388585f1..9cf67f97 100644
--- a/tests/server/handlers/tdd-handler.test.js
+++ b/tests/server/handlers/tdd-handler.test.js
@@ -1,5 +1,8 @@
import assert from 'node:assert';
-import { describe, it } from 'node:test';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join as joinPath } from 'node:path';
+import { afterEach, describe, it } from 'node:test';
import {
convertPathToUrl,
createTddHandler,
@@ -7,6 +10,16 @@ import {
groupComparisons,
unwrapProperties,
} from '../../../src/server/handlers/tdd-handler.js';
+import { createStateStore } from '../../../src/tdd/state-store.js';
+
+let testResources = [];
+
+afterEach(() => {
+ for (let cleanup of testResources) {
+ cleanup();
+ }
+ testResources = [];
+});
/**
* Create mock output for testing
@@ -65,6 +78,10 @@ function createMockTddService(overrides = {}) {
async acceptBaseline(comparison) {
return overrides.acceptBaseline?.(comparison) ?? { success: true };
}
+
+ resetRuntimeState() {
+ return overrides.resetRuntimeState?.();
+ }
};
}
@@ -73,24 +90,23 @@ function createMockTddService(overrides = {}) {
*/
function createMockDeps(overrides = {}) {
let mockOutput = createMockOutput();
- let fileSystem = {};
+ let dbWorkingDir = mkdtempSync(joinPath(tmpdir(), 'vizzly-tdd-handler-'));
+ let stateStore = createStateStore({
+ workingDir: dbWorkingDir,
+ mode: 'write',
+ });
- return {
+ let deps = {
TddService:
overrides.TddService ??
createMockTddService(overrides.tddServiceOverrides),
- existsSync: overrides.existsSync ?? (path => path in fileSystem),
+ existsSync: overrides.existsSync ?? (() => false),
+ unlinkSync: overrides.unlinkSync ?? (() => {}),
readFileSync:
overrides.readFileSync ??
(path => {
- if (path in fileSystem) return fileSystem[path];
throw new Error(`File not found: ${path}`);
}),
- writeFileSync:
- overrides.writeFileSync ??
- ((path, content) => {
- fileSystem[path] = content;
- }),
join: overrides.join ?? ((...parts) => parts.join('/')),
resolve: overrides.resolve ?? (path => path.replace('file://', '')),
Buffer: overrides.Buffer ?? {
@@ -103,9 +119,27 @@ function createMockDeps(overrides = {}) {
validateScreenshotProperties:
overrides.validateScreenshotProperties ?? (props => props),
output: overrides.output ?? mockOutput,
- _fileSystem: fileSystem,
+ stateStore,
_mockOutput: mockOutput,
+ _stateStore: stateStore,
+ _dbWorkingDir: dbWorkingDir,
+ seedReportData: (reportData, detailsById = null) =>
+ stateStore.replaceReportData(reportData, detailsById),
+ readStoredReportData: () => stateStore.readReportData(),
+ readStoredDetails: id => stateStore.getComparisonDetails(id),
};
+
+ testResources.push(() => {
+ try {
+ stateStore.close();
+ } catch {
+ // ignore
+ }
+
+ rmSync(dbWorkingDir, { recursive: true, force: true });
+ });
+
+ return deps;
}
describe('server/handlers/tdd-handler', () => {
@@ -1059,8 +1093,7 @@ describe('server/handlers/tdd-handler', () => {
groups: [],
summary: { total: 1, passed: 0, failed: 1, errors: 0 },
};
- deps._fileSystem['/test/.vizzly/report-data.json'] =
- JSON.stringify(reportData);
+ deps.seedReportData(reportData);
let handler = createTddHandler({}, '/test', null, null, false, deps);
@@ -1100,8 +1133,7 @@ describe('server/handlers/tdd-handler', () => {
groups: [],
summary: { total: 1, passed: 0, failed: 1, errors: 0 },
};
- deps._fileSystem['/test/.vizzly/report-data.json'] =
- JSON.stringify(reportData);
+ deps.seedReportData(reportData);
let handler = createTddHandler({}, '/test', null, null, false, deps);
@@ -1137,8 +1169,7 @@ describe('server/handlers/tdd-handler', () => {
groups: [],
summary: { total: 1, passed: 0, failed: 1, errors: 0 },
};
- deps._fileSystem['/test/.vizzly/report-data.json'] =
- JSON.stringify(reportData);
+ deps.seedReportData(reportData);
let handler = createTddHandler({}, '/test', null, null, false, deps);
@@ -1148,9 +1179,7 @@ describe('server/handlers/tdd-handler', () => {
assert.strictEqual(result.id, 'comp-1');
// Check that comparison was updated to rejected status
- let updatedReportData = JSON.parse(
- deps._fileSystem['/test/.vizzly/report-data.json']
- );
+ let updatedReportData = deps.readStoredReportData();
let comparison = updatedReportData.comparisons.find(
c => c.id === 'comp-1'
);
@@ -1187,16 +1216,13 @@ describe('server/handlers/tdd-handler', () => {
groups: [],
summary: { total: 1, passed: 0, failed: 1, errors: 0 },
};
- deps._fileSystem['/test/.vizzly/report-data.json'] =
- JSON.stringify(reportData);
+ deps.seedReportData(reportData);
let handler = createTddHandler({}, '/test', null, null, false, deps);
await handler.rejectBaseline('comp-1');
- let updatedReportData = JSON.parse(
- deps._fileSystem['/test/.vizzly/report-data.json']
- );
+ let updatedReportData = deps.readStoredReportData();
let comparison = updatedReportData.comparisons.find(
c => c.id === 'comp-1'
);
@@ -1226,8 +1252,7 @@ describe('server/handlers/tdd-handler', () => {
groups: [],
summary: { total: 1, passed: 0, failed: 1, errors: 0 },
};
- deps._fileSystem['/test/.vizzly/report-data.json'] =
- JSON.stringify(reportData);
+ deps.seedReportData(reportData);
let handler = createTddHandler({}, '/test', null, null, false, deps);
@@ -1266,8 +1291,7 @@ describe('server/handlers/tdd-handler', () => {
groups: [],
summary: { total: 3, passed: 1, failed: 1, errors: 0 },
};
- deps._fileSystem['/test/.vizzly/report-data.json'] =
- JSON.stringify(reportData);
+ deps.seedReportData(reportData);
let handler = createTddHandler({}, '/test', null, null, false, deps);
@@ -1309,8 +1333,7 @@ describe('server/handlers/tdd-handler', () => {
groups: [],
summary: { total: 1, passed: 0, failed: 1, errors: 0 },
};
- deps._fileSystem['/test/.vizzly/report-data.json'] =
- JSON.stringify(reportData);
+ deps.seedReportData(reportData);
let handler = createTddHandler({}, '/test', null, null, false, deps);
@@ -1318,11 +1341,37 @@ describe('server/handlers/tdd-handler', () => {
assert.ok(result.success);
// Check report was cleared
- let newReportData = JSON.parse(
- deps._fileSystem['/test/.vizzly/report-data.json']
- );
+ let newReportData = deps.readStoredReportData();
assert.strictEqual(newReportData.comparisons.length, 0);
});
+
+ it('resets in-memory tddService runtime state', async () => {
+ let resetCalled = false;
+ let deps = createMockDeps({
+ tddServiceOverrides: {
+ resetRuntimeState: () => {
+ resetCalled = true;
+ },
+ },
+ });
+
+ deps.seedReportData({
+ timestamp: Date.now(),
+ comparisons: [
+ {
+ id: 'comp-1',
+ name: 'test',
+ status: 'failed',
+ baseline: '/images/baselines/test.png',
+ },
+ ],
+ });
+
+ let handler = createTddHandler({}, '/test', null, null, false, deps);
+ await handler.resetBaselines();
+
+ assert.strictEqual(resetCalled, true);
+ });
});
describe('cleanup', () => {
@@ -1336,22 +1385,20 @@ describe('server/handlers/tdd-handler', () => {
});
describe('readReportData / updateComparison', () => {
- it('creates empty report data when file does not exist', async () => {
+ it('creates state data when no prior report exists', async () => {
let deps = createMockDeps();
let handler = createTddHandler({}, '/test', null, null, false, deps);
// Trigger a screenshot which calls updateComparison
await handler.handleScreenshot('build-1', 'test', 'base64data', {});
- // Check report was created
- let reportData = JSON.parse(
- deps._fileSystem['/test/.vizzly/report-data.json']
- );
+ // Check report state was created
+ let reportData = deps.readStoredReportData();
assert.ok(reportData.timestamp);
assert.ok(Array.isArray(reportData.comparisons));
});
- it('excludes heavy fields from report-data.json and writes them to comparison-details.json', async () => {
+ it('stores heavy fields in details state and keeps report rows lightweight', async () => {
let deps = createMockDeps({
tddServiceOverrides: {
compareScreenshot: name => ({
@@ -1379,10 +1426,8 @@ describe('server/handlers/tdd-handler', () => {
await handler.handleScreenshot('build-1', 'test', 'base64data', {});
- // report-data.json should NOT contain heavy fields
- let reportData = JSON.parse(
- deps._fileSystem['/test/.vizzly/report-data.json']
- );
+ // Report state should NOT contain heavy fields
+ let reportData = deps.readStoredReportData();
let comparison = reportData.comparisons[0];
assert.strictEqual(comparison.diffClusters, undefined);
assert.strictEqual(comparison.intensityStats, undefined);
@@ -1400,14 +1445,12 @@ describe('server/handlers/tdd-handler', () => {
assert.strictEqual(comparison.threshold, 0.1);
assert.strictEqual(comparison.status, 'failed');
- // comparison-details.json SHOULD contain heavy fields
- let details = JSON.parse(
- deps._fileSystem['/test/.vizzly/comparison-details.json']
- );
- assert.ok(details['comp-test']);
- assert.strictEqual(details['comp-test'].diffClusters.length, 1);
- assert.strictEqual(details['comp-test'].confirmedRegions.length, 1);
- assert.deepStrictEqual(details['comp-test'].intensityStats, {
+ // Details state SHOULD contain heavy fields
+ let details = deps.readStoredDetails('comp-test');
+ assert.ok(details);
+ assert.strictEqual(details.diffClusters.length, 1);
+ assert.strictEqual(details.confirmedRegions.length, 1);
+ assert.deepStrictEqual(details.intensityStats, {
mean: 0.3,
max: 0.8,
});
@@ -1434,29 +1477,9 @@ describe('server/handlers/tdd-handler', () => {
// Same ID, should update not add
await handler.handleScreenshot('build-1', 'test', 'base64data', {});
- let reportData = JSON.parse(
- deps._fileSystem['/test/.vizzly/report-data.json']
- );
+ let reportData = deps.readStoredReportData();
assert.strictEqual(reportData.comparisons.length, 1);
});
-
- it('handles read error gracefully', async () => {
- let deps = createMockDeps({
- existsSync: () => true,
- readFileSync: () => {
- throw new Error('Read error');
- },
- });
- let handler = createTddHandler({}, '/test', null, null, false, deps);
-
- // Should not throw, returns empty data
- await handler.handleScreenshot('build-1', 'test', 'base64data', {});
-
- let errorCall = deps._mockOutput.calls.find(
- c => c.method === 'error' && c.args[0].includes('Failed to read')
- );
- assert.ok(errorCall);
- });
});
});
});
diff --git a/tests/server/http-server.test.js b/tests/server/http-server.test.js
index 230def02..5dadcbb3 100644
--- a/tests/server/http-server.test.js
+++ b/tests/server/http-server.test.js
@@ -1,8 +1,9 @@
import assert from 'node:assert';
-import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
+import { existsSync, mkdirSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, it } from 'node:test';
import { createHttpServer } from '../../src/server/http-server.js';
+import { createStateStore } from '../../src/tdd/state-store.js';
/**
* Make an HTTP request to the server
@@ -20,6 +21,12 @@ async function request(port, path, options = {}) {
return { status: response.status, body, headers: response.headers };
}
+function writeReportData(workingDir, reportData) {
+ let store = createStateStore({ workingDir, mode: 'write' });
+ store.replaceReportData(reportData);
+ store.close();
+}
+
describe('server/http-server', () => {
let testDir = join(process.cwd(), '.test-http-server');
let originalCwd = process.cwd();
@@ -100,10 +107,7 @@ describe('server/http-server', () => {
});
it('serves /api/events SSE endpoint', async () => {
- writeFileSync(
- join(testDir, '.vizzly', 'report-data.json'),
- JSON.stringify({ comparisons: [], summary: { total: 0 } })
- );
+ writeReportData(testDir, { comparisons: [], summary: { total: 0 } });
server = createHttpServer(testPort, null);
await server.start();
@@ -118,10 +122,7 @@ describe('server/http-server', () => {
});
it('serves /api/report-data endpoint', async () => {
- writeFileSync(
- join(testDir, '.vizzly', 'report-data.json'),
- JSON.stringify({ comparisons: [], summary: { total: 0 } })
- );
+ writeReportData(testDir, { comparisons: [], summary: { total: 0 } });
server = createHttpServer(testPort, null);
await server.start();
diff --git a/tests/server/routers/dashboard.test.js b/tests/server/routers/dashboard.test.js
index 591a009b..5fe58625 100644
--- a/tests/server/routers/dashboard.test.js
+++ b/tests/server/routers/dashboard.test.js
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, it } from 'node:test';
import { createDashboardRouter } from '../../../src/server/routers/dashboard.js';
+import { createStateStore } from '../../../src/tdd/state-store.js';
/**
* Creates a mock HTTP request
@@ -47,6 +48,18 @@ function createMockResponse() {
};
}
+function writeReportData(workingDir, reportData, details = null) {
+ let store = createStateStore({ workingDir, mode: 'write' });
+ store.replaceReportData(reportData, details);
+ store.close();
+}
+
+function writeBaselineMetadata(workingDir, metadata) {
+ let store = createStateStore({ workingDir, mode: 'write' });
+ store.setBaselineMetadata(metadata);
+ store.close();
+}
+
describe('server/routers/dashboard', () => {
let testDir = join(process.cwd(), '.test-dashboard-router');
let originalCwd = process.cwd();
@@ -89,10 +102,10 @@ describe('server/routers/dashboard', () => {
describe('GET /api/report-data', () => {
it('returns report data when file exists', async () => {
- writeFileSync(
- join(testDir, '.vizzly', 'report-data.json'),
- JSON.stringify({ comparisons: [{ id: '1' }], summary: { total: 1 } })
- );
+ writeReportData(testDir, {
+ comparisons: [{ id: '1', name: 'shot', status: 'passed' }],
+ summary: { total: 1 },
+ });
let handler = createDashboardRouter();
let req = createMockRequest('GET');
@@ -118,14 +131,11 @@ describe('server/routers/dashboard', () => {
});
it('includes baseline metadata when available', async () => {
- writeFileSync(
- join(testDir, '.vizzly', 'report-data.json'),
- JSON.stringify({ comparisons: [], summary: { total: 0 } })
- );
- writeFileSync(
- join(testDir, '.vizzly', 'baselines', 'metadata.json'),
- JSON.stringify({ buildName: 'Test Build', createdAt: '2025-01-01' })
- );
+ writeReportData(testDir, { comparisons: [], summary: { total: 0 } });
+ writeBaselineMetadata(testDir, {
+ buildName: 'Test Build',
+ createdAt: '2025-01-01',
+ });
let handler = createDashboardRouter();
let req = createMockRequest('GET');
@@ -140,10 +150,7 @@ describe('server/routers/dashboard', () => {
});
it('returns null baseline when metadata does not exist', async () => {
- writeFileSync(
- join(testDir, '.vizzly', 'report-data.json'),
- JSON.stringify({ comparisons: [], summary: { total: 0 } })
- );
+ writeReportData(testDir, { comparisons: [], summary: { total: 0 } });
let handler = createDashboardRouter();
let req = createMockRequest('GET');
@@ -159,9 +166,9 @@ describe('server/routers/dashboard', () => {
describe('GET /api/comparison/:id', () => {
it('returns merged comparison data by id', async () => {
- writeFileSync(
- join(testDir, '.vizzly', 'report-data.json'),
- JSON.stringify({
+ writeReportData(
+ testDir,
+ {
comparisons: [
{
id: 'comp-1',
@@ -172,17 +179,14 @@ describe('server/routers/dashboard', () => {
hasDiffClusters: true,
},
],
- })
- );
- writeFileSync(
- join(testDir, '.vizzly', 'comparison-details.json'),
- JSON.stringify({
+ },
+ {
'comp-1': {
diffClusters: [{ x: 10, y: 20, width: 100, height: 50 }],
confirmedRegions: [{ id: 'r1', label: 'header' }],
intensityStats: { mean: 0.3 },
},
- })
+ }
);
let handler = createDashboardRouter();
@@ -203,19 +207,16 @@ describe('server/routers/dashboard', () => {
});
it('returns comparison by signature', async () => {
- writeFileSync(
- join(testDir, '.vizzly', 'report-data.json'),
- JSON.stringify({
- comparisons: [
- {
- id: 'comp-2',
- name: 'home-page',
- signature: 'home-page|1920|firefox',
- status: 'passed',
- },
- ],
- })
- );
+ writeReportData(testDir, {
+ comparisons: [
+ {
+ id: 'comp-2',
+ name: 'home-page',
+ signature: 'home-page|1920|firefox',
+ status: 'passed',
+ },
+ ],
+ });
let handler = createDashboardRouter();
let req = createMockRequest('GET');
@@ -229,14 +230,9 @@ describe('server/routers/dashboard', () => {
});
it('returns comparison by name', async () => {
- writeFileSync(
- join(testDir, '.vizzly', 'report-data.json'),
- JSON.stringify({
- comparisons: [
- { id: 'comp-3', name: 'settings-page', status: 'new' },
- ],
- })
- );
+ writeReportData(testDir, {
+ comparisons: [{ id: 'comp-3', name: 'settings-page', status: 'new' }],
+ });
let handler = createDashboardRouter();
let req = createMockRequest('GET');
@@ -250,10 +246,7 @@ describe('server/routers/dashboard', () => {
});
it('returns 404 when comparison not found', async () => {
- writeFileSync(
- join(testDir, '.vizzly', 'report-data.json'),
- JSON.stringify({ comparisons: [] })
- );
+ writeReportData(testDir, { comparisons: [] });
let handler = createDashboardRouter();
let req = createMockRequest('GET');
@@ -276,19 +269,16 @@ describe('server/routers/dashboard', () => {
});
it('returns lightweight data when no details file exists', async () => {
- writeFileSync(
- join(testDir, '.vizzly', 'report-data.json'),
- JSON.stringify({
- comparisons: [
- {
- id: 'comp-4',
- name: 'dashboard',
- status: 'passed',
- hasDiffClusters: false,
- },
- ],
- })
- );
+ writeReportData(testDir, {
+ comparisons: [
+ {
+ id: 'comp-4',
+ name: 'dashboard',
+ status: 'passed',
+ hasDiffClusters: false,
+ },
+ ],
+ });
let handler = createDashboardRouter();
let req = createMockRequest('GET');
@@ -381,10 +371,9 @@ describe('server/routers/dashboard', () => {
});
it('injects report data into HTML when available', async () => {
- writeFileSync(
- join(testDir, '.vizzly', 'report-data.json'),
- JSON.stringify({ comparisons: [{ id: 'test-123' }] })
- );
+ writeReportData(testDir, {
+ comparisons: [{ id: 'test-123', name: 'shot', status: 'passed' }],
+ });
let handler = createDashboardRouter();
let req = createMockRequest('GET');
diff --git a/tests/server/routers/events.test.js b/tests/server/routers/events.test.js
index 669d5d37..02f2d1c9 100644
--- a/tests/server/routers/events.test.js
+++ b/tests/server/routers/events.test.js
@@ -4,6 +4,7 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, it } from 'node:test';
import { createEventsRouter } from '../../../src/server/routers/events.js';
+import { createStateStore } from '../../../src/tdd/state-store.js';
/**
* Creates a mock HTTP request with EventEmitter capabilities
@@ -62,6 +63,22 @@ function createMockResponse() {
};
}
+function writeReportData(workingDir, reportData, details = null) {
+ let store = createStateStore({ workingDir, mode: 'write' });
+ store.replaceReportData(reportData, details);
+ store.close();
+}
+
+function writeBaselineMetadata(workingDir, metadata) {
+ let store = createStateStore({ workingDir, mode: 'write' });
+ store.setBaselineMetadata(metadata);
+ store.close();
+}
+
+async function flushSseUpdates() {
+ await new Promise(resolve => setImmediate(resolve));
+}
+
describe('server/routers/events', () => {
let testDir = join(process.cwd(), '.test-events-router');
let originalCwd = process.cwd();
@@ -120,11 +137,11 @@ describe('server/routers/events', () => {
});
it('sends initial data when report-data.json exists', async () => {
- let reportData = { comparisons: [{ id: 'test' }], summary: { total: 1 } };
- writeFileSync(
- join(testDir, '.vizzly', 'report-data.json'),
- JSON.stringify(reportData)
- );
+ let reportData = {
+ comparisons: [{ id: 'test', name: 'shot', status: 'passed' }],
+ summary: { total: 1 },
+ };
+ writeReportData(testDir, reportData);
let handler = createEventsRouter({ workingDir: testDir });
let req = createMockRequest('GET');
@@ -142,15 +159,11 @@ describe('server/routers/events', () => {
});
it('includes baseline metadata in report data', async () => {
- mkdirSync(join(testDir, '.vizzly', 'baselines'), { recursive: true });
- writeFileSync(
- join(testDir, '.vizzly', 'report-data.json'),
- JSON.stringify({ comparisons: [], summary: { total: 0 } })
- );
- writeFileSync(
- join(testDir, '.vizzly', 'baselines', 'metadata.json'),
- JSON.stringify({ buildName: 'Test Build', createdAt: '2025-01-01' })
- );
+ writeReportData(testDir, { comparisons: [], summary: { total: 0 } });
+ writeBaselineMetadata(testDir, {
+ buildName: 'Test Build',
+ createdAt: '2025-01-01',
+ });
let handler = createEventsRouter({ workingDir: testDir });
let req = createMockRequest('GET');
@@ -236,14 +249,11 @@ describe('server/routers/events', () => {
await handler(req, res, '/api/events');
- // Write initial data - this triggers the file watcher
- writeFileSync(
- join(testDir, '.vizzly', 'report-data.json'),
- JSON.stringify({ comparisons: [{ id: 'updated' }] })
- );
+ writeReportData(testDir, {
+ comparisons: [{ id: 'updated', name: 'shot', status: 'failed' }],
+ });
- // Wait for debounce (100ms) + buffer
- await new Promise(resolve => setTimeout(resolve, 200));
+ await flushSseUpdates();
let output = res.getOutput();
assert.ok(output.includes('event: reportData'));
@@ -260,28 +270,23 @@ describe('server/routers/events', () => {
await handler(req, res, '/api/events');
- // Rapid file changes
- writeFileSync(
- join(testDir, '.vizzly', 'report-data.json'),
- JSON.stringify({ version: 1 })
- );
- writeFileSync(
- join(testDir, '.vizzly', 'report-data.json'),
- JSON.stringify({ version: 2 })
- );
- writeFileSync(
- join(testDir, '.vizzly', 'report-data.json'),
- JSON.stringify({ version: 3 })
- );
+ writeReportData(testDir, {
+ comparisons: [{ id: 'v1', name: 'shot', status: 'passed' }],
+ });
+ writeReportData(testDir, {
+ comparisons: [{ id: 'v2', name: 'shot', status: 'passed' }],
+ });
+ writeReportData(testDir, {
+ comparisons: [{ id: 'v3', name: 'shot', status: 'passed' }],
+ });
- // Wait for debounce
- await new Promise(resolve => setTimeout(resolve, 200));
+ await flushSseUpdates();
let output = res.getOutput();
- // Should only have one event with the final version
+ // Should have at least one event and include the final update.
let eventCount = (output.match(/event: reportData/g) || []).length;
assert.ok(eventCount >= 1, 'Should have at least one event');
- assert.ok(output.includes('"version":3'), 'Should contain final version');
+ assert.ok(output.includes('"id":"v3"'), 'Should contain final version');
// Clean up
req.emit('close');
@@ -298,12 +303,9 @@ describe('server/routers/events', () => {
res.end();
// Try to trigger an update
- writeFileSync(
- join(testDir, '.vizzly', 'report-data.json'),
- JSON.stringify({ test: true })
- );
+ writeReportData(testDir, { test: true, comparisons: [] });
- await new Promise(resolve => setTimeout(resolve, 200));
+ await flushSseUpdates();
// Should not have written after end()
// The initial chunks should be empty since no initial data
@@ -323,7 +325,7 @@ describe('server/routers/events', () => {
let result = await handler(req, res, '/api/events');
- // Should still work, just no file watching
+ // Should still work, just no state updates yet
assert.strictEqual(result, true);
assert.strictEqual(res.statusCode, 200);
@@ -336,10 +338,7 @@ describe('server/routers/events', () => {
comparisons: [{ id: 'a', name: 'existing', status: 'passed' }],
total: 1,
};
- writeFileSync(
- join(testDir, '.vizzly', 'report-data.json'),
- JSON.stringify(initialData)
- );
+ writeReportData(testDir, initialData);
let handler = createEventsRouter({ workingDir: testDir });
let req = createMockRequest('GET');
@@ -355,12 +354,9 @@ describe('server/routers/events', () => {
],
total: 2,
};
- writeFileSync(
- join(testDir, '.vizzly', 'report-data.json'),
- JSON.stringify(updatedData)
- );
+ writeReportData(testDir, updatedData);
- await new Promise(resolve => setTimeout(resolve, 200));
+ await flushSseUpdates();
let output = res.getOutput();
assert.ok(output.includes('event: comparisonUpdate'));
@@ -377,10 +373,7 @@ describe('server/routers/events', () => {
],
total: 1,
};
- writeFileSync(
- join(testDir, '.vizzly', 'report-data.json'),
- JSON.stringify(initialData)
- );
+ writeReportData(testDir, initialData);
let handler = createEventsRouter({ workingDir: testDir });
let req = createMockRequest('GET');
@@ -395,12 +388,9 @@ describe('server/routers/events', () => {
],
total: 1,
};
- writeFileSync(
- join(testDir, '.vizzly', 'report-data.json'),
- JSON.stringify(updatedData)
- );
+ writeReportData(testDir, updatedData);
- await new Promise(resolve => setTimeout(resolve, 200));
+ await flushSseUpdates();
let output = res.getOutput();
assert.ok(output.includes('event: comparisonUpdate'));
@@ -419,15 +409,12 @@ describe('server/routers/events', () => {
it('sends comparisonRemoved when comparison is deleted', async () => {
let initialData = {
comparisons: [
- { id: 'a', name: 'keep' },
- { id: 'b', name: 'remove' },
+ { id: 'a', name: 'keep', status: 'passed' },
+ { id: 'b', name: 'remove', status: 'failed' },
],
total: 2,
};
- writeFileSync(
- join(testDir, '.vizzly', 'report-data.json'),
- JSON.stringify(initialData)
- );
+ writeReportData(testDir, initialData);
let handler = createEventsRouter({ workingDir: testDir });
let req = createMockRequest('GET');
@@ -437,15 +424,12 @@ describe('server/routers/events', () => {
// Remove comparison b
let updatedData = {
- comparisons: [{ id: 'a', name: 'keep' }],
+ comparisons: [{ id: 'a', name: 'keep', status: 'passed' }],
total: 1,
};
- writeFileSync(
- join(testDir, '.vizzly', 'report-data.json'),
- JSON.stringify(updatedData)
- );
+ writeReportData(testDir, updatedData);
- await new Promise(resolve => setTimeout(resolve, 200));
+ await flushSseUpdates();
let output = res.getOutput();
assert.ok(output.includes('event: comparisonRemoved'));
@@ -456,15 +440,12 @@ describe('server/routers/events', () => {
it('sends summaryUpdate when summary fields change', async () => {
let initialData = {
- comparisons: [{ id: 'a', name: 'test' }],
+ comparisons: [{ id: 'a', name: 'test', status: 'passed' }],
total: 1,
passed: 1,
failed: 0,
};
- writeFileSync(
- join(testDir, '.vizzly', 'report-data.json'),
- JSON.stringify(initialData)
- );
+ writeReportData(testDir, initialData);
let handler = createEventsRouter({ workingDir: testDir });
let req = createMockRequest('GET');
@@ -472,19 +453,16 @@ describe('server/routers/events', () => {
await handler(req, res, '/api/events');
- // Change summary fields only, same comparisons
+ // Change status so summary changes.
let updatedData = {
- comparisons: [{ id: 'a', name: 'test' }],
+ comparisons: [{ id: 'a', name: 'test', status: 'failed' }],
total: 1,
passed: 0,
failed: 1,
};
- writeFileSync(
- join(testDir, '.vizzly', 'report-data.json'),
- JSON.stringify(updatedData)
- );
+ writeReportData(testDir, updatedData);
- await new Promise(resolve => setTimeout(resolve, 200));
+ await flushSseUpdates();
let output = res.getOutput();
assert.ok(output.includes('event: summaryUpdate'));
@@ -500,13 +478,13 @@ describe('server/routers/events', () => {
it('sends no events when nothing changed', async () => {
let initialData = {
- comparisons: [{ id: 'a', name: 'test', status: 'passed' }],
+ timestamp: 1234,
+ comparisons: [
+ { id: 'a', name: 'test', status: 'passed', timestamp: 1000 },
+ ],
total: 1,
};
- writeFileSync(
- join(testDir, '.vizzly', 'report-data.json'),
- JSON.stringify(initialData)
- );
+ writeReportData(testDir, initialData);
let handler = createEventsRouter({ workingDir: testDir });
let req = createMockRequest('GET');
@@ -517,12 +495,9 @@ describe('server/routers/events', () => {
let chunksAfterInitial = res.chunks.length;
// Write identical data
- writeFileSync(
- join(testDir, '.vizzly', 'report-data.json'),
- JSON.stringify(initialData)
- );
+ writeReportData(testDir, initialData);
- await new Promise(resolve => setTimeout(resolve, 200));
+ await flushSseUpdates();
// No new chunks should have been written
assert.strictEqual(
@@ -547,7 +522,7 @@ describe('server/routers/events', () => {
JSON.stringify({ ignored: true })
);
- await new Promise(resolve => setTimeout(resolve, 200));
+ await flushSseUpdates();
// Should not have sent any events
let output = res.getOutput();
diff --git a/tests/server/routers/health.test.js b/tests/server/routers/health.test.js
index 0b14ebf4..48627be2 100644
--- a/tests/server/routers/health.test.js
+++ b/tests/server/routers/health.test.js
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, it } from 'node:test';
import { createHealthRouter } from '../../../src/server/routers/health.js';
+import { createStateStore } from '../../../src/tdd/state-store.js';
/**
* Creates a mock HTTP request
@@ -47,6 +48,18 @@ function createMockResponse() {
};
}
+function writeReportData(workingDir, reportData) {
+ let store = createStateStore({ workingDir, mode: 'write' });
+ store.replaceReportData(reportData);
+ store.close();
+}
+
+function writeBaselineMetadata(workingDir, metadata) {
+ let store = createStateStore({ workingDir, mode: 'write' });
+ store.setBaselineMetadata(metadata);
+ store.close();
+}
+
describe('server/routers/health', () => {
let testDir = join(process.cwd(), '.test-health-router');
let originalCwd = process.cwd();
@@ -120,17 +133,20 @@ describe('server/routers/health', () => {
});
it('includes report stats when report-data.json exists', async () => {
- writeFileSync(
- join(testDir, '.vizzly', 'report-data.json'),
- JSON.stringify({
- summary: {
- total: 10,
- passed: 8,
- failed: 1,
- errors: 1,
- },
- })
- );
+ writeReportData(testDir, {
+ comparisons: [
+ { id: 'p1', name: 'a', status: 'passed' },
+ { id: 'p2', name: 'b', status: 'passed' },
+ { id: 'p3', name: 'c', status: 'passed' },
+ { id: 'p4', name: 'd', status: 'passed' },
+ { id: 'p5', name: 'e', status: 'passed' },
+ { id: 'p6', name: 'f', status: 'passed' },
+ { id: 'p7', name: 'g', status: 'passed' },
+ { id: 'p8', name: 'h', status: 'passed' },
+ { id: 'f1', name: 'i', status: 'failed' },
+ { id: 'e1', name: 'j', status: 'error' },
+ ],
+ });
let handler = createHealthRouter({ port: 3000, screenshotHandler: null });
let req = createMockRequest('GET');
@@ -148,13 +164,10 @@ describe('server/routers/health', () => {
});
it('includes baseline info when metadata.json exists', async () => {
- writeFileSync(
- join(testDir, '.vizzly', 'baselines', 'metadata.json'),
- JSON.stringify({
- buildName: 'Test Build',
- createdAt: '2025-01-01T00:00:00Z',
- })
- );
+ writeBaselineMetadata(testDir, {
+ buildName: 'Test Build',
+ createdAt: '2025-01-01T00:00:00Z',
+ });
let handler = createHealthRouter({ port: 3000, screenshotHandler: null });
let req = createMockRequest('GET');
diff --git a/tests/services/static-report-generator.test.js b/tests/services/static-report-generator.test.js
index be994091..a00fd7d5 100644
--- a/tests/services/static-report-generator.test.js
+++ b/tests/services/static-report-generator.test.js
@@ -10,6 +10,7 @@ import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { afterEach, beforeEach, describe, it } from 'node:test';
import { fileURLToPath } from 'node:url';
+import { createStateStore } from '../../src/tdd/state-store.js';
let __dirname = dirname(fileURLToPath(import.meta.url));
@@ -60,7 +61,6 @@ function setupMockVizzlyDir(workingDir, options = {}) {
let vizzlyDir = join(workingDir, '.vizzly');
mkdirSync(vizzlyDir, { recursive: true });
- // Create report-data.json
let reportData = options.reportData || {
comparisons: [
{
@@ -74,10 +74,9 @@ function setupMockVizzlyDir(workingDir, options = {}) {
],
timestamp: Date.now(),
};
- writeFileSync(
- join(vizzlyDir, 'report-data.json'),
- JSON.stringify(reportData)
- );
+ let store = createStateStore({ workingDir, mode: 'write' });
+ store.replaceReportData(reportData);
+ store.close();
// Create image directories with test images
let imageData = Buffer.from(
@@ -238,14 +237,11 @@ describe('services/static-report-generator', () => {
return;
}
- let vizzlyDir = setupMockVizzlyDir(tempDir);
+ setupMockVizzlyDir(tempDir);
- // Add baseline metadata
- let metadataDir = join(vizzlyDir, 'baselines');
- writeFileSync(
- join(metadataDir, 'metadata.json'),
- JSON.stringify({ branch: 'main', commit: 'abc123' })
- );
+ let store = createStateStore({ workingDir: tempDir, mode: 'write' });
+ store.setBaselineMetadata({ branch: 'main', commit: 'abc123' });
+ store.close();
let { generateStaticReport } = await import(
'../../src/services/static-report-generator.js'
diff --git a/tests/tdd/metadata/baseline-metadata.test.js b/tests/tdd/metadata/baseline-metadata.test.js
index 450fb38f..138eaf19 100644
--- a/tests/tdd/metadata/baseline-metadata.test.js
+++ b/tests/tdd/metadata/baseline-metadata.test.js
@@ -1,23 +1,20 @@
import assert from 'node:assert';
-import {
- existsSync,
- mkdirSync,
- readFileSync,
- rmSync,
- writeFileSync,
-} from 'node:fs';
+import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, it } from 'node:test';
import {
createEmptyBaselineMetadata,
findScreenshotBySignature,
+ loadBaselineBuildMetadata,
loadBaselineMetadata,
+ saveBaselineBuildMetadata,
saveBaselineMetadata,
upsertScreenshotInMetadata,
} from '../../../src/tdd/metadata/baseline-metadata.js';
describe('tdd/metadata/baseline-metadata', () => {
let testDir = join(process.cwd(), '.test-baseline-metadata');
+ let baselinePath = join(testDir, '.vizzly', 'baselines');
beforeEach(() => {
if (existsSync(testDir)) {
@@ -32,68 +29,78 @@ describe('tdd/metadata/baseline-metadata', () => {
});
describe('loadBaselineMetadata', () => {
- it('returns null when metadata file does not exist', () => {
- let result = loadBaselineMetadata(testDir);
+ it('returns null when metadata does not exist', () => {
+ let result = loadBaselineMetadata(baselinePath);
assert.strictEqual(result, null);
});
- it('loads and parses existing metadata file', () => {
- mkdirSync(testDir, { recursive: true });
+ it('loads metadata after saving', () => {
let metadata = { buildId: 'test-123', screenshots: [] };
- writeFileSync(join(testDir, 'metadata.json'), JSON.stringify(metadata));
+ saveBaselineMetadata(baselinePath, metadata);
- let result = loadBaselineMetadata(testDir);
+ let result = loadBaselineMetadata(baselinePath);
assert.deepStrictEqual(result, metadata);
});
- it('returns null for invalid JSON', () => {
- mkdirSync(testDir, { recursive: true });
- writeFileSync(join(testDir, 'metadata.json'), 'not valid json {{{');
+ it('imports legacy baselines/metadata.json into DB', () => {
+ mkdirSync(baselinePath, { recursive: true });
+ let metadata = {
+ buildId: 'legacy-build',
+ screenshots: [{ name: 'home' }],
+ };
+ writeFileSync(
+ join(baselinePath, 'metadata.json'),
+ JSON.stringify(metadata)
+ );
- let result = loadBaselineMetadata(testDir);
+ let result = loadBaselineMetadata(baselinePath, { mode: 'write' });
- assert.strictEqual(result, null);
+ assert.deepStrictEqual(result, metadata);
});
});
describe('saveBaselineMetadata', () => {
- it('creates directory and saves metadata', () => {
+ it('creates state db and saves metadata', () => {
let metadata = { buildId: 'new-build', screenshots: [] };
- saveBaselineMetadata(testDir, metadata);
+ saveBaselineMetadata(baselinePath, metadata);
- assert.strictEqual(existsSync(testDir), true);
- let content = JSON.parse(
- readFileSync(join(testDir, 'metadata.json'), 'utf8')
+ assert.strictEqual(
+ existsSync(join(testDir, '.vizzly', 'state.db')),
+ true
);
- assert.deepStrictEqual(content, metadata);
+ assert.deepStrictEqual(loadBaselineMetadata(baselinePath), metadata);
});
- it('overwrites existing metadata file', () => {
- mkdirSync(testDir, { recursive: true });
- writeFileSync(
- join(testDir, 'metadata.json'),
- JSON.stringify({ old: true })
- );
- let newMetadata = { buildId: 'updated', screenshots: [] };
+ it('overwrites existing metadata', () => {
+ saveBaselineMetadata(baselinePath, { buildId: 'old-build' });
+ let newMetadata = { buildId: 'updated-build', screenshots: [] };
- saveBaselineMetadata(testDir, newMetadata);
+ saveBaselineMetadata(baselinePath, newMetadata);
- let content = JSON.parse(
- readFileSync(join(testDir, 'metadata.json'), 'utf8')
- );
- assert.deepStrictEqual(content, newMetadata);
+ assert.deepStrictEqual(loadBaselineMetadata(baselinePath), newMetadata);
});
+ });
+
+ describe('baseline build metadata', () => {
+ it('saves and loads baseline build metadata', () => {
+ let metadata = {
+ buildId: 'build-1',
+ commitSha: 'abc123',
+ downloadedAt: '2025-01-01T00:00:00Z',
+ };
- it('writes formatted JSON with 2-space indent', () => {
- let metadata = { key: 'value' };
+ saveBaselineBuildMetadata(testDir, metadata);
- saveBaselineMetadata(testDir, metadata);
+ let result = loadBaselineBuildMetadata(testDir);
+ assert.deepStrictEqual(result, metadata);
+ });
- let raw = readFileSync(join(testDir, 'metadata.json'), 'utf8');
- assert.strictEqual(raw, JSON.stringify(metadata, null, 2));
+ it('returns null when baseline build metadata is missing', () => {
+ let result = loadBaselineBuildMetadata(testDir);
+ assert.strictEqual(result, null);
});
});
diff --git a/tests/tdd/metadata/hotspot-metadata.test.js b/tests/tdd/metadata/hotspot-metadata.test.js
index 4f3463b4..d3b4d466 100644
--- a/tests/tdd/metadata/hotspot-metadata.test.js
+++ b/tests/tdd/metadata/hotspot-metadata.test.js
@@ -1,11 +1,5 @@
import assert from 'node:assert';
-import {
- existsSync,
- mkdirSync,
- readFileSync,
- rmSync,
- writeFileSync,
-} from 'node:fs';
+import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, it } from 'node:test';
import {
@@ -14,6 +8,7 @@ import {
loadHotspotMetadata,
saveHotspotMetadata,
} from '../../../src/tdd/metadata/hotspot-metadata.js';
+import { createStateStore } from '../../../src/tdd/state-store.js';
describe('tdd/metadata/hotspot-metadata', () => {
let testDir = join(process.cwd(), '.test-hotspot-metadata');
@@ -32,98 +27,71 @@ describe('tdd/metadata/hotspot-metadata', () => {
});
describe('loadHotspotMetadata', () => {
- it('returns null when hotspots.json does not exist', () => {
+ it('returns null when hotspot metadata does not exist', () => {
let result = loadHotspotMetadata(testDir);
assert.strictEqual(result, null);
});
- it('loads and returns hotspots from file', () => {
+ it('loads hotspot metadata after saving', () => {
+ let hotspotData = {
+ homepage: { regions: [{ y1: 0, y2: 100 }], confidence: 'high' },
+ };
+
+ saveHotspotMetadata(testDir, hotspotData);
+
+ let result = loadHotspotMetadata(testDir);
+ assert.deepStrictEqual(result, hotspotData);
+ });
+
+ it('imports legacy hotspots.json into DB', () => {
mkdirSync(vizzlyDir, { recursive: true });
let hotspotsData = {
+ downloadedAt: '2025-01-01T00:00:00Z',
+ summary: { totalScreenshots: 1 },
hotspots: {
homepage: { regions: [{ y1: 0, y2: 100 }], confidence: 'high' },
},
};
+
writeFileSync(
join(vizzlyDir, 'hotspots.json'),
JSON.stringify(hotspotsData)
);
- let result = loadHotspotMetadata(testDir);
-
+ let result = loadHotspotMetadata(testDir, { mode: 'write' });
assert.deepStrictEqual(result, hotspotsData.hotspots);
});
-
- it('returns null when hotspots field is missing', () => {
- mkdirSync(vizzlyDir, { recursive: true });
- writeFileSync(
- join(vizzlyDir, 'hotspots.json'),
- JSON.stringify({ other: 'data' })
- );
-
- let result = loadHotspotMetadata(testDir);
-
- assert.strictEqual(result, null);
- });
-
- it('returns null for invalid JSON', () => {
- mkdirSync(vizzlyDir, { recursive: true });
- writeFileSync(join(vizzlyDir, 'hotspots.json'), 'not valid json');
-
- let result = loadHotspotMetadata(testDir);
-
- assert.strictEqual(result, null);
- });
});
describe('saveHotspotMetadata', () => {
- it('creates .vizzly directory and saves hotspots', () => {
+ it('stores hotspot metadata in state db', () => {
let hotspotData = {
homepage: { regions: [{ y1: 0, y2: 100 }] },
};
saveHotspotMetadata(testDir, hotspotData);
- assert.strictEqual(existsSync(vizzlyDir), true);
- let content = JSON.parse(
- readFileSync(join(vizzlyDir, 'hotspots.json'), 'utf8')
+ assert.strictEqual(
+ existsSync(join(testDir, '.vizzly', 'state.db')),
+ true
);
- assert.deepStrictEqual(content.hotspots, hotspotData);
- assert.ok(content.downloadedAt);
+ assert.deepStrictEqual(loadHotspotMetadata(testDir), hotspotData);
});
- it('includes summary in saved data', () => {
+ it('stores summary with hotspot metadata', () => {
let hotspotData = { homepage: {} };
let summary = { totalScreenshots: 5, screenshotsWithHotspots: 3 };
saveHotspotMetadata(testDir, hotspotData, summary);
- let content = JSON.parse(
- readFileSync(join(vizzlyDir, 'hotspots.json'), 'utf8')
- );
- assert.deepStrictEqual(content.summary, summary);
- });
-
- it('writes formatted JSON', () => {
- let hotspotData = { key: 'value' };
-
- saveHotspotMetadata(testDir, hotspotData);
-
- let raw = readFileSync(join(vizzlyDir, 'hotspots.json'), 'utf8');
- assert.ok(raw.includes('\n')); // Check it's formatted
- });
-
- it('works when .vizzly directory already exists', () => {
- mkdirSync(vizzlyDir, { recursive: true });
- let hotspotData = { test: {} };
-
- saveHotspotMetadata(testDir, hotspotData);
-
- let content = JSON.parse(
- readFileSync(join(vizzlyDir, 'hotspots.json'), 'utf8')
- );
- assert.deepStrictEqual(content.hotspots, hotspotData);
+ let store = createStateStore({ workingDir: testDir, mode: 'write' });
+ try {
+ let bundle = store.getHotspotBundle();
+ assert.deepStrictEqual(bundle.summary, summary);
+ } finally {
+ store.close();
+ }
});
});
@@ -136,16 +104,16 @@ describe('tdd/metadata/hotspot-metadata', () => {
});
describe('getHotspotForScreenshot', () => {
- it('returns null when cache is empty and no file exists', () => {
+ it('returns null when cache is empty and no metadata exists', () => {
let cache = createHotspotCache();
let result = getHotspotForScreenshot(cache, testDir, 'homepage');
assert.strictEqual(result, null);
- assert.strictEqual(cache.loaded, true); // Should mark as loaded
+ assert.strictEqual(cache.loaded, true);
});
- it('returns cached data without reading file again', () => {
+ it('returns cached data without loading from storage', () => {
let cache = {
data: { homepage: { regions: [], confidence: 'high' } },
loaded: true,
@@ -156,15 +124,11 @@ describe('tdd/metadata/hotspot-metadata', () => {
assert.deepStrictEqual(result, { regions: [], confidence: 'high' });
});
- it('loads from disk on first access and caches', () => {
- mkdirSync(vizzlyDir, { recursive: true });
+ it('loads from storage on first access and caches', () => {
let hotspotData = {
homepage: { regions: [{ y1: 10, y2: 50 }], confidence: 'medium' },
};
- writeFileSync(
- join(vizzlyDir, 'hotspots.json'),
- JSON.stringify({ hotspots: hotspotData })
- );
+ saveHotspotMetadata(testDir, hotspotData);
let cache = createHotspotCache();
let result = getHotspotForScreenshot(cache, testDir, 'homepage');
@@ -186,7 +150,6 @@ describe('tdd/metadata/hotspot-metadata', () => {
});
it('returns from cache.data before checking loaded flag', () => {
- // Even if loaded is false, if data exists for this screenshot, return it
let cache = {
data: { homepage: { regions: [], confidence: 'low' } },
loaded: false,
@@ -195,7 +158,6 @@ describe('tdd/metadata/hotspot-metadata', () => {
let result = getHotspotForScreenshot(cache, testDir, 'homepage');
assert.deepStrictEqual(result, { regions: [], confidence: 'low' });
- // loaded should still be false since we got a cache hit
assert.strictEqual(cache.loaded, false);
});
});
diff --git a/tests/tdd/metadata/region-metadata.test.js b/tests/tdd/metadata/region-metadata.test.js
new file mode 100644
index 00000000..539b4e82
--- /dev/null
+++ b/tests/tdd/metadata/region-metadata.test.js
@@ -0,0 +1,145 @@
+import assert from 'node:assert';
+import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { afterEach, beforeEach, describe, it } from 'node:test';
+import {
+ createRegionCache,
+ getRegionsForScreenshot,
+ loadRegionMetadata,
+ saveRegionMetadata,
+} from '../../../src/tdd/metadata/region-metadata.js';
+import { createStateStore } from '../../../src/tdd/state-store.js';
+
+describe('tdd/metadata/region-metadata', () => {
+ let testDir = join(process.cwd(), '.test-region-metadata');
+ let vizzlyDir = join(testDir, '.vizzly');
+
+ beforeEach(() => {
+ if (existsSync(testDir)) {
+ rmSync(testDir, { recursive: true, force: true });
+ }
+ });
+
+ afterEach(() => {
+ if (existsSync(testDir)) {
+ rmSync(testDir, { recursive: true, force: true });
+ }
+ });
+
+ describe('loadRegionMetadata', () => {
+ it('returns null when region metadata does not exist', () => {
+ let result = loadRegionMetadata(testDir);
+ assert.strictEqual(result, null);
+ });
+
+ it('loads region metadata after saving', () => {
+ let regionData = {
+ homepage: {
+ confirmed: [{ x: 1, y: 2, width: 50, height: 20 }],
+ candidates: [],
+ },
+ };
+
+ saveRegionMetadata(testDir, regionData);
+
+ let result = loadRegionMetadata(testDir);
+ assert.deepStrictEqual(result, regionData);
+ });
+
+ it('imports legacy regions.json into DB', () => {
+ mkdirSync(vizzlyDir, { recursive: true });
+ let legacy = {
+ downloadedAt: '2025-01-01T00:00:00Z',
+ summary: { total_regions: 2 },
+ regions: {
+ homepage: {
+ confirmed: [{ x: 1, y: 2, width: 50, height: 20 }],
+ candidates: [],
+ },
+ },
+ };
+
+ writeFileSync(join(vizzlyDir, 'regions.json'), JSON.stringify(legacy));
+
+ let result = loadRegionMetadata(testDir, { mode: 'write' });
+ assert.deepStrictEqual(result, legacy.regions);
+ });
+ });
+
+ describe('saveRegionMetadata', () => {
+ it('stores region metadata in state db', () => {
+ let regionData = {
+ homepage: { confirmed: [{ x: 1, y: 2, width: 50, height: 20 }] },
+ };
+
+ saveRegionMetadata(testDir, regionData);
+
+ assert.strictEqual(
+ existsSync(join(testDir, '.vizzly', 'state.db')),
+ true
+ );
+ assert.deepStrictEqual(loadRegionMetadata(testDir), regionData);
+ });
+
+ it('stores summary with region metadata', () => {
+ let regionData = { homepage: { confirmed: [], candidates: [] } };
+ let summary = { total_regions: 4 };
+
+ saveRegionMetadata(testDir, regionData, summary);
+
+ let store = createStateStore({ workingDir: testDir, mode: 'write' });
+ try {
+ let bundle = store.getRegionBundle();
+ assert.deepStrictEqual(bundle.summary, summary);
+ } finally {
+ store.close();
+ }
+ });
+ });
+
+ describe('createRegionCache', () => {
+ it('creates empty cache object', () => {
+ assert.deepStrictEqual(createRegionCache(), {
+ data: null,
+ loaded: false,
+ });
+ });
+ });
+
+ describe('getRegionsForScreenshot', () => {
+ it('returns null when cache is empty and no metadata exists', () => {
+ let cache = createRegionCache();
+ let result = getRegionsForScreenshot(cache, testDir, 'homepage');
+
+ assert.strictEqual(result, null);
+ assert.strictEqual(cache.loaded, true);
+ });
+
+ it('returns cached data without loading from storage', () => {
+ let cache = {
+ data: { homepage: { confirmed: [], candidates: [] } },
+ loaded: true,
+ };
+
+ let result = getRegionsForScreenshot(cache, testDir, 'homepage');
+ assert.deepStrictEqual(result, { confirmed: [], candidates: [] });
+ });
+
+ it('loads from storage on first access and caches', () => {
+ let regionData = {
+ homepage: {
+ confirmed: [{ x: 1, y: 2, width: 50, height: 20 }],
+ candidates: [],
+ },
+ };
+ saveRegionMetadata(testDir, regionData);
+
+ let cache = createRegionCache();
+ let result = getRegionsForScreenshot(cache, testDir, 'homepage');
+
+ assert.deepStrictEqual(result, regionData.homepage);
+ assert.strictEqual(cache.loaded, true);
+ assert.deepStrictEqual(cache.data, regionData);
+ });
+ });
+});
diff --git a/tests/tdd/server-registry.test.js b/tests/tdd/server-registry.test.js
index ffb35d59..b0b6d4d6 100644
--- a/tests/tdd/server-registry.test.js
+++ b/tests/tdd/server-registry.test.js
@@ -13,6 +13,7 @@ function createTestRegistry(testDir) {
let registry = new ServerRegistry();
registry.vizzlyHome = testDir;
registry.registryPath = join(testDir, 'servers.json');
+ registry.dbPath = join(testDir, 'servers.db');
// Disable menubar notifications in tests
registry.notifyMenubar = () => {};
return registry;
@@ -44,6 +45,9 @@ describe('tdd/server-registry', () => {
});
afterEach(() => {
+ if (registry) {
+ registry.close();
+ }
if (existsSync(testDir)) {
rmSync(testDir, { recursive: true, force: true });
}
@@ -250,6 +254,30 @@ describe('tdd/server-registry', () => {
registry.register({ pid: 1, port: 47392, directory: '/a' });
assert.strictEqual(registry.list().length, 1);
});
+
+ it('imports valid legacy servers.json on first load', () => {
+ writeFileSync(
+ registry.registryPath,
+ JSON.stringify({
+ version: 1,
+ servers: [
+ {
+ id: 'legacy-1',
+ port: 47392,
+ pid: process.pid,
+ directory: '/legacy-app',
+ startedAt: '2025-01-01T00:00:00.000Z',
+ name: 'legacy-app',
+ },
+ ],
+ })
+ );
+
+ let servers = registry.list();
+ assert.strictEqual(servers.length, 1);
+ assert.strictEqual(servers[0].id, 'legacy-1');
+ assert.strictEqual(servers[0].directory, '/legacy-app');
+ });
});
describe('replaces existing entries', () => {
@@ -272,4 +300,38 @@ describe('tdd/server-registry', () => {
assert.strictEqual(servers[0].directory, '/projects/app-b');
});
});
+
+ describe('write', () => {
+ it('deduplicates conflicting rows and keeps the last one', () => {
+ registry.write({
+ servers: [
+ { id: 'a', pid: 1, port: 47392, directory: '/projects/app-a' },
+ { id: 'b', pid: 2, port: 47392, directory: '/projects/app-b' },
+ { id: 'c', pid: 3, port: 47393, directory: '/projects/app-b' },
+ ],
+ });
+
+ let servers = registry.list();
+ assert.strictEqual(servers.length, 1);
+ assert.strictEqual(servers[0].id, 'c');
+ assert.strictEqual(servers[0].port, 47393);
+ assert.strictEqual(servers[0].directory, '/projects/app-b');
+ });
+
+ it('skips rows with invalid numeric fields', () => {
+ registry.write({
+ servers: [
+ { id: 'bad-port', pid: 1, port: 'oops', directory: '/bad-port' },
+ { id: 'ok', pid: 2, port: 47395, directory: '/ok' },
+ { id: 'bad-pid', pid: 'oops', port: 47396, directory: '/bad-pid' },
+ ],
+ });
+
+ let servers = registry.list();
+ assert.strictEqual(servers.length, 1);
+ assert.strictEqual(servers[0].id, 'ok');
+ assert.strictEqual(servers[0].port, 47395);
+ assert.strictEqual(servers[0].pid, 2);
+ });
+ });
});
diff --git a/tests/tdd/state-store-mode.test.js b/tests/tdd/state-store-mode.test.js
new file mode 100644
index 00000000..d7e16c43
--- /dev/null
+++ b/tests/tdd/state-store-mode.test.js
@@ -0,0 +1,143 @@
+import assert from 'node:assert';
+import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { afterEach, beforeEach, describe, it } from 'node:test';
+import {
+ bootstrapLegacyStateIfNeeded,
+ createStateStore,
+} from '../../src/tdd/state-store.js';
+
+describe('tdd/state-store mode contract', () => {
+ let testDir = join(process.cwd(), '.test-state-store-mode');
+
+ beforeEach(() => {
+ if (existsSync(testDir)) {
+ rmSync(testDir, { recursive: true, force: true });
+ }
+ mkdirSync(testDir, { recursive: true });
+ });
+
+ afterEach(() => {
+ if (existsSync(testDir)) {
+ rmSync(testDir, { recursive: true, force: true });
+ }
+ });
+
+ it('read mode does not create db and throws on write operations', () => {
+ let readStore = createStateStore({ workingDir: testDir, mode: 'read' });
+ try {
+ assert.strictEqual(readStore.readReportData(), null);
+ assert.strictEqual(readStore.getBaselineMetadata(), null);
+ assert.strictEqual(
+ existsSync(join(testDir, '.vizzly', 'state.db')),
+ false
+ );
+
+ assert.throws(() => {
+ readStore.replaceReportData({ comparisons: [] });
+ }, /read-only/);
+ } finally {
+ readStore.close();
+ }
+ });
+
+ it('write mode persists data and read mode can read it back', () => {
+ let writeStore = createStateStore({ workingDir: testDir, mode: 'write' });
+ try {
+ writeStore.replaceReportData({
+ comparisons: [{ id: 'a', name: 'home', status: 'passed' }],
+ });
+ writeStore.setBaselineMetadata({
+ buildId: 'build-1',
+ screenshots: [{ name: 'home' }],
+ });
+ } finally {
+ writeStore.close();
+ }
+
+ let readStore = createStateStore({ workingDir: testDir, mode: 'read' });
+ try {
+ let reportData = readStore.readReportData();
+ assert.strictEqual(reportData.comparisons.length, 1);
+ assert.strictEqual(readStore.getBaselineMetadata().buildId, 'build-1');
+
+ assert.throws(() => {
+ readStore.setBaselineMetadata({ buildId: 'build-2' });
+ }, /read-only/);
+ } finally {
+ readStore.close();
+ }
+ });
+
+ it('runs legacy migration only when state.db is first created', () => {
+ let vizzlyDir = join(testDir, '.vizzly');
+ mkdirSync(vizzlyDir, { recursive: true });
+
+ writeFileSync(
+ join(vizzlyDir, 'report-data.json'),
+ JSON.stringify({
+ timestamp: 123,
+ comparisons: [{ id: 'a', name: 'home', status: 'passed' }],
+ })
+ );
+
+ let firstStore = createStateStore({ workingDir: testDir, mode: 'write' });
+ try {
+ let data = firstStore.readReportData();
+ assert.strictEqual(data.comparisons.length, 1);
+ } finally {
+ firstStore.close();
+ }
+
+ let secondStore = createStateStore({ workingDir: testDir, mode: 'write' });
+ try {
+ secondStore.resetReportData();
+ } finally {
+ secondStore.close();
+ }
+
+ let thirdStore = createStateStore({ workingDir: testDir, mode: 'write' });
+ try {
+ let data = thirdStore.readReportData();
+ assert.strictEqual(data.comparisons.length, 0);
+ } finally {
+ thirdStore.close();
+ }
+ });
+
+ it('bootstraps legacy state when invoked before first writer command', () => {
+ let vizzlyDir = join(testDir, '.vizzly');
+ mkdirSync(vizzlyDir, { recursive: true });
+
+ writeFileSync(
+ join(vizzlyDir, 'report-data.json'),
+ JSON.stringify({
+ timestamp: 456,
+ comparisons: [{ id: 'b', name: 'settings', status: 'failed' }],
+ })
+ );
+
+ let migrated = bootstrapLegacyStateIfNeeded({
+ workingDir: testDir,
+ output: {},
+ });
+
+ assert.strictEqual(migrated, true);
+ assert.strictEqual(existsSync(join(vizzlyDir, 'state.db')), true);
+
+ let readStore = createStateStore({ workingDir: testDir, mode: 'read' });
+ try {
+ let data = readStore.readReportData();
+ assert.strictEqual(data.comparisons.length, 1);
+ assert.strictEqual(data.comparisons[0].id, 'b');
+ } finally {
+ readStore.close();
+ }
+
+ let migratedAgain = bootstrapLegacyStateIfNeeded({
+ workingDir: testDir,
+ output: {},
+ });
+ assert.strictEqual(migratedAgain, false);
+ });
+});
diff --git a/tests/tdd/tdd-service.integration.test.js b/tests/tdd/tdd-service.integration.test.js
new file mode 100644
index 00000000..213178c2
--- /dev/null
+++ b/tests/tdd/tdd-service.integration.test.js
@@ -0,0 +1,300 @@
+import assert from 'node:assert';
+import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { afterEach, describe, it } from 'node:test';
+import { createStateStore } from '../../src/tdd/state-store.js';
+import { TddService } from '../../src/tdd/tdd-service.js';
+
+let testDirs = [];
+
+function createTestDir() {
+ let dir = mkdtempSync(join(tmpdir(), 'vizzly-tdd-service-integration-'));
+ testDirs.push(dir);
+ return dir;
+}
+
+function createOutputStub() {
+ return {
+ info: () => {},
+ debug: () => {},
+ warn: () => {},
+ error: () => {},
+ blank: () => {},
+ print: () => {},
+ isVerbose: () => false,
+ diffBar: () => '░░░░░░░░░░',
+ };
+}
+
+function createService(workingDir, deps = {}) {
+ return new TddService({}, workingDir, false, null, {
+ output: createOutputStub(),
+ ...deps,
+ });
+}
+
+afterEach(() => {
+ for (let dir of testDirs) {
+ if (existsSync(dir)) {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ }
+ testDirs = [];
+});
+
+describe('tdd/tdd-service integration', () => {
+ it('creates a new baseline and persists metadata in sqlite state', async () => {
+ let workingDir = createTestDir();
+ let service = createService(workingDir);
+
+ let result = await service.compareScreenshot(
+ 'home-page',
+ Buffer.from('image-a'),
+ {
+ browser: 'chrome',
+ viewport: { width: 1280, height: 720 },
+ }
+ );
+
+ assert.strictEqual(result.status, 'new');
+ assert.strictEqual(existsSync(result.baseline), true);
+ assert.strictEqual(existsSync(result.current), true);
+
+ let store = createStateStore({ workingDir, mode: 'write' });
+ try {
+ let metadata = store.getBaselineMetadata();
+ assert.ok(metadata);
+ assert.strictEqual(Array.isArray(metadata.screenshots), true);
+ assert.strictEqual(metadata.screenshots.length, 1);
+ assert.strictEqual(metadata.screenshots[0].name, 'home-page');
+ assert.strictEqual(metadata.screenshots[0].signature, result.signature);
+ } finally {
+ store.close();
+ }
+ });
+
+ it('loads baseline metadata from sqlite in a new service instance', async () => {
+ let workingDir = createTestDir();
+ let serviceA = createService(workingDir);
+
+ await serviceA.compareScreenshot('settings-page', Buffer.from('image-a'), {
+ browser: 'chrome',
+ viewport: { width: 1440, height: 900 },
+ });
+
+ let serviceB = createService(workingDir);
+ let baseline = await serviceB.loadBaseline();
+
+ assert.ok(baseline);
+ assert.strictEqual(baseline.screenshots.length, 1);
+ assert.strictEqual(baseline.screenshots[0].name, 'settings-page');
+ });
+
+ it('returns passed on second run when external comparer reports no diff', async () => {
+ let workingDir = createTestDir();
+ let service = createService(workingDir, {
+ comparison: {
+ compareImages: async () => ({
+ isDifferent: false,
+ totalPixels: 100,
+ aaPixelsIgnored: 0,
+ aaPercentage: 0,
+ }),
+ },
+ });
+
+ await service.compareScreenshot('profile', Buffer.from('image-a'), {
+ browser: 'chrome',
+ viewport: { width: 1280, height: 720 },
+ });
+
+ let result = await service.compareScreenshot(
+ 'profile',
+ Buffer.from('image-b'),
+ {
+ browser: 'chrome',
+ viewport: { width: 1280, height: 720 },
+ }
+ );
+
+ assert.strictEqual(result.status, 'passed');
+ assert.strictEqual(result.diff, null);
+
+ let summary = service.getResults();
+ assert.strictEqual(summary.total, 1);
+ assert.strictEqual(summary.passed, 1);
+ assert.strictEqual(summary.failed, 0);
+ });
+
+ it('returns failed and updates summary when comparer reports a diff', async () => {
+ let workingDir = createTestDir();
+ let service = createService(workingDir, {
+ comparison: {
+ compareImages: async () => ({
+ isDifferent: true,
+ diffPercentage: 12.5,
+ diffPixels: 42,
+ totalPixels: 400,
+ aaPixelsIgnored: 3,
+ aaPercentage: 0.75,
+ boundingBox: { x: 0, y: 0, width: 20, height: 20 },
+ heightDiff: 0,
+ intensityStats: { mean: 0.3, max: 0.8 },
+ diffClusters: [{ x: 1, y: 1, width: 5, height: 5, pixelCount: 10 }],
+ }),
+ },
+ });
+
+ await service.compareScreenshot('dashboard', Buffer.from('image-a'), {
+ browser: 'chrome',
+ viewport: { width: 1280, height: 720 },
+ });
+
+ let result = await service.compareScreenshot(
+ 'dashboard',
+ Buffer.from('image-b'),
+ {
+ browser: 'chrome',
+ viewport: { width: 1280, height: 720 },
+ }
+ );
+
+ assert.strictEqual(result.status, 'failed');
+ assert.strictEqual(result.diffPercentage, 12.5);
+ assert.strictEqual(result.diffCount, 42);
+ assert.strictEqual(Array.isArray(result.diffClusters), true);
+ assert.strictEqual(result.diffClusters.length, 1);
+
+ let summary = service.getResults();
+ assert.strictEqual(summary.total, 1);
+ assert.strictEqual(summary.failed, 1);
+ assert.strictEqual(summary.passed, 0);
+ });
+
+ it('accepts a changed screenshot and rewrites baseline + metadata', async () => {
+ let workingDir = createTestDir();
+ let service = createService(workingDir, {
+ comparison: {
+ compareImages: async () => ({
+ isDifferent: true,
+ diffPercentage: 3.2,
+ diffPixels: 8,
+ totalPixels: 100,
+ aaPixelsIgnored: 0,
+ aaPercentage: 0,
+ diffClusters: [{ x: 1, y: 1, width: 2, height: 2, pixelCount: 4 }],
+ }),
+ },
+ });
+
+ await service.compareScreenshot('avatar', Buffer.from('image-a'), {
+ browser: 'chrome',
+ viewport: { width: 1280, height: 720 },
+ });
+
+ let changed = await service.compareScreenshot(
+ 'avatar',
+ Buffer.from('image-b'),
+ {
+ browser: 'chrome',
+ viewport: { width: 1280, height: 720 },
+ }
+ );
+
+ let acceptance = await service.acceptBaseline(changed.id);
+ assert.strictEqual(acceptance.status, 'accepted');
+
+ let baselineBytes = readFileSync(changed.baseline);
+ assert.strictEqual(String(baselineBytes), 'image-b');
+
+ let store = createStateStore({ workingDir, mode: 'write' });
+ try {
+ let metadata = store.getBaselineMetadata();
+ assert.ok(metadata);
+ assert.strictEqual(metadata.screenshots.length, 1);
+ assert.strictEqual(metadata.screenshots[0].signature, changed.signature);
+ } finally {
+ store.close();
+ }
+ });
+
+ it('processes downloaded baselines and persists build/hotspot/region metadata', async () => {
+ let workingDir = createTestDir();
+ let service = createService(workingDir, {
+ api: {
+ fetchWithTimeout: async url => ({
+ ok: true,
+ statusText: 'OK',
+ arrayBuffer: async () => {
+ if (!url.includes('example.com')) {
+ throw new Error('Unexpected download URL');
+ }
+ return Uint8Array.from([1, 2, 3]).buffer;
+ },
+ }),
+ },
+ });
+
+ let baseline = await service.processDownloadedBaselines(
+ {
+ build: {
+ id: 'build-123',
+ name: 'Build 123',
+ status: 'completed',
+ commit_sha: 'abc123',
+ commit_message: 'feat: update',
+ approval_status: 'approved',
+ completed_at: '2026-01-01T00:00:00.000Z',
+ },
+ screenshots: [
+ {
+ id: 'ss-1',
+ name: 'checkout',
+ filename: 'checkout.png',
+ original_url: 'https://example.com/checkout.png',
+ },
+ ],
+ hotspots: {
+ checkout: {
+ confidence: 'high',
+ confidence_score: 90,
+ regions: [],
+ },
+ },
+ regions: {
+ checkout: {
+ confirmed: [{ id: 'r-1', x: 0, y: 0, width: 100, height: 30 }],
+ candidates: [],
+ },
+ },
+ summary: { total: 1 },
+ },
+ 'build-123'
+ );
+
+ assert.ok(baseline);
+ assert.strictEqual(baseline.buildId, 'build-123');
+ assert.strictEqual(
+ existsSync(join(workingDir, '.vizzly', 'baselines', 'checkout.png')),
+ true
+ );
+
+ let store = createStateStore({ workingDir, mode: 'write' });
+ try {
+ let buildMetadata = store.getBaselineBuildMetadata();
+ assert.ok(buildMetadata);
+ assert.strictEqual(buildMetadata.buildId, 'build-123');
+
+ let hotspotBundle = store.getHotspotBundle();
+ assert.ok(hotspotBundle);
+ assert.ok(hotspotBundle.hotspots.checkout);
+
+ let regionBundle = store.getRegionBundle();
+ assert.ok(regionBundle);
+ assert.ok(regionBundle.regions.checkout);
+ } finally {
+ store.close();
+ }
+ });
+});
diff --git a/tests/tdd/tdd-service.test.js b/tests/tdd/tdd-service.test.js
index f845f1b7..b8092f43 100644
--- a/tests/tdd/tdd-service.test.js
+++ b/tests/tdd/tdd-service.test.js
@@ -49,7 +49,9 @@ function createMockDeps(overrides = {}) {
};
let defaultMetadata = {
+ loadBaselineBuildMetadata: () => null,
loadBaselineMetadata: () => null,
+ saveBaselineBuildMetadata: () => {},
saveBaselineMetadata: () => {},
createEmptyBaselineMetadata: opts => ({
buildId: 'local',
@@ -60,6 +62,8 @@ function createMockDeps(overrides = {}) {
upsertScreenshotInMetadata: () => {},
loadHotspotMetadata: () => null,
saveHotspotMetadata: () => {},
+ loadRegionMetadata: () => null,
+ saveRegionMetadata: () => {},
};
let defaultBaseline = {
@@ -1551,7 +1555,7 @@ describe('tdd/tdd-service', () => {
await service.processDownloadedBaselines(apiResponse, 'build-1');
assert.ok(fetchCalled, 'Should fetch when SHA differs');
- assert.strictEqual(writtenFiles.length, 2); // screenshot + metadata
+ assert.strictEqual(writtenFiles.length, 1); // screenshot only
});
it('skips screenshots without download URL', async () => {
@@ -1928,17 +1932,20 @@ describe('tdd/tdd-service', () => {
assert.ok(batchCalls.length >= 2, 'Should process in at least 2 batches');
});
- it('saves baseline-metadata.json for MCP plugin', async () => {
- let writtenFiles = [];
+ it('saves baseline build metadata for MCP plugin', async () => {
+ let baselineBuildMetadata = null;
let mockDeps = createMockDeps({
baseline: { clearBaselineData: () => {} },
fs: {
existsSync: () => false,
- writeFileSync: (path, data) => writtenFiles.push({ path, data }),
+ writeFileSync: () => {},
},
metadata: {
loadBaselineMetadata: () => null,
saveBaselineMetadata: () => {},
+ saveBaselineBuildMetadata: (_workingDir, metadata) => {
+ baselineBuildMetadata = metadata;
+ },
},
api: {
fetchWithTimeout: async () => ({
@@ -1968,14 +1975,9 @@ describe('tdd/tdd-service', () => {
await service.processDownloadedBaselines(apiResponse, 'build-1');
- let metadataFile = writtenFiles.find(f =>
- f.path.includes('baseline-metadata.json')
- );
- assert.ok(metadataFile, 'Should write baseline-metadata.json');
-
- let metadata = JSON.parse(metadataFile.data);
- assert.strictEqual(metadata.buildId, 'build-1');
- assert.strictEqual(metadata.commitSha, 'abc123');
+ assert.ok(baselineBuildMetadata, 'Should save baseline build metadata');
+ assert.strictEqual(baselineBuildMetadata.buildId, 'build-1');
+ assert.strictEqual(baselineBuildMetadata.commitSha, 'abc123');
});
it('logs summary with download counts', async () => {
diff --git a/tests/utils/context.test.js b/tests/utils/context.test.js
index dcaddb4c..b584b985 100644
--- a/tests/utils/context.test.js
+++ b/tests/utils/context.test.js
@@ -3,8 +3,15 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, it } from 'node:test';
+import { createStateStore } from '../../src/tdd/state-store.js';
import { getContext, getDetailedContext } from '../../src/utils/context.js';
+function saveBaselineMetadata(workingDir, metadata) {
+ let store = createStateStore({ workingDir, mode: 'write' });
+ store.setBaselineMetadata(metadata);
+ store.close();
+}
+
describe('utils/context', () => {
let testDir;
let originalCwd;
@@ -73,19 +80,9 @@ describe('utils/context', () => {
it('detects baselines in .vizzly directory', () => {
process.chdir(testDir);
- // Create baseline metadata
- let baselinesDir = join(testDir, '.vizzly', 'baselines');
- mkdirSync(baselinesDir, { recursive: true });
- writeFileSync(
- join(baselinesDir, 'metadata.json'),
- JSON.stringify({
- screenshots: [
- { name: 'test1' },
- { name: 'test2' },
- { name: 'test3' },
- ],
- })
- );
+ saveBaselineMetadata(testDir, {
+ screenshots: [{ name: 'test1' }, { name: 'test2' }, { name: 'test3' }],
+ });
let items = getContext();
@@ -218,13 +215,9 @@ describe('utils/context', () => {
process.chdir(testDir);
let baselinesDir = join(testDir, '.vizzly', 'baselines');
- mkdirSync(baselinesDir, { recursive: true });
- writeFileSync(
- join(baselinesDir, 'metadata.json'),
- JSON.stringify({
- screenshots: [{ name: 'test1' }, { name: 'test2' }],
- })
- );
+ saveBaselineMetadata(testDir, {
+ screenshots: [{ name: 'test1' }, { name: 'test2' }],
+ });
let context = getDetailedContext();