mirror of
https://github.com/bdnugget/rs-sdk.git
synced 2026-11-07 09:07:30 +00:00
387 lines
12 KiB
TypeScript
387 lines
12 KiB
TypeScript
/**
|
|
* Browser launch helper for SDK-based tests.
|
|
* Launches a Puppeteer browser with the game client.
|
|
* No CLI dependency - just browser management.
|
|
*/
|
|
|
|
import puppeteer, { Browser, Page } from 'puppeteer';
|
|
import { BotSDK } from '../../sdk';
|
|
import { BotActions } from '../../sdk/actions';
|
|
import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
|
|
const BOT_URL = process.env.BOT_URL || 'http://localhost:8888/bot';
|
|
|
|
// Persistent browser endpoint file - allows cross-process browser sharing
|
|
const BROWSER_ENDPOINT_FILE = join(tmpdir(), 'rs-agent-browser-endpoint.txt');
|
|
|
|
// HEADLESS env var: 'true' or '1' for headless, anything else for visible browser
|
|
// Default: false (show browser) for easier debugging
|
|
const DEFAULT_HEADLESS = process.env.HEADLESS === 'true' || process.env.HEADLESS === '1';
|
|
|
|
// BACKGROUND env var: 'true' or '1' to spawn windows off-screen (won't steal focus)
|
|
const DEFAULT_BACKGROUND = process.env.BACKGROUND === 'true' || process.env.BACKGROUND === '1';
|
|
|
|
// Hold browser open for inspection when not headless
|
|
const CLEANUP_DELAY_MS = 2000;
|
|
|
|
export const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
|
|
|
|
// Shared browser instance for multi-bot scenarios
|
|
let sharedBrowser: Browser | null = null;
|
|
let sharedBrowserRefCount = 0;
|
|
|
|
// Game canvas size (must match the canvas element in bot.ejs)
|
|
const GAME_WIDTH = 765;
|
|
const GAME_HEIGHT = 600;
|
|
|
|
// Chrome args optimized for lower resource usage
|
|
const LIGHTWEIGHT_CHROME_ARGS = [
|
|
'--no-sandbox',
|
|
'--disable-setuid-sandbox',
|
|
'--mute-audio',
|
|
'--disable-gpu',
|
|
'--disable-dev-shm-usage',
|
|
'--disable-accelerated-2d-canvas',
|
|
'--disable-backgrounding-occluded-windows',
|
|
'--disable-renderer-backgrounding',
|
|
'--disable-background-timer-throttling',
|
|
'--disable-ipc-flooding-protection',
|
|
// Additional performance flags
|
|
'--disable-extensions',
|
|
'--disable-component-extensions-with-background-pages',
|
|
'--disable-default-apps',
|
|
'--disable-hang-monitor',
|
|
'--disable-popup-blocking',
|
|
'--disable-prompt-on-repost',
|
|
'--disable-sync',
|
|
'--disable-translate',
|
|
'--metrics-recording-only',
|
|
'--safebrowsing-disable-auto-update',
|
|
'--disable-infobars',
|
|
'--disable-features=TranslateUI',
|
|
// Set window size to match game canvas (avoids dead area)
|
|
`--window-size=${GAME_WIDTH},${GAME_HEIGHT}`,
|
|
];
|
|
|
|
/**
|
|
* Try to connect to an existing browser via saved WebSocket endpoint.
|
|
* Returns null if no browser available or connection fails.
|
|
*/
|
|
async function tryConnectToExistingBrowser(): Promise<Browser | null> {
|
|
if (!existsSync(BROWSER_ENDPOINT_FILE)) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const wsEndpoint = readFileSync(BROWSER_ENDPOINT_FILE, 'utf-8').trim();
|
|
const browser = await puppeteer.connect({
|
|
browserWSEndpoint: wsEndpoint,
|
|
protocolTimeout: 120000,
|
|
});
|
|
console.log('[Browser] Connected to existing browser');
|
|
return browser;
|
|
} catch (err) {
|
|
// Endpoint stale or browser closed - clean up file
|
|
try { unlinkSync(BROWSER_ENDPOINT_FILE); } catch {}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save browser endpoint for cross-process sharing.
|
|
*/
|
|
function saveBrowserEndpoint(browser: Browser): void {
|
|
const wsEndpoint = browser.wsEndpoint();
|
|
writeFileSync(BROWSER_ENDPOINT_FILE, wsEndpoint, 'utf-8');
|
|
console.log('[Browser] Saved endpoint for cross-process sharing');
|
|
}
|
|
|
|
/**
|
|
* Get or create a shared browser instance.
|
|
* Use this for load tests to avoid spawning many browser processes.
|
|
*
|
|
* Cross-process sharing: If another process has a browser running,
|
|
* we'll connect to it instead of launching a new one.
|
|
*/
|
|
export async function getSharedBrowser(headless: boolean = true, background: boolean = false): Promise<Browser> {
|
|
// First, try connecting to in-process shared browser
|
|
if (sharedBrowser && sharedBrowser.connected) {
|
|
sharedBrowserRefCount++;
|
|
return sharedBrowser;
|
|
}
|
|
|
|
// Try connecting to cross-process shared browser
|
|
const existingBrowser = await tryConnectToExistingBrowser();
|
|
if (existingBrowser) {
|
|
sharedBrowser = existingBrowser;
|
|
sharedBrowserRefCount++;
|
|
return existingBrowser;
|
|
}
|
|
|
|
// Launch new browser
|
|
const chromeArgs = background && !headless
|
|
? [...LIGHTWEIGHT_CHROME_ARGS, '--window-position=3000,3000']
|
|
: LIGHTWEIGHT_CHROME_ARGS;
|
|
|
|
sharedBrowser = await puppeteer.launch({
|
|
headless,
|
|
args: chromeArgs,
|
|
protocolTimeout: 120000, // 2 minutes for heavy load scenarios
|
|
});
|
|
|
|
// Save endpoint for other processes to connect
|
|
saveBrowserEndpoint(sharedBrowser);
|
|
|
|
sharedBrowserRefCount++;
|
|
return sharedBrowser;
|
|
}
|
|
|
|
/**
|
|
* Release a reference to the shared browser.
|
|
* Does NOT close the browser - it stays open for other processes.
|
|
* Use closeSharedBrowser() to force-close the shared browser.
|
|
*/
|
|
export async function releaseSharedBrowser(): Promise<void> {
|
|
sharedBrowserRefCount--;
|
|
if (sharedBrowserRefCount <= 0) {
|
|
sharedBrowser = null;
|
|
sharedBrowserRefCount = 0;
|
|
// Don't close browser - leave it running for other processes
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Force close the shared browser and clean up endpoint file.
|
|
* Call this when you're done with all scripts and want to free resources.
|
|
*/
|
|
export async function closeSharedBrowser(): Promise<void> {
|
|
// Clean up endpoint file
|
|
try { unlinkSync(BROWSER_ENDPOINT_FILE); } catch {}
|
|
|
|
if (sharedBrowser && sharedBrowser.connected) {
|
|
await sharedBrowser.close();
|
|
console.log('[Browser] Closed shared browser');
|
|
}
|
|
sharedBrowser = null;
|
|
sharedBrowserRefCount = 0;
|
|
}
|
|
|
|
export interface BrowserSession {
|
|
browser: Browser;
|
|
page: Page;
|
|
botName: string;
|
|
cleanup: () => Promise<void>;
|
|
}
|
|
|
|
export interface SDKSession extends BrowserSession {
|
|
sdk: BotSDK;
|
|
bot: BotActions;
|
|
}
|
|
|
|
/**
|
|
* Launches a browser with the game client and waits for login.
|
|
* Does NOT skip tutorial - use launchBotWithSDK for that.
|
|
*
|
|
* @param useSharedBrowser - If true, uses a shared browser instance (for load tests)
|
|
*/
|
|
export async function launchBotBrowser(
|
|
botName?: string,
|
|
options: { headless?: boolean; useSharedBrowser?: boolean; background?: boolean } = {}
|
|
): Promise<BrowserSession> {
|
|
const name = botName || 'bot' + Math.random().toString(36).substring(2, 5);
|
|
const headless = options.headless ?? DEFAULT_HEADLESS;
|
|
const useShared = options.useSharedBrowser ?? false;
|
|
const background = options.background ?? DEFAULT_BACKGROUND;
|
|
|
|
// Add off-screen position if running in background mode (prevents focus stealing)
|
|
const chromeArgs = background && !headless
|
|
? [...LIGHTWEIGHT_CHROME_ARGS, '--window-position=3000,3000']
|
|
: LIGHTWEIGHT_CHROME_ARGS;
|
|
|
|
let browser: Browser;
|
|
if (useShared) {
|
|
browser = await getSharedBrowser(headless, background);
|
|
} else {
|
|
browser = await puppeteer.launch({
|
|
headless,
|
|
args: chromeArgs
|
|
});
|
|
}
|
|
|
|
const page = await browser.newPage();
|
|
|
|
// Set viewport to match game canvas exactly
|
|
await page.setViewport({ width: GAME_WIDTH, height: GAME_HEIGHT });
|
|
page.setDefaultTimeout(60000); // 60s timeout for all operations
|
|
|
|
// Add crash/error handlers to detect page issues
|
|
page.on('crash', () => {
|
|
console.error(`[Browser] Page CRASHED for bot '${name}'!`);
|
|
});
|
|
|
|
page.on('pageerror', (error) => {
|
|
console.error(`[Browser] Page JS error for bot '${name}':`, (error as Error).message);
|
|
});
|
|
|
|
page.on('error', (error) => {
|
|
console.error(`[Browser] Page error for bot '${name}':`, error.message);
|
|
});
|
|
|
|
page.on('close', () => {
|
|
console.log(`[Browser] Page closed for bot '${name}'`);
|
|
});
|
|
|
|
// Log console messages from the game page
|
|
page.on('console', (msg) => {
|
|
if (msg.type() === 'error') {
|
|
console.error(`[Browser Console] ${name}:`, msg.text());
|
|
}
|
|
});
|
|
|
|
// Navigate to bot URL with all params - page handles auto-login, fps, etc.
|
|
// tst=1 indicates running via test (hides agent panel by default)
|
|
await page.goto(`${BOT_URL}?bot=${name}&password=test&fps=15&tst=1`, { waitUntil: 'networkidle2', timeout: 60000 });
|
|
|
|
// Wait for in-game (page auto-logs in via URL params)
|
|
let attempts = 0;
|
|
while (!await page.evaluate(() => (window as any).gameClient?.ingame)) {
|
|
await sleep(200);
|
|
attempts++;
|
|
if (attempts > 150) { // 30 seconds
|
|
await browser.close();
|
|
throw new Error('Timeout waiting for login');
|
|
}
|
|
}
|
|
|
|
console.log(`[Browser] Bot '${name}' logged in and in-game`);
|
|
|
|
// Randomize character appearance and send to server
|
|
const randomized = await page.evaluate(() => {
|
|
const client = (window as any).gameClient;
|
|
if (client?.randomizeCharacterDesign && client?.acceptCharacterDesign) {
|
|
client.randomizeCharacterDesign();
|
|
client.acceptCharacterDesign();
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
if (randomized) {
|
|
console.log(`[Browser] Randomized character appearance`);
|
|
}
|
|
|
|
return {
|
|
browser,
|
|
page,
|
|
botName: name,
|
|
cleanup: async () => {
|
|
// Hold browser open for inspection when not headless
|
|
if (!headless) {
|
|
console.log(`[Browser] Holding open for ${CLEANUP_DELAY_MS / 1000}s...`);
|
|
await sleep(CLEANUP_DELAY_MS);
|
|
}
|
|
console.log(`[Browser] Closing page for '${name}'`);
|
|
await page.close();
|
|
if (useShared) {
|
|
await releaseSharedBrowser();
|
|
} else {
|
|
await browser.close();
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Check if player is on Tutorial Island based on coordinates.
|
|
* Tutorial Island is a specific area, not just "west of Lumbridge".
|
|
*
|
|
* Tutorial Island bounds (approximate):
|
|
* - X: 3050 to 3156
|
|
* - Z: 3056 to 3136
|
|
*
|
|
* Other western locations like Catherby (x: 2836) or Falador (x: 2964)
|
|
* should NOT be considered tutorial.
|
|
*/
|
|
export function isOnTutorialIsland(x: number, z: number): boolean {
|
|
return x >= 3050 && x <= 3156 && z >= 3056 && z <= 3136;
|
|
}
|
|
|
|
/**
|
|
* Skip tutorial using SDK.
|
|
* Returns true if tutorial was skipped successfully.
|
|
* Note: Character appearance is now randomized in launchBotBrowser, not here.
|
|
*/
|
|
export async function skipTutorial(sdk: BotSDK, maxAttempts: number = 30): Promise<boolean> {
|
|
// Check if we're in tutorial (on Tutorial Island specifically)
|
|
const isInTutorial = () => {
|
|
const s = sdk.getState();
|
|
if (!s?.player) return true; // No player state yet, assume tutorial
|
|
return isOnTutorialIsland(s.player.worldX, s.player.worldZ);
|
|
};
|
|
|
|
const bot = new BotActions(sdk);
|
|
let attempts = 0;
|
|
while (isInTutorial() && attempts < maxAttempts) {
|
|
await bot.skipTutorial();
|
|
await sleep(1000);
|
|
attempts++;
|
|
}
|
|
|
|
return !isInTutorial();
|
|
}
|
|
|
|
/**
|
|
* Launches browser, connects SDK, and skips tutorial.
|
|
* This is the main entry point for most tests.
|
|
*
|
|
* @param useSharedBrowser - If true, uses a shared browser instance (for load tests)
|
|
* @param background - If true, spawns window off-screen (won't steal focus)
|
|
*/
|
|
export async function launchBotWithSDK(
|
|
botName?: string,
|
|
options: { headless?: boolean; skipTutorial?: boolean; useSharedBrowser?: boolean; background?: boolean } = {}
|
|
): Promise<SDKSession> {
|
|
const shouldSkipTutorial = options.skipTutorial ?? true;
|
|
|
|
// Launch browser
|
|
const browser = await launchBotBrowser(botName, {
|
|
headless: options.headless,
|
|
useSharedBrowser: options.useSharedBrowser,
|
|
background: options.background
|
|
});
|
|
|
|
// Connect SDK
|
|
const sdk = new BotSDK({ botUsername: browser.botName });
|
|
await sdk.connect();
|
|
|
|
// Wait for game state
|
|
await sdk.waitForCondition(s => s.inGame, 30000);
|
|
|
|
// Skip tutorial if requested
|
|
if (shouldSkipTutorial) {
|
|
const success = await skipTutorial(sdk, 30);
|
|
if (!success) {
|
|
await sdk.disconnect();
|
|
await browser.cleanup();
|
|
throw new Error('Failed to skip tutorial');
|
|
}
|
|
// Wait for state to settle after tutorial
|
|
await sleep(1000);
|
|
}
|
|
|
|
// Create porcelain wrapper
|
|
const bot = new BotActions(sdk);
|
|
|
|
return {
|
|
...browser,
|
|
sdk,
|
|
bot,
|
|
cleanup: async () => {
|
|
await sdk.disconnect();
|
|
await browser.cleanup();
|
|
}
|
|
};
|
|
}
|
|
|