diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de8830df..1769c405 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,9 +55,6 @@ jobs: - name: Verify generated config examples run: bun run verify:config-example - - name: Internal docs knowledge-base checks - run: bun run test:docs:kb - - name: Test suite (source) run: bun run test:fast @@ -71,6 +68,9 @@ jobs: path: coverage/test-src/lcov.info if-no-files-found: error + - name: Stats UI tests + run: bun run test:stats + - name: Launcher smoke suite (source) run: bun run test:launcher:smoke:src diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 8818ca4e..e183d6f4 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -70,6 +70,9 @@ jobs: path: coverage/test-src/lcov.info if-no-files-found: error + - name: Stats UI tests + run: bun run test:stats + - name: Launcher smoke suite (source) run: bun run test:launcher:smoke:src diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e1778227..722bca6f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -61,6 +61,9 @@ jobs: path: coverage/test-src/lcov.info if-no-files-found: error + - name: Stats UI tests + run: bun run test:stats + - name: Launcher smoke suite (source) run: bun run test:launcher:smoke:src diff --git a/AGENTS.md b/AGENTS.md index 306d9e6f..7be4dee1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,10 @@ Start here, then leave this file. - Config/schema/defaults: `bun run test:config`; if template/defaults changed, `bun run generate:config-example` - Launcher/plugin: `bun run test:launcher` or `bun run test:env` - Runtime-compat / dist-sensitive: `bun run test:runtime:compat` +- Stats dashboard UI (`stats/`): `bun run test:stats` +- Build/release scripts (`scripts/**`): `bun run test:scripts` - Docs-only: `bun run docs:test`, then `bun run docs:build` +- Test lanes are directory-discovered via `scripts/test-lanes.ts`; never hand-list test files in `package.json` ## Docs Upkeep diff --git a/changes/test-lane-overhaul.md b/changes/test-lane-overhaul.md new file mode 100644 index 00000000..8580ce13 --- /dev/null +++ b/changes/test-lane-overhaul.md @@ -0,0 +1,7 @@ +type: internal +area: testing + +- Test lanes are now defined once in `scripts/test-lanes.ts` and discovered by directory instead of hand-maintained file lists in `package.json`; the unused `test:core:*`/`test:config:dist`/`test:full` scripts were removed. +- `scripts/run-test-lane.mjs` runs each test file in an isolated `bun test` process with a wall timeout, so a hanging test or leaked global can no longer cascade failures across the lane. +- Previously orphaned suites now run in CI: the stats dashboard tests (`bun run test:stats`), the `scripts/**` tests (`bun run test:scripts`, including the change-verification skill tests), the `test-plugin-process-start-retries.lua` plugin test, and the runtime-compat dist slice (now part of `bun run test:fast`). +- The change-verification skill gained a `stats` lane for `stats/` edits. diff --git a/docs-site/development.md b/docs-site/development.md index 1433c6ac..b53f06d9 100644 --- a/docs-site/development.md +++ b/docs-site/development.md @@ -80,18 +80,24 @@ Default lanes: ```bash bun run test # alias for test:fast -bun run test:fast # default fast lane -bun run test:full # maintained source + launcher-unit + runtime compat surface +bun run test:fast # full source lanes: src + launcher-unit + scripts + runtime compat bun run test:runtime:compat # compiled/runtime compatibility slice only bun run test:env # launcher/plugin + env-sensitive verification +bun run test:stats # stats dashboard UI suite bun run test:immersion:sqlite # SQLite persistence lane bun run test:subtitle # maintained alass/ffsubsync subtitle surface ``` -- `bun run test` and `bun run test:fast` cover config/core suites plus representative entry/runtime, Anki integration, release-workflow coverage, typecheck, and runtime-registry checks. -- `bun run test:full` is the maintained full surface: Bun-compatible `src/**` discovery, Bun-compatible launcher unit discovery, and the compiled/runtime compatibility lane for suites routed through `dist/**`. +Test lane membership is defined once in `scripts/test-lanes.ts` and discovered by +directory, so new test files join their lane automatically. `scripts/run-test-lane.mjs` +runs each test file in its own `bun test` process (per-file isolation) so a hanging +test or leaked global in one file cannot cascade into the rest of the lane; pass +`--jobs N` to parallelize or `--single-process` for one shared process. + +- `bun run test` and `bun run test:fast` cover the full discovered `src/**` suite, launcher unit tests, `scripts/**` tests, and the compiled/runtime compatibility lane. - `bun run test:runtime:compat` covers the compiled/runtime slice directly: `ipc`, `anki-jimaku-ipc`, `overlay-manager`, `config-validation`, `startup-config`, and `registry`. - `bun run test:env` covers environment-sensitive checks: launcher smoke/plugin verification plus the Bun source SQLite lane. +- `bun run test:stats` runs the stats dashboard suite under `stats/src/**`. - `bun run test:immersion:sqlite` is the reproducible persistence lane when you need real DB-backed SQLite coverage under Bun. The Bun-managed discovery lanes intentionally exclude a small compiled/runtime-focused set: `src/core/services/ipc.test.ts`, `src/core/services/anki-jimaku-ipc.test.ts`, `src/core/services/overlay-manager.test.ts`, `src/main/config-validation.test.ts`, `src/main/runtime/startup-config.test.ts`, and `src/main/runtime/registry.test.ts`. `bun run test:runtime:compat` keeps them in the standard workflow via `dist/**`. @@ -126,11 +132,11 @@ Focused commands: ```bash bun run test:config # Source-level config schema/validation tests bun run test:launcher # Launcher regression tests (config discovery + command routing) -bun run test:core # Source-level core regression tests (default lane) bun run test:launcher:smoke:src # Launcher e2e smoke: launcher -> mpv IPC -> overlay start/stop wiring bun run test:launcher:env:src # Launcher smoke + Lua plugin gate bun run test:src # Bun-managed maintained src/** discovery lane bun run test:launcher:unit:src # Bun-managed maintained launcher unit lane +bun run test:scripts # Bun-managed scripts/** test lane bun run test:immersion:sqlite:src # Bun source lane ``` @@ -144,8 +150,6 @@ Smoke and optional deep dist commands: bun run build # compile dist artifacts bun run test:immersion:sqlite # compile + run SQLite-backed immersion tests under Bun bun run test:smoke:dist # explicit smoke scope for compiled runtime -bun run test:config:dist # optional full dist config suite -bun run test:core:dist # optional full dist core suite ``` Use `bun run test:immersion:sqlite` when you need real DB-backed coverage for the immersion tracker. diff --git a/docs/workflow/verification.md b/docs/workflow/verification.md index c71671ca..9a861c5b 100644 --- a/docs/workflow/verification.md +++ b/docs/workflow/verification.md @@ -3,10 +3,22 @@ # Verification Status: active -Last verified: 2026-05-23 +Last verified: 2026-07-06 Owner: Kyle Yasuda Read when: selecting the right verification lane for a change +## Lane Infrastructure + +- Lane membership is defined once in `scripts/test-lanes.ts` and discovered by + directory — new test files join their lane automatically; never hand-list test + files in `package.json`. +- `scripts/run-test-lane.mjs` runs each test file in its own `bun test` process + (per-file isolation with a wall timeout) so a hanging test or leaked global in + one file cannot cascade into the rest of the lane. `--jobs N` parallelizes; + `--single-process` restores the shared-process mode for debugging. +- `bun run test:fast` is the full source gate: discovered `src/**`, launcher + unit, `scripts/**`, and the compiled runtime-compat slice. + ## Default Handoff Gate ```bash @@ -31,6 +43,8 @@ bun run docs:build - Config/schema/defaults: `bun run test:config`, then `bun run generate:config-example` if template/defaults changed - Launcher/plugin: `bun run test:launcher` or `bun run test:env` - Runtime-compat / compiled behavior: `bun run test:runtime:compat` +- Stats dashboard UI: `bun run test:stats` +- Build/release scripts (`scripts/**`): `bun run test:scripts` - Coverage for the maintained source lane: `bun run test:coverage:src` - Deep/local full gate: default handoff gate above diff --git a/package.json b/package.json index 3a2022fd..19d7b3e3 100644 --- a/package.json +++ b/package.json @@ -44,35 +44,32 @@ "docs:preview": "bun run --cwd docs-site docs:preview", "docs:test": "bun run --cwd docs-site test", "test:docs:kb": "bun test scripts/docs-knowledge-base.test.ts", - "test:config:src": "bun test src/config/config.test.ts src/config/path-resolution.test.ts src/config/resolve/anki-connect.test.ts src/config/resolve/integrations.test.ts src/config/resolve/subtitle-style.test.ts src/config/resolve/jellyfin.test.ts src/config/definitions/domain-registry.test.ts src/generate-config-example.test.ts src/verify-config-example.test.ts", - "test:config:dist": "bun test dist/config/config.test.js dist/config/path-resolution.test.js dist/config/resolve/anki-connect.test.js dist/config/resolve/integrations.test.js dist/config/resolve/subtitle-style.test.js dist/config/resolve/jellyfin.test.js dist/config/definitions/domain-registry.test.js dist/generate-config-example.test.js dist/verify-config-example.test.js", + "test:config:src": "bun scripts/run-test-lane.mjs config", "test:config:smoke:dist": "bun test dist/config/path-resolution.test.js", - "test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-restart-feedback.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua", + "test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-process-start-retries.lua && lua scripts/test-plugin-restart-feedback.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua", "test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts", - "test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/history.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src", - "test:core:src": "bun test src/preload-settings.test.ts src/settings/settings-anki-controls.test.ts src/settings/settings-model.test.ts src/settings/settings-field-layout.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/shared/mpv-x11-backend.test.ts src/core/services/cli-command.test.ts src/core/services/ipc.test.ts src/core/services/anki-jimaku-ipc.test.ts src/core/services/field-grouping.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/settings-window-z-order.test.ts src/core/services/hyprland-window-placement.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-manager.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/stats-window-lifecycle.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/main/overlay-runtime.test.ts src/main/runtime/macos-mpv-focus.test.ts src/main/runtime/macos-modal-focus-handoff.test.ts src/main/runtime/current-subtitle-snapshot.test.ts src/main/runtime/youtube-media-cache-playback.test.ts src/main/runtime/autoplay-ready-gate.test.ts src/main/runtime/autoplay-tokenization-warm-release.test.ts src/main/runtime/autoplay-subtitle-primer.test.ts src/main/runtime/visible-overlay-autoplay-readiness.test.ts src/main/runtime/character-dictionary-manager-gate.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/core/utils/electron-backend.test.ts src/core/utils/notification.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/linux-overlay-pointer-interaction.test.ts src/main/runtime/windows-overlay-pointer-interaction.test.ts src/main/runtime/linux-overlay-zorder-keepalive.test.ts src/main/runtime/config-settings-window.test.ts src/main/runtime/settings-window-z-order.test.ts src/main/runtime/setup-window-factory.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/windows-mpv-plugin-detection.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/cli-command-context.test.ts src/main/runtime/composers/cli-startup-composer.test.ts src/main/runtime/log-export.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/overlay-bootstrap-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/overlay-content-measurement.test.ts src/renderer/subtitle-render.test.ts src/renderer/subtitle-render-word-class.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/main/character-dictionary-runtime/term-building.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts src/core/services/overlay-visibility.test.ts src/core/services/overlay-window-config.test.ts src/core/services/overlay-window.test.ts src/main/main-wiring.test.ts src/main/runtime/linux-mpv-fullscreen-overlay-refresh.test.ts src/main/runtime/mpv-main-event-actions.test.ts src/main/runtime/overlay-modal-input-state.test.ts src/main/runtime/overlay-window-factory-main-deps.test.ts src/main/runtime/overlay-window-factory.test.ts src/main/runtime/overlay-window-layout-main-deps.test.ts src/main/runtime/overlay-window-layout.test.ts src/main/runtime/overlay-window-runtime-handlers.test.ts src/main/runtime/yomitan-extension-overlay-reload.test.ts src/renderer/modals/subtitle-sidebar.test.ts src/renderer/overlay-mouse-ignore.test.ts src/main/runtime/linux-visible-overlay-window-mode.test.ts src/main/runtime/linux-x11-cursor-point.test.ts src/renderer/renderer-init-order.test.ts", - "test:core:dist": "bun test dist/preload-settings.test.js dist/settings/settings-anki-controls.test.js dist/settings/settings-model.test.js dist/settings/settings-field-layout.test.js dist/cli/args.test.js dist/cli/help.test.js dist/shared/setup-state.test.js dist/shared/mpv-x11-backend.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/yomitan-extension-loader.test.js dist/core/services/yomitan-settings.test.js dist/core/services/settings-window-z-order.test.js dist/core/services/hyprland-window-placement.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/stats-window.test.js dist/core/services/stats-window-lifecycle.test.js dist/core/services/__tests__/stats-server.test.js dist/main/runtime/stats-server-routing.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/main/overlay-runtime.test.js dist/main/runtime/macos-mpv-focus.test.js dist/main/runtime/macos-modal-focus-handoff.test.js dist/main/runtime/current-subtitle-snapshot.test.js dist/main/runtime/youtube-media-cache-playback.test.js dist/main/runtime/autoplay-ready-gate.test.js dist/main/runtime/autoplay-tokenization-warm-release.test.js dist/main/runtime/autoplay-subtitle-primer.test.js dist/main/runtime/visible-overlay-autoplay-readiness.test.js dist/main/runtime/character-dictionary-manager-gate.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/core/utils/shortcut-config.test.js dist/core/utils/electron-backend.test.js dist/core/utils/notification.test.js dist/main/runtime/startup-mode-flags.test.js dist/main/runtime/linux-overlay-pointer-interaction.test.js dist/main/runtime/windows-overlay-pointer-interaction.test.js dist/main/runtime/linux-overlay-zorder-keepalive.test.js dist/main/runtime/config-settings-window.test.js dist/main/runtime/settings-window-z-order.test.js dist/main/runtime/setup-window-factory.test.js dist/main/runtime/first-run-setup-plugin.test.js dist/main/runtime/windows-mpv-plugin-detection.test.js dist/main/runtime/first-run-setup-service.test.js dist/main/runtime/first-run-setup-window.test.js dist/main/runtime/command-line-launcher.test.js dist/main/runtime/cli-command-context.test.js dist/main/runtime/composers/cli-startup-composer.test.js dist/main/runtime/log-export.test.js dist/main/runtime/tray-runtime.test.js dist/main/runtime/tray-main-actions.test.js dist/main/runtime/tray-main-deps.test.js dist/main/runtime/tray-runtime-handlers.test.js dist/main/runtime/cli-command-context-main-deps.test.js dist/main/runtime/app-ready-main-deps.test.js dist/main/runtime/update/appimage-updater.test.js dist/main/runtime/update/fetch-adapter.test.js dist/main/runtime/update/release-metadata-policy.test.js dist/main/runtime/update/update-dialogs.test.js dist/main/runtime/update/support-assets.test.js dist/renderer/error-recovery.test.js dist/renderer/overlay-content-measurement.test.js dist/renderer/subtitle-render.test.js dist/renderer/subtitle-render-word-class.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/main/character-dictionary-runtime/term-building.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js dist/core/services/overlay-visibility.test.js dist/core/services/overlay-window-config.test.js dist/core/services/overlay-window.test.js dist/main/main-wiring.test.js dist/main/runtime/linux-mpv-fullscreen-overlay-refresh.test.js dist/main/runtime/mpv-main-event-actions.test.js dist/main/runtime/overlay-modal-input-state.test.js dist/main/runtime/overlay-window-factory-main-deps.test.js dist/main/runtime/overlay-window-factory.test.js dist/main/runtime/overlay-window-layout-main-deps.test.js dist/main/runtime/overlay-window-layout.test.js dist/main/runtime/overlay-window-runtime-handlers.test.js dist/main/runtime/yomitan-extension-overlay-reload.test.js dist/renderer/modals/subtitle-sidebar.test.js dist/renderer/overlay-mouse-ignore.test.js dist/main/runtime/linux-visible-overlay-window-mode.test.js dist/main/runtime/linux-x11-cursor-point.test.js dist/renderer/renderer-init-order.test.js", + "test:launcher:src": "bun scripts/run-test-lane.mjs launcher && bun run test:plugin:src", "test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist", "test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts", "test:immersion:sqlite:src": "bun test src/core/services/immersion-tracker-service.test.ts src/core/services/immersion-tracker/storage-session.test.ts", "test:immersion:sqlite:dist": "bun test dist/core/services/immersion-tracker-service.test.js dist/core/services/immersion-tracker/storage-session.test.js", "test:immersion:sqlite": "bun run tsc && bun run test:immersion:sqlite:dist", - "test:src": "bun scripts/run-test-lane.mjs bun-src-full", + "test:src": "bun run build:yomitan && bun scripts/run-test-lane.mjs bun-src-full", "test:coverage:src": "bun run build:yomitan && bun run scripts/run-coverage-lane.ts bun-src-full --coverage-dir coverage/test-src", "test:coverage:subtitle:src": "bun test --coverage --coverage-reporter=text --coverage-reporter=lcov --coverage-dir coverage/test-subtitle src/core/services/subsync.test.ts src/subsync/utils.test.ts", "test:launcher:unit:src": "bun scripts/run-test-lane.mjs bun-launcher-unit", + "test:scripts": "bun scripts/run-test-lane.mjs scripts", + "test:stats": "bun scripts/run-test-lane.mjs stats", "test:launcher:env:src": "bun run test:launcher:smoke:src && bun run test:plugin:src", "test:env": "bun run test:launcher:env:src && bun run test:immersion:sqlite:src", "test:runtime:compat": "bun run tsc && bun test dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/overlay-manager.test.js dist/main/config-validation.test.js dist/main/runtime/registry.test.js dist/main/runtime/startup-config.test.js", "test:node:compat": "bun run test:runtime:compat", - "test:full": "bun run test:src && bun run test:launcher:unit:src && bun run test:node:compat", "test": "bun run test:fast", "test:config": "bun run test:config:src", "test:launcher": "bun run test:launcher:src", - "test:core": "bun run test:core:src", "test:subtitle": "bun run test:subtitle:src", - "test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/card-creation-manual-update.test.ts src/anki-integration/card-creation-sentence-media.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/prerelease-workflow.test.ts src/ci-workflow.test.ts scripts/docs-versioning.test.ts scripts/docs-versioned-assets.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/get-mpv-window-macos.test.ts scripts/prepare-build-assets.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js", + "test:fast": "bun run test:src && bun run test:launcher:unit:src && bun run test:scripts && bun run test:runtime:compat", "generate:config-example": "bun run src/generate-config-example.ts", "verify:config-example": "bun run src/verify-config-example.ts", "start": "bun run build && electron . --start", diff --git a/plugins/subminer-workflow/skills/subminer-change-verification/SKILL.md b/plugins/subminer-workflow/skills/subminer-change-verification/SKILL.md index 585a764d..baf3b2e5 100644 --- a/plugins/subminer-workflow/skills/subminer-change-verification/SKILL.md +++ b/plugins/subminer-workflow/skills/subminer-change-verification/SKILL.md @@ -105,6 +105,8 @@ bash plugins/subminer-workflow/skills/subminer-change-verification/scripts/verif - For `docs-site/`, `docs/`, and doc-only edits. - `config` - For `src/config/` and config-template-sensitive edits. +- `stats` + - For `stats/` dashboard UI edits. - `core` - For general source changes where `typecheck` + `test:fast` is the best cheap signal. - `launcher-plugin` diff --git a/plugins/subminer-workflow/skills/subminer-change-verification/scripts/classify_subminer_diff.sh b/plugins/subminer-workflow/skills/subminer-change-verification/scripts/classify_subminer_diff.sh index a983ff3d..5d5b31c1 100755 --- a/plugins/subminer-workflow/skills/subminer-change-verification/scripts/classify_subminer_diff.sh +++ b/plugins/subminer-workflow/skills/subminer-change-verification/scripts/classify_subminer_diff.sh @@ -108,6 +108,14 @@ for path in "${PATHS[@]}"; do ;; esac + case "$path" in + stats/*) + add_lane "stats" + add_reason "$path -> stats" + specialized=1 + ;; + esac + case "$path" in launcher/*|plugin/subminer/*|plugin/subminer.conf|scripts/test-plugin-*|scripts/get-mpv-window-*|scripts/configure-plugin-binary-path.mjs) add_lane "launcher-plugin" diff --git a/plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh b/plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh index db637977..a03ba636 100755 --- a/plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh +++ b/plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh @@ -482,6 +482,9 @@ for lane in "${SELECTED_LANES[@]}"; do config) run_step "$lane" "config" "bun run test:config" || break ;; + stats) + run_step "$lane" "stats" "bun run test:stats" || break + ;; core) run_step "$lane" "typecheck" "bun run typecheck" || break run_step "$lane" "fast-tests" "bun run test:fast" || break diff --git a/scripts/run-coverage-lane.ts b/scripts/run-coverage-lane.ts index b333e495..c1248e8e 100644 --- a/scripts/run-coverage-lane.ts +++ b/scripts/run-coverage-lane.ts @@ -1,12 +1,7 @@ -import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import { isAbsolute, join, relative, resolve } from 'node:path'; - -type LaneConfig = { - roots: string[]; - include: string[]; - exclude: Set; -}; +import { collectLaneFiles } from './test-lanes'; type LcovRecord = { sourceFile: string; @@ -18,64 +13,6 @@ type LcovRecord = { const repoRoot = resolve(__dirname, '..'); -const lanes: Record = { - 'bun-src-full': { - roots: ['src'], - include: ['.test.ts', '.type-test.ts'], - exclude: new Set([ - 'src/core/services/anki-jimaku-ipc.test.ts', - 'src/core/services/ipc.test.ts', - 'src/core/services/overlay-manager.test.ts', - 'src/main/config-validation.test.ts', - 'src/main/runtime/registry.test.ts', - 'src/main/runtime/startup-config.test.ts', - ]), - }, - 'bun-launcher-unit': { - roots: ['launcher'], - include: ['.test.ts'], - exclude: new Set(['launcher/smoke.e2e.test.ts']), - }, -}; - -function collectFiles( - rootDir: string, - includeSuffixes: string[], - excludeSet: Set, -): string[] { - const out: string[] = []; - const visit = (currentDir: string) => { - for (const entry of readdirSync(currentDir, { withFileTypes: true })) { - const fullPath = resolve(currentDir, entry.name); - if (entry.isDirectory()) { - visit(fullPath); - continue; - } - const relPath = relative(repoRoot, fullPath).replaceAll('\\', '/'); - if (excludeSet.has(relPath)) continue; - if (includeSuffixes.some((suffix) => relPath.endsWith(suffix))) { - out.push(relPath); - } - } - }; - - visit(resolve(repoRoot, rootDir)); - out.sort(); - return out; -} - -function getLaneFiles(laneName: string): string[] { - const lane = lanes[laneName]; - if (!lane) { - throw new Error(`Unknown coverage lane: ${laneName}`); - } - const files = lane.roots.flatMap((rootDir) => collectFiles(rootDir, lane.include, lane.exclude)); - if (files.length === 0) { - throw new Error(`No test files found for coverage lane: ${laneName}`); - } - return files; -} - function parseCoverageDirArg(argv: string[]): string { for (let index = 0; index < argv.length; index += 1) { if (argv[index] === '--coverage-dir') { @@ -277,7 +214,7 @@ function runCoverageLane(): number { rmSync(shardRoot, { recursive: true, force: true }); mkdirSync(shardRoot, { recursive: true }); - const files = getLaneFiles(laneName); + const files = collectLaneFiles(repoRoot, laneName); const reports: string[] = []; try { diff --git a/scripts/run-test-lane.mjs b/scripts/run-test-lane.mjs index d9179bbf..1bc25ace 100644 --- a/scripts/run-test-lane.mjs +++ b/scripts/run-test-lane.mjs @@ -1,73 +1,119 @@ -import { readdirSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; -import { relative, resolve } from 'node:path'; -import { spawnSync } from 'node:child_process'; +import { resolve } from 'node:path'; +import { spawn, spawnSync } from 'node:child_process'; +import { collectLaneFiles } from './test-lanes.ts'; + +// Runs a test lane with per-file process isolation: one `bun test` process per +// test file so a hanging test or leaked global in one file cannot poison the +// rest of the lane. Use --single-process for the old all-in-one-process mode. +// +// Usage: bun scripts/run-test-lane.mjs [--jobs N] [--timeout-secs N] [--single-process] const repoRoot = resolve(fileURLToPath(new URL('..', import.meta.url))); -const lanes = { - 'bun-src-full': { - roots: ['src'], - include: ['.test.ts', '.type-test.ts'], - exclude: new Set([ - 'src/core/services/anki-jimaku-ipc.test.ts', - 'src/core/services/ipc.test.ts', - 'src/core/services/overlay-manager.test.ts', - 'src/main/config-validation.test.ts', - 'src/main/runtime/registry.test.ts', - 'src/main/runtime/startup-config.test.ts', - ]), - }, - 'bun-launcher-unit': { - roots: ['launcher'], - include: ['.test.ts'], - exclude: new Set(['launcher/smoke.e2e.test.ts']), - }, -}; +function parseArgs(argv) { + const options = { lane: undefined, jobs: 1, timeoutSecs: 300, singleProcess: false }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--jobs') { + options.jobs = Math.max(1, Number(argv[(index += 1)]) || 1); + } else if (arg === '--timeout-secs') { + options.timeoutSecs = Math.max(1, Number(argv[(index += 1)]) || 300); + } else if (arg === '--single-process') { + options.singleProcess = true; + } else if (!arg.startsWith('--') && options.lane === undefined) { + options.lane = arg; + } else { + process.stderr.write(`Unknown argument: ${arg}\n`); + process.exit(1); + } + } + return options; +} -function collectFiles(rootDir, includeSuffixes, excludeSet) { - const out = []; - const visit = (currentDir) => { - for (const entry of readdirSync(currentDir, { withFileTypes: true })) { - const fullPath = resolve(currentDir, entry.name); - if (entry.isDirectory()) { - visit(fullPath); - continue; - } - const relPath = relative(repoRoot, fullPath).replaceAll('\\', '/'); - if (excludeSet.has(relPath)) continue; - if (includeSuffixes.some((suffix) => relPath.endsWith(suffix))) { - out.push(relPath); +function runFile(file, timeoutSecs) { + return new Promise((resolvePromise) => { + const child = spawn('bun', ['test', `./${file}`], { cwd: repoRoot }); + let output = ''; + let timedOut = false; + child.stdout.on('data', (chunk) => (output += chunk)); + child.stderr.on('data', (chunk) => (output += chunk)); + const timer = setTimeout(() => { + timedOut = true; + child.kill('SIGKILL'); + }, timeoutSecs * 1000); + child.on('close', (code) => { + clearTimeout(timer); + resolvePromise({ file, code: timedOut ? 124 : (code ?? 1), output, timedOut }); + }); + child.on('error', (error) => { + clearTimeout(timer); + resolvePromise({ file, code: 1, output: String(error), timedOut: false }); + }); + }); +} + +async function runIsolated(files, options) { + const failures = []; + let nextIndex = 0; + let completed = 0; + + async function worker() { + while (nextIndex < files.length) { + const file = files[nextIndex]; + nextIndex += 1; + const result = await runFile(file, options.timeoutSecs); + completed += 1; + if (result.code !== 0) { + failures.push(result); + const reason = result.timedOut ? `timed out after ${options.timeoutSecs}s` : 'failed'; + process.stderr.write(`\n[${completed}/${files.length}] ${file} ${reason}\n`); + process.stderr.write(result.output); } } - }; + } - visit(resolve(repoRoot, rootDir)); - out.sort(); - return out; + await Promise.all(Array.from({ length: Math.min(options.jobs, files.length) }, worker)); + + if (failures.length > 0) { + process.stderr.write(`\n${failures.length} of ${files.length} test files failed:\n`); + for (const failure of failures) { + process.stderr.write(` ${failure.file}${failure.timedOut ? ' (timeout)' : ''}\n`); + } + return 1; + } + process.stdout.write(`All ${files.length} test files passed.\n`); + return 0; } -const lane = lanes[process.argv[2]]; +function runSingleProcess(files) { + const result = spawnSync('bun', ['test', ...files.map((file) => `./${file}`)], { + cwd: repoRoot, + stdio: 'inherit', + }); + if (result.error) { + throw result.error; + } + return result.status ?? 1; +} -if (!lane) { - process.stderr.write(`Unknown test lane: ${process.argv[2] ?? '(missing)'}\n`); +const options = parseArgs(process.argv.slice(2)); + +if (!options.lane) { + process.stderr.write('Missing test lane name\n'); process.exit(1); } -const files = lane.roots.flatMap((rootDir) => collectFiles(rootDir, lane.include, lane.exclude)); - -if (files.length === 0) { - process.stderr.write(`No test files found for lane: ${process.argv[2]}\n`); +let files; +try { + files = collectLaneFiles(repoRoot, options.lane); +} catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : error}\n`); process.exit(1); } -const result = spawnSync('bun', ['test', ...files.map((file) => `./${file}`)], { - cwd: repoRoot, - stdio: 'inherit', -}); - -if (result.error) { - throw result.error; +if (options.singleProcess) { + process.exit(runSingleProcess(files)); } -process.exit(result.status ?? 1); +process.exit(await runIsolated(files, options)); diff --git a/scripts/test-lanes.ts b/scripts/test-lanes.ts new file mode 100644 index 00000000..aa25cedf --- /dev/null +++ b/scripts/test-lanes.ts @@ -0,0 +1,97 @@ +import { readdirSync } from 'node:fs'; +import { relative, resolve } from 'node:path'; + +export type TestLane = { + roots: string[]; + include: string[]; + exclude?: string[]; + extraFiles?: string[]; +}; + +// Single source of truth for test-lane membership. Consumed by +// scripts/run-test-lane.mjs (plain runs) and scripts/run-coverage-lane.ts +// (per-file coverage shards). Lanes discover files by directory so new test +// files join their lane automatically. +export const testLanes: Record = { + 'bun-src-full': { + roots: ['src'], + include: ['.test.ts', '.type-test.ts'], + // Node-compat suites; their dist builds run via test:runtime:compat. + exclude: [ + 'src/core/services/anki-jimaku-ipc.test.ts', + 'src/core/services/ipc.test.ts', + 'src/core/services/overlay-manager.test.ts', + 'src/main/config-validation.test.ts', + 'src/main/runtime/registry.test.ts', + 'src/main/runtime/startup-config.test.ts', + ], + }, + config: { + roots: ['src/config'], + include: ['.test.ts'], + extraFiles: ['src/generate-config-example.test.ts', 'src/verify-config-example.test.ts'], + }, + launcher: { + roots: ['launcher'], + include: ['.test.ts'], + }, + 'bun-launcher-unit': { + roots: ['launcher'], + include: ['.test.ts'], + exclude: ['launcher/smoke.e2e.test.ts'], + }, + scripts: { + roots: ['scripts'], + include: ['.test.ts'], + }, + stats: { + roots: ['stats/src'], + include: ['.test.ts', '.test.tsx'], + }, +}; + +function collectFiles( + repoRoot: string, + rootDir: string, + includeSuffixes: string[], + excludeSet: Set, +): string[] { + const out: string[] = []; + const visit = (currentDir: string): void => { + for (const entry of readdirSync(currentDir, { withFileTypes: true })) { + const fullPath = resolve(currentDir, entry.name); + if (entry.isDirectory()) { + visit(fullPath); + continue; + } + const relPath = relative(repoRoot, fullPath).replaceAll('\\', '/'); + if (excludeSet.has(relPath)) continue; + if (includeSuffixes.some((suffix) => relPath.endsWith(suffix))) { + out.push(relPath); + } + } + }; + + visit(resolve(repoRoot, rootDir)); + out.sort(); + return out; +} + +export function collectLaneFiles(repoRoot: string, laneName: string): string[] { + const lane = testLanes[laneName]; + if (!lane) { + throw new Error(`Unknown test lane: ${laneName}`); + } + const excludeSet = new Set(lane.exclude ?? []); + const files = lane.roots.flatMap((rootDir) => + collectFiles(repoRoot, rootDir, lane.include, excludeSet), + ); + for (const extra of lane.extraFiles ?? []) { + if (!files.includes(extra)) files.push(extra); + } + files.sort(); + if (files.length === 0) { + throw new Error(`No test files found for lane: ${laneName}`); + } + return files; +}