538 lines
17 KiB
TypeScript
538 lines
17 KiB
TypeScript
#!/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<number, ExtractionConfig> = {
|
|
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 <div id="app"> 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<string> {
|
|
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<ExtractionResult> {
|
|
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 <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);
|
|
}); |