#!/usr/bin/env tsx /** * Extract Mock Fixtures from Real iRacing HTML Dumps * * This script extracts clean, minimal HTML from real iRacing dumps and validates * that all required selectors from IRacingSelectors.ts exist in the extracted HTML. * * Usage: * npx tsx scripts/extract-mock-fixtures.ts * npx tsx scripts/extract-mock-fixtures.ts --force * npx tsx scripts/extract-mock-fixtures.ts --steps 2,3,4 * npx tsx scripts/extract-mock-fixtures.ts --validate * npx tsx scripts/extract-mock-fixtures.ts --verbose */ import * as fs from 'fs'; import * as path from 'path'; import { Command } from 'commander'; import * as cheerio from 'cheerio'; import * as prettier from 'prettier'; import { IRACING_SELECTORS } from '../packages/infrastructure/adapters/automation/IRacingSelectors'; // ============================================================================ // Types and Configuration // ============================================================================ interface ExtractionConfig { source: string; output: string; requiredSelectors?: string[]; } interface ExtractionResult { step: number; sourceFile: string; outputFile: string; originalSize: number; extractedSize: number; selectorsFound: number; selectorsTotal: number; missingSelectors: string[]; success: boolean; error?: string; } const EXTRACTION_CONFIG: Record = { 2: { source: '01-hosted-racing.html', output: 'step-02-hosted-racing.html' }, 3: { source: '02-create-a-race.html', output: 'step-03-create-race.html' }, 4: { source: '03-race-information.html', output: 'step-04-race-information.html' }, 5: { source: '04-server-details.html', output: 'step-05-server-details.html' }, 6: { source: '05-set-admins.html', output: 'step-06-set-admins.html' }, 7: { source: '07-time-limits.html', output: 'step-07-time-limits.html' }, 8: { source: '08-set-cars.html', output: 'step-08-set-cars.html' }, 9: { source: '09-add-a-car.html', output: 'step-09-add-car-modal.html' }, 10: { source: '10-set-car-classes.html', output: 'step-10-set-car-classes.html' }, 11: { source: '11-set-track.html', output: 'step-11-set-track.html' }, 12: { source: '12-add-a-track.html', output: 'step-12-add-track-modal.html' }, 13: { source: '13-track-options.html', output: 'step-13-track-options.html' }, 14: { source: '14-time-of-day.html', output: 'step-14-time-of-day.html' }, 15: { source: '15-weather.html', output: 'step-15-weather.html' }, 16: { source: '16-race-options.html', output: 'step-16-race-options.html' }, 17: { source: '18-track-conditions.html', output: 'step-17-track-conditions.html' }, }; const PATHS = { source: path.resolve(__dirname, '../resources/iracing-hosted-sessions'), output: path.resolve(__dirname, '../resources/mock-fixtures'), }; // ============================================================================ // Selector Mapping - Which selectors are required for each step // ============================================================================ function getRequiredSelectorsForStep(step: number): string[] { const selectors: string[] = []; switch (step) { case 2: // Hosted Racing selectors.push( IRACING_SELECTORS.hostedRacing.createRaceButton, IRACING_SELECTORS.hostedRacing.hostedTab ); break; case 3: // Race Information selectors.push( IRACING_SELECTORS.wizard.modal, IRACING_SELECTORS.wizard.nextButton, IRACING_SELECTORS.wizard.stepContainers.raceInformation, IRACING_SELECTORS.steps.sessionName, IRACING_SELECTORS.steps.password, IRACING_SELECTORS.steps.description ); break; case 4: // Server Details selectors.push( IRACING_SELECTORS.wizard.nextButton, IRACING_SELECTORS.wizard.stepContainers.serverDetails, IRACING_SELECTORS.steps.region ); break; case 5: // Set Admins selectors.push( IRACING_SELECTORS.wizard.nextButton, IRACING_SELECTORS.wizard.stepContainers.admins, IRACING_SELECTORS.steps.adminSearch ); break; case 7: // Time Limits selectors.push( IRACING_SELECTORS.wizard.nextButton, IRACING_SELECTORS.wizard.stepContainers.timeLimit, IRACING_SELECTORS.steps.practice ); break; case 8: // Set Cars selectors.push( IRACING_SELECTORS.wizard.nextButton, IRACING_SELECTORS.wizard.stepContainers.cars, IRACING_SELECTORS.steps.addCarButton ); break; case 9: // Add Car Modal selectors.push( IRACING_SELECTORS.steps.addCarModal, IRACING_SELECTORS.steps.carSearch, IRACING_SELECTORS.steps.carSelectButton ); break; case 11: // Set Track selectors.push( IRACING_SELECTORS.wizard.nextButton, IRACING_SELECTORS.wizard.stepContainers.track, IRACING_SELECTORS.steps.addTrackButton ); break; case 12: // Add Track Modal selectors.push( IRACING_SELECTORS.steps.addTrackModal, IRACING_SELECTORS.steps.trackSearch, IRACING_SELECTORS.steps.trackSelectButton ); break; case 13: // Track Options selectors.push( IRACING_SELECTORS.wizard.nextButton, IRACING_SELECTORS.wizard.stepContainers.trackOptions, IRACING_SELECTORS.steps.trackConfig ); break; case 14: // Time of Day selectors.push( IRACING_SELECTORS.wizard.nextButton, IRACING_SELECTORS.wizard.stepContainers.timeOfDay, IRACING_SELECTORS.steps.timeOfDay ); break; case 15: // Weather selectors.push( IRACING_SELECTORS.wizard.nextButton, IRACING_SELECTORS.wizard.stepContainers.weather, IRACING_SELECTORS.steps.weatherType ); break; case 16: // Race Options selectors.push( IRACING_SELECTORS.wizard.nextButton, IRACING_SELECTORS.wizard.stepContainers.raceOptions, IRACING_SELECTORS.steps.maxDrivers ); break; case 17: // Track Conditions selectors.push( IRACING_SELECTORS.wizard.stepContainers.trackConditions, IRACING_SELECTORS.steps.trackState, IRACING_SELECTORS.BLOCKED_SELECTORS.checkout ); break; default: // For steps without specific selectors, require basic wizard structure if (step >= 3 && step <= 17) { selectors.push(IRACING_SELECTORS.wizard.modal); } } return selectors; } // ============================================================================ // HTML Extraction Logic // ============================================================================ function extractCleanHTML(html: string, verbose: boolean = false): string { const $ = cheerio.load(html); // Find the #app root const appRoot = $('#app'); if (appRoot.length === 0) { throw new Error('Could not find
in HTML'); } // Remove unnecessary elements while preserving interactive elements if (verbose) console.log(' Removing unnecessary elements...'); // Remove script tags (analytics, tracking) $('script').remove(); // Remove non-interactive visual elements $('canvas, iframe').remove(); // Remove SVG unless they're icons in buttons/interactive elements $('svg').each((_, el) => { const $el = $(el); // Keep SVGs inside interactive elements if (!$el.closest('button, a.btn, .icon').length) { $el.remove(); } }); // Remove base64 images but keep icon classes $('img').each((_, el) => { const $el = $(el); const src = $el.attr('src'); if (src && src.startsWith('data:image')) { // If it's in an icon context, keep the element but remove src if ($el.closest('.icon, button, a.btn').length) { $el.removeAttr('src'); } else { $el.remove(); } } }); // Remove large style blocks but keep link tags to external CSS $('style').each((_, el) => { const $el = $(el); const content = $el.html() || ''; // Only remove if it's a large inline style block (> 1KB) if (content.length > 1024) { $el.remove(); } }); // Remove comments $('*').contents().each((_, node) => { if (node.type === 'comment') { $(node).remove(); } }); // Extract the app root HTML const extracted = $.html(appRoot); return extracted; } async function prettifyHTML(html: string): Promise { try { return await prettier.format(html, { parser: 'html', printWidth: 120, tabWidth: 2, useTabs: false, htmlWhitespaceSensitivity: 'ignore', }); } catch (error) { // If prettify fails, return the original HTML console.warn(' ⚠️ Prettify failed, using raw HTML'); return html; } } // ============================================================================ // Selector Validation Logic // ============================================================================ function validateSelectors( html: string, requiredSelectors: string[], verbose: boolean = false ): { found: number; total: number; missing: string[] } { const $ = cheerio.load(html); const missing: string[] = []; let found = 0; for (const selector of requiredSelectors) { // Split compound selectors (comma-separated) and check if ANY match const alternatives = selector.split(',').map(s => s.trim()); let selectorFound = false; let hasPlaywrightOnlySelector = false; for (const alt of alternatives) { // Skip Playwright-specific selectors (cheerio doesn't support them) // Common Playwright selectors: :has-text(), :has(), :visible, :enabled, etc. if (alt.includes(':has-text(') || alt.includes(':text(') || alt.includes(':visible') || alt.includes(':enabled') || alt.includes(':disabled') || alt.includes(':has(') || alt.includes(':not(')) { hasPlaywrightOnlySelector = true; if (verbose) { console.log(` ⊘ Skipping Playwright-specific: ${alt.substring(0, 60)}${alt.length > 60 ? '...' : ''}`); } continue; } try { if ($(alt).length > 0) { selectorFound = true; break; } } catch (error) { if (verbose) { console.warn(` ⚠️ Invalid selector syntax: ${alt}`); } } } // If we found at least one valid selector, or all were Playwright-specific, count as found if (selectorFound || hasPlaywrightOnlySelector) { found++; if (verbose && selectorFound) { console.log(` ✓ Found: ${selector.substring(0, 60)}${selector.length > 60 ? '...' : ''}`); } } else { missing.push(selector); if (verbose) { console.log(` ✗ Missing: ${selector.substring(0, 60)}${selector.length > 60 ? '...' : ''}`); } } } return { found, total: requiredSelectors.length, missing }; } // ============================================================================ // File Operations // ============================================================================ async function extractFixture( step: number, config: ExtractionConfig, options: { force: boolean; validate: boolean; verbose: boolean } ): Promise { const result: ExtractionResult = { step, sourceFile: config.source, outputFile: config.output, originalSize: 0, extractedSize: 0, selectorsFound: 0, selectorsTotal: 0, missingSelectors: [], success: false, }; try { // Check source file exists const sourcePath = path.join(PATHS.source, config.source); if (!fs.existsSync(sourcePath)) { throw new Error(`Source file not found: ${sourcePath}`); } // Check if output file exists and we're not forcing const outputPath = path.join(PATHS.output, config.output); if (fs.existsSync(outputPath) && !options.force) { throw new Error(`Output file already exists (use --force to overwrite): ${outputPath}`); } // Read source HTML const sourceHTML = fs.readFileSync(sourcePath, 'utf-8'); result.originalSize = sourceHTML.length; if (options.verbose) { console.log(`\nProcessing step ${step}: ${config.source} → ${config.output}`); console.log(` Source size: ${(result.originalSize / 1024).toFixed(1)}KB`); } // Extract clean HTML const extractedHTML = extractCleanHTML(sourceHTML, options.verbose); // Prettify the output const prettyHTML = await prettifyHTML(extractedHTML); result.extractedSize = prettyHTML.length; // Validate selectors if requested const requiredSelectors = getRequiredSelectorsForStep(step); if (options.validate && requiredSelectors.length > 0) { if (options.verbose) { console.log(` Validating ${requiredSelectors.length} selectors...`); } const validation = validateSelectors(prettyHTML, requiredSelectors, options.verbose); result.selectorsFound = validation.found; result.selectorsTotal = validation.total; result.missingSelectors = validation.missing; } // Write output file fs.writeFileSync(outputPath, prettyHTML, 'utf-8'); result.success = true; // Print summary const reductionPct = ((1 - result.extractedSize / result.originalSize) * 100).toFixed(0); const sizeInfo = `${(result.extractedSize / 1024).toFixed(1)}KB (${reductionPct}% reduction)`; if (!options.verbose) { console.log(`\nProcessing step ${step}: ${config.source} → ${config.output}`); } console.log(` ✓ Extracted ${sizeInfo}`); if (options.validate && result.selectorsTotal > 0) { if (result.selectorsFound === result.selectorsTotal) { console.log(` ✓ All ${result.selectorsTotal} required selectors found`); } else { console.log(` ✗ ${result.selectorsFound}/${result.selectorsTotal} selectors found`); result.missingSelectors.forEach(sel => { console.log(` Missing: ${sel.substring(0, 80)}${sel.length > 80 ? '...' : ''}`); }); } } return result; } catch (error) { result.error = error instanceof Error ? error.message : String(error); result.success = false; return result; } } // ============================================================================ // Main Execution // ============================================================================ async function main() { const program = new Command(); program .name('extract-mock-fixtures') .description('Extract clean HTML fixtures from real iRacing dumps with selector validation') .option('-f, --force', 'Overwrite existing fixture files', false) .option('-s, --steps ', 'Extract specific steps only (comma-separated)', '') .option('-v, --validate', 'Validate that all required selectors exist', false) .option('--verbose', 'Verbose output with detailed logging', false) .parse(process.argv); const options = program.opts(); console.log('🔍 Extracting mock fixtures from real iRacing HTML dumps...\n'); // Determine which steps to process const stepsToProcess = options.steps ? options.steps.split(',').map((s: string) => parseInt(s.trim(), 10)) : Object.keys(EXTRACTION_CONFIG).map(Number); const results: ExtractionResult[] = []; let totalOriginalSize = 0; let totalExtractedSize = 0; // Process each step for (const step of stepsToProcess) { const config = EXTRACTION_CONFIG[step]; if (!config) { console.error(`❌ Invalid step number: ${step}`); continue; } const result = await extractFixture(step, config, { force: options.force, validate: options.validate, verbose: options.verbose, }); results.push(result); totalOriginalSize += result.originalSize; totalExtractedSize += result.extractedSize; if (!result.success) { console.error(` ❌ Error: ${result.error}`); } } // Print final summary console.log('\n' + '='.repeat(80)); const successCount = results.filter(r => r.success).length; const failCount = results.filter(r => !r.success).length; if (successCount > 0) { const totalReduction = ((1 - totalExtractedSize / totalOriginalSize) * 100).toFixed(0); console.log(`✅ Successfully extracted ${successCount} fixtures`); console.log(`📦 Total size reduction: ${totalReduction}% (${(totalOriginalSize / 1024).toFixed(0)}KB → ${(totalExtractedSize / 1024).toFixed(0)}KB)`); } if (failCount > 0) { console.log(`❌ Failed to extract ${failCount} fixtures`); } if (options.validate) { const validationResults = results.filter(r => r.success && r.selectorsTotal > 0); const allValid = validationResults.every(r => r.missingSelectors.length === 0); if (allValid && validationResults.length > 0) { console.log(`✅ All selector validations passed`); } else if (validationResults.length > 0) { const failedValidations = validationResults.filter(r => r.missingSelectors.length > 0); console.log(`⚠️ ${failedValidations.length} steps have missing selectors`); failedValidations.forEach(r => { console.log(`\n Step ${r.step}: ${r.missingSelectors.length} missing`); r.missingSelectors.forEach(sel => { console.log(` - ${sel.substring(0, 80)}${sel.length > 80 ? '...' : ''}`); }); }); } } console.log('='.repeat(80)); // Exit with error code if any extractions failed process.exit(failCount > 0 ? 1 : 0); } // Run the script main().catch(error => { console.error('❌ Fatal error:', error); process.exit(1); });