From fee8b791f0deda4dc7a633e106f349a443ddf22f Mon Sep 17 00:00:00 2001 From: sooniter Date: Tue, 20 Oct 2026 17:30:11 +0800 Subject: [PATCH 1/2] fix: scope Rsbuild package discovery to publishable packages --- utils.test.ts | 107 ++++++++++++++++++++++++++++++++++++++++++++++++++ utils.ts | 79 +++++++++++++++++++++++++++---------- 2 files changed, 165 insertions(+), 21 deletions(-) create mode 100644 utils.test.ts diff --git a/utils.test.ts b/utils.test.ts new file mode 100644 index 00000000..7b7f7075 --- /dev/null +++ b/utils.test.ts @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { test, type TestContext } from 'node:test'; +import { getPackages } from '@manypkg/get-packages'; +import { discoverMonorepoPackages } from './utils'; + +async function fixture(t: TestContext) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'eco-ci-packages-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const write = async (file: string, content: string) => { + await fs.mkdir(path.dirname(path.join(root, file)), { recursive: true }); + await fs.writeFile(path.join(root, file), content); + }; + const manifest = (dir: string, data: object) => + write(path.join(dir, 'package.json'), JSON.stringify(data)); + await manifest('.', { name: 'monorepo', private: true }); + await write( + 'pnpm-workspace.yaml', + 'packages:\n - packages/*\n - e2e/**\n - tools/*\n', + ); + await manifest('packages/core', { name: '@rsbuild/core', version: '1.0.0' }); + return { root, write, manifest }; +} + +test('Rsbuild discovers publishable packages before validating workspace fixtures', async (t) => { + const { root, manifest, write } = await fixture(t); + await manifest('packages/create-rsbuild', { name: 'create-rsbuild' }); + await manifest('packages/private', { private: true }); + await manifest('packages/create-rsbuild/template', { name: 'template' }); + await manifest('e2e/browserslist', { private: true }); + await manifest('e2e/auto-external', { name: '@e2e/auto-external' }); + await manifest('tools/helper', { name: 'helper' }); + await write('packages/README.md', 'Package documentation'); + await write('packages/no-manifest/README.md', 'Not a package'); + await assert.rejects(getPackages(root), /missing the "name" field/); + + const expected = [ + { name: '@rsbuild/core', directory: path.join(root, 'packages/core') }, + { + name: 'create-rsbuild', + directory: path.join(root, 'packages/create-rsbuild'), + }, + ]; + assert.deepEqual(await discoverMonorepoPackages('rsbuild', root), expected); + + // The old root workspaces field must not change the selected package set. + await manifest('.', { + name: 'monorepo', + private: true, + workspaces: ['packages/*'], + }); + assert.deepEqual(await discoverMonorepoPackages('rsbuild', root), expected); +}); + +for (const stack of ['rstest', 'rslib', 'rsdoctor', 'rspress'] as const) { + test(`${stack} retains workspace discovery outside packages/`, async (t) => { + const { root, manifest } = await fixture(t); + await manifest('tools/helper', { name: 'helper' }); + await manifest('e2e/private', { name: 'private-fixture', private: true }); + const { packages } = await getPackages(root); + const expected = packages + .filter((pkg) => pkg.packageJson.name && !pkg.packageJson.private) + .map((pkg) => ({ name: pkg.packageJson.name, directory: pkg.dir })); + assert(expected.some((pkg) => pkg.name === 'helper')); + assert.deepEqual(await discoverMonorepoPackages(stack, root), expected); + + await manifest('e2e/missing-name', { private: true }); + await assert.rejects( + discoverMonorepoPackages(stack, root), + /missing the "name" field/, + ); + }); +} + +test('Rsbuild rejects a missing public package name', async (t) => { + const { root, manifest } = await fixture(t); + await manifest('packages/core', { version: '1.0.0' }); + await assert.rejects( + discoverMonorepoPackages('rsbuild', root), + /Missing package name.*packages[/\\]core[/\\]package.json/, + ); +}); + +test('Rsbuild rejects duplicate public package names', async (t) => { + const { root, manifest } = await fixture(t); + await manifest('packages/duplicate', { name: '@rsbuild/core' }); + await assert.rejects( + discoverMonorepoPackages('rsbuild', root), + /Duplicate package name "@rsbuild\/core"/, + ); +}); + +test('Rsbuild propagates malformed package manifests', async (t) => { + const { root, write } = await fixture(t); + await write('packages/core/package.json', '{ broken'); + await assert.rejects(discoverMonorepoPackages('rsbuild', root), SyntaxError); +}); + +test('Rsbuild rejects a missing package directory', async (t) => { + const { root } = await fixture(t); + await fs.rm(path.join(root, 'packages'), { recursive: true }); + await assert.rejects(discoverMonorepoPackages('rsbuild', root), { + code: 'ENOENT', + }); +}); diff --git a/utils.ts b/utils.ts index ea854267..b162a3c5 100644 --- a/utils.ts +++ b/utils.ts @@ -54,13 +54,19 @@ let workspaceRoot: string; let cwd: string; let env: NodeJS.ProcessEnv; +type MonorepoStack = 'rsbuild' | 'rstest' | 'rslib' | 'rsdoctor' | 'rspress'; + const monorepoPackagesCache: Partial< - Record< - 'rsbuild' | 'rstest' | 'rslib' | 'rsdoctor' | 'rspress', - { name: string; directory: string }[] - > + Record > = {}; +// Rsbuild publishes packages/*, but its pnpm workspace also includes E2E +// fixtures that are not publishable packages (some intentionally have no name). +// Other stacks retain workspace discovery until their publish scope is verified. +const STACK_PACKAGE_DIR: Partial> = { + rsbuild: 'packages', +}; + interface RspackPackageInfo { name: string; directory: string; @@ -349,28 +355,59 @@ function toCommand( }; } -async function getMonorepoPackages( - stack: 'rsbuild' | 'rstest' | 'rslib' | 'rsdoctor' | 'rspress', +export async function discoverMonorepoPackages( + stack: MonorepoStack, + root: string, ) { + const packageDir = STACK_PACKAGE_DIR[stack]; + if (packageDir) { + const entries = await fs.promises.readdir(path.join(root, packageDir), { + withFileTypes: true, + }); + const packages: { name: string; directory: string }[] = []; + const names = new Set(); + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + const directory = path.join(root, packageDir, entry.name); + const manifest = path.join(directory, 'package.json'); + if (!fs.existsSync(manifest)) { + continue; + } + const { name, private: isPrivate } = JSON.parse( + await fs.promises.readFile(manifest, 'utf8'), + ); + if (isPrivate) { + continue; + } + assert( + typeof name === 'string' && name.trim(), + `Missing package name in ${manifest}`, + ); + assert( + !names.has(name), + `Duplicate package name "${name}" in ${manifest}`, + ); + names.add(name); + packages.push({ name, directory }); + } + return packages; + } + + const { packages } = await getPackages(root); + return packages.flatMap((pkg) => { + const { name, private: isPrivate } = pkg.packageJson; + return name && !isPrivate ? [{ name, directory: pkg.dir }] : []; + }); +} + +async function getMonorepoPackages(stack: MonorepoStack) { const cached = monorepoPackagesCache[stack]; if (cached) { return cached; } - const packages = await getPackages(stackPath); - const packageList = packages.packages.flatMap((pkg) => { - const { name, private: isPrivate } = pkg.packageJson; - - if (!name || isPrivate) { - return []; - } - - return [ - { - name, - directory: pkg.dir, - }, - ]; - }); + const packageList = await discoverMonorepoPackages(stack, stackPath); monorepoPackagesCache[stack] = packageList; return packageList; } From 9254c1eb6b2327b82b7763e3314033decd6798a1 Mon Sep 17 00:00:00 2001 From: sooniter Date: Tue, 20 Oct 2026 17:34:37 +0800 Subject: [PATCH 2/2] chore: remove package discovery regression tests --- utils.test.ts | 107 -------------------------------------------------- 1 file changed, 107 deletions(-) delete mode 100644 utils.test.ts diff --git a/utils.test.ts b/utils.test.ts deleted file mode 100644 index 7b7f7075..00000000 --- a/utils.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { test, type TestContext } from 'node:test'; -import { getPackages } from '@manypkg/get-packages'; -import { discoverMonorepoPackages } from './utils'; - -async function fixture(t: TestContext) { - const root = await fs.mkdtemp(path.join(os.tmpdir(), 'eco-ci-packages-')); - t.after(() => fs.rm(root, { recursive: true, force: true })); - const write = async (file: string, content: string) => { - await fs.mkdir(path.dirname(path.join(root, file)), { recursive: true }); - await fs.writeFile(path.join(root, file), content); - }; - const manifest = (dir: string, data: object) => - write(path.join(dir, 'package.json'), JSON.stringify(data)); - await manifest('.', { name: 'monorepo', private: true }); - await write( - 'pnpm-workspace.yaml', - 'packages:\n - packages/*\n - e2e/**\n - tools/*\n', - ); - await manifest('packages/core', { name: '@rsbuild/core', version: '1.0.0' }); - return { root, write, manifest }; -} - -test('Rsbuild discovers publishable packages before validating workspace fixtures', async (t) => { - const { root, manifest, write } = await fixture(t); - await manifest('packages/create-rsbuild', { name: 'create-rsbuild' }); - await manifest('packages/private', { private: true }); - await manifest('packages/create-rsbuild/template', { name: 'template' }); - await manifest('e2e/browserslist', { private: true }); - await manifest('e2e/auto-external', { name: '@e2e/auto-external' }); - await manifest('tools/helper', { name: 'helper' }); - await write('packages/README.md', 'Package documentation'); - await write('packages/no-manifest/README.md', 'Not a package'); - await assert.rejects(getPackages(root), /missing the "name" field/); - - const expected = [ - { name: '@rsbuild/core', directory: path.join(root, 'packages/core') }, - { - name: 'create-rsbuild', - directory: path.join(root, 'packages/create-rsbuild'), - }, - ]; - assert.deepEqual(await discoverMonorepoPackages('rsbuild', root), expected); - - // The old root workspaces field must not change the selected package set. - await manifest('.', { - name: 'monorepo', - private: true, - workspaces: ['packages/*'], - }); - assert.deepEqual(await discoverMonorepoPackages('rsbuild', root), expected); -}); - -for (const stack of ['rstest', 'rslib', 'rsdoctor', 'rspress'] as const) { - test(`${stack} retains workspace discovery outside packages/`, async (t) => { - const { root, manifest } = await fixture(t); - await manifest('tools/helper', { name: 'helper' }); - await manifest('e2e/private', { name: 'private-fixture', private: true }); - const { packages } = await getPackages(root); - const expected = packages - .filter((pkg) => pkg.packageJson.name && !pkg.packageJson.private) - .map((pkg) => ({ name: pkg.packageJson.name, directory: pkg.dir })); - assert(expected.some((pkg) => pkg.name === 'helper')); - assert.deepEqual(await discoverMonorepoPackages(stack, root), expected); - - await manifest('e2e/missing-name', { private: true }); - await assert.rejects( - discoverMonorepoPackages(stack, root), - /missing the "name" field/, - ); - }); -} - -test('Rsbuild rejects a missing public package name', async (t) => { - const { root, manifest } = await fixture(t); - await manifest('packages/core', { version: '1.0.0' }); - await assert.rejects( - discoverMonorepoPackages('rsbuild', root), - /Missing package name.*packages[/\\]core[/\\]package.json/, - ); -}); - -test('Rsbuild rejects duplicate public package names', async (t) => { - const { root, manifest } = await fixture(t); - await manifest('packages/duplicate', { name: '@rsbuild/core' }); - await assert.rejects( - discoverMonorepoPackages('rsbuild', root), - /Duplicate package name "@rsbuild\/core"/, - ); -}); - -test('Rsbuild propagates malformed package manifests', async (t) => { - const { root, write } = await fixture(t); - await write('packages/core/package.json', '{ broken'); - await assert.rejects(discoverMonorepoPackages('rsbuild', root), SyntaxError); -}); - -test('Rsbuild rejects a missing package directory', async (t) => { - const { root } = await fixture(t); - await fs.rm(path.join(root, 'packages'), { recursive: true }); - await assert.rejects(discoverMonorepoPackages('rsbuild', root), { - code: 'ENOENT', - }); -});