mirror of
https://github.com/teamchong/pxpipe.git
synced 2026-07-22 02:02:51 +02:00
cli: add 'pixelpipe stats' subcommand for offline log analysis
Reads ~/.pixelpipe/events.jsonl (or --file / PIXELPIPE_LOG) and prints
a one-screen summary of how the proxy is doing:
- request counts by status bucket (2xx/4xx/5xx)
- compression rate (compressed vs passthrough)
- latency p50/p95/p99 (duration + first-byte separately)
- aggregate Anthropic token usage (input/output/cache_create/cache_read)
- cache hit rate computed two ways (by tokens, by events)
- top skip reasons (so we see WHY a request wasn't compressed)
- top cwds with per-project compression ratio
- system_sha8 distribution → 'reuse rate' = how often the cached
payload is being re-sent. High reuse + low cache_read means
something is breaking cache_control.
- unknown_static_tags warning section if Claude Code shipped a new
dynamic tag that we're silently baking into the image.
--json mode dumps the raw Summary as JSON for downstream pipelines.
Streaming reader so a 100 MB log file doesn't OOM. Pure aggregator
(fold/newSummary/renderTextReport) is unit-tested; only the file IO
shell is untested.
Dispatched from src/node.ts BEFORE parseCli so its flags don't collide
with the proxy's flags. The CLI shim in bin/cli.js stays unchanged.
Tests: +6 (status bucketing, compressed/passthrough split, token agg,
cwd & sha histogram, unknown-tag collection, text report rendering).
This commit is contained in:
+12
-2
@@ -82,7 +82,8 @@ function printHelp(): void {
|
||||
console.log(`pixelpipe — token-saving proxy for Claude Code
|
||||
|
||||
Usage:
|
||||
pixelpipe [options]
|
||||
pixelpipe [options] run the proxy
|
||||
pixelpipe stats [--file <p>] summarize ~/.pixelpipe/events.jsonl
|
||||
|
||||
Options:
|
||||
-p, --port <N> listen port (default 47821)
|
||||
@@ -288,7 +289,16 @@ class FileTracker implements Tracker {
|
||||
// ---- main ----------------------------------------------------------------
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const opts = parseCli(process.argv.slice(2));
|
||||
// Subcommand dispatch — before parseCli so flag parsing doesn't choke on
|
||||
// `stats`'s own flags.
|
||||
const argv = process.argv.slice(2);
|
||||
if (argv[0] === 'stats') {
|
||||
const { runStats } = await import('./stats.js');
|
||||
const code = await runStats(argv.slice(1));
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
const opts = parseCli(argv);
|
||||
const transform: TransformOptions = {
|
||||
compress: opts.compress,
|
||||
compressTools: opts.compressTools,
|
||||
|
||||
+341
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* `pixelpipe stats` — read the JSONL events file (or any file matching the
|
||||
* tracker.ts schema) and print aggregate metrics about how the proxy is
|
||||
* doing.
|
||||
*
|
||||
* Node-only (uses node:fs). Streams the file line-by-line so a 100 MB log
|
||||
* doesn't blow the heap. The aggregator itself is pure — fed a sequence of
|
||||
* TrackEvent and produces a Summary — so a Workers-side dashboard could
|
||||
* reuse it later by extracting it into core/.
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 ok, summary printed
|
||||
* 1 events file missing or unreadable
|
||||
* 2 events file exists but contained zero valid lines
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import * as readline from 'node:readline';
|
||||
import type { TrackEvent } from './core/tracker.js';
|
||||
|
||||
// ---- pure aggregator ------------------------------------------------------
|
||||
|
||||
export interface Summary {
|
||||
total: number;
|
||||
ok2xx: number;
|
||||
err4xx: number;
|
||||
err5xx: number;
|
||||
compressed: number;
|
||||
passthrough: number;
|
||||
/** Sum of orig_chars across compressed requests — the bytes we removed
|
||||
* from the text path by rendering to PNG. */
|
||||
origCharsTotal: number;
|
||||
imageBytesTotal: number;
|
||||
/** Aggregated Anthropic token usage. */
|
||||
inputTokensTotal: number;
|
||||
outputTokensTotal: number;
|
||||
cacheCreateTokensTotal: number;
|
||||
cacheReadTokensTotal: number;
|
||||
/** Number of events whose cache_read_tokens > 0 — i.e. the prompt cache
|
||||
* actually hit. */
|
||||
cacheHitEvents: number;
|
||||
/** Number of events that carried any usage data at all. Denominator for
|
||||
* cacheHitEvents. */
|
||||
eventsWithUsage: number;
|
||||
durationMs: number[];
|
||||
firstByteMs: number[];
|
||||
skipReasons: Map<string, number>;
|
||||
byCwd: Map<string, { count: number; origChars: number; imageBytes: number }>;
|
||||
/** system_sha8 → number of times seen. High repeat count = cache should
|
||||
* be doing its job. */
|
||||
systemShaHist: Map<string, number>;
|
||||
unknownTags: Map<string, number>;
|
||||
}
|
||||
|
||||
export function newSummary(): Summary {
|
||||
return {
|
||||
total: 0,
|
||||
ok2xx: 0,
|
||||
err4xx: 0,
|
||||
err5xx: 0,
|
||||
compressed: 0,
|
||||
passthrough: 0,
|
||||
origCharsTotal: 0,
|
||||
imageBytesTotal: 0,
|
||||
inputTokensTotal: 0,
|
||||
outputTokensTotal: 0,
|
||||
cacheCreateTokensTotal: 0,
|
||||
cacheReadTokensTotal: 0,
|
||||
cacheHitEvents: 0,
|
||||
eventsWithUsage: 0,
|
||||
durationMs: [],
|
||||
firstByteMs: [],
|
||||
skipReasons: new Map(),
|
||||
byCwd: new Map(),
|
||||
systemShaHist: new Map(),
|
||||
unknownTags: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
export function fold(s: Summary, ev: TrackEvent): Summary {
|
||||
s.total++;
|
||||
if (ev.status >= 200 && ev.status < 300) s.ok2xx++;
|
||||
else if (ev.status >= 400 && ev.status < 500) s.err4xx++;
|
||||
else if (ev.status >= 500) s.err5xx++;
|
||||
|
||||
if (ev.compressed === true) {
|
||||
s.compressed++;
|
||||
if (typeof ev.orig_chars === 'number') s.origCharsTotal += ev.orig_chars;
|
||||
if (typeof ev.image_bytes === 'number') s.imageBytesTotal += ev.image_bytes;
|
||||
} else if (ev.compressed === false) {
|
||||
s.passthrough++;
|
||||
if (ev.reason) s.skipReasons.set(ev.reason, (s.skipReasons.get(ev.reason) ?? 0) + 1);
|
||||
}
|
||||
|
||||
if (typeof ev.duration_ms === 'number') s.durationMs.push(ev.duration_ms);
|
||||
if (typeof ev.first_byte_ms === 'number') s.firstByteMs.push(ev.first_byte_ms);
|
||||
|
||||
const hasUsage =
|
||||
typeof ev.input_tokens === 'number' ||
|
||||
typeof ev.cache_read_tokens === 'number' ||
|
||||
typeof ev.cache_create_tokens === 'number' ||
|
||||
typeof ev.output_tokens === 'number';
|
||||
if (hasUsage) {
|
||||
s.eventsWithUsage++;
|
||||
s.inputTokensTotal += ev.input_tokens ?? 0;
|
||||
s.outputTokensTotal += ev.output_tokens ?? 0;
|
||||
s.cacheCreateTokensTotal += ev.cache_create_tokens ?? 0;
|
||||
s.cacheReadTokensTotal += ev.cache_read_tokens ?? 0;
|
||||
if ((ev.cache_read_tokens ?? 0) > 0) s.cacheHitEvents++;
|
||||
}
|
||||
|
||||
if (ev.cwd) {
|
||||
const k = ev.cwd;
|
||||
const e = s.byCwd.get(k) ?? { count: 0, origChars: 0, imageBytes: 0 };
|
||||
e.count++;
|
||||
e.origChars += ev.orig_chars ?? 0;
|
||||
e.imageBytes += ev.image_bytes ?? 0;
|
||||
s.byCwd.set(k, e);
|
||||
}
|
||||
|
||||
if (ev.system_sha8) {
|
||||
s.systemShaHist.set(ev.system_sha8, (s.systemShaHist.get(ev.system_sha8) ?? 0) + 1);
|
||||
}
|
||||
|
||||
if (ev.unknown_static_tags) {
|
||||
for (const t of ev.unknown_static_tags) {
|
||||
s.unknownTags.set(t, (s.unknownTags.get(t) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
function percentile(sorted: number[], p: number): number {
|
||||
if (sorted.length === 0) return 0;
|
||||
const idx = Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length));
|
||||
return sorted[idx]!;
|
||||
}
|
||||
|
||||
/** Format a number with thousands separators. Used for big token counts. */
|
||||
function fmtN(n: number): string {
|
||||
return n.toLocaleString('en-US');
|
||||
}
|
||||
|
||||
function fmtPct(num: number, denom: number): string {
|
||||
if (denom === 0) return ' —';
|
||||
return ((num / denom) * 100).toFixed(1).padStart(4) + '%';
|
||||
}
|
||||
|
||||
// ---- text report ----------------------------------------------------------
|
||||
|
||||
export function renderTextReport(s: Summary): string {
|
||||
const lines: string[] = [];
|
||||
const sortedDur = [...s.durationMs].sort((a, b) => a - b);
|
||||
const sortedFB = [...s.firstByteMs].sort((a, b) => a - b);
|
||||
|
||||
lines.push('━━━ pixelpipe stats ━━━');
|
||||
lines.push('');
|
||||
lines.push(`requests: ${fmtN(s.total)}`);
|
||||
lines.push(
|
||||
` 2xx: ${fmtN(s.ok2xx).padStart(8)} ` +
|
||||
`4xx: ${fmtN(s.err4xx).padStart(6)} 5xx: ${fmtN(s.err5xx).padStart(6)}`,
|
||||
);
|
||||
lines.push(
|
||||
` compressed: ${fmtN(s.compressed).padStart(8)} (${fmtPct(s.compressed, s.total)})`,
|
||||
);
|
||||
lines.push(
|
||||
` passthrough: ${fmtN(s.passthrough).padStart(8)} (${fmtPct(s.passthrough, s.total)})`,
|
||||
);
|
||||
lines.push('');
|
||||
|
||||
lines.push('latency (ms):');
|
||||
lines.push(
|
||||
` duration p50=${percentile(sortedDur, 50)} p95=${percentile(sortedDur, 95)} p99=${percentile(sortedDur, 99)}`,
|
||||
);
|
||||
lines.push(
|
||||
` first-byte p50=${percentile(sortedFB, 50)} p95=${percentile(sortedFB, 95)} p99=${percentile(sortedFB, 99)}`,
|
||||
);
|
||||
lines.push('');
|
||||
|
||||
lines.push('compression:');
|
||||
lines.push(` orig text rendered: ${fmtN(s.origCharsTotal)} chars`);
|
||||
lines.push(` image bytes: ${fmtN(s.imageBytesTotal)} B`);
|
||||
const ratio =
|
||||
s.origCharsTotal > 0 ? (s.imageBytesTotal / s.origCharsTotal).toFixed(3) : '—';
|
||||
lines.push(` bytes/char ratio: ${ratio}`);
|
||||
lines.push('');
|
||||
|
||||
lines.push('Anthropic token usage:');
|
||||
lines.push(` input: ${fmtN(s.inputTokensTotal).padStart(12)}`);
|
||||
lines.push(` output: ${fmtN(s.outputTokensTotal).padStart(12)}`);
|
||||
lines.push(` cache create: ${fmtN(s.cacheCreateTokensTotal).padStart(12)}`);
|
||||
lines.push(` cache read: ${fmtN(s.cacheReadTokensTotal).padStart(12)}`);
|
||||
const totalIn =
|
||||
s.inputTokensTotal + s.cacheCreateTokensTotal + s.cacheReadTokensTotal;
|
||||
lines.push(
|
||||
` cache hit rate (by tokens): ${fmtPct(s.cacheReadTokensTotal, totalIn)}`,
|
||||
);
|
||||
lines.push(
|
||||
` cache hit rate (by events): ${fmtPct(s.cacheHitEvents, s.eventsWithUsage)}`,
|
||||
);
|
||||
lines.push('');
|
||||
|
||||
if (s.skipReasons.size > 0) {
|
||||
lines.push('top skip reasons:');
|
||||
const top = [...s.skipReasons.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
|
||||
for (const [reason, count] of top) {
|
||||
lines.push(` ${count.toString().padStart(6)} ${reason}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (s.byCwd.size > 0) {
|
||||
lines.push('top working dirs (by request count):');
|
||||
const top = [...s.byCwd.entries()].sort((a, b) => b[1].count - a[1].count).slice(0, 10);
|
||||
for (const [cwd, e] of top) {
|
||||
const cratio = e.origChars > 0 ? (e.imageBytes / e.origChars).toFixed(2) : '—';
|
||||
lines.push(` ${e.count.toString().padStart(6)} ratio=${cratio} ${cwd}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (s.systemShaHist.size > 0) {
|
||||
lines.push('top system prompts (system_sha8, high count = cache reuse):');
|
||||
const top = [...s.systemShaHist.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
|
||||
for (const [sha, count] of top) {
|
||||
lines.push(` ${count.toString().padStart(6)} ${sha}`);
|
||||
}
|
||||
const unique = s.systemShaHist.size;
|
||||
const reuseRate =
|
||||
s.total > 0 ? (((s.total - unique) / s.total) * 100).toFixed(1) : '—';
|
||||
lines.push(` unique prompts: ${unique} reuse rate: ${reuseRate}%`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (s.unknownTags.size > 0) {
|
||||
lines.push('⚠ unknown tag-shaped blocks observed in static slab:');
|
||||
const top = [...s.unknownTags.entries()].sort((a, b) => b[1] - a[1]);
|
||||
for (const [tag, count] of top) {
|
||||
lines.push(` ${count.toString().padStart(6)} <${tag}>`);
|
||||
}
|
||||
lines.push(
|
||||
' → consider adding these to DYNAMIC_BLOCK_TAGS in src/core/transform.ts',
|
||||
);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ---- entrypoint -----------------------------------------------------------
|
||||
|
||||
interface StatsOpts {
|
||||
file: string;
|
||||
json: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): StatsOpts {
|
||||
const opts: StatsOpts = {
|
||||
file:
|
||||
process.env.PIXELPIPE_LOG ??
|
||||
path.join(os.homedir(), '.pixelpipe', 'events.jsonl'),
|
||||
json: false,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i]!;
|
||||
if (a === '--file' || a === '-f') opts.file = argv[++i] ?? opts.file;
|
||||
else if (a === '--json') opts.json = true;
|
||||
else if (a === '-h' || a === '--help') {
|
||||
console.log(`pixelpipe stats — aggregate metrics from events JSONL
|
||||
|
||||
Usage:
|
||||
pixelpipe stats [--file <path>] [--json]
|
||||
|
||||
Options:
|
||||
-f, --file <path> events JSONL to read (default ~/.pixelpipe/events.jsonl
|
||||
or PIXELPIPE_LOG env var)
|
||||
--json emit the Summary object as JSON instead of a text report
|
||||
-h, --help show this help
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
export async function runStats(argv: string[]): Promise<number> {
|
||||
const opts = parseArgs(argv);
|
||||
|
||||
if (!fs.existsSync(opts.file)) {
|
||||
console.error(`[pixelpipe stats] events file not found: ${opts.file}`);
|
||||
console.error(
|
||||
`[pixelpipe stats] (run pixelpipe and send a request first, or set PIXELPIPE_LOG)`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const stream = fs.createReadStream(opts.file, { encoding: 'utf8' });
|
||||
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
||||
const summary = newSummary();
|
||||
let parsed = 0;
|
||||
let dropped = 0;
|
||||
for await (const line of rl) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const ev = JSON.parse(line) as TrackEvent;
|
||||
fold(summary, ev);
|
||||
parsed++;
|
||||
} catch {
|
||||
dropped++;
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed === 0) {
|
||||
console.error(`[pixelpipe stats] no valid events in ${opts.file}`);
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
// Maps don't survive JSON.stringify — convert to plain objects.
|
||||
process.stdout.write(
|
||||
JSON.stringify(
|
||||
{
|
||||
...summary,
|
||||
skipReasons: Object.fromEntries(summary.skipReasons),
|
||||
byCwd: Object.fromEntries(summary.byCwd),
|
||||
systemShaHist: Object.fromEntries(summary.systemShaHist),
|
||||
unknownTags: Object.fromEntries(summary.unknownTags),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + '\n',
|
||||
);
|
||||
} else {
|
||||
process.stdout.write(renderTextReport(summary) + '\n');
|
||||
if (dropped > 0) console.error(`(${dropped} unparseable line(s) skipped)`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { newSummary, fold, renderTextReport } from '../src/stats.js';
|
||||
import type { TrackEvent } from '../src/core/tracker.js';
|
||||
|
||||
function ev(partial: Partial<TrackEvent>): TrackEvent {
|
||||
return {
|
||||
ts: '2026-05-18T00:00:00Z',
|
||||
method: 'POST',
|
||||
path: '/v1/messages',
|
||||
status: 200,
|
||||
duration_ms: 100,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe('stats aggregator', () => {
|
||||
it('counts status buckets', () => {
|
||||
const s = newSummary();
|
||||
fold(s, ev({ status: 200 }));
|
||||
fold(s, ev({ status: 201 }));
|
||||
fold(s, ev({ status: 404 }));
|
||||
fold(s, ev({ status: 503 }));
|
||||
fold(s, ev({ status: 500 }));
|
||||
expect(s.total).toBe(5);
|
||||
expect(s.ok2xx).toBe(2);
|
||||
expect(s.err4xx).toBe(1);
|
||||
expect(s.err5xx).toBe(2);
|
||||
});
|
||||
|
||||
it('separates compressed vs passthrough and collects skip reasons', () => {
|
||||
const s = newSummary();
|
||||
fold(s, ev({ compressed: true, orig_chars: 1000, image_bytes: 200 }));
|
||||
fold(s, ev({ compressed: true, orig_chars: 2000, image_bytes: 300 }));
|
||||
fold(s, ev({ compressed: false, reason: 'below_min_chars (50 < 2000)' }));
|
||||
fold(s, ev({ compressed: false, reason: 'below_min_chars (60 < 2000)' }));
|
||||
fold(s, ev({ compressed: false, reason: 'compress=false' }));
|
||||
expect(s.compressed).toBe(2);
|
||||
expect(s.passthrough).toBe(3);
|
||||
expect(s.origCharsTotal).toBe(3000);
|
||||
expect(s.imageBytesTotal).toBe(500);
|
||||
// Reasons keep their exact string form (parenthetical char counts and
|
||||
// all) — useful for spotting outliers without collapsing detail.
|
||||
expect(s.skipReasons.size).toBe(3);
|
||||
expect(s.skipReasons.get('below_min_chars (50 < 2000)')).toBe(1);
|
||||
expect(s.skipReasons.get('below_min_chars (60 < 2000)')).toBe(1);
|
||||
expect(s.skipReasons.get('compress=false')).toBe(1);
|
||||
});
|
||||
|
||||
it('aggregates Anthropic token usage and computes cache hit metrics', () => {
|
||||
const s = newSummary();
|
||||
fold(
|
||||
s,
|
||||
ev({
|
||||
input_tokens: 100,
|
||||
output_tokens: 10,
|
||||
cache_read_tokens: 0,
|
||||
cache_create_tokens: 5000,
|
||||
}),
|
||||
);
|
||||
fold(
|
||||
s,
|
||||
ev({
|
||||
input_tokens: 50,
|
||||
output_tokens: 5,
|
||||
cache_read_tokens: 5000,
|
||||
cache_create_tokens: 0,
|
||||
}),
|
||||
);
|
||||
fold(
|
||||
s,
|
||||
ev({
|
||||
input_tokens: 60,
|
||||
output_tokens: 6,
|
||||
cache_read_tokens: 5000,
|
||||
cache_create_tokens: 0,
|
||||
}),
|
||||
);
|
||||
// 3 events all carried usage; 2 had cache_read > 0.
|
||||
expect(s.eventsWithUsage).toBe(3);
|
||||
expect(s.cacheHitEvents).toBe(2);
|
||||
expect(s.inputTokensTotal).toBe(210);
|
||||
expect(s.outputTokensTotal).toBe(21);
|
||||
expect(s.cacheReadTokensTotal).toBe(10000);
|
||||
expect(s.cacheCreateTokensTotal).toBe(5000);
|
||||
});
|
||||
|
||||
it('buckets by cwd and tracks system_sha8 reuse', () => {
|
||||
const s = newSummary();
|
||||
fold(s, ev({ cwd: '/a', system_sha8: 'aaa', orig_chars: 100, image_bytes: 20 }));
|
||||
fold(s, ev({ cwd: '/a', system_sha8: 'aaa', orig_chars: 100, image_bytes: 20 }));
|
||||
fold(s, ev({ cwd: '/b', system_sha8: 'bbb', orig_chars: 200, image_bytes: 40 }));
|
||||
expect(s.byCwd.size).toBe(2);
|
||||
expect(s.byCwd.get('/a')!.count).toBe(2);
|
||||
expect(s.byCwd.get('/a')!.origChars).toBe(200);
|
||||
expect(s.systemShaHist.get('aaa')).toBe(2);
|
||||
expect(s.systemShaHist.get('bbb')).toBe(1);
|
||||
});
|
||||
|
||||
it('collects unknown_static_tags across events', () => {
|
||||
const s = newSummary();
|
||||
fold(s, ev({ unknown_static_tags: ['recent_files', 'todo_list'] }));
|
||||
fold(s, ev({ unknown_static_tags: ['recent_files'] }));
|
||||
fold(s, ev({}));
|
||||
expect(s.unknownTags.get('recent_files')).toBe(2);
|
||||
expect(s.unknownTags.get('todo_list')).toBe(1);
|
||||
});
|
||||
|
||||
it('renders a non-empty text report for a populated summary', () => {
|
||||
const s = newSummary();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
fold(
|
||||
s,
|
||||
ev({
|
||||
compressed: true,
|
||||
orig_chars: 5000,
|
||||
image_bytes: 1000,
|
||||
input_tokens: 50,
|
||||
cache_read_tokens: i % 2 === 0 ? 4000 : 0,
|
||||
cache_create_tokens: i % 2 === 0 ? 0 : 4000,
|
||||
duration_ms: 100 + i,
|
||||
first_byte_ms: 30 + i,
|
||||
cwd: '/Users/x/code/pp',
|
||||
system_sha8: 'stable',
|
||||
}),
|
||||
);
|
||||
}
|
||||
const out = renderTextReport(s);
|
||||
expect(out).toContain('pixelpipe stats');
|
||||
expect(out).toContain('compressed');
|
||||
expect(out).toContain('cache hit rate');
|
||||
expect(out).toContain('/Users/x/code/pp');
|
||||
expect(out).toContain('stable');
|
||||
// 50% cache hit rate by event.
|
||||
expect(out).toMatch(/cache hit rate \(by events\):\s+50.0%/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user