Use native Chrome for Reddit login handoff

This commit is contained in:
jjbrunton committed 2026-08-08 16:16:11 +01:00
1 parent 2c533fa52a
commit dbb1842ef4
13 files changed
+324 -60

No files matched your search

+86 -26
View File
@@ -11,6 +11,7 @@ GEOMETRY="${SCREEN_GEOMETRY:-1280x800x24}"
BROWSER_SESSION_DIR="${BROWSER_SESSION_DIR:-/app/data/browser-session}" BROWSER_SESSION_DIR="${BROWSER_SESSION_DIR:-/app/data/browser-session}"
CHROME_CDP_PORT="${CHROME_CDP_PORT:-9222}" CHROME_CDP_PORT="${CHROME_CDP_PORT:-9222}"
GOOGLE_CHROME_BIN="${GOOGLE_CHROME_BIN:-google-chrome}" GOOGLE_CHROME_BIN="${GOOGLE_CHROME_BIN:-google-chrome}"
NATIVE_LOGIN_EXIT_CODE=75
export CHROME_CDP_URL="${CHROME_CDP_URL:-http://127.0.0.1:${CHROME_CDP_PORT}}" export CHROME_CDP_URL="${CHROME_CDP_URL:-http://127.0.0.1:${CHROME_CDP_PORT}}"
export DISPLAY=":${DISPLAY_NUM}" export DISPLAY=":${DISPLAY_NUM}"
@@ -107,13 +108,46 @@ websockify --web "${NOVNC_WEB_DIR}" "${NOVNC_PORT}" "localhost:${VNC_PORT}" \
>/var/log/websockify.log 2>&1 & >/var/log/websockify.log 2>&1 &
NOVNC_PID=$! 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_PID=""
CHROME_READY=0 BOT_PID=""
if command -v "${GOOGLE_CHROME_BIN}" >/dev/null 2>&1; then GATE_PID=""
echo "[entrypoint] starting Google Chrome for interactive login"
clear_chrome_locks() {
rm -f \
"${BROWSER_SESSION_DIR}/SingletonLock" \
"${BROWSER_SESSION_DIR}/SingletonSocket" \
"${BROWSER_SESSION_DIR}/SingletonCookie" \
2>/dev/null || true
}
stop_chrome() {
if [ -n "${CHROME_PID}" ]; then
kill "${CHROME_PID}" 2>/dev/null || true
wait "${CHROME_PID}" 2>/dev/null || true
CHROME_PID=""
fi
clear_chrome_locks
}
start_native_login_chrome() {
unset CHROME_CDP_URL
echo "[entrypoint] starting native Google Chrome login with DevTools disabled"
"${GOOGLE_CHROME_BIN}" \
--user-data-dir="${BROWSER_SESSION_DIR}" \
--no-first-run \
--no-default-browser-check \
--disable-dev-shm-usage \
--no-sandbox \
--start-maximized \
https://www.reddit.com/login/ \
>/var/log/google-chrome-native.log 2>&1 &
CHROME_PID=$!
}
start_controlled_chrome() {
export CHROME_CDP_URL="http://127.0.0.1:${CHROME_CDP_PORT}"
local chrome_ready=0
echo "[entrypoint] starting Google Chrome for authenticated bot control"
"${GOOGLE_CHROME_BIN}" \ "${GOOGLE_CHROME_BIN}" \
--user-data-dir="${BROWSER_SESSION_DIR}" \ --user-data-dir="${BROWSER_SESSION_DIR}" \
--remote-debugging-address=127.0.0.1 \ --remote-debugging-address=127.0.0.1 \
@@ -133,37 +167,63 @@ if command -v "${GOOGLE_CHROME_BIN}" >/dev/null 2>&1; then
fi fi
if curl -fsS "${CHROME_CDP_URL}/json/version" >/dev/null 2>&1; then if curl -fsS "${CHROME_CDP_URL}/json/version" >/dev/null 2>&1; then
echo "[entrypoint] Google Chrome is ready after ${i} checks" echo "[entrypoint] Google Chrome is ready after ${i} checks"
CHROME_READY=1 chrome_ready=1
break break
fi fi
sleep 0.2 sleep 0.2
done done
fi
if [ "${CHROME_READY}" != "1" ]; then if [ "${chrome_ready}" != "1" ]; then
echo "[entrypoint] WARNING: Google Chrome failed to start; using Playwright fallback" >&2 echo "[entrypoint] WARNING: Google Chrome failed to start; using Playwright fallback" >&2
if [ -n "${CHROME_PID}" ]; then stop_chrome
kill "${CHROME_PID}" 2>/dev/null || true unset CHROME_CDP_URL
wait "${CHROME_PID}" 2>/dev/null || true
fi 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() { cleanup() {
echo "[entrypoint] shutting down" echo "[entrypoint] shutting down"
if [ -n "${CHROME_PID}" ]; then [ -n "${BOT_PID}" ] && kill "${BOT_PID}" 2>/dev/null || true
kill "${CHROME_PID}" 2>/dev/null || true [ -n "${GATE_PID}" ] && kill "${GATE_PID}" 2>/dev/null || true
fi stop_chrome
kill "${NOVNC_PID}" "${X11VNC_PID}" "${XVFB_PID}" 2>/dev/null || true kill "${NOVNC_PID}" "${X11VNC_PID}" "${XVFB_PID}" 2>/dev/null || true
wait || true wait || true
} }
trap cleanup TERM INT EXIT trap cleanup TERM INT EXIT
echo "[entrypoint] launching bot" if ! command -v "${GOOGLE_CHROME_BIN}" >/dev/null 2>&1; then
exec node dist/index.js echo "[entrypoint] WARNING: Google Chrome is unavailable; using Playwright fallback" >&2
unset CHROME_CDP_URL
else
start_controlled_chrome
fi
while true; do
echo "[entrypoint] launching bot"
set +e
node dist/index.js &
BOT_PID=$!
wait "${BOT_PID}"
BOT_EXIT=$?
BOT_PID=""
set -e
if [ "${BOT_EXIT}" -ne "${NATIVE_LOGIN_EXIT_CODE}" ]; then
exit "${BOT_EXIT}"
fi
echo "[entrypoint] bot requested native Reddit login mode"
stop_chrome
start_native_login_chrome
node dist/native-login-gate.js &
GATE_PID=$!
wait "${GATE_PID}"
GATE_EXIT=$?
GATE_PID=""
if [ "${GATE_EXIT}" -ne 0 ]; then
exit "${GATE_EXIT}"
fi
echo "[entrypoint] human completed login handoff; enabling authenticated control"
stop_chrome
start_controlled_chrome
done
+5
View File
@@ -4079,6 +4079,11 @@
const res = await fetch('/api/login/start', { method: 'POST' }); const res = await fetch('/api/login/start', { method: 'POST' });
if (!res.ok) { if (!res.ok) {
alert('Failed to open Reddit login page. Check the server logs.'); alert('Failed to open Reddit login page. Check the server logs.');
return;
}
const data = await res.json();
if (data.restarting) {
window.setTimeout(() => window.location.reload(), 1500);
} }
} catch (err) { } catch (err) {
alert('Failed to open Reddit login page: ' + err); alert('Failed to open Reddit login page: ' + err);
+3 -1
View File
@@ -36,6 +36,7 @@ import {
authenticateCurrentAccount, authenticateCurrentAccount,
type AuthenticatedAccount, type AuthenticatedAccount,
} from './services/account-auth-context.js'; } from './services/account-auth-context.js';
import { ProcessNativeLoginHandoff } from './services/native-login-handoff.js';
export interface AppContainer { export interface AppContainer {
config: Config; config: Config;
@@ -111,7 +112,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
if (!loggedIn) { if (!loggedIn) {
logger.warn('Session invalid or not logged in.'); logger.warn('Session invalid or not logged in.');
if (config.reddit.username && config.reddit.password) { if (!config.web.enabled && config.reddit.username && config.reddit.password) {
logger.info('Credentials found in config/env. Attempting automated login...'); logger.info('Credentials found in config/env. Attempting automated login...');
loggedIn = await browserClient.login(); loggedIn = await browserClient.login();
} }
@@ -327,6 +328,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
webServer.setStrategySelector(strategySelector); webServer.setStrategySelector(strategySelector);
webServer.setBrowserClient(browserClient); webServer.setBrowserClient(browserClient);
webServer.setAccountAuthContext(accountAuth); webServer.setAccountAuthContext(accountAuth);
webServer.setNativeLoginHandoff(new ProcessNativeLoginHandoff());
webServer.setOnConfigChange(() => logger.info('Runtime config updated via dashboard')); webServer.setOnConfigChange(() => logger.info('Runtime config updated via dashboard'));
} }
+2 -10
View File
@@ -216,16 +216,8 @@ export class BrowserClient {
async login(): Promise<boolean> { async login(): Promise<boolean> {
if (this.usingExternalChrome) { if (this.usingExternalChrome) {
try { this.sessionState = 'login-required';
const page = await this.getPage(); this.logger.warn('Native Reddit login required. Start it from the dashboard before signing in through VNC.');
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; return false;
} }
+102
View File
@@ -0,0 +1,102 @@
import http, { type Server } from 'node:http';
import { pathToFileURL } from 'node:url';
export interface NativeLoginGateOptions {
publicVncUrl: string;
onComplete: () => void;
}
function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (character) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
})[character] ?? character);
}
function loginPage(publicVncUrl: string): string {
const safeVncUrl = escapeHtml(publicVncUrl);
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Reddit login required</title>
<style>
body { margin: 0; font: 16px system-ui, sans-serif; color: #f5f5f5; background: #111; }
main { max-width: 1100px; margin: 24px auto; padding: 0 20px; }
.notice { padding: 18px; border: 1px solid #3c765f; border-radius: 12px; background: #17251f; }
iframe { width: 100%; height: 70vh; margin-top: 18px; border: 1px solid #444; border-radius: 10px; background: #000; }
button, a { display: inline-block; margin: 14px 8px 0 0; padding: 11px 16px; border: 0; border-radius: 8px; font-weight: 700; }
button { color: white; background: #ff4500; cursor: pointer; }
a { color: white; background: #444; text-decoration: none; }
#status { margin-left: 8px; color: #b9dfcf; }
</style>
</head>
<body><main>
<section class="notice">
<strong>Chrome is running without automation.</strong>
<p>Log in to Reddit in the browser below. Only after Reddit shows you as signed in, click <em>I'm logged in - connect the bot</em>. Chrome will restart with the same profile and the bot will verify both Reddit and Matrix before any bot logic can run.</p>
<button id="complete" type="button">I'm logged in - connect the bot</button>
<a href="${safeVncUrl}" target="_blank" rel="noreferrer">Open VNC in a new tab</a>
<span id="status"></span>
</section>
<iframe src="${safeVncUrl}" title="Reddit login browser"></iframe>
<script>
document.getElementById('complete').addEventListener('click', async () => {
const button = document.getElementById('complete');
const status = document.getElementById('status');
button.disabled = true;
status.textContent = 'Restarting Chrome and verifying your account...';
const response = await fetch('/complete', { method: 'POST' });
if (!response.ok) {
button.disabled = false;
status.textContent = 'Could not start verification. Please try again.';
return;
}
setTimeout(() => window.location.reload(), 3000);
});
</script>
</main></body></html>`;
}
export function createNativeLoginGate(options: NativeLoginGateOptions): Server {
return http.createServer((request, response) => {
if (request.method === 'POST' && request.url === '/complete') {
response.writeHead(202, { 'content-type': 'application/json' });
response.end(JSON.stringify({ ok: true, restarting: true }));
options.onComplete();
return;
}
if (request.method !== 'GET') {
response.writeHead(405).end();
return;
}
response.writeHead(200, {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'no-store',
});
response.end(loginPage(options.publicVncUrl));
});
}
function run(): void {
const port = Number.parseInt(process.env.WEB_PORT ?? '3000', 10);
const publicBase = (process.env.NOVNC_PUBLIC_URL ?? `http://localhost:${process.env.NOVNC_PORT ?? '6080'}`).replace(/\/$/, '');
const publicVncUrl = `${publicBase}/vnc.html?autoconnect=1&resize=scale&reconnect=1`;
let server: Server;
server = createNativeLoginGate({
publicVncUrl,
onComplete: () => setTimeout(() => server.close(() => process.exit(0)), 250).unref(),
});
server.listen(port, () => {
process.stdout.write(`[native-login] waiting for human login on port ${port}\n`);
});
}
const entryUrl = process.argv[1] ? pathToFileURL(process.argv[1]).href : '';
if (import.meta.url === entryUrl) run();
+28
View File
@@ -0,0 +1,28 @@
export interface NativeLoginHandoff {
request(): void;
}
export interface ProcessNativeLoginHandoffOptions {
exitCode?: number;
delayMs?: number;
exit?: (code: number) => void;
}
export class ProcessNativeLoginHandoff implements NativeLoginHandoff {
private requested = false;
private readonly exitCode: number;
private readonly delayMs: number;
private readonly exit: (code: number) => void;
constructor(options: ProcessNativeLoginHandoffOptions = {}) {
this.exitCode = options.exitCode ?? 75;
this.delayMs = options.delayMs ?? 250;
this.exit = options.exit ?? ((code) => process.exit(code));
}
request(): void {
if (this.requested) return;
this.requested = true;
setTimeout(() => this.exit(this.exitCode), this.delayMs).unref();
}
}
+2
View File
@@ -14,6 +14,7 @@ import type { OpenRouterClient } from '../ai/openrouter.js';
import type { StrategySelector } from '../services/strategy-selector.js'; import type { StrategySelector } from '../services/strategy-selector.js';
import type { BrowserClient } from '../browser/client.js'; import type { BrowserClient } from '../browser/client.js';
import type { AccountAuthContext } from '../services/account-auth-context.js'; import type { AccountAuthContext } from '../services/account-auth-context.js';
import type { NativeLoginHandoff } from '../services/native-login-handoff.js';
import type { ActivityLog } from './activity-log.js'; import type { ActivityLog } from './activity-log.js';
import type { LogBroadcaster } from './log-broadcaster.js'; import type { LogBroadcaster } from './log-broadcaster.js';
import type { RuntimeConfig } from './types.js'; import type { RuntimeConfig } from './types.js';
@@ -42,6 +43,7 @@ export interface WebContext {
strategySelector?: StrategySelector; strategySelector?: StrategySelector;
browser?: BrowserClient; browser?: BrowserClient;
accountAuth?: AccountAuthContext; accountAuth?: AccountAuthContext;
nativeLogin?: NativeLoginHandoff;
}; };
/** Bot pause/resume toggle read by the main loop. */ /** Bot pause/resume toggle read by the main loop. */
+11 -13
View File
@@ -41,24 +41,22 @@ export function createLoginRouter(ctx: WebContext): Router {
} }
}); });
// Navigate the headed Chromium running under Xvfb to reddit.com/login so // Restart into a completely native Chrome process. The container supervisor
// the human watching the noVNC stream can complete the flow. // does not enable DevTools until the human explicitly completes the handoff.
router.post('/login/start', async (_req, res) => { router.post('/login/start', async (_req, res) => {
const browser = ctx.services.browser; const nativeLogin = ctx.services.nativeLogin;
if (!browser) { if (!nativeLogin) {
res.status(503).json({ ok: false, error: 'browser not initialised' }); res.status(503).json({ ok: false, error: 'native login unavailable' });
return; return;
} }
try { try {
ctx.services.accountAuth?.invalidate('Interactive Reddit login requested'); ctx.services.accountAuth?.invalidate('Native Reddit login requested');
const page = await browser.getPage(); nativeLogin.request();
await page.bringToFront().catch(() => undefined); ctx.logger.info('Restarting into native Chrome login mode; bot logic remains locked');
await page.goto('https://www.reddit.com/login', { waitUntil: 'domcontentloaded' }); res.status(202).json({ ok: true, restarting: true });
ctx.logger.info('Opened Reddit login page for human completion via VNC');
res.json({ ok: true });
} catch (error) { } catch (error) {
ctx.logger.error('Failed to open login page:', error); ctx.logger.error('Failed to start native login mode:', error);
res.status(500).json({ ok: false, error: 'navigation failed' }); res.status(500).json({ ok: false, error: 'handoff failed' });
} }
}); });
+5
View File
@@ -18,6 +18,7 @@ import type { OpenRouterClient } from '../ai/openrouter.js';
import type { StrategySelector } from '../services/strategy-selector.js'; import type { StrategySelector } from '../services/strategy-selector.js';
import type { BrowserClient } from '../browser/client.js'; import type { BrowserClient } from '../browser/client.js';
import type { AccountAuthContext } from '../services/account-auth-context.js'; import type { AccountAuthContext } from '../services/account-auth-context.js';
import type { NativeLoginHandoff } from '../services/native-login-handoff.js';
import { ActivityLog } from './activity-log.js'; import { ActivityLog } from './activity-log.js';
import { LogBroadcaster } from './log-broadcaster.js'; import { LogBroadcaster } from './log-broadcaster.js';
import type { WebContext } from './context.js'; import type { WebContext } from './context.js';
@@ -180,6 +181,10 @@ export class WebServer {
this.services.accountAuth = accountAuth; this.services.accountAuth = accountAuth;
} }
setNativeLoginHandoff(nativeLogin: NativeLoginHandoff): void {
this.services.nativeLogin = nativeLogin;
}
logActivity(type: ActivityItem['type'], message: string, recipient?: string, imageUrl?: string): void { logActivity(type: ActivityItem['type'], message: string, recipient?: string, imageUrl?: string): void {
this.activity.push(type, message, recipient, imageUrl); this.activity.push(type, message, recipient, imageUrl);
} }
+3 -6
View File
@@ -57,7 +57,7 @@ describe('BrowserClient external Chrome', () => {
expect(runtime.launchPersistentContext).toHaveBeenCalledOnce(); expect(runtime.launchPersistentContext).toHaveBeenCalledOnce();
}); });
it('opens the login page for a human without submitting credentials', async () => { it('does not control external Chrome during native human login', async () => {
process.env.CHROME_CDP_URL = 'http://127.0.0.1:9222'; process.env.CHROME_CDP_URL = 'http://127.0.0.1:9222';
const page = { const page = {
goto: vi.fn().mockResolvedValue(undefined), goto: vi.fn().mockResolvedValue(undefined),
@@ -79,11 +79,8 @@ describe('BrowserClient external Chrome', () => {
await client.initialize(); await client.initialize();
await expect(client.login()).resolves.toBe(false); await expect(client.login()).resolves.toBe(false);
expect(page.bringToFront).toHaveBeenCalledOnce(); expect(page.bringToFront).not.toHaveBeenCalled();
expect(page.goto).toHaveBeenCalledWith( expect(page.goto).not.toHaveBeenCalled();
'https://www.reddit.com/login',
{ waitUntil: 'domcontentloaded' },
);
expect(page.fill).not.toHaveBeenCalled(); expect(page.fill).not.toHaveBeenCalled();
expect(client.getSessionState()).toBe('login-required'); expect(client.getSessionState()).toBe('login-required');
}); });
+32 -1
View File
@@ -1,6 +1,6 @@
import express from 'express'; import express from 'express';
import type { AddressInfo } from 'node:net'; import type { AddressInfo } from 'node:net';
import { afterEach, describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { classifyRedditSessionError } from '../src/browser/client.js'; import { classifyRedditSessionError } from '../src/browser/client.js';
import { createLoginRouter } from '../src/web/routes/login.js'; import { createLoginRouter } from '../src/web/routes/login.js';
@@ -14,6 +14,14 @@ async function request(app: express.Express, path: string): Promise<Response> {
return fetch(`http://127.0.0.1:${port}${path}`); return fetch(`http://127.0.0.1:${port}${path}`);
} }
async function post(app: express.Express, path: string): Promise<Response> {
const server = app.listen(0);
servers.push(server);
await new Promise<void>((resolve) => server.once('listening', resolve));
const { port } = server.address() as AddressInfo;
return fetch(`http://127.0.0.1:${port}${path}`, { method: 'POST' });
}
afterEach(async () => { afterEach(async () => {
await Promise.all( await Promise.all(
servers.splice(0).map( servers.splice(0).map(
@@ -105,4 +113,27 @@ describe('Reddit session status', () => {
).toBe('unreachable'); ).toBe('unreachable');
expect(classifyRedditSessionError(new Error('login form was not found'))).toBe('login-required'); expect(classifyRedditSessionError(new Error('login form was not found'))).toBe('login-required');
}); });
it('starts a native Chrome login handoff without controlling the attached page', async () => {
const requestNativeLogin = vi.fn();
const getPage = vi.fn();
const invalidate = vi.fn();
const app = express();
app.use(createLoginRouter({
services: {
accountAuth: { invalidate },
browser: { getPage },
nativeLogin: { request: requestNativeLogin },
},
logger: { info: vi.fn(), error: vi.fn() },
} as never));
const response = await post(app, '/login/start');
expect(response.status).toBe(202);
expect(await response.json()).toEqual({ ok: true, restarting: true });
expect(invalidate).toHaveBeenCalledWith('Native Reddit login requested');
expect(requestNativeLogin).toHaveBeenCalledOnce();
expect(getPage).not.toHaveBeenCalled();
});
}); });
+36
View File
@@ -0,0 +1,36 @@
import type { AddressInfo } from 'node:net';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createNativeLoginGate } from '../src/native-login-gate.js';
const servers: Array<ReturnType<typeof createNativeLoginGate>> = [];
afterEach(async () => {
await Promise.all(servers.splice(0).map((server) => new Promise<void>((resolve) => {
server.close(() => resolve());
})));
});
describe('native Reddit login gate', () => {
it('serves VNC guidance and completes only after the explicit handoff', async () => {
const onComplete = vi.fn();
const server = createNativeLoginGate({
publicVncUrl: 'https://red-vnc.example/vnc.html',
onComplete,
}).listen(0);
servers.push(server);
await new Promise<void>((resolve) => server.once('listening', resolve));
const { port } = server.address() as AddressInfo;
const page = await fetch(`http://127.0.0.1:${port}/`);
const html = await page.text();
expect(html).toContain('Chrome is running without automation');
expect(html).toContain('https://red-vnc.example/vnc.html');
expect(html).toContain("fetch('/complete', { method: 'POST' })");
expect(onComplete).not.toHaveBeenCalled();
const completed = await fetch(`http://127.0.0.1:${port}/complete`, { method: 'POST' });
expect(completed.status).toBe(202);
expect(await completed.json()).toEqual({ ok: true, restarting: true });
expect(onComplete).toHaveBeenCalledOnce();
});
});
+9 -3
View File
@@ -23,17 +23,23 @@ describe('VNC container entrypoint', () => {
expect(script.indexOf('SingletonLock')).toBeLessThan(script.indexOf('launching bot')); expect(script.indexOf('SingletonLock')).toBeLessThan(script.indexOf('launching bot'));
}); });
it('launches regular Google Chrome before the bot and exposes its local CDP endpoint', () => { it('supports a native login phase with no DevTools before the controlled browser starts', () => {
const script = fs.readFileSync(path.resolve('docker/entrypoint.sh'), 'utf8'); const script = fs.readFileSync(path.resolve('docker/entrypoint.sh'), 'utf8');
expect(script).toContain('google-chrome'); expect(script).toContain('google-chrome');
expect(script).toContain('--user-data-dir="${BROWSER_SESSION_DIR}"'); expect(script).toContain('--user-data-dir="${BROWSER_SESSION_DIR}"');
expect(script).toContain('start_native_login_chrome');
expect(script).toContain('https://www.reddit.com/login/');
expect(script).toContain('node dist/native-login-gate.js');
expect(script).toContain('NATIVE_LOGIN_EXIT_CODE');
expect(script).toContain('--remote-debugging-port="${CHROME_CDP_PORT}"'); expect(script).toContain('--remote-debugging-port="${CHROME_CDP_PORT}"');
expect(script).toContain('export CHROME_CDP_URL='); expect(script).toContain('export CHROME_CDP_URL=');
expect(script).not.toContain('--enable-automation'); expect(script).not.toContain('--enable-automation');
expect(script.indexOf('starting Google Chrome')).toBeLessThan( const nativeFunction = script.slice(
script.indexOf('launching bot'), script.indexOf('start_native_login_chrome()'),
script.indexOf('start_controlled_chrome()'),
); );
expect(nativeFunction).not.toContain('--remote-debugging');
}); });
it('installs the stable Google Chrome package rather than Chrome for Testing', () => { it('installs the stable Google Chrome package rather than Chrome for Testing', () => {