~cytrogen/gstack

ref: 562a67503ab1308a711d5de17512e092912d0dac gstack/test/timeline.test.ts -rw-r--r-- 5.0 KiB
562a6750 — Garry Tan feat: Session Intelligence Layer — /checkpoint + /health + context recovery (v0.15.0.0) (#733) 8 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { execSync, ExecSyncOptionsWithStringEncoding } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';

const ROOT = path.resolve(import.meta.dir, '..');
const BIN = path.join(ROOT, 'bin');

let tmpDir: string;
let slugDir: string;

function runLog(input: string, opts: { expectFail?: boolean } = {}): { stdout: string; exitCode: number } {
  const execOpts: ExecSyncOptionsWithStringEncoding = {
    cwd: ROOT,
    env: { ...process.env, GSTACK_HOME: tmpDir },
    encoding: 'utf-8',
    timeout: 15000,
  };
  try {
    const stdout = execSync(`${BIN}/gstack-timeline-log '${input.replace(/'/g, "'\\''")}'`, execOpts).trim();
    return { stdout, exitCode: 0 };
  } catch (e: any) {
    if (opts.expectFail) {
      return { stdout: e.stderr?.toString() || '', exitCode: e.status || 1 };
    }
    throw e;
  }
}

function runRead(args: string = ''): string {
  const execOpts: ExecSyncOptionsWithStringEncoding = {
    cwd: ROOT,
    env: { ...process.env, GSTACK_HOME: tmpDir },
    encoding: 'utf-8',
    timeout: 15000,
  };
  try {
    return execSync(`${BIN}/gstack-timeline-read ${args}`, execOpts).trim();
  } catch {
    return '';
  }
}

beforeEach(() => {
  tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-timeline-'));
  slugDir = path.join(tmpDir, 'projects');
  fs.mkdirSync(slugDir, { recursive: true });
});

afterEach(() => {
  fs.rmSync(tmpDir, { recursive: true, force: true });
});

function findTimelineFile(): string | null {
  const projectDirs = fs.readdirSync(slugDir);
  if (projectDirs.length === 0) return null;
  const f = path.join(slugDir, projectDirs[0], 'timeline.jsonl');
  return fs.existsSync(f) ? f : null;
}

describe('gstack-timeline-log', () => {
  test('accepts valid JSON and appends to timeline.jsonl', () => {
    const input = '{"skill":"review","event":"started","branch":"main"}';
    const result = runLog(input);
    expect(result.exitCode).toBe(0);

    const f = findTimelineFile();
    expect(f).not.toBeNull();
    const content = fs.readFileSync(f!, 'utf-8').trim();
    const parsed = JSON.parse(content);
    expect(parsed.skill).toBe('review');
    expect(parsed.event).toBe('started');
    expect(parsed.branch).toBe('main');
  });

  test('rejects invalid JSON with exit 0 (non-blocking)', () => {
    const result = runLog('not json at all');
    expect(result.exitCode).toBe(0);

    // No file should be created
    const f = findTimelineFile();
    expect(f).toBeNull();
  });

  test('injects timestamp when ts field is missing', () => {
    const input = '{"skill":"review","event":"started","branch":"main"}';
    runLog(input);

    const f = findTimelineFile();
    expect(f).not.toBeNull();
    const parsed = JSON.parse(fs.readFileSync(f!, 'utf-8').trim());
    expect(parsed.ts).toBeDefined();
    expect(new Date(parsed.ts).getTime()).toBeGreaterThan(0);
  });

  test('preserves timestamp when ts field is present', () => {
    const input = '{"skill":"review","event":"completed","branch":"main","ts":"2025-06-15T10:00:00Z"}';
    runLog(input);

    const f = findTimelineFile();
    expect(f).not.toBeNull();
    const parsed = JSON.parse(fs.readFileSync(f!, 'utf-8').trim());
    expect(parsed.ts).toBe('2025-06-15T10:00:00Z');
  });

  test('validates required fields (skill, event) - exits 0 if missing skill', () => {
    const result = runLog('{"event":"started","branch":"main"}');
    expect(result.exitCode).toBe(0);

    const f = findTimelineFile();
    expect(f).toBeNull();
  });

  test('validates required fields (skill, event) - exits 0 if missing event', () => {
    const result = runLog('{"skill":"review","branch":"main"}');
    expect(result.exitCode).toBe(0);

    const f = findTimelineFile();
    expect(f).toBeNull();
  });
});

describe('gstack-timeline-read', () => {
  test('returns empty output for missing file (exit 0)', () => {
    const output = runRead();
    expect(output).toBe('');
  });

  test('filters by --branch', () => {
    runLog(JSON.stringify({ skill: 'review', event: 'completed', branch: 'feature-a', outcome: 'approved', ts: '2026-03-28T10:00:00Z' }));
    runLog(JSON.stringify({ skill: 'ship', event: 'completed', branch: 'feature-b', outcome: 'merged', ts: '2026-03-28T11:00:00Z' }));

    const output = runRead('--branch feature-a');
    expect(output).toContain('review');
    expect(output).not.toContain('feature-b');
  });

  test('limits output with --limit', () => {
    for (let i = 0; i < 5; i++) {
      runLog(JSON.stringify({ skill: 'review', event: 'completed', branch: 'main', outcome: 'approved', ts: `2026-03-2${i}T10:00:00Z` }));
    }

    const unlimited = runRead('--limit 20');
    const limited = runRead('--limit 2');

    // Count event lines (lines starting with "- ")
    const unlimitedEvents = unlimited.split('\n').filter(l => l.startsWith('- ')).length;
    const limitedEvents = limited.split('\n').filter(l => l.startsWith('- ')).length;

    expect(unlimitedEvents).toBe(5);
    expect(limitedEvents).toBe(2);
  });
});