@apmantza/greedysearch-pi 1.1.6 → 1.2.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 CHANGED
@@ -4,6 +4,13 @@ Pi extension that adds a `greedy_search` tool — fans out queries to Perplexity
4
4
 
5
5
  Forked from [GreedySearch-claude](https://github.com/apmantza/GreedySearch-claude).
6
6
 
7
+ ## What's New (v1.2.0)
8
+
9
+ - **Fixed parallel search race condition** — multiple `greedy_search` calls can now run concurrently without tab conflicts
10
+ - **Improved Bing Copilot verification** — better auto-handling of Turnstile challenges and modal dialogs
11
+ - **Added test suite** — run `./test.sh` to verify all modes work correctly
12
+ - **Atomic port file writes** — prevents corruption when multiple processes connect to Chrome
13
+
7
14
  ## Install
8
15
 
9
16
  ```bash
@@ -128,6 +135,22 @@ Check status:
128
135
  node ~/.pi/agent/git/GreedySearch-pi/launch.mjs --status
129
136
  ```
130
137
 
138
+ ## Testing
139
+
140
+ Run the test suite to verify everything works:
141
+
142
+ ```bash
143
+ ./test.sh # full suite (~3-4 min)
144
+ ./test.sh quick # skip parallel tests (~1 min)
145
+ ./test.sh parallel # parallel race condition tests only
146
+ ```
147
+
148
+ Tests verify:
149
+ - Single engine mode (perplexity, bing, google)
150
+ - Sequential "all" mode searches
151
+ - Parallel "all" mode (5 concurrent searches) — detects tab race conditions
152
+ - Synthesis mode with Gemini
153
+
131
154
  ## Troubleshooting
132
155
 
133
156
  ### "Chrome not found"
@@ -144,7 +167,10 @@ node ~/.pi/agent/git/GreedySearch-pi/launch.mjs
144
167
  ```
145
168
 
146
169
  ### Google / Bing "verify you're human"
147
- The extension auto-clicks simple verification buttons. For CAPTCHAs, solve manually in the Chrome window that opens.
170
+ The extension auto-clicks verification buttons and Cloudflare Turnstile challenges. For hard CAPTCHAs (image puzzles), solve manually in the Chrome window that opens.
171
+
172
+ ### Parallel searches failing
173
+ Earlier versions shared Chrome tabs between concurrent searches, causing `ERR_ABORTED` errors. Version 1.2.0+ creates fresh tabs for each search, allowing safe parallel execution.
148
174
 
149
175
  ### Search hangs
150
176
  Chrome may be unresponsive. Restart it with `launch.mjs --kill` then `launch.mjs`.
@@ -36,12 +36,42 @@ const VERIFY_DETECT_JS = `
36
36
  if (msVerify) { msVerify.click(); return 'clicked-ms-verify:' + (msVerify.innerText?.trim() || msVerify.value); }
37
37
  }
38
38
 
39
+ // --- Bing Copilot / Microsoft "Verify you're human" interstitial ---
40
+ // Copilot sometimes shows a modal with "Continue" or "Verify" before allowing queries
41
+ if (url.includes('copilot.microsoft.com') || url.includes('bing.com/chat')) {
42
+ // Look for verification modal/dialog
43
+ var modal = document.querySelector('[role="dialog"], .b_modal, .bnp_hfly, [class*="verify"], [class*="challenge"]');
44
+ if (modal) {
45
+ // Find any actionable button in the modal
46
+ var modalBtns = Array.from(modal.querySelectorAll('button, a[role="button"], input[type="submit"]'));
47
+ var actionBtn = modalBtns.find(b => /^(continue|verify|submit|next|i agree|accept|got it)$/i.test(b.innerText?.trim() || b.value || ''));
48
+ if (actionBtn) { actionBtn.click(); return 'clicked-copilot-modal:' + actionBtn.innerText.trim(); }
49
+ }
50
+
51
+ // Check for Turnstile iframe (Copilot uses Cloudflare Turnstile)
52
+ var turnstileIframe = document.querySelector('iframe[src*="challenges.cloudflare.com"], iframe[src*="turnstile"], iframe[title*="challenge"], iframe[title*="Widget"]');
53
+ if (turnstileIframe) {
54
+ // Try clicking the iframe container or nearby checkbox
55
+ var container = turnstileIframe.closest('[class*="turnstile"], [class*="challenge"], [id*="turnstile"]') || turnstileIframe.parentElement;
56
+ if (container) {
57
+ var checkbox = container.querySelector('input[type="checkbox"]');
58
+ if (checkbox && !checkbox.checked) {
59
+ checkbox.click();
60
+ return 'clicked-turnstile-in-iframe';
61
+ }
62
+ // Click the container itself (Turnstile often captures clicks on parent)
63
+ container.click();
64
+ return 'clicked-turnstile-container-near-iframe';
65
+ }
66
+ }
67
+ }
68
+
39
69
  // --- Cloudflare Turnstile (used by Copilot and many sites) ---
40
70
  // Turnstile widget in iframe
41
71
  var turnstileIframe = document.querySelector('iframe[src*="challenges.cloudflare.com"], iframe[src*="turnstile"]');
42
72
  if (turnstileIframe) {
43
73
  // Try to find and click the checkbox inside the iframe's container
44
- var turnstileCheckbox = document.querySelector('#cf-turnstile-response, [data-turnstile-callback] input, .cf-turnstile input[type=checkbox]');
74
+ var turnstileCheckbox = document.querySelector('#cf-turnstile-response, [data-turnstile-callback] input, .cf-turnstile input[type="checkbox"]');
45
75
  if (turnstileCheckbox && !turnstileCheckbox.checked) {
46
76
  turnstileCheckbox.click();
47
77
  return 'clicked-turnstile-checkbox';
@@ -95,19 +125,26 @@ const VERIFY_RETRY_JS = `
95
125
  var isVerifyPage = url.includes('/sorry/') ||
96
126
  url.includes('challenges.cloudflare.com') ||
97
127
  url.includes('login.microsoftonline.com') ||
98
- document.querySelector('#challenge-running, #challenge-stage, .cf-turnstile');
128
+ document.querySelector('#challenge-running, #challenge-stage, .cf-turnstile, [role="dialog"]');
99
129
 
100
130
  if (!isVerifyPage) return 'cleared';
101
131
 
102
132
  // Try clicking any verify/continue button again
103
133
  var btns = Array.from(document.querySelectorAll('button, input[type=submit], a[role=button]'));
104
- var btn = btns.find(b => /^(verify|continue|next|i am human|not a robot)$/i.test(b.innerText?.trim() || b.value || ''));
134
+ var btn = btns.find(b => /^(verify|continue|next|i am human|not a robot|submit)$/i.test(b.innerText?.trim() || b.value || ''));
105
135
  if (btn) { btn.click(); return 'clicked:' + (btn.innerText?.trim() || btn.value); }
106
136
 
107
137
  // Try Turnstile checkbox
108
138
  var cf = document.querySelector('#cf-stage input[type="checkbox"], .cf-turnstile input');
109
139
  if (cf && !cf.checked) { cf.click(); return 'clicked-turnstile'; }
110
140
 
141
+ // Check for modal dialog with continue button (Copilot interstitial)
142
+ var modal = document.querySelector('[role="dialog"], .b_modal, [class*="verify"]');
143
+ if (modal) {
144
+ var modalBtn = modal.querySelector('button, a[role="button"]');
145
+ if (modalBtn) { modalBtn.click(); return 'clicked-modal-btn:' + modalBtn.innerText.trim(); }
146
+ }
147
+
111
148
  return 'still-verifying';
112
149
  })()
113
150
  `;
@@ -119,6 +156,17 @@ export async function dismissConsent(tab, cdp) {
119
156
  }
120
157
  }
121
158
 
159
+ // Get iframe bounding box for coordinate-based clicking (for cross-origin Turnstile)
160
+ const GET_IFRAME_CENTER_JS = `
161
+ (function() {
162
+ var iframe = document.querySelector('iframe[src*="challenges.cloudflare.com"], iframe[src*="turnstile"], iframe[title*="challenge"], iframe[title*="Widget"]');
163
+ if (!iframe) return null;
164
+ var rect = iframe.getBoundingClientRect();
165
+ // Click near the center-left where the checkbox usually is
166
+ return JSON.stringify({ x: rect.left + 30, y: rect.top + rect.height / 2 });
167
+ })()
168
+ `;
169
+
122
170
  // Returns 'clear' | 'clicked' | 'needs-human'
123
171
  export async function handleVerification(tab, cdp, waitMs = 60000) {
124
172
  const result = await cdp(['eval', tab, VERIFY_DETECT_JS]).catch(() => null);
@@ -158,6 +206,17 @@ export async function handleVerification(tab, cdp, waitMs = 60000) {
158
206
  await new Promise(r => setTimeout(r, 2000));
159
207
  }
160
208
 
209
+ // If verification is stuck, try clicking the Turnstile iframe by coordinates
210
+ const iframeCenter = await cdp(['eval', tab, GET_IFRAME_CENTER_JS]).catch(() => null);
211
+ if (iframeCenter && iframeCenter !== 'null') {
212
+ try {
213
+ const { x, y } = JSON.parse(iframeCenter);
214
+ process.stderr.write(`[greedysearch] Trying coordinate click on Turnstile iframe at (${x}, ${y})...\n`);
215
+ await cdp(['clickxy', tab, String(x), String(y)]);
216
+ await new Promise(r => setTimeout(r, 3000));
217
+ } catch {}
218
+ }
219
+
161
220
  await new Promise(r => setTimeout(r, 1500));
162
221
  }
163
222
 
@@ -166,5 +225,24 @@ export async function handleVerification(tab, cdp, waitMs = 60000) {
166
225
  return 'needs-human';
167
226
  }
168
227
 
228
+ // Detection didn't find anything initially, but check for Turnstile iframe with coordinates
229
+ if (result === 'null' || !result) {
230
+ const iframeCenter = await cdp(['eval', tab, GET_IFRAME_CENTER_JS]).catch(() => null);
231
+ if (iframeCenter && iframeCenter !== 'null') {
232
+ process.stderr.write(`[greedysearch] Found Turnstile iframe, attempting coordinate click...\n`);
233
+ try {
234
+ const { x, y } = JSON.parse(iframeCenter);
235
+ await cdp(['clickxy', tab, String(x), String(y)]);
236
+ await new Promise(r => setTimeout(r, 3000));
237
+
238
+ // Check if it worked
239
+ const cleared = await cdp(['eval', tab, VERIFY_RETRY_JS]).catch(() => null);
240
+ if (cleared === 'cleared' || cleared === 'null') {
241
+ return 'clicked';
242
+ }
243
+ } catch {}
244
+ }
245
+ }
246
+
169
247
  return 'clear';
170
248
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@apmantza/greedysearch-pi",
3
- "version": "1.1.6",
4
- "description": "Pi extension: search Perplexity, Bing Copilot, and Google AI in parallel with optional Gemini synthesis — grounded AI answers, not just links",
3
+ "version": "1.2.0",
4
+ "description": "Pi extension: browser-automation tool that searches Perplexity, Bing Copilot, and Google AI in parallel, extracts answers and sources via CDP, with optional Gemini synthesis — grounded AI answers from real browser interactions.",
5
5
  "type": "module",
6
6
  "keywords": [
7
7
  "pi-package"
package/search.mjs CHANGED
@@ -22,7 +22,7 @@
22
22
  import { spawn } from 'child_process';
23
23
  import { fileURLToPath } from 'url';
24
24
  import { join, dirname } from 'path';
25
- import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs';
25
+ import { readFileSync, existsSync, writeFileSync, mkdirSync, renameSync, unlinkSync } from 'fs';
26
26
  import { tmpdir, homedir } from 'os';
27
27
  import http from 'http';
28
28
 
@@ -305,7 +305,39 @@ function probeGreedyChrome(timeoutMs = 3000) {
305
305
 
306
306
  // Write (or refresh) the DevToolsActivePort file for the GreedySearch Chrome so
307
307
  // cdp.mjs always connects to the right port rather than the user's main Chrome.
308
+ // Uses atomic write (write to temp + rename) to prevent corruption from parallel processes.
308
309
  async function refreshPortFile() {
310
+ const LOCK_FILE = ACTIVE_PORT_FILE + '.lock';
311
+ const TEMP_FILE = ACTIVE_PORT_FILE + '.tmp';
312
+
313
+ // Simple file-based lock with timeout (prevents parallel writes from corrupting the port file)
314
+ const lockAcquired = await new Promise((resolve) => {
315
+ const start = Date.now();
316
+ const tryLock = () => {
317
+ try {
318
+ writeFileSync(LOCK_FILE, `${process.pid}`, 'utf8');
319
+ resolve(true);
320
+ } catch {
321
+ // Lock file exists - check if stale (older than 5 seconds)
322
+ try {
323
+ const lockTime = parseInt(readFileSync(LOCK_FILE, 'utf8'));
324
+ if (Date.now() - lockTime > 5000) {
325
+ // Stale lock - overwrite
326
+ writeFileSync(LOCK_FILE, `${process.pid}`, 'utf8');
327
+ resolve(true);
328
+ } else if (Date.now() - start < 1000) {
329
+ setTimeout(tryLock, 50);
330
+ } else {
331
+ resolve(false); // Give up after 1s
332
+ }
333
+ } catch {
334
+ setTimeout(tryLock, 50);
335
+ }
336
+ }
337
+ };
338
+ tryLock();
339
+ });
340
+
309
341
  try {
310
342
  const body = await new Promise((res, rej) => {
311
343
  const req = http.get(`http://localhost:${GREEDY_PORT}/json/version`, r => {
@@ -318,8 +350,19 @@ async function refreshPortFile() {
318
350
  });
319
351
  const { webSocketDebuggerUrl } = JSON.parse(body);
320
352
  const wsPath = new URL(webSocketDebuggerUrl).pathname;
321
- writeFileSync(ACTIVE_PORT_FILE, `${GREEDY_PORT}\n${wsPath}`, 'utf8');
353
+
354
+ // Atomic write: write to temp file, then rename
355
+ if (lockAcquired) {
356
+ writeFileSync(TEMP_FILE, `${GREEDY_PORT}\n${wsPath}`, 'utf8');
357
+ try { unlinkSync(ACTIVE_PORT_FILE); } catch {}
358
+ renameSync(TEMP_FILE, ACTIVE_PORT_FILE);
359
+ }
322
360
  } catch { /* best-effort — launch.mjs already wrote the file on first start */ }
361
+ finally {
362
+ if (lockAcquired) {
363
+ try { unlinkSync(LOCK_FILE); } catch {}
364
+ }
365
+ }
323
366
  }
324
367
 
325
368
  async function ensureChrome() {
@@ -377,25 +420,14 @@ async function main() {
377
420
  if (engine === 'all') {
378
421
  await cdp(['list']); // refresh pages cache
379
422
 
380
- // Assign tabs: reuse existing engine tabs from cache, open new ones where needed.
381
- // Engine tabs are never closed keeping them alive preserves session cookies and
382
- // reduces the chance of verification challenges on subsequent searches.
423
+ // PARALLEL-SAFE: Always create fresh tabs for each engine to avoid race conditions
424
+ // when multiple "all" searches run concurrently. Previously, reusing cached tabs
425
+ // caused ERR_ABORTED and Uncaught errors as multiple processes fought over the same tab.
383
426
  const tabs = [];
384
- let blankReused = false;
385
-
386
- for (const e of ALL_ENGINES) {
387
- const existing = getTabFromCache(e);
388
- if (existing) {
389
- tabs.push(existing);
390
- } else if (!blankReused) {
391
- const tab = await getOrReuseBlankTab();
392
- tabs.push(tab);
393
- blankReused = true;
394
- } else {
395
- await new Promise(r => setTimeout(r, 500));
396
- const tab = await openNewTab();
397
- tabs.push(tab);
398
- }
427
+ for (let i = 0; i < ALL_ENGINES.length; i++) {
428
+ if (i > 0) await new Promise(r => setTimeout(r, 300)); // small delay between tab opens
429
+ const tab = await openNewTab();
430
+ tabs.push(tab);
399
431
  }
400
432
 
401
433
  // All tabs assigned — run extractors in parallel
package/test.sh ADDED
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env bash
2
+ # test.sh — GreedySearch test suite
3
+ #
4
+ # Usage:
5
+ # ./test.sh # run all tests
6
+ # ./test.sh parallel # run only parallel test
7
+ # ./test.sh quick # skip slow tests (parallel + stress)
8
+ #
9
+ # Tests verify:
10
+ # - No crashes/errors from extractors
11
+ # - All engines complete in "all" mode
12
+ # - Correct queries in results (not mixed up)
13
+ # - Parallel searches don't race on shared tabs
14
+
15
+ set -e
16
+
17
+ cd "$(dirname "$0")"
18
+ RESULTS_DIR="results/test_$(date +%Y%m%d_%H%M%S)"
19
+ mkdir -p "$RESULTS_DIR"
20
+
21
+ RED='\033[0;31m'
22
+ GREEN='\033[0;32m'
23
+ YELLOW='\033[1;33m'
24
+ NC='\033[0m'
25
+
26
+ PASS=0
27
+ FAIL=0
28
+
29
+ pass() { PASS=$((PASS+1)); echo -e " ${GREEN}✓${NC} $1"; }
30
+ fail() { FAIL=$((FAIL+1)); echo -e " ${RED}✗${NC} $1"; }
31
+
32
+ check_no_errors() {
33
+ local file="$1"
34
+ local errors=$(node -e "
35
+ const d = JSON.parse(require('fs').readFileSync('$file','utf8'));
36
+ const errs = [];
37
+ if (d.perplexity?.error) errs.push('perplexity: ' + d.perplexity.error);
38
+ if (d.bing?.error) errs.push('bing: ' + d.bing.error);
39
+ if (d.google?.error) errs.push('google: ' + d.google.error);
40
+ console.log(errs.join('; ') || '');
41
+ " 2>/dev/null)
42
+ echo "$errors"
43
+ }
44
+
45
+ check_correct_queries() {
46
+ local file="$1"
47
+ local expected="$2"
48
+ local result=$(node -e "
49
+ const d = JSON.parse(require('fs').readFileSync('$file','utf8'));
50
+ const queries = [d.perplexity?.query, d.bing?.query, d.google?.query].filter(Boolean);
51
+ const allMatch = queries.every(q => q === '$expected');
52
+ console.log(allMatch ? 'ok' : 'queries: ' + queries.join(', '));
53
+ " 2>/dev/null)
54
+ echo "$result"
55
+ }
56
+
57
+ check_all_engines_completed() {
58
+ local file="$1"
59
+ local result=$(node -e "
60
+ const d = JSON.parse(require('fs').readFileSync('$file','utf8'));
61
+ const hasAnswer = (e) => d[e]?.answer && d[e].answer.length > 10;
62
+ const engines = ['perplexity', 'bing', 'google'];
63
+ const ok = engines.every(hasAnswer);
64
+ console.log(ok ? 'ok' : 'missing: ' + engines.filter(e => !hasAnswer(e)).join(', '));
65
+ " 2>/dev/null)
66
+ echo "$result"
67
+ }
68
+
69
+ # ─────────────────────────────────────────────────────────
70
+ echo -e "\n${YELLOW}═══ GreedySearch Test Suite ═══${NC}\n"
71
+
72
+ # ── Test 1: Single engine mode ──────────────────────────
73
+ if [[ "$1" != "parallel" ]]; then
74
+ echo "Test 1: Single engine mode"
75
+
76
+ for engine in perplexity bing google; do
77
+ outfile="$RESULTS_DIR/single_${engine}.json"
78
+ node search.mjs "$engine" "explain $engine attention mechanism" --out "$outfile" 2>/dev/null
79
+ if [[ $? -eq 0 && -f "$outfile" ]]; then
80
+ errors=$(check_no_errors "$outfile")
81
+ if [[ -z "$errors" ]]; then
82
+ pass "$engine completed without errors"
83
+ else
84
+ fail "$engine errors: $errors"
85
+ fi
86
+ else
87
+ fail "$engine failed to run"
88
+ fi
89
+ done
90
+ fi
91
+
92
+ # ── Test 2: Sequential "all" mode ───────────────────────
93
+ if [[ "$1" != "parallel" ]]; then
94
+ echo -e "\nTest 2: Sequential 'all' mode (3 runs)"
95
+
96
+ for i in 1 2 3; do
97
+ outfile="$RESULTS_DIR/seq_${i}.json"
98
+ query="LLM inference optimization techniques $i"
99
+ node search.mjs all "$query" --out "$outfile" 2>/dev/null
100
+
101
+ if [[ $? -eq 0 && -f "$outfile" ]]; then
102
+ errors=$(check_no_errors "$outfile")
103
+ if [[ -z "$errors" ]]; then
104
+ pass "Run $i: no errors"
105
+ else
106
+ fail "Run $i errors: $errors"
107
+ fi
108
+
109
+ correct=$(check_correct_queries "$outfile" "$query")
110
+ if [[ "$correct" == "ok" ]]; then
111
+ pass "Run $i: correct queries"
112
+ else
113
+ fail "Run $i: $correct"
114
+ fi
115
+ else
116
+ fail "Run $i: failed to run"
117
+ fi
118
+ done
119
+ fi
120
+
121
+ # ── Test 3: Parallel "all" mode (race condition test) ───
122
+ if [[ "$1" != "quick" && "$1" != "sequential" ]]; then
123
+ echo -e "\nTest 3: Parallel 'all' mode (5 concurrent searches)"
124
+
125
+ PARALLEL_QUERIES=(
126
+ "what are transformer architectures in LLMs"
127
+ "explain RLHF fine-tuning process"
128
+ "difference between GPT and BERT models"
129
+ "how does chain of thought prompting work"
130
+ "what is retrieval augmented generation"
131
+ )
132
+
133
+ PIDS=()
134
+ for i in "${!PARALLEL_QUERIES[@]}"; do
135
+ outfile="$RESULTS_DIR/parallel_${i}.json"
136
+ query="${PARALLEL_QUERIES[$i]}"
137
+ node search.mjs all "$query" --out "$outfile" 2>/dev/null &
138
+ PIDS+=($!)
139
+ done
140
+
141
+ # Wait for all to complete
142
+ FAILED=0
143
+ for i in "${!PIDS[@]}"; do
144
+ if ! wait "${PIDS[$i]}"; then
145
+ fail "Parallel $i: process exited with error"
146
+ ((FAILED++))
147
+ fi
148
+ done
149
+
150
+ if [[ $FAILED -eq 0 ]]; then
151
+ # Check results
152
+ for i in "${!PARALLEL_QUERIES[@]}"; do
153
+ outfile="$RESULTS_DIR/parallel_${i}.json"
154
+ query="${PARALLEL_QUERIES[$i]}"
155
+
156
+ if [[ -f "$outfile" ]]; then
157
+ errors=$(check_no_errors "$outfile")
158
+ if [[ -z "$errors" ]]; then
159
+ pass "Parallel $i: no errors"
160
+ else
161
+ fail "Parallel $i: $errors"
162
+ fi
163
+
164
+ correct=$(check_correct_queries "$outfile" "$query")
165
+ if [[ "$correct" == "ok" ]]; then
166
+ pass "Parallel $i: correct query"
167
+ else
168
+ fail "Parallel $i: $correct (TAB RACE DETECTED)"
169
+ fi
170
+
171
+ all_done=$(check_all_engines_completed "$outfile")
172
+ if [[ "$all_done" == "ok" ]]; then
173
+ pass "Parallel $i: all engines answered"
174
+ else
175
+ fail "Parallel $i: $all_done"
176
+ fi
177
+ else
178
+ fail "Parallel $i: no result file"
179
+ fi
180
+ done
181
+ fi
182
+ fi
183
+
184
+ # ── Test 4: Synthesis mode ──────────────────────────────
185
+ if [[ "$1" != "parallel" && "$1" != "quick" ]]; then
186
+ echo -e "\nTest 4: Synthesis mode"
187
+
188
+ outfile="$RESULTS_DIR/synthesis.json"
189
+ node search.mjs all "what is Mixture of Experts in neural networks" --synthesize --out "$outfile" 2>/dev/null
190
+
191
+ if [[ $? -eq 0 && -f "$outfile" ]]; then
192
+ has_synthesis=$(node -e "
193
+ const d = JSON.parse(require('fs').readFileSync('$outfile','utf8'));
194
+ console.log(d._synthesis?.answer ? 'ok' : 'missing');
195
+ " 2>/dev/null)
196
+
197
+ if [[ "$has_synthesis" == "ok" ]]; then
198
+ pass "Synthesis completed"
199
+ else
200
+ fail "Synthesis missing"
201
+ fi
202
+
203
+ errors=$(check_no_errors "$outfile")
204
+ if [[ -z "$errors" ]]; then
205
+ pass "Synthesis: no engine errors"
206
+ else
207
+ fail "Synthesis: $errors"
208
+ fi
209
+ else
210
+ fail "Synthesis failed to run"
211
+ fi
212
+ fi
213
+
214
+ # ─────────────────────────────────────────────────────────
215
+ echo -e "\n${YELLOW}═══ Results ═══${NC}"
216
+ echo -e " ${GREEN}Passed: $PASS${NC}"
217
+ [[ $FAIL -gt 0 ]] && echo -e " ${RED}Failed: $FAIL${NC}" || echo " Failed: 0"
218
+ echo " Results in: $RESULTS_DIR"
219
+ echo ""
220
+
221
+ [[ $FAIL -eq 0 ]] && exit 0 || exit 1