Merge branch 'feature/13' into dev
This commit is contained in:
commit
34be2f8395
3 changed files with 267 additions and 1 deletions
2
TODO
2
TODO
|
|
@ -203,7 +203,7 @@ Content-Type: application/issue
|
|||
ID: 13
|
||||
Type: feature
|
||||
Title: implement browser local storage handler
|
||||
Status: open
|
||||
Status: done
|
||||
Priority: medium
|
||||
Created: 2026-03-13
|
||||
Relationships: dependsOn:8
|
||||
|
|
|
|||
108
src/handler.ts
108
src/handler.ts
|
|
@ -219,6 +219,114 @@ export class FileHandler extends StreamHandler {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A handler that persists log records to browser localStorage.
|
||||
*
|
||||
* Records are stored as a JSON array of formatted strings under a
|
||||
* configurable key. Log rotation is supported by entry count and/or
|
||||
* byte size to avoid exceeding storage quotas.
|
||||
*/
|
||||
export interface LocalStorageHandlerOptions {
|
||||
/**
|
||||
* localStorage key to store entries under.
|
||||
* Defaults to 'esm-logging'.
|
||||
*/
|
||||
key?: string;
|
||||
|
||||
/**
|
||||
* Maximum number of entries to retain. Oldest entries are discarded
|
||||
* first. Set to 0 for no entry limit. Defaults to 1000.
|
||||
*/
|
||||
maxEntries?: number;
|
||||
|
||||
/**
|
||||
* Maximum size in bytes (as measured by the JSON-serialized string
|
||||
* length). Oldest entries are discarded to stay within this limit.
|
||||
* Set to 0 for no byte limit. Defaults to 0.
|
||||
*/
|
||||
maxBytes?: number;
|
||||
}
|
||||
|
||||
export class LocalStorageHandler extends Handler {
|
||||
protected key: string;
|
||||
protected maxEntries: number;
|
||||
protected maxBytes: number;
|
||||
|
||||
constructor(options?: LocalStorageHandlerOptions) {
|
||||
super();
|
||||
|
||||
if (typeof localStorage === 'undefined') {
|
||||
throw new NotImplementedError(
|
||||
'LocalStorageHandler requires a browser environment with localStorage'
|
||||
);
|
||||
}
|
||||
|
||||
this.key = options?.key ?? 'esm-logging';
|
||||
this.maxEntries = options?.maxEntries ?? 1000;
|
||||
this.maxBytes = options?.maxBytes ?? 0;
|
||||
}
|
||||
|
||||
emit(record: LogRecord) {
|
||||
try {
|
||||
const msg = this.format(record);
|
||||
const entries = this.getEntries();
|
||||
entries.push(msg);
|
||||
this._rotate(entries);
|
||||
this._setEntries(entries);
|
||||
}
|
||||
catch (e) {
|
||||
this.handleError(record);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve all stored log entries.
|
||||
*/
|
||||
getEntries(): string[] {
|
||||
try {
|
||||
const data = localStorage.getItem(this.key);
|
||||
return data ? JSON.parse(data) : [];
|
||||
}
|
||||
catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all stored log entries.
|
||||
*/
|
||||
clearEntries() {
|
||||
localStorage.removeItem(this.key);
|
||||
}
|
||||
|
||||
close() {
|
||||
super.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard oldest entries to stay within maxEntries and maxBytes limits.
|
||||
*/
|
||||
protected _rotate(entries: string[]) {
|
||||
if (this.maxEntries > 0) {
|
||||
while (entries.length > this.maxEntries) {
|
||||
entries.shift();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.maxBytes > 0) {
|
||||
while (entries.length > 0) {
|
||||
const serialized = JSON.stringify(entries);
|
||||
if (serialized.length <= this.maxBytes) { break }
|
||||
entries.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected _setEntries(entries: string[]) {
|
||||
localStorage.setItem(this.key, JSON.stringify(entries));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This class is like a StreamHandler using sys.stderr, but always uses
|
||||
* whatever sys.stderr is currently set to rather than the value of
|
||||
|
|
|
|||
158
tests/unit/local-storage-handler.test.ts
Normal file
158
tests/unit/local-storage-handler.test.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import {expect, jest, test, beforeEach, afterEach} from '@jest/globals';
|
||||
import { LocalStorageHandler } from '../../src/handler';
|
||||
import { LogRecord } from '../../src/log-record';
|
||||
import { DEBUG, WARNING, ERROR } from '../../src/log-level';
|
||||
|
||||
// mock localStorage for Node/Jest environment
|
||||
const store: {[key: string]: string} = {};
|
||||
const localStorageMock = {
|
||||
getItem: (key: string): string | null => store[key] ?? null,
|
||||
setItem: (key: string, value: string) => { store[key] = value },
|
||||
removeItem: (key: string) => { delete store[key] },
|
||||
};
|
||||
|
||||
function clearStore() {
|
||||
for (const key of Object.keys(store)) { delete store[key] }
|
||||
}
|
||||
|
||||
function makeRecord(level: number, msg: string): LogRecord {
|
||||
return new LogRecord('test', { level, msg });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearStore();
|
||||
(globalThis as any).localStorage = localStorageMock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearStore();
|
||||
delete (globalThis as any).localStorage;
|
||||
});
|
||||
|
||||
describe('LocalStorageHandler', () => {
|
||||
describe('constructor', () => {
|
||||
test('uses default key and limits', () => {
|
||||
const h = new LocalStorageHandler();
|
||||
expect(h.getEntries()).toEqual([]);
|
||||
});
|
||||
|
||||
test('throws when localStorage is unavailable', () => {
|
||||
delete (globalThis as any).localStorage;
|
||||
expect(() => new LocalStorageHandler()).toThrow(
|
||||
'requires a browser environment'
|
||||
);
|
||||
});
|
||||
|
||||
test('accepts custom options', () => {
|
||||
const h = new LocalStorageHandler({
|
||||
key: 'custom-log',
|
||||
maxEntries: 50,
|
||||
maxBytes: 4096,
|
||||
});
|
||||
h.emit(makeRecord(DEBUG, 'test'));
|
||||
expect(store['custom-log']).toBeDefined();
|
||||
expect(store['esm-logging']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('emit', () => {
|
||||
test('stores formatted log entry', () => {
|
||||
const h = new LocalStorageHandler();
|
||||
h.emit(makeRecord(WARNING, 'hello'));
|
||||
const entries = h.getEntries();
|
||||
expect(entries.length).toBe(1);
|
||||
expect(entries[0]).toContain('hello');
|
||||
});
|
||||
|
||||
test('appends multiple entries', () => {
|
||||
const h = new LocalStorageHandler();
|
||||
h.emit(makeRecord(DEBUG, 'first'));
|
||||
h.emit(makeRecord(DEBUG, 'second'));
|
||||
h.emit(makeRecord(DEBUG, 'third'));
|
||||
expect(h.getEntries().length).toBe(3);
|
||||
});
|
||||
|
||||
test('persists across handler instances with same key', () => {
|
||||
const h1 = new LocalStorageHandler({ key: 'shared' });
|
||||
h1.emit(makeRecord(DEBUG, 'from h1'));
|
||||
|
||||
const h2 = new LocalStorageHandler({ key: 'shared' });
|
||||
const entries = h2.getEntries();
|
||||
expect(entries.length).toBe(1);
|
||||
expect(entries[0]).toContain('from h1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rotation by entry count', () => {
|
||||
test('discards oldest entries when maxEntries exceeded', () => {
|
||||
const h = new LocalStorageHandler({ maxEntries: 3 });
|
||||
h.emit(makeRecord(DEBUG, 'msg-1'));
|
||||
h.emit(makeRecord(DEBUG, 'msg-2'));
|
||||
h.emit(makeRecord(DEBUG, 'msg-3'));
|
||||
h.emit(makeRecord(DEBUG, 'msg-4'));
|
||||
|
||||
const entries = h.getEntries();
|
||||
expect(entries.length).toBe(3);
|
||||
expect(entries[0]).toContain('msg-2');
|
||||
expect(entries[2]).toContain('msg-4');
|
||||
});
|
||||
|
||||
test('maxEntries of 1 keeps only the latest', () => {
|
||||
const h = new LocalStorageHandler({ maxEntries: 1 });
|
||||
h.emit(makeRecord(DEBUG, 'old'));
|
||||
h.emit(makeRecord(DEBUG, 'new'));
|
||||
|
||||
const entries = h.getEntries();
|
||||
expect(entries.length).toBe(1);
|
||||
expect(entries[0]).toContain('new');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rotation by byte size', () => {
|
||||
test('discards oldest entries when maxBytes exceeded', () => {
|
||||
const h = new LocalStorageHandler({ maxEntries: 0, maxBytes: 100 });
|
||||
// emit entries until we exceed the limit
|
||||
for (let i = 0; i < 20; i++) {
|
||||
h.emit(makeRecord(DEBUG, `message-${i}`));
|
||||
}
|
||||
|
||||
const entries = h.getEntries();
|
||||
const serialized = JSON.stringify(entries);
|
||||
expect(serialized.length).toBeLessThanOrEqual(100);
|
||||
expect(entries.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEntries', () => {
|
||||
test('returns empty array when no entries', () => {
|
||||
const h = new LocalStorageHandler();
|
||||
expect(h.getEntries()).toEqual([]);
|
||||
});
|
||||
|
||||
test('returns empty array on corrupt data', () => {
|
||||
store['esm-logging'] = 'not valid json{{{';
|
||||
const h = new LocalStorageHandler();
|
||||
expect(h.getEntries()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearEntries', () => {
|
||||
test('removes all stored entries', () => {
|
||||
const h = new LocalStorageHandler();
|
||||
h.emit(makeRecord(DEBUG, 'to be cleared'));
|
||||
expect(h.getEntries().length).toBe(1);
|
||||
|
||||
h.clearEntries();
|
||||
expect(h.getEntries()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('close', () => {
|
||||
test('sets closed to true', () => {
|
||||
const h = new LocalStorageHandler();
|
||||
expect(h.closed).toBe(false);
|
||||
h.close();
|
||||
expect(h.closed).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue