From 80ffa26400ffe439c16a25673600d48dcce14745 Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 18 Aug 2026 22:42:26 -0700 Subject: [PATCH] fix(storage): address CI and review feedback --- .../anilist/cover-art-fetcher.test.ts | 20 ++++++++++++--- src/main-entry-runtime.test.ts | 25 +++++++++++++++++++ src/main-entry-runtime.ts | 5 +++- src/main-entry.ts | 11 +++++--- src/main/electron-runtime-guard.test.ts | 2 ++ src/main/electron-runtime-guard.ts | 1 + .../yomitan-dictionary-integrity.test.ts | 25 +++++++++++++------ .../runtime/yomitan-dictionary-integrity.ts | 4 +-- 8 files changed, 75 insertions(+), 18 deletions(-) diff --git a/src/core/services/anilist/cover-art-fetcher.test.ts b/src/core/services/anilist/cover-art-fetcher.test.ts index 1d2462dc..d75ba7da 100644 --- a/src/core/services/anilist/cover-art-fetcher.test.ts +++ b/src/core/services/anilist/cover-art-fetcher.test.ts @@ -47,7 +47,7 @@ test('stripFilenameTags normalizes common media-title formats', () => { ); }); -test('fetchIfMissing backfills a missing blob from an existing cover URL', async () => { +async function backfillMissingCoverBlob(): Promise { const dbPath = makeDbPath(); const db = new Database(dbPath); ensureSchema(db); @@ -103,9 +103,15 @@ test('fetchIfMissing backfills a missing blob from an existing cover URL', async db.close(); cleanupDbPath(dbPath); } -}); +} -test('fetchIfMissing reuses cached cover art from another video in the same anime', async () => { +test( + 'fetchIfMissing backfills a missing blob from an existing cover URL', + { timeout: 15_000 }, + backfillMissingCoverBlob, +); + +async function reuseCachedAnimeCoverArt(): Promise { const dbPath = makeDbPath(); const db = new Database(dbPath); ensureSchema(db); @@ -179,7 +185,13 @@ test('fetchIfMissing reuses cached cover art from another video in the same anim db.close(); cleanupDbPath(dbPath); } -}); +} + +test( + 'fetchIfMissing reuses cached cover art from another video in the same anime', + { timeout: 15_000 }, + reuseCachedAnimeCoverArt, +); function createJsonResponse(payload: unknown): Response { return new Response(JSON.stringify(payload), { diff --git a/src/main-entry-runtime.test.ts b/src/main-entry-runtime.test.ts index 0d2e7458..18bcd781 100644 --- a/src/main-entry-runtime.test.ts +++ b/src/main-entry-runtime.test.ts @@ -629,6 +629,31 @@ test('configureEarlyAppPaths isolates development runs from the production profi assert.deepEqual(calls, ['name:SubMiner', 'path:userData:/tmp/xdg/SubMiner-dev']); }); +test('configureEarlyAppPaths ignores development flags forwarded to mpv', () => { + for (const forwardedFlag of ['--dev', '--debug']) { + let selectedPath = ''; + const userDataPath = configureEarlyAppPaths( + { + setName: () => {}, + setPath: (_key, value) => { + selectedPath = value; + }, + }, + { + platform: 'linux', + homeDir: '/home/tester', + xdgConfigHome: '/tmp/xdg', + existsSync: () => false, + argv: ['electron', '.', '--launch-mpv', forwardedFlag], + env: {}, + }, + ); + + assert.equal(userDataPath, '/tmp/xdg/SubMiner'); + assert.equal(selectedPath, '/tmp/xdg/SubMiner'); + } +}); + test('configureEarlyAppPaths uses the supplied environment for config discovery', () => { const paths: string[] = []; diff --git a/src/main-entry-runtime.ts b/src/main-entry-runtime.ts index 329f577b..d1b5e9ac 100644 --- a/src/main-entry-runtime.ts +++ b/src/main-entry-runtime.ts @@ -267,8 +267,11 @@ export function configureEarlyAppPaths(app: EarlyAppLike, options?: EarlyAppPath existsSync: options?.existsSync ?? fs.existsSync, }); const argv = options?.argv ?? process.argv; + const launchMpvIndex = argv.indexOf('--launch-mpv'); + const appArgv = launchMpvIndex === -1 ? argv : argv.slice(0, launchMpvIndex); const useDevelopmentProfile = - (argv.includes('--dev') || argv.includes('--debug')) && env[USE_PRODUCTION_PROFILE_ENV] !== '1'; + (appArgv.includes('--dev') || appArgv.includes('--debug')) && + env[USE_PRODUCTION_PROFILE_ENV] !== '1'; const platformPath = platform === 'win32' ? path.win32 : path.posix; const userDataPath = useDevelopmentProfile ? platformPath.join(platformPath.dirname(configDir), DEVELOPMENT_APP_NAME) diff --git a/src/main-entry.ts b/src/main-entry.ts index e8eb83e3..c3704bc0 100644 --- a/src/main-entry.ts +++ b/src/main-entry.ts @@ -22,7 +22,10 @@ import { shouldHandleStatsDaemonCommandAtEntry, spawnDetachedApp, } from './main-entry-runtime'; -import { requestSingleInstanceLockEarly } from './main/early-single-instance'; +import { + requestSingleInstanceLockEarly, + shouldBypassSingleInstanceLockForArgv, +} from './main/early-single-instance'; import { readConfiguredWindowsMpvLaunch } from './main-entry-launch-config'; import { isAppControlServerAvailable, sendAppControlCommand } from './shared/app-control-client'; import { @@ -193,8 +196,10 @@ registerFatalErrorHandlers({ }); function startMainProcess(): void { - // This profile-scoped lock serializes the runtime guard's read-check-write sequence. - const gotSingleInstanceLock = requestSingleInstanceLockEarly(app); + // Normal launches serialize the runtime guard with the profile-scoped lock. Stats daemon + // commands keep their existing lock bypass when Electron runs in Node mode. + const gotSingleInstanceLock = + shouldBypassSingleInstanceLockForArgv(process.argv) || requestSingleInstanceLockEarly(app); if (!gotSingleInstanceLock) { app.exit(0); return; diff --git a/src/main/electron-runtime-guard.test.ts b/src/main/electron-runtime-guard.test.ts index 89c035aa..2178b2cf 100644 --- a/src/main/electron-runtime-guard.test.ts +++ b/src/main/electron-runtime-guard.test.ts @@ -89,6 +89,7 @@ test('runtime guard blocks a profile downgrade before rewriting its safety recor if (result.ok) return; assert.equal(result.title, 'Electron downgrade blocked'); assert.match(result.details, /destroy Yomitan dictionaries/); + assert.equal(result.details.includes(statePath), true); assert.deepEqual(JSON.parse(fs.readFileSync(statePath, 'utf8')), { highestElectronMajor: 44, lastElectronVersion: '44.1.0', @@ -114,6 +115,7 @@ test('runtime guard blocks a downgrade within the supported Electron major', () assert.equal(result.ok, false); if (result.ok) return; assert.equal(result.title, 'Electron downgrade blocked'); + assert.equal(result.details.includes(statePath), true); assert.deepEqual(JSON.parse(fs.readFileSync(statePath, 'utf8')), { highestElectronMajor: 43, lastElectronVersion: '43.4.1', diff --git a/src/main/electron-runtime-guard.ts b/src/main/electron-runtime-guard.ts index f50a97e5..6205e867 100644 --- a/src/main/electron-runtime-guard.ts +++ b/src/main/electron-runtime-guard.ts @@ -137,6 +137,7 @@ export function enforceElectronRuntimeGuard(options: { details: [ `This profile was previously opened with Electron ${previousState.state.lastElectronVersion}.`, `The current runtime is Electron ${options.electronVersion}.`, + `Runtime safety record: ${statePath}.`, '', 'Opening Chromium storage with an older Electron version can destroy Yomitan dictionaries. Upgrade SubMiner before using this profile.', ].join('\n'), diff --git a/src/main/runtime/yomitan-dictionary-integrity.test.ts b/src/main/runtime/yomitan-dictionary-integrity.test.ts index 5cf7cff9..35a7874f 100644 --- a/src/main/runtime/yomitan-dictionary-integrity.test.ts +++ b/src/main/runtime/yomitan-dictionary-integrity.test.ts @@ -27,12 +27,15 @@ test('dictionary integrity observation establishes and updates a non-empty basel safe: true, previousCount: 5, }); - assert.deepEqual( - JSON.parse( - fs.readFileSync(path.join(userDataPath, 'yomitan-dictionary-integrity.json'), 'utf8'), - ), - { lastKnownNonEmptyCount: 3 }, - ); + const statePath = path.join(userDataPath, 'yomitan-dictionary-integrity.json'); + const unchangedTimestamp = new Date('2000-01-01T00:00:00.000Z'); + fs.utimesSync(statePath, unchangedTimestamp, unchangedTimestamp); + assert.deepEqual(observeYomitanDictionaryCount(userDataPath, 3), { + safe: true, + previousCount: 3, + }); + assert.equal(fs.statSync(statePath).mtimeMs, unchangedTimestamp.getTime()); + assert.deepEqual(JSON.parse(fs.readFileSync(statePath, 'utf8')), { lastKnownNonEmptyCount: 3 }); assert.deepEqual(fs.readdirSync(userDataPath), ['yomitan-dictionary-integrity.json']); }); }); @@ -114,11 +117,17 @@ test('dictionary integrity blocks automatic mutation after a non-empty profile b test('dictionary integrity fails closed when its state is malformed', () => { withTempDir((userDataPath) => { - fs.writeFileSync(path.join(userDataPath, 'yomitan-dictionary-integrity.json'), '{}', 'utf8'); + const statePath = path.join(userDataPath, 'yomitan-dictionary-integrity.json'); + fs.writeFileSync(statePath, '{}', 'utf8'); assert.throws( () => assertYomitanDictionaryMutationSafe(userDataPath, 2), - /could not verify Yomitan dictionary storage/, + (error: unknown) => { + assert.ok(error instanceof Error); + assert.match(error.message, /could not verify Yomitan dictionary storage/); + assert.equal(error.message.includes(statePath), true); + return true; + }, ); }); }); diff --git a/src/main/runtime/yomitan-dictionary-integrity.ts b/src/main/runtime/yomitan-dictionary-integrity.ts index 0c1a52bf..26099c88 100644 --- a/src/main/runtime/yomitan-dictionary-integrity.ts +++ b/src/main/runtime/yomitan-dictionary-integrity.ts @@ -70,7 +70,7 @@ export function observeYomitanDictionaryCount( return { safe: false, previousCount: null, - message: `SubMiner could not verify Yomitan dictionary storage: ${(error as Error).message}`, + message: `SubMiner could not verify Yomitan dictionary storage at ${statePath}: ${(error as Error).message}`, }; } @@ -86,7 +86,7 @@ export function observeYomitanDictionaryCount( }; } - if (normalizedCount > 0) { + if (normalizedCount > 0 && normalizedCount !== state?.lastKnownNonEmptyCount) { try { writeState(statePath, { lastKnownNonEmptyCount: normalizedCount }); } catch (error) {