Files
sase.tr/ralph-vin-test/test-vin.ts
Fusion f4fea1e429
Some checks failed
Sync dev → Gitea / Mirror dev to Gitea (push) Has been cancelled
feat(FN-094): add comment line for deployment verification
- Added a comment line to main.ts for deployment verification purposes
2026-05-11 02:07:03 +00:00

550 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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); });
}