From e18ff0d3cf2793ff5941fe86989b0f8287b66a24 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Wed, 29 Apr 2026 13:57:01 -0600 Subject: [PATCH 01/15] Add IndexedDB test dependency --- package-lock.json | 11 +++++++++++ package.json | 1 + 2 files changed, 12 insertions(+) diff --git a/package-lock.json b/package-lock.json index 03b744d5..2fee50c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,6 +49,7 @@ "@types/turndown": "^5.0.6", "@vitejs/plugin-react": "^4.7.0", "cross-env": "^7.0.3", + "fake-indexeddb": "^6.2.5", "jsdom": "^26.1.0", "typescript": "^5.9.3", "vite": "^6.4.2", @@ -4467,6 +4468,16 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", diff --git a/package.json b/package.json index bb4acd67..f90b6691 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "@types/turndown": "^5.0.6", "@vitejs/plugin-react": "^4.7.0", "cross-env": "^7.0.3", + "fake-indexeddb": "^6.2.5", "jsdom": "^26.1.0", "typescript": "^5.9.3", "vite": "^6.4.2", From 5d9a8b11c757a2c8c6fe96d5b8cd12b96edaa422 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Wed, 29 Apr 2026 13:57:31 -0600 Subject: [PATCH 02/15] Add autologging preferences --- src/PreferencesStore.tsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/PreferencesStore.tsx b/src/PreferencesStore.tsx index 76be662d..e156cac8 100644 --- a/src/PreferencesStore.tsx +++ b/src/PreferencesStore.tsx @@ -49,6 +49,11 @@ export type HapticsPreferences = { autoStopTimeout: number; }; +export type AutologgingPreferences = { + enabled: boolean; + maxBytes: number; +}; + export type PrefState = { general: GeneralPreferences; speech: SpeechPreferences; @@ -58,6 +63,7 @@ export type PrefState = { keyboard: KeyboardPreferences; midi: MidiPreferences; haptics: HapticsPreferences; + autologging: AutologgingPreferences; }; export enum PrefActionType { @@ -70,6 +76,7 @@ export enum PrefActionType { SetKeyboard = "SET_KEYBOARD", SetMidi = "SET_MIDI", SetHaptics = "SET_HAPTICS", + SetAutologging = "SET_AUTOLOGGING", } export type PrefAction = @@ -81,7 +88,8 @@ export type PrefAction = | { type: PrefActionType.SetEditorAccessibilityMode; data: boolean } | { type: PrefActionType.SetKeyboard; data: KeyboardPreferences } | { type: PrefActionType.SetMidi; data: MidiPreferences } - | { type: PrefActionType.SetHaptics; data: HapticsPreferences }; + | { type: PrefActionType.SetHaptics; data: HapticsPreferences } + | { type: PrefActionType.SetAutologging; data: AutologgingPreferences }; class PreferencesStore { private state: PrefState; @@ -141,6 +149,10 @@ class PreferencesStore { intensityCap: 1.0, autoStopTimeout: 5, }, + autologging: { + enabled: false, + maxBytes: 100 * 1024 * 1024, + }, }; } @@ -159,6 +171,7 @@ class PreferencesStore { keyboard: { ...initial.keyboard, ...stored.keyboard }, midi: { ...initial.midi, ...stored.midi }, haptics: { ...initial.haptics, ...cleanHaptics }, + autologging: { ...initial.autologging, ...stored.autologging }, }; } @@ -182,6 +195,8 @@ class PreferencesStore { return { ...state, midi: action.data }; case PrefActionType.SetHaptics: return { ...state, haptics: action.data }; + case PrefActionType.SetAutologging: + return { ...state, autologging: action.data }; default: return state; } From b92000daa820667acb14e48fc75235bfec9bb353 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Wed, 29 Apr 2026 13:58:11 -0600 Subject: [PATCH 03/15] Add autolog data model --- src/logging/AutoLogTypes.ts | 55 +++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 src/logging/AutoLogTypes.ts diff --git a/src/logging/AutoLogTypes.ts b/src/logging/AutoLogTypes.ts new file mode 100644 index 00000000..f6b45798 --- /dev/null +++ b/src/logging/AutoLogTypes.ts @@ -0,0 +1,55 @@ +export type AutoLogLineType = + | "command" + | "serverMessage" + | "systemInfo" + | "errorMessage"; + +export type AutoLogSourceType = + | "ansi" + | "html" + | "command" + | "system" + | "error" + | "unknown"; + +export type AutoLogMode = "default" | "local" | "host" | "join"; + +export interface AutoLogSession { + id: string; + startedAt: number; + endedAt?: number; + title: string; + mode: AutoLogMode; + sanitizedUrl: string; + lineCount: number; + byteEstimate: number; +} + +export interface AutoLogEntry { + sessionId: string; + sequence: number; + timestamp: number; + type: AutoLogLineType; + sourceType: AutoLogSourceType; + sourceContent: string; + metadata?: Record; +} + +export interface AutoLogInputLine { + type: AutoLogLineType; + sourceType: AutoLogSourceType; + sourceContent: string; + metadata?: Record; +} + +export interface AutoLogSessionDraft { + title: string; + mode: AutoLogMode; + sanitizedUrl: string; +} + +export const AUTOLOG_DB_NAME = "mongoose-autologs"; +export const AUTOLOG_DB_VERSION = 1; +export const AUTOLOG_SESSIONS_STORE = "sessions"; +export const AUTOLOG_ENTRIES_STORE = "entries"; +export const AUTOLOG_DEFAULT_MAX_BYTES = 100 * 1024 * 1024; From 877dadbfb9ede728b5345ca31b1be45b528b3d27 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Wed, 29 Apr 2026 13:59:12 -0600 Subject: [PATCH 04/15] Add IndexedDB autolog store --- src/logging/AutoLogStore.ts | 233 ++++++++++++++++++++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 src/logging/AutoLogStore.ts diff --git a/src/logging/AutoLogStore.ts b/src/logging/AutoLogStore.ts new file mode 100644 index 00000000..2d3f538f --- /dev/null +++ b/src/logging/AutoLogStore.ts @@ -0,0 +1,233 @@ +import { + AUTOLOG_DB_NAME, + AUTOLOG_DB_VERSION, + AUTOLOG_ENTRIES_STORE, + AUTOLOG_SESSIONS_STORE, + AutoLogEntry, + AutoLogSession, + AutoLogSessionDraft, +} from "./AutoLogTypes"; + +const SESSION_INDEX_STARTED_AT = "startedAt"; +const ENTRY_INDEX_SESSION_ID = "sessionId"; + +function requestToPromise(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +function transactionDone(transaction: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => reject(transaction.error); + }); +} + +function createSessionId(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID(); + } + + return `autolog-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +export function estimateAutoLogEntryBytes(entry: Pick): number { + const metadataBytes = entry.metadata ? JSON.stringify(entry.metadata).length : 0; + return entry.sourceContent.length * 2 + metadataBytes + 128; +} + +export class AutoLogStore { + private db: IDBDatabase | null = null; + + async open(): Promise { + if (this.db) { + return this.db; + } + + this.db = await new Promise((resolve, reject) => { + const request = indexedDB.open(AUTOLOG_DB_NAME, AUTOLOG_DB_VERSION); + + request.onupgradeneeded = () => { + const db = request.result; + + if (!db.objectStoreNames.contains(AUTOLOG_SESSIONS_STORE)) { + const sessions = db.createObjectStore(AUTOLOG_SESSIONS_STORE, { keyPath: "id" }); + sessions.createIndex(SESSION_INDEX_STARTED_AT, "startedAt", { unique: false }); + } + + if (!db.objectStoreNames.contains(AUTOLOG_ENTRIES_STORE)) { + const entries = db.createObjectStore(AUTOLOG_ENTRIES_STORE, { keyPath: ["sessionId", "sequence"] }); + entries.createIndex(ENTRY_INDEX_SESSION_ID, "sessionId", { unique: false }); + } + }; + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + + this.db.onversionchange = () => { + this.close(); + }; + + return this.db; + } + + close(): void { + this.db?.close(); + this.db = null; + } + + async createSession(draft: AutoLogSessionDraft, startedAt = Date.now()): Promise { + const db = await this.open(); + const session: AutoLogSession = { + id: createSessionId(), + startedAt, + title: draft.title, + mode: draft.mode, + sanitizedUrl: draft.sanitizedUrl, + lineCount: 0, + byteEstimate: 0, + }; + + const transaction = db.transaction(AUTOLOG_SESSIONS_STORE, "readwrite"); + transaction.objectStore(AUTOLOG_SESSIONS_STORE).put(session); + await transactionDone(transaction); + return session; + } + + async updateSession(session: AutoLogSession): Promise { + const db = await this.open(); + const transaction = db.transaction(AUTOLOG_SESSIONS_STORE, "readwrite"); + transaction.objectStore(AUTOLOG_SESSIONS_STORE).put(session); + await transactionDone(transaction); + } + + async endSession(sessionId: string, endedAt = Date.now()): Promise { + const session = await this.getSession(sessionId); + if (!session) { + return; + } + + await this.updateSession({ ...session, endedAt }); + } + + async appendEntries(entries: AutoLogEntry[]): Promise { + if (entries.length === 0) { + return; + } + + const sessionId = entries[0].sessionId; + const db = await this.open(); + const transaction = db.transaction([AUTOLOG_ENTRIES_STORE, AUTOLOG_SESSIONS_STORE], "readwrite"); + const entryStore = transaction.objectStore(AUTOLOG_ENTRIES_STORE); + const sessionStore = transaction.objectStore(AUTOLOG_SESSIONS_STORE); + + for (const entry of entries) { + entryStore.put(entry); + } + + const session = await requestToPromise(sessionStore.get(sessionId)); + if (session) { + const byteEstimate = entries.reduce((total, entry) => total + estimateAutoLogEntryBytes(entry), 0); + sessionStore.put({ + ...session, + lineCount: session.lineCount + entries.length, + byteEstimate: session.byteEstimate + byteEstimate, + }); + } + + await transactionDone(transaction); + } + + async getSession(sessionId: string): Promise { + const db = await this.open(); + const transaction = db.transaction(AUTOLOG_SESSIONS_STORE, "readonly"); + const session = await requestToPromise( + transaction.objectStore(AUTOLOG_SESSIONS_STORE).get(sessionId) + ); + await transactionDone(transaction); + return session; + } + + async listSessions(): Promise { + const db = await this.open(); + const transaction = db.transaction(AUTOLOG_SESSIONS_STORE, "readonly"); + const sessions = await requestToPromise( + transaction.objectStore(AUTOLOG_SESSIONS_STORE).index(SESSION_INDEX_STARTED_AT).getAll() + ); + await transactionDone(transaction); + return sessions.sort((a, b) => b.startedAt - a.startedAt); + } + + async getEntries(sessionId: string): Promise { + const db = await this.open(); + const transaction = db.transaction(AUTOLOG_ENTRIES_STORE, "readonly"); + const entries = await requestToPromise( + transaction.objectStore(AUTOLOG_ENTRIES_STORE).index(ENTRY_INDEX_SESSION_ID).getAll(sessionId) + ); + await transactionDone(transaction); + return entries.sort((a, b) => a.sequence - b.sequence); + } + + async deleteSession(sessionId: string): Promise { + const db = await this.open(); + const transaction = db.transaction([AUTOLOG_ENTRIES_STORE, AUTOLOG_SESSIONS_STORE], "readwrite"); + const entryIndex = transaction.objectStore(AUTOLOG_ENTRIES_STORE).index(ENTRY_INDEX_SESSION_ID); + const cursorRequest = entryIndex.openCursor(IDBKeyRange.only(sessionId)); + + await new Promise((resolve, reject) => { + cursorRequest.onsuccess = () => { + const cursor = cursorRequest.result; + if (!cursor) { + resolve(); + return; + } + + cursor.delete(); + cursor.continue(); + }; + cursorRequest.onerror = () => reject(cursorRequest.error); + }); + + transaction.objectStore(AUTOLOG_SESSIONS_STORE).delete(sessionId); + await transactionDone(transaction); + } + + async deleteAll(): Promise { + const db = await this.open(); + const transaction = db.transaction([AUTOLOG_ENTRIES_STORE, AUTOLOG_SESSIONS_STORE], "readwrite"); + transaction.objectStore(AUTOLOG_ENTRIES_STORE).clear(); + transaction.objectStore(AUTOLOG_SESSIONS_STORE).clear(); + await transactionDone(transaction); + } + + async getTotalByteEstimate(): Promise { + const sessions = await this.listSessions(); + return sessions.reduce((total, session) => total + session.byteEstimate, 0); + } + + async pruneToMaxBytes(maxBytes: number): Promise { + if (maxBytes <= 0) { + await this.deleteAll(); + return; + } + + const sessions = await this.listSessions(); + let total = sessions.reduce((sum, session) => sum + session.byteEstimate, 0); + const oldestFirst = [...sessions].sort((a, b) => a.startedAt - b.startedAt); + + for (const session of oldestFirst) { + if (total <= maxBytes) { + return; + } + + await this.deleteSession(session.id); + total -= session.byteEstimate; + } + } +} + +export const autoLogStore = new AutoLogStore(); From a30ee38418ec1cd13b5faa75f55479ea6a0623b8 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Wed, 29 Apr 2026 14:00:02 -0600 Subject: [PATCH 05/15] Add autolog service --- src/logging/AutoLogService.ts | 187 ++++++++++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 src/logging/AutoLogService.ts diff --git a/src/logging/AutoLogService.ts b/src/logging/AutoLogService.ts new file mode 100644 index 00000000..188c5b50 --- /dev/null +++ b/src/logging/AutoLogService.ts @@ -0,0 +1,187 @@ +import { preferencesStore } from "../PreferencesStore"; +import { AutoLogStore, autoLogStore } from "./AutoLogStore"; +import { + AutoLogEntry, + AutoLogInputLine, + AutoLogMode, + AutoLogSession, + AutoLogSessionDraft, +} from "./AutoLogTypes"; + +const FLUSH_INTERVAL_MS = 2000; +const FLUSH_BATCH_SIZE = 50; +const SENSITIVE_URL_PARAMS = new Set([ + "password", + "pass", + "token", + "access_token", + "refresh_token", + "username", + "user", +]); + +export function sanitizeLogUrl(url: string): string { + try { + const parsed = new URL(url); + for (const key of Array.from(parsed.searchParams.keys())) { + if (SENSITIVE_URL_PARAMS.has(key.toLowerCase())) { + parsed.searchParams.set(key, "[redacted]"); + } + } + return parsed.toString(); + } catch { + return ""; + } +} + +export function getAutoLogModeFromLocation(search: string): AutoLogMode { + const params = new URLSearchParams(search); + const mode = params.get("mode"); + const dbUrl = params.get("db"); + + if (mode === "host") return "host"; + if (mode === "join") return "join"; + if (mode === "local" || dbUrl !== null) return "local"; + return "default"; +} + +export function createAutoLogSessionDraft( + title: string, + location: Pick = window.location +): AutoLogSessionDraft { + return { + title, + mode: getAutoLogModeFromLocation(location.search), + sanitizedUrl: sanitizeLogUrl(location.href), + }; +} + +export class AutoLogService { + private store: AutoLogStore; + private sessionDraft: AutoLogSessionDraft | null = null; + private currentSession: AutoLogSession | null = null; + private pendingEntries: AutoLogEntry[] = []; + private sequence = 0; + private flushTimer: number | null = null; + private flushPromise: Promise = Promise.resolve(); + + constructor(store: AutoLogStore = autoLogStore) { + this.store = store; + preferencesStore.subscribe(() => { + const preferences = preferencesStore.getState().autologging; + if (!preferences.enabled) { + this.endSession().catch((error) => { + console.error("Failed to end autolog session after disabling autologging:", error); + }); + } else { + this.store.pruneToMaxBytes(preferences.maxBytes).catch((error) => { + console.error("Failed to prune autolog sessions:", error); + }); + } + }); + } + + configureSession(draft: AutoLogSessionDraft | null): void { + this.sessionDraft = draft; + } + + async startSession(): Promise { + if (this.currentSession || !this.sessionDraft || !preferencesStore.getState().autologging.enabled) { + return; + } + + this.currentSession = await this.store.createSession(this.sessionDraft); + this.sequence = 0; + } + + recordLine(line: AutoLogInputLine): void { + if (!preferencesStore.getState().autologging.enabled) { + return; + } + + this.ensureSession() + .then(() => { + if (!this.currentSession) { + return; + } + + this.pendingEntries.push({ + ...line, + sessionId: this.currentSession.id, + sequence: this.sequence++, + timestamp: Date.now(), + }); + + if (this.pendingEntries.length >= FLUSH_BATCH_SIZE) { + this.flush().catch((error) => { + console.error("Failed to flush autolog entries:", error); + }); + } else { + this.scheduleFlush(); + } + }) + .catch((error) => { + console.error("Failed to record autolog entry:", error); + }); + } + + async flush(): Promise { + if (this.flushTimer !== null) { + window.clearTimeout(this.flushTimer); + this.flushTimer = null; + } + + if (this.pendingEntries.length === 0) { + return this.flushPromise; + } + + const entries = this.pendingEntries; + this.pendingEntries = []; + const maxBytes = preferencesStore.getState().autologging.maxBytes; + + this.flushPromise = this.flushPromise + .then(() => this.store.appendEntries(entries)) + .then(() => this.store.pruneToMaxBytes(maxBytes)); + + return this.flushPromise; + } + + async endSession(): Promise { + const session = this.currentSession; + await this.flush(); + + if (session) { + await this.store.endSession(session.id); + } + + this.currentSession = null; + this.sequence = 0; + } + + dispose(): void { + if (this.flushTimer !== null) { + window.clearTimeout(this.flushTimer); + this.flushTimer = null; + } + } + + private async ensureSession(): Promise { + if (!this.currentSession) { + await this.startSession(); + } + } + + private scheduleFlush(): void { + if (this.flushTimer !== null) { + return; + } + + this.flushTimer = window.setTimeout(() => { + this.flush().catch((error) => { + console.error("Failed to flush autolog entries:", error); + }); + }, FLUSH_INTERVAL_MS); + } +} + +export const autoLogService = new AutoLogService(); From 93070984a3a8dc01a6b3dce5ee7ebb15cca4961f Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Wed, 29 Apr 2026 14:00:50 -0600 Subject: [PATCH 06/15] Add autolog export helpers --- src/logging/AutoLogExport.ts | 104 +++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 src/logging/AutoLogExport.ts diff --git a/src/logging/AutoLogExport.ts b/src/logging/AutoLogExport.ts new file mode 100644 index 00000000..b2745450 --- /dev/null +++ b/src/logging/AutoLogExport.ts @@ -0,0 +1,104 @@ +import DOMPurify from "dompurify"; +import stripAnsi from "strip-ansi"; +import { AutoLogEntry, AutoLogSession } from "./AutoLogTypes"; + +export type AutoLogDownloadFormat = "text" | "html"; + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function htmlToPlainText(html: string): string { + const clean = DOMPurify.sanitize(html); + const doc = new DOMParser().parseFromString(clean, "text/html"); + return doc.body.textContent || ""; +} + +export function autoLogEntryToPlainText(entry: AutoLogEntry): string { + switch (entry.sourceType) { + case "html": + return htmlToPlainText(entry.sourceContent); + case "ansi": + return stripAnsi(entry.sourceContent); + default: + return entry.sourceContent; + } +} + +export function autoLogEntriesToText(entries: AutoLogEntry[]): string { + return entries + .map((entry) => { + const timestamp = new Date(entry.timestamp).toISOString(); + return `[${timestamp}] ${autoLogEntryToPlainText(entry)}`; + }) + .join("\n"); +} + +function entryToHtml(entry: AutoLogEntry): string { + const timestamp = escapeHtml(new Date(entry.timestamp).toISOString()); + const className = `autolog-entry autolog-entry-${escapeHtml(entry.type)}`; + + if (entry.sourceType === "html") { + return `
${timestamp}
${DOMPurify.sanitize(entry.sourceContent)}
`; + } + + const content = entry.sourceType === "ansi" ? stripAnsi(entry.sourceContent) : entry.sourceContent; + return `
${timestamp}
${escapeHtml(content)}
`; +} + +export function autoLogEntriesToHtml(session: AutoLogSession, entries: AutoLogEntry[]): string { + const title = escapeHtml(session.title || "Mongoose Client"); + const startedAt = escapeHtml(new Date(session.startedAt).toISOString()); + const endedAt = session.endedAt ? escapeHtml(new Date(session.endedAt).toISOString()) : "In progress"; + const url = escapeHtml(session.sanitizedUrl); + + return ` + + + + ${title} log ${startedAt} + + + +
+

${title}

+
Started: ${startedAt}
Ended: ${endedAt}
URL: ${url}
+ ${entries.map(entryToHtml).join("\n")} +
+ +`; +} + +export function buildAutoLogFilename(session: AutoLogSession, format: AutoLogDownloadFormat): string { + const startedAt = new Date(session.startedAt).toISOString().replace(/[:.]/g, "-"); + const title = (session.title || "mongoose-client").replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, ""); + return `${title || "mongoose-client"}-autolog-${startedAt}.${format === "html" ? "html" : "txt"}`; +} + +export function downloadAutoLog(content: string, filename: string, format: AutoLogDownloadFormat): void { + const blob = new Blob([content], { type: format === "html" ? "text/html" : "text/plain" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); +} From c06ddd4de55f37436e74e4925b53b343880405ee Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Wed, 29 Apr 2026 14:01:17 -0600 Subject: [PATCH 07/15] Capture output lines for autologging --- src/components/output.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/components/output.tsx b/src/components/output.tsx index 9d28467d..ea0745c9 100644 --- a/src/components/output.tsx +++ b/src/components/output.tsx @@ -9,6 +9,8 @@ import { setInputText } from '../InputStore'; import TurndownService from 'turndown'; // <-- Import TurndownService import { preferencesStore } from '../PreferencesStore'; // Import preferences store import BlockquoteWithCopy from './BlockquoteWithCopy'; +import { autoLogService } from '../logging/AutoLogService'; +import { AutoLogLineType, AutoLogSourceType } from '../logging/AutoLogTypes'; export enum OutputType { Command = 'command', @@ -446,6 +448,15 @@ componentDidUpdate( this.allLines.push(...newOutputLines); this.totalLinesAdded += newOutputLines.length; + if (sourceContent !== "") { + autoLogService.recordLine({ + type: type as unknown as AutoLogLineType, + sourceType: sourceType as AutoLogSourceType, + sourceContent, + metadata, + }); + } + // Trim if over max if (this.allLines.length > Output.MAX_OUTPUT_LENGTH) { const excess = this.allLines.length - Output.MAX_OUTPUT_LENGTH; From 4e26bebd298234df11fb38a0e4083c2f591b8375 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Wed, 29 Apr 2026 14:01:50 -0600 Subject: [PATCH 08/15] Wire autolog session lifecycle --- src/App.tsx | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/App.tsx b/src/App.tsx index 31241d8a..89a1747c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,6 +22,7 @@ import HostPanel from "./components/HostPanel"; import { useChannelHistory } from "./hooks/useChannelHistory"; import { FileTransferOffer, useClientEvent } from "./hooks/useClientEvent"; import type { GMCPMessageRoomInfo } from "./gmcp/Room"; +import { autoLogService, createAutoLogSessionDraft } from "./logging/AutoLogService"; const WINDOW_TITLE = "Mongoose Client"; @@ -122,6 +123,45 @@ function App() { } }, [client]); + useEffect(() => { + if (!client) { + autoLogService.configureSession(null); + return; + } + + const configureAutologSession = () => { + autoLogService.configureSession(createAutoLogSessionDraft(document.title || WINDOW_TITLE)); + }; + const handleConnect = () => { + configureAutologSession(); + autoLogService.startSession().catch((error) => { + console.error("Failed to start autolog session:", error); + }); + }; + const handleDisconnect = () => { + autoLogService.endSession().catch((error) => { + console.error("Failed to end autolog session:", error); + }); + }; + + configureAutologSession(); + if (client.connected) { + handleConnect(); + } + + client.on("connect", handleConnect); + client.on("disconnect", handleDisconnect); + + return () => { + client.off("connect", handleConnect); + client.off("disconnect", handleDisconnect); + autoLogService.endSession().catch((error) => { + console.error("Failed to end autolog session during cleanup:", error); + }); + autoLogService.configureSession(null); + }; + }, [client]); + // Common client setup: notifications, auto-login, MIDI, haptics, keyboard handlers useEffect(() => { if (!client) return; @@ -316,6 +356,9 @@ function App() { if (client) { client.shutdown(); } + autoLogService.flush().catch((error) => { + console.error("Failed to flush autolog entries before unload:", error); + }); // Best-effort checkpoint on tab close const wasmWorker = (window as any).wasmWorker; if (wasmWorker) { From 1f14f6a864f6f925128a4b60a5205bf974b240c9 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Wed, 29 Apr 2026 14:03:22 -0600 Subject: [PATCH 09/15] Add autolog management dialog --- src/components/AutoLogDialog.css | 188 +++++++++++++++++++++++++ src/components/AutoLogDialog.tsx | 231 +++++++++++++++++++++++++++++++ 2 files changed, 419 insertions(+) create mode 100644 src/components/AutoLogDialog.css create mode 100644 src/components/AutoLogDialog.tsx diff --git a/src/components/AutoLogDialog.css b/src/components/AutoLogDialog.css new file mode 100644 index 00000000..1ed93562 --- /dev/null +++ b/src/components/AutoLogDialog.css @@ -0,0 +1,188 @@ +.autolog-dialog { + position: fixed; + inset: 5vh 4vw; + width: min(1100px, 92vw); + max-height: 90vh; + margin: auto; + padding: 0; + background: var(--color-bg-surface); + color: var(--color-text); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-xl); + z-index: 1200; +} + +.autolog-dialog-header, +.autolog-dialog-toolbar { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border); +} + +.autolog-dialog-header h1 { + flex: 1; + margin: 0; + font-size: 1.25rem; +} + +.autolog-dialog-toolbar { + color: var(--color-text-secondary); + font-size: var(--font-size-sm); +} + +.autolog-dialog-toolbar span { + flex: 1; +} + +.autolog-dialog button { + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-sm); + background: var(--color-bg-hover); + color: var(--color-text); + cursor: pointer; + padding: var(--space-2) var(--space-3); +} + +.autolog-dialog button:hover { + background: var(--color-bg-active); + border-color: var(--color-primary); +} + +.autolog-dialog button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.autolog-dialog-error { + margin: var(--space-3) var(--space-4) 0; + padding: var(--space-2) var(--space-3); + border: 1px solid var(--color-danger); + border-radius: var(--radius-sm); + color: var(--color-danger); + background: var(--color-danger-subtle); +} + +.autolog-dialog-body { + display: grid; + grid-template-columns: minmax(260px, 360px) minmax(0, 1fr); + min-height: 0; + height: calc(90vh - 112px); +} + +.autolog-session-list, +.autolog-entry-viewer { + min-height: 0; + overflow: auto; + padding: var(--space-3); +} + +.autolog-session-list { + border-right: 1px solid var(--color-border); +} + +.autolog-session-row { + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + margin-bottom: var(--space-2); + background: var(--color-bg); +} + +.autolog-session-row.selected { + border-color: var(--color-primary); +} + +.autolog-session-main { + display: block; + width: 100%; + text-align: left; + background: transparent !important; + border: 0 !important; +} + +.autolog-session-title { + display: block; + font-weight: 600; + margin-bottom: var(--space-1); +} + +.autolog-session-meta, +.autolog-entry-meta { + display: block; + color: var(--color-text-secondary); + font-size: var(--font-size-xs); +} + +.autolog-session-actions { + display: flex; + gap: var(--space-1); + padding: 0 var(--space-2) var(--space-2); +} + +.autolog-session-actions button { + flex: 1; + padding: var(--space-1) var(--space-2); + font-size: var(--font-size-xs); +} + +.autolog-entry-viewer h2 { + margin: 0 0 var(--space-1); + font-size: 1rem; +} + +.autolog-entry-list { + margin-top: var(--space-3); +} + +.autolog-entry { + margin: 0; + padding: var(--space-2); + border-top: 1px solid var(--color-border); + white-space: pre-wrap; + overflow-wrap: anywhere; + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); +} + +.autolog-entry-time { + display: block; + color: var(--color-text-tertiary); + font-size: var(--font-size-xs); + margin-bottom: var(--space-1); +} + +.autolog-entry-systemInfo { + color: var(--color-info); +} + +.autolog-entry-errorMessage { + color: var(--color-danger); +} + +.autolog-entry-command { + color: var(--color-text-secondary); +} + +.autolog-empty { + color: var(--color-text-secondary); +} + +@media (max-width: 768px) { + .autolog-dialog { + inset: 2vh 2vw; + width: 96vw; + } + + .autolog-dialog-body { + grid-template-columns: 1fr; + height: calc(96vh - 112px); + } + + .autolog-session-list { + max-height: 34vh; + border-right: 0; + border-bottom: 1px solid var(--color-border); + } +} diff --git a/src/components/AutoLogDialog.tsx b/src/components/AutoLogDialog.tsx new file mode 100644 index 00000000..833c8c3e --- /dev/null +++ b/src/components/AutoLogDialog.tsx @@ -0,0 +1,231 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import FocusLock from "react-focus-lock"; +import { + autoLogEntriesToHtml, + autoLogEntriesToText, + autoLogEntryToPlainText, + buildAutoLogFilename, + downloadAutoLog, +} from "../logging/AutoLogExport"; +import { autoLogStore } from "../logging/AutoLogStore"; +import { AutoLogEntry, AutoLogSession } from "../logging/AutoLogTypes"; +import "./AutoLogDialog.css"; + +export type AutoLogDialogRef = { + open: () => void; + close: () => void; +}; + +function formatBytes(value: number): string { + if (value < 1024) return `${value} B`; + if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`; + return `${(value / 1024 / 1024).toFixed(1)} MB`; +} + +function formatSessionDate(value: number): string { + return new Date(value).toLocaleString(); +} + +function getSessionDuration(session: AutoLogSession): string { + if (!session.endedAt) { + return "In progress"; + } + + const seconds = Math.max(0, Math.round((session.endedAt - session.startedAt) / 1000)); + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return minutes > 0 ? `${minutes}m ${remainingSeconds}s` : `${remainingSeconds}s`; +} + +const AutoLogDialog = React.forwardRef((_, ref) => { + const [isOpen, setIsOpen] = useState(false); + const [sessions, setSessions] = useState([]); + const [selectedSession, setSelectedSession] = useState(null); + const [entries, setEntries] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const refreshSessions = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const nextSessions = await autoLogStore.listSessions(); + setSessions(nextSessions); + if (selectedSession && !nextSessions.some((session) => session.id === selectedSession.id)) { + setSelectedSession(null); + setEntries([]); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load autolog sessions."); + } finally { + setIsLoading(false); + } + }, [selectedSession]); + + const loadSessionEntries = useCallback(async (session: AutoLogSession) => { + setSelectedSession(session); + setIsLoading(true); + setError(null); + try { + setEntries(await autoLogStore.getEntries(session.id)); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load autolog entries."); + } finally { + setIsLoading(false); + } + }, []); + + const handleDelete = useCallback(async (session: AutoLogSession) => { + setIsLoading(true); + setError(null); + try { + await autoLogStore.deleteSession(session.id); + if (selectedSession?.id === session.id) { + setSelectedSession(null); + setEntries([]); + } + await refreshSessions(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to delete autolog session."); + } finally { + setIsLoading(false); + } + }, [refreshSessions, selectedSession]); + + const handleDeleteAll = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + await autoLogStore.deleteAll(); + setSelectedSession(null); + setEntries([]); + await refreshSessions(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to delete autolog sessions."); + } finally { + setIsLoading(false); + } + }, [refreshSessions]); + + const handleDownload = useCallback(async (session: AutoLogSession, format: "text" | "html") => { + setIsLoading(true); + setError(null); + try { + const sessionEntries = selectedSession?.id === session.id ? entries : await autoLogStore.getEntries(session.id); + const content = format === "html" + ? autoLogEntriesToHtml(session, sessionEntries) + : autoLogEntriesToText(sessionEntries); + downloadAutoLog(content, buildAutoLogFilename(session, format), format); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to download autolog session."); + } finally { + setIsLoading(false); + } + }, [entries, selectedSession]); + + React.useImperativeHandle(ref, () => ({ + open() { + setIsOpen(true); + }, + close() { + setIsOpen(false); + }, + })); + + useEffect(() => { + if (isOpen) { + refreshSessions(); + } + }, [isOpen, refreshSessions]); + + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setIsOpen(false); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isOpen]); + + const totalBytes = useMemo( + () => sessions.reduce((total, session) => total + session.byteEstimate, 0), + [sessions] + ); + + if (!isOpen) { + return null; + } + + return ( + + +
+

Autologs

+ +
+ +
+ {sessions.length} sessions, {formatBytes(totalBytes)} + + +
+ + {error &&
{error}
} + +
+
+ {sessions.length === 0 && !isLoading && ( +

No autolog sessions have been saved.

+ )} + {sessions.map((session) => ( +
+ +
+ + + +
+
+ ))} +
+ +
+ {selectedSession ? ( + <> +

{selectedSession.title}

+
+ {formatSessionDate(selectedSession.startedAt)} · {selectedSession.sanitizedUrl} +
+
+ {entries.map((entry) => ( +
+                      {new Date(entry.timestamp).toLocaleTimeString()}
+                      {autoLogEntryToPlainText(entry)}
+                    
+ ))} + {entries.length === 0 && !isLoading &&

No entries in this session.

} +
+ + ) : ( +

Select a session to view its entries.

+ )} +
+
+
+
+ ); +}); + +export default AutoLogDialog; From 75b0ccf248ef4f69e888c932ea013cbd69819dd4 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Wed, 29 Apr 2026 14:03:50 -0600 Subject: [PATCH 10/15] Add optional toolbar autolog button --- src/components/toolbar.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/components/toolbar.tsx b/src/components/toolbar.tsx index 5ee6fd1c..380b62bc 100644 --- a/src/components/toolbar.tsx +++ b/src/components/toolbar.tsx @@ -9,6 +9,7 @@ import { FaVolumeUp, FaChevronRight, FaChevronLeft, + FaHistory, } from "react-icons/fa"; import type MudClient from "../client"; import { preferencesStore, PrefActionType } from "../PreferencesStore"; @@ -22,6 +23,7 @@ export interface ToolbarProps { onCopyLog: () => void; // <-- Add onCopyLog prop onToggleSidebar: () => void; onOpenPrefs: () => void; + onOpenLogs?: () => void; showSidebar?: boolean; } @@ -32,6 +34,7 @@ const Toolbar = ({ onCopyLog, // <-- Destructure onCopyLog onToggleSidebar, onOpenPrefs, + onOpenLogs, showSidebar, }: ToolbarProps) => { const connected = useClientEvent(client, 'connectionChange', client.connected); @@ -86,6 +89,12 @@ const Toolbar = ({ Clear Log + {onOpenLogs && ( + + )}
From da950004ffb322e5203bf5c8344e8b7bd54b4eab Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Wed, 29 Apr 2026 14:04:14 -0600 Subject: [PATCH 11/15] Mount autolog dialog --- src/App.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/App.tsx b/src/App.tsx index 89a1747c..5a3021c0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,7 @@ import OutputWindow from "./components/output"; import PreferencesDialog, { PreferencesDialogRef, } from "./components/PreferencesDialog"; +import AutoLogDialog, { AutoLogDialogRef } from "./components/AutoLogDialog"; import Sidebar, { SidebarRef } from "./components/sidebar"; import Statusbar from "./components/statusbar"; import Toolbar from "./components/toolbar"; @@ -51,6 +52,7 @@ function App() { const outRef = React.useRef(null); const inRef = React.useRef(null); const prefsDialogRef = React.useRef(null); + const autoLogDialogRef = React.useRef(null); const sidebarRef = React.useRef(null); const clientInitialized = useRef(false); @@ -405,6 +407,7 @@ function App() { onCopyLog={copyLog} onToggleSidebar={() => setShowSidebar(!showSidebar)} onOpenPrefs={() => prefsDialogRef.current?.open()} + onOpenLogs={() => autoLogDialogRef.current?.open()} showSidebar={showSidebar} /> @@ -437,6 +440,7 @@ function App() { +
)} {/* Default mode with no client yet — blank */} From fd25c3cda70f7abe91de63af2ac91c2db3019bb8 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Wed, 29 Apr 2026 14:04:53 -0600 Subject: [PATCH 12/15] Add autologging preferences tab --- src/components/preferences.tsx | 70 ++++++++++++++++++++++++++++------ 1 file changed, 59 insertions(+), 11 deletions(-) diff --git a/src/components/preferences.tsx b/src/components/preferences.tsx index 735c41e4..ed8a39ee 100644 --- a/src/components/preferences.tsx +++ b/src/components/preferences.tsx @@ -3,6 +3,7 @@ import { PrefActionType, NavigationKeyScheme } from "../PreferencesStore"; import { usePreferences } from "../hooks/usePreferences"; import { useVoices } from "../hooks/useVoices"; import Tabs, { TabProps } from "./tabs"; +import AutoLogDialog, { AutoLogDialogRef } from "./AutoLogDialog"; const GeneralTab: React.FC = () => { const [state, dispatch] = usePreferences(); @@ -420,8 +421,8 @@ const HapticsTab: React.FC = () => { ); }; -const KeyboardTab: React.FC = () => { - const [state, dispatch] = usePreferences(); +const KeyboardTab: React.FC = () => { + const [state, dispatch] = usePreferences(); return (
@@ -446,19 +447,66 @@ const KeyboardTab: React.FC = () => { Arrow keys always work in addition to the selected scheme.

- ); -}; - -const Preferences: React.FC = () => { - const tabs: TabProps[] = [ - { id: "preferences-general-tab", label: "General", content: }, + ); +}; + +const AutologgingTab: React.FC = () => { + const [state, dispatch] = usePreferences(); + const dialogRef = React.useRef(null); + const maxMegabytes = Math.round(state.autologging.maxBytes / 1024 / 1024); + + return ( +
+ +
+ +
+ + +
+ ); +}; + +const Preferences: React.FC = () => { + const tabs: TabProps[] = [ + { id: "preferences-general-tab", label: "General", content: }, { id: "preferences-speech-tab", label: "Speech", content: }, { id: "preferences-sounds-tab", label: "Sounds", content: }, { id: "preferences-editor-tab", label: "Editor", content: }, { id: "preferences-keyboard-tab", label: "Keyboard", content: }, - { id: "preferences-midi-tab", label: "MIDI", content: }, - { id: "preferences-haptics-tab", label: "Haptics", content: }, - ]; + { id: "preferences-midi-tab", label: "MIDI", content: }, + { id: "preferences-haptics-tab", label: "Haptics", content: }, + { id: "preferences-autologging-tab", label: "Logging", content: }, + ]; return ; }; From 5a4c88cc7e71bb1365097ed05400c8e0ee01f859 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Wed, 29 Apr 2026 14:05:19 -0600 Subject: [PATCH 13/15] Enable IndexedDB in tests --- src/vitest.setup.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/vitest.setup.ts b/src/vitest.setup.ts index 66bed1e1..5f338887 100644 --- a/src/vitest.setup.ts +++ b/src/vitest.setup.ts @@ -1,4 +1,5 @@ import { vi } from 'vitest'; +import 'fake-indexeddb/auto'; // Mock BroadcastChannel global.BroadcastChannel = vi.fn().mockImplementation(() => ({ @@ -24,4 +25,4 @@ Object.defineProperty(window, 'localStorage', { Object.defineProperty(window, 'open', { value: vi.fn(), writable: true, -}); \ No newline at end of file +}); From 4004b7d8198dade7d01156a3b5137968804c3f3b Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Wed, 29 Apr 2026 14:05:52 -0600 Subject: [PATCH 14/15] Test autolog store --- src/logging/AutoLogStore.test.ts | 85 ++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 src/logging/AutoLogStore.test.ts diff --git a/src/logging/AutoLogStore.test.ts b/src/logging/AutoLogStore.test.ts new file mode 100644 index 00000000..a71c5df8 --- /dev/null +++ b/src/logging/AutoLogStore.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { AutoLogStore } from "./AutoLogStore"; +import { AUTOLOG_DB_NAME, AutoLogEntry, AutoLogSessionDraft } from "./AutoLogTypes"; + +function deleteAutoLogDatabase(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(AUTOLOG_DB_NAME); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + request.onblocked = () => reject(new Error("Autolog database delete was blocked.")); + }); +} + +const draft: AutoLogSessionDraft = { + title: "Test Session", + mode: "default", + sanitizedUrl: "https://example.test/", +}; + +function makeEntry(sessionId: string, sequence: number, sourceContent: string): AutoLogEntry { + return { + sessionId, + sequence, + timestamp: 1000 + sequence, + type: "serverMessage", + sourceType: "ansi", + sourceContent, + }; +} + +describe("AutoLogStore", () => { + beforeEach(async () => { + await deleteAutoLogDatabase(); + }); + + it("creates sessions and appends entries", async () => { + const store = new AutoLogStore(); + const session = await store.createSession(draft, 100); + await store.appendEntries([ + makeEntry(session.id, 0, "first"), + makeEntry(session.id, 1, "second"), + ]); + + const sessions = await store.listSessions(); + const entries = await store.getEntries(session.id); + + expect(sessions).toHaveLength(1); + expect(sessions[0].lineCount).toBe(2); + expect(sessions[0].byteEstimate).toBeGreaterThan(0); + expect(entries.map((entry) => entry.sourceContent)).toEqual(["first", "second"]); + + store.close(); + }); + + it("deletes a session and its entries", async () => { + const store = new AutoLogStore(); + const session = await store.createSession(draft, 100); + await store.appendEntries([makeEntry(session.id, 0, "line")]); + + await store.deleteSession(session.id); + + expect(await store.listSessions()).toEqual([]); + expect(await store.getEntries(session.id)).toEqual([]); + + store.close(); + }); + + it("prunes oldest whole sessions over the storage cap", async () => { + const store = new AutoLogStore(); + const oldSession = await store.createSession({ ...draft, title: "Old" }, 100); + const newSession = await store.createSession({ ...draft, title: "New" }, 200); + await store.appendEntries([makeEntry(oldSession.id, 0, "old ".repeat(100))]); + await store.appendEntries([makeEntry(newSession.id, 0, "new")]); + const newOnlyCap = (await store.getSession(newSession.id))!.byteEstimate + 1; + + await store.pruneToMaxBytes(newOnlyCap); + + const sessions = await store.listSessions(); + expect(sessions.map((session) => session.title)).toEqual(["New"]); + expect(await store.getEntries(oldSession.id)).toEqual([]); + expect(await store.getEntries(newSession.id)).toHaveLength(1); + + store.close(); + }); +}); From ed361f1d4df77e3eef9e847e963ec83a2201edb4 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Wed, 29 Apr 2026 14:06:35 -0600 Subject: [PATCH 15/15] Test autolog service --- src/logging/AutoLogService.test.ts | 116 +++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 src/logging/AutoLogService.test.ts diff --git a/src/logging/AutoLogService.test.ts b/src/logging/AutoLogService.test.ts new file mode 100644 index 00000000..d9eb4b8d --- /dev/null +++ b/src/logging/AutoLogService.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { preferencesStore, PrefActionType } from "../PreferencesStore"; +import { AutoLogStore } from "./AutoLogStore"; +import { AutoLogService, createAutoLogSessionDraft, getAutoLogModeFromLocation, sanitizeLogUrl } from "./AutoLogService"; +import { AutoLogEntry, AutoLogSession, AutoLogSessionDraft } from "./AutoLogTypes"; + +class FakeAutoLogStore { + sessions: AutoLogSession[] = []; + entries: AutoLogEntry[] = []; + prunedTo: number[] = []; + ended: string[] = []; + + async createSession(draft: AutoLogSessionDraft): Promise { + const session: AutoLogSession = { + id: `session-${this.sessions.length}`, + startedAt: Date.now(), + lineCount: 0, + byteEstimate: 0, + ...draft, + }; + this.sessions.push(session); + return session; + } + + async appendEntries(entries: AutoLogEntry[]): Promise { + this.entries.push(...entries); + } + + async pruneToMaxBytes(maxBytes: number): Promise { + this.prunedTo.push(maxBytes); + } + + async endSession(sessionId: string): Promise { + this.ended.push(sessionId); + } +} + +describe("AutoLogService", () => { + beforeEach(() => { + vi.useRealTimers(); + preferencesStore.dispatch({ + type: PrefActionType.SetAutologging, + data: { enabled: false, maxBytes: 1000 }, + }); + }); + + it("redacts sensitive URL parameters", () => { + const sanitized = sanitizeLogUrl("https://example.test/?username=q&password=secret&room=1"); + + expect(sanitized).toContain("username=%5Bredacted%5D"); + expect(sanitized).toContain("password=%5Bredacted%5D"); + expect(sanitized).toContain("room=1"); + }); + + it("detects URL mode for session metadata", () => { + expect(getAutoLogModeFromLocation("?mode=join")).toBe("join"); + expect(getAutoLogModeFromLocation("?mode=host")).toBe("host"); + expect(getAutoLogModeFromLocation("?db=/Minimal.db")).toBe("local"); + expect(getAutoLogModeFromLocation("")).toBe("default"); + }); + + it("records and flushes entries when enabled", async () => { + const store = new FakeAutoLogStore(); + const service = new AutoLogService(store as unknown as AutoLogStore); + service.configureSession(createAutoLogSessionDraft("Test", { + href: "https://example.test/?password=secret", + search: "?password=secret", + })); + + preferencesStore.dispatch({ + type: PrefActionType.SetAutologging, + data: { enabled: true, maxBytes: 1000 }, + }); + + service.recordLine({ + type: "serverMessage", + sourceType: "ansi", + sourceContent: "hello", + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + await service.flush(); + + expect(store.sessions).toHaveLength(1); + expect(store.sessions[0].sanitizedUrl).toContain("password=%5Bredacted%5D"); + expect(store.entries).toHaveLength(1); + expect(store.entries[0]).toMatchObject({ + sessionId: "session-0", + sequence: 0, + sourceContent: "hello", + }); + expect(store.prunedTo).toContain(1000); + + service.dispose(); + }); + + it("ignores entries when disabled", async () => { + const store = new FakeAutoLogStore(); + const service = new AutoLogService(store as unknown as AutoLogStore); + service.configureSession({ title: "Test", mode: "default", sanitizedUrl: "https://example.test/" }); + + service.recordLine({ + type: "serverMessage", + sourceType: "ansi", + sourceContent: "hello", + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + await service.flush(); + + expect(store.sessions).toEqual([]); + expect(store.entries).toEqual([]); + + service.dispose(); + }); +});