diff --git a/package-lock.json b/package-lock.json index 023929b..86882f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,9 @@ "fabric": "dist/cli.js" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/express": "^5.0.6", "@types/node": "^20.11.0", "@types/react": "^19.2.14", @@ -27,6 +30,7 @@ "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^5.1.4", "@vitest/coverage-v8": "^4.0.18", + "jsdom": "^28.1.0", "react": "^19.2.4", "react-dom": "^19.2.4", "typescript": "^5.3.0", @@ -37,6 +41,78 @@ "node": ">=18.0.0" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "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.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -298,6 +374,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -356,6 +442,151 @@ "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/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "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.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "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.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "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.0.2", + "@csstools/css-calc": "^3.1.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.0.29", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.29.tgz", + "integrity": "sha512-jx9GjkkP5YHuTmko2eWAvpPnb0mB4mGRr2U7XwVNwevm8nlpobZEVk+GNmiYMk2VuA75v+plfXWyroWKmICZXg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0" + }, + "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/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -798,6 +1029,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.14.1.tgz", + "integrity": "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==", + "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/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1212,6 +1461,104 @@ "dev": true, "license": "MIT" }, + "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/@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/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1594,6 +1941,27 @@ "node": ">= 0.6" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -1609,6 +1977,16 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "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/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1644,6 +2022,16 @@ "node": ">=6.0.0" } }, + "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/blessed": { "version": "0.1.81", "resolved": "https://registry.npmjs.org/blessed/-/blessed-0.1.81.tgz", @@ -1873,6 +2261,53 @@ "node": ">=6.6.0" } }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.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/cssstyle": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz", + "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1880,6 +2315,20 @@ "dev": true, "license": "MIT" }, + "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/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1897,6 +2346,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/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1906,6 +2362,24 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1942,6 +2416,19 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2282,6 +2769,19 @@ "node": ">= 0.4" } }, + "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", @@ -2309,6 +2809,34 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -2325,6 +2853,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -2340,6 +2878,13 @@ "node": ">= 0.10" } }, + "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", @@ -2392,6 +2937,47 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.21.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2428,6 +3014,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", @@ -2475,6 +3072,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "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", @@ -2521,6 +3125,16 @@ "url": "https://opencollective.com/express" } }, + "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/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2606,6 +3220,19 @@ "wrappy": "1" } }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.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", @@ -2681,6 +3308,36 @@ "node": "^10 || ^12 || >=14" } }, + "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/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -2694,6 +3351,16 @@ "node": ">= 0.10" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", @@ -2756,6 +3423,14 @@ "react": "^19.2.4" } }, + "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/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -2766,6 +3441,30 @@ "node": ">=0.10.0" } }, + "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/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -2833,6 +3532,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "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", @@ -3016,6 +3728,19 @@ "dev": true, "license": "MIT" }, + "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/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -3028,6 +3753,13 @@ "node": ">=8" } }, + "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/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3072,6 +3804,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.0.24", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.24.tgz", + "integrity": "sha512-1r6vQTTt1rUiJkI5vX7KG8PR342Ru/5Oh13kEQP2SMbRSZpOey9SrBe27IDxkoWulx8ShWu4K6C0BkctP8Z1bQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.24" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.24", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.24.tgz", + "integrity": "sha512-pj7yygNMoMRqG7ML2SDQ0xNIOfN3IBDUcPVM2Sg6hP96oFNN2nqnzHreT3z9xLq85IWJyNTvD38O002DdOrPMw==", + "dev": true, + "license": "MIT" + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -3081,6 +3833,32 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "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/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", @@ -3109,6 +3887,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "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", @@ -3317,6 +4105,54 @@ } } }, + "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/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/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": "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/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -3361,6 +4197,23 @@ } } }, + "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/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/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 28ec2a9..db0042d 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,9 @@ "node": ">=18.0.0" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/express": "^5.0.6", "@types/node": "^20.11.0", "@types/react": "^19.2.14", @@ -42,6 +45,7 @@ "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^5.1.4", "@vitest/coverage-v8": "^4.0.18", + "jsdom": "^28.1.0", "react": "^19.2.4", "react-dom": "^19.2.4", "typescript": "^5.3.0", diff --git a/src/web/frontend/components/ActivityStream.test.tsx b/src/web/frontend/components/ActivityStream.test.tsx new file mode 100644 index 0000000..2252f6b --- /dev/null +++ b/src/web/frontend/components/ActivityStream.test.tsx @@ -0,0 +1,286 @@ +/** + * Tests for ActivityStream component + * @vitest-environment jsdom + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import ActivityStream from '../src/components/ActivityStream'; +import { LogEvent } from '../src/types'; + +// Mock scroll behavior +const mockScrollTo = vi.fn(); +Object.defineProperty(HTMLElement.prototype, 'scrollTop', { + set: mockScrollTo, + get: () => 0, +}); +Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + get: () => 1000, +}); + +describe('ActivityStream', () => { + const createMockEvent = (overrides: Partial = {}): LogEvent => ({ + timestamp: '2026-03-03T12:00:00.000Z', + level: 'info', + worker: 'claude-code-glm-5-alpha', + message: 'Test message', + raw: '{"ts":123,"worker":"test","level":"info","msg":"Test message"}', + ...overrides, + }); + + beforeEach(() => { + mockScrollTo.mockClear(); + }); + + describe('rendering', () => { + it('should render with empty events', () => { + render(); + + expect(screen.getByText('All Events')).toBeInTheDocument(); + expect(screen.getByText('(0)')).toBeInTheDocument(); + expect(screen.getByText('No events to display')).toBeInTheDocument(); + }); + + it('should render event count correctly', () => { + const events = [ + createMockEvent({ message: 'Event 1' }), + createMockEvent({ message: 'Event 2' }), + createMockEvent({ message: 'Event 3' }), + ]; + + render(); + + expect(screen.getByText('(3)')).toBeInTheDocument(); + }); + + it('should render selected worker in title when provided', () => { + render(); + + expect(screen.getByText('Events for alpha')).toBeInTheDocument(); + }); + + it('should display event messages', () => { + const { container } = render( + + ); + + // Use container to find event items and check for message content + const eventItems = container.querySelectorAll('.event-item'); + expect(eventItems[0].textContent).toContain('First event'); + expect(eventItems[1].textContent).toContain('Second event'); + }); + + it('should display tool name when present', () => { + const events = [ + createMockEvent({ message: 'Tool executed', tool: 'Read' }), + ]; + + render(); + + expect(screen.getByText(/\[Read\] Tool executed/)).toBeInTheDocument(); + }); + + it('should not display tool prefix when tool is undefined', () => { + const events = [ + createMockEvent({ message: 'No tool event' }), + ]; + + render(); + + const eventElement = screen.getByText(/No tool event/); + expect(eventElement.textContent).toBe('No tool event'); + }); + }); + + describe('event levels', () => { + it('should display info level events', () => { + const events = [createMockEvent({ level: 'info' })]; + + render(); + + const levelElement = screen.getByText('info'); + expect(levelElement).toHaveClass('info'); + }); + + it('should display warn level events', () => { + const events = [createMockEvent({ level: 'warn' })]; + + render(); + + const levelElement = screen.getByText('warn'); + expect(levelElement).toHaveClass('warn'); + }); + + it('should display error level events', () => { + const events = [createMockEvent({ level: 'error' })]; + + render(); + + const levelElement = screen.getByText('error'); + expect(levelElement).toHaveClass('error'); + }); + + it('should display debug level events', () => { + const events = [createMockEvent({ level: 'debug' })]; + + render(); + + const levelElement = screen.getByText('debug'); + expect(levelElement).toHaveClass('debug'); + }); + }); + + describe('worker display', () => { + it('should display truncated worker name when no worker selected', () => { + const events = [ + createMockEvent({ worker: 'claude-code-glm-5-alpha' }), + ]; + + render(); + + expect(screen.getByText('[alpha]')).toBeInTheDocument(); + }); + + it('should extract last part of hyphenated worker names', () => { + const events = [ + createMockEvent({ worker: 'worker-with-multiple-parts' }), + ]; + + render(); + + expect(screen.getByText('[parts]')).toBeInTheDocument(); + }); + + it('should hide worker name when a worker is selected', () => { + const events = [ + createMockEvent({ worker: 'claude-code-glm-5-alpha' }), + ]; + + render(); + + expect(screen.queryByText('[alpha]')).not.toBeInTheDocument(); + }); + }); + + describe('time formatting', () => { + it('should format timestamp to HH:MM:SS', () => { + // 2026-03-03T12:34:56.000Z + const events = [ + createMockEvent({ timestamp: '2026-03-03T12:34:56.000Z' }), + ]; + + render(); + + // Time is formatted in local timezone, so just check the pattern + const timeElements = screen.getAllByText(/\d{2}:\d{2}:\d{2}/); + expect(timeElements.length).toBeGreaterThan(0); + }); + }); + + describe('event ordering', () => { + it('should render events in order', () => { + const { container } = render( + + ); + + const eventItems = container.querySelectorAll('.event-item'); + expect(eventItems[0].textContent).toContain('First'); + expect(eventItems[1].textContent).toContain('Second'); + expect(eventItems[2].textContent).toContain('Third'); + }); + }); + + describe('CSS classes', () => { + it('should apply activity-stream class to container', () => { + const { container } = render( + + ); + + expect(container.querySelector('.activity-stream')).toBeInTheDocument(); + }); + + it('should apply event-list class to list container', () => { + const { container } = render( + + ); + + expect(container.querySelector('.event-list')).toBeInTheDocument(); + }); + + it('should apply event-item class to each event', () => { + const events = [ + createMockEvent({ message: 'Event 1' }), + createMockEvent({ message: 'Event 2' }), + ]; + + const { container } = render( + + ); + + const eventItems = container.querySelectorAll('.event-item'); + expect(eventItems).toHaveLength(2); + }); + + it('should apply no-events class to empty message', () => { + const { container } = render( + + ); + + expect(container.querySelector('.no-events')).toBeInTheDocument(); + }); + }); + + describe('edge cases', () => { + it('should handle single event', () => { + const events = [createMockEvent({ message: 'Only event' })]; + + render(); + + expect(screen.getByText('(1)')).toBeInTheDocument(); + expect(screen.getByText(/Only event/)).toBeInTheDocument(); + }); + + it('should handle many events', () => { + const events = Array.from({ length: 100 }, (_, i) => + createMockEvent({ message: `Event ${i}` }) + ); + + render(); + + expect(screen.getByText('(100)')).toBeInTheDocument(); + }); + + it('should handle long messages', () => { + const longMessage = 'A'.repeat(500); + const events = [createMockEvent({ message: longMessage })]; + + render(); + + expect(screen.getByText(new RegExp(longMessage))).toBeInTheDocument(); + }); + + it('should handle special characters in messages', () => { + const events = [ + createMockEvent({ message: 'Message with & "chars"' }), + ]; + + render(); + + expect(screen.getByText(/Message with & "chars"/)).toBeInTheDocument(); + }); + + }); +}); diff --git a/src/web/frontend/src/App.tsx b/src/web/frontend/src/App.tsx index bff59cd..4ad340c 100644 --- a/src/web/frontend/src/App.tsx +++ b/src/web/frontend/src/App.tsx @@ -1,20 +1,24 @@ import React, { useState, useEffect, useCallback } from 'react'; -import { LogEvent, WorkerInfo, WebSocketMessage } from './types'; +import { LogEvent, WorkerInfo, WebSocketMessage, CollisionAlert as CollisionAlertData } from './types'; import WorkerGrid from './components/WorkerGrid'; import ActivityStream from './components/ActivityStream'; import WorkerDetail from './components/WorkerDetail'; +import CollisionAlert from './components/CollisionAlert'; const App: React.FC = () => { const [workers, setWorkers] = useState([]); const [events, setEvents] = useState([]); const [selectedWorker, setSelectedWorker] = useState(null); const [connected, setConnected] = useState(false); + const [collisionAlerts, setCollisionAlerts] = useState([]); + const [showCollisionPanel, setShowCollisionPanel] = useState(false); const handleWebSocketMessage = useCallback((message: WebSocketMessage) => { if (message.type === 'init') { - const data = message.data as { workers?: WorkerInfo[]; recentEvents?: LogEvent[] }; + const data = message.data as { workers?: WorkerInfo[]; recentEvents?: LogEvent[]; alerts?: CollisionAlertData[] }; if (data.workers) setWorkers(data.workers); if (data.recentEvents) setEvents(data.recentEvents); + if (data.alerts) setCollisionAlerts(data.alerts); } else if (message.type === 'event') { const event = message.data as LogEvent; setEvents(prev => [...prev.slice(-199), event]); @@ -42,6 +46,15 @@ const App: React.FC = () => { }]; } }); + } else if (message.type === 'collision-alert') { + const alert = message.data as CollisionAlertData; + setCollisionAlerts(prev => { + // Avoid duplicates + if (prev.some(a => a.id === alert.id)) { + return prev.map(a => a.id === alert.id ? alert : a); + } + return [...prev, alert]; + }); } }, []); @@ -84,13 +97,39 @@ const App: React.FC = () => { ? workers.find(w => w.id === selectedWorker) : null; + const handleAcknowledgeAlert = useCallback((alertId: string) => { + setCollisionAlerts(prev => + prev.map(a => a.id === alertId ? { ...a, acknowledged: true } : a) + ); + }, []); + + const handleAcknowledgeAllAlerts = useCallback(() => { + setCollisionAlerts(prev => + prev.map(a => ({ ...a, acknowledged: true })) + ); + }, []); + + const unacknowledgedAlertCount = collisionAlerts.filter(a => !a.acknowledged).length; + return (

FABRIC

-
- - {connected ? 'Connected' : 'Disconnected'} +
+ {unacknowledgedAlertCount > 0 && ( + + )} +
+ + {connected ? 'Connected' : 'Disconnected'} +
@@ -113,6 +152,16 @@ const App: React.FC = () => { allWorkerEvents={selectedWorker ? filteredEvents : undefined} /> )} + + {showCollisionPanel && ( + setShowCollisionPanel(false)} + /> + )}
); diff --git a/src/web/frontend/src/components/CollisionAlert.tsx b/src/web/frontend/src/components/CollisionAlert.tsx new file mode 100644 index 0000000..8180532 --- /dev/null +++ b/src/web/frontend/src/components/CollisionAlert.tsx @@ -0,0 +1,313 @@ +import React, { useState, useMemo } from 'react'; +import { CollisionAlert as CollisionAlertData } from '../types'; + +interface CollisionAlertProps { + /** Array of collision alerts to display */ + alerts: CollisionAlertData[]; + + /** Callback when an alert is acknowledged */ + onAcknowledge?: (alertId: string) => void; + + /** Callback when all alerts are acknowledged */ + onAcknowledgeAll?: () => void; + + /** Whether the panel is visible */ + visible?: boolean; + + /** Callback to close the panel */ + onClose?: () => void; +} + +/** + * CollisionAlert Component + * + * Displays collision alerts to users, warning about potential duplicate work + * or conflicting operations between workers. Ported from TUI CollisionAlert.ts + */ +const CollisionAlert: React.FC = ({ + alerts, + onAcknowledge, + onAcknowledgeAll, + visible = true, + onClose, +}) => { + const [selectedIndex, setSelectedIndex] = useState(0); + + // Group alerts by severity + const groupedAlerts = useMemo(() => { + const critical = alerts.filter(a => a.severity === 'critical' || a.severity === 'error'); + const warnings = alerts.filter(a => a.severity === 'warning'); + const info = alerts.filter(a => a.severity === 'info'); + return { critical, warnings, info }; + }, [alerts]); + + const unacknowledgedCount = useMemo(() => { + return alerts.filter(a => !a.acknowledged).length; + }, [alerts]); + + const getSeverityIcon = (severity: CollisionAlertData['severity']): string => { + switch (severity) { + case 'critical': + return '!!!'; + case 'error': + return '!!'; + case 'warning': + return '!'; + case 'info': + return 'i'; + } + }; + + const getSeverityClass = (severity: CollisionAlertData['severity']): string => { + return `collision-severity-${severity}`; + }; + + const getTypeIcon = (type: CollisionAlertData['type']): string => { + switch (type) { + case 'file': + return 'F'; + case 'bead': + return 'B'; + case 'task': + return 'T'; + } + }; + + const formatTime = (timestamp: number): string => { + return new Date(timestamp).toLocaleTimeString(); + }; + + const handleAcknowledge = (alertId: string) => { + onAcknowledge?.(alertId); + }; + + const handleAcknowledgeAll = () => { + onAcknowledgeAll?.(); + }; + + const handleSelectAlert = (index: number) => { + setSelectedIndex(index); + }; + + const selectedAlert = alerts[selectedIndex]; + + if (!visible) { + return null; + } + + return ( +
+ {/* Header */} +
+

+ ! + Collision Alerts + {unacknowledgedCount > 0 && ( + {unacknowledgedCount} + )} +

+ {onClose && ( + + )} +
+ + {/* Content */} +
+ {alerts.length === 0 ? ( +
+ OK + No active collisions detected +
+ ) : ( + <> + {/* Summary */} +
+ + Alerts: {alerts.length} ({unacknowledgedCount} unacknowledged) + +
+ + {/* Critical/Error Alerts */} + {groupedAlerts.critical.length > 0 && ( +
+
+ !!! + CRITICAL/ERROR ({groupedAlerts.critical.length}) +
+
+ {groupedAlerts.critical.map((alert, idx) => { + const globalIdx = alerts.indexOf(alert); + return ( +
handleSelectAlert(globalIdx)} + > + + {getSeverityIcon(alert.severity)} + + + [{getTypeIcon(alert.type)}] + + + {alert.title.length > 40 ? alert.title.slice(0, 40) + '...' : alert.title} + + + {alert.workers.length > 2 + ? `${alert.workers.length} workers` + : alert.workers.slice(0, 2).join(', ')} + + {alert.acknowledged && ( + [ACK] + )} +
+ ); + })} +
+
+ )} + + {/* Warning Alerts */} + {groupedAlerts.warnings.length > 0 && ( +
+
+ ! + WARNINGS ({groupedAlerts.warnings.length}) +
+
+ {groupedAlerts.warnings.map((alert, idx) => { + const globalIdx = alerts.indexOf(alert); + return ( +
handleSelectAlert(globalIdx)} + > + + {getSeverityIcon(alert.severity)} + + + [{getTypeIcon(alert.type)}] + + + {alert.title.length > 40 ? alert.title.slice(0, 40) + '...' : alert.title} + + + {alert.workers.length > 2 + ? `${alert.workers.length} workers` + : alert.workers.slice(0, 2).join(', ')} + + {alert.acknowledged && ( + [ACK] + )} +
+ ); + })} +
+
+ )} + + {/* Info Alerts */} + {groupedAlerts.info.length > 0 && ( +
+
+ i + INFO ({groupedAlerts.info.length}) +
+
+ {groupedAlerts.info.map((alert, idx) => { + const globalIdx = alerts.indexOf(alert); + return ( +
handleSelectAlert(globalIdx)} + > + + {getSeverityIcon(alert.severity)} + + + [{getTypeIcon(alert.type)}] + + + {alert.title.length > 40 ? alert.title.slice(0, 40) + '...' : alert.title} + + + {alert.workers.length > 2 + ? `${alert.workers.length} workers` + : alert.workers.slice(0, 2).join(', ')} + + {alert.acknowledged && ( + [ACK] + )} +
+ ); + })} +
+
+ )} + + {/* Selected Alert Details */} + {selectedAlert && ( +
+
+ ------------------------------------------ +
+
Selected Alert Details:
+
+ Title: + {selectedAlert.title} +
+
+ + {selectedAlert.description} + +
+
+ Workers: + + {selectedAlert.workers.join(', ')} + +
+ {selectedAlert.suggestion && ( +
+ Suggestion: {selectedAlert.suggestion} +
+ )} +
+ + +
+
+ )} + + )} +
+
+ ); +}; + +export default CollisionAlert; diff --git a/src/web/frontend/src/index.css b/src/web/frontend/src/index.css index ee4a28e..c9e8526 100644 --- a/src/web/frontend/src/index.css +++ b/src/web/frontend/src/index.css @@ -47,6 +47,39 @@ body { color: var(--accent); } +.header-actions { + display: flex; + align-items: center; + gap: 1rem; +} + +.collision-alert-toggle { + display: flex; + align-items: center; + gap: 0.375rem; + background: rgba(255, 152, 0, 0.2); + border: 1px solid var(--warning); + color: var(--warning); + padding: 0.375rem 0.625rem; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; +} + +.collision-alert-toggle:hover { + background: rgba(255, 152, 0, 0.3); +} + +.collision-alert-toggle .collision-alert-icon { + font-weight: bold; + font-size: 0.875rem; +} + +.collision-alert-toggle .collision-alert-count { + font-size: 0.75rem; + font-weight: 600; +} + .connection-status { display: flex; align-items: center; @@ -661,6 +694,288 @@ body { color: var(--text-secondary); } +/* ============================================ + Collision Alert Panel Styles + ============================================ */ + +.collision-alert-panel { + display: flex; + flex-direction: column; + background: var(--bg-secondary); + border: 1px solid var(--bg-tertiary); + border-radius: 6px; + overflow: hidden; + min-width: 300px; + max-width: 400px; +} + +.collision-alert-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + background: var(--bg-tertiary); + border-bottom: 1px solid var(--bg-primary); +} + +.collision-alert-header h2 { + font-size: 0.875rem; + font-weight: 600; + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0; + color: var(--warning); +} + +.collision-alert-icon { + font-size: 1rem; + color: var(--warning); +} + +.collision-badge { + background: var(--error); + color: #fff; + font-size: 0.7rem; + padding: 0.125rem 0.375rem; + border-radius: 10px; + margin-left: 0.5rem; +} + +.collision-alert-close { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.875rem; + transition: all 0.2s; +} + +.collision-alert-close:hover { + background: var(--bg-primary); + color: var(--text-primary); +} + +.collision-alert-content { + flex: 1; + overflow-y: auto; + padding: 0.5rem; +} + +.collision-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem; + color: var(--success); + gap: 0.5rem; +} + +.collision-empty-icon { + font-size: 1.5rem; + font-weight: bold; +} + +.collision-summary { + padding: 0.5rem; + font-size: 0.8125rem; + font-weight: 600; + color: var(--text-primary); + border-bottom: 1px solid var(--bg-tertiary); + margin-bottom: 0.5rem; +} + +.collision-count { + font-weight: 500; +} + +.collision-group { + margin-bottom: 0.75rem; +} + +.collision-group-header { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.75rem; + font-weight: 600; + padding: 0.375rem 0.5rem; + border-radius: 3px; + margin-bottom: 0.25rem; +} + +.collision-group-critical .collision-group-header { + color: var(--error); + background: rgba(244, 67, 54, 0.1); +} + +.collision-group-warning .collision-group-header { + color: var(--warning); + background: rgba(255, 193, 7, 0.1); +} + +.collision-group-info .collision-group-header { + color: var(--info); + background: rgba(33, 150, 243, 0.1); +} + +.collision-group-icon { + font-size: 0.875rem; +} + +.collision-group-items { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.collision-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0.5rem; + border-radius: 4px; + cursor: pointer; + font-size: 0.8125rem; + transition: all 0.2s; +} + +.collision-item:hover { + background: var(--bg-tertiary); +} + +.collision-item.selected { + background: var(--bg-tertiary); + border: 1px solid var(--accent); +} + +.collision-item.acknowledged { + opacity: 0.6; +} + +.collision-severity-critical .collision-item-icon, +.collision-severity-error .collision-item-icon { + color: var(--error); + font-weight: bold; +} + +.collision-severity-warning .collision-item-icon { + color: var(--warning); + font-weight: bold; +} + +.collision-severity-info .collision-item-icon { + color: var(--info); + font-weight: bold; +} + +.collision-item-icon { + min-width: 20px; + text-align: center; +} + +.collision-item-type { + color: var(--text-secondary); + font-size: 0.75rem; + min-width: 24px; +} + +.collision-item-title { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.collision-item-workers { + color: #00bcd4; + font-size: 0.75rem; + max-width: 80px; + overflow: hidden; + text-overflow: ellipsis; +} + +.collision-item-ack { + color: var(--text-secondary); + font-size: 0.7rem; +} + +.collision-detail { + margin-top: 0.5rem; + padding: 0.5rem; + border-top: 1px solid var(--bg-tertiary); +} + +.collision-detail-divider { + color: var(--text-secondary); + font-family: 'SF Mono', Monaco, monospace; + font-size: 0.75rem; + margin-bottom: 0.5rem; +} + +.collision-detail-header { + font-weight: 600; + font-size: 0.8125rem; + margin-bottom: 0.5rem; + color: var(--text-primary); +} + +.collision-detail-row { + font-size: 0.8125rem; + padding: 0.25rem 0; + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.collision-detail-label { + color: var(--text-secondary); + font-size: 0.75rem; +} + +.collision-detail-value { + color: var(--text-primary); +} + +.collision-detail-suggestion { + color: #00bcd4; + font-size: 0.8125rem; + padding: 0.375rem 0; + font-style: italic; +} + +.collision-detail-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.75rem; + padding-top: 0.5rem; + border-top: 1px solid var(--bg-tertiary); +} + +.collision-action-btn { + background: var(--bg-tertiary); + border: none; + color: var(--text-secondary); + padding: 0.375rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + cursor: pointer; + transition: all 0.2s; +} + +.collision-action-btn:hover:not(:disabled) { + background: var(--bg-primary); + color: var(--accent); +} + +.collision-action-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + /* Responsive adjustments */ @media (max-width: 768px) { .replay-controls { @@ -681,4 +996,12 @@ body { .replay-help { display: none; } + + .collision-alert-panel { + max-width: 100%; + } + + .collision-detail-actions { + flex-direction: column; + } } diff --git a/src/web/frontend/src/types.ts b/src/web/frontend/src/types.ts index fe2e322..2cb6a04 100644 --- a/src/web/frontend/src/types.ts +++ b/src/web/frontend/src/types.ts @@ -28,12 +28,13 @@ export interface FileCollision { } export interface WebSocketMessage { - type: 'init' | 'event' | 'collision'; + type: 'init' | 'event' | 'collision' | 'collision-alert'; data: { workers?: WorkerInfo[]; recentEvents?: LogEvent[]; collisions?: FileCollision[]; - } | LogEvent | FileCollision; + alerts?: CollisionAlert[]; + } | LogEvent | FileCollision | CollisionAlert; } // Cross-Reference Types @@ -98,3 +99,44 @@ export interface ReplayProgress { total: number; percent: number; } + +// Collision Alert Types +export interface FileCollision { + path: string; + workers: string[]; + detectedAt: number; + isActive: boolean; + events?: LogEvent[]; +} + +export interface BeadCollision { + beadId: string; + workers: string[]; + detectedAt: number; + isActive: boolean; + severity: 'warning' | 'critical'; + events?: LogEvent[]; +} + +export interface TaskCollision { + type: 'directory' | 'related_files' | 'dependency'; + description: string; + workers: string[]; + affectedResources: string[]; + detectedAt: number; + isActive: boolean; + riskLevel: 'low' | 'medium' | 'high'; +} + +export interface CollisionAlert { + id: string; + type: 'file' | 'bead' | 'task'; + severity: 'info' | 'warning' | 'error' | 'critical'; + title: string; + description: string; + workers: string[]; + timestamp: number; + acknowledged: boolean; + collision: FileCollision | BeadCollision | TaskCollision; + suggestion?: string; +} diff --git a/src/web/frontend/test/setup.ts b/src/web/frontend/test/setup.ts new file mode 100644 index 0000000..f82034b --- /dev/null +++ b/src/web/frontend/test/setup.ts @@ -0,0 +1,4 @@ +/** + * Test setup for React Testing Library + */ +import '@testing-library/jest-dom/vitest'; diff --git a/vitest.config.ts b/vitest.config.ts index 9cc6401..27ed984 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,11 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - exclude: ['node_modules', 'dist', 'src/web/frontend/**'], + exclude: ['node_modules', 'dist'], + include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], + environmentMatchGlobs: [ + ['src/web/frontend/**/*.test.tsx', 'jsdom'], + ], + setupFiles: ['./src/web/frontend/test/setup.ts'], }, });