From 3724e31a3094fe72ec3cc9d5af22e27721636b96 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 17 Feb 2026 10:24:27 +0100
Subject: [PATCH] feat: 3 browser variants with realistic devices, Teams login
flow, 20s wait + screenshot
Co-authored-by: Cursor
---
src/bot/authTestProcedure.ts | 304 ++++++++++++++++-------------------
1 file changed, 139 insertions(+), 165 deletions(-)
diff --git a/src/bot/authTestProcedure.ts b/src/bot/authTestProcedure.ts
index ef6413b..40d6cab 100644
--- a/src/bot/authTestProcedure.ts
+++ b/src/bot/authTestProcedure.ts
@@ -43,9 +43,19 @@ export interface AuthTestResults {
const _VARIANTS: AuthTestVariant[] = [
{
- id: 'headfulDirect',
- name: 'Headful + Direct /v2/',
- description: 'Playwright headful, navigiert direkt zu /v2/ URL (umgeht launcher.html komplett)',
+ id: 'chromiumClean',
+ name: 'Chromium Headful Clean',
+ description: 'Playwright Chromium headful, enhanced stealth + realistic devices, keine fake-flags',
+ },
+ {
+ id: 'chromiumNoAutomation',
+ name: 'Chromium No-Automation',
+ description: 'Chromium headful, zusaetzlich --disable-extensions, --no-first-run',
+ },
+ {
+ id: 'rebrowserHeadful',
+ name: 'rebrowser-playwright Headful',
+ description: 'rebrowser-playwright headful, CDP-Leak-Fixes + realistic devices',
},
];
@@ -53,40 +63,49 @@ const _VARIANTS: AuthTestVariant[] = [
// CONSTANTS
// ============================================================================
-const _VARIANT_TIMEOUT_MS = 45000;
+const _VARIANT_TIMEOUT_MS = 60000;
const _SCREENSHOT_QUALITY = 50;
const _USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0';
-const _BROWSER_ARGS = [
- '--use-fake-ui-for-media-stream',
- '--use-fake-device-for-media-stream',
+// Base args — NO fake device flags (those show "Fake Default Audio Input" to Teams)
+const _BROWSER_ARGS_CLEAN = [
+ '--use-fake-ui-for-media-stream', // Auto-accept media permissions (no popup)
'--disable-web-security',
'--disable-features=IsolateOrigins,site-per-process',
'--autoplay-policy=no-user-gesture-required',
'--disable-blink-features=AutomationControlled',
];
+// Extra args for the no-automation variant
+const _BROWSER_ARGS_NO_AUTOMATION = [
+ ..._BROWSER_ARGS_CLEAN,
+ '--disable-extensions',
+ '--no-first-run',
+ '--no-default-browser-check',
+ '--disable-component-update',
+];
+
// ============================================================================
// MAIN EXPORT
// ============================================================================
/**
- * Run all 6 auth detection test variants against a Teams meeting URL.
- * Does NOT join the meeting — only checks which page Teams serves.
+ * Run auth detection test variants: login at teams.microsoft.com, screenshot where we land.
+ * Does NOT navigate to any meeting URL — only tests if Teams login works.
*/
export async function runAuthTests(
meetingUrl: string,
botAccountEmail?: string,
botAccountPassword?: string,
): Promise {
- logger.info(`[AuthTest] Starting auth detection tests for: ${meetingUrl}`);
+ logger.info(`[AuthTest] Starting auth tests for: ${meetingUrl} (email=${!!botAccountEmail})`);
const results: AuthTestResult[] = [];
for (const variant of _VARIANTS) {
- // Skip auth variant if no credentials
- if (variant.id === 'headfulAuth' && (!botAccountEmail || !botAccountPassword)) {
- results.push(_createSkippedResult(variant, 'Keine System-Bot Credentials konfiguriert'));
+ // All variants require credentials
+ if (!botAccountEmail || !botAccountPassword) {
+ results.push(_createSkippedResult(variant, 'Keine Credentials angegeben'));
continue;
}
@@ -162,87 +181,40 @@ async function _runVariant(
}
});
- // Auth state
+ // All variants follow the same flow:
+ // 1. Navigate to teams.microsoft.com (triggers Teams-specific login redirect)
+ // 2. Login (email → password → stay signed in)
+ // 3. Wait 20 seconds after login (NO meeting navigation)
+ // 4. Screenshot where we landed
+
let authAttempted = false;
let authSuccess: boolean | null = null;
- if (variant.id === 'headfulDirect') {
- // Strategy: Login via Teams' own auth redirect (not generic MS login).
- // Generic login.microsoftonline.com sets cookies for m365.cloud.microsoft,
- // NOT for teams.microsoft.com. We must trigger the Teams-specific login flow.
+ _log('info', `Step 1: Navigate to teams.microsoft.com`);
+ await page.goto('https://teams.microsoft.com', {
+ waitUntil: 'domcontentloaded',
+ timeout: 30000,
+ });
+ await page.waitForTimeout(3000);
+ _log('info', `After Teams redirect: ${page.url().substring(0, 120)}`);
- if (botAccountEmail && botAccountPassword) {
- authAttempted = true;
- _log('info', `Step 1: Navigate to teams.microsoft.com to trigger Teams login redirect`);
+ // Step 2: Login
+ if (botAccountEmail && botAccountPassword) {
+ authAttempted = true;
+ const onLoginPage = page.url().includes('login.microsoftonline.com') || page.url().includes('login.live.com');
+ _log('info', `On login page: ${onLoginPage}, authenticating as ${botAccountEmail}`);
- // Navigate to Teams — this redirects to login.microsoftonline.com with
- // Teams-specific client_id and redirect_uri=teams.microsoft.com
- await page.goto('https://teams.microsoft.com', {
- waitUntil: 'domcontentloaded',
- timeout: 30000,
- });
- await page.waitForTimeout(3000);
- _log('info', `After Teams redirect: ${page.url().substring(0, 120)}`);
-
- // Now we should be on login.microsoftonline.com with Teams context.
- // Use AuthProcedure with skipNavigation=true (we're already on the login page)
- const authProcedure = new AuthProcedure(page, logger);
- const onLoginPage = page.url().includes('login.microsoftonline.com') || page.url().includes('login.live.com');
-
- if (onLoginPage) {
- _log('info', `On MS login page, authenticating as ${botAccountEmail}`);
- authSuccess = await authProcedure.authenticateWithMicrosoft(botAccountEmail, botAccountPassword, false);
- } else {
- // Maybe already logged in, or Teams served directly
- _log('info', `Not on login page (${page.url().substring(0, 80)}), trying direct auth`);
- authSuccess = await authProcedure.authenticateWithMicrosoft(botAccountEmail, botAccountPassword, false);
- }
-
- _log(authSuccess ? 'info' : 'warn', `Auth result: ${authSuccess ? 'success' : 'failed'}`);
-
- if (authSuccess) {
- // Wait for redirect chain to complete (MS → Teams)
- await page.waitForTimeout(5000);
- _log('info', `After auth settle: ${page.url().substring(0, 120)}`);
- }
- } else {
- _log('info', 'No credentials provided — skipping auth');
- }
-
- // Step 2: Navigate to /v2/ meeting URL (with Teams auth cookies)
- const directUrl = _buildDirectV2Url(meetingUrl);
- _log('info', `Step 2: Direct /v2/ navigation: ${directUrl.substring(0, 120)}`);
-
- await page.goto(directUrl, {
- waitUntil: 'domcontentloaded',
- timeout: 30000,
- });
-
- // Wait for page to settle
- await page.waitForTimeout(8000);
- const currentUrl = page.url();
- _log('info', `After meeting load: ${currentUrl.substring(0, 120)}`);
-
- if (currentUrl.includes('light-meetings')) {
- _log('warn', 'Teams redirected /v2/ to light-meetings (even after auth)');
- } else if (currentUrl.includes('/v2/')) {
- _log('info', '/v2/ page loaded after auth!');
- }
- } else {
- // Standard flow: resolve URL, navigate, click launcher
- const useAnon = variant.id === 'baseline';
- const launchUrl = await _resolveMeetingUrl(meetingUrl, useAnon);
- _log('info', `Navigating to: ${launchUrl.substring(0, 100)}`);
-
- await page.goto(launchUrl, {
- waitUntil: 'domcontentloaded',
- timeout: 30000,
- });
-
- await _handleLauncher(page);
- await page.waitForTimeout(5000);
+ const authProcedure = new AuthProcedure(page, logger);
+ // skipNavigation=true because we're already on the login page after teams.microsoft.com redirect
+ authSuccess = await authProcedure.authenticateWithMicrosoft(botAccountEmail, botAccountPassword, !onLoginPage);
+ _log(authSuccess ? 'info' : 'warn', `Auth result: ${authSuccess ? 'success' : 'failed'}`);
}
+ // Step 3: Wait 20 seconds — NO further navigation
+ _log('info', 'Waiting 20s after login (no meeting navigation)...');
+ await page.waitForTimeout(20000);
+ _log('info', `Final URL after 20s: ${page.url().substring(0, 150)}`);
+
// Detect page type and gather signals
const detection = await _detectPageType(page);
@@ -319,30 +291,20 @@ interface LaunchResult {
}
async function _launchBrowserForVariant(variant: AuthTestVariant): Promise {
+ _requireDisplay(); // All variants are headful
+
switch (variant.id) {
- case 'baseline':
- return _launchVanilla(true, 'basic');
+ case 'chromiumClean':
+ return _launchChromiumHeadful(_BROWSER_ARGS_CLEAN);
- case 'rebrowser':
- return _launchRebrowser();
+ case 'chromiumNoAutomation':
+ return _launchChromiumHeadful(_BROWSER_ARGS_NO_AUTOMATION);
- case 'playwrightExtra':
- return _launchPlaywrightExtra();
-
- case 'headful':
- _requireDisplay();
- return _launchVanilla(false, 'enhanced');
-
- case 'headfulAuth':
- _requireDisplay();
- return _launchVanilla(false, 'enhanced');
-
- case 'headfulDirect':
- _requireDisplay();
- return _launchVanilla(false, 'enhanced');
+ case 'rebrowserHeadful':
+ return _launchRebrowserHeadful();
default:
- return _launchVanilla(true, 'basic');
+ return _launchChromiumHeadful(_BROWSER_ARGS_CLEAN);
}
}
@@ -363,103 +325,58 @@ function _requireDisplay(): void {
}
/**
- * Launch vanilla Playwright with configurable headless mode and stealth level.
+ * Launch Chromium headful with enhanced stealth + realistic device spoofing.
*/
-async function _launchVanilla(headless: boolean, stealthLevel: 'basic' | 'enhanced'): Promise {
+async function _launchChromiumHeadful(args: string[]): Promise {
const browser = await chromium.launch({
- headless,
- args: _BROWSER_ARGS,
+ headless: false,
+ args,
});
const context = await browser.newContext({
permissions: ['microphone', 'camera'],
- viewport: { width: 1280, height: 720 },
+ viewport: { width: 1920, height: 1080 },
userAgent: _USER_AGENT,
});
const page = await context.newPage();
-
- if (stealthLevel === 'basic') {
- await page.addInitScript(_getBasicStealthScript);
- } else {
- await page.addInitScript(_getEnhancedStealthScript);
- }
+ await page.addInitScript(_getEnhancedStealthScript);
+ await page.addInitScript(_getDeviceSpoofScript);
return { browser, context, page };
}
/**
- * Launch rebrowser-playwright (patched Playwright with CDP leak fixes).
- * Uses dynamic require to avoid import errors if package is not installed.
- *
- * IMPORTANT: rebrowser-playwright ships its own playwright-core which may
- * expect a different Chromium version than what's installed in the Docker image.
- * We solve this by passing `executablePath` from vanilla Playwright, so
- * rebrowser uses its patched CDP handling with the available Chromium binary.
+ * Launch rebrowser-playwright headful with CDP leak fixes + realistic devices.
*/
-async function _launchRebrowser(): Promise {
+async function _launchRebrowserHeadful(): Promise {
let rbChromium: typeof chromium;
try {
const rb = require('rebrowser-playwright');
rbChromium = rb.chromium;
} catch {
logger.warn('[AuthTest] rebrowser-playwright not installed, falling back to vanilla');
- return _launchVanilla(true, 'enhanced');
+ return _launchChromiumHeadful(_BROWSER_ARGS_CLEAN);
}
- // Use the Chromium binary from vanilla Playwright (matches Docker image)
- // to avoid "Executable doesn't exist" errors from version mismatch
const execPath = chromium.executablePath();
logger.info(`[AuthTest:rebrowser] Using Chromium binary: ${execPath}`);
const browser = await rbChromium.launch({
- headless: true,
+ headless: false,
executablePath: execPath,
- args: _BROWSER_ARGS,
+ args: _BROWSER_ARGS_CLEAN,
});
const context = await browser.newContext({
permissions: ['microphone', 'camera'],
- viewport: { width: 1280, height: 720 },
+ viewport: { width: 1920, height: 1080 },
userAgent: _USER_AGENT,
});
const page = await context.newPage();
-
- // rebrowser-playwright handles most stealth automatically, but add enhanced overrides too
await page.addInitScript(_getEnhancedStealthScript);
-
- return { browser, context, page };
-}
-
-/**
- * Launch playwright-extra with the stealth plugin.
- * Uses dynamic require to avoid import errors if package is not installed.
- */
-async function _launchPlaywrightExtra(): Promise {
- let chromiumExtra: typeof chromium;
- try {
- const { chromium: pweChromium } = require('playwright-extra');
- const StealthPlugin = require('puppeteer-extra-plugin-stealth');
- pweChromium.use(StealthPlugin());
- chromiumExtra = pweChromium;
- } catch {
- logger.warn('[AuthTest] playwright-extra not installed, falling back to vanilla');
- return _launchVanilla(true, 'enhanced');
- }
-
- const browser = await chromiumExtra.launch({
- headless: true,
- args: _BROWSER_ARGS,
- });
-
- const context = await browser.newContext({
- permissions: ['microphone', 'camera'],
- viewport: { width: 1280, height: 720 },
- userAgent: _USER_AGENT,
- });
-
- const page = await context.newPage();
+ await page.addInitScript(_getDeviceSpoofScript);
return { browser, context, page };
}
@@ -594,6 +511,63 @@ function _getEnhancedStealthScript(): void {
Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
}
+/**
+ * Device spoofing script — replaces "Fake Default Audio Input" with realistic device names.
+ * Without --use-fake-device-for-media-stream, getUserMedia may fail, so we mock it too.
+ */
+function _getDeviceSpoofScript(): void {
+ // Override enumerateDevices to return realistic device names
+ if (navigator.mediaDevices) {
+ const _originalEnumerateDevices = navigator.mediaDevices.enumerateDevices?.bind(navigator.mediaDevices);
+
+ navigator.mediaDevices.enumerateDevices = async () => {
+ // Return realistic Windows devices
+ return [
+ { deviceId: 'default', kind: 'audioinput', label: 'Microphone (Realtek(R) Audio)', groupId: 'a1b2c3d4', toJSON: () => ({}) } as MediaDeviceInfo,
+ { deviceId: 'comm-default', kind: 'audioinput', label: 'Microphone (Realtek(R) Audio)', groupId: 'a1b2c3d4', toJSON: () => ({}) } as MediaDeviceInfo,
+ { deviceId: 'default', kind: 'audiooutput', label: 'Speakers (Realtek(R) Audio)', groupId: 'a1b2c3d4', toJSON: () => ({}) } as MediaDeviceInfo,
+ { deviceId: 'comm-default', kind: 'audiooutput', label: 'Speakers (Realtek(R) Audio)', groupId: 'a1b2c3d4', toJSON: () => ({}) } as MediaDeviceInfo,
+ { deviceId: 'webcam-01', kind: 'videoinput', label: 'HD Webcam C270', groupId: 'e5f6g7h8', toJSON: () => ({}) } as MediaDeviceInfo,
+ ];
+ };
+
+ // Override getUserMedia to return a valid (silent/black) MediaStream
+ const _originalGetUserMedia = navigator.mediaDevices.getUserMedia?.bind(navigator.mediaDevices);
+ navigator.mediaDevices.getUserMedia = async (constraints?: MediaStreamConstraints) => {
+ try {
+ // Try real getUserMedia first (works if actual devices exist)
+ if (_originalGetUserMedia) {
+ return await _originalGetUserMedia(constraints);
+ }
+ } catch {
+ // Fallback: return empty stream
+ }
+ // Create a silent audio track + black video track
+ const stream = new MediaStream();
+ // @ts-ignore - AudioContext may not be available in all envs
+ try {
+ const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)();
+ const oscillator = audioCtx.createOscillator();
+ const dest = audioCtx.createMediaStreamDestination();
+ oscillator.connect(dest);
+ oscillator.start();
+ oscillator.stop(audioCtx.currentTime + 0.01);
+ dest.stream.getAudioTracks().forEach(t => stream.addTrack(t));
+ } catch {}
+ try {
+ const canvas = document.createElement('canvas');
+ canvas.width = 640;
+ canvas.height = 480;
+ const ctx = canvas.getContext('2d');
+ if (ctx) ctx.fillRect(0, 0, 640, 480);
+ const videoStream = canvas.captureStream(1);
+ videoStream.getVideoTracks().forEach(t => stream.addTrack(t));
+ } catch {}
+ return stream;
+ };
+ }
+}
+
// ============================================================================
// URL RESOLUTION
// ============================================================================