Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }}
Expand Down
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
},
"files": {
"ignoreUnknown": false,
"includes": ["src/**/*.ts"]
"includes": ["src/**/*.ts", "docs/**/*.ts", ".github/workflows/*.yml"]
},
"formatter": {
"enabled": true,
Expand Down
Binary file modified bun.lockb
Binary file not shown.
4 changes: 3 additions & 1 deletion docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
43 changes: 43 additions & 0 deletions src/browser/browserAdapter.ts
Original file line number Diff line number Diff line change
@@ -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<string | null>;
}

/**
* Represents a locator for interacting with elements
*/
export interface Locator {
click(): Promise<void>;
fill(text: string): Promise<void>;
}

/**
* Represents a browser page with methods for automation
*/
export interface PageAdapter {
goto(url: string): Promise<void>;
type(selector: string, text: string): Promise<void>;
click(selector: string): Promise<void>;
locator(selector: string): Locator;
waitForSelector(selector: string): Promise<SelectorResult | null>;
waitForNetworkIdle(): Promise<void>;
url(): string;
close(): Promise<void>;
}

/**
* Represents a browser instance
*/
export interface BrowserAdapter {
newPage(): Promise<PageAdapter>;
close(): Promise<void>;
}
24 changes: 9 additions & 15 deletions src/browser.ts → src/browser/index.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,28 @@
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";

export const isBrowserAvailable = async () => {
try {
const browser = await getBrowser();
await browser.close();

return true;
} catch {
return false;
}
};

export const getBrowser = async () => {
export const getBrowser = async (): Promise<BrowserAdapter> => {
const config = await getConfig();
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
if (!endpoint) {
throw new Error("Browser endpoint is not configured");
}

return puppeteer.connect({
const browser = await puppeteer.connect({
browserWSEndpoint: endpoint,
});
return new PuppeteerAdapter(browser);
};
193 changes: 193 additions & 0 deletions src/browser/mockBrowser.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;

/**
* 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<string, Error>;

/**
* 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<string | null> {
return this.value;
}
}

/**
* Mock locator for testing
*/
class MockLocator implements Locator {
constructor(
private selector: string,
private scenario: MockPageScenario,
) {}

async click(): Promise<void> {
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<void> {
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<void> {
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<void> {
this.scenario.interactions?.push({ type: "type", selector, value: text });
}

async click(selector: string): Promise<void> {
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<SelectorResult | null> {
// 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<void> {
this.scenario.interactions?.push({ type: "waitForNetworkIdle" });
}

url(): string {
return this.scenario.currentUrl || "about:blank";
}

async close(): Promise<void> {
this.scenario.interactions?.push({ type: "close" });
}
}

/**
* Mock browser adapter for testing
*/
export class MockBrowserAdapter implements BrowserAdapter {
constructor(private scenario: MockPageScenario) {}

async newPage(): Promise<PageAdapter> {
return new MockPageAdapter(this.scenario);
}

async close(): Promise<void> {
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);
}
Loading