test: expand coverage + fix setup/ TypeScript errors
New test files (83 tests): - src/agent-identity.test.ts — resolveAgentIdentity across locales/genders - src/env.test.ts — readEnvFile parsing, quoting, edge cases - src/jail-registry.test.ts — getJailIp with/without env override - src/local-hosts.test.ts — block markers, entries, render, upsert - src/mount-security.test.ts — validateMount allowlist enforcement - src/transcription.test.ts — initTranscription + transcribeAudio with mocked OpenAI setup/ TypeScript audit (tsconfig.setup.json): - agent-jails: JAILS value serialised to JSON string for emitStatus - environment.test: use import type for pg.Pool type cast - onboarding: wrap showProfileMenu in normalizePiTuiProfile - preflight.test: fix process.exit mock type + typed call array casts - sanoid: execSync → spawnSync for multi-arg zfs invocation - skills-memory: bracket access for legacy chunking_version field - upstream: pass process.cwd() to isGitRepo() - verify: import StripeKeyMode type, annotate stripeKeyMode variable Full suite: 69 files, 1162 tests passing; tsc --noEmit clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- Build: pass | Tests: pass — Tests 1162 passed (1162) --- Build: pass | Tests: pass — Tests 1162 passed (1162)
This commit is contained in:
parent
ac4913c829
commit
8456fcc526
15 changed files with 935 additions and 15 deletions
|
|
@ -267,7 +267,7 @@ export async function run(args: string[]): Promise<void> {
|
|||
|
||||
emitStatus('SETUP_AGENT_JAILS', {
|
||||
STATUS: 'success',
|
||||
JAILS: results,
|
||||
JAILS: JSON.stringify(results),
|
||||
LOG,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import type * as pg from 'pg';
|
||||
|
||||
describe('environment detection', () => {
|
||||
it('detects platform correctly', async () => {
|
||||
|
|
@ -10,7 +11,6 @@ describe('environment detection', () => {
|
|||
|
||||
describe('registered groups DB query', () => {
|
||||
it('returns 0 for empty table', async () => {
|
||||
const pg = await import('pg');
|
||||
const groups: Map<string, unknown> = new Map();
|
||||
const pool = {
|
||||
query: async (sql: string) => {
|
||||
|
|
@ -32,8 +32,6 @@ describe('registered groups DB query', () => {
|
|||
const groups: Map<string, unknown> = new Map();
|
||||
groups.set('123@g.us', { jid: '123@g.us' });
|
||||
groups.set('456@g.us', { jid: '456@g.us' });
|
||||
|
||||
const pg = await import('pg');
|
||||
const pool = {
|
||||
query: async (sql: string) => {
|
||||
if (/SELECT COUNT/i.test(sql)) {
|
||||
|
|
|
|||
|
|
@ -671,7 +671,7 @@ export async function run(args: string[]): Promise<void> {
|
|||
assistantName,
|
||||
displayLocale,
|
||||
);
|
||||
piProfile = showProfileMenu(piProfile);
|
||||
piProfile = normalizePiTuiProfile(showProfileMenu(piProfile));
|
||||
let promptForStripeKey = false;
|
||||
let skipStripeForNow = false;
|
||||
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ vi.mock('fs', async () => {
|
|||
});
|
||||
|
||||
// process.exit must not terminate the test process
|
||||
vi.spyOn(process, 'exit').mockImplementation((() => {}) as (code?: number) => never);
|
||||
vi.spyOn(process, 'exit').mockImplementation((() => {}) as (code?: string | number | null) => never);
|
||||
|
||||
// Pretend to be root so requiresRoot steps run via spawnSync (which is mocked)
|
||||
vi.spyOn(process, 'getuid').mockReturnValue(0);
|
||||
|
|
@ -73,16 +73,19 @@ const BASIC_STEP_IDS = [
|
|||
];
|
||||
const BASIC_STEP_COUNT = BASIC_STEP_IDS.length; // 16
|
||||
|
||||
type SpawnCall = [string, string[], (Record<string, unknown> | undefined)?];
|
||||
type WriteCall = [string, string];
|
||||
|
||||
function getNpmCalls() {
|
||||
return spawnSyncMock.mock.calls.filter((c) => c[0] === 'npm');
|
||||
return (spawnSyncMock.mock.calls as unknown as SpawnCall[]).filter((c) => c[0] === 'npm');
|
||||
}
|
||||
|
||||
function getPythonCalls() {
|
||||
return spawnSyncMock.mock.calls.filter((c) => c[0] === 'python3');
|
||||
return (spawnSyncMock.mock.calls as unknown as SpawnCall[]).filter((c) => c[0] === 'python3');
|
||||
}
|
||||
|
||||
function getSummaryJson(): Record<string, unknown> | null {
|
||||
const call = writeFileSyncMock.mock.calls.find(
|
||||
const call = (writeFileSyncMock.mock.calls as unknown as WriteCall[]).find(
|
||||
(c) => typeof c[0] === 'string' && String(c[0]).endsWith('summary.json'),
|
||||
);
|
||||
if (!call) return null;
|
||||
|
|
@ -90,7 +93,7 @@ function getSummaryJson(): Record<string, unknown> | null {
|
|||
}
|
||||
|
||||
function getSummaryEnv(): string | null {
|
||||
const call = writeFileSyncMock.mock.calls.find(
|
||||
const call = (writeFileSyncMock.mock.calls as unknown as WriteCall[]).find(
|
||||
(c) => typeof c[0] === 'string' && String(c[0]).endsWith('summary.env'),
|
||||
);
|
||||
return call ? String(call[1]) : null;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
* Sanoid snapshots the ZFS datasets that back the jails —
|
||||
* it cannot and must not run inside a jail.
|
||||
*/
|
||||
import { execSync } from 'child_process';
|
||||
import { execSync, spawnSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
|
|
@ -24,11 +24,12 @@ type ZfsDatasetInfo = { name: string; mountpoint: string };
|
|||
|
||||
function listZfsDatasets(): ZfsDatasetInfo[] {
|
||||
try {
|
||||
const output = execSync(
|
||||
const result = spawnSync(
|
||||
'zfs',
|
||||
['list', '-H', '-o', 'name,mountpoint', '-t', 'filesystem'],
|
||||
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] },
|
||||
);
|
||||
const output = result.stdout as string;
|
||||
return output
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ function statusReport(
|
|||
EMBEDDING_MODEL: String(metadata?.embedding_model ?? 'unknown'),
|
||||
ARTIFACT_VERSION: String(metadata?.artifact_version ?? 'unknown'),
|
||||
CHUNKING_VERSION: String(
|
||||
metadata?.chunker_version ?? metadata?.chunking_version ?? 'unknown',
|
||||
metadata?.chunker_version ?? (metadata as Record<string, unknown> | undefined)?.['chunking_version'] ?? 'unknown',
|
||||
),
|
||||
...extra,
|
||||
LOG: 'logs/setup.log',
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ function statusReport(fetch: boolean): void {
|
|||
export async function run(args: string[]): Promise<void> {
|
||||
logger.info('Upstream configuration step');
|
||||
|
||||
if (!isGitRepo()) {
|
||||
if (!isGitRepo(process.cwd())) {
|
||||
emitStatus('SETUP_UPSTREAM', {
|
||||
STATUS: 'failed',
|
||||
ERROR: 'not_a_git_repo',
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import { collectSplitBrainStatus } from '../src/split-brain-status.js';
|
|||
import {
|
||||
deriveStripeStatus,
|
||||
getStripeKeyMode,
|
||||
type StripeKeyMode,
|
||||
type StripeStatus,
|
||||
} from '../src/stripe-config.js';
|
||||
import { commandExists, getPlatform } from './platform.js';
|
||||
|
|
@ -98,7 +99,7 @@ export async function run(_args: string[]): Promise<void> {
|
|||
let displayLocale = 'unset';
|
||||
let timeZone = 'unset';
|
||||
let stripe: StripeStatus = 'disabled';
|
||||
let stripeKeyMode = 'missing';
|
||||
let stripeKeyMode: StripeKeyMode = 'missing';
|
||||
let stripeRefunds = 'no';
|
||||
const stripeRuntimePresent = fs.existsSync(
|
||||
path.join(projectRoot, 'jail', 'agent-runner', 'src', 'stripe-tools.ts'),
|
||||
|
|
|
|||
151
src/agent-identity.test.ts
Normal file
151
src/agent-identity.test.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
/**
|
||||
* src/agent-identity.test.ts — resolveAgentIdentity unit tests.
|
||||
*
|
||||
* resolveAgentIdentity is a pure function: given name, displayName, gender,
|
||||
* and locale it returns an AgentIdentity with all fields populated.
|
||||
*
|
||||
* Run with: npx vitest run src/agent-identity.test.ts
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { resolveAgentIdentity } from './agent-identity.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// English
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('resolveAgentIdentity — English', () => {
|
||||
it('returns all required fields', () => {
|
||||
const id = resolveAgentIdentity('clawdie', 'Clawdie', 'f', 'en');
|
||||
expect(id.name).toBe('clawdie');
|
||||
expect(id.displayName).toBe('Clawdie');
|
||||
expect(id.gender).toBe('f');
|
||||
expect(id.locale).toBe('en');
|
||||
expect(typeof id.pronoun).toBe('string');
|
||||
expect(typeof id.possessive).toBe('string');
|
||||
expect(typeof id.title).toBe('string');
|
||||
expect(typeof id.titlePossessive).toBe('string');
|
||||
expect(typeof id.selfIntro).toBe('string');
|
||||
});
|
||||
|
||||
it('en female: pronoun=she, title=assistant', () => {
|
||||
const id = resolveAgentIdentity('clawdie', 'Clawdie', 'f', 'en');
|
||||
expect(id.pronoun).toBe('she');
|
||||
expect(id.title).toBe('assistant');
|
||||
expect(id.titlePossessive).toBe('your assistant');
|
||||
});
|
||||
|
||||
it('en male: pronoun=he', () => {
|
||||
const id = resolveAgentIdentity('clawdie', 'Clawdie', 'm', 'en');
|
||||
expect(id.pronoun).toBe('he');
|
||||
expect(id.title).toBe('assistant');
|
||||
});
|
||||
|
||||
it('en neutral: pronoun=they', () => {
|
||||
const id = resolveAgentIdentity('clawdie', 'Clawdie', 'n', 'en');
|
||||
expect(id.pronoun).toBe('they');
|
||||
});
|
||||
|
||||
it('selfIntro contains the display name', () => {
|
||||
const id = resolveAgentIdentity('clawdie', 'Clawdie', 'f', 'en');
|
||||
expect(id.selfIntro).toContain('Clawdie');
|
||||
});
|
||||
|
||||
it('selfIntro differs by display name', () => {
|
||||
const a = resolveAgentIdentity('agentx', 'AgentX', 'f', 'en');
|
||||
const b = resolveAgentIdentity('agenty', 'AgentY', 'f', 'en');
|
||||
expect(a.selfIntro).not.toBe(b.selfIntro);
|
||||
expect(a.selfIntro).toContain('AgentX');
|
||||
expect(b.selfIntro).toContain('AgentY');
|
||||
});
|
||||
|
||||
it('locale tag with region (en-US) resolves to English', () => {
|
||||
const id = resolveAgentIdentity('clawdie', 'Clawdie', 'f', 'en-US');
|
||||
expect(id.title).toBe('assistant');
|
||||
expect(id.pronoun).toBe('she');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slovenian
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('resolveAgentIdentity — Slovenian', () => {
|
||||
it('sl female: title=asistentka', () => {
|
||||
const id = resolveAgentIdentity('klavdija', 'Klavdija', 'f', 'sl');
|
||||
expect(id.title).toBe('asistentka');
|
||||
expect(id.pronoun).toBe('ona');
|
||||
expect(id.possessive).toBe('tvoja');
|
||||
expect(id.titlePossessive).toBe('tvoja asistentka');
|
||||
});
|
||||
|
||||
it('sl male: title=asistent', () => {
|
||||
const id = resolveAgentIdentity('klavdij', 'Klavdij', 'm', 'sl');
|
||||
expect(id.title).toBe('asistent');
|
||||
expect(id.pronoun).toBe('on');
|
||||
expect(id.possessive).toBe('tvoj');
|
||||
expect(id.titlePossessive).toBe('tvoj asistent');
|
||||
});
|
||||
|
||||
it('sl neutral: falls back to female forms', () => {
|
||||
const id = resolveAgentIdentity('clawdie', 'Clawdie', 'n', 'sl');
|
||||
expect(id.title).toBe('asistentka');
|
||||
expect(id.pronoun).toBe('ona');
|
||||
});
|
||||
|
||||
it('sl selfIntro contains the display name', () => {
|
||||
const id = resolveAgentIdentity('klavdija', 'Klavdija', 'f', 'sl');
|
||||
expect(id.selfIntro).toContain('Klavdija');
|
||||
});
|
||||
|
||||
it('sl_SI locale resolves to Slovenian', () => {
|
||||
const id = resolveAgentIdentity('clawdie', 'Clawdie', 'f', 'sl_SI');
|
||||
expect(id.title).toBe('asistentka');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unknown locale fallback
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('resolveAgentIdentity — unknown locale', () => {
|
||||
it('unknown locale falls back to English neutral', () => {
|
||||
const id = resolveAgentIdentity('bot', 'Bot', 'f', 'xx');
|
||||
// Falls back to EN neutral (no language-specific female for 'xx')
|
||||
expect(id.pronoun).toBe('they');
|
||||
expect(id.title).toBe('assistant');
|
||||
});
|
||||
|
||||
it('empty locale string falls back to English neutral', () => {
|
||||
const id = resolveAgentIdentity('bot', 'Bot', 'm', '');
|
||||
expect(id.title).toBe('assistant');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Identity fields
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('resolveAgentIdentity — identity fields', () => {
|
||||
it('name and displayName are stored verbatim', () => {
|
||||
const id = resolveAgentIdentity('my-agent', 'My Agent', 'n', 'en');
|
||||
expect(id.name).toBe('my-agent');
|
||||
expect(id.displayName).toBe('My Agent');
|
||||
});
|
||||
|
||||
it('gender is stored verbatim', () => {
|
||||
expect(resolveAgentIdentity('a', 'A', 'f', 'en').gender).toBe('f');
|
||||
expect(resolveAgentIdentity('a', 'A', 'm', 'en').gender).toBe('m');
|
||||
expect(resolveAgentIdentity('a', 'A', 'n', 'en').gender).toBe('n');
|
||||
});
|
||||
|
||||
it('locale is stored verbatim', () => {
|
||||
const id = resolveAgentIdentity('a', 'A', 'n', 'sl_SI.UTF-8');
|
||||
expect(id.locale).toBe('sl_SI.UTF-8');
|
||||
});
|
||||
|
||||
it('selfIntro is a non-empty string', () => {
|
||||
const id = resolveAgentIdentity('a', 'A', 'n', 'en');
|
||||
expect(id.selfIntro.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
149
src/env.test.ts
Normal file
149
src/env.test.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
/**
|
||||
* src/env.test.ts — readEnvFile unit tests.
|
||||
*
|
||||
* readEnvFile parses a .env-style file and returns only the requested keys.
|
||||
* It deliberately does NOT touch process.env.
|
||||
*
|
||||
* Run with: npx vitest run src/env.test.ts
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
import { readEnvFile } from './env.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers — write a temp .env and override process.cwd() isn't feasible,
|
||||
// so we test readEnvFile by pointing cwd to a tmp dir.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// readEnvFile reads path.join(process.cwd(), '.env'), so we must use a
|
||||
// wrapper that writes to the real cwd. Instead, we test via the module
|
||||
// indirectly by writing to the actual path and cleaning up.
|
||||
|
||||
const REAL_ENV_PATH = path.join(process.cwd(), '.env');
|
||||
let originalEnvContent: string | null = null;
|
||||
|
||||
function writeTestEnv(content: string): void {
|
||||
if (originalEnvContent === null) {
|
||||
try {
|
||||
originalEnvContent = fs.existsSync(REAL_ENV_PATH)
|
||||
? fs.readFileSync(REAL_ENV_PATH, 'utf-8')
|
||||
: '__NONE__';
|
||||
} catch {
|
||||
originalEnvContent = '__NONE__';
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(REAL_ENV_PATH, content, 'utf-8');
|
||||
}
|
||||
|
||||
function restoreEnv(): void {
|
||||
if (originalEnvContent === null) return;
|
||||
if (originalEnvContent === '__NONE__') {
|
||||
try { fs.unlinkSync(REAL_ENV_PATH); } catch { /* already gone */ }
|
||||
} else {
|
||||
fs.writeFileSync(REAL_ENV_PATH, originalEnvContent, 'utf-8');
|
||||
}
|
||||
originalEnvContent = null;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Basic parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('readEnvFile', () => {
|
||||
it('parses simple KEY=VALUE pairs', () => {
|
||||
writeTestEnv('FOO=bar\nBAZ=qux\n');
|
||||
const result = readEnvFile(['FOO', 'BAZ']);
|
||||
expect(result.FOO).toBe('bar');
|
||||
expect(result.BAZ).toBe('qux');
|
||||
});
|
||||
|
||||
it('only returns requested keys', () => {
|
||||
writeTestEnv('FOO=bar\nBAZ=qux\nXYZ=ignored\n');
|
||||
const result = readEnvFile(['FOO']);
|
||||
expect(result.FOO).toBe('bar');
|
||||
expect(result.BAZ).toBeUndefined();
|
||||
expect(result.XYZ).toBeUndefined();
|
||||
});
|
||||
|
||||
it('strips double quotes from values', () => {
|
||||
writeTestEnv('TOKEN="my-secret-token"\n');
|
||||
const result = readEnvFile(['TOKEN']);
|
||||
expect(result.TOKEN).toBe('my-secret-token');
|
||||
});
|
||||
|
||||
it('strips single quotes from values', () => {
|
||||
writeTestEnv("TOKEN='my-token'\n");
|
||||
const result = readEnvFile(['TOKEN']);
|
||||
expect(result.TOKEN).toBe('my-token');
|
||||
});
|
||||
|
||||
it('ignores comment lines', () => {
|
||||
writeTestEnv('# this is a comment\nFOO=bar\n');
|
||||
const result = readEnvFile(['FOO']);
|
||||
expect(result.FOO).toBe('bar');
|
||||
});
|
||||
|
||||
it('ignores blank lines', () => {
|
||||
writeTestEnv('\n\nFOO=bar\n\n');
|
||||
const result = readEnvFile(['FOO']);
|
||||
expect(result.FOO).toBe('bar');
|
||||
});
|
||||
|
||||
it('ignores lines without =', () => {
|
||||
writeTestEnv('NOTAKEY\nFOO=bar\n');
|
||||
const result = readEnvFile(['FOO', 'NOTAKEY']);
|
||||
expect(result.FOO).toBe('bar');
|
||||
expect(result.NOTAKEY).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns empty object for non-existent .env', () => {
|
||||
// Remove .env if it exists and try reading
|
||||
restoreEnv();
|
||||
if (fs.existsSync(REAL_ENV_PATH)) {
|
||||
// If a real .env exists, skip this test
|
||||
return;
|
||||
}
|
||||
const result = readEnvFile(['ANY_KEY']);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('handles value with = sign in it', () => {
|
||||
writeTestEnv('URL=http://example.com?a=1&b=2\n');
|
||||
const result = readEnvFile(['URL']);
|
||||
expect(result.URL).toBe('http://example.com?a=1&b=2');
|
||||
});
|
||||
|
||||
it('handles values with spaces when quoted', () => {
|
||||
writeTestEnv('MSG="hello world"\n');
|
||||
const result = readEnvFile(['MSG']);
|
||||
expect(result.MSG).toBe('hello world');
|
||||
});
|
||||
|
||||
it('skips keys with empty values', () => {
|
||||
writeTestEnv('EMPTY=\nFOO=bar\n');
|
||||
const result = readEnvFile(['EMPTY', 'FOO']);
|
||||
expect(result.EMPTY).toBeUndefined();
|
||||
expect(result.FOO).toBe('bar');
|
||||
});
|
||||
|
||||
it('trims whitespace around key and value', () => {
|
||||
writeTestEnv(' FOO = bar \n');
|
||||
const result = readEnvFile(['FOO']);
|
||||
// Key trimming is done: 'FOO' should be found
|
||||
// Value trimming: bare value gets trimmed
|
||||
expect(result.FOO).toBe('bar');
|
||||
});
|
||||
|
||||
it('returns empty object for empty keys array', () => {
|
||||
writeTestEnv('FOO=bar\n');
|
||||
const result = readEnvFile([]);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
44
src/jail-registry.test.ts
Normal file
44
src/jail-registry.test.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* src/jail-registry.test.ts — getJailIp tests.
|
||||
*
|
||||
* jail-registry.ts is a thin wrapper around jail-schema's loadJailRegistry
|
||||
* and resolveJailIp, adding an env override shortcut and a module-level cache.
|
||||
*
|
||||
* Run with: npx vitest run src/jail-registry.test.ts
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getJailIp } from './jail-registry.js';
|
||||
|
||||
describe('getJailIp', () => {
|
||||
it('returns the env override when provided', () => {
|
||||
expect(getJailIp('db', '10.9.9.9')).toBe('10.9.9.9');
|
||||
});
|
||||
|
||||
it('returns env override regardless of role', () => {
|
||||
expect(getJailIp('unknown-role', '1.2.3.4')).toBe('1.2.3.4');
|
||||
});
|
||||
|
||||
it('resolves db jail IP from registry when no override', () => {
|
||||
const ip = getJailIp('db', undefined);
|
||||
expect(ip).toMatch(/^\d+\.\d+\.\d+\.\d+$/);
|
||||
});
|
||||
|
||||
it('resolves cms jail IP from registry', () => {
|
||||
const ip = getJailIp('cms', undefined);
|
||||
expect(ip).toMatch(/^\d+\.\d+\.\d+\.\d+$/);
|
||||
});
|
||||
|
||||
it('resolves git jail IP from registry', () => {
|
||||
const ip = getJailIp('git', undefined);
|
||||
expect(ip).toMatch(/^\d+\.\d+\.\d+\.\d+$/);
|
||||
});
|
||||
|
||||
it('db and cms IPs are different', () => {
|
||||
expect(getJailIp('db', undefined)).not.toBe(getJailIp('cms', undefined));
|
||||
});
|
||||
|
||||
it('throws for unknown jail role', () => {
|
||||
expect(() => getJailIp('no-such-role', undefined)).toThrow();
|
||||
});
|
||||
});
|
||||
170
src/local-hosts.test.ts
Normal file
170
src/local-hosts.test.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
/**
|
||||
* src/local-hosts.test.ts — local-hosts pure function tests.
|
||||
*
|
||||
* Tests renderLocalHostsBlock(), upsertLocalHostsBlock(), getLocalHostsEntries(),
|
||||
* and the block start/end marker helpers.
|
||||
*
|
||||
* Run with: npx vitest run src/local-hosts.test.ts
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
getLocalHostsBlockStart,
|
||||
getLocalHostsBlockEnd,
|
||||
getLocalHostsEntries,
|
||||
renderLocalHostsBlock,
|
||||
upsertLocalHostsBlock,
|
||||
} from './local-hosts.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Block markers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('getLocalHostsBlockStart / getLocalHostsBlockEnd', () => {
|
||||
it('start marker contains agent name', () => {
|
||||
const start = getLocalHostsBlockStart();
|
||||
expect(start).toMatch(/>>>/);
|
||||
expect(start).toContain('local hosts');
|
||||
});
|
||||
|
||||
it('end marker contains agent name', () => {
|
||||
const end = getLocalHostsBlockEnd();
|
||||
expect(end).toMatch(/<<</);
|
||||
expect(end).toContain('local hosts');
|
||||
});
|
||||
|
||||
it('start and end markers are different', () => {
|
||||
expect(getLocalHostsBlockStart()).not.toBe(getLocalHostsBlockEnd());
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getLocalHostsEntries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('getLocalHostsEntries', () => {
|
||||
it('returns at least 4 entries', () => {
|
||||
const entries = getLocalHostsEntries();
|
||||
expect(entries.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it('each entry has an ip and non-empty names array', () => {
|
||||
const entries = getLocalHostsEntries();
|
||||
for (const entry of entries) {
|
||||
expect(typeof entry.ip).toBe('string');
|
||||
expect(entry.ip).toMatch(/^\d+\.\d+\.\d+\.\d+$/);
|
||||
expect(Array.isArray(entry.names)).toBe(true);
|
||||
expect(entry.names.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('contains a db entry', () => {
|
||||
const entries = getLocalHostsEntries();
|
||||
const hasDb = entries.some((e) => e.names.some((n) => n.includes('db')));
|
||||
expect(hasDb).toBe(true);
|
||||
});
|
||||
|
||||
it('contains a gateway entry', () => {
|
||||
const entries = getLocalHostsEntries();
|
||||
const hasGateway = entries.some((e) => e.names.some((n) => n.includes('gateway')));
|
||||
expect(hasGateway).toBe(true);
|
||||
});
|
||||
|
||||
it('no entry has duplicate names', () => {
|
||||
const entries = getLocalHostsEntries();
|
||||
for (const entry of entries) {
|
||||
const unique = new Set(entry.names);
|
||||
expect(unique.size).toBe(entry.names.length);
|
||||
}
|
||||
});
|
||||
|
||||
it('no empty string names', () => {
|
||||
const entries = getLocalHostsEntries();
|
||||
for (const entry of entries) {
|
||||
expect(entry.names.every((n) => n.length > 0)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// renderLocalHostsBlock
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('renderLocalHostsBlock', () => {
|
||||
it('starts with the block start marker', () => {
|
||||
const block = renderLocalHostsBlock();
|
||||
expect(block.trimStart().startsWith(getLocalHostsBlockStart())).toBe(true);
|
||||
});
|
||||
|
||||
it('ends with the block end marker (plus newline)', () => {
|
||||
const block = renderLocalHostsBlock();
|
||||
expect(block.trimEnd().endsWith(getLocalHostsBlockEnd())).toBe(true);
|
||||
});
|
||||
|
||||
it('contains IP addresses', () => {
|
||||
const block = renderLocalHostsBlock();
|
||||
expect(block).toMatch(/\d+\.\d+\.\d+\.\d+/);
|
||||
});
|
||||
|
||||
it('contains a comment line about internal names', () => {
|
||||
const block = renderLocalHostsBlock();
|
||||
expect(block).toContain('Internal jail');
|
||||
});
|
||||
|
||||
it('ends with a newline', () => {
|
||||
const block = renderLocalHostsBlock();
|
||||
expect(block.endsWith('\n')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// upsertLocalHostsBlock
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('upsertLocalHostsBlock', () => {
|
||||
it('inserts block into empty string', () => {
|
||||
const result = upsertLocalHostsBlock('');
|
||||
expect(result).toContain(getLocalHostsBlockStart());
|
||||
expect(result).toContain(getLocalHostsBlockEnd());
|
||||
});
|
||||
|
||||
it('appends block to existing content without block', () => {
|
||||
const existing = '127.0.0.1 localhost\n';
|
||||
const result = upsertLocalHostsBlock(existing);
|
||||
expect(result).toContain('127.0.0.1 localhost');
|
||||
expect(result).toContain(getLocalHostsBlockStart());
|
||||
});
|
||||
|
||||
it('replaces existing block in content', () => {
|
||||
const oldBlock = `${getLocalHostsBlockStart()}\n10.0.0.1 old-entry\n${getLocalHostsBlockEnd()}\n`;
|
||||
const result = upsertLocalHostsBlock(oldBlock);
|
||||
expect(result).not.toContain('10.0.0.1 old-entry');
|
||||
expect(result).toContain(getLocalHostsBlockStart());
|
||||
});
|
||||
|
||||
it('result contains exactly one start marker', () => {
|
||||
const oldBlock = `${getLocalHostsBlockStart()}\nold\n${getLocalHostsBlockEnd()}\n`;
|
||||
const result = upsertLocalHostsBlock(oldBlock);
|
||||
const count = (result.match(new RegExp(getLocalHostsBlockStart().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length;
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it('result ends with newline', () => {
|
||||
const result = upsertLocalHostsBlock('');
|
||||
expect(result.endsWith('\n')).toBe(true);
|
||||
});
|
||||
|
||||
it('replaces legacy clawdie block marker', () => {
|
||||
const legacy = '# >>> clawdie local hosts >>>\n10.0.0.1 old\n# <<< clawdie local hosts <<<\n';
|
||||
const result = upsertLocalHostsBlock(legacy);
|
||||
expect(result).not.toContain('10.0.0.1 old');
|
||||
expect(result).toContain(getLocalHostsBlockStart());
|
||||
});
|
||||
|
||||
it('preserves content before block', () => {
|
||||
const existing = '127.0.0.1 localhost\n192.168.1.1 router\n';
|
||||
const result = upsertLocalHostsBlock(existing);
|
||||
expect(result).toContain('127.0.0.1 localhost');
|
||||
expect(result).toContain('192.168.1.1 router');
|
||||
});
|
||||
});
|
||||
256
src/mount-security.test.ts
Normal file
256
src/mount-security.test.ts
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
/**
|
||||
* src/mount-security.test.ts — mount security validation tests.
|
||||
*
|
||||
* Tests validateMount(), validateAdditionalMounts(), and generateAllowlistTemplate().
|
||||
* The allowlist cache is module-level, so tests that need a fresh state use
|
||||
* vi.resetModules() + dynamic imports.
|
||||
*
|
||||
* Run with: npx vitest run src/mount-security.test.ts
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// generateAllowlistTemplate — pure, no side effects
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('generateAllowlistTemplate', () => {
|
||||
it('returns valid JSON', async () => {
|
||||
const { generateAllowlistTemplate } = await import('./mount-security.js');
|
||||
expect(() => JSON.parse(generateAllowlistTemplate())).not.toThrow();
|
||||
});
|
||||
|
||||
it('JSON has allowedRoots array', async () => {
|
||||
const { generateAllowlistTemplate } = await import('./mount-security.js');
|
||||
const parsed = JSON.parse(generateAllowlistTemplate());
|
||||
expect(Array.isArray(parsed.allowedRoots)).toBe(true);
|
||||
expect(parsed.allowedRoots.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('JSON has blockedPatterns array', async () => {
|
||||
const { generateAllowlistTemplate } = await import('./mount-security.js');
|
||||
const parsed = JSON.parse(generateAllowlistTemplate());
|
||||
expect(Array.isArray(parsed.blockedPatterns)).toBe(true);
|
||||
});
|
||||
|
||||
it('JSON has nonMainReadOnly boolean', async () => {
|
||||
const { generateAllowlistTemplate } = await import('./mount-security.js');
|
||||
const parsed = JSON.parse(generateAllowlistTemplate());
|
||||
expect(typeof parsed.nonMainReadOnly).toBe('boolean');
|
||||
});
|
||||
|
||||
it('each allowedRoot has path and allowReadWrite', async () => {
|
||||
const { generateAllowlistTemplate } = await import('./mount-security.js');
|
||||
const parsed = JSON.parse(generateAllowlistTemplate());
|
||||
for (const root of parsed.allowedRoots) {
|
||||
expect(typeof root.path).toBe('string');
|
||||
expect(typeof root.allowReadWrite).toBe('boolean');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// validateMount — no allowlist path → all mounts blocked
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('validateMount — no allowlist', () => {
|
||||
it('returns allowed=false when no allowlist configured', async () => {
|
||||
// The allowlist path doesn't exist in the test environment
|
||||
const { validateMount } = await import('./mount-security.js');
|
||||
const result = validateMount({ hostPath: '/some/path' }, true);
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toBeTruthy();
|
||||
});
|
||||
|
||||
it('reason message is descriptive', async () => {
|
||||
const { validateMount } = await import('./mount-security.js');
|
||||
const result = validateMount({ hostPath: '/some/path' }, true);
|
||||
// Should mention allowlist, mount, or path problem
|
||||
expect(result.reason.toLowerCase()).toMatch(/allowlist|mount|exist/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// validateAdditionalMounts — no allowlist → empty array returned
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('validateAdditionalMounts — no allowlist', () => {
|
||||
it('returns empty array when no allowlist', async () => {
|
||||
const { validateAdditionalMounts } = await import('./mount-security.js');
|
||||
const result = validateAdditionalMounts(
|
||||
[{ hostPath: '/some/path' }, { hostPath: '/other/path' }],
|
||||
'test-group',
|
||||
true,
|
||||
);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns empty array for empty mounts input', async () => {
|
||||
const { validateAdditionalMounts } = await import('./mount-security.js');
|
||||
const result = validateAdditionalMounts([], 'test-group', true);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// validateMount — with a real temp allowlist
|
||||
// Uses vi.resetModules() to clear the module cache between test groups.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('validateMount — with allowlist', () => {
|
||||
let tmpDir: string;
|
||||
let allowlistPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mount-security-test-'));
|
||||
allowlistPath = path.join(tmpDir, 'mount-allowlist.json');
|
||||
|
||||
// Override MOUNT_ALLOWLIST_PATH via the config mock
|
||||
vi.doMock('./config.js', async () => {
|
||||
const actual = await vi.importActual<typeof import('./config.js')>('./config.js');
|
||||
return { ...actual, MOUNT_ALLOWLIST_PATH: allowlistPath };
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('allows a mount under an allowed root', async () => {
|
||||
// Create an allowed root pointing to tmpDir itself
|
||||
const allowlist = {
|
||||
allowedRoots: [{ path: tmpDir, allowReadWrite: true, description: 'test root' }],
|
||||
blockedPatterns: [],
|
||||
nonMainReadOnly: false,
|
||||
};
|
||||
fs.writeFileSync(allowlistPath, JSON.stringify(allowlist));
|
||||
|
||||
// Create a real subdirectory to mount
|
||||
const mountTarget = path.join(tmpDir, 'data');
|
||||
fs.mkdirSync(mountTarget);
|
||||
|
||||
const { validateMount } = await import('./mount-security.js');
|
||||
const result = validateMount({ hostPath: mountTarget }, true);
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.realHostPath).toBeTruthy();
|
||||
});
|
||||
|
||||
it('blocks a mount not under any allowed root', async () => {
|
||||
const allowlist = {
|
||||
allowedRoots: [{ path: path.join(tmpDir, 'allowed'), allowReadWrite: true }],
|
||||
blockedPatterns: [],
|
||||
nonMainReadOnly: false,
|
||||
};
|
||||
fs.writeFileSync(allowlistPath, JSON.stringify(allowlist));
|
||||
fs.mkdirSync(path.join(tmpDir, 'allowed'));
|
||||
|
||||
// Try to mount something outside
|
||||
const { validateMount } = await import('./mount-security.js');
|
||||
const result = validateMount({ hostPath: tmpDir }, true);
|
||||
// tmpDir is not UNDER allowed/ — it's the parent
|
||||
expect(result.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks a mount matching a blocked pattern', async () => {
|
||||
const allowlist = {
|
||||
allowedRoots: [{ path: tmpDir, allowReadWrite: true }],
|
||||
blockedPatterns: ['secret-data'],
|
||||
nonMainReadOnly: false,
|
||||
};
|
||||
fs.writeFileSync(allowlistPath, JSON.stringify(allowlist));
|
||||
|
||||
const secretDir = path.join(tmpDir, 'secret-data');
|
||||
fs.mkdirSync(secretDir);
|
||||
|
||||
const { validateMount } = await import('./mount-security.js');
|
||||
const result = validateMount({ hostPath: secretDir }, true);
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toContain('blocked');
|
||||
});
|
||||
|
||||
it('blocks .ssh path via default blocked patterns', async () => {
|
||||
const allowlist = {
|
||||
allowedRoots: [{ path: tmpDir, allowReadWrite: true }],
|
||||
blockedPatterns: [],
|
||||
nonMainReadOnly: false,
|
||||
};
|
||||
fs.writeFileSync(allowlistPath, JSON.stringify(allowlist));
|
||||
|
||||
const sshDir = path.join(tmpDir, '.ssh');
|
||||
fs.mkdirSync(sshDir);
|
||||
|
||||
const { validateMount } = await import('./mount-security.js');
|
||||
const result = validateMount({ hostPath: sshDir }, true);
|
||||
expect(result.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it('forces readonly for non-main group when nonMainReadOnly=true', async () => {
|
||||
const allowlist = {
|
||||
allowedRoots: [{ path: tmpDir, allowReadWrite: true }],
|
||||
blockedPatterns: [],
|
||||
nonMainReadOnly: true,
|
||||
};
|
||||
fs.writeFileSync(allowlistPath, JSON.stringify(allowlist));
|
||||
|
||||
const dataDir = path.join(tmpDir, 'data');
|
||||
fs.mkdirSync(dataDir);
|
||||
|
||||
const { validateMount } = await import('./mount-security.js');
|
||||
// isMain=false, requesting read-write, but nonMainReadOnly=true
|
||||
const result = validateMount({ hostPath: dataDir, readonly: false }, false);
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.effectiveReadonly).toBe(true);
|
||||
});
|
||||
|
||||
it('allows read-write for main group when root allows it', async () => {
|
||||
const allowlist = {
|
||||
allowedRoots: [{ path: tmpDir, allowReadWrite: true }],
|
||||
blockedPatterns: [],
|
||||
nonMainReadOnly: false,
|
||||
};
|
||||
fs.writeFileSync(allowlistPath, JSON.stringify(allowlist));
|
||||
|
||||
const dataDir = path.join(tmpDir, 'data');
|
||||
fs.mkdirSync(dataDir);
|
||||
|
||||
const { validateMount } = await import('./mount-security.js');
|
||||
const result = validateMount({ hostPath: dataDir, readonly: false }, true);
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.effectiveReadonly).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults to readonly when readonly is not specified', async () => {
|
||||
const allowlist = {
|
||||
allowedRoots: [{ path: tmpDir, allowReadWrite: true }],
|
||||
blockedPatterns: [],
|
||||
nonMainReadOnly: false,
|
||||
};
|
||||
fs.writeFileSync(allowlistPath, JSON.stringify(allowlist));
|
||||
|
||||
const dataDir = path.join(tmpDir, 'data');
|
||||
fs.mkdirSync(dataDir);
|
||||
|
||||
const { validateMount } = await import('./mount-security.js');
|
||||
const result = validateMount({ hostPath: dataDir }, true);
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.effectiveReadonly).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks non-existent host path', async () => {
|
||||
const allowlist = {
|
||||
allowedRoots: [{ path: tmpDir, allowReadWrite: true }],
|
||||
blockedPatterns: [],
|
||||
nonMainReadOnly: false,
|
||||
};
|
||||
fs.writeFileSync(allowlistPath, JSON.stringify(allowlist));
|
||||
|
||||
const { validateMount } = await import('./mount-security.js');
|
||||
const result = validateMount({ hostPath: path.join(tmpDir, 'no-such-dir') }, true);
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toContain('does not exist');
|
||||
});
|
||||
});
|
||||
129
src/transcription.test.ts
Normal file
129
src/transcription.test.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
/**
|
||||
* src/transcription.test.ts — transcription module unit tests.
|
||||
*
|
||||
* Tests initTranscription() and transcribeAudio() with a mocked OpenAI client.
|
||||
*
|
||||
* Run with: npx vitest run src/transcription.test.ts
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock OpenAI before importing the module under test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockCreate = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('openai', () => {
|
||||
return {
|
||||
default: vi.fn().mockImplementation(function () {
|
||||
return {
|
||||
audio: {
|
||||
transcriptions: {
|
||||
create: mockCreate,
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('fs', async () => {
|
||||
const actual = await vi.importActual<typeof import('fs')>('fs');
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
...actual,
|
||||
createReadStream: vi.fn().mockReturnValue('mock-stream'),
|
||||
},
|
||||
createReadStream: vi.fn().mockReturnValue('mock-stream'),
|
||||
};
|
||||
});
|
||||
|
||||
import { initTranscription, transcribeAudio } from './transcription.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// initTranscription
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('initTranscription', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset module so the openaiClient is null again
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('does not throw when called without API key', async () => {
|
||||
const { initTranscription: init } = await import('./transcription.js');
|
||||
expect(() => init()).not.toThrow();
|
||||
expect(() => init(undefined)).not.toThrow();
|
||||
});
|
||||
|
||||
it('does not throw when called with an API key', async () => {
|
||||
const { initTranscription: init } = await import('./transcription.js');
|
||||
expect(() => init('sk-test-key')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// transcribeAudio — client not initialized
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('transcribeAudio — not initialized', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('returns null when client not initialized', async () => {
|
||||
const { transcribeAudio: transcribe } = await import('./transcription.js');
|
||||
// Module freshly imported — no initTranscription called yet
|
||||
const result = await transcribe('/tmp/audio.ogg');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// transcribeAudio — client initialized
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('transcribeAudio — initialized', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
mockCreate.mockReset();
|
||||
});
|
||||
|
||||
it('returns transcript text on success', async () => {
|
||||
mockCreate.mockResolvedValue({ text: ' hello world ' });
|
||||
const { initTranscription: init, transcribeAudio: transcribe } = await import('./transcription.js');
|
||||
init('sk-test-key');
|
||||
const result = await transcribe('/tmp/audio.ogg');
|
||||
expect(result).toBe('hello world'); // trimmed
|
||||
});
|
||||
|
||||
it('returns null on OpenAI error', async () => {
|
||||
mockCreate.mockRejectedValue(new Error('API error'));
|
||||
const { initTranscription: init, transcribeAudio: transcribe } = await import('./transcription.js');
|
||||
init('sk-test-key');
|
||||
const result = await transcribe('/tmp/audio.ogg');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('calls the transcription API with whisper-1 model', async () => {
|
||||
mockCreate.mockResolvedValue({ text: 'test' });
|
||||
const { initTranscription: init, transcribeAudio: transcribe } = await import('./transcription.js');
|
||||
init('sk-test-key');
|
||||
await transcribe('/tmp/audio.ogg');
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ model: 'whisper-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null for empty transcript text', async () => {
|
||||
mockCreate.mockResolvedValue({ text: ' ' });
|
||||
const { initTranscription: init, transcribeAudio: transcribe } = await import('./transcription.js');
|
||||
init('sk-test-key');
|
||||
const result = await transcribe('/tmp/audio.ogg');
|
||||
// Trimmed empty string — module returns trimmed string, not null on empty
|
||||
// but the trim() result would be '' which is falsy
|
||||
expect(result === null || result === '').toBe(true);
|
||||
});
|
||||
});
|
||||
18
tsconfig.setup.json
Normal file
18
tsconfig.setup.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist-setup",
|
||||
"rootDir": ".",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["setup/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue