mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 16:46:06 -08:00
Compare commits
4
Commits
v0.20.1
...
78a9b6346f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78a9b6346f | ||
|
|
78eca52b7e | ||
|
|
b48083ddd8 | ||
|
|
4b6842d034 |
No files matched your search
@@ -27,6 +27,7 @@ Do not use hidden wrapper commands. Verification commands are owned by `package.
|
||||
- Launcher or mpv plugin: `bun run test:launcher` or `bun run test:env`, based on the behavior changed
|
||||
- Runtime compatibility or dist-sensitive wiring: `bun run test:runtime:compat`
|
||||
- Stats dashboard: `bun run test:stats`
|
||||
- Overlay, renderer, mpv, or mining behavior in the running app: `bun run build`, then `bun run test:e2e`
|
||||
- Build/release scripts: `bun run test:scripts`
|
||||
|
||||
For substantial changes, use the full gate documented in `AGENTS.md` and `docs/workflow/verification.md`.
|
||||
|
||||
@@ -9,3 +9,79 @@ on:
|
||||
jobs:
|
||||
build-test-audit:
|
||||
uses: ./.github/workflows/quality-gate.yml
|
||||
|
||||
# Boots the built app with a real mpv on each platform. Kept outside the
|
||||
# reusable quality gate so releases do not depend on it while the lane is new.
|
||||
e2e:
|
||||
name: E2E (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
env:
|
||||
SUBMINER_E2E_KEEP: '1'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.5
|
||||
|
||||
- name: Point temp files at an uploadable directory
|
||||
run: |
|
||||
# os.tmpdir() follows these, so the kept session logs land under runner.temp.
|
||||
dir="$RUNNER_TEMP/e2e"
|
||||
mkdir -p "$dir"
|
||||
{ echo "TMPDIR=$dir"; echo "TEMP=$dir"; echo "TMP=$dir"; } >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install mpv and ffmpeg (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
# Only Ubuntu sources are needed; unrelated runner repositories can be unavailable.
|
||||
apt_sources=(-o Dir::Etc::sourcelist=sources.list.d/ubuntu.sources -o Dir::Etc::sourceparts=-)
|
||||
sudo apt-get "${apt_sources[@]}" update
|
||||
sudo apt-get "${apt_sources[@]}" install -y mpv ffmpeg xvfb
|
||||
|
||||
- name: Install mpv and ffmpeg (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
run: brew install mpv ffmpeg
|
||||
|
||||
- name: Install mpv and ffmpeg (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
run: |
|
||||
choco install mpv ffmpeg -y --no-progress
|
||||
# The mpv package adds no shim, so put its directory on PATH for later steps.
|
||||
mpv_exe="$(find /c/ProgramData/chocolatey/lib /c/ProgramData/chocolatey/bin "/c/Program Files" /c/tools -maxdepth 6 -iname mpv.exe 2>/dev/null | head -n 1)"
|
||||
test -n "$mpv_exe"
|
||||
cygpath -w "$(dirname "$mpv_exe")" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
cd stats && bun install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: bun run build
|
||||
|
||||
- name: End-to-end suite
|
||||
run: bun run test:e2e
|
||||
|
||||
- name: Upload session logs (on failure)
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2e-logs-${{ matrix.os }}
|
||||
path: ${{ runner.temp }}/e2e/subminer-e2e-*/*.log
|
||||
if-no-files-found: ignore
|
||||
@@ -8,6 +8,7 @@ Start here, then leave this file.
|
||||
- Architecture map: [`docs/architecture/README.md`](./docs/architecture/README.md)
|
||||
- Workflow map: [`docs/workflow/README.md`](./docs/workflow/README.md)
|
||||
- Verification lanes: [`docs/workflow/verification.md`](./docs/workflow/verification.md)
|
||||
- Testing guidelines: [`docs/workflow/testing.md`](./docs/workflow/testing.md)
|
||||
- Knowledge-base rules: [`docs/knowledge-base/README.md`](./docs/knowledge-base/README.md)
|
||||
- Release guide: [`docs/RELEASING.md`](./docs/RELEASING.md)
|
||||
|
||||
@@ -44,10 +45,11 @@ Start here, then leave this file.
|
||||
- Launcher/plugin: `bun run test:launcher` or `bun run test:env`
|
||||
- Runtime-compat / dist-sensitive: `bun run test:runtime:compat`
|
||||
- Stats dashboard UI (`stats/`): `bun run test:stats`
|
||||
- Overlay / renderer / mpv / mining behavior in the running app: `bun run build`, then `bun run test:e2e` (headless, sandboxed; `bun run e2e start` for exploratory checks)
|
||||
- Build/release scripts (`scripts/**`): `bun run test:scripts`
|
||||
- Internal docs, `AGENTS.md`, or repo skills: `bun run test:docs:kb`
|
||||
- User-facing `docs-site/`: `bun run docs:test`, then `bun run docs:build`
|
||||
- macOS mpv window helper: `bun test scripts/get-mpv-window-macos.test.ts`
|
||||
- macOS mpv window helper (`scripts/get-mpv-window-macos.swift`): compile it on macOS with `bun run build`
|
||||
- Test lanes are directory-discovered via `scripts/test-lanes.ts`; never hand-list test files in `package.json`
|
||||
|
||||
## Docs Upkeep
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
type: internal
|
||||
area: testing
|
||||
|
||||
- Added a headless end-to-end harness (`bun run test:e2e`, `bun run e2e`) that boots the built app with a real mpv, a synthetic clip, a fixture Yomitan dictionary, and a fake AnkiConnect on an offscreen display.
|
||||
@@ -0,0 +1,7 @@
|
||||
type: fixed
|
||||
area: jellyfin
|
||||
|
||||
- Fixed `subminer jellyfin -p` reporting "No Jellyfin libraries found." when SubMiner was already running (for example right after `subminer jellyfin` login). The app now hands library, item, and preview-auth results to the launcher directly instead of through log files, so picker image previews work again and real errors (expired session, unreachable server) are shown instead of an empty list.
|
||||
- The rofi "Jellyfin Search (optional)" step after picking a library is now a compact input box with a "Type to search, or press Enter to browse all" hint instead of a blank list.
|
||||
- Playing from the terminal (`subminer jellyfin -p`) now reports watch progress to Jellyfin even when cast discovery is off, so Jellyfin remembers where you stopped and marks finished episodes as played. Previously progress was only sent while the cast connection was active.
|
||||
- When `subminer jellyfin -p` has to start SubMiner itself, the app now shows its tray icon and quits when you close mpv, the same as playing a local file. Before, it had no tray icon and kept running hidden after playback. A SubMiner that was already running is still reused and left running.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: docs
|
||||
area: jellyfin
|
||||
|
||||
- Corrected the Jellyfin docs: SubMiner only joins the cast menu at startup when `startupWarmups.jellyfinRemoteSession` is on (off by default) along with `jellyfin.remoteControlAutoConnect`. Otherwise tick **Jellyfin Discovery** in the tray.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: setup
|
||||
|
||||
- First-run setup now labels a missing command-line launcher "Not set up" instead of "Not installed", so the AppImage catalog test no longer reads the setup window as an error screen.
|
||||
+12
-12
@@ -514,18 +514,18 @@ In external-profile mode, SubMiner only reads the profile. It does not open its
|
||||
|
||||
Log in to a Jellyfin server, browse libraries, and play or cast to SubMiner. Login tokens are stored encrypted, not in this file. See [Jellyfin integration](/jellyfin-integration).
|
||||
|
||||
| Key | Default | What it does |
|
||||
| ----------------------------------- | -------------------------------- | ----------------------------------------------- |
|
||||
| `jellyfin.enabled` | `false` | Enable Jellyfin |
|
||||
| `jellyfin.serverUrl` | `""` | Server URL, for example `http://localhost:8096` |
|
||||
| `jellyfin.username` | `""` | Default username for `subminer jellyfin -l` |
|
||||
| `jellyfin.remoteControlEnabled` | `true` | Let Jellyfin apps cast to SubMiner |
|
||||
| `jellyfin.remoteControlAutoConnect` | `true` | Connect the cast session on startup |
|
||||
| `jellyfin.autoAnnounce` | `false` | Announce SubMiner as a cast target on connect |
|
||||
| `jellyfin.pullPictures` | `false` | Fetch posters for launcher pickers |
|
||||
| `jellyfin.iconCacheDir` | `"/tmp/subminer-jellyfin-icons"` | Poster cache folder |
|
||||
| `jellyfin.directPlayPreferred` | `true` | Try direct play before transcoding |
|
||||
| `jellyfin.transcodeVideoCodec` | `"h264"` | Codec requested when transcoding |
|
||||
| Key | Default | What it does |
|
||||
| ----------------------------------- | -------------------------------- | --------------------------------------------------------------------------------------- |
|
||||
| `jellyfin.enabled` | `false` | Enable Jellyfin |
|
||||
| `jellyfin.serverUrl` | `""` | Server URL, for example `http://localhost:8096` |
|
||||
| `jellyfin.username` | `""` | Default username for `subminer jellyfin -l` |
|
||||
| `jellyfin.remoteControlEnabled` | `true` | Let Jellyfin apps cast to SubMiner |
|
||||
| `jellyfin.remoteControlAutoConnect` | `true` | Connect the cast session on startup (also needs `startupWarmups.jellyfinRemoteSession`) |
|
||||
| `jellyfin.autoAnnounce` | `false` | Announce SubMiner as a cast target on connect |
|
||||
| `jellyfin.pullPictures` | `false` | Fetch posters for launcher pickers |
|
||||
| `jellyfin.iconCacheDir` | `"/tmp/subminer-jellyfin-icons"` | Poster cache folder |
|
||||
| `jellyfin.directPlayPreferred` | `true` | Try direct play before transcoding |
|
||||
| `jellyfin.transcodeVideoCodec` | `"h264"` | Codec requested when transcoding |
|
||||
|
||||
### Discord rich presence
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ SubMiner stores an encrypted session token, not your password, and turns the int
|
||||
|
||||
## Casting from Jellyfin
|
||||
|
||||
After you sign in, SubMiner connects to Jellyfin at startup and shows up in the cast ("Play on") menu under your computer's hostname. To connect for the current session only, tick **Jellyfin Discovery** in the tray menu.
|
||||
After you sign in, tick **Jellyfin Discovery** in the tray menu. SubMiner then shows up in the cast ("Play on") menu under your computer's hostname until it quits. To connect every time SubMiner starts, turn on `startupWarmups.jellyfinRemoteSession` and leave `remoteControlAutoConnect` on.
|
||||
|
||||
1. In the Jellyfin web or mobile app, start playing an episode.
|
||||
2. Open the cast menu and pick your computer.
|
||||
@@ -34,20 +34,20 @@ subminer jellyfin -p # fzf picker; `jf` is an alias for `jellyfin`
|
||||
subminer -R jellyfin -p # rofi picker
|
||||
```
|
||||
|
||||
Sign in first. See [Launcher script](/launcher-script) for the other `jellyfin` subcommands.
|
||||
Sign in first. Playback resumes from Jellyfin's saved position, and watch progress syncs back the same way it does for casting, whether or not discovery is on. If SubMiner is already running, the picker uses it and leaves it running. Otherwise the launcher starts SubMiner and quits it when you close mpv, the same as playing a local file. See [Launcher script](/launcher-script) for the other `jellyfin` subcommands.
|
||||
|
||||
## Options
|
||||
|
||||
All options are under **Settings > Integrations > Jellyfin**, or `jellyfin` in `config.jsonc`. See [Configuration](/configuration#jellyfin) for the full list and defaults.
|
||||
|
||||
| Key | What it does |
|
||||
| -------------------------- | -------------------------------------------------------------------- |
|
||||
| `enabled` | Turns the integration on. Set for you when you sign in. |
|
||||
| `serverUrl` | Your Jellyfin server. Filled in when you sign in. |
|
||||
| `remoteControlEnabled` | Lets SubMiner act as a cast target. |
|
||||
| `remoteControlAutoConnect` | Connects at startup. Turn off to start discovery from the tray. |
|
||||
| `autoAnnounce` | Re-announces the device on connect. Try it if SubMiner appears late. |
|
||||
| `transcodeVideoCodec` | Video codec requested when Jellyfin transcodes. |
|
||||
| Key | What it does |
|
||||
| -------------------------- | ---------------------------------------------------------------------- |
|
||||
| `enabled` | Turns the integration on. Set for you when you sign in. |
|
||||
| `serverUrl` | Your Jellyfin server. Filled in when you sign in. |
|
||||
| `remoteControlEnabled` | Lets SubMiner act as a cast target. |
|
||||
| `remoteControlAutoConnect` | Connects at startup when `startupWarmups.jellyfinRemoteSession` is on. |
|
||||
| `autoAnnounce` | Re-announces the device on connect. Try it if SubMiner appears late. |
|
||||
| `transcodeVideoCodec` | Video codec requested when Jellyfin transcodes. |
|
||||
|
||||
For headless setups, `SUBMINER_JELLYFIN_ACCESS_TOKEN` and `SUBMINER_JELLYFIN_USER_ID` supply a session without the sign-in window. Treat the token store and `config.jsonc` as secrets.
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ Read when: finding internal docs or checking verification status
|
||||
| Planning guide | `docs/workflow/planning.md` | active | 2026-05-23 | lightweight vs execution plans |
|
||||
| Agent skills | `docs/workflow/agent-skills.md` | active | 2026-08-23 | repo-local workflow skill ownership |
|
||||
| Verification guide | `docs/workflow/verification.md` | active | 2026-08-13 | maintained verification lanes |
|
||||
| Testing guidelines | `docs/workflow/testing.md` | active | 2026-10-04 | test style and anti-patterns |
|
||||
| Release guide | `docs/RELEASING.md` | active | 2026-05-23 | release checklist |
|
||||
|
||||
## Update Rules
|
||||
|
||||
@@ -13,6 +13,7 @@ This section is the internal workflow map for contributors and agents.
|
||||
|
||||
- [Planning](./planning.md) - when to write a lightweight plan vs a full execution plan
|
||||
- [Verification](./verification.md) - maintained test/build lanes and handoff gate
|
||||
- [Testing](./testing.md) - what a good test looks like and what not to write
|
||||
- [Agent Skills](./agent-skills.md) - repo-local workflow skill ownership
|
||||
- [Release Guide](../RELEASING.md) - tagged release workflow
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<!-- read_when: writing, reviewing, or deleting tests -->
|
||||
|
||||
# Testing Guidelines
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-10-04
|
||||
Owner: Kyle Yasuda
|
||||
Read when: adding or changing tests
|
||||
|
||||
Which lanes to run lives in [Verification](./verification.md). This page covers what a
|
||||
good test looks like here.
|
||||
|
||||
## Test Behavior, Not Wiring
|
||||
|
||||
- Exercise a module through its public interface and assert outcomes: return values,
|
||||
resulting state, files written, rows in sqlite, messages sent to a fake process.
|
||||
- Prefer real collaborators that are cheap: tmp dirs, in-memory or tmp sqlite, fake
|
||||
executables, pure parsers. Fake only the edges (Electron, mpv socket, network).
|
||||
- Assert call order only when order is the behavior (startup sequencing, ABA races),
|
||||
and then assert relative order, not the whole call log.
|
||||
|
||||
## Do Not Write
|
||||
|
||||
- Pass-through tests: a builder or handler that only forwards deps (`*-main-deps.ts`,
|
||||
`*-runtime-handlers.ts`) needs no test. TypeScript already checks the shape.
|
||||
- Source-text tests: reading `.ts`, `.css`, `.html`, `.lua`, `.swift`, or workflow YAML
|
||||
and asserting with regexes. If the invariant matters, extract the logic and run it
|
||||
(see `src/preload-ipc-listeners.ts`). Policy lints over workflow YAML are the
|
||||
exception and belong in one parsed-YAML check.
|
||||
- "Stays deleted" tests for removed modules, flags, or selectors. `tsc` and review
|
||||
catch reintroduction.
|
||||
- Tests that restate constants, defaults, labels, or window options.
|
||||
- The same rule at several layers. Test a rule once where it lives; keep one
|
||||
end-to-end case (for the tokenizer, the golden corpus) for integration.
|
||||
|
||||
## Keep Tests Small
|
||||
|
||||
- Shared setup goes in a `makeDeps(overrides)` helper or a `*-test-harness.ts` file, not
|
||||
pasted into every test.
|
||||
- Near-identical cases become a table: `for (const c of cases) test(c.name, ...)`.
|
||||
- Inject timers, clocks, and loggers instead of patching globals or private members.
|
||||
- A bug fix gets a regression test for the behavior that broke, named after the
|
||||
behavior, not the ticket.
|
||||
|
||||
## Known Debt
|
||||
|
||||
- `src/main/main-wiring.test.ts` and `src/renderer/renderer-init-order.test.ts` still
|
||||
regex `main.ts` / `renderer.ts` to guard startup ordering. Replace each test with a
|
||||
behavioral one as the logic moves out of `main.ts` closures.
|
||||
@@ -3,7 +3,7 @@
|
||||
# Verification
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-08-13
|
||||
Last verified: 2026-10-05
|
||||
Owner: Kyle Yasuda
|
||||
Read when: selecting the right verification lane for a change
|
||||
|
||||
@@ -14,8 +14,9 @@ Read when: selecting the right verification lane for a change
|
||||
files in `package.json`.
|
||||
- `scripts/run-test-lane.mjs` runs each test file in its own `bun test` process
|
||||
(per-file isolation with a wall timeout) so a hanging test or leaked global in
|
||||
one file cannot cascade into the rest of the lane. `--jobs N` parallelizes;
|
||||
`--single-process` restores the shared-process mode for debugging.
|
||||
one file cannot cascade into the rest of the lane. Files run in parallel, one
|
||||
worker per CPU by default; `--jobs 1` runs serially and `--single-process`
|
||||
restores the shared-process mode for debugging.
|
||||
- `bun run test:fast` is the full source gate: discovered `src/**`, launcher
|
||||
unit, and `scripts/**`.
|
||||
- `.github/workflows/quality-gate.yml` is the reusable `workflow_call` gate for
|
||||
@@ -57,6 +58,8 @@ bun run docs:build
|
||||
- Launcher/plugin: `bun run test:launcher` or `bun run test:env`
|
||||
- Runtime-compat / compiled behavior after `bun run build`: `bun run test:runtime:compat`
|
||||
- Stats dashboard UI: `bun run test:stats`
|
||||
- Overlay, renderer, mpv, or mining behavior that unit tests cannot show: `bun run build`,
|
||||
then `bun run test:e2e` (see [Headless End-to-End Harness](#headless-end-to-end-harness))
|
||||
- Build/release scripts (`scripts/**`): `bun run test:scripts`
|
||||
- Packaging: build the platform package, then run `bun run test:package <resources-directory>`.
|
||||
On headless Linux: `xvfb-run -a bun run test:package release/linux-unpacked/resources`.
|
||||
@@ -70,7 +73,7 @@ bun run docs:build
|
||||
|
||||
- `bun run test:coverage:src` runs the same discovered `bun-src-full` membership as
|
||||
`test:src` through a sharded coverage runner: one Bun coverage process per test
|
||||
file, then merged LCOV output.
|
||||
file (parallel, one per CPU unless `--jobs N`), then merged LCOV output.
|
||||
- A failing coverage shard stops the runner with a nonzero status. Coverage is a
|
||||
source test gate, not a report-only step.
|
||||
- Machine-readable output lands at `coverage/test-src/lcov.info`.
|
||||
@@ -92,6 +95,54 @@ bun run docs:build
|
||||
- This is not a full Electron UI startup check. It does not require a display and
|
||||
makes no claims about renderer, window, tray, or mpv behavior.
|
||||
|
||||
## Headless End-to-End Harness
|
||||
|
||||
`bun run test:e2e` boots the built app against a real mpv and asserts on what the
|
||||
overlay renders and what reaches AnkiConnect. Use it instead of a live desktop test
|
||||
whenever the behavior does not depend on a specific compositor.
|
||||
|
||||
- Needs an existing `bun run build`, plus `mpv` and `ffmpeg` on `PATH` (and `Xvfb` on
|
||||
Linux). It is not part of `test:fast` or the handoff gate. The `e2e` job in
|
||||
`.github/workflows/ci.yml` runs it on Linux, macOS, and Windows for pull requests and
|
||||
`main`, outside the reusable quality gate, and uploads the session logs when it fails.
|
||||
- Every run is sandboxed in a temp dir: its own config and Electron profile, mpv IPC
|
||||
socket, and a fake AnkiConnect on a free port. It never touches the user's running
|
||||
SubMiner, Anki, or stats database. It is not offline: the app still asks AniList for
|
||||
cover art matching the clip's title.
|
||||
- Media is synthetic: an ffmpeg test-pattern clip, a three-cue Japanese `.srt`, and a
|
||||
tiny Yomitan dictionary covering those cues (`e2e/harness/fixtures.ts`). The harness
|
||||
imports the dictionary on a first boot, then restarts the app for the scenario.
|
||||
- Display: Linux runs everything inside a private Xvfb server, so no window reaches the
|
||||
desktop. macOS and Windows have no offscreen display server, so windows open in the
|
||||
current session there; `SUBMINER_E2E_DISPLAY=host` forces the same on Linux. The
|
||||
CI job runs all three. On macOS and Windows the app pre-creates a hidden modal window
|
||||
that loads the same renderer document, so window matching must include the
|
||||
`layer=visible` query (`OVERLAY_PAGE`).
|
||||
- The app is driven over the Chrome DevTools Protocol (DOM queries, input events,
|
||||
per-window screenshots), mpv over its JSON IPC socket, and app commands through the
|
||||
normal CLI handoff (`--mine-sentence`, `--toggle-subtitle-sidebar`, ...).
|
||||
- Scenarios live in `e2e/*.e2e.test.ts` and share one session per file through
|
||||
`startE2eSession()`. Pass `config` to override settings for a scenario file.
|
||||
- On a failed startup the temp dir is kept and its path is in the error. Set
|
||||
`SUBMINER_E2E_KEEP=1` to keep it after any run (`app-stdio.log`, `app.log`, `mpv.log`).
|
||||
|
||||
For exploratory checks, keep one session up and poke at it:
|
||||
|
||||
```bash
|
||||
bun run e2e start & # prints the session JSON, stays up until stopped
|
||||
bun run e2e mpv seek 2 absolute+exact
|
||||
bun run e2e eval overlay 'document.getElementById("subtitleRoot").textContent'
|
||||
bun run e2e shot overlay /tmp/overlay.png
|
||||
bun run e2e app --mine-sentence
|
||||
bun run e2e anki # AnkiConnect requests received so far
|
||||
bun run e2e stop
|
||||
```
|
||||
|
||||
Out of scope: compositor-specific behavior (Hyprland placement, stacking, fullscreen,
|
||||
click-through), real GPU video output, and global shortcuts. Xvfb runs without a window
|
||||
manager or compositor, so the transparent overlay is not composited over the video;
|
||||
check overlay visuals with per-window screenshots, and use the live desktop for the rest.
|
||||
|
||||
## Dependency Audit Policy
|
||||
|
||||
- `bun audit --audit-level high` blocks the reusable quality gate.
|
||||
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
// Keeps one isolated e2e session running so a change can be poked at by hand
|
||||
// (or by an agent) without touching the real desktop:
|
||||
//
|
||||
// bun run e2e start boot and stay up until `stop` or Ctrl-C
|
||||
// bun run e2e eval <page> <expression> evaluate JS in a window (or "main" for the main process)
|
||||
// bun run e2e shot <page> <file.png> save a screenshot of a window
|
||||
// bun run e2e mpv <command> [args...] send an mpv IPC command (args parsed as JSON)
|
||||
// bun run e2e app <args...> run a SubMiner CLI command against the instance
|
||||
// bun run e2e anki print the AnkiConnect requests received so far
|
||||
// bun run e2e stop
|
||||
//
|
||||
// <page> is a URL fragment: "overlay" is shorthand for the overlay window.
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { connectCdp, connectCdpPage, listCdpTargets } from './harness/cdp';
|
||||
import { connectMpv } from './harness/mpv';
|
||||
import { OVERLAY_PAGE, runAppCommand, startE2eSession, type AppTarget } from './harness/session';
|
||||
import { waitUntil } from './harness/wait';
|
||||
|
||||
type SessionState = {
|
||||
pid: number;
|
||||
root: string;
|
||||
display: string;
|
||||
cdpPort: number;
|
||||
inspectorUrl: string;
|
||||
mpvSocketPath: string;
|
||||
ankiUrl: string;
|
||||
target: AppTarget;
|
||||
};
|
||||
|
||||
const stateFile = path.join(os.tmpdir(), 'subminer-e2e-session.json');
|
||||
|
||||
function readState(): SessionState {
|
||||
if (!fs.existsSync(stateFile)) {
|
||||
throw new Error('No e2e session is running. Start one with `bun run e2e start`.');
|
||||
}
|
||||
return JSON.parse(fs.readFileSync(stateFile, 'utf8')) as SessionState;
|
||||
}
|
||||
|
||||
function isAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonOrString(value: string): unknown {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
async function connectNamedPage(state: SessionState, pageName: string) {
|
||||
if (pageName === 'main') return connectCdp(state.inspectorUrl, 'main process');
|
||||
const urlPart = pageName === 'overlay' ? OVERLAY_PAGE : pageName;
|
||||
const targets = await listCdpTargets(state.cdpPort);
|
||||
const target = targets.find((candidate) => candidate.url.includes(urlPart));
|
||||
if (!target) {
|
||||
const open = targets.map((candidate) => ` ${candidate.type} ${candidate.url.slice(0, 100)}`);
|
||||
throw new Error(`No window matches "${pageName}". Open targets:\n${open.join('\n')}`);
|
||||
}
|
||||
return connectCdpPage(target);
|
||||
}
|
||||
|
||||
async function withPage<T>(
|
||||
state: SessionState,
|
||||
pageName: string,
|
||||
use: (page: Awaited<ReturnType<typeof connectCdpPage>>) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const page = await connectNamedPage(state, pageName);
|
||||
try {
|
||||
return await use(page);
|
||||
} finally {
|
||||
page.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function start(): Promise<void> {
|
||||
if (fs.existsSync(stateFile) && isAlive(readState().pid)) {
|
||||
throw new Error('An e2e session is already running. Stop it with `bun run e2e stop`.');
|
||||
}
|
||||
const session = await startE2eSession();
|
||||
const state: SessionState = {
|
||||
pid: process.pid,
|
||||
root: session.root,
|
||||
display: session.display,
|
||||
cdpPort: session.cdpPort,
|
||||
inspectorUrl: session.inspectorUrl,
|
||||
mpvSocketPath: session.mpvSocketPath,
|
||||
ankiUrl: session.anki.url,
|
||||
target: session.target,
|
||||
};
|
||||
// The state file grants main-process code evaluation via inspectorUrl, so keep it
|
||||
// owner-only. 'wx' creates it exclusively: it fails rather than follow a symlink
|
||||
// planted in the shared temp dir after the stale file is removed.
|
||||
fs.rmSync(stateFile, { force: true });
|
||||
fs.writeFileSync(stateFile, JSON.stringify(state, null, 2), { mode: 0o600, flag: 'wx' });
|
||||
console.log(JSON.stringify(state, null, 2));
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
process.once('SIGINT', resolve);
|
||||
process.once('SIGTERM', resolve);
|
||||
});
|
||||
fs.rmSync(stateFile, { force: true });
|
||||
await session.dispose();
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const [command, ...args] = process.argv.slice(2);
|
||||
if (command === 'start') return start();
|
||||
|
||||
const state = readState();
|
||||
switch (command) {
|
||||
case 'eval': {
|
||||
const [pageName, expression] = args;
|
||||
if (!pageName || !expression) throw new Error('Usage: eval <page> <expression>');
|
||||
const value = await withPage(state, pageName, (page) => page.evaluate<unknown>(expression));
|
||||
console.log(JSON.stringify(value, null, 2));
|
||||
return;
|
||||
}
|
||||
case 'shot': {
|
||||
const [pageName, outputPath] = args;
|
||||
if (!pageName || !outputPath) throw new Error('Usage: shot <page> <file.png>');
|
||||
fs.writeFileSync(outputPath, await withPage(state, pageName, (page) => page.screenshot()));
|
||||
return;
|
||||
}
|
||||
case 'mpv': {
|
||||
if (args.length === 0) throw new Error('Usage: mpv <command> [args...]');
|
||||
const mpv = await connectMpv(state.mpvSocketPath);
|
||||
try {
|
||||
console.log(JSON.stringify((await mpv.command(...args.map(parseJsonOrString))) ?? null));
|
||||
} finally {
|
||||
mpv.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'app':
|
||||
await runAppCommand(state.target, args);
|
||||
return;
|
||||
case 'anki':
|
||||
console.log(JSON.stringify(await (await fetch(state.ankiUrl)).json(), null, 2));
|
||||
return;
|
||||
case 'stop':
|
||||
process.kill(state.pid, 'SIGTERM');
|
||||
await waitUntil(() => !isAlive(state.pid), { description: 'e2e session to exit' });
|
||||
return;
|
||||
default:
|
||||
throw new Error(`Unknown command "${command ?? ''}". See the header of e2e/cli.ts.`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { waitUntil, type Truthy, type WaitOptions } from './wait';
|
||||
|
||||
export type CdpTarget = {
|
||||
type: string;
|
||||
title: string;
|
||||
url: string;
|
||||
webSocketDebuggerUrl: string;
|
||||
};
|
||||
|
||||
export type CdpPage = {
|
||||
/** Sends a raw DevTools protocol command, for anything the helpers below do not cover. */
|
||||
send: (method: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
||||
/** Evaluates an expression in the page, awaiting promises, and returns its JSON value. */
|
||||
evaluate: <T>(expression: string) => Promise<T>;
|
||||
/** Polls an expression until it is truthy and returns that value. */
|
||||
waitFor: <T>(expression: string, options: WaitOptions) => Promise<Truthy<T>>;
|
||||
/** Console output and uncaught exceptions seen since connecting, oldest first. */
|
||||
console: string[];
|
||||
/** PNG of the page as rendered by Chromium; needs no OS screen-capture permission. */
|
||||
screenshot: () => Promise<Buffer>;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
type CdpReply = {
|
||||
id?: number;
|
||||
method?: string;
|
||||
params?: Record<string, unknown>;
|
||||
error?: { message: string };
|
||||
result?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type RemoteObject = { value?: unknown; description?: string };
|
||||
|
||||
// Renders a console/exception event as one log line.
|
||||
function formatConsoleEvent(method: string, params: Record<string, unknown>): string | null {
|
||||
if (method === 'Runtime.consoleAPICalled') {
|
||||
const args = Array.isArray(params.args) ? (params.args as RemoteObject[]) : [];
|
||||
const text = args.map((arg) => arg.description ?? JSON.stringify(arg.value)).join(' ');
|
||||
return `console.${String(params.type)}: ${text}`;
|
||||
}
|
||||
if (method === 'Runtime.exceptionThrown') {
|
||||
const details = params.exceptionDetails as
|
||||
| { text?: string; exception?: RemoteObject }
|
||||
| undefined;
|
||||
return `exception: ${details?.exception?.description ?? details?.text ?? 'unknown'}`;
|
||||
}
|
||||
if (method === 'Log.entryAdded') {
|
||||
const entry = params.entry as { level?: string; source?: string; text?: string } | undefined;
|
||||
return `${String(entry?.source)}.${String(entry?.level)}: ${String(entry?.text)}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type EvaluateResult = {
|
||||
result: { value?: unknown };
|
||||
exceptionDetails?: { text: string; exception?: { description?: string } };
|
||||
};
|
||||
|
||||
export async function listCdpTargets(port: number): Promise<CdpTarget[]> {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/json/list`);
|
||||
return (await response.json()) as CdpTarget[];
|
||||
}
|
||||
|
||||
export function connectCdpPage(target: CdpTarget): Promise<CdpPage> {
|
||||
return connectCdp(target.webSocketDebuggerUrl, target.url);
|
||||
}
|
||||
|
||||
/** Connects to any DevTools endpoint: a renderer page or Electron's main-process inspector. */
|
||||
export async function connectCdp(webSocketDebuggerUrl: string, label: string): Promise<CdpPage> {
|
||||
const socket = new WebSocket(webSocketDebuggerUrl);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.addEventListener('open', () => resolve(), { once: true });
|
||||
socket.addEventListener('error', () => reject(new Error(`CDP connection to ${label} failed`)), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
|
||||
const pending = new Map<number, (reply: CdpReply) => void>();
|
||||
const consoleLines: string[] = [];
|
||||
let nextId = 1;
|
||||
socket.addEventListener('message', (event) => {
|
||||
const reply: CdpReply = JSON.parse(String(event.data));
|
||||
if (reply.id === undefined) {
|
||||
if (reply.method) {
|
||||
const line = formatConsoleEvent(reply.method, reply.params ?? {});
|
||||
if (line) consoleLines.push(line);
|
||||
}
|
||||
return;
|
||||
}
|
||||
pending.get(reply.id)?.(reply);
|
||||
pending.delete(reply.id);
|
||||
});
|
||||
|
||||
const send = (method: string, params: Record<string, unknown> = {}) =>
|
||||
new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
const id = nextId++;
|
||||
pending.set(id, (reply) => {
|
||||
if (reply.error) reject(new Error(`CDP ${method} failed: ${reply.error.message}`));
|
||||
else resolve(reply.result ?? {});
|
||||
});
|
||||
socket.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
|
||||
const evaluate = async <T>(expression: string): Promise<T> => {
|
||||
const reply = (await send('Runtime.evaluate', {
|
||||
expression,
|
||||
returnByValue: true,
|
||||
awaitPromise: true,
|
||||
})) as EvaluateResult;
|
||||
if (reply.exceptionDetails) {
|
||||
const { text, exception } = reply.exceptionDetails;
|
||||
throw new Error(`Page evaluation threw: ${exception?.description ?? text}`);
|
||||
}
|
||||
return reply.result.value as T;
|
||||
};
|
||||
|
||||
await send('Runtime.enable');
|
||||
// The Node inspector behind Electron's main process has no Log domain.
|
||||
await send('Log.enable').catch(() => undefined);
|
||||
|
||||
return {
|
||||
send,
|
||||
evaluate,
|
||||
console: consoleLines,
|
||||
waitFor: <T>(expression: string, options: WaitOptions) =>
|
||||
waitUntil(() => evaluate<T>(expression), options),
|
||||
screenshot: async () => {
|
||||
const reply = await send('Page.captureScreenshot', { format: 'png' });
|
||||
return Buffer.from(String(reply.data), 'base64');
|
||||
},
|
||||
close: () => socket.close(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { stopProcess, type EnvDelta } from './process';
|
||||
|
||||
export const E2E_SCREEN = { width: 1280, height: 720 } as const;
|
||||
|
||||
export type E2eDisplay = {
|
||||
kind: 'xvfb' | 'host';
|
||||
env: EnvDelta;
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
// Linux gets a private Xvfb server so no window ever reaches the real desktop.
|
||||
// macOS and Windows have no offscreen display server, so windows open in the
|
||||
// current GUI session there. SUBMINER_E2E_DISPLAY=host forces that on Linux too.
|
||||
export async function startDisplay(): Promise<E2eDisplay> {
|
||||
const requested = process.env.SUBMINER_E2E_DISPLAY?.trim().toLowerCase();
|
||||
if (requested && requested !== 'host' && requested !== 'xvfb') {
|
||||
throw new Error(`SUBMINER_E2E_DISPLAY must be "host" or "xvfb", got "${requested}"`);
|
||||
}
|
||||
const kind = requested ?? (process.platform === 'linux' ? 'xvfb' : 'host');
|
||||
if (kind === 'host') {
|
||||
return { kind, env: { set: {}, unset: [] }, stop: async () => {} };
|
||||
}
|
||||
return startXvfb();
|
||||
}
|
||||
|
||||
async function startXvfb(): Promise<E2eDisplay> {
|
||||
// -displayfd makes Xvfb pick a free display number and write it to fd 3 once
|
||||
// it accepts connections, so there is no port guessing and no startup sleep.
|
||||
const child = spawn(
|
||||
'Xvfb',
|
||||
[
|
||||
'-displayfd',
|
||||
'3',
|
||||
'-screen',
|
||||
'0',
|
||||
`${E2E_SCREEN.width}x${E2E_SCREEN.height}x24`,
|
||||
'-nolisten',
|
||||
'tcp',
|
||||
],
|
||||
{ stdio: ['ignore', 'ignore', 'ignore', 'pipe'] },
|
||||
);
|
||||
const displayFd = child.stdio[3];
|
||||
if (!displayFd) throw new Error('Xvfb did not expose its display fd');
|
||||
|
||||
const displayNumber = await Promise.race([
|
||||
once(displayFd, 'data').then(([chunk]) => String(chunk).trim()),
|
||||
once(child, 'error').then(([error]) => {
|
||||
throw new Error(
|
||||
`Could not start Xvfb (${String(error)}). Install it or set SUBMINER_E2E_DISPLAY=host.`,
|
||||
);
|
||||
}),
|
||||
once(child, 'exit').then(([code]) => {
|
||||
throw new Error(`Xvfb exited during startup with code ${String(code)}`);
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
kind: 'xvfb',
|
||||
env: {
|
||||
set: { DISPLAY: `:${displayNumber}`, XDG_SESSION_TYPE: 'x11' },
|
||||
// Without these the app and mpv would pick the real Wayland session, or
|
||||
// take the compositor-specific code paths of the desktop running the tests.
|
||||
unset: [
|
||||
'WAYLAND_DISPLAY',
|
||||
'HYPRLAND_INSTANCE_SIGNATURE',
|
||||
'SWAYSOCK',
|
||||
'XDG_CURRENT_DESKTOP',
|
||||
'XDG_SESSION_DESKTOP',
|
||||
],
|
||||
},
|
||||
stop: () => stopProcess(child),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { once } from 'node:events';
|
||||
import http from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
|
||||
export type AnkiRequest = { action: string; params: Record<string, unknown> };
|
||||
|
||||
export type FakeAnki = {
|
||||
url: string;
|
||||
/** Every AnkiConnect action received so far, with `multi` batches flattened. */
|
||||
requests: AnkiRequest[];
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
export const FAKE_ANKI_DECK = 'E2E';
|
||||
export const FAKE_ANKI_MODEL = 'Lapis';
|
||||
|
||||
const MODEL_FIELDS = [
|
||||
'Expression',
|
||||
'ExpressionFurigana',
|
||||
'ExpressionReading',
|
||||
'ExpressionAudio',
|
||||
'SelectionText',
|
||||
'MainDefinition',
|
||||
'Sentence',
|
||||
'SentenceFurigana',
|
||||
'SentenceAudio',
|
||||
'Picture',
|
||||
'Glossary',
|
||||
'IsWordAndSentenceCard',
|
||||
'IsClickCard',
|
||||
'IsSentenceCard',
|
||||
'IsAudioCard',
|
||||
'MiscInfo',
|
||||
];
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
// A recording AnkiConnect stand-in: an empty collection with one deck and one
|
||||
// Lapis-shaped note type. Scenarios assert on `requests`, not on stored notes.
|
||||
export async function startFakeAnki(): Promise<FakeAnki> {
|
||||
const requests: AnkiRequest[] = [];
|
||||
let nextNoteId = 1000;
|
||||
|
||||
const handle = (body: unknown): unknown => {
|
||||
if (!isRecord(body) || typeof body.action !== 'string') return null;
|
||||
const params = isRecord(body.params) ? body.params : {};
|
||||
if (body.action === 'multi') {
|
||||
return Array.isArray(params.actions) ? params.actions.map(handle) : [];
|
||||
}
|
||||
requests.push({ action: body.action, params });
|
||||
switch (body.action) {
|
||||
case 'version':
|
||||
return 6;
|
||||
case 'deckNames':
|
||||
return [FAKE_ANKI_DECK];
|
||||
case 'modelNames':
|
||||
return [FAKE_ANKI_MODEL];
|
||||
case 'modelFieldNames':
|
||||
return MODEL_FIELDS;
|
||||
case 'addNote':
|
||||
return nextNoteId++;
|
||||
case 'findNotes':
|
||||
case 'notesInfo':
|
||||
return [];
|
||||
case 'storeMediaFile':
|
||||
return params.filename ?? null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const server = http.createServer((request, response) => {
|
||||
response.setHeader('content-type', 'application/json');
|
||||
// GET exposes the request log so the session CLI can read it from outside.
|
||||
if (request.method === 'GET') {
|
||||
response.end(JSON.stringify(requests));
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
request.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
request.on('end', () => {
|
||||
let body: unknown = null;
|
||||
try {
|
||||
body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
||||
} catch {
|
||||
// Malformed bodies get the same null result AnkiConnect gives unknown actions.
|
||||
}
|
||||
response.end(JSON.stringify({ result: handle(body), error: null }));
|
||||
});
|
||||
});
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
const { port } = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
requests,
|
||||
stop: async () => {
|
||||
server.closeAllConnections();
|
||||
server.close();
|
||||
await once(server, 'close');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import { promisify } from 'node:util';
|
||||
import { writeStoredZipAsync } from '../../src/shared/stored-zip';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export const FIXTURE_DICTIONARY_TITLE = 'SubMiner E2E Fixture';
|
||||
export const FIXTURE_CLIP_SECONDS = 15;
|
||||
|
||||
/** Cues of the synthetic clip. Seek to `at` to land inside a cue. */
|
||||
export const FIXTURE_CUES = [
|
||||
{ start: 1, end: 4, at: 2, text: '今日はいい天気ですね。' },
|
||||
{ start: 5, end: 8, at: 6, text: '猫が好きです。' },
|
||||
{ start: 9, end: 12, at: 10, text: '明日は学校に行きます。' },
|
||||
] as const;
|
||||
|
||||
// [term, reading, part of speech, gloss] for every content word in FIXTURE_CUES.
|
||||
const FIXTURE_TERMS = [
|
||||
['今日', 'きょう', 'n', 'today'],
|
||||
['いい', 'いい', 'adj-i', 'good'],
|
||||
['天気', 'てんき', 'n', 'weather'],
|
||||
['猫', 'ねこ', 'n', 'cat'],
|
||||
['好き', 'すき', 'adj-na', 'liked'],
|
||||
['明日', 'あした', 'n', 'tomorrow'],
|
||||
['学校', 'がっこう', 'n', 'school'],
|
||||
['行く', 'いく', 'v5', 'to go'],
|
||||
] as const;
|
||||
|
||||
function srtTimestamp(seconds: number): string {
|
||||
return `00:00:${String(seconds).padStart(2, '0')},000`;
|
||||
}
|
||||
|
||||
export function writeFixtureSubtitles(outputPath: string): void {
|
||||
const blocks = FIXTURE_CUES.map(
|
||||
(cue, index) =>
|
||||
`${index + 1}\n${srtTimestamp(cue.start)} --> ${srtTimestamp(cue.end)}\n${cue.text}\n`,
|
||||
);
|
||||
fs.writeFileSync(outputPath, blocks.join('\n'), 'utf8');
|
||||
}
|
||||
|
||||
/** Renders a deterministic test-pattern clip with a sine tone, so no real media is needed. */
|
||||
export async function generateFixtureClip(outputPath: string): Promise<void> {
|
||||
await execFileAsync('ffmpeg', [
|
||||
'-nostdin',
|
||||
'-v',
|
||||
'error',
|
||||
'-y',
|
||||
'-f',
|
||||
'lavfi',
|
||||
'-i',
|
||||
`testsrc2=size=640x360:rate=24:duration=${FIXTURE_CLIP_SECONDS}`,
|
||||
'-f',
|
||||
'lavfi',
|
||||
'-i',
|
||||
`sine=frequency=440:duration=${FIXTURE_CLIP_SECONDS}`,
|
||||
'-c:v',
|
||||
'libx264',
|
||||
'-preset',
|
||||
'ultrafast',
|
||||
'-pix_fmt',
|
||||
'yuv420p',
|
||||
'-c:a',
|
||||
'aac',
|
||||
outputPath,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Writes a minimal Yomitan term dictionary so the tokenizer has real entries to match. */
|
||||
export async function buildFixtureDictionary(outputPath: string): Promise<void> {
|
||||
const index = {
|
||||
title: FIXTURE_DICTIONARY_TITLE,
|
||||
revision: '1',
|
||||
format: 3,
|
||||
author: 'SubMiner',
|
||||
description: 'Synthetic dictionary for the e2e harness.',
|
||||
};
|
||||
const termBank = FIXTURE_TERMS.map(([term, reading, partOfSpeech, gloss], sequence) => [
|
||||
term,
|
||||
reading,
|
||||
'',
|
||||
partOfSpeech,
|
||||
0,
|
||||
[gloss],
|
||||
sequence + 1,
|
||||
'',
|
||||
]);
|
||||
await writeStoredZipAsync(outputPath, [
|
||||
{ name: 'index.json', data: Buffer.from(JSON.stringify(index), 'utf8') },
|
||||
{ name: 'term_bank_1.json', data: Buffer.from(JSON.stringify(termBank), 'utf8') },
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import type { E2eDisplay } from './display';
|
||||
import { E2E_SCREEN } from './display';
|
||||
import { applyEnvDelta } from './process';
|
||||
import { waitUntil } from './wait';
|
||||
|
||||
export type MpvClient = {
|
||||
/** Sends one mpv JSON IPC command and resolves with its `data` field. */
|
||||
command: (...args: unknown[]) => Promise<unknown>;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
type MpvReply = { request_id?: number; error?: string; data?: unknown };
|
||||
|
||||
export async function startMpv(options: {
|
||||
socketPath: string;
|
||||
mediaPath: string;
|
||||
subtitlePath: string;
|
||||
logPath: string;
|
||||
display: E2eDisplay;
|
||||
}): Promise<ChildProcess> {
|
||||
const log = fs.openSync(options.logPath, 'w');
|
||||
const child = spawn(
|
||||
'mpv',
|
||||
[
|
||||
'--no-config',
|
||||
'--no-terminal',
|
||||
'--pause',
|
||||
'--keep-open=yes',
|
||||
'--ao=null',
|
||||
`--geometry=${E2E_SCREEN.width}x${E2E_SCREEN.height}+0+0`,
|
||||
// Xvfb has no GPU; the software X11 output always works there.
|
||||
...(options.display.kind === 'xvfb' ? ['--vo=x11'] : []),
|
||||
`--input-ipc-server=${options.socketPath}`,
|
||||
`--sub-file=${options.subtitlePath}`,
|
||||
options.mediaPath,
|
||||
],
|
||||
{ env: applyEnvDelta(process.env, options.display.env), stdio: ['ignore', log, log] },
|
||||
);
|
||||
fs.closeSync(log);
|
||||
await Promise.race([
|
||||
once(child, 'spawn'),
|
||||
once(child, 'error').then(([error]) => {
|
||||
throw new Error(`Could not start mpv (${String(error)}). Is it on PATH?`);
|
||||
}),
|
||||
]);
|
||||
return child;
|
||||
}
|
||||
|
||||
function tryConnect(socketPath: string): Promise<net.Socket | null> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.connect(socketPath);
|
||||
socket.once('connect', () => resolve(socket));
|
||||
socket.once('error', () => resolve(null));
|
||||
});
|
||||
}
|
||||
|
||||
export async function connectMpv(socketPath: string): Promise<MpvClient> {
|
||||
const socket = await waitUntil(() => tryConnect(socketPath), {
|
||||
description: `mpv IPC socket ${socketPath}`,
|
||||
});
|
||||
const pending = new Map<number, (reply: MpvReply) => void>();
|
||||
let nextRequestId = 1;
|
||||
let buffer = '';
|
||||
|
||||
socket.setEncoding('utf8');
|
||||
socket.on('data', (chunk: string) => {
|
||||
buffer += chunk;
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
const reply: MpvReply = JSON.parse(line);
|
||||
if (reply.request_id === undefined) continue;
|
||||
pending.get(reply.request_id)?.(reply);
|
||||
pending.delete(reply.request_id);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
command: (...args) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const requestId = nextRequestId++;
|
||||
pending.set(requestId, (reply) => {
|
||||
if (reply.error === 'success') resolve(reply.data);
|
||||
else reject(new Error(`mpv ${JSON.stringify(args)} failed: ${reply.error}`));
|
||||
});
|
||||
socket.write(`${JSON.stringify({ command: args, request_id: requestId })}\n`);
|
||||
}),
|
||||
close: () => socket.destroy(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
|
||||
export type EnvDelta = { set: Record<string, string>; unset: string[] };
|
||||
|
||||
export function applyEnvDelta(base: NodeJS.ProcessEnv, ...deltas: EnvDelta[]): NodeJS.ProcessEnv {
|
||||
const env = { ...base };
|
||||
for (const delta of deltas) {
|
||||
for (const name of delta.unset) delete env[name];
|
||||
Object.assign(env, delta.set);
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
/** Terminates a child and waits for it to exit, escalating to SIGKILL after `graceMs`. */
|
||||
export async function stopProcess(child: ChildProcess, graceMs = 5_000): Promise<void> {
|
||||
// A child that never spawned (missing binary) has no pid and never emits exit.
|
||||
if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) return;
|
||||
const exited = once(child, 'exit');
|
||||
child.kill();
|
||||
const timer = setTimeout(() => child.kill('SIGKILL'), graceMs);
|
||||
await exited;
|
||||
clearTimeout(timer);
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { connectCdp, connectCdpPage, listCdpTargets, type CdpPage } from './cdp';
|
||||
import { startDisplay, type E2eDisplay } from './display';
|
||||
import { FAKE_ANKI_DECK, FAKE_ANKI_MODEL, startFakeAnki, type FakeAnki } from './fake-anki';
|
||||
import { buildFixtureDictionary, generateFixtureClip, writeFixtureSubtitles } from './fixtures';
|
||||
import { connectMpv, startMpv, type MpvClient } from './mpv';
|
||||
import { applyEnvDelta, stopProcess, type EnvDelta } from './process';
|
||||
import { waitUntil } from './wait';
|
||||
import {
|
||||
shouldForceX11ElectronBackend,
|
||||
X11_ELECTRON_BOOTSTRAP_ENV,
|
||||
} from '../../src/core/utils/electron-backend';
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..', '..');
|
||||
|
||||
/** URL fragments identifying the app's windows among the CDP targets. The modal
|
||||
* window loads the same document with `layer=modal`, so the layer is required. */
|
||||
export const OVERLAY_PAGE = 'renderer/index.html?layer=visible';
|
||||
const YOMITAN_SETTINGS_PAGE = 'settings.html';
|
||||
|
||||
type JsonObject = { [key: string]: JsonValue };
|
||||
type JsonValue = string | number | boolean | null | JsonValue[] | JsonObject;
|
||||
|
||||
export type E2eSessionOptions = {
|
||||
/** Deep-merged over the harness config, for scenarios that need other settings. */
|
||||
config?: JsonObject;
|
||||
};
|
||||
|
||||
export type AppTarget = {
|
||||
/** Environment changes that point a process at the isolated instance. */
|
||||
env: EnvDelta;
|
||||
/** Electron/Chromium switches every launch needs. */
|
||||
electronArgs: string[];
|
||||
/** Receives stdout and stderr of every launch. */
|
||||
logPath: string;
|
||||
};
|
||||
|
||||
export type E2eSession = {
|
||||
/** Temp dir holding the isolated config, fixtures, sockets, and logs. */
|
||||
root: string;
|
||||
display: E2eDisplay['kind'];
|
||||
cdpPort: number;
|
||||
/** DevTools endpoint of the Electron main process (Node inspector). */
|
||||
inspectorUrl: string;
|
||||
mpvSocketPath: string;
|
||||
/** How to start a process that talks to this isolated instance. */
|
||||
target: AppTarget;
|
||||
mpv: MpvClient;
|
||||
anki: FakeAnki;
|
||||
/** Connects to the first window or frame whose URL contains `urlPart`, waiting for it to exist. */
|
||||
openPage: (urlPart: string) => Promise<CdpPage>;
|
||||
/** Evaluates in the main process; reach modules via `process.mainModule.require`. */
|
||||
mainProcess: () => Promise<CdpPage>;
|
||||
/** Runs a CLI command (e.g. `--mine-sentence`) against the running instance. */
|
||||
runAppCommand: (...args: string[]) => Promise<void>;
|
||||
dispose: () => Promise<void>;
|
||||
};
|
||||
|
||||
function isJsonObject(value: JsonValue | undefined): value is JsonObject {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function mergeConfig(base: JsonObject, overrides: JsonObject): JsonObject {
|
||||
const merged = { ...base };
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
const existing = merged[key];
|
||||
merged[key] =
|
||||
isJsonObject(existing) && isJsonObject(value) ? mergeConfig(existing, value) : value;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
// Everything that could reach outside the sandbox (network services, the real
|
||||
// Anki, ports the user's own SubMiner holds) is off or pointed at a fake.
|
||||
function buildConfig(mpvSocketPath: string, ankiUrl: string): JsonObject {
|
||||
return {
|
||||
mpv: { socketPath: mpvSocketPath, aniskipEnabled: false },
|
||||
ankiConnect: {
|
||||
url: ankiUrl,
|
||||
deck: FAKE_ANKI_DECK,
|
||||
proxy: { enabled: false },
|
||||
isLapis: { enabled: true, sentenceCardModel: FAKE_ANKI_MODEL },
|
||||
},
|
||||
stats: { autoStartServer: false },
|
||||
updates: { enabled: false },
|
||||
discordPresence: { enabled: false },
|
||||
websocket: { enabled: false },
|
||||
annotationWebsocket: { enabled: false },
|
||||
texthooker: { launchAtStartup: false },
|
||||
};
|
||||
}
|
||||
|
||||
// Outside Electron the package's entry point is the path of its binary, and
|
||||
// resolving it downloads the binary when an install skipped that step (CI).
|
||||
function resolveElectronBinary(): string {
|
||||
const binary: unknown = require('electron');
|
||||
if (typeof binary !== 'string') throw new Error('Could not resolve the Electron binary path');
|
||||
return binary;
|
||||
}
|
||||
|
||||
// Electron announces its main-process inspector on stderr; `offset` skips earlier launches.
|
||||
function readInspectorUrl(logPath: string, byteOffset: number): string | null {
|
||||
const tail = fs.readFileSync(logPath).subarray(byteOffset).toString('utf8');
|
||||
return /Debugger listening on (ws:\/\/\S+)/.exec(tail)?.[1] ?? null;
|
||||
}
|
||||
|
||||
function readDevToolsPort(portFile: string): number | null {
|
||||
try {
|
||||
const port = Number(fs.readFileSync(portFile, 'utf8').split('\n')[0]);
|
||||
return port > 0 ? port : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function spawnApp(target: AppTarget, electronArgs: string[], args: string[]): ChildProcess {
|
||||
const log = fs.openSync(target.logPath, 'a');
|
||||
const child = spawn(
|
||||
resolveElectronBinary(),
|
||||
[...target.electronArgs, ...electronArgs, repoRoot, ...args],
|
||||
{ cwd: repoRoot, env: applyEnvDelta(process.env, target.env), stdio: ['ignore', log, log] },
|
||||
);
|
||||
fs.closeSync(log);
|
||||
return child;
|
||||
}
|
||||
|
||||
/** Runs a CLI command against the running instance and waits for it to finish. */
|
||||
export async function runAppCommand(target: AppTarget, args: string[]): Promise<void> {
|
||||
const child = spawnApp(target, [], args);
|
||||
const timer = setTimeout(() => child.kill('SIGKILL'), 30_000);
|
||||
const [code] = await once(child, 'exit');
|
||||
clearTimeout(timer);
|
||||
if (code !== 0) {
|
||||
throw new Error(
|
||||
`SubMiner ${args.join(' ')} exited with ${String(code)}; see ${target.logPath}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function openPage(cdpPort: number, urlPart: string): Promise<CdpPage> {
|
||||
const target = await waitUntil(
|
||||
async () =>
|
||||
(await listCdpTargets(cdpPort)).find((candidate) => candidate.url.includes(urlPart)),
|
||||
{ description: `app window matching "${urlPart}"`, timeoutMs: 30_000 },
|
||||
);
|
||||
const page = await connectCdpPage(target);
|
||||
// A target is listed before its document exists; callers expect a usable DOM.
|
||||
await page.waitFor(`document.readyState === "complete"`, {
|
||||
description: `"${urlPart}" document to load`,
|
||||
});
|
||||
return page;
|
||||
}
|
||||
|
||||
export async function startE2eSession(options: E2eSessionOptions = {}): Promise<E2eSession> {
|
||||
if (!fs.existsSync(path.join(repoRoot, 'dist', 'main-entry.js'))) {
|
||||
throw new Error('dist/ is missing. Run `bun run build` before the e2e harness.');
|
||||
}
|
||||
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-e2e-'));
|
||||
const configHome = path.join(root, 'config');
|
||||
const userDataDir = path.join(configHome, 'SubMiner');
|
||||
const mediaPath = path.join(root, 'clip.mkv');
|
||||
const subtitlePath = path.join(root, 'clip.srt');
|
||||
const dictionaryPath = path.join(root, 'fixture-dictionary.zip');
|
||||
const mpvSocketPath =
|
||||
process.platform === 'win32'
|
||||
? `\\\\.\\pipe\\${path.basename(root)}-mpv`
|
||||
: path.join(root, 'mpv.sock');
|
||||
|
||||
// Teardown steps, run in reverse. Also used to unwind a failed startup.
|
||||
const teardown: Array<() => Promise<void> | void> = [];
|
||||
const dispose = async (): Promise<void> => {
|
||||
for (const step of teardown.reverse()) await step();
|
||||
teardown.length = 0;
|
||||
if (process.env.SUBMINER_E2E_KEEP !== '1') {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
fs.mkdirSync(userDataDir, { recursive: true });
|
||||
writeFixtureSubtitles(subtitlePath);
|
||||
await Promise.all([generateFixtureClip(mediaPath), buildFixtureDictionary(dictionaryPath)]);
|
||||
|
||||
const display = await startDisplay();
|
||||
teardown.push(display.stop);
|
||||
const anki = await startFakeAnki();
|
||||
teardown.push(anki.stop);
|
||||
|
||||
const config = mergeConfig(buildConfig(mpvSocketPath, anki.url), options.config ?? {});
|
||||
fs.writeFileSync(path.join(userDataDir, 'config.jsonc'), JSON.stringify(config, null, 2));
|
||||
|
||||
const env: EnvDelta = {
|
||||
set: {
|
||||
...display.env.set,
|
||||
// The app derives its config dir, Electron profile, and control socket from these.
|
||||
...(process.platform === 'win32'
|
||||
? { APPDATA: configHome }
|
||||
: { XDG_CONFIG_HOME: configHome, XDG_DATA_HOME: path.join(root, 'data') }),
|
||||
SUBMINER_APP_LOG: path.join(root, 'app.log'),
|
||||
// Stay attached instead of re-spawning detached, so the harness owns the process.
|
||||
SUBMINER_BACKGROUND_CHILD: '1',
|
||||
},
|
||||
unset: [...display.env.unset, 'ELECTRON_RUN_AS_NODE'],
|
||||
};
|
||||
// --enable-logging routes renderer console output into the app log too.
|
||||
const electronArgs = ['--password-store=basic', '--enable-logging=stderr'];
|
||||
// Outside Hyprland/Sway the app re-spawns itself detached onto the X11
|
||||
// backend. Start it there directly so the process we spawn is the app.
|
||||
if (shouldForceX11ElectronBackend(applyEnvDelta(process.env, env))) {
|
||||
env.set[X11_ELECTRON_BOOTSTRAP_ENV] = '1';
|
||||
electronArgs.push('--ozone-platform=x11');
|
||||
}
|
||||
// CI containers and runners lack the user namespaces Chromium's sandbox needs.
|
||||
if (process.platform === 'linux' && process.env.CI) electronArgs.push('--no-sandbox');
|
||||
const target: AppTarget = { env, electronArgs, logPath: path.join(root, 'app-stdio.log') };
|
||||
|
||||
const launchApp = async (
|
||||
args: string[],
|
||||
): Promise<{ child: ChildProcess; cdpPort: number; inspectorUrl: string }> => {
|
||||
// With port 0 Chromium and Node each pick a free port; Chromium records
|
||||
// its port in the profile dir, Node prints its endpoint to stderr.
|
||||
const portFile = path.join(userDataDir, 'DevToolsActivePort');
|
||||
fs.rmSync(portFile, { force: true });
|
||||
const logOffset = fs.existsSync(target.logPath) ? fs.statSync(target.logPath).size : 0;
|
||||
const child = spawnApp(
|
||||
target,
|
||||
['--remote-debugging-port=0', '--inspect=0'],
|
||||
[...args, '--log-level', 'debug'],
|
||||
);
|
||||
const stop = () => stopProcess(child);
|
||||
teardown.push(stop);
|
||||
const cdpPort = await waitUntil(
|
||||
() => {
|
||||
if (child.exitCode !== null) {
|
||||
throw new Error(`SubMiner exited with code ${child.exitCode} during startup`);
|
||||
}
|
||||
return readDevToolsPort(portFile);
|
||||
},
|
||||
{ description: 'SubMiner DevTools port', timeoutMs: 30_000 },
|
||||
);
|
||||
const inspectorUrl = await waitUntil(() => readInspectorUrl(target.logPath, logOffset), {
|
||||
description: 'SubMiner main-process inspector endpoint',
|
||||
});
|
||||
return { child, cdpPort, inspectorUrl };
|
||||
};
|
||||
|
||||
// First boot: import the fixture dictionary through Yomitan's settings page.
|
||||
// Without one the tokenizer finds nothing and first-run setup takes over.
|
||||
const seeder = await launchApp(['--yomitan']);
|
||||
const settings = await openPage(seeder.cdpPort, YOMITAN_SETTINGS_PAGE);
|
||||
await settings.waitFor('globalThis.__subminerYomitanSettingsAutomation?.ready === true', {
|
||||
description: 'Yomitan settings automation bridge',
|
||||
});
|
||||
const archive = fs.readFileSync(dictionaryPath).toString('base64');
|
||||
await settings.evaluate(
|
||||
`globalThis.__subminerYomitanSettingsAutomation
|
||||
.importDictionaryArchiveBase64(${JSON.stringify(archive)}, "fixture-dictionary.zip")
|
||||
.then(() => true)`,
|
||||
);
|
||||
settings.close();
|
||||
await stopProcess(seeder.child);
|
||||
|
||||
const mpvProcess = await startMpv({
|
||||
socketPath: mpvSocketPath,
|
||||
mediaPath,
|
||||
subtitlePath,
|
||||
logPath: path.join(root, 'mpv.log'),
|
||||
display,
|
||||
});
|
||||
teardown.push(() => stopProcess(mpvProcess));
|
||||
const mpv = await connectMpv(mpvSocketPath);
|
||||
teardown.push(mpv.close);
|
||||
|
||||
const app = await launchApp(['--start']);
|
||||
// mpv starts paused and the app resumes it once the overlay and tokenizer
|
||||
// are ready, so that resume marks the end of startup.
|
||||
await waitUntil(async () => (await mpv.command('get_property', 'pause')) === false, {
|
||||
description: 'SubMiner to resume playback after overlay startup',
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
// Hand scenarios a paused player. The app retries its resume for a moment,
|
||||
// so keep pausing until the pause has held for longer than that retry window.
|
||||
let pausedSince: number | null = null;
|
||||
await waitUntil(
|
||||
async () => {
|
||||
if ((await mpv.command('get_property', 'pause')) !== true) {
|
||||
pausedSince = null;
|
||||
await mpv.command('set_property', 'pause', true);
|
||||
return false;
|
||||
}
|
||||
pausedSince ??= Date.now();
|
||||
return Date.now() - pausedSince >= 1_500;
|
||||
},
|
||||
{ description: 'playback to stay paused after startup', timeoutMs: 40_000 },
|
||||
);
|
||||
const pages: CdpPage[] = [];
|
||||
teardown.push(() => pages.forEach((page) => page.close()));
|
||||
|
||||
return {
|
||||
root,
|
||||
display: display.kind,
|
||||
cdpPort: app.cdpPort,
|
||||
inspectorUrl: app.inspectorUrl,
|
||||
mpvSocketPath,
|
||||
target,
|
||||
mpv,
|
||||
anki,
|
||||
openPage: async (urlPart) => {
|
||||
const page = await openPage(app.cdpPort, urlPart);
|
||||
pages.push(page);
|
||||
return page;
|
||||
},
|
||||
mainProcess: async () => {
|
||||
const page = await connectCdp(app.inspectorUrl, 'main process');
|
||||
pages.push(page);
|
||||
return page;
|
||||
},
|
||||
runAppCommand: (...args) => runAppCommand(target, args),
|
||||
dispose,
|
||||
};
|
||||
} catch (error) {
|
||||
// Keep the logs: a failed startup is exactly when they are needed.
|
||||
process.env.SUBMINER_E2E_KEEP = '1';
|
||||
await dispose();
|
||||
throw new Error(`E2E session failed to start (logs kept in ${root})`, { cause: error });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export type WaitOptions = {
|
||||
description: string;
|
||||
timeoutMs?: number;
|
||||
intervalMs?: number;
|
||||
};
|
||||
|
||||
export type Truthy<T> = Exclude<T, false | 0 | '' | null | undefined>;
|
||||
|
||||
/** Polls `probe` until it returns a truthy value, then returns that value. */
|
||||
export async function waitUntil<T>(
|
||||
probe: () => T | Promise<T>,
|
||||
{ description, timeoutMs = 15_000, intervalMs = 100 }: WaitOptions,
|
||||
): Promise<Truthy<T>> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
for (;;) {
|
||||
const value = await probe();
|
||||
if (value) return value as Truthy<T>;
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`Timed out after ${timeoutMs}ms waiting for ${description}`);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { after } from 'node:test';
|
||||
import { connectCdpPage, listCdpTargets, type CdpPage } from './harness/cdp';
|
||||
import { FAKE_ANKI_DECK, FAKE_ANKI_MODEL } from './harness/fake-anki';
|
||||
import { FIXTURE_CUES } from './harness/fixtures';
|
||||
import { OVERLAY_PAGE, startE2eSession, type E2eSession } from './harness/session';
|
||||
import { waitUntil } from './harness/wait';
|
||||
|
||||
type Started = { session: E2eSession; overlay: CdpPage };
|
||||
|
||||
// Bun caps node:test hooks at 5s whatever their timeout option says, so the
|
||||
// session boots inside whichever test runs first and every test budgets for it.
|
||||
const TEST_TIMEOUT_MS = 120_000;
|
||||
let started: Promise<Started> | undefined;
|
||||
|
||||
function useSession(): Promise<Started> {
|
||||
started ??= (async () => {
|
||||
const session = await startE2eSession();
|
||||
try {
|
||||
return { session, overlay: await session.openPage(OVERLAY_PAGE) };
|
||||
} catch (error) {
|
||||
await session.dispose();
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
return started;
|
||||
}
|
||||
|
||||
after(async () => {
|
||||
const running = await started?.catch(() => undefined);
|
||||
await running?.session.dispose();
|
||||
});
|
||||
|
||||
type RenderedToken = { surface: string; headword: string | null };
|
||||
|
||||
// Parks playback inside a cue and waits for the overlay to show that line.
|
||||
async function showCue(cue: (typeof FIXTURE_CUES)[number]): Promise<RenderedToken[]> {
|
||||
const { session, overlay } = await useSession();
|
||||
await session.mpv.command('seek', cue.at, 'absolute+exact');
|
||||
// One expression reads the line and its tokens together, so a re-render
|
||||
// between two round trips cannot hand back a half-updated subtitle.
|
||||
return overlay
|
||||
.waitFor<RenderedToken[] | false>(
|
||||
`(() => {
|
||||
const root = document.getElementById('subtitleRoot');
|
||||
const words = Array.from(root.querySelectorAll('.word'), (word) => ({
|
||||
surface: word.textContent,
|
||||
headword: word.getAttribute('data-headword'),
|
||||
}));
|
||||
return root.textContent === ${JSON.stringify(cue.text)} && words.length > 0 && words;
|
||||
})()`,
|
||||
{ description: `overlay to render "${cue.text}" tokenized` },
|
||||
)
|
||||
.catch(async (error: unknown) => {
|
||||
// Say what the overlay was showing, what main thinks the line is, and
|
||||
// what the renderer logged, so a dropped update can be placed.
|
||||
const state = await overlay.evaluate<string>(
|
||||
`(async () => JSON.stringify({
|
||||
visibility: document.visibilityState,
|
||||
text: document.getElementById('subtitleRoot').textContent,
|
||||
html: document.getElementById('subtitleRoot').innerHTML.slice(0, 200),
|
||||
mainLine: (await window.electronAPI.getCurrentSubtitle())?.text,
|
||||
errorToast: document.getElementById('overlayErrorToast')?.textContent,
|
||||
}))()`,
|
||||
);
|
||||
// Diagnostics must not replace the cue failure if the inspector is gone too.
|
||||
const windows = await session
|
||||
.mainProcess()
|
||||
.then((main) =>
|
||||
main.evaluate<string>(
|
||||
`JSON.stringify(process.mainModule.require('electron').BrowserWindow.getAllWindows().map((w) => ({
|
||||
id: w.id,
|
||||
title: w.getTitle(),
|
||||
url: w.webContents.getURL().split('/').pop(),
|
||||
visible: w.isVisible(),
|
||||
loading: w.webContents.isLoading(),
|
||||
documentLoaded: w.__subminerOverlayDocumentLoaded,
|
||||
contentReady: w.__subminerOverlayContentReady,
|
||||
})))`,
|
||||
),
|
||||
)
|
||||
.catch((inspectorError: unknown) => `unavailable (${String(inspectorError)})`);
|
||||
const targets = (await listCdpTargets(session.cdpPort))
|
||||
.filter((t) => t.type === 'page')
|
||||
.map((t) => t.url.split('/').pop());
|
||||
const log = overlay.console.slice(-20).join('\n');
|
||||
throw new Error(
|
||||
`Overlay state: ${state}\nMain windows: ${windows}\nPage targets: ${JSON.stringify(targets)}\nRenderer console:\n${log}`,
|
||||
{ cause: error },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function hasToken(tokens: RenderedToken[], surface: string, headword: string): boolean {
|
||||
return tokens.some((token) => token.surface === surface && token.headword === headword);
|
||||
}
|
||||
|
||||
test(
|
||||
'overlay renders the current mpv subtitle line as dictionary tokens',
|
||||
{ timeout: TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
const tokens = await showCue(FIXTURE_CUES[0]);
|
||||
|
||||
assert.ok(hasToken(tokens, '今日', '今日'), JSON.stringify(tokens));
|
||||
assert.ok(hasToken(tokens, '天気', '天気'), JSON.stringify(tokens));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'seeking to another cue replaces the line and deinflects verbs to their headword',
|
||||
{ timeout: TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
const tokens = await showCue(FIXTURE_CUES[2]);
|
||||
|
||||
assert.ok(hasToken(tokens, '行きます', '行く'), JSON.stringify(tokens));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'mining a sentence card sends the line with generated audio and image to Anki',
|
||||
{ timeout: TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
const { session } = await useSession();
|
||||
const cue = FIXTURE_CUES[1];
|
||||
await showCue(cue);
|
||||
session.anki.requests.length = 0;
|
||||
|
||||
await session.runAppCommand('--mine-sentence');
|
||||
const update = await waitUntil(
|
||||
() => session.anki.requests.find((request) => request.action === 'updateNoteFields'),
|
||||
{ description: 'media fields to be written to the mined note', timeoutMs: 30_000 },
|
||||
);
|
||||
|
||||
const added = session.anki.requests.filter((request) => request.action === 'addNote');
|
||||
assert.equal(added.length, 1);
|
||||
assert.partialDeepStrictEqual(added[0]?.params.note, {
|
||||
deckName: FAKE_ANKI_DECK,
|
||||
modelName: FAKE_ANKI_MODEL,
|
||||
fields: { Sentence: cue.text },
|
||||
});
|
||||
|
||||
const stored = session.anki.requests
|
||||
.filter((request) => request.action === 'storeMediaFile')
|
||||
.map((request) => String(request.params.filename));
|
||||
assert.ok(
|
||||
stored.some((name) => name.endsWith('.mp3')),
|
||||
`no audio in ${stored.join(', ')}`,
|
||||
);
|
||||
assert.ok(
|
||||
stored.some((name) => name.endsWith('.jpg')),
|
||||
`no image in ${stored.join(', ')}`,
|
||||
);
|
||||
|
||||
const updatedFields = JSON.stringify(update.params);
|
||||
for (const filename of stored) assert.ok(updatedFields.includes(filename), filename);
|
||||
},
|
||||
);
|
||||
|
||||
// Runs last: it leaves the lookup popup open over the overlay.
|
||||
test(
|
||||
'holding Shift over a word opens the Yomitan popup with its dictionary entry',
|
||||
{ timeout: TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
const { session, overlay } = await useSession();
|
||||
await showCue(FIXTURE_CUES[0]);
|
||||
const word = await overlay.evaluate<{ x: number; y: number }>(
|
||||
`(() => {
|
||||
const words = Array.from(document.querySelectorAll('#subtitleRoot .word'));
|
||||
const box = words.find((word) => word.textContent === '天気').getBoundingClientRect();
|
||||
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
||||
})()`,
|
||||
);
|
||||
|
||||
// Yomitan's default scan modifier is Shift, and it scans on mouse movement,
|
||||
// so approach the word, hold Shift, then move across it.
|
||||
const shift = { key: 'Shift', code: 'ShiftLeft', windowsVirtualKeyCode: 16 };
|
||||
const move = (x: number, modifiers = 0) =>
|
||||
overlay.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y: word.y, modifiers });
|
||||
await move(word.x - 30);
|
||||
await overlay.send('Input.dispatchKeyEvent', { type: 'rawKeyDown', ...shift, modifiers: 8 });
|
||||
await move(word.x - 2, 8);
|
||||
await move(word.x, 8);
|
||||
|
||||
await overlay.waitFor(
|
||||
`document.querySelector('[data-subminer-yomitan-popup-visible="true"]') !== null`,
|
||||
{ description: 'overlay to mark the Yomitan popup visible' },
|
||||
);
|
||||
// Yomitan keeps more than one popup frame around, so read whichever
|
||||
// one holds the entry instead of trusting target order.
|
||||
let seen: string[] = [];
|
||||
const entry = await waitUntil(
|
||||
async () => {
|
||||
const targets = (await listCdpTargets(session.cdpPort)).filter((target) =>
|
||||
target.url.includes('popup.html'),
|
||||
);
|
||||
seen = [];
|
||||
for (const target of targets) {
|
||||
// A frame can go away between listing and connecting; skip it.
|
||||
const frame = await connectCdpPage(target).catch(() => undefined);
|
||||
if (!frame) continue;
|
||||
// textContent, not innerText: the entry counts once it is in the DOM,
|
||||
// even where a GPU-less runner never lays the frame out.
|
||||
const text = await frame.evaluate<string>('document.body?.textContent ?? ""');
|
||||
frame.close();
|
||||
seen.push(text);
|
||||
}
|
||||
return seen.find((text) => text.includes('weather'));
|
||||
},
|
||||
{ description: 'a popup frame to show the fixture dictionary entry' },
|
||||
).catch((error: unknown) => {
|
||||
const summary = seen.map((text) => text.replace(/\s+/g, ' ').trim().slice(0, 200));
|
||||
throw new Error(`Popup frames held: ${JSON.stringify(summary)}`, { cause: error });
|
||||
});
|
||||
await overlay.send('Input.dispatchKeyEvent', { type: 'keyUp', ...shift });
|
||||
assert.match(entry, /天気/);
|
||||
},
|
||||
);
|
||||
@@ -150,74 +150,7 @@ test('doctor command exits non-zero for missing hard dependencies', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('doctor command forwards refresh-known-words to app binary', () => {
|
||||
const context = createContext();
|
||||
context.args.doctor = true;
|
||||
context.args.doctorRefreshKnownWords = true;
|
||||
const forwarded: string[][] = [];
|
||||
|
||||
const handled = runDoctorCommand(context, {
|
||||
commandExists: () => false,
|
||||
configExists: () => true,
|
||||
resolveMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
|
||||
runAppCommandWithInherit: (_appPath, appArgs) => {
|
||||
forwarded.push(appArgs);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(forwarded, [['--refresh-known-words']]);
|
||||
});
|
||||
|
||||
test('logs command exports logs and writes archive path', () => {
|
||||
const writes: string[] = [];
|
||||
const context = createContext();
|
||||
context.args.logsExport = true;
|
||||
context.processAdapter = {
|
||||
...context.processAdapter,
|
||||
writeStdout: (text) => writes.push(text),
|
||||
};
|
||||
|
||||
const handled = runLogsCommand(context, {
|
||||
exportLogsArchive: () => ({
|
||||
zipPath: '/tmp/subminer-logs.zip',
|
||||
exportedFiles: ['/tmp/app.log'],
|
||||
mode: 'current-day',
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(writes, ['/tmp/subminer-logs.zip\n']);
|
||||
});
|
||||
|
||||
test('logs command ignores unrelated launcher commands', () => {
|
||||
const context = createContext();
|
||||
|
||||
assert.equal(runLogsCommand(context), false);
|
||||
});
|
||||
|
||||
test('app command starts default macOS background app detached from launcher', () => {
|
||||
const context = createContext();
|
||||
context.args.appPassthrough = true;
|
||||
context.args.appArgs = [];
|
||||
const calls: string[] = [];
|
||||
|
||||
const handled = runAppPassthroughCommand(context, {
|
||||
runAppCommandWithInherit: () => {
|
||||
calls.push('attached');
|
||||
},
|
||||
launchSyncUiDetached: () => calls.push('sync-ui'),
|
||||
launchYoutubeBrowserDetached: () => calls.push('youtube'),
|
||||
launchAppBackgroundDetached: (appPath, logLevel) => {
|
||||
calls.push(`detached:${appPath}:${logLevel}`);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(calls, ['detached:/tmp/subminer.app:warn']);
|
||||
});
|
||||
|
||||
test('app command starts default Linux background app detached from launcher', () => {
|
||||
test('app command starts default background app detached from launcher app detached from launcher', () => {
|
||||
const context = createContext();
|
||||
context.args.appPassthrough = true;
|
||||
context.args.appArgs = [];
|
||||
@@ -389,49 +322,6 @@ test('dictionary command forwards candidate and selection modes to app binary',
|
||||
]);
|
||||
});
|
||||
|
||||
test('dictionary command returns after app handoff starts', () => {
|
||||
const context = createContext();
|
||||
context.args.dictionary = true;
|
||||
|
||||
const handled = runDictionaryCommand(context, {
|
||||
runAppCommandWithInherit: () => undefined,
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
});
|
||||
|
||||
test('update command runs direct Linux release update without launching Electron', async () => {
|
||||
const context = createContext();
|
||||
context.args.update = true;
|
||||
const calls: string[] = [];
|
||||
|
||||
const handled = await runUpdateCommand(context, {
|
||||
runAppCommandCaptureOutput: () => {
|
||||
throw new Error('unexpected Electron launch');
|
||||
},
|
||||
runDirectReleaseUpdate: async (request) => {
|
||||
calls.push(`direct:${request.appPath}:${request.launcherPath}:${request.channel}`);
|
||||
return {
|
||||
appImage: { status: 'not-found' },
|
||||
launcher: { status: 'updated' },
|
||||
supportAssets: [{ status: 'skipped' }],
|
||||
};
|
||||
},
|
||||
readMainConfig: () => null,
|
||||
log: (level, _configured, message) => {
|
||||
calls.push(`${level}:${message}`);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(calls, [
|
||||
'direct:/tmp/subminer.app:/tmp/subminer:stable',
|
||||
'info:AppImage update: not-found',
|
||||
'info:Launcher update: updated',
|
||||
'info:Support assets update: skipped',
|
||||
]);
|
||||
});
|
||||
|
||||
test('stats command launches attached app command with response path', async () => {
|
||||
const harness = createStatsTestHarness({ stats: true, logLevel: 'debug' });
|
||||
const handled = await runStatsCommand(harness.context, harness.commandDeps);
|
||||
@@ -449,16 +339,41 @@ test('stats command launches attached app command with response path', async ()
|
||||
assert.equal(harness.removedPaths.length, 1);
|
||||
});
|
||||
|
||||
test('stats background command launches attached daemon control command with response path', async () => {
|
||||
const harness = createStatsTestHarness({ stats: true, statsBackground: true });
|
||||
const handled = await runStatsCommand(harness.context, harness.commandDeps);
|
||||
const STATS_RESPONSE_ARGS = ['--stats-response-path', '/tmp/subminer-stats-test/response.json'];
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(harness.forwarded, [
|
||||
['--stats-daemon-start', '--stats-response-path', '/tmp/subminer-stats-test/response.json'],
|
||||
]);
|
||||
assert.equal(harness.removedPaths.length, 1);
|
||||
});
|
||||
const statsForwardingCases = [
|
||||
{
|
||||
name: 'stats background command launches the daemon start command',
|
||||
overrides: { statsBackground: true },
|
||||
expected: ['--stats-daemon-start', ...STATS_RESPONSE_ARGS],
|
||||
},
|
||||
{
|
||||
name: 'stats stop command forwards the daemon stop flag',
|
||||
overrides: { statsStop: true },
|
||||
expected: ['--stats-daemon-stop', ...STATS_RESPONSE_ARGS],
|
||||
},
|
||||
{
|
||||
name: 'stats cleanup command forwards cleanup vocab flags',
|
||||
overrides: { statsCleanup: true, statsCleanupVocab: true },
|
||||
expected: ['--stats', ...STATS_RESPONSE_ARGS, '--stats-cleanup', '--stats-cleanup-vocab'],
|
||||
},
|
||||
{
|
||||
name: 'stats cleanup command forwards the lifetime rebuild flag',
|
||||
overrides: { statsCleanup: true, statsCleanupLifetime: true },
|
||||
expected: ['--stats', ...STATS_RESPONSE_ARGS, '--stats-cleanup', '--stats-cleanup-lifetime'],
|
||||
},
|
||||
];
|
||||
|
||||
for (const c of statsForwardingCases) {
|
||||
test(`${c.name} to the app`, async () => {
|
||||
const harness = createStatsTestHarness({ stats: true, ...c.overrides });
|
||||
const handled = await runStatsCommand(harness.context, harness.commandDeps);
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(harness.forwarded, [c.expected]);
|
||||
assert.equal(harness.removedPaths.length, 1);
|
||||
});
|
||||
}
|
||||
|
||||
test('stats command waits for attached app exit after startup response', async () => {
|
||||
const harness = createStatsTestHarness({ stats: true });
|
||||
@@ -503,45 +418,6 @@ test('stats command throws when attached app exits non-zero after startup respon
|
||||
assert.equal(harness.removedPaths.length, 1);
|
||||
});
|
||||
|
||||
test('stats cleanup command forwards cleanup vocab flags to the app', async () => {
|
||||
const harness = createStatsTestHarness({
|
||||
stats: true,
|
||||
statsCleanup: true,
|
||||
statsCleanupVocab: true,
|
||||
});
|
||||
const handled = await runStatsCommand(harness.context, {
|
||||
...harness.commandDeps,
|
||||
waitForStatsResponse: async () => ({ ok: true }),
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(harness.forwarded, [
|
||||
[
|
||||
'--stats',
|
||||
'--stats-response-path',
|
||||
'/tmp/subminer-stats-test/response.json',
|
||||
'--stats-cleanup',
|
||||
'--stats-cleanup-vocab',
|
||||
],
|
||||
]);
|
||||
assert.equal(harness.removedPaths.length, 1);
|
||||
});
|
||||
|
||||
test('stats stop command forwards stop flag to the app', async () => {
|
||||
const harness = createStatsTestHarness({ stats: true, statsStop: true });
|
||||
|
||||
const handled = await runStatsCommand(harness.context, {
|
||||
...harness.commandDeps,
|
||||
waitForStatsResponse: async () => ({ ok: true }),
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(harness.forwarded, [
|
||||
['--stats-daemon-stop', '--stats-response-path', '/tmp/subminer-stats-test/response.json'],
|
||||
]);
|
||||
assert.equal(harness.removedPaths.length, 1);
|
||||
});
|
||||
|
||||
test('stats stop command exits on process exit without waiting for startup response', async () => {
|
||||
const harness = createStatsTestHarness({ stats: true, statsStop: true });
|
||||
let waitedForResponse = false;
|
||||
@@ -563,30 +439,6 @@ test('stats stop command exits on process exit without waiting for startup respo
|
||||
assert.equal(harness.removedPaths.length, 1);
|
||||
});
|
||||
|
||||
test('stats cleanup command forwards lifetime rebuild flag to the app', async () => {
|
||||
const harness = createStatsTestHarness({
|
||||
stats: true,
|
||||
statsCleanup: true,
|
||||
statsCleanupLifetime: true,
|
||||
});
|
||||
const handled = await runStatsCommand(harness.context, {
|
||||
...harness.commandDeps,
|
||||
waitForStatsResponse: async () => ({ ok: true }),
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(harness.forwarded, [
|
||||
[
|
||||
'--stats',
|
||||
'--stats-response-path',
|
||||
'/tmp/subminer-stats-test/response.json',
|
||||
'--stats-cleanup',
|
||||
'--stats-cleanup-lifetime',
|
||||
],
|
||||
]);
|
||||
assert.equal(harness.removedPaths.length, 1);
|
||||
});
|
||||
|
||||
test('stats command throws when stats response reports an error', async () => {
|
||||
const harness = createStatsTestHarness({ stats: true });
|
||||
|
||||
@@ -692,36 +544,3 @@ test('stats command aborts pending response wait when attached app fails to spaw
|
||||
assert.equal(aborted, true);
|
||||
assert.equal(harness.removedPaths.length, 1);
|
||||
});
|
||||
|
||||
test('stats cleanup command aborts pending response wait when app exits before startup response', async () => {
|
||||
const harness = createStatsTestHarness({
|
||||
stats: true,
|
||||
statsCleanup: true,
|
||||
statsCleanupVocab: true,
|
||||
});
|
||||
let aborted = false;
|
||||
|
||||
await assert.rejects(async () => {
|
||||
await runStatsCommand(harness.context, {
|
||||
...harness.commandDeps,
|
||||
runAppCommandAttached: async (...args) => {
|
||||
await harness.runAppCommandAttachedStub(...args);
|
||||
return 2;
|
||||
},
|
||||
waitForStatsResponse: async (_responsePath, signal) =>
|
||||
await new Promise((resolve) => {
|
||||
signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
aborted = true;
|
||||
resolve({ ok: false, error: 'aborted' });
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
});
|
||||
}, /Stats app exited before startup response \(status 2\)\./);
|
||||
|
||||
assert.equal(aborted, true);
|
||||
assert.equal(harness.removedPaths.length, 1);
|
||||
});
|
||||
@@ -4,81 +4,53 @@ import { EventEmitter } from 'node:events';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { Args } from '../types.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
import { registerCleanup, runPlaybackCommandWithDeps } from './playback-command.js';
|
||||
import { state } from '../mpv.js';
|
||||
import { state, type startMpv } from '../mpv.js';
|
||||
import { makeLauncherArgs } from '../test-support/args.js';
|
||||
import { withEnv } from '../test-support/env.js';
|
||||
|
||||
function createContext(): LauncherCommandContext {
|
||||
type PlaybackDeps = Parameters<typeof runPlaybackCommandWithDeps>[1];
|
||||
type StartMpvOptions = NonNullable<Parameters<typeof startMpv>[6]>;
|
||||
|
||||
const YOUTUBE_URL = 'https://www.youtube.com/watch?v=65Ovd7t8sNw';
|
||||
|
||||
function makePluginConfig(
|
||||
overrides: Partial<LauncherCommandContext['pluginRuntimeConfig']> = {},
|
||||
): LauncherCommandContext['pluginRuntimeConfig'] {
|
||||
return {
|
||||
args: {
|
||||
backend: 'auto',
|
||||
directory: '.',
|
||||
recursive: false,
|
||||
profile: '',
|
||||
startOverlay: false,
|
||||
youtubePrimarySubLangs: [],
|
||||
youtubeSecondarySubLangs: [],
|
||||
youtubeAudioLangs: [],
|
||||
useTexthooker: false,
|
||||
autoStartOverlay: false,
|
||||
texthookerOnly: false,
|
||||
texthookerOpenBrowser: false,
|
||||
useRofi: false,
|
||||
history: false,
|
||||
sync: false,
|
||||
syncCliTokens: [],
|
||||
syncUi: false,
|
||||
logLevel: 'info',
|
||||
logRotation: 7,
|
||||
passwordStore: '',
|
||||
target: 'https://www.youtube.com/watch?v=65Ovd7t8sNw',
|
||||
socketPath: '/tmp/subminer.sock',
|
||||
binaryPath: '',
|
||||
backend: 'auto',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
osdMessages: false,
|
||||
texthookerEnabled: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** A context for a YouTube URL target with logging at info and CLI texthooker off. */
|
||||
function createContext(
|
||||
options: {
|
||||
args?: Partial<Args>;
|
||||
plugin?: Partial<LauncherCommandContext['pluginRuntimeConfig']>;
|
||||
} = {},
|
||||
): LauncherCommandContext {
|
||||
return {
|
||||
args: makeLauncherArgs({
|
||||
target: YOUTUBE_URL,
|
||||
targetKind: 'url',
|
||||
jimakuApiKey: '',
|
||||
jimakuApiKeyCommand: '',
|
||||
jimakuApiBaseUrl: '',
|
||||
jimakuLanguagePreference: 'ja',
|
||||
jimakuMaxEntryResults: 20,
|
||||
jellyfin: false,
|
||||
jellyfinLogin: false,
|
||||
jellyfinLogout: false,
|
||||
jellyfinPlay: false,
|
||||
jellyfinDiscovery: false,
|
||||
dictionary: false,
|
||||
dictionaryCandidates: false,
|
||||
dictionarySelect: false,
|
||||
stats: false,
|
||||
doctor: false,
|
||||
doctorRefreshKnownWords: false,
|
||||
logsExport: false,
|
||||
version: false,
|
||||
settings: false,
|
||||
youtubeBrowser: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
mpvIdle: false,
|
||||
mpvSocket: false,
|
||||
mpvStatus: false,
|
||||
mpvArgs: '',
|
||||
appPassthrough: false,
|
||||
appArgs: [],
|
||||
jellyfinServer: '',
|
||||
jellyfinUsername: '',
|
||||
jellyfinPassword: '',
|
||||
launchMode: 'normal',
|
||||
},
|
||||
logLevel: 'info',
|
||||
useTexthooker: false,
|
||||
...options.args,
|
||||
}),
|
||||
scriptPath: '/tmp/subminer',
|
||||
scriptName: 'subminer',
|
||||
mpvSocketPath: '/tmp/subminer.sock',
|
||||
pluginRuntimeConfig: {
|
||||
socketPath: '/tmp/subminer.sock',
|
||||
binaryPath: '',
|
||||
backend: 'auto',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
osdMessages: false,
|
||||
texthookerEnabled: false,
|
||||
},
|
||||
pluginRuntimeConfig: makePluginConfig(options.plugin),
|
||||
appPath: '/tmp/SubMiner.AppImage',
|
||||
launcherJellyfinConfig: {},
|
||||
processAdapter: {
|
||||
@@ -93,8 +65,70 @@ function createContext(): LauncherCommandContext {
|
||||
};
|
||||
}
|
||||
|
||||
const FILE_TARGET = { target: '/tmp/movie.mkv', targetKind: 'file' } as const;
|
||||
|
||||
/**
|
||||
* Playback harness. Deps are inert by default and record what the tests assert on: the
|
||||
* options mpv was started with, the overlay launches, and an `events` log for the few cases
|
||||
* where relative order is the behavior.
|
||||
*/
|
||||
function makeHarness(
|
||||
options: {
|
||||
context?: LauncherCommandContext;
|
||||
deps?: Partial<PlaybackDeps>;
|
||||
events?: string[];
|
||||
} = {},
|
||||
) {
|
||||
const context = options.context ?? createContext();
|
||||
const events = options.events ?? [];
|
||||
const startMpvOptions: StartMpvOptions[] = [];
|
||||
const overlayCalls: Array<{ extraAppArgs: string[]; configDir?: string }> = [];
|
||||
const deps: PlaybackDeps = {
|
||||
ensurePlaybackSetupReady: async () => {},
|
||||
ensureRuntimePluginReady: async () => {
|
||||
events.push('plugin');
|
||||
},
|
||||
chooseTarget: async () => ({
|
||||
target: context.args.target,
|
||||
kind: context.args.targetKind === 'file' ? 'file' : 'url',
|
||||
}),
|
||||
checkDependencies: () => {},
|
||||
registerCleanup: () => {},
|
||||
startMpv: async (_target, _kind, _args, _socket, _appPath, _subtitles, startOptions) => {
|
||||
events.push('startMpv');
|
||||
if (startOptions) startMpvOptions.push(startOptions);
|
||||
},
|
||||
waitForUnixSocketReady: async () => true,
|
||||
startOverlay: async (_appPath, _args, _socket, extraAppArgs = [], configDir) => {
|
||||
events.push('startOverlay');
|
||||
overlayCalls.push({ extraAppArgs, configDir });
|
||||
},
|
||||
launchAppCommandDetached: () => {},
|
||||
log: () => {},
|
||||
cleanupPlaybackSession: async () => {},
|
||||
getMpvProc: () => null,
|
||||
...options.deps,
|
||||
};
|
||||
return {
|
||||
context,
|
||||
events,
|
||||
startMpvOptions,
|
||||
overlayCalls,
|
||||
run: () => runPlaybackCommandWithDeps(context, deps),
|
||||
};
|
||||
}
|
||||
|
||||
function assertRanInOrder(events: string[], ...expected: string[]): void {
|
||||
const positions = expected.map((event) => events.indexOf(event));
|
||||
assert.ok(
|
||||
positions.every(
|
||||
(position, index) => position >= 0 && (index === 0 || position > positions[index - 1]!),
|
||||
),
|
||||
`expected ${expected.join(' -> ')} in order, got ${events.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
test('playback cleanup signal handlers are registered once across repeated sessions', () => {
|
||||
assert.equal(typeof registerCleanup, 'function', 'cleanup registration is not exported');
|
||||
const context = createContext();
|
||||
const registeredSignals: NodeJS.Signals[] = [];
|
||||
context.processAdapter.onSignal = (signal) => {
|
||||
@@ -108,121 +142,41 @@ test('playback cleanup signal handlers are registered once across repeated sessi
|
||||
});
|
||||
|
||||
test('youtube playback launches overlay with app-owned youtube flow args', async () => {
|
||||
const calls: string[] = [];
|
||||
const context = createContext();
|
||||
context.pluginRuntimeConfig = {
|
||||
...context.pluginRuntimeConfig,
|
||||
autoStart: false,
|
||||
autoStartVisibleOverlay: false,
|
||||
autoStartPauseUntilReady: false,
|
||||
};
|
||||
const receivedStartMpvOptions: Record<string, unknown>[] = [];
|
||||
|
||||
await runPlaybackCommandWithDeps(context, {
|
||||
ensurePlaybackSetupReady: async () => {},
|
||||
ensureRuntimePluginReady: async () => {},
|
||||
chooseTarget: async (_args, _scriptPath) => ({ target: context.args.target, kind: 'url' }),
|
||||
checkDependencies: () => {},
|
||||
registerCleanup: () => {},
|
||||
startMpv: async (
|
||||
_target,
|
||||
_targetKind,
|
||||
_args,
|
||||
_socketPath,
|
||||
_appPath,
|
||||
_preloadedSubtitles,
|
||||
options,
|
||||
) => {
|
||||
if (options) {
|
||||
receivedStartMpvOptions.push(options as Record<string, unknown>);
|
||||
}
|
||||
calls.push('startMpv');
|
||||
},
|
||||
waitForUnixSocketReady: async () => true,
|
||||
startOverlay: async (_appPath, _args, _socketPath, extraAppArgs = []) => {
|
||||
calls.push(`startOverlay:${extraAppArgs.join(' ')}`);
|
||||
},
|
||||
launchAppCommandDetached: (_appPath: string, appArgs: string[]) => {
|
||||
calls.push(`launch:${appArgs.join(' ')}`);
|
||||
},
|
||||
log: () => {},
|
||||
cleanupPlaybackSession: async () => {},
|
||||
getMpvProc: () => null,
|
||||
const harness = makeHarness({
|
||||
context: createContext({
|
||||
plugin: {
|
||||
autoStart: false,
|
||||
autoStartVisibleOverlay: false,
|
||||
autoStartPauseUntilReady: false,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'startMpv',
|
||||
'startOverlay:--youtube-play https://www.youtube.com/watch?v=65Ovd7t8sNw',
|
||||
]);
|
||||
assert.equal(receivedStartMpvOptions[0]?.startPaused, true);
|
||||
assert.equal(receivedStartMpvOptions[0]?.disableYoutubeSubtitleAutoLoad, true);
|
||||
await harness.run();
|
||||
|
||||
assertRanInOrder(harness.events, 'startMpv', 'startOverlay');
|
||||
assert.deepEqual(harness.overlayCalls[0]?.extraAppArgs, ['--youtube-play', YOUTUBE_URL]);
|
||||
assert.equal(harness.startMpvOptions[0]?.startPaused, true);
|
||||
assert.equal(harness.startMpvOptions[0]?.disableYoutubeSubtitleAutoLoad, true);
|
||||
});
|
||||
|
||||
test('youtube app-owned playback disables mpv plugin auto-start', async () => {
|
||||
const context = createContext();
|
||||
context.pluginRuntimeConfig = {
|
||||
...context.pluginRuntimeConfig,
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
};
|
||||
const receivedStartMpvOptions: Record<string, unknown>[] = [];
|
||||
const harness = makeHarness();
|
||||
|
||||
await runPlaybackCommandWithDeps(context, {
|
||||
ensurePlaybackSetupReady: async () => {},
|
||||
ensureRuntimePluginReady: async () => {},
|
||||
chooseTarget: async () => ({ target: context.args.target, kind: 'url' }),
|
||||
checkDependencies: () => {},
|
||||
registerCleanup: () => {},
|
||||
startMpv: async (
|
||||
_target,
|
||||
_targetKind,
|
||||
_args,
|
||||
_socketPath,
|
||||
_appPath,
|
||||
_preloadedSubtitles,
|
||||
options,
|
||||
) => {
|
||||
if (options) {
|
||||
receivedStartMpvOptions.push(options as Record<string, unknown>);
|
||||
}
|
||||
},
|
||||
waitForUnixSocketReady: async () => true,
|
||||
startOverlay: async () => {},
|
||||
launchAppCommandDetached: () => {},
|
||||
log: () => {},
|
||||
cleanupPlaybackSession: async () => {},
|
||||
getMpvProc: () => null,
|
||||
});
|
||||
await harness.run();
|
||||
|
||||
const runtimeConfig = receivedStartMpvOptions[0]?.runtimePluginConfig as
|
||||
| { autoStart?: boolean; autoStartVisibleOverlay?: boolean; autoStartPauseUntilReady?: boolean }
|
||||
| undefined;
|
||||
const runtimeConfig = harness.startMpvOptions[0]?.runtimePluginConfig;
|
||||
assert.equal(runtimeConfig?.autoStart, false);
|
||||
assert.equal(runtimeConfig?.autoStartVisibleOverlay, false);
|
||||
assert.equal(runtimeConfig?.autoStartPauseUntilReady, false);
|
||||
});
|
||||
|
||||
test('plugin auto-start playback leaves app lifetime to managed-playback owner', async () => {
|
||||
const context = createContext();
|
||||
context.args = {
|
||||
...context.args,
|
||||
target: '/tmp/movie.mkv',
|
||||
targetKind: 'file',
|
||||
useTexthooker: true,
|
||||
};
|
||||
context.pluginRuntimeConfig = {
|
||||
socketPath: '/tmp/subminer.sock',
|
||||
binaryPath: '',
|
||||
backend: 'auto',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: false,
|
||||
autoStartPauseUntilReady: false,
|
||||
osdMessages: false,
|
||||
texthookerEnabled: false,
|
||||
};
|
||||
const appPath = context.appPath ?? '';
|
||||
state.appPath = appPath;
|
||||
const context = createContext({
|
||||
args: { ...FILE_TARGET, useTexthooker: true },
|
||||
plugin: { autoStartVisibleOverlay: false, autoStartPauseUntilReady: false },
|
||||
});
|
||||
state.appPath = context.appPath ?? '';
|
||||
state.overlayManagedByLauncher = false;
|
||||
const mpvProc = new EventEmitter() as EventEmitter & {
|
||||
exitCode: number | null;
|
||||
@@ -233,31 +187,27 @@ test('plugin auto-start playback leaves app lifetime to managed-playback owner',
|
||||
mpvProc.killed = false;
|
||||
mpvProc.kill = () => true;
|
||||
let cleanupSawManagedOverlay = true;
|
||||
|
||||
try {
|
||||
await runPlaybackCommandWithDeps(context, {
|
||||
ensurePlaybackSetupReady: async () => {},
|
||||
ensureRuntimePluginReady: async () => {},
|
||||
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
||||
checkDependencies: () => {},
|
||||
registerCleanup: () => {},
|
||||
const harness = makeHarness({
|
||||
context,
|
||||
deps: {
|
||||
startMpv: async () => {
|
||||
setTimeout(() => {
|
||||
mpvProc.exitCode = 0;
|
||||
mpvProc.emit('exit', 0);
|
||||
}, 5);
|
||||
},
|
||||
waitForUnixSocketReady: async () => true,
|
||||
startOverlay: async () => {
|
||||
throw new Error('startOverlay should not run when plugin auto-start is used');
|
||||
},
|
||||
launchAppCommandDetached: () => {},
|
||||
log: () => {},
|
||||
cleanupPlaybackSession: async () => {
|
||||
cleanupSawManagedOverlay = state.overlayManagedByLauncher;
|
||||
},
|
||||
getMpvProc: () => mpvProc as NonNullable<typeof state.mpvProc>,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await harness.run();
|
||||
|
||||
assert.equal(cleanupSawManagedOverlay, false);
|
||||
} finally {
|
||||
@@ -267,258 +217,89 @@ test('plugin auto-start playback leaves app lifetime to managed-playback owner',
|
||||
});
|
||||
|
||||
test('plugin auto-start playback attaches a warm background app through the launcher', async () => {
|
||||
const context = createContext();
|
||||
context.args = {
|
||||
...context.args,
|
||||
target: '/tmp/movie.mkv',
|
||||
targetKind: 'file',
|
||||
useTexthooker: true,
|
||||
};
|
||||
context.pluginRuntimeConfig = {
|
||||
socketPath: '/tmp/subminer.sock',
|
||||
binaryPath: '',
|
||||
backend: 'auto',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
osdMessages: false,
|
||||
texthookerEnabled: true,
|
||||
};
|
||||
const calls: string[] = [];
|
||||
const receivedStartMpvOptions: Record<string, unknown>[] = [];
|
||||
|
||||
await runPlaybackCommandWithDeps(context, {
|
||||
ensurePlaybackSetupReady: async () => {},
|
||||
ensureRuntimePluginReady: async () => {},
|
||||
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
||||
checkDependencies: () => {},
|
||||
registerCleanup: () => {},
|
||||
startMpv: async (
|
||||
_target,
|
||||
_targetKind,
|
||||
_args,
|
||||
_socketPath,
|
||||
_appPath,
|
||||
_preloadedSubtitles,
|
||||
options,
|
||||
) => {
|
||||
calls.push('startMpv');
|
||||
if (options) {
|
||||
receivedStartMpvOptions.push(options as Record<string, unknown>);
|
||||
}
|
||||
},
|
||||
waitForUnixSocketReady: async () => true,
|
||||
startOverlay: async (_appPath, _args, _socketPath, extraAppArgs = []) => {
|
||||
calls.push(`startOverlay:${extraAppArgs.join(' ')}`);
|
||||
},
|
||||
launchAppCommandDetached: () => {},
|
||||
log: () => {},
|
||||
cleanupPlaybackSession: async () => {},
|
||||
getMpvProc: () => null,
|
||||
isAppControlServerAvailable: async () => true,
|
||||
} as Parameters<typeof runPlaybackCommandWithDeps>[1] & {
|
||||
isAppControlServerAvailable: () => Promise<boolean>;
|
||||
const harness = makeHarness({
|
||||
context: createContext({
|
||||
args: { ...FILE_TARGET, useTexthooker: true },
|
||||
plugin: { texthookerEnabled: true },
|
||||
}),
|
||||
deps: { isAppControlServerAvailable: async () => true },
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['startMpv', 'startOverlay:--show-visible-overlay --texthooker']);
|
||||
assert.equal(receivedStartMpvOptions[0]?.startPaused, true);
|
||||
assert.equal(
|
||||
(receivedStartMpvOptions[0]?.runtimePluginConfig as { autoStart?: boolean } | undefined)
|
||||
?.autoStart,
|
||||
false,
|
||||
);
|
||||
await harness.run();
|
||||
|
||||
assertRanInOrder(harness.events, 'startMpv', 'startOverlay');
|
||||
assert.deepEqual(harness.overlayCalls[0]?.extraAppArgs, [
|
||||
'--show-visible-overlay',
|
||||
'--texthooker',
|
||||
]);
|
||||
assert.equal(harness.startMpvOptions[0]?.startPaused, true);
|
||||
assert.equal(harness.startMpvOptions[0]?.runtimePluginConfig?.autoStart, false);
|
||||
});
|
||||
|
||||
test('plugin auto-start attach mode reuses launcher-resolved config dir for app control', async () => {
|
||||
const context = createContext();
|
||||
const originalXdgConfigHome = process.env.XDG_CONFIG_HOME;
|
||||
const originalAppData = process.env.APPDATA;
|
||||
const xdgConfigHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-xdg-'));
|
||||
const expectedConfigDir = path.join(xdgConfigHome, 'SubMiner');
|
||||
fs.mkdirSync(expectedConfigDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(expectedConfigDir, 'config.jsonc'), '{}');
|
||||
context.args = {
|
||||
...context.args,
|
||||
target: '/tmp/movie.mkv',
|
||||
targetKind: 'file',
|
||||
useTexthooker: true,
|
||||
};
|
||||
context.pluginRuntimeConfig = {
|
||||
socketPath: '/tmp/subminer.sock',
|
||||
binaryPath: '',
|
||||
backend: 'auto',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
osdMessages: false,
|
||||
texthookerEnabled: true,
|
||||
};
|
||||
let availabilityConfigDir: string | undefined;
|
||||
let overlayConfigDir: string | undefined;
|
||||
let overlayLoadingOsd: boolean | undefined;
|
||||
|
||||
try {
|
||||
process.env.XDG_CONFIG_HOME = xdgConfigHome;
|
||||
process.env.APPDATA = xdgConfigHome;
|
||||
|
||||
await runPlaybackCommandWithDeps(context, {
|
||||
ensurePlaybackSetupReady: async () => {},
|
||||
ensureRuntimePluginReady: async () => {},
|
||||
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
||||
checkDependencies: () => {},
|
||||
registerCleanup: () => {},
|
||||
startMpv: async (
|
||||
_target,
|
||||
_targetKind,
|
||||
_args,
|
||||
_socketPath,
|
||||
_appPath,
|
||||
_preloadedSubtitles,
|
||||
options,
|
||||
) => {
|
||||
overlayLoadingOsd = (
|
||||
options?.runtimePluginConfig as { overlayLoadingOsd?: boolean } | undefined
|
||||
)?.overlayLoadingOsd;
|
||||
},
|
||||
waitForUnixSocketReady: async () => true,
|
||||
startOverlay: async (_appPath, _args, _socketPath, _extraAppArgs = [], configDir) => {
|
||||
overlayConfigDir = configDir;
|
||||
},
|
||||
launchAppCommandDetached: () => {},
|
||||
log: () => {},
|
||||
cleanupPlaybackSession: async () => {},
|
||||
getMpvProc: () => null,
|
||||
const harness = makeHarness({
|
||||
context: createContext({
|
||||
args: { ...FILE_TARGET, useTexthooker: true },
|
||||
plugin: { texthookerEnabled: true },
|
||||
}),
|
||||
deps: {
|
||||
isAppControlServerAvailable: async (_logLevel, configDir) => {
|
||||
availabilityConfigDir = configDir;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await withEnv({ XDG_CONFIG_HOME: xdgConfigHome, APPDATA: xdgConfigHome }, () => harness.run());
|
||||
|
||||
assert.equal(availabilityConfigDir, expectedConfigDir);
|
||||
assert.equal(overlayConfigDir, expectedConfigDir);
|
||||
assert.equal(overlayLoadingOsd, true);
|
||||
assert.equal(harness.overlayCalls[0]?.configDir, expectedConfigDir);
|
||||
assert.equal(harness.startMpvOptions[0]?.runtimePluginConfig?.overlayLoadingOsd, true);
|
||||
} finally {
|
||||
if (originalXdgConfigHome === undefined) {
|
||||
delete process.env.XDG_CONFIG_HOME;
|
||||
} else {
|
||||
process.env.XDG_CONFIG_HOME = originalXdgConfigHome;
|
||||
}
|
||||
if (originalAppData === undefined) {
|
||||
delete process.env.APPDATA;
|
||||
} else {
|
||||
process.env.APPDATA = originalAppData;
|
||||
}
|
||||
fs.rmSync(xdgConfigHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('plugin auto-start attach mode omits texthooker flag when CLI texthooker is disabled', async () => {
|
||||
const context = createContext();
|
||||
context.args = {
|
||||
...context.args,
|
||||
target: '/tmp/movie.mkv',
|
||||
targetKind: 'file',
|
||||
};
|
||||
context.pluginRuntimeConfig = {
|
||||
socketPath: '/tmp/subminer.sock',
|
||||
binaryPath: '',
|
||||
backend: 'auto',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
osdMessages: false,
|
||||
texthookerEnabled: true,
|
||||
};
|
||||
const calls: string[] = [];
|
||||
|
||||
await runPlaybackCommandWithDeps(context, {
|
||||
ensurePlaybackSetupReady: async () => {},
|
||||
ensureRuntimePluginReady: async () => {},
|
||||
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
||||
checkDependencies: () => {},
|
||||
registerCleanup: () => {},
|
||||
startMpv: async () => {
|
||||
calls.push('startMpv');
|
||||
},
|
||||
waitForUnixSocketReady: async () => true,
|
||||
startOverlay: async (_appPath, _args, _socketPath, extraAppArgs = []) => {
|
||||
calls.push(`startOverlay:${extraAppArgs.join(' ')}`);
|
||||
},
|
||||
launchAppCommandDetached: () => {},
|
||||
log: () => {},
|
||||
cleanupPlaybackSession: async () => {},
|
||||
getMpvProc: () => null,
|
||||
isAppControlServerAvailable: async () => true,
|
||||
} as Parameters<typeof runPlaybackCommandWithDeps>[1] & {
|
||||
isAppControlServerAvailable: () => Promise<boolean>;
|
||||
const harness = makeHarness({
|
||||
context: createContext({ args: FILE_TARGET, plugin: { texthookerEnabled: true } }),
|
||||
deps: { isAppControlServerAvailable: async () => true },
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['startMpv', 'startOverlay:--show-visible-overlay']);
|
||||
await harness.run();
|
||||
|
||||
assert.deepEqual(harness.overlayCalls[0]?.extraAppArgs, ['--show-visible-overlay']);
|
||||
});
|
||||
|
||||
test('playback command ensures Linux runtime plugin before mpv launch', async () => {
|
||||
const context = createContext();
|
||||
context.args = {
|
||||
...context.args,
|
||||
target: '/tmp/movie.mkv',
|
||||
targetKind: 'file',
|
||||
};
|
||||
const calls: string[] = [];
|
||||
const harness = makeHarness({ context: createContext({ args: FILE_TARGET }) });
|
||||
|
||||
await runPlaybackCommandWithDeps(context, {
|
||||
ensurePlaybackSetupReady: async () => {},
|
||||
ensureRuntimePluginReady: async () => {
|
||||
calls.push('plugin');
|
||||
},
|
||||
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
||||
checkDependencies: () => {},
|
||||
registerCleanup: () => {},
|
||||
startMpv: async () => {
|
||||
calls.push('startMpv');
|
||||
},
|
||||
waitForUnixSocketReady: async () => true,
|
||||
startOverlay: async () => {},
|
||||
launchAppCommandDetached: () => {},
|
||||
log: () => {},
|
||||
cleanupPlaybackSession: async () => {},
|
||||
getMpvProc: () => null,
|
||||
});
|
||||
await harness.run();
|
||||
|
||||
assert.deepEqual(calls, ['plugin', 'startMpv']);
|
||||
assertRanInOrder(harness.events, 'plugin', 'startMpv');
|
||||
});
|
||||
|
||||
test('rofi playback repairs support assets before opening the picker', async () => {
|
||||
const context = createContext();
|
||||
context.args = {
|
||||
...context.args,
|
||||
target: '',
|
||||
targetKind: '',
|
||||
useRofi: true,
|
||||
};
|
||||
const calls: string[] = [];
|
||||
|
||||
await runPlaybackCommandWithDeps(context, {
|
||||
ensurePlaybackSetupReady: async () => {},
|
||||
ensureRuntimePluginReady: async () => {
|
||||
calls.push('assets');
|
||||
const events: string[] = [];
|
||||
const harness = makeHarness({
|
||||
events,
|
||||
context: createContext({ args: { target: '', targetKind: '', useRofi: true } }),
|
||||
deps: {
|
||||
chooseTarget: async () => {
|
||||
events.push('picker');
|
||||
return { target: '/tmp/movie.mkv', kind: 'file' };
|
||||
},
|
||||
checkPickerDependencies: () => {},
|
||||
},
|
||||
chooseTarget: async () => {
|
||||
calls.push('picker');
|
||||
return { target: '/tmp/movie.mkv', kind: 'file' };
|
||||
},
|
||||
checkPickerDependencies: () => {},
|
||||
checkDependencies: () => {},
|
||||
registerCleanup: () => {},
|
||||
startMpv: async () => {
|
||||
calls.push('startMpv');
|
||||
},
|
||||
waitForUnixSocketReady: async () => true,
|
||||
startOverlay: async () => {},
|
||||
launchAppCommandDetached: () => {},
|
||||
log: () => {},
|
||||
cleanupPlaybackSession: async () => {},
|
||||
getMpvProc: () => null,
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['assets', 'picker', 'startMpv']);
|
||||
await harness.run();
|
||||
|
||||
assertRanInOrder(events, 'plugin', 'picker', 'startMpv');
|
||||
});
|
||||
@@ -48,49 +48,6 @@ test('runSyncCommand proxies sync argv to the app in --sync-cli mode', async ()
|
||||
assert.equal(spawned.length, 1);
|
||||
});
|
||||
|
||||
test('runSyncCommand forwards tokens verbatim and appends the effective log level', async () => {
|
||||
const spawned: string[][] = [];
|
||||
const deps: Partial<SyncCommandDeps> = {
|
||||
runAppCommand: (_appPath, appArgs) => {
|
||||
spawned.push(appArgs);
|
||||
},
|
||||
};
|
||||
|
||||
await runSyncCommand(
|
||||
makeContext({
|
||||
syncCliTokens: [
|
||||
'media-box',
|
||||
'--pull',
|
||||
'--remote-cmd',
|
||||
'/opt/SubMiner.AppImage',
|
||||
'--db',
|
||||
'/tmp/db.sqlite',
|
||||
'--force',
|
||||
'--json',
|
||||
],
|
||||
logLevel: 'debug',
|
||||
}),
|
||||
deps,
|
||||
);
|
||||
|
||||
assert.deepEqual(spawned, [
|
||||
[
|
||||
'--sync-cli',
|
||||
'sync',
|
||||
'media-box',
|
||||
'--pull',
|
||||
'--remote-cmd',
|
||||
'/opt/SubMiner.AppImage',
|
||||
'--db',
|
||||
'/tmp/db.sqlite',
|
||||
'--force',
|
||||
'--json',
|
||||
'--log-level',
|
||||
'debug',
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test('runSyncCommand fails with a clear message when the app binary is missing', async () => {
|
||||
const deps: Partial<SyncCommandDeps> = {
|
||||
runAppCommand: () => {
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
buildPluginRuntimeScriptOptParts,
|
||||
parsePluginRuntimeConfigFromMainConfig,
|
||||
} from './config/plugin-runtime-config.js';
|
||||
import { getDefaultSocketPath } from './types.js';
|
||||
|
||||
test('parseLauncherYoutubeSubgenConfig keeps only valid typed values', () => {
|
||||
const parsed = parseLauncherYoutubeSubgenConfig({
|
||||
@@ -207,10 +206,6 @@ test('buildPluginRuntimeScriptOptParts strips script-option delimiters from stri
|
||||
);
|
||||
});
|
||||
|
||||
test('getDefaultSocketPath returns Windows named pipe default', () => {
|
||||
assert.equal(getDefaultSocketPath('win32'), '\\\\.\\pipe\\subminer-socket');
|
||||
});
|
||||
|
||||
test('parseLauncherMpvConfig reads configured mpv profile', () => {
|
||||
assert.deepEqual(
|
||||
parseLauncherMpvConfig({
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
|
||||
test('launcher root help lists subcommands', () => {
|
||||
const output = execFileSync('bun', ['run', path.join(process.cwd(), 'launcher/main.ts'), '-h'], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
assert.match(output, /Commands:/);
|
||||
assert.match(output, /jellyfin\|jf/);
|
||||
assert.match(output, /doctor/);
|
||||
assert.match(output, /config/);
|
||||
assert.match(output, /mpv/);
|
||||
assert.match(output, /logs/);
|
||||
assert.match(output, /dictionary\|dict/);
|
||||
assert.match(output, /texthooker/);
|
||||
assert.match(output, /app\|bin/);
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
applyRootOptionsToArgs,
|
||||
createDefaultArgs,
|
||||
} from './args-normalizer.js';
|
||||
import type { CliInvocations } from './cli-parser-builder.js';
|
||||
|
||||
function withTempDir<T>(fn: (dir: string) => T): T {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-launcher-args-'));
|
||||
@@ -19,6 +20,48 @@ function withTempDir<T>(fn: (dir: string) => T): T {
|
||||
}
|
||||
}
|
||||
|
||||
/** An invocations bag with nothing triggered; tests pass only the fields under test. */
|
||||
function emptyInvocations(overrides: Partial<CliInvocations> = {}): CliInvocations {
|
||||
return {
|
||||
jellyfinInvocation: null,
|
||||
configInvocation: null,
|
||||
settingsInvocation: null,
|
||||
youtubeBrowserInvocation: null,
|
||||
mpvInvocation: null,
|
||||
appInvocation: null,
|
||||
dictionaryTriggered: false,
|
||||
dictionaryTarget: null,
|
||||
dictionaryLogLevel: null,
|
||||
dictionaryCandidates: false,
|
||||
dictionarySelect: false,
|
||||
dictionaryAnilistId: null,
|
||||
statsTriggered: false,
|
||||
statsBackground: false,
|
||||
statsStop: false,
|
||||
statsCleanup: false,
|
||||
statsCleanupVocab: false,
|
||||
statsCleanupLifetime: false,
|
||||
statsCleanupDuplicateLines: false,
|
||||
statsCleanupDryRun: false,
|
||||
statsCleanupLookbackDays: null,
|
||||
statsLogLevel: null,
|
||||
syncTriggered: false,
|
||||
syncCliTokens: [],
|
||||
syncLogLevel: null,
|
||||
syncUiTriggered: false,
|
||||
syncUiLogLevel: null,
|
||||
doctorTriggered: false,
|
||||
doctorLogLevel: null,
|
||||
doctorRefreshKnownWords: false,
|
||||
logsTriggered: false,
|
||||
logsExport: false,
|
||||
texthookerTriggered: false,
|
||||
texthookerLogLevel: null,
|
||||
texthookerOpenBrowser: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('createDefaultArgs normalizes configured language codes', () => {
|
||||
const parsed = createDefaultArgs({
|
||||
primarySubLanguages: [' JA ', 'jpn', 'ja'],
|
||||
@@ -92,53 +135,23 @@ test('applyRootOptionsToArgs rejects unsupported targets', () => {
|
||||
test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
|
||||
const parsed = createDefaultArgs({});
|
||||
|
||||
applyInvocationsToArgs(parsed, {
|
||||
jellyfinInvocation: {
|
||||
action: 'play',
|
||||
play: true,
|
||||
server: 'https://jf.example',
|
||||
username: 'alice',
|
||||
password: 'secret',
|
||||
logLevel: 'debug',
|
||||
},
|
||||
configInvocation: {
|
||||
action: 'show',
|
||||
logLevel: 'warn',
|
||||
},
|
||||
settingsInvocation: null,
|
||||
youtubeBrowserInvocation: null,
|
||||
mpvInvocation: null,
|
||||
appInvocation: null,
|
||||
dictionaryTriggered: false,
|
||||
dictionaryTarget: null,
|
||||
dictionaryLogLevel: null,
|
||||
dictionaryCandidates: false,
|
||||
dictionarySelect: false,
|
||||
dictionaryAnilistId: null,
|
||||
statsTriggered: false,
|
||||
statsBackground: false,
|
||||
statsStop: false,
|
||||
statsCleanup: false,
|
||||
statsCleanupVocab: false,
|
||||
statsCleanupLifetime: false,
|
||||
statsCleanupDuplicateLines: false,
|
||||
statsCleanupDryRun: false,
|
||||
statsCleanupLookbackDays: null,
|
||||
statsLogLevel: null,
|
||||
syncTriggered: false,
|
||||
syncCliTokens: [],
|
||||
syncLogLevel: null,
|
||||
syncUiTriggered: false,
|
||||
syncUiLogLevel: null,
|
||||
doctorTriggered: false,
|
||||
doctorLogLevel: null,
|
||||
doctorRefreshKnownWords: false,
|
||||
logsTriggered: false,
|
||||
logsExport: false,
|
||||
texthookerTriggered: false,
|
||||
texthookerLogLevel: null,
|
||||
texthookerOpenBrowser: false,
|
||||
});
|
||||
applyInvocationsToArgs(
|
||||
parsed,
|
||||
emptyInvocations({
|
||||
jellyfinInvocation: {
|
||||
action: 'play',
|
||||
play: true,
|
||||
server: 'https://jf.example',
|
||||
username: 'alice',
|
||||
password: 'secret',
|
||||
logLevel: 'debug',
|
||||
},
|
||||
configInvocation: {
|
||||
action: 'show',
|
||||
logLevel: 'warn',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(parsed.jellyfin, false);
|
||||
assert.equal(parsed.jellyfinPlay, true);
|
||||
@@ -155,45 +168,14 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
|
||||
test('applyInvocationsToArgs maps settings invocation to settings window', () => {
|
||||
const parsed = createDefaultArgs({});
|
||||
|
||||
applyInvocationsToArgs(parsed, {
|
||||
jellyfinInvocation: null,
|
||||
configInvocation: null,
|
||||
settingsInvocation: {
|
||||
logLevel: undefined,
|
||||
},
|
||||
youtubeBrowserInvocation: null,
|
||||
mpvInvocation: null,
|
||||
appInvocation: null,
|
||||
dictionaryTriggered: false,
|
||||
dictionaryTarget: null,
|
||||
dictionaryLogLevel: null,
|
||||
dictionaryCandidates: false,
|
||||
dictionarySelect: false,
|
||||
dictionaryAnilistId: null,
|
||||
statsTriggered: false,
|
||||
statsBackground: false,
|
||||
statsStop: false,
|
||||
statsCleanup: false,
|
||||
statsCleanupVocab: false,
|
||||
statsCleanupLifetime: false,
|
||||
statsCleanupDuplicateLines: false,
|
||||
statsCleanupDryRun: false,
|
||||
statsCleanupLookbackDays: null,
|
||||
statsLogLevel: null,
|
||||
syncTriggered: false,
|
||||
syncCliTokens: [],
|
||||
syncLogLevel: null,
|
||||
syncUiTriggered: false,
|
||||
syncUiLogLevel: null,
|
||||
doctorTriggered: false,
|
||||
doctorLogLevel: null,
|
||||
doctorRefreshKnownWords: false,
|
||||
logsTriggered: false,
|
||||
logsExport: false,
|
||||
texthookerTriggered: false,
|
||||
texthookerLogLevel: null,
|
||||
texthookerOpenBrowser: false,
|
||||
});
|
||||
applyInvocationsToArgs(
|
||||
parsed,
|
||||
emptyInvocations({
|
||||
settingsInvocation: {
|
||||
logLevel: undefined,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(parsed.settings, true);
|
||||
assert.equal(parsed.configPath, false);
|
||||
@@ -203,45 +185,14 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
|
||||
const parsed = createDefaultArgs({});
|
||||
|
||||
const error = withProcessExitIntercept(() => {
|
||||
applyInvocationsToArgs(parsed, {
|
||||
jellyfinInvocation: null,
|
||||
configInvocation: {
|
||||
action: undefined,
|
||||
},
|
||||
settingsInvocation: null,
|
||||
youtubeBrowserInvocation: null,
|
||||
mpvInvocation: null,
|
||||
appInvocation: null,
|
||||
dictionaryTriggered: false,
|
||||
dictionaryTarget: null,
|
||||
dictionaryLogLevel: null,
|
||||
dictionaryCandidates: false,
|
||||
dictionarySelect: false,
|
||||
dictionaryAnilistId: null,
|
||||
statsTriggered: false,
|
||||
statsBackground: false,
|
||||
statsStop: false,
|
||||
statsCleanup: false,
|
||||
statsCleanupVocab: false,
|
||||
statsCleanupLifetime: false,
|
||||
statsCleanupDuplicateLines: false,
|
||||
statsCleanupDryRun: false,
|
||||
statsCleanupLookbackDays: null,
|
||||
statsLogLevel: null,
|
||||
syncTriggered: false,
|
||||
syncCliTokens: [],
|
||||
syncLogLevel: null,
|
||||
syncUiTriggered: false,
|
||||
syncUiLogLevel: null,
|
||||
doctorTriggered: false,
|
||||
doctorLogLevel: null,
|
||||
doctorRefreshKnownWords: false,
|
||||
logsTriggered: false,
|
||||
logsExport: false,
|
||||
texthookerTriggered: false,
|
||||
texthookerLogLevel: null,
|
||||
texthookerOpenBrowser: false,
|
||||
});
|
||||
applyInvocationsToArgs(
|
||||
parsed,
|
||||
emptyInvocations({
|
||||
configInvocation: {
|
||||
action: undefined,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
assert.equal(error.code, 1);
|
||||
@@ -251,43 +202,13 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
|
||||
test('applyInvocationsToArgs maps texthooker browser-open request', () => {
|
||||
const parsed = createDefaultArgs({});
|
||||
|
||||
applyInvocationsToArgs(parsed, {
|
||||
jellyfinInvocation: null,
|
||||
configInvocation: null,
|
||||
settingsInvocation: null,
|
||||
youtubeBrowserInvocation: null,
|
||||
mpvInvocation: null,
|
||||
appInvocation: null,
|
||||
dictionaryTriggered: false,
|
||||
dictionaryTarget: null,
|
||||
dictionaryLogLevel: null,
|
||||
dictionaryCandidates: false,
|
||||
dictionarySelect: false,
|
||||
dictionaryAnilistId: null,
|
||||
statsTriggered: false,
|
||||
statsBackground: false,
|
||||
statsStop: false,
|
||||
statsCleanup: false,
|
||||
statsCleanupVocab: false,
|
||||
statsCleanupLifetime: false,
|
||||
statsCleanupDuplicateLines: false,
|
||||
statsCleanupDryRun: false,
|
||||
statsCleanupLookbackDays: null,
|
||||
statsLogLevel: null,
|
||||
syncTriggered: false,
|
||||
syncCliTokens: [],
|
||||
syncLogLevel: null,
|
||||
syncUiTriggered: false,
|
||||
syncUiLogLevel: null,
|
||||
doctorTriggered: false,
|
||||
doctorLogLevel: null,
|
||||
doctorRefreshKnownWords: false,
|
||||
logsTriggered: false,
|
||||
logsExport: false,
|
||||
texthookerTriggered: true,
|
||||
texthookerLogLevel: null,
|
||||
texthookerOpenBrowser: true,
|
||||
});
|
||||
applyInvocationsToArgs(
|
||||
parsed,
|
||||
emptyInvocations({
|
||||
texthookerTriggered: true,
|
||||
texthookerOpenBrowser: true,
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(parsed.texthookerOnly, true);
|
||||
assert.equal(parsed.texthookerOpenBrowser, true);
|
||||
|
||||
+304
-120
@@ -1,128 +1,312 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { Args } from './types.js';
|
||||
import { runJellyfinPlayMenuWithDeps } from './jellyfin.js';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
buildForwardedJellyfinAppArgs,
|
||||
buildRootSearchGroups,
|
||||
classifyJellyfinChildSelection,
|
||||
deriveJellyfinTokenStorePath,
|
||||
hasStoredJellyfinSession,
|
||||
parseEpisodePathFromDisplay,
|
||||
parseJellyfinErrorFromAppOutput,
|
||||
parseJellyfinAppReply,
|
||||
parseJellyfinItemsReply,
|
||||
parseJellyfinLibrariesReply,
|
||||
parseJellyfinPreviewAuthResponse,
|
||||
runJellyfinPlayMenuWithDeps,
|
||||
shouldRetryWithStartForNoRunningInstance,
|
||||
} from './jellyfin.js';
|
||||
import { makeLauncherArgs } from './test-support/args.js';
|
||||
import { withEnv } from './test-support/env.js';
|
||||
|
||||
function createArgs(): Args {
|
||||
return {
|
||||
backend: 'auto',
|
||||
directory: '.',
|
||||
recursive: false,
|
||||
profile: '',
|
||||
startOverlay: false,
|
||||
youtubePrimarySubLangs: [],
|
||||
youtubeSecondarySubLangs: [],
|
||||
youtubeAudioLangs: [],
|
||||
useTexthooker: false,
|
||||
autoStartOverlay: false,
|
||||
texthookerOnly: false,
|
||||
texthookerOpenBrowser: false,
|
||||
useRofi: false,
|
||||
history: false,
|
||||
sync: false,
|
||||
syncCliTokens: [],
|
||||
syncUi: false,
|
||||
logLevel: 'info',
|
||||
logRotation: 7,
|
||||
passwordStore: '',
|
||||
target: '',
|
||||
targetKind: '',
|
||||
jimakuApiKey: '',
|
||||
jimakuApiKeyCommand: '',
|
||||
jimakuApiBaseUrl: '',
|
||||
jimakuLanguagePreference: 'ja',
|
||||
jimakuMaxEntryResults: 20,
|
||||
jellyfin: false,
|
||||
jellyfinLogin: false,
|
||||
jellyfinLogout: false,
|
||||
jellyfinPlay: false,
|
||||
jellyfinDiscovery: false,
|
||||
dictionary: false,
|
||||
dictionaryCandidates: false,
|
||||
dictionarySelect: false,
|
||||
stats: false,
|
||||
doctor: false,
|
||||
doctorRefreshKnownWords: false,
|
||||
logsExport: false,
|
||||
version: false,
|
||||
settings: false,
|
||||
youtubeBrowser: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
mpvIdle: false,
|
||||
mpvSocket: false,
|
||||
mpvStatus: false,
|
||||
mpvArgs: '',
|
||||
appPassthrough: false,
|
||||
appArgs: [],
|
||||
jellyfinServer: 'https://jellyfin.example.test',
|
||||
jellyfinUsername: '',
|
||||
jellyfinPassword: '',
|
||||
launchMode: 'normal',
|
||||
class HandoffComplete extends Error {}
|
||||
|
||||
type PlayMenuDeps = NonNullable<Parameters<typeof runJellyfinPlayMenuWithDeps>[4]>;
|
||||
|
||||
/**
|
||||
* Runs the play menu with fake deps and a direct env session. The app handoff never returns in
|
||||
* production, so the fake throws `HandoffComplete` after recording its args into `handoff`.
|
||||
*/
|
||||
async function runPlayMenu(options: {
|
||||
socketReady?: boolean;
|
||||
overrides?: Partial<PlayMenuDeps>;
|
||||
args?: Parameters<typeof makeLauncherArgs>[0];
|
||||
}) {
|
||||
const launches: string[] = [];
|
||||
const handoff: { appArgs: string[] | null } = { appArgs: null };
|
||||
const deps: PlayMenuDeps = {
|
||||
loadLauncherJellyfinConfig: () => ({}),
|
||||
findRofiTheme: () => null,
|
||||
resolveJellyfinSelection: async () => 'item-123',
|
||||
resolveJellyfinSelectionViaApp: async () => {
|
||||
throw new Error('unexpected app-based selection');
|
||||
},
|
||||
hasStoredJellyfinSession: () => true,
|
||||
requestJellyfinPreviewAuthFromApp: async () => null,
|
||||
resolveLauncherMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
|
||||
pathExists: () => options.socketReady ?? false,
|
||||
ensureRuntimePluginReady: async () => {
|
||||
launches.push('plugin');
|
||||
},
|
||||
waitForUnixSocketReady: async () => true,
|
||||
launchMpvIdleDetached: async () => {
|
||||
launches.push('launch');
|
||||
},
|
||||
resolveLauncherRuntimePluginPath: () => '/tmp/plugin/main.lua',
|
||||
runAppCommandWithInheritLogged: (_appPath, appArgs) => {
|
||||
handoff.appArgs = appArgs;
|
||||
throw new HandoffComplete();
|
||||
},
|
||||
isAppManagedByLauncher: () => false,
|
||||
runAppCommandCaptureOutput: () => {
|
||||
throw new Error('unexpected owned-app handoff');
|
||||
},
|
||||
waitForUnixSocketClosed: async () => {
|
||||
throw new Error('unexpected wait for mpv to close');
|
||||
},
|
||||
exitProcess: () => {
|
||||
throw new Error('unexpected exit');
|
||||
},
|
||||
log: () => {},
|
||||
...options.overrides,
|
||||
};
|
||||
|
||||
await withEnv(
|
||||
{ SUBMINER_JELLYFIN_ACCESS_TOKEN: 'token', SUBMINER_JELLYFIN_USER_ID: 'user' },
|
||||
() =>
|
||||
assert.rejects(
|
||||
() =>
|
||||
runJellyfinPlayMenuWithDeps(
|
||||
'/tmp/SubMiner.AppImage',
|
||||
makeLauncherArgs({ jellyfinServer: 'https://jellyfin.example.test', ...options.args }),
|
||||
'/tmp/subminer',
|
||||
'/tmp/subminer.sock',
|
||||
deps,
|
||||
),
|
||||
HandoffComplete,
|
||||
),
|
||||
);
|
||||
|
||||
return { launches, appArgs: handoff.appArgs };
|
||||
}
|
||||
|
||||
test('Jellyfin playback ensures Linux runtime plugin before detached idle mpv bootstrap', async () => {
|
||||
const originalAccessToken = process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN;
|
||||
const originalUserId = process.env.SUBMINER_JELLYFIN_USER_ID;
|
||||
process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN = 'token';
|
||||
process.env.SUBMINER_JELLYFIN_USER_ID = 'user';
|
||||
const calls: string[] = [];
|
||||
test('Jellyfin playback launches the idle mpv with the runtime plugin, then hands the item to the app', async () => {
|
||||
const { launches, appArgs } = await runPlayMenu({});
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
runJellyfinPlayMenuWithDeps(
|
||||
'/tmp/SubMiner.AppImage',
|
||||
createArgs(),
|
||||
'/tmp/subminer',
|
||||
'/tmp/subminer.sock',
|
||||
{
|
||||
loadLauncherJellyfinConfig: () => ({}),
|
||||
findRofiTheme: () => null,
|
||||
resolveJellyfinSelection: async () => 'item-123',
|
||||
resolveJellyfinSelectionViaApp: async () => {
|
||||
throw new Error('unexpected app-based selection');
|
||||
},
|
||||
hasStoredJellyfinSession: () => true,
|
||||
requestJellyfinPreviewAuthFromApp: async () => null,
|
||||
resolveLauncherMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
|
||||
pathExists: () => false,
|
||||
ensureRuntimePluginReady: async () => {
|
||||
calls.push('plugin');
|
||||
},
|
||||
waitForUnixSocketReady: async () => {
|
||||
calls.push('wait');
|
||||
return true;
|
||||
},
|
||||
launchMpvIdleDetached: async () => {
|
||||
calls.push('launch');
|
||||
},
|
||||
resolveLauncherRuntimePluginPath: () => {
|
||||
calls.push('resolve');
|
||||
return '/tmp/plugin/main.lua';
|
||||
},
|
||||
runAppCommandWithInheritLogged: () => {
|
||||
calls.push('handoff');
|
||||
throw new Error('stop after handoff');
|
||||
},
|
||||
log: () => {},
|
||||
},
|
||||
),
|
||||
/stop after handoff/,
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, ['plugin', 'resolve', 'launch', 'wait', 'handoff']);
|
||||
} finally {
|
||||
if (originalAccessToken === undefined) {
|
||||
delete process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN;
|
||||
} else {
|
||||
process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN = originalAccessToken;
|
||||
}
|
||||
if (originalUserId === undefined) {
|
||||
delete process.env.SUBMINER_JELLYFIN_USER_ID;
|
||||
} else {
|
||||
process.env.SUBMINER_JELLYFIN_USER_ID = originalUserId;
|
||||
}
|
||||
}
|
||||
assert.deepEqual(launches, ['plugin', 'launch']);
|
||||
assert.deepEqual(appArgs, ['--start', '--jellyfin-play', '--jellyfin-item-id=item-123']);
|
||||
});
|
||||
|
||||
test('Jellyfin playback reuses a ready mpv socket instead of launching idle mpv', async () => {
|
||||
const { launches, appArgs } = await runPlayMenu({ socketReady: true });
|
||||
|
||||
assert.deepEqual(launches, []);
|
||||
assert.deepEqual(appArgs, ['--start', '--jellyfin-play', '--jellyfin-item-id=item-123']);
|
||||
});
|
||||
|
||||
test('Jellyfin playback in an app the picker started waits for mpv to close before exiting', async () => {
|
||||
const events: string[] = [];
|
||||
const { appArgs } = await runPlayMenu({
|
||||
socketReady: true,
|
||||
overrides: {
|
||||
isAppManagedByLauncher: () => true,
|
||||
runAppCommandCaptureOutput: (_appPath, appArgs) => {
|
||||
events.push(`handoff ${appArgs.join(' ')}`);
|
||||
return { status: 0, stdout: '', stderr: '' };
|
||||
},
|
||||
waitForUnixSocketClosed: async (socketPath) => {
|
||||
events.push(`wait ${socketPath}`);
|
||||
},
|
||||
exitProcess: (code) => {
|
||||
events.push(`exit ${code}`);
|
||||
throw new HandoffComplete();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(appArgs, null);
|
||||
assert.deepEqual(events, [
|
||||
'handoff --start --jellyfin-play --jellyfin-item-id=item-123',
|
||||
'wait /tmp/subminer.sock',
|
||||
'exit 0',
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseJellyfinAppReply maps library and item replies to picker entries', () => {
|
||||
const libraries = parseJellyfinAppReply(
|
||||
JSON.stringify({
|
||||
libraries: [
|
||||
{ id: 'lib1', name: 'Anime', collectionType: 'tvshows' },
|
||||
{ id: 'lib1', name: 'Duplicate', collectionType: 'tvshows' },
|
||||
{ name: 'No id' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
assert.ok(libraries?.ok);
|
||||
assert.deepEqual(parseJellyfinLibrariesReply(libraries.payload), [
|
||||
{ id: 'lib1', name: 'Anime', kind: 'tvshows' },
|
||||
]);
|
||||
|
||||
const items = parseJellyfinAppReply(
|
||||
JSON.stringify({ items: [{ id: 'movie-1', title: 'Movie [Alt]', type: 'Movie' }] }),
|
||||
);
|
||||
assert.ok(items?.ok);
|
||||
assert.deepEqual(parseJellyfinItemsReply(items.payload), [
|
||||
{ id: 'movie-1', name: 'Movie [Alt]', type: 'Movie', display: 'Movie [Alt]' },
|
||||
]);
|
||||
assert.equal(parseJellyfinLibrariesReply(items.payload), null);
|
||||
});
|
||||
|
||||
test('parseJellyfinAppReply surfaces app errors and rejects partial files', () => {
|
||||
assert.deepEqual(
|
||||
parseJellyfinAppReply(
|
||||
JSON.stringify({ error: 'Missing Jellyfin session. Run --jellyfin-login first.' }),
|
||||
),
|
||||
{ ok: false, error: 'Missing Jellyfin session. Run `subminer jellyfin -l` to log in again.' },
|
||||
);
|
||||
assert.equal(parseJellyfinAppReply(''), null);
|
||||
assert.equal(parseJellyfinAppReply('{"libraries": ['), null);
|
||||
});
|
||||
|
||||
test('buildForwardedJellyfinAppArgs appends server, password store, and log level', () => {
|
||||
const forwarded = buildForwardedJellyfinAppArgs(
|
||||
{
|
||||
jellyfinServer: 'https://jf.example.test/',
|
||||
passwordStore: 'gnome-libsecret',
|
||||
logLevel: 'info',
|
||||
} as never,
|
||||
['--jellyfin-libraries'],
|
||||
);
|
||||
|
||||
assert.deepEqual(forwarded, [
|
||||
'--jellyfin-libraries',
|
||||
'--jellyfin-server',
|
||||
'https://jf.example.test',
|
||||
'--password-store',
|
||||
'gnome-libsecret',
|
||||
'--log-level',
|
||||
'info',
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseJellyfinErrorFromAppOutput extracts bracketed error lines', () => {
|
||||
const parsed = parseJellyfinErrorFromAppOutput(`
|
||||
[subminer] - 2026-03-01 13:10:34 - WARN - [main] test warning
|
||||
[2026-03-01T21:11:28.821Z] [ERROR] Missing Jellyfin session. Set SUBMINER_JELLYFIN_ACCESS_TOKEN and SUBMINER_JELLYFIN_USER_ID, then retry.
|
||||
`);
|
||||
|
||||
assert.equal(
|
||||
parsed,
|
||||
'Missing Jellyfin session. Set SUBMINER_JELLYFIN_ACCESS_TOKEN and SUBMINER_JELLYFIN_USER_ID, then retry.',
|
||||
);
|
||||
});
|
||||
|
||||
test('parseJellyfinErrorFromAppOutput extracts main runtime error lines', () => {
|
||||
const parsed = parseJellyfinErrorFromAppOutput(`
|
||||
[subminer] - 2026-03-01 13:10:34 - ERROR - [main] runJellyfinCommand failed: {"message":"Missing Jellyfin password."}
|
||||
`);
|
||||
|
||||
assert.equal(
|
||||
parsed,
|
||||
'[main] runJellyfinCommand failed: {"message":"Missing Jellyfin password."}',
|
||||
);
|
||||
});
|
||||
|
||||
test('parseJellyfinPreviewAuthResponse parses valid structured response payload', () => {
|
||||
const parsed = parseJellyfinPreviewAuthResponse({
|
||||
serverUrl: 'http://pve-main:8096/',
|
||||
accessToken: 'token-123',
|
||||
userId: 'user-1',
|
||||
});
|
||||
|
||||
assert.deepEqual(parsed, {
|
||||
serverUrl: 'http://pve-main:8096',
|
||||
accessToken: 'token-123',
|
||||
userId: 'user-1',
|
||||
});
|
||||
});
|
||||
|
||||
test('parseJellyfinPreviewAuthResponse returns null without a token', () => {
|
||||
assert.equal(
|
||||
parseJellyfinPreviewAuthResponse({
|
||||
serverUrl: 'http://pve-main:8096',
|
||||
accessToken: '',
|
||||
userId: 'user-1',
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('hasStoredJellyfinSession checks token-store existence', () => {
|
||||
const configPath = path.join('/home/test', '.config', 'SubMiner', 'config.jsonc');
|
||||
const tokenPath = deriveJellyfinTokenStorePath(configPath);
|
||||
const exists = (candidate: string): boolean => candidate === tokenPath;
|
||||
assert.equal(hasStoredJellyfinSession(configPath, exists), true);
|
||||
assert.equal(
|
||||
hasStoredJellyfinSession(path.join('/home/test', '.config', 'Other', 'alt.jsonc'), exists),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldRetryWithStartForNoRunningInstance matches expected app lifecycle error', () => {
|
||||
assert.equal(
|
||||
shouldRetryWithStartForNoRunningInstance('No running instance. Use --start to launch the app.'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRetryWithStartForNoRunningInstance(
|
||||
'Missing Jellyfin session. Run --jellyfin-login first.',
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('parseEpisodePathFromDisplay extracts series and season from episode display titles', () => {
|
||||
assert.deepEqual(
|
||||
parseEpisodePathFromDisplay('KONOSUBA S01E03 A Panty Treasure in This Right Hand!'),
|
||||
{
|
||||
seriesName: 'KONOSUBA',
|
||||
seasonNumber: 1,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(parseEpisodePathFromDisplay('Frieren S2E10 Something'), {
|
||||
seriesName: 'Frieren',
|
||||
seasonNumber: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test('parseEpisodePathFromDisplay returns null for non-episode displays', () => {
|
||||
assert.equal(parseEpisodePathFromDisplay('Movie Title (Movie)'), null);
|
||||
assert.equal(parseEpisodePathFromDisplay('Just A Name'), null);
|
||||
});
|
||||
|
||||
test('buildRootSearchGroups excludes episodes and keeps containers/movies', () => {
|
||||
const groups = buildRootSearchGroups([
|
||||
{ id: 'series-1', name: 'The Eminence in Shadow', type: 'Series', display: 'x' },
|
||||
{ id: 'movie-1', name: 'Spirited Away', type: 'Movie', display: 'x' },
|
||||
{ id: 'episode-1', name: 'The Eminence in Shadow S01E01', type: 'Episode', display: 'x' },
|
||||
]);
|
||||
|
||||
assert.deepEqual(groups, [
|
||||
{
|
||||
id: 'series-1',
|
||||
name: 'The Eminence in Shadow',
|
||||
type: 'Series',
|
||||
display: 'The Eminence in Shadow (Series)',
|
||||
},
|
||||
{
|
||||
id: 'movie-1',
|
||||
name: 'Spirited Away',
|
||||
type: 'Movie',
|
||||
display: 'Spirited Away (Movie)',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('classifyJellyfinChildSelection keeps container drilldown state instead of flattening', () => {
|
||||
const next = classifyJellyfinChildSelection({ id: 'season-2', type: 'Season' });
|
||||
assert.deepEqual(next, {
|
||||
kind: 'container',
|
||||
id: 'season-2',
|
||||
});
|
||||
});
|
||||
+195
-222
@@ -10,7 +10,8 @@ import type {
|
||||
JellyfinGroupEntry,
|
||||
} from './types.js';
|
||||
import { shouldForwardLogLevel } from './types.js';
|
||||
import { log, fail, getMpvLogPath } from './log.js';
|
||||
import type { JellyfinPreviewAuthPayload } from '../src/shared/jellyfin-cli-response.js';
|
||||
import { log, fail } from './log.js';
|
||||
import { nowMs } from './time.js';
|
||||
import { commandExists, resolvePathMaybe, sleep } from './util.js';
|
||||
import {
|
||||
@@ -23,11 +24,14 @@ import {
|
||||
import { loadLauncherJellyfinConfig } from './config.js';
|
||||
import { resolveLauncherMainConfigPath } from './config/shared-config-reader.js';
|
||||
import {
|
||||
adoptLauncherStartedApp,
|
||||
isAppManagedByLauncher,
|
||||
runAppCommandWithInheritLogged,
|
||||
runAppCommandCaptureOutput,
|
||||
launchAppStartDetached,
|
||||
launchAppBackgroundDetached,
|
||||
launchMpvIdleDetached,
|
||||
resolveLauncherRuntimePluginPath,
|
||||
waitForUnixSocketClosed,
|
||||
waitForUnixSocketReady,
|
||||
} from './mpv.js';
|
||||
import { ensureLinuxRuntimePluginAvailable } from './runtime-plugin-preflight.js';
|
||||
@@ -52,6 +56,10 @@ type JellyfinPlayMenuDeps = {
|
||||
launchMpvIdleDetached: typeof launchMpvIdleDetached;
|
||||
resolveLauncherRuntimePluginPath: typeof resolveLauncherRuntimePluginPath;
|
||||
runAppCommandWithInheritLogged: typeof runAppCommandWithInheritLogged;
|
||||
isAppManagedByLauncher: typeof isAppManagedByLauncher;
|
||||
runAppCommandCaptureOutput: typeof runAppCommandCaptureOutput;
|
||||
waitForUnixSocketClosed: typeof waitForUnixSocketClosed;
|
||||
exitProcess: (code: number) => never;
|
||||
log: typeof log;
|
||||
};
|
||||
|
||||
@@ -75,6 +83,10 @@ const defaultJellyfinPlayMenuDeps: JellyfinPlayMenuDeps = {
|
||||
launchMpvIdleDetached,
|
||||
resolveLauncherRuntimePluginPath,
|
||||
runAppCommandWithInheritLogged,
|
||||
isAppManagedByLauncher,
|
||||
runAppCommandCaptureOutput,
|
||||
waitForUnixSocketClosed,
|
||||
exitProcess: (code) => process.exit(code),
|
||||
log,
|
||||
};
|
||||
|
||||
@@ -171,76 +183,77 @@ function stripAnsi(value: string): string {
|
||||
return value.replace(ANSI_ESCAPE_PATTERN, '');
|
||||
}
|
||||
|
||||
function parseNamedJellyfinRecord(payload: string): {
|
||||
name: string;
|
||||
id: string;
|
||||
type: string;
|
||||
} | null {
|
||||
const typeClose = payload.lastIndexOf(')');
|
||||
if (typeClose !== payload.length - 1) return null;
|
||||
// Reply the app writes to --jellyfin-response-path; see src/shared/jellyfin-cli-response.ts.
|
||||
export type JellyfinAppReply =
|
||||
| { ok: true; payload: Record<string, unknown> }
|
||||
| { ok: false; error: string };
|
||||
|
||||
const typeOpen = payload.lastIndexOf(' (');
|
||||
if (typeOpen <= 0 || typeOpen >= typeClose) return null;
|
||||
|
||||
const idClose = payload.lastIndexOf(']', typeOpen);
|
||||
if (idClose <= 0) return null;
|
||||
|
||||
const idOpen = payload.lastIndexOf(' [', idClose);
|
||||
if (idOpen <= 0 || idOpen >= idClose) return null;
|
||||
|
||||
const name = payload.slice(0, idOpen).trim();
|
||||
const id = payload.slice(idOpen + 2, idClose).trim();
|
||||
const type = payload.slice(typeOpen + 2, typeClose).trim();
|
||||
if (!name || !id || !type) return null;
|
||||
|
||||
return { name, id, type };
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function parseJellyfinLibrariesFromAppOutput(output: string): JellyfinLibraryEntry[] {
|
||||
function readString(record: Record<string, unknown>, key: string): string {
|
||||
const value = record[key];
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
export function parseJellyfinAppReply(raw: string): JellyfinAppReply | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!isRecord(parsed)) return null;
|
||||
const error = readString(parsed, 'error');
|
||||
if (error) return { ok: false, error: normalizeJellyfinAppError(error) };
|
||||
return { ok: true, payload: parsed };
|
||||
}
|
||||
|
||||
export function parseJellyfinLibrariesReply(
|
||||
payload: Record<string, unknown>,
|
||||
): JellyfinLibraryEntry[] | null {
|
||||
if (!Array.isArray(payload.libraries)) return null;
|
||||
const libraries: JellyfinLibraryEntry[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
|
||||
for (const rawLine of output.split(/\r?\n/)) {
|
||||
const line = stripAnsi(rawLine);
|
||||
const markerIndex = line.indexOf('Jellyfin library:');
|
||||
if (markerIndex < 0) continue;
|
||||
const payload = line.slice(markerIndex + 'Jellyfin library:'.length).trim();
|
||||
const parsed = parseNamedJellyfinRecord(payload);
|
||||
if (!parsed || seenIds.has(parsed.id)) continue;
|
||||
seenIds.add(parsed.id);
|
||||
for (const entry of payload.libraries) {
|
||||
if (!isRecord(entry)) continue;
|
||||
const id = readString(entry, 'id');
|
||||
if (!id || seenIds.has(id)) continue;
|
||||
seenIds.add(id);
|
||||
libraries.push({
|
||||
id: parsed.id,
|
||||
name: parsed.name,
|
||||
kind: parsed.type,
|
||||
id,
|
||||
name: readString(entry, 'name') || 'Untitled',
|
||||
kind: readString(entry, 'collectionType') || 'unknown',
|
||||
});
|
||||
}
|
||||
|
||||
return libraries;
|
||||
}
|
||||
|
||||
export function parseJellyfinItemsFromAppOutput(output: string): JellyfinItemEntry[] {
|
||||
export function parseJellyfinItemsReply(
|
||||
payload: Record<string, unknown>,
|
||||
): JellyfinItemEntry[] | null {
|
||||
if (!Array.isArray(payload.items)) return null;
|
||||
const items: JellyfinItemEntry[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
|
||||
for (const rawLine of output.split(/\r?\n/)) {
|
||||
const line = stripAnsi(rawLine);
|
||||
const markerIndex = line.indexOf('Jellyfin item:');
|
||||
if (markerIndex < 0) continue;
|
||||
const payload = line.slice(markerIndex + 'Jellyfin item:'.length).trim();
|
||||
const parsed = parseNamedJellyfinRecord(payload);
|
||||
if (!parsed || seenIds.has(parsed.id)) continue;
|
||||
seenIds.add(parsed.id);
|
||||
items.push({
|
||||
id: parsed.id,
|
||||
name: parsed.name,
|
||||
type: parsed.type,
|
||||
display: parsed.name,
|
||||
});
|
||||
for (const entry of payload.items) {
|
||||
if (!isRecord(entry)) continue;
|
||||
const id = readString(entry, 'id');
|
||||
if (!id || seenIds.has(id)) continue;
|
||||
seenIds.add(id);
|
||||
const title = readString(entry, 'title') || 'Untitled';
|
||||
items.push({ id, name: title, type: readString(entry, 'type'), display: title });
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function normalizeJellyfinAppError(message: string): string {
|
||||
if (message.includes('Missing Jellyfin session')) {
|
||||
return 'Missing Jellyfin session. Run `subminer jellyfin -l` to log in again.';
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
export function parseJellyfinErrorFromAppOutput(output: string): string {
|
||||
const lines = output.split(/\r?\n/).map((line) => stripAnsi(line).trim());
|
||||
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
||||
@@ -260,41 +273,20 @@ export function parseJellyfinErrorFromAppOutput(output: string): string {
|
||||
}
|
||||
|
||||
if (line.includes('Missing Jellyfin session')) {
|
||||
return 'Missing Jellyfin session. Run `subminer jellyfin -l` to log in again.';
|
||||
return normalizeJellyfinAppError(line);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
type JellyfinPreviewAuthResponse = {
|
||||
serverUrl: string;
|
||||
accessToken: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export function parseJellyfinPreviewAuthResponse(raw: string): JellyfinPreviewAuthResponse | null {
|
||||
if (!raw || raw.trim().length === 0) return null;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
|
||||
const candidate = parsed as Record<string, unknown>;
|
||||
const serverUrl = sanitizeServerUrl(
|
||||
typeof candidate.serverUrl === 'string' ? candidate.serverUrl : '',
|
||||
);
|
||||
const accessToken = typeof candidate.accessToken === 'string' ? candidate.accessToken.trim() : '';
|
||||
const userId = typeof candidate.userId === 'string' ? candidate.userId.trim() : '';
|
||||
export function parseJellyfinPreviewAuthResponse(
|
||||
payload: Record<string, unknown>,
|
||||
): JellyfinPreviewAuthPayload | null {
|
||||
const serverUrl = sanitizeServerUrl(readString(payload, 'serverUrl'));
|
||||
const accessToken = readString(payload, 'accessToken');
|
||||
const userId = readString(payload, 'userId');
|
||||
if (!serverUrl || !accessToken) return null;
|
||||
|
||||
return {
|
||||
serverUrl,
|
||||
accessToken,
|
||||
userId,
|
||||
};
|
||||
return { serverUrl, accessToken, userId };
|
||||
}
|
||||
|
||||
export function shouldRetryWithStartForNoRunningInstance(errorMessage: string): boolean {
|
||||
@@ -312,19 +304,6 @@ export function hasStoredJellyfinSession(
|
||||
return exists(deriveJellyfinTokenStorePath(configPath));
|
||||
}
|
||||
|
||||
export function readUtf8FileAppendedSince(logPath: string, offsetBytes: number): string {
|
||||
try {
|
||||
const buffer = fs.readFileSync(logPath);
|
||||
if (buffer.length === 0) return '';
|
||||
const normalizedOffset =
|
||||
Number.isFinite(offsetBytes) && offsetBytes >= 0 ? Math.floor(offsetBytes) : 0;
|
||||
const startOffset = normalizedOffset > buffer.length ? 0 : normalizedOffset;
|
||||
return buffer.subarray(startOffset).toString('utf8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function parseEpisodePathFromDisplay(
|
||||
display: string,
|
||||
): { seriesName: string; seasonNumber: number } | null {
|
||||
@@ -421,145 +400,79 @@ export function buildForwardedJellyfinAppArgs(args: Args, appArgs: string[]): st
|
||||
return forwarded;
|
||||
}
|
||||
|
||||
async function runAppJellyfinListCommand(
|
||||
appPath: string,
|
||||
args: Args,
|
||||
appArgs: string[],
|
||||
label: string,
|
||||
): Promise<string> {
|
||||
const attempt = await runAppJellyfinCommand(appPath, args, appArgs, label);
|
||||
if (attempt.status !== 0) {
|
||||
const message = attempt.output.trim();
|
||||
fail(message || `${label} failed.`);
|
||||
const JELLYFIN_APP_REPLY_TIMEOUT_MS = 60_000;
|
||||
const JELLYFIN_PREVIEW_AUTH_TIMEOUT_MS = 15_000;
|
||||
|
||||
function readJellyfinAppReply(responsePath: string): JellyfinAppReply | null {
|
||||
try {
|
||||
return parseJellyfinAppReply(fs.readFileSync(responsePath, 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (attempt.error) {
|
||||
fail(attempt.error);
|
||||
}
|
||||
return attempt.output;
|
||||
}
|
||||
|
||||
async function runAppJellyfinCommand(
|
||||
// Runs a Jellyfin command in the app and waits for the reply it writes to a temp file. The helper
|
||||
// process usually hands the command to an already-running instance and exits at once with no
|
||||
// output, so the reply file is the only result channel that works in both cases.
|
||||
async function requestJellyfinReplyFromApp(
|
||||
appPath: string,
|
||||
args: Args,
|
||||
appArgs: string[],
|
||||
label: string,
|
||||
): Promise<{ status: number; output: string; error: string; logOffset: number }> {
|
||||
const forwardedBase = buildForwardedJellyfinAppArgs(args, appArgs);
|
||||
timeoutMs: number,
|
||||
): Promise<JellyfinAppReply> {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jf-reply-'));
|
||||
const responsePath = path.join(tmpDir, 'response.json');
|
||||
const forwarded = buildForwardedJellyfinAppArgs(args, [
|
||||
...appArgs,
|
||||
`--jellyfin-response-path=${responsePath}`,
|
||||
]);
|
||||
|
||||
const readLogAppendedSince = (offset: number): string => {
|
||||
const logPath = getMpvLogPath();
|
||||
return readUtf8FileAppendedSince(logPath, offset);
|
||||
};
|
||||
|
||||
const hasCommandSignal = (output: string): boolean => {
|
||||
if (label === 'jellyfin-libraries') {
|
||||
return (
|
||||
output.includes('Jellyfin library:') || output.includes('No Jellyfin libraries found.')
|
||||
);
|
||||
}
|
||||
if (label === 'jellyfin-items') {
|
||||
return (
|
||||
output.includes('Jellyfin item:') ||
|
||||
output.includes('No Jellyfin items found for the selected library/search.')
|
||||
);
|
||||
}
|
||||
if (label === 'jellyfin-preview-auth') {
|
||||
return output.includes('Jellyfin preview auth written.');
|
||||
}
|
||||
return output.trim().length > 0;
|
||||
};
|
||||
|
||||
const runOnce = (): { status: number; output: string; error: string; logOffset: number } => {
|
||||
const forwarded = [...forwardedBase];
|
||||
const logPath = getMpvLogPath();
|
||||
let logOffset = 0;
|
||||
try {
|
||||
if (fs.existsSync(logPath)) {
|
||||
logOffset = fs.statSync(logPath).size;
|
||||
}
|
||||
} catch {
|
||||
logOffset = 0;
|
||||
}
|
||||
const runOnce = (): { status: number; output: string; error: string } => {
|
||||
log('debug', args.logLevel, `${label}: launching app with args: ${forwarded.join(' ')}`);
|
||||
const result = runAppCommandCaptureOutput(appPath, forwarded);
|
||||
log('debug', args.logLevel, `${label}: app command exited with status ${result.status}`);
|
||||
let output = `${result.stdout || ''}\n${result.stderr || ''}\n${readLogAppendedSince(logOffset)}`;
|
||||
let error = parseJellyfinErrorFromAppOutput(output);
|
||||
|
||||
return { status: result.status, output, error, logOffset };
|
||||
const output = `${result.stdout || ''}\n${result.stderr || ''}`;
|
||||
return { status: result.status, output, error: parseJellyfinErrorFromAppOutput(output) };
|
||||
};
|
||||
|
||||
let retriedAfterStart = false;
|
||||
let attempt = runOnce();
|
||||
if (shouldRetryWithStartForNoRunningInstance(attempt.error)) {
|
||||
log('debug', args.logLevel, `${label}: starting app detached, then retrying command`);
|
||||
launchAppStartDetached(appPath, args.logLevel);
|
||||
await sleep(1000);
|
||||
retriedAfterStart = true;
|
||||
attempt = runOnce();
|
||||
}
|
||||
|
||||
if (attempt.status === 0 && !attempt.error && !hasCommandSignal(attempt.output)) {
|
||||
// When app is already running, command handling happens in the primary process and log
|
||||
// lines can land slightly after the helper process exits.
|
||||
const settleWindowMs = (() => {
|
||||
if (label === 'jellyfin-items') {
|
||||
return retriedAfterStart ? 45000 : 30000;
|
||||
}
|
||||
return retriedAfterStart ? 12000 : 4000;
|
||||
})();
|
||||
const settleDeadline = nowMs() + settleWindowMs;
|
||||
const settleOffset = attempt.logOffset;
|
||||
while (nowMs() < settleDeadline) {
|
||||
await sleep(100);
|
||||
const settledOutput = readLogAppendedSince(settleOffset);
|
||||
if (!settledOutput.trim()) {
|
||||
continue;
|
||||
}
|
||||
attempt.output = `${attempt.output}\n${settledOutput}`;
|
||||
attempt.error = parseJellyfinErrorFromAppOutput(attempt.output);
|
||||
if (attempt.error || hasCommandSignal(attempt.output)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return attempt;
|
||||
}
|
||||
|
||||
async function requestJellyfinPreviewAuthFromApp(
|
||||
appPath: string,
|
||||
args: Args,
|
||||
): Promise<JellyfinPreviewAuthResponse | null> {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jf-preview-auth-'));
|
||||
const responsePath = path.join(tmpDir, 'response.json');
|
||||
try {
|
||||
const attempt = await runAppJellyfinCommand(
|
||||
appPath,
|
||||
args,
|
||||
['--jellyfin-preview-auth', `--jellyfin-response-path=${responsePath}`],
|
||||
'jellyfin-preview-auth',
|
||||
);
|
||||
if (attempt.status !== 0 || attempt.error) {
|
||||
return null;
|
||||
let attempt = runOnce();
|
||||
if (shouldRetryWithStartForNoRunningInstance(attempt.error)) {
|
||||
// Start it the way `subminer app` does: the app outlives this picker and the playback, so it
|
||||
// needs its tray to be visible and quittable.
|
||||
log(
|
||||
'debug',
|
||||
args.logLevel,
|
||||
`${label}: starting app in the background, then retrying command`,
|
||||
);
|
||||
launchAppBackgroundDetached(appPath, args.logLevel);
|
||||
adoptLauncherStartedApp(appPath, args);
|
||||
// A cold start (an AppImage mount, first-run setup) can take a while to claim the instance
|
||||
// lock, so keep retrying until the app takes the command or the request deadline passes.
|
||||
const startDeadline = nowMs() + timeoutMs;
|
||||
do {
|
||||
await sleep(1000);
|
||||
attempt = runOnce();
|
||||
} while (shouldRetryWithStartForNoRunningInstance(attempt.error) && nowMs() < startDeadline);
|
||||
}
|
||||
// A written reply is authoritative; the exit status and log lines only explain a missing one.
|
||||
const earlyReply = readJellyfinAppReply(responsePath);
|
||||
if (earlyReply) return earlyReply;
|
||||
if (attempt.status !== 0) {
|
||||
return { ok: false, error: attempt.error || attempt.output.trim() || `${label} failed.` };
|
||||
}
|
||||
if (attempt.error) {
|
||||
return { ok: false, error: attempt.error };
|
||||
}
|
||||
|
||||
const deadline = nowMs() + 4000;
|
||||
const deadline = nowMs() + timeoutMs;
|
||||
while (nowMs() < deadline) {
|
||||
try {
|
||||
if (fs.existsSync(responsePath)) {
|
||||
const raw = fs.readFileSync(responsePath, 'utf8');
|
||||
const parsed = parseJellyfinPreviewAuthResponse(raw);
|
||||
if (parsed) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// retry until timeout
|
||||
}
|
||||
const reply = readJellyfinAppReply(responsePath);
|
||||
if (reply) return reply;
|
||||
await sleep(100);
|
||||
}
|
||||
return null;
|
||||
return { ok: false, error: `Timed out waiting for SubMiner to answer ${label}.` };
|
||||
} finally {
|
||||
try {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
@@ -569,19 +482,57 @@ async function requestJellyfinPreviewAuthFromApp(
|
||||
}
|
||||
}
|
||||
|
||||
async function requestJellyfinPreviewAuthFromApp(
|
||||
appPath: string,
|
||||
args: Args,
|
||||
): Promise<JellyfinPreviewAuthPayload | null> {
|
||||
const reply = await requestJellyfinReplyFromApp(
|
||||
appPath,
|
||||
args,
|
||||
['--jellyfin-preview-auth'],
|
||||
'jellyfin-preview-auth',
|
||||
JELLYFIN_PREVIEW_AUTH_TIMEOUT_MS,
|
||||
);
|
||||
if (!reply.ok) {
|
||||
log('debug', args.logLevel, `jellyfin-preview-auth: ${reply.error}`);
|
||||
return null;
|
||||
}
|
||||
return parseJellyfinPreviewAuthResponse(reply.payload);
|
||||
}
|
||||
|
||||
async function requestJellyfinListFromApp<T>(
|
||||
appPath: string,
|
||||
args: Args,
|
||||
appArgs: string[],
|
||||
label: string,
|
||||
parse: (payload: Record<string, unknown>) => T[] | null,
|
||||
): Promise<T[]> {
|
||||
const reply = await requestJellyfinReplyFromApp(
|
||||
appPath,
|
||||
args,
|
||||
appArgs,
|
||||
label,
|
||||
JELLYFIN_APP_REPLY_TIMEOUT_MS,
|
||||
);
|
||||
if (!reply.ok) fail(reply.error);
|
||||
const entries = parse(reply.payload);
|
||||
if (!entries) fail(`Unexpected reply from SubMiner for ${label}.`);
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function resolveJellyfinSelectionViaApp(
|
||||
appPath: string,
|
||||
args: Args,
|
||||
session: JellyfinSessionConfig,
|
||||
themePath: string | null = null,
|
||||
): Promise<string> {
|
||||
const listLibrariesOutput = await runAppJellyfinListCommand(
|
||||
const libraries = await requestJellyfinListFromApp(
|
||||
appPath,
|
||||
args,
|
||||
['--jellyfin-libraries'],
|
||||
'jellyfin-libraries',
|
||||
parseJellyfinLibrariesReply,
|
||||
);
|
||||
const libraries = parseJellyfinLibrariesFromAppOutput(listLibrariesOutput);
|
||||
if (libraries.length === 0) {
|
||||
fail('No Jellyfin libraries found.');
|
||||
}
|
||||
@@ -655,8 +606,13 @@ async function resolveJellyfinSelectionViaApp(
|
||||
if (includeItemTypes) {
|
||||
itemArgs.push(`--jellyfin-include-item-types=${includeItemTypes}`);
|
||||
}
|
||||
const output = await runAppJellyfinListCommand(appPath, args, itemArgs, 'jellyfin-items');
|
||||
return parseJellyfinItemsFromAppOutput(output);
|
||||
return requestJellyfinListFromApp(
|
||||
appPath,
|
||||
args,
|
||||
itemArgs,
|
||||
'jellyfin-items',
|
||||
parseJellyfinItemsReply,
|
||||
);
|
||||
};
|
||||
|
||||
let rootItems =
|
||||
@@ -1106,5 +1062,22 @@ export async function runJellyfinPlayMenuWithDeps(
|
||||
const forwarded = ['--start', '--jellyfin-play', `--jellyfin-item-id=${itemId}`];
|
||||
if (shouldForwardLogLevel(args.logLevel)) forwarded.push('--log-level', args.logLevel);
|
||||
if (args.passwordStore) forwarded.push('--password-store', args.passwordStore);
|
||||
deps.runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');
|
||||
if (!deps.isAppManagedByLauncher()) {
|
||||
// A running app keeps running after playback; a cold `--start --jellyfin-play` makes the app
|
||||
// quit with mpv on its own.
|
||||
deps.runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');
|
||||
}
|
||||
// The picker started the app, so this playback owns it, as with local playback: hand over the
|
||||
// item, wait for mpv to close, then exit, which stops the app.
|
||||
const handoff = deps.runAppCommandCaptureOutput(appPath, forwarded);
|
||||
if (handoff.status !== 0) {
|
||||
fail(handoff.error?.message || handoff.stderr.trim() || 'Failed to start Jellyfin playback.');
|
||||
}
|
||||
deps.log(
|
||||
'debug',
|
||||
args.logLevel,
|
||||
'jellyfin-play: waiting for mpv to close before stopping the app',
|
||||
);
|
||||
await deps.waitForUnixSocketClosed(mpvSocketPath);
|
||||
return deps.exitProcess(0);
|
||||
}
|
||||
+325
-894
File diff suppressed because it is too large.
Load diff
+523
-964
File diff suppressed because it is too large.
Load diff
@@ -1121,6 +1121,21 @@ function clearOverlayManagedByLauncher(): void {
|
||||
state.overlayManagedByLauncher = false;
|
||||
}
|
||||
|
||||
// For commands that start the app ahead of playback (the Jellyfin picker): the launcher now owns
|
||||
// it, as with local playback, and stops it when the launcher exits for any reason, including a
|
||||
// cancelled picker or Ctrl+C.
|
||||
export function adoptLauncherStartedApp(appPath: string, args: Args): void {
|
||||
if (state.overlayManagedByLauncher) return;
|
||||
markOverlayManagedByLauncher(appPath);
|
||||
process.once('exit', () => stopManagedOverlayApp(args));
|
||||
process.once('SIGINT', () => process.exit(130));
|
||||
process.once('SIGTERM', () => process.exit(143));
|
||||
}
|
||||
|
||||
export function isAppManagedByLauncher(): boolean {
|
||||
return state.overlayManagedByLauncher;
|
||||
}
|
||||
|
||||
function isAppAlreadyRunning(appPath: string, logLevel: LogLevel): boolean {
|
||||
const result = runSyncAppCommand(appPath, ['--app-ping'], false);
|
||||
if (result.error) {
|
||||
@@ -1723,6 +1738,13 @@ export async function canConnectUnixSocket(socketPath: string): Promise<boolean>
|
||||
});
|
||||
}
|
||||
|
||||
// Resolves once mpv stops accepting connections on its IPC socket, i.e. the player was closed.
|
||||
export async function waitForUnixSocketClosed(socketPath: string, pollMs = 1000): Promise<void> {
|
||||
while (await canConnectUnixSocket(socketPath)) {
|
||||
await sleep(pollMs);
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForUnixSocketReady(
|
||||
socketPath: string,
|
||||
timeoutMs: number,
|
||||
|
||||
@@ -30,12 +30,6 @@ test('parseArgs captures mpv args string', () => {
|
||||
assert.equal(parsed.mpvArgs, '--pause=yes --title="movie night"');
|
||||
});
|
||||
|
||||
test('parseArgs appends CLI mpv profile to configured mpv profile', () => {
|
||||
const parsed = parseArgs(['--profile', 'hdr'], 'subminer', {}, { profile: 'anime' });
|
||||
|
||||
assert.equal(parsed.profile, 'anime,hdr');
|
||||
});
|
||||
|
||||
test('parseArgs maps root settings window option', () => {
|
||||
const parsed = parseArgs(['--settings'], 'subminer', {});
|
||||
|
||||
@@ -224,14 +218,6 @@ test('parseArgs maps stats cleanup to vocab mode by default', () => {
|
||||
assert.equal(parsed.statsCleanupVocab, true);
|
||||
});
|
||||
|
||||
test('parseArgs maps explicit stats cleanup vocab flag', () => {
|
||||
const parsed = parseArgs(['stats', 'cleanup', '-v'], 'subminer', {});
|
||||
|
||||
assert.equal(parsed.stats, true);
|
||||
assert.equal(parsed.statsCleanup, true);
|
||||
assert.equal(parsed.statsCleanupVocab, true);
|
||||
});
|
||||
|
||||
test('parseArgs maps lifetime stats cleanup flag', () => {
|
||||
const parsed = parseArgs(['stats', 'cleanup', '--lifetime'], 'subminer', {});
|
||||
|
||||
|
||||
+19
-3
@@ -119,11 +119,27 @@ export async function promptOptionalJellyfinSearch(
|
||||
themePath: string | null = null,
|
||||
): Promise<string> {
|
||||
if (useRofi && commandExists('rofi')) {
|
||||
const rofiArgs = ['-dmenu', '-i', '-p', formatRofiPrompt('Jellyfin Search (optional)')];
|
||||
// Input-only prompt: the shipped theme pins a tall window and has no message widget, so
|
||||
// drop the listview (an empty row there reads as "no results") and show the hint instead.
|
||||
const inputOnlyTheme =
|
||||
'window { height: 0; } mainbox { children: [inputbar, message]; } ' +
|
||||
'message { background-color: transparent; padding: 10px 18px 4px 18px; } ' +
|
||||
'textbox { background-color: transparent; }';
|
||||
const rofiArgs = [
|
||||
'-dmenu',
|
||||
'-i',
|
||||
'-p',
|
||||
formatRofiPrompt('Jellyfin Search (optional)'),
|
||||
'-mesg',
|
||||
'Type to search, or press Enter to browse all',
|
||||
];
|
||||
if (themePath) {
|
||||
rofiArgs.push('-theme', themePath);
|
||||
rofiArgs.push('-theme', themePath, '-theme-str', inputOnlyTheme);
|
||||
} else {
|
||||
rofiArgs.push('-theme-str', 'configuration { font: "Noto Sans CJK JP Regular 8";}');
|
||||
rofiArgs.push(
|
||||
'-theme-str',
|
||||
`configuration { font: "Noto Sans CJK JP Regular 8";} ${inputOnlyTheme}`,
|
||||
);
|
||||
}
|
||||
const result = spawnSync('rofi', rofiArgs, {
|
||||
input: '\n',
|
||||
|
||||
@@ -8,282 +8,168 @@ import {
|
||||
installManagedPluginAssetsViaApp,
|
||||
} from './runtime-plugin-preflight';
|
||||
|
||||
test('ensureLinuxRuntimePluginAvailable is a no-op on non-Linux platforms', async () => {
|
||||
const calls: string[] = [];
|
||||
type EnsureOptions = Parameters<typeof ensureLinuxRuntimePluginAvailable>[0];
|
||||
|
||||
await ensureLinuxRuntimePluginAvailable({
|
||||
platform: 'darwin',
|
||||
detectInstalledPlugin: () => {
|
||||
calls.push('detect');
|
||||
return false;
|
||||
},
|
||||
resolveRuntimePluginPath: () => {
|
||||
calls.push('resolve');
|
||||
return null;
|
||||
},
|
||||
installManagedPluginAssets: async () => {
|
||||
calls.push('install');
|
||||
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||
},
|
||||
log: () => {
|
||||
calls.push('log');
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('ensureLinuxRuntimePluginAvailable skips install when plugin, theme, and thumbnailer exist', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await ensureLinuxRuntimePluginAvailable({
|
||||
platform: 'linux',
|
||||
detectInstalledPlugin: () => {
|
||||
calls.push('detect');
|
||||
return true;
|
||||
},
|
||||
resolveRuntimePluginPath: () => {
|
||||
calls.push('resolve');
|
||||
return null;
|
||||
},
|
||||
installManagedPluginAssets: async () => {
|
||||
calls.push('install');
|
||||
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||
},
|
||||
isManagedThemeAvailable: () => {
|
||||
calls.push('theme');
|
||||
return true;
|
||||
},
|
||||
isManagedThumbnailerAvailable: () => {
|
||||
calls.push('thumbnailer');
|
||||
return true;
|
||||
},
|
||||
log: () => {},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['detect', 'theme', 'thumbnailer']);
|
||||
});
|
||||
|
||||
test('ensureLinuxRuntimePluginAvailable skips install when all managed assets resolve', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await ensureLinuxRuntimePluginAvailable({
|
||||
platform: 'linux',
|
||||
xdgDataHome: '/tmp/xdg-data',
|
||||
detectInstalledPlugin: () => {
|
||||
calls.push('detect');
|
||||
return false;
|
||||
},
|
||||
resolveRuntimePluginPath: () => {
|
||||
calls.push('resolve');
|
||||
return '/tmp/plugin/main.lua';
|
||||
},
|
||||
installManagedPluginAssets: async () => {
|
||||
calls.push('install');
|
||||
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||
},
|
||||
isManagedThemeAvailable: () => {
|
||||
calls.push('theme');
|
||||
return true;
|
||||
},
|
||||
isManagedThumbnailerAvailable: () => {
|
||||
calls.push('thumbnailer');
|
||||
return true;
|
||||
},
|
||||
log: () => {},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['detect', 'resolve', 'theme', 'thumbnailer']);
|
||||
});
|
||||
|
||||
test('ensureLinuxRuntimePluginAvailable installs managed assets when rofi theme is missing', async () => {
|
||||
const calls: string[] = [];
|
||||
let themeAvailable = false;
|
||||
|
||||
await ensureLinuxRuntimePluginAvailable({
|
||||
platform: 'linux',
|
||||
xdgDataHome: '/tmp/xdg-data',
|
||||
detectInstalledPlugin: () => {
|
||||
calls.push('detect');
|
||||
return false;
|
||||
},
|
||||
resolveRuntimePluginPath: () => {
|
||||
calls.push('resolve');
|
||||
return '/tmp/plugin/main.lua';
|
||||
},
|
||||
isManagedThemeAvailable: () => {
|
||||
calls.push('theme');
|
||||
return themeAvailable;
|
||||
},
|
||||
isManagedThumbnailerAvailable: () => {
|
||||
calls.push('thumbnailer');
|
||||
return true;
|
||||
},
|
||||
installManagedPluginAssets: async () => {
|
||||
calls.push('install');
|
||||
themeAvailable = true;
|
||||
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||
},
|
||||
log: (level, _configured, message) => {
|
||||
calls.push(`${level}:${message}`);
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'detect',
|
||||
'resolve',
|
||||
'theme',
|
||||
'info:Linux runtime support assets missing; installing managed plugin/theme/thumbnailer assets.',
|
||||
'install',
|
||||
'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi thumbnailer=/tmp/xdg-data/SubMiner/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer',
|
||||
'resolve',
|
||||
'theme',
|
||||
'thumbnailer',
|
||||
]);
|
||||
});
|
||||
|
||||
test('ensureLinuxRuntimePluginAvailable installs managed assets when thumbnailer is missing', async () => {
|
||||
const calls: string[] = [];
|
||||
let thumbnailerAvailable = false;
|
||||
|
||||
await ensureLinuxRuntimePluginAvailable({
|
||||
platform: 'linux',
|
||||
xdgDataHome: '/tmp/xdg-data',
|
||||
detectInstalledPlugin: () => true,
|
||||
resolveRuntimePluginPath: () => '/tmp/plugin/main.lua',
|
||||
isManagedThemeAvailable: () => true,
|
||||
isManagedThumbnailerAvailable: () => thumbnailerAvailable,
|
||||
installManagedPluginAssets: async () => {
|
||||
calls.push('install');
|
||||
thumbnailerAvailable = true;
|
||||
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||
},
|
||||
log: (_level, _configured, message) => {
|
||||
calls.push(message);
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'Linux runtime support assets missing; installing managed plugin/theme/thumbnailer assets.',
|
||||
'install',
|
||||
'Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi thumbnailer=/tmp/xdg-data/SubMiner/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer',
|
||||
]);
|
||||
});
|
||||
|
||||
test('ensureLinuxRuntimePluginAvailable retains an installed plugin after installing support assets', async () => {
|
||||
const calls: string[] = [];
|
||||
let thumbnailerAvailable = false;
|
||||
|
||||
await ensureLinuxRuntimePluginAvailable({
|
||||
platform: 'linux',
|
||||
xdgDataHome: '/tmp/xdg-data',
|
||||
detectInstalledPlugin: () => true,
|
||||
resolveRuntimePluginPath: () => {
|
||||
calls.push('resolve');
|
||||
return null;
|
||||
},
|
||||
isManagedThemeAvailable: () => true,
|
||||
isManagedThumbnailerAvailable: () => thumbnailerAvailable,
|
||||
installManagedPluginAssets: async () => {
|
||||
calls.push('install');
|
||||
thumbnailerAvailable = true;
|
||||
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||
},
|
||||
log: () => {},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['install']);
|
||||
});
|
||||
|
||||
test('ensureLinuxRuntimePluginAvailable installs managed assets and re-resolves plugin path', async () => {
|
||||
const calls: string[] = [];
|
||||
let resolveCount = 0;
|
||||
|
||||
await ensureLinuxRuntimePluginAvailable({
|
||||
/**
|
||||
* Linux preflight harness: by default every managed asset resolves, so tests override only
|
||||
* the probes that should report something missing. Counts installs and collects log messages.
|
||||
*/
|
||||
function makeEnsureHarness(overrides: Partial<EnsureOptions> = {}) {
|
||||
const state = { installs: 0, logs: [] as string[] };
|
||||
const options: EnsureOptions = {
|
||||
platform: 'linux',
|
||||
xdgDataHome: '/tmp/xdg-data',
|
||||
detectInstalledPlugin: () => false,
|
||||
resolveRuntimePluginPath: () => {
|
||||
resolveCount += 1;
|
||||
calls.push(`resolve:${resolveCount}`);
|
||||
return resolveCount === 1 ? null : '/tmp/plugin/main.lua';
|
||||
},
|
||||
resolveRuntimePluginPath: () => '/tmp/plugin/main.lua',
|
||||
isManagedThemeAvailable: () => true,
|
||||
isManagedThumbnailerAvailable: () => true,
|
||||
installManagedPluginAssets: async () => {
|
||||
calls.push('install');
|
||||
state.installs += 1;
|
||||
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||
},
|
||||
log: (level, _configured, message) => {
|
||||
calls.push(`${level}:${message}`);
|
||||
log: (_level, _configured, message) => {
|
||||
state.logs.push(message);
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
return { state, run: () => ensureLinuxRuntimePluginAvailable(options) };
|
||||
}
|
||||
|
||||
test('ensureLinuxRuntimePluginAvailable is a no-op on non-Linux platforms', async () => {
|
||||
const unexpected = () => {
|
||||
throw new Error('probed on a non-Linux platform');
|
||||
};
|
||||
const harness = makeEnsureHarness({
|
||||
platform: 'darwin',
|
||||
detectInstalledPlugin: unexpected,
|
||||
resolveRuntimePluginPath: unexpected,
|
||||
isManagedThemeAvailable: unexpected,
|
||||
isManagedThumbnailerAvailable: unexpected,
|
||||
});
|
||||
|
||||
await harness.run();
|
||||
|
||||
assert.equal(harness.state.installs, 0);
|
||||
assert.deepEqual(harness.state.logs, []);
|
||||
});
|
||||
|
||||
const skipInstallCases: Array<{ name: string; overrides: Partial<EnsureOptions> }> = [
|
||||
{
|
||||
name: 'plugin, theme, and thumbnailer exist',
|
||||
overrides: { detectInstalledPlugin: () => true, resolveRuntimePluginPath: () => null },
|
||||
},
|
||||
{ name: 'all managed assets resolve', overrides: {} },
|
||||
];
|
||||
|
||||
for (const c of skipInstallCases) {
|
||||
test(`ensureLinuxRuntimePluginAvailable skips install when ${c.name}`, async () => {
|
||||
const harness = makeEnsureHarness(c.overrides);
|
||||
|
||||
await harness.run();
|
||||
|
||||
assert.equal(harness.state.installs, 0);
|
||||
assert.deepEqual(harness.state.logs, []);
|
||||
});
|
||||
}
|
||||
|
||||
const installWhenMissingCases: Array<{ name: string; missing: 'theme' | 'thumbnailer' }> = [
|
||||
{ name: 'rofi theme is missing', missing: 'theme' },
|
||||
{ name: 'thumbnailer is missing', missing: 'thumbnailer' },
|
||||
];
|
||||
|
||||
for (const c of installWhenMissingCases) {
|
||||
test(`ensureLinuxRuntimePluginAvailable installs managed assets when ${c.name}`, async () => {
|
||||
let available = false;
|
||||
const probe = {
|
||||
[c.missing === 'theme' ? 'isManagedThemeAvailable' : 'isManagedThumbnailerAvailable']: () =>
|
||||
available,
|
||||
};
|
||||
const harness = makeEnsureHarness({
|
||||
...probe,
|
||||
installManagedPluginAssets: async () => {
|
||||
harness.state.installs += 1;
|
||||
available = true;
|
||||
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||
},
|
||||
});
|
||||
|
||||
await harness.run();
|
||||
|
||||
assert.equal(harness.state.installs, 1);
|
||||
assert.equal(harness.state.logs.length, 2);
|
||||
assert.match(harness.state.logs[0]!, /support assets missing; installing/);
|
||||
assert.match(
|
||||
harness.state.logs[1]!,
|
||||
/installed: plugin=\/tmp\/plugin\/main\.lua theme=.*subminer\.rasi thumbnailer=.*\.thumbnailer/,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('ensureLinuxRuntimePluginAvailable retains an installed plugin after installing support assets', async () => {
|
||||
let thumbnailerAvailable = false;
|
||||
const harness = makeEnsureHarness({
|
||||
detectInstalledPlugin: () => true,
|
||||
// An installed plugin means the bundled path is never needed, even after the install.
|
||||
resolveRuntimePluginPath: () => null,
|
||||
isManagedThumbnailerAvailable: () => thumbnailerAvailable,
|
||||
installManagedPluginAssets: async () => {
|
||||
harness.state.installs += 1;
|
||||
thumbnailerAvailable = true;
|
||||
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'resolve:1',
|
||||
'info:Linux runtime support assets missing; installing managed plugin/theme/thumbnailer assets.',
|
||||
'install',
|
||||
'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi thumbnailer=/tmp/xdg-data/SubMiner/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer',
|
||||
'resolve:2',
|
||||
]);
|
||||
await harness.run();
|
||||
|
||||
assert.equal(harness.state.installs, 1);
|
||||
});
|
||||
|
||||
test('ensureLinuxRuntimePluginAvailable fails when install result is not ok', async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
ensureLinuxRuntimePluginAvailable({
|
||||
platform: 'linux',
|
||||
detectInstalledPlugin: () => false,
|
||||
resolveRuntimePluginPath: () => null,
|
||||
installManagedPluginAssets: async () => ({
|
||||
ok: false,
|
||||
status: 'failed',
|
||||
error: 'copy failed',
|
||||
}),
|
||||
log: () => {},
|
||||
}),
|
||||
/copy failed/,
|
||||
);
|
||||
test('ensureLinuxRuntimePluginAvailable installs managed assets and re-resolves plugin path', async () => {
|
||||
let installed = false;
|
||||
const harness = makeEnsureHarness({
|
||||
resolveRuntimePluginPath: () => (installed ? '/tmp/plugin/main.lua' : null),
|
||||
installManagedPluginAssets: async () => {
|
||||
harness.state.installs += 1;
|
||||
installed = true;
|
||||
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||
},
|
||||
});
|
||||
|
||||
await harness.run();
|
||||
|
||||
assert.equal(harness.state.installs, 1);
|
||||
});
|
||||
|
||||
test('ensureLinuxRuntimePluginAvailable fails when runtime path remains unresolved after install', async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
ensureLinuxRuntimePluginAvailable({
|
||||
platform: 'linux',
|
||||
detectInstalledPlugin: () => false,
|
||||
resolveRuntimePluginPath: () => null,
|
||||
installManagedPluginAssets: async () => ({
|
||||
ok: true,
|
||||
status: 'installed',
|
||||
path: '/tmp/plugin/main.lua',
|
||||
}),
|
||||
log: () => {},
|
||||
const failureCases: Array<{ name: string; overrides: Partial<EnsureOptions>; error: RegExp }> = [
|
||||
{
|
||||
name: 'install result is not ok',
|
||||
overrides: {
|
||||
resolveRuntimePluginPath: () => null,
|
||||
installManagedPluginAssets: async () => ({
|
||||
ok: false,
|
||||
status: 'failed',
|
||||
error: 'copy failed',
|
||||
}),
|
||||
/managed runtime plugin assets could not be installed/i,
|
||||
);
|
||||
});
|
||||
},
|
||||
error: /copy failed/,
|
||||
},
|
||||
{
|
||||
name: 'runtime path remains unresolved after install',
|
||||
overrides: { resolveRuntimePluginPath: () => null },
|
||||
error: /managed runtime plugin assets could not be installed/i,
|
||||
},
|
||||
{
|
||||
name: 'thumbnailer remains missing after install',
|
||||
overrides: { detectInstalledPlugin: () => true, isManagedThumbnailerAvailable: () => false },
|
||||
error: /thumbnailer=.*subminer-ffmpegthumbnailer\.thumbnailer/i,
|
||||
},
|
||||
];
|
||||
|
||||
test('ensureLinuxRuntimePluginAvailable fails when thumbnailer remains missing after install', async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
ensureLinuxRuntimePluginAvailable({
|
||||
platform: 'linux',
|
||||
xdgDataHome: '/tmp/xdg-data',
|
||||
detectInstalledPlugin: () => true,
|
||||
resolveRuntimePluginPath: () => '/tmp/plugin/main.lua',
|
||||
isManagedThemeAvailable: () => true,
|
||||
isManagedThumbnailerAvailable: () => false,
|
||||
installManagedPluginAssets: async () => ({
|
||||
ok: true,
|
||||
status: 'installed',
|
||||
path: '/tmp/plugin/main.lua',
|
||||
}),
|
||||
log: () => {},
|
||||
}),
|
||||
/thumbnailer=.*subminer-ffmpegthumbnailer\.thumbnailer/i,
|
||||
);
|
||||
});
|
||||
for (const c of failureCases) {
|
||||
test(`ensureLinuxRuntimePluginAvailable fails when ${c.name}`, async () => {
|
||||
await assert.rejects(() => makeEnsureHarness(c.overrides).run(), c.error);
|
||||
});
|
||||
}
|
||||
|
||||
test('ensureLinuxRuntimePluginAvailable rejects a thumbnailer directory before and after install', async () => {
|
||||
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-thumbnailer-directory-'));
|
||||
|
||||
+105
-211
@@ -3,104 +3,90 @@ import assert from 'node:assert/strict';
|
||||
import { ensureLauncherSetupReady, waitForSetupCompletion } from './setup-gate';
|
||||
import type { SetupState } from '../src/shared/setup-state';
|
||||
|
||||
const commandLineSetupDefaults = {
|
||||
bunInstallStatus: 'unknown',
|
||||
launcherInstallStatus: 'unknown',
|
||||
launcherInstallPath: null,
|
||||
} satisfies Pick<SetupState, 'bunInstallStatus' | 'launcherInstallStatus' | 'launcherInstallPath'>;
|
||||
/** An in-progress setup state; tests override only the fields that matter. */
|
||||
function makeState(overrides: Partial<SetupState> = {}): SetupState {
|
||||
return {
|
||||
version: 4,
|
||||
status: 'in_progress',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
yomitanSetupMode: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
bunInstallStatus: 'unknown',
|
||||
launcherInstallStatus: 'unknown',
|
||||
launcherInstallPath: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('waitForSetupCompletion resolves completed and cancelled states', async () => {
|
||||
const sequence: Array<SetupState | null> = [
|
||||
null,
|
||||
{
|
||||
version: 4,
|
||||
status: 'in_progress',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
yomitanSetupMode: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
...commandLineSetupDefaults,
|
||||
},
|
||||
{
|
||||
version: 4,
|
||||
status: 'completed',
|
||||
completedAt: '2026-03-07T00:00:00.000Z',
|
||||
completionSource: 'user',
|
||||
yomitanSetupMode: 'internal',
|
||||
lastSeenYomitanDictionaryCount: 1,
|
||||
pluginInstallStatus: 'skipped',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'skipped',
|
||||
...commandLineSetupDefaults,
|
||||
},
|
||||
];
|
||||
function completedState(overrides: Partial<SetupState> = {}): SetupState {
|
||||
return makeState({
|
||||
status: 'completed',
|
||||
completedAt: '2026-03-07T00:00:00.000Z',
|
||||
completionSource: 'user',
|
||||
yomitanSetupMode: 'internal',
|
||||
lastSeenYomitanDictionaryCount: 1,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await waitForSetupCompletion({
|
||||
readSetupState: () => sequence.shift() ?? null,
|
||||
const cancelledState = () => makeState({ status: 'cancelled' });
|
||||
|
||||
/** Deterministic clock: every `now()` advances 100ms and `sleep` returns immediately. */
|
||||
function fakeClock() {
|
||||
let value = 0;
|
||||
return {
|
||||
sleep: async () => undefined,
|
||||
now: (() => {
|
||||
let value = 0;
|
||||
return () => (value += 100);
|
||||
})(),
|
||||
now: () => (value += 100),
|
||||
timeoutMs: 5_000,
|
||||
pollIntervalMs: 100,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
assert.equal(result, 'completed');
|
||||
});
|
||||
/** Reader that serves `states` in order, then repeats the last one; counts its reads. */
|
||||
function sequenceReader(states: Array<SetupState | null>) {
|
||||
const reader = {
|
||||
reads: 0,
|
||||
read: (): SetupState | null => states[Math.min(reader.reads++, states.length - 1)] ?? null,
|
||||
};
|
||||
return reader;
|
||||
}
|
||||
|
||||
for (const c of [
|
||||
{ name: 'completed', last: completedState(), expected: 'completed' },
|
||||
{ name: 'cancelled', last: cancelledState(), expected: 'cancelled' },
|
||||
] as const) {
|
||||
test(`waitForSetupCompletion resolves ${c.name} state after polling through earlier states`, async () => {
|
||||
const reader = sequenceReader([null, makeState(), c.last]);
|
||||
|
||||
const result = await waitForSetupCompletion({
|
||||
readSetupState: reader.read,
|
||||
...fakeClock(),
|
||||
});
|
||||
|
||||
assert.equal(result, c.expected);
|
||||
assert.equal(reader.reads, 3);
|
||||
});
|
||||
}
|
||||
|
||||
test('ensureLauncherSetupReady launches setup app and resumes only after completion', async () => {
|
||||
const calls: string[] = [];
|
||||
let reads = 0;
|
||||
const reader = sequenceReader([
|
||||
null,
|
||||
makeState(),
|
||||
completedState({ pluginInstallStatus: 'installed', pluginInstallPathSummary: '/tmp/mpv' }),
|
||||
]);
|
||||
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
readSetupState: () => {
|
||||
reads += 1;
|
||||
if (reads === 1) return null;
|
||||
if (reads === 2) {
|
||||
return {
|
||||
version: 4,
|
||||
status: 'in_progress',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
yomitanSetupMode: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
...commandLineSetupDefaults,
|
||||
};
|
||||
}
|
||||
return {
|
||||
version: 4,
|
||||
status: 'completed',
|
||||
completedAt: '2026-03-07T00:00:00.000Z',
|
||||
completionSource: 'user',
|
||||
yomitanSetupMode: 'internal',
|
||||
lastSeenYomitanDictionaryCount: 1,
|
||||
pluginInstallStatus: 'installed',
|
||||
pluginInstallPathSummary: '/tmp/mpv',
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'installed',
|
||||
...commandLineSetupDefaults,
|
||||
};
|
||||
},
|
||||
readSetupState: reader.read,
|
||||
launchSetupApp: () => {
|
||||
calls.push('launch');
|
||||
},
|
||||
sleep: async () => undefined,
|
||||
now: (() => {
|
||||
let value = 0;
|
||||
return () => (value += 100);
|
||||
})(),
|
||||
timeoutMs: 5_000,
|
||||
pollIntervalMs: 100,
|
||||
...fakeClock(),
|
||||
});
|
||||
|
||||
assert.equal(ready, true);
|
||||
@@ -116,10 +102,7 @@ test('ensureLauncherSetupReady bypasses setup gate when external yomitan is conf
|
||||
launchSetupApp: () => {
|
||||
calls.push('launch');
|
||||
},
|
||||
sleep: async () => undefined,
|
||||
now: () => 0,
|
||||
timeoutMs: 5_000,
|
||||
pollIntervalMs: 100,
|
||||
...fakeClock(),
|
||||
});
|
||||
|
||||
assert.equal(ready, true);
|
||||
@@ -129,76 +112,47 @@ test('ensureLauncherSetupReady bypasses setup gate when external yomitan is conf
|
||||
test('ensureLauncherSetupReady waits for finish after legacy mpv plugin removal', async () => {
|
||||
const calls: string[] = [];
|
||||
let legacyPluginInstalled = true;
|
||||
let reads = 0;
|
||||
// The completion timestamp moves forward once the setup app has finished.
|
||||
const reader = sequenceReader([
|
||||
completedState({ yomitanSetupMode: null, lastSeenYomitanDictionaryCount: 0 }),
|
||||
completedState({ yomitanSetupMode: null, lastSeenYomitanDictionaryCount: 0 }),
|
||||
completedState({
|
||||
completedAt: '2026-05-12T14:40:00.000Z',
|
||||
yomitanSetupMode: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
}),
|
||||
]);
|
||||
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
readSetupState: () => {
|
||||
reads += 1;
|
||||
return {
|
||||
version: 4,
|
||||
status: 'completed',
|
||||
completedAt: reads < 3 ? '2026-03-07T00:00:00.000Z' : '2026-05-12T14:40:00.000Z',
|
||||
completionSource: 'user',
|
||||
yomitanSetupMode: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
...commandLineSetupDefaults,
|
||||
};
|
||||
},
|
||||
readSetupState: reader.read,
|
||||
hasLegacyMpvPlugin: () => legacyPluginInstalled,
|
||||
launchSetupApp: () => {
|
||||
calls.push('launch');
|
||||
legacyPluginInstalled = false;
|
||||
},
|
||||
sleep: async () => undefined,
|
||||
now: (() => {
|
||||
let value = 0;
|
||||
return () => (value += 100);
|
||||
})(),
|
||||
timeoutMs: 5_000,
|
||||
pollIntervalMs: 100,
|
||||
...fakeClock(),
|
||||
});
|
||||
|
||||
assert.equal(ready, true);
|
||||
assert.deepEqual(calls, ['launch']);
|
||||
assert.equal(reads >= 3, true);
|
||||
assert.equal(reader.reads >= 3, true);
|
||||
});
|
||||
|
||||
test('ensureLauncherSetupReady lets users continue without removing a legacy mpv plugin', async () => {
|
||||
const calls: string[] = [];
|
||||
let reads = 0;
|
||||
const reader = sequenceReader([
|
||||
completedState({ lastSeenYomitanDictionaryCount: 2 }),
|
||||
completedState({ lastSeenYomitanDictionaryCount: 2 }),
|
||||
completedState({ completedAt: '2026-05-12T14:30:00.000Z', lastSeenYomitanDictionaryCount: 2 }),
|
||||
]);
|
||||
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
readSetupState: () => {
|
||||
reads += 1;
|
||||
return {
|
||||
version: 4,
|
||||
status: 'completed',
|
||||
completedAt: reads < 3 ? '2026-03-07T00:00:00.000Z' : '2026-05-12T14:30:00.000Z',
|
||||
completionSource: 'user',
|
||||
yomitanSetupMode: 'internal',
|
||||
lastSeenYomitanDictionaryCount: 2,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
...commandLineSetupDefaults,
|
||||
};
|
||||
},
|
||||
readSetupState: reader.read,
|
||||
hasLegacyMpvPlugin: () => true,
|
||||
launchSetupApp: () => {
|
||||
calls.push('launch');
|
||||
},
|
||||
sleep: async () => undefined,
|
||||
now: (() => {
|
||||
let value = 0;
|
||||
return () => (value += 100);
|
||||
})(),
|
||||
timeoutMs: 5_000,
|
||||
pollIntervalMs: 100,
|
||||
...fakeClock(),
|
||||
});
|
||||
|
||||
assert.equal(ready, true);
|
||||
@@ -207,90 +161,30 @@ test('ensureLauncherSetupReady lets users continue without removing a legacy mpv
|
||||
|
||||
test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => {
|
||||
const result = await ensureLauncherSetupReady({
|
||||
readSetupState: () => ({
|
||||
version: 4,
|
||||
status: 'cancelled',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
yomitanSetupMode: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
...commandLineSetupDefaults,
|
||||
}),
|
||||
readSetupState: cancelledState,
|
||||
launchSetupApp: () => undefined,
|
||||
sleep: async () => undefined,
|
||||
now: (() => {
|
||||
let value = 0;
|
||||
return () => (value += 100);
|
||||
})(),
|
||||
timeoutMs: 5_000,
|
||||
pollIntervalMs: 100,
|
||||
...fakeClock(),
|
||||
});
|
||||
|
||||
assert.equal(result, false);
|
||||
});
|
||||
|
||||
test('ensureLauncherSetupReady ignores stale cancelled state after launching setup app', async () => {
|
||||
let reads = 0;
|
||||
const reader = sequenceReader([
|
||||
cancelledState(),
|
||||
cancelledState(),
|
||||
makeState(),
|
||||
completedState({
|
||||
completionSource: 'legacy_auto_detected',
|
||||
pluginInstallStatus: 'installed',
|
||||
pluginInstallPathSummary: '/tmp/mpv',
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = await ensureLauncherSetupReady({
|
||||
readSetupState: () => {
|
||||
reads += 1;
|
||||
if (reads <= 2) {
|
||||
return {
|
||||
version: 4,
|
||||
status: 'cancelled',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
yomitanSetupMode: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
...commandLineSetupDefaults,
|
||||
};
|
||||
}
|
||||
if (reads === 3) {
|
||||
return {
|
||||
version: 4,
|
||||
status: 'in_progress',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
yomitanSetupMode: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
...commandLineSetupDefaults,
|
||||
};
|
||||
}
|
||||
return {
|
||||
version: 4,
|
||||
status: 'completed',
|
||||
completedAt: '2026-03-07T00:00:00.000Z',
|
||||
completionSource: 'legacy_auto_detected',
|
||||
yomitanSetupMode: 'internal',
|
||||
lastSeenYomitanDictionaryCount: 1,
|
||||
pluginInstallStatus: 'installed',
|
||||
pluginInstallPathSummary: '/tmp/mpv',
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
...commandLineSetupDefaults,
|
||||
};
|
||||
},
|
||||
readSetupState: reader.read,
|
||||
launchSetupApp: () => undefined,
|
||||
sleep: async () => undefined,
|
||||
now: (() => {
|
||||
let value = 0;
|
||||
return () => (value += 100);
|
||||
})(),
|
||||
timeoutMs: 5_000,
|
||||
pollIntervalMs: 100,
|
||||
...fakeClock(),
|
||||
});
|
||||
|
||||
assert.equal(result, true);
|
||||
|
||||
+51
-67
@@ -9,7 +9,6 @@ import {
|
||||
createDefaultSetupState,
|
||||
getDefaultConfigDir,
|
||||
getSetupStatePath,
|
||||
readSetupState,
|
||||
writeSetupState,
|
||||
} from '../src/shared/setup-state.js';
|
||||
|
||||
@@ -407,65 +406,65 @@ setInterval(() => {}, 1000);
|
||||
};
|
||||
}
|
||||
|
||||
test('launcher smoke fixture seeds completed setup state', () => {
|
||||
const smokeCase = createSmokeCase('setup-state');
|
||||
/**
|
||||
* Some sandboxes forbid binding unix sockets (EPERM). The fake mpv needs one, so these tests
|
||||
* cannot pass there; probe once and skip them instead of weakening every assertion.
|
||||
*/
|
||||
function unixSocketsDenied(): boolean {
|
||||
if (process.platform === 'win32') return false;
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-sock-probe-'));
|
||||
try {
|
||||
const configDir = getDefaultConfigDir({
|
||||
xdgConfigHome: smokeCase.xdgConfigHome,
|
||||
homeDir: smokeCase.homeDir,
|
||||
});
|
||||
const statePath = getSetupStatePath(configDir);
|
||||
|
||||
assert.equal(readSetupState(statePath)?.status, 'completed');
|
||||
const probe = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
'-e',
|
||||
`require('node:net').createServer().on('error', (e) => { process.stderr.write(String(e)); process.exit(1); }).listen(${JSON.stringify(path.join(dir, 's.sock'))}, function () { this.close(() => process.exit(0)); });`,
|
||||
],
|
||||
{ encoding: 'utf8', timeout: 5000 },
|
||||
);
|
||||
return probe.status !== 0 && /eperm|operation not permitted/i.test(probe.stderr);
|
||||
} finally {
|
||||
fs.rmSync(smokeCase.root, { recursive: true, force: true });
|
||||
fs.rmSync(smokeCase.socketDir, { recursive: true, force: true });
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('launcher mpv status returns ready when socket is connectable', async () => {
|
||||
await withSmokeCase('mpv-status', async (smokeCase) => {
|
||||
const env = makeTestEnv(smokeCase);
|
||||
const fakeMpv = spawn(smokeCase.fakeMpvPath, [`--input-ipc-server=${smokeCase.socketPath}`], {
|
||||
env,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
const skipWithoutUnixSockets = unixSocketsDenied() ? 'sandbox denies unix socket binding' : false;
|
||||
|
||||
try {
|
||||
await waitForSocketReady(smokeCase.socketPath);
|
||||
const result = runLauncher(
|
||||
smokeCase,
|
||||
['mpv', 'status', '--log-level', 'debug'],
|
||||
test(
|
||||
'launcher mpv status returns ready when socket is connectable',
|
||||
{ skip: skipWithoutUnixSockets },
|
||||
async () => {
|
||||
await withSmokeCase('mpv-status', async (smokeCase) => {
|
||||
const env = makeTestEnv(smokeCase);
|
||||
const fakeMpv = spawn(smokeCase.fakeMpvPath, [`--input-ipc-server=${smokeCase.socketPath}`], {
|
||||
env,
|
||||
'mpv-status',
|
||||
);
|
||||
const fakeMpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
|
||||
const fakeMpvError = fakeMpvEntries.find(
|
||||
(entry): entry is { error: string } => typeof entry.error === 'string',
|
||||
)?.error;
|
||||
const unixSocketDenied =
|
||||
typeof fakeMpvError === 'string' && /eperm|operation not permitted/i.test(fakeMpvError);
|
||||
stdio: 'ignore',
|
||||
});
|
||||
|
||||
if (unixSocketDenied) {
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stdout, /socket not ready/i);
|
||||
} else {
|
||||
try {
|
||||
await waitForSocketReady(smokeCase.socketPath);
|
||||
const result = runLauncher(
|
||||
smokeCase,
|
||||
['mpv', 'status', '--log-level', 'debug'],
|
||||
env,
|
||||
'mpv-status',
|
||||
);
|
||||
assert.equal(result.status, 0);
|
||||
assert.match(result.stdout, /socket ready/i);
|
||||
} finally {
|
||||
if (fakeMpv.exitCode === null) {
|
||||
await new Promise<void>((resolve) => {
|
||||
fakeMpv.once('close', () => resolve());
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (fakeMpv.exitCode === null) {
|
||||
await new Promise<void>((resolve) => {
|
||||
fakeMpv.once('close', () => resolve());
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'launcher start-overlay run forwards socket/backend and stops owned background app after mpv exits',
|
||||
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
|
||||
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS, skip: skipWithoutUnixSockets },
|
||||
async () => {
|
||||
await withSmokeCase('overlay-start-stop', async (smokeCase) => {
|
||||
const env = makeTestEnv(smokeCase);
|
||||
@@ -484,13 +483,8 @@ test(
|
||||
const appStartEntries = readJsonLines(appStartPath);
|
||||
const appStopEntries = readJsonLines(appStopPath);
|
||||
const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
|
||||
const mpvError = mpvEntries.find(
|
||||
(entry): entry is { error: string } => typeof entry.error === 'string',
|
||||
)?.error;
|
||||
const unixSocketDenied =
|
||||
typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
|
||||
|
||||
assert.equal(result.status, unixSocketDenied ? 3 : 0);
|
||||
assert.equal(result.status, 0);
|
||||
assert.match(result.stdout, /Starting SubMiner overlay/i);
|
||||
|
||||
assert.equal(appStartEntries.length, 1);
|
||||
@@ -523,7 +517,7 @@ test(
|
||||
|
||||
test(
|
||||
'launcher start-overlay attaches to a running background app without spawning another app start command',
|
||||
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
|
||||
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS, skip: skipWithoutUnixSockets },
|
||||
async () => {
|
||||
await withSmokeCase('overlay-borrow-background', async (smokeCase) => {
|
||||
const controlServer = await startFakeControlServer(smokeCase);
|
||||
@@ -550,13 +544,8 @@ test(
|
||||
const appStopEntries = readJsonLines(appStopPath);
|
||||
const controlEntries = readJsonLines(controlServer.logPath);
|
||||
const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
|
||||
const mpvError = mpvEntries.find(
|
||||
(entry): entry is { error: string } => typeof entry.error === 'string',
|
||||
)?.error;
|
||||
const unixSocketDenied =
|
||||
typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
|
||||
|
||||
assert.equal(result.status, unixSocketDenied ? 3 : 0);
|
||||
assert.equal(result.status, 0);
|
||||
if (process.platform === 'linux') {
|
||||
assert.equal(appEntries.length > 0, true);
|
||||
assert.equal(
|
||||
@@ -583,7 +572,7 @@ test(
|
||||
|
||||
test(
|
||||
'launcher starts mpv paused when plugin auto-start visible overlay gate is enabled',
|
||||
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
|
||||
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS, skip: skipWithoutUnixSockets },
|
||||
async () => {
|
||||
await withSmokeCase('autoplay-ready-gate', async (smokeCase) => {
|
||||
fs.writeFileSync(
|
||||
@@ -607,14 +596,9 @@ test(
|
||||
);
|
||||
|
||||
const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
|
||||
const mpvError = mpvEntries.find(
|
||||
(entry): entry is { error: string } => typeof entry.error === 'string',
|
||||
)?.error;
|
||||
const unixSocketDenied =
|
||||
typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
|
||||
const mpvFirstArgs = mpvEntries[0]?.argv;
|
||||
|
||||
assert.equal(result.status, unixSocketDenied ? 3 : 0);
|
||||
assert.equal(result.status, 0);
|
||||
assert.equal(Array.isArray(mpvFirstArgs), true);
|
||||
assert.equal((mpvFirstArgs as string[]).includes('--pause=yes'), true);
|
||||
assert.match(
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { parseArgs } from '../config.js';
|
||||
import type { Args } from '../types.js';
|
||||
|
||||
/** Default launcher `Args` (as if run with no CLI input), with `overrides` applied. */
|
||||
export function makeLauncherArgs(overrides: Partial<Args> = {}): Args {
|
||||
return { ...parseArgs([], 'subminer', {}), ...overrides };
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Runs `run` with the given process env values (`undefined` deletes a key) and restores the
|
||||
* originals afterwards, even if `run` throws or rejects.
|
||||
*/
|
||||
export async function withEnv<T>(
|
||||
overrides: Record<string, string | undefined>,
|
||||
run: () => T | Promise<T>,
|
||||
): Promise<T> {
|
||||
const originals = Object.fromEntries(
|
||||
Object.keys(overrides).map((key) => [key, process.env[key]]),
|
||||
);
|
||||
const apply = (values: Record<string, string | undefined>): void => {
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
};
|
||||
|
||||
apply(overrides);
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
apply(originals);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Database as BunDatabase } from 'bun:sqlite';
|
||||
import { ensureSchema } from '../../src/core/services/immersion-tracker/storage.js';
|
||||
import { createImmersionDbFixture, insertFixtureSession } from './immersion-db-fixture.js';
|
||||
import { createImmersionDbFixture } from './immersion-db-fixture.js';
|
||||
|
||||
type SchemaRow = { type: string; name: string; tbl_name: string; sql: string | null };
|
||||
|
||||
@@ -108,65 +108,3 @@ test('fixture schema stays aligned with production sync-touched tables and index
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('fixture leaves lexical rollups pending when their table is absent', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-fixture-rollup-state-'));
|
||||
const fixturePath = path.join(dir, 'fixture.sqlite');
|
||||
try {
|
||||
createImmersionDbFixture(fixturePath);
|
||||
const db = new BunDatabase(fixturePath, { readonly: true });
|
||||
try {
|
||||
const state = db
|
||||
.query<{ state_value: string }>(
|
||||
`SELECT state_value FROM imm_rollup_state
|
||||
WHERE state_key = 'lexical_daily_rollups_version'`,
|
||||
)
|
||||
.get();
|
||||
const rollupTable = db
|
||||
.query<{ name: string }>(
|
||||
`SELECT name FROM sqlite_schema
|
||||
WHERE type = 'table' AND name = 'imm_lexical_daily_rollups'`,
|
||||
)
|
||||
.get();
|
||||
|
||||
assert.equal(state?.state_value, '0');
|
||||
assert.equal(rollupTable, null);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('fixture session inserts enforce foreign keys', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-fixture-foreign-keys-'));
|
||||
const fixturePath = path.join(dir, 'fixture.sqlite');
|
||||
try {
|
||||
createImmersionDbFixture(fixturePath);
|
||||
const db = new BunDatabase(fixturePath, { readwrite: true });
|
||||
try {
|
||||
db.run(`
|
||||
CREATE TRIGGER remove_fixture_video
|
||||
BEFORE INSERT ON imm_sessions
|
||||
BEGIN
|
||||
DELETE FROM imm_videos WHERE video_id = NEW.video_id;
|
||||
END
|
||||
`);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
insertFixtureSession(fixturePath, {
|
||||
uuid: 'foreign-key-check',
|
||||
videoKey: 'foreign-key-check',
|
||||
startedAtMs: Date.UTC(2026, 6, 9),
|
||||
}),
|
||||
/FOREIGN KEY constraint failed/,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -62,6 +62,8 @@
|
||||
"test:launcher:unit:src": "bun scripts/run-test-lane.mjs bun-launcher-unit",
|
||||
"test:scripts": "bun scripts/run-test-lane.mjs scripts",
|
||||
"test:stats": "bun scripts/run-test-lane.mjs stats",
|
||||
"test:e2e": "bun scripts/run-test-lane.mjs e2e --jobs 1",
|
||||
"e2e": "bun e2e/cli.ts",
|
||||
"test:env": "bun run test:launcher:smoke:src && bun run test:plugin:src && bun run test:immersion:sqlite:src",
|
||||
"test:runtime:compat": "bun run test:smoke:dist",
|
||||
"test": "bun run test:fast",
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
## Highlights
|
||||
### Added
|
||||
- **YouTube Browser Window**:
|
||||
- Browse YouTube in a window that keeps your login across restarts. Open it with `subminer youtube` (or `subminer yt`), or choose **Browse YouTube** from the tray.
|
||||
- Clicking a video plays it in mpv. Middle-click, Shift/Ctrl-click, or the right-click menu adds it to mpv's playlist.
|
||||
- Queued videos get the same Japanese subtitle setup when they start playing.
|
||||
- **Whisper Subtitles for YouTube**: Set `youtube.subtitleSource` to `whisper` to transcribe YouTube videos with Whisper instead of using YouTube's captions. The subtitle generation modal (`Ctrl+Shift+G`) now also works on YouTube videos.
|
||||
- **Scroll Wheel and Mouse Bindings**: `keybindings` now accepts scroll wheel keys, and the settings key editor can capture them. Mouse button and wheel bindings from mpv now work while the cursor is over the overlay.
|
||||
- **Run Commands From Session Help**: Press `Enter` or double-click a row in session help to run that command during playback.
|
||||
|
||||
### Fixed
|
||||
- **YouTube**:
|
||||
- Japanese subtitles no longer fail with "HTTP 429" on videos that also have a machine-translated Japanese track.
|
||||
- On Windows, the subtitle generation modal now works during YouTube playback.
|
||||
- On Windows, playing a video queued from the YouTube browser window no longer clears the rest of mpv's queue.
|
||||
- **Jellyfin**:
|
||||
- The Japanese subtitle track is selected as soon as it downloads, instead of waiting for every other track.
|
||||
- Japanese and English image-based subtitles (PGS, DVD, DVB) are now selected automatically.
|
||||
- Timing review previews and mined card audio use the audio track you are playing.
|
||||
- **Hyprland Overlay**: The overlay no longer interrupts mpv when it goes fullscreen. With `mpv.backend: x11`, the overlay stays clickable after mpv goes fullscreen.
|
||||
- **mpv Key Bindings**: Keys from mpv's defaults and your `input.conf` (such as `9` and `0` for volume) now work while the overlay has focus, even when the overlay loads before SubMiner connects to mpv.
|
||||
- **Media Timing Review**: Trim handles are now thin lines on the clip edges, and the original subtitle boundaries are plain orange bars. On macOS with Bluetooth headphones such as AirPods, the preview cursor now moves smoothly.
|
||||
- **Stats**:
|
||||
- Each mined card is counted once instead of twice.
|
||||
- Overview totals, charts, the calendar, and the vocabulary summary update right after you delete sessions.
|
||||
- Word and kanji dates stay correct after deletes and pruning, and the new-words charts now agree with the vocabulary summary. Existing data is repaired in the background on the next launch.
|
||||
- **Yomitan Profile Safety**: Electron is updated to 43.7.7, and SubMiner now refuses to load your Yomitan data with an older or unsupported runtime. Automatic character-dictionary updates stop if a profile that had dictionaries suddenly reports none.
|
||||
- **Security Updates**: Updated Electron and undici to resolve security advisories.
|
||||
- **AppImage in Sandboxes**: The AppImage now opens a window when launched inside a sandbox such as `firejail --appimage`.
|
||||
- **Ctrl+C Crash**: SubMiner no longer crashes with "write EPIPE" when you stop the terminal command that started it with Ctrl+C.
|
||||
|
||||
### Docs
|
||||
- **Docs Site**: The docs site is shorter and easier to scan, and the configuration reference now has a table of keys and defaults for each section. Outdated pages were corrected, and the Arch Linux MeCab install now uses the AUR package `mecab-git`.
|
||||
- **Stats**: Added docs on how the Overview refreshes after you delete sessions and how discovery dates are kept after pruning.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(storage): protect Yomitan profiles across Electron runtime changes by @ksyasuda in #211
|
||||
- fix(jellyfin): select subtitles faster and survive failed track downloads by @ksyasuda in #270
|
||||
- fix(jellyfin): use the current audio track for media generation by @ksyasuda in #271
|
||||
- feat(youtube): add YouTube browser window and Whisper subtitle source by @ksyasuda in #273
|
||||
- fix(overlay): thin centered trim handles and label-free subtitle boundaries in timing review by @ksyasuda in #274
|
||||
- feat(overlay): run session help commands with Enter or double-click by @ksyasuda in #275
|
||||
- fix(overlay): keep overlay mapped during Hyprland/Sway fullscreen by @ksyasuda in #276
|
||||
- fix(linux): skip AppImage mount keepalive on sandbox squashfs mounts by @ksyasuda in #277
|
||||
- fix(overlay): reload imported mpv keys after mpv connects by @ksyasuda in #278
|
||||
- feat(overlay): forward mpv mouse button and wheel bindings by @ksyasuda in #279
|
||||
- fix(stats): count mined cards once and keep lexical stats exact after deletes by @ksyasuda in #280
|
||||
- fix(youtube): keep mpv queue when advancing to a queued video on Windows by @ksyasuda in #281
|
||||
- fix(subtitles): allow Whisper generation during Windows YouTube stream playback by @ksyasuda in #282
|
||||
|
||||
## Installation
|
||||
|
||||
See the README and docs/installation guide for full setup steps.
|
||||
|
||||
## Assets
|
||||
|
||||
- Linux: `SubMiner.AppImage`
|
||||
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
||||
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
|
||||
- Optional extras: `subminer-assets.tar.gz`, the `subminer` launcher, and the Windows `subminer.cmd` launcher
|
||||
- Bun corresponding source: `bun-v1.3.5-source.tar.gz` and its `.sha256` file
|
||||
|
||||
Both launcher downloads use Bun included with the SubMiner app. Download `subminer` on Linux or macOS and `subminer.cmd` on Windows.
|
||||
|
||||
The app bundles an unmodified Bun 1.3.5 runtime. Bun is MIT licensed and statically links JavaScriptCore (LGPL 2.0) and TinyCC (LGPL 2.1). License texts and third-party notices ship inside the app under `resources/bun/licenses`, and the source archive above contains the matching Bun, WebKit, and dependency sources for relinking.
|
||||
+889
-1373
File diff suppressed because it is too large.
Load diff
@@ -1,26 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const source = readFileSync('scripts/compare-yomitan-api.ts', 'utf8');
|
||||
|
||||
test('creates the configured Yomitan user-data directory before Electron uses it', () => {
|
||||
const setPathIndex = source.indexOf(
|
||||
"electronModule.app.setPath('userData', options.yomitanUserDataPath)",
|
||||
);
|
||||
assert.notEqual(setPathIndex, -1);
|
||||
|
||||
const mkdirIndex = source.lastIndexOf(
|
||||
'fs.mkdirSync(options.yomitanUserDataPath, { recursive: true })',
|
||||
setPathIndex,
|
||||
);
|
||||
assert.ok(mkdirIndex > -1, 'must create the user-data directory');
|
||||
assert.ok(mkdirIndex < setPathIndex, 'must create the directory before app.setPath');
|
||||
});
|
||||
|
||||
test('lets output drain by using exitCode in the top-level completion handlers', () => {
|
||||
const completionHandlers = source.slice(source.lastIndexOf('\nmain()'));
|
||||
assert.match(completionHandlers, /process\.exitCode = process\.exitCode \?\? 0/);
|
||||
assert.match(completionHandlers, /process\.exitCode = 1/);
|
||||
assert.doesNotMatch(completionHandlers, /process\.exit\(/);
|
||||
});
|
||||
@@ -21,6 +21,7 @@ const requiredDocs = [
|
||||
'docs/workflow/README.md',
|
||||
'docs/workflow/agent-skills.md',
|
||||
'docs/workflow/planning.md',
|
||||
'docs/workflow/testing.md',
|
||||
'docs/workflow/verification.md',
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const source = readFileSync('scripts/get-mpv-window-macos.swift', 'utf8');
|
||||
|
||||
test('minimized Accessibility windows are validated by PID and socket before reporting minimized', () => {
|
||||
const minimizedAssignmentIndex = source.indexOf('foundMinimizedTargetWindow = true');
|
||||
assert.notEqual(minimizedAssignmentIndex, -1);
|
||||
|
||||
const loopStartIndex = source.lastIndexOf('for window in windows', minimizedAssignmentIndex);
|
||||
assert.notEqual(loopStartIndex, -1);
|
||||
|
||||
const pidExtractionIndex = source.indexOf(
|
||||
'AXUIElementGetPid(window, &windowPid)',
|
||||
loopStartIndex,
|
||||
);
|
||||
const appPidMatchIndex = source.indexOf('windowPid != app.processIdentifier', loopStartIndex);
|
||||
const socketCheckIndex = source.indexOf('if !windowHasTargetSocket(windowPid)', loopStartIndex);
|
||||
|
||||
assert.ok(
|
||||
pidExtractionIndex > loopStartIndex && pidExtractionIndex < minimizedAssignmentIndex,
|
||||
'window PID must be extracted before accepting a minimized window',
|
||||
);
|
||||
assert.ok(
|
||||
appPidMatchIndex > pidExtractionIndex && appPidMatchIndex < minimizedAssignmentIndex,
|
||||
'window PID must match the owning app before accepting a minimized window',
|
||||
);
|
||||
assert.ok(
|
||||
socketCheckIndex > appPidMatchIndex && socketCheckIndex < minimizedAssignmentIndex,
|
||||
'target socket must be validated before accepting a minimized window',
|
||||
);
|
||||
});
|
||||
|
||||
test('focused mpv window follows the frontmost mpv app signal', () => {
|
||||
const focusHelperIndex = source.indexOf('private func isFocusedMpvWindow');
|
||||
assert.notEqual(focusHelperIndex, -1);
|
||||
|
||||
const nextFunctionIndex = source.indexOf('\nprivate func ', focusHelperIndex + 1);
|
||||
const focusHelperBody = source.slice(focusHelperIndex, nextFunctionIndex);
|
||||
|
||||
assert.ok(
|
||||
focusHelperBody.includes('frontmost.pid == ownerPid'),
|
||||
'matching frontmost PID should mark the mpv window focused',
|
||||
);
|
||||
assert.ok(
|
||||
focusHelperBody.includes('frontmost.isMpv && windowHasTargetSocket(ownerPid)'),
|
||||
'frontmost mpv app should mark the target mpv window focused even when PIDs differ',
|
||||
);
|
||||
assert.ok(
|
||||
source.includes('focused: isFocusedMpvWindow(ownerPid: windowPid, frontmost: frontmost)'),
|
||||
'Accessibility path should use the shared focused mpv helper',
|
||||
);
|
||||
assert.ok(
|
||||
source.includes('focused: isFocusedMpvWindow(ownerPid: ownerPid, frontmost: frontmost)'),
|
||||
'CoreGraphics path should use the shared focused mpv helper',
|
||||
);
|
||||
});
|
||||
|
||||
test('frontmost mpv app emits active state when geometry lookup misses', () => {
|
||||
assert.ok(
|
||||
/case\s+\.active:/.test(source),
|
||||
'helper should expose an active state without window geometry',
|
||||
);
|
||||
assert.ok(
|
||||
source.includes('if windowHasTargetSocket(frontmost.pid)'),
|
||||
'active state should still accept a matching target socket when available',
|
||||
);
|
||||
assert.ok(
|
||||
source.includes('return targetMpvSocketPath != nil'),
|
||||
'active state should preserve frontmost mpv even if command-line socket detection fails',
|
||||
);
|
||||
assert.ok(
|
||||
source.includes('return .active'),
|
||||
'lookup should preserve active mpv state after geometry lookup misses',
|
||||
);
|
||||
assert.ok(source.includes('print("active")'), 'active state should be printed for the tracker');
|
||||
});
|
||||
|
||||
test('frontmost non-mpv app emits inactive state when geometry lookup misses', () => {
|
||||
assert.ok(
|
||||
/case\s+\.inactive:/.test(source),
|
||||
'helper should expose an inactive state without window geometry',
|
||||
);
|
||||
assert.ok(
|
||||
source.includes('if frontmost != nil'),
|
||||
'helper should distinguish a known non-mpv frontmost app from an unknown miss',
|
||||
);
|
||||
assert.ok(source.includes('return .inactive'), 'known non-mpv focus should return inactive');
|
||||
assert.ok(
|
||||
source.includes('print("inactive")'),
|
||||
'inactive state should be printed for the tracker',
|
||||
);
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
function createWorkspace(name: string): string {
|
||||
const baseDir = path.join(process.cwd(), '.tmp', 'get-frequency-typecheck-test');
|
||||
fs.mkdirSync(baseDir, { recursive: true });
|
||||
return fs.mkdtempSync(path.join(baseDir, `${name}-`));
|
||||
}
|
||||
|
||||
test('scripts/get_frequency.ts typechecks in isolation', () => {
|
||||
const workspace = createWorkspace('isolated-script');
|
||||
const tsconfigPath = path.join(workspace, 'tsconfig.json');
|
||||
|
||||
fs.writeFileSync(
|
||||
tsconfigPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
extends: '../../../tsconfig.typecheck.json',
|
||||
include: ['../../../scripts/get_frequency.ts'],
|
||||
exclude: [],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
try {
|
||||
const result = spawnSync('bunx', ['tsc', '--noEmit', '-p', tsconfigPath], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
result.status,
|
||||
0,
|
||||
`expected scripts/get_frequency.ts to typecheck\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -39,24 +39,6 @@ describe('Bun corresponding-source manifest', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('pins the source to the revision reported by the distributed binary', async () => {
|
||||
const manifest = parseSourceManifest(
|
||||
JSON.parse(
|
||||
await fs.readFile(path.join(projectRoot, 'build/bun-source-manifest.json'), 'utf8'),
|
||||
),
|
||||
);
|
||||
|
||||
expect(manifest.version).toBe('1.3.5');
|
||||
expect(manifest.bunRevision).toBe('1e86cebd74a5723e818b5c0555276b646bcf0e4c');
|
||||
expect(manifest.releaseTagCommit).toBe('fa5a5bbe556a4bda5bde77b4013aa6c3bb4ec9ab');
|
||||
expect(manifest.sources.find((source) => source.name === 'WebKit')?.revision).toBe(
|
||||
'6d0f3aac0b817cc01a846b3754b21271adedac12',
|
||||
);
|
||||
expect(manifest.sources.find((source) => source.name === 'tinycc')?.revision).toBe(
|
||||
'29985a3b59898861442fa3b43f663fc1af2591d7',
|
||||
);
|
||||
});
|
||||
|
||||
test('parses commit and tag registrations from Bun CMake', () => {
|
||||
const registrations = parseRegisteredRepositories(`
|
||||
register_repository(
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
test('build:syncui bundles the sandboxed preload and keeps Electron external', () => {
|
||||
const packageJson = JSON.parse(
|
||||
fs.readFileSync(path.join(import.meta.dir, '..', 'package.json'), 'utf8'),
|
||||
) as { scripts: Record<string, string> };
|
||||
const command = packageJson.scripts['build:syncui'] ?? '';
|
||||
|
||||
assert.match(command, /src\/preload-syncui\.ts/);
|
||||
assert.match(command, /--bundle/);
|
||||
assert.match(command, /--external:electron/);
|
||||
assert.match(command, /--outfile=dist\/preload-syncui\.js/);
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const source = readFileSync('scripts/prepare-build-assets.mjs', 'utf8');
|
||||
|
||||
test('macOS helper build creates dist scripts directory before swiftc output', () => {
|
||||
const buildFunctionIndex = source.indexOf('function buildMacosHelper()');
|
||||
assert.notEqual(buildFunctionIndex, -1);
|
||||
|
||||
const swiftcIndex = source.indexOf("'swiftc'", buildFunctionIndex);
|
||||
assert.notEqual(swiftcIndex, -1);
|
||||
|
||||
const ensureDirIndex = source.lastIndexOf('ensureDir(scriptsOutputDir)', swiftcIndex);
|
||||
|
||||
assert.ok(
|
||||
ensureDirIndex > buildFunctionIndex,
|
||||
'buildMacosHelper must create dist/scripts before swiftc writes the helper binary',
|
||||
);
|
||||
});
|
||||
|
||||
// Regression guard for #213: an untargeted swiftc stamps the build machine's OS
|
||||
// version as the helper's minimum, so released builds refuse to load on older macOS.
|
||||
test('macOS helper is compiled with an explicit deployment target', () => {
|
||||
assert.match(source, /-target/);
|
||||
assert.match(source, /apple-macos\$\{MACOS_HELPER_DEPLOYMENT_TARGET\}/);
|
||||
});
|
||||
@@ -12,6 +12,7 @@ paths=(
|
||||
".prettierrc.json"
|
||||
".github"
|
||||
"build"
|
||||
"e2e"
|
||||
"launcher"
|
||||
"scripts"
|
||||
"src"
|
||||
|
||||
@@ -75,7 +75,7 @@ test('resolveCoverageDir keeps coverage output inside the repository', () => {
|
||||
assert.throws(() => resolveCoverageDir(repoRoot, ['--coverage-dir', '/tmp/escape']));
|
||||
});
|
||||
|
||||
test('runCoverageLane returns a failure when a discovered test fails', () => {
|
||||
test('runCoverageLane returns a failure when a discovered test fails', async () => {
|
||||
const repoRoot = mkdtempSync(join(tmpdir(), 'subminer-coverage-failure-'));
|
||||
try {
|
||||
mkdirSync(join(repoRoot, 'src'));
|
||||
@@ -93,10 +93,10 @@ test('runCoverageLane returns a failure when a discovered test fails', () => {
|
||||
);
|
||||
|
||||
assert.notEqual(
|
||||
runCoverageLane({
|
||||
await runCoverageLane({
|
||||
repoRootDir: repoRoot,
|
||||
argv: ['bun-src-full', '--coverage-dir', 'coverage/test-src'],
|
||||
stdio: 'pipe',
|
||||
quiet: true,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { availableParallelism } from 'node:os';
|
||||
import { isAbsolute, join, relative, resolve } from 'node:path';
|
||||
import { collectLaneFiles } from './test-lanes';
|
||||
|
||||
@@ -201,9 +202,40 @@ export function mergeLcovReports(reports: string[]): string {
|
||||
return chunks.length > 0 ? `${chunks.join('\n')}\n` : '';
|
||||
}
|
||||
|
||||
export function runCoverageLane(
|
||||
options: { repoRootDir?: string; argv?: string[]; stdio?: 'inherit' | 'pipe' } = {},
|
||||
): number {
|
||||
type ShardResult = { status: number; output: string; lcov: string | null };
|
||||
|
||||
// Runs one test file under coverage into its own shard directory, buffering
|
||||
// output so parallel shards do not interleave on the terminal.
|
||||
function runShard(repoRootDir: string, file: string, shardDir: string): Promise<ShardResult> {
|
||||
return new Promise((resolveShard) => {
|
||||
const child = spawn(
|
||||
'bun',
|
||||
['test', '--coverage', '--coverage-reporter=lcov', '--coverage-dir', shardDir, `./${file}`],
|
||||
{ cwd: repoRootDir },
|
||||
);
|
||||
let output = '';
|
||||
child.stdout.on('data', (chunk) => (output += chunk));
|
||||
child.stderr.on('data', (chunk) => (output += chunk));
|
||||
child.on('error', (error) => resolveShard({ status: 1, output: String(error), lcov: null }));
|
||||
child.on('close', (code) => {
|
||||
const lcovPath = join(shardDir, 'lcov.info');
|
||||
const lcov = existsSync(lcovPath) ? readFileSync(lcovPath, 'utf8') : null;
|
||||
resolveShard({ status: code ?? 1, output, lcov });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function parseJobsArg(argv: string[]): number {
|
||||
const index = argv.indexOf('--jobs');
|
||||
if (index === -1) return availableParallelism();
|
||||
return Math.max(1, Number(argv[index + 1]) || 1);
|
||||
}
|
||||
|
||||
// Shards run in parallel (one per CPU unless --jobs N). The first failing shard
|
||||
// stops scheduling new shards and its exit status becomes the lane result.
|
||||
export async function runCoverageLane(
|
||||
options: { repoRootDir?: string; argv?: string[]; quiet?: boolean } = {},
|
||||
): Promise<number> {
|
||||
const repoRootDir = options.repoRootDir ?? repoRoot;
|
||||
const argv = options.argv ?? process.argv.slice(2);
|
||||
const laneName = argv[0];
|
||||
@@ -225,40 +257,44 @@ export function runCoverageLane(
|
||||
process.stderr.write(`${error instanceof Error ? error.message : error}\n`);
|
||||
return 1;
|
||||
}
|
||||
const reports: string[] = [];
|
||||
|
||||
const reports: Array<string | null> = new Array(files.length).fill(null);
|
||||
const failures: Array<{ file: string; result: ShardResult }> = [];
|
||||
let nextIndex = 0;
|
||||
|
||||
const worker = async (): Promise<void> => {
|
||||
while (failures.length === 0 && nextIndex < files.length) {
|
||||
const index = nextIndex++;
|
||||
const file = files[index]!;
|
||||
const shardDir = join(shardRoot, String(index + 1).padStart(3, '0'));
|
||||
const result = await runShard(repoRootDir, file, shardDir);
|
||||
if (result.status !== 0) {
|
||||
failures.push({ file, result });
|
||||
return;
|
||||
}
|
||||
if (result.lcov === null && !options.quiet) {
|
||||
process.stdout.write(`Skipping empty coverage shard for ${file}\n`);
|
||||
}
|
||||
reports[index] = result.lcov;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
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: repoRootDir,
|
||||
stdio: options.stdio ?? 'inherit',
|
||||
},
|
||||
);
|
||||
const jobs = Math.min(parseJobsArg(argv), files.length);
|
||||
await Promise.all(Array.from({ length: jobs }, worker));
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
const [failure] = failures;
|
||||
if (failure !== undefined) {
|
||||
const { file, result } = failure;
|
||||
if (!options.quiet) {
|
||||
process.stderr.write(`${result.output}\nCoverage shard failed: ${file}\n`);
|
||||
}
|
||||
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'));
|
||||
return result.status;
|
||||
}
|
||||
|
||||
writeFileSync(join(coverageDir, 'lcov.info'), mergeLcovReports(reports), 'utf8');
|
||||
process.stdout.write(
|
||||
`Merged LCOV written to ${relative(repoRootDir, join(coverageDir, 'lcov.info'))}\n`,
|
||||
);
|
||||
const lcovPath = join(coverageDir, 'lcov.info');
|
||||
writeFileSync(lcovPath, mergeLcovReports(reports.filter((r) => r !== null)), 'utf8');
|
||||
process.stdout.write(`Merged LCOV written to ${relative(repoRootDir, lcovPath)}\n`);
|
||||
return 0;
|
||||
} finally {
|
||||
rmSync(shardRoot, { recursive: true, force: true });
|
||||
@@ -267,5 +303,5 @@ export function runCoverageLane(
|
||||
|
||||
// @ts-ignore Bun entrypoint detection; TS config for scripts still targets CommonJS.
|
||||
if (import.meta.main) {
|
||||
process.exit(runCoverageLane());
|
||||
runCoverageLane().then((status) => process.exit(status));
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resolve } from 'node:path';
|
||||
import { availableParallelism } from 'node:os';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { collectLaneFiles } from './test-lanes.ts';
|
||||
|
||||
@@ -7,6 +8,8 @@ import { collectLaneFiles } from './test-lanes.ts';
|
||||
// test file so a hanging test or leaked global in one file cannot poison the
|
||||
// rest of the lane. Use --single-process for the old all-in-one-process mode.
|
||||
//
|
||||
// Files run in parallel, one worker per CPU by default; --jobs 1 runs serially.
|
||||
//
|
||||
// Usage: bun scripts/run-test-lane.mjs <lane> [--jobs N] [--timeout-secs N] [--single-process]
|
||||
|
||||
const repoRoot = resolve(fileURLToPath(new URL('..', import.meta.url)));
|
||||
@@ -34,7 +37,12 @@ for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { lane: undefined, jobs: 1, timeoutSecs: 300, singleProcess: false };
|
||||
const options = {
|
||||
lane: undefined,
|
||||
jobs: availableParallelism(),
|
||||
timeoutSecs: 300,
|
||||
singleProcess: false,
|
||||
};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === '--jobs') {
|
||||
|
||||
@@ -61,15 +61,6 @@ test('normalizeTarget maps each supported electron-builder target without using
|
||||
assert.throws(() => normalizeTarget('linux', 0), /Unsupported Bun runtime target architecture/);
|
||||
});
|
||||
|
||||
test('resolveArtifact chooses baseline x64 builds and standard arm64 builds', () => {
|
||||
const config = runtimeConfig();
|
||||
assert.equal(resolveArtifact(config, 'linux', 'x64').file, 'bun-linux-x64-baseline.zip');
|
||||
assert.equal(resolveArtifact(config, 'darwin', 'x64').file, 'bun-darwin-x64-baseline.zip');
|
||||
assert.equal(resolveArtifact(config, 'win32', 'x64').file, 'bun-windows-x64-baseline.zip');
|
||||
assert.equal(resolveArtifact(config, 'linux', 'arm64').file, 'bun-linux-aarch64.zip');
|
||||
assert.equal(resolveArtifact(config, 'darwin', 'arm64').file, 'bun-darwin-aarch64.zip');
|
||||
});
|
||||
|
||||
test('tracked runtime manifest covers every supported target at the packageManager version', async () => {
|
||||
const config = await loadRuntimeConfig();
|
||||
assert.equal(config.version, '1.3.5');
|
||||
|
||||
@@ -35,6 +35,12 @@ export const testLanes: Record<string, TestLane> = {
|
||||
roots: ['scripts'],
|
||||
include: ['.test.ts'],
|
||||
},
|
||||
// Boots the built app, mpv, and a fake AnkiConnect on an offscreen display.
|
||||
// Slow and dependent on mpv/ffmpeg/Xvfb, so it stays out of test:fast.
|
||||
e2e: {
|
||||
roots: ['e2e'],
|
||||
include: ['.e2e.test.ts'],
|
||||
},
|
||||
stats: {
|
||||
roots: ['stats/src'],
|
||||
include: ['.test.ts', '.test.tsx'],
|
||||
|
||||
@@ -3,20 +3,6 @@ import assert from 'node:assert/strict';
|
||||
import { AnkiConnectClient } from './anki-connect';
|
||||
import { setLogLevel } from './logger';
|
||||
|
||||
test('AnkiConnectClient disables keep-alive agents to avoid stale socket retries', () => {
|
||||
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
|
||||
client: {
|
||||
defaults: {
|
||||
httpAgent?: { options?: { keepAlive?: boolean } };
|
||||
httpsAgent?: { options?: { keepAlive?: boolean } };
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(client.client.defaults.httpAgent?.options?.keepAlive, false);
|
||||
assert.equal(client.client.defaults.httpsAgent?.options?.keepAlive, false);
|
||||
});
|
||||
|
||||
test('AnkiConnectClient includes action name in retry logs', async () => {
|
||||
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
|
||||
client: { post: (url: string, body: unknown, options: unknown) => Promise<unknown> };
|
||||
|
||||
+612
-1235
File diff suppressed because it is too large.
Load diff
@@ -2,9 +2,13 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { CardCreationService } from './card-creation';
|
||||
import type { AnkiConnectConfig } from '../types/anki';
|
||||
|
||||
type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
|
||||
import {
|
||||
type CardCreationDeps,
|
||||
createAnkiConfig,
|
||||
createCardCreationDeps,
|
||||
createFakeClient,
|
||||
createFakeMediaGenerator,
|
||||
} from './card-creation-test-utils';
|
||||
|
||||
test('sentence card writes generated audio only to sentence audio field', async () => {
|
||||
const addedFields: Record<string, string>[] = [];
|
||||
@@ -13,128 +17,88 @@ test('sentence card writes generated audio only to sentence audio field', async
|
||||
const requestedProperties: string[] = [];
|
||||
const audioVolumeScales: Array<number | undefined> = [];
|
||||
const audioRanges: Array<{ start: number; end: number; padding: number | undefined }> = [];
|
||||
|
||||
const deps: CardCreationDeps = {
|
||||
getConfig: () =>
|
||||
({
|
||||
deck: 'Mining',
|
||||
fields: {
|
||||
word: 'Expression',
|
||||
sentence: 'Sentence',
|
||||
audio: 'ExpressionAudio',
|
||||
translation: 'SelectionText',
|
||||
},
|
||||
media: {
|
||||
generateAudio: true,
|
||||
generateImage: false,
|
||||
mirrorMpvVolume: true,
|
||||
maxMediaDuration: 30,
|
||||
},
|
||||
behavior: {},
|
||||
ai: false,
|
||||
}) as AnkiConnectConfig,
|
||||
getAiConfig: () => ({}),
|
||||
getTimingTracker: () => ({}) as never,
|
||||
getMpvClient: () =>
|
||||
({
|
||||
currentVideoPath: '/video.mp4',
|
||||
currentSubText: '字幕',
|
||||
currentSubStart: 12,
|
||||
currentSubEnd: 14,
|
||||
currentTimePos: 13,
|
||||
currentAudioStreamIndex: 0,
|
||||
requestProperty: async (name: string) => {
|
||||
requestedProperties.push(name);
|
||||
return 40;
|
||||
},
|
||||
}) as never,
|
||||
client: {
|
||||
addNote: async (_deck, _modelName, fields) => {
|
||||
addedFields.push(fields);
|
||||
return 42;
|
||||
},
|
||||
addTags: async () => undefined,
|
||||
notesInfo: async () => [
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Expression: { value: '字幕' },
|
||||
Sentence: { value: '字幕' },
|
||||
SelectionText: { value: 'Subtitle' },
|
||||
ExpressionAudio: { value: '' },
|
||||
SentenceAudio: { value: '' },
|
||||
},
|
||||
},
|
||||
],
|
||||
updateNoteFields: async (_noteId, fields) => {
|
||||
updatedFields.push(fields);
|
||||
},
|
||||
storeMediaFile: async (filename) => {
|
||||
storedMedia.push(filename);
|
||||
},
|
||||
findNotes: async () => [],
|
||||
retrieveMediaFile: async () => '',
|
||||
deleteNotes: async () => undefined,
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async (
|
||||
_path,
|
||||
startTime,
|
||||
endTime,
|
||||
audioPadding,
|
||||
_audioStreamIndex,
|
||||
_normalizeAudio,
|
||||
volumeScale,
|
||||
) => {
|
||||
audioRanges.push({ start: startTime, end: endTime, padding: audioPadding });
|
||||
audioVolumeScales.push(volumeScale);
|
||||
return Buffer.from('audio');
|
||||
},
|
||||
generateScreenshot: async () => null,
|
||||
generateAnimatedImage: async () => null,
|
||||
},
|
||||
showOsdNotification: () => undefined,
|
||||
showUpdateResult: () => undefined,
|
||||
showStatusNotification: () => undefined,
|
||||
showNotification: async () => undefined,
|
||||
beginUpdateProgress: () => undefined,
|
||||
endUpdateProgress: () => undefined,
|
||||
withUpdateProgress: async (_message, action) => action(),
|
||||
resolveConfiguredFieldName: (noteInfo, ...preferredNames) => {
|
||||
for (const preferredName of preferredNames) {
|
||||
if (preferredName && preferredName in noteInfo.fields) return preferredName;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
resolveNoteFieldName: (noteInfo, preferredName) =>
|
||||
preferredName && preferredName in noteInfo.fields ? preferredName : null,
|
||||
getAnimatedImageLeadInSeconds: async () => 0,
|
||||
extractFields: () => ({}),
|
||||
processSentence: (sentence) => sentence,
|
||||
setCardTypeFields: () => undefined,
|
||||
mergeFieldValue: (_existing, newValue) => newValue,
|
||||
formatMiscInfoPattern: () => '',
|
||||
getEffectiveSentenceCardConfig: () => ({
|
||||
model: 'Sentence',
|
||||
sentenceField: 'Sentence',
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: true,
|
||||
kikuEnabled: false,
|
||||
fieldGroupingMode: 'disabled',
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
removeKnownWordNote: () => undefined,
|
||||
isUpdateInProgress: () => false,
|
||||
setUpdateInProgress: () => undefined,
|
||||
trackLastAddedNoteId: () => undefined,
|
||||
reviewMediaTiming: async () => ({ action: 'confirm', startTime: 11.4, endTime: 14.2 }),
|
||||
let review: Awaited<ReturnType<NonNullable<CardCreationDeps['reviewMediaTiming']>>> = {
|
||||
action: 'confirm',
|
||||
startTime: 11.4,
|
||||
endTime: 14.2,
|
||||
};
|
||||
|
||||
const service = new CardCreationService(deps);
|
||||
const created = await service.createSentenceCard('字幕', 12, 14, 'Subtitle');
|
||||
const service = new CardCreationService(
|
||||
createCardCreationDeps({
|
||||
getConfig: () =>
|
||||
createAnkiConfig({
|
||||
fields: {
|
||||
word: 'Expression',
|
||||
audio: 'ExpressionAudio',
|
||||
translation: 'SelectionText',
|
||||
},
|
||||
media: { generateAudio: true, mirrorMpvVolume: true, maxMediaDuration: 30 },
|
||||
}),
|
||||
getMpvClient: () =>
|
||||
({
|
||||
currentVideoPath: '/video.mp4',
|
||||
currentSubText: '字幕',
|
||||
currentSubStart: 12,
|
||||
currentSubEnd: 14,
|
||||
currentTimePos: 13,
|
||||
currentAudioStreamIndex: 0,
|
||||
requestProperty: async (name: string) => {
|
||||
requestedProperties.push(name);
|
||||
return 40;
|
||||
},
|
||||
}) as never,
|
||||
client: createFakeClient({
|
||||
addNote: async (_deck, _modelName, fields) => {
|
||||
addedFields.push(fields);
|
||||
return 42;
|
||||
},
|
||||
notesInfo: async () => [
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Expression: { value: '字幕' },
|
||||
Sentence: { value: '字幕' },
|
||||
SelectionText: { value: 'Subtitle' },
|
||||
ExpressionAudio: { value: '' },
|
||||
SentenceAudio: { value: '' },
|
||||
},
|
||||
},
|
||||
],
|
||||
updateNoteFields: async (_noteId, fields) => {
|
||||
updatedFields.push(fields);
|
||||
},
|
||||
storeMediaFile: async (filename) => {
|
||||
storedMedia.push(filename);
|
||||
},
|
||||
}),
|
||||
mediaGenerator: createFakeMediaGenerator({
|
||||
generateAudio: async (
|
||||
_path,
|
||||
startTime,
|
||||
endTime,
|
||||
audioPadding,
|
||||
_audioStreamIndex,
|
||||
_normalizeAudio,
|
||||
volumeScale,
|
||||
) => {
|
||||
audioRanges.push({ start: startTime, end: endTime, padding: audioPadding });
|
||||
audioVolumeScales.push(volumeScale);
|
||||
return Buffer.from('audio');
|
||||
},
|
||||
}),
|
||||
getEffectiveSentenceCardConfig: () => ({
|
||||
model: 'Sentence',
|
||||
sentenceField: 'Sentence',
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: true,
|
||||
kikuEnabled: false,
|
||||
fieldGroupingMode: 'disabled',
|
||||
}),
|
||||
reviewMediaTiming: async () => review,
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(created, true);
|
||||
assert.equal(await service.createSentenceCard('字幕', 12, 14, 'Subtitle'), true);
|
||||
assert.deepEqual(addedFields[0], {
|
||||
Sentence: '字幕',
|
||||
SelectionText: 'Subtitle',
|
||||
@@ -149,11 +113,11 @@ test('sentence card writes generated audio only to sentence audio field', async
|
||||
assert.equal(mediaUpdate?.SentenceAudio, `[sound:${storedMedia[0]}]`);
|
||||
assert.equal('ExpressionAudio' in mediaUpdate!, false);
|
||||
|
||||
deps.reviewMediaTiming = async () => ({ action: 'discard' });
|
||||
review = { action: 'discard' };
|
||||
assert.equal(await service.createSentenceCard('作らない', 20, 22), false);
|
||||
assert.equal(addedFields.length, 1);
|
||||
|
||||
deps.reviewMediaTiming = async () => ({ action: 'skip-media' });
|
||||
review = { action: 'skip-media' };
|
||||
assert.equal(await service.createSentenceCard('メディアなし', 30, 32), true);
|
||||
assert.equal(addedFields.length, 2);
|
||||
assert.equal(storedMedia.length, 1);
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { CardCreationService } from './card-creation';
|
||||
import type { AnkiConnectConfig } from '../types/anki';
|
||||
|
||||
export type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
|
||||
|
||||
/** Builds the minimal config the service reads; pass only the sections a test cares about. */
|
||||
export function createAnkiConfig(
|
||||
overrides: {
|
||||
fields?: Record<string, string>;
|
||||
media?: Record<string, unknown>;
|
||||
} = {},
|
||||
): AnkiConnectConfig {
|
||||
return {
|
||||
deck: 'Mining',
|
||||
fields: { sentence: 'Sentence', audio: 'SentenceAudio', ...overrides.fields },
|
||||
media: { generateAudio: false, generateImage: false, ...overrides.media },
|
||||
behavior: {},
|
||||
ai: false,
|
||||
} as AnkiConnectConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fake CardCreationService dependencies: a bare mpv session, a client that adds note 42, no media,
|
||||
* and field resolvers that match exact field names. Override only what the test exercises.
|
||||
*/
|
||||
export function createCardCreationDeps(
|
||||
overrides: Partial<CardCreationDeps> = {},
|
||||
): CardCreationDeps {
|
||||
return {
|
||||
getConfig: () => createAnkiConfig(),
|
||||
getAiConfig: () => ({}),
|
||||
getTimingTracker: () => ({}) as never,
|
||||
getMpvClient: () =>
|
||||
({
|
||||
currentVideoPath: '/video.mp4',
|
||||
currentSubText: '字幕',
|
||||
currentSubStart: 1,
|
||||
currentSubEnd: 2,
|
||||
currentTimePos: 1.5,
|
||||
currentAudioStreamIndex: 0,
|
||||
}) as never,
|
||||
client: {
|
||||
addNote: async () => 42,
|
||||
addTags: async () => undefined,
|
||||
notesInfo: async () => [],
|
||||
updateNoteFields: async () => undefined,
|
||||
storeMediaFile: async () => undefined,
|
||||
findNotes: async () => [],
|
||||
retrieveMediaFile: async () => '',
|
||||
deleteNotes: async () => undefined,
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async () => null,
|
||||
generateScreenshot: async () => null,
|
||||
generateAnimatedImage: async () => null,
|
||||
},
|
||||
showOsdNotification: () => undefined,
|
||||
showUpdateResult: () => undefined,
|
||||
showStatusNotification: () => undefined,
|
||||
showNotification: async () => undefined,
|
||||
beginUpdateProgress: () => undefined,
|
||||
endUpdateProgress: () => undefined,
|
||||
withUpdateProgress: async (_message, action) => action(),
|
||||
resolveConfiguredFieldName: (noteInfo, ...preferredNames) => {
|
||||
for (const preferredName of preferredNames) {
|
||||
if (preferredName && preferredName in noteInfo.fields) return preferredName;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
resolveNoteFieldName: (noteInfo, preferredName) =>
|
||||
preferredName && preferredName in noteInfo.fields ? preferredName : null,
|
||||
getAnimatedImageLeadInSeconds: async () => 0,
|
||||
extractFields: () => ({}),
|
||||
processSentence: (sentence) => sentence,
|
||||
setCardTypeFields: () => undefined,
|
||||
mergeFieldValue: (_existing, newValue) => newValue,
|
||||
formatMiscInfoPattern: () => '',
|
||||
getEffectiveSentenceCardConfig: () => ({
|
||||
model: 'Sentence',
|
||||
sentenceField: 'Sentence',
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: false,
|
||||
fieldGroupingMode: 'disabled',
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
removeKnownWordNote: () => undefined,
|
||||
isUpdateInProgress: () => false,
|
||||
setUpdateInProgress: () => undefined,
|
||||
trackLastAddedNoteId: () => undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Anki client overrides, kept separate so a test can swap one method without redeclaring the rest. */
|
||||
export function createFakeClient(
|
||||
overrides: Partial<CardCreationDeps['client']> = {},
|
||||
): CardCreationDeps['client'] {
|
||||
return { ...createCardCreationDeps().client, ...overrides };
|
||||
}
|
||||
|
||||
export function createFakeMediaGenerator(
|
||||
overrides: Partial<CardCreationDeps['mediaGenerator']> = {},
|
||||
): CardCreationDeps['mediaGenerator'] {
|
||||
return { ...createCardCreationDeps().mediaGenerator, ...overrides };
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -500,3 +500,38 @@ test('Senren merge keeps duplicate SentenceFurigana when the kept field is empty
|
||||
|
||||
assert.equal(merged.SentenceFurigana, '<span class="group">duplicate furigana</span>');
|
||||
});
|
||||
|
||||
test('computeFieldGroupingMergedFields keeps independent groups for identical sentence, audio, and image values', async () => {
|
||||
const { collaborator } = createCollaborator();
|
||||
|
||||
const merged = await collaborator.computeFieldGroupingMergedFields(
|
||||
202,
|
||||
101,
|
||||
makeNote(202, {
|
||||
Sentence: 'same sentence',
|
||||
SentenceAudio: '[sound:same.mp3]',
|
||||
Picture: '<img src="same.png">',
|
||||
ExpressionAudio: '[sound:same.mp3]',
|
||||
}),
|
||||
makeNote(101, {
|
||||
Sentence: 'same sentence',
|
||||
SentenceAudio: '[sound:same.mp3]',
|
||||
Picture: '<img src="same.png">',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
merged.Sentence,
|
||||
'<span data-group-id="202">same sentence</span><span data-group-id="101">same sentence</span>',
|
||||
);
|
||||
assert.equal(
|
||||
merged.SentenceAudio,
|
||||
'<span data-group-id="202">[sound:same.mp3]</span><span data-group-id="101">[sound:same.mp3]</span>',
|
||||
);
|
||||
assert.equal(
|
||||
merged.Picture,
|
||||
'<img data-group-id="202" src="same.png"><img data-group-id="101" src="same.png">',
|
||||
);
|
||||
assert.equal('ExpressionAudio' in merged, false);
|
||||
});
|
||||
@@ -127,18 +127,6 @@ test('lifecycle config key is unchanged when maturity is disabled', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('a cache built under the old tier rules is invalidated', () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
knownWords: { highlightEnabled: true, maturityEnabled: true, refreshMinutes: 60 },
|
||||
};
|
||||
// v1 rules put lapsed cards in young because the interval queries did not
|
||||
// exclude is:learn; those persisted tiers must not be served under v2.
|
||||
assert.notEqual(
|
||||
getKnownWordCacheLifecycleConfig(config),
|
||||
'{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":21}',
|
||||
);
|
||||
});
|
||||
|
||||
test('refresh fetches tier sets and getKnownWordTier classifies notes', async () => {
|
||||
const { manager, calls, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
||||
|
||||
|
||||
@@ -223,127 +223,6 @@ test('NoteUpdateWorkflow marks enriched Kiku word cards as word-and-sentence car
|
||||
});
|
||||
});
|
||||
|
||||
test('NoteUpdateWorkflow marks the configured word card kind instead of word-and-sentence', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
harness.deps.getEffectiveSentenceCardConfig = () => ({
|
||||
sentenceField: 'Sentence',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: true,
|
||||
fieldGroupingMode: 'manual',
|
||||
wordCardKind: 'click',
|
||||
});
|
||||
harness.deps.client.notesInfo = async () =>
|
||||
[
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Expression: { value: 'taberu' },
|
||||
Sentence: { value: '' },
|
||||
IsWordAndSentenceCard: { value: 'x' },
|
||||
IsClickCard: { value: '' },
|
||||
IsSentenceCard: { value: '' },
|
||||
IsAudioCard: { value: '' },
|
||||
},
|
||||
},
|
||||
] satisfies NoteUpdateWorkflowNoteInfo[];
|
||||
|
||||
await harness.workflow.execute(42);
|
||||
|
||||
assert.equal(harness.updates.length, 1);
|
||||
assert.deepEqual(harness.updates[0]?.fields, {
|
||||
Sentence: 'subtitle-text',
|
||||
IsClickCard: 'x',
|
||||
IsWordAndSentenceCard: '',
|
||||
IsSentenceCard: '',
|
||||
IsAudioCard: '',
|
||||
});
|
||||
});
|
||||
|
||||
test('NoteUpdateWorkflow leaves card type flags alone when the word card kind is none', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
harness.deps.getEffectiveSentenceCardConfig = () => ({
|
||||
sentenceField: 'Sentence',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: true,
|
||||
fieldGroupingMode: 'manual',
|
||||
wordCardKind: 'none',
|
||||
});
|
||||
harness.deps.client.notesInfo = async () =>
|
||||
[
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Expression: { value: 'taberu' },
|
||||
Sentence: { value: '' },
|
||||
IsWordAndSentenceCard: { value: '' },
|
||||
IsSentenceCard: { value: '' },
|
||||
IsAudioCard: { value: '' },
|
||||
},
|
||||
},
|
||||
] satisfies NoteUpdateWorkflowNoteInfo[];
|
||||
|
||||
await harness.workflow.execute(42);
|
||||
|
||||
assert.equal(harness.updates.length, 1);
|
||||
assert.deepEqual(harness.updates[0]?.fields, {
|
||||
Sentence: 'subtitle-text',
|
||||
});
|
||||
});
|
||||
|
||||
test('NoteUpdateWorkflow does not set Kiku card flags when Lapis and Kiku are disabled', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
harness.deps.client.notesInfo = async () =>
|
||||
[
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Expression: { value: 'taberu' },
|
||||
Sentence: { value: '' },
|
||||
IsWordAndSentenceCard: { value: '' },
|
||||
IsSentenceCard: { value: '' },
|
||||
IsAudioCard: { value: '' },
|
||||
},
|
||||
},
|
||||
] satisfies NoteUpdateWorkflowNoteInfo[];
|
||||
|
||||
await harness.workflow.execute(42);
|
||||
|
||||
assert.equal(harness.updates.length, 1);
|
||||
assert.deepEqual(harness.updates[0]?.fields, {
|
||||
Sentence: 'subtitle-text',
|
||||
});
|
||||
});
|
||||
|
||||
test('NoteUpdateWorkflow preserves explicit sentence card type during sentence enrichment', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
harness.deps.getEffectiveSentenceCardConfig = () => ({
|
||||
sentenceField: 'Sentence',
|
||||
lapisEnabled: true,
|
||||
kikuEnabled: false,
|
||||
fieldGroupingMode: 'disabled',
|
||||
});
|
||||
harness.deps.client.notesInfo = async () =>
|
||||
[
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Expression: { value: 'sentence expression' },
|
||||
Sentence: { value: '' },
|
||||
IsWordAndSentenceCard: { value: '' },
|
||||
IsSentenceCard: { value: 'x' },
|
||||
IsAudioCard: { value: '' },
|
||||
},
|
||||
},
|
||||
] satisfies NoteUpdateWorkflowNoteInfo[];
|
||||
|
||||
await harness.workflow.execute(42);
|
||||
|
||||
assert.equal(harness.updates.length, 1);
|
||||
assert.deepEqual(harness.updates[0]?.fields, {
|
||||
Sentence: 'subtitle-text',
|
||||
});
|
||||
});
|
||||
|
||||
test('NoteUpdateWorkflow no-ops when note info is missing', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
harness.deps.client.notesInfo = async () => [];
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const ciWorkflowPath = resolve(__dirname, '../.github/workflows/ci.yml');
|
||||
const ciWorkflow = readFileSync(ciWorkflowPath, 'utf8');
|
||||
const docsPagesWorkflowPath = resolve(__dirname, '../.github/workflows/docs-pages.yml');
|
||||
const docsPagesWorkflow = readFileSync(docsPagesWorkflowPath, 'utf8');
|
||||
const packageJsonPath = resolve(__dirname, '../package.json');
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
|
||||
scripts: Record<string, string>;
|
||||
};
|
||||
|
||||
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('source and coverage scripts discover the same maintained source lane', () => {
|
||||
const sourceLane = packageJson.scripts['test:src']?.match(/run-test-lane\.mjs\s+([^\s]+)/)?.[1];
|
||||
const coverageLane = packageJson.scripts['test:coverage:src']?.match(
|
||||
/run-coverage-lane\.ts\s+([^\s]+)/,
|
||||
)?.[1];
|
||||
|
||||
assert.equal(sourceLane, 'bun-src-full');
|
||||
assert.equal(coverageLane, sourceLane);
|
||||
});
|
||||
|
||||
test('environment suite owns launcher smoke execution', () => {
|
||||
assert.match(
|
||||
packageJson.scripts['test:env'] ?? '',
|
||||
/^bun run test:launcher:smoke:src && bun run test:plugin:src && bun run test:immersion:sqlite:src$/,
|
||||
);
|
||||
});
|
||||
|
||||
test('ci delegates its gate instead of duplicating quality steps', () => {
|
||||
assert.match(
|
||||
ciWorkflow,
|
||||
/build-test-audit:\s*\n\s*uses: \.\/\.github\/workflows\/quality-gate\.yml/,
|
||||
);
|
||||
assert.doesNotMatch(ciWorkflow, /oven-sh\/setup-bun/);
|
||||
assert.doesNotMatch(ciWorkflow, /bun run test:coverage:src/);
|
||||
assert.doesNotMatch(ciWorkflow, /bun run test:env/);
|
||||
});
|
||||
|
||||
test('main docs deploy exists, serializes deploys, and uses Cloudflare credentials', () => {
|
||||
assert.match(docsPagesWorkflow, /name: Docs Pages/);
|
||||
assert.match(docsPagesWorkflow, /branches:\s*\n\s*-\s*main/);
|
||||
assert.match(docsPagesWorkflow, /group:\s*docs-pages-production/);
|
||||
assert.match(docsPagesWorkflow, /CLOUDFLARE_API_TOKEN/);
|
||||
assert.match(docsPagesWorkflow, /CLOUDFLARE_ACCOUNT_ID/);
|
||||
assert.match(docsPagesWorkflow, /CLOUDFLARE_PAGES_PROJECT_NAME/);
|
||||
assert.match(docsPagesWorkflow, /pages deploy \.\.\/\.tmp\/docs-versioned-site/);
|
||||
assert.match(docsPagesWorkflow, /--branch main/);
|
||||
});
|
||||
|
||||
test('docs deploy syncs frozen archives to R2 and ships the archive Pages Function', () => {
|
||||
assert.doesNotMatch(docsPagesWorkflow, /actions\/cache@/);
|
||||
assert.match(docsPagesWorkflow, /DOCS_ARCHIVE_R2_ACCESS_KEY_ID/);
|
||||
assert.match(docsPagesWorkflow, /DOCS_ARCHIVE_R2_SECRET_ACCESS_KEY/);
|
||||
assert.match(docsPagesWorkflow, /--require-archives/);
|
||||
assert.match(docsPagesWorkflow, /--rebuild-archives=\$\{REBUILD_ARCHIVES\}/);
|
||||
assert.match(docsPagesWorkflow, /workingDirectory: docs-site/);
|
||||
});
|
||||
|
||||
test('docs deploy skips invalid release tags without failing the workflow', () => {
|
||||
assert.match(docsPagesWorkflow, /id:\s*tag_guard/);
|
||||
assert.match(docsPagesWorkflow, /stable_tag=false/);
|
||||
assert.doesNotMatch(docsPagesWorkflow, /exit 78/);
|
||||
assert.match(docsPagesWorkflow, /if:\s*steps\.tag_guard\.outputs\.stable_tag != 'false'/);
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { printHelp } from './help';
|
||||
|
||||
test('printHelp includes configured texthooker port', () => {
|
||||
const original = console.log;
|
||||
let output = '';
|
||||
console.log = (value?: unknown) => {
|
||||
output += String(value);
|
||||
};
|
||||
|
||||
try {
|
||||
printHelp(7777);
|
||||
} finally {
|
||||
console.log = original;
|
||||
}
|
||||
|
||||
assert.match(output, /--help\s+Show this help/);
|
||||
assert.match(output, /default: 7777/);
|
||||
assert.match(output, /--launch-mpv.*Launch mpv with SubMiner defaults and exit/);
|
||||
assert.match(output, /--stats\s+Open the stats dashboard in your browser/);
|
||||
assert.match(output, /--open-browser\s+Open texthooker in your default browser/);
|
||||
assert.doesNotMatch(output, /--refresh-known-words/);
|
||||
assert.match(output, /--setup\s+Open first-run setup window/);
|
||||
assert.match(output, /--settings\s+Open SubMiner settings window/);
|
||||
assert.match(output, /--yomitan\s+Open Yomitan settings window/);
|
||||
assert.match(output, /--mark-watched\s+Mark current video watched and advance playlist/);
|
||||
assert.match(output, /--anilist-status/);
|
||||
assert.match(output, /--anilist-retry-queue/);
|
||||
assert.match(output, /--dictionary/);
|
||||
assert.match(output, /--dictionary-target/);
|
||||
assert.match(output, /--jellyfin\s+Open Jellyfin setup window/);
|
||||
assert.match(output, /--jellyfin-login/);
|
||||
assert.match(output, /--jellyfin-subtitles/);
|
||||
assert.match(output, /--jellyfin-play/);
|
||||
});
|
||||
+103
-1004
File diff suppressed because it is too large.
Load diff
@@ -2,17 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { ResolvedConfig } from '../../types/config';
|
||||
import {
|
||||
CONFIG_OPTION_REGISTRY,
|
||||
CONFIG_TEMPLATE_SECTIONS,
|
||||
DEFAULT_CONFIG,
|
||||
DEFAULT_KEYBINDINGS,
|
||||
RUNTIME_OPTION_REGISTRY,
|
||||
} from '../definitions';
|
||||
import { buildCoreConfigOptionRegistry } from './options-core';
|
||||
import { buildImmersionConfigOptionRegistry } from './options-immersion';
|
||||
import { buildIntegrationConfigOptionRegistry } from './options-integrations';
|
||||
import { buildSubtitleConfigOptionRegistry } from './options-subtitle';
|
||||
import { CONFIG_OPTION_REGISTRY, CONFIG_TEMPLATE_SECTIONS, DEFAULT_CONFIG } from '../definitions';
|
||||
|
||||
function collectConfigLeafPaths(config: ResolvedConfig): string[] {
|
||||
const leaves: string[] = [];
|
||||
@@ -87,58 +77,6 @@ const UNDOCUMENTED_LEAVES: ReadonlySet<string> = new Set([
|
||||
'subtitleStyle.wordSpacing',
|
||||
]);
|
||||
|
||||
test('config option registry includes critical paths and has unique entries', () => {
|
||||
const paths = CONFIG_OPTION_REGISTRY.map((entry) => entry.path);
|
||||
|
||||
for (const requiredPath of [
|
||||
'logging.level',
|
||||
'logging.files.mpv',
|
||||
'annotationWebsocket.enabled',
|
||||
'controller.enabled',
|
||||
'controller.scrollPixelsPerSecond',
|
||||
'startupWarmups.lowPowerMode',
|
||||
'updates.channel',
|
||||
'youtube.primarySubLanguages',
|
||||
'subtitleStyle.enableJlpt',
|
||||
'subtitleStyle.autoPauseVideoOnYomitanPopup',
|
||||
'ankiConnect.enabled',
|
||||
'subtitleStyle.nameMatchEnabled',
|
||||
'ankiConnect.media.normalizeAudio',
|
||||
'ankiConnect.media.mirrorMpvVolume',
|
||||
'anilist.characterDictionary.collapsibleSections.description',
|
||||
'mpv.executablePath',
|
||||
'mpv.launchMode',
|
||||
'mpv.profile',
|
||||
'mpv.socketPath',
|
||||
'mpv.backend',
|
||||
'mpv.autoStartSubMiner',
|
||||
'mpv.pauseUntilOverlayReady',
|
||||
'mpv.subminerBinaryPath',
|
||||
'mpv.aniskipEnabled',
|
||||
'mpv.aniskipButtonKey',
|
||||
'yomitan.externalProfilePath',
|
||||
'immersionTracking.enabled',
|
||||
]) {
|
||||
assert.ok(paths.includes(requiredPath), `missing config path: ${requiredPath}`);
|
||||
}
|
||||
|
||||
assert.equal(new Set(paths).size, paths.length);
|
||||
});
|
||||
|
||||
test('known-word annotation color has one public config path', () => {
|
||||
const leaves = collectConfigLeafPaths(DEFAULT_CONFIG);
|
||||
|
||||
assert.ok(leaves.includes('subtitleStyle.knownWordColor'));
|
||||
assert.ok(!leaves.includes('ankiConnect.knownWords.color'));
|
||||
});
|
||||
|
||||
test('n+1 annotation color has one public config path', () => {
|
||||
const leaves = collectConfigLeafPaths(DEFAULT_CONFIG);
|
||||
|
||||
assert.ok(leaves.includes('subtitleStyle.nPlusOneColor'));
|
||||
assert.ok(!leaves.includes('ankiConnect.nPlusOne.color'));
|
||||
});
|
||||
|
||||
test('every DEFAULT_CONFIG leaf is in CONFIG_OPTION_REGISTRY or UNDOCUMENTED_LEAVES', () => {
|
||||
const registryPaths = new Set(CONFIG_OPTION_REGISTRY.map((entry) => entry.path));
|
||||
const leaves = collectConfigLeafPaths(DEFAULT_CONFIG);
|
||||
@@ -168,77 +106,10 @@ test('every DEFAULT_CONFIG leaf is in CONFIG_OPTION_REGISTRY or UNDOCUMENTED_LEA
|
||||
);
|
||||
});
|
||||
|
||||
test('config template sections include expected domains and unique keys', () => {
|
||||
test('config option paths and template section keys are unique', () => {
|
||||
const paths = CONFIG_OPTION_REGISTRY.map((entry) => entry.path);
|
||||
assert.equal(new Set(paths).size, paths.length);
|
||||
|
||||
const keys = CONFIG_TEMPLATE_SECTIONS.map((section) => section.key);
|
||||
const requiredKeys: (typeof keys)[number][] = [
|
||||
'websocket',
|
||||
'annotationWebsocket',
|
||||
'controller',
|
||||
'startupWarmups',
|
||||
'youtube',
|
||||
'subtitleStyle',
|
||||
'ankiConnect',
|
||||
'yomitan',
|
||||
'mpv',
|
||||
'immersionTracking',
|
||||
];
|
||||
|
||||
for (const requiredKey of requiredKeys) {
|
||||
assert.ok(keys.includes(requiredKey), `missing template section key: ${requiredKey}`);
|
||||
}
|
||||
|
||||
assert.equal(new Set(keys).size, keys.length);
|
||||
});
|
||||
|
||||
test('domain registry builders each contribute entries to composed registry', () => {
|
||||
const domainEntries = [
|
||||
buildCoreConfigOptionRegistry(DEFAULT_CONFIG),
|
||||
buildSubtitleConfigOptionRegistry(DEFAULT_CONFIG),
|
||||
buildIntegrationConfigOptionRegistry(DEFAULT_CONFIG, RUNTIME_OPTION_REGISTRY),
|
||||
buildImmersionConfigOptionRegistry(DEFAULT_CONFIG),
|
||||
];
|
||||
const composedPaths = new Set(CONFIG_OPTION_REGISTRY.map((entry) => entry.path));
|
||||
|
||||
for (const entries of domainEntries) {
|
||||
assert.ok(entries.length > 0);
|
||||
assert.ok(entries.some((entry) => composedPaths.has(entry.path)));
|
||||
}
|
||||
});
|
||||
|
||||
test('default keybindings include primary and secondary subtitle track cycling on J keys', () => {
|
||||
const keybindingMap = new Map(
|
||||
DEFAULT_KEYBINDINGS.map((binding) => [binding.key, binding.command]),
|
||||
);
|
||||
assert.deepEqual(keybindingMap.get('KeyJ'), ['cycle', 'sid']);
|
||||
assert.deepEqual(keybindingMap.get('Shift+KeyJ'), ['cycle', 'secondary-sid']);
|
||||
assert.deepEqual(keybindingMap.get('Ctrl+Alt+KeyC'), ['__youtube-picker-open']);
|
||||
assert.deepEqual(keybindingMap.get('Ctrl+Alt+KeyP'), ['__playlist-browser-open']);
|
||||
});
|
||||
|
||||
test('default keybindings include fullscreen on F', () => {
|
||||
const keybindingMap = new Map(
|
||||
DEFAULT_KEYBINDINGS.map((binding) => [binding.key, binding.command]),
|
||||
);
|
||||
assert.deepEqual(keybindingMap.get('KeyF'), ['cycle', 'fullscreen']);
|
||||
});
|
||||
|
||||
test('default keybindings include replay and next subtitle controls', () => {
|
||||
const keybindingMap = new Map(
|
||||
DEFAULT_KEYBINDINGS.map((binding) => [binding.key, binding.command]),
|
||||
);
|
||||
assert.deepEqual(keybindingMap.get('Ctrl+Shift+KeyH'), ['__replay-subtitle']);
|
||||
assert.deepEqual(keybindingMap.get('Ctrl+Shift+KeyL'), ['__play-next-subtitle']);
|
||||
});
|
||||
|
||||
test('default keybindings mirror mpv subtitle delay and sub-step keys', () => {
|
||||
const keybindingMap = new Map(
|
||||
DEFAULT_KEYBINDINGS.map((binding) => [binding.key, binding.command]),
|
||||
);
|
||||
assert.deepEqual(keybindingMap.get('KeyZ'), ['add', 'sub-delay', -0.1]);
|
||||
assert.deepEqual(keybindingMap.get('Shift+KeyZ'), ['add', 'sub-delay', 0.1]);
|
||||
assert.deepEqual(keybindingMap.get('KeyX'), ['add', 'sub-delay', 0.1]);
|
||||
assert.deepEqual(keybindingMap.get('Ctrl+Shift+ArrowLeft'), ['sub-step', -1]);
|
||||
assert.deepEqual(keybindingMap.get('Ctrl+Shift+ArrowRight'), ['sub-step', 1]);
|
||||
assert.equal(keybindingMap.has('Shift+BracketLeft'), false);
|
||||
assert.equal(keybindingMap.has('Shift+BracketRight'), false);
|
||||
});
|
||||
@@ -11,209 +11,6 @@ function field(path: string) {
|
||||
return match;
|
||||
}
|
||||
|
||||
test('settings registry splits viewing into appearance and behavior categories', () => {
|
||||
assert.equal(field('subtitleStyle.fontSize').category, 'appearance');
|
||||
assert.equal(field('subtitleStyle.primaryDefaultMode').category, 'behavior');
|
||||
assert.equal(field('subtitleStyle.primaryDefaultMode').section, 'Subtitle Behavior');
|
||||
assert.equal(field('subtitleStyle.primaryVisibleOnYomitanPopup').category, 'behavior');
|
||||
assert.equal(field('subtitleStyle.primaryVisibleOnYomitanPopup').section, 'Subtitle Behavior');
|
||||
assert.equal(field('secondarySub.defaultMode').category, 'behavior');
|
||||
assert.equal(field('subtitlePosition.yPercent').label, 'Subtitle Position');
|
||||
assert.equal(field('subtitleStyle.frequencyDictionary.mode').label, 'Frequency Mode');
|
||||
assert.equal(field('auto_start_overlay').category, 'behavior');
|
||||
assert.equal(field('auto_start_overlay').section, 'Playback Behavior');
|
||||
assert.equal(field('youtube.primarySubLanguages').category, 'behavior');
|
||||
assert.equal(field('youtube.primarySubLanguages').section, 'YouTube Playback Settings');
|
||||
assert.equal(field('mpv.launchMode').category, 'behavior');
|
||||
assert.equal(field('mpv.launchMode').section, 'mpv Playback');
|
||||
assert.equal(field('mpv.profile').category, 'behavior');
|
||||
assert.equal(field('mpv.profile').section, 'mpv Playback');
|
||||
assert.ok(
|
||||
fields.findIndex((candidate) => candidate.configPath === 'subtitleStyle.primaryDefaultMode') <
|
||||
fields.findIndex(
|
||||
(candidate) => candidate.configPath === 'subtitleStyle.primaryVisibleOnYomitanPopup',
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
fields.findIndex(
|
||||
(candidate) => candidate.configPath === 'subtitleStyle.primaryVisibleOnYomitanPopup',
|
||||
) < fields.findIndex((candidate) => candidate.configPath === 'secondarySub.defaultMode'),
|
||||
);
|
||||
});
|
||||
|
||||
test('settings registry groups playback startup controls under playback behavior', () => {
|
||||
for (const path of [
|
||||
'subtitleStyle.autoPauseVideoOnHover',
|
||||
'subtitleStyle.autoPauseVideoOnYomitanPopup',
|
||||
'subtitleSidebar.pauseVideoOnHover',
|
||||
'mpv.autoStartSubMiner',
|
||||
'auto_start_overlay',
|
||||
'mpv.pauseUntilOverlayReady',
|
||||
]) {
|
||||
assert.equal(field(path).category, 'behavior', path);
|
||||
assert.equal(field(path).section, 'Playback Behavior', path);
|
||||
}
|
||||
});
|
||||
|
||||
test('settings registry moves AniSkip button key into input shortcuts and hot reload', () => {
|
||||
assert.equal(field('mpv.aniskipButtonKey').category, 'input');
|
||||
assert.equal(field('mpv.aniskipButtonKey').section, 'Overlay Shortcuts');
|
||||
assert.equal(field('mpv.aniskipButtonKey').subsection, 'Playback');
|
||||
assert.equal(field('mpv.aniskipButtonKey').control, 'mpv-key');
|
||||
assert.equal(field('mpv.aniskipButtonKey').restartBehavior, 'hot-reload');
|
||||
});
|
||||
|
||||
test('settings registry exposes character dictionary panel shortcuts dynamically', () => {
|
||||
assert.equal(
|
||||
field('shortcuts.openCharacterDictionaryManager').label,
|
||||
'Open Character Dictionary Manager',
|
||||
);
|
||||
assert.equal(field('shortcuts.openCharacterDictionaryManager').subsection, 'Open Panels');
|
||||
});
|
||||
|
||||
test('settings registry hides removed modal-only fields', () => {
|
||||
for (const path of [
|
||||
'shortcuts.multiCopyTimeoutMs',
|
||||
'anilist.characterDictionary.profileScope',
|
||||
'jellyfin.directPlayContainers',
|
||||
]) {
|
||||
assert.equal(
|
||||
fields.some((candidate) => candidate.configPath === path),
|
||||
false,
|
||||
path,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('settings registry orders websocket server immediately after annotation websocket', () => {
|
||||
const integrationSections = [
|
||||
...new Set(
|
||||
fields
|
||||
.filter((candidate) => candidate.category === 'integrations')
|
||||
.map((candidate) => candidate.section),
|
||||
),
|
||||
];
|
||||
const annotationIndex = integrationSections.indexOf('Annotation WebSocket');
|
||||
assert.equal(integrationSections[annotationIndex + 1], 'WebSocket server');
|
||||
});
|
||||
|
||||
test('settings registry explains websocket auto mode and keeps it disabled by default', () => {
|
||||
assert.equal(field('websocket.enabled').defaultValue, false);
|
||||
assert.equal(
|
||||
field('websocket.enabled').description,
|
||||
'Built-in subtitle WebSocket server mode. Auto starts the built-in server only when mpv_websocket is not detected; otherwise it defers to the plugin.',
|
||||
);
|
||||
});
|
||||
|
||||
test('settings registry places immersion tracking after other tracking and app sections', () => {
|
||||
const trackingSections = [
|
||||
...new Set(
|
||||
fields
|
||||
.filter((candidate) => candidate.category === 'tracking-app')
|
||||
.map((candidate) => candidate.section),
|
||||
),
|
||||
];
|
||||
assert.equal(trackingSections.at(-1), 'Immersion tracking');
|
||||
});
|
||||
|
||||
test('settings registry groups annotation display fields by config group', () => {
|
||||
assert.equal(field('ankiConnect.knownWords.highlightEnabled').section, 'Annotation Display');
|
||||
assert.equal(field('ankiConnect.knownWords.highlightEnabled').subsection, 'Known Words');
|
||||
assert.equal(field('subtitleStyle.knownWordColor').subsection, 'Known Words');
|
||||
assert.equal(field('subtitleStyle.nPlusOneColor').subsection, 'N+1');
|
||||
assert.equal(field('subtitleStyle.enableJlpt').subsection, 'JLPT');
|
||||
assert.equal(field('subtitleStyle.jlptColors.N1').control, 'color');
|
||||
});
|
||||
|
||||
test('settings registry groups maturity settings under Known Words with color controls', () => {
|
||||
assert.equal(field('ankiConnect.knownWords.maturityEnabled').subsection, 'Known Words');
|
||||
assert.equal(field('ankiConnect.knownWords.matureThresholdDays').subsection, 'Known Words');
|
||||
for (const tier of ['new', 'learning', 'young', 'mature']) {
|
||||
const tierField = field(`subtitleStyle.knownWordMaturityColors.${tier}`);
|
||||
assert.equal(tierField.subsection, 'Known Words');
|
||||
assert.equal(tierField.control, 'color');
|
||||
}
|
||||
});
|
||||
|
||||
test('settings registry orders maturity colors from least mature to mature', () => {
|
||||
const knownWordsPaths = fields
|
||||
.filter((candidate) => candidate.subsection === 'Known Words')
|
||||
.map((candidate) => candidate.configPath);
|
||||
assert.deepEqual(knownWordsPaths, [
|
||||
'ankiConnect.knownWords.highlightEnabled',
|
||||
'ankiConnect.knownWords.maturityEnabled',
|
||||
'subtitleStyle.knownWordColor',
|
||||
'ankiConnect.knownWords.matureThresholdDays',
|
||||
'subtitleStyle.knownWordMaturityColors.new',
|
||||
'subtitleStyle.knownWordMaturityColors.learning',
|
||||
'subtitleStyle.knownWordMaturityColors.young',
|
||||
'subtitleStyle.knownWordMaturityColors.mature',
|
||||
]);
|
||||
});
|
||||
|
||||
test('settings registry routes known words sync, n+1, and frequency config to behavior', () => {
|
||||
assert.equal(field('ankiConnect.knownWords.addMinedWordsImmediately').category, 'behavior');
|
||||
assert.equal(field('ankiConnect.knownWords.addMinedWordsImmediately').section, 'Known Words');
|
||||
assert.equal(field('ankiConnect.knownWords.decks').category, 'behavior');
|
||||
assert.equal(field('ankiConnect.knownWords.decks').section, 'Known Words');
|
||||
assert.equal(field('ankiConnect.knownWords.matchMode').category, 'behavior');
|
||||
assert.equal(field('ankiConnect.knownWords.matchMode').section, 'Known Words');
|
||||
assert.equal(field('ankiConnect.knownWords.refreshMinutes').category, 'behavior');
|
||||
assert.equal(field('ankiConnect.knownWords.refreshMinutes').section, 'Known Words');
|
||||
assert.equal(field('ankiConnect.nPlusOne.minSentenceWords').category, 'behavior');
|
||||
assert.equal(field('ankiConnect.nPlusOne.minSentenceWords').section, 'N+1');
|
||||
assert.equal(field('subtitleStyle.frequencyDictionary.sourcePath').category, 'behavior');
|
||||
assert.equal(
|
||||
field('subtitleStyle.frequencyDictionary.sourcePath').section,
|
||||
'Frequency Highlighting',
|
||||
);
|
||||
assert.equal(field('subtitleStyle.frequencyDictionary.mode').category, 'behavior');
|
||||
assert.equal(field('subtitleStyle.frequencyDictionary.matchMode').category, 'behavior');
|
||||
assert.equal(field('subtitleStyle.frequencyDictionary.topX').category, 'behavior');
|
||||
});
|
||||
|
||||
test('settings registry exposes mpv aniskip button as an mpv key learn control', () => {
|
||||
assert.equal(field('mpv.aniskipButtonKey').control, 'mpv-key');
|
||||
});
|
||||
|
||||
test('settings registry exposes specialized controls for config-assisted inputs', () => {
|
||||
assert.equal(field('ankiConnect.deck').control, 'anki-deck');
|
||||
assert.equal(field('ankiConnect.knownWords.decks').control, 'known-words-decks');
|
||||
assert.equal(field('ankiConnect.isLapis.sentenceCardModel').control, 'anki-note-type');
|
||||
assert.equal(field('ankiConnect.fields.word').control, 'anki-field');
|
||||
assert.equal(field('keybindings').control, 'mpv-keybindings');
|
||||
assert.equal(field('subtitleStyle.css').control, 'css-declarations');
|
||||
assert.equal(field('subtitleStyle.secondary.css').control, 'css-declarations');
|
||||
assert.equal(field('shortcuts.copySubtitle').control, 'keyboard-shortcut');
|
||||
assert.equal(field('mpv.aniskipButtonKey').control, 'mpv-key');
|
||||
assert.equal(field('subtitleSidebar.css').control, 'css-declarations');
|
||||
assert.equal(field('stats.toggleKey').control, 'key-code');
|
||||
assert.equal(field('discordPresence.presenceStyle').control, 'select');
|
||||
});
|
||||
|
||||
test('settings registry exposes mpv volume mirroring as a mining toggle', () => {
|
||||
const volumeField = field('ankiConnect.media.mirrorMpvVolume');
|
||||
|
||||
assert.equal(volumeField.category, 'mining-anki');
|
||||
assert.equal(volumeField.section, 'Media Capture');
|
||||
assert.equal(volumeField.control, 'boolean');
|
||||
assert.equal(volumeField.defaultValue, true);
|
||||
});
|
||||
|
||||
test('settings registry exposes YouTube media cache mode as a labeled select', () => {
|
||||
const mediaCacheMode = field('youtube.mediaCache.mode');
|
||||
const mediaCacheMaxHeight = field('youtube.mediaCache.maxHeight');
|
||||
|
||||
assert.equal(mediaCacheMode.control, 'select');
|
||||
assert.deepEqual(mediaCacheMode.enumValues, ['direct', 'background']);
|
||||
assert.deepEqual(mediaCacheMode.enumLabels, {
|
||||
direct: 'Direct stream extraction',
|
||||
background: 'Background media cache',
|
||||
});
|
||||
assert.equal(mediaCacheMaxHeight.control, 'number');
|
||||
assert.equal(mediaCacheMaxHeight.defaultValue, 720);
|
||||
});
|
||||
|
||||
test('settings registry exposes css declaration editor for primary and secondary subtitle appearance', () => {
|
||||
const primaryVisible = fields
|
||||
.filter(
|
||||
@@ -268,18 +65,6 @@ test('settings registry exposes css declaration editor for subtitle sidebar appe
|
||||
assert.equal(field('subtitleSidebar.layout').settingsHidden, false);
|
||||
});
|
||||
|
||||
test('settings registry routes playback-related integrations into integrations', () => {
|
||||
assert.equal(field('jimaku.apiBaseUrl').category, 'integrations');
|
||||
assert.equal(field('jimaku.apiBaseUrl').section, 'Jimaku');
|
||||
assert.equal(field('subsync.replace').category, 'integrations');
|
||||
assert.equal(field('subsync.replace').section, 'Subtitle Sync');
|
||||
assert.equal(field('tsukihime.apiBaseUrl').category, 'integrations');
|
||||
assert.equal(field('tsukihime.apiBaseUrl').section, 'TsukiHime');
|
||||
assert.equal(field('tmdb.apiKey').category, 'integrations');
|
||||
assert.equal(field('tmdb.apiKey').section, 'TMDB');
|
||||
assert.equal(field('tmdb.apiKey').secret, true);
|
||||
});
|
||||
|
||||
test('settings registry puts feature toggles first, then other toggles alphabetically', () => {
|
||||
const ankiConnect = fields.filter((candidate) => candidate.section === 'AnkiConnect');
|
||||
assert.equal(ankiConnect[0]?.configPath, 'ankiConnect.enabled');
|
||||
@@ -309,89 +94,3 @@ test('settings registry puts feature toggles first, then other toggles alphabeti
|
||||
['ankiConnect.isLapis.enabled', 'ankiConnect.isKiku.enabled', 'ankiConnect.isSenren.enabled'],
|
||||
);
|
||||
});
|
||||
|
||||
test('settings registry hides app-managed and inactive config surfaces', () => {
|
||||
const paths = new Set(fields.map((candidate) => candidate.configPath));
|
||||
for (const hiddenPath of [
|
||||
'ai.enabled',
|
||||
'ai.apiKey',
|
||||
'ai.apiKeyCommand',
|
||||
'ai.model',
|
||||
'ai.baseUrl',
|
||||
'ai.systemPrompt',
|
||||
'ai.requestTimeoutMs',
|
||||
'ankiConnect.ai.enabled',
|
||||
'ankiConnect.ai.model',
|
||||
'ankiConnect.ai.systemPrompt',
|
||||
'ankiConnect.fields.translation',
|
||||
'controller.bindings',
|
||||
'controller.preferredGamepadId',
|
||||
'controller.preferredGamepadLabel',
|
||||
'controller.profiles',
|
||||
'jellyfin.defaultLibraryId',
|
||||
'subtitleSidebar.toggleKey',
|
||||
'jellyfin.recentServers',
|
||||
]) {
|
||||
assert.equal(paths.has(hiddenPath), false, `${hiddenPath} should be hidden`);
|
||||
}
|
||||
assert.equal(paths.has('anilist.characterDictionary.enabled'), false);
|
||||
});
|
||||
|
||||
test('settings registry marks safe live config paths as hot-reloadable', () => {
|
||||
for (const path of [
|
||||
'mpv.aniskipButtonKey',
|
||||
'stats.toggleKey',
|
||||
'stats.markWatchedKey',
|
||||
'logging.level',
|
||||
'logging.rotation',
|
||||
'logging.files.app',
|
||||
'logging.files.launcher',
|
||||
'logging.files.mpv',
|
||||
'youtube.primarySubLanguages',
|
||||
'jimaku.apiBaseUrl',
|
||||
'jimaku.languagePreference',
|
||||
'jimaku.maxEntryResults',
|
||||
'subsync.replace',
|
||||
'ankiConnect.behavior.autoUpdateNewCards',
|
||||
'ankiConnect.deck',
|
||||
'ankiConnect.media.normalizeAudio',
|
||||
'ankiConnect.media.mirrorMpvVolume',
|
||||
'ankiConnect.media.reviewTiming',
|
||||
'ankiConnect.knownWords.highlightEnabled',
|
||||
'ankiConnect.knownWords.refreshMinutes',
|
||||
'ankiConnect.knownWords.addMinedWordsImmediately',
|
||||
'ankiConnect.knownWords.matchMode',
|
||||
'ankiConnect.knownWords.decks',
|
||||
'ankiConnect.nPlusOne.enabled',
|
||||
'ankiConnect.nPlusOne.minSentenceWords',
|
||||
'ankiConnect.fields.word',
|
||||
'ankiConnect.fields.audio',
|
||||
'ankiConnect.fields.wordAudio',
|
||||
'ankiConnect.fields.image',
|
||||
'ankiConnect.fields.sentence',
|
||||
'ankiConnect.fields.miscInfo',
|
||||
'ankiConnect.isLapis.sentenceCardModel',
|
||||
'ankiConnect.isKiku.fieldGrouping',
|
||||
'ankiConnect.isSenren.fieldGrouping',
|
||||
]) {
|
||||
assert.equal(field(path).restartBehavior, 'hot-reload', path);
|
||||
}
|
||||
});
|
||||
|
||||
test('settings registry does not expose removed subsync mode option', () => {
|
||||
const paths = new Set(fields.map((candidate) => candidate.configPath));
|
||||
assert.equal(paths.has('subsync.defaultMode'), false);
|
||||
});
|
||||
|
||||
test('settings registry keeps unsafe config siblings restart-required', () => {
|
||||
for (const path of [
|
||||
'stats.serverPort',
|
||||
'ankiConnect.url',
|
||||
'ankiConnect.proxy.enabled',
|
||||
'mpv.socketPath',
|
||||
'mpv.profile',
|
||||
'websocket.port',
|
||||
]) {
|
||||
assert.equal(field(path).restartBehavior, 'restart', path);
|
||||
}
|
||||
});
|
||||
@@ -5,30 +5,6 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { DatabaseSync } from '../immersion-tracker/sqlite';
|
||||
|
||||
type ImmersionTrackerService = import('../immersion-tracker-service').ImmersionTrackerService;
|
||||
type ImmersionTrackerServiceCtor =
|
||||
typeof import('../immersion-tracker-service').ImmersionTrackerService;
|
||||
|
||||
let trackerCtor: ImmersionTrackerServiceCtor | null = null;
|
||||
|
||||
async function loadTrackerCtor(): Promise<ImmersionTrackerServiceCtor> {
|
||||
if (trackerCtor) return trackerCtor;
|
||||
const mod = await import('../immersion-tracker-service');
|
||||
trackerCtor = mod.ImmersionTrackerService;
|
||||
return trackerCtor;
|
||||
}
|
||||
|
||||
function makeDbPath(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-write-queue-test-'));
|
||||
return path.join(dir, 'immersion.sqlite');
|
||||
}
|
||||
|
||||
function cleanupDbPath(dbPath: string): void {
|
||||
const dir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dir)) return;
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
interface TrackerInternals {
|
||||
db: DatabaseSync;
|
||||
queue: unknown[];
|
||||
@@ -42,155 +18,38 @@ interface TrackerInternals {
|
||||
writeLock: { locked: boolean };
|
||||
}
|
||||
|
||||
test('delete maintenance fails closed when queued writes cannot drain', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
interface TrackerHarness {
|
||||
tracker: TrackerInternals;
|
||||
deleteRunnerCalls: () => number;
|
||||
}
|
||||
|
||||
/** Runs `fn` against a fresh tracker (batchSize 2) seeded with two library entries. */
|
||||
async function withTracker(fn: (harness: TrackerHarness) => Promise<void>): Promise<void> {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-write-queue-test-'));
|
||||
const { ImmersionTrackerService } = await import('../immersion-tracker-service');
|
||||
let deleteRunnerCalls = 0;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor(
|
||||
{ dbPath, policy: { batchSize: 2 } },
|
||||
{
|
||||
runDeleteMaintenanceTask: async () => {
|
||||
deleteRunnerCalls += 1;
|
||||
},
|
||||
const service = new ImmersionTrackerService(
|
||||
{ dbPath: path.join(dir, 'immersion.sqlite'), policy: { batchSize: 2 } },
|
||||
{
|
||||
runDeleteMaintenanceTask: async () => {
|
||||
deleteRunnerCalls += 1;
|
||||
},
|
||||
);
|
||||
const internals = tracker as unknown as TrackerInternals;
|
||||
seedTwoEntries(internals.db);
|
||||
queueSubtitleLines(internals, 1);
|
||||
let flushCalls = 0;
|
||||
internals.flushNow = () => {
|
||||
flushCalls += 1;
|
||||
if (flushCalls > 1) throw new Error('bounded no-progress sentinel');
|
||||
};
|
||||
|
||||
await assert.rejects(internals.deleteSession(1), /queue did not drain/i);
|
||||
|
||||
assert.equal(flushCalls, 1);
|
||||
assert.equal(deleteRunnerCalls, 0);
|
||||
assert.equal(internals.writeLock.locked, false);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('reassignAnimeAnilist fails closed before resolving a conflict when writes cannot drain', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
},
|
||||
);
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
|
||||
const internals = tracker as unknown as TrackerInternals;
|
||||
seedTwoEntries(internals.db);
|
||||
internals.db.prepare('UPDATE imm_anime SET anilist_id = 123 WHERE anime_id = 2').run();
|
||||
queueSubtitleLines(internals, 1);
|
||||
internals.flushNow = () => {};
|
||||
|
||||
await assert.rejects(
|
||||
internals.reassignAnimeAnilist(1, { anilistId: 123 }),
|
||||
/queue did not drain/i,
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
internals.db
|
||||
.prepare(
|
||||
'SELECT anime_id AS animeId, anilist_id AS anilistId FROM imm_anime ORDER BY anime_id',
|
||||
)
|
||||
.all(),
|
||||
[
|
||||
{ animeId: 1, anilistId: null },
|
||||
{ animeId: 2, anilistId: 123 },
|
||||
],
|
||||
);
|
||||
const tracker = service as unknown as TrackerInternals;
|
||||
seedTwoEntries(tracker.db);
|
||||
await fn({ tracker, deleteRunnerCalls: () => deleteRunnerCalls });
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
service.destroy();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('mergeAnime fails closed when queued writes cannot drain', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
|
||||
const internals = tracker as unknown as TrackerInternals;
|
||||
seedTwoEntries(internals.db);
|
||||
queueSubtitleLines(internals, 1);
|
||||
internals.flushNow = () => {};
|
||||
|
||||
await assert.rejects(internals.mergeAnime(1, [2]), /queue did not drain/i);
|
||||
|
||||
assert.deepEqual(
|
||||
internals.db
|
||||
.prepare('SELECT anime_id AS animeId FROM imm_anime ORDER BY anime_id')
|
||||
.all()
|
||||
.map((row) => (row as { animeId: number }).animeId),
|
||||
[1, 2],
|
||||
);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('moveVideoToAnime fails closed when queued writes cannot drain', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
|
||||
const internals = tracker as unknown as TrackerInternals;
|
||||
seedTwoEntries(internals.db);
|
||||
queueSubtitleLines(internals, 1);
|
||||
internals.flushNow = () => {};
|
||||
|
||||
await assert.rejects(internals.moveVideoToAnime(2, 1), /queue did not drain/i);
|
||||
assert.equal(
|
||||
(
|
||||
internals.db
|
||||
.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = 2')
|
||||
.get() as {
|
||||
animeId: number;
|
||||
}
|
||||
).animeId,
|
||||
2,
|
||||
);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('rebuildLifetimeSummaries fails closed when queued writes cannot drain', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
|
||||
const internals = tracker as unknown as TrackerInternals;
|
||||
seedTwoEntries(internals.db);
|
||||
queueSubtitleLines(internals, 1);
|
||||
internals.flushNow = () => {};
|
||||
|
||||
await assert.rejects(internals.rebuildLifetimeSummaries(), /queue did not drain/i);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function seedTwoEntries(db: DatabaseSync): void {
|
||||
db.exec(`
|
||||
INSERT INTO imm_anime (anime_id, normalized_title_key, canonical_title, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (1, 'show', 'Show', 1000, 1000), (2, 'show season 1', 'Show Season 1', 1000, 1000);
|
||||
INSERT INTO imm_anime (anime_id, normalized_title_key, canonical_title, anilist_id, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (1, 'show', 'Show', NULL, 1000, 1000), (2, 'show season 1', 'Show Season 1', 123, 1000, 1000);
|
||||
INSERT INTO imm_videos (video_id, video_key, canonical_title, anime_id, source_type, watched, duration_ms, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (1, 'local:/tmp/a.mkv', 'A', 1, 1, 0, 1440000, 1000, 1000),
|
||||
(2, 'local:/tmp/b.mkv', 'B', 2, 1, 0, 1440000, 1000, 1000);
|
||||
@@ -244,25 +103,53 @@ function queueTelemetry(tracker: TrackerInternals, linesSeen: number): void {
|
||||
});
|
||||
}
|
||||
|
||||
/** The queued telemetry sample only exists in the database once the queue drained fully. */
|
||||
function latestTelemetryLinesSeen(db: DatabaseSync, sessionId: number): number | null {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT lines_seen AS linesSeen
|
||||
FROM imm_session_telemetry
|
||||
WHERE session_id = ?
|
||||
ORDER BY sample_ms DESC, telemetry_id DESC
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(sessionId) as { linesSeen: number } | undefined;
|
||||
return row ? Number(row.linesSeen) : null;
|
||||
const SNAPSHOT_TABLES = [
|
||||
'imm_anime',
|
||||
'imm_videos',
|
||||
'imm_sessions',
|
||||
'imm_subtitle_lines',
|
||||
'imm_session_telemetry',
|
||||
'imm_lifetime_global',
|
||||
'imm_lifetime_anime',
|
||||
'imm_lifetime_media',
|
||||
'imm_lifetime_applied_sessions',
|
||||
];
|
||||
|
||||
function snapshotDb(db: DatabaseSync): Record<string, unknown[]> {
|
||||
return Object.fromEntries(
|
||||
SNAPSHOT_TABLES.map((table) => [table, db.prepare(`SELECT * FROM ${table} ORDER BY 1`).all()]),
|
||||
);
|
||||
}
|
||||
|
||||
function countLinesForAnime(db: DatabaseSync, animeId: number): number {
|
||||
const row = db
|
||||
.prepare('SELECT COUNT(*) AS total FROM imm_subtitle_lines WHERE anime_id = ?')
|
||||
.get(animeId) as { total: number };
|
||||
return Number(row.total);
|
||||
const guardedOperations: Array<{
|
||||
name: string;
|
||||
run: (tracker: TrackerInternals) => Promise<unknown>;
|
||||
}> = [
|
||||
{ name: 'deleteSession', run: (tracker) => tracker.deleteSession(1) },
|
||||
{ name: 'mergeAnime', run: (tracker) => tracker.mergeAnime(1, [2]) },
|
||||
{ name: 'moveVideoToAnime', run: (tracker) => tracker.moveVideoToAnime(2, 1) },
|
||||
{ name: 'rebuildLifetimeSummaries', run: (tracker) => tracker.rebuildLifetimeSummaries() },
|
||||
{
|
||||
// Anime 2 already owns AniList id 123, so this would otherwise resolve a conflict.
|
||||
name: 'reassignAnimeAnilist',
|
||||
run: (tracker) => tracker.reassignAnimeAnilist(1, { anilistId: 123 }),
|
||||
},
|
||||
];
|
||||
|
||||
for (const operation of guardedOperations) {
|
||||
test(`${operation.name} fails closed without touching the database when queued writes cannot drain`, async () => {
|
||||
await withTracker(async ({ tracker, deleteRunnerCalls }) => {
|
||||
queueSubtitleLines(tracker, 1);
|
||||
tracker.flushNow = () => {};
|
||||
const before = snapshotDb(tracker.db);
|
||||
|
||||
await assert.rejects(operation.run(tracker), /queue did not drain/i);
|
||||
|
||||
assert.deepEqual(snapshotDb(tracker.db), before);
|
||||
assert.equal(deleteRunnerCalls(), 0);
|
||||
assert.equal(tracker.writeLock.locked, false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -271,52 +158,36 @@ function countLinesForAnime(db: DatabaseSync, animeId: number): number {
|
||||
* queue, so anything past `batchSize` would still be unwritten when the merge
|
||||
* repoints rows.
|
||||
*/
|
||||
test('mergeAnime drains a queue larger than one batch before repointing rows', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
const repointingOperations: Array<{
|
||||
name: string;
|
||||
run: (tracker: TrackerInternals) => Promise<unknown>;
|
||||
}> = [
|
||||
{ name: 'mergeAnime', run: (tracker) => tracker.mergeAnime(1, [2]) },
|
||||
{ name: 'moveVideoToAnime', run: (tracker) => tracker.moveVideoToAnime(2, 1) },
|
||||
];
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
|
||||
const internals = tracker as unknown as TrackerInternals;
|
||||
for (const operation of repointingOperations) {
|
||||
test(`${operation.name} drains a queue larger than one batch before repointing rows`, async () => {
|
||||
await withTracker(async ({ tracker }) => {
|
||||
queueSubtitleLines(tracker, 8);
|
||||
queueTelemetry(tracker, 8);
|
||||
assert.ok(tracker.queue.length > 2, 'expected more queued writes than one batch');
|
||||
|
||||
seedTwoEntries(internals.db);
|
||||
queueSubtitleLines(internals, 8);
|
||||
queueTelemetry(internals, 8);
|
||||
assert.ok(internals.queue.length > 2, 'expected more queued writes than one batch');
|
||||
await operation.run(tracker);
|
||||
|
||||
await internals.mergeAnime(1, [2]);
|
||||
|
||||
assert.equal(internals.queue.length, 0);
|
||||
// Every queued line landed, attributed to the surviving entry.
|
||||
assert.equal(countLinesForAnime(internals.db, 1), 8);
|
||||
assert.equal(latestTelemetryLinesSeen(internals.db, 1), 8);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('moveVideoToAnime drains a queue larger than one batch before repointing rows', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
|
||||
const internals = tracker as unknown as TrackerInternals;
|
||||
|
||||
seedTwoEntries(internals.db);
|
||||
queueSubtitleLines(internals, 8);
|
||||
queueTelemetry(internals, 8);
|
||||
|
||||
await internals.moveVideoToAnime(2, 1);
|
||||
|
||||
assert.equal(internals.queue.length, 0);
|
||||
assert.equal(countLinesForAnime(internals.db, 1), 8);
|
||||
assert.equal(latestTelemetryLinesSeen(internals.db, 1), 8);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
assert.equal(tracker.queue.length, 0);
|
||||
// Every queued line and the trailing telemetry sample landed on the surviving entry.
|
||||
const lines = tracker.db
|
||||
.prepare('SELECT COUNT(*) AS total FROM imm_subtitle_lines WHERE anime_id = 1')
|
||||
.get() as { total: number };
|
||||
assert.equal(Number(lines.total), 8);
|
||||
const telemetry = tracker.db
|
||||
.prepare(
|
||||
`SELECT lines_seen AS linesSeen FROM imm_session_telemetry
|
||||
WHERE session_id = 1 ORDER BY sample_ms DESC, telemetry_id DESC LIMIT 1`,
|
||||
)
|
||||
.get() as { linesSeen: number } | undefined;
|
||||
assert.equal(Number(telemetry?.linesSeen), 8);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from 'node:test';
|
||||
import {
|
||||
registerStatsAnalyticsRoutes,
|
||||
registerStatsIntegrationRoutes,
|
||||
registerStatsLibraryRoutes,
|
||||
registerStatsMiningRoutes,
|
||||
registerStatsStaticRoutes,
|
||||
} from '../stats-server/routes.js';
|
||||
|
||||
describe('stats server route groups', () => {
|
||||
test('exposes focused registrars for createStatsApp composition', () => {
|
||||
for (const registrar of [
|
||||
registerStatsAnalyticsRoutes,
|
||||
registerStatsLibraryRoutes,
|
||||
registerStatsIntegrationRoutes,
|
||||
registerStatsMiningRoutes,
|
||||
registerStatsStaticRoutes,
|
||||
]) {
|
||||
assert.equal(typeof registrar, 'function');
|
||||
}
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -158,33 +158,6 @@ function createHarness(): RuntimeHarness {
|
||||
return { options, registered, state };
|
||||
}
|
||||
|
||||
test('registerAnkiJimakuIpcRuntime provides full handler surface', () => {
|
||||
const { registered } = createHarness();
|
||||
const expected = [
|
||||
'setAnkiConnectEnabled',
|
||||
'clearAnkiHistory',
|
||||
'refreshKnownWords',
|
||||
'respondFieldGrouping',
|
||||
'buildKikuMergePreview',
|
||||
'getJimakuMediaInfo',
|
||||
'searchJimakuEntries',
|
||||
'listJimakuFiles',
|
||||
'resolveJimakuApiKey',
|
||||
'getCurrentMediaPath',
|
||||
'isRemoteMediaPath',
|
||||
'downloadToFile',
|
||||
'onDownloadedSubtitle',
|
||||
'searchTsukihimeEntries',
|
||||
'listTsukihimeFiles',
|
||||
'downloadTsukihimeSubtitle',
|
||||
'onDownloadedSecondarySubtitle',
|
||||
];
|
||||
|
||||
for (const key of expected) {
|
||||
assert.equal(typeof registered[key], 'function', `missing handler: ${key}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('refreshKnownWords throws when integration is unavailable', async () => {
|
||||
const { registered } = createHarness();
|
||||
|
||||
@@ -196,20 +169,6 @@ test('refreshKnownWords throws when integration is unavailable', async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('refreshKnownWords delegates to integration', async () => {
|
||||
const { registered, state } = createHarness();
|
||||
let refreshed = 0;
|
||||
state.ankiIntegration = {
|
||||
refreshKnownWordCache: async () => {
|
||||
refreshed += 1;
|
||||
},
|
||||
};
|
||||
|
||||
await registered.refreshKnownWords!();
|
||||
|
||||
assert.equal(refreshed, 1);
|
||||
});
|
||||
|
||||
test('setAnkiConnectEnabled disables active integration and broadcasts changes', () => {
|
||||
const { registered, state } = createHarness();
|
||||
let destroyed = 0;
|
||||
@@ -274,30 +233,6 @@ test('buildKikuMergePreview returns guard error when integration is missing', as
|
||||
});
|
||||
});
|
||||
|
||||
test('buildKikuMergePreview delegates to integration when available', async () => {
|
||||
const { registered, state } = createHarness();
|
||||
const calls: unknown[] = [];
|
||||
state.ankiIntegration = {
|
||||
buildFieldGroupingPreview: async (
|
||||
keepNoteId: number,
|
||||
deleteNoteId: number,
|
||||
deleteDuplicate: boolean,
|
||||
) => {
|
||||
calls.push([keepNoteId, deleteNoteId, deleteDuplicate]);
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
|
||||
const result = await registered.buildKikuMergePreview!({
|
||||
keepNoteId: 3,
|
||||
deleteNoteId: 4,
|
||||
deleteDuplicate: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [[3, 4, true]]);
|
||||
assert.deepEqual(result, { ok: true });
|
||||
});
|
||||
|
||||
test('searchJimakuEntries caps results and onDownloadedSubtitle sends sub-add to mpv', async () => {
|
||||
const { registered, state } = createHarness();
|
||||
|
||||
|
||||
@@ -103,17 +103,6 @@ test('runAppReadyRuntime starts texthooker on startup when enabled in config', a
|
||||
);
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime creates immersion tracker during heavy startup', async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
||||
});
|
||||
|
||||
await runAppReadyRuntime(deps);
|
||||
|
||||
assert.equal(calls.includes('createImmersionTracker'), false);
|
||||
assert.ok(calls.includes('log:Runtime ready: immersion tracker startup requested.'));
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime keeps annotation websocket enabled when regular websocket auto-skips', async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
getResolvedConfig: () => ({
|
||||
@@ -226,22 +215,6 @@ test('runAppReadyRuntime skips Jellyfin remote startup when dependency is not wi
|
||||
);
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime logs when createImmersionTracker dependency is missing', async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
createImmersionTracker: undefined,
|
||||
});
|
||||
await runAppReadyRuntime(deps);
|
||||
assert.ok(calls.includes('log:Runtime ready: immersion tracker dependency is missing.'));
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime logs defer message when overlay not auto-started', async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
||||
});
|
||||
await runAppReadyRuntime(deps);
|
||||
assert.ok(calls.includes('log:Overlay runtime deferred: waiting for explicit overlay command.'));
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime applies config logging level during app-ready', async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
getResolvedConfig: () => ({
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { CliArgs } from '../../cli/args';
|
||||
import {
|
||||
CliCommandServiceDeps,
|
||||
createCliCommandDepsRuntime,
|
||||
handleCliCommand,
|
||||
} from './cli-command';
|
||||
import { CliCommandServiceDeps, handleCliCommand } from './cli-command';
|
||||
|
||||
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
return {
|
||||
@@ -557,135 +553,6 @@ test('handleCliCommand applies socket path and connects on start', () => {
|
||||
assert.ok(calls.includes('connectMpvClient'));
|
||||
});
|
||||
|
||||
test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exists', () => {
|
||||
const calls: string[] = [];
|
||||
const client = {
|
||||
setSocketPath: (socketPath: string) => {
|
||||
calls.push(`setSocketPath:${socketPath}`);
|
||||
},
|
||||
connect: () => {
|
||||
calls.push('connect');
|
||||
},
|
||||
reconnect: () => {
|
||||
calls.push('reconnect');
|
||||
},
|
||||
};
|
||||
const deps = createCliCommandDepsRuntime({
|
||||
mpv: {
|
||||
getSocketPath: () => '/tmp/runtime.sock',
|
||||
setSocketPath: () => {},
|
||||
getClient: () => client,
|
||||
showOsd: () => {},
|
||||
},
|
||||
texthooker: {
|
||||
service: { isRunning: () => false, start: () => {} },
|
||||
getPort: () => 5174,
|
||||
setPort: () => {},
|
||||
getWebsocketUrl: () => undefined,
|
||||
shouldOpenBrowser: () => false,
|
||||
openInBrowser: () => {},
|
||||
},
|
||||
overlay: {
|
||||
isInitialized: () => true,
|
||||
initialize: () => {},
|
||||
toggleVisible: () => {},
|
||||
togglePrimarySubtitleBar: () => {},
|
||||
setVisible: () => {},
|
||||
},
|
||||
mining: {
|
||||
copyCurrentSubtitle: () => {},
|
||||
startPendingMultiCopy: () => {},
|
||||
mineSentenceCard: async () => {},
|
||||
startPendingMineSentenceMultiple: () => {},
|
||||
updateLastCardFromClipboard: async () => {},
|
||||
refreshKnownWords: async () => {},
|
||||
triggerFieldGrouping: async () => {},
|
||||
triggerSubsyncFromConfig: async () => {},
|
||||
markLastCardAsAudioCard: async () => {},
|
||||
},
|
||||
anilist: {
|
||||
getStatus: () => ({
|
||||
tokenStatus: 'not_checked',
|
||||
tokenSource: 'none',
|
||||
tokenMessage: null,
|
||||
tokenResolvedAt: null,
|
||||
tokenErrorAt: null,
|
||||
queuePending: 0,
|
||||
queueReady: 0,
|
||||
queueDeadLetter: 0,
|
||||
queueLastAttemptAt: null,
|
||||
queueLastError: null,
|
||||
}),
|
||||
clearToken: () => {},
|
||||
openSetup: () => {},
|
||||
getQueueStatus: () => ({
|
||||
pending: 0,
|
||||
ready: 0,
|
||||
deadLetter: 0,
|
||||
lastAttemptAt: null,
|
||||
lastError: null,
|
||||
}),
|
||||
retryQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||
},
|
||||
dictionary: {
|
||||
generate: async () => ({
|
||||
zipPath: '/tmp/test.zip',
|
||||
fromCache: false,
|
||||
mediaId: 1,
|
||||
mediaTitle: 'Test',
|
||||
entryCount: 0,
|
||||
}),
|
||||
getSelection: async () => ({
|
||||
seriesKey: 'test',
|
||||
guessTitle: null,
|
||||
current: null,
|
||||
override: null,
|
||||
candidates: [],
|
||||
}),
|
||||
setSelection: async () => ({
|
||||
ok: true,
|
||||
seriesKey: 'test',
|
||||
selected: { id: 1, title: 'Test', episodes: null },
|
||||
staleMediaIds: [],
|
||||
}),
|
||||
},
|
||||
jellyfin: {
|
||||
openSetup: () => {},
|
||||
runStatsCommand: async () => {},
|
||||
runCommand: async () => {},
|
||||
},
|
||||
ui: {
|
||||
openFirstRunSetup: () => {},
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
openSyncUiWindow: () => {},
|
||||
openYoutubeBrowserWindow: () => {},
|
||||
cycleSecondarySubMode: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
printHelp: () => {},
|
||||
},
|
||||
app: {
|
||||
stop: () => {},
|
||||
hasMainWindow: () => true,
|
||||
runUpdateCommand: async () => {},
|
||||
runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
|
||||
runYoutubePlaybackFlow: async () => {},
|
||||
},
|
||||
dispatchSessionAction: async () => {},
|
||||
getMultiCopyTimeoutMs: () => 2500,
|
||||
schedule: () => undefined,
|
||||
log: () => {},
|
||||
logDebug: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
});
|
||||
|
||||
deps.setMpvClientSocketPath('/tmp/runtime.sock');
|
||||
deps.connectMpvClient();
|
||||
|
||||
assert.deepEqual(calls, ['setSocketPath:/tmp/runtime.sock', 'reconnect']);
|
||||
});
|
||||
|
||||
test('handleCliCommand warns when texthooker port override used while running', () => {
|
||||
const { deps, calls } = createDeps({
|
||||
isTexthookerRunning: () => true,
|
||||
@@ -882,13 +749,6 @@ test('handleCliCommand dispatches mark-watched session action', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('handleCliCommand logs AniList status details', () => {
|
||||
const { deps, calls } = createDeps();
|
||||
handleCliCommand(makeArgs({ anilistStatus: true }), 'initial', deps);
|
||||
assert.ok(calls.some((value) => value.startsWith('log:AniList token status:')));
|
||||
assert.ok(calls.some((value) => value.startsWith('log:AniList queue:')));
|
||||
});
|
||||
|
||||
test('handleCliCommand runs AniList retry command', async () => {
|
||||
const { deps, calls } = createDeps();
|
||||
handleCliCommand(makeArgs({ anilistRetryQueue: true }), 'initial', deps);
|
||||
@@ -1065,9 +925,10 @@ test('handleCliCommand runs jellyfin command dispatcher', async () => {
|
||||
const { deps, calls } = createDeps();
|
||||
handleCliCommand(makeArgs({ jellyfinLibraries: true }), 'initial', deps);
|
||||
handleCliCommand(makeArgs({ jellyfinSubtitles: true }), 'initial', deps);
|
||||
handleCliCommand(makeArgs({ jellyfinPreviewAuth: true }), 'second-instance', deps);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const runJellyfinCallCount = calls.filter((value) => value === 'runJellyfinCommand').length;
|
||||
assert.equal(runJellyfinCallCount, 2);
|
||||
assert.equal(runJellyfinCallCount, 3);
|
||||
});
|
||||
|
||||
test('handleCliCommand reports jellyfin command errors to OSD', async () => {
|
||||
|
||||
@@ -762,7 +762,8 @@ export function handleCliCommand(
|
||||
args.jellyfinItems ||
|
||||
args.jellyfinSubtitles ||
|
||||
args.jellyfinPlay ||
|
||||
args.jellyfinRemoteAnnounce
|
||||
args.jellyfinRemoteAnnounce ||
|
||||
args.jellyfinPreviewAuth
|
||||
) {
|
||||
runAsyncWithOsd(
|
||||
() => deps.runJellyfinCommand(args),
|
||||
|
||||
@@ -49,28 +49,6 @@ test('buildDiscordPresenceActivity falls back to idle with default style', () =>
|
||||
assert.equal(payload.details, 'Sentence Mining');
|
||||
});
|
||||
|
||||
test('buildDiscordPresenceActivity uses meme style fallback', () => {
|
||||
const memeConfig = { ...baseConfig, presenceStyle: 'meme' as const };
|
||||
const payload = buildDiscordPresenceActivity(memeConfig, {
|
||||
...baseSnapshot,
|
||||
connected: false,
|
||||
mediaPath: null,
|
||||
});
|
||||
assert.equal(payload.details, 'Mining and crafting (Anki cards)');
|
||||
assert.equal(payload.smallImageText, 'Sentence Mining');
|
||||
});
|
||||
|
||||
test('buildDiscordPresenceActivity uses japanese style', () => {
|
||||
const jpConfig = { ...baseConfig, presenceStyle: 'japanese' as const };
|
||||
const payload = buildDiscordPresenceActivity(jpConfig, {
|
||||
...baseSnapshot,
|
||||
connected: false,
|
||||
mediaPath: null,
|
||||
});
|
||||
assert.equal(payload.details, '文の採掘中');
|
||||
assert.equal(payload.smallImageText, 'イマージョン学習');
|
||||
});
|
||||
|
||||
test('buildDiscordPresenceActivity uses minimal style', () => {
|
||||
const minConfig = { ...baseConfig, presenceStyle: 'minimal' as const };
|
||||
const payload = buildDiscordPresenceActivity(minConfig, {
|
||||
|
||||
@@ -3,78 +3,6 @@ import assert from 'node:assert/strict';
|
||||
import { KikuFieldGroupingChoice } from '../../types';
|
||||
import { createFieldGroupingOverlayRuntime } from './field-grouping-overlay';
|
||||
|
||||
test('createFieldGroupingOverlayRuntime sends overlay messages and sets restore flag', () => {
|
||||
const sent: unknown[][] = [];
|
||||
let visible = false;
|
||||
const restore = new Set<'runtime-options' | 'subsync'>();
|
||||
|
||||
const runtime = createFieldGroupingOverlayRuntime<'runtime-options' | 'subsync'>({
|
||||
getMainWindow: () => ({
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
isLoading: () => false,
|
||||
send: (...args: unknown[]) => {
|
||||
sent.push(args);
|
||||
},
|
||||
},
|
||||
}),
|
||||
getVisibleOverlayVisible: () => visible,
|
||||
setVisibleOverlayVisible: (next) => {
|
||||
visible = next;
|
||||
},
|
||||
getResolver: () => null,
|
||||
setResolver: () => {},
|
||||
getRestoreVisibleOverlayOnModalClose: () => restore,
|
||||
});
|
||||
|
||||
const ok = runtime.sendToVisibleOverlay('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
});
|
||||
|
||||
assert.equal(ok, true);
|
||||
assert.equal(visible, true);
|
||||
assert.equal(restore.has('runtime-options'), true);
|
||||
assert.deepEqual(sent, [['runtime-options:open']]);
|
||||
});
|
||||
|
||||
test('createFieldGroupingOverlayRuntime callback cancels when send fails', async () => {
|
||||
let resolver: ((choice: KikuFieldGroupingChoice) => void) | null = null;
|
||||
const runtime = createFieldGroupingOverlayRuntime<'runtime-options' | 'subsync'>({
|
||||
getMainWindow: () => null,
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: () => resolver,
|
||||
setResolver: (next: ((choice: KikuFieldGroupingChoice) => void) | null) => {
|
||||
resolver = next;
|
||||
},
|
||||
getRestoreVisibleOverlayOnModalClose: () => new Set<'runtime-options' | 'subsync'>(),
|
||||
});
|
||||
|
||||
const callback = runtime.createFieldGroupingCallback();
|
||||
const result = await callback({
|
||||
original: {
|
||||
noteId: 1,
|
||||
expression: 'a',
|
||||
sentencePreview: 'a',
|
||||
hasAudio: false,
|
||||
hasImage: false,
|
||||
isOriginal: true,
|
||||
},
|
||||
duplicate: {
|
||||
noteId: 2,
|
||||
expression: 'b',
|
||||
sentencePreview: 'b',
|
||||
hasAudio: false,
|
||||
hasImage: false,
|
||||
isOriginal: false,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.cancelled, true);
|
||||
assert.equal(result.keepNoteId, 0);
|
||||
assert.equal(result.deleteNoteId, 0);
|
||||
});
|
||||
|
||||
test('createFieldGroupingOverlayRuntime callback restores hidden visible overlay after resolver settles', async () => {
|
||||
let resolver: unknown = null;
|
||||
let visible = false;
|
||||
|
||||
@@ -98,124 +98,59 @@ test('field grouping callback does not reject the next request after a response'
|
||||
assert.equal(secondChoice.keepNoteId, 2);
|
||||
});
|
||||
|
||||
test('field grouping callback dismisses the modal UI when the send fails', async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
let dismissed = 0;
|
||||
const callback = createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: store.getResolver,
|
||||
setResolver: store.setResolver,
|
||||
sendRequestToVisibleOverlay: () => false,
|
||||
dismissModalUi: () => {
|
||||
dismissed += 1;
|
||||
},
|
||||
});
|
||||
const dismissCases = [
|
||||
{ name: 'the send fails', sends: false, responseTimeoutMs: undefined },
|
||||
{ name: 'the response times out', sends: true, responseTimeoutMs: 5 },
|
||||
];
|
||||
|
||||
const result = await callback(makeRequestData());
|
||||
assert.equal(result.cancelled, true);
|
||||
assert.equal(dismissed, 1);
|
||||
assert.equal(store.getResolver(), null);
|
||||
});
|
||||
|
||||
test('field grouping callback handles modal dismiss failures on send failure', async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
const originalConsoleError = console.error;
|
||||
console.error = () => {};
|
||||
try {
|
||||
for (const c of dismissCases) {
|
||||
test(`field grouping callback dismisses the modal UI when ${c.name}`, async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
let dismissed = 0;
|
||||
const callback = createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: store.getResolver,
|
||||
setResolver: store.setResolver,
|
||||
sendRequestToVisibleOverlay: () => false,
|
||||
sendRequestToVisibleOverlay: () => c.sends,
|
||||
dismissModalUi: () => {
|
||||
throw new Error('dismiss failed');
|
||||
dismissed += 1;
|
||||
},
|
||||
responseTimeoutMs: c.responseTimeoutMs,
|
||||
});
|
||||
|
||||
const result = await callback(makeRequestData());
|
||||
|
||||
assert.equal(result.cancelled, true);
|
||||
assert.equal(dismissed, 1);
|
||||
assert.equal(store.getResolver(), null);
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
|
||||
test('field grouping callback handles modal dismiss failures on timeout', async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
const originalConsoleError = console.error;
|
||||
console.error = () => {};
|
||||
try {
|
||||
const callback = createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: store.getResolver,
|
||||
setResolver: store.setResolver,
|
||||
sendRequestToVisibleOverlay: () => true,
|
||||
dismissModalUi: () => {
|
||||
throw new Error('dismiss failed');
|
||||
},
|
||||
responseTimeoutMs: 5,
|
||||
});
|
||||
|
||||
const result = await callback(makeRequestData());
|
||||
|
||||
assert.equal(result.cancelled, true);
|
||||
assert.equal(store.getResolver(), null);
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
|
||||
test('field grouping callback reports modal dismiss failures', async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
const errors: unknown[] = [];
|
||||
const originalConsoleError = console.error;
|
||||
console.error = (...args: unknown[]) => {
|
||||
errors.push(args);
|
||||
};
|
||||
try {
|
||||
const callback = createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: store.getResolver,
|
||||
setResolver: store.setResolver,
|
||||
sendRequestToVisibleOverlay: () => false,
|
||||
dismissModalUi: () => {
|
||||
throw new Error('dismiss failed');
|
||||
},
|
||||
});
|
||||
|
||||
await callback(makeRequestData());
|
||||
|
||||
assert.equal(errors.length, 1);
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
|
||||
test('field grouping callback dismisses the modal UI when the response times out', async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
let dismissed = 0;
|
||||
const callback = createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: store.getResolver,
|
||||
setResolver: store.setResolver,
|
||||
sendRequestToVisibleOverlay: () => true,
|
||||
dismissModalUi: () => {
|
||||
dismissed += 1;
|
||||
},
|
||||
responseTimeoutMs: 5,
|
||||
});
|
||||
|
||||
const result = await callback(makeRequestData());
|
||||
assert.equal(result.cancelled, true);
|
||||
assert.equal(dismissed, 1);
|
||||
assert.equal(store.getResolver(), null);
|
||||
});
|
||||
test(`field grouping callback still cancels and clears the resolver when ${c.name} and dismiss throws`, async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
const originalConsoleError = console.error;
|
||||
console.error = () => {};
|
||||
try {
|
||||
const callback = createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: store.getResolver,
|
||||
setResolver: store.setResolver,
|
||||
sendRequestToVisibleOverlay: () => c.sends,
|
||||
dismissModalUi: () => {
|
||||
throw new Error('dismiss failed');
|
||||
},
|
||||
responseTimeoutMs: c.responseTimeoutMs,
|
||||
});
|
||||
|
||||
const result = await callback(makeRequestData());
|
||||
|
||||
assert.equal(result.cancelled, true);
|
||||
assert.equal(store.getResolver(), null);
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('field grouping callback does not dismiss the modal UI on a normal response', async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
|
||||
@@ -499,36 +499,13 @@ test('ensureHyprlandWindowFloatingByTitle dispatches Lua syntax for Lua-config H
|
||||
});
|
||||
|
||||
assert.equal(placed, true);
|
||||
assert.deepEqual(
|
||||
calls.map(([, args]) => args),
|
||||
[
|
||||
['-j', 'clients'],
|
||||
['-j', 'status'],
|
||||
['dispatch', 'hl.dsp.window.resize({ x = 1920, y = 1080, window = "address:0xmatch" })'],
|
||||
['dispatch', 'hl.dsp.window.move({ x = 0, y = 0, window = "address:0xmatch" })'],
|
||||
[
|
||||
'dispatch',
|
||||
'hl.dsp.window.set_prop({ prop = "rounding", value = "0", window = "address:0xmatch" })',
|
||||
],
|
||||
[
|
||||
'dispatch',
|
||||
'hl.dsp.window.set_prop({ prop = "border_size", value = "0", window = "address:0xmatch" })',
|
||||
],
|
||||
[
|
||||
'dispatch',
|
||||
'hl.dsp.window.set_prop({ prop = "no_shadow", value = "1", window = "address:0xmatch" })',
|
||||
],
|
||||
[
|
||||
'dispatch',
|
||||
'hl.dsp.window.set_prop({ prop = "no_blur", value = "1", window = "address:0xmatch" })',
|
||||
],
|
||||
[
|
||||
'dispatch',
|
||||
'hl.dsp.window.set_prop({ prop = "decorate", value = "0", window = "address:0xmatch" })',
|
||||
],
|
||||
['dispatch', 'hl.dsp.window.alter_zorder({ mode = "top", window = "address:0xmatch" })'],
|
||||
],
|
||||
);
|
||||
const dispatches = calls
|
||||
.map(([, args]) => args as string[])
|
||||
.filter(([verb]) => verb === 'dispatch');
|
||||
assert.ok(dispatches.length > 0);
|
||||
for (const [, dispatcher] of dispatches) {
|
||||
assert.ok(dispatcher!.startsWith('hl.dsp.window.'), dispatcher);
|
||||
}
|
||||
});
|
||||
|
||||
test('hasHyprlandWindowPlacementBoundsMismatch compares compositor client bounds', () => {
|
||||
|
||||
@@ -3,7 +3,6 @@ import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { toMonthKey } from './immersion-tracker/maintenance';
|
||||
import { enqueueWrite } from './immersion-tracker/queue';
|
||||
import { toDbTimestamp } from './immersion-tracker/query-shared';
|
||||
import { repairJellyfinStreamVideoLinks } from './immersion-tracker/jellyfin-link-repair';
|
||||
@@ -137,11 +136,6 @@ test('seam: enqueueWrite drops oldest entries once capacity is exceeded', () =>
|
||||
assert.equal((queue[1] as Extract<QueuedWrite, { kind: 'event' }>).eventType, 3);
|
||||
});
|
||||
|
||||
test('seam: toMonthKey uses UTC calendar month', () => {
|
||||
assert.equal(toMonthKey(-86_400_000), 196912);
|
||||
assert.equal(toMonthKey(0), 197001);
|
||||
});
|
||||
|
||||
test('startSession generates UUID-like session identifiers', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
@@ -528,37 +522,6 @@ test('rebuildLifetimeSummaries backfills retained ended sessions and resets stal
|
||||
}
|
||||
});
|
||||
|
||||
test('fresh tracker DB creates lifetime summary tables', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath });
|
||||
|
||||
const db = new Database(dbPath);
|
||||
const tableRows = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
||||
.all() as Array<{ name: string }>;
|
||||
db.close();
|
||||
|
||||
const tableNames = new Set(tableRows.map((row) => row.name));
|
||||
const expectedTables = [
|
||||
'imm_lifetime_global',
|
||||
'imm_lifetime_anime',
|
||||
'imm_lifetime_media',
|
||||
'imm_lifetime_applied_sessions',
|
||||
];
|
||||
|
||||
for (const tableName of expectedTables) {
|
||||
assert.ok(tableNames.has(tableName), `Expected ${tableName} to exist`);
|
||||
}
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('fresh tracker DB skips lexical rollup backfill work', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
@@ -3773,138 +3736,6 @@ test('monthly rollups are grouped by calendar month', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('flushSingle reuses cached prepared statements', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
let originalPrepare: DatabaseSync['prepare'] | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath });
|
||||
const privateApi = tracker as unknown as {
|
||||
db: DatabaseSync;
|
||||
flushSingle: (write: {
|
||||
kind: 'telemetry' | 'event';
|
||||
sessionId: number;
|
||||
sampleMs: number;
|
||||
eventType?: number;
|
||||
lineIndex?: number | null;
|
||||
segmentStartMs?: number | null;
|
||||
segmentEndMs?: number | null;
|
||||
tokensDelta?: number;
|
||||
cardsDelta?: number;
|
||||
payloadJson?: string | null;
|
||||
totalWatchedMs?: number;
|
||||
activeWatchedMs?: number;
|
||||
linesSeen?: number;
|
||||
tokensSeen?: number;
|
||||
cardsMined?: number;
|
||||
lookupCount?: number;
|
||||
lookupHits?: number;
|
||||
yomitanLookupCount?: number;
|
||||
pauseCount?: number;
|
||||
pauseMs?: number;
|
||||
seekForwardCount?: number;
|
||||
seekBackwardCount?: number;
|
||||
mediaBufferEvents?: number;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
originalPrepare = privateApi.db.prepare;
|
||||
let prepareCalls = 0;
|
||||
privateApi.db.prepare = (...args: Parameters<DatabaseSync['prepare']>) => {
|
||||
prepareCalls += 1;
|
||||
return originalPrepare!.apply(privateApi.db, args);
|
||||
};
|
||||
const preparedRestore = originalPrepare;
|
||||
|
||||
privateApi.db.exec(`
|
||||
INSERT INTO imm_videos (
|
||||
video_id,
|
||||
video_key,
|
||||
canonical_title,
|
||||
source_type,
|
||||
duration_ms,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES (
|
||||
1,
|
||||
'local:/tmp/prepared.mkv',
|
||||
'Prepared',
|
||||
1,
|
||||
0,
|
||||
1000,
|
||||
1000
|
||||
)
|
||||
`);
|
||||
|
||||
privateApi.db.exec(`
|
||||
INSERT INTO imm_sessions (
|
||||
session_id,
|
||||
session_uuid,
|
||||
video_id,
|
||||
started_at_ms,
|
||||
status,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE,
|
||||
ended_at_ms
|
||||
) VALUES (
|
||||
1,
|
||||
'33333333-3333-3333-3333-333333333333',
|
||||
1,
|
||||
1000,
|
||||
2,
|
||||
1000,
|
||||
1000,
|
||||
2000
|
||||
)
|
||||
`);
|
||||
|
||||
privateApi.flushSingle({
|
||||
kind: 'telemetry',
|
||||
sessionId: 1,
|
||||
sampleMs: 1500,
|
||||
totalWatchedMs: 1000,
|
||||
activeWatchedMs: 1000,
|
||||
linesSeen: 1,
|
||||
tokensSeen: 2,
|
||||
cardsMined: 0,
|
||||
lookupCount: 0,
|
||||
lookupHits: 0,
|
||||
yomitanLookupCount: 0,
|
||||
pauseCount: 0,
|
||||
pauseMs: 0,
|
||||
seekForwardCount: 0,
|
||||
seekBackwardCount: 0,
|
||||
mediaBufferEvents: 0,
|
||||
});
|
||||
|
||||
privateApi.flushSingle({
|
||||
kind: 'event',
|
||||
sessionId: 1,
|
||||
sampleMs: 1600,
|
||||
eventType: 1,
|
||||
lineIndex: 1,
|
||||
segmentStartMs: 0,
|
||||
segmentEndMs: 1000,
|
||||
tokensDelta: 2,
|
||||
cardsDelta: 0,
|
||||
payloadJson: '{"event":"subtitle-line"}',
|
||||
});
|
||||
|
||||
privateApi.db.prepare = preparedRestore;
|
||||
|
||||
assert.equal(prepareCalls, 0);
|
||||
} finally {
|
||||
if (tracker && originalPrepare) {
|
||||
const privateApi = tracker as unknown as { db: DatabaseSync };
|
||||
privateApi.db.prepare = originalPrepare;
|
||||
}
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('reassignAnimeAnilist deduplicates cover blobs and getCoverArt remains compatible', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
test('getRollupGroupsForSessions uses only localtime rollup keys', () => {
|
||||
const source = fs.readFileSync(
|
||||
path.join(process.cwd(), 'src/core/services/immersion-tracker/maintenance.ts'),
|
||||
'utf8',
|
||||
);
|
||||
const start = source.indexOf('export function getRollupGroupsForSessions');
|
||||
const end = source.indexOf('export function refreshRollupsForGroupsInTransaction');
|
||||
const functionSource = source.slice(start, end);
|
||||
|
||||
assert.match(functionSource, /'unixepoch', 'localtime'/);
|
||||
assert.doesNotMatch(functionSource, /UNION/);
|
||||
assert.doesNotMatch(functionSource, /86400000/);
|
||||
});
|
||||
@@ -602,39 +602,6 @@ test('getSessionTimeline returns the full session when no limit is provided', ()
|
||||
}
|
||||
});
|
||||
|
||||
test('getDailyRollups limits by distinct days (not rows)', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = openTestDb(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
|
||||
const insert = db.prepare(
|
||||
`
|
||||
INSERT INTO imm_daily_rollups (
|
||||
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||
total_tokens_seen, total_cards
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
);
|
||||
|
||||
insert.run(10, 1, 1, 1, 0, 0, 2);
|
||||
insert.run(10, 2, 1, 1, 0, 0, 3);
|
||||
insert.run(9, 1, 1, 1, 0, 0, 1);
|
||||
insert.run(8, 1, 1, 1, 0, 0, 1);
|
||||
|
||||
const rows = getDailyRollups(db, 2);
|
||||
assert.equal(rows.length, 3);
|
||||
assert.ok(rows.every((r) => r.rollupDayOrMonth === 10 || r.rollupDayOrMonth === 9));
|
||||
assert.ok(rows.some((r) => r.rollupDayOrMonth === 10 && r.videoId === 1));
|
||||
assert.ok(rows.some((r) => r.rollupDayOrMonth === 10 && r.videoId === 2));
|
||||
assert.ok(rows.some((r) => r.rollupDayOrMonth === 9 && r.videoId === 1));
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('getTrendsDashboard returns chart-ready aggregated series', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = openTestDb(dbPath);
|
||||
@@ -2075,20 +2042,6 @@ test('getVocabularyStats pages past hidden rows until enough visible rows are co
|
||||
}
|
||||
});
|
||||
|
||||
test('getVocabularyStats returns empty array when no words exist', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = openTestDb(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
const rows = getVocabularyStats(db, 10);
|
||||
assert.deepEqual(rows, []);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('cleanupVocabularyStats repairs stored POS metadata and removes excluded imm_words rows', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = openTestDb(dbPath);
|
||||
@@ -2524,20 +2477,6 @@ test('getKanjiStats returns rows ordered by frequency descending', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('getKanjiStats returns empty array when no kanji exist', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = openTestDb(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
const rows = getKanjiStats(db, 10);
|
||||
assert.deepEqual(rows, []);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('getSessionEvents returns events ordered by ts_ms ascending', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = openTestDb(dbPath);
|
||||
@@ -2643,29 +2582,6 @@ test('getSessionEvents round-trips wall-clock timestamps written through event i
|
||||
}
|
||||
});
|
||||
|
||||
test('getSessionEvents returns empty array for session with no events', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = openTestDb(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/events-empty.mkv', {
|
||||
canonicalTitle: 'Events Empty',
|
||||
sourcePath: '/tmp/events-empty.mkv',
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
});
|
||||
const { sessionId } = startSessionRecord(db, videoId, 6_000_000);
|
||||
|
||||
const events = getSessionEvents(db, sessionId, 50);
|
||||
assert.deepEqual(events, []);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('getSessionEvents filters events to the requested session id', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = openTestDb(dbPath);
|
||||
|
||||
@@ -4,12 +4,7 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Database } from './sqlite';
|
||||
import {
|
||||
pruneRawRetention,
|
||||
pruneRollupRetention,
|
||||
runOptimizeMaintenance,
|
||||
toMonthKey,
|
||||
} from './maintenance';
|
||||
import { pruneRawRetention, pruneRollupRetention, toMonthKey } from './maintenance';
|
||||
import { ensureSchema } from './storage';
|
||||
import { toDbTimestamp } from './query-shared';
|
||||
|
||||
@@ -225,42 +220,3 @@ test('raw retention keeps rollups and rollup retention prunes them separately',
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('ensureSchema adds sample_ms index for telemetry rollup scans', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
const indexes = db.prepare("PRAGMA index_list('imm_session_telemetry')").all() as Array<{
|
||||
name: string;
|
||||
}>;
|
||||
const hasSampleMsIndex = indexes.some((row) => row.name === 'idx_telemetry_sample_ms');
|
||||
assert.equal(hasSampleMsIndex, true);
|
||||
|
||||
const indexColumns = db.prepare("PRAGMA index_info('idx_telemetry_sample_ms')").all() as Array<{
|
||||
name: string;
|
||||
}>;
|
||||
assert.deepEqual(
|
||||
indexColumns.map((column) => column.name),
|
||||
['sample_ms'],
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('runOptimizeMaintenance executes PRAGMA optimize', () => {
|
||||
const executedSql: string[] = [];
|
||||
const db = {
|
||||
exec(source: string) {
|
||||
executedSql.push(source);
|
||||
return this;
|
||||
},
|
||||
} as unknown as Parameters<typeof runOptimizeMaintenance>[0];
|
||||
|
||||
runOptimizeMaintenance(db);
|
||||
|
||||
assert.deepEqual(executedSql, ['PRAGMA optimize']);
|
||||
});
|
||||
@@ -59,34 +59,6 @@ function cleanupDbPath(dbPath: string): void {
|
||||
// libsql keeps Windows file handles alive after close when prepared statements were used.
|
||||
}
|
||||
|
||||
test('applyPragmas sets the SQLite tuning defaults used by immersion tracking', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
try {
|
||||
applyPragmas(db);
|
||||
|
||||
const journalModeRow = db.prepare('PRAGMA journal_mode').get() as {
|
||||
journal_mode: string;
|
||||
};
|
||||
const synchronousRow = db.prepare('PRAGMA synchronous').get() as { synchronous: number };
|
||||
const foreignKeysRow = db.prepare('PRAGMA foreign_keys').get() as { foreign_keys: number };
|
||||
const busyTimeoutRow = db.prepare('PRAGMA busy_timeout').get() as { timeout: number };
|
||||
const journalSizeLimitRow = db.prepare('PRAGMA journal_size_limit').get() as {
|
||||
journal_size_limit: number;
|
||||
};
|
||||
|
||||
assert.equal(journalModeRow.journal_mode, 'wal');
|
||||
assert.equal(synchronousRow.synchronous, 1);
|
||||
assert.equal(foreignKeysRow.foreign_keys, 1);
|
||||
assert.equal(busyTimeoutRow.timeout, 2500);
|
||||
assert.equal(journalSizeLimitRow.journal_size_limit, 67_108_864);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('applyPragmas installs the busy timeout before WAL negotiation', () => {
|
||||
const statements: string[] = [];
|
||||
const db: DatabaseSync = {
|
||||
@@ -110,76 +82,6 @@ test('applyPragmas installs the busy timeout before WAL negotiation', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('ensureSchema creates immersion core tables', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'imm_%' ORDER BY name`,
|
||||
)
|
||||
.all() as Array<{ name: string }>;
|
||||
const tableNames = new Set(rows.map((row) => row.name));
|
||||
|
||||
assert.ok(tableNames.has('imm_videos'));
|
||||
assert.ok(tableNames.has('imm_anime'));
|
||||
assert.ok(tableNames.has('imm_sessions'));
|
||||
assert.ok(tableNames.has('imm_session_telemetry'));
|
||||
assert.ok(tableNames.has('imm_session_events'));
|
||||
assert.ok(tableNames.has('imm_daily_rollups'));
|
||||
assert.ok(tableNames.has('imm_monthly_rollups'));
|
||||
assert.ok(tableNames.has('imm_words'));
|
||||
assert.ok(tableNames.has('imm_kanji'));
|
||||
assert.ok(tableNames.has('imm_subtitle_lines'));
|
||||
assert.ok(tableNames.has('imm_word_line_occurrences'));
|
||||
assert.ok(tableNames.has('imm_kanji_line_occurrences'));
|
||||
assert.ok(tableNames.has('imm_rollup_state'));
|
||||
assert.ok(tableNames.has('imm_cover_art_blobs'));
|
||||
assert.ok(tableNames.has('imm_youtube_videos'));
|
||||
assert.ok(tableNames.has('imm_stats_excluded_words'));
|
||||
|
||||
const videoColumns = new Set(
|
||||
(
|
||||
db.prepare('PRAGMA table_info(imm_videos)').all() as Array<{
|
||||
name: string;
|
||||
}>
|
||||
).map((row) => row.name),
|
||||
);
|
||||
|
||||
assert.ok(videoColumns.has('anime_id'));
|
||||
assert.ok(videoColumns.has('parsed_basename'));
|
||||
assert.ok(videoColumns.has('parsed_title'));
|
||||
assert.ok(videoColumns.has('parsed_season'));
|
||||
assert.ok(videoColumns.has('parsed_episode'));
|
||||
assert.ok(videoColumns.has('parser_source'));
|
||||
assert.ok(videoColumns.has('parser_confidence'));
|
||||
assert.ok(videoColumns.has('parse_metadata_json'));
|
||||
assert.ok(videoColumns.has('anime_assignment_locked'));
|
||||
|
||||
const mediaArtColumns = new Set(
|
||||
(
|
||||
db.prepare('PRAGMA table_info(imm_media_art)').all() as Array<{
|
||||
name: string;
|
||||
}>
|
||||
).map((row) => row.name),
|
||||
);
|
||||
assert.ok(mediaArtColumns.has('cover_blob_hash'));
|
||||
|
||||
const rollupStateRow = db
|
||||
.prepare('SELECT state_value FROM imm_rollup_state WHERE state_key = ?')
|
||||
.get('last_rollup_sample_ms') as {
|
||||
state_value: string;
|
||||
} | null;
|
||||
assert.ok(rollupStateRow);
|
||||
assert.equal(Number(rollupStateRow?.state_value ?? 0), 0);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('ensureSchema adds manual assignment locks when upgrading the previous schema', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
@@ -752,37 +654,6 @@ test('ensureSchema migrates session event timestamps to text and repairs libsql-
|
||||
}
|
||||
});
|
||||
|
||||
test('ensureSchema creates large-history performance indexes', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
const indexNames = new Set(
|
||||
(
|
||||
db
|
||||
.prepare(`SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE 'idx_%'`)
|
||||
.all() as Array<{
|
||||
name: string;
|
||||
}>
|
||||
).map((row) => row.name),
|
||||
);
|
||||
|
||||
assert.ok(indexNames.has('idx_telemetry_sample_ms'));
|
||||
assert.ok(indexNames.has('idx_sessions_started_at'));
|
||||
assert.ok(indexNames.has('idx_sessions_ended_at'));
|
||||
assert.ok(indexNames.has('idx_words_frequency'));
|
||||
assert.ok(indexNames.has('idx_kanji_frequency'));
|
||||
assert.ok(indexNames.has('idx_media_art_anilist_id'));
|
||||
assert.ok(indexNames.has('idx_media_art_cover_url'));
|
||||
assert.ok(indexNames.has('idx_youtube_videos_channel_id'));
|
||||
assert.ok(indexNames.has('idx_youtube_videos_youtube_video_id'));
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('ensureSchema adds the subtitle event index to current-version databases', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
@@ -131,7 +131,11 @@ export {
|
||||
resolvePlaybackPlan as resolveJellyfinPlaybackPlanRuntime,
|
||||
ticksToSeconds as jellyfinTicksToSecondsRuntime,
|
||||
} from './jellyfin';
|
||||
export { buildJellyfinTimelinePayload, JellyfinRemoteSessionService } from './jellyfin-remote';
|
||||
export { JellyfinRemoteSessionService } from './jellyfin-remote';
|
||||
export {
|
||||
buildJellyfinTimelinePayload,
|
||||
JellyfinPlaybackReporter,
|
||||
} from './jellyfin-playback-reporter';
|
||||
export {
|
||||
broadcastRuntimeOptionsChangedRuntime,
|
||||
createOverlayManager,
|
||||
|
||||
@@ -129,47 +129,32 @@ test('handleMpvCommandFromIpc emits mpv OSD for subtitle step keybinding proxies
|
||||
assert.deepEqual(playbackFeedback, []);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc does not dispatch retired subtitle-delay shift tokens', () => {
|
||||
const { options, calls, sentCommands, osd } = createOptions();
|
||||
handleMpvCommandFromIpc(['__sub-delay-next-line'], options);
|
||||
assert.deepEqual(calls, []);
|
||||
assert.deepEqual(sentCommands, [['__sub-delay-next-line']]);
|
||||
assert.deepEqual(osd, []);
|
||||
});
|
||||
const specialCommandCases = [
|
||||
{ name: 'youtube picker open', command: '__youtube-picker-open', expected: 'youtube-picker' },
|
||||
{ name: 'jimaku open', command: '__jimaku-open', expected: 'jimaku' },
|
||||
// Legacy Animetosho command is kept as a TsukiHime alias.
|
||||
{
|
||||
name: 'legacy Animetosho open as a TsukiHime alias',
|
||||
command: '__animetosho-open',
|
||||
expected: 'tsukihime',
|
||||
},
|
||||
{
|
||||
name: 'playlist browser open',
|
||||
command: '__playlist-browser-open',
|
||||
expected: 'playlist-browser',
|
||||
},
|
||||
];
|
||||
|
||||
test('handleMpvCommandFromIpc dispatches special youtube picker open command', () => {
|
||||
const { options, calls, sentCommands, osd } = createOptions();
|
||||
handleMpvCommandFromIpc(['__youtube-picker-open'], options);
|
||||
assert.deepEqual(calls, ['youtube-picker']);
|
||||
assert.deepEqual(sentCommands, []);
|
||||
assert.deepEqual(osd, []);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc dispatches special jimaku open command', () => {
|
||||
const { options, calls, sentCommands, osd } = createOptions();
|
||||
handleMpvCommandFromIpc(['__jimaku-open'], options);
|
||||
assert.deepEqual(calls, ['jimaku']);
|
||||
assert.deepEqual(sentCommands, []);
|
||||
assert.deepEqual(osd, []);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc keeps the legacy Animetosho command as a TsukiHime alias', () => {
|
||||
const { options, calls, sentCommands } = createOptions();
|
||||
|
||||
handleMpvCommandFromIpc(['__animetosho-open'], options);
|
||||
|
||||
assert.deepEqual(calls, ['tsukihime']);
|
||||
assert.deepEqual(sentCommands, []);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc dispatches special playlist browser open command', async () => {
|
||||
const { options, calls, sentCommands, osd } = createOptions();
|
||||
handleMpvCommandFromIpc(['__playlist-browser-open'], options);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(calls, ['playlist-browser']);
|
||||
assert.deepEqual(sentCommands, []);
|
||||
assert.deepEqual(osd, []);
|
||||
});
|
||||
for (const c of specialCommandCases) {
|
||||
test(`handleMpvCommandFromIpc dispatches special ${c.name} command`, async () => {
|
||||
const { options, calls, sentCommands, osd } = createOptions();
|
||||
handleMpvCommandFromIpc([c.command], options);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(calls, [c.expected]);
|
||||
assert.deepEqual(sentCommands, []);
|
||||
assert.deepEqual(osd, []);
|
||||
});
|
||||
}
|
||||
|
||||
test('handleMpvCommandFromIpc surfaces playlist browser open rejections via mpv osd', async () => {
|
||||
const { options, osd } = createOptions({
|
||||
|
||||
+37
-253
@@ -3,11 +3,7 @@ import assert from 'node:assert/strict';
|
||||
|
||||
import { createIpcDepsRuntime, registerIpcHandlers, type IpcServiceDeps } from './ipc';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import type {
|
||||
PlaylistBrowserSnapshot,
|
||||
SessionActionDispatchRequest,
|
||||
SubtitleSidebarSnapshot,
|
||||
} from '../../types';
|
||||
import type { SessionActionDispatchRequest, SubtitleSidebarSnapshot } from '../../types';
|
||||
|
||||
interface FakeIpcRegistrar {
|
||||
on: Map<string, (event: unknown, ...args: unknown[]) => void>;
|
||||
@@ -187,107 +183,6 @@ function createFakeImmersionTracker(
|
||||
};
|
||||
}
|
||||
|
||||
test('createIpcDepsRuntime wires AniList handlers', async () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createIpcDepsRuntime({
|
||||
getMainWindow: () => null,
|
||||
getVisibleOverlayVisibility: () => false,
|
||||
onOverlayModalClosed: () => {},
|
||||
onOverlayMouseInteractionChanged: (active) => {
|
||||
calls.push(`overlay-interaction:${active}`);
|
||||
},
|
||||
openYomitanSettings: () => {},
|
||||
quitApp: () => {},
|
||||
toggleVisibleOverlay: () => {},
|
||||
tokenizeCurrentSubtitle: async () => null,
|
||||
getCurrentSubtitleRaw: () => '',
|
||||
getCurrentSubtitleAss: () => '',
|
||||
getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(),
|
||||
getPlaybackPaused: () => true,
|
||||
getSubtitlePosition: () => null,
|
||||
getSubtitleStyle: () => null,
|
||||
saveSubtitlePosition: () => {},
|
||||
getMecabTokenizer: () => null,
|
||||
handleMpvCommand: () => {},
|
||||
getKeybindings: () => [],
|
||||
getSessionBindings: () => [],
|
||||
getConfiguredShortcuts: () => ({}),
|
||||
dispatchSessionAction: async () => {},
|
||||
getStatsToggleKey: () => 'Backquote',
|
||||
getMarkWatchedKey: () => 'KeyW',
|
||||
getOverlayNotificationPosition: () => 'top-right',
|
||||
getControllerConfig: () => createControllerConfigFixture(),
|
||||
saveControllerConfig: () => {},
|
||||
saveControllerPreference: () => {},
|
||||
getSecondarySubMode: () => 'hover',
|
||||
getMpvClient: () => null,
|
||||
focusMainWindow: () => {},
|
||||
activatePlaybackWindowForOverlayInteraction: () => false,
|
||||
runSubsyncManual: async () => ({ ok: true, message: 'ok' }),
|
||||
getAnkiConnectStatus: () => false,
|
||||
getRuntimeOptions: () => ({}),
|
||||
setRuntimeOption: () => ({ ok: true }),
|
||||
cycleRuntimeOption: () => ({ ok: true }),
|
||||
reportOverlayContentBounds: () => {},
|
||||
getAnilistStatus: () => ({ tokenStatus: 'resolved' }),
|
||||
clearAnilistToken: () => {
|
||||
calls.push('clearAnilistToken');
|
||||
},
|
||||
openAnilistSetup: () => {
|
||||
calls.push('openAnilistSetup');
|
||||
},
|
||||
getAnilistQueueStatus: () => ({ pending: 1, ready: 0, deadLetter: 0 }),
|
||||
retryAnilistQueueNow: async () => {
|
||||
calls.push('retryAnilistQueueNow');
|
||||
return { ok: true, message: 'done' };
|
||||
},
|
||||
appendClipboardVideoToQueue: () => ({ ok: true, message: 'queued' }),
|
||||
getPlaylistBrowserSnapshot: async () => ({
|
||||
directoryPath: '/tmp',
|
||||
directoryAvailable: true,
|
||||
directoryStatus: '/tmp',
|
||||
directoryItems: [],
|
||||
playlistItems: [],
|
||||
playingIndex: 0,
|
||||
currentFilePath: '/tmp/current.mkv',
|
||||
}),
|
||||
appendPlaylistBrowserFile: async () => ({ ok: true, message: 'append' }),
|
||||
playPlaylistBrowserIndex: async () => ({ ok: true, message: 'play' }),
|
||||
removePlaylistBrowserIndex: async () => ({ ok: true, message: 'remove' }),
|
||||
movePlaylistBrowserIndex: async () => ({ ok: true, message: 'move' }),
|
||||
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
|
||||
});
|
||||
|
||||
assert.deepEqual(deps.getAnilistStatus(), { tokenStatus: 'resolved' });
|
||||
deps.clearAnilistToken();
|
||||
deps.openAnilistSetup();
|
||||
deps.onOverlayMouseInteractionChanged?.(true, null);
|
||||
assert.deepEqual(deps.getAnilistQueueStatus(), {
|
||||
pending: 1,
|
||||
ready: 0,
|
||||
deadLetter: 0,
|
||||
});
|
||||
assert.deepEqual(await deps.retryAnilistQueueNow(), {
|
||||
ok: true,
|
||||
message: 'done',
|
||||
});
|
||||
assert.equal((await deps.getPlaylistBrowserSnapshot()).directoryAvailable, true);
|
||||
assert.deepEqual(await deps.appendPlaylistBrowserFile('/tmp/new.mkv'), {
|
||||
ok: true,
|
||||
message: 'append',
|
||||
});
|
||||
assert.deepEqual(await deps.playPlaylistBrowserIndex(2), { ok: true, message: 'play' });
|
||||
assert.deepEqual(await deps.removePlaylistBrowserIndex(2), { ok: true, message: 'remove' });
|
||||
assert.deepEqual(await deps.movePlaylistBrowserIndex(2, -1), { ok: true, message: 'move' });
|
||||
assert.deepEqual(calls, [
|
||||
'clearAnilistToken',
|
||||
'openAnilistSetup',
|
||||
'overlay-interaction:true',
|
||||
'retryAnilistQueueNow',
|
||||
]);
|
||||
assert.equal(deps.getPlaybackPaused(), true);
|
||||
});
|
||||
|
||||
test('createIpcDepsRuntime ignores overlay content reports from stale visible renderers', () => {
|
||||
const mainWindow = { id: 'main', isDestroyed: () => false } as never;
|
||||
const staleWindow = { id: 'stale', isDestroyed: () => false } as never;
|
||||
@@ -332,27 +227,6 @@ test('registerIpcHandlers maps setIgnoreMouseEvents to overlay interaction activ
|
||||
assert.deepEqual(calls, ['overlay-interaction:false', 'overlay-interaction:true']);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers passes sender window to overlay content bounds reports', () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const senderWindows: unknown[] = [];
|
||||
|
||||
registerIpcHandlers(
|
||||
createRegisterIpcDeps({
|
||||
reportOverlayContentBounds: ((_payload: unknown, senderWindow: unknown) => {
|
||||
senderWindows.push(senderWindow);
|
||||
}) as IpcServiceDeps['reportOverlayContentBounds'],
|
||||
}),
|
||||
registrar,
|
||||
);
|
||||
|
||||
const handler = handlers.on.get(IPC_CHANNELS.command.reportOverlayContentBounds);
|
||||
assert.equal(typeof handler, 'function');
|
||||
|
||||
handler?.({}, { layer: 'visible' });
|
||||
|
||||
assert.deepEqual(senderWindows, [null]);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers runs AniList update after manual mark watched succeeds', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const calls: string[] = [];
|
||||
@@ -433,71 +307,6 @@ test('registerIpcHandlers skips AniList update when manual mark watched has no a
|
||||
assert.deepEqual(calls, ['mark']);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers exposes playlist browser snapshot and mutations', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const calls: Array<[string, unknown[]]> = [];
|
||||
registerIpcHandlers(
|
||||
createRegisterIpcDeps({
|
||||
getPlaylistBrowserSnapshot: async () => ({
|
||||
directoryPath: '/tmp/videos',
|
||||
directoryAvailable: true,
|
||||
directoryStatus: '/tmp/videos',
|
||||
directoryItems: [],
|
||||
playlistItems: [],
|
||||
playingIndex: 1,
|
||||
currentFilePath: '/tmp/videos/ep2.mkv',
|
||||
}),
|
||||
appendPlaylistBrowserFile: async (filePath) => {
|
||||
calls.push(['append', [filePath]]);
|
||||
return { ok: true, message: 'append-ok' };
|
||||
},
|
||||
playPlaylistBrowserIndex: async (index) => {
|
||||
calls.push(['play', [index]]);
|
||||
return { ok: true, message: 'play-ok' };
|
||||
},
|
||||
removePlaylistBrowserIndex: async (index) => {
|
||||
calls.push(['remove', [index]]);
|
||||
return { ok: true, message: 'remove-ok' };
|
||||
},
|
||||
movePlaylistBrowserIndex: async (index, direction) => {
|
||||
calls.push(['move', [index, direction]]);
|
||||
return { ok: true, message: 'move-ok' };
|
||||
},
|
||||
}),
|
||||
registrar,
|
||||
);
|
||||
|
||||
const snapshot = (await handlers.handle.get(IPC_CHANNELS.request.getPlaylistBrowserSnapshot)?.(
|
||||
{},
|
||||
)) as PlaylistBrowserSnapshot | undefined;
|
||||
const append = await handlers.handle.get(IPC_CHANNELS.request.appendPlaylistBrowserFile)?.(
|
||||
{},
|
||||
'/tmp/videos/ep3.mkv',
|
||||
);
|
||||
const play = await handlers.handle.get(IPC_CHANNELS.request.playPlaylistBrowserIndex)?.({}, 2);
|
||||
const remove = await handlers.handle.get(IPC_CHANNELS.request.removePlaylistBrowserIndex)?.(
|
||||
{},
|
||||
2,
|
||||
);
|
||||
const move = await handlers.handle.get(IPC_CHANNELS.request.movePlaylistBrowserIndex)?.(
|
||||
{},
|
||||
2,
|
||||
-1,
|
||||
);
|
||||
|
||||
assert.equal(snapshot?.playingIndex, 1);
|
||||
assert.deepEqual(append, { ok: true, message: 'append-ok' });
|
||||
assert.deepEqual(play, { ok: true, message: 'play-ok' });
|
||||
assert.deepEqual(remove, { ok: true, message: 'remove-ok' });
|
||||
assert.deepEqual(move, { ok: true, message: 'move-ok' });
|
||||
assert.deepEqual(calls, [
|
||||
['append', ['/tmp/videos/ep3.mkv']],
|
||||
['play', [2]],
|
||||
['remove', [2]],
|
||||
['move', [2, -1]],
|
||||
]);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers rejects malformed runtime-option payloads', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const calls: Array<{ id: string; value: unknown }> = [];
|
||||
@@ -610,45 +419,6 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
|
||||
);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers exposes subtitle sidebar snapshot request', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const snapshot = createSubtitleSidebarSnapshotFixture();
|
||||
snapshot.cues = [{ startTime: 1, endTime: 2, text: 'line-1' }];
|
||||
snapshot.config.enabled = true;
|
||||
|
||||
registerIpcHandlers(
|
||||
createRegisterIpcDeps({
|
||||
getSubtitleSidebarSnapshot: async () => snapshot,
|
||||
}),
|
||||
registrar,
|
||||
);
|
||||
|
||||
const handler = handlers.handle.get(IPC_CHANNELS.request.getSubtitleSidebarSnapshot);
|
||||
assert.ok(handler);
|
||||
assert.deepEqual(await handler!({}), snapshot);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers exposes playback window activation request', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const calls: string[] = [];
|
||||
registerIpcHandlers(
|
||||
createRegisterIpcDeps({
|
||||
activatePlaybackWindowForOverlayInteraction: async () => {
|
||||
calls.push('activate');
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
registrar,
|
||||
);
|
||||
|
||||
const handler = handlers.handle.get(
|
||||
IPC_CHANNELS.request.activatePlaybackWindowForOverlayInteraction,
|
||||
);
|
||||
assert.ok(handler);
|
||||
assert.equal(await handler!({}), true);
|
||||
assert.deepEqual(calls, ['activate']);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers accepts the keep-without-media timing decision', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const requests: unknown[] = [];
|
||||
@@ -757,28 +527,6 @@ test('registerIpcHandlers validates and forwards timing review text and screensh
|
||||
assert.equal(requests.length, 1);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers forwards yomitan lookup tracking commands to immersion tracker', () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const calls: string[] = [];
|
||||
registerIpcHandlers(
|
||||
createRegisterIpcDeps({
|
||||
immersionTracker: createFakeImmersionTracker({
|
||||
recordYomitanLookup: () => {
|
||||
calls.push('lookup');
|
||||
},
|
||||
}),
|
||||
}),
|
||||
registrar,
|
||||
);
|
||||
|
||||
const handler = handlers.on.get(IPC_CHANNELS.command.recordYomitanLookup);
|
||||
assert.equal(typeof handler, 'function');
|
||||
|
||||
handler?.({}, null);
|
||||
|
||||
assert.deepEqual(calls, ['lookup']);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers forwards valid subtitle sidebar mining context', () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const contexts: unknown[] = [];
|
||||
@@ -1369,3 +1117,39 @@ test('mpv discovery has its own request and does not change session bindings', a
|
||||
assert.deepEqual(await discovery({}), snapshot);
|
||||
assert.deepEqual(await session({}), []);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers validates playlist browser mutation payloads before forwarding', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const forwarded: unknown[][] = [];
|
||||
const ok = { ok: true, message: 'ok' };
|
||||
registerIpcHandlers(
|
||||
createRegisterIpcDeps({
|
||||
appendPlaylistBrowserFile: async (...args) => (forwarded.push(args), ok),
|
||||
playPlaylistBrowserIndex: async (...args) => (forwarded.push(args), ok),
|
||||
removePlaylistBrowserIndex: async (...args) => (forwarded.push(args), ok),
|
||||
movePlaylistBrowserIndex: async (...args) => (forwarded.push(args), ok),
|
||||
}),
|
||||
registrar,
|
||||
);
|
||||
const { request } = IPC_CHANNELS;
|
||||
const cases: Array<{ channel: string; args: unknown[]; valid: boolean }> = [
|
||||
{ channel: request.appendPlaylistBrowserFile, args: ['/media/ep01.mkv'], valid: true },
|
||||
{ channel: request.appendPlaylistBrowserFile, args: [' '], valid: false },
|
||||
{ channel: request.appendPlaylistBrowserFile, args: [42], valid: false },
|
||||
{ channel: request.playPlaylistBrowserIndex, args: [0], valid: true },
|
||||
{ channel: request.playPlaylistBrowserIndex, args: [-1], valid: false },
|
||||
{ channel: request.removePlaylistBrowserIndex, args: [1.5], valid: false },
|
||||
{ channel: request.removePlaylistBrowserIndex, args: ['2'], valid: false },
|
||||
{ channel: request.movePlaylistBrowserIndex, args: [2, -1], valid: true },
|
||||
{ channel: request.movePlaylistBrowserIndex, args: [2, 2], valid: false },
|
||||
{ channel: request.movePlaylistBrowserIndex, args: [Number.NaN, 1], valid: false },
|
||||
];
|
||||
|
||||
for (const { channel, args, valid } of cases) {
|
||||
forwarded.length = 0;
|
||||
const result = (await handlers.handle.get(channel)?.({}, ...args)) as { ok: boolean };
|
||||
const label = `${channel}(${args.map(String).join(', ')})`;
|
||||
assert.equal(result.ok, valid, label);
|
||||
assert.deepEqual(forwarded, valid ? [args] : [], label);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildJellyfinTimelinePayload,
|
||||
JellyfinPlaybackReporter,
|
||||
} from './jellyfin-playback-reporter';
|
||||
|
||||
type FetchCall = { input: string; init: RequestInit };
|
||||
|
||||
function createReporter(respond: (input: string) => Response, logWarn?: (message: string) => void) {
|
||||
const calls: FetchCall[] = [];
|
||||
const reporter = new JellyfinPlaybackReporter({
|
||||
serverUrl: 'http://jellyfin.local/',
|
||||
accessToken: 'token-1',
|
||||
deviceId: 'device-1',
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '1.2.3',
|
||||
deviceName: 'host',
|
||||
fetchImpl: (async (input, init) => {
|
||||
calls.push({ input: String(input), init: init ?? {} });
|
||||
return respond(String(input));
|
||||
}) as typeof fetch,
|
||||
logWarn,
|
||||
});
|
||||
return { reporter, calls };
|
||||
}
|
||||
|
||||
test('reportProgress posts the timeline payload as this device and treats failure as non-fatal', async () => {
|
||||
let fail = false;
|
||||
const { reporter, calls } = createReporter(
|
||||
() => new Response(fail ? 'boom' : null, { status: fail ? 500 : 200 }),
|
||||
);
|
||||
const state = {
|
||||
itemId: 'movie-2',
|
||||
positionTicks: 123456,
|
||||
isPaused: true,
|
||||
volumeLevel: 33,
|
||||
audioStreamIndex: 1,
|
||||
subtitleStreamIndex: 2,
|
||||
};
|
||||
|
||||
assert.equal(await reporter.reportProgress(state), true);
|
||||
fail = true;
|
||||
assert.equal(await reporter.reportProgress({ itemId: 'movie-2', positionTicks: 999 }), false);
|
||||
|
||||
const call = calls[0]!;
|
||||
assert.equal(call.input, 'http://jellyfin.local/Sessions/Playing/Progress');
|
||||
assert.deepEqual(
|
||||
JSON.parse(String(call.init.body)),
|
||||
JSON.parse(JSON.stringify(buildJellyfinTimelinePayload(state))),
|
||||
);
|
||||
assert.equal(
|
||||
(call.init.headers as Record<string, string>).Authorization,
|
||||
'MediaBrowser Client="SubMiner", Device="host", DeviceId="device-1", Version="1.2.3", Token="token-1"',
|
||||
);
|
||||
});
|
||||
|
||||
test('timeline payload omits websocket-only event names', () => {
|
||||
const payload = buildJellyfinTimelinePayload({
|
||||
itemId: 'movie-2',
|
||||
positionTicks: 123456,
|
||||
eventName: 'TimeUpdate',
|
||||
});
|
||||
|
||||
assert.equal('EventName' in payload, false);
|
||||
});
|
||||
|
||||
test('reportStopped posts final position and explicit non-failed state', async () => {
|
||||
const { reporter, calls } = createReporter(() => new Response(null, { status: 200 }));
|
||||
|
||||
assert.equal(
|
||||
await reporter.reportStopped({ itemId: 'movie-stop', positionTicks: 7654321 }),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(calls[0]!.input, 'http://jellyfin.local/Sessions/Playing/Stopped');
|
||||
const posted = JSON.parse(String(calls[0]!.init.body));
|
||||
assert.equal(posted.PositionTicks, 7654321);
|
||||
assert.equal(posted.Failed, false);
|
||||
});
|
||||
|
||||
test('warns once per failing endpoint until it recovers', async () => {
|
||||
const warnings: string[] = [];
|
||||
let status = 400;
|
||||
const { reporter } = createReporter(
|
||||
() => new Response(null, { status }),
|
||||
(message) => warnings.push(message),
|
||||
);
|
||||
const state = { itemId: 'item-1', positionTicks: 10, playMethod: 'DirectPlay' };
|
||||
|
||||
assert.equal(await reporter.reportStopped(state), false);
|
||||
assert.equal(await reporter.reportStopped(state), false);
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0]!, /Sessions\/Playing\/Stopped/);
|
||||
|
||||
status = 200;
|
||||
assert.equal(await reporter.reportStopped(state), true);
|
||||
status = 500;
|
||||
assert.equal(await reporter.reportStopped(state), false);
|
||||
assert.equal(warnings.length, 2);
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
export interface JellyfinTimelinePlaybackState {
|
||||
itemId: string;
|
||||
mediaSourceId?: string;
|
||||
positionTicks?: number;
|
||||
playbackStartTimeTicks?: number;
|
||||
isPaused?: boolean;
|
||||
isMuted?: boolean;
|
||||
canSeek?: boolean;
|
||||
volumeLevel?: number;
|
||||
playbackRate?: number;
|
||||
playMethod?: string;
|
||||
audioStreamIndex?: number | null;
|
||||
subtitleStreamIndex?: number | null;
|
||||
playlistItemId?: string | null;
|
||||
eventName?: string;
|
||||
failed?: boolean;
|
||||
}
|
||||
|
||||
export interface JellyfinTimelinePayload {
|
||||
ItemId: string;
|
||||
MediaSourceId?: string;
|
||||
PositionTicks: number;
|
||||
PlaybackStartTimeTicks: number;
|
||||
IsPaused: boolean;
|
||||
IsMuted: boolean;
|
||||
CanSeek: boolean;
|
||||
VolumeLevel: number;
|
||||
PlaybackRate: number;
|
||||
PlayMethod: string;
|
||||
AudioStreamIndex?: number | null;
|
||||
SubtitleStreamIndex?: number | null;
|
||||
PlaylistItemId?: string | null;
|
||||
Failed?: boolean;
|
||||
}
|
||||
|
||||
export interface JellyfinPlaybackReporterOptions {
|
||||
serverUrl: string;
|
||||
accessToken: string;
|
||||
deviceId: string;
|
||||
clientName?: string;
|
||||
clientVersion?: string;
|
||||
deviceName?: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
logWarn?: (message: string, details?: unknown) => void;
|
||||
}
|
||||
|
||||
const JELLYFIN_REPORT_TIMEOUT_MS = 5000;
|
||||
|
||||
function clampVolume(value: number | undefined): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return 100;
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
|
||||
function normalizeTicks(value: number | undefined): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return 0;
|
||||
return Math.max(0, Math.floor(value));
|
||||
}
|
||||
|
||||
function asNullableInteger(value: number | null | undefined): number | null {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value)) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
export function buildJellyfinTimelinePayload(
|
||||
state: JellyfinTimelinePlaybackState,
|
||||
): JellyfinTimelinePayload {
|
||||
return {
|
||||
ItemId: state.itemId,
|
||||
MediaSourceId: state.mediaSourceId,
|
||||
PositionTicks: normalizeTicks(state.positionTicks),
|
||||
PlaybackStartTimeTicks: normalizeTicks(state.playbackStartTimeTicks),
|
||||
IsPaused: state.isPaused === true,
|
||||
IsMuted: state.isMuted === true,
|
||||
CanSeek: state.canSeek !== false,
|
||||
VolumeLevel: clampVolume(state.volumeLevel),
|
||||
PlaybackRate:
|
||||
typeof state.playbackRate === 'number' && Number.isFinite(state.playbackRate)
|
||||
? state.playbackRate
|
||||
: 1,
|
||||
PlayMethod: state.playMethod || 'DirectPlay',
|
||||
AudioStreamIndex: asNullableInteger(state.audioStreamIndex),
|
||||
SubtitleStreamIndex: asNullableInteger(state.subtitleStreamIndex),
|
||||
PlaylistItemId: state.playlistItemId,
|
||||
Failed: state.failed,
|
||||
};
|
||||
}
|
||||
|
||||
// Authenticated JSON POSTs as this device. Playback timeline reports (start, progress, stop)
|
||||
// are plain HTTP, so they work whether or not the cast-control websocket is connected; that
|
||||
// is how Jellyfin learns the resume position and marks items played for every playback.
|
||||
export class JellyfinPlaybackReporter {
|
||||
readonly authHeader: string;
|
||||
private readonly serverUrl: string;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
private readonly logWarn?: (message: string, details?: unknown) => void;
|
||||
private readonly failedRequestPaths = new Set<string>();
|
||||
|
||||
constructor(options: JellyfinPlaybackReporterOptions) {
|
||||
this.serverUrl = options.serverUrl.trim().replace(/\/+$/, '');
|
||||
this.fetchImpl = options.fetchImpl ?? fetch;
|
||||
this.logWarn = options.logWarn;
|
||||
const clientName = options.clientName || 'SubMiner';
|
||||
const clientVersion = options.clientVersion || '0.1.0';
|
||||
const deviceName = options.deviceName || clientName;
|
||||
this.authHeader = `MediaBrowser Client="${clientName}", Device="${deviceName}", DeviceId="${options.deviceId}", Version="${clientVersion}", Token="${options.accessToken}"`;
|
||||
}
|
||||
|
||||
reportPlaying(state: JellyfinTimelinePlaybackState): Promise<boolean> {
|
||||
return this.postJson('/Sessions/Playing', buildJellyfinTimelinePayload(state));
|
||||
}
|
||||
|
||||
reportProgress(state: JellyfinTimelinePlaybackState): Promise<boolean> {
|
||||
return this.postJson('/Sessions/Playing/Progress', buildJellyfinTimelinePayload(state));
|
||||
}
|
||||
|
||||
reportStopped(state: JellyfinTimelinePlaybackState): Promise<boolean> {
|
||||
return this.postJson('/Sessions/Playing/Stopped', {
|
||||
...buildJellyfinTimelinePayload(state),
|
||||
Failed: state.failed === true,
|
||||
});
|
||||
}
|
||||
|
||||
async postJson(path: string, payload: unknown): Promise<boolean> {
|
||||
try {
|
||||
const response = await this.fetchImpl(`${this.serverUrl}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: this.authHeader,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
// The stop report waits for in-flight progress reports, so a hung request must not
|
||||
// hold it back indefinitely.
|
||||
signal: AbortSignal.timeout(JELLYFIN_REPORT_TIMEOUT_MS),
|
||||
});
|
||||
this.noteRequestOutcome(path, response.ok ? null : `HTTP ${response.status}`);
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
this.noteRequestOutcome(path, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Warn once per path while it keeps failing so a rejected stop report is visible in the
|
||||
// log without a warning per progress tick.
|
||||
private noteRequestOutcome(path: string, failure: unknown): void {
|
||||
if (failure === null) {
|
||||
this.failedRequestPaths.delete(path);
|
||||
return;
|
||||
}
|
||||
if (this.failedRequestPaths.has(path)) return;
|
||||
this.failedRequestPaths.add(path);
|
||||
this.logWarn?.(`Jellyfin request failed: POST ${path}`, failure);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildJellyfinTimelinePayload, JellyfinRemoteSessionService } from './jellyfin-remote';
|
||||
import { JellyfinRemoteSessionService } from './jellyfin-remote';
|
||||
|
||||
class FakeWebSocket {
|
||||
private listeners: Record<string, Array<(...args: unknown[]) => void>> = {};
|
||||
@@ -240,105 +240,6 @@ test('Jellyfin remote stop prevents further reconnect/network activity', () => {
|
||||
assert.equal(service.isConnected(), false);
|
||||
});
|
||||
|
||||
test('reportProgress posts timeline payload and treats failure as non-fatal', async () => {
|
||||
const sockets: FakeWebSocket[] = [];
|
||||
const fetchCalls: Array<{ input: string; init: RequestInit }> = [];
|
||||
let shouldFailTimeline = false;
|
||||
|
||||
const service = new JellyfinRemoteSessionService({
|
||||
serverUrl: 'http://jellyfin.local',
|
||||
accessToken: 'token-4',
|
||||
deviceId: 'device-4',
|
||||
webSocketFactory: () => {
|
||||
const socket = new FakeWebSocket();
|
||||
sockets.push(socket);
|
||||
return socket as unknown as any;
|
||||
},
|
||||
fetchImpl: (async (input, init) => {
|
||||
fetchCalls.push({ input: String(input), init: init ?? {} });
|
||||
if (String(input).endsWith('/Sessions/Playing/Progress') && shouldFailTimeline) {
|
||||
return new Response('boom', { status: 500 });
|
||||
}
|
||||
return new Response(null, { status: 200 });
|
||||
}) as typeof fetch,
|
||||
});
|
||||
|
||||
service.start();
|
||||
sockets[0]!.emit('open');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const expectedPayload = buildJellyfinTimelinePayload({
|
||||
itemId: 'movie-2',
|
||||
positionTicks: 123456,
|
||||
isPaused: true,
|
||||
volumeLevel: 33,
|
||||
audioStreamIndex: 1,
|
||||
subtitleStreamIndex: 2,
|
||||
});
|
||||
const expectedPostedPayload = Object.fromEntries(
|
||||
Object.entries(structuredClone(expectedPayload)).filter(([, value]) => value !== undefined),
|
||||
);
|
||||
|
||||
const ok = await service.reportProgress({
|
||||
itemId: 'movie-2',
|
||||
positionTicks: 123456,
|
||||
isPaused: true,
|
||||
volumeLevel: 33,
|
||||
audioStreamIndex: 1,
|
||||
subtitleStreamIndex: 2,
|
||||
});
|
||||
shouldFailTimeline = true;
|
||||
const failed = await service.reportProgress({
|
||||
itemId: 'movie-2',
|
||||
positionTicks: 999,
|
||||
});
|
||||
|
||||
const timelineCall = fetchCalls.find((call) => call.input.endsWith('/Sessions/Playing/Progress'));
|
||||
assert.ok(timelineCall);
|
||||
assert.equal(ok, true);
|
||||
assert.equal(failed, false);
|
||||
assert.ok(typeof timelineCall.init.body === 'string');
|
||||
assert.deepEqual(JSON.parse(String(timelineCall.init.body)), expectedPostedPayload);
|
||||
});
|
||||
|
||||
test('timeline payload omits websocket-only event names', () => {
|
||||
const payload = buildJellyfinTimelinePayload({
|
||||
itemId: 'movie-2',
|
||||
positionTicks: 123456,
|
||||
eventName: 'TimeUpdate',
|
||||
});
|
||||
|
||||
assert.equal('EventName' in payload, false);
|
||||
});
|
||||
|
||||
test('reportStopped posts final position and explicit non-failed state', async () => {
|
||||
const fetchCalls: Array<{ input: string; init: RequestInit }> = [];
|
||||
const service = new JellyfinRemoteSessionService({
|
||||
serverUrl: 'http://jellyfin.local',
|
||||
accessToken: 'token-stop-payload',
|
||||
deviceId: 'device-stop-payload',
|
||||
webSocketFactory: () => new FakeWebSocket() as unknown as any,
|
||||
fetchImpl: (async (input, init) => {
|
||||
fetchCalls.push({ input: String(input), init: init ?? {} });
|
||||
return new Response(null, { status: 200 });
|
||||
}) as typeof fetch,
|
||||
});
|
||||
|
||||
const ok = await service.reportStopped({
|
||||
itemId: 'movie-stop',
|
||||
positionTicks: 7654321,
|
||||
failed: false,
|
||||
});
|
||||
|
||||
const stoppedCall = fetchCalls.find((call) => call.input.endsWith('/Sessions/Playing/Stopped'));
|
||||
assert.equal(ok, true);
|
||||
assert.ok(stoppedCall);
|
||||
assert.ok(typeof stoppedCall.init.body === 'string');
|
||||
const posted = JSON.parse(String(stoppedCall.init.body));
|
||||
assert.equal(posted.PositionTicks, 7654321);
|
||||
assert.equal(posted.Failed, false);
|
||||
});
|
||||
|
||||
test('advertiseNow validates server registration using Sessions endpoint', async () => {
|
||||
const sockets: FakeWebSocket[] = [];
|
||||
const calls: string[] = [];
|
||||
@@ -451,34 +352,6 @@ test('reconnects when the server stops answering keep-alives', () => {
|
||||
assert.equal(sockets.length, 2);
|
||||
});
|
||||
|
||||
test('warns once per failing timeline endpoint until it recovers', async () => {
|
||||
const warnings: string[] = [];
|
||||
let status = 400;
|
||||
|
||||
const service = new JellyfinRemoteSessionService({
|
||||
serverUrl: 'http://jellyfin.local',
|
||||
accessToken: 'token-warn',
|
||||
deviceId: 'device-warn',
|
||||
webSocketFactory: () => new FakeWebSocket() as unknown as any,
|
||||
fetchImpl: (async () => new Response(null, { status })) as typeof fetch,
|
||||
logWarn: (message) => {
|
||||
warnings.push(message);
|
||||
},
|
||||
});
|
||||
const state = { itemId: 'item-1', positionTicks: 10, playMethod: 'DirectPlay' };
|
||||
|
||||
assert.equal(await service.reportStopped(state), false);
|
||||
assert.equal(await service.reportStopped(state), false);
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0]!, /Sessions\/Playing\/Stopped/);
|
||||
|
||||
status = 200;
|
||||
assert.equal(await service.reportStopped(state), true);
|
||||
status = 500;
|
||||
assert.equal(await service.reportStopped(state), false);
|
||||
assert.equal(warnings.length, 2);
|
||||
});
|
||||
|
||||
test('ignores messages from a superseded socket', () => {
|
||||
const sockets: FakeWebSocket[] = [];
|
||||
const playPayloads: unknown[] = [];
|
||||
|
||||
@@ -1,45 +1,11 @@
|
||||
import WebSocket from 'ws';
|
||||
import { JellyfinPlaybackReporter } from './jellyfin-playback-reporter';
|
||||
|
||||
export interface JellyfinRemoteSessionMessage {
|
||||
MessageType?: string;
|
||||
Data?: unknown;
|
||||
}
|
||||
|
||||
export interface JellyfinTimelinePlaybackState {
|
||||
itemId: string;
|
||||
mediaSourceId?: string;
|
||||
positionTicks?: number;
|
||||
playbackStartTimeTicks?: number;
|
||||
isPaused?: boolean;
|
||||
isMuted?: boolean;
|
||||
canSeek?: boolean;
|
||||
volumeLevel?: number;
|
||||
playbackRate?: number;
|
||||
playMethod?: string;
|
||||
audioStreamIndex?: number | null;
|
||||
subtitleStreamIndex?: number | null;
|
||||
playlistItemId?: string | null;
|
||||
eventName?: string;
|
||||
failed?: boolean;
|
||||
}
|
||||
|
||||
export interface JellyfinTimelinePayload {
|
||||
ItemId: string;
|
||||
MediaSourceId?: string;
|
||||
PositionTicks: number;
|
||||
PlaybackStartTimeTicks: number;
|
||||
IsPaused: boolean;
|
||||
IsMuted: boolean;
|
||||
CanSeek: boolean;
|
||||
VolumeLevel: number;
|
||||
PlaybackRate: number;
|
||||
PlayMethod: string;
|
||||
AudioStreamIndex?: number | null;
|
||||
SubtitleStreamIndex?: number | null;
|
||||
PlaylistItemId?: string | null;
|
||||
Failed?: boolean;
|
||||
}
|
||||
|
||||
interface JellyfinRemoteSocket {
|
||||
on(event: 'open', listener: () => void): this;
|
||||
on(event: 'close', listener: () => void): this;
|
||||
@@ -99,16 +65,6 @@ function normalizeServerUrl(serverUrl: string): string {
|
||||
return serverUrl.trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function clampVolume(value: number | undefined): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return 100;
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
|
||||
function normalizeTicks(value: number | undefined): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return 0;
|
||||
return Math.max(0, Math.floor(value));
|
||||
}
|
||||
|
||||
function parseMessageData(value: unknown): unknown {
|
||||
if (typeof value !== 'string') return value;
|
||||
const trimmed = value.trim();
|
||||
@@ -137,11 +93,6 @@ function parseInboundMessage(rawData: unknown): JellyfinRemoteSessionMessage | n
|
||||
}
|
||||
}
|
||||
|
||||
function asNullableInteger(value: number | null | undefined): number | null {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value)) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
function createDefaultCapabilities(): {
|
||||
PlayableMediaTypes: string;
|
||||
SupportedCommands: string;
|
||||
@@ -155,40 +106,6 @@ function createDefaultCapabilities(): {
|
||||
};
|
||||
}
|
||||
|
||||
function buildAuthorizationHeader(params: {
|
||||
clientName: string;
|
||||
deviceName: string;
|
||||
clientVersion: string;
|
||||
deviceId: string;
|
||||
accessToken: string;
|
||||
}): string {
|
||||
return `MediaBrowser Client="${params.clientName}", Device="${params.deviceName}", DeviceId="${params.deviceId}", Version="${params.clientVersion}", Token="${params.accessToken}"`;
|
||||
}
|
||||
|
||||
export function buildJellyfinTimelinePayload(
|
||||
state: JellyfinTimelinePlaybackState,
|
||||
): JellyfinTimelinePayload {
|
||||
return {
|
||||
ItemId: state.itemId,
|
||||
MediaSourceId: state.mediaSourceId,
|
||||
PositionTicks: normalizeTicks(state.positionTicks),
|
||||
PlaybackStartTimeTicks: normalizeTicks(state.playbackStartTimeTicks),
|
||||
IsPaused: state.isPaused === true,
|
||||
IsMuted: state.isMuted === true,
|
||||
CanSeek: state.canSeek !== false,
|
||||
VolumeLevel: clampVolume(state.volumeLevel),
|
||||
PlaybackRate:
|
||||
typeof state.playbackRate === 'number' && Number.isFinite(state.playbackRate)
|
||||
? state.playbackRate
|
||||
: 1,
|
||||
PlayMethod: state.playMethod || 'DirectPlay',
|
||||
AudioStreamIndex: asNullableInteger(state.audioStreamIndex),
|
||||
SubtitleStreamIndex: asNullableInteger(state.subtitleStreamIndex),
|
||||
PlaylistItemId: state.playlistItemId,
|
||||
Failed: state.failed,
|
||||
};
|
||||
}
|
||||
|
||||
export class JellyfinRemoteSessionService {
|
||||
private readonly serverUrl: string;
|
||||
private readonly accessToken: string;
|
||||
@@ -210,6 +127,8 @@ export class JellyfinRemoteSessionService {
|
||||
SupportsMediaControl: boolean;
|
||||
};
|
||||
private readonly authHeader: string;
|
||||
// Shares this device's identity for capability posts; playback reports use their own reporter.
|
||||
private readonly http: JellyfinPlaybackReporter;
|
||||
private readonly onConnected?: () => void;
|
||||
private readonly onDisconnected?: () => void;
|
||||
private readonly logWarn?: (message: string, details?: unknown) => void;
|
||||
@@ -217,7 +136,6 @@ export class JellyfinRemoteSessionService {
|
||||
private keepAliveTimeoutMs: number;
|
||||
private keepAliveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private lastInboundAtMs = 0;
|
||||
private readonly failedRequestPaths = new Set<string>();
|
||||
|
||||
private readonly reconnectBaseDelayMs: number;
|
||||
private readonly reconnectMaxDelayMs: number;
|
||||
@@ -243,19 +161,20 @@ export class JellyfinRemoteSessionService {
|
||||
...createDefaultCapabilities(),
|
||||
...(options.capabilities ?? {}),
|
||||
};
|
||||
const clientName = options.clientName || 'SubMiner';
|
||||
const clientVersion = options.clientVersion || '0.1.0';
|
||||
const deviceName = options.deviceName || clientName;
|
||||
this.authHeader = buildAuthorizationHeader({
|
||||
clientName,
|
||||
deviceName,
|
||||
clientVersion,
|
||||
deviceId: this.deviceId,
|
||||
this.logWarn = options.logWarn;
|
||||
this.http = new JellyfinPlaybackReporter({
|
||||
serverUrl: this.serverUrl,
|
||||
accessToken: this.accessToken,
|
||||
deviceId: this.deviceId,
|
||||
clientName: options.clientName,
|
||||
clientVersion: options.clientVersion,
|
||||
deviceName: options.deviceName,
|
||||
fetchImpl: this.fetchImpl,
|
||||
logWarn: this.logWarn,
|
||||
});
|
||||
this.authHeader = this.http.authHeader;
|
||||
this.onConnected = options.onConnected;
|
||||
this.onDisconnected = options.onDisconnected;
|
||||
this.logWarn = options.logWarn;
|
||||
this.now = options.getNow ?? Date.now;
|
||||
this.keepAliveTimeoutMs = Math.max(
|
||||
1000,
|
||||
@@ -298,21 +217,6 @@ export class JellyfinRemoteSessionService {
|
||||
return this.isRegisteredOnServer();
|
||||
}
|
||||
|
||||
public async reportPlaying(state: JellyfinTimelinePlaybackState): Promise<boolean> {
|
||||
return this.postTimeline('/Sessions/Playing', buildJellyfinTimelinePayload(state));
|
||||
}
|
||||
|
||||
public async reportProgress(state: JellyfinTimelinePlaybackState): Promise<boolean> {
|
||||
return this.postTimeline('/Sessions/Playing/Progress', buildJellyfinTimelinePayload(state));
|
||||
}
|
||||
|
||||
public async reportStopped(state: JellyfinTimelinePlaybackState): Promise<boolean> {
|
||||
return this.postTimeline('/Sessions/Playing/Stopped', {
|
||||
...buildJellyfinTimelinePayload(state),
|
||||
Failed: state.failed === true,
|
||||
});
|
||||
}
|
||||
|
||||
private connectSocket(): void {
|
||||
if (!this.running) return;
|
||||
if (this.reconnectTimer) {
|
||||
@@ -441,9 +345,9 @@ export class JellyfinRemoteSessionService {
|
||||
|
||||
private async postCapabilities(): Promise<void> {
|
||||
const payload = this.capabilities;
|
||||
const fullEndpointOk = await this.postJson('/Sessions/Capabilities/Full', payload);
|
||||
const fullEndpointOk = await this.http.postJson('/Sessions/Capabilities/Full', payload);
|
||||
if (fullEndpointOk) return;
|
||||
await this.postJson('/Sessions/Capabilities', payload);
|
||||
await this.http.postJson('/Sessions/Capabilities', payload);
|
||||
}
|
||||
|
||||
private async isRegisteredOnServer(): Promise<boolean> {
|
||||
@@ -462,40 +366,6 @@ export class JellyfinRemoteSessionService {
|
||||
}
|
||||
}
|
||||
|
||||
private async postTimeline(path: string, payload: JellyfinTimelinePayload): Promise<boolean> {
|
||||
return this.postJson(path, payload);
|
||||
}
|
||||
|
||||
private async postJson(path: string, payload: unknown): Promise<boolean> {
|
||||
try {
|
||||
const response = await this.fetchImpl(`${this.serverUrl}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: this.authHeader,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
this.noteRequestOutcome(path, response.ok ? null : `HTTP ${response.status}`);
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
this.noteRequestOutcome(path, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Warn once per path while it keeps failing so a rejected stop report is visible in the
|
||||
// log without a warning per progress tick.
|
||||
private noteRequestOutcome(path: string, failure: unknown): void {
|
||||
if (failure === null) {
|
||||
this.failedRequestPaths.delete(path);
|
||||
return;
|
||||
}
|
||||
if (this.failedRequestPaths.has(path)) return;
|
||||
this.failedRequestPaths.add(path);
|
||||
this.logWarn?.(`Jellyfin remote request failed: POST ${path}`, failure);
|
||||
}
|
||||
|
||||
private handleInboundMessage(socket: JellyfinRemoteSocket, rawData: unknown): void {
|
||||
const message = parseInboundMessage(rawData);
|
||||
if (!message) return;
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
getIgnoredPos1Entries,
|
||||
JLPT_EXCLUDED_TERMS,
|
||||
JLPT_IGNORED_MECAB_POS1,
|
||||
JLPT_IGNORED_MECAB_POS1_ENTRIES,
|
||||
JLPT_IGNORED_MECAB_POS1_LIST,
|
||||
shouldIgnoreJlptByTerm,
|
||||
shouldIgnoreJlptForMecabPos1,
|
||||
} from './jlpt-token-filter';
|
||||
|
||||
test('shouldIgnoreJlptByTerm matches the excluded JLPT lexical terms', () => {
|
||||
assert.equal(shouldIgnoreJlptByTerm('この'), true);
|
||||
assert.equal(shouldIgnoreJlptByTerm('そこ'), true);
|
||||
assert.equal(shouldIgnoreJlptByTerm('猫'), false);
|
||||
assert.deepEqual(Array.from(JLPT_EXCLUDED_TERMS), [
|
||||
'この',
|
||||
'その',
|
||||
'あの',
|
||||
'どの',
|
||||
'これ',
|
||||
'それ',
|
||||
'あれ',
|
||||
'どれ',
|
||||
'ここ',
|
||||
'そこ',
|
||||
'あそこ',
|
||||
'どこ',
|
||||
'こと',
|
||||
'ああ',
|
||||
'ええ',
|
||||
'うう',
|
||||
'おお',
|
||||
'はは',
|
||||
'へえ',
|
||||
'ふう',
|
||||
'ほう',
|
||||
]);
|
||||
});
|
||||
|
||||
test('shouldIgnoreJlptForMecabPos1 matches the exported ignored POS1 list', () => {
|
||||
assert.equal(shouldIgnoreJlptForMecabPos1('助詞'), true);
|
||||
assert.equal(shouldIgnoreJlptForMecabPos1('名詞'), false);
|
||||
assert.deepEqual(JLPT_IGNORED_MECAB_POS1, JLPT_IGNORED_MECAB_POS1_LIST);
|
||||
assert.deepEqual(
|
||||
JLPT_IGNORED_MECAB_POS1_ENTRIES.map((entry) => entry.pos1),
|
||||
JLPT_IGNORED_MECAB_POS1_LIST,
|
||||
);
|
||||
assert.deepEqual(getIgnoredPos1Entries(), JLPT_IGNORED_MECAB_POS1_ENTRIES);
|
||||
});
|
||||
@@ -1,12 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
playNextSubtitleRuntime,
|
||||
replayCurrentSubtitleRuntime,
|
||||
sendMpvCommandRuntime,
|
||||
setMpvSubVisibilityRuntime,
|
||||
showMpvOsdRuntime,
|
||||
} from './mpv';
|
||||
import { showMpvOsdRuntime } from './mpv';
|
||||
|
||||
test('showMpvOsdRuntime sends show-text when connected', () => {
|
||||
const commands: (string | number)[][] = [];
|
||||
@@ -55,29 +49,3 @@ test('showMpvOsdRuntime logs fallback when disconnected', () => {
|
||||
assert.equal(shown, false);
|
||||
assert.deepEqual(logs, ['OSD (MPV not connected): hello']);
|
||||
});
|
||||
|
||||
test('mpv runtime command wrappers call expected client methods', () => {
|
||||
const calls: string[] = [];
|
||||
const client = {
|
||||
connected: true,
|
||||
send: ({ command }: { command: (string | number)[] }) => {
|
||||
calls.push(`send:${command.join(',')}`);
|
||||
},
|
||||
replayCurrentSubtitle: () => {
|
||||
calls.push('replay');
|
||||
},
|
||||
playNextSubtitle: () => {
|
||||
calls.push('next');
|
||||
},
|
||||
setSubVisibility: (visible: boolean) => {
|
||||
calls.push(`subVisible:${visible}`);
|
||||
},
|
||||
};
|
||||
|
||||
replayCurrentSubtitleRuntime(client);
|
||||
playNextSubtitleRuntime(client);
|
||||
sendMpvCommandRuntime(client, ['script-message', 'x']);
|
||||
setMpvSubVisibilityRuntime(client, false);
|
||||
|
||||
assert.deepEqual(calls, ['replay', 'next', 'send:script-message,x', 'subVisible:false']);
|
||||
});
|
||||
@@ -48,19 +48,6 @@ class HangingSocket extends FakeSocket {
|
||||
|
||||
const wait = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
test('getMpvReconnectDelay follows existing reconnect ramp', () => {
|
||||
assert.equal(getMpvReconnectDelay(0, true), 1000);
|
||||
assert.equal(getMpvReconnectDelay(1, true), 1000);
|
||||
assert.equal(getMpvReconnectDelay(2, true), 2000);
|
||||
assert.equal(getMpvReconnectDelay(4, true), 5000);
|
||||
assert.equal(getMpvReconnectDelay(7, true), 10000);
|
||||
|
||||
assert.equal(getMpvReconnectDelay(0, false), 200);
|
||||
assert.equal(getMpvReconnectDelay(2, false), 500);
|
||||
assert.equal(getMpvReconnectDelay(4, false), 1000);
|
||||
assert.equal(getMpvReconnectDelay(6, false), 2000);
|
||||
});
|
||||
|
||||
test('scheduleMpvReconnect clears existing timer and increments attempt', () => {
|
||||
const existing = {} as ReturnType<typeof setTimeout>;
|
||||
const cleared: Array<ReturnType<typeof setTimeout> | null> = [];
|
||||
|
||||
@@ -262,50 +262,6 @@ test('MpvIpcClient requestProperty throws on mpv error response', async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('MpvIpcClient connect does not log connect-request at info level', () => {
|
||||
const originalLevel = process.env.SUBMINER_LOG_LEVEL;
|
||||
const originalInfo = console.info;
|
||||
const infoLines: string[] = [];
|
||||
process.env.SUBMINER_LOG_LEVEL = 'info';
|
||||
console.info = (message?: unknown) => {
|
||||
infoLines.push(String(message ?? ''));
|
||||
};
|
||||
|
||||
try {
|
||||
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
||||
(client as any).transport.connect = () => {};
|
||||
client.connect();
|
||||
} finally {
|
||||
process.env.SUBMINER_LOG_LEVEL = originalLevel;
|
||||
console.info = originalInfo;
|
||||
}
|
||||
|
||||
const requestLogs = infoLines.filter((line) => line.includes('MPV IPC connect requested.'));
|
||||
assert.equal(requestLogs.length, 0);
|
||||
});
|
||||
|
||||
test('MpvIpcClient connect logs connect-request at debug level', () => {
|
||||
const originalLevel = process.env.SUBMINER_LOG_LEVEL;
|
||||
const originalDebug = console.debug;
|
||||
const debugLines: string[] = [];
|
||||
process.env.SUBMINER_LOG_LEVEL = 'debug';
|
||||
console.debug = (message?: unknown) => {
|
||||
debugLines.push(String(message ?? ''));
|
||||
};
|
||||
|
||||
try {
|
||||
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
||||
(client as any).transport.connect = () => {};
|
||||
client.connect();
|
||||
} finally {
|
||||
process.env.SUBMINER_LOG_LEVEL = originalLevel;
|
||||
console.debug = originalDebug;
|
||||
}
|
||||
|
||||
const requestLogs = debugLines.filter((line) => line.includes('MPV IPC connect requested.'));
|
||||
assert.equal(requestLogs.length, 1);
|
||||
});
|
||||
|
||||
test('MpvIpcClient reconnect clears stale connected state and starts a fresh transport connect', () => {
|
||||
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
||||
const calls: string[] = [];
|
||||
@@ -356,80 +312,6 @@ test('MpvIpcClient failPendingRequests resolves outstanding requests as disconne
|
||||
assert.equal((client as any).pendingRequests.size, 0);
|
||||
});
|
||||
|
||||
test('MpvIpcClient scheduleReconnect schedules timer and invokes connect', () => {
|
||||
const timers: Array<ReturnType<typeof setTimeout> | null> = [];
|
||||
const client = new MpvIpcClient(
|
||||
'/tmp/mpv.sock',
|
||||
makeDeps({
|
||||
getReconnectTimer: () => null,
|
||||
setReconnectTimer: (timer) => {
|
||||
timers.push(timer);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
let connectCalled = false;
|
||||
(client as any).connect = () => {
|
||||
connectCalled = true;
|
||||
};
|
||||
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
(globalThis as any).setTimeout = (handler: () => void, _delay: number) => {
|
||||
handler();
|
||||
return 1 as unknown as ReturnType<typeof setTimeout>;
|
||||
};
|
||||
try {
|
||||
(client as any).scheduleReconnect();
|
||||
} finally {
|
||||
(globalThis as any).setTimeout = originalSetTimeout;
|
||||
}
|
||||
|
||||
assert.equal(timers.length, 1);
|
||||
assert.equal(connectCalled, true);
|
||||
});
|
||||
|
||||
test('MpvIpcClient scheduleReconnect clears existing reconnect timer', () => {
|
||||
const timers: Array<ReturnType<typeof setTimeout> | null> = [];
|
||||
const cleared: Array<ReturnType<typeof setTimeout> | null> = [];
|
||||
const existingTimer = {} as ReturnType<typeof setTimeout>;
|
||||
const client = new MpvIpcClient(
|
||||
'/tmp/mpv.sock',
|
||||
makeDeps({
|
||||
getReconnectTimer: () => existingTimer,
|
||||
setReconnectTimer: (timer) => {
|
||||
timers.push(timer);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
let connectCalled = false;
|
||||
(client as any).connect = () => {
|
||||
connectCalled = true;
|
||||
};
|
||||
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
const originalClearTimeout = globalThis.clearTimeout;
|
||||
(globalThis as any).setTimeout = (handler: () => void, _delay: number) => {
|
||||
handler();
|
||||
return 1 as unknown as ReturnType<typeof setTimeout>;
|
||||
};
|
||||
(globalThis as any).clearTimeout = (timer: ReturnType<typeof setTimeout> | null) => {
|
||||
cleared.push(timer);
|
||||
};
|
||||
|
||||
try {
|
||||
(client as any).scheduleReconnect();
|
||||
} finally {
|
||||
(globalThis as any).setTimeout = originalSetTimeout;
|
||||
(globalThis as any).clearTimeout = originalClearTimeout;
|
||||
}
|
||||
|
||||
assert.equal(cleared.length, 1);
|
||||
assert.equal(cleared[0], existingTimer);
|
||||
assert.equal(timers.length, 1);
|
||||
assert.equal(connectCalled, true);
|
||||
});
|
||||
|
||||
test('MpvIpcClient onClose resolves outstanding requests and schedules reconnect', () => {
|
||||
const timers: Array<ReturnType<typeof setTimeout> | null> = [];
|
||||
const client = new MpvIpcClient(
|
||||
|
||||
@@ -1,51 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createNumericShortcutRuntime, createNumericShortcutSession } from './numeric-shortcut';
|
||||
|
||||
test('createNumericShortcutRuntime creates sessions wired to globalShortcut', () => {
|
||||
const registered: string[] = [];
|
||||
const unregistered: string[] = [];
|
||||
const osd: string[] = [];
|
||||
const handlers = new Map<string, () => void>();
|
||||
|
||||
const runtime = createNumericShortcutRuntime({
|
||||
globalShortcut: {
|
||||
register: (accelerator, callback) => {
|
||||
registered.push(accelerator);
|
||||
handlers.set(accelerator, callback);
|
||||
return true;
|
||||
},
|
||||
unregister: (accelerator) => {
|
||||
unregistered.push(accelerator);
|
||||
handlers.delete(accelerator);
|
||||
},
|
||||
},
|
||||
showMpvOsd: (text) => {
|
||||
osd.push(text);
|
||||
},
|
||||
setTimer: () => setTimeout(() => {}, 1000),
|
||||
clearTimer: (timer) => clearTimeout(timer),
|
||||
});
|
||||
|
||||
const session = runtime.createSession();
|
||||
session.start({
|
||||
timeoutMs: 5000,
|
||||
onDigit: () => {},
|
||||
messages: {
|
||||
prompt: 'Select count',
|
||||
timeout: 'Timed out',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(session.isActive(), true);
|
||||
assert.ok(registered.includes('1'));
|
||||
assert.ok(registered.includes('Escape'));
|
||||
assert.equal(osd[0], 'Select count');
|
||||
|
||||
handlers.get('Escape')?.();
|
||||
assert.equal(session.isActive(), false);
|
||||
assert.ok(unregistered.includes('Escape'));
|
||||
});
|
||||
import { createNumericShortcutSession } from './numeric-shortcut';
|
||||
|
||||
test('numeric shortcut session handles digit selection and unregisters shortcuts', () => {
|
||||
const handlers = new Map<string, () => void>();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { KikuFieldGroupingChoice } from '../../types';
|
||||
import { createFieldGroupingCallbackRuntime, sendToVisibleOverlayRuntime } from './overlay-bridge';
|
||||
import { sendToVisibleOverlayRuntime } from './overlay-bridge';
|
||||
|
||||
test('sendToVisibleOverlayRuntime restores visibility flag when opening hidden overlay modal', () => {
|
||||
const sent: unknown[][] = [];
|
||||
@@ -72,39 +71,3 @@ test('sendToVisibleOverlayRuntime waits for overlay page before sending open com
|
||||
|
||||
assert.deepEqual(sent, [['runtime-options:open']]);
|
||||
});
|
||||
|
||||
test('createFieldGroupingCallbackRuntime cancels when overlay request cannot be sent', async () => {
|
||||
let resolver: ((choice: KikuFieldGroupingChoice) => void) | null = null;
|
||||
const callback = createFieldGroupingCallbackRuntime<'runtime-options' | 'subsync'>({
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: () => resolver,
|
||||
setResolver: (next) => {
|
||||
resolver = next;
|
||||
},
|
||||
sendToVisibleOverlay: () => false,
|
||||
});
|
||||
|
||||
const result = await callback({
|
||||
original: {
|
||||
noteId: 1,
|
||||
expression: 'a',
|
||||
sentencePreview: 'a',
|
||||
hasAudio: false,
|
||||
hasImage: false,
|
||||
isOriginal: true,
|
||||
},
|
||||
duplicate: {
|
||||
noteId: 2,
|
||||
expression: 'b',
|
||||
sentencePreview: 'b',
|
||||
hasAudio: false,
|
||||
hasImage: false,
|
||||
isOriginal: false,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.cancelled, true);
|
||||
assert.equal(result.keepNoteId, 0);
|
||||
assert.equal(result.deleteNoteId, 0);
|
||||
});
|
||||
Loaded 100 of 415 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user