Merge branch 'feature/13' into dev

This commit is contained in:
Tiara Rodney 2026-03-14 05:02:56 +01:00
commit 34be2f8395
No known key found for this signature in database
GPG key ID: 5CD8EC1D46106723
3 changed files with 267 additions and 1 deletions

2
TODO
View file

@ -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

View file

@ -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

View 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);
});
});
});