From 24421d99e23f54d51d34a952f562a3ab6d86b386 Mon Sep 17 00:00:00 2001 From: Avi Fenesh Date: Wed, 25 Feb 2026 14:37:51 +0200 Subject: [PATCH 1/6] feat(goto): add --wait-loaded flag for async-rendered content Add waitForLoaded function to browser-launcher that combines three phases: network idle + DOM stability (via existing waitForStable), loading indicator absence detection (spinners, skeletons, progress bars, aria-busy elements), and a final 300ms DOM quiet period. Wire --wait-loaded into all four goto snapshot paths, register as boolean flag, expose in macro helpers, and update help text. Closes #68 --- README.md | 3 +- scripts/browser-launcher.js | 62 ++++++++++- scripts/web-ctl.js | 29 ++++- skills/web-browse/SKILL.md | 6 +- tests/web-ctl-actions.test.js | 199 ++++++++++++++++++++++++++++++++++ 5 files changed, 291 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index f0adcdd..421d587 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ web-ctl session end github | Action | Usage | Returns | |--------|-------|---------| -| `goto` | `run goto [--no-auth-wall-detect] [--ensure-auth]` | `{ url, status, authWallDetected, checkpointCompleted, ensureAuthCompleted, snapshot }` | +| `goto` | `run goto [--no-auth-wall-detect] [--ensure-auth] [--wait-loaded]` | `{ url, status, authWallDetected, checkpointCompleted, ensureAuthCompleted, waitLoaded, snapshot }` | | `snapshot` | `run snapshot` | `{ url, snapshot }` | | `click` | `run click [--wait-stable]` | `{ url, clicked, snapshot }` | | `click-wait` | `run click-wait [--timeout]` | `{ url, clicked, settled, snapshot }` | @@ -174,6 +174,7 @@ This eliminates the common click-snapshot-check loop that wastes agent turns on | `--allow-evaluate` | `evaluate` | Required safety flag for JS execution | | `--no-auth-wall-detect` | `goto` | Disable automatic auth wall detection and checkpoint opening | | `--ensure-auth` | `goto` | Poll for auth completion instead of timed checkpoint; overrides `--no-auth-wall-detect` | +| `--wait-loaded` | `goto` | Wait for async content to finish rendering (network idle + loading indicator absence + DOM quiet) | | `--snapshot-depth ` | Any action with snapshot | Limit ARIA tree depth (e.g. 3 for top 3 levels) | | `--snapshot-selector ` | Any action with snapshot | Scope snapshot to a DOM subtree | | `--snapshot-max-lines ` | Any action with snapshot | Truncate snapshot to N lines | diff --git a/scripts/browser-launcher.js b/scripts/browser-launcher.js index 144c92a..fa227d7 100644 --- a/scripts/browser-launcher.js +++ b/scripts/browser-launcher.js @@ -195,4 +195,64 @@ async function waitForStable(page, { timeout = 5000 } = {}) { }), DOM_QUIET_MS); } -module.exports = { launchBrowser, closeBrowser, randomDelay, isWSL, canLaunchHeaded, waitForStable }; +/** + * Wait for async-rendered content to finish loading. + * Combines network idle, DOM stability, and loading indicator absence detection. + * + * @param {import('playwright').Page} page + * @param {object} options - { timeout: number (ms, default 15000) } + */ +async function waitForLoaded(page, { timeout = 15000 } = {}) { + // Phase 1: Network idle + DOM stability (reuse existing) + await waitForStable(page, { timeout }); + + // Phase 2: Wait for loading indicators to disappear + const deadline = Date.now() + timeout; + const POLL_MS = 200; + await page.evaluate(({ pollMs, deadlineTs }) => new Promise(resolve => { + const SELECTORS = [ + '[role="progressbar"]', '[aria-busy="true"]', + '.loading', '.spinner', '.skeleton', + '[class*="loading"]', '[class*="spinner"]', '[class*="skeleton"]' + ]; + const TEXT_RE = /^\s*(loading|please wait|crunching)\.*\s*$/i; + + function hasLoadingIndicators() { + for (const sel of SELECTORS) { + const el = document.querySelector(sel); + if (el && el.offsetParent !== null) return true; + } + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); + while (walker.nextNode()) { + if (TEXT_RE.test(walker.currentNode.textContent)) return true; + } + return false; + } + + function poll() { + if (!hasLoadingIndicators() || Date.now() >= deadlineTs) { + resolve(); + } else { + setTimeout(poll, pollMs); + } + } + poll(); + }), { pollMs: POLL_MS, deadlineTs: deadline }); + + // Phase 3: Short DOM quiet wait to catch final renders + const remaining = Math.max(deadline - Date.now(), 0); + if (remaining > 300) { + const DOM_QUIET_MS = 300; + await page.evaluate((ms) => new Promise(resolve => { + let timer = setTimeout(resolve, ms); + const observer = new MutationObserver(() => { + clearTimeout(timer); + timer = setTimeout(resolve, ms); + }); + observer.observe(document.body, { childList: true, subtree: true, attributes: true }); + setTimeout(() => { observer.disconnect(); resolve(); }, ms * 5); + }), DOM_QUIET_MS); + } +} + +module.exports = { launchBrowser, closeBrowser, randomDelay, isWSL, canLaunchHeaded, waitForStable, waitForLoaded }; diff --git a/scripts/web-ctl.js b/scripts/web-ctl.js index ae159a1..ef85f28 100755 --- a/scripts/web-ctl.js +++ b/scripts/web-ctl.js @@ -2,7 +2,7 @@ 'use strict'; const sessionStore = require('./session-store'); -const { launchBrowser, closeBrowser, randomDelay, waitForStable, canLaunchHeaded } = require('./browser-launcher'); +const { launchBrowser, closeBrowser, randomDelay, waitForStable, waitForLoaded, canLaunchHeaded } = require('./browser-launcher'); const { detectAuthWall } = require('./auth-wall-detect'); const { runAuthFlow } = require('./auth-flow'); const { checkAuthSuccess } = require('./auth-check'); @@ -21,7 +21,7 @@ const BOOLEAN_FLAGS = new Set([ '--allow-evaluate', '--no-snapshot', '--wait-stable', '--vnc', '--exact', '--accept', '--submit', '--dismiss', '--auto', '--snapshot-collapse', '--snapshot-text-only', '--snapshot-compact', - '--snapshot-full', '--no-auth-wall-detect', '--ensure-auth', + '--snapshot-full', '--no-auth-wall-detect', '--ensure-auth', '--wait-loaded', ]); function validateSessionName(name) { @@ -986,8 +986,13 @@ async function runAction(sessionName, action, actionArgs, opts) { context = headlessBrowser.context; page = headlessBrowser.page; await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); + if (opts.waitLoaded) { + const loadedTimeout = opts.timeout ? parseInt(opts.timeout, 10) : 15000; + await waitForLoaded(page, { timeout: loadedTimeout }); + } const snapshot = await getSnapshot(page, opts); result = { url: page.url(), authWallDetected: true, ensureAuthCompleted: true, + ...(opts.waitLoaded && { waitLoaded: true }), ...(snapshot != null && { snapshot }) }; } catch (relaunchErr) { result = { url, authWallDetected: true, ensureAuthCompleted: true, @@ -1007,8 +1012,13 @@ async function runAction(sessionName, action, actionArgs, opts) { } else { console.warn('[WARN] Checkpoint open for ' + (ckTimeout / 1000) + 's'); await new Promise(resolve => setTimeout(resolve, ckTimeout)); + if (opts.waitLoaded) { + const loadedTimeout = opts.timeout ? parseInt(opts.timeout, 10) : 15000; + await waitForLoaded(page, { timeout: loadedTimeout }); + } const snapshot = await getSnapshot(page, opts); result = { url: page.url(), authWallDetected: true, checkpointCompleted: true, + ...(opts.waitLoaded && { waitLoaded: true }), ...(snapshot != null && { snapshot }) }; break; } @@ -1018,16 +1028,25 @@ async function runAction(sessionName, action, actionArgs, opts) { message: 'Auth wall detected but no display available for headed browser.' }; break; } + if (opts.waitLoaded) { + const loadedTimeout = opts.timeout ? parseInt(opts.timeout, 10) : 15000; + await waitForLoaded(page, { timeout: loadedTimeout }); + } const snapshot = await getSnapshot(page, opts); result = { url: page.url(), authWallDetected: true, checkpointCompleted: false, message: 'Auth wall detected but no display for headed checkpoint.', + ...(opts.waitLoaded && { waitLoaded: true }), ...(snapshot != null && { snapshot }) }; break; } } } + if (opts.waitLoaded) { + const loadedTimeout = opts.timeout ? parseInt(opts.timeout, 10) : 15000; + await waitForLoaded(page, { timeout: loadedTimeout }); + } const snapshot = await getSnapshot(page, opts); - result = { url: page.url(), status: response ? response.status() : null, ...(snapshot != null && { snapshot }) }; + result = { url: page.url(), status: response ? response.status() : null, ...(opts.waitLoaded && { waitLoaded: true }), ...(snapshot != null && { snapshot }) }; break; } @@ -1167,7 +1186,7 @@ async function runAction(sessionName, action, actionArgs, opts) { default: { const macro = macros[action]; if (macro) { - const helpers = { resolveSelector, waitForStable, randomDelay, getSnapshot: (page) => getSnapshot(page, opts), sanitizeWebContent }; + const helpers = { resolveSelector, waitForStable, waitForLoaded, randomDelay, getSnapshot: (page) => getSnapshot(page, opts), sanitizeWebContent }; result = await macro(page, actionArgs, opts, helpers); // Clean up null snapshot from macros when --no-snapshot is active if (result && result.snapshot == null) delete result.snapshot; @@ -1251,6 +1270,8 @@ Session commands: Run actions: goto Navigate to URL [--ensure-auth] Poll for auth completion instead of timed checkpoint + [--wait-loaded] Wait for async content to finish rendering + [--timeout ] Wait timeout (default: 15000) snapshot Get accessibility tree click Click element [--wait-stable] Wait for DOM + network to settle after click diff --git a/skills/web-browse/SKILL.md b/skills/web-browse/SKILL.md index 32c12bb..0788ad0 100644 --- a/skills/web-browse/SKILL.md +++ b/skills/web-browse/SKILL.md @@ -46,7 +46,7 @@ Safe practice: always double-quote URL arguments. ### goto - Navigate to URL ```bash -node ${PLUGIN_ROOT}/scripts/web-ctl.js run goto [--no-auth-wall-detect] [--ensure-auth] +node ${PLUGIN_ROOT}/scripts/web-ctl.js run goto [--no-auth-wall-detect] [--ensure-auth] [--wait-loaded] ``` Navigates to a URL and automatically detects authentication walls using a three-heuristic detection system: @@ -60,7 +60,9 @@ Use `--no-auth-wall-detect` to disable this automatic detection and skip the che Use `--ensure-auth` to actively poll for authentication completion instead of a timed checkpoint. When set, the headed browser polls with `checkAuthSuccess` at 2-second intervals using the URL-change heuristic. On success, the headed browser closes, a headless browser relaunches, and the original URL is loaded. On timeout, returns `ensureAuthCompleted: false`. This flag overrides `--no-auth-wall-detect`. -Returns: `{ url, status, authWallDetected, checkpointCompleted, ensureAuthCompleted, snapshot }` +Use `--wait-loaded` to wait for async-rendered content to finish loading before taking the snapshot. This combines network idle, DOM stability, loading indicator absence detection (spinners, skeletons, progress bars, aria-busy), and a final DOM quiet period. Use `--timeout ` to set the wait timeout (default: 15000ms). Ideal for SPAs and pages that render content after the initial page load. + +Returns: `{ url, status, authWallDetected, checkpointCompleted, ensureAuthCompleted, waitLoaded, snapshot }` ### snapshot - Get Accessibility Tree diff --git a/tests/web-ctl-actions.test.js b/tests/web-ctl-actions.test.js index f367d5d..419ab74 100644 --- a/tests/web-ctl-actions.test.js +++ b/tests/web-ctl-actions.test.js @@ -997,3 +997,202 @@ describe('--ensure-auth CLI integration', () => { '--ensure-auth should not cause an invalid flag error'); }); }); + +describe('waitForLoaded export', () => { + it('is exported from browser-launcher', () => { + const launcher = require('../scripts/browser-launcher'); + assert.equal(typeof launcher.waitForLoaded, 'function'); + }); +}); + +describe('--wait-loaded flag in web-ctl source', () => { + const fs = require('fs'); + const path = require('path'); + const webCtlSource = fs.readFileSync( + path.join(__dirname, '..', 'scripts', 'web-ctl.js'), + 'utf8' + ); + + it('BOOLEAN_FLAGS includes --wait-loaded', () => { + assert.ok( + webCtlSource.includes("'--wait-loaded'"), + '--wait-loaded should be in BOOLEAN_FLAGS' + ); + }); + + it('help text contains --wait-loaded flag', () => { + assert.ok( + webCtlSource.includes('--wait-loaded'), + 'help text should document --wait-loaded' + ); + }); + + it('goto case references opts.waitLoaded', () => { + assert.ok( + webCtlSource.includes('opts.waitLoaded'), + 'goto case should check waitLoaded flag' + ); + }); + + it('waitForLoaded is imported from browser-launcher', () => { + assert.ok( + webCtlSource.includes('waitForLoaded'), + 'web-ctl.js should import waitForLoaded' + ); + }); + + it('result includes waitLoaded conditional spread', () => { + assert.ok( + webCtlSource.includes("...(opts.waitLoaded && { waitLoaded: true })"), + 'result should conditionally include waitLoaded' + ); + }); + + it('waitForLoaded is exposed in macro helpers', () => { + assert.ok( + webCtlSource.includes('waitForLoaded,'), + 'macro helpers object should include waitForLoaded' + ); + }); +}); + +describe('--wait-loaded flag parsing', () => { + const BOOLEAN_FLAGS = new Set([ + '--allow-evaluate', '--no-snapshot', '--wait-stable', '--vnc', + '--exact', '--accept', '--submit', '--dismiss', '--auto', + '--snapshot-collapse', '--snapshot-text-only', '--snapshot-compact', + '--snapshot-full', '--no-auth-wall-detect', '--ensure-auth', '--wait-loaded', + ]); + + function parseOptions(args) { + const opts = {}; + for (let i = 0; i < args.length; i++) { + if (args[i].startsWith('--')) { + const key = args[i].slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + const next = args[i + 1]; + if (next && !next.startsWith('--') && !BOOLEAN_FLAGS.has(args[i])) { + opts[key] = next; + i++; + } else { + opts[key] = true; + } + } + } + return opts; + } + + it('--wait-loaded parses as boolean true', () => { + const opts = parseOptions(['--wait-loaded']); + assert.equal(opts.waitLoaded, true); + }); + + it('--wait-loaded does not consume next positional arg', () => { + const opts = parseOptions(['--wait-loaded', 'https://example.com']); + assert.equal(opts.waitLoaded, true); + assert.equal(opts['https://example.com'], undefined); + }); + + it('--wait-loaded works alongside --timeout', () => { + const opts = parseOptions(['--wait-loaded', '--timeout', '20000']); + assert.equal(opts.waitLoaded, true); + assert.equal(opts.timeout, '20000'); + }); + + it('--wait-loaded works alongside --ensure-auth', () => { + const opts = parseOptions(['--wait-loaded', '--ensure-auth']); + assert.equal(opts.waitLoaded, true); + assert.equal(opts.ensureAuth, true); + }); +}); + +describe('waitForLoaded implementation details', () => { + const fs = require('fs'); + const path = require('path'); + const launcherSource = fs.readFileSync( + path.join(__dirname, '..', 'scripts', 'browser-launcher.js'), + 'utf8' + ); + + it('waitForLoaded calls waitForStable', () => { + const fnStart = launcherSource.indexOf('async function waitForLoaded'); + const fnBody = launcherSource.slice(fnStart, fnStart + 2000); + assert.ok( + fnBody.includes('await waitForStable(page, { timeout })'), + 'waitForLoaded should delegate to waitForStable' + ); + }); + + it('checks for loading indicator selectors', () => { + assert.ok(launcherSource.includes('[role="progressbar"]'), 'should check progressbar role'); + assert.ok(launcherSource.includes('.spinner'), 'should check .spinner class'); + assert.ok(launcherSource.includes('.skeleton'), 'should check .skeleton class'); + assert.ok(launcherSource.includes('.loading'), 'should check .loading class'); + }); + + it('has 15000ms default timeout', () => { + assert.ok( + launcherSource.includes('timeout = 15000'), + 'default timeout should be 15000ms' + ); + }); + + it('checks aria-busy attribute', () => { + assert.ok( + launcherSource.includes('[aria-busy="true"]'), + 'should check aria-busy attribute' + ); + }); +}); + +describe('--wait-loaded CLI integration', () => { + const { beforeEach, afterEach } = require('node:test'); + const fs = require('fs'); + const path = require('path'); + const os = require('os'); + const { execFileSync } = require('child_process'); + + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'web-ctl-wait-loaded-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function runCliSafe(...args) { + try { + const result = execFileSync(process.execPath, [ + path.join(__dirname, '..', 'scripts', 'web-ctl.js'), + ...args + ], { + env: { ...process.env, AI_STATE_DIR: tmpDir }, + encoding: 'utf8', + timeout: 30000 + }); + return JSON.parse(result); + } catch (err) { + if (err.stdout) { + try { return JSON.parse(err.stdout); } catch { /* fall through */ } + } + throw err; + } + } + + it('goto with --wait-loaded against example.com runs without error', () => { + const result = runCliSafe('run', 'loadtest', 'goto', 'https://example.com', '--wait-loaded'); + assert.ok(result, 'should return a result'); + assert.notEqual(result.error, 'invalid_flag', + '--wait-loaded should not cause an invalid flag error'); + }); + + it('result includes waitLoaded: true', () => { + const result = runCliSafe('run', 'loadtest2', 'goto', 'https://example.com', '--wait-loaded'); + assert.ok(result, 'should return a result'); + if (result.ok && result.result) { + assert.equal(result.result.waitLoaded, true, + 'result should include waitLoaded: true'); + } + }); +}); From 96283a09b49e0476c5b4d66a1f6fdda12610f791 Mon Sep 17 00:00:00 2001 From: Avi Fenesh Date: Wed, 25 Feb 2026 14:41:16 +0200 Subject: [PATCH 2/6] fix: clean up AI slop in waitForLoaded --- scripts/browser-launcher.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/scripts/browser-launcher.js b/scripts/browser-launcher.js index fa227d7..b83cd54 100644 --- a/scripts/browser-launcher.js +++ b/scripts/browser-launcher.js @@ -203,10 +203,8 @@ async function waitForStable(page, { timeout = 5000 } = {}) { * @param {object} options - { timeout: number (ms, default 15000) } */ async function waitForLoaded(page, { timeout = 15000 } = {}) { - // Phase 1: Network idle + DOM stability (reuse existing) await waitForStable(page, { timeout }); - // Phase 2: Wait for loading indicators to disappear const deadline = Date.now() + timeout; const POLL_MS = 200; await page.evaluate(({ pollMs, deadlineTs }) => new Promise(resolve => { @@ -239,7 +237,6 @@ async function waitForLoaded(page, { timeout = 15000 } = {}) { poll(); }), { pollMs: POLL_MS, deadlineTs: deadline }); - // Phase 3: Short DOM quiet wait to catch final renders const remaining = Math.max(deadline - Date.now(), 0); if (remaining > 300) { const DOM_QUIET_MS = 300; From 8ba09df379c8ec7d6ce59cc32b201b0952fcd96c Mon Sep 17 00:00:00 2001 From: Avi Fenesh Date: Wed, 25 Feb 2026 14:46:04 +0200 Subject: [PATCH 3/6] fix: harden waitForLoaded from review findings - Guard against null document.body in indicator check and MutationObserver - Limit TreeWalker to 5000 nodes to prevent DoS on huge DOMs - Use querySelectorAll with combined selectors for efficient polling - Ensure MutationObserver disconnect in all resolve paths - Parse loadedTimeout once at top of goto case (DRY) - Add tests for null guard, node limit, querySelectorAll, DRY timeout --- scripts/browser-launcher.js | 18 +++++++++++------- scripts/web-ctl.js | 5 +---- tests/web-ctl-actions.test.js | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/scripts/browser-launcher.js b/scripts/browser-launcher.js index b83cd54..df53786 100644 --- a/scripts/browser-launcher.js +++ b/scripts/browser-launcher.js @@ -216,12 +216,14 @@ async function waitForLoaded(page, { timeout = 15000 } = {}) { const TEXT_RE = /^\s*(loading|please wait|crunching)\.*\s*$/i; function hasLoadingIndicators() { - for (const sel of SELECTORS) { - const el = document.querySelector(sel); - if (el && el.offsetParent !== null) return true; + if (!document.body) return false; + const match = document.body.querySelectorAll(SELECTORS.join(',')); + for (const el of match) { + if (el.offsetParent !== null) return true; } const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); - while (walker.nextNode()) { + let nodeCount = 0; + while (walker.nextNode() && nodeCount++ < 5000) { if (TEXT_RE.test(walker.currentNode.textContent)) return true; } return false; @@ -241,13 +243,15 @@ async function waitForLoaded(page, { timeout = 15000 } = {}) { if (remaining > 300) { const DOM_QUIET_MS = 300; await page.evaluate((ms) => new Promise(resolve => { - let timer = setTimeout(resolve, ms); + if (!document.body) { resolve(); return; } + const done = () => { observer.disconnect(); resolve(); }; + let timer = setTimeout(done, ms); const observer = new MutationObserver(() => { clearTimeout(timer); - timer = setTimeout(resolve, ms); + timer = setTimeout(done, ms); }); observer.observe(document.body, { childList: true, subtree: true, attributes: true }); - setTimeout(() => { observer.disconnect(); resolve(); }, ms * 5); + setTimeout(done, ms * 5); }), DOM_QUIET_MS); } } diff --git a/scripts/web-ctl.js b/scripts/web-ctl.js index ef85f28..5266685 100755 --- a/scripts/web-ctl.js +++ b/scripts/web-ctl.js @@ -950,6 +950,7 @@ async function runAction(sessionName, action, actionArgs, opts) { const url = actionArgs[0]; if (!url) throw new Error('URL required: run goto '); validateUrl(url); + const loadedTimeout = opts.timeout ? parseInt(opts.timeout, 10) : 15000; const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); if (opts.ensureAuth || !opts.noAuthWallDetect) { const detection = await detectAuthWall(page, context, url); @@ -987,7 +988,6 @@ async function runAction(sessionName, action, actionArgs, opts) { page = headlessBrowser.page; await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); if (opts.waitLoaded) { - const loadedTimeout = opts.timeout ? parseInt(opts.timeout, 10) : 15000; await waitForLoaded(page, { timeout: loadedTimeout }); } const snapshot = await getSnapshot(page, opts); @@ -1013,7 +1013,6 @@ async function runAction(sessionName, action, actionArgs, opts) { console.warn('[WARN] Checkpoint open for ' + (ckTimeout / 1000) + 's'); await new Promise(resolve => setTimeout(resolve, ckTimeout)); if (opts.waitLoaded) { - const loadedTimeout = opts.timeout ? parseInt(opts.timeout, 10) : 15000; await waitForLoaded(page, { timeout: loadedTimeout }); } const snapshot = await getSnapshot(page, opts); @@ -1029,7 +1028,6 @@ async function runAction(sessionName, action, actionArgs, opts) { break; } if (opts.waitLoaded) { - const loadedTimeout = opts.timeout ? parseInt(opts.timeout, 10) : 15000; await waitForLoaded(page, { timeout: loadedTimeout }); } const snapshot = await getSnapshot(page, opts); @@ -1042,7 +1040,6 @@ async function runAction(sessionName, action, actionArgs, opts) { } } if (opts.waitLoaded) { - const loadedTimeout = opts.timeout ? parseInt(opts.timeout, 10) : 15000; await waitForLoaded(page, { timeout: loadedTimeout }); } const snapshot = await getSnapshot(page, opts); diff --git a/tests/web-ctl-actions.test.js b/tests/web-ctl-actions.test.js index 419ab74..89cc922 100644 --- a/tests/web-ctl-actions.test.js +++ b/tests/web-ctl-actions.test.js @@ -1054,6 +1054,14 @@ describe('--wait-loaded flag in web-ctl source', () => { 'macro helpers object should include waitForLoaded' ); }); + + it('goto case declares loadedTimeout once at top', () => { + const gotoStart = webCtlSource.indexOf("case 'goto':"); + const gotoEnd = webCtlSource.indexOf("case 'snapshot':", gotoStart); + const gotoBody = webCtlSource.slice(gotoStart, gotoEnd); + const matches = gotoBody.match(/const loadedTimeout/g) || []; + assert.equal(matches.length, 1, 'loadedTimeout should be declared once at top of goto case'); + }); }); describe('--wait-loaded flag parsing', () => { @@ -1142,6 +1150,33 @@ describe('waitForLoaded implementation details', () => { 'should check aria-busy attribute' ); }); + + it('guards against null document.body', () => { + const fnStart = launcherSource.indexOf('async function waitForLoaded'); + const fnBody = launcherSource.slice(fnStart, fnStart + 2000); + assert.ok( + fnBody.includes('!document.body'), + 'should check for null document.body' + ); + }); + + it('limits TreeWalker traversal depth', () => { + const fnStart = launcherSource.indexOf('async function waitForLoaded'); + const fnBody = launcherSource.slice(fnStart, fnStart + 2000); + assert.ok( + fnBody.includes('nodeCount') && fnBody.includes('5000'), + 'should limit tree walker to prevent DoS on large DOMs' + ); + }); + + it('uses querySelectorAll for combined selector check', () => { + const fnStart = launcherSource.indexOf('async function waitForLoaded'); + const fnBody = launcherSource.slice(fnStart, fnStart + 2000); + assert.ok( + fnBody.includes('querySelectorAll'), + 'should use querySelectorAll for efficient combined selector matching' + ); + }); }); describe('--wait-loaded CLI integration', () => { From e42efccb13fc031d748b48e7a088bfe70d5ef6a1 Mon Sep 17 00:00:00 2001 From: Avi Fenesh Date: Wed, 25 Feb 2026 14:50:04 +0200 Subject: [PATCH 4/6] docs: add changelog entry for --wait-loaded flag --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcf257f..c22c2ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ - `verifyUrl` and `verifySelector` provider fields for built-in providers (github, gitlab, microsoft) to automatically verify API/dashboard access after login - `--min-wait ` flag for `session auth` to configure grace period before auth success polling starts (default: 5 seconds, clamped to 0-300) - `--max-field-length ` flag for `extract` macro to configure maximum characters per extracted field (default: 500, max: 2000) +- `--wait-loaded` flag for goto action - waits for async-rendered content to finish loading before taking the snapshot. Combines network idle, DOM stability, and loading indicator absence detection (spinners, skeletons, progress bars, aria-busy). Use `--timeout ` to set wait timeout (default: 15000ms) ### Fixed - Smart default snapshot scoping now includes complementary ARIA landmarks (`