Skip to content

test: boot the built package in a browser, and cover the two branches nothing could reach - #483

Merged
IgorShevchik merged 10 commits into
mainfrom
test/smoke-built-package
Aug 25, 2026
Merged

test: boot the built package in a browser, and cover the two branches nothing could reach#483
IgorShevchik merged 10 commits into
mainfrom
test/smoke-built-package

Conversation

@IgorShevchik

@IgorShevchik IgorShevchik commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Linked issue

Resolves #329 · fixes #485

Follow-ups filed: #486 (prefix missing from AppConfig['b24ui']), #487 (no tests for the smoke runner itself).

Type of change

  • Documentation (updates to the documentation or readme)
  • Bug fix (a non-breaking change that fixes an issue)
  • Enhancement (improving an existing functionality)
  • New feature (a non-breaking change that adds functionality)
  • Chore (updates to the build process or auxiliary tools and libraries)
  • Revert (undoing a merged change — retitle this PR revert(Scope): ...)
  • Breaking change (fix or feature that would cause existing functionality to change)

Description

#329 lists three places where a regression would still be invisible, and ranks them: 3 (nothing in CI boots the package) covers the widest class of failure, 1 (the module's setup()) protects a fix that has no other signal, 2 (platform.ts's SSR branch) comes nearly free alongside 3. All three are here.

The first run of the new smoke test found a live bug, and getting CI green found a second one. That is the short version of why this was worth doing.

3 — nothing boots the package

Nothing in ci.yml 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.

pnpm build && pnpm test:smoke builds two applications against the built package, serves both, loads them in Chromium and fails on anything the browser logs:

what it covers
test/smoke/fixture a minimal Nuxt app consuming @bitrix24/b24ui-nuxt through the workspace link — module registration, runtime plugins, SSR
playgrounds/vue the unplugin/Vite distribution as a real SPA served as static files — #301's shape exactly

Console errors are the assertion, not a heuristic. A Vue app that throws in setup() still answers 200 and still renders somethingcurl cannot tell it apart from a working one, and that is the entire reason this needs a browser.

The script refuses to run against a --stub dist. pnpm dev:prepare leaves one behind, and a smoke run against it would boot src/ under a different name.

What it found on the first run

console.error: TypeError: Cannot destructure property 'options' of 'm(...)' as it is undefined.
    at setup (…/assets/Button-CXJ9_J8D.js:1:3768)

vue-router's own RouterLink, reading the router through inject(routerKey). routerKey is a module-level Symbol, so two copies of vue-router in one bundle are two different keys: the injection returns undefined and RouterLink dies on first render. The playground was serving a page with no navigation on it.

Two copies is what this workspace has — the playground depends on 5.2.0, the root gets 5.1.0 hoisted from nuxt, and the b24ui runtime resolves from the root. Fixed with resolve.dedupe.

Not a packaging defect: vue-router is already an optional peerDependency, so a consumer gets one copy. Which is also why it survived — nothing here had ever opened the page.

1 — b24ui.version never went through the real setup()

#314 was a public, typed option that setup() ignored — no error, no warning, just a <meta> tag with the wrong string. #324 fixed it while covering only the consuming half.

setup() cannot be called directly: defineNuxtModule merges defaults through defu before the body runs, and the body reaches for @nuxt/kit's ambient context. loadNuxt gives a real instance without a build, in about three seconds.

2 — platform.ts's SSR branch

const ua = import.meta.server ? useRequestHeader('user-agent') : navigator.userAgent

The server half has never run — the Nuxt vitest environment is client-only. It sets data-platform / data-version on <html>, which the bitrix-mobile: and bitrix-desktop: Tailwind variants key on, so a broken branch means a flash of wrong styling on first paint inside a Bitrix24 frame.

Only two of the three user agents can fail if the branch breaks — web / air is the fallback. That is said in a comment rather than left to be discovered later.

The second bug: #485

CI went red on this branch and stayed red after the obvious fix, which is what forced a real bisect.

test/utils/indistinguishable-snapshots.spec.ts — a guard merged this morning in #480 — collected snapshots with readdirSync(root, { recursive: true }). That option has no ignore list: it walks everything under test/ and returns every path in one array, and test/ holds installed dependency trees. Hundreds of thousands of strings, built to find 212 files. The worker died at the heap limit and vitest reported Worker exited unexpectedly with no file name, so it read as a suite-wide condition.

run files result
main @ 6f0e71ef 314 green
this branch, first push 315 OOM
this branch, after moving the module suite out 314 OOM
--project vue alone 153 152 passed, 1 error
--project vue, --max-old-space-size=1024 153 152 passed, 1 error

The last row named it. With real accumulation an eight-times-smaller heap dies eight times sooner; it died at exactly the same point, so exactly one file was responsible. main was green only because it had one dependency tree under test/ instead of two — this branch's fixture pushed it over, which is how a pull request that never touched the guard broke it.

snapshotFiles() prunes as it walks now. Same 212 files, same two directories, 120+ seconds and a dead worker down to 2 ms and 46 MB. The suite goes from 312 of 314 with two errors to 314 of 314, and from 720 s to 341 s. Guarded by a test that puts a snapshot inside node_modules/ and one inside .output/ and asserts neither is returned.

testing.md gains the bisect, because the failure names nothing and the cheap first move is not obvious.

Verified by mutation

mutation what went red
SSR branch returns nothing the two Bitrix user-agent checks (web stayed green, as documented)
a runtime plugin throws on the client all four boot checks
the vue-router dedupe removed the SPA console check and the navigation check (1 link vs 80)
setup() ignores options.version (#314 again) honours a b24ui.version set in the app config
getDefaultConfig stops seeing theme threads theme.prefix into the app config
a snapshot planted in node_modules/ does not descend into an installed dependency tree

Changes after review

Six reviews. What each one changed:

The SPA render check was very nearly vacuous. It measured text.length > 100, and the tester proved it: killing RouterLink — the bug this file found — left it green, because the playground carries enough static copy to clear the threshold on a broken page. It counts the navigation's links now. Working: 80. Dedupe removed: 1.

serveStatic had three ways to take the whole run down. file.startsWith(dir) is a directory-escape check with no separator boundary, so a sibling named dist-anything passes it — relative() now answers the question that was being asked. decodeURIComponent throws on a malformed % escape, inside a request handler, skipping every cleanup below. A createReadStream with no error listener made a missing file fatal instead of a failed check.

waitForServer never watched the child. A Nitro server that died on boot burned the full 60 s and reported "did not answer", hiding the exit code that said why.

Cleanup could strand things. browser and the server were started before the try, and the finally awaited browser.close() first — a rejection there skipped the static server and the kill().

npx undercut the pinned playwright-core, since it falls back to the registry on a resolution miss. pnpm exec in both the script and the workflow.

The module suite moved out of the shared vitest run, into vitest.module.config.ts / pnpm test:module with its own CI step. The reasoning at the time was memory, and that reasoning turned out to be wrong — the memory problem was #485. The split is kept on its own merits: loadNuxt in a plain-Node process, three specs, four seconds, and no interaction with the component pool.

The smoke job is now smoke.yml, nightly and dispatchable, on the maintainer's call: a browser download plus two application builds on every pull request is a poor trade for a library whose runtime plugins change rarely. The cost is named in the workflow and in testing.md — a boot failure is found the morning after it lands, so dispatch it by hand when touching a runtime plugin, setup(), or anything reaching the client bundle.

Also: the browser is cached keyed on the pinned version rather than the lockfile; the workflow says why --with-deps and its sudo do not widen permissions:; testing.md documents both extra suites and the --with-deps difference.

Cost

ci.yml gains one step: pnpm test:module, about four seconds — and gets ~380 s faster, because #485 was costing that. Everything browser-shaped is in smoke.yml, off the pull-request path. Adds playwright-core as a pinned devDependency.

Checklist

  • I have linked an issue or discussion.
  • I have updated the documentation accordingly.

Merged main (#482's dependency bumps) — lockfile taken from main and regenerated, --frozen-lockfile holds.

Gate run locally: install --frozen-lockfile · test:workflows 49/0 · lint 0 · typecheck 0 · test 314/314, 7172 passed, 0 errors · test:module 3/3 · build · test:smoke 8/8.

claude added 5 commits August 24, 2026 12:16
…y do nothing

Every other spec here mounts something the module has already configured,
which leaves the configuring untested. #314 lived exactly there: `b24ui.version`
was a public, typed option that `setup()` ignored, and there is no failure
signal for that class of bug — no error, no warning, just a `<meta>` tag with
the wrong string. #324 fixed it and covered only the consuming half.

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. `loadNuxt` gives a real instance without a build: it
resolves the config, installs modules and stops, in about three seconds.

That needs plain Node, so the suite grows a third vitest project. `module/` is
recorded in `test/vitest-include.ts` alongside the other two, and
`vitest-include.spec.ts` now asserts the separation in both directions — a
`module/` spec leaking into another project would not fail, it would hang.

Three cases, each verified by breaking the line it covers: the option is
honoured, it falls back to the package version, and `theme.prefix` reaches
both places `getDefaultConfig` writes it.

Refs #329
The Vue playground threw on boot and had been doing so unnoticed, because
nothing in this repository has ever loaded a page in a browser.

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: the injection
returns `undefined` and `RouterLink` dies with `Cannot destructure property
'options'` on first render. The playground rendered a page with no navigation
on it.

Two copies is what this workspace has. The playground depends on vue-router
5.2.0; the repo root gets 5.1.0 hoisted from nuxt, and the b24ui runtime
resolves from the root. `resolve.dedupe` is Vite's answer to exactly that.

Not a packaging defect: `vue-router` is already an optional peerDependency, so
an installed copy is shared and a consumer gets one. Which is why this
survived — it needed something to open the page.

Refs #329
…ranch

`ci.yml` ran lint, typecheck, test and build, and no step ever started 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. Nothing
about "builds fine, dies on boot" was covered, and the first run of this
script found a live instance of it (fixed in the previous commit).

`pnpm build && pnpm test:smoke` builds two applications against the built
package — a minimal Nuxt app under `test/smoke/fixture` and the Vue
playground — serves both, loads them in Chromium and fails on anything the
browser logs. Console errors rather than status codes, because a Vue app that
throws in `setup()` still answers 200 with an empty root: `curl` cannot tell
the two apart and that is the whole reason this needs a browser.

The Nuxt half also closes the second gap in #329. `platform.ts` reads the
user-agent off the request during SSR to set `data-platform` / `data-version`,
which the `bitrix-mobile:` and `bitrix-desktop:` Tailwind variants key on. The
vitest environment is client-only, so that branch has never run; the smoke run
fetches the page with three user agents and checks the attributes.

The script refuses to run against a `--stub` dist. `dev:prepare` leaves one
behind, and a smoke run against it would boot `src/` under a different name
and report success for a package nobody built.

Verified by mutation: removing the SSR branch fails the two Bitrix user-agent
checks (the `web` fallback cannot fail, and says so in a comment); making a
runtime plugin throw on the client fails all four boot checks; removing the
vue-router dedupe fails the SPA console check alone.

Costs CI a Chromium download plus about a minute of builds. Adds
`playwright-core` as a devDependency.

Resolves #329
Resolves `package.json` and `pnpm-lock.yaml` against #482's dependency bumps:
takes main's versions wholesale, then re-adds this branch's two entries —
`playwright-core` and the `test:smoke` / `test:module` scripts — and
regenerates the lockfile so `--frozen-lockfile` still holds.
…edule the job

Six reviews of #483. The findings, and what each one changed.

**The SPA render check was very nearly vacuous.** It measured `text.length >
100`, and the tester proved it: killing `RouterLink` — the very bug this file
found — left it green, because the playground carries enough static copy to
clear the threshold on a broken page. It counts the navigation's links now.
Working: 80. With the dedupe removed: 1.

**`serveStatic` had three ways to take the whole run down.**
`file.startsWith(dir)` is a directory-escape check with no separator boundary,
so a sibling named `dist-anything` passes it; `relative()` answers the question
that was being asked. `decodeURIComponent` throws on a malformed `%` escape,
inside a request handler, skipping every cleanup below. And a `createReadStream`
with no `error` listener makes a missing file fatal instead of a failed check.

**`waitForServer` never watched the child.** A Nitro server that died on boot
burned the full 60 seconds and reported "did not answer", hiding the exit code
that said why.

**Cleanup could strand things.** `browser` and the server were started before
the `try`, and the `finally` awaited `browser.close()` first — a rejection there
skipped the static server and the `kill()`. Everything is inside the `try` now
and each teardown is independent.

**`npx` undercut the pinned `playwright-core`**, since it falls back to the
registry on a resolution miss. `pnpm exec` in both the script and the workflow.

**The module suite moved out of the shared vitest run.** Measured, not assumed:
the `vue` project alone OOMs a worker at the fork heap limit — 152 of 153 files
— on `main`, with none of this branch's files in it. The component suite is
sitting on that edge and CI has been passing on the near side of it. Adding a
`loadNuxt` instance to the same pool is not a risk worth taking for three
specs. `vitest.module.config.ts`, `pnpm test:module`, its own CI step. The
growth itself is a real defect, is not this branch's, and is filed separately.

**The smoke job is now `smoke.yml`, nightly and dispatchable**, on the
maintainer's call: a browser download and two application builds on every pull
request is a poor trade for a library whose runtime plugins change rarely. The
cost of that choice is named in the workflow and in testing.md — a boot failure
is found the morning after it lands, so dispatch it by hand when you touch a
runtime plugin, `setup()`, or anything that reaches the client bundle.

Also: cache the browser keyed on the pinned version rather than the lockfile;
say in the workflow why `--with-deps` and its sudo do not widen `permissions:`;
document the two extra suites and the `--with-deps` difference in testing.md.

Refs #329
claude added 5 commits August 25, 2026 06:09
`test/utils/indistinguishable-snapshots.spec.ts` collected every `.snap` file
under `test/` with `readdirSync(root, { recursive: true })`. That option has no
ignore list: it walks everything and returns every path in one array — and
`test/` holds installed dependency trees, `test/nuxt/node_modules` and, since
this branch, `test/smoke/fixture/node_modules`. Hundreds of thousands of
strings, built to find 212 files.

The worker died at the heap limit, and vitest reported `Worker exited
unexpectedly` with no file name, so it read as a suite-wide condition:

  main @ 6f0e71e        314 files   green
  this branch, 315       315 files   OOM
  this branch, 314       314 files   OOM
  --project vue alone    153 files   152 passed, 1 error

The last row named it. Halving the heap to 1 GB killed it at exactly the same
point — with real accumulation an eight-times-smaller cap would have died eight
times sooner — so exactly one file was responsible, and a verbose reporter
diffed against the collected list said which.

`snapshotFiles()` now prunes as it walks. Same 212 files, same two directories,
120+ seconds and a dead worker down to 2ms and 46 MB. The whole suite goes from
312 of 314 with two errors to 314 of 314, and from 720s to 341s.

Guarded by a test that builds a temp tree with a snapshot inside
`node_modules/` and one inside `.output/` and asserts neither is returned.

`testing.md` gains the bisect, because the failure names nothing and the cheap
first move is not obvious: halve the heap, and see whether the same number of
files passes.
… fixtures lying

Six more reviews. What each one changed.

**The fixtures were not what their comments claimed.** c12 resolves the
workspace root and merges the repository's own `.nuxtrc` into every Nuxt
instance under it, so both fixtures were booting with `@nuxt/content` installed
and `experimental.normalizeComponentNames=false`. The second one matters: it is
`true` by default in Nuxt 4, so the smoke run was booting in a configuration no
consumer has — and component resolution is exactly the class of boot failure
this file exists to catch. Restored explicitly in the config, which wins over
the rc. Both comments now say what is actually inherited instead of "kept
deliberately bare".

**The smoke run could not tell a hydrated app from a server-rendered one.**
Every check was satisfied by HTML that arrives before any client code runs, so
hydration that hangs quietly — a promise that never settles, no exception
anywhere — passed. It clicks the button now and asserts the badge moved. Proved
by adding `defineNuxtPlugin(() => new Promise(() => {}))` to the fixture: that
one check goes red and every other check stays green, which is the tester's
point made in one line.

**Console collection stopped the instant the network went quiet.** Anything
logged from `onMounted`, a deferred plugin or a lazy chunk landed after
`page.goto` returned, and the check passed or failed by how fast the runner
was. There is a 750ms settle window now; verified with a `setTimeout(…, 500)`
that logs an error, which is caught.

**The directory-escape guard repeated the bug it warns about.**
`relative(...).startsWith('..')` has no separator boundary either — in the
other direction: it rejects an ordinary file called `..hidden.js`, which then
gets served as `index.html` and reads as a routing bug. It asks about `..`
segments now.

**Three more in `run.mjs`.** The readiness probe never cancelled its response
body, holding an undici socket per attempt. `page.close()` was not in a
`finally`, so a `goto` timeout cancelled the SPA half of the run instead of
recording a failure. And the teardown comment promised independent steps while
only the first one was guarded.

**`vitest.module.config.ts` still carried the wrong diagnosis** — a claim that
the component suite leaks memory "on main, with none of this branch's files in
it", and a defect "filed separately" with no number. Neither survived the
bisect: it was #485, a directory walk in a spec of ours, fixed here. The split
survives its own bad argument and now says so.

Also: a 20-minute timeout on the nightly job, because nobody is watching it and
the GitHub default is six hours; a note on why `TZ` is not carried into the
module config.

Refs #329

Copy link
Copy Markdown
Collaborator Author

Second review round

Six more reviews after the rework. Four findings that mattered, all fixed here.

The fixtures were not what their comments claimed. c12 resolves the workspace root and merges the repository's own .nuxtrc into every Nuxt instance under it — both fixtures included:

test/smoke/fixture  → modules: ["@nuxt/content", "@bitrix24/b24ui-nuxt"]
                      experimental.normalizeComponentNames: false

The second line is the one that matters. normalizeComponentNames is true by default in Nuxt 4, so the smoke run was booting in a configuration no consumer has — and component resolution is exactly the class of boot failure this file exists to catch. Restored explicitly in the config, which wins over the rc. Both comments now say what is actually inherited instead of "kept deliberately bare".

The smoke run could not tell a hydrated app from a server-rendered one. Every check was satisfied by HTML that arrives before any client code runs, so hydration that hangs quietly — a promise that never settles, no exception anywhere — passed. It clicks the button now and asserts the badge moved. Proved by adding defineNuxtPlugin(() => new Promise(() => {})) to the fixture: that one check goes red and every other check stays green, which makes the reviewer's point in one line.

Console collection stopped the instant the network went quiet. Anything logged from onMounted, a deferred plugin or a lazy chunk landed after page.goto returned, so the check passed or failed by how fast the runner was. There is a 750 ms settle window now, verified with a setTimeout(…, 500) that logs an error.

The directory-escape guard repeated the bug it warns about. relative(...).startsWith('..') has no separator boundary either, in the other direction: it rejects an ordinary file called ..hidden.js, which then gets served as index.html and reads as a routing bug. It asks about .. segments now.

Three smaller ones in run.mjs: the readiness probe never cancelled its response body (an undici socket per attempt); page.close() was not in a finally, so a goto timeout cancelled the SPA half instead of recording a failure; and the teardown comment promised independent steps while only the first was guarded.

vitest.module.config.ts still carried the wrong diagnosis — a claim that the component suite leaks memory "on main, with none of this branch's files in it", and a defect "filed separately" with no number. Neither survived the bisect: it was #485, fixed here. Two reviewers found it independently. The split survives its own bad argument and now says so.

Also: timeout-minutes: 20 on the nightly job, because nobody is watching it and the GitHub default is six hours; a note on why TZ is not carried into the module config.

Verified by mutation

mutation what went red
a plugin that hangs hydration without throwing the hydration check alone — console and content stayed green
a console.error fired 500 ms after load the Nuxt console check (it was invisible before the settle window)

Gate

install --frozen-lockfile · test:workflows 49/0 · lint 0 · typecheck 0 · test 314/314 · test:module 3/3 · build · test:smoke 9/9. main merged through #492; CI green on the merge commit.


Generated by Claude Code

@IgorShevchik
IgorShevchik merged commit 462fa4e into main Aug 25, 2026
2 checks passed
@IgorShevchik
IgorShevchik deleted the test/smoke-built-package branch August 25, 2026 10:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants