* refactor(sdk): extract gsdtools transport seam with per-command policy * refactor(query): centralize registry command catalog wiring * refactor(query): unify command resolution seam across sdk callers * fix(sdk): address CodeRabbit transport policy and timeout findings * refactor(query): extract mutation event mapper seam * refactor(query): converge mutation and transport policy data * refactor(query): share fallback orchestration across cli and sdk * refactor(query): split static registry catalog by domain clusters * refactor(query): extract mutation event emission decorator seam * refactor(query): extract alias-family handler catalog module * refactor(query): extract cjs fallback execution adapter * refactor(query): deepen command semantics seam * refactor(query): extract deep dispatch seam * refactor(query): deepen cjs fallback execution seam * refactor(query): merge routing plan into dispatch seam * fix(query): address CodeRabbit review findings on PR #3060 Critical: prevent double-execution race by checking timeout errors before subprocess fallback (gsd-transport.ts). Major: fix execRaw() to respect transport policy outputMode instead of hardcoding 'raw' (gsd-tools.ts). Major: add explicit 30s timeout to subprocess fallback execution (query-fallback-executor.ts). Major: remove raw args from stderr banner to prevent secret leakage (query-fallback-executor.ts). Minor: ensure native text output has trailing newline for CLI parity (query-dispatch.ts). Update gsd-tools.test.ts to match new execRaw() behavior. * fix(tests): update CLI integration tests for catalog-based registration The refactoring moved handler registration from inline registry.register() calls to catalog-based registration (registerStaticCatalog/registerAliasCatalog). - gsd-sdk-query-registry-integration.test.cjs: collectRegisteredNames() now also scans catalog files for handler names registered via the new system. - bug-2492-context-coverage-gate.test.cjs: checks for catalog-based registration (DECISION_ROUTING_STATIC_CATALOG) instead of inline strings. - bug-2524-sdk-query-ws-flag.test.cjs: checks for dispatchNative callback pattern instead of direct registry.dispatch() call. * fix(query): address remaining CodeRabbit review findings - query-command-semantics.ts: guard stats/progress rewrite so option tokens (e.g. --pick) are not turned into subcommands, preserving the top-level handler dispatch. - query-dispatch.ts: formatOutput now skips --pick for text-format responses (matching CJS fallback behavior) and surfaces a proper error when extractField returns undefined instead of silently producing 'undefined'. - query-dispatch.ts: fix backwards error message — 'registered' is the restrictive policy that disables fallback, not enables it. - tests/bug-2492-context-coverage-gate.test.cjs: check VERIFY_DECISION_STATIC_CATALOG (the correct catalog for plan-gate handlers) instead of DECISION_ROUTING_STATIC_CATALOG. - tests/gsd-sdk-query-registry-integration.test.cjs: resolve catalog variable before loading entries so the drift guard checks each referenced catalog individually. * refactor(query): deepen registry assembly module with strict invariants - extract registry assembly into dedicated module - split build vs mutation decoration internals - add strict assembly invariants: 1) no duplicate keys 2) alias canonicals must have handlers 3) mutation commands must be registered 4) raw-output policy commands must be registered - slim query index to thin re-export seam - add focused registry assembly tests - update drift-guard tests to target new seam * test(query): add thin-seam coverage for query index re-exports * fix(query): return structured native dispatch errors + tighten decisions.parse guard - runQueryDispatch native path now catches adapter errors and returns QueryDispatchResult.error instead of throwing. - preserve legacy CLI exit contract by using code=1 for native dispatch failures. - strengthen bug-2492 guard: decisions.parse assertion now checks VERIFY_DECISION_STATIC_CATALOG OR explicit command token.
209 lines
7.5 KiB
TypeScript
209 lines
7.5 KiB
TypeScript
/**
|
|
* Unit tests for QueryRegistry, extractField, and createRegistry factory.
|
|
*/
|
|
|
|
import { describe, it, expect, vi } from 'vitest';
|
|
import { QueryRegistry, extractField, resolveQueryArgv } from './registry.js';
|
|
import { createRegistry, QUERY_MUTATION_COMMANDS } from './index.js';
|
|
import type { QueryResult } from './utils.js';
|
|
|
|
// ─── extractField ──────────────────────────────────────────────────────────
|
|
|
|
describe('extractField', () => {
|
|
it('extracts nested value with dot notation', () => {
|
|
expect(extractField({ a: { b: 1 } }, 'a.b')).toBe(1);
|
|
});
|
|
|
|
it('extracts top-level value', () => {
|
|
expect(extractField({ slug: 'my-phase' }, 'slug')).toBe('my-phase');
|
|
});
|
|
|
|
it('extracts array element with bracket notation', () => {
|
|
expect(extractField({ items: [10, 20, 30] }, 'items[1]')).toBe(20);
|
|
});
|
|
|
|
it('extracts array element with negative index', () => {
|
|
expect(extractField({ items: [10, 20, 30] }, 'items[-1]')).toBe(30);
|
|
});
|
|
|
|
it('returns undefined for null input', () => {
|
|
expect(extractField(null, 'a')).toBeUndefined();
|
|
});
|
|
|
|
it('returns undefined for undefined input', () => {
|
|
expect(extractField(undefined, 'a')).toBeUndefined();
|
|
});
|
|
|
|
it('returns undefined for missing nested path', () => {
|
|
expect(extractField({ a: 1 }, 'b.c')).toBeUndefined();
|
|
});
|
|
|
|
it('returns undefined when bracket access targets non-array', () => {
|
|
expect(extractField({ items: 'not-array' }, 'items[0]')).toBeUndefined();
|
|
});
|
|
|
|
it('handles deeply nested paths', () => {
|
|
expect(extractField({ a: { b: { c: { d: 42 } } } }, 'a.b.c.d')).toBe(42);
|
|
});
|
|
|
|
it('handles mixed dot and bracket notation', () => {
|
|
expect(extractField({ data: { items: [{ name: 'x' }] } }, 'data.items[0].name')).toBe('x');
|
|
});
|
|
});
|
|
|
|
// ─── QueryRegistry ─────────────────────────────────────────────────────────
|
|
|
|
describe('QueryRegistry', () => {
|
|
it('register makes has() return true', () => {
|
|
const registry = new QueryRegistry();
|
|
const handler = async () => ({ data: 'test' });
|
|
registry.register('test-cmd', handler);
|
|
|
|
expect(registry.has('test-cmd')).toBe(true);
|
|
});
|
|
|
|
it('has() returns false for unregistered command', () => {
|
|
const registry = new QueryRegistry();
|
|
|
|
expect(registry.has('nonexistent')).toBe(false);
|
|
});
|
|
|
|
it('dispatch calls registered handler', async () => {
|
|
const registry = new QueryRegistry();
|
|
const handler = vi.fn(async (args: string[], _projectDir: string): Promise<QueryResult> => {
|
|
return { data: { value: args[0] } };
|
|
});
|
|
registry.register('test-cmd', handler);
|
|
|
|
const result = await registry.dispatch('test-cmd', ['arg1'], '/tmp');
|
|
|
|
expect(handler).toHaveBeenCalledWith(['arg1'], '/tmp', undefined);
|
|
expect(result).toEqual({ data: { value: 'arg1' } });
|
|
});
|
|
|
|
it('dispatch throws GSDError for unregistered command', async () => {
|
|
const registry = new QueryRegistry();
|
|
// Bridge removed in v3.0 — unknown commands throw, not fallback
|
|
await expect(registry.dispatch('unknown-cmd', ['arg1'], '/tmp/project'))
|
|
.rejects.toThrow('Unknown command: "unknown-cmd"');
|
|
});
|
|
|
|
it('commands() returns all registered command names', () => {
|
|
const registry = new QueryRegistry();
|
|
registry.register('alpha', async () => ({ data: 1 }));
|
|
registry.register('beta', async () => ({ data: 2 }));
|
|
expect(registry.commands().sort()).toEqual(['alpha', 'beta']);
|
|
});
|
|
});
|
|
|
|
// ─── QUERY_MUTATION_COMMANDS vs registry ───────────────────────────────────
|
|
|
|
describe('QUERY_MUTATION_COMMANDS', () => {
|
|
it('has a registered handler for every mutation command name', () => {
|
|
const registry = createRegistry();
|
|
const missing: string[] = [];
|
|
for (const cmd of QUERY_MUTATION_COMMANDS) {
|
|
if (!registry.has(cmd)) missing.push(cmd);
|
|
}
|
|
expect(missing).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ─── createRegistry ────────────────────────────────────────────────────────
|
|
|
|
describe('createRegistry', () => {
|
|
it('returns a QueryRegistry instance', () => {
|
|
const registry = createRegistry();
|
|
|
|
expect(registry).toBeInstanceOf(QueryRegistry);
|
|
});
|
|
|
|
it('has generate-slug registered', () => {
|
|
const registry = createRegistry();
|
|
|
|
expect(registry.has('generate-slug')).toBe(true);
|
|
});
|
|
|
|
it('has current-timestamp registered', () => {
|
|
const registry = createRegistry();
|
|
|
|
expect(registry.has('current-timestamp')).toBe(true);
|
|
});
|
|
|
|
it('has summary-extract dash alias (PR #2179 / workflows)', () => {
|
|
const registry = createRegistry();
|
|
expect(registry.has('summary-extract')).toBe(true);
|
|
});
|
|
|
|
it('can dispatch generate-slug', async () => {
|
|
const registry = createRegistry();
|
|
const result = await registry.dispatch('generate-slug', ['My Phase'], '/tmp');
|
|
|
|
expect(result).toEqual({ data: { slug: 'my-phase' } });
|
|
});
|
|
});
|
|
|
|
// ─── resolveQueryArgv ───────────────────────────────────────────────────────
|
|
|
|
describe('resolveQueryArgv', () => {
|
|
it('matches longest dotted prefix (state.update + args)', () => {
|
|
const registry = createRegistry();
|
|
const m = resolveQueryArgv(['state', 'update', 'status', 'X'], registry);
|
|
expect(m).toEqual({ cmd: 'state.update', args: ['status', 'X'] });
|
|
});
|
|
|
|
it('matches longest prefix (phase.add wins over phase when both registered)', () => {
|
|
const registry = createRegistry();
|
|
const m = resolveQueryArgv(['phase', 'add', 'desc'], registry);
|
|
expect(m).toEqual({ cmd: 'phase.add', args: ['desc'] });
|
|
});
|
|
|
|
it('prefers longer match over shorter', () => {
|
|
const registry = createRegistry();
|
|
const m = resolveQueryArgv(['state', 'load'], registry);
|
|
expect(m?.cmd).toBe('state.load');
|
|
expect(m?.args).toEqual([]);
|
|
});
|
|
|
|
it('returns null when no prefix matches', () => {
|
|
const registry = createRegistry();
|
|
const m = resolveQueryArgv(['totally-unknown', 'x'], registry);
|
|
expect(m).toBeNull();
|
|
});
|
|
|
|
it('matches a single dotted command token', () => {
|
|
const registry = createRegistry();
|
|
expect(resolveQueryArgv(['init.new-project'], registry)).toEqual({
|
|
cmd: 'init.new-project',
|
|
args: [],
|
|
});
|
|
});
|
|
|
|
// Regression: #2597 — dotted command token followed by positional args.
|
|
// Before the fix, argv like ['init.execute-phase', '1'] returned null because
|
|
// expansion only ran for single-token input.
|
|
it('matches a dotted command token when positional args follow (#2597)', () => {
|
|
const registry = createRegistry();
|
|
expect(resolveQueryArgv(['init.execute-phase', '1'], registry)).toEqual({
|
|
cmd: 'init.execute-phase',
|
|
args: ['1'],
|
|
});
|
|
});
|
|
|
|
it('matches dotted state.update with trailing args (#2597)', () => {
|
|
const registry = createRegistry();
|
|
expect(resolveQueryArgv(['state.update', 'status', 'X'], registry)).toEqual({
|
|
cmd: 'state.update',
|
|
args: ['status', 'X'],
|
|
});
|
|
});
|
|
|
|
it('matches dotted phase.add with trailing args (#2597)', () => {
|
|
const registry = createRegistry();
|
|
expect(resolveQueryArgv(['phase.add', 'desc'], registry)).toEqual({
|
|
cmd: 'phase.add',
|
|
args: ['desc'],
|
|
});
|
|
});
|
|
});
|