diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 4a80455c698..a72d362845d 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -24,6 +24,14 @@ jobs: with: fetch-depth: 0 + - name: Cache Tool Downloads + uses: actions/cache@v5 + with: + path: ~/.cache + key: ${{ runner.os }}-toolcache-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-toolcache- + - name: Setup pnpm uses: pnpm/action-setup@v4 @@ -34,26 +42,32 @@ jobs: cache: 'pnpm' cache-dependency-path: '**/pnpm-lock.yaml' - - name: Install dependencies + - name: Remove cached node_modules + run: rm -rf node_modules .nx + + - name: Set Playwright cache status + run: | + if [ -d "$HOME/.cache/ms-playwright" ] || [ -d "$HOME/.cache/Cypress" ]; then + echo "PLAYWRIGHT_CACHE_HIT=true" >> "$GITHUB_ENV" + else + echo "PLAYWRIGHT_CACHE_HIT=false" >> "$GITHUB_ENV" + fi + + - name: Set Nx SHA + uses: nrwl/nx-set-shas@v4 + + - name: Install Dependencies run: pnpm install --frozen-lockfile - - name: Restore Turborepo cache - uses: actions/cache/restore@v4 - with: - path: | - .turbo - **/.turbo - key: ${{ runner.os }}-turbo-${{ github.workflow }}-${{ github.job }}-${{ github.ref_name }}-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-turbo-${{ github.workflow }}-${{ github.job }}-${{ github.ref_name }}- - ${{ runner.os }}-turbo-${{ github.workflow }}-${{ github.job }}- - ${{ runner.os }}-turbo- + - name: Install Playwright Browsers + run: pnpm exec playwright install --force - name: Install Cypress + # if: steps.browsers-cache.outputs.cache-hit != 'true' run: npx cypress install - - name: Check code format - run: node tools/scripts/check-format-changed.mjs + - name: Check Code Format + run: npx nx format:check - name: Verify Rslib Template Publint Wiring run: node packages/create-module-federation/scripts/verify-rslib-templates.mjs @@ -64,11 +78,22 @@ jobs: - name: Verify Publint Workflow Coverage run: node tools/scripts/verify-publint-workflow-coverage.mjs - - name: Verify Turbo Conventions - run: pnpm run verify:turbo + - name: Run Rslib Harness Tests + run: pnpm run test:rslib-harness + + - name: Verify Rslib Harness Coverage + run: pnpm run verify:rslib-harness + + - name: Verify Rslib Harness Workflow Coverage + run: pnpm run verify:rslib-harness:workflow - - name: Build packages - run: pnpm run build:packages + - name: Print Number of CPU Cores + run: nproc + + - name: Run Build for All + run: | + npx nx run-many --targets=build --projects=tag:type:pkg --parallel=4 --skip-nx-cache + npx nx run-many --targets=build --projects=tag:type:pkg --parallel=4 - name: Check Package Publishing Compatibility run: | @@ -84,8 +109,12 @@ jobs: fi done - - name: Run affected package tests - run: node tools/scripts/run-affected-package-tests.mjs + - name: Warm Nx Cache + run: npx nx run-many --targets=build --projects=tag:type:pkg --parallel=4 + + - name: Run Affected Test + timeout-minutes: 10 + run: npx nx affected -t test --parallel=3 --exclude='*,!tag:type:pkg' e2e-modern: needs: checkout-install diff --git a/README.md b/README.md index 4d43d56612d..30e3500efb7 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,62 @@ To get started with Module Federation, see the [Quick Start](https://module-fede Come and chat with us on [Discussions](https://github.com/module-federation/universe/discussions) or [Discord](https://discord.gg/n69NnT3ACV)! The Module federation team and users are active there, and we're always looking for contributions. +## 🧪 Rslib Monorepo Harness + +This repository includes a workspace-level Rslib harness that can orchestrate +multiple `rslib.config.*` projects from the repo root (including nested project +definitions via harness config files). + +Quick examples: + +- List resolved projects: + + ```bash + pnpm run rslib:harness list + ``` + +- List resolved projects as JSON: + + ```bash + pnpm run rslib:harness list --json + ``` + + `--json` also implies list output when used with other commands. + +- Emit a machine-readable dry-run command plan: + + ```bash + pnpm run rslib:harness:build --project create-module-federation --json --dry-run + ``` + + For non-`list` commands, `--json` is only supported with `--dry-run`. + +- Build a single project by name/path filter: + + ```bash + pnpm run rslib:harness:build --project create-module-federation + ``` + +- Show commands without executing: + + ```bash + pnpm run rslib:harness:build --project create-module-federation --dry-run + ``` + +- Inspect one project's generated config outputs: + + ```bash + pnpm run rslib:harness:inspect --project create-module-federation + ``` + +- Verify harness coverage against repo Rslib configs: + + ```bash + pnpm run verify:rslib-harness + ``` + +The default root harness config is `rslib.harness.config.mjs`. + ## 🤝 Contribution > New contributors welcome! diff --git a/package.json b/package.json index 62bbb218b8d..3862dd8b22a 100644 --- a/package.json +++ b/package.json @@ -19,72 +19,55 @@ ], "license": "MIT", "scripts": { - "turbo": "turbo", + "nx": "nx", + "nx:safe": "./scripts/nx-worktree.sh", + "setup:codex": "./scripts/codex-setup.sh", "commit": "cz", "docs": "typedoc", - "f": "pnpm exec prettier --write .", + "f": "nx format:write", "ci:local": "node tools/scripts/ci-local.mjs", - "verify:turbo": "node tools/scripts/verify-turbo-conventions.mjs", - "e2e:modern": "pnpm exec turbo run test --filter=@module-federation/modern-js --filter=@module-federation/modern-js-v3 --concurrency=20", - "e2e:runtime": "pnpm exec turbo run test:e2e --filter=runtime-host", - "e2e:manifest:dev": "pnpm exec turbo run test:e2e --filter=3008-webpack-host", - "e2e:manifest:prod": "pnpm exec turbo run test:e2e:production --filter=3008-webpack-host", - "e2e:node": "node tools/scripts/run-node-e2e.mjs", - "e2e:next:dev": "pnpm exec turbo run test:e2e --filter=@module-federation/3000-home", - "e2e:next:prod": "pnpm exec turbo run test:e2e:production --filter=@module-federation/3000-home", - "e2e:treeshake:server": "pnpm exec turbo run test --filter=@module-federation/treeshake-server", - "e2e:treeshake:frontend": "pnpm exec turbo run e2e --filter=@module-federation/treeshake-frontend", - "e2e:modern:ssr": "node tools/scripts/run-modern-e2e.mjs --mode=manifest", - "e2e:router": "node tools/scripts/run-router-e2e.mjs --mode=dev", - "e2e:shared-tree-shaking:runtime-infer": "npx kill-port --port 3001,3002 && pnpm exec turbo run test:e2e --filter=shared-tree-shaking-no-server-host && lsof -ti tcp:3001,3002 | xargs kill", - "e2e:shared-tree-shaking:server-calc": "npx kill-port --port 3001,3002,3003 && pnpm exec turbo run test:e2e --filter=shared-tree-shaking-with-server-host && lsof -ti tcp:3001,3002,3003 | xargs kill", - "e2e:devtools:dev": "npx kill-port 3009 3010 3011 3012 3013 4001 && pnpm run app:manifest:dev & echo \"done\" && npx wait-on tcp:3009 tcp:3010 tcp:3011 tcp:3012 tcp:3013 && sleep 10 && pnpm exec turbo run test:e2e --filter=@module-federation/devtools", - "e2e:devtools:prod": "npx kill-port 3009 3010 3011 3012 3013 4001 && npx kill-port 3009 3010 3011 3012 3013 4001 && pnpm run app:manifest:prod & echo \"done\" && npx wait-on tcp:3009 tcp:3010 tcp:3011 tcp:3012 tcp:3013 && sleep 30 && pnpm exec turbo run test:e2e --filter=@module-federation/devtools", - "enhanced:rstest": "pnpm --filter @module-federation/enhanced run build && NODE_OPTIONS=--experimental-vm-modules npx rstest -c packages/enhanced/rstest.config.ts", - "lint": "pnpm run lint:packages", - "test": "pnpm exec turbo run test", - "test:affected": "node tools/scripts/run-affected-package-tests.mjs", - "build": "pnpm run build:packages", - "build:all": "pnpm exec turbo run build --concurrency=20", - "build:apps": "pnpm exec turbo run build --filter=./apps/** --concurrency=20", - "build:packages": "pnpm exec turbo run build --filter=./packages/** --concurrency=20", - "test:packages": "pnpm exec turbo run test --filter=./packages/** --concurrency=20", - "lint:packages": "pnpm exec turbo run lint --filter=./packages/**", - "lint-fix": "node tools/scripts/lint-fix.mjs", + "enhanced:rstest": "nx run enhanced:build && NODE_OPTIONS=--experimental-vm-modules npx rstest -c packages/enhanced/rstest.config.ts", + "lint": "nx run-many --target=lint", + "test": "nx run-many --target=test", + "build": "NX_TUI=false nx run-many --target=build --parallel=5 --projects=tag:type:pkg", + "rslib:harness": "node ./scripts/rslib-harness.mjs", + "rslib:harness:list": "node ./scripts/rslib-harness.mjs list", + "rslib:harness:build": "node ./scripts/rslib-harness.mjs build", + "rslib:harness:inspect": "node ./scripts/rslib-harness.mjs inspect", + "test:rslib-harness": "node --test ./scripts/__tests__/rslib-harness.test.mjs", + "verify:rslib-harness": "node ./tools/scripts/verify-rslib-harness-coverage.mjs", + "verify:rslib-harness:workflow": "node ./tools/scripts/verify-rslib-harness-workflow-coverage.mjs", + "build:pkg": "NX_TUI=false nx run-many --targets=build --projects=tag:type:pkg --skip-nx-cache", + "test:pkg": "NX_TUI=false nx run-many --targets=test --projects=tag:type:pkg --skip-nx-cache", + "lint-fix": "nx format:write --uncommitted", "trigger-release": "node -e 'import(\"open\").then(open => open.default(\"https://github.com/module-federation/core/actions/workflows/trigger-release.yml\"))'", - "serve:next": "pnpm exec turbo run serve --filter=@module-federation/3000-home --filter=@module-federation/3001-shop --filter=@module-federation/3002-checkout --filter=app-router-4000 --filter=app-router-4001 --concurrency=20", - "app:router:build": "pnpm exec turbo run build --filter=host --filter=host-v5 --filter=host-vue3 --filter=remote1 --filter=remote2 --filter=remote3 --filter=remote4 --filter=remote5 --filter=remote6 --concurrency=20", - "app:router:dev": "pnpm exec turbo run serve --filter=host --filter=host-v5 --filter=host-vue3 --filter=remote1 --filter=remote2 --filter=remote3 --filter=remote4 --filter=remote5 --filter=remote6 --concurrency=20", - "app:next-router:build": "pnpm exec turbo run build --filter=app-router-4000 --filter=app-router-4001 --concurrency=20", - "app:next-router:dev": "pnpm exec turbo run serve --filter=app-router-4000 --filter=app-router-4001", - "serve:website": "pnpm --filter website-new run serve", - "build:website": "pnpm exec turbo run build --filter=website-new --concurrency=20", - "extract-i18n:website": "pnpm --filter website-new run extract-i18n", + "serve:next": "nx run-many --target=serve --all --parallel=3 -exclude='*,!tag:nextjs'", + "app:router:dev": "nx run-many --target=serve --parallel=10 --projects='router-*'", + "app:next-router:dev": "nx run-many --target=serve --projects=next-app-router-4000,next-app-router-4001 --parallel", + "serve:website": "nx run website-new:serve", + "build:website": "NX_TUI=false nx run website-new:build", + "extract-i18n:website": "nx run website:extract-i18n", "sync:webpack:types": "node ./scripts/sync-webpack-unbundled-types.mjs", "sync:pullMFTypes": "concurrently \"node ./packages/enhanced/pullts.js\"", - "app:next:dev": "pnpm exec turbo run serve:development --filter=@module-federation/3000-home --filter=@module-federation/3001-shop --filter=@module-federation/3002-checkout", - "app:next:build": "pnpm exec turbo run build:production --filter=@module-federation/3000-home --filter=@module-federation/3001-shop --filter=@module-federation/3002-checkout --concurrency=20", - "app:next:prod": "pnpm run app:next:build && pnpm exec turbo run serve:production --filter=@module-federation/3000-home --filter=@module-federation/3001-shop --filter=@module-federation/3002-checkout", - "app:node:build": "pnpm exec turbo run build --filter=node-host --filter=node-local-remote --filter=node-remote --filter=node-dynamic-remote-new-version --filter=node-dynamic-remote --concurrency=20", - "app:node:dev": "pnpm exec turbo run serve:development --filter=node-host --filter=node-local-remote --filter=node-remote --filter=node-dynamic-remote-new-version --filter=node-dynamic-remote --concurrency=20", - "app:runtime:build": "pnpm exec turbo run build --filter=runtime-host --filter=runtime-remote1 --filter=runtime-remote2 --concurrency=20", - "app:runtime:dev": "pnpm exec turbo run serve:development --filter=runtime-host --filter=runtime-remote1 --filter=runtime-remote2", - "app:manifest:build": "pnpm exec turbo run build --filter=3008-webpack-host --filter=3009-webpack-provider --filter=3010-rspack-provider --filter=3011-rspack-manifest-provider --filter=3012-rspack-js-entry-provider --concurrency=20", - "app:manifest:dev": "pnpm exec turbo run serve:development --filter=3008-webpack-host --filter=3009-webpack-provider --filter=3010-rspack-provider --filter=3011-rspack-manifest-provider --filter=3012-rspack-js-entry-provider --concurrency=20", - "app:manifest:prod": "pnpm exec turbo run serve:production --filter=3008-webpack-host --filter=3009-webpack-provider --filter=3010-rspack-provider --filter=3011-rspack-manifest-provider --filter=3012-rspack-js-entry-provider --concurrency=20", - "app:ts:dev": "pnpm exec turbo run serve --filter=react-ts-host --filter=react-ts-nested-remote --filter=react-ts-remote", - "app:component-data-fetch:build": "pnpm exec turbo run build --filter=modernjs-ssr-data-fetch-provider --filter=modernjs-ssr-data-fetch-provider-csr --filter=modernjs-ssr-data-fetch-host --concurrency=20", - "app:component-data-fetch:dev": "pnpm exec turbo run dev --filter=modernjs-ssr-data-fetch-provider --filter=modernjs-ssr-data-fetch-provider-csr --filter=modernjs-ssr-data-fetch-host --concurrency=20", - "app:modern:build": "pnpm exec turbo run build --filter=modernjs-ssr-dynamic-nested-remote --filter=modernjs-ssr-dynamic-remote --filter=modernjs-ssr-dynamic-remote-new-version --filter=modernjs-ssr-host --filter=modernjs-ssr-nested-remote --filter=modernjs-ssr-remote --filter=modernjs-ssr-remote-new-version --concurrency=20", - "app:modern:dev": "pnpm exec turbo run dev --filter=modernjs-ssr-dynamic-nested-remote --filter=modernjs-ssr-dynamic-remote --filter=modernjs-ssr-dynamic-remote-new-version --filter=modernjs-ssr-host --filter=modernjs-ssr-nested-remote --filter=modernjs-ssr-remote --filter=modernjs-ssr-remote-new-version --concurrency=20", + "app:next:dev": "nx run-many --target=serve --configuration=development -p 3000-home,3001-shop,3002-checkout", + "app:next:build": "nx run-many --target=build --parallel=2 --configuration=production -p 3000-home,3001-shop,3002-checkout", + "app:next:prod": "nx run-many --target=serve --configuration=production -p 3000-home,3001-shop,3002-checkout", + "app:node:dev": "nx run-many --target=serve --parallel=10 --configuration=development -p node-host,node-local-remote,node-remote,node-dynamic-remote-new-version,node-dynamic-remote", + "app:runtime:dev": "nx run-many --target=serve --configuration=development -p 3005-runtime-host,3006-runtime-remote,3007-runtime-remote", + "app:manifest:dev": "NX_TUI=false nx run-many --target=serve --configuration=development --parallel=100 -p modernjs,manifest-webpack-host,3009-webpack-provider,3010-rspack-provider,3011-rspack-manifest-provider,3012-rspack-js-entry-provider", + "app:manifest:prod": "NX_TUI=false nx run-many --target=serve --configuration=production --parallel=100 -p modernjs,manifest-webpack-host,3009-webpack-provider,3010-rspack-provider,3011-rspack-manifest-provider,3012-rspack-js-entry-provider", + "app:ts:dev": "nx run-many --target=serve -p react_ts_host,react_ts_nested_remote,react_ts_remote", + "app:component-data-fetch:dev": "NX_TUI=false nx run-many --target=serve --parallel=3 --configuration=development -p modernjs-ssr-data-fetch-provider,modernjs-ssr-data-fetch-provider-csr,modernjs-ssr-data-fetch-host", + "app:modern:dev": "NX_TUI=false nx run-many --target=serve --parallel=10 --configuration=development -p modernjs-ssr-dynamic-nested-remote,modernjs-ssr-dynamic-remote,modernjs-ssr-dynamic-remote-new-version,modernjs-ssr-host,modernjs-ssr-nested-remote,modernjs-ssr-remote,modernjs-ssr-remote-new-version", "commitlint": "commitlint --edit", "prepare": "husky install", "changeset": "changeset", + "build:packages": "npx nx affected -t build --parallel=10 --exclude='*,!tag:type:pkg'", "changegen": "./changeset-gen.js --path ./packages/runtime && ./changeset-gen.js --path ./packages/runtime-core && ./changeset-gen.js --path ./packages/sdk &&./changeset-gen.js --path ./packages/cli --staged && ./changeset-gen.js --path ./packages/enhanced && ./changeset-gen.js --path ./packages/node && ./changeset-gen.js --path ./packages/data-prefetch && ./changeset-gen.js --path ./packages/nextjs-mf && ./changeset-gen.js --path ./packages/dts-plugin && ./changeset-gen.js --path ./packages/metro-core", "commitgen:staged": "./commit-gen.js --path ./packages --staged", "commitgen:main": "./commit-gen.js --path ./packages", "changeset:status": "changeset status", - "generate:schema": "pnpm --filter @module-federation/enhanced run generate:schema && pnpm exec prettier --write ." + "generate:schema": "nx run enhanced:generate:schema && nx format:write" }, "pnpm": { "packageExtensions": { @@ -93,15 +76,31 @@ "@changesets/assemble-release-plan": "workspace:*" } }, + "@nx/rollup": { + "dependencies": { + "typescript": "*" + } + }, + "@nx/js": { + "dependencies": { + "typescript": "*" + } + }, + "nx": { + "dependencies": { + "prettier": "*" + } + }, "next": { "dependencies": { "webpack": "*" } }, - "react-server-dom-webpack@19.2.4": { - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" + "@nx/module-federation": { + "peerDependenciesMeta": { + "next": { + "optional": true + } } }, "@storybook/nextjs": { @@ -156,6 +155,7 @@ "esbuild", "fsevents", "msw", + "nx", "sharp", "unrs-resolver" ] @@ -184,18 +184,34 @@ "devDependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx": "7.27.1", - "@babel/preset-env": "^7.28.0", "@babel/preset-react": "^7.27.1", - "@babel/preset-typescript": "^7.28.0", "@changesets/cli": "^2.27.9", "@chromatic-com/storybook": "^1.7.0", "@commitlint/cli": "^19.4.1", "@commitlint/config-conventional": "19.5.0", + "@commitlint/config-nx-scopes": "19.5.0", "@commitlint/cz-commitlint": "19.5.0", "@fontsource/roboto": "5.1.0", "@fontsource/roboto-mono": "5.1.0", "@module-federation/enhanced": "workspace:*", "@module-federation/node": "workspace:*", + "@nx/cypress": "21.2.3", + "@nx/devkit": "21.2.3", + "@nx/esbuild": "21.2.3", + "@nx/eslint": "21.2.3", + "@nx/eslint-plugin": "21.2.3", + "@nx/express": "21.2.3", + "@nx/jest": "21.2.3", + "@nx/js": "21.2.3", + "@nx/module-federation": "21.2.3", + "@nx/node": "21.2.3", + "@nx/react": "21.2.3", + "@nx/rollup": "21.2.3", + "@nx/rspack": "21.2.3", + "@nx/storybook": "21.2.3", + "@nx/vite": "21.2.3", + "@nx/web": "21.2.3", + "@nx/webpack": "21.2.3", "@playwright/test": "1.57.0", "@pmmmwh/react-refresh-webpack-plugin": "0.5.15", "@rollup/plugin-alias": "5.1.1", @@ -264,11 +280,11 @@ "jest-environment-node": "29.7.0", "jiti": "2.6.1", "js-yaml": "4.1.0", - "jsonc-eslint-parser": "^3.1.0", "kill-port": "^2.0.1", "mime-types": "2.1.35", "msw": "^1.2.1", "node-fetch": "~3.3.2", + "nx": "21.2.3", "open": "^10.1.0", "postcss-calc": "9.0.1", "postcss-custom-properties": "13.3.12", @@ -278,6 +294,7 @@ "prettier-eslint": "16.3.0", "prettier-plugin-tailwindcss": "0.5.14", "publint": "^0.3.13", + "qwik-nx": "^3.1.1", "react-refresh": "0.14.2", "rimraf": "^6.0.1", "rollup-plugin-copy": "3.5.0", @@ -293,7 +310,6 @@ "tsdown": "0.20.3", "tslib": "2.8.1", "tsup": "7.3.0", - "turbo": "^2.5.8", "typescript": "5.9.3", "url-loader": "4.1.1", "vite": "7.3.1", diff --git a/rslib.harness.config.mjs b/rslib.harness.config.mjs new file mode 100644 index 00000000000..ed9045f45cd --- /dev/null +++ b/rslib.harness.config.mjs @@ -0,0 +1,30 @@ +/** + * Rslib monorepo harness configuration. + * + * Supported project entry forms: + * - string path / glob: + * - directory (auto-detect rslib.config.*) + * - explicit rslib.config.* file + * - nested rslib.harness.config.* file + * - object: + * { + * name?: string; + * root?: string; + * config?: string; + * args?: string[]; + * ignore?: string[]; + * projects?: (string | object)[]; + * } + * + * Notes: + * - `` token is supported in path values. + * - this root harness intentionally targets publishable package projects and + * app-level rslib projects in apps/. + */ +export default { + ignore: ['**/dist/**', '**/.{idea,cache,output,temp}/**'], + projects: [ + 'packages/*/rslib.config.{mjs,ts,js,cjs,mts,cts}', + 'apps/**/rslib.config.{mjs,ts,js,cjs,mts,cts}', + ], +}; diff --git a/scripts/__tests__/rslib-harness.test.mjs b/scripts/__tests__/rslib-harness.test.mjs new file mode 100644 index 00000000000..c3b77064b7a --- /dev/null +++ b/scripts/__tests__/rslib-harness.test.mjs @@ -0,0 +1,959 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { + parseCliArgs, + resolveProjects, + validateCommandGuards, +} from '../rslib-harness.mjs'; + +const HARNESS_CLI_PATH = join( + dirname(fileURLToPath(import.meta.url)), + '../rslib-harness.mjs', +); + +async function withTempDir(run) { + const tempRoot = mkdtempSync(join(tmpdir(), 'rslib-harness-test-')); + try { + return await run(tempRoot); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } +} + +function writeFile(path, content) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content, 'utf8'); +} + +function writeRslibProject(root, projectDir, packageName) { + const fullProjectRoot = join(root, projectDir); + writeFile( + join(fullProjectRoot, 'package.json'), + JSON.stringify({ name: packageName }, null, 2), + ); + writeFile( + join(fullProjectRoot, 'rslib.config.ts'), + 'export default { lib: [{ format: "esm" }] };\n', + ); +} + +test('parseCliArgs parses project filters, parallel and passthrough', () => { + const parsed = parseCliArgs([ + 'build', + '--project', + 'pkg-a,pkg-b', + '--project', + 'pkg-c', + '--parallel', + '3', + '--', + '--watch', + ]); + + assert.equal(parsed.command, 'build'); + assert.deepEqual(parsed.projectFilters, ['pkg-a', 'pkg-b', 'pkg-c']); + assert.equal(parsed.parallel, 3); + assert.deepEqual(parsed.passthroughArgs, ['--watch']); +}); + +test('parseCliArgs enables list mode for list command', () => { + const parsed = parseCliArgs(['list', '--project', 'pkg-a', '--json']); + assert.equal(parsed.command, 'list'); + assert.equal(parsed.list, true); + assert.equal(parsed.json, true); + assert.deepEqual(parsed.projectFilters, ['pkg-a']); +}); + +test('parseCliArgs enables list mode when --json is passed', () => { + const parsed = parseCliArgs(['build', '--json']); + assert.equal(parsed.command, 'build'); + assert.equal(parsed.json, true); + assert.equal(parsed.list, true); +}); + +test('parseCliArgs rejects invalid --parallel values', () => { + assert.throws( + () => parseCliArgs(['build', '--parallel', '0']), + /Invalid --parallel value "0"/, + ); +}); + +test('parseCliArgs rejects unknown command values', () => { + assert.throws(() => parseCliArgs(['deploy']), /Unknown command "deploy"/); +}); + +test('parseCliArgs supports inline option assignments', () => { + const parsed = parseCliArgs([ + 'build', + '--config=./custom.harness.mjs', + '--root=./apps', + '--project=pkg-a,pkg-b', + '--parallel=2', + ]); + assert.equal(parsed.config, './custom.harness.mjs'); + assert.match(parsed.root, /\/apps$/); + assert.deepEqual(parsed.projectFilters, ['pkg-a', 'pkg-b']); + assert.equal(parsed.parallel, 2); +}); + +test('parseCliArgs rejects empty inline config/root assignments', () => { + assert.throws( + () => parseCliArgs(['build', '--config=']), + /Missing value for --config/, + ); + assert.throws( + () => parseCliArgs(['build', '--root=']), + /Missing value for --root/, + ); +}); + +test('parseCliArgs rejects unknown options', () => { + assert.throws( + () => parseCliArgs(['build', '--mystery']), + /Unknown option "--mystery"/, + ); +}); + +test('parseCliArgs rejects missing option values', () => { + assert.throws(() => parseCliArgs(['build', '--config']), /Missing value/); + assert.throws(() => parseCliArgs(['build', '--root']), /Missing value/); + assert.throws(() => parseCliArgs(['build', '--project']), /Missing value/); + assert.throws(() => parseCliArgs(['build', '--parallel']), /Missing value/); +}); + +test('resolveProjects discovers projects from glob entries', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeRslibProject(root, 'packages/pkg-b', 'pkg-b'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + 'export default { projects: ["packages/*"] };\n', + ); + + const projects = await resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }); + + assert.equal(projects.length, 2); + assert.deepEqual( + projects.map((project) => project.name), + ['pkg-a', 'pkg-b'], + ); + }); +}); + +test('resolveProjects supports nested harness config recursion', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/leaf', 'leaf'); + writeRslibProject(root, 'packages/group/nested', 'nested-fallback-name'); + writeFile( + join(root, 'packages/group/rslib.harness.config.mjs'), + ` +export default { + projects: [ + { + name: 'nested-explicit', + root: './nested', + config: './rslib.config.ts', + }, + ], +}; +`, + ); + writeFile( + join(root, 'rslib.harness.config.mjs'), + 'export default { projects: ["packages/*"] };\n', + ); + + const projects = await resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }); + + assert.equal(projects.length, 2); + assert.deepEqual( + projects.map((project) => project.name), + ['nested-explicit', 'leaf'], + ); + }); +}); + +test('resolveProjects merges default and per-project args', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + ` +export default { + defaults: { args: ['--lib', 'esm'] }, + projects: [ + { + name: 'pkg-a', + root: './packages/pkg-a', + args: ['--log-level', 'warn'], + }, + ], +}; +`, + ); + + const projects = await resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }); + + assert.equal(projects.length, 1); + assert.deepEqual(projects[0]?.args, [ + '--lib', + 'esm', + '--log-level', + 'warn', + ]); + }); +}); + +test('resolveProjects enforces unique project names', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'duplicate-name'); + writeRslibProject(root, 'packages/pkg-b', 'duplicate-name'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + 'export default { projects: ["packages/*"] };\n', + ); + + await assert.rejects( + () => + resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }), + /Duplicate project name "duplicate-name"/, + ); + }); +}); + +test('resolveProjects applies project filters', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeRslibProject(root, 'packages/pkg-b', 'pkg-b'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + 'export default { projects: ["packages/*"] };\n', + ); + + const projects = await resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: ['pkg-b'], + }); + + assert.equal(projects.length, 1); + assert.equal(projects[0]?.name, 'pkg-b'); + }); +}); + +test('resolveProjects applies project filters by path case-insensitively', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeRslibProject(root, 'packages/pkg-b', 'pkg-b'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + 'export default { projects: ["packages/*"] };\n', + ); + + const projects = await resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: ['PACKAGES/PKG-B'], + }); + + assert.equal(projects.length, 1); + assert.equal(projects[0]?.name, 'pkg-b'); + }); +}); + +test('resolveProjects deduplicates projects resolved from multiple entries', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + ` +export default { + projects: [ + 'packages/*', + 'packages/pkg-a/rslib.config.ts', + ], +}; +`, + ); + + const projects = await resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }); + + assert.equal(projects.length, 1); + assert.equal(projects[0]?.name, 'pkg-a'); + }); +}); + +test('resolveProjects returns deterministic sorted project ordering', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-b', 'pkg-b'); + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + ` +export default { + projects: [ + 'packages/pkg-b', + 'packages/pkg-a', + ], +}; +`, + ); + + const projects = await resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }); + + assert.equal(projects.length, 2); + assert.deepEqual( + projects.map((project) => project.name), + ['pkg-a', 'pkg-b'], + ); + }); +}); + +test('resolveProjects respects ignore patterns in harness config', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeRslibProject(root, 'packages/pkg-b', 'pkg-b'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + ` +export default { + ignore: ['packages/pkg-b/**'], + projects: ['packages/*'], +}; +`, + ); + + const projects = await resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }); + + assert.equal(projects.length, 1); + assert.equal(projects[0]?.name, 'pkg-a'); + }); +}); + +test('resolveProjects throws when project filter has no matches', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + `export default { projects: ['packages/*'] };`, + ); + + await assert.rejects( + () => + resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: ['does-not-exist'], + }), + /No projects matched filters/, + ); + }); +}); + +test('resolveProjects validates defaults.args as string array', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + ` +export default { + defaults: { args: [true] }, + projects: ['packages/*'], +}; +`, + ); + + await assert.rejects( + () => + resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }), + /"defaults\.args" must be an array of strings/, + ); + }); +}); + +test('resolveProjects validates defaults as object', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + ` +export default { + defaults: true, + projects: ['packages/*'], +}; +`, + ); + + await assert.rejects( + () => + resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }), + /"defaults" must be an object/, + ); + }); +}); + +test('resolveProjects rejects defaults array shape', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + ` +export default { + defaults: [], + projects: ['packages/*'], +}; +`, + ); + + await assert.rejects( + () => + resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }), + /"defaults" must be an object/, + ); + }); +}); + +test('resolveProjects validates top-level root as string', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + ` +export default { + root: 123, + projects: ['packages/*'], +}; +`, + ); + + await assert.rejects( + () => + resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }), + /"root" must be a string/, + ); + }); +}); + +test('resolveProjects rejects unknown top-level config keys', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + ` +export default { + projects: ['packages/*'], + mystery: true, +}; +`, + ); + + await assert.rejects( + () => + resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }), + /unknown top-level keys: mystery/, + ); + }); +}); + +test('resolveProjects rejects unknown defaults keys', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + ` +export default { + defaults: { + args: ['--lib', 'esm'], + extra: true, + }, + projects: ['packages/*'], +}; +`, + ); + + await assert.rejects( + () => + resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }), + /unknown defaults keys: extra/, + ); + }); +}); + +test('resolveProjects validates project args as string array', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + ` +export default { + projects: [ + { + root: './packages/pkg-a', + args: ['--lib', 123], + }, + ], +}; +`, + ); + + await assert.rejects( + () => + resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }), + /"projects\[0\]\.args" must be an array of strings/, + ); + }); +}); + +test('resolveProjects rejects unknown project entry keys', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + ` +export default { + projects: [ + { + root: './packages/pkg-a', + featureFlag: true, + }, + ], +}; +`, + ); + + await assert.rejects( + () => + resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }), + /"projects\[0\]" has unknown keys: featureFlag/, + ); + }); +}); + +test('resolveProjects validates project root type', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + ` +export default { + projects: [ + { + root: 123, + }, + ], +}; +`, + ); + + await assert.rejects( + () => + resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }), + /"projects\[0\]\.root" must be a string/, + ); + }); +}); + +test('resolveProjects rejects missing project root paths', async () => { + await withTempDir(async (root) => { + writeFile( + join(root, 'rslib.harness.config.mjs'), + ` +export default { + projects: [ + { + root: './packages/missing-pkg', + config: './rslib.config.ts', + }, + ], +}; +`, + ); + + await assert.rejects( + () => + resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }), + /resolved root ".*packages\/missing-pkg" but the path does not exist/, + ); + }); +}); + +test('resolveProjects rejects non-directory project roots', async () => { + await withTempDir(async (root) => { + writeFile(join(root, 'packages/pkg-a.txt'), 'not a directory\n'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + ` +export default { + projects: [ + { + root: './packages/pkg-a.txt', + config: './rslib.config.ts', + }, + ], +}; +`, + ); + + await assert.rejects( + () => + resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }), + /resolved root ".*packages\/pkg-a\.txt" but it is not a directory/, + ); + }); +}); + +test('resolveProjects validates nested project entry keys recursively', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + ` +export default { + projects: [ + { + projects: [ + { + root: './packages/pkg-a', + mystery: true, + }, + ], + }, + ], +}; +`, + ); + + await assert.rejects( + () => + resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }), + /"projects\[0\]\.projects\[0\]" has unknown keys: mystery/, + ); + }); +}); + +test('resolveProjects rejects unsupported explicit project config files', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeFile( + join(root, 'packages/pkg-a/custom.config.ts'), + 'export default {};\n', + ); + writeFile( + join(root, 'rslib.harness.config.mjs'), + ` +export default { + projects: [ + { + root: './packages/pkg-a', + config: './custom.config.ts', + }, + ], +}; +`, + ); + + await assert.rejects( + () => + resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }), + /is not a supported rslib\.config\.\* file/, + ); + }); +}); + +test('resolveProjects rejects explicit config paths that are not files', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + mkdirSync(join(root, 'packages/pkg-a/rslib.config.js'), { + recursive: true, + }); + writeFile( + join(root, 'rslib.harness.config.mjs'), + ` +export default { + projects: [ + { + root: './packages/pkg-a', + config: './rslib.config.js', + }, + ], +}; +`, + ); + + await assert.rejects( + () => + resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }), + /is not a file/, + ); + }); +}); + +test('validateCommandGuards rejects multi-project watch/mf-dev mode', () => { + assert.throws( + () => + validateCommandGuards({ + command: 'mf-dev', + passthroughArgs: [], + projects: [{ name: 'a' }, { name: 'b' }], + parallel: 1, + }), + /single-project only/, + ); + + assert.throws( + () => + validateCommandGuards({ + command: 'build', + passthroughArgs: ['--watch'], + projects: [{ name: 'a' }], + parallel: 2, + }), + /does not support --parallel > 1/, + ); +}); + +test('list --json emits machine-readable project metadata', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + 'export default { projects: ["packages/*"] };\n', + ); + + const result = spawnSync( + process.execPath, + [ + HARNESS_CLI_PATH, + 'list', + '--root', + root, + '--config', + join(root, 'rslib.harness.config.mjs'), + '--json', + ], + { + cwd: root, + encoding: 'utf8', + }, + ); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.length, 1); + assert.equal(payload[0]?.name, 'pkg-a'); + assert.equal(payload[0]?.root, 'packages/pkg-a'); + assert.equal(payload[0]?.config, 'packages/pkg-a/rslib.config.ts'); + assert.deepEqual(payload[0]?.args, []); + }); +}); + +test('build --json --dry-run emits machine-readable command plan', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + 'export default { projects: ["packages/*"] };\n', + ); + + const result = spawnSync( + process.execPath, + [ + HARNESS_CLI_PATH, + 'build', + '--root', + root, + '--config', + join(root, 'rslib.harness.config.mjs'), + '--project', + 'pkg-a', + '--json', + '--dry-run', + ], + { + cwd: root, + encoding: 'utf8', + }, + ); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.command, 'build'); + assert.equal(payload.dryRun, true); + assert.equal(payload.projects.length, 1); + assert.equal(payload.projects[0]?.name, 'pkg-a'); + assert.equal(payload.commands.length, 1); + assert.match( + payload.commands[0]?.command ?? '', + /^pnpm exec rslib build --config /, + ); + }); +}); + +test('build --json --dry-run command plan follows deterministic project order', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-b', 'pkg-b'); + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + ` +export default { + projects: [ + 'packages/pkg-b', + 'packages/pkg-a', + ], +}; +`, + ); + + const result = spawnSync( + process.execPath, + [ + HARNESS_CLI_PATH, + 'build', + '--root', + root, + '--config', + join(root, 'rslib.harness.config.mjs'), + '--json', + '--dry-run', + ], + { + cwd: root, + encoding: 'utf8', + }, + ); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.deepEqual( + payload.projects.map((project) => project.name), + ['pkg-a', 'pkg-b'], + ); + assert.deepEqual( + payload.commands.map((command) => command.name), + ['pkg-a', 'pkg-b'], + ); + }); +}); + +test('build --json without --dry-run fails fast', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + 'export default { projects: ["packages/*"] };\n', + ); + + const result = spawnSync( + process.execPath, + [ + HARNESS_CLI_PATH, + 'build', + '--root', + root, + '--config', + join(root, 'rslib.harness.config.mjs'), + '--json', + ], + { + cwd: root, + encoding: 'utf8', + }, + ); + + assert.notEqual(result.status, 0); + assert.match( + result.stderr, + /--json requires list mode or --dry-run to avoid mixed structured and live command output/, + ); + }); +}); diff --git a/scripts/rslib-harness.mjs b/scripts/rslib-harness.mjs new file mode 100644 index 00000000000..61bf8da7b9c --- /dev/null +++ b/scripts/rslib-harness.mjs @@ -0,0 +1,1048 @@ +#!/usr/bin/env node +import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'; +import { resolve, dirname, basename, isAbsolute, join } from 'node:path'; +import { spawn } from 'node:child_process'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import fg from 'fast-glob'; + +const DEFAULT_HARNESS_CONFIG = 'rslib.harness.config.mjs'; +const DEFAULT_PARALLEL = 1; +const RSLIB_CONFIG_FILES = [ + 'rslib.config.mjs', + 'rslib.config.ts', + 'rslib.config.js', + 'rslib.config.cjs', + 'rslib.config.mts', + 'rslib.config.cts', +]; +const HARNESS_CONFIG_PATTERN = + /^rslib\.harness\.config\.(?:mjs|js|cjs|mts|cts|ts)$/; + +function printUsage() { + console.log(`Rslib monorepo harness + +Usage: + node scripts/rslib-harness.mjs [command] [options] [-- passthrough] + +Commands: + build Run "rslib build" in resolved projects (default) + inspect Run "rslib inspect" in resolved projects + mf-dev Run "rslib mf-dev" (single-project only) + list Resolve and print projects only + +Options: + -c, --config Harness config path (default: ${DEFAULT_HARNESS_CONFIG}) + -r, --root Root directory for resolving config and projects + -p, --project Filter project(s) by name or path (repeatable or comma-separated) + --parallel Concurrent project commands (default: ${DEFAULT_PARALLEL}) + --dry-run Print commands without executing + --list Print resolved projects before execution + --json Print resolved projects as JSON + --continue-on-error Continue running remaining projects when one fails + -h, --help Show help +`); +} + +function parseCliArgs(argv) { + const parsed = { + command: 'build', + config: DEFAULT_HARNESS_CONFIG, + root: process.cwd(), + projectFilters: [], + parallel: DEFAULT_PARALLEL, + dryRun: false, + list: false, + json: false, + continueOnError: false, + passthroughArgs: [], + }; + + let commandSet = false; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + + if (arg === '--') { + parsed.passthroughArgs = argv.slice(i + 1); + break; + } + + if (arg === '-h' || arg === '--help') { + printUsage(); + process.exit(0); + } + + if (!arg.startsWith('-') && !commandSet) { + parsed.command = arg; + commandSet = true; + continue; + } + + if (arg === '-c' || arg === '--config') { + parsed.config = requireNextValue(argv, i, arg); + i += 1; + continue; + } + + if (arg === '-r' || arg === '--root') { + parsed.root = requireNextValue(argv, i, arg); + i += 1; + continue; + } + + if (arg === '-p' || arg === '--project') { + const value = requireNextValue(argv, i, arg); + i += 1; + parsed.projectFilters.push(...splitListOption(value)); + continue; + } + + if (arg === '--parallel') { + const value = requireNextValue(argv, i, arg); + i += 1; + const parallel = Number.parseInt(value, 10); + if (!Number.isFinite(parallel) || parallel < 1) { + throw new Error(`Invalid --parallel value "${value}". Expected >= 1.`); + } + parsed.parallel = parallel; + continue; + } + + if (arg === '--dry-run') { + parsed.dryRun = true; + continue; + } + + if (arg === '--list') { + parsed.list = true; + continue; + } + + if (arg === '--json') { + parsed.json = true; + parsed.list = true; + continue; + } + + if (arg === '--continue-on-error') { + parsed.continueOnError = true; + continue; + } + + if (arg.startsWith('--project=')) { + parsed.projectFilters.push( + ...splitListOption(arg.slice('--project='.length)), + ); + continue; + } + + if (arg.startsWith('--config=')) { + parsed.config = arg.slice('--config='.length); + if (!parsed.config) { + throw new Error('Missing value for --config.'); + } + continue; + } + + if (arg.startsWith('--root=')) { + parsed.root = arg.slice('--root='.length); + if (!parsed.root) { + throw new Error('Missing value for --root.'); + } + continue; + } + + if (arg.startsWith('--parallel=')) { + const value = arg.slice('--parallel='.length); + const parallel = Number.parseInt(value, 10); + if (!Number.isFinite(parallel) || parallel < 1) { + throw new Error(`Invalid --parallel value "${value}". Expected >= 1.`); + } + parsed.parallel = parallel; + continue; + } + + if (arg.startsWith('-')) { + throw new Error(`Unknown option "${arg}". Use --help for usage.`); + } + + throw new Error( + `Unexpected positional argument "${arg}". Pass through command arguments after "--".`, + ); + } + + if (!['build', 'inspect', 'mf-dev', 'list'].includes(parsed.command)) { + throw new Error( + `Unknown command "${parsed.command}". Expected build | inspect | mf-dev | list.`, + ); + } + + if (parsed.command === 'list') { + parsed.list = true; + } + + parsed.projectFilters = Array.from(new Set(parsed.projectFilters)); + parsed.root = resolve(process.cwd(), parsed.root); + + return parsed; +} + +function requireNextValue(argv, index, optionName) { + const value = argv[index + 1]; + if (!value || value.startsWith('-')) { + throw new Error(`Missing value for ${optionName}.`); + } + return value; +} + +function splitListOption(value) { + return value + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +} + +function readPackageName(projectRoot) { + const packageJsonPath = join(projectRoot, 'package.json'); + if (!existsSync(packageJsonPath)) { + return basename(projectRoot); + } + + try { + const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8')); + return typeof pkg.name === 'string' && pkg.name.trim() + ? pkg.name.trim() + : basename(projectRoot); + } catch { + return basename(projectRoot); + } +} + +function toCanonicalPath(targetPath) { + try { + return realpathSync(targetPath); + } catch { + return resolve(targetPath); + } +} + +function resolveWithRootDirToken(value, rootDir) { + const withToken = value.replaceAll('', rootDir); + if (isAbsolute(withToken)) { + return withToken; + } + return resolve(rootDir, withToken); +} + +function isHarnessConfigFile(filePath) { + return HARNESS_CONFIG_PATTERN.test(basename(filePath)); +} + +function isRslibConfigFile(filePath) { + return RSLIB_CONFIG_FILES.includes(basename(filePath)); +} + +function findProjectRslibConfig(projectRoot) { + for (const file of RSLIB_CONFIG_FILES) { + const configPath = join(projectRoot, file); + if (existsSync(configPath)) { + return configPath; + } + } + return null; +} + +async function loadHarnessConfig(configPath) { + const absolutePath = resolve(configPath); + if (!existsSync(absolutePath)) { + throw new Error(`Harness config not found: ${absolutePath}`); + } + + const moduleUrl = pathToFileURL(absolutePath).href; + const imported = await import(moduleUrl); + const config = imported.default ?? imported; + + if (!config || typeof config !== 'object' || Array.isArray(config)) { + throw new Error( + `Invalid harness config at ${absolutePath}: expected object export.`, + ); + } + + if (!Array.isArray(config.projects)) { + throw new Error( + `Invalid harness config at ${absolutePath}: "projects" must be an array.`, + ); + } + + validateHarnessConfigShape(config, absolutePath); + + return { + path: absolutePath, + config, + }; +} + +function assertStringArray(value, pathLabel, configPath) { + if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) { + throw new Error( + `Invalid harness config at ${configPath}: "${pathLabel}" must be an array of strings.`, + ); + } +} + +function validateProjectEntryShape(entry, pathLabel, configPath) { + if (typeof entry === 'string') { + return; + } + + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + throw new Error( + `Invalid harness config at ${configPath}: "${pathLabel}" must be a string or object entry.`, + ); + } + + if (entry.name !== undefined && typeof entry.name !== 'string') { + throw new Error( + `Invalid harness config at ${configPath}: "${pathLabel}.name" must be a string.`, + ); + } + + if (entry.root !== undefined && typeof entry.root !== 'string') { + throw new Error( + `Invalid harness config at ${configPath}: "${pathLabel}.root" must be a string.`, + ); + } + + if (entry.config !== undefined && typeof entry.config !== 'string') { + throw new Error( + `Invalid harness config at ${configPath}: "${pathLabel}.config" must be a string.`, + ); + } + + if (entry.args !== undefined) { + assertStringArray(entry.args, `${pathLabel}.args`, configPath); + } + + if (entry.ignore !== undefined) { + assertStringArray(entry.ignore, `${pathLabel}.ignore`, configPath); + } + + if (entry.projects !== undefined) { + if (!Array.isArray(entry.projects)) { + throw new Error( + `Invalid harness config at ${configPath}: "${pathLabel}.projects" must be an array.`, + ); + } + entry.projects.forEach((childEntry, index) => + validateProjectEntryShape( + childEntry, + `${pathLabel}.projects[${index}]`, + configPath, + ), + ); + } + + const allowedEntryKeys = new Set([ + 'name', + 'root', + 'config', + 'args', + 'ignore', + 'projects', + ]); + const unknownEntryKeys = Object.keys(entry).filter( + (key) => !allowedEntryKeys.has(key), + ); + if (unknownEntryKeys.length > 0) { + throw new Error( + `Invalid harness config at ${configPath}: "${pathLabel}" has unknown keys: ${unknownEntryKeys.join( + ', ', + )}.`, + ); + } +} + +function validateHarnessConfigShape(config, configPath) { + const allowedConfigKeys = new Set(['root', 'ignore', 'defaults', 'projects']); + const unknownConfigKeys = Object.keys(config).filter( + (key) => !allowedConfigKeys.has(key), + ); + if (unknownConfigKeys.length > 0) { + throw new Error( + `Invalid harness config at ${configPath}: unknown top-level keys: ${unknownConfigKeys.join( + ', ', + )}.`, + ); + } + + if (config.root !== undefined && typeof config.root !== 'string') { + throw new Error( + `Invalid harness config at ${configPath}: "root" must be a string.`, + ); + } + + if (config.ignore !== undefined) { + assertStringArray(config.ignore, 'ignore', configPath); + } + + if (config.defaults !== undefined) { + if ( + !config.defaults || + typeof config.defaults !== 'object' || + Array.isArray(config.defaults) + ) { + throw new Error( + `Invalid harness config at ${configPath}: "defaults" must be an object.`, + ); + } + + if (config.defaults.args !== undefined) { + assertStringArray(config.defaults.args, 'defaults.args', configPath); + } + + const allowedDefaultsKeys = new Set(['args']); + const unknownDefaultsKeys = Object.keys(config.defaults).filter( + (key) => !allowedDefaultsKeys.has(key), + ); + if (unknownDefaultsKeys.length > 0) { + throw new Error( + `Invalid harness config at ${configPath}: unknown defaults keys: ${unknownDefaultsKeys.join( + ', ', + )}.`, + ); + } + } + + config.projects.forEach((entry, index) => + validateProjectEntryShape(entry, `projects[${index}]`, configPath), + ); +} + +function mergeIgnorePatterns(parentIgnore, configIgnore) { + const combined = [ + ...parentIgnore, + ...(Array.isArray(configIgnore) ? configIgnore : []), + ]; + return Array.from(new Set(combined)); +} + +function createProjectRecord({ name, root, configFile, args, sourceConfig }) { + return { + name, + root, + configFile: configFile ?? null, + args: args ?? [], + sourceConfig, + }; +} + +function ensureUniqueProjectName(projectByName, project, dedupeKey) { + const existing = projectByName.get(project.name); + if (!existing) { + projectByName.set(project.name, { + dedupeKey, + root: project.root, + configFile: project.configFile, + }); + return; + } + + if (existing.dedupeKey === dedupeKey) { + return; + } + + throw new Error( + `Duplicate project name "${project.name}" detected.\n` + + `- Existing: ${existing.configFile ?? existing.root}\n` + + `- New: ${project.configFile ?? project.root}\n` + + 'Use explicit unique "name" values in harness entries.', + ); +} + +function shouldFilterProject(project, projectFilters) { + if (projectFilters.length === 0) { + return true; + } + + const rootLower = project.root.toLowerCase(); + const configLower = (project.configFile ?? '').toLowerCase(); + const nameLower = project.name.toLowerCase(); + + return projectFilters.some((filterValue) => { + const needle = filterValue.toLowerCase(); + return ( + nameLower === needle || + nameLower.includes(needle) || + rootLower.includes(needle) || + configLower.includes(needle) + ); + }); +} + +function toDisplayPath(pathValue, rootDir, fallback = '(auto)') { + if (!pathValue) { + return fallback; + } + return pathValue.startsWith(rootDir) + ? pathValue.slice(rootDir.length + 1) || '.' + : pathValue; +} + +function toProjectOutput(project, rootDir) { + return { + name: project.name, + root: toDisplayPath(project.root, rootDir, '.'), + config: toDisplayPath(project.configFile, rootDir, '(auto)'), + args: project.args, + }; +} + +function getProjectCommandArgs(project, command, passthroughArgs) { + const args = ['exec', 'rslib', command, ...project.args]; + if (project.configFile) { + args.push('--config', project.configFile); + } + args.push(...passthroughArgs); + return args; +} + +async function resolveProjects({ harnessConfigPath, rootDir, projectFilters }) { + const dedupeMap = new Map(); + const projectByName = new Map(); + const resolvedProjects = []; + + async function recurseConfig({ + configPath, + inheritedRootDir, + inheritedIgnore, + inheritedArgs, + }) { + const { path: absoluteConfigPath, config } = + await loadHarnessConfig(configPath); + const configDir = dirname(absoluteConfigPath); + const configRootDir = config.root + ? resolveWithRootDirToken(config.root, configDir) + : (inheritedRootDir ?? configDir); + const configIgnore = mergeIgnorePatterns(inheritedIgnore, config.ignore); + const configArgs = [ + ...inheritedArgs, + ...(Array.isArray(config.defaults?.args) ? config.defaults.args : []), + ]; + + for (const entry of config.projects) { + await resolveEntry({ + entry, + entryRootDir: configRootDir, + ignorePatterns: configIgnore, + inheritedArgs: configArgs, + sourceConfig: absoluteConfigPath, + }); + } + } + + async function addProject({ + name, + projectRoot, + configFile, + args, + sourceConfig, + }) { + const canonicalConfig = configFile ? toCanonicalPath(configFile) : null; + const canonicalRoot = toCanonicalPath(projectRoot); + const dedupeKey = canonicalConfig + ? `config:${canonicalConfig}` + : `root:${canonicalRoot}`; + + if (dedupeMap.has(dedupeKey)) { + return; + } + + const finalName = name ?? readPackageName(projectRoot); + const project = createProjectRecord({ + name: finalName, + root: canonicalRoot, + configFile: canonicalConfig, + args, + sourceConfig, + }); + + ensureUniqueProjectName(projectByName, project, dedupeKey); + dedupeMap.set(dedupeKey, project); + resolvedProjects.push(project); + } + + async function resolvePathLikeEntry({ + targetPath, + inheritedArgs, + sourceConfig, + entryRootDir, + ignorePatterns, + }) { + const absolutePath = resolveWithRootDirToken(targetPath, entryRootDir); + + if (!existsSync(absolutePath)) { + throw new Error( + `Project entry "${targetPath}" resolved to missing path: ${absolutePath} (from ${sourceConfig})`, + ); + } + + const stats = statSync(absolutePath); + if (stats.isDirectory()) { + const nestedHarnessConfig = join(absolutePath, DEFAULT_HARNESS_CONFIG); + if (existsSync(nestedHarnessConfig)) { + await recurseConfig({ + configPath: nestedHarnessConfig, + inheritedRootDir: absolutePath, + inheritedIgnore: ignorePatterns, + inheritedArgs, + }); + return; + } + + const rslibConfigPath = findProjectRslibConfig(absolutePath); + if (!rslibConfigPath) { + return; + } + + await addProject({ + projectRoot: absolutePath, + configFile: rslibConfigPath, + args: inheritedArgs, + sourceConfig, + }); + return; + } + + if (isHarnessConfigFile(absolutePath)) { + await recurseConfig({ + configPath: absolutePath, + inheritedRootDir: dirname(absolutePath), + inheritedIgnore: ignorePatterns, + inheritedArgs, + }); + return; + } + + if (!isRslibConfigFile(absolutePath)) { + throw new Error( + `Unsupported project file "${absolutePath}". Expected rslib.config.* or rslib.harness.config.*`, + ); + } + + await addProject({ + projectRoot: dirname(absolutePath), + configFile: absolutePath, + args: inheritedArgs, + sourceConfig, + }); + } + + async function resolveStringEntry({ + value, + entryRootDir, + ignorePatterns, + inheritedArgs, + sourceConfig, + }) { + const expandedValue = value.replaceAll('', entryRootDir); + + if (fg.isDynamicPattern(expandedValue)) { + const matches = await fg(expandedValue, { + cwd: entryRootDir, + absolute: true, + dot: true, + onlyFiles: false, + unique: true, + followSymbolicLinks: false, + ignore: ignorePatterns, + }); + + matches.sort((a, b) => a.localeCompare(b)); + + for (const match of matches) { + await resolvePathLikeEntry({ + targetPath: match, + entryRootDir, + ignorePatterns, + inheritedArgs, + sourceConfig, + }); + } + return; + } + + await resolvePathLikeEntry({ + targetPath: expandedValue, + entryRootDir, + ignorePatterns, + inheritedArgs, + sourceConfig, + }); + } + + async function resolveObjectEntry({ + entry, + entryRootDir, + ignorePatterns, + inheritedArgs, + sourceConfig, + }) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + throw new Error( + `Invalid project entry in ${sourceConfig}. Expected object, got ${typeof entry}.`, + ); + } + + const objectRootDir = entry.root + ? resolveWithRootDirToken(entry.root, entryRootDir) + : entryRootDir; + if (!existsSync(objectRootDir)) { + throw new Error( + `Project entry in ${sourceConfig} resolved root "${objectRootDir}" but the path does not exist.`, + ); + } + + if (!statSync(objectRootDir).isDirectory()) { + throw new Error( + `Project entry in ${sourceConfig} resolved root "${objectRootDir}" but it is not a directory.`, + ); + } + + const objectIgnore = mergeIgnorePatterns(ignorePatterns, entry.ignore); + const objectArgs = [ + ...inheritedArgs, + ...(Array.isArray(entry.args) ? entry.args : []), + ]; + + if (Array.isArray(entry.projects)) { + for (const nestedEntry of entry.projects) { + await resolveEntry({ + entry: nestedEntry, + entryRootDir: objectRootDir, + ignorePatterns: objectIgnore, + inheritedArgs: objectArgs, + sourceConfig, + }); + } + return; + } + + const explicitConfigFile = entry.config + ? resolveWithRootDirToken(entry.config, objectRootDir) + : findProjectRslibConfig(objectRootDir); + + if (!explicitConfigFile) { + throw new Error( + `Project entry in ${sourceConfig} resolved to "${objectRootDir}" but no rslib.config.* file was found.`, + ); + } + + if (!existsSync(explicitConfigFile)) { + throw new Error( + `Project config path "${explicitConfigFile}" does not exist (from ${sourceConfig}).`, + ); + } + + if (!isRslibConfigFile(explicitConfigFile)) { + throw new Error( + `Project config path "${explicitConfigFile}" is not a supported rslib.config.* file (from ${sourceConfig}).`, + ); + } + + if (!statSync(explicitConfigFile).isFile()) { + throw new Error( + `Project config path "${explicitConfigFile}" is not a file (from ${sourceConfig}).`, + ); + } + + await addProject({ + name: entry.name, + projectRoot: dirname(explicitConfigFile), + configFile: explicitConfigFile, + args: objectArgs, + sourceConfig, + }); + } + + async function resolveEntry({ + entry, + entryRootDir, + ignorePatterns, + inheritedArgs, + sourceConfig, + }) { + if (typeof entry === 'string') { + await resolveStringEntry({ + value: entry, + entryRootDir, + ignorePatterns, + inheritedArgs, + sourceConfig, + }); + return; + } + + await resolveObjectEntry({ + entry, + entryRootDir, + ignorePatterns, + inheritedArgs, + sourceConfig, + }); + } + + await recurseConfig({ + configPath: harnessConfigPath, + inheritedRootDir: rootDir, + inheritedIgnore: ['**/node_modules/**', '**/.git/**'], + inheritedArgs: [], + }); + + resolvedProjects.sort((left, right) => { + if (left.root !== right.root) { + return left.root.localeCompare(right.root); + } + if (left.name !== right.name) { + return left.name.localeCompare(right.name); + } + return (left.configFile ?? '').localeCompare(right.configFile ?? ''); + }); + + const filteredProjects = resolvedProjects.filter((project) => + shouldFilterProject(project, projectFilters), + ); + + if (projectFilters.length > 0 && filteredProjects.length === 0) { + throw new Error( + `No projects matched filters: ${projectFilters.join(', ')}`, + ); + } + + return filteredProjects; +} + +function printResolvedProjects(projects, rootDir, options = {}) { + if (options.json === true) { + const payload = projects.map((project) => + toProjectOutput(project, rootDir), + ); + console.log(JSON.stringify(payload, null, 2)); + return; + } + + console.log(`[rslib-harness] Resolved ${projects.length} project(s):`); + for (const project of projects) { + const root = project.root.startsWith(rootDir) + ? project.root.slice(rootDir.length + 1) || '.' + : project.root; + const configPath = project.configFile?.startsWith(rootDir) + ? project.configFile.slice(rootDir.length + 1) || '.' + : (project.configFile ?? '(auto)'); + console.log(`- ${project.name}`); + console.log(` root: ${root}`); + console.log(` config: ${configPath}`); + if (project.args.length > 0) { + console.log(` args: ${project.args.join(' ')}`); + } + } +} + +function spawnProjectCommand({ project, command, passthroughArgs, dryRun }) { + const args = getProjectCommandArgs(project, command, passthroughArgs); + + const commandLine = `pnpm ${args.join(' ')}`; + console.log(`[rslib-harness] ${project.name}: ${commandLine}`); + + if (dryRun) { + return Promise.resolve({ code: 0, project }); + } + + return new Promise((resolvePromise) => { + const child = spawn('pnpm', args, { + cwd: project.root, + stdio: 'inherit', + env: process.env, + }); + + child.on('close', (code) => { + resolvePromise({ + code: code ?? 1, + project, + }); + }); + }); +} + +async function runWithConcurrency({ + projects, + command, + passthroughArgs, + parallel, + dryRun, + continueOnError, +}) { + const failures = []; + const queue = [...projects]; + const active = new Set(); + let shouldStop = false; + + async function launchNext() { + if (shouldStop) { + return; + } + + const nextProject = queue.shift(); + if (!nextProject) { + return; + } + + const runPromise = spawnProjectCommand({ + project: nextProject, + command, + passthroughArgs, + dryRun, + }).then((result) => { + if (result.code !== 0) { + failures.push(result); + if (!continueOnError) { + shouldStop = true; + } + } + }); + + active.add(runPromise); + runPromise.finally(() => active.delete(runPromise)); + + if (active.size >= parallel) { + await Promise.race(active); + } + + await launchNext(); + } + + const initialWorkers = Math.min(parallel, queue.length); + const workers = []; + for (let i = 0; i < initialWorkers; i += 1) { + workers.push(launchNext()); + } + await Promise.all(workers); + await Promise.all(active); + + return failures; +} + +function validateCommandGuards({ + command, + passthroughArgs, + projects, + parallel, +}) { + const watchRequested = + command === 'mf-dev' || + passthroughArgs.includes('--watch') || + passthroughArgs.includes('-w'); + + if (watchRequested && projects.length > 1) { + throw new Error( + 'Watch/mf-dev mode is currently single-project only. Use --project to select one project.', + ); + } + + if (watchRequested && parallel !== 1) { + throw new Error('Watch/mf-dev mode does not support --parallel > 1.'); + } +} + +async function main() { + const cli = parseCliArgs(process.argv.slice(2)); + const harnessConfigPath = isAbsolute(cli.config) + ? cli.config + : resolve(cli.root, cli.config); + + if (cli.json && cli.command !== 'list' && !cli.dryRun) { + throw new Error( + '--json requires list mode or --dry-run to avoid mixed structured and live command output.', + ); + } + + const projects = await resolveProjects({ + harnessConfigPath, + rootDir: cli.root, + projectFilters: cli.projectFilters, + }); + + if (projects.length === 0) { + throw new Error('No projects were resolved from harness config.'); + } + + if (cli.command === 'list') { + printResolvedProjects(projects, cli.root, { json: cli.json }); + return; + } + + if (cli.json && cli.dryRun) { + const payload = { + command: cli.command, + dryRun: true, + projects: projects.map((project) => toProjectOutput(project, cli.root)), + commands: projects.map((project) => ({ + name: project.name, + cwd: toDisplayPath(project.root, cli.root, '.'), + command: `pnpm ${getProjectCommandArgs( + project, + cli.command, + cli.passthroughArgs, + ).join(' ')}`, + })), + }; + console.log(JSON.stringify(payload, null, 2)); + return; + } + + if (cli.list || cli.dryRun) { + printResolvedProjects(projects, cli.root, { json: cli.json }); + } + + validateCommandGuards({ + command: cli.command, + passthroughArgs: cli.passthroughArgs, + projects, + parallel: cli.parallel, + }); + + const failures = await runWithConcurrency({ + projects, + command: cli.command, + passthroughArgs: cli.passthroughArgs, + parallel: cli.parallel, + dryRun: cli.dryRun, + continueOnError: cli.continueOnError, + }); + + if (failures.length > 0) { + console.error( + `[rslib-harness] ${failures.length} project(s) failed:\n` + + failures + .map( + (failure) => `- ${failure.project.name} (${failure.project.root})`, + ) + .join('\n'), + ); + process.exit(1); + } +} + +const isMainModule = + process.argv[1] && + resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); + +if (isMainModule) { + main().catch((error) => { + console.error( + `[rslib-harness] ${error instanceof Error ? error.message : error}`, + ); + process.exit(1); + }); +} + +export { parseCliArgs, resolveProjects, validateCommandGuards }; diff --git a/tools/scripts/ci-local.mjs b/tools/scripts/ci-local.mjs index f43ff562baf..226bd57e7dc 100644 --- a/tools/scripts/ci-local.mjs +++ b/tools/scripts/ci-local.mjs @@ -4,6 +4,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +process.env.NX_TUI = 'false'; process.env.CI = process.env.CI ?? 'true'; const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); @@ -27,18 +28,19 @@ const onlyJobNames = args.only : []; const onlyJobs = args.only === null ? null : new Set(onlyJobNames); -function setupE2E() { - return step('Setup E2E dependencies and package build', async (ctx) => { - await runCommand('pnpm', ['install', '--frozen-lockfile'], ctx); - await runCommand('npx', ['cypress', 'install'], ctx); - await runPackagesBuild(ctx); - }); -} - const jobs = [ { name: 'build-and-test', steps: [ + step('Optional clean node_modules/.nx', async (ctx) => { + if (process.env.CI_LOCAL_CLEAN === 'true') { + await runShell('rm -rf node_modules .nx', ctx); + return; + } + console.log( + '[ci:local] Skipping cache clean (set CI_LOCAL_CLEAN=true to enable).', + ); + }), step('Install dependencies', (ctx) => runCommand('pnpm', ['install', '--frozen-lockfile'], ctx), ), @@ -46,7 +48,7 @@ const jobs = [ runCommand('npx', ['cypress', 'install'], ctx), ), step('Check code format', (ctx) => - runCommand('node', ['tools/scripts/check-format-changed.mjs'], ctx), + runCommand('npx', ['nx', 'format:check'], ctx), ), step('Verify Rslib Template Publint Wiring', (ctx) => runCommand( @@ -71,10 +73,43 @@ const jobs = [ ctx, ), ), - step('Verify Turbo Conventions', (ctx) => - runCommand('pnpm', ['run', 'verify:turbo'], ctx), + step('Run Rslib Harness Tests', (ctx) => + runCommand('pnpm', ['run', 'test:rslib-harness'], ctx), + ), + step('Verify Rslib Harness Coverage', (ctx) => + runCommand('pnpm', ['run', 'verify:rslib-harness'], ctx), + ), + step('Verify Rslib Harness Workflow Coverage', (ctx) => + runCommand('pnpm', ['run', 'verify:rslib-harness:workflow'], ctx), + ), + step('Print number of CPU cores', (ctx) => runCommand('nproc', [], ctx)), + step('Build packages (cold cache)', (ctx) => + runCommand( + 'npx', + [ + 'nx', + 'run-many', + '--targets=build', + '--projects=tag:type:pkg', + '--parallel=4', + '--skip-nx-cache', + ], + ctx, + ), + ), + step('Build packages (warm cache)', (ctx) => + runCommand( + 'npx', + [ + 'nx', + 'run-many', + '--targets=build', + '--projects=tag:type:pkg', + '--parallel=4', + ], + ctx, + ), ), - step('Build packages', (ctx) => runPackagesBuild(ctx)), step('Check package publishing compatibility (publint)', (ctx) => runShell( ` @@ -93,7 +128,33 @@ const jobs = [ ctx, ), ), - step('Run affected package tests', (ctx) => runChangedPackageTests(ctx)), + step('Warm Nx cache', (ctx) => + runCommand( + 'npx', + [ + 'nx', + 'run-many', + '--targets=build', + '--projects=tag:type:pkg', + '--parallel=4', + ], + ctx, + ), + ), + step('Run affected tests', (ctx) => + runCommand( + 'npx', + [ + 'nx', + 'affected', + '-t', + 'test', + '--parallel=3', + '--exclude=*,!tag:type:pkg', + ], + ctx, + ), + ), ], }, { @@ -125,68 +186,105 @@ const jobs = [ ctx, ), ), - step('Verify Turbo Conventions', (ctx) => - runCommand('pnpm', ['run', 'verify:turbo'], ctx), + step('Run Rslib Harness Tests', (ctx) => + runCommand('pnpm', ['run', 'test:rslib-harness'], ctx), ), - step('Build shared packages', (ctx) => runPackagesBuild(ctx)), - step('Check metro package publishing compatibility (publint)', (ctx) => - runShell( - ` - for pkg in packages/metro-*; do - if [ -f "$pkg/package.json" ]; then - echo "Checking $pkg..." - npx publint "$pkg" - fi - done - `, - ctx, - ), + step('Verify Rslib Harness Coverage', (ctx) => + runCommand('pnpm', ['run', 'verify:rslib-harness'], ctx), ), - step('Test metro packages', (ctx) => + step('Verify Rslib Harness Workflow Coverage', (ctx) => + runCommand('pnpm', ['run', 'verify:rslib-harness:workflow'], ctx), + ), + step('Build all required packages', (ctx) => runCommand( - 'pnpm', + 'npx', [ - 'exec', - 'turbo', - 'run', - 'test', - '--filter=@module-federation/metro*', + 'nx', + 'run-many', + '--targets=build', + '--projects=tag:type:pkg', + '--parallel=4', + '--skip-nx-cache', ], ctx, ), ), + step('Test metro packages', (ctx) => + runWithRetry({ + label: 'metro affected tests', + attempts: 2, + run: () => + runCommand( + 'npx', + [ + 'nx', + 'affected', + '-t', + 'test', + '--parallel=2', + '--exclude=*,!tag:npm:metro', + ], + ctx, + ), + }), + ), step('Lint metro packages', (ctx) => runCommand( - 'pnpm', + 'npx', [ - 'exec', - 'turbo', - 'run', - 'lint', - '--filter=@module-federation/metro*', + 'nx', + 'run-many', + '--targets=lint', + '--projects=tag:npm:metro', + '--parallel=2', ], ctx, ), ), + step('Check package publishing compatibility (publint)', (ctx) => + runShell( + ` + for pkg in packages/metro-*; do + if [ -f "$pkg/package.json" ]; then + echo "Checking $pkg..." + npx publint "$pkg" + fi + done + `, + ctx, + ), + ), ], }, { name: 'e2e-modern', env: { SKIP_DEVTOOLS_POSTINSTALL: 'true' }, steps: [ - setupE2E(), - step('Check CI conditions', async (ctx) => { - ctx.state.shouldRun = await ciIsAffected( - '@module-federation/modern-js,@module-federation/modern-js-v3', + step('Install dependencies', (ctx) => + runCommand('pnpm', ['install', '--frozen-lockfile'], ctx), + ), + step('Install Cypress', (ctx) => + runCommand('npx', ['cypress', 'install'], ctx), + ), + step('Build packages', (ctx) => + runCommand( + 'npx', + ['nx', 'run-many', '--targets=build', '--projects=tag:type:pkg'], ctx, - ); + ), + ), + step('Check CI conditions', async (ctx) => { + ctx.state.shouldRun = await ciIsAffected('modernjs', ctx); }), step('E2E Test for ModernJS', async (ctx) => { if (!ctx.state.shouldRun) { logStepSkip(ctx, 'Not affected by current changes.'); return; } - await runCommand('pnpm', ['run', 'e2e:modern'], ctx); + await runShell( + 'npx kill-port --port 4001 && npx nx run-many --target=test:e2e --projects=modernjs --parallel=1 && npx kill-port --port 4001', + ctx, + ); }), ], }, @@ -194,43 +292,98 @@ const jobs = [ name: 'e2e-runtime', env: { SKIP_DEVTOOLS_POSTINSTALL: 'true' }, steps: [ - setupE2E(), - step('E2E Test for Runtime Demo', (ctx) => - runIfAffected(ctx, 'runtime-host,runtime-remote1,runtime-remote2', () => - runCommand('pnpm', ['run', 'e2e:runtime'], ctx), + step('Install dependencies', (ctx) => + runCommand('pnpm', ['install', '--frozen-lockfile'], ctx), + ), + step('Install Cypress', (ctx) => + runCommand('npx', ['cypress', 'install'], ctx), + ), + step('Build packages', (ctx) => + runCommand( + 'npx', + ['nx', 'run-many', '--targets=build', '--projects=tag:type:pkg'], + ctx, ), ), + step('Check CI conditions', async (ctx) => { + ctx.state.shouldRun = await ciIsAffected('3005-runtime-host', ctx); + }), + step('E2E Test for Runtime Demo', async (ctx) => { + if (!ctx.state.shouldRun) { + logStepSkip(ctx, 'Not affected by current changes.'); + return; + } + await runShell( + 'npx kill-port --port 3005,3006,3007 && pnpm run app:runtime:dev & echo "done" && sleep 20 && npx nx run-many --target=test:e2e --projects=3005-runtime-host --parallel=1 && lsof -ti tcp:3005,3006,3007 | xargs kill', + ctx, + ); + }), ], }, { name: 'e2e-manifest', env: { SKIP_DEVTOOLS_POSTINSTALL: 'true' }, steps: [ - setupE2E(), - step('E2E Test for Manifest Demo (dev)', (ctx) => - runIfAffected( - ctx, - '3008-webpack-host,3009-webpack-provider,3010-rspack-provider,3011-rspack-manifest-provider,3012-rspack-js-entry-provider', - () => runCommand('pnpm', ['run', 'e2e:manifest:dev'], ctx), - ), + step('Install dependencies', (ctx) => + runCommand('pnpm', ['install', '--frozen-lockfile'], ctx), ), - step('E2E Test for Manifest Demo (prod)', (ctx) => - runIfAffected( + step('Install Cypress', (ctx) => + runCommand('npx', ['cypress', 'install'], ctx), + ), + step('Build packages', (ctx) => + runCommand( + 'npx', + ['nx', 'run-many', '--targets=build', '--projects=tag:type:pkg'], ctx, - '3008-webpack-host,3009-webpack-provider,3010-rspack-provider,3011-rspack-manifest-provider,3012-rspack-js-entry-provider', - () => runCommand('pnpm', ['run', 'e2e:manifest:prod'], ctx), ), ), + step('Check CI conditions', async (ctx) => { + ctx.state.shouldRun = await ciIsAffected('manifest-webpack-host', ctx); + }), + step('E2E Test for Manifest Demo (dev)', async (ctx) => { + if (!ctx.state.shouldRun) { + logStepSkip(ctx, 'Not affected by current changes.'); + return; + } + await runCommand( + 'node', + ['tools/scripts/run-manifest-e2e.mjs', '--mode=dev'], + ctx, + ); + }), + step('E2E Test for Manifest Demo (prod)', async (ctx) => { + if (!ctx.state.shouldRun) { + logStepSkip(ctx, 'Not affected by current changes.'); + return; + } + await runCommand( + 'node', + ['tools/scripts/run-manifest-e2e.mjs', '--mode=prod'], + ctx, + ); + }), ], }, { name: 'e2e-node', env: { SKIP_DEVTOOLS_POSTINSTALL: 'true' }, steps: [ - setupE2E(), + step('Install dependencies', (ctx) => + runCommand('pnpm', ['install', '--frozen-lockfile'], ctx), + ), + step('Install Cypress', (ctx) => + runCommand('npx', ['cypress', 'install'], ctx), + ), + step('Build packages', (ctx) => + runCommand( + 'npx', + ['nx', 'run-many', '--targets=build', '--projects=tag:type:pkg'], + ctx, + ), + ), step('Check CI conditions', async (ctx) => { ctx.state.shouldRun = await ciIsAffected( - 'node-host,node-local-remote,node-remote,node-dynamic-remote-new-version,node-dynamic-remote,node-host-e2e', + 'node-local-remote,node-remote,node-dynamic-remote-new-version,node-dynamic-remote', ctx, ); }), @@ -239,7 +392,10 @@ const jobs = [ logStepSkip(ctx, 'Not affected by current changes.'); return; } - await runCommand('pnpm', ['run', 'e2e:node'], ctx); + await runShell( + 'npx nx run-many --target=serve --projects=node-local-remote,node-remote,node-dynamic-remote-new-version,node-dynamic-remote --parallel=10 & echo "done" && sleep 25 && npx nx run-many --target=serve --projects=node-host & sleep 5 && npx wait-on tcp:3333 && npx nx run node-host-e2e:test:e2e', + ctx, + ); }), ], }, @@ -250,28 +406,66 @@ const jobs = [ NEXT_PRIVATE_LOCAL_WEBPACK: 'true', }, steps: [ - setupE2E(), - step('E2E Test for Next.js Dev', (ctx) => - runIfAffected( + step('Install dependencies', (ctx) => + runCommand('pnpm', ['install', '--frozen-lockfile'], ctx), + ), + step('Install Cypress', (ctx) => + runCommand('npx', ['cypress', 'install'], ctx), + ), + step('Build packages', (ctx) => + runCommand( + 'npx', + ['nx', 'run-many', '--targets=build', '--projects=tag:type:pkg'], ctx, - '@module-federation/3000-home,@module-federation/3001-shop,@module-federation/3002-checkout', - () => runCommand('pnpm', ['run', 'e2e:next:dev'], ctx), ), ), + step('Check CI conditions', async (ctx) => { + ctx.state.shouldRun = await ciIsAffected('3000-home', ctx); + }), + step('E2E Test for Next.js Dev', async (ctx) => { + if (!ctx.state.shouldRun) { + logStepSkip(ctx, 'Not affected by current changes.'); + return; + } + await runCommand( + 'node', + ['tools/scripts/run-next-e2e.mjs', '--mode=dev'], + ctx, + ); + }), ], }, { name: 'e2e-next-prod', env: { SKIP_DEVTOOLS_POSTINSTALL: 'true' }, steps: [ - setupE2E(), - step('E2E Test for Next.js Prod', (ctx) => - runIfAffected( + step('Install dependencies', (ctx) => + runCommand('pnpm', ['install', '--frozen-lockfile'], ctx), + ), + step('Install Cypress', (ctx) => + runCommand('npx', ['cypress', 'install'], ctx), + ), + step('Build packages', (ctx) => + runCommand( + 'npx', + ['nx', 'run-many', '--targets=build', '--projects=tag:type:pkg'], ctx, - '@module-federation/3000-home,@module-federation/3001-shop,@module-federation/3002-checkout', - () => runCommand('pnpm', ['run', 'e2e:next:prod'], ctx), ), ), + step('Check CI conditions', async (ctx) => { + ctx.state.shouldRun = await ciIsAffected('3000-home', ctx); + }), + step('E2E Test for Next.js Prod', async (ctx) => { + if (!ctx.state.shouldRun) { + logStepSkip(ctx, 'Not affected by current changes.'); + return; + } + await runCommand( + 'node', + ['tools/scripts/run-next-e2e.mjs', '--mode=prod'], + ctx, + ); + }), ], }, { @@ -281,10 +475,16 @@ const jobs = [ step('Install dependencies', (ctx) => runCommand('pnpm', ['install', '--frozen-lockfile'], ctx), ), - step('Build packages', (ctx) => runPackagesBuild(ctx)), + step('Build packages', (ctx) => + runCommand( + 'npx', + ['nx', 'run-many', '--targets=build', '--projects=tag:type:pkg'], + ctx, + ), + ), step('Check CI conditions', async (ctx) => { ctx.state.shouldRun = await ciIsAffected( - '@module-federation/treeshake-server,@module-federation/treeshake-frontend', + 'treeshake-server,treeshake-frontend', ctx, ); }), @@ -293,34 +493,47 @@ const jobs = [ logStepSkip(ctx, 'Not affected by current changes.'); return; } - await runCommand('pnpm', ['run', 'e2e:treeshake:server'], ctx); + await runCommand('npx', ['nx', 'run', 'treeshake-server:test'], ctx); }), step('E2E Treeshake Frontend', async (ctx) => { if (!ctx.state.shouldRun) { logStepSkip(ctx, 'Not affected by current changes.'); return; } - await runCommand('pnpm', ['run', 'e2e:treeshake:frontend'], ctx); + await runCommand('npx', ['nx', 'run', 'treeshake-frontend:e2e'], ctx); }), ], }, + { name: 'e2e-modern-ssr', env: { SKIP_DEVTOOLS_POSTINSTALL: 'true' }, steps: [ - setupE2E(), - step('Check CI conditions', async (ctx) => { - ctx.state.shouldRun = await ciIsAffected( - '@module-federation/modern-js,@module-federation/modern-js-v3,modernjs-ssr-host,modernjs-ssr-remote,modernjs-ssr-remote-new-version,modernjs-ssr-nested-remote,modernjs-ssr-dynamic-remote,modernjs-ssr-dynamic-remote-new-version,modernjs-ssr-dynamic-nested-remote,modernjs-ssr-data-fetch-host,modernjs-ssr-data-fetch-provider,modernjs-ssr-data-fetch-provider-csr', + step('Install dependencies', (ctx) => + runCommand('pnpm', ['install', '--frozen-lockfile'], ctx), + ), + step('Install Cypress', (ctx) => + runCommand('npx', ['cypress', 'install'], ctx), + ), + step('Build packages', (ctx) => + runCommand( + 'npx', + ['nx', 'run-many', '--targets=build', '--projects=tag:type:pkg'], ctx, - ); + ), + ), + step('Check CI conditions', async (ctx) => { + ctx.state.shouldRun = await ciIsAffected('modernjs', ctx); }), step('E2E Test for ModernJS SSR', async (ctx) => { if (!ctx.state.shouldRun) { logStepSkip(ctx, 'Not affected by current changes.'); return; } - await runCommand('pnpm', ['run', 'e2e:modern:ssr'], ctx); + await runShell( + 'lsof -ti tcp:3050,3051,3052,3053,3054,3055,3056 | xargs -r kill && pnpm run app:modern:dev & sleep 30 && for port in 3050 3051 3052 3053 3054 3055 3056; do while true; do response=$(curl -s http://127.0.0.1:$port/mf-manifest.json); if echo "$response" | jq empty >/dev/null 2>&1; then break; fi; sleep 1; done; done', + ctx, + ); }), ], }, @@ -328,10 +541,22 @@ const jobs = [ name: 'e2e-router', env: { SKIP_DEVTOOLS_POSTINSTALL: 'true' }, steps: [ - setupE2E(), + step('Install dependencies', (ctx) => + runCommand('pnpm', ['install', '--frozen-lockfile'], ctx), + ), + step('Install Cypress', (ctx) => + runCommand('npx', ['cypress', 'install'], ctx), + ), + step('Build packages', (ctx) => + runCommand( + 'npx', + ['nx', 'run-many', '--targets=build', '--projects=tag:type:pkg'], + ctx, + ), + ), step('Check CI conditions', async (ctx) => { ctx.state.shouldRun = await ciIsAffected( - 'host,host-v5,host-vue3,remote1,remote2,remote3,remote4,remote5,remote6', + 'router-host-2000,router-host-v5-2200,router-host-vue3-2100,router-remote1-2001,router-remote2-2002,router-remote3-2003,router-remote4-2004', ctx, ); }), @@ -340,7 +565,11 @@ const jobs = [ logStepSkip(ctx, 'Not affected by current changes.'); return; } - await runCommand('pnpm', ['run', 'e2e:router'], ctx); + await runCommand( + 'node', + ['tools/scripts/run-router-e2e.mjs', '--mode=dev'], + ctx, + ); }), ], }, @@ -381,7 +610,13 @@ const jobs = [ step('Install dependencies', (ctx) => runCommand('pnpm', ['install', '--frozen-lockfile'], ctx), ), - step('Build shared packages', (ctx) => runPackagesBuild(ctx)), + step('Build shared packages', (ctx) => + runCommand( + 'npx', + ['nx', 'run-many', '--targets=build', '--projects=tag:type:pkg'], + ctx, + ), + ), step('Check CI conditions', async (ctx) => { ctx.state.shouldRun = await ciIsAffected(ctx.env.METRO_APP_NAME, ctx); }), @@ -419,7 +654,13 @@ const jobs = [ step('Install dependencies', (ctx) => runCommand('pnpm', ['install', '--frozen-lockfile'], ctx), ), - step('Build shared packages', (ctx) => runPackagesBuild(ctx)), + step('Build shared packages', (ctx) => + runCommand( + 'npx', + ['nx', 'run-many', '--targets=build', '--projects=tag:type:pkg'], + ctx, + ), + ), step('Check CI conditions', async (ctx) => { ctx.state.shouldRun = await ciIsAffected(ctx.env.METRO_APP_NAME, ctx); }), @@ -461,10 +702,22 @@ const jobs = [ name: 'e2e-shared-tree-shaking', env: { SKIP_DEVTOOLS_POSTINSTALL: 'true' }, steps: [ - setupE2E(), + step('Install dependencies', (ctx) => + runCommand('pnpm', ['install', '--frozen-lockfile'], ctx), + ), + step('Install Cypress', (ctx) => + runCommand('npx', ['cypress', 'install'], ctx), + ), + step('Build packages', (ctx) => + runCommand( + 'npx', + ['nx', 'run-many', '--targets=build', '--projects=tag:type:pkg'], + ctx, + ), + ), step('Check CI conditions', async (ctx) => { ctx.state.shouldRun = await ciIsAffected( - 'shared-tree-shaking-no-server-host,shared-tree-shaking-no-server-provider,shared-tree-shaking-with-server-host,shared-tree-shaking-with-server-provider', + 'shared-tree-shaking-with-server-host', ctx, ); }), @@ -473,9 +726,8 @@ const jobs = [ logStepSkip(ctx, 'Not affected by current changes.'); return; } - await runCommand( - 'pnpm', - ['run', 'e2e:shared-tree-shaking:runtime-infer'], + await runShell( + 'npx kill-port --port 3001,3002 && npx nx run shared-tree-shaking-no-server-host:test:e2e && lsof -ti tcp:3001,3002 | xargs kill', ctx, ); }), @@ -484,9 +736,8 @@ const jobs = [ logStepSkip(ctx, 'Not affected by current changes.'); return; } - await runCommand( - 'pnpm', - ['run', 'e2e:shared-tree-shaking:server-calc'], + await runShell( + 'npx kill-port --port 3001,3002,3003 && npx nx run shared-tree-shaking-with-server-host:test:e2e && lsof -ti tcp:3001,3002,3003 | xargs kill', ctx, ); }), @@ -505,15 +756,27 @@ const jobs = [ step('Install Cypress', (ctx) => runCommand('npx', ['cypress', 'install'], ctx), ), - step('Build packages', (ctx) => runPackagesBuild(ctx)), + step('Build packages', (ctx) => + runCommand( + 'npx', + ['nx', 'run-many', '--targets=build', '--projects=tag:type:pkg'], + ctx, + ), + ), step('Install xvfb', (ctx) => runShell('sudo apt-get update && sudo apt-get install xvfb', ctx), ), step('E2E Chrome Devtools Dev', (ctx) => - runCommand('pnpm', ['run', 'e2e:devtools:dev'], ctx), + runShell( + 'npx kill-port 3009 3010 3011 3012 3013 4001 && pnpm run app:manifest:dev & echo "done" && npx wait-on tcp:3009 tcp:3010 tcp:3011 tcp:3012 tcp:3013 && sleep 10 && npx nx e2e:devtools chrome-devtools', + ctx, + ), ), step('E2E Chrome Devtools Prod', (ctx) => - runCommand('pnpm', ['run', 'e2e:devtools:prod'], ctx), + runShell( + 'npx kill-port 3009 3010 3011 3012 3013 4001 && npx kill-port 3009 3010 3011 3012 3013 4001 && pnpm run app:manifest:prod & echo "done" && npx wait-on tcp:3009 tcp:3010 tcp:3011 tcp:3012 tcp:3013 && sleep 30 && npx nx e2e:devtools chrome-devtools', + ctx, + ), ), step('Kill devtools ports', (ctx) => runShell('npx kill-port 3013 3009 3010 3011 3012 4001 || true', ctx), @@ -531,7 +794,20 @@ const jobs = [ step('Install dependencies', (ctx) => runCommand('pnpm', ['install', '--frozen-lockfile'], ctx), ), - step('Build packages (current)', (ctx) => runPackagesBuild(ctx)), + step('Build packages (current)', (ctx) => + runCommand( + 'npx', + [ + 'nx', + 'run-many', + '--targets=build', + '--projects=tag:type:pkg', + '--parallel=4', + '--skip-nx-cache', + ], + ctx, + ), + ), step('Measure bundle sizes (current)', (ctx) => runCommand( 'node', @@ -541,23 +817,14 @@ const jobs = [ ), step('Prepare base worktree', async (ctx) => { const baseRef = process.env.CI_LOCAL_BASE_REF ?? 'origin/main'; - const localBaseRef = baseRef.startsWith('origin/') - ? baseRef.slice('origin/'.length) - : baseRef; const basePath = join(ROOT, `.ci-local-base-${Date.now()}`); if (existsSync(basePath)) { throw new Error(`Base worktree path already exists: ${basePath}`); } ctx.state.baseRef = baseRef; - ctx.state.localBaseRef = localBaseRef; ctx.state.basePath = basePath; console.log(`[ci:local] Using base ref ${baseRef}`); await runCommand('git', ['worktree', 'add', basePath, baseRef], ctx); - await runCommand( - 'git', - ['-C', basePath, 'branch', '-f', localBaseRef, baseRef], - ctx, - ); }), step('Install dependencies (base)', (ctx) => runCommand('pnpm', ['install', '--frozen-lockfile'], { @@ -565,7 +832,23 @@ const jobs = [ cwd: ctx.state.basePath, }), ), - step('Build packages (base)', (ctx) => runBasePackagesBuild(ctx)), + step('Build packages (base)', (ctx) => + runCommand( + 'npx', + [ + 'nx', + 'run-many', + '--targets=build', + '--projects=tag:type:pkg', + '--parallel=4', + '--skip-nx-cache', + ], + { + ...ctx, + cwd: ctx.state.basePath, + }, + ), + ), step('Measure bundle sizes (base)', (ctx) => runCommand( 'node', @@ -633,9 +916,6 @@ async function main() { return; } preflight(); - if (args.skipCache) { - console.log('[ci:local] Task cache bypass enabled (--skip-cache).'); - } if (args.printParity) { printParity(); return; @@ -645,58 +925,6 @@ async function main() { } } -async function runBasePackagesBuild(ctx) { - await runPackagesBuildAtPath(ctx.state.basePath, { - ...ctx, - cwd: ctx.state.basePath, - }); -} - -async function runPackagesBuild(ctx) { - await runPackagesBuildAtPath(ctx.cwd ?? ROOT, ctx); -} - -async function runPackagesBuildAtPath(targetPath, ctx) { - const targetCtx = { ...ctx, cwd: targetPath }; - const rootPackageJsonPath = join(targetPath, 'package.json'); - let basePackageJson = null; - try { - basePackageJson = JSON.parse(readFileSync(rootPackageJsonPath, 'utf-8')); - } catch { - basePackageJson = null; - } - - const buildPackagesScript = basePackageJson?.scripts?.['build:packages']; - if ( - typeof buildPackagesScript === 'string' && - buildPackagesScript.trim() && - !args.skipCache - ) { - await runCommand('pnpm', ['run', 'build:packages'], targetCtx); - return; - } - - if (existsSync(join(targetPath, 'turbo.json'))) { - const turboArgs = [ - 'exec', - 'turbo', - 'run', - 'build', - '--filter=./packages/**', - '--concurrency=20', - ]; - if (args.skipCache) { - turboArgs.push('--force'); - } - await runCommand('pnpm', turboArgs, targetCtx); - return; - } - - throw new Error( - '[ci:local] No turbo.json found for package builds. ci-local expects Turbo-managed package builds.', - ); -} - function preflight() { const nodeMajor = Number(process.versions.node.split('.')[0]); const parityIssues = []; @@ -897,9 +1125,6 @@ function printHelp() { console.log( ' --strict-parity Fail when node/pnpm parity is mismatched', ); - console.log( - ' --skip-cache Bypass Turbo task caches for supported ci-local steps', - ); console.log(' --help Show this help message'); console.log(''); console.log('Examples:'); @@ -912,9 +1137,6 @@ function printHelp() { console.log( ' node tools/scripts/ci-local.mjs --strict-parity --only=build-and-test', ); - console.log( - ' node tools/scripts/ci-local.mjs --skip-cache --only=build-and-test', - ); } function parseArgs(argv) { @@ -924,7 +1146,6 @@ function parseArgs(argv) { only: null, onlyTokens: [], printParity: false, - skipCache: false, strictParity: false, errors: [], unknownArgs: [], @@ -957,10 +1178,6 @@ function parseArgs(argv) { result.printParity = true; continue; } - if (arg === '--skip-cache') { - result.skipCache = true; - continue; - } if (arg === '--strict-parity') { result.strictParity = true; continue; @@ -1034,34 +1251,10 @@ function getSelectableJobNames(jobList) { return names; } -async function runIfAffected(ctx, affectedAppName, run) { - const shouldRun = await ciIsAffected(affectedAppName, ctx); - if (!shouldRun) { - logStepSkip(ctx, 'Not affected by current changes.'); - return; - } - await run(); -} - -async function runChangedPackageTests(ctx) { - const commandArgs = ['tools/scripts/run-affected-package-tests.mjs']; - if (args.skipCache) { - commandArgs.push('--skip-cache'); - } - await runCommand('node', commandArgs, { - ...ctx, - env: { - ...ctx.env, - CI_BASE_REF: process.env.CI_LOCAL_BASE_REF ?? '', - }, - }); -} - function runCommand(command, args = [], options = {}) { const { env = {}, cwd, allowFailure = false } = options; - const resolvedArgs = applyCacheBypassArgs(command, args); - const child = spawn(command, resolvedArgs, { + const child = spawn(command, args, { stdio: 'inherit', env, cwd, @@ -1079,7 +1272,7 @@ function runCommand(command, args = [], options = {}) { } reject( new Error( - `${command} ${resolvedArgs.join(' ')} exited with ${formatExit({ + `${command} ${args.join(' ')} exited with ${formatExit({ code, signal, })}`, @@ -1090,21 +1283,6 @@ function runCommand(command, args = [], options = {}) { }); } -function applyCacheBypassArgs(command, commandArgs) { - if (!args.skipCache) { - return commandArgs; - } - if ( - command === 'pnpm' && - commandArgs[0] === 'exec' && - commandArgs[1] === 'turbo' && - !commandArgs.includes('--force') - ) { - return [...commandArgs, '--force']; - } - return commandArgs; -} - function runShell(command, options = {}) { return runCommand('bash', ['-lc', command], options); } @@ -1210,6 +1388,30 @@ function resolveExpectedPnpmVersion(packageJson) { return null; } +async function runWithRetry({ label, attempts, run }) { + let lastError; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + await run(); + return; + } catch (error) { + lastError = error; + if (attempt === attempts) { + throw error; + } + console.warn( + `[ci:local] ${label} failed on attempt ${attempt}/${attempts}: ${error.message}`, + ); + await sleep(2000); + } + } + throw lastError; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + async function ciIsAffected(appName, ctx) { const result = await runCommand( 'node', diff --git a/tools/scripts/verify-rslib-harness-coverage.mjs b/tools/scripts/verify-rslib-harness-coverage.mjs new file mode 100644 index 00000000000..38f65c8d938 --- /dev/null +++ b/tools/scripts/verify-rslib-harness-coverage.mjs @@ -0,0 +1,94 @@ +#!/usr/bin/env node +import { existsSync, realpathSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import fg from 'fast-glob'; +import { resolveProjects } from '../../scripts/rslib-harness.mjs'; + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(SCRIPT_DIR, '../..'); +const HARNESS_CONFIG_PATH = join(ROOT, 'rslib.harness.config.mjs'); +const RSLIB_CONFIG_GLOBS = [ + 'packages/*/rslib.config.{mjs,ts,js,cjs,mts,cts}', + 'apps/**/rslib.config.{mjs,ts,js,cjs,mts,cts}', +]; +const EXPECTED_MIN_CONFIGS = Number.parseInt( + process.env.MIN_EXPECTED_RSLIB_HARNESS_PROJECTS ?? '20', + 10, +); + +function getCanonicalPath(value) { + try { + return realpathSync(value); + } catch { + return resolve(value); + } +} + +async function main() { + process.chdir(ROOT); + + if (!existsSync(HARNESS_CONFIG_PATH)) { + throw new Error(`Harness config not found at ${HARNESS_CONFIG_PATH}`); + } + + const expectedConfigs = fg + .sync(RSLIB_CONFIG_GLOBS, { + cwd: ROOT, + absolute: true, + dot: true, + onlyFiles: true, + unique: true, + followSymbolicLinks: false, + ignore: ['**/node_modules/**', '**/dist/**'], + }) + .map(getCanonicalPath) + .sort((a, b) => a.localeCompare(b)); + + if ( + Number.isFinite(EXPECTED_MIN_CONFIGS) && + EXPECTED_MIN_CONFIGS > 0 && + expectedConfigs.length < EXPECTED_MIN_CONFIGS + ) { + throw new Error( + `Expected at least ${EXPECTED_MIN_CONFIGS} rslib configs, found ${expectedConfigs.length}.`, + ); + } + + const resolvedProjects = await resolveProjects({ + harnessConfigPath: HARNESS_CONFIG_PATH, + rootDir: ROOT, + projectFilters: [], + }); + + const resolvedConfigSet = new Set( + resolvedProjects + .map((project) => project.configFile) + .filter(Boolean) + .map(getCanonicalPath), + ); + + const missingConfigs = expectedConfigs.filter( + (configPath) => !resolvedConfigSet.has(configPath), + ); + + if (missingConfigs.length > 0) { + const prettyMissing = missingConfigs + .map((configPath) => `- ${configPath.replace(`${ROOT}/`, '')}`) + .join('\n'); + throw new Error( + `Harness is missing ${missingConfigs.length} rslib config(s):\n${prettyMissing}`, + ); + } + + console.log( + `[verify-rslib-harness-coverage] Verified ${resolvedProjects.length} harness projects covering ${expectedConfigs.length} rslib config(s).`, + ); +} + +main().catch((error) => { + console.error( + `[verify-rslib-harness-coverage] ${error instanceof Error ? error.message : error}`, + ); + process.exit(1); +}); diff --git a/tools/scripts/verify-rslib-harness-workflow-coverage.mjs b/tools/scripts/verify-rslib-harness-workflow-coverage.mjs new file mode 100644 index 00000000000..a296a3418ed --- /dev/null +++ b/tools/scripts/verify-rslib-harness-workflow-coverage.mjs @@ -0,0 +1,141 @@ +#!/usr/bin/env node +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import yaml from 'js-yaml'; + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(SCRIPT_DIR, '../..'); +const BUILD_AND_TEST_WORKFLOW_PATH = resolve( + ROOT, + '.github/workflows/build-and-test.yml', +); +const CI_LOCAL_PATH = resolve(ROOT, 'tools/scripts/ci-local.mjs'); +const PACKAGE_JSON_PATH = resolve(ROOT, 'package.json'); + +const REQUIRED_PACKAGE_SCRIPTS = ['test:rslib-harness', 'verify:rslib-harness']; +const REQUIRED_WORKFLOW_STEPS = [ + { + name: 'Run Rslib Harness Tests', + runPattern: /pnpm run test:rslib-harness/, + }, + { + name: 'Verify Rslib Harness Coverage', + runPattern: /pnpm run verify:rslib-harness/, + }, +]; + +function fail(message) { + console.error(`[verify-rslib-harness-workflow-coverage] ${message}`); + process.exit(1); +} + +function readYaml(path) { + try { + return yaml.load(readFileSync(path, 'utf8')); + } catch (error) { + fail( + `Failed to read/parse YAML file ${path}: ${ + error instanceof Error ? error.message : error + }`, + ); + } +} + +function readJson(path) { + try { + return JSON.parse(readFileSync(path, 'utf8')); + } catch (error) { + fail( + `Failed to read/parse JSON file ${path}: ${ + error instanceof Error ? error.message : error + }`, + ); + } +} + +function assertFileExists(path) { + if (!existsSync(path)) { + fail(`Required file does not exist: ${path}`); + } +} + +function assertPackageScripts(packageJson) { + const scripts = packageJson?.scripts ?? {}; + const missing = REQUIRED_PACKAGE_SCRIPTS.filter( + (scriptName) => typeof scripts[scriptName] !== 'string', + ); + + if (missing.length > 0) { + fail( + `Missing required package scripts: ${missing.join(', ')} in ${PACKAGE_JSON_PATH}`, + ); + } +} + +function assertWorkflowSteps(workflow) { + const steps = workflow?.jobs?.['checkout-install']?.steps; + if (!Array.isArray(steps)) { + fail( + `Unable to locate checkout-install steps in ${BUILD_AND_TEST_WORKFLOW_PATH}`, + ); + } + + for (const requiredStep of REQUIRED_WORKFLOW_STEPS) { + const step = steps.find( + (candidate) => candidate?.name === requiredStep.name, + ); + if (!step) { + fail( + `Missing workflow step "${requiredStep.name}" in ${BUILD_AND_TEST_WORKFLOW_PATH}`, + ); + } + + if ( + typeof step.run !== 'string' || + !requiredStep.runPattern.test(step.run) + ) { + fail( + `Workflow step "${requiredStep.name}" does not match expected run command pattern ${requiredStep.runPattern}`, + ); + } + } +} + +function assertCiLocalSteps(ciLocalText) { + for (const requiredStep of REQUIRED_WORKFLOW_STEPS) { + const escapedStepName = requiredStep.name.replace( + /[.*+?^${}()|[\]\\]/g, + '\\$&', + ); + const stepRegex = new RegExp(`step\\('${escapedStepName}'`, 'g'); + const matches = ciLocalText.match(stepRegex) ?? []; + if (matches.length < 2) { + fail( + `Expected ci-local to include step "${requiredStep.name}" in both build-and-test and build-metro jobs (found ${matches.length}).`, + ); + } + } +} + +function main() { + process.chdir(ROOT); + + assertFileExists(PACKAGE_JSON_PATH); + assertFileExists(BUILD_AND_TEST_WORKFLOW_PATH); + assertFileExists(CI_LOCAL_PATH); + + const packageJson = readJson(PACKAGE_JSON_PATH); + const workflow = readYaml(BUILD_AND_TEST_WORKFLOW_PATH); + const ciLocalText = readFileSync(CI_LOCAL_PATH, 'utf8'); + + assertPackageScripts(packageJson); + assertWorkflowSteps(workflow); + assertCiLocalSteps(ciLocalText); + + console.log( + '[verify-rslib-harness-workflow-coverage] Verified harness checks in package scripts, GitHub workflow, and ci-local jobs.', + ); +} + +main();