mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 19:49:14 +00:00
* fix(recipes/x-to-brain): use /users/by/username for app-only bearer health check Takeover of #2343. /users/me requires user-context OAuth and always fails under the app-only bearer the recipe collects. Health check + setup curls now use /users/by/username/$X_HANDLE, with X_HANDLE declared in secrets so the installer prompts for it. Recipe version 0.8.1 -> 0.8.2. Co-authored-by: ethanbeard <ethanbeard@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(cycle): bound propose_takes with per-call timeout + phase deadline Takeover of #2262. The extractor's gateway.chat call had no abortSignal, so one stalled provider socket could pin the phase for the 300s gateway default per page; the nightly wrapper then SIGTERMed the whole phase mid-run. Each extractor call is now bounded at 90s (per-page failure already logs a warning and continues), and the page loop carries a 30-min wall-clock deadline that breaks cleanly into a partial result with deadline_hit:true + warn status. Unlike the original PR, the default pageLimit stays at 100 — shrinking it to 30 was an unrelated product-knob change that would permanently cut nightly take coverage. Co-authored-by: tschew72 <tschew72@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(capture): make fallback title truncation explicit and astral-safe Takeover of #2310. deriveTitle's silent .slice(0, 80) could split an astral surrogate pair mid-character and gave no signal the title was cut. Truncation is now codepoint-aware and appends an ellipsis (still capped at 80 codepoints). Unlike the original PR, this stays a three-line change: no whitespace normalization of every derived title, no word-boundary heuristics. Co-authored-by: xd-Neji <xd-Neji@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(doctor): clear extract_atoms raw source-holder backlog + normalize pooler direct-URL overrides Takeover of #2242, split to the two concerns that survive review: - extract_atoms: exclude source pages whose frontmatter declares a raw payload pointer from discovery AND the doctor backlog count (shared SQL fragment so they can't drift). Extraction on these yields zero atoms, so no atom row is ever written and they re-enter the backlog every cycle — a permanent no-progress doctor blocker. - connection-manager: a direct-URL override (opts/env) that still points at the Supavisor TRANSACTION pooler (port 6543, usually a copy-paste of the primary URL) is normalized to the real direct host via deriveDirectUrl. Session-mode pooler overrides (port 5432) pass through — they are a legitimate direct-ish target, which the original PR would have nulled out. Dropped from the original PR: orphan-reporting atom exclusions (master already excludes atoms/ and raw/ first segments plus /raw/ segments in src/commands/orphans.ts) and the drain dry-run status tweak. Co-authored-by: benjonp <benjonp@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(cycle): record propose_takes deadline break as a halt in the extract rollup A deadline-hit run breaks the page loop mid-list — same posture as budget exhaustion — but the rollup still counted it as a completed round with no halt, hiding chronic never-finishing nightly runs from extract-status/ doctor. Treat deadline_hit like budget_exhausted in the rollup deltas; deadline test now pins halt=1 / completed=0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Garry Tan <garrytan@gmail.com> Co-authored-by: ethanbeard <ethanbeard@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: tschew72 <tschew72@users.noreply.github.com> Co-authored-by: xd-Neji <xd-Neji@users.noreply.github.com> Co-authored-by: benjonp <benjonp@users.noreply.github.com>
240 lines
8.9 KiB
TypeScript
240 lines
8.9 KiB
TypeScript
/**
|
|
* gbrain capture CLI verb tests.
|
|
*
|
|
* Pure helper coverage (parseArgs, defaultSlug, buildContent) + an
|
|
* integration smoke that the local-install path lands a page when the
|
|
* engine is connected. Thin-client routing isn't exercised here (that's
|
|
* an e2e concern; the local path uses the same put_page operation and
|
|
* gets the same write-through plumbing).
|
|
*/
|
|
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import * as os from 'node:os';
|
|
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
|
|
import { resetPgliteState } from '../helpers/reset-pglite.ts';
|
|
import matter from 'gray-matter';
|
|
import { runCapture, __testing } from '../../src/commands/capture.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
let tmpRoot: string;
|
|
let brainDir: string;
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await resetPgliteState(engine);
|
|
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-capture-'));
|
|
brainDir = path.join(tmpRoot, 'brain');
|
|
fs.mkdirSync(brainDir, { recursive: true });
|
|
await engine.setConfig('sync.repo_path', brainDir);
|
|
});
|
|
|
|
describe('capture — defaultSlug helper', () => {
|
|
test('builds inbox/YYYY-MM-DD-<hash8> shape', () => {
|
|
const slug = __testing.defaultSlug('hello world', new Date('2026-05-20T12:00:00Z'));
|
|
expect(slug).toMatch(/^inbox\/2026-05-20-[a-f0-9]{8}$/);
|
|
});
|
|
|
|
test('deterministic for same content', () => {
|
|
const date = new Date('2026-05-20T00:00:00Z');
|
|
expect(__testing.defaultSlug('thought A', date)).toBe(__testing.defaultSlug('thought A', date));
|
|
});
|
|
|
|
test('different content -> different slug', () => {
|
|
const date = new Date('2026-05-20T00:00:00Z');
|
|
expect(__testing.defaultSlug('thought A', date)).not.toBe(__testing.defaultSlug('thought B', date));
|
|
});
|
|
|
|
test('UTC math (no tz drift)', () => {
|
|
const slug = __testing.defaultSlug('x', new Date('2026-01-05T23:59:59Z'));
|
|
expect(slug).toMatch(/^inbox\/2026-01-05-/);
|
|
});
|
|
});
|
|
|
|
describe('capture — parseArgs', () => {
|
|
test('inline content goes into opts.content', () => {
|
|
const r = __testing.parseArgs(['hello world']);
|
|
expect('help' in r).toBe(false);
|
|
if (!('help' in r)) expect(r.content).toBe('hello world');
|
|
});
|
|
|
|
test('multi-token inline content is joined with spaces', () => {
|
|
const r = __testing.parseArgs(['some', 'tokens', 'here']);
|
|
if (!('help' in r)) expect(r.content).toBe('some tokens here');
|
|
});
|
|
|
|
test('flags are extracted out of positional', () => {
|
|
const r = __testing.parseArgs(['--slug', 'inbox/x', 'the', 'thought']);
|
|
if (!('help' in r)) {
|
|
expect(r.slug).toBe('inbox/x');
|
|
expect(r.content).toBe('the thought');
|
|
}
|
|
});
|
|
|
|
test('--file is captured', () => {
|
|
const r = __testing.parseArgs(['--file', '/tmp/note.md']);
|
|
if (!('help' in r)) expect(r.filePath).toBe('/tmp/note.md');
|
|
});
|
|
|
|
test('--stdin flag', () => {
|
|
const r = __testing.parseArgs(['--stdin']);
|
|
if (!('help' in r)) expect(r.stdin).toBe(true);
|
|
});
|
|
|
|
test('--quiet, --json, --help', () => {
|
|
const r1 = __testing.parseArgs(['--quiet']);
|
|
if (!('help' in r1)) expect(r1.quiet).toBe(true);
|
|
const r2 = __testing.parseArgs(['--json']);
|
|
if (!('help' in r2)) expect(r2.json).toBe(true);
|
|
const r3 = __testing.parseArgs(['--help']);
|
|
expect('help' in r3).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('capture — buildContent', () => {
|
|
test('wraps plain prose in frontmatter + heading', () => {
|
|
const result = __testing.buildContent('a simple thought', {});
|
|
expect(result).toContain('---');
|
|
expect(result).toContain('type: note');
|
|
expect(result).toContain('captured_via: capture-cli');
|
|
expect(result).toContain('# a simple thought');
|
|
});
|
|
|
|
test('honors --type override', () => {
|
|
const result = __testing.buildContent('body', { type: 'idea' });
|
|
expect(result).toContain('type: idea');
|
|
});
|
|
|
|
test('does not double-wrap content that already has frontmatter', () => {
|
|
const result = __testing.buildContent('---\ntitle: pre-wrapped\n---\n\nbody', {});
|
|
// The result has our captured-via frontmatter PLUS the body that
|
|
// included its own frontmatter (raw). Importantly we don't prepend a
|
|
// second `# ...` heading.
|
|
const headingCount = (result.match(/^# /gm) ?? []).length;
|
|
expect(headingCount).toBe(0);
|
|
});
|
|
|
|
test('uses first non-empty line as the title', () => {
|
|
// v0.39.3.0: BUG-1 fix moved buildContent to mergeCaptureFrontmatter
|
|
// which uses gray-matter's `matter.stringify()`. The emitter follows
|
|
// YAML defaults — simple strings emit unquoted (`title: Real first
|
|
// line`), strings with special chars get single-quoted (the prior
|
|
// hand-rolled `title: "..."` JSON-style quoting is gone). Parse the
|
|
// YAML and assert on the value, not the literal quoting style.
|
|
const result = __testing.buildContent('\n \nReal first line\nmore', {});
|
|
const parsed = matter(result);
|
|
expect(parsed.data.title).toBe('Real first line');
|
|
});
|
|
|
|
test('caps title at 80 chars with explicit ellipsis', () => {
|
|
const longLine = 'x'.repeat(200);
|
|
const result = __testing.buildContent(longLine, {});
|
|
const parsed = matter(result);
|
|
expect(typeof parsed.data.title).toBe('string');
|
|
expect((parsed.data.title as string).length).toBeLessThanOrEqual(80);
|
|
expect((parsed.data.title as string).endsWith('…')).toBe(true);
|
|
});
|
|
|
|
test('honors --source via captured_via', () => {
|
|
const result = __testing.buildContent('body', { source: 'voice-whisper' });
|
|
expect(result).toContain('captured_via: voice-whisper');
|
|
});
|
|
});
|
|
|
|
describe('capture — local install integration', () => {
|
|
test('inline content lands as a page + a file on disk', async () => {
|
|
// Capture writes to stdout/stderr; redirect those to nullsinks so the
|
|
// test runner doesn't show the receipt block.
|
|
const logCaptured: string[] = [];
|
|
const origLog = console.log;
|
|
console.log = (...args: unknown[]) => logCaptured.push(args.map(String).join(' '));
|
|
try {
|
|
await runCapture(engine, ['--slug', 'inbox/test-capture-1', '--quiet', 'A captured thought']);
|
|
} finally {
|
|
console.log = origLog;
|
|
}
|
|
|
|
// --quiet should have printed just the slug.
|
|
expect(logCaptured.length).toBeGreaterThan(0);
|
|
expect(logCaptured[0]).toBe('inbox/test-capture-1');
|
|
|
|
// Page exists in the DB.
|
|
const page = await engine.getPage('inbox/test-capture-1');
|
|
expect(page).not.toBeNull();
|
|
expect(page?.compiled_truth).toContain('A captured thought');
|
|
|
|
// File written to disk via write-through.
|
|
const onDisk = path.join(brainDir, 'inbox/test-capture-1.md');
|
|
expect(fs.existsSync(onDisk)).toBe(true);
|
|
});
|
|
|
|
test('default slug is inbox/YYYY-MM-DD-<hash8> when --slug not provided', async () => {
|
|
const logCaptured: string[] = [];
|
|
const origLog = console.log;
|
|
console.log = (...args: unknown[]) => logCaptured.push(args.map(String).join(' '));
|
|
try {
|
|
await runCapture(engine, ['--quiet', 'auto-slugged']);
|
|
} finally {
|
|
console.log = origLog;
|
|
}
|
|
const printedSlug = logCaptured[0];
|
|
expect(printedSlug).toMatch(/^inbox\/\d{4}-\d{2}-\d{2}-[a-f0-9]{8}$/);
|
|
});
|
|
|
|
test('--file reads content from disk', async () => {
|
|
const file = path.join(tmpRoot, 'note.md');
|
|
fs.writeFileSync(file, '# from a file\n\nbody content here');
|
|
const logCaptured: string[] = [];
|
|
const origLog = console.log;
|
|
console.log = (...args: unknown[]) => logCaptured.push(args.map(String).join(' '));
|
|
try {
|
|
await runCapture(engine, ['--file', file, '--slug', 'inbox/from-file', '--quiet']);
|
|
} finally {
|
|
console.log = origLog;
|
|
}
|
|
const page = await engine.getPage('inbox/from-file');
|
|
expect(page).not.toBeNull();
|
|
expect(page?.compiled_truth).toContain('body content here');
|
|
});
|
|
|
|
test('--json emits structured output', async () => {
|
|
const logCaptured: string[] = [];
|
|
const origLog = console.log;
|
|
console.log = (...args: unknown[]) => logCaptured.push(args.map(String).join(' '));
|
|
try {
|
|
await runCapture(engine, ['--json', '--slug', 'inbox/json-out', 'jsonny']);
|
|
} finally {
|
|
console.log = origLog;
|
|
}
|
|
expect(logCaptured.length).toBeGreaterThan(0);
|
|
const json = JSON.parse(logCaptured.join('\n'));
|
|
expect(json.slug).toBe('inbox/json-out');
|
|
expect(json.content_hash).toMatch(/^[a-f0-9]{64}$/);
|
|
expect(json.captured_at).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
});
|
|
});
|
|
|
|
describe('capture — help', () => {
|
|
test('--help prints usage and returns without engine roundtrip', async () => {
|
|
const logCaptured: string[] = [];
|
|
const origLog = console.log;
|
|
console.log = (...args: unknown[]) => logCaptured.push(args.map(String).join(' '));
|
|
try {
|
|
await runCapture(null, ['--help']);
|
|
} finally {
|
|
console.log = origLog;
|
|
}
|
|
expect(logCaptured.join('\n')).toContain('Usage: gbrain capture');
|
|
});
|
|
});
|