mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-22 08:38: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
|
|
@ -1,37 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { beginCommandSpan, completeCommandSpan, resetCommandSpan } from './command-hook.js';
|
||||
|
||||
describe('telemetry command hook', () => {
|
||||
it('builds a completed command event from a span', () => {
|
||||
resetCommandSpan();
|
||||
beginCommandSpan({
|
||||
commandPath: ['ktx', 'status'],
|
||||
flagsPresent: { projectDir: true, json: true },
|
||||
projectDir: '/tmp/private',
|
||||
hasProject: true,
|
||||
attachProjectGroup: true,
|
||||
startedAt: 100,
|
||||
});
|
||||
|
||||
expect(
|
||||
completeCommandSpan({
|
||||
completedAt: 125,
|
||||
outcome: 'ok',
|
||||
}),
|
||||
).toEqual({
|
||||
commandPath: ['ktx', 'status'],
|
||||
durationMs: 25,
|
||||
outcome: 'ok',
|
||||
flagsPresent: { projectDir: true, json: true },
|
||||
hasProject: true,
|
||||
projectDir: '/tmp/private',
|
||||
projectGroupAttached: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined when no preAction span exists', () => {
|
||||
resetCommandSpan();
|
||||
expect(completeCommandSpan({ completedAt: 200, outcome: 'ok' })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isDemoConnection } from './demo-detect.js';
|
||||
|
||||
describe('isDemoConnection', () => {
|
||||
it('detects only the packaged Orbit SQLite demo recipe', () => {
|
||||
expect(
|
||||
isDemoConnection('orbit_demo', {
|
||||
driver: 'sqlite',
|
||||
path: '/tmp/ktx-demo/demo.db',
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isDemoConnection('orbit_demo', {
|
||||
driver: 'postgres',
|
||||
path: '/tmp/ktx-demo/demo.db',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isDemoConnection('warehouse', {
|
||||
driver: 'sqlite',
|
||||
path: '/tmp/ktx-demo/demo.db',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isDemoConnection('orbit_demo', {
|
||||
driver: 'sqlite',
|
||||
path: '/tmp/ktx-demo/private.db',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
__resetTelemetryEmitterForTests,
|
||||
shutdownTelemetryEmitter,
|
||||
trackTelemetryEvent,
|
||||
} from './emitter.js';
|
||||
import type { BuiltTelemetryEvent } from './events.js';
|
||||
|
||||
const captures: unknown[] = [];
|
||||
const shutdown = vi.fn(async () => {});
|
||||
|
||||
function liveConfigId(): string {
|
||||
return 'fixture';
|
||||
}
|
||||
|
||||
vi.mock('posthog-node', () => ({
|
||||
PostHog: vi.fn().mockImplementation(function () {
|
||||
return {
|
||||
capture: (event: unknown) => captures.push(event),
|
||||
shutdown,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
function commandEvent(): BuiltTelemetryEvent<'command'> {
|
||||
return {
|
||||
name: 'command',
|
||||
properties: {
|
||||
cliVersion: '0.4.1',
|
||||
nodeVersion: 'v22.0.0',
|
||||
osPlatform: 'darwin',
|
||||
osRelease: '25.0.0',
|
||||
arch: 'arm64',
|
||||
runtime: 'node',
|
||||
isCi: false,
|
||||
commandPath: ['ktx', 'status'],
|
||||
durationMs: 1,
|
||||
outcome: 'ok',
|
||||
flagsPresent: {},
|
||||
hasProject: true,
|
||||
projectGroupAttached: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('telemetry emitter', () => {
|
||||
beforeEach(() => {
|
||||
captures.length = 0;
|
||||
shutdown.mockClear();
|
||||
__resetTelemetryEmitterForTests();
|
||||
});
|
||||
|
||||
it('prints debug payloads without importing or sending to PostHog', async () => {
|
||||
const stderr: string[] = [];
|
||||
|
||||
await trackTelemetryEvent({
|
||||
event: commandEvent(),
|
||||
distinctId: 'install-1',
|
||||
projectId: 'project-1',
|
||||
env: { KTX_TELEMETRY_DEBUG: '1' },
|
||||
stderr: { write: (chunk) => stderr.push(chunk) },
|
||||
});
|
||||
|
||||
expect(stderr.join('')).toContain('[telemetry]');
|
||||
expect(stderr.join('')).toContain('"event":"command"');
|
||||
expect(captures).toEqual([]);
|
||||
});
|
||||
|
||||
it('sends to PostHog by default once config constants are populated', async () => {
|
||||
await trackTelemetryEvent({
|
||||
event: commandEvent(),
|
||||
distinctId: 'install-1',
|
||||
projectId: 'project-1',
|
||||
env: {},
|
||||
stderr: { write: () => {} },
|
||||
});
|
||||
|
||||
expect(captures).toHaveLength(1);
|
||||
expect(captures[0]).toMatchObject({
|
||||
distinctId: 'install-1',
|
||||
event: 'command',
|
||||
groups: { project: 'project-1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('captures with distinctId, properties, and groups when live config is supplied', async () => {
|
||||
await trackTelemetryEvent({
|
||||
event: commandEvent(),
|
||||
distinctId: 'install-1',
|
||||
projectId: 'project-1',
|
||||
projectApiKey: liveConfigId(),
|
||||
host: 'https://us.i.posthog.com',
|
||||
env: {},
|
||||
stderr: { write: () => {} },
|
||||
});
|
||||
|
||||
expect(captures).toHaveLength(1);
|
||||
expect(captures[0]).toMatchObject({
|
||||
distinctId: 'install-1',
|
||||
event: 'command',
|
||||
groups: { project: 'project-1' },
|
||||
properties: {
|
||||
cliVersion: '0.4.1',
|
||||
commandPath: ['ktx', 'status'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('shuts down the client without throwing', async () => {
|
||||
await trackTelemetryEvent({
|
||||
event: commandEvent(),
|
||||
distinctId: 'install-1',
|
||||
projectApiKey: liveConfigId(),
|
||||
host: 'https://us.i.posthog.com',
|
||||
env: {},
|
||||
stderr: { write: () => {} },
|
||||
});
|
||||
|
||||
await expect(shutdownTelemetryEmitter()).resolves.toBeUndefined();
|
||||
expect(shutdown).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildTelemetryEvent, type TelemetryCommonEnvelope } from './events.js';
|
||||
|
||||
const BLACKLIST = [
|
||||
'/Users/',
|
||||
'/home/',
|
||||
'C:\\',
|
||||
'localhost',
|
||||
'.local',
|
||||
'kaelio.com',
|
||||
'select ',
|
||||
'SELECT ',
|
||||
'INSERT',
|
||||
'CREATE',
|
||||
'@',
|
||||
'password',
|
||||
'secret',
|
||||
'token',
|
||||
'key',
|
||||
];
|
||||
|
||||
const envelope: TelemetryCommonEnvelope = {
|
||||
cliVersion: '0.4.1',
|
||||
nodeVersion: 'v22.0.0',
|
||||
osPlatform: 'darwin',
|
||||
osRelease: '25.0.0',
|
||||
arch: 'arm64',
|
||||
runtime: 'node',
|
||||
isCi: false,
|
||||
};
|
||||
|
||||
describe('telemetry privacy snapshot', () => {
|
||||
it('does not emit known private substrings from phase 1 event payloads', () => {
|
||||
const events = [
|
||||
buildTelemetryEvent('install_first_run', envelope, {}),
|
||||
buildTelemetryEvent('command', envelope, {
|
||||
commandPath: ['ktx', 'sql'],
|
||||
durationMs: 10,
|
||||
outcome: 'error',
|
||||
errorClass: 'KtxProjectMissingAbortError',
|
||||
flagsPresent: {
|
||||
'project-dir': true,
|
||||
connection: true,
|
||||
c: true,
|
||||
},
|
||||
hasProject: false,
|
||||
projectGroupAttached: false,
|
||||
}),
|
||||
buildTelemetryEvent('setup_step', envelope, {
|
||||
step: 'databases',
|
||||
outcome: 'completed',
|
||||
durationMs: 42,
|
||||
}),
|
||||
buildTelemetryEvent('connection_added', envelope, {
|
||||
driver: 'postgres',
|
||||
isDemoConnection: false,
|
||||
}),
|
||||
buildTelemetryEvent('connection_test', envelope, {
|
||||
driver: 'postgres',
|
||||
isDemoConnection: false,
|
||||
outcome: 'error',
|
||||
errorClass: 'KtxConnectionTestAbortError',
|
||||
durationMs: 34,
|
||||
serverVersion: '16',
|
||||
}),
|
||||
buildTelemetryEvent('project_stack_snapshot', envelope, {
|
||||
connectors: [
|
||||
{ driver: 'sqlite', isDemo: true },
|
||||
{ driver: 'postgres', isDemo: false },
|
||||
],
|
||||
connectionCount: 2,
|
||||
hasSl: true,
|
||||
hasWiki: true,
|
||||
hasMcp: true,
|
||||
hasManagedRuntime: true,
|
||||
}),
|
||||
buildTelemetryEvent('ingest_completed', envelope, {
|
||||
driver: 'postgres',
|
||||
isDemoConnection: false,
|
||||
schemaCount: 2,
|
||||
tableCount: 4,
|
||||
columnCount: 20,
|
||||
rowsBucket: '<100k',
|
||||
durationMs: 100,
|
||||
outcome: 'ok',
|
||||
}),
|
||||
buildTelemetryEvent('scan_completed', envelope, {
|
||||
driver: 'postgres',
|
||||
tableCount: 4,
|
||||
columnCount: 20,
|
||||
inferredFkCount: 2,
|
||||
declaredFkCount: 1,
|
||||
durationMs: 70,
|
||||
outcome: 'ok',
|
||||
}),
|
||||
buildTelemetryEvent('sl_validate_completed', envelope, {
|
||||
sourceCount: 1,
|
||||
modelCount: 3,
|
||||
validationErrorCount: 0,
|
||||
outcome: 'ok',
|
||||
durationMs: 15,
|
||||
}),
|
||||
buildTelemetryEvent('sl_query_completed', envelope, {
|
||||
mode: 'compile',
|
||||
referencedSourceCount: 1,
|
||||
referencedDimensionCount: 2,
|
||||
referencedMeasureCount: 1,
|
||||
durationMs: 18,
|
||||
outcome: 'ok',
|
||||
}),
|
||||
buildTelemetryEvent('sql_completed', envelope, {
|
||||
driver: 'postgres',
|
||||
isDemoConnection: false,
|
||||
queryVerb: 'select',
|
||||
referencedTableCount: 3,
|
||||
durationMs: 20,
|
||||
outcome: 'ok',
|
||||
}),
|
||||
buildTelemetryEvent('wiki_query_completed', envelope, {
|
||||
queryLength: 'select private_table from /Users/alice'.length,
|
||||
resultCount: 2,
|
||||
durationMs: 8,
|
||||
outcome: 'ok',
|
||||
}),
|
||||
buildTelemetryEvent('mcp_request_completed', envelope, {
|
||||
toolName: 'sl_query',
|
||||
outcome: 'error',
|
||||
errorClass: 'KtxProjectMissingAbortError',
|
||||
durationMs: 12,
|
||||
sampleRate: 0.1,
|
||||
}),
|
||||
];
|
||||
|
||||
const payload = JSON.stringify(events);
|
||||
|
||||
for (const forbidden of BLACKLIST) {
|
||||
expect(payload).not.toContain(forbidden);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1,165 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildTelemetryEvent,
|
||||
telemetryEventCatalog,
|
||||
telemetryEventSchemas,
|
||||
type TelemetryCommonEnvelope,
|
||||
} from './events.js';
|
||||
|
||||
const envelope: TelemetryCommonEnvelope = {
|
||||
cliVersion: '0.4.1',
|
||||
nodeVersion: 'v22.0.0',
|
||||
osPlatform: 'darwin',
|
||||
osRelease: '25.0.0',
|
||||
arch: 'arm64',
|
||||
runtime: 'node',
|
||||
isCi: false,
|
||||
};
|
||||
|
||||
describe('telemetry event schemas', () => {
|
||||
it('catalogs all v1 telemetry events', () => {
|
||||
expect(telemetryEventCatalog.map((event) => event.name)).toEqual([
|
||||
'install_first_run',
|
||||
'command',
|
||||
'setup_step',
|
||||
'connection_added',
|
||||
'connection_test',
|
||||
'project_stack_snapshot',
|
||||
'ingest_completed',
|
||||
'scan_completed',
|
||||
'sl_validate_completed',
|
||||
'sl_query_completed',
|
||||
'sql_completed',
|
||||
'wiki_query_completed',
|
||||
'mcp_request_completed',
|
||||
'daemon_started',
|
||||
'daemon_stopped',
|
||||
'sl_plan_completed',
|
||||
'sql_gen_completed',
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds strict daemon telemetry events', () => {
|
||||
const daemonEnvelope = {
|
||||
...envelope,
|
||||
runtime: 'daemon-py' as const,
|
||||
nodeVersion: '3.13.0',
|
||||
};
|
||||
|
||||
expect(
|
||||
buildTelemetryEvent('sl_plan_completed', daemonEnvelope, {
|
||||
outcome: 'ok',
|
||||
stage: 'transpile',
|
||||
durationMs: 25,
|
||||
sourceCount: 2,
|
||||
joinCount: 1,
|
||||
}),
|
||||
).toMatchObject({
|
||||
name: 'sl_plan_completed',
|
||||
properties: {
|
||||
runtime: 'daemon-py',
|
||||
outcome: 'ok',
|
||||
stage: 'transpile',
|
||||
sourceCount: 2,
|
||||
joinCount: 1,
|
||||
},
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
telemetryEventSchemas.sql_gen_completed.parse({
|
||||
...daemonEnvelope,
|
||||
outcome: 'ok',
|
||||
dialect: 'postgres',
|
||||
durationMs: 4,
|
||||
sql: 'select * from private_table',
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('builds a strict install_first_run event', () => {
|
||||
expect(buildTelemetryEvent('install_first_run', envelope, {})).toEqual({
|
||||
name: 'install_first_run',
|
||||
properties: envelope,
|
||||
});
|
||||
});
|
||||
|
||||
it('builds a strict command event with project grouping fields', () => {
|
||||
expect(
|
||||
buildTelemetryEvent('command', envelope, {
|
||||
commandPath: ['ktx', 'status'],
|
||||
durationMs: 12,
|
||||
outcome: 'ok',
|
||||
flagsPresent: { json: true },
|
||||
hasProject: true,
|
||||
projectGroupAttached: true,
|
||||
}),
|
||||
).toEqual({
|
||||
name: 'command',
|
||||
properties: {
|
||||
...envelope,
|
||||
commandPath: ['ktx', 'status'],
|
||||
durationMs: 12,
|
||||
outcome: 'ok',
|
||||
flagsPresent: { json: true },
|
||||
hasProject: true,
|
||||
projectGroupAttached: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unmodeled event properties', () => {
|
||||
expect(() =>
|
||||
telemetryEventSchemas.command.parse({
|
||||
...envelope,
|
||||
commandPath: ['ktx', 'status'],
|
||||
durationMs: 12,
|
||||
outcome: 'ok',
|
||||
flagsPresent: {},
|
||||
hasProject: true,
|
||||
projectGroupAttached: true,
|
||||
tableName: 'private_table',
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('builds strict Phase 2 events without private names or text', () => {
|
||||
expect(
|
||||
buildTelemetryEvent('connection_test', envelope, {
|
||||
driver: 'postgres',
|
||||
isDemoConnection: false,
|
||||
outcome: 'ok',
|
||||
durationMs: 34,
|
||||
serverVersion: '16',
|
||||
}),
|
||||
).toMatchObject({
|
||||
name: 'connection_test',
|
||||
properties: {
|
||||
driver: 'postgres',
|
||||
isDemoConnection: false,
|
||||
outcome: 'ok',
|
||||
durationMs: 34,
|
||||
serverVersion: '16',
|
||||
},
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
telemetryEventSchemas.sql_completed.parse({
|
||||
...envelope,
|
||||
driver: 'postgres',
|
||||
isDemoConnection: false,
|
||||
queryVerb: 'select',
|
||||
referencedTableCount: 1,
|
||||
durationMs: 10,
|
||||
outcome: 'ok',
|
||||
sql: 'select * from private_table',
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('rejects raw private field names that are not in the telemetry schemas', () => {
|
||||
expect(JSON.stringify(telemetryEventSchemas)).not.toContain('tableName');
|
||||
expect(Object.keys(telemetryEventSchemas.sql_completed.shape)).not.toContain('sql');
|
||||
expect(JSON.stringify(telemetryEventSchemas)).not.toContain('path');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
computeTelemetryProjectId,
|
||||
loadTelemetryIdentity,
|
||||
readExistingTelemetryProjectId,
|
||||
TELEMETRY_NOTICE,
|
||||
type TelemetryIdentityEnv,
|
||||
} from './identity.js';
|
||||
|
||||
function makeIo(stdoutIsTTY = true) {
|
||||
let stderr = '';
|
||||
return {
|
||||
io: {
|
||||
stdout: { isTTY: stdoutIsTTY, write: () => {} },
|
||||
stderr: {
|
||||
write: (chunk: string) => {
|
||||
stderr += chunk;
|
||||
},
|
||||
},
|
||||
},
|
||||
stderr: () => stderr,
|
||||
};
|
||||
}
|
||||
|
||||
describe('telemetry identity', () => {
|
||||
let homeDir: string;
|
||||
let env: TelemetryIdentityEnv;
|
||||
|
||||
beforeEach(async () => {
|
||||
homeDir = await mkdtemp(join(tmpdir(), 'ktx-telemetry-home-'));
|
||||
env = {};
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(homeDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('creates the telemetry file and one-line notice on first interactive enabled load', async () => {
|
||||
const testIo = makeIo(true);
|
||||
|
||||
const identity = await loadTelemetryIdentity({
|
||||
homeDir,
|
||||
env,
|
||||
stdoutIsTTY: true,
|
||||
stderr: testIo.io.stderr,
|
||||
now: () => new Date('2026-05-22T14:33:02.000Z'),
|
||||
});
|
||||
|
||||
expect(identity.enabled).toBe(true);
|
||||
expect(identity.installId).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(identity.createdFile).toBe(true);
|
||||
expect(identity.noticeShown).toBe(true);
|
||||
expect(testIo.stderr()).toBe(`[2m${TELEMETRY_NOTICE}[22m\n`);
|
||||
|
||||
const stored = JSON.parse(await readFile(join(homeDir, '.ktx', 'telemetry.json'), 'utf-8')) as {
|
||||
enabled: boolean;
|
||||
noticeShownVersion: number;
|
||||
};
|
||||
expect(stored.enabled).toBe(true);
|
||||
expect(stored.noticeShownVersion).toBe(1);
|
||||
});
|
||||
|
||||
it('emits the notice without ANSI when NO_COLOR is set', async () => {
|
||||
const testIo = makeIo(true);
|
||||
|
||||
await loadTelemetryIdentity({
|
||||
homeDir,
|
||||
env: { NO_COLOR: '1' },
|
||||
stdoutIsTTY: true,
|
||||
stderr: testIo.io.stderr,
|
||||
now: () => new Date('2026-05-22T14:33:02.000Z'),
|
||||
});
|
||||
|
||||
expect(testIo.stderr()).toBe(`${TELEMETRY_NOTICE}\n`);
|
||||
});
|
||||
|
||||
it('does not create a file when env disables telemetry', async () => {
|
||||
const identity = await loadTelemetryIdentity({
|
||||
homeDir,
|
||||
env: { KTX_TELEMETRY_DISABLED: '1' },
|
||||
stdoutIsTTY: true,
|
||||
stderr: makeIo(true).io.stderr,
|
||||
now: () => new Date('2026-05-22T14:33:02.000Z'),
|
||||
});
|
||||
|
||||
expect(identity.enabled).toBe(false);
|
||||
await expect(readFile(join(homeDir, '.ktx', 'telemetry.json'), 'utf-8')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('does not create a file for CI or non-TTY command invocations', async () => {
|
||||
await expect(
|
||||
loadTelemetryIdentity({
|
||||
homeDir,
|
||||
env: { CI: '1' },
|
||||
stdoutIsTTY: true,
|
||||
stderr: makeIo(true).io.stderr,
|
||||
now: () => new Date('2026-05-22T14:33:02.000Z'),
|
||||
}),
|
||||
).resolves.toMatchObject({ enabled: false, createdFile: false });
|
||||
|
||||
await expect(
|
||||
loadTelemetryIdentity({
|
||||
homeDir,
|
||||
env: {},
|
||||
stdoutIsTTY: false,
|
||||
stderr: makeIo(false).io.stderr,
|
||||
now: () => new Date('2026-05-22T14:33:02.000Z'),
|
||||
}),
|
||||
).resolves.toMatchObject({ enabled: false, createdFile: false });
|
||||
});
|
||||
|
||||
it('honors persistent enabled false', async () => {
|
||||
await mkdir(join(homeDir, '.ktx'), { recursive: true });
|
||||
await writeFile(
|
||||
join(homeDir, '.ktx', 'telemetry.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
installId: '00000000-0000-4000-8000-000000000000',
|
||||
enabled: false,
|
||||
noticeShownAt: '2026-05-22T14:33:02.000Z',
|
||||
noticeShownVersion: 1,
|
||||
createdAt: '2026-05-22T14:33:02.000Z',
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + '\n',
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
await expect(
|
||||
loadTelemetryIdentity({
|
||||
homeDir,
|
||||
env,
|
||||
stdoutIsTTY: true,
|
||||
stderr: makeIo(true).io.stderr,
|
||||
now: () => new Date('2026-05-22T15:00:00.000Z'),
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
installId: '00000000-0000-4000-8000-000000000000',
|
||||
enabled: false,
|
||||
createdFile: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('recreates a corrupted file instead of surfacing an error to users', async () => {
|
||||
await mkdir(join(homeDir, '.ktx'), { recursive: true });
|
||||
await writeFile(join(homeDir, '.ktx', 'telemetry.json'), '{bad json', 'utf-8');
|
||||
|
||||
const identity = await loadTelemetryIdentity({
|
||||
homeDir,
|
||||
env,
|
||||
stdoutIsTTY: true,
|
||||
stderr: makeIo(true).io.stderr,
|
||||
now: () => new Date('2026-05-22T14:33:02.000Z'),
|
||||
});
|
||||
|
||||
expect(identity.enabled).toBe(true);
|
||||
expect(identity.createdFile).toBe(true);
|
||||
});
|
||||
|
||||
it('derives a salted project hash without exposing the path', () => {
|
||||
const projectDir = resolve('/tmp/acme-private-project');
|
||||
const projectId = computeTelemetryProjectId('00000000-0000-4000-8000-000000000000', projectDir);
|
||||
|
||||
expect(projectId).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(projectId).not.toContain('acme');
|
||||
expect(computeTelemetryProjectId('00000000-0000-4000-8000-000000000000', projectDir)).toBe(projectId);
|
||||
expect(computeTelemetryProjectId('11111111-1111-4111-8111-111111111111', projectDir)).not.toBe(projectId);
|
||||
});
|
||||
|
||||
it('reads an existing project id for Python telemetry without creating identity', async () => {
|
||||
await mkdir(join(homeDir, '.ktx'), { recursive: true });
|
||||
await writeFile(
|
||||
join(homeDir, '.ktx', 'telemetry.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
installId: '00000000-0000-4000-8000-000000000000',
|
||||
enabled: true,
|
||||
noticeShownAt: '2026-05-22T14:33:02.000Z',
|
||||
noticeShownVersion: 1,
|
||||
createdAt: '2026-05-22T14:33:02.000Z',
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + '\n',
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
await expect(
|
||||
readExistingTelemetryProjectId({
|
||||
homeDir,
|
||||
projectDir: '/tmp/acme-private-project',
|
||||
env: {},
|
||||
}),
|
||||
).resolves.toMatch(/^[a-f0-9]{64}$/);
|
||||
|
||||
await expect(
|
||||
readExistingTelemetryProjectId({
|
||||
homeDir,
|
||||
projectDir: '/tmp/acme-private-project',
|
||||
env: { KTX_TELEMETRY_DISABLED: '1' },
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -55,6 +55,10 @@ const emittedProjectSnapshots = new Set<string>();
|
|||
const MCP_SAMPLE_RATE = 0.1 as const;
|
||||
let mcpSampled: boolean | undefined;
|
||||
|
||||
function telemetryDebugEnabled(): boolean {
|
||||
return process.env.KTX_TELEMETRY_DEBUG === '1';
|
||||
}
|
||||
|
||||
export function shouldEmitMcpTelemetry(): boolean {
|
||||
mcpSampled ??= Math.random() < MCP_SAMPLE_RATE;
|
||||
return mcpSampled;
|
||||
|
|
@ -71,19 +75,21 @@ export async function emitTelemetryEvent<Name extends TelemetryEventName>(input:
|
|||
packageInfo?: KtxCliPackageInfo;
|
||||
projectDir?: string;
|
||||
}): Promise<void> {
|
||||
const debug = telemetryDebugEnabled();
|
||||
const identity = await loadTelemetryIdentity({
|
||||
stdoutIsTTY: input.io.stdout.isTTY === true,
|
||||
stderr: input.io.stderr,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
if (!identity.enabled || !identity.installId) {
|
||||
if ((!identity.enabled || !identity.installId) && !debug) {
|
||||
return;
|
||||
}
|
||||
|
||||
const packageInfo = input.packageInfo ?? getKtxCliPackageInfo();
|
||||
const installId = identity.installId ?? 'debug';
|
||||
|
||||
const projectId = input.projectDir ? computeTelemetryProjectId(identity.installId, input.projectDir) : undefined;
|
||||
const projectId = input.projectDir ? computeTelemetryProjectId(installId, input.projectDir) : undefined;
|
||||
await trackTelemetryEvent({
|
||||
event: buildTelemetryEvent(
|
||||
input.name,
|
||||
|
|
@ -93,7 +99,7 @@ export async function emitTelemetryEvent<Name extends TelemetryEventName>(input:
|
|||
}),
|
||||
input.fields,
|
||||
),
|
||||
distinctId: identity.installId,
|
||||
distinctId: installId,
|
||||
projectId,
|
||||
env: process.env,
|
||||
stderr: input.io.stderr,
|
||||
|
|
|
|||
|
|
@ -1,78 +0,0 @@
|
|||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildProjectStackSnapshotFields } from './project-snapshot.js';
|
||||
|
||||
describe('buildProjectStackSnapshotFields', () => {
|
||||
let projectDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
projectDir = await mkdtemp(join(tmpdir(), 'ktx-stack-snapshot-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(projectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('summarizes connectors and project capabilities without names or paths', async () => {
|
||||
await mkdir(join(projectDir, 'semantic-layer', 'warehouse'), { recursive: true });
|
||||
await mkdir(join(projectDir, 'wiki', 'global'), { recursive: true });
|
||||
await writeFile(join(projectDir, 'semantic-layer', 'warehouse', 'orders.yaml'), 'name: orders\n');
|
||||
await writeFile(join(projectDir, 'wiki', 'global', 'revenue.md'), '# Revenue\n');
|
||||
await writeFile(join(projectDir, '.mcp.json'), '{"mcpServers":{"ktx":{}}}\n');
|
||||
|
||||
const fields = await buildProjectStackSnapshotFields({
|
||||
projectDir,
|
||||
config: {
|
||||
connections: {
|
||||
orbit_demo: { driver: 'sqlite', path: join(projectDir, 'demo.db') },
|
||||
warehouse: { driver: 'postgres', readonly: true },
|
||||
},
|
||||
ingest: {
|
||||
adapters: [],
|
||||
embeddings: { backend: 'sentence-transformers', dimensions: 384 },
|
||||
workUnits: { stepBudget: 40, maxConcurrency: 1, failureMode: 'continue' },
|
||||
},
|
||||
llm: { provider: { backend: 'none' }, models: {}, promptCaching: {} },
|
||||
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,
|
||||
},
|
||||
},
|
||||
storage: {
|
||||
state: 'sqlite',
|
||||
search: 'sqlite-fts5',
|
||||
git: { auto_commit: true, author: 'ktx <ktx@example.com>' },
|
||||
},
|
||||
agent: { run_research: { enabled: false, max_iterations: 20, default_toolset: [] } },
|
||||
memory: { auto_commit: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(fields).toEqual({
|
||||
connectors: [
|
||||
{ driver: 'sqlite', isDemo: true },
|
||||
{ driver: 'postgres', isDemo: false },
|
||||
],
|
||||
connectionCount: 2,
|
||||
hasSl: true,
|
||||
hasWiki: true,
|
||||
hasMcp: true,
|
||||
hasManagedRuntime: true,
|
||||
});
|
||||
expect(JSON.stringify(fields)).not.toContain(projectDir);
|
||||
expect(JSON.stringify(fields)).not.toContain('warehouse');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildTelemetrySchemaArtifact } from './schema-writer.js';
|
||||
|
||||
describe('telemetry schema writer', () => {
|
||||
it('exports a schema artifact with the full catalog and strict metadata', () => {
|
||||
const artifact = buildTelemetrySchemaArtifact();
|
||||
|
||||
expect(artifact.$schema).toBe('https://json-schema.org/draft/2020-12/schema');
|
||||
expect(artifact['x-ktx-common-fields']).toEqual([
|
||||
'cliVersion',
|
||||
'nodeVersion',
|
||||
'osPlatform',
|
||||
'osRelease',
|
||||
'arch',
|
||||
'runtime',
|
||||
'isCi',
|
||||
]);
|
||||
expect(artifact['x-ktx-catalog'].map((event) => event.name)).toContain('daemon_started');
|
||||
expect(artifact['x-ktx-catalog'].map((event) => event.name)).toContain('sql_gen_completed');
|
||||
expect(artifact.$defs.sql_gen_completed).toMatchObject({
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { scrubErrorClass } from './scrubber.js';
|
||||
|
||||
class KtxProjectMissingAbortError extends Error {}
|
||||
|
||||
describe('scrubErrorClass', () => {
|
||||
it('keeps normal JavaScript class names', () => {
|
||||
expect(scrubErrorClass(new KtxProjectMissingAbortError('missing'))).toBe('KtxProjectMissingAbortError');
|
||||
});
|
||||
|
||||
it('drops path-like, URL-like, email-like, and long values', () => {
|
||||
expect(scrubErrorClass({ constructor: { name: '/Users/alice/project' } })).toBeUndefined();
|
||||
expect(scrubErrorClass({ constructor: { name: 'https://example.test/error' } })).toBeUndefined();
|
||||
expect(scrubErrorClass({ constructor: { name: 'alice@example.test' } })).toBeUndefined();
|
||||
expect(scrubErrorClass({ constructor: { name: 'A'.repeat(81) } })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops lowercase, spaced, and non-error-like values', () => {
|
||||
expect(scrubErrorClass({ constructor: { name: 'lowercaseError' } })).toBeUndefined();
|
||||
expect(scrubErrorClass({ constructor: { name: 'Bad Error' } })).toBeUndefined();
|
||||
expect(scrubErrorClass('plain string')).toBeUndefined();
|
||||
expect(scrubErrorClass(null)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue