From 3b5f89c14eaada036179d03d7215c6b188b6732b Mon Sep 17 00:00:00 2001 From: simse Date: Thu, 16 Oct 2025 19:35:00 +0100 Subject: [PATCH 1/5] feat: Add browser mocking --- .gitignore | 4 +- bun.lockb | Bin 158876 -> 169796 bytes package.json | 1 + src/browser/browserAdapter.ts | 43 +++++ src/{browser.ts => browser/index.ts} | 12 +- src/browser/mockBrowser.ts | 193 ++++++++++++++++++++ src/browser/puppeteerAdapter.ts | 96 ++++++++++ src/connectors/standardLifePension.test.ts | 173 ++++++++++++++++++ src/connectors/standardLifePension.ts | 11 +- src/connectors/trading212.mock.ts | 125 +++++++++++++ src/connectors/trading212.test.ts | 173 +++++++++++++++++- src/connectors/ukStudentLoan.test.ts | 200 +++++++++++++++++++++ src/connectors/ukStudentLoan.ts | 6 +- src/index.ts | 8 +- src/runtime.ts | 17 +- src/ynab.ts | 4 +- 16 files changed, 1042 insertions(+), 24 deletions(-) create mode 100644 src/browser/browserAdapter.ts rename src/{browser.ts => browser/index.ts} (62%) create mode 100644 src/browser/mockBrowser.ts create mode 100644 src/browser/puppeteerAdapter.ts create mode 100644 src/connectors/standardLifePension.test.ts create mode 100644 src/connectors/trading212.mock.ts create mode 100644 src/connectors/ukStudentLoan.test.ts diff --git a/.gitignore b/.gitignore index 0728873..1b5b54b 100644 --- a/.gitignore +++ b/.gitignore @@ -187,4 +187,6 @@ docs/.vitepress/cache docs/.vitepress/config-schema.json # Claude Code -.claude \ No newline at end of file +.claude + +lightpanda \ No newline at end of file diff --git a/bun.lockb b/bun.lockb index 891680d7fc54c6713c6a7c103815e038f29b9bf6..fd8e37be56ba805ffd2a595654626f231d653102 100755 GIT binary patch delta 15094 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 R>IPU4>C^?j;j&>S{|D$k!R!D4 delta 8643 zcmeI2d3aRS8Hev3638TkMTD@#M3BW$h!B<_AcP>uV#ouOqHKa}vOHQWR1Acf1P~Py zmO(D7ghiGbMNkPrz@S(K+`=M4tCdATQPB!M*0$RBcjv^6Ed^|8`6uUj-uZp!JKwoy zxyzh;W_CXlx$KB{Zeqgv;xWfY4cy;ga^-Jt+VFH<#jf8LB)wJm%jJjeo?X>@Vq0;= z@g7TQ!My(Qo)OD7O+Fpv8$WW?#Cz_$Z`?CeEbCg!@-ISbpfgPVUXveS^0%6N5_%1B zamF8oRwMsShGkVp>(fp}v^E-v4)7hQ|W4^}11YUe-fx2)@MY)9*$YtWi#E~<`9 zLTjNz&=|C00fx)d`j6V5Pa% zQLJEG!t`KVyTIH!VS&6^VZN%SJ5n1sF>aEE1M}wknO9etPs;<$x2w#*n=Yp1iMYYU zNh?5O;5a_3i75zGb)>2BOUpqslMhuLDB1X>%6BmN3#pFN$&?3`$(5l7Q%ymrszMi# zb_2zAH`)VL3e_<3y+Cp4pd{4{^aCB|4xdXo>;D16VE^5<0^$XpboJH~oJAEcjXVCJHme35vIR75HQ zJ>A{_C80X-O<5y2L!q582?BWl1BP4^kwyc33$*HNM&a5Q5#)g z{8w6iSNecI=XiW8`>cF3va6$<|No|8ecwFK0CaA*pc+eo8BnOowbhgtqPkYLOLhIf zWD1HB3FDnDrQ7gJrV@0<8Pp*~eShM~I0`B2Y3LghbY z{HKk6f+|TB_nFCus_K1V{8E``O+HlR`jW@xxE0>O)D>0KFY7Bg1DPx0eU&k64kd>N z)v(L!(Ep?=uYg}mr%EQivWb(byo%AP#(x>=bBA}WsUVeE!{kF%u9|$Pp<1T=LK;nZ zLsNcf4L%hwT0vjD&CCI>xW<|hHAmG(3zKh&Mg*c)c6K|cMYm9rY7X=)Q%caM(wjU2;rL#?hb?j@izH zP6R&Y?2+U+byJaC=RwIFr&u!AN$8BsbFw7!og~CYgsLqJKt@!+q!Ga?|%p;`6p4Vh~!|{k9l57tQ`s< z{no^2#U1B*f_p2fq9tHma^o}yx*xGZ^c9?dIY}T7lk`^kSyBy++U&egQW^0yKf9kO((HE$7HKyM?Eb?NoZv z?&{k~TQ9kU5||#epygbZ0oQ5T1m^upV?# zJ^@Q$DdfRwSP83O4Qznl!BemZR=`@F;dK~Vo^*rW&>#9hU$_f~K{@z@et!gC!58ou zoP{spbNCwm49DRVoQ4x{20n#j@G+c(58y+1%W7mm@tRx1Vtx0Bss+qi8$! z4%i7jKpVk>K@0!CQSS%%9uC5OH~@#>U047C(0lL$kO32*5A=m}=ncJKC=AoKXJ?F5 z=m;sW4pu=PtcE%e3)(}|e%q&T22Q|9_yDxCRfML)--&g%7O)@3eiUZIRLF$;;Wuyx z4A9hd!srU0Q{h9HhRt1P`C$fRK{n{=xDRa#36NnseTwWpadYv{gZU5u54@*qkMw%J z9+LIEv&lXTvkJ}^*>fU>KZHFKX25jFhAi-d52isTJOceW&ZDTVG96#-s6Dl-+anip z;4%0%pAS-%Q?|2ouboqny3d}N+`8l^`PJEfJ-hUQl$*os=84T)By!)D{Agdl?%`eg z3O4#mOX8+D(<*pswl4XZ|FN8yTAP2BQCJ#L@<)K~xzUw692r-;v}C{(=MCz{mi%>K z(5tf&PM+C&zBFRSl!Xx-yyU+I;af&eXgOub{L+AuehC>?@@u whyBiY>P8Jc6+9b5PutEMucynp_q-lEa_;G%H*9>t@kr0%jDk;cJOjP|0Hwg7o&W#< diff --git a/package.json b/package.json index 3bd44f0..271d9d5 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@types/bun": "1.3.0", "@types/retry": "0.12.5", "lefthook": "1.13.6", + "msw": "2.11.5", "pino-pretty": "13.1.2", "vitepress": "^2.0.0-alpha.12", "wrangler": "4.43.0" diff --git a/src/browser/browserAdapter.ts b/src/browser/browserAdapter.ts new file mode 100644 index 0000000..7ded681 --- /dev/null +++ b/src/browser/browserAdapter.ts @@ -0,0 +1,43 @@ +/** + * Abstraction layer for browser automation that makes testing easier. + * + * This provides interfaces that can be implemented by both real browser + * instances (via Puppeteer) and mock implementations for testing. + */ + +/** + * Represents a selector result that can be evaluated + */ +export interface SelectorResult { + evaluate(fn: (el: Element) => string | null): Promise; +} + +/** + * Represents a locator for interacting with elements + */ +export interface Locator { + click(): Promise; + fill(text: string): Promise; +} + +/** + * Represents a browser page with methods for automation + */ +export interface PageAdapter { + goto(url: string): Promise; + type(selector: string, text: string): Promise; + click(selector: string): Promise; + locator(selector: string): Locator; + waitForSelector(selector: string): Promise; + waitForNetworkIdle(): Promise; + url(): string; + close(): Promise; +} + +/** + * Represents a browser instance + */ +export interface BrowserAdapter { + newPage(): Promise; + close(): Promise; +} diff --git a/src/browser.ts b/src/browser/index.ts similarity index 62% rename from src/browser.ts rename to src/browser/index.ts index 2c6ace9..cfdecf7 100644 --- a/src/browser.ts +++ b/src/browser/index.ts @@ -1,5 +1,7 @@ import puppeteer from "puppeteer"; -import config from "./config.ts"; +import config from "../config.ts"; +import type { BrowserAdapter } from "./browserAdapter.ts"; +import { PuppeteerAdapter } from "./puppeteerAdapter.ts"; export const isBrowserAvailable = async () => { try { @@ -12,15 +14,16 @@ export const isBrowserAvailable = async () => { } }; -export const getBrowser = async () => { +export const getBrowser = async (): Promise => { const endpoint = config.browser?.endpoint; const isProduction = Bun.env.NODE_ENV === "production"; // In non-production environments without an endpoint, launch a headful browser if (!isProduction && !endpoint) { - return puppeteer.launch({ + const browser = await puppeteer.launch({ headless: false, }); + return new PuppeteerAdapter(browser); } // In production or when an endpoint is configured, connect to the endpoint @@ -28,7 +31,8 @@ export const getBrowser = async () => { throw new Error("Browser endpoint is not configured"); } - return puppeteer.connect({ + const browser = await puppeteer.connect({ browserWSEndpoint: endpoint, }); + return new PuppeteerAdapter(browser); }; diff --git a/src/browser/mockBrowser.ts b/src/browser/mockBrowser.ts new file mode 100644 index 0000000..ca8c396 --- /dev/null +++ b/src/browser/mockBrowser.ts @@ -0,0 +1,193 @@ +import type { + BrowserAdapter, + Locator, + PageAdapter, + SelectorResult, +} from "./browserAdapter.ts"; + +/** + * Configuration for a mock page scenario + */ +export interface MockPageScenario { + /** + * Mock values to return when waitForSelector is called. + * Key is the selector, value is the text content to return. + */ + selectorValues?: Record; + + /** + * Mock URLs to return for page.url() calls. + * Updated as goto() is called or can be set explicitly. + */ + currentUrl?: string; + + /** + * Track interactions for verification in tests + */ + interactions?: Array<{ + type: string; + selector?: string; + value?: string; + url?: string; + }>; + + /** + * Whether to throw errors for specific selectors + */ + selectorErrors?: Record; + + /** + * URL transitions to simulate after specific actions + * Allows tests to simulate page navigation after interactions + */ + urlTransitions?: Array<{ + trigger: { + type: string; + selector?: string; + url?: string; + }; + newUrl: string; + }>; +} + +/** + * Mock selector result for testing + */ +class MockSelectorResult implements SelectorResult { + constructor(private value: string) {} + + async evaluate(_fn: (el: Element) => string | null): Promise { + return this.value; + } +} + +/** + * Mock locator for testing + */ +class MockLocator implements Locator { + constructor( + private selector: string, + private scenario: MockPageScenario, + ) {} + + async click(): Promise { + this.scenario.interactions?.push({ + type: "locator.click", + selector: this.selector, + }); + + // Check for URL transitions + const transition = this.scenario.urlTransitions?.find( + (t) => + t.trigger.type === "locator.click" && + t.trigger.selector === this.selector, + ); + if (transition) { + this.scenario.currentUrl = transition.newUrl; + } + } + + async fill(text: string): Promise { + this.scenario.interactions?.push({ + type: "locator.fill", + selector: this.selector, + value: text, + }); + } +} + +/** + * Mock page adapter for testing + */ +class MockPageAdapter implements PageAdapter { + constructor(private scenario: MockPageScenario) {} + + async goto(url: string): Promise { + this.scenario.interactions?.push({ type: "goto", url }); + + // Check for URL transitions + const transition = this.scenario.urlTransitions?.find( + (t) => t.trigger.type === "goto" && t.trigger.url === url, + ); + if (transition) { + this.scenario.currentUrl = transition.newUrl; + } else { + this.scenario.currentUrl = url; + } + } + + async type(selector: string, text: string): Promise { + this.scenario.interactions?.push({ type: "type", selector, value: text }); + } + + async click(selector: string): Promise { + this.scenario.interactions?.push({ type: "click", selector }); + + // Check for URL transitions + const transition = this.scenario.urlTransitions?.find( + (t) => t.trigger.type === "click" && t.trigger.selector === selector, + ); + if (transition) { + this.scenario.currentUrl = transition.newUrl; + } + } + + locator(selector: string): Locator { + return new MockLocator(selector, this.scenario); + } + + async waitForSelector(selector: string): Promise { + // Check if we should throw an error for this selector + if (this.scenario.selectorErrors?.[selector]) { + throw this.scenario.selectorErrors[selector]; + } + + // Return mock value if configured + const value = this.scenario.selectorValues?.[selector]; + if (value !== undefined) { + return new MockSelectorResult(value); + } + + return null; + } + + async waitForNetworkIdle(): Promise { + this.scenario.interactions?.push({ type: "waitForNetworkIdle" }); + } + + url(): string { + return this.scenario.currentUrl || "about:blank"; + } + + async close(): Promise { + this.scenario.interactions?.push({ type: "close" }); + } +} + +/** + * Mock browser adapter for testing + */ +export class MockBrowserAdapter implements BrowserAdapter { + constructor(private scenario: MockPageScenario) {} + + async newPage(): Promise { + return new MockPageAdapter(this.scenario); + } + + async close(): Promise { + this.scenario.interactions?.push({ type: "browser.close" }); + } +} + +/** + * Helper function to create a mock browser for testing + */ +export function createMockBrowser( + scenario: MockPageScenario = {}, +): BrowserAdapter { + // Initialize interactions array if not provided + if (!scenario.interactions) { + scenario.interactions = []; + } + return new MockBrowserAdapter(scenario); +} diff --git a/src/browser/puppeteerAdapter.ts b/src/browser/puppeteerAdapter.ts new file mode 100644 index 0000000..a4e6b77 --- /dev/null +++ b/src/browser/puppeteerAdapter.ts @@ -0,0 +1,96 @@ +import type { + Browser, + ElementHandle, + Page, + Locator as PuppeteerLocatorType, +} from "puppeteer"; +import type { + BrowserAdapter, + Locator, + PageAdapter, + SelectorResult, +} from "./browserAdapter.ts"; + +/** + * Wraps a Puppeteer element handle to provide evaluate functionality + */ +class PuppeteerSelectorResult implements SelectorResult { + constructor(private element: ElementHandle) {} + + async evaluate(fn: (el: Element) => string | null): Promise { + return this.element.evaluate(fn); + } +} + +/** + * Wraps Puppeteer locator functionality + */ +class PuppeteerLocator implements Locator { + constructor(private locator: PuppeteerLocatorType) {} + + async click(): Promise { + await this.locator.click(); + } + + async fill(text: string): Promise { + await this.locator.fill(text); + } +} + +/** + * Wraps a Puppeteer Page to implement PageAdapter interface + */ +class PuppeteerPageAdapter implements PageAdapter { + constructor(private page: Page) {} + + async goto(url: string): Promise { + await this.page.goto(url); + } + + async type(selector: string, text: string): Promise { + await this.page.type(selector, text); + } + + async click(selector: string): Promise { + await this.page.click(selector); + } + + locator(selector: string): Locator { + const locator = this.page.locator(selector); + return new PuppeteerLocator(locator); + } + + async waitForSelector(selector: string): Promise { + const element = await this.page.waitForSelector(selector); + if (!element) return null; + return new PuppeteerSelectorResult(element); + } + + async waitForNetworkIdle(): Promise { + await this.page.waitForNetworkIdle(); + } + + url(): string { + return this.page.url(); + } + + async close(): Promise { + await this.page.close(); + } +} + +/** + * Wraps a Puppeteer Browser to implement BrowserAdapter interface + */ +export class PuppeteerAdapter implements BrowserAdapter { + constructor(private browser: Browser) {} + + async newPage(): Promise { + const page = await this.browser.newPage(); + return new PuppeteerPageAdapter(page); + } + + async close(): Promise { + await this.browser.close(); + } +} diff --git a/src/connectors/standardLifePension.test.ts b/src/connectors/standardLifePension.test.ts new file mode 100644 index 0000000..5c8f4a4 --- /dev/null +++ b/src/connectors/standardLifePension.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from "bun:test"; +import { + createMockBrowser, + type MockPageScenario, +} from "../browser/mockBrowser.ts"; +import { StandardLifePensionConnector } from "./standardLifePension.ts"; + +describe("Standard Life Pension Connector", () => { + it("should successfully retrieve balance when no 2FA is required", async () => { + // Create a mock browser that simulates a successful login without 2FA + const mockScenario: MockPageScenario = { + currentUrl: "about:blank", + selectorValues: { + ".we_hud-plan-value-amount": "£45,678.90", + }, + interactions: [], + urlTransitions: [ + { + trigger: { type: "click", selector: "#submit" }, + newUrl: + "https://platform.secure.standardlife.co.uk/secure/customer-platform/dashboard", + }, + ], + }; + + const mockBrowser = createMockBrowser(mockScenario); + + // Create connector with mock browser + const connector = new StandardLifePensionConnector(async () => mockBrowser); + + // Test account + const account = { + type: "standard_life_pension" as const, + username: "test@example.com", + password: "testpassword", + policyNumber: "12345", + name: "Test Pension", + ynabAccountId: "test-account-id", + interval: "0 0 * * *", + }; + + const result = await connector.getBalance(account); + + // Verify the result + expect("balance" in result).toBe(true); + if ("balance" in result) { + expect(result.balance).toBe(45678.9); + } + + // Verify interactions happened in correct order + expect(mockScenario.interactions).toContainEqual({ + type: "goto", + url: "https://online.standardlife.com/secure/customer-authentication-client/customer/login", + }); + expect(mockScenario.interactions).toContainEqual({ + type: "type", + selector: "#userid", + value: "test@example.com", + }); + expect(mockScenario.interactions).toContainEqual({ + type: "type", + selector: "#password", + value: "testpassword", + }); + expect(mockScenario.interactions).toContainEqual({ + type: "click", + selector: "#submit", + }); + }); + + it("should return error when balance element is not found", async () => { + const mockScenario: MockPageScenario = { + currentUrl: "about:blank", + selectorValues: {}, + interactions: [], + urlTransitions: [ + { + trigger: { type: "click", selector: "#submit" }, + newUrl: + "https://platform.secure.standardlife.co.uk/secure/customer-platform/dashboard", + }, + ], + }; + + const mockBrowser = createMockBrowser(mockScenario); + + const connector = new StandardLifePensionConnector(async () => mockBrowser); + + const account = { + type: "standard_life_pension" as const, + username: "test@example.com", + password: "testpassword", + policyNumber: "12345", + name: "Test Pension", + ynabAccountId: "test-account-id", + interval: "0 0 * * *", + }; + + const result = await connector.getBalance(account); + + expect("error" in result).toBe(true); + if ("error" in result) { + expect(result.error).toBe("Could not find balance element on page"); + expect(result.canRetry).toBe(true); + } + }); + + it("should throw error for invalid account type", async () => { + const mockBrowser = createMockBrowser({}); + + const connector = new StandardLifePensionConnector(async () => mockBrowser); + + const account = { + type: "trading_212", + trading212ApiKey: "test", + trading212SecretKey: "test", + } as const; + + // @ts-expect-error - Testing invalid account type + await expect(connector.getBalance(account)).rejects.toThrow( + "Invalid account type for Standard Life Pension connector", + ); + }); + + it("should parse various balance formats correctly", async () => { + const testCases = [ + { input: "£45,678.90", expected: 45678.9 }, + { input: "£1,234.56", expected: 1234.56 }, + { input: "£100", expected: 100 }, + { input: "£1,000,000.00", expected: 1000000 }, + ]; + + for (const testCase of testCases) { + const mockScenario: MockPageScenario = { + currentUrl: "about:blank", + selectorValues: { + ".we_hud-plan-value-amount": testCase.input, + }, + interactions: [], + urlTransitions: [ + { + trigger: { type: "click", selector: "#submit" }, + newUrl: + "https://platform.secure.standardlife.co.uk/secure/customer-platform/dashboard", + }, + ], + }; + + const mockBrowser = createMockBrowser(mockScenario); + + const connector = new StandardLifePensionConnector( + async () => mockBrowser, + ); + + const account = { + type: "standard_life_pension" as const, + username: "test@example.com", + password: "testpassword", + policyNumber: "12345", + name: "Test Pension", + ynabAccountId: "test-account-id", + interval: "0 0 * * *", + }; + + const result = await connector.getBalance(account); + + expect("balance" in result).toBe(true); + if ("balance" in result) { + expect(result.balance).toBe(testCase.expected); + } + } + }); +}); diff --git a/src/connectors/standardLifePension.ts b/src/connectors/standardLifePension.ts index abcf0e7..1856713 100644 --- a/src/connectors/standardLifePension.ts +++ b/src/connectors/standardLifePension.ts @@ -1,5 +1,6 @@ import { await2FACode } from "../2fa.ts"; -import { getBrowser } from "../browser.ts"; +import type { BrowserAdapter } from "../browser/browserAdapter.ts"; +import { getBrowser } from "../browser/index.ts"; import type config from "../config.ts"; import type { AccountResult, Connector } from "./index.ts"; @@ -18,9 +19,13 @@ const parseBalanceString = (input: string): number => { return parseFloat(m[1]?.replace(/,/g, "") || "0"); }; -class StandardLifePensionConnector implements Connector { +export class StandardLifePensionConnector implements Connector { friendlyName = "Standard Life Pension"; + constructor( + private browserFactory: () => Promise = getBrowser, + ) {} + async getBalance(account: AccountType): Promise { if (account.type !== "standard_life_pension") { throw new Error( @@ -29,7 +34,7 @@ class StandardLifePensionConnector implements Connector { } // get a browser instance - const browser = await getBrowser(); + const browser = await this.browserFactory(); // sign in const page = await browser.newPage(); diff --git a/src/connectors/trading212.mock.ts b/src/connectors/trading212.mock.ts new file mode 100644 index 0000000..e269e3d --- /dev/null +++ b/src/connectors/trading212.mock.ts @@ -0,0 +1,125 @@ +import { type HttpHandler, HttpResponse, http } from "msw"; + +const TRADING212_BASE_URL = "https://live.trading212.com/api/v0"; + +/** + * Trading 212 API mock response types + */ +export interface Trading212AccountCash { + blocked: number; + free: number; + invested: number; + pieCash: number; + ppl: number; + result: number; + total: number; +} + +/** + * Helper function to validate Basic auth header + */ +const validateAuthHeader = ( + authHeader: string | null, +): { valid: boolean; apiKey?: string; apiSecret?: string } => { + if (!authHeader || !authHeader.startsWith("Basic ")) { + return { valid: false }; + } + + try { + const base64Credentials = authHeader.slice(6); + const decoded = Buffer.from(base64Credentials, "base64").toString("utf-8"); + const [apiKey, apiSecret] = decoded.split(":"); + + if (!apiKey || !apiSecret) { + return { valid: false }; + } + + return { valid: true, apiKey, apiSecret }; + } catch { + return { valid: false }; + } +}; + +/** + * Create Trading 212 API mock handlers + */ +export const createTrading212Handlers = ( + config: { + validApiKey?: string; + validApiSecret?: string; + accountCash?: Trading212AccountCash; + returnError?: "unauthorized" | "forbidden" | "rate-limit" | "server-error"; + returnInvalidFormat?: boolean; + } = {}, +): HttpHandler[] => { + const { + validApiKey = "valid-api-key", + validApiSecret = "valid-api-secret", + accountCash = { + blocked: 0, + free: 1000.5, + invested: 5000.25, + pieCash: 0, + ppl: 234.75, + result: 5235, + total: 6235.5, + }, + returnError, + returnInvalidFormat = false, + } = config; + + return [ + // GET /equity/account/cash - Get account cash information + http.get(`${TRADING212_BASE_URL}/equity/account/cash`, ({ request }) => { + const authHeader = request.headers.get("Authorization"); + const auth = validateAuthHeader(authHeader); + + // Handle forced error scenarios + if (returnError === "rate-limit") { + return HttpResponse.json( + { message: "Too Many Requests" }, + { status: 429, statusText: "Too Many Requests" }, + ); + } + + if (returnError === "server-error") { + return HttpResponse.json( + { message: "Internal Server Error" }, + { status: 500, statusText: "Internal Server Error" }, + ); + } + + if (returnError === "forbidden") { + return HttpResponse.json( + { message: "Forbidden" }, + { status: 403, statusText: "Forbidden" }, + ); + } + + // Validate authentication + if ( + !auth.valid || + auth.apiKey !== validApiKey || + auth.apiSecret !== validApiSecret + ) { + return HttpResponse.json( + { message: "Unauthorized" }, + { status: 401, statusText: "Unauthorized" }, + ); + } + + // Return invalid format if requested + if (returnInvalidFormat) { + return HttpResponse.json({ invalid: "data" }); + } + + // Return successful account cash response + return HttpResponse.json(accountCash); + }), + ]; +}; + +/** + * Default Trading 212 handlers for testing + */ +export const trading212Handlers = createTrading212Handlers(); diff --git a/src/connectors/trading212.test.ts b/src/connectors/trading212.test.ts index fc86ee6..7043d53 100644 --- a/src/connectors/trading212.test.ts +++ b/src/connectors/trading212.test.ts @@ -1,7 +1,172 @@ -import { describe, expect, it } from "bun:test"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test"; +import { setupServer } from "msw/node"; +import { createTrading212Handlers } from "./trading212.mock.ts"; +import { getTrading212Balance } from "./trading212.ts"; -describe("Trading 212", () => { - it("should return an error if API key is invalid", async () => { - expect(true).toBe(true); +describe("Trading 212 Connector", () => { + const server = setupServer(); + + beforeAll(() => { + server.listen({ onUnhandledRequest: "error" }); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + afterAll(() => { + server.close(); + }); + + it("should successfully retrieve balance", async () => { + const mockResponse = { + blocked: 0, + free: 1000.5, + invested: 5000.25, + pieCash: 0, + ppl: 234.75, + result: 5235, + total: 6235.5, + }; + + server.use( + ...createTrading212Handlers({ + validApiKey: "test-api-key", + validApiSecret: "test-secret", + accountCash: mockResponse, + }), + ); + + const result = await getTrading212Balance("test-api-key", "test-secret"); + + expect("balance" in result).toBe(true); + if ("balance" in result) { + expect(result.balance).toBe(6235.5); + } + }); + + it("should return error for unauthorized access (401)", async () => { + server.use( + ...createTrading212Handlers({ + validApiKey: "correct-key", + validApiSecret: "correct-secret", + }), + ); + + const result = await getTrading212Balance("invalid-key", "invalid-secret"); + + expect("error" in result).toBe(true); + if ("error" in result) { + expect(result.error).toBe( + "Unauthorized: Check your Trading212 API Key and Secret Key.", + ); + expect(result.canRetry).toBe(false); + } + }); + + it("should return error for forbidden access (403)", async () => { + server.use( + ...createTrading212Handlers({ + validApiKey: "test-api-key", + validApiSecret: "test-secret", + returnError: "forbidden", + }), + ); + + const result = await getTrading212Balance("test-api-key", "test-secret"); + + expect("error" in result).toBe(true); + if ("error" in result) { + expect(result.error).toBe( + "Forbidden: Check your Trading212 API Key scopes.", + ); + expect(result.canRetry).toBe(false); + } + }); + + it("should return error for rate limit exceeded (429)", async () => { + server.use( + ...createTrading212Handlers({ + validApiKey: "test-api-key", + validApiSecret: "test-secret", + returnError: "rate-limit", + }), + ); + + const result = await getTrading212Balance("test-api-key", "test-secret"); + + expect("error" in result).toBe(true); + if ("error" in result) { + expect(result.error).toBe( + "Rate limit exceeded: Too many requests to Trading212 API.", + ); + expect(result.canRetry).toBe(false); + } + }); + + it("should return error for other HTTP errors", async () => { + server.use( + ...createTrading212Handlers({ + validApiKey: "test-api-key", + validApiSecret: "test-secret", + returnError: "server-error", + }), + ); + + const result = await getTrading212Balance("test-api-key", "test-secret"); + + expect("error" in result).toBe(true); + if ("error" in result) { + expect(result.error).toBe( + "Error fetching Trading212 balance: 500 Internal Server Error", + ); + expect(result.canRetry).toBe(true); + } + }); + + it("should return error for invalid response format", async () => { + server.use( + ...createTrading212Handlers({ + validApiKey: "test-api-key", + validApiSecret: "test-secret", + returnInvalidFormat: true, + }), + ); + + const result = await getTrading212Balance("test-api-key", "test-secret"); + + expect("error" in result).toBe(true); + if ("error" in result) { + expect(result.error).toBe( + "Unexpected response format from Trading212 API.", + ); + expect(result.canRetry).toBe(false); + } + }); + + it("should correctly encode API credentials in Authorization header", async () => { + server.use( + ...createTrading212Handlers({ + validApiKey: "myApiKey", + validApiSecret: "mySecret", + accountCash: { + blocked: 0, + free: 0, + invested: 0, + pieCash: 0, + ppl: 0, + result: 0, + total: 100, + }, + }), + ); + + const result = await getTrading212Balance("myApiKey", "mySecret"); + + // If the credentials were correctly encoded, the request should succeed + expect("balance" in result).toBe(true); + if ("balance" in result) { + expect(result.balance).toBe(100); + } }); }); diff --git a/src/connectors/ukStudentLoan.test.ts b/src/connectors/ukStudentLoan.test.ts new file mode 100644 index 0000000..ca1acec --- /dev/null +++ b/src/connectors/ukStudentLoan.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from "bun:test"; +import { + createMockBrowser, + type MockPageScenario, +} from "../browser/mockBrowser.ts"; +import { + getUkStudentLoanBalance, + parseBalanceString, +} from "./ukStudentLoan.ts"; + +describe("UK Student Loan Connector", () => { + it("should successfully retrieve balance", async () => { + const mockScenario: MockPageScenario = { + currentUrl: "about:blank", + selectorValues: { + "#balanceId_1": "£45,678.90", + }, + interactions: [], + urlTransitions: [ + { + trigger: { + type: "goto", + url: "https://www.gov.uk/sign-in-to-manage-your-student-loan-balance", + }, + newUrl: + "https://www.gov.uk/sign-in-to-manage-your-student-loan-balance", + }, + { + trigger: { type: "locator.click", selector: "text/Start now" }, + newUrl: "https://www.gov.uk/manage-student-loan/select-option", + }, + { + trigger: { type: "locator.click", selector: "text/Continue" }, + newUrl: "https://www.gov.uk/manage-student-loan/login", + }, + { + trigger: { type: "locator.click", selector: "text/Login to account" }, + newUrl: "https://www.gov.uk/manage-student-loan/dashboard", + }, + ], + }; + + const mockBrowser = createMockBrowser(mockScenario); + + const result = await getUkStudentLoanBalance( + "test@example.com", + "testpassword", + "testsecret", + async () => mockBrowser, + ); + + // Verify the result - balance should be negative (it's a loan) + expect("balance" in result).toBe(true); + if ("balance" in result) { + expect(result.balance).toBe(-45678.9); + } + + // Verify interactions happened in correct order + expect(mockScenario.interactions).toContainEqual({ + type: "goto", + url: "https://www.gov.uk/sign-in-to-manage-your-student-loan-balance", + }); + expect(mockScenario.interactions).toContainEqual({ + type: "locator.click", + selector: "text/Start now", + }); + expect(mockScenario.interactions).toContainEqual({ + type: "locator.click", + selector: "#textForSignIn1", + }); + expect(mockScenario.interactions).toContainEqual({ + type: "locator.fill", + selector: "input#userId", + value: "test@example.com", + }); + expect(mockScenario.interactions).toContainEqual({ + type: "locator.fill", + selector: "input#password", + value: "testpassword", + }); + expect(mockScenario.interactions).toContainEqual({ + type: "locator.fill", + selector: "input#secretAnswer", + value: "testsecret", + }); + }); + + it("should return error when balance element is not found", async () => { + const mockScenario: MockPageScenario = { + currentUrl: "about:blank", + selectorValues: {}, + interactions: [], + urlTransitions: [ + { + trigger: { + type: "goto", + url: "https://www.gov.uk/sign-in-to-manage-your-student-loan-balance", + }, + newUrl: + "https://www.gov.uk/sign-in-to-manage-your-student-loan-balance", + }, + { + trigger: { type: "locator.click", selector: "text/Start now" }, + newUrl: "https://www.gov.uk/manage-student-loan/select-option", + }, + { + trigger: { type: "locator.click", selector: "text/Continue" }, + newUrl: "https://www.gov.uk/manage-student-loan/login", + }, + { + trigger: { type: "locator.click", selector: "text/Login to account" }, + newUrl: "https://www.gov.uk/manage-student-loan/dashboard", + }, + ], + }; + + const mockBrowser = createMockBrowser(mockScenario); + + const result = await getUkStudentLoanBalance( + "test@example.com", + "testpassword", + "testsecret", + async () => mockBrowser, + ); + + expect("error" in result).toBe(true); + if ("error" in result) { + expect(result.error).toBe( + "Could not find balance element on UK Student Loan page", + ); + expect(result.canRetry).toBe(false); + } + }); + + it("should parse various balance formats correctly", async () => { + const testCases = [ + { input: "£45,678.90", expected: -45678.9 }, + { input: "£1,234.56", expected: -1234.56 }, + { input: "£100", expected: -100 }, + { input: "£1,000,000.00", expected: -1000000 }, + ]; + + for (const testCase of testCases) { + const mockScenario: MockPageScenario = { + currentUrl: "about:blank", + selectorValues: { + "#balanceId_1": testCase.input, + }, + interactions: [], + urlTransitions: [ + { + trigger: { + type: "goto", + url: "https://www.gov.uk/sign-in-to-manage-your-student-loan-balance", + }, + newUrl: + "https://www.gov.uk/sign-in-to-manage-your-student-loan-balance", + }, + { + trigger: { type: "locator.click", selector: "text/Start now" }, + newUrl: "https://www.gov.uk/manage-student-loan/select-option", + }, + { + trigger: { type: "locator.click", selector: "text/Continue" }, + newUrl: "https://www.gov.uk/manage-student-loan/login", + }, + { + trigger: { + type: "locator.click", + selector: "text/Login to account", + }, + newUrl: "https://www.gov.uk/manage-student-loan/dashboard", + }, + ], + }; + + const mockBrowser = createMockBrowser(mockScenario); + + const result = await getUkStudentLoanBalance( + "test@example.com", + "testpassword", + "testsecret", + async () => mockBrowser, + ); + + expect("balance" in result).toBe(true); + if ("balance" in result) { + expect(result.balance).toBe(testCase.expected); + } + } + }); + + it("should handle parseBalanceString edge cases", () => { + expect(parseBalanceString("£45,678.90")).toBe(45678.9); + expect(parseBalanceString("£1,234.56")).toBe(1234.56); + expect(parseBalanceString("£100")).toBe(100); + expect(parseBalanceString("invalid")).toBe(0); + expect(parseBalanceString("")).toBe(0); + }); +}); diff --git a/src/connectors/ukStudentLoan.ts b/src/connectors/ukStudentLoan.ts index d02ff6c..5ab39b1 100644 --- a/src/connectors/ukStudentLoan.ts +++ b/src/connectors/ukStudentLoan.ts @@ -1,4 +1,5 @@ -import { getBrowser, isBrowserAvailable } from "../browser.ts"; +import { getBrowser, isBrowserAvailable } from "../browser"; +import type { BrowserAdapter } from "../browser/browserAdapter.ts"; import type { AccountResult } from "./index.ts"; const parseBalanceString = (input: string): number => { @@ -11,6 +12,7 @@ const getUkStudentLoanBalance = async ( slcEmail: string, slcPassword: string, slcSecretAnswer: string, + browserFactory: () => Promise = getBrowser, ): Promise => { const browserAvailable = await isBrowserAvailable(); if (!browserAvailable) { @@ -21,7 +23,7 @@ const getUkStudentLoanBalance = async ( }; } - const browser = await getBrowser(); + const browser = await browserFactory(); const page = await browser.newPage(); diff --git a/src/index.ts b/src/index.ts index 1f5c769..03b3ed8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -58,7 +58,9 @@ if (command === "run") { } // start 2FA server -start2FAServer(config.server?.port || 4030); +if (config.server) { + start2FAServer(config.server.port); +} // schedule jobs for each account const jobs: Map = new Map(); @@ -83,10 +85,6 @@ for (const account of config.accounts) { }, `Scheduled job successfully`, ); - - if (Bun.env.NODE_ENV !== "production") { - await task.execute(); - } } // schedule summary job diff --git a/src/runtime.ts b/src/runtime.ts index d97b665..f017e7d 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -18,14 +18,23 @@ const getBalanceWithRetry = async ( return new Promise((resolve) => { operation.attempt(async (currentAttempt) => { - const result = await connector.getBalance(account); + let result: AccountResult; + + try { + result = await connector.getBalance(account); + } catch (e) { + result = { + canRetry: true, + error: (e as Error).message, + }; + } if ("balance" in result) { return resolve(result); } if (!result.canRetry) { - logger.info( + logger.error( { account: account.name, type: account.type, @@ -78,7 +87,7 @@ const runSyncJob = async (account: Account) => { return; } - logger.info( + logger.debug( { type: account.type, balance: result.balance, @@ -111,6 +120,8 @@ const runSyncJob = async (account: Account) => { { type: account.type, balance: result.balance, + accountId: account.ynabAccountId, + accountName: account.name, }, `Adjusted balance in YNAB successfully`, ); diff --git a/src/ynab.ts b/src/ynab.ts index 73f92c4..0397d8c 100644 --- a/src/ynab.ts +++ b/src/ynab.ts @@ -54,7 +54,7 @@ const adjustBalance = async ( } if (balanceDelta === 0) { - log.info( + log.debug( { accountId, amount, date: dateToYnabFormat(balanceDate) }, `No adjustment needed.`, ); @@ -74,7 +74,7 @@ const adjustBalance = async ( ); if (existingTransaction) { - log.info( + log.debug( { accountId, transactionId: existingTransaction.id, From 446ee9791366a10794307256d5b8303e1f475180 Mon Sep 17 00:00:00 2001 From: simse Date: Thu, 16 Oct 2025 19:53:01 +0100 Subject: [PATCH 2/5] fix: Lazy load config --- .gitignore | 4 +-- src/browser/index.ts | 3 +- src/config.ts | 39 ++++++++++++++-------- src/connectors/index.ts | 12 +++---- src/connectors/standardLifePension.ts | 6 ++-- src/index.ts | 3 +- src/ynab.ts | 47 ++++++++++++++++----------- 7 files changed, 66 insertions(+), 48 deletions(-) diff --git a/.gitignore b/.gitignore index 1b5b54b..0728873 100644 --- a/.gitignore +++ b/.gitignore @@ -187,6 +187,4 @@ docs/.vitepress/cache docs/.vitepress/config-schema.json # Claude Code -.claude - -lightpanda \ No newline at end of file +.claude \ No newline at end of file diff --git a/src/browser/index.ts b/src/browser/index.ts index cfdecf7..8b2baec 100644 --- a/src/browser/index.ts +++ b/src/browser/index.ts @@ -1,5 +1,5 @@ import puppeteer from "puppeteer"; -import config from "../config.ts"; +import { getConfig } from "../config.ts"; import type { BrowserAdapter } from "./browserAdapter.ts"; import { PuppeteerAdapter } from "./puppeteerAdapter.ts"; @@ -15,6 +15,7 @@ export const isBrowserAvailable = async () => { }; export const getBrowser = async (): Promise => { + const config = await getConfig(); const endpoint = config.browser?.endpoint; const isProduction = Bun.env.NODE_ENV === "production"; diff --git a/src/config.ts b/src/config.ts index 2b7c740..8515ab7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -115,22 +115,35 @@ const schemaConfig = z export { schemaConfig }; +export type Config = z.infer; + const configPath = Bun.env.NODE_ENV === "production" ? "/config.yaml" : path.join(__dirname, "../config.yaml"); -// TODO: ensure this only loads once -const config = await loadConfig({ - schema: schemaConfig, - adapters: yamlAdapter({ - path: configPath, - }), - onError: (e) => { - console.error(`error while loading config: ${fromError(e).message}`); - process.exit(1); - }, - logger, -}); +let cachedConfig: Config | null = null; + +/** + * Get the configuration, loading it if not already loaded. + * The config is cached after the first load. + */ +export const getConfig = async (): Promise => { + if (cachedConfig) { + return cachedConfig; + } -export default config; + cachedConfig = await loadConfig({ + schema: schemaConfig, + adapters: yamlAdapter({ + path: configPath, + }), + onError: (e) => { + console.error(`error while loading config: ${fromError(e).message}`); + process.exit(1); + }, + logger, + }); + + return cachedConfig; +}; diff --git a/src/connectors/index.ts b/src/connectors/index.ts index f83b2ec..0e42cb1 100644 --- a/src/connectors/index.ts +++ b/src/connectors/index.ts @@ -1,16 +1,14 @@ -import type config from "../config.ts"; +import type { Config } from "../config.ts"; import { standardLifePensionConnector } from "./standardLifePension.ts"; import { getTrading212Balance } from "./trading212.ts"; import { getUkStudentLoanBalance } from "./ukStudentLoan.ts"; -type AccountType = (typeof config.accounts)[number]["type"]; +type AccountType = Config["accounts"][number]["type"]; const connectors: { [type in AccountType]: { friendlyName: string; - getBalance: ( - account: (typeof config.accounts)[number], - ) => Promise; + getBalance: (account: Config["accounts"][number]) => Promise; }; } = { trading212: { @@ -64,9 +62,7 @@ type AccountResult = interface Connector { friendlyName: string; - getBalance: ( - account: (typeof config.accounts)[number], - ) => Promise; + getBalance: (account: Config["accounts"][number]) => Promise; } export { connectors }; diff --git a/src/connectors/standardLifePension.ts b/src/connectors/standardLifePension.ts index 1856713..e1d7ef4 100644 --- a/src/connectors/standardLifePension.ts +++ b/src/connectors/standardLifePension.ts @@ -1,10 +1,10 @@ import { await2FACode } from "../2fa.ts"; +import { getBrowser } from "../browser"; import type { BrowserAdapter } from "../browser/browserAdapter.ts"; -import { getBrowser } from "../browser/index.ts"; -import type config from "../config.ts"; +import type { Config } from "../config.ts"; import type { AccountResult, Connector } from "./index.ts"; -type AccountType = (typeof config.accounts)[number]; +type AccountType = Config["accounts"][number]; const STANDARD_LIFE_PENSION_AUTH_URL = "https://online.standardlife.com/secure/customer-authentication-client/customer/login"; diff --git a/src/index.ts b/src/index.ts index 03b3ed8..a0ee719 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,7 +21,8 @@ if (command === "export-schema") { logger.info("Welcome to ynab-connect"); // load config only when needed -const config = (await import("./config.ts")).default; +const { getConfig } = await import("./config.ts"); +const config = await getConfig(); // check YNAB budget exists const budgetExists = await ensureBudgetExists(config.ynab.budgetId); diff --git a/src/ynab.ts b/src/ynab.ts index 0397d8c..b12ad30 100644 --- a/src/ynab.ts +++ b/src/ynab.ts @@ -1,15 +1,25 @@ import * as ynab from "ynab"; -import config from "./config.ts"; +import { getConfig } from "./config.ts"; import logger from "./logger.ts"; const YNAB_MEMO = "Automated balance adjustment created by ynab-connect"; const YNAB_PAYEE = "Balance Adjustment"; -const ynabAPI = new ynab.API(config.ynab.accessToken); +let ynabAPI: ynab.API | null = null; + +const getYnabAPI = async () => { + if (ynabAPI) { + return ynabAPI; + } + const config = await getConfig(); + ynabAPI = new ynab.API(config.ynab.accessToken); + return ynabAPI; +}; const ensureBudgetExists = async (budgetId: string) => { + const api = await getYnabAPI(); try { - await ynabAPI.budgets.getBudgetById(budgetId); + await api.budgets.getBudgetById(budgetId); } catch (_e) { return false; } @@ -18,9 +28,11 @@ const ensureBudgetExists = async (budgetId: string) => { }; const getAccountBalance = async (accountId: string) => { + const config = await getConfig(); + const api = await getYnabAPI(); const budgetId = config.ynab.budgetId; - const accountResponse = await ynabAPI.accounts.getAccountById( + const accountResponse = await api.accounts.getAccountById( budgetId, accountId, ); @@ -38,6 +50,8 @@ const adjustBalance = async ( date?: Date, log = logger, ) => { + const config = await getConfig(); + const api = await getYnabAPI(); const budgetId = config.ynab.budgetId; const balanceDate = date ?? new Date(); @@ -62,12 +76,11 @@ const adjustBalance = async ( } // check if there's already a transaction with the same memo and date - const transactionsResponse = - await ynabAPI.transactions.getTransactionsByAccount( - budgetId, - accountId, - dateToYnabFormat(balanceDate), - ); + const transactionsResponse = await api.transactions.getTransactionsByAccount( + budgetId, + accountId, + dateToYnabFormat(balanceDate), + ); const existingTransaction = transactionsResponse.data.transactions.find( (t) => t.memo === YNAB_MEMO && t.date === dateToYnabFormat(balanceDate), @@ -84,20 +97,16 @@ const adjustBalance = async ( `An adjustment transaction already exists, updating that transaction instead of creating a new one.`, ); - await ynabAPI.transactions.updateTransaction( - budgetId, - existingTransaction.id, - { - transaction: { - amount: balanceDelta + existingTransaction.amount, - }, + await api.transactions.updateTransaction(budgetId, existingTransaction.id, { + transaction: { + amount: balanceDelta + existingTransaction.amount, }, - ); + }); return; } - await ynabAPI.transactions.createTransaction(budgetId, { + await api.transactions.createTransaction(budgetId, { transaction: { account_id: accountId, cleared: "reconciled", From 423f05beb309fec539d20e089564a889708b2cbe Mon Sep 17 00:00:00 2001 From: simse Date: Thu, 16 Oct 2025 19:56:16 +0100 Subject: [PATCH 3/5] ci: Add docs build check --- .github/workflows/docs.yml | 8 ++++++++ biome.json | 2 +- docs/.vitepress/config.ts | 4 +++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index ac38add..322ff1f 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -3,6 +3,13 @@ on: push: branches: - main + pull_request: + types: + - opened + - synchronize + - reopened +permissions: + contents: read jobs: publish_docs: runs-on: ubuntu-22.04 @@ -22,6 +29,7 @@ jobs: - name: Deploy docs to Cloudflare uses: cloudflare/wrangler-action@v3 + if: github.ref == 'refs/heads/main' with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/biome.json b/biome.json index 047cd18..9092b79 100644 --- a/biome.json +++ b/biome.json @@ -7,7 +7,7 @@ }, "files": { "ignoreUnknown": false, - "includes": ["src/**/*.ts"] + "includes": ["src/**/*.ts", "docs/**/*.ts", ".github/workflows/*.yml"] }, "formatter": { "enabled": true, diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 8c64812..fe01a01 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -69,7 +69,9 @@ export default defineConfig({ }, { text: "Guides", - items: [{ text: "Create YNAB Token", link: "/guide/create-ynab-token" }], + items: [ + { text: "Create YNAB Token", link: "/guide/create-ynab-token" }, + ], }, { text: "Connectors", From 782704e28dc709ca626f747eabbc4e43d7a2f4d5 Mon Sep 17 00:00:00 2001 From: simse Date: Thu, 16 Oct 2025 20:04:18 +0100 Subject: [PATCH 4/5] fix: Fix unit test not working in CI --- src/connectors/ukStudentLoan.ts | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/connectors/ukStudentLoan.ts b/src/connectors/ukStudentLoan.ts index 5ab39b1..d2ca856 100644 --- a/src/connectors/ukStudentLoan.ts +++ b/src/connectors/ukStudentLoan.ts @@ -12,18 +12,21 @@ const getUkStudentLoanBalance = async ( slcEmail: string, slcPassword: string, slcSecretAnswer: string, - browserFactory: () => Promise = getBrowser, + browserFactory?: () => Promise, ): Promise => { - const browserAvailable = await isBrowserAvailable(); - if (!browserAvailable) { - return { - error: - "Browser is not available. Please check your browser configuration.", - canRetry: false, - }; + // Only check browser availability when using the default factory + if (!browserFactory) { + const browserAvailable = await isBrowserAvailable(); + if (!browserAvailable) { + return { + error: + "Browser is not available. Please check your browser configuration.", + canRetry: false, + }; + } } - const browser = await browserFactory(); + const browser = await (browserFactory ?? getBrowser)(); const page = await browser.newPage(); From 5b103eeaf3060564a550a108b63710fb6dd5907d Mon Sep 17 00:00:00 2001 From: simse Date: Fri, 17 Oct 2025 21:50:46 +0100 Subject: [PATCH 5/5] chore: Update UK student loan connector --- src/browser/index.ts | 11 ----------- src/connectors/ukStudentLoan.ts | 18 +++--------------- 2 files changed, 3 insertions(+), 26 deletions(-) diff --git a/src/browser/index.ts b/src/browser/index.ts index 8b2baec..a7c1fe0 100644 --- a/src/browser/index.ts +++ b/src/browser/index.ts @@ -3,17 +3,6 @@ import { getConfig } from "../config.ts"; import type { BrowserAdapter } from "./browserAdapter.ts"; import { PuppeteerAdapter } from "./puppeteerAdapter.ts"; -export const isBrowserAvailable = async () => { - try { - const browser = await getBrowser(); - await browser.close(); - - return true; - } catch { - return false; - } -}; - export const getBrowser = async (): Promise => { const config = await getConfig(); const endpoint = config.browser?.endpoint; diff --git a/src/connectors/ukStudentLoan.ts b/src/connectors/ukStudentLoan.ts index d2ca856..a087d73 100644 --- a/src/connectors/ukStudentLoan.ts +++ b/src/connectors/ukStudentLoan.ts @@ -1,4 +1,4 @@ -import { getBrowser, isBrowserAvailable } from "../browser"; +import { getBrowser } from "../browser"; import type { BrowserAdapter } from "../browser/browserAdapter.ts"; import type { AccountResult } from "./index.ts"; @@ -12,21 +12,9 @@ const getUkStudentLoanBalance = async ( slcEmail: string, slcPassword: string, slcSecretAnswer: string, - browserFactory?: () => Promise, + browserFactory: () => Promise = getBrowser, ): Promise => { - // Only check browser availability when using the default factory - if (!browserFactory) { - const browserAvailable = await isBrowserAvailable(); - if (!browserAvailable) { - return { - error: - "Browser is not available. Please check your browser configuration.", - canRetry: false, - }; - } - } - - const browser = await (browserFactory ?? getBrowser)(); + const browser = await browserFactory(); const page = await browser.newPage();