#!/usr/bin/env node /** * VIN End-to-End Test Script * * Tests each VIN through the sase.tr dashboard: * 1. Login as admin * 2. Search VIN on /dashboard/search * 3. Check if VIN resolves (PL24 / EMEX / failed) * 4. Click first categories, verify schema + OEM parts load * 5. Verify DB records (vehicles, categories, parts, schema_pics) * 6. Verify MinIO image storage * 7. Generate a full report * * Usage: node scripts/vin-e2e-test.js [--headless] [--vin VIN1,VIN2,...] [--delay 5000] */ const { chromium } = require("playwright"); const fs = require("fs"); const path = require("path"); const { execSync } = require("child_process"); // ── Config ────────────────────────────────────────────── const CONFIG = { webUrl: "http://localhost:3000", apiUrl: "http://localhost:4000/api", email: "admin@sase.tr", password: "Sase2026", dbUrl: "postgresql://sase:vlii9wcMK0dcXD0A8zYdHx4wQp3XUQ7@127.0.0.1:5432/sase", minioPublicUrl: "https://storage.sase.tr/sase-schemas", headless: true, delayBetweenVins: 8000, // ms between VIN searches (rate limit protection) navigationTimeout: 120000, // 2 min actionTimeout: 60000, // 1 min categoryClickDelay: 5000, // ms after clicking a category maxCategoriesToTest: 3, // how many categories to click per VIN }; // ── Known-brand VINs (primary test set) ───────────────── const KNOWN_BRAND_VINS = [ { brand: "Renault", vin: "VF1C066MC19290416" }, { brand: "Porsche", vin: "WP1ZZZ92ZCLA29834" }, { brand: "Subaru", vin: "JF1GD9LF37G069905" }, { brand: "Mercedes", vin: "WDB2020181A148652" }, { brand: "Honda", vin: "NLAFC5650HW030691" }, { brand: "Mazda", vin: "JMZDKFWSA10182289" }, { brand: "Ford", vin: "NM0GXXTTPGAG07617" }, { brand: "Volvo", vin: "YV1AS985691096540" }, { brand: "Kia", vin: "TMAJ3812HGJ226161" }, { brand: "BMW", vin: "WBALZ72090DY98841" }, { brand: "Mitsubishi", vin: "JMBSNCS3A4U004435" }, { brand: "Suzuki", vin: "TSMLYD21S00268458" }, { brand: "Scania", vin: "XLER4X20005217616" }, { brand: "Nissan", vin: "SJNFCAJ10U1242901" }, { brand: "MAN", vin: "WMAH12ZZ04M393187" }, { brand: "Land Rover", vin: "SALLNABA8YA563459" }, ]; // ── Parse CLI args ────────────────────────────────────── function parseArgs() { const args = process.argv.slice(2); const opts = { ...CONFIG }; for (let i = 0; i < args.length; i++) { if (args[i] === "--headless") opts.headless = true; if (args[i] === "--headed") opts.headless = false; if (args[i] === "--vin" && args[i + 1]) { opts.filterVins = args[++i].split(",").map((v) => v.trim().toUpperCase()); } if (args[i] === "--delay" && args[i + 1]) { opts.delayBetweenVins = parseInt(args[++i], 10); } if (args[i] === "--max-categories" && args[i + 1]) { opts.maxCategoriesToTest = parseInt(args[++i], 10); } } return opts; } // ── Helpers ───────────────────────────────────────────── const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); function timestamp() { return new Date().toISOString().replace("T", " ").substring(0, 19); } function log(msg) { console.log(`[${timestamp()}] ${msg}`); } function logSection(title) { console.log(`\n${"═".repeat(70)}`); console.log(` ${title}`); console.log(`${"═".repeat(70)}`); } // ── Database helper (uses psql CLI) ───────────────────── class DbChecker { constructor(connectionString) { this.connStr = connectionString; this.available = false; } async connect() { try { this._query("SELECT 1"); this.available = true; log("DB connected via psql"); } catch (err) { log(`DB connection failed: ${err.message}`); this.available = false; } } async close() { // psql is stateless, nothing to close } _query(sql) { const escaped = sql.replace(/"/g, '\\"'); const result = execSync( `psql "${this.connStr}" -t -A -F '|' -c "${escaped}"`, { encoding: "utf-8", timeout: 15000 }, ).trim(); if (!result) return []; return result.split("\n").filter(Boolean).map((row) => row.split("|")); } _esc(val) { return String(val).replace(/'/g, "''"); } async getVehicle(vin) { const rows = this._query( `SELECT id, vin, brand_name, model, year, engine, source, raw_data IS NOT NULL AS has_raw_data FROM vehicles WHERE vin = '${this._esc(vin.toUpperCase())}' ORDER BY updated_at DESC LIMIT 1`, ); if (rows.length === 0 || !rows[0][0]) return null; const r = rows[0]; return { id: r[0], vin: r[1], brand_name: r[2], model: r[3], year: r[4], engine: r[5], source: r[6], has_raw_data: r[7] === "t", }; } async getCategoryCount(vehicleId) { const rows = this._query( `SELECT count(*) FROM categories WHERE vehicle_id = '${this._esc(vehicleId)}'`, ); return parseInt(rows[0]?.[0] || "0", 10); } async getTopCategories(vehicleId, limit = 5) { const rows = this._query( `SELECT id, name, name_original, source, link_path IS NOT NULL FROM categories WHERE vehicle_id = '${this._esc(vehicleId)}' AND parent_id IS NULL ORDER BY created_at LIMIT ${limit}`, ); return rows.filter((r) => r[0]).map((r) => ({ id: r[0], name: r[1], name_original: r[2], source: r[3], has_link: r[4] === "t", })); } async getPartsCount(vehicleId) { const rows = this._query( `SELECT count(*) FROM parts WHERE vehicle_id = '${this._esc(vehicleId)}'`, ); return parseInt(rows[0]?.[0] || "0", 10); } async getPartsForCategory(categoryId) { const rows = this._query( `SELECT id, oem_code, name, position, hotspot_index FROM parts WHERE category_id = '${this._esc(categoryId)}' ORDER BY position LIMIT 20`, ); return rows.filter((r) => r[0]).map((r) => ({ id: r[0], oem_code: r[1], name: r[2], position: r[3], hotspot_index: r[4], })); } async getSchemaPics(categoryId) { const rows = this._query( `SELECT id, image_url, source, hotspots IS NOT NULL FROM schema_pics WHERE category_id = '${this._esc(categoryId)}'`, ); return rows.filter((r) => r[0]).map((r) => ({ id: r[0], image_url: r[1], source: r[2], has_hotspots: r[3] === "t", })); } async getLeafCategories(vehicleId, limit = 5) { const rows = this._query( `SELECT c.id, c.name, c.source, c.link_path FROM categories c WHERE c.vehicle_id = '${this._esc(vehicleId)}' AND NOT EXISTS (SELECT 1 FROM categories c2 WHERE c2.parent_id = c.id) AND c.link_path IS NOT NULL ORDER BY c.created_at LIMIT ${limit}`, ); return rows.filter((r) => r[0]).map((r) => ({ id: r[0], name: r[1], source: r[2], link_path: r[3], })); } } // ── MinIO check ───────────────────────────────────────── async function checkMinioImage(imageUrl) { if (!imageUrl) return { exists: false, reason: "no URL" }; try { const resp = await fetch(imageUrl, { method: "HEAD", signal: AbortSignal.timeout(10000) }); return { exists: resp.ok, status: resp.status, contentType: resp.headers.get("content-type"), size: resp.headers.get("content-length"), }; } catch (err) { return { exists: false, reason: err.message }; } } // ── Main test runner ──────────────────────────────────── async function main() { const opts = parseArgs(); const report = { startedAt: new Date().toISOString(), config: { headless: opts.headless, delayBetweenVins: opts.delayBetweenVins, maxCategoriesToTest: opts.maxCategoriesToTest, }, results: [], summary: { total: 0, resolved: 0, failed: 0, errors: 0 }, }; // Decide which VINs to test let vinList = KNOWN_BRAND_VINS; if (opts.filterVins) { vinList = opts.filterVins.map((vin) => { const known = KNOWN_BRAND_VINS.find((v) => v.vin === vin); return known || { brand: "Unknown", vin }; }); } logSection("VIN E2E TEST - STARTING"); log(`Testing ${vinList.length} VINs`); log(`Headless: ${opts.headless}, Delay: ${opts.delayBetweenVins}ms`); // ── Launch browser ──────────────────────────────────── const browser = await chromium.launch({ headless: opts.headless, args: ["--no-sandbox", "--disable-setuid-sandbox"], }); const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, ignoreHTTPSErrors: true, }); context.setDefaultTimeout(opts.actionTimeout); context.setDefaultNavigationTimeout(opts.navigationTimeout); const page = await context.newPage(); // ── Connect to DB ───────────────────────────────────── const db = new DbChecker(opts.dbUrl); try { await db.connect(); } catch (err) { log(`WARNING: Could not connect to DB: ${err.message}`); log("DB checks will be skipped."); } // ── Login ───────────────────────────────────────────── logSection("LOGIN"); try { log("Navigating to login page..."); await page.goto(`${opts.webUrl}/login`, { waitUntil: "networkidle", timeout: opts.navigationTimeout, }); // Fill login form await page.fill('#email', opts.email); await page.fill('#password', opts.password); await sleep(500); // Submit await page.click('button[type="submit"]'); log("Login form submitted, waiting for redirect..."); // Wait for dashboard await page.waitForURL("**/dashboard**", { timeout: opts.navigationTimeout, }); log("Login successful - on dashboard"); } catch (err) { log(`LOGIN FAILED: ${err.message}`); // Try API login fallback log("Trying API login fallback..."); try { const loginResp = await page.request.post( `${opts.apiUrl}/auth/sign-in/email`, { data: { email: opts.email, password: opts.password }, }, ); if (loginResp.ok()) { log("API login successful, navigating to dashboard..."); await page.goto(`${opts.webUrl}/dashboard`, { waitUntil: "networkidle", }); } else { throw new Error(`API login failed: ${loginResp.status()}`); } } catch (err2) { log(`FATAL: Could not login: ${err2.message}`); await browser.close(); process.exit(1); } } // ── Test each VIN ───────────────────────────────────── for (let i = 0; i < vinList.length; i++) { const { brand, vin } = vinList[i]; const vinUpper = vin.toUpperCase(); const result = { index: i + 1, brand, vin: vinUpper, resolved: false, platform: null, vehicleInfo: null, categoriesLoaded: false, categoryCount: 0, categoryTests: [], dbChecks: {}, errors: [], timing: {}, }; logSection(`[${i + 1}/${vinList.length}] ${brand}: ${vinUpper}`); try { // ── Navigate to search page ─────────────────────── log("Navigating to search page..."); await page.goto(`${opts.webUrl}/dashboard/search`, { waitUntil: "networkidle", timeout: opts.navigationTimeout, }); await sleep(1000); // ── Enter VIN ───────────────────────────────────── log("Entering VIN..."); const vinInput = page.locator( 'input[placeholder*="VIN"], input.font-mono', ); await vinInput.fill(""); await sleep(300); await vinInput.fill(vinUpper); await sleep(500); // ── Click search button ─────────────────────────── const searchBtn = page.locator('button[type="submit"]'); const startTime = Date.now(); // Intercept API responses const decodeResponsePromise = page.waitForResponse( (resp) => resp.url().includes("/vehicles/decode") && resp.request().method() === "POST", { timeout: opts.navigationTimeout }, ); await searchBtn.click(); log("Search submitted, waiting for decode response..."); // Wait for decode response let decodeResp; try { decodeResp = await decodeResponsePromise; result.timing.decodeMs = Date.now() - startTime; log(`Decode response: ${decodeResp.status()} (${result.timing.decodeMs}ms)`); if (decodeResp.ok()) { const rawBody = await decodeResp.json(); // API wraps in { data: {...} } envelope const body = rawBody?.data || rawBody; result.resolved = true; result.vehicleInfo = { id: body.id, brandName: body.brandName, model: body.model, year: body.year, source: body.source, }; result.platform = body.source; log( `RESOLVED via ${body.source || "?"}: ${body.brandName || "?"} ${body.model || ""} ${body.year || ""} (id: ${body.id})`, ); } else { const errBody = await decodeResp.text(); result.errors.push(`Decode HTTP ${decodeResp.status()}: ${errBody.substring(0, 200)}`); log(`FAILED: HTTP ${decodeResp.status()}`); } } catch (err) { result.errors.push(`Decode timeout/error: ${err.message}`); log(`DECODE ERROR: ${err.message}`); } // ── If resolved, check vehicle page ─────────────── if (result.resolved && result.vehicleInfo?.id) { const vehicleId = result.vehicleInfo.id; // Wait for navigation to vehicle page try { await page.waitForURL(`**/vehicles/${vehicleId}**`, { timeout: 15000, }); log("On vehicle detail page"); } catch { // Might already be there or URL slightly different log("Navigating to vehicle page manually..."); await page.goto( `${opts.webUrl}/dashboard/vehicles/${vehicleId}`, { waitUntil: "networkidle", timeout: opts.navigationTimeout }, ); } // ── Wait for category tree to load ────────────── log("Waiting for categories to load..."); const catTreeStart = Date.now(); try { // Wait for category tree API response const catResp = await page.waitForResponse( (resp) => resp.url().includes(`/categories/tree/${vehicleId}`) && resp.request().method() === "GET", { timeout: opts.navigationTimeout }, ); result.timing.categoryTreeMs = Date.now() - catTreeStart; if (catResp.ok()) { const catRaw = await catResp.json(); const catTree = catRaw?.data || catRaw; result.categoriesLoaded = true; result.categoryCount = Array.isArray(catTree) ? catTree.length : 0; log( `Categories loaded: ${result.categoryCount} top-level (${result.timing.categoryTreeMs}ms)`, ); } else { result.errors.push( `Category tree HTTP ${catResp.status()}`, ); log(`Category tree failed: ${catResp.status()}`); } } catch (err) { // Maybe already loaded from cache log(`Category tree wait: ${err.message} - checking page content...`); await sleep(3000); } // ── Check categories are visible on page ──────── await sleep(2000); // Look for category elements (cards or tree nodes) const categoryElements = await page .locator('[class*="cursor-pointer"], a[href*="/categories/"]') .count(); if (categoryElements > 0) { result.categoriesLoaded = true; log(`Found ${categoryElements} clickable category elements on page`); } // ── Click first categories and test parts/schema ─ if (result.categoriesLoaded && result.categoryCount > 0) { const categoriesToTest = Math.min( opts.maxCategoriesToTest, result.categoryCount, ); for (let ci = 0; ci < categoriesToTest; ci++) { const catTest = { index: ci, name: null, clicked: false, partsLoaded: false, partsCount: 0, schemaLoaded: false, schemaImageUrl: null, minioCheck: null, dbParts: [], dbSchema: [], errors: [], }; try { // Navigate back to vehicle page for each category test if (ci > 0) { await page.goto( `${opts.webUrl}/dashboard/vehicles/${vehicleId}`, { waitUntil: "networkidle", timeout: opts.navigationTimeout, }, ); await sleep(2000); } // Click the category card (first available link to categories) const catLinks = page.locator( 'a[href*="/categories/"]', ); const catLinkCount = await catLinks.count(); if (catLinkCount > ci) { const catLink = catLinks.nth(ci); catTest.name = (await catLink.textContent())?.trim() || `Category ${ci}`; log( ` Clicking category ${ci + 1}: "${catTest.name}"...`, ); // Listen for parts API response const partsResponsePromise = page.waitForResponse( (resp) => resp.url().includes("/categories/") && resp.url().includes("/vehicles/") && resp.request().method() === "GET", { timeout: opts.navigationTimeout }, ); await catLink.click(); catTest.clicked = true; // Wait for parts response try { const partsResp = await partsResponsePromise; if (partsResp.ok()) { const partsRaw = await partsResp.json(); const partsData = partsRaw?.data || partsRaw; catTest.partsLoaded = true; catTest.partsCount = partsData?.parts?.length || 0; if ( partsData?.schemaPics && partsData.schemaPics.length > 0 ) { catTest.schemaLoaded = true; catTest.schemaImageUrl = partsData.schemaPics[0]?.imageUrl || null; } log( ` Parts: ${catTest.partsCount}, Schema: ${catTest.schemaLoaded ? "YES" : "NO"}`, ); // ── Check MinIO image ─────────────── if (catTest.schemaImageUrl) { catTest.minioCheck = await checkMinioImage( catTest.schemaImageUrl, ); log( ` MinIO image: ${catTest.minioCheck.exists ? "OK" : "MISSING"} (${catTest.schemaImageUrl})`, ); } } else { catTest.errors.push( `Parts HTTP ${partsResp.status()}`, ); } } catch (err) { catTest.errors.push( `Parts timeout: ${err.message}`, ); log(` Parts error: ${err.message}`); } } else { // No direct category links - try clicking grid cards const gridCards = page.locator( ".grid button, .grid [role='button']", ); const cardCount = await gridCards.count(); if (cardCount > ci) { const card = gridCards.nth(ci); catTest.name = (await card.textContent())?.trim()?.substring(0, 50) || `Card ${ci}`; log(` Clicking grid card ${ci + 1}: "${catTest.name}"...`); await card.click(); catTest.clicked = true; await sleep(opts.categoryClickDelay); // After clicking a parent, look for leaf links const leafLinks = page.locator( 'a[href*="/categories/"]', ); const leafCount = await leafLinks.count(); if (leafCount > 0) { log( ` Drilled down - found ${leafCount} sub-categories`, ); const firstLeaf = leafLinks.first(); catTest.name += " > " + ((await firstLeaf.textContent())?.trim() || "sub"); const subPartsPromise = page.waitForResponse( (resp) => resp.url().includes("/categories/") && resp.url().includes("/vehicles/") && resp.request().method() === "GET", { timeout: opts.navigationTimeout }, ); await firstLeaf.click(); try { const subResp = await subPartsPromise; if (subResp.ok()) { const subRaw = await subResp.json(); const subData = subRaw?.data || subRaw; catTest.partsLoaded = true; catTest.partsCount = subData?.parts?.length || 0; if ( subData?.schemaPics?.length > 0 ) { catTest.schemaLoaded = true; catTest.schemaImageUrl = subData.schemaPics[0]?.imageUrl; } log( ` Sub-category parts: ${catTest.partsCount}, Schema: ${catTest.schemaLoaded}`, ); } } catch (err) { catTest.errors.push( `Sub parts timeout: ${err.message}`, ); } } } else { catTest.errors.push("No clickable categories found"); log(" No clickable categories found on page"); } } } catch (err) { catTest.errors.push(`Category test error: ${err.message}`); log(` Category test error: ${err.message}`); } result.categoryTests.push(catTest); await sleep(opts.categoryClickDelay); } } // ── DB verification ───────────────────────────── if (db.available) { log("Running DB checks..."); try { const dbVehicle = await db.getVehicle(vinUpper); if (dbVehicle) { result.dbChecks.vehicle = { found: true, id: dbVehicle.id, brandName: dbVehicle.brand_name, model: dbVehicle.model, year: dbVehicle.year, source: dbVehicle.source, hasRawData: dbVehicle.has_raw_data, }; const catCount = await db.getCategoryCount(dbVehicle.id); result.dbChecks.categories = { count: catCount, }; const topCats = await db.getTopCategories(dbVehicle.id); result.dbChecks.topCategories = topCats.map((c) => ({ id: c.id, name: c.name, source: c.source, hasLink: c.has_link, })); const partsCount = await db.getPartsCount(dbVehicle.id); result.dbChecks.partsCount = partsCount; // Check leaf categories for schema pics const leaves = await db.getLeafCategories( dbVehicle.id, 3, ); result.dbChecks.leafCategorySchemas = []; for (const leaf of leaves) { const schemas = await db.getSchemaPics(leaf.id); const parts = await db.getPartsForCategory(leaf.id); result.dbChecks.leafCategorySchemas.push({ categoryId: leaf.id, name: leaf.name, source: leaf.source, schemaCount: schemas.length, partsCount: parts.length, schemaUrls: schemas.map((s) => s.image_url), hasHotspots: schemas.some((s) => s.has_hotspots), }); } log( ` DB: vehicle=${result.dbChecks.vehicle.found}, categories=${catCount}, parts=${partsCount}`, ); } else { result.dbChecks.vehicle = { found: false }; log(" DB: vehicle NOT FOUND"); } } catch (err) { result.dbChecks.error = err.message; log(` DB check error: ${err.message}`); } } } } catch (err) { result.errors.push(`VIN test error: ${err.message}`); log(`ERROR: ${err.message}`); } // Update summary report.results.push(result); report.summary.total++; if (result.resolved) report.summary.resolved++; else if (result.errors.length > 0) report.summary.errors++; else report.summary.failed++; // ── Delay before next VIN ─────────────────────────── if (i < vinList.length - 1) { log(`Waiting ${opts.delayBetweenVins}ms before next VIN...`); await sleep(opts.delayBetweenVins); } } // ── Generate report ─────────────────────────────────── report.finishedAt = new Date().toISOString(); logSection("REPORT SUMMARY"); console.log(`Total: ${report.summary.total}`); console.log(`Resolved: ${report.summary.resolved}`); console.log(`Failed: ${report.summary.failed}`); console.log(`Errors: ${report.summary.errors}`); console.log("\nPer-VIN Results:"); console.log( "─".repeat(90), ); console.log( `${"Brand".padEnd(15)} ${"VIN".padEnd(20)} ${"Status".padEnd(10)} ${"Platform".padEnd(8)} ${"Cats".padEnd(6)} ${"Parts".padEnd(6)} ${"Schema".padEnd(8)} DB`, ); console.log( "─".repeat(90), ); for (const r of report.results) { const status = r.resolved ? "OK" : "FAIL"; const platform = r.platform || "-"; const cats = r.categoryCount || 0; const partsTotal = r.categoryTests.reduce( (s, t) => s + t.partsCount, 0, ); const schemaOk = r.categoryTests.some((t) => t.schemaLoaded) ? "YES" : "NO"; const dbOk = r.dbChecks?.vehicle?.found ? "OK" : "NO"; console.log( `${r.brand.padEnd(15)} ${r.vin.padEnd(20)} ${status.padEnd(10)} ${platform.padEnd(8)} ${String(cats).padEnd(6)} ${String(partsTotal).padEnd(6)} ${schemaOk.padEnd(8)} ${dbOk}`, ); if (r.errors.length > 0) { for (const err of r.errors) { console.log(` └─ ERROR: ${err}`); } } for (const ct of r.categoryTests) { if (ct.errors.length > 0) { for (const err of ct.errors) { console.log(` └─ CAT "${ct.name}": ${err}`); } } if (ct.minioCheck && !ct.minioCheck.exists) { console.log( ` └─ MINIO MISSING: ${ct.schemaImageUrl} (${ct.minioCheck.reason || ct.minioCheck.status})`, ); } } } // ── Platform breakdown ──────────────────────────────── console.log(`\n${"─".repeat(50)}`); console.log("Platform Breakdown:"); const platformCounts = {}; for (const r of report.results) { const p = r.platform || "unresolved"; platformCounts[p] = (platformCounts[p] || 0) + 1; } for (const [p, count] of Object.entries(platformCounts)) { console.log(` ${p}: ${count}`); } // ── DB Summary ──────────────────────────────────────── console.log(`\n${"─".repeat(50)}`); console.log("DB Storage Summary:"); let totalDbCats = 0; let totalDbParts = 0; let totalDbSchemas = 0; for (const r of report.results) { if (r.dbChecks?.categories) totalDbCats += r.dbChecks.categories.count; if (r.dbChecks?.partsCount) totalDbParts += r.dbChecks.partsCount; if (r.dbChecks?.leafCategorySchemas) { totalDbSchemas += r.dbChecks.leafCategorySchemas.reduce( (s, l) => s + l.schemaCount, 0, ); } } console.log(` Total categories in DB: ${totalDbCats}`); console.log(` Total parts in DB: ${totalDbParts}`); console.log(` Total schema pics in DB: ${totalDbSchemas}`); // ── MinIO Summary ───────────────────────────────────── console.log(`\n${"─".repeat(50)}`); console.log("MinIO Storage Summary:"); let minioOk = 0; let minioFail = 0; for (const r of report.results) { for (const ct of r.categoryTests) { if (ct.minioCheck) { if (ct.minioCheck.exists) minioOk++; else minioFail++; } } } console.log(` Images accessible: ${minioOk}`); console.log(` Images missing: ${minioFail}`); // ── Save report ─────────────────────────────────────── const reportPath = path.join( __dirname, `vin-e2e-report-${Date.now()}.json`, ); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2)); log(`\nFull report saved to: ${reportPath}`); // Also save a human-readable markdown report const mdPath = path.join( __dirname, `vin-e2e-report-${Date.now()}.md`, ); let md = `# VIN E2E Test Report\n\n`; md += `**Date:** ${report.startedAt}\n`; md += `**Total:** ${report.summary.total} | **Resolved:** ${report.summary.resolved} | **Failed:** ${report.summary.failed} | **Errors:** ${report.summary.errors}\n\n`; md += `## Results\n\n`; md += `| # | Brand | VIN | Status | Platform | Categories | Parts | Schema | DB | MinIO |\n`; md += `|---|-------|-----|--------|----------|------------|-------|--------|----|-------|\n`; for (const r of report.results) { const status = r.resolved ? "OK" : "FAIL"; const platform = r.platform || "-"; const cats = r.categoryCount || 0; const partsTotal = r.categoryTests.reduce( (s, t) => s + t.partsCount, 0, ); const schemaOk = r.categoryTests.some((t) => t.schemaLoaded) ? "YES" : "NO"; const dbOk = r.dbChecks?.vehicle?.found ? "OK" : "-"; const minioStatus = r.categoryTests.some( (t) => t.minioCheck?.exists, ) ? "OK" : r.categoryTests.some((t) => t.minioCheck) ? "FAIL" : "-"; md += `| ${r.index} | ${r.brand} | \`${r.vin}\` | ${status} | ${platform} | ${cats} | ${partsTotal} | ${schemaOk} | ${dbOk} | ${minioStatus} |\n`; } md += `\n## Errors\n\n`; for (const r of report.results) { if (r.errors.length > 0) { md += `### ${r.brand} (\`${r.vin}\`)\n`; for (const err of r.errors) { md += `- ${err}\n`; } md += `\n`; } } md += `## Platform Breakdown\n\n`; for (const [p, count] of Object.entries(platformCounts)) { md += `- **${p}**: ${count}\n`; } md += `\n## DB Storage\n\n`; md += `- Categories: ${totalDbCats}\n`; md += `- Parts: ${totalDbParts}\n`; md += `- Schema images: ${totalDbSchemas}\n`; fs.writeFileSync(mdPath, md); log(`Markdown report saved to: ${mdPath}`); // ── Cleanup ─────────────────────────────────────────── await db.close().catch(() => {}); await browser.close(); log("Done!"); process.exit(report.summary.errors > 0 ? 1 : 0); } main().catch((err) => { console.error("FATAL:", err); process.exit(1); });