Repository navigation
build: migrate bundler and test runner from webpack/Jest to Vite/Vitest - #236
Conversation
- Replace top-level require()/jest.requireMock() with import declarations - Hoist in-function require()/jest.requireMock() to namespace imports - Use dynamic import() after resetModules()/doMock() - Convert rogersBankPage and wealthsimpleAuthCookie helpers to ESM exports - Prepares the suite for Vitest, where vi.mock only intercepts ESM imports
- Add vite, vitest, @vitest/coverage-v8, and jsdom devDependencies - Configure Vitest in vite.config.mjs (jsdom, globals, setupFiles, v8 coverage) - Extract debugLog into src/core/logger.ts and replace CJS require() cycles in src/ with ESM imports - Replace jest.* APIs with vi.* across test/ and add default export shims to vi.mock factories - Fix unawaited setTimeout callbacks in test/ui/categorySelector.test.js - Update package.json test, test:coverage, and test:watch scripts to use Vitest
- Configure Vite IIFE library build in vite.config.mjs (entry src/index.ts, output dist/monarch-uploader.user.js) - Add userscript-banner plugin to prepend src/userscript-metadata.cjs post-minify - Configure Terser minifier with ascii_only: true and userscript comment preservation - Update build and watch scripts in package.json to use vite build - Update test/build/bundleEncoding.test.js to validate the Vite build and Terser config
- Uninstall webpack, webpack-cli, terser-webpack-plugin, babel-loader, @babel/*, jest, jest-environment-jsdom, and c8 - Delete webpack.config.cjs, jest.config.cjs, and .babelrc - Update tsconfig.json types to include vitest/globals - Update eslint.config.mjs globals from jest to vitest - Update knip.json and .github/dependabot.yml for Vite and Vitest - Replace @jest-environment docblocks with @vitest-environment in test/ui/
…o 8.0.3 - Update AGENTS.md, .claude commands/skills, and integration runbook - Update entry point and integration barrel docstrings for Vite - Bump version from 8.0.2 to 8.0.3
ReviewNothing blocks merging. There's one real bug, which only affects dev builds, plus a few smaller fixes and some nits. What I checked:
P1: fix before merge
P2: should address
P3: nits and cleanup
🤖 Generated with Claude Code |
- Inject userscript banner via rollupOptions.output.banner in dev/watch builds so inline source maps include the banner line offset - Set explicit build.target (chrome100, firefox100, safari16.4) in vite.config.mjs - Remove duplicate top-level process.env.TZ assignment and unused rollupOptions.output.extend in vite.config.mjs - Enable pool: 'vmThreads' in Vitest config (cutting test run time from ~24s to ~5s) and remove global.window reassignment in monarchLoginLink.test.js - Add typecheck step to .github/workflows/ci.yml - Clean up outdated cycle comments in integrationCapabilities.ts and utils.ts, and use named imports in utils.ts - Remove redundant ignore entries in eslint.config.mjs - Remove unused __esModule: true properties across test mocks and dead vi.doMock in categorySelector.test.js - Add jsdom runtime boot smoke tests and dev banner source-map test to test/build/bundleEncoding.test.js
Review, round 2 (
|
| # | Item | How I checked |
|---|---|---|
| 1 | Dev source maps off by ~30 lines | Fixed. In a dev build, generated lines now resolve exactly to src/index.ts:119 and src/core/logger.ts:12. |
| 2 | Minimum browser version | Set explicitly: chrome100 / firefox100 / safari16.4. |
| 3 | Stale cycle comments | Fixed, and utils.ts now uses named imports. |
| 4 | CI typecheck | Added to ci.yml. |
| 5–7, 10 | Leftover config, duplicate timezone setting, ESLint ignores, __esModule entries, dead vi.doMock |
All cleaned up; no __esModule entries remain. |
| 6 | Timezone now set only when mode === 'test' |
Tests see America/Vancouver whether the shell has no TZ, UTC or Asia/Tokyo, with and without coverage. The full suite also passes under TZ=Asia/Tokyo. |
P1: the boot smoke test checks much less than it claims
test/build/bundleEncoding.test.js, runtime boot smoke test (jsdom):
- Every case runs on
localhost.global.mockLocationintest/setup.jsexpects an object, but the test passes a URL string. The string is spread into keys like'0','1', …, andhreffalls back tohttp://localhost/.- The helper doesn't work even when given
{ href }:window.location.hostnamestill readslocalhostunder thevmThreads,threadsandforkspools alike. jsdom doesn't allowlocationto be overridden. - This test is the only caller of
mockLocation, so nothing exposed the problem. - Result: the six site cases are one identical case, and no site-specific code path runs.
initAppnever runs. jsdom'sreadyStateis'complete', sosrc/index.ts:114schedulesinitAppwithsetTimeout(…, 1000).vi.clearAllTimers()infinallythen discards it. The test only covers evaluating the bundle and setting up the early hooks.- Each case runs the bundle again on the same shared window. Side effects pile up across the six cases, such as the Rogers XHR wrapper and extra event listeners.
Suggested fix: create a fresh new JSDOM('<!doctype html>…', { url, runScripts: 'outside-only' }) for each case, with GM stubs. jsdom is already a dev dependency. Then eval the bundle, dispatch DOMContentLoaded, advance past 1s, and assert that there were no error events and no console.error calls.
P3: nits
- The dev-banner test asserts config details (
pluginsequals[],rollupOptions.output.banneris a function) instead of the result. Building the dev bundle and checking one known source line through the map would test the actual fix and survive refactors. - The comment on
build.targetsays "without legacy polyfill bloat", but Vite never adds polyfills. It only converts newer syntax for older browsers. Something like "keeps native ES2022 syntax" would be accurate. vmThreadsis a big speed-up, but Vitest documents caveats: memory growth, andinstanceofchecks on built-in types can fail across contexts. The suite passes today. Keep it in mind if oddinstanceoffailures appear later.- Optional: the PR description still doesn't mention the minimum browser change (Chrome 61 / Firefox 60 → 100 / 100). That's worth a line, and it bears on whether this should be a patch or a minor bump.
🤖 Generated with Claude Code
…ify dev source map VLQ mapping - Create a fresh JSDOM instance per site URL in test/build/bundleEncoding.test.js, dispatch DOMContentLoaded, advance timers through initApp (1000ms), and assert zero errors and site-specific initialization logs - Build the development bundle in bundleEncoding.test.js and decode its inline VLQ source map to verify line alignment with src/index.ts after the userscript banner - Remove dead broken mockLocation/restoreLocation helpers from test/setup.js and expose Web Streams globals for JSDOM under vmThreads - Clarify build.target comment in vite.config.mjs
Review, round 3 (
|
| Planted failure | Result |
|---|---|
throw new Error(...) (sync) |
✅ All site cases fail |
Promise.resolve().then(() => { throw … }) |
|
Promise.resolve().then(() => console.error(...)) |
❌ Not caught: all 19 tests pass |
P2
- Async errors during startup aren't caught. The test is synchronous: it asserts
errors/consoleErrorsright after draining the timers, before any queued promise callbacks run. Thendom.window.close()runs. AnythinginitAppstarts asynchronously is never checked. The fix is small: make the testasyncand addawait new Promise((r) => setImmediate(r))(or a few such ticks) before theexpects. - The
unhandledrejectionlistener never fires. jsdom doesn't dispatch that event; I checked with a standaloneJSDOMandPromise.reject, and the listener stayed silent. Rejections are only caught by Vitest's own global handler, which fails the run without pointing at the test. Either remove the listener or collect rejections some other way. One option is to wrap the window'sPromiserejection path; another is to rely on theawaitfrom item 1 plusconsole.error.
P3: nits
- The timer loop has no cap on iterations.
while (timeouts.length > 0)re-runs any callback with a delay of 1000ms or less, including ones queued during the loop. If startup ever gets a poll that reschedules itself (e.g.setTimeout(poll, 500)), the loop spins forever. The test's timeout can't interrupt a synchronous loop, so the worker hangs instead of failing. Add a cap such asfor (let i = 0; i < 100 && timeouts.length; i++). - Timers run in the order they were queued, not by delay. A 1000ms callback queued before a 0ms one runs first. That's harmless today, but sorting by
delaywould match real browsers. - The Rogers case uses
rbaccess.rogersbank.com, but the userscript only matchesselfserve.rogersbank.com/*. The test passes becauseindex.tschecksincludes('rogersbank.com'), but the real script never runs on that host. Useselfserve.rogersbank.com. expect(menuCommands).toHaveLength(5)will break whenever a menu command is added. Assert specific command names, or at least> 0.- The test hand-writes a ~35-line source-map decoder. It's correct for this purpose, so this is fine to leave.
source-map-jsis already installed as a dependency of Vite, so adding it as a dev dependency would replace those lines with three. Your call.
🤖 Generated with Claude Code
- Make runtime boot smoke test async and flush setImmediate ticks after eval, DOMContentLoaded, each timer step, and initApp so async console.error calls and promise rejections are caught
- Capture unhandled promise rejections via process.on('unhandledRejection') instead of a no-op window listener
- Order queued timers by dueAt on a virtual clock with a 100-step cap and add Questrade and Canada Life cases
- Use https://selfserve.rogersbank.com/home for Rogers Bank and assert menuCommands contains 'Clear All Cached Data'
Review, round 4 (
|
| # | Item | Fix |
|---|---|---|
| 1 | Async errors not caught | The test is now async and lets pending promise work finish after each step and at the end. |
| 2 | unhandledrejection listener never fires |
Replaced with process.on('unhandledRejection'), which is removed in finally. |
| 3 | Timer loop could hang | Capped at 100 steps, with an expect(steps).toBeLessThan(MAX_TIMER_STEPS) check. |
| 4 | Timers run in queue order | A simulated clock now runs them in order of due time and stops past 1000ms. |
| 5 | Rogers host doesn't match @match |
Now selfserve.rogersbank.com/home. |
| 6 | Hard-coded toHaveLength(5) |
Now toContain('Clear All Cached Data'). |
| 7 | Hand-written source-map decoder | Kept. That was optional, so fine. |
Planted failures
I put each of these at the top of initApp and re-ran the test:
| Planted failure | Result |
|---|---|
Sync throw |
✅ All 8 boot cases fail |
Async console.error |
✅ All 8 fail (round 3: not caught) |
| Async promise rejection | ✅ All 8 fail, and the failure now points at the test |
| Poll that reschedules itself every 0ms | ✅ All 8 fail through the step cap; no hang |
| Poll that reschedules itself every 500ms | ✅ Passes, which is correct: the clock stops at 1000ms |
Leftover observations (not worth another round)
setIntervalis stubbed out and never fires, so code driven by intervals isn't exercised. That's an accepted limit of a boot smoke test.- The
process.on('unhandledRejection')listener would also catch an unrelated rejection from elsewhere in the same worker while a case runs. Cases in a file run one after another, so the risk is low.
🤖 Generated with Claude Code
Summary
vite.config.mjs), preserving single-file IIFE output (dist/monarch-uploader.user.js), Terserascii_only: trueminification, and UserScript metadata header injection (src/userscript-metadata.cjsviarollupOptions.output.bannerin dev builds for accurate inline source maps and post-minify in production builds).babelrc(Chrome 61/Firefox 60) to explicitbuild.target: ['chrome100', 'firefox100', 'safari16.4']for modern evergreen browsers running Violentmonkey/Tampermonkey (keeping native ES2022?./??/async-awaitsyntax without downleveling)@vitest/coverage-v8(pool: 'vmThreads', reducing full suite execution time from ~24s to ~5s)require()/jest.requireMock()calls to ESM imports andjest.*APIs tovi.*debugLogintosrc/core/logger.tsand replace lazyrequire('./integrationRegistry')cycles insrc/with standard ESM importswebpack,webpack-cli,terser-webpack-plugin,babel-loader,@babel/core,@babel/preset-env,@babel/preset-typescript,jest,jest-environment-jsdom,c8) and config files (webpack.config.cjs,jest.config.cjs,.babelrc)npm run typecheckstep to.github/workflows/ci.ymland updatetsconfig.json,eslint.config.mjs,knip.json,.github/dependabot.yml, and project documentation8.0.3Test Plan
npm run build:fullpasses with zero errors or warningspool: 'vmThreads')test/build/bundleEncoding.test.jsverifies pure 7-bit ASCII output, UserScript metadata block, Terserascii_onlyconfig, end-to-end dev inline source map VLQ line resolution after the banner, and isolatednew JSDOM(..., { url })runtime boot smoke tests throughDOMContentLoadedandinitAppacross all supported institutionsdist/monarch-uploader.user.jslocally