diff --git a/.c8rc.json b/.c8rc.json new file mode 100644 index 0000000..e28584d --- /dev/null +++ b/.c8rc.json @@ -0,0 +1,14 @@ +{ + "all": true, + "include": ["src/**"], + "exclude": ["tests/**"], + "reporter": ["text", "json-summary"], + "report-dir": "coverage", + "temp-directory": "coverage/tmp", + "check-coverage": true, + "lines": 100, + "branches": 100, + "functions": 100, + "statements": 100, + "clean": true +} diff --git a/.claude/rules/code-style.md b/.claude/rules/code-style.md new file mode 100644 index 0000000..34f774a --- /dev/null +++ b/.claude/rules/code-style.md @@ -0,0 +1,23 @@ +# Coding Standards + +- Keep at most a single public struct per module. +- Keep at most a single public function per module (multiple public struct methods are OK). +- Keep module names elegant and clearly readable. The name of the module, or any file, should be enough to determine its contents unambiguously. +- Keep modules structure as flat as possible, avoid logical grouping of modules, instead keep the naming consistent. +- Keep standalone, private functions and structs above the public struct or function that is exported. +- Group the modules by name prefix. For example, `client_foo`, `client_bar`, etc., wherever it makes sense to do so. +- Decide to group the modules based on software architecture, messaging hierarchy, or inheritance. Do not group modules just for the sake of it. +- Maintain a tree-like structure of modules, avoid circular dependencies at all costs. Extract common functions or structs into separate modules, or separate subprojects in the workspace. +- Name files the same way as the struct or function they contain. +- Be explicit, do not use general import statements that involve "*", prefer to import everything explicitly. +- Do not use copy-pasted or copied code in any capacity. If you have issues extracting something into a module, discuss the steps first. +- Keeping slightly different message types, or other kinds of structs that are only slightly different, because of the context they are used in, is fine. +- Each function or method should do just a single thing. The single responsibility principle is really important. +- Always use descriptive and explicit variable names, even in anonymous functions. Never use single-letter variable names. +- Instead of writing comments that explain what the code does, make the code self-documenting. +- Handle all the errors; never ignore them. Make sure the application does not panic. +- Use object-oriented style and composition. Avoid functions that take a struct as a parameter; move it to the struct implementation instead. +- Avoid unnecessary abstractions. +- Before using vendor crates or modules, make sure they are well-maintained, secure, and documented. +- Always make sure there is only one valid way to do a specific task in the codebase. Make sure everything has a single source of truth. +- Prefer using data/value objects instead of inline types diff --git a/.claude/rules/commits.md b/.claude/rules/commits.md new file mode 100644 index 0000000..e660b3a --- /dev/null +++ b/.claude/rules/commits.md @@ -0,0 +1,5 @@ +# Committing Changes + +- Always keep the commit messages short, human-readable, and descriptive. Keep commit messages as one-liners. +- Do not add any metadata to commits. +- Describe what the changes actually do instead of listing the changed files. diff --git a/.claude/rules/github-actions.md b/.claude/rules/github-actions.md new file mode 100644 index 0000000..1457661 --- /dev/null +++ b/.claude/rules/github-actions.md @@ -0,0 +1,9 @@ +--- +paths: + - ".github/actions/**/*" +--- + +# GitHub Actions Standards + +- Actions must represent a single purpose and a single concern. +- Compose actions if they need to provide a more complex functionality. diff --git a/.claude/rules/github-workflows.md b/.claude/rules/github-workflows.md new file mode 100644 index 0000000..c8554a9 --- /dev/null +++ b/.claude/rules/github-workflows.md @@ -0,0 +1,14 @@ +--- +paths: + - ".github/workflows/**/*" +--- + +# GitHub Workflows Standards + +- Always use Makefile targets in the workflow to avoid code duplication (if they need to run something that is already present in a Makefile). +- Never add the tests that use LLMs to GitHub workflows, because the default GitHub worker does not have the capacity to run them. +- Only add unit tests or linters to GitHub workflows. +- Keep GitHub workflows responsible for only a single concern. For example, run linter, and tests in parallel. +- Treat GitHub workflows as a coding project. Use composable actions, factor similar concerns into actions. +- Encapsulate functionalities in composable actions. +- Keep the workflows clean and purposeful. diff --git a/.claude/rules/makefile.md b/.claude/rules/makefile.md new file mode 100644 index 0000000..3540731 --- /dev/null +++ b/.claude/rules/makefile.md @@ -0,0 +1,13 @@ +--- +paths: + - "**/Makefile" +--- + +# Makefile Standards + +- Keep variables at the top of the file. Always. +- Prefer real targets over phony targets. If something can be express as a real target, do that. +- If you see that a phony target can be expressed as a real target, you can suggest a fix. +- Keep real targets, phony targets grouped together. Keep targets alphabetically sorted within each group. +- Keep all the real targets above phony targets. +- Make sure each Makefile target has enough dependencies to be able to run from a clean state. diff --git a/.claude/rules/nix-shell.md b/.claude/rules/nix-shell.md new file mode 100644 index 0000000..7f582f5 --- /dev/null +++ b/.claude/rules/nix-shell.md @@ -0,0 +1,12 @@ +--- +paths: + - "**/shell.nix" +--- + +# Nix Shell Standards + +- shell.nix must follow idiomatic NixOS ways of providing packages +- shell.nix must be minimal, and only providing project dependencies that would not be able to run natively otherwise +- shell.nix must not contain any workarounds +- shell.nix must not contain any kind of ELF patching +- shell.nix must not contain any kind of monkey patching diff --git a/.claude/rules/teamwork.md b/.claude/rules/teamwork.md new file mode 100644 index 0000000..34752f5 --- /dev/null +++ b/.claude/rules/teamwork.md @@ -0,0 +1,7 @@ +# Teamwork and Project Organization + +Team members own one module each. The project needs to be organized around small self-contained modules. + +Each class, struct, function, interface, trait, and alike needs to be named after its functionality in self-descriptive English. The goal is to name things in a way that will allow anyone to understand the project organization, and goals by just listing the directory of files. + +Developers need to be able to own their own modules without stepping on another's work. diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md new file mode 100644 index 0000000..c60bf4b --- /dev/null +++ b/.claude/rules/testing.md @@ -0,0 +1,14 @@ +# Unit Tests and Quality Control + +- Always check that the unit tests pass. +- Always test the code, make sure tests work after the changes. +- Always write tests that check the algorithms, or meaningful edge cases. Never write tests that check things that can be handled by types instead. +- If some piece of code can be handled by proper types, use types instead. Write tests as a last resort. +- In unit tests, make sure there is always just a single correct way to do a specific thing. Never accept fuzzy inputs from end users. +- When working on tests, if you notice that the tested code can be better, you can suggest changes. +- Maintain 100% test coverage across the codebase. No file, branch, or line may be excluded from coverage reports. +- Reach 100% coverage with the minimum number of tests. Each test must cover a unique code path, behavior, or edge case that no other test already covers. +- If two tests cover overlapping paths, remove the weaker one. Redundant tests waste maintenance effort without improving correctness signal. +- Tests must exercise actual functionality and observable behavior. Never write a test purely to hit lines for the sake of coverage. +- Design tests deliberately before writing them. Identify the feature or branch under test, then write the smallest test that verifies it. +- Coverage gaps signal missing tests, never permission to exclude files. Write the test instead of suppressing the gap. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..cb0cfa8 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,20 @@ +name: test + +on: + push: + branches: + - main + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: .nvmrc + - name: Run tests + run: make test diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml new file mode 100644 index 0000000..b4d50ed --- /dev/null +++ b/.github/workflows/typecheck.yml @@ -0,0 +1,20 @@ +name: typecheck + +on: + push: + branches: + - main + pull_request: + +jobs: + typecheck: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: .nvmrc + - name: Run typecheck + run: make typecheck diff --git a/.gitignore b/.gitignore index 07e6e47..ccc2930 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ +/coverage /node_modules diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..c8066dd --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +/coverage +/node_modules +/package-lock.json +.claude/ +CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ccdbce0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,11 @@ +# Project Context + +When working with this codebase, prioritize readability over cleverness. Ask clarifying questions before making architectural changes. + +Keep it simple, be opinionated, follow best practices. Avoid using configurable parameters. + +Keep the code beautiful. Always optimize the code for a great developer experience. + +Codebase needs to be architected in a way to make it easy for multiple team members to work in parallel on multiple modules, so the concerns always need clear separation. + +Be proactive and fix any preexisting issues you encounter. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4fc5c90 --- /dev/null +++ b/Makefile @@ -0,0 +1,31 @@ +COVERAGE_DIR := coverage +NODE_MODULES := node_modules +TEST_GLOB := tests/**/*.test.mjs + +$(NODE_MODULES): package.json package-lock.json + npm install + touch $(NODE_MODULES) + +.PHONY: clean +clean: + rm -rf $(COVERAGE_DIR) + +.PHONY: coverage +coverage: $(NODE_MODULES) + node scripts/check-coverage.mjs '$(TEST_GLOB)' + +.PHONY: format +format: $(NODE_MODULES) + npx prettier --write . + +.PHONY: test +test: $(NODE_MODULES) + node --test '$(TEST_GLOB)' + +.PHONY: type-coverage +type-coverage: $(NODE_MODULES) + npx type-coverage --at-least 100 --strict --detail + +.PHONY: typecheck +typecheck: $(NODE_MODULES) + npx tsc diff --git a/package-lock.json b/package-lock.json index e6c98de..6127820 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,41 +1,255 @@ { "name": "jarmuz", - "version": "0.8.0", + "version": "0.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jarmuz", - "version": "0.8.0", + "version": "0.10.0", "license": "MIT", "dependencies": { - "chokidar": "^4.0.3", - "cross-spawn": "^7.0.6", - "minimatch": "^10.0.3", - "nanoid": "^5.1.5", - "string-argv": "^0.3.2" + "chokidar": "4.0.3", + "cross-spawn": "7.0.6", + "minimatch": "10.2.5", + "nanoid": "5.1.5", + "string-argv": "0.3.2" }, "devDependencies": { - "prettier-plugin-organize-imports": "^4.2.0" + "@types/cross-spawn": "6.0.6", + "@types/node": "25.8.0", + "c8": "11.0.0", + "prettier": "3.8.3", + "prettier-plugin-organize-imports": "4.2.0", + "type-coverage": "2.29.7", + "typescript": "6.0.3" + }, + "engines": { + "node": ">=24.0.0" } }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", "engines": { - "node": "20 || >=22" + "node": ">=18" } }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@types/cross-spawn": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/@types/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.8.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz", + "integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", "dependencies": { - "@isaacs/balanced-match": "^4.0.1" + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/c8": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/c8/-/c8-11.0.0.tgz", + "integrity": "sha512-e/uRViGHSVIJv7zsaDKM7VRn2390TgHXqUSvYwPHBQaU6L7E9L0n9JbdkwdYPvshDT0KymBmmlwSpms3yBaMNg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.1", + "@istanbuljs/schema": "^0.1.3", + "find-up": "^5.0.0", + "foreground-child": "^3.1.1", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.1.6", + "test-exclude": "^8.0.0", + "v8-to-istanbul": "^9.0.0", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "c8": "bin/c8.js" }, "engines": { "node": "20 || >=22" + }, + "peerDependencies": { + "monocart-coverage-reports": "^2" + }, + "peerDependenciesMeta": { + "monocart-coverage-reports": { + "optional": true + } + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, "node_modules/chokidar": { @@ -52,6 +266,48 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -65,25 +321,343 @@ "node": ">= 8" } }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, - "node_modules/minimatch": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", - "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", + "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", + "dev": true, + "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/nanoid": { "version": "5.1.5", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.5.tgz", @@ -102,6 +676,58 @@ "node": "^18 || >=20" } }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -110,13 +736,42 @@ "node": ">=8" } }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/prettier": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", - "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -144,6 +799,27 @@ } } }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -156,6 +832,64 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -175,6 +909,19 @@ "node": ">=8" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -183,10 +930,141 @@ "node": ">=0.6.19" } }, - "node_modules/typescript": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", - "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", + "integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^13.0.6", + "minimatch": "^10.2.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^1.8.1" + }, + "engines": { + "node": ">= 6" + }, + "peerDependencies": { + "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" + } + }, + "node_modules/tsutils/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-coverage": { + "version": "2.29.7", + "resolved": "https://registry.npmjs.org/type-coverage/-/type-coverage-2.29.7.tgz", + "integrity": "sha512-E67Chw7SxFe++uotisxt/xzB1UxxvLztzzQqVyUZ/jKujsejVqvoO5vn25oMvqJydqYrASBVBCQCy082E2qQYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "minimist": "1", + "type-coverage-core": "^2.29.7" + }, + "bin": { + "type-coverage": "bin/type-coverage" + } + }, + "node_modules/type-coverage/node_modules/type-coverage-core": { + "version": "2.29.7", + "resolved": "https://registry.npmjs.org/type-coverage-core/-/type-coverage-core-2.29.7.tgz", + "integrity": "sha512-bt+bnXekw3p5NnqiZpNupOOxfUKGw2Z/YJedfGHkxpeyGLK7DZ59a6Wds8eq1oKjJc5Wulp2xL207z8FjFO14Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "3", + "minimatch": "6 || 7 || 8 || 9 || 10", + "normalize-path": "3", + "tslib": "1 || 2", + "tsutils": "3" + }, + "peerDependencies": { + "typescript": "2 || 3 || 4 || 5" + } + }, + "node_modules/type-coverage/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", "peer": true, @@ -198,6 +1076,42 @@ "node": ">=14.17" } }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -211,6 +1125,76 @@ "engines": { "node": ">= 8" } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index 5de61e5..8100bde 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,14 @@ { "name": "jarmuz", - "version": "0.8.0", + "version": "0.10.0", "description": "Opinionated build automation tool", "exports": { ".": "./src/index.mjs", "./job-types": "./src/job-types/index.mjs" }, "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "coverage": "make coverage", + "test": "make test" }, "repository": { "type": "git", @@ -19,16 +20,25 @@ "url": "https://github.com/intentee/jarmuz/issues" }, "homepage": "https://github.com/intentee/jarmuz#readme", + "engines": { + "node": ">=24.0.0" + }, "dependencies": { - "chokidar": "^4.0.3", - "cross-spawn": "^7.0.6", - "minimatch": "^10.0.3", - "nanoid": "^5.1.5", - "string-argv": "^0.3.2" + "chokidar": "4.0.3", + "cross-spawn": "7.0.6", + "minimatch": "10.2.5", + "nanoid": "5.1.5", + "string-argv": "0.3.2" }, "sideEffects": false, "type": "module", "devDependencies": { - "prettier-plugin-organize-imports": "^4.2.0" + "@types/cross-spawn": "6.0.6", + "@types/node": "25.8.0", + "c8": "11.0.0", + "prettier": "3.8.3", + "prettier-plugin-organize-imports": "4.2.0", + "type-coverage": "2.29.7", + "typescript": "6.0.3" } } diff --git a/scripts/check-coverage.mjs b/scripts/check-coverage.mjs new file mode 100644 index 0000000..bf8b0c6 --- /dev/null +++ b/scripts/check-coverage.mjs @@ -0,0 +1,22 @@ +import { spawnSync } from "node:child_process"; + +const DEFAULT_TEST_GLOB = "tests/**/*.test.mjs"; + +export function checkCoverage(testGlob = DEFAULT_TEST_GLOB) { + const result = spawnSync("npx", ["c8", "node", "--test", testGlob], { + shell: false, + stdio: "inherit", + }); + + if (result.error) { + throw result.error; + } + + if (null !== result.signal) { + throw new Error(`Coverage check terminated by signal ${result.signal}`); + } + + return result.status; +} + +process.exit(checkCoverage(process.argv[2])); diff --git a/src/helpers/autogenerated-comment.mjs b/src/helpers/autogenerated-comment.mjs index 4a561d6..8ae7662 100644 --- a/src/helpers/autogenerated-comment.mjs +++ b/src/helpers/autogenerated-comment.mjs @@ -1,3 +1,7 @@ +/** + * @param {string} name + * @returns {string} + */ export function autogeneratedComment(name) { return `// AUTOGENERATED by jarmuz/worker-${name}. // Do not edit this file directly. diff --git a/src/helpers/format-subtree-list.mjs b/src/helpers/format-subtree-list.mjs new file mode 100644 index 0000000..5448096 --- /dev/null +++ b/src/helpers/format-subtree-list.mjs @@ -0,0 +1,23 @@ +/** + * @typedef {object} SubtreeList + * @property {string} title + * @property {string[]} items + */ + +/** + * @param {SubtreeList} subtreeList + * @returns {string[]} + */ +export function formatSubtreeList({ title, items }) { + const sortedItems = [...items].sort(); + const lines = [`└── ${title}:`]; + + for (const [index, item] of sortedItems.entries()) { + const isLast = index === sortedItems.length - 1; + const prefix = isLast ? "└──" : "├──"; + + lines.push(` ${prefix} ${item}`); + } + + return lines; +} diff --git a/src/helpers/print-subtree-list.mjs b/src/helpers/print-subtree-list.mjs index deae475..d5f2abc 100644 --- a/src/helpers/print-subtree-list.mjs +++ b/src/helpers/print-subtree-list.mjs @@ -1,12 +1,10 @@ -export function printSubtreeList({ title, items }) { - console.log(`└── ${title}:`); - - items.sort(); +import { formatSubtreeList } from "./format-subtree-list.mjs"; - for (const [index, item] of items.entries()) { - const isLast = index === items.length - 1; - const prefix = isLast ? "└──" : "├──"; - - console.log(` ${prefix} ${item}`); +/** + * @param {import("./format-subtree-list.mjs").SubtreeList} subtreeList + */ +export function printSubtreeList({ title, items }) { + for (const line of formatSubtreeList({ title, items })) { + console.log(line); } } diff --git a/src/helpers/sleep.mjs b/src/helpers/sleep.mjs new file mode 100644 index 0000000..ac8cde1 --- /dev/null +++ b/src/helpers/sleep.mjs @@ -0,0 +1,11 @@ +/** + * @param {{ delay: number }} options + * @returns {Promise} + */ +export async function sleep({ delay }) { + return new Promise(function (resolve) { + setTimeout(function () { + resolve(); + }, delay); + }); +} diff --git a/src/job-types/basic.mjs b/src/job-types/basic.mjs index 397b81b..3392fab 100644 --- a/src/job-types/basic.mjs +++ b/src/job-types/basic.mjs @@ -1,38 +1,66 @@ +import assert from "node:assert/strict"; import { parentPort } from "node:worker_threads"; import { autogeneratedComment } from "../helpers/autogenerated-comment.mjs"; import { printSubtreeList } from "../helpers/print-subtree-list.mjs"; import { resetConsole } from "../helpers/reset-console.mjs"; +import { sleep } from "../helpers/sleep.mjs"; +/** + * @typedef {object} BasicContext + * @property {string} autogeneratedComment + * @property {string} baseDirectory + * @property {string} buildId + * @property {string} name + * @property {(input: import("../helpers/format-subtree-list.mjs").SubtreeList) => void} printSubtreeList + * @property {() => void} resetConsole + * @property {(options: { delay: number }) => Promise} sleep + */ + +/** + * @param {(context: BasicContext) => unknown} build + */ export function basic(build) { - parentPort.on("message", async function ({ baseDirectory, buildId, name }) { - function reportSuccess(success) { - parentPort.postMessage({ - baseDirectory, - buildId, - success, - }); - } - - try { - reportSuccess( - false !== - (await build({ - autogeneratedComment: autogeneratedComment(name), - buildId, - baseDirectory, - name, - printSubtreeList, - resetConsole, - })), - ); - } catch (error) { - console.error( - `Build ${buildId} failed because of uncaught exception:`, - error, - ); - - reportSuccess(false); - } - }); + assert(parentPort, "basic() must run in a worker thread"); + + const port = parentPort; + + port.on( + "message", + /** @param {import("../libs/keep-worker-alive.mjs").BuildMessage} message */ + async function (message) { + const { baseDirectory, buildId, name } = message; + + /** @param {boolean} success */ + function reportSuccess(success) { + port.postMessage({ + baseDirectory, + buildId, + success, + }); + } + + try { + reportSuccess( + false !== + (await build({ + autogeneratedComment: autogeneratedComment(name), + buildId, + baseDirectory, + name, + printSubtreeList, + resetConsole, + sleep, + })), + ); + } catch (error) { + console.error( + `Build ${buildId} failed because of uncaught exception:`, + error, + ); + + reportSuccess(false); + } + }, + ); } diff --git a/src/job-types/command.mjs b/src/job-types/command.mjs index 02c7e81..b78e564 100644 --- a/src/job-types/command.mjs +++ b/src/job-types/command.mjs @@ -1,8 +1,9 @@ import { spawner } from "./spawner.mjs"; +/** @param {string} exec */ export function command(exec) { return spawner(async function ({ command: execCommand, resetConsole }) { - await resetConsole(); + resetConsole(); return execCommand(exec); }); diff --git a/src/job-types/persist.mjs b/src/job-types/persist.mjs index e1a9c56..14ee244 100644 --- a/src/job-types/persist.mjs +++ b/src/job-types/persist.mjs @@ -3,10 +3,23 @@ import { parseArgsStringToArgv } from "string-argv"; import { basic } from "./basic.mjs"; +/** + * @typedef {import("./basic.mjs").BasicContext & { + * keepAlive: (exec: string) => void; + * }} PersistContext + */ + +/** @type {Set} */ const running = new Set(); +/** + * @param {(context: PersistContext) => unknown} build + */ export function persist(build) { - function run({ args, baseDirectory, command, cwd }) { + /** + * @param {{ args: string[]; baseDirectory: string; command: string }} input + */ + function run({ args, baseDirectory, command }) { const proc = spawn(command, args, { cwd: baseDirectory, stdio: "inherit", @@ -27,12 +40,12 @@ export function persist(build) { args, baseDirectory, command, - cwd, }); }); } return basic(async function ({ buildId, baseDirectory, ...rest }) { + /** @param {string} exec */ function keepAlive(exec) { if (running.has(exec)) { return; @@ -46,7 +59,6 @@ export function persist(build) { args, baseDirectory, command, - cwd: baseDirectory, }); } diff --git a/src/job-types/spawner.mjs b/src/job-types/spawner.mjs index 3ee35d5..46d30e3 100644 --- a/src/job-types/spawner.mjs +++ b/src/job-types/spawner.mjs @@ -1,12 +1,34 @@ import spawn from "cross-spawn"; +import { exec as nodeExec } from "node:child_process"; import { parseArgsStringToArgv } from "string-argv"; import { basic } from "./basic.mjs"; +/** + * @typedef {import("./basic.mjs").BasicContext & { + * background: (exec: string) => Promise; + * command: (exec: string) => Promise; + * exec: (cmd: string) => Promise<{ stderr: string; stdout: string }>; + * register: (input: { + * background: boolean; + * proc: import("node:child_process").ChildProcess; + * }) => Promise; + * }} SpawnerContext + */ + +/** + * @param {(context: SpawnerContext) => unknown} build + */ export function spawner(build) { + let abortController = new AbortController(); + /** @type {Set} */ const running = new Set(); + /** @returns {Promise} */ async function abort() { + abortController.abort(); + abortController = new AbortController(); + for (const proc of running) { await new Promise(function (resolve) { console.debug(`jarmuz: Killing Process(${proc.pid})...`); @@ -19,6 +41,13 @@ export function spawner(build) { running.clear(); } + /** + * @param {{ + * background: boolean; + * proc: import("node:child_process").ChildProcess; + * }} input + * @returns {Promise} + */ function register({ background, proc }) { running.add(proc); @@ -49,6 +78,10 @@ export function spawner(build) { return basic(async function ({ buildId, baseDirectory, ...rest }) { await abort(); + /** + * @param {{ background: boolean; exec: string }} input + * @returns {Promise} + */ function registerProc({ background, exec }) { const [command, ...args] = parseArgsStringToArgv(exec); const proc = spawn(command, args, { @@ -62,19 +95,55 @@ export function spawner(build) { }); } - function background(exec) { - return registerProc({ background: true, exec }); + /** + * @param {string} exec + * @returns {Promise} + */ + async function background(exec) { + await registerProc({ background: true, exec }); } + /** + * @param {string} exec + * @returns {Promise} + */ function command(exec) { return registerProc({ background: false, exec }); } + /** + * @param {string} command + * @returns {Promise<{ stderr: string; stdout: string }>} + */ + function exec(command) { + return new Promise(function (resolve, reject) { + nodeExec( + command, + { + cwd: baseDirectory, + encoding: "utf-8", + signal: abortController.signal, + }, + function (error, stdout, stderr) { + if (error) { + reject(error); + } else { + resolve({ + stderr, + stdout, + }); + } + }, + ); + }); + } + return build({ background, baseDirectory, buildId, command, + exec, register, ...rest, }); diff --git a/src/libs/jarmuz.mjs b/src/libs/jarmuz.mjs index 628c60e..23a7121 100644 --- a/src/libs/jarmuz.mjs +++ b/src/libs/jarmuz.mjs @@ -6,6 +6,38 @@ import { managePipeline } from "./manage-pipeline.mjs"; import { manageWorkers } from "./manage-workers.mjs"; import { scheduler } from "./scheduler.mjs"; +/** + * @typedef {object} JarmuzOptions + * @property {string} [baseDirectory] + * @property {string[]} [ignore] + * @property {boolean} [once] + * @property {string[]} pipeline + * @property {string | string[]} watch + */ + +/** + * Internal shared state passed to scheduler / manage-pipeline / manage-workers. + * + * @typedef {object} State + * @property {Map} pending + * @property {Map} workers + */ + +/** + * @typedef {object} DeciderContext + * @property {string} baseDirectory + * @property {boolean} initial + * @property {(pattern: string) => boolean} matches + * @property {(name: string) => void} schedule + */ + +/** + * @typedef {(context: DeciderContext) => void} Decider + */ + +/** + * @param {JarmuzOptions} options + */ export function jarmuz({ baseDirectory = process.cwd(), ignore = [], @@ -13,6 +45,7 @@ export function jarmuz({ pipeline, watch, }) { + /** @type {State} */ const state = { pending: new Map(), workers: new Map(), @@ -34,12 +67,7 @@ export function jarmuz({ }, onSuccess({ baseDirectory, buildId }) { if ( - !pipelineManager.scheduleSuccessor( - baseDirectory, - buildId, - name, - once, - ) && + !pipelineManager.scheduleSuccessor(baseDirectory, buildId, name) && once ) { workers.stopAll(); @@ -49,15 +77,18 @@ export function jarmuz({ } return { + /** @param {Decider} decider */ decide(decider) { + /** @type {Set} */ const toBeScheduled = new Set(); const watcher = chokidar.watch(watch); + /** @param {string} name */ function schedule(name) { if (once) { toBeScheduled.add(name); } else { - pipelineManager.schedule(baseDirectory, name, nanoid(), once); + pipelineManager.schedule(baseDirectory, name, nanoid()); } } @@ -70,7 +101,7 @@ export function jarmuz({ schedule, }); - watcher.on("all", function (event, path) { + watcher.on("all", function (_event, path) { if ( ignore.some(function (pattern) { return minimatch(path, pattern); @@ -98,7 +129,7 @@ export function jarmuz({ const buildId = nanoid(); for (const name of toBeScheduled) { - pipelineManager.schedule(baseDirectory, name, buildId, once); + pipelineManager.schedule(baseDirectory, name, buildId); } }); }, diff --git a/src/libs/keep-worker-alive.mjs b/src/libs/keep-worker-alive.mjs index f8f1403..8013d08 100644 --- a/src/libs/keep-worker-alive.mjs +++ b/src/libs/keep-worker-alive.mjs @@ -1,52 +1,86 @@ import { basename } from "node:path"; import { Worker } from "node:worker_threads"; -function spawnWorker(state, path, name, onMessage) { - const worker = new Worker(path, { - name, - }); +import { TerminatedWorkerError } from "./terminated-worker-error.mjs"; - worker.once("exit", function (code) { - console.error( - state.isTerminated - ? `jarmuz: Worker(${name}) terminated with exit code ${code}.` - : `jarmuz: Worker(${name}) stopped with exit code ${code}. Restarting...`, - ); - worker.off("message", onMessage); - - if (!state.isTerminated) { - spawnWorker(state, path, name, onMessage); - } - }); - worker.on("message", onMessage); +/** + * Inbound build message: jarmuż -> worker. + * + * @typedef {object} BuildMessage + * @property {string} baseDirectory + * @property {string} buildId + * @property {string} name + */ - state.isTerminated = false; - state.worker = worker; -} +/** + * Outbound build result: worker -> jarmuż. + * + * @typedef {object} BuildResult + * @property {string} baseDirectory + * @property {string} buildId + * @property {boolean} success + */ +/** + * Handle to a kept-alive worker thread, returned by `keepWorkerAlive`. + * + * @typedef {object} WorkerHandle + * @property {(data: BuildMessage) => void} postMessage + * @property {() => Promise} terminate + */ + +/** + * @param {{ + * path: string; + * onMessage: (message: BuildResult) => void; + * }} options + * @returns {WorkerHandle} + */ export function keepWorkerAlive({ path, onMessage }) { const name = basename(path, ".mjs"); - const state = { - isTerminated: false, - worker: null, - }; + const state = { isTerminated: false }; + + /** @type {Worker} */ + let worker; + + function spawnWorker() { + worker = new Worker(path, { + name, + }); - spawnWorker(state, path, name, onMessage); + worker.once("exit", function (code) { + console.error( + state.isTerminated + ? `jarmuz: Worker(${name}) terminated with exit code ${code}.` + : `jarmuz: Worker(${name}) stopped with exit code ${code}. Restarting...`, + ); + worker.off("message", onMessage); + + if (!state.isTerminated) { + spawnWorker(); + } + }); + worker.on("message", onMessage); + + state.isTerminated = false; + } + + spawnWorker(); return Object.freeze({ + /** @param {BuildMessage} data */ postMessage(data) { if (state.isTerminated) { - throw new Error(`Worker(${name}) is terminated`); - } else if (!state.worker) { - throw new Error(`Worker(${name}) is not ready`); - } else { - state.worker.postMessage(data); + throw new TerminatedWorkerError(name); } + + worker.postMessage(data); }, terminate() { state.isTerminated = true; - state.worker.off("message", onMessage); - state.worker.terminate(); + worker.off("message", onMessage); + + return worker.terminate(); }, }); } diff --git a/src/libs/manage-pipeline.mjs b/src/libs/manage-pipeline.mjs index b9549db..597210c 100644 --- a/src/libs/manage-pipeline.mjs +++ b/src/libs/manage-pipeline.mjs @@ -1,3 +1,11 @@ +import { UnknownJobError } from "./unknown-job-error.mjs"; +import { WorkerNotRunningError } from "./worker-not-running-error.mjs"; + +/** + * @param {import("./jarmuz.mjs").State} state + * @param {string[]} predecessors + * @returns {boolean} + */ function hasPendingPredecessor(state, predecessors) { for (const predecessor of predecessors) { if (state.pending.has(predecessor)) { @@ -8,10 +16,20 @@ function hasPendingPredecessor(state, predecessors) { return false; } +/** + * @param {import("./jarmuz.mjs").State} state + * @param {import("./scheduler.mjs").Scheduler} scheduler + * @param {string[]} pipeline + */ export function managePipeline(state, scheduler, pipeline) { + /** + * @param {string} baseDirectory + * @param {string} name + * @param {string} buildId + */ function schedule(baseDirectory, name, buildId) { if (-1 === pipeline.indexOf(name)) { - throw new Error(`Unknown job: ${name}`); + throw new UnknownJobError(name); } const predecessors = pipeline.slice(0, pipeline.indexOf(name)); @@ -25,11 +43,13 @@ export function managePipeline(state, scheduler, pipeline) { if (hasPendingPredecessor(state, predecessors)) { state.pending.delete(name); } else { - if (!state.workers.has(name)) { - throw new Error(`Worker is not running: "${name}"`); + const worker = state.workers.get(name); + + if (worker === undefined) { + throw new WorkerNotRunningError(name); } - state.workers.get(name).postMessage({ + worker.postMessage({ baseDirectory, buildId, name, @@ -40,11 +60,17 @@ export function managePipeline(state, scheduler, pipeline) { return Object.freeze({ schedule, - scheduleSuccessor(baseDirectory, buildId, name, once) { + /** + * @param {string} baseDirectory + * @param {string} buildId + * @param {string} name + * @returns {boolean} + */ + scheduleSuccessor(baseDirectory, buildId, name) { const successor = pipeline[pipeline.indexOf(name) + 1]; if ("string" === typeof successor) { - schedule(baseDirectory, successor, buildId, once); + schedule(baseDirectory, successor, buildId); return true; } diff --git a/src/libs/manage-workers.mjs b/src/libs/manage-workers.mjs index d819c63..c8c3f18 100644 --- a/src/libs/manage-workers.mjs +++ b/src/libs/manage-workers.mjs @@ -1,6 +1,26 @@ import { keepWorkerAlive } from "./keep-worker-alive.mjs"; +/** + * The identifying portion of a build result, passed to onSuccess / onFailure. + * + * @typedef {object} BuildOutcome + * @property {string} baseDirectory + * @property {string} buildId + */ + +/** + * @typedef {object} StartOptions + * @property {string} name + * @property {(outcome: BuildOutcome) => void} onFailure + * @property {(outcome: BuildOutcome) => void} onSuccess + */ + +/** + * @param {string} baseDirectory + * @param {import("./jarmuz.mjs").State} state + */ export function manageWorkers(baseDirectory, state) { + /** @param {StartOptions} options */ function start({ name, onFailure, onSuccess }) { state.workers.set( name, diff --git a/src/libs/scheduler.mjs b/src/libs/scheduler.mjs index 0ce8bc7..0a48ceb 100644 --- a/src/libs/scheduler.mjs +++ b/src/libs/scheduler.mjs @@ -1,5 +1,18 @@ +/** + * @typedef {object} Scheduler + * @property {(name: string, callback: () => void) => void} unique + */ + +/** + * @param {import("./jarmuz.mjs").State} state + * @returns {Scheduler} + */ export function scheduler(state) { return Object.freeze({ + /** + * @param {string} name + * @param {() => void} callback + */ unique(name, callback) { if (state.pending.has(name)) { clearTimeout(state.pending.get(name)); diff --git a/src/libs/terminated-worker-error.mjs b/src/libs/terminated-worker-error.mjs new file mode 100644 index 0000000..def4e0c --- /dev/null +++ b/src/libs/terminated-worker-error.mjs @@ -0,0 +1,14 @@ +export class TerminatedWorkerError extends Error { + /** @type {string} */ + workerName; + + /** @type {string} */ + name = "TerminatedWorkerError"; + + /** @param {string} workerName */ + constructor(workerName) { + super(`Worker(${workerName}) is terminated`); + + this.workerName = workerName; + } +} diff --git a/src/libs/unknown-job-error.mjs b/src/libs/unknown-job-error.mjs new file mode 100644 index 0000000..ced7776 --- /dev/null +++ b/src/libs/unknown-job-error.mjs @@ -0,0 +1,14 @@ +export class UnknownJobError extends Error { + /** @type {string} */ + jobName; + + /** @type {string} */ + name = "UnknownJobError"; + + /** @param {string} jobName */ + constructor(jobName) { + super(`Unknown job: ${jobName}`); + + this.jobName = jobName; + } +} diff --git a/src/libs/worker-not-running-error.mjs b/src/libs/worker-not-running-error.mjs new file mode 100644 index 0000000..35ce004 --- /dev/null +++ b/src/libs/worker-not-running-error.mjs @@ -0,0 +1,14 @@ +export class WorkerNotRunningError extends Error { + /** @type {string} */ + workerName; + + /** @type {string} */ + name = "WorkerNotRunningError"; + + /** @param {string} workerName */ + constructor(workerName) { + super(`Worker is not running: "${workerName}"`); + + this.workerName = workerName; + } +} diff --git a/tests/autogenerated-comment-embeds-the-worker-name.test.mjs b/tests/autogenerated-comment-embeds-the-worker-name.test.mjs new file mode 100644 index 0000000..f7cb89b --- /dev/null +++ b/tests/autogenerated-comment-embeds-the-worker-name.test.mjs @@ -0,0 +1,8 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { autogeneratedComment } from "../src/helpers/autogenerated-comment.mjs"; + +test("autogeneratedComment embeds the worker name in the banner", function () { + assert.ok(autogeneratedComment("stylesheet").includes("stylesheet")); +}); diff --git a/tests/basic-passes-context-helpers-into-build-function.test.mjs b/tests/basic-passes-context-helpers-into-build-function.test.mjs new file mode 100644 index 0000000..382b3fd --- /dev/null +++ b/tests/basic-passes-context-helpers-into-build-function.test.mjs @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { test } from "node:test"; + +import { autogeneratedComment } from "../src/helpers/autogenerated-comment.mjs"; +import { makeTempDirectory } from "./support/temp-directory.mjs"; +import { runWorkerBuild } from "./support/run-worker-build.mjs"; + +test("basic passes context helpers into the build function", async function () { + const tempDirectory = await makeTempDirectory(); + const resultFile = join(tempDirectory.path, "result.txt"); + + const message = await runWorkerBuild( + "worker-basic-uses-context-helpers", + { + baseDirectory: tempDirectory.path, + buildId: "build-1", + name: "stylesheet", + }, + { env: { ...process.env, JARMUZ_RESULT_FILE: resultFile } }, + ); + + assert.equal(message.success, true); + assert.equal( + await readFile(resultFile, "utf8"), + autogeneratedComment("stylesheet"), + ); + + await tempDirectory.cleanup(); +}); diff --git a/tests/basic-reports-failure-when-build-returns-false.test.mjs b/tests/basic-reports-failure-when-build-returns-false.test.mjs new file mode 100644 index 0000000..7a69795 --- /dev/null +++ b/tests/basic-reports-failure-when-build-returns-false.test.mjs @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { runWorkerBuild } from "./support/run-worker-build.mjs"; + +test("basic reports failure when the build function returns false", async function () { + const message = await runWorkerBuild("worker-basic-returns-false", { + baseDirectory: "/tmp", + buildId: "build-1", + name: "stylesheet", + }); + + assert.deepEqual(message, { + baseDirectory: "/tmp", + buildId: "build-1", + success: false, + }); +}); diff --git a/tests/basic-reports-failure-when-build-throws.test.mjs b/tests/basic-reports-failure-when-build-throws.test.mjs new file mode 100644 index 0000000..5b41713 --- /dev/null +++ b/tests/basic-reports-failure-when-build-throws.test.mjs @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { runWorkerBuild } from "./support/run-worker-build.mjs"; + +test("basic reports failure when the build function throws", async function () { + const message = await runWorkerBuild("worker-basic-throws", { + baseDirectory: "/tmp", + buildId: "build-1", + name: "stylesheet", + }); + + assert.deepEqual(message, { + baseDirectory: "/tmp", + buildId: "build-1", + success: false, + }); +}); diff --git a/tests/basic-reports-success-when-build-returns-non-false.test.mjs b/tests/basic-reports-success-when-build-returns-non-false.test.mjs new file mode 100644 index 0000000..2f49353 --- /dev/null +++ b/tests/basic-reports-success-when-build-returns-non-false.test.mjs @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { runWorkerBuild } from "./support/run-worker-build.mjs"; + +test("basic reports success when the build function returns a non-false value", async function () { + const message = await runWorkerBuild("worker-basic-returns-undefined", { + baseDirectory: "/tmp", + buildId: "build-1", + name: "stylesheet", + }); + + assert.deepEqual(message, { + baseDirectory: "/tmp", + buildId: "build-1", + success: true, + }); +}); diff --git a/tests/command-reports-failure-when-command-exits-nonzero.test.mjs b/tests/command-reports-failure-when-command-exits-nonzero.test.mjs new file mode 100644 index 0000000..6b9e514 --- /dev/null +++ b/tests/command-reports-failure-when-command-exits-nonzero.test.mjs @@ -0,0 +1,14 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { runWorkerBuild } from "./support/run-worker-build.mjs"; + +test("command reports failure when the command exits with a non-zero code", async function () { + const message = await runWorkerBuild("worker-command-failure", { + baseDirectory: "/tmp", + buildId: "build-1", + name: "lint", + }); + + assert.equal(message.success, false); +}); diff --git a/tests/command-reports-success-when-command-exits-zero.test.mjs b/tests/command-reports-success-when-command-exits-zero.test.mjs new file mode 100644 index 0000000..ee1ab39 --- /dev/null +++ b/tests/command-reports-success-when-command-exits-zero.test.mjs @@ -0,0 +1,14 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { runWorkerBuild } from "./support/run-worker-build.mjs"; + +test("command reports success when the command exits zero", async function () { + const message = await runWorkerBuild("worker-command-success", { + baseDirectory: "/tmp", + buildId: "build-1", + name: "lint", + }); + + assert.equal(message.success, true); +}); diff --git a/tests/fixtures/entry-once-records-context.mjs b/tests/fixtures/entry-once-records-context.mjs new file mode 100644 index 0000000..376710d --- /dev/null +++ b/tests/fixtures/entry-once-records-context.mjs @@ -0,0 +1,22 @@ +import { writeFileSync } from "node:fs"; + +import { jarmuz } from "../../src/index.mjs"; + +const pipeline = JSON.parse(process.env.JARMUZ_PIPELINE); +const watch = JSON.parse(process.env.JARMUZ_WATCH); + +jarmuz({ + baseDirectory: process.env.JARMUZ_BASE_DIRECTORY, + once: true, + pipeline, + watch, +}).decide(function ({ initial, matches, schedule }) { + if (initial) { + writeFileSync( + process.env.JARMUZ_DECIDER_RESULT_FILE, + JSON.stringify({ initial, matchesResult: matches("anything") }), + ); + + schedule(pipeline[0]); + } +}); diff --git a/tests/fixtures/entry-once.mjs b/tests/fixtures/entry-once.mjs new file mode 100644 index 0000000..d61c263 --- /dev/null +++ b/tests/fixtures/entry-once.mjs @@ -0,0 +1,20 @@ +import { jarmuz } from "../../src/index.mjs"; + +const pipeline = JSON.parse(process.env.JARMUZ_PIPELINE); +const watch = JSON.parse(process.env.JARMUZ_WATCH); + +const baseDirectoryOption = + process.env.JARMUZ_BASE_DIRECTORY === undefined + ? {} + : { baseDirectory: process.env.JARMUZ_BASE_DIRECTORY }; + +jarmuz({ + ...baseDirectoryOption, + once: true, + pipeline, + watch, +}).decide(function ({ initial, schedule }) { + if (initial) { + schedule(pipeline[0]); + } +}); diff --git a/tests/fixtures/entry-watch.mjs b/tests/fixtures/entry-watch.mjs new file mode 100644 index 0000000..49ef437 --- /dev/null +++ b/tests/fixtures/entry-watch.mjs @@ -0,0 +1,31 @@ +import { takeCoverage } from "node:v8"; + +import { jarmuz } from "../../src/index.mjs"; + +process.on("SIGTERM", function () { + takeCoverage(); + process.exit(0); +}); + +const ignore = JSON.parse(process.env.JARMUZ_IGNORE); +const pipeline = JSON.parse(process.env.JARMUZ_PIPELINE); +const rules = JSON.parse(process.env.JARMUZ_RULES); +const watch = JSON.parse(process.env.JARMUZ_WATCH); + +jarmuz({ + baseDirectory: process.env.JARMUZ_BASE_DIRECTORY, + ignore, + once: false, + pipeline, + watch, +}).decide(function ({ initial, matches, schedule }) { + if (initial) { + return; + } + + for (const rule of rules) { + if (matches(rule.pattern)) { + schedule(rule.job); + } + } +}); diff --git a/tests/fixtures/jarmuz/worker-reports-failure.mjs b/tests/fixtures/jarmuz/worker-reports-failure.mjs new file mode 100644 index 0000000..9e6c518 --- /dev/null +++ b/tests/fixtures/jarmuz/worker-reports-failure.mjs @@ -0,0 +1,5 @@ +import { parentPort } from "node:worker_threads"; + +parentPort.on("message", function ({ baseDirectory, buildId }) { + parentPort.postMessage({ baseDirectory, buildId, success: false }); +}); diff --git a/tests/fixtures/jarmuz/worker-reports-success.mjs b/tests/fixtures/jarmuz/worker-reports-success.mjs new file mode 100644 index 0000000..f7a4eb8 --- /dev/null +++ b/tests/fixtures/jarmuz/worker-reports-success.mjs @@ -0,0 +1,5 @@ +import { parentPort } from "node:worker_threads"; + +parentPort.on("message", function ({ baseDirectory, buildId }) { + parentPort.postMessage({ baseDirectory, buildId, success: true }); +}); diff --git a/tests/fixtures/scripts/append-and-exit.mjs b/tests/fixtures/scripts/append-and-exit.mjs new file mode 100644 index 0000000..31ed326 --- /dev/null +++ b/tests/fixtures/scripts/append-and-exit.mjs @@ -0,0 +1,12 @@ +import { appendFile } from "node:fs/promises"; + +try { + await appendFile(process.argv[2], "run\n"); +} catch (error) { + // The result directory is removed during test teardown; a still-running + // restart chain may outlive it for a moment. A missing directory means the + // test is over and there is nothing left to record. + if (error.code !== "ENOENT") { + throw error; + } +} diff --git a/tests/fixtures/scripts/append-and-self-kill.mjs b/tests/fixtures/scripts/append-and-self-kill.mjs new file mode 100644 index 0000000..f0056b0 --- /dev/null +++ b/tests/fixtures/scripts/append-and-self-kill.mjs @@ -0,0 +1,16 @@ +import { appendFile } from "node:fs/promises"; + +try { + await appendFile(process.argv[2], "run\n"); +} catch (error) { + // The result directory is removed during test teardown; a still-running + // restart chain may outlive it for a moment. A missing directory means the + // test is over and there is nothing left to record. + if (error.code !== "ENOENT") { + throw error; + } + + process.exit(0); +} + +process.kill(process.pid, "SIGKILL"); diff --git a/tests/fixtures/scripts/create-lock-or-report.mjs b/tests/fixtures/scripts/create-lock-or-report.mjs new file mode 100644 index 0000000..ed342d3 --- /dev/null +++ b/tests/fixtures/scripts/create-lock-or-report.mjs @@ -0,0 +1,14 @@ +import { appendFile, writeFile } from "node:fs/promises"; +import { createServer } from "node:net"; + +const [, , lockFile, resultFile, pidFile] = process.argv; + +try { + await writeFile(lockFile, "", { flag: "wx" }); + await writeFile(pidFile, String(process.pid)); + await appendFile(resultFile, "ok"); + + createServer().listen(0); +} catch (error) { + await appendFile(resultFile, error.code === "EEXIST" ? "conflict" : "error"); +} diff --git a/tests/fixtures/scripts/touch-and-exit.mjs b/tests/fixtures/scripts/touch-and-exit.mjs new file mode 100644 index 0000000..549661f --- /dev/null +++ b/tests/fixtures/scripts/touch-and-exit.mjs @@ -0,0 +1,3 @@ +import { writeFile } from "node:fs/promises"; + +await writeFile(process.argv[2], "started"); diff --git a/tests/fixtures/scripts/write-pid-and-stay-alive.mjs b/tests/fixtures/scripts/write-pid-and-stay-alive.mjs new file mode 100644 index 0000000..775a943 --- /dev/null +++ b/tests/fixtures/scripts/write-pid-and-stay-alive.mjs @@ -0,0 +1,6 @@ +import { writeFile } from "node:fs/promises"; +import { createServer } from "node:net"; + +await writeFile(process.argv[2], String(process.pid)); + +createServer().listen(0); diff --git a/tests/fixtures/workers/worker-basic-returns-false.mjs b/tests/fixtures/workers/worker-basic-returns-false.mjs new file mode 100644 index 0000000..fb8c8da --- /dev/null +++ b/tests/fixtures/workers/worker-basic-returns-false.mjs @@ -0,0 +1,8 @@ +import { basic } from "../../../src/job-types/basic.mjs"; +import { exitWorkerOnDrain } from "../../support/exit-worker-on-drain.mjs"; + +basic(async function () { + return false; +}); + +exitWorkerOnDrain(); diff --git a/tests/fixtures/workers/worker-basic-returns-undefined.mjs b/tests/fixtures/workers/worker-basic-returns-undefined.mjs new file mode 100644 index 0000000..cb53ea9 --- /dev/null +++ b/tests/fixtures/workers/worker-basic-returns-undefined.mjs @@ -0,0 +1,6 @@ +import { basic } from "../../../src/job-types/index.mjs"; +import { exitWorkerOnDrain } from "../../support/exit-worker-on-drain.mjs"; + +basic(async function () {}); + +exitWorkerOnDrain(); diff --git a/tests/fixtures/workers/worker-basic-throws.mjs b/tests/fixtures/workers/worker-basic-throws.mjs new file mode 100644 index 0000000..255bc76 --- /dev/null +++ b/tests/fixtures/workers/worker-basic-throws.mjs @@ -0,0 +1,8 @@ +import { basic } from "../../../src/job-types/basic.mjs"; +import { exitWorkerOnDrain } from "../../support/exit-worker-on-drain.mjs"; + +basic(async function () { + throw new Error("build failed"); +}); + +exitWorkerOnDrain(); diff --git a/tests/fixtures/workers/worker-basic-uses-context-helpers.mjs b/tests/fixtures/workers/worker-basic-uses-context-helpers.mjs new file mode 100644 index 0000000..50cd395 --- /dev/null +++ b/tests/fixtures/workers/worker-basic-uses-context-helpers.mjs @@ -0,0 +1,26 @@ +import { appendFile } from "node:fs/promises"; + +import { basic } from "../../../src/job-types/basic.mjs"; +import { exitWorkerOnDrain } from "../../support/exit-worker-on-drain.mjs"; + +let alreadyBuilt = false; + +basic(async function ({ + autogeneratedComment, + printSubtreeList, + resetConsole, + sleep, +}) { + if (alreadyBuilt) { + return; + } + + alreadyBuilt = true; + + resetConsole(); + printSubtreeList({ title: "items", items: ["b", "a"] }); + await sleep({ delay: 0 }); + await appendFile(process.env.JARMUZ_RESULT_FILE, autogeneratedComment); +}); + +exitWorkerOnDrain(); diff --git a/tests/fixtures/workers/worker-command-failure.mjs b/tests/fixtures/workers/worker-command-failure.mjs new file mode 100644 index 0000000..711db95 --- /dev/null +++ b/tests/fixtures/workers/worker-command-failure.mjs @@ -0,0 +1,6 @@ +import { command } from "../../../src/job-types/command.mjs"; +import { exitWorkerOnDrain } from "../../support/exit-worker-on-drain.mjs"; + +command("false"); + +exitWorkerOnDrain(); diff --git a/tests/fixtures/workers/worker-command-success.mjs b/tests/fixtures/workers/worker-command-success.mjs new file mode 100644 index 0000000..fd559c6 --- /dev/null +++ b/tests/fixtures/workers/worker-command-success.mjs @@ -0,0 +1,6 @@ +import { command } from "../../../src/job-types/command.mjs"; +import { exitWorkerOnDrain } from "../../support/exit-worker-on-drain.mjs"; + +command("true"); + +exitWorkerOnDrain(); diff --git a/tests/fixtures/workers/worker-crashes-once.mjs b/tests/fixtures/workers/worker-crashes-once.mjs new file mode 100644 index 0000000..776faed --- /dev/null +++ b/tests/fixtures/workers/worker-crashes-once.mjs @@ -0,0 +1,14 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { parentPort } from "node:worker_threads"; + +const runCountFile = process.env.JARMUZ_RUN_COUNT_FILE; +const currentCount = Number(readFileSync(runCountFile, "utf8").trim()) + 1; + +writeFileSync(runCountFile, String(currentCount)); + +if (currentCount === 1) { + process.exit(1); +} + +parentPort.postMessage({ ready: true, runCount: currentCount }); +parentPort.on("message", function () {}); diff --git a/tests/fixtures/workers/worker-echo.mjs b/tests/fixtures/workers/worker-echo.mjs new file mode 100644 index 0000000..46bc751 --- /dev/null +++ b/tests/fixtures/workers/worker-echo.mjs @@ -0,0 +1,5 @@ +import { parentPort } from "node:worker_threads"; + +parentPort.on("message", function (message) { + parentPort.postMessage(message); +}); diff --git a/tests/fixtures/workers/worker-persist-keepalive-dedup.mjs b/tests/fixtures/workers/worker-persist-keepalive-dedup.mjs new file mode 100644 index 0000000..7289416 --- /dev/null +++ b/tests/fixtures/workers/worker-persist-keepalive-dedup.mjs @@ -0,0 +1,33 @@ +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { persist } from "../../../src/job-types/persist.mjs"; +import { exitWorkerOnDrain } from "../../support/exit-worker-on-drain.mjs"; + +const createLockScript = join( + dirname(fileURLToPath(import.meta.url)), + "..", + "scripts", + "create-lock-or-report.mjs", +); + +let alreadyBuilt = false; + +persist(async function ({ keepAlive }) { + if (alreadyBuilt) { + return; + } + + alreadyBuilt = true; + + const exec = + `${process.execPath} ${createLockScript} ` + + `${process.env.JARMUZ_LOCK_FILE} ` + + `${process.env.JARMUZ_RESULT_FILE} ` + + `${process.env.JARMUZ_PID_FILE}`; + + keepAlive(exec); + keepAlive(exec); +}); + +exitWorkerOnDrain(); diff --git a/tests/fixtures/workers/worker-persist-keepalive-restart.mjs b/tests/fixtures/workers/worker-persist-keepalive-restart.mjs new file mode 100644 index 0000000..620888e --- /dev/null +++ b/tests/fixtures/workers/worker-persist-keepalive-restart.mjs @@ -0,0 +1,28 @@ +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { persist } from "../../../src/job-types/persist.mjs"; +import { exitWorkerOnDrain } from "../../support/exit-worker-on-drain.mjs"; + +const appendAndExitScript = join( + dirname(fileURLToPath(import.meta.url)), + "..", + "scripts", + "append-and-exit.mjs", +); + +let alreadyBuilt = false; + +persist(async function ({ keepAlive }) { + if (alreadyBuilt) { + return; + } + + alreadyBuilt = true; + + keepAlive( + `${process.execPath} ${appendAndExitScript} ${process.env.JARMUZ_RESULT_FILE}`, + ); +}); + +exitWorkerOnDrain(); diff --git a/tests/fixtures/workers/worker-persist-keepalive-signal-kill.mjs b/tests/fixtures/workers/worker-persist-keepalive-signal-kill.mjs new file mode 100644 index 0000000..52e8408 --- /dev/null +++ b/tests/fixtures/workers/worker-persist-keepalive-signal-kill.mjs @@ -0,0 +1,28 @@ +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { persist } from "../../../src/job-types/persist.mjs"; +import { exitWorkerOnDrain } from "../../support/exit-worker-on-drain.mjs"; + +const appendAndSelfKillScript = join( + dirname(fileURLToPath(import.meta.url)), + "..", + "scripts", + "append-and-self-kill.mjs", +); + +let alreadyBuilt = false; + +persist(async function ({ keepAlive }) { + if (alreadyBuilt) { + return; + } + + alreadyBuilt = true; + + keepAlive( + `${process.execPath} ${appendAndSelfKillScript} ${process.env.JARMUZ_RESULT_FILE}`, + ); +}); + +exitWorkerOnDrain(); diff --git a/tests/fixtures/workers/worker-spawner-abort.mjs b/tests/fixtures/workers/worker-spawner-abort.mjs new file mode 100644 index 0000000..ac6834e --- /dev/null +++ b/tests/fixtures/workers/worker-spawner-abort.mjs @@ -0,0 +1,27 @@ +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { takeCoverage } from "node:v8"; + +import { spawner } from "../../../src/job-types/spawner.mjs"; + +const stayAliveScript = join( + dirname(fileURLToPath(import.meta.url)), + "..", + "scripts", + "write-pid-and-stay-alive.mjs", +); + +let buildCount = 0; + +spawner(async function ({ background }) { + buildCount += 1; + + if (buildCount === 1) { + background( + `${process.execPath} ${stayAliveScript} ${process.env.JARMUZ_PID_FILE}`, + ); + } else { + takeCoverage(); + process.exit(0); + } +}); diff --git a/tests/fixtures/workers/worker-spawner-background.mjs b/tests/fixtures/workers/worker-spawner-background.mjs new file mode 100644 index 0000000..28fe158 --- /dev/null +++ b/tests/fixtures/workers/worker-spawner-background.mjs @@ -0,0 +1,28 @@ +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { spawner } from "../../../src/job-types/spawner.mjs"; +import { exitWorkerOnDrain } from "../../support/exit-worker-on-drain.mjs"; + +const touchAndExitScript = join( + dirname(fileURLToPath(import.meta.url)), + "..", + "scripts", + "touch-and-exit.mjs", +); + +let alreadyBuilt = false; + +spawner(async function ({ background }) { + if (alreadyBuilt) { + return; + } + + alreadyBuilt = true; + + background( + `${process.execPath} ${touchAndExitScript} ${process.env.JARMUZ_MARKER_FILE}`, + ); +}); + +exitWorkerOnDrain(); diff --git a/tests/fixtures/workers/worker-spawner-exec-failure.mjs b/tests/fixtures/workers/worker-spawner-exec-failure.mjs new file mode 100644 index 0000000..061bdab --- /dev/null +++ b/tests/fixtures/workers/worker-spawner-exec-failure.mjs @@ -0,0 +1,26 @@ +import { appendFile } from "node:fs/promises"; + +import { spawner } from "../../../src/job-types/spawner.mjs"; +import { exitWorkerOnDrain } from "../../support/exit-worker-on-drain.mjs"; + +let alreadyBuilt = false; + +spawner(async function ({ exec }) { + if (alreadyBuilt) { + return; + } + + alreadyBuilt = true; + + try { + await exec("false"); + await appendFile(process.env.JARMUZ_RESULT_FILE, "resolved"); + } catch (error) { + await appendFile( + process.env.JARMUZ_RESULT_FILE, + error instanceof Error ? "rejected" : "rejected-non-error", + ); + } +}); + +exitWorkerOnDrain(); diff --git a/tests/fixtures/workers/worker-spawner-exec-success.mjs b/tests/fixtures/workers/worker-spawner-exec-success.mjs new file mode 100644 index 0000000..2512eb3 --- /dev/null +++ b/tests/fixtures/workers/worker-spawner-exec-success.mjs @@ -0,0 +1,20 @@ +import { appendFile } from "node:fs/promises"; + +import { spawner } from "../../../src/job-types/spawner.mjs"; +import { exitWorkerOnDrain } from "../../support/exit-worker-on-drain.mjs"; + +let alreadyBuilt = false; + +spawner(async function ({ exec }) { + if (alreadyBuilt) { + return; + } + + alreadyBuilt = true; + + const { stdout } = await exec("echo jarmuz-exec-output"); + + await appendFile(process.env.JARMUZ_RESULT_FILE, stdout); +}); + +exitWorkerOnDrain(); diff --git a/tests/format-subtree-list-leaves-input-array-unmutated.test.mjs b/tests/format-subtree-list-leaves-input-array-unmutated.test.mjs new file mode 100644 index 0000000..99f4612 --- /dev/null +++ b/tests/format-subtree-list-leaves-input-array-unmutated.test.mjs @@ -0,0 +1,12 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { formatSubtreeList } from "../src/helpers/format-subtree-list.mjs"; + +test("formatSubtreeList leaves the input items array unmutated", function () { + const items = ["c", "a", "b"]; + + formatSubtreeList({ title: "assets", items }); + + assert.deepEqual(items, ["c", "a", "b"]); +}); diff --git a/tests/format-subtree-list-renders-sorted-tree-with-prefixes.test.mjs b/tests/format-subtree-list-renders-sorted-tree-with-prefixes.test.mjs new file mode 100644 index 0000000..56be346 --- /dev/null +++ b/tests/format-subtree-list-renders-sorted-tree-with-prefixes.test.mjs @@ -0,0 +1,11 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { formatSubtreeList } from "../src/helpers/format-subtree-list.mjs"; + +test("formatSubtreeList renders a sorted tree with branch and leaf prefixes", function () { + assert.deepEqual( + formatSubtreeList({ title: "assets", items: ["c", "a", "b"] }), + ["└── assets:", " ├── a", " ├── b", " └── c"], + ); +}); diff --git a/tests/jarmuz-defaults-base-directory-to-the-working-directory.test.mjs b/tests/jarmuz-defaults-base-directory-to-the-working-directory.test.mjs new file mode 100644 index 0000000..82fa7d9 --- /dev/null +++ b/tests/jarmuz-defaults-base-directory-to-the-working-directory.test.mjs @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { makeConsumerProject } from "./support/consumer-project.mjs"; +import { runNodeScript } from "./support/run-node-script.mjs"; +import { touchFileWorkerSource } from "./support/consumer-worker-sources.mjs"; + +test("jarmuz defaults the base directory to the working directory", async function (t) { + const consumerProject = await makeConsumerProject({ + workers: [{ name: "touch-file", source: touchFileWorkerSource }], + }); + const resultFile = join(consumerProject.baseDirectory, "result.txt"); + + t.after(function () { + return consumerProject.cleanup(); + }); + + const { closed } = runNodeScript( + fileURLToPath(new URL("./fixtures/entry-once.mjs", import.meta.url)), + { + cwd: consumerProject.baseDirectory, + env: { + ...process.env, + JARMUZ_PIPELINE: JSON.stringify(["touch-file"]), + JARMUZ_RESULT_FILE: resultFile, + JARMUZ_WATCH: JSON.stringify([consumerProject.watchedDirectory]), + }, + }, + ); + + const { code } = await closed; + + assert.equal(code, 0); + assert.equal(await readFile(resultFile, "utf8"), "touch-file"); +}); diff --git a/tests/jarmuz-once-mode-exits-1-when-a-job-fails.test.mjs b/tests/jarmuz-once-mode-exits-1-when-a-job-fails.test.mjs new file mode 100644 index 0000000..753ccb1 --- /dev/null +++ b/tests/jarmuz-once-mode-exits-1-when-a-job-fails.test.mjs @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import { join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { makeConsumerProject } from "./support/consumer-project.mjs"; +import { runNodeScript } from "./support/run-node-script.mjs"; +import { failingWorkerSource } from "./support/consumer-worker-sources.mjs"; + +test("jarmuz once mode exits with code 1 when a job fails", async function (t) { + const consumerProject = await makeConsumerProject({ + workers: [{ name: "bad", source: failingWorkerSource }], + }); + const resultFile = join(consumerProject.baseDirectory, "result.txt"); + + t.after(function () { + return consumerProject.cleanup(); + }); + + const { closed } = runNodeScript( + fileURLToPath(new URL("./fixtures/entry-once.mjs", import.meta.url)), + { + cwd: consumerProject.baseDirectory, + env: { + ...process.env, + JARMUZ_BASE_DIRECTORY: consumerProject.baseDirectory, + JARMUZ_PIPELINE: JSON.stringify(["bad"]), + JARMUZ_RESULT_FILE: resultFile, + JARMUZ_WATCH: JSON.stringify([consumerProject.watchedDirectory]), + }, + }, + ); + + const { code } = await closed; + + assert.equal(code, 1); +}); diff --git a/tests/jarmuz-once-mode-runs-the-pipeline-after-the-watcher-is-ready.test.mjs b/tests/jarmuz-once-mode-runs-the-pipeline-after-the-watcher-is-ready.test.mjs new file mode 100644 index 0000000..5afae9d --- /dev/null +++ b/tests/jarmuz-once-mode-runs-the-pipeline-after-the-watcher-is-ready.test.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { makeConsumerProject } from "./support/consumer-project.mjs"; +import { runNodeScript } from "./support/run-node-script.mjs"; +import { touchFileWorkerSource } from "./support/consumer-worker-sources.mjs"; + +test("jarmuz once mode runs the whole pipeline after the watcher is ready", async function (t) { + const consumerProject = await makeConsumerProject({ + workers: [ + { name: "first", source: touchFileWorkerSource }, + { name: "second", source: touchFileWorkerSource }, + ], + }); + const resultFile = join(consumerProject.baseDirectory, "result.txt"); + + t.after(function () { + return consumerProject.cleanup(); + }); + + const { closed } = runNodeScript( + fileURLToPath(new URL("./fixtures/entry-once.mjs", import.meta.url)), + { + cwd: consumerProject.baseDirectory, + env: { + ...process.env, + JARMUZ_BASE_DIRECTORY: consumerProject.baseDirectory, + JARMUZ_PIPELINE: JSON.stringify(["first", "second"]), + JARMUZ_RESULT_FILE: resultFile, + JARMUZ_WATCH: JSON.stringify([consumerProject.watchedDirectory]), + }, + }, + ); + + const { code } = await closed; + + assert.equal(code, 0); + assert.equal(await readFile(resultFile, "utf8"), "firstsecond"); +}); diff --git a/tests/jarmuz-runs-the-decider-immediately-with-the-initial-flag.test.mjs b/tests/jarmuz-runs-the-decider-immediately-with-the-initial-flag.test.mjs new file mode 100644 index 0000000..9070404 --- /dev/null +++ b/tests/jarmuz-runs-the-decider-immediately-with-the-initial-flag.test.mjs @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { makeConsumerProject } from "./support/consumer-project.mjs"; +import { runNodeScript } from "./support/run-node-script.mjs"; +import { touchFileWorkerSource } from "./support/consumer-worker-sources.mjs"; + +test("jarmuz runs the decider immediately with the initial flag", async function (t) { + const consumerProject = await makeConsumerProject({ + workers: [{ name: "touch-file", source: touchFileWorkerSource }], + }); + const resultFile = join(consumerProject.baseDirectory, "result.txt"); + const deciderResultFile = join(consumerProject.baseDirectory, "decider.json"); + + t.after(function () { + return consumerProject.cleanup(); + }); + + const { closed } = runNodeScript( + fileURLToPath( + new URL("./fixtures/entry-once-records-context.mjs", import.meta.url), + ), + { + cwd: consumerProject.baseDirectory, + env: { + ...process.env, + JARMUZ_BASE_DIRECTORY: consumerProject.baseDirectory, + JARMUZ_DECIDER_RESULT_FILE: deciderResultFile, + JARMUZ_PIPELINE: JSON.stringify(["touch-file"]), + JARMUZ_RESULT_FILE: resultFile, + JARMUZ_WATCH: JSON.stringify([consumerProject.watchedDirectory]), + }, + }, + ); + + const { code } = await closed; + + assert.equal(code, 0); + assert.deepEqual(JSON.parse(await readFile(deciderResultFile, "utf8")), { + initial: true, + matchesResult: false, + }); + assert.equal(await readFile(resultFile, "utf8"), "touch-file"); +}); diff --git a/tests/jarmuz-uses-the-provided-base-directory.test.mjs b/tests/jarmuz-uses-the-provided-base-directory.test.mjs new file mode 100644 index 0000000..41d071f --- /dev/null +++ b/tests/jarmuz-uses-the-provided-base-directory.test.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { makeConsumerProject } from "./support/consumer-project.mjs"; +import { makeTempDirectory } from "./support/temp-directory.mjs"; +import { runNodeScript } from "./support/run-node-script.mjs"; +import { touchFileWorkerSource } from "./support/consumer-worker-sources.mjs"; + +test("jarmuz uses the provided base directory instead of the working directory", async function (t) { + const consumerProject = await makeConsumerProject({ + workers: [{ name: "touch-file", source: touchFileWorkerSource }], + }); + const workingDirectory = await makeTempDirectory(); + const resultFile = join(consumerProject.baseDirectory, "result.txt"); + + t.after(async function () { + await consumerProject.cleanup(); + await workingDirectory.cleanup(); + }); + + const { closed } = runNodeScript( + fileURLToPath(new URL("./fixtures/entry-once.mjs", import.meta.url)), + { + cwd: workingDirectory.path, + env: { + ...process.env, + JARMUZ_BASE_DIRECTORY: consumerProject.baseDirectory, + JARMUZ_PIPELINE: JSON.stringify(["touch-file"]), + JARMUZ_RESULT_FILE: resultFile, + JARMUZ_WATCH: JSON.stringify([consumerProject.watchedDirectory]), + }, + }, + ); + + const { code } = await closed; + + assert.equal(code, 0); + assert.equal(await readFile(resultFile, "utf8"), "touch-file"); +}); diff --git a/tests/jarmuz-watch-mode-schedules-jobs-on-matching-changes.test.mjs b/tests/jarmuz-watch-mode-schedules-jobs-on-matching-changes.test.mjs new file mode 100644 index 0000000..cbaa1e8 --- /dev/null +++ b/tests/jarmuz-watch-mode-schedules-jobs-on-matching-changes.test.mjs @@ -0,0 +1,47 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { makeConsumerProject } from "./support/consumer-project.mjs"; +import { runNodeScript } from "./support/run-node-script.mjs"; +import { touchFileWorkerSource } from "./support/consumer-worker-sources.mjs"; +import { waitForFileContent } from "./support/wait-for-file-content.mjs"; + +test("jarmuz watch mode schedules jobs on changes that match a pattern", async function (t) { + const consumerProject = await makeConsumerProject({ + workers: [{ name: "touch-file", source: touchFileWorkerSource }], + }); + const resultFile = join(consumerProject.baseDirectory, "result.txt"); + + await writeFile(resultFile, ""); + await writeFile(join(consumerProject.watchedDirectory, "style.css"), ""); + + const { child, closed } = runNodeScript( + fileURLToPath(new URL("./fixtures/entry-watch.mjs", import.meta.url)), + { + cwd: consumerProject.baseDirectory, + env: { + ...process.env, + JARMUZ_BASE_DIRECTORY: consumerProject.baseDirectory, + JARMUZ_IGNORE: JSON.stringify([]), + JARMUZ_PIPELINE: JSON.stringify(["touch-file"]), + JARMUZ_RESULT_FILE: resultFile, + JARMUZ_RULES: JSON.stringify([ + { pattern: "**/*.css", job: "touch-file" }, + ]), + JARMUZ_WATCH: JSON.stringify(["watched"]), + }, + }, + ); + + t.after(async function () { + child.kill(); + await closed; + await consumerProject.cleanup(); + }); + + await waitForFileContent(resultFile, function (content) { + return content === "touch-file"; + }); +}); diff --git a/tests/jarmuz-watch-mode-skips-changes-matching-ignore-patterns.test.mjs b/tests/jarmuz-watch-mode-skips-changes-matching-ignore-patterns.test.mjs new file mode 100644 index 0000000..7f17b5c --- /dev/null +++ b/tests/jarmuz-watch-mode-skips-changes-matching-ignore-patterns.test.mjs @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { makeConsumerProject } from "./support/consumer-project.mjs"; +import { runNodeScript } from "./support/run-node-script.mjs"; +import { failingWorkerSource } from "./support/consumer-worker-sources.mjs"; +import { waitForFileContent } from "./support/wait-for-file-content.mjs"; + +test("jarmuz watch mode skips changes that match the ignore patterns", async function (t) { + const consumerProject = await makeConsumerProject({ + workers: [{ name: "trigger-job", source: failingWorkerSource }], + }); + const resultFile = join(consumerProject.baseDirectory, "result.txt"); + + await writeFile(resultFile, ""); + await writeFile(join(consumerProject.watchedDirectory, "a.ignored"), ""); + await writeFile(join(consumerProject.watchedDirectory, "b.trigger"), ""); + + const { child, closed } = runNodeScript( + fileURLToPath(new URL("./fixtures/entry-watch.mjs", import.meta.url)), + { + cwd: consumerProject.baseDirectory, + env: { + ...process.env, + JARMUZ_BASE_DIRECTORY: consumerProject.baseDirectory, + JARMUZ_IGNORE: JSON.stringify(["**/*.ignored"]), + JARMUZ_PIPELINE: JSON.stringify(["trigger-job"]), + JARMUZ_RESULT_FILE: resultFile, + JARMUZ_RULES: JSON.stringify([ + { pattern: "**/*.ignored", job: "ignored-job" }, + { pattern: "**/*.trigger", job: "trigger-job" }, + ]), + JARMUZ_WATCH: JSON.stringify(["watched"]), + }, + }, + ); + + t.after(async function () { + child.kill(); + await closed; + await consumerProject.cleanup(); + }); + + const content = await waitForFileContent(resultFile, function (text) { + return text.length > 0; + }); + + assert.equal(content, "trigger-job"); +}); diff --git a/tests/keep-worker-alive-delivers-a-posted-message-to-the-worker.test.mjs b/tests/keep-worker-alive-delivers-a-posted-message-to-the-worker.test.mjs new file mode 100644 index 0000000..cf63b5a --- /dev/null +++ b/tests/keep-worker-alive-delivers-a-posted-message-to-the-worker.test.mjs @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { keepWorkerAlive } from "../src/libs/keep-worker-alive.mjs"; + +test("keepWorkerAlive delivers a posted message to the worker", async function (t) { + const received = new Promise(function (resolve) { + const handle = keepWorkerAlive({ + path: fileURLToPath( + new URL("./fixtures/workers/worker-echo.mjs", import.meta.url), + ), + onMessage: resolve, + }); + + t.after(function () { + return handle.terminate(); + }); + + handle.postMessage({ ping: "hello" }); + }); + + assert.deepEqual(await received, { ping: "hello" }); +}); diff --git a/tests/keep-worker-alive-restarts-the-worker-after-an-unexpected-exit.test.mjs b/tests/keep-worker-alive-restarts-the-worker-after-an-unexpected-exit.test.mjs new file mode 100644 index 0000000..5ea2f4b --- /dev/null +++ b/tests/keep-worker-alive-restarts-the-worker-after-an-unexpected-exit.test.mjs @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { keepWorkerAlive } from "../src/libs/keep-worker-alive.mjs"; +import { makeTempDirectory } from "./support/temp-directory.mjs"; + +test("keepWorkerAlive restarts the worker after an unexpected exit", async function (t) { + const tempDirectory = await makeTempDirectory(); + const runCountFile = join(tempDirectory.path, "run-count.txt"); + + await writeFile(runCountFile, "0"); + + process.env.JARMUZ_RUN_COUNT_FILE = runCountFile; + + let handle; + + t.after(async function () { + if (handle !== undefined) { + await handle.terminate(); + } + + delete process.env.JARMUZ_RUN_COUNT_FILE; + await tempDirectory.cleanup(); + }); + + const message = await new Promise(function (resolve) { + handle = keepWorkerAlive({ + path: fileURLToPath( + new URL("./fixtures/workers/worker-crashes-once.mjs", import.meta.url), + ), + onMessage: resolve, + }); + }); + + assert.equal(message.ready, true); + assert.equal(message.runCount, 2); + assert.equal((await readFile(runCountFile, "utf8")).trim(), "2"); +}); diff --git a/tests/keep-worker-alive-stops-and-rejects-posting-after-terminate.test.mjs b/tests/keep-worker-alive-stops-and-rejects-posting-after-terminate.test.mjs new file mode 100644 index 0000000..ad06bc4 --- /dev/null +++ b/tests/keep-worker-alive-stops-and-rejects-posting-after-terminate.test.mjs @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { keepWorkerAlive } from "../src/libs/keep-worker-alive.mjs"; +import { TerminatedWorkerError } from "../src/libs/terminated-worker-error.mjs"; + +test("keepWorkerAlive stops the worker and rejects posting after terminate", async function () { + const handle = keepWorkerAlive({ + path: fileURLToPath( + new URL("./fixtures/workers/worker-echo.mjs", import.meta.url), + ), + onMessage: function () {}, + }); + + await handle.terminate(); + + assert.throws( + function () { + handle.postMessage({ ping: "hello" }); + }, + function (error) { + return ( + error instanceof TerminatedWorkerError && + error.workerName === "worker-echo" + ); + }, + ); +}); diff --git a/tests/manage-pipeline-callback-drops-job-when-a-predecessor-becomes-pending.test.mjs b/tests/manage-pipeline-callback-drops-job-when-a-predecessor-becomes-pending.test.mjs new file mode 100644 index 0000000..074ed19 --- /dev/null +++ b/tests/manage-pipeline-callback-drops-job-when-a-predecessor-becomes-pending.test.mjs @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { managePipeline } from "../src/libs/manage-pipeline.mjs"; +import { scheduler } from "../src/libs/scheduler.mjs"; + +test("manage-pipeline callback drops the job when a predecessor becomes pending during the debounce", async function () { + const state = { pending: new Map(), workers: new Map() }; + const schedule = scheduler(state); + const pipeline = managePipeline(state, schedule, ["compile", "bundle"]); + + pipeline.schedule("/project", "bundle", "build-1"); + + assert.equal(state.pending.has("bundle"), true); + + schedule.unique("compile", function () {}); + + await new Promise(function (resolve) { + schedule.unique("debounce-sentinel", resolve); + }); + + assert.equal(state.pending.has("bundle"), false); +}); diff --git a/tests/manage-pipeline-callback-posts-the-build-message-to-the-worker.test.mjs b/tests/manage-pipeline-callback-posts-the-build-message-to-the-worker.test.mjs new file mode 100644 index 0000000..f380079 --- /dev/null +++ b/tests/manage-pipeline-callback-posts-the-build-message-to-the-worker.test.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { keepWorkerAlive } from "../src/libs/keep-worker-alive.mjs"; +import { managePipeline } from "../src/libs/manage-pipeline.mjs"; +import { scheduler } from "../src/libs/scheduler.mjs"; + +test("manage-pipeline callback posts the build message to the registered worker", async function (t) { + const state = { pending: new Map(), workers: new Map() }; + const pipeline = managePipeline(state, scheduler(state), ["compile"]); + + const received = new Promise(function (resolve) { + const echoWorker = keepWorkerAlive({ + path: fileURLToPath( + new URL("./fixtures/workers/worker-echo.mjs", import.meta.url), + ), + onMessage: resolve, + }); + + t.after(function () { + echoWorker.terminate(); + }); + + state.workers.set("compile", echoWorker); + }); + + pipeline.schedule("/project", "compile", "build-1"); + + assert.deepEqual(await received, { + baseDirectory: "/project", + buildId: "build-1", + name: "compile", + }); +}); diff --git a/tests/manage-pipeline-callback-throws-when-the-worker-is-not-registered.test.mjs b/tests/manage-pipeline-callback-throws-when-the-worker-is-not-registered.test.mjs new file mode 100644 index 0000000..bc783e6 --- /dev/null +++ b/tests/manage-pipeline-callback-throws-when-the-worker-is-not-registered.test.mjs @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { managePipeline } from "../src/libs/manage-pipeline.mjs"; +import { scheduler } from "../src/libs/scheduler.mjs"; +import { WorkerNotRunningError } from "../src/libs/worker-not-running-error.mjs"; + +test("manage-pipeline callback throws when the target worker is not registered", async function () { + const state = { pending: new Map(), workers: new Map() }; + const pipeline = managePipeline(state, scheduler(state), ["compile"]); + + const savedListeners = process.listeners("uncaughtException"); + + process.removeAllListeners("uncaughtException"); + + const error = await new Promise(function (resolve) { + process.once("uncaughtException", resolve); + + pipeline.schedule("/project", "compile", "build-1"); + }); + + for (const listener of savedListeners) { + process.on("uncaughtException", listener); + } + + assert.ok(error instanceof WorkerNotRunningError); + assert.equal(error.workerName, "compile"); +}); diff --git a/tests/manage-pipeline-schedule-skips-when-a-predecessor-is-pending.test.mjs b/tests/manage-pipeline-schedule-skips-when-a-predecessor-is-pending.test.mjs new file mode 100644 index 0000000..f86b820 --- /dev/null +++ b/tests/manage-pipeline-schedule-skips-when-a-predecessor-is-pending.test.mjs @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { managePipeline } from "../src/libs/manage-pipeline.mjs"; +import { scheduler } from "../src/libs/scheduler.mjs"; + +test("manage-pipeline schedule skips when an earlier pipeline job is pending", function () { + const state = { pending: new Map(), workers: new Map() }; + const schedule = scheduler(state); + const pipeline = managePipeline(state, schedule, ["compile", "bundle"]); + + schedule.unique("compile", function () {}); + + pipeline.schedule("/project", "bundle", "build-1"); + + assert.equal(state.pending.has("bundle"), false); +}); diff --git a/tests/manage-pipeline-schedule-successor-returns-false-at-pipeline-end.test.mjs b/tests/manage-pipeline-schedule-successor-returns-false-at-pipeline-end.test.mjs new file mode 100644 index 0000000..ff16be1 --- /dev/null +++ b/tests/manage-pipeline-schedule-successor-returns-false-at-pipeline-end.test.mjs @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { managePipeline } from "../src/libs/manage-pipeline.mjs"; +import { scheduler } from "../src/libs/scheduler.mjs"; + +test("manage-pipeline scheduleSuccessor returns false at the end of the pipeline", function () { + const state = { pending: new Map(), workers: new Map() }; + const pipeline = managePipeline(state, scheduler(state), [ + "compile", + "bundle", + ]); + + assert.equal( + pipeline.scheduleSuccessor("/project", "build-1", "bundle"), + false, + ); +}); diff --git a/tests/manage-pipeline-schedule-successor-runs-the-next-job.test.mjs b/tests/manage-pipeline-schedule-successor-runs-the-next-job.test.mjs new file mode 100644 index 0000000..30e64bc --- /dev/null +++ b/tests/manage-pipeline-schedule-successor-runs-the-next-job.test.mjs @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { keepWorkerAlive } from "../src/libs/keep-worker-alive.mjs"; +import { managePipeline } from "../src/libs/manage-pipeline.mjs"; +import { scheduler } from "../src/libs/scheduler.mjs"; + +test("manage-pipeline scheduleSuccessor runs the next job in the pipeline", async function (t) { + const state = { pending: new Map(), workers: new Map() }; + const pipeline = managePipeline(state, scheduler(state), [ + "compile", + "bundle", + ]); + + const received = new Promise(function (resolve) { + const echoWorker = keepWorkerAlive({ + path: fileURLToPath( + new URL("./fixtures/workers/worker-echo.mjs", import.meta.url), + ), + onMessage: resolve, + }); + + t.after(function () { + echoWorker.terminate(); + }); + + state.workers.set("bundle", echoWorker); + }); + + const scheduled = pipeline.scheduleSuccessor( + "/project", + "build-1", + "compile", + ); + + assert.equal(scheduled, true); + assert.deepEqual(await received, { + baseDirectory: "/project", + buildId: "build-1", + name: "bundle", + }); +}); diff --git a/tests/manage-pipeline-schedule-throws-for-an-unknown-job.test.mjs b/tests/manage-pipeline-schedule-throws-for-an-unknown-job.test.mjs new file mode 100644 index 0000000..f678a5f --- /dev/null +++ b/tests/manage-pipeline-schedule-throws-for-an-unknown-job.test.mjs @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { managePipeline } from "../src/libs/manage-pipeline.mjs"; +import { scheduler } from "../src/libs/scheduler.mjs"; +import { UnknownJobError } from "../src/libs/unknown-job-error.mjs"; + +test("manage-pipeline schedule throws for a job that is not in the pipeline", function () { + const state = { pending: new Map(), workers: new Map() }; + const pipeline = managePipeline(state, scheduler(state), ["compile"]); + + assert.throws( + function () { + pipeline.schedule("/project", "deploy", "build-1"); + }, + function (error) { + return error instanceof UnknownJobError && error.jobName === "deploy"; + }, + ); +}); diff --git a/tests/manage-workers-invokes-on-failure-for-a-failed-build.test.mjs b/tests/manage-workers-invokes-on-failure-for-a-failed-build.test.mjs new file mode 100644 index 0000000..661fc54 --- /dev/null +++ b/tests/manage-workers-invokes-on-failure-for-a-failed-build.test.mjs @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { manageWorkers } from "../src/libs/manage-workers.mjs"; + +test("manageWorkers invokes onFailure and clears pending for a failed build", async function (t) { + const baseDirectory = fileURLToPath(new URL("./fixtures", import.meta.url)); + const state = { pending: new Map(), workers: new Map() }; + const workers = manageWorkers(baseDirectory, state); + + t.after(function () { + workers.stopAll(); + }); + + state.pending.set("reports-failure", "pending"); + + const failed = new Promise(function (resolve) { + workers.start({ + name: "reports-failure", + onFailure: resolve, + onSuccess: function () {}, + }); + }); + + state.workers.get("reports-failure").postMessage({ + baseDirectory, + buildId: "build-1", + }); + + assert.deepEqual(await failed, { baseDirectory, buildId: "build-1" }); + assert.equal(state.pending.has("reports-failure"), false); +}); diff --git a/tests/manage-workers-invokes-on-success-for-a-successful-build.test.mjs b/tests/manage-workers-invokes-on-success-for-a-successful-build.test.mjs new file mode 100644 index 0000000..197df5e --- /dev/null +++ b/tests/manage-workers-invokes-on-success-for-a-successful-build.test.mjs @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { manageWorkers } from "../src/libs/manage-workers.mjs"; + +test("manageWorkers invokes onSuccess and clears pending for a successful build", async function (t) { + const baseDirectory = fileURLToPath(new URL("./fixtures", import.meta.url)); + const state = { pending: new Map(), workers: new Map() }; + const workers = manageWorkers(baseDirectory, state); + + t.after(function () { + workers.stopAll(); + }); + + state.pending.set("reports-success", "pending"); + + const succeeded = new Promise(function (resolve) { + workers.start({ + name: "reports-success", + onFailure: function () {}, + onSuccess: resolve, + }); + }); + + state.workers.get("reports-success").postMessage({ + baseDirectory, + buildId: "build-1", + }); + + assert.deepEqual(await succeeded, { baseDirectory, buildId: "build-1" }); + assert.equal(state.pending.has("reports-success"), false); +}); diff --git a/tests/manage-workers-stop-all-terminates-every-worker.test.mjs b/tests/manage-workers-stop-all-terminates-every-worker.test.mjs new file mode 100644 index 0000000..b5cf25d --- /dev/null +++ b/tests/manage-workers-stop-all-terminates-every-worker.test.mjs @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { manageWorkers } from "../src/libs/manage-workers.mjs"; + +test("manageWorkers stopAll terminates every worker and empties the registry", function () { + const baseDirectory = fileURLToPath(new URL("./fixtures", import.meta.url)); + const state = { pending: new Map(), workers: new Map() }; + const workers = manageWorkers(baseDirectory, state); + + workers.start({ + name: "reports-success", + onFailure: function () {}, + onSuccess: function () {}, + }); + workers.start({ + name: "reports-failure", + onFailure: function () {}, + onSuccess: function () {}, + }); + + assert.equal(state.workers.size, 2); + + workers.stopAll(); + + assert.equal(state.workers.size, 0); +}); diff --git a/tests/persist-keepalive-ignores-a-duplicate-exec-string.test.mjs b/tests/persist-keepalive-ignores-a-duplicate-exec-string.test.mjs new file mode 100644 index 0000000..77118f1 --- /dev/null +++ b/tests/persist-keepalive-ignores-a-duplicate-exec-string.test.mjs @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { test } from "node:test"; + +import { createWorker } from "./support/create-worker.mjs"; +import { drainWorker } from "./support/drain-worker.mjs"; +import { killProcess } from "./support/kill-process.mjs"; +import { makeTempDirectory } from "./support/temp-directory.mjs"; +import { waitForFileContent } from "./support/wait-for-file-content.mjs"; +import { waitForMessage } from "./support/wait-for-message.mjs"; + +test("persist keepAlive ignores a duplicate exec string", async function (t) { + const tempDirectory = await makeTempDirectory(); + const lockFile = join(tempDirectory.path, "lock.txt"); + const resultFile = join(tempDirectory.path, "result.txt"); + const pidFile = join(tempDirectory.path, "pid.txt"); + + await writeFile(resultFile, ""); + await writeFile(pidFile, ""); + + const worker = createWorker("worker-persist-keepalive-dedup", { + env: { + ...process.env, + JARMUZ_LOCK_FILE: lockFile, + JARMUZ_RESULT_FILE: resultFile, + JARMUZ_PID_FILE: pidFile, + }, + }); + + let childPid; + + t.after(async function () { + if (childPid !== undefined) { + killProcess(childPid); + } + + await worker.terminate(); + await tempDirectory.cleanup(); + }); + + worker.postMessage({ + baseDirectory: tempDirectory.path, + buildId: "build-1", + name: "server", + }); + + await waitForMessage(worker); + + const result = await waitForFileContent(resultFile, function (content) { + return content.length > 0; + }); + + assert.equal(result, "ok"); + + childPid = Number( + await waitForFileContent(pidFile, function (content) { + return content.length > 0; + }), + ); + + await drainWorker(worker); +}); diff --git a/tests/persist-keepalive-restarts-a-signal-killed-process.test.mjs b/tests/persist-keepalive-restarts-a-signal-killed-process.test.mjs new file mode 100644 index 0000000..c5cf3f9 --- /dev/null +++ b/tests/persist-keepalive-restarts-a-signal-killed-process.test.mjs @@ -0,0 +1,7 @@ +import { test } from "node:test"; + +import { runPersistRestartScenario } from "./support/run-persist-restart-scenario.mjs"; + +test("persist keepAlive restarts a process that is killed by a signal", async function (t) { + await runPersistRestartScenario(t, "worker-persist-keepalive-signal-kill"); +}); diff --git a/tests/persist-keepalive-starts-and-restarts-a-process.test.mjs b/tests/persist-keepalive-starts-and-restarts-a-process.test.mjs new file mode 100644 index 0000000..df36186 --- /dev/null +++ b/tests/persist-keepalive-starts-and-restarts-a-process.test.mjs @@ -0,0 +1,7 @@ +import { test } from "node:test"; + +import { runPersistRestartScenario } from "./support/run-persist-restart-scenario.mjs"; + +test("persist keepAlive starts a process and restarts it after it exits", async function (t) { + await runPersistRestartScenario(t, "worker-persist-keepalive-restart"); +}); diff --git a/tests/scheduler-reschedule-cancels-the-earlier-callback.test.mjs b/tests/scheduler-reschedule-cancels-the-earlier-callback.test.mjs new file mode 100644 index 0000000..348f89a --- /dev/null +++ b/tests/scheduler-reschedule-cancels-the-earlier-callback.test.mjs @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { scheduler } from "../src/libs/scheduler.mjs"; + +test("scheduler reschedule cancels the earlier callback so only the latest runs", async function () { + const state = { pending: new Map() }; + const schedule = scheduler(state); + + let firstCallbackRan = false; + + schedule.unique("compile", function () { + firstCallbackRan = true; + }); + + await new Promise(function (resolve) { + schedule.unique("compile", resolve); + }); + + assert.equal(firstCallbackRan, false); +}); diff --git a/tests/scheduler-runs-callback-after-debounce-window.test.mjs b/tests/scheduler-runs-callback-after-debounce-window.test.mjs new file mode 100644 index 0000000..07fb748 --- /dev/null +++ b/tests/scheduler-runs-callback-after-debounce-window.test.mjs @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { scheduler } from "../src/libs/scheduler.mjs"; + +test("scheduler runs a uniquely scheduled callback after the debounce window", async function () { + const state = { pending: new Map() }; + const schedule = scheduler(state); + + await new Promise(function (resolve) { + schedule.unique("compile", resolve); + }); + + assert.equal(state.pending.has("compile"), true); +}); diff --git a/tests/spawner-rejects-exec-when-command-fails.test.mjs b/tests/spawner-rejects-exec-when-command-fails.test.mjs new file mode 100644 index 0000000..db62b31 --- /dev/null +++ b/tests/spawner-rejects-exec-when-command-fails.test.mjs @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { test } from "node:test"; + +import { makeTempDirectory } from "./support/temp-directory.mjs"; +import { runWorkerBuild } from "./support/run-worker-build.mjs"; + +test("spawner rejects exec when the command fails", async function (t) { + const tempDirectory = await makeTempDirectory(); + const resultFile = join(tempDirectory.path, "result.txt"); + + t.after(async function () { + await tempDirectory.cleanup(); + }); + + await runWorkerBuild( + "worker-spawner-exec-failure", + { baseDirectory: tempDirectory.path, buildId: "build-1", name: "probe" }, + { env: { ...process.env, JARMUZ_RESULT_FILE: resultFile } }, + ); + + assert.equal(await readFile(resultFile, "utf8"), "rejected"); +}); diff --git a/tests/spawner-resolves-exec-with-command-stdout.test.mjs b/tests/spawner-resolves-exec-with-command-stdout.test.mjs new file mode 100644 index 0000000..f58d19c --- /dev/null +++ b/tests/spawner-resolves-exec-with-command-stdout.test.mjs @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { test } from "node:test"; + +import { makeTempDirectory } from "./support/temp-directory.mjs"; +import { runWorkerBuild } from "./support/run-worker-build.mjs"; + +test("spawner resolves exec with the command stdout", async function (t) { + const tempDirectory = await makeTempDirectory(); + const resultFile = join(tempDirectory.path, "result.txt"); + + t.after(async function () { + await tempDirectory.cleanup(); + }); + + const message = await runWorkerBuild( + "worker-spawner-exec-success", + { baseDirectory: tempDirectory.path, buildId: "build-1", name: "probe" }, + { env: { ...process.env, JARMUZ_RESULT_FILE: resultFile } }, + ); + + assert.equal(message.success, true); + assert.equal(await readFile(resultFile, "utf8"), "jarmuz-exec-output\n"); +}); diff --git a/tests/spawner-runs-background-process-without-waiting.test.mjs b/tests/spawner-runs-background-process-without-waiting.test.mjs new file mode 100644 index 0000000..91e3434 --- /dev/null +++ b/tests/spawner-runs-background-process-without-waiting.test.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { test } from "node:test"; + +import { createWorker } from "./support/create-worker.mjs"; +import { drainWorker } from "./support/drain-worker.mjs"; +import { makeTempDirectory } from "./support/temp-directory.mjs"; +import { waitForFileContent } from "./support/wait-for-file-content.mjs"; +import { waitForMessage } from "./support/wait-for-message.mjs"; + +test("spawner runs a background process without waiting for it to finish", async function (t) { + const tempDirectory = await makeTempDirectory(); + const markerFile = join(tempDirectory.path, "marker.txt"); + + await writeFile(markerFile, ""); + + const worker = createWorker("worker-spawner-background", { + env: { ...process.env, JARMUZ_MARKER_FILE: markerFile }, + }); + + t.after(async function () { + await worker.terminate(); + await tempDirectory.cleanup(); + }); + + worker.postMessage({ + baseDirectory: tempDirectory.path, + buildId: "build-1", + name: "server", + }); + + const result = await waitForMessage(worker); + + assert.equal(result.success, true); + + await waitForFileContent(markerFile, function (content) { + return content === "started"; + }); + + await drainWorker(worker); +}); diff --git a/tests/spawner-sigkills-running-process-on-next-build.test.mjs b/tests/spawner-sigkills-running-process-on-next-build.test.mjs new file mode 100644 index 0000000..0223e72 --- /dev/null +++ b/tests/spawner-sigkills-running-process-on-next-build.test.mjs @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import { once } from "node:events"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { test } from "node:test"; + +import { createWorker } from "./support/create-worker.mjs"; +import { killProcess } from "./support/kill-process.mjs"; +import { makeTempDirectory } from "./support/temp-directory.mjs"; +import { waitForFileContent } from "./support/wait-for-file-content.mjs"; +import { waitForMessage } from "./support/wait-for-message.mjs"; + +test("spawner SIGKILLs a still-running process on the next build", async function (t) { + const tempDirectory = await makeTempDirectory(); + const pidFile = join(tempDirectory.path, "pid.txt"); + + await writeFile(pidFile, ""); + + const worker = createWorker("worker-spawner-abort", { + env: { ...process.env, JARMUZ_PID_FILE: pidFile }, + }); + + let childPid; + + t.after(async function () { + if (childPid !== undefined) { + killProcess(childPid); + } + + await worker.terminate(); + await tempDirectory.cleanup(); + }); + + worker.postMessage({ + baseDirectory: tempDirectory.path, + buildId: "build-1", + name: "server", + }); + + await waitForMessage(worker); + + childPid = Number( + await waitForFileContent(pidFile, function (content) { + return content.length > 0; + }), + ); + + assert.doesNotThrow(function () { + process.kill(childPid, 0); + }); + + worker.postMessage({ + baseDirectory: tempDirectory.path, + buildId: "build-2", + name: "server", + }); + + await once(worker, "exit"); + + assert.throws(function () { + process.kill(childPid, 0); + }); +}); diff --git a/tests/support/consumer-project.mjs b/tests/support/consumer-project.mjs new file mode 100644 index 0000000..53364b8 --- /dev/null +++ b/tests/support/consumer-project.mjs @@ -0,0 +1,35 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repositoryRoot = join( + dirname(fileURLToPath(import.meta.url)), + "..", + "..", +); + +export async function makeConsumerProject({ workers }) { + const baseDirectory = await mkdtemp(join(tmpdir(), "jarmuz-project-")); + const watchedDirectory = join(baseDirectory, "watched"); + + await mkdir(join(baseDirectory, "jarmuz")); + await mkdir(join(baseDirectory, "node_modules")); + await mkdir(watchedDirectory); + await symlink(repositoryRoot, join(baseDirectory, "node_modules", "jarmuz")); + + for (const { name, source } of workers) { + await writeFile( + join(baseDirectory, "jarmuz", `worker-${name}.mjs`), + source, + ); + } + + return { + baseDirectory, + watchedDirectory, + async cleanup() { + await rm(baseDirectory, { force: true, recursive: true }); + }, + }; +} diff --git a/tests/support/consumer-worker-sources.mjs b/tests/support/consumer-worker-sources.mjs new file mode 100644 index 0000000..c84c0ca --- /dev/null +++ b/tests/support/consumer-worker-sources.mjs @@ -0,0 +1,19 @@ +export const touchFileWorkerSource = `import { appendFile } from "node:fs/promises"; + +import { basic } from "jarmuz/job-types"; + +basic(async function ({ name }) { + await appendFile(process.env.JARMUZ_RESULT_FILE, name); +}); +`; + +export const failingWorkerSource = `import { appendFile } from "node:fs/promises"; + +import { basic } from "jarmuz/job-types"; + +basic(async function ({ name }) { + await appendFile(process.env.JARMUZ_RESULT_FILE, name); + + return false; +}); +`; diff --git a/tests/support/create-worker.mjs b/tests/support/create-worker.mjs new file mode 100644 index 0000000..ea8bfd6 --- /dev/null +++ b/tests/support/create-worker.mjs @@ -0,0 +1,12 @@ +import { Worker } from "node:worker_threads"; + +export function createWorker(fixtureName, options = {}) { + const worker = new Worker( + new URL(`../fixtures/workers/${fixtureName}.mjs`, import.meta.url), + { stdout: true, ...options }, + ); + + worker.stdout.resume(); + + return worker; +} diff --git a/tests/support/drain-worker.mjs b/tests/support/drain-worker.mjs new file mode 100644 index 0000000..e4c227c --- /dev/null +++ b/tests/support/drain-worker.mjs @@ -0,0 +1,7 @@ +import { once } from "node:events"; + +export async function drainWorker(worker) { + worker.postMessage({ drain: true }); + + await once(worker, "exit"); +} diff --git a/tests/support/exit-worker-on-drain.mjs b/tests/support/exit-worker-on-drain.mjs new file mode 100644 index 0000000..0d71c21 --- /dev/null +++ b/tests/support/exit-worker-on-drain.mjs @@ -0,0 +1,11 @@ +import { parentPort } from "node:worker_threads"; +import { takeCoverage } from "node:v8"; + +export function exitWorkerOnDrain() { + parentPort.on("message", function ({ drain }) { + if (drain) { + takeCoverage(); + process.exit(0); + } + }); +} diff --git a/tests/support/kill-process.mjs b/tests/support/kill-process.mjs new file mode 100644 index 0000000..51d6296 --- /dev/null +++ b/tests/support/kill-process.mjs @@ -0,0 +1,9 @@ +export function killProcess(pid) { + try { + process.kill(pid, "SIGKILL"); + } catch (error) { + if (error.code !== "ESRCH") { + throw error; + } + } +} diff --git a/tests/support/run-node-script.mjs b/tests/support/run-node-script.mjs new file mode 100644 index 0000000..9ed68ce --- /dev/null +++ b/tests/support/run-node-script.mjs @@ -0,0 +1,17 @@ +import { spawn } from "node:child_process"; + +export function runNodeScript(scriptPath, options = {}) { + const child = spawn(process.execPath, [scriptPath], { + stdio: ["ignore", "ignore", "inherit"], + ...options, + }); + + const closed = new Promise(function (resolve, reject) { + child.once("error", reject); + child.once("close", function (code, signal) { + resolve({ code, signal }); + }); + }); + + return { child, closed }; +} diff --git a/tests/support/run-persist-restart-scenario.mjs b/tests/support/run-persist-restart-scenario.mjs new file mode 100644 index 0000000..8a2ef60 --- /dev/null +++ b/tests/support/run-persist-restart-scenario.mjs @@ -0,0 +1,38 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { createWorker } from "./create-worker.mjs"; +import { drainWorker } from "./drain-worker.mjs"; +import { makeTempDirectory } from "./temp-directory.mjs"; +import { waitForFileContent } from "./wait-for-file-content.mjs"; +import { waitForMessage } from "./wait-for-message.mjs"; + +export async function runPersistRestartScenario(t, fixtureName) { + const tempDirectory = await makeTempDirectory(); + const resultFile = join(tempDirectory.path, "result.txt"); + + await writeFile(resultFile, ""); + + const worker = createWorker(fixtureName, { + env: { ...process.env, JARMUZ_RESULT_FILE: resultFile }, + }); + + t.after(async function () { + await worker.terminate(); + await tempDirectory.cleanup(); + }); + + worker.postMessage({ + baseDirectory: tempDirectory.path, + buildId: "build-1", + name: "server", + }); + + await waitForMessage(worker); + + await waitForFileContent(resultFile, function (content) { + return content.split("\n").filter(Boolean).length >= 2; + }); + + await drainWorker(worker); +} diff --git a/tests/support/run-worker-build.mjs b/tests/support/run-worker-build.mjs new file mode 100644 index 0000000..695e37f --- /dev/null +++ b/tests/support/run-worker-build.mjs @@ -0,0 +1,15 @@ +import { createWorker } from "./create-worker.mjs"; +import { drainWorker } from "./drain-worker.mjs"; +import { waitForMessage } from "./wait-for-message.mjs"; + +export async function runWorkerBuild(fixtureName, message, options = {}) { + const worker = createWorker(fixtureName, options); + + worker.postMessage(message); + + const result = await waitForMessage(worker); + + await drainWorker(worker); + + return result; +} diff --git a/tests/support/temp-directory.mjs b/tests/support/temp-directory.mjs new file mode 100644 index 0000000..a5c616c --- /dev/null +++ b/tests/support/temp-directory.mjs @@ -0,0 +1,14 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +export async function makeTempDirectory() { + const path = await mkdtemp(join(tmpdir(), "jarmuz-test-")); + + return { + path, + async cleanup() { + await rm(path, { force: true, recursive: true }); + }, + }; +} diff --git a/tests/support/wait-for-file-content.mjs b/tests/support/wait-for-file-content.mjs new file mode 100644 index 0000000..23282a8 --- /dev/null +++ b/tests/support/wait-for-file-content.mjs @@ -0,0 +1,27 @@ +import { watch } from "node:fs"; +import { readFile } from "node:fs/promises"; + +export function waitForFileContent(filePath, predicate) { + return new Promise(function (resolve, reject) { + const watcher = watch(filePath, function () { + check(); + }); + + function check() { + readFile(filePath, "utf8").then( + function (content) { + if (predicate(content)) { + watcher.close(); + resolve(content); + } + }, + function (error) { + watcher.close(); + reject(error); + }, + ); + } + + check(); + }); +} diff --git a/tests/support/wait-for-message.mjs b/tests/support/wait-for-message.mjs new file mode 100644 index 0000000..5b90587 --- /dev/null +++ b/tests/support/wait-for-message.mjs @@ -0,0 +1,5 @@ +export function waitForMessage(emitter) { + return new Promise(function (resolve) { + emitter.once("message", resolve); + }); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..794ddaa --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "lib": ["es2024"], + "module": "nodenext", + "noEmit": true, + "skipLibCheck": true, + "strict": true, + "target": "es2024" + }, + "include": ["src/**/*.mjs"] +}