This commit is contained in:
2025-11-30 23:00:48 +01:00
parent 4b8c70978f
commit 645f537895
41 changed files with 738 additions and 1631 deletions

View File

@@ -1,7 +1,6 @@
import type { Browser, Page, BrowserContext } from 'playwright';
import * as fs from 'fs';
import * as path from 'path';
import { extractDom, ExportedElement } from '../../../../scripts/dom-export/domExtractor';
import { StepId } from '../../../../domain/value-objects/StepId';
import { AuthenticationState } from '../../../../domain/value-objects/AuthenticationState';
import { BrowserAuthenticationState } from '../../../../domain/value-objects/BrowserAuthenticationState';
@@ -775,6 +774,30 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
}
async executeStep(stepId: StepId, config: Record<string, unknown>): Promise<AutomationResult> {
const stepNumber = stepId.value;
if (!this.isRealMode() && this.config.baseUrl) {
if (stepNumber >= 2 && stepNumber <= this.totalSteps) {
try {
const fixture = getFixtureForStep(stepNumber);
if (fixture) {
const base = this.config.baseUrl.replace(/\/$/, '');
const url = `${base}/${fixture}`;
this.log('debug', 'Mock mode: navigating to fixture for step', {
step: stepNumber,
url,
});
await this.navigator.navigateToPage(url);
}
} catch (error) {
this.log('debug', 'Mock mode fixture navigation failed (non-fatal)', {
step: stepNumber,
error: String(error),
});
}
}
}
return this.stepOrchestrator.executeStep(stepId, config);
}
@@ -888,36 +911,34 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
*
* Error dumps are always kept and not subject to cleanup.
*/
private async saveDebugInfo(stepName: string, error: Error): Promise<{ screenshotPath?: string; htmlPath?: string; domPath?: string }> {
private async saveDebugInfo(stepName: string, error: Error): Promise<{ screenshotPath?: string; htmlPath?: string }> {
if (!this.page) return {};
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const baseName = `debug-error-${stepName}-${timestamp}`;
const debugDir = path.join(process.cwd(), 'debug-screenshots');
const result: { screenshotPath?: string; htmlPath?: string; domPath?: string } = {};
const result: { screenshotPath?: string; htmlPath?: string } = {};
try {
await fs.promises.mkdir(debugDir, { recursive: true });
// Save screenshot
const screenshotPath = path.join(debugDir, `${baseName}.png`);
await this.page.screenshot({ path: screenshotPath, fullPage: true });
result.screenshotPath = screenshotPath;
this.log('error', `Error debug screenshot saved: ${screenshotPath}`, { path: screenshotPath, error: error.message });
this.log('error', `Error debug screenshot saved: ${screenshotPath}`, {
path: screenshotPath,
error: error.message,
});
// Save HTML (cleaned to remove noise)
const htmlPath = path.join(debugDir, `${baseName}.html`);
const html = await this.page.evaluate(() => {
// Clone the document
const root = document.documentElement.cloneNode(true) as HTMLElement;
// Remove noise elements
['script', 'noscript', 'meta', 'base', 'style', 'link', 'iframe',
'picture', 'source', 'svg', 'path', 'img', 'canvas', 'video', 'audio']
.forEach(sel => root.querySelectorAll(sel).forEach(n => n.remove()));
.forEach((sel) => root.querySelectorAll(sel).forEach((n) => n.remove()));
// Remove empty non-interactive elements
root.querySelectorAll('*').forEach(n => {
root.querySelectorAll('*').forEach((n) => {
const text = (n.textContent || '').trim();
const interactive = n.matches('a,button,input,select,textarea,option,label');
if (!interactive && text === '' && n.children.length === 0) {
@@ -930,22 +951,6 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
await fs.promises.writeFile(htmlPath, html);
result.htmlPath = htmlPath;
this.log('error', `Error debug HTML saved: ${htmlPath}`, { path: htmlPath });
// Save structural DOM export alongside HTML
try {
await this.page.evaluate(() => {
(window as any).__name = (window as any).__name || ((fn: any) => fn);
});
const items = (await this.page.evaluate(
extractDom as () => ExportedElement[]
)) as unknown as ExportedElement[];
const domPath = path.join(debugDir, `${baseName}.dom.json`);
await fs.promises.writeFile(domPath, JSON.stringify(items, null, 2), 'utf8');
result.domPath = domPath;
this.log('error', `Error debug DOM saved: ${domPath}`, { path: domPath });
} catch (domErr) {
this.log('warn', 'Failed to save error debug DOM', { error: String(domErr) });
}
} catch (e) {
this.log('warn', 'Failed to save error debug info', { error: String(e) });
}
@@ -960,39 +965,36 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
* Files are named with "before-step-N" prefix and old snapshots are cleaned up
* to avoid disk bloat (keeps only last MAX_BEFORE_SNAPSHOTS).
*/
private async saveProactiveDebugInfo(step: number): Promise<{ screenshotPath?: string; htmlPath?: string; domPath?: string }> {
private async saveProactiveDebugInfo(step: number): Promise<{ screenshotPath?: string; htmlPath?: string }> {
if (!this.page) return {};
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const baseName = `debug-before-step-${step}-${timestamp}`;
const debugDir = path.join(process.cwd(), 'debug-screenshots');
const result: { screenshotPath?: string; htmlPath?: string; domPath?: string } = {};
const result: { screenshotPath?: string; htmlPath?: string } = {};
try {
await fs.promises.mkdir(debugDir, { recursive: true });
// Clean up old "before" snapshots first
await this.cleanupOldBeforeSnapshots(debugDir);
// Save screenshot
const screenshotPath = path.join(debugDir, `${baseName}.png`);
await this.page.screenshot({ path: screenshotPath, fullPage: true });
result.screenshotPath = screenshotPath;
this.log('info', `Pre-step screenshot saved: ${screenshotPath}`, { path: screenshotPath, step });
this.log('info', `Pre-step screenshot saved: ${screenshotPath}`, {
path: screenshotPath,
step,
});
// Save HTML (cleaned to remove noise)
const htmlPath = path.join(debugDir, `${baseName}.html`);
const html = await this.page.evaluate(() => {
// Clone the document
const root = document.documentElement.cloneNode(true) as HTMLElement;
// Remove noise elements
['script', 'noscript', 'meta', 'base', 'style', 'link', 'iframe',
'picture', 'source', 'svg', 'path', 'img', 'canvas', 'video', 'audio']
.forEach(sel => root.querySelectorAll(sel).forEach(n => n.remove()));
.forEach((sel) => root.querySelectorAll(sel).forEach((n) => n.remove()));
// Remove empty non-interactive elements
root.querySelectorAll('*').forEach(n => {
root.querySelectorAll('*').forEach((n) => {
const text = (n.textContent || '').trim();
const interactive = n.matches('a,button,input,select,textarea,option,label');
if (!interactive && text === '' && n.children.length === 0) {
@@ -1005,24 +1007,7 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
await fs.promises.writeFile(htmlPath, html);
result.htmlPath = htmlPath;
this.log('info', `Pre-step HTML saved: ${htmlPath}`, { path: htmlPath, step });
// Save structural DOM export alongside HTML
try {
await this.page.evaluate(() => {
(window as any).__name = (window as any).__name || ((fn: any) => fn);
});
const items = (await this.page.evaluate(
extractDom as () => ExportedElement[]
)) as unknown as ExportedElement[];
const domPath = path.join(debugDir, `${baseName}.dom.json`);
await fs.promises.writeFile(domPath, JSON.stringify(items, null, 2), 'utf8');
result.domPath = domPath;
this.log('info', `Pre-step DOM saved: ${domPath}`, { path: domPath, step });
} catch (domErr) {
this.log('warn', 'Failed to save proactive debug DOM', { error: String(domErr), step });
}
} catch (e) {
// Don't fail step execution if debug save fails
this.log('warn', 'Failed to save proactive debug info', { error: String(e), step });
}

View File

@@ -307,13 +307,8 @@ export class WizardStepOrchestrator {
break;
case 2:
if (!this.isRealMode() && this.config.baseUrl) {
const fixture = getFixtureForStep(2);
if (fixture) {
const base = this.config.baseUrl.replace(/\/$/, '');
await this.navigator.navigateToPage(`${base}/${fixture}`);
break;
}
if (!this.isRealMode()) {
break;
}
await this.clickAction('create');
break;
@@ -351,11 +346,12 @@ export class WizardStepOrchestrator {
'Race Information panel not found with fallback selector, dumping #create-race-wizard innerHTML',
{ selector: raceInfoFallback },
);
const inner = await this.page!.evaluate(
() =>
document.querySelector('#create-race-wizard')?.innerHTML ||
'',
);
const inner = await this.page!.evaluate(() => {
const doc = (globalThis as any).document as any;
return (
doc?.querySelector('#create-race-wizard')?.innerHTML || ''
);
});
this.log(
'debug',
'create-race-wizard innerHTML (truncated)',
@@ -412,14 +408,57 @@ export class WizardStepOrchestrator {
if (this.isRealMode()) {
await this.waitForWizardStep('admins');
await this.checkWizardDismissed(step);
} else {
const adminSearch =
(config.adminSearch ?? config.admin) as string | undefined;
if (adminSearch) {
await this.fillField('adminSearch', String(adminSearch));
}
}
await this.clickNextButton('Time Limit');
break;
case 6:
if (this.isRealMode()) {
await this.waitForWizardStep('admins');
await this.checkWizardDismissed(step);
} else {
const adminSearch =
(config.adminSearch ?? config.admin) as string | undefined;
if (adminSearch) {
await this.fillField('adminSearch', String(adminSearch));
const page = this.page;
if (page) {
await page.evaluate((term) => {
const doc = (globalThis as any).document as any;
if (!doc) {
return;
}
const root =
(doc.querySelector('#set-admins') as any) ?? doc.body;
if (!root) {
return;
}
const rows = Array.from(
(root as any).querySelectorAll(
'tbody[data-testid="admin-display-name-list"] tr',
),
) as any[];
if (rows.length === 0) {
return;
}
const needle = String(term).toLowerCase();
for (const r of rows) {
const text = String((r as any).textContent || '').toLowerCase();
if (text.includes(needle)) {
(r as any).setAttribute('data-selected-admin', 'true');
return;
}
}
(rows[0] as any).setAttribute('data-selected-admin', 'true');
}, String(adminSearch));
}
}
}
await this.clickNextButton('Time Limit');
break;
@@ -553,11 +592,11 @@ export class WizardStepOrchestrator {
case 9:
this.log('info', 'Step 9: Validating we are still on Cars page');
if (this.isRealMode()) {
const actualPage = await this.detectCurrentWizardPage();
const skipOffset = this.synchronizeStepCounter(step, actualPage);
if (skipOffset > 0) {
this.log('info', `Step ${step} was auto-skipped by wizard`, {
actualPage,
@@ -565,7 +604,7 @@ export class WizardStepOrchestrator {
});
return { success: true };
}
const wizardFooter = await this.page!
.locator('.wizard-footer')
.innerText()
@@ -573,21 +612,21 @@ export class WizardStepOrchestrator {
this.log('info', 'Step 9: Current wizard footer', {
footer: wizardFooter,
});
const onTrackPage =
wizardFooter.includes('Track Options') ||
(await this.page!
.locator(IRACING_SELECTORS.wizard.stepContainers.track)
.isVisible()
.catch(() => false));
if (onTrackPage) {
const errorMsg = `FATAL: Step 9 attempted on Track page (Step 11) - navigation bug detected. Wizard footer: "${wizardFooter}"`;
this.log('error', errorMsg);
throw new Error(errorMsg);
}
}
const validation = await this.validatePageState({
expectedStep: 'cars',
requiredSelectors: this.isRealMode()
@@ -595,7 +634,7 @@ export class WizardStepOrchestrator {
: ['#set-cars, .wizard-step[id*="cars"], .cars-panel'],
forbiddenSelectors: ['#set-track'],
});
if (validation.isErr()) {
const errorMsg = `Step 9 validation error: ${
validation.error?.message ?? 'unknown error'
@@ -603,7 +642,7 @@ export class WizardStepOrchestrator {
this.log('error', errorMsg);
throw new Error(errorMsg);
}
const validationResult = validation.unwrap();
this.log('info', 'Step 9 validation result', {
isValid: validationResult.isValid,
@@ -611,7 +650,7 @@ export class WizardStepOrchestrator {
missingSelectors: validationResult.missingSelectors,
unexpectedSelectors: validationResult.unexpectedSelectors,
});
if (!validationResult.isValid) {
const errorMsg = `Step 9 FAILED validation: ${
validationResult.message
@@ -626,14 +665,16 @@ export class WizardStepOrchestrator {
});
throw new Error(errorMsg);
}
this.log('info', 'Step 9 validation passed - confirmed on Cars page');
const carIds = config.carIds as string[] | undefined;
const carSearchTerm =
(config.carSearch as string | undefined) ||
(config.car as string | undefined) ||
carIds?.[0];
if (this.isRealMode()) {
const carIds = config.carIds as string[] | undefined;
const carSearchTerm =
config.carSearch || config.car || carIds?.[0];
if (carSearchTerm) {
await this.clickAddCarButton();
await this.waitForAddCarModal();
@@ -647,11 +688,31 @@ export class WizardStepOrchestrator {
await this.clickNextButton('Car Classes');
} else {
if (config.carSearch) {
await this.fillField('carSearch', String(config.carSearch));
await this.clickAction('confirm');
if (carSearchTerm) {
const page = this.page;
if (page) {
await this.clickAddCarButton();
await this.waitForAddCarModal();
await this.fillField('carSearch', String(carSearchTerm));
await page.waitForTimeout(200);
try {
await this.selectFirstSearchResult();
} catch (e) {
this.log('debug', 'Step 9 mock mode: selectFirstSearchResult failed (non-fatal)', {
error: String(e),
});
}
this.log('info', 'Step 9 mock mode: selected car from JSON-backed list', {
carSearch: String(carSearchTerm),
});
}
} else {
this.log(
'debug',
'Step 9 mock mode: no carSearch provided, skipping car addition',
);
}
await this.clickNextButton('Car Classes');
}
break;
@@ -805,13 +866,23 @@ export class WizardStepOrchestrator {
await this.waitForWizardStep('timeOfDay');
await this.checkWizardDismissed(step);
}
if (config.trackConfig) {
await this.selectDropdown('trackConfig', String(config.trackConfig));
}
await this.clickNextButton('Time of Day');
if (this.isRealMode()) {
if (config.timeOfDay !== undefined) {
await this.setSlider('timeOfDay', Number(config.timeOfDay));
}
if (config.trackConfig) {
await this.selectDropdown('trackConfig', String(config.trackConfig));
}
await this.clickNextButton('Time of Day');
await this.waitForWizardStep('timeOfDay');
} else {
if (config.timeOfDay !== undefined) {
await this.setSlider('timeOfDay', Number(config.timeOfDay));
}
if (config.trackConfig) {
await this.selectDropdown('trackConfig', String(config.trackConfig));
}
}
break;
@@ -863,11 +934,12 @@ export class WizardStepOrchestrator {
'Weather panel not found with fallback selector, dumping #create-race-wizard innerHTML',
{ selector: weatherFallbackSelector },
);
const inner = await this.page!.evaluate(
() =>
document.querySelector('#create-race-wizard')?.innerHTML ||
'',
);
const inner = await this.page!.evaluate(() => {
const doc = (globalThis as any).document as any;
return (
doc?.querySelector('#create-race-wizard')?.innerHTML || ''
);
});
this.log(
'debug',
'create-race-wizard innerHTML (truncated)',
@@ -889,14 +961,32 @@ export class WizardStepOrchestrator {
});
}
await this.checkWizardDismissed(step);
if (config.timeOfDay !== undefined) {
await this.setSlider('timeOfDay', Number(config.timeOfDay));
}
} else {
if (config.weatherType) {
await this.selectDropdown('weatherType', String(config.weatherType));
}
if (config.temperature !== undefined && this.page) {
const tempSelector = IRACING_SELECTORS.steps.temperature;
const tempExists =
(await this.page
.locator(tempSelector)
.first()
.count()
.catch(() => 0)) > 0;
if (tempExists) {
await this.setSlider('temperature', Number(config.temperature));
}
}
}
if (config.timeOfDay !== undefined) {
await this.setSlider('timeOfDay', Number(config.timeOfDay));
}
if (this.isRealMode()) {
await this.dismissDatetimePickers();
await this.clickNextButton('Weather');
}
await this.clickNextButton('Weather');
break;
case 16:
@@ -1000,6 +1090,10 @@ export class WizardStepOrchestrator {
} else {
const valueStr = String(config.trackState);
await this.page!.evaluate((trackStateValue) => {
const doc = (globalThis as any).document as any;
if (!doc) {
return;
}
const map: Record<string, number> = {
'very-low': 10,
low: 25,
@@ -1011,24 +1105,29 @@ export class WizardStepOrchestrator {
};
const numeric = map[trackStateValue] ?? null;
const inputs = Array.from(
document.querySelectorAll<HTMLInputElement>(
doc.querySelectorAll(
'input[id*="starting-track-state"], input[id*="track-state"], input[data-value]',
),
);
) as any[];
if (numeric !== null && inputs.length > 0) {
for (const inp of inputs) {
try {
inp.value = String(numeric);
(inp as any).dataset = (inp as any).dataset || {};
(inp as any).dataset.value = String(numeric);
inp.setAttribute('data-value', String(numeric));
inp.dispatchEvent(
new Event('input', { bubbles: true }),
(inp as any).value = String(numeric);
const ds =
(inp as any).dataset || ((inp as any).dataset = {});
ds.value = String(numeric);
(inp as any).setAttribute?.(
'data-value',
String(numeric),
);
inp.dispatchEvent(
new Event('change', { bubbles: true }),
const Ev = (globalThis as any).Event;
(inp as any).dispatchEvent?.(
new Ev('input', { bubbles: true }),
);
} catch (e) {
(inp as any).dispatchEvent?.(
new Ev('change', { bubbles: true }),
);
} catch {
}
}
}