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
87 changes: 86 additions & 1 deletion .github/contributing/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,85 @@ plain bash, runs in about a second, and CI runs it before anything else.
pnpm run test:workflows
```

A third covers the module itself — `test/module/` boots Nuxt with `loadNuxt` so
the module's real `setup()` runs. It has its own config and its own invocation
rather than being a third project in `vitest.config.ts`; the reason is measured
and written down in `vitest.module.config.ts`.

```bash
pnpm run test:module
```

And a fourth, which is the only thing here that starts an application:

```bash
pnpm build && pnpm test:smoke
```

`test/smoke/run.mjs` builds a small Nuxt app and the Vue playground **against
the built package**, serves both, loads them in Chromium and fails on anything
the browser logs as an error. Everything else in this repository tests source;
this is the step that would have caught #301, a client-only boot failure that
shipped and left the unit suite green for five weeks.

The `pnpm build` is not optional. `pnpm dev:prepare` leaves `dist/` as a jiti
stub that re-exports `src/`, so a smoke run against it would boot the sources
under a different name — the script detects that and refuses rather than
passing quietly. It also needs a browser once:

```bash
pnpm exec playwright-core install chromium
```

CI runs that same command with `--with-deps`, which installs the system
libraries Chromium needs through apt. Locally that is usually unnecessary and
wants sudo, so it is left off here — add it if the browser fails to launch.

**It is not part of `ci.yml`.** `.github/workflows/smoke.yml` runs it nightly
and on `workflow_dispatch`, so a boot failure is found the morning after it
lands rather than before it merges. That is a deliberate trade against putting
a browser download and two application builds on every pull request. Dispatch
it on your branch by hand if you touched a runtime plugin, the module's
`setup()`, or a dependency that ends up in the client bundle.

Two things it asserts that nothing else can:

- **the `platform` plugin's SSR branch** — `useRequestHeader('user-agent')`
never runs under Vitest, because the Nuxt test environment is client-only.
The smoke run fetches the page with three user agents and checks the
`data-platform` / `data-version` attributes the Tailwind `bitrix-mobile:` and
`bitrix-desktop:` variants key on;
- **that the page rendered anything at all** — a Vue app that throws in
`setup()` still answers 200 with an empty root, so `curl` cannot tell a
working app from a dead one and a browser can.

## When a worker dies instead of failing

```
FATAL ERROR: Ineffective mark-compacts near heap limit
Error: [vitest-pool]: Worker forks emitted error.
Caused by: Error: Worker exited unexpectedly
```

Vitest cannot name the file in this path — the process is gone — so the failure
reads as a property of the whole suite. It usually is not. Halve the heap
first, before doing anything else:

```bash
NODE_OPTIONS=--max-old-space-size=1024 pnpm run test --project vue
```

If the same number of files passes with an eight-times-smaller heap, nothing is
accumulating across files and **exactly one file** is responsible. Name it by
diffing what reported against what was collected:

```bash
NODE_OPTIONS=--max-old-space-size=1024 npx vitest run --project vue --reporter=verbose
```

That is how #485 was found — a snapshot guard whose directory walk descended
into `node_modules` — after an afternoon spent believing it was a vitest leak.

## File Location

Tests live in `test/components/` matching the component name (e.g., `Button.spec.ts`).
Expand Down Expand Up @@ -196,9 +275,15 @@ When component changes require snapshot updates:
## Running Tests

```bash
# Run all tests
# Run all component tests (the `nuxt` and `vue` projects)
pnpm run test

# The module's own setup(), in its own process
pnpm run test:module

# Boot the built package in a browser
pnpm build && pnpm run test:smoke

# Run specific test file
pnpm run test Button

Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -117,5 +117,14 @@ jobs:
- name: Test
run: pnpm run test run

# A separate invocation, not a third project in the run above. These
# specs boot Nuxt with `loadNuxt`, and the component suite already sits
# close to the fork heap limit — see the note in vitest.module.config.ts.
- name: Test the module
run: pnpm test:module

# Everything in this job tests source: `Test` mounts components out of
# `src/` and `Build` only proves the bundler is happy. Nothing here starts
# an application — that is smoke.yml, on a schedule (#329).
- name: Build
run: pnpm build
99 changes: 99 additions & 0 deletions .github/workflows/smoke.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
name: Smoke 🔥

# Boots the built package in a real browser.
#
# ci.yml runs lint, typecheck, tests and build, and not one of those steps ever
# starts an application. #301 was a client-only boot failure: the unit suite
# stayed green for five weeks while the published package could not start an
# SPA. This job is the missing step (#329) — build the package, put a Nuxt app
# and a Vue SPA on top of it, load both in Chromium, fail on anything the
# browser logs as an error.
#
# Nightly rather than per-PR, on the maintainer's call. The per-PR version
# would catch a boot failure before it merges, but it puts a browser download
# and two application builds on every typo fix; a library this size does not
# change its runtime plugins often enough to pay that every time. The cost of
# the choice is real and worth naming: a boot failure is found the morning
# after it lands, not before, so `workflow_dispatch` is here for anyone who
# touches a runtime plugin, the module's `setup()`, or a dependency that ends
# up in the client bundle — run it on the branch before merging.
#
# An hour after the release watchdog, so a red morning has one obvious order to
# read it in.
on:
schedule:
- cron: '0 10 * * *'
workflow_dispatch:

permissions:
contents: read

concurrency:
group: smoke-${{ github.ref }}
cancel-in-progress: true

jobs:
smoke:
runs-on: ubuntu-latest
# Nobody is watching a nightly run. Two application builds and two page
# loads take about five minutes; anything past twenty is hung, and the
# GitHub default would let it sit for six hours before saying so.
timeout-minutes: 20
env:
pnpm_config_strict_dep_builds: false
pnpm_config_verify_deps_before_run: false
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Install pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10

- name: Install node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
cache: pnpm

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Prepare
run: pnpm run dev:prepare

# Not optional, and asserted by the script rather than assumed:
# `dev:prepare` above leaves `dist/` as a jiti stub pointing back at
# `src/`, so a smoke run against it would boot the sources under a
# different name and report success for a package nobody built.
- name: Build
run: pnpm build

- name: Resolve the pinned Playwright version
id: playwright
run: echo "version=$(node -p "require('./package.json').devDependencies['playwright-core']")" >> "$GITHUB_OUTPUT"

# Keyed on the pinned version rather than the lockfile, so an unrelated
# dependency bump does not re-download 130 MB of browser. `install` still
# runs on a hit: it is a no-op for an already-present revision, and it is
# what installs the system packages, which are not cached.
- name: Restore the Playwright browser
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.playwright.outputs.version }}

# `pnpm exec`, not `npx`: `npx` fetches from the registry when it cannot
# resolve a binary locally, which would quietly undo the pinned
# `playwright-core` and the frozen lockfile ci.yml's guards assert.
#
# `--with-deps` runs apt through passwordless sudo, which every
# GitHub-hosted runner grants its own user. That is unrelated to
# `GITHUB_TOKEN` and does not widen the `permissions:` block above — it is
# the runner VM's own privileges, on a machine thrown away after the job.
- name: Install Chromium
run: pnpm exec playwright-core install --with-deps chromium

- name: Smoke test the built package
run: pnpm test:smoke
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,9 @@
"test:vue": "vitest --project vue",
"test:nuxt": "vitest --project nuxt",
"bench": "vitest bench --project vue",
"test:workflows": "test/workflows/run.sh"
"test:workflows": "test/workflows/run.sh",
"test:module": "vitest run --config vitest.module.config.ts",
"test:smoke": "node test/smoke/run.mjs"
},
"dependencies": {
"@floating-ui/dom": "^1.8.0",
Expand Down Expand Up @@ -224,6 +226,7 @@
"eslint": "^10.9.0",
"happy-dom": "^20.11.6",
"nuxt": "^4.5.2",
"playwright-core": "1.56.1",
"unbuild": "^3.6.1",
"vitest": "^4.1.11",
"vitest-axe": "^0.1.0",
Expand Down
17 changes: 17 additions & 0 deletions playgrounds/vue/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,23 @@ export default defineConfig(({ mode }) => {
}
}
],
resolve: {
// b24ui's `Link` renders vue-router's own `RouterLink`, which reads the
// router through `inject(routerKey)`. `routerKey` is a module-level
// Symbol, so two copies of vue-router in one bundle are two different
// keys and the injection returns `undefined` — `RouterLink` then throws
// `Cannot destructure property 'options'` on first render.
//
// That is the state this workspace is in: the playground depends on
// vue-router 5.2.0, while the repo root gets 5.1.0 hoisted from nuxt, and
// the b24ui runtime resolves from the root. Both ended up in the bundle
// and the SPA threw on boot. Not visible to any unit test, and not
// visible to a consumer either — the package declares vue-router as an
// optional peer, so an installed copy is shared — which is exactly why it
// survived here until something loaded the page in a browser (#329).
dedupe: ['vue', 'vue-router']
},

server: {
// Fix: "Blocked request. This host is not allowed" when using tunnels like ngrok
allowedHosts: [...extraAllowedHosts]
Expand Down
Loading