@monochromatic-dev/module-logger 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/LICENSES/GPL-3.0-or-later.txt +674 -0
- package/LICENSES/LGPL-3.0-or-later.txt +165 -0
- package/README.md +404 -0
- package/dist/final/neutral/index.d.mts +673 -0
- package/dist/final/neutral/index.mjs +3 -0
- package/dist/final/neutral/rolldown-runtime-5duEfhBv.mjs +1 -0
- package/dist/final/node/index.d.mts +673 -0
- package/dist/final/node/index.mjs +3 -0
- package/dist/final/node/rolldown-runtime-5duEfhBv.mjs +1 -0
- package/package.json +43 -0
- package/src/create-logger.ts +494 -0
- package/src/create-logger.unit.test.ts +752 -0
- package/src/error-format.ts +43 -0
- package/src/index.ts +35 -0
- package/src/logger.ts +67 -0
- package/src/logger.unit.test.ts +190 -0
- package/src/sink/console-control-chars.ts +140 -0
- package/src/sink/console-control-chars.unit.test.ts +206 -0
- package/src/sink/console.ts +531 -0
- package/src/sink/console.unit.test.ts +542 -0
- package/src/sink/file.ts +297 -0
- package/src/sink/file.unit.test.ts +202 -0
- package/src/sink/index.ts +11 -0
- package/src/sink/indexed-db-util.ts +96 -0
- package/src/sink/indexed-db.browser.test.ts +184 -0
- package/src/sink/indexed-db.ts +324 -0
- package/src/sink/indexed-db.unit.test.ts +80 -0
- package/src/sink/local-storage-key.ts +176 -0
- package/src/sink/local-storage-key.unit.test.ts +106 -0
- package/src/sink/local-storage-quota.ts +60 -0
- package/src/sink/local-storage-quota.unit.test.ts +98 -0
- package/src/sink/local-storage-store.ts +368 -0
- package/src/sink/local-storage-store.unit.test.ts +329 -0
- package/src/sink/local-storage.browser.test.ts +125 -0
- package/src/sink/local-storage.ts +182 -0
- package/src/sink/local-storage.unit.test.ts +218 -0
- package/src/sink/noop.ts +46 -0
- package/src/sink/noop.unit.test.ts +47 -0
- package/src/sink/opfs.browser.test.ts +84 -0
- package/src/sink/opfs.ts +212 -0
- package/src/sink/opfs.unit.test.ts +81 -0
- package/src/sink/record-buffer.ts +230 -0
- package/src/sink/record-buffer.unit.test.ts +288 -0
- package/src/sink/session-storage-quota.ts +57 -0
- package/src/sink/session-storage-quota.unit.test.ts +98 -0
- package/src/sink/session-storage-store.ts +178 -0
- package/src/sink/session-storage.browser.test.ts +137 -0
- package/src/sink/session-storage.ts +128 -0
- package/src/sink/session-storage.unit.test.ts +527 -0
- package/src/sink/web-storage-quota-error.ts +43 -0
- package/src/sink/web-storage-quota-error.unit.test.ts +55 -0
- package/src/sink/web-storage-runtime.ts +49 -0
- package/src/startup.unit.test.ts +232 -0
- package/src/tagged.ts +74 -0
- package/src/tagged.unit.test.ts +211 -0
- package/src/types.ts +78 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// oxlint-disable typescript/no-unsafe-assignment, typescript/no-unsafe-member-access, typescript/strict-boolean-expressions -- browser evaluate callbacks lose type info across page boundary
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
expect,
|
|
5
|
+
test,
|
|
6
|
+
} from '@playwright/test';
|
|
7
|
+
|
|
8
|
+
declare global {
|
|
9
|
+
// oxlint-disable-next-line typescript/consistent-type-imports -- typeof import() cannot use import type syntax
|
|
10
|
+
var moduleLogger: typeof import('@monochromatic-dev/module-logger');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
test.describe('localStorage sink', () => {
|
|
14
|
+
test.beforeEach(async ({ page, },) => {
|
|
15
|
+
await page.goto('/',);
|
|
16
|
+
await page.waitForFunction(() => globalThis.moduleLogger !== undefined);
|
|
17
|
+
},);
|
|
18
|
+
|
|
19
|
+
test('createLocalStorageSink exposes a callable verify', async ({ page, },) => {
|
|
20
|
+
const typeofVerify = await page.evaluate(() => {
|
|
21
|
+
const { createLocalStorageSink, } = globalThis.moduleLogger.sinks;
|
|
22
|
+
return typeof createLocalStorageSink().verify;
|
|
23
|
+
},);
|
|
24
|
+
expect(typeofVerify,).toBe('function',);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('verify detects availability', async ({ page, },) => {
|
|
28
|
+
const result = await page.evaluate(async () => {
|
|
29
|
+
const { createLocalStorageSink, } = globalThis.moduleLogger.sinks;
|
|
30
|
+
return createLocalStorageSink().verify();
|
|
31
|
+
},);
|
|
32
|
+
expect(result,).toBe(true,);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('a verified sink writes records across levels and message shapes', async ({ page, },) => {
|
|
36
|
+
const allSucceeded = await page.evaluate(async () => {
|
|
37
|
+
const { createLocalStorageSink, } = globalThis.moduleLogger.sinks;
|
|
38
|
+
const sink = createLocalStorageSink();
|
|
39
|
+
await sink.verify();
|
|
40
|
+
const levels = ['trace', 'debug', 'info', 'warn', 'error', 'fatal',] as const;
|
|
41
|
+
const messages = [
|
|
42
|
+
'test message',
|
|
43
|
+
'Hello δΈη π',
|
|
44
|
+
'',
|
|
45
|
+
'{"key": "value", "nested": {"a": 1}}',
|
|
46
|
+
];
|
|
47
|
+
for (const level of levels) {
|
|
48
|
+
for (const message of messages) {
|
|
49
|
+
try {
|
|
50
|
+
void sink.write({
|
|
51
|
+
level,
|
|
52
|
+
message,
|
|
53
|
+
timestamp: Date.now(),
|
|
54
|
+
},);
|
|
55
|
+
}
|
|
56
|
+
catch (error: unknown) {
|
|
57
|
+
console.warn('localStorage sink browser test write failed', error,);
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return true;
|
|
63
|
+
},);
|
|
64
|
+
expect(allSucceeded,).toBe(true,);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('written records can be retrieved from localStorage under a run-scoped key', async ({ page, },) => {
|
|
68
|
+
const result = await page.evaluate(async () => {
|
|
69
|
+
const { createLocalStorageSink, } = globalThis.moduleLogger.sinks;
|
|
70
|
+
|
|
71
|
+
// Clear any existing logs first.
|
|
72
|
+
const keysToRemove: string[] = [];
|
|
73
|
+
const storageLength = globalThis.localStorage.length;
|
|
74
|
+
for (let storageIndex = 0; storageIndex < storageLength; storageIndex++) {
|
|
75
|
+
const key = globalThis.localStorage.key(storageIndex,);
|
|
76
|
+
if (key?.startsWith('monochromatic.log',))
|
|
77
|
+
keysToRemove.push(key,);
|
|
78
|
+
}
|
|
79
|
+
keysToRemove.forEach(key => {
|
|
80
|
+
globalThis.localStorage.removeItem(key,);
|
|
81
|
+
},);
|
|
82
|
+
|
|
83
|
+
const sink = createLocalStorageSink();
|
|
84
|
+
await sink.verify();
|
|
85
|
+
|
|
86
|
+
const testMessage = `unique-test-${Date.now()}`;
|
|
87
|
+
await sink.write({
|
|
88
|
+
level: 'info' as const,
|
|
89
|
+
message: testMessage,
|
|
90
|
+
timestamp: Date.now(),
|
|
91
|
+
},);
|
|
92
|
+
// Routine severity buffers; the flush hook forces the batch out so the
|
|
93
|
+
// read-back below observes it deterministically.
|
|
94
|
+
await sink.flush?.();
|
|
95
|
+
|
|
96
|
+
// Find the written record under a run-scoped key
|
|
97
|
+
// (`monochromatic.log.{stamp}.{nonce}.{index}`).
|
|
98
|
+
const currentStorageLength = globalThis.localStorage.length;
|
|
99
|
+
for (let storageIndex = 0; storageIndex < currentStorageLength; storageIndex++) {
|
|
100
|
+
const key = globalThis.localStorage.key(storageIndex,);
|
|
101
|
+
if (key?.startsWith('monochromatic.log',)) {
|
|
102
|
+
const value = globalThis.localStorage.getItem(key,);
|
|
103
|
+
if (value?.includes(testMessage,)) {
|
|
104
|
+
const parsed = JSON.parse(value,);
|
|
105
|
+
return {
|
|
106
|
+
found: true,
|
|
107
|
+
message: parsed.message,
|
|
108
|
+
level: parsed.level,
|
|
109
|
+
keySegments: key.split('.',).length,
|
|
110
|
+
testMessage,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { found: false, message: null, level: null, keySegments: 0, testMessage, };
|
|
117
|
+
},);
|
|
118
|
+
|
|
119
|
+
expect(result.found,).toBe(true,);
|
|
120
|
+
expect(result.message,).toBe(result.testMessage,);
|
|
121
|
+
expect(result.level,).toBe('info',);
|
|
122
|
+
// Prefix `monochromatic.log` plus stamp, nonce, and index.
|
|
123
|
+
expect(result.keySegments,).toBe(5,);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { reportLoggerInternalError, } from '../error-format.ts';
|
|
2
|
+
import { createLocalStorageStore, } from './local-storage-store.ts';
|
|
3
|
+
import { createRecordBuffer, } from './record-buffer.ts';
|
|
4
|
+
import { detectWebStorageRuntime, } from './web-storage-runtime.ts';
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
Level,
|
|
8
|
+
Sink,
|
|
9
|
+
} from '../types.ts';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Node CLI flag that backs `localStorage` with a file; without it Node 26
|
|
13
|
+
* leaves `globalThis.localStorage` undefined and prints an
|
|
14
|
+
* `ExperimentalWarning` on stderr the moment the getter is touched.
|
|
15
|
+
*/
|
|
16
|
+
const NODE_LOCALSTORAGE_FLAG = '--localstorage-file';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Reports whether this is a flagless plain-Node process, where
|
|
20
|
+
* `globalThis.localStorage` is undefined and merely touching the getter
|
|
21
|
+
* prints Node's ExperimentalWarning on stderr for every consumer, so `verify`
|
|
22
|
+
* skips the probe without any access. The flag is honored both on the command
|
|
23
|
+
* line (`process.execArgv`) and through `NODE_OPTIONS` (verified on Node 26;
|
|
24
|
+
* `execArgv` does not echo `NODE_OPTIONS` flags, so both are checked). A DOM
|
|
25
|
+
* host (such as an Electron renderer, which carries `process.versions.node`
|
|
26
|
+
* alongside a real `localStorage`) is exempt and probes normally.
|
|
27
|
+
*
|
|
28
|
+
* @returns Whether the probe must be skipped because Node would only warn.
|
|
29
|
+
*/
|
|
30
|
+
function nodeWithoutLocalStorageFile(): boolean {
|
|
31
|
+
if (detectWebStorageRuntime() !== 'node')
|
|
32
|
+
return false;
|
|
33
|
+
if ('document' in globalThis)
|
|
34
|
+
return false;
|
|
35
|
+
/**
|
|
36
|
+
* Whether the backing-file flag reached this process on the command line.
|
|
37
|
+
*/
|
|
38
|
+
const flaggedInExecArgv = process.execArgv
|
|
39
|
+
.some(function startsWithFlag(argument: string,) {
|
|
40
|
+
return argument.startsWith(NODE_LOCALSTORAGE_FLAG,);
|
|
41
|
+
},);
|
|
42
|
+
/**
|
|
43
|
+
* Raw `NODE_OPTIONS` value, absent when the variable is unset; `execArgv`
|
|
44
|
+
* does not echo flags arriving through it, so it is scanned separately.
|
|
45
|
+
*/
|
|
46
|
+
const nodeOptions = process.env
|
|
47
|
+
.NODE_OPTIONS;
|
|
48
|
+
/**
|
|
49
|
+
* Whether the backing-file flag reached this process through `NODE_OPTIONS`.
|
|
50
|
+
*/
|
|
51
|
+
const flaggedInNodeOptions = (nodeOptions ?? '').includes(NODE_LOCALSTORAGE_FLAG,);
|
|
52
|
+
return !(flaggedInExecArgv || flaggedInNodeOptions);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Verifies localStorage actually persists data. Stateless: the logger calls
|
|
57
|
+
* this once per sink at startup and owns the resulting availability, so no
|
|
58
|
+
* verified/available flag is kept here.
|
|
59
|
+
*
|
|
60
|
+
* Election is by probe alone: any runtime whose `localStorage` round-trips
|
|
61
|
+
* (browsers, Deno, Node launched with `--localstorage-file`) keeps the sink.
|
|
62
|
+
* The one short-circuit, {@link nodeWithoutLocalStorageFile}, returns the
|
|
63
|
+
* same `false` the probe would and exists only to keep Node's access warning
|
|
64
|
+
* off every consumer's stderr, not to gate by runtime brand.
|
|
65
|
+
*
|
|
66
|
+
* @returns Whether localStorage is available and round-trips a probe write.
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* ```ts
|
|
70
|
+
* if (await verifyLocalStorage()) {
|
|
71
|
+
* // localStorage usable
|
|
72
|
+
* }
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
function verifyLocalStorage(): Promise<boolean> {
|
|
76
|
+
if (nodeWithoutLocalStorageFile())
|
|
77
|
+
return Promise.resolve(false,);
|
|
78
|
+
try {
|
|
79
|
+
/**
|
|
80
|
+
* Sentinel key used only for the probe write/read; removed afterward to avoid polluting real log entries.
|
|
81
|
+
*/
|
|
82
|
+
const testKey = '__monochromatic_verify__';
|
|
83
|
+
/**
|
|
84
|
+
* Timestamp-based probe value so concurrent verifications never read each other's writes.
|
|
85
|
+
*/
|
|
86
|
+
const testValue = `test-${Date.now()}`;
|
|
87
|
+
globalThis.localStorage
|
|
88
|
+
.setItem(
|
|
89
|
+
testKey,
|
|
90
|
+
testValue,
|
|
91
|
+
);
|
|
92
|
+
/**
|
|
93
|
+
* Probe value read back from storage; equality with `testValue` proves writes actually persist.
|
|
94
|
+
*/
|
|
95
|
+
const readBack = globalThis.localStorage
|
|
96
|
+
.getItem(testKey,);
|
|
97
|
+
globalThis.localStorage
|
|
98
|
+
.removeItem(testKey,);
|
|
99
|
+
return Promise.resolve(readBack === testValue,);
|
|
100
|
+
}
|
|
101
|
+
catch (error: unknown) {
|
|
102
|
+
if ('localStorage' in globalThis)
|
|
103
|
+
reportLoggerInternalError({
|
|
104
|
+
context: 'localStorage sink verification failed',
|
|
105
|
+
error,
|
|
106
|
+
},);
|
|
107
|
+
return Promise.resolve(false,);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Builds a localStorage sink that buffers serialized records through the
|
|
113
|
+
* shared {@link createRecordBuffer} policy and persists each newline-joined
|
|
114
|
+
* JSONL batch under a run-scoped counter-incremented key through
|
|
115
|
+
* {@link createLocalStorageStore}. One uniform write path runs on every
|
|
116
|
+
* runtime; no per-runtime mode exists. Flush triggers (32 KiB in-write cap,
|
|
117
|
+
* `warn`-or-worse severity, 250 ms quiet-period deadline, page lifecycle,
|
|
118
|
+
* and the `flush` hook) are the buffer's; see {@link createRecordBuffer}.
|
|
119
|
+
*
|
|
120
|
+
* Unlike the sessionStorage sink, whose store dies with the tab, this sink's
|
|
121
|
+
* batches survive tab close and browser restart, bounded by oldest-first
|
|
122
|
+
* eviction at half the localStorage quota; that makes it the web storage sink
|
|
123
|
+
* whose records remain inspectable after a full crash-and-restart.
|
|
124
|
+
*
|
|
125
|
+
* @returns Sink backed by web `localStorage`.
|
|
126
|
+
*
|
|
127
|
+
* @example
|
|
128
|
+
* ```ts
|
|
129
|
+
* const { logger } = createLogger({ sinks: [createLocalStorageSink()] });
|
|
130
|
+
* logger.info('user signed in'); // buffered
|
|
131
|
+
* logger.warn('quota near'); // flushes both records in one batch
|
|
132
|
+
* ```
|
|
133
|
+
*/
|
|
134
|
+
export function createLocalStorageSink(): Sink {
|
|
135
|
+
/**
|
|
136
|
+
* Persistence engine owning run identity, key allocation, prior-run
|
|
137
|
+
* adoption, footprint accounting, and quota eviction; the buffer decides
|
|
138
|
+
* when a batch is handed to it.
|
|
139
|
+
*/
|
|
140
|
+
const store = createLocalStorageStore();
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Shared buffering stage; every flush trigger lands one joined batch in the
|
|
144
|
+
* persistence engine synchronously.
|
|
145
|
+
*/
|
|
146
|
+
const buffer = createRecordBuffer({ onFlush: store.persist, },);
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Buffers a log record through the shared policy; see
|
|
150
|
+
* {@link createRecordBuffer} for the flush triggers.
|
|
151
|
+
*
|
|
152
|
+
* @param record - Log record to buffer and eventually persist.
|
|
153
|
+
*
|
|
154
|
+
* @mutates record - `JSON.stringify` may invoke `toJSON`, getters, or proxy traps.
|
|
155
|
+
*/
|
|
156
|
+
function write(record: {
|
|
157
|
+
level: Level;
|
|
158
|
+
message: string;
|
|
159
|
+
timestamp: number;
|
|
160
|
+
},): Promise<void> {
|
|
161
|
+
buffer.add({
|
|
162
|
+
level: record.level,
|
|
163
|
+
serialized: JSON.stringify(record,),
|
|
164
|
+
},);
|
|
165
|
+
return Promise.resolve();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Drains the buffer into the persistence engine; the drain is synchronous,
|
|
170
|
+
* so the batch has landed by the time the resolved promise is observed.
|
|
171
|
+
*/
|
|
172
|
+
function flush(): Promise<void> {
|
|
173
|
+
buffer.drain();
|
|
174
|
+
return Promise.resolve();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
flush,
|
|
179
|
+
verify: verifyLocalStorage,
|
|
180
|
+
write,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import {
|
|
2
|
+
describe,
|
|
3
|
+
expect,
|
|
4
|
+
it,
|
|
5
|
+
} from '@monochromatic-dev/module-test/ts';
|
|
6
|
+
import {
|
|
7
|
+
_parseLogKey as parseLogKey,
|
|
8
|
+
sinks,
|
|
9
|
+
} from '@monochromatic-dev/module-logger';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Sink factories under test, read from the built artifact's `sinks` namespace.
|
|
13
|
+
*/
|
|
14
|
+
const {
|
|
15
|
+
createLocalStorageSink,
|
|
16
|
+
} = sinks;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Installs `fake` as `globalThis.localStorage` via the property descriptor,
|
|
20
|
+
* restoring the original descriptor, or removing the property when none
|
|
21
|
+
* existed, when the returned guard leaves `using` scope.
|
|
22
|
+
*
|
|
23
|
+
* @param fake - Storage stand-in to install for the duration of the scope.
|
|
24
|
+
*
|
|
25
|
+
* @returns Disposable that restores the original `localStorage` on exit.
|
|
26
|
+
*/
|
|
27
|
+
function installFakeLocalStorage(fake: Storage,): Disposable {
|
|
28
|
+
const original = Object.getOwnPropertyDescriptor(globalThis, 'localStorage',);
|
|
29
|
+
Object.defineProperty(globalThis, 'localStorage', {
|
|
30
|
+
configurable: true,
|
|
31
|
+
value: fake,
|
|
32
|
+
},);
|
|
33
|
+
return {
|
|
34
|
+
[Symbol.dispose](): void {
|
|
35
|
+
if (original === undefined)
|
|
36
|
+
Reflect.deleteProperty(globalThis, 'localStorage',);
|
|
37
|
+
else
|
|
38
|
+
Object.defineProperty(globalThis, 'localStorage', original,);
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Installs a minimal fake `globalThis.document` (absent under Node) so the
|
|
45
|
+
* sink under test takes the DOM-host verify path instead of the flagless-Node
|
|
46
|
+
* short-circuit, restoring or removing the global when the returned guard
|
|
47
|
+
* leaves `using` scope.
|
|
48
|
+
*
|
|
49
|
+
* @returns Disposable that restores the original `document` on exit.
|
|
50
|
+
*/
|
|
51
|
+
function installFakeDocument(): Disposable {
|
|
52
|
+
const had = 'document' in globalThis;
|
|
53
|
+
const original = globalThis.document;
|
|
54
|
+
globalThis.document = {
|
|
55
|
+
addEventListener(): void {},
|
|
56
|
+
visibilityState: 'visible',
|
|
57
|
+
} as unknown as Document;
|
|
58
|
+
return {
|
|
59
|
+
[Symbol.dispose](): void {
|
|
60
|
+
if (had)
|
|
61
|
+
globalThis.document = original;
|
|
62
|
+
else
|
|
63
|
+
Reflect.deleteProperty(globalThis, 'document',);
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Builds an in-memory `Storage` stand-in with enumeration support, exposing
|
|
70
|
+
* the raw `backing` map so a test can assert exactly which keys and values
|
|
71
|
+
* landed.
|
|
72
|
+
*
|
|
73
|
+
* @returns Storage stand-in exposing the raw `backing` map.
|
|
74
|
+
*/
|
|
75
|
+
function createFakeStorage(): Storage & { readonly backing: Map<string, string>; } {
|
|
76
|
+
const backing = new Map<string, string>();
|
|
77
|
+
return {
|
|
78
|
+
backing,
|
|
79
|
+
get length() {
|
|
80
|
+
return backing.size;
|
|
81
|
+
},
|
|
82
|
+
key(slot: number,) {
|
|
83
|
+
return [...backing.keys(),][slot] ?? null;
|
|
84
|
+
},
|
|
85
|
+
clear(): void {
|
|
86
|
+
backing.clear();
|
|
87
|
+
},
|
|
88
|
+
getItem(key: string,) {
|
|
89
|
+
return backing.get(key,) ?? null;
|
|
90
|
+
},
|
|
91
|
+
setItem(key: string, value: string,): void {
|
|
92
|
+
backing.set(key, value,);
|
|
93
|
+
},
|
|
94
|
+
removeItem(key: string,): void {
|
|
95
|
+
backing.delete(key,);
|
|
96
|
+
},
|
|
97
|
+
} as unknown as Storage & { readonly backing: Map<string, string>; };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Captures `console.warn` output, restoring the real method when the returned
|
|
102
|
+
* guard leaves `using` scope, so a test can prove the flagless-Node
|
|
103
|
+
* short-circuit stays silent.
|
|
104
|
+
*
|
|
105
|
+
* @returns Disposable exposing captured warn lines as `calls`.
|
|
106
|
+
*/
|
|
107
|
+
function spyConsoleWarn(): Disposable & { readonly calls: string[]; } {
|
|
108
|
+
const original = console.warn;
|
|
109
|
+
const calls: string[] = [];
|
|
110
|
+
console.warn = (...args: unknown[]): void => {
|
|
111
|
+
calls.push(args.map(String,)
|
|
112
|
+
.join(' ',),);
|
|
113
|
+
};
|
|
114
|
+
return {
|
|
115
|
+
calls,
|
|
116
|
+
[Symbol.dispose](): void {
|
|
117
|
+
console.warn = original;
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Serial because tests swap the process-global `localStorage` and `document`,
|
|
123
|
+
// and the flagless-Node test depends on both staying absent.
|
|
124
|
+
await describe({
|
|
125
|
+
name: createLocalStorageSink.name,
|
|
126
|
+
concurrency: 1,
|
|
127
|
+
children: [
|
|
128
|
+
it({
|
|
129
|
+
name: 'verify resolves false on flagless Node without touching the getter or reporting',
|
|
130
|
+
fn: async () => {
|
|
131
|
+
using warnSpy = spyConsoleWarn();
|
|
132
|
+
const sink = createLocalStorageSink();
|
|
133
|
+
expect(await sink.verify(),)
|
|
134
|
+
.toBe(false,);
|
|
135
|
+
// A silent skip: no internal-error report and, because the getter was
|
|
136
|
+
// never touched, no ExperimentalWarning from Node either.
|
|
137
|
+
expect(warnSpy.calls,)
|
|
138
|
+
.toHaveLength(0,);
|
|
139
|
+
},
|
|
140
|
+
},),
|
|
141
|
+
|
|
142
|
+
it({
|
|
143
|
+
name: 'verify round-trips against an installed storage on a DOM host and cleans its probe',
|
|
144
|
+
fn: async () => {
|
|
145
|
+
using _document = installFakeDocument();
|
|
146
|
+
const fake = createFakeStorage();
|
|
147
|
+
using _storage = installFakeLocalStorage(fake,);
|
|
148
|
+
const sink = createLocalStorageSink();
|
|
149
|
+
|
|
150
|
+
expect(await sink.verify(),)
|
|
151
|
+
.toBe(true,);
|
|
152
|
+
expect(fake.backing.size,)
|
|
153
|
+
.toBe(0,);
|
|
154
|
+
},
|
|
155
|
+
},),
|
|
156
|
+
|
|
157
|
+
it({
|
|
158
|
+
name: 'a warn record lands buffered records as one run-scoped JSONL batch',
|
|
159
|
+
fn: async () => {
|
|
160
|
+
using _document = installFakeDocument();
|
|
161
|
+
const fake = createFakeStorage();
|
|
162
|
+
using _storage = installFakeLocalStorage(fake,);
|
|
163
|
+
const sink = createLocalStorageSink();
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Routine record that must stay buffered on its own.
|
|
167
|
+
*/
|
|
168
|
+
const first = {
|
|
169
|
+
level: 'info' as const,
|
|
170
|
+
message: 'first',
|
|
171
|
+
timestamp: 1,
|
|
172
|
+
};
|
|
173
|
+
/**
|
|
174
|
+
* Urgent record whose severity flushes itself and `first` together.
|
|
175
|
+
*/
|
|
176
|
+
const second = {
|
|
177
|
+
level: 'warn' as const,
|
|
178
|
+
message: 'second',
|
|
179
|
+
timestamp: 2,
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
await sink.write(first,);
|
|
183
|
+
expect(fake.backing.size,)
|
|
184
|
+
.toBe(0,);
|
|
185
|
+
|
|
186
|
+
await sink.write(second,);
|
|
187
|
+
expect([...fake.backing.values(),],)
|
|
188
|
+
.toEqual([`${JSON.stringify(first,)}\n${JSON.stringify(second,)}`,],);
|
|
189
|
+
/**
|
|
190
|
+
* Identity of the landed key; the first batch of a run takes index zero.
|
|
191
|
+
*/
|
|
192
|
+
const { parsed: landed, } = parseLogKey([...fake.backing.keys(),][0] ?? '',);
|
|
193
|
+
expect(landed?.index,)
|
|
194
|
+
.toBe(0,);
|
|
195
|
+
},
|
|
196
|
+
},),
|
|
197
|
+
|
|
198
|
+
it({
|
|
199
|
+
name: 'the flush hook drains a buffered routine record synchronously',
|
|
200
|
+
fn: async () => {
|
|
201
|
+
using _document = installFakeDocument();
|
|
202
|
+
const fake = createFakeStorage();
|
|
203
|
+
using _storage = installFakeLocalStorage(fake,);
|
|
204
|
+
const sink = createLocalStorageSink();
|
|
205
|
+
|
|
206
|
+
await sink.write({
|
|
207
|
+
level: 'info',
|
|
208
|
+
message: 'buffered until flush',
|
|
209
|
+
timestamp: 3,
|
|
210
|
+
},);
|
|
211
|
+
await sink.flush?.();
|
|
212
|
+
|
|
213
|
+
expect([...fake.backing.values(),].join('|',),)
|
|
214
|
+
.toContain('buffered until flush',);
|
|
215
|
+
},
|
|
216
|
+
},),
|
|
217
|
+
],
|
|
218
|
+
},);
|
package/src/sink/noop.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
LogRecord,
|
|
3
|
+
Sink,
|
|
4
|
+
} from '../types.ts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Noop verification: the sink is always available, so it never throws nor
|
|
8
|
+
* needs setup.
|
|
9
|
+
*
|
|
10
|
+
* @returns Always-resolved `true`.
|
|
11
|
+
*/
|
|
12
|
+
function verify(): Promise<boolean> {
|
|
13
|
+
return Promise.resolve(true,);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Discards a log record. Matches the `Sink['write']` signature.
|
|
18
|
+
*
|
|
19
|
+
* @param _record - Log record to discard.
|
|
20
|
+
*/
|
|
21
|
+
function write(_record: LogRecord,): Promise<void> {
|
|
22
|
+
// Intentionally discards all logs; resolves immediately to match the async Sink contract.
|
|
23
|
+
return Promise.resolve();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Builds a noop sink that discards every record and always verifies as
|
|
28
|
+
* available. Stateless, so the returned adapters share the same functions;
|
|
29
|
+
* the factory shape merely matches the other sinks. Useful as a stand-in
|
|
30
|
+
* that disables logging without removing log calls.
|
|
31
|
+
*
|
|
32
|
+
* @returns Sink that discards all records and exposes no `flush` (nothing
|
|
33
|
+
* is buffered).
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```ts
|
|
37
|
+
* const { logger } = createLogger({ sinks: [createNoopSink()] });
|
|
38
|
+
* logger.info('goes nowhere');
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export function createNoopSink(): Sink {
|
|
42
|
+
return {
|
|
43
|
+
verify,
|
|
44
|
+
write,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import {
|
|
2
|
+
describe,
|
|
3
|
+
expect,
|
|
4
|
+
it,
|
|
5
|
+
} from '@monochromatic-dev/module-test/ts';
|
|
6
|
+
import {
|
|
7
|
+
sinks,
|
|
8
|
+
} from '@monochromatic-dev/module-logger';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Sink factories under test, read from the built artifact's `sinks` namespace.
|
|
12
|
+
*/
|
|
13
|
+
const {
|
|
14
|
+
createNoopSink,
|
|
15
|
+
} = sinks;
|
|
16
|
+
|
|
17
|
+
await describe({
|
|
18
|
+
name: 'noop sink',
|
|
19
|
+
children: [
|
|
20
|
+
it({
|
|
21
|
+
name: 'verifies as available',
|
|
22
|
+
fn: async () => {
|
|
23
|
+
const sink = createNoopSink();
|
|
24
|
+
expect(await sink.verify(),)
|
|
25
|
+
.toBe(true,);
|
|
26
|
+
},
|
|
27
|
+
},),
|
|
28
|
+
|
|
29
|
+
it({
|
|
30
|
+
name: 'discards writes without throwing and exposes no flush hook',
|
|
31
|
+
fn: async () => {
|
|
32
|
+
const sink = createNoopSink();
|
|
33
|
+
await expect(
|
|
34
|
+
sink.write({
|
|
35
|
+
level: 'info',
|
|
36
|
+
message: 'discarded',
|
|
37
|
+
timestamp: 0,
|
|
38
|
+
},),
|
|
39
|
+
)
|
|
40
|
+
.resolves
|
|
41
|
+
.toBeUndefined();
|
|
42
|
+
expect(sink.flush,)
|
|
43
|
+
.toBeUndefined();
|
|
44
|
+
},
|
|
45
|
+
},),
|
|
46
|
+
],
|
|
47
|
+
},);
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// oxlint-disable eslint/no-await-in-loop -- browser evaluate callbacks lose type info across page boundary
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
expect,
|
|
5
|
+
test,
|
|
6
|
+
} from '@playwright/test';
|
|
7
|
+
|
|
8
|
+
declare global {
|
|
9
|
+
// oxlint-disable-next-line typescript/consistent-type-imports -- typeof import() cannot use import type syntax
|
|
10
|
+
var moduleLogger: typeof import('@monochromatic-dev/module-logger');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
test.describe('OPFS sink', () => {
|
|
14
|
+
test.beforeEach(async ({ page, },) => {
|
|
15
|
+
await page.goto('/',);
|
|
16
|
+
await page.waitForFunction(() => globalThis.moduleLogger !== undefined);
|
|
17
|
+
},);
|
|
18
|
+
|
|
19
|
+
test('createOpfsSink exposes a callable verify', async ({ page, },) => {
|
|
20
|
+
const typeofVerify = await page.evaluate(() => {
|
|
21
|
+
const { createOpfsSink, } = globalThis.moduleLogger.sinks;
|
|
22
|
+
return typeof createOpfsSink().verify;
|
|
23
|
+
},);
|
|
24
|
+
expect(typeofVerify,).toBe('function',);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('verify resolves a boolean', async ({ page, },) => {
|
|
28
|
+
const resultType = await page.evaluate(async () => {
|
|
29
|
+
const { createOpfsSink, } = globalThis.moduleLogger.sinks;
|
|
30
|
+
const resolved = await createOpfsSink().verify();
|
|
31
|
+
return typeof resolved;
|
|
32
|
+
},);
|
|
33
|
+
expect(resultType,).toBe('boolean',);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('verify detects OPFS availability', async ({ page, },) => {
|
|
37
|
+
const result = await page.evaluate(async () => {
|
|
38
|
+
const { createOpfsSink, } = globalThis.moduleLogger.sinks;
|
|
39
|
+
return createOpfsSink().verify();
|
|
40
|
+
},);
|
|
41
|
+
expect(result,).toBe(true,);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('sink write method exists', async ({ page, },) => {
|
|
45
|
+
const typeofSink = await page.evaluate(() => {
|
|
46
|
+
const { createOpfsSink, } = globalThis.moduleLogger.sinks;
|
|
47
|
+
return typeof createOpfsSink().write;
|
|
48
|
+
},);
|
|
49
|
+
expect(typeofSink,).toBe('function',);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('a verified sink writes records across levels and message shapes', async ({ page, },) => {
|
|
53
|
+
const allSucceeded = await page.evaluate(async () => {
|
|
54
|
+
const { createOpfsSink, } = globalThis.moduleLogger.sinks;
|
|
55
|
+
const sink = createOpfsSink();
|
|
56
|
+
await sink.verify();
|
|
57
|
+
const levels = ['trace', 'debug', 'info', 'warn', 'error', 'fatal',] as const;
|
|
58
|
+
const messages = [
|
|
59
|
+
'test message',
|
|
60
|
+
'Hello δΈη π',
|
|
61
|
+
'',
|
|
62
|
+
'{"key": "value", "nested": {"a": 1}}',
|
|
63
|
+
'line1\nline2\nline3',
|
|
64
|
+
];
|
|
65
|
+
for (const level of levels) {
|
|
66
|
+
for (const message of messages) {
|
|
67
|
+
try {
|
|
68
|
+
await Promise.resolve(sink.write({
|
|
69
|
+
level,
|
|
70
|
+
message,
|
|
71
|
+
timestamp: Date.now(),
|
|
72
|
+
},),);
|
|
73
|
+
}
|
|
74
|
+
catch (error: unknown) {
|
|
75
|
+
console.warn('OPFS sink browser test write failed', error,);
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return true;
|
|
81
|
+
},);
|
|
82
|
+
expect(allSucceeded,).toBe(true,);
|
|
83
|
+
});
|
|
84
|
+
});
|