mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
Co-Authored-By: Time Attakc <89218912+time-attack@users.noreply.github.com>
228 lines
7.6 KiB
TypeScript
228 lines
7.6 KiB
TypeScript
/**
|
|
* Tests for the heartbeat_max_age health-check type (#2787) and the
|
|
* output_paths recipe frontmatter + configured-collector helper (#2788).
|
|
* Heartbeat files live under a temp GBRAIN_HOME so nothing touches ~/.gbrain.
|
|
*/
|
|
|
|
import { describe, test, expect } from 'bun:test';
|
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { withEnv } from './helpers/with-env.ts';
|
|
import {
|
|
parseMaxAge,
|
|
executeHealthCheck,
|
|
parseRecipe,
|
|
getConfiguredCollectorOutputs,
|
|
} from '../src/commands/integrations.ts';
|
|
|
|
function tempHome(): string {
|
|
return mkdtempSync(join(tmpdir(), 'gbrain-hb-'));
|
|
}
|
|
|
|
function writeHeartbeat(home: string, id: string, entries: Array<{ ts: string; event: string; status: string }>): void {
|
|
const dir = join(home, '.gbrain', 'integrations', id);
|
|
mkdirSync(dir, { recursive: true });
|
|
writeFileSync(join(dir, 'heartbeat.jsonl'), entries.map(e => JSON.stringify(e)).join('\n') + '\n');
|
|
}
|
|
|
|
describe('parseMaxAge', () => {
|
|
test('parses h/d/m/s durations', () => {
|
|
expect(parseMaxAge('48h')).toBe(48 * 3_600_000);
|
|
expect(parseMaxAge('2d')).toBe(2 * 86_400_000);
|
|
expect(parseMaxAge('90m')).toBe(90 * 60_000);
|
|
expect(parseMaxAge('30s')).toBe(30_000);
|
|
expect(parseMaxAge(' 48H ')).toBe(48 * 3_600_000);
|
|
});
|
|
|
|
test('rejects garbage', () => {
|
|
expect(parseMaxAge('abc')).toBeNull();
|
|
expect(parseMaxAge('48')).toBeNull();
|
|
expect(parseMaxAge('-3h')).toBeNull();
|
|
expect(parseMaxAge('')).toBeNull();
|
|
expect(parseMaxAge('0h')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('heartbeat_max_age health check (#2787)', () => {
|
|
test('fresh heartbeat within max_age → ok', async () => {
|
|
const home = tempHome();
|
|
try {
|
|
writeHeartbeat(home, 'calendar-to-brain', [
|
|
{ ts: new Date(Date.now() - 3_600_000).toISOString(), event: 'sync', status: 'ok' },
|
|
]);
|
|
await withEnv({ GBRAIN_HOME: home }, async () => {
|
|
const r = await executeHealthCheck(
|
|
{ type: 'heartbeat_max_age', max_age: '48h', label: 'freshness' } as any,
|
|
'calendar-to-brain',
|
|
true,
|
|
);
|
|
expect(r.status).toBe('ok');
|
|
expect(r.output).toContain('within 48h');
|
|
});
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('16-day-stale sense FAILS (the #2787 silent-death receipt)', async () => {
|
|
const home = tempHome();
|
|
try {
|
|
writeHeartbeat(home, 'calendar-to-brain', [
|
|
{ ts: new Date(Date.now() - 16 * 86_400_000).toISOString(), event: 'sync', status: 'ok' },
|
|
]);
|
|
await withEnv({ GBRAIN_HOME: home }, async () => {
|
|
const r = await executeHealthCheck(
|
|
{ type: 'heartbeat_max_age', max_age: '48h' } as any,
|
|
'calendar-to-brain',
|
|
true,
|
|
);
|
|
expect(r.status).toBe('fail');
|
|
expect(r.output).toContain('exceeds max_age 48h');
|
|
expect(r.output).toContain('stopped producing data');
|
|
});
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('no heartbeat data at all → fail', async () => {
|
|
const home = tempHome();
|
|
try {
|
|
await withEnv({ GBRAIN_HOME: home }, async () => {
|
|
const r = await executeHealthCheck(
|
|
{ type: 'heartbeat_max_age', max_age: '48h' } as any,
|
|
'never-ran',
|
|
true,
|
|
);
|
|
expect(r.status).toBe('fail');
|
|
expect(r.output).toContain('no heartbeat events');
|
|
});
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('invalid max_age → fail with guidance, not a crash', async () => {
|
|
const r = await executeHealthCheck(
|
|
{ type: 'heartbeat_max_age', max_age: 'soon' } as any,
|
|
'whatever',
|
|
true,
|
|
);
|
|
expect(r.status).toBe('fail');
|
|
expect(r.output).toContain("invalid max_age 'soon'");
|
|
});
|
|
|
|
test('not gated on embedded trust (read-only local file)', async () => {
|
|
const home = tempHome();
|
|
try {
|
|
writeHeartbeat(home, 'user-recipe', [
|
|
{ ts: new Date().toISOString(), event: 'sync', status: 'ok' },
|
|
]);
|
|
await withEnv({ GBRAIN_HOME: home }, async () => {
|
|
const r = await executeHealthCheck(
|
|
{ type: 'heartbeat_max_age', max_age: '1d' } as any,
|
|
'user-recipe',
|
|
false, // NOT embedded
|
|
);
|
|
expect(r.status).toBe('ok');
|
|
});
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('newest entry wins even when the file is not time-ordered', async () => {
|
|
const home = tempHome();
|
|
try {
|
|
writeHeartbeat(home, 'unordered', [
|
|
{ ts: new Date(Date.now() - 60_000).toISOString(), event: 'sync', status: 'ok' },
|
|
{ ts: new Date(Date.now() - 20 * 86_400_000).toISOString(), event: 'sync', status: 'ok' },
|
|
]);
|
|
await withEnv({ GBRAIN_HOME: home }, async () => {
|
|
const r = await executeHealthCheck(
|
|
{ type: 'heartbeat_max_age', max_age: '48h' } as any,
|
|
'unordered',
|
|
true,
|
|
);
|
|
expect(r.status).toBe('ok');
|
|
});
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('works inside any_of', async () => {
|
|
const home = tempHome();
|
|
try {
|
|
writeHeartbeat(home, 'combo', [
|
|
{ ts: new Date().toISOString(), event: 'sync', status: 'ok' },
|
|
]);
|
|
await withEnv({ GBRAIN_HOME: home }, async () => {
|
|
const r = await executeHealthCheck(
|
|
{ type: 'any_of', checks: [{ type: 'heartbeat_max_age', max_age: '1h' }] } as any,
|
|
'combo',
|
|
true,
|
|
);
|
|
expect(r.status).toBe('ok');
|
|
});
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('output_paths frontmatter + configured-collector outputs (#2788)', () => {
|
|
const RECIPE = `---
|
|
id: test-collector
|
|
name: Test Collector
|
|
version: 0.1.0
|
|
description: writes files
|
|
category: sense
|
|
health_checks: []
|
|
output_paths:
|
|
- daily/test-collector/
|
|
setup_time: 1 min
|
|
---
|
|
Body.
|
|
`;
|
|
|
|
test('parseRecipe surfaces output_paths (and defaults to [])', () => {
|
|
const parsed = parseRecipe(RECIPE, 'test-collector.md');
|
|
expect(parsed).not.toBeNull();
|
|
expect(parsed!.frontmatter.output_paths).toEqual(['daily/test-collector/']);
|
|
const bare = parseRecipe('---\nid: bare\n---\nBody.', 'bare.md');
|
|
expect(bare!.frontmatter.output_paths).toEqual([]);
|
|
});
|
|
|
|
test('the shipped calendar-to-brain recipe declares heartbeat_max_age + output_paths', () => {
|
|
const content = require('node:fs').readFileSync(
|
|
join(import.meta.dir, '..', 'recipes', 'calendar-to-brain.md'),
|
|
'utf-8',
|
|
);
|
|
const parsed = parseRecipe(content, 'calendar-to-brain.md');
|
|
expect(parsed).not.toBeNull();
|
|
expect(parsed!.frontmatter.output_paths).toEqual(['daily/calendar/']);
|
|
const hb = parsed!.frontmatter.health_checks.find(
|
|
(c: any) => typeof c === 'object' && c.type === 'heartbeat_max_age',
|
|
) as any;
|
|
expect(hb).toBeDefined();
|
|
expect(hb.max_age).toBe('48h');
|
|
});
|
|
|
|
test('getConfiguredCollectorOutputs includes secretless recipes with output_paths', async () => {
|
|
const home = tempHome();
|
|
const recipesDir = mkdtempSync(join(tmpdir(), 'gbrain-recipes-'));
|
|
try {
|
|
writeFileSync(join(recipesDir, 'test-collector.md'), RECIPE);
|
|
await withEnv({ GBRAIN_HOME: home, GBRAIN_RECIPES_DIR: recipesDir }, async () => {
|
|
const outputs = getConfiguredCollectorOutputs();
|
|
expect(outputs).toContainEqual({ id: 'test-collector', output_path: 'daily/test-collector/' });
|
|
});
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
rmSync(recipesDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|