mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 04:49:49 -07:00
Compare commits
2 Commits
9f4888293b
...
2398f5c030
| Author | SHA1 | Date | |
|---|---|---|---|
| 2398f5c030 | |||
|
8711cf1a48
|
@@ -135,7 +135,7 @@ Focused commands:
|
||||
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: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:env # Launcher smoke + Lua plugin gate
|
||||
bun run test:src # Bun-managed maintained src/** discovery lane
|
||||
bun run test:launcher:unit:src # Bun-managed maintained launcher unit lane
|
||||
bun run test:scripts # Bun-managed scripts/** test lane
|
||||
|
||||
@@ -31,6 +31,8 @@ The desktop app keeps `src/main.ts` as composition root and pushes behavior into
|
||||
- `src/config/` owns config definitions, defaults, loading, and resolution.
|
||||
- `src/types/` owns shared cross-runtime contracts via domain entrypoints; `src/types.ts` stays a compatibility barrel.
|
||||
- `src/main/runtime/composers/` owns larger domain compositions.
|
||||
- `src/main.ts` call sites invoke configured runtime handlers and runtime-object methods directly;
|
||||
do not add local pass-through wrappers around them.
|
||||
|
||||
## Architecture Intent
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ Read when: selecting the right verification lane for a change
|
||||
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.
|
||||
unit, and `scripts/**`.
|
||||
- `.github/workflows/quality-gate.yml` is the reusable `workflow_call` gate for
|
||||
pull requests, stable tags, and prerelease tags. Keep common quality steps
|
||||
there instead of copying them into caller workflows.
|
||||
|
||||
@@ -632,8 +632,10 @@ test('adopted word frequency excludes active-session counts that merge later', (
|
||||
// Only the ended session's count is adopted; the active session's slice
|
||||
// is re-added when that session finalizes and syncs.
|
||||
assert.equal(
|
||||
queryOne<{ frequency: number }>(localPath, `SELECT frequency FROM imm_words WHERE word = '食べた'`)
|
||||
?.frequency,
|
||||
queryOne<{ frequency: number }>(
|
||||
localPath,
|
||||
`SELECT frequency FROM imm_words WHERE word = '食べた'`,
|
||||
)?.frequency,
|
||||
1,
|
||||
);
|
||||
|
||||
@@ -650,8 +652,10 @@ test('adopted word frequency excludes active-session counts that merge later', (
|
||||
assert.equal(second.sessionsAlreadyPresent, 1);
|
||||
// 1 (ended session) + 4 (finalized session), not 5 + 4 = 9.
|
||||
assert.equal(
|
||||
queryOne<{ frequency: number }>(localPath, `SELECT frequency FROM imm_words WHERE word = '食べた'`)
|
||||
?.frequency,
|
||||
queryOne<{ frequency: number }>(
|
||||
localPath,
|
||||
`SELECT frequency FROM imm_words WHERE word = '食べた'`,
|
||||
)?.frequency,
|
||||
5,
|
||||
);
|
||||
} finally {
|
||||
|
||||
+6
-12
@@ -47,13 +47,9 @@
|
||||
"docs:preview": "bun run --cwd docs-site docs:preview",
|
||||
"docs:test": "bun run --cwd docs-site test",
|
||||
"test:docs:kb": "bun test scripts/docs-knowledge-base.test.ts",
|
||||
"test:config:src": "bun scripts/run-test-lane.mjs config",
|
||||
"test:config:smoke:dist": "bun test dist/config/path-resolution.test.js",
|
||||
"test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-process-start-retries.lua && lua scripts/test-plugin-restart-feedback.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua",
|
||||
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
|
||||
"test:launcher:src": "bun scripts/run-test-lane.mjs launcher && bun run test:plugin:src",
|
||||
"test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
|
||||
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
|
||||
"test:smoke:dist": "bun scripts/run-test-lane.mjs bun-src-full",
|
||||
"test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts",
|
||||
"test:immersion:sqlite:src": "bun test src/core/services/immersion-tracker-service.test.ts src/core/services/immersion-tracker/storage-session.test.ts",
|
||||
"test:immersion:sqlite:dist": "bun test dist/core/services/immersion-tracker-service.test.js dist/core/services/immersion-tracker/storage-session.test.js",
|
||||
@@ -64,15 +60,13 @@
|
||||
"test:launcher:unit:src": "bun scripts/run-test-lane.mjs bun-launcher-unit",
|
||||
"test:scripts": "bun scripts/run-test-lane.mjs scripts",
|
||||
"test:stats": "bun scripts/run-test-lane.mjs stats",
|
||||
"test:launcher:env:src": "bun run test:launcher:smoke:src && bun run test:plugin:src",
|
||||
"test:env": "bun run test:launcher:env:src && bun run test:immersion:sqlite:src",
|
||||
"test:runtime:compat": "bun run tsc && bun test dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/overlay-manager.test.js dist/main/config-validation.test.js dist/main/runtime/registry.test.js dist/main/runtime/startup-config.test.js",
|
||||
"test:node:compat": "bun run test:runtime:compat",
|
||||
"test:env": "bun run test:launcher:smoke:src && bun run test:plugin:src && bun run test:immersion:sqlite:src",
|
||||
"test:runtime:compat": "bun run tsc && bun scripts/run-test-lane.mjs bun-src-full",
|
||||
"test": "bun run test:fast",
|
||||
"test:config": "bun run test:config:src",
|
||||
"test:launcher": "bun run test:launcher:src",
|
||||
"test:config": "bun scripts/run-test-lane.mjs config",
|
||||
"test:launcher": "bun scripts/run-test-lane.mjs launcher && bun run test:plugin:src",
|
||||
"test:subtitle": "bun run test:subtitle:src",
|
||||
"test:fast": "bun run test:src && bun run test:launcher:unit:src && bun run test:scripts && bun run test:runtime:compat",
|
||||
"test:fast": "bun run test:src && bun run test:launcher:unit:src && bun run test:scripts",
|
||||
"generate:config-example": "bun run src/generate-config-example.ts",
|
||||
"verify:config-example": "bun run src/verify-config-example.ts",
|
||||
"start": "bun run build && electron . --start",
|
||||
|
||||
@@ -16,15 +16,6 @@ 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'],
|
||||
|
||||
@@ -404,8 +404,7 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
path: 'tsukihime.apiBaseUrl',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.tsukihime.apiBaseUrl,
|
||||
description:
|
||||
'Base URL of the TsukiHime API (Animetosho successor). No API key required.',
|
||||
description: 'Base URL of the TsukiHime API (Animetosho successor). No API key required.',
|
||||
},
|
||||
{
|
||||
path: 'tsukihime.maxSearchResults',
|
||||
|
||||
@@ -302,9 +302,7 @@ export function registerAnkiJimakuIpcHandlers(
|
||||
await deps.onDownloadedSecondarySubtitle(result.path);
|
||||
}
|
||||
} else {
|
||||
logger.error(
|
||||
`[tsukihime] download-file failed: ${result.error?.error ?? 'unknown error'}`,
|
||||
);
|
||||
logger.error(`[tsukihime] download-file failed: ${result.error?.error ?? 'unknown error'}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -48,7 +48,10 @@ test('parseSyncCliTokens handles the temp-dir protocol modes', () => {
|
||||
}
|
||||
|
||||
assert.equal(parseSyncCliTokens(['sync', '--make-temp', 'host']).kind, 'error');
|
||||
assert.equal(parseSyncCliTokens(['sync', '--make-temp', '--remove-temp', '/tmp/x']).kind, 'error');
|
||||
assert.equal(
|
||||
parseSyncCliTokens(['sync', '--make-temp', '--remove-temp', '/tmp/x']).kind,
|
||||
'error',
|
||||
);
|
||||
});
|
||||
|
||||
test('parseSyncCliTokens owns the sync CLI validation rules', () => {
|
||||
|
||||
@@ -58,7 +58,9 @@ export function parseSyncCliTokens(tokens: readonly string[]): ParsedSyncCli {
|
||||
|
||||
for (let i = 0; i < rest.length; i += 1) {
|
||||
const token = rest[i]!;
|
||||
const assignValue = valueFlags.get(token.includes('=') ? token.slice(0, token.indexOf('=')) : token);
|
||||
const assignValue = valueFlags.get(
|
||||
token.includes('=') ? token.slice(0, token.indexOf('=')) : token,
|
||||
);
|
||||
if (assignValue) {
|
||||
if (token.includes('=')) {
|
||||
assignValue(token.slice(token.indexOf('=') + 1));
|
||||
@@ -109,7 +111,10 @@ export function parseSyncCliTokens(tokens: readonly string[]): ParsedSyncCli {
|
||||
Boolean(removeTemp),
|
||||
].filter(Boolean).length;
|
||||
if (modes === 0) {
|
||||
return { kind: 'error', message: 'Sync requires a host, --snapshot <file>, or --merge <file>.' };
|
||||
return {
|
||||
kind: 'error',
|
||||
message: 'Sync requires a host, --snapshot <file>, or --merge <file>.',
|
||||
};
|
||||
}
|
||||
if (modes > 1) {
|
||||
return {
|
||||
|
||||
@@ -34,9 +34,7 @@ export function resolveImmersionDbPath(): string {
|
||||
// no config or unreadable config → default location
|
||||
}
|
||||
if (configured) {
|
||||
return configured.startsWith('~')
|
||||
? path.join(os.homedir(), configured.slice(1))
|
||||
: configured;
|
||||
return configured.startsWith('~') ? path.join(os.homedir(), configured.slice(1)) : configured;
|
||||
}
|
||||
return path.join(getDefaultConfigDir(), 'immersion.sqlite');
|
||||
}
|
||||
|
||||
@@ -251,11 +251,7 @@ export function mergeMediaMetadata(
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeExcludedWords(
|
||||
local: SyncDb,
|
||||
remote: SyncDb,
|
||||
summary: SyncMergeSummary,
|
||||
): void {
|
||||
export function mergeExcludedWords(local: SyncDb, remote: SyncDb, summary: SyncMergeSummary): void {
|
||||
if (
|
||||
!tableExists(remote, 'imm_stats_excluded_words') ||
|
||||
!tableExists(local, 'imm_stats_excluded_words')
|
||||
|
||||
@@ -147,7 +147,9 @@ export function refreshRollupsForNewSessions(
|
||||
}
|
||||
|
||||
const stampMs = nowDbTimestamp();
|
||||
const deleteDaily = local.query('DELETE FROM imm_daily_rollups WHERE rollup_day = ? AND video_id = ?');
|
||||
const deleteDaily = local.query(
|
||||
'DELETE FROM imm_daily_rollups WHERE rollup_day = ? AND video_id = ?',
|
||||
);
|
||||
const deleteMonthly = local.query(
|
||||
'DELETE FROM imm_monthly_rollups WHERE rollup_month = ? AND video_id = ?',
|
||||
);
|
||||
|
||||
@@ -319,7 +319,8 @@ function applyMergedSessionLifetime(
|
||||
.get(videoId, sessionId),
|
||||
);
|
||||
const isFirstSessionForVideoRun = !mediaLifetime && !hasOtherSessionForVideo;
|
||||
const isFirstCompletedSessionForVideoRun = watched > 0 && Number(mediaLifetime?.completed ?? 0) <= 0;
|
||||
const isFirstCompletedSessionForVideoRun =
|
||||
watched > 0 && Number(mediaLifetime?.completed ?? 0) <= 0;
|
||||
|
||||
const hasOtherSessionOnDay = Boolean(
|
||||
local
|
||||
|
||||
@@ -8,11 +8,7 @@ import {
|
||||
} from './merge-catalog';
|
||||
import { mergeSessions } from './merge-sessions';
|
||||
import { copyRemoteOnlyRollups, refreshRollupsForNewSessions } from './merge-rollups';
|
||||
import {
|
||||
assertMergeableSchema,
|
||||
createEmptyMergeSummary,
|
||||
type SyncMergeSummary,
|
||||
} from './shared';
|
||||
import { assertMergeableSchema, createEmptyMergeSummary, type SyncMergeSummary } from './shared';
|
||||
import { openLibsqlSyncDb, type SyncDb } from './libsql-driver';
|
||||
|
||||
export type { SyncMergeSummary } from './shared';
|
||||
|
||||
+397
-659
File diff suppressed because it is too large
Load Diff
@@ -52,7 +52,13 @@ function runKeepaliveScript(
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
'/bin/sh',
|
||||
['-c', APPIMAGE_MOUNT_KEEPALIVE_SCRIPT, APPIMAGE_MOUNT_KEEPALIVE_LABEL, appImagePath, ...extraArgs],
|
||||
[
|
||||
'-c',
|
||||
APPIMAGE_MOUNT_KEEPALIVE_SCRIPT,
|
||||
APPIMAGE_MOUNT_KEEPALIVE_LABEL,
|
||||
appImagePath,
|
||||
...extraArgs,
|
||||
],
|
||||
{ timeout: 30_000 },
|
||||
(error) => {
|
||||
if (error && typeof error.code !== 'number') {
|
||||
@@ -69,113 +75,107 @@ function writeExecutable(filePath: string, content: string): void {
|
||||
fs.writeFileSync(filePath, content, { mode: 0o755 });
|
||||
}
|
||||
|
||||
test(
|
||||
'keepalive script releases the mount only after straggler processes exit',
|
||||
{ skip: process.platform !== 'linux' },
|
||||
async () => {
|
||||
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-keepalive-test-'));
|
||||
const resultsDir = path.join(workDir, 'results');
|
||||
fs.mkdirSync(resultsDir);
|
||||
const mountDir = path.join(workDir, 'fake-mount');
|
||||
fs.mkdirSync(mountDir);
|
||||
const linuxTest = process.platform === 'linux' ? test : test.skip;
|
||||
|
||||
// AppRun leaves behind a straggler that keeps executing *from the mount*
|
||||
// after AppRun itself exits — mimicking Chromium utility children.
|
||||
fs.copyFileSync('/usr/bin/sleep', path.join(mountDir, 'straggler'));
|
||||
fs.chmodSync(path.join(mountDir, 'straggler'), 0o755);
|
||||
writeExecutable(
|
||||
path.join(mountDir, 'AppRun'),
|
||||
[
|
||||
'#!/bin/sh',
|
||||
`"${mountDir}/straggler" 1 &`,
|
||||
`date +%s%N > "${resultsDir}/apprun-exited"`,
|
||||
'exit 42',
|
||||
].join('\n'),
|
||||
linuxTest('keepalive script releases the mount only after straggler processes exit', async () => {
|
||||
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-keepalive-test-'));
|
||||
const resultsDir = path.join(workDir, 'results');
|
||||
fs.mkdirSync(resultsDir);
|
||||
const mountDir = path.join(workDir, 'fake-mount');
|
||||
fs.mkdirSync(mountDir);
|
||||
|
||||
// AppRun leaves behind a straggler that keeps executing *from the mount*
|
||||
// after AppRun itself exits — mimicking Chromium utility children.
|
||||
fs.copyFileSync('/usr/bin/sleep', path.join(mountDir, 'straggler'));
|
||||
fs.chmodSync(path.join(mountDir, 'straggler'), 0o755);
|
||||
writeExecutable(
|
||||
path.join(mountDir, 'AppRun'),
|
||||
[
|
||||
'#!/bin/sh',
|
||||
`"${mountDir}/straggler" 1 &`,
|
||||
`date +%s%N > "${resultsDir}/apprun-exited"`,
|
||||
'exit 42',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const fakeAppImage = path.join(workDir, 'Fake.AppImage');
|
||||
writeExecutable(
|
||||
fakeAppImage,
|
||||
[
|
||||
'#!/bin/sh',
|
||||
'if [ "${1:-}" = "--appimage-mount" ]; then',
|
||||
` echo "${mountDir}"`,
|
||||
` trap ': > "${resultsDir}/holder-released"; sleep 0.1; date +%s%N > "${resultsDir}/holder-released"; exit 0' TERM INT`,
|
||||
' while :; do sleep 0.05; done',
|
||||
'fi',
|
||||
`date +%s%N > "${resultsDir}/direct-run"`,
|
||||
'exit 0',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
try {
|
||||
const { status } = await runKeepaliveScript(fakeAppImage);
|
||||
|
||||
assert.equal(status, 42, 'exit code of AppRun must be propagated');
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(resultsDir, 'direct-run')),
|
||||
'must not fall back to direct AppImage run when mount succeeds',
|
||||
);
|
||||
|
||||
const fakeAppImage = path.join(workDir, 'Fake.AppImage');
|
||||
writeExecutable(
|
||||
fakeAppImage,
|
||||
[
|
||||
'#!/bin/sh',
|
||||
'if [ "${1:-}" = "--appimage-mount" ]; then',
|
||||
` echo "${mountDir}"`,
|
||||
` trap ': > "${resultsDir}/holder-released"; sleep 0.1; date +%s%N > "${resultsDir}/holder-released"; exit 0' TERM INT`,
|
||||
' while :; do sleep 0.05; done',
|
||||
'fi',
|
||||
`date +%s%N > "${resultsDir}/direct-run"`,
|
||||
'exit 0',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
try {
|
||||
const { status } = await runKeepaliveScript(fakeAppImage);
|
||||
|
||||
assert.equal(status, 42, 'exit code of AppRun must be propagated');
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(resultsDir, 'direct-run')),
|
||||
'must not fall back to direct AppImage run when mount succeeds',
|
||||
);
|
||||
// The script does not wait for the holder to finish handling SIGTERM
|
||||
// (the real runtime unmounts on its own after the signal), so poll.
|
||||
const releasedMarker = path.join(resultsDir, 'holder-released');
|
||||
const pollDeadline = Date.now() + 2000;
|
||||
let holderReleased: number | null = null;
|
||||
while (holderReleased === null && Date.now() < pollDeadline) {
|
||||
if (fs.existsSync(releasedMarker)) {
|
||||
const timestamp = fs.readFileSync(releasedMarker, 'utf8').trim();
|
||||
if (/^\d+$/.test(timestamp)) holderReleased = Number(timestamp);
|
||||
}
|
||||
if (holderReleased !== null) break;
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
// The script does not wait for the holder to finish handling SIGTERM
|
||||
// (the real runtime unmounts on its own after the signal), so poll.
|
||||
const releasedMarker = path.join(resultsDir, 'holder-released');
|
||||
const pollDeadline = Date.now() + 2000;
|
||||
let holderReleased: number | null = null;
|
||||
while (holderReleased === null && Date.now() < pollDeadline) {
|
||||
if (fs.existsSync(releasedMarker)) {
|
||||
const timestamp = fs.readFileSync(releasedMarker, 'utf8').trim();
|
||||
if (/^\d+$/.test(timestamp)) holderReleased = Number(timestamp);
|
||||
}
|
||||
assert.ok(holderReleased !== null, 'holder release timestamp must be recorded');
|
||||
|
||||
const appRunExited = Number(
|
||||
fs.readFileSync(path.join(resultsDir, 'apprun-exited'), 'utf8').trim(),
|
||||
);
|
||||
const drainNs = holderReleased - appRunExited;
|
||||
assert.ok(
|
||||
drainNs >= 0.8e9,
|
||||
`holder must outlive the 1s straggler (drained after ${drainNs / 1e9}s)`,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(workDir, { recursive: true, force: true });
|
||||
if (holderReleased !== null) break;
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
}
|
||||
},
|
||||
);
|
||||
assert.ok(holderReleased !== null, 'holder release timestamp must be recorded');
|
||||
|
||||
test(
|
||||
'keepalive script falls back to direct run when --appimage-mount fails',
|
||||
{ skip: process.platform !== 'linux' },
|
||||
async () => {
|
||||
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-keepalive-test-'));
|
||||
const resultsDir = path.join(workDir, 'results');
|
||||
fs.mkdirSync(resultsDir);
|
||||
|
||||
const fakeAppImage = path.join(workDir, 'Fake.AppImage');
|
||||
writeExecutable(
|
||||
fakeAppImage,
|
||||
[
|
||||
'#!/bin/sh',
|
||||
'if [ "${1:-}" = "--appimage-mount" ]; then',
|
||||
' exit 1',
|
||||
'fi',
|
||||
`printf '%s\\n' "$@" > "${resultsDir}/direct-run"`,
|
||||
'exit 7',
|
||||
].join('\n'),
|
||||
const appRunExited = Number(
|
||||
fs.readFileSync(path.join(resultsDir, 'apprun-exited'), 'utf8').trim(),
|
||||
);
|
||||
const drainNs = holderReleased - appRunExited;
|
||||
assert.ok(
|
||||
drainNs >= 0.8e9,
|
||||
`holder must outlive the 1s straggler (drained after ${drainNs / 1e9}s)`,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(workDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const { status } = await runKeepaliveScript(fakeAppImage, ['--start', '--background']);
|
||||
assert.equal(status, 7, 'direct-run exit code must be propagated');
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(resultsDir, 'direct-run'), 'utf8'),
|
||||
'--start\n--background\n',
|
||||
'launch args must be forwarded to the direct run',
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(workDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
linuxTest('keepalive script falls back to direct run when --appimage-mount fails', async () => {
|
||||
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-keepalive-test-'));
|
||||
const resultsDir = path.join(workDir, 'results');
|
||||
fs.mkdirSync(resultsDir);
|
||||
|
||||
const fakeAppImage = path.join(workDir, 'Fake.AppImage');
|
||||
writeExecutable(
|
||||
fakeAppImage,
|
||||
[
|
||||
'#!/bin/sh',
|
||||
'if [ "${1:-}" = "--appimage-mount" ]; then',
|
||||
' exit 1',
|
||||
'fi',
|
||||
`printf '%s\\n' "$@" > "${resultsDir}/direct-run"`,
|
||||
'exit 7',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
try {
|
||||
const { status } = await runKeepaliveScript(fakeAppImage, ['--start', '--background']);
|
||||
assert.equal(status, 7, 'direct-run exit code must be propagated');
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(resultsDir, 'direct-run'), 'utf8'),
|
||||
'--start\n--background\n',
|
||||
'launch args must be forwarded to the direct run',
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(workDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import ts from 'typescript';
|
||||
|
||||
test('main process call sites bypass zero-logic pass-through wrappers', () => {
|
||||
const sourcePath = path.join(process.cwd(), 'src/main.ts');
|
||||
const source = fs.readFileSync(sourcePath, 'utf8');
|
||||
const sourceFile = ts.createSourceFile(
|
||||
sourcePath,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TS,
|
||||
);
|
||||
const offenders: string[] = [];
|
||||
|
||||
for (const statement of sourceFile.statements) {
|
||||
if (ts.isFunctionDeclaration(statement) && statement.name && statement.body) {
|
||||
if (isRuntimePassthrough(statement, statement.body)) {
|
||||
offenders.push(statement.name.text);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ts.isVariableStatement(statement)) continue;
|
||||
for (const declaration of statement.declarationList.declarations) {
|
||||
if (
|
||||
ts.isIdentifier(declaration.name) &&
|
||||
declaration.initializer &&
|
||||
(ts.isArrowFunction(declaration.initializer) ||
|
||||
ts.isFunctionExpression(declaration.initializer)) &&
|
||||
isRuntimePassthrough(declaration.initializer, declaration.initializer.body)
|
||||
) {
|
||||
offenders.push(declaration.name.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(offenders, []);
|
||||
});
|
||||
|
||||
function isRuntimePassthrough(
|
||||
callable: ts.FunctionDeclaration | ts.ArrowFunction | ts.FunctionExpression,
|
||||
body: ts.ConciseBody,
|
||||
): boolean {
|
||||
let expression: ts.Expression | undefined;
|
||||
if (!ts.isBlock(body)) {
|
||||
expression = body;
|
||||
} else {
|
||||
if (body.statements.length !== 1) return false;
|
||||
const statement = body.statements[0]!;
|
||||
if (ts.isExpressionStatement(statement)) expression = statement.expression;
|
||||
if (ts.isReturnStatement(statement)) expression = statement.expression;
|
||||
}
|
||||
if (!expression) return false;
|
||||
if (ts.isAwaitExpression(expression) || ts.isVoidExpression(expression)) {
|
||||
expression = expression.expression;
|
||||
}
|
||||
if (!ts.isCallExpression(expression)) return false;
|
||||
|
||||
const root = getCalleeRoot(expression.expression);
|
||||
if (!root) return false;
|
||||
if (callable.parameters.length !== expression.arguments.length) return false;
|
||||
|
||||
return callable.parameters.every((parameter, index) => {
|
||||
if (!ts.isIdentifier(parameter.name)) return false;
|
||||
const argument = expression.arguments[index];
|
||||
if (!argument) return false;
|
||||
if (parameter.dotDotDotToken) {
|
||||
if (!ts.isSpreadElement(argument)) return false;
|
||||
const spreadExpression = unwrapTransparentExpression(argument.expression);
|
||||
return ts.isIdentifier(spreadExpression) && spreadExpression.text === parameter.name.text;
|
||||
}
|
||||
const argumentExpression = unwrapTransparentExpression(argument);
|
||||
return ts.isIdentifier(argumentExpression) && argumentExpression.text === parameter.name.text;
|
||||
});
|
||||
}
|
||||
|
||||
function unwrapTransparentExpression(expression: ts.Expression): ts.Expression {
|
||||
let current = expression;
|
||||
while (
|
||||
ts.isParenthesizedExpression(current) ||
|
||||
ts.isAsExpression(current) ||
|
||||
ts.isNonNullExpression(current)
|
||||
) {
|
||||
current = current.expression;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function getCalleeRoot(expression: ts.LeftHandSideExpression): string {
|
||||
let current: ts.Expression = expression;
|
||||
while (ts.isPropertyAccessExpression(current) || ts.isCallExpression(current)) {
|
||||
current = current.expression;
|
||||
}
|
||||
return ts.isIdentifier(current) ? current.text : '';
|
||||
}
|
||||
@@ -446,7 +446,10 @@ test('Linux visible overlay recreation avoids display fallback before tracked ge
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(actionBlock);
|
||||
assert.match(actionBlock, /const trackedGeometry = getCurrentTrackedOverlayGeometry\(\);/);
|
||||
assert.match(
|
||||
actionBlock,
|
||||
/const trackedGeometry = overlayGeometryRuntime\.getCurrentTrackedOverlayGeometry\(\);/,
|
||||
);
|
||||
assert.match(actionBlock, /if \(trackedGeometry\) \{/);
|
||||
assert.match(actionBlock, /overlayManager\.setOverlayWindowBounds\(trackedGeometry\);/);
|
||||
assert.doesNotMatch(actionBlock, /setOverlayWindowBounds\(getCurrentOverlayGeometry\(\)\)/);
|
||||
@@ -556,11 +559,60 @@ test('YouTube media cache lifecycle routes through configured status notificatio
|
||||
);
|
||||
assert.match(cacheBlock, /variant:\s*'success'/);
|
||||
assert.match(cacheBlock, /notifyNoQueued:\s*false/);
|
||||
assert.match(startCacheBlock, /mode:\s*getResolvedConfig\(\)\.youtube\.mediaCache\.mode/);
|
||||
assert.match(
|
||||
startCacheBlock,
|
||||
/maxHeight:\s*getResolvedConfig\(\)\.youtube\.mediaCache\.maxHeight/,
|
||||
/const mediaCacheConfig = configService\.getConfig\(\)\.youtube\.mediaCache;/,
|
||||
);
|
||||
assert.match(startCacheBlock, /mode:\s*mediaCacheConfig\.mode/);
|
||||
assert.match(startCacheBlock, /maxHeight:\s*mediaCacheConfig\.maxHeight/);
|
||||
});
|
||||
|
||||
test('subtitle broadcasts share one frequency options snapshot per emitted payload', () => {
|
||||
const source = readMainSource();
|
||||
const emitBlock = source.match(
|
||||
/function emitSubtitlePayload\(payload: SubtitleData\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
const frequencyOptionsSnapshot = emitBlock?.match(
|
||||
/const frequencyDictionary = configService\.getConfig\(\)\.subtitleStyle\.frequencyDictionary;(?<body>[\s\S]*?)\n \};/,
|
||||
)?.[0];
|
||||
|
||||
assert.ok(emitBlock);
|
||||
assert.ok(frequencyOptionsSnapshot);
|
||||
assert.match(frequencyOptionsSnapshot, /const frequencyOptions = \{/);
|
||||
assert.match(frequencyOptionsSnapshot, /enabled:\s*frequencyDictionary\.enabled/);
|
||||
assert.match(frequencyOptionsSnapshot, /topX:\s*frequencyDictionary\.topX/);
|
||||
assert.match(frequencyOptionsSnapshot, /mode:\s*frequencyDictionary\.mode/);
|
||||
assert.equal((frequencyOptionsSnapshot.match(/configService\.getConfig\(\)/g) ?? []).length, 1);
|
||||
assert.match(emitBlock, /subtitleWsService\.broadcast\(timedPayload, frequencyOptions\);/);
|
||||
assert.match(
|
||||
emitBlock,
|
||||
/annotationSubtitleWsService\.broadcast\(timedPayload, frequencyOptions\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test('websocket frequency options callbacks each read one configuration snapshot', () => {
|
||||
const source = readMainSource();
|
||||
const subtitleBlock = source.match(
|
||||
/startSubtitleWebsocket:\s*\(port: number\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},\n startAnnotationWebsocket:/,
|
||||
)?.groups?.body;
|
||||
const annotationBlock = source.match(
|
||||
/startAnnotationWebsocket:\s*\(port: number\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},\n startTexthooker:/,
|
||||
)?.groups?.body;
|
||||
const subtitleFrequencyOptionsCallback = subtitleBlock?.match(
|
||||
/(?<body>\(\) => \{\s+const frequencyDictionary = configService\.getConfig\(\)\.subtitleStyle\.frequencyDictionary;[\s\S]*?\s+return \{[\s\S]*?\s+\};\s+\})/,
|
||||
)?.groups?.body;
|
||||
const annotationFrequencyOptionsCallback = annotationBlock?.match(
|
||||
/(?<body>\(\) => \{\s+const frequencyDictionary = configService\.getConfig\(\)\.subtitleStyle\.frequencyDictionary;[\s\S]*?\s+return \{[\s\S]*?\s+\};\s+\})/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(subtitleFrequencyOptionsCallback);
|
||||
assert.ok(annotationFrequencyOptionsCallback);
|
||||
for (const callback of [subtitleFrequencyOptionsCallback, annotationFrequencyOptionsCallback]) {
|
||||
assert.match(callback, /return \{[\s\S]*enabled:\s*frequencyDictionary\.enabled/);
|
||||
assert.match(callback, /topX:\s*frequencyDictionary\.topX/);
|
||||
assert.match(callback, /mode:\s*frequencyDictionary\.mode/);
|
||||
assert.equal((callback.match(/configService\.getConfig\(\)/g) ?? []).length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('mpv connection flushes queued configured OSD notifications', () => {
|
||||
@@ -587,11 +639,11 @@ test('manual visible overlay show primes current subtitle from mpv before relyin
|
||||
assert.ok(toggleBlock);
|
||||
assert.match(
|
||||
setBlock,
|
||||
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+startLinuxVisibleOverlayStartupInputGrace\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
|
||||
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+visibleOverlayInteractionRuntime\.resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+visibleOverlayInteractionRuntime\.startLinuxVisibleOverlayStartupInputGrace\(\);\s+visibleOverlayInteractionRuntime\.restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void autoplaySubtitlePrimingRuntime\.primeCurrentSubtitleForVisibleOverlay\(\);/,
|
||||
);
|
||||
assert.match(
|
||||
toggleBlock,
|
||||
/else \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+startLinuxVisibleOverlayStartupInputGrace\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
|
||||
/else \{\s+maybeStartOverlayLoadingOsd\(\);\s+visibleOverlayInteractionRuntime\.resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+visibleOverlayInteractionRuntime\.startLinuxVisibleOverlayStartupInputGrace\(\);\s+visibleOverlayInteractionRuntime\.restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void autoplaySubtitlePrimingRuntime\.primeCurrentSubtitleForVisibleOverlay\(\);/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -612,7 +664,7 @@ test('Linux visible overlay show/reset does not leave an empty X11 window shape'
|
||||
assert.doesNotMatch(runtimeSource, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/);
|
||||
assert.match(
|
||||
setBlock,
|
||||
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+startLinuxVisibleOverlayStartupInputGrace\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/,
|
||||
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+visibleOverlayInteractionRuntime\.resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+visibleOverlayInteractionRuntime\.startLinuxVisibleOverlayStartupInputGrace\(\);\s+visibleOverlayInteractionRuntime\.restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -663,14 +715,20 @@ test('Linux visible overlay show starts input grace before first measurement', (
|
||||
|
||||
for (const block of [setVisibleBlock, toggleBlock, setOverlayBlock]) {
|
||||
assert.ok(block);
|
||||
assert.ok(
|
||||
block.indexOf('resetLinuxVisibleOverlayStartupInputPrimer();') <
|
||||
block.indexOf('startLinuxVisibleOverlayStartupInputGrace();'),
|
||||
const resetIndex = block.indexOf(
|
||||
'visibleOverlayInteractionRuntime.resetLinuxVisibleOverlayStartupInputPrimer();',
|
||||
);
|
||||
assert.ok(
|
||||
block.indexOf('startLinuxVisibleOverlayStartupInputGrace();') <
|
||||
block.indexOf('void primeCurrentSubtitleForVisibleOverlay();'),
|
||||
const graceIndex = block.indexOf(
|
||||
'visibleOverlayInteractionRuntime.startLinuxVisibleOverlayStartupInputGrace();',
|
||||
);
|
||||
const primeIndex = block.indexOf(
|
||||
'void autoplaySubtitlePrimingRuntime.primeCurrentSubtitleForVisibleOverlay();',
|
||||
);
|
||||
assert.ok(resetIndex >= 0);
|
||||
assert.ok(graceIndex >= 0);
|
||||
assert.ok(primeIndex >= 0);
|
||||
assert.ok(resetIndex < graceIndex);
|
||||
assert.ok(graceIndex < primeIndex);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -147,10 +147,7 @@ test('runSyncLauncher settles on exit when the terminal event is already parsed'
|
||||
onEvent: () => {},
|
||||
spawn,
|
||||
});
|
||||
children[0]!.stdout.emit(
|
||||
'data',
|
||||
Buffer.from('{"type":"result","ok":true,"error":null}\n'),
|
||||
);
|
||||
children[0]!.stdout.emit('data', Buffer.from('{"type":"result","ok":true,"error":null}\n'));
|
||||
children[0]!.emit('exit', 0, null);
|
||||
|
||||
const result = await Promise.race([
|
||||
|
||||
@@ -106,7 +106,8 @@ export function parseSyncProgressLine(line: string): SyncProgressEvent | null {
|
||||
? (parsed as SyncProgressEvent)
|
||||
: null;
|
||||
case 'merge-summary':
|
||||
return (event.target === 'local' || event.target === 'remote') && isMergeSummary(event.summary)
|
||||
return (event.target === 'local' || event.target === 'remote') &&
|
||||
isMergeSummary(event.summary)
|
||||
? (parsed as SyncProgressEvent)
|
||||
: null;
|
||||
case 'remote-output':
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as tokenMerger from './token-merger';
|
||||
|
||||
test('does not expose the redundant verb non-independent predicate', () => {
|
||||
assert.equal('isVerbNonIndependent' in tokenMerger, false);
|
||||
});
|
||||
+1
-8
@@ -94,10 +94,6 @@ export function isVerb(tok: Token): boolean {
|
||||
return [PartOfSpeech.verb, PartOfSpeech.bound_auxiliary].includes(tok.partOfSpeech);
|
||||
}
|
||||
|
||||
export function isVerbNonIndependent(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function canReceiveAuxiliary(tok: Token): boolean {
|
||||
return [PartOfSpeech.verb, PartOfSpeech.bound_auxiliary, PartOfSpeech.i_adjective].includes(
|
||||
tok.partOfSpeech,
|
||||
@@ -127,10 +123,7 @@ export function shouldMerge(lastStandaloneToken: Token, token: Token): boolean {
|
||||
if (isAuxVerb(token)) {
|
||||
return true;
|
||||
}
|
||||
if (isContinuativeForm(lastStandaloneToken) && isVerbSuffix(token)) {
|
||||
return true;
|
||||
}
|
||||
if (isVerbSuffix(token) && isVerbNonIndependent()) {
|
||||
if (isVerbSuffix(token)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,9 +60,7 @@ export function tsukihimeTrackMatchesLanguages(
|
||||
export function describeTsukihimeTabLanguages(configuredLanguages: string[]): string {
|
||||
const normalized = [
|
||||
...new Set(
|
||||
configuredLanguages
|
||||
.map((candidate) => normalizeTsukihimeLangCode(candidate))
|
||||
.filter(Boolean),
|
||||
configuredLanguages.map((candidate) => normalizeTsukihimeLangCode(candidate)).filter(Boolean),
|
||||
),
|
||||
];
|
||||
if (normalized.length === 0) return 'English';
|
||||
|
||||
@@ -76,10 +76,7 @@ function asFiniteNumber(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
export function mapTsukihimeSearchResults(
|
||||
payload: unknown,
|
||||
maxResults: number,
|
||||
): TsukihimeEntry[] {
|
||||
export function mapTsukihimeSearchResults(payload: unknown, maxResults: number): TsukihimeEntry[] {
|
||||
if (!isObject(payload) || !Array.isArray(payload.results)) return [];
|
||||
const entries: TsukihimeEntry[] = [];
|
||||
for (const item of payload.results) {
|
||||
|
||||
Reference in New Issue
Block a user