Fix false-positive search failures: real timeout + logged-in searches

The "has hits" searches produced false-positive failures in two ways:

- search() relied on Puppeteer's default 30s navigation timeout, which is
  independent of KORAP_SEARCH_TIMEOUT, so complex queries on very large
  corpora timed out at 30s even when the timeout was raised. The caller
  timeout now flows into waitForNavigation and waitForFunction.

- "Logout works" ran before the searches: Mocha executes a suite's direct
  it() tests before its nested describe() suites, so the top-level logout
  test fired first and logged us out, leaving the searches to query the
  (tiny) public corpus and report 0 hits. Logout is now wrapped in its own
  suite declared after the searches, so it runs last; the searches' before
  hook also asserts a required login actually succeeded.

Add an optional KORAP_VC env var to restrict the searches to a virtual
corpus (passed as cq), keeping complex queries fast enough to finish within
the timeout.

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

Change-Id: If72a0b9c36700ca1a546acfd4850ee447d586a38
diff --git a/Readme.md b/Readme.md
index 7af1131..20a427c 100644
--- a/Readme.md
+++ b/Readme.md
@@ -31,7 +31,8 @@
 | `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_SEARCH_TIMEOUT` | `60000` | Per-query timeout in ms for the "has hits" search tests. Applies to the actual page navigation/result wait, so raising it genuinely helps slow/complex queries on very large corpora avoid false-positive timeout failures |
+| `KORAP_VC` | _(none)_ | Optional virtual corpus restriction applied to the "has hits" searches, passed as the corpus query (`cq`). E.g. `KORAP_VC="pubDate in 2020"`. Narrowing the corpus keeps complex queries fast enough to finish within the timeout (also accepts `VC`) |
 | `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 0bdf1af..e2d8a15 100644
--- a/lib/korap_rc.js
+++ b/lib/korap_rc.js
@@ -70,17 +70,38 @@
         }
     }
 
-    async search(page, query) {
+    async search(page, query, options = {}) {
+        // Both the navigation and the post-navigation settle should honour the
+        // caller-supplied timeout. Without this, Puppeteer's default 30s
+        // navigation timeout silently caps the wait, so complex queries on very
+        // large corpora time out (false-positive failure) even when
+        // KORAP_SEARCH_TIMEOUT is raised. Default to 30s for backwards compat.
+        const timeout = options.timeout || 30000;
+        // Optional virtual corpus restriction (e.g. "pubDate in 2020"). When
+        // set, it is passed as the corpus query (cq) to narrow the search and
+        // keep it fast enough to finish within the timeout.
+        const vc = options.vc || "";
         try {
-            await page.waitForSelector("#q-field", { visible: true });
-            const query_field = await page.$("#q-field");
-            assert.notEqual(query_field, null, "Query field not found");
+            if (vc) {
+                // A VC can't be entered through the query field, so navigate to
+                // the search URL directly with q + cq. The session cookie is
+                // preserved, so the user stays logged in.
+                const url = new URL(this.korap_url);
+                url.searchParams.set('q', query);
+                url.searchParams.set('ql', 'poliqarp');
+                url.searchParams.set('cq', vc);
+                await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout });
+            } else {
+                await page.waitForSelector("#q-field", { visible: true });
+                const query_field = await page.$("#q-field");
+                assert.notEqual(query_field, null, "Query field not found");
 
-            await query_field.click({ clickCount: 3 });
-            await page.keyboard.type(query);
-            await page.keyboard.press("Enter");
+                await query_field.click({ clickCount: 3 });
+                await page.keyboard.type(query);
+                await page.keyboard.press("Enter");
 
-            await page.waitForNavigation({ waitUntil: 'domcontentloaded' });
+                await page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout });
+            }
 
             // Wait until the results page has actually settled, then read the
             // count immediately (no fixed sleep). Kalamar renders the precise
@@ -93,7 +114,7 @@
                 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 });
+            }, { timeout, polling: 200 });
 
             const hits = await page.evaluate(() => {
                 const total = document.querySelector('#total-results');
diff --git a/test/korap-ui.js b/test/korap-ui.js
index bec14bd..aaf46c1 100644
--- a/test/korap-ui.js
+++ b/test/korap-ui.js
@@ -21,6 +21,7 @@
 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 KORAP_VC = process.env.KORAP_VC || process.env.VC || "";
 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)
@@ -332,23 +333,40 @@
 
         describe('Running searches that should have hits', () => {
 
-            before(async () => { await korap_rc.login(page, KORAP_LOGIN, KORAP_PWD) })
+            // The searches must run while logged in — most corpora are only
+            // available after authentication. Re-assert the session here and,
+            // when a login is required, fail loudly if it didn't take instead
+            // of silently testing the (tiny) public corpus logged out.
+            before(async () => {
+                const logged_in = await korap_rc.login(page, KORAP_LOGIN, KORAP_PWD)
+                if (KORAP_LOGIN != "") {
+                    assert.isTrue(logged_in,
+                        "Login is required for the has-hits searches but did not succeed; aborting so we don't test the public corpus while logged out")
+                }
+            })
 
             KORAP_QUERIES.split(/[;,] */).forEach((query, i) => {
                 it('Search for "' + query + '" has hits',
                     (async () => {
                         await korap_rc.assure_glimpse_off(page)
-                        const hits = await korap_rc.search(page, query)
+                        const hits = await korap_rc.search(page, query, { timeout: KORAP_SEARCH_TIMEOUT, vc: KORAP_VC })
                         hits.should.be.above(0)
-                    })).timeout(KORAP_SEARCH_TIMEOUT)
+                    })).timeout(KORAP_SEARCH_TIMEOUT + 30000)
             })
         })
 
-        ifConditionIt('Logout works',
-            KORAP_LOGIN != "",
-            (async () => {
-                const logout_result = await korap_rc.logout(page)
-                logout_result.should.be.true
-            })).timeout(15000)
+        // Logout must be the LAST UI test. Mocha runs a suite's direct it()
+        // tests before its nested describe() suites, so a top-level "Logout"
+        // test would otherwise run *before* the searches above and log us out,
+        // leaving them to query the public corpus. Wrapping it in its own suite
+        // declared after the searches suite guarantees it runs last.
+        describe('Logging out', () => {
+            ifConditionIt('Logout works',
+                KORAP_LOGIN != "",
+                (async () => {
+                    const logout_result = await korap_rc.logout(page)
+                    logout_result.should.be.true
+                })).timeout(15000)
+        })
     });
 });
\ No newline at end of file