diff --git a/CLAUDE.md b/CLAUDE.md index de25961..22d7dbf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,7 +53,7 @@ This is a GitHub Actions monitoring dashboard with a React frontend and Express - Live configuration reloading via file watching - Environment variable fallbacks for deployment - Validation for required GitHub tokens and repository configs -- Optional cache timeout configuration (defaults to 5 minutes) +- Optional cache timeout configuration (defaults to 300 seconds) ### API Endpoints - `GET /api/health` - Health check @@ -72,7 +72,7 @@ This is a GitHub Actions monitoring dashboard with a React frontend and Express ### Rate Limiting and Caching - **Parallel Processing**: API requests are made in parallel with intelligent rate limiting -- **Smart Caching**: Responses are cached with configurable TTL (defaults to 5 minutes) +- **Smart Caching**: Responses are cached with configurable TTL (defaults to 300 seconds) - **Rate Limit Monitoring**: Tracks and respects GitHub's rate limits with automatic waiting - **Controlled Concurrency**: Maximum 10 concurrent requests with 10 requests/second limit - **Cache Management**: Provides API endpoints to view cache statistics and clear cache when needed diff --git a/config.example.json b/config.example.json index 8cda8ee..b4023a8 100644 --- a/config.example.json +++ b/config.example.json @@ -21,6 +21,6 @@ "host": "0.0.0.0" }, "cache": { - "timeoutMinutes": 5 + "timeoutSeconds": 300 } } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 1a11ced..6fe074e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "autoprefixer": "^10.4.16", "axios": "^1.6.0", + "chokidar": "^3.5.3", "cors": "^2.8.5", "date-fns": "^2.30.0", "dotenv": "^16.3.1", @@ -21,6 +22,9 @@ "tailwindcss": "^3.3.6" }, "devDependencies": { + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/react": "^18.2.43", @@ -32,12 +36,20 @@ "eslint": "^8.55.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.5", + "jsdom": "^26.1.0", "tsx": "^4.6.2", "typescript": "^5.2.2", "vite": "^5.0.8", "vitest": "^1.0.4" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.3.tgz", + "integrity": "sha512-VQKMkwriZbaOgVCby1UDY/LDk5fIjhQicCvVPFqfe+69fWaPWydbWJ3wRt59/YzIwda1I81loas3oCoHxnqvdA==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -64,6 +76,27 @@ "node": ">=6.0.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -375,6 +408,121 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", + "integrity": "sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.10.tgz", + "integrity": "sha512-TiJ5Ajr6WRd1r8HSiwJvZBiJOqtH86aHpUjq5aEKWHiII2Qfjqd/HCWKPOW8EP4vcspXbHnXrwIDlu5savQipg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.0.2", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -1397,6 +1545,157 @@ "dev": true, "license": "MIT" }, + "node_modules/@testing-library/dom": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", + "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", + "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", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/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/@testing-library/dom/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/@testing-library/dom/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/@testing-library/jest-dom": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz", + "integrity": "sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "lodash": "^4.17.21", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "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.0", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz", + "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", + "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", @@ -1987,6 +2286,16 @@ "node": ">=0.4.0" } }, + "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/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2060,6 +2369,16 @@ "dev": true, "license": "Python-2.0" }, + "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-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -2644,6 +2963,13 @@ "node": ">= 8" } }, + "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/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -2656,6 +2982,20 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -2663,6 +3003,20 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/date-fns": { "version": "2.30.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", @@ -2697,6 +3051,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/deep-eql": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", @@ -2735,6 +3096,16 @@ "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/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -2793,6 +3164,14 @@ "node": ">=6.0.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/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -2852,6 +3231,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", @@ -3790,6 +4182,19 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -3806,6 +4211,34 @@ "node": ">= 0.8" } }, + "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/human-signals": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", @@ -3865,6 +4298,16 @@ "node": ">=0.8.19" } }, + "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/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -3968,6 +4411,13 @@ "node": ">=8" } }, + "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-stream": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", @@ -4030,6 +4480,46 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "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", @@ -4207,6 +4697,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } }, + "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.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", @@ -4328,6 +4829,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/minimatch": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", @@ -4477,6 +4988,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/nwsapi": { + "version": "2.2.20", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz", + "integrity": "sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -4614,6 +5132,19 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "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", @@ -5100,6 +5631,20 @@ "node": ">=8.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-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -5217,6 +5762,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -5276,6 +5828,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.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -5643,6 +6208,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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", @@ -5758,6 +6336,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/tailwindcss": { "version": "3.4.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", @@ -5850,6 +6435,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -5871,6 +6476,32 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -6659,6 +7290,79 @@ } } }, + "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": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6802,6 +7506,45 @@ "dev": true, "license": "ISC" }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "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/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 c68ca94..88156ab 100644 --- a/package.json +++ b/package.json @@ -11,34 +11,38 @@ "test": "vitest" }, "dependencies": { + "autoprefixer": "^10.4.16", + "axios": "^1.6.0", + "chokidar": "^3.5.3", + "cors": "^2.8.5", + "date-fns": "^2.30.0", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "lucide-react": "^0.294.0", + "postcss": "^8.4.32", "react": "^18.2.0", "react-dom": "^18.2.0", - "axios": "^1.6.0", - "date-fns": "^2.30.0", - "lucide-react": "^0.294.0", - "tailwindcss": "^3.3.6", - "autoprefixer": "^10.4.16", - "postcss": "^8.4.32", - "express": "^4.18.2", - "cors": "^2.8.5", - "dotenv": "^16.3.1", - "chokidar": "^3.5.3" + "tailwindcss": "^3.3.6" }, "devDependencies": { + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", "@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/parser": "^6.14.0", "@vitejs/plugin-react": "^4.2.1", + "concurrently": "^8.2.2", "eslint": "^8.55.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.5", + "jsdom": "^26.1.0", + "tsx": "^4.6.2", "typescript": "^5.2.2", "vite": "^5.0.8", - "vitest": "^1.0.4", - "@types/express": "^4.17.21", - "@types/cors": "^2.8.17", - "tsx": "^4.6.2", - "concurrently": "^8.2.2" + "vitest": "^1.0.4" } -} \ No newline at end of file +} diff --git a/src/components/CompactWorkflowCard.test.tsx b/src/components/CompactWorkflowCard.test.tsx new file mode 100644 index 0000000..adc07ea --- /dev/null +++ b/src/components/CompactWorkflowCard.test.tsx @@ -0,0 +1,267 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { CompactWorkflowCard } from './CompactWorkflowCard'; +import { WorkflowRun } from '../types/github'; +import { SettingsProvider } from '../contexts/SettingsContext'; + +// Mock the settings context +const mockSettings = { + githubUsername: 'test-user', + autoRefreshInterval: 30, + showLastUpdateTime: true, + notifications: { + enabled: true, + showFailures: true, + showRecoveries: true, + showWaiting: true + } +}; + +const MockSettingsProvider = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + +const createMockWorkflowRun = (overrides: Partial = {}): WorkflowRun => ({ + id: 1, + name: 'Test Workflow', + display_title: 'Test Build', + status: 'completed', + conclusion: 'success', + workflow_id: 123, + head_branch: 'main', + head_sha: 'abc123def456', + run_number: 42, + event: 'push', + created_at: '2024-01-01T10:00:00Z', + updated_at: '2024-01-01T10:05:00Z', + html_url: 'https://github.com/test/repo/actions/runs/1', + repository: { + id: 1, + name: 'test-repo', + full_name: 'test/test-repo', + owner: { + login: 'test-owner', + avatar_url: 'https://github.com/test-owner.png' + } + }, + head_commit: { + id: 'abc123def456', + message: 'Add new feature', + author: { + name: 'Test Author', + email: 'test@example.com' + } + }, + actor: { + login: 'test-actor', + avatar_url: 'https://github.com/test-actor.png' + }, + ...overrides +}); + +describe('CompactWorkflowCard', () => { + it('renders workflow run information correctly', () => { + const run = createMockWorkflowRun(); + + render( + + + + ); + + expect(screen.getByText('test-repo')).toBeInTheDocument(); + expect(screen.getByText('#42')).toBeInTheDocument(); + expect(screen.getByText('test-actor')).toBeInTheDocument(); + expect(screen.getByText('abc123d')).toBeInTheDocument(); // Shortened commit hash + expect(screen.getByText('Add new feature')).toBeInTheDocument(); + }); + + it('displays success status correctly', () => { + const run = createMockWorkflowRun({ + status: 'completed', + conclusion: 'success' + }); + + render( + + + + ); + + // Check if success icon is rendered (CheckCircle) + const successIcon = screen.getByRole('img', { hidden: true }); + expect(successIcon).toBeInTheDocument(); + }); + + it('displays failure status correctly', () => { + const run = createMockWorkflowRun({ + status: 'completed', + conclusion: 'failure' + }); + + render( + + + + ); + + // The card should have failure styling + const card = screen.getByRole('generic'); + expect(card).toHaveClass('border-ctp-red/30'); + }); + + it('displays in-progress status correctly', () => { + const run = createMockWorkflowRun({ + status: 'in_progress', + conclusion: null + }); + + render( + + + + ); + + // Check if in-progress icon is rendered + const progressIcon = screen.getByRole('img', { hidden: true }); + expect(progressIcon).toBeInTheDocument(); + }); + + it('displays queued status correctly', () => { + const run = createMockWorkflowRun({ + status: 'queued', + conclusion: null + }); + + render( + + + + ); + + // Check if queued icon is rendered + const queuedIcon = screen.getByRole('img', { hidden: true }); + expect(queuedIcon).toBeInTheDocument(); + }); + + it('shows actor avatar instead of repository owner avatar', () => { + const run = createMockWorkflowRun(); + + render( + + + + ); + + const avatar = screen.getByAltText('test-actor'); + expect(avatar).toHaveAttribute('src', 'https://github.com/test-actor.png'); + }); + + it('handles click events', () => { + const mockOnClick = vi.fn(); + const run = createMockWorkflowRun(); + + render( + + + + ); + + const card = screen.getByRole('generic'); + fireEvent.click(card); + + expect(mockOnClick).toHaveBeenCalledTimes(1); + }); + + it('does not trigger onClick when clicking external link', () => { + const mockOnClick = vi.fn(); + const run = createMockWorkflowRun(); + + render( + + + + ); + + const externalLink = screen.getByRole('link'); + fireEvent.click(externalLink); + + expect(mockOnClick).not.toHaveBeenCalled(); + }); + + it('displays external link correctly', () => { + const run = createMockWorkflowRun(); + + render( + + + + ); + + const externalLink = screen.getByRole('link'); + expect(externalLink).toHaveAttribute('href', 'https://github.com/test/repo/actions/runs/1'); + expect(externalLink).toHaveAttribute('target', '_blank'); + expect(externalLink).toHaveAttribute('rel', 'noopener noreferrer'); + }); + + it('highlights current user failures', () => { + const run = createMockWorkflowRun({ + status: 'completed', + conclusion: 'failure', + actor: { + login: 'test-user', // Same as mockSettings.githubUsername + avatar_url: 'https://github.com/test-user.png' + } + }); + + render( + + + + ); + + // Should have special highlighting for current user failures + const card = screen.getByRole('generic'); + expect(card).toHaveClass('ring-2', 'ring-ctp-red/50'); + }); + + it('displays commit information when available', () => { + const run = createMockWorkflowRun({ + head_commit: { + id: 'abc123def456', + message: 'Fix critical bug', + author: { + name: 'Test Author', + email: 'test@example.com' + } + } + }); + + render( + + + + ); + + expect(screen.getByText('abc123d')).toBeInTheDocument(); + expect(screen.getByText('Fix critical bug')).toBeInTheDocument(); + }); + + it('handles missing commit information gracefully', () => { + const run = createMockWorkflowRun({ + head_commit: null as any + }); + + render( + + + + ); + + // Should not crash and should still render other information + expect(screen.getByText('test-repo')).toBeInTheDocument(); + expect(screen.getByText('#42')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/components/CompactWorkflowCard.tsx b/src/components/CompactWorkflowCard.tsx index a91d10d..28777df 100644 --- a/src/components/CompactWorkflowCard.tsx +++ b/src/components/CompactWorkflowCard.tsx @@ -118,8 +118,8 @@ export const CompactWorkflowCard: React.FC = ({ run, o
{run.repository.owner.login}

diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 3655a14..4ce067c 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -134,6 +134,13 @@ export const Dashboard: React.FC = () => { }, [settings.autoRefreshInterval, fetchWorkflowRuns]); + // Sort workflow runs by created_at date (newest first) + const sortedWorkflowRuns = useMemo(() => { + return [...workflowRuns].sort((a, b) => { + return new Date(b.created_at).getTime() - new Date(a.created_at).getTime(); + }); + }, [workflowRuns]); + const statusCounts = { total: workflowRuns.length, success: workflowRuns.filter(r => r.conclusion === 'success').length, @@ -237,13 +244,13 @@ export const Dashboard: React.FC = () => {
- ) : workflowRuns.length === 0 ? ( + ) : sortedWorkflowRuns.length === 0 ? (

No workflow runs found.

Check your config.json file

) : ( - workflowRuns.map(run => ( + sortedWorkflowRuns.map(run => ( = ({ run }) => {
{run.repository.owner.login}
diff --git a/src/components/WorkflowRunsModal.tsx b/src/components/WorkflowRunsModal.tsx index bb9f617..02d198d 100644 --- a/src/components/WorkflowRunsModal.tsx +++ b/src/components/WorkflowRunsModal.tsx @@ -90,6 +90,13 @@ export const WorkflowRunsModal: React.FC = ({ isOpen, on } }, [isOpen, repository, fetchWorkflowRuns]); + // Sort workflow runs by created_at date (newest first) + const sortedRuns = useMemo(() => { + return [...runs].sort((a, b) => { + return new Date(b.created_at).getTime() - new Date(a.created_at).getTime(); + }); + }, [runs]); + if (!isOpen) return null; return ( @@ -122,13 +129,13 @@ export const WorkflowRunsModal: React.FC = ({ isOpen, on

{error}

- ) : runs.length === 0 ? ( + ) : sortedRuns.length === 0 ? (

No workflow runs found

) : (
- {runs.map((run) => ( + {sortedRuns.map((run) => (
{ + let apiService: ApiService; + + beforeEach(() => { + apiService = new ApiService(); + vi.clearAllMocks(); + }); + + describe('getWorkflowRuns', () => { + it('should fetch workflow runs successfully', async () => { + const mockRuns = [ + { + id: 1, + name: 'Test Workflow', + display_title: 'Test Run', + status: 'completed', + conclusion: 'success', + created_at: '2024-01-01T10:00:00Z', + repository: { + id: 1, + name: 'test-repo', + full_name: 'test/test-repo', + owner: { + login: 'test-owner', + avatar_url: 'https://github.com/test-owner.png' + } + }, + actor: { + login: 'test-actor', + avatar_url: 'https://github.com/test-actor.png' + } + } + ]; + + mockedAxios.get.mockResolvedValueOnce({ data: mockRuns }); + + const result = await apiService.getWorkflowRuns(); + + expect(mockedAxios.get).toHaveBeenCalledWith('/api/workflow-runs'); + expect(result).toEqual(mockRuns); + }); + + it('should handle API errors', async () => { + const errorMessage = 'Network Error'; + mockedAxios.get.mockRejectedValueOnce(new Error(errorMessage)); + + await expect(apiService.getWorkflowRuns()).rejects.toThrow(errorMessage); + }); + }); + + describe('getRepositoryWorkflowRuns', () => { + it('should fetch repository-specific workflow runs', async () => { + const mockRuns = [ + { + id: 1, + name: 'Test Workflow', + display_title: 'Test Run', + status: 'completed', + conclusion: 'success', + created_at: '2024-01-01T10:00:00Z' + } + ]; + + mockedAxios.get.mockResolvedValueOnce({ data: mockRuns }); + + const result = await apiService.getRepositoryWorkflowRuns('test-owner', 'test-repo'); + + expect(mockedAxios.get).toHaveBeenCalledWith('/api/repository/test-owner/test-repo/workflow-runs', { + params: { limit: 10 } + }); + expect(result).toEqual(mockRuns); + }); + + it('should handle repository not found', async () => { + mockedAxios.get.mockRejectedValueOnce({ + response: { status: 404 } + }); + + await expect(apiService.getRepositoryWorkflowRuns('invalid', 'repo')).rejects.toMatchObject({ + response: { status: 404 } + }); + }); + }); + + describe('getConfig', () => { + it('should fetch configuration successfully', async () => { + const mockConfig = { + repositories: [ + { + owner: 'test-owner', + name: 'test-repo', + full_name: 'test-owner/test-repo' + } + ] + }; + + mockedAxios.get.mockResolvedValueOnce({ data: mockConfig }); + + const result = await apiService.getConfig(); + + expect(mockedAxios.get).toHaveBeenCalledWith('/api/config'); + expect(result).toEqual(mockConfig); + }); + }); + + describe('getRateLimit', () => { + it('should fetch rate limit information', async () => { + const mockRateLimit = { + limit: 5000, + remaining: 4999, + resetTime: 1640995200000, + used: 1, + resetTimeFormatted: '2022-01-01T00:00:00.000Z', + timeUntilReset: 3600000 + }; + + mockedAxios.get.mockResolvedValueOnce({ data: mockRateLimit }); + + const result = await apiService.getRateLimitInfo(); + + expect(mockedAxios.get).toHaveBeenCalledWith('/api/rate-limit'); + expect(result).toEqual(mockRateLimit); + }); + }); + + describe('getCacheStats', () => { + it('should fetch cache statistics', async () => { + const mockStats = { + size: 10, + entries: ['key1', 'key2', 'key3'] + }; + + mockedAxios.get.mockResolvedValueOnce({ data: mockStats }); + + const result = await apiService.getCacheStats(); + + expect(mockedAxios.get).toHaveBeenCalledWith('/api/cache/stats'); + expect(result).toEqual(mockStats); + }); + }); + + describe('clearCache', () => { + it('should clear cache successfully', async () => { + const mockResponse = { message: 'Cache cleared successfully' }; + + mockedAxios.delete.mockResolvedValueOnce({ data: mockResponse }); + + const result = await apiService.clearCache(); + + expect(mockedAxios.delete).toHaveBeenCalledWith('/api/cache'); + expect(result).toEqual(mockResponse); + }); + }); + + describe('error handling', () => { + it('should handle network errors', async () => { + mockedAxios.get.mockRejectedValueOnce(new Error('Network Error')); + + await expect(apiService.getWorkflowRuns()).rejects.toThrow('Network Error'); + }); + + it('should handle HTTP errors', async () => { + mockedAxios.get.mockRejectedValueOnce({ + response: { + status: 500, + data: { error: 'Internal Server Error' } + } + }); + + await expect(apiService.getWorkflowRuns()).rejects.toMatchObject({ + response: { + status: 500, + data: { error: 'Internal Server Error' } + } + }); + }); + }); +}); \ No newline at end of file diff --git a/src/test-setup.ts b/src/test-setup.ts new file mode 100644 index 0000000..570e366 --- /dev/null +++ b/src/test-setup.ts @@ -0,0 +1,42 @@ +import { expect, afterEach } from 'vitest'; +import { cleanup } from '@testing-library/react'; +import * as matchers from '@testing-library/jest-dom/matchers'; + +// Extend Vitest's expect with testing-library matchers +expect.extend(matchers); + +// Clean up after each test +afterEach(() => { + cleanup(); +}); + +// Mock window.matchMedia for components that use it +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => {}, + }), +}); + +// Mock IntersectionObserver +global.IntersectionObserver = class IntersectionObserver { + constructor() {} + disconnect() {} + observe() {} + unobserve() {} +} as any; + +// Mock ResizeObserver +global.ResizeObserver = class ResizeObserver { + constructor() {} + disconnect() {} + observe() {} + unobserve() {} +} as any; \ No newline at end of file diff --git a/src/utils/sorting.test.ts b/src/utils/sorting.test.ts new file mode 100644 index 0000000..1768970 --- /dev/null +++ b/src/utils/sorting.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from 'vitest'; +import { WorkflowRun } from '../types/github'; + +// Helper function to create a mock workflow run +const createMockWorkflowRun = (id: number, created_at: string): WorkflowRun => ({ + id, + name: `Test Workflow ${id}`, + display_title: `Test ${id}`, + status: 'completed', + conclusion: 'success', + workflow_id: 1, + head_branch: 'main', + head_sha: 'abc123', + run_number: id, + event: 'push', + created_at, + updated_at: created_at, + html_url: `https://github.com/test/repo/actions/runs/${id}`, + repository: { + id: 1, + name: 'test-repo', + full_name: 'test/test-repo', + owner: { + login: 'test-owner', + avatar_url: 'https://github.com/test-owner.png' + } + }, + head_commit: { + id: 'abc123', + message: 'Test commit', + author: { + name: 'Test Author', + email: 'test@example.com' + } + }, + actor: { + login: 'test-actor', + avatar_url: 'https://github.com/test-actor.png' + } +}); + +// Sorting function (extracted from components) +const sortWorkflowRunsByDate = (runs: WorkflowRun[]): WorkflowRun[] => { + return [...runs].sort((a, b) => { + return new Date(b.created_at).getTime() - new Date(a.created_at).getTime(); + }); +}; + +describe('Workflow Run Sorting', () => { + it('should sort workflow runs by created_at date (newest first)', () => { + const runs = [ + createMockWorkflowRun(1, '2024-01-01T10:00:00Z'), + createMockWorkflowRun(2, '2024-01-03T10:00:00Z'), + createMockWorkflowRun(3, '2024-01-02T10:00:00Z') + ]; + + const sortedRuns = sortWorkflowRunsByDate(runs); + + expect(sortedRuns).toHaveLength(3); + expect(sortedRuns[0].id).toBe(2); // 2024-01-03 (newest) + expect(sortedRuns[1].id).toBe(3); // 2024-01-02 + expect(sortedRuns[2].id).toBe(1); // 2024-01-01 (oldest) + }); + + it('should handle empty array', () => { + const runs: WorkflowRun[] = []; + const sortedRuns = sortWorkflowRunsByDate(runs); + expect(sortedRuns).toHaveLength(0); + }); + + it('should handle single item', () => { + const runs = [createMockWorkflowRun(1, '2024-01-01T10:00:00Z')]; + const sortedRuns = sortWorkflowRunsByDate(runs); + expect(sortedRuns).toHaveLength(1); + expect(sortedRuns[0].id).toBe(1); + }); + + it('should handle identical timestamps', () => { + const timestamp = '2024-01-01T10:00:00Z'; + const runs = [ + createMockWorkflowRun(1, timestamp), + createMockWorkflowRun(2, timestamp), + createMockWorkflowRun(3, timestamp) + ]; + + const sortedRuns = sortWorkflowRunsByDate(runs); + expect(sortedRuns).toHaveLength(3); + // Order should be stable for identical timestamps + }); + + it('should not mutate the original array', () => { + const runs = [ + createMockWorkflowRun(1, '2024-01-01T10:00:00Z'), + createMockWorkflowRun(2, '2024-01-02T10:00:00Z') + ]; + const originalOrder = runs.map(r => r.id); + + sortWorkflowRunsByDate(runs); + + expect(runs.map(r => r.id)).toEqual(originalOrder); + }); + + it('should handle various date formats', () => { + const runs = [ + createMockWorkflowRun(1, '2024-01-01T10:00:00.000Z'), + createMockWorkflowRun(2, '2024-01-02T10:00:00Z'), + createMockWorkflowRun(3, '2024-01-01T15:00:00.500Z') + ]; + + const sortedRuns = sortWorkflowRunsByDate(runs); + + expect(sortedRuns[0].id).toBe(2); // 2024-01-02 + expect(sortedRuns[1].id).toBe(3); // 2024-01-01 15:00 + expect(sortedRuns[2].id).toBe(1); // 2024-01-01 10:00 + }); +}); \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..e4d4e18 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + setupFiles: ['src/test-setup.ts'], + globals: true, + css: true, + }, + resolve: { + alias: { + '@': '/src', + }, + }, +}); \ No newline at end of file