diff --git a/package-lock.json b/package-lock.json index ceb8ab72f..b8d79a01d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "lighthouse-logger": "2.0.1", "multi-progress-bars": "^5.0.3", "nx": "21.4.1", + "ora": "^9.0.0", "parse-lcov": "^1.0.4", "rimraf": "^6.0.1", "semver": "^7.6.3", @@ -5911,6 +5912,19 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/@nx/js/node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@nx/js/node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -5938,6 +5952,55 @@ "node": ">=8" } }, + "node_modules/@nx/js/node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@nx/js/node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@nx/js/node_modules/ora": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.3.0.tgz", + "integrity": "sha512-zAKMgGXUim0Jyd6CXK9lraBnD3H5yPGBPPOkC23a2BG6hsm4Zu6OQSjQuEtV0BHDf4aKHcUFvJiGRrFuW3MG8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "log-symbols": "^4.0.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@nx/js/node_modules/picomatch": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", @@ -5951,6 +6014,40 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/@nx/js/node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@nx/js/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@nx/js/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@nx/js/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -12691,9 +12788,10 @@ } }, "node_modules/chalk": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", - "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" }, @@ -17755,9 +17853,10 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.2.0.tgz", - "integrity": "sha512-2nk+7SIVb14QrgXFHcm84tD4bKQz0RxPuMT8Ag5KPOq7J5fEmAg0UbXdTOSHqNuHSU28k55qnceesxXRZGzKWA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", + "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "license": "MIT", "engines": { "node": ">=18" }, @@ -23823,6 +23922,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/nx/node_modules/ora": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.3.0.tgz", + "integrity": "sha512-zAKMgGXUim0Jyd6CXK9lraBnD3H5yPGBPPOkC23a2BG6hsm4Zu6OQSjQuEtV0BHDf4aKHcUFvJiGRrFuW3MG8g==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "log-symbols": "^4.0.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/nx/node_modules/restore-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", @@ -24090,149 +24211,121 @@ } }, "node_modules/ora": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/ora/-/ora-5.3.0.tgz", - "integrity": "sha512-zAKMgGXUim0Jyd6CXK9lraBnD3H5yPGBPPOkC23a2BG6hsm4Zu6OQSjQuEtV0BHDf4aKHcUFvJiGRrFuW3MG8g==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-9.0.0.tgz", + "integrity": "sha512-m0pg2zscbYgWbqRR6ABga5c3sZdEon7bSgjnlXC64kxtxLOyjRcbbUkLj7HFyy/FTD+P2xdBWu8snGhYI0jc4A==", + "license": "MIT", "dependencies": { - "bl": "^4.0.3", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "log-symbols": "^4.0.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" + "chalk": "^5.6.2", + "cli-cursor": "^5.0.0", + "cli-spinners": "^3.2.0", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.1.0", + "log-symbols": "^7.0.1", + "stdin-discarder": "^0.2.2", + "string-width": "^8.1.0", + "strip-ansi": "^7.1.2" }, "engines": { - "node": ">=10" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ora/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dependencies": { - "color-convert": "^2.0.1" - }, + "node_modules/ora/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/ora/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, + "node_modules/ora/node_modules/cli-spinners": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.3.0.tgz", + "integrity": "sha512-/+40ljC3ONVnYIttjMWrlL51nItDAbBrq2upN8BPyvGU/2n5Oxw3tbNwORCaNuNqLJnxGqOfjUuhsv7l5Q4IsQ==", + "license": "MIT", "engines": { - "node": ">=10" + "node": ">=18.20" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ora/node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "dependencies": { - "restore-cursor": "^3.1.0" - }, + "node_modules/ora/node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "license": "MIT", "engines": { - "node": ">=8" - } - }, - "node_modules/ora/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dependencies": { - "color-name": "~1.1.4" + "node": ">=12" }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/ora/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "node_modules/ora/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "engines": { - "node": ">=8" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ora/node_modules/mimic-fn": { + "node_modules/ora/node_modules/is-unicode-supported": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", "engines": { - "node": ">=6" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ora/node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "node_modules/ora/node_modules/log-symbols": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", + "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", + "license": "MIT", "dependencies": { - "mimic-fn": "^2.1.0" + "is-unicode-supported": "^2.0.0", + "yoctocolors": "^2.1.1" }, "engines": { - "node": ">=6" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ora/node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "node_modules/ora/node_modules/string-width": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", + "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", + "license": "MIT", "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" + "get-east-asian-width": "^1.3.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=8" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ora/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" - }, "node_modules/ora/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", "dependencies": { - "ansi-regex": "^5.0.1" + "ansi-regex": "^6.0.1" }, "engines": { - "node": ">=8" - } - }, - "node_modules/ora/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dependencies": { - "has-flag": "^4.0.0" + "node": ">=12" }, - "engines": { - "node": ">=8" + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, "node_modules/os-tmpdir": { @@ -27859,6 +27952,18 @@ "integrity": "sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==", "dev": true }, + "node_modules/stdin-discarder": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", + "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/steno": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/steno/-/steno-0.4.4.tgz", @@ -31836,6 +31941,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/yoctocolors-cjs": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz", diff --git a/package.json b/package.json index f70cc974b..a5fec600c 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "lighthouse-logger": "2.0.1", "multi-progress-bars": "^5.0.3", "nx": "21.4.1", + "ora": "^9.0.0", "parse-lcov": "^1.0.4", "rimraf": "^6.0.1", "semver": "^7.6.3", diff --git a/packages/plugin-lighthouse/src/lib/runner/details/__snapshots__/critical-request-chain.type.unit.test.ts.snap b/packages/plugin-lighthouse/src/lib/runner/details/__snapshots__/critical-request-chain.type.unit.test.ts.snap index 8d6fa43ff..1639065c6 100644 --- a/packages/plugin-lighthouse/src/lib/runner/details/__snapshots__/critical-request-chain.type.unit.test.ts.snap +++ b/packages/plugin-lighthouse/src/lib/runner/details/__snapshots__/critical-request-chain.type.unit.test.ts.snap @@ -10,21 +10,21 @@ exports[`parseCriticalRequestChainToAuditDetails > should convert chains to basi { "name": "https://fonts.gstatic.com/s/googlesans/v62/4UasrENHsxJlGDuGo1OIlJfC6l_24rlCK1Yo_Iqcsih3SAyH6cAwhX9RPjIUvbQoi-E.woff2", "values": { - "duration": "48.083 ms", + "duration": "48 ms", "transferSize": "35.89 kB", }, }, { "name": "https://fonts.gstatic.com/s/robotomono/v23/L0xTDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_3vrtSM1J-gEPT5Ese6hmHSh0me8iUI0.woff2", "values": { - "duration": "63.943 ms", + "duration": "64 ms", "transferSize": "22.31 kB", }, }, ], "name": "https://fonts.googleapis.com/css?family=Google+Sans:400,500|Roboto:400,400italic,500,500italic,700,700italic|Roboto+Mono:400,500,700&display=swap", "values": { - "duration": "50.656 ms", + "duration": "51 ms", "transferSize": "3.68 kB", }, }, @@ -33,42 +33,42 @@ exports[`parseCriticalRequestChainToAuditDetails > should convert chains to basi { "name": "https://fonts.gstatic.com/s/materialicons/v143/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.woff2", "values": { - "duration": "86.765 ms", + "duration": "87 ms", "transferSize": "125.78 kB", }, }, ], "name": "https://fonts.googleapis.com/css2?family=Material+Icons&family=Material+Symbols+Outlined&display=block", "values": { - "duration": "55.102 ms", + "duration": "55 ms", "transferSize": "615 B", }, }, { "name": "https://www.gstatic.com/devrel-devsite/prod/ve761bca974e16662f27aa8810df6d144acde5bdbeeca0dfd50e25f86621eaa19/chrome/css/app.css", "values": { - "duration": "70.050 ms", + "duration": "70 ms", "transferSize": "133.58 kB", }, }, { "name": "https://www.gstatic.com/devrel-devsite/prod/ve761bca974e16662f27aa8810df6d144acde5bdbeeca0dfd50e25f86621eaa19/chrome/css/dark-theme.css", "values": { - "duration": "69.755 ms", + "duration": "70 ms", "transferSize": "3.98 kB", }, }, { "name": "https://developer.chrome.com/extras.css", "values": { - "duration": "199.327 ms", + "duration": "199 ms", "transferSize": "109 B", }, }, ], "name": "https://developer.chrome.com/docs/lighthouse/performance/critical-request-chains", "values": { - "duration": "472.304 ms", + "duration": "472 ms", "transferSize": "18.66 kB", }, }, @@ -98,7 +98,7 @@ exports[`parseCriticalRequestChainToAuditDetails > should convert longest chain ], "rows": [ { - "duration": "757.072 ms", + "duration": "757 ms", "length": 3, "transferSize": "125.78 kB", }, diff --git a/packages/plugin-lighthouse/src/lib/runner/details/details.unit.test.ts b/packages/plugin-lighthouse/src/lib/runner/details/details.unit.test.ts index 7e6bf1106..fecce3275 100644 --- a/packages/plugin-lighthouse/src/lib/runner/details/details.unit.test.ts +++ b/packages/plugin-lighthouse/src/lib/runner/details/details.unit.test.ts @@ -82,7 +82,7 @@ describe('toAuditDetails', () => { }, { name: 'Zone:ZoneAwarePromise', - duration: 0.783, + duration: 1.783, }, ], }); @@ -104,11 +104,11 @@ describe('toAuditDetails', () => { rows: [ { name: 'Zone', - duration: '0.634 ms', + duration: '1 ms', }, { name: 'Zone:ZoneAwarePromise', - duration: '0.783 ms', + duration: '2 ms', }, ], }, @@ -360,7 +360,7 @@ describe('toAuditDetails', () => { root: { name: 'https://example.com/', values: { - duration: '508.498 ms', + duration: '508 ms', transferSize: '849 B', }, }, @@ -375,7 +375,7 @@ describe('toAuditDetails', () => { ], rows: [ { - duration: '508.498 ms', + duration: '508 ms', transferSize: '849 B', length: 1, }, diff --git a/packages/plugin-lighthouse/src/lib/runner/details/item-value.ts b/packages/plugin-lighthouse/src/lib/runner/details/item-value.ts index 87de82abc..1e2890af4 100644 --- a/packages/plugin-lighthouse/src/lib/runner/details/item-value.ts +++ b/packages/plugin-lighthouse/src/lib/runner/details/item-value.ts @@ -5,6 +5,7 @@ import { formatBytes, formatDuration, html, + roundDecimals, truncateText, ui, } from '@code-pushup/utils'; @@ -72,17 +73,14 @@ export function formatTableItemPropertyValue( return html.link(url); case 'timespanMs': case 'ms': - return formatDuration(Number(parsedItemValue)); + return formatDuration(Number(parsedItemValue), 3); case 'node': return parseNodeValue(itemValue as Details.NodeValue); case 'source-location': return truncateText(String(parsedItemValue), 200); case 'numeric': const num = Number(parsedItemValue); - if (num.toFixed(3).toString().endsWith('.000')) { - return String(num); - } - return String(num.toFixed(3)); + return roundDecimals(num, 3).toString(); case 'text': return truncateText(String(parsedItemValue), 500); case 'multi': // @TODO diff --git a/packages/plugin-lighthouse/src/lib/runner/details/item-value.unit.test.ts b/packages/plugin-lighthouse/src/lib/runner/details/item-value.unit.test.ts index 5c6a0617f..70fa705ce 100644 --- a/packages/plugin-lighthouse/src/lib/runner/details/item-value.unit.test.ts +++ b/packages/plugin-lighthouse/src/lib/runner/details/item-value.unit.test.ts @@ -260,13 +260,13 @@ describe('formatTableItemPropertyValue', () => { { type: 'numeric', value: 2142 }, 'timespanMs', ), - ).toBe('2.14 s'); + ).toBe('2.142 s'); }); it('should format value based on itemValueFormat "ms"', () => { expect( formatTableItemPropertyValue({ type: 'numeric', value: 2142 }, 'ms'), - ).toBe('2.14 s'); + ).toBe('2.142 s'); }); it('should format value based on itemValueFormat "node"', () => { @@ -318,7 +318,7 @@ describe('formatTableItemPropertyValue', () => { { type: 'numeric', value: 42.1 } as Details.ItemValue, 'numeric', ), - ).toBe('42.100'); + ).toBe('42.1'); }); it('should format value based on itemValueFormat "numeric" as int if float has only 0 post comma', () => { diff --git a/packages/utils/mocks/logger-demo.ts b/packages/utils/mocks/logger-demo.ts new file mode 100644 index 000000000..ae45e7f4f --- /dev/null +++ b/packages/utils/mocks/logger-demo.ts @@ -0,0 +1,91 @@ +import ansis from 'ansis'; +import { logger } from '../src/index.js'; + +async function sleep(delay: number) { + return new Promise(resolve => { + setTimeout(resolve, delay); + }); +} + +logger.setVerbose(process.argv.includes('--verbose')); + +const errorStage = process.argv + .find(arg => arg.startsWith('--error=')) + ?.split('=')[1]; + +try { + logger.info(ansis.bold.blue('Code PushUp CLI v0.80.1')); + logger.newline(); + + await logger.task('Importing code-pushup.config.ts', async () => { + await sleep(500); + + return 'Loaded configuration from code-pushup.config.ts'; + }); + logger.debug('2 plugins:'); + logger.debug('• ESLint'); + logger.debug('• Lighthouse'); + + await logger.group( + `Running plugin "ESLint" ${ansis.gray('[1/2]')}`, + async () => { + const bin = 'npx eslint . --format=json'; + await logger.command(bin, async () => { + await sleep(3000); + if (errorStage === 'plugin') { + logger.info('Configuration file not found.'); + throw new Error(`Command ${ansis.bold(bin)} exited with code 1`); + } + logger.debug('All files pass linting.'); + }); + + logger.info('Found 0 lint problems'); + + logger.warn( + 'Metadata not found for rule @angular-eslint/template/eqeqeq', + ); + + return 'Completed "ESLint" plugin execution'; + }, + ); + + await logger.group( + `Running plugin "Lighthouse" ${ansis.gray('[2/2]')}`, + async () => { + await logger.task( + `Executing ${ansis.bold('runLighthouse')} function`, + async () => { + await sleep(8000); + return `Executed ${ansis.bold('runLighthouse')} function`; + }, + ); + + logger.debug('Lighthouse category scores:'); + logger.debug('• Accessibility: 100'); + logger.debug('• SEO: 84'); + + return 'Completed "Lighthouse" plugin execution'; + }, + ); + + logger.info(ansis.bold('Collected report')); + logger.newline(); + + await logger.task(ansis.bold('Uploading report to portal'), async () => { + logger.debug( + 'Sent GraphQL mutation to https://api.code-pushup.example.com/graphql (organization: "example", project: "website")', + ); + await sleep(2000); + if (errorStage === 'core') { + throw new Error('GraphQL error'); + } + return ansis.bold('Uploaded report to portal'); + }); +} catch (error) { + logger.newline(); + console.error(error); + logger.newline(); + logger.error(ansis.bold(`Code PushUp CLI failed (see error above)`)); + // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit + process.exit(1); +} diff --git a/packages/utils/package.json b/packages/utils/package.json index a3fc10d8a..ed7a0759d 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -37,7 +37,8 @@ "multi-progress-bars": "^5.0.3", "semver": "^7.6.0", "simple-git": "^3.20.0", - "zod": "^4.0.5" + "zod": "^4.0.5", + "ora": "^9.0.0" }, "files": [ "src", diff --git a/packages/utils/project.json b/packages/utils/project.json index 2953464ca..bcef53a99 100644 --- a/packages/utils/project.json +++ b/packages/utils/project.json @@ -20,7 +20,29 @@ } }, "unit-test": {}, - "int-test": {} + "int-test": {}, + "demo-logger": { + "executor": "nx:run-commands", + "options": { + "command": "npx tsx --tsconfig=tsconfig.base.json packages/utils/mocks/logger-demo.ts" + }, + "configurations": { + "ci": { + "env": { + "CI": "true" + } + }, + "verbose": { + "args": ["--verbose"] + }, + "error-core": { + "args": ["--error=core"] + }, + "error-plugin": { + "args": ["--error=plugin"] + } + } + } }, "tags": ["scope:shared", "type:util", "publishable"] } diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index c255cd2cc..e010cf7ac 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -10,6 +10,7 @@ export { } from './lib/case-conversions.js'; export { filesCoverageToTree, type FileCoverage } from './lib/coverage-tree.js'; export { createRunnerFiles } from './lib/create-runner-files.js'; +export { dateToUnixTimestamp } from './lib/dates.js'; export { comparePairs, matchArrayItemsByKey, type Diff } from './lib/diff.js'; export { coerceBooleanValue, @@ -51,9 +52,12 @@ export { filterItemRefsBy } from './lib/filter.js'; export { formatBytes, formatDuration, + indentLines, pluralize, pluralizeToken, + roundDecimals, slugify, + transformLines, truncateDescription, truncateIssueMessage, truncateText, @@ -81,10 +85,15 @@ export { } from './lib/guards.js'; export { interpolate } from './lib/interpolate.js'; export { logMultipleResults } from './lib/log-results.js'; +export { Logger, logger } from './lib/logger.js'; export { link, ui, type CliUi, type Column } from './lib/logging.js'; export { mergeConfigs } from './lib/merge-configs.js'; export { getProgressBar, type ProgressBar } from './lib/progress.js'; -export { asyncSequential, groupByStatus } from './lib/promises.js'; +export { + asyncSequential, + groupByStatus, + settlePromise, +} from './lib/promises.js'; export { generateRandomId } from './lib/random.js'; export { CODE_PUSHUP_DOMAIN, diff --git a/packages/utils/src/lib/dates.ts b/packages/utils/src/lib/dates.ts new file mode 100644 index 000000000..b22e06d7f --- /dev/null +++ b/packages/utils/src/lib/dates.ts @@ -0,0 +1,3 @@ +export function dateToUnixTimestamp(date: Date): number { + return Math.round(date.getTime() / 1000); +} diff --git a/packages/utils/src/lib/dates.unit.test.ts b/packages/utils/src/lib/dates.unit.test.ts new file mode 100644 index 000000000..fd5d79136 --- /dev/null +++ b/packages/utils/src/lib/dates.unit.test.ts @@ -0,0 +1,9 @@ +import { dateToUnixTimestamp } from './dates.js'; + +describe('dateToUnixTimestamp', () => { + it('should convert date to number of seconds since epoch', () => { + expect(dateToUnixTimestamp(new Date('1970-01-01T01:00:04.567Z'))).toBe( + 3605, // 1 hour is 3600 seconds + 4.567 seconds is rounded up to 5 seconds + ); + }); +}); diff --git a/packages/utils/src/lib/formatting.ts b/packages/utils/src/lib/formatting.ts index 86bc91301..260da8bc1 100644 --- a/packages/utils/src/lib/formatting.ts +++ b/packages/utils/src/lib/formatting.ts @@ -4,6 +4,11 @@ import { MAX_TITLE_LENGTH, } from '@code-pushup/models'; +export function roundDecimals(value: number, maxDecimals: number) { + const multiplier = Math.pow(10, maxDecimals); + return Math.round(value * multiplier) / multiplier; +} + export function slugify(text: string): string { return text .trim() @@ -40,20 +45,18 @@ export function formatBytes(bytes: number, decimals = 2) { const i = Math.floor(Math.log(positiveBytes) / Math.log(k)); - return `${Number.parseFloat((positiveBytes / Math.pow(k, i)).toFixed(dm))} ${ - sizes[i] - }`; + return `${roundDecimals(positiveBytes / Math.pow(k, i), dm)} ${sizes[i]}`; } export function pluralizeToken(token: string, times: number): string { return `${times} ${Math.abs(times) === 1 ? token : pluralize(token)}`; } -export function formatDuration(duration: number, granularity = 0): string { - if (duration < 1000) { - return `${granularity ? duration.toFixed(granularity) : duration} ms`; +export function formatDuration(ms: number, maxDecimals: number = 2): string { + if (ms < 1000) { + return `${Math.round(ms)} ms`; } - return `${(duration / 1000).toFixed(2)} s`; + return `${roundDecimals(ms / 1000, maxDecimals)} s`; } export function formatDate(date: Date): string { @@ -117,3 +120,14 @@ export function truncateDescription(text: string): string { export function truncateIssueMessage(text: string): string { return truncateText(text, MAX_ISSUE_MESSAGE_LENGTH); } + +export function transformLines( + text: string, + fn: (line: string) => string, +): string { + return text.split(/\r?\n/).map(fn).join('\n'); +} + +export function indentLines(text: string, identation: number): string { + return transformLines(text, line => `${' '.repeat(identation)}${line}`); +} diff --git a/packages/utils/src/lib/formatting.unit.test.ts b/packages/utils/src/lib/formatting.unit.test.ts index db564b826..b4234279f 100644 --- a/packages/utils/src/lib/formatting.unit.test.ts +++ b/packages/utils/src/lib/formatting.unit.test.ts @@ -1,14 +1,46 @@ +import ansis from 'ansis'; import { describe, expect, it } from 'vitest'; import { formatBytes, formatDate, formatDuration, + indentLines, pluralize, pluralizeToken, + roundDecimals, slugify, + transformLines, truncateText, } from './formatting.js'; +describe('roundDecimals', () => { + it('should remove extra decimals', () => { + expect(roundDecimals(1.2345, 2)).toBe(1.23); + }); + + it('should round last decimal', () => { + expect(roundDecimals(123.456, 2)).toBe(123.46); + }); + + it('should return number to prevent unnecessary trailing 0s in decimals', () => { + const result = roundDecimals(42.500001, 3); + expect(result).toBeTypeOf('number'); + expect(result.toString()).toBe('42.5'); + expect(result.toString()).not.toBe('42.50'); + }); + + it('should leave integers unchanged', () => { + const value = 42; + const result = roundDecimals(value, 3); + expect(result).toBe(value); + expect(result.toString()).toBe('42'); + }); + + it('should round to integer if max decimals set to 0', () => { + expect(roundDecimals(100.5, 0)).toBe(101); + }); +}); + describe('slugify', () => { it.each([ ['Largest Contentful Paint', 'largest-contentful-paint'], @@ -76,16 +108,15 @@ describe('formatDuration', () => { it.each([ [-1, '-1 ms'], [0, '0 ms'], - [1, '1 ms'], - [2, '2 ms'], - [1200, '1.20 s'], - ])('should log correctly formatted duration for %s', (ms, displayValue) => { + [23, '23 ms'], + [891, '891 ms'], + [499.85, '500 ms'], + [1200, '1.2 s'], + [56789, '56.79 s'], + [60_000, '60 s'], + ])('should format duration of %s milliseconds as %s', (ms, displayValue) => { expect(formatDuration(ms)).toBe(displayValue); }); - - it('should log formatted duration with 1 digit after the decimal point', () => { - expect(formatDuration(120.255_555, 1)).toBe('120.3 ms'); - }); }); describe('formatDate', () => { @@ -154,3 +185,48 @@ describe('truncateText', () => { ); }); }); + +describe('transformLines', () => { + it('should apply custom transformation to each line', () => { + let count = 0; + expect( + transformLines( + `export function greet(name = 'World') {\n console.log('Hello, ' + name + '!');\n}\n`, + line => `${ansis.gray(`${++count} | `)}${line}`, + ), + ).toBe( + ` +${ansis.gray('1 | ')}export function greet(name = 'World') { +${ansis.gray('2 | ')} console.log('Hello, ' + name + '!'); +${ansis.gray('3 | ')}} +${ansis.gray('4 | ')}`.trimStart(), + ); + }); + + it('should support CRLF line endings', () => { + expect( + transformLines( + 'ESLint v9.16.0\r\n\r\nAll files pass linting.\r\n', + line => `> ${line}`, + ), + ).toBe( + ` +> ESLint v9.16.0 +> +> All files pass linting. +> `.trimStart(), + ); + }); +}); + +describe('indentLines', () => { + it('should indent each line by given number of spaces', () => { + expect(indentLines('ESLint v9.16.0\n\nAll files pass linting.\n', 2)).toBe( + ` + ESLint v9.16.0 + + All files pass linting. + `.slice(1), // ignore first line break + ); + }); +}); diff --git a/packages/utils/src/lib/logger.int.test.ts b/packages/utils/src/lib/logger.int.test.ts new file mode 100644 index 000000000..e48020591 --- /dev/null +++ b/packages/utils/src/lib/logger.int.test.ts @@ -0,0 +1,850 @@ +import ansis from 'ansis'; +import cliSpinners from 'cli-spinners'; +import os from 'node:os'; +import process from 'node:process'; +import type { MockInstance } from 'vitest'; +import { Logger } from './logger.js'; + +// customize ora options for test environment +vi.mock('ora', async (): Promise => { + const exports = await vi.importActual('ora'); + return { + ...exports, + default: options => { + const spinner = exports.default({ + // skip cli-cursor package + hideCursor: false, + // skip is-interactive package + isEnabled: process.env['CI'] !== 'true', + // skip is-unicode-supported package + spinner: cliSpinners.dots, + // preserve other options + ...(typeof options === 'string' ? { text: options } : options), + }); + // skip log-symbols package + vi.spyOn(spinner, 'succeed').mockImplementation(text => + spinner.stopAndPersist({ text, symbol: ansis.green('✔') }), + ); + vi.spyOn(spinner, 'fail').mockImplementation(text => + spinner.stopAndPersist({ text, symbol: ansis.red('✖') }), + ); + return spinner; + }, + }; +}); + +describe('Logger', () => { + let output = ''; + let consoleLogSpy: MockInstance; + let processStderrSpy: MockInstance<[], typeof process.stderr>; + let performanceNowSpy: MockInstance<[], number>; + let mathRandomSpy: MockInstance<[], number>; + + beforeAll(() => { + vi.useFakeTimers(); + + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(message => { + output += `${message}\n`; + }); + + // ora spinner uses process.stderr stream + const mockProcessStderr: Partial = { + write: message => { + output += message; + return true; + }, + get isTTY() { + return process.env['CI'] !== 'true'; + }, + cursorTo: () => true, + moveCursor: () => true, + clearLine: () => { + const idx = output.lastIndexOf('\n'); + output = idx >= 0 ? output.substring(0, idx + 1) : ''; + return true; + }, + }; + processStderrSpy = vi + .spyOn(process, 'stderr', 'get') + .mockReturnValue(mockProcessStderr as typeof process.stderr); + }); + + beforeEach(() => { + output = ''; + performanceNowSpy = vi.spyOn(performance, 'now'); + mathRandomSpy = vi.spyOn(Math, 'random'); + + vi.stubEnv('CI', 'false'); + vi.stubEnv('GITHUB_ACTIONS', 'false'); + vi.stubEnv('GITLAB_CI', 'false'); + }); + + afterAll(() => { + vi.useRealTimers(); + consoleLogSpy.mockReset(); + processStderrSpy.mockReset(); + performanceNowSpy.mockReset(); + mathRandomSpy.mockReset(); + }); + + describe('basic usage', () => { + it('should colorize logs based on level', () => { + vi.stubEnv('CP_VERBOSE', 'true'); // to render debug log + const logger = new Logger(); + + logger.info('Code PushUp CLI'); + logger.debug('v1.2.3'); + logger.warn('Config file in CommonJS format'); + logger.error('Failed to load config'); + + expect(output).toBe( + ` +Code PushUp CLI +${ansis.gray('v1.2.3')} +${ansis.yellow('Config file in CommonJS format')} +${ansis.red('Failed to load config')} +`.trimStart(), + ); + }); + + it('should omit debug logs if not verbose', () => { + vi.stubEnv('CP_VERBOSE', 'false'); + + new Logger().debug('Found config file code-pushup.config.js'); + + expect(output).toBe(''); + }); + + it('should set verbose flag and environment variable', () => { + vi.stubEnv('CP_VERBOSE', 'false'); + const logger = new Logger(); + + logger.setVerbose(true); + + expect(logger.isVerbose()).toBe(true); + expect(process.env['CP_VERBOSE']).toBe('true'); + expect(new Logger().isVerbose()).toBe(true); + }); + }); + + describe('groups', () => { + it('should group logs with symbols and print duration', async () => { + performanceNowSpy.mockReturnValueOnce(0).mockReturnValueOnce(1234); // group duration: 1.23 s + const logger = new Logger(); + + await logger.group('Running plugin "ESLint"', async () => { + logger.info('$ npx eslint . --format=json'); + logger.warn('Skipping unknown rule "deprecation/deprecation"'); + return 'ESLint reported 4 errors and 11 warnings'; + }); + + expect(ansis.strip(output)).toBe(` +❯ Running plugin "ESLint" +│ $ npx eslint . --format=json +│ Skipping unknown rule "deprecation/deprecation" +└ ESLint reported 4 errors and 11 warnings (1.23 s) + +`); + }); + + it('should complete group logs when error is thrown', async () => { + const logger = new Logger(); + + await expect( + logger.group('Running plugin "ESLint"', async () => { + logger.info( + '$ npx eslint . --format=json --output-file=.code-pushup/eslint/results.json', + ); + throw new Error( + "ENOENT: no such file or directory, open '.code-pushup/eslint/results.json'", + ); + }), + ).rejects.toThrow( + "ENOENT: no such file or directory, open '.code-pushup/eslint/results.json'", + ); + expect(output).toBe( + ` +${ansis.bold.cyan('❯ Running plugin "ESLint"')} +${ansis.cyan('│')} $ npx eslint . --format=json --output-file=.code-pushup/eslint/results.json +${ansis.cyan('└')} ${ansis.red("Error: ENOENT: no such file or directory, open '.code-pushup/eslint/results.json'")} + +`, + ); + }); + + it('should alternate colors for log groups and preserve child log styles', async () => { + performanceNowSpy + .mockReturnValueOnce(0) + .mockReturnValueOnce(1234) // 1st group duration: 1.23 s + .mockReturnValueOnce(0) + .mockReturnValueOnce(12_000) // 2nd group duration: 12 s + .mockReturnValueOnce(0) + .mockReturnValueOnce(42); // 3rd group duration: 42 ms + const logger = new Logger(); + + await logger.group('Running plugin "ESLint"', async () => { + logger.info(`${ansis.blue('$')} npx eslint . --format=json`); + logger.warn('Skipping unknown rule "deprecation/deprecation"'); + return 'ESLint reported 4 errors and 11 warnings'; + }); + + await logger.group( + 'Running plugin "Lighthouse"', + async () => 'Calculated Lighthouse scores for 4 categories', + ); + + await logger.group('Running plugin "Code coverage"', async () => { + logger.info(`${ansis.blue('$')} npx vitest --coverage.enabled`); + return `Total line coverage is ${ansis.bold('82%')}`; + }); + + expect(output).toBe( + ` +${ansis.bold.cyan('❯ Running plugin "ESLint"')} +${ansis.cyan('│')} ${ansis.blue('$')} npx eslint . --format=json +${ansis.cyan('│')} ${ansis.yellow('Skipping unknown rule "deprecation/deprecation"')} +${ansis.cyan('└')} ${ansis.green('ESLint reported 4 errors and 11 warnings')} ${ansis.gray('(1.23 s)')} + +${ansis.bold.magenta('❯ Running plugin "Lighthouse"')} +${ansis.magenta('└')} ${ansis.green('Calculated Lighthouse scores for 4 categories')} ${ansis.gray('(12 s)')} + +${ansis.bold.cyan('❯ Running plugin "Code coverage"')} +${ansis.cyan('│')} ${ansis.blue('$')} npx vitest --coverage.enabled +${ansis.cyan('└')} ${ansis.green(`Total line coverage is ${ansis.bold('82%')}`)} ${ansis.gray('(42 ms)')} + +`, + ); + }); + + it('should use log group prefix in child loggers', async () => { + performanceNowSpy.mockReturnValueOnce(0).mockReturnValueOnce(1234); // group duration: 1.23 s + + await new Logger().group('Running plugin "ESLint"', async () => { + new Logger().info(`${ansis.blue('$')} npx eslint . --format=json`); + return 'ESLint reported 4 errors and 11 warnings'; + }); + + expect(output).toBe(` +${ansis.bold.cyan('❯ Running plugin "ESLint"')} +${ansis.cyan('│')} ${ansis.blue('$')} npx eslint . --format=json +${ansis.cyan('└')} ${ansis.green('ESLint reported 4 errors and 11 warnings')} ${ansis.gray('(1.23 s)')} + +`); + }); + + it('should use workflow commands to group logs in GitHub Actions environment', async () => { + vi.stubEnv('CI', 'true'); + vi.stubEnv('GITHUB_ACTIONS', 'true'); + performanceNowSpy.mockReturnValueOnce(0).mockReturnValueOnce(1234); // group duration: 1.23 s + const logger = new Logger(); + + await logger.group('Running plugin "ESLint"', async () => { + logger.info('$ npx eslint . --format=json'); + logger.warn('Skipping unknown rule "deprecation/deprecation"'); + return 'ESLint reported 4 errors and 11 warnings'; + }); + + expect(ansis.strip(output)).toBe(` +::group::Running plugin "ESLint" +│ $ npx eslint . --format=json +│ Skipping unknown rule "deprecation/deprecation" +└ ESLint reported 4 errors and 11 warnings (1.23 s) +::endgroup:: + +`); + }); + + it('should use collapsible sections in GitLab CI/CD environment, initial collapse depends on verbosity', async () => { + vi.stubEnv('CI', 'true'); + vi.stubEnv('GITLAB_CI', 'true'); + vi.setSystemTime(new Date(123456789000)); // current Unix timestamp: 123456789 seconds since epoch + performanceNowSpy + .mockReturnValueOnce(0) + .mockReturnValueOnce(123) // 1st group duration: 123 ms + .mockReturnValueOnce(0) + .mockReturnValue(45); // 2nd group duration: 45 ms + mathRandomSpy + .mockReturnValueOnce(0x1a / Math.pow(2, 8)) // 1st group's random ID: "1a" + .mockReturnValueOnce(0x1b / Math.pow(2, 8)); // 2nd group's random ID: "1b" + const logger = new Logger(); + + logger.setVerbose(false); + await logger.group('Running plugin "ESLint"', async () => { + logger.info(`${ansis.blue('$')} npx eslint . --format=json`); + logger.warn('Skipping unknown rule "deprecation/deprecation"'); + return 'ESLint reported 4 errors and 11 warnings'; + }); + logger.setVerbose(true); + await logger.group('Running plugin "Code coverage"', async () => { + logger.info(`${ansis.blue('$')} npx vitest --coverage.enabled`); + return `Total line coverage is ${ansis.bold('82%')}`; + }); + + // debugging tip: temporarily remove '\r' character from original implementation + expect(output).toBe(` +\x1b[0Ksection_start:123456789:code_pushup_logs_group_1a[collapsed=true]\r\x1b[0K${ansis.bold.cyan('❯ Running plugin "ESLint"')} +${ansis.cyan('│')} ${ansis.blue('$')} npx eslint . --format=json +${ansis.cyan('│')} ${ansis.yellow('Skipping unknown rule "deprecation/deprecation"')} +${ansis.cyan('└')} ${ansis.green('ESLint reported 4 errors and 11 warnings')} ${ansis.gray('(123 ms)')} +\x1b[0Ksection_end:123456789:code_pushup_logs_group_1a\r\x1b[0K + +\x1b[0Ksection_start:123456789:code_pushup_logs_group_1b\r\x1b[0K${ansis.bold.magenta('❯ Running plugin "Code coverage"')} +${ansis.magenta('│')} ${ansis.blue('$')} npx vitest --coverage.enabled +${ansis.magenta('└')} ${ansis.green(`Total line coverage is ${ansis.bold('82%')}`)} ${ansis.gray('(45 ms)')} +\x1b[0Ksection_end:123456789:code_pushup_logs_group_1b\r\x1b[0K + +`); + }); + }); + + describe('spinners', () => { + beforeEach(() => { + performanceNowSpy.mockReturnValueOnce(0).mockReturnValueOnce(42); // task duration: 42 ms + }); + + it('should render dots spinner for async tasks', async () => { + const task = new Logger().task( + 'Uploading report to portal', + async () => 'Uploaded report to portal', + ); + + expect(output).toBe(`${ansis.cyan('⠋')} Uploading report to portal`); + + vi.advanceTimersByTime(cliSpinners.dots.interval); + expect(output).toBe(`${ansis.cyan('⠙')} Uploading report to portal`); + + vi.advanceTimersByTime(cliSpinners.dots.interval); + expect(output).toBe(`${ansis.cyan('⠹')} Uploading report to portal`); + + await expect(task).resolves.toBeUndefined(); + + expect(output).toBe( + `${ansis.green('✔')} Uploaded report to portal ${ansis.gray('(42 ms)')}\n`, + ); + }); + + it('should fail spinner if async task rejects', async () => { + const task = new Logger().task('Uploading report to portal', async () => { + throw new Error('GraphQL error: Invalid API key'); + }); + + expect(output).toBe(`${ansis.cyan('⠋')} Uploading report to portal`); + + await expect(task).rejects.toThrow('GraphQL error: Invalid API key'); + + expect(output).toBe( + `${ansis.red('✖')} Uploading report to portal → ${ansis.red('Error: GraphQL error: Invalid API key')}\n`, + ); + }); + + it('should skip interactive spinner in CI', async () => { + vi.stubEnv('CI', 'true'); + + const task = new Logger().task( + 'Uploading report to portal', + async () => 'Uploaded report to portal', + ); + + expect(output).toBe('- Uploading report to portal\n'); + + await task; + + expect(output).toBe( + ` +- Uploading report to portal +${ansis.green('✔')} Uploaded report to portal ${ansis.gray('(42 ms)')} +`.trimStart(), + ); + }); + + it('should fail spinner and exit if SIGINT received', async () => { + vi.spyOn(process, 'exit').mockReturnValue(undefined as never); + vi.spyOn(os, 'platform').mockReturnValue('linux'); + + new Logger().task( + 'Uploading report to portal', + async () => 'Uploaded report to portal', + ); + + expect(output).toBe(`${ansis.cyan('⠋')} Uploading report to portal`); + + process.emit('SIGINT'); + + expect(output).toBe( + ` +${ansis.red('✖')} Uploading report to portal ${ansis.red.bold('[SIGINT]')} + +${ansis.red.bold('Cancelled by SIGINT')} +`.trimStart(), + ); + + expect(process.exit).toHaveBeenCalledWith(130); + }); + + it('should silence other logs while spinner is running, and print them with indentation after it completes', async () => { + vi.stubEnv('CP_VERBOSE', 'true'); + const logger = new Logger(); + + const task = logger.task('Uploading report to portal', async () => { + logger.debug('Sent request to Portal API'); + await new Promise(resolve => { + setTimeout(resolve, 42); + }); + logger.debug('Received response from Portal API'); + return 'Uploaded report to portal'; + }); + + expect(output).toBe(`${ansis.cyan('⠋')} Uploading report to portal`); + + await vi.advanceTimersByTimeAsync(42); + await expect(task).resolves.toBeUndefined(); + + expect(output).toBe( + ` +${ansis.green('✔')} Uploaded report to portal ${ansis.gray('(42 ms)')} + ${ansis.gray('Sent request to Portal API')} + ${ansis.gray('Received response from Portal API')} +`.trimStart(), + ); + }); + + it('should print other logs once spinner fails', async () => { + vi.stubEnv('CP_VERBOSE', 'true'); + const logger = new Logger(); + + const task = logger.task('Uploading report to portal', async () => { + logger.debug('Sent request to Portal API'); + await new Promise(resolve => { + setTimeout(resolve, 42); + }); + logger.debug('Received response from Portal API'); + throw new Error('GraphQL error: Invalid API key'); + }); + + expect(output).toBe(`${ansis.cyan('⠋')} Uploading report to portal`); + + vi.advanceTimersByTime(42); + await expect(task).rejects.toThrow('GraphQL error: Invalid API key'); + + expect(output).toBe( + ` +${ansis.red('✖')} Uploading report to portal → ${ansis.red('Error: GraphQL error: Invalid API key')} + ${ansis.gray('Sent request to Portal API')} + ${ansis.gray('Received response from Portal API')} +`.trimStart(), + ); + }); + + it('should print other logs immediately in CI', async () => { + vi.stubEnv('CI', 'true'); + vi.stubEnv('CP_VERBOSE', 'true'); + const logger = new Logger(); + + logger.task('Uploading report to portal', async () => { + logger.debug('Sent request to Portal API'); + await new Promise(resolve => { + setTimeout(resolve, 42); + }); + logger.debug('Received response from Portal API'); + return 'Uploaded report to portal'; + }); + + expect(ansis.strip(output)).toBe( + ` +- Uploading report to portal + Sent request to Portal API +`.trimStart(), + ); + + await vi.advanceTimersByTimeAsync(42); + + expect(ansis.strip(output)).toBe( + ` +- Uploading report to portal + Sent request to Portal API + Received response from Portal API +✔ Uploaded report to portal (42 ms) +`.trimStart(), + ); + }); + + it('should use colored dollar prefix for commands (success)', async () => { + const command = new Logger().command( + 'npx eslint . --format=json', + async () => {}, + ); + + expect(output).toBe( + `${ansis.cyan('⠋')} ${ansis.blue('$')} npx eslint . --format=json`, + ); + + await expect(command).resolves.toBeUndefined(); + + expect(output).toBe( + `${ansis.green('✔')} ${ansis.green('$')} npx eslint . --format=json ${ansis.gray('(42 ms)')}\n`, + ); + }); + + it('should use colored dollar prefix for commands (failure)', async () => { + const command = new Logger().command( + 'npx eslint . --format=json', + async () => { + throw new Error('Process failed with exit code 1'); + }, + ); + + expect(output).toBe( + `${ansis.cyan('⠋')} ${ansis.blue('$')} npx eslint . --format=json`, + ); + + await expect(command).rejects.toThrow('Process failed with exit code 1'); + + expect(output).toBe( + `${ansis.red('✖')} ${ansis.red('$')} npx eslint . --format=json\n`, + ); + }); + }); + + describe('spinners + groups', () => { + beforeEach(() => { + performanceNowSpy + .mockReturnValueOnce(0) + .mockReturnValueOnce(0) + .mockReturnValueOnce(42) // task duration: 42 ms + .mockReturnValueOnce(50); // group duration: 50 ms; + }); + + it('should render line spinner for async tasks within group', async () => { + const logger = new Logger(); + + const group = logger.group('Running plugin "ESLint"', async () => { + await logger.command('npx eslint . --format=json', async () => {}); + logger.warn('Skipping unknown rule "deprecation/deprecation"'); + return 'ESLint reported 4 errors and 11 warnings'; + }); + + expect(output).toBe( + ` +${ansis.bold.cyan('❯ Running plugin "ESLint"')} +${ansis.cyan('-')} ${ansis.blue('$')} npx eslint . --format=json`, + ); + + vi.advanceTimersByTime(cliSpinners.line.interval); + expect(output).toBe( + ` +${ansis.bold.cyan('❯ Running plugin "ESLint"')} +${ansis.cyan('\\')} ${ansis.blue('$')} npx eslint . --format=json`, + ); + + vi.advanceTimersByTime(cliSpinners.line.interval); + expect(output).toBe( + ` +${ansis.bold.cyan('❯ Running plugin "ESLint"')} +${ansis.cyan('|')} ${ansis.blue('$')} npx eslint . --format=json`, + ); + + vi.advanceTimersByTime(cliSpinners.line.interval); + expect(output).toBe( + ` +${ansis.bold.cyan('❯ Running plugin "ESLint"')} +${ansis.cyan('/')} ${ansis.blue('$')} npx eslint . --format=json`, + ); + + await expect(group).resolves.toBeUndefined(); + + expect(output).toBe( + ` +${ansis.bold.cyan('❯ Running plugin "ESLint"')} +${ansis.cyan('│')} ${ansis.green('$')} npx eslint . --format=json ${ansis.gray('(42 ms)')} +${ansis.cyan('│')} ${ansis.yellow('Skipping unknown rule "deprecation/deprecation"')} +${ansis.cyan('└')} ${ansis.green('ESLint reported 4 errors and 11 warnings')} ${ansis.gray('(50 ms)')} + +`, + ); + }); + + it('should colorize line spinner with same color as group', async () => { + const logger = new Logger(); + + const group1 = logger.group('Running plugin "ESLint"', async () => { + await logger.command('npx eslint . --format=json', async () => {}); + return 'ESLint reported 4 errors and 11 warnings'; + }); + + expect(output).toBe( + ` +${ansis.bold.cyan('❯ Running plugin "ESLint"')} +${ansis.cyan('-')} ${ansis.blue('$')} npx eslint . --format=json`, + ); + + await group1; + + performanceNowSpy + .mockReturnValueOnce(0) + .mockReturnValueOnce(0) + .mockReturnValueOnce(cliSpinners.line.interval) // task duration + .mockReturnValueOnce(cliSpinners.line.interval); // group duration + + const group2 = logger.group('Running plugin "Lighthouse"', async () => { + await logger.task( + `Executing ${ansis.bold('runLighthouse')} function`, + async () => { + await new Promise(resolve => { + setTimeout(resolve, cliSpinners.line.interval); + }); + return `Executed ${ansis.bold('runLighthouse')} function`; + }, + ); + return 'Calculated Lighthouse scores for 4 categories'; + }); + + expect(output).toBe( + ` +${ansis.bold.cyan('❯ Running plugin "ESLint"')} +${ansis.cyan('│')} ${ansis.green('$')} npx eslint . --format=json ${ansis.gray('(42 ms)')} +${ansis.cyan('└')} ${ansis.green('ESLint reported 4 errors and 11 warnings')} ${ansis.gray('(50 ms)')} + +${ansis.bold.magenta('❯ Running plugin "Lighthouse"')} +${ansis.magenta('-')} Executing ${ansis.bold('runLighthouse')} function`, + ); + + vi.advanceTimersByTime(cliSpinners.line.interval); + expect(output).toBe( + ` +${ansis.bold.cyan('❯ Running plugin "ESLint"')} +${ansis.cyan('│')} ${ansis.green('$')} npx eslint . --format=json ${ansis.gray('(42 ms)')} +${ansis.cyan('└')} ${ansis.green('ESLint reported 4 errors and 11 warnings')} ${ansis.gray('(50 ms)')} + +${ansis.bold.magenta('❯ Running plugin "Lighthouse"')} +${ansis.magenta('\\')} Executing ${ansis.bold('runLighthouse')} function`, + ); + + await group2; + expect(output).toBe( + ` +${ansis.bold.cyan('❯ Running plugin "ESLint"')} +${ansis.cyan('│')} ${ansis.green('$')} npx eslint . --format=json ${ansis.gray('(42 ms)')} +${ansis.cyan('└')} ${ansis.green('ESLint reported 4 errors and 11 warnings')} ${ansis.gray('(50 ms)')} + +${ansis.bold.magenta('❯ Running plugin "Lighthouse"')} +${ansis.magenta('│')} Executed ${ansis.bold('runLighthouse')} function ${ansis.gray('(130 ms)')} +${ansis.magenta('└')} ${ansis.green('Calculated Lighthouse scores for 4 categories')} ${ansis.gray('(130 ms)')} + +`, + ); + }); + + it('should skip interactive group spinner in CI', async () => { + vi.stubEnv('CI', 'true'); + const logger = new Logger(); + + const group = logger.group('Running plugin "ESLint"', async () => { + await logger.command('npx eslint . --format=json', async () => {}); + return 'ESLint reported 4 errors and 11 warnings'; + }); + + expect(output).toBe( + ` +${ansis.bold.cyan('❯ Running plugin "ESLint"')} +${ansis.cyan('│')} ${ansis.blue('$')} npx eslint . --format=json +`, + ); + + await group; + + expect(output).toBe( + ` +${ansis.bold.cyan('❯ Running plugin "ESLint"')} +${ansis.cyan('│')} ${ansis.blue('$')} npx eslint . --format=json +${ansis.cyan('│')} ${ansis.green('$')} npx eslint . --format=json ${ansis.gray('(42 ms)')} +${ansis.cyan('└')} ${ansis.green('ESLint reported 4 errors and 11 warnings')} ${ansis.gray('(50 ms)')} + +`, + ); + }); + + it('should fail group if spinner task rejects', async () => { + const logger = new Logger(); + + const group = logger.group('Running plugin "ESLint"', async () => { + await logger.command('npx eslint . --format=json', async () => { + await new Promise(resolve => { + setTimeout(resolve, 0); + }); + throw new Error('Process failed with exit code 1'); + }); + return 'ESLint reported 4 errors and 11 warnings'; + }); + + expect(output).toBe( + ` +${ansis.bold.cyan('❯ Running plugin "ESLint"')} +${ansis.cyan('-')} ${ansis.blue('$')} npx eslint . --format=json`, + ); + + vi.advanceTimersToNextTimer(); + await expect(group).rejects.toThrow('Process failed with exit code 1'); + + expect(output).toBe( + ` +${ansis.bold.cyan('❯ Running plugin "ESLint"')} +${ansis.cyan('│')} ${ansis.red('$')} npx eslint . --format=json +${ansis.cyan('└')} ${ansis.red('Error: Process failed with exit code 1')} + +`, + ); + }); + + it('should fail spinner, complete group and exit if SIGINT received', async () => { + vi.spyOn(process, 'exit').mockReturnValue(undefined as never); + vi.spyOn(os, 'platform').mockReturnValue('win32'); + const logger = new Logger(); + + logger.group('Running plugin "ESLint"', async () => { + await logger.command('npx eslint . --format=json', async () => {}); + return 'ESLint reported 4 errors and 11 warnings'; + }); + + expect(output).toBe( + ` +${ansis.bold.cyan('❯ Running plugin "ESLint"')} +${ansis.cyan('-')} ${ansis.blue('$')} npx eslint . --format=json`, + ); + + process.emit('SIGINT'); + + expect(output).toBe( + ` +${ansis.bold.cyan('❯ Running plugin "ESLint"')} +${ansis.cyan('└')} ${ansis.blue('$')} npx eslint . --format=json ${ansis.red.bold('[SIGINT]')} + +${ansis.red.bold('Cancelled by SIGINT')} +`, + ); + + expect(process.exit).toHaveBeenCalledWith(2); + }); + + it('should indent other logs within group if they were logged while spinner was active', async () => { + vi.stubEnv('CP_VERBOSE', 'true'); + const logger = new Logger(); + + const group = logger.group('Running plugin "ESLint"', async () => { + await logger.command('npx eslint . --format=json', async () => { + logger.debug('ESLint v9.0.0\n\nAll files pass linting.\n'); + }); + return 'ESLint reported 0 problems'; + }); + + expect(ansis.strip(output)).toBe( + ` +❯ Running plugin "ESLint" +- $ npx eslint . --format=json`, + ); + + await expect(group).resolves.toBeUndefined(); + + expect(ansis.strip(output)).toBe( + ` +❯ Running plugin "ESLint" +│ $ npx eslint . --format=json (42 ms) +│ ESLint v9.0.0 +│ +│ All files pass linting. +│ +└ ESLint reported 0 problems (50 ms) + +`, + ); + }); + + it('should indent other logs from spinner in group when it fails in CI', async () => { + vi.stubEnv('CI', 'true'); + const logger = new Logger(); + + const group = logger.group('Running plugin "ESLint"', async () => { + await logger.command('npx eslint . --format=json', async () => { + logger.error( + "\nOops! Something went wrong! :(\n\nESLint: 8.26.0\n\nESLint couldn't find a configuration file.\n", + ); + throw new Error('Process failed with exit code 2'); + }); + return 'ESLint reported 0 problems'; + }); + + await expect(group).rejects.toThrow('Process failed with exit code 2'); + + expect(ansis.strip(output)).toBe( + ` +❯ Running plugin "ESLint" +│ $ npx eslint . --format=json +│ +│ Oops! Something went wrong! :( +│ +│ ESLint: 8.26.0 +│ +│ ESLint couldn't find a configuration file. +│ +│ $ npx eslint . --format=json +└ Error: Process failed with exit code 2 + +`, + ); + }); + }); + + describe('invalid usage', () => { + it('should throw if nesting group in another group', async () => { + const logger = new Logger(); + + await expect( + logger.group('Outer group', async () => { + await logger.group('Inner group', async () => 'Inner group complete'); + return 'Outer group complete'; + }), + ).rejects.toThrow( + 'Internal Logger error - nested groups are not supported', + ); + }); + + it('should throw if nesting groups across logger instances', async () => { + await expect( + new Logger().group('Outer group', async () => { + await new Logger().group( + 'Inner group', + async () => 'Inner group complete', + ); + return 'Outer group complete'; + }), + ).rejects.toThrow( + 'Internal Logger error - nested groups are not supported', + ); + }); + + it('should throw if creating group while spinner is running', async () => { + const logger = new Logger(); + + await expect( + logger.task('Some async process', async () => { + await logger.group('Some group', async () => 'Group completed'); + return 'Async process completed'; + }), + ).rejects.toThrow( + 'Internal Logger error - creating group in active spinner is not supported', + ); + }); + + it('should throw if starting new spinner while another is still active', async () => { + const logger = new Logger(); + + await expect( + Promise.all([ + logger.task('Task 1', async () => 'DONE'), + logger.task('Task 2', async () => 'DONE'), + ]), + ).rejects.toThrow( + 'Internal Logger error - concurrent spinners are not supported', + ); + }); + }); +}); diff --git a/packages/utils/src/lib/logger.ts b/packages/utils/src/lib/logger.ts new file mode 100644 index 000000000..087c4c55f --- /dev/null +++ b/packages/utils/src/lib/logger.ts @@ -0,0 +1,475 @@ +import ansis, { type AnsiColors } from 'ansis'; +import os from 'node:os'; +import ora, { type Ora } from 'ora'; +import { dateToUnixTimestamp } from './dates.js'; +import { isEnvVarEnabled } from './env.js'; +import { formatDuration, indentLines, transformLines } from './formatting.js'; +import { settlePromise } from './promises.js'; + +type GroupColor = Extract; +type CiPlatform = 'GitHub Actions' | 'GitLab CI/CD'; + +const GROUP_COLOR_ENV_VAR_NAME = 'CP_LOGGER_GROUP_COLOR'; + +/** + * Rich logging implementation for Code PushUp CLI, plugins, etc. + * + * Use {@link logger} singleton. + */ +export class Logger { + #isVerbose = isEnvVarEnabled('CP_VERBOSE'); + #isCI = isEnvVarEnabled('CI'); + #ciPlatform: CiPlatform | undefined = + process.env['GITHUB_ACTIONS'] === 'true' + ? 'GitHub Actions' + : process.env['GITLAB_CI'] === 'true' + ? 'GitLab CI/CD' + : undefined; + #groupColor: GroupColor | undefined = + process.env[GROUP_COLOR_ENV_VAR_NAME] === 'cyan' || + process.env[GROUP_COLOR_ENV_VAR_NAME] === 'magenta' + ? process.env[GROUP_COLOR_ENV_VAR_NAME] + : undefined; + + #groupsCount = 0; + #activeSpinner: Ora | undefined; + #activeSpinnerLogs: string[] = []; + #endsWithBlankLine = false; + + #groupSymbols = { + start: '❯', + middle: '│', + end: '└', + }; + + #sigintListener = () => { + if (this.#activeSpinner != null) { + const text = `${this.#activeSpinner.text} ${ansis.red.bold('[SIGINT]')}`; + if (this.#groupColor) { + this.#activeSpinner.stopAndPersist({ + text, + symbol: this.#colorize(this.#groupSymbols.end, this.#groupColor), + }); + this.#setGroupColor(undefined); + } else { + this.#activeSpinner.fail(text); + } + this.#activeSpinner = undefined; + } + this.newline(); + this.error(ansis.bold('Cancelled by SIGINT')); + process.exit(os.platform() === 'win32' ? 2 : 130); + }; + + /** + * Logs an error to the console (red). + * + * Automatically adapts to logger state if called within {@link task}, {@link group}, etc. + * + * @example + * logger.error('Config file is invalid'); + * + * @param message Error text + */ + error(message: string): void { + this.#log(message, 'red'); + } + + /** + * Logs a warning to the console (yellow). + * + * Automatically adapts to logger state if called within {@link task}, {@link group}, etc. + * + * @example + * logger.warn('Skipping invalid audits'); + * + * @param message Warning text + */ + warn(message: string): void { + this.#log(message, 'yellow'); + } + + /** + * Logs an informational message to the console (unstyled). + * + * Automatically adapts to logger state if called within {@link task}, {@link group}, etc. + * + * @example + * logger.info('Code PushUp CLI v0.80.2'); + * + * @param message Info text + */ + info(message: string): void { + this.#log(message); + } + + /** + * Logs a debug message to the console (gray), but **only if verbose** flag is set (see {@link isVerbose}). + * + * Automatically adapts to logger state if called within {@link task}, {@link group}, etc. + * + * @example + * logger.debug('Running ESLint version 9.16.0'); + * + * @param message Debug text + */ + debug(message: string): void { + if (this.#isVerbose) { + this.#log(message, 'gray'); + } + } + + /** + * Print a blank line to the console, used to separate logs for readability. + * + * Automatically adapts to logger state if called within {@link task}, {@link group}, etc. + * + * @example + * logger.newline(); + */ + newline(): void { + this.#log(''); + } + + /** + * Is verbose flag set? + * + * Verbosity is configured by {@link setVerbose} call or `CP_VERBOSE` environment variable. + * + * @example + * if (logger.isVerbose()) { + * // ... + * } + */ + isVerbose(): boolean { + return this.#isVerbose; + } + + /** + * Sets verbose flag for this logger. + * + * Also sets the `CP_VERBOSE` environment variable. + * This means any future {@link Logger} instantiations (including child processes) will use the same verbosity level. + * + * @example + * logger.setVerbose(process.argv.includes('--verbose')); + * + * @param isVerbose Verbosity level + */ + setVerbose(isVerbose: boolean): void { + process.env['CP_VERBOSE'] = `${isVerbose}`; + this.#isVerbose = isVerbose; + } + + /** + * Animates asynchronous work using a spinner. + * + * Basic logs are supported within the worker function, they will be printed with indentation once the spinner completes. + * + * In CI environments, the spinner animation is disabled, and inner logs are printed immediately. + * + * Spinners may be nested within a {@link group} call, in which case line symbols are used instead of dots, as well the group's color. + * + * The task's duration is included in the logged output as a suffix. + * + * Listens for `SIGINT` event in order to cancel and restore spinner before exiting. + * + * Concurrent or nested spinners are not supported, nor can groups be nested in spinners. + * + * @example + * await logger.task('Uploading report to portal', async () => { + * // ... + * return 'Uploaded report to portal'; + * }); + * + * @param title Display text used as pending message. + * @param worker Asynchronous implementation. Returned promise determines spinner status and final message. Support for inner logs has some limitations (described above). + */ + task(title: string, worker: () => Promise): Promise { + return this.#spinner(worker, { + pending: title, + success: value => value, + failure: error => `${title} → ${ansis.red(`${error}`)}`, + }); + } + + /** + * Similar to {@link task}, but spinner texts are formatted as shell commands. + * + * A `$`-prefix is added. Its color indicates the status (blue=pending, green=success, red=failure). + * + * @example + * await logger.command('npx eslint . --format=json', async () => { + * // ... + * }); + * + * @param bin Command string with arguments. + * @param worker Asynchronous execution of the command (not implemented by the logger). + */ + command(bin: string, worker: () => Promise): Promise { + return this.#spinner(worker, { + pending: `${ansis.blue('$')} ${bin}`, + success: () => `${ansis.green('$')} ${bin}`, + failure: () => `${ansis.red('$')} ${bin}`, + }); + } + + /** + * Groups many logs into a visually distinct section. + * + * Groups alternate prefix colors between cyan and magenta. + * + * The group's total duration is included in the logged output. + * + * Nested groups are not supported. + * + * @example + * await logger.group('Running plugin "ESLint"', async () => { + * logger.debug('ESLint version is 9.16.0'); + * await logger.command('npx eslint . --format=json', () => { + * // ... + * }) + * logger.info('Found 42 lint errors.'); + * return 'Completed "ESLint" plugin execution'; + * }); + * + * @param title Display title for the group. + * @param worker Asynchronous implementation. Returned promise determines group status and ending message. Inner logs are attached to the group. + */ + async group(title: string, worker: () => Promise): Promise { + if (this.#groupColor) { + throw new Error( + 'Internal Logger error - nested groups are not supported', + ); + } + if (this.#activeSpinner) { + throw new Error( + 'Internal Logger error - creating group in active spinner is not supported', + ); + } + + if (!this.#endsWithBlankLine) { + this.newline(); + } + + this.#setGroupColor(this.#groupsCount % 2 === 0 ? 'cyan' : 'magenta'); + this.#groupsCount++; + + const groupMarkers = this.#createGroupMarkers(); + + console.log(groupMarkers.start(title)); + + const start = performance.now(); + const result = await settlePromise(worker()); + const end = performance.now(); + + if (result.status === 'fulfilled') { + console.log( + [ + this.#colorize(this.#groupSymbols.end, this.#groupColor), + this.#colorize(result.value, 'green'), + this.#formatDurationSuffix({ start, end }), + ].join(' '), + ); + } else { + console.log( + [ + this.#colorize(this.#groupSymbols.end, this.#groupColor), + this.#colorize(`${result.reason}`, 'red'), + ].join(' '), + ); + } + + const endMarker = groupMarkers.end(); + if (endMarker) { + console.log(endMarker); + } + this.#setGroupColor(undefined); + this.newline(); + + if (result.status === 'rejected') { + throw result.reason; + } + } + + #createGroupMarkers(): { + start: (title: string) => string; + end: () => string; + } { + switch (this.#ciPlatform) { + case 'GitHub Actions': + // https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands#grouping-log-lines + return { + start: title => + `::group::${this.#formatGroupTitle(title, { prefix: false })}`, + end: () => '::endgroup::', + }; + case 'GitLab CI/CD': + // https://docs.gitlab.com/ci/jobs/job_logs/#custom-collapsible-sections + const ansiEscCode = '\x1b[0K'; // '\e' ESC character only works for `echo -e`, Node console must use '\x1b' + const id = Math.random().toString(16).slice(2); + const sectionId = `code_pushup_logs_group_${id}`; + return { + start: title => { + const sectionHeader = this.#formatGroupTitle(title, { + prefix: true, + }); + const options = this.#isVerbose ? '' : '[collapsed=true]'; + // return `${ansiEscCode}section_start:${dateToUnixTimestamp(new Date())}:${sectionId}${options}${ansiEscCode}${sectionHeader}`; + return `${ansiEscCode}section_start:${dateToUnixTimestamp(new Date())}:${sectionId}${options}\r${ansiEscCode}${sectionHeader}`; + }, + end: () => + // `${ansiEscCode}section_end:${dateToUnixTimestamp(new Date())}:${sectionId}${ansiEscCode}`, + `${ansiEscCode}section_end:${dateToUnixTimestamp(new Date())}:${sectionId}\r${ansiEscCode}`, + }; + case undefined: + return { + start: title => this.#formatGroupTitle(title, { prefix: true }), + end: () => '', + }; + } + } + + #formatGroupTitle(title: string, symbols: { prefix: boolean }): string { + const text = symbols.prefix + ? `${this.#groupSymbols.start} ${title}` + : title; + return ansis.bold(this.#colorize(text, this.#groupColor)); + } + + #setGroupColor(groupColor: GroupColor | undefined) { + this.#groupColor = groupColor; + if (groupColor) { + process.env[GROUP_COLOR_ENV_VAR_NAME] = groupColor; + } else { + delete process.env[GROUP_COLOR_ENV_VAR_NAME]; + } + } + + async #spinner( + worker: () => Promise, + messages: { + pending: string; + success: (value: T) => string; + failure: (error: unknown) => string; + }, + ): Promise { + if (this.#activeSpinner) { + throw new Error( + 'Internal Logger error - concurrent spinners are not supported', + ); + } + + process.removeListener('SIGINT', this.#sigintListener); + process.addListener('SIGINT', this.#sigintListener); + + if (this.#groupColor) { + this.#activeSpinner = ora({ + text: messages.pending, + spinner: 'line', + color: this.#groupColor, + }); + if (this.#isCI) { + console.log(this.#format(messages.pending, undefined)); + } else { + this.#activeSpinner.start(); + } + } else { + this.#activeSpinner = ora(messages.pending); + this.#activeSpinner.start(); + } + + this.#endsWithBlankLine = false; + + const start = performance.now(); + const result = await settlePromise(worker()); + const end = performance.now(); + + const text = + result.status === 'fulfilled' + ? [ + messages.success(result.value), + this.#formatDurationSuffix({ start, end }), + ].join(' ') + : messages.failure(result.reason); + + if (this.#activeSpinner) { + if (this.#groupColor) { + this.#activeSpinner.stopAndPersist({ + text, + symbol: this.#colorize(this.#groupSymbols.middle, this.#groupColor), + }); + } else { + if (result.status === 'fulfilled') { + this.#activeSpinner.succeed(text); + } else { + this.#activeSpinner.fail(text); + } + } + this.#endsWithBlankLine = false; + } + + this.#activeSpinner = undefined; + this.#activeSpinnerLogs.forEach(message => { + this.#log(indentLines(message, 2)); + }); + this.#activeSpinnerLogs = []; + process.removeListener('SIGINT', this.#sigintListener); + + if (result.status === 'rejected') { + throw result.reason; + } + } + + #log(message: string, color?: AnsiColors): void { + if (this.#activeSpinner) { + if (this.#activeSpinner.isSpinning) { + this.#activeSpinnerLogs.push(this.#format(message, color)); + } else { + console.log(this.#format(indentLines(message, 2), color)); + } + } else { + console.log(this.#format(message, color)); + } + this.#endsWithBlankLine = !message || message.endsWith('\n'); + } + + #format(message: string, color: AnsiColors | undefined): string { + if (!this.#groupColor || this.#activeSpinner?.isSpinning) { + return this.#colorize(message, color); + } + return transformLines( + message, + line => + `${this.#colorize('│', this.#groupColor)} ${this.#colorize(line, color)}`, + ); + } + + #colorize(text: string, color: AnsiColors | undefined): string { + if (!color) { + return text; + } + return ansis[color](text); + } + + #formatDurationSuffix({ + start, + end, + }: { + start: number; + end: number; + }): string { + const duration = formatDuration(end - start); + return ansis.gray(`(${duration})`); + } +} + +/** + * Shared {@link Logger} instance. + * + * @example + * import { logger } from '@code-pushup/utils'; + * + * logger.info('Made with ❤️ by Code PushUp'); + */ +export const logger = new Logger(); diff --git a/packages/utils/src/lib/promises.ts b/packages/utils/src/lib/promises.ts index 8216acfe2..9e554a302 100644 --- a/packages/utils/src/lib/promises.ts +++ b/packages/utils/src/lib/promises.ts @@ -28,3 +28,14 @@ export async function asyncSequential( } return results; } + +export async function settlePromise( + promise: Promise, +): Promise> { + try { + const value = await promise; + return { status: 'fulfilled', value }; + } catch (error) { + return { status: 'rejected', reason: error }; + } +} diff --git a/packages/utils/src/lib/promises.unit.test.ts b/packages/utils/src/lib/promises.unit.test.ts index 89434b0bc..9139ba43c 100644 --- a/packages/utils/src/lib/promises.unit.test.ts +++ b/packages/utils/src/lib/promises.unit.test.ts @@ -1,5 +1,5 @@ import { describe } from 'vitest'; -import { asyncSequential, groupByStatus } from './promises.js'; +import { asyncSequential, groupByStatus, settlePromise } from './promises.js'; describe('groupByStatus', () => { it('should group results by status', () => { @@ -50,3 +50,20 @@ describe('asyncSequential', () => { expect(sequentialResult).not.toEqual(parallelResult); }); }); + +describe('settlePromise', () => { + it('should wrap resolved value in object with status (as in `Promise.allSettled`)', async () => { + await expect(settlePromise(Promise.resolve(42))).resolves.toEqual({ + status: 'fulfilled', + value: 42, + }); + }); + + it('should resolve rejected promise', async () => { + const error = new Error('something went wrong'); + await expect(settlePromise(Promise.reject(error))).resolves.toEqual({ + status: 'rejected', + reason: error, + }); + }); +}); diff --git a/packages/utils/src/lib/reports/__snapshots__/generate-md-report.unit.test.ts.snap b/packages/utils/src/lib/reports/__snapshots__/generate-md-report.unit.test.ts.snap index 29b525112..cca882a1e 100644 --- a/packages/utils/src/lib/reports/__snapshots__/generate-md-report.unit.test.ts.snap +++ b/packages/utils/src/lib/reports/__snapshots__/generate-md-report.unit.test.ts.snap @@ -11,7 +11,7 @@ Report was created by [Code PushUp](https://github.com/code-pushup/cli#readme) o | Commit | Version | Duration | Plugins | Categories | Audits | | :----------------------------------------------------------- | :------: | -------: | :-----: | :--------: | :----: | -| ci: update action (535b8e9e557336618a764f3fa45609d224a62837) | \`v1.0.0\` | 4.20 s | 1 | 3 | 3 | +| ci: update action (535b8e9e557336618a764f3fa45609d224a62837) | \`v1.0.0\` | 4.2 s | 1 | 3 | 3 | " `; diff --git a/packages/utils/src/lib/reports/generate-md-report.unit.test.ts b/packages/utils/src/lib/reports/generate-md-report.unit.test.ts index 91f47f7c9..25761d37d 100644 --- a/packages/utils/src/lib/reports/generate-md-report.unit.test.ts +++ b/packages/utils/src/lib/reports/generate-md-report.unit.test.ts @@ -601,7 +601,7 @@ describe('aboutSection', () => { expect(md).toContainMarkdownTableRow([ 'ci: update action (535b8e9e557336618a764f3fa45609d224a62837)', '`v1.0.0`', - '4.20 s', + '4.2 s', '1', '3', '3',