From 666674327f009e9b1013218fc384f193b64c6997 Mon Sep 17 00:00:00 2001 From: Elizabeth Hunt Date: Sun, 14 Dec 2025 22:39:18 -0800 Subject: Adds unit tests --- lib/leftpadesque/debug.ts | 3 +- lib/process/exec.ts | 4 +- lib/trace/metric/trace.ts | 11 +++- package.json | 2 +- tst/argv.test.ts | 55 ++++++++++++++++ tst/collections_cons_zipper.test.ts | 58 +++++++++++++++++ tst/collections_jsonds.test.ts | 37 +++++++++++ tst/debug.test.ts | 43 +++++++++++++ tst/either.test.ts | 24 ++++++- tst/env.test.ts | 32 +++++++++ tst/exec.test.ts | 110 +++++++++++++++++++++++++++++++ tst/fourohfour.test.ts | 38 +++++++++++ tst/hono_proxy.test.ts | 90 ++++++++++++++++++++++++++ tst/http_status.test.ts | 9 +++ tst/log_trace.test.ts | 55 ++++++++++++++++ tst/memoize.test.ts | 2 +- tst/metric.test.ts | 53 +++++++++++++++ tst/metrics_trace.test.ts | 125 ++++++++++++++++++++++++++++++++++++ tst/optional.test.ts | 37 +++++++++++ tst/prepend.test.ts | 15 +++++ tst/pretty_json_console.test.ts | 40 ++++++++++++ tst/server_filters_activity.test.ts | 96 +++++++++++++++++++++++++++ tst/server_request_response.test.ts | 110 +++++++++++++++++++++++++++++++ tst/signals.test.ts | 70 ++++++++++++++++++++ tst/tagged_object.test.ts | 16 +++++ tst/test_utils.ts | 30 +++++++++ tst/trace_util.test.ts | 37 +++++++++++ tst/traceables.test.ts | 64 ++++++++++++++++++ tst/validate_identifier.test.ts | 28 ++++++++ 29 files changed, 1286 insertions(+), 8 deletions(-) create mode 100644 tst/argv.test.ts create mode 100644 tst/collections_cons_zipper.test.ts create mode 100644 tst/collections_jsonds.test.ts create mode 100644 tst/debug.test.ts create mode 100644 tst/env.test.ts create mode 100644 tst/exec.test.ts create mode 100644 tst/fourohfour.test.ts create mode 100644 tst/hono_proxy.test.ts create mode 100644 tst/http_status.test.ts create mode 100644 tst/log_trace.test.ts create mode 100644 tst/metric.test.ts create mode 100644 tst/metrics_trace.test.ts create mode 100644 tst/optional.test.ts create mode 100644 tst/prepend.test.ts create mode 100644 tst/pretty_json_console.test.ts create mode 100644 tst/server_filters_activity.test.ts create mode 100644 tst/server_request_response.test.ts create mode 100644 tst/signals.test.ts create mode 100644 tst/tagged_object.test.ts create mode 100644 tst/test_utils.ts create mode 100644 tst/trace_util.test.ts create mode 100644 tst/traceables.test.ts create mode 100644 tst/validate_identifier.test.ts 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( new Promise((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 { 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; + expect(z2.read().get()).toBe(2); + + const z3 = z2.replace(9) as ListZipper; + expect(z3.collection()).toEqual([1, 9, 3]); + + const z4 = z3.remove() as ListZipper; + expect(z4.collection()).toEqual([1, 3]); + + const z5 = z4.prependChunk([7, 8]) as ListZipper; + expect(z5.collection()).toEqual([1, 7, 8, 3]); + + const back = (z2.previous().get() as ListZipper).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([]); + 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(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('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(2); + const b = Either.right(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(); + const cmd = TestTraceable.of(['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('false', new CollectingTrace()); + 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, any>( + [ + ['echo', 'a'], + ['echo', 'b'], + ], + new CollectingTrace(), + ); + + 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 => ({ + 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(); + 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; + +const makeBaseRequest = (overrides: Partial = {}): 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 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().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())).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 => ({ + 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(); + 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(); + 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(); + 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(); + 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 => ({ + 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(); + 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(); + 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(); + 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 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(); + 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 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(); + 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 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()); + + 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 implements ITrace { + constructor( + private readonly collector: Array>> = [], + private readonly scopes: Array> = [], + ) {} + + public get events() { + return this.collector; + } + + public traceScope(trace: ITraceWith): ITrace { + return new CollectingTrace(this.collector, this.scopes.concat([trace])); + } + + public trace(trace: ITraceWith) { + this.collector.push(this.scopes.concat([trace])); + } +} + +export class TestTraceable extends TraceableImpl { + constructor(item: T, trace: ITrace) { + super(item, trace); + } + + static of(item: T, trace: ITrace) { + return new TestTraceable(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(); + 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(); + + const ok = TestTraceable.of(Either.right('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(); + const bad = TestTraceable.of(Either.left(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(); + + 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(); + + 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(); + 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(''))).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']]); + }); +}); -- cgit v1.2.3-70-g09d2