From 674cd89cd72402194f91d41cfa579073d1912025 Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Sat, 8 Aug 2026 15:30:24 +0100 Subject: [PATCH] Use full Chrome for interactive Reddit login --- Dockerfile | 7 +++ docker/entrypoint.sh | 54 ++++++++++++++++ src/browser/client.ts | 72 ++++++++++++++++++++- tests/browser-external-chrome.test.ts | 90 +++++++++++++++++++++++++++ tests/vnc-entrypoint.test.ts | 21 +++++++ 5 files changed, 241 insertions(+), 3 deletions(-) create mode 100644 tests/browser-external-chrome.test.ts diff --git a/Dockerfile b/Dockerfile index 757d1d3..91ab15d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ websockify \ fluxbox \ procps \ + ca-certificates \ + curl \ + && curl -fsSLo /tmp/google-chrome-stable_current_amd64.deb \ + https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb \ + && apt-get install -y --no-install-recommends \ + /tmp/google-chrome-stable_current_amd64.deb \ + && rm -f /tmp/google-chrome-stable_current_amd64.deb \ && rm -rf /var/lib/apt/lists/* WORKDIR /app diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index a96488a..ac1954b 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -9,6 +9,9 @@ VNC_PORT="${VNC_PORT:-5900}" NOVNC_PORT="${NOVNC_PORT:-6080}" GEOMETRY="${SCREEN_GEOMETRY:-1280x800x24}" BROWSER_SESSION_DIR="${BROWSER_SESSION_DIR:-/app/data/browser-session}" +CHROME_CDP_PORT="${CHROME_CDP_PORT:-9222}" +GOOGLE_CHROME_BIN="${GOOGLE_CHROME_BIN:-google-chrome}" +export CHROME_CDP_URL="${CHROME_CDP_URL:-http://127.0.0.1:${CHROME_CDP_PORT}}" export DISPLAY=":${DISPLAY_NUM}" # noVNC ships under different paths depending on the distro; find the web root once. @@ -104,8 +107,59 @@ websockify --web "${NOVNC_WEB_DIR}" "${NOVNC_PORT}" "localhost:${VNC_PORT}" \ >/var/log/websockify.log 2>&1 & NOVNC_PID=$! +# Start the installed consumer Google Chrome directly. Playwright attaches only +# after Chrome is running, so the human login is not performed in a browser +# process launched with Playwright's automation arguments. +CHROME_PID="" +CHROME_READY=0 +if command -v "${GOOGLE_CHROME_BIN}" >/dev/null 2>&1; then + echo "[entrypoint] starting Google Chrome for interactive login" + "${GOOGLE_CHROME_BIN}" \ + --user-data-dir="${BROWSER_SESSION_DIR}" \ + --remote-debugging-address=127.0.0.1 \ + --remote-debugging-port="${CHROME_CDP_PORT}" \ + --no-first-run \ + --no-default-browser-check \ + --disable-dev-shm-usage \ + --no-sandbox \ + --start-maximized \ + about:blank \ + >/var/log/google-chrome.log 2>&1 & + CHROME_PID=$! + + for i in $(seq 1 100); do + if ! kill -0 "${CHROME_PID}" 2>/dev/null; then + break + fi + if curl -fsS "${CHROME_CDP_URL}/json/version" >/dev/null 2>&1; then + echo "[entrypoint] Google Chrome is ready after ${i} checks" + CHROME_READY=1 + break + fi + sleep 0.2 + done +fi + +if [ "${CHROME_READY}" != "1" ]; then + echo "[entrypoint] WARNING: Google Chrome failed to start; using Playwright fallback" >&2 + if [ -n "${CHROME_PID}" ]; then + kill "${CHROME_PID}" 2>/dev/null || true + wait "${CHROME_PID}" 2>/dev/null || true + fi + rm -f \ + "${BROWSER_SESSION_DIR}/SingletonLock" \ + "${BROWSER_SESSION_DIR}/SingletonSocket" \ + "${BROWSER_SESSION_DIR}/SingletonCookie" \ + 2>/dev/null || true + CHROME_PID="" + unset CHROME_CDP_URL +fi + cleanup() { echo "[entrypoint] shutting down" + if [ -n "${CHROME_PID}" ]; then + kill "${CHROME_PID}" 2>/dev/null || true + fi kill "${NOVNC_PID}" "${X11VNC_PID}" "${XVFB_PID}" 2>/dev/null || true wait || true } diff --git a/src/browser/client.ts b/src/browser/client.ts index 88b45bc..7329edf 100644 --- a/src/browser/client.ts +++ b/src/browser/client.ts @@ -4,6 +4,25 @@ import type { Logger } from '../utils/logger.js'; import path from 'path'; import fs from 'fs'; +export interface ConnectedBrowser { + contexts(): BrowserContext[]; + close(): Promise; +} + +export interface BrowserRuntime { + connectOverCDP(endpoint: string): Promise; + launchPersistentContext( + userDataDir: string, + options: Parameters[1] + ): Promise; +} + +const defaultBrowserRuntime: BrowserRuntime = { + connectOverCDP: (endpoint) => chromium.connectOverCDP(endpoint), + launchPersistentContext: (userDataDir, options) => + chromium.launchPersistentContext(userDataDir, options), +}; + export type RedditSessionState = | 'unknown' | 'authenticated' @@ -19,13 +38,16 @@ export function classifyRedditSessionError(error: unknown): Exclude | null = null; private sessionState: RedditSessionState = 'unknown'; constructor( private config: Config, - private logger: Logger + private logger: Logger, + private browserRuntime: BrowserRuntime = defaultBrowserRuntime ) { } async initialize(): Promise { @@ -37,10 +59,31 @@ export class BrowserClient { fs.mkdirSync(sessionDir, { recursive: true }); } - this.logger.info(`Launching browser with session dir: ${sessionDir}`); + const cdpEndpoint = process.env.CHROME_CDP_URL?.trim(); + if (cdpEndpoint) { + try { + this.logger.info(`Attaching to independently launched Google Chrome at ${cdpEndpoint}`); + const browser = await this.browserRuntime.connectOverCDP(cdpEndpoint); + const context = browser.contexts()[0]; + if (!context) { + await browser.close().catch(() => undefined); + throw new Error('External Chrome did not expose a default browser context'); + } + + this.connectedBrowser = browser; + this.usingExternalChrome = true; + this.context = context; + this.logger.info('Attached to Google Chrome; interactive login is available through VNC'); + return context; + } catch (error) { + this.logger.warn('Could not attach to Google Chrome; falling back to Playwright browser launch:', error); + } + } + + this.logger.info(`Launching fallback Playwright browser with session dir: ${sessionDir}`); // Use launchPersistentContext to maintain cookies/localStorage - this.context = await chromium.launchPersistentContext(sessionDir, { + this.context = await this.browserRuntime.launchPersistentContext(sessionDir, { headless: this.config.browser.headless, viewport: { width: 1280, height: 720 }, userAgent: this.config.reddit.userAgent, @@ -70,6 +113,15 @@ export class BrowserClient { } async close(): Promise { + if (this.connectedBrowser) { + this.logger.info('Closing Google Chrome connection'); + await this.connectedBrowser.close(); + this.connectedBrowser = null; + this.context = null; + this.usingExternalChrome = false; + return; + } + if (this.context) { this.logger.info('Closing browser'); await this.context.close(); @@ -150,6 +202,20 @@ export class BrowserClient { } async login(): Promise { + if (this.usingExternalChrome) { + try { + const page = await this.getPage(); + await page.bringToFront(); + await page.goto('https://www.reddit.com/login', { waitUntil: 'domcontentloaded' }); + this.sessionState = 'login-required'; + this.logger.warn('Interactive Reddit login required. Open the VNC page and sign in using Google Chrome.'); + } catch (error) { + this.sessionState = classifyRedditSessionError(error); + this.logger.error('Failed to open Google Chrome for interactive login:', error); + } + return false; + } + if (!this.config.reddit.username || !this.config.reddit.password) { this.sessionState = 'login-required'; this.logger.error('Login failed: Missing credentials in config/env'); diff --git a/tests/browser-external-chrome.test.ts b/tests/browser-external-chrome.test.ts new file mode 100644 index 0000000..fece3d6 --- /dev/null +++ b/tests/browser-external-chrome.test.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { BrowserContext, Page } from 'playwright'; +import { + BrowserClient, + type BrowserRuntime, + type ConnectedBrowser, +} from '../src/browser/client.js'; +import type { Logger } from '../src/utils/logger.js'; +import { getTestConfig } from './testUtils.js'; + +function createLogger(): Logger { + return { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } as unknown as Logger; +} + +afterEach(() => { + delete process.env.CHROME_CDP_URL; +}); + +describe('BrowserClient external Chrome', () => { + it('attaches to independently launched Chrome before considering Playwright launch', async () => { + process.env.CHROME_CDP_URL = 'http://127.0.0.1:9222'; + const context = { pages: () => [] } as unknown as BrowserContext; + const connectedBrowser = { + contexts: () => [context], + close: vi.fn(), + } as unknown as ConnectedBrowser; + const runtime: BrowserRuntime = { + connectOverCDP: vi.fn().mockResolvedValue(connectedBrowser), + launchPersistentContext: vi.fn(), + }; + + const client = new BrowserClient(getTestConfig(), createLogger(), runtime); + const result = await client.initialize(); + + expect(result).toBe(context); + expect(runtime.connectOverCDP).toHaveBeenCalledWith('http://127.0.0.1:9222'); + expect(runtime.launchPersistentContext).not.toHaveBeenCalled(); + }); + + it('falls back to Playwright only when external Chrome cannot be reached', async () => { + process.env.CHROME_CDP_URL = 'http://127.0.0.1:9222'; + const fallbackContext = { pages: () => [] } as unknown as BrowserContext; + const runtime: BrowserRuntime = { + connectOverCDP: vi.fn().mockRejectedValue(new Error('connection refused')), + launchPersistentContext: vi.fn().mockResolvedValue(fallbackContext), + }; + + const client = new BrowserClient(getTestConfig(), createLogger(), runtime); + const result = await client.initialize(); + + expect(result).toBe(fallbackContext); + expect(runtime.launchPersistentContext).toHaveBeenCalledOnce(); + }); + + it('opens the login page for a human without submitting credentials', async () => { + process.env.CHROME_CDP_URL = 'http://127.0.0.1:9222'; + const page = { + goto: vi.fn().mockResolvedValue(undefined), + bringToFront: vi.fn().mockResolvedValue(undefined), + fill: vi.fn(), + } as unknown as Page; + const context = { pages: () => [page] } as unknown as BrowserContext; + const runtime: BrowserRuntime = { + connectOverCDP: vi.fn().mockResolvedValue({ + contexts: () => [context], + close: vi.fn(), + }), + launchPersistentContext: vi.fn(), + }; + + const client = new BrowserClient(getTestConfig({ + reddit: { username: 'human', password: 'secret' }, + }), createLogger(), runtime); + await client.initialize(); + + await expect(client.login()).resolves.toBe(false); + expect(page.bringToFront).toHaveBeenCalledOnce(); + expect(page.goto).toHaveBeenCalledWith( + 'https://www.reddit.com/login', + { waitUntil: 'domcontentloaded' }, + ); + expect(page.fill).not.toHaveBeenCalled(); + expect(client.getSessionState()).toBe('login-required'); + }); +}); diff --git a/tests/vnc-entrypoint.test.ts b/tests/vnc-entrypoint.test.ts index 23623d9..f03e5b7 100644 --- a/tests/vnc-entrypoint.test.ts +++ b/tests/vnc-entrypoint.test.ts @@ -22,4 +22,25 @@ describe('VNC container entrypoint', () => { expect(script).toContain('"${BROWSER_SESSION_DIR}/SingletonCookie"'); expect(script.indexOf('SingletonLock')).toBeLessThan(script.indexOf('launching bot')); }); + + it('launches regular Google Chrome before the bot and exposes its local CDP endpoint', () => { + const script = fs.readFileSync(path.resolve('docker/entrypoint.sh'), 'utf8'); + + expect(script).toContain('google-chrome'); + expect(script).toContain('--user-data-dir="${BROWSER_SESSION_DIR}"'); + expect(script).toContain('--remote-debugging-port="${CHROME_CDP_PORT}"'); + expect(script).toContain('export CHROME_CDP_URL='); + expect(script).not.toContain('--enable-automation'); + expect(script.indexOf('starting Google Chrome')).toBeLessThan( + script.indexOf('launching bot'), + ); + }); + + it('installs the stable Google Chrome package rather than Chrome for Testing', () => { + const dockerfile = fs.readFileSync(path.resolve('Dockerfile'), 'utf8'); + + expect(dockerfile).toContain('google-chrome-stable_current_amd64.deb'); + expect(dockerfile).toContain('google-chrome-stable'); + expect(dockerfile).not.toContain('chrome-for-testing'); + }); });