From 930070129236870ac3c70e032089286a8d36a06f Mon Sep 17 00:00:00 2001 From: Robert DeLuca Date: Wed, 18 Feb 2026 20:42:14 -0600 Subject: [PATCH 1/3] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20migrate=20TDD=20state?= =?UTF-8?q?=20and=20registry=20to=20SQLite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce schema-versioned SQLite migrations for reporter state and metadata.\nPort baseline/hotspot/region/baseline-build metadata and server registry off JSON files with one-time legacy imports.\nUpdate routers, context, handlers, and tests to read/write through the DB-backed state store. --- package-lock.json | 137 +- package.json | 1 + src/server/handlers/tdd-handler.js | 222 +-- src/server/routers/dashboard.js | 125 +- src/server/routers/events.js | 114 +- src/server/routers/health.js | 41 +- src/services/static-report-generator.js | 27 +- src/tdd/index.js | 2 + src/tdd/metadata/baseline-metadata.js | 106 +- src/tdd/metadata/hotspot-metadata.js | 60 +- src/tdd/metadata/region-metadata.js | 59 +- src/tdd/server-registry.js | 401 ++++- src/tdd/services/hotspot-service.js | 2 +- src/tdd/services/region-service.js | 2 +- src/tdd/state-store.js | 1385 +++++++++++++++++ src/tdd/tdd-service.js | 90 +- src/utils/context.js | 45 +- tests/server/handlers/tdd-handler.test.js | 16 +- tests/server/http-server.test.js | 19 +- tests/server/routers/dashboard.test.js | 123 +- tests/server/routers/events.test.js | 169 +- tests/server/routers/health.test.js | 49 +- .../services/static-report-generator.test.js | 20 +- tests/tdd/metadata/baseline-metadata.test.js | 89 +- tests/tdd/metadata/hotspot-metadata.test.js | 110 +- tests/tdd/metadata/region-metadata.test.js | 145 ++ tests/tdd/server-registry.test.js | 28 + tests/tdd/tdd-service.test.js | 26 +- tests/utils/context.test.js | 33 +- 29 files changed, 2605 insertions(+), 1041 deletions(-) create mode 100644 src/tdd/state-store.js create mode 100644 tests/tdd/metadata/region-metadata.test.js 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/server/handlers/tdd-handler.js b/src/server/handlers/tdd-handler.js index 863d0e52..e5cd185a 100644 --- a/src/server/handlers/tdd-handler.js +++ b/src/server/handlers/tdd-handler.js @@ -1,12 +1,14 @@ import { Buffer as defaultBuffer } from 'node:buffer'; import { existsSync as defaultExistsSync, + mkdirSync as defaultMkdirSync, 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'; @@ -182,6 +184,7 @@ export const createTddHandler = ( let { TddService = DefaultTddService, existsSync = defaultExistsSync, + mkdirSync = defaultMkdirSync, readFileSync = defaultReadFileSync, unlinkSync = defaultUnlinkSync, writeFileSync = defaultWriteFileSync, @@ -194,29 +197,48 @@ export const createTddHandler = ( sanitizeScreenshotName = defaultSanitizeScreenshotName, validateScreenshotProperties = defaultValidateScreenshotProperties, output = defaultOutput, + stateStore: injectedStateStore = null, + stateBackend = 'sqlite', } = 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({ + backend: stateBackend, + workingDir, + output, + existsSync, + mkdirSync, + unlinkSync, + readFileSync, + writeFileSync, + joinPath: join, + }); /** - * 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 +247,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 +469,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,35 +739,14 @@ 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 comparison details - if (existsSync(detailsPath)) { - writeFileSync(detailsPath, JSON.stringify({})); - } + // Clear state store data entirely - fresh start + stateStore.resetReportData(); output.info( `Baselines reset - ${deletedBaselines} baselines deleted, ${deletedCurrents} current screenshots deleted, ${deletedDiffs} diffs deleted` @@ -878,65 +800,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..bdb9f960 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 }); + 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 }); 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 }); + 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..5253b267 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,25 @@ 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; - } - try { - const data = JSON.parse(readFileSync(reportDataPath, 'utf8')); - // Include baseline metadata for stats view - data.baseline = readBaselineMetadata(); - return data; - } catch { + let readReportData = stateStore => { + let data = stateStore.readReportData(); + if (!data) { return null; } + + data.baseline = stateStore.getBaselineMetadata(); + return data; }; /** * 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 +39,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 +47,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 +62,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 +72,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 +111,8 @@ export function createEventsRouter(context) { return false; } + let stateStore = createStateStore({ workingDir }); + // Set SSE headers res.writeHead(200, { 'Content-Type': 'text/event-stream', @@ -146,17 +122,16 @@ export function createEventsRouter(context) { }); // Send initial full data immediately - let lastSentData = readReportData(); + let lastSentData = readReportData(stateStore); if (lastSentData) { 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(stateStore); if (!newData) return; if (!lastSentData) { @@ -171,45 +146,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 = stateStore.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(); + stateStore.close(); }; req.on('close', cleanup); diff --git a/src/server/routers/health.js b/src/server/routers/health.js index 64515528..d30a8d3a 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 }); + 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..20a957bf 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 }); + 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..eb0e6e07 100644 --- a/src/tdd/metadata/baseline-metadata.js +++ b/src/tdd/metadata/baseline-metadata.js @@ -1,50 +1,98 @@ /** * 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, operation) { + let store = createStateStore({ workingDir }); + + 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'); + 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, 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, 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) { + return withStateStore(workingDir, store => { + try { + return store.getBaselineBuildMetadata(); + } catch (error) { + console.debug?.( + `Failed to read baseline build metadata: ${error.message}` + ); + return null; + } + }); +} - let metadataPath = join(baselinePath, 'metadata.json'); - writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); +/** + * 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, store => { + store.setBaselineBuildMetadata(metadata); + }); } /** @@ -86,7 +134,7 @@ export function upsertScreenshotInMetadata( } let existingIndex = metadata.screenshots.findIndex( - s => s.signature === signature + screenshot => screenshot.signature === signature ); if (existingIndex >= 0) { @@ -110,5 +158,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..8f1fea67 100644 --- a/src/tdd/metadata/hotspot-metadata.js +++ b/src/tdd/metadata/hotspot-metadata.js @@ -1,60 +1,50 @@ /** * 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, operation) { + let store = createStateStore({ workingDir }); + + 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; - } - - 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, 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, store => { + store.setHotspotMetadata(hotspotData, summary); + }); } /** @@ -69,12 +59,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..49dcd55a 100644 --- a/src/tdd/metadata/region-metadata.js +++ b/src/tdd/metadata/region-metadata.js @@ -3,58 +3,47 @@ * * 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, operation) { + let store = createStateStore({ workingDir }); + + 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; - } - - 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, 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, store => { + store.setRegionMetadata(regionData, summary); + }); } /** @@ -69,12 +58,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..bbde8590 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,166 @@ 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 (?, ?, ?, ?, ?, ?, ?, ?) + `); + + let transaction = db.transaction(() => { + db.prepare('DELETE FROM registry_servers').run(); + + for (let server of servers) { + if (!server?.port || !server?.pid || !server?.directory) { + continue; + } + + 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(); + 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 +276,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 +311,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 +418,7 @@ export class ServerRegistry { timeout: 500, }); } catch { - // Non-fatal - menubar will still see changes via file watching + // Non-fatal } } @@ -196,7 +428,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 +438,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 +445,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 +484,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..1f9c5ce9 --- /dev/null +++ b/src/tdd/state-store.js @@ -0,0 +1,1385 @@ +/** + * TDD State Store + * + * Stores volatile TDD reporter state in SQLite. + * + * SQLite backend is the production default. + * The file backend exists for tests that inject mocked fs behavior. + */ + +import { EventEmitter } from 'node:events'; +import { + existsSync, + mkdirSync, + readFileSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; +import { dirname, join } from 'node:path'; +import BetterSqlite3 from 'better-sqlite3'; + +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; +} + +function emitStateChanged(workingDir) { + getStateEmitter(workingDir).emit('changed'); +} + +function subscribeToStateChanges(workingDir, listener) { + let emitter = getStateEmitter(workingDir); + emitter.on('changed', listener); + return () => emitter.off('changed', listener); +} + +function parseJson(value, fallback = null) { + if (value == null || value === '') { + return fallback; + } + + try { + return JSON.parse(value); + } catch { + return fallback; + } +} + +function toIntegerBool(value) { + return value ? 1 : 0; +} + +function fromIntegerBool(value) { + return value === 1; +} + +function hasReportData(reportData) { + if (!reportData || typeof reportData !== 'object') { + return false; + } + if (!Array.isArray(reportData.comparisons)) { + return false; + } + return true; +} + +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, + }; +} + +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: fromIntegerBool(row.has_diff_clusters), + hasConfirmedRegions: fromIntegerBool(row.has_confirmed_regions), + }; +} + +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: toIntegerBool(normalized.hasDiffClusters), + has_confirmed_regions: toIntegerBool(normalized.hasConfirmedRegions), + timestamp: + normalized.timestamp == null ? now : Number(normalized.timestamp), + updated_at: now, + }; +} + +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, + }; +} + +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, + }; +} + +export let STATE_METADATA_KEYS = { + baseline: 'baseline_metadata', + hotspot: 'hotspot_metadata', + region: 'region_metadata', + baselineBuild: 'baseline_build_metadata', +}; + +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 + ); + `, + }, +]; + +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}` + ); + } +} + +export function getStateDbPath(workingDir) { + return join(workingDir, '.vizzly', 'state.db'); +} + +export function createSqliteStateStore(options = {}) { + let { + workingDir = process.cwd(), + output = {}, + Database, + fs = {}, + joinPath = join, + dbPath = null, + } = options; + + let { + existsSync: existsSyncImpl = existsSync, + mkdirSync: mkdirSyncImpl = mkdirSync, + readFileSync: readFileSyncImpl = readFileSync, + } = fs; + + let vizzlyDir = joinPath(workingDir, '.vizzly'); + if (!existsSyncImpl(vizzlyDir)) { + mkdirSyncImpl(vizzlyDir, { recursive: true }); + } + + let resolvedDbPath = dbPath || joinPath(vizzlyDir, 'state.db'); + let dbDirectory = dirname(resolvedDbPath); + if (!existsSyncImpl(dbDirectory)) { + mkdirSyncImpl(dbDirectory, { recursive: true }); + } + + let DatabaseImpl = Database || BetterSqlite3; + let 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); + + 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) { + 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 legacyMigrated = getKey('legacy_json_migrated'); + if (legacyMigrated === '1') { + return; + } + + let hasRows = countComparisonsStmt.get().count > 0; + let initialized = getKey('report_initialized') === '1'; + if (hasRows || initialized) { + setKey('legacy_json_migrated', '1'); + return; + } + + let reportPath = joinPath(vizzlyDir, 'report-data.json'); + let detailsPath = joinPath(vizzlyDir, 'comparison-details.json'); + + if (!existsSyncImpl(reportPath)) { + setKey('legacy_json_migrated', '1'); + return; + } + + try { + let reportData = parseJson(readFileSyncImpl(reportPath, 'utf8'), null); + if (!hasReportData(reportData)) { + setKey('legacy_json_migrated', '1'); + 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}` + ); + } finally { + setKey('legacy_json_migrated', '1'); + } + } + + function maybeMigrateLegacyMetadataJson() { + let legacyMigrated = getKey('legacy_metadata_json_migrated'); + if (legacyMigrated === '1') { + return; + } + + 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' + ); + + try { + if (existsSyncImpl(baselineMetadataPath) && !getBaselineMetadata()) { + let baselineMetadata = parseJson( + readFileSyncImpl(baselineMetadataPath, 'utf8'), + null + ); + if (baselineMetadata) { + setBaselineMetadata(baselineMetadata, false); + output.debug?.( + 'state', + 'migrated baselines/metadata.json to SQLite metadata state' + ); + } + } + + if (existsSyncImpl(hotspotMetadataPath) && !getHotspotBundle()) { + 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' + ); + } + } + + if (existsSyncImpl(regionMetadataPath) && !getRegionBundle()) { + 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' + ); + } + } + + if ( + existsSyncImpl(baselineBuildMetadataPath) && + !getBaselineBuildMetadata() + ) { + 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 metadata JSON migration skipped: ${error.message}` + ); + } finally { + setKey('legacy_metadata_json_migrated', '1'); + } + } + + maybeMigrateLegacyJson(); + maybeMigrateLegacyMetadataJson(); + + return { + backend: 'sqlite', + + 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) { + replaceReportDataInternal(reportData, detailsById, true); + }, + + upsertComparison(comparison) { + 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) { + 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) { + deleteDetailsStmt.run(id); + }, + + deleteComparison(id) { + let transaction = db.transaction(() => { + deleteDetailsStmt.run(id); + deleteComparisonStmt.run(id); + setReportInitialized(Date.now()); + }); + + transaction(); + emitStateChanged(workingDir); + }, + + 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) { + setMetadataInternal(key, value, true); + }, + + removeMetadata(key) { + return removeMetadataInternal(key, true); + }, + + getBaselineMetadata() { + return getBaselineMetadata(); + }, + + setBaselineMetadata(metadata) { + setBaselineMetadata(metadata, true); + }, + + clearBaselineMetadata() { + return clearBaselineMetadata(true); + }, + + removeBaselineScreenshot(signature) { + return removeBaselineScreenshot(signature); + }, + + getHotspotBundle() { + return getHotspotBundle(); + }, + + getHotspotMetadata() { + return getHotspotMetadata(); + }, + + setHotspotMetadata(hotspotData, summary = {}) { + setHotspotMetadata(hotspotData, summary, true); + }, + + clearHotspotMetadata() { + return clearHotspotMetadata(true); + }, + + getRegionBundle() { + return getRegionBundle(); + }, + + getRegionMetadata() { + return getRegionMetadata(); + }, + + setRegionMetadata(regionData, summary = {}) { + setRegionMetadata(regionData, summary, true); + }, + + clearRegionMetadata() { + return clearRegionMetadata(true); + }, + + getBaselineBuildMetadata() { + return getBaselineBuildMetadata(); + }, + + setBaselineBuildMetadata(metadata) { + setBaselineBuildMetadata(metadata, true); + }, + + clearBaselineBuildMetadata() { + return clearBaselineBuildMetadata(true); + }, + + subscribe(listener) { + return subscribeToStateChanges(workingDir, listener); + }, + + close() { + try { + db.close(); + } catch { + // Ignore close errors + } + }, + }; +} + +export function createFileStateStore(options = {}) { + let { + workingDir = process.cwd(), + existsSync: existsSyncImpl = existsSync, + mkdirSync: mkdirSyncImpl = mkdirSync, + readFileSync: readFileSyncImpl = readFileSync, + writeFileSync: writeFileSyncImpl = writeFileSync, + unlinkSync: unlinkSyncImpl = unlinkSync, + joinPath = join, + } = options; + + let reportPath = joinPath(workingDir, '.vizzly', 'report-data.json'); + let detailsPath = joinPath(workingDir, '.vizzly', 'comparison-details.json'); + let baselineMetadataPath = joinPath( + workingDir, + '.vizzly', + 'baselines', + 'metadata.json' + ); + let hotspotMetadataPath = joinPath(workingDir, '.vizzly', 'hotspots.json'); + let regionMetadataPath = joinPath(workingDir, '.vizzly', 'regions.json'); + let baselineBuildMetadataPath = joinPath( + workingDir, + '.vizzly', + 'baseline-metadata.json' + ); + + function ensureDirectoryForFile(filePath) { + let pathDirectory = dirname(filePath); + if (!existsSyncImpl(pathDirectory)) { + mkdirSyncImpl(pathDirectory, { recursive: true }); + } + } + + function readJsonFile(filePath, fallback = null) { + try { + if (!existsSyncImpl(filePath)) { + return fallback; + } + return JSON.parse(readFileSyncImpl(filePath, 'utf8')); + } catch { + return fallback; + } + } + + function writeJsonFile(filePath, value, pretty = false) { + ensureDirectoryForFile(filePath); + if (pretty) { + writeFileSyncImpl(filePath, JSON.stringify(value, null, 2)); + return; + } + + writeFileSyncImpl(filePath, JSON.stringify(value)); + } + + function removeFile(filePath) { + try { + if (!existsSyncImpl(filePath)) { + return false; + } + unlinkSyncImpl(filePath); + return true; + } catch { + return false; + } + } + + function readReportData() { + if (!existsSyncImpl(reportPath)) { + return null; + } + + let data = readFileSyncImpl(reportPath, 'utf8'); + return JSON.parse(data); + } + + function writeReportData(reportData) { + ensureDirectoryForFile(reportPath); + writeFileSyncImpl(reportPath, JSON.stringify(reportData)); + emitStateChanged(workingDir); + } + + function readComparisonDetails() { + return readJsonFile(detailsPath, {}); + } + + function writeComparisonDetails(details) { + writeJsonFile(detailsPath, details, false); + } + + function withSummary(reportData) { + if (!reportData) return null; + + let comparisons = reportData.comparisons || []; + return { + ...reportData, + comparisons, + summary: buildSummary(comparisons), + timestamp: reportData.timestamp || Date.now(), + }; + } + + function getBaselineMetadata() { + return readJsonFile(baselineMetadataPath, null); + } + + function setBaselineMetadata(metadata) { + writeJsonFile(baselineMetadataPath, metadata, true); + emitStateChanged(workingDir); + } + + function clearBaselineMetadata() { + let removed = removeFile(baselineMetadataPath); + if (removed) { + emitStateChanged(workingDir); + } + return removed; + } + + 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); + return true; + } + + function getHotspotBundle() { + return normalizeHotspotBundle(readJsonFile(hotspotMetadataPath, null)); + } + + function getHotspotMetadata() { + let bundle = getHotspotBundle(); + return bundle?.hotspots || null; + } + + function setHotspotMetadata(hotspotData, summary = {}) { + writeJsonFile( + hotspotMetadataPath, + { + downloadedAt: new Date().toISOString(), + summary, + hotspots: hotspotData || {}, + }, + true + ); + emitStateChanged(workingDir); + } + + function clearHotspotMetadata() { + let removed = removeFile(hotspotMetadataPath); + if (removed) { + emitStateChanged(workingDir); + } + return removed; + } + + function getRegionBundle() { + return normalizeRegionBundle(readJsonFile(regionMetadataPath, null)); + } + + function getRegionMetadata() { + let bundle = getRegionBundle(); + return bundle?.regions || null; + } + + function setRegionMetadata(regionData, summary = {}) { + writeJsonFile( + regionMetadataPath, + { + downloadedAt: new Date().toISOString(), + summary, + regions: regionData || {}, + }, + true + ); + emitStateChanged(workingDir); + } + + function clearRegionMetadata() { + let removed = removeFile(regionMetadataPath); + if (removed) { + emitStateChanged(workingDir); + } + return removed; + } + + function getBaselineBuildMetadata() { + return readJsonFile(baselineBuildMetadataPath, null); + } + + function setBaselineBuildMetadata(metadata) { + writeJsonFile(baselineBuildMetadataPath, metadata, true); + emitStateChanged(workingDir); + } + + function clearBaselineBuildMetadata() { + let removed = removeFile(baselineBuildMetadataPath); + if (removed) { + emitStateChanged(workingDir); + } + return removed; + } + + function getMetadata(key, fallback = null) { + switch (key) { + case STATE_METADATA_KEYS.baseline: + return getBaselineMetadata() ?? fallback; + case STATE_METADATA_KEYS.hotspot: + return getHotspotBundle() ?? fallback; + case STATE_METADATA_KEYS.region: + return getRegionBundle() ?? fallback; + case STATE_METADATA_KEYS.baselineBuild: + return getBaselineBuildMetadata() ?? fallback; + default: + return fallback; + } + } + + function setMetadata(key, value) { + switch (key) { + case STATE_METADATA_KEYS.baseline: + setBaselineMetadata(value); + return; + case STATE_METADATA_KEYS.hotspot: + setHotspotMetadata(value?.hotspots || value, value?.summary || {}); + return; + case STATE_METADATA_KEYS.region: + setRegionMetadata(value?.regions || value, value?.summary || {}); + return; + case STATE_METADATA_KEYS.baselineBuild: + setBaselineBuildMetadata(value); + return; + default: + throw new Error(`Unknown metadata key: ${key}`); + } + } + + function removeMetadata(key) { + switch (key) { + case STATE_METADATA_KEYS.baseline: + return clearBaselineMetadata(); + case STATE_METADATA_KEYS.hotspot: + return clearHotspotMetadata(); + case STATE_METADATA_KEYS.region: + return clearRegionMetadata(); + case STATE_METADATA_KEYS.baselineBuild: + return clearBaselineBuildMetadata(); + default: + return false; + } + } + + return { + backend: 'file', + + readReportData() { + return withSummary(readReportData()); + }, + + replaceReportData(reportData, detailsById = null) { + let normalized = withSummary({ + timestamp: reportData?.timestamp || Date.now(), + comparisons: reportData?.comparisons || [], + }); + + writeReportData(normalized); + + if (detailsById && typeof detailsById === 'object') { + writeComparisonDetails(detailsById); + } else { + writeComparisonDetails({}); + } + }, + + upsertComparison(comparison) { + let reportData = readReportData() || { + timestamp: Date.now(), + comparisons: [], + summary: { total: 0, passed: 0, failed: 0, errors: 0 }, + }; + + if (!reportData.comparisons) { + reportData.comparisons = []; + } + + let existingIndex = reportData.comparisons.findIndex( + item => item.id === comparison.id + ); + + if (existingIndex >= 0) { + let initialStatus = reportData.comparisons[existingIndex].initialStatus; + reportData.comparisons[existingIndex] = { + ...comparison, + initialStatus: initialStatus || comparison.status, + }; + } else { + reportData.comparisons.push({ + ...comparison, + initialStatus: comparison.status, + }); + } + + reportData.timestamp = Date.now(); + reportData.summary = buildSummary(reportData.comparisons); + writeReportData(reportData); + }, + + getComparisonByIdOrSignatureOrName(value) { + let reportData = readReportData(); + if (!reportData) return null; + + return ( + (reportData.comparisons || []).find( + comparison => + comparison.id === value || + comparison.signature === value || + comparison.name === value + ) || null + ); + }, + + upsertComparisonDetails(id, details) { + let allDetails = readComparisonDetails(); + allDetails[id] = details; + writeComparisonDetails(allDetails); + }, + + getComparisonDetails(id) { + let allDetails = readComparisonDetails(); + return allDetails[id] || null; + }, + + removeComparisonDetails(id) { + let allDetails = readComparisonDetails(); + delete allDetails[id]; + writeComparisonDetails(allDetails); + }, + + deleteComparison(id) { + let reportData = readReportData(); + if (!reportData) { + return; + } + + reportData.comparisons = (reportData.comparisons || []).filter( + comparison => comparison.id !== id + ); + reportData.timestamp = Date.now(); + reportData.summary = buildSummary(reportData.comparisons); + writeReportData(reportData); + + let allDetails = readComparisonDetails(); + delete allDetails[id]; + writeComparisonDetails(allDetails); + }, + + resetReportData() { + writeReportData({ + timestamp: Date.now(), + comparisons: [], + summary: { total: 0, passed: 0, failed: 0, errors: 0 }, + }); + writeComparisonDetails({}); + }, + + getMetadata, + + getSchemaVersion() { + return 0; + }, + setMetadata, + removeMetadata, + getBaselineMetadata, + setBaselineMetadata, + clearBaselineMetadata, + removeBaselineScreenshot, + getHotspotBundle, + getHotspotMetadata, + setHotspotMetadata, + clearHotspotMetadata, + getRegionBundle, + getRegionMetadata, + setRegionMetadata, + clearRegionMetadata, + getBaselineBuildMetadata, + setBaselineBuildMetadata, + clearBaselineBuildMetadata, + + subscribe(listener) { + return subscribeToStateChanges(workingDir, listener); + }, + + close() { + // No-op for file backend + }, + }; +} + +export function createStateStore(options = {}) { + let { backend = 'sqlite' } = options; + + if (backend === 'file') { + return createFileStateStore(options); + } + + return createSqliteStateStore(options); +} diff --git a/src/tdd/tdd-service.js b/src/tdd/tdd-service.js index 0b6a0d58..4555d290 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) @@ -301,6 +305,7 @@ export class TddService { existsSync, fetchWithTimeout, writeFileSync, + saveBaselineBuildMetadata, saveBaselineMetadata, } = this._deps; @@ -674,29 +679,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 +736,7 @@ export class TddService { existsSync, fetchWithTimeout, writeFileSync, + saveBaselineBuildMetadata, saveBaselineMetadata, } = this._deps; @@ -978,29 +972,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 +1042,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,7 +1063,7 @@ export class TddService { } /** - * Load hotspot data from disk + * Load hotspot data from state storage */ loadHotspots() { let { loadHotspotMetadata } = this._deps; @@ -1091,7 +1073,7 @@ export class TddService { /** * 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 +1083,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,7 +1092,7 @@ export class TddService { } /** - * Load region data from disk + * Load region data from state storage */ loadRegions() { let { loadRegionMetadata } = this._deps; @@ -1120,7 +1102,7 @@ export class TddService { /** * 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 +1115,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(); } diff --git a/src/utils/context.js b/src/utils/context.js index 7d665b21..7db6124d 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 }); + 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 }); + 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..cc99f3ca 100644 --- a/tests/server/handlers/tdd-handler.test.js +++ b/tests/server/handlers/tdd-handler.test.js @@ -80,6 +80,16 @@ function createMockDeps(overrides = {}) { overrides.TddService ?? createMockTddService(overrides.tddServiceOverrides), existsSync: overrides.existsSync ?? (path => path in fileSystem), + mkdirSync: + overrides.mkdirSync ?? + (() => { + // No-op for virtual file system + }), + unlinkSync: + overrides.unlinkSync ?? + (path => { + delete fileSystem[path]; + }), readFileSync: overrides.readFileSync ?? (path => { @@ -103,6 +113,7 @@ function createMockDeps(overrides = {}) { validateScreenshotProperties: overrides.validateScreenshotProperties ?? (props => props), output: overrides.output ?? mockOutput, + stateBackend: overrides.stateBackend ?? 'file', _fileSystem: fileSystem, _mockOutput: mockOutput, }; @@ -1453,7 +1464,10 @@ describe('server/handlers/tdd-handler', () => { await handler.handleScreenshot('build-1', 'test', 'base64data', {}); let errorCall = deps._mockOutput.calls.find( - c => c.method === 'error' && c.args[0].includes('Failed to read') + c => + c.method === 'error' && + (c.args[0].includes('Failed to read') || + c.args[0].includes('Failed to update comparison')) ); assert.ok(errorCall); }); diff --git a/tests/server/http-server.test.js b/tests/server/http-server.test.js index 230def02..e06d6b92 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 }); + 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..62005b4d 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 }); + store.replaceReportData(reportData, details); + store.close(); +} + +function writeBaselineMetadata(workingDir, metadata) { + let store = createStateStore({ workingDir }); + 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..6680c86b 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 }); + store.replaceReportData(reportData, details); + store.close(); +} + +function writeBaselineMetadata(workingDir, metadata) { + let store = createStateStore({ workingDir }); + 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..45cd4d99 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 }); + store.replaceReportData(reportData); + store.close(); +} + +function writeBaselineMetadata(workingDir, metadata) { + let store = createStateStore({ workingDir }); + 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..079e48a3 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 }); + 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 }); + 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..91ba93d6 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); - 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..3e21efa9 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); - 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 }); + 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..78f1dc81 --- /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); + 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 }); + 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..4230fe37 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', () => { 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..4d4a5aee 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 }); + 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(); From 3632ee3c0f90c8aa290dc20d835383a1ff266de4 Mon Sep 17 00:00:00 2001 From: Robert DeLuca Date: Wed, 18 Feb 2026 20:55:46 -0600 Subject: [PATCH 2/3] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20split=20state=20store?= =?UTF-8?q?=20into=20focused=20modules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Break the monolithic state store into a small facade plus dedicated files for schema migrations, sqlite backend, file backend, events, constants, and shared helpers. This keeps DB evolution isolated and gives us a cleaner foundation for future migrations. --- src/tdd/state-store.js | 1379 +-------------------------- src/tdd/state-store/constants.js | 6 + src/tdd/state-store/events.js | 23 + src/tdd/state-store/file-store.js | 429 +++++++++ src/tdd/state-store/migrations.js | 100 ++ src/tdd/state-store/sqlite-store.js | 657 +++++++++++++ src/tdd/state-store/utils.js | 171 ++++ 7 files changed, 1399 insertions(+), 1366 deletions(-) create mode 100644 src/tdd/state-store/constants.js create mode 100644 src/tdd/state-store/events.js create mode 100644 src/tdd/state-store/file-store.js create mode 100644 src/tdd/state-store/migrations.js create mode 100644 src/tdd/state-store/sqlite-store.js create mode 100644 src/tdd/state-store/utils.js diff --git a/src/tdd/state-store.js b/src/tdd/state-store.js index 1f9c5ce9..c3b421bf 100644 --- a/src/tdd/state-store.js +++ b/src/tdd/state-store.js @@ -1,1379 +1,26 @@ /** * TDD State Store * - * Stores volatile TDD reporter state in SQLite. + * Public API facade for reporter state persistence. * * SQLite backend is the production default. - * The file backend exists for tests that inject mocked fs behavior. + * File backend exists for tests with mocked fs behavior. */ -import { EventEmitter } from 'node:events'; +import { STATE_METADATA_KEYS } from './state-store/constants.js'; +import { createFileStateStore } from './state-store/file-store.js'; import { - existsSync, - mkdirSync, - readFileSync, - unlinkSync, - writeFileSync, -} from 'node:fs'; -import { dirname, join } from 'node:path'; -import BetterSqlite3 from 'better-sqlite3'; - -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; -} - -function emitStateChanged(workingDir) { - getStateEmitter(workingDir).emit('changed'); -} - -function subscribeToStateChanges(workingDir, listener) { - let emitter = getStateEmitter(workingDir); - emitter.on('changed', listener); - return () => emitter.off('changed', listener); -} - -function parseJson(value, fallback = null) { - if (value == null || value === '') { - return fallback; - } - - try { - return JSON.parse(value); - } catch { - return fallback; - } -} - -function toIntegerBool(value) { - return value ? 1 : 0; -} - -function fromIntegerBool(value) { - return value === 1; -} - -function hasReportData(reportData) { - if (!reportData || typeof reportData !== 'object') { - return false; - } - if (!Array.isArray(reportData.comparisons)) { - return false; - } - return true; -} - -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, - }; -} - -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: fromIntegerBool(row.has_diff_clusters), - hasConfirmedRegions: fromIntegerBool(row.has_confirmed_regions), - }; -} - -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: toIntegerBool(normalized.hasDiffClusters), - has_confirmed_regions: toIntegerBool(normalized.hasConfirmedRegions), - timestamp: - normalized.timestamp == null ? now : Number(normalized.timestamp), - updated_at: now, - }; -} - -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, - }; -} - -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, - }; -} - -export let STATE_METADATA_KEYS = { - baseline: 'baseline_metadata', - hotspot: 'hotspot_metadata', - region: 'region_metadata', - baselineBuild: 'baseline_build_metadata', + createSqliteStateStore, + getStateDbPath, +} from './state-store/sqlite-store.js'; + +export { + STATE_METADATA_KEYS, + createFileStateStore, + createSqliteStateStore, + getStateDbPath, }; -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 - ); - `, - }, -]; - -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}` - ); - } -} - -export function getStateDbPath(workingDir) { - return join(workingDir, '.vizzly', 'state.db'); -} - -export function createSqliteStateStore(options = {}) { - let { - workingDir = process.cwd(), - output = {}, - Database, - fs = {}, - joinPath = join, - dbPath = null, - } = options; - - let { - existsSync: existsSyncImpl = existsSync, - mkdirSync: mkdirSyncImpl = mkdirSync, - readFileSync: readFileSyncImpl = readFileSync, - } = fs; - - let vizzlyDir = joinPath(workingDir, '.vizzly'); - if (!existsSyncImpl(vizzlyDir)) { - mkdirSyncImpl(vizzlyDir, { recursive: true }); - } - - let resolvedDbPath = dbPath || joinPath(vizzlyDir, 'state.db'); - let dbDirectory = dirname(resolvedDbPath); - if (!existsSyncImpl(dbDirectory)) { - mkdirSyncImpl(dbDirectory, { recursive: true }); - } - - let DatabaseImpl = Database || BetterSqlite3; - let 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); - - 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) { - 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 legacyMigrated = getKey('legacy_json_migrated'); - if (legacyMigrated === '1') { - return; - } - - let hasRows = countComparisonsStmt.get().count > 0; - let initialized = getKey('report_initialized') === '1'; - if (hasRows || initialized) { - setKey('legacy_json_migrated', '1'); - return; - } - - let reportPath = joinPath(vizzlyDir, 'report-data.json'); - let detailsPath = joinPath(vizzlyDir, 'comparison-details.json'); - - if (!existsSyncImpl(reportPath)) { - setKey('legacy_json_migrated', '1'); - return; - } - - try { - let reportData = parseJson(readFileSyncImpl(reportPath, 'utf8'), null); - if (!hasReportData(reportData)) { - setKey('legacy_json_migrated', '1'); - 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}` - ); - } finally { - setKey('legacy_json_migrated', '1'); - } - } - - function maybeMigrateLegacyMetadataJson() { - let legacyMigrated = getKey('legacy_metadata_json_migrated'); - if (legacyMigrated === '1') { - return; - } - - 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' - ); - - try { - if (existsSyncImpl(baselineMetadataPath) && !getBaselineMetadata()) { - let baselineMetadata = parseJson( - readFileSyncImpl(baselineMetadataPath, 'utf8'), - null - ); - if (baselineMetadata) { - setBaselineMetadata(baselineMetadata, false); - output.debug?.( - 'state', - 'migrated baselines/metadata.json to SQLite metadata state' - ); - } - } - - if (existsSyncImpl(hotspotMetadataPath) && !getHotspotBundle()) { - 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' - ); - } - } - - if (existsSyncImpl(regionMetadataPath) && !getRegionBundle()) { - 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' - ); - } - } - - if ( - existsSyncImpl(baselineBuildMetadataPath) && - !getBaselineBuildMetadata() - ) { - 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 metadata JSON migration skipped: ${error.message}` - ); - } finally { - setKey('legacy_metadata_json_migrated', '1'); - } - } - - maybeMigrateLegacyJson(); - maybeMigrateLegacyMetadataJson(); - - return { - backend: 'sqlite', - - 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) { - replaceReportDataInternal(reportData, detailsById, true); - }, - - upsertComparison(comparison) { - 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) { - 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) { - deleteDetailsStmt.run(id); - }, - - deleteComparison(id) { - let transaction = db.transaction(() => { - deleteDetailsStmt.run(id); - deleteComparisonStmt.run(id); - setReportInitialized(Date.now()); - }); - - transaction(); - emitStateChanged(workingDir); - }, - - 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) { - setMetadataInternal(key, value, true); - }, - - removeMetadata(key) { - return removeMetadataInternal(key, true); - }, - - getBaselineMetadata() { - return getBaselineMetadata(); - }, - - setBaselineMetadata(metadata) { - setBaselineMetadata(metadata, true); - }, - - clearBaselineMetadata() { - return clearBaselineMetadata(true); - }, - - removeBaselineScreenshot(signature) { - return removeBaselineScreenshot(signature); - }, - - getHotspotBundle() { - return getHotspotBundle(); - }, - - getHotspotMetadata() { - return getHotspotMetadata(); - }, - - setHotspotMetadata(hotspotData, summary = {}) { - setHotspotMetadata(hotspotData, summary, true); - }, - - clearHotspotMetadata() { - return clearHotspotMetadata(true); - }, - - getRegionBundle() { - return getRegionBundle(); - }, - - getRegionMetadata() { - return getRegionMetadata(); - }, - - setRegionMetadata(regionData, summary = {}) { - setRegionMetadata(regionData, summary, true); - }, - - clearRegionMetadata() { - return clearRegionMetadata(true); - }, - - getBaselineBuildMetadata() { - return getBaselineBuildMetadata(); - }, - - setBaselineBuildMetadata(metadata) { - setBaselineBuildMetadata(metadata, true); - }, - - clearBaselineBuildMetadata() { - return clearBaselineBuildMetadata(true); - }, - - subscribe(listener) { - return subscribeToStateChanges(workingDir, listener); - }, - - close() { - try { - db.close(); - } catch { - // Ignore close errors - } - }, - }; -} - -export function createFileStateStore(options = {}) { - let { - workingDir = process.cwd(), - existsSync: existsSyncImpl = existsSync, - mkdirSync: mkdirSyncImpl = mkdirSync, - readFileSync: readFileSyncImpl = readFileSync, - writeFileSync: writeFileSyncImpl = writeFileSync, - unlinkSync: unlinkSyncImpl = unlinkSync, - joinPath = join, - } = options; - - let reportPath = joinPath(workingDir, '.vizzly', 'report-data.json'); - let detailsPath = joinPath(workingDir, '.vizzly', 'comparison-details.json'); - let baselineMetadataPath = joinPath( - workingDir, - '.vizzly', - 'baselines', - 'metadata.json' - ); - let hotspotMetadataPath = joinPath(workingDir, '.vizzly', 'hotspots.json'); - let regionMetadataPath = joinPath(workingDir, '.vizzly', 'regions.json'); - let baselineBuildMetadataPath = joinPath( - workingDir, - '.vizzly', - 'baseline-metadata.json' - ); - - function ensureDirectoryForFile(filePath) { - let pathDirectory = dirname(filePath); - if (!existsSyncImpl(pathDirectory)) { - mkdirSyncImpl(pathDirectory, { recursive: true }); - } - } - - function readJsonFile(filePath, fallback = null) { - try { - if (!existsSyncImpl(filePath)) { - return fallback; - } - return JSON.parse(readFileSyncImpl(filePath, 'utf8')); - } catch { - return fallback; - } - } - - function writeJsonFile(filePath, value, pretty = false) { - ensureDirectoryForFile(filePath); - if (pretty) { - writeFileSyncImpl(filePath, JSON.stringify(value, null, 2)); - return; - } - - writeFileSyncImpl(filePath, JSON.stringify(value)); - } - - function removeFile(filePath) { - try { - if (!existsSyncImpl(filePath)) { - return false; - } - unlinkSyncImpl(filePath); - return true; - } catch { - return false; - } - } - - function readReportData() { - if (!existsSyncImpl(reportPath)) { - return null; - } - - let data = readFileSyncImpl(reportPath, 'utf8'); - return JSON.parse(data); - } - - function writeReportData(reportData) { - ensureDirectoryForFile(reportPath); - writeFileSyncImpl(reportPath, JSON.stringify(reportData)); - emitStateChanged(workingDir); - } - - function readComparisonDetails() { - return readJsonFile(detailsPath, {}); - } - - function writeComparisonDetails(details) { - writeJsonFile(detailsPath, details, false); - } - - function withSummary(reportData) { - if (!reportData) return null; - - let comparisons = reportData.comparisons || []; - return { - ...reportData, - comparisons, - summary: buildSummary(comparisons), - timestamp: reportData.timestamp || Date.now(), - }; - } - - function getBaselineMetadata() { - return readJsonFile(baselineMetadataPath, null); - } - - function setBaselineMetadata(metadata) { - writeJsonFile(baselineMetadataPath, metadata, true); - emitStateChanged(workingDir); - } - - function clearBaselineMetadata() { - let removed = removeFile(baselineMetadataPath); - if (removed) { - emitStateChanged(workingDir); - } - return removed; - } - - 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); - return true; - } - - function getHotspotBundle() { - return normalizeHotspotBundle(readJsonFile(hotspotMetadataPath, null)); - } - - function getHotspotMetadata() { - let bundle = getHotspotBundle(); - return bundle?.hotspots || null; - } - - function setHotspotMetadata(hotspotData, summary = {}) { - writeJsonFile( - hotspotMetadataPath, - { - downloadedAt: new Date().toISOString(), - summary, - hotspots: hotspotData || {}, - }, - true - ); - emitStateChanged(workingDir); - } - - function clearHotspotMetadata() { - let removed = removeFile(hotspotMetadataPath); - if (removed) { - emitStateChanged(workingDir); - } - return removed; - } - - function getRegionBundle() { - return normalizeRegionBundle(readJsonFile(regionMetadataPath, null)); - } - - function getRegionMetadata() { - let bundle = getRegionBundle(); - return bundle?.regions || null; - } - - function setRegionMetadata(regionData, summary = {}) { - writeJsonFile( - regionMetadataPath, - { - downloadedAt: new Date().toISOString(), - summary, - regions: regionData || {}, - }, - true - ); - emitStateChanged(workingDir); - } - - function clearRegionMetadata() { - let removed = removeFile(regionMetadataPath); - if (removed) { - emitStateChanged(workingDir); - } - return removed; - } - - function getBaselineBuildMetadata() { - return readJsonFile(baselineBuildMetadataPath, null); - } - - function setBaselineBuildMetadata(metadata) { - writeJsonFile(baselineBuildMetadataPath, metadata, true); - emitStateChanged(workingDir); - } - - function clearBaselineBuildMetadata() { - let removed = removeFile(baselineBuildMetadataPath); - if (removed) { - emitStateChanged(workingDir); - } - return removed; - } - - function getMetadata(key, fallback = null) { - switch (key) { - case STATE_METADATA_KEYS.baseline: - return getBaselineMetadata() ?? fallback; - case STATE_METADATA_KEYS.hotspot: - return getHotspotBundle() ?? fallback; - case STATE_METADATA_KEYS.region: - return getRegionBundle() ?? fallback; - case STATE_METADATA_KEYS.baselineBuild: - return getBaselineBuildMetadata() ?? fallback; - default: - return fallback; - } - } - - function setMetadata(key, value) { - switch (key) { - case STATE_METADATA_KEYS.baseline: - setBaselineMetadata(value); - return; - case STATE_METADATA_KEYS.hotspot: - setHotspotMetadata(value?.hotspots || value, value?.summary || {}); - return; - case STATE_METADATA_KEYS.region: - setRegionMetadata(value?.regions || value, value?.summary || {}); - return; - case STATE_METADATA_KEYS.baselineBuild: - setBaselineBuildMetadata(value); - return; - default: - throw new Error(`Unknown metadata key: ${key}`); - } - } - - function removeMetadata(key) { - switch (key) { - case STATE_METADATA_KEYS.baseline: - return clearBaselineMetadata(); - case STATE_METADATA_KEYS.hotspot: - return clearHotspotMetadata(); - case STATE_METADATA_KEYS.region: - return clearRegionMetadata(); - case STATE_METADATA_KEYS.baselineBuild: - return clearBaselineBuildMetadata(); - default: - return false; - } - } - - return { - backend: 'file', - - readReportData() { - return withSummary(readReportData()); - }, - - replaceReportData(reportData, detailsById = null) { - let normalized = withSummary({ - timestamp: reportData?.timestamp || Date.now(), - comparisons: reportData?.comparisons || [], - }); - - writeReportData(normalized); - - if (detailsById && typeof detailsById === 'object') { - writeComparisonDetails(detailsById); - } else { - writeComparisonDetails({}); - } - }, - - upsertComparison(comparison) { - let reportData = readReportData() || { - timestamp: Date.now(), - comparisons: [], - summary: { total: 0, passed: 0, failed: 0, errors: 0 }, - }; - - if (!reportData.comparisons) { - reportData.comparisons = []; - } - - let existingIndex = reportData.comparisons.findIndex( - item => item.id === comparison.id - ); - - if (existingIndex >= 0) { - let initialStatus = reportData.comparisons[existingIndex].initialStatus; - reportData.comparisons[existingIndex] = { - ...comparison, - initialStatus: initialStatus || comparison.status, - }; - } else { - reportData.comparisons.push({ - ...comparison, - initialStatus: comparison.status, - }); - } - - reportData.timestamp = Date.now(); - reportData.summary = buildSummary(reportData.comparisons); - writeReportData(reportData); - }, - - getComparisonByIdOrSignatureOrName(value) { - let reportData = readReportData(); - if (!reportData) return null; - - return ( - (reportData.comparisons || []).find( - comparison => - comparison.id === value || - comparison.signature === value || - comparison.name === value - ) || null - ); - }, - - upsertComparisonDetails(id, details) { - let allDetails = readComparisonDetails(); - allDetails[id] = details; - writeComparisonDetails(allDetails); - }, - - getComparisonDetails(id) { - let allDetails = readComparisonDetails(); - return allDetails[id] || null; - }, - - removeComparisonDetails(id) { - let allDetails = readComparisonDetails(); - delete allDetails[id]; - writeComparisonDetails(allDetails); - }, - - deleteComparison(id) { - let reportData = readReportData(); - if (!reportData) { - return; - } - - reportData.comparisons = (reportData.comparisons || []).filter( - comparison => comparison.id !== id - ); - reportData.timestamp = Date.now(); - reportData.summary = buildSummary(reportData.comparisons); - writeReportData(reportData); - - let allDetails = readComparisonDetails(); - delete allDetails[id]; - writeComparisonDetails(allDetails); - }, - - resetReportData() { - writeReportData({ - timestamp: Date.now(), - comparisons: [], - summary: { total: 0, passed: 0, failed: 0, errors: 0 }, - }); - writeComparisonDetails({}); - }, - - getMetadata, - - getSchemaVersion() { - return 0; - }, - setMetadata, - removeMetadata, - getBaselineMetadata, - setBaselineMetadata, - clearBaselineMetadata, - removeBaselineScreenshot, - getHotspotBundle, - getHotspotMetadata, - setHotspotMetadata, - clearHotspotMetadata, - getRegionBundle, - getRegionMetadata, - setRegionMetadata, - clearRegionMetadata, - getBaselineBuildMetadata, - setBaselineBuildMetadata, - clearBaselineBuildMetadata, - - subscribe(listener) { - return subscribeToStateChanges(workingDir, listener); - }, - - close() { - // No-op for file backend - }, - }; -} - export function createStateStore(options = {}) { let { backend = 'sqlite' } = options; 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..e501e3b2 --- /dev/null +++ b/src/tdd/state-store/events.js @@ -0,0 +1,23 @@ +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) { + getStateEmitter(workingDir).emit('changed'); +} + +export function subscribeToStateChanges(workingDir, listener) { + let emitter = getStateEmitter(workingDir); + emitter.on('changed', listener); + return () => emitter.off('changed', listener); +} diff --git a/src/tdd/state-store/file-store.js b/src/tdd/state-store/file-store.js new file mode 100644 index 00000000..0727ec6c --- /dev/null +++ b/src/tdd/state-store/file-store.js @@ -0,0 +1,429 @@ +import { + existsSync, + mkdirSync, + readFileSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; +import { dirname, join } from 'node:path'; +import { STATE_METADATA_KEYS } from './constants.js'; +import { emitStateChanged, subscribeToStateChanges } from './events.js'; +import { + buildSummary, + normalizeHotspotBundle, + normalizeRegionBundle, +} from './utils.js'; + +export function createFileStateStore(options = {}) { + let { + workingDir = process.cwd(), + existsSync: existsSyncImpl = existsSync, + mkdirSync: mkdirSyncImpl = mkdirSync, + readFileSync: readFileSyncImpl = readFileSync, + writeFileSync: writeFileSyncImpl = writeFileSync, + unlinkSync: unlinkSyncImpl = unlinkSync, + joinPath = join, + } = options; + + let reportPath = joinPath(workingDir, '.vizzly', 'report-data.json'); + let detailsPath = joinPath(workingDir, '.vizzly', 'comparison-details.json'); + let baselineMetadataPath = joinPath( + workingDir, + '.vizzly', + 'baselines', + 'metadata.json' + ); + let hotspotMetadataPath = joinPath(workingDir, '.vizzly', 'hotspots.json'); + let regionMetadataPath = joinPath(workingDir, '.vizzly', 'regions.json'); + let baselineBuildMetadataPath = joinPath( + workingDir, + '.vizzly', + 'baseline-metadata.json' + ); + + function ensureDirectoryForFile(filePath) { + let pathDirectory = dirname(filePath); + if (!existsSyncImpl(pathDirectory)) { + mkdirSyncImpl(pathDirectory, { recursive: true }); + } + } + + function readJsonFile(filePath, fallback = null) { + try { + if (!existsSyncImpl(filePath)) { + return fallback; + } + return JSON.parse(readFileSyncImpl(filePath, 'utf8')); + } catch { + return fallback; + } + } + + function writeJsonFile(filePath, value, pretty = false) { + ensureDirectoryForFile(filePath); + if (pretty) { + writeFileSyncImpl(filePath, JSON.stringify(value, null, 2)); + return; + } + + writeFileSyncImpl(filePath, JSON.stringify(value)); + } + + function removeFile(filePath) { + try { + if (!existsSyncImpl(filePath)) { + return false; + } + unlinkSyncImpl(filePath); + return true; + } catch { + return false; + } + } + + function readReportData() { + if (!existsSyncImpl(reportPath)) { + return null; + } + + let data = readFileSyncImpl(reportPath, 'utf8'); + return JSON.parse(data); + } + + function writeReportData(reportData) { + ensureDirectoryForFile(reportPath); + writeFileSyncImpl(reportPath, JSON.stringify(reportData)); + emitStateChanged(workingDir); + } + + function readComparisonDetails() { + return readJsonFile(detailsPath, {}); + } + + function writeComparisonDetails(details) { + writeJsonFile(detailsPath, details, false); + } + + function withSummary(reportData) { + if (!reportData) return null; + + let comparisons = reportData.comparisons || []; + return { + ...reportData, + comparisons, + summary: buildSummary(comparisons), + timestamp: reportData.timestamp || Date.now(), + }; + } + + function getBaselineMetadata() { + return readJsonFile(baselineMetadataPath, null); + } + + function setBaselineMetadata(metadata) { + writeJsonFile(baselineMetadataPath, metadata, true); + emitStateChanged(workingDir); + } + + function clearBaselineMetadata() { + let removed = removeFile(baselineMetadataPath); + if (removed) { + emitStateChanged(workingDir); + } + return removed; + } + + 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); + return true; + } + + function getHotspotBundle() { + return normalizeHotspotBundle(readJsonFile(hotspotMetadataPath, null)); + } + + function getHotspotMetadata() { + let bundle = getHotspotBundle(); + return bundle?.hotspots || null; + } + + function setHotspotMetadata(hotspotData, summary = {}) { + writeJsonFile( + hotspotMetadataPath, + { + downloadedAt: new Date().toISOString(), + summary, + hotspots: hotspotData || {}, + }, + true + ); + emitStateChanged(workingDir); + } + + function clearHotspotMetadata() { + let removed = removeFile(hotspotMetadataPath); + if (removed) { + emitStateChanged(workingDir); + } + return removed; + } + + function getRegionBundle() { + return normalizeRegionBundle(readJsonFile(regionMetadataPath, null)); + } + + function getRegionMetadata() { + let bundle = getRegionBundle(); + return bundle?.regions || null; + } + + function setRegionMetadata(regionData, summary = {}) { + writeJsonFile( + regionMetadataPath, + { + downloadedAt: new Date().toISOString(), + summary, + regions: regionData || {}, + }, + true + ); + emitStateChanged(workingDir); + } + + function clearRegionMetadata() { + let removed = removeFile(regionMetadataPath); + if (removed) { + emitStateChanged(workingDir); + } + return removed; + } + + function getBaselineBuildMetadata() { + return readJsonFile(baselineBuildMetadataPath, null); + } + + function setBaselineBuildMetadata(metadata) { + writeJsonFile(baselineBuildMetadataPath, metadata, true); + emitStateChanged(workingDir); + } + + function clearBaselineBuildMetadata() { + let removed = removeFile(baselineBuildMetadataPath); + if (removed) { + emitStateChanged(workingDir); + } + return removed; + } + + function getMetadata(key, fallback = null) { + switch (key) { + case STATE_METADATA_KEYS.baseline: + return getBaselineMetadata() ?? fallback; + case STATE_METADATA_KEYS.hotspot: + return getHotspotBundle() ?? fallback; + case STATE_METADATA_KEYS.region: + return getRegionBundle() ?? fallback; + case STATE_METADATA_KEYS.baselineBuild: + return getBaselineBuildMetadata() ?? fallback; + default: + return fallback; + } + } + + function setMetadata(key, value) { + switch (key) { + case STATE_METADATA_KEYS.baseline: + setBaselineMetadata(value); + return; + case STATE_METADATA_KEYS.hotspot: + setHotspotMetadata(value?.hotspots || value, value?.summary || {}); + return; + case STATE_METADATA_KEYS.region: + setRegionMetadata(value?.regions || value, value?.summary || {}); + return; + case STATE_METADATA_KEYS.baselineBuild: + setBaselineBuildMetadata(value); + return; + default: + throw new Error(`Unknown metadata key: ${key}`); + } + } + + function removeMetadata(key) { + switch (key) { + case STATE_METADATA_KEYS.baseline: + return clearBaselineMetadata(); + case STATE_METADATA_KEYS.hotspot: + return clearHotspotMetadata(); + case STATE_METADATA_KEYS.region: + return clearRegionMetadata(); + case STATE_METADATA_KEYS.baselineBuild: + return clearBaselineBuildMetadata(); + default: + return false; + } + } + + return { + backend: 'file', + + readReportData() { + return withSummary(readReportData()); + }, + + replaceReportData(reportData, detailsById = null) { + let normalized = withSummary({ + timestamp: reportData?.timestamp || Date.now(), + comparisons: reportData?.comparisons || [], + }); + + writeReportData(normalized); + + if (detailsById && typeof detailsById === 'object') { + writeComparisonDetails(detailsById); + } else { + writeComparisonDetails({}); + } + }, + + upsertComparison(comparison) { + let reportData = readReportData() || { + timestamp: Date.now(), + comparisons: [], + summary: { total: 0, passed: 0, failed: 0, errors: 0 }, + }; + + if (!reportData.comparisons) { + reportData.comparisons = []; + } + + let existingIndex = reportData.comparisons.findIndex( + item => item.id === comparison.id + ); + + if (existingIndex >= 0) { + let initialStatus = reportData.comparisons[existingIndex].initialStatus; + reportData.comparisons[existingIndex] = { + ...comparison, + initialStatus: initialStatus || comparison.status, + }; + } else { + reportData.comparisons.push({ + ...comparison, + initialStatus: comparison.status, + }); + } + + reportData.timestamp = Date.now(); + reportData.summary = buildSummary(reportData.comparisons); + writeReportData(reportData); + }, + + getComparisonByIdOrSignatureOrName(value) { + let reportData = readReportData(); + if (!reportData) return null; + + return ( + (reportData.comparisons || []).find( + comparison => + comparison.id === value || + comparison.signature === value || + comparison.name === value + ) || null + ); + }, + + upsertComparisonDetails(id, details) { + let allDetails = readComparisonDetails(); + allDetails[id] = details; + writeComparisonDetails(allDetails); + }, + + getComparisonDetails(id) { + let allDetails = readComparisonDetails(); + return allDetails[id] || null; + }, + + removeComparisonDetails(id) { + let allDetails = readComparisonDetails(); + delete allDetails[id]; + writeComparisonDetails(allDetails); + }, + + deleteComparison(id) { + let reportData = readReportData(); + if (!reportData) { + return; + } + + reportData.comparisons = (reportData.comparisons || []).filter( + comparison => comparison.id !== id + ); + reportData.timestamp = Date.now(); + reportData.summary = buildSummary(reportData.comparisons); + writeReportData(reportData); + + let allDetails = readComparisonDetails(); + delete allDetails[id]; + writeComparisonDetails(allDetails); + }, + + resetReportData() { + writeReportData({ + timestamp: Date.now(), + comparisons: [], + summary: { total: 0, passed: 0, failed: 0, errors: 0 }, + }); + writeComparisonDetails({}); + }, + + getMetadata, + + getSchemaVersion() { + return 0; + }, + setMetadata, + removeMetadata, + getBaselineMetadata, + setBaselineMetadata, + clearBaselineMetadata, + removeBaselineScreenshot, + getHotspotBundle, + getHotspotMetadata, + setHotspotMetadata, + clearHotspotMetadata, + getRegionBundle, + getRegionMetadata, + setRegionMetadata, + clearRegionMetadata, + getBaselineBuildMetadata, + setBaselineBuildMetadata, + clearBaselineBuildMetadata, + + subscribe(listener) { + return subscribeToStateChanges(workingDir, listener); + }, + + close() { + // No-op for file backend + }, + }; +} 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..c6235b74 --- /dev/null +++ b/src/tdd/state-store/sqlite-store.js @@ -0,0 +1,657 @@ +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 = {}, + Database, + fs = {}, + joinPath = join, + dbPath = null, + } = options; + + let { + existsSync: existsSyncImpl = existsSync, + mkdirSync: mkdirSyncImpl = mkdirSync, + readFileSync: readFileSyncImpl = readFileSync, + } = fs; + + let vizzlyDir = joinPath(workingDir, '.vizzly'); + if (!existsSyncImpl(vizzlyDir)) { + mkdirSyncImpl(vizzlyDir, { recursive: true }); + } + + let resolvedDbPath = dbPath || joinPath(vizzlyDir, 'state.db'); + let dbDirectory = dirname(resolvedDbPath); + if (!existsSyncImpl(dbDirectory)) { + mkdirSyncImpl(dbDirectory, { recursive: true }); + } + + let DatabaseImpl = Database || BetterSqlite3; + let 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); + + 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) { + 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 legacyMigrated = getKey('legacy_json_migrated'); + if (legacyMigrated === '1') { + return; + } + + let hasRows = countComparisonsStmt.get().count > 0; + let initialized = getKey('report_initialized') === '1'; + if (hasRows || initialized) { + setKey('legacy_json_migrated', '1'); + return; + } + + let reportPath = joinPath(vizzlyDir, 'report-data.json'); + let detailsPath = joinPath(vizzlyDir, 'comparison-details.json'); + + if (!existsSyncImpl(reportPath)) { + setKey('legacy_json_migrated', '1'); + return; + } + + try { + let reportData = parseJson(readFileSyncImpl(reportPath, 'utf8'), null); + if (!hasReportData(reportData)) { + setKey('legacy_json_migrated', '1'); + 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}` + ); + } finally { + setKey('legacy_json_migrated', '1'); + } + } + + function maybeMigrateLegacyMetadataJson() { + let legacyMigrated = getKey('legacy_metadata_json_migrated'); + if (legacyMigrated === '1') { + return; + } + + 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' + ); + + try { + if (existsSyncImpl(baselineMetadataPath) && !getBaselineMetadata()) { + let baselineMetadata = parseJson( + readFileSyncImpl(baselineMetadataPath, 'utf8'), + null + ); + if (baselineMetadata) { + setBaselineMetadata(baselineMetadata, false); + output.debug?.( + 'state', + 'migrated baselines/metadata.json to SQLite metadata state' + ); + } + } + + if (existsSyncImpl(hotspotMetadataPath) && !getHotspotBundle()) { + 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' + ); + } + } + + if (existsSyncImpl(regionMetadataPath) && !getRegionBundle()) { + 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' + ); + } + } + + if ( + existsSyncImpl(baselineBuildMetadataPath) && + !getBaselineBuildMetadata() + ) { + 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 metadata JSON migration skipped: ${error.message}` + ); + } finally { + setKey('legacy_metadata_json_migrated', '1'); + } + } + + maybeMigrateLegacyJson(); + maybeMigrateLegacyMetadataJson(); + + return { + backend: 'sqlite', + + 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) { + replaceReportDataInternal(reportData, detailsById, true); + }, + + upsertComparison(comparison) { + 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) { + 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) { + deleteDetailsStmt.run(id); + }, + + deleteComparison(id) { + let transaction = db.transaction(() => { + deleteDetailsStmt.run(id); + deleteComparisonStmt.run(id); + setReportInitialized(Date.now()); + }); + + transaction(); + emitStateChanged(workingDir); + }, + + 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) { + setMetadataInternal(key, value, true); + }, + + removeMetadata(key) { + return removeMetadataInternal(key, true); + }, + + getBaselineMetadata() { + return getBaselineMetadata(); + }, + + setBaselineMetadata(metadata) { + setBaselineMetadata(metadata, true); + }, + + clearBaselineMetadata() { + return clearBaselineMetadata(true); + }, + + removeBaselineScreenshot(signature) { + return removeBaselineScreenshot(signature); + }, + + getHotspotBundle() { + return getHotspotBundle(); + }, + + getHotspotMetadata() { + return getHotspotMetadata(); + }, + + setHotspotMetadata(hotspotData, summary = {}) { + setHotspotMetadata(hotspotData, summary, true); + }, + + clearHotspotMetadata() { + return clearHotspotMetadata(true); + }, + + getRegionBundle() { + return getRegionBundle(); + }, + + getRegionMetadata() { + return getRegionMetadata(); + }, + + setRegionMetadata(regionData, summary = {}) { + setRegionMetadata(regionData, summary, true); + }, + + clearRegionMetadata() { + return clearRegionMetadata(true); + }, + + getBaselineBuildMetadata() { + return getBaselineBuildMetadata(); + }, + + setBaselineBuildMetadata(metadata) { + setBaselineBuildMetadata(metadata, true); + }, + + 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..fba96d58 --- /dev/null +++ b/src/tdd/state-store/utils.js @@ -0,0 +1,171 @@ +export function parseJson(value, fallback = null) { + if (value == null || value === '') { + return fallback; + } + + try { + return JSON.parse(value); + } catch { + return fallback; + } +} + +function toIntegerBool(value) { + return value ? 1 : 0; +} + +function fromIntegerBool(value) { + return value === 1; +} + +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: fromIntegerBool(row.has_diff_clusters), + hasConfirmedRegions: fromIntegerBool(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: toIntegerBool(normalized.hasDiffClusters), + has_confirmed_regions: toIntegerBool(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, + }; +} From 6a9c32e236ac3df4c74f2ce422f36db9bda4217d Mon Sep 17 00:00:00 2001 From: Robert DeLuca Date: Thu, 19 Feb 2026 03:13:16 -0600 Subject: [PATCH 3/3] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20complete=20sqlite-only?= =?UTF-8?q?=20state=20store=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - remove file state-store fallback and backend toggles\n- tighten registry/event/state utilities for cleaner behavior\n- move handler/service coverage to real sqlite-backed tests --- src/server/handlers/tdd-handler.js | 12 - src/tdd/server-registry.js | 24 +- src/tdd/state-store.js | 19 +- src/tdd/state-store/events.js | 15 +- src/tdd/state-store/file-store.js | 429 ---------------------- src/tdd/state-store/utils.js | 16 +- tests/server/handlers/tdd-handler.test.js | 148 +++----- tests/tdd/server-registry.test.js | 34 ++ tests/tdd/tdd-service.integration.test.js | 300 +++++++++++++++ 9 files changed, 437 insertions(+), 560 deletions(-) delete mode 100644 src/tdd/state-store/file-store.js create mode 100644 tests/tdd/tdd-service.integration.test.js diff --git a/src/server/handlers/tdd-handler.js b/src/server/handlers/tdd-handler.js index e5cd185a..0b4375fe 100644 --- a/src/server/handlers/tdd-handler.js +++ b/src/server/handlers/tdd-handler.js @@ -1,10 +1,8 @@ import { Buffer as defaultBuffer } from 'node:buffer'; import { existsSync as defaultExistsSync, - mkdirSync as defaultMkdirSync, 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'; @@ -184,10 +182,8 @@ export const createTddHandler = ( let { TddService = DefaultTddService, existsSync = defaultExistsSync, - mkdirSync = defaultMkdirSync, readFileSync = defaultReadFileSync, unlinkSync = defaultUnlinkSync, - writeFileSync = defaultWriteFileSync, join = defaultJoin, resolve = defaultResolve, Buffer = defaultBuffer, @@ -198,22 +194,14 @@ export const createTddHandler = ( validateScreenshotProperties = defaultValidateScreenshotProperties, output = defaultOutput, stateStore: injectedStateStore = null, - stateBackend = 'sqlite', } = deps; const tddService = new TddService(config, workingDir, setBaseline); const stateStore = injectedStateStore || createStateStore({ - backend: stateBackend, workingDir, output, - existsSync, - mkdirSync, - unlinkSync, - readFileSync, - writeFileSync, - joinPath: join, }); /** diff --git a/src/tdd/server-registry.js b/src/tdd/server-registry.js index bbde8590..059d845c 100644 --- a/src/tdd/server-registry.js +++ b/src/tdd/server-registry.js @@ -234,7 +234,18 @@ export class ServerRegistry { 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(); @@ -244,10 +255,19 @@ export class ServerRegistry { 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'), - Number(server.port), - Number(server.pid), + port, + pid, server.directory, server.startedAt || new Date().toISOString(), server.configPath || null, diff --git a/src/tdd/state-store.js b/src/tdd/state-store.js index c3b421bf..d464b879 100644 --- a/src/tdd/state-store.js +++ b/src/tdd/state-store.js @@ -3,30 +3,17 @@ * * Public API facade for reporter state persistence. * - * SQLite backend is the production default. - * File backend exists for tests with mocked fs behavior. + * SQLite is the only supported backend. */ import { STATE_METADATA_KEYS } from './state-store/constants.js'; -import { createFileStateStore } from './state-store/file-store.js'; import { createSqliteStateStore, getStateDbPath, } from './state-store/sqlite-store.js'; -export { - STATE_METADATA_KEYS, - createFileStateStore, - createSqliteStateStore, - getStateDbPath, -}; - -export function createStateStore(options = {}) { - let { backend = 'sqlite' } = options; - - if (backend === 'file') { - return createFileStateStore(options); - } +export { STATE_METADATA_KEYS, createSqliteStateStore, getStateDbPath }; +export function createStateStore(options) { return createSqliteStateStore(options); } diff --git a/src/tdd/state-store/events.js b/src/tdd/state-store/events.js index e501e3b2..99fb1abe 100644 --- a/src/tdd/state-store/events.js +++ b/src/tdd/state-store/events.js @@ -13,11 +13,22 @@ function getStateEmitter(workingDir) { } export function emitStateChanged(workingDir) { - getStateEmitter(workingDir).emit('changed'); + 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); + return () => { + emitter.off('changed', listener); + + if (emitter.listenerCount('changed') === 0) { + stateEmitters.delete(workingDir); + } + }; } diff --git a/src/tdd/state-store/file-store.js b/src/tdd/state-store/file-store.js deleted file mode 100644 index 0727ec6c..00000000 --- a/src/tdd/state-store/file-store.js +++ /dev/null @@ -1,429 +0,0 @@ -import { - existsSync, - mkdirSync, - readFileSync, - unlinkSync, - writeFileSync, -} from 'node:fs'; -import { dirname, join } from 'node:path'; -import { STATE_METADATA_KEYS } from './constants.js'; -import { emitStateChanged, subscribeToStateChanges } from './events.js'; -import { - buildSummary, - normalizeHotspotBundle, - normalizeRegionBundle, -} from './utils.js'; - -export function createFileStateStore(options = {}) { - let { - workingDir = process.cwd(), - existsSync: existsSyncImpl = existsSync, - mkdirSync: mkdirSyncImpl = mkdirSync, - readFileSync: readFileSyncImpl = readFileSync, - writeFileSync: writeFileSyncImpl = writeFileSync, - unlinkSync: unlinkSyncImpl = unlinkSync, - joinPath = join, - } = options; - - let reportPath = joinPath(workingDir, '.vizzly', 'report-data.json'); - let detailsPath = joinPath(workingDir, '.vizzly', 'comparison-details.json'); - let baselineMetadataPath = joinPath( - workingDir, - '.vizzly', - 'baselines', - 'metadata.json' - ); - let hotspotMetadataPath = joinPath(workingDir, '.vizzly', 'hotspots.json'); - let regionMetadataPath = joinPath(workingDir, '.vizzly', 'regions.json'); - let baselineBuildMetadataPath = joinPath( - workingDir, - '.vizzly', - 'baseline-metadata.json' - ); - - function ensureDirectoryForFile(filePath) { - let pathDirectory = dirname(filePath); - if (!existsSyncImpl(pathDirectory)) { - mkdirSyncImpl(pathDirectory, { recursive: true }); - } - } - - function readJsonFile(filePath, fallback = null) { - try { - if (!existsSyncImpl(filePath)) { - return fallback; - } - return JSON.parse(readFileSyncImpl(filePath, 'utf8')); - } catch { - return fallback; - } - } - - function writeJsonFile(filePath, value, pretty = false) { - ensureDirectoryForFile(filePath); - if (pretty) { - writeFileSyncImpl(filePath, JSON.stringify(value, null, 2)); - return; - } - - writeFileSyncImpl(filePath, JSON.stringify(value)); - } - - function removeFile(filePath) { - try { - if (!existsSyncImpl(filePath)) { - return false; - } - unlinkSyncImpl(filePath); - return true; - } catch { - return false; - } - } - - function readReportData() { - if (!existsSyncImpl(reportPath)) { - return null; - } - - let data = readFileSyncImpl(reportPath, 'utf8'); - return JSON.parse(data); - } - - function writeReportData(reportData) { - ensureDirectoryForFile(reportPath); - writeFileSyncImpl(reportPath, JSON.stringify(reportData)); - emitStateChanged(workingDir); - } - - function readComparisonDetails() { - return readJsonFile(detailsPath, {}); - } - - function writeComparisonDetails(details) { - writeJsonFile(detailsPath, details, false); - } - - function withSummary(reportData) { - if (!reportData) return null; - - let comparisons = reportData.comparisons || []; - return { - ...reportData, - comparisons, - summary: buildSummary(comparisons), - timestamp: reportData.timestamp || Date.now(), - }; - } - - function getBaselineMetadata() { - return readJsonFile(baselineMetadataPath, null); - } - - function setBaselineMetadata(metadata) { - writeJsonFile(baselineMetadataPath, metadata, true); - emitStateChanged(workingDir); - } - - function clearBaselineMetadata() { - let removed = removeFile(baselineMetadataPath); - if (removed) { - emitStateChanged(workingDir); - } - return removed; - } - - 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); - return true; - } - - function getHotspotBundle() { - return normalizeHotspotBundle(readJsonFile(hotspotMetadataPath, null)); - } - - function getHotspotMetadata() { - let bundle = getHotspotBundle(); - return bundle?.hotspots || null; - } - - function setHotspotMetadata(hotspotData, summary = {}) { - writeJsonFile( - hotspotMetadataPath, - { - downloadedAt: new Date().toISOString(), - summary, - hotspots: hotspotData || {}, - }, - true - ); - emitStateChanged(workingDir); - } - - function clearHotspotMetadata() { - let removed = removeFile(hotspotMetadataPath); - if (removed) { - emitStateChanged(workingDir); - } - return removed; - } - - function getRegionBundle() { - return normalizeRegionBundle(readJsonFile(regionMetadataPath, null)); - } - - function getRegionMetadata() { - let bundle = getRegionBundle(); - return bundle?.regions || null; - } - - function setRegionMetadata(regionData, summary = {}) { - writeJsonFile( - regionMetadataPath, - { - downloadedAt: new Date().toISOString(), - summary, - regions: regionData || {}, - }, - true - ); - emitStateChanged(workingDir); - } - - function clearRegionMetadata() { - let removed = removeFile(regionMetadataPath); - if (removed) { - emitStateChanged(workingDir); - } - return removed; - } - - function getBaselineBuildMetadata() { - return readJsonFile(baselineBuildMetadataPath, null); - } - - function setBaselineBuildMetadata(metadata) { - writeJsonFile(baselineBuildMetadataPath, metadata, true); - emitStateChanged(workingDir); - } - - function clearBaselineBuildMetadata() { - let removed = removeFile(baselineBuildMetadataPath); - if (removed) { - emitStateChanged(workingDir); - } - return removed; - } - - function getMetadata(key, fallback = null) { - switch (key) { - case STATE_METADATA_KEYS.baseline: - return getBaselineMetadata() ?? fallback; - case STATE_METADATA_KEYS.hotspot: - return getHotspotBundle() ?? fallback; - case STATE_METADATA_KEYS.region: - return getRegionBundle() ?? fallback; - case STATE_METADATA_KEYS.baselineBuild: - return getBaselineBuildMetadata() ?? fallback; - default: - return fallback; - } - } - - function setMetadata(key, value) { - switch (key) { - case STATE_METADATA_KEYS.baseline: - setBaselineMetadata(value); - return; - case STATE_METADATA_KEYS.hotspot: - setHotspotMetadata(value?.hotspots || value, value?.summary || {}); - return; - case STATE_METADATA_KEYS.region: - setRegionMetadata(value?.regions || value, value?.summary || {}); - return; - case STATE_METADATA_KEYS.baselineBuild: - setBaselineBuildMetadata(value); - return; - default: - throw new Error(`Unknown metadata key: ${key}`); - } - } - - function removeMetadata(key) { - switch (key) { - case STATE_METADATA_KEYS.baseline: - return clearBaselineMetadata(); - case STATE_METADATA_KEYS.hotspot: - return clearHotspotMetadata(); - case STATE_METADATA_KEYS.region: - return clearRegionMetadata(); - case STATE_METADATA_KEYS.baselineBuild: - return clearBaselineBuildMetadata(); - default: - return false; - } - } - - return { - backend: 'file', - - readReportData() { - return withSummary(readReportData()); - }, - - replaceReportData(reportData, detailsById = null) { - let normalized = withSummary({ - timestamp: reportData?.timestamp || Date.now(), - comparisons: reportData?.comparisons || [], - }); - - writeReportData(normalized); - - if (detailsById && typeof detailsById === 'object') { - writeComparisonDetails(detailsById); - } else { - writeComparisonDetails({}); - } - }, - - upsertComparison(comparison) { - let reportData = readReportData() || { - timestamp: Date.now(), - comparisons: [], - summary: { total: 0, passed: 0, failed: 0, errors: 0 }, - }; - - if (!reportData.comparisons) { - reportData.comparisons = []; - } - - let existingIndex = reportData.comparisons.findIndex( - item => item.id === comparison.id - ); - - if (existingIndex >= 0) { - let initialStatus = reportData.comparisons[existingIndex].initialStatus; - reportData.comparisons[existingIndex] = { - ...comparison, - initialStatus: initialStatus || comparison.status, - }; - } else { - reportData.comparisons.push({ - ...comparison, - initialStatus: comparison.status, - }); - } - - reportData.timestamp = Date.now(); - reportData.summary = buildSummary(reportData.comparisons); - writeReportData(reportData); - }, - - getComparisonByIdOrSignatureOrName(value) { - let reportData = readReportData(); - if (!reportData) return null; - - return ( - (reportData.comparisons || []).find( - comparison => - comparison.id === value || - comparison.signature === value || - comparison.name === value - ) || null - ); - }, - - upsertComparisonDetails(id, details) { - let allDetails = readComparisonDetails(); - allDetails[id] = details; - writeComparisonDetails(allDetails); - }, - - getComparisonDetails(id) { - let allDetails = readComparisonDetails(); - return allDetails[id] || null; - }, - - removeComparisonDetails(id) { - let allDetails = readComparisonDetails(); - delete allDetails[id]; - writeComparisonDetails(allDetails); - }, - - deleteComparison(id) { - let reportData = readReportData(); - if (!reportData) { - return; - } - - reportData.comparisons = (reportData.comparisons || []).filter( - comparison => comparison.id !== id - ); - reportData.timestamp = Date.now(); - reportData.summary = buildSummary(reportData.comparisons); - writeReportData(reportData); - - let allDetails = readComparisonDetails(); - delete allDetails[id]; - writeComparisonDetails(allDetails); - }, - - resetReportData() { - writeReportData({ - timestamp: Date.now(), - comparisons: [], - summary: { total: 0, passed: 0, failed: 0, errors: 0 }, - }); - writeComparisonDetails({}); - }, - - getMetadata, - - getSchemaVersion() { - return 0; - }, - setMetadata, - removeMetadata, - getBaselineMetadata, - setBaselineMetadata, - clearBaselineMetadata, - removeBaselineScreenshot, - getHotspotBundle, - getHotspotMetadata, - setHotspotMetadata, - clearHotspotMetadata, - getRegionBundle, - getRegionMetadata, - setRegionMetadata, - clearRegionMetadata, - getBaselineBuildMetadata, - setBaselineBuildMetadata, - clearBaselineBuildMetadata, - - subscribe(listener) { - return subscribeToStateChanges(workingDir, listener); - }, - - close() { - // No-op for file backend - }, - }; -} diff --git a/src/tdd/state-store/utils.js b/src/tdd/state-store/utils.js index fba96d58..70ea1014 100644 --- a/src/tdd/state-store/utils.js +++ b/src/tdd/state-store/utils.js @@ -10,14 +10,6 @@ export function parseJson(value, fallback = null) { } } -function toIntegerBool(value) { - return value ? 1 : 0; -} - -function fromIntegerBool(value) { - return value === 1; -} - export function hasReportData(reportData) { if (!reportData || typeof reportData !== 'object') { return false; @@ -73,8 +65,8 @@ export function mapComparisonRow(row) { error: row.error, originalName: row.original_name, timestamp: row.timestamp, - hasDiffClusters: fromIntegerBool(row.has_diff_clusters), - hasConfirmedRegions: fromIntegerBool(row.has_confirmed_regions), + hasDiffClusters: Boolean(row.has_diff_clusters), + hasConfirmedRegions: Boolean(row.has_confirmed_regions), }; } @@ -122,8 +114,8 @@ export function normalizeComparison(comparison, initialStatus) { normalized.heightDiff == null ? null : Number(normalized.heightDiff), error: normalized.error ?? null, original_name: normalized.originalName ?? null, - has_diff_clusters: toIntegerBool(normalized.hasDiffClusters), - has_confirmed_regions: toIntegerBool(normalized.hasConfirmedRegions), + 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, diff --git a/tests/server/handlers/tdd-handler.test.js b/tests/server/handlers/tdd-handler.test.js index cc99f3ca..90da859d 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 @@ -73,34 +86,20 @@ function createMockTddService(overrides = {}) { */ function createMockDeps(overrides = {}) { let mockOutput = createMockOutput(); - let fileSystem = {}; + let dbWorkingDir = mkdtempSync(joinPath(tmpdir(), 'vizzly-tdd-handler-')); + let stateStore = createStateStore({ workingDir: dbWorkingDir }); - return { + let deps = { TddService: overrides.TddService ?? createMockTddService(overrides.tddServiceOverrides), - existsSync: overrides.existsSync ?? (path => path in fileSystem), - mkdirSync: - overrides.mkdirSync ?? - (() => { - // No-op for virtual file system - }), - unlinkSync: - overrides.unlinkSync ?? - (path => { - delete fileSystem[path]; - }), + 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 ?? { @@ -113,10 +112,27 @@ function createMockDeps(overrides = {}) { validateScreenshotProperties: overrides.validateScreenshotProperties ?? (props => props), output: overrides.output ?? mockOutput, - stateBackend: overrides.stateBackend ?? 'file', - _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', () => { @@ -1070,8 +1086,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); @@ -1111,8 +1126,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,8 +1162,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); @@ -1159,9 +1172,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' ); @@ -1198,16 +1209,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' ); @@ -1237,8 +1245,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); @@ -1277,8 +1284,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); @@ -1320,8 +1326,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); @@ -1329,9 +1334,7 @@ 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); }); }); @@ -1347,22 +1350,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 => ({ @@ -1390,10 +1391,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); @@ -1411,14 +1410,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, }); @@ -1445,32 +1442,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') || - c.args[0].includes('Failed to update comparison')) - ); - assert.ok(errorCall); - }); }); }); }); diff --git a/tests/tdd/server-registry.test.js b/tests/tdd/server-registry.test.js index 4230fe37..b0b6d4d6 100644 --- a/tests/tdd/server-registry.test.js +++ b/tests/tdd/server-registry.test.js @@ -300,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/tdd-service.integration.test.js b/tests/tdd/tdd-service.integration.test.js new file mode 100644 index 00000000..21b31a8a --- /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 }); + 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 }); + 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 }); + 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(); + } + }); +});