Fix stats command flow and tracking metrics regressions

- Route default `subminer stats` through attached `--stats`; keep daemon path for `--background`/`--stop`
- Update overview metrics: lookup rate uses lifetime Yomitan lookups per 100 tokens; new words dedupe by headword
- Suppress repeated macOS `Overlay loading...` OSD during fullscreen tracker flaps and improve session-detail chart scaling
- Add/adjust launcher, tracker query, stats server, IPC, overlay, and stats UI regression tests; add changelog fragments
This commit is contained in:
2026-03-19 15:46:52 -07:00
parent 274b0619ac
commit f2d6c70019
37 changed files with 1093 additions and 190 deletions

View File

@@ -260,6 +260,12 @@ function createMockTracker(
totalActiveMin: 120,
totalCards: 0,
activeDays: 7,
totalTokensSeen: 80,
totalLookupCount: 5,
totalLookupHits: 4,
totalYomitanLookupCount: 5,
newWordsToday: 0,
newWordsThisWeek: 0,
}),
getSessionTimeline: async () => [],
getSessionEvents: async () => [],
@@ -337,6 +343,8 @@ describe('stats server API routes', () => {
assert.equal(body.hints.totalAnimeCompleted, 0);
assert.equal(body.hints.totalActiveMin, 120);
assert.equal(body.hints.activeDays, 7);
assert.equal(body.hints.totalTokensSeen, 80);
assert.equal(body.hints.totalYomitanLookupCount, 5);
});
it('GET /api/stats/sessions returns session list', async () => {
@@ -347,6 +355,39 @@ describe('stats server API routes', () => {
assert.ok(Array.isArray(body));
});
it('GET /api/stats/sessions enriches each session with known-word metrics when cache exists', async () => {
await withTempDir(async (dir) => {
const cachePath = path.join(dir, 'known-words.json');
fs.writeFileSync(
cachePath,
JSON.stringify({
version: 1,
words: ['する'],
}),
);
const app = createStatsApp(
createMockTracker({
getSessionWordsByLine: async (sessionId: number) =>
sessionId === 1
? [
{ lineIndex: 1, headword: 'する', occurrenceCount: 2 },
{ lineIndex: 2, headword: '未知', occurrenceCount: 1 },
]
: [],
}),
{ knownWordCachePath: cachePath },
);
const res = await app.request('/api/stats/sessions?limit=5');
assert.equal(res.status, 200);
const body = await res.json();
const first = body[0];
assert.equal(first.knownWordsSeen, 2);
assert.equal(first.knownWordRate, 2.5);
});
});
it('GET /api/stats/sessions/:id/events forwards event type filters to the tracker', async () => {
let seenSessionId = 0;
let seenLimit = 0;

View File

@@ -365,6 +365,12 @@ export class ImmersionTrackerService {
totalActiveMin: number;
totalCards: number;
activeDays: number;
totalTokensSeen: number;
totalLookupCount: number;
totalLookupHits: number;
totalYomitanLookupCount: number;
newWordsToday: number;
newWordsThisWeek: number;
}> {
return getQueryHints(this.db);
}

View File

@@ -409,6 +409,28 @@ test('getQueryHints reads all-time totals from lifetime summary', () => {
insert.run(10, 2, 1, 11, 0, 0, 3);
insert.run(9, 1, 1, 10, 0, 0, 1);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/query-hints.mkv', {
canonicalTitle: 'Query Hints Episode',
sourcePath: '/tmp/query-hints.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const { sessionId } = startSessionRecord(db, videoId, 1_000_000);
db.prepare(
`
UPDATE imm_sessions
SET
ended_at_ms = ?,
status = 2,
tokens_seen = ?,
yomitan_lookup_count = ?,
lookup_count = ?,
lookup_hits = ?,
LAST_UPDATE_DATE = ?
WHERE session_id = ?
`,
).run(1_060_000, 120, 8, 11, 7, 1_060_000, sessionId);
const hints = getQueryHints(db);
assert.equal(hints.totalSessions, 4);
assert.equal(hints.totalCards, 2);
@@ -416,6 +438,52 @@ test('getQueryHints reads all-time totals from lifetime summary', () => {
assert.equal(hints.activeDays, 9);
assert.equal(hints.totalEpisodesWatched, 11);
assert.equal(hints.totalAnimeCompleted, 22);
assert.equal(hints.totalTokensSeen, 120);
assert.equal(hints.totalYomitanLookupCount, 8);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('getQueryHints counts new words by distinct headword first-seen time', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
const now = new Date();
const todayStartSec =
new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000;
const oneHourAgo = todayStartSec + 3_600;
const twoDaysAgo = todayStartSec - 2 * 86_400;
db.prepare(
`
INSERT INTO imm_words (
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run('知る', '知った', 'しった', 'verb', '動詞', '', '', oneHourAgo, oneHourAgo, 1);
db.prepare(
`
INSERT INTO imm_words (
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run('知る', '知っている', 'しっている', 'verb', '動詞', '', '', oneHourAgo, oneHourAgo, 1);
db.prepare(
`
INSERT INTO imm_words (
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run('猫', '猫', 'ねこ', 'noun', '名詞', '', '', twoDaysAgo, twoDaysAgo, 1);
const hints = getQueryHints(db);
assert.equal(hints.newWordsToday, 1);
assert.equal(hints.newWordsThisWeek, 2);
} finally {
db.close();
cleanupDbPath(dbPath);

View File

@@ -480,8 +480,10 @@ export function getQueryHints(db: DatabaseSync): {
totalActiveMin: number;
totalCards: number;
activeDays: number;
totalTokensSeen: number;
totalLookupCount: number;
totalLookupHits: number;
totalYomitanLookupCount: number;
newWordsToday: number;
newWordsThisWeek: number;
} {
@@ -556,18 +558,30 @@ export function getQueryHints(db: DatabaseSync): {
.prepare(
`
SELECT
COALESCE(SUM(COALESCE(t.tokens_seen, s.tokens_seen, 0)), 0) AS totalTokensSeen,
COALESCE(SUM(COALESCE(t.lookup_count, s.lookup_count, 0)), 0) AS totalLookupCount,
COALESCE(SUM(COALESCE(t.lookup_hits, s.lookup_hits, 0)), 0) AS totalLookupHits
COALESCE(SUM(COALESCE(t.lookup_hits, s.lookup_hits, 0)), 0) AS totalLookupHits,
COALESCE(SUM(COALESCE(t.yomitan_lookup_count, s.yomitan_lookup_count, 0)), 0) AS totalYomitanLookupCount
FROM imm_sessions s
LEFT JOIN (
SELECT session_id, MAX(lookup_count) AS lookup_count, MAX(lookup_hits) AS lookup_hits
SELECT
session_id,
MAX(tokens_seen) AS tokens_seen,
MAX(lookup_count) AS lookup_count,
MAX(lookup_hits) AS lookup_hits,
MAX(yomitan_lookup_count) AS yomitan_lookup_count
FROM imm_session_telemetry
GROUP BY session_id
) t ON t.session_id = s.session_id
WHERE s.ended_at_ms IS NOT NULL
`,
)
.get() as { totalLookupCount: number; totalLookupHits: number } | null;
.get() as {
totalTokensSeen: number;
totalLookupCount: number;
totalLookupHits: number;
totalYomitanLookupCount: number;
} | null;
return {
totalSessions,
@@ -579,8 +593,10 @@ export function getQueryHints(db: DatabaseSync): {
totalActiveMin,
totalCards,
activeDays,
totalTokensSeen: Number(lookupTotals?.totalTokensSeen ?? 0),
totalLookupCount: Number(lookupTotals?.totalLookupCount ?? 0),
totalLookupHits: Number(lookupTotals?.totalLookupHits ?? 0),
totalYomitanLookupCount: Number(lookupTotals?.totalYomitanLookupCount ?? 0),
...getNewWordCounts(db),
};
}
@@ -593,11 +609,20 @@ function getNewWordCounts(db: DatabaseSync): { newWordsToday: number; newWordsTh
const row = db
.prepare(
`
WITH headword_first_seen AS (
SELECT
headword,
MIN(first_seen) AS first_seen
FROM imm_words
WHERE first_seen IS NOT NULL
AND headword IS NOT NULL
AND headword != ''
GROUP BY headword
)
SELECT
COALESCE(SUM(CASE WHEN first_seen >= ? THEN 1 ELSE 0 END), 0) AS today,
COALESCE(SUM(CASE WHEN first_seen >= ? THEN 1 ELSE 0 END), 0) AS week
FROM imm_words
WHERE first_seen IS NOT NULL
FROM headword_first_seen
`,
)
.get(todayStartSec, weekAgoSec) as { today: number; week: number } | null;

View File

@@ -234,6 +234,8 @@ export interface SessionSummaryQueryRow {
lookupCount: number;
lookupHits: number;
yomitanLookupCount: number;
knownWordsSeen?: number;
knownWordRate?: number;
}
export interface LifetimeGlobalRow {

View File

@@ -140,8 +140,10 @@ function createFakeImmersionTracker(
activeDays: 0,
totalEpisodesWatched: 0,
totalAnimeCompleted: 0,
totalTokensSeen: 0,
totalLookupCount: 0,
totalLookupHits: 0,
totalYomitanLookupCount: 0,
newWordsToday: 0,
newWordsThisWeek: 0,
}),
@@ -359,8 +361,10 @@ test('registerIpcHandlers returns empty stats overview shape without a tracker',
activeDays: 0,
totalEpisodesWatched: 0,
totalAnimeCompleted: 0,
totalTokensSeen: 0,
totalLookupCount: 0,
totalLookupHits: 0,
totalYomitanLookupCount: 0,
newWordsToday: 0,
newWordsThisWeek: 0,
},
@@ -397,8 +401,10 @@ test('registerIpcHandlers validates and clamps stats request limits', async () =
activeDays: 0,
totalEpisodesWatched: 0,
totalAnimeCompleted: 0,
totalTokensSeen: 0,
totalLookupCount: 0,
totalLookupHits: 0,
totalYomitanLookupCount: 0,
newWordsToday: 0,
newWordsThisWeek: 0,
}),
@@ -472,6 +478,12 @@ test('registerIpcHandlers requests the full timeline when no limit is provided',
activeDays: 0,
totalEpisodesWatched: 0,
totalAnimeCompleted: 0,
totalTokensSeen: 0,
totalLookupCount: 0,
totalLookupHits: 0,
totalYomitanLookupCount: 0,
newWordsToday: 0,
newWordsThisWeek: 0,
}),
getSessionTimeline: async (sessionId: number, limit?: number) => {
calls.push(['timeline', limit, sessionId]);

View File

@@ -85,6 +85,12 @@ export interface IpcServiceDeps {
activeDays: number;
totalEpisodesWatched: number;
totalAnimeCompleted: number;
totalTokensSeen: number;
totalLookupCount: number;
totalLookupHits: number;
totalYomitanLookupCount: number;
newWordsToday: number;
newWordsThisWeek: number;
}>;
getSessionTimeline: (sessionId: number, limit?: number) => Promise<unknown>;
getSessionEvents: (sessionId: number, limit?: number) => Promise<unknown>;
@@ -486,8 +492,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
activeDays: 0,
totalEpisodesWatched: 0,
totalAnimeCompleted: 0,
totalTokensSeen: 0,
totalLookupCount: 0,
totalLookupHits: 0,
totalYomitanLookupCount: 0,
newWordsToday: 0,
newWordsThisWeek: 0,
},

View File

@@ -358,6 +358,59 @@ test('macOS keeps visible overlay hidden while tracker is not initialized yet',
assert.ok(!calls.includes('update-bounds'));
});
test('macOS suppresses immediate repeat loading OSD after tracker recovery until cooldown expires', () => {
const { window } = createMainWindowRecorder();
const osdMessages: string[] = [];
let trackerWarning = false;
let lastLoadingOsdAtMs: number | null = null;
let nowMs = 1_000;
const hiddenTracker: WindowTrackerStub = {
isTracking: () => false,
getGeometry: () => null,
};
const trackedTracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
};
const run = (windowTracker: WindowTrackerStub) =>
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: windowTracker as never,
trackerNotReadyWarningShown: trackerWarning,
setTrackerNotReadyWarningShown: (shown: boolean) => {
trackerWarning = shown;
},
updateVisibleOverlayBounds: () => {},
ensureOverlayWindowLevel: () => {},
syncPrimaryOverlayWindowLayer: () => {},
enforceOverlayLayerOrder: () => {},
syncOverlayShortcuts: () => {},
isMacOSPlatform: true,
showOverlayLoadingOsd: (message: string) => {
osdMessages.push(message);
},
shouldShowOverlayLoadingOsd: () =>
lastLoadingOsdAtMs === null || nowMs - lastLoadingOsdAtMs >= 5_000,
markOverlayLoadingOsdShown: () => {
lastLoadingOsdAtMs = nowMs;
},
} as never);
run(hiddenTracker);
run(trackedTracker);
nowMs = 2_000;
run(hiddenTracker);
run(trackedTracker);
nowMs = 6_500;
run(hiddenTracker);
assert.deepEqual(osdMessages, ['Overlay loading...', 'Overlay loading...']);
});
test('setVisibleOverlayVisible does not mutate mpv subtitle visibility directly', () => {
const calls: string[] = [];
setVisibleOverlayVisible({
@@ -373,10 +426,12 @@ test('setVisibleOverlayVisible does not mutate mpv subtitle visibility directly'
assert.deepEqual(calls, ['state:true', 'update']);
});
test('macOS loading OSD can show again after overlay is hidden and retried', () => {
test('macOS explicit hide resets loading OSD suppression before retry', () => {
const { window, calls } = createMainWindowRecorder();
const osdMessages: string[] = [];
let trackerWarning = false;
let lastLoadingOsdAtMs: number | null = null;
let nowMs = 1_000;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
@@ -406,8 +461,17 @@ test('macOS loading OSD can show again after overlay is hidden and retried', ()
showOverlayLoadingOsd: (message: string) => {
osdMessages.push(message);
},
shouldShowOverlayLoadingOsd: () =>
lastLoadingOsdAtMs === null || nowMs - lastLoadingOsdAtMs >= 5_000,
markOverlayLoadingOsdShown: () => {
lastLoadingOsdAtMs = nowMs;
},
resetOverlayLoadingOsdSuppression: () => {
lastLoadingOsdAtMs = null;
},
} as never);
nowMs = 1_500;
updateVisibleOverlayVisibility({
visibleOverlayVisible: false,
mainWindow: window as never,
@@ -424,6 +488,9 @@ test('macOS loading OSD can show again after overlay is hidden and retried', ()
syncOverlayShortcuts: () => {},
isMacOSPlatform: true,
showOverlayLoadingOsd: () => {},
resetOverlayLoadingOsdSuppression: () => {
lastLoadingOsdAtMs = null;
},
} as never);
updateVisibleOverlayVisibility({
@@ -454,6 +521,14 @@ test('macOS loading OSD can show again after overlay is hidden and retried', ()
showOverlayLoadingOsd: (message: string) => {
osdMessages.push(message);
},
shouldShowOverlayLoadingOsd: () =>
lastLoadingOsdAtMs === null || nowMs - lastLoadingOsdAtMs >= 5_000,
markOverlayLoadingOsdShown: () => {
lastLoadingOsdAtMs = nowMs;
},
resetOverlayLoadingOsdSuppression: () => {
lastLoadingOsdAtMs = null;
},
} as never);
assert.deepEqual(osdMessages, ['Overlay loading...', 'Overlay loading...']);

View File

@@ -17,6 +17,9 @@ export function updateVisibleOverlayVisibility(args: {
isMacOSPlatform?: boolean;
isWindowsPlatform?: boolean;
showOverlayLoadingOsd?: (message: string) => void;
shouldShowOverlayLoadingOsd?: () => boolean;
markOverlayLoadingOsdShown?: () => void;
resetOverlayLoadingOsdSuppression?: () => void;
resolveFallbackBounds?: () => WindowGeometry;
}): void {
if (!args.mainWindow || args.mainWindow.isDestroyed()) {
@@ -39,8 +42,20 @@ export function updateVisibleOverlayVisibility(args: {
}
};
const maybeShowOverlayLoadingOsd = (): void => {
if (!args.isMacOSPlatform || !args.showOverlayLoadingOsd) {
return;
}
if (args.shouldShowOverlayLoadingOsd && !args.shouldShowOverlayLoadingOsd()) {
return;
}
args.showOverlayLoadingOsd('Overlay loading...');
args.markOverlayLoadingOsdShown?.();
};
if (!args.visibleOverlayVisible) {
args.setTrackerNotReadyWarningShown(false);
args.resetOverlayLoadingOsdSuppression?.();
mainWindow.hide();
args.syncOverlayShortcuts();
return;
@@ -63,9 +78,7 @@ export function updateVisibleOverlayVisibility(args: {
if (args.isMacOSPlatform || args.isWindowsPlatform) {
if (!args.trackerNotReadyWarningShown) {
args.setTrackerNotReadyWarningShown(true);
if (args.isMacOSPlatform) {
args.showOverlayLoadingOsd?.('Overlay loading...');
}
maybeShowOverlayLoadingOsd();
}
mainWindow.hide();
args.syncOverlayShortcuts();
@@ -81,9 +94,7 @@ export function updateVisibleOverlayVisibility(args: {
if (!args.trackerNotReadyWarningShown) {
args.setTrackerNotReadyWarningShown(true);
if (args.isMacOSPlatform) {
args.showOverlayLoadingOsd?.('Overlay loading...');
}
maybeShowOverlayLoadingOsd();
}
mainWindow.hide();

View File

@@ -87,6 +87,62 @@ function countKnownWords(
return { totalUniqueWords: headwords.length, knownWordCount };
}
function toKnownWordRate(knownWordsSeen: number, tokensSeen: number): number {
if (!Number.isFinite(knownWordsSeen) || !Number.isFinite(tokensSeen) || tokensSeen <= 0) {
return 0;
}
return Number(((knownWordsSeen / tokensSeen) * 100).toFixed(1));
}
async function enrichSessionsWithKnownWordMetrics(
tracker: ImmersionTrackerService,
sessions: Array<{
sessionId: number;
tokensSeen: number;
}>,
knownWordsCachePath?: string,
): Promise<
Array<{
sessionId: number;
tokensSeen: number;
knownWordsSeen: number;
knownWordRate: number;
}>
> {
const knownWordsSet = loadKnownWordsSet(knownWordsCachePath);
if (!knownWordsSet) {
return sessions.map((session) => ({
...session,
knownWordsSeen: 0,
knownWordRate: 0,
}));
}
const enriched = await Promise.all(
sessions.map(async (session) => {
let knownWordsSeen = 0;
try {
const wordsByLine = await tracker.getSessionWordsByLine(session.sessionId);
for (const row of wordsByLine) {
if (knownWordsSet.has(row.headword)) {
knownWordsSeen += row.occurrenceCount;
}
}
} catch {
knownWordsSeen = 0;
}
return {
...session,
knownWordsSeen,
knownWordRate: toKnownWordRate(knownWordsSeen, session.tokensSeen),
};
}),
);
return enriched;
}
export interface StatsServerConfig {
port: number;
staticDir: string; // Path to stats/dist/
@@ -182,11 +238,16 @@ export function createStatsApp(
const app = new Hono();
app.get('/api/stats/overview', async (c) => {
const [sessions, rollups, hints] = await Promise.all([
const [rawSessions, rollups, hints] = await Promise.all([
tracker.getSessionSummaries(5),
tracker.getDailyRollups(14),
tracker.getQueryHints(),
]);
const sessions = await enrichSessionsWithKnownWordMetrics(
tracker,
rawSessions,
options?.knownWordCachePath,
);
return c.json({ sessions, rollups, hints });
});
@@ -230,7 +291,12 @@ export function createStatsApp(
app.get('/api/stats/sessions', async (c) => {
const limit = parseIntQuery(c.req.query('limit'), 50, 500);
const sessions = await tracker.getSessionSummaries(limit);
const rawSessions = await tracker.getSessionSummaries(limit);
const sessions = await enrichSessionsWithKnownWordMetrics(
tracker,
rawSessions,
options?.knownWordCachePath,
);
return c.json(sessions);
});
@@ -353,11 +419,16 @@ export function createStatsApp(
app.get('/api/stats/media/:videoId', async (c) => {
const videoId = parseIntQuery(c.req.param('videoId'), 0);
if (videoId <= 0) return c.json(null, 400);
const [detail, sessions, rollups] = await Promise.all([
const [detail, rawSessions, rollups] = await Promise.all([
tracker.getMediaDetail(videoId),
tracker.getMediaSessions(videoId, 100),
tracker.getMediaDailyRollups(videoId, 90),
]);
const sessions = await enrichSessionsWithKnownWordMetrics(
tracker,
rawSessions,
options?.knownWordCachePath,
);
return c.json({ detail, sessions, rollups });
});
@@ -529,9 +600,14 @@ export function createStatsApp(
app.get('/api/stats/episode/:videoId/detail', async (c) => {
const videoId = parseIntQuery(c.req.param('videoId'), 0);
if (videoId <= 0) return c.body(null, 400);
const sessions = await tracker.getEpisodeSessions(videoId);
const rawSessions = await tracker.getEpisodeSessions(videoId);
const words = await tracker.getEpisodeWords(videoId);
const cardEvents = await tracker.getEpisodeCardEvents(videoId);
const sessions = await enrichSessionsWithKnownWordMetrics(
tracker,
rawSessions,
options?.knownWordCachePath,
);
return c.json({ sessions, words, cardEvents });
});

View File

@@ -4,6 +4,8 @@ import type { BaseWindowTracker } from '../window-trackers';
import type { WindowGeometry } from '../types';
import { updateVisibleOverlayVisibility } from '../core/services';
const OVERLAY_LOADING_OSD_COOLDOWN_MS = 30_000;
export interface OverlayVisibilityRuntimeDeps {
getMainWindow: () => BrowserWindow | null;
getVisibleOverlayVisible: () => boolean;
@@ -29,6 +31,8 @@ export interface OverlayVisibilityRuntimeService {
export function createOverlayVisibilityRuntimeService(
deps: OverlayVisibilityRuntimeDeps,
): OverlayVisibilityRuntimeService {
let lastOverlayLoadingOsdAtMs: number | null = null;
return {
updateVisibleOverlayVisibility(): void {
updateVisibleOverlayVisibility({
@@ -50,6 +54,15 @@ export function createOverlayVisibilityRuntimeService(
isMacOSPlatform: deps.isMacOSPlatform(),
isWindowsPlatform: deps.isWindowsPlatform(),
showOverlayLoadingOsd: (message: string) => deps.showOverlayLoadingOsd(message),
shouldShowOverlayLoadingOsd: () =>
lastOverlayLoadingOsdAtMs === null ||
Date.now() - lastOverlayLoadingOsdAtMs >= OVERLAY_LOADING_OSD_COOLDOWN_MS,
markOverlayLoadingOsdShown: () => {
lastOverlayLoadingOsdAtMs = Date.now();
},
resetOverlayLoadingOsdSuppression: () => {
lastOverlayLoadingOsdAtMs = null;
},
resolveFallbackBounds: () => deps.resolveFallbackBounds(),
});
},

View File

@@ -75,6 +75,29 @@ test('stats cli command starts tracker, server, browser, and writes success resp
]);
});
test('stats cli command respects stats.autoOpenBrowser=false', async () => {
const { handler, calls, responses } = makeHandler({
getResolvedConfig: () => ({
immersionTracking: { enabled: true },
stats: { serverPort: 6969, autoOpenBrowser: false },
}),
});
await handler({ statsResponsePath: '/tmp/subminer-stats-response.json' }, 'initial');
assert.deepEqual(calls, [
'ensureImmersionTrackerStarted',
'ensureStatsServerStarted',
'info:Stats dashboard available at http://127.0.0.1:6969',
]);
assert.deepEqual(responses, [
{
responsePath: '/tmp/subminer-stats-response.json',
payload: { ok: true, url: 'http://127.0.0.1:6969' },
},
]);
});
test('stats cli command starts background daemon without opening browser', async () => {
const { handler, calls, responses } = makeHandler({
ensureBackgroundStatsServerStarted: () => {