From d1a43f3029d98da4565989b82d0f41d0beed617e Mon Sep 17 00:00:00 2001 From: Tom Lauwaerts Date: Tue, 11 Aug 2026 06:56:49 +0200 Subject: [PATCH 1/5] Refactor reporter to ink --- package-lock.json | 624 ++++++++++++++++++++++++++- package.json | 10 +- src/framework/Framework.ts | 183 +++++--- src/framework/Testee.ts | 242 ++++++----- src/reporter/ArchiveWriter.ts | 38 ++ src/reporter/AutoReporter.ts | 84 ++++ src/reporter/PlainReporter.ts | 140 ++++++ src/reporter/Reporter.ts | 156 ++----- src/reporter/ReporterFactory.ts | 44 ++ src/reporter/ReporterState.ts | 123 ++++++ src/reporter/Results.ts | 2 +- src/reporter/Style.ts | 1 + src/reporter/Summary.ts | 71 +++ src/reporter/index.ts | 3 + src/reporter/ink/App.tsx | 36 ++ src/reporter/ink/FinalSummary.tsx | 96 +++++ src/reporter/ink/InkReporter.ts | 117 +++++ src/reporter/ink/LogPanel.tsx | 29 ++ src/reporter/ink/ProgressSummary.tsx | 22 + src/reporter/ink/RunHeader.tsx | 15 + src/reporter/ink/StatusBadge.tsx | 24 ++ src/reporter/ink/SuiteList.tsx | 18 + src/reporter/ink/SuiteView.tsx | 81 ++++ src/reporter/ink/Tree.tsx | 71 +++ src/reporter/ink/format.ts | 33 ++ src/reporter/ink/verbosity.ts | 13 + tests/unit/reporter.test.ts | 420 ++++++++++++++++++ tests/unit/tree.test.ts | 69 +++ tsconfig.json | 1 + 29 files changed, 2459 insertions(+), 307 deletions(-) create mode 100644 src/reporter/ArchiveWriter.ts create mode 100644 src/reporter/AutoReporter.ts create mode 100644 src/reporter/PlainReporter.ts create mode 100644 src/reporter/ReporterFactory.ts create mode 100644 src/reporter/ReporterState.ts create mode 100644 src/reporter/Summary.ts create mode 100644 src/reporter/ink/App.tsx create mode 100644 src/reporter/ink/FinalSummary.tsx create mode 100644 src/reporter/ink/InkReporter.ts create mode 100644 src/reporter/ink/LogPanel.tsx create mode 100644 src/reporter/ink/ProgressSummary.tsx create mode 100644 src/reporter/ink/RunHeader.tsx create mode 100644 src/reporter/ink/StatusBadge.tsx create mode 100644 src/reporter/ink/SuiteList.tsx create mode 100644 src/reporter/ink/SuiteView.tsx create mode 100644 src/reporter/ink/Tree.tsx create mode 100644 src/reporter/ink/format.ts create mode 100644 src/reporter/ink/verbosity.ts create mode 100644 tests/unit/reporter.test.ts create mode 100644 tests/unit/tree.test.ts diff --git a/package-lock.json b/package-lock.json index 24e6380..2004b02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,20 @@ { "name": "latch", - "version": "0.5.1", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "latch", - "version": "0.5.1", + "version": "0.6.0", "dependencies": { "@thi.ng/leb128": "^3.1.90", "ansi-colors": "^4.1.3", "ieee754": "^1.2.1", + "ink": "^3.2.0", "json-with-bigint": "^3.5.8", "ora": "^9.4.0", + "react": "^17.0.2", "source-map": "^0.7.6", "ts-node": "^10.9.2", "tslib": "^2.8.1" @@ -24,6 +26,7 @@ "@types/chai": "^5.2.3", "@types/mocha": "^10.0.10", "@types/node": "^25.9.1", + "@types/react": "^17.0.83", "@types/uuid": "^11.0.0", "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8.60.1", @@ -32,6 +35,7 @@ "convert-extension": "^0.3.0", "eslint": "^10.4.1", "globals": "^17.6.0", + "ink-testing-library": "^2.1.0", "mqtt": "^5.15.1", "serialport": "^13.0.0", "typescript": "^6.0.3", @@ -1165,6 +1169,25 @@ "undici-types": ">=7.24.0 <7.24.7" } }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "17.0.83", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.83.tgz", + "integrity": "sha512-l0m4ArKJvmFtR4e8UmKrj1pB4tUgOhJITf+mADyF/p69Ts1YAR/E+G9XEM0mHXKVRa1dQNHseyyDNzeuAXfXQw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "^0.16", + "csstype": "^3.0.2" + } + }, "node_modules/@types/readable-stream": { "version": "4.0.23", "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.23.tgz", @@ -1175,6 +1198,13 @@ "@types/node": "*" } }, + "node_modules/@types/scheduler": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", + "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@types/uuid": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-11.0.0.tgz", @@ -1196,6 +1226,12 @@ "@types/node": "*" } }, + "node_modules/@types/yoga-layout": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@types/yoga-layout/-/yoga-layout-1.9.2.tgz", + "integrity": "sha512-S9q47ByT2pPvD65IvrWp7qppVMpk9WGMbVq9wbWZOHg6tnXSD4vyhao6nOSBwwfDdV2p3Kx9evA9vI+XWTfDvw==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.60.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz", @@ -1559,6 +1595,33 @@ "node": ">=6" } }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -1633,6 +1696,15 @@ "node": ">=12" } }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/async-sema": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/async-sema/-/async-sema-3.1.1.tgz", @@ -1640,6 +1712,18 @@ "dev": true, "license": "MIT" }, + "node_modules/auto-bind": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-4.0.0.tgz", + "integrity": "sha512-Hdw8qdNiqdJ8LqT0iK0sVzkFbzg6fhnQqqfWhBDxcHZvU75+B+ayzTy8x+k5Ix0Y92XOhOUlx74ps+bA6BeYMQ==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ava": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/ava/-/ava-8.0.1.tgz", @@ -2093,6 +2177,18 @@ "dev": true, "license": "MIT" }, + "node_modules/cli-boxes": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz", + "integrity": "sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -2258,7 +2354,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -2271,7 +2366,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/commist": { @@ -2452,6 +2546,13 @@ "node": ">= 8" } }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/currently-unhandled": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz", @@ -3208,7 +3309,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3334,6 +3434,301 @@ "dev": true, "license": "ISC" }, + "node_modules/ink": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/ink/-/ink-3.2.0.tgz", + "integrity": "sha512-firNp1q3xxTzoItj/eOOSZQnYSlyrWks5llCTVX37nJ59K3eXbQ8PtzCguqo8YI19EELo5QxaKnJd4VxzhU8tg==", + "license": "MIT", + "dependencies": { + "ansi-escapes": "^4.2.1", + "auto-bind": "4.0.0", + "chalk": "^4.1.0", + "cli-boxes": "^2.2.0", + "cli-cursor": "^3.1.0", + "cli-truncate": "^2.1.0", + "code-excerpt": "^3.0.0", + "indent-string": "^4.0.0", + "is-ci": "^2.0.0", + "lodash": "^4.17.20", + "patch-console": "^1.0.0", + "react-devtools-core": "^4.19.1", + "react-reconciler": "^0.26.2", + "scheduler": "^0.20.2", + "signal-exit": "^3.0.2", + "slice-ansi": "^3.0.0", + "stack-utils": "^2.0.2", + "string-width": "^4.2.2", + "type-fest": "^0.12.0", + "widest-line": "^3.1.0", + "wrap-ansi": "^6.2.0", + "ws": "^7.5.5", + "yoga-layout-prebuilt": "^1.9.6" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": ">=16.8.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/ink-testing-library": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ink-testing-library/-/ink-testing-library-2.1.0.tgz", + "integrity": "sha512-7TNlOjJlJXB33vG7yVa+MMO7hCjaC1bCn+zdpSjknWoLbOWMaFdKc7LJvqVkZ0rZv2+akhjXPrcR/dbxissjUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/ink/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ink/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==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ink/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ink/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==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ink/node_modules/cli-truncate": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", + "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "license": "MIT", + "dependencies": { + "slice-ansi": "^3.0.0", + "string-width": "^4.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink/node_modules/code-excerpt": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-3.0.0.tgz", + "integrity": "sha512-VHNTVhd7KsLGOqfX3SyeO8RyYPMp1GJOg194VITk04WMYCv4plV68YWe6TJZxd9MhobjtpMRnVky01gqZsalaw==", + "license": "MIT", + "dependencies": { + "convert-to-spaces": "^1.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ink/node_modules/convert-to-spaces": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-1.0.2.tgz", + "integrity": "sha512-cj09EBuObp9gZNQCzc7hByQyrs6jVGE+o9kSJmeUoj+GiPiJvi5LYqEH/Hmme4+MTLHM+Ejtq+FChpjjEnsPdQ==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/ink/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/ink/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==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ink/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ink/node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink/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==", + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ink/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==", + "license": "ISC" + }, + "node_modules/ink/node_modules/slice-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", + "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ink/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ink/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==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ink/node_modules/type-fest": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.12.0.tgz", + "integrity": "sha512-53RyidyjvkGpnWPMF9bQgFtWp+Sl8O2Rp13VavmJgfAP9WWG6q6TkrKU8iyJdnwnfgHI6k2hTlgqH4aSdjoTbg==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ink/node_modules/ws": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/ip-address": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", @@ -3357,6 +3752,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", + "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==", + "license": "MIT", + "dependencies": { + "ci-info": "^2.0.0" + }, + "bin": { + "is-ci": "bin.js" + } + }, + "node_modules/is-ci/node_modules/ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "license": "MIT" + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3557,7 +3970,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/jsesc": { @@ -3670,7 +4082,6 @@ "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "dev": true, "license": "MIT" }, "node_modules/log-symbols": { @@ -3689,6 +4100,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3803,6 +4226,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "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==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/mimic-function": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", @@ -4080,6 +4512,15 @@ "js-sdsl": "4.3.0" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -4220,6 +4661,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/patch-console": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-1.0.0.tgz", + "integrity": "sha512-nxl9nrnLQmh64iTzMfyylSlRozL7kAXIaxw1fVcLYdyhNkJCRUzirRZTikXGJsg+hc4fqpneTK6iU2H1Q8THSA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -4387,6 +4837,67 @@ ], "license": "MIT" }, + "node_modules/react": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", + "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-devtools-core": { + "version": "4.28.5", + "resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-4.28.5.tgz", + "integrity": "sha512-cq/o30z9W2Wb4rzBefjv5fBalHU0rJGZCHAkf/RHSBWSSYwh8PlQTqqOJmgIIbBtpj27T6FIPXeomIjZtCNVqA==", + "license": "MIT", + "dependencies": { + "shell-quote": "^1.6.1", + "ws": "^7" + } + }, + "node_modules/react-devtools-core/node_modules/ws": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/react-reconciler": { + "version": "0.26.2", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.26.2.tgz", + "integrity": "sha512-nK6kgY28HwrMNwDnMui3dvm3rCFjZrcGiuwLc5COUipBK5hWHLOxMJhSnSomirqWwjPBJKV1QcbkI0VJr7Gl1Q==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "scheduler": "^0.20.2" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^17.0.2" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -4514,6 +5025,16 @@ ], "license": "MIT" }, + "node_modules/scheduler": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", + "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, "node_modules/semver": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz", @@ -4613,6 +5134,18 @@ "node": ">=8" } }, + "node_modules/shell-quote": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz", + "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -4711,7 +5244,6 @@ "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", - "dev": true, "license": "MIT", "dependencies": { "escape-string-regexp": "^2.0.0" @@ -4724,7 +5256,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4840,7 +5371,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -5226,6 +5756,68 @@ "node": ">= 8" } }, + "node_modules/widest-line": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", + "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==", + "license": "MIT", + "dependencies": { + "string-width": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/widest-line/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/widest-line/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/widest-line/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/widest-line/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/widest-line/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==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -5499,6 +6091,18 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/yoga-layout-prebuilt": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/yoga-layout-prebuilt/-/yoga-layout-prebuilt-1.10.0.tgz", + "integrity": "sha512-YnOmtSbv4MTf7RGJMK0FvZ+KD8OEe/J5BNnR0GHhD8J/XcG/Qvxgszm0Un6FTHWW4uHlTgP0IztiXQnGyIR45g==", + "license": "MIT", + "dependencies": { + "@types/yoga-layout": "1.9.2" + }, + "engines": { + "node": ">=8" + } } } } diff --git a/package.json b/package.json index 7854d2d..ab6b774 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,10 @@ "@thi.ng/leb128": "^3.1.90", "ansi-colors": "^4.1.3", "ieee754": "^1.2.1", + "ink": "^3.2.0", "json-with-bigint": "^3.5.8", "ora": "^9.4.0", + "react": "^17.0.2", "source-map": "^0.7.6", "ts-node": "^10.9.2", "tslib": "^2.8.1" @@ -41,6 +43,7 @@ "@types/chai": "^5.2.3", "@types/mocha": "^10.0.10", "@types/node": "^25.9.1", + "@types/react": "^17.0.83", "@types/uuid": "^11.0.0", "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8.60.1", @@ -49,6 +52,7 @@ "convert-extension": "^0.3.0", "eslint": "^10.4.1", "globals": "^17.6.0", + "ink-testing-library": "^2.1.0", "mqtt": "^5.15.1", "serialport": "^13.0.0", "typescript": "^6.0.3", @@ -56,11 +60,7 @@ }, "ava": { "files": [ - "out/tests/unit/describers.test.js", - "out/tests/unit/messaging.test.js", - "out/tests/unit/parsing.test.js", - "out/tests/unit/sourcemap.test.js", - "out/tests/unit/util.test.js" + "out/tests/unit/**/*.test.js" ], "typescript": { "compile": false, diff --git a/src/framework/Framework.ts b/src/framework/Framework.ts index 38193eb..38eae40 100644 --- a/src/framework/Framework.ts +++ b/src/framework/Framework.ts @@ -7,6 +7,7 @@ import {TestbedSpecification} from '../testbeds/TestbedSpecification'; import {SuiteResult} from '../reporter/Results'; import {Reporter} from '../reporter/Reporter'; import {Outcome} from "../reporter/describers/Describer"; +import {AutoReporter} from '../reporter/AutoReporter'; export interface TesteeOptions { disabled?: boolean; @@ -51,7 +52,7 @@ export class Framework { private scheduled: Suite[] = []; - public readonly reporter: Reporter = new Reporter(); + public readonly reporter: Reporter = new AutoReporter(); private constructor() { } @@ -65,89 +66,103 @@ export class Framework { } public async sequential(suites: Suite[]) { - this.scheduled.concat(suites); - this.reporter.general(); + this.scheduled = this.scheduled.concat(suites); + this.reporter.start(); const t0 = performance.now(); - for (const suite of suites) { - for (const testee of suite.testees) { - const order: TestScenario[] = suite.scheduler.sequential(suite); - const result: SuiteResult = new SuiteResult(suite); - - const first: TestScenario = order[0]; - await timeout('Initialize testbed', testee.connector.timeout, testee.initialize(first.program, first.args ?? []).catch((e: Error) => result.error(e.message))); - - await this.runSuite(result, testee, order); - this.reporter.report(result); + try { + let executionIndex = 0; + for (const suite of suites) { + for (const testee of suite.testees) { + const order: TestScenario[] = suite.scheduler.sequential(suite); + await this.executeSuite(suite, testee, order, ++executionIndex); + } } - } - const t1 = performance.now(); - this.reporter.results(t1 - t0); - await Promise.all(suites.map(suite => suite.testees.map(async (testee: Testee) => { - await timeout('Shutdown testbed', testee.timeout, testee.shutdown()); - }))) + await this.shutdown(suites); + } finally { + const t1 = performance.now(); + this.reporter.finish(t1 - t0); + await this.reporter.close(); + } } public async run(suites: Suite[]): Promise { let success: boolean = true; - this.scheduled.concat(suites); - this.reporter.general(); + this.scheduled = this.scheduled.concat(suites); + this.reporter.start(); const t0 = performance.now(); - await Promise.all(suites.map(async (suite: Suite) => { - await Promise.all(suite.testees.map(async (testee: Testee) => { - const order: TestScenario[] = suite.scheduler.sequential(suite); - const result: SuiteResult = new SuiteResult(suite); - - const first: TestScenario = order[0]; - await timeout('Initialize testbed', testee.connector.timeout, testee.initialize(first.program, first.args ?? []).catch((e: Error) => result.error(e.message))); - - await this.runSuite(result, testee, order); - this.reporter.report(result); - success = success && result.outcome === Outcome.succeeded; + try { + let executionIndex = 0; + await Promise.all(suites.map(async (suite: Suite) => { + await Promise.all(suite.testees.map(async (testee: Testee) => { + const order: TestScenario[] = suite.scheduler.sequential(suite); + const result = await this.executeSuite(suite, testee, order, ++executionIndex); + success = success && result.outcome === Outcome.succeeded; + })) })) - })) - const t1 = performance.now(); - this.reporter.results(t1 - t0); - await Promise.all(suites.map(suite => suite.testees.map(async (testee: Testee) => { - await timeout('Shutdown testbed', testee.timeout, testee.shutdown()); - }))) + await this.shutdown(suites); - return success; + return success; + } finally { + const t1 = performance.now(); + this.reporter.finish(t1 - t0); + await this.reporter.close(); + } } public async parallel(suites: Suite[]) { - this.scheduled.concat(suites); - this.reporter.general(); + this.scheduled = this.scheduled.concat(suites); + this.reporter.start(); const t0 = performance.now(); - await Promise.all(suites.map(async (suite: Suite) => { - const order: TestScenario[][] = suite.scheduler.parallel(suite, suite.testees.length); - await Promise.all(suite.testees.map(async (testee: Testee, i: number) => { - // console.log(`scheduling on ${testee.name}`) - const result: SuiteResult = new SuiteResult(suite); - - const first: TestScenario = order[i][0]; - await timeout('Initialize testbed', testee.connector.timeout, testee.initialize(first.program, first.args ?? []).catch((e: Error) => result.error(e.message))); - - for (let j = i; j < order.length; j += suite.testees.length) { - await this.runSuite(result, testee, order[j]); - } - this.reporter.report(result); + try { + let executionIndex = 0; + await Promise.all(suites.map(async (suite: Suite) => { + const order: TestScenario[][] = suite.scheduler.parallel(suite, suite.testees.length); + await Promise.all(suite.testees.map(async (testee: Testee, i: number) => { + const result: SuiteResult = new SuiteResult(suite); + const runId = this.runId(suite, testee, ++executionIndex); + + this.reporter.suiteStarted({ + id: runId, + suite: result, + suiteTitle: suite.title, + testeeName: testee.name, + executionIndex, + startedAt: Date.now(), + plannedScenarios: order.flat().length, + plannedActions: order.flat().flatMap((scenario) => scenario.steps ?? []).length + }); + + try { + const first: TestScenario = order[i][0]; + await timeout('Initialize testbed', testee.connector.timeout, testee.initialize(first.program, first.args ?? []).catch((e: Error) => result.error(e.message))); + + for (let j = i; j < order.length; j += suite.testees.length) { + await this.runSuite(result, testee, order[j], runId); + } + } catch (e) { + result.error(e instanceof Error ? e.message : `${e}`); + } finally { + this.reporter.suiteFinished(runId, result); + } + })) + + await Promise.all(suite.testees.map(async (testee: Testee) => { + await timeout('Shutdown testbed', testee.timeout, testee.shutdown()); + })) })) - - await Promise.all(suite.testees.map(async (testee: Testee) => { - await timeout('Shutdown testbed', testee.timeout, testee.shutdown()); - })) - })) - - const t1 = performance.now(); - this.reporter.results(t1 - t0); + } finally { + const t1 = performance.now(); + this.reporter.finish(t1 - t0); + await this.reporter.close(); + } } - private async runSuite(result: SuiteResult, testee: Testee, order: TestScenario[]) { + private async runSuite(result: SuiteResult, testee: Testee, order: TestScenario[], runId: string) { for (const test of order) { - await testee.describe(test, result, this.runs); + await testee.describe(test, result, runId, this.runs); } } @@ -156,6 +171,44 @@ export class Framework { this.run(suite).then((success: boolean) => process.exit(success ? 0 : 1)); } + private async executeSuite(suite: Suite, testee: Testee, order: TestScenario[], executionIndex: number): Promise { + const result: SuiteResult = new SuiteResult(suite); + const runId = this.runId(suite, testee, executionIndex); + + this.reporter.suiteStarted({ + id: runId, + suite: result, + suiteTitle: suite.title, + testeeName: testee.name, + executionIndex, + startedAt: Date.now(), + plannedScenarios: order.length, + plannedActions: order.flatMap((scenario) => scenario.steps ?? []).length + }); + + try { + const first: TestScenario = order[0]; + await timeout('Initialize testbed', testee.connector.timeout, testee.initialize(first.program, first.args ?? []).catch((e: Error) => result.error(e.message))); + await this.runSuite(result, testee, order, runId); + } catch (e) { + result.error(e instanceof Error ? e.message : `${e}`); + } finally { + this.reporter.suiteFinished(runId, result); + } + + return result; + } + + private async shutdown(suites: Suite[]) { + await Promise.all(suites.flatMap(suite => suite.testees.map(async (testee: Testee) => { + await timeout('Shutdown testbed', testee.timeout, testee.shutdown()); + }))); + } + + private runId(suite: Suite, testee: Testee, executionIndex: number): string { + return `${suite.title}:${testee.name}:${executionIndex}`; + } + public static getImplementation() { if (!Framework.implementation) { Framework.implementation = new Framework(); @@ -163,4 +216,4 @@ export class Framework { return Framework.implementation; } -} \ No newline at end of file +} diff --git a/src/framework/Testee.ts b/src/framework/Testee.ts index 3f265bf..4e434ca 100644 --- a/src/framework/Testee.ts +++ b/src/framework/Testee.ts @@ -141,122 +141,172 @@ export class Testee { // TODO unified with testbed interface return timeout(name, limit, fn()); } - public async describe(description: TestScenario, suiteResult: SuiteResult, runs: number = 1) { + public async describe(description: TestScenario, suiteResult: SuiteResult, runId: string, runs: number = 1) { const testee = this; const scenarioResult: ScenarioResult = new ScenarioResult(description); + const reporter = this.framework.reporter; + let addedToSuite = false; + + const addScenario = () => { + if (!addedToSuite) { + suiteResult.add(scenarioResult); + addedToSuite = true; + } + }; + + const addStep = (result: StepOutcome) => { + scenarioResult.add(result); + reporter.stepFinished(runId, scenarioResult, result); + }; + const scenarioHasError = () => scenarioResult.outcome === Outcome.error; + + reporter.scenarioStarted(runId, scenarioResult); if (description.skip) { + scenarioResult.update(Outcome.skipped, 'Skipped by scenario configuration'); + addScenario(); + reporter.scenarioFinished(runId, scenarioResult); return; } - // call(this.formatTitle(description.title), function () { - let map: SourceMap.Mapping = new SourceMap.Mapping(); + try { + // call(this.formatTitle(description.title), function () { + let map: SourceMap.Mapping = new SourceMap.Mapping(); - /** Each test requires some housekeeping before and after */ - await this.run('Check for failing dependencies', testee.timeout, async function () { - const failedDependencies: TestScenario[] = testee.failedDependencies(description); - if (failedDependencies.length > 0) { - testee.states.set(description.title, new Skipped('Skipping', 'Test has failing dependencies')); - throw new Error(`Skipped: failed dependent tests: ${failedDependencies.map(dependence => dependence.title)}`); - } - }).catch((e: Error) => { - scenarioResult.error(e.message); - }); + /** Each test requires some housekeeping before and after */ + await this.run('Check for failing dependencies', testee.timeout, async function () { + const failedDependencies: TestScenario[] = testee.failedDependencies(description); + if (failedDependencies.length > 0) { + testee.states.set(description.title, new Skipped('Skipping', 'Test has failing dependencies')); + throw new Error(`Skipped: failed dependent tests: ${failedDependencies.map(dependence => dependence.title)}`); + } + }).catch((e: Error) => { + scenarioResult.error(e.message); + }); - await this.run('Compile and upload program', testee.connector.timeout, async function () { - if (testee.current === description.program) { - await testee.reset(testee.testbed); + if (scenarioHasError()) { + addScenario(); return; } - const compiled: CompileOutput = await new CompilerFactory(WABT).pickCompiler(description.program).compile(description.program); - try { - await timeout(`uploading module`, testee.timeout, testee.bed()!.sendRequest(new SourceMap.Mapping(), Message.updateModule(compiled.file))).catch((e) => Promise.reject(e)); - testee.current = description.program; - } catch { - await testee.initialize(description.program, description.args ?? []).catch((o) => Promise.reject(o)); - } - }).catch((e: Error | string) => { - if (typeof e === 'string') { - scenarioResult.error(e); - } else { - scenarioResult.error(e.toString()); - } - }); - - await this.run('Get source mapping', testee.connector.timeout, async function () { - map = await testee.mapper.map(description.program); - }).catch((e: Error | string) => { - if (typeof e === 'string') { - scenarioResult.error(e); - } else { - scenarioResult.error(e.toString()); - } - }); + await this.run('Compile and upload program', testee.connector.timeout, async function () { + if (testee.current === description.program) { + await testee.reset(testee.testbed); + return; + } - if (scenarioResult.outcome === Outcome.error) { - suiteResult.add(scenarioResult); - return; - } + const compiled: CompileOutput = await new CompilerFactory(WABT).pickCompiler(description.program).compile(description.program); + try { + await timeout(`uploading module`, testee.timeout, testee.bed()!.sendRequest(new SourceMap.Mapping(), Message.updateModule(compiled.file))).catch((e) => Promise.reject(e)); + testee.current = description.program; + } catch { + await testee.initialize(description.program, description.args ?? []).catch((o) => Promise.reject(o)); + } + }).catch((e: Error | string) => { + if (typeof e === 'string') { + scenarioResult.error(e); + } else { + scenarioResult.error(e.toString()); + } + }); - /** Each test is made of one or more scenario */ + if (scenarioHasError()) { + addScenario(); + return; + } - let previous: any = undefined; - for (let i = 0; i < runs; i++) { - if (0 < i) { - await this.run('resetting before retry', testee.timeout, async function () { - await testee.reset(testee.testbed); - }).catch((e: Error) => { + await this.run('Get source mapping', testee.connector.timeout, async function () { + map = await testee.mapper.map(description.program); + }).catch((e: Error | string) => { + if (typeof e === 'string') { + scenarioResult.error(e); + } else { scenarioResult.error(e.toString()); - }); + } + }); + + if (scenarioHasError()) { + addScenario(); + return; } - for (const step of description.steps ?? []) { - const verifier: Verifier = new Verifier(step); - - /** Perform the step and check if expectations were met */ - await this.step(step.title, testee.timeout, async function () { - if (testee.bed(step.target ?? Target.supervisor) === undefined) { - testee.states.set(description.title, verifier.error('Cannot run test: no debugger connection.')); - return; - } - - let actual: object | void; - if (step.instruction.kind === Kind.Action) { - actual = await timeout(`performing action . ${step.title}`, testee.timeout, - step.instruction.value.act(testee)).catch((err) => { - testee.states.set(description.title, verifier.error(err)); + /** Each test is made of one or more scenario */ + + let previous: any = undefined; + for (let i = 0; i < runs; i++) { + if (0 < i) { + await this.run('resetting before retry', testee.timeout, async function () { + await testee.reset(testee.testbed); + }).catch((e: Error) => { + scenarioResult.error(e.toString()); + }); + } + + for (const step of description.steps ?? []) { + const verifier: Verifier = new Verifier(step); + let stepRecorded = false; + const recordStep = (result: StepOutcome) => { + if (!stepRecorded) { + stepRecorded = true; + addStep(result); + } + }; + + /** Perform the step and check if expectations were met */ + await this.step(step.title, testee.timeout, async function () { + if (testee.bed(step.target ?? Target.supervisor) === undefined) { + const result = verifier.error('Cannot run test: no debugger connection.'); + testee.states.set(description.title, result); + recordStep(result); return; - }); - } else { - actual = await testee.recoverable(testee, step.instruction.value, map, - (testee, req, map) => timeout(`sending instruction ${req.type}`, testee.timeout, - testee.bed(step.target ?? Target.supervisor)!.sendRequest(map, req)), - (testee) => testee.run(`Recover: re-initialize ${testee.testbed?.name}`, testee.connector.timeout, async function () { - await testee.initialize(description.program, description.args ?? []).catch((o) => { - return Promise.reject(o) - }); - }), 1).catch((e: string) => { - const result = new StepOutcome(step); - testee.states.set(description.title, result.update((e.includes('timeout')) ? Outcome.timedout : Outcome.error, e)); - }); - } - - const result = verifier.verify(actual, previous); - - if (actual !== undefined) { - previous = actual; - } - - testee.states.set(description.title, result); - scenarioResult.add(result); - }).catch((error: Error | string) => { - const result = verifier.error(stringify(error)); - testee.states.set(description.title, result); - scenarioResult.add(result); - }); + } + + let actual: object | void; + if (step.instruction.kind === Kind.Action) { + actual = await timeout(`performing action . ${step.title}`, testee.timeout, + step.instruction.value.act(testee)).catch((err) => { + const result = verifier.error(stringify(err)); + testee.states.set(description.title, result); + recordStep(result); + return; + }); + } else { + actual = await testee.recoverable(testee, step.instruction.value, map, + (testee, req, map) => timeout(`sending instruction ${req.type}`, testee.timeout, + testee.bed(step.target ?? Target.supervisor)!.sendRequest(map, req)), + (testee) => testee.run(`Recover: re-initialize ${testee.testbed?.name}`, testee.connector.timeout, async function () { + await testee.initialize(description.program, description.args ?? []).catch((o) => { + return Promise.reject(o) + }); + }), 1).catch((e: string) => { + const result = new StepOutcome(step); + testee.states.set(description.title, result.update((e.includes('timeout')) ? Outcome.timedout : Outcome.error, e)); + recordStep(result); + }); + } + + if (stepRecorded) { + return; + } + + const result = verifier.verify(actual, previous); + + if (actual !== undefined) { + previous = actual; + } + + testee.states.set(description.title, result); + recordStep(result); + }).catch((error: Error | string) => { + const result = verifier.error(stringify(error)); + testee.states.set(description.title, result); + recordStep(result); + }); + } } - suiteResult.add(scenarioResult); + addScenario(); + } finally { + reporter.scenarioFinished(runId, scenarioResult); } } diff --git a/src/reporter/ArchiveWriter.ts b/src/reporter/ArchiveWriter.ts new file mode 100644 index 0000000..f93da98 --- /dev/null +++ b/src/reporter/ArchiveWriter.ts @@ -0,0 +1,38 @@ +import {Archiver} from '../framework/Archiver'; +import {SuiteResult} from './Results'; +import {summarize} from './Summary'; +import {Outcome} from './describers/Describer'; + +export class ArchiveWriter { + private readonly archiver: Archiver; + + constructor(now: number = Date.now()) { + this.archiver = new Archiver(`${process.env.TESTFILE?.replace('.asserts.wast', '.wast') ?? 'suite'}.${now}.log`); + this.archiver.set('date', new Date(now).toISOString()); + } + + get archive(): string { + return this.archiver.archive; + } + + write(durationMs: number, suites: SuiteResult[]) { + const summary = summarize(suites); + const scenarios = suites.flatMap((suite) => suite.outcomes()); + + this.archiver.set('duration (ms)', Math.round(durationMs)); + + scenarios + .filter((scenario) => scenario.outcome === Outcome.failed) + .forEach((scenario) => this.archiver.extend('failures', scenario.name)); + + scenarios + .filter((scenario) => scenario.outcome === Outcome.succeeded) + .forEach((scenario) => this.archiver.extend('passes', scenario.name)); + + this.archiver.set('passed scenarios', summary.scenarios.passing); + this.archiver.set('skipped scenarios', summary.scenarios.skipped); + this.archiver.set('failed scenarios', summary.scenarios.failing); + + this.archiver.write(); + } +} diff --git a/src/reporter/AutoReporter.ts b/src/reporter/AutoReporter.ts new file mode 100644 index 0000000..ad0da24 --- /dev/null +++ b/src/reporter/AutoReporter.ts @@ -0,0 +1,84 @@ +import {Reporter, SuiteRun} from './Reporter'; +import {ReporterFactory, ReporterSelection} from './ReporterFactory'; +import {ScenarioResult, StepOutcome, SuiteResult} from './Results'; +import {StyleType, Verbosity} from './index'; + +export class AutoReporter implements Reporter { + private selected?: Reporter; + private styleType: StyleType; + private verbosityLevel: Verbosity; + + constructor(style: StyleType = StyleType.plain, verbosity: Verbosity = Verbosity.normal, private readonly selection: ReporterSelection = ReporterSelection.auto) { + this.styleType = style; + this.verbosityLevel = verbosity; + } + + style(type: StyleType) { + this.styleType = type; + this.selected?.style(type); + } + + styling(): StyleType { + return this.selected?.styling() ?? this.styleType; + } + + verbosity(level: Verbosity) { + this.verbosityLevel = level; + this.selected?.verbosity(level); + } + + start() { + if (!this.selected) { + this.selected = ReporterFactory.create(this.styleType, this.verbosityLevel, this.selection); + } + this.selected.start(); + } + + suiteStarted(run: SuiteRun) { + this.delegate().suiteStarted(run); + } + + scenarioStarted(runId: string, scenario: ScenarioResult) { + this.delegate().scenarioStarted(runId, scenario); + } + + stepFinished(runId: string, scenario: ScenarioResult, step: StepOutcome) { + this.delegate().stepFinished(runId, scenario, step); + } + + scenarioFinished(runId: string, scenario: ScenarioResult) { + this.delegate().scenarioFinished(runId, scenario); + } + + suiteFinished(runId: string, suite: SuiteResult) { + this.delegate().suiteFinished(runId, suite); + } + + info(text: string) { + this.delegate().info(text); + } + + error(text: string) { + this.delegate().error(text); + } + + debug(text: string) { + this.delegate().debug(text); + } + + finish(durationMs: number) { + this.delegate().finish(durationMs); + } + + close(): Promise { + return this.delegate().close(); + } + + private delegate(): Reporter { + if (!this.selected) { + this.selected = ReporterFactory.create(this.styleType, this.verbosityLevel, this.selection); + } + + return this.selected; + } +} diff --git a/src/reporter/PlainReporter.ts b/src/reporter/PlainReporter.ts new file mode 100644 index 0000000..ca04805 --- /dev/null +++ b/src/reporter/PlainReporter.ts @@ -0,0 +1,140 @@ +import {green, red, yellow} from 'ansi-colors'; +import {version} from '../../package.json'; +import {indent} from '../util/printing'; +import {ArchiveWriter} from './ArchiveWriter'; +import {Reporter, SuiteRun} from './Reporter'; +import {ScenarioResult, StepOutcome, SuiteResult} from './Results'; +import {Style, styling as styleMap} from './Style'; +import {summarize} from './Summary'; +import {Outcome, SilentDescriber} from './describers/Describer'; +import { + MinimalSuiteDescriber, + NormalSuiteDescriber, + ShortSuiteDescriber, + SuiteDescriber +} from './describers/SuiteDescribers'; +import {StyleType, Verbosity} from './index'; + +function describer(verbosity: Verbosity, item: SuiteResult): SuiteDescriber { + switch (verbosity) { + case Verbosity.none: + return new SilentDescriber(item); + case Verbosity.minimal: + return new MinimalSuiteDescriber(item); + case Verbosity.short: + return new ShortSuiteDescriber(item); + case Verbosity.normal: + case Verbosity.more: + case Verbosity.all: + case Verbosity.debug: + default: + return new NormalSuiteDescriber(item); + } +} + +export class PlainReporter implements Reporter { + private output: string = ''; + private indentationLevel: number = 2; + private readonly suites: SuiteResult[] = []; + private readonly archiveWriter: ArchiveWriter; + private design: Style; + private verboseness: Verbosity; + + constructor(style: StyleType = StyleType.plain, verbosity: Verbosity = Verbosity.normal, archiveWriter: ArchiveWriter = new ArchiveWriter()) { + this.design = styleMap(style); + this.verboseness = verbosity; + this.archiveWriter = archiveWriter; + } + + private indent(override?: number) { + return indent(override ?? this.indentationLevel, this.design.indentation); + } + + style(type: StyleType) { + this.design = styleMap(type); + } + + styling(): StyleType { + return this.design.type; + } + + verbosity(level: Verbosity) { + this.verboseness = level; + } + + start() { + console.log(this.indent() + this.design.colors.highlight(this.design.bullet) + this.design.colors.highlight('latch.') + this.design.emph(' General information')); + console.log(this.indent() + ' '.repeat(2) + this.design.emph('version') + ' '.repeat(5) + version); + console.log(this.indent() + ' '.repeat(2) + this.design.emph('archive') + ' '.repeat(5) + this.archiveWriter.archive); + console.log(this.design.end); + } + + suiteStarted(_run: SuiteRun) { + // Plain output remains suite-buffered for readable CI/non-TTY logs. + } + + scenarioStarted(_runId: string, _scenario: ScenarioResult) { + // Plain output remains suite-buffered for readable CI/non-TTY logs. + } + + stepFinished(_runId: string, _scenario: ScenarioResult, _step: StepOutcome) { + // Plain output remains suite-buffered for readable CI/non-TTY logs. + } + + scenarioFinished(_runId: string, _scenario: ScenarioResult) { + // Plain output remains suite-buffered for readable CI/non-TTY logs. + } + + suiteFinished(_runId: string, suiteResult: SuiteResult) { + this.suites.push(suiteResult); + const report: string[] = describer(this.verboseness, suiteResult).describe(this.design); + + for (const line of report) { + console.log(this.indent() + line); + } + console.log(this.design.end); + } + + finish(durationMs: number) { + this.archiveWriter.write(durationMs, this.suites); + const summary = summarize(this.suites); + + console.log(this.indent() + this.design.colors.highlight(this.design.bullet) + this.design.colors.highlight('results.') + this.design.emph(' Overview')); + console.log(); + this.indentationLevel += 1; + + const len: number = 12; + const suitePassing = `${summary.suites.passing} passing`; + const scenarioPassing = `${summary.scenarios.passing} passing`; + const actionPassing = `${summary.actions.passing} passing`; + + console.log(this.indent() + this.design.emph('Test suites:') + ' '.repeat(len - suitePassing.length) + this.design.emph((summary.suites.passing === summary.suites.total ? green : red)(suitePassing)) + `, ${summary.suites.total} total` + this.design.emph(` (${durationMs.toFixed(0)}ms)`)); + if (this.verboseness > Verbosity.minimal) { + console.log(this.indent() + this.design.emph('Scenarios:') + + ' '.repeat(2 + len - scenarioPassing.length) + this.design.emph((summary.scenarios.passing === summary.scenarios.total ? green : red)(scenarioPassing)) + + (summary.scenarios.skipped > 0 ? ', ' + this.design.emph(yellow(`${summary.scenarios.skipped} skipped`)) : '') + `, ${summary.scenarios.total} total`); + console.log(this.indent() + this.design.emph('Actions:') + ' '.repeat(4 + len - actionPassing.length) + this.design.emph((summary.actions.passing === summary.actions.total ? green : red)(actionPassing)) + (summary.actions.timeouts > 0 ? `, ${summary.actions.timeouts} timeouts` : '') + `, ${summary.actions.total} total`); + } + this.indentationLevel -= 1; + + console.log(this.design.end); + } + + info(text: string) { + this.output += `info: ${text}\n`; + } + + error(text: string) { + this.output += `error: ${text}\n`; + } + + debug(text: string) { + if (this.verboseness === Verbosity.debug) { + console.debug(text); + } + } + + async close(): Promise { + return Promise.resolve(); + } +} diff --git a/src/reporter/Reporter.ts b/src/reporter/Reporter.ts index 2a235c2..7da1b83 100644 --- a/src/reporter/Reporter.ts +++ b/src/reporter/Reporter.ts @@ -1,147 +1,43 @@ -import {SuiteResult} from './Results'; -import {Archiver} from '../framework/Archiver'; -import {Style, styling as styleMap} from './Style'; +import {ScenarioResult, StepOutcome, SuiteResult} from './Results'; import {StyleType, Verbosity} from './index'; -import {version} from '../../package.json'; -import {green, red, yellow} from 'ansi-colors'; -import {Outcome, SilentDescriber} from './describers/Describer'; -import {indent} from '../util/printing'; -import { - MinimalSuiteDescriber, - NormalSuiteDescriber, - ShortSuiteDescriber, - SuiteDescriber -} from './describers/SuiteDescribers'; -function describer(verbosity: Verbosity, item: SuiteResult): SuiteDescriber { - switch (verbosity) { - case Verbosity.none: - return new SilentDescriber(item); - case Verbosity.minimal: - return new MinimalSuiteDescriber(item); - case Verbosity.short: - return new ShortSuiteDescriber(item); - case Verbosity.normal: - case Verbosity.more: - case Verbosity.all: - case Verbosity.debug: - default: - return new NormalSuiteDescriber(item); - } +export interface SuiteRun { + id: string; + suite: SuiteResult; + suiteTitle: string; + testeeName: string; + executionIndex: number; + startedAt: number; + plannedScenarios?: number; + plannedActions?: number; } -export class Reporter { - private output: string = ''; +export interface Reporter { + start(): void; - private indentationLevel: number = 2; + suiteStarted(run: SuiteRun): void; - private suites: SuiteResult[] = []; + scenarioStarted(runId: string, scenario: ScenarioResult): void; - private archiver: Archiver; + stepFinished(runId: string, scenario: ScenarioResult, step: StepOutcome): void; - private design: Style; + scenarioFinished(runId: string, scenario: ScenarioResult): void; - private verboseness: Verbosity; + suiteFinished(runId: string, suite: SuiteResult): void; - constructor(style: StyleType = StyleType.plain, verbosity: Verbosity = Verbosity.normal) { - this.design = styleMap(style); - this.verboseness = verbosity; - this.archiver = new Archiver(`${process.env.TESTFILE?.replace('.asserts.wast', '.wast') ?? 'suite'}.${Date.now()}.log`); - this.archiver.set('date', new Date(Date.now()).toISOString()); - } + info(text: string): void; - private indent(override?: number) { - return indent(override ?? this.indentationLevel, this.design.indentation); - } + error(text: string): void; - style(type: StyleType) { - this.design = styleMap(type); - } + debug(text: string): void; - styling(): StyleType { - return this.design.type; - } + finish(durationMs: number): void; - verbosity(level: Verbosity) { - this.verboseness = level; - } + close(): Promise; - general() { - console.log(this.indent() + this.design.colors.highlight(this.design.bullet) + this.design.colors.highlight('latch.') + this.design.emph(' General information')); - // console.log(blue(`${this.indent()}===================`)); - console.log(this.indent() + ' '.repeat(2) + this.design.emph('version') + ' '.repeat(5) + version); - console.log(this.indent() + ' '.repeat(2) + this.design.emph('archive') + ' '.repeat(5) + this.archiver.archive); - console.log(); - } + style(type: StyleType): void; - report(suiteResult: SuiteResult) { - this.suites.push(suiteResult); - const report: string[] = describer(this.verboseness, suiteResult).describe(this.design); + styling(): StyleType; - for (const line of report) { - console.log(this.indent() + line); - } - console.log(); - } - - results(time: number) { - this.archiver.set('duration (ms)', Math.round(time)); - - const passing = this.suites.flatMap((suite) => suite.outcomes()).filter((scenario) => scenario.outcome === Outcome.succeeded).length; - const failing = this.suites.flatMap((suite) => suite.outcomes()).filter((scenario) => scenario.outcome === Outcome.failed).length; - const skipped = this.suites.flatMap((suite) => suite.outcomes()).filter((scenario) => scenario.outcome === Outcome.skipped).length; - - const scs = this.suites.flatMap((suite) => suite.outcomes()); - - this.suites.flatMap((suite) => suite.outcomes()).filter((scenario) => scenario.outcome === Outcome.failed).forEach((scenario) => this.archiver.extend('failures', scenario.name)); - this.suites.flatMap((suite) => suite.outcomes()).filter((scenario) => scenario.outcome === Outcome.succeeded).forEach((scenario) => this.archiver.extend('passes', scenario.name)); - - this.archiver.set('passed scenarios', passing); - this.archiver.set('skipped scenarios', skipped); - this.archiver.set('failed scenarios', failing); - - console.log(this.indent() + this.design.colors.highlight(this.design.bullet) + this.design.colors.highlight('results.') + this.design.emph(' Overview')); - console.log(); - this.indentationLevel += 1; - - const sc = this.suites.filter((suite) => suite.outcome === Outcome.succeeded).length; - const tl = this.suites.length; - - const psa = this.suites.flatMap((suite) => suite.outcomes()).flatMap((scenario) => - scenario.outcomes().filter((result) => - result.outcome === Outcome.succeeded).length).reduce((acc, val) => acc + val, 0); - const timeouts = this.suites.flatMap((suite) => suite.outcomes()).flatMap((scenario) => - scenario.outcomes().filter((result) => - result.outcome === Outcome.timedout).length).reduce((acc, val) => acc + val, 0); - const total = this.suites.flatMap((suite) => suite.outcomes()).flatMap((scenario) => - scenario.outcomes().length ?? 0).reduce((acc, val) => acc + val, 0); - - const len: number = 12; - const pss = [`${sc} passing`, `${passing} passing`, `${psa} passing`] - console.log(this.indent() + this.design.emph('Test suites:') + ' '.repeat(len - pss[0].length) + this.design.emph((sc === tl ? green : red)(pss[0])) + `, ${tl} total` + this.design.emph(` (${time.toFixed(0)}ms)`)); - if (this.verboseness > Verbosity.minimal) { - console.log(this.indent() + this.design.emph('Scenarios:') + - ' '.repeat(2 + len - pss[1].length) + this.design.emph((passing === scs.length ? green : red)(pss[1])) + - (skipped > 0 ? ', ' + this.design.emph(yellow(`${skipped} skipped`)) : '') + `, ${scs.length} total`); - console.log(this.indent() + this.design.emph('Actions:') + ' '.repeat(4 + len - pss[2].length) + this.design.emph((passing === scs.length ? green : red)(pss[2])) + (timeouts > 0 ? `, ${timeouts} timeouts` : '') + `, ${total} total`); - } - this.indentationLevel -= 1; - - console.log(); - this.archiver.write(); - } - - info(text: string) { - this.output += `info: ${text}\n`; - } - - error(text: string) { - this.output += `error: ${text}\n`; - } - - debug(text: string) { - if (this.verboseness === Verbosity.debug) { - console.debug(text); - } - } -} \ No newline at end of file + verbosity(level: Verbosity): void; +} diff --git a/src/reporter/ReporterFactory.ts b/src/reporter/ReporterFactory.ts new file mode 100644 index 0000000..8c78d82 --- /dev/null +++ b/src/reporter/ReporterFactory.ts @@ -0,0 +1,44 @@ +import {Reporter} from './Reporter'; +import {PlainReporter} from './PlainReporter'; +import {InkReporter} from './ink/InkReporter'; +import {StyleType, Verbosity} from './index'; + +export enum ReporterSelection { + auto = 'auto', + plain = 'plain', + ink = 'ink' +} + +export function reporterSelectionFromEnvironment(): ReporterSelection { + switch (process.env.LATCH_REPORTER) { + case ReporterSelection.plain: + return ReporterSelection.plain; + case ReporterSelection.ink: + return ReporterSelection.ink; + case ReporterSelection.auto: + default: + return ReporterSelection.auto; + } +} + +export class ReporterFactory { + static create(style: StyleType = StyleType.plain, verbosity: Verbosity = Verbosity.normal, selection: ReporterSelection = reporterSelectionFromEnvironment()): Reporter { + if (selection === ReporterSelection.plain) { + return new PlainReporter(style, verbosity); + } + + if (selection === ReporterSelection.ink) { + return new InkReporter(style, verbosity); + } + + if (style === StyleType.github) { + return new PlainReporter(style, verbosity); + } + + if (process.stdout.isTTY) { + return new InkReporter(style, verbosity); + } + + return new PlainReporter(style, verbosity); + } +} diff --git a/src/reporter/ReporterState.ts b/src/reporter/ReporterState.ts new file mode 100644 index 0000000..4d216ca --- /dev/null +++ b/src/reporter/ReporterState.ts @@ -0,0 +1,123 @@ +import {ScenarioResult, StepOutcome, SuiteResult} from './Results'; +import {SuiteRun} from './Reporter'; + +export type ReporterLogLevel = 'info' | 'error' | 'debug'; + +export interface ReporterLog { + level: ReporterLogLevel; + text: string; + timestamp: number; +} + +export interface ReporterRunState extends SuiteRun { + scenarios: ScenarioResult[]; + activeScenario?: ScenarioResult; + lastStep?: StepOutcome; + finishedAt?: number; +} + +export interface ReporterSnapshot { + activeRuns: ReporterRunState[]; + completedRuns: ReporterRunState[]; + logs: ReporterLog[]; + durationMs?: number; + started: boolean; + finished: boolean; +} + +export class ReporterState { + private readonly active = new Map(); + private readonly completed: ReporterRunState[] = []; + private readonly logEntries: ReporterLog[] = []; + private duration?: number; + private hasStarted = false; + private hasFinished = false; + + start() { + this.hasStarted = true; + } + + suiteStarted(run: SuiteRun) { + this.active.set(run.id, { + ...run, + scenarios: [] + }); + } + + scenarioStarted(runId: string, scenario: ScenarioResult) { + const run = this.active.get(runId); + if (!run) { + return; + } + + if (!run.scenarios.includes(scenario)) { + run.scenarios.push(scenario); + } + run.activeScenario = scenario; + } + + stepFinished(runId: string, scenario: ScenarioResult, step: StepOutcome) { + const run = this.active.get(runId); + if (!run) { + return; + } + + if (!run.scenarios.includes(scenario)) { + run.scenarios.push(scenario); + } + run.activeScenario = scenario; + run.lastStep = step; + } + + scenarioFinished(runId: string, scenario: ScenarioResult) { + const run = this.active.get(runId); + if (!run) { + return; + } + + if (!run.scenarios.includes(scenario)) { + run.scenarios.push(scenario); + } + + if (run.activeScenario === scenario) { + run.activeScenario = undefined; + } + } + + suiteFinished(runId: string, suite: SuiteResult) { + const run = this.active.get(runId); + if (!run) { + return; + } + + run.suite = suite; + run.finishedAt = Date.now(); + run.activeScenario = undefined; + this.active.delete(runId); + this.completed.push(run); + } + + log(level: ReporterLogLevel, text: string) { + this.logEntries.push({level, text, timestamp: Date.now()}); + } + + finish(durationMs: number) { + this.duration = durationMs; + this.hasFinished = true; + } + + suites(): SuiteResult[] { + return this.snapshot().completedRuns.map((run) => run.suite); + } + + snapshot(): ReporterSnapshot { + return { + activeRuns: Array.from(this.active.values()), + completedRuns: [...this.completed], + logs: [...this.logEntries], + durationMs: this.duration, + started: this.hasStarted, + finished: this.hasFinished + }; + } +} diff --git a/src/reporter/Results.ts b/src/reporter/Results.ts index 835186c..dc09571 100644 --- a/src/reporter/Results.ts +++ b/src/reporter/Results.ts @@ -73,7 +73,7 @@ abstract class AbstractAggregateResult implements AggregateResult { } private failing(): boolean { - return this.subOutcomes.some((outcome) => outcome.outcome === Outcome.failed || outcome.outcome === Outcome.timedout); + return this.subOutcomes.some((outcome) => outcome.outcome === Outcome.failed || outcome.outcome === Outcome.timedout || outcome.outcome === Outcome.error); } } diff --git a/src/reporter/Style.ts b/src/reporter/Style.ts index cf76385..7dd8430 100644 --- a/src/reporter/Style.ts +++ b/src/reporter/Style.ts @@ -29,6 +29,7 @@ interface Labels { export function styling(type: StyleType): Style { switch (type) { case StyleType.github: + return new GitHub(); case StyleType.plain: default: return new Plain(); diff --git a/src/reporter/Summary.ts b/src/reporter/Summary.ts new file mode 100644 index 0000000..3286a73 --- /dev/null +++ b/src/reporter/Summary.ts @@ -0,0 +1,71 @@ +import {SuiteResult} from './Results'; +import {Outcome} from './describers/Describer'; +import {ReporterSnapshot} from './ReporterState'; + +export interface SummaryTotals { + suites: { + passing: number; + failing: number; + skipped: number; + total: number; + }; + scenarios: { + passing: number; + failing: number; + skipped: number; + errors: number; + total: number; + }; + actions: { + passing: number; + failing: number; + skipped: number; + timeouts: number; + errors: number; + total: number; + }; +} + +export function summarize(suites: SuiteResult[]): SummaryTotals { + const scenarios = suites.flatMap((suite) => suite.outcomes()); + return summarizeItems(suites, scenarios); +} + +export function summarizeSnapshot(snapshot: ReporterSnapshot): SummaryTotals { + const completedSuites = snapshot.completedRuns.map((run) => run.suite); + const activeSuites = snapshot.activeRuns.map((run) => run.suite); + const activeScenarios = snapshot.activeRuns.flatMap((run) => run.scenarios); + + return summarizeItems([...completedSuites, ...activeSuites], [ + ...completedSuites.flatMap((suite) => suite.outcomes()), + ...activeScenarios + ]); +} + +function summarizeItems(suites: SuiteResult[], scenarios: ReturnType): SummaryTotals { + const actions = scenarios.flatMap((scenario) => scenario.outcomes()); + + return { + suites: { + passing: suites.filter((suite) => suite.outcome === Outcome.succeeded).length, + failing: suites.filter((suite) => suite.outcome === Outcome.failed || suite.outcome === Outcome.error).length, + skipped: suites.filter((suite) => suite.outcome === Outcome.skipped).length, + total: suites.length + }, + scenarios: { + passing: scenarios.filter((scenario) => scenario.outcome === Outcome.succeeded).length, + failing: scenarios.filter((scenario) => scenario.outcome === Outcome.failed).length, + skipped: scenarios.filter((scenario) => scenario.outcome === Outcome.skipped).length, + errors: scenarios.filter((scenario) => scenario.outcome === Outcome.error).length, + total: scenarios.length + }, + actions: { + passing: actions.filter((action) => action.outcome === Outcome.succeeded).length, + failing: actions.filter((action) => action.outcome === Outcome.failed).length, + skipped: actions.filter((action) => action.outcome === Outcome.skipped).length, + timeouts: actions.filter((action) => action.outcome === Outcome.timedout).length, + errors: actions.filter((action) => action.outcome === Outcome.error).length, + total: actions.length + } + }; +} diff --git a/src/reporter/index.ts b/src/reporter/index.ts index 3b2c634..d3c003a 100644 --- a/src/reporter/index.ts +++ b/src/reporter/index.ts @@ -12,3 +12,6 @@ export enum Verbosity { all, debug } + +export * from './Reporter'; +export * from './ReporterFactory'; diff --git a/src/reporter/ink/App.tsx b/src/reporter/ink/App.tsx new file mode 100644 index 0000000..9b64a5e --- /dev/null +++ b/src/reporter/ink/App.tsx @@ -0,0 +1,36 @@ +import {Box} from 'ink'; +import {ReporterSnapshot} from '../ReporterState'; +import {Verbosity} from '../index'; +import {RunHeader} from './RunHeader'; +import {ProgressSummary} from './ProgressSummary'; +import {SuiteList} from './SuiteList'; +import {LogPanel} from './LogPanel'; +import {FinalSummary} from './FinalSummary'; +import {showsDebugDetails} from './verbosity'; + +interface Props { + snapshot: ReporterSnapshot; + archive: string; + verbosity: Verbosity; +} + +export function App({snapshot, archive, verbosity}: Props) { + if (snapshot.finished) { + return ( + + {showsDebugDetails(verbosity) ? : null} + + {showsDebugDetails(verbosity) ? : null} + + ); + } + + return ( + + + + + + + ); +} diff --git a/src/reporter/ink/FinalSummary.tsx b/src/reporter/ink/FinalSummary.tsx new file mode 100644 index 0000000..da02ce5 --- /dev/null +++ b/src/reporter/ink/FinalSummary.tsx @@ -0,0 +1,96 @@ +import {Box, Text} from 'ink'; +import {ReporterSnapshot} from '../ReporterState'; +import {summarizeSnapshot} from '../Summary'; +import {Outcome} from '../describers/Describer'; +import {Verbosity} from '../index'; +import {alignRight, pad} from './format'; +import {StatusBadge} from './StatusBadge'; +import {Tree, TreeNode} from './Tree'; +import {preservesFullHistory, showsActionDetails} from './verbosity'; + +interface Props { + snapshot: ReporterSnapshot; + archive: string; + verbosity: Verbosity; +} + +export function FinalSummary({snapshot, archive, verbosity}: Props) { + if (!snapshot.finished) { + return null; + } + + const summary = summarizeSnapshot(snapshot); + const failed = summary.suites.failing > 0 || summary.scenarios.failing > 0 || summary.scenarios.errors > 0 || summary.actions.failing > 0 || summary.actions.errors > 0 || summary.actions.timeouts > 0; + const firstCountWidth = Math.max( + `${summary.suites.passing}`.length, + `${summary.scenarios.passing}`.length, + `${summary.actions.passing}`.length + ); + + return ( + + + + {summary.suites.passing} suites passed · {summary.scenarios.total} scenarios · {summary.actions.total} actions · {snapshot.durationMs?.toFixed(0) ?? 0}ms + + + {snapshot.completedRuns.map((run) => { + const scenarios = run.suite.outcomes(); + const visibleScenarios = preservesFullHistory(verbosity) + ? scenarios + : (verbosity >= Verbosity.normal + ? scenarios.filter((scenario) => scenario.outcome === Outcome.failed || scenario.outcome === Outcome.error) + : []); + const scenarioTrees = visibleScenarios.map((scenario): TreeNode => ({ + label: ( + + {preservesFullHistory(verbosity) ? <> : null} + {scenario.name} + + ), + children: scenario.outcome === Outcome.error && scenario.clarification && showsActionDetails(verbosity) + ? [{label: {scenario.clarification}}] + : scenario.outcomes() + .filter((step) => { + if (preservesFullHistory(verbosity)) { + return true; + } + + return showsActionDetails(verbosity) && (step.outcome === Outcome.failed || step.outcome === Outcome.error || step.outcome === Outcome.timedout); + }) + .map((step): TreeNode => ({ + label: ( + + + {step.name} + {step.clarification ? {step.clarification} : null} + + ) + })) + })); + + return ( + + + + {pad(run.suiteTitle, 28)} + {pad(`${scenarios.filter((scenario) => scenario.outcome === Outcome.succeeded).length}/${run.plannedScenarios ?? scenarios.length}`, 6)} + {run.testeeName} + + {scenarioTrees.map((node, index) => ( + + + + ))} + + ); + })} + + Suites {alignRight(`${summary.suites.passing}`, firstCountWidth)} passed · {summary.suites.failing} failed + Scenarios {alignRight(`${summary.scenarios.passing}`, firstCountWidth)} passed · {summary.scenarios.failing} failed · {summary.scenarios.errors} errors · {summary.scenarios.skipped} skipped + Actions {alignRight(`${summary.actions.passing}`, firstCountWidth)} passed · {summary.actions.failing} failed · {summary.actions.errors} errors · {summary.actions.timeouts} timeouts + + Archive {archive} + + ); +} diff --git a/src/reporter/ink/InkReporter.ts b/src/reporter/ink/InkReporter.ts new file mode 100644 index 0000000..93167db --- /dev/null +++ b/src/reporter/ink/InkReporter.ts @@ -0,0 +1,117 @@ +import React from 'react'; +import {Instance, render} from 'ink'; +import {ArchiveWriter} from '../ArchiveWriter'; +import {Reporter, SuiteRun} from '../Reporter'; +import {ReporterState} from '../ReporterState'; +import {ScenarioResult, StepOutcome, SuiteResult} from '../Results'; +import {StyleType, Verbosity} from '../index'; +import {App} from './App'; + +export class InkReporter implements Reporter { + private readonly state = new ReporterState(); + private readonly archiveWriter: ArchiveWriter; + private instance?: Instance; + private styleType: StyleType; + private verbosityLevel: Verbosity; + + constructor(style: StyleType = StyleType.plain, verbosity: Verbosity = Verbosity.normal, archiveWriter: ArchiveWriter = new ArchiveWriter()) { + this.styleType = style; + this.verbosityLevel = verbosity; + this.archiveWriter = archiveWriter; + } + + style(type: StyleType) { + this.styleType = type; + this.rerender(); + } + + styling(): StyleType { + return this.styleType; + } + + verbosity(level: Verbosity) { + this.verbosityLevel = level; + this.rerender(); + } + + start() { + this.state.start(); + this.instance = render(this.element(), {patchConsole: true}); + } + + suiteStarted(run: SuiteRun) { + this.state.suiteStarted(run); + this.rerender(); + } + + scenarioStarted(runId: string, scenario: ScenarioResult) { + this.state.scenarioStarted(runId, scenario); + this.rerender(); + } + + stepFinished(runId: string, scenario: ScenarioResult, step: StepOutcome) { + this.state.stepFinished(runId, scenario, step); + this.rerender(); + } + + scenarioFinished(runId: string, scenario: ScenarioResult) { + this.state.scenarioFinished(runId, scenario); + this.rerender(); + } + + suiteFinished(runId: string, suite: SuiteResult) { + this.state.suiteFinished(runId, suite); + this.rerender(); + } + + info(text: string) { + this.state.log('info', text); + this.rerender(); + } + + error(text: string) { + this.state.log('error', text); + this.rerender(); + } + + debug(text: string) { + if (this.verbosityLevel === Verbosity.debug) { + this.state.log('debug', text); + this.rerender(); + } + } + + finish(durationMs: number) { + this.state.finish(durationMs); + this.archiveWriter.write(durationMs, this.state.suites()); + this.rerender(); + } + + async close(): Promise { + if (!this.instance) { + return; + } + + this.rerender(); + await this.flushInk(); + this.instance.unmount(); + await this.instance.waitUntilExit(); + await this.flushInk(); + } + + private rerender() { + this.instance?.rerender(this.element()); + } + + private element() { + return React.createElement(App, { + snapshot: this.state.snapshot(), + archive: this.archiveWriter.archive, + verbosity: this.verbosityLevel + }); + } + + private async flushInk(): Promise { + await new Promise(resolve => setTimeout(resolve, 50)); + } +} diff --git a/src/reporter/ink/LogPanel.tsx b/src/reporter/ink/LogPanel.tsx new file mode 100644 index 0000000..ae2d901 --- /dev/null +++ b/src/reporter/ink/LogPanel.tsx @@ -0,0 +1,29 @@ +import {Box, Text} from 'ink'; +import {ReporterLog} from '../ReporterState'; +import {Verbosity} from '../index'; +import {showsDebugDetails} from './verbosity'; + +interface Props { + logs: ReporterLog[]; + verbosity: Verbosity; +} + +export function LogPanel({logs, verbosity}: Props) { + const visible = showsDebugDetails(verbosity) ? logs : logs.filter((log) => log.level !== 'debug'); + const recent = visible.slice(-8); + + if (recent.length === 0) { + return null; + } + + return ( + + Logs + {recent.map((log, index) => ( + + {log.level}: {log.text} + + ))} + + ); +} diff --git a/src/reporter/ink/ProgressSummary.tsx b/src/reporter/ink/ProgressSummary.tsx new file mode 100644 index 0000000..43256d9 --- /dev/null +++ b/src/reporter/ink/ProgressSummary.tsx @@ -0,0 +1,22 @@ +import {Box, Text} from 'ink'; +import {ReporterSnapshot} from '../ReporterState'; +import {summarizeSnapshot} from '../Summary'; + +interface Props { + snapshot: ReporterSnapshot; +} + +export function ProgressSummary({snapshot}: Props) { + const summary = summarizeSnapshot(snapshot); + const plannedScenarios = snapshot.activeRuns.concat(snapshot.completedRuns).reduce((total, run) => total + (run.plannedScenarios ?? run.suite.outcomes().length), 0); + const plannedActions = snapshot.activeRuns.concat(snapshot.completedRuns).reduce((total, run) => total + (run.plannedActions ?? run.suite.outcomes().flatMap((scenario) => scenario.outcomes()).length), 0); + const completedScenarios = summary.scenarios.passing + summary.scenarios.failing + summary.scenarios.errors + summary.scenarios.skipped; + + return ( + + + Progress {snapshot.completedRuns.length}/{summary.suites.total} suites · {completedScenarios}/{plannedScenarios} scenarios · {summary.actions.total}/{plannedActions} actions + + + ); +} diff --git a/src/reporter/ink/RunHeader.tsx b/src/reporter/ink/RunHeader.tsx new file mode 100644 index 0000000..6c9151c --- /dev/null +++ b/src/reporter/ink/RunHeader.tsx @@ -0,0 +1,15 @@ +import {Box, Text} from 'ink'; +import {version} from '../../../package.json'; + +interface Props { + archive: string; +} + +export function RunHeader({archive}: Props) { + return ( + + Latch + v{version} · archive {archive} + + ); +} diff --git a/src/reporter/ink/StatusBadge.tsx b/src/reporter/ink/StatusBadge.tsx new file mode 100644 index 0000000..dbc2f19 --- /dev/null +++ b/src/reporter/ink/StatusBadge.tsx @@ -0,0 +1,24 @@ +import {Text} from 'ink'; +import {Outcome} from '../describers/Describer'; + +interface Props { + outcome: Outcome; +} + +export function StatusBadge({outcome}: Props) { + switch (outcome) { + case Outcome.succeeded: + return PASS; + case Outcome.skipped: + return SKIP; + case Outcome.timedout: + return TIMEOUT; + case Outcome.error: + return ERROR; + case Outcome.failed: + return FAIL; + case Outcome.uncommenced: + default: + return RUN; + } +} diff --git a/src/reporter/ink/SuiteList.tsx b/src/reporter/ink/SuiteList.tsx new file mode 100644 index 0000000..392c435 --- /dev/null +++ b/src/reporter/ink/SuiteList.tsx @@ -0,0 +1,18 @@ +import {Box, Text} from 'ink'; +import {ReporterSnapshot} from '../ReporterState'; +import {Verbosity} from '../index'; +import {SuiteView} from './SuiteView'; + +interface Props { + snapshot: ReporterSnapshot; + verbosity: Verbosity; +} + +export function SuiteList({snapshot, verbosity}: Props) { + return ( + + {snapshot.completedRuns.map((run) => )} + {snapshot.activeRuns.map((run) => )} + + ); +} diff --git a/src/reporter/ink/SuiteView.tsx b/src/reporter/ink/SuiteView.tsx new file mode 100644 index 0000000..ebd15ef --- /dev/null +++ b/src/reporter/ink/SuiteView.tsx @@ -0,0 +1,81 @@ +import {Box, Text} from 'ink'; +import {ReporterRunState} from '../ReporterState'; +import {Verbosity} from '../index'; +import {Outcome} from '../describers/Describer'; +import {StatusBadge} from './StatusBadge'; +import {duration, pad, plural} from './format'; +import {Tree, TreeNode} from './Tree'; +import {preservesFullHistory, showsActionDetails} from './verbosity'; + +interface Props { + run: ReporterRunState; + active: boolean; + verbosity: Verbosity; +} + +export function SuiteView({run, active, verbosity}: Props) { + const scenarios = active ? run.scenarios : run.suite.outcomes(); + const showFailureDetails = !active && run.suite.outcome !== Outcome.succeeded; + const failedScenarios = scenarios.filter((scenario) => scenario.outcome === Outcome.failed || scenario.outcome === Outcome.error); + const currentScenario = run.activeScenario ?? scenarios[scenarios.length - 1]; + const visibleScenarios = active + ? (showsActionDetails(verbosity) ? scenarios : (currentScenario ? [currentScenario] : [])) + : (preservesFullHistory(verbosity) ? scenarios : (showFailureDetails && verbosity >= Verbosity.normal ? failedScenarios : [])); + const actions = scenarios.flatMap((scenario) => scenario.outcomes()); + const completed = !active && run.suite.outcome === Outcome.succeeded && verbosity <= Verbosity.normal; + const scenarioProgress = `${scenarios.filter((scenario) => scenario.outcome === Outcome.succeeded).length}/${run.plannedScenarios ?? scenarios.length}`; + const scenarioTrees = visibleScenarios.map((scenario): TreeNode => ({ + label: {scenario.name}{scenario.outcomes().length > 0 ? {scenario.outcomes().length}{active && scenario === run.activeScenario ? '' : `/${scenario.outcomes().length}`} : null}, + children: scenario.outcome === Outcome.error && scenario.clarification + ? [{label: {scenario.clarification}}] + : scenario.outcomes() + .filter((step) => { + if (preservesFullHistory(verbosity)) { + return true; + } + + if (active) { + return showsActionDetails(verbosity) && scenario === currentScenario; + } + + return showsActionDetails(verbosity) && (step.outcome === Outcome.failed || step.outcome === Outcome.error || step.outcome === Outcome.timedout); + }) + .map((step): TreeNode => ({ + label: ( + + + {step.name} + {step.clarification ? {step.clarification} : null} + + ) + })) + })); + + return ( + + + + {pad(run.suiteTitle, 28)} + {completed ? ( + {pad(scenarioProgress, 6)}{run.testeeName} · {plural(actions.length, 'action')} · {duration(run)}ms + ) : ( + {pad(scenarioProgress, 6)}{run.testeeName} + )} + + {scenarioTrees.map((node, index) => active ? ( + + {node.label} + {node.children?.map((child, childIndex) => ( + + {child.label} + + ))} + + ) : ( + + + + ))} + + ); +} diff --git a/src/reporter/ink/Tree.tsx b/src/reporter/ink/Tree.tsx new file mode 100644 index 0000000..c30d306 --- /dev/null +++ b/src/reporter/ink/Tree.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import {Box, Text} from 'ink'; + +export interface TreeNode { + label: T; + children?: TreeNode[]; +} + +export function prefix(ancestorsLast: boolean[], isLast: boolean): string { + const ancestors = ancestorsLast + .map(last => last ? ' ' : '│ ') + .join(''); + + return ancestors + (isLast ? '└─ ' : '├─ '); +} + +export function renderTreeLines(node: TreeNode, ancestorsLast: boolean[] = [], isLast = true): string[] { + const indent = ancestorsLast + .map(last => last ? ' ' : '│ ') + .join(''); + + const branch = ancestorsLast.length === 0 + ? '' + : isLast ? '└─ ' : '├─ '; + + return [ + `${indent}${branch}${node.label}`, + ...(node.children ?? []).flatMap((child, index) => + renderTreeLines( + child, + [...ancestorsLast, isLast], + index === (node.children ?? []).length - 1 + ) + ) + ]; +} + +interface TreeProps { + node: TreeNode; + ancestorsLast?: boolean[]; + isLast?: boolean; + showRootBranch?: boolean; +} + +export function Tree({node, ancestorsLast = [], isLast = true, showRootBranch = false}: TreeProps) { + const indent = ancestorsLast + .map(last => last ? ' ' : '│ ') + .join(''); + + const branch = ancestorsLast.length === 0 && !showRootBranch + ? '' + : isLast ? '└─ ' : '├─ '; + + return ( + + + {indent}{branch} + {node.label} + + {node.children?.map((child, index) => ( + + ))} + + ); +} diff --git a/src/reporter/ink/format.ts b/src/reporter/ink/format.ts new file mode 100644 index 0000000..403f675 --- /dev/null +++ b/src/reporter/ink/format.ts @@ -0,0 +1,33 @@ +import {ReporterRunState} from '../ReporterState'; + +export function pad(text: string, width: number): string { + if (text.length >= width) { + return `${text} `; + } + + return text + ' '.repeat(width - text.length); +} + +export function align(text: string, width: number): string { + if (text.length >= width) { + return text; + } + + return text + ' '.repeat(width - text.length); +} + +export function alignRight(text: string, width: number): string { + if (text.length >= width) { + return text; + } + + return ' '.repeat(width - text.length) + text; +} + +export function plural(count: number, singular: string, pluralForm: string = `${singular}s`): string { + return `${count} ${count === 1 ? singular : pluralForm}`; +} + +export function duration(run: ReporterRunState): number { + return Math.max(0, Math.round((run.finishedAt ?? Date.now()) - run.startedAt)); +} diff --git a/src/reporter/ink/verbosity.ts b/src/reporter/ink/verbosity.ts new file mode 100644 index 0000000..3acc84c --- /dev/null +++ b/src/reporter/ink/verbosity.ts @@ -0,0 +1,13 @@ +import {Verbosity} from '../index'; + +export function preservesFullHistory(verbosity: Verbosity): boolean { + return verbosity === Verbosity.all || verbosity === Verbosity.debug; +} + +export function showsActionDetails(verbosity: Verbosity): boolean { + return verbosity === Verbosity.more || preservesFullHistory(verbosity); +} + +export function showsDebugDetails(verbosity: Verbosity): boolean { + return verbosity === Verbosity.debug; +} diff --git a/tests/unit/reporter.test.ts b/tests/unit/reporter.test.ts new file mode 100644 index 0000000..bcb4a9b --- /dev/null +++ b/tests/unit/reporter.test.ts @@ -0,0 +1,420 @@ +import test from 'ava'; +import React from 'react'; +import {mkdtempSync, readFileSync, rmSync} from 'fs'; +import {tmpdir} from 'os'; +import {join} from 'path'; +import {render} from 'ink-testing-library'; +import {Framework, Suite} from '../../src/framework/Framework'; +import {TestScenario} from '../../src/framework/scenario/TestScenario'; +import {Kind, Step} from '../../src/framework/scenario/Step'; +import {ArchiveWriter} from '../../src/reporter/ArchiveWriter'; +import {AutoReporter} from '../../src/reporter/AutoReporter'; +import {InkReporter} from '../../src/reporter/ink/InkReporter'; +import {App} from '../../src/reporter/ink/App'; +import {PlainReporter} from '../../src/reporter/PlainReporter'; +import {Reporter, SuiteRun} from '../../src/reporter/Reporter'; +import {ReporterFactory, ReporterSelection} from '../../src/reporter/ReporterFactory'; +import {ReporterState} from '../../src/reporter/ReporterState'; +import {ScenarioResult, StepOutcome, SuiteResult} from '../../src/reporter/Results'; +import {summarize} from '../../src/reporter/Summary'; +import {Outcome} from '../../src/reporter/describers/Describer'; +import {StyleType, Verbosity} from '../../src/reporter'; + +const step = (title: string): Step => ({ + title, + instruction: { + kind: Kind.Action, + value: { + act: async () => ({value: 1}) + } + }, + expected: [] +}); + +const scenario = (title: string, steps: Step[] = [step('step')]): TestScenario => ({ + title, + program: 'program.wast', + steps +}); + +const suiteResult = (title = 'suite', scenarios: TestScenario[] = [scenario('scenario')]): SuiteResult => + new SuiteResult({title, scenarios} as Suite); + +const run = (id: string, suite: SuiteResult, index = 1): SuiteRun => ({ + id, + suite, + suiteTitle: suite.name, + testeeName: `testee-${index}`, + executionIndex: index, + startedAt: Date.now() +}); + +test('Summary totals count suites, scenarios, and actions once', t => { + const first = step('first'); + const second = step('second'); + const scenarioResult = new ScenarioResult(scenario('scenario', [first, second])); + scenarioResult.add(new StepOutcome(first).update(Outcome.succeeded)); + scenarioResult.add(new StepOutcome(second).update(Outcome.timedout, 'timeout')); + + const suite = suiteResult('suite', [scenario('scenario', [first, second])]); + suite.add(scenarioResult); + + const summary = summarize([suite]); + + t.deepEqual(summary.suites, {passing: 0, failing: 1, skipped: 0, total: 1}); + t.deepEqual(summary.scenarios, {passing: 0, failing: 1, skipped: 0, errors: 0, total: 1}); + t.deepEqual(summary.actions, {passing: 1, failing: 0, skipped: 0, timeouts: 1, errors: 0, total: 2}); +}); + +test('Aggregate results treat child errors as failures, not skipped', t => { + const passingScenario = new ScenarioResult(scenario('passing-scenario')); + passingScenario.add(new StepOutcome(step('passing-step')).update(Outcome.succeeded)); + + const errorScenario = new ScenarioResult(scenario('error-scenario')); + errorScenario.add(new StepOutcome(step('error-step')).update(Outcome.error, 'missing field')); + + const suite = suiteResult('suite', [scenario('passing-scenario'), scenario('error-scenario')]); + suite.add(passingScenario); + suite.add(errorScenario); + + const summary = summarize([suite]); + + t.is(errorScenario.outcome, Outcome.failed); + t.is(suite.outcome, Outcome.failed); + t.deepEqual(summary.suites, {passing: 0, failing: 1, skipped: 0, total: 1}); + t.deepEqual(summary.scenarios, {passing: 1, failing: 1, skipped: 0, errors: 0, total: 2}); + t.deepEqual(summary.actions, {passing: 1, failing: 0, skipped: 0, timeouts: 0, errors: 1, total: 2}); +}); + +test('ArchiveWriter preserves archive fields', t => { + const previous = process.env.TESTFILE; + const temp = mkdtempSync(join(tmpdir(), 'latch-reporter-')); + process.env.TESTFILE = join(temp, 'suite.asserts.wast'); + + try { + const sc = new ScenarioResult(scenario('passing')); + sc.add(new StepOutcome(step('step')).update(Outcome.succeeded)); + const suite = suiteResult('suite'); + suite.add(sc); + + const writer = new ArchiveWriter(1234); + writer.write(42.4, [suite]); + + const archived = JSON.parse(readFileSync(writer.archive, 'utf8')); + t.is(archived['duration (ms)'], 42); + t.deepEqual(archived.passes, ['passing']); + t.is(archived['passed scenarios'], 1); + t.is(archived['skipped scenarios'], 0); + t.is(archived['failed scenarios'], 0); + } finally { + if (previous === undefined) { + delete process.env.TESTFILE; + } else { + process.env.TESTFILE = previous; + } + rmSync(temp, {recursive: true, force: true}); + } +}); + +test('ReporterState keeps active step visible before scenario and suite completion', t => { + const state = new ReporterState(); + const suite = suiteResult(); + const sc = new ScenarioResult(scenario('scenario')); + const outcome = new StepOutcome(step('visible')).update(Outcome.succeeded); + + state.start(); + state.suiteStarted(run('run-1', suite)); + state.scenarioStarted('run-1', sc); + sc.add(outcome); + state.stepFinished('run-1', sc, outcome); + + const active = state.snapshot().activeRuns[0]; + t.is(active.activeScenario, sc); + t.is(active.lastStep, outcome); + t.deepEqual(active.scenarios, [sc]); + t.is(state.snapshot().completedRuns.length, 0); + + state.scenarioFinished('run-1', sc); + suite.add(sc); + state.suiteFinished('run-1', suite); + + t.is(state.snapshot().activeRuns.length, 0); + t.is(state.snapshot().completedRuns[0].suite, suite); +}); + +test.serial('ReporterFactory falls back to plain reporter for non-TTY stdout and GitHub style', t => { + const descriptor = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY'); + Object.defineProperty(process.stdout, 'isTTY', {value: false, configurable: true}); + + try { + t.true(ReporterFactory.create(StyleType.plain, Verbosity.normal, ReporterSelection.auto) instanceof PlainReporter); + + Object.defineProperty(process.stdout, 'isTTY', {value: true, configurable: true}); + t.true(ReporterFactory.create(StyleType.github, Verbosity.normal, ReporterSelection.auto) instanceof PlainReporter); + t.true(ReporterFactory.create(StyleType.plain, Verbosity.normal, ReporterSelection.ink) instanceof InkReporter); + } finally { + if (descriptor) { + Object.defineProperty(process.stdout, 'isTTY', descriptor); + } else { + delete (process.stdout as unknown as {isTTY?: boolean}).isTTY; + } + } +}); + +test('Ink App applies running verbosity levels', t => { + const activeSuite = suiteResult('active-suite'); + const activeScenario = new ScenarioResult(scenario('active-scenario')); + const activeStep = new StepOutcome(step('active-step')).update(Outcome.succeeded); + activeScenario.add(activeStep); + + const completedSuite = suiteResult('completed-suite'); + const failedScenario = new ScenarioResult(scenario('failed-scenario')); + const failedStep = new StepOutcome(step('failed-step')).update(Outcome.failed, 'Expected 1 got 2'); + failedScenario.add(failedStep); + completedSuite.add(failedScenario); + + const state = new ReporterState(); + state.start(); + state.suiteStarted(run('active', activeSuite)); + state.scenarioStarted('active', activeScenario); + state.stepFinished('active', activeScenario, activeStep); + state.suiteStarted(run('completed', completedSuite, 2)); + state.suiteFinished('completed', completedSuite); + + const normal = render(React.createElement(App, { + snapshot: state.snapshot(), + archive: 'suite.log', + verbosity: Verbosity.normal + })).lastFrame() ?? ''; + + const more = render(React.createElement(App, { + snapshot: state.snapshot(), + archive: 'suite.log', + verbosity: Verbosity.more + })).lastFrame() ?? ''; + + t.true(normal.includes('active-suite')); + t.true(normal.includes('active-scenario')); + t.false(normal.includes('active-step')); + t.true(normal.includes('completed-suite')); + t.false(normal.includes('Expected 1 got 2')); + t.true(normal.includes('Progress')); + + t.true(more.includes('active-step')); + t.true(more.includes('Expected 1 got 2')); +}); + +test('Ink App renders compact final summary at the bottom', t => { + const completedSuite = suiteResult('completed-suite'); + const passedScenario = new ScenarioResult(scenario('passed-scenario')); + for (let i = 0; i < 10; i++) { + passedScenario.add(new StepOutcome(step(`passed-step-${i}`)).update(Outcome.succeeded)); + } + completedSuite.add(passedScenario); + + const state = new ReporterState(); + state.start(); + state.suiteStarted(run('completed', completedSuite, 2)); + state.suiteFinished('completed', completedSuite); + state.finish(12); + + const app = render(React.createElement(App, { + snapshot: state.snapshot(), + archive: 'suite.log', + verbosity: Verbosity.normal + })); + + const frame = app.lastFrame() ?? ''; + t.true(frame.includes('PASS 1 suites passed · 1 scenarios · 10 actions · 12ms')); + t.true(frame.includes('PASS completed-suite')); + t.regex(frame, /PASS completed-suite\s+1\/1\s+testee-2/); + t.true(frame.includes('Suites')); + t.true(frame.includes('Scenarios')); + t.true(frame.includes('Actions')); + t.true(frame.includes('Archive suite.log')); + t.false(frame.includes('passed-step-0')); + + const overviewRows = frame.split('\n').filter((line) => line.includes('passed') && line.includes('failed')); + t.is(overviewRows.length, 3); + t.deepEqual(overviewRows.map((line) => line.indexOf('·')), [overviewRows[0].indexOf('·'), overviewRows[0].indexOf('·'), overviewRows[0].indexOf('·')]); +}); + +test('Ink App shows failed scenarios under suite rows for normal final verbosity', t => { + const completedSuite = suiteResult('completed-suite'); + const failedScenario = new ScenarioResult(scenario('failed-scenario')); + failedScenario.add(new StepOutcome(step('failed-step')).update(Outcome.failed, 'Expected 1 got 2')); + completedSuite.add(failedScenario); + + const state = new ReporterState(); + state.start(); + state.suiteStarted(run('completed', completedSuite, 2)); + state.suiteFinished('completed', completedSuite); + state.finish(12); + + const normal = render(React.createElement(App, { + snapshot: state.snapshot(), + archive: 'suite.log', + verbosity: Verbosity.normal + })).lastFrame() ?? ''; + + const minimal = render(React.createElement(App, { + snapshot: state.snapshot(), + archive: 'suite.log', + verbosity: Verbosity.minimal + })).lastFrame() ?? ''; + + t.true(normal.includes('FAIL completed-suite')); + t.true(normal.includes('└─ failed-scenario')); + t.true(normal.indexOf('└─ failed-scenario') > normal.indexOf('FAIL completed-suite')); + t.false(normal.includes('failed-step')); + t.false(normal.includes('Expected 1 got 2')); + t.false(minimal.includes('failed-scenario')); +}); + +test('Ink App expands failed actions for more and full history for all final verbosity', t => { + const completedSuite = suiteResult('completed-suite'); + const passedScenario = new ScenarioResult(scenario('passed-scenario')); + passedScenario.add(new StepOutcome(step('passed-step')).update(Outcome.succeeded)); + const failedScenario = new ScenarioResult(scenario('failed-scenario')); + failedScenario.add(new StepOutcome(step('failed-step')).update(Outcome.failed, 'Expected 1 got 2')); + completedSuite.add(passedScenario); + completedSuite.add(failedScenario); + + const state = new ReporterState(); + state.start(); + state.suiteStarted(run('completed', completedSuite, 2)); + state.suiteFinished('completed', completedSuite); + state.finish(12); + + const more = render(React.createElement(App, { + snapshot: state.snapshot(), + archive: 'suite.log', + verbosity: Verbosity.more + })).lastFrame() ?? ''; + + const all = render(React.createElement(App, { + snapshot: state.snapshot(), + archive: 'suite.log', + verbosity: Verbosity.all + })).lastFrame() ?? ''; + + t.true(more.includes('└─ failed-scenario')); + t.true(more.includes('└─ FAIL failed-step')); + t.true(more.includes('Expected 1 got 2')); + t.false(more.includes('passed-step')); + + t.true(all.includes('passed-scenario')); + t.true(all.includes('PASS passed-step')); + t.true(all.includes('failed-scenario')); + t.true(all.includes('FAIL failed-step')); +}); + +test('Ink App debug final verbosity is all plus framework and log details', t => { + const completedSuite = suiteResult('completed-suite'); + const passedScenario = new ScenarioResult(scenario('passed-scenario')); + passedScenario.add(new StepOutcome(step('passed-step')).update(Outcome.succeeded)); + const failedScenario = new ScenarioResult(scenario('failed-scenario')); + failedScenario.add(new StepOutcome(step('failed-step')).update(Outcome.failed, 'Expected 1 got 2')); + completedSuite.add(passedScenario); + completedSuite.add(failedScenario); + + const state = new ReporterState(); + state.start(); + state.log('info', 'framework info'); + state.log('debug', 'debug frame'); + state.suiteStarted(run('completed', completedSuite, 2)); + state.suiteFinished('completed', completedSuite); + state.finish(12); + + const debug = render(React.createElement(App, { + snapshot: state.snapshot(), + archive: 'suite.log', + verbosity: Verbosity.debug + })).lastFrame() ?? ''; + + t.true(debug.includes('Latch')); + t.true(debug.includes('archive suite.log')); + t.true(debug.includes('passed-scenario')); + t.true(debug.includes('PASS passed-step')); + t.true(debug.includes('failed-scenario')); + t.true(debug.includes('FAIL failed-step')); + t.true(debug.includes('Logs')); + t.true(debug.includes('info: framework info')); + t.true(debug.includes('debug: debug frame')); +}); + +test('Ink App respects debug verbosity for logs', t => { + const state = new ReporterState(); + state.start(); + state.log('debug', 'debug-only'); + state.log('info', 'info-visible'); + + const normal = render(React.createElement(App, { + snapshot: state.snapshot(), + archive: 'suite.log', + verbosity: Verbosity.normal + })).lastFrame() ?? ''; + + const debug = render(React.createElement(App, { + snapshot: state.snapshot(), + archive: 'suite.log', + verbosity: Verbosity.debug + })).lastFrame() ?? ''; + + t.false(normal.includes('debug-only')); + t.true(normal.includes('info-visible')); + t.true(debug.includes('debug-only')); +}); + +test('Framework run IDs distinguish parallel testees by execution index', t => { + const framework = Framework.getImplementation() as unknown as {runId: (suite: {title: string}, testee: {name: string}, executionIndex: number) => string}; + + t.not(framework.runId({title: 'suite'}, {name: 'testee'}, 1), framework.runId({title: 'suite'}, {name: 'testee'}, 2)); +}); + +test.serial('Framework.analyse waits for reporter cleanup before process exit', async t => { + const framework = Framework.getImplementation() as unknown as {reporter: Reporter; analyse: (suite: Suite[], runs?: number) => void}; + const originalReporter = framework.reporter; + const originalExit = process.exit; + let closed = false; + let exitedAfterClose = false; + let exitCode: string | number | null | undefined; + + framework.reporter = { + start() {}, + suiteStarted() {}, + scenarioStarted() {}, + stepFinished() {}, + scenarioFinished() {}, + suiteFinished() {}, + info() {}, + error() {}, + debug() {}, + finish() {}, + async close() { + await new Promise(resolve => setTimeout(resolve, 5)); + closed = true; + }, + style() {}, + styling() { + return StyleType.plain; + }, + verbosity() {} + }; + + process.exit = ((code?: string | number | null | undefined) => { + exitCode = code; + exitedAfterClose = closed; + return undefined as never; + }) as typeof process.exit; + + try { + framework.analyse([], 1); + await new Promise(resolve => setTimeout(resolve, 20)); + t.true(exitedAfterClose); + t.is(exitCode, 0); + } finally { + framework.reporter = originalReporter ?? new AutoReporter(); + process.exit = originalExit; + } +}); diff --git a/tests/unit/tree.test.ts b/tests/unit/tree.test.ts new file mode 100644 index 0000000..14af010 --- /dev/null +++ b/tests/unit/tree.test.ts @@ -0,0 +1,69 @@ +import test from 'ava'; +import {prefix, renderTreeLines, TreeNode} from '../../src/reporter/ink/Tree'; + +test('tree prefix renders ancestor continuations', t => { + t.is(prefix([], true), '└─ '); + t.is(prefix([], false), '├─ '); + t.is(prefix([false], true), '│ └─ '); + t.is(prefix([false], false), '│ ├─ '); + t.is(prefix([true], true), ' └─ '); +}); + +test('renderTreeLines renders one failed scenario', t => { + const tree: TreeNode = { + label: 'FAIL Test Debugger interface', + children: [{ + label: 'Test DUMP blink', + children: [{label: 'ERROR Send DUMP command'}] + }] + }; + + t.deepEqual(renderTreeLines(tree), [ + 'FAIL Test Debugger interface', + ' └─ Test DUMP blink', + ' └─ ERROR Send DUMP command' + ]); +}); + +test('renderTreeLines renders multiple failed scenarios', t => { + const tree: TreeNode = { + label: 'FAIL Test Debugger interface', + children: [{ + label: 'Test STEP OVER', + children: [{label: 'ERROR Check execution'}] + }, { + label: 'Test DUMP blink', + children: [{label: 'ERROR Send DUMP command'}] + }] + }; + + t.deepEqual(renderTreeLines(tree), [ + 'FAIL Test Debugger interface', + ' ├─ Test STEP OVER', + ' │ └─ ERROR Check execution', + ' └─ Test DUMP blink', + ' └─ ERROR Send DUMP command' + ]); +}); + +test('renderTreeLines renders multiple failed actions', t => { + const tree: TreeNode = { + label: 'FAIL Test Debugger interface', + children: [{ + label: 'Test DUMP blink', + children: [ + {label: 'ERROR Send DUMP command'}, + {label: 'ERROR Check state'}, + {label: 'ERROR Check memory'} + ] + }] + }; + + t.deepEqual(renderTreeLines(tree), [ + 'FAIL Test Debugger interface', + ' └─ Test DUMP blink', + ' ├─ ERROR Send DUMP command', + ' ├─ ERROR Check state', + ' └─ ERROR Check memory' + ]); +}); diff --git a/tsconfig.json b/tsconfig.json index 3112ec1..b03cddf 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ "rootDir": ".", "sourceMap": true, "strict": true, + "jsx": "react-jsx", "allowSyntheticDefaultImports": true, "resolveJsonModule": true, "importHelpers": true, From ab799c4c5f23cfa40e4a56554ac25417487891fd Mon Sep 17 00:00:00 2001 From: Tom Lauwaerts Date: Tue, 11 Aug 2026 09:01:02 +0200 Subject: [PATCH 2/5] Linting --- src/messaging/Parsers.ts | 2 +- src/reporter/PlainReporter.ts | 2 +- src/reporter/ink/SuiteList.tsx | 5 +++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/messaging/Parsers.ts b/src/messaging/Parsers.ts index 558f499..b02f442 100644 --- a/src/messaging/Parsers.ts +++ b/src/messaging/Parsers.ts @@ -62,7 +62,7 @@ export function breakpointHitParser(text: string): Breakpoint { } export function signed(value: bigint, bits = 32) { - let x = value; + const x = value; const sign = 1n << BigInt(bits - 1); const mod = 1n << BigInt(bits); return x >= sign ? x - mod : x; diff --git a/src/reporter/PlainReporter.ts b/src/reporter/PlainReporter.ts index ca04805..c57ebef 100644 --- a/src/reporter/PlainReporter.ts +++ b/src/reporter/PlainReporter.ts @@ -6,7 +6,7 @@ import {Reporter, SuiteRun} from './Reporter'; import {ScenarioResult, StepOutcome, SuiteResult} from './Results'; import {Style, styling as styleMap} from './Style'; import {summarize} from './Summary'; -import {Outcome, SilentDescriber} from './describers/Describer'; +import {SilentDescriber} from './describers/Describer'; import { MinimalSuiteDescriber, NormalSuiteDescriber, diff --git a/src/reporter/ink/SuiteList.tsx b/src/reporter/ink/SuiteList.tsx index 392c435..5fd3783 100644 --- a/src/reporter/ink/SuiteList.tsx +++ b/src/reporter/ink/SuiteList.tsx @@ -1,4 +1,4 @@ -import {Box, Text} from 'ink'; +import {Box} from 'ink'; import {ReporterSnapshot} from '../ReporterState'; import {Verbosity} from '../index'; import {SuiteView} from './SuiteView'; @@ -11,7 +11,8 @@ interface Props { export function SuiteList({snapshot, verbosity}: Props) { return ( - {snapshot.completedRuns.map((run) => )} + {snapshot.completedRuns.map((run) => )} {snapshot.activeRuns.map((run) => )} ); From 7b0659271ab744e74a409efed0ed926a0f7c956d Mon Sep 17 00:00:00 2001 From: Tom Lauwaerts Date: Tue, 11 Aug 2026 09:38:13 +0200 Subject: [PATCH 3/5] Remove `Style` --- src/framework/Framework.ts | 4 +- src/reporter/AutoReporter.ts | 84 ----------- src/reporter/PlainReporter.ts | 140 ------------------ src/reporter/Reporter.ts | 6 +- src/reporter/ReporterFactory.ts | 44 ------ src/reporter/Style.ts | 83 ----------- src/reporter/describers/Describer.ts | 43 ++++-- src/reporter/describers/ScenarioDescribers.ts | 17 ++- src/reporter/describers/SuiteDescribers.ts | 27 ++-- src/reporter/index.ts | 7 +- src/reporter/ink/InkReporter.ts | 15 +- tests/unit/describers.test.ts | 9 +- tests/unit/reporter.test.ts | 71 ++++----- 13 files changed, 94 insertions(+), 456 deletions(-) delete mode 100644 src/reporter/AutoReporter.ts delete mode 100644 src/reporter/PlainReporter.ts delete mode 100644 src/reporter/ReporterFactory.ts delete mode 100644 src/reporter/Style.ts diff --git a/src/framework/Framework.ts b/src/framework/Framework.ts index 38eae40..0f25e59 100644 --- a/src/framework/Framework.ts +++ b/src/framework/Framework.ts @@ -7,7 +7,7 @@ import {TestbedSpecification} from '../testbeds/TestbedSpecification'; import {SuiteResult} from '../reporter/Results'; import {Reporter} from '../reporter/Reporter'; import {Outcome} from "../reporter/describers/Describer"; -import {AutoReporter} from '../reporter/AutoReporter'; +import {InkReporter} from '../reporter/ink/InkReporter'; export interface TesteeOptions { disabled?: boolean; @@ -52,7 +52,7 @@ export class Framework { private scheduled: Suite[] = []; - public readonly reporter: Reporter = new AutoReporter(); + public readonly reporter: Reporter = new InkReporter(); private constructor() { } diff --git a/src/reporter/AutoReporter.ts b/src/reporter/AutoReporter.ts deleted file mode 100644 index ad0da24..0000000 --- a/src/reporter/AutoReporter.ts +++ /dev/null @@ -1,84 +0,0 @@ -import {Reporter, SuiteRun} from './Reporter'; -import {ReporterFactory, ReporterSelection} from './ReporterFactory'; -import {ScenarioResult, StepOutcome, SuiteResult} from './Results'; -import {StyleType, Verbosity} from './index'; - -export class AutoReporter implements Reporter { - private selected?: Reporter; - private styleType: StyleType; - private verbosityLevel: Verbosity; - - constructor(style: StyleType = StyleType.plain, verbosity: Verbosity = Verbosity.normal, private readonly selection: ReporterSelection = ReporterSelection.auto) { - this.styleType = style; - this.verbosityLevel = verbosity; - } - - style(type: StyleType) { - this.styleType = type; - this.selected?.style(type); - } - - styling(): StyleType { - return this.selected?.styling() ?? this.styleType; - } - - verbosity(level: Verbosity) { - this.verbosityLevel = level; - this.selected?.verbosity(level); - } - - start() { - if (!this.selected) { - this.selected = ReporterFactory.create(this.styleType, this.verbosityLevel, this.selection); - } - this.selected.start(); - } - - suiteStarted(run: SuiteRun) { - this.delegate().suiteStarted(run); - } - - scenarioStarted(runId: string, scenario: ScenarioResult) { - this.delegate().scenarioStarted(runId, scenario); - } - - stepFinished(runId: string, scenario: ScenarioResult, step: StepOutcome) { - this.delegate().stepFinished(runId, scenario, step); - } - - scenarioFinished(runId: string, scenario: ScenarioResult) { - this.delegate().scenarioFinished(runId, scenario); - } - - suiteFinished(runId: string, suite: SuiteResult) { - this.delegate().suiteFinished(runId, suite); - } - - info(text: string) { - this.delegate().info(text); - } - - error(text: string) { - this.delegate().error(text); - } - - debug(text: string) { - this.delegate().debug(text); - } - - finish(durationMs: number) { - this.delegate().finish(durationMs); - } - - close(): Promise { - return this.delegate().close(); - } - - private delegate(): Reporter { - if (!this.selected) { - this.selected = ReporterFactory.create(this.styleType, this.verbosityLevel, this.selection); - } - - return this.selected; - } -} diff --git a/src/reporter/PlainReporter.ts b/src/reporter/PlainReporter.ts deleted file mode 100644 index c57ebef..0000000 --- a/src/reporter/PlainReporter.ts +++ /dev/null @@ -1,140 +0,0 @@ -import {green, red, yellow} from 'ansi-colors'; -import {version} from '../../package.json'; -import {indent} from '../util/printing'; -import {ArchiveWriter} from './ArchiveWriter'; -import {Reporter, SuiteRun} from './Reporter'; -import {ScenarioResult, StepOutcome, SuiteResult} from './Results'; -import {Style, styling as styleMap} from './Style'; -import {summarize} from './Summary'; -import {SilentDescriber} from './describers/Describer'; -import { - MinimalSuiteDescriber, - NormalSuiteDescriber, - ShortSuiteDescriber, - SuiteDescriber -} from './describers/SuiteDescribers'; -import {StyleType, Verbosity} from './index'; - -function describer(verbosity: Verbosity, item: SuiteResult): SuiteDescriber { - switch (verbosity) { - case Verbosity.none: - return new SilentDescriber(item); - case Verbosity.minimal: - return new MinimalSuiteDescriber(item); - case Verbosity.short: - return new ShortSuiteDescriber(item); - case Verbosity.normal: - case Verbosity.more: - case Verbosity.all: - case Verbosity.debug: - default: - return new NormalSuiteDescriber(item); - } -} - -export class PlainReporter implements Reporter { - private output: string = ''; - private indentationLevel: number = 2; - private readonly suites: SuiteResult[] = []; - private readonly archiveWriter: ArchiveWriter; - private design: Style; - private verboseness: Verbosity; - - constructor(style: StyleType = StyleType.plain, verbosity: Verbosity = Verbosity.normal, archiveWriter: ArchiveWriter = new ArchiveWriter()) { - this.design = styleMap(style); - this.verboseness = verbosity; - this.archiveWriter = archiveWriter; - } - - private indent(override?: number) { - return indent(override ?? this.indentationLevel, this.design.indentation); - } - - style(type: StyleType) { - this.design = styleMap(type); - } - - styling(): StyleType { - return this.design.type; - } - - verbosity(level: Verbosity) { - this.verboseness = level; - } - - start() { - console.log(this.indent() + this.design.colors.highlight(this.design.bullet) + this.design.colors.highlight('latch.') + this.design.emph(' General information')); - console.log(this.indent() + ' '.repeat(2) + this.design.emph('version') + ' '.repeat(5) + version); - console.log(this.indent() + ' '.repeat(2) + this.design.emph('archive') + ' '.repeat(5) + this.archiveWriter.archive); - console.log(this.design.end); - } - - suiteStarted(_run: SuiteRun) { - // Plain output remains suite-buffered for readable CI/non-TTY logs. - } - - scenarioStarted(_runId: string, _scenario: ScenarioResult) { - // Plain output remains suite-buffered for readable CI/non-TTY logs. - } - - stepFinished(_runId: string, _scenario: ScenarioResult, _step: StepOutcome) { - // Plain output remains suite-buffered for readable CI/non-TTY logs. - } - - scenarioFinished(_runId: string, _scenario: ScenarioResult) { - // Plain output remains suite-buffered for readable CI/non-TTY logs. - } - - suiteFinished(_runId: string, suiteResult: SuiteResult) { - this.suites.push(suiteResult); - const report: string[] = describer(this.verboseness, suiteResult).describe(this.design); - - for (const line of report) { - console.log(this.indent() + line); - } - console.log(this.design.end); - } - - finish(durationMs: number) { - this.archiveWriter.write(durationMs, this.suites); - const summary = summarize(this.suites); - - console.log(this.indent() + this.design.colors.highlight(this.design.bullet) + this.design.colors.highlight('results.') + this.design.emph(' Overview')); - console.log(); - this.indentationLevel += 1; - - const len: number = 12; - const suitePassing = `${summary.suites.passing} passing`; - const scenarioPassing = `${summary.scenarios.passing} passing`; - const actionPassing = `${summary.actions.passing} passing`; - - console.log(this.indent() + this.design.emph('Test suites:') + ' '.repeat(len - suitePassing.length) + this.design.emph((summary.suites.passing === summary.suites.total ? green : red)(suitePassing)) + `, ${summary.suites.total} total` + this.design.emph(` (${durationMs.toFixed(0)}ms)`)); - if (this.verboseness > Verbosity.minimal) { - console.log(this.indent() + this.design.emph('Scenarios:') + - ' '.repeat(2 + len - scenarioPassing.length) + this.design.emph((summary.scenarios.passing === summary.scenarios.total ? green : red)(scenarioPassing)) + - (summary.scenarios.skipped > 0 ? ', ' + this.design.emph(yellow(`${summary.scenarios.skipped} skipped`)) : '') + `, ${summary.scenarios.total} total`); - console.log(this.indent() + this.design.emph('Actions:') + ' '.repeat(4 + len - actionPassing.length) + this.design.emph((summary.actions.passing === summary.actions.total ? green : red)(actionPassing)) + (summary.actions.timeouts > 0 ? `, ${summary.actions.timeouts} timeouts` : '') + `, ${summary.actions.total} total`); - } - this.indentationLevel -= 1; - - console.log(this.design.end); - } - - info(text: string) { - this.output += `info: ${text}\n`; - } - - error(text: string) { - this.output += `error: ${text}\n`; - } - - debug(text: string) { - if (this.verboseness === Verbosity.debug) { - console.debug(text); - } - } - - async close(): Promise { - return Promise.resolve(); - } -} diff --git a/src/reporter/Reporter.ts b/src/reporter/Reporter.ts index 7da1b83..397a584 100644 --- a/src/reporter/Reporter.ts +++ b/src/reporter/Reporter.ts @@ -1,5 +1,5 @@ import {ScenarioResult, StepOutcome, SuiteResult} from './Results'; -import {StyleType, Verbosity} from './index'; +import {Verbosity} from './index'; export interface SuiteRun { id: string; @@ -35,9 +35,5 @@ export interface Reporter { close(): Promise; - style(type: StyleType): void; - - styling(): StyleType; - verbosity(level: Verbosity): void; } diff --git a/src/reporter/ReporterFactory.ts b/src/reporter/ReporterFactory.ts deleted file mode 100644 index 8c78d82..0000000 --- a/src/reporter/ReporterFactory.ts +++ /dev/null @@ -1,44 +0,0 @@ -import {Reporter} from './Reporter'; -import {PlainReporter} from './PlainReporter'; -import {InkReporter} from './ink/InkReporter'; -import {StyleType, Verbosity} from './index'; - -export enum ReporterSelection { - auto = 'auto', - plain = 'plain', - ink = 'ink' -} - -export function reporterSelectionFromEnvironment(): ReporterSelection { - switch (process.env.LATCH_REPORTER) { - case ReporterSelection.plain: - return ReporterSelection.plain; - case ReporterSelection.ink: - return ReporterSelection.ink; - case ReporterSelection.auto: - default: - return ReporterSelection.auto; - } -} - -export class ReporterFactory { - static create(style: StyleType = StyleType.plain, verbosity: Verbosity = Verbosity.normal, selection: ReporterSelection = reporterSelectionFromEnvironment()): Reporter { - if (selection === ReporterSelection.plain) { - return new PlainReporter(style, verbosity); - } - - if (selection === ReporterSelection.ink) { - return new InkReporter(style, verbosity); - } - - if (style === StyleType.github) { - return new PlainReporter(style, verbosity); - } - - if (process.stdout.isTTY) { - return new InkReporter(style, verbosity); - } - - return new PlainReporter(style, verbosity); - } -} diff --git a/src/reporter/Style.ts b/src/reporter/Style.ts deleted file mode 100644 index 7dd8430..0000000 --- a/src/reporter/Style.ts +++ /dev/null @@ -1,83 +0,0 @@ -import {blue, bold, green, inverse, red, yellow} from 'ansi-colors'; -import {StyleType} from './index'; - -interface Styler { - (s: string): string; -} - -interface Colors { - highlight: Styler; - success: Styler; - skipped: Styler; - failure: Styler; - failureMessage: Styler; - error: Styler; -} - -interface Labels { - suiteSuccess: string; - suiteSkipped: string; - - success: string; - skipped: string; - timeout: string; - failure: string; - error: string; -} - -// strategy factory -export function styling(type: StyleType): Style { - switch (type) { - case StyleType.github: - return new GitHub(); - case StyleType.plain: - default: - return new Plain(); - } -} - -// strategy pattern -export interface Style { - readonly type: StyleType; - - indentation: number; - - bullet: string; - end: string; - - emph: Styler; - - colors: Colors; - labels: Labels; -} - -export class Plain implements Style { - type = StyleType.plain; - indentation = 2; - bullet = '● '; - end = ''; - emph = (s: string) => bold(s); - colors: Colors = { - highlight: (s: string) => bold(blue(s)), - success: (s: string) => inverse(bold(green(s))), - skipped: (s: string) => inverse(bold(yellow(s))), - failure: (s: string) => inverse(bold(red(s))), - failureMessage: (s: string) => red(s), - error: (s: string) => inverse(bold(red(s))) - }; - labels: Labels = { - suiteSuccess: ' PASSED ', - suiteSkipped: ' SKIPPED ', - success: ' PASS ', - skipped: ' SKIP ', - timeout: ' TIMEOUT ', - failure: ' FAIL ', - error: ' ERROR ' - } -} - -export class GitHub extends Plain { - type = StyleType.github; - bullet = '::group::'; - end = '::endgroup::'; -} diff --git a/src/reporter/describers/Describer.ts b/src/reporter/describers/Describer.ts index 3313316..de9d124 100644 --- a/src/reporter/describers/Describer.ts +++ b/src/reporter/describers/Describer.ts @@ -1,10 +1,36 @@ import {Result, StepOutcome} from '../Results'; -import {Style} from '../Style'; +import {blue, bold, green, inverse, red, yellow} from 'ansi-colors'; + +const plain = { + bullet: '● ', + emph: (s: string) => bold(s), + colors: { + highlight: (s: string) => bold(blue(s)), + success: (s: string) => inverse(bold(green(s))), + skipped: (s: string) => inverse(bold(yellow(s))), + failure: (s: string) => inverse(bold(red(s))), + failureMessage: (s: string) => red(s), + error: (s: string) => inverse(bold(red(s))) + }, + labels: { + suiteSuccess: ' PASSED ', + suiteSkipped: ' SKIPPED ', + success: ' PASS ', + skipped: ' SKIP ', + timeout: ' TIMEOUT ', + failure: ' FAIL ', + error: ' ERROR ' + } +}; + +export function plainReporting() { + return plain; +} export interface Describable { readonly item: R; - describe(style: Style): string[]; + describe(): string[]; } export abstract class Describer implements Describable { @@ -14,7 +40,7 @@ export abstract class Describer implements Describable { this.item = item; } - abstract describe(style: Style): string[]; + abstract describe(): string[]; } export class SilentDescriber extends Describer { @@ -28,19 +54,19 @@ export class StepDescriber extends Describer { super(outcome); } - describe(style: Style): string[] { + describe(): string[] { switch (this.item.outcome) { case Outcome.succeeded: - return [`${style.colors.success(style.labels.success)} ${this.item.name}`]; + return [`${plain.colors.success(plain.labels.success)} ${this.item.name}`]; case Outcome.uncommenced: case Outcome.skipped: - return [`${style.colors.skipped(style.labels.skipped)} ${this.item.name}`]; + return [`${plain.colors.skipped(plain.labels.skipped)} ${this.item.name}`]; case Outcome.timedout: - return [`${style.colors.failure(style.labels.timeout)} ${this.item.name}`]; + return [`${plain.colors.failure(plain.labels.timeout)} ${this.item.name}`]; case Outcome.error: case Outcome.failed: default: - return [`${style.colors.failure(style.labels.failure)} ${this.item.name}\n ${style.colors.failureMessage(this.item.outcome + this.item.clarification)}`]; + return [`${plain.colors.failure(plain.labels.failure)} ${this.item.name}\n ${plain.colors.failureMessage(this.item.outcome + this.item.clarification)}`]; } } } @@ -55,4 +81,3 @@ export enum Outcome { } // const r Result = expect(e: Expected) <-- replaces the function in Testee todo - diff --git a/src/reporter/describers/ScenarioDescribers.ts b/src/reporter/describers/ScenarioDescribers.ts index 22d1157..727c105 100644 --- a/src/reporter/describers/ScenarioDescribers.ts +++ b/src/reporter/describers/ScenarioDescribers.ts @@ -1,7 +1,6 @@ // decorator class for minimal describers -import {Outcome, Describer, StepDescriber} from './Describer'; +import {Outcome, Describer, plainReporting, StepDescriber} from './Describer'; import {ScenarioResult} from '../Results'; -import {Style} from '../Style'; abstract class ScenarioDescriber extends Describer { protected readonly label?: string; @@ -13,7 +12,8 @@ abstract class ScenarioDescriber extends Describer { } export class MinimalScenarioDescriber extends ScenarioDescriber { - describe(style: Style): string[] { + describe(): string[] { + const style = plainReporting(); const report: string[] = []; report.push(style.colors.highlight(`scenario.`) + ' ' + style.emph(this.item.name) + ' ' + style.colors.highlight(this.label ?? '') + '\n'); return report; @@ -21,8 +21,9 @@ export class MinimalScenarioDescriber extends ScenarioDescriber { } export class ShortScenarioDescriber extends MinimalScenarioDescriber { - describe(style: Style): string[] { - let report: string[] = super.describe(style); + describe(): string[] { + const style = plainReporting(); + let report: string[] = super.describe(); if (this.item.outcome === Outcome.error) { report.push(style.colors.failureMessage(`${style.colors.failure(style.labels.error)}`)); report = report.concat(this.item.clarification.trim().split('\n')); @@ -32,11 +33,11 @@ export class ShortScenarioDescriber extends MinimalScenarioDescriber { } export class NormalScenarioDescriber extends ShortScenarioDescriber { - describe(style: Style): string[] { - let report: string[] = super.describe(style); + describe(): string[] { + let report: string[] = super.describe(); if (this.item.outcome !== Outcome.error) { this.item.outcomes().forEach((outcome) => { - report = report.concat(new StepDescriber(outcome).describe(style)); + report = report.concat(new StepDescriber(outcome).describe()); }); } return report; diff --git a/src/reporter/describers/SuiteDescribers.ts b/src/reporter/describers/SuiteDescribers.ts index 3d52609..8f0ae9d 100644 --- a/src/reporter/describers/SuiteDescribers.ts +++ b/src/reporter/describers/SuiteDescribers.ts @@ -1,20 +1,22 @@ -import {Outcome, Describer} from './Describer'; +import {Outcome, Describer, plainReporting} from './Describer'; import {SuiteResult} from '../Results'; -import {Style} from '../Style'; import {red} from 'ansi-colors'; import {NormalScenarioDescriber} from './ScenarioDescribers'; -const table: (style: Style) => Map = - (style: Style) => new Map([ +const table = () => { + const style = plainReporting(); + return new Map([ [Outcome.error, style.colors.error(style.labels.error)], [Outcome.succeeded, style.colors.success(style.labels.suiteSuccess)], [Outcome.failed, style.colors.failure(style.labels.failure)], [Outcome.skipped, style.colors.skipped(style.labels.suiteSkipped)]]); +}; export abstract class SuiteDescriber extends Describer {} export class MinimalSuiteDescriber extends SuiteDescriber { - describe(style: Style): string[] { + describe(): string[] { + const style = plainReporting(); const report: string[] = []; const status = (this.item.outcome === Outcome.error ? style.colors.error(style.labels.error) : (this.item.outcome === Outcome.succeeded ? style.colors.success(style.labels.suiteSuccess) : style.colors.failure(style.labels.failure))); @@ -24,9 +26,9 @@ export class MinimalSuiteDescriber extends SuiteDescriber { } export class ShortSuiteDescriber extends SuiteDescriber { - describe(style: Style): string[] { + describe(): string[] { let report: string[] = []; - report = report.concat(this.overview(style)); + report = report.concat(this.overview()); if (this.item.outcome === Outcome.error) { report.push(''); @@ -35,9 +37,10 @@ export class ShortSuiteDescriber extends SuiteDescriber { return report; } - protected overview(style: Style): string[] { + protected overview(): string[] { + const style = plainReporting(); const overview: string[] = []; - const status = table(style).get(this.item.outcome) ?? style.colors.skipped(style.labels.suiteSkipped); + const status = table().get(this.item.outcome) ?? style.colors.skipped(style.labels.suiteSkipped); overview.push(style.colors.highlight(style.bullet) + style.colors.highlight('suite.') + ` ${style.emph(this.item.name)}`); if (this.item.testbed) { overview.push(' '.repeat(2) + style.emph('testbed') + ' '.repeat(5) + this.item.testbed); @@ -50,14 +53,14 @@ export class ShortSuiteDescriber extends SuiteDescriber { } export class NormalSuiteDescriber extends ShortSuiteDescriber { - describe(style: Style): string[] { + describe(): string[] { let report: string[] = []; - report = report.concat(this.overview(style)); + report = report.concat(this.overview()); report.push(''); this.item.outcomes().forEach((scenario, index) => { - report = report.concat(new NormalScenarioDescriber(scenario, `(#${index + 1})`).describe(style)); + report = report.concat(new NormalScenarioDescriber(scenario, `(#${index + 1})`).describe()); report.push(''); }); diff --git a/src/reporter/index.ts b/src/reporter/index.ts index d3c003a..e1957b1 100644 --- a/src/reporter/index.ts +++ b/src/reporter/index.ts @@ -1,8 +1,3 @@ -export enum StyleType { - plain, - github -} - export enum Verbosity { none, minimal, @@ -14,4 +9,4 @@ export enum Verbosity { } export * from './Reporter'; -export * from './ReporterFactory'; +export * from './ink/InkReporter'; diff --git a/src/reporter/ink/InkReporter.ts b/src/reporter/ink/InkReporter.ts index 93167db..f3a5320 100644 --- a/src/reporter/ink/InkReporter.ts +++ b/src/reporter/ink/InkReporter.ts @@ -4,31 +4,20 @@ import {ArchiveWriter} from '../ArchiveWriter'; import {Reporter, SuiteRun} from '../Reporter'; import {ReporterState} from '../ReporterState'; import {ScenarioResult, StepOutcome, SuiteResult} from '../Results'; -import {StyleType, Verbosity} from '../index'; +import {Verbosity} from '../index'; import {App} from './App'; export class InkReporter implements Reporter { private readonly state = new ReporterState(); private readonly archiveWriter: ArchiveWriter; private instance?: Instance; - private styleType: StyleType; private verbosityLevel: Verbosity; - constructor(style: StyleType = StyleType.plain, verbosity: Verbosity = Verbosity.normal, archiveWriter: ArchiveWriter = new ArchiveWriter()) { - this.styleType = style; + constructor(verbosity: Verbosity = Verbosity.normal, archiveWriter: ArchiveWriter = new ArchiveWriter()) { this.verbosityLevel = verbosity; this.archiveWriter = archiveWriter; } - style(type: StyleType) { - this.styleType = type; - this.rerender(); - } - - styling(): StyleType { - return this.styleType; - } - verbosity(level: Verbosity) { this.verbosityLevel = level; this.rerender(); diff --git a/tests/unit/describers.test.ts b/tests/unit/describers.test.ts index 6b0d94b..aa350f6 100644 --- a/tests/unit/describers.test.ts +++ b/tests/unit/describers.test.ts @@ -7,7 +7,6 @@ import { import {ScenarioResult, StepOutcome, SuiteResult} from '../../src/reporter/Results'; import {Kind, Message, Step} from '../../src'; import {Outcome} from '../../src/reporter/describers/Describer'; -import {Plain} from '../../src/reporter/Style'; const steps: Step[] = [ { @@ -26,21 +25,21 @@ const dummy = new ScenarioResult({ test('[MinimalScenarioDescriber] : test printing', t => { const describer = new MinimalScenarioDescriber(dummy); dummy.aggregate([new StepOutcome(steps[0]).update(Outcome.succeeded), new StepOutcome(steps[1]).update(Outcome.succeeded)]); - t.is(describer.describe(new Plain()).join('\n'), '\x1B[1m\x1B[34mscenario.\x1B[39m\x1B[22m \x1B[1mScenario title\x1B[22m \n'); + t.is(describer.describe().join('\n'), '\x1B[1m\x1B[34mscenario.\x1B[39m\x1B[22m \x1B[1mScenario title\x1B[22m \n'); }); test('[ShortScenarioDescriber] : test printing', t => { const describer = new ShortScenarioDescriber(dummy); - const output = describer.describe(new Plain()).join('\n'); + const output = describer.describe().join('\n'); t.is(output, '\x1B[1m\x1B[34mscenario.\x1B[39m\x1B[22m \x1B[1mScenario title\x1B[22m \n'); t.false(['Send DUMP command', 'Send STEP OVER command'].some((element) => output.includes(element))); }); test('[NormalScenarioDescriber] : test printing', t => { - let output = new NormalScenarioDescriber(dummy).describe(new Plain()).join('\n'); + let output = new NormalScenarioDescriber(dummy).describe().join('\n'); t.true(['PASS', 'Send DUMP command', 'Send STEP OVER command'].every((element) => output.includes(element))); t.false(['FAIL'].some((element) => output.includes(element))); dummy.aggregate([new StepOutcome(steps[0]).update(Outcome.failed), new StepOutcome(steps[1]).update(Outcome.succeeded)]); - output = new NormalScenarioDescriber(dummy).describe(new Plain()).join('\n'); + output = new NormalScenarioDescriber(dummy).describe().join('\n'); t.true(['FAIL', 'Send DUMP command', 'PASS', 'Send STEP OVER command'].every((element) => output.includes(element))); }); diff --git a/tests/unit/reporter.test.ts b/tests/unit/reporter.test.ts index bcb4a9b..cee4c01 100644 --- a/tests/unit/reporter.test.ts +++ b/tests/unit/reporter.test.ts @@ -8,17 +8,14 @@ import {Framework, Suite} from '../../src/framework/Framework'; import {TestScenario} from '../../src/framework/scenario/TestScenario'; import {Kind, Step} from '../../src/framework/scenario/Step'; import {ArchiveWriter} from '../../src/reporter/ArchiveWriter'; -import {AutoReporter} from '../../src/reporter/AutoReporter'; import {InkReporter} from '../../src/reporter/ink/InkReporter'; import {App} from '../../src/reporter/ink/App'; -import {PlainReporter} from '../../src/reporter/PlainReporter'; import {Reporter, SuiteRun} from '../../src/reporter/Reporter'; -import {ReporterFactory, ReporterSelection} from '../../src/reporter/ReporterFactory'; import {ReporterState} from '../../src/reporter/ReporterState'; import {ScenarioResult, StepOutcome, SuiteResult} from '../../src/reporter/Results'; import {summarize} from '../../src/reporter/Summary'; import {Outcome} from '../../src/reporter/describers/Describer'; -import {StyleType, Verbosity} from '../../src/reporter'; +import {Verbosity} from '../../src/reporter'; const step = (title: string): Step => ({ title, @@ -49,6 +46,9 @@ const run = (id: string, suite: SuiteResult, index = 1): SuiteRun => ({ startedAt: Date.now() }); +const plainFrame = (frame: string): string => + frame.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ''); + test('Summary totals count suites, scenarios, and actions once', t => { const first = step('first'); const second = step('second'); @@ -142,23 +142,8 @@ test('ReporterState keeps active step visible before scenario and suite completi t.is(state.snapshot().completedRuns[0].suite, suite); }); -test.serial('ReporterFactory falls back to plain reporter for non-TTY stdout and GitHub style', t => { - const descriptor = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY'); - Object.defineProperty(process.stdout, 'isTTY', {value: false, configurable: true}); - - try { - t.true(ReporterFactory.create(StyleType.plain, Verbosity.normal, ReporterSelection.auto) instanceof PlainReporter); - - Object.defineProperty(process.stdout, 'isTTY', {value: true, configurable: true}); - t.true(ReporterFactory.create(StyleType.github, Verbosity.normal, ReporterSelection.auto) instanceof PlainReporter); - t.true(ReporterFactory.create(StyleType.plain, Verbosity.normal, ReporterSelection.ink) instanceof InkReporter); - } finally { - if (descriptor) { - Object.defineProperty(process.stdout, 'isTTY', descriptor); - } else { - delete (process.stdout as unknown as {isTTY?: boolean}).isTTY; - } - } +test('InkReporter exposes verbosity-only reporter configuration', t => { + t.true(new InkReporter(Verbosity.normal) instanceof InkReporter); }); test('Ink App applies running verbosity levels', t => { @@ -181,17 +166,17 @@ test('Ink App applies running verbosity levels', t => { state.suiteStarted(run('completed', completedSuite, 2)); state.suiteFinished('completed', completedSuite); - const normal = render(React.createElement(App, { + const normal = plainFrame(render(React.createElement(App, { snapshot: state.snapshot(), archive: 'suite.log', verbosity: Verbosity.normal - })).lastFrame() ?? ''; + })).lastFrame() ?? ''); - const more = render(React.createElement(App, { + const more = plainFrame(render(React.createElement(App, { snapshot: state.snapshot(), archive: 'suite.log', verbosity: Verbosity.more - })).lastFrame() ?? ''; + })).lastFrame() ?? ''); t.true(normal.includes('active-suite')); t.true(normal.includes('active-scenario')); @@ -224,7 +209,7 @@ test('Ink App renders compact final summary at the bottom', t => { verbosity: Verbosity.normal })); - const frame = app.lastFrame() ?? ''; + const frame = plainFrame(app.lastFrame() ?? ''); t.true(frame.includes('PASS 1 suites passed · 1 scenarios · 10 actions · 12ms')); t.true(frame.includes('PASS completed-suite')); t.regex(frame, /PASS completed-suite\s+1\/1\s+testee-2/); @@ -251,17 +236,17 @@ test('Ink App shows failed scenarios under suite rows for normal final verbosity state.suiteFinished('completed', completedSuite); state.finish(12); - const normal = render(React.createElement(App, { + const normal = plainFrame(render(React.createElement(App, { snapshot: state.snapshot(), archive: 'suite.log', verbosity: Verbosity.normal - })).lastFrame() ?? ''; + })).lastFrame() ?? ''); - const minimal = render(React.createElement(App, { + const minimal = plainFrame(render(React.createElement(App, { snapshot: state.snapshot(), archive: 'suite.log', verbosity: Verbosity.minimal - })).lastFrame() ?? ''; + })).lastFrame() ?? ''); t.true(normal.includes('FAIL completed-suite')); t.true(normal.includes('└─ failed-scenario')); @@ -286,17 +271,17 @@ test('Ink App expands failed actions for more and full history for all final ver state.suiteFinished('completed', completedSuite); state.finish(12); - const more = render(React.createElement(App, { + const more = plainFrame(render(React.createElement(App, { snapshot: state.snapshot(), archive: 'suite.log', verbosity: Verbosity.more - })).lastFrame() ?? ''; + })).lastFrame() ?? ''); - const all = render(React.createElement(App, { + const all = plainFrame(render(React.createElement(App, { snapshot: state.snapshot(), archive: 'suite.log', verbosity: Verbosity.all - })).lastFrame() ?? ''; + })).lastFrame() ?? ''); t.true(more.includes('└─ failed-scenario')); t.true(more.includes('└─ FAIL failed-step')); @@ -326,11 +311,11 @@ test('Ink App debug final verbosity is all plus framework and log details', t => state.suiteFinished('completed', completedSuite); state.finish(12); - const debug = render(React.createElement(App, { + const debug = plainFrame(render(React.createElement(App, { snapshot: state.snapshot(), archive: 'suite.log', verbosity: Verbosity.debug - })).lastFrame() ?? ''; + })).lastFrame() ?? ''); t.true(debug.includes('Latch')); t.true(debug.includes('archive suite.log')); @@ -349,17 +334,17 @@ test('Ink App respects debug verbosity for logs', t => { state.log('debug', 'debug-only'); state.log('info', 'info-visible'); - const normal = render(React.createElement(App, { + const normal = plainFrame(render(React.createElement(App, { snapshot: state.snapshot(), archive: 'suite.log', verbosity: Verbosity.normal - })).lastFrame() ?? ''; + })).lastFrame() ?? ''); - const debug = render(React.createElement(App, { + const debug = plainFrame(render(React.createElement(App, { snapshot: state.snapshot(), archive: 'suite.log', verbosity: Verbosity.debug - })).lastFrame() ?? ''; + })).lastFrame() ?? ''); t.false(normal.includes('debug-only')); t.true(normal.includes('info-visible')); @@ -395,10 +380,6 @@ test.serial('Framework.analyse waits for reporter cleanup before process exit', await new Promise(resolve => setTimeout(resolve, 5)); closed = true; }, - style() {}, - styling() { - return StyleType.plain; - }, verbosity() {} }; @@ -414,7 +395,7 @@ test.serial('Framework.analyse waits for reporter cleanup before process exit', t.true(exitedAfterClose); t.is(exitCode, 0); } finally { - framework.reporter = originalReporter ?? new AutoReporter(); + framework.reporter = originalReporter ?? new InkReporter(); process.exit = originalExit; } }); From 4f791c07a2286d2dd1ad8bc741215c84a26df61d Mon Sep 17 00:00:00 2001 From: Tom Lauwaerts Date: Tue, 11 Aug 2026 11:10:08 +0200 Subject: [PATCH 4/5] Fix ink exit --- src/reporter/ink/InkReporter.ts | 3 ++- tests/unit/reporter.test.ts | 13 +++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/reporter/ink/InkReporter.ts b/src/reporter/ink/InkReporter.ts index f3a5320..51f134c 100644 --- a/src/reporter/ink/InkReporter.ts +++ b/src/reporter/ink/InkReporter.ts @@ -83,8 +83,9 @@ export class InkReporter implements Reporter { this.rerender(); await this.flushInk(); + const closed = this.instance.waitUntilExit(); this.instance.unmount(); - await this.instance.waitUntilExit(); + await closed; await this.flushInk(); } diff --git a/tests/unit/reporter.test.ts b/tests/unit/reporter.test.ts index cee4c01..24364ed 100644 --- a/tests/unit/reporter.test.ts +++ b/tests/unit/reporter.test.ts @@ -146,6 +146,19 @@ test('InkReporter exposes verbosity-only reporter configuration', t => { t.true(new InkReporter(Verbosity.normal) instanceof InkReporter); }); +test('InkReporter close resolves after unmounting', async t => { + const reporter = new InkReporter(Verbosity.normal); + + reporter.start(); + + const closed = await Promise.race([ + reporter.close().then(() => true), + new Promise(resolve => setTimeout(() => resolve(false), 250)) + ]); + + t.true(closed); +}); + test('Ink App applies running verbosity levels', t => { const activeSuite = suiteResult('active-suite'); const activeScenario = new ScenarioResult(scenario('active-scenario')); From f6dccc48b137dab6dcd22f3da8150378f5b3b755 Mon Sep 17 00:00:00 2001 From: Tom Lauwaerts Date: Tue, 11 Aug 2026 13:17:09 +0200 Subject: [PATCH 5/5] Delete dead code --- package-lock.json | 10 --- package.json | 2 - src/framework/Framework.ts | 2 +- src/framework/Testee.ts | 2 +- src/framework/Verifier.ts | 6 +- src/reporter/ArchiveWriter.ts | 2 +- src/reporter/Outcome.ts | 8 ++ src/reporter/Results.ts | 4 +- src/reporter/Summary.ts | 2 +- src/reporter/describers/Describer.ts | 83 ------------------- src/reporter/describers/ScenarioDescribers.ts | 45 ---------- src/reporter/describers/SuiteDescribers.ts | 73 ---------------- src/reporter/ink/FinalSummary.tsx | 5 +- src/reporter/ink/StatusBadge.tsx | 2 +- src/reporter/ink/StepClarification.tsx | 18 ++++ src/reporter/ink/SuiteView.tsx | 5 +- tests/examples/example.ts | 2 +- tests/unit/describers.test.ts | 45 ---------- tests/unit/reporter.test.ts | 2 +- 19 files changed, 45 insertions(+), 273 deletions(-) create mode 100644 src/reporter/Outcome.ts delete mode 100644 src/reporter/describers/Describer.ts delete mode 100644 src/reporter/describers/ScenarioDescribers.ts delete mode 100644 src/reporter/describers/SuiteDescribers.ts create mode 100644 src/reporter/ink/StepClarification.tsx delete mode 100644 tests/unit/describers.test.ts diff --git a/package-lock.json b/package-lock.json index 2004b02..70839e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "0.6.0", "dependencies": { "@thi.ng/leb128": "^3.1.90", - "ansi-colors": "^4.1.3", "ieee754": "^1.2.1", "ink": "^3.2.0", "json-with-bigint": "^3.5.8", @@ -1586,15 +1585,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", diff --git a/package.json b/package.json index ab6b774..e6b0034 100644 --- a/package.json +++ b/package.json @@ -26,11 +26,9 @@ }, "dependencies": { "@thi.ng/leb128": "^3.1.90", - "ansi-colors": "^4.1.3", "ieee754": "^1.2.1", "ink": "^3.2.0", "json-with-bigint": "^3.5.8", - "ora": "^9.4.0", "react": "^17.0.2", "source-map": "^0.7.6", "ts-node": "^10.9.2", diff --git a/src/framework/Framework.ts b/src/framework/Framework.ts index 0f25e59..5a87302 100644 --- a/src/framework/Framework.ts +++ b/src/framework/Framework.ts @@ -6,7 +6,7 @@ import {TestbedSpecification} from '../testbeds/TestbedSpecification'; import {SuiteResult} from '../reporter/Results'; import {Reporter} from '../reporter/Reporter'; -import {Outcome} from "../reporter/describers/Describer"; +import {Outcome} from "../reporter/Outcome"; import {InkReporter} from '../reporter/ink/InkReporter'; export interface TesteeOptions { diff --git a/src/framework/Testee.ts b/src/framework/Testee.ts index 4e434ca..6a7c1de 100644 --- a/src/framework/Testee.ts +++ b/src/framework/Testee.ts @@ -9,7 +9,7 @@ import {TestScenario} from './scenario/TestScenario'; import {OutofPlaceSpecification, PlatformType, TestbedSpecification} from '../testbeds/TestbedSpecification'; import {CompileOutput, CompilerFactory} from '../manage/Compiler'; import {WABT} from '../util/env'; -import {Outcome} from '../reporter/describers/Describer'; +import {Outcome} from '../reporter/Outcome'; import {WASM} from '../sourcemap/Wasm'; import {DummyProxy} from '../testbeds/Emulator'; import {ScenarioResult, Skipped, StepOutcome, SuiteResult} from '../reporter/Results'; diff --git a/src/framework/Verifier.ts b/src/framework/Verifier.ts index 86dd711..30f2279 100644 --- a/src/framework/Verifier.ts +++ b/src/framework/Verifier.ts @@ -1,8 +1,7 @@ import {Behaviour, Description, Step} from './scenario/Step'; import {StepOutcome} from '../reporter/Results'; import {getValue} from './Testee'; -import {Outcome} from '../reporter/describers/Describer'; -import {bold} from 'ansi-colors'; +import {Outcome} from '../reporter/Outcome'; import {JSONStringify} from "json-with-bigint"; // decorator for Step class @@ -53,7 +52,8 @@ export class Verifier { if (deepEqual(actual, expected)) { result.update(Outcome.succeeded); } else { - result.update(Outcome.failed, `Expected ${bold(`${expected}`)} got ${bold(`${actual}`)}`); + result.update(Outcome.failed, `Expected ${expected} got`); + result.actual = `${actual}`; } return result; } diff --git a/src/reporter/ArchiveWriter.ts b/src/reporter/ArchiveWriter.ts index f93da98..4aeb7de 100644 --- a/src/reporter/ArchiveWriter.ts +++ b/src/reporter/ArchiveWriter.ts @@ -1,7 +1,7 @@ import {Archiver} from '../framework/Archiver'; import {SuiteResult} from './Results'; import {summarize} from './Summary'; -import {Outcome} from './describers/Describer'; +import {Outcome} from './Outcome'; export class ArchiveWriter { private readonly archiver: Archiver; diff --git a/src/reporter/Outcome.ts b/src/reporter/Outcome.ts new file mode 100644 index 0000000..cb1823f --- /dev/null +++ b/src/reporter/Outcome.ts @@ -0,0 +1,8 @@ +export enum Outcome { + uncommenced = 'not started', // test hasn't started + succeeded = 'success', // test succeeded + failed = 'failure: ', // test failed + timedout = 'timed out', // test failed + error = 'error: ', // test was unable to complete + skipped = 'skipped' // test has failing dependencies +} diff --git a/src/reporter/Results.ts b/src/reporter/Results.ts index dc09571..f10b795 100644 --- a/src/reporter/Results.ts +++ b/src/reporter/Results.ts @@ -1,11 +1,12 @@ import {Step} from '../framework/scenario/Step'; -import {Outcome} from './describers/Describer'; +import {Outcome} from './Outcome'; import {TestScenario} from '../framework/scenario/TestScenario'; import {Suite} from '../framework/Framework'; export interface Result { outcome: Outcome; clarification: string; + actual?: string; readonly name: string; readonly checks: number; @@ -140,6 +141,7 @@ export class SuiteResult extends AbstractAggregateResult { export class StepOutcome implements Result { outcome: Outcome; clarification: string; + actual?: string; readonly name: string; readonly testbed?: string; readonly checks: number; diff --git a/src/reporter/Summary.ts b/src/reporter/Summary.ts index 3286a73..824bf89 100644 --- a/src/reporter/Summary.ts +++ b/src/reporter/Summary.ts @@ -1,5 +1,5 @@ import {SuiteResult} from './Results'; -import {Outcome} from './describers/Describer'; +import {Outcome} from './Outcome'; import {ReporterSnapshot} from './ReporterState'; export interface SummaryTotals { diff --git a/src/reporter/describers/Describer.ts b/src/reporter/describers/Describer.ts deleted file mode 100644 index de9d124..0000000 --- a/src/reporter/describers/Describer.ts +++ /dev/null @@ -1,83 +0,0 @@ -import {Result, StepOutcome} from '../Results'; -import {blue, bold, green, inverse, red, yellow} from 'ansi-colors'; - -const plain = { - bullet: '● ', - emph: (s: string) => bold(s), - colors: { - highlight: (s: string) => bold(blue(s)), - success: (s: string) => inverse(bold(green(s))), - skipped: (s: string) => inverse(bold(yellow(s))), - failure: (s: string) => inverse(bold(red(s))), - failureMessage: (s: string) => red(s), - error: (s: string) => inverse(bold(red(s))) - }, - labels: { - suiteSuccess: ' PASSED ', - suiteSkipped: ' SKIPPED ', - success: ' PASS ', - skipped: ' SKIP ', - timeout: ' TIMEOUT ', - failure: ' FAIL ', - error: ' ERROR ' - } -}; - -export function plainReporting() { - return plain; -} - -export interface Describable { - readonly item: R; - - describe(): string[]; -} - -export abstract class Describer implements Describable { - public readonly item: R; - - constructor(item: R) { - this.item = item; - } - - abstract describe(): string[]; -} - -export class SilentDescriber extends Describer { - describe(): string[] { - return []; - } -} - -export class StepDescriber extends Describer { - constructor(outcome: Result) { - super(outcome); - } - - describe(): string[] { - switch (this.item.outcome) { - case Outcome.succeeded: - return [`${plain.colors.success(plain.labels.success)} ${this.item.name}`]; - case Outcome.uncommenced: - case Outcome.skipped: - return [`${plain.colors.skipped(plain.labels.skipped)} ${this.item.name}`]; - case Outcome.timedout: - return [`${plain.colors.failure(plain.labels.timeout)} ${this.item.name}`]; - case Outcome.error: - case Outcome.failed: - default: - return [`${plain.colors.failure(plain.labels.failure)} ${this.item.name}\n ${plain.colors.failureMessage(this.item.outcome + this.item.clarification)}`]; - } - } -} - -export enum Outcome { - uncommenced = 'not started', // test hasn't started - succeeded = 'success', // test succeeded - failed = 'failure: ', // test failed - timedout = 'timed out', // test failed - error = 'error: ', // test was unable to complete - skipped = 'skipped' // test has failing dependencies -} - -// const r Result = expect(e: Expected) <-- replaces the function in Testee todo diff --git a/src/reporter/describers/ScenarioDescribers.ts b/src/reporter/describers/ScenarioDescribers.ts deleted file mode 100644 index 727c105..0000000 --- a/src/reporter/describers/ScenarioDescribers.ts +++ /dev/null @@ -1,45 +0,0 @@ -// decorator class for minimal describers -import {Outcome, Describer, plainReporting, StepDescriber} from './Describer'; -import {ScenarioResult} from '../Results'; - -abstract class ScenarioDescriber extends Describer { - protected readonly label?: string; - - constructor(scenario: ScenarioResult, label?: string) { - super(scenario); - this.label = label; - } -} - -export class MinimalScenarioDescriber extends ScenarioDescriber { - describe(): string[] { - const style = plainReporting(); - const report: string[] = []; - report.push(style.colors.highlight(`scenario.`) + ' ' + style.emph(this.item.name) + ' ' + style.colors.highlight(this.label ?? '') + '\n'); - return report; - } -} - -export class ShortScenarioDescriber extends MinimalScenarioDescriber { - describe(): string[] { - const style = plainReporting(); - let report: string[] = super.describe(); - if (this.item.outcome === Outcome.error) { - report.push(style.colors.failureMessage(`${style.colors.failure(style.labels.error)}`)); - report = report.concat(this.item.clarification.trim().split('\n')); - } - return report; - } -} - -export class NormalScenarioDescriber extends ShortScenarioDescriber { - describe(): string[] { - let report: string[] = super.describe(); - if (this.item.outcome !== Outcome.error) { - this.item.outcomes().forEach((outcome) => { - report = report.concat(new StepDescriber(outcome).describe()); - }); - } - return report; - } -} diff --git a/src/reporter/describers/SuiteDescribers.ts b/src/reporter/describers/SuiteDescribers.ts deleted file mode 100644 index 8f0ae9d..0000000 --- a/src/reporter/describers/SuiteDescribers.ts +++ /dev/null @@ -1,73 +0,0 @@ -import {Outcome, Describer, plainReporting} from './Describer'; -import {SuiteResult} from '../Results'; -import {red} from 'ansi-colors'; -import {NormalScenarioDescriber} from './ScenarioDescribers'; - -const table = () => { - const style = plainReporting(); - return new Map([ - [Outcome.error, style.colors.error(style.labels.error)], - [Outcome.succeeded, style.colors.success(style.labels.suiteSuccess)], - [Outcome.failed, style.colors.failure(style.labels.failure)], - [Outcome.skipped, style.colors.skipped(style.labels.suiteSkipped)]]); -}; - -export abstract class SuiteDescriber extends Describer {} - -export class MinimalSuiteDescriber extends SuiteDescriber { - describe(): string[] { - const style = plainReporting(); - const report: string[] = []; - const status = (this.item.outcome === Outcome.error ? style.colors.error(style.labels.error) : - (this.item.outcome === Outcome.succeeded ? style.colors.success(style.labels.suiteSuccess) : style.colors.failure(style.labels.failure))); - report.push(style.colors.highlight(style.bullet) + style.colors.highlight('suite.') + ` ${style.emph(this.item.name)} ${status}`); - return report; - } -} - -export class ShortSuiteDescriber extends SuiteDescriber { - describe(): string[] { - let report: string[] = []; - report = report.concat(this.overview()); - - if (this.item.outcome === Outcome.error) { - report.push(''); - report.push(' '.repeat(2) + red(this.item.clarification.toString())); - } - return report; - } - - protected overview(): string[] { - const style = plainReporting(); - const overview: string[] = []; - const status = table().get(this.item.outcome) ?? style.colors.skipped(style.labels.suiteSkipped); - overview.push(style.colors.highlight(style.bullet) + style.colors.highlight('suite.') + ` ${style.emph(this.item.name)}`); - if (this.item.testbed) { - overview.push(' '.repeat(2) + style.emph('testbed') + ' '.repeat(5) + this.item.testbed); - } - overview.push(' '.repeat(2) + style.emph('scenarios') + ' '.repeat(3) + this.item.outcomes().length); - overview.push(' '.repeat(2) + style.emph('actions') + ' '.repeat(5) + this.item.outcomes().flatMap((scenario) => scenario.outcomes() ?? []).flat().length); //.reduce((total, count) => total + count)); - overview.push(' '.repeat(2) + style.emph('status') + ' '.repeat(6) + status); - return overview; - } -} - -export class NormalSuiteDescriber extends ShortSuiteDescriber { - describe(): string[] { - - let report: string[] = []; - report = report.concat(this.overview()); - report.push(''); - - this.item.outcomes().forEach((scenario, index) => { - report = report.concat(new NormalScenarioDescriber(scenario, `(#${index + 1})`).describe()); - report.push(''); - }); - - if (this.item.outcome === Outcome.error) { - report.push(' '.repeat(2) + red(this.item.clarification)); - } - - return report; - } -} diff --git a/src/reporter/ink/FinalSummary.tsx b/src/reporter/ink/FinalSummary.tsx index da02ce5..efe4403 100644 --- a/src/reporter/ink/FinalSummary.tsx +++ b/src/reporter/ink/FinalSummary.tsx @@ -1,10 +1,11 @@ import {Box, Text} from 'ink'; import {ReporterSnapshot} from '../ReporterState'; import {summarizeSnapshot} from '../Summary'; -import {Outcome} from '../describers/Describer'; +import {Outcome} from '../Outcome'; import {Verbosity} from '../index'; import {alignRight, pad} from './format'; import {StatusBadge} from './StatusBadge'; +import {StepClarification} from './StepClarification'; import {Tree, TreeNode} from './Tree'; import {preservesFullHistory, showsActionDetails} from './verbosity'; @@ -63,7 +64,7 @@ export function FinalSummary({snapshot, archive, verbosity}: Props) { {step.name} - {step.clarification ? {step.clarification} : null} + {step.clarification || step.actual !== undefined ? : null} ) })) diff --git a/src/reporter/ink/StatusBadge.tsx b/src/reporter/ink/StatusBadge.tsx index dbc2f19..983e10b 100644 --- a/src/reporter/ink/StatusBadge.tsx +++ b/src/reporter/ink/StatusBadge.tsx @@ -1,5 +1,5 @@ import {Text} from 'ink'; -import {Outcome} from '../describers/Describer'; +import {Outcome} from '../Outcome'; interface Props { outcome: Outcome; diff --git a/src/reporter/ink/StepClarification.tsx b/src/reporter/ink/StepClarification.tsx new file mode 100644 index 0000000..d87160a --- /dev/null +++ b/src/reporter/ink/StepClarification.tsx @@ -0,0 +1,18 @@ +import {Text} from 'ink'; +import {StepOutcome} from '../Results'; + +interface Props { + step: StepOutcome; +} + +export function StepClarification({step}: Props) { + if (!step.clarification && step.actual === undefined) { + return null; + } + + return ( + + {' '}{step.clarification}{step.actual !== undefined ? {step.actual} : null} + + ); +} diff --git a/src/reporter/ink/SuiteView.tsx b/src/reporter/ink/SuiteView.tsx index ebd15ef..3abf12d 100644 --- a/src/reporter/ink/SuiteView.tsx +++ b/src/reporter/ink/SuiteView.tsx @@ -1,8 +1,9 @@ import {Box, Text} from 'ink'; import {ReporterRunState} from '../ReporterState'; import {Verbosity} from '../index'; -import {Outcome} from '../describers/Describer'; +import {Outcome} from '../Outcome'; import {StatusBadge} from './StatusBadge'; +import {StepClarification} from './StepClarification'; import {duration, pad, plural} from './format'; import {Tree, TreeNode} from './Tree'; import {preservesFullHistory, showsActionDetails} from './verbosity'; @@ -45,7 +46,7 @@ export function SuiteView({run, active, verbosity}: Props) { {step.name} - {step.clarification ? {step.clarification} : null} + {step.clarification || step.actual !== undefined ? : null} ) })) diff --git a/tests/examples/example.ts b/tests/examples/example.ts index fff0642..855181d 100644 --- a/tests/examples/example.ts +++ b/tests/examples/example.ts @@ -156,5 +156,5 @@ reverse.test({ new Invoker('read', [WASM.i32(15n)], WASM.i32(1n))] }) -framework.reporter.verbosity(Verbosity.debug); +framework.reporter.verbosity(Verbosity.normal); framework.analyse([spec, debug, threethree, copysign]); diff --git a/tests/unit/describers.test.ts b/tests/unit/describers.test.ts deleted file mode 100644 index aa350f6..0000000 --- a/tests/unit/describers.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import test from 'ava'; -import { - MinimalScenarioDescriber, - NormalScenarioDescriber, - ShortScenarioDescriber -} from '../../src/reporter/describers/ScenarioDescribers'; -import {ScenarioResult, StepOutcome, SuiteResult} from '../../src/reporter/Results'; -import {Kind, Message, Step} from '../../src'; -import {Outcome} from '../../src/reporter/describers/Describer'; - -const steps: Step[] = [ - { - title: 'Send DUMP command', - instruction: {kind: Kind.Request, value: Message.dump}, - expected: [{'pc': {kind: 'comparison', value: (_: Object, value: number) => value > 0}}] - }, { - title: 'Send STEP OVER command', - instruction: {kind: Kind.Request, value: Message.stepOver} - } -]; -const dummy = new ScenarioResult({ - title: 'Scenario title', program: 'artifacts/blink.wat', steps: steps -}); - -test('[MinimalScenarioDescriber] : test printing', t => { - const describer = new MinimalScenarioDescriber(dummy); - dummy.aggregate([new StepOutcome(steps[0]).update(Outcome.succeeded), new StepOutcome(steps[1]).update(Outcome.succeeded)]); - t.is(describer.describe().join('\n'), '\x1B[1m\x1B[34mscenario.\x1B[39m\x1B[22m \x1B[1mScenario title\x1B[22m \n'); -}); - -test('[ShortScenarioDescriber] : test printing', t => { - const describer = new ShortScenarioDescriber(dummy); - const output = describer.describe().join('\n'); - t.is(output, '\x1B[1m\x1B[34mscenario.\x1B[39m\x1B[22m \x1B[1mScenario title\x1B[22m \n'); - t.false(['Send DUMP command', 'Send STEP OVER command'].some((element) => output.includes(element))); -}); - -test('[NormalScenarioDescriber] : test printing', t => { - let output = new NormalScenarioDescriber(dummy).describe().join('\n'); - t.true(['PASS', 'Send DUMP command', 'Send STEP OVER command'].every((element) => output.includes(element))); - t.false(['FAIL'].some((element) => output.includes(element))); - dummy.aggregate([new StepOutcome(steps[0]).update(Outcome.failed), new StepOutcome(steps[1]).update(Outcome.succeeded)]); - output = new NormalScenarioDescriber(dummy).describe().join('\n'); - t.true(['FAIL', 'Send DUMP command', 'PASS', 'Send STEP OVER command'].every((element) => output.includes(element))); -}); diff --git a/tests/unit/reporter.test.ts b/tests/unit/reporter.test.ts index 24364ed..0a07bed 100644 --- a/tests/unit/reporter.test.ts +++ b/tests/unit/reporter.test.ts @@ -14,7 +14,7 @@ import {Reporter, SuiteRun} from '../../src/reporter/Reporter'; import {ReporterState} from '../../src/reporter/ReporterState'; import {ScenarioResult, StepOutcome, SuiteResult} from '../../src/reporter/Results'; import {summarize} from '../../src/reporter/Summary'; -import {Outcome} from '../../src/reporter/describers/Describer'; +import {Outcome} from '../../src/reporter/Outcome'; import {Verbosity} from '../../src/reporter'; const step = (title: string): Step => ({