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 @@ + + + + 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 `