feat: Ford legacy support, EMEX browser pooling, collapsible sidebar

- Add PL24 Ford legacy service for fordt_parts architecture
- Refactor EMEX to use persistent browser pool instead of per-call instances
- Make vehicle decode resilient: fallback to PL24 when Corgi doesn't recognize VIN
- Add collapsible sidebar with persistent user preference
- Improve brand access guard and categories service
- Add debug/test scripts for VIN e2e testing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sase Dev
2026-02-14 22:25:06 +00:00
parent 7b024df4d5
commit 4a06ba5fdb
53 changed files with 17053 additions and 682 deletions

921
scripts/vin-e2e-test.js Normal file
View File

@@ -0,0 +1,921 @@
#!/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);
});