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:
921
scripts/vin-e2e-test.js
Normal file
921
scripts/vin-e2e-test.js
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user