From 95208377060a39cabf31cd72898bdc66d5083d9d Mon Sep 17 00:00:00 2001 From: Avi Fenesh Date: Wed, 25 Feb 2026 01:41:31 +0200 Subject: [PATCH 1/6] feat(goto): add --ensure-auth flag for automated auth wall handling Register --ensure-auth in BOOLEAN_FLAGS and modify the goto auth wall detection block. When --ensure-auth is set, polls with checkAuthSuccess at 2s intervals instead of a static setTimeout checkpoint. On success, closes the headed browser, relaunches headless, and navigates to the original URL. On timeout or no display, returns ensureAuthCompleted: false with a descriptive message. The flag overrides --no-auth-wall-detect so auth detection runs even when wall detection is disabled. --- scripts/web-ctl.js | 56 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 48 insertions(+), 8 deletions(-) diff --git a/scripts/web-ctl.js b/scripts/web-ctl.js index 9417d9d..28b8e88 100755 --- a/scripts/web-ctl.js +++ b/scripts/web-ctl.js @@ -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', + '--snapshot-full', '--no-auth-wall-detect', '--ensure-auth', ]); function validateSessionName(name) { @@ -951,7 +951,7 @@ async function runAction(sessionName, action, actionArgs, opts) { if (!url) throw new Error('URL required: run goto '); validateUrl(url); const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); - if (!opts.noAuthWallDetect) { + if (opts.ensureAuth || !opts.noAuthWallDetect) { const detection = await detectAuthWall(page, context, url); if (detection.detected) { console.warn('[WARN] Auth wall detected for ' + new URL(url).hostname); @@ -963,13 +963,53 @@ async function runAction(sessionName, action, actionArgs, opts) { page = headedBrowser.page; await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); const ckTimeout = Math.min(opts.timeout ? parseInt(opts.timeout, 10) : 120, 3600) * 1000; - console.warn('[WARN] Checkpoint open for ' + (ckTimeout / 1000) + 's'); - await new Promise(resolve => setTimeout(resolve, ckTimeout)); - const snapshot = await getSnapshot(page, opts); - result = { url: page.url(), authWallDetected: true, checkpointCompleted: true, - ...(snapshot != null && { snapshot }) }; - break; + if (opts.ensureAuth) { + console.warn('[WARN] Waiting for auth completion (' + (ckTimeout / 1000) + 's timeout)'); + const pollInterval = 2000; + const startTime = Date.now(); + let authCompleted = false; + while (Date.now() - startTime < ckTimeout) { + const authResult = await checkAuthSuccess(page, context, url, { loginUrl: url }); + if (authResult.success) { + authCompleted = true; + break; + } + await new Promise(resolve => setTimeout(resolve, pollInterval)); + } + if (authCompleted) { + const originalUrl = url; + await closeBrowser(sessionName, context); + const headlessBrowser = await launchBrowser(sessionName, { headless: true }); + context = headlessBrowser.context; + page = headlessBrowser.page; + await page.goto(originalUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }); + const snapshot = await getSnapshot(page, opts); + result = { url: page.url(), authWallDetected: true, ensureAuthCompleted: true, + ...(snapshot != null && { snapshot }) }; + break; + } else { + await closeBrowser(sessionName, context); + const headlessBrowser = await launchBrowser(sessionName, { headless: true }); + context = headlessBrowser.context; + page = headlessBrowser.page; + result = { url: page.url(), authWallDetected: true, ensureAuthCompleted: false, + message: 'Auth did not complete within timeout' }; + break; + } + } else { + console.warn('[WARN] Checkpoint open for ' + (ckTimeout / 1000) + 's'); + await new Promise(resolve => setTimeout(resolve, ckTimeout)); + const snapshot = await getSnapshot(page, opts); + result = { url: page.url(), authWallDetected: true, checkpointCompleted: true, + ...(snapshot != null && { snapshot }) }; + break; + } } else { + if (opts.ensureAuth) { + result = { url: page.url(), authWallDetected: true, ensureAuthCompleted: false, + message: 'Auth wall detected but no display available for headed browser.' }; + break; + } const snapshot = await getSnapshot(page, opts); result = { url: page.url(), authWallDetected: true, checkpointCompleted: false, message: 'Auth wall detected but no display for headed checkpoint.', From 835287ad2e2f07ef719891e04d565fb8e31af7d1 Mon Sep 17 00:00:00 2001 From: Avi Fenesh Date: Wed, 25 Feb 2026 01:41:52 +0200 Subject: [PATCH 2/6] docs: add --ensure-auth flag to printHelp output --- scripts/web-ctl.js | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/web-ctl.js b/scripts/web-ctl.js index 28b8e88..886f595 100755 --- a/scripts/web-ctl.js +++ b/scripts/web-ctl.js @@ -1242,6 +1242,7 @@ Session commands: Run actions: goto Navigate to URL + [--ensure-auth] Poll for auth completion instead of timed checkpoint snapshot Get accessibility tree click Click element [--wait-stable] Wait for DOM + network to settle after click From 9769be32c30769ecb7d3b4ee43b2f6a30f9753d1 Mon Sep 17 00:00:00 2001 From: Avi Fenesh Date: Wed, 25 Feb 2026 01:43:03 +0200 Subject: [PATCH 3/6] test: add --ensure-auth flag tests Source-level assertions verify BOOLEAN_FLAGS registration, help text, opts.ensureAuth guard, checkAuthSuccess polling, result fields (ensureAuthCompleted true/false), timeout message, no-display path, guard condition override, headless relaunch, and poll interval. Flag parsing tests confirm boolean true parsing, no positional arg consumption, and compatibility with --timeout and --no-auth-wall-detect. CLI integration test runs goto with --ensure-auth against example.com. --- tests/web-ctl-actions.test.js | 179 ++++++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) diff --git a/tests/web-ctl-actions.test.js b/tests/web-ctl-actions.test.js index a781e55..882522b 100644 --- a/tests/web-ctl-actions.test.js +++ b/tests/web-ctl-actions.test.js @@ -759,3 +759,182 @@ describe('auth wall detection in goto', () => { ); }); }); + +describe('--ensure-auth flag', () => { + const fs = require('fs'); + const path = require('path'); + const webCtlSource = fs.readFileSync( + path.join(__dirname, '..', 'scripts', 'web-ctl.js'), + 'utf8' + ); + + it('BOOLEAN_FLAGS includes --ensure-auth', () => { + assert.ok( + webCtlSource.includes("'--ensure-auth'"), + '--ensure-auth should be in BOOLEAN_FLAGS' + ); + }); + + it('help text contains --ensure-auth flag', () => { + assert.ok( + webCtlSource.includes('--ensure-auth'), + 'help text should document --ensure-auth' + ); + }); + + it('goto case references opts.ensureAuth', () => { + assert.ok( + webCtlSource.includes('opts.ensureAuth'), + 'goto case should check ensureAuth flag' + ); + }); + + it('goto case calls checkAuthSuccess when ensureAuth is set', () => { + // Verify checkAuthSuccess is called within the ensureAuth block + const ensureAuthIdx = webCtlSource.indexOf('if (opts.ensureAuth)'); + const checkAuthIdx = webCtlSource.indexOf('checkAuthSuccess(page, context, url, { loginUrl: url })', ensureAuthIdx); + assert.ok(ensureAuthIdx > 0, 'opts.ensureAuth guard should exist'); + assert.ok(checkAuthIdx > ensureAuthIdx, 'checkAuthSuccess should be called after ensureAuth check'); + }); + + it('result includes ensureAuthCompleted on success path', () => { + assert.ok( + webCtlSource.includes('ensureAuthCompleted: true'), + 'success result should include ensureAuthCompleted: true' + ); + }); + + it('result includes ensureAuthCompleted on timeout path', () => { + assert.ok( + webCtlSource.includes('ensureAuthCompleted: false'), + 'timeout result should include ensureAuthCompleted: false' + ); + }); + + it('timeout result includes descriptive message', () => { + assert.ok( + webCtlSource.includes('Auth did not complete within timeout'), + 'timeout path should include descriptive message' + ); + }); + + it('no-display path returns ensureAuthCompleted false', () => { + assert.ok( + webCtlSource.includes('no display available for headed browser'), + 'no-display path should include descriptive message when ensureAuth is set' + ); + }); + + it('ensureAuth overrides noAuthWallDetect in guard condition', () => { + assert.ok( + webCtlSource.includes('opts.ensureAuth || !opts.noAuthWallDetect'), + 'guard should allow ensureAuth to override noAuthWallDetect' + ); + }); + + it('relaunches headless browser after auth success', () => { + const ensureAuthIdx = webCtlSource.indexOf('if (opts.ensureAuth)'); + const headlessRelaunch = webCtlSource.indexOf("launchBrowser(sessionName, { headless: true })", ensureAuthIdx); + assert.ok(headlessRelaunch > ensureAuthIdx, 'should relaunch headless browser after successful auth'); + }); + + it('polls at 2s intervals', () => { + assert.ok( + webCtlSource.includes('const pollInterval = 2000'), + 'poll interval should be 2000ms' + ); + }); +}); + +describe('--ensure-auth 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', + ]); + + 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('--ensure-auth parses as boolean true', () => { + const opts = parseOptions(['--ensure-auth']); + assert.equal(opts.ensureAuth, true); + }); + + it('--ensure-auth does not consume next positional arg', () => { + const opts = parseOptions(['--ensure-auth', 'https://example.com']); + assert.equal(opts.ensureAuth, true); + assert.equal(opts['https://example.com'], undefined); + }); + + it('--ensure-auth works alongside --timeout', () => { + const opts = parseOptions(['--ensure-auth', '--timeout', '60']); + assert.equal(opts.ensureAuth, true); + assert.equal(opts.timeout, '60'); + }); + + it('--ensure-auth works alongside --no-auth-wall-detect', () => { + const opts = parseOptions(['--ensure-auth', '--no-auth-wall-detect']); + assert.equal(opts.ensureAuth, true); + assert.equal(opts.noAuthWallDetect, true); + }); +}); + +describe('--ensure-auth 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-ensure-auth-')); + }); + + 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: 15000 + }); + return JSON.parse(result); + } catch (err) { + if (err.stdout) { + try { return JSON.parse(err.stdout); } catch { /* fall through */ } + } + throw err; + } + } + + it('goto with --ensure-auth against example.com runs without error', () => { + const result = runCliSafe('run', 'authtest', 'goto', 'https://example.com', '--ensure-auth'); + assert.ok(result, 'should return a result'); + assert.notEqual(result.error, 'invalid_flag', + '--ensure-auth should not cause an invalid flag error'); + }); +}); From c0f783f6f346e65a6ad63974dee7b37dccdf22a5 Mon Sep 17 00:00:00 2001 From: Avi Fenesh Date: Wed, 25 Feb 2026 01:43:52 +0200 Subject: [PATCH 4/6] docs: document --ensure-auth flag across all references Update SKILL.md goto section with flag description and return type, README.md action reference table and common flags table, commands/web-ctl.md quick reference, and CHANGELOG.md with new feature entry. --- CHANGELOG.md | 1 + README.md | 3 ++- commands/web-ctl.md | 2 +- skills/web-browse/SKILL.md | 6 ++++-- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b72732c..dcf257f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] ### Added +- `--ensure-auth` flag for goto action - polls for auth completion at 2s intervals using URL-change heuristic instead of a static timed checkpoint. On success, closes headed browser, relaunches headless, and loads the original URL. Overrides `--no-auth-wall-detect` so auth detection runs even when wall detection is disabled - Auto-detect authentication walls after goto navigation - uses three-heuristic detection (domain cookies, URL auth patterns, DOM login elements) and automatically opens headed checkpoint. Disable with `--no-auth-wall-detect` flag - Smart default snapshot scoping - snapshots automatically scope to `
` element (then `[role="main"]`, fallback to ``), reducing output size by excluding navigation, headers, and footers. Use `--snapshot-full` to capture full page body when needed - `--snapshot-compact` flag for token-efficient LLM consumption - applies four transforms: link collapsing (merges link + /url child into `link "Title" -> /path`), heading inlining (merges heading with single link child), decorative image removal (strips img nodes with empty or single-char alt text), and duplicate URL dedup (removes second occurrence at same depth scope). Applied after `--snapshot-depth` and before `--snapshot-collapse` in the pipeline diff --git a/README.md b/README.md index 63b9d8b..f0adcdd 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]` | `{ url, status, authWallDetected, checkpointCompleted, snapshot }` | +| `goto` | `run goto [--no-auth-wall-detect] [--ensure-auth]` | `{ url, status, authWallDetected, checkpointCompleted, ensureAuthCompleted, snapshot }` | | `snapshot` | `run snapshot` | `{ url, snapshot }` | | `click` | `run click [--wait-stable]` | `{ url, clicked, snapshot }` | | `click-wait` | `run click-wait [--timeout]` | `{ url, clicked, settled, snapshot }` | @@ -173,6 +173,7 @@ This eliminates the common click-snapshot-check loop that wastes agent turns on | `--path ` | `screenshot` | Custom screenshot path (within session dir) | | `--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` | | `--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/commands/web-ctl.md b/commands/web-ctl.md index 9b7891a..02864f1 100644 --- a/commands/web-ctl.md +++ b/commands/web-ctl.md @@ -48,7 +48,7 @@ node ${PLUGIN_ROOT}/scripts/web-ctl.js session end node ${PLUGIN_ROOT}/scripts/web-ctl.js session verify --url # Browser actions -node ${PLUGIN_ROOT}/scripts/web-ctl.js run goto +node ${PLUGIN_ROOT}/scripts/web-ctl.js run goto [--ensure-auth] node ${PLUGIN_ROOT}/scripts/web-ctl.js run snapshot node ${PLUGIN_ROOT}/scripts/web-ctl.js run click node ${PLUGIN_ROOT}/scripts/web-ctl.js run read diff --git a/skills/web-browse/SKILL.md b/skills/web-browse/SKILL.md index 215e7c8..32c12bb 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] +node ${PLUGIN_ROOT}/scripts/web-ctl.js run goto [--no-auth-wall-detect] [--ensure-auth] ``` Navigates to a URL and automatically detects authentication walls using a three-heuristic detection system: @@ -58,7 +58,9 @@ When an authentication wall is detected, the tool automatically opens a headed c Use `--no-auth-wall-detect` to disable this automatic detection and skip the checkpoint, navigating headlessly without waiting for user interaction. -Returns: `{ url, status, authWallDetected, checkpointCompleted, snapshot }` +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 }` ### snapshot - Get Accessibility Tree From 03f8a4e64d7d3307861eb9179c9d8e8d8c57fd20 Mon Sep 17 00:00:00 2001 From: Avi Fenesh Date: Wed, 25 Feb 2026 01:51:18 +0200 Subject: [PATCH 5/6] fix: harden --ensure-auth polling loop and timeout path - Move sleep before checkAuthSuccess so first poll waits 2s - Add page.isClosed() check to prevent hung evaluations - Wrap checkAuthSuccess in try-catch for resilience during navigation - Remove unnecessary headless browser launch on timeout path - Guard closeBrowser with context null check on normal exit - Use url variable directly instead of redundant originalUrl --- scripts/web-ctl.js | 25 +++++++++++++------------ tests/web-ctl-actions.test.js | 23 +++++++++++++++++++++++ 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/scripts/web-ctl.js b/scripts/web-ctl.js index 886f595..89fee85 100755 --- a/scripts/web-ctl.js +++ b/scripts/web-ctl.js @@ -969,31 +969,32 @@ async function runAction(sessionName, action, actionArgs, opts) { const startTime = Date.now(); let authCompleted = false; while (Date.now() - startTime < ckTimeout) { - const authResult = await checkAuthSuccess(page, context, url, { loginUrl: url }); - if (authResult.success) { - authCompleted = true; - break; - } await new Promise(resolve => setTimeout(resolve, pollInterval)); + if (page.isClosed()) break; + try { + const authResult = await checkAuthSuccess(page, context, url, { loginUrl: url }); + if (authResult.success) { + authCompleted = true; + break; + } + } catch { /* page may have navigated - retry next poll */ } } if (authCompleted) { - const originalUrl = url; await closeBrowser(sessionName, context); const headlessBrowser = await launchBrowser(sessionName, { headless: true }); context = headlessBrowser.context; page = headlessBrowser.page; - await page.goto(originalUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }); + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); const snapshot = await getSnapshot(page, opts); result = { url: page.url(), authWallDetected: true, ensureAuthCompleted: true, ...(snapshot != null && { snapshot }) }; break; } else { await closeBrowser(sessionName, context); - const headlessBrowser = await launchBrowser(sessionName, { headless: true }); - context = headlessBrowser.context; - page = headlessBrowser.page; - result = { url: page.url(), authWallDetected: true, ensureAuthCompleted: false, + result = { url, authWallDetected: true, ensureAuthCompleted: false, message: 'Auth did not complete within timeout' }; + context = null; + page = null; break; } } else { @@ -1178,7 +1179,7 @@ async function runAction(sessionName, action, actionArgs, opts) { } } catch { /* ignore - page may have closed before URL read */ } - await closeBrowser(sessionName, context); + if (context) await closeBrowser(sessionName, context); sessionStore.unlockSession(sessionName); output({ ok: true, command: `run ${action}`, session: sessionName, ...(autoCreated && { autoCreated: true }), result }); diff --git a/tests/web-ctl-actions.test.js b/tests/web-ctl-actions.test.js index 882522b..11e2fb9 100644 --- a/tests/web-ctl-actions.test.js +++ b/tests/web-ctl-actions.test.js @@ -844,6 +844,29 @@ describe('--ensure-auth flag', () => { 'poll interval should be 2000ms' ); }); + + it('checks page.isClosed() before polling', () => { + const ensureAuthIdx = webCtlSource.indexOf('if (opts.ensureAuth)'); + const isClosedIdx = webCtlSource.indexOf('page.isClosed()', ensureAuthIdx); + assert.ok(isClosedIdx > ensureAuthIdx, 'should check page.isClosed() in polling loop'); + }); + + it('wraps checkAuthSuccess in try-catch during polling', () => { + const ensureAuthIdx = webCtlSource.indexOf('if (opts.ensureAuth)'); + const nextCheckpoint = webCtlSource.indexOf('} else {', ensureAuthIdx + 200); + const pollBlock = webCtlSource.slice(ensureAuthIdx, nextCheckpoint); + assert.ok( + pollBlock.includes('try {') && pollBlock.includes('checkAuthSuccess'), + 'checkAuthSuccess should be wrapped in try-catch within polling loop' + ); + }); + + it('guards closeBrowser with context null check on normal exit', () => { + assert.ok( + webCtlSource.includes('if (context) await closeBrowser(sessionName, context)'), + 'normal exit should guard closeBrowser with context null check' + ); + }); }); describe('--ensure-auth flag parsing', () => { From 2dfd24fe1d3b3f807feb7b23912d1b31e145d7f1 Mon Sep 17 00:00:00 2001 From: Avi Fenesh Date: Wed, 25 Feb 2026 01:54:46 +0200 Subject: [PATCH 6/6] fix: add error handling to auth success and timeout paths - Wrap headless relaunch in try-catch on success path to preserve auth completion context if reload fails - Wrap closeBrowser in try-catch on timeout path to ensure consistent context/page nullification even if browser already closed - Add source-level tests for new safety patterns --- scripts/web-ctl.js | 23 ++++++++++++++-------- tests/web-ctl-actions.test.js | 36 +++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/scripts/web-ctl.js b/scripts/web-ctl.js index 89fee85..ae159a1 100755 --- a/scripts/web-ctl.js +++ b/scripts/web-ctl.js @@ -981,16 +981,23 @@ async function runAction(sessionName, action, actionArgs, opts) { } if (authCompleted) { await closeBrowser(sessionName, context); - const headlessBrowser = await launchBrowser(sessionName, { headless: true }); - context = headlessBrowser.context; - page = headlessBrowser.page; - await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); - const snapshot = await getSnapshot(page, opts); - result = { url: page.url(), authWallDetected: true, ensureAuthCompleted: true, - ...(snapshot != null && { snapshot }) }; + try { + const headlessBrowser = await launchBrowser(sessionName, { headless: true }); + context = headlessBrowser.context; + page = headlessBrowser.page; + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); + const snapshot = await getSnapshot(page, opts); + result = { url: page.url(), authWallDetected: true, ensureAuthCompleted: true, + ...(snapshot != null && { snapshot }) }; + } catch (relaunchErr) { + result = { url, authWallDetected: true, ensureAuthCompleted: true, + message: 'Auth completed but headless reload failed: ' + relaunchErr.message }; + context = null; + page = null; + } break; } else { - await closeBrowser(sessionName, context); + try { await closeBrowser(sessionName, context); } catch { /* already closed */ } result = { url, authWallDetected: true, ensureAuthCompleted: false, message: 'Auth did not complete within timeout' }; context = null; diff --git a/tests/web-ctl-actions.test.js b/tests/web-ctl-actions.test.js index 11e2fb9..f367d5d 100644 --- a/tests/web-ctl-actions.test.js +++ b/tests/web-ctl-actions.test.js @@ -867,6 +867,42 @@ describe('--ensure-auth flag', () => { 'normal exit should guard closeBrowser with context null check' ); }); + + it('nullifies context and page on timeout path', () => { + const timeoutIdx = webCtlSource.indexOf('Auth did not complete within timeout'); + const nextBreak = webCtlSource.indexOf('break;', timeoutIdx); + const block = webCtlSource.slice(timeoutIdx, nextBreak); + assert.ok(block.includes('context = null'), 'timeout path should set context to null'); + assert.ok(block.includes('page = null'), 'timeout path should set page to null'); + }); + + it('breaks polling loop when page is closed', () => { + const ensureAuthIdx = webCtlSource.indexOf('if (opts.ensureAuth)'); + const pollBlock = webCtlSource.slice(ensureAuthIdx, ensureAuthIdx + 600); + assert.ok( + pollBlock.includes('if (page.isClosed()) break'), + 'polling loop should break when page is closed' + ); + }); + + it('wraps headless relaunch in try-catch on success path', () => { + const authCompletedIdx = webCtlSource.indexOf('if (authCompleted)'); + const elseIdx = webCtlSource.indexOf('} else {', authCompletedIdx + 50); + const successBlock = webCtlSource.slice(authCompletedIdx, elseIdx); + assert.ok( + successBlock.includes('try {') && successBlock.includes('launchBrowser'), + 'success path should wrap headless relaunch in try-catch' + ); + }); + + it('wraps closeBrowser in try-catch on timeout path', () => { + const elseIdx = webCtlSource.indexOf('Auth did not complete within timeout'); + const beforeTimeout = webCtlSource.slice(elseIdx - 300, elseIdx); + assert.ok( + beforeTimeout.includes('try { await closeBrowser'), + 'timeout path should wrap closeBrowser in try-catch' + ); + }); }); describe('--ensure-auth flag parsing', () => {