From c2a063b946302234d57a6950fe7ce7ef2a904199 Mon Sep 17 00:00:00 2001 From: User Date: Fri, 10 Jul 2026 08:56:54 +0530 Subject: [PATCH 001/334] test(infra): honest coverage denominator + React render harness Correct the coverage measurement to the true testable surface: all:true + .ts include (was all:false, which hid ~63% of the surface behind a flattering 77%), exclude native/IPC/spawn shells (logic extracted to measured siblings) and e2e-covered .tsx components. Add a React render harness (jsdom + @testing-library/react + resolve aliases + .tsx test glob) so renderer components get real mount tests. Floors ratcheted to measured (95/90/93/96). Co-Authored-By: Claude Opus 4.8 --- package-lock.json | 772 +++++++++++++++++++++++++++++++++++++++++++++- package.json | 4 + vitest.config.ts | 152 +++++++-- 3 files changed, 906 insertions(+), 22 deletions(-) diff --git a/package-lock.json b/package-lock.json index 12cd4cd4..fc682477 100644 --- a/package-lock.json +++ b/package-lock.json @@ -79,6 +79,9 @@ "@electron-toolkit/eslint-config-ts": "^3.1.0", "@electron-toolkit/tsconfig": "^2.0.0", "@playwright/test": "^1.61.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.19.1", "@types/react": "^19.2.7", @@ -94,6 +97,7 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", + "jsdom": "^29.1.1", "postcss": "^8.5.6", "prettier": "^3.7.4", "react": "^19.2.1", @@ -123,6 +127,64 @@ "extraneous": true, "license": "AGPL-3.0-only" }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", @@ -440,12 +502,165 @@ "node": ">=18" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@cfworker/json-schema": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", "license": "MIT" }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@develar/schema-utils": { "version": "2.6.5", "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", @@ -1647,6 +1862,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -7165,6 +7398,96 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tinyhttp/content-disposition": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/@tinyhttp/content-disposition/-/content-disposition-2.2.2.tgz", @@ -7648,6 +7971,14 @@ "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", "license": "MIT" }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/aws-lambda": { "version": "8.10.159", "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.159.tgz", @@ -8902,6 +9233,16 @@ "node": ">=10" } }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/array-back": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/array-back/-/array-back-3.1.0.tgz", @@ -9421,6 +9762,16 @@ "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -10552,6 +10903,27 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -10729,6 +11101,58 @@ "node": ">=12" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -10800,6 +11224,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", @@ -11104,6 +11535,14 @@ "node": ">=0.10.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dompurify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", @@ -11542,6 +11981,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -13706,6 +14158,19 @@ "dev": true, "license": "ISC" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -13888,8 +14353,8 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "devOptional": true, "license": "MIT", - "optional": true, "engines": { "node": ">=8" } @@ -14514,6 +14979,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -14893,6 +15365,95 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -15561,6 +16122,17 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -15996,6 +16568,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -16661,6 +17240,16 @@ "node": ">=4" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/mini-svg-data-uri": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", @@ -18125,6 +18714,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -18573,6 +19175,44 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/pretty-ms": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", @@ -19115,6 +19755,20 @@ "node": ">= 6" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -19543,6 +20197,19 @@ "node": ">=11.0.0" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -20451,6 +21118,19 @@ "node": ">=8" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -20528,6 +21208,13 @@ "react": ">=17.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/synckit": { "version": "0.11.12", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", @@ -20930,6 +21617,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.7", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.7.tgz", + "integrity": "sha512-56L0/9HELHSsG1bFCzay8UoLxzRL7kpFf7Wl5q/kSYwiSJGACvro61xnKzPNM+SadxllzdtXsKDSXE7HPeqIAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.7" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.7", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.7.tgz", + "integrity": "sha512-rNlAI8fKn/JckBMUSbNL/ES2kmDiurWaE49l+ikwEc9A6lFR7gMx9AhgQMQKBK4H5w4pKLH64JzZfB99uRsGNQ==", + "dev": true, + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", @@ -20968,6 +21675,19 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -21267,6 +21987,16 @@ "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", "license": "MIT" }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -22211,6 +22941,19 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", @@ -22240,6 +22983,16 @@ "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", "license": "MIT" }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -22442,6 +23195,16 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", @@ -22452,6 +23215,13 @@ "node": ">=8.0" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 7342d230..5ba73ad2 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,9 @@ "@electron-toolkit/eslint-config-ts": "^3.1.0", "@electron-toolkit/tsconfig": "^2.0.0", "@playwright/test": "^1.61.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.19.1", "@types/react": "^19.2.7", @@ -119,6 +122,7 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", + "jsdom": "^29.1.1", "postcss": "^8.5.6", "prettier": "^3.7.4", "react": "^19.2.1", diff --git a/vitest.config.ts b/vitest.config.ts index c66ce907..005772f8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,3 +1,4 @@ +import { resolve } from 'path'; import { defineConfig } from 'vitest/config'; // Unit + integration tests (fast, deterministic). The Playwright Electron E2E lives @@ -10,18 +11,38 @@ import { defineConfig } from 'vitest/config'; // The 85% floor is enforced here and on pre-push. `all: true` means a new pure module // with no test drags the number down, so untested logic cannot sneak in. export default defineConfig({ + // Renderer path aliases, mirrored 1:1 from tsconfig.web.json `paths`. Without these + // a .tsx render test cannot import any renderer module (electron-vite provides them + // in the app build, but vitest has no tsconfig-paths plugin), so the *.test.tsx glob + // above is inert until they exist. Additive only — no gate/threshold/include change. + resolve: { + alias: { + '@renderer': resolve(__dirname, 'src/renderer/src'), + '@offgrid/core': resolve(__dirname, 'src'), + '@offgrid/pro': resolve(__dirname, 'src/bootstrap/proStub.ts'), + '@': resolve(__dirname, 'src/renderer/src') + } + }, test: { - include: ['src/**/*.test.ts', 'pro/**/*.test.ts'], + // .ts = pure/main unit + integration tests (node env, the default). .tsx = renderer + // component render tests, which opt into jsdom per-file via `// @vitest-environment jsdom` + // so the default suite stays node-fast. (React render harness: jsdom + @testing-library/react.) + include: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'pro/**/*.test.ts', 'pro/**/*.test.tsx'], exclude: ['e2e/**', 'node_modules/**', 'out/**'], coverage: { provider: 'v8', - all: false, + // all:true + an `include` of the LOGIC surface (.ts, both core src AND the pro + // submodule) => every logic file is in the denominator whether or not a test imports + // it, so untested modules show as 0% and are VISIBLE (previously all:false hid them - + // pro/main had ~72 .ts files but only the imported ones counted, flattering the %). + // Mirrors mobile's collectCoverageFrom(src + pro). UI (.tsx) is deliberately NOT here: + // desktop covers rendered components via the Playwright e2e tour, not unit tests. + all: true, + include: ['src/**/*.ts', 'pro/**/*.ts'], reporter: ['text-summary', 'json-summary', 'json'], - // No `include` glob + all:false => coverage counts ONLY the files the tests - // actually import. The gate measures OUR unit-testable logic; it excludes - // (a) vendored/built code (not ours) and (b) native/DB/spawn I/O shells that the - // default vitest runner CANNOT cover in-process - each of those is covered by a - // real alternative suite (noted per line), not left untested. See + // Excludes: (a) vendored/built code (not ours) and (b) native/DB/spawn/IPC-wiring + // shells the default vitest runner CANNOT cover in-process - each covered by a real + // alternative suite (test:db / smoke / e2e), not left untested. See // docs/FUNCTIONAL_TEST_STRATEGY.md. exclude: [ '**/*.test.ts', @@ -48,22 +69,111 @@ export default defineConfig({ 'src/main/transcription/parakeet-cli.ts', 'src/main/transcription/whisper-server.ts', 'src/main/coreml-image.ts', - // Large UI components: rendered-behavior surface, covered by the Playwright e2e - // tour (npm run test:e2e), not unit tests. Their pure helpers are extracted + - // unit-tested separately (e.g. parseArtifact, lib/*). - 'src/renderer/src/components/MemoryChat.tsx', - 'pro/renderer/screens/VaultScreen.tsx', + // Entry/wiring that isn't logic (index barrels re-export; bootstrap boots Electron). + 'src/main/index.ts', + 'src/preload/**', + // CORE native/IPC-wiring/entry shells (recon-classified): pure logic already + // extracted to measured siblings (ipc-query-logic, search-ranking, model-sizing, + // models/*, llm/*, licensing/*-logic, files-classify, tts-logic, etc.). These husks + // register ipcMain handlers, spawn binaries, bind sockets, or call native/OS/network + // APIs - not unit-coverable in-process; exercised via e2e / smoke / test:db. + 'src/main/ipc.ts', // ~100 ipcMain.handle registrations (logic → ipc-query-logic.ts) + 'src/main/rag-ipc.ts', + 'src/main/mcp-ipc.ts', + 'src/main/license-ipc.ts', + 'src/main/llm.ts', // spawns llama-server; pure bits in llm/* (tested) + 'src/main/mcp.ts', + 'src/main/mcp-oauth.ts', + 'src/main/mcp-server.ts', // MCP tool registration; parseDataUrl extracted+tested + 'src/main/updater.ts', + 'src/main/dev-seed.ts', + 'src/main/vision.ts', + 'src/main/ocr.ts', + 'src/main/embeddings.ts', + 'src/main/permissions.ts', + 'src/main/rag/extractors.ts', + 'src/main/rag/index.ts', // orchestrator; buildProjectPrompt extracted → rag/prompt.ts + 'src/main/licensing/license-service.ts', // Keychain/IPC shell; isProActive → license-service logic exports (tested) + 'src/main/licensing/keygen-client.ts', // fetch shell; parsers extracted+tested + 'src/main/licensing/keygen-config.ts', // constants only + 'src/main/bootstrap/loadProFeaturesMain.ts', // dynamic-import loader; proEnabled() tested + 'src/main/search.ts', // DB orchestrator; ranking in search-ranking.ts (tested) + 'src/main/setup.ts', // model-recommendation orchestrator; fusion via tested model-sizing + 'src/main/models-manager.ts', // catalog/install/activate IO; logic in models/* (tested) + 'src/main/skills.ts', // fs CRUD shell; parsers → skills-parse.ts (tested) + 'src/main/tools.ts', // agentic loop (tools-stream.test.ts) + parsers (tools-parsers.ts) + 'src/main/files.ts', // upload IO; classifyUpload → files-classify.ts (tested) + 'src/main/tts.ts', // engine spawn; chooseVoice/parseServeLine → tts-logic.ts (tested) + 'src/main/vectors.ts', // LanceDB shell; predicates → vectors-predicates.ts (tested) + 'src/main/data-privacy.ts', + 'src/main/artifacts.ts', + 'src/main/secrets.ts', + 'src/main/vision.ts', + // Renderer .ts that are pure IPC passthrough (no logic) or React hooks (e2e-covered). + 'src/renderer/src/lib/voiceApi.ts', + 'src/renderer/src/useMeetingRecorder.ts', + 'src/renderer/src/bootstrap/loadProFeaturesRenderer.ts', + 'src/bootstrap/proStub.ts', + // PRO renderer IPC-passthrough API wrappers (no logic — mirror the core voiceApi rule). + 'pro/renderer/api.ts', + 'pro/renderer/vaultApi.ts', + 'pro/renderer/components/voice/voiceApi.ts', + // PRO native/IPC-wiring/entry shells: the same class as core's excluded shells - + // IPC registration, native ScreenCaptureKit/meeting bridges, OS text injection, + // screen-capture watcher, network clients, the dev seeder, window/overlay glue. + // Their pure logic lives in sibling modules that ARE measured (crm/*, dictation/*, + // vault/*, lib/*, clipboard-*.ts). Exercised via e2e/integration, not unit. + 'pro/main/index.ts', + 'pro/main/**/*-ipc.ts', + 'pro/main/**/ipc.ts', + 'pro/main/meeting-native.ts', + 'pro/main/meeting-detect.ts', + 'pro/main/meeting-controller.ts', + 'pro/main/meeting-service.ts', + 'pro/main/meetings.ts', + 'pro/main/watcher.ts', + 'pro/main/text-injection.ts', + 'pro/main/scraper.ts', + 'pro/main/console.ts', + 'pro/main/google-rest.ts', + 'pro/main/dev-seed.ts', + 'pro/main/services.ts', + 'pro/main/dictation/overlay.ts', + 'pro/main/dictation/controller.ts', + // Recon-confirmed pro shells (logic already extracted+tested in siblings, or pure + // native/window glue): clipboard.ts = BrowserWindow popup + ipcMain + globalShortcut + // (logic in clipboard-store/search/file-write, tested); focus.ts = setInterval + native + // activeWindow poll; hotkey/toggle.ts = globalShortcut register/unregister wrapper. + 'pro/main/clipboard.ts', + 'pro/main/focus.ts', + 'pro/main/dictation/hotkey/toggle.ts', + 'pro/main/crm/notify.ts', // pure Electron Notification shell (isSupported/new Notification/show) — no branchable logic + + // Renderer .tsx COMPONENTS are rendered-behavior surface, covered by the Playwright + // e2e tour (npm run test:e2e) + targeted render tests (MemoryChat.image.test.tsx), + // NOT unit coverage — their pure logic is extracted to measured .ts (lib/*, image-params, + // message-persistence, chat-labels, image-intent). The coverage `include` is .ts-only by + // design; this also drops any .tsx a render test transitively imports from the denominator + // (a render test asserts the terminal artifact, it is not a unit-coverage vehicle). + 'src/renderer/src/**/*.tsx', + 'pro/renderer/**/*.tsx', ], thresholds: { - // RATCHET FLOOR on the testable surface (see exclude list above). Set just below - // current measured coverage so the pre-push hook blocks REGRESSIONS. 85% is the - // documented goal (CLAUDE.md); we are close now. Raise toward 85 as coverage - // climbs; never lower them. (Jumped from 54->77 when the coverage denominator was - // corrected to exclude vendored + native-shell code the default runner can't cover.) - statements: 77, - branches: 74, - functions: 76, - lines: 78, + // RATCHET FLOOR on the HONEST TESTABLE surface. The denominator is corrected two ways + // vs the old flattering "77%": (1) `all:true` + the .ts include measures ALL owned logic, + // not just files a test imported; (2) the exclude list carves out native/IPC/spawn/entry + // SHELLS (pure logic extracted to measured siblings) + e2e-covered .tsx components — so + // what's counted is the code that CAN be unit-tested. The coverage-campaign brought this + // from a real ~29% baseline to measured global ~97/92/95/98 (core 97/94/96/98, pro + // 97/91/95/98). Floors set just under measured so pre-push blocks REGRESSIONS; they only + // ever rise, never lower to pass. Comfortably past the 85 goal. + statements: 95, + branches: 90, + functions: 93, + lines: 96, + // pro/** carved into its own group (mobile pattern) so pro is separately regression- + // guarded, not averaged into core. Just under pro's measured 96.9/91.0/94.8/98.3. + 'pro/**': { statements: 95, branches: 89, functions: 93, lines: 97 }, }, }, }, From 598aa999998c71a73dab68afa76a386855aa681c Mon Sep 17 00:00:00 2001 From: User Date: Fri, 10 Jul 2026 08:56:54 +0530 Subject: [PATCH 002/334] fix(llm): KV-cache choice survives a mode pick + restart (two-writer merge) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An explicit kvCacheType (q8_0) silently reverted to f16 because a performanceMode patch applied MODE_PRESETS and unconditionally overwrote the user's kvCacheType/flashAttn/ctxSize, persisting the clobber — re-triggered on every mode (re)pick. Fix: a pure applyModePreset() that MERGES — a preset only fills fields the user hasn't explicitly pinned (userExplicit set, persisted + reloaded). Extract buildLaunchArgs() as the single source for the launch argv. Terminal-artifact test drives the real persist->restart->launch-args round-trip (q8_0 still in --cache-type-k after a balanced pick + restart), red-proven 3 ways. Co-Authored-By: Claude Opus 4.8 --- src/main/llm.ts | 81 +++++++---- .../llm/__tests__/kv-launch-roundtrip.test.ts | 127 ++++++++++++++++++ src/main/llm/__tests__/settings-merge.test.ts | 99 ++++++++++++++ src/main/llm/settings-math.ts | 85 ++++++++++++ 4 files changed, 368 insertions(+), 24 deletions(-) create mode 100644 src/main/llm/__tests__/kv-launch-roundtrip.test.ts create mode 100644 src/main/llm/__tests__/settings-merge.test.ts diff --git a/src/main/llm.ts b/src/main/llm.ts index 182a5b0d..336c4b64 100644 --- a/src/main/llm.ts +++ b/src/main/llm.ts @@ -11,7 +11,7 @@ import { classifyLlamaError } from "./llama-error"; import type { ManagedRuntime } from "./runtime-manager"; import { LLAMA_SERVER_PORT } from "../shared/ports"; import { DEFAULT_CTX_SIZE } from "../shared/llm-defaults"; -import { MODE_PRESETS, samplingPayload, launchArgsChanged } from "./llm/settings-math"; +import { applyModePreset, samplingPayload, launchArgsChanged, buildLaunchArgs, type PresetField } from "./llm/settings-math"; import { buildMessages, imageMime, thinkingPayload, type DecodedImage } from "./llm/chat-payload"; import { parseSseLine, createThinkSplitter, createToolCallAccumulator, type AssembledToolCall } from "./llm/sse-stream"; import { modelRequestOptions, postCompletionOnce } from "./llm/http-post"; @@ -78,6 +78,12 @@ export class LLMService { // Launch-time params (need a respawn). Defaults match prior hardcoded behavior. private kvCacheType: KvCacheType = "f16"; private flashAttn = false; + // Which mode-preset-governed fields (ctxSize/kvCacheType/flashAttn) the user has + // explicitly pinned via a granular control. A mode preset only fills fields NOT in + // this set, so choosing/reapplying a performance mode never clobbers an explicit KV + // choice (the "q8_0 reverts to f16 on every restart" bug). Persisted alongside the + // values so a plain restart keeps the pin. + private userExplicit = new Set(); private gpuLayers = 99; private threads: number | undefined; private batchSize: number | undefined; @@ -113,6 +119,11 @@ export class LLMService { if (typeof s.threads === "number") this.threads = s.threads; if (typeof s.batchSize === "number") this.batchSize = s.batchSize; if (s.performanceMode === "conservative" || s.performanceMode === "balanced" || s.performanceMode === "extreme") this.performanceMode = s.performanceMode; + // Restore which preset fields the user pinned, so a plain restart keeps an + // explicit KV/ctx/flash-attn choice instead of letting the mode preset win. + if (Array.isArray(s.userExplicit)) { + for (const f of s.userExplicit) if (f === "ctxSize" || f === "kvCacheType" || f === "flashAttn") this.userExplicit.add(f); + } } catch { /* defaults */ } } @@ -156,6 +167,33 @@ export class LLMService { } as LlmSettings & { effectiveCtxSize: number }; } + /** The exact argv handed to `llama-server` for the CURRENT settings — the terminal + * artifact of the whole settings→persist→reload path. Delegates to the pure + * `buildLaunchArgs` (single source of truth) after applying the impure RAM clamp, + * so `_doInit` and tests build args the same way. */ + launchArgs(): string[] { + return buildLaunchArgs({ + modelPath: this.modelPath, + mmProjPath: this.mmProjPath, + port: this.port, + effectiveCtxSize: this.safeCtxSize(this.ctxSize), + gpuLayers: this.gpuLayers, + flashAttn: this.flashAttn, + kvCacheType: this.kvCacheType, + threads: this.threads, + batchSize: this.batchSize, + }); + } + + /** Persist settings to disk. Writes the public settings PLUS the internal + * `userExplicit` pin-set (which fields the user set granularly), so a plain restart + * restores the pins and a mode preset can't reclobber an explicit KV/ctx choice. */ + private persist(): void { + try { + fs.writeFileSync(this.settingsFile, JSON.stringify({ ...this.getSettings(), userExplicit: [...this.userExplicit] })); + } catch { /* ignore */ } + } + /** Sampling params to merge into a request payload (only those the user set). */ private samplingPayload(): Record { return samplingPayload({ topP: this.topP, topK: this.topK, minP: this.minP, repeatPenalty: this.repeatPenalty }); @@ -179,13 +217,24 @@ export class LLMService { /** Update inference settings; respawns the server if any launch-time arg changed * (context, KV-cache type, flash-attn, GPU layers, threads, batch). */ async setSettings(s: LlmSettings): Promise { - // A resource-usage mode change applies its preset first (explicit fields in the - // same patch still override it below). Always treated as a launch change. + // Granular launch-time fields the user sets in THIS patch become pinned: a mode + // preset (now or on a future restart / mode re-pick) must NOT clobber them. Pin + // BEFORE applying the preset so an explicit q8_0 in the same patch survives. + if (s.kvCacheType === "f16" || s.kvCacheType === "q8_0" || s.kvCacheType === "q4_0") this.userExplicit.add("kvCacheType"); + if (typeof s.flashAttn === "boolean") this.userExplicit.add("flashAttn"); + if (typeof s.ctxSize === "number") this.userExplicit.add("ctxSize"); + // A resource-usage mode change applies its preset by MERGING: it fills only the + // preset fields the user has NOT pinned, so it can't wipe an explicit KV choice. + // Always treated as a launch change. let modeChanged = false; if ((s.performanceMode === "conservative" || s.performanceMode === "balanced" || s.performanceMode === "extreme") && s.performanceMode !== this.performanceMode) { this.performanceMode = s.performanceMode; - const p = MODE_PRESETS[s.performanceMode]; - this.ctxSize = p.ctxSize; this.kvCacheType = p.kvCacheType; this.flashAttn = p.flashAttn; + const merged = applyModePreset( + { ctxSize: this.ctxSize, kvCacheType: this.kvCacheType, flashAttn: this.flashAttn }, + s.performanceMode, + this.userExplicit, + ); + this.ctxSize = merged.ctxSize; this.kvCacheType = merged.kvCacheType; this.flashAttn = merged.flashAttn; modeChanged = true; } // Launch-time args: changing any of these requires a server respawn. @@ -208,7 +257,7 @@ export class LLMService { if (typeof s.batchSize === "number") this.batchSize = s.batchSize; // Quantized KV cache requires FlashAttention — auto-enable it so the pair is valid. if (this.kvCacheType !== "f16" && !this.flashAttn) this.flashAttn = true; - try { fs.writeFileSync(this.settingsFile, JSON.stringify(this.getSettings())); } catch { /* ignore */ } + this.persist(); if (launchChanged && !this.paused) { this.stop(); await this.init(); @@ -358,23 +407,7 @@ export class LLMService { const binDir = path.dirname(serverPath); - const args = ["-m", this.modelPath]; - if (this.mmProjPath) args.push("--mmproj", this.mmProjPath); - args.push( - "--port", String(this.port), - "--host", "127.0.0.1", - "-c", String(this.safeCtxSize(this.ctxSize)), - "-ngl", String(this.gpuLayers) - ); - // FlashAttention: faster + lower memory. Required for a quantized KV cache. - if (this.flashAttn || this.kvCacheType !== "f16") args.push("--flash-attn", "on"); - // Quantized KV cache (q8_0/q4_0) shrinks the per-token memory footprint — the - // single biggest lever against memory-overcommit freezes on big contexts. - if (this.kvCacheType !== "f16") { - args.push("--cache-type-k", this.kvCacheType, "--cache-type-v", this.kvCacheType); - } - if (typeof this.threads === "number") args.push("-t", String(this.threads)); - if (typeof this.batchSize === "number") args.push("-b", String(this.batchSize)); + const args = this.launchArgs(); // Kill any lingering server before spawning (defends against a crashed or // orphaned instance still holding the port / RAM). if (this.server) { @@ -480,7 +513,7 @@ export class LLMService { if (reduced < this.ctxSize) { console.warn(`[LLMService] reducing context ${this.ctxSize} -> ${reduced} after repeated crashes`); this.ctxSize = reduced; - try { fs.writeFileSync(this.settingsFile, JSON.stringify(this.getSettings())); } catch { /* ignore */ } + this.persist(); } } await new Promise((r) => setTimeout(r, 1000 * this.restartTimes.length)); diff --git a/src/main/llm/__tests__/kv-launch-roundtrip.test.ts b/src/main/llm/__tests__/kv-launch-roundtrip.test.ts new file mode 100644 index 00000000..aacc180d --- /dev/null +++ b/src/main/llm/__tests__/kv-launch-roundtrip.test.ts @@ -0,0 +1,127 @@ +/** + * Terminal-artifact regression test for the "explicit KV-cache reverts to f16" bug. + * + * The shape test (settings-merge.test.ts) asserts the pure `applyModePreset` return. + * That is necessary but NOT sufficient (hygiene §D assertion-subject gate): the FEATURE + * the user cares about is that llama-server actually LAUNCHES with q8_0 after a mode + * pick AND after an app restart. The terminal artifact is the LAUNCH ARGS — what really + * gets passed to the engine — reached through the REAL persist→reload path, not a merge + * function's return value. + * + * This drives the whole round-trip against a REAL temp settings file (OFFGRID_DATA_DIR + * points `modelsDir()` at a mkdtemp dir, so persist() writes and the constructor reads + * the same JSON on disk — no stubs): + * (a) user sets kvCacheType='q8_0' → pins + persists + * (b) apply performanceMode='balanced' (the mode preset that carries f16) + * (c) RESTART — a fresh LLMService whose constructor re-reads the persisted file + * (d) build the launch args from the reloaded instance + * (e) assert the args contain '--cache-type-k','q8_0' and '--flash-attn','on' — NOT f16 + * + * Litmus (red-capable by a DIFFERENT mechanism than the merge line): this fails if + * persist() drops the pin-set, if the constructor doesn't reload kvCacheType/pins, or + * if the arg-builder ignores the KV setting — not only if applyModePreset regresses. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { LLMService, LlmSettings } from '../../llm'; + +// Assert on the actual argv slots, not just "includes 'q8_0'": a `--cache-type-k q8_0` +// pair is the real launch contract. Helper finds the value that follows a flag. +function argValue(args: string[], flag: string): string | undefined { + const i = args.indexOf(flag); + return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined; +} + +describe('KV-cache launch-args round-trip (persist → restart → launch)', () => { + let dataDir: string; + const prevDataDir = process.env.OFFGRID_DATA_DIR; + + beforeEach(() => { + // Real temp dir → real settings file on disk (mirrors the vault-service.test.ts / + // database-integration.dbtest.ts temp-dir pattern). + dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-kv-')); + fs.mkdirSync(path.join(dataDir, 'models'), { recursive: true }); + process.env.OFFGRID_DATA_DIR = dataDir; + }); + + afterEach(() => { + if (prevDataDir === undefined) { + delete process.env.OFFGRID_DATA_DIR; + } else { + process.env.OFFGRID_DATA_DIR = prevDataDir; + } + try { + fs.rmSync(dataDir, { recursive: true, force: true }); + } catch { + /* ignore */ + } + }); + + // Fresh module instance per test so each LLMService reads the current env/dir. + async function freshService() { + const mod = await import('../../llm'); + return new mod.LLMService(); + } + + // setSettings respawns on a launch-arg change; with no gguf in the temp dir the + // respawn's init() rejects with "Models not downloaded" AFTER persist() has already + // written — so swallow that expected rejection; the persisted round-trip is intact. + async function applySettings(svc: LLMService, s: LlmSettings) { + await svc.setSettings(s).catch(() => {}); + } + + it('q8_0 pinned → balanced mode → RESTART still launches with q8_0 + flash-attn on (not f16)', async () => { + // (a) user pins q8_0 granularly + const first = await freshService(); + await applySettings(first, { kvCacheType: 'q8_0' }); + // (b) then picks a performance mode whose preset is f16 (the clobber the bug caused) + await applySettings(first, { performanceMode: 'balanced' }); + + // The persisted file must carry BOTH the value AND the pin-set — the round-trip + // relies on it, so assert the on-disk contract directly. + const persisted = JSON.parse(fs.readFileSync(path.join(dataDir, 'models', 'llm-settings.json'), 'utf-8')); + expect(persisted.kvCacheType).toBe('q8_0'); + expect(persisted.userExplicit).toContain('kvCacheType'); + + // (c) RESTART: a brand-new service whose constructor re-reads the persisted file. + const restarted = await freshService(); + + // (d)+(e) the TERMINAL ARTIFACT: the argv handed to llama-server after the restart. + const args = restarted.launchArgs(); + expect(argValue(args, '--cache-type-k')).toBe('q8_0'); + expect(argValue(args, '--cache-type-v')).toBe('q8_0'); + expect(argValue(args, '--flash-attn')).toBe('on'); + // Belt-and-braces: f16 never leaks into the KV flags. + expect(args).not.toContain('f16'); + }); + + it('no pin: conservative → balanced mode → RESTART launches with f16 (no KV flags) — the intended default', async () => { + // A user who never pinned KV: the mode preset is the source of truth. Go through + // conservative (q8_0) first so the balanced switch has to actually flip KV back to + // f16 — an unpinned field follows the mode all the way through the reload. + const first = await freshService(); + await applySettings(first, { performanceMode: 'conservative' }); + await applySettings(first, { performanceMode: 'balanced' }); + + const restarted = await freshService(); + const args = restarted.launchArgs(); + // balanced = f16 → no --cache-type-k/-v at all, and flash-attn stays off. + expect(args).not.toContain('--cache-type-k'); + expect(args).not.toContain('--cache-type-v'); + expect(args).not.toContain('--flash-attn'); + }); + + it('conservative mode (preset q8_0) with no pin → RESTART launches with q8_0', async () => { + // Guards that the reload path carries a preset-derived (not user-pinned) q8_0 too. + const first = await freshService(); + await applySettings(first, { performanceMode: 'conservative' }); + + const restarted = await freshService(); + const args = restarted.launchArgs(); + expect(argValue(args, '--cache-type-k')).toBe('q8_0'); + expect(argValue(args, '--flash-attn')).toBe('on'); + }); +}); diff --git a/src/main/llm/__tests__/settings-merge.test.ts b/src/main/llm/__tests__/settings-merge.test.ts new file mode 100644 index 00000000..5c61453a --- /dev/null +++ b/src/main/llm/__tests__/settings-merge.test.ts @@ -0,0 +1,99 @@ +/** + * Regression tests for the "explicit KV-cache choice silently reverts to f16" bug. + * + * Root cause: a performance-mode preset (MODE_PRESETS[mode], where balanced/extreme = + * { kvCacheType: 'f16', flashAttn: false }) UNCONDITIONALLY overwrote the user's + * kvCacheType/flashAttn/ctxSize and persisted the clobber. Two writers (the mode + * preset + the granular KV control) owned the same keys with no merge; the preset + * always won. The SetupPanel re-sends `{ performanceMode }` whenever a mode is + * (re)picked, so an explicit q8_0 died the moment a mode was reapplied. + * + * The fix is a pure MERGE (applyModePreset): the preset fills ONLY the fields the user + * has not explicitly pinned. These tests lock: an explicit q8_0 survives a preset; + * unpinned fields still take the preset; and a persisted pin survives a plain restart. + */ + +import { describe, it, expect } from 'vitest'; +import { applyModePreset, MODE_PRESETS, type PresetState, type PresetField } from '../settings-math'; + +const NONE = new Set(); + +describe('applyModePreset — the explicit-KV-reverts bug', () => { + it('THE BUG: an explicitly-pinned q8_0 is PRESERVED when balanced is applied (not reset to f16)', () => { + // User set kvCacheType='q8_0' granularly (pinned), then picks/reapplies balanced. + const current: PresetState = { ctxSize: 16384, kvCacheType: 'q8_0', flashAttn: true }; + const merged = applyModePreset(current, 'balanced', new Set(['kvCacheType', 'flashAttn'])); + expect(merged.kvCacheType).toBe('q8_0'); // NOT reverted to the balanced preset's f16 + expect(merged.flashAttn).toBe(true); + }); + + it('extreme mode also preserves a pinned q8_0 (both non-conservative presets are f16)', () => { + const current: PresetState = { ctxSize: 65536, kvCacheType: 'q8_0', flashAttn: true }; + const merged = applyModePreset(current, 'extreme', new Set(['kvCacheType', 'flashAttn'])); + expect(merged.kvCacheType).toBe('q8_0'); + }); + + it('applies the preset to fields the user has NOT pinned (ctxSize follows balanced)', () => { + // Only kvCacheType is pinned — ctxSize + flashAttn take the balanced preset. + const current: PresetState = { ctxSize: 8192, kvCacheType: 'q8_0', flashAttn: true }; + const merged = applyModePreset(current, 'balanced', new Set(['kvCacheType'])); + expect(merged.ctxSize).toBe(MODE_PRESETS.balanced.ctxSize); // 16384, unpinned → preset + expect(merged.flashAttn).toBe(MODE_PRESETS.balanced.flashAttn); // false, unpinned → preset + expect(merged.kvCacheType).toBe('q8_0'); // pinned → kept + }); + + it('with NOTHING pinned, a preset fully applies (behavior-neutral for the non-conflicting case)', () => { + const current: PresetState = { ctxSize: 999, kvCacheType: 'q4_0', flashAttn: false }; + const merged = applyModePreset(current, 'balanced', NONE); + expect(merged).toEqual(MODE_PRESETS.balanced); + }); + + it('conservative applied with nothing pinned yields the conservative preset (q8_0)', () => { + const merged = applyModePreset({ ctxSize: 16384, kvCacheType: 'f16', flashAttn: false }, 'conservative', NONE); + expect(merged).toEqual(MODE_PRESETS.conservative); + }); + + it('a pinned ctxSize survives while KV follows the preset (independent per-field merge)', () => { + const current: PresetState = { ctxSize: 40000, kvCacheType: 'f16', flashAttn: false }; + const merged = applyModePreset(current, 'conservative', new Set(['ctxSize'])); + expect(merged.ctxSize).toBe(40000); // pinned → kept + expect(merged.kvCacheType).toBe(MODE_PRESETS.conservative.kvCacheType); // unpinned → q8_0 + expect(merged.flashAttn).toBe(MODE_PRESETS.conservative.flashAttn); + }); + + it('does not mutate the input state (pure merge returns a fresh object)', () => { + const current: PresetState = { ctxSize: 16384, kvCacheType: 'q8_0', flashAttn: true }; + const before = { ...current }; + applyModePreset(current, 'balanced', new Set(['kvCacheType'])); + expect(current).toEqual(before); + }); +}); + +describe('boot/restart path — a persisted pin survives a plain restart', () => { + // Simulates the constructor: read persisted settings (values + userExplicit pin-set) + // off disk, then a subsequent mode re-pick (the SetupPanel resend). This is the + // "every restart" path — before the fix, the bare { performanceMode } patch let the + // preset reclobber the persisted q8_0. + it('a persisted explicit q8_0 + pin survives a restart followed by a balanced re-pick', () => { + // What was on disk from a prior session where the user pinned q8_0: + const persisted = { + ctxSize: 8192, + kvCacheType: 'q8_0' as const, + flashAttn: true, + userExplicit: ['kvCacheType', 'flashAttn'] as PresetField[], + }; + // Constructor restores state + pin-set from disk: + const state: PresetState = { ctxSize: persisted.ctxSize, kvCacheType: persisted.kvCacheType, flashAttn: persisted.flashAttn }; + const pinned = new Set(persisted.userExplicit); + // SetupPanel resends the saved mode on load → applyModePreset runs with the pins: + const merged = applyModePreset(state, 'balanced', pinned); + expect(merged.kvCacheType).toBe('q8_0'); // the pin persisted the choice across restart + }); + + it('without a pin, a restart + mode re-pick correctly takes the preset default', () => { + // A user who never pinned KV: the mode preset is the source of truth, as intended. + const state: PresetState = { ctxSize: 16384, kvCacheType: 'f16', flashAttn: false }; + const merged = applyModePreset(state, 'conservative', new Set()); + expect(merged.kvCacheType).toBe('q8_0'); // conservative preset applies (nothing pinned) + }); +}); diff --git a/src/main/llm/settings-math.ts b/src/main/llm/settings-math.ts index 86ade9dc..208947ed 100644 --- a/src/main/llm/settings-math.ts +++ b/src/main/llm/settings-math.ts @@ -18,6 +18,42 @@ export const MODE_PRESETS: Record, +): PresetState { + const p = MODE_PRESETS[mode]; + return { + ctxSize: userExplicit.has('ctxSize') ? current.ctxSize : p.ctxSize, + kvCacheType: userExplicit.has('kvCacheType') ? current.kvCacheType : p.kvCacheType, + flashAttn: userExplicit.has('flashAttn') ? current.flashAttn : p.flashAttn, + }; +} + /** The tunable inference state that the sampling/launch math reads. Matches the * private fields on LLMService so it can pass `this` straight in. */ export interface SamplingState { @@ -49,6 +85,55 @@ export interface LaunchState { batchSize: number | undefined; } +/** The fully-resolved launch inputs the arg-builder needs. `ctxSize` is already the + * EFFECTIVE (RAM-clamped) value — the clamp itself is impure (reads host RAM + weight + * file sizes) and stays in LLMService; everything here is a pure function of these + * inputs so the terminal artifact (the args array handed to llama-server) is testable. */ +export interface LaunchArgsInput { + modelPath: string; + mmProjPath: string; // empty string = text-only (no --mmproj) + port: number; + effectiveCtxSize: number; // already RAM-clamped + gpuLayers: number; + flashAttn: boolean; + kvCacheType: KvCacheType; + threads: number | undefined; + batchSize: number | undefined; +} + +/** Build the exact argv passed to `llama-server`. Pure: same inputs → same args, no I/O. + * This is the single source of truth for launch args, so the KV-cache / flash-attn + * coupling (a quantized KV cache forces `--flash-attn on` and sets both -k/-v cache + * types) is asserted directly against what the engine actually receives. */ +export function buildLaunchArgs(i: LaunchArgsInput): string[] { + const args = ['-m', i.modelPath]; + if (i.mmProjPath) { + args.push('--mmproj', i.mmProjPath); + } + args.push( + '--port', String(i.port), + '--host', '127.0.0.1', + '-c', String(i.effectiveCtxSize), + '-ngl', String(i.gpuLayers), + ); + // FlashAttention: faster + lower memory. Required for a quantized KV cache. + if (i.flashAttn || i.kvCacheType !== 'f16') { + args.push('--flash-attn', 'on'); + } + // Quantized KV cache (q8_0/q4_0) shrinks the per-token memory footprint — the single + // biggest lever against memory-overcommit freezes on big contexts. + if (i.kvCacheType !== 'f16') { + args.push('--cache-type-k', i.kvCacheType, '--cache-type-v', i.kvCacheType); + } + if (typeof i.threads === 'number') { + args.push('-t', String(i.threads)); + } + if (typeof i.batchSize === 'number') { + args.push('-b', String(i.batchSize)); + } + return args; +} + /** Whether a settings patch changes any LAUNCH-TIME arg (context, KV-cache type, * flash-attn, GPU layers, threads, batch) and therefore needs a server respawn. * `modeChanged` forces true (a mode switch always rewrites launch args). A field From 23c76fed825cfca91b70e362ebb8a6d8470770a9 Mon Sep 17 00:00:00 2001 From: User Date: Fri, 10 Jul 2026 08:56:54 +0530 Subject: [PATCH 003/334] fix(chat): image-gen params single-source + reasoning survives reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two data-layer/presentation drift bugs (§A). (1) Image composer kept local copies of the active model + steps: the dropdown never wrote through to setActiveModalModel (diverged from the Active-models panel) and a model-change effect stomped the user's steps ("showed 10, generated 28"). Now the dropdown binds to the owning source and steps/size/seed/negative persist per-model (override ?? default resolver). (2) Reasoning/thinking showed live but was never persisted, so it vanished on conversation remap — now carried through the message context blob and restored by mapRagMessages. Terminal-artifact tests: a real mount asserts the generateImage payload carries the user's steps + model; a real DB round-trip asserts reasoning restores. Red-proven. Co-Authored-By: Claude Opus 4.8 --- src/renderer/src/components/MemoryChat.tsx | 147 +++++++++++--- .../__tests__/MemoryChat.image.test.tsx | 185 ++++++++++++++++++ .../lib/__tests__/image-params-wiring.test.ts | 162 +++++++++++++++ .../src/lib/__tests__/image-params.test.ts | 111 +++++++++++ .../lib/__tests__/message-persistence.test.ts | 59 ++++++ src/renderer/src/lib/image-params.ts | 90 +++++++++ src/renderer/src/lib/message-persistence.ts | 48 +++++ 7 files changed, 772 insertions(+), 30 deletions(-) create mode 100644 src/renderer/src/components/__tests__/MemoryChat.image.test.tsx create mode 100644 src/renderer/src/lib/__tests__/image-params-wiring.test.ts create mode 100644 src/renderer/src/lib/__tests__/image-params.test.ts create mode 100644 src/renderer/src/lib/__tests__/message-persistence.test.ts create mode 100644 src/renderer/src/lib/image-params.ts create mode 100644 src/renderer/src/lib/message-persistence.ts diff --git a/src/renderer/src/components/MemoryChat.tsx b/src/renderer/src/components/MemoryChat.tsx index a61146ba..97044b6d 100644 --- a/src/renderer/src/components/MemoryChat.tsx +++ b/src/renderer/src/components/MemoryChat.tsx @@ -10,8 +10,9 @@ import { VoiceBubble, stopAllVoicePlayback } from './VoiceBubble'; import { SkillsPanel } from './SkillsPanel'; import { SettingsPanel } from './SettingsPanel'; import { ModelPicker } from './ModelPicker'; -import { standardModelDefaults } from '@offgrid/core/shared/image-defaults'; +import { resolveImageParams, setOverride, type ImageParamStore } from '@renderer/lib/image-params'; import { looksLikeImageRequest, cleanImagePrompt } from '@renderer/lib/image-intent'; +import { buildAssistantContext, readReasoning } from '@renderer/lib/message-persistence'; import { Button } from '@renderer/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent } from '@renderer/components/ui/dropdown-menu'; @@ -128,6 +129,8 @@ function mapRagMessages(raw: any[]): ChatMessage[] { role: m.role as 'user' | 'assistant', content: m.content, context: ctx, + // Reasoning rides in the context blob so the "Thinking" block survives reload. + reasoning: readReasoning(ctx), toolCalls: Array.isArray(ctx?.toolCalls) ? ctx.toolCalls : undefined, image: ctx?.image ? `ogcapture://${ctx.image}` : undefined, imagePath: ctx?.image, @@ -250,6 +253,12 @@ export function MemoryChat({ onNavigateToMemory, onNavigateToChat, onNavigateToE const [imgStrength, setImgStrength] = useState(0.6); const [imgModels, setImgModels] = useState([]); const [imgModel, setImgModel] = useState(''); + // Per-model steps/size overrides. This is the ONE persisted owner of those two + // params — the composer and (future) a Settings > Image section both read/write + // it. Persisted via saveSetting('imageParams', …). A value here means the user + // pinned it; absence means "track the model default". Resolved through the pure + // resolveImageParams so a model change never clobbers a user override. + const [imgParamStore, setImgParamStore] = useState({}); const [activeStyle, setActiveStyle] = useState(null); const [styleThumbs, setStyleThumbs] = useState>({}); const [genThumbsBusy, setGenThumbsBusy] = useState(false); @@ -299,6 +308,14 @@ export function MemoryChat({ onNavigateToMemory, onNavigateToChat, onNavigateToE if (typeof s.composerConnectorsOn === 'boolean') setConnectorsOn(s.composerConnectorsOn); if (typeof s.composerThinking === 'boolean') setThinkingEnabled(s.composerThinking); if (typeof s.composerVoiceMode === 'boolean') setVoiceMode(s.composerVoiceMode); + // Image-composer params: per-model steps/size overrides + the global + // seed/negative/strength/style. These are persisted so they survive a + // remount (they used to reset every mount). + if (s.imageParams && typeof s.imageParams === 'object') setImgParamStore(s.imageParams as ImageParamStore); + if (typeof s.imgSeed === 'string') setImgSeed(s.imgSeed); + if (typeof s.imgNegative === 'string') setImgNegative(s.imgNegative); + if (typeof s.imgStrength === 'number') setImgStrength(s.imgStrength); + if (typeof s.imgStyle === 'string' || s.imgStyle === null) setActiveStyle((s.imgStyle as string | null) ?? null); } } catch (e) { console.error('Failed to load composer prefs', e); } finally { prefsLoaded.current = true; } @@ -309,6 +326,13 @@ export function MemoryChat({ onNavigateToMemory, onNavigateToChat, onNavigateToE useEffect(() => { if (prefsLoaded.current) void window.api.saveSetting('composerConnectorsOn', connectorsOn); }, [connectorsOn]); useEffect(() => { if (prefsLoaded.current) void window.api.saveSetting('composerThinking', thinkingEnabled); }, [thinkingEnabled]); useEffect(() => { if (prefsLoaded.current) void window.api.saveSetting('composerVoiceMode', voiceMode); }, [voiceMode]); + // Persist the global image-composer params (per-model steps/size live in the + // store, saved on change). Guarded by prefsLoaded so the initial load doesn't + // echo back an empty default. + useEffect(() => { if (prefsLoaded.current) void window.api.saveSetting('imgSeed', imgSeed); }, [imgSeed]); + useEffect(() => { if (prefsLoaded.current) void window.api.saveSetting('imgNegative', imgNegative); }, [imgNegative]); + useEffect(() => { if (prefsLoaded.current) void window.api.saveSetting('imgStrength', imgStrength); }, [imgStrength]); + useEffect(() => { if (prefsLoaded.current) void window.api.saveSetting('imgStyle', activeStyle); }, [activeStyle]); const [autoPlayId, setAutoPlayId] = useState(null); // assistant reply to auto-speak once const [speakingId, setSpeakingId] = useState(null); const [speakLoadingId, setSpeakLoadingId] = useState(null); @@ -436,6 +460,35 @@ export function MemoryChat({ onNavigateToMemory, onNavigateToChat, onNavigateToE }, } as Components); + // Bind the composer's image model to the ONE owner of that state: the active + // modal model (what the Active-models panel / ModelPicker writes via + // setActiveModalModel). We READ it from imageGenStatus().active and mirror it + // locally for the dropdown; we never hold a divergent latched copy. Called on + // mount and whenever the model picker closes, so a change made there flows back + // into the composer. Falls back to a sensible default only when nothing is active. + const refreshImageModel = useCallback(async () => { + try { + const s = await window.api.imageGenStatus?.(); + if (!s) return; + setImageAvailable(!!s.available); + const models = s.models || []; + setImgModels(models); + // Skip the parked/slow Core ML dir (it would otherwise win on an "sdxl" name + // match and default the composer to a non-distilled model). + const usable = models.filter(m => !/coreml/i.test(m)); + const preferred = usable.find(m => /dreamshaper/i.test(m)) || usable.find(m => /lightning|turbo/i.test(m)) || usable.find(m => /z[-_]?image/i.test(m)) || usable.find(m => /sdxl|xl/i.test(m)) || usable[0] || models[0] || ''; + setImgModel(s.active || preferred); + } catch { /* engine may be down; leave prior state */ } + }, []); + // When the model picker closes it may have changed the active image model + // (setActiveModalModel). Re-read so the composer reflects the single source of + // truth rather than a stale mirror. + const prevPickerOpen = useRef(modelPickerOpen); + useEffect(() => { + if (prevPickerOpen.current && !modelPickerOpen) void refreshImageModel(); + prevPickerOpen.current = modelPickerOpen; + }, [modelPickerOpen, refreshImageModel]); + // Load conversations on mount; probe image gen; load projects for scoping. useEffect(() => { void (async () => { @@ -451,19 +504,7 @@ export function MemoryChat({ onNavigateToMemory, onNavigateToChat, onNavigateToE try { setConvMessages(first.id, mapRagMessages(await window.api.getRagMessages(first.id))); } catch { setConvMessages(first.id, []); } } })(); - window.api.imageGenStatus?.().then((s: { available: boolean; models?: string[]; active?: string | null }) => { - setImageAvailable(!!s?.available); - const models = s?.models || []; - setImgModels(models); - // Default the picker to the ACTIVE image model (what the Active-models panel - // shows, e.g. DreamShaper) so the composer never mismatches it. Fall back to a - // preference that skips the parked/slow Core ML dir (it would otherwise win on - // an "sdxl" name match and default the composer to a non-distilled model). - const usable = models.filter(m => !/coreml/i.test(m)); - const preferred = usable.find(m => /dreamshaper/i.test(m)) || usable.find(m => /lightning|turbo/i.test(m)) || usable.find(m => /z[-_]?image/i.test(m)) || usable.find(m => /sdxl|xl/i.test(m)) || usable[0] || models[0] || ''; - const active = s?.active || preferred; - setImgModel(prev => prev || active); - }).catch(() => {}); + void refreshImageModel(); window.api.listProjects?.().then((p: ProjectLite[]) => setProjects(p || [])).catch(() => {}); window.api.styleThumbs?.().then((t: Record) => setStyleThumbs(t || {})).catch(() => {}); }, []); @@ -489,15 +530,47 @@ export function MemoryChat({ onNavigateToMemory, onNavigateToChat, onNavigateToE } }, [genThumbsBusy, styleThumbs]); - // Seed the size + steps controls with the model's sensible defaults (from the - // SINGLE shared source of truth the main process also uses — so the two layers - // can never drift; a stale copy of this logic once defaulted turbo models to 4 - // steps -> rainbow artifacts). The user can then adjust Size/Steps directly. + // Resolve the size + steps controls for the current model: a per-model user + // OVERRIDE (persisted in imgParamStore) wins; otherwise fall back to the model's + // default from the SINGLE shared source of truth the main process also uses (so + // the two layers can't drift — a stale copy once defaulted turbo models to 4 + // steps -> rainbow artifacts). This never clobbers a value the user typed: the + // resolver reads the override for whichever model is now selected. Depends on the + // store too, so persisted overrides apply once they load. useEffect(() => { if (!imgModel) return; - const d = standardModelDefaults(imgModel); - setImgSize(d.defaultSize); - setImgSteps(d.defaultSteps); + const { steps, size } = resolveImageParams(imgModel, imgParamStore); + setImgSize(size); + setImgSteps(steps); + }, [imgModel, imgParamStore]); + + // Composer image-model dropdown: write through to the SAME owner ModelPicker + // uses (setActiveModalModel), then mirror locally for immediate UI. This is what + // keeps the composer and the Active-models panel from silently disagreeing about + // which model runs — one source of truth. + const chooseImageModel = useCallback((value: string) => { + setImgModel(value); + void window.api.setActiveModalModel?.('image', value); + }, []); + // Steps/size edits persist as a per-model override so they survive a remount and + // a model switch (setOverride is pure; a value == the model default clears it). + const setStepsOverride = useCallback((value: number) => { + setImgSteps(value); + if (!imgModel) return; + setImgParamStore(prev => { + const next = setOverride(prev, imgModel, 'steps', value); + void window.api.saveSetting('imageParams', next); + return next; + }); + }, [imgModel]); + const setSizeOverride = useCallback((value: number) => { + setImgSize(value); + if (!imgModel) return; + setImgParamStore(prev => { + const next = setOverride(prev, imgModel, 'size', value); + void window.api.saveSetting('imageParams', next); + return next; + }); }, [imgModel]); const activeProjectName = projects.find(p => p.id === activeProjectId)?.name ?? null; @@ -832,8 +905,16 @@ export function MemoryChat({ onNavigateToMemory, onNavigateToChat, onNavigateToE return; } const answer = tr?.answer || 'No response returned.'; + // Capture the reasoning that streamed onto this turn so it can ride the + // persisted context blob (React-only state would vanish on reload). + let toolReasoning: string | undefined; // Finalize the streamed placeholder in place (never append a second bubble). - setConvMessages(convId, prev => prev.map(m => (m.id === toolStreamId ? { ...m, content: answer, context, toolCalls, activity: undefined, streaming: false } : m))); + setConvMessages(convId, prev => prev.map(m => { + if (m.id !== toolStreamId) return m; + toolReasoning = m.reasoning; + return { ...m, content: answer, context, toolCalls, activity: undefined, streaming: false }; + })); + const toolCtxWithReasoning = buildAssistantContext(toolCtx, { reasoning: toolReasoning }); if (voiceMode) setAutoPlayId(toolStreamId); // Deferred image generation: the tool loop only RECORDS the prompt (it never // generates inline, which would evict the LLM). Generate + attach here AFTER @@ -842,14 +923,14 @@ export function MemoryChat({ onNavigateToMemory, onNavigateToChat, onNavigateToE try { const img = await window.api.generateImage({ prompt: tr.imageRequest.prompt, conversationId: convId, projectId: activeProjectId }); setConvMessages(convId, prev => prev.map(m => (m.id === toolStreamId ? { ...m, image: img.dataUrl, imagePath: img.path } : m))); - try { await window.api.addRagMessage(convId, 'assistant', answer, { ...(toolCtx ?? {}), image: img.path }); } catch (_) { /* ignore */ } + try { await window.api.addRagMessage(convId, 'assistant', answer, { ...(toolCtxWithReasoning ?? {}), image: img.path }); } catch (_) { /* ignore */ } } catch (e) { const msg = e instanceof Error ? e.message : String(e); - if (!/cancel/i.test(msg)) { try { await window.api.addRagMessage(convId, 'assistant', answer, toolCtx); } catch (_) { /* ignore */ } } + if (!/cancel/i.test(msg)) { try { await window.api.addRagMessage(convId, 'assistant', answer, toolCtxWithReasoning); } catch (_) { /* ignore */ } } } return; } - try { await window.api.addRagMessage(convId, 'assistant', answer, toolCtx); } catch (_) { /* ignore */ } + try { await window.api.addRagMessage(convId, 'assistant', answer, toolCtxWithReasoning); } catch (_) { /* ignore */ } return; } @@ -900,7 +981,13 @@ export function MemoryChat({ onNavigateToMemory, onNavigateToChat, onNavigateToE const priorVariants = pendingVariantsRef.current; pendingVariantsRef.current = null; const allVariants = priorVariants ? [...priorVariants, assistantContent] : undefined; - setConvMessages(convId, prev => prev.map(m => (m.id === streamId ? { ...m, content: assistantContent, context: result.context, streaming: false, variants: allVariants, variantIndex: allVariants ? allVariants.length - 1 : undefined } : m))); + // Capture the streamed reasoning so it rides the persisted context blob. + let ragReasoning: string | undefined; + setConvMessages(convId, prev => prev.map(m => { + if (m.id !== streamId) return m; + ragReasoning = m.reasoning; + return { ...m, content: assistantContent, context: result.context, streaming: false, variants: allVariants, variantIndex: allVariants ? allVariants.length - 1 : undefined }; + })); const art = parseArtifact(assistantContent); if (art) { // Inline-first: don't force the canvas open — the user opens the live @@ -910,7 +997,7 @@ export function MemoryChat({ onNavigateToMemory, onNavigateToChat, onNavigateToE } if (voiceMode) setAutoPlayId(streamId); try { - await window.api.addRagMessage(convId, 'assistant', assistantContent, result.context); + await window.api.addRagMessage(convId, 'assistant', assistantContent, buildAssistantContext(result.context, { reasoning: ragReasoning })); } catch (e) { console.error('Failed to persist assistant message:', e); } @@ -2043,14 +2130,14 @@ export function MemoryChat({ onNavigateToMemory, onNavigateToChat, onNavigateToE {imgModels.length > 1 && ( )}