diff --git a/bin/gstack-analytics b/bin/gstack-analytics index ad06edd16..ba80ec45d 100755 --- a/bin/gstack-analytics +++ b/bin/gstack-analytics @@ -168,7 +168,9 @@ echo "$SKILL_COUNTS" | while read -r COUNT SKILL; do # Extract duration_s value using split on "duration_s": n = split($0, parts, "\"duration_s\":") if (n >= 2) { - # parts[2] starts with the value, e.g. "142," + # parts[2] starts with the value: unquoted (142,...) or quoted ("142",...). + # Strip a leading double-quote so the digit-extraction below handles both forms. + sub(/^"/, "", parts[2]) gsub(/[^0-9.].*/, "", parts[2]) if (parts[2]+0 > 0) { total += parts[2]; count++ } } diff --git a/test/gstack-analytics-avg-duration.test.ts b/test/gstack-analytics-avg-duration.test.ts new file mode 100644 index 000000000..8b06adb38 --- /dev/null +++ b/test/gstack-analytics-avg-duration.test.ts @@ -0,0 +1,87 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const BIN = path.join(ROOT, 'bin', 'gstack-analytics'); + +// Verifies the per-skill avg-duration calculation in the bash dashboard +// (bin/gstack-analytics) parses both unquoted and quoted "duration_s" +// values from skill-usage.jsonl. The completion-status preamble (and +// gstack-codex-probe) emit quoted strings; gstack-telemetry-log emits +// unquoted numbers. The previous awk regex stripped from the leading +// quote, silently yielding avg=0 for quoted-only skills. +describe('gstack-analytics avg duration parsing', () => { + let stateDir: string; + + beforeEach(() => { + stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-analytics-test-')); + fs.mkdirSync(path.join(stateDir, 'analytics'), { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(stateDir, { recursive: true, force: true }); + }); + + function run(jsonl: string[]): string { + fs.writeFileSync( + path.join(stateDir, 'analytics', 'skill-usage.jsonl'), + jsonl.join('\n') + '\n', + ); + const r = spawnSync('bash', [BIN, 'all'], { + env: { ...process.env, GSTACK_STATE_DIR: stateDir }, + encoding: 'utf-8', + }); + if (r.status !== 0) { + throw new Error(`gstack-analytics failed (status ${r.status}): ${r.stderr}`); + } + return r.stdout; + } + + // Pull "(avg )" out of a /skill row. + function avgFor(report: string, skill: string): string { + const re = new RegExp(`/${skill}\\b[^\\n]*\\(avg ([^)]+)\\)`); + const m = report.match(re); + if (!m) throw new Error(`no avg row for /${skill} in:\n${report}`); + return m[1]; + } + + test('quoted "duration_s" values are averaged (regression: previously read as 0)', () => { + const output = run([ + '{"skill":"review","duration_s":"120","outcome":"success","ts":"2026-05-25T10:00:00Z"}', + '{"skill":"review","duration_s":"180","outcome":"success","ts":"2026-05-25T11:00:00Z"}', + '{"skill":"review","duration_s":"60","outcome":"success","ts":"2026-05-25T12:00:00Z"}', + ]); + // (120+180+60)/3 = 120s = 2m + expect(avgFor(output, 'review')).toBe('2m'); + }); + + test('unquoted numeric "duration_s" values are averaged', () => { + const output = run([ + '{"skill":"qa","duration_s":300,"outcome":"success","ts":"2026-05-25T13:00:00Z"}', + '{"skill":"qa","duration_s":420,"outcome":"success","ts":"2026-05-25T14:00:00Z"}', + ]); + // (300+420)/2 = 360s = 6m + expect(avgFor(output, 'qa')).toBe('6m'); + }); + + test('mixed quoted and unquoted values average together', () => { + const output = run([ + '{"skill":"ship","duration_s":"30","outcome":"success","ts":"2026-05-25T10:00:00Z"}', + '{"skill":"ship","duration_s":50,"outcome":"success","ts":"2026-05-25T11:00:00Z"}', + ]); + // (30+50)/2 = 40s — under 60 so shown in seconds, exercising both paths. + expect(avgFor(output, 'ship')).toBe('40s'); + }); + + test('total time sums quoted and unquoted across all skills', () => { + const output = run([ + '{"skill":"review","duration_s":"120","outcome":"success","ts":"2026-05-25T10:00:00Z"}', + '{"skill":"qa","duration_s":300,"outcome":"success","ts":"2026-05-25T13:00:00Z"}', + ]); + // 120+300 = 420s = 7m + expect(output).toContain('Total time: 7m'); + }); +});