Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/expected-repository-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ A repository that deviates from these rules is fixed manually (or by re-running
- Slidev decks are named `*.slidev.md`. wbfy installs `slidev-check` wherever such a deck exists, and `wb verify --full` runs `slidev-check` on every deck it finds (both sides skip the same ignored directories, so a fixture or built deck is neither counted nor audited). A repository holding a deck declares `@slidev/cli` itself — that declaration is what keeps its install project-local, which Slidev requires — and has Playwright's Chromium available, which `slidev-check` renders the deck in.
- Both `wb slidev-check` and `wb verify --full` run wb's bundled textlint rules before rendering each deck, including Markdown slides loaded through `src:`. Import paths must stay within the workspace root or deck directory; Slidev's parser rejects outside paths before reading them. This path check does not resolve symlink targets. Text errors fail verification; text is never automatically rewritten. The rules check half-width kana, invalid control characters, U+200B, and Chinese or Korean characters (as `@willbooster/shared-lib`'s `findForeignCjkCharactersInJapanese` detects them), without enforcing prose style or length. Frontmatter, code, and notes are outside the text checks. The Chinese or Korean check covers the rest of the slide source, including raw HTML, tag attributes, image alt text, and link URLs, except HTML comments and `<code>`, `<pre>`, `<script>`, and `<style>` elements; the other rules skip raw HTML blocks and HTML tag attributes. Slidev-specific syntax is not interpreted; Vue expressions and MDC attributes may be checked as Markdown prose. Half-width kana is checked in plain paragraph and list text, not headings or bold/italic spans; other rule-specific exclusions also apply. Local `.textlintrc` files are not loaded.
- Under `test/`, only `unit/`, `e2e/`, `debug/`, `helpers/`, and `fixtures/` may exist, and `*.test.*` / `*.spec.*` files only under the first three (fixture data under `test/fixtures/` and hidden entries are exempt); violations fail `wb lint` and `wb test`. Tree-sitter grammars with `tree-sitter.json` may also have `test/corpus/`, which their CLI requires. A non-workspace-root package with its own `playwright.config.ts` must have `test/e2e/`; a workspace root may share a root-level `playwright.config.ts` while the e2e specs live in one app package.
- Test files under `test/unit/` are independent of each other and tolerate CPU contention: `wb` runs them with `bun test --parallel` (one worker process per CPU core, each file in a fresh global), and there is no way to opt out. A file must not share a directory, port, or global state with another file, must wait for conditions instead of relying on timing, and must set an explicit timeout on a test that cannot finish well within the runner's default under load. A test that needs an exclusive resource (a rate-limited real API, a shared database, a fixed port) belongs in `test/e2e/`, whose files run one at a time.
- Test files under `test/unit/` are independent of each other and tolerate CPU contention: `wb` runs them with `bun test --parallel --no-isolate` (one worker process per CPU core, each running its files in one global and module registry, as a plain `bun test` run does), and there is no way to opt out. A file must not share a directory or port with another file, must not depend on global or module state that another file leaves behind, must restore what it changes in the global and in imported modules (spies, globals, environment variables) once its tests finish, must wait for conditions instead of relying on timing, and must set an explicit timeout on a test that cannot finish well within the runner's default under load. A test that needs an exclusive resource (a rate-limited real API, a shared database, a fixed port) belongs in `test/e2e/`, whose files run one at a time.
- `package.json` scripts follow the `wbfy`-generated set (`typecheck`, `lint`, `test`, `test/ci`, `verify`, …); optional hooks such as `build`, `gen-code`, `deploy`, `deploy:staging`, `seed`, and `docker/build/prepare` are picked up by name.
- Generated commands use one form per target kind, in every context (package scripts, lefthook, Playwright `webServer`, CI steps): package scripts are invoked as `bun run <script>`, `node_modules` bins as `bun <bin>` (e.g. `bun wb lint`, `bun lefthook install`, `bun playwright install`), and tools outside `node_modules` (mise-managed `flutter`, `poetry`, `uv`, …) bare. Because `bun <name>` runs a package script of that name in preference to a bin, no package script may share its name with an installed bin.
- `@willbooster/wb` provides `wb open-cli <URL-or-file>` and the canonical single-resource `wb wait-on [-t timeout] [-i interval] <http(s)-URL-or-tcp:[host:]port>`; repositories use these instead of the standalone packages. Production migrations invoke wb through the canonical `db-migrate` package script, which keeps `@willbooster/wb` in runtime dependencies after Docker pruning. Repositories never declare `kill-port` either: `wb kill-port <ports..>` frees arbitrary ports (code that killed ports programmatically spawns it instead of importing the package), and `wb kill-port-if-non-ci` frees the dev-server port outside CI.
Expand Down
5 changes: 4 additions & 1 deletion packages/wb/src/scripts/execution/baseScripts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,10 @@ export abstract class BaseScripts {
validateTestSelection(_project: Project, _argv: TestArgv, _forwardedArgs: string[]): void {}

testUnit(project: Project, argv: TestArgv): string {
return this.buildUnitRunnerCommand(project, argv, ['--parallel']);
// --no-isolate: in Bun 1.4.2, when the stdout/stderr writers of the global that --parallel's default isolation
// discards after each file are garbage-collected during a later spawnSync, that and every later spawnSync of the
// worker can miss its child's exit and hang until the test times out (https://github.com/oven-sh/bun/issues/34069).
return this.buildUnitRunnerCommand(project, argv, ['--parallel', '--no-isolate']);
}

/**
Expand Down
18 changes: 11 additions & 7 deletions packages/wb/test/helpers/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,29 @@ import { expect } from 'bun:test';
/**
* Builds dist/ for tests that run bin/index.js. Every such test file must call this in `beforeAll`:
* `bun test --parallel` runs files in concurrent workers, and build-ts deletes dist/ before writing,
* so an unserialized rebuild breaks another worker's CLI run. The lock serializes the builds, and
* buildIfNeeded turns every build after the first into a no-op.
* so a rebuild breaks another worker's CLI run. The lock serializes the workers, and only the first
* of a run builds: buildIfNeeded alone would rebuild for a worker whose environment an earlier test
* file changed, since workers run their files in one global.
*/
export async function buildWb(): Promise<void> {
// Only workers of one parallel run race each other, so the lock is named after that run's
// coordinator (the workers' parent), by PID plus start time since a PID alone is reused: a lock left
// by a force-killed run then never matches a later run. No waiter ever removes a lock, since
// deciding it is stale and removing it cannot be atomic.
const lockPath =
const runPathPrefix =
process.env.BUN_TEST_WORKER_ID &&
path.resolve('node_modules', '.cache', `wb-test-build-${process.ppid}-${readStartTime(process.ppid)}.lock`);
if (lockPath) await acquireLock(lockPath);
path.resolve('node_modules', '.cache', `wb-test-build-${process.ppid}-${readStartTime(process.ppid)}`);
if (runPathPrefix) await acquireLock(`${runPathPrefix}.lock`);
try {
// buildIfNeeded hashes the environment, so the per-worker IDs would make every worker rebuild.
if (runPathPrefix && fs.existsSync(`${runPathPrefix}.built`)) return;
// Leaves out the worker ID: buildIfNeeded hashes the environment, so keeping it would record a cache
// that the next run misses whenever a different worker builds first.
const { BUN_TEST_WORKER_ID: _bunWorkerId, JEST_WORKER_ID: _jestWorkerId, ...env } = process.env;
const build = spawnSync('bun', ['run', 'build'], { encoding: 'utf8', env, timeout: 60_000 });
expect(build.status, build.stdout + build.stderr).toBe(0);
if (runPathPrefix) fs.writeFileSync(`${runPathPrefix}.built`, '');
} finally {
if (lockPath) fs.rmSync(lockPath, { force: true });
if (runPathPrefix) fs.rmSync(`${runPathPrefix}.lock`, { force: true });
}
}

Expand Down
10 changes: 8 additions & 2 deletions packages/wb/test/helpers/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,20 @@ import { clearProjectCaches } from '../../src/project.js';

/**
* Creates a fixture directory that the calling test file removes in `afterAll`, so concurrent test
* files never copy fixtures over each other's directories. Call it at the test file's top level: the
* hook attaches to the scope being collected, and without --parallel all test files share this
* files never copy fixtures over each other's directories, and restores `process.env` as it was when
* the file started, which `initializeProjectDirectory` changes. Call it at the test file's top level:
* the hook attaches to the scope being collected, and the test files that one process runs share this
* module, so a hook registered here at import time would run after the first importing file only.
*/
export function createTempDir(): string {
const dirPath = fs.mkdtempSync(path.join(os.tmpdir(), 'wb-test-'));
const originalEnv = { ...process.env };
afterAll(() => {
fs.rmSync(dirPath, { force: true, recursive: true });
for (const key of Object.keys(process.env)) {
if (!(key in originalEnv)) delete process.env[key];
}
Object.assign(process.env, originalEnv);
});
return dirPath;
}
Expand Down
4 changes: 3 additions & 1 deletion packages/wb/test/unit/privateRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ import {
} from '../../src/utils/privateRegistry.js';

const tempDirPaths: string[] = [];
const originalVerdaccioToken = process.env.VERDACCIO_TOKEN;

afterEach(async () => {
mock.restore();
delete process.env.VERDACCIO_TOKEN;
if (originalVerdaccioToken === undefined) delete process.env.VERDACCIO_TOKEN;
else process.env.VERDACCIO_TOKEN = originalVerdaccioToken;
await Promise.all(tempDirPaths.splice(0).map((dirPath) => fs.promises.rm(dirPath, { recursive: true, force: true })));
});

Expand Down
Loading