chore: bump version to 2.80.27 and update core features

This commit is contained in:
g1nation
2026-05-09 01:16:12 +09:00
parent 5ffb472d22
commit 3220a126fd
41 changed files with 4457 additions and 72 deletions
+164
View File
@@ -0,0 +1,164 @@
/**
* Unit tests for ApprovalQueue.
*
* Strategy: drive enqueue → approve / reject / clear / pre-empt directly,
* confirm the onChange event fires at the right moments and callbacks fire
* exactly once.
*/
import { ApprovalQueue, Approval } from '../src/features/approval/approvalQueue';
function makeApproval(id: string = 'txn-1'): Approval {
return {
id,
kind: 'transaction',
title: 'Pending file changes',
summary: '2 files',
files: ['/tmp/a.ts', '/tmp/b.ts'],
createdAt: Date.now(),
};
}
describe('ApprovalQueue', () => {
test('starts empty', () => {
const q = new ApprovalQueue();
expect(q.current()).toBeNull();
expect(q.pendingCount()).toBe(0);
});
test('enqueue sets current and fires onChange', () => {
const q = new ApprovalQueue();
let fired = 0;
q.onChange(() => fired++);
q.enqueue(makeApproval(), { approve: () => {}, reject: () => {} });
expect(q.pendingCount()).toBe(1);
expect(q.current()?.id).toBe('txn-1');
expect(fired).toBe(1);
});
test('approve invokes the approve callback exactly once and clears state', async () => {
const q = new ApprovalQueue();
let approveCount = 0;
let rejectCount = 0;
q.enqueue(makeApproval(), {
approve: () => { approveCount++; },
reject: () => { rejectCount++; },
});
await q.approve('txn-1');
expect(approveCount).toBe(1);
expect(rejectCount).toBe(0);
expect(q.current()).toBeNull();
// Idempotent — second approve does nothing.
await q.approve('txn-1');
expect(approveCount).toBe(1);
});
test('reject invokes the reject callback exactly once', async () => {
const q = new ApprovalQueue();
let approveCount = 0;
let rejectCount = 0;
q.enqueue(makeApproval(), {
approve: () => { approveCount++; },
reject: () => { rejectCount++; },
});
await q.reject('txn-1');
expect(rejectCount).toBe(1);
expect(approveCount).toBe(0);
expect(q.current()).toBeNull();
});
test('mismatched id is ignored — protects against stale webview button clicks', async () => {
const q = new ApprovalQueue();
let count = 0;
q.enqueue(makeApproval('txn-1'), {
approve: () => { count++; },
reject: () => { count++; },
});
await q.approve('txn-OLD');
expect(count).toBe(0);
expect(q.current()?.id).toBe('txn-1');
});
test('approve/reject without id picks current', async () => {
const q = new ApprovalQueue();
let approveCount = 0;
q.enqueue(makeApproval(), { approve: () => { approveCount++; }, reject: () => {} });
await q.approve();
expect(approveCount).toBe(1);
});
test('enqueue while pending pre-empts the previous one without firing its callbacks', () => {
const q = new ApprovalQueue();
let oldApprove = 0, oldReject = 0;
q.enqueue(makeApproval('old'), {
approve: () => { oldApprove++; },
reject: () => { oldReject++; },
});
let newApprove = 0;
q.enqueue(makeApproval('new'), {
approve: () => { newApprove++; },
reject: () => {},
});
expect(q.current()?.id).toBe('new');
expect(oldApprove).toBe(0);
expect(oldReject).toBe(0);
// Approving "new" must hit only the new callback.
return q.approve('new').then(() => {
expect(newApprove).toBe(1);
expect(oldApprove).toBe(0);
});
});
test('clear() resets without firing callbacks', () => {
const q = new ApprovalQueue();
let cb = 0;
q.enqueue(makeApproval(), { approve: () => { cb++; }, reject: () => { cb++; } });
q.clear();
expect(q.current()).toBeNull();
expect(cb).toBe(0);
});
test('onChange fires on enqueue, approve, reject, clear', async () => {
const q = new ApprovalQueue();
const events: string[] = [];
q.onChange(() => events.push(`change-${q.pendingCount()}`));
q.enqueue(makeApproval('a'), { approve: () => {}, reject: () => {} });
await q.approve('a');
q.enqueue(makeApproval('b'), { approve: () => {}, reject: () => {} });
await q.reject('b');
q.enqueue(makeApproval('c'), { approve: () => {}, reject: () => {} });
q.clear();
expect(events).toEqual([
'change-1', 'change-0', // enqueue a, approve a
'change-1', 'change-0', // enqueue b, reject b
'change-1', 'change-0', // enqueue c, clear
]);
});
test('callback exception is swallowed (next enqueue still works)', async () => {
const q = new ApprovalQueue();
q.enqueue(makeApproval('boom'), {
approve: () => { throw new Error('callback boom'); },
reject: () => {},
});
await q.approve('boom');
expect(q.current()).toBeNull();
// Verify we can still operate the queue afterwards.
let next = 0;
q.enqueue(makeApproval('next'), { approve: () => { next++; }, reject: () => {} });
await q.approve('next');
expect(next).toBe(1);
});
test('dispose drops state and prevents further events', () => {
const q = new ApprovalQueue();
let fired = 0;
q.onChange(() => fired++);
q.enqueue(makeApproval(), { approve: () => {}, reject: () => {} });
expect(fired).toBe(1);
q.dispose();
expect(q.current()).toBeNull();
// Re-enqueueing after dispose: the emitter is disposed but should not crash.
// The contract is "dispose terminates the queue" — callers shouldn't reuse it.
});
});
+8
View File
@@ -73,6 +73,14 @@ class FakeLMStudioClient implements ILMStudioClient {
async isReachable(): Promise<boolean> {
return true;
}
async listLoadedCached(): Promise<string[]> {
return [];
}
async getModelHandle(_modelKey: string): Promise<any> {
return {};
}
}
function makeManager(overrides: Partial<LifecycleConfig> = {}, depOverrides: Partial<LifecycleManagerDeps> = {}) {
+185
View File
@@ -0,0 +1,185 @@
/**
* Unit tests for LMStudioStreamer.
*
* Strategy: inject a fake ILMStudioClient that returns a fake model handle whose
* `respond()` yields a controllable async iterable. No real SDK or WebSocket touched.
*/
import { LMStudioStreamer } from '../src/lmstudio/streamer';
import type { ILMStudioClient } from '../src/lmstudio/client';
class FakeModel {
public lastChat: any = null;
public lastOpts: any = null;
public cancelCount = 0;
public failNext: Error | null = null;
public chunks: string[] = [];
constructor(opts: { chunks?: string[]; failAfter?: number; throwOnRespond?: Error } = {}) {
this.chunks = opts.chunks ?? ['Hel', 'lo, ', 'world'];
this._failAfter = opts.failAfter;
this._throwOnRespond = opts.throwOnRespond;
}
private _failAfter?: number;
private _throwOnRespond?: Error;
respond(chat: any, opts: any) {
if (this._throwOnRespond) {
throw this._throwOnRespond;
}
this.lastChat = chat;
this.lastOpts = opts;
const chunks = this.chunks;
const failAfter = this._failAfter;
let i = 0;
const self = this;
return {
cancel: async () => { self.cancelCount++; },
[Symbol.asyncIterator]() {
return {
async next() {
if (opts?.signal?.aborted) {
return { value: undefined, done: true };
}
if (failAfter !== undefined && i >= failAfter) {
throw new Error('mid-stream failure');
}
if (i >= chunks.length) {
return { value: undefined, done: true };
}
const fragment = { content: chunks[i++] };
return { value: fragment, done: false };
},
};
},
};
}
}
class FakeClient implements ILMStudioClient {
public model: FakeModel;
public getModelHandleCalls: string[] = [];
constructor(model: FakeModel = new FakeModel()) {
this.model = model;
}
setBaseUrl(_: string): void { /* noop */ }
async load(_: string): Promise<void> { /* noop */ }
async unload(_: string): Promise<void> { /* noop */ }
async listLoaded(): Promise<string[]> { return []; }
async listLoadedCached(): Promise<string[]> { return []; }
async isReachable(): Promise<boolean> { return true; }
async getModelHandle(modelKey: string): Promise<any> {
this.getModelHandleCalls.push(modelKey);
return this.model;
}
}
async function collect(stream: AsyncIterable<{ token: string }>): Promise<string[]> {
const out: string[] = [];
for await (const { token } of stream) out.push(token);
return out;
}
describe('LMStudioStreamer', () => {
test('streams tokens from the SDK respond iterator', async () => {
const client = new FakeClient(new FakeModel({ chunks: ['Hel', 'lo'] }));
const streamer = new LMStudioStreamer(client);
const tokens = await collect(streamer.stream({
modelName: 'm1',
messages: [{ role: 'user', content: 'hi' }],
temperature: 0.4,
}));
expect(tokens).toEqual(['Hel', 'lo']);
expect(client.getModelHandleCalls).toEqual(['m1']);
expect(client.model.lastOpts.temperature).toBe(0.4);
});
test('passes signal through to the SDK', async () => {
const client = new FakeClient();
const streamer = new LMStudioStreamer(client);
const ac = new AbortController();
await collect(streamer.stream({
modelName: 'm1',
messages: [{ role: 'user', content: 'hi' }],
temperature: 0.2,
signal: ac.signal,
}));
expect(client.model.lastOpts.signal).toBe(ac.signal);
});
test('aborting mid-stream stops cleanly without throwing', async () => {
const client = new FakeClient(new FakeModel({ chunks: ['a', 'b', 'c', 'd'] }));
const streamer = new LMStudioStreamer(client);
const ac = new AbortController();
const out: string[] = [];
const iter = streamer.stream({
modelName: 'm1',
messages: [{ role: 'user', content: 'hi' }],
temperature: 0.3,
signal: ac.signal,
});
for await (const { token } of iter) {
out.push(token);
if (out.length === 2) ac.abort();
}
expect(out.length).toBeGreaterThanOrEqual(2);
expect(out.length).toBeLessThanOrEqual(3);
});
test('rejects when modelName is empty', async () => {
const client = new FakeClient();
const streamer = new LMStudioStreamer(client);
await expect(collect(streamer.stream({
modelName: '',
messages: [{ role: 'user', content: 'hi' }],
temperature: 0.2,
}))).rejects.toThrow(/without a model name/i);
});
test('mid-stream SDK failure is re-thrown when signal not aborted', async () => {
const client = new FakeClient(new FakeModel({ chunks: ['a', 'b'], failAfter: 1 }));
const streamer = new LMStudioStreamer(client);
await expect(collect(streamer.stream({
modelName: 'm1',
messages: [{ role: 'user', content: 'hi' }],
temperature: 0.2,
}))).rejects.toThrow(/mid-stream failure/);
});
test('mid-stream SDK failure swallowed if signal already aborted', async () => {
const client = new FakeClient(new FakeModel({ chunks: ['a', 'b'], failAfter: 1 }));
const streamer = new LMStudioStreamer(client);
const ac = new AbortController();
const iter = streamer.stream({
modelName: 'm1',
messages: [{ role: 'user', content: 'hi' }],
temperature: 0.2,
signal: ac.signal,
});
const out: string[] = [];
try {
for await (const { token } of iter) {
out.push(token);
ac.abort(); // abort right after first token, before failure point
}
} catch (e) {
// expected to be swallowed
}
expect(out).toEqual(['a']);
});
test('passes messages through to model.respond', async () => {
const client = new FakeClient();
const streamer = new LMStudioStreamer(client);
const messages = [
{ role: 'system' as const, content: 'sys' },
{ role: 'user' as const, content: 'hi' },
];
await collect(streamer.stream({ modelName: 'm1', messages, temperature: 0.5 }));
expect(client.model.lastChat).toEqual(messages);
});
});
+84
View File
@@ -0,0 +1,84 @@
/**
* Unit tests for the centralized path resolver.
*/
import * as os from 'os';
import * as path from 'path';
import {
expandTilde,
resolvePathInput,
isInside,
} from '../src/lib/paths';
describe('expandTilde', () => {
test('expands "~" to home', () => {
expect(expandTilde('~')).toBe(os.homedir());
});
test('expands "~/foo" to home/foo', () => {
expect(expandTilde('~/foo')).toBe(path.join(os.homedir(), 'foo'));
});
test('leaves absolute paths untouched', () => {
expect(expandTilde('/tmp/x')).toBe('/tmp/x');
});
test('returns empty for blank input', () => {
expect(expandTilde('')).toBe('');
expect(expandTilde(' ')).toBe('');
});
});
describe('resolvePathInput', () => {
test('accepts absolute paths', () => {
expect(resolvePathInput('/tmp/abc')).toBe(path.normalize('/tmp/abc'));
});
test('accepts ~/-prefixed paths after expansion', () => {
expect(resolvePathInput('~/notes')).toBe(path.normalize(path.join(os.homedir(), 'notes')));
});
test('rejects relative paths to prevent surprises', () => {
expect(resolvePathInput('relative/dir')).toBe('');
expect(resolvePathInput('./local')).toBe('');
});
test('returns empty on blank / undefined', () => {
expect(resolvePathInput('')).toBe('');
expect(resolvePathInput(undefined as any)).toBe('');
});
});
describe('isInside', () => {
test('a path is inside itself', () => {
expect(isInside('/tmp/a', '/tmp/a')).toBe(true);
});
test('detects direct descendants', () => {
expect(isInside('/tmp/a', '/tmp/a/b')).toBe(true);
});
test('detects deep descendants', () => {
expect(isInside('/tmp/a', '/tmp/a/b/c/d.txt')).toBe(true);
});
test('rejects siblings', () => {
expect(isInside('/tmp/a', '/tmp/b')).toBe(false);
});
test('rejects path-traversal escapes', () => {
// Even though string-prefix would say "/tmp/a/../b" starts with "/tmp/a/",
// path.resolve normalizes the dotdot back to the parent → not inside.
expect(isInside('/tmp/a', '/tmp/a/../b')).toBe(false);
});
test('rejects siblings whose name shares a prefix', () => {
// "/tmp/agents-evil" must not be considered inside "/tmp/agents".
expect(isInside('/tmp/agents', '/tmp/agents-evil')).toBe(false);
});
test('returns false on empty inputs', () => {
expect(isInside('', '/tmp/a')).toBe(false);
expect(isInside('/tmp/a', '')).toBe(false);
});
});
+135
View File
@@ -0,0 +1,135 @@
/**
* Unit tests for FileSystemProjectScaffolder.
*
* Drives against a real temp directory so end-to-end file IO + path-traversal
* defenses are exercised.
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
FileSystemProjectScaffolder,
validateProjectName,
} from '../src/scaffolder/projectScaffolder';
function tmp(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'astra-scaffold-test-'));
}
describe('validateProjectName', () => {
test('accepts allowed names', () => {
expect(validateProjectName('foo')).toBe('foo');
expect(validateProjectName('foo-bar_v2')).toBe('foo-bar_v2');
expect(validateProjectName(' trimmed ')).toBe('trimmed');
});
test('rejects too short', () => {
expect(validateProjectName('a')).toBeNull();
});
test('rejects too long', () => {
expect(validateProjectName('a'.repeat(41))).toBeNull();
});
test('rejects invalid chars', () => {
expect(validateProjectName('foo bar')).toBeNull();
expect(validateProjectName('foo/bar')).toBeNull();
expect(validateProjectName('foo.bar')).toBeNull();
expect(validateProjectName('한글이름')).toBeNull();
});
test('rejects empty / non-string', () => {
expect(validateProjectName('')).toBeNull();
expect(validateProjectName(undefined as any)).toBeNull();
});
});
describe('FileSystemProjectScaffolder', () => {
let root: string;
let scaffolder: FileSystemProjectScaffolder;
beforeEach(() => {
root = tmp();
scaffolder = new FileSystemProjectScaffolder();
});
afterEach(() => {
try { fs.rmSync(root, { recursive: true, force: true }); } catch { /* ignore */ }
});
test('listTemplates returns the catalog', () => {
const list = scaffolder.listTemplates();
const ids = list.map(t => t.id);
expect(ids).toContain('static');
expect(ids).toContain('vite-vanilla');
expect(ids).toContain('vite-react');
});
test('static template writes index.html + README.md', async () => {
const result = await scaffolder.scaffold({ name: 'demo-static', template: 'static', rootDir: root });
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(fs.existsSync(path.join(result.projectPath, 'site', 'index.html'))).toBe(true);
expect(fs.existsSync(path.join(result.projectPath, 'README.md'))).toBe(true);
const html = fs.readFileSync(path.join(result.projectPath, 'site', 'index.html'), 'utf8');
expect(html).toContain('demo-static');
});
test('vite-vanilla template writes package.json + main.js', async () => {
const result = await scaffolder.scaffold({ name: 'vv', template: 'vite-vanilla', rootDir: root });
expect(result.ok).toBe(true);
if (!result.ok) return;
const pkg = JSON.parse(fs.readFileSync(path.join(result.projectPath, 'site', 'package.json'), 'utf8'));
expect(pkg.name).toBe('vv');
expect(pkg.devDependencies.vite).toBeDefined();
expect(fs.existsSync(path.join(result.projectPath, 'site', 'main.js'))).toBe(true);
});
test('vite-react template includes tsconfig + main.tsx', async () => {
const result = await scaffolder.scaffold({ name: 'vr', template: 'vite-react', rootDir: root });
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(fs.existsSync(path.join(result.projectPath, 'site', 'tsconfig.json'))).toBe(true);
expect(fs.existsSync(path.join(result.projectPath, 'site', 'src', 'main.tsx'))).toBe(true);
const tsx = fs.readFileSync(path.join(result.projectPath, 'site', 'src', 'main.tsx'), 'utf8');
expect(tsx).toContain('<h1>vr</h1>');
});
test('rejects invalid name with INVALID_NAME', async () => {
const result = await scaffolder.scaffold({ name: 'foo bar', template: 'static', rootDir: root });
expect(result).toEqual(expect.objectContaining({ ok: false, code: 'INVALID_NAME' }));
});
test('rejects unknown template with UNKNOWN_TEMPLATE', async () => {
const result = await scaffolder.scaffold({ name: 'foo', template: 'made-up' as any, rootDir: root });
expect(result).toEqual(expect.objectContaining({ ok: false, code: 'UNKNOWN_TEMPLATE' }));
});
test('rejects empty rootDir with NO_ROOT_DIR', async () => {
const result = await scaffolder.scaffold({ name: 'foo', template: 'static', rootDir: '' });
expect(result).toEqual(expect.objectContaining({ ok: false, code: 'NO_ROOT_DIR' }));
});
test('rejects relative rootDir with ROOT_NOT_ABSOLUTE', async () => {
const result = await scaffolder.scaffold({ name: 'foo', template: 'static', rootDir: 'relative/path' });
expect(result).toEqual(expect.objectContaining({ ok: false, code: 'ROOT_NOT_ABSOLUTE' }));
});
test('rejects when target already exists with ALREADY_EXISTS', async () => {
const r1 = await scaffolder.scaffold({ name: 'twice', template: 'static', rootDir: root });
expect(r1.ok).toBe(true);
const r2 = await scaffolder.scaffold({ name: 'twice', template: 'static', rootDir: root });
expect(r2).toEqual(expect.objectContaining({ ok: false, code: 'ALREADY_EXISTS' }));
});
test('reports filesWritten for downstream UI', async () => {
const result = await scaffolder.scaffold({ name: 'count', template: 'vite-react', rootDir: root });
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.filesWritten.length).toBeGreaterThanOrEqual(5);
for (const file of result.filesWritten) {
expect(fs.existsSync(file)).toBe(true);
}
});
});
+172
View File
@@ -0,0 +1,172 @@
/**
* Unit tests for FileSystemSkillInjectionService.
*
* Strategy: drive the service against a real temp directory so path-traversal
* defenses and writeFileSync paths are exercised end-to-end. The service
* accepts a fs override but the prod path uses node:fs — testing real fs
* gives stronger confidence here.
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
FileSystemSkillInjectionService,
SkillInjectionError,
sanitizeSkillName,
} from '../src/skills/skillInjectionService';
function makeTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'astra-skill-test-'));
}
describe('sanitizeSkillName', () => {
test('keeps ASCII alphanumerics and -_.', () => {
expect(sanitizeSkillName('frontend_expert-v2.alpha')).toBe('frontend_expert-v2.alpha');
});
test('strips .md extension', () => {
expect(sanitizeSkillName('foo.md')).toBe('foo');
expect(sanitizeSkillName('foo.markdown')).toBe('foo');
});
test('replaces invalid chars with underscore', () => {
expect(sanitizeSkillName('hello world!')).toBe('hello_world');
expect(sanitizeSkillName('foo/bar')).toBe('foo_bar');
expect(sanitizeSkillName('파이썬')).toBe('');
});
test('strips leading/trailing dots and underscores', () => {
expect(sanitizeSkillName('___foo___')).toBe('foo');
expect(sanitizeSkillName('...foo...')).toBe('foo');
});
test('returns empty for path-traversal attempts', () => {
// ".." after stripping leading dots collapses to ""
expect(sanitizeSkillName('..')).toBe('');
expect(sanitizeSkillName('../etc/passwd')).toBe('etc_passwd');
});
test('caps length at 80', () => {
const long = 'a'.repeat(200);
expect(sanitizeSkillName(long).length).toBe(80);
});
test('rejects blank / non-string input', () => {
expect(sanitizeSkillName('')).toBe('');
expect(sanitizeSkillName(' ')).toBe('');
expect(sanitizeSkillName(undefined as any)).toBe('');
});
});
describe('FileSystemSkillInjectionService.inject', () => {
let dir: string;
let service: FileSystemSkillInjectionService;
beforeEach(() => {
dir = makeTempDir();
service = new FileSystemSkillInjectionService({
resolveSkillsDir: () => dir,
});
});
afterEach(() => {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
});
test('writes <name>.md and <name>.meta.json', async () => {
const result = await service.inject({
name: 'react_expert',
content: '# React Expert\n\nKnow React deeply.',
displayName: 'React Expert',
description: 'React specialist agent.',
source: 'ezer',
});
expect(result.safeName).toBe('react_expert');
expect(fs.existsSync(result.filePath)).toBe(true);
expect(fs.existsSync(result.metaPath)).toBe(true);
const md = fs.readFileSync(result.filePath, 'utf8');
expect(md).toContain('# React Expert');
const meta = JSON.parse(fs.readFileSync(result.metaPath, 'utf8'));
expect(meta.name).toBe('react_expert');
expect(meta.displayName).toBe('React Expert');
expect(meta.description).toBe('React specialist agent.');
expect(meta.injectedFrom).toBe('ezer');
expect(meta.injectedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});
test('sanitizes the name on disk', async () => {
const result = await service.inject({
name: 'hello world!.md',
content: 'body',
});
expect(result.safeName).toBe('hello_world');
expect(path.basename(result.filePath)).toBe('hello_world.md');
});
test('rejects empty name', async () => {
await expect(service.inject({ name: '', content: 'body' }))
.rejects.toMatchObject({ code: 'INVALID_NAME' });
});
test('rejects empty content', async () => {
await expect(service.inject({ name: 'foo', content: '' }))
.rejects.toMatchObject({ code: 'EMPTY_CONTENT' });
await expect(service.inject({ name: 'foo', content: ' ' }))
.rejects.toMatchObject({ code: 'EMPTY_CONTENT' });
});
test('rejects when skills dir cannot be resolved', async () => {
const noDirService = new FileSystemSkillInjectionService({
resolveSkillsDir: () => '',
});
await expect(noDirService.inject({ name: 'foo', content: 'body' }))
.rejects.toMatchObject({ code: 'NO_SKILLS_DIR' });
});
test('creates skills dir if it does not exist', async () => {
fs.rmSync(dir, { recursive: true, force: true });
expect(fs.existsSync(dir)).toBe(false);
const result = await service.inject({ name: 'foo', content: 'body' });
expect(fs.existsSync(result.filePath)).toBe(true);
});
test('falls back to safeName when displayName is blank', async () => {
const result = await service.inject({ name: 'bare', content: 'body' });
const meta = JSON.parse(fs.readFileSync(result.metaPath, 'utf8'));
expect(meta.displayName).toBe('bare');
expect(meta.injectedFrom).toBe('external');
});
test('overwrites existing skill on repeated inject', async () => {
await service.inject({ name: 'foo', content: 'v1' });
const r2 = await service.inject({ name: 'foo', content: 'v2' });
expect(fs.readFileSync(r2.filePath, 'utf8')).toBe('v2');
});
test('fires onInjected hook on success', async () => {
const calls: any[] = [];
const hooked = new FileSystemSkillInjectionService({
resolveSkillsDir: () => dir,
onInjected: (result, req) => calls.push({ result, req }),
});
await hooked.inject({ name: 'hooked', content: 'body', displayName: 'Hooked Skill' });
expect(calls).toHaveLength(1);
expect(calls[0].result.safeName).toBe('hooked');
expect(calls[0].req.displayName).toBe('Hooked Skill');
});
test('SkillInjectionError is thrown for write failures', async () => {
// Point at a path that exists but is not a directory — mkdirSync will
// throw. The service should wrap it as WRITE_FAILED.
const file = path.join(dir, 'notadir');
fs.writeFileSync(file, 'x');
const broken = new FileSystemSkillInjectionService({
resolveSkillsDir: () => path.join(file, 'sub'),
});
await expect(broken.inject({ name: 'foo', content: 'body' }))
.rejects.toMatchObject({ name: 'SkillInjectionError', code: 'WRITE_FAILED' });
});
});
+90
View File
@@ -0,0 +1,90 @@
/**
* Unit tests for SystemSpecs + HeuristicModelMemoryEstimator.
*
* Strategy:
* - HeuristicModelMemoryEstimator is pure — directly drive it with model ids.
* - NodeSystemSpecsProvider depends on `os.*` so we test:
* a) caching (same instance returned twice),
* b) shape (all required fields present, sane numbers).
* We don't pin platform-specific values since CI hardware varies.
*/
import {
NodeSystemSpecsProvider,
HeuristicModelMemoryEstimator,
} from '../src/system/specs';
describe('HeuristicModelMemoryEstimator', () => {
const est = new HeuristicModelMemoryEstimator();
test('extracts parameter count from "7B" suffix', () => {
// 7B q4 default: 7 * 0.6 + 1 = 5.2
expect(est.estimate('llama-3.2-7b-q4_K_M')).toBeCloseTo(5.2, 1);
});
test('extracts parameter count from "70B"', () => {
// 70B q4 default: 70 * 0.6 + 1 = 43
expect(est.estimate('llama-3-70b-instruct-q4_0')).toBeCloseTo(43, 0);
});
test('q8 quantization uses higher byte/param', () => {
// 7B q8: 7 * 1.0 + 1 = 8
expect(est.estimate('mistral-7b-q8_0')).toBeCloseTo(8, 1);
});
test('fp16 uses 2 bytes/param', () => {
// 7B fp16: 7 * 2.0 + 1 = 15
expect(est.estimate('mistral-7b-fp16')).toBeCloseTo(15, 1);
});
test('q5 sits between q4 and q6', () => {
const q4 = est.estimate('foo-7b-q4');
const q5 = est.estimate('foo-7b-q5');
const q6 = est.estimate('foo-7b-q6');
expect(q4).toBeLessThan(q5);
expect(q5).toBeLessThan(q6);
});
test('falls back to 7B when parameter count is absent', () => {
// unknown size → 7B q4 default → 5.2
expect(est.estimate('some-model-no-size')).toBeCloseTo(5.2, 1);
});
test('decimal parameter counts like "3.8b"', () => {
// 3.8B q4: 3.8 * 0.6 + 1 = 3.28
expect(est.estimate('phi-3.8b-q4')).toBeCloseTo(3.28, 1);
});
test('handles empty / undefined input gracefully', () => {
expect(est.estimate('')).toBeCloseTo(5.2, 1); // defaults
expect(est.estimate(undefined as any)).toBeCloseTo(5.2, 1);
});
});
describe('NodeSystemSpecsProvider', () => {
test('returns the same cached object on repeated calls', () => {
const provider = new NodeSystemSpecsProvider();
const a = provider.get();
const b = provider.get();
expect(a).toBe(b);
});
test('produces a sane shape', () => {
const specs = new NodeSystemSpecsProvider().get();
expect(specs.totalRamGB).toBeGreaterThan(0);
expect(specs.cpuCount).toBeGreaterThanOrEqual(1);
expect(specs.platform).toMatch(/^(darwin|linux|win32|freebsd|openbsd|sunos|aix)$/);
expect(specs.arch.length).toBeGreaterThan(0);
expect(typeof specs.isAppleSilicon).toBe('boolean');
expect(specs.safeModelBudgetGB).toBeGreaterThanOrEqual(2);
expect(specs.safeModelBudgetGB).toBeLessThanOrEqual(specs.totalRamGB);
expect(specs.summary).toMatch(/RAM/);
});
test('safe budget is at most ~65% of total RAM (Apple Silicon ceiling)', () => {
const specs = new NodeSystemSpecsProvider().get();
// Even on Apple Silicon (most generous ratio) the budget is capped at
// 0.65 of total. Use 0.7 as a soft upper bound for any platform.
expect(specs.safeModelBudgetGB).toBeLessThanOrEqual(specs.totalRamGB * 0.7 + 1);
});
});
+363
View File
@@ -0,0 +1,363 @@
/**
* Unit tests for TelegramBot + truncateForTelegram.
*
* Strategy:
* - TelegramBot is driven by an injected ITelegramClient stub. We script
* `getUpdates` to return queued batches and assert that:
* - the offset cursor advances correctly,
* - replies fire through sendMessage,
* - errors trigger backoff via the injected sleep,
* - aborted shutdown exits cleanly without sending residual messages,
* - invalid-token (401) stops the loop without burning retries.
* - The `sleep` injection turns the polling backoff into a counter we can
* drain synchronously inside the test loop.
*/
import { TelegramBot } from '../src/integrations/telegram/telegramBot';
import {
TelegramClientError,
truncateForTelegram,
type ITelegramClient,
type GetUpdatesOptions,
type SendMessageOptions,
} from '../src/integrations/telegram/telegramClient';
import type { TelegramMessage, TelegramUpdate, TelegramUser } from '../src/integrations/telegram/types';
class StubClient implements ITelegramClient {
public sent: SendMessageOptions[] = [];
public getUpdatesCalls: GetUpdatesOptions[] = [];
private _queue: Array<TelegramUpdate[] | Error> = [];
private _onDrain?: () => void;
private _waiters: Array<() => void> = [];
public failSendOnce: Error | null = null;
constructor(public meValue: TelegramUser = { id: 1, is_bot: true, first_name: 'TestBot' }) {}
queueBatch(updates: TelegramUpdate[]) {
this._queue.push(updates);
this._wakeWaiters();
}
queueError(err: Error) {
this._queue.push(err);
this._wakeWaiters();
}
onceDrained(cb: () => void) { this._onDrain = cb; }
private _wakeWaiters() {
const w = this._waiters.splice(0);
for (const fn of w) fn();
}
async getMe(): Promise<TelegramUser> { return this.meValue; }
async getUpdates(opts: GetUpdatesOptions): Promise<TelegramUpdate[]> {
this.getUpdatesCalls.push(opts);
if (this._queue.length === 0) {
this._onDrain?.();
await new Promise<void>((resolve, reject) => {
const onAbort = () => reject(new TelegramClientError('aborted', 'aborted'));
opts.signal?.addEventListener('abort', onAbort);
this._waiters.push(() => {
opts.signal?.removeEventListener('abort', onAbort);
resolve();
});
});
if (this._queue.length === 0) return [];
}
const next = this._queue.shift()!;
if (next instanceof Error) throw next;
return next;
}
async sendMessage(opts: SendMessageOptions): Promise<TelegramMessage> {
if (this.failSendOnce) {
const err = this.failSendOnce;
this.failSendOnce = null;
throw err;
}
this.sent.push(opts);
return {
message_id: 1,
date: 0,
chat: { id: opts.chatId, type: 'private' },
text: opts.text,
};
}
}
function update(id: number, chatId: number, text: string): TelegramUpdate {
return {
update_id: id,
message: { message_id: id, date: 0, chat: { id: chatId, type: 'private' }, text },
};
}
const flush = () => new Promise<void>((r) => setImmediate(r));
describe('truncateForTelegram', () => {
test('passes through short text', () => {
expect(truncateForTelegram('hello')).toBe('hello');
});
test('truncates over 4096 chars with ellipsis', () => {
const long = 'x'.repeat(5000);
const out = truncateForTelegram(long);
expect(out.length).toBeLessThanOrEqual(4096);
expect(out.endsWith('(truncated)')).toBe(true);
});
test('handles non-string defensively', () => {
expect(truncateForTelegram(undefined as any)).toBe('');
});
});
describe('TelegramBot', () => {
let client: StubClient;
let handled: Array<{ text: string; chatId: number }>;
let bot: TelegramBot;
beforeEach(() => {
client = new StubClient();
handled = [];
});
afterEach(async () => {
if (bot) await bot.stop();
});
test('start is idempotent', () => {
bot = new TelegramBot({ client, handle: async () => null });
bot.start();
expect(bot.isRunning()).toBe(true);
bot.start();
expect(bot.isRunning()).toBe(true);
});
test('processes updates and advances offset', async () => {
bot = new TelegramBot({
client,
handle: async (text, chatId) => { handled.push({ text, chatId }); return `echo: ${text}`; },
sleep: () => Promise.resolve(),
});
client.queueBatch([update(101, 999, 'hi'), update(102, 999, 'second')]);
client.onceDrained(() => { /* now hanging, safe to stop */ void bot.stop(); });
bot.start();
await new Promise<void>((r) => setTimeout(r, 30));
await bot.stop();
expect(handled).toEqual([
{ text: 'hi', chatId: 999 },
{ text: 'second', chatId: 999 },
]);
expect(client.sent.map(s => s.text)).toEqual(['echo: hi', 'echo: second']);
// Second poll uses offset = 103 (lastId + 1)
const offsets = client.getUpdatesCalls.map(c => c.offset);
expect(offsets[1]).toBe(103);
});
test('skips reply when handler returns null', async () => {
bot = new TelegramBot({
client,
handle: async () => null,
sleep: () => Promise.resolve(),
});
client.queueBatch([update(1, 1, 'ignored')]);
client.onceDrained(() => void bot.stop());
bot.start();
await new Promise<void>((r) => setTimeout(r, 20));
await bot.stop();
expect(client.sent).toEqual([]);
});
test('handler exception is converted to a reply (loop survives)', async () => {
bot = new TelegramBot({
client,
handle: async () => { throw new Error('boom'); },
sleep: () => Promise.resolve(),
});
client.queueBatch([update(1, 1, 'hi')]);
client.onceDrained(() => void bot.stop());
bot.start();
await new Promise<void>((r) => setTimeout(r, 20));
await bot.stop();
expect(client.sent).toHaveLength(1);
expect(client.sent[0].text).toContain('boom');
});
test('network error triggers backoff and retries', async () => {
const sleeps: number[] = [];
bot = new TelegramBot({
client,
handle: async () => null,
initialBackoffMs: 100,
maxBackoffMs: 800,
sleep: async (ms) => { sleeps.push(ms); },
});
client.queueError(new TelegramClientError('network', 'temp net'));
client.queueError(new TelegramClientError('network', 'temp net'));
client.queueBatch([update(1, 1, 'after-recovery')]);
client.onceDrained(() => void bot.stop());
bot.start();
await new Promise<void>((r) => setTimeout(r, 30));
await bot.stop();
// First two failures should produce 100ms then 200ms (exponential, capped at 800).
expect(sleeps.length).toBeGreaterThanOrEqual(2);
expect(sleeps[0]).toBe(100);
expect(sleeps[1]).toBe(200);
// Loop continued past errors and eventually saw the success batch.
expect(client.getUpdatesCalls.length).toBeGreaterThanOrEqual(3);
});
test('401 invalid-token stops loop without retries', async () => {
const sleeps: number[] = [];
bot = new TelegramBot({
client,
handle: async () => null,
sleep: async (ms) => { sleeps.push(ms); },
});
client.queueError(new TelegramClientError('api', 'unauthorized', 401));
bot.start();
await new Promise<void>((r) => setTimeout(r, 20));
// Bot should have stopped itself.
expect(bot.isRunning()).toBe(false);
expect(sleeps).toEqual([]);
});
test('aborted exits cleanly without backoff', async () => {
const sleeps: number[] = [];
bot = new TelegramBot({
client,
handle: async () => null,
sleep: async (ms) => { sleeps.push(ms); },
});
client.onceDrained(() => void bot.stop());
bot.start();
await new Promise<void>((r) => setTimeout(r, 20));
await bot.stop();
expect(sleeps).toEqual([]); // no backoff on graceful abort
});
test('reply send failure is logged but loop continues', async () => {
bot = new TelegramBot({
client,
handle: async () => 'reply',
sleep: () => Promise.resolve(),
});
client.failSendOnce = new Error('send failed');
client.queueBatch([update(1, 1, 'first'), update(2, 1, 'second')]);
client.onceDrained(() => void bot.stop());
bot.start();
await new Promise<void>((r) => setTimeout(r, 30));
await bot.stop();
// First send failed, second still attempted.
expect(client.sent).toHaveLength(1);
expect(client.sent[0].text).toBe('reply');
});
test('enrollNextChat captures the next message and skips the AI handler', async () => {
bot = new TelegramBot({
client,
handle: async (text, chatId) => { handled.push({ text, chatId }); return 'should-not-fire'; },
sleep: () => Promise.resolve(),
});
client.queueBatch([
{
update_id: 50,
message: {
message_id: 1, date: 0,
chat: { id: 777, type: 'private' },
from: { id: 777, is_bot: false, first_name: 'Alice', username: 'alice' },
text: 'hello',
},
},
]);
client.onceDrained(() => void bot.stop());
bot.start();
const captured = await bot.enrollNextChat(5000);
await bot.stop();
expect(captured.chatId).toBe(777);
expect(captured.username).toBe('alice');
expect(captured.firstName).toBe('Alice');
// Normal handler must NOT have fired for the captured update.
expect(handled).toEqual([]);
// Bot should have sent the enrollment ack (not a real reply).
expect(client.sent).toHaveLength(1);
expect(client.sent[0].text).toContain('등록');
});
test('enrollNextChat times out cleanly when no message arrives', async () => {
bot = new TelegramBot({
client,
handle: async () => null,
sleep: () => Promise.resolve(),
});
bot.start();
await expect(bot.enrollNextChat(50)).rejects.toThrow(/within|received/i);
await bot.stop();
});
test('a second enrollNextChat supersedes the first', async () => {
bot = new TelegramBot({
client,
handle: async () => null,
sleep: () => Promise.resolve(),
});
bot.start();
const first = bot.enrollNextChat(60_000);
// Eat the unhandled rejection by attaching a handler immediately.
const firstFailed = first.catch((e) => e.message);
const secondP = bot.enrollNextChat(60_000);
client.queueBatch([
{ update_id: 1, message: { message_id: 1, date: 0, chat: { id: 5, type: 'private' }, text: 'hi' } },
]);
client.onceDrained(() => void bot.stop());
const second = await secondP;
await bot.stop();
expect(second.chatId).toBe(5);
await expect(firstFailed).resolves.toMatch(/superseded/i);
});
test('cancelEnrollment rejects the pending promise', async () => {
bot = new TelegramBot({
client,
handle: async () => null,
sleep: () => Promise.resolve(),
});
bot.start();
const p = bot.enrollNextChat(60_000);
bot.cancelEnrollment();
await expect(p).rejects.toThrow(/cancel/i);
await bot.stop();
});
test('updates without text or chatId are ignored', async () => {
bot = new TelegramBot({
client,
handle: async (text, chatId) => { handled.push({ text, chatId }); return null; },
sleep: () => Promise.resolve(),
});
const malformed: TelegramUpdate = { update_id: 1, message: { message_id: 1, date: 0, chat: { id: 0, type: 'private' } } };
const noChat: TelegramUpdate = { update_id: 2, message: { message_id: 2, date: 0, chat: undefined as any, text: 'hi' } };
const valid = update(3, 99, 'real');
client.queueBatch([malformed, noChat, valid]);
client.onceDrained(() => void bot.stop());
bot.start();
await new Promise<void>((r) => setTimeout(r, 20));
await bot.stop();
expect(handled).toEqual([{ text: 'real', chatId: 99 }]);
});
});