mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-25 08:48:08 +02:00
test: split cli tests from source tree (#216)
* feat(cli): define full warehouse dialect contract
* test(cli): keep dialect edge tests focused
* fix(cli): stabilize dialect contract foundation
* refactor(connectors): own read-only query preparation
* refactor(connectors): resolve dialects through registry
* refactor(connectors): keep concrete dialect classes internal
* chore(workspace): enforce dialect import boundary
* refactor(cli): resolve relationship dialect at scan boundary
* refactor(cli): use dialect display parsing for entity details
* refactor(cli): use dialect display parsing for warehouse catalog
* refactor(cli): use dialect SQL in relationship workflows
* test(cli): verify solid dialect scan workflow closure
* test: split cli tests from source tree
* refactor(cli): standardize BigQuery scope listing
* feat(sqlite): implement connector scope listing
* test(connectors): cover required table listing
* feat(cli): add warehouse driver registry
* refactor(setup): route scope discovery through driver registry
* refactor(cli): route local query execution through driver registry
* refactor(historic-sql): route dialect support through driver registry
* refactor(cli): test warehouse connections through driver registry
* fix(cli): close driver registry type export gaps
* Improve setup daemon diagnostics
* refactor(setup): centralize rail-prefixed diagnostics + query-history fallback
Extract errorMessage, writePrefixedLines, and flushPrefixedBufferedCommandOutput
into clack.ts so the setup wizard, managed daemons, and embedding/agent steps
share one rail-formatted writer. setup-databases.ts also adds a
"disable query history and retry" option when the schema-context build fails
and query history is the likely culprit, surfaced via a new
failed-query-history-unavailable status.
* fix(cli): carry catalog through the picker so BigQuery/Snowflake/SQL Server scope filters match
The setup picker's KtxTableListEntry was a 2-level { schema, name }, so
qualifiedTableId always wrote db.name into enabled_tables. When BigQuery,
Snowflake, or SQL Server later ran fast ingest, their introspect step filtered
the scope set with scopedTableNames(scope, { catalog: projectId|database, db })
— catalog was non-null on the introspect side but null in the scope refs, so
every entry was rejected, the live-database adapter staged zero table files,
and detect() failed with 'Adapter "live-database" did not recognize fetched
source output'.
Align the picker boundary with the canonical 3-level KtxTableRef:
- Add catalog: string | null to KtxTableListEntry.
- BigQuery/Snowflake/SQL Server listTables populate catalog from the
resolved projectId / database; Postgres/MySQL/ClickHouse/SQLite set null.
- qualifiedTableId emits catalog.schema.name when catalog is non-null
(resolveEnabledTables already accepts the 3-part shape) and
schemasFromEnabledTables now goes through parseDottedTableEntry so it
recovers the schema correctly from both 2-part and 3-part entries.
- Export parseDottedTableEntry from enabled-tables.ts (@internal) for picker
reuse.
Update listTables expectations in all seven connector tests and the setup /
picker test fixtures. Add a picker regression test that covers the
catalog-bearing round-trip (save + refine).
* fix(cli): allow debug telemetry under opt-out env
This commit is contained in:
parent
924868841d
commit
56985b7e09
548 changed files with 5048 additions and 2228 deletions
545
packages/cli/test/context/project/config.test.ts
Normal file
545
packages/cli/test/context/project/config.test.ts
Normal file
|
|
@ -0,0 +1,545 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildDefaultKtxProjectConfig,
|
||||
generateKtxProjectConfigJsonSchema,
|
||||
parseKtxProjectConfig,
|
||||
serializeKtxProjectConfig,
|
||||
validateKtxProjectConfig,
|
||||
} from '../../../src/context/project/config.js';
|
||||
|
||||
describe('KTX project config', () => {
|
||||
it.each(['status', 'replay', 'run', 'watch'])('accepts former ingest subcommand name "%s" as a connection id', (connectionId) => {
|
||||
expect(
|
||||
parseKtxProjectConfig(`
|
||||
connections:
|
||||
${connectionId}:
|
||||
driver: postgres
|
||||
`),
|
||||
).toMatchObject({
|
||||
connections: {
|
||||
[connectionId]: { driver: 'postgres' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('builds the default standalone project config', () => {
|
||||
expect(buildDefaultKtxProjectConfig()).toEqual({
|
||||
connections: {},
|
||||
storage: {
|
||||
state: 'sqlite',
|
||||
search: 'sqlite-fts5',
|
||||
git: {
|
||||
auto_commit: true,
|
||||
author: 'ktx <ktx@example.com>',
|
||||
},
|
||||
},
|
||||
llm: {
|
||||
provider: {
|
||||
backend: 'none',
|
||||
},
|
||||
models: {},
|
||||
},
|
||||
ingest: {
|
||||
adapters: [],
|
||||
embeddings: {
|
||||
backend: 'none',
|
||||
dimensions: 8,
|
||||
},
|
||||
workUnits: {
|
||||
stepBudget: 40,
|
||||
maxConcurrency: 1,
|
||||
failureMode: 'continue',
|
||||
},
|
||||
},
|
||||
agent: {
|
||||
run_research: {
|
||||
enabled: false,
|
||||
max_iterations: 20,
|
||||
default_toolset: ['sl_query', 'wiki_search', 'sl_read_source'],
|
||||
},
|
||||
},
|
||||
memory: {
|
||||
auto_commit: true,
|
||||
},
|
||||
scan: {
|
||||
enrichment: {
|
||||
mode: 'none',
|
||||
},
|
||||
relationships: {
|
||||
enabled: true,
|
||||
llmProposals: true,
|
||||
validationRequiredForManifest: true,
|
||||
acceptThreshold: 0.85,
|
||||
reviewThreshold: 0.55,
|
||||
maxLlmTablesPerBatch: 40,
|
||||
maxCandidatesPerColumn: 25,
|
||||
profileSampleRows: 10000,
|
||||
profileConcurrency: 4,
|
||||
validationConcurrency: 4,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips through YAML with stable defaults', () => {
|
||||
const serialized = serializeKtxProjectConfig(buildDefaultKtxProjectConfig());
|
||||
const parsed = parseKtxProjectConfig(serialized);
|
||||
|
||||
expect(serialized).not.toContain('project:');
|
||||
expect(serialized).not.toContain('live-database');
|
||||
expect(serialized).toContain(' embeddings:\n backend: none\n dimensions: 8');
|
||||
expect(parsed.ingest.adapters).toEqual([]);
|
||||
expect(parsed.ingest.embeddings).toEqual({
|
||||
backend: 'none',
|
||||
dimensions: 8,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses and serializes setup warehouse metadata without setup progress', () => {
|
||||
const config = parseKtxProjectConfig(`
|
||||
setup:
|
||||
database_connection_ids:
|
||||
- warehouse
|
||||
- analytics
|
||||
connections:
|
||||
warehouse:
|
||||
driver: postgres
|
||||
url: env:WAREHOUSE_URL
|
||||
`);
|
||||
|
||||
expect(config.setup).toEqual({
|
||||
database_connection_ids: ['warehouse', 'analytics'],
|
||||
});
|
||||
|
||||
const serialized = serializeKtxProjectConfig(config);
|
||||
expect(serialized).toContain('setup:');
|
||||
expect(serialized).toContain('database_connection_ids:');
|
||||
expect(serialized).not.toContain('completed_steps:');
|
||||
});
|
||||
|
||||
it('parses global direct Anthropic LLM config', () => {
|
||||
const config = parseKtxProjectConfig(`
|
||||
llm:
|
||||
provider:
|
||||
backend: anthropic
|
||||
anthropic:
|
||||
api_key: env:ANTHROPIC_API_KEY
|
||||
models:
|
||||
default: claude-sonnet-4-6
|
||||
triage: claude-haiku-4-5
|
||||
repair: claude-opus-4-7
|
||||
promptCaching:
|
||||
enabled: false
|
||||
ingest:
|
||||
workUnits:
|
||||
stepBudget: 30
|
||||
maxConcurrency: 2
|
||||
failureMode: abort
|
||||
`);
|
||||
|
||||
expect(config.llm).toMatchObject({
|
||||
provider: {
|
||||
backend: 'anthropic',
|
||||
anthropic: { api_key: 'env:ANTHROPIC_API_KEY' }, // pragma: allowlist secret
|
||||
},
|
||||
models: {
|
||||
default: 'claude-sonnet-4-6',
|
||||
triage: 'claude-haiku-4-5',
|
||||
repair: 'claude-opus-4-7',
|
||||
},
|
||||
promptCaching: { enabled: false },
|
||||
});
|
||||
expect(config.ingest.workUnits).toEqual({
|
||||
stepBudget: 30,
|
||||
maxConcurrency: 2,
|
||||
failureMode: 'abort',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses global Vertex LLM config', () => {
|
||||
const config = parseKtxProjectConfig(`
|
||||
llm:
|
||||
provider:
|
||||
backend: vertex
|
||||
vertex:
|
||||
project: local-gcp-project
|
||||
location: us-east5
|
||||
models:
|
||||
default: claude-sonnet-4-6
|
||||
triage: claude-haiku-4-5
|
||||
`);
|
||||
|
||||
expect(config.llm.provider.backend).toBe('vertex');
|
||||
expect(config.llm.provider.vertex).toEqual({ project: 'local-gcp-project', location: 'us-east5' });
|
||||
expect(config.llm.models).toEqual({
|
||||
default: 'claude-sonnet-4-6',
|
||||
triage: 'claude-haiku-4-5',
|
||||
});
|
||||
});
|
||||
|
||||
it('requires a non-empty Vertex location when the Vertex provider block is present', () => {
|
||||
const yaml = `
|
||||
llm:
|
||||
provider:
|
||||
backend: vertex
|
||||
vertex:
|
||||
project: local-gcp-project
|
||||
`;
|
||||
|
||||
expect(() => parseKtxProjectConfig(yaml)).toThrow(/llm\.provider\.vertex\.location/);
|
||||
|
||||
const validation = validateKtxProjectConfig(yaml);
|
||||
expect(validation.ok).toBe(false);
|
||||
expect(validation.issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: 'llm.provider.vertex.location',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('parses Claude Code as a first-class LLM backend', () => {
|
||||
const config = parseKtxProjectConfig(`
|
||||
llm:
|
||||
provider:
|
||||
backend: claude-code
|
||||
models:
|
||||
default: sonnet
|
||||
triage: haiku
|
||||
candidateExtraction: sonnet
|
||||
curator: sonnet
|
||||
reconcile: sonnet
|
||||
repair: opus
|
||||
`);
|
||||
|
||||
expect(config.llm.provider.backend).toBe('claude-code');
|
||||
expect(config.llm.models).toEqual({
|
||||
default: 'sonnet',
|
||||
triage: 'haiku',
|
||||
candidateExtraction: 'sonnet',
|
||||
curator: 'sonnet',
|
||||
reconcile: 'sonnet',
|
||||
repair: 'opus',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses gateway LLM, OpenAI scan embeddings, and sentence-transformers ingest embeddings', () => {
|
||||
const config = parseKtxProjectConfig(`
|
||||
llm:
|
||||
provider:
|
||||
backend: gateway
|
||||
gateway:
|
||||
api_key: env:AI_GATEWAY_API_KEY
|
||||
base_url: https://gateway.example/v1
|
||||
models:
|
||||
default: anthropic/claude-sonnet-4-6
|
||||
ingest:
|
||||
embeddings:
|
||||
backend: sentence-transformers
|
||||
model: all-MiniLM-L6-v2
|
||||
dimensions: 384
|
||||
sentenceTransformers:
|
||||
base_url: http://127.0.0.1:18081
|
||||
pathPrefix: ""
|
||||
batchSize: 16
|
||||
scan:
|
||||
enrichment:
|
||||
mode: llm
|
||||
embeddings:
|
||||
backend: openai
|
||||
model: text-embedding-3-small
|
||||
dimensions: 1536
|
||||
openai:
|
||||
api_key: env:OPENAI_API_KEY
|
||||
batchSize: 32
|
||||
`);
|
||||
|
||||
expect(config.ingest.embeddings).toMatchObject({
|
||||
backend: 'sentence-transformers',
|
||||
model: 'all-MiniLM-L6-v2',
|
||||
dimensions: 384,
|
||||
sentenceTransformers: { base_url: 'http://127.0.0.1:18081', pathPrefix: '' },
|
||||
batchSize: 16,
|
||||
});
|
||||
expect(config.llm.models.default).toBe('anthropic/claude-sonnet-4-6');
|
||||
expect(config.scan.enrichment.mode).toBe('llm');
|
||||
expect(config.scan.enrichment.embeddings?.dimensions).toBe(1536);
|
||||
});
|
||||
|
||||
it('parses scan relationship settings', () => {
|
||||
const config = parseKtxProjectConfig(`
|
||||
scan:
|
||||
relationships:
|
||||
enabled: false
|
||||
llmProposals: false
|
||||
validationRequiredForManifest: true
|
||||
acceptThreshold: 0.91
|
||||
reviewThreshold: 0.61
|
||||
maxLlmTablesPerBatch: 12
|
||||
maxCandidatesPerColumn: 7
|
||||
profileSampleRows: 500
|
||||
profileConcurrency: 3
|
||||
validationConcurrency: 2
|
||||
validationBudget: 0
|
||||
`);
|
||||
|
||||
expect(config.scan.relationships).toEqual({
|
||||
enabled: false,
|
||||
llmProposals: false,
|
||||
validationRequiredForManifest: true,
|
||||
acceptThreshold: 0.91,
|
||||
reviewThreshold: 0.61,
|
||||
maxLlmTablesPerBatch: 12,
|
||||
maxCandidatesPerColumn: 7,
|
||||
profileSampleRows: 500,
|
||||
profileConcurrency: 3,
|
||||
validationConcurrency: 2,
|
||||
validationBudget: 0,
|
||||
});
|
||||
expect(serializeKtxProjectConfig(config)).toContain('enabled: false');
|
||||
expect(serializeKtxProjectConfig(config)).toContain('llmProposals: false');
|
||||
expect(serializeKtxProjectConfig(config)).toContain('validationRequiredForManifest: true');
|
||||
expect(serializeKtxProjectConfig(config)).toContain('acceptThreshold: 0.91');
|
||||
expect(serializeKtxProjectConfig(config)).toContain('reviewThreshold: 0.61');
|
||||
expect(serializeKtxProjectConfig(config)).toContain('maxLlmTablesPerBatch: 12');
|
||||
expect(serializeKtxProjectConfig(config)).toContain('maxCandidatesPerColumn: 7');
|
||||
expect(serializeKtxProjectConfig(config)).toContain('profileSampleRows: 500');
|
||||
expect(serializeKtxProjectConfig(config)).toContain('profileConcurrency: 3');
|
||||
expect(serializeKtxProjectConfig(config)).toContain('validationConcurrency: 2');
|
||||
expect(serializeKtxProjectConfig(config)).toContain('validationBudget: 0');
|
||||
});
|
||||
|
||||
it('parses the scan relationship validation budget sentinel', () => {
|
||||
const config = parseKtxProjectConfig(`
|
||||
scan:
|
||||
relationships:
|
||||
validationBudget: all
|
||||
`);
|
||||
|
||||
expect(config.scan.relationships.validationBudget).toBe('all');
|
||||
expect(serializeKtxProjectConfig(config)).toContain('validationBudget: all');
|
||||
});
|
||||
|
||||
it('rejects out-of-range scan relationship numeric settings', () => {
|
||||
const yaml = `
|
||||
scan:
|
||||
relationships:
|
||||
acceptThreshold: 2
|
||||
reviewThreshold: -1
|
||||
maxLlmTablesPerBatch: 0
|
||||
maxCandidatesPerColumn: -4
|
||||
profileSampleRows: 0
|
||||
profileConcurrency: 0
|
||||
validationConcurrency: 0
|
||||
validationBudget: 1.5
|
||||
`;
|
||||
expect(() => parseKtxProjectConfig(yaml)).toThrow(/scan\.relationships\.acceptThreshold/);
|
||||
|
||||
const validation = validateKtxProjectConfig(yaml);
|
||||
expect(validation.ok).toBe(false);
|
||||
const paths = validation.issues.map((issue) => issue.path);
|
||||
expect(paths).toEqual(
|
||||
expect.arrayContaining([
|
||||
'scan.relationships.acceptThreshold',
|
||||
'scan.relationships.reviewThreshold',
|
||||
'scan.relationships.maxLlmTablesPerBatch',
|
||||
'scan.relationships.maxCandidatesPerColumn',
|
||||
'scan.relationships.profileSampleRows',
|
||||
'scan.relationships.profileConcurrency',
|
||||
'scan.relationships.validationConcurrency',
|
||||
'scan.relationships.validationBudget',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid scan relationship validation budget strings', () => {
|
||||
const yaml = `
|
||||
scan:
|
||||
relationships:
|
||||
validationBudget: infinite
|
||||
`;
|
||||
expect(() => parseKtxProjectConfig(yaml)).toThrow(/scan\.relationships\.validationBudget/);
|
||||
});
|
||||
|
||||
it('rejects unsupported local LLM and embedding fields', () => {
|
||||
expect(() =>
|
||||
parseKtxProjectConfig(`
|
||||
ingest:
|
||||
llm:
|
||||
backend: anthropic
|
||||
`),
|
||||
).toThrow('Unsupported ingest.llm: unknown field');
|
||||
|
||||
expect(() =>
|
||||
parseKtxProjectConfig(`
|
||||
scan:
|
||||
enrichment:
|
||||
backend: gateway
|
||||
`),
|
||||
).toThrow('Unsupported scan.enrichment.backend: unknown field');
|
||||
|
||||
expect(() =>
|
||||
parseKtxProjectConfig(`
|
||||
scan:
|
||||
enrichment:
|
||||
mode: llm
|
||||
llm:
|
||||
backend: gateway
|
||||
`),
|
||||
).toThrow('Unsupported scan.enrichment.llm: unknown field');
|
||||
|
||||
expect(() =>
|
||||
parseKtxProjectConfig(`
|
||||
ingest:
|
||||
embeddings:
|
||||
provider: gateway
|
||||
max_batch_size: 32
|
||||
`),
|
||||
).toThrow('Unsupported ingest.embeddings.provider');
|
||||
});
|
||||
|
||||
it('rejects gateway embedding configs', () => {
|
||||
expect(() =>
|
||||
parseKtxProjectConfig(`
|
||||
ingest:
|
||||
embeddings:
|
||||
backend: gateway
|
||||
model: provider/text-embedding
|
||||
dimensions: 1536
|
||||
`),
|
||||
).toThrow('Unsupported ingest.embeddings.backend: gateway');
|
||||
|
||||
expect(() =>
|
||||
parseKtxProjectConfig(`
|
||||
scan:
|
||||
enrichment:
|
||||
mode: llm
|
||||
embeddings:
|
||||
backend: gateway
|
||||
model: provider/text-embedding
|
||||
dimensions: 1536
|
||||
`),
|
||||
).toThrow('Unsupported scan.enrichment.embeddings.backend: gateway');
|
||||
});
|
||||
|
||||
it('fills optional sections when a minimal config is loaded', () => {
|
||||
const config = parseKtxProjectConfig('{}\n');
|
||||
|
||||
expect(config).toEqual(buildDefaultKtxProjectConfig());
|
||||
expect(config.ingest.embeddings).toEqual({
|
||||
backend: 'none',
|
||||
dimensions: 8,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects configs without an object root', () => {
|
||||
expect(() => parseKtxProjectConfig('- nope\n')).toThrow('ktx.yaml must contain a YAML object');
|
||||
});
|
||||
|
||||
it('accepts configs without a project name', () => {
|
||||
expect(parseKtxProjectConfig('connections: {}\n')).toMatchObject({
|
||||
connections: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unknown top-level fields under strict mode', () => {
|
||||
expect(() =>
|
||||
parseKtxProjectConfig(`
|
||||
storrage:
|
||||
state: sqlite
|
||||
`),
|
||||
).toThrow(/Unsupported storrage/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateKtxProjectConfig', () => {
|
||||
it('returns ok: true with no issues for a valid config', () => {
|
||||
const result = validateKtxProjectConfig('connections: {}\n');
|
||||
expect(result).toEqual({ ok: true, issues: [] });
|
||||
});
|
||||
|
||||
it('collects every schema issue without throwing', () => {
|
||||
const result = validateKtxProjectConfig(`
|
||||
storage:
|
||||
search: not-a-real-backend
|
||||
scan:
|
||||
relationships:
|
||||
acceptThreshold: 1.7
|
||||
`);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
const paths = result.issues.map((issue) => issue.path);
|
||||
expect(paths).toEqual(
|
||||
expect.arrayContaining([
|
||||
'storage.search',
|
||||
'scan.relationships.acceptThreshold',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('reports YAML parse errors as a root-level issue', () => {
|
||||
const result = validateKtxProjectConfig(': not valid yaml :\n');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.issues[0]?.path).toBe('');
|
||||
expect(result.issues[0]?.message).toMatch(/ktx\.yaml parse error/);
|
||||
});
|
||||
|
||||
it('reports a YAML scalar root as a single issue', () => {
|
||||
const result = validateKtxProjectConfig('- nope\n');
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
issues: [{ path: '', message: 'ktx.yaml must contain a YAML object' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateKtxProjectConfigJsonSchema', () => {
|
||||
const schema = generateKtxProjectConfigJsonSchema();
|
||||
|
||||
it('emits draft-07 metadata', () => {
|
||||
expect(schema.$schema).toBe('http://json-schema.org/draft-07/schema#');
|
||||
expect(schema.$id).toBe('https://ktx.dev/schemas/ktx-project-config.json');
|
||||
expect(schema.title).toBe('ktx.yaml');
|
||||
expect(schema.type).toBe('object');
|
||||
});
|
||||
|
||||
it('exposes every top-level ktx.yaml section under properties', () => {
|
||||
const properties = schema.properties as Record<string, unknown>;
|
||||
expect(Object.keys(properties).sort()).toEqual(['agent', 'connections', 'ingest', 'llm', 'memory', 'scan', 'setup', 'storage'].sort());
|
||||
});
|
||||
|
||||
it('does not require any top-level fields', () => {
|
||||
expect(schema.required).toBeUndefined();
|
||||
});
|
||||
|
||||
it('carries .describe() text on top-level fields', () => {
|
||||
const properties = schema.properties as Record<string, { description?: string }>;
|
||||
expect(properties.llm?.description).toMatch(/LLM/);
|
||||
expect(properties.scan?.description).toMatch(/Schema-scan/);
|
||||
});
|
||||
|
||||
it('propagates enum values through to nested fields', () => {
|
||||
const llm = (schema.properties as Record<string, { properties?: Record<string, unknown> }>).llm;
|
||||
const provider = llm?.properties?.provider as { properties?: Record<string, unknown> };
|
||||
const backend = provider?.properties?.backend as { enum?: readonly string[] };
|
||||
expect(backend?.enum).toEqual(['none', 'anthropic', 'vertex', 'gateway', 'claude-code']);
|
||||
|
||||
const storage = (schema.properties as Record<string, { properties?: Record<string, unknown> }>).storage;
|
||||
const state = storage?.properties?.state as { enum?: readonly string[] };
|
||||
expect(state?.enum).toEqual(['sqlite', 'postgres']);
|
||||
});
|
||||
|
||||
it('carries descriptions on deeply nested leaves', () => {
|
||||
const scan = (schema.properties as Record<string, { properties?: Record<string, unknown> }>).scan;
|
||||
const relationships = scan?.properties?.relationships as { properties?: Record<string, { description?: string }> };
|
||||
expect(relationships?.properties?.acceptThreshold?.description).toMatch(/auto-accepted/);
|
||||
});
|
||||
|
||||
it('emits the mappings shapes under connections', () => {
|
||||
const serialized = JSON.stringify(schema);
|
||||
expect(serialized).toContain('databaseMappings');
|
||||
expect(serialized).toContain('connectionMappings');
|
||||
expect(serialized).toContain('expectedLookerConnectionName');
|
||||
});
|
||||
});
|
||||
143
packages/cli/test/context/project/driver-schemas.test.ts
Normal file
143
packages/cli/test/context/project/driver-schemas.test.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { connectionConfigSchema } from '../../../src/context/project/driver-schemas.js';
|
||||
|
||||
describe('connectionConfigSchema (driver discriminated union)', () => {
|
||||
it.each([
|
||||
['postgres', 'postgres://user:pass@host:5432/db'], // pragma: allowlist secret
|
||||
['mysql', 'mysql://user:pass@host:3306/db'], // pragma: allowlist secret
|
||||
['snowflake', 'snowflake://account/db'],
|
||||
['bigquery', 'bigquery://project/dataset'],
|
||||
['sqlite', 'sqlite:///tmp/db.sqlite'],
|
||||
['clickhouse', 'clickhouse://host:8123/db'],
|
||||
['sqlserver', 'sqlserver://host:1433;database=db'],
|
||||
])('parses %s warehouse connection', (driver, url) => {
|
||||
expect(connectionConfigSchema.parse({ driver, url })).toMatchObject({ driver, url });
|
||||
});
|
||||
|
||||
it('preserves unknown warehouse fields via looseObject passthrough', () => {
|
||||
const parsed = connectionConfigSchema.parse({
|
||||
driver: 'postgres',
|
||||
url: 'postgres://x',
|
||||
customField: { enabled: true },
|
||||
context: { queryHistory: { enabled: false } },
|
||||
});
|
||||
expect(parsed).toMatchObject({
|
||||
driver: 'postgres',
|
||||
customField: { enabled: true },
|
||||
context: { queryHistory: { enabled: false } },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an unknown driver', () => {
|
||||
expect(() => connectionConfigSchema.parse({ driver: 'nope', url: 'x' })).toThrow();
|
||||
});
|
||||
|
||||
it('rejects legacy warehouse driver aliases', () => {
|
||||
expect(() => connectionConfigSchema.parse({ driver: 'postgresql', url: 'postgresql://host/db' })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('connectionConfigSchema - context source drivers with mappings', () => {
|
||||
it('parses a metabase connection with mappings', () => {
|
||||
const parsed = connectionConfigSchema.parse({
|
||||
driver: 'metabase',
|
||||
api_url: 'https://metabase.example.com',
|
||||
api_key_ref: 'env:METABASE_API_KEY', // pragma: allowlist secret
|
||||
mappings: {
|
||||
databaseMappings: { '3': 'prod-warehouse' },
|
||||
syncEnabled: { '3': true },
|
||||
syncMode: 'ONLY',
|
||||
},
|
||||
});
|
||||
expect(parsed).toMatchObject({
|
||||
driver: 'metabase',
|
||||
api_url: 'https://metabase.example.com',
|
||||
mappings: {
|
||||
databaseMappings: { '3': 'prod-warehouse' },
|
||||
syncMode: 'ONLY',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a looker connection with connectionMappings', () => {
|
||||
const parsed = connectionConfigSchema.parse({
|
||||
driver: 'looker',
|
||||
base_url: 'https://looker.example.com',
|
||||
client_id: 'abc',
|
||||
client_secret_ref: 'env:LOOKER_CLIENT_SECRET', // pragma: allowlist secret
|
||||
mappings: { connectionMappings: { bigquery_prod: 'wh' } },
|
||||
});
|
||||
expect(parsed.mappings).toEqual({ connectionMappings: { bigquery_prod: 'wh' } });
|
||||
});
|
||||
|
||||
it('parses a lookml connection with expectedLookerConnectionName', () => {
|
||||
const parsed = connectionConfigSchema.parse({
|
||||
driver: 'lookml',
|
||||
repoUrl: 'https://github.com/acme/looker.git',
|
||||
branch: 'main',
|
||||
mappings: { expectedLookerConnectionName: 'bigquery_prod' },
|
||||
});
|
||||
expect(parsed.mappings).toEqual({ expectedLookerConnectionName: 'bigquery_prod' });
|
||||
});
|
||||
|
||||
it('rejects metabase mapping with non-integer database key', () => {
|
||||
expect(() =>
|
||||
connectionConfigSchema.parse({
|
||||
driver: 'metabase',
|
||||
api_url: 'https://x',
|
||||
mappings: { databaseMappings: { abc: 'wh' } },
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('connectionConfigSchema - notion / dbt / metricflow', () => {
|
||||
it('parses a notion connection with selected_roots crawl', () => {
|
||||
const parsed = connectionConfigSchema.parse({
|
||||
driver: 'notion',
|
||||
auth_token_ref: 'env:NOTION_TOKEN',
|
||||
crawl_mode: 'selected_roots',
|
||||
root_page_ids: ['abc', 'def'],
|
||||
max_pages_per_run: 500,
|
||||
});
|
||||
expect(parsed).toMatchObject({
|
||||
driver: 'notion',
|
||||
crawl_mode: 'selected_roots',
|
||||
root_page_ids: ['abc', 'def'],
|
||||
max_pages_per_run: 500,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects notion with unknown crawl_mode', () => {
|
||||
expect(() =>
|
||||
connectionConfigSchema.parse({
|
||||
driver: 'notion',
|
||||
auth_token_ref: 'env:NOTION_TOKEN',
|
||||
crawl_mode: 'everything',
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('parses a dbt connection from a local source_dir', () => {
|
||||
const parsed = connectionConfigSchema.parse({
|
||||
driver: 'dbt',
|
||||
source_dir: '/tmp/dbt-project',
|
||||
target: 'dev',
|
||||
});
|
||||
expect(parsed).toMatchObject({ driver: 'dbt', source_dir: '/tmp/dbt-project', target: 'dev' });
|
||||
});
|
||||
|
||||
it('parses a metricflow connection with nested config', () => {
|
||||
const parsed = connectionConfigSchema.parse({
|
||||
driver: 'metricflow',
|
||||
metricflow: {
|
||||
repoUrl: 'https://github.com/acme/sl.git',
|
||||
branch: 'main',
|
||||
},
|
||||
});
|
||||
expect(parsed).toMatchObject({
|
||||
driver: 'metricflow',
|
||||
metricflow: { repoUrl: 'https://github.com/acme/sl.git' },
|
||||
});
|
||||
});
|
||||
});
|
||||
102
packages/cli/test/context/project/local-git-file-store.test.ts
Normal file
102
packages/cli/test/context/project/local-git-file-store.test.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { GitService } from '../../../src/context/core/git.service.js';
|
||||
import type { KtxCoreConfig } from '../../../src/context/core/config.js';
|
||||
import { LocalGitFileStore } from '../../../src/context/project/local-git-file-store.js';
|
||||
|
||||
describe('LocalGitFileStore', () => {
|
||||
let tempDir: string;
|
||||
let store: LocalGitFileStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-local-store-'));
|
||||
const coreConfig: KtxCoreConfig = {
|
||||
storage: { configDir: tempDir, homeDir: tempDir },
|
||||
git: {
|
||||
userName: 'ktx',
|
||||
userEmail: 'ktx@example.com',
|
||||
bootstrapMessage: 'Initialize test project',
|
||||
bootstrapAuthor: 'ktx',
|
||||
bootstrapAuthorEmail: 'ktx@example.com',
|
||||
},
|
||||
};
|
||||
const git = new GitService(coreConfig);
|
||||
await git.onModuleInit();
|
||||
store = new LocalGitFileStore({ rootDir: tempDir, git });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('writes, commits, and reads a project file', async () => {
|
||||
const write = await store.writeFile(
|
||||
'wiki/global/revenue.md',
|
||||
'# Revenue\n',
|
||||
'Agent',
|
||||
'agent@example.com',
|
||||
'Add revenue page',
|
||||
);
|
||||
|
||||
expect(write.commitHash).toMatch(/^[0-9a-f]{40}$/);
|
||||
await expect(readFile(join(tempDir, 'wiki/global/revenue.md'), 'utf-8')).resolves.toBe('# Revenue\n');
|
||||
await expect(store.readFile('wiki/global/revenue.md')).resolves.toMatchObject({
|
||||
content: '# Revenue\n',
|
||||
});
|
||||
});
|
||||
|
||||
it('lists files recursively and can strip the requested prefix', async () => {
|
||||
await store.writeFile('wiki/global/a.md', 'a', 'Agent', 'agent@example.com', 'Add a');
|
||||
await store.writeFile('wiki/global/nested/b.md', 'b', 'Agent', 'agent@example.com', 'Add b');
|
||||
|
||||
await expect(store.listFiles('wiki')).resolves.toEqual({
|
||||
files: ['wiki/global/a.md', 'wiki/global/nested/b.md'],
|
||||
});
|
||||
await expect(store.listFiles('wiki/global', true)).resolves.toEqual({
|
||||
files: ['a.md', 'nested/b.md'],
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes and commits an existing file', async () => {
|
||||
await store.writeFile('semantic-layer/conn/orders.yaml', 'name: orders\n', 'Agent', 'agent@example.com', 'Add SL');
|
||||
|
||||
const deleted = await store.deleteFile(
|
||||
'semantic-layer/conn/orders.yaml',
|
||||
'Agent',
|
||||
'agent@example.com',
|
||||
'Delete SL',
|
||||
);
|
||||
|
||||
expect(deleted?.commitHash).toMatch(/^[0-9a-f]{40}$/);
|
||||
await expect(stat(join(tempDir, 'semantic-layer/conn/orders.yaml'))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('returns null when deleting a missing file', async () => {
|
||||
await expect(store.deleteFile('missing.md', 'Agent', 'agent@example.com', 'Delete missing')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('exposes Git history for a file', async () => {
|
||||
await store.writeFile('wiki/global/history.md', 'v1', 'Agent', 'agent@example.com', 'Add history');
|
||||
await store.writeFile('wiki/global/history.md', 'v2', 'Agent', 'agent@example.com', 'Update history');
|
||||
|
||||
const history = await store.getFileHistory('wiki/global/history.md');
|
||||
|
||||
expect(Array.isArray(history)).toBe(true);
|
||||
expect(history[0]).toMatchObject({ message: 'Update history' });
|
||||
expect(history[1]).toMatchObject({ message: 'Add history' });
|
||||
});
|
||||
|
||||
it('rejects absolute paths and parent-directory traversal', async () => {
|
||||
await expect(store.writeFile('/tmp/outside.md', 'bad', 'Agent', 'agent@example.com', 'Bad write')).rejects.toThrow(
|
||||
'Path must be relative',
|
||||
);
|
||||
|
||||
await expect(store.readFile('../outside.md')).rejects.toThrow('Path escapes the project directory');
|
||||
});
|
||||
|
||||
it('rejects direct .git access', async () => {
|
||||
await expect(store.readFile('.git/config')).rejects.toThrow('Path cannot access .git');
|
||||
});
|
||||
});
|
||||
101
packages/cli/test/context/project/mappings-yaml-schema.test.ts
Normal file
101
packages/cli/test/context/project/mappings-yaml-schema.test.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
lookerMappingsSchema,
|
||||
lookmlMappingsSchema,
|
||||
metabaseMappingsSchema,
|
||||
parseConnectionMappingBootstrap,
|
||||
parseLookmlMappingBootstrap,
|
||||
parseLookerMappingBootstrap,
|
||||
parseMetabaseMappingBootstrap,
|
||||
} from '../../../src/context/project/mappings-yaml-schema.js';
|
||||
|
||||
describe('ktx.yaml mapping bootstrap schema', () => {
|
||||
it('parses Metabase mapping intent with CLI syncMode default ALL', () => {
|
||||
const bootstrap = parseMetabaseMappingBootstrap('prod-metabase', {
|
||||
driver: 'metabase',
|
||||
mappings: {
|
||||
databaseMappings: { '1': 'prod-warehouse', '2': null },
|
||||
syncEnabled: { '1': true, '2': false },
|
||||
selections: { collections: [12], items: [345] },
|
||||
defaultTagNames: ['ktx', 'prod'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(bootstrap).toEqual({
|
||||
adapter: 'metabase',
|
||||
connectionId: 'prod-metabase',
|
||||
databaseMappings: { '1': 'prod-warehouse', '2': null },
|
||||
syncEnabled: { '1': true, '2': false },
|
||||
syncMode: 'ALL',
|
||||
selections: { collections: [12], items: [345] },
|
||||
defaultTagNames: ['ktx', 'prod'],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects Metabase non-integer mapping keys', () => {
|
||||
expect(() =>
|
||||
parseMetabaseMappingBootstrap('prod-metabase', {
|
||||
driver: 'metabase',
|
||||
mappings: { databaseMappings: { abc: 'warehouse' } },
|
||||
}),
|
||||
).toThrow(/databaseMappings key "abc" must be a positive integer string/);
|
||||
});
|
||||
|
||||
it('parses Looker connection mapping intent', () => {
|
||||
const bootstrap = parseLookerMappingBootstrap('prod-looker', {
|
||||
driver: 'looker',
|
||||
mappings: {
|
||||
connectionMappings: {
|
||||
bigquery_prod: 'prod-warehouse',
|
||||
snowflake_dev: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(bootstrap).toEqual({
|
||||
adapter: 'looker',
|
||||
connectionId: 'prod-looker',
|
||||
connectionMappings: {
|
||||
bigquery_prod: 'prod-warehouse',
|
||||
snowflake_dev: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('parses LookML expected connection from mappings block', () => {
|
||||
expect(
|
||||
parseLookmlMappingBootstrap('prod-lookml', {
|
||||
driver: 'lookml',
|
||||
repo_url: 'https://github.com/acme/looker.git',
|
||||
mappings: { expectedLookerConnectionName: 'bigquery_prod' },
|
||||
}),
|
||||
).toEqual({
|
||||
adapter: 'lookml',
|
||||
connectionId: 'prod-lookml',
|
||||
expectedLookerConnectionName: 'bigquery_prod',
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches by flat driver and returns null for connections with no mappings block', () => {
|
||||
expect(parseConnectionMappingBootstrap('warehouse', { driver: 'postgres', url: 'env:DATABASE_URL' })).toBeNull();
|
||||
expect(
|
||||
parseConnectionMappingBootstrap('prod-looker', {
|
||||
driver: 'looker',
|
||||
mappings: { connectionMappings: { analytics: 'prod-warehouse' } },
|
||||
}),
|
||||
).toMatchObject({ adapter: 'looker', connectionId: 'prod-looker' });
|
||||
});
|
||||
|
||||
it('exports mapping shapes that parse documented examples', () => {
|
||||
expect(metabaseMappingsSchema.parse({ databaseMappings: { '1': 'wh' } })).toMatchObject({
|
||||
databaseMappings: { '1': 'wh' },
|
||||
syncMode: 'ALL',
|
||||
});
|
||||
expect(lookerMappingsSchema.parse({ connectionMappings: { x: 'wh' } })).toEqual({
|
||||
connectionMappings: { x: 'wh' },
|
||||
});
|
||||
expect(lookmlMappingsSchema.parse({ expectedLookerConnectionName: 'x' })).toEqual({
|
||||
expectedLookerConnectionName: 'x',
|
||||
});
|
||||
});
|
||||
});
|
||||
73
packages/cli/test/context/project/project.test.ts
Normal file
73
packages/cli/test/context/project/project.test.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { initKtxProject, loadKtxProject } from '../../../src/context/project/project.js';
|
||||
|
||||
describe('KTX local project runtime', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-project-runtime-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('initializes the standalone project layout and commits it', async () => {
|
||||
const projectDir = join(tempDir, 'warehouse');
|
||||
|
||||
const result = await initKtxProject({
|
||||
projectDir,
|
||||
authorName: 'Agent',
|
||||
authorEmail: 'agent@example.com',
|
||||
});
|
||||
|
||||
expect(result.projectDir).toBe(projectDir);
|
||||
expect(result.commitHash).toMatch(/^[0-9a-f]{40}$/);
|
||||
await expect(readFile(join(projectDir, 'ktx.yaml'), 'utf-8')).resolves.not.toContain('project:');
|
||||
const gitignore = await readFile(join(projectDir, '.ktx/.gitignore'), 'utf-8');
|
||||
expect(gitignore).toContain('cache/');
|
||||
expect(gitignore).toContain('db.sqlite');
|
||||
expect(gitignore).toContain('db.sqlite-*');
|
||||
expect(gitignore).toContain('ingest-transcripts/');
|
||||
expect(gitignore).toContain('secrets/');
|
||||
expect(gitignore).toContain('setup/');
|
||||
expect(gitignore).toContain('agents/');
|
||||
await expect(stat(join(projectDir, 'wiki/global/.gitkeep'))).resolves.toBeDefined();
|
||||
await expect(stat(join(projectDir, 'semantic-layer/.gitkeep'))).resolves.toBeDefined();
|
||||
await expect(stat(join(projectDir, '_schema/.gitkeep'))).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
await expect(stat(join(projectDir, 'raw-sources/.gitkeep'))).resolves.toBeDefined();
|
||||
await expect(stat(join(projectDir, '.git'))).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('loads an initialized project with a working file store', async () => {
|
||||
const projectDir = join(tempDir, 'warehouse');
|
||||
await initKtxProject({ projectDir });
|
||||
|
||||
const loaded = await loadKtxProject({ projectDir });
|
||||
await loaded.fileStore.writeFile(
|
||||
'wiki/global/revenue.md',
|
||||
'# Revenue\n',
|
||||
'Agent',
|
||||
'agent@example.com',
|
||||
'Add revenue page',
|
||||
);
|
||||
|
||||
await expect(loaded.fileStore.readFile('wiki/global/revenue.md')).resolves.toMatchObject({
|
||||
content: '# Revenue\n',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects reinitializing an existing project unless force is set', async () => {
|
||||
const projectDir = join(tempDir, 'warehouse');
|
||||
await initKtxProject({ projectDir });
|
||||
|
||||
await expect(initKtxProject({ projectDir })).rejects.toThrow('Project already contains ktx.yaml');
|
||||
|
||||
await expect(initKtxProject({ projectDir, force: true })).resolves.toMatchObject({
|
||||
configPath: join(projectDir, 'ktx.yaml'),
|
||||
});
|
||||
});
|
||||
});
|
||||
58
packages/cli/test/context/project/setup-config.test.ts
Normal file
58
packages/cli/test/context/project/setup-config.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { buildDefaultKtxProjectConfig } from '../../../src/context/project/config.js';
|
||||
import {
|
||||
markKtxSetupStateStepComplete,
|
||||
mergeKtxSetupGitignoreEntries,
|
||||
readKtxSetupState,
|
||||
setKtxSetupDatabaseConnectionIds,
|
||||
} from '../../../src/context/project/setup-config.js';
|
||||
|
||||
describe('KTX setup config helpers', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-setup-state-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('marks setup steps complete in local state without duplicating existing state', async () => {
|
||||
await markKtxSetupStateStepComplete(tempDir, 'project');
|
||||
await markKtxSetupStateStepComplete(tempDir, 'project');
|
||||
await markKtxSetupStateStepComplete(tempDir, 'llm');
|
||||
await markKtxSetupStateStepComplete(tempDir, 'runtime');
|
||||
await markKtxSetupStateStepComplete(tempDir, 'context');
|
||||
|
||||
expect(await readKtxSetupState(tempDir)).toEqual({
|
||||
completed_steps: ['project', 'llm', 'runtime', 'context'],
|
||||
});
|
||||
await expect(readFile(join(tempDir, '.ktx', 'setup', 'state.json'), 'utf-8')).resolves.toBe(
|
||||
`${JSON.stringify({ completed_steps: ['project', 'llm', 'runtime', 'context'] }, null, 2)}\n`,
|
||||
);
|
||||
});
|
||||
|
||||
it('sets setup database connection ids without duplicates', () => {
|
||||
const config = buildDefaultKtxProjectConfig();
|
||||
|
||||
const withDatabases = setKtxSetupDatabaseConnectionIds(config, ['warehouse', 'analytics', 'warehouse']);
|
||||
|
||||
expect(withDatabases.setup).toEqual({
|
||||
database_connection_ids: ['warehouse', 'analytics'],
|
||||
});
|
||||
expect(config.setup).toBeUndefined();
|
||||
});
|
||||
|
||||
it('merges setup-local gitignore entries without removing existing lines', () => {
|
||||
expect(mergeKtxSetupGitignoreEntries('cache/\ndb.sqlite\n')).toBe(
|
||||
['cache/', 'db.sqlite', 'db.sqlite-*', 'ingest-transcripts/', 'secrets/', 'setup/', 'agents/', ''].join('\n'),
|
||||
);
|
||||
expect(mergeKtxSetupGitignoreEntries('cache/\nsecrets/\n')).toBe(
|
||||
['cache/', 'secrets/', 'db.sqlite', 'db.sqlite-*', 'ingest-transcripts/', 'setup/', 'agents/', ''].join('\n'),
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue