Files
dpsapi/e2e/harness/logs.ts
T
bryanpedini fe0cd4bf6d test(e2e): add playwright e2e harness
Process and API-only Playwright harness: binary and image setup, server/CLI runners, fake Docker Engine socket, read-only database inspection, secret redaction in reports, seeded fuzzing, leak detection and harness self-checks.
2026-10-06 10:52:25 +02:00

167 lines
5.5 KiB
TypeScript

// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
/**
* Parser and query helpers for dpsapi's log/slog text-handler output (stderr).
*
* Line format: `time=<RFC3339 ms UTC> level=<LEVEL> msg=<value> key=value ...` where a value
* is either bare (no spaces/quotes/=) or Go-quoted.
*/
import { goUnquote } from './gostrings';
export const LOG_LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'] as const;
export type LogLevel = (typeof LOG_LEVELS)[number];
/** Severity rank: TRACE 0 … ERROR 4. */
export function levelRank(level: string): number {
const i = LOG_LEVELS.indexOf(level.toUpperCase() as LogLevel);
if (i < 0) throw new Error(`unknown log level ${level}`);
return i;
}
/** Levels that must be emitted when the minimum level is `min`. */
export function visibleLevels(min: LogLevel): LogLevel[] {
return LOG_LEVELS.filter((l) => levelRank(l) >= levelRank(min));
}
export interface LogRecord {
/** The `time` attribute (UTC, millisecond precision). */
time: string;
level: string;
msg: string;
/** All attributes except time/level/msg; the last value wins for duplicate keys. */
attrs: Record<string, string>;
/** Every key/value pair in order, including time/level/msg. */
pairs: Array<[string, string]>;
/** The raw line. */
raw: string;
/** 0-based line index in the stream. */
line: number;
}
export type AttrMatcher = string | number | boolean | RegExp | ((value: string | undefined) => boolean);
export interface LogQuery {
level?: string | string[];
msg?: string | RegExp;
/** Every listed attribute must match. Use `undefined`-checking functions for absence. */
attrs?: Record<string, AttrMatcher>;
}
/** Strict format of the slog `time` attribute as emitted by dpsapi (UTC, milliseconds). */
export const LOG_TIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
/** Parses one log line; returns undefined when the line is not a slog record. */
export function parseLogLine(raw: string, line = 0): LogRecord | undefined {
if (!raw.startsWith('time=')) return undefined;
const pairs: Array<[string, string]> = [];
let i = 0;
const n = raw.length;
const readToken = (stopAtEq: boolean): string | undefined => {
if (raw[i] === '"') {
// quoted: find closing quote honoring backslash escapes
let j = i + 1;
while (j < n && raw[j] !== '"') j += raw[j] === '\\' ? 2 : 1;
if (j >= n) return undefined;
const q = raw.slice(i, j + 1);
i = j + 1;
try {
return goUnquote(q);
} catch {
return undefined;
}
}
let j = i;
while (j < n && raw[j] !== ' ' && !(stopAtEq && raw[j] === '=')) j++;
const t = raw.slice(i, j);
i = j;
return t;
};
while (i < n) {
while (raw[i] === ' ') i++;
if (i >= n) break;
const key = readToken(true);
if (key === undefined || raw[i] !== '=') return undefined;
i++;
const value = readToken(false);
if (value === undefined) return undefined;
pairs.push([key, value]);
}
if (pairs.length < 3 || pairs[0][0] !== 'time' || pairs[1][0] !== 'level' || pairs[2][0] !== 'msg') return undefined;
const attrs: Record<string, string> = {};
for (const [k, v] of pairs.slice(3)) attrs[k] = v;
return { time: pairs[0][1], level: pairs[1][1], msg: pairs[2][1], attrs, pairs, raw, line };
}
export function matchesQuery(r: LogRecord, q: LogQuery): boolean {
if (q.level !== undefined) {
const levels = Array.isArray(q.level) ? q.level : [q.level];
if (!levels.map((l) => l.toUpperCase()).includes(r.level)) return false;
}
if (q.msg !== undefined) {
if (typeof q.msg === 'string' ? r.msg !== q.msg : !q.msg.test(r.msg)) return false;
}
for (const [k, m] of Object.entries(q.attrs ?? {})) {
const v = Object.prototype.hasOwnProperty.call(r.attrs, k) ? r.attrs[k] : undefined;
if (typeof m === 'function') {
if (!m(v)) return false;
} else if (m instanceof RegExp) {
if (v === undefined || !m.test(v)) return false;
} else if (v !== String(m)) return false;
}
return true;
}
export class LogSet {
readonly records: LogRecord[];
/** Lines that are not slog records (e.g. the final `dpsapi: ...` error line, usage text). */
readonly unparsed: Array<{ line: number; text: string }>;
constructor(text: string) {
this.records = [];
this.unparsed = [];
const lines = text.split('\n');
if (lines.length && lines[lines.length - 1] === '') lines.pop();
lines.forEach((l, idx) => {
const r = parseLogLine(l, idx);
if (r) this.records.push(r);
else this.unparsed.push({ line: idx, text: l });
});
}
filter(q: LogQuery = {}): LogRecord[] {
return this.records.filter((r) => matchesQuery(r, q));
}
find(q: LogQuery): LogRecord | undefined {
return this.records.find((r) => matchesQuery(r, q));
}
count(q: LogQuery = {}): number {
return this.filter(q).length;
}
has(q: LogQuery): boolean {
return this.find(q) !== undefined;
}
/** Distinct levels present. */
levels(): string[] {
return [...new Set(this.records.map((r) => r.level))];
}
/** Distinct messages present (optionally only at one level). */
messages(level?: string): string[] {
return [...new Set(this.records.filter((r) => !level || r.level === level.toUpperCase()).map((r) => r.msg))];
}
/** `this request has been made` records. */
requests(q: Omit<LogQuery, 'msg'> = {}): LogRecord[] {
return this.filter({ ...q, msg: 'this request has been made' });
}
}
export function parseLogs(text: string): LogSet {
return new LogSet(text);
}