Skip to content

Commit 92511a1

Browse files
committed
feat(core): adopt IBS utility patterns + opt-in login flow
Adopts non-login and login structural patterns from israeli-bank-scrapers as pure additions — no existing adapters are affected. Non-login utilities (waiting.ts, fetch-utils.ts, adapter-utils.ts): - waitUntil / raceTimeout / runSerial / sleep / TimeoutError primitives - fetchGetWithinPage / fetchPostWithinPage (in-page fetch using browser cookies) - fetchGet / fetchPost / fetchGraphql (Node-side helpers) - Navigation: waitForRedirect, waitForUrl, getCurrentUrl - Element: fillInput, clickButton, setValue, elementPresentOnPage, waitUntilElementFound, waitUntilElementDisappear, waitUntilIframeFound, pageEval, pageEvalAll - Misc: maskHeadlessUserAgent, getFromSessionStorage, chunk - preparePage? lifecycle hook on SiteAdapter (wired in SessionManager) Opt-in login flow (login-flow.ts, types.ts): - AuthErrorType union + LoginError class for structured MCP error responses - LoginOptions / PossibleLoginResults types (URL/regex/predicate matching) - withLoginFlow() utility — form-fill + result detection - getLoginOptions?() optional hook on SiteAdapter - handleAuthFailure: tries automated login first when getLoginOptions defined; falls back to human-handoff on transient errors; throws LoginError on definitive failures (INVALID_PASSWORD, ACCOUNT_BLOCKED, etc.) - wrapToolCall catches LoginError and returns typed MCP error to the client CI: fix duplicate patchright instances causing TS2322 in E2E adapter build by removing patchright from the adapter's devDeps before npm install, letting npm deduplicate to core's instance. Tests: 107 unit tests in core (16 login-flow, 22 adapter-utils-ibs, 13 waiting, 11 fetch-utils) — all passing. Made-with: Cursor
1 parent d824ea4 commit 92511a1

14 files changed

Lines changed: 1572 additions & 14 deletions

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ jobs:
4848
const pkg = JSON.parse(require('fs').readFileSync('package.json'));
4949
pkg.devDependencies['@browserkit/core'] = 'file:../core';
5050
pkg.peerDependencies['@browserkit/core'] = '>=0.1.0';
51+
// Remove patchright from adapter devDeps so npm deduplicates to core's
52+
// instance — prevents duplicate Page types causing TS2322 errors.
53+
delete pkg.devDependencies['patchright'];
5154
require('fs').writeFileSync('package.json', JSON.stringify(pkg, null, 2));
5255
"
5356
npm install

packages/core/src/adapter-server.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { LockManager } from "./lock-manager.js";
1616
import { RateLimiter } from "./rate-limiter.js";
1717
import { buildHandoffResult, handleAuthFailure, isBackgroundLoginInProgress } from "./human-handoff.js";
1818
import { screenshotOnError, screenshotToContent, detectRateLimit } from "./adapter-utils.js";
19+
import { LoginError } from "./types.js";
1920
import { getLogger } from "./logger.js";
2021

2122
const log = getLogger("adapter-server");
@@ -76,20 +77,30 @@ export async function createAdapterServer(
7677

7778
let page: Page;
7879
try {
79-
page = await sessionManager.getPage(sessionConfig);
80+
page = await sessionManager.getPage(sessionConfig, adapter);
8081
} catch (err) {
8182
return errorResult(`Failed to get browser page: ${String(err)}`);
8283
}
8384

8485
const loggedIn = await adapter.isLoggedIn(page);
8586
if (!loggedIn) {
86-
const reauthed = await handleAuthFailure(sessionManager, sessionConfig, adapter);
87+
let reauthed: boolean;
88+
try {
89+
reauthed = await handleAuthFailure(sessionManager, sessionConfig, adapter);
90+
} catch (err) {
91+
if (err instanceof LoginError) {
92+
return errorResult(
93+
`Login failed (${err.errorType}): ${err.message}`
94+
);
95+
}
96+
throw err;
97+
}
8798
if (!reauthed) {
8899
return buildHandoffResult(adapter, isBackgroundLoginInProgress(adapter.site)) as {
89100
content: Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: "image/png" }>;
90101
};
91102
}
92-
page = await sessionManager.getPage(sessionConfig);
103+
page = await sessionManager.getPage(sessionConfig, adapter);
93104
}
94105

95106
const tool = adapter.tools().find((t) => t.name === toolName);

packages/core/src/adapter-utils.ts

Lines changed: 275 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import fs from "node:fs";
22
import path from "node:path";
3-
import type { Page, Locator } from "patchright";
3+
import type { Page, Locator, Frame } from "patchright";
44
import type { SelectorReport, ToolContent } from "./types.js";
5+
import { waitUntil, sleep } from "./waiting.js";
56

67
/**
78
* Validate a map of named CSS selector strings against the current page state.
@@ -136,10 +137,6 @@ export async function screenshotOnError(
136137
return filePath;
137138
}
138139

139-
function sleep(ms: number): Promise<void> {
140-
return new Promise((resolve) => setTimeout(resolve, ms));
141-
}
142-
143140
/**
144141
* Detect rate limiting or security challenges after a navigation.
145142
*
@@ -378,3 +375,276 @@ export async function detectAuthBarrier(page: Page, quick = true): Promise<strin
378375
return null; // never throw — page errors should not break callers
379376
}
380377
}
378+
379+
// ── Navigation utilities ──────────────────────────────────────────────────────
380+
// Ported from israeli-bank-scrapers helpers/navigation.ts — adapted for Playwright/Patchright.
381+
382+
/**
383+
* Get the current page URL. When `clientSide` is true, reads from
384+
* `window.location.href` instead of the Playwright/Patchright URL — necessary
385+
* for SPAs that use the History API, where the framework URL lags behind.
386+
*/
387+
export function getCurrentUrl(page: Page | Frame, clientSide = false): Promise<string> {
388+
if (clientSide) {
389+
return page.evaluate(() => window.location.href);
390+
}
391+
return Promise.resolve(page.url());
392+
}
393+
394+
/**
395+
* Poll until the page URL changes from its current value.
396+
*
397+
* `ignoreList` lets you skip intermediate redirect URLs (e.g. a loading page
398+
* that appears between the start URL and the final destination).
399+
*
400+
* Use `clientSide: true` for SPAs where `page.waitForNavigation()` never fires
401+
* because there is no real HTTP navigation.
402+
*/
403+
export async function waitForRedirect(
404+
page: Page | Frame,
405+
timeout = 20_000,
406+
clientSide = false,
407+
ignoreList: string[] = [],
408+
): Promise<void> {
409+
const initial = await getCurrentUrl(page, clientSide);
410+
await waitUntil(
411+
async () => {
412+
const current = await getCurrentUrl(page, clientSide);
413+
return current !== initial && !ignoreList.includes(current);
414+
},
415+
`waiting for redirect from ${initial}`,
416+
timeout,
417+
1_000,
418+
);
419+
}
420+
421+
/**
422+
* Poll until the page URL exactly matches `url` (string) or matches the
423+
* regex pattern. Useful for confirming SPA navigation completed.
424+
*/
425+
export async function waitForUrl(
426+
page: Page | Frame,
427+
url: string | RegExp,
428+
timeout = 20_000,
429+
clientSide = false,
430+
): Promise<void> {
431+
await waitUntil(
432+
async () => {
433+
const current = await getCurrentUrl(page, clientSide);
434+
return url instanceof RegExp ? url.test(current) : url === current;
435+
},
436+
`waiting for url to be ${String(url)}`,
437+
timeout,
438+
1_000,
439+
);
440+
}
441+
442+
// ── Element interaction helpers ───────────────────────────────────────────────
443+
// Ported from israeli-bank-scrapers helpers/elements-interactions.ts.
444+
// These wrap Playwright's Locator API with ergonomic defaults.
445+
446+
/**
447+
* Fill an input field with `value`, replacing any existing content.
448+
* Triggers input and change events (correct for React controlled components).
449+
*/
450+
export async function fillInput(
451+
page: Page | Frame,
452+
selector: string,
453+
value: string,
454+
): Promise<void> {
455+
await page.locator(selector).fill(value);
456+
}
457+
458+
/**
459+
* Click a button or element by selector.
460+
*/
461+
export async function clickButton(
462+
page: Page | Frame,
463+
selector: string,
464+
): Promise<void> {
465+
await page.locator(selector).click();
466+
}
467+
468+
/**
469+
* Set an input's `.value` property directly without firing keyboard events.
470+
* Use this when the site reads `.value` directly rather than listening for
471+
* input/change events (non-React forms, legacy apps).
472+
*/
473+
export async function setValue(
474+
page: Page | Frame,
475+
selector: string,
476+
value: string,
477+
): Promise<void> {
478+
await page.locator(selector).evaluate(
479+
(el, v) => { (el as HTMLInputElement).value = v; },
480+
value,
481+
);
482+
}
483+
484+
/**
485+
* Return true if at least one element matching `selector` exists in the DOM
486+
* right now. Does not wait — use `waitUntilElementFound` for polling.
487+
*/
488+
export async function elementPresentOnPage(
489+
page: Page | Frame,
490+
selector: string,
491+
): Promise<boolean> {
492+
return (await page.locator(selector).count()) > 0;
493+
}
494+
495+
/**
496+
* Wait until an element matching `selector` is present in the DOM.
497+
*
498+
* @param isVisible When true, also waits for the element to be visible.
499+
* @param timeout Override the default Playwright timeout (ms).
500+
*/
501+
export async function waitUntilElementFound(
502+
page: Page | Frame,
503+
selector: string,
504+
isVisible = false,
505+
timeout?: number,
506+
): Promise<void> {
507+
await page.waitForSelector(selector, {
508+
state: isVisible ? "visible" : "attached",
509+
...(timeout !== undefined ? { timeout } : {}),
510+
});
511+
}
512+
513+
/**
514+
* Wait until an element matching `selector` disappears from the DOM (or
515+
* becomes hidden). Useful for waiting out loading spinners.
516+
*/
517+
export async function waitUntilElementDisappear(
518+
page: Page | Frame,
519+
selector: string,
520+
timeout?: number,
521+
): Promise<void> {
522+
await page.waitForSelector(selector, {
523+
state: "hidden",
524+
...(timeout !== undefined ? { timeout } : {}),
525+
});
526+
}
527+
528+
/**
529+
* Wait until a frame matching `framePredicate` appears among the page's frames.
530+
* Returns the matched frame.
531+
*
532+
* Uses `waitUntil` internally so it respects `timeout` and `description`.
533+
*/
534+
export async function waitUntilIframeFound(
535+
page: Page,
536+
framePredicate: (frame: Frame) => boolean,
537+
description = "waiting for iframe",
538+
timeout = 30_000,
539+
): Promise<Frame> {
540+
let found: Frame | undefined;
541+
await waitUntil(
542+
() => {
543+
found = page.frames().find(framePredicate);
544+
return Promise.resolve(found ?? null);
545+
},
546+
description,
547+
timeout,
548+
1_000,
549+
);
550+
if (!found) throw new Error("failed to find iframe");
551+
return found;
552+
}
553+
554+
/**
555+
* Typed `$eval` with graceful missing-element handling.
556+
*
557+
* When no element matches `selector`, returns `defaultResult` instead of
558+
* throwing — eliminating the try/catch boilerplate every adapter needs.
559+
*/
560+
export async function pageEval<R>(
561+
page: Page | Frame,
562+
selector: string,
563+
defaultResult: R,
564+
callback: (element: Element) => R,
565+
): Promise<R> {
566+
try {
567+
const locator = page.locator(selector);
568+
if ((await locator.count()) === 0) return defaultResult;
569+
return await locator.first().evaluate(callback);
570+
} catch {
571+
return defaultResult;
572+
}
573+
}
574+
575+
/**
576+
* Typed `$$eval` with graceful missing-element handling.
577+
*
578+
* Calls `callback` with all matching elements. When the selector matches
579+
* nothing, `evaluateAll` passes an empty array to the callback. On any
580+
* error, returns `defaultResult`.
581+
*/
582+
export async function pageEvalAll<R>(
583+
page: Page | Frame,
584+
selector: string,
585+
defaultResult: R,
586+
callback: (elements: Element[]) => R,
587+
): Promise<R> {
588+
try {
589+
return await page.locator(selector).evaluateAll(callback);
590+
} catch {
591+
return defaultResult;
592+
}
593+
}
594+
595+
// ── Miscellaneous browser utilities ──────────────────────────────────────────
596+
// Ported from israeli-bank-scrapers helpers/browser.ts and helpers/storage.ts.
597+
598+
/**
599+
* Strip "HeadlessChrome/" from the navigator user agent string so headless
600+
* Chrome reports as regular Chrome. Call from `preparePage` to apply once at
601+
* startup before any navigation.
602+
*
603+
* Uses `addInitScript` so the override takes effect in every frame and
604+
* survives cross-origin navigations.
605+
*/
606+
export async function maskHeadlessUserAgent(page: Page): Promise<void> {
607+
const raw = await page.evaluate(() => navigator.userAgent);
608+
const masked = raw.replace("HeadlessChrome/", "Chrome/");
609+
await page.addInitScript(
610+
(ua: string) => {
611+
Object.defineProperty(navigator, "userAgent", { get: () => ua });
612+
},
613+
masked,
614+
);
615+
}
616+
617+
/**
618+
* Read and JSON-parse a value from the page's `sessionStorage`.
619+
* Returns null if the key is absent or the value is not valid JSON.
620+
*
621+
* Useful for sites (e.g. Visa Cal) that store auth tokens in sessionStorage
622+
* rather than cookies.
623+
*/
624+
export async function getFromSessionStorage<T>(
625+
page: Page,
626+
key: string,
627+
): Promise<T | null> {
628+
const raw = await page.evaluate((k: string) => sessionStorage.getItem(k), key);
629+
if (!raw) return null;
630+
try {
631+
return JSON.parse(raw) as T;
632+
} catch {
633+
return null;
634+
}
635+
}
636+
637+
/**
638+
* Split an array into chunks of at most `size` elements.
639+
*
640+
* @example
641+
* chunk([1, 2, 3, 4, 5], 2) // [[1, 2], [3, 4], [5]]
642+
*/
643+
export function chunk<T>(array: T[], size: number): T[][] {
644+
if (size <= 0) throw new Error(`chunk size must be positive, got ${size}`);
645+
const chunks: T[][] = [];
646+
for (let i = 0; i < array.length; i += size) {
647+
chunks.push(array.slice(i, i + size));
648+
}
649+
return chunks;
650+
}

0 commit comments

Comments
 (0)