beast-agent 0.27.0 → 0.28.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.
@@ -383,6 +383,17 @@ function addErrorBubble(text) {
383
383
  scrollDown(true);
384
384
  }
385
385
 
386
+ /* ajan durduruldu/iptal edildi — SEBEP zorunlu, sohbete sönük not olarak düşer */
387
+ function addStopNote(reason) {
388
+ streamEl = null;
389
+ showEmpty(false);
390
+ const div = document.createElement('div');
391
+ div.className = 'msg msg-sys';
392
+ div.textContent = '\u23F9 Durduruldu — sebep: ' + String(reason || 'sebep belirtilmedi');
393
+ els.msgs.appendChild(div);
394
+ scrollDown(true);
395
+ }
396
+
386
397
  /* ---------------- tool cards ---------------- */
387
398
  /* Ard arda gelen araç kartları TEK kutuda toplanır (.tool-box): çalışırken
388
399
  lacivert outline döner, gövde max 420px scroll'lu. Grup; tool_calls İÇEREN
@@ -738,12 +749,30 @@ async function refreshFalloutPane() {
738
749
  renderFalloutPane();
739
750
  }
740
751
 
741
- /* Web Arama sekmesi: TinyFish (zincirin başı) + Exa API anahtarı (maskeli) */
752
+ /* Web Arama sekmesi: TinyFish anahtarı + Obscura (kurulum/aktiflik) + arama sırası */
742
753
  async function renderWebSearchPane() {
743
754
  const pane = $('#tab-websearch');
744
755
  if (!pane) return;
745
756
  const tf = await beast.tinyfishGet().catch(() => ({ set: false, masked: '' }));
746
757
  pane.innerHTML =
758
+ '<h2>' + _t('ws_h2') + '</h2>' +
759
+ '<div class="sub">' + _t('ws_sub') + '</div>' +
760
+ /* --- Obscura --- */
761
+ '<div class="divider"></div>' +
762
+ '<h2>' + _t('oc_h2') + '</h2>' +
763
+ '<div class="sub">' + _t('oc_sub') + '</div>' +
764
+ '<div id="ocStatus" class="sub" style="text-align:left;margin-top:8px"></div>' +
765
+ '<label class="mem-label" style="display:flex;align-items:center;gap:8px;cursor:pointer">' +
766
+ '<input id="ocEnabled" type="checkbox" style="width:auto" /> <span>' + _t('oc_enabled') + '</span></label>' +
767
+ '<div class="form-grid" style="grid-template-columns:auto auto;gap:8px;margin-top:10px">' +
768
+ '<button id="ocInstall" class="btn">' + _t('oc_install') + '</button></div>' +
769
+ /* --- Arama sırası --- */
770
+ '<div class="divider"></div>' +
771
+ '<h2>' + _t('so_h2') + '</h2>' +
772
+ '<div class="sub">' + _t('so_sub') + '</div>' +
773
+ '<div id="soList" style="margin-top:10px"></div>' +
774
+ /* --- TinyFish --- */
775
+ '<div class="divider"></div>' +
747
776
  '<h2>' + _t('tf_h2') + '</h2>' +
748
777
  '<div class="sub">' + _t('tf_sub') + '</div>' +
749
778
  '<div id="tfStatus" class="sub" style="text-align:left;margin-top:8px"></div>' +
@@ -751,26 +780,80 @@ async function renderWebSearchPane() {
751
780
  '<input id="tfKeyInp" class="inp" type="password" placeholder="tf_..." autocomplete="new-password" spellcheck="false" />' +
752
781
  '<div class="form-grid" style="grid-template-columns:auto auto;gap:8px;margin-top:8px">' +
753
782
  '<button id="tfSave" class="btn">' + _t('ws_save') + '</button>' +
754
- '<button id="tfClear" class="btn ghost">' + _t('ws_clear') + '</button></div>' +
755
- '<div class="divider"></div>' +
756
- '<h2>' + _t('ws_h2') + '</h2>' +
757
- '<div class="sub">' + _t('ws_sub') + '</div>' +
758
- '<div id="exaStatus" class="sub" style="text-align:left;margin-top:10px"></div>' +
759
- '<label class="mem-label">' + _t('ws_exa_label') + '</label>' +
760
- '<input id="exaKeyInp" class="inp" type="password" placeholder="' + _t('ws_exa_ph') + '" autocomplete="new-password" spellcheck="false" />' +
761
- '<div class="form-grid" style="grid-template-columns:auto auto;gap:8px;margin-top:8px">' +
762
- '<button id="exaSave" class="btn">' + _t('ws_save') + '</button>' +
763
- '<button id="exaClear" class="btn ghost">' + _t('ws_clear') + '</button></div>' +
764
- '<div class="sub" style="margin-top:8px">' + _t('ws_note') + '</div>';
765
- const st = $('#exaStatus');
766
- const setExaSt = (r) => {
767
- st.textContent = r.set ? _t('ws_status_set') + r.masked : _t('ws_status_unset');
768
- };
783
+ '<button id="tfClear" class="btn ghost">' + _t('ws_clear') + '</button></div>';
784
+
785
+ /* --- Obscura durumu --- */
786
+ const ocSt = $('#ocStatus');
787
+ const ocChk = $('#ocEnabled');
788
+ const ocBtn = $('#ocInstall');
789
+ const oc = await beast.obscuraGet().catch(() => ({ installed: false, enabled: true, dir: '' }));
790
+ ocChk.checked = oc.enabled !== false;
791
+ ocSt.textContent = oc.installed ? _t('oc_status_ok') : _t('oc_status_missing');
792
+ ocChk.addEventListener('change', async () => {
793
+ await beast.obscuraSetEnabled(ocChk.checked).catch(() => {});
794
+ toast(ocChk.checked ? _t('oc_on_toast') : _t('oc_off_toast'));
795
+ });
796
+ ocBtn.addEventListener('click', async () => {
797
+ ocBtn.disabled = true;
798
+ ocBtn.textContent = _t('oc_installing');
799
+ ocSt.textContent = _t('oc_installing');
800
+ const r = await beast.obscuraInstall().catch(() => ({ ok: false, error: 'hata' }));
801
+ ocBtn.disabled = false;
802
+ ocBtn.textContent = _t('oc_install');
803
+ ocSt.textContent = r && r.ok ? _t('oc_status_ok') : _t('oc_install_fail') + (r && r.error ? r.error : '?');
804
+ toast(r && r.ok ? _t('oc_installed_toast') : _t('oc_install_fail') + (r && r.error ? r.error : '?'));
805
+ });
806
+
807
+ /* --- Arama sırası --- */
808
+ const soBox = $('#soList');
809
+ let rows = [];
810
+ const soRes = await beast.searchOrderGet().catch(() => ({ chain: [] }));
811
+ rows = (soRes && Array.isArray(soRes.chain) && soRes.chain.length) ? soRes.chain.map((x) => ({ id: x.id, on: x.on !== false })) : [];
812
+ const engName = (id) => _t('so_engine_' + id) || id;
813
+ function renderRows() {
814
+ soBox.innerHTML = '';
815
+ rows.forEach((row, i) => {
816
+ const el = document.createElement('div');
817
+ el.className = 'so-row' + (row.on ? '' : ' off');
818
+ el.style.cssText = 'display:flex;align-items:center;gap:8px;padding:6px 8px;border:1px solid var(--border);border-radius:8px;margin-bottom:6px';
819
+ el.innerHTML =
820
+ '<span class="so-idx" style="min-width:18px;color:var(--muted);font-size:12px">' + (i + 1) + '.</span>' +
821
+ '<input type="checkbox" class="so-chk" style="width:auto" ' + (row.on ? 'checked' : '') + ' />' +
822
+ '<span style="flex:1">' + escapeHtml(engName(row.id)) + '</span>' +
823
+ '<button class="so-up" title="Yukarı" style="width:auto;padding:2px 8px">↑</button>' +
824
+ '<button class="so-down" title="Aşağı" style="width:auto;padding:2px 8px">↓</button>';
825
+ el.querySelector('.so-chk').addEventListener('change', async (e) => {
826
+ row.on = e.target.checked;
827
+ el.classList.toggle('off', !row.on);
828
+ await saveOrder();
829
+ });
830
+ el.querySelector('.so-up').addEventListener('click', async () => {
831
+ if (i === 0) return;
832
+ [rows[i - 1], rows[i]] = [rows[i], rows[i - 1]];
833
+ renderRows();
834
+ await saveOrder();
835
+ });
836
+ el.querySelector('.so-down').addEventListener('click', async () => {
837
+ if (i >= rows.length - 1) return;
838
+ [rows[i + 1], rows[i]] = [rows[i], rows[i + 1]];
839
+ renderRows();
840
+ await saveOrder();
841
+ });
842
+ soBox.appendChild(el);
843
+ });
844
+ }
845
+ async function saveOrder() {
846
+ const r = await beast.searchOrderSet(rows).catch(() => null);
847
+ if (r && r.ok) toast(_t('so_saved_toast'));
848
+ else toast(_t('ws_fail_toast'));
849
+ }
850
+ if (rows.length) renderRows();
851
+
852
+ /* --- TinyFish --- */
769
853
  const tfSt = $('#tfStatus');
770
854
  const setTfSt = (r) => {
771
855
  tfSt.textContent = r.set ? _t('tf_status_set') + r.masked : _t('tf_status_unset');
772
856
  };
773
- setExaSt(await beast.exaGet().catch(() => ({ set: false, masked: '' })));
774
857
  setTfSt(tf);
775
858
  $('#tfSave').addEventListener('click', async () => {
776
859
  const v = $('#tfKeyInp').value.trim();
@@ -790,24 +873,6 @@ async function renderWebSearchPane() {
790
873
  setTfSt({ set: false, masked: '' });
791
874
  toast(_t('tf_cleared_toast'));
792
875
  });
793
- $('#exaSave').addEventListener('click', async () => {
794
- const v = $('#exaKeyInp').value.trim();
795
- if (!v) { toast(_t('ws_empty_toast')); return; }
796
- const rr = await beast.exaSet(v).catch(() => null);
797
- $('#exaKeyInp').value = '';
798
- if (rr && rr.set) {
799
- setExaSt(rr);
800
- toast(_t('ws_saved_toast'));
801
- } else {
802
- toast(_t('ws_fail_toast'));
803
- }
804
- });
805
- $('#exaClear').addEventListener('click', async () => {
806
- await beast.exaClear().catch(() => {});
807
- $('#exaKeyInp').value = '';
808
- setExaSt({ set: false, masked: '' });
809
- toast(_t('ws_cleared_toast'));
810
- });
811
876
  }
812
877
 
813
878
  async function renderProviderPane() {
@@ -3447,7 +3512,7 @@ function renderAgentRail() {
3447
3512
  `<span class="ag-dot"></span>` +
3448
3513
  `<span class="sess-title">${escapeHtml(j.title)}</span>` +
3449
3514
  (j.code ? `<span class="sess-code" title="Oturum kodu">${escapeHtml(j.code)}</span>` : ``) +
3450
- `<span class="rj-time">${j.status === 'running' ? when + ' · ' + agentTimerHtml(j.startedAt) + ' · ' + _t('ag_working') : (agStText(j.status) === _t('ag_st_done') ? '\u2713' : (agStText(j.status) || j.status))}</span>` +
3515
+ `<span class="rj-time">${j.status === 'running' ? when + ' · ' + agentTimerHtml(j.startedAt) + ' · ' + _t('ag_working') : (agStText(j.status) === _t('ag_st_done') ? '\u2713' : (agStText(j.status) || j.status) + (j.status === 'aborted' && j.error ? ' — ' + escapeHtml(String(j.error).slice(0, 70)) : ''))}</span>` +
3451
3516
  (j.status === 'running'
3452
3517
  ? `<button class="rj-cancel" title="${_t('ag_cancel')}">×</button>`
3453
3518
  : `<button class="rj-cancel" title="${_t('ag_delete')}"><svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M10 11v6M14 11v6"/></svg></button>`) +
@@ -3577,7 +3642,7 @@ function renderAgentsPane() {
3577
3642
  `<div class="ag-head">` +
3578
3643
  `<span class="ag-dot"></span>` +
3579
3644
  `<span class="ag-title">${escapeHtml(j.title)}</span>` +
3580
- `<span class="ag-st">${agStText(j.status) || j.status}</span>` +
3645
+ `<span class="ag-st" title="${j.error ? escapeHtml(String(j.error)) : ''}">${agStText(j.status) || j.status}</span>` +
3581
3646
  `<span class="ag-time">${when}${j.status === 'running' ? ' · ' + agentTimerHtml(j.startedAt) : j.endedAt ? ` · ${fmtAgo(j.endedAt)}` : ''}</span>` +
3582
3647
  (j.status === 'running'
3583
3648
  ? `<button class="ag-btn cancel" title="${_t('ag_cancel')}">${_t('ag_cancel')}</button>`
@@ -3764,6 +3829,8 @@ function onEvent(ev) {
3764
3829
  break;
3765
3830
  case 'done':
3766
3831
  closeChatToolGroup();
3832
+ /* iptal/durdurma sebebini SOHBETE yaz — sebepsiz durdurma yok */
3833
+ if (ev.aborted) addStopNote(ev.reason);
3767
3834
  setBusy(false);
3768
3835
  setStatus(''); /* son durum balonu ("düşünüyor…" vb.) yapışmasın */
3769
3836
  /* cevabın SONU görünsün: markdown son render sonrası iki kez en alta kilitle */
@@ -6103,7 +6170,8 @@ function bcIngest(ev) {
6103
6170
  bcFlushStream();
6104
6171
  bcCloseToolGroup();
6105
6172
  bcSetBusy(false);
6106
- bcLine('t-dim', '(tamamlandı)');
6173
+ /* iptal/durdurma SEBEBİ panelde de görünür */
6174
+ bcLine(ev.aborted ? 't-err' : 't-dim', ev.aborted ? '[durduruldu — sebep: ' + (ev.reason || 'sebep belirtilmedi') + ']' : '(tamamlandı)');
6107
6175
  bcRefreshPreview();
6108
6176
  if (ideModeOn() && els.bcInput) els.bcInput.focus();
6109
6177
  break;
@@ -759,6 +759,17 @@ body.term-open #settingsOverlay { right: var(--tw, 520px); }
759
759
  padding: 10px 14px;
760
760
  }
761
761
 
762
+ /* durdurma/iptal notu: sebep zorunlu — nötr, sönük ton */
763
+ .msg-sys {
764
+ border: 1px dashed var(--border);
765
+ background: var(--panel);
766
+ color: var(--muted);
767
+ border-radius: 10px;
768
+ padding: 8px 12px;
769
+ font-size: 12.5px;
770
+ word-break: break-word;
771
+ }
772
+
762
773
  /* markdown */
763
774
  .md p { margin: 0 0 8px; }
764
775
  .md p:last-child { margin-bottom: 0; }
@@ -271,18 +271,86 @@ test('#5 eşzamanlılık limiti: fazlası kuyrukta bekler, slot boşalınca FIFO
271
271
  assert.deepEqual(st, ['queued', 'queued', 'running', 'running']);
272
272
  assert.strictEqual(sent.length, 2);
273
273
 
274
- /* slot açılır → sıradaki queued iş running olur ve gönderilir */
274
+ /* slot açılır → sıradaki queued iş running olur ve gönderilir.
275
+ _bgFinish('aborted') artık üst sohbete [ARKA PLAN İPTAL + SEBEP] raporu da düşürür. */
275
276
  eng._bgFinish(ids[0], 'aborted');
276
277
  await sleep(30);
277
278
  const queued = [...eng._bgJobs.values()].filter((j) => j.status === 'queued').length;
278
279
  const running = [...eng._bgJobs.values()].filter((j) => j.status === 'running').length;
279
280
  assert.strictEqual(queued, 1);
280
281
  assert.strictEqual(running, 2);
281
- assert.strictEqual(sent.length, 3);
282
+ /* 3 gönderim = kuyruktan başlayan iş + 1 rapor = üst sohbete düşen iptal sebebi */
283
+ assert.strictEqual(sent.length, 4);
284
+ assert.ok(eng._pendingReports.length === 0, 'iptal raporu üst sohbete düştü');
282
285
  });
283
286
 
284
- /* ---------- /stop anahtarı ---------- */test('stopAll: koşan turları keser, paralel ajanı aborted işaretler', async () => {
287
+ /* ---------- İPTAL SEBEBİ DİSİPLİNİ ---------- */
288
+
289
+ test('iptal SEBEBİ zorunlu: interrupt sebebi iş kaydına ve üst sohbete yazar', async () => {
285
290
  const { eng } = tmpEngine();
291
+ const sent = [];
292
+ eng.send = (sid, payload) => { sent.push({ sid, text: String((payload && payload.text) || '') }); return true; };
293
+ const r = eng.runBackground('pr', 'koşan iş', 'Sebep İş');
294
+ const id = r.backgroundId;
295
+ const c = new AbortController();
296
+ eng.ctrls.set(id, c);
297
+
298
+ eng.interrupt(id, 'kullanıcı panelden iptal etti');
299
+ assert.ok(c.signal.aborted, 'ctrl kesildi');
300
+ assert.strictEqual(eng._abortReasons.get(id), 'kullanıcı panelden iptal etti', 'sebep haritada');
301
+
302
+ /* _run catch'inin yaptığı iş: haritadan sebebi okuyup _bgFinish'e verir */
303
+ const why = eng._abortReasons.get(id);
304
+ eng._abortReasons.delete(id);
305
+ eng._bgFinish(id, 'aborted', why);
306
+
307
+ const j = eng._bgJobs.get(id);
308
+ assert.strictEqual(j.status, 'aborted');
309
+ assert.strictEqual(j.error, 'kullanıcı panelden iptal etti', 'sebep iş kaydına yazıldı');
310
+ const rep = sent.find((x) => x.sid === 'pr' && x.text.includes('[ARKA PLAN İPTAL'));
311
+ assert.ok(rep, 'üst sohbete İPTAL raporu düştü');
312
+ assert.ok(/Sebep: kullanıcı panelden/.test(rep.text), 'rapor SEBEPİ içerir');
313
+ });
314
+
315
+ test('kuyruktaki ajan interrupt ile SEBEPİYLE kesilir', async () => {
316
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'beast-qc-'));
317
+ const eng = new Engine({}, { sessionsDir: dir, emit: () => {} });
318
+ eng._bgLimit = 0; // yapıcı min 1 kelepçeler; sıfırla → her iş queued kalır
319
+ eng.send = () => true;
320
+ const r = eng.runBackground('pq2', 'kuyruk işi', 'Kuyruk İş');
321
+ await sleep(80); // admit → bgLimit 0 → queued
322
+ assert.strictEqual(eng._bgJobs.get(r.backgroundId).status, 'queued');
323
+
324
+ assert.strictEqual(eng.interrupt(r.backgroundId, 'sıra iptal edildi'), true);
325
+ const j = eng._bgJobs.get(r.backgroundId);
326
+ assert.strictEqual(j.status, 'aborted');
327
+ assert.strictEqual(j.error, 'sıra iptal edildi', 'kuyruktaki işin iptal sebebi kayda geçti');
328
+ eng.deleteSession(r.backgroundId);
329
+ });
330
+
331
+ test('task_cancel reason ZORUNLU: sebep iş kaydına ve note mesajına düşer', async () => {
332
+ const { eng } = tmpEngine();
333
+ const r = eng.runBackground('pc', 'iş', 'İptal Edilen');
334
+ const id = r.backgroundId;
335
+ const c = new AbortController();
336
+ eng.ctrls.set(id, c);
337
+
338
+ const out = JSON.parse(await eng._execTool('task_cancel', { id, reason: 'yanlış kaynağa baktı' }, {}));
339
+ assert.ok(out.ok, 'cancel başarılı');
340
+ assert.ok(/yanlış kaynağa baktı/.test(out.note), 'note ceza sebep içerir');
341
+ assert.strictEqual(eng._abortReasons.get(id), 'yanlış kaynağa baktı');
342
+ assert.ok(c.signal.aborted);
343
+
344
+ /* reason verilmezse varsayılan sebep yine kayda düşer */
345
+ const id2 = eng.runBackground('pc', 'iş 2', 'İptal 2').backgroundId;
346
+ eng.ctrls.set(id2, new AbortController());
347
+ const out2 = JSON.parse(await eng._execTool('task_cancel', { id: id2 }, {}));
348
+ assert.ok(out2.ok);
349
+ assert.ok(eng._abortReasons.get(id2).includes('task_cancel'), 'varsayılan sebep CEO kaynağını söyler');
350
+ });
351
+
352
+ /* ---------- /stop anahtarı ---------- */
353
+ test('stopAll: koşan turları keser, paralel ajanı aborted işaretler', async () => { const { eng } = tmpEngine();
286
354
  const r = eng.runBackground('pS', 'koşan iş', 'Koşu');
287
355
  const id = r.backgroundId;
288
356
 
@@ -0,0 +1,124 @@
1
+ 'use strict';
2
+
3
+ /* Obscura entegrasyonu + sıralı web arama zinciri testleri (ağ çağrısı yok) */
4
+
5
+ const test = require('node:test');
6
+ const assert = require('node:assert');
7
+ const tools = require('../src/agent/tools');
8
+ const obscura = require('../src/agent/obscura');
9
+
10
+ test('varsayılan zincir: 4 motor, hepsi açık, obscura AKTİF', () => {
11
+ const c = tools.getSearchChain();
12
+ assert.deepEqual(c.map((x) => x.id), ['browser', 'obscura', 'tinyfish', 'python']);
13
+ for (const r of c) assert.strictEqual(r.on, true, r.id + ' varsayılan açık');
14
+ });
15
+
16
+ test('setSearchChain: sıra yeniden düzenlenir, eksik motorlar sona eklenir (açık)', () => {
17
+ const c = tools.setSearchChain([
18
+ { id: 'obscura', on: true },
19
+ { id: 'python', on: true },
20
+ { id: 'browser', on: false },
21
+ ]);
22
+ assert.deepEqual(c.map((x) => x.id), ['obscura', 'python', 'browser', 'tinyfish']);
23
+ assert.strictEqual(c.find((x) => x.id === 'browser').on, false);
24
+ assert.strictEqual(c.find((x) => x.id === 'tinyfish').on, true, 'listede olmayan motor varsayılan açık');
25
+ tools.setSearchChain(tools.DEFAULT_SEARCH_CHAIN); /* testler arası temizlik */
26
+ });
27
+
28
+ test('setSearchChain: hepsi kapalıysa tarayıcı zorunlu açılır (zincir boş kalmasın)', () => {
29
+ const c = tools.setSearchChain(['obscura', 'tinyfish', 'python', 'browser'].map((id) => ({ id, on: false })));
30
+ assert.strictEqual(c.find((x) => x.id === 'browser').on, true);
31
+ tools.setSearchChain(tools.DEFAULT_SEARCH_CHAIN);
32
+ });
33
+
34
+ test('setSearchChain: bilinmeyen id ve tekrar elenir', () => {
35
+ const c = tools.setSearchChain([{ id: 'exa', on: true }, { id: 'obscura' }, { id: 'obscura', on: false }, 'python']);
36
+ assert.deepEqual(c.map((x) => x.id), ['obscura', 'python', 'browser', 'tinyfish']);
37
+ tools.setSearchChain(tools.DEFAULT_SEARCH_CHAIN);
38
+ });
39
+
40
+ test('searchChainWeb: sırayı uygular, dolu motorda durur, boş tarayıcı banlanır', async () => {
41
+ tools.setSearchChain([
42
+ { id: 'browser', on: true },
43
+ { id: 'obscura', on: false },
44
+ { id: 'tinyfish', on: false },
45
+ { id: 'python', on: false },
46
+ ]);
47
+ const calls = [];
48
+ const r = await tools.searchChainWeb('test sorgu', 5, {
49
+ browser: async () => {
50
+ calls.push('browser');
51
+ return { ok: true, results: [{ title: 'T', url: 'https://x.com', snippet: 's', engine: 'browser' }] };
52
+ },
53
+ });
54
+ assert.ok(r.ok, 'sonuç geldi');
55
+ assert.strictEqual(calls.length, 1, 'zincir ilk dolu motorda durdu');
56
+ assert.ok(!tools.browserBanned(), 'başarılı tarayıcı banlanmaz');
57
+
58
+ /* tarayıcı boş dönerse 10 dk ban + zincir başarısız (diğer motorlar kapalı) */
59
+ const r2 = await tools.searchChainWeb('test 2', 5, {
60
+ browser: async () => {
61
+ calls.push('browser');
62
+ return { ok: true, results: [] };
63
+ },
64
+ });
65
+ assert.strictEqual(r2.ok, false);
66
+ assert.ok(tools.browserBanned(), 'boş sonuç tarayıcıyı banladı');
67
+ tools.banBrowser(0); /* banı temizle */
68
+ const r3 = await tools.searchChainWeb('test 3', 5, {
69
+ browser: async () => {
70
+ calls.push('browser');
71
+ return { ok: true, results: [{ title: 'T3', url: 'https://y.com' }] };
72
+ },
73
+ });
74
+ assert.ok(r3.ok && calls.length === 3, 'ban süresi dolunca tarayıcı geri döner');
75
+ tools.setSearchChain(tools.DEFAULT_SEARCH_CHAIN);
76
+ });
77
+
78
+ test('searchChainWeb: browser hook yoksa obscura/tinyfish/python sırası izlenir', async () => {
79
+ tools.setSearchChain([{ id: 'browser', on: true }, { id: 'obscura', on: false }, { id: 'tinyfish', on: false }, { id: 'python', on: false }]);
80
+ const r = await tools.searchChainWeb('test', 5, {});
81
+ /* tüm motorlar kapalı/atlanmış → normalize browser'ı zorunlu açtı ama hook yok → başarısız */
82
+ assert.strictEqual(r.ok, false);
83
+ tools.setSearchChain(tools.DEFAULT_SEARCH_CHAIN);
84
+ });
85
+
86
+ test('searchChainWeb: boş sorgu hata döner', async () => {
87
+ const r = await tools.searchChainWeb(' ', 5, {});
88
+ assert.strictEqual(r.ok, false);
89
+ assert.ok(/boş sorgu/.test(r.error));
90
+ });
91
+
92
+ test('obscura parseDdgLite: lite tablosundan sonuç çıkarır', () => {
93
+ const html =
94
+ '<table><tr><td><a rel="nofollow" class="result-link" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fornek.com%2Fa&amp;rut=1">Örnek A</a></td></tr>' +
95
+ '<tr><td><a rel="nofollow" class="result-link" href="https://ornek.com/b">Örnek B</a></td></tr></table>';
96
+ const rows = obscura.parseDdgLite(html, 5);
97
+ assert.strictEqual(rows.length, 2);
98
+ assert.strictEqual(rows[0].url, 'https://ornek.com/a');
99
+ assert.strictEqual(rows[0].title, 'Örnek A');
100
+ assert.strictEqual(rows[1].url, 'https://ornek.com/b');
101
+ });
102
+
103
+ test('obscura yolları ve kurulum durumu', () => {
104
+ assert.ok(/beast[\\/]obscura$/.test(obscura.obscuraDir()));
105
+ assert.ok(/obscura\.exe$/.test(obscura.obscuraExe()));
106
+ /* test makinesinde kurulu olabilir — sadece tipleri doğrula */
107
+ assert.strictEqual(typeof obscura.obscuraInstalled(), 'boolean');
108
+ assert.strictEqual(typeof obscura.obscuraSearch, 'function');
109
+ assert.strictEqual(typeof obscura.installObscura, 'function');
110
+ });
111
+
112
+ test('obscuraSearch: kurulu değilse null döner (zincir atlar)', async () => {
113
+ const wasInstalled = obscura.obscuraInstalled();
114
+ if (wasInstalled) return; /* kurulu makinede atla */
115
+ const r = await obscura.obscuraSearch('sorgu', {});
116
+ assert.strictEqual(r, null);
117
+ });
118
+
119
+ test('Exa sistemden tamamen kalktı', () => {
120
+ for (const k of ['setExaKey', 'exaSearch']) assert.ok(!(k in tools), k + ' export yok');
121
+ const desc = JSON.stringify(tools.definitions);
122
+ assert.ok(!/exa\.ai/i.test(desc), 'araç tanımlarında exa yok');
123
+ assert.ok(!/api\.exa\.ai/i.test(desc));
124
+ });