mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-13 08:15:14 +02:00
* Improve schema setup and Notion ingest UX * Handle Postgres network scan failures * WIP: save local changes before main merge * Refine setup prompt choices * Tighten ingest reconciliation guidance * Commit setup config updates * Canonicalize unmapped fallback details * Count reconciliation actions in reports * Harden semantic layer source validation * Return wiki content after edits * Validate SL sources against manifests * Validate wiki refs before writes * Simplify CLI next steps * Clarify agent setup summary * Surface dbt target SL sources * Recover SL write fallbacks * Preserve failed context build metadata * Track raw paths for ingest actions * test(cli): update seeded demo expectations * fix(ingest): scope fallback recovery checks * fix(sl): tighten source validation guards * fix(wiki): ignore empty embedding vectors * Improve Notion ingest UX * Enforce flat wiki keys * test(context): update wiki key assertion --------- Co-authored-by: Andrey Avtomonov <andreybavt@gmail.com>
766 lines
28 KiB
TypeScript
766 lines
28 KiB
TypeScript
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import type { IngestReportSnapshot, MemoryFlowReplayInput } from '@ktx/context/ingest';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { runKtxDemo } from './demo.js';
|
|
import { DEMO_FULL_JOB_ID, defaultDemoProjectDir, ensureDemoProject } from './demo-assets.js';
|
|
import type { DemoFullResult } from './demo-full.js';
|
|
import { createTestDemoPromptAdapter } from './demo-interaction.js';
|
|
import type { renderMemoryFlowTui } from './memory-flow-tui.js';
|
|
import { KTX_NEXT_STEP_DIRECT_COMMANDS } from './next-steps.js';
|
|
import { resetVizFallbackWarningsForTest } from './viz-fallback.js';
|
|
|
|
const SEEDED_DEMO_SEMANTIC_SOURCE_COUNT = 46;
|
|
const SEEDED_DEMO_KNOWLEDGE_PAGE_COUNT = 28;
|
|
|
|
function makeIo(options: { isTTY?: boolean; columns?: number; rawMode?: boolean } = {}) {
|
|
let stdout = '';
|
|
let stderr = '';
|
|
return {
|
|
io: {
|
|
stdin: {
|
|
isTTY: options.isTTY ?? false,
|
|
...(options.rawMode === false ? {} : { setRawMode: vi.fn() }),
|
|
},
|
|
stdout: {
|
|
isTTY: options.isTTY ?? false,
|
|
columns: options.columns ?? 140,
|
|
write: (chunk: string) => {
|
|
stdout += chunk;
|
|
},
|
|
},
|
|
stderr: {
|
|
write: (chunk: string) => {
|
|
stderr += chunk;
|
|
},
|
|
},
|
|
},
|
|
stdout: () => stdout,
|
|
stderr: () => stderr,
|
|
};
|
|
}
|
|
|
|
function fakeFullResult(projectDir: string): DemoFullResult {
|
|
const report: IngestReportSnapshot = {
|
|
id: 'report-full',
|
|
runId: 'run-full',
|
|
jobId: DEMO_FULL_JOB_ID,
|
|
connectionId: 'orbit_demo',
|
|
sourceKey: 'live-database',
|
|
createdAt: '2026-05-01T00:00:00.000Z',
|
|
body: {
|
|
syncId: 'sync-full',
|
|
diffSummary: { added: 7, modified: 0, deleted: 0, unchanged: 0 },
|
|
commitSha: null,
|
|
workUnits: [
|
|
{
|
|
unitKey: 'accounts',
|
|
rawFiles: ['accounts.schema.json'],
|
|
status: 'success',
|
|
actions: [
|
|
{ target: 'wiki', type: 'created', key: 'knowledge/accounts.md', detail: 'account lifecycle context' },
|
|
{ target: 'sl', type: 'created', key: 'orbit_demo.accounts', detail: 'accounts semantic source' },
|
|
],
|
|
touchedSlSources: [{ connectionId: 'orbit_demo', sourceName: 'orbit_demo.accounts' }],
|
|
},
|
|
],
|
|
failedWorkUnits: [],
|
|
reconciliationSkipped: false,
|
|
conflictsResolved: [],
|
|
evictionsApplied: [],
|
|
unmappedFallbacks: [],
|
|
evictionInputs: [],
|
|
unresolvedCards: [],
|
|
supersededBy: null,
|
|
overrideOf: null,
|
|
provenanceRows: [
|
|
{
|
|
rawPath: 'accounts.schema.json',
|
|
artifactKind: 'wiki',
|
|
artifactKey: 'knowledge/accounts.md',
|
|
actionType: 'wiki_written',
|
|
},
|
|
],
|
|
toolTranscripts: [],
|
|
},
|
|
};
|
|
|
|
return {
|
|
project: { projectDir } as never,
|
|
scan: { report: { runId: 'scan-run' } } as never,
|
|
ingest: { result: { ok: true }, report } as never,
|
|
report,
|
|
replay: {
|
|
runId: 'run-full',
|
|
connectionId: 'orbit_demo',
|
|
adapter: 'live-database',
|
|
status: 'done',
|
|
sourceDir: `${projectDir}/raw-sources/orbit_demo/live-database/sync-full`,
|
|
syncId: 'sync-full',
|
|
errors: [],
|
|
events: [
|
|
{ type: 'source_acquired', adapter: 'live-database', trigger: 'demo_full', fileCount: 7 },
|
|
{ type: 'saved', commitSha: null, wikiCount: 1, slCount: 1 },
|
|
{ type: 'provenance_recorded', rowCount: 1 },
|
|
{ type: 'report_created', runId: 'run-full', reportPath: 'report-full' },
|
|
],
|
|
plannedWorkUnits: [],
|
|
details: { actions: [], provenance: [], transcripts: [] },
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('runKtxDemo', () => {
|
|
let tempDir: string;
|
|
|
|
beforeEach(async () => {
|
|
resetVizFallbackWarningsForTest();
|
|
tempDir = await mkdtemp(join(tmpdir(), 'ktx-demo-command-'));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rm(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('initializes the demo project', async () => {
|
|
const io = makeIo();
|
|
await expect(
|
|
runKtxDemo({ command: 'init', projectDir: tempDir, force: false, inputMode: 'disabled' }, io.io),
|
|
).resolves.toBe(0);
|
|
|
|
expect(io.stdout()).toContain(`Demo project: ${tempDir}`);
|
|
expect(io.stdout()).toContain('Config:');
|
|
expect(io.stdout()).toContain('Replay:');
|
|
expect(io.stderr()).toBe('');
|
|
});
|
|
|
|
it('renders the packaged replay in no-input viz mode', async () => {
|
|
const io = makeIo({ isTTY: true });
|
|
await expect(
|
|
runKtxDemo(
|
|
{ command: 'replay', projectDir: tempDir, outputMode: 'viz', inputMode: 'disabled' },
|
|
io.io,
|
|
{ env: { ...process.env, TERM: 'xterm-256color' } },
|
|
),
|
|
).resolves.toBe(0);
|
|
|
|
expect(io.stdout()).toContain('KTX memory flow Warehouse + dbt + BI + Docs done');
|
|
expect(io.stdout()).toContain('Saved 16 memories');
|
|
expect(io.stderr()).toBe('');
|
|
});
|
|
|
|
it('routes interactive packaged replay viz through the stored TUI renderer', async () => {
|
|
const io = makeIo({ isTTY: true });
|
|
const renderStoredMemoryFlow = vi.fn<typeof renderMemoryFlowTui>(async () => true);
|
|
|
|
await expect(
|
|
runKtxDemo(
|
|
{ command: 'replay', projectDir: tempDir, outputMode: 'viz' },
|
|
io.io,
|
|
{ env: { ...process.env, TERM: 'xterm-256color' }, renderStoredMemoryFlow },
|
|
),
|
|
).resolves.toBe(0);
|
|
|
|
expect(renderStoredMemoryFlow).toHaveBeenCalledTimes(1);
|
|
expect(renderStoredMemoryFlow.mock.calls[0]?.[0]).toMatchObject({
|
|
runId: 'demo-seeded-orbit',
|
|
connectionId: 'orbit_demo',
|
|
adapter: 'live-database',
|
|
});
|
|
expect(renderStoredMemoryFlow.mock.calls[0]?.[2]).toEqual({ speedMultiplier: 0.125 });
|
|
expect(io.stdout()).toContain('KTX finished ingesting your data');
|
|
expect(io.stderr()).toBe('');
|
|
});
|
|
|
|
it('routes interactive seeded demo viz through the stored TUI renderer at eighth speed', async () => {
|
|
const io = makeIo({ isTTY: true });
|
|
const renderStoredMemoryFlow = vi.fn<typeof renderMemoryFlowTui>(async () => true);
|
|
|
|
await expect(
|
|
runKtxDemo(
|
|
{ command: 'seeded', projectDir: tempDir, outputMode: 'viz' },
|
|
io.io,
|
|
{ env: { ...process.env, TERM: 'xterm-256color' }, renderStoredMemoryFlow },
|
|
),
|
|
).resolves.toBe(0);
|
|
|
|
expect(renderStoredMemoryFlow).toHaveBeenCalledTimes(1);
|
|
expect(renderStoredMemoryFlow.mock.calls[0]?.[2]).toEqual({ speedMultiplier: 0.125 });
|
|
expect(io.stdout()).toContain('KTX finished ingesting your data');
|
|
expect(io.stderr()).toBe('');
|
|
});
|
|
|
|
it('falls back to plain replay output when interactive replay viz lacks stdin raw mode', async () => {
|
|
const io = makeIo({ isTTY: true, rawMode: false });
|
|
const renderStoredMemoryFlow = vi.fn(async (_input: MemoryFlowReplayInput, _io: unknown) => true);
|
|
|
|
await expect(
|
|
runKtxDemo(
|
|
{ command: 'replay', projectDir: tempDir, outputMode: 'viz' },
|
|
io.io,
|
|
{ env: { ...process.env, TERM: 'xterm-256color' }, renderStoredMemoryFlow },
|
|
),
|
|
).resolves.toBe(0);
|
|
|
|
expect(renderStoredMemoryFlow).not.toHaveBeenCalled();
|
|
expect(io.stdout()).toContain('Memory-flow summary: done');
|
|
expect(io.stdout()).toContain('Connection: orbit_demo');
|
|
expect(io.stdout()).toContain('ktx sl list');
|
|
expect(io.stdout()).toContain('ktx wiki list');
|
|
expect(io.stdout()).not.toContain('ktx serve --mcp stdio --user-id local');
|
|
expect(io.stdout()).not.toContain('KTX memory flow');
|
|
expect(io.stderr()).toContain(
|
|
'Visualization requested but stdin raw mode is unavailable; printing plain output.',
|
|
);
|
|
});
|
|
|
|
it('degrades default visual demo replay to a plain memory-flow summary when stdout is redirected', async () => {
|
|
const testIo = makeIo({ isTTY: false });
|
|
|
|
await expect(
|
|
runKtxDemo({ command: 'replay', projectDir: tempDir, outputMode: 'viz', inputMode: 'disabled' }, testIo.io),
|
|
).resolves.toBe(0);
|
|
|
|
expect(testIo.stdout()).toContain('Memory-flow summary: done');
|
|
expect(testIo.stdout()).toContain('Connection: orbit_demo');
|
|
expect(testIo.stdout()).toContain('ktx sl list');
|
|
expect(testIo.stdout()).toContain('ktx wiki list');
|
|
expect(testIo.stdout()).not.toContain('ktx serve --mcp stdio --user-id local');
|
|
expect(testIo.stdout()).not.toContain('KTX memory flow');
|
|
expect(testIo.stderr()).toContain(
|
|
'Visualization requested but stdout is not an interactive terminal; printing plain output.',
|
|
);
|
|
});
|
|
|
|
it('prints JSON replay output when requested', async () => {
|
|
const io = makeIo();
|
|
await expect(
|
|
runKtxDemo({ command: 'replay', projectDir: tempDir, outputMode: 'json', inputMode: 'disabled' }, io.io),
|
|
).resolves.toBe(0);
|
|
|
|
expect(JSON.parse(io.stdout())).toMatchObject({ runId: 'demo-seeded-orbit', connectionId: 'orbit_demo' });
|
|
expect(io.stderr()).toBe('');
|
|
});
|
|
|
|
it('runs the packaged SQLite demo scan', async () => {
|
|
const io = makeIo();
|
|
await expect(runKtxDemo({ command: 'scan', projectDir: tempDir, inputMode: 'disabled' }, io.io)).resolves.toBe(0);
|
|
|
|
expect(io.stdout()).toContain('Demo scan: done');
|
|
expect(io.stdout()).toContain('Connection: orbit_demo');
|
|
expect(io.stdout()).toContain('Driver: sqlite');
|
|
expect(io.stdout()).toContain('Report: raw-sources/orbit_demo/live-database/');
|
|
expect(io.stderr()).toBe('');
|
|
});
|
|
|
|
it('runs seeded mode with pre-seeded assets and inspect summary', async () => {
|
|
const io = makeIo({ isTTY: true });
|
|
await expect(
|
|
runKtxDemo(
|
|
{ command: 'seeded', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
|
|
io.io,
|
|
{ env: { ...process.env, TERM: 'xterm-256color' } },
|
|
),
|
|
).resolves.toBe(0);
|
|
|
|
expect(io.stdout()).toContain('Mode: seeded');
|
|
expect(io.stdout()).toContain('LLM calls: none');
|
|
expect(io.stdout()).toContain('Semantic-layer sources:');
|
|
expect(io.stdout()).toContain('Knowledge pages:');
|
|
expect(io.stderr()).toBe('');
|
|
});
|
|
|
|
it('uses seeded mode as the default demo and creates a temp project when no project-dir is supplied', async () => {
|
|
const io = makeIo();
|
|
|
|
await expect(
|
|
runKtxDemo(
|
|
{ command: 'seeded', projectDir: defaultDemoProjectDir(), outputMode: 'plain', inputMode: 'disabled' },
|
|
io.io,
|
|
),
|
|
).resolves.toBe(0);
|
|
|
|
expect(io.stdout()).toContain('Mode: seeded');
|
|
expect(io.stdout()).toContain('Source: packaged demo project');
|
|
expect(io.stdout()).toContain('Generated context: prebuilt from bundled assets');
|
|
expect(io.stdout()).toContain('LLM calls: none');
|
|
expect(io.stdout()).toContain('Your KTX project files are at:');
|
|
expect(io.stdout()).toContain(join(tmpdir(), 'ktx-demo-'));
|
|
expect(io.stdout()).not.toContain('ktx serve --mcp stdio');
|
|
expect(io.stdout()).not.toContain(['ktx', 'mcp'].join(' '));
|
|
expect(io.stdout()).not.toContain('deterministic');
|
|
});
|
|
|
|
it('degrades default visual seeded demo to plain output when TERM is dumb', async () => {
|
|
const testIo = makeIo({ isTTY: true, columns: 120 });
|
|
|
|
await expect(
|
|
runKtxDemo(
|
|
{ command: 'seeded', projectDir: tempDir, outputMode: 'viz', inputMode: 'disabled' },
|
|
testIo.io,
|
|
{ env: { ...process.env, TERM: 'dumb' } },
|
|
),
|
|
).resolves.toBe(0);
|
|
|
|
expect(testIo.stdout()).toContain('Mode: seeded');
|
|
expect(testIo.stdout()).toContain('LLM calls: none');
|
|
expect(testIo.stderr()).toContain(
|
|
'Visualization requested but TERM=dumb does not support the visual renderer; printing plain output.',
|
|
);
|
|
});
|
|
|
|
it('prints demo inspect as plain text and JSON', async () => {
|
|
const seededIo = makeIo();
|
|
await expect(
|
|
runKtxDemo({ command: 'seeded', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' }, seededIo.io),
|
|
).resolves.toBe(0);
|
|
|
|
const plainIo = makeIo();
|
|
await expect(
|
|
runKtxDemo({ command: 'inspect', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' }, plainIo.io),
|
|
).resolves.toBe(0);
|
|
expect(plainIo.stdout()).toContain('Mode: seeded');
|
|
expect(plainIo.stdout()).toContain('Semantic-layer sources:');
|
|
|
|
const jsonIo = makeIo();
|
|
await expect(
|
|
runKtxDemo({ command: 'inspect', projectDir: tempDir, outputMode: 'json', inputMode: 'disabled' }, jsonIo.io),
|
|
).resolves.toBe(0);
|
|
const parsed = JSON.parse(jsonIo.stdout());
|
|
expect(parsed).toMatchObject({
|
|
projectDir: tempDir,
|
|
mode: 'seeded',
|
|
status: { status: 'ready', missing: [] },
|
|
sourceBundle: {
|
|
warehouse: { tableCount: 8, totalRows: 11234 },
|
|
dbt: { modelCount: 3, sourceTableCount: 8 },
|
|
bi: { exploreCount: 5, dashboardCount: 2 },
|
|
notion: { pageCount: 8 },
|
|
},
|
|
generatedOutputs: {
|
|
semanticLayer: {
|
|
manifestSourceCount: SEEDED_DEMO_SEMANTIC_SOURCE_COUNT,
|
|
fileCount: SEEDED_DEMO_SEMANTIC_SOURCE_COUNT,
|
|
},
|
|
knowledge: {
|
|
manifestPageCount: SEEDED_DEMO_KNOWLEDGE_PAGE_COUNT,
|
|
fileCount: SEEDED_DEMO_KNOWLEDGE_PAGE_COUNT,
|
|
},
|
|
links: { manifestLinkCount: 23, linkCount: 23 },
|
|
reports: { primaryPath: 'reports/seeded-demo-report.json', fileCount: 1 },
|
|
},
|
|
modeMetadata: {
|
|
mode: 'seeded',
|
|
source: 'packaged demo project',
|
|
generatedContext: 'prebuilt from bundled assets',
|
|
llmCalls: 'none',
|
|
},
|
|
nextCommands: KTX_NEXT_STEP_DIRECT_COMMANDS,
|
|
});
|
|
expect(parsed.generatedOutputs.replays.fileCount).toBeGreaterThanOrEqual(3);
|
|
expect(jsonIo.stderr()).toBe('');
|
|
});
|
|
|
|
it('routes top-level full mode and prints memory-flow plus final summary', async () => {
|
|
const testIo = makeIo({ isTTY: true });
|
|
const runFullDemo = vi.fn().mockResolvedValue(fakeFullResult(tempDir));
|
|
await ensureDemoProject({ projectDir: tempDir, force: false });
|
|
|
|
await expect(
|
|
runKtxDemo({ command: 'full', projectDir: tempDir, outputMode: 'viz', inputMode: 'disabled' }, testIo.io, {
|
|
env: {},
|
|
runFullDemo,
|
|
}),
|
|
).resolves.toBe(0);
|
|
|
|
expect(runFullDemo).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
projectDir: tempDir,
|
|
env: {},
|
|
onMemoryFlowChange: expect.any(Function),
|
|
}),
|
|
);
|
|
expect(testIo.stdout()).toContain('KTX memory flow orbit_demo/live-database done');
|
|
expect(testIo.stdout()).toContain('Full demo ingest: done');
|
|
expect(testIo.stdout()).toContain('Next: ktx setup demo inspect');
|
|
expect(testIo.stdout()).toContain('Shows the files, semantic-layer sources, and memory KTX just produced.');
|
|
});
|
|
|
|
it('streams live memory-flow snapshots for full demo viz and then prints final summary', async () => {
|
|
const testIo = makeIo({ isTTY: true, columns: 120 });
|
|
const liveSession = {
|
|
update: vi.fn(),
|
|
close: vi.fn(),
|
|
isClosed: vi.fn(() => false),
|
|
};
|
|
const startLiveMemoryFlow = vi.fn(async (_input: MemoryFlowReplayInput, _io: unknown) => liveSession);
|
|
const runFullDemo = vi.fn(
|
|
async (options: { projectDir: string; onMemoryFlowChange?: (snapshot: MemoryFlowReplayInput) => void }) => {
|
|
options.onMemoryFlowChange?.({
|
|
...fakeFullResult(tempDir).replay,
|
|
status: 'running',
|
|
events: [{ type: 'source_acquired', adapter: 'live-database', trigger: 'demo_full', fileCount: 7 }],
|
|
});
|
|
return fakeFullResult(tempDir);
|
|
},
|
|
);
|
|
await ensureDemoProject({ projectDir: tempDir, force: false });
|
|
|
|
await expect(
|
|
runKtxDemo({ command: 'full', projectDir: tempDir, outputMode: 'viz' }, testIo.io, {
|
|
env: { ANTHROPIC_API_KEY: 'sk-ant-test' }, // pragma: allowlist secret
|
|
prompts: createTestDemoPromptAdapter({ choices: ['reuse'] }),
|
|
runFullDemo,
|
|
startLiveMemoryFlow,
|
|
}),
|
|
).resolves.toBe(0);
|
|
|
|
expect(startLiveMemoryFlow).toHaveBeenCalledTimes(1);
|
|
expect(liveSession.update).toHaveBeenCalledTimes(1);
|
|
expect(liveSession.close).toHaveBeenCalledTimes(1);
|
|
expect(testIo.stdout()).not.toContain('Memory-flow summary: done');
|
|
expect(testIo.stdout()).toContain('KTX finished ingesting your data');
|
|
expect(testIo.stdout()).toContain('ktx sl list');
|
|
expect(testIo.stdout()).toContain('ktx wiki list');
|
|
expect(testIo.stdout()).not.toContain('ktx serve --mcp stdio --user-id local');
|
|
expect(testIo.stdout()).not.toContain(['ktx', 'ask'].join(' '));
|
|
expect(testIo.stdout()).not.toContain(['ktx', 'mcp'].join(' '));
|
|
});
|
|
|
|
it('uses plain progress for full demo viz when stdin raw mode is unavailable', async () => {
|
|
const testIo = makeIo({ isTTY: true, rawMode: false, columns: 120 });
|
|
const liveSession = {
|
|
update: vi.fn(),
|
|
close: vi.fn(),
|
|
isClosed: vi.fn(() => false),
|
|
};
|
|
const startLiveMemoryFlow = vi.fn(async (_input: MemoryFlowReplayInput, _io: unknown) => liveSession);
|
|
const runFullDemo = vi.fn(
|
|
async (options: { projectDir: string; onMemoryFlowChange?: (snapshot: MemoryFlowReplayInput) => void }) => {
|
|
options.onMemoryFlowChange?.({
|
|
...fakeFullResult(tempDir).replay,
|
|
status: 'running',
|
|
events: [{ type: 'source_acquired', adapter: 'live-database', trigger: 'demo_full', fileCount: 7 }],
|
|
});
|
|
return fakeFullResult(tempDir);
|
|
},
|
|
);
|
|
await ensureDemoProject({ projectDir: tempDir, force: false });
|
|
|
|
await expect(
|
|
runKtxDemo({ command: 'full', projectDir: tempDir, outputMode: 'viz' }, testIo.io, {
|
|
env: { ANTHROPIC_API_KEY: 'sk-ant-test' }, // pragma: allowlist secret
|
|
prompts: createTestDemoPromptAdapter({ choices: ['reuse'] }),
|
|
runFullDemo,
|
|
startLiveMemoryFlow,
|
|
}),
|
|
).resolves.toBe(0);
|
|
|
|
expect(startLiveMemoryFlow).not.toHaveBeenCalled();
|
|
expect(runFullDemo).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
onMemoryFlowChange: expect.any(Function),
|
|
}),
|
|
);
|
|
expect(testIo.stdout()).toContain('[connect] Connected live-database - 7 database files (demo_full)');
|
|
expect(testIo.stdout()).toContain('Full demo ingest: done');
|
|
expect(testIo.stdout()).not.toContain('KTX memory flow');
|
|
expect(testIo.stderr()).toContain(
|
|
'Visualization requested but stdin raw mode is unavailable; printing plain output.',
|
|
);
|
|
});
|
|
|
|
it('streams plain-text progress lines for full demo when no live TUI is active', async () => {
|
|
const testIo = makeIo();
|
|
const runFullDemo = vi.fn(
|
|
async (options: { projectDir: string; onMemoryFlowChange?: (snapshot: MemoryFlowReplayInput) => void }) => {
|
|
const baseSnapshot = fakeFullResult(tempDir).replay;
|
|
options.onMemoryFlowChange?.({
|
|
...baseSnapshot,
|
|
status: 'running',
|
|
events: [{ type: 'source_acquired', adapter: 'live-database', trigger: 'manual_resync', fileCount: 7 }],
|
|
});
|
|
options.onMemoryFlowChange?.({
|
|
...baseSnapshot,
|
|
status: 'running',
|
|
events: [
|
|
{ type: 'source_acquired', adapter: 'live-database', trigger: 'manual_resync', fileCount: 7 },
|
|
{ type: 'diff_computed', added: 0, modified: 0, deleted: 0, unchanged: 7 },
|
|
],
|
|
});
|
|
return fakeFullResult(tempDir);
|
|
},
|
|
);
|
|
await ensureDemoProject({ projectDir: tempDir, force: false });
|
|
|
|
await expect(
|
|
runKtxDemo(
|
|
{ command: 'full', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
|
|
testIo.io,
|
|
{ env: { ANTHROPIC_API_KEY: 'sk-ant-test' }, runFullDemo }, // pragma: allowlist secret
|
|
),
|
|
).resolves.toBe(0);
|
|
|
|
const stdout = testIo.stdout();
|
|
expect(stdout).toContain('[connect] Connected live-database - 7 database files (manual_resync)');
|
|
expect(stdout).toContain('[diff] Tables: =7 unchanged');
|
|
expect(stdout).toContain('Full demo ingest: done');
|
|
});
|
|
|
|
it('skips plain progress lines for json output mode', async () => {
|
|
const testIo = makeIo();
|
|
const runFullDemo = vi.fn(
|
|
async (options: { projectDir: string; onMemoryFlowChange?: (snapshot: MemoryFlowReplayInput) => void }) => {
|
|
expect(options.onMemoryFlowChange).toBeUndefined();
|
|
return fakeFullResult(tempDir);
|
|
},
|
|
);
|
|
await ensureDemoProject({ projectDir: tempDir, force: false });
|
|
|
|
await expect(
|
|
runKtxDemo(
|
|
{ command: 'full', projectDir: tempDir, outputMode: 'json', inputMode: 'disabled' },
|
|
testIo.io,
|
|
{ env: { ANTHROPIC_API_KEY: 'sk-ant-test' }, runFullDemo }, // pragma: allowlist secret
|
|
),
|
|
).resolves.toBe(0);
|
|
expect(testIo.stdout()).not.toContain('[connect]');
|
|
expect(testIo.stdout()).not.toContain('[snapshot]');
|
|
});
|
|
|
|
it('routes demo ingest full mode', async () => {
|
|
const testIo = makeIo();
|
|
const runFullDemo = vi.fn().mockResolvedValue(fakeFullResult(tempDir));
|
|
await ensureDemoProject({ projectDir: tempDir, force: false });
|
|
|
|
await expect(
|
|
runKtxDemo(
|
|
{ command: 'ingest', mode: 'full', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
|
|
testIo.io,
|
|
{ env: {}, runFullDemo },
|
|
),
|
|
).resolves.toBe(0);
|
|
|
|
expect(testIo.stdout()).toContain('Full demo ingest: done');
|
|
});
|
|
|
|
it('saves full-demo replay output for the next demo replay command', async () => {
|
|
const tempDir = await mkdtemp(join(tmpdir(), 'ktx-demo-full-replay-'));
|
|
await ensureDemoProject({ projectDir: tempDir, force: false });
|
|
const io = makeIo();
|
|
|
|
await expect(
|
|
runKtxDemo(
|
|
{ command: 'full', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
|
|
io.io,
|
|
{
|
|
env: { ANTHROPIC_API_KEY: 'sk-ant-test' }, // pragma: allowlist secret
|
|
runFullDemo: vi.fn(async () => fakeFullResult(tempDir)),
|
|
},
|
|
),
|
|
).resolves.toBe(0);
|
|
|
|
const replayIo = makeIo();
|
|
await expect(
|
|
runKtxDemo({ command: 'replay', projectDir: tempDir, outputMode: 'json', inputMode: 'disabled' }, replayIo.io),
|
|
).resolves.toBe(0);
|
|
expect(JSON.parse(replayIo.stdout())).toMatchObject({
|
|
runId: 'run-full',
|
|
metadata: { mode: 'full', origin: 'captured' },
|
|
});
|
|
});
|
|
|
|
it('routes demo ingest seeded mode through the seeded path', async () => {
|
|
const testIo = makeIo();
|
|
|
|
await expect(
|
|
runKtxDemo(
|
|
{ command: 'ingest', mode: 'seeded', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
|
|
testIo.io,
|
|
),
|
|
).resolves.toBe(0);
|
|
|
|
expect(testIo.stdout()).toContain('Mode: seeded');
|
|
expect(testIo.stdout()).toContain('LLM calls: none');
|
|
});
|
|
|
|
it('routes demo doctor through the doctor module', async () => {
|
|
const testIo = makeIo();
|
|
const runDoctor = vi.fn().mockResolvedValue(0);
|
|
|
|
await expect(
|
|
runKtxDemo(
|
|
{
|
|
command: 'doctor',
|
|
projectDir: tempDir,
|
|
outputMode: 'plain',
|
|
inputMode: 'disabled',
|
|
},
|
|
testIo.io,
|
|
{ runDoctor },
|
|
),
|
|
).resolves.toBe(0);
|
|
|
|
expect(runDoctor).toHaveBeenCalledWith(
|
|
{
|
|
command: 'demo',
|
|
projectDir: tempDir,
|
|
outputMode: 'plain',
|
|
inputMode: 'disabled',
|
|
},
|
|
testIo.io,
|
|
);
|
|
});
|
|
|
|
it('resets the demo project only when force is explicit', async () => {
|
|
await ensureDemoProject({ projectDir: tempDir, force: false });
|
|
await rm(join(tempDir, 'demo.db'), { force: true });
|
|
|
|
const rejected = makeIo();
|
|
await expect(
|
|
runKtxDemo({ command: 'reset', projectDir: tempDir, force: false, inputMode: 'disabled' }, rejected.io),
|
|
).resolves.toBe(1);
|
|
expect(rejected.stderr()).toContain(`ktx setup demo reset is destructive; pass --force to recreate ${tempDir}`);
|
|
|
|
const accepted = makeIo();
|
|
await expect(
|
|
runKtxDemo({ command: 'reset', projectDir: tempDir, force: true, inputMode: 'disabled' }, accepted.io),
|
|
).resolves.toBe(0);
|
|
expect(accepted.stdout()).toContain(`Demo project reset: ${tempDir}`);
|
|
});
|
|
|
|
it('rehydrates seeded assets after reset --force', async () => {
|
|
const resetIo = makeIo();
|
|
await expect(
|
|
runKtxDemo({ command: 'reset', projectDir: tempDir, force: true, inputMode: 'disabled' }, resetIo.io),
|
|
).resolves.toBe(0);
|
|
|
|
const seededIo = makeIo();
|
|
await expect(
|
|
runKtxDemo(
|
|
{ command: 'seeded', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
|
|
seededIo.io,
|
|
),
|
|
).resolves.toBe(0);
|
|
|
|
expect(seededIo.stdout()).toContain('Status: ready');
|
|
expect(seededIo.stdout()).toContain(
|
|
`Semantic-layer sources: ${SEEDED_DEMO_SEMANTIC_SOURCE_COUNT} manifest, ${SEEDED_DEMO_SEMANTIC_SOURCE_COUNT} files`,
|
|
);
|
|
expect(seededIo.stdout()).toContain(
|
|
`Knowledge pages: ${SEEDED_DEMO_KNOWLEDGE_PAGE_COUNT} manifest, ${SEEDED_DEMO_KNOWLEDGE_PAGE_COUNT} files`,
|
|
);
|
|
expect(seededIo.stdout()).not.toContain('Status: corrupt');
|
|
expect(seededIo.stdout()).not.toContain(
|
|
`Semantic-layer sources: ${SEEDED_DEMO_SEMANTIC_SOURCE_COUNT} manifest, 0 files`,
|
|
);
|
|
});
|
|
|
|
it('fails corrupted demo projects in no-input mode with reset guidance', async () => {
|
|
await ensureDemoProject({ projectDir: tempDir, force: false });
|
|
await rm(join(tempDir, 'demo.db'), { force: true });
|
|
const testIo = makeIo();
|
|
|
|
await expect(
|
|
runKtxDemo({ command: 'replay', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' }, testIo.io),
|
|
).resolves.toBe(1);
|
|
|
|
expect(testIo.stderr()).toContain(`Demo project is not ready at ${tempDir}: missing demo.db`);
|
|
expect(testIo.stderr()).toContain(`ktx setup demo reset --project-dir ${tempDir} --force --no-input`);
|
|
});
|
|
|
|
it('uses a process-local Anthropic key from the interactive prompt', async () => {
|
|
const testIo = makeIo({ isTTY: true, columns: 120 });
|
|
const runFullDemo = vi.fn().mockResolvedValue(fakeFullResult(tempDir));
|
|
await ensureDemoProject({ projectDir: tempDir, force: false });
|
|
|
|
await expect(
|
|
runKtxDemo(
|
|
{ command: 'full', projectDir: tempDir, outputMode: 'plain' },
|
|
testIo.io,
|
|
{
|
|
env: {},
|
|
prompts: createTestDemoPromptAdapter({
|
|
choices: ['reuse', 'process_key'],
|
|
passwords: ['sk-ant-process'], // pragma: allowlist secret
|
|
}),
|
|
runFullDemo,
|
|
},
|
|
),
|
|
).resolves.toBe(0);
|
|
|
|
expect(runFullDemo).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
projectDir: tempDir,
|
|
env: { ANTHROPIC_API_KEY: 'sk-ant-process' }, // pragma: allowlist secret
|
|
onMemoryFlowChange: expect.any(Function),
|
|
}),
|
|
);
|
|
expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).toContain('api_key: env:ANTHROPIC_API_KEY');
|
|
});
|
|
|
|
it('routes an interactive missing-key choice to seeded mode', async () => {
|
|
const testIo = makeIo({ isTTY: true, columns: 120 });
|
|
const runFullDemo = vi.fn();
|
|
await ensureDemoProject({ projectDir: tempDir, force: false });
|
|
|
|
await expect(
|
|
runKtxDemo(
|
|
{ command: 'full', projectDir: tempDir, outputMode: 'plain' },
|
|
testIo.io,
|
|
{
|
|
env: {},
|
|
prompts: createTestDemoPromptAdapter({ choices: ['reuse', 'seeded'] }),
|
|
runFullDemo,
|
|
},
|
|
),
|
|
).resolves.toBe(0);
|
|
|
|
expect(runFullDemo).not.toHaveBeenCalled();
|
|
expect(testIo.stdout()).toContain('Mode: seeded');
|
|
expect(testIo.stdout()).toContain('LLM calls: none');
|
|
expect(testIo.stdout()).not.toContain('deterministic');
|
|
});
|
|
|
|
it('routes missing full-mode credentials to seeded when the interactive user chooses the no-LLM demo', async () => {
|
|
const testIo = makeIo({ isTTY: true });
|
|
|
|
await expect(
|
|
runKtxDemo(
|
|
{ command: 'full', projectDir: tempDir, outputMode: 'plain' },
|
|
testIo.io,
|
|
{
|
|
env: { ...process.env, ANTHROPIC_API_KEY: '' },
|
|
prompts: createTestDemoPromptAdapter({ choices: ['seeded'] }),
|
|
},
|
|
),
|
|
).resolves.toBe(0);
|
|
|
|
expect(testIo.stdout()).toContain('Mode: seeded');
|
|
expect(testIo.stdout()).toContain('LLM calls: none');
|
|
expect(testIo.stdout()).not.toContain('deterministic');
|
|
});
|
|
|
|
it('routes an interactive missing-key choice to replay mode', async () => {
|
|
const testIo = makeIo({ isTTY: true, columns: 120 });
|
|
const runFullDemo = vi.fn();
|
|
await ensureDemoProject({ projectDir: tempDir, force: false });
|
|
|
|
await expect(
|
|
runKtxDemo(
|
|
{ command: 'full', projectDir: tempDir, outputMode: 'viz' },
|
|
testIo.io,
|
|
{
|
|
env: {},
|
|
prompts: createTestDemoPromptAdapter({ choices: ['reuse', 'replay'] }),
|
|
runFullDemo,
|
|
},
|
|
),
|
|
).resolves.toBe(0);
|
|
|
|
expect(runFullDemo).not.toHaveBeenCalled();
|
|
expect(testIo.stdout()).toContain('KTX memory flow');
|
|
expect(testIo.stdout()).toContain('done');
|
|
});
|
|
});
|