diff --git a/README.md b/README.md index 9765d37..f754e38 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,36 @@ + +![LogHighlighter — Highlight, filter, and share raw logs in your browser. No build, no backend.](assets/banner.png) + # Log Highlighter [![CI](https://github.com/al-af/LogHighlighter/actions/workflows/ci.yml/badge.svg)](https://github.com/al-af/LogHighlighter/actions/workflows/ci.yml) A static, no-build browser tool for highlighting and filtering raw logs. Paste log text, define keyword groups (each gets a distinct pastel color), and the output panel highlights matches. Embedded JSON and Apple `NSDictionary` payloads inside log lines are detected and pretty-printed inline. Android `logcat` lines (threadtime, time, and brief formats) are detected and colored by severity (V/D/I/W/E/F/A), so iOS and Android logs both render usefully. +## Quickstart + +**Try it instantly** on GitHub Pages: . + +**Use it in 30 seconds:** + +1. Pick a starter preset from the **Presets** dropdown — iOS SDK, Android SDK, React Native bridge, or OkHttp. The groups load immediately. +2. Paste any log into the **Input** panel. +3. Matching lines stay; non-matching lines collapse into `···` separators. Use the **Full** toggle to see everything. +4. Press n / Shift+N to jump between matches. The counter next to **Output** shows your position. +5. Click ? Guide in the header for the full feature tour. + +**Make it yours:** + +- Define your own keyword groups in the **Keyword Groups** panel (comma-separated, one chip per keyword). +- Click + New preset to save a setup with a name. Pick it from the dropdown anytime to reload. +- Click Share to copy a URL with your groups encoded — send it to a teammate. **Log content is never included.** + +**For teams:** + +- Share the GitHub Pages URL with your team — no install, no backend. +- Use Share to standardize keyword setups across the team. +- Click the moon / sun icon to switch between light and dark themes. + ## Structure ``` diff --git a/assets/banner.png b/assets/banner.png new file mode 100644 index 0000000..88deb91 Binary files /dev/null and b/assets/banner.png differ diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..8edbc58 Binary files /dev/null and b/assets/icon.png differ diff --git a/index.html b/index.html index dea451c..5643a71 100644 --- a/index.html +++ b/index.html @@ -3,6 +3,8 @@ Log Highlighter + + @@ -13,6 +15,7 @@

Log Highlighter

+ @@ -48,7 +51,7 @@

Input

-

Output

+

Output

Add a keyword group and paste logs to begin.
diff --git a/package.json b/package.json index d56ddae..2815535 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "loghighlighter", - "version": "0.1.0", + "version": "0.1.1", "private": true, "type": "module", "scripts": { diff --git a/src/guide.js b/src/guide.js index 6365081..0d4db39 100644 --- a/src/guide.js +++ b/src/guide.js @@ -31,6 +31,7 @@ const HTML = `
  • + New preset — opens an empty editor where you give the preset a name and define its keyword groups (one group per line, comma-separated keywords).
  • Picking a preset from the dropdown applies it immediately, replacing your current groups.
  • × — delete the selected preset (with confirmation).
  • +
  • Four starter presets ship by default: iOS SDK, Android SDK, React Native bridge, OkHttp. Delete the ones you don't use — they won't come back on reload.
  • @@ -60,13 +61,18 @@ const HTML = `
  • F Fatal — dark red, bold
  • A Assert — purple tint
  • -

    iOS-style log lines without a logcat level are unaffected.

    +

    The same severity scheme applies to Apple os_log output: Debug maps to Verbose, Info stays Info, Default/Notice become Debug-blue, Error stays Error, Fault stays Fatal.

    +

    Plain NSLog-style lines without an explicit level marker pass through uncolored.

    Tips

    diff --git a/src/lineNav.js b/src/lineNav.js new file mode 100644 index 0000000..3b602de --- /dev/null +++ b/src/lineNav.js @@ -0,0 +1,70 @@ +// Tracks the set of elements inside #output and exposes +// next/prev navigation plus a count to render in the header slot. + +let currentIdx = -1; + +function getMarks() { + return Array.from(document.querySelectorAll('#output mark')); +} + +function clearCurrent(marks) { + for (const m of marks) m.classList.remove('mark-current'); +} + +function applyCurrent(marks) { + if (currentIdx < 0 || currentIdx >= marks.length) return; + const el = marks[currentIdx]; + el.classList.add('mark-current'); + if (typeof el.scrollIntoView === 'function') { + el.scrollIntoView({ block: 'center', behavior: 'smooth' }); + } +} + +function renderCounter(total) { + const el = document.getElementById('matchCount'); + if (!el) return; + if (total === 0) { el.hidden = true; el.textContent = ''; return; } + el.hidden = false; + el.textContent = currentIdx >= 0 + ? `— ${currentIdx + 1} of ${total} matches` + : `— ${total} matches`; +} + +export function refreshAfterRender() { + currentIdx = -1; + const marks = getMarks(); + clearCurrent(marks); + renderCounter(marks.length); +} + +function step(delta) { + const marks = getMarks(); + if (marks.length === 0) return; + clearCurrent(marks); + currentIdx = currentIdx === -1 + ? (delta > 0 ? 0 : marks.length - 1) + : (currentIdx + delta + marks.length) % marks.length; + applyCurrent(marks); + renderCounter(marks.length); +} + +function isTypingTarget(el) { + if (!el) return false; + const tag = el.tagName; + return tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable; +} + +let keyboardAttached = false; + +function onKeydown(e) { + if (isTypingTarget(e.target)) return; + if (e.metaKey || e.ctrlKey || e.altKey) return; + if (e.key === 'n' && !e.shiftKey) { e.preventDefault(); step(1); } + else if (e.key === 'N' || (e.key === 'n' && e.shiftKey)) { e.preventDefault(); step(-1); } +} + +export function attachKeyboard() { + if (keyboardAttached) return; + keyboardAttached = true; + document.addEventListener('keydown', onKeydown); +} diff --git a/src/main.js b/src/main.js index 94417f6..5a6933b 100644 --- a/src/main.js +++ b/src/main.js @@ -7,6 +7,9 @@ import { listPresets, savePreset, loadPreset, deletePreset, onChange as onPreset import { encodeGroups, consumeHash } from './share.js'; import { openGuide } from './guide.js'; import { openPresetEditor } from './presetEditor.js'; +import { seedStarterPresets } from './starterPresets.js'; +import { attachKeyboard } from './lineNav.js'; +import { applyInitialTheme, toggleTheme, syncToggleButton } from './theme.js'; const STATE_KEY = 'loghl:state'; @@ -193,6 +196,9 @@ document.getElementById('copyShareLink').addEventListener('click', copyShareLink document.getElementById('openGuide').addEventListener('click', openGuide); +const themeBtn = document.getElementById('toggleTheme'); +themeBtn.addEventListener('click', () => syncToggleButton(themeBtn, toggleTheme())); + window.addEventListener('hashchange', () => { const r = consumeHash(); if (r.ok) { @@ -209,9 +215,12 @@ window.addEventListener('hashchange', () => { // ── initial render ────────────────────────────────────────────────────────── +syncToggleButton(themeBtn, applyInitialTheme()); +seedStarterPresets(); renderPresetSelect(); syncModeButtons(); syncShareButton(); syncPresetSelectionButtons(); renderGroups(); renderOutput(); +attachKeyboard(); diff --git a/src/oslog.js b/src/oslog.js new file mode 100644 index 0000000..429518b --- /dev/null +++ b/src/oslog.js @@ -0,0 +1,27 @@ +// Detects Apple `os_log` / `log show` / `log stream` lines and maps their +// level keywords to the same severity letters used by logcat so the +// existing .lc-* CSS classes light up correctly. + +// Matches `log show --style syslog` and `log stream` output: +// 2026-05-27 10:08:42.123456+0300 0x1a2b3c Default 0x0 1234 0 MyApp: hello +const OSLOG_SYSLOG = /^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d+[+-]\d{4}\s+0x[\da-fA-F]+\s+(Debug|Info|Notice|Default|Error|Fault)\b/; + +// Matches `log show` "compact" / table output where the level is the +// second whitespace-separated column right after the timestamp. +// 2026-05-27 10:08:42.123456+0300 Default com.appsflyer.sdk ... +const OSLOG_TABLE = /^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d+[+-]\d{4}\s+(Debug|Info|Notice|Default|Error|Fault)\b/; + +const LEVEL_MAP = { + Debug: 'V', + Info: 'I', + Notice: 'D', // legacy alias for Default in Apple's unified logging + Default: 'D', + Error: 'E', + Fault: 'F', +}; + +export function parseOSLog(line) { + const m = OSLOG_SYSLOG.exec(line) || OSLOG_TABLE.exec(line); + if (!m) return null; + return { level: LEVEL_MAP[m[1]] }; +} diff --git a/src/output.js b/src/output.js index 479fbc6..5c44f58 100644 --- a/src/output.js +++ b/src/output.js @@ -1,12 +1,14 @@ import { state } from './state.js'; import { detectPayload } from './payload.js'; import { parseLogcat } from './logcat.js'; +import { parseOSLog } from './oslog.js'; +import { refreshAfterRender } from './lineNav.js'; import { escapeHTML, highlight } from './highlight.js'; -function lineDiv(line, innerHTML) { - const lc = parseLogcat(line); - const cls = lc ? ` class="lc lc-${lc.level}"` : ''; - return `${innerHTML}`; +function lineDiv(lineText, innerHTML, lineNumber) { + const parsed = parseLogcat(lineText) || parseOSLog(lineText); + const cls = parsed ? `lc lc-${parsed.level}` : ''; + return `
    ${lineNumber}${innerHTML}
    `; } function lineMatches(line) { @@ -41,30 +43,35 @@ export function renderOutput() { const out = document.getElementById('output'); const raw = state.input; if (!raw) { - out.innerHTML = '
    Add a keyword group and paste logs to begin. (best with logs under 500KB)
    '; + out.innerHTML = '
    Paste raw logs in the Input panel, then pick a preset above (or open ? Guide) to start highlighting.
    Best with logs under 500 KB.
    '; + refreshAfterRender(); return; } const lines = raw.split('\n'); const html = []; let dropped = 0; let emittedAny = false; - for (const line of lines) { + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const lineNumber = i + 1; const matched = lineMatches(line); if (state.mode === 'filter') { if (!matched) { dropped++; continue; } if (dropped > 0 && emittedAny) html.push('
    ···
    '); dropped = 0; - html.push(lineDiv(line, renderLine(line))); + html.push(lineDiv(line, renderLine(line), lineNumber)); emittedAny = true; } else { - if (matched) html.push(lineDiv(line, renderLine(line))); - else html.push(lineDiv(line, escapeHTML(line))); + if (matched) html.push(lineDiv(line, renderLine(line), lineNumber)); + else html.push(lineDiv(line, escapeHTML(line), lineNumber)); emittedAny = true; } } if (!emittedAny) { - out.innerHTML = '
    No matching lines.
    '; + out.innerHTML = '
    No matching lines.
    Switch to Full mode to see everything, or adjust your keyword groups.
    '; + refreshAfterRender(); return; } out.innerHTML = html.join(''); + refreshAfterRender(); } diff --git a/src/starterPresets.js b/src/starterPresets.js new file mode 100644 index 0000000..33d0166 --- /dev/null +++ b/src/starterPresets.js @@ -0,0 +1,57 @@ +// Built-in preset library seeded once into localStorage on first run. +// Users may delete or overwrite these; the seed flag prevents re-seeding. + +import { load, save } from './storage.js'; +import { listPresets, savePreset } from './presets.js'; + +export const STARTER_PRESETS = [ + { + name: 'iOS SDK', + groups: [ + { keywords: ['error', 'fatal', 'exception', 'NSError', 'crash'], colorIndex: 9 }, + { keywords: ['warning', 'deprecated'], colorIndex: 0 }, + { keywords: ['didFinishLaunching', 'willTerminate', 'applicationDidBecomeActive'], colorIndex: 4 }, + ], + }, + { + name: 'Android SDK', + groups: [ + { keywords: ['FATAL', 'AndroidRuntime', 'Exception'], colorIndex: 9 }, + { keywords: ['W/', 'deprecated'], colorIndex: 0 }, + { keywords: ['onCreate', 'onResume', 'onDestroy'], colorIndex: 2 }, + ], + }, + { + name: 'React Native bridge', + groups: [ + { keywords: ['ReactNativeJS', 'Unhandled', 'Error:'], colorIndex: 9 }, + { keywords: ['NativeModule', 'bridge', 'callback'], colorIndex: 5 }, + { keywords: ['Metro', 'bundling', 'Bundle'], colorIndex: 3 }, + ], + }, + { + name: 'OkHttp', + groups: [ + { keywords: ['-->', '--> POST', '--> GET'], colorIndex: 4 }, + { keywords: ['<--', '<-- 200', '<-- 4', '<-- 5'], colorIndex: 2 }, + { keywords: ['SocketTimeoutException', 'IOException', 'failed'], colorIndex: 9 }, + ], + }, +]; + +const SEED_KEY = 'loghl:starterSeeded'; +const SEED_VERSION = 1; + +export function seedStarterPresets() { + const seeded = load(SEED_KEY); + if (seeded && seeded.version >= SEED_VERSION) return { seeded: false, reason: 'already-seeded' }; + const existing = new Set(listPresets()); + let added = 0; + for (const preset of STARTER_PRESETS) { + if (existing.has(preset.name)) continue; + const result = savePreset(preset.name, preset.groups); + if (result.ok) added++; + } + save(SEED_KEY, { version: SEED_VERSION, seededAt: Date.now() }); + return { seeded: true, added }; +} diff --git a/src/theme.js b/src/theme.js new file mode 100644 index 0000000..534d8e2 --- /dev/null +++ b/src/theme.js @@ -0,0 +1,36 @@ +// data-theme on ; persists to localStorage, falls back to prefers-color-scheme. + +import { load, save } from './storage.js'; + +const KEY = 'loghl:theme'; + +function systemPrefersDark() { + return typeof window !== 'undefined' + && typeof window.matchMedia === 'function' + && window.matchMedia('(prefers-color-scheme: dark)').matches; +} + +export function applyInitialTheme() { + const stored = load(KEY); + const theme = (stored === 'light' || stored === 'dark') + ? stored + : (systemPrefersDark() ? 'dark' : 'light'); + document.documentElement.setAttribute('data-theme', theme); + return theme; +} + +export function getTheme() { + return document.documentElement.getAttribute('data-theme') || 'light'; +} + +export function toggleTheme() { + const next = getTheme() === 'dark' ? 'light' : 'dark'; + document.documentElement.setAttribute('data-theme', next); + save(KEY, next); + return next; +} + +export function syncToggleButton(btn, theme) { + btn.textContent = theme === 'dark' ? '☀' : '☾'; + btn.title = theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'; +} diff --git a/styles/app.css b/styles/app.css index 84c0ecf..dffcc98 100644 --- a/styles/app.css +++ b/styles/app.css @@ -7,6 +7,26 @@ --accent: #2c5aa0; --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +:root[data-theme="dark"] { + --bg: #0e1117; + --panel: #161b22; + --border: #30363d; + --text: #e6edf3; + --muted: #8b949e; + --accent: #58a6ff; +} +:root[data-theme="dark"] #output .line.lc-V .content { color: #6e7681; } +:root[data-theme="dark"] #output .line.lc-W .content { background: rgba(240, 160, 32, 0.10); } +:root[data-theme="dark"] #output .line.lc-E .content { background: rgba(224, 64, 64, 0.14); } +:root[data-theme="dark"] #output .line.lc-F .content { background: rgba(176, 0, 32, 0.20); } +:root[data-theme="dark"] .group { background: #1c2128; } +:root[data-theme="dark"] .kw-chip { background: rgba(255,255,255,0.08); } +:root[data-theme="dark"] .add-group button:hover, +:root[data-theme="dark"] .header-btn:hover, +:root[data-theme="dark"] .presets button:hover:not(:disabled), +:root[data-theme="dark"] .share-banner button:hover { background: #21262d; } +:root[data-theme="dark"] kbd { background: #21262d; color: var(--text); } +:root[data-theme="dark"] .share-banner { background: #2d2a1d; border-bottom-color: #5c4a1a; } * { box-sizing: border-box; } html, body { height: 100%; } body { @@ -142,6 +162,8 @@ main { font-family: var(--mono); font-size: 12px; min-width: 0; + background: var(--panel); + color: var(--text); } .add-group button { padding: 6px 12px; @@ -149,6 +171,7 @@ main { background: var(--panel); border-radius: 6px; cursor: pointer; + color: var(--text); } .add-group button:hover { background: #f0f0f0; } #input { @@ -161,6 +184,7 @@ main { font-size: 12px; outline: none; background: var(--panel); + color: var(--text); } #output { font-family: var(--mono); @@ -169,20 +193,42 @@ main { word-break: break-word; line-height: 1.5; } -#output > div { padding: 1px 0; } +#output .line { + display: grid; + grid-template-columns: 48px 1fr; + padding: 1px 0; + align-items: baseline; +} +#output .line .ln { + text-align: right; + padding-right: 10px; + color: var(--muted); + user-select: none; + font-variant-numeric: tabular-nums; +} +#output .line.lc { padding-left: 0; } +#output .line.lc .content { border-left: 3px solid transparent; padding-left: 6px; } +#output .line.lc-V .content { border-left-color: #b0b0b0; color: #666; } +#output .line.lc-D .content { border-left-color: #5a9fd4; } +#output .line.lc-I .content { border-left-color: #4caf50; } +#output .line.lc-W .content { border-left-color: #f0a020; background: rgba(240, 160, 32, 0.06); } +#output .line.lc-E .content { border-left-color: #e04040; background: rgba(224, 64, 64, 0.07); } +#output .line.lc-F .content { border-left-color: #b00020; background: rgba(176, 0, 32, 0.10); font-weight: 600; } +#output .line.lc-A .content { border-left-color: #8a4dff; background: rgba(138, 77, 255, 0.07); } #output .sep { color: var(--muted); text-align: center; padding: 4px 0; user-select: none; } -.placeholder { color: var(--muted); font-family: inherit; font-size: 13px; } +.placeholder { color: var(--muted); font-family: inherit; font-size: 13px; line-height: 1.6; } +.placeholder small { display: inline-block; margin-top: 6px; font-size: 11px; opacity: 0.8; } +.placeholder kbd { + font-family: var(--mono); + background: rgba(0, 0, 0, 0.06); + border: 1px solid var(--border); + border-radius: 3px; + padding: 1px 5px; + font-size: 11px; +} +:root[data-theme="dark"] .placeholder kbd { background: rgba(255, 255, 255, 0.12); } mark { padding: 0 1px; border-radius: 2px; color: #000; } -#output .lc { border-left: 3px solid transparent; padding-left: 6px; } -#output .lc-V { border-left-color: #b0b0b0; color: #666; } -#output .lc-D { border-left-color: #5a9fd4; } -#output .lc-I { border-left-color: #4caf50; } -#output .lc-W { border-left-color: #f0a020; background: rgba(240, 160, 32, 0.06); } -#output .lc-E { border-left-color: #e04040; background: rgba(224, 64, 64, 0.07); } -#output .lc-F { border-left-color: #b00020; background: rgba(176, 0, 32, 0.10); font-weight: 600; } -#output .lc-A { border-left-color: #8a4dff; background: rgba(138, 77, 255, 0.07); } - .presets { display: flex; gap: 6px; @@ -462,3 +508,17 @@ mark { padding: 0 1px; border-radius: 2px; color: #000; } } .editor-footer button.primary:hover:not(:disabled) { background: #234a85; } .editor-footer button:disabled { opacity: 0.5; cursor: not-allowed; } + +.match-count { + font-size: 11px; + text-transform: none; + letter-spacing: normal; + font-weight: 500; + margin-left: 8px; + color: var(--muted); +} +.match-count[hidden] { display: none; } +#output mark.mark-current { + outline: 2px solid var(--accent); + outline-offset: 1px; +} diff --git a/tests/lineNav.test.js b/tests/lineNav.test.js new file mode 100644 index 0000000..a1608aa --- /dev/null +++ b/tests/lineNav.test.js @@ -0,0 +1,63 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { refreshAfterRender, attachKeyboard } from '../src/lineNav.js'; + +function setupDOM(html) { + document.body.innerHTML = ` +

    Output

    +
    ${html}
    + `; +} + +describe('lineNav', () => { + beforeEach(() => { document.body.innerHTML = ''; }); + + it('hides counter when no matches', () => { + setupDOM('
    plain
    '); + refreshAfterRender(); + expect(document.getElementById('matchCount').hidden).toBe(true); + }); + + it('shows match count when matches exist', () => { + setupDOM('
    a
    b
    '); + refreshAfterRender(); + const el = document.getElementById('matchCount'); + expect(el.hidden).toBe(false); + expect(el.textContent).toBe('— 2 matches'); + }); + + it('advances current match with n key', () => { + setupDOM('
    a
    b
    '); + refreshAfterRender(); + attachKeyboard(); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'n' })); + expect(document.querySelectorAll('mark.mark-current').length).toBe(1); + expect(document.getElementById('matchCount').textContent).toBe('— 1 of 2 matches'); + }); + + it('wraps around at the end', () => { + setupDOM('
    a
    b
    '); + refreshAfterRender(); + attachKeyboard(); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'n' })); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'n' })); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'n' })); + expect(document.getElementById('matchCount').textContent).toBe('— 1 of 2 matches'); + }); + + it('Shift+N steps backwards', () => { + setupDOM('
    a
    b
    '); + refreshAfterRender(); + attachKeyboard(); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'N', shiftKey: true })); + expect(document.getElementById('matchCount').textContent).toBe('— 2 of 2 matches'); + }); + + it('ignores n when an input is focused', () => { + setupDOM('
    a
    '); + refreshAfterRender(); + attachKeyboard(); + document.getElementById('x').focus(); + document.getElementById('x').dispatchEvent(new KeyboardEvent('keydown', { key: 'n', bubbles: true })); + expect(document.querySelectorAll('mark.mark-current').length).toBe(0); + }); +}); diff --git a/tests/oslog.test.js b/tests/oslog.test.js new file mode 100644 index 0000000..c93d682 --- /dev/null +++ b/tests/oslog.test.js @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest'; +import { parseOSLog } from '../src/oslog.js'; + +describe('parseOSLog', () => { + it('parses log-stream syslog Error line', () => { + const line = '2026-05-27 10:08:42.123456+0300 0x1a2b3c Error 0x0 1234 0 MyApp: boom'; + expect(parseOSLog(line)).toEqual({ level: 'E' }); + }); + + it('parses log-stream syslog Fault line', () => { + const line = '2026-05-27 10:08:42.123456+0300 0x1a2b3c Fault 0x0 1234 0 MyApp: kaboom'; + expect(parseOSLog(line)).toEqual({ level: 'F' }); + }); + + it('parses log-stream Default level', () => { + const line = '2026-05-27 10:08:42.123456+0300 0x1a2b3c Default 0x0 1234 0 MyApp: hello'; + expect(parseOSLog(line)).toEqual({ level: 'D' }); + }); + + it('parses log-stream Debug level', () => { + const line = '2026-05-27 10:08:42.123456+0300 0x1a2b3c Debug 0x0 1234 0 MyApp: trace'; + expect(parseOSLog(line)).toEqual({ level: 'V' }); + }); + + it('parses log show compact table format', () => { + const line = '2026-05-27 10:08:42.123456+0300 Error com.appsflyer.sdk: failure'; + expect(parseOSLog(line)).toEqual({ level: 'E' }); + }); + + it('parses negative timezone offset', () => { + const line = '2026-05-27 10:08:42.123456-0500 0x1a2b3c Error 0x0 1234 0 MyApp: boom'; + expect(parseOSLog(line)).toEqual({ level: 'E' }); + }); + + it('parses Info level', () => { + const line = '2026-05-27 10:08:42.123456+0300 0x1a2b3c Info 0x0 1234 0 MyApp: hi'; + expect(parseOSLog(line)).toEqual({ level: 'I' }); + }); + + it('parses Notice level as Default-equivalent', () => { + const line = '2026-05-27 10:08:42.123456+0300 0x1a2b3c Notice 0x0 1234 0 MyApp: note'; + expect(parseOSLog(line)).toEqual({ level: 'D' }); + }); + + it('returns null for malformed near-miss timestamp', () => { + const line = '2026/05/27 10:08:42.123456+0300 0x1a2b3c Error 0x0 1234 0 MyApp: nope'; + expect(parseOSLog(line)).toBeNull(); + }); + + it('returns null for Xcode-console-style NSLog line without level', () => { + const line = '2026-05-27 10:08:42.123 MyApp[1234:567890] hello from NSLog'; + expect(parseOSLog(line)).toBeNull(); + }); + + it('returns null for logcat threadtime line', () => { + const line = '05-27 10:08:42.123 1234 5678 E MyTag: boom'; + expect(parseOSLog(line)).toBeNull(); + }); + + it('returns null for plain prose', () => { + expect(parseOSLog('hello world')).toBeNull(); + }); +}); diff --git a/tests/starterPresets.test.js b/tests/starterPresets.test.js new file mode 100644 index 0000000..a54799e --- /dev/null +++ b/tests/starterPresets.test.js @@ -0,0 +1,35 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { seedStarterPresets, STARTER_PRESETS } from '../src/starterPresets.js'; +import { listPresets, deletePreset, savePreset } from '../src/presets.js'; + +describe('seedStarterPresets', () => { + beforeEach(() => localStorage.clear()); + + it('seeds all starter presets on fresh storage', () => { + const result = seedStarterPresets(); + expect(result.seeded).toBe(true); + expect(result.added).toBe(STARTER_PRESETS.length); + for (const p of STARTER_PRESETS) expect(listPresets()).toContain(p.name); + }); + + it('does not re-seed on second call', () => { + seedStarterPresets(); + const second = seedStarterPresets(); + expect(second.seeded).toBe(false); + }); + + it('does not restore a starter preset the user deleted', () => { + seedStarterPresets(); + deletePreset('iOS SDK'); + seedStarterPresets(); + expect(listPresets()).not.toContain('iOS SDK'); + }); + + it('does not overwrite a user preset that shares a starter name', () => { + savePreset('iOS SDK', [{ keywords: ['mine'], colorIndex: 0 }]); + seedStarterPresets(); + expect(listPresets()).toContain('iOS SDK'); + const all = JSON.parse(localStorage.getItem('loghl:presets')).data; + expect(all['iOS SDK'].groups[0].keywords).toEqual(['mine']); + }); +}); diff --git a/tests/theme.test.js b/tests/theme.test.js new file mode 100644 index 0000000..5b8b01a --- /dev/null +++ b/tests/theme.test.js @@ -0,0 +1,43 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { applyInitialTheme, getTheme, toggleTheme } from '../src/theme.js'; + +describe('theme', () => { + beforeEach(() => { + localStorage.clear(); + document.documentElement.removeAttribute('data-theme'); + }); + + it('applies light theme by default in jsdom (no system pref)', () => { + expect(applyInitialTheme()).toBe('light'); + expect(getTheme()).toBe('light'); + }); + + it('toggleTheme flips light -> dark and persists', () => { + applyInitialTheme(); + expect(toggleTheme()).toBe('dark'); + expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); + }); + + it('toggleTheme flips dark -> light', () => { + applyInitialTheme(); + toggleTheme(); + expect(toggleTheme()).toBe('light'); + }); + + it('persists choice across reload (simulated)', () => { + applyInitialTheme(); + toggleTheme(); + document.documentElement.removeAttribute('data-theme'); + expect(applyInitialTheme()).toBe('dark'); + }); + + it('respects prefers-color-scheme: dark when no stored choice', () => { + const original = window.matchMedia; + window.matchMedia = (q) => ({ matches: q.includes('dark'), media: q, addListener: () => {}, removeListener: () => {} }); + try { + expect(applyInitialTheme()).toBe('dark'); + } finally { + window.matchMedia = original; + } + }); +});