- 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>
922 lines
33 KiB
JavaScript
922 lines
33 KiB
JavaScript
#!/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);
|
|
});
|