chore: bump version to 2.80.27 and update core features
This commit is contained in:
@@ -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.
|
||||
});
|
||||
});
|
||||
@@ -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> = {}) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 }]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user