summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorElizabeth Hunt <me@liz.coffee>2025-12-14 22:39:18 -0800
committerElizabeth Hunt <me@liz.coffee>2025-12-14 22:39:18 -0800
commit666674327f009e9b1013218fc384f193b64c6997 (patch)
treeacebae7b425b469584eb0a5bec396899c2739501
parent594ce452693a71b501d3aff3f35ef3732c06c341 (diff)
downloadpengueno-666674327f009e9b1013218fc384f193b64c6997.tar.gz
pengueno-666674327f009e9b1013218fc384f193b64c6997.zip
Adds unit tests
-rw-r--r--lib/leftpadesque/debug.ts3
-rw-r--r--lib/process/exec.ts4
-rw-r--r--lib/trace/metric/trace.ts11
-rw-r--r--package.json2
-rw-r--r--tst/argv.test.ts55
-rw-r--r--tst/collections_cons_zipper.test.ts58
-rw-r--r--tst/collections_jsonds.test.ts37
-rw-r--r--tst/debug.test.ts43
-rw-r--r--tst/either.test.ts24
-rw-r--r--tst/env.test.ts32
-rw-r--r--tst/exec.test.ts110
-rw-r--r--tst/fourohfour.test.ts38
-rw-r--r--tst/hono_proxy.test.ts90
-rw-r--r--tst/http_status.test.ts9
-rw-r--r--tst/log_trace.test.ts55
-rw-r--r--tst/memoize.test.ts2
-rw-r--r--tst/metric.test.ts53
-rw-r--r--tst/metrics_trace.test.ts125
-rw-r--r--tst/optional.test.ts37
-rw-r--r--tst/prepend.test.ts15
-rw-r--r--tst/pretty_json_console.test.ts40
-rw-r--r--tst/server_filters_activity.test.ts96
-rw-r--r--tst/server_request_response.test.ts110
-rw-r--r--tst/signals.test.ts70
-rw-r--r--tst/tagged_object.test.ts16
-rw-r--r--tst/test_utils.ts30
-rw-r--r--tst/trace_util.test.ts37
-rw-r--r--tst/traceables.test.ts64
-rw-r--r--tst/validate_identifier.test.ts28
29 files changed, 1286 insertions, 8 deletions
diff --git a/lib/leftpadesque/debug.ts b/lib/leftpadesque/debug.ts
index 074e567..f381540 100644
--- a/lib/leftpadesque/debug.ts
+++ b/lib/leftpadesque/debug.ts
@@ -4,5 +4,6 @@ const _env: 'development' | 'production' =
_hasEnv && (process.env.ENVIRONMENT ?? '').toLowerCase().includes('prod') ? 'production' : 'development';
export const isProd = () => _env === 'production';
-const _debug = !isProd() || (_hasEnv && ['y', 't'].some((process.env.DEBUG ?? '').toLowerCase().startsWith));
+const _debugEnv = (process.env.DEBUG ?? '').toLowerCase();
+const _debug = !isProd() || (_hasEnv && ['y', 't'].some((prefix) => _debugEnv.startsWith(prefix)));
export const isDebug = () => _debug;
diff --git a/lib/process/exec.ts b/lib/process/exec.ts
index f8d572c..3a934b3 100644
--- a/lib/process/exec.ts
+++ b/lib/process/exec.ts
@@ -7,7 +7,7 @@ import {
Metric,
TraceUtil,
} from '@emprespresso/pengueno';
-import { exec } from 'node:child_process';
+import * as child_process from 'node:child_process';
export type Command = string[] | string;
export type StdStreams = { stdout: string; stderr: string };
@@ -28,7 +28,7 @@ export const getStdout = (
const env = options.clearEnv ? options.env : { ...process.env, ...options.env };
return Either.fromFailableAsync<Error, StdStreams>(
new Promise<StdStreams>((res, rej) => {
- const proc = exec(_exec, { env });
+ const proc = child_process.exec(_exec, { env });
let stdout = '';
proc.stdout?.on('data', (d: Buffer) => {
const s = d.toString();
diff --git a/lib/trace/metric/trace.ts b/lib/trace/metric/trace.ts
index b28d828..396bd9c 100644
--- a/lib/trace/metric/trace.ts
+++ b/lib/trace/metric/trace.ts
@@ -16,9 +16,16 @@ export class MetricsTrace implements ITrace<MetricsTraceSupplier> {
public traceScope(trace: MetricsTraceSupplier): MetricsTrace {
const now = Date.now();
const metricsToTrace = (Array.isArray(trace) ? trace : [trace]).filter(isIMetric);
- const initialTraces = new Map(metricsToTrace.map((metric) => [metric.name, now]));
- return new MetricsTrace(this.metricConsumer, initialTraces, this.completedTraces);
+ // Inherit existing active traces across scopes.
+ const nextActiveTraces = new Map(this.activeTraces);
+ for (const metric of metricsToTrace) {
+ if (!nextActiveTraces.has(metric.name)) {
+ nextActiveTraces.set(metric.name, now);
+ }
+ }
+
+ return new MetricsTrace(this.metricConsumer, nextActiveTraces, this.completedTraces);
}
public trace(metrics: MetricsTraceSupplier): MetricsTrace {
diff --git a/package.json b/package.json
index b5f4484..462afb8 100644
--- a/package.json
+++ b/package.json
@@ -11,7 +11,7 @@
"author": "emprespresso",
"license": "MIT",
"scripts": {
- "build": "tsc && tsconfig-replace-paths --project tsconfig.json",
+ "build": "npm test && tsc && tsconfig-replace-paths --project tsconfig.json",
"test": "jest",
"dev": "tsc --watch",
"clean": "rm -rf dist node_modules",
diff --git a/tst/argv.test.ts b/tst/argv.test.ts
new file mode 100644
index 0000000..1eae059
--- /dev/null
+++ b/tst/argv.test.ts
@@ -0,0 +1,55 @@
+import { argv, getArg, isArgKey } from '../lib/index';
+
+describe('process/argv', () => {
+ test('isArgKey detects keys', () => {
+ expect(isArgKey('--foo')).toBe(true);
+ expect(isArgKey('foo')).toBe(false);
+ expect(isArgKey('-f')).toBe(false);
+ });
+
+ test('getArg returns absent default when missing', () => {
+ const res = getArg('--foo', ['--bar', 'x'], { absent: 'nope', present: (v: string) => v });
+ expect(res.right().get()).toBe('nope');
+ });
+
+ test('getArg errors when missing and no absent default', () => {
+ const res = getArg('--foo', ['--bar', 'x'], { present: (v: string) => v });
+ expect(res.left().get().message).toMatch(/arg --foo is not present/);
+ });
+
+ test('getArg reads value from equals form', () => {
+ const res = getArg('--foo', ['--foo=bar'], { present: (v: string) => v });
+ expect(res.right().get()).toBe('bar');
+ });
+
+ test('getArg reads value from next token form', () => {
+ const res = getArg('--foo', ['--foo', 'bar'], { present: (v: string) => v });
+ expect(res.right().get()).toBe('bar');
+ });
+
+ test('getArg uses unspecified when value missing', () => {
+ const res = getArg('--foo', ['--foo', '--bar', 'x'], {
+ unspecified: 'default',
+ present: (v: string) => v,
+ });
+ expect(res.right().get()).toBe('default');
+ });
+
+ test('argv maps multiple args with handlers', () => {
+ const res = argv(
+ ['--port', '--host'] as const,
+ {
+ '--port': {
+ present: (v: string) => parseInt(v, 10),
+ },
+ '--host': {
+ absent: '127.0.0.1',
+ present: (v: string) => v,
+ },
+ },
+ ['--port', '8080'],
+ );
+
+ expect(res.right().get()).toEqual({ '--port': 8080, '--host': '127.0.0.1' });
+ });
+});
diff --git a/tst/collections_cons_zipper.test.ts b/tst/collections_cons_zipper.test.ts
new file mode 100644
index 0000000..3dfe75d
--- /dev/null
+++ b/tst/collections_cons_zipper.test.ts
@@ -0,0 +1,58 @@
+import { Cons, ListZipper } from '../lib/index';
+
+describe('types/collections/cons + zipper', () => {
+ test('Cons.from iterates in order', () => {
+ const list = Cons.from([1, 2, 3]);
+ expect(Array.from(list.get())).toEqual([1, 2, 3]);
+ });
+
+ test('Cons.replace replaces head value', () => {
+ const list = Cons.from([1, 2]).get();
+ expect(Array.from(list.replace(9))).toEqual([9, 2]);
+ });
+
+ test('Cons.before prepends a head chain', () => {
+ const tail = Cons.from([2, 3]);
+ const head = new Cons(1).before(tail);
+ expect(Array.from(head)).toEqual([1, 2, 3]);
+ });
+
+ test('Cons.addOnto appends onto tail optional', () => {
+ const tail = Cons.from([3]);
+ const list = Cons.addOnto([1, 2], tail).get();
+ expect(Array.from(list)).toEqual([1, 2, 3]);
+ });
+
+ test('ListZipper navigation and edits', () => {
+ const zipper = ListZipper.from([1, 2, 3]);
+
+ expect(zipper.read().get()).toBe(1);
+ const z2 = zipper.next().get() as ListZipper<number>;
+ expect(z2.read().get()).toBe(2);
+
+ const z3 = z2.replace(9) as ListZipper<number>;
+ expect(z3.collection()).toEqual([1, 9, 3]);
+
+ const z4 = z3.remove() as ListZipper<number>;
+ expect(z4.collection()).toEqual([1, 3]);
+
+ const z5 = z4.prependChunk([7, 8]) as ListZipper<number>;
+ expect(z5.collection()).toEqual([1, 7, 8, 3]);
+
+ const back = (z2.previous().get() as ListZipper<number>).read().get();
+ expect(back).toBe(1);
+ });
+
+ test('ListZipper iteration yields full list', () => {
+ const zipper = ListZipper.from([1, 2, 3]);
+ expect(Array.from(zipper)).toEqual([1, 2, 3]);
+
+ const moved = zipper.next().flatMap((z: any) => z.next());
+ expect(moved.present()).toBe(true);
+ expect(Array.from(moved.get())).toEqual([1, 2, 3]);
+
+ const empty = ListZipper.from<number>([]);
+ expect(empty.read().present()).toBe(false);
+ expect(Array.from(empty)).toEqual([]);
+ });
+});
diff --git a/tst/collections_jsonds.test.ts b/tst/collections_jsonds.test.ts
new file mode 100644
index 0000000..17b23a6
--- /dev/null
+++ b/tst/collections_jsonds.test.ts
@@ -0,0 +1,37 @@
+import { JSONHashMap, JSONSet } from '../lib/index';
+
+describe('types/collections/jsonds', () => {
+ test('JSONSet uses stable JSON identity', () => {
+ const set = new JSONSet<{ a: number; b: number }>();
+ set.add({ a: 1, b: 2 });
+
+ expect(set.has({ b: 2, a: 1 })).toBe(true);
+ expect(set.size()).toBe(1);
+
+ expect(set.delete({ b: 2, a: 1 })).toBe(true);
+ expect(set.has({ a: 1, b: 2 })).toBe(false);
+
+ set.add({ a: 1, b: 2 });
+ set.clear();
+ expect(set.size()).toBe(0);
+ });
+
+ test('JSONHashMap stable keying and keys()', () => {
+ const map = new JSONHashMap<{ a: number; b: number }, { v: string }>();
+
+ map.set({ a: 1, b: 2 }, { v: 'x' });
+ expect(map.get({ b: 2, a: 1 })).toEqual({ v: 'x' });
+ expect(map.has({ b: 2, a: 1 })).toBe(true);
+ expect(map.size()).toBe(1);
+
+ const keys = map.keys();
+ expect(keys).toEqual([{ a: 1, b: 2 }]);
+
+ expect(map.delete({ b: 2, a: 1 })).toBe(true);
+ expect(map.size()).toBe(0);
+
+ map.set({ a: 1, b: 2 }, { v: 'x' });
+ map.clear();
+ expect(map.size()).toBe(0);
+ });
+});
diff --git a/tst/debug.test.ts b/tst/debug.test.ts
new file mode 100644
index 0000000..c4274c9
--- /dev/null
+++ b/tst/debug.test.ts
@@ -0,0 +1,43 @@
+describe('leftpadesque/debug', () => {
+ const originalEnv = process.env;
+
+ beforeEach(() => {
+ process.env = { ...originalEnv };
+ });
+
+ afterAll(() => {
+ process.env = originalEnv;
+ });
+
+ const load = async () => {
+ jest.resetModules();
+ return await import('../lib/leftpadesque/debug');
+ };
+
+ test('prod env disables debug by default', async () => {
+ process.env.ENVIRONMENT = 'prod';
+ delete process.env.DEBUG;
+
+ const dbg = await load();
+ expect(dbg.isProd()).toBe(true);
+ expect(dbg.isDebug()).toBe(false);
+ });
+
+ test('DEBUG=y enables debug even in prod', async () => {
+ process.env.ENVIRONMENT = 'production';
+ process.env.DEBUG = 'y';
+
+ const dbg = await load();
+ expect(dbg.isProd()).toBe(true);
+ expect(dbg.isDebug()).toBe(true);
+ });
+
+ test('dev env always enables debug', async () => {
+ delete process.env.ENVIRONMENT;
+ delete process.env.DEBUG;
+
+ const dbg = await load();
+ expect(dbg.isProd()).toBe(false);
+ expect(dbg.isDebug()).toBe(true);
+ });
+});
diff --git a/tst/either.test.ts b/tst/either.test.ts
index d87bcab..6f37508 100644
--- a/tst/either.test.ts
+++ b/tst/either.test.ts
@@ -1,4 +1,26 @@
-import { Either } from '@emprespresso/pengueno';
+import { Either } from '../lib/index';
+
+describe('Either.basic', () => {
+ test('mapRight/mapLeft/swap', () => {
+ const r = Either.right<string, number>(2)
+ .mapRight((n) => n + 1)
+ .mapLeft((e) => e.toUpperCase());
+
+ expect(r.right().get()).toBe(3);
+ expect(r.swap().left().get()).toBe(3);
+
+ const l = Either.left<string, number>('nope').mapRight((n) => n + 1);
+ expect(l.left().get()).toBe('nope');
+ expect(l.swap().right().get()).toBe('nope');
+ });
+
+ test('joinRight combines rights', () => {
+ const a = Either.right<string, number>(2);
+ const b = Either.right<string, number>(3);
+ const res = a.joinRight(b, (x, y) => x + y);
+ expect(res.right().get()).toBe(5);
+ });
+});
describe('Either.retrying', () => {
beforeEach(() => {
diff --git a/tst/env.test.ts b/tst/env.test.ts
new file mode 100644
index 0000000..0fd7f5a
--- /dev/null
+++ b/tst/env.test.ts
@@ -0,0 +1,32 @@
+import { getEnv, getRequiredEnv, getRequiredEnvVars } from '../lib/index';
+
+describe('process/env', () => {
+ const originalEnv = process.env;
+
+ beforeEach(() => {
+ process.env = { ...originalEnv };
+ });
+
+ afterAll(() => {
+ process.env = originalEnv;
+ });
+
+ test('getEnv returns optional when present', () => {
+ process.env.MY_VAR = 'hello';
+ expect(getEnv('MY_VAR').get()).toBe('hello');
+ });
+
+ test('getRequiredEnv returns left when missing', () => {
+ delete process.env.MISSING;
+ const res = getRequiredEnv('MISSING');
+ expect(res.left().get().message).toMatch(/environment variable "MISSING" is required/);
+ });
+
+ test('getRequiredEnvVars collects required variables', () => {
+ process.env.A = '1';
+ process.env.B = '2';
+
+ const res = getRequiredEnvVars(['A', 'B'] as const);
+ expect(res.right().get()).toEqual({ A: '1', B: '2' });
+ });
+});
diff --git a/tst/exec.test.ts b/tst/exec.test.ts
new file mode 100644
index 0000000..1022ec7
--- /dev/null
+++ b/tst/exec.test.ts
@@ -0,0 +1,110 @@
+import { EventEmitter } from 'node:events';
+import type { Command } from '../lib/index';
+import { CollectingTrace, TestTraceable } from './test_utils';
+
+jest.mock('node:child_process', () => ({
+ exec: jest.fn(),
+}));
+
+const getExecMock = async () => {
+ const mod = await import('node:child_process');
+ return (mod as any).exec as jest.Mock;
+};
+
+describe('process/exec (getStdout/getStdoutMany)', () => {
+ let execMock: jest.Mock;
+
+ beforeEach(async () => {
+ jest.resetModules();
+ execMock = await getExecMock();
+ execMock.mockReset();
+ });
+
+ test('getStdout executes command and returns stdout', async () => {
+ const { getStdout } = await import('../lib/index');
+
+ const proc: any = new EventEmitter();
+ proc.stdout = new EventEmitter();
+ proc.stderr = new EventEmitter();
+
+ execMock.mockReturnValue(proc);
+
+ const trace = new CollectingTrace<any>();
+ const cmd = TestTraceable.of<Command, any>(['echo', 'hi'], trace);
+
+ const p = getStdout(cmd, { env: { FOO: 'bar' }, streamTraceable: ['stdout'] });
+
+ proc.stdout.emit('data', Buffer.from('hello'));
+ proc.emit('exit', 0);
+
+ const res = await p;
+ expect(res.right().get()).toBe('hello');
+
+ expect(execMock).toHaveBeenCalledTimes(1);
+ expect(execMock.mock.calls[0][0]).toBe('echo hi');
+ expect(execMock.mock.calls[0][1]).toEqual(
+ expect.objectContaining({ env: expect.objectContaining({ FOO: 'bar' }) }),
+ );
+
+ const flattened = trace.events.flatMap((e) => e);
+ expect(flattened).toEqual(expect.arrayContaining(['hello']));
+ });
+
+ test('getStdout returns left on non-zero exit', async () => {
+ const { getStdout } = await import('../lib/index');
+
+ const proc: any = new EventEmitter();
+ proc.stdout = new EventEmitter();
+ proc.stderr = new EventEmitter();
+ execMock.mockReturnValue(proc);
+
+ const cmd = TestTraceable.of<Command, any>('false', new CollectingTrace<any>());
+ const p = getStdout(cmd);
+
+ proc.emit('exit', 2);
+ const res = await p;
+ expect(res.left().present()).toBe(true);
+ expect(res.left().get().message).toMatch(/non-zero/);
+ });
+
+ test('getStdoutMany runs commands sequentially', async () => {
+ const { getStdoutMany } = await import('../lib/index');
+
+ const makeProc = (out: string) => {
+ const proc: any = new EventEmitter();
+ proc.stdout = new EventEmitter();
+ proc.stderr = new EventEmitter();
+ return proc;
+ };
+
+ execMock
+ .mockImplementationOnce(() => {
+ const proc = makeProc('a');
+ queueMicrotask(() => {
+ proc.stdout.emit('data', Buffer.from('a'));
+ proc.emit('exit', 0);
+ });
+ return proc;
+ })
+ .mockImplementationOnce(() => {
+ const proc = makeProc('b');
+ queueMicrotask(() => {
+ proc.stdout.emit('data', Buffer.from('b'));
+ proc.emit('exit', 0);
+ });
+ return proc;
+ });
+
+ const cmds = TestTraceable.of<Array<Command>, any>(
+ [
+ ['echo', 'a'],
+ ['echo', 'b'],
+ ],
+ new CollectingTrace<any>(),
+ );
+
+ const res = await getStdoutMany(cmds);
+ expect(res.right().get()).toEqual(['a', 'b']);
+ expect(execMock).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/tst/fourohfour.test.ts b/tst/fourohfour.test.ts
new file mode 100644
index 0000000..c8fdd6f
--- /dev/null
+++ b/tst/fourohfour.test.ts
@@ -0,0 +1,38 @@
+import { FourOhFourActivityImpl, PenguenoRequest, type BaseRequest, type ServerTrace } from '../lib/index';
+import { CollectingTrace, TestTraceable } from './test_utils';
+
+const makeBaseRequest = (overrides: Partial<BaseRequest> = {}): BaseRequest => ({
+ url: 'https://example.com/missing',
+ method: 'GET',
+ header: () => ({}),
+ formData: async () => new FormData(),
+ json: async () => ({}),
+ text: async () => '',
+ param: () => undefined,
+ query: () => ({}),
+ queries: () => ({}),
+ ...overrides,
+});
+
+describe('server/activity/fourohfour (FourOhFourActivityImpl)', () => {
+ beforeEach(() => {
+ jest.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue('00000000-0000-0000-0000-000000000000');
+ jest.spyOn(Math, 'random').mockReturnValue(0);
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ test('returns JsonResponse with 404', async () => {
+ const trace = new CollectingTrace<ServerTrace>();
+ const req = PenguenoRequest.from(TestTraceable.of(makeBaseRequest(), trace));
+
+ const activity = new FourOhFourActivityImpl();
+ const resp = await activity.fourOhFour(req);
+
+ expect(resp.status).toBe(404);
+ expect(resp.headers['Content-Type']).toBe('application/json; charset=utf-8');
+ expect(resp.body()).toContain('"error"');
+ });
+});
diff --git a/tst/hono_proxy.test.ts b/tst/hono_proxy.test.ts
new file mode 100644
index 0000000..56ed5d9
--- /dev/null
+++ b/tst/hono_proxy.test.ts
@@ -0,0 +1,90 @@
+import type { BaseRequest } from '../lib/index';
+
+type HonoHandler = (c: { req: BaseRequest }) => Promise<Response>;
+
+const makeBaseRequest = (overrides: Partial<BaseRequest> = {}): BaseRequest => ({
+ url: 'https://example.com/hello',
+ method: 'GET',
+ header: () => ({}),
+ formData: async () => new FormData(),
+ json: async () => ({ ok: true }),
+ text: async () => 'hi',
+ param: () => undefined,
+ query: () => ({}),
+ queries: () => ({}),
+ ...overrides,
+});
+
+let routeHandler: HonoHandler | undefined;
+const allMock = jest.fn((_path: string, handler: HonoHandler) => {
+ routeHandler = handler;
+});
+
+class Hono {
+ public all = allMock;
+ public async fetch(_r: Request) {
+ if (!routeHandler) throw new Error('route handler not registered');
+ return await routeHandler({ req: makeBaseRequest() });
+ }
+}
+
+const serveMock = jest.fn();
+
+jest.mock('hono', () => ({ Hono }));
+jest.mock('@hono/node-server', () => ({ serve: serveMock }));
+
+describe('server/hono/proxy (HonoProxy)', () => {
+ beforeEach(() => {
+ jest.resetModules();
+ serveMock.mockReset();
+ allMock.mockClear();
+ routeHandler = undefined;
+
+ jest.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue('00000000-0000-0000-0000-000000000000');
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ test('wires Server.serve into hono and returns right', async () => {
+ const log = jest.spyOn(console, 'log').mockImplementation(() => undefined);
+ const err = jest.spyOn(console, 'error').mockImplementation(() => undefined);
+
+ const handlers: Record<string, () => void> = {};
+ jest.spyOn(process, 'on').mockImplementation(((evt: string, cb: () => void) => {
+ handlers[evt] = cb;
+ return process;
+ }) as any);
+
+ serveMock.mockImplementation((opts: any) => {
+ queueMicrotask(() => opts.fetch(new Request('http://x')));
+ return {
+ close: (cb: (err?: Error) => void) => cb(undefined),
+ };
+ });
+
+ const { HonoProxy, PenguenoResponse } = await import('../lib/index');
+
+ const server = {
+ serve: jest.fn(async (req: any) => new PenguenoResponse(req, 'ok', { status: 200, headers: {} })),
+ };
+
+ const proxy = new HonoProxy(server as any);
+ const p = proxy.serve(3001, '127.0.0.1');
+
+ // allow awaitClose to register handlers
+ await new Promise((r) => setImmediate(r));
+ handlers.SIGINT();
+
+ const res = await p;
+ expect(res.left().present()).toBe(false);
+
+ expect(serveMock).toHaveBeenCalledWith(expect.objectContaining({ port: 3001, hostname: '127.0.0.1' }));
+ expect(allMock).toHaveBeenCalledWith('*', expect.any(Function));
+ expect(server.serve).toHaveBeenCalledTimes(1);
+
+ log.mockRestore();
+ err.mockRestore();
+ });
+});
diff --git a/tst/http_status.test.ts b/tst/http_status.test.ts
new file mode 100644
index 0000000..09d6790
--- /dev/null
+++ b/tst/http_status.test.ts
@@ -0,0 +1,9 @@
+import { HttpStatusCodes } from '../lib/index';
+
+describe('server/http/status', () => {
+ test('contains common status codes', () => {
+ expect(HttpStatusCodes[200]).toBe('OK');
+ expect(HttpStatusCodes[404]).toBe('Not Found');
+ expect(HttpStatusCodes[500]).toBe('Internal Server Error');
+ });
+});
diff --git a/tst/log_trace.test.ts b/tst/log_trace.test.ts
new file mode 100644
index 0000000..29234f6
--- /dev/null
+++ b/tst/log_trace.test.ts
@@ -0,0 +1,55 @@
+import { LogLevel, LogTrace, type ILogger } from '../lib/index';
+
+describe('trace/log/trace (LogTrace)', () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ jest.setSystemTime(new Date('2020-01-01T00:00:00.000Z'));
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ test('uses defaultLevel when no explicit level', () => {
+ const logger: ILogger = { log: jest.fn() };
+ const allowed = () => new Set([LogLevel.UNKNOWN, LogLevel.WARN]);
+ const trace = new LogTrace(logger, [], LogLevel.WARN, allowed);
+
+ trace.trace('hello');
+ expect(logger.log).toHaveBeenCalledTimes(1);
+
+ const [level, ...rest] = (logger.log as jest.Mock).mock.calls[0];
+ expect(level).toBe(LogLevel.WARN);
+ expect(rest.join(' ')).toContain('hello');
+ });
+
+ test('picks highest log level across traces', () => {
+ const logger: ILogger = { log: jest.fn() };
+ const allowed = () => new Set([LogLevel.UNKNOWN, LogLevel.INFO, LogLevel.WARN, LogLevel.ERROR]);
+ const trace = new LogTrace(logger, [], LogLevel.INFO, allowed);
+
+ trace.traceScope(LogLevel.INFO).traceScope(LogLevel.ERROR).trace('boom');
+ const [level] = (logger.log as jest.Mock).mock.calls[0];
+ expect(level).toBe(LogLevel.ERROR);
+ });
+
+ test('filters disallowed levels', () => {
+ const logger: ILogger = { log: jest.fn() };
+ const allowed = () => new Set([LogLevel.UNKNOWN, LogLevel.INFO]);
+ const trace = new LogTrace(logger, [], LogLevel.INFO, allowed);
+
+ trace.traceScope(LogLevel.DEBUG).trace('nope');
+ expect(logger.log).not.toHaveBeenCalled();
+ });
+
+ test('formats Error objects in trace', () => {
+ const logger: ILogger = { log: jest.fn() };
+ const allowed = () => new Set([LogLevel.UNKNOWN, LogLevel.ERROR]);
+ const trace = new LogTrace(logger, [], LogLevel.INFO, allowed);
+
+ trace.traceScope(LogLevel.ERROR).trace(new Error('boom'));
+ const [_level, msg] = (logger.log as jest.Mock).mock.calls[0];
+ expect(msg).toContain('TracedException.Name = Error');
+ expect(msg).toContain('TracedException.Message = boom');
+ });
+});
diff --git a/tst/memoize.test.ts b/tst/memoize.test.ts
index e3d9050..184fc67 100644
--- a/tst/memoize.test.ts
+++ b/tst/memoize.test.ts
@@ -1,4 +1,4 @@
-import { memoize } from '@emprespresso/pengueno';
+import { memoize } from '../lib/index';
interface RefCounter {
inc(by: number): number;
diff --git a/tst/metric.test.ts b/tst/metric.test.ts
new file mode 100644
index 0000000..b55be40
--- /dev/null
+++ b/tst/metric.test.ts
@@ -0,0 +1,53 @@
+import { EmittableMetric, Metric, MetricValueTag, ResultMetric, Unit, isIMetric, isMetricValue } from '../lib/index';
+
+describe('trace/metric', () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ jest.setSystemTime(new Date('2020-01-01T00:00:00.000Z'));
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ test('EmittableMetric.withValue creates MetricValue', () => {
+ const m = new EmittableMetric('x', Unit.COUNT);
+ const v = m.withValue(2.5);
+ expect(isMetricValue(v)).toBe(true);
+ expect(v).toEqual({
+ _tag: MetricValueTag,
+ name: 'x',
+ unit: Unit.COUNT,
+ value: 2.5,
+ emissionTimestamp: 1577836800000,
+ });
+ });
+
+ test('Metric.join/fromName/child set names and parents', () => {
+ expect(Metric.join('a', 'b', 'c')).toBe('a.b.c');
+
+ const root = Metric.fromName('root');
+ expect(isIMetric(root)).toBe(true);
+ expect(root.count.name).toBe('root.count');
+ expect(root.time.name).toBe('root.time');
+ expect(root.parent).toBeUndefined();
+
+ const child = root.child('leaf');
+ expect(child.name).toBe('root.leaf');
+ expect(child.parent).toBe(root);
+ });
+
+ test('ResultMetric.from creates failure/success/warn children', () => {
+ const metric = Metric.fromName('work');
+ const result = ResultMetric.from(metric);
+
+ expect(result.name).toBe('work');
+ expect(result.failure.name).toBe('work.failure');
+ expect(result.success.name).toBe('work.success');
+ expect(result.warn.name).toBe('work.warn');
+
+ expect(result.failure.parent!.name).toBe('work');
+ expect(result.success.parent!.name).toBe('work');
+ expect(result.warn.parent!.name).toBe('work');
+ });
+});
diff --git a/tst/metrics_trace.test.ts b/tst/metrics_trace.test.ts
new file mode 100644
index 0000000..23d5147
--- /dev/null
+++ b/tst/metrics_trace.test.ts
@@ -0,0 +1,125 @@
+import { Metric, MetricsTrace, MetricValueTag, isMetricsTraceSupplier, type MetricValue } from '../lib/index';
+
+describe('trace/metric/trace (MetricsTrace)', () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ jest.setSystemTime(new Date('2020-01-01T00:00:00.000Z'));
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ test('isMetricsTraceSupplier accepts metrics and values', () => {
+ const m = Metric.fromName('m');
+ const v = m.count.withValue(1);
+
+ expect(isMetricsTraceSupplier(m)).toBe(true);
+ expect(isMetricsTraceSupplier(v)).toBe(true);
+ expect(isMetricsTraceSupplier([m, v])).toBe(true);
+ expect(isMetricsTraceSupplier('nope')).toBe(false);
+ expect(isMetricsTraceSupplier(undefined)).toBe(false);
+ });
+
+ test('traceScope + trace ends a metric and emits count/time', () => {
+ const emitted: MetricValue[] = [];
+ const consumer = (vals: MetricValue[]) => emitted.push(...vals);
+
+ const metric = Metric.fromName('A');
+ const t0 = new MetricsTrace(consumer).traceScope(metric);
+
+ jest.setSystemTime(new Date('2020-01-01T00:00:00.100Z'));
+ t0.trace(metric);
+
+ expect(emitted).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ _tag: MetricValueTag, name: 'A.count', value: 1 }),
+ expect.objectContaining({ _tag: MetricValueTag, name: 'A.time', value: 100 }),
+ ]),
+ );
+ });
+
+ test('trace does not emit when given string/undefined', () => {
+ const emitted: MetricValue[] = [];
+ const consumer = (vals: MetricValue[]) => emitted.push(...vals);
+ const t = new MetricsTrace(consumer);
+
+ t.trace(undefined);
+ t.trace('hello');
+ expect(emitted).toEqual([]);
+ });
+
+ test('parent-based metric emits relative to parent start', () => {
+ const emitted: MetricValue[] = [];
+ const consumer = (vals: MetricValue[]) => emitted.push(...vals);
+
+ const parent = Metric.fromName('parent');
+ const child = parent.child('child');
+
+ const t0 = new MetricsTrace(consumer).traceScope(parent);
+
+ jest.setSystemTime(new Date('2020-01-01T00:00:00.050Z'));
+ const t1 = t0.trace(child);
+
+ expect(emitted).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ name: 'parent.child.count', value: 1 }),
+ expect.objectContaining({ name: 'parent.child.time', value: 50 }),
+ ]),
+ );
+
+ // end parent normally
+ jest.setSystemTime(new Date('2020-01-01T00:00:00.080Z'));
+ t1.trace(parent);
+ expect(emitted).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ name: 'parent.count', value: 1 }),
+ expect.objectContaining({ name: 'parent.time', value: 80 }),
+ ]),
+ );
+
+ // child should be considered completed already
+ const before = emitted.length;
+ jest.setSystemTime(new Date('2020-01-01T00:00:00.100Z'));
+ t1.trace(child);
+ expect(emitted.length).toBe(before);
+ });
+
+ test('nested scope can end parent metric via result child', () => {
+ const emitted: MetricValue[] = [];
+ const consumer = (vals: MetricValue[]) => emitted.push(...vals);
+
+ const result = Metric.fromName('job').asResult();
+
+ const root = new MetricsTrace(consumer).traceScope(result);
+
+ jest.setSystemTime(new Date('2020-01-01T00:00:00.100Z'));
+ const child = root.traceScope('child-scope');
+ const childAfterSuccess = child.trace(result.success);
+
+ expect(emitted).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ name: 'job.success.count', value: 1 }),
+ expect.objectContaining({ name: 'job.success.time', value: 100 }),
+ ]),
+ );
+
+ const before = emitted.length;
+ jest.setSystemTime(new Date('2020-01-01T00:00:00.200Z'));
+ childAfterSuccess.trace(result.success);
+ expect(emitted.length).toBe(before);
+ });
+
+ test('trace forwards MetricValue emissions unchanged', () => {
+ const emitted: MetricValue[] = [];
+ const consumer = (vals: MetricValue[]) => emitted.push(...vals);
+
+ const metric = Metric.fromName('X');
+ const value = metric.count.withValue(7);
+
+ const t = new MetricsTrace(consumer);
+ t.trace(value);
+
+ expect(emitted).toEqual([value]);
+ });
+});
diff --git a/tst/optional.test.ts b/tst/optional.test.ts
new file mode 100644
index 0000000..ece6b31
--- /dev/null
+++ b/tst/optional.test.ts
@@ -0,0 +1,37 @@
+import { IOptionalEmptyError, Optional, isOptional } from '../lib/index';
+
+describe('types/fn/optional', () => {
+ test('from creates some/none', () => {
+ expect(Optional.from('x').get()).toBe('x');
+ expect(Optional.from(null).present()).toBe(false);
+ expect(Optional.from(undefined).present()).toBe(false);
+ });
+
+ test('get throws on none', () => {
+ expect(() => Optional.none().get()).toThrow(IOptionalEmptyError);
+ });
+
+ test('map/filter/flatMap work', () => {
+ const res = Optional.from(2)
+ .map((n: number) => n * 2)
+ .filter((n: number) => n > 3)
+ .flatMap((n: number) => Optional.from(n.toString()));
+
+ expect(res.get()).toBe('4');
+ });
+
+ test('orSome supplies fallback', () => {
+ const res = Optional.none<number>().orSome(() => 5);
+ expect(res.get()).toBe(5);
+ });
+
+ test('iterator yields only when present', () => {
+ expect(Array.from(Optional.some(1))).toEqual([1]);
+ expect(Array.from(Optional.none<number>())).toEqual([]);
+ });
+
+ test('isOptional detects tagged values', () => {
+ expect(isOptional(Optional.some('x'))).toBe(true);
+ expect(isOptional({})).toBe(false);
+ });
+});
diff --git a/tst/prepend.test.ts b/tst/prepend.test.ts
new file mode 100644
index 0000000..19a9b9a
--- /dev/null
+++ b/tst/prepend.test.ts
@@ -0,0 +1,15 @@
+import { prependWith } from '../lib/index';
+
+describe('leftpadesque/prepend', () => {
+ test('prepends between elements', () => {
+ expect(prependWith(['a', 'b'], '--')).toEqual(['--', 'a', '--', 'b']);
+ });
+
+ test('handles single element', () => {
+ expect(prependWith(['a'], '--')).toEqual(['--', 'a']);
+ });
+
+ test('handles empty input', () => {
+ expect(prependWith([], '--')).toEqual([]);
+ });
+});
diff --git a/tst/pretty_json_console.test.ts b/tst/pretty_json_console.test.ts
new file mode 100644
index 0000000..f4043ad
--- /dev/null
+++ b/tst/pretty_json_console.test.ts
@@ -0,0 +1,40 @@
+import { ANSI, LogLevel, PrettyJsonConsoleLogger } from '../lib/index';
+
+describe('trace/log/pretty_json_console (PrettyJsonConsoleLogger)', () => {
+ test('logs to console.log for non-error', () => {
+ const log = jest.spyOn(console, 'log').mockImplementation(() => undefined);
+ const err = jest.spyOn(console, 'error').mockImplementation(() => undefined);
+
+ const logger = new PrettyJsonConsoleLogger();
+ logger.log(LogLevel.INFO, 'hello');
+
+ expect(log).toHaveBeenCalledTimes(1);
+ expect(err).not.toHaveBeenCalled();
+
+ const payload = log.mock.calls[0][0] as string;
+ expect(payload).toContain('"level": "INFO"');
+ expect(payload).toContain('"trace":');
+ expect(payload).toContain(ANSI.RESET);
+
+ log.mockRestore();
+ err.mockRestore();
+ });
+
+ test('logs to console.error for ERROR', () => {
+ const log = jest.spyOn(console, 'log').mockImplementation(() => undefined);
+ const err = jest.spyOn(console, 'error').mockImplementation(() => undefined);
+
+ const logger = new PrettyJsonConsoleLogger();
+ logger.log(LogLevel.ERROR, 'boom');
+
+ expect(err).toHaveBeenCalledTimes(1);
+ expect(log).not.toHaveBeenCalled();
+
+ const payload = err.mock.calls[0][0] as string;
+ expect(payload.startsWith(ANSI.BRIGHT_RED)).toBe(true);
+ expect(payload.endsWith(`${ANSI.RESET}\n`)).toBe(true);
+
+ log.mockRestore();
+ err.mockRestore();
+ });
+});
diff --git a/tst/server_filters_activity.test.ts b/tst/server_filters_activity.test.ts
new file mode 100644
index 0000000..a992e10
--- /dev/null
+++ b/tst/server_filters_activity.test.ts
@@ -0,0 +1,96 @@
+import {
+ Either,
+ HealthCheckActivityImpl,
+ HealthCheckOutput,
+ PenguenoError,
+ PenguenoRequest,
+ jsonModel,
+ requireMethod,
+ type BaseRequest,
+ type ServerTrace,
+} from '../lib/index';
+import { CollectingTrace, TestTraceable } from './test_utils';
+
+const makeBaseRequest = (overrides: Partial<BaseRequest> = {}): BaseRequest => ({
+ url: 'https://example.com/hello',
+ method: 'GET',
+ header: () => ({}),
+ formData: async () => new FormData(),
+ json: async () => ({}),
+ text: async () => '',
+ param: () => undefined,
+ query: () => ({}),
+ queries: () => ({}),
+ ...overrides,
+});
+
+describe('server filters + activities', () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ jest.setSystemTime(new Date('2020-01-01T00:00:00.000Z'));
+ jest.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue('00000000-0000-0000-0000-000000000000');
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ jest.restoreAllMocks();
+ });
+
+ test('requireMethod rejects unallowed methods', () => {
+ const trace = new CollectingTrace<ServerTrace>();
+ const req = PenguenoRequest.from(TestTraceable.of(makeBaseRequest({ method: 'GET' }), trace));
+
+ const res = requireMethod(['POST'])(req);
+ const err = res.left().get();
+
+ expect(err).toBeInstanceOf(PenguenoError);
+ expect(err.status).toBe(405);
+ });
+
+ test('jsonModel transforms valid JSON', async () => {
+ const trace = new CollectingTrace<ServerTrace>();
+ const req = PenguenoRequest.from(
+ TestTraceable.of(
+ makeBaseRequest({
+ json: async () => ({ hello: 'world' }),
+ }),
+ trace,
+ ),
+ );
+
+ const filter = jsonModel((json) => {
+ const body = json.get() as { hello?: string };
+ return body.hello === 'world' ? Either.right('ok') : Either.left(new PenguenoError('bad json', 400));
+ });
+
+ const res = await filter(req);
+ expect(res.right().get()).toBe('ok');
+ });
+
+ test('jsonModel returns 400 for invalid JSON', async () => {
+ const trace = new CollectingTrace<ServerTrace>();
+ const req = PenguenoRequest.from(
+ TestTraceable.of(
+ makeBaseRequest({
+ json: async () => Promise.reject(new Error('nope')),
+ }),
+ trace,
+ ),
+ );
+
+ const filter = jsonModel((_json) => Either.right('never'));
+ const res = await filter(req);
+ expect(res.left().get().status).toBe(400);
+ });
+
+ test('HealthCheckActivityImpl returns 200 on success', async () => {
+ const trace = new CollectingTrace<ServerTrace>();
+ const req = PenguenoRequest.from(TestTraceable.of(makeBaseRequest(), trace));
+
+ const activity = new HealthCheckActivityImpl(async () => Either.right(HealthCheckOutput.YAASSSLAYQUEEN));
+ const res = await activity.checkHealth(req);
+
+ expect(res.status).toBe(200);
+ expect(res.body()).toBe('{"ok":"ok"}');
+ });
+});
diff --git a/tst/server_request_response.test.ts b/tst/server_request_response.test.ts
new file mode 100644
index 0000000..99e74d8
--- /dev/null
+++ b/tst/server_request_response.test.ts
@@ -0,0 +1,110 @@
+import {
+ Either,
+ JsonResponse,
+ PenguenoRequest,
+ PenguenoResponse,
+ getResponseMetrics,
+ MetricValueTag,
+ type BaseRequest,
+ type ServerTrace,
+} from '../lib/index';
+import { CollectingTrace, TestTraceable } from './test_utils';
+
+const makeBaseRequest = (overrides: Partial<BaseRequest> = {}): BaseRequest => ({
+ url: 'https://example.com/hello?x=1',
+ method: 'GET',
+ header: () => ({}),
+ formData: async () => new FormData(),
+ json: async () => ({}),
+ text: async () => '',
+ param: () => undefined,
+ query: () => ({}),
+ queries: () => ({}),
+ ...overrides,
+});
+
+describe('server/request + server/response', () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ jest.setSystemTime(new Date('2020-01-01T00:00:00.000Z'));
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ jest.restoreAllMocks();
+ });
+
+ test('PenguenoRequest.from creates response headers', () => {
+ jest.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue('00000000-0000-0000-0000-000000000000');
+ jest.spyOn(Math, 'random').mockReturnValue(0);
+
+ const baseReq = makeBaseRequest();
+ const trace = new CollectingTrace<ServerTrace>();
+ const req = PenguenoRequest.from(TestTraceable.of(baseReq, trace));
+
+ jest.setSystemTime(new Date('2020-01-01T00:00:01.000Z'));
+ const headers = req.get().getResponseHeaders();
+
+ expect(headers).toMatchObject({
+ RequestId: '00000000-0000-0000-0000-000000000000',
+ Hai: 'hewwo :D',
+ });
+ expect(headers.DeltaUnix).toBe('1000');
+ expect(headers.RequestReceivedUnix).toBe('1577836800000');
+ expect(headers.RequestHandleUnix).toBe('1577836801000');
+ });
+
+ test('PenguenoResponse sets headers and emits metrics', () => {
+ jest.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue('00000000-0000-0000-0000-000000000000');
+ jest.spyOn(Math, 'random').mockReturnValue(0);
+
+ const trace = new CollectingTrace<ServerTrace>();
+ const req = PenguenoRequest.from(TestTraceable.of(makeBaseRequest(), trace));
+
+ jest.setSystemTime(new Date('2020-01-01T00:00:01.000Z'));
+ const res = new PenguenoResponse(req, 'hi', { status: 200, headers: { X: 'Y' } });
+
+ expect(res.status).toBe(200);
+ expect(res.statusText).toBe('OK');
+ expect(res.headers['Content-Type']).toBe('text/plain; charset=utf-8');
+ expect(res.headers.X).toBe('Y');
+
+ const lastTrace = trace.events[trace.events.length - 1];
+ expect(lastTrace).toBeDefined();
+
+ const metricValues = lastTrace[lastTrace.length - 1] as any[];
+ expect(Array.isArray(metricValues)).toBe(true);
+ expect(
+ metricValues.some((m) => m._tag === MetricValueTag && m.name === 'response.2xx.count' && m.value === 1),
+ ).toBe(true);
+ });
+
+ test('JsonResponse formats Either bodies', () => {
+ jest.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue('00000000-0000-0000-0000-000000000000');
+
+ const trace = new CollectingTrace<ServerTrace>();
+ const req = PenguenoRequest.from(TestTraceable.of(makeBaseRequest(), trace));
+
+ const ok = new JsonResponse(req, Either.right('yay'), { status: 200, headers: {} });
+ expect(ok.headers['Content-Type']).toBe('application/json; charset=utf-8');
+ expect(ok.body()).toBe('{"ok":"yay"}');
+
+ const err = new JsonResponse(req, Either.left('nope'), { status: 400, headers: {} });
+ expect(err.body()).toBe('{"error":"nope"}');
+ });
+
+ test('getResponseMetrics returns one active bucket', () => {
+ jest.setSystemTime(new Date('2020-01-01T00:00:00.000Z'));
+ const metrics = getResponseMetrics(201, 12);
+
+ expect(metrics).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ name: 'response.2xx.count', value: 1 }),
+ expect.objectContaining({ name: 'response.2xx.time', value: 12 }),
+ ]),
+ );
+
+ const inactive = metrics.filter((m) => m.value === 0);
+ expect(inactive).toHaveLength(5);
+ });
+});
diff --git a/tst/signals.test.ts b/tst/signals.test.ts
new file mode 100644
index 0000000..14b4da6
--- /dev/null
+++ b/tst/signals.test.ts
@@ -0,0 +1,70 @@
+import { Either, Signals } from '../lib/index';
+import { CollectingTrace, TestTraceable } from './test_utils';
+
+describe('process/signals (Signals.awaitClose)', () => {
+ const originalOn = process.on;
+
+ afterEach(() => {
+ // restore in case a test fails mid-way
+ (process.on as any) = originalOn;
+ jest.restoreAllMocks();
+ });
+
+ test('resolves right on SIGINT with no error', async () => {
+ const handlers: Record<string, () => void> = {};
+ jest.spyOn(process, 'on').mockImplementation(((evt: string, cb: () => void) => {
+ handlers[evt] = cb;
+ return process;
+ }) as any);
+
+ const close = jest.fn((cb: (err: Error | undefined) => void) => cb(undefined));
+ const trace = new CollectingTrace<any>();
+ const t = TestTraceable.of({ close }, trace);
+
+ const p = Signals.awaitClose(t);
+ handlers.SIGINT();
+
+ const res = await p;
+ expect(res.left().present()).toBe(false);
+ expect(close).toHaveBeenCalledTimes(1);
+
+ const flattened = trace.events.flatMap((e) => e);
+ expect(flattened).toEqual(expect.arrayContaining(['closing', 'finished']));
+ });
+
+ test('resolves left on SIGTERM with error', async () => {
+ const handlers: Record<string, () => void> = {};
+ jest.spyOn(process, 'on').mockImplementation(((evt: string, cb: () => void) => {
+ handlers[evt] = cb;
+ return process;
+ }) as any);
+
+ const close = jest.fn((cb: (err: Error | undefined) => void) => cb(new Error('boom')));
+ const trace = new CollectingTrace<any>();
+ const t = TestTraceable.of({ close }, trace);
+
+ const p = Signals.awaitClose(t);
+ handlers.SIGTERM();
+
+ const res = await p;
+ expect(res.left().get().message).toBe('boom');
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+
+ test('close callback is optional error', async () => {
+ const handlers: Record<string, () => void> = {};
+ jest.spyOn(process, 'on').mockImplementation(((evt: string, cb: () => void) => {
+ handlers[evt] = cb;
+ return process;
+ }) as any);
+
+ const close = jest.fn((cb: (err: Error | undefined) => void) => cb(undefined));
+ const t = TestTraceable.of({ close }, new CollectingTrace<any>());
+
+ const p = Signals.awaitClose(t);
+ handlers.SIGINT();
+
+ const res = await p;
+ expect(res).toEqual(Either.right(undefined));
+ });
+});
diff --git a/tst/tagged_object.test.ts b/tst/tagged_object.test.ts
new file mode 100644
index 0000000..51b6c81
--- /dev/null
+++ b/tst/tagged_object.test.ts
@@ -0,0 +1,16 @@
+import { isObject, isTagged } from '../lib/index';
+
+describe('types/tagged + types/object', () => {
+ test('isObject excludes null/arrays', () => {
+ expect(isObject({})).toBe(true);
+ expect(isObject([])).toBe(false);
+ expect(isObject(null)).toBe(false);
+ expect(isObject('x')).toBe(false);
+ });
+
+ test('isTagged checks _tag field', () => {
+ expect(isTagged({ _tag: 'X' }, 'X')).toBe(true);
+ expect(isTagged({ _tag: 'Y' }, 'X')).toBe(false);
+ expect(isTagged({}, 'X')).toBe(false);
+ });
+});
diff --git a/tst/test_utils.ts b/tst/test_utils.ts
new file mode 100644
index 0000000..5c6237c
--- /dev/null
+++ b/tst/test_utils.ts
@@ -0,0 +1,30 @@
+import { type ITrace, type ITraceWith, TraceableImpl } from '../lib/index';
+
+export class CollectingTrace<TraceWith> implements ITrace<TraceWith> {
+ constructor(
+ private readonly collector: Array<Array<ITraceWith<TraceWith>>> = [],
+ private readonly scopes: Array<ITraceWith<TraceWith>> = [],
+ ) {}
+
+ public get events() {
+ return this.collector;
+ }
+
+ public traceScope(trace: ITraceWith<TraceWith>): ITrace<TraceWith> {
+ return new CollectingTrace(this.collector, this.scopes.concat([trace]));
+ }
+
+ public trace(trace: ITraceWith<TraceWith>) {
+ this.collector.push(this.scopes.concat([trace]));
+ }
+}
+
+export class TestTraceable<T, TraceWith> extends TraceableImpl<T, TraceWith> {
+ constructor(item: T, trace: ITrace<TraceWith>) {
+ super(item, trace);
+ }
+
+ static of<T, TraceWith>(item: T, trace: ITrace<TraceWith>) {
+ return new TestTraceable<T, TraceWith>(item, trace);
+ }
+}
diff --git a/tst/trace_util.test.ts b/tst/trace_util.test.ts
new file mode 100644
index 0000000..d36226d
--- /dev/null
+++ b/tst/trace_util.test.ts
@@ -0,0 +1,37 @@
+import { Either, Metric, TraceUtil } from '../lib/index';
+import { CollectingTrace, TestTraceable } from './test_utils';
+
+describe('trace/util (TraceUtil)', () => {
+ test('withFunctionTrace adds fn scope', () => {
+ const trace = new CollectingTrace<any>();
+ const t = TestTraceable.of(1, trace);
+
+ const out = t.flatMap(TraceUtil.withFunctionTrace(function hello() {}));
+ out.trace.trace('x');
+
+ const flattened = trace.events.flatMap((e) => e);
+ expect(flattened.some((x) => x === 'fn.hello')).toBe(true);
+ });
+
+ test('traceResultingEither emits metric success/failure', () => {
+ const metric = Metric.fromName('Job').asResult();
+ const trace = new CollectingTrace<any>();
+
+ const ok = TestTraceable.of(Either.right<Error, string>('ok'), trace).map(
+ TraceUtil.traceResultingEither(metric),
+ );
+ ok.get();
+
+ const flattened = trace.events.flatMap((e) => e);
+ expect(flattened.some((x: any) => typeof x === 'object' && x?.name === 'Job.success')).toBe(true);
+
+ const trace2 = new CollectingTrace<any>();
+ const bad = TestTraceable.of(Either.left<Error, string>(new Error('nope')), trace2).map(
+ TraceUtil.traceResultingEither(metric),
+ );
+ bad.get();
+
+ const flattened2 = trace2.events.flatMap((e) => e);
+ expect(flattened2.some((x: any) => typeof x === 'object' && x?.name === 'Job.failure')).toBe(true);
+ });
+});
diff --git a/tst/traceables.test.ts b/tst/traceables.test.ts
new file mode 100644
index 0000000..cafd333
--- /dev/null
+++ b/tst/traceables.test.ts
@@ -0,0 +1,64 @@
+import { CollectingTrace, TestTraceable } from './test_utils';
+import {
+ LogLevel,
+ LogMetricTrace,
+ LogMetricTraceable,
+ Metric,
+ MetricsTrace,
+ MetricValueTag,
+ type MetricValue,
+} from '../lib/index';
+
+describe('trace/trace (traceables + LogMetricTrace)', () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ jest.setSystemTime(new Date('2020-01-01T00:00:00.000Z'));
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ test('LogMetricTrace routes metrics to MetricsTrace', () => {
+ const emitted: MetricValue[] = [];
+ const metrics = new MetricsTrace((vals: MetricValue[]) => emitted.push(...vals));
+ const logs = new CollectingTrace<any>();
+
+ const t = new LogMetricTrace(logs, metrics);
+ const m = Metric.fromName('x');
+
+ const scoped = t.traceScope(m);
+ jest.setSystemTime(new Date('2020-01-01T00:00:00.010Z'));
+ scoped.trace(m);
+
+ expect(emitted).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ _tag: MetricValueTag, name: 'x.count', value: 1 }),
+ expect.objectContaining({ _tag: MetricValueTag, name: 'x.time', value: 10 }),
+ ]),
+ );
+ });
+
+ test('LogMetricTrace routes non-metrics to LogTrace', () => {
+ const metrics = new MetricsTrace(() => undefined);
+ const logs = new CollectingTrace<any>();
+
+ const t = new LogMetricTrace(logs, metrics);
+ t.traceScope(LogLevel.INFO).trace('hello');
+
+ const flattened = logs.events.flatMap((e) => e);
+ expect(flattened).toEqual(expect.arrayContaining([LogLevel.INFO, 'hello']));
+ });
+
+ test('LogMetricTraceable embeds emitted metrics into log trace', () => {
+ const logs = new CollectingTrace<any>();
+ const t = TestTraceable.of('x', logs);
+ const withMetrics = LogMetricTraceable.ofLogTraceable(t);
+
+ const metric = Metric.fromName('m');
+ withMetrics.trace.trace(metric.count.withValue(1));
+
+ const flattened = logs.events.flatMap((e) => e.map((x) => (typeof x === 'function' ? x() : x)));
+ expect(flattened.some((x) => typeof x === 'string' && x.includes('<metrics>'))).toBe(true);
+ });
+});
diff --git a/tst/validate_identifier.test.ts b/tst/validate_identifier.test.ts
new file mode 100644
index 0000000..de5ac5e
--- /dev/null
+++ b/tst/validate_identifier.test.ts
@@ -0,0 +1,28 @@
+import { validateIdentifier, validateExecutionEntries } from '../lib/index';
+
+describe('process/validate_identifier', () => {
+ test('validateIdentifier accepts safe tokens', () => {
+ expect(validateIdentifier('abcDEF_0123-:.?/&= ')).toBe(true);
+ expect(validateIdentifier('path/to/file.txt')).toBe(true);
+ });
+
+ test('validateIdentifier rejects path traversal', () => {
+ expect(validateIdentifier('../etc/passwd')).toBe(false);
+ expect(validateIdentifier('ok..not')).toBe(false);
+ });
+
+ test('validateIdentifier rejects unsafe characters', () => {
+ expect(validateIdentifier('rm -rf /;')).toBe(false);
+ expect(validateIdentifier('$HOME')).toBe(false);
+ });
+
+ test('validateExecutionEntries returns right for safe env entries', () => {
+ const res = validateExecutionEntries({ FOO: 'bar', HELLO: 'world-123' });
+ expect(res.right().get()).toEqual({ FOO: 'bar', HELLO: 'world-123' });
+ });
+
+ test('validateExecutionEntries returns invalid entries', () => {
+ const res = validateExecutionEntries({ OK: 'good', BAD: '../nope' });
+ expect(res.left().get()).toEqual([['BAD', '../nope']]);
+ });
+});