From faf6a9173b6eb28d710bf0e36198b5b8ed65b845 Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Sat, 26 Aug 2023 17:06:30 -0700 Subject: [PATCH] chore: consolidate option parsing BREAKING CHANGE: The `Smoker` class can no longer be instantiated directly; use `Smoker.init()`. The `verbose` option will now cause a fatal error to throw its exception to the terminal. Rule configuration is now `severity string`, `rule-specific options`, or a tuple of `[rule-specifc options, severity string]`. It can no longer be `[rule-specific options` nor `[severity string]`. The config file schema has changed to reflect this. Type `SmokeOptions` removed and is now the same type as `SmokerOptions`. All options--wherever they come from--now go thru a single object schema. This allows elimination some custom logic and duplicate types and provides better normalization. Rules _must_ provide an options schema, even if that is just `ZodTypeObject`. Rules may now provide default values for options; the `opts` parameter to a `RuleCheckFn` is now always defined and at minimum an empty object. fix(checks): the "warn" severity is respected; closes #333 --- __snapshots__/cli.spec.ts.js | 312 +++++++---- package-lock.json | 24 +- package.json | 7 +- schema/midnight-smoker.schema.json | 528 +++++++++--------- scripts/generate-schema.ts | 23 +- src/cli.ts | 222 ++++---- src/config-file.ts | 48 ++ src/config.ts | 146 ----- src/error.ts | 19 + src/{events.ts => event.ts} | 47 +- src/index.ts | 9 +- src/options.ts | 117 ++++ src/pm/npm7.ts | 1 + src/rules/builtin/no-banned-files.ts | 21 +- src/rules/builtin/no-missing-entry-point.ts | 2 + src/rules/builtin/no-missing-exports.ts | 66 +-- src/rules/builtin/no-missing-pkg-files.ts | 44 +- src/rules/check-options.ts | 43 ++ src/rules/index.ts | 2 +- src/rules/result.ts | 3 + src/rules/rule-config.ts | 41 -- src/rules/rule.ts | 92 ++- src/rules/severity.ts | 18 +- src/schema-util.ts | 18 + src/smoker.ts | 259 +++++---- src/types.ts | 81 +-- src/util.ts | 4 +- test/e2e/cli.spec.ts | 68 ++- test/e2e/config-file.spec.ts | 23 +- test/e2e/fixture/check-error/id_rsa | 1 + test/e2e/fixture/check-error/index.js | 1 + test/e2e/fixture/check-error/package.json | 12 + test/e2e/fixture/check-off/id_rsa | 1 + test/e2e/fixture/check-off/index.js | 1 + test/e2e/fixture/check-off/package.json | 12 + test/e2e/fixture/check-warn/id_rsa | 1 + test/e2e/fixture/check-warn/index.js | 1 + test/e2e/fixture/check-warn/package.json | 12 + .../fixture/config-package-json/package.json | 12 + .../fixture/no-missing-pkg-files/index.js | 1 + .../fixture/no-missing-pkg-files/package.json | 5 + test/e2e/rules/no-banned-files.spec.ts | 8 +- test/e2e/rules/no-missing-entry-point.spec.ts | 10 +- test/e2e/rules/no-missing-exports.spec.ts | 6 +- test/e2e/rules/no-missing-pkg-files.spec.ts | 14 +- test/e2e/rules/rule-helpers.ts | 15 +- test/e2e/rules/severity.spec.ts | 65 +++ test/unit/options.spec.ts | 33 ++ test/unit/rule.spec.ts | 54 ++ test/unit/smoker.spec.ts | 104 ++-- 50 files changed, 1501 insertions(+), 1156 deletions(-) create mode 100644 src/config-file.ts delete mode 100644 src/config.ts rename src/{events.ts => event.ts} (90%) create mode 100644 src/options.ts create mode 100644 src/rules/check-options.ts delete mode 100644 src/rules/rule-config.ts create mode 100644 src/schema-util.ts create mode 100644 test/e2e/fixture/check-error/id_rsa create mode 100644 test/e2e/fixture/check-error/index.js create mode 100644 test/e2e/fixture/check-error/package.json create mode 100644 test/e2e/fixture/check-off/id_rsa create mode 100644 test/e2e/fixture/check-off/index.js create mode 100644 test/e2e/fixture/check-off/package.json create mode 100644 test/e2e/fixture/check-warn/id_rsa create mode 100644 test/e2e/fixture/check-warn/index.js create mode 100644 test/e2e/fixture/check-warn/package.json create mode 100644 test/e2e/fixture/config-package-json/package.json create mode 100644 test/e2e/rules/fixture/no-missing-pkg-files/index.js create mode 100644 test/e2e/rules/severity.spec.ts create mode 100644 test/unit/options.spec.ts create mode 100644 test/unit/rule.spec.ts diff --git a/__snapshots__/cli.spec.ts.js b/__snapshots__/cli.spec.ts.js index f66d1065..b5724a39 100644 --- a/__snapshots__/cli.spec.ts.js +++ b/__snapshots__/cli.spec.ts.js @@ -1,6 +1,4 @@ -exports[ - 'midnight-smoker smoker CLI script single script when the script succeeds should produce expected output [snapshot] 1' -] = ` +exports['midnight-smoker smoker CLI script single script when the script succeeds should produce expected output [snapshot] 1'] = ` 💨 midnight-smoker v - Packing current project… ✔ Packed 1 unique package using npm@… @@ -9,11 +7,9 @@ exports[ - Running script 0/1… ✔ Successfully ran 1 script ✔ Lovey-dovey! 💖 -`; +` -exports[ - 'midnight-smoker smoker CLI script single script when the script fails should produce expected output [snapshot] 1' -] = ` +exports['midnight-smoker smoker CLI script single script when the script fails should produce expected output [snapshot] 1'] = ` 💨 midnight-smoker v - Packing current project… ✔ Packed 1 unique package using npm@… @@ -29,11 +25,9 @@ exports[ ✖ 🤮 Maurice! -`; +` -exports[ - 'midnight-smoker smoker CLI script multiple scripts when the scripts succeed should produce expected output [snapshot] 1' -] = ` +exports['midnight-smoker smoker CLI script multiple scripts when the scripts succeed should produce expected output [snapshot] 1'] = ` 💨 midnight-smoker v - Packing current project… ✔ Packed 1 unique package using npm@… @@ -42,11 +36,9 @@ exports[ - Running script 0/2… ✔ Successfully ran 2 scripts ✔ Lovey-dovey! 💖 -`; +` -exports[ - 'midnight-smoker smoker CLI option --help should show help text [snapshot] 1' -] = ` +exports['midnight-smoker smoker CLI option --help should show help text [snapshot] 1'] = ` smoker [scripts..] Run tests against a package as it would be published @@ -61,10 +53,10 @@ Behavior: --include-root Include the workspace root; must provide '--all' [boolean] --json Output JSON only [boolean] --pm Run script(s) with a specific package manager; - [@version] [array] [default: "npm@latest"] + [@version] [array] [default: ["npm@latest"]] --loose Ignore missing scripts (used with --all) [boolean] --workspace Run script in a specific workspace or workspaces [array] - --checks Run built-in checks [boolean] [default: true] + --checks Run built-in checks [boolean] Options: --version Show version number [boolean] @@ -72,103 +64,140 @@ Options: --help Show help [boolean] For more info, see https://github.com/boneskull/midnight-smoker -`; +` -exports[ - 'midnight-smoker smoker CLI option --json when the script succeeds should produce expected script output [snapshot] 1' -] = { - scripts: [ +exports['midnight-smoker smoker CLI option --json when the script succeeds should produce expected script output [snapshot] 1'] = { + "scripts": [ { - pkgName: 'single-script', - script: 'smoke', - rawResult: { - command: - '/bin/node /.bin/corepack npm@9.8.1 run smoke', - escapedCommand: - '"/bin/node" "/.bin/corepack" "npm@9.8.1" run smoke', - exitCode: 0, - stdout: '\n> single-script@1.0.0 smoke\n> exit 0\n', - stderr: '', - failed: false, - timedOut: false, - isCanceled: false, - killed: false, + "pkgName": "single-script", + "script": "smoke", + "rawResult": { + "command": "/bin/node /.bin/corepack npm@9.8.1 run smoke", + "escapedCommand": "\"/bin/node\" \"/.bin/corepack\" \"npm@9.8.1\" run smoke", + "exitCode": 0, + "stdout": "\n> single-script@1.0.0 smoke\n> exit 0\n", + "stderr": "", + "failed": false, + "timedOut": false, + "isCanceled": false, + "killed": false }, - cwd: '', - }, + "cwd": "" + } ], - checks: { - failed: [], - passed: [ + "checks": { + "failed": [], + "passed": [ { - rule: { - name: 'no-missing-pkg-files', - description: - 'Checks that files referenced in package.json exist in the tarball', + "rule": { + "name": "no-missing-pkg-files", + "description": "Checks that files referenced in package.json exist in the tarball" }, - context: { - pkgJson: '', - pkg: '', - severity: 'error', + "context": { + "pkgJson": "", + "pkg": "", + "severity": "error" }, - failed: false, + "failed": false }, { - rule: { - name: 'no-banned-files', - description: 'Bans certain files from being published', + "rule": { + "name": "no-banned-files", + "description": "Bans certain files from being published" }, - context: { - pkgJson: '', - pkg: '', - severity: 'error', + "context": { + "pkgJson": "", + "pkg": "", + "severity": "error" }, - failed: false, + "failed": false }, { - rule: { - name: 'no-missing-entry-point', - description: - 'Checks that the package contains an entry point; only applies to CJS packages without an "exports" field', + "rule": { + "name": "no-missing-entry-point", + "description": "Checks that the package contains an entry point; only applies to CJS packages without an \"exports\" field" }, - context: { - pkgJson: '', - pkg: '', - severity: 'error', + "context": { + "pkgJson": "", + "pkg": "", + "severity": "error" }, - failed: false, + "failed": false }, { - rule: { - name: 'no-missing-exports', - description: - 'Checks that all files in the "exports" field (if present) exist', + "rule": { + "name": "no-missing-exports", + "description": "Checks that all files in the \"exports\" field (if present) exist" }, - context: { - pkgJson: '', - pkg: '', - severity: 'error', + "context": { + "pkgJson": "", + "pkg": "", + "severity": "error" }, - failed: false, - }, - ], - }, - opts: { - _: [], - json: true, - scripts: ['smoke'], - add: [], - pm: ['npm@latest'], - workspace: [], - checks: true, - $0: 'smoker', - verbose: false, + "failed": false + } + ] }, -}; + "opts": { + "add": [], + "all": false, + "bail": false, + "includeRoot": false, + "json": true, + "linger": false, + "verbose": false, + "workspace": [], + "pm": [ + "npm@latest" + ], + "script": [ + "smoke" + ], + "scripts": [ + "smoke" + ], + "loose": false, + "checks": true, + "rules": { + "no-banned-files": { + "severity": "error", + "opts": { + "allow": [], + "deny": [] + } + }, + "no-missing-pkg-files": { + "severity": "error", + "opts": { + "bin": true, + "browser": true, + "types": true, + "fields": [ + "bin", + "browser", + "types" + ] + } + }, + "no-missing-entry-point": { + "severity": "error", + "opts": {} + }, + "no-missing-exports": { + "severity": "error", + "opts": { + "types": true, + "require": true, + "import": true, + "order": true, + "glob": true + } + } + } + } +} -exports[ - 'midnight-smoker smoker CLI option --json when the script fails should provide helpful result [snapshot] 1' -] = ` +exports['midnight-smoker smoker CLI option --json when the script fails should provide helpful result [snapshot] 1'] = ` { "results": { "scripts": [ @@ -220,19 +249,57 @@ exports[ "failed": [] }, "opts": { - "_": [], - "json": true, - "checks": false, - "scripts": [ - "smoke" - ], "add": [], + "all": false, + "bail": false, + "includeRoot": false, + "json": true, + "linger": false, + "verbose": false, + "workspace": [], "pm": [ "npm@latest" ], - "workspace": [], - "$0": "smoker", - "verbose": false + "script": [ + "smoke" + ], + "scripts": [ + "smoke" + ], + "loose": false, + "checks": false, + "rules": { + "no-banned-files": { + "severity": "error", + "opts": { + "allow": [], + "deny": [] + } + }, + "no-missing-pkg-files": { + "severity": "error", + "opts": { + "bin": true, + "browser": true, + "types": true, + "fields": [] + } + }, + "no-missing-entry-point": { + "severity": "error", + "opts": {} + }, + "no-missing-exports": { + "severity": "error", + "opts": { + "types": true, + "require": true, + "import": true, + "order": true, + "glob": true + } + } + } } }, "stats": { @@ -246,4 +313,43 @@ exports[ "passedChecks": null } } -`; +` + +exports['midnight-smoker smoker CLI check when a check fails when the rule severity is "error" should produce expected output [snapshot] 1'] = ` +💨 midnight-smoker v +- Packing current project… +✔ Packed 1 unique package using npm@… +- Installing 1 unique package from tarball using npm@… +✔ Installed 1 unique package from tarball +- Running 0/4 checks… +✖ 1 check of 4 failed +ℹ Check failures in package check-error: +» ✖ Banned file found: id_rsa (Private SSH key) + +✖ 🤮 Maurice! +` + +exports['midnight-smoker smoker CLI check when a check fails when the rule severity is "warn" should produce expected output [snapshot] 1'] = ` +💨 midnight-smoker v +- Packing current project… +✔ Packed 1 unique package using npm@… +- Installing 1 unique package from tarball using npm@… +✔ Installed 1 unique package from tarball +- Running 0/4 checks… +✖ 1 check of 4 failed +ℹ Check failures in package check-warn: +» ⚠ Banned file found: id_rsa (Private SSH key) + +✔ Lovey-dovey! 💖 +` + +exports['midnight-smoker smoker CLI check when a check fails when the rule severity is "off" should produce expected output [snapshot] 1'] = ` +💨 midnight-smoker v +- Packing current project… +✔ Packed 1 unique package using npm@… +- Installing 1 unique package from tarball using npm@… +✔ Installed 1 unique package from tarball +- Running 0/3 checks… +✔ Successfully ran 3 checks +✔ Lovey-dovey! 💖 +` diff --git a/package-lock.json b/package-lock.json index cc636493..5f520a6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,10 +13,12 @@ "chalk": "4.1.2", "corepack": "0.20.0", "debug": "4.3.4", + "deepmerge": "4.3.1", "execa": "5.1.1", "glob": "10.3.3", "is-file-esm": "1.0.0", "lilconfig": "2.1.0", + "log-symbols": "4.1.0", "ora": "5.4.1", "pluralize": "8.0.0", "read-pkg-up": "7.0.1", @@ -25,7 +27,8 @@ "strict-event-emitter-types": "2.0.0", "which": "3.0.1", "yargs": "17.7.2", - "zod": "3.22.2" + "zod": "3.22.2", + "zod-validation-error": "1.5.0" }, "bin": { "smoker": "bin/smoker.js" @@ -2268,6 +2271,14 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/defaults": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", @@ -8162,6 +8173,17 @@ "peerDependencies": { "zod": "^3.21.4" } + }, + "node_modules/zod-validation-error": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-1.5.0.tgz", + "integrity": "sha512-/7eFkAI4qV0tcxMBB/3+d2c1P6jzzZYdYSlBuAklzMuCrJu5bzJfHS0yVAS87dRHVlhftd6RFJDIvv03JgkSbw==", + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "zod": "^3.18.0" + } } } } diff --git a/package.json b/package.json index 56b4ecdc..c7fc1145 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "test": "run-s test:unit test:smoke", "test:ci": "run-s test:unit test:smoke test:e2e", "test:unit": "mocha \"test/unit/**/*.spec.ts\"", - "pretest:e2e": "run-s rebuild", + "pretest:e2e": "run-s build", "test:e2e": "mocha --timeout 20s --slow 10s \"test/e2e/**/*.spec.ts\"", "test:smoke": "node ./bin/smoker.js smoke:ts smoke:js --add ts-node --add @tsconfig/node16", "test:update-snapshots": "cross-env SNAPSHOT_UPDATE=1 npm run test:e2e -- --fgrep \"[snapshot]\"", @@ -87,10 +87,12 @@ "chalk": "4.1.2", "corepack": "0.20.0", "debug": "4.3.4", + "deepmerge": "4.3.1", "execa": "5.1.1", "glob": "10.3.3", "is-file-esm": "1.0.0", "lilconfig": "2.1.0", + "log-symbols": "4.1.0", "ora": "5.4.1", "pluralize": "8.0.0", "read-pkg-up": "7.0.1", @@ -99,7 +101,8 @@ "strict-event-emitter-types": "2.0.0", "which": "3.0.1", "yargs": "17.7.2", - "zod": "3.22.2" + "zod": "3.22.2", + "zod-validation-error": "1.5.0" }, "devDependencies": { "@commitlint/cli": "17.7.1", diff --git a/schema/midnight-smoker.schema.json b/schema/midnight-smoker.schema.json index dca2dc58..e1be13c8 100644 --- a/schema/midnight-smoker.schema.json +++ b/schema/midnight-smoker.schema.json @@ -1,329 +1,299 @@ { - "$ref": "#/definitions/midnight-smoker-config", - "definitions": { - "midnight-smoker-config": { + "type": "object", + "properties": { + "add": { + "$ref": "#/$defs/stringOrStringArray", + "description": "Add an extra package to the list of packages to be installed." + }, + "all": { + "$ref": "#/$defs/defaultFalse", + "default": false, + "description": "Operate on all workspaces. The root workspace is omitted unless `includeRoot` is `true`." + }, + "bail": { + "$ref": "#/$defs/defaultFalse", + "default": false, + "description": "Fail on first script failure." + }, + "includeRoot": { + "$ref": "#/$defs/defaultFalse", + "default": false, + "description": "Operate on the root workspace. Only has an effect if `all` is `true`." + }, + "json": { + "$ref": "#/$defs/defaultFalse", + "default": false, + "description": "Output JSON only." + }, + "linger": { + "$ref": "#/$defs/defaultFalse", + "default": false, + "description": "Do not delete temp directories after completion." + }, + "verbose": { + "$ref": "#/$defs/defaultFalse", + "default": false, + "description": "Verbose logging." + }, + "workspace": { + "$ref": "#/$defs/stringOrStringArray", + "description": "One or more workspaces to run scripts in." + }, + "pm": { + "$ref": "#/$defs/stringOrStringArray", + "default": "npm@latest", + "description": "Package manager(s) to use." + }, + "script": { + "$ref": "#/$defs/stringOrStringArray", + "description": "Script(s) to run. Alias of `scripts`." + }, + "scripts": { + "$ref": "#/$defs/stringOrStringArray", + "description": "Script(s) to run. Alias of `script`." + }, + "loose": { + "$ref": "#/$defs/defaultFalse", + "default": false, + "description": "If `true`, fail if a workspace is missing a script." + }, + "checks": { + "$ref": "#/$defs/defaultTrue", + "default": true, + "description": "If `false`, run no builtin checks." + }, + "rules": { "type": "object", "properties": { - "add": { + "no-banned-files": { "anyOf": [ { - "type": "string" + "$ref": "#/$defs/severity", + "default": "error", + "description": "Severity of a rule. `off` disables the rule, `warn` will warn on violations, and `error` will error on violations." }, { - "type": "array", - "items": { - "type": "string" - } - } - ], - "description": "Add an extra package to the list of packages to be installed." - }, - "all": { - "type": "boolean", - "description": "Operate on all workspaces. The root workspace is omitted unless `includeRoot` is `true`." - }, - "bail": { - "type": "boolean", - "description": "Fail on first script failure." - }, - "includeRoot": { - "type": "boolean", - "description": "Operate on the root workspace. Only has an effect if `all` is `true`." - }, - "json": { - "type": "boolean", - "description": "Output JSON only." - }, - "linger": { - "type": "boolean", - "description": "Do not delete temp directories after completion." - }, - "verbose": { - "type": "boolean", - "description": "Verbose logging." - }, - "workspace": { - "anyOf": [ - { - "type": "string" + "type": "object", + "properties": { + "allow": { + "$ref": "#/$defs/arrayOfNonEmptyStrings", + "default": [], + "description": "Allow these banned files" + }, + "deny": { + "$ref": "#/$defs/arrayOfNonEmptyStrings", + "default": [], + "description": "Deny these additional files" + } + }, + "additionalProperties": false }, { "type": "array", - "items": { - "type": "string" - } + "minItems": 2, + "maxItems": 2, + "items": [ + { + "$ref": "#/properties/rules/properties/no-banned-files/anyOf/1" + }, + { + "$ref": "#/properties/rules/properties/no-banned-files/anyOf/0" + } + ] } - ], - "description": "One or more workspaces to run scripts in." + ] }, - "pm": { + "no-missing-pkg-files": { "anyOf": [ { - "type": "string" + "$ref": "#/$defs/severity", + "default": "error", + "description": "Severity of a rule. `off` disables the rule, `warn` will warn on violations, and `error` will error on violations." }, { - "type": "array", - "items": { - "type": "string" - } - } - ], - "description": "Package manager(s) to use." - }, - "script": { - "anyOf": [ - { - "type": "string" + "type": "object", + "properties": { + "bin": { + "$ref": "#/$defs/defaultTrue", + "default": true, + "description": "Check the \"bin\" field (if it exists)" + }, + "browser": { + "$ref": "#/$defs/defaultTrue", + "default": true, + "description": "Check the \"browser\" field (if it exists)" + }, + "types": { + "$ref": "#/$defs/defaultTrue", + "default": true, + "description": "Check the \"types\" field (if it exists)" + }, + "fields": { + "$ref": "#/$defs/arrayOfNonEmptyStrings", + "default": [], + "description": "Check files referenced by these additional top-level fields" + } + }, + "additionalProperties": false }, { "type": "array", - "items": { - "type": "string" - } + "minItems": 2, + "maxItems": 2, + "items": [ + { + "$ref": "#/properties/rules/properties/no-missing-pkg-files/anyOf/1" + }, + { + "$ref": "#/properties/rules/properties/no-missing-pkg-files/anyOf/0" + } + ] } - ], - "description": "Script(s) to run. Alias of `scripts`." + ] }, - "scripts": { + "no-missing-entry-point": { "anyOf": [ { - "type": "string" + "$ref": "#/$defs/severity", + "default": "error", + "description": "Severity of a rule. `off` disables the rule, `warn` will warn on violations, and `error` will error on violations." }, + {}, { "type": "array", - "items": { - "type": "string" - } - } - ], - "description": "Script(s) to run. Alias of `script`." - }, - "loose": { - "type": "boolean", - "description": "If `true`, fail if a workspace is missing a script." - }, - "checks": { - "type": "boolean", - "description": "If `false`, run no builtin checks." - }, - "rules": { - "type": "object", - "properties": { - "no-banned-files": { - "anyOf": [ - { - "type": "string", - "enum": [ - "off", - "warn", - "error" - ], - "description": "Severity of a rule. `off` disables the rule, `warn` will warn on violations, and `error` will error on violations.", - "default": "error" - }, - { - "type": "object", - "properties": { - "allow": { - "type": "array", - "items": { - "type": "string", - "minLength": 1 - }, - "description": "Allow these banned files" - }, - "deny": { - "type": "array", - "items": { - "type": "string", - "minLength": 1 - }, - "description": "Deny these additional files" - } - }, - "additionalProperties": false - }, + "minItems": 2, + "maxItems": 2, + "items": [ { - "type": "array", - "minItems": 1, - "maxItems": 1, - "items": [ - { - "$ref": "#/definitions/midnight-smoker-config/properties/rules/properties/no-banned-files/anyOf/1" - } - ] + "$ref": "#/properties/rules/properties/no-missing-entry-point/anyOf/1" }, { - "type": "array", - "minItems": 2, - "maxItems": 2, - "items": [ - { - "$ref": "#/definitions/midnight-smoker-config/properties/rules/properties/no-banned-files/anyOf/1" - }, - { - "$ref": "#/definitions/midnight-smoker-config/properties/rules/properties/no-banned-files/anyOf/0" - } - ] + "$ref": "#/properties/rules/properties/no-missing-entry-point/anyOf/0" } ] + } + ] + }, + "no-missing-exports": { + "anyOf": [ + { + "$ref": "#/$defs/severity", + "default": "error", + "description": "Severity of a rule. `off` disables the rule, `warn` will warn on violations, and `error` will error on violations." }, - "no-missing-pkg-files": { - "anyOf": [ - { - "$ref": "#/definitions/midnight-smoker-config/properties/rules/properties/no-banned-files/anyOf/0", - "default": "error", - "description": "Severity of a rule. `off` disables the rule, `warn` will warn on violations, and `error` will error on violations." - }, - { - "type": "object", - "properties": { - "bin": { - "type": "boolean", - "default": true, - "description": "Check the \"bin\" field (if it exists)" - }, - "fields": { - "type": "array", - "items": { - "type": "string", - "minLength": 1 - }, - "description": "Check files referenced by these additional top-level fields" - } - }, - "additionalProperties": false + { + "type": "object", + "properties": { + "types": { + "$ref": "#/$defs/defaultTrue", + "default": true, + "description": "Assert a \"types\" conditional export matches a file with a .d.ts extension" }, - { - "type": "array", - "minItems": 1, - "maxItems": 1, - "items": [ - { - "$ref": "#/definitions/midnight-smoker-config/properties/rules/properties/no-missing-pkg-files/anyOf/1" - } - ] + "require": { + "$ref": "#/$defs/defaultTrue", + "default": true, + "description": "Assert a \"require\" conditional export matches a CJS script" }, - { - "type": "array", - "minItems": 2, - "maxItems": 2, - "items": [ - { - "$ref": "#/definitions/midnight-smoker-config/properties/rules/properties/no-missing-pkg-files/anyOf/1" - }, - { - "$ref": "#/definitions/midnight-smoker-config/properties/rules/properties/no-missing-pkg-files/anyOf/0" - } - ] - } - ] - }, - "no-missing-entry-point": { - "anyOf": [ - { - "$ref": "#/definitions/midnight-smoker-config/properties/rules/properties/no-banned-files/anyOf/0", - "default": "error", - "description": "Severity of a rule. `off` disables the rule, `warn` will warn on violations, and `error` will error on violations." + "import": { + "$ref": "#/$defs/defaultTrue", + "default": true, + "description": "Assert an \"import\" conditional export matches a ESM module" }, - { - "not": {} + "order": { + "$ref": "#/$defs/defaultTrue", + "default": true, + "description": "Assert conditional export \"default\", if present, is the last export" }, - { - "type": "array", - "minItems": 1, - "maxItems": 1, - "items": [ - { - "$ref": "#/definitions/midnight-smoker-config/properties/rules/properties/no-missing-entry-point/anyOf/1" - } - ] - }, - { - "type": "array", - "minItems": 2, - "maxItems": 2, - "items": [ - { - "$ref": "#/definitions/midnight-smoker-config/properties/rules/properties/no-missing-entry-point/anyOf/1" - }, - { - "$ref": "#/definitions/midnight-smoker-config/properties/rules/properties/no-missing-entry-point/anyOf/0" - } - ] + "glob": { + "$ref": "#/$defs/defaultTrue", + "default": true, + "description": "Allow glob patterns in subpath exports" } - ] + }, + "additionalProperties": false }, - "no-missing-exports": { - "anyOf": [ - { - "$ref": "#/definitions/midnight-smoker-config/properties/rules/properties/no-banned-files/anyOf/0", - "default": "error", - "description": "Severity of a rule. `off` disables the rule, `warn` will warn on violations, and `error` will error on violations." - }, - { - "type": "object", - "properties": { - "types": { - "type": "boolean", - "default": true, - "description": "Assert a \"types\" conditional export matches a file with a .d.ts extension" - }, - "require": { - "type": "boolean", - "default": true, - "description": "Assert a \"require\" conditional export matches a CJS script" - }, - "import": { - "type": "boolean", - "default": true, - "description": "Assert an \"import\" conditional export matches a ESM module" - }, - "order": { - "type": "boolean", - "default": true, - "description": "Assert conditional export \"default\", if present, is the last export" - }, - "glob": { - "type": "boolean", - "default": true, - "description": "Allow glob patterns in subpath exports" - } - }, - "additionalProperties": false - }, + { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": [ { - "type": "array", - "minItems": 1, - "maxItems": 1, - "items": [ - { - "$ref": "#/definitions/midnight-smoker-config/properties/rules/properties/no-missing-exports/anyOf/1" - } - ] + "$ref": "#/properties/rules/properties/no-missing-exports/anyOf/1" }, { - "type": "array", - "minItems": 2, - "maxItems": 2, - "items": [ - { - "$ref": "#/definitions/midnight-smoker-config/properties/rules/properties/no-missing-exports/anyOf/1" - }, - { - "$ref": "#/definitions/midnight-smoker-config/properties/rules/properties/no-missing-exports/anyOf/0" - } - ] + "$ref": "#/properties/rules/properties/no-missing-exports/anyOf/0" } ] } - }, - "additionalProperties": false, - "description": "Rule configuration for checks" + ] } }, "additionalProperties": false, - "description": "midnight-smoker configuration file schema" + "default": { + "no-missing-pkg-files": { + "bin": true, + "browser": true, + "types": true, + "fields": [] + }, + "no-banned-files": { + "allow": [], + "deny": [] + }, + "no-missing-entry-point": {}, + "no-missing-exports": { + "types": true, + "require": true, + "import": true, + "order": true, + "glob": true + } + }, + "description": "Rule configuration for checks" + } + }, + "additionalProperties": false, + "description": "midnight-smoker options schema", + "$defs": { + "defaultTrue": { + "type": "boolean", + "default": true + }, + "defaultFalse": { + "type": "boolean", + "default": false + }, + "stringOrStringArray": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/stringOrStringArray/anyOf/0" + } + } + ], + "default": [] + }, + "arrayOfNonEmptyStrings": { + "type": "array", + "items": { + "$ref": "#/$defs/stringOrStringArray/anyOf/0" + }, + "default": [] + }, + "severity": { + "type": "string", + "enum": [ + "off", + "warn", + "error" + ], + "description": "Severity of a rule. `off` disables the rule, `warn` will warn on violations, and `error` will error on violations.", + "default": "error" } }, "$schema": "http://json-schema.org/draft-07/schema#" diff --git a/scripts/generate-schema.ts b/scripts/generate-schema.ts index a93fbbbf..dacdd14f 100755 --- a/scripts/generate-schema.ts +++ b/scripts/generate-schema.ts @@ -3,12 +3,25 @@ import path from 'node:path'; import {writeFileSync} from 'node:fs'; import {zodToJsonSchema} from 'zod-to-json-schema'; -import {SmokerConfigSchema} from '../src'; +import { + zTrue, + zFalse, + zStringOrArray, + zNonEmptyStringArray, +} from '../src/schema-util'; +import {zRawSmokerOptions} from '../src/options'; +import {zCheckSeverity} from '../src/rules/severity'; -const jsonSchema = zodToJsonSchema( - SmokerConfigSchema, - 'midnight-smoker-config', -); +const jsonSchema = zodToJsonSchema(zRawSmokerOptions, { + definitions: { + defaultTrue: zTrue, + defaultFalse: zFalse, + stringOrStringArray: zStringOrArray, + arrayOfNonEmptyStrings: zNonEmptyStringArray, + severity: zCheckSeverity, + }, + definitionPath: '$defs', +}); writeFileSync( path.join(__dirname, '..', 'schema', 'midnight-smoker.schema.json'), diff --git a/src/cli.ts b/src/cli.ts index 7fd26dfc..0fe8e603 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,15 +1,21 @@ import {blue, cyan, red, white, yellow} from 'chalk'; +import createDebug from 'debug'; +import deepMerge from 'deepmerge'; +import {error, info, warning} from 'log-symbols'; import ora from 'ora'; import pluralize from 'pluralize'; import {hideBin} from 'yargs/helpers'; import yargs from 'yargs/yargs'; -import {SmokerConfig, readConfig} from './config'; +import {readConfigFile} from './config-file'; import {NotImplementedError} from './error'; -import {Events, type SmokerEvents} from './events'; +import {Event, type SmokerEvent} from './event'; +import {DEFAULT_PACKAGE_MANAGER_ID} from './options'; import type {CheckFailure} from './rules/result'; import {Smoker} from './smoker'; import type {SmokerJsonOutput, SmokerStats} from './types'; -import {normalizeStringArray, readPackageJson} from './util'; +import {readPackageJson} from './util'; + +const debug = createDebug('midnight-smoker:cli'); const BEHAVIOR_GROUP = 'Behavior:'; @@ -29,21 +35,9 @@ async function main(args: string[]): Promise { const {packageJson} = result; const {version, homepage} = packageJson; - /** - * These options can be specified more than once. - * - * Unfortunately, the typing gets hinky if we try to define the options object outside of the `options()` call, so we cannot programmatically gather these. - */ - const arrayOptNames: Readonly> = new Set([ - 'add', - 'workspace', - 'pm', - 'script', - 'scripts', - ]); - + let verbose = false; try { - const config = await readConfig(); + const config = await readConfigFile(); await y .scriptName('smoker') @@ -58,52 +52,43 @@ async function main(args: string[]): Promise { requiresArg: true, array: true, type: 'string', - coerce: normalizeStringArray, } as const; return yargs .positional('scripts', { describe: 'Script(s) in package.json to run', type: 'string', - coerce: normalizeStringArray, - default: config.scripts, }) .options({ add: { describe: 'Additional dependency to provide to script(s)', group: BEHAVIOR_GROUP, - default: config.add, ...arrayOptConfig, }, all: { describe: 'Run script in all workspaces', group: BEHAVIOR_GROUP, - default: config.all, type: 'boolean', }, bail: { describe: 'When running scripts, halt on first error', group: BEHAVIOR_GROUP, - default: config.bail, type: 'boolean', }, 'include-root': { describe: "Include the workspace root; must provide '--all'", group: BEHAVIOR_GROUP, - default: config.includeRoot, implies: 'all', type: 'boolean', }, json: { describe: 'Output JSON only', - default: config.json, group: BEHAVIOR_GROUP, type: 'boolean', }, linger: { describe: 'Do not clean up temp dir(s) after completion', group: BEHAVIOR_GROUP, - default: config.linger, hidden: true, type: 'boolean', }, @@ -111,49 +96,39 @@ async function main(args: string[]): Promise { describe: 'Run script(s) with a specific package manager; [@version]', group: BEHAVIOR_GROUP, - default: config.pm ?? 'npm@latest', + default: [DEFAULT_PACKAGE_MANAGER_ID], ...arrayOptConfig, }, loose: { describe: 'Ignore missing scripts (used with --all)', type: 'boolean', - default: config.loose, group: BEHAVIOR_GROUP, implies: 'all', }, workspace: { describe: 'Run script in a specific workspace or workspaces', group: BEHAVIOR_GROUP, - default: config.workspace, ...arrayOptConfig, }, checks: { describe: 'Run built-in checks', group: BEHAVIOR_GROUP, - default: Boolean('checks' in config ? config.checks : true), type: 'boolean', }, - }) - .check((argv) => { - if (argv.pm?.some((pm) => pm.startsWith('pnpm'))) { - throw new NotImplementedError('pnpm is currently unsupported'); - } - return true; }); }, async (argv) => { - const scripts = argv.scripts ?? []; - - let smoker: Smoker; - try { - smoker = await Smoker.init(scripts, argv); - } catch (err) { - console.error(red((err as Error).message)); - process.exitCode = 1; - return; + const opts = deepMerge(config, argv); + if (opts.pm?.some((pm) => pm.startsWith('pnpm'))) { + throw new NotImplementedError('pnpm is currently unsupported'); } + debug('Final options: %O', opts); + verbose = Boolean(opts.verbose); + + const smoker = await Smoker.init(opts); - if (argv.json) { + if (opts.json) { + // if we don't have any scripts, the script-related ones will remain null let totalPackages = null; let totalScripts = null; let totalPackageManagers = null; @@ -164,40 +139,50 @@ async function main(args: string[]): Promise { let passedChecks = null; smoker - .once(Events.INSTALL_BEGIN, ({uniquePkgs, packageManagers}) => { + .once(Event.INSTALL_BEGIN, ({uniquePkgs, packageManagers}) => { totalPackages = uniquePkgs.length; totalPackageManagers = packageManagers.length; }) - .once(Events.RUN_SCRIPTS_BEGIN, ({total}) => { + .once(Event.INSTALL_FAILED, () => { + process.exitCode = 1; + }) + .once(Event.RUN_SCRIPTS_BEGIN, ({total}) => { totalScripts = total; }) - .once(Events.RUN_SCRIPTS_FAILED, ({failed, passed}) => { + .once(Event.RUN_SCRIPTS_FAILED, ({failed, passed}) => { failedScripts = failed; passedScripts = passed; - smoker.removeAllListeners(Events.RUN_SCRIPTS_OK); + smoker.removeAllListeners(Event.RUN_SCRIPTS_OK); process.exitCode = 1; }) - .once(Events.RUN_SCRIPTS_OK, ({passed}) => { + .once(Event.RUN_SCRIPTS_OK, ({passed}) => { passedScripts = passed; failedScripts = 0; - smoker.removeAllListeners(Events.RUN_SCRIPTS_FAILED); + smoker.removeAllListeners(Event.RUN_SCRIPTS_FAILED); }) - .once(Events.RUN_CHECKS_BEGIN, ({total}) => { + .once(Event.RUN_CHECKS_BEGIN, ({total}) => { totalChecks = total; }) - .once(Events.RUN_CHECKS_FAILED, ({failed, passed}) => { + .once(Event.RUN_CHECKS_FAILED, ({failed, passed}) => { failedChecks = failed.length; passedChecks = passed.length; - smoker.removeAllListeners(Events.RUN_CHECKS_OK); - process.exitCode = 1; + smoker.removeAllListeners(Event.RUN_CHECKS_OK); + }) + .on(Event.RUN_CHECK_FAILED, ({config}) => { + if (config.severity === 'error') { + process.exitCode = 1; + } }) - .once(Events.RUN_CHECKS_OK, ({passed}) => { + .once(Event.RUN_CHECKS_OK, ({passed}) => { passedChecks = passed.length; failedChecks = 0; - smoker.removeAllListeners(Events.RUN_CHECKS_FAILED); + smoker.removeAllListeners(Event.RUN_CHECKS_FAILED); }) - .once(Events.SMOKE_FAILED, () => { - process.exitCode = 1; + .once(Event.SMOKE_OK, () => { + smoker.removeAllListeners(); + }) + .once(Event.SMOKE_FAILED, () => { + smoker.removeAllListeners(); }); let output: SmokerJsonOutput; @@ -236,22 +221,22 @@ async function main(args: string[]): Promise { console.log(JSON.stringify(output, null, 2)); } else { const spinner = ora(); - const scriptFailedEvts: SmokerEvents['RunScriptFailed'][] = []; - const checkFailedEvts: SmokerEvents['RunCheckFailed'][] = []; + const scriptFailedEvts: SmokerEvent['RunScriptFailed'][] = []; + const checkFailedEvts: SmokerEvent['RunCheckFailed'][] = []; smoker - .on(Events.SMOKE_BEGIN, () => { + .once(Event.SMOKE_BEGIN, () => { console.error( `💨 ${blue('midnight-smoker')} ${white(`v${version}`)}`, ); }) - .on(Events.PACK_BEGIN, () => { + .once(Event.PACK_BEGIN, () => { let what: string; - if (argv.workspace?.length) { - what = pluralize('workspace', argv.workspace.length, true); - } else if (argv.all) { + if (opts.workspace?.length) { + what = pluralize('workspace', opts.workspace.length, true); + } else if (opts.all) { what = 'all workspaces'; - if (argv.includeRoot) { + if (opts.includeRoot) { what += ' (and the workspace root)'; } } else { @@ -259,7 +244,7 @@ async function main(args: string[]): Promise { } spinner.start(`Packing ${what}…`); }) - .on(Events.PACK_OK, ({uniquePkgs, packageManagers}) => { + .once(Event.PACK_OK, ({uniquePkgs, packageManagers}) => { let msg = `Packed ${pluralize( 'unique package', uniquePkgs.length, @@ -277,12 +262,12 @@ async function main(args: string[]): Promise { msg += '…'; spinner.succeed(msg); }) - .on(Events.PACK_FAILED, (err) => { + .once(Event.PACK_FAILED, (err) => { spinner.fail(err.message); process.exitCode = 1; }) - .on( - Events.INSTALL_BEGIN, + .once( + Event.INSTALL_BEGIN, ({uniquePkgs, packageManagers, additionalDeps}) => { let msg = `Installing ${pluralize( 'unique package', @@ -309,11 +294,11 @@ async function main(args: string[]): Promise { spinner.start(msg); }, ) - .on(Events.INSTALL_FAILED, (err) => { + .once(Event.INSTALL_FAILED, (err) => { spinner.fail(err.message); process.exitCode = 1; }) - .on(Events.INSTALL_OK, ({uniquePkgs}) => { + .once(Event.INSTALL_OK, ({uniquePkgs}) => { spinner.succeed( `Installed ${pluralize( 'unique package', @@ -322,28 +307,30 @@ async function main(args: string[]): Promise { )} from tarball`, ); }) - .on(Events.RUN_CHECKS_BEGIN, ({total}) => { + .once(Event.RUN_CHECKS_BEGIN, ({total}) => { spinner.start(`Running 0/${total} checks…`); }) - .on(Events.RUN_CHECK_BEGIN, ({current, total}) => { + .on(Event.RUN_CHECK_BEGIN, ({current, total}) => { spinner.text = `Running check ${current}/${total}…`; }) - .on(Events.RUN_CHECK_FAILED, (evt) => { + .on(Event.RUN_CHECK_FAILED, (evt) => { checkFailedEvts.push(evt); - process.exitCode = 1; + if (evt.config.severity === 'error') { + process.exitCode = 1; + } }) - .on(Events.RUN_CHECKS_OK, ({total}) => { + .once(Event.RUN_CHECKS_OK, ({total}) => { spinner.succeed( `Successfully ran ${pluralize('check', total, true)}`, ); }) - .on(Events.RUN_CHECKS_FAILED, ({total, failed}) => { + .once(Event.RUN_CHECKS_FAILED, ({total, failed}) => { spinner.fail( `${pluralize( 'check', failed.length, true, - )} out of ${total} failed`, + )} of ${total} failed`, ); // TODO: move this crunching somewhere else @@ -363,33 +350,37 @@ async function main(args: string[]): Promise { for (const [pkgName, failed] of Object.entries( failedByPackage, )) { - let text = `${red( - 'Check failure', - )} details for package ${cyan(pkgName)}:\n`; - for (const failure of failed) { - text += `» ${yellow(failure)}\n`; + let text = `Check failures in package ${cyan(pkgName)}:\n`; + for (const {message, severity} of failed) { + if (severity === 'error') { + text += `» ${error} ${red(message)}\n`; + } else { + text += `» ${warning} ${yellow(message)}\n`; + } } spinner.info(text); } - + }) + .on(Event.RULE_ERROR, (err) => { + spinner.fail(err.message); process.exitCode = 1; }) - .on(Events.RUN_SCRIPTS_BEGIN, ({total}) => { + .once(Event.RUN_SCRIPTS_BEGIN, ({total}) => { spinner.start(`Running script 0/${total}…`); }) - .on(Events.RUN_SCRIPT_BEGIN, ({current, total}) => { + .on(Event.RUN_SCRIPT_BEGIN, ({current, total}) => { spinner.text = `Running script ${current}/${total}…`; }) - .on(Events.RUN_SCRIPT_FAILED, (evt) => { + .on(Event.RUN_SCRIPT_FAILED, (evt) => { scriptFailedEvts.push(evt); process.exitCode = 1; }) - .on(Events.RUN_SCRIPTS_OK, ({total}) => { + .once(Event.RUN_SCRIPTS_OK, ({total}) => { spinner.succeed( `Successfully ran ${pluralize('script', total, true)}`, ); }) - .on(Events.RUN_SCRIPTS_FAILED, ({total, failed: failures}) => { + .once(Event.RUN_SCRIPTS_FAILED, ({total, failed: failures}) => { spinner.fail( `${failures} of ${total} ${pluralize( 'script', @@ -405,19 +396,21 @@ async function main(args: string[]): Promise { } process.exitCode = 1; }) - .on(Events.SMOKE_FAILED, (err) => { + .once(Event.SMOKE_FAILED, (err) => { spinner.fail(err?.message ?? err); - process.exitCode = 1; }) - .on(Events.SMOKE_OK, () => { + .once(Event.SMOKE_OK, () => { spinner.succeed('Lovey-dovey! 💖'); }) - .on(Events.LINGERED, (dirs) => { + .once(Event.LINGERED, (dirs) => { console.error( - `Lingering ${pluralize('temp directory', dirs.length)}:\n`, + `${info} Lingering ${pluralize( + 'temp directory', + dirs.length, + )}:\n`, ); for (const dir of dirs) { - console.error(` ${yellow(dir)}`); + console.error(`» ${yellow(dir)}`); } }); await smoker.smoke(); @@ -432,40 +425,15 @@ async function main(args: string[]): Promise { }, }) .epilog(`For more info, see ${homepage}\n`) - .middleware( - /** - * If an array-type option is provided in both the config file and on the command-line, - * we use this to merge the two arrays. - * - * If we had any object-type options (we don't) then we'd do that here as well. - */ - (argv) => { - for (const key of arrayOptNames) { - const cfgKey = key as keyof SmokerConfig; - const arg = key as keyof typeof argv; - if (cfgKey in config && arg in argv) { - const cfgValue = ( - Array.isArray(config[cfgKey]) - ? config[cfgKey] - : [config[cfgKey]] - ) as string[]; - const argvValue = ( - Array.isArray(argv[arg]) ? argv[arg] : [argv[arg]] - ) as string[]; - argv[arg] = [...new Set([...cfgValue, ...argvValue])]; - } - } - - // squelch some output if `json` is true - argv.verbose = argv.json ? false : argv.verbose; - }, - ) .showHelpOnFail(false) .help() .strict() .parseAsync(); } catch (err) { - console.error(red(err)); + if (verbose) { + throw err; + } + console.error(error, red(err)); process.exitCode = 1; } } diff --git a/src/config-file.ts b/src/config-file.ts new file mode 100644 index 00000000..f5535ce9 --- /dev/null +++ b/src/config-file.ts @@ -0,0 +1,48 @@ +import createDebug from 'debug'; +import {lilconfig, type Options as LilconfigOpts} from 'lilconfig'; +import {RawSmokerOptions} from './options'; + +const debug = createDebug('midnight-smoker:config'); + +async function loadEsm(filepath: string) { + return import(filepath); +} + +const DEFAULT_OPTS: Readonly = Object.freeze({ + loaders: {'.mjs': loadEsm, '.js': loadEsm}, + searchPlaces: [ + 'package.json', + '.smokerrc.json', + '.smokerrc.js', + '.smokerrc.cjs', + '.smokerrc.mjs', + 'smoker.config.json', + 'smoker.config.js', + 'smoker.config.cjs', + 'smoker.config.mjs', + '.config/smokerrc.json', + '.config/smokerrc.js', + '.config/smokerrc.cjs', + '.config/smokerrc.mjs', + '.config/smoker.config.json', + '.config/smoker.config.js', + '.config/smoker.config.cjs', + '.config/smoker.config.mjs', + ], +}); + +export async function readConfigFile(): Promise { + const result = await lilconfig('smoker', DEFAULT_OPTS).search(); + + let opts: any = {}; + if (result?.config && !result.config.isEmpty) { + debug('Found config at %s', result.filepath); + opts = result.config; + + // I love ESM, really I do + if ('default' in opts) { + opts = opts.default; + } + } + return opts; +} diff --git a/src/config.ts b/src/config.ts deleted file mode 100644 index 15ec6ea1..00000000 --- a/src/config.ts +++ /dev/null @@ -1,146 +0,0 @@ -import {z} from 'zod'; -import createDebug from 'debug'; -import {lilconfig, type Options as LilconfigOpts} from 'lilconfig'; -import {castArray} from './util'; -import {RuleConfigSchema} from './rules'; - -const debug = createDebug('midnight-smoker:config'); - -async function loadEsm(filepath: string) { - return import(filepath); -} - -const DEFAULT_OPTS: Readonly = Object.freeze({ - loaders: {'.mjs': loadEsm, '.js': loadEsm}, - searchPlaces: [ - 'package.json', - '.smokerrc.json', - '.smokerrc.js', - '.smokerrc.cjs', - '.smokerrc.mjs', - 'smoker.config.json', - 'smoker.config.js', - 'smoker.config.cjs', - 'smoker.config.mjs', - '.config/smokerrc.json', - '.config/smokerrc.js', - '.config/smokerrc.cjs', - '.config/smokerrc.mjs', - '.config/smoker.config.json', - '.config/smoker.config.js', - '.config/smoker.config.cjs', - '.config/smoker.config.mjs', - ], -}); - -export const SmokerConfigSchema = z - .object({ - add: z - .union([z.string(), z.array(z.string())]) - .optional() - .describe( - 'Add an extra package to the list of packages to be installed.', - ), - all: z - .boolean() - .optional() - .describe( - 'Operate on all workspaces. The root workspace is omitted unless `includeRoot` is `true`.', - ), - bail: z.boolean().optional().describe('Fail on first script failure.'), - includeRoot: z - .boolean() - .optional() - .describe( - 'Operate on the root workspace. Only has an effect if `all` is `true`.', - ), - json: z.boolean().optional().describe('Output JSON only.'), - linger: z - .boolean() - .optional() - .describe('Do not delete temp directories after completion.'), - verbose: z.boolean().optional().describe('Verbose logging.'), - workspace: z - .union([z.string(), z.array(z.string())]) - .optional() - .describe('One or more workspaces to run scripts in.'), - pm: z - .union([z.string(), z.array(z.string())]) - .optional() - .describe('Package manager(s) to use.'), - script: z - .union([z.string(), z.array(z.string())]) - .optional() - .describe('Script(s) to run. Alias of `scripts`.'), - scripts: z - .union([z.string(), z.array(z.string())]) - .optional() - .describe('Script(s) to run. Alias of `script`.'), - loose: z - .boolean() - .optional() - .describe('If `true`, fail if a workspace is missing a script.'), - checks: z - .boolean() - .optional() - .describe('If `false`, run no builtin checks.'), - rules: RuleConfigSchema.optional().describe( - 'Rule configuration for checks', - ), - }) - .describe('midnight-smoker configuration file schema'); - -export type SmokerConfig = z.infer; - -/** - * After normalization, `script` and `scripts` are merged into `scripts` and - * some other props which can be `string | string[]` become `string[]` - * @internal - */ -export interface NormalizedSmokerConfig extends SmokerConfig { - add?: string[]; - workspace?: string[]; - pm?: string[]; - script?: never; - scripts?: string[]; -} - -type KeysWithValueType = keyof { - [P in keyof T as T[P] extends V ? P : never]: P; -}; - -const ARRAY_KEYS: Set< - KeysWithValueType -> = new Set(['add', 'workspace', 'pm', 'script', 'scripts']); - -export async function readConfig(): Promise { - const result = await lilconfig('smoker', DEFAULT_OPTS).search(); - - if (result?.config && !result.config.isEmpty) { - debug('Found config at %s', result.filepath); - let config: SmokerConfig = result.config; - - // I love ESM, really I do - if ('default' in config) { - config = (config as any).default; - } - - // TODO actually use zod to validate this. - // TODO also use transform or coerce or w/e to cast to array - for (const key of ARRAY_KEYS) { - const value = config[key]; - - if (value) { - config[key] = castArray(value); - } - } - - config.scripts = [...(config.script ?? []), ...(config.scripts ?? [])]; - delete config.script; - - debug('Loaded config: %O', config); - return config as NormalizedSmokerConfig; - } - - return {}; -} diff --git a/src/error.ts b/src/error.ts index 1ed0dd5f..e96127a7 100644 --- a/src/error.ts +++ b/src/error.ts @@ -5,6 +5,7 @@ import {bold} from 'chalk'; import {SmokeResults} from './types'; +import {StaticCheckContext} from '.'; /** * Options for {@link SmokerError} with a generic `Cause` type for `cause` prop. @@ -267,3 +268,21 @@ export class UnsupportedPackageManagerError extends SmokerError<{ }); } } + +export class RuleError extends SmokerError<{ + context: StaticCheckContext; + ruleName: string; + error: Error; +}> { + constructor( + message: string, + context: StaticCheckContext, + ruleName: string, + error: Error, + ) { + super(message, { + code: 'ESMOKER_RULEERROR', + cause: {context, ruleName, error}, + }); + } +} diff --git a/src/events.ts b/src/event.ts similarity index 90% rename from src/events.ts rename to src/event.ts index 7d3aaf72..1071f6c3 100644 --- a/src/events.ts +++ b/src/event.ts @@ -1,10 +1,11 @@ import type { InstallError, PackError, + RuleError, ScriptError, SmokeFailedError, } from './error'; -import type {CheckFailure, CheckOk, RuleConfig} from './rules'; +import type {CheckFailure, CheckOk, CheckOptions} from './rules'; import type { InstallManifest, RunManifest, @@ -55,13 +56,13 @@ export interface RunScriptFailedEventData extends RunScriptEventData { } export interface RunChecksBeginEventData { - config: RuleConfig; + config: CheckOptions; total: number; } export interface RunCheckEventData { rule: string; - config: RuleConfig[keyof RuleConfig]; + config: CheckOptions[keyof CheckOptions]; current: number; total: number; } @@ -76,7 +77,7 @@ export type RunCheckOkEventData = RunCheckEventData; export interface RunChecksEndEventData { total: number; - config: RuleConfig; + config: CheckOptions; passed: CheckOk[]; failed: CheckFailure[]; } @@ -84,7 +85,7 @@ export interface RunChecksEndEventData { export type RunChecksFailedEventData = RunChecksEndEventData; export type RunChecksOkEventData = RunChecksEndEventData; -export interface SmokerEvents { +export interface SmokerEvent { InstallBegin: InstallEventData; InstallFailed: InstallError; InstallOk: InstallEventData; @@ -92,6 +93,7 @@ export interface SmokerEvents { PackBegin: PackBeginEventData; PackFailed: PackError; PackOk: PackOkEventData; + RuleError: RuleError; RunCheckBegin: RunCheckEventData; RunCheckFailed: RunCheckFailedEventData; RunCheckOk: RunCheckOkEventData; @@ -109,27 +111,28 @@ export interface SmokerEvents { SmokeOk: SmokeResults; } -export const Events = { - SMOKE_BEGIN: 'SmokeBegin', - SMOKE_OK: 'SmokeOk', - SMOKE_FAILED: 'SmokeFailed', - PACK_BEGIN: 'PackBegin', - PACK_FAILED: 'PackFailed', - PACK_OK: 'PackOk', +export const Event = { INSTALL_BEGIN: 'InstallBegin', INSTALL_FAILED: 'InstallFailed', INSTALL_OK: 'InstallOk', - RUN_SCRIPTS_BEGIN: 'RunScriptsBegin', - RUN_SCRIPTS_FAILED: 'RunScriptsFailed', - RUN_SCRIPTS_OK: 'RunScriptsOk', - RUN_SCRIPT_BEGIN: 'RunScriptBegin', - RUN_SCRIPT_FAILED: 'RunScriptFailed', - RUN_SCRIPT_OK: 'RunScriptOk', LINGERED: 'Lingered', - RUN_CHECKS_BEGIN: 'RunChecksBegin', - RUN_CHECKS_FAILED: 'RunChecksFailed', - RUN_CHECKS_OK: 'RunChecksOk', + PACK_BEGIN: 'PackBegin', + PACK_FAILED: 'PackFailed', + PACK_OK: 'PackOk', + RULE_ERROR: 'RuleError', RUN_CHECK_BEGIN: 'RunCheckBegin', RUN_CHECK_FAILED: 'RunCheckFailed', RUN_CHECK_OK: 'RunCheckOk', -} as const satisfies Record; + RUN_CHECKS_BEGIN: 'RunChecksBegin', + RUN_CHECKS_FAILED: 'RunChecksFailed', + RUN_CHECKS_OK: 'RunChecksOk', + RUN_SCRIPT_BEGIN: 'RunScriptBegin', + RUN_SCRIPT_FAILED: 'RunScriptFailed', + RUN_SCRIPT_OK: 'RunScriptOk', + RUN_SCRIPTS_BEGIN: 'RunScriptsBegin', + RUN_SCRIPTS_FAILED: 'RunScriptsFailed', + RUN_SCRIPTS_OK: 'RunScriptsOk', + SMOKE_BEGIN: 'SmokeBegin', + SMOKE_FAILED: 'SmokeFailed', + SMOKE_OK: 'SmokeOk', +} as const satisfies Record; diff --git a/src/index.ts b/src/index.ts index 7ed4332e..50b64753 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,9 @@ export const {smoke} = Smoker; export type * from './pm'; export type * from './error'; export type * from './types'; -export * from './events'; -export * from './smoker'; -export {SmokerConfigSchema, type SmokerConfig} from './config'; +export {CheckSeverities} from './rules'; +export type * from './rules'; +export * from './event'; +export {Smoker}; +export {parseOptions} from './options'; +export {readConfigFile} from './config-file'; diff --git a/src/options.ts b/src/options.ts new file mode 100644 index 00000000..02e028cc --- /dev/null +++ b/src/options.ts @@ -0,0 +1,117 @@ +/** + * Handles parsing of options (CLI, API, or config file) for `midnight-smoker` + * @module + */ +import createDebug from 'debug'; +import z, {type ZodError} from 'zod'; +import {zCheckOptions} from './rules'; +import {fromZodError} from 'zod-validation-error'; +import {zStringOrArray, zFalse, zTrue} from './schema-util'; + +const debug = createDebug('midnight-smoker:options'); + +/** + * @internal + */ +export const DEFAULT_PACKAGE_MANAGER_ID = 'npm@latest'; + +export const zRawSmokerOptions = z + .object({ + add: zStringOrArray.describe( + 'Add an extra package to the list of packages to be installed.', + ), + all: zFalse.describe( + 'Operate on all workspaces. The root workspace is omitted unless `includeRoot` is `true`.', + ), + bail: zFalse.describe('Fail on first script failure.'), + includeRoot: zFalse.describe( + 'Operate on the root workspace. Only has an effect if `all` is `true`.', + ), + json: zFalse.describe('Output JSON only.'), + linger: zFalse.describe('Do not delete temp directories after completion.'), + verbose: zFalse.describe('Verbose logging.'), + workspace: zStringOrArray.describe( + 'One or more workspaces to run scripts in.', + ), + pm: zStringOrArray + .default(DEFAULT_PACKAGE_MANAGER_ID) + .describe('Package manager(s) to use.'), + script: zStringOrArray.describe('Script(s) to run. Alias of `scripts`.'), + scripts: zStringOrArray.describe('Script(s) to run. Alias of `script`.'), + loose: zFalse.describe( + 'If `true`, fail if a workspace is missing a script.', + ), + checks: zTrue.describe('If `false`, run no builtin checks.'), + rules: zCheckOptions, + }) + .describe('midnight-smoker options schema'); + +/** + * @internal + */ +export const zSmokerOptions = zRawSmokerOptions + .transform((cfg, ctx) => { + if (cfg.all) { + if (cfg.workspace.length) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Option "workspace" is mutually exclusive with "all"', + }); + return z.NEVER; + } + } else if (cfg.loose) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Option "loose" requires "all" to be set', + }); + return z.NEVER; + } + + // stuff `scripts` into into `script` + const script = [...new Set([...cfg.script, ...cfg.scripts])]; + + return { + ...cfg, + script, + }; + }) + .brand<'SmokerOptions'>() + .readonly(); + +/** + * Options for `Smoker` as provided by a user + */ +export type RawSmokerOptions = z.input; + +/** + * Normalized options for `Smoker`. + */ +export type SmokerOptions = z.output; + +/** + * Parses options for `Smoker`. + * + * @param opts Options for `Smoker`. Could come from CLI, config file, API, or some combination thereof. + * @returns Parsed & normalized options. + */ +export function parseOptions( + opts?: RawSmokerOptions | SmokerOptions, +): SmokerOptions { + if (parseOptions.cache.has(opts)) { + return parseOptions.cache.get(opts)!; + } + let result: SmokerOptions; + try { + result = zSmokerOptions.parse(opts ?? {}); + } catch (err) { + throw fromZodError(err as ZodError); + } + parseOptions.cache.set(opts, result); + parseOptions.cache.set(result, result); + debug('Normalized options: %O', result); + return result; +} +parseOptions.cache = new Map< + RawSmokerOptions | SmokerOptions | undefined, + SmokerOptions +>(); diff --git a/src/pm/npm7.ts b/src/pm/npm7.ts index 63a1420e..d0c76b30 100644 --- a/src/pm/npm7.ts +++ b/src/pm/npm7.ts @@ -134,6 +134,7 @@ export class Npm7 extends GenericNpmPackageManager implements PackageManager { try { packResult = await this.executor.exec(packArgs); } catch (err) { + this.debug('(pack) Failed: %O', err); throw new PackError( `Package manager "${this.name}" failed to pack`, this.name, diff --git a/src/rules/builtin/no-banned-files.ts b/src/rules/builtin/no-banned-files.ts index d8f59834..139f1cd8 100644 --- a/src/rules/builtin/no-banned-files.ts +++ b/src/rules/builtin/no-banned-files.ts @@ -5,13 +5,14 @@ * @module */ +import createDebug from 'debug'; import fs from 'node:fs/promises'; -import {createRule} from '../rule'; -import type {CheckFailure} from '../result'; import path from 'node:path'; -import createDebug from 'debug'; import {z} from 'zod'; +import {zNonEmptyStringArray} from '../../schema-util'; import {findDataDir} from '../../util'; +import type {CheckFailure} from '../result'; +import {createRule} from '../rule'; const debug = createDebug('midnight-smoker:rule:no-banned-files'); @@ -82,8 +83,8 @@ const noBannedFiles = createRule({ async check({pkgPath, fail}, opts) { const queue: string[] = [pkgPath]; const failed: CheckFailure[] = []; - const allow = new Set(opts?.allow ?? []); - const deny = new Set(opts?.deny ?? []); + const allow = new Set(opts.allow); + const deny = new Set(opts.deny); while (queue.length) { const dir = queue.pop()!; @@ -120,14 +121,8 @@ const noBannedFiles = createRule({ name: 'no-banned-files', description: 'Bans certain files from being published', schema: z.object({ - allow: z - .array(z.string().min(1)) - .optional() - .describe('Allow these banned files'), - deny: z - .array(z.string().min(1)) - .optional() - .describe('Deny these additional files'), + allow: zNonEmptyStringArray.describe('Allow these banned files'), + deny: zNonEmptyStringArray.describe('Deny these additional files'), }), }); diff --git a/src/rules/builtin/no-missing-entry-point.ts b/src/rules/builtin/no-missing-entry-point.ts index 9efc460a..edf90c80 100644 --- a/src/rules/builtin/no-missing-entry-point.ts +++ b/src/rules/builtin/no-missing-entry-point.ts @@ -1,6 +1,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import {createRule} from '../rule'; +import {z} from 'zod'; const DEFAULT_FIELD = 'main'; @@ -59,6 +60,7 @@ const noMissingEntryPoint = createRule({ } } }, + schema: z.any(), name: 'no-missing-entry-point', description: 'Checks that the package contains an entry point; only applies to CJS packages without an "exports" field', diff --git a/src/rules/builtin/no-missing-exports.ts b/src/rules/builtin/no-missing-exports.ts index ad791c85..f76e22f1 100644 --- a/src/rules/builtin/no-missing-exports.ts +++ b/src/rules/builtin/no-missing-exports.ts @@ -7,6 +7,7 @@ import {z} from 'zod'; import {castArray} from '../../util'; import {CheckFailure} from '../result'; import {createRule} from '../rule'; +import {zTrue} from '../../schema-util'; const EXPORTS_FIELD = 'exports'; const CONDITIONAL_EXPORT_DEFAULT = 'default'; @@ -28,15 +29,6 @@ function isESMPkg(pkgJson: PackageJson) { const noMissingExports = createRule({ async check({pkgJson, pkgPath, fail}, opts) { - opts = { - glob: true, - require: true, - import: true, - types: true, - order: true, - ...opts, - }; - if (!pkgJson[EXPORTS_FIELD]) { if (isESMPkg(pkgJson)) { return [ @@ -72,7 +64,7 @@ const noMissingExports = createRule({ // use glob only if there's a glob pattern. premature optimization? if (glob.hasMagic(relativePath, {magicalBraces: true})) { - if (opts?.glob === false) { + if (opts.glob === false) { return fail( displayExportName ? `Export "${displayExportName}" contains a glob pattern` @@ -110,7 +102,7 @@ const noMissingExports = createRule({ if ( baseExportName === CONDITIONAL_EXPORT_IMPORT && - opts?.import && + opts.import && !(await isESMFile(filepath)).esm ) { return fail( @@ -120,7 +112,7 @@ const noMissingExports = createRule({ ); } else if ( baseExportName === CONDITIONAL_EXPORT_REQUIRE && - opts?.require && + opts.require && (await isESMFile(filepath)).esm ) { return fail( @@ -130,7 +122,7 @@ const noMissingExports = createRule({ ); } else if ( baseExportName === CONDITIONAL_EXPORT_TYPES && - opts?.types && + opts.types && !path.extname(relativePath).endsWith('.d.ts') ) { return fail( @@ -150,7 +142,7 @@ const noMissingExports = createRule({ if (!exports || typeof exports === 'string' || Array.isArray(exports)) { return; } - if (opts?.order && CONDITIONAL_EXPORT_DEFAULT in exports) { + if (opts.order && CONDITIONAL_EXPORT_DEFAULT in exports) { const keys = Object.keys(exports); if (keys[keys.length - 1] !== CONDITIONAL_EXPORT_DEFAULT) { return [ @@ -232,39 +224,19 @@ const noMissingExports = createRule({ name: 'no-missing-exports', description: `Checks that all files in the "${EXPORTS_FIELD}" field (if present) exist`, schema: z.object({ - types: z - .boolean() - .default(true) - .optional() - .describe( - `Assert a "${CONDITIONAL_EXPORT_TYPES}" conditional export matches a file with a .d.ts extension`, - ), - require: z - .boolean() - .default(true) - .optional() - .describe( - `Assert a "${CONDITIONAL_EXPORT_REQUIRE}" conditional export matches a CJS script`, - ), - import: z - .boolean() - .default(true) - .optional() - .describe( - `Assert an "${CONDITIONAL_EXPORT_IMPORT}" conditional export matches a ESM module`, - ), - order: z - .boolean() - .default(true) - .optional() - .describe( - `Assert conditional export "${CONDITIONAL_EXPORT_DEFAULT}", if present, is the last export`, - ), - glob: z - .boolean() - .default(true) - .optional() - .describe('Allow glob patterns in subpath exports'), + types: zTrue.describe( + `Assert a "${CONDITIONAL_EXPORT_TYPES}" conditional export matches a file with a .d.ts extension`, + ), + require: zTrue.describe( + `Assert a "${CONDITIONAL_EXPORT_REQUIRE}" conditional export matches a CJS script`, + ), + import: zTrue.describe( + `Assert an "${CONDITIONAL_EXPORT_IMPORT}" conditional export matches a ESM module`, + ), + order: zTrue.describe( + `Assert conditional export "${CONDITIONAL_EXPORT_DEFAULT}", if present, is the last export`, + ), + glob: zTrue.describe('Allow glob patterns in subpath exports'), }), }); diff --git a/src/rules/builtin/no-missing-pkg-files.ts b/src/rules/builtin/no-missing-pkg-files.ts index 44a42c93..72802ab8 100644 --- a/src/rules/builtin/no-missing-pkg-files.ts +++ b/src/rules/builtin/no-missing-pkg-files.ts @@ -4,21 +4,23 @@ import path from 'node:path'; import {z} from 'zod'; import {CheckFailure} from '../result'; import {createRule} from '../rule'; +import {zNonEmptyStringArray, zTrue} from '../../schema-util'; const debug = createDebug('midnight-smoker:rule:no-missing-pkg-files'); const noMissingPkgFiles = createRule({ async check({pkgJson: pkg, pkgPath, fail}, opts) { - const fieldsToCheck = opts?.fields ?? []; - if (opts?.bin !== false) { + let fieldsToCheck = opts.fields ?? []; + if (opts.bin !== false) { fieldsToCheck.push('bin'); } - if (opts?.browser !== false) { + if (opts.browser !== false) { fieldsToCheck.push('browser'); } - if (opts?.types !== false) { + if (opts.types !== false) { fieldsToCheck.push('types'); } + fieldsToCheck = [...new Set(fieldsToCheck)]; /** * @param relativePath Path from package root to file @@ -52,11 +54,12 @@ const noMissingPkgFiles = createRule({ if (value) { if (typeof value === 'string') { return checkFile(value, field); - } else if (!Array.isArray(value) && typeof value === 'object') { + } + if (!Array.isArray(value) && typeof value === 'object') { return Promise.all( - Object.entries(value).map(([name, relativePath]) => { - return checkFile(relativePath, field, name); - }), + Object.entries(value).map(([name, relativePath]) => + checkFile(relativePath, field, name), + ), ); } } @@ -69,25 +72,12 @@ const noMissingPkgFiles = createRule({ description: 'Checks that files referenced in package.json exist in the tarball', schema: z.object({ - bin: z - .boolean() - .default(true) - .optional() - .describe('Check the "bin" field (if it exists)'), - browser: z - .boolean() - .default(true) - .optional() - .describe('Check the "browser" field (if it exists)'), - types: z - .boolean() - .default(true) - .optional() - .describe('Check the "types" field (if it exists)'), - fields: z - .array(z.string().min(1)) - .optional() - .describe('Check files referenced by these additional top-level fields'), + bin: zTrue.describe('Check the "bin" field (if it exists)'), + browser: zTrue.describe('Check the "browser" field (if it exists)'), + types: zTrue.describe('Check the "types" field (if it exists)'), + fields: zNonEmptyStringArray.describe( + 'Check files referenced by these additional top-level fields', + ), }), }); diff --git a/src/rules/check-options.ts b/src/rules/check-options.ts new file mode 100644 index 00000000..8bce1ba3 --- /dev/null +++ b/src/rules/check-options.ts @@ -0,0 +1,43 @@ +import {z} from 'zod'; +import noBannedFiles from './builtin/no-banned-files'; +import noMissingEntryPoint from './builtin/no-missing-entry-point'; +import noMissingExports from './builtin/no-missing-exports'; +import noMissingPkgFiles from './builtin/no-missing-pkg-files'; + +/** + * Default options for rule behavior (the `rules` prop in `SmokerOptions`). + * + * Dynamically created by each rule's `defaultOptions` property. + * + * @internal + */ +export const DEFAULT_CHECK_OPTIONS = { + [noMissingPkgFiles.name]: noMissingPkgFiles.defaultOptions, + [noBannedFiles.name]: noBannedFiles.defaultOptions, + [noMissingEntryPoint.name]: noMissingEntryPoint.defaultOptions, + [noMissingExports.name]: noMissingExports.defaultOptions, +} as const; + +/** + * @internal + */ +export const zCheckOptions = z + .object({ + [noBannedFiles.name]: noBannedFiles.zRuleSchema, + [noMissingPkgFiles.name]: noMissingPkgFiles.zRuleSchema, + [noMissingEntryPoint.name]: noMissingEntryPoint.zRuleSchema, + [noMissingExports.name]: noMissingExports.zRuleSchema, + }) + .default(DEFAULT_CHECK_OPTIONS) + .describe('Rule configuration for checks'); + +/** + * The input type for {@linkcode zCheckOptions} + * @internal + */ +export type RawCheckOptions = z.input; + +/** + * The parsed and normalized type for {@linkcode zCheckOptions} + */ +export type CheckOptions = z.infer; diff --git a/src/rules/index.ts b/src/rules/index.ts index 201042d3..535892b1 100644 --- a/src/rules/index.ts +++ b/src/rules/index.ts @@ -1,4 +1,4 @@ export * from './rule'; -export * from './rule-config'; +export * from './check-options'; export * from './severity'; export type * from './result'; diff --git a/src/rules/result.ts b/src/rules/result.ts index 310c4dca..29db5249 100644 --- a/src/rules/result.ts +++ b/src/rules/result.ts @@ -1,4 +1,5 @@ import type {StaticCheckContext, StaticRuleDef} from './rule'; +import type {CheckSeverity} from './severity'; export interface CheckResult { rule: StaticRuleDef; @@ -18,6 +19,8 @@ export interface CheckFailure extends CheckResult { message: string; data?: CheckResultData; failed: true; + + severity: CheckSeverity; } export interface CheckOk extends CheckResult { diff --git a/src/rules/rule-config.ts b/src/rules/rule-config.ts deleted file mode 100644 index 4a95c8bf..00000000 --- a/src/rules/rule-config.ts +++ /dev/null @@ -1,41 +0,0 @@ -import {z} from 'zod'; -import noBannedFiles from './builtin/no-banned-files'; -import noMissingEntryPoint from './builtin/no-missing-entry-point'; -import noMissingExports from './builtin/no-missing-exports'; -import noMissingPkgFiles from './builtin/no-missing-pkg-files'; -import {RuleSeverities, RuleSeveritySchema} from './severity'; - -const BaseRuleConfigSchema = z.object({ - [noBannedFiles.name]: noBannedFiles.ruleSchema, - [noMissingPkgFiles.name]: noMissingPkgFiles.ruleSchema, - [noMissingEntryPoint.name]: noMissingEntryPoint.ruleSchema, - [noMissingExports.name]: noMissingExports.ruleSchema, -}); -export type RawRuleConfig = z.infer; - -export const RuleConfigSchema = BaseRuleConfigSchema.transform((val) => ({ - ...val, - getEnabledRules: () => - new Set( - Object.entries(val) - .filter(([, severity]) => severity !== RuleSeverities.OFF) - .map(([ruleName]) => ruleName as keyof RawRuleConfig), - ), - isRuleEnabled: (ruleName: string) => { - const name = ruleName as keyof RawRuleConfig; - return ( - name in BaseRuleConfigSchema.shape && val[name] !== RuleSeverities.OFF - ); - }, -})); - -export type RuleConfig = z.infer; - -export const DEFAULT_RULE_CONFIG = { - [noMissingPkgFiles.name]: noMissingPkgFiles.defaultSeverity, - [noBannedFiles.name]: noBannedFiles.defaultSeverity, - [noMissingEntryPoint.name]: noMissingEntryPoint.defaultSeverity, - [noMissingExports.name]: noMissingExports.defaultSeverity, -} as const satisfies RawRuleConfig; - -export type RuleSeverity = z.infer; diff --git a/src/rules/rule.ts b/src/rules/rule.ts index 900d93a3..7756ac23 100644 --- a/src/rules/rule.ts +++ b/src/rules/rule.ts @@ -1,8 +1,7 @@ import type {PackageJson} from 'read-pkg-up'; import {z} from 'zod'; -import type {RuleSeverity} from './rule-config'; import type {CheckFailure} from './result'; -import {RuleSeverities, RuleSeveritySchema} from './severity'; +import {CheckSeverities, zCheckSeverity, type CheckSeverity} from './severity'; /** * The bits of a {@linkcode CheckContext} suitable for serialization. @@ -12,7 +11,7 @@ export interface StaticCheckContext { pkgJson: PackageJson; pkgJsonPath: string; pkgPath: string; - severity: RuleSeverity; + severity: CheckSeverity; } /** @@ -31,11 +30,11 @@ export type CheckContextFailFn = (message: string, data?: any) => CheckFailure; */ export class CheckContext< const Name extends string = string, - Schema extends z.ZodTypeAny = z.ZodUndefined, + Schema extends z.ZodTypeAny = z.ZodTypeAny, > implements StaticCheckContext { /** - * The parsed `package.json` for the package being checked. + * The (parse as )d `package.json` for the package being checked. * * _Not_ normalized. */ @@ -53,7 +52,7 @@ export class CheckContext< /** * The severity level for this rule (as chosen by the user) */ - public readonly severity: RuleSeverity; + public readonly severity: CheckSeverity; /** * The `fail` function as provided to the `Rule`'s {@linkcode RuleCheckFn}. @@ -75,11 +74,12 @@ export class CheckContext< */ private createFailFn(rule: Rule): CheckContextFailFn { return (message, data) => ({ - rule, + rule: rule.toJSON(), message, data, context: this, failed: true, + severity: this.severity, }); } @@ -101,61 +101,50 @@ export class CheckContext< * The bits of a {@linkcode RuleDef} suitable for passing the API edge */ export interface StaticRuleDef { - readonly defaultSeverity?: RuleSeverity; + readonly defaultSeverity?: CheckSeverity; readonly description: string; readonly name: Name; } -/** - * The parsed and validated options for a {@linkcode Rule}. - */ -export type RuleOpts = - z.infer; - /** * The type _returned or fulfilled_ by the {@linkcode Rule.check} method. */ export type RuleCheckFnResult = CheckFailure[] | undefined; +export type RuleOptions = z.infer; + /** * The function which actually performs the check within a {@linkcode Rule} * @public */ -export type RuleCheckFn< - Name extends string = string, - Schema extends z.ZodTypeAny = z.ZodUndefined, -> = ( +export type RuleCheckFn = ( ctx: CheckContext, - opts?: RuleOpts, + opts: RuleOptions, ) => Promise | RuleCheckFnResult; /** * The raw definition of a {@linkcode Rule}, as defined by a implementor. * @public */ -export interface RuleDef< - Name extends string = string, - Schema extends z.ZodTypeAny = z.ZodUndefined, -> extends StaticRuleDef { +export interface RuleDef + extends StaticRuleDef { /** * The function which actually performs the check. */ - readonly check: RuleCheckFn; + check: RuleCheckFn; /** * Options schema for this rule, if any */ - readonly schema?: Schema; + schema: Schema; } /** * Represents a _Rule_, which performs a check upon an installed (from tarball) package. * @internal */ -export class Rule< - const Name extends string = string, - Schema extends z.ZodTypeAny = z.ZodUndefined, -> implements RuleDef +export class Rule + implements RuleDef { /** * The name for this rule. @@ -171,7 +160,7 @@ export class Rule< /** * The default severity for this rule if not supplied by the user */ - public readonly defaultSeverity: RuleSeverity; + public readonly defaultSeverity: CheckSeverity; /** * The function which actually performs the check. @@ -186,30 +175,36 @@ export class Rule< /** * A composable schema handling the default severity for this rule */ - private readonly ruleSeveritySchema: z.ZodDefault; + private readonly zDefaultRuleSeverity: z.ZodDefault; + + public readonly defaultOptions: RuleOptions; constructor(def: RuleDef) { this.name = def.name; this.description = def.description; - this.defaultSeverity = def.defaultSeverity ?? RuleSeverities.ERROR; + this.defaultSeverity = def.defaultSeverity ?? CheckSeverities.ERROR; this.check = def.check; - // TODO determine if this is Bad - this.schema = def.schema ?? (z.undefined() as Schema); - this.ruleSeveritySchema = RuleSeveritySchema.default(this.defaultSeverity); + this.schema = def.schema; + this.zDefaultRuleSeverity = zCheckSeverity.default(this.defaultSeverity); + this.defaultOptions = this.schema.parse({}); } /** * Returns the entire schema for the value of this rule in the `RuleConfig` object. */ - get ruleSchema() { - return z - .union([ - this.ruleSeveritySchema, - this.schema, - z.tuple([this.schema]), - z.tuple([this.schema, this.ruleSeveritySchema]), - ]) - .optional(); + get zRuleSchema() { + const {zDefaultRuleSeverity, schema: zSchema} = this; + + return z.union([ + zDefaultRuleSeverity.transform((severity) => ({ + severity, + opts: this.defaultOptions, + })), + zSchema.transform((opts) => ({severity: this.defaultSeverity, opts})), + z + .tuple([zSchema, zDefaultRuleSeverity]) + .transform(([opts, severity]) => ({severity, opts})), + ]); } /** @@ -222,7 +217,7 @@ export class Rule< return ( cont: < const Name extends string, - Schema extends z.ZodTypeAny = z.ZodUndefined, + Schema extends z.ZodTypeAny = z.ZodTypeAny, >( rule: Rule, ) => R, @@ -250,10 +245,7 @@ export class Rule< * @see {@link https://jalo.website/existential-types-in-typescript-through-continuations} */ export type RuleCont = ( - cont: < - const Name extends string, - Schema extends z.ZodTypeAny = z.ZodUndefined, - >( + cont: ( rule: Rule, ) => R, ) => R; @@ -267,7 +259,7 @@ export type RuleCont = ( */ export function createRule< const Name extends string, - Schema extends z.ZodTypeAny = z.ZodUndefined, + Schema extends z.ZodTypeAny = z.ZodTypeAny, >(ruleDef: RuleDef): Rule { return new Rule(ruleDef); } diff --git a/src/rules/severity.ts b/src/rules/severity.ts index 40e2f3b7..146392cb 100644 --- a/src/rules/severity.ts +++ b/src/rules/severity.ts @@ -1,13 +1,23 @@ +/** + * Handles severity of rules + * @module + */ + import {z} from 'zod'; -export const RuleSeverities = { +export const CheckSeverities = { ERROR: 'error', WARN: 'warn', OFF: 'off', } as const; -export const RuleSeveritySchema = z - .enum([RuleSeverities.OFF, RuleSeverities.WARN, RuleSeverities.ERROR]) +export const zCheckSeverity = z + .enum([CheckSeverities.OFF, CheckSeverities.WARN, CheckSeverities.ERROR]) .describe( 'Severity of a rule. `off` disables the rule, `warn` will warn on violations, and `error` will error on violations.', - ); + ) + .default(CheckSeverities.ERROR); + +export type CheckSeverity = z.infer; + +export type EnabledCheckSeverity = Extract; diff --git a/src/schema-util.ts b/src/schema-util.ts new file mode 100644 index 00000000..259ff583 --- /dev/null +++ b/src/schema-util.ts @@ -0,0 +1,18 @@ +import {castArray} from './util'; +import {z} from 'zod'; + +export const zString = z.string().min(1).trim(); +export const zFalse = z.boolean().default(false); + +export const zTrue = z.boolean().default(true); +export const zStringOrArray = z + .union([zString, z.array(zString)]) + .default([]) + .transform(castArray); + +/** + * Schema representing an array of non-empty strings. + * + * _**Not** a non-empty array of strings_. + */ +export const zNonEmptyStringArray = z.array(zString).default([]); diff --git a/src/smoker.ts b/src/smoker.ts index d29ad021..d5fd5308 100644 --- a/src/smoker.ts +++ b/src/smoker.ts @@ -1,12 +1,10 @@ /* eslint-disable no-labels */ -import {yellow} from 'chalk'; import createDebug from 'debug'; import {EventEmitter} from 'node:events'; import fs from 'node:fs/promises'; import {tmpdir} from 'node:os'; import path from 'node:path'; import StrictEventEmitter from 'strict-event-emitter-types'; -import {z} from 'zod'; import { DirCreationError, DirDeletionError, @@ -14,11 +12,17 @@ import { InvalidArgError, PackageManagerError, PackageManagerIdError, + RuleError, SmokeFailedError, type InstallError, type PackError, } from './error'; -import {Events, type InstallEventData, type SmokerEvents} from './events'; +import {Event, type InstallEventData, type SmokerEvent} from './event'; +import { + parseOptions, + type RawSmokerOptions, + type SmokerOptions, +} from './options'; import { loadPackageManagers, type InstallResults, @@ -26,12 +30,11 @@ import { } from './pm'; import { CheckContext, - DEFAULT_RULE_CONFIG, - RuleConfigSchema, - type RawRuleConfig, - type RuleConfig, + CheckSeverities, + type CheckOptions, type RuleCont, type StaticCheckContext, + RuleOptions, } from './rules'; import {BuiltinRuleConts} from './rules/builtin'; import { @@ -39,54 +42,27 @@ import { type CheckOk, type CheckResults, } from './rules/result'; -import type { - InstallManifest, - PkgInstallManifest, - PkgRunManifest, - RunManifest, - RunScriptResult, - SmokeOptions, - SmokeResults, - SmokerOptions, +import { + type InstallManifest, + type PkgInstallManifest, + type PkgRunManifest, + type RunManifest, + type RunScriptResult, + type SmokeResults, } from './types'; -import {normalizeStringArray, readPackageJson} from './util'; +import {readPackageJson} from './util'; const debug = createDebug('midnight-smoker:smoker'); export const TMP_DIR_PREFIX = 'midnight-smoker-'; -type TSmokerEmitter = StrictEventEmitter; +type TSmokerEmitter = StrictEventEmitter; function createStrictEventEmitterClass() { const TypedEmitter: {new (): TSmokerEmitter} = EventEmitter as any; return TypedEmitter; } -const { - SMOKE_BEGIN, - SMOKE_OK, - SMOKE_FAILED, - PACK_BEGIN, - PACK_FAILED, - PACK_OK, - INSTALL_BEGIN, - INSTALL_FAILED, - INSTALL_OK, - RUN_SCRIPTS_BEGIN, - RUN_SCRIPTS_FAILED, - RUN_SCRIPTS_OK, - RUN_SCRIPT_BEGIN, - RUN_SCRIPT_FAILED, - RUN_SCRIPT_OK, - LINGERED, - RUN_CHECKS_BEGIN, - RUN_CHECKS_FAILED, - RUN_CHECKS_OK, - RUN_CHECK_BEGIN, - RUN_CHECK_FAILED, - RUN_CHECK_OK, -} = Events; - export class Smoker extends createStrictEventEmitterClass() { /** * List of extra dependencies to install @@ -121,7 +97,7 @@ export class Smoker extends createStrictEventEmitterClass() { */ private readonly workspaces: string[]; - public readonly ruleConfig: RuleConfig; + public readonly ruleConfig: CheckOptions; /** * List of scripts to run in each workspace */ @@ -134,66 +110,73 @@ export class Smoker extends createStrictEventEmitterClass() { private readonly originalOpts: SmokerOptions; - constructor( + private constructor( public readonly pms: Map, - scripts: string | string[] = [], - opts: SmokerOptions = {}, + opts: SmokerOptions, ) { super(); - this.originalOpts = opts; - this.scripts = normalizeStringArray(scripts); - opts = {...opts}; - - this.linger = Boolean(opts.linger); - this.includeWorkspaceRoot = Boolean(opts.includeRoot); - if (this.includeWorkspaceRoot) { - opts.all = true; - } - this.add = normalizeStringArray(opts.add); - this.bail = Boolean(opts.bail); - this.allWorkspaces = Boolean(opts.all); - this.workspaces = normalizeStringArray(opts.workspace); + const { + script, + linger, + includeRoot, + add, + bail, + all, + workspace, + checks, + rules, + } = opts; + this.originalOpts = Object.freeze(opts); + + this.scripts = script; + this.linger = linger; + this.includeWorkspaceRoot = includeRoot; + this.add = add; + this.bail = bail; + this.allWorkspaces = all; + this.workspaces = workspace; + this.checks = checks; + this.ruleConfig = rules; + if (this.allWorkspaces && this.workspaces.length) { throw new InvalidArgError( 'Option "workspace" is mutually exclusive with "all" and/or "includeRoot"', ); } - this.checks = opts.checks !== false; + this.pmIds = new WeakMap(); for (const [pmId, pm] of pms) { this.pmIds.set(pm, pmId); } this.tempDirs = new Set(); - this.ruleConfig = RuleConfigSchema.parse({ - ...DEFAULT_RULE_CONFIG, - ...opts.rules, - }); } - public static async init( - scripts: string | string[] = [], - opts: SmokeOptions = {}, - ) { - const {pm, verbose, loose, all} = opts; - + public static async init(opts: RawSmokerOptions = {}) { + const smokerOpts = parseOptions(opts); + const {verbose, pm, loose} = smokerOpts; const pms = await loadPackageManagers(pm, { verbose, - loose: all && loose, + loose, }); - return new Smoker(pms, scripts, opts); + return Smoker.create(pms, smokerOpts); + } + + public static create( + pms: Map, + opts: RawSmokerOptions | SmokerOptions = {}, + ) { + return new Smoker(pms, parseOptions(opts)); } /** * Run the smoke test scripts! - * @param scripts - One or more npm scripts to run * @param opts - Options */ public static async smoke( - scripts: string | string[] = [], - opts: SmokeOptions = {}, + opts: RawSmokerOptions = {}, ): Promise { - const smoker = await Smoker.init(scripts, opts); + const smoker = await Smoker.init(opts); return smoker.smoke(); } @@ -202,7 +185,7 @@ export class Smoker extends createStrictEventEmitterClass() { * * If the {@linkcode SmokeOptions.linger} option is set to `true`, this method * will _not_ clean up the directories, but will instead emit a - * {@linkcode SmokerEvents.Lingered|Lingered} event. + * {@linkcode SmokerEvent.Lingered|Lingered} event. */ public async cleanup(): Promise { if (!this.linger) { @@ -226,7 +209,7 @@ export class Smoker extends createStrictEventEmitterClass() { ); } else if (this.tempDirs.size) { await Promise.resolve(); - this.emit(LINGERED, [...this.tempDirs]); + this.emit(Event.LINGERED, [...this.tempDirs]); } } @@ -264,7 +247,7 @@ export class Smoker extends createStrictEventEmitterClass() { await Promise.resolve(); const installData = this.buildInstallEventData(manifests); - this.emit(INSTALL_BEGIN, installData); + this.emit(Event.INSTALL_BEGIN, installData); const installResults: InstallResults = new Map(); for (const [pm, manifest] of manifests) { @@ -285,12 +268,12 @@ export class Smoker extends createStrictEventEmitterClass() { installResults.set(pm, [manifest, result]); } catch (err) { const error = err as InstallError; - this.emit(INSTALL_FAILED, error); + this.emit(Event.INSTALL_FAILED, error); throw error; } } - this.emit(INSTALL_OK, installData); + this.emit(Event.INSTALL_OK, installData); return installResults; } @@ -301,7 +284,7 @@ export class Smoker extends createStrictEventEmitterClass() { public async pack(): Promise { await Promise.resolve(); - this.emit(PACK_BEGIN, {packageManagers: [...this.pms.keys()]}); + this.emit(Event.PACK_BEGIN, {packageManagers: [...this.pms.keys()]}); const manifestMap: PkgInstallManifest = new Map(); @@ -318,11 +301,11 @@ export class Smoker extends createStrictEventEmitterClass() { }); manifestMap.set(pm, manifest); } catch (err) { - this.emit(PACK_FAILED, err as PackError); + this.emit(Event.PACK_FAILED, err as PackError); throw err; } } - this.emit(PACK_OK, this.buildInstallEventData(manifestMap)); + this.emit(Event.PACK_OK, this.buildInstallEventData(manifestMap)); return manifestMap; } @@ -330,26 +313,25 @@ export class Smoker extends createStrictEventEmitterClass() { * @internal * @param ruleCont - Rule continuation * @param pkgPath - Path to installed package - * @param config - Parsed rule config + * @param checkOpts - Parsed rule options * @returns Results of a single check */ public async runCheck( ruleCont: RuleCont, pkgPath: string, - config: RawRuleConfig, + checkOpts: CheckOptions, ): Promise { return ruleCont(async (rule) => { - let {name: ruleName, defaultSeverity: severity, schema} = rule; - const configForRule = config[ruleName as keyof typeof config]; - - let opts: z.infer | undefined; - if (typeof configForRule === 'string') { - severity = configForRule; - } else if (Array.isArray(configForRule)) { - opts = configForRule[0]; - severity = (configForRule[1] as typeof severity) ?? severity; - } else { - opts = configForRule; + const {name: ruleName} = rule; + const ruleOpts: RuleOptions = + checkOpts[ruleName as keyof CheckOptions]; + const {severity, opts} = ruleOpts; + + // XXX might be something better to do here. this won't happen during + // expected non-test operation + /* istanbul ignore next */ + if (severity === CheckSeverities.OFF) { + return {failed: [], passed: []}; } const {packageJson: pkgJson, path: pkgJsonPath} = await readPackageJson({ @@ -364,15 +346,44 @@ export class Smoker extends createStrictEventEmitterClass() { severity, }; - debug(`Running rule %s with context %O`, ruleName, staticCtx); + debug( + `Running rule %s with context %O and opts %O`, + ruleName, + { + pkgJsonPath, + pkgPath, + severity, + pkgName: pkgJson.name, + }, + opts, + ); const context = new CheckContext(rule, staticCtx); let result: CheckFailure[] | undefined; try { result = await rule.check(context, opts); } catch (err) { - console.error(yellow(`Warning: error running rule ${ruleName}:`)); - console.error(err); + this.emit( + Event.RULE_ERROR, + new RuleError( + `Rule "${ruleName}" threw an exception`, + staticCtx, + ruleName, + err as Error, + ), + ); + return { + failed: [ + { + message: String(err), + failed: true, + severity, + rule, + context: staticCtx, + }, + ], + passed: [], + }; } if (result?.length) { @@ -399,19 +410,23 @@ export class Smoker extends createStrictEventEmitterClass() { await Promise.resolve(); const runnableRules = BuiltinRuleConts.filter((ruleCont) => - ruleCont((rule) => ruleConfig.isRuleEnabled(rule.name)), + ruleCont( + (rule) => + ruleConfig[rule.name as keyof typeof ruleConfig].severity !== + CheckSeverities.OFF, + ), ); const total = runnableRules.length; let current = 0; - this.emit(RUN_CHECKS_BEGIN, {config: ruleConfig, total}); + this.emit(Event.RUN_CHECKS_BEGIN, {config: ruleConfig, total}); // run against multiple package managers?? for (const ruleCont of runnableRules) { const ruleName = ruleCont((rule) => rule.name); - const configForRule = ruleConfig[ruleName as keyof RawRuleConfig]; - this.emit(RUN_CHECK_BEGIN, { + const configForRule = ruleConfig[ruleName as keyof typeof ruleConfig]; + this.emit(Event.RUN_CHECK_BEGIN, { rule: ruleName, config: configForRule, current, @@ -426,7 +441,7 @@ export class Smoker extends createStrictEventEmitterClass() { ruleConfig, ); if (failed.length) { - this.emit(RUN_CHECK_FAILED, { + this.emit(Event.RUN_CHECK_FAILED, { rule: ruleName, config: configForRule, current, @@ -434,7 +449,7 @@ export class Smoker extends createStrictEventEmitterClass() { failed, }); } else { - this.emit(RUN_CHECK_OK, { + this.emit(Event.RUN_CHECK_OK, { rule: ruleName, config: configForRule, current, @@ -458,9 +473,9 @@ export class Smoker extends createStrictEventEmitterClass() { }; if (allFailed.length) { - this.emit(RUN_CHECKS_FAILED, evtData); + this.emit(Event.RUN_CHECKS_FAILED, evtData); } else { - this.emit(RUN_CHECKS_OK, evtData); + this.emit(Event.RUN_CHECKS_OK, evtData); } return {failed: allFailed, passed: allPassed}; @@ -492,7 +507,7 @@ export class Smoker extends createStrictEventEmitterClass() { ); await Promise.resolve(); - this.emit(RUN_SCRIPTS_BEGIN, { + this.emit(Event.RUN_SCRIPTS_BEGIN, { manifest: pkgRunManifestForEmit, total: totalScripts, }); @@ -514,7 +529,7 @@ export class Smoker extends createStrictEventEmitterClass() { const {script} = runManifest; const {pkgName} = runManifest.packedPkg; scripts.push(script); - this.emit(RUN_SCRIPT_BEGIN, { + this.emit(Event.RUN_SCRIPT_BEGIN, { script, pkgName, total: totalScripts, @@ -530,7 +545,7 @@ export class Smoker extends createStrictEventEmitterClass() { pkgName, result, ); - this.emit(RUN_SCRIPT_FAILED, { + this.emit(Event.RUN_SCRIPT_FAILED, { ...result, script, error: result.error, @@ -542,7 +557,7 @@ export class Smoker extends createStrictEventEmitterClass() { break BAIL; } } else { - this.emit(RUN_SCRIPT_OK, { + this.emit(Event.RUN_SCRIPT_OK, { ...result, script, current, @@ -562,7 +577,7 @@ export class Smoker extends createStrictEventEmitterClass() { const failed = results.filter((result) => result.error).length; const passed = results.length - failed; - this.emit(failed ? RUN_SCRIPTS_FAILED : RUN_SCRIPTS_OK, { + this.emit(failed ? Event.RUN_SCRIPTS_FAILED : Event.RUN_SCRIPTS_OK, { results, manifest: pkgRunManifestForEmit, total: totalScripts, @@ -600,8 +615,12 @@ export class Smoker extends createStrictEventEmitterClass() { * @param results Results from {@linkcode smoke} * @returns Whether the results indicate a failure */ - public isSmokeFailure({scripts, checks}: SmokeResults) { - return checks.failed.length || scripts.some((r) => r.error); + public isSmokeFailure({scripts, checks}: SmokeResults): boolean { + return ( + checks.failed.some( + (checkFailure) => checkFailure.severity === CheckSeverities.ERROR, + ) || scripts.some((runScriptResult) => runScriptResult.error) + ); } /** @@ -611,7 +630,7 @@ export class Smoker extends createStrictEventEmitterClass() { public async smoke(): Promise { // do not emit synchronously await Promise.resolve(); - this.emit(SMOKE_BEGIN); + this.emit(Event.SMOKE_BEGIN); try { // PACK @@ -643,11 +662,11 @@ export class Smoker extends createStrictEventEmitterClass() { if (this.isSmokeFailure(smokeResults)) { this.emit( - SMOKE_FAILED, + Event.SMOKE_FAILED, new SmokeFailedError('🤮 Maurice!', {results: smokeResults}), ); } else { - this.emit(SMOKE_OK, smokeResults); + this.emit(Event.SMOKE_OK, smokeResults); } return smokeResults; @@ -661,7 +680,7 @@ export class Smoker extends createStrictEventEmitterClass() { /** * This is only here because it's a fair amount of work to mash the data into a format more suitable for display. * - * This is used by the events {@linkcode SmokerEvents.InstallBegin}, {@linkcode SmokerEvents.InstallOk}, and {@linkcode SmokerEvents.PackOk}. + * This is used by the events {@linkcode SmokerEvent.InstallBegin}, {@linkcode SmokerEvent.InstallOk}, and {@linkcode SmokerEvent.PackOk}. * @param pkgInstallManifest What to install and with what package manager * @returns Something to be emitted */ diff --git a/src/types.ts b/src/types.ts index 96eebb9c..9c0c52e6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,8 @@ import type {ExecaError, ExecaReturnValue} from 'execa'; import type {ScriptError} from './error'; import type {PackageManager} from './pm'; -import type {RawRuleConfig, CheckResults} from './rules'; +import type {CheckResults} from './rules'; +import type {SmokerOptions} from './options'; /** * Properties of the result of running `execa` that we care about @@ -62,82 +63,6 @@ export interface InstallManifest { tarballRootDir: string; } -export interface SmokeOptions { - /** - * List of workspaces to use - */ - workspace?: string[]; - /** - * Use all workspaces - */ - all?: boolean; - /** - * Include workspace root; implies `allWorkspaces` - */ - includeRoot?: boolean; - /** - * Working directory to use. If omitted, a temp dir is created - */ - dir?: string; - /** - * If `true`, working directory will be overwritten - */ - force?: boolean; - /** - * If `true` truncate working directory. Implies `force` - */ - clean?: boolean; - /** - * Explicit path to `npm` - */ - npm?: string; - /** - * If `true`, show STDERR/STDOUT from the package manager - */ - verbose?: boolean; - /** - * If `true`, leave temp dir intact after exit - */ - linger?: boolean; - /** - * If `true`, halt at first failure - */ - bail?: boolean; - - /** - * If `true`, output JSON instead of human-readable text - */ - json?: boolean; - - /** - * Additional deps to install - */ - add?: string[]; - - /** - * Package manager ids - */ - pm?: string[]; - - /** - * If `true`, ignore missing scripts (if `all` is `true`) - */ - loose?: boolean; - - /** - * Raw (user-provided) rule configuration - */ - rules?: RawRuleConfig; - - /** - * If `true`, run checks - * @defaultValue true - */ - checks?: boolean; -} - -export type SmokerOptions = Omit; - /** * Describes what tarballs to install where with what package manager */ @@ -152,7 +77,7 @@ export type PkgRunManifest = Map>; * The result of running `Smoker.prototype.smoke()` */ export interface SmokeResults { - opts: SmokeOptions; + opts: SmokerOptions; scripts: RunScriptResult[]; checks: CheckResults; } diff --git a/src/util.ts b/src/util.ts index 0560028c..92c11e0c 100644 --- a/src/util.ts +++ b/src/util.ts @@ -18,10 +18,10 @@ export function normalizeStringArray(value?: string | string[]): string[] { : []; } -export function castArray(value: T | T[]): T[] { +export function castArray(value: T | T[]): NonNullable[] { return (value ? (Array.isArray(value) ? value : [value]) : []).filter( Boolean, - ); + ) as NonNullable[]; } /** diff --git a/test/e2e/cli.spec.ts b/test/e2e/cli.spec.ts index 0ac160a6..30485ca4 100644 --- a/test/e2e/cli.spec.ts +++ b/test/e2e/cli.spec.ts @@ -82,6 +82,71 @@ describe('midnight-smoker', function () { }); }); + describe('check', function () { + describe('when a check fails', function () { + describe('when the rule severity is "error"', function () { + const cwd = path.join(__dirname, 'fixture', 'check-error'); + let result: RawRunScriptResult; + + before(async function () { + try { + result = await execSmoker([], { + cwd, + }); + } catch (e) { + result = e as RawRunScriptResult; + } + }); + + it('should exit with a non-zero exit code', function () { + expect(result.exitCode, 'to be greater than', 0); + }); + + it('should produce expected output [snapshot]', async function () { + snapshot(fixupOutput(result.stderr)); + }); + }); + + describe('when the rule severity is "warn"', function () { + const cwd = path.join(__dirname, 'fixture', 'check-warn'); + let result: RawRunScriptResult; + + before(async function () { + result = await execSmoker([], { + cwd, + }); + }); + + it('should not exit with a non-zero exit code', function () { + expect(result.exitCode, 'to be', 0); + }); + + it('should produce expected output [snapshot]', async function () { + snapshot(fixupOutput(result.stderr)); + }); + }); + + describe('when the rule severity is "off"', function () { + const cwd = path.join(__dirname, 'fixture', 'check-off'); + let result: RawRunScriptResult; + + before(async function () { + result = await execSmoker([], { + cwd, + }); + }); + + it('should not exit with a non-zero exit code', function () { + expect(result.exitCode, 'to be', 0); + }); + + it('should produce expected output [snapshot]', async function () { + snapshot(fixupOutput(result.stderr)); + }); + }); + }); + }); + describe('option', function () { describe('--version', function () { it('should print version and exit', async function () { @@ -192,7 +257,8 @@ describe('midnight-smoker', function () { }, ); const lines = stderr.trim().split(/\r?\n/); - lingeringTempDir = lines[lines.length - 1].trim(); + // leading "» " + lingeringTempDir = lines[lines.length - 1].trim().slice(2); expect(failed, 'to be false'); // this is probably brittle. could use something like `resolve-from` diff --git a/test/e2e/config-file.spec.ts b/test/e2e/config-file.spec.ts index cc3d5954..04683b30 100644 --- a/test/e2e/config-file.spec.ts +++ b/test/e2e/config-file.spec.ts @@ -11,7 +11,28 @@ describe('midnight-smoker', function () { const cwd = path.join(__dirname, 'fixture', 'config-esm'); it('should respect the config file', async function () { - const {stdout} = await execSmoker(['smoke', '--json', '--no-checks'], { + const {stdout} = await execSmoker(['smoke', '--no-checks'], { + cwd, + }); + const result = JSON.parse(stdout); + expect(result, 'to satisfy', { + results: { + scripts: expect + .it('to have length', 2) + .and('to satisfy', [ + {rawResult: {command: /npm/}}, + {rawResult: {command: /yarn/}}, + ]), + }, + }); + }); + }); + + describe('when config is within package.json', async function () { + const cwd = path.join(__dirname, 'fixture', 'config-package-json'); + + it('should respect the config file', async function () { + const {stdout} = await execSmoker(['smoke'], { cwd, }); const result = JSON.parse(stdout); diff --git a/test/e2e/fixture/check-error/id_rsa b/test/e2e/fixture/check-error/id_rsa new file mode 100644 index 00000000..4d933d1a --- /dev/null +++ b/test/e2e/fixture/check-error/id_rsa @@ -0,0 +1 @@ +I am a private key diff --git a/test/e2e/fixture/check-error/index.js b/test/e2e/fixture/check-error/index.js new file mode 100644 index 00000000..8562fd05 --- /dev/null +++ b/test/e2e/fixture/check-error/index.js @@ -0,0 +1 @@ +console.log('entry point'); diff --git a/test/e2e/fixture/check-error/package.json b/test/e2e/fixture/check-error/package.json new file mode 100644 index 00000000..18745e52 --- /dev/null +++ b/test/e2e/fixture/check-error/package.json @@ -0,0 +1,12 @@ +{ + "name": "check-error", + "version": "1.0.0", + "scripts": { + "smoke": "exit 0" + }, + "smoker": { + "rules": { + "no-banned-files": "error" + } + } +} diff --git a/test/e2e/fixture/check-off/id_rsa b/test/e2e/fixture/check-off/id_rsa new file mode 100644 index 00000000..4d933d1a --- /dev/null +++ b/test/e2e/fixture/check-off/id_rsa @@ -0,0 +1 @@ +I am a private key diff --git a/test/e2e/fixture/check-off/index.js b/test/e2e/fixture/check-off/index.js new file mode 100644 index 00000000..8562fd05 --- /dev/null +++ b/test/e2e/fixture/check-off/index.js @@ -0,0 +1 @@ +console.log('entry point'); diff --git a/test/e2e/fixture/check-off/package.json b/test/e2e/fixture/check-off/package.json new file mode 100644 index 00000000..5077acea --- /dev/null +++ b/test/e2e/fixture/check-off/package.json @@ -0,0 +1,12 @@ +{ + "name": "check-off", + "version": "1.0.0", + "scripts": { + "smoke": "exit 0" + }, + "smoker": { + "rules": { + "no-banned-files": "off" + } + } +} diff --git a/test/e2e/fixture/check-warn/id_rsa b/test/e2e/fixture/check-warn/id_rsa new file mode 100644 index 00000000..4d933d1a --- /dev/null +++ b/test/e2e/fixture/check-warn/id_rsa @@ -0,0 +1 @@ +I am a private key diff --git a/test/e2e/fixture/check-warn/index.js b/test/e2e/fixture/check-warn/index.js new file mode 100644 index 00000000..8562fd05 --- /dev/null +++ b/test/e2e/fixture/check-warn/index.js @@ -0,0 +1 @@ +console.log('entry point'); diff --git a/test/e2e/fixture/check-warn/package.json b/test/e2e/fixture/check-warn/package.json new file mode 100644 index 00000000..e3ae99e0 --- /dev/null +++ b/test/e2e/fixture/check-warn/package.json @@ -0,0 +1,12 @@ +{ + "name": "check-warn", + "version": "1.0.0", + "scripts": { + "smoke": "exit 0" + }, + "smoker": { + "rules": { + "no-banned-files": "warn" + } + } +} diff --git a/test/e2e/fixture/config-package-json/package.json b/test/e2e/fixture/config-package-json/package.json new file mode 100644 index 00000000..b19773fa --- /dev/null +++ b/test/e2e/fixture/config-package-json/package.json @@ -0,0 +1,12 @@ +{ + "name": "config-esm", + "version": "1.0.0", + "scripts": { + "smoke": "exit 0" + }, + "smoker": { + "pm": ["npm@latest", "yarn@1"], + "json": true, + "checks": false + } +} diff --git a/test/e2e/rules/fixture/no-missing-pkg-files/index.js b/test/e2e/rules/fixture/no-missing-pkg-files/index.js new file mode 100644 index 00000000..b7be9983 --- /dev/null +++ b/test/e2e/rules/fixture/no-missing-pkg-files/index.js @@ -0,0 +1 @@ +console.log('worms') diff --git a/test/e2e/rules/fixture/no-missing-pkg-files/package.json b/test/e2e/rules/fixture/no-missing-pkg-files/package.json index 1660759b..58f177ac 100644 --- a/test/e2e/rules/fixture/no-missing-pkg-files/package.json +++ b/test/e2e/rules/fixture/no-missing-pkg-files/package.json @@ -3,5 +3,10 @@ "version": "1.0.0", "bin": { "no-missing-pkg-files": "./bin/no-missing-pkg-files.js" + }, + "smoker": { + "rules": { + "no-missing-pkg-files": {"bin": false} + } } } diff --git a/test/e2e/rules/no-banned-files.spec.ts b/test/e2e/rules/no-banned-files.spec.ts index 5c768c1f..0aa81956 100644 --- a/test/e2e/rules/no-banned-files.spec.ts +++ b/test/e2e/rules/no-banned-files.spec.ts @@ -1,6 +1,6 @@ import unexpected from 'unexpected'; import type {PackedPackage} from '../../../src'; -import {RuleSeverities, type RawRuleConfig} from '../../../src/rules'; +import {CheckSeverities, type RawCheckOptions} from '../../../src/rules'; import noBannedFiles from '../../../src/rules/builtin/no-banned-files'; import {setupRuleTest, applyRules} from './rule-helpers'; @@ -8,7 +8,7 @@ const expect = unexpected.clone(); describe('midnight-smoker', function () { describe('rules', function () { - let ruleConfig: RawRuleConfig; + let ruleConfig: RawCheckOptions; let pkg: PackedPackage; describe('no-banned-files', function () { @@ -33,7 +33,7 @@ describe('midnight-smoker', function () { pkgJson: expect.it('to be an object'), pkgJsonPath: expect.it('to be a string'), pkgPath: expect.it('to be a string'), - severity: RuleSeverities.ERROR, + severity: CheckSeverities.ERROR, }, }, ]), @@ -67,7 +67,7 @@ describe('midnight-smoker', function () { pkgJson: expect.it('to be an object'), pkgJsonPath: expect.it('to be a string'), pkgPath: expect.it('to be a string'), - severity: RuleSeverities.ERROR, + severity: CheckSeverities.ERROR, }, }, ]), diff --git a/test/e2e/rules/no-missing-entry-point.spec.ts b/test/e2e/rules/no-missing-entry-point.spec.ts index f8d3629c..13a30a45 100644 --- a/test/e2e/rules/no-missing-entry-point.spec.ts +++ b/test/e2e/rules/no-missing-entry-point.spec.ts @@ -1,6 +1,6 @@ import unexpected from 'unexpected'; import type {PackedPackage} from '../../../src'; -import {RuleSeverities, type RawRuleConfig} from '../../../src/rules'; +import {CheckSeverities, type RawCheckOptions} from '../../../src/rules'; import noMissingEntryPoint from '../../../src/rules/builtin/no-missing-entry-point'; import {setupRuleTest, applyRules} from './rule-helpers'; @@ -8,7 +8,7 @@ const expect = unexpected.clone(); describe('midnight-smoker', function () { describe('rules', function () { - let ruleConfig: RawRuleConfig; + let ruleConfig: RawCheckOptions; let pkg: PackedPackage; describe('no-missing-entry-point', function () { @@ -31,7 +31,7 @@ describe('midnight-smoker', function () { pkgJson: expect.it('to be an object'), pkgJsonPath: expect.it('to be a string'), pkgPath: expect.it('to be a string'), - severity: RuleSeverities.ERROR, + severity: CheckSeverities.ERROR, }, }, ], @@ -60,7 +60,7 @@ describe('midnight-smoker', function () { pkgJson: expect.it('to be an object'), pkgJsonPath: expect.it('to be a string'), pkgPath: expect.it('to be a string'), - severity: RuleSeverities.ERROR, + severity: CheckSeverities.ERROR, }, }, ], @@ -106,7 +106,7 @@ describe('midnight-smoker', function () { pkgJson: expect.it('to be an object'), pkgJsonPath: expect.it('to be a string'), pkgPath: expect.it('to be a string'), - severity: RuleSeverities.ERROR, + severity: CheckSeverities.ERROR, }, }, ], diff --git a/test/e2e/rules/no-missing-exports.spec.ts b/test/e2e/rules/no-missing-exports.spec.ts index 12a346c1..ae12f3bd 100644 --- a/test/e2e/rules/no-missing-exports.spec.ts +++ b/test/e2e/rules/no-missing-exports.spec.ts @@ -1,6 +1,6 @@ import unexpected from 'unexpected'; import type {PackedPackage} from '../../../src'; -import {RuleSeverities, type RawRuleConfig} from '../../../src/rules'; +import {CheckSeverities, type RawCheckOptions} from '../../../src/rules'; import noMissingExports from '../../../src/rules/builtin/no-missing-exports'; import {setupRuleTest, applyRules} from './rule-helpers'; @@ -8,7 +8,7 @@ const expect = unexpected.clone(); describe('midnight-smoker', function () { describe('rules', function () { - let ruleConfig: RawRuleConfig; + let ruleConfig: RawCheckOptions; let pkg: PackedPackage; describe('no-missing-exports', function () { @@ -31,7 +31,7 @@ describe('midnight-smoker', function () { pkgJson: expect.it('to be an object'), pkgJsonPath: expect.it('to be a string'), pkgPath: expect.it('to be a string'), - severity: RuleSeverities.ERROR, + severity: CheckSeverities.ERROR, }, }, ], diff --git a/test/e2e/rules/no-missing-pkg-files.spec.ts b/test/e2e/rules/no-missing-pkg-files.spec.ts index e621b531..52d68a0d 100644 --- a/test/e2e/rules/no-missing-pkg-files.spec.ts +++ b/test/e2e/rules/no-missing-pkg-files.spec.ts @@ -1,6 +1,6 @@ import unexpected from 'unexpected'; import type {PackedPackage} from '../../../src'; -import {RuleSeverities, type RawRuleConfig} from '../../../src/rules'; +import {CheckSeverities, type RawCheckOptions} from '../../../src/rules'; import noMissingPkgFiles from '../../../src/rules/builtin/no-missing-pkg-files'; import {setupRuleTest, applyRules} from './rule-helpers'; @@ -8,7 +8,7 @@ const expect = unexpected.clone(); describe('midnight-smoker', function () { describe('rules', function () { - let ruleConfig: RawRuleConfig; + let ruleConfig: RawCheckOptions; let pkg: PackedPackage; describe('no-missing-pkg-files', function () { @@ -35,7 +35,7 @@ describe('midnight-smoker', function () { pkgJson: expect.it('to be an object'), pkgJsonPath: expect.it('to be a string'), pkgPath: expect.it('to be a string'), - severity: RuleSeverities.ERROR, + severity: CheckSeverities.ERROR, }, }, ], @@ -61,7 +61,7 @@ describe('midnight-smoker', function () { pkgJson: expect.it('to be an object'), pkgJsonPath: expect.it('to be a string'), pkgPath: expect.it('to be a string'), - severity: RuleSeverities.ERROR, + severity: CheckSeverities.ERROR, }, }, ], @@ -92,7 +92,7 @@ describe('midnight-smoker', function () { pkgJson: expect.it('to be an object'), pkgJsonPath: expect.it('to be a string'), pkgPath: expect.it('to be a string'), - severity: RuleSeverities.ERROR, + severity: CheckSeverities.ERROR, }, }, ], @@ -120,7 +120,7 @@ describe('midnight-smoker', function () { pkgJson: expect.it('to be an object'), pkgJsonPath: expect.it('to be a string'), pkgPath: expect.it('to be a string'), - severity: RuleSeverities.ERROR, + severity: CheckSeverities.ERROR, }, }, ], @@ -148,7 +148,7 @@ describe('midnight-smoker', function () { pkgJson: expect.it('to be an object'), pkgJsonPath: expect.it('to be a string'), pkgPath: expect.it('to be a string'), - severity: RuleSeverities.ERROR, + severity: CheckSeverities.ERROR, }, }, ], diff --git a/test/e2e/rules/rule-helpers.ts b/test/e2e/rules/rule-helpers.ts index 4d8e0c56..69d01d5e 100644 --- a/test/e2e/rules/rule-helpers.ts +++ b/test/e2e/rules/rule-helpers.ts @@ -1,13 +1,14 @@ import path from 'node:path'; import fs from 'node:fs'; import {PackedPackage, Smoker} from '../../../src'; -import {RawRuleConfig} from '../../../src/rules/rule-config'; +import {RawCheckOptions, zCheckOptions} from '../../../src/rules/check-options'; import {RuleCont} from '../../../src/rules/rule'; import {EventEmitter} from 'node:events'; -import {BuiltinRuleConts} from '../../../src/rules/builtin'; -export function setupRuleTest(fixtureName: string, config: RawRuleConfig = {}) { - const ruleConfig = config; +export function setupRuleTest( + fixtureName: string, + config: RawCheckOptions = {}, +) { const installPath = path.join(__dirname, 'fixture', fixtureName); try { fs.statSync(installPath); @@ -19,11 +20,11 @@ export function setupRuleTest(fixtureName: string, config: RawRuleConfig = {}) { installPath, tarballFilepath: '', }; - return {ruleConfig, pkg}; + return {ruleConfig: config, pkg}; } export async function applyRules( - config: RawRuleConfig, + config: RawCheckOptions, {installPath: pkgPath}: PackedPackage, ruleCont: RuleCont, ) { @@ -31,6 +32,6 @@ export async function applyRules( new EventEmitter(), ruleCont, pkgPath, - config, + zCheckOptions.parse(config), ); } diff --git a/test/e2e/rules/severity.spec.ts b/test/e2e/rules/severity.spec.ts new file mode 100644 index 00000000..f6f807c2 --- /dev/null +++ b/test/e2e/rules/severity.spec.ts @@ -0,0 +1,65 @@ +import unexpected from 'unexpected'; +import type {PackedPackage} from '../../../src'; +import {CheckSeverities, type RawCheckOptions} from '../../../src/rules'; +import noBannedFiles from '../../../src/rules/builtin/no-banned-files'; +import {setupRuleTest, applyRules} from './rule-helpers'; + +const expect = unexpected.clone(); + +describe('midnight-smoker', function () { + describe('rules', function () { + let ruleConfig: RawCheckOptions; + let pkg: PackedPackage; + + describe('severity behavior', function () { + describe('when severity for a rule is configured to "warn"', function () { + beforeEach(function () { + ({ruleConfig, pkg} = setupRuleTest('no-banned-files', { + 'no-banned-files': 'warn', + })); + }); + + it('should return a RuleFailure with severity "warn"', async function () { + const ruleCont = noBannedFiles.toRuleCont(); + await expect( + applyRules(ruleConfig, pkg, ruleCont), + 'to be fulfilled with value satisfying', + { + failed: expect.it('to satisfy', [ + { + rule: noBannedFiles.toJSON(), + message: 'Banned file found: id_rsa (Private SSH key)', + context: { + pkgJson: expect.it('to be an object'), + pkgJsonPath: expect.it('to be a string'), + pkgPath: expect.it('to be a string'), + severity: CheckSeverities.WARN, + }, + }, + ]), + }, + ); + }); + }); + + describe('when severity for a rule is configured to "off"', function () { + beforeEach(function () { + ({ruleConfig, pkg} = setupRuleTest('no-banned-files', { + 'no-banned-files': 'off', + })); + }); + + it('should not return a RuleFailure', async function () { + const ruleCont = noBannedFiles.toRuleCont(); + await expect( + applyRules(ruleConfig, pkg, ruleCont), + 'to be fulfilled with value satisfying', + { + failed: expect.it('to be empty'), + }, + ); + }); + }); + }); + }); +}); diff --git a/test/unit/options.spec.ts b/test/unit/options.spec.ts new file mode 100644 index 00000000..8aebba9e --- /dev/null +++ b/test/unit/options.spec.ts @@ -0,0 +1,33 @@ +import unexpected from 'unexpected'; +import {parseOptions} from '../../src/options'; + +const expect = unexpected.clone(); + +describe('midnight-smoker', function () { + describe('options', function () { + describe('parseOptions()', function () { + describe('when provided no options', function () { + it('should not throw', function () { + expect(() => parseOptions(), 'not to throw'); + }); + }); + + describe('when provided unknown options', function () { + it('should not throw', function () { + // @ts-expect-error bad type + expect(() => parseOptions({cows: true}), 'not to throw'); + }); + }); + + describe('when provided invalid options', function () { + it('should throw', function () { + expect( + () => parseOptions({all: true, workspace: ['foo']}), + 'to throw', + /Option "workspace" is mutually exclusive with "all"/, + ); + }); + }); + }); + }); +}); diff --git a/test/unit/rule.spec.ts b/test/unit/rule.spec.ts new file mode 100644 index 00000000..b3749210 --- /dev/null +++ b/test/unit/rule.spec.ts @@ -0,0 +1,54 @@ +import unexpected from 'unexpected'; +import {zCheckOptions} from '../../src/rules/check-options'; +const expect = unexpected.clone(); + +describe('midnight-smoker', function () { + describe('rule', function () { + describe('rule options schema', function () { + it('should allow undefined', function () { + expect(() => zCheckOptions.parse(undefined), 'not to throw'); + }); + + it('should allow an empty object', function () { + expect(() => zCheckOptions.parse({}), 'not to throw'); + }); + }); + + it('should allow overriding of severity', function () { + expect(zCheckOptions.parse({'no-banned-files': 'warn'}), 'to satisfy', { + 'no-banned-files': { + severity: 'warn', + }, + }); + }); + + it('should allow overriding of options', function () { + expect( + zCheckOptions.parse({'no-missing-exports': {glob: false}}), + 'to satisfy', + { + 'no-missing-exports': { + opts: { + glob: false, + }, + }, + }, + ); + }); + + it('should allow overriding of options and severity', function () { + expect( + zCheckOptions.parse({'no-missing-exports': [{glob: false}, 'warn']}), + 'to satisfy', + { + 'no-missing-exports': { + severity: 'warn', + opts: { + glob: false, + }, + }, + }, + ); + }); + }); +}); diff --git a/test/unit/smoker.spec.ts b/test/unit/smoker.spec.ts index a7a50013..3fba8309 100644 --- a/test/unit/smoker.spec.ts +++ b/test/unit/smoker.spec.ts @@ -48,7 +48,7 @@ describe('midnight-smoker', function () { let Smoker: typeof MS.Smoker; - let Events: typeof MS.Events; + let Event: typeof MS.Event; let smoke: typeof MS.smoke; @@ -90,7 +90,7 @@ describe('midnight-smoker', function () { mockPm = new Mocks.NullPm(new CorepackExecutor('moo')); pms.set(MOCK_PM_ID, mockPm); - ({Smoker, smoke, Events} = rewiremock.proxy( + ({Smoker, smoke, Event} = rewiremock.proxy( () => require('../../src'), mocks, )); @@ -101,33 +101,11 @@ describe('midnight-smoker', function () { }); describe('class Smoker', function () { - describe('constructor', function () { - it('should throw if both non-empty "workspace" and true "all" options are provided', function () { - expect( - () => new Smoker(pms, [], {workspace: ['foo'], all: true}), - 'to throw', - /Option "workspace" is mutually exclusive with "all" and\/or "includeRoot"/, - ); - }); - - describe('when passed a string for "scripts" argument', function () { - it('should not throw', function () { - expect(() => new Smoker(pms, 'foo'), 'not to throw'); - }); - }); - - describe('when not passed any scripts at all', function () { - it('should not throw', function () { - expect(() => new Smoker(pms), 'not to throw'); - }); - }); - }); - describe('method', function () { let smoker: MS.Smoker; beforeEach(function () { - smoker = new Smoker(pms, 'foo'); + smoker = Smoker.create(pms, {script: 'foo'}); }); describe('cleanup()', function () { @@ -178,7 +156,10 @@ describe('midnight-smoker', function () { describe('when the "linger" option is true and a temp dir was created', function () { beforeEach(async function () { - smoker = new Smoker(pms, 'foo', {linger: true}); + smoker = Smoker.create(pms, { + script: 'foo', + linger: true, + }); await smoker.createTempDir(); }); @@ -192,7 +173,7 @@ describe('midnight-smoker', function () { smoker.cleanup(), 'to emit from', smoker, - Events.LINGERED, + Event.LINGERED, ); }); }); @@ -226,17 +207,12 @@ describe('midnight-smoker', function () { describe('pack()', function () { it('should emit the "PackBegin" event', async function () { - await expect( - smoker.pack(), - 'to emit from', - smoker, - Events.PACK_BEGIN, - ); + await expect(smoker.pack(), 'to emit from', smoker, Event.PACK_BEGIN); }); describe('when packing succeeds', function () { it('should emit the "PackOk" event', async function () { - await expect(smoker.pack(), 'to emit from', smoker, Events.PACK_OK); + await expect(smoker.pack(), 'to emit from', smoker, Event.PACK_OK); }); }); @@ -288,7 +264,7 @@ describe('midnight-smoker', function () { }, 'to emit from', smoker, - Events.PACK_FAILED, + Event.PACK_FAILED, new Error('uh oh'), ); }); @@ -325,7 +301,7 @@ describe('midnight-smoker', function () { smoker.install(pkgInstallManifest), 'to emit from', smoker, - Events.INSTALL_BEGIN, + Event.INSTALL_BEGIN, ); }); @@ -334,7 +310,7 @@ describe('midnight-smoker', function () { smoker.install(pkgInstallManifest), 'to emit from', smoker, - Events.INSTALL_OK, + Event.INSTALL_OK, ); }); @@ -364,7 +340,7 @@ describe('midnight-smoker', function () { }, 'to emit from', smoker, - Events.INSTALL_FAILED, + Event.INSTALL_FAILED, new Error('uh oh'), ); }); @@ -414,7 +390,7 @@ describe('midnight-smoker', function () { smoker.runScripts(pkgRunManifest), 'to emit from', smoker, - Events.RUN_SCRIPTS_BEGIN, + Event.RUN_SCRIPTS_BEGIN, ); }); @@ -423,7 +399,7 @@ describe('midnight-smoker', function () { smoker.runScripts(pkgRunManifest), 'to emit from', smoker, - Events.RUN_SCRIPT_BEGIN, + Event.RUN_SCRIPT_BEGIN, {script: 'foo', pkgName: 'bar', total: 2, current: 0}, ); }); @@ -446,7 +422,7 @@ describe('midnight-smoker', function () { smoker.runScripts(pkgRunManifest), 'to emit from', smoker, - Events.RUN_SCRIPTS_OK, + Event.RUN_SCRIPTS_OK, { results: expect.it('to be an array'), failed: 0, @@ -462,7 +438,7 @@ describe('midnight-smoker', function () { smoker.runScripts(pkgRunManifest), 'to emit from', smoker, - Events.RUN_SCRIPT_OK, + Event.RUN_SCRIPT_OK, { script: 'foo', current: 0, @@ -526,7 +502,7 @@ describe('midnight-smoker', function () { smoker.runScripts(pkgRunManifest), 'to emit from', smoker, - Events.RUN_SCRIPT_FAILED, + Event.RUN_SCRIPT_FAILED, { pkgName: 'bar', error: expect.it('to be a', SmokerError), @@ -543,7 +519,7 @@ describe('midnight-smoker', function () { smoker.runScripts(pkgRunManifest), 'to emit from', smoker, - Events.RUN_SCRIPTS_FAILED, + Event.RUN_SCRIPTS_FAILED, { results: [ {pkgName: 'bar', error: expect.it('to be a', SmokerError)}, @@ -557,7 +533,10 @@ describe('midnight-smoker', function () { describe('when the "bail" option is false', function () { beforeEach(function () { - smoker = new Smoker(pms, 'foo', {bail: false}); + smoker = Smoker.create(pms, { + script: 'foo', + bail: false, + }); }); it('should execute all scripts', async function () { @@ -574,16 +553,17 @@ describe('midnight-smoker', function () { describe('when the "bail" option is true', function () { beforeEach(function () { - smoker = new Smoker(pms, 'foo', {bail: true}); + smoker = Smoker.create(pms, { + script: 'foo', + bail: true, + }); }); it('should execute only until a script fails', async function () { await expect( smoker.runScripts(pkgRunManifest), 'to be fulfilled with value satisfying', - expect - .it('to have length', 1) - .and('to satisfy', [{pkgName: 'bar', error}]), + expect.it('to have length', 1), ); }); }); @@ -609,7 +589,7 @@ describe('midnight-smoker', function () { describe('when checks enabled', async function () { beforeEach(function () { - smoker = new Smoker(pms); + smoker = Smoker.create(pms); }); it('should run checks', async function () { @@ -646,7 +626,7 @@ describe('midnight-smoker', function () { describe('smoke()', function () { it('should pack, install, and run scripts', async function () { await expect( - Smoker.smoke('foo'), + Smoker.smoke({script: 'foo'}), 'to be fulfilled with value satisfying', { scripts: [ @@ -658,14 +638,24 @@ describe('midnight-smoker', function () { }); }); - describe('init()', function () { - it('should return a new Smoker instance', async function () { - await expect( - Smoker.init('foo'), - 'to be fulfilled with value satisfying', - expect.it('to be a', Smoker), + describe('create()', function () { + it('should throw if both non-empty "workspace" and true "all" options are provided', function () { + expect( + () => Smoker.create(pms, {workspace: ['foo'], all: true}), + 'to throw', + /Option "workspace" is mutually exclusive with "all"/, ); }); + + describe('when not passed any scripts at all', function () { + it('should not throw', function () { + expect(() => Smoker.create(pms), 'not to throw'); + }); + }); + + it('should return a Smoker instance', function () { + expect(Smoker.create(pms), 'to be a', Smoker); + }); }); }); });