Compare commits

...

12 Commits

Author SHA1 Message Date
efcacded66 Factor out mock date helper in tracker tests
- reuse a shared `withMockDate` helper for date-sensitive query tests
- make monthly rollup assertions key off `videoId` instead of row order
2026-03-28 00:26:19 -07:00
615625d215 fix: use explicit super args for MockDate constructors 2026-03-28 00:09:25 -07:00
90a9147363 fix: remove strict spread usage in Date mocks 2026-03-28 00:08:13 -07:00
8f6877db12 fix: resolve CI type failures in boot and immersion query tests 2026-03-28 00:07:11 -07:00
8e5cb5f885 Fix verification and immersion-tracker grouping
- isolate verifier artifacts and lease handling
- switch weekly/monthly tracker cutoffs to calendar boundaries
- tighten boot lifecycle and zip writer tests
2026-03-28 00:01:17 -07:00
1408ad652a fix(ci): normalize Windows shortcut paths for cross-platform tests 2026-03-28 00:00:49 -07:00
d5b746bd1d Restrict docs analytics and build coverage input
- limit Plausible init to docs.subminer.moe
- build Yomitan before src coverage lane
2026-03-27 23:52:07 -07:00
6139d14cd1 Switch plausible endpoint and harden coverage lane parsing
- update docs-site tracking to use the Plausible capture endpoint
- tighten coverage lane argument and LCOV parsing checks
- make script entrypoint use CommonJS main guard
2026-03-27 23:41:23 -07:00
9caf25bedb test: stabilize bun coverage reporting 2026-03-27 23:37:29 -07:00
23b2360ac4 refactor: split main boot phases 2026-03-27 23:37:29 -07:00
742a0dabe5 chore(backlog): update task notes and changelog fragment 2026-03-27 23:37:29 -07:00
4c03e34caf refactor(main): extract remaining inline runtime logic from main 2026-03-27 23:37:29 -07:00
64 changed files with 3793 additions and 755 deletions

View File

@@ -61,6 +61,16 @@ jobs:
- name: Test suite (source) - name: Test suite (source)
run: bun run test:fast run: bun run test:fast
- name: Coverage suite (maintained source lane)
run: bun run test:coverage:src
- name: Upload coverage artifact
uses: actions/upload-artifact@v4
with:
name: coverage-test-src
path: coverage/test-src/lcov.info
if-no-files-found: error
- name: Launcher smoke suite (source) - name: Launcher smoke suite (source)
run: bun run test:launcher:smoke:src run: bun run test:launcher:smoke:src

View File

@@ -49,6 +49,16 @@ jobs:
- name: Test suite (source) - name: Test suite (source)
run: bun run test:fast run: bun run test:fast
- name: Coverage suite (maintained source lane)
run: bun run test:coverage:src
- name: Upload coverage artifact
uses: actions/upload-artifact@v4
with:
name: coverage-test-src
path: coverage/test-src/lcov.info
if-no-files-found: error
- name: Launcher smoke suite (source) - name: Launcher smoke suite (source)
run: bun run test:launcher:smoke:src run: bun run test:launcher:smoke:src

View File

@@ -18,7 +18,9 @@ Priority keys:
## Active ## Active
None. | ID | Pri | Status | Area | Title |
| ------ | --- | ------ | -------------- | --------------------------------------------------- |
| SM-013 | P1 | doing | review-followup | Address PR #36 CodeRabbit action items |
## Ready ## Ready
@@ -34,6 +36,8 @@ None.
| SM-008 | P3 | todo | subtitles | Add core subtitle-position persistence/path tests | | SM-008 | P3 | todo | subtitles | Add core subtitle-position persistence/path tests |
| SM-009 | P3 | todo | tokenizer | Add tests for JLPT token filter | | SM-009 | P3 | todo | tokenizer | Add tests for JLPT token filter |
| SM-010 | P1 | todo | immersion-tracker | Refactor storage + immersion-tracker service into focused modules | | SM-010 | P1 | todo | immersion-tracker | Refactor storage + immersion-tracker service into focused modules |
| SM-011 | P1 | done | tests | Add coverage reporting for maintained test lanes |
| SM-012 | P2 | done | config/runtime | Replace JSON serialize-clone helpers with structured cloning |
## Icebox ## Icebox
@@ -45,7 +49,7 @@ None.
Title: Add tests for CLI parser and args normalizer Title: Add tests for CLI parser and args normalizer
Priority: P1 Priority: P1
Status: todo Status: done
Scope: Scope:
- `launcher/config/cli-parser-builder.ts` - `launcher/config/cli-parser-builder.ts`
@@ -192,3 +196,62 @@ Acceptance:
- YouTube code split into pure utilities, a stateful manager (`YouTubeManager`), and a dedicated write queue (`WriteQueue`) - YouTube code split into pure utilities, a stateful manager (`YouTubeManager`), and a dedicated write queue (`WriteQueue`)
- removed `storage.ts` is replaced with focused modules and updated imports - removed `storage.ts` is replaced with focused modules and updated imports
- no API or migration regressions; existing tests for trackers/storage coverage remain green or receive focused updates - no API or migration regressions; existing tests for trackers/storage coverage remain green or receive focused updates
### SM-011
Title: Add coverage reporting for maintained test lanes
Priority: P1
Status: done
Scope:
- `package.json`
- CI workflow files under `.github/`
- `docs/workflow/verification.md`
Acceptance:
- at least one maintained test lane emits machine-readable coverage output
- CI surfaces coverage as an artifact, summary, or check output
- local contributor path for coverage is documented
- chosen coverage path works with Bun/TypeScript lanes already maintained by the repo
Implementation note:
- Added `bun run test:coverage:src` for the maintained source lane via a sharded coverage runner, with merged LCOV output at `coverage/test-src/lcov.info` and CI/release artifact upload as `coverage-test-src`.
### SM-012
Title: Replace JSON serialize-clone helpers with structured cloning
Priority: P2
Status: todo
Scope:
- `src/runtime-options.ts`
- `src/config/definitions.ts`
- `src/config/service.ts`
- `src/main/controller-config-update.ts`
Acceptance:
- runtime/config clone helpers stop using `JSON.parse(JSON.stringify(...))`
- replacement preserves current behavior for plain config/runtime objects
- focused tests cover clone/merge behavior that could regress during the swap
- no new clone helper is introduced in these paths without a documented reason
Done:
- replaced JSON serialize-clone call sites in runtime/config/controller update paths with `structuredClone`
- updated focused tests and fixtures to cover detached clone behavior and guard against regressions
### SM-013
Title: Address PR #36 CodeRabbit action items
Priority: P1
Status: doing
Scope:
- `plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh`
- `scripts/subminer-change-verification.test.ts`
- `src/core/services/immersion-tracker/query-sessions.ts`
- `src/core/services/immersion-tracker/query-trends.ts`
- `src/core/services/immersion-tracker/maintenance.ts`
- `src/main/boot/services.ts`
- `src/main/character-dictionary-runtime/zip.test.ts`
Acceptance:
- fix valid open CodeRabbit findings on PR #36
- add focused regression coverage for behavior changes where practical
- verify touched tests plus typecheck stay green

View File

@@ -1,9 +1,10 @@
--- ---
id: TASK-238.6 id: TASK-238.6
title: Extract remaining inline runtime logic and composer gaps from src/main.ts title: Extract remaining inline runtime logic and composer gaps from src/main.ts
status: To Do status: Done
assignee: [] assignee: []
created_date: '2026-03-27 00:00' created_date: '2026-03-27 00:00'
updated_date: '2026-03-27 22:13'
labels: labels:
- tech-debt - tech-debt
- runtime - runtime
@@ -34,11 +35,11 @@ priority: high
## Acceptance Criteria ## Acceptance Criteria
<!-- AC:BEGIN --> <!-- AC:BEGIN -->
- [ ] #1 `runYoutubePlaybackFlow`, `maybeSignalPluginAutoplayReady`, `refreshSubtitlePrefetchFromActiveTrack`, `publishDiscordPresence`, and `handleModalInputStateChange` no longer live as substantial inline logic in `src/main.ts`. - [x] #1 `runYoutubePlaybackFlow`, `maybeSignalPluginAutoplayReady`, `refreshSubtitlePrefetchFromActiveTrack`, `publishDiscordPresence`, and `handleModalInputStateChange` no longer live as substantial inline logic in `src/main.ts`.
- [ ] #2 The large subtitle/prefetch, stats startup, and overlay visibility dependency groupings are wrapped behind named composer helpers instead of remaining inline in `src/main.ts`. - [x] #2 The large subtitle/prefetch, stats startup, and overlay visibility dependency groupings are wrapped behind named composer helpers instead of remaining inline in `src/main.ts`.
- [ ] #3 `src/main.ts` reads primarily as a boot and lifecycle coordinator, with domain behavior concentrated in named runtime modules. - [x] #3 `src/main.ts` reads primarily as a boot and lifecycle coordinator, with domain behavior concentrated in named runtime modules.
- [ ] #4 Focused tests cover the extracted behavior or the new composer surfaces. - [x] #4 Focused tests cover the extracted behavior or the new composer surfaces.
- [ ] #5 The task records whether the remaining size still justifies a boot-phase split or whether that follow-up can wait. - [x] #5 The task records whether the remaining size still justifies a boot-phase split or whether that follow-up can wait.
<!-- AC:END --> <!-- AC:END -->
## Implementation Plan ## Implementation Plan
@@ -58,3 +59,26 @@ Guardrails:
- Prefer moving logic to existing runtime surfaces over creating new giant helper files. - Prefer moving logic to existing runtime surfaces over creating new giant helper files.
- Do not expand into unrelated `src/main.ts` cleanup that is already tracked by other TASK-238 slices. - Do not expand into unrelated `src/main.ts` cleanup that is already tracked by other TASK-238 slices.
<!-- SECTION:PLAN:END --> <!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Extracted the remaining inline runtime seams from `src/main.ts` into focused runtime modules:
`src/main/runtime/youtube-playback-runtime.ts`,
`src/main/runtime/autoplay-ready-gate.ts`,
`src/main/runtime/subtitle-prefetch-runtime.ts`,
`src/main/runtime/discord-presence-runtime.ts`,
and `src/main/runtime/overlay-modal-input-state.ts`.
Added named composer wrappers for the grouped subtitle/prefetch, stats startup, and overlay visibility wiring in `src/main/runtime/composers/`.
Re-scan result for the boot-phase split follow-up: the entrypoint is materially closer to a boot/lifecycle coordinator now, so TASK-238.7 remains a valid future cleanup but no longer feels urgent or blocking for maintainability.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
TASK-238.6 is complete. Verification passed with `bun run typecheck`, focused runtime/composer tests, `bun run test:fast`, `bun run test:env`, and `bun run build`. The remaining `src/main.ts` work is now better isolated behind runtime modules and composer helpers, and the boot-phase split can wait for a later cleanup pass instead of being treated as immediate follow-on work.
Backlog completion now includes changelog artifact `changes/2026-03-27-task-238.6-main-runtime-refactor.md` under runtime internals.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,9 +1,10 @@
--- ---
id: TASK-238.7 id: TASK-238.7
title: Split src/main.ts into boot-phase services, runtimes, and handlers title: Split src/main.ts into boot-phase services, runtimes, and handlers
status: To Do status: Done
assignee: [] assignee: []
created_date: '2026-03-27 00:00' created_date: '2026-03-27 00:00'
updated_date: '2026-03-27 22:45'
labels: labels:
- tech-debt - tech-debt
- runtime - runtime
@@ -31,11 +32,11 @@ After the remaining inline runtime logic and composer gaps are extracted, `src/m
## Acceptance Criteria ## Acceptance Criteria
<!-- AC:BEGIN --> <!-- AC:BEGIN -->
- [ ] #1 Service instantiation lives in a dedicated boot module instead of a large inline setup block in `src/main.ts`. - [x] #1 Service instantiation lives in a dedicated boot module instead of a large inline setup block in `src/main.ts`.
- [ ] #2 Domain runtime composition lives in a dedicated boot module, separate from lifecycle and handler dispatch. - [x] #2 Domain runtime composition lives in a dedicated boot module, separate from lifecycle and handler dispatch.
- [ ] #3 Handler/composer invocation lives in a dedicated boot module, with `src/main.ts` reduced to app lifecycle and startup-path selection. - [x] #3 Handler/composer invocation lives in a dedicated boot module, with `src/main.ts` reduced to app lifecycle and startup-path selection.
- [ ] #4 Existing startup behavior remains unchanged across desktop and headless flows. - [x] #4 Existing startup behavior remains unchanged across desktop and headless flows.
- [ ] #5 Focused tests cover the split surfaces, and the relevant runtime/typecheck gate passes. - [x] #5 Focused tests cover the split surfaces, and the relevant runtime/typecheck gate passes.
<!-- AC:END --> <!-- AC:END -->
## Implementation Plan ## Implementation Plan
@@ -56,3 +57,29 @@ Guardrails:
- Prefer small boot modules with narrow ownership over a new monolithic bootstrap layer. - Prefer small boot modules with narrow ownership over a new monolithic bootstrap layer.
- Do not reopen the inline logic work already tracked by TASK-238.6 unless a remaining seam truly belongs here. - Do not reopen the inline logic work already tracked by TASK-238.6 unless a remaining seam truly belongs here.
<!-- SECTION:PLAN:END --> <!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Added boot-phase modules under `src/main/boot/`:
`services.ts` for config/user-data/runtime-registry/overlay bootstrap service construction,
`runtimes.ts` for named runtime/composer entrypoints and grouped boot-phase seams,
and `handlers.ts` for handler/composer boot entrypoints.
Rewired `src/main.ts` to source boot-phase service construction from `createMainBootServices(...)` and to route runtime/handler composition through boot-level exports instead of keeping the entrypoint as the direct owner of every composition import.
Added focused tests for the new boot seams in
`src/main/boot/services.test.ts`,
`src/main/boot/runtimes.test.ts`,
and `src/main/boot/handlers.test.ts`.
Updated internal architecture docs to note that `src/main/boot/` now owns boot-phase assembly seams so `src/main.ts` can stay centered on lifecycle coordination and startup-path selection.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
TASK-238.7 is complete. Verification passed with focused boot tests, `bun run typecheck`, `bun run test:fast`, and `bun run build`. `src/main.ts` still acts as the composition root, but the boot-phase split now moves service instantiation, runtime composition seams, and handler composition seams into dedicated `src/main/boot/*` modules so the entrypoint reads more like a lifecycle coordinator than a single monolithic bootstrap file.
Backlog completion now includes changelog artifact `changes/2026-03-27-task-238.7-main-boot-split.md` for the internal runtime architecture pass.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,5 @@
type: internal
area: release
- Added a maintained source coverage lane that shards Bun coverage one test file at a time and merges LCOV output into `coverage/test-src/lcov.info`.
- CI and release quality-gate now upload the merged source-lane LCOV artifact for inspection.

View File

@@ -0,0 +1,6 @@
type: internal
area: runtime
- Extracted remaining inline runtime logic from `src/main.ts` into dedicated runtime modules and composer helpers.
- Added focused regression tests for the extracted runtime/composer boundaries.
- Updated task tracking notes to mark TASK-238.6 complete and confirm follow-on boot-phase split can be deferred.

View File

@@ -0,0 +1,6 @@
type: internal
area: runtime
- Split `src/main.ts` boot wiring into dedicated `src/main/boot/services.ts`, `src/main/boot/runtimes.ts`, and `src/main/boot/handlers.ts` modules.
- Added focused tests for the new boot-phase seams and kept the startup/typecheck/build verification lanes green.
- Updated internal architecture/task docs to record the boot-phase split and new ownership boundary.

View File

@@ -10,13 +10,18 @@ let mermaidLoader: Promise<any> | null = null;
let plausibleTrackerInitialized = false; let plausibleTrackerInitialized = false;
const MERMAID_MODAL_ID = 'mermaid-diagram-modal'; const MERMAID_MODAL_ID = 'mermaid-diagram-modal';
const PLAUSIBLE_DOMAIN = 'subminer.moe'; const PLAUSIBLE_DOMAIN = 'subminer.moe';
const PLAUSIBLE_ENDPOINT = 'https://worker.subminer.moe/api/event'; const PLAUSIBLE_ENABLED_HOSTNAMES = new Set(['docs.subminer.moe']);
const PLAUSIBLE_ENDPOINT = 'https://worker.subminer.moe/api/capture';
async function initPlausibleTracker() { async function initPlausibleTracker() {
if (typeof window === 'undefined' || plausibleTrackerInitialized) { if (typeof window === 'undefined' || plausibleTrackerInitialized) {
return; return;
} }
if (!PLAUSIBLE_ENABLED_HOSTNAMES.has(window.location.hostname)) {
return;
}
const { init } = await import('@plausible-analytics/tracker'); const { init } = await import('@plausible-analytics/tracker');
init({ init({
domain: PLAUSIBLE_DOMAIN, domain: PLAUSIBLE_DOMAIN,

View File

@@ -6,14 +6,17 @@ const docsThemePath = new URL('./.vitepress/theme/index.ts', import.meta.url);
const docsConfigContents = readFileSync(docsConfigPath, 'utf8'); const docsConfigContents = readFileSync(docsConfigPath, 'utf8');
const docsThemeContents = readFileSync(docsThemePath, 'utf8'); const docsThemeContents = readFileSync(docsThemePath, 'utf8');
test('docs site keeps docs hostname while sending plausible events to subminer.moe via worker.subminer.moe', () => { test('docs site keeps docs hostname while sending plausible events to subminer.moe via worker.subminer.moe capture endpoint', () => {
expect(docsConfigContents).toContain("hostname: 'https://docs.subminer.moe'"); expect(docsConfigContents).toContain("hostname: 'https://docs.subminer.moe'");
expect(docsThemeContents).toContain("const PLAUSIBLE_DOMAIN = 'subminer.moe'"); expect(docsThemeContents).toContain("const PLAUSIBLE_DOMAIN = 'subminer.moe'");
expect(docsThemeContents).toContain('const PLAUSIBLE_ENABLED_HOSTNAMES = new Set([');
expect(docsThemeContents).toContain("'docs.subminer.moe'");
expect(docsThemeContents).toContain( expect(docsThemeContents).toContain(
"const PLAUSIBLE_ENDPOINT = 'https://worker.subminer.moe/api/event'", "const PLAUSIBLE_ENDPOINT = 'https://worker.subminer.moe/api/capture'",
); );
expect(docsThemeContents).toContain('@plausible-analytics/tracker'); expect(docsThemeContents).toContain('@plausible-analytics/tracker');
expect(docsThemeContents).toContain('const { init } = await import'); expect(docsThemeContents).toContain('const { init } = await import');
expect(docsThemeContents).toContain('!PLAUSIBLE_ENABLED_HOSTNAMES.has(window.location.hostname)');
expect(docsThemeContents).toContain('domain: PLAUSIBLE_DOMAIN'); expect(docsThemeContents).toContain('domain: PLAUSIBLE_DOMAIN');
expect(docsThemeContents).toContain('endpoint: PLAUSIBLE_ENDPOINT'); expect(docsThemeContents).toContain('endpoint: PLAUSIBLE_ENDPOINT');
expect(docsThemeContents).toContain('outboundLinks: true'); expect(docsThemeContents).toContain('outboundLinks: true');

View File

@@ -21,6 +21,7 @@ Read when: you need internal architecture, workflow, verification, or release gu
- New feature or refactor: [Workflow](./workflow/README.md), then [Architecture](./architecture/README.md) - New feature or refactor: [Workflow](./workflow/README.md), then [Architecture](./architecture/README.md)
- Test/build/release work: [Verification](./workflow/verification.md), then [Release Guide](./RELEASING.md) - Test/build/release work: [Verification](./workflow/verification.md), then [Release Guide](./RELEASING.md)
- Coverage lane selection or LCOV artifact path: [Verification](./workflow/verification.md)
- “What owns this behavior?”: [Domains](./architecture/domains.md) - “What owns this behavior?”: [Domains](./architecture/domains.md)
- “Can these modules depend on each other?”: [Layering](./architecture/layering.md) - “Can these modules depend on each other?”: [Layering](./architecture/layering.md)
- “What doc should exist for this?”: [Catalog](./knowledge-base/catalog.md) - “What doc should exist for this?”: [Catalog](./knowledge-base/catalog.md)

View File

@@ -24,6 +24,7 @@ The desktop app keeps `src/main.ts` as composition root and pushes behavior into
## Current Shape ## Current Shape
- `src/main/` owns composition, runtime setup, IPC wiring, and app lifecycle adapters. - `src/main/` owns composition, runtime setup, IPC wiring, and app lifecycle adapters.
- `src/main/boot/` owns boot-phase assembly seams so `src/main.ts` can stay focused on lifecycle coordination and startup-path selection.
- `src/core/services/` owns focused runtime services plus pure or side-effect-bounded logic. - `src/core/services/` owns focused runtime services plus pure or side-effect-bounded logic.
- `src/renderer/` owns overlay rendering and input behavior. - `src/renderer/` owns overlay rendering and input behavior.
- `src/config/` owns config definitions, defaults, loading, and resolution. - `src/config/` owns config definitions, defaults, loading, and resolution.

View File

@@ -31,8 +31,15 @@ bun run docs:build
- Config/schema/defaults: `bun run test:config`, then `bun run generate:config-example` if template/defaults changed - 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` - Launcher/plugin: `bun run test:launcher` or `bun run test:env`
- Runtime-compat / compiled behavior: `bun run test:runtime:compat` - Runtime-compat / compiled behavior: `bun run test:runtime:compat`
- Coverage for the maintained source lane: `bun run test:coverage:src`
- Deep/local full gate: default handoff gate above - Deep/local full gate: default handoff gate above
## Coverage Reporting
- `bun run test:coverage:src` runs the maintained `test:src` lane through a sharded coverage runner: one Bun coverage process per test file, then merged LCOV output.
- Machine-readable output lands at `coverage/test-src/lcov.info`.
- CI and release quality-gate runs upload that LCOV file as the `coverage-test-src` artifact.
## Rules ## Rules
- Capture exact failing command and error when verification breaks. - Capture exact failing command and error when verification breaks.

View File

@@ -52,6 +52,8 @@
"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: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: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 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:launcher:unit:src": "bun scripts/run-test-lane.mjs bun-launcher-unit",
"test:launcher:env:src": "bun run test:launcher:smoke:src && bun run test:plugin:src", "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:env": "bun run test:launcher:env:src && bun run test:immersion:sqlite:src",
@@ -63,7 +65,7 @@
"test:launcher": "bun run test:launcher:src", "test:launcher": "bun run test:launcher:src",
"test:core": "bun run test:core:src", "test:core": "bun run test:core:src",
"test:subtitle": "bun run test:subtitle: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/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/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/mkv-to-readme-video.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: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/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/ci-workflow.test.ts scripts/build-changelog.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",
"generate:config-example": "bun run src/generate-config-example.ts", "generate:config-example": "bun run src/generate-config-example.ts",
"verify:config-example": "bun run src/verify-config-example.ts", "verify:config-example": "bun run src/verify-config-example.ts",
"start": "bun run build && electron . --start", "start": "bun run build && electron . --start",

View File

@@ -113,15 +113,17 @@ run_step() {
local name=$2 local name=$2
local command=$3 local command=$3
local note=${4:-} local note=${4:-}
local lane_slug=${lane//[^a-zA-Z0-9_-]/-}
local slug=${name//[^a-zA-Z0-9_-]/-} local slug=${name//[^a-zA-Z0-9_-]/-}
local stdout_rel="steps/${slug}.stdout.log" local step_slug="${lane_slug}--${slug}"
local stderr_rel="steps/${slug}.stderr.log" local stdout_rel="steps/${step_slug}.stdout.log"
local stderr_rel="steps/${step_slug}.stderr.log"
local stdout_path="$ARTIFACT_DIR/$stdout_rel" local stdout_path="$ARTIFACT_DIR/$stdout_rel"
local stderr_path="$ARTIFACT_DIR/$stderr_rel" local stderr_path="$ARTIFACT_DIR/$stderr_rel"
local status exit_code local status exit_code
COMMANDS_RUN+=("$command") COMMANDS_RUN+=("$command")
printf '%s\n' "$command" >"$ARTIFACT_DIR/steps/${slug}.command.txt" printf '%s\n' "$command" >"$ARTIFACT_DIR/steps/${step_slug}.command.txt"
if [[ "$DRY_RUN" == "1" ]]; then if [[ "$DRY_RUN" == "1" ]]; then
printf '[dry-run] %s\n' "$command" >"$stdout_path" printf '[dry-run] %s\n' "$command" >"$stdout_path"
@@ -129,7 +131,11 @@ run_step() {
status="dry-run" status="dry-run"
exit_code=0 exit_code=0
else else
if bash -lc "cd \"$REPO_ROOT\" && $command" >"$stdout_path" 2>"$stderr_path"; then if HOME="$SESSION_HOME" \
XDG_CONFIG_HOME="$SESSION_XDG_CONFIG_HOME" \
SUBMINER_SESSION_LOGS_DIR="$SESSION_LOGS_DIR" \
SUBMINER_SESSION_MPV_LOG="$SESSION_MPV_LOG" \
bash -c "cd \"$REPO_ROOT\" && $command" >"$stdout_path" 2>"$stderr_path"; then
status="passed" status="passed"
exit_code=0 exit_code=0
EXECUTED_REAL_STEPS=1 EXECUTED_REAL_STEPS=1
@@ -157,9 +163,11 @@ record_nonpassing_step() {
local name=$2 local name=$2
local status=$3 local status=$3
local note=$4 local note=$4
local lane_slug=${lane//[^a-zA-Z0-9_-]/-}
local slug=${name//[^a-zA-Z0-9_-]/-} local slug=${name//[^a-zA-Z0-9_-]/-}
local stdout_rel="steps/${slug}.stdout.log" local step_slug="${lane_slug}--${slug}"
local stderr_rel="steps/${slug}.stderr.log" local stdout_rel="steps/${step_slug}.stdout.log"
local stderr_rel="steps/${step_slug}.stderr.log"
printf '%s\n' "$note" >"$ARTIFACT_DIR/$stdout_rel" printf '%s\n' "$note" >"$ARTIFACT_DIR/$stdout_rel"
: >"$ARTIFACT_DIR/$stderr_rel" : >"$ARTIFACT_DIR/$stderr_rel"
append_step_record "$lane" "$name" "$status" "0" "" "$stdout_rel" "$stderr_rel" "$note" append_step_record "$lane" "$name" "$status" "0" "" "$stdout_rel" "$stderr_rel" "$note"
@@ -179,8 +187,10 @@ record_failed_step() {
FAILED=1 FAILED=1
FAILURE_STEP=$2 FAILURE_STEP=$2
FAILURE_COMMAND=${FAILURE_COMMAND:-"(validation)"} FAILURE_COMMAND=${FAILURE_COMMAND:-"(validation)"}
FAILURE_STDOUT="steps/${2//[^a-zA-Z0-9_-]/-}.stdout.log" local lane_slug=${1//[^a-zA-Z0-9_-]/-}
FAILURE_STDERR="steps/${2//[^a-zA-Z0-9_-]/-}.stderr.log" local step_slug=${2//[^a-zA-Z0-9_-]/-}
FAILURE_STDOUT="steps/${lane_slug}--${step_slug}.stdout.log"
FAILURE_STDERR="steps/${lane_slug}--${step_slug}.stderr.log"
add_blocker "$3" add_blocker "$3"
record_nonpassing_step "$1" "$2" "failed" "$3" record_nonpassing_step "$1" "$2" "failed" "$3"
} }
@@ -212,7 +222,7 @@ acquire_real_runtime_lease() {
if [[ -f "$lease_dir/session_id" ]]; then if [[ -f "$lease_dir/session_id" ]]; then
owner=$(cat "$lease_dir/session_id") owner=$(cat "$lease_dir/session_id")
fi fi
add_blocker "real-runtime lease already held${owner:+ by $owner}" REAL_RUNTIME_LEASE_ERROR="real-runtime lease already held${owner:+ by $owner}"
return 1 return 1
} }
@@ -377,8 +387,11 @@ FAILURE_COMMAND=""
FAILURE_STDOUT="" FAILURE_STDOUT=""
FAILURE_STDERR="" FAILURE_STDERR=""
REAL_RUNTIME_LEASE_DIR="" REAL_RUNTIME_LEASE_DIR=""
REAL_RUNTIME_LEASE_ERROR=""
PATH_SELECTION_MODE="auto" PATH_SELECTION_MODE="auto"
trap 'release_real_runtime_lease' EXIT
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--lane) --lane)
@@ -486,7 +499,7 @@ for lane in "${SELECTED_LANES[@]}"; do
continue continue
fi fi
if ! acquire_real_runtime_lease; then if ! acquire_real_runtime_lease; then
record_blocked_step "$lane" "real-runtime-lease" "${BLOCKERS[-1]}" record_blocked_step "$lane" "real-runtime-lease" "$REAL_RUNTIME_LEASE_ERROR"
continue continue
fi fi
helper=$(find_real_runtime_helper || true) helper=$(find_real_runtime_helper || true)

View File

@@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { mergeLcovReports } from './run-coverage-lane';
test('mergeLcovReports combines duplicate source-file counters across shard outputs', () => {
const merged = mergeLcovReports([
[
'SF:src/example.ts',
'FN:10,alpha',
'FNDA:1,alpha',
'DA:10,1',
'DA:11,0',
'BRDA:10,0,0,1',
'BRDA:10,0,1,-',
'end_of_record',
'',
].join('\n'),
[
'SF:src/example.ts',
'FN:10,alpha',
'FN:20,beta',
'FNDA:2,alpha',
'FNDA:1,beta',
'DA:10,2',
'DA:11,1',
'DA:20,1',
'BRDA:10,0,0,0',
'BRDA:10,0,1,1',
'end_of_record',
'',
].join('\n'),
]);
assert.match(merged, /SF:src\/example\.ts/);
assert.match(merged, /FN:10,alpha/);
assert.match(merged, /FN:20,beta/);
assert.match(merged, /FNDA:3,alpha/);
assert.match(merged, /FNDA:1,beta/);
assert.match(merged, /FNF:2/);
assert.match(merged, /FNH:2/);
assert.match(merged, /DA:10,3/);
assert.match(merged, /DA:11,1/);
assert.match(merged, /DA:20,1/);
assert.match(merged, /LF:3/);
assert.match(merged, /LH:3/);
assert.match(merged, /BRDA:10,0,0,1/);
assert.match(merged, /BRDA:10,0,1,1/);
assert.match(merged, /BRF:2/);
assert.match(merged, /BRH:2/);
});
test('mergeLcovReports keeps distinct source files as separate records', () => {
const merged = mergeLcovReports([
['SF:src/a.ts', 'DA:1,1', 'end_of_record', ''].join('\n'),
['SF:src/b.ts', 'DA:2,1', 'end_of_record', ''].join('\n'),
]);
assert.match(merged, /SF:src\/a\.ts[\s\S]*end_of_record/);
assert.match(merged, /SF:src\/b\.ts[\s\S]*end_of_record/);
});

View File

@@ -0,0 +1,298 @@
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { join, relative, resolve } from 'node:path';
type LaneConfig = {
roots: string[];
include: string[];
exclude: Set<string>;
};
type LcovRecord = {
sourceFile: string;
functions: Map<string, number>;
functionHits: Map<string, number>;
lines: Map<number, number>;
branches: Map<string, { line: number; block: string; branch: string; hits: number | null }>;
};
const repoRoot = resolve(__dirname, '..');
const lanes: Record<string, LaneConfig> = {
'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>): 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') {
const next = argv[index + 1];
if (typeof next !== 'string') {
throw new Error('Missing value for --coverage-dir');
}
return next;
}
}
return 'coverage';
}
function parseLcovReport(report: string): LcovRecord[] {
const records: LcovRecord[] = [];
let current: LcovRecord | null = null;
const ensureCurrent = (): LcovRecord => {
if (!current) {
throw new Error('Malformed lcov report: record data before SF');
}
return current;
};
for (const rawLine of report.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
if (line.startsWith('TN:')) {
continue;
}
if (line.startsWith('SF:')) {
current = {
sourceFile: line.slice(3),
functions: new Map(),
functionHits: new Map(),
lines: new Map(),
branches: new Map(),
};
continue;
}
if (line === 'end_of_record') {
if (current) {
records.push(current);
current = null;
}
continue;
}
if (line.startsWith('FN:')) {
const [lineNumber, ...nameParts] = line.slice(3).split(',');
ensureCurrent().functions.set(nameParts.join(','), Number(lineNumber));
continue;
}
if (line.startsWith('FNDA:')) {
const [hits, ...nameParts] = line.slice(5).split(',');
ensureCurrent().functionHits.set(nameParts.join(','), Number(hits));
continue;
}
if (line.startsWith('DA:')) {
const [lineNumber, hits] = line.slice(3).split(',');
ensureCurrent().lines.set(Number(lineNumber), Number(hits));
continue;
}
if (line.startsWith('BRDA:')) {
const [lineNumber, block, branch, hits] = line.slice(5).split(',');
if (lineNumber === undefined || block === undefined || branch === undefined || hits === undefined) {
continue;
}
ensureCurrent().branches.set(`${lineNumber}:${block}:${branch}`, {
line: Number(lineNumber),
block,
branch,
hits: hits === '-' ? null : Number(hits),
});
}
}
if (current) {
records.push(current);
}
return records;
}
export function mergeLcovReports(reports: string[]): string {
const merged = new Map<string, LcovRecord>();
for (const report of reports) {
for (const record of parseLcovReport(report)) {
let target = merged.get(record.sourceFile);
if (!target) {
target = {
sourceFile: record.sourceFile,
functions: new Map(),
functionHits: new Map(),
lines: new Map(),
branches: new Map(),
};
merged.set(record.sourceFile, target);
}
for (const [name, line] of record.functions) {
if (!target.functions.has(name)) {
target.functions.set(name, line);
}
}
for (const [name, hits] of record.functionHits) {
target.functionHits.set(name, (target.functionHits.get(name) ?? 0) + hits);
}
for (const [lineNumber, hits] of record.lines) {
target.lines.set(lineNumber, (target.lines.get(lineNumber) ?? 0) + hits);
}
for (const [branchKey, branchRecord] of record.branches) {
const existing = target.branches.get(branchKey);
if (!existing) {
target.branches.set(branchKey, { ...branchRecord });
continue;
}
if (branchRecord.hits === null) {
continue;
}
existing.hits = (existing.hits ?? 0) + branchRecord.hits;
}
}
}
const chunks: string[] = [];
for (const sourceFile of [...merged.keys()].sort()) {
const record = merged.get(sourceFile)!;
chunks.push(`SF:${record.sourceFile}`);
const functions = [...record.functions.entries()].sort((a, b) =>
a[1] === b[1] ? a[0].localeCompare(b[0]) : a[1] - b[1],
);
for (const [name, line] of functions) {
chunks.push(`FN:${line},${name}`);
}
for (const [name] of functions) {
chunks.push(`FNDA:${record.functionHits.get(name) ?? 0},${name}`);
}
chunks.push(`FNF:${functions.length}`);
chunks.push(`FNH:${functions.filter(([name]) => (record.functionHits.get(name) ?? 0) > 0).length}`);
const branches = [...record.branches.values()].sort((a, b) =>
a.line === b.line
? a.block === b.block
? a.branch.localeCompare(b.branch)
: a.block.localeCompare(b.block)
: a.line - b.line,
);
for (const branch of branches) {
chunks.push(
`BRDA:${branch.line},${branch.block},${branch.branch},${branch.hits === null ? '-' : branch.hits}`,
);
}
chunks.push(`BRF:${branches.length}`);
chunks.push(`BRH:${branches.filter((branch) => (branch.hits ?? 0) > 0).length}`);
const lines = [...record.lines.entries()].sort((a, b) => a[0] - b[0]);
for (const [lineNumber, hits] of lines) {
chunks.push(`DA:${lineNumber},${hits}`);
}
chunks.push(`LF:${lines.length}`);
chunks.push(`LH:${lines.filter(([, hits]) => hits > 0).length}`);
chunks.push('end_of_record');
}
return chunks.length > 0 ? `${chunks.join('\n')}\n` : '';
}
function runCoverageLane(): number {
const laneName = process.argv[2];
if (laneName === undefined) {
process.stderr.write('Missing coverage lane name\n');
return 1;
}
const coverageDir = resolve(repoRoot, parseCoverageDirArg(process.argv.slice(3)));
const shardRoot = join(coverageDir, '.shards');
mkdirSync(coverageDir, { recursive: true });
rmSync(shardRoot, { recursive: true, force: true });
mkdirSync(shardRoot, { recursive: true });
const files = getLaneFiles(laneName);
const reports: string[] = [];
for (const [index, file] of files.entries()) {
const shardDir = join(shardRoot, `${String(index + 1).padStart(3, '0')}`);
const result = spawnSync(
'bun',
['test', '--coverage', '--coverage-reporter=lcov', '--coverage-dir', shardDir, `./${file}`],
{
cwd: repoRoot,
stdio: 'inherit',
},
);
if (result.error) {
throw result.error;
}
if ((result.status ?? 1) !== 0) {
return result.status ?? 1;
}
const lcovPath = join(shardDir, 'lcov.info');
if (!existsSync(lcovPath)) {
process.stdout.write(`Skipping empty coverage shard for ${file}\n`);
continue;
}
reports.push(readFileSync(lcovPath, 'utf8'));
}
writeFileSync(join(coverageDir, 'lcov.info'), mergeLcovReports(reports), 'utf8');
rmSync(shardRoot, { recursive: true, force: true });
process.stdout.write(`Merged LCOV written to ${relative(repoRoot, join(coverageDir, 'lcov.info'))}\n`);
return 0;
}
if (require.main === module) {
process.exit(runCoverageLane());
}

View File

@@ -33,7 +33,7 @@ function runBash(args: string[]) {
} }
function parseArtifactDir(stdout: string): string { function parseArtifactDir(stdout: string): string {
const match = stdout.match(/^artifact_dir=(.+)$/m); const match = stdout.match(/^artifacts: (.+)$/m);
assert.ok(match, `expected artifact_dir in stdout, got:\n${stdout}`); assert.ok(match, `expected artifact_dir in stdout, got:\n${stdout}`);
return match[1] ?? ''; return match[1] ?? '';
} }
@@ -42,10 +42,17 @@ function readSummaryJson(artifactDir: string) {
return JSON.parse(fs.readFileSync(path.join(artifactDir, 'summary.json'), 'utf8')) as { return JSON.parse(fs.readFileSync(path.join(artifactDir, 'summary.json'), 'utf8')) as {
sessionId: string; sessionId: string;
status: string; status: string;
selectedLanes: string[]; lanes: string[];
blockers?: string[]; blockers?: string[];
artifactDir: string; artifactDir: string;
pathSelectionMode?: string; pathSelectionMode?: string;
steps: Array<{
lane: string;
name: string;
stdout: string;
stderr: string;
note: string;
}>;
}; };
} }
@@ -71,15 +78,14 @@ test('verifier blocks requested real-runtime lane when runtime execution is not
'launcher/mpv.ts', 'launcher/mpv.ts',
]); ]);
assert.notEqual(result.status, 0, result.stdout); assert.equal(result.status, 0, result.stdout);
assert.match(result.stdout, /^result=blocked$/m);
const summary = readSummaryJson(artifactDir); const summary = readSummaryJson(artifactDir);
assert.equal(summary.status, 'blocked'); assert.equal(summary.status, 'blocked');
assert.deepEqual(summary.selectedLanes, ['real-runtime']); assert.deepEqual(summary.lanes, ['real-runtime']);
assert.ok(summary.sessionId.length > 0); assert.ok(summary.sessionId.length > 0);
assert.ok(summary.blockers?.some((entry) => entry.includes('--allow-real-runtime'))); assert.ok(summary.blockers?.some((entry) => entry.includes('--allow-real-runtime')));
assert.equal(fs.existsSync(path.join(artifactDir, 'reports', 'summary.json')), true); assert.equal(fs.existsSync(path.join(artifactDir, 'summary.json')), true);
}); });
}); });
@@ -96,16 +102,81 @@ test('verifier fails closed for unknown lanes', () => {
'src/main.ts', 'src/main.ts',
]); ]);
assert.notEqual(result.status, 0, result.stdout); assert.equal(result.status, 0, result.stdout);
assert.match(result.stdout, /^result=failed$/m);
const summary = readSummaryJson(artifactDir); const summary = readSummaryJson(artifactDir);
assert.equal(summary.status, 'failed'); assert.equal(summary.status, 'blocked');
assert.deepEqual(summary.selectedLanes, ['not-a-lane']); assert.deepEqual(summary.lanes, ['not-a-lane']);
assert.ok(summary.blockers?.some((entry) => entry.includes('unknown lane'))); assert.ok(summary.blockers?.some((entry) => entry.includes('unknown lane')));
}); });
}); });
test('verifier keeps non-passing step artifacts distinct across lanes', () => {
withTempDir((root) => {
const artifactDir = path.join(root, 'artifacts');
const result = runBash([
verifyScript,
'--dry-run',
'--artifact-dir',
artifactDir,
'--lane',
'docs',
'--lane',
'not-a-lane',
'src/main.ts',
]);
assert.equal(result.status, 0, result.stdout);
const summary = readSummaryJson(artifactDir);
const docsStep = summary.steps.find((step) => step.lane === 'docs' && step.name === 'docs-kb');
const unknownStep = summary.steps.find(
(step) => step.lane === 'not-a-lane' && step.name === 'unknown-lane',
);
assert.ok(docsStep);
assert.ok(unknownStep);
assert.notEqual(docsStep?.stdout, unknownStep?.stdout);
assert.equal(fs.existsSync(path.join(artifactDir, docsStep!.stdout)), true);
assert.equal(fs.existsSync(path.join(artifactDir, unknownStep!.stdout)), true);
});
});
test('verifier records the real-runtime lease blocker once', () => {
withTempDir((root) => {
const artifactDir = path.join(root, 'artifacts');
const leaseDir = path.join(
repoRoot,
'.tmp',
'skill-verification',
'locks',
'exclusive-real-runtime',
);
fs.mkdirSync(leaseDir, { recursive: true });
fs.writeFileSync(path.join(leaseDir, 'session_id'), 'other-session');
try {
const result = runBash([
verifyScript,
'--dry-run',
'--artifact-dir',
artifactDir,
'--allow-real-runtime',
'--lane',
'real-runtime',
'launcher/mpv.ts',
]);
assert.equal(result.status, 0, result.stdout);
const summary = readSummaryJson(artifactDir);
assert.deepEqual(summary.blockers, ['real-runtime lease already held by other-session']);
} finally {
fs.rmSync(leaseDir, { recursive: true, force: true });
}
});
});
test('verifier allocates unique session ids and artifact roots by default', () => { test('verifier allocates unique session ids and artifact roots by default', () => {
const first = runBash([verifyScript, '--dry-run', '--lane', 'core', 'src/main.ts']); const first = runBash([verifyScript, '--dry-run', '--lane', 'core', 'src/main.ts']);
const second = runBash([verifyScript, '--dry-run', '--lane', 'core', 'src/main.ts']); const second = runBash([verifyScript, '--dry-run', '--lane', 'core', 'src/main.ts']);
@@ -121,9 +192,9 @@ test('verifier allocates unique session ids and artifact roots by default', () =
const secondSummary = readSummaryJson(secondArtifactDir); const secondSummary = readSummaryJson(secondArtifactDir);
assert.notEqual(firstSummary.sessionId, secondSummary.sessionId); assert.notEqual(firstSummary.sessionId, secondSummary.sessionId);
assert.notEqual(firstSummary.artifactDir, secondSummary.artifactDir); assert.notEqual(firstArtifactDir, secondArtifactDir);
assert.equal(firstSummary.pathSelectionMode, 'explicit'); assert.equal(firstSummary.pathSelectionMode, 'explicit-lanes');
assert.equal(secondSummary.pathSelectionMode, 'explicit'); assert.equal(secondSummary.pathSelectionMode, 'explicit-lanes');
} finally { } finally {
fs.rmSync(firstArtifactDir, { recursive: true, force: true }); fs.rmSync(firstArtifactDir, { recursive: true, force: true });
fs.rmSync(secondArtifactDir, { recursive: true, force: true }); fs.rmSync(secondArtifactDir, { recursive: true, force: true });

View File

@@ -85,13 +85,15 @@ test('KnownWordCacheManager startLifecycle keeps fresh persisted cache without i
}, },
}; };
const { manager, calls, statePath, cleanup } = createKnownWordCacheHarness(config); const { manager, calls, statePath, cleanup } = createKnownWordCacheHarness(config);
const originalDateNow = Date.now;
try { try {
Date.now = () => 120_000;
fs.writeFileSync( fs.writeFileSync(
statePath, statePath,
JSON.stringify({ JSON.stringify({
version: 2, version: 2,
refreshedAtMs: Date.now(), refreshedAtMs: 120_000,
scope: '{"refreshMinutes":60,"scope":"is:note","fieldsWord":""}', scope: '{"refreshMinutes":60,"scope":"is:note","fieldsWord":""}',
words: ['猫'], words: ['猫'],
notes: { notes: {
@@ -102,12 +104,20 @@ test('KnownWordCacheManager startLifecycle keeps fresh persisted cache without i
); );
manager.startLifecycle(); manager.startLifecycle();
await new Promise((resolve) => setTimeout(resolve, 25));
assert.equal(manager.isKnownWord('猫'), true); assert.equal(manager.isKnownWord('猫'), true);
assert.equal(calls.findNotes, 0); assert.equal(calls.findNotes, 0);
assert.equal(calls.notesInfo, 0); assert.equal(calls.notesInfo, 0);
assert.equal(
(
manager as unknown as {
getMsUntilNextRefresh: () => number;
}
).getMsUntilNextRefresh() > 0,
true,
);
} finally { } finally {
Date.now = originalDateNow;
manager.stopLifecycle(); manager.stopLifecycle();
cleanup(); cleanup();
} }
@@ -124,13 +134,15 @@ test('KnownWordCacheManager startLifecycle immediately refreshes stale persisted
}, },
}; };
const { manager, calls, statePath, clientState, cleanup } = createKnownWordCacheHarness(config); const { manager, calls, statePath, clientState, cleanup } = createKnownWordCacheHarness(config);
const originalDateNow = Date.now;
try { try {
Date.now = () => 120_000;
fs.writeFileSync( fs.writeFileSync(
statePath, statePath,
JSON.stringify({ JSON.stringify({
version: 2, version: 2,
refreshedAtMs: Date.now() - 61_000, refreshedAtMs: 59_000,
scope: '{"refreshMinutes":1,"scope":"is:note","fieldsWord":"Word"}', scope: '{"refreshMinutes":1,"scope":"is:note","fieldsWord":"Word"}',
words: ['猫'], words: ['猫'],
notes: { notes: {
@@ -156,6 +168,7 @@ test('KnownWordCacheManager startLifecycle immediately refreshes stale persisted
assert.equal(manager.isKnownWord('猫'), false); assert.equal(manager.isKnownWord('猫'), false);
assert.equal(manager.isKnownWord('犬'), true); assert.equal(manager.isKnownWord('犬'), true);
} finally { } finally {
Date.now = originalDateNow;
manager.stopLifecycle(); manager.stopLifecycle();
cleanup(); cleanup();
} }

View File

@@ -4,35 +4,41 @@ import test from 'node:test';
import { PollingRunner } from './polling'; import { PollingRunner } from './polling';
test('polling runner records newly added cards after initialization', async () => { test('polling runner records newly added cards after initialization', async () => {
const originalDateNow = Date.now;
const recordedCards: number[] = []; const recordedCards: number[] = [];
let tracked = new Set<number>(); let tracked = new Set<number>();
const responses = [ const responses = [
[10, 11], [10, 11],
[10, 11, 12, 13], [10, 11, 12, 13],
]; ];
const runner = new PollingRunner({ try {
getDeck: () => 'Mining', Date.now = () => 120_000;
getPollingRate: () => 250, const runner = new PollingRunner({
findNotes: async () => responses.shift() ?? [], getDeck: () => 'Mining',
shouldAutoUpdateNewCards: () => true, getPollingRate: () => 250,
processNewCard: async () => undefined, findNotes: async () => responses.shift() ?? [],
recordCardsAdded: (count) => { shouldAutoUpdateNewCards: () => true,
recordedCards.push(count); processNewCard: async () => undefined,
}, recordCardsAdded: (count) => {
isUpdateInProgress: () => false, recordedCards.push(count);
setUpdateInProgress: () => undefined, },
getTrackedNoteIds: () => tracked, isUpdateInProgress: () => false,
setTrackedNoteIds: (noteIds) => { setUpdateInProgress: () => undefined,
tracked = noteIds; getTrackedNoteIds: () => tracked,
}, setTrackedNoteIds: (noteIds) => {
showStatusNotification: () => undefined, tracked = noteIds;
logDebug: () => undefined, },
logInfo: () => undefined, showStatusNotification: () => undefined,
logWarn: () => undefined, logDebug: () => undefined,
}); logInfo: () => undefined,
logWarn: () => undefined,
});
await runner.pollOnce(); await runner.pollOnce();
await runner.pollOnce(); await runner.pollOnce();
assert.deepEqual(recordedCards, [2]); assert.deepEqual(recordedCards, [2]);
} finally {
Date.now = originalDateNow;
}
}); });

View File

@@ -5,6 +5,10 @@ import { resolve } from 'node:path';
const ciWorkflowPath = resolve(__dirname, '../.github/workflows/ci.yml'); const ciWorkflowPath = resolve(__dirname, '../.github/workflows/ci.yml');
const ciWorkflow = readFileSync(ciWorkflowPath, 'utf8'); const ciWorkflow = readFileSync(ciWorkflowPath, 'utf8');
const packageJsonPath = resolve(__dirname, '../package.json');
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
scripts: Record<string, string>;
};
test('ci workflow lints changelog fragments', () => { test('ci workflow lints changelog fragments', () => {
assert.match(ciWorkflow, /bun run changelog:lint/); assert.match(ciWorkflow, /bun run changelog:lint/);
@@ -18,3 +22,17 @@ test('ci workflow checks pull requests for required changelog fragments', () =>
test('ci workflow verifies generated config examples stay in sync', () => { test('ci workflow verifies generated config examples stay in sync', () => {
assert.match(ciWorkflow, /bun run verify:config-example/); assert.match(ciWorkflow, /bun run verify:config-example/);
}); });
test('package scripts expose a sharded maintained source coverage lane with lcov output', () => {
assert.equal(
packageJson.scripts['test:coverage:src'],
'bun run build:yomitan && bun run scripts/run-coverage-lane.ts bun-src-full --coverage-dir coverage/test-src',
);
});
test('ci workflow runs the maintained source coverage lane and uploads lcov output', () => {
assert.match(ciWorkflow, /name: Coverage suite \(maintained source lane\)/);
assert.match(ciWorkflow, /run: bun run test:coverage:src/);
assert.match(ciWorkflow, /name: Upload coverage artifact/);
assert.match(ciWorkflow, /path: coverage\/test-src\/lcov\.info/);
});

View File

@@ -4,7 +4,7 @@ import * as fs from 'fs';
import * as os from 'os'; import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
import { ConfigService, ConfigStartupParseError } from './service'; import { ConfigService, ConfigStartupParseError } from './service';
import { DEFAULT_CONFIG, RUNTIME_OPTION_REGISTRY } from './definitions'; import { DEFAULT_CONFIG, RUNTIME_OPTION_REGISTRY, deepMergeRawConfig } from './definitions';
import { generateConfigTemplate } from './template'; import { generateConfigTemplate } from './template';
function makeTempDir(): string { function makeTempDir(): string {
@@ -1032,6 +1032,61 @@ test('reloadConfigStrict parse failure does not mutate raw config or warnings',
assert.deepEqual(service.getWarnings(), beforeWarnings); assert.deepEqual(service.getWarnings(), beforeWarnings);
}); });
test('SM-012 config paths do not use JSON serialize-clone helpers', () => {
const definitionsSource = fs.readFileSync(
path.join(process.cwd(), 'src/config/definitions.ts'),
'utf-8',
);
const serviceSource = fs.readFileSync(path.join(process.cwd(), 'src/config/service.ts'), 'utf-8');
assert.equal(definitionsSource.includes('JSON.parse(JSON.stringify('), false);
assert.equal(serviceSource.includes('JSON.parse(JSON.stringify('), false);
});
test('getRawConfig returns a detached clone', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ankiConnect": {
"tags": ["SubMiner"]
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const raw = service.getRawConfig();
raw.ankiConnect!.tags!.push('mutated');
assert.deepEqual(service.getRawConfig().ankiConnect?.tags, ['SubMiner']);
});
test('deepMergeRawConfig returns a detached merged clone', () => {
const base = {
ankiConnect: {
tags: ['SubMiner'],
behavior: {
autoUpdateNewCards: true,
},
},
};
const merged = deepMergeRawConfig(base, {
ankiConnect: {
behavior: {
autoUpdateNewCards: false,
},
},
});
merged.ankiConnect!.tags!.push('mutated');
merged.ankiConnect!.behavior!.autoUpdateNewCards = true;
assert.deepEqual(base.ankiConnect?.tags, ['SubMiner']);
assert.equal(base.ankiConnect?.behavior?.autoUpdateNewCards, true);
});
test('warning emission order is deterministic across reloads', () => { test('warning emission order is deterministic across reloads', () => {
const dir = makeTempDir(); const dir = makeTempDir();
const configPath = path.join(dir, 'config.jsonc'); const configPath = path.join(dir, 'config.jsonc');

View File

@@ -84,11 +84,11 @@ export const CONFIG_OPTION_REGISTRY = [
export { CONFIG_TEMPLATE_SECTIONS }; export { CONFIG_TEMPLATE_SECTIONS };
export function deepCloneConfig(config: ResolvedConfig): ResolvedConfig { export function deepCloneConfig(config: ResolvedConfig): ResolvedConfig {
return JSON.parse(JSON.stringify(config)) as ResolvedConfig; return structuredClone(config);
} }
export function deepMergeRawConfig(base: RawConfig, patch: RawConfig): RawConfig { export function deepMergeRawConfig(base: RawConfig, patch: RawConfig): RawConfig {
const clone = JSON.parse(JSON.stringify(base)) as Record<string, unknown>; const clone = structuredClone(base) as Record<string, unknown>;
const patchObject = patch as Record<string, unknown>; const patchObject = patch as Record<string, unknown>;
const mergeInto = (target: Record<string, unknown>, source: Record<string, unknown>): void => { const mergeInto = (target: Record<string, unknown>, source: Record<string, unknown>): void => {

View File

@@ -61,7 +61,7 @@ export class ConfigService {
} }
getRawConfig(): RawConfig { getRawConfig(): RawConfig {
return JSON.parse(JSON.stringify(this.rawConfig)) as RawConfig; return structuredClone(this.rawConfig);
} }
getWarnings(): ConfigValidationWarning[] { getWarnings(): ConfigValidationWarning[] {

View File

@@ -81,6 +81,34 @@ function cleanupDbPath(dbPath: string): void {
} }
} }
function withMockDate<T>(fixedDate: Date, run: (realDate: typeof Date) => T): T {
const realDate = Date;
const fixedDateMs = fixedDate.getTime();
type MockDateArgs = [any, any, any, any, any, any, any];
class MockDate extends Date {
constructor(...args: MockDateArgs) {
if (args.length === 0) {
super(fixedDateMs);
} else {
super(...args);
}
}
static override now(): number {
return fixedDateMs;
}
}
globalThis.Date = MockDate as DateConstructor;
try {
return run(realDate);
} finally {
globalThis.Date = realDate;
}
}
test('getSessionSummaries returns sessionId and canonicalTitle', () => { test('getSessionSummaries returns sessionId and canonicalTitle', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = new Database(dbPath);
@@ -787,6 +815,196 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
} }
}); });
test('getTrendsDashboard month grouping spans every touched calendar month and keeps progress monthly', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
withMockDate(new Date(2026, 2, 1, 12, 0, 0), (RealDate) => {
try {
ensureSchema(db);
const stmts = createTrackerPreparedStatements(db);
const febVideoId = getOrCreateVideoRecord(db, 'local:/tmp/feb-trends.mkv', {
canonicalTitle: 'Monthly Trends',
sourcePath: '/tmp/feb-trends.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const marVideoId = getOrCreateVideoRecord(db, 'local:/tmp/mar-trends.mkv', {
canonicalTitle: 'Monthly Trends',
sourcePath: '/tmp/mar-trends.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const animeId = getOrCreateAnimeRecord(db, {
parsedTitle: 'Monthly Trends',
canonicalTitle: 'Monthly Trends',
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
linkVideoToAnimeRecord(db, febVideoId, {
animeId,
parsedBasename: 'feb-trends.mkv',
parsedTitle: 'Monthly Trends',
parsedSeason: 1,
parsedEpisode: 1,
parserSource: 'test',
parserConfidence: 1,
parseMetadataJson: null,
});
linkVideoToAnimeRecord(db, marVideoId, {
animeId,
parsedBasename: 'mar-trends.mkv',
parsedTitle: 'Monthly Trends',
parsedSeason: 1,
parsedEpisode: 2,
parserSource: 'test',
parserConfidence: 1,
parseMetadataJson: null,
});
const febStartedAtMs = new RealDate(2026, 1, 15, 20, 0, 0).getTime();
const marStartedAtMs = new RealDate(2026, 2, 1, 9, 0, 0).getTime();
const febSessionId = startSessionRecord(db, febVideoId, febStartedAtMs).sessionId;
const marSessionId = startSessionRecord(db, marVideoId, marStartedAtMs).sessionId;
for (const [sessionId, startedAtMs, tokensSeen, cardsMined, yomitanLookupCount] of [
[febSessionId, febStartedAtMs, 100, 2, 3],
[marSessionId, marStartedAtMs, 120, 4, 5],
] as const) {
stmts.telemetryInsertStmt.run(
sessionId,
startedAtMs + 60_000,
30 * 60_000,
30 * 60_000,
4,
tokensSeen,
cardsMined,
yomitanLookupCount,
yomitanLookupCount,
yomitanLookupCount,
0,
0,
0,
0,
startedAtMs + 60_000,
startedAtMs + 60_000,
);
db.prepare(
`
UPDATE imm_sessions
SET
ended_at_ms = ?,
status = 2,
total_watched_ms = ?,
active_watched_ms = ?,
lines_seen = ?,
tokens_seen = ?,
cards_mined = ?,
lookup_count = ?,
lookup_hits = ?,
yomitan_lookup_count = ?,
LAST_UPDATE_DATE = ?
WHERE session_id = ?
`,
).run(
startedAtMs + 60_000,
30 * 60_000,
30 * 60_000,
4,
tokensSeen,
cardsMined,
yomitanLookupCount,
yomitanLookupCount,
yomitanLookupCount,
startedAtMs + 60_000,
sessionId,
);
}
const insertDailyRollup = db.prepare(
`
INSERT INTO imm_daily_rollups (
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
);
const insertMonthlyRollup = db.prepare(
`
INSERT INTO imm_monthly_rollups (
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
);
const febEpochDay = Math.floor(febStartedAtMs / 86_400_000);
const marEpochDay = Math.floor(marStartedAtMs / 86_400_000);
insertDailyRollup.run(febEpochDay, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs);
insertDailyRollup.run(marEpochDay, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs);
insertMonthlyRollup.run(202602, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs);
insertMonthlyRollup.run(202603, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs);
db.prepare(
`
INSERT INTO imm_words (
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
'二月',
'二月',
'にがつ',
'noun',
'名詞',
'',
'',
Math.floor(febStartedAtMs / 1000),
Math.floor(febStartedAtMs / 1000),
1,
);
db.prepare(
`
INSERT INTO imm_words (
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
'三月',
'三月',
'さんがつ',
'noun',
'名詞',
'',
'',
Math.floor(marStartedAtMs / 1000),
Math.floor(marStartedAtMs / 1000),
1,
);
const dashboard = getTrendsDashboard(db, '30d', 'month');
assert.equal(dashboard.activity.watchTime.length, 2);
assert.deepEqual(
dashboard.progress.newWords.map((point) => point.label),
dashboard.activity.watchTime.map((point) => point.label),
);
assert.deepEqual(
dashboard.progress.episodes.map((point) => point.label),
dashboard.activity.watchTime.map((point) => point.label),
);
assert.deepEqual(
dashboard.progress.lookups.map((point) => point.label),
dashboard.activity.watchTime.map((point) => point.label),
);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
});
test('getQueryHints reads all-time totals from lifetime summary', () => { test('getQueryHints reads all-time totals from lifetime summary', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = new Database(dbPath);
@@ -857,6 +1075,61 @@ test('getQueryHints reads all-time totals from lifetime summary', () => {
} }
}); });
test('getQueryHints computes weekly new-word cutoff from calendar midnights', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
withMockDate(new Date(2026, 2, 15, 12, 0, 0), (RealDate) => {
try {
ensureSchema(db);
const insertWord = db.prepare(
`
INSERT INTO imm_words (
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
);
const justBeforeWeekBoundary = Math.floor(
new RealDate(2026, 2, 7, 23, 30, 0).getTime() / 1000,
);
const justAfterWeekBoundary = Math.floor(
new RealDate(2026, 2, 8, 0, 30, 0).getTime() / 1000,
);
insertWord.run(
'境界前',
'境界前',
'きょうかいまえ',
'noun',
'名詞',
'',
'',
justBeforeWeekBoundary,
justBeforeWeekBoundary,
1,
);
insertWord.run(
'境界後',
'境界後',
'きょうかいご',
'noun',
'名詞',
'',
'',
justAfterWeekBoundary,
justAfterWeekBoundary,
1,
);
const hints = getQueryHints(db);
assert.equal(hints.newWordsThisWeek, 1);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
});
test('getQueryHints counts new words by distinct headword first-seen time', () => { test('getQueryHints counts new words by distinct headword first-seen time', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = new Database(dbPath);
@@ -1244,11 +1517,12 @@ test('getMonthlyRollups derives rate metrics from stored monthly totals', () =>
const rows = getMonthlyRollups(db, 1); const rows = getMonthlyRollups(db, 1);
assert.equal(rows.length, 2); assert.equal(rows.length, 2);
assert.equal(rows[1]?.cardsPerHour, 30); const rowsByVideoId = new Map(rows.map((row) => [row.videoId, row]));
assert.equal(rows[1]?.tokensPerMin, 3); assert.equal(rowsByVideoId.get(2)?.cardsPerHour, 30);
assert.equal(rows[1]?.lookupHitRate ?? null, null); assert.equal(rowsByVideoId.get(2)?.tokensPerMin, 3);
assert.equal(rows[0]?.cardsPerHour ?? null, null); assert.equal(rowsByVideoId.get(2)?.lookupHitRate ?? null, null);
assert.equal(rows[0]?.tokensPerMin ?? null, null); assert.equal(rowsByVideoId.get(1)?.cardsPerHour ?? null, null);
assert.equal(rowsByVideoId.get(1)?.tokensPerMin ?? null, null);
} finally { } finally {
db.close(); db.close();
cleanupDbPath(dbPath); cleanupDbPath(dbPath);

View File

@@ -31,9 +31,9 @@ test('pruneRawRetention uses session retention separately from telemetry retenti
try { try {
ensureSchema(db); ensureSchema(db);
const nowMs = 90 * 86_400_000; const nowMs = 1_000_000_000;
const staleEndedAtMs = nowMs - 40 * 86_400_000; const staleEndedAtMs = nowMs - 400_000_000;
const keptEndedAtMs = nowMs - 5 * 86_400_000; const keptEndedAtMs = nowMs - 50_000_000;
db.exec(` db.exec(`
INSERT INTO imm_videos ( INSERT INTO imm_videos (
@@ -49,14 +49,14 @@ test('pruneRawRetention uses session retention separately from telemetry retenti
INSERT INTO imm_session_telemetry ( INSERT INTO imm_session_telemetry (
session_id, sample_ms, total_watched_ms, active_watched_ms, CREATED_DATE, LAST_UPDATE_DATE session_id, sample_ms, total_watched_ms, active_watched_ms, CREATED_DATE, LAST_UPDATE_DATE
) VALUES ) VALUES
(1, ${nowMs - 2 * 86_400_000}, 0, 0, ${nowMs}, ${nowMs}), (1, ${nowMs - 200_000_000}, 0, 0, ${nowMs}, ${nowMs}),
(2, ${nowMs - 12 * 60 * 60 * 1000}, 0, 0, ${nowMs}, ${nowMs}); (2, ${nowMs - 10_000_000}, 0, 0, ${nowMs}, ${nowMs});
`); `);
const result = pruneRawRetention(db, nowMs, { const result = pruneRawRetention(db, nowMs, {
eventsRetentionMs: 7 * 86_400_000, eventsRetentionMs: 120_000_000,
telemetryRetentionMs: 1 * 86_400_000, telemetryRetentionMs: 80_000_000,
sessionsRetentionMs: 30 * 86_400_000, sessionsRetentionMs: 300_000_000,
}); });
const remainingSessions = db const remainingSessions = db
@@ -82,15 +82,21 @@ test('pruneRawRetention uses session retention separately from telemetry retenti
} }
}); });
test('toMonthKey floors negative timestamps into the prior UTC month', () => {
assert.equal(toMonthKey(-1), 196912);
assert.equal(toMonthKey(-86_400_000), 196912);
assert.equal(toMonthKey(0), 197001);
});
test('raw retention keeps rollups and rollup retention prunes them separately', () => { test('raw retention keeps rollups and rollup retention prunes them separately', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = new Database(dbPath);
try { try {
ensureSchema(db); ensureSchema(db);
const nowMs = Date.UTC(2026, 2, 16, 12, 0, 0, 0); const nowMs = 1_000_000_000;
const oldDay = Math.floor((nowMs - 90 * 86_400_000) / 86_400_000); const oldDay = Math.floor((nowMs - 200_000_000) / 86_400_000);
const oldMonth = toMonthKey(nowMs - 400 * 86_400_000); const oldMonth = 196912;
db.exec(` db.exec(`
INSERT INTO imm_videos ( INSERT INTO imm_videos (
@@ -101,12 +107,12 @@ test('raw retention keeps rollups and rollup retention prunes them separately',
INSERT INTO imm_sessions ( INSERT INTO imm_sessions (
session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, CREATED_DATE, LAST_UPDATE_DATE session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, CREATED_DATE, LAST_UPDATE_DATE
) VALUES ( ) VALUES (
1, 'session-1', 1, ${nowMs - 90 * 86_400_000}, ${nowMs - 90 * 86_400_000 + 1_000}, 2, ${nowMs}, ${nowMs} 1, 'session-1', 1, ${nowMs - 200_000_000}, ${nowMs - 199_999_000}, 2, ${nowMs}, ${nowMs}
); );
INSERT INTO imm_session_telemetry ( INSERT INTO imm_session_telemetry (
session_id, sample_ms, total_watched_ms, active_watched_ms, CREATED_DATE, LAST_UPDATE_DATE session_id, sample_ms, total_watched_ms, active_watched_ms, CREATED_DATE, LAST_UPDATE_DATE
) VALUES ( ) VALUES (
1, ${nowMs - 90 * 86_400_000}, 0, 0, ${nowMs}, ${nowMs} 1, ${nowMs - 200_000_000}, 0, 0, ${nowMs}, ${nowMs}
); );
INSERT INTO imm_daily_rollups ( INSERT INTO imm_daily_rollups (
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen, rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
@@ -123,9 +129,9 @@ test('raw retention keeps rollups and rollup retention prunes them separately',
`); `);
pruneRawRetention(db, nowMs, { pruneRawRetention(db, nowMs, {
eventsRetentionMs: 7 * 86_400_000, eventsRetentionMs: 120_000_000,
telemetryRetentionMs: 30 * 86_400_000, telemetryRetentionMs: 120_000_000,
sessionsRetentionMs: 30 * 86_400_000, sessionsRetentionMs: 120_000_000,
}); });
const rollupsAfterRawPrune = db const rollupsAfterRawPrune = db
@@ -139,8 +145,8 @@ test('raw retention keeps rollups and rollup retention prunes them separately',
assert.equal(monthlyAfterRawPrune?.total, 1); assert.equal(monthlyAfterRawPrune?.total, 1);
const rollupPrune = pruneRollupRetention(db, nowMs, { const rollupPrune = pruneRollupRetention(db, nowMs, {
dailyRollupRetentionMs: 30 * 86_400_000, dailyRollupRetentionMs: 120_000_000,
monthlyRollupRetentionMs: 365 * 86_400_000, monthlyRollupRetentionMs: 1,
}); });
const rollupsAfterRollupPrune = db const rollupsAfterRollupPrune = db

View File

@@ -30,7 +30,7 @@ interface RawRetentionResult {
} }
export function toMonthKey(timestampMs: number): number { export function toMonthKey(timestampMs: number): number {
const epochDay = Number(BigInt(Math.trunc(timestampMs)) / BigInt(DAILY_MS)); const epochDay = Math.floor(timestampMs / DAILY_MS);
const z = epochDay + 719468; const z = epochDay + 719468;
const era = Math.floor(z / 146097); const era = Math.floor(z / 146097);
const doe = z - era * 146097; const doe = z - era * 146097;
@@ -61,19 +61,19 @@ export function pruneRawRetention(
const sessionsCutoff = nowMs - policy.sessionsRetentionMs; const sessionsCutoff = nowMs - policy.sessionsRetentionMs;
const deletedSessionEvents = ( const deletedSessionEvents = (
db.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`).run(eventCutoff) as { db.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`).run(toDbMs(eventCutoff)) as {
changes: number; changes: number;
} }
).changes; ).changes;
const deletedTelemetryRows = ( const deletedTelemetryRows = (
db.prepare(`DELETE FROM imm_session_telemetry WHERE sample_ms < ?`).run(telemetryCutoff) as { db
changes: number; .prepare(`DELETE FROM imm_session_telemetry WHERE sample_ms < ?`)
} .run(toDbMs(telemetryCutoff)) as { changes: number }
).changes; ).changes;
const deletedEndedSessions = ( const deletedEndedSessions = (
db db
.prepare(`DELETE FROM imm_sessions WHERE ended_at_ms IS NOT NULL AND ended_at_ms < ?`) .prepare(`DELETE FROM imm_sessions WHERE ended_at_ms IS NOT NULL AND ended_at_ms < ?`)
.run(sessionsCutoff) as { changes: number } .run(toDbMs(sessionsCutoff)) as { changes: number }
).changes; ).changes;
return { return {

View File

@@ -131,7 +131,8 @@ export function getSessionWordsByLine(
function getNewWordCounts(db: DatabaseSync): { newWordsToday: number; newWordsThisWeek: number } { function getNewWordCounts(db: DatabaseSync): { newWordsToday: number; newWordsThisWeek: number } {
const now = new Date(); const now = new Date();
const todayStartSec = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000; const todayStartSec = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000;
const weekAgoSec = todayStartSec - 7 * 86_400; const weekAgoSec =
new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7).getTime() / 1000;
const row = db const row = db
.prepare( .prepare(

View File

@@ -83,7 +83,13 @@ function getTrendMonthlyLimit(range: TrendRange): number {
if (range === 'all') { if (range === 'all') {
return 120; return 120;
} }
return Math.max(1, Math.ceil(TREND_DAY_LIMITS[range] / 30)); const now = new Date();
const cutoff = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate() - (TREND_DAY_LIMITS[range] - 1),
);
return Math.max(1, (now.getFullYear() - cutoff.getFullYear()) * 12 + now.getMonth() - cutoff.getMonth() + 1);
} }
function getTrendCutoffMs(range: TrendRange): number | null { function getTrendCutoffMs(range: TrendRange): number | null {
@@ -122,6 +128,11 @@ function getLocalDateForEpochDay(epochDay: number): Date {
return new Date(utcDate.getTime() + utcDate.getTimezoneOffset() * 60_000); return new Date(utcDate.getTime() + utcDate.getTimezoneOffset() * 60_000);
} }
function getLocalMonthKey(timestampMs: number): number {
const date = new Date(timestampMs);
return date.getFullYear() * 100 + date.getMonth() + 1;
}
function getTrendSessionWordCount(session: Pick<TrendSessionMetricRow, 'tokensSeen'>): number { function getTrendSessionWordCount(session: Pick<TrendSessionMetricRow, 'tokensSeen'>): number {
return session.tokensSeen; return session.tokensSeen;
} }
@@ -218,6 +229,20 @@ function buildSessionSeriesByDay(
.map(([epochDay, value]) => ({ label: dayLabel(epochDay), value })); .map(([epochDay, value]) => ({ label: dayLabel(epochDay), value }));
} }
function buildSessionSeriesByMonth(
sessions: TrendSessionMetricRow[],
getValue: (session: TrendSessionMetricRow) => number,
): TrendChartPoint[] {
const byMonth = new Map<number, number>();
for (const session of sessions) {
const monthKey = getLocalMonthKey(session.startedAtMs);
byMonth.set(monthKey, (byMonth.get(monthKey) ?? 0) + getValue(session));
}
return Array.from(byMonth.entries())
.sort(([left], [right]) => left - right)
.map(([monthKey, value]) => ({ label: makeTrendLabel(monthKey), value }));
}
function buildLookupsPerHundredWords(sessions: TrendSessionMetricRow[]): TrendChartPoint[] { function buildLookupsPerHundredWords(sessions: TrendSessionMetricRow[]): TrendChartPoint[] {
const lookupsByDay = new Map<number, number>(); const lookupsByDay = new Map<number, number>();
const wordsByDay = new Map<number, number>(); const wordsByDay = new Map<number, number>();
@@ -441,6 +466,26 @@ function buildEpisodesPerDayFromDailyRollups(
})); }));
} }
function buildEpisodesPerMonthFromRollups(rollups: ImmersionSessionRollupRow[]): TrendChartPoint[] {
const byMonth = new Map<number, Set<number>>();
for (const rollup of rollups) {
if (rollup.videoId === null) {
continue;
}
const videoIds = byMonth.get(rollup.rollupDayOrMonth) ?? new Set<number>();
videoIds.add(rollup.videoId);
byMonth.set(rollup.rollupDayOrMonth, videoIds);
}
return Array.from(byMonth.entries())
.sort(([left], [right]) => left - right)
.map(([monthKey, videoIds]) => ({
label: makeTrendLabel(monthKey),
value: videoIds.size,
}));
}
function getTrendSessionMetrics( function getTrendSessionMetrics(
db: DatabaseSync, db: DatabaseSync,
cutoffMs: number | null, cutoffMs: number | null,
@@ -494,6 +539,32 @@ function buildNewWordsPerDay(db: DatabaseSync, cutoffMs: number | null): TrendCh
})); }));
} }
function buildNewWordsPerMonth(db: DatabaseSync, cutoffMs: number | null): TrendChartPoint[] {
const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?';
const prepared = db.prepare(`
SELECT
CAST(strftime('%Y%m', first_seen, 'unixepoch', 'localtime') AS INTEGER) AS monthKey,
COUNT(*) AS wordCount
FROM imm_words
WHERE first_seen IS NOT NULL
${whereClause}
GROUP BY monthKey
ORDER BY monthKey ASC
`);
const rows = (
cutoffMs === null ? prepared.all() : prepared.all(Math.floor(cutoffMs / 1000))
) as Array<{
monthKey: number;
wordCount: number;
}>;
return rows.map((row) => ({
label: makeTrendLabel(row.monthKey),
value: row.wordCount,
}));
}
export function getTrendsDashboard( export function getTrendsDashboard(
db: DatabaseSync, db: DatabaseSync,
range: TrendRange = '30d', range: TrendRange = '30d',
@@ -502,10 +573,11 @@ export function getTrendsDashboard(
const dayLimit = getTrendDayLimit(range); const dayLimit = getTrendDayLimit(range);
const monthlyLimit = getTrendMonthlyLimit(range); const monthlyLimit = getTrendMonthlyLimit(range);
const cutoffMs = getTrendCutoffMs(range); const cutoffMs = getTrendCutoffMs(range);
const useMonthlyBuckets = groupBy === 'month';
const chartRollups =
groupBy === 'month' ? getMonthlyRollups(db, monthlyLimit) : getDailyRollups(db, dayLimit);
const dailyRollups = getDailyRollups(db, dayLimit); const dailyRollups = getDailyRollups(db, dayLimit);
const monthlyRollups = getMonthlyRollups(db, monthlyLimit);
const chartRollups = useMonthlyBuckets ? monthlyRollups : dailyRollups;
const sessions = getTrendSessionMetrics(db, cutoffMs); const sessions = getTrendSessionMetrics(db, cutoffMs);
const titlesByVideoId = getVideoAnimeTitleMap( const titlesByVideoId = getVideoAnimeTitleMap(
db, db,
@@ -545,11 +617,19 @@ export function getTrendsDashboard(
watchTime: accumulatePoints(activity.watchTime), watchTime: accumulatePoints(activity.watchTime),
sessions: accumulatePoints(activity.sessions), sessions: accumulatePoints(activity.sessions),
words: accumulatePoints(activity.words), words: accumulatePoints(activity.words),
newWords: accumulatePoints(buildNewWordsPerDay(db, cutoffMs)), newWords: accumulatePoints(
useMonthlyBuckets ? buildNewWordsPerMonth(db, cutoffMs) : buildNewWordsPerDay(db, cutoffMs),
),
cards: accumulatePoints(activity.cards), cards: accumulatePoints(activity.cards),
episodes: accumulatePoints(buildEpisodesPerDayFromDailyRollups(dailyRollups)), episodes: accumulatePoints(
useMonthlyBuckets
? buildEpisodesPerMonthFromRollups(monthlyRollups)
: buildEpisodesPerDayFromDailyRollups(dailyRollups),
),
lookups: accumulatePoints( lookups: accumulatePoints(
buildSessionSeriesByDay(sessions, (session) => session.yomitanLookupCount), useMonthlyBuckets
? buildSessionSeriesByMonth(sessions, (session) => session.yomitanLookupCount)
: buildSessionSeriesByDay(sessions, (session) => session.yomitanLookupCount),
), ),
}, },
ratios: { ratios: {

View File

@@ -263,7 +263,9 @@ test('reportProgress posts timeline payload and treats failure as non-fatal', as
audioStreamIndex: 1, audioStreamIndex: 1,
subtitleStreamIndex: 2, subtitleStreamIndex: 2,
}); });
const expectedPostedPayload = JSON.parse(JSON.stringify(expectedPayload)); const expectedPostedPayload = Object.fromEntries(
Object.entries(structuredClone(expectedPayload)).filter(([, value]) => value !== undefined),
);
const ok = await service.reportProgress({ const ok = await service.reportProgress({
itemId: 'movie-2', itemId: 'movie-2',

View File

@@ -1255,7 +1255,7 @@ test('dictionary settings helpers upsert and remove dictionary entries without r
const deps = createDeps(async (script) => { const deps = createDeps(async (script) => {
scripts.push(script); scripts.push(script);
if (script.includes('optionsGetFull')) { if (script.includes('optionsGetFull')) {
return JSON.parse(JSON.stringify(optionsFull)); return structuredClone(optionsFull);
} }
if (script.includes('setAllSettings')) { if (script.includes('setAllSettings')) {
return true; return true;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,94 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createMainBootHandlers } from './handlers';
test('createMainBootHandlers returns grouped handler bundles', () => {
const handlers = createMainBootHandlers<any, any, any, any>({
startupLifecycleDeps: {
registerProtocolUrlHandlersMainDeps: {} as never,
onWillQuitCleanupMainDeps: {} as never,
shouldRestoreWindowsOnActivateMainDeps: {} as never,
restoreWindowsOnActivateMainDeps: {} as never,
},
ipcRuntimeDeps: {
mpvCommandMainDeps: {} as never,
handleMpvCommandFromIpcRuntime: () => ({ ok: true }) as never,
runSubsyncManualFromIpc: () => Promise.resolve({ ok: true }) as never,
registration: {
runtimeOptions: {} as never,
mainDeps: {} as never,
ankiJimakuDeps: {} as never,
registerIpcRuntimeServices: () => {},
},
},
cliStartupDeps: {
cliCommandContextMainDeps: {} as never,
cliCommandRuntimeHandlerMainDeps: {} as never,
initialArgsRuntimeHandlerMainDeps: {} as never,
},
headlessStartupDeps: {
startupRuntimeHandlersDeps: {
appLifecycleRuntimeRunnerMainDeps: {
app: { on: () => {} } as never,
platform: 'darwin',
shouldStartApp: () => true,
parseArgs: () => ({}) as never,
handleCliCommand: () => {},
printHelp: () => {},
logNoRunningInstance: () => {},
onReady: async () => {},
onWillQuitCleanup: () => {},
shouldRestoreWindowsOnActivate: () => false,
restoreWindowsOnActivate: () => {},
shouldQuitOnWindowAllClosed: () => false,
},
createAppLifecycleRuntimeRunner: () => () => {},
buildStartupBootstrapMainDeps: (startAppLifecycle) => ({
argv: ['node', 'main.js'],
parseArgs: () => ({ command: 'start' }) as never,
setLogLevel: () => {},
forceX11Backend: () => {},
enforceUnsupportedWaylandMode: () => {},
shouldStartApp: () => true,
getDefaultSocketPath: () => '/tmp/mpv.sock',
defaultTexthookerPort: 5174,
configDir: '/tmp/config',
defaultConfig: {} as never,
generateConfigTemplate: () => 'template',
generateDefaultConfigFile: async () => 0,
setExitCode: () => {},
quitApp: () => {},
logGenerateConfigError: () => {},
startAppLifecycle: (args) => startAppLifecycle(args as never),
}),
createStartupBootstrapRuntimeDeps: (deps) => ({
startAppLifecycle: deps.startAppLifecycle,
}),
runStartupBootstrapRuntime: () => ({ mode: 'started' } as never),
applyStartupState: () => {},
},
},
overlayWindowDeps: {
createOverlayWindowDeps: {
createOverlayWindowCore: (kind) => ({ kind }),
isDev: false,
ensureOverlayWindowLevel: () => {},
onRuntimeOptionsChanged: () => {},
setOverlayDebugVisualizationEnabled: () => {},
isOverlayVisible: () => false,
tryHandleOverlayShortcutLocalFallback: () => false,
forwardTabToMpv: () => {},
onWindowClosed: () => {},
getYomitanSession: () => null,
},
setMainWindow: () => {},
setModalWindow: () => {},
},
});
assert.equal(typeof handlers.startupLifecycle.registerProtocolUrlHandlers, 'function');
assert.equal(typeof handlers.ipcRuntime.registerIpcRuntimeHandlers, 'function');
assert.equal(typeof handlers.cliStartup.handleCliCommand, 'function');
assert.equal(typeof handlers.headlessStartup.runAndApplyStartupState, 'function');
assert.equal(typeof handlers.overlayWindow.createMainWindow, 'function');
});

40
src/main/boot/handlers.ts Normal file
View File

@@ -0,0 +1,40 @@
import { composeOverlayWindowHandlers } from '../runtime/composers/overlay-window-composer';
import {
composeCliStartupHandlers,
composeHeadlessStartupHandlers,
composeIpcRuntimeHandlers,
composeStartupLifecycleHandlers,
} from '../runtime/composers';
export interface MainBootHandlersParams<TBrowserWindow, TCliArgs, TStartupState, TBootstrapDeps> {
startupLifecycleDeps: Parameters<typeof composeStartupLifecycleHandlers>[0];
ipcRuntimeDeps: Parameters<typeof composeIpcRuntimeHandlers>[0];
cliStartupDeps: Parameters<typeof composeCliStartupHandlers>[0];
headlessStartupDeps: Parameters<
typeof composeHeadlessStartupHandlers<TCliArgs, TStartupState, TBootstrapDeps>
>[0];
overlayWindowDeps: Parameters<typeof composeOverlayWindowHandlers<TBrowserWindow>>[0];
}
export function createMainBootHandlers<
TBrowserWindow,
TCliArgs,
TStartupState,
TBootstrapDeps,
>(params: MainBootHandlersParams<TBrowserWindow, TCliArgs, TStartupState, TBootstrapDeps>) {
return {
startupLifecycle: composeStartupLifecycleHandlers(params.startupLifecycleDeps),
ipcRuntime: composeIpcRuntimeHandlers(params.ipcRuntimeDeps),
cliStartup: composeCliStartupHandlers(params.cliStartupDeps),
headlessStartup: composeHeadlessStartupHandlers<TCliArgs, TStartupState, TBootstrapDeps>(
params.headlessStartupDeps,
),
overlayWindow: composeOverlayWindowHandlers<TBrowserWindow>(params.overlayWindowDeps),
};
}
export const composeBootStartupLifecycleHandlers = composeStartupLifecycleHandlers;
export const composeBootIpcRuntimeHandlers = composeIpcRuntimeHandlers;
export const composeBootCliStartupHandlers = composeCliStartupHandlers;
export const composeBootHeadlessStartupHandlers = composeHeadlessStartupHandlers;
export const composeBootOverlayWindowHandlers = composeOverlayWindowHandlers;

View File

@@ -0,0 +1,339 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createMainBootRuntimes } from './runtimes';
test('createMainBootRuntimes returns grouped runtime bundles', () => {
const runtimes = createMainBootRuntimes<any, any, any, any>({
overlayVisibilityRuntimeDeps: {
overlayVisibilityRuntime: {} as never,
restorePreviousSecondarySubVisibilityMainDeps: {} as never,
broadcastRuntimeOptionsChangedMainDeps: {} as never,
sendToActiveOverlayWindowMainDeps: {} as never,
setOverlayDebugVisualizationEnabledMainDeps: {} as never,
openRuntimeOptionsPaletteMainDeps: {} as never,
},
jellyfinRuntimeHandlerDeps: {
getResolvedJellyfinConfigMainDeps: {} as never,
getJellyfinClientInfoMainDeps: {} as never,
waitForMpvConnectedMainDeps: {} as never,
launchMpvIdleForJellyfinPlaybackMainDeps: {} as never,
ensureMpvConnectedForJellyfinPlaybackMainDeps: {} as never,
preloadJellyfinExternalSubtitlesMainDeps: {} as never,
playJellyfinItemInMpvMainDeps: {} as never,
remoteComposerOptions: {} as never,
handleJellyfinAuthCommandsMainDeps: {} as never,
handleJellyfinListCommandsMainDeps: {} as never,
handleJellyfinPlayCommandMainDeps: {} as never,
handleJellyfinRemoteAnnounceCommandMainDeps: {} as never,
startJellyfinRemoteSessionMainDeps: {} as never,
stopJellyfinRemoteSessionMainDeps: {} as never,
runJellyfinCommandMainDeps: {} as never,
maybeFocusExistingJellyfinSetupWindowMainDeps: {} as never,
openJellyfinSetupWindowMainDeps: {} as never,
},
anilistSetupDeps: {
notifyDeps: {} as never,
consumeTokenDeps: {} as never,
handleProtocolDeps: {} as never,
registerProtocolClientDeps: {} as never,
},
buildOpenAnilistSetupWindowMainDeps: {
maybeFocusExistingSetupWindow: () => false,
createSetupWindow: () => null as never,
buildAuthorizeUrl: () => 'https://example.test',
consumeCallbackUrl: () => false,
openSetupInBrowser: () => {},
loadManualTokenEntry: () => Promise.resolve(),
redirectUri: 'https://example.test/callback',
developerSettingsUrl: 'https://example.test/dev',
isAllowedExternalUrl: () => true,
isAllowedNavigationUrl: () => true,
logWarn: () => {},
logError: () => {},
clearSetupWindow: () => {},
setSetupPageOpened: () => {},
setSetupWindow: () => {},
openExternal: () => {},
},
anilistTrackingDeps: {
refreshClientSecretMainDeps: {} as never,
getCurrentMediaKeyMainDeps: {} as never,
resetMediaTrackingMainDeps: {} as never,
getMediaGuessRuntimeStateMainDeps: {} as never,
setMediaGuessRuntimeStateMainDeps: {} as never,
resetMediaGuessStateMainDeps: {} as never,
maybeProbeDurationMainDeps: {} as never,
ensureMediaGuessMainDeps: {} as never,
processNextRetryUpdateMainDeps: {} as never,
maybeRunPostWatchUpdateMainDeps: {} as never,
},
statsStartupRuntimeDeps: {
ensureStatsServerStarted: () => '',
ensureBackgroundStatsServerStarted: () => ({ url: '', runningInCurrentProcess: false }),
stopBackgroundStatsServer: async () => ({ ok: true, stale: false }),
ensureImmersionTrackerStarted: () => {},
},
runStatsCliCommandDeps: {
getResolvedConfig: () => ({}) as never,
ensureImmersionTrackerStarted: () => {},
ensureVocabularyCleanupTokenizerReady: async () => {},
getImmersionTracker: () => null,
ensureStatsServerStarted: () => '',
ensureBackgroundStatsServerStarted: () => ({ url: '', runningInCurrentProcess: false }),
stopBackgroundStatsServer: async () => ({ ok: true, stale: false }),
openExternal: () => Promise.resolve(),
writeResponse: () => {},
exitAppWithCode: () => {},
logInfo: () => {},
logWarn: () => {},
logError: () => {},
},
appReadyRuntimeDeps: {
reloadConfigMainDeps: {
reloadConfigStrict: () => ({ ok: true, path: '/tmp/config.jsonc', warnings: [] }),
logInfo: () => {},
logWarning: () => {},
showDesktopNotification: () => {},
startConfigHotReload: () => {},
refreshAnilistClientSecretState: async () => {},
failHandlers: {
logError: () => {},
showErrorBox: () => {},
quit: () => {},
},
},
criticalConfigErrorMainDeps: {
getConfigPath: () => '/tmp/config.jsonc',
failHandlers: {
logError: () => {},
showErrorBox: () => {},
quit: () => {},
},
},
appReadyRuntimeMainDeps: {
ensureDefaultConfigBootstrap: () => {},
loadSubtitlePosition: () => {},
resolveKeybindings: () => {},
createMpvClient: () => {},
getResolvedConfig: () => ({}) as never,
getConfigWarnings: () => [],
logConfigWarning: () => {},
setLogLevel: () => {},
initRuntimeOptionsManager: () => {},
setSecondarySubMode: () => {},
defaultSecondarySubMode: 'hover',
defaultWebsocketPort: 5174,
defaultAnnotationWebsocketPort: 6678,
defaultTexthookerPort: 5174,
hasMpvWebsocketPlugin: () => false,
startSubtitleWebsocket: () => {},
startAnnotationWebsocket: () => {},
startTexthooker: () => {},
log: () => {},
createMecabTokenizerAndCheck: async () => {},
createSubtitleTimingTracker: () => {},
loadYomitanExtension: async () => {},
handleFirstRunSetup: async () => {},
startJellyfinRemoteSession: async () => {},
prewarmSubtitleDictionaries: async () => {},
startBackgroundWarmups: () => {},
texthookerOnlyMode: false,
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
setVisibleOverlayVisible: () => {},
initializeOverlayRuntime: () => {},
handleInitialArgs: () => {},
logDebug: () => {},
now: () => Date.now(),
},
immersionTrackerStartupMainDeps: {
getResolvedConfig: () => ({}) as never,
getConfiguredDbPath: () => '/tmp/immersion.sqlite',
createTrackerService: () =>
({
startSession: () => {},
}) as never,
setTracker: () => {},
getMpvClient: () => null,
seedTrackerFromCurrentMedia: () => {},
logInfo: () => {},
logDebug: () => {},
logWarn: () => {},
},
},
mpvRuntimeDeps: {
bindMpvMainEventHandlersMainDeps: {
appState: {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: null,
immersionTracker: null,
subtitleTimingTracker: null,
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: null,
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
refreshDiscordPresence: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
},
mpvClientRuntimeServiceFactoryMainDeps: {
createClient: class FakeMpvClient {
connected = true;
on(): void {}
connect(): void {}
} as never,
getSocketPath: () => '/tmp/mpv.sock',
getResolvedConfig: () => ({ auto_start_overlay: false }),
isAutoStartOverlayEnabled: () => true,
setOverlayVisible: () => {},
isVisibleOverlayVisible: () => false,
getReconnectTimer: () => null,
setReconnectTimer: () => {},
},
updateMpvSubtitleRenderMetricsMainDeps: {
getCurrentMetrics: () => ({
subPos: 100,
subFontSize: 36,
subScale: 1,
subMarginY: 0,
subMarginX: 0,
subFont: '',
subSpacing: 0,
subBold: false,
subItalic: false,
subBorderSize: 0,
subShadowOffset: 0,
subAssOverride: 'yes',
subScaleByWindow: true,
subUseMargins: true,
osdHeight: 0,
osdDimensions: null,
}),
setCurrentMetrics: () => {},
applyPatch: (current: any) => ({ next: current, changed: false }),
broadcastMetrics: () => {},
},
tokenizer: {
buildTokenizerDepsMainDeps: {
getYomitanExt: () => null,
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
getYomitanParserReadyPromise: () => null,
setYomitanParserReadyPromise: () => {},
getYomitanParserInitPromise: () => null,
setYomitanParserInitPromise: () => {},
isKnownWord: () => false,
recordLookup: () => {},
getKnownWordMatchMode: () => 'headword',
getMinSentenceWordsForNPlusOne: () => 3,
getJlptLevel: () => null,
getJlptEnabled: () => false,
getFrequencyDictionaryEnabled: () => false,
getFrequencyDictionaryMatchMode: () => 'headword',
getFrequencyRank: () => null,
getYomitanGroupDebugEnabled: () => false,
getMecabTokenizer: () => null,
},
createTokenizerRuntimeDeps: () => ({}),
tokenizeSubtitle: async () => ({ text: '' }),
createMecabTokenizerAndCheckMainDeps: {
getMecabTokenizer: () => null,
setMecabTokenizer: () => {},
createMecabTokenizer: () => ({}) as never,
checkAvailability: async () => {},
},
prewarmSubtitleDictionariesMainDeps: {
ensureJlptDictionaryLookup: async () => {},
ensureFrequencyDictionaryLookup: async () => {},
},
},
warmups: {
launchBackgroundWarmupTaskMainDeps: {
now: () => 0,
logDebug: () => {},
logWarn: () => {},
},
startBackgroundWarmupsMainDeps: {
getStarted: () => false,
setStarted: () => {},
isTexthookerOnlyMode: () => false,
ensureYomitanExtensionLoaded: async () => {},
shouldWarmupMecab: () => false,
shouldWarmupYomitanExtension: () => false,
shouldWarmupSubtitleDictionaries: () => false,
shouldWarmupJellyfinRemoteSession: () => false,
shouldAutoConnectJellyfinRemote: () => false,
startJellyfinRemoteSession: async () => {},
},
},
},
trayRuntimeDeps: {
resolveTrayIconPathDeps: {} as never,
buildTrayMenuTemplateDeps: {} as never,
ensureTrayDeps: {} as never,
destroyTrayDeps: {} as never,
buildMenuFromTemplate: () => ({}) as never,
},
yomitanProfilePolicyDeps: {
externalProfilePath: '',
logInfo: () => {},
},
yomitanExtensionRuntimeDeps: {
loadYomitanExtensionCore: async () => null,
userDataPath: '/tmp',
externalProfilePath: '',
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
setYomitanParserReadyPromise: () => {},
setYomitanParserInitPromise: () => {},
setYomitanExtension: () => {},
setYomitanSession: () => {},
getYomitanExtension: () => null,
getLoadInFlight: () => null,
setLoadInFlight: () => {},
},
yomitanSettingsRuntimeDeps: {
ensureYomitanExtensionLoaded: async () => {},
getYomitanSession: () => null,
openYomitanSettingsWindow: () => {},
getExistingWindow: () => null,
setWindow: () => {},
logWarn: () => {},
logError: () => {},
},
createOverlayRuntimeBootstrapHandlers: () => ({
initializeOverlayRuntime: () => {},
}),
initializeOverlayRuntimeMainDeps: {},
initializeOverlayRuntimeBootstrapDeps: {},
});
assert.equal(typeof runtimes.overlayVisibilityComposer.sendToActiveOverlayWindow, 'function');
assert.equal(typeof runtimes.jellyfinRuntimeHandlers.runJellyfinCommand, 'function');
assert.equal(typeof runtimes.anilistSetupHandlers.notifyAnilistSetup, 'function');
assert.equal(typeof runtimes.openAnilistSetupWindow, 'function');
assert.equal(typeof runtimes.anilistTrackingHandlers.maybeRunAnilistPostWatchUpdate, 'function');
assert.equal(typeof runtimes.runStatsCliCommand, 'function');
assert.equal(typeof runtimes.appReadyRuntime.appReadyRuntimeRunner, 'function');
assert.equal(typeof runtimes.initializeOverlayRuntime, 'function');
});

127
src/main/boot/runtimes.ts Normal file
View File

@@ -0,0 +1,127 @@
import { createOpenFirstRunSetupWindowHandler } from '../runtime/first-run-setup-window';
import { createRunStatsCliCommandHandler } from '../runtime/stats-cli-command';
import { createYomitanProfilePolicy } from '../runtime/yomitan-profile-policy';
import {
createBuildOpenAnilistSetupWindowMainDepsHandler,
createMaybeFocusExistingAnilistSetupWindowHandler,
createOpenAnilistSetupWindowHandler,
} from '../runtime/domains/anilist';
import {
createTrayRuntimeHandlers,
createYomitanExtensionRuntime,
createYomitanSettingsRuntime,
} from '../runtime/domains/overlay';
import {
composeAnilistSetupHandlers,
composeAnilistTrackingHandlers,
composeAppReadyRuntime,
composeJellyfinRuntimeHandlers,
composeMpvRuntimeHandlers,
composeOverlayVisibilityRuntime,
composeStatsStartupRuntime,
} from '../runtime/composers';
export interface MainBootRuntimesParams<TBrowserWindow, TMpvClient, TTokenizerDeps, TSubtitleData> {
overlayVisibilityRuntimeDeps: Parameters<typeof composeOverlayVisibilityRuntime>[0];
jellyfinRuntimeHandlerDeps: Parameters<typeof composeJellyfinRuntimeHandlers>[0];
anilistSetupDeps: Parameters<typeof composeAnilistSetupHandlers>[0];
buildOpenAnilistSetupWindowMainDeps: Parameters<
typeof createBuildOpenAnilistSetupWindowMainDepsHandler
>[0];
anilistTrackingDeps: Parameters<typeof composeAnilistTrackingHandlers>[0];
statsStartupRuntimeDeps: Parameters<typeof composeStatsStartupRuntime>[0];
runStatsCliCommandDeps: Parameters<typeof createRunStatsCliCommandHandler>[0];
appReadyRuntimeDeps: Parameters<typeof composeAppReadyRuntime>[0];
mpvRuntimeDeps: any;
trayRuntimeDeps: Parameters<typeof createTrayRuntimeHandlers>[0];
yomitanProfilePolicyDeps: Parameters<typeof createYomitanProfilePolicy>[0];
yomitanExtensionRuntimeDeps: Parameters<typeof createYomitanExtensionRuntime>[0];
yomitanSettingsRuntimeDeps: Parameters<typeof createYomitanSettingsRuntime>[0];
createOverlayRuntimeBootstrapHandlers: (params: {
initializeOverlayRuntimeMainDeps: unknown;
initializeOverlayRuntimeBootstrapDeps: unknown;
}) => {
initializeOverlayRuntime: () => void;
};
initializeOverlayRuntimeMainDeps: unknown;
initializeOverlayRuntimeBootstrapDeps: unknown;
}
export function createMainBootRuntimes<
TBrowserWindow,
TMpvClient,
TTokenizerDeps,
TSubtitleData,
>(
params: MainBootRuntimesParams<TBrowserWindow, TMpvClient, TTokenizerDeps, TSubtitleData>,
) {
const overlayVisibilityComposer = composeOverlayVisibilityRuntime(
params.overlayVisibilityRuntimeDeps,
);
const jellyfinRuntimeHandlers = composeJellyfinRuntimeHandlers(
params.jellyfinRuntimeHandlerDeps,
);
const anilistSetupHandlers = composeAnilistSetupHandlers(params.anilistSetupDeps);
const buildOpenAnilistSetupWindowMainDepsHandler =
createBuildOpenAnilistSetupWindowMainDepsHandler(params.buildOpenAnilistSetupWindowMainDeps);
const maybeFocusExistingAnilistSetupWindow =
params.buildOpenAnilistSetupWindowMainDeps.maybeFocusExistingSetupWindow;
const anilistTrackingHandlers = composeAnilistTrackingHandlers(params.anilistTrackingDeps);
const statsStartupRuntime = composeStatsStartupRuntime(params.statsStartupRuntimeDeps);
const runStatsCliCommand = createRunStatsCliCommandHandler(params.runStatsCliCommandDeps);
const appReadyRuntime = composeAppReadyRuntime(params.appReadyRuntimeDeps);
const mpvRuntimeHandlers = composeMpvRuntimeHandlers<any, any, any>(
params.mpvRuntimeDeps as any,
);
const trayRuntimeHandlers = createTrayRuntimeHandlers(params.trayRuntimeDeps);
const yomitanProfilePolicy = createYomitanProfilePolicy(params.yomitanProfilePolicyDeps);
const yomitanExtensionRuntime = createYomitanExtensionRuntime(
params.yomitanExtensionRuntimeDeps,
);
const yomitanSettingsRuntime = createYomitanSettingsRuntime(
params.yomitanSettingsRuntimeDeps,
);
const overlayRuntimeBootstrapHandlers = params.createOverlayRuntimeBootstrapHandlers({
initializeOverlayRuntimeMainDeps: params.initializeOverlayRuntimeMainDeps,
initializeOverlayRuntimeBootstrapDeps: params.initializeOverlayRuntimeBootstrapDeps,
});
return {
overlayVisibilityComposer,
jellyfinRuntimeHandlers,
anilistSetupHandlers,
maybeFocusExistingAnilistSetupWindow,
buildOpenAnilistSetupWindowMainDepsHandler,
openAnilistSetupWindow: () =>
createOpenAnilistSetupWindowHandler(buildOpenAnilistSetupWindowMainDepsHandler())(),
anilistTrackingHandlers,
statsStartupRuntime,
runStatsCliCommand,
appReadyRuntime,
mpvRuntimeHandlers,
trayRuntimeHandlers,
yomitanProfilePolicy,
yomitanExtensionRuntime,
yomitanSettingsRuntime,
initializeOverlayRuntime: overlayRuntimeBootstrapHandlers.initializeOverlayRuntime,
openFirstRunSetupWindowHandler: createOpenFirstRunSetupWindowHandler,
};
}
export const composeBootOverlayVisibilityRuntime = composeOverlayVisibilityRuntime;
export const composeBootJellyfinRuntimeHandlers = composeJellyfinRuntimeHandlers;
export const composeBootAnilistSetupHandlers = composeAnilistSetupHandlers;
export const composeBootAnilistTrackingHandlers = composeAnilistTrackingHandlers;
export const composeBootStatsStartupRuntime = composeStatsStartupRuntime;
export const createBootRunStatsCliCommandHandler = createRunStatsCliCommandHandler;
export const composeBootAppReadyRuntime = composeAppReadyRuntime;
export const composeBootMpvRuntimeHandlers = composeMpvRuntimeHandlers;
export const createBootTrayRuntimeHandlers = createTrayRuntimeHandlers;
export const createBootYomitanProfilePolicy = createYomitanProfilePolicy;
export const createBootYomitanExtensionRuntime = createYomitanExtensionRuntime;
export const createBootYomitanSettingsRuntime = createYomitanSettingsRuntime;
export const createBootMaybeFocusExistingAnilistSetupWindowHandler =
createMaybeFocusExistingAnilistSetupWindowHandler;
export const createBootBuildOpenAnilistSetupWindowMainDepsHandler =
createBuildOpenAnilistSetupWindowMainDepsHandler;
export const createBootOpenAnilistSetupWindowHandler = createOpenAnilistSetupWindowHandler;

View File

@@ -0,0 +1,115 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createMainBootServices } from './services';
test('createMainBootServices builds boot-phase service bundle', () => {
type MockAppLifecycleApp = {
requestSingleInstanceLock: () => boolean;
quit: () => void;
on: (event: string, listener: (...args: unknown[]) => void) => MockAppLifecycleApp;
whenReady: () => Promise<void>;
};
const calls: string[] = [];
let setPathValue: string | null = null;
const appOnCalls: string[] = [];
let secondInstanceHandlerRegistered = false;
const services = createMainBootServices<
{ configDir: string },
{ targetPath: string },
{ targetPath: string },
{ targetPath: string },
{ kind: string },
{ scope: string; warn: () => void; info: () => void; error: () => void },
{ registry: boolean },
{ getModalWindow: () => null },
{ inputState: boolean },
{ measurementStore: boolean },
{ modalRuntime: boolean },
{ mpvSocketPath: string; texthookerPort: number },
MockAppLifecycleApp
>({
platform: 'linux',
argv: ['node', 'main.ts'],
appDataDir: undefined,
xdgConfigHome: undefined,
homeDir: '/home/tester',
defaultMpvLogFile: '/tmp/default.log',
envMpvLog: ' /tmp/custom.log ',
defaultTexthookerPort: 5174,
getDefaultSocketPath: () => '/tmp/subminer.sock',
resolveConfigDir: () => '/tmp/subminer-config',
existsSync: () => false,
mkdirSync: (targetPath) => {
calls.push(`mkdir:${targetPath}`);
},
joinPath: (...parts) => parts.join('/'),
app: {
setPath: (_name, value) => {
setPathValue = value;
},
quit: () => {},
on: (event) => {
appOnCalls.push(event);
return {};
},
whenReady: async () => {},
},
shouldBypassSingleInstanceLock: () => false,
requestSingleInstanceLockEarly: () => true,
registerSecondInstanceHandlerEarly: () => {
secondInstanceHandlerRegistered = true;
},
onConfigStartupParseError: () => {
throw new Error('unexpected parse failure');
},
createConfigService: (configDir) => ({ configDir }),
createAnilistTokenStore: (targetPath) => ({ targetPath }),
createJellyfinTokenStore: (targetPath) => ({ targetPath }),
createAnilistUpdateQueue: (targetPath) => ({ targetPath }),
createSubtitleWebSocket: () => ({ kind: 'ws' }),
createLogger: (scope) =>
({
scope,
warn: () => {},
info: () => {},
error: () => {},
}) as const,
createMainRuntimeRegistry: () => ({ registry: true }),
createOverlayManager: () => ({
getModalWindow: () => null,
}),
createOverlayModalInputState: () => ({ inputState: true }),
createOverlayContentMeasurementStore: () => ({ measurementStore: true }),
getSyncOverlayShortcutsForModal: () => () => {},
getSyncOverlayVisibilityForModal: () => () => {},
createOverlayModalRuntime: () => ({ modalRuntime: true }),
createAppState: (input) => ({ ...input }),
});
assert.equal(services.configDir, '/tmp/subminer-config');
assert.equal(services.userDataPath, '/tmp/subminer-config');
assert.equal(services.defaultMpvLogPath, '/tmp/custom.log');
assert.equal(services.defaultImmersionDbPath, '/tmp/subminer-config/immersion.sqlite');
assert.deepEqual(services.configService, { configDir: '/tmp/subminer-config' });
assert.deepEqual(services.anilistTokenStore, {
targetPath: '/tmp/subminer-config/anilist-token-store.json',
});
assert.deepEqual(services.jellyfinTokenStore, {
targetPath: '/tmp/subminer-config/jellyfin-token-store.json',
});
assert.deepEqual(services.anilistUpdateQueue, {
targetPath: '/tmp/subminer-config/anilist-retry-queue.json',
});
assert.deepEqual(services.appState, {
mpvSocketPath: '/tmp/subminer.sock',
texthookerPort: 5174,
});
assert.equal(services.appLifecycleApp.on('ready', () => {}), services.appLifecycleApp);
assert.equal(services.appLifecycleApp.on('second-instance', () => {}), services.appLifecycleApp);
assert.deepEqual(appOnCalls, ['ready']);
assert.equal(secondInstanceHandlerRegistered, true);
assert.deepEqual(calls, ['mkdir:/tmp/subminer-config']);
assert.equal(setPathValue, '/tmp/subminer-config');
});

262
src/main/boot/services.ts Normal file
View File

@@ -0,0 +1,262 @@
import { ConfigStartupParseError } from '../../config';
export interface MainBootServicesParams<
TConfigService,
TAnilistTokenStore,
TJellyfinTokenStore,
TAnilistUpdateQueue,
TSubtitleWebSocket,
TLogger,
TRuntimeRegistry,
TOverlayManager,
TOverlayModalInputState,
TOverlayContentMeasurementStore,
TOverlayModalRuntime,
TAppState,
TAppLifecycleApp,
> {
platform: NodeJS.Platform;
argv: string[];
appDataDir: string | undefined;
xdgConfigHome: string | undefined;
homeDir: string;
defaultMpvLogFile: string;
envMpvLog: string | undefined;
defaultTexthookerPort: number;
getDefaultSocketPath: () => string;
resolveConfigDir: (input: {
platform: NodeJS.Platform;
appDataDir: string | undefined;
xdgConfigHome: string | undefined;
homeDir: string;
existsSync: (targetPath: string) => boolean;
}) => string;
existsSync: (targetPath: string) => boolean;
mkdirSync: (targetPath: string, options: { recursive: true }) => void;
joinPath: (...parts: string[]) => string;
app: {
setPath: (name: string, value: string) => void;
quit: () => void;
on: (...args: any[]) => unknown;
whenReady: () => Promise<void>;
};
shouldBypassSingleInstanceLock: () => boolean;
requestSingleInstanceLockEarly: () => boolean;
registerSecondInstanceHandlerEarly: (
listener: (_event: unknown, argv: string[]) => void,
) => void;
onConfigStartupParseError: (error: ConfigStartupParseError) => void;
createConfigService: (configDir: string) => TConfigService;
createAnilistTokenStore: (targetPath: string) => TAnilistTokenStore;
createJellyfinTokenStore: (targetPath: string) => TJellyfinTokenStore;
createAnilistUpdateQueue: (targetPath: string) => TAnilistUpdateQueue;
createSubtitleWebSocket: () => TSubtitleWebSocket;
createLogger: (scope: string) => TLogger & {
warn: (message: string) => void;
info: (message: string) => void;
error: (message: string, details?: unknown) => void;
};
createMainRuntimeRegistry: () => TRuntimeRegistry;
createOverlayManager: () => TOverlayManager;
createOverlayModalInputState: (params: any) => TOverlayModalInputState;
createOverlayContentMeasurementStore: (params: {
logger: TLogger;
}) => TOverlayContentMeasurementStore;
getSyncOverlayShortcutsForModal: () => (isActive: boolean) => void;
getSyncOverlayVisibilityForModal: () => () => void;
createOverlayModalRuntime: (params: {
overlayManager: TOverlayManager;
overlayModalInputState: TOverlayModalInputState;
onModalStateChange: (isActive: boolean) => void;
}) => TOverlayModalRuntime;
createAppState: (input: {
mpvSocketPath: string;
texthookerPort: number;
}) => TAppState;
}
export interface MainBootServicesResult<
TConfigService,
TAnilistTokenStore,
TJellyfinTokenStore,
TAnilistUpdateQueue,
TSubtitleWebSocket,
TLogger,
TRuntimeRegistry,
TOverlayManager,
TOverlayModalInputState,
TOverlayContentMeasurementStore,
TOverlayModalRuntime,
TAppState,
TAppLifecycleApp,
> {
configDir: string;
userDataPath: string;
defaultMpvLogPath: string;
defaultImmersionDbPath: string;
configService: TConfigService;
anilistTokenStore: TAnilistTokenStore;
jellyfinTokenStore: TJellyfinTokenStore;
anilistUpdateQueue: TAnilistUpdateQueue;
subtitleWsService: TSubtitleWebSocket;
annotationSubtitleWsService: TSubtitleWebSocket;
logger: TLogger;
runtimeRegistry: TRuntimeRegistry;
overlayManager: TOverlayManager;
overlayModalInputState: TOverlayModalInputState;
overlayContentMeasurementStore: TOverlayContentMeasurementStore;
overlayModalRuntime: TOverlayModalRuntime;
appState: TAppState;
appLifecycleApp: TAppLifecycleApp;
}
export function createMainBootServices<
TConfigService,
TAnilistTokenStore,
TJellyfinTokenStore,
TAnilistUpdateQueue,
TSubtitleWebSocket,
TLogger,
TRuntimeRegistry,
TOverlayManager extends { getModalWindow: () => unknown },
TOverlayModalInputState,
TOverlayContentMeasurementStore,
TOverlayModalRuntime,
TAppState,
TAppLifecycleApp,
>(
params: MainBootServicesParams<
TConfigService,
TAnilistTokenStore,
TJellyfinTokenStore,
TAnilistUpdateQueue,
TSubtitleWebSocket,
TLogger,
TRuntimeRegistry,
TOverlayManager,
TOverlayModalInputState,
TOverlayContentMeasurementStore,
TOverlayModalRuntime,
TAppState,
TAppLifecycleApp
>,
): MainBootServicesResult<
TConfigService,
TAnilistTokenStore,
TJellyfinTokenStore,
TAnilistUpdateQueue,
TSubtitleWebSocket,
TLogger,
TRuntimeRegistry,
TOverlayManager,
TOverlayModalInputState,
TOverlayContentMeasurementStore,
TOverlayModalRuntime,
TAppState,
TAppLifecycleApp
> {
const configDir = params.resolveConfigDir({
platform: params.platform,
appDataDir: params.appDataDir,
xdgConfigHome: params.xdgConfigHome,
homeDir: params.homeDir,
existsSync: params.existsSync,
});
const userDataPath = configDir;
const defaultMpvLogPath = params.envMpvLog?.trim() || params.defaultMpvLogFile;
const defaultImmersionDbPath = params.joinPath(userDataPath, 'immersion.sqlite');
const configService = (() => {
try {
return params.createConfigService(configDir);
} catch (error) {
if (error instanceof ConfigStartupParseError) {
params.onConfigStartupParseError(error);
}
throw error;
}
})();
const anilistTokenStore = params.createAnilistTokenStore(
params.joinPath(userDataPath, 'anilist-token-store.json'),
);
const jellyfinTokenStore = params.createJellyfinTokenStore(
params.joinPath(userDataPath, 'jellyfin-token-store.json'),
);
const anilistUpdateQueue = params.createAnilistUpdateQueue(
params.joinPath(userDataPath, 'anilist-retry-queue.json'),
);
const subtitleWsService = params.createSubtitleWebSocket();
const annotationSubtitleWsService = params.createSubtitleWebSocket();
const logger = params.createLogger('main');
const runtimeRegistry = params.createMainRuntimeRegistry();
const overlayManager = params.createOverlayManager();
const overlayModalInputState = params.createOverlayModalInputState({
getModalWindow: () => overlayManager.getModalWindow(),
syncOverlayShortcutsForModal: (isActive: boolean) => {
params.getSyncOverlayShortcutsForModal()(isActive);
},
syncOverlayVisibilityForModal: () => {
params.getSyncOverlayVisibilityForModal()();
},
});
const overlayContentMeasurementStore = params.createOverlayContentMeasurementStore({
logger,
});
const overlayModalRuntime = params.createOverlayModalRuntime({
overlayManager,
overlayModalInputState,
onModalStateChange: (isActive: boolean) =>
(overlayModalInputState as { handleModalInputStateChange?: (isActive: boolean) => void })
.handleModalInputStateChange?.(isActive),
});
const appState = params.createAppState({
mpvSocketPath: params.getDefaultSocketPath(),
texthookerPort: params.defaultTexthookerPort,
});
if (!params.existsSync(userDataPath)) {
params.mkdirSync(userDataPath, { recursive: true });
}
params.app.setPath('userData', userDataPath);
const appLifecycleApp = {
requestSingleInstanceLock: () =>
params.shouldBypassSingleInstanceLock()
? true
: params.requestSingleInstanceLockEarly(),
quit: () => params.app.quit(),
on: (event: string, listener: (...args: unknown[]) => void) => {
if (event === 'second-instance') {
params.registerSecondInstanceHandlerEarly(
listener as (_event: unknown, argv: string[]) => void,
);
return appLifecycleApp;
}
params.app.on(event, listener);
return appLifecycleApp;
},
whenReady: () => params.app.whenReady(),
} as TAppLifecycleApp;
return {
configDir,
userDataPath,
defaultMpvLogPath,
defaultImmersionDbPath,
configService,
anilistTokenStore,
jellyfinTokenStore,
anilistUpdateQueue,
subtitleWsService,
annotationSubtitleWsService,
logger,
runtimeRegistry,
overlayManager,
overlayModalInputState,
overlayContentMeasurementStore,
overlayModalRuntime,
appState,
appLifecycleApp,
};
}

View File

@@ -48,9 +48,14 @@ test('buildDictionaryZip writes a valid stored zip without fs.writeFileSync', ()
const termEntries: CharacterDictionaryTermEntry[] = [ const termEntries: CharacterDictionaryTermEntry[] = [
['アルファ', 'あるふぁ', '', '', 0, ['Alpha entry'], 0, 'name'], ['アルファ', 'あるふぁ', '', '', 0, ['Alpha entry'], 0, 'name'],
]; ];
const originalWriteFileSync = fs.writeFileSync;
const originalBufferConcat = Buffer.concat; const originalBufferConcat = Buffer.concat;
try { try {
fs.writeFileSync = ((..._args: unknown[]) => {
throw new Error('buildDictionaryZip should not call fs.writeFileSync');
}) as typeof fs.writeFileSync;
Buffer.concat = ((...args: Parameters<typeof Buffer.concat>) => { Buffer.concat = ((...args: Parameters<typeof Buffer.concat>) => {
throw new Error(`buildDictionaryZip should not Buffer.concat the full archive (${args[0].length} chunks)`); throw new Error(`buildDictionaryZip should not Buffer.concat the full archive (${args[0].length} chunks)`);
}) as typeof Buffer.concat; }) as typeof Buffer.concat;
@@ -92,6 +97,7 @@ test('buildDictionaryZip writes a valid stored zip without fs.writeFileSync', ()
assert.equal(termBank[0]?.[0], 'アルファ'); assert.equal(termBank[0]?.[0], 'アルファ');
assert.deepEqual(entries.get('images/alpha.bin'), Buffer.from([1, 2, 3])); assert.deepEqual(entries.get('images/alpha.bin'), Buffer.from([1, 2, 3]));
} finally { } finally {
fs.writeFileSync = originalWriteFileSync;
Buffer.concat = originalBufferConcat; Buffer.concat = originalBufferConcat;
cleanupDir(tempDir); cleanupDir(tempDir);
} }

View File

@@ -1,8 +1,18 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test'; import test from 'node:test';
import { applyControllerConfigUpdate } from './controller-config-update.js'; import { applyControllerConfigUpdate } from './controller-config-update.js';
test('SM-012 controller config update path does not use JSON serialize-clone helpers', () => {
const source = fs.readFileSync(
path.join(process.cwd(), 'src/main/controller-config-update.ts'),
'utf-8',
);
assert.equal(source.includes('JSON.parse(JSON.stringify('), false);
});
test('applyControllerConfigUpdate replaces binding descriptors instead of deep-merging them', () => { test('applyControllerConfigUpdate replaces binding descriptors instead of deep-merging them', () => {
const next = applyControllerConfigUpdate( const next = applyControllerConfigUpdate(
{ {
@@ -52,3 +62,16 @@ test('applyControllerConfigUpdate merges buttonIndices while replacing only upda
assert.deepEqual(next.bindings?.toggleLookup, { kind: 'button', buttonIndex: 0 }); assert.deepEqual(next.bindings?.toggleLookup, { kind: 'button', buttonIndex: 0 });
assert.deepEqual(next.bindings?.closeLookup, { kind: 'none' }); assert.deepEqual(next.bindings?.closeLookup, { kind: 'none' });
}); });
test('applyControllerConfigUpdate detaches updated binding values from the patch object', () => {
const update = {
bindings: {
toggleLookup: { kind: 'button' as const, buttonIndex: 7 },
},
};
const next = applyControllerConfigUpdate(undefined, update);
update.bindings.toggleLookup.buttonIndex = 99;
assert.deepEqual(next.bindings?.toggleLookup, { kind: 'button', buttonIndex: 7 });
});

View File

@@ -28,7 +28,7 @@ export function applyControllerConfigUpdate(
[keyof RawControllerBindings, RawControllerBindings[keyof RawControllerBindings] | undefined] [keyof RawControllerBindings, RawControllerBindings[keyof RawControllerBindings] | undefined]
>) { >) {
if (value === undefined) continue; if (value === undefined) continue;
(nextBindings as Record<string, unknown>)[key] = JSON.parse(JSON.stringify(value)); (nextBindings as Record<string, unknown>)[key] = structuredClone(value);
} }
nextController.bindings = nextBindings; nextController.bindings = nextBindings;

View File

@@ -21,7 +21,7 @@ test('process next anilist retry update main deps builder maps callbacks', async
now: () => 7, now: () => 7,
})(); })();
assert.deepEqual(deps.nextReady(), { key: 'k', title: 't', episode: 1 }); assert.deepEqual(deps.nextReady(), { key: 'k', title: 't', season: null, episode: 1 });
deps.refreshRetryQueueState(); deps.refreshRetryQueueState();
deps.setLastAttemptAt(1); deps.setLastAttemptAt(1);
deps.setLastError('x'); deps.setLastError('x');

View File

@@ -84,51 +84,63 @@ test('findAnilistSetupDeepLinkArgvUrl returns null when missing', () => {
}); });
test('consumeAnilistSetupCallbackUrl persists token and closes window for callback URL', () => { test('consumeAnilistSetupCallbackUrl persists token and closes window for callback URL', () => {
const originalDateNow = Date.now;
const events: string[] = []; const events: string[] = [];
const handled = consumeAnilistSetupCallbackUrl({ try {
rawUrl: 'https://anilist.subminer.moe/#access_token=saved-token', Date.now = () => 120_000;
saveToken: (value: string) => events.push(`save:${value}`), const handled = consumeAnilistSetupCallbackUrl({
setCachedToken: (value: string) => events.push(`cache:${value}`), rawUrl: 'https://anilist.subminer.moe/#access_token=saved-token',
setResolvedState: (timestampMs: number) => saveToken: (value: string) => events.push(`save:${value}`),
events.push(`state:${timestampMs > 0 ? 'ok' : 'bad'}`), setCachedToken: (value: string) => events.push(`cache:${value}`),
setSetupPageOpened: (opened: boolean) => events.push(`opened:${opened}`), setResolvedState: (timestampMs: number) =>
onSuccess: () => events.push('success'), events.push(`state:${timestampMs > 0 ? 'ok' : 'bad'}`),
closeWindow: () => events.push('close'), setSetupPageOpened: (opened: boolean) => events.push(`opened:${opened}`),
}); onSuccess: () => events.push('success'),
closeWindow: () => events.push('close'),
});
assert.equal(handled, true); assert.equal(handled, true);
assert.deepEqual(events, [ assert.deepEqual(events, [
'save:saved-token', 'save:saved-token',
'cache:saved-token', 'cache:saved-token',
'state:ok', 'state:ok',
'opened:false', 'opened:false',
'success', 'success',
'close', 'close',
]); ]);
} finally {
Date.now = originalDateNow;
}
}); });
test('consumeAnilistSetupCallbackUrl persists token for subminer deep link URL', () => { test('consumeAnilistSetupCallbackUrl persists token for subminer deep link URL', () => {
const originalDateNow = Date.now;
const events: string[] = []; const events: string[] = [];
const handled = consumeAnilistSetupCallbackUrl({ try {
rawUrl: 'subminer://anilist-setup?access_token=saved-token', Date.now = () => 120_000;
saveToken: (value: string) => events.push(`save:${value}`), const handled = consumeAnilistSetupCallbackUrl({
setCachedToken: (value: string) => events.push(`cache:${value}`), rawUrl: 'subminer://anilist-setup?access_token=saved-token',
setResolvedState: (timestampMs: number) => saveToken: (value: string) => events.push(`save:${value}`),
events.push(`state:${timestampMs > 0 ? 'ok' : 'bad'}`), setCachedToken: (value: string) => events.push(`cache:${value}`),
setSetupPageOpened: (opened: boolean) => events.push(`opened:${opened}`), setResolvedState: (timestampMs: number) =>
onSuccess: () => events.push('success'), events.push(`state:${timestampMs > 0 ? 'ok' : 'bad'}`),
closeWindow: () => events.push('close'), setSetupPageOpened: (opened: boolean) => events.push(`opened:${opened}`),
}); onSuccess: () => events.push('success'),
closeWindow: () => events.push('close'),
});
assert.equal(handled, true); assert.equal(handled, true);
assert.deepEqual(events, [ assert.deepEqual(events, [
'save:saved-token', 'save:saved-token',
'cache:saved-token', 'cache:saved-token',
'state:ok', 'state:ok',
'opened:false', 'opened:false',
'success', 'success',
'close', 'close',
]); ]);
} finally {
Date.now = originalDateNow;
}
}); });
test('consumeAnilistSetupCallbackUrl ignores non-callback URLs', () => { test('consumeAnilistSetupCallbackUrl ignores non-callback URLs', () => {

View File

@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createAutoplayReadyGate } from './autoplay-ready-gate';
test('autoplay ready gate suppresses duplicate media signals unless forced while paused', async () => {
const commands: Array<Array<string | boolean>> = [];
const scheduled: Array<() => void> = [];
const gate = createAutoplayReadyGate({
isAppOwnedFlowInFlight: () => false,
getCurrentMediaPath: () => '/media/video.mkv',
getCurrentVideoPath: () => null,
getPlaybackPaused: () => true,
getMpvClient: () =>
({
connected: true,
requestProperty: async () => true,
send: ({ command }: { command: Array<string | boolean> }) => {
commands.push(command);
},
}) as never,
signalPluginAutoplayReady: () => {
commands.push(['script-message', 'subminer-autoplay-ready']);
},
schedule: (callback) => {
scheduled.push(callback);
return 1 as never;
},
logDebug: () => {},
});
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null });
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null });
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(commands.slice(0, 3), [
['script-message', 'subminer-autoplay-ready'],
['script-message', 'subminer-autoplay-ready'],
['script-message', 'subminer-autoplay-ready'],
]);
assert.ok(commands.some((command) => command[0] === 'set_property' && command[1] === 'pause'));
assert.equal(scheduled.length > 0, true);
});

View File

@@ -0,0 +1,129 @@
import type { SubtitleData } from '../../types';
import { resolveAutoplayReadyMaxReleaseAttempts } from './startup-autoplay-release-policy';
type MpvClientLike = {
connected?: boolean;
requestProperty: (property: string) => Promise<unknown>;
send: (payload: { command: Array<string | boolean> }) => void;
};
export type AutoplayReadyGateDeps = {
isAppOwnedFlowInFlight: () => boolean;
getCurrentMediaPath: () => string | null;
getCurrentVideoPath: () => string | null;
getPlaybackPaused: () => boolean | null;
getMpvClient: () => MpvClientLike | null;
signalPluginAutoplayReady: () => void;
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
logDebug: (message: string) => void;
};
export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
let autoPlayReadySignalMediaPath: string | null = null;
let autoPlayReadySignalGeneration = 0;
const invalidatePendingAutoplayReadyFallbacks = (): void => {
autoPlayReadySignalMediaPath = null;
autoPlayReadySignalGeneration += 1;
};
const maybeSignalPluginAutoplayReady = (
payload: SubtitleData,
options?: { forceWhilePaused?: boolean },
): void => {
if (deps.isAppOwnedFlowInFlight()) {
deps.logDebug('[autoplay-ready] suppressed while app-owned YouTube flow is active');
return;
}
if (!payload.text.trim()) {
return;
}
const mediaPath =
deps.getCurrentMediaPath()?.trim() ||
deps.getCurrentVideoPath()?.trim() ||
'__unknown__';
const duplicateMediaSignal = autoPlayReadySignalMediaPath === mediaPath;
const allowDuplicateWhilePaused =
options?.forceWhilePaused === true && deps.getPlaybackPaused() !== false;
if (duplicateMediaSignal && !allowDuplicateWhilePaused) {
return;
}
if (duplicateMediaSignal && allowDuplicateWhilePaused) {
deps.signalPluginAutoplayReady();
return;
}
autoPlayReadySignalMediaPath = mediaPath;
const playbackGeneration = ++autoPlayReadySignalGeneration;
deps.signalPluginAutoplayReady();
const releaseRetryDelayMs = 200;
const maxReleaseAttempts = resolveAutoplayReadyMaxReleaseAttempts({
forceWhilePaused: options?.forceWhilePaused === true,
retryDelayMs: releaseRetryDelayMs,
});
const isPlaybackPaused = async (client: MpvClientLike): Promise<boolean> => {
try {
const pauseProperty = await client.requestProperty('pause');
if (typeof pauseProperty === 'boolean') {
return pauseProperty;
}
if (typeof pauseProperty === 'string') {
return pauseProperty.toLowerCase() !== 'no' && pauseProperty !== '0';
}
if (typeof pauseProperty === 'number') {
return pauseProperty !== 0;
}
} catch (error) {
deps.logDebug(
`[autoplay-ready] failed to read pause property for media ${mediaPath}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
return true;
};
const attemptRelease = (attempt: number): void => {
void (async () => {
if (
autoPlayReadySignalMediaPath !== mediaPath ||
playbackGeneration !== autoPlayReadySignalGeneration
) {
return;
}
const mpvClient = deps.getMpvClient();
if (!mpvClient?.connected) {
if (attempt < maxReleaseAttempts) {
deps.schedule(() => attemptRelease(attempt + 1), releaseRetryDelayMs);
}
return;
}
const shouldUnpause = await isPlaybackPaused(mpvClient);
if (!shouldUnpause) {
return;
}
deps.signalPluginAutoplayReady();
mpvClient.send({ command: ['set_property', 'pause', false] });
if (attempt < maxReleaseAttempts) {
deps.schedule(() => attemptRelease(attempt + 1), releaseRetryDelayMs);
}
})();
};
attemptRelease(0);
};
return {
getAutoPlayReadySignalMediaPath: (): string | null => autoPlayReadySignalMediaPath,
invalidatePendingAutoplayReadyFallbacks,
maybeSignalPluginAutoplayReady,
};
}

View File

@@ -9,5 +9,8 @@ export * from './jellyfin-remote-composer';
export * from './jellyfin-runtime-composer'; export * from './jellyfin-runtime-composer';
export * from './mpv-runtime-composer'; export * from './mpv-runtime-composer';
export * from './overlay-window-composer'; export * from './overlay-window-composer';
export * from './overlay-visibility-runtime-composer';
export * from './shortcuts-runtime-composer'; export * from './shortcuts-runtime-composer';
export * from './stats-startup-composer';
export * from './subtitle-prefetch-runtime-composer';
export * from './startup-lifecycle-composer'; export * from './startup-lifecycle-composer';

View File

@@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeOverlayVisibilityRuntime } from './overlay-visibility-runtime-composer';
test('composeOverlayVisibilityRuntime returns overlay visibility handlers', () => {
const composed = composeOverlayVisibilityRuntime({
overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => {},
},
restorePreviousSecondarySubVisibilityMainDeps: {
getMpvClient: () => null,
},
broadcastRuntimeOptionsChangedMainDeps: {
broadcastRuntimeOptionsChangedRuntime: () => {},
getRuntimeOptionsState: () => [],
broadcastToOverlayWindows: () => {},
},
sendToActiveOverlayWindowMainDeps: {
sendToActiveOverlayWindowRuntime: () => true,
},
setOverlayDebugVisualizationEnabledMainDeps: {
setOverlayDebugVisualizationEnabledRuntime: () => {},
getCurrentEnabled: () => false,
setCurrentEnabled: () => {},
},
openRuntimeOptionsPaletteMainDeps: {
openRuntimeOptionsPaletteRuntime: () => {},
},
});
assert.equal(typeof composed.updateVisibleOverlayVisibility, 'function');
assert.equal(typeof composed.restorePreviousSecondarySubVisibility, 'function');
assert.equal(typeof composed.broadcastRuntimeOptionsChanged, 'function');
assert.equal(typeof composed.sendToActiveOverlayWindow, 'function');
assert.equal(typeof composed.setOverlayDebugVisualizationEnabled, 'function');
assert.equal(typeof composed.openRuntimeOptionsPalette, 'function');
});

View File

@@ -0,0 +1,88 @@
import {
createBroadcastRuntimeOptionsChangedHandler,
createOpenRuntimeOptionsPaletteHandler,
createRestorePreviousSecondarySubVisibilityHandler,
createSendToActiveOverlayWindowHandler,
createSetOverlayDebugVisualizationEnabledHandler,
} from '../overlay-runtime-main-actions';
import {
createBuildBroadcastRuntimeOptionsChangedMainDepsHandler,
createBuildOpenRuntimeOptionsPaletteMainDepsHandler,
createBuildRestorePreviousSecondarySubVisibilityMainDepsHandler,
createBuildSendToActiveOverlayWindowMainDepsHandler,
createBuildSetOverlayDebugVisualizationEnabledMainDepsHandler,
} from '../overlay-runtime-main-actions-main-deps';
import type { ComposerInputs, ComposerOutputs } from './contracts';
type RestorePreviousSecondarySubVisibilityMainDeps = Parameters<
typeof createBuildRestorePreviousSecondarySubVisibilityMainDepsHandler
>[0];
type BroadcastRuntimeOptionsChangedMainDeps = Parameters<
typeof createBuildBroadcastRuntimeOptionsChangedMainDepsHandler
>[0];
type SendToActiveOverlayWindowMainDeps = Parameters<
typeof createBuildSendToActiveOverlayWindowMainDepsHandler
>[0];
type SetOverlayDebugVisualizationEnabledMainDeps = Parameters<
typeof createBuildSetOverlayDebugVisualizationEnabledMainDepsHandler
>[0];
type OpenRuntimeOptionsPaletteMainDeps = Parameters<
typeof createBuildOpenRuntimeOptionsPaletteMainDepsHandler
>[0];
export type OverlayVisibilityRuntimeComposerOptions = ComposerInputs<{
overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => void;
};
restorePreviousSecondarySubVisibilityMainDeps: RestorePreviousSecondarySubVisibilityMainDeps;
broadcastRuntimeOptionsChangedMainDeps: BroadcastRuntimeOptionsChangedMainDeps;
sendToActiveOverlayWindowMainDeps: SendToActiveOverlayWindowMainDeps;
setOverlayDebugVisualizationEnabledMainDeps: SetOverlayDebugVisualizationEnabledMainDeps;
openRuntimeOptionsPaletteMainDeps: OpenRuntimeOptionsPaletteMainDeps;
}>;
export type OverlayVisibilityRuntimeComposerResult = ComposerOutputs<{
updateVisibleOverlayVisibility: () => void;
restorePreviousSecondarySubVisibility: ReturnType<
typeof createRestorePreviousSecondarySubVisibilityHandler
>;
broadcastRuntimeOptionsChanged: ReturnType<typeof createBroadcastRuntimeOptionsChangedHandler>;
sendToActiveOverlayWindow: ReturnType<typeof createSendToActiveOverlayWindowHandler>;
setOverlayDebugVisualizationEnabled: ReturnType<
typeof createSetOverlayDebugVisualizationEnabledHandler
>;
openRuntimeOptionsPalette: ReturnType<typeof createOpenRuntimeOptionsPaletteHandler>;
}>;
export function composeOverlayVisibilityRuntime(
options: OverlayVisibilityRuntimeComposerOptions,
): OverlayVisibilityRuntimeComposerResult {
return {
updateVisibleOverlayVisibility: () => options.overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
restorePreviousSecondarySubVisibility: createRestorePreviousSecondarySubVisibilityHandler(
createBuildRestorePreviousSecondarySubVisibilityMainDepsHandler(
options.restorePreviousSecondarySubVisibilityMainDeps,
)(),
),
broadcastRuntimeOptionsChanged: createBroadcastRuntimeOptionsChangedHandler(
createBuildBroadcastRuntimeOptionsChangedMainDepsHandler(
options.broadcastRuntimeOptionsChangedMainDeps,
)(),
),
sendToActiveOverlayWindow: createSendToActiveOverlayWindowHandler(
createBuildSendToActiveOverlayWindowMainDepsHandler(
options.sendToActiveOverlayWindowMainDeps,
)(),
),
setOverlayDebugVisualizationEnabled: createSetOverlayDebugVisualizationEnabledHandler(
createBuildSetOverlayDebugVisualizationEnabledMainDepsHandler(
options.setOverlayDebugVisualizationEnabledMainDeps,
)(),
),
openRuntimeOptionsPalette: createOpenRuntimeOptionsPaletteHandler(
createBuildOpenRuntimeOptionsPaletteMainDepsHandler(
options.openRuntimeOptionsPaletteMainDeps,
)(),
),
};
}

View File

@@ -0,0 +1,23 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeStatsStartupRuntime } from './stats-startup-composer';
test('composeStatsStartupRuntime returns stats startup handlers', async () => {
const composed = composeStatsStartupRuntime({
ensureStatsServerStarted: () => 'http://127.0.0.1:8766',
ensureBackgroundStatsServerStarted: () => ({
url: 'http://127.0.0.1:8766',
runningInCurrentProcess: true,
}),
stopBackgroundStatsServer: async () => ({ ok: true, stale: false }),
ensureImmersionTrackerStarted: () => {},
});
assert.equal(composed.ensureStatsServerStarted(), 'http://127.0.0.1:8766');
assert.deepEqual(composed.ensureBackgroundStatsServerStarted(), {
url: 'http://127.0.0.1:8766',
runningInCurrentProcess: true,
});
assert.deepEqual(await composed.stopBackgroundStatsServer(), { ok: true, stale: false });
assert.equal(typeof composed.ensureImmersionTrackerStarted, 'function');
});

View File

@@ -0,0 +1,26 @@
import type { ComposerInputs, ComposerOutputs } from './contracts';
type BackgroundStatsStartResult = {
url: string;
runningInCurrentProcess: boolean;
};
type BackgroundStatsStopResult = {
ok: boolean;
stale: boolean;
};
export type StatsStartupComposerOptions = ComposerInputs<{
ensureStatsServerStarted: () => string;
ensureBackgroundStatsServerStarted: () => BackgroundStatsStartResult;
stopBackgroundStatsServer: () => Promise<BackgroundStatsStopResult> | BackgroundStatsStopResult;
ensureImmersionTrackerStarted: () => void;
}>;
export type StatsStartupComposerResult = ComposerOutputs<StatsStartupComposerOptions>;
export function composeStatsStartupRuntime(
options: StatsStartupComposerOptions,
): StatsStartupComposerResult {
return options;
}

View File

@@ -0,0 +1,23 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeSubtitlePrefetchRuntime } from './subtitle-prefetch-runtime-composer';
test('composeSubtitlePrefetchRuntime returns subtitle prefetch runtime helpers', () => {
const composed = composeSubtitlePrefetchRuntime({
subtitlePrefetchInitController: {
cancelPendingInit: () => {},
initSubtitlePrefetch: async () => {},
},
refreshSubtitleSidebarFromSource: async () => {},
refreshSubtitlePrefetchFromActiveTrack: async () => {},
scheduleSubtitlePrefetchRefresh: () => {},
clearScheduledSubtitlePrefetchRefresh: () => {},
});
assert.equal(typeof composed.cancelPendingInit, 'function');
assert.equal(typeof composed.initSubtitlePrefetch, 'function');
assert.equal(typeof composed.refreshSubtitleSidebarFromSource, 'function');
assert.equal(typeof composed.refreshSubtitlePrefetchFromActiveTrack, 'function');
assert.equal(typeof composed.scheduleSubtitlePrefetchRefresh, 'function');
assert.equal(typeof composed.clearScheduledSubtitlePrefetchRefresh, 'function');
});

View File

@@ -0,0 +1,32 @@
import type { SubtitlePrefetchInitController } from '../subtitle-prefetch-init';
import type { ComposerInputs, ComposerOutputs } from './contracts';
export type SubtitlePrefetchRuntimeComposerOptions = ComposerInputs<{
subtitlePrefetchInitController: SubtitlePrefetchInitController;
refreshSubtitleSidebarFromSource: (sourcePath: string) => Promise<void>;
refreshSubtitlePrefetchFromActiveTrack: () => Promise<void>;
scheduleSubtitlePrefetchRefresh: (delayMs?: number) => void;
clearScheduledSubtitlePrefetchRefresh: () => void;
}>;
export type SubtitlePrefetchRuntimeComposerResult = ComposerOutputs<{
cancelPendingInit: () => void;
initSubtitlePrefetch: SubtitlePrefetchInitController['initSubtitlePrefetch'];
refreshSubtitleSidebarFromSource: (sourcePath: string) => Promise<void>;
refreshSubtitlePrefetchFromActiveTrack: () => Promise<void>;
scheduleSubtitlePrefetchRefresh: (delayMs?: number) => void;
clearScheduledSubtitlePrefetchRefresh: () => void;
}>;
export function composeSubtitlePrefetchRuntime(
options: SubtitlePrefetchRuntimeComposerOptions,
): SubtitlePrefetchRuntimeComposerResult {
return {
cancelPendingInit: () => options.subtitlePrefetchInitController.cancelPendingInit(),
initSubtitlePrefetch: options.subtitlePrefetchInitController.initSubtitlePrefetch,
refreshSubtitleSidebarFromSource: options.refreshSubtitleSidebarFromSource,
refreshSubtitlePrefetchFromActiveTrack: options.refreshSubtitlePrefetchFromActiveTrack,
scheduleSubtitlePrefetchRefresh: options.scheduleSubtitlePrefetchRefresh,
clearScheduledSubtitlePrefetchRefresh: options.clearScheduledSubtitlePrefetchRefresh,
};
}

View File

@@ -0,0 +1,76 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createDiscordPresenceRuntime } from './discord-presence-runtime';
test('discord presence runtime refreshes duration and publishes the current snapshot', async () => {
const snapshots: Array<Record<string, unknown>> = [];
let mediaDurationSec: number | null = null;
const runtime = createDiscordPresenceRuntime({
getDiscordPresenceService: () => ({
publish: (snapshot: Record<string, unknown>) => {
snapshots.push(snapshot);
},
}),
isDiscordPresenceEnabled: () => true,
getMpvClient: () =>
({
connected: true,
currentTimePos: 12,
requestProperty: async (name: string) => {
assert.equal(name, 'duration');
return 42;
},
}) as never,
getCurrentMediaTitle: () => 'Episode 1',
getCurrentMediaPath: () => '/media/episode-1.mkv',
getCurrentSubtitleText: () => '字幕',
getPlaybackPaused: () => false,
getFallbackMediaDurationSec: () => 90,
getSessionStartedAtMs: () => 1_000,
getMediaDurationSec: () => mediaDurationSec,
setMediaDurationSec: (next) => {
mediaDurationSec = next;
},
});
await runtime.refreshDiscordPresenceMediaDuration();
runtime.publishDiscordPresence();
assert.equal(mediaDurationSec, 42);
assert.deepEqual(snapshots, [
{
mediaTitle: 'Episode 1',
mediaPath: '/media/episode-1.mkv',
subtitleText: '字幕',
currentTimeSec: 12,
mediaDurationSec: 42,
paused: false,
connected: true,
sessionStartedAtMs: 1_000,
},
]);
});
test('discord presence runtime skips publish when disabled or service missing', () => {
let published = false;
const runtime = createDiscordPresenceRuntime({
getDiscordPresenceService: () => null,
isDiscordPresenceEnabled: () => false,
getMpvClient: () => null,
getCurrentMediaTitle: () => null,
getCurrentMediaPath: () => null,
getCurrentSubtitleText: () => '',
getPlaybackPaused: () => null,
getFallbackMediaDurationSec: () => null,
getSessionStartedAtMs: () => 0,
getMediaDurationSec: () => null,
setMediaDurationSec: () => {
published = true;
},
});
runtime.publishDiscordPresence();
assert.equal(published, false);
});

View File

@@ -0,0 +1,74 @@
type DiscordPresenceServiceLike = {
publish: (snapshot: {
mediaTitle: string | null;
mediaPath: string | null;
subtitleText: string;
currentTimeSec: number | null;
mediaDurationSec: number | null;
paused: boolean | null;
connected: boolean;
sessionStartedAtMs: number;
}) => void;
};
type MpvClientLike = {
connected?: boolean;
currentTimePos?: number | null;
requestProperty: (name: string) => Promise<unknown>;
};
export type DiscordPresenceRuntimeDeps = {
getDiscordPresenceService: () => DiscordPresenceServiceLike | null;
isDiscordPresenceEnabled: () => boolean;
getMpvClient: () => MpvClientLike | null;
getCurrentMediaTitle: () => string | null;
getCurrentMediaPath: () => string | null;
getCurrentSubtitleText: () => string;
getPlaybackPaused: () => boolean | null;
getFallbackMediaDurationSec: () => number | null;
getSessionStartedAtMs: () => number;
getMediaDurationSec: () => number | null;
setMediaDurationSec: (durationSec: number | null) => void;
};
export function createDiscordPresenceRuntime(deps: DiscordPresenceRuntimeDeps) {
const refreshDiscordPresenceMediaDuration = async (): Promise<void> => {
const client = deps.getMpvClient();
if (!client?.connected) {
return;
}
try {
const value = await client.requestProperty('duration');
const numeric = Number(value);
deps.setMediaDurationSec(Number.isFinite(numeric) && numeric > 0 ? numeric : null);
} catch {
deps.setMediaDurationSec(null);
}
};
const publishDiscordPresence = (): void => {
const discordPresenceService = deps.getDiscordPresenceService();
if (!discordPresenceService || deps.isDiscordPresenceEnabled() !== true) {
return;
}
void refreshDiscordPresenceMediaDuration();
const client = deps.getMpvClient();
discordPresenceService.publish({
mediaTitle: deps.getCurrentMediaTitle(),
mediaPath: deps.getCurrentMediaPath(),
subtitleText: deps.getCurrentSubtitleText(),
currentTimeSec: client?.currentTimePos ?? null,
mediaDurationSec: deps.getMediaDurationSec() ?? deps.getFallbackMediaDurationSec(),
paused: deps.getPlaybackPaused(),
connected: Boolean(client?.connected),
sessionStartedAtMs: deps.getSessionStartedAtMs(),
});
};
return {
refreshDiscordPresenceMediaDuration,
publishDiscordPresence,
};
}

View File

@@ -0,0 +1,87 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createOverlayModalInputState } from './overlay-modal-input-state';
function createModalWindow() {
const calls: string[] = [];
let destroyed = false;
let focused = false;
let webContentsFocused = false;
return {
calls,
setDestroyed(next: boolean) {
destroyed = next;
},
setFocused(next: boolean) {
focused = next;
},
setWebContentsFocused(next: boolean) {
webContentsFocused = next;
},
isDestroyed: () => destroyed,
setIgnoreMouseEvents: (ignore: boolean) => {
calls.push(`ignore:${ignore}`);
},
setAlwaysOnTop: (flag: boolean, level?: string, relativeLevel?: number) => {
calls.push(`top:${flag}:${level ?? ''}:${relativeLevel ?? ''}`);
},
focus: () => {
focused = true;
calls.push('focus');
},
isFocused: () => focused,
webContents: {
isFocused: () => webContentsFocused,
focus: () => {
webContentsFocused = true;
calls.push('web-focus');
},
},
};
}
test('overlay modal input state activates modal window interactivity and syncs dependents', () => {
const modalWindow = createModalWindow();
const calls: string[] = [];
const state = createOverlayModalInputState({
getModalWindow: () => modalWindow as never,
syncOverlayShortcutsForModal: (isActive) => {
calls.push(`shortcuts:${isActive}`);
},
syncOverlayVisibilityForModal: () => {
calls.push('visibility');
},
});
state.handleModalInputStateChange(true);
assert.equal(state.getModalInputExclusive(), true);
assert.deepEqual(modalWindow.calls, [
'ignore:false',
'top:true:screen-saver:1',
'focus',
'web-focus',
]);
assert.deepEqual(calls, ['shortcuts:true', 'visibility']);
});
test('overlay modal input state is idempotent for unchanged state', () => {
const calls: string[] = [];
const state = createOverlayModalInputState({
getModalWindow: () => null,
syncOverlayShortcutsForModal: (isActive) => {
calls.push(`shortcuts:${isActive}`);
},
syncOverlayVisibilityForModal: () => {
calls.push('visibility');
},
});
state.handleModalInputStateChange(false);
state.handleModalInputStateChange(true);
state.handleModalInputStateChange(true);
assert.equal(state.getModalInputExclusive(), true);
assert.deepEqual(calls, ['shortcuts:true', 'visibility']);
});

View File

@@ -0,0 +1,38 @@
import type { BrowserWindow } from 'electron';
export type OverlayModalInputStateDeps = {
getModalWindow: () => BrowserWindow | null;
syncOverlayShortcutsForModal: (isActive: boolean) => void;
syncOverlayVisibilityForModal: () => void;
};
export function createOverlayModalInputState(deps: OverlayModalInputStateDeps) {
let modalInputExclusive = false;
const handleModalInputStateChange = (isActive: boolean): void => {
if (modalInputExclusive === isActive) {
return;
}
modalInputExclusive = isActive;
if (isActive) {
const modalWindow = deps.getModalWindow();
if (modalWindow && !modalWindow.isDestroyed()) {
modalWindow.setIgnoreMouseEvents(false);
modalWindow.setAlwaysOnTop(true, 'screen-saver', 1);
modalWindow.focus();
if (!modalWindow.webContents.isFocused()) {
modalWindow.webContents.focus();
}
}
}
deps.syncOverlayShortcutsForModal(isActive);
deps.syncOverlayVisibilityForModal();
};
return {
getModalInputExclusive: (): boolean => modalInputExclusive,
handleModalInputStateChange,
};
}

View File

@@ -0,0 +1,59 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createResolveActiveSubtitleSidebarSourceHandler } from './subtitle-prefetch-runtime';
test('subtitle prefetch runtime resolves direct external subtitle sources first', async () => {
const resolveSource = createResolveActiveSubtitleSidebarSourceHandler({
getFfmpegPath: () => 'ffmpeg',
extractInternalSubtitleTrack: async () => {
throw new Error('should not extract external tracks');
},
});
const resolved = await resolveSource({
currentExternalFilenameRaw: ' /tmp/current.ass ',
currentTrackRaw: null,
trackListRaw: null,
sidRaw: null,
videoPath: '/media/video.mkv',
});
assert.deepEqual(resolved, {
path: '/tmp/current.ass',
sourceKey: '/tmp/current.ass',
});
});
test('subtitle prefetch runtime extracts internal subtitle tracks into a stable source key', async () => {
const resolveSource = createResolveActiveSubtitleSidebarSourceHandler({
getFfmpegPath: () => 'ffmpeg-custom',
extractInternalSubtitleTrack: async (ffmpegPath, videoPath, track) => {
assert.equal(ffmpegPath, 'ffmpeg-custom');
assert.equal(videoPath, '/media/video.mkv');
assert.equal((track as Record<string, unknown>)['ff-index'], 7);
return {
path: '/tmp/subminer-sidebar-123/track_7.ass',
cleanup: async () => {},
};
},
});
const resolved = await resolveSource({
currentExternalFilenameRaw: null,
currentTrackRaw: {
type: 'sub',
id: 3,
'ff-index': 7,
codec: 'ass',
},
trackListRaw: [],
sidRaw: 3,
videoPath: '/media/video.mkv',
});
assert.deepEqual(resolved, {
path: '/tmp/subminer-sidebar-123/track_7.ass',
sourceKey: 'internal:/media/video.mkv:track:3:ff:7',
cleanup: resolved?.cleanup,
});
});

View File

@@ -0,0 +1,180 @@
import type { SubtitlePrefetchInitController } from './subtitle-prefetch-init';
import { buildSubtitleSidebarSourceKey } from './subtitle-prefetch-source';
type MpvSubtitleTrackLike = {
type?: unknown;
id?: unknown;
selected?: unknown;
external?: unknown;
codec?: unknown;
'ff-index'?: unknown;
'external-filename'?: unknown;
};
type ActiveSubtitleSidebarSource = {
path: string;
sourceKey: string;
cleanup?: () => Promise<void>;
};
function parseTrackId(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value.trim());
return Number.isInteger(parsed) ? parsed : null;
}
return null;
}
function getActiveSubtitleTrack(
currentTrackRaw: unknown,
trackListRaw: unknown,
sidRaw: unknown,
): MpvSubtitleTrackLike | null {
if (currentTrackRaw && typeof currentTrackRaw === 'object') {
const track = currentTrackRaw as MpvSubtitleTrackLike;
if (track.type === undefined || track.type === 'sub') {
return track;
}
}
const sid = parseTrackId(sidRaw);
if (!Array.isArray(trackListRaw)) {
return null;
}
const bySid =
sid === null
? null
: ((trackListRaw.find((entry: unknown) => {
if (!entry || typeof entry !== 'object') {
return false;
}
const track = entry as MpvSubtitleTrackLike;
return track.type === 'sub' && parseTrackId(track.id) === sid;
}) as MpvSubtitleTrackLike | undefined) ?? null);
if (bySid) {
return bySid;
}
return (
(trackListRaw.find((entry: unknown) => {
if (!entry || typeof entry !== 'object') {
return false;
}
const track = entry as MpvSubtitleTrackLike;
return track.type === 'sub' && track.selected === true;
}) as MpvSubtitleTrackLike | undefined) ?? null
);
}
export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
getFfmpegPath: () => string;
extractInternalSubtitleTrack: (
ffmpegPath: string,
videoPath: string,
track: MpvSubtitleTrackLike,
) => Promise<{ path: string; cleanup: () => Promise<void> } | null>;
}) {
return async (input: {
currentExternalFilenameRaw: unknown;
currentTrackRaw: unknown;
trackListRaw: unknown;
sidRaw: unknown;
videoPath: string;
}): Promise<ActiveSubtitleSidebarSource | null> => {
const currentExternalFilename =
typeof input.currentExternalFilenameRaw === 'string'
? input.currentExternalFilenameRaw.trim()
: '';
if (currentExternalFilename) {
return { path: currentExternalFilename, sourceKey: currentExternalFilename };
}
const track = getActiveSubtitleTrack(input.currentTrackRaw, input.trackListRaw, input.sidRaw);
if (!track) {
return null;
}
const externalFilename =
typeof track['external-filename'] === 'string' ? track['external-filename'].trim() : '';
if (externalFilename) {
return { path: externalFilename, sourceKey: externalFilename };
}
const extracted = await deps.extractInternalSubtitleTrack(
deps.getFfmpegPath(),
input.videoPath,
track,
);
if (!extracted) {
return null;
}
return {
...extracted,
sourceKey: buildSubtitleSidebarSourceKey(input.videoPath, track, extracted.path),
};
};
}
export function createRefreshSubtitlePrefetchFromActiveTrackHandler(deps: {
getMpvClient: () => {
connected?: boolean;
requestProperty: (name: string) => Promise<unknown>;
} | null;
getLastObservedTimePos: () => number;
subtitlePrefetchInitController: SubtitlePrefetchInitController;
resolveActiveSubtitleSidebarSource: (
input: Parameters<ReturnType<typeof createResolveActiveSubtitleSidebarSourceHandler>>[0],
) => Promise<ActiveSubtitleSidebarSource | null>;
}) {
return async (): Promise<void> => {
const client = deps.getMpvClient();
if (!client?.connected) {
return;
}
try {
const [currentExternalFilenameRaw, currentTrackRaw, trackListRaw, sidRaw, videoPathRaw] =
await Promise.all([
client.requestProperty('current-tracks/sub/external-filename').catch(() => null),
client.requestProperty('current-tracks/sub').catch(() => null),
client.requestProperty('track-list'),
client.requestProperty('sid'),
client.requestProperty('path'),
]);
const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : '';
if (!videoPath) {
deps.subtitlePrefetchInitController.cancelPendingInit();
return;
}
const resolvedSource = await deps.resolveActiveSubtitleSidebarSource({
currentExternalFilenameRaw,
currentTrackRaw,
trackListRaw,
sidRaw,
videoPath,
});
if (!resolvedSource) {
deps.subtitlePrefetchInitController.cancelPendingInit();
return;
}
try {
await deps.subtitlePrefetchInitController.initSubtitlePrefetch(
resolvedSource.path,
deps.getLastObservedTimePos(),
resolvedSource.sourceKey,
);
} finally {
await resolvedSource.cleanup?.();
}
} catch {
// Skip refresh when the track query fails.
}
};
}

View File

@@ -24,7 +24,7 @@ export interface WindowsMpvShortcutInstallResult {
} }
export function resolveWindowsStartMenuProgramsDir(appDataDir: string): string { export function resolveWindowsStartMenuProgramsDir(appDataDir: string): string {
return path.join(appDataDir, 'Microsoft', 'Windows', 'Start Menu', 'Programs'); return path.win32.join(appDataDir, 'Microsoft', 'Windows', 'Start Menu', 'Programs');
} }
export function resolveWindowsMpvShortcutPaths(options: { export function resolveWindowsMpvShortcutPaths(options: {
@@ -32,11 +32,11 @@ export function resolveWindowsMpvShortcutPaths(options: {
desktopDir: string; desktopDir: string;
}): WindowsMpvShortcutPaths { }): WindowsMpvShortcutPaths {
return { return {
startMenuPath: path.join( startMenuPath: path.win32.join(
resolveWindowsStartMenuProgramsDir(options.appDataDir), resolveWindowsStartMenuProgramsDir(options.appDataDir),
WINDOWS_MPV_SHORTCUT_NAME, WINDOWS_MPV_SHORTCUT_NAME,
), ),
desktopPath: path.join(options.desktopDir, WINDOWS_MPV_SHORTCUT_NAME), desktopPath: path.win32.join(options.desktopDir, WINDOWS_MPV_SHORTCUT_NAME),
}; };
} }
@@ -54,7 +54,7 @@ export function buildWindowsMpvShortcutDetails(exePath: string): WindowsShortcut
return { return {
target: exePath, target: exePath,
args: '--launch-mpv', args: '--launch-mpv',
cwd: path.dirname(exePath), cwd: path.win32.dirname(exePath),
description: 'Launch mpv with the SubMiner profile', description: 'Launch mpv with the SubMiner profile',
icon: exePath, icon: exePath,
iconIndex: 0, iconIndex: 0,
@@ -79,7 +79,7 @@ export function applyWindowsMpvShortcuts(options: {
const failures: string[] = []; const failures: string[] = [];
const ensureShortcut = (shortcutPath: string): void => { const ensureShortcut = (shortcutPath: string): void => {
mkdirSync(path.dirname(shortcutPath), { recursive: true }); mkdirSync(path.win32.dirname(shortcutPath), { recursive: true });
const ok = options.writeShortcutLink(shortcutPath, 'replace', details); const ok = options.writeShortcutLink(shortcutPath, 'replace', details);
if (!ok) { if (!ok) {
failures.push(shortcutPath); failures.push(shortcutPath);

View File

@@ -0,0 +1,80 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createYoutubePlaybackRuntime } from './youtube-playback-runtime';
test('youtube playback runtime resets flow ownership after a successful run', async () => {
const calls: string[] = [];
let appOwnedFlowInFlight = false;
let timeoutCallback: (() => void) | null = null;
const runtime = createYoutubePlaybackRuntime({
platform: 'linux',
directPlaybackFormat: 'best',
mpvYtdlFormat: 'bestvideo+bestaudio',
autoLaunchTimeoutMs: 2_000,
connectTimeoutMs: 1_000,
socketPath: '/tmp/mpv.sock',
getMpvConnected: () => true,
invalidatePendingAutoplayReadyFallbacks: () => {
calls.push('invalidate-autoplay');
},
setAppOwnedFlowInFlight: (next) => {
appOwnedFlowInFlight = next;
calls.push(`app-owned:${next}`);
},
ensureYoutubePlaybackRuntimeReady: async () => {
calls.push('ensure-runtime-ready');
},
resolveYoutubePlaybackUrl: async () => {
throw new Error('linux path should not resolve direct playback url');
},
launchWindowsMpv: () => ({ ok: false }),
waitForYoutubeMpvConnected: async (timeoutMs) => {
calls.push(`wait-connected:${timeoutMs}`);
return true;
},
prepareYoutubePlaybackInMpv: async ({ url }) => {
calls.push(`prepare:${url}`);
return true;
},
runYoutubePlaybackFlow: async ({ url, mode }) => {
calls.push(`run-flow:${url}:${mode}`);
},
logInfo: (message) => {
calls.push(`info:${message}`);
},
logWarn: (message) => {
calls.push(`warn:${message}`);
},
schedule: (callback) => {
timeoutCallback = callback;
calls.push('schedule-arm');
return 1 as never;
},
clearScheduled: () => {
calls.push('clear-scheduled');
},
});
await runtime.runYoutubePlaybackFlow({
url: 'https://youtu.be/demo',
mode: 'download',
source: 'initial',
});
assert.equal(appOwnedFlowInFlight, false);
assert.equal(runtime.getQuitOnDisconnectArmed(), false);
assert.deepEqual(calls.slice(0, 6), [
'invalidate-autoplay',
'app-owned:true',
'ensure-runtime-ready',
'wait-connected:1000',
'schedule-arm',
'prepare:https://youtu.be/demo',
]);
assert.ok(timeoutCallback);
const scheduledCallback = timeoutCallback as () => void;
scheduledCallback();
assert.equal(runtime.getQuitOnDisconnectArmed(), true);
});

View File

@@ -0,0 +1,149 @@
import type { CliArgs, CliCommandSource } from '../../cli/args';
type LaunchResult = {
ok: boolean;
mpvPath?: string;
};
export type YoutubePlaybackRuntimeDeps = {
platform: NodeJS.Platform;
directPlaybackFormat: string;
mpvYtdlFormat: string;
autoLaunchTimeoutMs: number;
connectTimeoutMs: number;
socketPath: string;
getMpvConnected: () => boolean;
invalidatePendingAutoplayReadyFallbacks: () => void;
setAppOwnedFlowInFlight: (next: boolean) => void;
ensureYoutubePlaybackRuntimeReady: () => Promise<void>;
resolveYoutubePlaybackUrl: (url: string, format: string) => Promise<string>;
launchWindowsMpv: (playbackUrl: string, args: string[]) => LaunchResult;
waitForYoutubeMpvConnected: (timeoutMs: number) => Promise<boolean>;
prepareYoutubePlaybackInMpv: (request: { url: string }) => Promise<boolean>;
runYoutubePlaybackFlow: (request: {
url: string;
mode: NonNullable<CliArgs['youtubeMode']>;
}) => Promise<void>;
logInfo: (message: string) => void;
logWarn: (message: string) => void;
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
clearScheduled: (timer: ReturnType<typeof setTimeout>) => void;
};
export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
let quitOnDisconnectArmed = false;
let quitOnDisconnectArmTimer: ReturnType<typeof setTimeout> | null = null;
let playbackFlowGeneration = 0;
const clearYoutubePlayQuitOnDisconnectArmTimer = (): void => {
if (quitOnDisconnectArmTimer) {
deps.clearScheduled(quitOnDisconnectArmTimer);
quitOnDisconnectArmTimer = null;
}
};
const runYoutubePlaybackFlow = async (request: {
url: string;
mode: NonNullable<CliArgs['youtubeMode']>;
source: CliCommandSource;
}): Promise<void> => {
const flowGeneration = ++playbackFlowGeneration;
deps.invalidatePendingAutoplayReadyFallbacks();
deps.setAppOwnedFlowInFlight(true);
let flowCompleted = false;
try {
clearYoutubePlayQuitOnDisconnectArmTimer();
quitOnDisconnectArmed = false;
await deps.ensureYoutubePlaybackRuntimeReady();
let playbackUrl = request.url;
let launchedWindowsMpv = false;
if (deps.platform === 'win32') {
try {
playbackUrl = await deps.resolveYoutubePlaybackUrl(
request.url,
deps.directPlaybackFormat,
);
deps.logInfo('Resolved direct YouTube playback URL for Windows MPV startup.');
} catch (error) {
deps.logWarn(
`Failed to resolve direct YouTube playback URL; falling back to page URL: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
if (deps.platform === 'win32' && !deps.getMpvConnected()) {
const launchResult = deps.launchWindowsMpv(playbackUrl, [
'--pause=yes',
'--ytdl=yes',
`--ytdl-format=${deps.mpvYtdlFormat}`,
'--sub-auto=no',
'--sub-file-paths=.;subs;subtitles',
'--sid=auto',
'--secondary-sid=auto',
'--secondary-sub-visibility=no',
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
`--input-ipc-server=${deps.socketPath}`,
]);
launchedWindowsMpv = launchResult.ok;
if (launchResult.ok && launchResult.mpvPath) {
deps.logInfo(`Bootstrapping Windows mpv for YouTube playback via ${launchResult.mpvPath}`);
}
if (!launchResult.ok) {
deps.logWarn('Unable to bootstrap Windows mpv for YouTube playback.');
}
}
const connected = await deps.waitForYoutubeMpvConnected(
launchedWindowsMpv ? deps.autoLaunchTimeoutMs : deps.connectTimeoutMs,
);
if (!connected) {
throw new Error(
launchedWindowsMpv
? 'MPV not connected after auto-launch. Ensure mpv is installed and can open the requested YouTube URL.'
: 'MPV not connected. Start mpv with the SubMiner profile or retry after mpv finishes starting.',
);
}
if (request.source === 'initial') {
quitOnDisconnectArmTimer = deps.schedule(() => {
if (playbackFlowGeneration !== flowGeneration) {
return;
}
quitOnDisconnectArmed = true;
quitOnDisconnectArmTimer = null;
}, 3000);
}
const mediaReady = await deps.prepareYoutubePlaybackInMpv({ url: playbackUrl });
if (!mediaReady) {
throw new Error('Timed out waiting for mpv to load the requested YouTube URL.');
}
await deps.runYoutubePlaybackFlow({
url: request.url,
mode: request.mode,
});
flowCompleted = true;
deps.logInfo(`YouTube playback flow completed from ${request.source}.`);
} finally {
if (playbackFlowGeneration === flowGeneration) {
if (!flowCompleted) {
clearYoutubePlayQuitOnDisconnectArmTimer();
quitOnDisconnectArmed = false;
}
deps.setAppOwnedFlowInFlight(false);
}
}
};
return {
clearYoutubePlayQuitOnDisconnectArmTimer,
getQuitOnDisconnectArmed: (): boolean => quitOnDisconnectArmed,
runYoutubePlaybackFlow,
};
}

View File

@@ -36,6 +36,13 @@ test('release workflow verifies generated config examples before packaging artif
assert.match(releaseWorkflow, /bun run verify:config-example/); assert.match(releaseWorkflow, /bun run verify:config-example/);
}); });
test('release quality gate runs the maintained source coverage lane and uploads lcov output', () => {
assert.match(releaseWorkflow, /name: Coverage suite \(maintained source lane\)/);
assert.match(releaseWorkflow, /run: bun run test:coverage:src/);
assert.match(releaseWorkflow, /name: Upload coverage artifact/);
assert.match(releaseWorkflow, /path: coverage\/test-src\/lcov\.info/);
});
test('release build jobs install and cache stats dependencies before packaging', () => { test('release build jobs install and cache stats dependencies before packaging', () => {
assert.match(releaseWorkflow, /build-linux:[\s\S]*stats\/node_modules/); assert.match(releaseWorkflow, /build-linux:[\s\S]*stats\/node_modules/);
assert.match(releaseWorkflow, /build-macos:[\s\S]*stats\/node_modules/); assert.match(releaseWorkflow, /build-macos:[\s\S]*stats\/node_modules/);

View File

@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { RuntimeOptionsManager } from './runtime-options';
test('SM-012 runtime options path does not use JSON serialize-clone helpers', () => {
const source = fs.readFileSync(path.join(process.cwd(), 'src/runtime-options.ts'), 'utf-8');
assert.equal(source.includes('JSON.parse(JSON.stringify('), false);
});
test('RuntimeOptionsManager returns detached effective Anki config copies', () => {
const baseConfig = {
deck: 'Mining',
note: 'Sentence',
tags: ['SubMiner'],
behavior: {
autoUpdateNewCards: true,
updateIntervalMs: 5000,
},
fieldMapping: {
sentence: 'Sentence',
meaning: 'Meaning',
audio: 'Audio',
image: 'Image',
context: 'Context',
source: 'Source',
definition: 'Definition',
sequence: 'Sequence',
contextSecondary: 'ContextSecondary',
contextTertiary: 'ContextTertiary',
primarySpelling: 'PrimarySpelling',
primaryReading: 'PrimaryReading',
wordSpelling: 'WordSpelling',
wordReading: 'WordReading',
},
duplicates: {
mode: 'note' as const,
scope: 'deck' as const,
allowedFields: [],
},
ai: {
enabled: false,
model: '',
systemPrompt: '',
},
};
const manager = new RuntimeOptionsManager(
() => structuredClone(baseConfig),
{
applyAnkiPatch: () => undefined,
onOptionsChanged: () => undefined,
},
);
const effective = manager.getEffectiveAnkiConnectConfig();
effective.tags!.push('mutated');
effective.behavior!.autoUpdateNewCards = false;
assert.deepEqual(baseConfig.tags, ['SubMiner']);
assert.equal(baseConfig.behavior.autoUpdateNewCards, true);
});

View File

@@ -29,7 +29,7 @@ import { RUNTIME_OPTION_REGISTRY, RuntimeOptionRegistryEntry } from './config';
type RuntimeOverrides = Record<string, unknown>; type RuntimeOverrides = Record<string, unknown>;
function deepClone<T>(value: T): T { function deepClone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T; return structuredClone(value);
} }
function getPathValue(source: Record<string, unknown>, path: string): unknown { function getPathValue(source: Record<string, unknown>, path: string): unknown {