diff --git a/tests/playwright/cesium-queries.spec.js b/tests/playwright/cesium-queries.spec.js index c326b4b..56a6299 100644 --- a/tests/playwright/cesium-queries.spec.js +++ b/tests/playwright/cesium-queries.spec.js @@ -16,7 +16,7 @@ const { test, expect } = require('@playwright/test'); // Configuration -const BASE_URL = process.env.TEST_URL || 'http://localhost:5860'; +const { siteUrl } = require('./helpers/url'); const PAGE_PATH = '/tutorials/parquet_cesium.html'; // Test data - PKAP location with known samples @@ -27,7 +27,7 @@ test.describe('Cesium Query Results UI', () => { test.beforeEach(async ({ page }) => { // Navigate to page - await page.goto(`${BASE_URL}${PAGE_PATH}`, { + await page.goto(siteUrl(PAGE_PATH), { waitUntil: 'domcontentloaded', timeout: 60000 }); diff --git a/tests/playwright/explorer-helper.spec.js b/tests/playwright/explorer-helper.spec.js index 136bb60..3ef9c2d 100644 --- a/tests/playwright/explorer-helper.spec.js +++ b/tests/playwright/explorer-helper.spec.js @@ -35,9 +35,7 @@ */ const { test, expect } = require('@playwright/test'); - -const BASE_URL = process.env.TEST_URL || 'http://localhost:5860'; -const EXPLORER_PATH = '/explorer.html'; +const { explorerUrl } = require('./helpers/url'); // Phrases written by `updatePhaseMsg` we check for in tests. const FETCHING_SAMPLE_INDEX = 'Fetching sample index'; // the helper's loadingMsg @@ -218,7 +216,7 @@ async function flyCameraTo(page, lat, lng, alt) { test.describe('explorer: tryEnterPointModeIfNeeded short-circuit invariants', () => { test('boot to cluster mode reaches a done message with cluster counts', async ({ page }) => { - await page.goto(`${BASE_URL}${EXPLORER_PATH}#v=1&lat=20&lng=0&alt=${ALT_WORLD}`, { + await page.goto(explorerUrl(`#v=1&lat=20&lng=0&alt=${ALT_WORLD}`), { waitUntil: 'domcontentloaded', timeout: 60000, }); @@ -235,7 +233,7 @@ test.describe('explorer: tryEnterPointModeIfNeeded short-circuit invariants', () // helper after its own loadRes settles; at world altitude the chase // should bail at the altitude check WITHOUT painting "Fetching sample // index…". - await page.goto(`${BASE_URL}${EXPLORER_PATH}#v=1&lat=20&lng=0&alt=${ALT_WORLD}`, { + await page.goto(explorerUrl(`#v=1&lat=20&lng=0&alt=${ALT_WORLD}`), { waitUntil: 'domcontentloaded', timeout: 60000, }); @@ -277,7 +275,7 @@ test.describe('explorer: tryEnterPointModeIfNeeded short-circuit invariants', () // and paints "Fetching sample index…"; warm-cache: currentRes is // already 8 so the helper short-circuits its loadRes and goes // straight to enterPointMode → "Loading individual samples…".) - await page.goto(`${BASE_URL}${EXPLORER_PATH}#v=1&lat=20&lng=0&alt=${ALT_WORLD}`, { + await page.goto(explorerUrl(`#v=1&lat=20&lng=0&alt=${ALT_WORLD}`), { waitUntil: 'domcontentloaded', timeout: 60000, }); @@ -316,7 +314,7 @@ test.describe('explorer: tryEnterPointModeIfNeeded short-circuit invariants', () // wires a chase in, the short-circuit must keep the helper's // loadingMsg ("Fetching sample index…") suppressed because we're // already in point mode. - await page.goto(`${BASE_URL}${EXPLORER_PATH}#v=1&lat=20&lng=0&alt=${ALT_WORLD}`, { + await page.goto(explorerUrl(`#v=1&lat=20&lng=0&alt=${ALT_WORLD}`), { waitUntil: 'domcontentloaded', timeout: 60000, }); diff --git a/tests/playwright/explorer-layout-stability.spec.js b/tests/playwright/explorer-layout-stability.spec.js index 352967a..6359949 100644 --- a/tests/playwright/explorer-layout-stability.spec.js +++ b/tests/playwright/explorer-layout-stability.spec.js @@ -1,7 +1,5 @@ const { test, expect } = require('@playwright/test'); - -const BASE_URL = process.env.TEST_URL || 'http://localhost:5860'; -const EXPLORER_PATH = '/explorer.html'; +const { explorerUrl } = require('./helpers/url'); const ALT_WORLD = 10000000; const ALT_POINT_CYPRUS = 62054; @@ -95,7 +93,7 @@ async function resolveMapHeightPx(page) { test.describe('explorer layout stability', () => { test('desktop globe rect is stable across boot, status, and point-mode flight; table is permanent below', async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); - await page.goto(`${BASE_URL}${EXPLORER_PATH}#v=1&lat=20&lng=0&alt=${ALT_WORLD}`, { + await page.goto(explorerUrl(`#v=1&lat=20&lng=0&alt=${ALT_WORLD}`), { waitUntil: 'domcontentloaded', timeout: 60000, }); @@ -142,7 +140,7 @@ test.describe('explorer layout stability', () => { test('mobile globe height override is stable across boot and wrapped status', async ({ page }) => { const viewport = { width: 390, height: 844 }; await page.setViewportSize(viewport); - await page.goto(`${BASE_URL}${EXPLORER_PATH}#v=1&lat=20&lng=0&alt=${ALT_WORLD}`, { + await page.goto(explorerUrl(`#v=1&lat=20&lng=0&alt=${ALT_WORLD}`), { waitUntil: 'domcontentloaded', timeout: 60000, }); @@ -172,7 +170,7 @@ test.describe('explorer layout stability', () => { // 50vh = 284px — below the 360px floor — so map height = 360px. // Covers the clamp-floor branch which the 390×844 case never exercises. await page.setViewportSize({ width: 320, height: 568 }); - await page.goto(`${BASE_URL}${EXPLORER_PATH}#v=1&lat=20&lng=0&alt=${ALT_WORLD}`, { + await page.goto(explorerUrl(`#v=1&lat=20&lng=0&alt=${ALT_WORLD}`), { waitUntil: 'domcontentloaded', timeout: 60000, }); diff --git a/tests/playwright/explorer-map-overlay.spec.js b/tests/playwright/explorer-map-overlay.spec.js index 0b7dc13..b6039ed 100644 --- a/tests/playwright/explorer-map-overlay.spec.js +++ b/tests/playwright/explorer-map-overlay.spec.js @@ -1,7 +1,5 @@ const { test, expect } = require('@playwright/test'); - -const BASE_URL = process.env.TEST_URL || 'http://localhost:5860'; -const EXPLORER_PATH = '/explorer.html'; +const { explorerUrl } = require('./helpers/url'); // Cesium + OJS boot can be slow on CI; the in-map-overlay specs all wait // for #cesiumContainer + toolbar render before measuring. @@ -31,7 +29,7 @@ async function waitForBootReady(page) { test.describe('Map search overlay — Cesium toolbar coexistence (#200 / M-1A)', () => { test('desktop: overlay does not cover Cesium toolbar buttons', async ({ page }) => { await page.setViewportSize({ width: 1280, height: 800 }); - await page.goto(`${BASE_URL}${EXPLORER_PATH}#v=1&lat=20&lng=0&alt=10000000`, { + await page.goto(explorerUrl('#v=1&lat=20&lng=0&alt=10000000'), { waitUntil: 'domcontentloaded', timeout: 60000, }); @@ -56,7 +54,7 @@ test.describe('Map search overlay — Cesium toolbar coexistence (#200 / M-1A)', test('mobile (390px): overlay does not cover Cesium toolbar', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); - await page.goto(`${BASE_URL}${EXPLORER_PATH}#v=1&lat=20&lng=0&alt=10000000`, { + await page.goto(explorerUrl('#v=1&lat=20&lng=0&alt=10000000'), { waitUntil: 'domcontentloaded', timeout: 60000, }); @@ -71,7 +69,7 @@ test.describe('Map search overlay — Cesium toolbar coexistence (#200 / M-1A)', test('iPhone SE (320px): overlay clears toolbar and search buttons do not overflow', async ({ page }) => { await page.setViewportSize({ width: 320, height: 568 }); - await page.goto(`${BASE_URL}${EXPLORER_PATH}#v=1&lat=20&lng=0&alt=10000000`, { + await page.goto(explorerUrl('#v=1&lat=20&lng=0&alt=10000000'), { waitUntil: 'domcontentloaded', timeout: 60000, }); @@ -102,7 +100,7 @@ test.describe('Map search overlay — Cesium toolbar coexistence (#200 / M-1A)', test('base-layer picker dropdown is clickable (not occluded) above the overlay', async ({ page }) => { await page.setViewportSize({ width: 1280, height: 800 }); - await page.goto(`${BASE_URL}${EXPLORER_PATH}#v=1&lat=20&lng=0&alt=10000000`, { + await page.goto(explorerUrl('#v=1&lat=20&lng=0&alt=10000000'), { waitUntil: 'domcontentloaded', timeout: 60000, }); @@ -154,7 +152,7 @@ test.describe('Map search overlay — Cesium toolbar coexistence (#200 / M-1A)', await page.setViewportSize({ width: 1280, height: 900 }); // Load at world zoom and read total. - await page.goto(`${BASE_URL}${EXPLORER_PATH}#v=1&lat=20&lng=0&alt=10000000`, { + await page.goto(explorerUrl('#v=1&lat=20&lng=0&alt=10000000'), { waitUntil: 'domcontentloaded', timeout: 60000, }); @@ -203,7 +201,7 @@ test.describe('Map search overlay — Cesium toolbar coexistence (#200 / M-1A)', test('table v2: pagination is server-side, pager shows Page X of Y, Next loads new rows', async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); - await page.goto(`${BASE_URL}${EXPLORER_PATH}#v=1&lat=20&lng=0&alt=10000000`, { + await page.goto(explorerUrl('#v=1&lat=20&lng=0&alt=10000000'), { waitUntil: 'domcontentloaded', timeout: 60000, }); @@ -236,7 +234,7 @@ test.describe('Map search overlay — Cesium toolbar coexistence (#200 / M-1A)', test('table v2: filter change clears pager text and re-fetches count', async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); - await page.goto(`${BASE_URL}${EXPLORER_PATH}#v=1&lat=20&lng=0&alt=10000000`, { + await page.goto(explorerUrl('#v=1&lat=20&lng=0&alt=10000000'), { waitUntil: 'domcontentloaded', timeout: 60000, }); @@ -260,7 +258,7 @@ test.describe('Map search overlay — Cesium toolbar coexistence (#200 / M-1A)', test('clicking a table row selects the sample, updates #pid hash, and marks the row selected', async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); - await page.goto(`${BASE_URL}${EXPLORER_PATH}#v=1&lat=20&lng=0&alt=10000000`, { + await page.goto(explorerUrl('#v=1&lat=20&lng=0&alt=10000000'), { waitUntil: 'domcontentloaded', timeout: 60000, }); @@ -295,7 +293,7 @@ test.describe('Map search overlay — Cesium toolbar coexistence (#200 / M-1A)', test('sidebar search input mirrors in-map search input', async ({ page }) => { await page.setViewportSize({ width: 1280, height: 800 }); - await page.goto(`${BASE_URL}${EXPLORER_PATH}`, { + await page.goto(explorerUrl(), { waitUntil: 'domcontentloaded', timeout: 60000, }); diff --git a/tests/playwright/facet-viewport.spec.js b/tests/playwright/facet-viewport.spec.js index 419e6f6..c74ed8f 100644 --- a/tests/playwright/facet-viewport.spec.js +++ b/tests/playwright/facet-viewport.spec.js @@ -40,8 +40,7 @@ * the implementation plan logs query latency for visibility. */ const { test, expect } = require('@playwright/test'); - -const EXPLORER_PATH = '/explorer.html'; +const { explorerUrl } = require('./helpers/url'); // Global view position. alt=15000000 (15,000 km) is above the // `GLOBAL_VIEW_ALT_M = 1e7` shortcut in `isGlobalView()`, so the @@ -133,7 +132,7 @@ test.describe('B1 viewport-aware facet counts (#234 step 3)', () => { test.setTimeout(180000); test('zoom in → counts shrink to viewport; zoom out → counts restore', async ({ page }) => { - await page.goto(`${EXPLORER_PATH}${GLOBAL_HASH}`); + await page.goto(explorerUrl(GLOBAL_HASH)); await waitForFacetUI(page); await waitForFacetCountsStable(page); @@ -181,7 +180,7 @@ test.describe('B1 viewport-aware facet counts (#234 step 3)', () => { // DuckDB since `lite_url` also carries a `source` column) and // against the JOIN inadvertently double-counting via duplicate pids. // Codex round-1 review of PR #237 called out the coverage gap. - await page.goto(`${EXPLORER_PATH}${GLOBAL_HASH}`); + await page.goto(explorerUrl(GLOBAL_HASH)); await waitForFacetUI(page); await waitForFacetCountsStable(page); @@ -243,7 +242,7 @@ test.describe('B1 viewport-aware facet counts (#234 step 3)', () => { }); test('moveStart marks .recomputing before the debounce can run', async ({ page }) => { - await page.goto(`${EXPLORER_PATH}${GLOBAL_HASH}`); + await page.goto(explorerUrl(GLOBAL_HASH)); await waitForFacetUI(page); await waitForFacetCountsStable(page); @@ -289,7 +288,7 @@ test.describe('B1 viewport-aware facet counts (#234 step 3)', () => { // must NOT leave any `.recomputing` class behind. Guards against a // future refactor that moves `markFacetCountsRecomputing()` above // the early-return. - await page.goto(`${EXPLORER_PATH}${GLOBAL_HASH}`); + await page.goto(explorerUrl(GLOBAL_HASH)); await waitForFacetUI(page); await waitForFacetCountsStable(page); const stuck = await page.evaluate( diff --git a/tests/playwright/facetnote-url-load.spec.js b/tests/playwright/facetnote-url-load.spec.js index d3663fd..f321b5d 100644 --- a/tests/playwright/facetnote-url-load.spec.js +++ b/tests/playwright/facetnote-url-load.spec.js @@ -20,8 +20,7 @@ */ const { test, expect } = require('@playwright/test'); - -const EXPLORER_PATH = '/explorer.html'; +const { explorerUrl } = require('./helpers/url'); // Cluster-altitude default (well above ENTER_POINT_ALT = 120000). const ALT_CLUSTER = 5000000; @@ -60,7 +59,7 @@ test.describe('#facetNote URL deep-link visibility (issue #234 step 1)', () => { // Boot the page once to discover a real material URI from the rendered // checkboxes. Hardcoding a URI would couple the test to a specific // vocabulary version; reading the live data keeps it self-healing. - await page.goto(`${EXPLORER_PATH}#v=1&lat=${LAT}&lng=${LNG}&alt=${ALT_CLUSTER}`); + await page.goto(explorerUrl(`#v=1&lat=${LAT}&lng=${LNG}&alt=${ALT_CLUSTER}`)); await waitForMode(page, 'cluster'); await waitForFacetCheckboxes(page); @@ -75,7 +74,7 @@ test.describe('#facetNote URL deep-link visibility (issue #234 step 1)', () => { // syncFacetNote() must run to flip #facetNote visible. const encoded = encodeURIComponent(materialUri); await page.goto( - `${EXPLORER_PATH}?material=${encoded}#v=1&lat=${LAT}&lng=${LNG}&alt=${ALT_CLUSTER}` + explorerUrl(`?material=${encoded}#v=1&lat=${LAT}&lng=${LNG}&alt=${ALT_CLUSTER}`) ); await waitForMode(page, 'cluster'); await waitForFacetCheckboxes(page); @@ -112,7 +111,7 @@ test.describe('#facetNote URL deep-link visibility (issue #234 step 1)', () => { // Negative control: arriving with no facet params must keep the note // hidden. Guards against an over-eager `syncFacetNote()` that flips // visibility independent of `hasFacetFilters()`. - await page.goto(`${EXPLORER_PATH}#v=1&lat=${LAT}&lng=${LNG}&alt=${ALT_CLUSTER}`); + await page.goto(explorerUrl(`#v=1&lat=${LAT}&lng=${LNG}&alt=${ALT_CLUSTER}`)); await waitForMode(page, 'cluster'); await waitForFacetCheckboxes(page); diff --git a/tests/playwright/helpers/url.js b/tests/playwright/helpers/url.js new file mode 100644 index 0000000..8cfdc9a --- /dev/null +++ b/tests/playwright/helpers/url.js @@ -0,0 +1,57 @@ +// URL helpers for the Playwright suite. +// +// Why this exists: all specs in tests/playwright/ navigate to rendered +// pages on the site (explorer.html, tutorials/*.html, etc.) and accept +// a TEST_URL env var so they can run against: +// +// - the dev / CI smoke gate's static server (`http://localhost:5860`) +// - production (`https://isamples.org`) +// - the fork-staging GitHub Pages URL +// (`https://rdhyee.github.io/isamplesorg.github.io/`, with sub-path) +// +// The historical hand-rolled patterns had two latent gotchas: +// +// 1. Some specs called `page.goto('/explorer.html')` and relied on +// Playwright's `baseURL` config. Playwright resolves an absolute +// path against the ORIGIN of baseURL, so a sub-path TEST_URL like +// `https://rdhyee.github.io/isamplesorg.github.io/` silently +// resolves to `https://rdhyee.github.io/explorer.html` (404). +// +// 2. Specs that string-concat `${BASE_URL}${EXPLORER_PATH}` work on +// sub-path TEST_URLs only when TEST_URL has no trailing slash. +// A trailing slash produces `//explorer.html` — tolerated by some +// servers, but not the intended URL shape. +// +// `siteUrl()` / `explorerUrl()` below collapse both gotchas: strip the +// trailing slash from BASE_URL once, then string-concat a leading-slash +// path. Same result whether TEST_URL is given with or without a trailing +// slash, and the sub-path is preserved on fork-staging. +// +// History: extracted 2026-05-27 from PR #238 (Codex round-1 review of +// the facet-viewport.spec.js URL fix recommended a shared helper rather +// than duplicating the fix into every spec). + +const BASE_URL = (process.env.TEST_URL || 'http://localhost:5860').replace(/\/$/, ''); + +/** Build a URL on the rendered site. + * + * @param {string} path Path on the site, should start with `/` + * (e.g. `/explorer.html`, `/tutorials/parquet_cesium.html`). + * @param {string} [suffix] Optional hash or query suffix appended as-is + * (e.g. `#v=1&lat=0&lng=0&alt=15000000`). + * @returns {string} Full URL ready for `page.goto()`. + */ +function siteUrl(path, suffix = '') { + return `${BASE_URL}${path}${suffix}`; +} + +/** Convenience for the most common case: a URL on the explorer page. + * + * @param {string} [suffix] Optional hash or query suffix. + * @returns {string} Full URL ready for `page.goto()`. + */ +function explorerUrl(suffix = '') { + return siteUrl('/explorer.html', suffix); +} + +module.exports = { BASE_URL, siteUrl, explorerUrl }; diff --git a/tests/playwright/search-real-count.spec.js b/tests/playwright/search-real-count.spec.js index 19e070e..6f8ce6a 100644 --- a/tests/playwright/search-real-count.spec.js +++ b/tests/playwright/search-real-count.spec.js @@ -26,8 +26,7 @@ * unchanged from pre-#232 ("N results for term" with no "of M"). */ const { test, expect } = require('@playwright/test'); - -const EXPLORER_PATH = '/explorer.html'; +const { explorerUrl } = require('./helpers/url'); /** Wait until the explorer has rendered the search input. Boot sequence: * phase1 (viewer + cluster cache) → facetFilters → search input wiring. @@ -77,7 +76,7 @@ test.describe('Search real-count display (#232 / #234 step 2)', () => { test('cap-hit search shows "N of M results" + structured log carries total_count', async ({ page }) => { const logs = attachSearchLogCollector(page); - await page.goto(EXPLORER_PATH); + await page.goto(explorerUrl()); await waitForSearchReady(page); await runSearch(page, 'pottery'); @@ -148,7 +147,7 @@ test.describe('Search real-count display (#232 / #234 step 2)', () => { // `finally`, this test fails. Both round-1 (predicate snapshot) // and round-2 (telemetry snapshot) fixes are exercised. const logs = attachSearchLogCollector(page); - await page.goto(EXPLORER_PATH); + await page.goto(explorerUrl()); await waitForSearchReady(page); // Sanity: no facet active at search-fire time. @@ -222,7 +221,7 @@ test.describe('Search real-count display (#232 / #234 step 2)', () => { test('no-results search short-circuits without COUNT (total_count remains null)', async ({ page }) => { const logs = attachSearchLogCollector(page); - await page.goto(EXPLORER_PATH); + await page.goto(explorerUrl()); await waitForSearchReady(page); await runSearch(page, 'xyzzyqqqplugh'); diff --git a/tests/playwright/url-roundtrip.spec.js b/tests/playwright/url-roundtrip.spec.js index f35cdd3..55bfdb6 100644 --- a/tests/playwright/url-roundtrip.spec.js +++ b/tests/playwright/url-roundtrip.spec.js @@ -24,8 +24,7 @@ */ const { test, expect } = require('@playwright/test'); - -const EXPLORER_PATH = '/explorer.html'; +const { explorerUrl } = require('./helpers/url'); // Cyprus / Polis — confirmed dense region (~23k samples), used in #206/#210. const LAT = 34.9957; @@ -145,7 +144,7 @@ test.describe('Explorer URL state round-trip (issue #209)', () => { test('deep-link with mode=point enters point mode (Bug B from #203)', async ({ page }) => { - const url = `${EXPLORER_PATH}#v=1&lat=${LAT}&lng=${LNG}&alt=${ALT_POINT_DEEP}&mode=point`; + const url = explorerUrl(`#v=1&lat=${LAT}&lng=${LNG}&alt=${ALT_POINT_DEEP}&mode=point`); await page.goto(url); await waitForMode(page, 'point'); await waitForPointModeSettled(page); @@ -156,7 +155,7 @@ test.describe('Explorer URL state round-trip (issue #209)', () => { test('deep-link with low altitude AND no mode=point still enters point mode (#207 item 4)', async ({ page }) => { // No `mode=point` in URL. Boot should enter point based on altitude alone. - const url = `${EXPLORER_PATH}#v=1&lat=${LAT}&lng=${LNG}&alt=${ALT_POINT}`; + const url = explorerUrl(`#v=1&lat=${LAT}&lng=${LNG}&alt=${ALT_POINT}`); await page.goto(url); // Wait for the settled point-mode done message — more reliable than // waitForMode alone, which can match a transient mode flip during boot @@ -169,7 +168,7 @@ test.describe('Explorer URL state round-trip (issue #209)', () => { test('sub-threshold pan updates URL hash via moveEnd (#205)', async ({ page }) => { // Start at a settled point-mode view. - const url = `${EXPLORER_PATH}#v=1&lat=${LAT}&lng=${LNG}&alt=${ALT_POINT_DEEP}&mode=point`; + const url = explorerUrl(`#v=1&lat=${LAT}&lng=${LNG}&alt=${ALT_POINT_DEEP}&mode=point`); await page.goto(url); await waitForMode(page, 'point'); await waitForPointModeSettled(page); @@ -205,7 +204,7 @@ test.describe('Explorer URL state round-trip (issue #209)', () => { let ctxB; try { const pageA = await ctxA.newPage(); - await pageA.goto(`${EXPLORER_PATH}#v=1&lat=${LAT}&lng=${LNG}&alt=${ALT_POINT_DEEP}&mode=point`); + await pageA.goto(explorerUrl(`#v=1&lat=${LAT}&lng=${LNG}&alt=${ALT_POINT_DEEP}&mode=point`)); await waitForMode(pageA, 'point'); await waitForPointModeSettled(pageA); @@ -266,7 +265,7 @@ test.describe('Explorer URL state round-trip (issue #209)', () => { // by then the handler has executed past line 2272 (which writes a new // non-null selectedH3) AND reached line 2285 (the null-clear branch). const invalidH3 = '0deadbeefffffff'; // 15 chars but not a real h3 cell - await page.goto(`${EXPLORER_PATH}#v=1&lat=${LAT}&lng=${LNG}&alt=${ALT_CLUSTER}&h3=${invalidH3}`); + await page.goto(explorerUrl(`#v=1&lat=${LAT}&lng=${LNG}&alt=${ALT_CLUSTER}&h3=${invalidH3}`)); await waitForMode(page, 'cluster'); // `_globeState.mode` is initialized at explorer.qmd:871, well before the // hashchange listener is registered at line 2210; wait for boot settle.