Skip to content

feat(Build): modernize build and test tooling with Vite/Vitest & oxlint/oxfmt#3435

Open
daker wants to merge 38 commits into
Kitware:masterfrom
daker:vite-vitest-migration
Open

feat(Build): modernize build and test tooling with Vite/Vitest & oxlint/oxfmt#3435
daker wants to merge 38 commits into
Kitware:masterfrom
daker:vite-vitest-migration

Conversation

@daker
Copy link
Copy Markdown
Collaborator

@daker daker commented Mar 3, 2026

Context

This PR completes a tooling migration from the legacy webpack/rollup + Karma/Tape setup to a unified Vite + Vitest stack. It updates local development, build output, test execution, and CI integration to run on the new toolchain.

Results

  • Reduce maintenance overhead from multiple legacy build/test systems.
  • Standardize on a modern, faster toolchain for both local and CI workflows.
  • Improve consistency between build and test environments by sharing Vite-based config.
  • Everything should works as before. 🤞

Changes

  • Replaced legacy build configuration with vite.config.js.
  • Added Vitest browser-runner configuration via vitest.config.js.
  • Migrated test infrastructure and tests to Vitest.
  • Removed old webpack, rollup-legacy, and Karma-related configs/plugins/utilities.
  • Migrate ExampleRunner to vite devserver.
  • Updated GitHub Actions workflows to run the Vite/Vitest pipeline.
  • Updated dependencies and lockfile to reflect the new build/test stack.
  • Documentation and TypeScript definitions were updated to match those changes

PR and Code Checklist

  • semantic-release commit messages
  • Run npm run reformat to have correctly formatted code

Testing

  • This change adds or fixes unit tests
  • Tested environment:
    • vtk.js:
    • OS:
    • Browser:

@daker daker force-pushed the vite-vitest-migration branch 29 times, most recently from 65d7ab0 to 825c998 Compare March 5, 2026 12:40
@daker daker marked this pull request as ready for review March 5, 2026 12:51
PaulHax added 14 commits May 18, 2026 16:16
The old webpack (UMD via style-loader) and rollup (ESM via
rollup-plugin-postcss with default inject:true) both injected
stylesheets as <style> tags on import. The Vite migration silently
dropped this: UMD emitted vtk.js.css alongside vtk.js, and ESM emitted
orphan .module.css files that nothing imported, so UI/widget consumers
lost their styling without any warning.

- inlineUmdCssPlugin: add enforce:'post' so generateBundle sees the
  CSS asset emitted by Vite's css-post plugin. Without it, the bundle
  filter returned empty and the plugin no-op'd.
- injectEsmCssPlugin: new plugin that inlines each *.module.css into
  its corresponding *.module.css.js wrapper as a <style> tag injected
  on first evaluation, then deletes the orphan stylesheet from the
  bundle.

Verified by diffing against the published @kitware/vtk.js@35.15.1
artifacts, which inject CSS via styleInject; the new wrappers behave
equivalently.
Application demos are emitted as standalone single-file HTML (per each
app's index.md: "the only requirement is the single HTML file without
any web server"). The old Rollup path ran terser; the Vite migration
disabled minification, bloating each demo from ~1.5 MB to ~2.5 MB.
Re-enable esbuild minification, which is the fast path Vite already
bundles, no extra dependency.

Readable source for learning still lives in Examples/Applications/*
on GitHub; the inlined script in the deployable HTML was never
intended as a view-source artifact.
The old webpack ESLint plugin honored NOLINT to skip linting during
the release build (since lint runs separately on the line). The Vite
migration has no equivalent consumer, so the env var is dead config.
The previous cssRuntimePlugin hand-rolled ~130 lines of CSS Modules
logic — regex class-name parsing, sha256 hashing, manual composes
resolution — that re-implemented (incompletely) what Vite handles via
postcss-modules. Cross-file composes, nested rules, complex selectors,
and pseudo-classes were all silent footguns waiting for a contributor
to write a more complex CSS module.

Let Vite do the CSS parsing and class-name scoping. A new ~25-line
inlineExtractedCssPlugin walks chunk.viteMetadata.importedCss at
generateBundle time, inlines each chunk's extracted CSS as a <style>
injection IIFE, and deletes the orphan asset. Same end behavior as the
old plugin (CSS bundled into the standalone HTML, class names work),
but with Vite's battle-tested CSS Modules implementation underneath.

Also set assetsInlineLimit:Infinity on the ES module build to match
the application build; the old plugin always inlined url() refs as
data URIs, and extracted assets would otherwise resolve to /_assets/
paths that don't honor the VitePress base prefix.

Net: -196 / +41 lines.
Forrest Li added matched ESM and UMD .d.ts validation configs in 2022
(c925926); both have been in CI ever since. The Vite migration
kept the ESM half but dropped the UMD half, leaving the UMD .d.ts
rewriting pipeline (copyUmdAssetsPlugin in vite.config.js) with no
in-repo check.

The UMD .d.ts files use absolute "vtk.js/Sources/..." imports rewritten
from relative paths at build time. If that rewriting breaks, the only
signal today is downstream TypeScript users hitting unresolvable
imports. Restore the config and wire it into both build-test and
publish workflows so the next .d.ts regression fails at PR time.
Two related broken URL behaviors in the generated examples:

generate-examples.mjs hard-coded the iframe src and full-screen <a>
href to '${name}/index.html' without forwarding window.location.search
or window.location.hash, so navigating to examples/SkyboxViewer.html
?fileURL=... loaded the example wrapper but the iframe inside got the
URL without any parameters. Add a <script setup> block with an
onMounted hook that re-binds the iframe and link to the page URL plus
the current search/hash; Vue's :src/:href bindings then update both.

develop_webxr.md linked to legacy paths like GeometryViewer/Geometry-
Viewer.html and WebXRVolume/WebXRVolume.html that the new build never
emits. After the new build the working paths are GeometryViewer.html
(the VitePress wrapper, which forwards its query string to the iframe
per the change above) and WebXRVolume.html. Rewrite all ten broken
references and the four nested ones inside fileURL=[...] values.
The Testing sidebar items hard-coded link: '/vtk-js/coverage/...',
but VitePress automatically prepends base: '/vtk-js/' to absolute
links. The built sidebar ended up with /vtk-js/vtk-js/coverage/...
and docs:build logged "No matching file" warnings. Drop the manual
prefix so VitePress emits the intended single-prefixed path.

Coverage report files are still generated separately by CI and
served at /vtk-js/coverage/ on the deployed site; this fix only
corrects the sidebar link the docs site emits.
docs:generate writes ~200 files into Documentation/api/, ~179 into
Documentation/examples/, and a Documentation/examples/gallery.js
manifest. None were gitignored, so a casual `git add Documentation/`
after running the docs pipeline locally would commit hundreds of
build artifacts. Both directories keep one hand-authored index.md
that stays tracked via negated patterns.

sidebar.ts is left tracked-as-placeholder for now (config.ts imports
it; gitignoring needs a fresh-checkout strategy that's out of scope).
The Vite migration dropped the slimmed-down vtk-lite.js UMD bundle.
It was never documented but was published to npm and CDN for years,
and at least one downstream (sphinxcontrib-cadquery) vendors it.

Copy vtk.js to vtk-lite.js so existing <script src=.../vtk-lite.js>
and CDN consumers keep working. Byte-identical so the shared
sourceMappingURL still resolves vtk.js.map. Adds ~2.65MB to the UMD
npm tarball — accepted as a one-version deprecation alias.

BREAKING_CHANGES.md notes the alias and the one behavior change
worth flagging: anything indexing into the ColorMaps array by
position now sees the full preset set instead of the lite subset.
vite.config.js had grown to ~450 lines holding six inline Rollup
plugins (copyEsmAssetsPlugin, copyUmdAssetsPlugin,
generateDtsReferencesPlugin, cleanupAssetsPlugin, injectEsmCssPlugin,
inlineUmdCssPlugin) plus five filesystem helpers and the
SOURCE_IGNORE_LIST/ignoreSourceFile pair. Move all of that to a new
Utilities/build/vtk-plugins.mjs alongside the existing generic
plugins.mjs, so vite.config.js becomes config wiring only (~150
lines). Same closeBundle/generateBundle behavior, including the
vtk-lite.js alias and the .module.css inlining.

Drive-by cleanups while moving:

- Extract flattenIndexEntry so both the ESM entryFileNames callback
  and generateDtsReferencesPlugin use a single Foo/index -> Foo
  helper instead of duplicate regexes.
- Tighten externals from new RegExp('^' + name) to ^name(/|$) so a
  hypothetical @types/webxray-foo wouldn't be matched by the
  @types/webxr pattern. Today nothing collides, but the old form
  was a quiet footgun.
- Replace the hand-rolled copyDir with fs.cpSync({recursive:true}),
  stable in the Node 22 we already pin in CI.
- Share the style-injection IIFE template between the ESM and UMD
  CSS plugins.

Also flip ESM sourcemap to true. UMD already had it; ESM was off
without a stated reason and the old rollup ESM build emitted them.
Per-file .map files are siblings of each preserveModules chunk.
The migration left compareImages calling expect() with the
condition as the value and the diagnostic message as the second
positional arg, then chaining .toBeTruthy():

  expect(minDelta < mismatchTolerance, '...').toBeTruthy();

This works because Vitest 4's expect(value, message) carries the
message into the assertion error, but it reads like the old tape
t.ok(cond, msg) shape and obscures both the matcher and the actual
value on failure.

Switch to expect(value, msg).toBeLessThan(...) so the failure
report shows the actual delta percentage instead of just "true is
not truthy". Mirror the dimensions check with .toBe(true).
example-runner-cli.js was CJS (require) that loaded Vite via
dynamic import, then spawned createServer({ configFile: ... })
pointing at vite.example.config.mjs (ESM). The two halves
communicated by mutating process.env with EXAMPLE_ENTRY,
EXAMPLE_NAME, EXAMPLE_HOST, etc., which the config file then read
back at module-load time. Mixing module formats in a modernization
PR is jarring, and the env-var protocol forces a singleton vite
process per CLI invocation.

Rewrite the CLI as .mjs and switch vite.example.config.mjs from a
defineConfig'd default export to an exported
createExampleConfig({ repoRoot, entry, name, host, port,
openBrowser, useHttps }) function. The CLI imports that function
and passes the result directly to createServer() with
configFile:false, so there's no env-var handoff and no file path
to keep in sync. Also expose --host and --port flags now that
they're cheap to thread through.

Run via the new path in package.json scripts:
  example, example:https, example:webgpu
Local runs popped the Vitest browser-mode test runner UI every
invocation. CI was already headless (vitest detects CI=1
automatically); set browser.headless explicitly so local matches.

browser.headless suppresses both the UI tab and the test
execution browser; setting it on the per-instance launch options
only affects the latter.
@daker
Copy link
Copy Markdown
Collaborator Author

daker commented May 18, 2026

@PaulHax FF test are failing due to no webgl context, i still believe that trace: "retain-on-failure" is needed, maybe it will make Firefox tests passes when Playwright is "watching" either by recording videos or traces.

@daker
Copy link
Copy Markdown
Collaborator Author

daker commented May 18, 2026

@PaulHax try : trace: 'retain-on-failure',

PaulHax added 7 commits May 18, 2026 18:23
Headless Firefox under Playwright disables WebGL when the GPU
blocklist trips, which is always the case in GitHub Actions runners.
The CI Firefox instance was launched with no firefoxUserPrefs, so
every rendering test logged "no webgl context" and failed.

Apply the same prefs in CI as locally (webgl.force-enabled plus
disable-fail-if-major-performance-caveat) so xvfb-run + llvmpipe is
accepted as a software renderer. Local mode already had these prefs;
this just dedupes them and adds the CI branch.
- Remove xvfb-run from build-test.yml: every browser launches headless
  (browser.headless: true + chromium --headless flag from Playwright),
  so no X server is consumed.
- Remove --headless=new from the chromium args; Playwright already
  passes the right headless flag for the version it ships.
- Gate junit reporter on CI so local runs don't write
  Utilities/TestResults/junit-report.xml every invocation.
- Add cache: 'npm' to the setup-node-project composite action so both
  workflows pick up the npm cache without duplication.
- Drop "Running tests in CI mode" log; Vitest re-evaluates the config
  per browser instance so it printed twice and added nothing.
The script was `cross-env CI=1 vitest run`. GitHub Actions auto-sets
CI=true, so the cross-env wrapper was a no-op in CI — its only caller.
The misleading "reporter" name is a fossil from when this script
toggled the junit reporter, which is now unconditional.

Workflow now calls `npm test` directly.
…chromium

Extract the Firefox instance object (browser + prefs) once at module
scope and reference it from both CI and local branches. Drops the
old quirk where firefoxUserPrefs were attached to local Chromium
launch (Playwright ignored them; just noise).
Splits the single --with-deps install step into three:

- Read playwright version from node_modules (post-npm-ci) for the
  cache key, so the cache invalidates exactly when the dep bumps.
- actions/cache@v4 over ~/.cache/ms-playwright keyed on OS + version.
- Browser binary download runs only on cache miss (~300MB skipped on
  hit). System libs (libgbm, ffmpeg, etc.) run every time since
  apt-installs aren't cacheable per job.

Saves ~30-90s per CI run.
PR Kitware#15 in this fork proved Firefox WebGL passes with per-instance
launch.headless: true + xvfb-run. The earlier simplifications here
broke Firefox because Vitest's global browser.headless follows a
different launch path (Firefox drops WebGL on Linux CI runners).

- Restore xvfb-run --auto-servernum on the test step.
- Restore per-instance launch.headless: true for both chromium and
  firefox (CI and local).
- Bump actions/checkout v4→v6, setup-node v4→v6, cache v4→v5,
  upload-artifact v4→v7 to silence the Node 20 deprecation warning
  without falling back to FORCE_JAVASCRIPT_ACTIONS_TO_NODE24.
Three followups surfaced by /simplify review:

- publish.yml was still calling the deleted `test:reporter` script and
  using v4 actions. Switch it to `npm test` and bump checkout v4→v6 /
  upload-artifact v4→v7 to match build-test.yml.
- Cache key for Playwright browsers uses matrix.os (ubuntu-24.04) so
  the cache invalidates if the runner image is bumped (runner.os is
  just "Linux" and would reuse a stale cache after an OS upgrade).
- vitest.config.js: tighten the header comment and the Firefox-prefs
  inline notes — same WHY, half the lines.
@daker
Copy link
Copy Markdown
Collaborator Author

daker commented May 18, 2026

@PaulHax FF tests failed again 😭 i am not sure if FF WebGL in headless mode is working https://bugzilla.mozilla.org/show_bug.cgi?id=1375585

PaulHax added 3 commits May 18, 2026 22:41
Set `screenshotDirectory: 'Utilities/TestResults/screenshots'` on the
Vitest browser provider so failure screenshots land where the existing
CI test-results artifact upload already picks them up.

Add `.vitest-attachments/` to .gitignore — that's the transient dir
Vitest writes other browser-test attachments into, which doesn't need
to be tracked.
The Vite-migration build wrote `"type": "module"` into the published
package.json and stopped copying `Utilities/config/`. Two regressions
vs `@kitware/vtk.js@35.15.2`:

1. Shipped CommonJS files (webpack-config helpers, bin CLIs) failed to
   load with `ReferenceError: require is not defined`.
2. `Utilities/config/*` helpers vanished — webpack/vue-CLI consumers
   relying on
   `require('@kitware/vtk.js/Utilities/config/rules-vtk')` etc. broke.

Restore both:

- Copy `Utilities/config/` into `dist/esm` (alongside the existing
  `Utilities/XMLConverter` and `Utilities/DataGenerator` copies).
- Write a nested `{"type": "commonjs"}` package.json into each of the
  three CJS subdirs so their `.js` files load via `require()` despite
  the root `"type": "module"`. Node respects nearest-ancestor scope.

Add a CI smoke step that packs `dist/esm`, installs the tarball in a
scratch dir, then exercises three previously-regressed surfaces:
extension-less `require()` of two config helpers and `--help` on the
`vtkDataConverter` bin.
@PaulHax
Copy link
Copy Markdown
Collaborator

PaulHax commented May 18, 2026

I think we got the FireFox CI issue by getting the config plumming right so it gets a headed browser in 39f2b94

@daker
Copy link
Copy Markdown
Collaborator Author

daker commented May 18, 2026

let's see what @finetjul and @jourdain have to say

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants