diff --git a/CHANGELOG.md b/CHANGELOG.md index 706c2399..14944fed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 2026-09-18 + +### Changes + +- [Navigator] A site that redirects to another host under the same domain — a bare domain sending the browser to + its `www.` host, for example — no longer fails every navigation. Explorbot used to compare the landing host to the + configured one exactly, so it rejected the page it had just loaded and reported `expected /, got /`. A host now + counts as the same site when it is the configured one or a subdomain of it; the port still has to match, the + scheme no longer does. +- [Navigator] When a navigation does land on a different site, the failure now names the full URL it reached + instead of just the path, so the mismatch is visible in the log. +- Network calls and API requests recorded during a run are matched against the site the same way, so calls made + from a redirected host are captured instead of silently dropped. + ## 2026-09-17 ### Changes diff --git a/src/action.ts b/src/action.ts index d04b348c..6ee8e5ea 100644 --- a/src/action.ts +++ b/src/action.ts @@ -17,6 +17,7 @@ import { Overlay, OverlayPage } from './utils/overlay.js'; import { sleep, waitForPageReadiness } from './utils/page-readiness.ts'; import type { Region } from './utils/region.js'; import { safeFilename } from './utils/strings.ts'; +import { isSameHostFamily } from './utils/url-matcher.js'; import { codeceptJSSandbox, hasPlaywrightCommands, playwrightSandbox, sanitizeCodeBlock } from './utils/web-sandbox.ts'; const debugLog = createDebug('explorbot:action'); @@ -317,7 +318,7 @@ class Action { const url = URL.parse(request.url()); if (!url) return; - if (url.origin !== this.baseOrigin) return; + if (!isSameHostFamily(url.href, this.baseOrigin)) return; const call: NetworkCall = { method: request.method(), path: url.pathname, status }; if (this.networkRequests.some((r) => r.method === call.method && r.path === call.path && r.status === call.status)) return; diff --git a/src/ai/navigator.ts b/src/ai/navigator.ts index 8502bb9a..3fa44587 100644 --- a/src/ai/navigator.ts +++ b/src/ai/navigator.ts @@ -18,7 +18,7 @@ import { createDebug, pluralize, tag } from '../utils/logger.js'; import { loop, pause } from '../utils/loop.js'; import { RulesLoader } from '../utils/rules-loader.ts'; import { normalizeInlineText } from '../utils/strings.ts'; -import { extractStatePath, matchesNavigationUrl } from '../utils/url-matcher.js'; +import { extractStatePath, isSameHostFamily, matchesNavigationUrl } from '../utils/url-matcher.js'; import type { Agent, AgentDeps } from './agent.js'; import type { Conversation } from './conversation.js'; import type { Provider } from './provider.js'; @@ -99,15 +99,6 @@ class Navigator implements Agent { return this.config.ai?.agents?.navigator?.verifyTimeout ?? 1500; } - private getBaseOrigin(): string | null { - const baseUrl = this.config.playwright.url; - try { - return new URL(baseUrl).origin; - } catch { - return null; - } - } - private getComparableCurrentUrl(stateManager: any, expectedUrl: string): string { const currentState = stateManager.getCurrentState(); if (!currentState) return ''; @@ -126,18 +117,12 @@ class Navigator implements Agent { const currentFullUrl = currentState.fullUrl || currentState.url || ''; if (!currentFullUrl) return false; - try { - const currentOrigin = new URL(currentFullUrl).origin; - if (/^https?:\/\//i.test(expectedUrl)) { - return currentOrigin === new URL(expectedUrl).origin; - } + if (!/^https?:\/\//i.test(currentFullUrl)) return !/^https?:\/\//i.test(expectedUrl); + if (/^https?:\/\//i.test(expectedUrl)) return isSameHostFamily(currentFullUrl, expectedUrl); - const baseOrigin = this.getBaseOrigin(); - if (!baseOrigin) return true; - return currentOrigin === baseOrigin; - } catch { - return !/^https?:\/\//i.test(expectedUrl); - } + const baseUrl = this.config.playwright.url; + if (!baseUrl) return true; + return isSameHostFamily(currentFullUrl, baseUrl); } private isOnExpectedPage(expectedUrl: string, stateManager: any): boolean { @@ -325,8 +310,9 @@ class Navigator implements Agent { lastFailure = `Reached ${check.freshState.url} but the page state did not change`; tag('warning').log(`Page state did not change at ${check.freshState.url}`); } else { - lastFailure = `Reached ${check.freshState.url}, expected ${expectedUrl}`; - tag('warning').log(`URL verification failed: expected ${expectedUrl}, got ${check.freshState.url}`); + const reachedUrl = check.freshState.fullUrl || check.freshState.url; + lastFailure = `Reached ${reachedUrl}, expected ${expectedUrl}`; + tag('warning').log(`URL verification failed: expected ${expectedUrl}, got ${reachedUrl}`); } batchFailures.push({ code: codeBlock, diff --git a/src/api/xhr-capture.ts b/src/api/xhr-capture.ts index 2e7c6493..5ad8677d 100644 --- a/src/api/xhr-capture.ts +++ b/src/api/xhr-capture.ts @@ -1,3 +1,4 @@ +import { isSameHostFamily } from '../utils/url-matcher.js'; import { RequestResult, generateRequestId } from './request-result.ts'; import type { RequestStore } from './request-store.ts'; @@ -39,7 +40,7 @@ export class XhrCapture { const method = request.method(); const url = request.url(); - if (!url.startsWith(this.baseOrigin)) return; + if (!isSameHostFamily(url, this.baseOrigin)) return; const status = response.status(); diff --git a/src/utils/url-matcher.ts b/src/utils/url-matcher.ts index f6a5452b..670f015f 100644 --- a/src/utils/url-matcher.ts +++ b/src/utils/url-matcher.ts @@ -132,3 +132,10 @@ export function matchesNavigationUrl(expected: string, current: string): boolean .filter(Boolean); return recordSegments.length > 0 && recordSegments.every(isDynamicSegment); } + +export function isSameHostFamily(urlA: string, urlB: string): boolean { + const hostA = URL.parse(urlA)?.host.toLowerCase(); + const hostB = URL.parse(urlB)?.host.toLowerCase(); + if (!hostA || !hostB) return false; + return hostA === hostB || hostA.endsWith(`.${hostB}`) || hostB.endsWith(`.${hostA}`); +} diff --git a/tests/unit/navigator-origin-guard.test.ts b/tests/unit/navigator-origin-guard.test.ts index ac20bf73..90883bf5 100644 --- a/tests/unit/navigator-origin-guard.test.ts +++ b/tests/unit/navigator-origin-guard.test.ts @@ -24,6 +24,18 @@ describe('Navigator origin guard', () => { expect((navigator as any).isOnExpectedPage('/', stateManager)).toBe(false); }); + it('accepts the host the configured origin redirects to', () => { + const navigator = createNavigator('https://example.com'); + const stateManager = { + getCurrentState: () => ({ + url: '/', + fullUrl: 'https://www.example.com/', + }), + }; + + expect((navigator as any).isOnExpectedPage('/', stateManager)).toBe(true); + }); + it('accepts the configured origin for relative expected URLs', () => { const navigator = createNavigator(); const stateManager = { diff --git a/tests/unit/navigator-resolve-state.test.ts b/tests/unit/navigator-resolve-state.test.ts index 82b3136c..ce5d8b4c 100644 --- a/tests/unit/navigator-resolve-state.test.ts +++ b/tests/unit/navigator-resolve-state.test.ts @@ -96,7 +96,7 @@ describe('Navigator resolveState', () => { await harness.navigator.resolveState('reach /defects', fakeActionResult('/login'), { expectedUrl: '/defects' }); - expect(harness.sent.some((text) => text.includes('Reached /login, expected /defects'))).toBe(true); + expect(harness.sent.some((text) => text.includes(`Reached ${BASE_URL}/login, expected /defects`))).toBe(true); }); it('resolves when a proposed step reaches the expected URL', async () => { @@ -217,7 +217,7 @@ describe('Navigator resolveState', () => { expect(resolved).toBe(false); const retry = harness.sent[1]; expect(retry).toContain(''); - expect(retry).toContain('Reached /login, expected /defects'); + expect(retry).toContain(`Reached ${BASE_URL}/login, expected /defects`); expect(retry).toContain('Invalid email or password'); expect(retry).toContain('Full HTML context'); expect(retry).toContain('Choose exactly ONE path'); diff --git a/tests/unit/url-matcher.test.ts b/tests/unit/url-matcher.test.ts index e2a7a544..6aff706e 100644 --- a/tests/unit/url-matcher.test.ts +++ b/tests/unit/url-matcher.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it } from 'bun:test'; import { ConfigParser } from '../../src/config'; import { normalizeUrl } from '../../src/state-manager'; -import { extractStatePath, generalizeSegment, generalizeUrl, hasDynamicUrlSegment, isDynamicSegment, isSamePageFamily, matchesNavigationUrl, matchesUrl } from '../../src/utils/url-matcher'; +import { extractStatePath, generalizeSegment, generalizeUrl, hasDynamicUrlSegment, isDynamicSegment, isSameHostFamily, isSamePageFamily, matchesNavigationUrl, matchesUrl } from '../../src/utils/url-matcher'; describe('url-matcher', () => { beforeEach(() => { @@ -282,4 +282,37 @@ describe('url-matcher', () => { expect(isSamePageFamily('/plans/new', '/plans/a57eab1a')).toBe(false); }); }); + describe('isSameHostFamily', () => { + it('matches a host with its www redirect target', () => { + expect(isSameHostFamily('https://www.example.com/', 'https://example.com')).toBe(true); + expect(isSameHostFamily('https://example.com/', 'https://www.example.com')).toBe(true); + }); + + it('matches a subdomain with its parent domain', () => { + expect(isSameHostFamily('https://app.example.com/dashboard', 'https://example.com')).toBe(true); + }); + + it('matches across schemes', () => { + expect(isSameHostFamily('https://example.com/', 'http://example.com')).toBe(true); + }); + + it('rejects sibling subdomains', () => { + expect(isSameHostFamily('https://app.example.com/', 'https://auth.example.com')).toBe(false); + }); + + it('rejects a host that only shares a suffix without a dot boundary', () => { + expect(isSameHostFamily('https://example.com.evil.test/', 'https://example.com')).toBe(false); + expect(isSameHostFamily('https://notexample.com/', 'https://example.com')).toBe(false); + }); + + it('keeps ports significant', () => { + expect(isSameHostFamily('http://localhost:3000/', 'http://localhost:3001')).toBe(false); + expect(isSameHostFamily('http://localhost:3000/', 'http://localhost:3000')).toBe(true); + }); + + it('rejects urls without a host', () => { + expect(isSameHostFamily('/login', 'https://example.com')).toBe(false); + expect(isSameHostFamily('https://example.com/', '')).toBe(false); + }); + }); });