diff --git a/.github/contributing/testing.md b/.github/contributing/testing.md
index 3b6edc940..fb7a9fd46 100644
--- a/.github/contributing/testing.md
+++ b/.github/contributing/testing.md
@@ -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`).
@@ -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
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 35f58dfa7..ceb2fec10 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml
new file mode 100644
index 000000000..f25f5b736
--- /dev/null
+++ b/.github/workflows/smoke.yml
@@ -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
diff --git a/package.json b/package.json
index 5d53f108d..3f0afda50 100644
--- a/package.json
+++ b/package.json
@@ -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",
@@ -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",
diff --git a/playgrounds/vue/vite.config.ts b/playgrounds/vue/vite.config.ts
index 14c236d52..8be10efd8 100644
--- a/playgrounds/vue/vite.config.ts
+++ b/playgrounds/vue/vite.config.ts
@@ -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]
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4d6e68cb3..9f90cfba7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -253,7 +253,7 @@ importers:
version: 1.0.3(@nuxt/cli@3.37.0(@nuxt/schema@4.5.2)(cac@6.7.14)(magicast@0.5.3)(supports-color@10.2.2))(@volar/typescript@2.4.28(typescript@6.0.3))(@vue/compiler-core@3.5.41)(@vue/language-core@3.3.10)(esbuild@0.27.7)(rolldown@1.2.3)(rollup@4.60.2)(typescript@6.0.3)(vite@8.2.1(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(yaml@2.9.0))(vue-tsc@3.3.11(typescript@6.0.3))(vue@3.5.41(typescript@6.0.3))
'@nuxt/test-utils':
specifier: ^4.1.0
- version: 4.1.0(@vue/test-utils@2.4.11(@vue/compiler-dom@3.5.38)(@vue/server-renderer@3.5.41)(vue@3.5.41(typescript@6.0.3)))(esbuild@0.27.7)(happy-dom@20.11.6)(magicast@0.5.3)(oxc-parser@0.140.0)(rolldown@1.2.3)(rollup@4.60.2)(typescript@6.0.3)(vite@8.2.1(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.11(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(happy-dom@20.11.6)(vite@8.2.1(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(yaml@2.9.0)))
+ version: 4.1.0(@vue/test-utils@2.4.11(@vue/compiler-dom@3.5.38)(@vue/server-renderer@3.5.41)(vue@3.5.41(typescript@6.0.3)))(esbuild@0.27.7)(happy-dom@20.11.6)(magicast@0.5.3)(oxc-parser@0.140.0)(playwright-core@1.56.1)(rolldown@1.2.3)(rollup@4.60.2)(typescript@6.0.3)(vite@8.2.1(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.11(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(happy-dom@20.11.6)(vite@8.2.1(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(yaml@2.9.0)))
'@tanstack/table-core':
specifier: ^8.21.3
version: 8.21.3
@@ -284,6 +284,9 @@ importers:
nuxt:
specifier: ^4.5.2
version: 4.5.2(c8b663152ac765ff3353ff2a71a8607e)
+ playwright-core:
+ specifier: 1.56.1
+ version: 1.56.1
typescript:
specifier: ^6.0.3
version: 6.0.3
@@ -298,7 +301,7 @@ importers:
version: 0.1.0(vitest@4.1.11(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(happy-dom@20.11.6)(vite@8.2.1(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(yaml@2.9.0)))
vitest-environment-nuxt:
specifier: ^2.0.0
- version: 2.0.0(@vue/test-utils@2.4.11(@vue/compiler-dom@3.5.38)(@vue/server-renderer@3.5.41)(vue@3.5.41(typescript@6.0.3)))(esbuild@0.27.7)(happy-dom@20.11.6)(magicast@0.5.3)(oxc-parser@0.140.0)(rolldown@1.2.3)(rollup@4.60.2)(typescript@6.0.3)(vite@8.2.1(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.11(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(happy-dom@20.11.6)(vite@8.2.1(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(yaml@2.9.0)))
+ version: 2.0.0(@vue/test-utils@2.4.11(@vue/compiler-dom@3.5.38)(@vue/server-renderer@3.5.41)(vue@3.5.41(typescript@6.0.3)))(esbuild@0.27.7)(happy-dom@20.11.6)(magicast@0.5.3)(oxc-parser@0.140.0)(playwright-core@1.56.1)(rolldown@1.2.3)(rollup@4.60.2)(typescript@6.0.3)(vite@8.2.1(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.11(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(happy-dom@20.11.6)(vite@8.2.1(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(yaml@2.9.0)))
vue:
specifier: ^3.5.41
version: 3.5.41(typescript@6.0.3)
@@ -641,6 +644,15 @@ importers:
specifier: ^3.3.11
version: 3.3.11(typescript@6.0.3)
+ test/smoke/fixture:
+ dependencies:
+ '@bitrix24/b24ui-nuxt':
+ specifier: workspace:*
+ version: link:../../..
+ nuxt:
+ specifier: ^4.5.2
+ version: 4.5.2(2b0dccac192ba6de92c699f93d6dff1a)
+
packages:
'@ai-sdk/deepseek@3.0.28':
@@ -7028,6 +7040,11 @@ packages:
pkg-types@2.3.1:
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
+ playwright-core@1.56.1:
+ resolution: {integrity: sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==}
+ engines: {node: '>=18'}
+ hasBin: true
+
pluralize@8.0.0:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
@@ -11295,7 +11312,7 @@ snapshots:
rc9: 3.0.1
std-env: 4.2.0
- '@nuxt/test-utils@4.1.0(@vue/test-utils@2.4.11(@vue/compiler-dom@3.5.38)(@vue/server-renderer@3.5.41)(vue@3.5.41(typescript@6.0.3)))(esbuild@0.27.7)(happy-dom@20.11.6)(magicast@0.5.3)(oxc-parser@0.140.0)(rolldown@1.2.3)(rollup@4.60.2)(typescript@6.0.3)(vite@8.2.1(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.11(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(happy-dom@20.11.6)(vite@8.2.1(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(yaml@2.9.0)))':
+ '@nuxt/test-utils@4.1.0(@vue/test-utils@2.4.11(@vue/compiler-dom@3.5.38)(@vue/server-renderer@3.5.41)(vue@3.5.41(typescript@6.0.3)))(esbuild@0.27.7)(happy-dom@20.11.6)(magicast@0.5.3)(oxc-parser@0.140.0)(playwright-core@1.56.1)(rolldown@1.2.3)(rollup@4.60.2)(typescript@6.0.3)(vite@8.2.1(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.11(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(happy-dom@20.11.6)(vite@8.2.1(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(yaml@2.9.0)))':
dependencies:
'@clack/prompts': 1.7.0
'@nuxt/devtools-kit': 2.7.0(magic-string@1.2.2)(magicast@0.5.3)(oxc-parser@0.140.0)(rolldown@1.2.3)(unplugin@3.3.0(esbuild@0.27.7)(rolldown@1.2.3)(rollup@4.60.2)(vite@8.2.1(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(yaml@2.9.0)))(vite@8.2.1(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(yaml@2.9.0))
@@ -11323,11 +11340,12 @@ snapshots:
tinyexec: 1.2.4
ufo: 1.6.4
unplugin: 3.3.0(esbuild@0.27.7)(rolldown@1.2.3)(rollup@4.60.2)(vite@8.2.1(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(yaml@2.9.0))
- vitest-environment-nuxt: 2.0.0(@vue/test-utils@2.4.11(@vue/compiler-dom@3.5.38)(@vue/server-renderer@3.5.41)(vue@3.5.41(typescript@6.0.3)))(esbuild@0.27.7)(happy-dom@20.11.6)(magicast@0.5.3)(oxc-parser@0.140.0)(rolldown@1.2.3)(rollup@4.60.2)(typescript@6.0.3)(vite@8.2.1(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.11(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(happy-dom@20.11.6)(vite@8.2.1(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(yaml@2.9.0)))
+ vitest-environment-nuxt: 2.0.0(@vue/test-utils@2.4.11(@vue/compiler-dom@3.5.38)(@vue/server-renderer@3.5.41)(vue@3.5.41(typescript@6.0.3)))(esbuild@0.27.7)(happy-dom@20.11.6)(magicast@0.5.3)(oxc-parser@0.140.0)(playwright-core@1.56.1)(rolldown@1.2.3)(rollup@4.60.2)(typescript@6.0.3)(vite@8.2.1(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.11(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(happy-dom@20.11.6)(vite@8.2.1(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(yaml@2.9.0)))
vue: 3.5.41(typescript@6.0.3)
optionalDependencies:
'@vue/test-utils': 2.4.11(@vue/compiler-dom@3.5.38)(@vue/server-renderer@3.5.41)(vue@3.5.41(typescript@6.0.3))
happy-dom: 20.11.6
+ playwright-core: 1.56.1
vitest: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(happy-dom@20.11.6)(vite@8.2.1(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(yaml@2.9.0))
transitivePeerDependencies:
- '@farmfe/core'
@@ -17974,6 +17992,8 @@ snapshots:
exsolve: 1.1.1
pathe: 2.0.3
+ playwright-core@1.56.1: {}
+
pluralize@8.0.0: {}
possible-typed-array-names@1.1.0: {}
@@ -20053,9 +20073,9 @@ snapshots:
redent: 3.0.0
vitest: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(happy-dom@20.11.6)(vite@8.2.1(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(yaml@2.9.0))
- vitest-environment-nuxt@2.0.0(@vue/test-utils@2.4.11(@vue/compiler-dom@3.5.38)(@vue/server-renderer@3.5.41)(vue@3.5.41(typescript@6.0.3)))(esbuild@0.27.7)(happy-dom@20.11.6)(magicast@0.5.3)(oxc-parser@0.140.0)(rolldown@1.2.3)(rollup@4.60.2)(typescript@6.0.3)(vite@8.2.1(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.11(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(happy-dom@20.11.6)(vite@8.2.1(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(yaml@2.9.0))):
+ vitest-environment-nuxt@2.0.0(@vue/test-utils@2.4.11(@vue/compiler-dom@3.5.38)(@vue/server-renderer@3.5.41)(vue@3.5.41(typescript@6.0.3)))(esbuild@0.27.7)(happy-dom@20.11.6)(magicast@0.5.3)(oxc-parser@0.140.0)(playwright-core@1.56.1)(rolldown@1.2.3)(rollup@4.60.2)(typescript@6.0.3)(vite@8.2.1(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.11(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(happy-dom@20.11.6)(vite@8.2.1(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(yaml@2.9.0))):
dependencies:
- '@nuxt/test-utils': 4.1.0(@vue/test-utils@2.4.11(@vue/compiler-dom@3.5.38)(@vue/server-renderer@3.5.41)(vue@3.5.41(typescript@6.0.3)))(esbuild@0.27.7)(happy-dom@20.11.6)(magicast@0.5.3)(oxc-parser@0.140.0)(rolldown@1.2.3)(rollup@4.60.2)(typescript@6.0.3)(vite@8.2.1(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.11(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(happy-dom@20.11.6)(vite@8.2.1(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(yaml@2.9.0)))
+ '@nuxt/test-utils': 4.1.0(@vue/test-utils@2.4.11(@vue/compiler-dom@3.5.38)(@vue/server-renderer@3.5.41)(vue@3.5.41(typescript@6.0.3)))(esbuild@0.27.7)(happy-dom@20.11.6)(magicast@0.5.3)(oxc-parser@0.140.0)(playwright-core@1.56.1)(rolldown@1.2.3)(rollup@4.60.2)(typescript@6.0.3)(vite@8.2.1(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.11(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(happy-dom@20.11.6)(vite@8.2.1(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(yaml@2.9.0)))
transitivePeerDependencies:
- '@cucumber/cucumber'
- '@farmfe/core'
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 6e99e45b9..52a71c7e7 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -6,6 +6,7 @@ packages:
- playgrounds/vue
- playgrounds/repl
- playgrounds/demo
+ - test/smoke/fixture
ignoreWorkspaceRootCheck: true
diff --git a/scripts/indistinguishable-snapshots.mjs b/scripts/indistinguishable-snapshots.mjs
index 26f8424b0..51c2bc0c4 100644
--- a/scripts/indistinguishable-snapshots.mjs
+++ b/scripts/indistinguishable-snapshots.mjs
@@ -7,7 +7,7 @@
// with a non-recursive `readdirSync`, so every snapshot under
// `test/components/content/` was outside the guard without saying so.
import { readdirSync, readFileSync } from 'node:fs'
-import { join, sep } from 'node:path'
+import { join } from 'node:path'
/** Everything below this is searched, so a new snapshot directory is covered. */
export const SNAPSHOT_ROOT = 'test'
@@ -19,12 +19,41 @@ export const SNAPSHOT_ROOT = 'test'
*/
const ENTRY = /^exports\[`([^`]+)`\] = `\n?([\s\S]*?)\n?`;$/gm
-/** Every `.snap` file under `root`, as `/`-separated relative paths, sorted. */
+/**
+ * Directories the walk below refuses to descend into.
+ *
+ * `readdirSync(root, { recursive: true })` has no ignore option and returns
+ * every path it finds in one array, so a single installed dependency tree is
+ * enough to make it hundreds of thousands of entries. `test/` holds two —
+ * `test/nuxt/` and the smoke fixture — and the array cost this spec the whole
+ * fork heap: it took minutes and then killed the worker, with vitest reporting
+ * only "Worker exited unexpectedly" and no file name.
+ *
+ * Snapshots never live in any of these, so pruning loses nothing.
+ */
+const PRUNED = new Set(['node_modules', '.nuxt', '.output', '.data', '.cache', 'dist'])
+
+/**
+ * Every `.snap` file under `root`, as `/`-separated relative paths, sorted.
+ *
+ * Hand-rolled rather than `{ recursive: true }` so the directories above can be
+ * pruned as the walk goes, instead of enumerated and then filtered.
+ */
export function snapshotFiles(root = SNAPSHOT_ROOT) {
- return readdirSync(root, { recursive: true, encoding: 'utf8' })
- .filter(f => f.endsWith('.snap'))
- .map(f => f.split(sep).join('/'))
- .sort()
+ const found = []
+
+ const walk = (dir, prefix) => {
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ if (entry.isDirectory()) {
+ if (!PRUNED.has(entry.name)) walk(join(dir, entry.name), `${prefix}${entry.name}/`)
+ } else if (entry.name.endsWith('.snap')) {
+ found.push(`${prefix}${entry.name}`)
+ }
+ }
+ }
+
+ walk(root, '')
+ return found.sort()
}
/** Entry names grouped by the body they rendered. */
diff --git a/test/module/fixture/nuxt.config.ts b/test/module/fixture/nuxt.config.ts
new file mode 100644
index 000000000..26d7c58ed
--- /dev/null
+++ b/test/module/fixture/nuxt.config.ts
@@ -0,0 +1,19 @@
+// A Nuxt project that exists only to be loaded, never built or served.
+//
+// `test/module/module-setup.spec.ts` boots it with `loadNuxt` so the module's
+// real `setup()` runs; the module is referenced by path rather than by package
+// name on purpose, because what is under test is the source in `src/`, not the
+// built artifact. (`test/smoke/fixture` is the one that goes through the
+// package.)
+//
+// c12 merges the repository's `.nuxtrc` in here too, so `@nuxt/content` is
+// always installed and `src/module.ts` always takes its content/mdc branches.
+// Left alone rather than pinned: the spec asserts `appConfig.version` and
+// `theme.prefix`, neither of which that branch touches. Worth knowing before
+// adding a case that does — the coverage of this fixture depends on a file two
+// directories up.
+export default defineNuxtConfig({
+ modules: ['../../../src/module'],
+ devtools: { enabled: false },
+ compatibilityDate: '2024-07-09'
+})
diff --git a/test/module/module-setup.spec.ts b/test/module/module-setup.spec.ts
new file mode 100644
index 000000000..4362ab868
--- /dev/null
+++ b/test/module/module-setup.spec.ts
@@ -0,0 +1,80 @@
+import { fileURLToPath } from 'node:url'
+import { describe, it, expect } from 'vitest'
+import { loadNuxt } from '@nuxt/kit'
+import { version as packageVersion } from '../../package.json'
+
+/**
+ * The module's own `setup()`, run the way Nuxt runs it.
+ *
+ * Every other spec in this repository mounts something the module has already
+ * configured. That leaves the configuring itself untested, and #314 lived
+ * exactly there: `b24ui.version` was a public, typed option that `setup()`
+ * ignored, so `appConfig.version` always held the package's own version. There
+ * is no failure signal for that class of bug — no error, no warning, just a
+ * `` tag carrying the wrong string — and #324 fixed it while covering
+ * only the consuming half (`test/plugins/ui-version.spec.ts` renders whatever
+ * `appConfig.version` holds, including a value nothing put there).
+ *
+ * Calling `setup()` directly does not work: `defineNuxtModule` merges
+ * `defaults` through defu before the body runs, and the body reaches for
+ * `@nuxt/kit`'s ambient Nuxt context (`addPlugin`, `installModule`, …), which
+ * only exists inside a real instance. `loadNuxt` gives us that instance
+ * without a build — it resolves the config, installs modules and stops, which
+ * is all this needs and takes a few seconds rather than a few minutes.
+ *
+ * This is why the suite has a third vitest project: the spec needs plain Node,
+ * not the `nuxt` environment (which is itself a Nuxt instance) and not
+ * happy-dom.
+ */
+const cwd = fileURLToPath(new URL('./fixture', import.meta.url))
+
+async function loadFixture(overrides: Record = {}) {
+ const nuxt = await loadNuxt({ cwd, dev: false, overrides })
+ try {
+ return { version: nuxt.options.appConfig.version, b24ui: nuxt.options.appConfig.b24ui }
+ } finally {
+ await nuxt.close()
+ }
+}
+
+describe('module setup()', () => {
+ // The first `loadNuxt` in the process pays for resolving the whole config;
+ // later ones are an order of magnitude faster. Well under the ceiling, but
+ // far over vitest's 5s default.
+ it('honours a `b24ui.version` set in the app config (#314)', { timeout: 60_000 }, async () => {
+ const { version } = await loadFixture({ b24ui: { version: '1.2.3-custom' } })
+
+ // The assertion the bug would fail: before #324 this came back as the
+ // package's own version, because `setup()` never read the option.
+ expect(version).toBe('1.2.3-custom')
+ expect(version).not.toBe(packageVersion)
+ })
+
+ it('falls back to the package version when none is set', { timeout: 60_000 }, async () => {
+ const { version } = await loadFixture()
+
+ // Pinned against `package.json` rather than a literal, so a release bump
+ // does not have to touch this file — and so the fallback is asserted to be
+ // *that* value rather than merely "some string".
+ expect(version).toBe(packageVersion)
+ })
+
+ it('threads `theme.prefix` into the app config', { timeout: 60_000 }, async () => {
+ // `appConfig.version` and `appConfig.b24ui` are written by adjacent lines
+ // of the same `setup()`, so a version-only spec would pass against a
+ // module that had stopped configuring the theme altogether. `prefix` is
+ // the part of that config an app can set, which makes it the half worth
+ // asserting: it has to survive the same journey `version` does — through
+ // `defaults`, through defu, into `getDefaultConfig`.
+ const { b24ui } = await loadFixture({ b24ui: { theme: { prefix: 'smoke' } } })
+
+ // `AppConfig['b24ui']` does not declare `prefix`, though `getDefaultConfig`
+ // writes it and `nuxt.options.app.rootAttrs` reads it back — a gap in the
+ // module's types rather than in this assertion, so the read is widened
+ // here rather than papered over by dropping the case.
+ expect((b24ui as Record | undefined)?.prefix).toBe('smoke')
+ // Reached by `tv()` at render time to build twMerge's config; a `prefix`
+ // that lands in one place and not the other is a live bug.
+ expect(b24ui?.tv?.twMergeConfig?.prefix).toBe('smoke')
+ })
+})
diff --git a/test/smoke/fixture/app/app.vue b/test/smoke/fixture/app/app.vue
new file mode 100644
index 000000000..6fdf0694d
--- /dev/null
+++ b/test/smoke/fixture/app/app.vue
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+ Home
+
+
+
+
+
diff --git a/test/smoke/fixture/nuxt.config.ts b/test/smoke/fixture/nuxt.config.ts
new file mode 100644
index 000000000..7f37f91bb
--- /dev/null
+++ b/test/smoke/fixture/nuxt.config.ts
@@ -0,0 +1,26 @@
+// A Nuxt app that installs `@bitrix24/b24ui-nuxt` from the workspace link,
+// which resolves to the built `dist/` — so this boots the package the way a
+// consumer would rather than the sources the unit suite mounts. Everything
+// added here is time `pnpm test:smoke` spends on something other than "does
+// the package start", so it stays small.
+//
+// It is *not* bare, though an earlier version of this comment said so. c12
+// resolves the workspace root and merges the repository's own `.nuxtrc` into
+// every Nuxt instance under it, this fixture included:
+//
+// experimental.normalizeComponentNames=false
+// modules[]=@nuxt/content
+//
+// `@nuxt/content` therefore loads here despite not being in this package's
+// dependencies, which is untidy and harmless — its components are not used.
+// `normalizeComponentNames` is neither. It is `true` by default in Nuxt 4, so
+// inheriting `false` would have this fixture boot in a configuration no
+// consumer has, and component-resolution failures are exactly the class of
+// boot failure this file exists to catch. Restored explicitly below; the
+// config file wins over the rc.
+export default defineNuxtConfig({
+ modules: ['@bitrix24/b24ui-nuxt'],
+ devtools: { enabled: false },
+ experimental: { normalizeComponentNames: true },
+ compatibilityDate: '2024-07-09'
+})
diff --git a/test/smoke/fixture/package.json b/test/smoke/fixture/package.json
new file mode 100644
index 000000000..36b68edb8
--- /dev/null
+++ b/test/smoke/fixture/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "b24ui-smoke-fixture",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "build": "nuxt build"
+ },
+ "dependencies": {
+ "@bitrix24/b24ui-nuxt": "workspace:*",
+ "nuxt": "^4.5.2"
+ }
+}
diff --git a/test/smoke/run.mjs b/test/smoke/run.mjs
new file mode 100755
index 000000000..c5dcf3cec
--- /dev/null
+++ b/test/smoke/run.mjs
@@ -0,0 +1,344 @@
+#!/usr/bin/env node
+// Boots the built package in a real browser.
+//
+// Everything else in this repository tests source. `pnpm test` mounts
+// components out of `src/`, `pnpm build` proves the bundler is happy, and
+// neither of them ever starts an application — so #301, a client-only boot
+// failure, shipped and stayed green for five weeks. This is the missing step
+// (#329): take what `pnpm build` produced, put an app on top of it, load the
+// page, and fail on anything the browser logs as an error.
+//
+// pnpm build && pnpm test:smoke
+//
+// Two applications, because b24ui ships two distributions and they fail
+// differently:
+//
+// * `test/smoke/fixture` — a Nuxt app consuming `@bitrix24/b24ui-nuxt`
+// through the workspace link, so module registration, the runtime plugins
+// and SSR all run. Also where the `platform` plugin's server branch is
+// asserted: it reads `user-agent` off the request, which no unit test can
+// reach because the vitest environment is client-only.
+// * `playgrounds/vue` — the unplugin/Vite distribution, built as a real SPA
+// and served as static files. This is #301's shape exactly: no server, no
+// SSR, everything happens in the browser or not at all.
+//
+// Console errors are the assertion, not a heuristic. A Vue app that throws
+// during setup still returns 200 and still renders something — `curl` cannot
+// tell the difference, and that is the whole reason this file needs a browser.
+import { spawn } from 'node:child_process'
+import { createServer } from 'node:http'
+import { createServer as createSocketServer } from 'node:net'
+import { createReadStream, existsSync, readFileSync, statSync } from 'node:fs'
+import { extname, join, relative, isAbsolute, sep } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { chromium } from 'playwright-core'
+
+const root = fileURLToPath(new URL('../..', import.meta.url))
+const fixture = join(root, 'test/smoke/fixture')
+const spa = join(root, 'playgrounds/vue/dist')
+
+/** Collected as we go so one run reports every failure, not just the first. */
+const failures = []
+
+function check(name, ok, detail) {
+ console.log(`${ok ? ' ok ' : ' FAIL '} ${name}${ok || detail === undefined ? '' : `\n ${detail}`}`)
+ if (!ok) failures.push(name)
+}
+
+function step(name) {
+ console.log(`\n${name}`)
+}
+
+// region helpers ////
+
+/** A port the OS just told us is free. Racy in principle, fine for one runner. */
+function freePort() {
+ return new Promise((resolve, reject) => {
+ const probe = createSocketServer()
+ probe.on('error', reject)
+ probe.listen(0, '127.0.0.1', () => {
+ const { port } = probe.address()
+ probe.close(() => resolve(port))
+ })
+ })
+}
+
+function run(command, args, cwd) {
+ return new Promise((resolve, reject) => {
+ const child = spawn(command, args, { cwd, stdio: 'inherit', shell: process.platform === 'win32' })
+ child.on('error', reject)
+ child.on('exit', code => code === 0 ? resolve() : reject(new Error(`${command} ${args.join(' ')} exited with ${code}`)))
+ })
+}
+
+/**
+ * Polls `url` until it answers — or until `child` dies, which is the case
+ * worth handling: a server that fails on startup would otherwise burn the full
+ * timeout and report "did not answer", hiding the exit code that says why.
+ */
+async function waitForServer(url, child, timeoutMs = 60_000) {
+ let exit = null
+ child.on('exit', (code, signal) => {
+ exit = signal ?? `code ${code}`
+ })
+ child.on('error', (error) => {
+ exit = error.message
+ })
+
+ const deadline = Date.now() + timeoutMs
+ while (Date.now() < deadline) {
+ if (exit !== null) {
+ throw new Error(`the server exited before answering (${exit}) — its output is above`)
+ }
+ try {
+ // The body is never read, so it has to be discarded explicitly —
+ // undici holds the socket open until it is, and this loop can run
+ // hundreds of times before the server answers.
+ const response = await fetch(url)
+ await response.body?.cancel()
+ return
+ } catch {
+ await new Promise(resolve => setTimeout(resolve, 250))
+ }
+ }
+ throw new Error(`server at ${url} did not answer within ${timeoutMs}ms`)
+}
+
+const MIME = {
+ '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json',
+ '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.ico': 'image/x-icon',
+ '.woff': 'font/woff', '.woff2': 'font/woff2', '.webp': 'image/webp'
+}
+
+/**
+ * Resolves a request path inside `dir`, or returns `null` if it points out.
+ *
+ * Two versions of this were wrong before this one, both for the same reason.
+ * `file.startsWith(dir)` has no separator boundary, so a sibling directory
+ * whose name merely begins with the root's — `dist-something` next to `dist` —
+ * passes it. Its replacement, `relative(...).startsWith('..')`, has no
+ * separator boundary either, in the other direction: it rejects a perfectly
+ * ordinary file called `..hidden.js`, which then gets served as `index.html`
+ * and looks like a routing bug.
+ *
+ * The question is whether the relative path contains a `..` **segment**, so
+ * that is what is asked.
+ */
+function resolveWithin(dir, url) {
+ let path
+ try {
+ path = decodeURIComponent(url.split('?')[0])
+ } catch {
+ // A malformed `%` escape throws here. Unhandled it takes down the whole
+ // run from inside a request handler, skipping every cleanup below.
+ return null
+ }
+
+ const file = join(dir, path)
+ const rel = relative(dir, file)
+ const inside = rel === '' || (!rel.split(sep).includes('..') && !isAbsolute(rel))
+ return inside ? file : null
+}
+
+/**
+ * Static files with an SPA fallback — the same shape as any host serving a
+ * built Vite app, and enough for the router to hand out its own routes.
+ */
+async function serveStatic(dir) {
+ const port = await freePort()
+ const server = createServer((req, res) => {
+ let file = resolveWithin(dir, req.url)
+ if (file === null || !existsSync(file) || statSync(file).isDirectory()) {
+ file = join(dir, 'index.html')
+ }
+ res.setHeader('content-type', MIME[extname(file)] ?? 'application/octet-stream')
+
+ // Without this listener a read error — a deleted `index.html`, a bad
+ // permission — is an unhandled `error` event, which is fatal. Failing the
+ // request instead lets the browser report it and the checks below say so.
+ const body = createReadStream(file)
+ body.on('error', () => {
+ res.statusCode = 500
+ res.end()
+ })
+ body.pipe(res)
+ })
+ await new Promise(resolve => server.listen(port, '127.0.0.1', resolve))
+ return { url: `http://127.0.0.1:${port}`, close: () => new Promise(resolve => server.close(resolve)) }
+}
+
+/**
+ * Loads `url` and returns everything the browser complained about.
+ *
+ * `pageerror` catches uncaught exceptions, `console` type `error` catches what
+ * Vue's own error handler reports — a failing `setup()` reaches the second and
+ * not always the first, which is how a broken app can look fine from Node.
+ */
+async function boot(browser, url, probe) {
+ const page = await browser.newPage()
+ // A `Set`: Vue re-renders a broken component once per parent, so one defect
+ // arrives as twenty identical lines and buries everything else.
+ const problems = new Set()
+ page.on('console', message => message.type() === 'error' && problems.add(`console.error: ${message.text().split('\n')[0]}`))
+ page.on('pageerror', error => problems.add(`pageerror: ${error.message.split('\n')[0]}`))
+
+ try {
+ await page.goto(url, { waitUntil: 'networkidle', timeout: 60_000 })
+
+ // `networkidle` says the network went quiet, not that the app finished.
+ // Anything logged from `onMounted`, a deferred plugin or a microtask after
+ // the last response lands after it — without a settle window this check
+ // passes or fails by how fast the runner is.
+ await page.waitForTimeout(SETTLE_MS)
+ } catch (error) {
+ await page.close()
+ throw error
+ }
+
+ // Playwright's `Locator#innerText()`, not the DOM property the lint rule is
+ // about — and the right one here: `textContent` would also return the text
+ // of `