mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-08 01:08:53 -07:00
refactor(tests): centralize lane definitions and add per-file isolation (#143)
This commit is contained in:
@@ -55,9 +55,6 @@ jobs:
|
|||||||
- name: Verify generated config examples
|
- name: Verify generated config examples
|
||||||
run: bun run verify:config-example
|
run: bun run verify:config-example
|
||||||
|
|
||||||
- name: Internal docs knowledge-base checks
|
|
||||||
run: bun run test:docs:kb
|
|
||||||
|
|
||||||
- name: Test suite (source)
|
- name: Test suite (source)
|
||||||
run: bun run test:fast
|
run: bun run test:fast
|
||||||
|
|
||||||
@@ -71,6 +68,9 @@ jobs:
|
|||||||
path: coverage/test-src/lcov.info
|
path: coverage/test-src/lcov.info
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Stats UI tests
|
||||||
|
run: bun run test:stats
|
||||||
|
|
||||||
- name: Launcher smoke suite (source)
|
- name: Launcher smoke suite (source)
|
||||||
run: bun run test:launcher:smoke:src
|
run: bun run test:launcher:smoke:src
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ jobs:
|
|||||||
path: coverage/test-src/lcov.info
|
path: coverage/test-src/lcov.info
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Stats UI tests
|
||||||
|
run: bun run test:stats
|
||||||
|
|
||||||
- name: Launcher smoke suite (source)
|
- name: Launcher smoke suite (source)
|
||||||
run: bun run test:launcher:smoke:src
|
run: bun run test:launcher:smoke:src
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ jobs:
|
|||||||
path: coverage/test-src/lcov.info
|
path: coverage/test-src/lcov.info
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Stats UI tests
|
||||||
|
run: bun run test:stats
|
||||||
|
|
||||||
- name: Launcher smoke suite (source)
|
- name: Launcher smoke suite (source)
|
||||||
run: bun run test:launcher:smoke:src
|
run: bun run test:launcher:smoke:src
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,10 @@ Start here, then leave this file.
|
|||||||
- Config/schema/defaults: `bun run test:config`; if template/defaults changed, `bun run generate:config-example`
|
- Config/schema/defaults: `bun run test:config`; if template/defaults changed, `bun run generate:config-example`
|
||||||
- Launcher/plugin: `bun run test:launcher` or `bun run test:env`
|
- Launcher/plugin: `bun run test:launcher` or `bun run test:env`
|
||||||
- Runtime-compat / dist-sensitive: `bun run test:runtime:compat`
|
- Runtime-compat / dist-sensitive: `bun run test:runtime:compat`
|
||||||
|
- Stats dashboard UI (`stats/`): `bun run test:stats`
|
||||||
|
- Build/release scripts (`scripts/**`): `bun run test:scripts`
|
||||||
- Docs-only: `bun run docs:test`, then `bun run docs:build`
|
- Docs-only: `bun run docs:test`, then `bun run docs:build`
|
||||||
|
- Test lanes are directory-discovered via `scripts/test-lanes.ts`; never hand-list test files in `package.json`
|
||||||
|
|
||||||
## Docs Upkeep
|
## Docs Upkeep
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
type: internal
|
||||||
|
area: testing
|
||||||
|
|
||||||
|
- Test lanes are now defined once in `scripts/test-lanes.ts` and discovered by directory instead of hand-maintained file lists in `package.json`; the unused `test:core:*`/`test:config:dist`/`test:full` scripts were removed.
|
||||||
|
- `scripts/run-test-lane.mjs` runs each test file in an isolated `bun test` process with a wall timeout, so a hanging test or leaked global can no longer cascade failures across the lane.
|
||||||
|
- Previously orphaned suites now run in CI: the stats dashboard tests (`bun run test:stats`), the `scripts/**` tests (`bun run test:scripts`, including the change-verification skill tests), the `test-plugin-process-start-retries.lua` plugin test, and the runtime-compat dist slice (now part of `bun run test:fast`).
|
||||||
|
- The change-verification skill gained a `stats` lane for `stats/` edits.
|
||||||
@@ -80,18 +80,24 @@ Default lanes:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run test # alias for test:fast
|
bun run test # alias for test:fast
|
||||||
bun run test:fast # default fast lane
|
bun run test:fast # full source lanes: src + launcher-unit + scripts + runtime compat
|
||||||
bun run test:full # maintained source + launcher-unit + runtime compat surface
|
|
||||||
bun run test:runtime:compat # compiled/runtime compatibility slice only
|
bun run test:runtime:compat # compiled/runtime compatibility slice only
|
||||||
bun run test:env # launcher/plugin + env-sensitive verification
|
bun run test:env # launcher/plugin + env-sensitive verification
|
||||||
|
bun run test:stats # stats dashboard UI suite
|
||||||
bun run test:immersion:sqlite # SQLite persistence lane
|
bun run test:immersion:sqlite # SQLite persistence lane
|
||||||
bun run test:subtitle # maintained alass/ffsubsync subtitle surface
|
bun run test:subtitle # maintained alass/ffsubsync subtitle surface
|
||||||
```
|
```
|
||||||
|
|
||||||
- `bun run test` and `bun run test:fast` cover config/core suites plus representative entry/runtime, Anki integration, release-workflow coverage, typecheck, and runtime-registry checks.
|
Test lane membership is defined once in `scripts/test-lanes.ts` and discovered by
|
||||||
- `bun run test:full` is the maintained full surface: Bun-compatible `src/**` discovery, Bun-compatible launcher unit discovery, and the compiled/runtime compatibility lane for suites routed through `dist/**`.
|
directory, so new test files join their lane automatically. `scripts/run-test-lane.mjs`
|
||||||
|
runs each test file in its own `bun test` process (per-file isolation) so a hanging
|
||||||
|
test or leaked global in one file cannot cascade into the rest of the lane; pass
|
||||||
|
`--jobs N` to parallelize or `--single-process` for one shared process.
|
||||||
|
|
||||||
|
- `bun run test` and `bun run test:fast` cover the full discovered `src/**` suite, launcher unit tests, `scripts/**` tests, and the compiled/runtime compatibility lane.
|
||||||
- `bun run test:runtime:compat` covers the compiled/runtime slice directly: `ipc`, `anki-jimaku-ipc`, `overlay-manager`, `config-validation`, `startup-config`, and `registry`.
|
- `bun run test:runtime:compat` covers the compiled/runtime slice directly: `ipc`, `anki-jimaku-ipc`, `overlay-manager`, `config-validation`, `startup-config`, and `registry`.
|
||||||
- `bun run test:env` covers environment-sensitive checks: launcher smoke/plugin verification plus the Bun source SQLite lane.
|
- `bun run test:env` covers environment-sensitive checks: launcher smoke/plugin verification plus the Bun source SQLite lane.
|
||||||
|
- `bun run test:stats` runs the stats dashboard suite under `stats/src/**`.
|
||||||
- `bun run test:immersion:sqlite` is the reproducible persistence lane when you need real DB-backed SQLite coverage under Bun.
|
- `bun run test:immersion:sqlite` is the reproducible persistence lane when you need real DB-backed SQLite coverage under Bun.
|
||||||
|
|
||||||
The Bun-managed discovery lanes intentionally exclude a small compiled/runtime-focused set: `src/core/services/ipc.test.ts`, `src/core/services/anki-jimaku-ipc.test.ts`, `src/core/services/overlay-manager.test.ts`, `src/main/config-validation.test.ts`, `src/main/runtime/startup-config.test.ts`, and `src/main/runtime/registry.test.ts`. `bun run test:runtime:compat` keeps them in the standard workflow via `dist/**`.
|
The Bun-managed discovery lanes intentionally exclude a small compiled/runtime-focused set: `src/core/services/ipc.test.ts`, `src/core/services/anki-jimaku-ipc.test.ts`, `src/core/services/overlay-manager.test.ts`, `src/main/config-validation.test.ts`, `src/main/runtime/startup-config.test.ts`, and `src/main/runtime/registry.test.ts`. `bun run test:runtime:compat` keeps them in the standard workflow via `dist/**`.
|
||||||
@@ -126,11 +132,11 @@ Focused commands:
|
|||||||
```bash
|
```bash
|
||||||
bun run test:config # Source-level config schema/validation tests
|
bun run test:config # Source-level config schema/validation tests
|
||||||
bun run test:launcher # Launcher regression tests (config discovery + command routing)
|
bun run test:launcher # Launcher regression tests (config discovery + command routing)
|
||||||
bun run test:core # Source-level core regression tests (default lane)
|
|
||||||
bun run test:launcher:smoke:src # Launcher e2e smoke: launcher -> mpv IPC -> overlay start/stop wiring
|
bun run test:launcher:smoke:src # Launcher e2e smoke: launcher -> mpv IPC -> overlay start/stop wiring
|
||||||
bun run test:launcher:env:src # Launcher smoke + Lua plugin gate
|
bun run test:launcher:env:src # Launcher smoke + Lua plugin gate
|
||||||
bun run test:src # Bun-managed maintained src/** discovery lane
|
bun run test:src # Bun-managed maintained src/** discovery lane
|
||||||
bun run test:launcher:unit:src # Bun-managed maintained launcher unit lane
|
bun run test:launcher:unit:src # Bun-managed maintained launcher unit lane
|
||||||
|
bun run test:scripts # Bun-managed scripts/** test lane
|
||||||
bun run test:immersion:sqlite:src # Bun source lane
|
bun run test:immersion:sqlite:src # Bun source lane
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -144,8 +150,6 @@ Smoke and optional deep dist commands:
|
|||||||
bun run build # compile dist artifacts
|
bun run build # compile dist artifacts
|
||||||
bun run test:immersion:sqlite # compile + run SQLite-backed immersion tests under Bun
|
bun run test:immersion:sqlite # compile + run SQLite-backed immersion tests under Bun
|
||||||
bun run test:smoke:dist # explicit smoke scope for compiled runtime
|
bun run test:smoke:dist # explicit smoke scope for compiled runtime
|
||||||
bun run test:config:dist # optional full dist config suite
|
|
||||||
bun run test:core:dist # optional full dist core suite
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `bun run test:immersion:sqlite` when you need real DB-backed coverage for the immersion tracker.
|
Use `bun run test:immersion:sqlite` when you need real DB-backed coverage for the immersion tracker.
|
||||||
|
|||||||
@@ -3,10 +3,22 @@
|
|||||||
# Verification
|
# Verification
|
||||||
|
|
||||||
Status: active
|
Status: active
|
||||||
Last verified: 2026-05-23
|
Last verified: 2026-07-06
|
||||||
Owner: Kyle Yasuda
|
Owner: Kyle Yasuda
|
||||||
Read when: selecting the right verification lane for a change
|
Read when: selecting the right verification lane for a change
|
||||||
|
|
||||||
|
## Lane Infrastructure
|
||||||
|
|
||||||
|
- Lane membership is defined once in `scripts/test-lanes.ts` and discovered by
|
||||||
|
directory — new test files join their lane automatically; never hand-list test
|
||||||
|
files in `package.json`.
|
||||||
|
- `scripts/run-test-lane.mjs` runs each test file in its own `bun test` process
|
||||||
|
(per-file isolation with a wall timeout) so a hanging test or leaked global in
|
||||||
|
one file cannot cascade into the rest of the lane. `--jobs N` parallelizes;
|
||||||
|
`--single-process` restores the shared-process mode for debugging.
|
||||||
|
- `bun run test:fast` is the full source gate: discovered `src/**`, launcher
|
||||||
|
unit, `scripts/**`, and the compiled runtime-compat slice.
|
||||||
|
|
||||||
## Default Handoff Gate
|
## Default Handoff Gate
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -31,6 +43,8 @@ bun run docs:build
|
|||||||
- Config/schema/defaults: `bun run test:config`, then `bun run generate:config-example` if template/defaults changed
|
- Config/schema/defaults: `bun run test:config`, then `bun run generate:config-example` if template/defaults changed
|
||||||
- Launcher/plugin: `bun run test:launcher` or `bun run test:env`
|
- Launcher/plugin: `bun run test:launcher` or `bun run test:env`
|
||||||
- Runtime-compat / compiled behavior: `bun run test:runtime:compat`
|
- Runtime-compat / compiled behavior: `bun run test:runtime:compat`
|
||||||
|
- Stats dashboard UI: `bun run test:stats`
|
||||||
|
- Build/release scripts (`scripts/**`): `bun run test:scripts`
|
||||||
- Coverage for the maintained source lane: `bun run test:coverage:src`
|
- Coverage for the maintained source lane: `bun run test:coverage:src`
|
||||||
- Deep/local full gate: default handoff gate above
|
- Deep/local full gate: default handoff gate above
|
||||||
|
|
||||||
|
|||||||
+49
-16
@@ -65,11 +65,36 @@ function makeTestEnv(homeDir: string, xdgConfigHome: string): NodeJS.ProcessEnv
|
|||||||
APPDATA: xdgConfigHome,
|
APPDATA: xdgConfigHome,
|
||||||
LOCALAPPDATA: path.join(homeDir, 'AppData', 'Local'),
|
LOCALAPPDATA: path.join(homeDir, 'AppData', 'Local'),
|
||||||
XDG_CONFIG_HOME: xdgConfigHome,
|
XDG_CONFIG_HOME: xdgConfigHome,
|
||||||
|
// Pin the data dir under the temp home so the Linux runtime-plugin preflight
|
||||||
|
// resolves managed asset paths deterministically (not the CI runner's).
|
||||||
|
XDG_DATA_HOME: path.join(homeDir, '.local', 'share'),
|
||||||
PATH: pathValue,
|
PATH: pathValue,
|
||||||
Path: pathValue,
|
Path: pathValue,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On Linux the playback path runs `ensureLinuxRuntimePluginAvailable`, which —
|
||||||
|
// when the runtime plugin/theme are missing — spawns the app with
|
||||||
|
// `--ensure-linux-runtime-plugin-assets` and polls up to 30s
|
||||||
|
// (RESPONSE_TIMEOUT_MS) for an install response. A fake app that just exits
|
||||||
|
// never writes that response, so the launcher hangs and the test times out on
|
||||||
|
// Linux CI (the preflight is a no-op on macOS/Windows). This shell prelude makes
|
||||||
|
// the fake app install the managed plugin/theme and write the response, matching
|
||||||
|
// launcher/smoke.e2e.test.ts. Prepend it to each fake app that reaches playback.
|
||||||
|
const RUNTIME_PLUGIN_PREFLIGHT_SH = `if [ "$1" = "--ensure-linux-runtime-plugin-assets" ]; then
|
||||||
|
data="\${XDG_DATA_HOME:-$HOME/.local/share}/SubMiner"
|
||||||
|
mkdir -p "$data/plugin/subminer" "$data/themes"
|
||||||
|
printf -- '-- test plugin\\n' > "$data/plugin/subminer/main.lua"
|
||||||
|
printf 'test=true\\n' > "$data/plugin/subminer.conf"
|
||||||
|
printf '/* test theme */\\n' > "$data/themes/subminer.rasi"
|
||||||
|
if [ "$2" = "--ensure-linux-runtime-plugin-assets-response-path" ] && [ -n "$3" ]; then
|
||||||
|
mkdir -p "$(dirname "$3")"
|
||||||
|
printf '{"ok":true,"status":"installed","path":"%s"}' "$data/plugin/subminer/main.lua" > "$3"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
`;
|
||||||
|
|
||||||
test('config path uses XDG_CONFIG_HOME override', () => {
|
test('config path uses XDG_CONFIG_HOME override', () => {
|
||||||
withTempDir((root) => {
|
withTempDir((root) => {
|
||||||
const xdgConfigHome = path.join(root, 'xdg');
|
const xdgConfigHome = path.join(root, 'xdg');
|
||||||
@@ -237,7 +262,7 @@ test('doctor refresh-known-words forwards app refresh command without requiring
|
|||||||
const capturePath = path.join(root, 'captured-args.txt');
|
const capturePath = path.join(root, 'captured-args.txt');
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
appPath,
|
appPath,
|
||||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||||
);
|
);
|
||||||
fs.chmodSync(appPath, 0o755);
|
fs.chmodSync(appPath, 0o755);
|
||||||
|
|
||||||
@@ -264,7 +289,7 @@ test('launcher settings option forwards app settings window command', () => {
|
|||||||
const capturePath = path.join(root, 'captured-args.txt');
|
const capturePath = path.join(root, 'captured-args.txt');
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
appPath,
|
appPath,
|
||||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||||
);
|
);
|
||||||
fs.chmodSync(appPath, 0o755);
|
fs.chmodSync(appPath, 0o755);
|
||||||
|
|
||||||
@@ -288,7 +313,7 @@ test('launcher settings command forwards app settings window command', () => {
|
|||||||
const capturePath = path.join(root, 'captured-args.txt');
|
const capturePath = path.join(root, 'captured-args.txt');
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
appPath,
|
appPath,
|
||||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||||
);
|
);
|
||||||
fs.chmodSync(appPath, 0o755);
|
fs.chmodSync(appPath, 0o755);
|
||||||
|
|
||||||
@@ -369,7 +394,7 @@ test('launcher forwards --args to mpv as parsed tokens', { timeout: 15000 }, ()
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
|
fs.writeFileSync(appPath, `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}exit 0\n`);
|
||||||
fs.chmodSync(appPath, 0o755);
|
fs.chmodSync(appPath, 0o755);
|
||||||
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
@@ -460,7 +485,7 @@ test('launcher forwards non-info log level into mpv logging args', { timeout: 15
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
|
fs.writeFileSync(appPath, `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}exit 0\n`);
|
||||||
fs.chmodSync(appPath, 0o755);
|
fs.chmodSync(appPath, 0o755);
|
||||||
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
@@ -539,7 +564,7 @@ test('launcher routes youtube urls through regular playback startup', { timeout:
|
|||||||
);
|
);
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
appPath,
|
appPath,
|
||||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||||
);
|
);
|
||||||
fs.chmodSync(appPath, 0o755);
|
fs.chmodSync(appPath, 0o755);
|
||||||
|
|
||||||
@@ -566,17 +591,25 @@ ${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); con
|
|||||||
fs.chmodSync(path.join(binDir, 'yt-dlp'), 0o755);
|
fs.chmodSync(path.join(binDir, 'yt-dlp'), 0o755);
|
||||||
fs.chmodSync(path.join(binDir, 'ffmpeg'), 0o755);
|
fs.chmodSync(path.join(binDir, 'ffmpeg'), 0o755);
|
||||||
|
|
||||||
|
// Note: no SUBMINER_TEST_CAPTURE here. When set, the launcher intercepts
|
||||||
|
// *every* app command — including the Linux runtime-plugin preflight's
|
||||||
|
// `--ensure-linux-runtime-plugin-assets` install — and returns without
|
||||||
|
// running the fake app, so the preflight would poll 30s for a response that
|
||||||
|
// never arrives and time out. This test asserts on the mpv args, not on
|
||||||
|
// captured app args, so capture isn't needed.
|
||||||
const env = {
|
const env = {
|
||||||
...makeTestEnv(homeDir, xdgConfigHome),
|
...makeTestEnv(homeDir, xdgConfigHome),
|
||||||
PATH: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`,
|
PATH: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`,
|
||||||
Path: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`,
|
Path: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`,
|
||||||
DISPLAY: ':99',
|
|
||||||
XDG_SESSION_TYPE: 'x11',
|
|
||||||
SUBMINER_APPIMAGE_PATH: appPath,
|
SUBMINER_APPIMAGE_PATH: appPath,
|
||||||
SUBMINER_TEST_MPV_ARGS: mpvArgsPath,
|
SUBMINER_TEST_MPV_ARGS: mpvArgsPath,
|
||||||
SUBMINER_TEST_CAPTURE: path.join(root, 'captured-args.txt'),
|
|
||||||
};
|
};
|
||||||
const result = runLauncher(['https://www.youtube.com/watch?v=abc123'], env);
|
// Pass an explicit backend so overlay startup doesn't probe for a display
|
||||||
|
// (headless CI has none), matching launcher/smoke.e2e.test.ts.
|
||||||
|
const result = runLauncher(
|
||||||
|
['--backend', 'x11', 'https://www.youtube.com/watch?v=abc123'],
|
||||||
|
env,
|
||||||
|
);
|
||||||
|
|
||||||
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
||||||
const forwardedArgs = fs
|
const forwardedArgs = fs
|
||||||
@@ -597,7 +630,7 @@ test('dictionary command forwards --dictionary and --dictionary-target to app co
|
|||||||
const capturePath = path.join(root, 'captured-args.txt');
|
const capturePath = path.join(root, 'captured-args.txt');
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
appPath,
|
appPath,
|
||||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||||
);
|
);
|
||||||
fs.chmodSync(appPath, 0o755);
|
fs.chmodSync(appPath, 0o755);
|
||||||
|
|
||||||
@@ -626,7 +659,7 @@ test('dictionary command forwards manual AniList selection modes to app command
|
|||||||
const capturePath = path.join(root, 'captured-args.txt');
|
const capturePath = path.join(root, 'captured-args.txt');
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
appPath,
|
appPath,
|
||||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||||
);
|
);
|
||||||
fs.chmodSync(appPath, 0o755);
|
fs.chmodSync(appPath, 0o755);
|
||||||
|
|
||||||
@@ -763,7 +796,7 @@ test('jellyfin discovery routes to app --background and remote announce with log
|
|||||||
const capturePath = path.join(root, 'captured-args.txt');
|
const capturePath = path.join(root, 'captured-args.txt');
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
appPath,
|
appPath,
|
||||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||||
);
|
);
|
||||||
fs.chmodSync(appPath, 0o755);
|
fs.chmodSync(appPath, 0o755);
|
||||||
|
|
||||||
@@ -790,7 +823,7 @@ test('jellyfin discovery via jf alias forwards remote announce for cast visibili
|
|||||||
const capturePath = path.join(root, 'captured-args.txt');
|
const capturePath = path.join(root, 'captured-args.txt');
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
appPath,
|
appPath,
|
||||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||||
);
|
);
|
||||||
fs.chmodSync(appPath, 0o755);
|
fs.chmodSync(appPath, 0o755);
|
||||||
|
|
||||||
@@ -817,7 +850,7 @@ test('jellyfin login routes credentials to app command', () => {
|
|||||||
const capturePath = path.join(root, 'captured-args.txt');
|
const capturePath = path.join(root, 'captured-args.txt');
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
appPath,
|
appPath,
|
||||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||||
);
|
);
|
||||||
fs.chmodSync(appPath, 0o755);
|
fs.chmodSync(appPath, 0o755);
|
||||||
|
|
||||||
@@ -856,7 +889,7 @@ test('jellyfin setup forwards password-store to app command', () => {
|
|||||||
const capturePath = path.join(root, 'captured-args.txt');
|
const capturePath = path.join(root, 'captured-args.txt');
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
appPath,
|
appPath,
|
||||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||||
);
|
);
|
||||||
fs.chmodSync(appPath, 0o755);
|
fs.chmodSync(appPath, 0o755);
|
||||||
|
|
||||||
|
|||||||
+7
-10
File diff suppressed because one or more lines are too long
@@ -105,6 +105,8 @@ bash plugins/subminer-workflow/skills/subminer-change-verification/scripts/verif
|
|||||||
- For `docs-site/`, `docs/`, and doc-only edits.
|
- For `docs-site/`, `docs/`, and doc-only edits.
|
||||||
- `config`
|
- `config`
|
||||||
- For `src/config/` and config-template-sensitive edits.
|
- For `src/config/` and config-template-sensitive edits.
|
||||||
|
- `stats`
|
||||||
|
- For `stats/` dashboard UI edits.
|
||||||
- `core`
|
- `core`
|
||||||
- For general source changes where `typecheck` + `test:fast` is the best cheap signal.
|
- For general source changes where `typecheck` + `test:fast` is the best cheap signal.
|
||||||
- `launcher-plugin`
|
- `launcher-plugin`
|
||||||
|
|||||||
+8
@@ -108,6 +108,14 @@ for path in "${PATHS[@]}"; do
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
case "$path" in
|
||||||
|
stats/*)
|
||||||
|
add_lane "stats"
|
||||||
|
add_reason "$path -> stats"
|
||||||
|
specialized=1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
case "$path" in
|
case "$path" in
|
||||||
launcher/*|plugin/subminer/*|plugin/subminer.conf|scripts/test-plugin-*|scripts/get-mpv-window-*|scripts/configure-plugin-binary-path.mjs)
|
launcher/*|plugin/subminer/*|plugin/subminer.conf|scripts/test-plugin-*|scripts/get-mpv-window-*|scripts/configure-plugin-binary-path.mjs)
|
||||||
add_lane "launcher-plugin"
|
add_lane "launcher-plugin"
|
||||||
|
|||||||
+15
-2
@@ -255,8 +255,18 @@ write_summary_files() {
|
|||||||
local lane_lines
|
local lane_lines
|
||||||
lane_lines=$(printf '%s\n' "${SELECTED_LANES[@]}")
|
lane_lines=$(printf '%s\n' "${SELECTED_LANES[@]}")
|
||||||
printf '%s\n' "$lane_lines" >"$ARTIFACT_DIR/lanes.txt"
|
printf '%s\n' "$lane_lines" >"$ARTIFACT_DIR/lanes.txt"
|
||||||
printf '%s\n' "${BLOCKERS[@]}" >"$ARTIFACT_DIR/blockers.txt"
|
# bash 3.2 raises "unbound variable" under set -u when expanding an empty
|
||||||
printf '%s\n' "${PATH_ARGS[@]}" >"$ARTIFACT_DIR/requested-paths.txt"
|
# array, so guard on length (matching the idiom used elsewhere here).
|
||||||
|
if [[ ${#BLOCKERS[@]} -gt 0 ]]; then
|
||||||
|
printf '%s\n' "${BLOCKERS[@]}" >"$ARTIFACT_DIR/blockers.txt"
|
||||||
|
else
|
||||||
|
: >"$ARTIFACT_DIR/blockers.txt"
|
||||||
|
fi
|
||||||
|
if [[ ${#PATH_ARGS[@]} -gt 0 ]]; then
|
||||||
|
printf '%s\n' "${PATH_ARGS[@]}" >"$ARTIFACT_DIR/requested-paths.txt"
|
||||||
|
else
|
||||||
|
: >"$ARTIFACT_DIR/requested-paths.txt"
|
||||||
|
fi
|
||||||
|
|
||||||
ARTIFACT_DIR_ENV="$ARTIFACT_DIR" \
|
ARTIFACT_DIR_ENV="$ARTIFACT_DIR" \
|
||||||
SESSION_ID_ENV="$SESSION_ID" \
|
SESSION_ID_ENV="$SESSION_ID" \
|
||||||
@@ -482,6 +492,9 @@ for lane in "${SELECTED_LANES[@]}"; do
|
|||||||
config)
|
config)
|
||||||
run_step "$lane" "config" "bun run test:config" || break
|
run_step "$lane" "config" "bun run test:config" || break
|
||||||
;;
|
;;
|
||||||
|
stats)
|
||||||
|
run_step "$lane" "stats" "bun run test:stats" || break
|
||||||
|
;;
|
||||||
core)
|
core)
|
||||||
run_step "$lane" "typecheck" "bun run typecheck" || break
|
run_step "$lane" "typecheck" "bun run typecheck" || break
|
||||||
run_step "$lane" "fast-tests" "bun run test:fast" || break
|
run_step "$lane" "fast-tests" "bun run test:fast" || break
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
import { isAbsolute, join, relative, resolve } from 'node:path';
|
import { isAbsolute, join, relative, resolve } from 'node:path';
|
||||||
|
import { collectLaneFiles } from './test-lanes';
|
||||||
type LaneConfig = {
|
|
||||||
roots: string[];
|
|
||||||
include: string[];
|
|
||||||
exclude: Set<string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type LcovRecord = {
|
type LcovRecord = {
|
||||||
sourceFile: string;
|
sourceFile: string;
|
||||||
@@ -18,64 +13,6 @@ type LcovRecord = {
|
|||||||
|
|
||||||
const repoRoot = resolve(__dirname, '..');
|
const repoRoot = resolve(__dirname, '..');
|
||||||
|
|
||||||
const lanes: Record<string, LaneConfig> = {
|
|
||||||
'bun-src-full': {
|
|
||||||
roots: ['src'],
|
|
||||||
include: ['.test.ts', '.type-test.ts'],
|
|
||||||
exclude: new Set([
|
|
||||||
'src/core/services/anki-jimaku-ipc.test.ts',
|
|
||||||
'src/core/services/ipc.test.ts',
|
|
||||||
'src/core/services/overlay-manager.test.ts',
|
|
||||||
'src/main/config-validation.test.ts',
|
|
||||||
'src/main/runtime/registry.test.ts',
|
|
||||||
'src/main/runtime/startup-config.test.ts',
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
'bun-launcher-unit': {
|
|
||||||
roots: ['launcher'],
|
|
||||||
include: ['.test.ts'],
|
|
||||||
exclude: new Set(['launcher/smoke.e2e.test.ts']),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function collectFiles(
|
|
||||||
rootDir: string,
|
|
||||||
includeSuffixes: string[],
|
|
||||||
excludeSet: Set<string>,
|
|
||||||
): string[] {
|
|
||||||
const out: string[] = [];
|
|
||||||
const visit = (currentDir: string) => {
|
|
||||||
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
|
|
||||||
const fullPath = resolve(currentDir, entry.name);
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
visit(fullPath);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const relPath = relative(repoRoot, fullPath).replaceAll('\\', '/');
|
|
||||||
if (excludeSet.has(relPath)) continue;
|
|
||||||
if (includeSuffixes.some((suffix) => relPath.endsWith(suffix))) {
|
|
||||||
out.push(relPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
visit(resolve(repoRoot, rootDir));
|
|
||||||
out.sort();
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLaneFiles(laneName: string): string[] {
|
|
||||||
const lane = lanes[laneName];
|
|
||||||
if (!lane) {
|
|
||||||
throw new Error(`Unknown coverage lane: ${laneName}`);
|
|
||||||
}
|
|
||||||
const files = lane.roots.flatMap((rootDir) => collectFiles(rootDir, lane.include, lane.exclude));
|
|
||||||
if (files.length === 0) {
|
|
||||||
throw new Error(`No test files found for coverage lane: ${laneName}`);
|
|
||||||
}
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCoverageDirArg(argv: string[]): string {
|
function parseCoverageDirArg(argv: string[]): string {
|
||||||
for (let index = 0; index < argv.length; index += 1) {
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
if (argv[index] === '--coverage-dir') {
|
if (argv[index] === '--coverage-dir') {
|
||||||
@@ -277,7 +214,13 @@ function runCoverageLane(): number {
|
|||||||
rmSync(shardRoot, { recursive: true, force: true });
|
rmSync(shardRoot, { recursive: true, force: true });
|
||||||
mkdirSync(shardRoot, { recursive: true });
|
mkdirSync(shardRoot, { recursive: true });
|
||||||
|
|
||||||
const files = getLaneFiles(laneName);
|
let files: string[];
|
||||||
|
try {
|
||||||
|
files = collectLaneFiles(repoRoot, laneName);
|
||||||
|
} catch (error) {
|
||||||
|
process.stderr.write(`${error instanceof Error ? error.message : error}\n`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
const reports: string[] = [];
|
const reports: string[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
+133
-53
@@ -1,73 +1,153 @@
|
|||||||
import { readdirSync } from 'node:fs';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { relative, resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import { spawnSync } from 'node:child_process';
|
import { spawn, spawnSync } from 'node:child_process';
|
||||||
|
import { collectLaneFiles } from './test-lanes.ts';
|
||||||
|
|
||||||
|
// Runs a test lane with per-file process isolation: one `bun test` process per
|
||||||
|
// test file so a hanging test or leaked global in one file cannot poison the
|
||||||
|
// rest of the lane. Use --single-process for the old all-in-one-process mode.
|
||||||
|
//
|
||||||
|
// Usage: bun scripts/run-test-lane.mjs <lane> [--jobs N] [--timeout-secs N] [--single-process]
|
||||||
|
|
||||||
const repoRoot = resolve(fileURLToPath(new URL('..', import.meta.url)));
|
const repoRoot = resolve(fileURLToPath(new URL('..', import.meta.url)));
|
||||||
|
|
||||||
const lanes = {
|
// Cap per-file buffered output so a long or noisy test cannot grow the string
|
||||||
'bun-src-full': {
|
// without bound and exhaust memory.
|
||||||
roots: ['src'],
|
const MAX_OUTPUT_BYTES = 1024 * 1024;
|
||||||
include: ['.test.ts', '.type-test.ts'],
|
|
||||||
exclude: new Set([
|
|
||||||
'src/core/services/anki-jimaku-ipc.test.ts',
|
|
||||||
'src/core/services/ipc.test.ts',
|
|
||||||
'src/core/services/overlay-manager.test.ts',
|
|
||||||
'src/main/config-validation.test.ts',
|
|
||||||
'src/main/runtime/registry.test.ts',
|
|
||||||
'src/main/runtime/startup-config.test.ts',
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
'bun-launcher-unit': {
|
|
||||||
roots: ['launcher'],
|
|
||||||
include: ['.test.ts'],
|
|
||||||
exclude: new Set(['launcher/smoke.e2e.test.ts']),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function collectFiles(rootDir, includeSuffixes, excludeSet) {
|
// Track spawned `bun test` children so we can kill them if the runner is
|
||||||
const out = [];
|
// interrupted, avoiding orphaned in-flight test processes.
|
||||||
const visit = (currentDir) => {
|
const activeChildren = new Set();
|
||||||
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
|
|
||||||
const fullPath = resolve(currentDir, entry.name);
|
function terminateChildren() {
|
||||||
if (entry.isDirectory()) {
|
for (const child of activeChildren) {
|
||||||
visit(fullPath);
|
child.kill('SIGKILL');
|
||||||
continue;
|
}
|
||||||
|
activeChildren.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||||
|
process.on(signal, () => {
|
||||||
|
terminateChildren();
|
||||||
|
process.exit(130);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const options = { lane: undefined, jobs: 1, timeoutSecs: 300, singleProcess: false };
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const arg = argv[index];
|
||||||
|
if (arg === '--jobs') {
|
||||||
|
options.jobs = Math.max(1, Number(argv[(index += 1)]) || 1);
|
||||||
|
} else if (arg === '--timeout-secs') {
|
||||||
|
options.timeoutSecs = Math.max(1, Number(argv[(index += 1)]) || 300);
|
||||||
|
} else if (arg === '--single-process') {
|
||||||
|
options.singleProcess = true;
|
||||||
|
} else if (!arg.startsWith('--') && options.lane === undefined) {
|
||||||
|
options.lane = arg;
|
||||||
|
} else {
|
||||||
|
process.stderr.write(`Unknown argument: ${arg}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runFile(file, timeoutSecs) {
|
||||||
|
return new Promise((resolvePromise) => {
|
||||||
|
const child = spawn('bun', ['test', `./${file}`], { cwd: repoRoot });
|
||||||
|
activeChildren.add(child);
|
||||||
|
let output = '';
|
||||||
|
let truncated = false;
|
||||||
|
let timedOut = false;
|
||||||
|
const append = (chunk) => {
|
||||||
|
if (truncated) return;
|
||||||
|
output += chunk;
|
||||||
|
if (output.length > MAX_OUTPUT_BYTES) {
|
||||||
|
output = `${output.slice(0, MAX_OUTPUT_BYTES)}\n[output truncated at ${MAX_OUTPUT_BYTES} bytes]\n`;
|
||||||
|
truncated = true;
|
||||||
}
|
}
|
||||||
const relPath = relative(repoRoot, fullPath).replaceAll('\\', '/');
|
};
|
||||||
if (excludeSet.has(relPath)) continue;
|
child.stdout.on('data', append);
|
||||||
if (includeSuffixes.some((suffix) => relPath.endsWith(suffix))) {
|
child.stderr.on('data', append);
|
||||||
out.push(relPath);
|
const timer = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
}, timeoutSecs * 1000);
|
||||||
|
child.on('close', (code) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
activeChildren.delete(child);
|
||||||
|
resolvePromise({ file, code: timedOut ? 124 : (code ?? 1), output, timedOut });
|
||||||
|
});
|
||||||
|
child.on('error', (error) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
activeChildren.delete(child);
|
||||||
|
resolvePromise({ file, code: 1, output: String(error), timedOut: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runIsolated(files, options) {
|
||||||
|
const failures = [];
|
||||||
|
let nextIndex = 0;
|
||||||
|
let completed = 0;
|
||||||
|
|
||||||
|
async function worker() {
|
||||||
|
while (nextIndex < files.length) {
|
||||||
|
const file = files[nextIndex];
|
||||||
|
nextIndex += 1;
|
||||||
|
const result = await runFile(file, options.timeoutSecs);
|
||||||
|
completed += 1;
|
||||||
|
if (result.code !== 0) {
|
||||||
|
failures.push(result);
|
||||||
|
const reason = result.timedOut ? `timed out after ${options.timeoutSecs}s` : 'failed';
|
||||||
|
process.stderr.write(`\n[${completed}/${files.length}] ${file} ${reason}\n`);
|
||||||
|
process.stderr.write(result.output);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
visit(resolve(repoRoot, rootDir));
|
await Promise.all(Array.from({ length: Math.min(options.jobs, files.length) }, worker));
|
||||||
out.sort();
|
|
||||||
return out;
|
if (failures.length > 0) {
|
||||||
|
process.stderr.write(`\n${failures.length} of ${files.length} test files failed:\n`);
|
||||||
|
for (const failure of failures) {
|
||||||
|
process.stderr.write(` ${failure.file}${failure.timedOut ? ' (timeout)' : ''}\n`);
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
process.stdout.write(`All ${files.length} test files passed.\n`);
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lane = lanes[process.argv[2]];
|
function runSingleProcess(files) {
|
||||||
|
const result = spawnSync('bun', ['test', ...files.map((file) => `./${file}`)], {
|
||||||
|
cwd: repoRoot,
|
||||||
|
stdio: 'inherit',
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
throw result.error;
|
||||||
|
}
|
||||||
|
return result.status ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
if (!lane) {
|
const options = parseArgs(process.argv.slice(2));
|
||||||
process.stderr.write(`Unknown test lane: ${process.argv[2] ?? '(missing)'}\n`);
|
|
||||||
|
if (!options.lane) {
|
||||||
|
process.stderr.write('Missing test lane name\n');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = lane.roots.flatMap((rootDir) => collectFiles(rootDir, lane.include, lane.exclude));
|
let files;
|
||||||
|
try {
|
||||||
if (files.length === 0) {
|
files = collectLaneFiles(repoRoot, options.lane);
|
||||||
process.stderr.write(`No test files found for lane: ${process.argv[2]}\n`);
|
} catch (error) {
|
||||||
|
process.stderr.write(`${error instanceof Error ? error.message : error}\n`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = spawnSync('bun', ['test', ...files.map((file) => `./${file}`)], {
|
if (options.singleProcess) {
|
||||||
cwd: repoRoot,
|
process.exit(runSingleProcess(files));
|
||||||
stdio: 'inherit',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
throw result.error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
process.exit(result.status ?? 1);
|
process.exit(await runIsolated(files, options));
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { readdirSync } from 'node:fs';
|
||||||
|
import { relative, resolve } from 'node:path';
|
||||||
|
|
||||||
|
export type TestLane = {
|
||||||
|
roots: string[];
|
||||||
|
include: string[];
|
||||||
|
exclude?: string[];
|
||||||
|
extraFiles?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Single source of truth for test-lane membership. Consumed by
|
||||||
|
// scripts/run-test-lane.mjs (plain runs) and scripts/run-coverage-lane.ts
|
||||||
|
// (per-file coverage shards). Lanes discover files by directory so new test
|
||||||
|
// files join their lane automatically.
|
||||||
|
export const testLanes: Record<string, TestLane> = {
|
||||||
|
'bun-src-full': {
|
||||||
|
roots: ['src'],
|
||||||
|
include: ['.test.ts', '.type-test.ts'],
|
||||||
|
// Node-compat suites; their dist builds run via test:runtime:compat.
|
||||||
|
exclude: [
|
||||||
|
'src/core/services/anki-jimaku-ipc.test.ts',
|
||||||
|
'src/core/services/ipc.test.ts',
|
||||||
|
'src/core/services/overlay-manager.test.ts',
|
||||||
|
'src/main/config-validation.test.ts',
|
||||||
|
'src/main/runtime/registry.test.ts',
|
||||||
|
'src/main/runtime/startup-config.test.ts',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
roots: ['src/config'],
|
||||||
|
include: ['.test.ts'],
|
||||||
|
extraFiles: ['src/generate-config-example.test.ts', 'src/verify-config-example.test.ts'],
|
||||||
|
},
|
||||||
|
launcher: {
|
||||||
|
roots: ['launcher'],
|
||||||
|
include: ['.test.ts'],
|
||||||
|
},
|
||||||
|
'bun-launcher-unit': {
|
||||||
|
roots: ['launcher'],
|
||||||
|
include: ['.test.ts'],
|
||||||
|
exclude: ['launcher/smoke.e2e.test.ts'],
|
||||||
|
},
|
||||||
|
scripts: {
|
||||||
|
roots: ['scripts'],
|
||||||
|
include: ['.test.ts'],
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
roots: ['stats/src'],
|
||||||
|
include: ['.test.ts', '.test.tsx'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function collectFiles(
|
||||||
|
repoRoot: string,
|
||||||
|
rootDir: string,
|
||||||
|
includeSuffixes: string[],
|
||||||
|
excludeSet: Set<string>,
|
||||||
|
): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
const visit = (currentDir: string): void => {
|
||||||
|
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
|
||||||
|
const fullPath = resolve(currentDir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
visit(fullPath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const relPath = relative(repoRoot, fullPath).replaceAll('\\', '/');
|
||||||
|
if (excludeSet.has(relPath)) continue;
|
||||||
|
if (includeSuffixes.some((suffix) => relPath.endsWith(suffix))) {
|
||||||
|
out.push(relPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
visit(resolve(repoRoot, rootDir));
|
||||||
|
out.sort();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectLaneFiles(repoRoot: string, laneName: string): string[] {
|
||||||
|
const lane = testLanes[laneName];
|
||||||
|
if (!lane) {
|
||||||
|
throw new Error(`Unknown test lane: ${laneName}`);
|
||||||
|
}
|
||||||
|
const excludeSet = new Set(lane.exclude ?? []);
|
||||||
|
const files = lane.roots.flatMap((rootDir) =>
|
||||||
|
collectFiles(repoRoot, rootDir, lane.include, excludeSet),
|
||||||
|
);
|
||||||
|
for (const extra of lane.extraFiles ?? []) {
|
||||||
|
if (!files.includes(extra)) files.push(extra);
|
||||||
|
}
|
||||||
|
files.sort();
|
||||||
|
if (files.length === 0) {
|
||||||
|
throw new Error(`No test files found for lane: ${laneName}`);
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { appendClipboardVideoToQueueRuntime } from './clipboard-queue';
|
import { appendClipboardVideoToQueueRuntime } from './clipboard-queue';
|
||||||
|
|
||||||
@@ -27,7 +28,8 @@ test('appendClipboardVideoToQueueRuntime rejects unsupported clipboard path', ()
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('appendClipboardVideoToQueueRuntime queues readable media file', () => {
|
test('appendClipboardVideoToQueueRuntime queues readable media file', () => {
|
||||||
const tempPath = path.join(process.cwd(), 'dist', 'clipboard-queue-test-video.mkv');
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'clipboard-queue-'));
|
||||||
|
const tempPath = path.join(tempDir, 'clipboard-queue-test-video.mkv');
|
||||||
fs.writeFileSync(tempPath, 'stub');
|
fs.writeFileSync(tempPath, 'stub');
|
||||||
|
|
||||||
const commands: Array<(string | number)[]> = [];
|
const commands: Array<(string | number)[]> = [];
|
||||||
@@ -43,5 +45,5 @@ test('appendClipboardVideoToQueueRuntime queues readable media file', () => {
|
|||||||
assert.deepEqual(commands[0], ['loadfile', tempPath, 'append']);
|
assert.deepEqual(commands[0], ['loadfile', tempPath, 'append']);
|
||||||
assert.equal(osdMessages[0], `Queued from clipboard: ${path.basename(tempPath)}`);
|
assert.equal(osdMessages[0], `Queued from clipboard: ${path.basename(tempPath)}`);
|
||||||
|
|
||||||
fs.unlinkSync(tempPath);
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user