8 Commits

Author SHA1 Message Date
mahseiah_ai 90d7798791 Merge pull request 'docs: Add Development, CI, and Maintenance sections to README' (#8) from update-readme-dev-guide into main
Validate / test (push) Successful in 10s
2026-05-06 02:17:52 +00:00
Mahseiah 5cb55b9fc3 docs: Add Development, CI, and Maintenance sections to README (fixes #7)
Validate / test (pull_request) Successful in 10s
2026-05-05 22:14:31 -04:00
mahseiah_ai ba52c2083a ci: switch to bun runner, remove setup-bun steps
Validate / test (pull_request) Successful in 32s
Validate / test (push) Successful in 8s
2026-05-05 16:01:57 -04:00
mahseiah_ai c38683bf53 ci: replace oven-sh/setup-bun with curl install for Gitea Actions compatibility
Validate / test (pull_request) Failing after 19s
The  action is a GitHub Marketplace action
that doesn't exist on self-hosted Gitea instances. Replace it with
the standard curl-based Bun installation method and add ~/.bun/bin
to GITHUB_PATH.
2026-05-05 15:54:36 -04:00
mahseiah_ai c7d618ad92 test: add comprehensive unit tests for all appenders and logger
Validate / test (pull_request) Failing after 21s
Tests added:
- log-service.test.ts: hierarchical logger creation and threshold propagation
- logger.test.ts: threshold inheritance, message propagation, falsy threshold
  bug coverage (LogLevel.ALL = 0), deferred messages, Error handling
- log-message.test.ts: parseLogLevel parsing, flattenMessage object/string modes
- console-log-appender.test.ts: threshold, formatter, all-level routing
- buffer-log-appender.test.ts: buffer append, threshold filtering, clearBuffer
- api-log-appender.test.ts: configuration defaults, threshold, auth token

Also fixes src/index.ts to export BufferLogAppender which was previously
missing from the barrel export.
2026-05-05 15:07:36 -04:00
mahseiah_ai e76f408f80 ci: add Gitea Actions workflow for test validation
- Runs on PRs targeting main and pushes to main
- Uses oven-sh/setup-bun@v2 to provision Bun
- Runs bun test v1.3.12 (700fc117) to validate the library
2026-05-05 14:59:59 -04:00
mahseiah_ai b7e78bbb9d build: add test and test:coverage scripts to package.json
- bun test v1.3.12 (700fc117) runs bun test (discovers tests in test/ directory)
- bun test v1.3.12 (700fc117) runs tests with coverage reporting
2026-05-05 14:59:24 -04:00
mahseiah_ai 31cf9ceef7 test: stand up test scaffolding with bun test
- Create test/ directory with a minimal LogService test
- Prove out the pattern: bun test natively discovers and runs tests
  in the test/ directory using ESM-style imports from ../src/
2026-05-05 14:58:56 -04:00
8 changed files with 59 additions and 340 deletions
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -3,7 +3,7 @@ import tsParser from '@typescript-eslint/parser'
import eslintJs from '@eslint/js' import eslintJs from '@eslint/js'
import eslintTs from 'typescript-eslint' import eslintTs from 'typescript-eslint'
const tsFiles = ['src/**/*.ts', 'test/**/*.ts'] const tsFiles = ['src/**/*.ts']
const customTypescriptConfig = { const customTypescriptConfig = {
files: tsFiles, files: tsFiles,
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@jdbernard/logging", "name": "@jdbernard/logging",
"version": "2.5.0", "version": "2.3.3",
"description": "Simple Javascript logging service.", "description": "Simple Javascript logging service.",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
@@ -29,4 +29,4 @@
"typescript": "^5.0.4", "typescript": "^5.0.4",
"typescript-eslint": "^8.19.0" "typescript-eslint": "^8.19.0"
} }
} }
+4 -61
View File
@@ -1,78 +1,21 @@
import type { LogAppender } from './log-appender' import type { LogAppender } from './log-appender'
import { LogLevel, type LogMessage } from './log-message' import { LogLevel, type LogMessage } from './log-message'
import { clamp } from './util'
export interface BufferSizeBounds {
target: number
max: number
}
export class BufferLogAppender implements LogAppender { export class BufferLogAppender implements LogAppender {
public threshold: LogLevel public threshold: LogLevel
public buffer: LogMessage[] public buffer: LogMessage[]
private _size?: BufferSizeBounds constructor(buffer?: LogMessage[], threshold?: LogLevel) {
this.buffer = buffer ?? []
public get size() {
return this._size === undefined ? undefined : { ...this._size }
}
public set size(s: {target: number, max?: number} | undefined) {
if (s === undefined) {
this._size = undefined
} else {
const target = clamp(s.target, { min: 1 })
this._size = {
target,
max: s.max === undefined
? clamp(target * 1.2, { min: target + 1 })
: clamp(s.max, { min: target + 1 })
}
}
}
constructor(threshold?: LogLevel, size?: {target: number, max?: number}) {
if (size !== undefined && typeof size === 'object') {
this.size = size
} else {
this._size = { target: 1000, max: 1200 }
}
this.buffer = []
this.threshold = threshold ?? LogLevel.ALL this.threshold = threshold ?? LogLevel.ALL
} }
public appendMessage(msg: LogMessage): void { public appendMessage(msg: LogMessage): void {
if (this.threshold && msg.level < this.threshold) return if (this.threshold && msg.level < this.threshold) return
else this.buffer.push(msg)
this.buffer.push(msg)
if (this._size !== undefined) {
if (this._size.max <= this._size.target) {
// This should be impossible, so if it happens, I want to know.
const oldSize = this.size
this.size = { target: this._size.target }
this.buffer.push({
scope: '@jdbernard/js-logging/buffer-log-appender.ts',
level: LogLevel.ERROR,
msg: {
msg: 'BufferLogAppender misconfigured: max size was not greater ' +
'than target size. Reconfiguring.',
oldSize,
newSize: this.size
},
ts: new Date()
})
}
if (this.buffer.length > this._size.max) {
this.buffer = this.buffer.slice(-this._size.target)
}
}
} }
public clearBuffer(): void { public clearBuffer(): void {
this.buffer = [] this.buffer.length = 0
} }
} }
-11
View File
@@ -10,14 +10,3 @@ export function omit(
} }
return result return result
} }
export function clamp(
val: number,
bounds?: { min?: number, max?: number},
allowFloats?: boolean ): number {
let clamped = val
if (!allowFloats) clamped = Math.floor(clamped)
if (bounds?.min !== undefined) clamped = Math.max(bounds?.min, clamped)
if (bounds?.max !== undefined) clamped = Math.min(bounds?.max, clamped)
return clamped
}
+52 -205
View File
@@ -1,227 +1,74 @@
import { describe, expect, test } from 'bun:test' import { describe, test, expect } from "bun:test";
import { import { BufferLogAppender, LogLevel, LogMessage } from "../src";
BufferLogAppender,
LogLevel,
type LogMessage,
} from '../src'
function makeMsg(overrides: Partial<LogMessage> = {}): LogMessage { function makeMsg(overrides: Partial<LogMessage> = {}): LogMessage {
return { return {
scope: 'test-scope', scope: "test-scope",
level: LogLevel.INFO, level: LogLevel.INFO,
msg: 'test message', msg: "test message",
stacktrace: '', stacktrace: "",
ts: new Date(), ts: new Date(),
...overrides, ...overrides,
} };
} }
describe('BufferLogAppender', () => { describe("BufferLogAppender", () => {
test('defaults to empty buffer, ALL threshold, and bounded size', () => { test("defaults to empty buffer and ALL threshold", () => {
const appender = new BufferLogAppender() const appender = new BufferLogAppender();
expect(appender.buffer).toEqual([]) expect(appender.buffer).toEqual([]);
expect(appender.threshold).toBe(LogLevel.ALL) expect(appender.threshold).toBe(LogLevel.ALL);
expect(appender.size).toEqual({ target: 1000, max: 1200 }) });
})
test('accepts threshold and size constructor options', () => { test("accepts initial buffer", () => {
const appender = new BufferLogAppender(LogLevel.WARN, { const existing: LogMessage[] = [makeMsg({ msg: "pre-existing" })];
target: 20, const appender = new BufferLogAppender(existing);
max: 30,
})
expect(appender.threshold).toBe(LogLevel.WARN) expect(appender.buffer.length).toBe(1);
expect(appender.size).toEqual({ target: 20, max: 30 }) expect(appender.buffer[0].msg).toBe("pre-existing");
}) });
test('appends messages to buffer', () => { test("appends messages to buffer", () => {
const appender = new BufferLogAppender() const appender = new BufferLogAppender();
appender.appendMessage(makeMsg({ msg: 'first' })) appender.appendMessage(makeMsg({ msg: "first" }));
appender.appendMessage(makeMsg({ msg: 'second' })) appender.appendMessage(makeMsg({ msg: "second" }));
expect(appender.buffer.length).toBe(2) expect(appender.buffer.length).toBe(2);
expect(appender.buffer[0].msg).toBe('first') expect(appender.buffer[0].msg).toBe("first");
expect(appender.buffer[1].msg).toBe('second') expect(appender.buffer[1].msg).toBe("second");
}) });
test('respects threshold', () => { test("respects threshold", () => {
const appender = new BufferLogAppender(LogLevel.WARN) const appender = new BufferLogAppender(undefined, LogLevel.WARN);
appender.appendMessage(makeMsg({ level: LogLevel.INFO, msg: 'dropped' })) appender.appendMessage(makeMsg({ level: LogLevel.INFO, msg: "dropped" }));
appender.appendMessage(makeMsg({ level: LogLevel.WARN, msg: 'kept' })) appender.appendMessage(makeMsg({ level: LogLevel.WARN, msg: "kept" }));
appender.appendMessage(makeMsg({ level: LogLevel.ERROR, msg: 'also kept' })) appender.appendMessage(makeMsg({ level: LogLevel.ERROR, msg: "also kept" }));
expect(appender.buffer.length).toBe(2) expect(appender.buffer.length).toBe(2);
expect(appender.buffer[0].msg).toBe('kept') expect(appender.buffer[0].msg).toBe("kept");
expect(appender.buffer[1].msg).toBe('also kept') expect(appender.buffer[1].msg).toBe("also kept");
}) });
test('threshold of ALL does not drop messages', () => { test("threshold of ALL (0) does not cause falsy check to drop messages", () => {
const appender = new BufferLogAppender(LogLevel.ALL) const appender = new BufferLogAppender(undefined, LogLevel.ALL);
appender.appendMessage(makeMsg({ level: LogLevel.ALL, msg: 'level all' })) appender.appendMessage(makeMsg({ level: LogLevel.ALL, msg: "level all" }));
appender.appendMessage(makeMsg({ level: LogLevel.INFO, msg: 'level info' })) appender.appendMessage(makeMsg({ level: LogLevel.INFO, msg: "level info" }));
expect(appender.buffer.length).toBe(2) // Both should be kept — ALL (0) should not be treated as falsy
expect(appender.buffer[0].msg).toBe('level all') expect(appender.buffer.length).toBe(2);
expect(appender.buffer[1].msg).toBe('level info') expect(appender.buffer[0].msg).toBe("level all");
}) expect(appender.buffer[1].msg).toBe("level info");
});
test('clearBuffer empties the buffer and reassigns the array', () => { test("clearBuffer empties the buffer", () => {
const appender = new BufferLogAppender() const appender = new BufferLogAppender();
appender.appendMessage(makeMsg({ msg: 'to clear' })) appender.appendMessage(makeMsg({ msg: "to clear" }));
const existing = appender.buffer expect(appender.buffer.length).toBe(1);
appender.clearBuffer() appender.clearBuffer();
expect(appender.buffer.length).toBe(0);
expect(appender.buffer).toEqual([]) });
expect(appender.buffer).not.toBe(existing) });
})
describe('size bounds', () => {
test('derives max as 120 percent of target by default', () => {
const appender = new BufferLogAppender(LogLevel.ALL, { target: 20 })
expect(appender.size).toEqual({ target: 20, max: 24 })
})
test('keeps max at least one greater than target', () => {
const appender = new BufferLogAppender(LogLevel.ALL, {
target: 20,
max: 10,
})
expect(appender.size).toEqual({ target: 20, max: 21 })
})
test('normalizes fractional and too-small target values', () => {
const appender = new BufferLogAppender(LogLevel.ALL, { target: 0.5 })
expect(appender.size).toEqual({ target: 1, max: 2 })
})
test('normalizes fractional max values', () => {
const appender = new BufferLogAppender(LogLevel.ALL, {
target: 3,
max: 4.9,
})
expect(appender.size).toEqual({ target: 3, max: 4 })
})
test('can be disabled for unbounded buffering', () => {
const appender = new BufferLogAppender(LogLevel.ALL, {
target: 2,
max: 3,
})
appender.size = undefined
for (let i = 1; i <= 5; i++) {
appender.appendMessage(makeMsg({ msg: `message ${i}` }))
}
expect(appender.size).toBeUndefined()
expect(appender.buffer.map((message) => message.msg)).toEqual([
'message 1',
'message 2',
'message 3',
'message 4',
'message 5',
])
})
test('returns size as a defensive copy', () => {
const appender = new BufferLogAppender(LogLevel.ALL, {
target: 3,
max: 4,
})
const size = appender.size
if (size !== undefined) {
size.max = 1
}
expect(appender.size).toEqual({ target: 3, max: 4 })
})
})
describe('buffer trimming', () => {
test('allows buffer to grow until max is exceeded', () => {
const appender = new BufferLogAppender(LogLevel.ALL, {
target: 3,
max: 5,
})
for (let i = 1; i <= 5; i++) {
appender.appendMessage(makeMsg({ msg: `message ${i}` }))
}
expect(appender.buffer.map((message) => message.msg)).toEqual([
'message 1',
'message 2',
'message 3',
'message 4',
'message 5',
])
})
test('batch trims to target when max is exceeded', () => {
const appender = new BufferLogAppender(LogLevel.ALL, {
target: 3,
max: 5,
})
for (let i = 1; i <= 6; i++) {
appender.appendMessage(makeMsg({ msg: `message ${i}` }))
}
expect(appender.buffer.map((message) => message.msg)).toEqual([
'message 4',
'message 5',
'message 6',
])
})
test('reassigns buffer array when trimming', () => {
const appender = new BufferLogAppender(LogLevel.ALL, {
target: 2,
max: 3,
})
appender.appendMessage(makeMsg({ msg: 'first' }))
appender.appendMessage(makeMsg({ msg: 'second' }))
appender.appendMessage(makeMsg({ msg: 'third' }))
const existing = appender.buffer
appender.appendMessage(makeMsg({ msg: 'fourth' }))
expect(appender.buffer).not.toBe(existing)
expect(appender.buffer.map((message) => message.msg)).toEqual([
'third',
'fourth',
])
})
test('repairs invalid internal bounds before trimming', () => {
const appender = new BufferLogAppender(LogLevel.ALL, {
target: 3,
max: 4,
})
const unsafeAppender = appender as unknown as {
_size: { target: number, max: number }
}
unsafeAppender._size = { target: 3, max: 2 }
appender.appendMessage(makeMsg({ msg: 'first' }))
expect(appender.size).toEqual({ target: 3, max: 4 })
expect(appender.buffer.length).toBe(2)
expect(appender.buffer[1].level).toBe(LogLevel.ERROR)
expect(appender.buffer[1].msg).toMatchObject({
oldSize: { target: 3, max: 2 },
newSize: { target: 3, max: 4 },
})
})
})
})
-59
View File
@@ -1,59 +0,0 @@
import { describe, expect, test } from 'bun:test'
import { clamp } from '../src/util'
describe('utils', () => {
describe('clamp', () => {
test("doesn't alter integers within the given range", () => {
expect(clamp(5, { min: 0, max: 10 })).toBe(5)
expect(clamp(-8, { min: -100, max: 100 })).toBe(-8)
})
test('keeps values at the range boundaries', () => {
expect(clamp(0, { min: 0, max: 10 })).toBe(0)
expect(clamp(10, { min: 0, max: 10 })).toBe(10)
expect(clamp(-100, { min: -100, max: 100 })).toBe(-100)
expect(clamp(100, { min: -100, max: 100 })).toBe(100)
})
test('raises values below the minimum', () => {
expect(clamp(-1, { min: 0, max: 10 })).toBe(0)
expect(clamp(-101, { min: -100, max: 100 })).toBe(-100)
})
test('lowers values above the maximum', () => {
expect(clamp(11, { min: 0, max: 10 })).toBe(10)
expect(clamp(101, { min: -100, max: 100 })).toBe(100)
})
test('floors floats by default', () => {
expect(clamp(1.5, { min: 0, max: 2 })).toBe(1)
expect(clamp(1.999, { min: 0, max: 2 })).toBe(1)
expect(clamp(-0.3, { min: -2, max: 2 })).toBe(-1)
expect(clamp(-1.1, { min: -2, max: 2 })).toBe(-2)
})
test('floors before applying bounds', () => {
expect(clamp(10.5, { min: 0, max: 10 })).toBe(10)
expect(clamp(-10.5, { min: -10, max: 10 })).toBe(-10)
expect(clamp(1.2, { min: 1.5 })).toBe(1.5)
})
test('preserves floats when allowed', () => {
expect(clamp(1.5, { min: 0, max: 2 }, true)).toBe(1.5)
expect(clamp(2.5, { min: 0, max: 2 }, true)).toBe(2)
expect(clamp(-0.5, { min: 0, max: 2 }, true)).toBe(0)
})
test('supports one-sided bounds', () => {
expect(clamp(-1, { min: 0 })).toBe(0)
expect(clamp(11, { min: 0 })).toBe(11)
expect(clamp(-1, { max: 10 })).toBe(-1)
expect(clamp(11, { max: 10 })).toBe(10)
})
test('clamps infinite values to the range boundaries', () => {
expect(clamp(Infinity, { min: 0, max: 10 })).toBe(10)
expect(clamp(-Infinity, { min: 0, max: 10 })).toBe(0)
})
})
})
-1
View File
@@ -3,7 +3,6 @@
"module": "commonjs", "module": "commonjs",
"target": "es2016", "target": "es2016",
"declaration": true, "declaration": true,
"rootDir": "./src",
"outDir": "./dist", "outDir": "./dist",
"strict": true, "strict": true,
"skipLibCheck": true "skipLibCheck": true