Some checks failed
Sync dev → Gitea / Mirror dev to Gitea (push) Has been cancelled
- Added a comment line to main.ts for deployment verification purposes
550 lines
22 KiB
TypeScript
550 lines
22 KiB
TypeScript
/**
|
||
* test-vin.ts — Playwright VIN tester using grid UI navigation.
|
||
*
|
||
* Strategy:
|
||
* 1. Login → decode VIN → vehicle page
|
||
* 2. Grid modunda kategorileri göster
|
||
* 3. Her kart:
|
||
* - Branch (button) → tıkla, drill-down, recursive test, breadcrumb back
|
||
* - Leaf (a link) → API ile parts endpoint'ini test et
|
||
* 4. Index-based kart seçimi (multiline isim sorunu yok)
|
||
*
|
||
* Rate limiting: sase.tr 100 req/60s. Grid UI kendi fetch'lerini yapar,
|
||
* biz sadece leaf test API call'ları yapıyoruz → çok daha az istek.
|
||
*
|
||
* Usage: npx tsx test-vin.ts <VIN>
|
||
*/
|
||
|
||
import { chromium, type Browser, type BrowserContext, type Page } from "playwright";
|
||
|
||
// ─── TYPES ──────────────────────────────────────────────────────────────────
|
||
|
||
export interface CategoryTestResult {
|
||
categoryId: string;
|
||
categoryName: string;
|
||
path: string[];
|
||
status: "ok" | "error" | "empty";
|
||
errorDetail?: string;
|
||
httpCode?: number;
|
||
endpoint?: string;
|
||
}
|
||
|
||
export interface VinTestResult {
|
||
vin: string;
|
||
status: "success" | "decode_failed" | "no_categories" | "error" | "partial";
|
||
vehicleId?: string;
|
||
brandName?: string;
|
||
totalCategories: number;
|
||
testedCategories: number;
|
||
successCategories: number;
|
||
errorCategories: number;
|
||
errors: CategoryTestResult[];
|
||
duration: number;
|
||
message?: string;
|
||
}
|
||
|
||
// ─── CONFIG ─────────────────────────────────────────────────────────────────
|
||
|
||
const BASE_URL = "http://localhost:3000";
|
||
const LOGIN_EMAIL = "admin@sase.tr";
|
||
const LOGIN_PASSWORD = "Sase2026";
|
||
const VIN_DECODE_TIMEOUT = 120_000;
|
||
const PAGE_LOAD_TIMEOUT = 30_000;
|
||
|
||
// ─── RATE LIMITER ───────────────────────────────────────────────────────────
|
||
|
||
const RATE_WINDOW = 60_000;
|
||
const RATE_MAX = 85;
|
||
const timestamps: number[] = [];
|
||
|
||
async function rateWait(): Promise<void> {
|
||
const now = Date.now();
|
||
while (timestamps.length > 0 && timestamps[0] < now - RATE_WINDOW) timestamps.shift();
|
||
if (timestamps.length >= RATE_MAX) {
|
||
const wait = timestamps[0] + RATE_WINDOW - Date.now() + 200;
|
||
if (wait > 0) await sleep(wait);
|
||
while (timestamps.length > 0 && timestamps[0] < Date.now() - RATE_WINDOW) timestamps.shift();
|
||
}
|
||
timestamps.push(Date.now());
|
||
}
|
||
|
||
// ─── SESSION ────────────────────────────────────────────────────────────────
|
||
|
||
let sharedCtx: BrowserContext | null = null;
|
||
let sharedBrowser: Browser | null = null;
|
||
|
||
export async function getOrCreateSession(): Promise<{ browser: Browser; context: BrowserContext }> {
|
||
if (sharedBrowser && sharedCtx) {
|
||
try {
|
||
const p = await sharedCtx.newPage();
|
||
await p.goto(`${BASE_URL}/dashboard/search`, { waitUntil: "domcontentloaded", timeout: 10_000 });
|
||
await p.waitForTimeout(2000);
|
||
const ok = p.url().includes("/dashboard/search");
|
||
await p.close();
|
||
if (ok) return { browser: sharedBrowser, context: sharedCtx };
|
||
} catch {}
|
||
}
|
||
if (sharedBrowser) try { await sharedBrowser.close(); } catch {}
|
||
|
||
const browser = await chromium.launch({ headless: true });
|
||
const context = await browser.newContext({ baseURL: BASE_URL, viewport: { width: 1280, height: 900 } });
|
||
const page = await context.newPage();
|
||
await loginViaUI(page);
|
||
await page.close();
|
||
sharedBrowser = browser;
|
||
sharedCtx = context;
|
||
return { browser, context };
|
||
}
|
||
|
||
async function loginViaUI(page: Page): Promise<void> {
|
||
await page.goto(`${BASE_URL}/login`, { waitUntil: "domcontentloaded", timeout: 15_000 });
|
||
await page.waitForTimeout(1000);
|
||
await page.fill('#email', LOGIN_EMAIL);
|
||
await page.fill('#password', LOGIN_PASSWORD);
|
||
await page.click('button[type="submit"]');
|
||
await page.waitForURL("**/dashboard/**", { timeout: 15_000 });
|
||
console.log(" Login OK");
|
||
}
|
||
|
||
export async function closeSession(): Promise<void> {
|
||
if (sharedBrowser) try { await sharedBrowser.close(); } catch {}
|
||
sharedBrowser = null;
|
||
sharedCtx = null;
|
||
}
|
||
|
||
function sleep(ms: number): Promise<void> { return new Promise((r) => setTimeout(r, ms)); }
|
||
|
||
// ─── GRID HELPERS ───────────────────────────────────────────────────────────
|
||
|
||
/** Wait for grid to be loaded (no spinner, cards visible) */
|
||
async function waitForGrid(page: Page, timeout = PAGE_LOAD_TIMEOUT): Promise<void> {
|
||
try {
|
||
await page.waitForFunction(
|
||
() => !document.querySelector('.animate-spin'),
|
||
{ timeout },
|
||
);
|
||
} catch {}
|
||
await page.waitForTimeout(600);
|
||
}
|
||
|
||
/** Wait for drill-down: the grid content should change after clicking a branch */
|
||
async function waitForDrillDown(page: Page, prevCardNames: string[], timeout = 15_000): Promise<boolean> {
|
||
try {
|
||
await page.waitForFunction(
|
||
(prevNames) => {
|
||
const noSpinner = !document.querySelector('.animate-spin');
|
||
if (!noSpinner) return false;
|
||
// Check if breadcrumb appeared (means we drilled down)
|
||
const breadcrumb = document.querySelector('.flex.items-center.gap-1\\.5.text-sm');
|
||
if (breadcrumb) {
|
||
// Grid cards changed or breadcrumb appeared
|
||
const grid = document.querySelector('.grid.gap-3.lg\\:grid-cols-3');
|
||
if (!grid) return false;
|
||
const names = Array.from(grid.children).map(el => {
|
||
const n = el.querySelector('.font-semibold, .font-medium');
|
||
return n?.textContent?.trim() || '';
|
||
});
|
||
// Cards should be different from previous level
|
||
return JSON.stringify(names) !== JSON.stringify(prevNames);
|
||
}
|
||
return false;
|
||
},
|
||
prevCardNames,
|
||
{ timeout },
|
||
);
|
||
await page.waitForTimeout(300);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/** Check if breadcrumb is visible (we're in a drilled-down state) */
|
||
async function hasBreadcrumb(page: Page): Promise<boolean> {
|
||
return page.locator('.flex.items-center.gap-1\\.5.text-sm').isVisible({ timeout: 500 }).catch(() => false);
|
||
}
|
||
|
||
interface CardInfo {
|
||
index: number;
|
||
name: string;
|
||
isLeaf: boolean; // <a> = leaf, <button> = branch
|
||
categoryId: string; // extracted from href for leaves
|
||
}
|
||
|
||
/** Get all category cards currently visible in the grid */
|
||
async function getGridCards(page: Page): Promise<CardInfo[]> {
|
||
return page.evaluate(() => {
|
||
const grid = document.querySelector('.grid.gap-3.lg\\:grid-cols-3');
|
||
if (!grid) return [];
|
||
const items = Array.from(grid.children) as HTMLElement[];
|
||
return items.map((el, i) => {
|
||
const isLeaf = el.tagName.toLowerCase() === 'a';
|
||
const nameEl = el.querySelector('.font-semibold, .font-medium');
|
||
const name = nameEl?.textContent?.trim() || `Card ${i}`;
|
||
let categoryId = '';
|
||
if (isLeaf) {
|
||
const href = el.getAttribute('href') || '';
|
||
const match = href.match(/categories\/([^/]+)/);
|
||
if (match) categoryId = match[1];
|
||
}
|
||
return { index: i, name, isLeaf, categoryId };
|
||
});
|
||
});
|
||
}
|
||
|
||
/** Click a card by index in the grid */
|
||
async function clickCard(page: Page, index: number): Promise<boolean> {
|
||
const card = page.locator('.grid.gap-3.lg\\:grid-cols-3 > *').nth(index);
|
||
try {
|
||
await card.click({ timeout: 5000 });
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/** Go back one level via the breadcrumb back arrow */
|
||
async function gridGoBack(page: Page): Promise<void> {
|
||
// ArrowLeft button in breadcrumb area: first button inside .flex.items-center.gap-1\.5
|
||
const backBtn = page.locator('.flex.items-center.gap-1\\.5.text-sm button').first();
|
||
if (await backBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||
await backBtn.click();
|
||
await page.waitForTimeout(400);
|
||
return;
|
||
}
|
||
// Fallback: "Kategoriler" text button (goes to root)
|
||
const rootBtn = page.locator('button:has-text("Kategoriler")');
|
||
if (await rootBtn.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||
await rootBtn.click();
|
||
await page.waitForTimeout(400);
|
||
}
|
||
}
|
||
|
||
// ─── RECURSIVE GRID TESTER ─────────────────────────────────────────────────
|
||
|
||
let leafCounter = 0;
|
||
|
||
async function testGridLevel(
|
||
page: Page,
|
||
vehicleId: string,
|
||
pathSoFar: string[],
|
||
results: CategoryTestResult[],
|
||
depth: number = 0,
|
||
): Promise<void> {
|
||
if (depth > 10) return;
|
||
|
||
await waitForGrid(page);
|
||
const initialCards = await getGridCards(page);
|
||
if (initialCards.length === 0) return;
|
||
|
||
// Track processed cards by name to survive grid re-reads
|
||
const processed = new Set<string>();
|
||
|
||
// First pass: test all leaves (no navigation needed, just API calls)
|
||
for (const card of initialCards) {
|
||
if (card.isLeaf) {
|
||
processed.add(card.name);
|
||
leafCounter++;
|
||
const currentPath = [...pathSoFar, card.name];
|
||
const result = await testLeafApi(page, vehicleId, card.categoryId, card.name, currentPath);
|
||
|
||
if (result.httpCode === 429) {
|
||
console.log(` [${leafCounter}] Rate limited, waiting 60s...`);
|
||
await sleep(60_000);
|
||
const retry = await testLeafApi(page, vehicleId, card.categoryId, card.name, currentPath);
|
||
results.push(retry);
|
||
if (retry.status === "error") console.log(` [${leafCounter}] ERROR: ${card.name} | ${retry.errorDetail}`);
|
||
} else {
|
||
results.push(result);
|
||
if (result.status === "error") {
|
||
console.log(` [${leafCounter}] ERROR: ${card.name} | ${result.errorDetail}`);
|
||
} else if (leafCounter % 50 === 0) {
|
||
console.log(` [${leafCounter}] OK...`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Second pass: drill into each branch
|
||
// Re-read cards fresh before each branch click (grid state may have changed)
|
||
const branchNames = initialCards.filter(c => !c.isLeaf).map(c => c.name);
|
||
|
||
for (const branchName of branchNames) {
|
||
if (processed.has(branchName)) continue;
|
||
processed.add(branchName);
|
||
|
||
// Re-read grid to find the branch by name (index may have shifted)
|
||
const freshCards = await getGridCards(page);
|
||
const branchIdx = freshCards.findIndex(c => c.name === branchName && !c.isLeaf);
|
||
if (branchIdx === -1) {
|
||
console.log(` ${" ".repeat(depth)}> ${branchName} (not found in grid, skipping)`);
|
||
continue;
|
||
}
|
||
|
||
const currentPath = [...pathSoFar, branchName];
|
||
const prevNames = freshCards.map(c => c.name);
|
||
console.log(` ${" ".repeat(depth)}> ${branchName}`);
|
||
|
||
const clicked = await clickCard(page, branchIdx);
|
||
if (!clicked) {
|
||
// Retry: wait a bit and try again
|
||
await page.waitForTimeout(1000);
|
||
const retryCards = await getGridCards(page);
|
||
const retryIdx = retryCards.findIndex(c => c.name === branchName && !c.isLeaf);
|
||
if (retryIdx === -1 || !(await clickCard(page, retryIdx))) {
|
||
console.log(` ${" ".repeat(depth)} (click failed, skipping)`);
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// Wait for drill-down
|
||
const drilled = await waitForDrillDown(page, prevNames);
|
||
if (!drilled) {
|
||
await waitForGrid(page);
|
||
const afterCards = await getGridCards(page);
|
||
const afterNames = afterCards.map(c => c.name);
|
||
if (JSON.stringify(afterNames) === JSON.stringify(prevNames)) {
|
||
console.log(` ${" ".repeat(depth)} (drill-down failed, skipping)`);
|
||
continue;
|
||
}
|
||
}
|
||
|
||
await waitForGrid(page);
|
||
const subCards = await getGridCards(page);
|
||
console.log(` ${" ".repeat(depth)} -> ${subCards.length} children`);
|
||
|
||
if (subCards.length > 0) {
|
||
await testGridLevel(page, vehicleId, currentPath, results, depth + 1);
|
||
}
|
||
|
||
// Go back to current level
|
||
const restored = await navigateBackToLevel(page, prevNames, depth);
|
||
if (!restored) {
|
||
console.log(` ${" ".repeat(depth)} (nav lost, resetting...)`);
|
||
// Hard reset: reload page and re-navigate to current path
|
||
await resetToRoot(page, vehicleId);
|
||
if (pathSoFar.length > 0) {
|
||
const renavOk = await navigateToPath(page, pathSoFar);
|
||
if (!renavOk) {
|
||
console.log(` ${" ".repeat(depth)} (re-nav failed, stopping this branch)`);
|
||
return;
|
||
}
|
||
}
|
||
// Now we should be at the correct level, continue to next branch
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Navigate back and verify we're at the correct level */
|
||
async function navigateBackToLevel(page: Page, expectedNames: string[], depth: number): Promise<boolean> {
|
||
await gridGoBack(page);
|
||
await page.waitForTimeout(400);
|
||
await waitForGrid(page);
|
||
|
||
let currentCards = await getGridCards(page);
|
||
if (currentCards.map(c => c.name).join('|') === expectedNames.join('|')) return true;
|
||
|
||
// Try a few more back clicks
|
||
for (let i = 0; i < 3; i++) {
|
||
const hasBc = await page.locator('.flex.items-center.gap-1\\.5.text-sm').isVisible({ timeout: 500 }).catch(() => false);
|
||
if (!hasBc && depth === 0) {
|
||
// At root, check if cards match
|
||
currentCards = await getGridCards(page);
|
||
if (currentCards.map(c => c.name).join('|') === expectedNames.join('|')) return true;
|
||
break;
|
||
}
|
||
await gridGoBack(page);
|
||
await page.waitForTimeout(400);
|
||
await waitForGrid(page);
|
||
currentCards = await getGridCards(page);
|
||
if (currentCards.map(c => c.name).join('|') === expectedNames.join('|')) return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/** Reset grid to root level — hard reload as fallback */
|
||
async function resetToRoot(page: Page, vehicleId?: string): Promise<void> {
|
||
// Try breadcrumb "Kategoriler" button first
|
||
const rootBtn = page.locator('button:has-text("Kategoriler")');
|
||
if (await rootBtn.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||
await rootBtn.click();
|
||
await page.waitForTimeout(500);
|
||
await waitForGrid(page);
|
||
// Verify we're at root (no breadcrumb visible)
|
||
const hasBc = await page.locator('.flex.items-center.gap-1\\.5.text-sm').isVisible({ timeout: 500 }).catch(() => false);
|
||
if (!hasBc) return;
|
||
}
|
||
// Hard fallback: reload the vehicle page
|
||
if (vehicleId) {
|
||
await page.goto(`${BASE_URL}/dashboard/vehicles/${vehicleId}`, { waitUntil: "domcontentloaded", timeout: PAGE_LOAD_TIMEOUT });
|
||
await page.waitForSelector('.grid.gap-3.lg\\:grid-cols-3', { timeout: PAGE_LOAD_TIMEOUT }).catch(() => {});
|
||
await waitForGrid(page);
|
||
}
|
||
}
|
||
|
||
/** Navigate from root to a specific path by clicking branches one by one */
|
||
async function navigateToPath(page: Page, pathSoFar: string[]): Promise<boolean> {
|
||
for (const name of pathSoFar) {
|
||
await waitForGrid(page);
|
||
const cards = await getGridCards(page);
|
||
const idx = cards.findIndex(c => c.name === name && !c.isLeaf);
|
||
if (idx === -1) return false;
|
||
const prevNames = cards.map(c => c.name);
|
||
const clicked = await clickCard(page, idx);
|
||
if (!clicked) return false;
|
||
const drilled = await waitForDrillDown(page, prevNames);
|
||
if (!drilled) {
|
||
// Check if cards actually changed
|
||
const afterCards = await getGridCards(page);
|
||
if (JSON.stringify(afterCards.map(c => c.name)) === JSON.stringify(prevNames)) return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/** Test a leaf category's parts endpoint via API */
|
||
async function testLeafApi(
|
||
page: Page,
|
||
vehicleId: string,
|
||
categoryId: string,
|
||
name: string,
|
||
path: string[],
|
||
): Promise<CategoryTestResult> {
|
||
const endpoint = `/api/vehicles/${vehicleId}/categories/${categoryId}`;
|
||
try {
|
||
await rateWait();
|
||
const resp = await page.request.get(`${BASE_URL}${endpoint}`, { timeout: 120_000 });
|
||
const status = resp.status();
|
||
|
||
if (status >= 400) {
|
||
return { categoryId, categoryName: name, path, status: "error", errorDetail: `HTTP ${status} ${resp.statusText()}`, httpCode: status, endpoint };
|
||
}
|
||
|
||
let data: any;
|
||
try {
|
||
data = await resp.json();
|
||
if (data.data) data = data.data;
|
||
} catch {
|
||
return { categoryId, categoryName: name, path, status: "error", errorDetail: "JSON parse failed", httpCode: status, endpoint };
|
||
}
|
||
|
||
return { categoryId, categoryName: name, path, status: "ok" };
|
||
} catch (err: any) {
|
||
return { categoryId, categoryName: name, path, status: "error", errorDetail: `Request failed: ${err.message}`, endpoint };
|
||
}
|
||
}
|
||
|
||
// ─── MAIN ───────────────────────────────────────────────────────────────────
|
||
|
||
export async function testVin(vin: string): Promise<VinTestResult> {
|
||
const startTime = Date.now();
|
||
const results: CategoryTestResult[] = [];
|
||
leafCounter = 0;
|
||
let page: Page | null = null;
|
||
|
||
try {
|
||
const { context } = await getOrCreateSession();
|
||
page = await context.newPage();
|
||
|
||
// ── Step 1: Search page ─────────────────────────────────────────────
|
||
await page.goto(`${BASE_URL}/dashboard/search`, { waitUntil: "domcontentloaded", timeout: PAGE_LOAD_TIMEOUT });
|
||
if (!page.url().includes("/dashboard/search")) {
|
||
await loginViaUI(page);
|
||
await page.goto(`${BASE_URL}/dashboard/search`, { waitUntil: "domcontentloaded", timeout: PAGE_LOAD_TIMEOUT });
|
||
}
|
||
|
||
// ── Step 2: Enter VIN ───────────────────────────────────────────────
|
||
const vinInput = page.locator('input[placeholder*="17 karakter"]');
|
||
await vinInput.waitFor({ state: "visible", timeout: 10_000 });
|
||
await vinInput.fill(vin);
|
||
await page.waitForTimeout(500);
|
||
await page.locator('button[type="submit"]:has-text("Çöz")').click();
|
||
|
||
// ── Step 3: Wait for vehicle page ───────────────────────────────────
|
||
try {
|
||
await page.waitForURL("**/dashboard/vehicles/**", { timeout: VIN_DECODE_TIMEOUT });
|
||
} catch {
|
||
const errEl = page.locator(".text-destructive");
|
||
if (await errEl.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||
const msg = await errEl.first().textContent().catch(() => "Unknown");
|
||
return mkResult(vin, "decode_failed", startTime, { message: `Decode failed: ${msg}` });
|
||
}
|
||
return mkResult(vin, "decode_failed", startTime, { message: "Decode timeout" });
|
||
}
|
||
|
||
const vehicleId = page.url().match(/\/vehicles\/([^/]+)/)?.[1] || "";
|
||
const brandName = (await page.locator("h2").first().textContent().catch(() => ""))?.split(" ")[0] || "";
|
||
|
||
// ── Step 4: Wait for categories grid ────────────────────────────────
|
||
// Wait for skeleton to disappear and grid to appear
|
||
try {
|
||
await page.waitForSelector('.grid.gap-3.lg\\:grid-cols-3', { timeout: PAGE_LOAD_TIMEOUT });
|
||
} catch {
|
||
// Check "Kategori bulunamadi"
|
||
const nocat = page.locator('text="Kategori bulunamadi."');
|
||
if (await nocat.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||
return mkResult(vin, "no_categories", startTime, { vehicleId, brandName, message: "Kategori bulunamadi" });
|
||
}
|
||
return mkResult(vin, "no_categories", startTime, { vehicleId, brandName, message: "Grid did not load" });
|
||
}
|
||
|
||
await waitForGrid(page);
|
||
const rootCards = await getGridCards(page);
|
||
console.log(` Vehicle: ${brandName} | ${rootCards.length} root categories`);
|
||
|
||
if (rootCards.length === 0) {
|
||
return mkResult(vin, "no_categories", startTime, { vehicleId, brandName, message: "No root categories" });
|
||
}
|
||
|
||
// ── Step 5: Recursively test all categories via grid ────────────────
|
||
await testGridLevel(page, vehicleId, [], results, 0);
|
||
|
||
console.log(` Done: ${leafCounter} leaves tested`);
|
||
|
||
const errs = results.filter((r) => r.status === "error");
|
||
return {
|
||
vin,
|
||
status: errs.length > 0 ? "partial" : "success",
|
||
vehicleId,
|
||
brandName,
|
||
totalCategories: leafCounter,
|
||
testedCategories: results.length,
|
||
successCategories: results.filter((r) => r.status === "ok").length,
|
||
errorCategories: errs.length,
|
||
errors: errs,
|
||
duration: Date.now() - startTime,
|
||
};
|
||
} catch (err: any) {
|
||
return {
|
||
vin,
|
||
status: "error",
|
||
totalCategories: 0,
|
||
testedCategories: results.length,
|
||
successCategories: results.filter((r) => r.status === "ok").length,
|
||
errorCategories: results.filter((r) => r.status === "error").length,
|
||
errors: results.filter((r) => r.status === "error"),
|
||
duration: Date.now() - startTime,
|
||
message: `Unexpected: ${err.message}`,
|
||
};
|
||
} finally {
|
||
if (page) try { await page.close(); } catch {}
|
||
}
|
||
}
|
||
|
||
function mkResult(vin: string, status: VinTestResult["status"], start: number, o: Partial<VinTestResult> = {}): VinTestResult {
|
||
return { vin, status, totalCategories: 0, testedCategories: 0, successCategories: 0, errorCategories: 0, errors: [], duration: Date.now() - start, ...o };
|
||
}
|
||
|
||
// ─── CLI ─────────────────────────────────────────────────────────────────────
|
||
|
||
if (process.argv[1]?.endsWith("test-vin.ts")) {
|
||
const vin = process.argv[2];
|
||
if (!vin) { console.error("Usage: npx tsx test-vin.ts <VIN>"); process.exit(1); }
|
||
console.log(`Testing VIN: ${vin}`);
|
||
testVin(vin)
|
||
.then((r) => { console.log(JSON.stringify(r, null, 2)); return closeSession(); })
|
||
.then(() => process.exit(0))
|
||
.catch((e) => { console.error("Fatal:", e); process.exit(1); });
|
||
}
|