ktx/packages/cli/test/demo-assets.test.ts
Andrey Avtomonov 2877b85adc
fix(cli): isolate ktx-owned project repositories (#283)
* fix(cli): isolate ktx project git repos

* fix(cli): remove inert auto commit config

* test(cli): drop stale auto commit fixtures

* docs: document isolated ktx project repos

* test(cli): keep stale config grep clean

* fix(cli): guide setup away from foreign repos at the project dir

ktx owns the git repo rooted at the project dir and refuses to adopt one it
did not create (the Finding 3 isolation invariant). But setup steered users
straight into that failure: the interactive menu offers "Current directory"
first, and `--no-input --yes --project-dir <repo-root>` created directly in
place — both then threw a generic "Failed to initialize git repository:"
wrapper from deep in GitService.initialize().

Extract the ownership rule into a shared `classifyKtxRepoOwnership(dir)` used by
both GitService.initialize() (the invariant) and the setup wizard (pre-flight
guidance), so the decision derives from one rule. Setup now detects a foreign
repo before constructing GitService and: interactively re-prompts (the user
picks the existing `ktx-project` subfolder), or non-interactively returns a
clean missing-input with the actionable message. The typed foreign-repo error
is also surfaced verbatim instead of being buried under the generic wrapper.

Empty/non-repo current directories still work — only foreign repos are blocked.

* fix(cli): keep classifyKtxRepoOwnership total for non-directory paths

The setup ownership guard runs before the existing not-a-directory check, so
pointing a custom/--project-dir path at a file made classifyKtxRepoOwnership
lstat `<file>/.git`, hit ENOTDIR, and throw — crashing the setup step instead
of returning the friendly "path exists and is not a directory" result.

A path that is a file (or missing) holds no git repo for ktx to avoid, so treat
ENOTDIR like ENOENT and return 'unowned'. The downstream existingFolderState
check still rejects a non-directory with its friendly message, and the
classifier no longer throws raw errno for any caller.
2026-06-10 14:12:25 +02:00

169 lines
6.9 KiB
TypeScript

import { access, readFile, rm, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
DEMO_ADAPTER,
DEMO_CONNECTION_ID,
DEMO_REPLAY_FILE,
defaultDemoProjectDir,
ensureDemoProject,
ensureSeededDemoProject,
} from '../src/demo-assets.js';
const packagedDemoSource = 'packaged-orbit-demo';
const removedAutoCommitKey = ['auto', 'commit'].join('_');
function packagedDemoAssetPath(relativePath: string): string {
return fileURLToPath(new URL(`../assets/demo/orbit/${relativePath}`, import.meta.url));
}
async function readPackagedJson<T>(relativePath: string): Promise<T> {
return JSON.parse(await readFile(packagedDemoAssetPath(relativePath), 'utf-8')) as T;
}
function makeIo() {
let stderr = '';
return {
stdout: {
isTTY: true,
write() {},
},
stderr: {
write(chunk: string) {
stderr += chunk;
},
},
stderrText: () => stderr,
};
}
describe('demo assets', () => {
const projectDir = join(tmpdir(), `ktx-demo-assets-${process.pid}`);
afterEach(async () => {
vi.unstubAllEnvs();
await rm(projectDir, { recursive: true, force: true });
});
it('resolves the default demo root under the OS temp directory', () => {
const dir = defaultDemoProjectDir();
expect(dir.startsWith(join(tmpdir(), 'ktx-demo-'))).toBe(true);
expect(dir).toMatch(/ktx-demo-[a-f0-9]{8}$/);
});
it('exports the packaged Orbit demo identity', () => {
expect(DEMO_CONNECTION_ID).toBe('orbit_demo');
expect(DEMO_ADAPTER).toBe('live-database');
expect(DEMO_REPLAY_FILE).toBe('replay.memory-flow.v1.json');
});
it('ships the seeded demo bundle required by the May 6 PRD', async () => {
const manifest = await readPackagedJson<{
demoAssetSchemaVersion: number;
mode: string;
source: string;
sources: {
warehouse: { tables: number; rowCounts: Record<string, number> };
dbt: { models: number; sourceTables: number };
bi: { explores: number; dashboards: number };
notion: { pages: number };
};
name: string;
displayName: string;
generated: {
semanticLayer: { path: string; sourceCount: number };
knowledge: { pageCount: number };
links: { linkCount: number };
};
}>('manifest.json');
expect(manifest).toMatchObject({
demoAssetSchemaVersion: 2,
name: 'orbit',
displayName: 'Orbit Demo',
mode: 'seeded',
source: packagedDemoSource,
});
expect(manifest.sources.warehouse.tables).toBeGreaterThanOrEqual(5);
expect(manifest.sources.warehouse.tables).toBeLessThanOrEqual(10);
expect(Object.keys(manifest.sources.warehouse.rowCounts).sort()).toEqual([
'accounts',
'arr_movements',
'contracts',
'invoices',
'plans',
'purchase_requests',
'support_tickets',
'users',
]);
expect(manifest.sources.dbt.models).toBeGreaterThanOrEqual(3);
expect(manifest.sources.dbt.models).toBeLessThanOrEqual(6);
expect(manifest.sources.bi.explores).toBeGreaterThanOrEqual(2);
expect(manifest.sources.bi.dashboards).toBeGreaterThanOrEqual(2);
expect(manifest.sources.notion.pages).toBeGreaterThanOrEqual(5);
expect(manifest.generated.semanticLayer.sourceCount).toBeGreaterThanOrEqual(40);
expect(manifest.generated.knowledge.pageCount).toBeGreaterThanOrEqual(20);
expect(manifest.generated.links.linkCount).toBeGreaterThanOrEqual(10);
const dbStat = await stat(packagedDemoAssetPath('demo.db'));
expect(dbStat.size).toBeGreaterThan(0);
expect(dbStat.size).toBeLessThan(10 * 1024 * 1024);
await expect(access(packagedDemoAssetPath('semantic-layer/dbt-main/mart_arr_daily.yaml'))).resolves.toBeUndefined();
await expect(access(packagedDemoAssetPath('semantic-layer/postgres-warehouse/mart_account_activity.yaml'))).resolves.toBeUndefined();
await expect(access(packagedDemoAssetPath('wiki/global/orbit-company-overview.md'))).resolves.toBeUndefined();
await expect(access(packagedDemoAssetPath('links/provenance.json'))).resolves.toBeUndefined();
await expect(access(packagedDemoAssetPath('reports/seeded-demo-report.json'))).resolves.toBeUndefined();
});
it('initializes a flat demo project without writing literal credentials', async () => {
const result = await ensureDemoProject({ projectDir, force: false });
expect(result.projectDir).toBe(projectDir);
await expect(access(join(projectDir, 'demo.db'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'state.sqlite'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'reports'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'semantic-layer'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'wiki'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'replays', 'replay.memory-flow.v1.json'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'raw-sources'))).resolves.toBeUndefined();
await expect(access(join(projectDir, '_schema'))).rejects.toMatchObject({ code: 'ENOENT' });
const config = await readFile(join(projectDir, 'ktx.yaml'), 'utf-8');
expect(config).toContain('backend: anthropic');
expect(config).toContain('api_key: env:ANTHROPIC_API_KEY');
expect(config).not.toContain(removedAutoCommitKey);
expect(config).not.toContain('memory:');
expect(config).not.toContain('sk-ant-');
});
it('rejects an existing demo project unless force is set', async () => {
await ensureDemoProject({ projectDir, force: false });
await expect(ensureDemoProject({ projectDir, force: false })).rejects.toThrow('Demo project already exists');
await expect(ensureDemoProject({ projectDir, force: true })).resolves.toMatchObject({ projectDir });
});
it('emits debug telemetry when the demo connection is created', async () => {
vi.stubEnv('KTX_TELEMETRY_DEBUG', '1');
vi.stubEnv('CI', '');
const io = makeIo();
await ensureDemoProject({ projectDir, force: false, io, cliVersion: '0.2.0' });
expect(io.stderrText()).toContain('"event":"connection_added"');
expect(io.stderrText()).toContain('"driver":"sqlite"');
expect(io.stderrText()).toContain('"isDemoConnection":true');
expect(io.stderrText()).not.toContain(projectDir);
});
it('copies the seeded project assets used by the setup wizard tour', async () => {
await ensureSeededDemoProject({ projectDir, force: false });
await expect(access(join(projectDir, 'semantic-layer', 'dbt-main', 'mart_arr_daily.yaml'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'wiki', 'global', 'orbit-company-overview.md'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'links', 'provenance.json'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'reports', 'seeded-demo-report.json'))).resolves.toBeUndefined();
});
});