obol-ai 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +364 -0
- package/bin/obol.js +64 -0
- package/docs/DEPLOY.md +277 -0
- package/docs/obol-banner.png +0 -0
- package/package.json +29 -0
- package/src/background.js +188 -0
- package/src/backup.js +66 -0
- package/src/claude.js +443 -0
- package/src/clean.js +168 -0
- package/src/cli/backup.js +20 -0
- package/src/cli/init.js +381 -0
- package/src/cli/logs.js +12 -0
- package/src/cli/start.js +47 -0
- package/src/cli/status.js +44 -0
- package/src/cli/stop.js +12 -0
- package/src/config.js +57 -0
- package/src/db/migrate.js +134 -0
- package/src/evolve.js +668 -0
- package/src/first-run.js +110 -0
- package/src/heartbeat.js +16 -0
- package/src/index.js +55 -0
- package/src/memory.js +164 -0
- package/src/messages.js +140 -0
- package/src/personality.js +27 -0
- package/src/post-setup.js +410 -0
- package/src/telegram.js +377 -0
- package/src/test-utils.js +111 -0
package/src/telegram.js
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
const { Bot, GrammyError, HttpError } = require('grammy');
|
|
2
|
+
const {
|
|
3
|
+
isFirstRun, markFirstRunComplete, FIRST_RUN_SYSTEM,
|
|
4
|
+
parseSetupResponse, cleanResponse, writePersonalityFromSetup,
|
|
5
|
+
} = require('./first-run');
|
|
6
|
+
const { loadConfig } = require('./config');
|
|
7
|
+
const { isPostSetupDone, runPostSetup } = require('./post-setup');
|
|
8
|
+
const { BackgroundRunner } = require('./background');
|
|
9
|
+
const { shouldEvolve, evolve } = require('./evolve');
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
function createBot(telegramConfig, claude, memory, messageLog) {
|
|
13
|
+
const bot = new Bot(telegramConfig.token);
|
|
14
|
+
const allowedUsers = new Set(telegramConfig.allowedUsers || []);
|
|
15
|
+
const firstRunHistory = []; // Separate history for onboarding conversation
|
|
16
|
+
const bg = new BackgroundRunner();
|
|
17
|
+
|
|
18
|
+
// Auth middleware
|
|
19
|
+
bot.use(async (ctx, next) => {
|
|
20
|
+
if (allowedUsers.size > 0 && !allowedUsers.has(ctx.from?.id)) {
|
|
21
|
+
return; // Silently ignore unauthorized users
|
|
22
|
+
}
|
|
23
|
+
await next();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Set bot commands menu
|
|
27
|
+
bot.api.setMyCommands([
|
|
28
|
+
{ command: 'new', description: 'Start a fresh conversation' },
|
|
29
|
+
{ command: 'tasks', description: 'Show running background tasks' },
|
|
30
|
+
{ command: 'status', description: 'Bot status and uptime' },
|
|
31
|
+
{ command: 'backup', description: 'Trigger GitHub backup now' },
|
|
32
|
+
{ command: 'clean', description: 'Audit and fix workspace' },
|
|
33
|
+
]).catch(() => {}); // Best effort
|
|
34
|
+
|
|
35
|
+
// Handle /start
|
|
36
|
+
bot.command('start', async (ctx) => {
|
|
37
|
+
await ctx.reply('🪙 OBOL is ready. Just send me a message.');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Handle /memory commands
|
|
41
|
+
bot.command('memory', async (ctx) => {
|
|
42
|
+
if (!memory) return ctx.reply('Memory not configured.');
|
|
43
|
+
const args = ctx.message.text.split(' ').slice(1);
|
|
44
|
+
const sub = args[0];
|
|
45
|
+
|
|
46
|
+
if (sub === 'search' && args[1]) {
|
|
47
|
+
const results = await memory.search(args.slice(1).join(' '));
|
|
48
|
+
if (results.length === 0) return ctx.reply('No memories found.');
|
|
49
|
+
const text = results.map((m, i) =>
|
|
50
|
+
`${i + 1}. [${m.category}] ${m.content.substring(0, 100)}`
|
|
51
|
+
).join('\n');
|
|
52
|
+
return ctx.reply(`🔍 Found ${results.length}:\n\n${text}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (sub === 'stats') {
|
|
56
|
+
const stats = await memory.stats();
|
|
57
|
+
return ctx.reply(`📊 ${stats.total} memories\n\n${stats.breakdown}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return ctx.reply('Usage: /memory search <query> | /memory stats');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Handle /new — fresh conversation
|
|
64
|
+
bot.command('new', async (ctx) => {
|
|
65
|
+
claude.clearHistory(ctx.chat.id);
|
|
66
|
+
await ctx.reply('🪙 Fresh start. What\'s up?');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Handle /status
|
|
70
|
+
bot.command('status', async (ctx) => {
|
|
71
|
+
const uptime = process.uptime();
|
|
72
|
+
const mem = (process.memoryUsage().rss / 1024 / 1024).toFixed(0);
|
|
73
|
+
const h = Math.floor(uptime / 3600);
|
|
74
|
+
const m = Math.floor((uptime % 3600) / 60);
|
|
75
|
+
const running = bg.getStatus();
|
|
76
|
+
|
|
77
|
+
let text = `🪙 OBOL Status\n\n`;
|
|
78
|
+
text += `⏱️ Uptime: ${h}h ${m}m\n`;
|
|
79
|
+
text += `💾 Memory: ${mem}MB\n`;
|
|
80
|
+
text += `⚡ Tasks: ${running.length} running\n`;
|
|
81
|
+
|
|
82
|
+
if (memory) {
|
|
83
|
+
const stats = await memory.stats().catch(() => null);
|
|
84
|
+
if (stats) text += `🧠 Memories: ${stats.total}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
await ctx.reply(text);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Handle /backup
|
|
91
|
+
bot.command('backup', async (ctx) => {
|
|
92
|
+
try {
|
|
93
|
+
const config = loadConfig();
|
|
94
|
+
if (!config?.github) return ctx.reply('GitHub backup not configured.');
|
|
95
|
+
const { runBackup } = require('./backup');
|
|
96
|
+
await ctx.reply('📦 Running backup...');
|
|
97
|
+
await runBackup(config.github);
|
|
98
|
+
await ctx.reply('✅ Backup complete');
|
|
99
|
+
} catch (e) {
|
|
100
|
+
await ctx.reply(`⚠️ Backup failed: ${e.message}`);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Handle /forget
|
|
105
|
+
bot.command('forget', async (ctx) => {
|
|
106
|
+
if (!memory) return ctx.reply('Memory not configured.');
|
|
107
|
+
const id = ctx.message.text.split(' ')[1];
|
|
108
|
+
if (!id) return ctx.reply('Usage: /forget <memory-id>');
|
|
109
|
+
try {
|
|
110
|
+
await memory.forget(id);
|
|
111
|
+
await ctx.reply(`🗑️ Forgotten: ${id}`);
|
|
112
|
+
} catch (e) {
|
|
113
|
+
await ctx.reply(`⚠️ ${e.message}`);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Handle /recent
|
|
118
|
+
bot.command('recent', async (ctx) => {
|
|
119
|
+
if (!memory) return ctx.reply('Memory not configured.');
|
|
120
|
+
const results = await memory.recent({ limit: 10 });
|
|
121
|
+
if (results.length === 0) return ctx.reply('No memories yet.');
|
|
122
|
+
const text = results.map((m, i) => {
|
|
123
|
+
const time = new Date(m.created_at).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
|
124
|
+
return `${i + 1}. [${time}] [${m.category}] ${m.content.substring(0, 80)}`;
|
|
125
|
+
}).join('\n');
|
|
126
|
+
await ctx.reply(`🕐 Recent memories:\n\n${text}`);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Handle /today
|
|
130
|
+
bot.command('today', async (ctx) => {
|
|
131
|
+
if (!memory) return ctx.reply('Memory not configured.');
|
|
132
|
+
const results = await memory.byDate('today', { limit: 20 });
|
|
133
|
+
if (results.length === 0) return ctx.reply('Nothing stored today yet.');
|
|
134
|
+
const text = results.map((m, i) => {
|
|
135
|
+
const time = new Date(m.created_at).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
|
136
|
+
return `${i + 1}. [${time}] [${m.category}] ${m.content.substring(0, 80)}`;
|
|
137
|
+
}).join('\n');
|
|
138
|
+
await ctx.reply(`📅 Today (${results.length} memories):\n\n${text}`);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Handle /clean — audit and fix workspace
|
|
142
|
+
bot.command('clean', async (ctx) => {
|
|
143
|
+
const { cleanWorkspace } = require('./clean');
|
|
144
|
+
await ctx.replyWithChatAction('typing');
|
|
145
|
+
try {
|
|
146
|
+
const result = await cleanWorkspace();
|
|
147
|
+
if (result.issues.length === 0) {
|
|
148
|
+
await ctx.reply('✨ Workspace is clean. Nothing out of place.');
|
|
149
|
+
} else {
|
|
150
|
+
const text = `🧹 Found ${result.issues.length} issue(s):\n\n` +
|
|
151
|
+
result.issues.map(i => `${i.action === 'deleted' ? '🗑️' : '📦'} ${i.path} → ${i.action}`).join('\n') +
|
|
152
|
+
(result.errors.length > 0 ? `\n\n⚠️ ${result.errors.length} error(s):\n${result.errors.join('\n')}` : '');
|
|
153
|
+
await ctx.reply(text);
|
|
154
|
+
}
|
|
155
|
+
} catch (e) {
|
|
156
|
+
await ctx.reply(`⚠️ Clean failed: ${e.message}`);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Handle /tasks — show running background tasks
|
|
161
|
+
bot.command('tasks', async (ctx) => {
|
|
162
|
+
const running = bg.getStatus();
|
|
163
|
+
if (running.length === 0) {
|
|
164
|
+
return ctx.reply('No background tasks running.');
|
|
165
|
+
}
|
|
166
|
+
const text = running.map(t =>
|
|
167
|
+
`⏳ #${t.id}: ${t.task}... (${t.elapsed})`
|
|
168
|
+
).join('\n');
|
|
169
|
+
return ctx.reply(`Running tasks:\n\n${text}`);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Handle all text messages
|
|
173
|
+
bot.on('message:text', async (ctx) => {
|
|
174
|
+
const userMessage = ctx.message.text;
|
|
175
|
+
const userId = ctx.from.id;
|
|
176
|
+
const userName = ctx.from.first_name || 'User';
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
// Show typing indicator
|
|
180
|
+
await ctx.replyWithChatAction('typing');
|
|
181
|
+
|
|
182
|
+
// Log user message
|
|
183
|
+
messageLog?.log(ctx.chat.id, 'user', userMessage);
|
|
184
|
+
|
|
185
|
+
let response;
|
|
186
|
+
|
|
187
|
+
// First-run onboarding — OBOL learns about the user through conversation
|
|
188
|
+
if (isFirstRun()) {
|
|
189
|
+
firstRunHistory.push({ role: 'user', content: userMessage });
|
|
190
|
+
|
|
191
|
+
const msg = await claude.client.messages.create({
|
|
192
|
+
model: 'claude-sonnet-4-20250514',
|
|
193
|
+
max_tokens: 4096,
|
|
194
|
+
system: FIRST_RUN_SYSTEM,
|
|
195
|
+
messages: firstRunHistory,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const fullText = msg.content.filter(b => b.type === 'text').map(b => b.text).join('\n');
|
|
199
|
+
firstRunHistory.push({ role: 'assistant', content: fullText });
|
|
200
|
+
|
|
201
|
+
// Check if OBOL has gathered enough info
|
|
202
|
+
const setup = parseSetupResponse(fullText);
|
|
203
|
+
if (setup?.ready) {
|
|
204
|
+
const config = loadConfig();
|
|
205
|
+
writePersonalityFromSetup(setup, config?.bot?.name);
|
|
206
|
+
markFirstRunComplete();
|
|
207
|
+
// Reload personality in claude instance
|
|
208
|
+
claude.reloadPersonality?.();
|
|
209
|
+
|
|
210
|
+
// Run post-setup tasks (pass, swap, firewall)
|
|
211
|
+
if (!isPostSetupDone()) {
|
|
212
|
+
const config = loadConfig({ resolve: false });
|
|
213
|
+
await runPostSetup(config, async (msg) => {
|
|
214
|
+
await ctx.reply(msg).catch(() => {});
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
response = cleanResponse(fullText);
|
|
220
|
+
} else {
|
|
221
|
+
// Normal operation
|
|
222
|
+
response = await claude.chat(userMessage, {
|
|
223
|
+
userId,
|
|
224
|
+
userName,
|
|
225
|
+
chatId: ctx.chat.id,
|
|
226
|
+
bg,
|
|
227
|
+
ctx,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Log assistant response
|
|
232
|
+
messageLog?.log(ctx.chat.id, 'assistant', response);
|
|
233
|
+
|
|
234
|
+
// Check if it's time for soul evolution
|
|
235
|
+
if (messageLog && await shouldEvolve().catch(() => false)) {
|
|
236
|
+
// Run evolution in background — don't block the response
|
|
237
|
+
setImmediate(async () => {
|
|
238
|
+
try {
|
|
239
|
+
const result = await evolve(claude.client, messageLog, memory);
|
|
240
|
+
claude.reloadPersonality?.();
|
|
241
|
+
let msg = `🪙 Evolution #${result.evolutionNumber} complete.`;
|
|
242
|
+
|
|
243
|
+
if (result.scriptsFixed) {
|
|
244
|
+
msg += '\n🔧 Fixed a test regression automatically.';
|
|
245
|
+
} else if (result.scriptsRolledBack) {
|
|
246
|
+
msg += '\n⚠️ Rolled back a script refactor — tests couldn\'t be fixed.';
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (result.upgrades && result.upgrades.length > 0) {
|
|
250
|
+
msg += '\n\n🆕 **New capabilities:**';
|
|
251
|
+
for (const u of result.upgrades) {
|
|
252
|
+
msg += `\n• **${u.name}** — ${u.description}`;
|
|
253
|
+
if (u.command) msg += ` → \`${u.command}\``;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (result.deployedApps && result.deployedApps.length > 0) {
|
|
258
|
+
msg += '\n\n🚀 **Deployed:**';
|
|
259
|
+
for (const app of result.deployedApps) {
|
|
260
|
+
if (app.url) {
|
|
261
|
+
msg += `\n• ${app.name} → ${app.url}`;
|
|
262
|
+
} else if (app.error) {
|
|
263
|
+
msg += `\n• ${app.name} — deploy failed: ${app.error.substring(0, 100)}`;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (result.changelog) {
|
|
269
|
+
msg += `\n\n_${result.changelog}_`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
await ctx.reply(msg, { parse_mode: 'Markdown' }).catch(() =>
|
|
273
|
+
ctx.reply(msg).catch(() => {})
|
|
274
|
+
);
|
|
275
|
+
} catch (e) {
|
|
276
|
+
console.error('Evolution failed:', e.message);
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Send response (split if too long)
|
|
282
|
+
if (response.length > 4096) {
|
|
283
|
+
const chunks = splitMessage(response, 4096);
|
|
284
|
+
for (const chunk of chunks) {
|
|
285
|
+
await ctx.reply(chunk, { parse_mode: 'Markdown' }).catch(() =>
|
|
286
|
+
ctx.reply(chunk) // Fallback without markdown if parsing fails
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
} else {
|
|
290
|
+
await ctx.reply(response, { parse_mode: 'Markdown' }).catch(() =>
|
|
291
|
+
ctx.reply(response)
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
} catch (e) {
|
|
295
|
+
console.error('Message handling error:', e.message);
|
|
296
|
+
await ctx.reply('⚠️ Something went wrong. Check logs with `obol logs`.');
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// Handle photos/documents
|
|
301
|
+
bot.on('message:photo', async (ctx) => {
|
|
302
|
+
await ctx.reply('📷 Image support coming soon.');
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// Global error handler — catch everything, never crash
|
|
306
|
+
bot.catch((err) => {
|
|
307
|
+
const ctx = err.ctx;
|
|
308
|
+
const e = err.error;
|
|
309
|
+
console.error(`[bot.catch] Error while handling update ${ctx?.update?.update_id}:`);
|
|
310
|
+
|
|
311
|
+
if (e instanceof GrammyError) {
|
|
312
|
+
console.error(` Grammy error: ${e.description}`);
|
|
313
|
+
} else if (e instanceof HttpError) {
|
|
314
|
+
console.error(` HTTP error: ${e.message}`);
|
|
315
|
+
} else {
|
|
316
|
+
console.error(` Unknown error:`, e?.message || e);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Try to notify the user, but don't let that fail either
|
|
320
|
+
ctx?.reply?.('⚠️ Something went wrong. I\'m still alive though.').catch(() => {});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// Wrap bot.start with auto-restart on polling failures
|
|
324
|
+
const originalStart = bot.start.bind(bot);
|
|
325
|
+
bot.start = async function startWithResilience(opts = {}) {
|
|
326
|
+
const MAX_RETRIES = 10;
|
|
327
|
+
const BASE_DELAY = 1000;
|
|
328
|
+
let retries = 0;
|
|
329
|
+
|
|
330
|
+
const attempt = async () => {
|
|
331
|
+
try {
|
|
332
|
+
retries = 0; // Reset on successful start
|
|
333
|
+
await originalStart({
|
|
334
|
+
...opts,
|
|
335
|
+
onStart: (info) => {
|
|
336
|
+
console.log(` Bot: @${info.username}`);
|
|
337
|
+
opts.onStart?.(info);
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
} catch (e) {
|
|
341
|
+
retries++;
|
|
342
|
+
if (retries > MAX_RETRIES) {
|
|
343
|
+
console.error(`💀 Polling failed ${MAX_RETRIES} times. Giving up.`);
|
|
344
|
+
process.exit(1);
|
|
345
|
+
}
|
|
346
|
+
const delay = Math.min(BASE_DELAY * Math.pow(2, retries - 1), 60000);
|
|
347
|
+
console.error(`⚠️ Polling error (attempt ${retries}/${MAX_RETRIES}): ${e.message}`);
|
|
348
|
+
console.error(` Retrying in ${delay / 1000}s...`);
|
|
349
|
+
await new Promise(r => setTimeout(r, delay));
|
|
350
|
+
return attempt();
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
return attempt();
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
return bot;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function splitMessage(text, maxLength) {
|
|
361
|
+
const chunks = [];
|
|
362
|
+
let remaining = text;
|
|
363
|
+
while (remaining.length > 0) {
|
|
364
|
+
if (remaining.length <= maxLength) {
|
|
365
|
+
chunks.push(remaining);
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
// Find last newline before limit
|
|
369
|
+
let splitAt = remaining.lastIndexOf('\n', maxLength);
|
|
370
|
+
if (splitAt === -1 || splitAt < maxLength / 2) splitAt = maxLength;
|
|
371
|
+
chunks.push(remaining.substring(0, splitAt));
|
|
372
|
+
remaining = remaining.substring(splitAt).trimStart();
|
|
373
|
+
}
|
|
374
|
+
return chunks;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
module.exports = { createBot };
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Shared test utilities for OBOL.
|
|
4
|
+
* Used by both core tests and Opus-generated script tests.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* const { test, run, runFail, report } = require('obol/src/test-utils');
|
|
8
|
+
*
|
|
9
|
+
* test('should do X', () => {
|
|
10
|
+
* const out = run('my-script.js', '--flag value');
|
|
11
|
+
* if (!out.includes('expected')) throw new Error('missing expected output');
|
|
12
|
+
* });
|
|
13
|
+
*
|
|
14
|
+
* report();
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const { execSync } = require('child_process');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
|
|
20
|
+
let passed = 0;
|
|
21
|
+
let failed = 0;
|
|
22
|
+
let suiteName = '';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Set the suite name (printed as header)
|
|
26
|
+
*/
|
|
27
|
+
function suite(name) {
|
|
28
|
+
suiteName = name;
|
|
29
|
+
console.log(`\n${name}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Run a single test case
|
|
34
|
+
*/
|
|
35
|
+
function test(name, fn) {
|
|
36
|
+
try {
|
|
37
|
+
fn();
|
|
38
|
+
passed++;
|
|
39
|
+
console.log(` ✅ ${name}`);
|
|
40
|
+
} catch (e) {
|
|
41
|
+
failed++;
|
|
42
|
+
console.error(` ❌ ${name}: ${e.message}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Execute a script and return stdout. Throws on non-zero exit.
|
|
48
|
+
* @param {string} scriptPath - Absolute path or relative to cwd
|
|
49
|
+
* @param {string} args - CLI arguments
|
|
50
|
+
* @param {object} opts - { env, timeout, cwd }
|
|
51
|
+
*/
|
|
52
|
+
function run(scriptPath, args = '', opts = {}) {
|
|
53
|
+
const ext = path.extname(scriptPath);
|
|
54
|
+
const cmd = ext === '.sh' ? 'bash' : 'node';
|
|
55
|
+
const timeout = opts.timeout || 30000;
|
|
56
|
+
return execSync(`${cmd} "${scriptPath}" ${args}`, {
|
|
57
|
+
encoding: 'utf-8',
|
|
58
|
+
timeout,
|
|
59
|
+
cwd: opts.cwd || process.cwd(),
|
|
60
|
+
env: { ...process.env, ...opts.env },
|
|
61
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
62
|
+
}).trim();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Execute a script expecting failure (non-zero exit).
|
|
67
|
+
* Returns true if it failed, false if it succeeded (which is unexpected).
|
|
68
|
+
*/
|
|
69
|
+
function runFail(scriptPath, args = '', opts = {}) {
|
|
70
|
+
try {
|
|
71
|
+
run(scriptPath, args, opts);
|
|
72
|
+
return false; // Should have failed
|
|
73
|
+
} catch {
|
|
74
|
+
return true; // Expected failure
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Assert helper — throws if condition is false
|
|
80
|
+
*/
|
|
81
|
+
function assert(condition, message) {
|
|
82
|
+
if (!condition) throw new Error(message || 'assertion failed');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Assert two values are equal
|
|
87
|
+
*/
|
|
88
|
+
function assertEqual(actual, expected, message) {
|
|
89
|
+
if (actual !== expected) {
|
|
90
|
+
throw new Error(message || `expected "${expected}", got "${actual}"`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Assert string includes substring
|
|
96
|
+
*/
|
|
97
|
+
function assertIncludes(str, substr, message) {
|
|
98
|
+
if (!str.includes(substr)) {
|
|
99
|
+
throw new Error(message || `expected "${str}" to include "${substr}"`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Print results and exit with appropriate code
|
|
105
|
+
*/
|
|
106
|
+
function report() {
|
|
107
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
108
|
+
if (failed > 0) process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = { suite, test, run, runFail, assert, assertEqual, assertIncludes, report };
|