alexa-ai 2.1.1 → 2.1.2

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.
@@ -0,0 +1,986 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Every method the host bot's `src/modules/Aii.js` wrapper calls, exercised
5
+ * against a MOCKED DeepAI transport (no network needed) — and against a real
6
+ * PostgreSQL when POSTGRES_URL is set.
7
+ *
8
+ * node test/wrapper-methods.js
9
+ * POSTGRES_URL=postgres://... node test/wrapper-methods.js
10
+ *
11
+ * The mock reproduces DeepAI's real behaviour on FREE keys:
12
+ * • /api/text2img answers {"status": "Out of API credits"} (HTTP 200)
13
+ * • the in-chat image tool answers a `generated_image` packet
14
+ * • /api/summarization is refused, so the chat fallback must kick in
15
+ * • the chat endpoint with search returns text + a trailing web-results packet
16
+ */
17
+
18
+ const { AlexaAI, Media } = require('../index');
19
+ const { createFakeDb } = require('./fakes');
20
+
21
+ let passed = 0;
22
+ let failed = 0;
23
+ const failures = [];
24
+
25
+ function ok(name, condition, detail = '') {
26
+ if (condition) {
27
+ passed++;
28
+ console.log(` ✅ ${name}`);
29
+ } else {
30
+ failed++;
31
+ failures.push(`${name} ${detail}`);
32
+ console.log(` ❌ ${name} ${detail}`);
33
+ }
34
+ }
35
+ function check(name, actual, expected) {
36
+ ok(name, JSON.stringify(actual) === JSON.stringify(expected), `\n expected: ${JSON.stringify(expected)}\n actual: ${JSON.stringify(actual)}`);
37
+ }
38
+ function section(title) {
39
+ console.log(`\n\x1b[1m${title}\x1b[0m`);
40
+ }
41
+
42
+ // 1x1 PNG so the mime sniffer has something real to look at.
43
+ const PNG = Buffer.from(
44
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
45
+ 'base64'
46
+ );
47
+
48
+ /** DeepAI stand-in that behaves like the free tier. */
49
+ function installMockDeepAI(options = {}) {
50
+ const calls = [];
51
+ const realFetch = global.fetch;
52
+ const FS = '\u001C';
53
+
54
+ global.fetch = async (url, init = {}) => {
55
+ const u = String(url);
56
+ const fields = {};
57
+ const files = {};
58
+ if (init.body && typeof init.body.forEach === 'function') {
59
+ init.body.forEach((value, key) => {
60
+ if (typeof value === 'string') fields[key] = value;
61
+ else files[key] = { type: value.type, size: value.size, name: value.name };
62
+ });
63
+ }
64
+ calls.push({ url: u, method: init.method || 'GET', fields, files, headers: init.headers || {} });
65
+
66
+ const respond = (body, status = 200, type = 'text/plain') => ({
67
+ status,
68
+ headers: { get: () => type },
69
+ text: async () => body,
70
+ body: null,
71
+ });
72
+
73
+ if (u.includes('/api/text2img')) {
74
+ if (options.paidText2img) {
75
+ return respond(JSON.stringify({ id: 'img-1', output_url: 'https://api.deepai.org/job-view-file/img-1/outputs/output.jpg' }), 200, 'application/json');
76
+ }
77
+ return respond(JSON.stringify({ status: 'Out of API credits - please top up your account' }), 200, 'application/json');
78
+ }
79
+ if (u.includes('/api/summarization')) {
80
+ return respond(JSON.stringify({ status: 'Out of API credits' }), 200, 'application/json');
81
+ }
82
+ if (u.includes('/api/torch-srgan') || u.includes('/api/image-editor') || u.includes('/api/colorizer')) {
83
+ return respond(JSON.stringify({ id: 'job-2', output_url: 'https://api.deepai.org/job-view-file/job-2/outputs/output.jpg' }), 200, 'application/json');
84
+ }
85
+ if (u.includes('/api/nsfw-detector')) {
86
+ return respond(JSON.stringify({ id: 'job-3', output: { nsfw_score: 0.03 } }), 200, 'application/json');
87
+ }
88
+ if (u.includes('/chat_attachments/upload')) {
89
+ return respond(JSON.stringify({ success: true, attachment: { uuid: 'att-1', extraction_status: 'skipped' } }), 200, 'application/json');
90
+ }
91
+ if (u.includes('/chat_attachments/get')) {
92
+ return respond(JSON.stringify({ success: true, attachment: { uuid: 'att-1', extraction_status: 'skipped' } }), 200, 'application/json');
93
+ }
94
+ if (u.includes('ocr.space')) {
95
+ return { status: 200, headers: { get: () => 'application/json' }, json: async () => ({ IsErroredOnProcessing: false, ParsedResults: [{ ParsedText: 'Invoice total: Rs 4,500' }] }), text: async () => '' };
96
+ }
97
+ // ---- engine web search providers ---------------------------------
98
+ if (u.includes('duckduckgo.com') || u.includes('bing.com/') || u.includes('news.google.com') || u.includes('wikipedia.org')) {
99
+ if (options.searchDown) return respond('', 503, 'text/html');
100
+ if (options.searchEmpty) return respond('<html><body>No results.</body></html>', 200, 'text/html');
101
+ if (u.includes('bing.com/search?')) {
102
+ // live shape 2026-09-06: plain RSS, direct links, pubDate
103
+ return respond(
104
+ '<rss><channel><title>Bing: coffee</title><item><title>Coffee | Origin, Types, Uses, History, &amp; Facts | Britannica</title><link>https://www.britannica.com/topic/coffee</link>' +
105
+ '<description>Coffee, beverage brewed from the roasted and ground seeds of the tropical evergreen coffee plant.</description><pubDate>Fri, 04 Sep 2026 06:02:00 GMT</pubDate></item>' +
106
+ '<item><title>Bing videos</title><link>https://www.bing.com/videos/search?q=coffee</link></item></channel></rss>',
107
+ 200,
108
+ 'application/xml'
109
+ );
110
+ }
111
+ if (u.includes('html.duckduckgo.com')) {
112
+ if (options.ddgHtmlChallenge) return respond('<html><body><div class="anomaly-modal__title">Unfortunately, bots use DuckDuckGo too.</div></body></html>', 200, 'text/html');
113
+ return respond(
114
+ '<div class="result"><h2 class="result__title"><a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.reuters.com%2Fmarkets%2Fcommodities%2Fcoffee-2026&amp;rut=1">Coffee prices hit record &amp; keep <b>climbing</b></a></h2>' +
115
+ '<a class="result__snippet" href="#">Arabica futures rose <b>12%</b> in August after frost in Brazil&hellip;</a></div>' +
116
+ '<div class="result"><h2 class="result__title"><a rel="nofollow" class="result__a" href="https://duckduckgo.com/y.js?ad_provider=bing&u3=https%3A%2F%2Fwww.bing.com%2Faclick">Ad: Buy coffee</a></h2></div>' +
117
+ '<div class="result"><h2 class="result__title"><a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.ncausa.org%2F">National Coffee Association</a></h2>' +
118
+ '<a class="result__snippet" href="#">The NCA is the leading trade group for the US coffee industry.</a></div>',
119
+ 200,
120
+ 'text/html'
121
+ );
122
+ }
123
+ if (u.includes('lite.duckduckgo.com')) {
124
+ if (options.ddgLiteResults) {
125
+ return respond(
126
+ "<tr><td valign=\"top\">1.&nbsp;</td><td><a rel=\"nofollow\" href=\"//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.ncausa.org%2F&amp;rut=x\" class='result-link'>National Coffee Association</a></td></tr>" +
127
+ "<tr><td>&nbsp;&nbsp;&nbsp;</td><td class='result-snippet'>The NCA is the leading trade group for the US coffee industry.</td></tr>",
128
+ 200,
129
+ 'text/html'
130
+ );
131
+ }
132
+ return respond('<html></html>', 200, 'text/html');
133
+ }
134
+ if (u.includes('bing.com/news')) {
135
+ return respond(
136
+ '<?xml version="1.0"?><rss><channel><item><title>Starbucks trims menu &amp; adds cold brew</title>' +
137
+ '<link>http://www.bing.com/news/apiclick.aspx?ref=FexRss&amp;url=https%3a%2f%2fwww.cnbc.com%2f2026%2f08%2f20%2fstarbucks-menu.html&amp;c=1</link>' +
138
+ '<pubDate>Wed, 20 Aug 2026 14:02:00 GMT</pubDate><description>Starbucks removed 30% of its drinks to speed up service.</description>' +
139
+ '<News:Source xmlns:News="https://www.bing.com:443/news/search?q=coffee&amp;format=rss">CNBC</News:Source></item></channel></rss>',
140
+ 200,
141
+ 'application/xml'
142
+ );
143
+ }
144
+ if (u.includes('news.google.com')) {
145
+ return respond(
146
+ '<rss><channel><item><title><![CDATA[Sri Lanka coffee exports rise 18% - Daily Mirror]]></title>' +
147
+ '<link>https://news.google.com/rss/articles/CBMiXGh0dHBz?oc=5</link><pubDate>Thu, 04 Sep 2026 09:00:00 GMT</pubDate>' +
148
+ '<description><![CDATA[<a href="https://news.google.com/rss/articles/CBMi?oc=5">Sri Lanka coffee exports rise 18%</a>&nbsp;&nbsp;<font color="#6f6f6f">Daily Mirror</font>]]></description>' +
149
+ '<source url="https://www.dailymirror.lk">Daily Mirror</source></item></channel></rss>',
150
+ 200,
151
+ 'application/xml'
152
+ );
153
+ }
154
+ if (u.includes('wikipedia.org')) {
155
+ return respond(
156
+ JSON.stringify({ query: { search: [{ title: 'Coffee', snippet: '<span class="searchmatch">Coffee</span> is a beverage brewed from roasted coffee beans.', timestamp: '2026-08-30T10:00:00Z' }] } }),
157
+ 200,
158
+ 'application/json'
159
+ );
160
+ }
161
+ }
162
+
163
+ if (u.includes('/hacking_is_a_serious_crime')) {
164
+ const history = JSON.parse(fields.chatHistory || '[]');
165
+ const last = history[history.length - 1]?.content || '';
166
+
167
+ // Grounded research: the prompt carries numbered search results.
168
+ if (last.includes('Search results:\n[1]') || last.includes('built from the search results above')) {
169
+ if (options.groundedReply) return respond(typeof options.groundedReply === 'function' ? options.groundedReply(last) : options.groundedReply);
170
+ return respond(
171
+ 'Coffee is having a turbulent 2026, with record prices and big menu changes at the largest chains [1][2].\n\n' +
172
+ '**Recent Coffee News:**\n' +
173
+ '1. **Prices at a record**: Arabica futures rose 12% in August after frost damaged crops in Brazil [5]. Traders expect the squeeze to last into 2027.\n' +
174
+ '2. **Starbucks trims its menu**: The chain removed 30% of its drinks to speed up service, keeping cold brew [2]. Analysts see it as a bet on fewer, faster orders.\n' +
175
+ '3. **Sri Lanka exports up**: Coffee exports rose 18% year on year, according to the Daily Mirror [4]. Kandy estates lead the growth.\n\n' +
176
+ '**Coffee Background:**\n' +
177
+ '1. **What it is**: Coffee is a beverage brewed from roasted coffee beans [3]. It is one of the most traded commodities in the world.\n' +
178
+ '2. **Industry body**: Britannica calls coffee one of the three most popular beverages in the world [1]. It publishes yearly consumption trends.\n\n' +
179
+ 'Sources:\n- Made Up Times (https://www.made-up-times.example/coffee)\n'
180
+ );
181
+ }
182
+
183
+ // In-chat image tool: the browser's generate_image function call.
184
+ if (last.includes('"function_call"') && last.includes('generate_image')) {
185
+ return respond(`Here is your image!${FS}${JSON.stringify({ type: 'generated_image', share_url: 'https://deepai.org/generated/abc123.png' })}`);
186
+ }
187
+ if (fields.search === 'search' || fields.web_access_enabled === 'true') {
188
+ if (options.webReply) return respond(typeof options.webReply === 'function' ? options.webReply(last) : options.webReply);
189
+ if (last.includes('Topic: coffee\n') || last.includes('topic "coffee":')) {
190
+ // gpt-4o-mini, first attempt — the exact live reply that
191
+ // prompted this fix: one sentence, links as prose, no packet.
192
+ if (last.includes('Topic: coffee\n')) {
193
+ return respond(
194
+ 'Coffee is a popular beverage made from the roasted seeds of the coffee plant, providing a stimulating effect due to its caffeine content.\n\n' +
195
+ 'Sources:\n\n' +
196
+ 'Coffee Association (https://www.coffeeassociation.org/)\n' +
197
+ 'National Coffee Association (https://www.ncausa.org/)\n' +
198
+ 'Wikipedia - Coffee (https://en.wikipedia.org/wiki/Coffee)'
199
+ );
200
+ }
201
+ // …and the expansion turn, which follows the layout.
202
+ return respond(
203
+ "I'm a large language model, I don't have the ability to browse the web. However, here is an overview.\n\n" +
204
+ 'Coffee remains one of the most traded commodities in the world, and 2026 has brought record prices.\n\n' +
205
+ '**Recent Coffee News:**\n' +
206
+ '1. **Arabica futures hit a high**: Prices rose 12% in August after frost damaged crops in Brazil. Traders expect the squeeze to last into 2027.\n' +
207
+ '2. **Starbucks menu shake-up**: The chain removed 30% of its drinks to speed up service. Cold brew and nitro cold brew stay on the menu.\n' +
208
+ '3. **Sri Lanka exports grow**: Specialty coffee exports rose 18% year on year. Kandy and Nuwara Eliya estates lead the growth.\n' +
209
+ '4. **Coffee machine market**: Home espresso machine sales grew 9%, driven by compact bean-to-cup models.\n\n' +
210
+ '**Coffee Trends:**\n' +
211
+ '1. **Cold brew keeps growing**: Ready-to-drink sales are up 20%. Supermarkets now stock more cold brew than iced coffee.\n' +
212
+ '2. **Home roasting**: Small roasters sold out of green beans twice this summer. Online courses on roasting doubled.\n' +
213
+ '3. **Sustainability labels**: Deforestation-free certification is now demanded by EU importers. Exporters must show farm-level traceability.\n' +
214
+ '4. **Coffee and health**: New studies link two to three cups a day with lower heart-disease risk. Researchers caution against added sugar.\n\n' +
215
+ '**Other Coffee News:**\n' +
216
+ '1. **Subscription services**: Bean subscriptions grew 15% as commuters returned to offices. Bundles with grinders are the fastest sellers.\n' +
217
+ '2. **New roasters open**: Colombo gained six specialty cafés this year. Most roast on site and publish origin details.\n' +
218
+ '3. **Coffee desserts**: Tiramisu and affogato lead café dessert menus. Chains are adding coffee-flavoured ice cream for the season.\n\n' +
219
+ '*<Section heading 4>:*\n1. *<Headline>*: <…>\n\n' +
220
+ 'Please note that the information I provided is based on my training data and may not reflect the most up-to-date news and trends.\n\n' +
221
+ 'Sources:\n\n' +
222
+ 'Reuters – Coffee prices (https://www.reuters.com/markets/commodities/coffee-2026)\n' +
223
+ 'National Coffee Association (https://www.ncausa.org/)\n' +
224
+ 'Wikipedia - Coffee (https://en.wikipedia.org/wiki/Coffee)\n' +
225
+ '<Page title> (<url>)'
226
+ );
227
+ }
228
+ const results = [
229
+ { title: 'Central Bank of Sri Lanka', url: 'https://www.cbsl.gov.lk', description: 'Exchange rates' },
230
+ { title: 'XE', url: 'https://www.xe.com/currencyconverter/', description: 'LKR to USD' },
231
+ ];
232
+ return respond(
233
+ `${FS}{"tool_activity":"Searching the web…"}${FS}Today **1 USD ≈ 300 LKR** according to the Central Bank.\n\n` +
234
+ `**Key Facts:**\n1. **Official rate**: 300.10 LKR.\n2. **Trend**: the rupee gained 2% this month.\n\n` +
235
+ `Sources:\n- XE (https://www.xe.com/currencyconverter/)\n- Reuters (https://www.reuters.com/lkr)${FS}${JSON.stringify(results)}`
236
+ );
237
+ }
238
+ if (last.includes('Summarise the following text')) {
239
+ return respond('• Alexa is a WhatsApp assistant\n• She remembers people across chats');
240
+ }
241
+ if (fields.attachment_uuids) {
242
+ return respond("I'm sorry, I can't see images.");
243
+ }
244
+ if (last.includes('Reply with the single word')) return respond('ok');
245
+ return respond('Hello from Alexa!');
246
+ }
247
+ return respond(JSON.stringify({ success: true }), 200, 'application/json');
248
+ };
249
+
250
+ return { calls, restore: () => { global.fetch = realFetch; } };
251
+ }
252
+
253
+ async function mockedTests() {
254
+ section('Media — every input shape becomes { buffer | url }');
255
+ {
256
+ const fromBuffer = Media.normalize(PNG);
257
+ check('bare Buffer -> sniffed mimetype', fromBuffer.mimetype, 'image/png');
258
+ check('bare Buffer -> default filename', fromBuffer.filename, 'image.png');
259
+ check('Uint8Array is accepted', Media.normalize(new Uint8Array(PNG)).mimetype, 'image/png');
260
+ check('ArrayBuffer is accepted', Boolean(Media.normalize(PNG.buffer.slice(PNG.byteOffset, PNG.byteOffset + PNG.length))), true);
261
+ const uri = Media.normalize(`data:image/png;base64,${PNG.toString('base64')}`);
262
+ check('data URI -> bytes', uri.buffer.equals(PNG), true);
263
+ check('data URI -> mimetype', uri.mimetype, 'image/png');
264
+ check('raw base64 string -> bytes', Media.normalize(PNG.toString('base64')).buffer.equals(PNG), true);
265
+ check('URL string -> { url }', Media.normalize('https://example.com/a.jpg'), { url: 'https://example.com/a.jpg' });
266
+ check('{ url }', Media.normalize({ url: 'https://example.com/a.jpg' }).url, 'https://example.com/a.jpg');
267
+ check('{ base64 } (whatsapp-web.js MessageMedia)', Media.normalize({ base64: PNG.toString('base64'), mimetype: 'image/png' }).buffer.equals(PNG), true);
268
+ check('{ data } (whatsapp-web.js MessageMedia)', Media.normalize({ data: PNG.toString('base64'), mimetype: 'image/png' }).filename, 'image.png');
269
+ check('{ buffer } keeps caller filename', Media.normalize({ buffer: PNG, filename: 'shot.png' }).filename, 'shot.png');
270
+ check('wrong label is corrected from the bytes', Media.normalize({ buffer: PNG, mimetype: 'image/jpeg' }).mimetype, 'image/png');
271
+ check('plain sentence is NOT an image', Media.normalize('hello there how are you doing today my friend'), null);
272
+ check('empty input', Media.normalize(''), null);
273
+ check('null input', Media.normalize(null), null);
274
+ check('document detection by mimetype', Media.isDocument({ mimetype: 'application/pdf' }), true);
275
+ check('document detection by name', Media.isDocument({ filename: 'notes.txt' }), true);
276
+ check('image is not a document', Media.isDocument({ mimetype: 'image/png', filename: 'a.pdf' }), false);
277
+ check('toApiField(url) is a string', Media.toApiField('https://x.y/z.png'), 'https://x.y/z.png');
278
+ check('toApiField(buffer) keeps mimetype', Media.toApiField(PNG).mimetype, 'image/png');
279
+ }
280
+
281
+ section('Engine surface — the methods the bot wrapper calls exist');
282
+ {
283
+ const required = [
284
+ 'chat', 'ask', 'init', 'close', 'health', 'deepaiHealth', 'stats',
285
+ 'forgetAll', 'getMemories', 'remember', 'forget', 'clearHistory',
286
+ 'blockUser', 'unblockUser', 'isBlocked', 'setGroupEnabled', 'isGroupEnabled', 'getProfile',
287
+ 'linkIdentity', 'getAliases', 'whoIs', 'mergeUsers',
288
+ 'generateImage', 'editImage', 'upscaleImage', 'colorizeImage', 'detectNsfw',
289
+ 'describeImage', 'summarizeText', 'searchWeb',
290
+ ];
291
+ const ai = new AlexaAI({ key: 'tryit-1-x', postgresUrl: 'postgres://u:p@localhost/db', autoMigrate: false });
292
+ const missing = required.filter((m) => typeof ai[m] !== 'function');
293
+ check('no wrapper method is missing', missing, []);
294
+ ok('AlexaAI.methods() lists them all', required.every((m) => AlexaAI.methods().includes(m)));
295
+ ok('AlexaAI.version is exposed', /^\d+\.\d+\.\d+/.test(AlexaAI.version));
296
+ check('instance.version matches', ai.version, AlexaAI.version);
297
+ }
298
+
299
+ section('generateImage() — free key: /api/text2img refused, chat tool succeeds');
300
+ {
301
+ const ai = new AlexaAI({ key: 'tryit-1-x', postgresUrl: 'postgres://u:p@localhost/db', autoMigrate: false, maxRetries: 0 });
302
+ const mock = installMockDeepAI();
303
+ try {
304
+ const r = await ai.generateImage('a red tuk-tuk in Galle at sunset');
305
+ check('ok', r.ok, true);
306
+ check('url comes from the chat image tool', r.url, 'https://deepai.org/generated/abc123.png');
307
+ check('route reported', r.via, 'chat');
308
+ const api = mock.calls.find((c) => c.url.includes('/api/text2img'));
309
+ ok('/api/text2img was tried first', Boolean(api));
310
+ check('/api/text2img got the prompt', api.fields.text, 'a red tuk-tuk in Galle at sunset');
311
+ const tool = mock.calls.find((c) => c.url.includes('hacking_is_a_serious_crime'));
312
+ ok('chat tool was used as fallback', Boolean(tool));
313
+ const payload = JSON.parse(JSON.parse(tool.fields.chatHistory)[0].content);
314
+ check('function_call name', payload.function_call.name, 'generate_image');
315
+ check('function_call prompt', JSON.parse(payload.function_call.arguments).prompt, 'a red tuk-tuk in Galle at sunset');
316
+ check('image_generation flag sent', tool.fields.image_generation, 'true');
317
+
318
+ const empty = await ai.generateImage(' ');
319
+ check('empty prompt is rejected, not thrown', empty.error, 'VALIDATION_ERROR');
320
+
321
+ const apiOnly = await ai.generateImage('x', { apiOnly: true });
322
+ check('apiOnly on a free key reports quota', apiOnly.error, 'DEEPAI_QUOTA_EXCEEDED');
323
+ ok('apiOnly failure carries a message', /credits/i.test(apiOnly.message));
324
+ } finally {
325
+ mock.restore();
326
+ }
327
+ }
328
+
329
+ section('generateImage() — paid key: /api/text2img answers directly');
330
+ {
331
+ const ai = new AlexaAI({ key: 'paid-key', postgresUrl: 'postgres://u:p@localhost/db', autoMigrate: false, maxRetries: 0 });
332
+ const mock = installMockDeepAI({ paidText2img: true });
333
+ try {
334
+ const r = await ai.generateImage('a cat', { width: 512, height: 512 });
335
+ check('ok', r.ok, true);
336
+ check('route', r.via, 'api');
337
+ ok('url from /api/text2img', r.url.includes('job-view-file'));
338
+ check('extra fields forwarded', mock.calls[0].fields.width, '512');
339
+ check('no chat call was needed', mock.calls.filter((c) => c.url.includes('hacking')).length, 0);
340
+ } finally {
341
+ mock.restore();
342
+ }
343
+ }
344
+
345
+ section('searchWeb() — web access flags, clean text, sources');
346
+ {
347
+ const ai = new AlexaAI({ key: 'tryit-1-x', postgresUrl: 'postgres://u:p@localhost/db', autoMigrate: false, maxRetries: 0 });
348
+ const mock = installMockDeepAI();
349
+ try {
350
+ const r = await ai.searchWeb('LKR to USD today', { search: false });
351
+ check('ok', r.ok, true);
352
+ check('search:false -> DeepAI web access path', r.grounded, false);
353
+ ok('answer text present', r.text.includes('1 USD'));
354
+ ok('markdown ** converted to WhatsApp *', !r.text.includes('**') && r.text.includes('*1 USD ≈ 300 LKR*'));
355
+ ok('no control characters leak', !/[\u001C\u001D\u001E]/.test(r.text));
356
+ ok('tool activity packet stripped', !r.text.includes('Searching the web'));
357
+ check(
358
+ 'packet sources first, then the ones the model listed, de-duplicated by URL',
359
+ r.sources.map((s) => s.url),
360
+ ['https://www.cbsl.gov.lk', 'https://www.xe.com/currencyconverter/', 'https://www.reuters.com/lkr']
361
+ );
362
+ check('packet description kept', r.sources[0].description, 'Exchange rates');
363
+ check('one Sources block in the text', (r.text.match(/Sources:/g) || []).length, 1);
364
+ ok('sources block is WhatsApp formatted', r.text.includes('*Sources:*\n1. Central Bank of Sri Lanka — https://www.cbsl.gov.lk'));
365
+ ok('answer excludes the sources block', !r.answer.includes('Sources') && r.answer.includes('*Key Facts:*'));
366
+ const call = mock.calls.find((c) => c.url.includes('hacking'));
367
+ check('web_access_enabled sent', call.fields.web_access_enabled, 'true');
368
+ check('search flag sent', call.fields.search, 'search');
369
+ check('online flag sent', call.fields.online, 'online');
370
+ const history = JSON.parse(call.fields.chatHistory);
371
+ const prompt = history[history.length - 1].content;
372
+ ok('query is in the prompt', prompt.includes('Topic: LKR to USD today'));
373
+ ok('prompt asks for a long, sectioned answer', /3 to 5 sections/.test(prompt) && /300 to 450 words/.test(prompt));
374
+ ok('prompt shows the layout template', prompt.includes('*<Section heading 1>:*') && prompt.includes('1. *<Headline>*:'));
375
+ ok('prompt forbids the browse / training-data notes', /unable to browse the web/.test(prompt) && /training data/.test(prompt));
376
+ check('short packet reply triggers one expansion attempt', r.attempts, 2);
377
+ const lkrCalls = mock.calls.filter((c) => c.url.includes('hacking'));
378
+ check('…so two chat requests were made', lkrCalls.length, 2);
379
+ ok('persona still applied', history.some((m) => m.content.includes('Alexa')));
380
+
381
+ const empty = await ai.searchWeb('');
382
+ check('empty query rejected, not thrown', empty.error, 'VALIDATION_ERROR');
383
+ } finally {
384
+ mock.restore();
385
+ }
386
+ }
387
+
388
+ section('searchWeb() — grounded: the engine searches, the model writes, sources come from the search');
389
+ {
390
+ const ai = new AlexaAI({ key: 'tryit-1-x', postgresUrl: 'postgres://u:p@localhost/db', autoMigrate: false, maxRetries: 0 });
391
+ let mock = installMockDeepAI();
392
+ try {
393
+ const r = await ai.searchWeb('coffee', { minWords: 100 });
394
+ check('ok', r.ok, true);
395
+ check('grounded', r.grounded, true);
396
+ check('via model', r.via, 'model');
397
+ check('providers that answered', r.providers, ['bing', 'bing-news', 'wikipedia', 'google-news', 'duckduckgo']);
398
+ const searchCalls = mock.calls.filter((c) => /duckduckgo|bing\.com|news\.google|wikipedia/.test(c.url));
399
+ ok('all providers queried in parallel before the model', searchCalls.length >= 5);
400
+ ok('ddg: lite first, html only as fallback', mock.calls.findIndex((c) => c.url.includes('lite.duckduckgo')) < mock.calls.findIndex((c) => c.url.includes('html.duckduckgo')));
401
+ const chat = mock.calls.filter((c) => c.url.includes('hacking'));
402
+ check('one model call (reply was long enough)', chat.length, 1);
403
+ check('DeepAI web access NOT requested when grounded', chat[0].fields.web_access_enabled, 'false');
404
+ ok('search flag not sent when grounded', !chat[0].fields.search);
405
+ const prompt = JSON.parse(chat[0].fields.chatHistory).pop().content;
406
+ ok('prompt carries numbered results', prompt.includes('[1] Coffee | Origin, Types, Uses, History, & Facts | Britannica') && prompt.includes('reuters.com'));
407
+ ok('bing.com video/junk link filtered', !prompt.includes('bing.com/videos'));
408
+ ok('prompt carries snippets', prompt.includes('Arabica futures rose 12% in August'));
409
+ ok('prompt forbids URLs from the model', prompt.includes('Never write a URL'));
410
+ ok('ad result filtered out', !prompt.includes('Buy coffee'));
411
+
412
+ ok('citation markers removed from the text', !/\[\d\]/.test(r.text));
413
+ ok('prose intact', r.text.includes('1. *Prices at a record*: Arabica futures rose 12% in August after frost damaged crops in Brazil. Traders expect'));
414
+ ok("model's invented source discarded", !r.text.includes('made-up-times') && !r.sources.some((s) => /made-up/.test(s.url)));
415
+ // interleaved order: [1] bing/britannica, [2] bing-news/cnbc, [3] wikipedia, [4] gnews, [5] ddg/reuters, [6] ddg/ncausa
416
+ // the mock reply cites [1][2] in the intro, then [5], [2], [4], [3] -> order 1, 2, 5, 4, 3, uncited 6 last
417
+ check(
418
+ 'sources = search results, cited first in citation order',
419
+ r.sources.map((s) => s.url),
420
+ [
421
+ 'https://www.britannica.com/topic/coffee',
422
+ 'https://www.cnbc.com/2026/08/20/starbucks-menu.html',
423
+ 'https://www.reuters.com/markets/commodities/coffee-2026',
424
+ 'https://news.google.com/rss/articles/CBMiXGh0dHBz?oc=5',
425
+ 'https://en.wikipedia.org/wiki/Coffee',
426
+ 'https://www.ncausa.org/',
427
+ ]
428
+ );
429
+ check('cited flags', r.sources.map((s) => s.cited), [true, true, true, true, true, false]);
430
+ check('dates carried', r.sources[1].date, '2026-08-20');
431
+ ok('sources block rendered once', (r.text.match(/\*Sources:\*/g) || []).length === 1);
432
+ ok('sources block lists real titles', r.text.includes('1. Coffee | Origin, Types, Uses, History, & Facts | Britannica — https://www.britannica.com/topic/coffee'));
433
+ ok('maxSources default 5 caps the block, array keeps all 6', !r.text.includes('ncausa.org') && r.sources.length === 6);
434
+ ok('no ** markdown', !r.text.includes('**'));
435
+
436
+ // caller-supplied results skip the built-in search
437
+ mock.calls.length = 0;
438
+ const own = await ai.searchWeb('coffee', { results: [{ title: 'My API result', url: 'https://api.example/1', snippet: 'from my search api', publishedAt: '2026-09-05' }] });
439
+ check('caller results -> grounded', own.grounded, true);
440
+ check('caller results -> provider tag', own.providers, ['caller']);
441
+ check('built-in search skipped', mock.calls.filter((c) => /duckduckgo|bing\.com|news\.google|wikipedia/.test(c.url)).length, 0);
442
+ check('caller result is the source', own.sources.map((s) => s.url), ['https://api.example/1']);
443
+
444
+ // provider subset
445
+ mock.calls.length = 0;
446
+ await ai.searchWeb('coffee', { providers: ['wikipedia'] });
447
+ check('providers option limits the search', mock.calls.filter((c) => /duckduckgo|bing\.com|news\.google/.test(c.url)).length, 0);
448
+ } finally {
449
+ mock.restore();
450
+ }
451
+
452
+ // DuckDuckGo bot challenge on html, results on lite
453
+ mock = installMockDeepAI({ ddgHtmlChallenge: true, ddgLiteResults: true });
454
+ try {
455
+ const r = await ai.searchWeb('coffee', { providers: ['duckduckgo'], minWords: 0 });
456
+ check('ddg lite results used', r.providers, ['duckduckgo']);
457
+ ok('lite result is a source', r.sources.some((s) => s.url === 'https://www.ncausa.org/'));
458
+ check('html endpoint not needed when lite answered', mock.calls.filter((c) => c.url.includes('html.duckduckgo')).length, 0);
459
+ } finally {
460
+ mock.restore();
461
+ }
462
+ mock = installMockDeepAI({ ddgHtmlChallenge: true });
463
+ try {
464
+ const r = await ai.searchWeb('coffee', { providers: ['duckduckgo'] });
465
+ check('ddg challenge page -> no results, not grounded', r.grounded, false);
466
+ } finally {
467
+ mock.restore();
468
+ }
469
+ }
470
+
471
+ section('searchWeb() — grounded fallbacks');
472
+ {
473
+ const ai = new AlexaAI({ key: 'tryit-1-x', postgresUrl: 'postgres://u:p@localhost/db', autoMigrate: false, maxRetries: 0 });
474
+
475
+ let mock = installMockDeepAI({ searchDown: true });
476
+ try {
477
+ const r = await ai.searchWeb('coffee');
478
+ check('all providers down -> DeepAI web access fallback', r.grounded, false);
479
+ const chat = mock.calls.filter((c) => c.url.includes('hacking'));
480
+ check('fallback requests DeepAI web access', chat[0].fields.web_access_enabled, 'true');
481
+ check('fallback still answers', r.ok, true);
482
+ } finally {
483
+ mock.restore();
484
+ }
485
+
486
+ mock = installMockDeepAI({ searchEmpty: true });
487
+ try {
488
+ const r = await ai.searchWeb('coffee');
489
+ check('empty pages -> not grounded', r.grounded, false);
490
+ check('no providers reported', r.providers, []);
491
+ } finally {
492
+ mock.restore();
493
+ }
494
+
495
+ const FS = '\u001C';
496
+ mock = installMockDeepAI({ groundedReply: `${FS}{"status":"Out of API credits"}${FS}` });
497
+ try {
498
+ const r = await ai.searchWeb('coffee');
499
+ check('model down but search worked -> digest of the results', r.via, 'digest');
500
+ check('still ok', r.ok, true);
501
+ ok('digest lists the results', r.text.startsWith('Here is what I found:') && r.text.includes('*Coffee prices hit record & keep climbing*') && r.text.includes('*Coffee | Origin, Types'));
502
+ ok('sources attached', r.sources.length === 6 && r.text.includes('*Sources:*'));
503
+ ok('error carried for logging', typeof r.error === 'string');
504
+ } finally {
505
+ mock.restore();
506
+ }
507
+
508
+ let calls = 0;
509
+ mock = installMockDeepAI({
510
+ groundedReply: () => (++calls === 1 ? 'Coffee is a popular drink [5].' : 'Coffee is having a big year [1].\n\n*Recent News:*\n1. *Prices*: Arabica rose 12% after frost in Brazil [1]. Traders expect more.\n2. *Menu*: Starbucks cut 30% of drinks [2]. Cold brew stays.\n3. *Exports*: Sri Lanka up 18% [4]. Kandy leads.\n\n*Background:*\n1. *Definition*: Brewed from roasted beans [5]. Traded worldwide.\n2. *Trade body*: The NCA leads the US industry [3]. Publishes trends.'),
511
+ });
512
+ try {
513
+ const r = await ai.searchWeb('coffee', { minWords: 40 });
514
+ check('short grounded reply is retried', r.attempts, 2);
515
+ const retry = JSON.parse(mock.calls.filter((c) => c.url.includes('hacking'))[1].fields.chatHistory).pop().content;
516
+ ok('grounded retry prompt forbids URLs', retry.includes('Do not write URLs or a Sources list'));
517
+ ok('longer reply won', r.words > 40);
518
+ check('cited order follows the winning reply', r.sources.slice(0, 2).map((s) => s.cited), [true, true]);
519
+ } finally {
520
+ mock.restore();
521
+ }
522
+ }
523
+
524
+ section('WebSearch — parsers for the free endpoints');
525
+ {
526
+ const { WebSearch } = require('../index');
527
+ const ddg = WebSearch.parseDuckDuckGo(
528
+ '<a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.org%2Fa&amp;rut=1">Title &amp; more</a><a class="result__snippet" href="#">Snip <b>bold</b>&hellip;</a>' +
529
+ '<a rel="nofollow" class="result__a" href="https://duckduckgo.com/y.js?u3=https%3A%2F%2Fwww.bing.com%2Faclick">Ad</a>' +
530
+ '<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.org%2Fa%2F" class=\'result-link\'>dup</a>'
531
+ );
532
+ check('ddg: redirect unwrapped, entities decoded, ads and duplicates dropped', ddg, [
533
+ { title: 'Title & more', url: 'https://example.org/a', description: 'Snip bold…', date: null, provider: 'duckduckgo' },
534
+ ]);
535
+ const rss = WebSearch.parseRss('<rss><item><title><![CDATA[Headline - Pub]]></title><link>https://x.y/z</link><pubDate>Thu, 04 Sep 2026 09:00:00 GMT</pubDate><source url="https://pub">Pub</source></item><item><title>no link</title></item></rss>', 'google-news');
536
+ check('rss: publisher suffix stripped, date normalised, linkless item skipped', rss, [
537
+ { title: 'Headline', url: 'https://x.y/z', description: 'Pub', date: '2026-09-04', provider: 'google-news' },
538
+ ]);
539
+ check('wikipedia: bad json -> []', WebSearch.parseWikipedia('nope'), []);
540
+ check('unwrapRedirect: bing apiclick', WebSearch.unwrapRedirect('http://www.bing.com/news/apiclick.aspx?url=https%3a%2f%2fcnbc.com%2fa&c=1'), 'https://cnbc.com/a');
541
+ check('unwrapRedirect: plain url untouched', WebSearch.unwrapRedirect('https://a.b/c?d=1'), 'https://a.b/c?d=1');
542
+ check('interleave: round robin + de-dup', WebSearch.interleave([[{ url: 'https://a/1', title: 'A1' }, { url: 'https://a/2', title: 'A2' }], [{ url: 'https://A/1/', title: 'dup' }, { url: 'https://b/1', title: 'B1' }]], 3).map((r) => r.title), ['A1', 'B1', 'A2']);
543
+ check('normalise: strings and loose objects', WebSearch.normalise(['https://x.y', { link: 'https://p.q', name: 'P' }, { title: 'no url' }]).map((r) => r.url), ['https://x.y', 'https://p.q']);
544
+
545
+ const custom = new WebSearch({ webSearchProvider: async (q) => [{ title: `Result for ${q}`, url: 'https://custom.example/r' }] });
546
+ const viaCustom = await custom.search('coffee');
547
+ check('custom provider replaces the built-ins', viaCustom.providers, ['custom']);
548
+ check('custom provider results normalised', viaCustom.results[0].title, 'Result for coffee');
549
+ const disabled = new WebSearch({ webSearch: false });
550
+ check('webSearch:false -> no results, no requests', await disabled.search('coffee'), { results: [], providers: [], errors: [] });
551
+ const failing = new WebSearch({ webSearchProvider: async () => { throw new Error('boom'); } });
552
+ check('custom provider failure is reported, not thrown', (await failing.search('x')).errors, [{ provider: 'custom', message: 'boom' }]);
553
+ }
554
+
555
+ section('searchWeb() — gpt-4o-mini answers in one sentence: retried, sources lifted out of prose');
556
+ {
557
+ const ai = new AlexaAI({ key: 'tryit-1-x', postgresUrl: 'postgres://u:p@localhost/db', autoMigrate: false, maxRetries: 0 });
558
+ const mock = installMockDeepAI();
559
+ try {
560
+ const r = await ai.searchWeb('coffee', { search: false });
561
+ check('ok', r.ok, true);
562
+ check('two attempts were made', r.attempts, 2);
563
+ const chatCalls = mock.calls.filter((c) => c.url.includes('hacking'));
564
+ check('exactly two chat requests', chatCalls.length, 2);
565
+ const retry = JSON.parse(chatCalls[1].fields.chatHistory);
566
+ ok('retry keeps the first reply in the transcript', retry.some((m) => m.role === 'assistant' && m.content.startsWith('Coffee is a popular beverage')));
567
+ ok('retry asks for the full layout', /Your reply was only \d+ words/.test(retry[retry.length - 1].content));
568
+ ok('retry still has web access on', chatCalls[1].fields.web_access_enabled === 'true' && chatCalls[1].fields.search === 'search');
569
+
570
+ ok('long-form answer won', r.words > 200, `words=${r.words}`);
571
+ ok('word count excludes markers, markup and urls', r.words < 300, `words=${r.words}`);
572
+ ok('section headings preserved as WhatsApp bold', r.text.includes('*Recent Coffee News:*') && r.text.includes('*Coffee Trends:*') && r.text.includes('*Other Coffee News:*'));
573
+ ok('numbered points preserved', r.text.includes('1. *Arabica futures hit a high*:') && r.text.includes('3. *Coffee desserts*:'));
574
+ ok('no ** markdown', !r.text.includes('**'));
575
+ ok('"large language model" disclaimer removed', !/language model|browse the web/i.test(r.text));
576
+ ok('"based on my training data" note removed', !/training data/i.test(r.text));
577
+ ok('unused template placeholders removed', !r.text.includes('<') && !/Section heading 4/.test(r.text));
578
+ ok('reply starts with the real intro', r.text.startsWith('Here is an overview.\n\nCoffee remains'));
579
+ check(
580
+ 'sources: first attempt + retry, de-duplicated by URL',
581
+ r.sources.map((s) => s.title),
582
+ ['Reuters – Coffee prices', 'National Coffee Association', 'Wikipedia - Coffee', 'Coffee Association']
583
+ );
584
+ check('urls parsed', r.sources[2].url, 'https://en.wikipedia.org/wiki/Coffee');
585
+ check('sources rendered once', (r.text.match(/Sources:/g) || []).length, 1);
586
+ ok('sources block at the end', r.text.endsWith('4. Coffee Association — https://www.coffeeassociation.org/'));
587
+ ok('no "title (url)" list left in the answer', !r.answer.includes('(https://'));
588
+
589
+ const bare = await ai.searchWeb('coffee', { includeSources: false, search: false });
590
+ ok('includeSources:false drops the block from text', !bare.text.includes('Sources') && bare.sources.length === 4);
591
+ const capped = await ai.searchWeb('coffee', { maxSources: 1, search: false });
592
+ ok('maxSources caps the rendered list only', capped.text.includes('1. Reuters') && !capped.text.includes('2. National') && capped.sources.length === 4);
593
+
594
+ const noRetry = await ai.searchWeb('coffee', { minWords: 0, search: false });
595
+ check('minWords: 0 disables the retry', noRetry.attempts, 1);
596
+ ok('…and the short reply is returned as-is, sources still lifted', noRetry.text.startsWith('Coffee is a popular beverage') && noRetry.sources.length === 3);
597
+
598
+ const short = await ai.searchWeb('coffee', { detail: 'short', language: 'Sinhala', instructions: 'focus on Sri Lanka', search: false });
599
+ const prompt = JSON.parse(mock.calls.filter((c) => c.url.includes('hacking')).pop().fields.chatHistory).pop().content;
600
+ ok('short mode asks for 2–4 sentences', /2 to 4 sentences/.test(prompt) && !/3 to 5 sections/.test(prompt));
601
+ ok('language forwarded', prompt.includes('Write the entire answer in Sinhala.'));
602
+ ok('instructions forwarded', prompt.includes('focus on Sri Lanka'));
603
+ check('short mode never retries', short.attempts, 1);
604
+ check('short mode still ok', short.ok, true);
605
+ } finally {
606
+ mock.restore();
607
+ }
608
+ }
609
+
610
+ section('searchWeb() — edge replies');
611
+ {
612
+ const ai = new AlexaAI({ key: 'tryit-1-x', postgresUrl: 'postgres://u:p@localhost/db', autoMigrate: false, maxRetries: 0 });
613
+ const FS = '\u001C';
614
+
615
+ let mock = installMockDeepAI({ webReply: `Nothing to say.${FS}[{"title":"Only","url":"https://only.example"}]` });
616
+ try {
617
+ const r = await ai.searchWeb('anything', { search: false });
618
+ check('packet-only sources still populate', r.sources.map((s) => s.url), ['https://only.example']);
619
+ ok('text carries answer + block', r.text.startsWith('Nothing to say.') && r.text.includes('*Sources:*'));
620
+ } finally {
621
+ mock.restore();
622
+ }
623
+
624
+ mock = installMockDeepAI({ webReply: `Sources:\nA (https://a.example)\nB (https://b.example)` });
625
+ try {
626
+ const r = await ai.searchWeb('anything', { search: false });
627
+ check('sources-only reply is still ok', r.ok, true);
628
+ check('answer empty, sources kept', r.answer, '');
629
+ ok('text is the sources block', r.text.startsWith('*Sources:*') && r.text.includes('A — https://a.example'));
630
+ } finally {
631
+ mock.restore();
632
+ }
633
+
634
+ mock = installMockDeepAI({ webReply: `Sri Lanka's coffee exports grew 18% (Source: https://www.edb.gov.lk/coffee). Bloomberg is a trusted source for ChatGPT and Gemini market news.` });
635
+ try {
636
+ const r = await ai.searchWeb('sri lanka coffee', { minWords: 0, search: false });
637
+ check('inline citation lifted', r.sources.map((s) => s.url), ['https://www.edb.gov.lk/coffee']);
638
+ ok('citation removed from the sentence', r.answer.startsWith("Sri Lanka's coffee exports grew 18%."));
639
+ ok('third-party vendor names survive in research output', /for ChatGPT and Gemini market news/.test(r.answer));
640
+ } finally {
641
+ mock.restore();
642
+ }
643
+
644
+ mock = installMockDeepAI({ webReply: `OpenAI released a new model today and Google answered with Gemini. I am Alexa Mini, not Alexa.` });
645
+ try {
646
+ const r = await ai.searchWeb('ai news', { minWords: 0, search: false });
647
+ ok('news about AI vendors is not rewritten', r.answer.startsWith('OpenAI released a new model today and Google answered with Gemini.'));
648
+ ok('but the assistant renaming itself is still repaired', r.answer.endsWith('I am Alexa.'));
649
+ } finally {
650
+ mock.restore();
651
+ }
652
+
653
+ let calls = 0;
654
+ mock = installMockDeepAI({
655
+ webReply: () => {
656
+ calls++;
657
+ if (calls === 1) return 'Coffee is a drink.';
658
+ return `${FS}{"status":"Out of API credits"}${FS}`;
659
+ },
660
+ });
661
+ try {
662
+ const r = await ai.searchWeb('coffee prices', { search: false });
663
+ check('retry failure falls back to the first reply', r.ok, true);
664
+ check('two attempts recorded', r.attempts, 2);
665
+ check('first reply kept', r.answer, 'Coffee is a drink.');
666
+ } finally {
667
+ mock.restore();
668
+ }
669
+
670
+ calls = 0;
671
+ mock = installMockDeepAI({ webReply: () => (++calls === 1 ? 'Coffee is a drink.' : 'Coffee.') });
672
+ try {
673
+ const r = await ai.searchWeb('coffee prices', { search: false });
674
+ check('shorter retry never replaces the first reply', r.answer, 'Coffee is a drink.');
675
+ } finally {
676
+ mock.restore();
677
+ }
678
+
679
+ mock = installMockDeepAI({ webReply: `${FS}{"status":"Out of API credits"}${FS}` });
680
+ try {
681
+ const r = await ai.searchWeb('anything', { search: false });
682
+ check('empty reply -> ok:false with error', r.ok, false);
683
+ ok('error code set', typeof r.error === 'string' && r.error.length > 0);
684
+ } finally {
685
+ mock.restore();
686
+ }
687
+ }
688
+
689
+ section('WebAnswer — parsing the source formats models actually write');
690
+ {
691
+ const { WebAnswer, ResponseFormatter } = require('../index');
692
+ const parse = (s) => WebAnswer.parse(ResponseFormatter.format(s));
693
+
694
+ let r = parse('Answer.\n\n*Sources*\n1. Reuters — https://www.reuters.com/a\n2. BBC News: https://www.bbc.com/b\n3. The Guardian\nhttps://www.theguardian.com/c');
695
+ check('dash / colon / two-line entries', r.sources.map((s) => [s.title, s.url]), [
696
+ ['Reuters', 'https://www.reuters.com/a'],
697
+ ['BBC News', 'https://www.bbc.com/b'],
698
+ ['The Guardian', 'https://www.theguardian.com/c'],
699
+ ]);
700
+ check('answer trimmed', r.text, 'Answer.');
701
+
702
+ r = parse('Answer.\n\nSources:\n- [Reuters](https://www.reuters.com/a)\n- [NCA](https://www.ncausa.org/trends)');
703
+ check('markdown links under a heading', r.sources.map((s) => s.title), ['Reuters', 'NCA']);
704
+
705
+ r = parse('Answer.\n\nSources: https://a.example/x, https://b.example/y');
706
+ check('inline heading with urls', r.sources.map((s) => s.url), ['https://a.example/x', 'https://b.example/y']);
707
+
708
+ r = parse('Answer.\n\nSources:\n- Coffee (https://en.wikipedia.org/wiki/Coffee_(beverage))');
709
+ check('url with balanced parentheses', r.sources[0].url, 'https://en.wikipedia.org/wiki/Coffee_(beverage)');
710
+
711
+ r = parse('Answer.\n\nhttps://www.ico.org/\nhttps://www.ncausa.org/');
712
+ check('bare trailing urls without a heading', r.sources.map((s) => s.url), ['https://www.ico.org/', 'https://www.ncausa.org/']);
713
+
714
+ r = parse('Coffee prices are rising. Read the report at https://www.ico.org/report which explains why.');
715
+ check('a sentence containing a url is not a source list', r.sources, []);
716
+ ok('sentence untouched', r.text.endsWith('which explains why.'));
717
+
718
+ r = parse('Answer.\n\nReferences:\nI used general knowledge for this answer, no specific pages.');
719
+ check('prose under a heading is not a list', r.sources, []);
720
+ ok('prose kept', r.text.includes('general knowledge'));
721
+
722
+ r = parse('Answer.\n\nSources: General knowledge, no specific pages.');
723
+ check('placeholder sources line dropped', r.text, 'Answer.');
724
+
725
+ r = parse('Answer.\n\nSources:\n1. National Coffee Association\n2. Reuters');
726
+ check('named but unlinked sources', r.sources.map((s) => [s.title, s.url]), [['National Coffee Association', null], ['Reuters', null]]);
727
+
728
+ check(
729
+ 'stripDisclaimers: first-person only',
730
+ WebAnswer.stripDisclaimers("I'm a large language model, I don't have the ability to browse the web. However, coffee is popular. Google says its AI can browse the web."),
731
+ 'Coffee is popular. Google says its AI can browse the web.'
732
+ );
733
+ check(
734
+ 'stripDisclaimers: knowledge cut-off sentence',
735
+ WebAnswer.stripDisclaimers('As of my last update in 2023, prices were high. Prices rose 10% in August.'),
736
+ 'Prices rose 10% in August.'
737
+ );
738
+ check('stripDisclaimers: clean text untouched', WebAnswer.stripDisclaimers('Retailers say they cannot access real-time data.'), 'Retailers say they cannot access real-time data.');
739
+
740
+ const merged = WebAnswer.mergeSources(
741
+ [{ title: 'XE', url: 'https://www.xe.com/currencyconverter/', description: 'rates' }],
742
+ [{ title: null, url: 'http://xe.com/currencyconverter' }, { title: 'Reuters', url: null }, { title: 'Reuters', url: 'https://reuters.com/x' }]
743
+ );
744
+ check('mergeSources de-duplicates by normalised url and title', merged.map((s) => [s.title, s.url]), [
745
+ ['XE', 'https://www.xe.com/currencyconverter/'],
746
+ ['Reuters', 'https://reuters.com/x'],
747
+ ]);
748
+ check('mergeSources keeps the description', merged[0].description, 'rates');
749
+
750
+ check('render: block appended once', WebAnswer.render('Body', [{ title: 'A', url: 'https://a' }, { title: null, url: 'https://b' }]), 'Body\n\n*Sources:*\n1. A — https://a\n2. https://b');
751
+ check('render: no sources -> body only', WebAnswer.render('Body', []), 'Body');
752
+ check('render: includeSources false', WebAnswer.render('Body', [{ url: 'https://a' }], { includeSources: false }), 'Body');
753
+ check('render: maxSources', WebAnswer.render('Body', [{ url: 'https://a' }, { url: 'https://b' }], { maxSources: 1 }), 'Body\n\n*Sources:*\n1. https://a');
754
+
755
+ check(
756
+ 'dropPlaceholders removes template tokens and empty scaffolding',
757
+ WebAnswer.dropPlaceholders('*<Section heading 1>:*\n1. *<Headline>*: <…>\n2. *Real*: real text <…>\n\nSources:\n<Page title> (<url>)\nReal (https://r.example)'),
758
+ '2. *Real*: real text\n\nSources:\nReal (https://r.example)'
759
+ );
760
+ check('dropPlaceholders leaves html-free text alone', WebAnswer.dropPlaceholders('a < b and b > c'), 'a < b and b > c');
761
+ check('wordCount ignores markup, markers and urls', WebAnswer.wordCount("*Recent Coffee News:*\n1. *Starbucks' New Menu*: new drinks. https://x.y/z"), 8);
762
+ check('wordCount of empty', WebAnswer.wordCount(''), 0);
763
+ check(
764
+ 'stripDisclaimers: "based on my training data" note',
765
+ WebAnswer.stripDisclaimers('3. *Subscriptions*: growing.\n\nPlease note that the information I provided is based on my training data and may not reflect the most up-to-date news.'),
766
+ '3. *Subscriptions*: growing.'
767
+ );
768
+ check('stripDisclaimers: "generated by ChatGPT"', WebAnswer.stripDisclaimers('This response was generated by ChatGPT. Coffee is nice.'), 'Coffee is nice.');
769
+ ok('expandPrompt mentions the topic and the word count', /"coffee"/.test(WebAnswer.expandPrompt('coffee', 23)) && /only 23 words/.test(WebAnswer.expandPrompt('coffee', 23)));
770
+ const grounded = WebAnswer.prompt('coffee', { results: [{ title: 'T1', url: 'https://a.example/x', description: 'D1', date: '2026-09-01' }, { url: 'https://b.example/y' }] });
771
+ ok('prompt(grounded): numbered material', grounded.includes('[1] T1 (2026-09-01) — a.example\n D1') && grounded.includes('[2] b.example'));
772
+ ok('prompt(grounded): no Sources list requested', grounded.includes('Do NOT write a "Sources" list') && !grounded.includes('<Page title> (<url>)'));
773
+ ok('prompt(grounded): asks for citations', grounded.includes('[<result number>]'));
774
+ ok('prompt(ungrounded): asks for the Sources list', WebAnswer.prompt('x').includes('<Page title> (<url>)'));
775
+ check('extractCitations', WebAnswer.extractCitations('A [1]. B [2][1]. C [1, 3]. D [2-3]. Year [2026]. Out [9].', 3), { text: 'A. B. C. D. Year [2026]. Out.', cited: [1, 2, 3] });
776
+ check('extractCitations: worded markers, prose numbers kept', WebAnswer.extractCitations('Up 12% (2025) [Source 2]. Then (source: 1) and (result 3), but (4) stays.', 3), { text: 'Up 12% (2025). Then and, but (4) stays.', cited: [2, 1, 3] });
777
+ check(
778
+ 'stripUrls: matching URL -> citation, unknown URL removed',
779
+ WebAnswer.stripUrls('Prices rose (see https://a.example/x). More at https://fake.example/b. Also (Source: https://fake.example/c, https://www.a.example/x/) here.', [{ url: 'https://a.example/x' }]),
780
+ 'Prices rose [1]. More. Also [1] here.'
781
+ );
782
+ ok('digest lists results', WebAnswer.digest([{ title: 'T', url: 'https://a/b', description: 'D', date: '2026-01-01' }]).includes('1. *T* _(2026-01-01)_: D'));
783
+ ok('prompt: long by default', /3 to 5 sections/.test(WebAnswer.prompt('x')));
784
+ ok('prompt: short', /2 to 4 sentences/.test(WebAnswer.prompt('x', { detail: 'brief' })));
785
+ ok("prompt: today's date included", WebAnswer.prompt('x', { now: new Date('2026-09-06T00:00:00Z') }).includes('2026-09-06'));
786
+ check('detailOf', ['short', 'brief', 'long', undefined, 'anything'].map(WebAnswer.detailOf), ['short', 'short', 'long', 'long', 'long']);
787
+ }
788
+
789
+ section('summarizeText() — /api refused on free key, chat fallback answers');
790
+ {
791
+ const ai = new AlexaAI({ key: 'tryit-1-x', postgresUrl: 'postgres://u:p@localhost/db', autoMigrate: false, maxRetries: 0 });
792
+ const mock = installMockDeepAI();
793
+ try {
794
+ const r = await ai.summarizeText('Alexa is a WhatsApp assistant created by Hansaka. She remembers people across chats.');
795
+ check('ok', r.ok, true);
796
+ check('route', r.via, 'chat');
797
+ ok('summary text', r.text.includes('WhatsApp assistant'));
798
+ const empty = await ai.summarizeText('');
799
+ check('empty text rejected', empty.error, 'VALIDATION_ERROR');
800
+ } finally {
801
+ mock.restore();
802
+ }
803
+ }
804
+
805
+ section('upscaleImage / editImage / colorizeImage / detectNsfw — media shapes');
806
+ {
807
+ const ai = new AlexaAI({ key: 'tryit-1-x', postgresUrl: 'postgres://u:p@localhost/db', autoMigrate: false, maxRetries: 0 });
808
+ const mock = installMockDeepAI();
809
+ try {
810
+ const up = await ai.upscaleImage(PNG);
811
+ check('upscale(Buffer) ok', up.ok, true);
812
+ ok('upscale url', up.url.includes('job-view-file'));
813
+ const upCall = mock.calls.find((c) => c.url.includes('torch-srgan'));
814
+ check('upload carries the sniffed content type', upCall.files.image.type, 'image/png');
815
+ check('upload carries a filename', upCall.files.image.name, 'image.png');
816
+
817
+ const upB64 = await ai.upscaleImage(PNG.toString('base64'));
818
+ check('upscale(raw base64) ok', upB64.ok, true);
819
+ const upObj = await ai.upscaleImage({ base64: PNG.toString('base64'), mimetype: 'image/png' });
820
+ check('upscale({ base64 }) ok', upObj.ok, true);
821
+ const upUrl = await ai.upscaleImage('https://example.com/a.jpg');
822
+ check('upscale(url) ok', upUrl.ok, true);
823
+ const urlCall = mock.calls.filter((c) => c.url.includes('torch-srgan')).pop();
824
+ check('url is sent as a plain field', urlCall.fields.image, 'https://example.com/a.jpg');
825
+
826
+ const bad = await ai.upscaleImage('not an image at all');
827
+ check('unusable input returns an error, not a throw', bad.ok, false);
828
+ ok('…with a helpful message', /Buffer, base64/.test(bad.message));
829
+
830
+ const edit = await ai.editImage({ buffer: PNG }, 'make the sky purple');
831
+ check('editImage ok', edit.ok, true);
832
+ const editCall = mock.calls.find((c) => c.url.includes('image-editor'));
833
+ check('editImage sends the prompt', editCall.fields.text, 'make the sky purple');
834
+
835
+ const col = await ai.colorizeImage(PNG);
836
+ check('colorizeImage ok', col.ok, true);
837
+
838
+ const nsfw = await ai.detectNsfw(PNG);
839
+ check('detectNsfw score', nsfw.score, 0.03);
840
+ check('detectNsfw verdict', nsfw.nsfw, false);
841
+ } finally {
842
+ mock.restore();
843
+ }
844
+ }
845
+
846
+ section('describeImage() — bare Buffer, OCR fallback, WhatsApp-ready text');
847
+ {
848
+ const ai = new AlexaAI({ key: 'tryit-1-x', postgresUrl: 'postgres://u:p@localhost/db', autoMigrate: false, maxRetries: 0 });
849
+ const mock = installMockDeepAI();
850
+ try {
851
+ const r = await ai.describeImage(PNG, 'what is the total?');
852
+ check('bare Buffer is readable now', r.reason, null);
853
+ check('ok', r.ok, true);
854
+ check('came from OCR (free key has no vision)', r.source, 'ocr');
855
+ ok('description carries the OCR text', r.description.includes('Rs 4,500'));
856
+ ok('text field is ready to send', r.text.includes('Rs 4,500'));
857
+ ok('attachment uuid returned', r.attachmentUuids.includes('att-1'));
858
+
859
+ const none = await ai.describeImage(null);
860
+ check('no image -> no_image', none.reason, 'no_image');
861
+ check('no image -> empty text', none.text, '');
862
+ } finally {
863
+ mock.restore();
864
+ }
865
+ }
866
+
867
+ section('chat() — image param accepts every shape');
868
+ {
869
+ const ai = new AlexaAI({ key: 'tryit-1-x', postgresUrl: 'postgres://u:p@localhost/db', autoMigrate: false, maxRetries: 0 });
870
+ const db = createFakeDb();
871
+ ai.db = db; ai.users.db = db; ai.memories.db = db; ai.conversations.db = db; ai.identities.db = db;
872
+ const mock = installMockDeepAI();
873
+ try {
874
+ const r = await ai.chat({ message: 'what is this?', userId: '94771234567@s.whatsapp.net', image: PNG });
875
+ ok('bare Buffer image reaches the vision chain', mock.calls.some((c) => c.url.includes('chat_attachments/upload')));
876
+ ok('reply produced', r.text.length > 0);
877
+ const r2 = await ai.chat({ message: 'and this?', userId: '94771234567@s.whatsapp.net', image: `data:image/png;base64,${PNG.toString('base64')}` });
878
+ ok('data URI image accepted', r2.text.length > 0 && r2.error !== 'vision_unavailable');
879
+ const r3 = await ai.chat({ message: 'file alias', userId: '94771234567@s.whatsapp.net', file: { buffer: PNG } });
880
+ ok('`file` alias accepted', r3.text.length > 0);
881
+
882
+ const viaAsk = await ai.ask({ text: 'ask shape', files: [PNG.toString('base64')] }, '94771234567@s.whatsapp.net');
883
+ ok('ask() accepts a raw base64 file', typeof viaAsk === 'string' && viaAsk.length > 0);
884
+ } finally {
885
+ mock.restore();
886
+ }
887
+ }
888
+
889
+ section('deepaiHealth()');
890
+ {
891
+ const ai = new AlexaAI({ key: 'tryit-1-x', postgresUrl: 'postgres://u:p@localhost/db', autoMigrate: false, maxRetries: 0 });
892
+ const mock = installMockDeepAI();
893
+ try {
894
+ const h = await ai.deepaiHealth();
895
+ check('ok', h.ok, true);
896
+ check('reply', h.reply, 'ok');
897
+ check('model reported', h.model, 'standard');
898
+ } finally {
899
+ mock.restore();
900
+ }
901
+ }
902
+ }
903
+
904
+ async function databaseTests() {
905
+ const pgUrl = process.env.POSTGRES_URL;
906
+ if (!pgUrl) {
907
+ console.log('\n\x1b[33m⏭ Skipping PostgreSQL wrapper tests (set POSTGRES_URL)\x1b[0m');
908
+ return;
909
+ }
910
+ section('Admin methods on a real PostgreSQL — aliases and unseen rows');
911
+ const ai = new AlexaAI({ key: 'tryit-1-x', postgresUrl: pgUrl, autoMigrate: true });
912
+ const LID = '78151912841263@lid';
913
+ const PHONE = '94771234567@s.whatsapp.net';
914
+ const GROUP = '120363413125431525@g.us';
915
+ const NEW_GROUP = '120363000000000001@g.us';
916
+ try {
917
+ await ai.init();
918
+ await ai.db.query('TRUNCATE wa_users, wa_groups RESTART IDENTITY CASCADE');
919
+
920
+ // Person first seen via DM (phone), later linked to their group @lid.
921
+ await ai.users.upsertUser(PHONE, { pushName: 'Nimal' });
922
+ await ai.linkIdentity(PHONE, LID);
923
+
924
+ const blocked = await ai.blockUser(LID);
925
+ ok('blockUser() through an ALIAS returns the row', Boolean(blocked && blocked.is_blocked));
926
+ check('…and the person really is blocked', await ai.isBlocked(PHONE), true);
927
+ const silent = await ai.chat({ message: 'hello?', userId: LID, groupId: GROUP });
928
+ check('blocked person gets an empty reply', [silent.text, silent.error], ['', 'user_blocked']);
929
+
930
+ const unblocked = await ai.unblockUser(PHONE);
931
+ check('unblockUser() through the other alias', unblocked.is_blocked, false);
932
+ check('isBlocked() false again', await ai.isBlocked(LID), false);
933
+
934
+ const pre = await ai.blockUser('94770000009@s.whatsapp.net');
935
+ ok('blocking an unseen user creates the row', Boolean(pre && pre.is_blocked));
936
+ check('unblocking an unseen user is a no-op (null)', await ai.unblockUser('94770000010@s.whatsapp.net'), null);
937
+
938
+ const off = await ai.setGroupEnabled(NEW_GROUP, false);
939
+ ok('setGroupEnabled() on an UNSEEN group creates it', Boolean(off) && off.is_enabled === false);
940
+ check('isGroupEnabled() false', await ai.isGroupEnabled(NEW_GROUP), false);
941
+ check('unknown groups default to enabled', await ai.isGroupEnabled('120363999999999999@g.us'), true);
942
+ const muted = await ai.chat({ message: 'hi', userId: PHONE, groupId: NEW_GROUP });
943
+ check('disabled group gets an empty reply', [muted.text, muted.error], ['', 'group_disabled']);
944
+ const on = await ai.setGroupEnabled(NEW_GROUP, true);
945
+ check('re-enabled', on.is_enabled, true);
946
+
947
+ let threw = null;
948
+ try { await ai.blockUser(GROUP); } catch (e) { threw = e.code; }
949
+ check('blockUser(groupJid) is a validation error', threw, 'VALIDATION_ERROR');
950
+ threw = null;
951
+ try { await ai.setGroupEnabled(PHONE, false); } catch (e) { threw = e.code; }
952
+ check('setGroupEnabled(userJid) is a validation error', threw, 'VALIDATION_ERROR');
953
+
954
+ // Memory helpers through either alias.
955
+ await ai.remember(LID, 'city', 'Galle');
956
+ check('remember() via @lid is visible via phone', (await ai.getMemories(PHONE)).city, 'Galle');
957
+ check('forget() via phone', await ai.forget(PHONE, 'city'), true);
958
+ check('forgetAll() count', await ai.forgetAll(LID), 0);
959
+ const profile = await ai.getProfile(LID);
960
+ ok('getProfile() via alias', profile && profile.user.jid === PHONE);
961
+ const who = await ai.whoIs(PHONE);
962
+ check('whoIs() lists both addresses', who.aliases.sort(), [LID, PHONE].sort());
963
+ ok('health()', (await ai.health()).ok);
964
+ ok('stats()', Number((await ai.stats()).users) >= 1);
965
+ } finally {
966
+ await ai.close();
967
+ }
968
+ }
969
+
970
+ mockedTests()
971
+ .then(databaseTests)
972
+ .catch((err) => {
973
+ failed++;
974
+ failures.push(`Crashed: ${err.stack || err.message}`);
975
+ console.log(`\n\x1b[31m❌ Crashed: ${err.message}\x1b[0m`);
976
+ })
977
+ .finally(() => {
978
+ console.log(`\n${'═'.repeat(62)}`);
979
+ console.log(`\x1b[1mRESULT\x1b[0m ✅ ${passed} passed ❌ ${failed} failed`);
980
+ console.log('═'.repeat(62));
981
+ if (failures.length) {
982
+ console.log('\nFailures:');
983
+ failures.forEach((f, i) => console.log(` ${i + 1}. ${f}`));
984
+ }
985
+ process.exit(failed ? 1 : 0);
986
+ });