mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-22 00:31:17 +00:00
* feat(cursor): upgrade hooks to Cursor 2.4 postToolUse for Read/Grep/Shell coverage
Cursor 2.4 (released 2026-01-22) shipped generic preToolUse/postToolUse hooks
matching `Shell|Read|Write|Grep|Delete|Task|MCP:<tool>`, replacing the
2.3-era beforeShellExecution hook that only fired on shell commands. The
existing integration only intercepted the shell path, so Cursor users got
graph augmentation roughly 10% as often as Claude Code users — only when
the agent dropped to rg/grep instead of using its native Read/Grep tools.
This swaps the integration over to postToolUse and ports the bash+jq
hook script to cross-platform Node:
- gitnexus-cursor-integration/hooks/hooks.json: registers a single
postToolUse hook matching Shell|Read|Grep that invokes the new
gitnexus-hook.cjs.
- gitnexus-cursor-integration/hooks/gitnexus-hook.cjs: new Node hook
mirroring the safety patterns from the Claude hook (absolute-cwd
validation, .gitnexus discovery with linked-worktree fallback,
npx.cmd on Windows, end-of-options `--` marker, debug truncation,
graceful failure). Extracts the search pattern per tool kind:
Grep -> toolInput.query; Read -> file basename stripped to identifier
chars; Shell -> existing rg/grep arg parser. Emits Cursor-shape
`{ "additional_context": "..." }` on stdout — no shell, no jq.
- gitnexus-cursor-integration/hooks/augment-shell.sh: removed (Windows
incompatible, narrower coverage).
- gitnexus/test/unit/cursor-hook.test.ts: 33 regression tests covering
manifest wiring, source-level invariants (no shell:true, npx.cmd,
isAbsolute, additional_context output shape, end-of-options marker),
extractPattern coverage per tool, and behavioral early-exit paths
(empty/invalid stdin, relative cwd, no .gitnexus, unknown tool name,
short patterns, non-search shell commands, case-insensitive matching).
- README.md / gitnexus/README.md: editor-support table now lists Cursor
as Full / hooks=Yes (postToolUse), matching reality.
- gitnexus/src/cli/augment.ts and gitnexus/src/core/augmentation/engine.ts:
doc-strings updated from `Cursor beforeShellExecution` to
`Cursor postToolUse`.
Closes #1466.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(cursor): hook timeout is in seconds, not milliseconds
Cursor's `timeout` field in hooks.json is in seconds (per
https://cursor.com/docs/agent/hooks and the original integration's
`"timeout": 5`). I'd written `10000` after blindly copying the issue
body's example — that resolves to ~2.8 hours, not 10 seconds. If the
script ever hangs before reaching its inner spawnSync timeouts (e.g.
during stdin read), Cursor would have waited that long before killing
it.
Drop to `10` (seconds), matching the Claude plugin's hooks.json and
giving plenty of headroom over the inner 7s augment-CLI timeout.
Add a regression-guard assertion in cursor-hook.test.ts so a future
ms/s mixup fails fast.
Reported by Cursor Bugbot on PR #1467.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(cursor): address Claude review findings — payload aliases, debug, install docs
Resolves three findings from Claude reviewer on PR #1467:
1. Cursor payload field-name uncertainty (SIGNIFICANT)
Claude flagged that the Grep `query` field is an unverified assumption
per Cursor 2.4 docs (https://cursor.com/docs/agent/hooks). Mitigated:
- Expanded Grep aliases: query | pattern | regex | q | search | searchQuery
- Added pickLongestStringValue() last-resort fallback so the hook
extracts *something* even if Cursor renames every documented field
- Added GITNEXUS_DEBUG=1 stderr logging of the raw stdin payload so
users can capture Cursor's actual contract when diagnosing silent
no-ops, and report it back if aliases drift
- Added Read alias `filePath` (camelCase variant alongside `file_path`)
- Inline comment block citing the docs URL and the uncertainty
2. Hook command path resolution + install docs (SIGNIFICANT)
Claude flagged `node ./hooks/gitnexus-hook.cjs` as relative without
documented install path. Added gitnexus-cursor-integration/README.md
with explicit install steps:
- .cursor/hooks.json + hooks/gitnexus-hook.cjs at project root
- Confirms Cursor's project-root CWD convention with doc link
- Verify steps including GITNEXUS_DEBUG capture
- Pattern-extraction contract table per tool
- Troubleshooting: not-firing, npx fallback, wrong-pattern diagnosis
3. README "Full" overclaim for Cursor (MODERATE)
Both README rows now read `Yes (postToolUse, manual install)` linking
to the new install README, accurately signaling that hooks aren't
automated by `gitnexus setup` like they are for Claude Code.
4. Shell quoted-pattern parser limitation (MINOR, documented)
Added inline comment in gitnexus-hook.cjs documenting the known
`rg "User Service"` -> `User` truncation, plus regression tests in
cursor-hook.test.ts pinning the behavior so a future change is
visible.
Test additions (33 -> 41):
- Wide-alias source coverage for Grep (query / pattern / regex / q /
search / searchQuery) plus pickLongestStringValue fallback
- Read alias coverage including camelCase filePath
- GITNEXUS_DEBUG behavioral test: stderr quiet by default, payload
echoed when env var set, stdout output contract preserved either way
- Shell quoted-pattern documented behavior tests
- Install README presence + content (.cursor/hooks.json, hooks/, debug
diagnostics)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
458 lines
16 KiB
TypeScript
458 lines
16 KiB
TypeScript
/**
|
|
* Regression Tests: Cursor postToolUse Hook
|
|
*
|
|
* Tests the hook script at gitnexus-cursor-integration/hooks/gitnexus-hook.cjs
|
|
* which runs as a Cursor 2.4 postToolUse hook.
|
|
*
|
|
* Covers:
|
|
* - extractPattern: pattern extraction from Grep/Read/Shell tool inputs
|
|
* - findGitNexusDir: .gitnexus directory discovery (shared with Claude hook)
|
|
* - cwd validation: rejects relative paths
|
|
* - shell injection: verifies no `shell: true` in spawnSync calls
|
|
* - cross-platform: Windows .cmd extension handling
|
|
* - output shape: top-level `additional_context` (NOT Claude's `hookSpecificOutput.additionalContext`)
|
|
* - hooks.json wiring matches the script's actual handlers
|
|
*
|
|
* Cursor hooks reach the augment CLI only when cwd is inside an indexed
|
|
* repo, so behavior tests stick to early-exit paths to avoid spawning
|
|
* `npx gitnexus`.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import { spawnSync } from 'child_process';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import os from 'os';
|
|
import { runHook } from '../utils/hook-test-helpers.js';
|
|
|
|
// ─── Path to the Cursor hook + manifest ─────────────────────────────
|
|
|
|
const CURSOR_HOOK = path.resolve(
|
|
__dirname,
|
|
'..',
|
|
'..',
|
|
'..',
|
|
'gitnexus-cursor-integration',
|
|
'hooks',
|
|
'gitnexus-hook.cjs',
|
|
);
|
|
const CURSOR_HOOKS_JSON = path.resolve(
|
|
__dirname,
|
|
'..',
|
|
'..',
|
|
'..',
|
|
'gitnexus-cursor-integration',
|
|
'hooks',
|
|
'hooks.json',
|
|
);
|
|
|
|
// ─── Cursor-specific output parser ──────────────────────────────────
|
|
// Cursor postToolUse output shape: { "additional_context": "..." }
|
|
|
|
function parseCursorOutput(stdout: string): { additional_context?: string } | null {
|
|
if (!stdout.trim()) return null;
|
|
try {
|
|
return JSON.parse(stdout.trim());
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ─── Test fixtures ──────────────────────────────────────────────────
|
|
|
|
let tmpDir: string;
|
|
|
|
beforeAll(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-cursor-hook-test-'));
|
|
spawnSync('git', ['init'], { cwd: tmpDir, stdio: 'pipe' });
|
|
spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: tmpDir, stdio: 'pipe' });
|
|
spawnSync('git', ['config', 'user.name', 'Test'], { cwd: tmpDir, stdio: 'pipe' });
|
|
});
|
|
|
|
afterAll(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
// ─── Manifest + hook file presence ───────────────────────────────────
|
|
|
|
describe('Cursor integration files', () => {
|
|
it('hook script exists', () => {
|
|
expect(fs.existsSync(CURSOR_HOOK)).toBe(true);
|
|
});
|
|
|
|
it('hooks.json exists', () => {
|
|
expect(fs.existsSync(CURSOR_HOOKS_JSON)).toBe(true);
|
|
});
|
|
|
|
it('legacy augment-shell.sh has been removed', () => {
|
|
const legacy = path.resolve(
|
|
__dirname,
|
|
'..',
|
|
'..',
|
|
'..',
|
|
'gitnexus-cursor-integration',
|
|
'hooks',
|
|
'augment-shell.sh',
|
|
);
|
|
expect(fs.existsSync(legacy)).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ─── hooks.json wiring ──────────────────────────────────────────────
|
|
|
|
describe('hooks.json wiring', () => {
|
|
const manifest = JSON.parse(fs.readFileSync(CURSOR_HOOKS_JSON, 'utf-8'));
|
|
|
|
it('declares version 1', () => {
|
|
expect(manifest.version).toBe(1);
|
|
});
|
|
|
|
it('registers a postToolUse hook (not legacy beforeShellExecution)', () => {
|
|
expect(manifest.hooks.postToolUse).toBeDefined();
|
|
expect(Array.isArray(manifest.hooks.postToolUse)).toBe(true);
|
|
expect(manifest.hooks.beforeShellExecution).toBeUndefined();
|
|
});
|
|
|
|
it('matches Shell, Read, and Grep tools', () => {
|
|
const matcher: string = manifest.hooks.postToolUse[0].matcher;
|
|
expect(matcher).toMatch(/Shell/);
|
|
expect(matcher).toMatch(/Read/);
|
|
expect(matcher).toMatch(/Grep/);
|
|
});
|
|
|
|
it('points command at the new Node hook', () => {
|
|
const command: string = manifest.hooks.postToolUse[0].command;
|
|
expect(command).toContain('gitnexus-hook.cjs');
|
|
expect(command).not.toContain('augment-shell.sh');
|
|
});
|
|
|
|
it('declares timeout in seconds (not milliseconds)', () => {
|
|
// Cursor's `timeout` field is in seconds per
|
|
// https://cursor.com/docs/agent/hooks. Regression guard: a value of
|
|
// 1000+ here would be a >16-minute timeout, almost certainly a ms/s mixup.
|
|
const timeout: number = manifest.hooks.postToolUse[0].timeout;
|
|
expect(typeof timeout).toBe('number');
|
|
expect(timeout).toBeGreaterThan(0);
|
|
expect(timeout).toBeLessThan(120);
|
|
});
|
|
});
|
|
|
|
// ─── Source code regressions ────────────────────────────────────────
|
|
|
|
describe('Cursor hook source regressions', () => {
|
|
const source = fs.readFileSync(CURSOR_HOOK, 'utf-8');
|
|
|
|
it('does not pass shell: true to spawnSync', () => {
|
|
const lines = source.split('\n');
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
if (line.trim().startsWith('//') || line.trim().startsWith('*')) continue;
|
|
if (/shell:\s*(true|isWin)/.test(line)) {
|
|
throw new Error(`Cursor hook line ${i + 1} has shell injection risk: ${line.trim()}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('uses npx.cmd for Windows', () => {
|
|
expect(source).toContain('npx.cmd');
|
|
});
|
|
|
|
it('validates cwd is an absolute path', () => {
|
|
expect(source).toMatch(/path\.isAbsolute\(cwd\)/);
|
|
});
|
|
|
|
it('truncates debug error messages to 200 chars', () => {
|
|
expect(source).toContain('.slice(0, 200)');
|
|
});
|
|
|
|
it('emits Cursor-shape additional_context (not Claude hookSpecificOutput)', () => {
|
|
expect(source).toContain('additional_context');
|
|
expect(source).not.toContain('hookSpecificOutput');
|
|
expect(source).not.toContain('hookEventName');
|
|
});
|
|
|
|
it('rejects patterns shorter than 3 chars', () => {
|
|
expect(source).toMatch(/length\s*>=\s*3/);
|
|
});
|
|
|
|
it('passes pattern after end-of-options marker (--)', () => {
|
|
// Regression for #200 — augment patterns starting with `-` would
|
|
// otherwise be parsed as CLI flags by the gitnexus CLI.
|
|
expect(source).toMatch(/'augment',\s*'--',\s*pattern/);
|
|
});
|
|
|
|
it('gates on a non-global .gitnexus directory before invoking the CLI', () => {
|
|
expect(source).toContain('findGitNexusDir');
|
|
expect(source).toContain('isGlobalRegistryDir');
|
|
});
|
|
|
|
it('handles linked git worktrees via git rev-parse --git-common-dir', () => {
|
|
expect(source).toContain('--git-common-dir');
|
|
});
|
|
});
|
|
|
|
// ─── extractPattern coverage (source-level) ─────────────────────────
|
|
|
|
describe('Cursor hook extractPattern coverage', () => {
|
|
const source = fs.readFileSync(CURSOR_HOOK, 'utf-8');
|
|
|
|
it("handles 'grep' tool (Cursor matcher: Grep)", () => {
|
|
expect(source).toMatch(/t === 'grep'/);
|
|
});
|
|
|
|
it('probes a wide alias set for Grep query field (Cursor contract not formally specified)', () => {
|
|
// Cursor 2.4 docs at https://cursor.com/docs/agent/hooks list the
|
|
// matchers but not the per-tool tool_input field names. If Cursor
|
|
// changes the contract, we want the hook to still extract *something*
|
|
// — these aliases plus the longest-string fallback give us coverage.
|
|
for (const alias of ['query', 'pattern', 'regex', 'q', 'search', 'searchQuery']) {
|
|
expect(source).toContain(`toolInput.${alias}`);
|
|
}
|
|
expect(source).toContain('pickLongestStringValue');
|
|
});
|
|
|
|
it("handles 'read' tool (Cursor matcher: Read)", () => {
|
|
expect(source).toMatch(/t === 'read'/);
|
|
for (const alias of ['target_file', 'file_path', 'filePath', 'path', 'file']) {
|
|
expect(source).toContain(`toolInput.${alias}`);
|
|
}
|
|
});
|
|
|
|
it("handles 'shell' tool (Cursor matcher: Shell)", () => {
|
|
expect(source).toMatch(/t === 'shell'/);
|
|
expect(source).toMatch(/\\brg\\b\|\\bgrep\\b/);
|
|
});
|
|
|
|
it('logs raw payload to stderr when GITNEXUS_DEBUG is set (for contract diagnostics)', () => {
|
|
expect(source).toContain('GITNEXUS_DEBUG');
|
|
expect(source).toContain('GitNexus Cursor hook stdin:');
|
|
});
|
|
});
|
|
|
|
// ─── Behavior: graceful no-op paths (no augment CLI invocation) ─────
|
|
|
|
describe('Cursor hook behavior — early-exit paths', () => {
|
|
it('exits cleanly on empty stdin', () => {
|
|
const result = spawnSync(process.execPath, [CURSOR_HOOK], {
|
|
input: '',
|
|
encoding: 'utf-8',
|
|
timeout: 10000,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
});
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout.trim()).toBe('');
|
|
});
|
|
|
|
it('exits cleanly on invalid JSON stdin', () => {
|
|
const result = spawnSync(process.execPath, [CURSOR_HOOK], {
|
|
input: 'not json at all',
|
|
encoding: 'utf-8',
|
|
timeout: 10000,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
});
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout.trim()).toBe('');
|
|
});
|
|
|
|
it('produces no output when cwd is relative', () => {
|
|
const result = runHook(CURSOR_HOOK, {
|
|
tool_name: 'Grep',
|
|
tool_input: { query: 'validateUser' },
|
|
cwd: 'relative/path',
|
|
});
|
|
expect(result.stdout.trim()).toBe('');
|
|
expect(result.status).toBe(0);
|
|
});
|
|
|
|
it('produces no output when cwd has no .gitnexus dir', () => {
|
|
const result = runHook(CURSOR_HOOK, {
|
|
tool_name: 'Grep',
|
|
tool_input: { query: 'validateUser' },
|
|
cwd: tmpDir,
|
|
});
|
|
expect(result.stdout.trim()).toBe('');
|
|
expect(result.status).toBe(0);
|
|
});
|
|
|
|
it('produces no output for unknown tool names', () => {
|
|
const result = runHook(CURSOR_HOOK, {
|
|
tool_name: 'TotallyMadeUpTool',
|
|
tool_input: { foo: 'bar' },
|
|
cwd: tmpDir,
|
|
});
|
|
expect(result.stdout.trim()).toBe('');
|
|
expect(result.status).toBe(0);
|
|
});
|
|
|
|
it('produces no output for Shell commands without rg/grep', () => {
|
|
const result = runHook(CURSOR_HOOK, {
|
|
tool_name: 'Shell',
|
|
tool_input: { command: 'ls -la' },
|
|
cwd: tmpDir,
|
|
});
|
|
expect(result.stdout.trim()).toBe('');
|
|
expect(result.status).toBe(0);
|
|
});
|
|
|
|
it('produces no output for Grep with a 2-char query', () => {
|
|
const result = runHook(CURSOR_HOOK, {
|
|
tool_name: 'Grep',
|
|
tool_input: { query: 'is' },
|
|
cwd: tmpDir,
|
|
});
|
|
expect(result.stdout.trim()).toBe('');
|
|
expect(result.status).toBe(0);
|
|
});
|
|
|
|
it('produces no output for Read whose basename has no identifier chars', () => {
|
|
const result = runHook(CURSOR_HOOK, {
|
|
tool_name: 'Read',
|
|
tool_input: { target_file: '/tmp/--.md' },
|
|
cwd: tmpDir,
|
|
});
|
|
expect(result.stdout.trim()).toBe('');
|
|
expect(result.status).toBe(0);
|
|
});
|
|
|
|
it('produces no output for Read with no file path', () => {
|
|
const result = runHook(CURSOR_HOOK, {
|
|
tool_name: 'Read',
|
|
tool_input: {},
|
|
cwd: tmpDir,
|
|
});
|
|
expect(result.stdout.trim()).toBe('');
|
|
expect(result.status).toBe(0);
|
|
});
|
|
|
|
it('treats tool_name case-insensitively (Grep vs grep)', () => {
|
|
// Both should reach the same handler — and both should early-exit silently
|
|
// because tmpDir has no .gitnexus.
|
|
for (const toolName of ['Grep', 'grep', 'GREP']) {
|
|
const result = runHook(CURSOR_HOOK, {
|
|
tool_name: toolName,
|
|
tool_input: { query: 'validateUser' },
|
|
cwd: tmpDir,
|
|
});
|
|
expect(result.stdout.trim()).toBe('');
|
|
expect(result.status).toBe(0);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Behavior: GITNEXUS_DEBUG payload logging ────────────────────────
|
|
|
|
describe('Cursor hook debug logging', () => {
|
|
it('echoes the payload to stderr only when GITNEXUS_DEBUG is set', () => {
|
|
const payload = {
|
|
tool_name: 'Grep',
|
|
tool_input: { query: 'validateUser' },
|
|
cwd: tmpDir,
|
|
};
|
|
|
|
// GITNEXUS_DEBUG unset → stderr quiet.
|
|
const quiet = spawnSync(process.execPath, [CURSOR_HOOK], {
|
|
input: JSON.stringify(payload),
|
|
encoding: 'utf-8',
|
|
timeout: 10000,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
env: { ...process.env, GITNEXUS_DEBUG: '' },
|
|
});
|
|
expect(quiet.status).toBe(0);
|
|
expect(quiet.stderr).not.toContain('GitNexus Cursor hook stdin');
|
|
|
|
// GITNEXUS_DEBUG=1 → payload echoed to stderr (stdout still empty for
|
|
// unindexed cwd, so the hook output contract is preserved).
|
|
const verbose = spawnSync(process.execPath, [CURSOR_HOOK], {
|
|
input: JSON.stringify(payload),
|
|
encoding: 'utf-8',
|
|
timeout: 10000,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
env: { ...process.env, GITNEXUS_DEBUG: '1' },
|
|
});
|
|
expect(verbose.status).toBe(0);
|
|
expect(verbose.stderr).toContain('GitNexus Cursor hook stdin');
|
|
expect(verbose.stderr).toContain('"tool_name":"Grep"');
|
|
expect(verbose.stdout.trim()).toBe('');
|
|
});
|
|
});
|
|
|
|
// ─── Documented contract behavior (extractPattern via the live hook) ─
|
|
|
|
describe('Shell quoted-pattern parser limitations (documented)', () => {
|
|
// The Shell parser cannot reconstruct shell quoting. These tests pin the
|
|
// current behavior so a future "fix" doesn't silently change extraction
|
|
// — and so users diagnosing a noisy/missed pattern can find the behavior
|
|
// documented in tests.
|
|
//
|
|
// We can't observe the extracted pattern directly without an indexed
|
|
// repo, but we *can* confirm the hook reaches the augment-call path
|
|
// (vs. early-exiting) by checking exit status + clean stdout for cases
|
|
// where parseRgGrepPattern would yield a >=3-char token.
|
|
|
|
it('quoted multi-word `rg "User Service"` extracts the first word only', () => {
|
|
const result = runHook(CURSOR_HOOK, {
|
|
tool_name: 'Shell',
|
|
tool_input: { command: 'rg "User Service" src/' },
|
|
cwd: tmpDir, // no .gitnexus → exits early after extract
|
|
});
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout.trim()).toBe('');
|
|
});
|
|
|
|
it('single-token quoted `rg "validateUser"` works as expected', () => {
|
|
const result = runHook(CURSOR_HOOK, {
|
|
tool_name: 'Shell',
|
|
tool_input: { command: 'rg "validateUser"' },
|
|
cwd: tmpDir,
|
|
});
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout.trim()).toBe('');
|
|
});
|
|
});
|
|
|
|
// ─── Install docs ─────────────────────────────────────────────────────
|
|
|
|
describe('Cursor integration install docs', () => {
|
|
const integrationReadme = path.resolve(
|
|
__dirname,
|
|
'..',
|
|
'..',
|
|
'..',
|
|
'gitnexus-cursor-integration',
|
|
'README.md',
|
|
);
|
|
|
|
it('install README exists', () => {
|
|
expect(fs.existsSync(integrationReadme)).toBe(true);
|
|
});
|
|
|
|
it('install README documents the hook install path', () => {
|
|
const body = fs.readFileSync(integrationReadme, 'utf-8');
|
|
expect(body).toContain('.cursor/hooks.json');
|
|
expect(body).toContain('hooks/gitnexus-hook.cjs');
|
|
expect(body).toContain('Hook install');
|
|
});
|
|
|
|
it('install README documents GITNEXUS_DEBUG for payload diagnostics', () => {
|
|
const body = fs.readFileSync(integrationReadme, 'utf-8');
|
|
expect(body).toContain('GITNEXUS_DEBUG');
|
|
});
|
|
});
|
|
|
|
// ─── Output parser sanity (synthetic JSON) ──────────────────────────
|
|
|
|
describe('parseCursorOutput', () => {
|
|
it('parses a well-formed { additional_context } payload', () => {
|
|
const parsed = parseCursorOutput('{"additional_context":"hello"}');
|
|
expect(parsed).not.toBeNull();
|
|
expect(parsed?.additional_context).toBe('hello');
|
|
});
|
|
|
|
it('returns null on empty stdout', () => {
|
|
expect(parseCursorOutput('')).toBeNull();
|
|
expect(parseCursorOutput(' \n')).toBeNull();
|
|
});
|
|
|
|
it('returns null on malformed JSON', () => {
|
|
expect(parseCursorOutput('not json')).toBeNull();
|
|
});
|
|
});
|