Fix false-positive failures in "has hits" search tests

The search tests sometimes reported failure (and fired notifications) even
though the result page showed matches. Two changes:

- The per-search mocha timeout was a hard 20s, shorter than search()'s own
  internal waits, so a slow total-count computation could trip it before the
  count was read. The timeout is now configurable via KORAP_SEARCH_TIMEOUT
  (default 60s).

- search() no longer relies on a fixed 2s sleep. It waits until the result
  page has settled -- the total is shown, matches are listed, or a "no
  matches" message appears -- and reads the outcome immediately.

Note this slightly relaxes what "has hits" verifies: when glimpse/cutoff is
on, Kalamar lists matches without computing a total, so the test now accepts
the presence of listed matches as proof of hits rather than requiring a
counted total. This keeps the check stable across glimpse states; when
glimpse is off the exact total is still read and asserted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Change-Id: I92f2004ba668722939e3a70e3ab8aa7eae698b4d
diff --git a/Readme.md b/Readme.md
index d492bbe..7af1131 100644
--- a/Readme.md
+++ b/Readme.md
@@ -31,6 +31,7 @@
 | `KORAP_PASSWORD` | `password2` | Password for KorAP login (also accepts legacy `KORAP_PWD`) |
 | `KORAP_QUERIES` | `geht, [orth=geht & cmc/pos=VVFIN]` | Comma-separated list of queries to test |
 | `KORAP_MIN_TOKENS_IN_CORPUS` | `100000` | Minimum expected number of tokens for corpus statistics test |
+| `KORAP_SEARCH_TIMEOUT` | `60000` | Per-query timeout in ms for the "has hits" search tests. Raise it for slow/complex queries on very large corpora to avoid false-positive timeout failures |
 | `KORAP_HEADLESS` | `true` | Set to `false` or `0` to run browser in UI mode (visible window) instead of headless |
 | `SLACK_WEBHOOK_URL` | _(none)_ | Slack webhook URL for test failure notifications (text only) |
 | `SLACK_TOKEN` | _(none)_ | Slack bot token for uploading failure screenshots |
diff --git a/lib/korap_rc.js b/lib/korap_rc.js
index 158ecbb..0bdf1af 100644
--- a/lib/korap_rc.js
+++ b/lib/korap_rc.js
@@ -82,82 +82,37 @@
 
             await page.waitForNavigation({ waitUntil: 'domcontentloaded' });
 
-            // Wait for search results to be fully loaded
-            try {
-                await page.waitForSelector('ol li, #resultinfo, .result-item', {
-                    visible: true,
-                    timeout: 15000
-                });
-                // Give additional time for the results count to be populated
-                await new Promise(resolve => setTimeout(resolve, 2000));
-            } catch (error) {
-                // Continue if timeout, fallback methods will handle it
-            }
+            // Wait until the results page has actually settled, then read the
+            // count immediately (no fixed sleep). Kalamar renders the precise
+            // total into #total-results (e.g. "1,039,193") on hits, but when
+            // glimpse/cutoff is on it lists matches without computing a total,
+            // and on a miss it shows a .no-results message. A search "has hits"
+            // as soon as any match is listed, so settle on whichever appears.
+            await page.waitForFunction(() => {
+                const total = document.querySelector('#total-results');
+                if (total && /\d/.test(total.textContent || '')) return true;
+                if (document.querySelectorAll('#search ol li').length > 0) return true;
+                return document.querySelector('#search .no-results, .no-results') !== null;
+            }, { timeout: 15000, polling: 200 });
 
-            const resultsInfo = await page.evaluate(() => {
-                // Check common selectors for result counts
-                const selectors = [
-                    '#total-results',
-                    '#resultinfo',
-                    '.result-count',
-                    '.total-results',
-                    '[data-results]',
-                    '.found'
-                ];
-
-                for (const selector of selectors) {
-                    const element = document.querySelector(selector);
-                    if (element) {
-                        const text = element.textContent || element.innerText || '';
-                        const numbers = text.match(/\d+/g);
-                        if (numbers && numbers.length > 0) {
-                            return {
-                                selector: selector,
-                                numbers: numbers
-                            };
-                        }
-                    }
+            const hits = await page.evaluate(() => {
+                const total = document.querySelector('#total-results');
+                if (total) {
+                    // The span holds only the number; strip thousands separators
+                    // (commas, periods, spaces) and parse what remains.
+                    const digits = (total.textContent || '').replace(/[^\d]/g, '');
+                    if (digits.length > 0) return parseInt(digits, 10);
                 }
 
-                // Look in the page title for results count
-                const title = document.title;
-                if (title) {
-                    const numbers = title.match(/\d+/g);
-                    if (numbers && numbers.length > 0) {
-                        return {
-                            selector: 'title',
-                            numbers: numbers
-                        };
-                    }
-                }
+                // No exact total (e.g. glimpse/cutoff on): listed matches still
+                // prove the query has hits, so fall back to counting them.
+                const items = document.querySelectorAll('#search ol li').length;
+                if (items > 0) return items;
 
-                // Count the actual result items as fallback
-                const resultItems = document.querySelectorAll('ol li');
-                if (resultItems.length > 0) {
-                    return {
-                        selector: 'counted-items',
-                        numbers: [resultItems.length.toString()]
-                    };
-                }
-
-                return null;
+                // Explicit "no matches" state reported by Kalamar.
+                return 0;
             });
 
-            if (!resultsInfo || !resultsInfo.numbers || resultsInfo.numbers.length === 0) {
-                // Final fallback: just count visible list items
-                const itemCount = await page.evaluate(() => {
-                    return document.querySelectorAll('ol li').length;
-                });
-
-                if (itemCount > 0) {
-                    return itemCount;
-                }
-
-                throw new Error("Cannot find any results count on the page");
-            }
-
-            // Extract the largest number found (likely the total results)
-            const hits = Math.max(...resultsInfo.numbers.map(n => parseInt(n, 10)));
             return hits;
         } catch (error) {
             throw new Error(`Failed to perform search: ${error.message}`);
diff --git a/test/korap-ui.js b/test/korap-ui.js
index 621b22f..bec14bd 100644
--- a/test/korap-ui.js
+++ b/test/korap-ui.js
@@ -20,6 +20,7 @@
 const KORAP_PWD = process.env.KORAP_PWD || process.env.KORAP_PASSWORD || "password2";
 const KORAP_QUERIES = process.env.KORAP_QUERIES || 'geht, [orth=geht & cmc/pos=VVFIN]'
 const KORAP_MIN_TOKENS_IN_CORPUS = parseInt(process.env.KORAP_MIN_TOKENS_IN_CORPUS || "100000", 10);
+const KORAP_SEARCH_TIMEOUT = parseInt(process.env.KORAP_SEARCH_TIMEOUT || "60000", 10);
 const NOTIFY_ON_SUCCESS = process.env.NOTIFY_ON_SUCCESS === 'true' || process.env.NOTIFY_ON_SUCCESS === '1';
 const KORAP_HEADLESS = !(process.env.KORAP_HEADLESS === 'false' || process.env.KORAP_HEADLESS === '0');
 const korap_rc = require('../lib/korap_rc.js').new(KORAP_URL)
@@ -339,7 +340,7 @@
                         await korap_rc.assure_glimpse_off(page)
                         const hits = await korap_rc.search(page, query)
                         hits.should.be.above(0)
-                    })).timeout(20000)
+                    })).timeout(KORAP_SEARCH_TIMEOUT)
             })
         })