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:
223
.agents/skills/playwright-skill/.temp-execution-1770984872032.js
Normal file
223
.agents/skills/playwright-skill/.temp-execution-1770984872032.js
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* EMEX VIN Scraper - Playwright Benchmark
|
||||
* Same flow as puppeteer-based emex-vin-scraper.js but using Playwright
|
||||
*/
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const VIN = process.argv[2] || 'NM0GXXTTPGAG07617';
|
||||
|
||||
const CONFIG = {
|
||||
baseUrl: 'https://emexdwc.ae',
|
||||
timeout: 60000,
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
catalogMap: {
|
||||
'WBA': 'BMW202501', 'WBS': 'BMW202501', 'WBY': 'BMW202501',
|
||||
'WDB': 'MB201810', 'WDD': 'MB201810', 'WDC': 'MB201810', 'WDF': 'MB201810',
|
||||
'WAU': 'AU1587', 'WVW': 'VW1587', 'WVG': 'VW1587',
|
||||
'VF1': 'RENAULT201910', 'VF7': 'CPSA01', 'VF3': 'CPSA01',
|
||||
'ZFA': 'CFIAT84', 'ZAR': 'RFIAT84',
|
||||
'WF0': 'FORD202201', 'NM0': 'FORD202201',
|
||||
'JTD': 'TOYOTA00', 'JTE': 'TOYOTA00',
|
||||
'WP0': 'PO799', 'WP1': 'PO799',
|
||||
},
|
||||
brandMap: {
|
||||
'BMW202501': 'BMW', 'MB201810': 'Mercedes-Benz', 'AU1587': 'Audi',
|
||||
'VW1587': 'Volkswagen', 'RENAULT201910': 'Renault', 'CPSA01': 'Peugeot',
|
||||
'CFIAT84': 'Fiat', 'RFIAT84': 'Alfa Romeo', 'FORD202201': 'Ford',
|
||||
'TOYOTA00': 'Toyota', 'PO799': 'Porsche',
|
||||
}
|
||||
};
|
||||
|
||||
function getCatalogCode(vin) {
|
||||
return CONFIG.catalogMap[vin.substring(0, 3)] || null;
|
||||
}
|
||||
|
||||
function getBrand(code) {
|
||||
return CONFIG.brandMap[code] || code;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const totalStart = performance.now();
|
||||
const timings = {};
|
||||
|
||||
console.log('=' .repeat(70));
|
||||
console.log('EMEX VIN SCRAPER - PLAYWRIGHT BENCHMARK');
|
||||
console.log('=' .repeat(70));
|
||||
console.log(`VIN: ${VIN}`);
|
||||
const catalogCode = getCatalogCode(VIN);
|
||||
console.log(`Catalog: ${catalogCode}`);
|
||||
console.log('=' .repeat(70));
|
||||
|
||||
// ── Launch browser ──
|
||||
let t = performance.now();
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-gpu',
|
||||
]
|
||||
});
|
||||
const context = await browser.newContext({
|
||||
userAgent: CONFIG.userAgent,
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
timings.browserLaunch = performance.now() - t;
|
||||
console.log(`[${timings.browserLaunch.toFixed(0)}ms] Browser launched`);
|
||||
|
||||
// ── Step 1: Establish session ──
|
||||
t = performance.now();
|
||||
await page.goto(CONFIG.baseUrl, { waitUntil: 'networkidle', timeout: CONFIG.timeout });
|
||||
timings.session = performance.now() - t;
|
||||
const cookies = await context.cookies();
|
||||
const sessionCookie = cookies.find(c => c.name === 'ASP.NET_SessionId')?.value || '';
|
||||
console.log(`[${timings.session.toFixed(0)}ms] Session: ${sessionCookie ? 'established' : 'none'}`);
|
||||
|
||||
// ── Step 2: VIN URL search ──
|
||||
t = performance.now();
|
||||
const vinUrl = `${CONFIG.baseUrl}/Vehicles.aspx?ft=findByVIN&c=&ssd=&vin=${VIN}`;
|
||||
console.log(`\nNavigating to: ${vinUrl}`);
|
||||
await page.goto(vinUrl, { waitUntil: 'networkidle', timeout: CONFIG.timeout });
|
||||
await page.waitForTimeout(1500); // wait for dynamic content
|
||||
timings.vinSearch = performance.now() - t;
|
||||
|
||||
// Extract vehicle links
|
||||
const vehicleLinks = await page.evaluate(() => {
|
||||
const results = [];
|
||||
const links = document.querySelectorAll('a[href*="Vehicle.aspx"], a[href*="QuickGroups.aspx"]');
|
||||
links.forEach(link => {
|
||||
const href = link.href;
|
||||
const name = link.textContent.trim();
|
||||
if (name && !results.some(r => r.name === name)) {
|
||||
const cMatch = href.match(/[?&]c=([^&]+)/);
|
||||
const ssdMatch = href.match(/[?&]ssd=([^&]+)/);
|
||||
results.push({
|
||||
name,
|
||||
href,
|
||||
catalogCode: cMatch ? cMatch[1] : '',
|
||||
ssd: ssdMatch ? decodeURIComponent(ssdMatch[1]) : '',
|
||||
isQuickGroups: href.includes('QuickGroups.aspx'),
|
||||
});
|
||||
}
|
||||
});
|
||||
return results;
|
||||
});
|
||||
|
||||
console.log(`[${timings.vinSearch.toFixed(0)}ms] VIN search → ${vehicleLinks.length} vehicle(s) found`);
|
||||
|
||||
if (vehicleLinks.length > 0) {
|
||||
const v = vehicleLinks[0];
|
||||
console.log(` Vehicle: ${v.name}`);
|
||||
}
|
||||
|
||||
// ── Step 3: Fetch category tree ──
|
||||
const qgLink = vehicleLinks.find(l => l.isQuickGroups);
|
||||
let quickGroupsUrl = qgLink?.href || null;
|
||||
|
||||
if (!quickGroupsUrl && vehicleLinks.length > 0) {
|
||||
const first = vehicleLinks[0];
|
||||
if (first.ssd) {
|
||||
const vidMatch = first.href.match(/[?&]vid=([^&]+)/);
|
||||
const vid = vidMatch ? vidMatch[1] : '0';
|
||||
quickGroupsUrl = `${CONFIG.baseUrl}/QuickGroups.aspx?c=${first.catalogCode || catalogCode}&vid=${vid}&ssd=${encodeURIComponent(first.ssd)}`;
|
||||
}
|
||||
}
|
||||
|
||||
let categories = [];
|
||||
if (quickGroupsUrl) {
|
||||
t = performance.now();
|
||||
await page.goto(quickGroupsUrl, { waitUntil: 'networkidle', timeout: CONFIG.timeout });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
categories = await page.evaluate(() => {
|
||||
const cats = [];
|
||||
const links = document.querySelectorAll('a[href*="QuickDetails"], a[href*="gid="]');
|
||||
links.forEach(link => {
|
||||
const gidMatch = link.href.match(/gid=(\d+)/);
|
||||
if (gidMatch) {
|
||||
cats.push({ gid: gidMatch[1], name: link.textContent.trim(), url: link.href });
|
||||
}
|
||||
});
|
||||
return cats;
|
||||
});
|
||||
timings.categories = performance.now() - t;
|
||||
console.log(`[${timings.categories.toFixed(0)}ms] Categories → ${categories.length} found`);
|
||||
}
|
||||
|
||||
// ── Step 4: Fetch first category parts ──
|
||||
let parts = [];
|
||||
let schemaImageUrl = null;
|
||||
if (categories.length > 0) {
|
||||
const firstCatUrl = categories[0].url;
|
||||
t = performance.now();
|
||||
console.log(`\nFetching parts from: ${categories[0].name}`);
|
||||
await page.goto(firstCatUrl, { waitUntil: 'networkidle', timeout: CONFIG.timeout });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Check if we landed on Unit.aspx (redirect for BOM leaf nodes)
|
||||
const currentUrl = page.url();
|
||||
if (currentUrl.includes('QuickDetails.aspx')) {
|
||||
// Look for Unit.aspx links
|
||||
const unitLink = await page.evaluate(() => {
|
||||
const link = document.querySelector('a[href*="Unit.aspx"]');
|
||||
return link ? link.href : null;
|
||||
});
|
||||
if (unitLink) {
|
||||
console.log(' Following Unit.aspx link...');
|
||||
await page.goto(unitLink, { waitUntil: 'networkidle', timeout: CONFIG.timeout });
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract parts from the page
|
||||
const partsData = await page.evaluate(() => {
|
||||
const result = { parts: [], schemaImageUrl: null };
|
||||
|
||||
// Schema image
|
||||
const img = document.querySelector('img[src*="GetImage"], img[src*="schema"], img.partImage, #imgSchema');
|
||||
if (img) result.schemaImageUrl = img.src;
|
||||
|
||||
// Parts table
|
||||
const rows = document.querySelectorAll('table tr, .partRow, [class*="part"]');
|
||||
rows.forEach(row => {
|
||||
const cells = row.querySelectorAll('td');
|
||||
if (cells.length >= 2) {
|
||||
const name = cells[0]?.textContent?.trim() || '';
|
||||
const partNumber = cells[1]?.textContent?.trim() || '';
|
||||
if (name && partNumber) {
|
||||
result.parts.push({ name, partNumber });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
parts = partsData.parts;
|
||||
schemaImageUrl = partsData.schemaImageUrl;
|
||||
timings.parts = performance.now() - t;
|
||||
console.log(`[${timings.parts.toFixed(0)}ms] Parts → ${parts.length} found, schema: ${schemaImageUrl ? 'yes' : 'no'}`);
|
||||
}
|
||||
|
||||
// ── Cleanup ──
|
||||
await browser.close();
|
||||
|
||||
// ── Summary ──
|
||||
const totalTime = performance.now() - totalStart;
|
||||
console.log('\n' + '=' .repeat(70));
|
||||
console.log('PLAYWRIGHT BENCHMARK RESULTS');
|
||||
console.log('=' .repeat(70));
|
||||
console.log(`Browser launch: ${timings.browserLaunch?.toFixed(0) || '-'}ms`);
|
||||
console.log(`Session establish: ${timings.session?.toFixed(0) || '-'}ms`);
|
||||
console.log(`VIN search: ${timings.vinSearch?.toFixed(0) || '-'}ms`);
|
||||
console.log(`Categories: ${timings.categories?.toFixed(0) || '-'}ms`);
|
||||
console.log(`Parts (1st cat): ${timings.parts?.toFixed(0) || '-'}ms`);
|
||||
console.log('-'.repeat(70));
|
||||
console.log(`TOTAL: ${totalTime.toFixed(0)}ms (${(totalTime / 1000).toFixed(1)}s)`);
|
||||
console.log('=' .repeat(70));
|
||||
console.log(`Vehicle: ${getBrand(catalogCode)} - ${vehicleLinks[0]?.name || 'N/A'}`);
|
||||
console.log(`Categories: ${categories.length}`);
|
||||
console.log(`Parts (sample): ${parts.length}`);
|
||||
})();
|
||||
653
.agents/skills/playwright-skill/API_REFERENCE.md
Normal file
653
.agents/skills/playwright-skill/API_REFERENCE.md
Normal file
@@ -0,0 +1,653 @@
|
||||
# Playwright Skill - Complete API Reference
|
||||
|
||||
This document contains the comprehensive Playwright API reference and advanced patterns. For quick-start execution patterns, see [SKILL.md](SKILL.md).
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Installation & Setup](#installation--setup)
|
||||
- [Core Patterns](#core-patterns)
|
||||
- [Selectors & Locators](#selectors--locators)
|
||||
- [Common Actions](#common-actions)
|
||||
- [Waiting Strategies](#waiting-strategies)
|
||||
- [Assertions](#assertions)
|
||||
- [Page Object Model](#page-object-model-pom)
|
||||
- [Network & API Testing](#network--api-testing)
|
||||
- [Authentication & Session Management](#authentication--session-management)
|
||||
- [Visual Testing](#visual-testing)
|
||||
- [Mobile Testing](#mobile-testing)
|
||||
- [Debugging](#debugging)
|
||||
- [Performance Testing](#performance-testing)
|
||||
- [Parallel Execution](#parallel-execution)
|
||||
- [Data-Driven Testing](#data-driven-testing)
|
||||
- [Accessibility Testing](#accessibility-testing)
|
||||
- [CI/CD Integration](#cicd-integration)
|
||||
- [Best Practices](#best-practices)
|
||||
- [Common Patterns & Solutions](#common-patterns--solutions)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
## Installation & Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Before using this skill, ensure Playwright is available:
|
||||
|
||||
```bash
|
||||
# Check if Playwright is installed
|
||||
npm list playwright 2>/dev/null || echo "Playwright not installed"
|
||||
|
||||
# Install (if needed)
|
||||
cd ~/.claude/skills/playwright-skill
|
||||
npm run setup
|
||||
```
|
||||
|
||||
### Basic Configuration
|
||||
|
||||
Create `playwright.config.ts`:
|
||||
|
||||
```typescript
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL: 'http://localhost:3000',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: 'npm run start',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Basic Browser Automation
|
||||
|
||||
```javascript
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
// Launch browser
|
||||
const browser = await chromium.launch({
|
||||
headless: false, // Set to true for headless mode
|
||||
slowMo: 50 // Slow down operations by 50ms
|
||||
});
|
||||
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1280, height: 720 },
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
|
||||
// Navigate
|
||||
await page.goto('https://example.com', {
|
||||
waitUntil: 'networkidle' // Wait for network to be idle
|
||||
});
|
||||
|
||||
// Your automation here
|
||||
|
||||
await browser.close();
|
||||
})();
|
||||
```
|
||||
|
||||
### Test Structure
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Feature Name', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test('should do something', async ({ page }) => {
|
||||
// Arrange
|
||||
const button = page.locator('button[data-testid="submit"]');
|
||||
|
||||
// Act
|
||||
await button.click();
|
||||
|
||||
// Assert
|
||||
await expect(page).toHaveURL('/success');
|
||||
await expect(page.locator('.message')).toHaveText('Success!');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Selectors & Locators
|
||||
|
||||
### Best Practices for Selectors
|
||||
|
||||
```javascript
|
||||
// PREFERRED: Data attributes (most stable)
|
||||
await page.locator('[data-testid="submit-button"]').click();
|
||||
await page.locator('[data-cy="user-input"]').fill('text');
|
||||
|
||||
// GOOD: Role-based selectors (accessible)
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
|
||||
await page.getByRole('heading', { level: 1 }).click();
|
||||
|
||||
// GOOD: Text content (for unique text)
|
||||
await page.getByText('Sign in').click();
|
||||
await page.getByText(/welcome back/i).click();
|
||||
|
||||
// OK: Semantic HTML
|
||||
await page.locator('button[type="submit"]').click();
|
||||
await page.locator('input[name="email"]').fill('test@test.com');
|
||||
|
||||
// AVOID: Classes and IDs (can change frequently)
|
||||
await page.locator('.btn-primary').click(); // Avoid
|
||||
await page.locator('#submit').click(); // Avoid
|
||||
|
||||
// LAST RESORT: Complex CSS/XPath
|
||||
await page.locator('div.container > form > button').click(); // Fragile
|
||||
```
|
||||
|
||||
### Advanced Locator Patterns
|
||||
|
||||
```javascript
|
||||
// Filter and chain locators
|
||||
const row = page.locator('tr').filter({ hasText: 'John Doe' });
|
||||
await row.locator('button').click();
|
||||
|
||||
// Nth element
|
||||
await page.locator('button').nth(2).click();
|
||||
|
||||
// Combining conditions
|
||||
await page.locator('button').and(page.locator('[disabled]')).count();
|
||||
|
||||
// Parent/child navigation
|
||||
const cell = page.locator('td').filter({ hasText: 'Active' });
|
||||
const row = cell.locator('..');
|
||||
await row.locator('button.edit').click();
|
||||
```
|
||||
|
||||
## Common Actions
|
||||
|
||||
### Form Interactions
|
||||
|
||||
```javascript
|
||||
// Text input
|
||||
await page.getByLabel('Email').fill('user@example.com');
|
||||
await page.getByPlaceholder('Enter your name').fill('John Doe');
|
||||
|
||||
// Clear and type
|
||||
await page.locator('#username').clear();
|
||||
await page.locator('#username').type('newuser', { delay: 100 });
|
||||
|
||||
// Checkbox
|
||||
await page.getByLabel('I agree').check();
|
||||
await page.getByLabel('Subscribe').uncheck();
|
||||
|
||||
// Radio button
|
||||
await page.getByLabel('Option 2').check();
|
||||
|
||||
// Select dropdown
|
||||
await page.selectOption('select#country', 'usa');
|
||||
await page.selectOption('select#country', { label: 'United States' });
|
||||
await page.selectOption('select#country', { index: 2 });
|
||||
|
||||
// Multi-select
|
||||
await page.selectOption('select#colors', ['red', 'blue', 'green']);
|
||||
|
||||
// File upload
|
||||
await page.setInputFiles('input[type="file"]', 'path/to/file.pdf');
|
||||
await page.setInputFiles('input[type="file"]', [
|
||||
'file1.pdf',
|
||||
'file2.pdf'
|
||||
]);
|
||||
```
|
||||
|
||||
### Mouse Actions
|
||||
|
||||
```javascript
|
||||
// Click variations
|
||||
await page.click('button'); // Left click
|
||||
await page.click('button', { button: 'right' }); // Right click
|
||||
await page.dblclick('button'); // Double click
|
||||
await page.click('button', { position: { x: 10, y: 10 } }); // Click at position
|
||||
|
||||
// Hover
|
||||
await page.hover('.menu-item');
|
||||
|
||||
// Drag and drop
|
||||
await page.dragAndDrop('#source', '#target');
|
||||
|
||||
// Manual drag
|
||||
await page.locator('#source').hover();
|
||||
await page.mouse.down();
|
||||
await page.locator('#target').hover();
|
||||
await page.mouse.up();
|
||||
```
|
||||
|
||||
### Keyboard Actions
|
||||
|
||||
```javascript
|
||||
// Type with delay
|
||||
await page.keyboard.type('Hello World', { delay: 100 });
|
||||
|
||||
// Key combinations
|
||||
await page.keyboard.press('Control+A');
|
||||
await page.keyboard.press('Control+C');
|
||||
await page.keyboard.press('Control+V');
|
||||
|
||||
// Special keys
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Escape');
|
||||
await page.keyboard.press('ArrowDown');
|
||||
```
|
||||
|
||||
## Waiting Strategies
|
||||
|
||||
### Smart Waiting
|
||||
|
||||
```javascript
|
||||
// Wait for element states
|
||||
await page.locator('button').waitFor({ state: 'visible' });
|
||||
await page.locator('.spinner').waitFor({ state: 'hidden' });
|
||||
await page.locator('button').waitFor({ state: 'attached' });
|
||||
await page.locator('button').waitFor({ state: 'detached' });
|
||||
|
||||
// Wait for specific conditions
|
||||
await page.waitForURL('**/success');
|
||||
await page.waitForURL(url => url.pathname === '/dashboard');
|
||||
|
||||
// Wait for network
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
// Wait for function
|
||||
await page.waitForFunction(() => document.querySelector('.loaded'));
|
||||
await page.waitForFunction(
|
||||
text => document.body.innerText.includes(text),
|
||||
'Content loaded'
|
||||
);
|
||||
|
||||
// Wait for response
|
||||
const responsePromise = page.waitForResponse('**/api/users');
|
||||
await page.click('button#load-users');
|
||||
const response = await responsePromise;
|
||||
|
||||
// Wait for request
|
||||
await page.waitForRequest(request =>
|
||||
request.url().includes('/api/') && request.method() === 'POST'
|
||||
);
|
||||
|
||||
// Custom timeout
|
||||
await page.locator('.slow-element').waitFor({
|
||||
state: 'visible',
|
||||
timeout: 10000 // 10 seconds
|
||||
});
|
||||
```
|
||||
|
||||
## Assertions
|
||||
|
||||
### Common Assertions
|
||||
|
||||
```javascript
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
// Page assertions
|
||||
await expect(page).toHaveTitle('My App');
|
||||
await expect(page).toHaveURL('https://example.com/dashboard');
|
||||
await expect(page).toHaveURL(/.*dashboard/);
|
||||
|
||||
// Element visibility
|
||||
await expect(page.locator('.message')).toBeVisible();
|
||||
await expect(page.locator('.spinner')).toBeHidden();
|
||||
await expect(page.locator('button')).toBeEnabled();
|
||||
await expect(page.locator('input')).toBeDisabled();
|
||||
|
||||
// Text content
|
||||
await expect(page.locator('h1')).toHaveText('Welcome');
|
||||
await expect(page.locator('.message')).toContainText('success');
|
||||
await expect(page.locator('.items')).toHaveText(['Item 1', 'Item 2']);
|
||||
|
||||
// Input values
|
||||
await expect(page.locator('input')).toHaveValue('test@example.com');
|
||||
await expect(page.locator('input')).toBeEmpty();
|
||||
|
||||
// Attributes
|
||||
await expect(page.locator('button')).toHaveAttribute('type', 'submit');
|
||||
await expect(page.locator('img')).toHaveAttribute('src', /.*\.png/);
|
||||
|
||||
// CSS properties
|
||||
await expect(page.locator('.error')).toHaveCSS('color', 'rgb(255, 0, 0)');
|
||||
|
||||
// Count
|
||||
await expect(page.locator('.item')).toHaveCount(5);
|
||||
|
||||
// Checkbox/Radio state
|
||||
await expect(page.locator('input[type="checkbox"]')).toBeChecked();
|
||||
```
|
||||
|
||||
## Page Object Model (POM)
|
||||
|
||||
### Basic Page Object
|
||||
|
||||
```javascript
|
||||
// pages/LoginPage.js
|
||||
class LoginPage {
|
||||
constructor(page) {
|
||||
this.page = page;
|
||||
this.usernameInput = page.locator('input[name="username"]');
|
||||
this.passwordInput = page.locator('input[name="password"]');
|
||||
this.submitButton = page.locator('button[type="submit"]');
|
||||
this.errorMessage = page.locator('.error-message');
|
||||
}
|
||||
|
||||
async navigate() {
|
||||
await this.page.goto('/login');
|
||||
}
|
||||
|
||||
async login(username, password) {
|
||||
await this.usernameInput.fill(username);
|
||||
await this.passwordInput.fill(password);
|
||||
await this.submitButton.click();
|
||||
}
|
||||
|
||||
async getErrorMessage() {
|
||||
return await this.errorMessage.textContent();
|
||||
}
|
||||
}
|
||||
|
||||
// Usage in test
|
||||
test('login with valid credentials', async ({ page }) => {
|
||||
const loginPage = new LoginPage(page);
|
||||
await loginPage.navigate();
|
||||
await loginPage.login('user@example.com', 'password123');
|
||||
await expect(page).toHaveURL('/dashboard');
|
||||
});
|
||||
```
|
||||
|
||||
## Network & API Testing
|
||||
|
||||
### Intercepting Requests
|
||||
|
||||
```javascript
|
||||
// Mock API responses
|
||||
await page.route('**/api/users', route => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{ id: 1, name: 'John' },
|
||||
{ id: 2, name: 'Jane' }
|
||||
])
|
||||
});
|
||||
});
|
||||
|
||||
// Modify requests
|
||||
await page.route('**/api/**', route => {
|
||||
const headers = {
|
||||
...route.request().headers(),
|
||||
'X-Custom-Header': 'value'
|
||||
};
|
||||
route.continue({ headers });
|
||||
});
|
||||
|
||||
// Block resources
|
||||
await page.route('**/*.{png,jpg,jpeg,gif}', route => route.abort());
|
||||
```
|
||||
|
||||
### Custom Headers via Environment Variables
|
||||
|
||||
The skill supports automatic header injection via environment variables:
|
||||
|
||||
```bash
|
||||
# Single header (simple)
|
||||
PW_HEADER_NAME=X-Automated-By PW_HEADER_VALUE=playwright-skill
|
||||
|
||||
# Multiple headers (JSON)
|
||||
PW_EXTRA_HEADERS='{"X-Automated-By":"playwright-skill","X-Request-ID":"123"}'
|
||||
```
|
||||
|
||||
These headers are automatically applied to all requests when using:
|
||||
- `helpers.createContext(browser)` - headers merged automatically
|
||||
- `getContextOptionsWithHeaders(options)` - utility injected by run.js wrapper
|
||||
|
||||
**Precedence (highest to lowest):**
|
||||
1. Headers passed directly in `options.extraHTTPHeaders`
|
||||
2. Environment variable headers
|
||||
3. Playwright defaults
|
||||
|
||||
**Use case:** Identify automated traffic so your backend can return LLM-optimized responses (e.g., plain text errors instead of styled HTML).
|
||||
|
||||
## Visual Testing
|
||||
|
||||
### Screenshots
|
||||
|
||||
```javascript
|
||||
// Full page screenshot
|
||||
await page.screenshot({
|
||||
path: 'screenshot.png',
|
||||
fullPage: true
|
||||
});
|
||||
|
||||
// Element screenshot
|
||||
await page.locator('.chart').screenshot({
|
||||
path: 'chart.png'
|
||||
});
|
||||
|
||||
// Visual comparison
|
||||
await expect(page).toHaveScreenshot('homepage.png');
|
||||
```
|
||||
|
||||
## Mobile Testing
|
||||
|
||||
```javascript
|
||||
// Device emulation
|
||||
const { devices } = require('playwright');
|
||||
const iPhone = devices['iPhone 12'];
|
||||
|
||||
const context = await browser.newContext({
|
||||
...iPhone,
|
||||
locale: 'en-US',
|
||||
permissions: ['geolocation'],
|
||||
geolocation: { latitude: 37.7749, longitude: -122.4194 }
|
||||
});
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
### Debug Mode
|
||||
|
||||
```bash
|
||||
# Run with inspector
|
||||
npx playwright test --debug
|
||||
|
||||
# Headed mode
|
||||
npx playwright test --headed
|
||||
|
||||
# Slow motion
|
||||
npx playwright test --headed --slowmo=1000
|
||||
```
|
||||
|
||||
### In-Code Debugging
|
||||
|
||||
```javascript
|
||||
// Pause execution
|
||||
await page.pause();
|
||||
|
||||
// Console logs
|
||||
page.on('console', msg => console.log('Browser log:', msg.text()));
|
||||
page.on('pageerror', error => console.log('Page error:', error));
|
||||
```
|
||||
|
||||
## Performance Testing
|
||||
|
||||
```javascript
|
||||
// Measure page load time
|
||||
const startTime = Date.now();
|
||||
await page.goto('https://example.com');
|
||||
const loadTime = Date.now() - startTime;
|
||||
console.log(`Page loaded in ${loadTime}ms`);
|
||||
```
|
||||
|
||||
## Parallel Execution
|
||||
|
||||
```javascript
|
||||
// Run tests in parallel
|
||||
test.describe.parallel('Parallel suite', () => {
|
||||
test('test 1', async ({ page }) => {
|
||||
// Runs in parallel with test 2
|
||||
});
|
||||
|
||||
test('test 2', async ({ page }) => {
|
||||
// Runs in parallel with test 1
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Data-Driven Testing
|
||||
|
||||
```javascript
|
||||
// Parameterized tests
|
||||
const testData = [
|
||||
{ username: 'user1', password: 'pass1', expected: 'Welcome user1' },
|
||||
{ username: 'user2', password: 'pass2', expected: 'Welcome user2' },
|
||||
];
|
||||
|
||||
testData.forEach(({ username, password, expected }) => {
|
||||
test(`login with ${username}`, async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.fill('#username', username);
|
||||
await page.fill('#password', password);
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page.locator('.message')).toHaveText(expected);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Accessibility Testing
|
||||
|
||||
```javascript
|
||||
import { injectAxe, checkA11y } from 'axe-playwright';
|
||||
|
||||
test('accessibility check', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await injectAxe(page);
|
||||
await checkA11y(page);
|
||||
});
|
||||
```
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
### GitHub Actions
|
||||
|
||||
```yaml
|
||||
name: Playwright Tests
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install --with-deps
|
||||
- name: Run tests
|
||||
run: npx playwright test
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Test Organization** - Use descriptive test names, group related tests
|
||||
2. **Selector Strategy** - Prefer data-testid attributes, use role-based selectors
|
||||
3. **Waiting** - Use Playwright's auto-waiting, avoid hard-coded delays
|
||||
4. **Error Handling** - Add proper error messages, take screenshots on failure
|
||||
5. **Performance** - Run tests in parallel, reuse authentication state
|
||||
|
||||
## Common Patterns & Solutions
|
||||
|
||||
### Handling Popups
|
||||
|
||||
```javascript
|
||||
const [popup] = await Promise.all([
|
||||
page.waitForEvent('popup'),
|
||||
page.click('button.open-popup')
|
||||
]);
|
||||
await popup.waitForLoadState();
|
||||
```
|
||||
|
||||
### File Downloads
|
||||
|
||||
```javascript
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent('download'),
|
||||
page.click('button.download')
|
||||
]);
|
||||
await download.saveAs(`./downloads/${download.suggestedFilename()}`);
|
||||
```
|
||||
|
||||
### iFrames
|
||||
|
||||
```javascript
|
||||
const frame = page.frameLocator('#my-iframe');
|
||||
await frame.locator('button').click();
|
||||
```
|
||||
|
||||
### Infinite Scroll
|
||||
|
||||
```javascript
|
||||
async function scrollToBottom(page) {
|
||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Element not found** - Check if element is in iframe, verify visibility
|
||||
2. **Timeout errors** - Increase timeout, check network conditions
|
||||
3. **Flaky tests** - Use proper waiting strategies, mock external dependencies
|
||||
4. **Authentication issues** - Verify auth state is properly saved
|
||||
|
||||
## Quick Reference Commands
|
||||
|
||||
```bash
|
||||
# Run tests
|
||||
npx playwright test
|
||||
|
||||
# Run in headed mode
|
||||
npx playwright test --headed
|
||||
|
||||
# Debug tests
|
||||
npx playwright test --debug
|
||||
|
||||
# Generate code
|
||||
npx playwright codegen https://example.com
|
||||
|
||||
# Show report
|
||||
npx playwright show-report
|
||||
```
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Playwright Documentation](https://playwright.dev/docs/intro)
|
||||
- [API Reference](https://playwright.dev/docs/api/class-playwright)
|
||||
- [Best Practices](https://playwright.dev/docs/best-practices)
|
||||
453
.agents/skills/playwright-skill/SKILL.md
Normal file
453
.agents/skills/playwright-skill/SKILL.md
Normal file
@@ -0,0 +1,453 @@
|
||||
---
|
||||
name: playwright-skill
|
||||
description: Complete browser automation with Playwright. Auto-detects dev servers, writes clean test scripts to /tmp. Test pages, fill forms, take screenshots, check responsive design, validate UX, test login flows, check links, automate any browser task. Use when user wants to test websites, automate browser interactions, validate web functionality, or perform any browser-based testing.
|
||||
---
|
||||
|
||||
**IMPORTANT - Path Resolution:**
|
||||
This skill can be installed in different locations (plugin system, manual installation, global, or project-specific). Before executing any commands, determine the skill directory based on where you loaded this SKILL.md file, and use that path in all commands below. Replace `$SKILL_DIR` with the actual discovered path.
|
||||
|
||||
Common installation paths:
|
||||
|
||||
- Plugin system: `~/.claude/plugins/marketplaces/playwright-skill/skills/playwright-skill`
|
||||
- Manual global: `~/.claude/skills/playwright-skill`
|
||||
- Project-specific: `<project>/.claude/skills/playwright-skill`
|
||||
|
||||
# Playwright Browser Automation
|
||||
|
||||
General-purpose browser automation skill. I'll write custom Playwright code for any automation task you request and execute it via the universal executor.
|
||||
|
||||
**CRITICAL WORKFLOW - Follow these steps in order:**
|
||||
|
||||
1. **Auto-detect dev servers** - For localhost testing, ALWAYS run server detection FIRST:
|
||||
|
||||
```bash
|
||||
cd $SKILL_DIR && node -e "require('./lib/helpers').detectDevServers().then(servers => console.log(JSON.stringify(servers)))"
|
||||
```
|
||||
|
||||
- If **1 server found**: Use it automatically, inform user
|
||||
- If **multiple servers found**: Ask user which one to test
|
||||
- If **no servers found**: Ask for URL or offer to help start dev server
|
||||
|
||||
2. **Write scripts to /tmp** - NEVER write test files to skill directory; always use `/tmp/playwright-test-*.js`
|
||||
|
||||
3. **Use visible browser by default** - Always use `headless: false` unless user specifically requests headless mode
|
||||
|
||||
4. **Parameterize URLs** - Always make URLs configurable via environment variable or constant at top of script
|
||||
|
||||
## How It Works
|
||||
|
||||
1. You describe what you want to test/automate
|
||||
2. I auto-detect running dev servers (or ask for URL if testing external site)
|
||||
3. I write custom Playwright code in `/tmp/playwright-test-*.js` (won't clutter your project)
|
||||
4. I execute it via: `cd $SKILL_DIR && node run.js /tmp/playwright-test-*.js`
|
||||
5. Results displayed in real-time, browser window visible for debugging
|
||||
6. Test files auto-cleaned from /tmp by your OS
|
||||
|
||||
## Setup (First Time)
|
||||
|
||||
```bash
|
||||
cd $SKILL_DIR
|
||||
npm run setup
|
||||
```
|
||||
|
||||
This installs Playwright and Chromium browser. Only needed once.
|
||||
|
||||
## Execution Pattern
|
||||
|
||||
**Step 1: Detect dev servers (for localhost testing)**
|
||||
|
||||
```bash
|
||||
cd $SKILL_DIR && node -e "require('./lib/helpers').detectDevServers().then(s => console.log(JSON.stringify(s)))"
|
||||
```
|
||||
|
||||
**Step 2: Write test script to /tmp with URL parameter**
|
||||
|
||||
```javascript
|
||||
// /tmp/playwright-test-page.js
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
// Parameterized URL (detected or user-provided)
|
||||
const TARGET_URL = 'http://localhost:3001'; // <-- Auto-detected or from user
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: false });
|
||||
const page = await browser.newPage();
|
||||
|
||||
await page.goto(TARGET_URL);
|
||||
console.log('Page loaded:', await page.title());
|
||||
|
||||
await page.screenshot({ path: '/tmp/screenshot.png', fullPage: true });
|
||||
console.log('📸 Screenshot saved to /tmp/screenshot.png');
|
||||
|
||||
await browser.close();
|
||||
})();
|
||||
```
|
||||
|
||||
**Step 3: Execute from skill directory**
|
||||
|
||||
```bash
|
||||
cd $SKILL_DIR && node run.js /tmp/playwright-test-page.js
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Test a Page (Multiple Viewports)
|
||||
|
||||
```javascript
|
||||
// /tmp/playwright-test-responsive.js
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const TARGET_URL = 'http://localhost:3001'; // Auto-detected
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: false, slowMo: 100 });
|
||||
const page = await browser.newPage();
|
||||
|
||||
// Desktop test
|
||||
await page.setViewportSize({ width: 1920, height: 1080 });
|
||||
await page.goto(TARGET_URL);
|
||||
console.log('Desktop - Title:', await page.title());
|
||||
await page.screenshot({ path: '/tmp/desktop.png', fullPage: true });
|
||||
|
||||
// Mobile test
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await page.screenshot({ path: '/tmp/mobile.png', fullPage: true });
|
||||
|
||||
await browser.close();
|
||||
})();
|
||||
```
|
||||
|
||||
### Test Login Flow
|
||||
|
||||
```javascript
|
||||
// /tmp/playwright-test-login.js
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const TARGET_URL = 'http://localhost:3001'; // Auto-detected
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: false });
|
||||
const page = await browser.newPage();
|
||||
|
||||
await page.goto(`${TARGET_URL}/login`);
|
||||
|
||||
await page.fill('input[name="email"]', 'test@example.com');
|
||||
await page.fill('input[name="password"]', 'password123');
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Wait for redirect
|
||||
await page.waitForURL('**/dashboard');
|
||||
console.log('✅ Login successful, redirected to dashboard');
|
||||
|
||||
await browser.close();
|
||||
})();
|
||||
```
|
||||
|
||||
### Fill and Submit Form
|
||||
|
||||
```javascript
|
||||
// /tmp/playwright-test-form.js
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const TARGET_URL = 'http://localhost:3001'; // Auto-detected
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: false, slowMo: 50 });
|
||||
const page = await browser.newPage();
|
||||
|
||||
await page.goto(`${TARGET_URL}/contact`);
|
||||
|
||||
await page.fill('input[name="name"]', 'John Doe');
|
||||
await page.fill('input[name="email"]', 'john@example.com');
|
||||
await page.fill('textarea[name="message"]', 'Test message');
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Verify submission
|
||||
await page.waitForSelector('.success-message');
|
||||
console.log('✅ Form submitted successfully');
|
||||
|
||||
await browser.close();
|
||||
})();
|
||||
```
|
||||
|
||||
### Check for Broken Links
|
||||
|
||||
```javascript
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: false });
|
||||
const page = await browser.newPage();
|
||||
|
||||
await page.goto('http://localhost:3000');
|
||||
|
||||
const links = await page.locator('a[href^="http"]').all();
|
||||
const results = { working: 0, broken: [] };
|
||||
|
||||
for (const link of links) {
|
||||
const href = await link.getAttribute('href');
|
||||
try {
|
||||
const response = await page.request.head(href);
|
||||
if (response.ok()) {
|
||||
results.working++;
|
||||
} else {
|
||||
results.broken.push({ url: href, status: response.status() });
|
||||
}
|
||||
} catch (e) {
|
||||
results.broken.push({ url: href, error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ Working links: ${results.working}`);
|
||||
console.log(`❌ Broken links:`, results.broken);
|
||||
|
||||
await browser.close();
|
||||
})();
|
||||
```
|
||||
|
||||
### Take Screenshot with Error Handling
|
||||
|
||||
```javascript
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: false });
|
||||
const page = await browser.newPage();
|
||||
|
||||
try {
|
||||
await page.goto('http://localhost:3000', {
|
||||
waitUntil: 'networkidle',
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await page.screenshot({
|
||||
path: '/tmp/screenshot.png',
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
console.log('📸 Screenshot saved to /tmp/screenshot.png');
|
||||
} catch (error) {
|
||||
console.error('❌ Error:', error.message);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
})();
|
||||
```
|
||||
|
||||
### Test Responsive Design
|
||||
|
||||
```javascript
|
||||
// /tmp/playwright-test-responsive-full.js
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const TARGET_URL = 'http://localhost:3001'; // Auto-detected
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: false });
|
||||
const page = await browser.newPage();
|
||||
|
||||
const viewports = [
|
||||
{ name: 'Desktop', width: 1920, height: 1080 },
|
||||
{ name: 'Tablet', width: 768, height: 1024 },
|
||||
{ name: 'Mobile', width: 375, height: 667 },
|
||||
];
|
||||
|
||||
for (const viewport of viewports) {
|
||||
console.log(
|
||||
`Testing ${viewport.name} (${viewport.width}x${viewport.height})`,
|
||||
);
|
||||
|
||||
await page.setViewportSize({
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
});
|
||||
|
||||
await page.goto(TARGET_URL);
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await page.screenshot({
|
||||
path: `/tmp/${viewport.name.toLowerCase()}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
|
||||
console.log('✅ All viewports tested');
|
||||
await browser.close();
|
||||
})();
|
||||
```
|
||||
|
||||
## Inline Execution (Simple Tasks)
|
||||
|
||||
For quick one-off tasks, you can execute code inline without creating files:
|
||||
|
||||
```bash
|
||||
# Take a quick screenshot
|
||||
cd $SKILL_DIR && node run.js "
|
||||
const browser = await chromium.launch({ headless: false });
|
||||
const page = await browser.newPage();
|
||||
await page.goto('http://localhost:3001');
|
||||
await page.screenshot({ path: '/tmp/quick-screenshot.png', fullPage: true });
|
||||
console.log('Screenshot saved');
|
||||
await browser.close();
|
||||
"
|
||||
```
|
||||
|
||||
**When to use inline vs files:**
|
||||
|
||||
- **Inline**: Quick one-off tasks (screenshot, check if element exists, get page title)
|
||||
- **Files**: Complex tests, responsive design checks, anything user might want to re-run
|
||||
|
||||
## Available Helpers
|
||||
|
||||
Optional utility functions in `lib/helpers.js`:
|
||||
|
||||
```javascript
|
||||
const helpers = require('./lib/helpers');
|
||||
|
||||
// Detect running dev servers (CRITICAL - use this first!)
|
||||
const servers = await helpers.detectDevServers();
|
||||
console.log('Found servers:', servers);
|
||||
|
||||
// Safe click with retry
|
||||
await helpers.safeClick(page, 'button.submit', { retries: 3 });
|
||||
|
||||
// Safe type with clear
|
||||
await helpers.safeType(page, '#username', 'testuser');
|
||||
|
||||
// Take timestamped screenshot
|
||||
await helpers.takeScreenshot(page, 'test-result');
|
||||
|
||||
// Handle cookie banners
|
||||
await helpers.handleCookieBanner(page);
|
||||
|
||||
// Extract table data
|
||||
const data = await helpers.extractTableData(page, 'table.results');
|
||||
```
|
||||
|
||||
See `lib/helpers.js` for full list.
|
||||
|
||||
## Custom HTTP Headers
|
||||
|
||||
Configure custom headers for all HTTP requests via environment variables. Useful for:
|
||||
|
||||
- Identifying automated traffic to your backend
|
||||
- Getting LLM-optimized responses (e.g., plain text errors instead of styled HTML)
|
||||
- Adding authentication tokens globally
|
||||
|
||||
### Configuration
|
||||
|
||||
**Single header (common case):**
|
||||
|
||||
```bash
|
||||
PW_HEADER_NAME=X-Automated-By PW_HEADER_VALUE=playwright-skill \
|
||||
cd $SKILL_DIR && node run.js /tmp/my-script.js
|
||||
```
|
||||
|
||||
**Multiple headers (JSON format):**
|
||||
|
||||
```bash
|
||||
PW_EXTRA_HEADERS='{"X-Automated-By":"playwright-skill","X-Debug":"true"}' \
|
||||
cd $SKILL_DIR && node run.js /tmp/my-script.js
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
Headers are automatically applied when using `helpers.createContext()`:
|
||||
|
||||
```javascript
|
||||
const context = await helpers.createContext(browser);
|
||||
const page = await context.newPage();
|
||||
// All requests from this page include your custom headers
|
||||
```
|
||||
|
||||
For scripts using raw Playwright API, use the injected `getContextOptionsWithHeaders()`:
|
||||
|
||||
```javascript
|
||||
const context = await browser.newContext(
|
||||
getContextOptionsWithHeaders({ viewport: { width: 1920, height: 1080 } }),
|
||||
);
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
For comprehensive Playwright API documentation, see [API_REFERENCE.md](API_REFERENCE.md):
|
||||
|
||||
- Selectors & Locators best practices
|
||||
- Network interception & API mocking
|
||||
- Authentication & session management
|
||||
- Visual regression testing
|
||||
- Mobile device emulation
|
||||
- Performance testing
|
||||
- Debugging techniques
|
||||
- CI/CD integration
|
||||
|
||||
## Tips
|
||||
|
||||
- **CRITICAL: Detect servers FIRST** - Always run `detectDevServers()` before writing test code for localhost testing
|
||||
- **Custom headers** - Use `PW_HEADER_NAME`/`PW_HEADER_VALUE` env vars to identify automated traffic to your backend
|
||||
- **Use /tmp for test files** - Write to `/tmp/playwright-test-*.js`, never to skill directory or user's project
|
||||
- **Parameterize URLs** - Put detected/provided URL in a `TARGET_URL` constant at the top of every script
|
||||
- **DEFAULT: Visible browser** - Always use `headless: false` unless user explicitly asks for headless mode
|
||||
- **Headless mode** - Only use `headless: true` when user specifically requests "headless" or "background" execution
|
||||
- **Slow down:** Use `slowMo: 100` to make actions visible and easier to follow
|
||||
- **Wait strategies:** Use `waitForURL`, `waitForSelector`, `waitForLoadState` instead of fixed timeouts
|
||||
- **Error handling:** Always use try-catch for robust automation
|
||||
- **Console output:** Use `console.log()` to track progress and show what's happening
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Playwright not installed:**
|
||||
|
||||
```bash
|
||||
cd $SKILL_DIR && npm run setup
|
||||
```
|
||||
|
||||
**Module not found:**
|
||||
Ensure running from skill directory via `run.js` wrapper
|
||||
|
||||
**Browser doesn't open:**
|
||||
Check `headless: false` and ensure display available
|
||||
|
||||
**Element not found:**
|
||||
Add wait: `await page.waitForSelector('.element', { timeout: 10000 })`
|
||||
|
||||
## Example Usage
|
||||
|
||||
```
|
||||
User: "Test if the marketing page looks good"
|
||||
|
||||
Claude: I'll test the marketing page across multiple viewports. Let me first detect running servers...
|
||||
[Runs: detectDevServers()]
|
||||
[Output: Found server on port 3001]
|
||||
I found your dev server running on http://localhost:3001
|
||||
|
||||
[Writes custom automation script to /tmp/playwright-test-marketing.js with URL parameterized]
|
||||
[Runs: cd $SKILL_DIR && node run.js /tmp/playwright-test-marketing.js]
|
||||
[Shows results with screenshots from /tmp/]
|
||||
```
|
||||
|
||||
```
|
||||
User: "Check if login redirects correctly"
|
||||
|
||||
Claude: I'll test the login flow. First, let me check for running servers...
|
||||
[Runs: detectDevServers()]
|
||||
[Output: Found servers on ports 3000 and 3001]
|
||||
I found 2 dev servers. Which one should I test?
|
||||
- http://localhost:3000
|
||||
- http://localhost:3001
|
||||
|
||||
User: "Use 3001"
|
||||
|
||||
[Writes login automation to /tmp/playwright-test-login.js]
|
||||
[Runs: cd $SKILL_DIR && node run.js /tmp/playwright-test-login.js]
|
||||
[Reports: ✅ Login successful, redirected to /dashboard]
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Each automation is custom-written for your specific request
|
||||
- Not limited to pre-built scripts - any browser task possible
|
||||
- Auto-detects running dev servers to eliminate hardcoded URLs
|
||||
- Test scripts written to `/tmp` for automatic cleanup (no clutter)
|
||||
- Code executes reliably with proper module resolution via `run.js`
|
||||
- Progressive disclosure - API_REFERENCE.md loaded only when advanced features needed
|
||||
441
.agents/skills/playwright-skill/lib/helpers.js
Normal file
441
.agents/skills/playwright-skill/lib/helpers.js
Normal file
@@ -0,0 +1,441 @@
|
||||
// playwright-helpers.js
|
||||
// Reusable utility functions for Playwright automation
|
||||
|
||||
const { chromium, firefox, webkit } = require('playwright');
|
||||
|
||||
/**
|
||||
* Parse extra HTTP headers from environment variables.
|
||||
* Supports two formats:
|
||||
* - PW_HEADER_NAME + PW_HEADER_VALUE: Single header (simple, common case)
|
||||
* - PW_EXTRA_HEADERS: JSON object for multiple headers (advanced)
|
||||
* Single header format takes precedence if both are set.
|
||||
* @returns {Object|null} Headers object or null if none configured
|
||||
*/
|
||||
function getExtraHeadersFromEnv() {
|
||||
const headerName = process.env.PW_HEADER_NAME;
|
||||
const headerValue = process.env.PW_HEADER_VALUE;
|
||||
|
||||
if (headerName && headerValue) {
|
||||
return { [headerName]: headerValue };
|
||||
}
|
||||
|
||||
const headersJson = process.env.PW_EXTRA_HEADERS;
|
||||
if (headersJson) {
|
||||
try {
|
||||
const parsed = JSON.parse(headersJson);
|
||||
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
console.warn('PW_EXTRA_HEADERS must be a JSON object, ignoring...');
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse PW_EXTRA_HEADERS as JSON:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch browser with standard configuration
|
||||
* @param {string} browserType - 'chromium', 'firefox', or 'webkit'
|
||||
* @param {Object} options - Additional launch options
|
||||
*/
|
||||
async function launchBrowser(browserType = 'chromium', options = {}) {
|
||||
const defaultOptions = {
|
||||
headless: process.env.HEADLESS !== 'false',
|
||||
slowMo: process.env.SLOW_MO ? parseInt(process.env.SLOW_MO) : 0,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox']
|
||||
};
|
||||
|
||||
const browsers = { chromium, firefox, webkit };
|
||||
const browser = browsers[browserType];
|
||||
|
||||
if (!browser) {
|
||||
throw new Error(`Invalid browser type: ${browserType}`);
|
||||
}
|
||||
|
||||
return await browser.launch({ ...defaultOptions, ...options });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new page with viewport and user agent
|
||||
* @param {Object} context - Browser context
|
||||
* @param {Object} options - Page options
|
||||
*/
|
||||
async function createPage(context, options = {}) {
|
||||
const page = await context.newPage();
|
||||
|
||||
if (options.viewport) {
|
||||
await page.setViewportSize(options.viewport);
|
||||
}
|
||||
|
||||
if (options.userAgent) {
|
||||
await page.setExtraHTTPHeaders({
|
||||
'User-Agent': options.userAgent
|
||||
});
|
||||
}
|
||||
|
||||
// Set default timeout
|
||||
page.setDefaultTimeout(options.timeout || 30000);
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Smart wait for page to be ready
|
||||
* @param {Object} page - Playwright page
|
||||
* @param {Object} options - Wait options
|
||||
*/
|
||||
async function waitForPageReady(page, options = {}) {
|
||||
const waitOptions = {
|
||||
waitUntil: options.waitUntil || 'networkidle',
|
||||
timeout: options.timeout || 30000
|
||||
};
|
||||
|
||||
try {
|
||||
await page.waitForLoadState(waitOptions.waitUntil, {
|
||||
timeout: waitOptions.timeout
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Page load timeout, continuing...');
|
||||
}
|
||||
|
||||
// Additional wait for dynamic content if selector provided
|
||||
if (options.waitForSelector) {
|
||||
await page.waitForSelector(options.waitForSelector, {
|
||||
timeout: options.timeout
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe click with retry logic
|
||||
* @param {Object} page - Playwright page
|
||||
* @param {string} selector - Element selector
|
||||
* @param {Object} options - Click options
|
||||
*/
|
||||
async function safeClick(page, selector, options = {}) {
|
||||
const maxRetries = options.retries || 3;
|
||||
const retryDelay = options.retryDelay || 1000;
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
await page.waitForSelector(selector, {
|
||||
state: 'visible',
|
||||
timeout: options.timeout || 5000
|
||||
});
|
||||
await page.click(selector, {
|
||||
force: options.force || false,
|
||||
timeout: options.timeout || 5000
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (i === maxRetries - 1) {
|
||||
console.error(`Failed to click ${selector} after ${maxRetries} attempts`);
|
||||
throw e;
|
||||
}
|
||||
console.log(`Retry ${i + 1}/${maxRetries} for clicking ${selector}`);
|
||||
await page.waitForTimeout(retryDelay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe text input with clear before type
|
||||
* @param {Object} page - Playwright page
|
||||
* @param {string} selector - Input selector
|
||||
* @param {string} text - Text to type
|
||||
* @param {Object} options - Type options
|
||||
*/
|
||||
async function safeType(page, selector, text, options = {}) {
|
||||
await page.waitForSelector(selector, {
|
||||
state: 'visible',
|
||||
timeout: options.timeout || 10000
|
||||
});
|
||||
|
||||
if (options.clear !== false) {
|
||||
await page.fill(selector, '');
|
||||
}
|
||||
|
||||
if (options.slow) {
|
||||
await page.type(selector, text, { delay: options.delay || 100 });
|
||||
} else {
|
||||
await page.fill(selector, text);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text from multiple elements
|
||||
* @param {Object} page - Playwright page
|
||||
* @param {string} selector - Elements selector
|
||||
*/
|
||||
async function extractTexts(page, selector) {
|
||||
await page.waitForSelector(selector, { timeout: 10000 });
|
||||
return await page.$$eval(selector, elements =>
|
||||
elements.map(el => el.textContent?.trim()).filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Take screenshot with timestamp
|
||||
* @param {Object} page - Playwright page
|
||||
* @param {string} name - Screenshot name
|
||||
* @param {Object} options - Screenshot options
|
||||
*/
|
||||
async function takeScreenshot(page, name, options = {}) {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const filename = `${name}-${timestamp}.png`;
|
||||
|
||||
await page.screenshot({
|
||||
path: filename,
|
||||
fullPage: options.fullPage !== false,
|
||||
...options
|
||||
});
|
||||
|
||||
console.log(`Screenshot saved: ${filename}`);
|
||||
return filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle authentication
|
||||
* @param {Object} page - Playwright page
|
||||
* @param {Object} credentials - Username and password
|
||||
* @param {Object} selectors - Login form selectors
|
||||
*/
|
||||
async function authenticate(page, credentials, selectors = {}) {
|
||||
const defaultSelectors = {
|
||||
username: 'input[name="username"], input[name="email"], #username, #email',
|
||||
password: 'input[name="password"], #password',
|
||||
submit: 'button[type="submit"], input[type="submit"], button:has-text("Login"), button:has-text("Sign in")'
|
||||
};
|
||||
|
||||
const finalSelectors = { ...defaultSelectors, ...selectors };
|
||||
|
||||
await safeType(page, finalSelectors.username, credentials.username);
|
||||
await safeType(page, finalSelectors.password, credentials.password);
|
||||
await safeClick(page, finalSelectors.submit);
|
||||
|
||||
// Wait for navigation or success indicator
|
||||
await Promise.race([
|
||||
page.waitForNavigation({ waitUntil: 'networkidle' }),
|
||||
page.waitForSelector(selectors.successIndicator || '.dashboard, .user-menu, .logout', { timeout: 10000 })
|
||||
]).catch(() => {
|
||||
console.log('Login might have completed without navigation');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll page
|
||||
* @param {Object} page - Playwright page
|
||||
* @param {string} direction - 'down', 'up', 'top', 'bottom'
|
||||
* @param {number} distance - Pixels to scroll (for up/down)
|
||||
*/
|
||||
async function scrollPage(page, direction = 'down', distance = 500) {
|
||||
switch (direction) {
|
||||
case 'down':
|
||||
await page.evaluate(d => window.scrollBy(0, d), distance);
|
||||
break;
|
||||
case 'up':
|
||||
await page.evaluate(d => window.scrollBy(0, -d), distance);
|
||||
break;
|
||||
case 'top':
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
break;
|
||||
case 'bottom':
|
||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||||
break;
|
||||
}
|
||||
await page.waitForTimeout(500); // Wait for scroll animation
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract table data
|
||||
* @param {Object} page - Playwright page
|
||||
* @param {string} tableSelector - Table selector
|
||||
*/
|
||||
async function extractTableData(page, tableSelector) {
|
||||
await page.waitForSelector(tableSelector);
|
||||
|
||||
return await page.evaluate((selector) => {
|
||||
const table = document.querySelector(selector);
|
||||
if (!table) return null;
|
||||
|
||||
const headers = Array.from(table.querySelectorAll('thead th')).map(th =>
|
||||
th.textContent?.trim()
|
||||
);
|
||||
|
||||
const rows = Array.from(table.querySelectorAll('tbody tr')).map(tr => {
|
||||
const cells = Array.from(tr.querySelectorAll('td'));
|
||||
if (headers.length > 0) {
|
||||
return cells.reduce((obj, cell, index) => {
|
||||
obj[headers[index] || `column_${index}`] = cell.textContent?.trim();
|
||||
return obj;
|
||||
}, {});
|
||||
} else {
|
||||
return cells.map(cell => cell.textContent?.trim());
|
||||
}
|
||||
});
|
||||
|
||||
return { headers, rows };
|
||||
}, tableSelector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for and dismiss cookie banners
|
||||
* @param {Object} page - Playwright page
|
||||
* @param {number} timeout - Max time to wait
|
||||
*/
|
||||
async function handleCookieBanner(page, timeout = 3000) {
|
||||
const commonSelectors = [
|
||||
'button:has-text("Accept")',
|
||||
'button:has-text("Accept all")',
|
||||
'button:has-text("OK")',
|
||||
'button:has-text("Got it")',
|
||||
'button:has-text("I agree")',
|
||||
'.cookie-accept',
|
||||
'#cookie-accept',
|
||||
'[data-testid="cookie-accept"]'
|
||||
];
|
||||
|
||||
for (const selector of commonSelectors) {
|
||||
try {
|
||||
const element = await page.waitForSelector(selector, {
|
||||
timeout: timeout / commonSelectors.length,
|
||||
state: 'visible'
|
||||
});
|
||||
if (element) {
|
||||
await element.click();
|
||||
console.log('Cookie banner dismissed');
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
// Continue to next selector
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry a function with exponential backoff
|
||||
* @param {Function} fn - Function to retry
|
||||
* @param {number} maxRetries - Maximum retry attempts
|
||||
* @param {number} initialDelay - Initial delay in ms
|
||||
*/
|
||||
async function retryWithBackoff(fn, maxRetries = 3, initialDelay = 1000) {
|
||||
let lastError;
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const delay = initialDelay * Math.pow(2, i);
|
||||
console.log(`Attempt ${i + 1} failed, retrying in ${delay}ms...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create browser context with common settings
|
||||
* @param {Object} browser - Browser instance
|
||||
* @param {Object} options - Context options
|
||||
*/
|
||||
async function createContext(browser, options = {}) {
|
||||
const envHeaders = getExtraHeadersFromEnv();
|
||||
|
||||
// Merge environment headers with any passed in options
|
||||
const mergedHeaders = {
|
||||
...envHeaders,
|
||||
...options.extraHTTPHeaders
|
||||
};
|
||||
|
||||
const defaultOptions = {
|
||||
viewport: { width: 1280, height: 720 },
|
||||
userAgent: options.mobile
|
||||
? 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Mobile/15E148 Safari/604.1'
|
||||
: undefined,
|
||||
permissions: options.permissions || [],
|
||||
geolocation: options.geolocation,
|
||||
locale: options.locale || 'en-US',
|
||||
timezoneId: options.timezoneId || 'America/New_York',
|
||||
// Only include extraHTTPHeaders if we have any
|
||||
...(Object.keys(mergedHeaders).length > 0 && { extraHTTPHeaders: mergedHeaders })
|
||||
};
|
||||
|
||||
return await browser.newContext({ ...defaultOptions, ...options });
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect running dev servers on common ports
|
||||
* @param {Array<number>} customPorts - Additional ports to check
|
||||
* @returns {Promise<Array>} Array of detected server URLs
|
||||
*/
|
||||
async function detectDevServers(customPorts = []) {
|
||||
const http = require('http');
|
||||
|
||||
// Common dev server ports
|
||||
const commonPorts = [3000, 3001, 3002, 5173, 8080, 8000, 4200, 5000, 9000, 1234];
|
||||
const allPorts = [...new Set([...commonPorts, ...customPorts])];
|
||||
|
||||
const detectedServers = [];
|
||||
|
||||
console.log('🔍 Checking for running dev servers...');
|
||||
|
||||
for (const port of allPorts) {
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const req = http.request({
|
||||
hostname: 'localhost',
|
||||
port: port,
|
||||
path: '/',
|
||||
method: 'HEAD',
|
||||
timeout: 500
|
||||
}, (res) => {
|
||||
if (res.statusCode < 500) {
|
||||
detectedServers.push(`http://localhost:${port}`);
|
||||
console.log(` ✅ Found server on port ${port}`);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
|
||||
req.on('error', () => resolve());
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
resolve();
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
} catch (e) {
|
||||
// Port not available, continue
|
||||
}
|
||||
}
|
||||
|
||||
if (detectedServers.length === 0) {
|
||||
console.log(' ❌ No dev servers detected');
|
||||
}
|
||||
|
||||
return detectedServers;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
launchBrowser,
|
||||
createPage,
|
||||
waitForPageReady,
|
||||
safeClick,
|
||||
safeType,
|
||||
extractTexts,
|
||||
takeScreenshot,
|
||||
authenticate,
|
||||
scrollPage,
|
||||
extractTableData,
|
||||
handleCookieBanner,
|
||||
retryWithBackoff,
|
||||
createContext,
|
||||
detectDevServers,
|
||||
getExtraHeadersFromEnv
|
||||
};
|
||||
63
.agents/skills/playwright-skill/package-lock.json
generated
Normal file
63
.agents/skills/playwright-skill/package-lock.json
generated
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "playwright-skill",
|
||||
"version": "4.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "playwright-skill",
|
||||
"version": "4.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"playwright": "^1.57.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
26
.agents/skills/playwright-skill/package.json
Normal file
26
.agents/skills/playwright-skill/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "playwright-skill",
|
||||
"version": "4.1.0",
|
||||
"description": "General-purpose browser automation with Playwright for Claude Code with auto-detection and smart test management",
|
||||
"author": "lackeyjb",
|
||||
"main": "run.js",
|
||||
"scripts": {
|
||||
"setup": "npm install && npx playwright install chromium",
|
||||
"install-all-browsers": "npx playwright install chromium firefox webkit"
|
||||
},
|
||||
"keywords": [
|
||||
"playwright",
|
||||
"automation",
|
||||
"browser-testing",
|
||||
"web-automation",
|
||||
"claude-skill",
|
||||
"general-purpose"
|
||||
],
|
||||
"dependencies": {
|
||||
"playwright": "^1.57.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
228
.agents/skills/playwright-skill/run.js
Executable file
228
.agents/skills/playwright-skill/run.js
Executable file
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Universal Playwright Executor for Claude Code
|
||||
*
|
||||
* Executes Playwright automation code from:
|
||||
* - File path: node run.js script.js
|
||||
* - Inline code: node run.js 'await page.goto("...")'
|
||||
* - Stdin: cat script.js | node run.js
|
||||
*
|
||||
* Ensures proper module resolution by running from skill directory.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
// Change to skill directory for proper module resolution
|
||||
process.chdir(__dirname);
|
||||
|
||||
/**
|
||||
* Check if Playwright is installed
|
||||
*/
|
||||
function checkPlaywrightInstalled() {
|
||||
try {
|
||||
require.resolve('playwright');
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install Playwright if missing
|
||||
*/
|
||||
function installPlaywright() {
|
||||
console.log('📦 Playwright not found. Installing...');
|
||||
try {
|
||||
execSync('npm install', { stdio: 'inherit', cwd: __dirname });
|
||||
execSync('npx playwright install chromium', { stdio: 'inherit', cwd: __dirname });
|
||||
console.log('✅ Playwright installed successfully');
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('❌ Failed to install Playwright:', e.message);
|
||||
console.error('Please run manually: cd', __dirname, '&& npm run setup');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get code to execute from various sources
|
||||
*/
|
||||
function getCodeToExecute() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
// Case 1: File path provided
|
||||
if (args.length > 0 && fs.existsSync(args[0])) {
|
||||
const filePath = path.resolve(args[0]);
|
||||
console.log(`📄 Executing file: ${filePath}`);
|
||||
return fs.readFileSync(filePath, 'utf8');
|
||||
}
|
||||
|
||||
// Case 2: Inline code provided as argument
|
||||
if (args.length > 0) {
|
||||
console.log('⚡ Executing inline code');
|
||||
return args.join(' ');
|
||||
}
|
||||
|
||||
// Case 3: Code from stdin
|
||||
if (!process.stdin.isTTY) {
|
||||
console.log('📥 Reading from stdin');
|
||||
return fs.readFileSync(0, 'utf8');
|
||||
}
|
||||
|
||||
// No input
|
||||
console.error('❌ No code to execute');
|
||||
console.error('Usage:');
|
||||
console.error(' node run.js script.js # Execute file');
|
||||
console.error(' node run.js "code here" # Execute inline');
|
||||
console.error(' cat script.js | node run.js # Execute from stdin');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old temporary execution files from previous runs
|
||||
*/
|
||||
function cleanupOldTempFiles() {
|
||||
try {
|
||||
const files = fs.readdirSync(__dirname);
|
||||
const tempFiles = files.filter(f => f.startsWith('.temp-execution-') && f.endsWith('.js'));
|
||||
|
||||
if (tempFiles.length > 0) {
|
||||
tempFiles.forEach(file => {
|
||||
const filePath = path.join(__dirname, file);
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
} catch (e) {
|
||||
// Ignore errors - file might be in use or already deleted
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore directory read errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap code in async IIFE if not already wrapped
|
||||
*/
|
||||
function wrapCodeIfNeeded(code) {
|
||||
// Check if code already has require() and async structure
|
||||
const hasRequire = code.includes('require(');
|
||||
const hasAsyncIIFE = code.includes('(async () => {') || code.includes('(async()=>{');
|
||||
|
||||
// If it's already a complete script, return as-is
|
||||
if (hasRequire && hasAsyncIIFE) {
|
||||
return code;
|
||||
}
|
||||
|
||||
// If it's just Playwright commands, wrap in full template
|
||||
if (!hasRequire) {
|
||||
return `
|
||||
const { chromium, firefox, webkit, devices } = require('playwright');
|
||||
const helpers = require('./lib/helpers');
|
||||
|
||||
// Extra headers from environment variables (if configured)
|
||||
const __extraHeaders = helpers.getExtraHeadersFromEnv();
|
||||
|
||||
/**
|
||||
* Utility to merge environment headers into context options.
|
||||
* Use when creating contexts with raw Playwright API instead of helpers.createContext().
|
||||
* @param {Object} options - Context options
|
||||
* @returns {Object} Options with extraHTTPHeaders merged in
|
||||
*/
|
||||
function getContextOptionsWithHeaders(options = {}) {
|
||||
if (!__extraHeaders) return options;
|
||||
return {
|
||||
...options,
|
||||
extraHTTPHeaders: {
|
||||
...__extraHeaders,
|
||||
...(options.extraHTTPHeaders || {})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
${code}
|
||||
} catch (error) {
|
||||
console.error('❌ Automation error:', error.message);
|
||||
if (error.stack) {
|
||||
console.error(error.stack);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
`;
|
||||
}
|
||||
|
||||
// If has require but no async wrapper
|
||||
if (!hasAsyncIIFE) {
|
||||
return `
|
||||
(async () => {
|
||||
try {
|
||||
${code}
|
||||
} catch (error) {
|
||||
console.error('❌ Automation error:', error.message);
|
||||
if (error.stack) {
|
||||
console.error(error.stack);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
`;
|
||||
}
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main execution
|
||||
*/
|
||||
async function main() {
|
||||
console.log('🎭 Playwright Skill - Universal Executor\n');
|
||||
|
||||
// Clean up old temp files from previous runs
|
||||
cleanupOldTempFiles();
|
||||
|
||||
// Check Playwright installation
|
||||
if (!checkPlaywrightInstalled()) {
|
||||
const installed = installPlaywright();
|
||||
if (!installed) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Get code to execute
|
||||
const rawCode = getCodeToExecute();
|
||||
const code = wrapCodeIfNeeded(rawCode);
|
||||
|
||||
// Create temporary file for execution
|
||||
const tempFile = path.join(__dirname, `.temp-execution-${Date.now()}.js`);
|
||||
|
||||
try {
|
||||
// Write code to temp file
|
||||
fs.writeFileSync(tempFile, code, 'utf8');
|
||||
|
||||
// Execute the code
|
||||
console.log('🚀 Starting automation...\n');
|
||||
require(tempFile);
|
||||
|
||||
// Note: Temp file will be cleaned up on next run
|
||||
// This allows long-running async operations to complete safely
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Execution failed:', error.message);
|
||||
if (error.stack) {
|
||||
console.error('\n📋 Stack trace:');
|
||||
console.error(error.stack);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run main function
|
||||
main().catch(error => {
|
||||
console.error('❌ Fatal error:', error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
1
.claude/skills/playwright-skill
Symbolic link
1
.claude/skills/playwright-skill
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/playwright-skill
|
||||
@@ -49,7 +49,7 @@
|
||||
"@types/node": "^22.0.0",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"puppeteer": "^23.0.0",
|
||||
"playwright": "^1.50.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
|
||||
@@ -81,7 +81,7 @@ export class CategoriesService {
|
||||
}
|
||||
|
||||
// If still no categories, try EMEX fallback
|
||||
if (dbCategories.length === 0 && vehicle.vin && this.emexService.isSupported(vehicle.vin)) {
|
||||
if (dbCategories.length === 0 && vehicle.vin) {
|
||||
this.logger.log(`No PL24 categories for ${vehicle.vin}, trying EMEX fallback`);
|
||||
try {
|
||||
const emexResult = await this.emexService.decodeVin(vehicle.vin);
|
||||
@@ -105,6 +105,8 @@ export class CategoriesService {
|
||||
for (const node of nodes) {
|
||||
if (!node.name) continue;
|
||||
const isLeaf = !node.children?.length;
|
||||
// URL'si olmayan leaf node'lar dead-end — kaydetme
|
||||
if (isLeaf && !node.url) continue;
|
||||
|
||||
const [inserted] = await this.db
|
||||
.insert(categories)
|
||||
@@ -221,8 +223,8 @@ export class CategoriesService {
|
||||
return [];
|
||||
}
|
||||
|
||||
// BOM links are leaf categories — they return parts, not subgroups
|
||||
if (linkPath.includes("/bom/")) {
|
||||
// BOM / servicepart item links are leaf categories — they return parts, not subgroups
|
||||
if (linkPath.includes("/bom/") || linkPath.includes("/bomdetails") || linkPath.includes("/partinfo/") || linkPath.includes("/servicepart/vin_items")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -315,8 +317,15 @@ export class CategoriesService {
|
||||
.from(schemaPics)
|
||||
.where(eq(schemaPics.categoryId, categoryId));
|
||||
|
||||
// If no parts in DB, fetch from source
|
||||
if (dbParts.length === 0 && category.linkPath) {
|
||||
// Fetch parts and/or schema image from source if missing
|
||||
const needParts = dbParts.length === 0;
|
||||
const needImage = pics.length === 0;
|
||||
|
||||
if ((needParts || needImage) && !category.linkPath && category.source === "emex") {
|
||||
this.logger.warn(`EMEX leaf category ${categoryId} has no linkPath — dead-end node`);
|
||||
}
|
||||
|
||||
if ((needParts || needImage) && category.linkPath) {
|
||||
const [vehicle] = await this.db
|
||||
.select()
|
||||
.from(vehicles)
|
||||
@@ -337,7 +346,7 @@ export class CategoriesService {
|
||||
}
|
||||
}
|
||||
|
||||
if (emexResult.parts.length > 0) {
|
||||
if (needParts && emexResult.parts.length > 0) {
|
||||
const insertData = emexResult.parts.map((p) => ({
|
||||
vehicleId: vehicle.id,
|
||||
categoryId,
|
||||
@@ -356,7 +365,7 @@ export class CategoriesService {
|
||||
}
|
||||
|
||||
// Download schema image from img.laximo.net and upload to MinIO
|
||||
if (emexResult.schemaImageUrl && pics.length === 0) {
|
||||
if (needImage && emexResult.schemaImageUrl) {
|
||||
try {
|
||||
const imgResp = await fetch(emexResult.schemaImageUrl, {
|
||||
signal: AbortSignal.timeout(15000),
|
||||
@@ -400,7 +409,7 @@ export class CategoriesService {
|
||||
this.logger.error(`Failed to fetch EMEX parts for category ${categoryId}: ${(err as Error).message}`);
|
||||
}
|
||||
} else if (vehicle) {
|
||||
// PL24: fetch parts via PL24 API
|
||||
// PL24: fetch parts + schema image via PL24 API
|
||||
const rawData = vehicle.rawData as any;
|
||||
const catalogInfo = rawData?.catalogInfo;
|
||||
|
||||
@@ -412,7 +421,7 @@ export class CategoriesService {
|
||||
);
|
||||
|
||||
// Store parts
|
||||
if (pl24Result.parts.length > 0) {
|
||||
if (needParts && pl24Result.parts.length > 0) {
|
||||
const insertData = pl24Result.parts.map((p) => ({
|
||||
vehicleId: vehicle.id,
|
||||
categoryId,
|
||||
@@ -422,7 +431,10 @@ export class CategoriesService {
|
||||
description: p.description || null,
|
||||
quantity: p.quantity || null,
|
||||
position: p.positionCode || null,
|
||||
hotspotIndex: p.hotspotId ? parseInt(p.hotspotId, 10) || null : null,
|
||||
hotspotIndex: p.hotspotId ? (() => {
|
||||
const val = parseInt(p.hotspotId!, 10);
|
||||
return (val > 0 && val <= 2147483647) ? val : null;
|
||||
})() : null,
|
||||
source: "pl24" as const,
|
||||
}));
|
||||
|
||||
@@ -430,7 +442,7 @@ export class CategoriesService {
|
||||
}
|
||||
|
||||
// Store schema image if available
|
||||
if (pl24Result.schemaImageUrl && pics.length === 0) {
|
||||
if (needImage && pl24Result.schemaImageUrl) {
|
||||
const imageResult = await this.pl24Service.getSchemaImage(
|
||||
pl24Result.schemaImageUrl,
|
||||
catalogInfo.serviceName,
|
||||
@@ -566,8 +578,11 @@ export class CategoriesService {
|
||||
|
||||
return cats.map((c) => {
|
||||
const dbChildCount = childCountMap.get(c.id) || 0;
|
||||
// Leaf if: has BOM linkPath, OR has no linkPath and no DB children
|
||||
const isLeaf = c.linkPath?.includes("/bom/") || (!c.linkPath && dbChildCount === 0);
|
||||
// EMEX: leaf only if linkPath exists and no DB children
|
||||
// PL24: leaf if BOM/servicepart-items linkPath, or no linkPath and no DB children
|
||||
const isLeaf = c.source === "emex"
|
||||
? (!!c.linkPath && dbChildCount === 0)
|
||||
: (c.linkPath?.includes("/bom/") || c.linkPath?.includes("/bomdetails") || c.linkPath?.includes("/partinfo/") || c.linkPath?.includes("/servicepart/vin_items") || (!c.linkPath && dbChildCount === 0));
|
||||
return {
|
||||
...c,
|
||||
schemaImageUrl: picMap.get(c.id) || null,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Inject, Injectable } from "@nestjs/common";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { DATABASE, Database } from "../../database/database.provider";
|
||||
import { userSubscriptions, userBrands } from "../../database/schema/core";
|
||||
import { userSubscriptions, userBrands, plans } from "../../database/schema/core";
|
||||
|
||||
@Injectable()
|
||||
export class BrandAccessGuard implements CanActivate {
|
||||
@@ -22,8 +22,12 @@ export class BrandAccessGuard implements CanActivate {
|
||||
|
||||
// Check if user has an active subscription with access to this brand
|
||||
const activeSub = await this.db
|
||||
.select()
|
||||
.select({
|
||||
id: userSubscriptions.id,
|
||||
brandCount: plans.brandCount,
|
||||
})
|
||||
.from(userSubscriptions)
|
||||
.innerJoin(plans, eq(userSubscriptions.planId, plans.id))
|
||||
.where(and(eq(userSubscriptions.userId, user.id), eq(userSubscriptions.status, "active")))
|
||||
.limit(1);
|
||||
|
||||
@@ -31,10 +35,12 @@ export class BrandAccessGuard implements CanActivate {
|
||||
throw new ForbiddenException("No active subscription");
|
||||
}
|
||||
|
||||
// Check if the subscription includes this brand (brandCount=0 means all brands)
|
||||
const subscription = activeSub[0];
|
||||
|
||||
// Check userBrands junction
|
||||
// brandCount === 0 means unlimited (Full Paket) — all brands accessible
|
||||
if (subscription.brandCount === 0) return true;
|
||||
|
||||
// Check userBrands junction for limited plans
|
||||
const brandAccess = await this.db
|
||||
.select()
|
||||
.from(userBrands)
|
||||
|
||||
@@ -29,6 +29,8 @@ const BRANDS_DATA = [
|
||||
{ name: "Mini", slug: "mini" },
|
||||
{ name: "Dacia", slug: "dacia" },
|
||||
{ name: "Subaru", slug: "subaru" },
|
||||
{ name: "Suzuki", slug: "suzuki" },
|
||||
{ name: "Mitsubishi", slug: "mitsubishi" },
|
||||
];
|
||||
|
||||
const PLANS_DATA = [
|
||||
|
||||
@@ -34,7 +34,7 @@ const WMI_DATABASE: Record<string, string> = {
|
||||
// Kia
|
||||
KNA: "Kia", KND: "Kia",
|
||||
// Ford
|
||||
WF0: "Ford", "1FA": "Ford", "3FA": "Ford",
|
||||
WF0: "Ford", NM0: "Ford", "1FA": "Ford", "3FA": "Ford",
|
||||
// Opel
|
||||
W0L: "Opel",
|
||||
// Skoda
|
||||
@@ -46,7 +46,7 @@ const WMI_DATABASE: Record<string, string> = {
|
||||
// Nissan
|
||||
JN1: "Nissan", "1N4": "Nissan", "3N1": "Nissan",
|
||||
// Mazda
|
||||
JM1: "Mazda", JM3: "Mazda",
|
||||
JMZ: "Mazda", JM1: "Mazda", JM3: "Mazda",
|
||||
// Porsche
|
||||
WP0: "Porsche", WP1: "Porsche",
|
||||
// Land Rover
|
||||
@@ -59,6 +59,10 @@ const WMI_DATABASE: Record<string, string> = {
|
||||
UU1: "Dacia",
|
||||
// Subaru
|
||||
JF1: "Subaru", JF2: "Subaru",
|
||||
// Suzuki
|
||||
JS2: "Suzuki", JS3: "Suzuki", TSM: "Suzuki", MA3: "Suzuki", MBH: "Suzuki",
|
||||
// Mitsubishi
|
||||
JMB: "Mitsubishi", JMY: "Mitsubishi", MMB: "Mitsubishi", ML3: "Mitsubishi",
|
||||
};
|
||||
|
||||
const YEAR_MAP: Record<string, number> = {
|
||||
|
||||
286
apps/api/src/integrations/emex/emex.browser.ts
Normal file
286
apps/api/src/integrations/emex/emex.browser.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* EMEX Browser Service — Singleton Playwright browser manager
|
||||
*
|
||||
* Keeps a single Chromium instance alive for the app lifetime.
|
||||
* Each scraping request gets its own Page (tab) via acquirePage().
|
||||
* Session cookies are shared through a single BrowserContext.
|
||||
*
|
||||
* Features:
|
||||
* - Semaphore limits concurrent pages (default 3)
|
||||
* - Session cookie auto-refresh (25 min TTL)
|
||||
* - Crash recovery (auto-relaunch if browser disconnects)
|
||||
*/
|
||||
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleInit,
|
||||
OnModuleDestroy,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { Browser, BrowserContext, Page } from 'playwright';
|
||||
|
||||
const SESSION_TTL_MS = 25 * 60 * 1000; // 25 minutes
|
||||
const MAX_CONCURRENT_PAGES = 3;
|
||||
const EMEX_BASE_URL = 'https://emexdwc.ae';
|
||||
|
||||
/** Simple counting semaphore */
|
||||
class Semaphore {
|
||||
private current = 0;
|
||||
private queue: Array<() => void> = [];
|
||||
|
||||
constructor(private readonly max: number) {}
|
||||
|
||||
acquire(): Promise<void> {
|
||||
if (this.current < this.max) {
|
||||
this.current++;
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
this.queue.push(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
release(): void {
|
||||
const next = this.queue.shift();
|
||||
if (next) {
|
||||
next(); // hand slot to next waiter
|
||||
} else {
|
||||
this.current--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface AcquiredPage {
|
||||
page: Page;
|
||||
release: () => Promise<void>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(EmexBrowserService.name);
|
||||
private browser: Browser | null = null;
|
||||
private context: BrowserContext | null = null;
|
||||
private sessionExpiry = 0;
|
||||
private readonly semaphore: Semaphore;
|
||||
private launching: Promise<void> | null = null;
|
||||
|
||||
private readonly useProxy: boolean;
|
||||
private readonly proxyHost: string;
|
||||
private readonly proxyPortStart: number;
|
||||
private readonly proxyPortEnd: number;
|
||||
private readonly proxyUsername: string;
|
||||
private readonly proxyPassword: string;
|
||||
private startedAt = 0;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
this.semaphore = new Semaphore(MAX_CONCURRENT_PAGES);
|
||||
|
||||
this.useProxy =
|
||||
this.configService.get<string>('EMEX_USE_PROXY', 'false') === 'true';
|
||||
this.proxyHost = this.configService.get<string>(
|
||||
'EMEX_PROXY_HOST',
|
||||
'74.81.81.81',
|
||||
);
|
||||
this.proxyPortStart = this.configService.get<number>(
|
||||
'EMEX_PROXY_PORT_START',
|
||||
10000,
|
||||
);
|
||||
this.proxyPortEnd = this.configService.get<number>(
|
||||
'EMEX_PROXY_PORT_END',
|
||||
10099,
|
||||
);
|
||||
this.proxyUsername = this.configService.get<string>(
|
||||
'EMEX_PROXY_USER',
|
||||
'1726bbe361918676d44e',
|
||||
);
|
||||
this.proxyPassword = this.configService.get<string>(
|
||||
'EMEX_PROXY_PASS',
|
||||
'f11c7b6128cc86c6',
|
||||
);
|
||||
}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
try {
|
||||
await this.launchBrowser();
|
||||
this.logger.log('Browser launched on module init');
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
this.logger.error(
|
||||
`Failed to launch browser on init: ${e.message}`,
|
||||
e.stack,
|
||||
);
|
||||
// Non-fatal — will retry on first acquirePage()
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.closeBrowser();
|
||||
this.logger.log('Browser closed on module destroy');
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire a new Page (tab) from the shared browser.
|
||||
* Caller MUST call release() when done.
|
||||
*/
|
||||
async acquirePage(): Promise<AcquiredPage> {
|
||||
await this.semaphore.acquire();
|
||||
|
||||
try {
|
||||
await this.ensureBrowser();
|
||||
await this.ensureSession();
|
||||
|
||||
const page = await this.context!.newPage();
|
||||
|
||||
let released = false;
|
||||
const release = async () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
try {
|
||||
if (!page.isClosed()) {
|
||||
await page.close();
|
||||
}
|
||||
} catch {
|
||||
// page may already be closed
|
||||
}
|
||||
this.semaphore.release();
|
||||
};
|
||||
|
||||
return { page, release };
|
||||
} catch (err) {
|
||||
this.semaphore.release();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check for monitoring
|
||||
*/
|
||||
healthCheck(): {
|
||||
browserConnected: boolean;
|
||||
sessionValid: boolean;
|
||||
uptimeMs: number;
|
||||
} {
|
||||
return {
|
||||
browserConnected: this.browser?.isConnected() ?? false,
|
||||
sessionValid: Date.now() < this.sessionExpiry,
|
||||
uptimeMs: this.startedAt ? Date.now() - this.startedAt : 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Private ─────────────────────────────────────────────
|
||||
|
||||
private async launchBrowser(): Promise<void> {
|
||||
// Prevent duplicate launches
|
||||
if (this.launching) {
|
||||
return this.launching;
|
||||
}
|
||||
|
||||
this.launching = this._doLaunch();
|
||||
try {
|
||||
await this.launching;
|
||||
} finally {
|
||||
this.launching = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async _doLaunch(): Promise<void> {
|
||||
// Dynamic import — playwright is a devDependency
|
||||
const { chromium } = await import('playwright');
|
||||
|
||||
const launchOptions: Record<string, unknown> = {
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-accelerated-2d-canvas',
|
||||
'--disable-gpu',
|
||||
],
|
||||
};
|
||||
|
||||
if (this.useProxy) {
|
||||
const port = this.randomProxyPort();
|
||||
launchOptions.proxy = {
|
||||
server: `http://${this.proxyHost}:${port}`,
|
||||
username: this.proxyUsername,
|
||||
password: this.proxyPassword,
|
||||
};
|
||||
this.logger.log(`Using proxy: ${this.proxyHost}:${port}`);
|
||||
}
|
||||
|
||||
this.browser = await chromium.launch(launchOptions);
|
||||
this.context = await this.browser.newContext({
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
});
|
||||
|
||||
this.startedAt = Date.now();
|
||||
this.sessionExpiry = 0; // force session establish on first acquirePage
|
||||
|
||||
// Auto-recover on disconnect
|
||||
this.browser.on('disconnected', () => {
|
||||
this.logger.warn('Browser disconnected — will relaunch on next request');
|
||||
this.browser = null;
|
||||
this.context = null;
|
||||
this.sessionExpiry = 0;
|
||||
});
|
||||
}
|
||||
|
||||
private async closeBrowser(): Promise<void> {
|
||||
if (this.browser) {
|
||||
try {
|
||||
await this.browser.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
this.browser = null;
|
||||
this.context = null;
|
||||
this.sessionExpiry = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureBrowser(): Promise<void> {
|
||||
if (this.browser?.isConnected()) return;
|
||||
this.logger.log('Browser not connected — relaunching');
|
||||
await this.launchBrowser();
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit baseUrl to establish/refresh ASP.NET session cookie
|
||||
*/
|
||||
private async ensureSession(): Promise<void> {
|
||||
if (Date.now() < this.sessionExpiry) return;
|
||||
|
||||
this.logger.log('Establishing EMEX session...');
|
||||
const page = await this.context!.newPage();
|
||||
try {
|
||||
await page.goto(EMEX_BASE_URL, {
|
||||
waitUntil: 'networkidle',
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
const cookies = await this.context!.cookies();
|
||||
const session = cookies.find((c) => c.name === 'ASP.NET_SessionId');
|
||||
if (session) {
|
||||
this.sessionExpiry = Date.now() + SESSION_TTL_MS;
|
||||
this.logger.log('Session established, TTL 25 min');
|
||||
} else {
|
||||
this.logger.warn('No session cookie found after visiting baseUrl');
|
||||
// Still set a short TTL to avoid hammering
|
||||
this.sessionExpiry = Date.now() + 60_000;
|
||||
}
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
private randomProxyPort(): number {
|
||||
return (
|
||||
Math.floor(
|
||||
Math.random() * (this.proxyPortEnd - this.proxyPortStart + 1),
|
||||
) + this.proxyPortStart
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { EmexBrowserService } from "./emex.browser";
|
||||
import { EmexService } from "./emex.service";
|
||||
|
||||
@Module({
|
||||
providers: [EmexService],
|
||||
providers: [EmexBrowserService, EmexService],
|
||||
exports: [EmexService],
|
||||
})
|
||||
export class EmexModule {}
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
* NestJS service for emexdwc.ae VIN integration.
|
||||
* Wraps the EmexVinScraper from scripts/emex-vin-scraper.js
|
||||
* and provides standardized DecodedVehicle responses.
|
||||
*
|
||||
* Uses EmexBrowserService for a persistent singleton browser —
|
||||
* each request gets a pre-created Page (tab) via acquirePage().
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -12,7 +15,6 @@ import {
|
||||
BadRequestException,
|
||||
ServiceUnavailableException,
|
||||
InternalServerErrorException,
|
||||
OnModuleDestroy,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as path from 'path';
|
||||
@@ -25,10 +27,11 @@ import {
|
||||
CATALOG_MAP,
|
||||
} from './emex.types';
|
||||
import { mapEmexResponse, createEmptyDecodedVehicle } from './emex.mapper';
|
||||
import { EmexBrowserService } from './emex.browser';
|
||||
|
||||
// Type definition for the imported scraper module
|
||||
interface EmexScraperModule {
|
||||
EmexVinScraper: new () => EmexVinScraperInstance;
|
||||
EmexVinScraper: new (options?: { page?: unknown }) => EmexVinScraperInstance;
|
||||
getCatalogCode: (vin: string) => string | null;
|
||||
getYearFromVIN: (vin: string) => number | null;
|
||||
CONFIG: Record<string, unknown>;
|
||||
@@ -50,7 +53,7 @@ interface EmexVinScraperInstance {
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EmexService implements OnModuleDestroy {
|
||||
export class EmexService {
|
||||
private readonly logger = new Logger(EmexService.name);
|
||||
private scraperModule: EmexScraperModule | null = null;
|
||||
private isInitialized = false;
|
||||
@@ -60,7 +63,10 @@ export class EmexService implements OnModuleDestroy {
|
||||
private readonly timeout: number;
|
||||
private readonly debug: boolean;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private browserService: EmexBrowserService,
|
||||
) {
|
||||
// __dirname is apps/api/src/integrations/emex/ or dist/integrations/emex/
|
||||
// Scraper lives at <monorepo-root>/scripts/emex-vin-scraper.js
|
||||
const monorepoRoot = path.resolve(__dirname, '..', '..', '..', '..', '..');
|
||||
@@ -76,10 +82,6 @@ export class EmexService implements OnModuleDestroy {
|
||||
this.logger.log(`EMEX Service initialized with scraper path: ${this.scraperPath}`);
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
// Nothing to clean up — each scraper instance is created and closed per-call
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily initialize the scraper module
|
||||
*/
|
||||
@@ -127,18 +129,24 @@ export class EmexService implements OnModuleDestroy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new scraper instance and initializes browser
|
||||
* Creates a scraper instance bound to a pre-created page from the browser pool.
|
||||
* Returns { scraper, release } — caller MUST call release() in finally.
|
||||
*/
|
||||
private async createScraperInstance(): Promise<EmexVinScraperInstance> {
|
||||
private async createScraperInstance(): Promise<{
|
||||
scraper: EmexVinScraperInstance;
|
||||
release: () => Promise<void>;
|
||||
}> {
|
||||
await this.initializeScraper();
|
||||
|
||||
if (!this.scraperModule) {
|
||||
throw new InternalServerErrorException('EMEX scraper modulu yuklenemedi');
|
||||
}
|
||||
|
||||
const instance = new this.scraperModule.EmexVinScraper();
|
||||
await instance.init();
|
||||
return instance;
|
||||
const { page, release } = await this.browserService.acquirePage();
|
||||
const scraper = new this.scraperModule.EmexVinScraper({ page });
|
||||
// init() is a no-op in managed mode, but call it for consistency
|
||||
await scraper.init();
|
||||
return { scraper, release };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,19 +173,24 @@ export class EmexService implements OnModuleDestroy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a VIN number using EMEX scraper
|
||||
* Decodes a VIN number using EMEX scraper.
|
||||
* Works with or without a known catalog code — the scraper
|
||||
* falls back to VIN URL search which doesn't require one.
|
||||
*/
|
||||
async decodeVin(vin: string): Promise<DecodedVehicle> {
|
||||
const cleanVin = vin.toUpperCase().replace(/[^A-HJ-NPR-Z0-9]/g, '');
|
||||
|
||||
this.validateVin(cleanVin);
|
||||
|
||||
this.logger.log(`Decoding VIN: ${cleanVin}`);
|
||||
const supported = this.isSupported(cleanVin);
|
||||
this.logger.log(`Decoding VIN: ${cleanVin} (catalog supported: ${supported})`);
|
||||
|
||||
let scraper: EmexVinScraperInstance | null = null;
|
||||
let release: (() => Promise<void>) | null = null;
|
||||
|
||||
try {
|
||||
scraper = await this.createScraperInstance();
|
||||
const instance = await this.createScraperInstance();
|
||||
const scraper = instance.scraper;
|
||||
release = instance.release;
|
||||
|
||||
const response = await this.executeWithTimeout(
|
||||
scraper.searchByVIN(cleanVin),
|
||||
@@ -270,7 +283,7 @@ export class EmexService implements OnModuleDestroy {
|
||||
|
||||
if (
|
||||
err.message?.includes('browser') ||
|
||||
err.message?.includes('puppeteer') ||
|
||||
err.message?.includes('playwright') ||
|
||||
err.message?.includes('navigation')
|
||||
) {
|
||||
this.logger.error(`Browser error: ${err.message}`, err.stack);
|
||||
@@ -284,12 +297,12 @@ export class EmexService implements OnModuleDestroy {
|
||||
'VIN sorgulama sirasinda bir hata olustu',
|
||||
);
|
||||
} finally {
|
||||
if (scraper) {
|
||||
if (release) {
|
||||
try {
|
||||
await scraper.close();
|
||||
await release();
|
||||
} catch (closeError) {
|
||||
const err = closeError as Error;
|
||||
this.logger.warn(`Error closing scraper: ${err.message}`);
|
||||
this.logger.warn(`Error releasing page: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -363,10 +376,12 @@ export class EmexService implements OnModuleDestroy {
|
||||
|
||||
this.logger.log(`Fetching parts from category URL: ${categoryUrl}`);
|
||||
|
||||
let scraper: EmexVinScraperInstance | null = null;
|
||||
let release: (() => Promise<void>) | null = null;
|
||||
|
||||
try {
|
||||
scraper = await this.createScraperInstance();
|
||||
const instance = await this.createScraperInstance();
|
||||
const scraper = instance.scraper;
|
||||
release = instance.release;
|
||||
|
||||
const result = await this.executeWithTimeout(
|
||||
scraper.getParts(categoryUrl),
|
||||
@@ -387,12 +402,12 @@ export class EmexService implements OnModuleDestroy {
|
||||
this.logger.error(`Failed to fetch category parts: ${err.message}`);
|
||||
return { parts: [], schemaImageUrl: null, hotspots: [], schemaWidth: 0, schemaHeight: 0 };
|
||||
} finally {
|
||||
if (scraper) {
|
||||
if (release) {
|
||||
try {
|
||||
await scraper.close();
|
||||
await release();
|
||||
} catch (closeError) {
|
||||
const err = closeError as Error;
|
||||
this.logger.warn(`Error closing scraper: ${err.message}`);
|
||||
this.logger.warn(`Error releasing page: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,6 +247,7 @@ export const CATALOG_MAP: Record<string, CatalogEntry> = {
|
||||
ZFA: { code: 'CFIAT84', brand: 'Fiat' },
|
||||
ZAR: { code: 'RFIAT84', brand: 'Alfa Romeo' },
|
||||
WF0: { code: 'FORD202201', brand: 'Ford' },
|
||||
NM0: { code: 'FORD202201', brand: 'Ford' },
|
||||
JTD: { code: 'TOYOTA00', brand: 'Toyota' },
|
||||
JTE: { code: 'TOYOTA00', brand: 'Toyota' },
|
||||
SHH: { code: 'HONDA00', brand: 'Honda' },
|
||||
@@ -256,4 +257,7 @@ export const CATALOG_MAP: Record<string, CatalogEntry> = {
|
||||
WP1: { code: 'PO799', brand: 'Porsche' },
|
||||
JF1: { code: 'SUBARU201802', brand: 'Subaru' },
|
||||
JF2: { code: 'SUBARU201802', brand: 'Subaru' },
|
||||
JMZ: { code: 'MAZDA2020', brand: 'Mazda' },
|
||||
JM1: { code: 'MAZDA2020', brand: 'Mazda' },
|
||||
JM3: { code: 'MAZDA2020', brand: 'Mazda' },
|
||||
};
|
||||
|
||||
@@ -276,6 +276,34 @@ export class PL24AuthService {
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build headers for Ford legacy HTML page requests.
|
||||
* Uses text/html Accept instead of application/json.
|
||||
*/
|
||||
async buildFordLegacyHeaders(
|
||||
serviceName: string,
|
||||
): Promise<Record<string, string>> {
|
||||
const token = await this.authorizeService(serviceName);
|
||||
const sessionCookie = await this.getSessionCookie();
|
||||
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Cookie: sessionCookie,
|
||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PL24TOKEN cookie value for Ford hintstoken parameter.
|
||||
*/
|
||||
getPL24TokenValue(): string | null {
|
||||
if (!this.tokenData?.sessionCookie) return null;
|
||||
const match = this.tokenData.sessionCookie.match(/PL24TOKEN=([^;]+)/);
|
||||
return match?.[1] || null;
|
||||
}
|
||||
|
||||
private isTokenValid(token: PL24TokenData): boolean {
|
||||
const bufferMs = 60 * 1000;
|
||||
return token.expiresAt.getTime() - bufferMs > Date.now();
|
||||
|
||||
692
apps/api/src/integrations/pl24/pl24-ford-legacy.service.ts
Normal file
692
apps/api/src/integrations/pl24/pl24-ford-legacy.service.ts
Normal file
@@ -0,0 +1,692 @@
|
||||
/**
|
||||
* Ford Legacy PL24 Service
|
||||
*
|
||||
* Handles Ford VIN decode and parts catalog via PL24's legacy .action endpoints.
|
||||
* Ford uses server-rendered HTML with embedded JavaScript variables instead of JSON APIs.
|
||||
* HTML parsing via regex + string extraction (no external deps like cheerio).
|
||||
*/
|
||||
|
||||
import { createHash } from "crypto";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { PL24AuthService } from "./pl24-auth.service";
|
||||
import { RedisService } from "../../redis/redis.service";
|
||||
import { StorageService } from "../../storage/storage.service";
|
||||
import { PL24_DEFAULTS } from "./pl24.constants";
|
||||
import {
|
||||
PL24DecodedVehicle,
|
||||
PL24DecodedCategory,
|
||||
PL24PartsResponse,
|
||||
PL24Part,
|
||||
PL24MainGroup,
|
||||
SERVICE_TO_BRAND,
|
||||
} from "./pl24.types";
|
||||
import {
|
||||
FORD_LEGACY_ENDPOINTS,
|
||||
type FordPL24Support,
|
||||
} from "./pl24-ford-legacy.types";
|
||||
|
||||
@Injectable()
|
||||
export class PL24FordLegacyService {
|
||||
private readonly logger = new Logger(PL24FordLegacyService.name);
|
||||
private readonly baseUrl: string;
|
||||
private readonly timeout: number;
|
||||
private readonly language = "tr";
|
||||
|
||||
constructor(
|
||||
private readonly authService: PL24AuthService,
|
||||
private configService: ConfigService,
|
||||
private redis: RedisService,
|
||||
private storage: StorageService,
|
||||
) {
|
||||
this.baseUrl = this.configService.get<string>(
|
||||
"pl24.baseUrl",
|
||||
"https://www.partslink24.com",
|
||||
);
|
||||
this.timeout = 30000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a Ford VIN via legacy .action endpoints.
|
||||
* Returns PL24DecodedVehicle or null (triggers EMEX fallback).
|
||||
*/
|
||||
async decodeVin(vin: string): Promise<PL24DecodedVehicle | null> {
|
||||
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}vehicle:${vin}`;
|
||||
const cached = await this.redis.getJson<PL24DecodedVehicle>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
this.logger.log(`Ford legacy: decoding VIN ${vin}`);
|
||||
|
||||
try {
|
||||
// Step 1: Authorize for Ford service
|
||||
await this.authService.authorizeService("fordt_parts");
|
||||
|
||||
// Step 2: Fetch VIN group page
|
||||
const html = await this.fetchVinGroupPage(vin);
|
||||
if (!html) return null;
|
||||
|
||||
// Step 3: Check if we got demo mode (not authenticated properly)
|
||||
const support = this.extractScriptVariable<FordPL24Support>(html, "PL24_SUPPORT");
|
||||
if (support?.demo || support?.role === "NOT_LOGGED_IN_DEMO") {
|
||||
this.logger.warn("Ford legacy: got demo mode, retrying with fresh auth");
|
||||
this.authService.clearTokens();
|
||||
await this.authService.authorizeService("fordt_parts");
|
||||
|
||||
const retryHtml = await this.fetchVinGroupPage(vin);
|
||||
if (!retryHtml) return null;
|
||||
|
||||
const retrySupport = this.extractScriptVariable<FordPL24Support>(retryHtml, "PL24_SUPPORT");
|
||||
if (retrySupport?.demo || retrySupport?.role === "NOT_LOGGED_IN_DEMO") {
|
||||
this.logger.warn("Ford legacy: still demo after retry, returning null");
|
||||
return null;
|
||||
}
|
||||
return this.parseAndCacheVehicle(retryHtml, vin, cacheKey);
|
||||
}
|
||||
|
||||
return this.parseAndCacheVehicle(html, vin, cacheKey);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error(`Ford legacy VIN decode error: ${err.message}`, err.stack);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch sub-groups for a Ford legacy link path.
|
||||
*/
|
||||
async fetchSubGroupsByPath(
|
||||
linkPath: string,
|
||||
serviceName: string,
|
||||
): Promise<PL24MainGroup[]> {
|
||||
const pathHash = createHash("sha256").update(linkPath).digest("hex").substring(0, 16);
|
||||
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}ford:subgroups:${pathHash}`;
|
||||
const cached = await this.redis.getJson<PL24MainGroup[]>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
this.logger.log(`Ford legacy: fetching sub-groups from ${linkPath}`);
|
||||
|
||||
try {
|
||||
const html = await this.fetchFordPage(linkPath, serviceName);
|
||||
if (!html) return [];
|
||||
|
||||
const links = this.extractLinks(html, /\.action/);
|
||||
const groups: PL24MainGroup[] = links.map((link, idx) => ({
|
||||
id: String(idx),
|
||||
code: this.extractCodeFromText(link.text) || String(idx),
|
||||
name: link.text.replace(/^\d+\s+/, "").trim() || `Group ${idx}`,
|
||||
linkPath: link.href,
|
||||
}));
|
||||
|
||||
if (groups.length > 0) {
|
||||
await this.redis.setJson(cacheKey, groups, 86400);
|
||||
}
|
||||
return groups;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error(`Ford legacy sub-groups error: ${err.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch parts for a Ford legacy link path.
|
||||
*/
|
||||
async fetchPartsByPath(
|
||||
linkPath: string,
|
||||
serviceName: string,
|
||||
): Promise<PL24PartsResponse> {
|
||||
const pathHash = createHash("sha256").update(linkPath).digest("hex").substring(0, 16);
|
||||
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}ford:parts:${pathHash}`;
|
||||
const cached = await this.redis.getJson<PL24PartsResponse>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
this.logger.log(`Ford legacy: fetching parts from ${linkPath}`);
|
||||
|
||||
try {
|
||||
const html = await this.fetchFordPage(linkPath, serviceName);
|
||||
if (!html) {
|
||||
return { success: false, groupId: "", groupName: "", parts: [] };
|
||||
}
|
||||
|
||||
const parts = this.parsePartsFromHtml(html);
|
||||
const groupName = this.extractPageTitle(html);
|
||||
const schemaImageUrl = this.extractSchemaImageUrl(html);
|
||||
|
||||
const result: PL24PartsResponse = {
|
||||
success: true,
|
||||
groupId: pathHash,
|
||||
groupName,
|
||||
schemaImageUrl: schemaImageUrl || undefined,
|
||||
parts,
|
||||
};
|
||||
|
||||
if (parts.length > 0) {
|
||||
await this.redis.setJson(cacheKey, result, 3600);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error(`Ford legacy parts error: ${err.message}`);
|
||||
return { success: false, groupId: "", groupName: "", parts: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== PRIVATE: Page fetching ====================
|
||||
|
||||
/**
|
||||
* Fetch the VIN group page for a Ford VIN.
|
||||
*/
|
||||
private async fetchVinGroupPage(vin: string): Promise<string | null> {
|
||||
const token = this.authService.getPL24TokenValue();
|
||||
const params = new URLSearchParams({
|
||||
vin,
|
||||
lang: this.language,
|
||||
...(token ? { hintstoken: token } : {}),
|
||||
});
|
||||
|
||||
const url = `${this.baseUrl}${FORD_LEGACY_ENDPOINTS.VIN_GROUP}?${params}`;
|
||||
return this.fetchFordPage(url, "fordt_parts", true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a Ford legacy page (HTML). Handles 401 retry.
|
||||
*/
|
||||
private async fetchFordPage(
|
||||
url: string,
|
||||
serviceName: string,
|
||||
isFullUrl = false,
|
||||
): Promise<string | null> {
|
||||
const fullUrl = isFullUrl ? url : `${this.baseUrl}${url}`;
|
||||
const headers = await this.authService.buildFordLegacyHeaders(serviceName);
|
||||
|
||||
try {
|
||||
let response = await fetch(fullUrl, {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
redirect: "follow",
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
this.logger.warn("Ford legacy: 401, refreshing auth");
|
||||
this.authService.clearTokens();
|
||||
await this.authService.authorizeService(serviceName);
|
||||
const newHeaders = await this.authService.buildFordLegacyHeaders(serviceName);
|
||||
|
||||
response = await fetch(fullUrl, {
|
||||
method: "GET",
|
||||
headers: newHeaders,
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
redirect: "follow",
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
this.logger.warn(`Ford legacy: HTTP ${response.status} for ${fullUrl}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
// Some Ford endpoints may return JSON (e.g., model-config)
|
||||
if (contentType.includes("application/json")) {
|
||||
const json = await response.json();
|
||||
return JSON.stringify(json);
|
||||
}
|
||||
|
||||
return await response.text();
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error(`Ford legacy fetch error: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== PRIVATE: HTML parsing helpers ====================
|
||||
|
||||
/**
|
||||
* Extract a JavaScript variable from HTML <script> blocks.
|
||||
* Matches: window.VAR_NAME = {...}; or var VAR_NAME = {...};
|
||||
*/
|
||||
extractScriptVariable<T = unknown>(html: string, varName: string): T | null {
|
||||
// Match window.VAR = value; or var VAR = value;
|
||||
const patterns = [
|
||||
new RegExp(`window\\.${varName}\\s*=\\s*({[\\s\\S]*?});`, "m"),
|
||||
new RegExp(`window\\.${varName}\\s*=\\s*(\\[[\\s\\S]*?\\]);`, "m"),
|
||||
new RegExp(`var\\s+${varName}\\s*=\\s*({[\\s\\S]*?});`, "m"),
|
||||
new RegExp(`var\\s+${varName}\\s*=\\s*(\\[[\\s\\S]*?\\]);`, "m"),
|
||||
// Single-quoted string values
|
||||
new RegExp(`window\\.${varName}\\s*=\\s*'([^']*)'`, "m"),
|
||||
new RegExp(`window\\.${varName}\\s*=\\s*"([^"]*)"`, "m"),
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = html.match(pattern);
|
||||
if (match?.[1]) {
|
||||
try {
|
||||
// Try JSON parse first (handles objects and arrays)
|
||||
return JSON.parse(match[1]) as T;
|
||||
} catch {
|
||||
// For string values, return as-is
|
||||
return match[1] as T;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract <a> links matching a pattern from HTML.
|
||||
*/
|
||||
extractLinks(html: string, pattern: RegExp): { href: string; text: string }[] {
|
||||
const linkRegex = /<a\s+[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi;
|
||||
const results: { href: string; text: string }[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = linkRegex.exec(html)) !== null) {
|
||||
const href = match[1];
|
||||
const text = match[2].replace(/<[^>]+>/g, "").trim();
|
||||
if (pattern.test(href) && text) {
|
||||
results.push({ href, text });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract table rows from HTML <table> as objects.
|
||||
* Uses first row as headers.
|
||||
*/
|
||||
extractTableRows(html: string): Record<string, string>[] {
|
||||
// Find all tables
|
||||
const tableRegex = /<table[^>]*>([\s\S]*?)<\/table>/gi;
|
||||
const tableMatch = tableRegex.exec(html);
|
||||
if (!tableMatch) return [];
|
||||
|
||||
const tableHtml = tableMatch[1];
|
||||
|
||||
// Extract header cells
|
||||
const headerRegex = /<th[^>]*>([\s\S]*?)<\/th>/gi;
|
||||
const headers: string[] = [];
|
||||
let hMatch: RegExpExecArray | null;
|
||||
while ((hMatch = headerRegex.exec(tableHtml)) !== null) {
|
||||
headers.push(hMatch[1].replace(/<[^>]+>/g, "").trim());
|
||||
}
|
||||
|
||||
// Extract rows
|
||||
const rowRegex = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
|
||||
const rows: Record<string, string>[] = [];
|
||||
let rMatch: RegExpExecArray | null;
|
||||
let rowIndex = 0;
|
||||
|
||||
while ((rMatch = rowRegex.exec(tableHtml)) !== null) {
|
||||
const cellRegex = /<td[^>]*>([\s\S]*?)<\/td>/gi;
|
||||
const cells: string[] = [];
|
||||
let cMatch: RegExpExecArray | null;
|
||||
while ((cMatch = cellRegex.exec(rMatch[1])) !== null) {
|
||||
cells.push(cMatch[1].replace(/<[^>]+>/g, "").trim());
|
||||
}
|
||||
|
||||
if (cells.length > 0) {
|
||||
const row: Record<string, string> = {};
|
||||
cells.forEach((cell, idx) => {
|
||||
const key = headers[idx] || `col${idx}`;
|
||||
row[key] = cell;
|
||||
});
|
||||
rows.push(row);
|
||||
}
|
||||
rowIndex++;
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ==================== PRIVATE: Ford-specific parsing ====================
|
||||
|
||||
/**
|
||||
* Parse vehicle info + categories from VIN group HTML and cache result.
|
||||
*/
|
||||
private async parseAndCacheVehicle(
|
||||
html: string,
|
||||
vin: string,
|
||||
cacheKey: string,
|
||||
): Promise<PL24DecodedVehicle | null> {
|
||||
const vehicle = this.parseFordVehicleResponse(html, vin);
|
||||
if (!vehicle) return null;
|
||||
|
||||
const categories = this.parseFordCategories(html);
|
||||
|
||||
const result: PL24DecodedVehicle = {
|
||||
...vehicle,
|
||||
categories,
|
||||
};
|
||||
|
||||
await this.redis.setJson(cacheKey, result, 86400);
|
||||
this.logger.log(
|
||||
`Ford legacy: decoded ${vin} - ${vehicle.brand} ${vehicle.model} ${vehicle.year}, ${categories.length} categories`,
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Ford vehicle info from VIN group HTML.
|
||||
* Extracts from window.vehicles, data tables, or page content.
|
||||
*/
|
||||
private parseFordVehicleResponse(
|
||||
html: string,
|
||||
vin: string,
|
||||
): Omit<PL24DecodedVehicle, "categories"> | null {
|
||||
// Try extracting vehicle data from embedded JS
|
||||
const vehicles = this.extractScriptVariable<Array<Record<string, string>>>(html, "vehicles");
|
||||
const vehicleData = vehicles?.[0] || null;
|
||||
|
||||
// Try extracting from vehicle info table
|
||||
const tableRows = this.extractTableRows(html);
|
||||
|
||||
// Build vehicle info from whatever we found
|
||||
let model = "";
|
||||
let year = 0;
|
||||
let bodyType: string | null = null;
|
||||
let engineCode: string | null = null;
|
||||
let engineType: string | null = null;
|
||||
|
||||
if (vehicleData) {
|
||||
model = vehicleData.model || vehicleData.modelName || vehicleData.description || "";
|
||||
year = parseInt(vehicleData.year || vehicleData.modelYear || "", 10) || 0;
|
||||
bodyType = vehicleData.bodyStyle || vehicleData.body || null;
|
||||
engineCode = vehicleData.engineCode || vehicleData.engine || null;
|
||||
engineType = vehicleData.engineDescription || vehicleData.engineType || null;
|
||||
}
|
||||
|
||||
// Try to extract from page title or description
|
||||
if (!model) {
|
||||
const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
||||
if (titleMatch) {
|
||||
const title = titleMatch[1].replace(/<[^>]+>/g, "").trim();
|
||||
// Ford titles often have format: "Model Year - Parts"
|
||||
const parts = title.split(/[-–—]/);
|
||||
if (parts.length > 0) {
|
||||
model = parts[0].trim().replace(/Ford\s*/i, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try extracting from breadcrumb or header
|
||||
if (!model) {
|
||||
const headerMatch = html.match(/<h[12][^>]*>([\s\S]*?)<\/h[12]>/i);
|
||||
if (headerMatch) {
|
||||
model = headerMatch[1].replace(/<[^>]+>/g, "").trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Try extracting from vehicle info section
|
||||
if (!model || !year) {
|
||||
for (const row of tableRows) {
|
||||
const values = Object.values(row);
|
||||
const keys = Object.keys(row);
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const key = keys[i].toLowerCase();
|
||||
if (!model && (key.includes("model") || key.includes("arac"))) {
|
||||
model = values[i] || model;
|
||||
}
|
||||
if (!year && (key.includes("year") || key.includes("yil") || key.includes("yıl"))) {
|
||||
year = parseInt(values[i], 10) || year;
|
||||
}
|
||||
if (!engineCode && (key.includes("engine") || key.includes("motor"))) {
|
||||
engineCode = values[i] || engineCode;
|
||||
}
|
||||
if (!bodyType && (key.includes("body") || key.includes("kasa"))) {
|
||||
bodyType = values[i] || bodyType;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: year from VIN position 10
|
||||
if (!year) {
|
||||
year = this.getYearFromVin(vin);
|
||||
}
|
||||
|
||||
// If we still have nothing, at least return basic info
|
||||
if (!model && !vehicleData && tableRows.length === 0) {
|
||||
// Check if we got a valid page at all
|
||||
if (!html.includes("ford") && !html.includes("Ford")) {
|
||||
this.logger.warn("Ford legacy: page doesn't contain Ford data");
|
||||
return null;
|
||||
}
|
||||
model = "Ford";
|
||||
}
|
||||
|
||||
return {
|
||||
brand: SERVICE_TO_BRAND["fordt_parts"] || "Ford",
|
||||
model,
|
||||
year,
|
||||
series: null,
|
||||
bodyType,
|
||||
engineCode,
|
||||
engineType,
|
||||
engineVolume: null,
|
||||
transmission: null,
|
||||
driveType: null,
|
||||
colorCode: null,
|
||||
productionDate: null,
|
||||
raw: { html_length: html.length, has_vehicles_var: !!vehicleData },
|
||||
catalogInfo: {
|
||||
serviceName: "fordt_parts",
|
||||
vehicleId: vin,
|
||||
catalogPath: "/ford/fordt_parts",
|
||||
baseUrl: this.baseUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse categories from Ford VIN group HTML.
|
||||
* Categories appear as links to .action endpoints in the page.
|
||||
*/
|
||||
private parseFordCategories(html: string): PL24DecodedCategory[] {
|
||||
// Try to extract category links from the page
|
||||
// Ford categories are typically in a navigation list or table
|
||||
const categoryLinks = this.extractLinks(html, /\.action/);
|
||||
|
||||
// Filter to only category-like links (exclude navigation/auth links)
|
||||
const filtered = categoryLinks.filter((link) => {
|
||||
const href = link.href.toLowerCase();
|
||||
// Include group/category navigation links
|
||||
if (
|
||||
href.includes("group") ||
|
||||
href.includes("category") ||
|
||||
href.includes("maingroup") ||
|
||||
href.includes("parts-group")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// Exclude login, keep-alive, etc.
|
||||
if (
|
||||
href.includes("login") ||
|
||||
href.includes("keep-alive") ||
|
||||
href.includes("logout") ||
|
||||
href.includes("json-")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Include if it looks like a content link with text
|
||||
return link.text.length > 2 && href.includes(".action");
|
||||
});
|
||||
|
||||
// Deduplicate by href
|
||||
const seen = new Set<string>();
|
||||
const unique = filtered.filter((link) => {
|
||||
if (seen.has(link.href)) return false;
|
||||
seen.add(link.href);
|
||||
return true;
|
||||
});
|
||||
|
||||
return unique.map((link, idx) => {
|
||||
const code = this.extractCodeFromText(link.text) || String(idx + 1);
|
||||
const name = link.text.replace(/^\d+\s+/, "").trim();
|
||||
|
||||
return {
|
||||
code,
|
||||
nameEn: name || code,
|
||||
nameTr: name || code,
|
||||
description: null,
|
||||
iconUrl: null,
|
||||
subGroups: [],
|
||||
linkPath: link.href,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse parts from Ford HTML table.
|
||||
*/
|
||||
private parsePartsFromHtml(html: string): PL24Part[] {
|
||||
const rows = this.extractTableRows(html);
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const parts: PL24Part[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
// Try to find OEM code column (various possible names)
|
||||
const oemCode = this.findColumnValue(row, [
|
||||
"partno", "part_no", "part number", "parca no", "parça no",
|
||||
"oem", "oemcode", "code", "kod", "no",
|
||||
]);
|
||||
|
||||
if (!oemCode) continue;
|
||||
|
||||
const cleanOem = oemCode.replace(/\s+/g, "");
|
||||
const name = this.findColumnValue(row, [
|
||||
"description", "name", "aciklama", "açıklama", "tanim", "tanım",
|
||||
"descr", "part name", "parca adi", "parça adı",
|
||||
]) || "";
|
||||
|
||||
const positionCode = this.findColumnValue(row, [
|
||||
"pos", "position", "pozisyon", "no", "sira",
|
||||
]) || "";
|
||||
|
||||
const qtyStr = this.findColumnValue(row, [
|
||||
"qty", "quantity", "miktar", "adet", "count",
|
||||
]) || "";
|
||||
const quantity = parseInt(qtyStr, 10) || undefined;
|
||||
|
||||
const remark = this.findColumnValue(row, [
|
||||
"remark", "remarks", "note", "notes", "not", "aciklama2",
|
||||
]) || undefined;
|
||||
|
||||
parts.push({
|
||||
id: cleanOem,
|
||||
oemCode: cleanOem,
|
||||
formattedPartNo: oemCode,
|
||||
name,
|
||||
description: name,
|
||||
positionCode: positionCode || undefined,
|
||||
quantity,
|
||||
remark,
|
||||
});
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract schema/illustration image URL from HTML.
|
||||
*/
|
||||
private extractSchemaImageUrl(html: string): string | null {
|
||||
// Look for illustration images
|
||||
const imgPatterns = [
|
||||
/<img[^>]+src=["']([^"']*(?:illustration|schema|diagram|exploded)[^"']*)["']/i,
|
||||
/<img[^>]+src=["']([^"']*(?:\.png|\.jpg|\.gif|\.svg)[^"']*)["'][^>]*class=["'][^"']*(?:schema|illus|diagram)/i,
|
||||
/<img[^>]+id=["'](?:schema|illustration|diagram)["'][^>]*src=["']([^"']+)["']/i,
|
||||
];
|
||||
|
||||
for (const pattern of imgPatterns) {
|
||||
const match = html.match(pattern);
|
||||
if (match?.[1]) {
|
||||
const src = match[1];
|
||||
return src.startsWith("http") ? src : `${this.baseUrl}${src}`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract page title from HTML.
|
||||
*/
|
||||
private extractPageTitle(html: string): string {
|
||||
const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
||||
if (titleMatch) {
|
||||
return titleMatch[1].replace(/<[^>]+>/g, "").trim();
|
||||
}
|
||||
|
||||
const h1Match = html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i);
|
||||
if (h1Match) {
|
||||
return h1Match[1].replace(/<[^>]+>/g, "").trim();
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
// ==================== PRIVATE: Utility ====================
|
||||
|
||||
/**
|
||||
* Find a column value in a row by trying multiple possible column names.
|
||||
*/
|
||||
private findColumnValue(
|
||||
row: Record<string, string>,
|
||||
possibleKeys: string[],
|
||||
): string | null {
|
||||
// Try exact match first
|
||||
for (const key of possibleKeys) {
|
||||
if (row[key]) return row[key];
|
||||
}
|
||||
|
||||
// Try case-insensitive match
|
||||
const rowKeys = Object.keys(row);
|
||||
for (const key of possibleKeys) {
|
||||
const found = rowKeys.find(
|
||||
(k) => k.toLowerCase() === key.toLowerCase(),
|
||||
);
|
||||
if (found && row[found]) return row[found];
|
||||
}
|
||||
|
||||
// Try partial match
|
||||
for (const key of possibleKeys) {
|
||||
const found = rowKeys.find(
|
||||
(k) => k.toLowerCase().includes(key.toLowerCase()),
|
||||
);
|
||||
if (found && row[found]) return row[found];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract numeric code from category text (e.g., "01 Engine" → "01").
|
||||
*/
|
||||
private extractCodeFromText(text: string): string {
|
||||
const match = text.match(/^(\d+)\s/);
|
||||
return match?.[1] || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get year from VIN position 10.
|
||||
*/
|
||||
private getYearFromVin(vin: string): number {
|
||||
if (!vin || vin.length < 10) return 0;
|
||||
const yearChar = vin.charAt(9).toUpperCase();
|
||||
const yearMap: Record<string, number> = {
|
||||
"1": 2001, "2": 2002, "3": 2003, "4": 2004, "5": 2005,
|
||||
"6": 2006, "7": 2007, "8": 2008, "9": 2009,
|
||||
A: 2010, B: 2011, C: 2012, D: 2013, E: 2014, F: 2015,
|
||||
G: 2016, H: 2017, J: 2018, K: 2019, L: 2020, M: 2021,
|
||||
N: 2022, P: 2023, R: 2024, S: 2025, T: 2026, V: 2027,
|
||||
W: 2028, X: 2029, Y: 2030,
|
||||
};
|
||||
return yearMap[yearChar] || 0;
|
||||
}
|
||||
}
|
||||
38
apps/api/src/integrations/pl24/pl24-ford-legacy.types.ts
Normal file
38
apps/api/src/integrations/pl24/pl24-ford-legacy.types.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Ford Legacy PL24 Types
|
||||
*
|
||||
* Ford uses a legacy JSP/Struts architecture on PL24 with .action endpoints
|
||||
* and server-rendered HTML containing embedded JavaScript variables.
|
||||
*/
|
||||
|
||||
export interface FordPL24Support {
|
||||
role: string; // "NOT_LOGGED_IN_DEMO" or authenticated role
|
||||
locale: string; // "tr"
|
||||
mode: string; // e.g. "A0LW0TRTR"
|
||||
action: string;
|
||||
contextPath: string; // "/ford"
|
||||
demo: boolean;
|
||||
}
|
||||
|
||||
export interface FordVehicleInfo {
|
||||
vin: string;
|
||||
brand: string;
|
||||
model: string;
|
||||
year: number;
|
||||
bodyType: string | null;
|
||||
engineCode: string | null;
|
||||
engineType: string | null;
|
||||
}
|
||||
|
||||
export interface FordCategoryLink {
|
||||
href: string;
|
||||
text: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export const FORD_LEGACY_ENDPOINTS = {
|
||||
VIN_GROUP: "/ford/fordt_parts/vin-group.action",
|
||||
VEHICLE: "/ford/vehicle.action",
|
||||
MODEL_CONFIG: "/ford/json-model-config.action",
|
||||
KEEP_ALIVE: "/ford/json-keep-alive.action",
|
||||
} as const;
|
||||
@@ -15,4 +15,10 @@ export const PL24_ENDPOINTS = {
|
||||
|
||||
// Image server
|
||||
IMAGESERVER: "/imageserver/ext/api/images",
|
||||
|
||||
// Ford Legacy (.action endpoints)
|
||||
FORD_VIN_GROUP: "/ford/fordt_parts/vin-group.action",
|
||||
FORD_VEHICLE: "/ford/vehicle.action",
|
||||
FORD_MODEL_CONFIG: "/ford/json-model-config.action",
|
||||
FORD_KEEP_ALIVE: "/ford/json-keep-alive.action",
|
||||
} as const;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PL24Service } from "./pl24.service";
|
||||
import { PL24AuthService } from "./pl24-auth.service";
|
||||
import { PL24FordLegacyService } from "./pl24-ford-legacy.service";
|
||||
|
||||
@Module({
|
||||
providers: [PL24Service, PL24AuthService],
|
||||
exports: [PL24Service, PL24AuthService],
|
||||
providers: [PL24Service, PL24AuthService, PL24FordLegacyService],
|
||||
exports: [PL24Service, PL24AuthService, PL24FordLegacyService],
|
||||
})
|
||||
export class PL24Module {}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { PL24AuthService } from "./pl24-auth.service";
|
||||
import { PL24FordLegacyService } from "./pl24-ford-legacy.service";
|
||||
import { RedisService } from "../../redis/redis.service";
|
||||
import { StorageService } from "../../storage/storage.service";
|
||||
import { PL24_DEFAULTS } from "./pl24.constants";
|
||||
@@ -41,6 +42,7 @@ export class PL24Service {
|
||||
|
||||
constructor(
|
||||
private readonly authService: PL24AuthService,
|
||||
private readonly fordLegacyService: PL24FordLegacyService,
|
||||
private configService: ConfigService,
|
||||
private redis: RedisService,
|
||||
private storage: StorageService,
|
||||
@@ -74,8 +76,11 @@ export class PL24Service {
|
||||
);
|
||||
}
|
||||
|
||||
// Only P5 Modern is supported for now
|
||||
// Dispatch legacy architectures to their dedicated services
|
||||
if (!isP5Modern(serviceName)) {
|
||||
if (serviceName === "fordt_parts") {
|
||||
return this.fordLegacyService.decodeVin(cleanVin);
|
||||
}
|
||||
this.logger.warn(
|
||||
`Legacy architecture not supported yet: ${serviceName}`,
|
||||
);
|
||||
@@ -251,6 +256,11 @@ export class PL24Service {
|
||||
linkPath: string,
|
||||
serviceName: string,
|
||||
): Promise<PL24PartsResponse> {
|
||||
// Ford legacy dispatch
|
||||
if (this.isFordLegacyPath(linkPath)) {
|
||||
return this.fordLegacyService.fetchPartsByPath(linkPath, serviceName);
|
||||
}
|
||||
|
||||
const pathHash = createHash("sha256").update(linkPath).digest("hex").substring(0, 16);
|
||||
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}parts:path:${pathHash}`;
|
||||
const cached = await this.redis.getJson<PL24PartsResponse>(cacheKey);
|
||||
@@ -368,6 +378,11 @@ export class PL24Service {
|
||||
linkPath: string,
|
||||
serviceName: string,
|
||||
): Promise<PL24MainGroup[]> {
|
||||
// Ford legacy dispatch
|
||||
if (this.isFordLegacyPath(linkPath)) {
|
||||
return this.fordLegacyService.fetchSubGroupsByPath(linkPath, serviceName);
|
||||
}
|
||||
|
||||
this.logger.log(`Fetching sub-groups by path: ${linkPath}`);
|
||||
|
||||
try {
|
||||
@@ -530,7 +545,8 @@ export class PL24Service {
|
||||
isSupported(vin: string): boolean {
|
||||
if (!vin || vin.length < 3) return false;
|
||||
const serviceName = this.getServiceName(vin);
|
||||
return !!serviceName && isP5Modern(serviceName);
|
||||
if (!serviceName) return false;
|
||||
return isP5Modern(serviceName) || serviceName === "fordt_parts";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -540,13 +556,22 @@ export class PL24Service {
|
||||
const brands = new Set<string>();
|
||||
for (const service of Object.values(PL24_WMI_SERVICE_MAP)) {
|
||||
const brand = SERVICE_TO_BRAND[service];
|
||||
if (brand && isP5Modern(service)) {
|
||||
if (brand && (isP5Modern(service) || service === "fordt_parts")) {
|
||||
brands.add(brand);
|
||||
}
|
||||
}
|
||||
return Array.from(brands).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get brand display name from VIN's WMI.
|
||||
*/
|
||||
getBrandName(vin: string): string | null {
|
||||
const serviceName = this.getServiceName(vin);
|
||||
if (!serviceName) return null;
|
||||
return SERVICE_TO_BRAND[serviceName] || null;
|
||||
}
|
||||
|
||||
// ==================== PRIVATE: Request helpers ====================
|
||||
|
||||
/**
|
||||
@@ -861,6 +886,7 @@ export class PL24Service {
|
||||
const responseData = response as Record<string, unknown>;
|
||||
|
||||
let records: Array<Record<string, unknown>> = [];
|
||||
let bomBasePath: string | undefined;
|
||||
|
||||
if (Array.isArray(response)) {
|
||||
records = response;
|
||||
@@ -871,6 +897,11 @@ export class PL24Service {
|
||||
} else if (Array.isArray(innerData)) {
|
||||
records = innerData as unknown as Array<Record<string, unknown>>;
|
||||
}
|
||||
// Servicepart: extract bomBaseLink for constructing child paths
|
||||
const bomBaseLink = innerData.bomBaseLink as Record<string, unknown> | undefined;
|
||||
if (bomBaseLink?.path) {
|
||||
bomBasePath = bomBaseLink.path as string;
|
||||
}
|
||||
} else if (responseData.subGroups) {
|
||||
records = responseData.subGroups as Array<Record<string, unknown>>;
|
||||
} else if (responseData.groups) {
|
||||
@@ -879,9 +910,11 @@ export class PL24Service {
|
||||
|
||||
const availableRecords = records.filter((record) => {
|
||||
if (record.unavailable) return false;
|
||||
// Filter out illustration headers that have no navigable link
|
||||
const link = (record.link as Record<string, unknown>) || {};
|
||||
if (!link.path) return false;
|
||||
// Servicepart records have no link.path but can use bomBaseLink
|
||||
if (!link.path && !bomBasePath) return false;
|
||||
// Skip the "all" pseudo-record in servicepart responses
|
||||
if (record.id === "all") return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -924,6 +957,12 @@ export class PL24Service {
|
||||
|
||||
if (!name) name = code;
|
||||
|
||||
// Construct linkPath: use record's own link.path, or bomBaseLink + record id
|
||||
const recordLinkPath = (link.path as string) || undefined;
|
||||
const constructedPath = !recordLinkPath && bomBasePath
|
||||
? `${bomBasePath}${record.id}`
|
||||
: recordLinkPath;
|
||||
|
||||
return {
|
||||
id: String(record.id || ""),
|
||||
code,
|
||||
@@ -931,8 +970,8 @@ export class PL24Service {
|
||||
description: values.modelDescriptions || undefined,
|
||||
imageUrl: undefined,
|
||||
partCount: undefined,
|
||||
linkPath: (link.path as string) || undefined,
|
||||
linkWid: (link.wid as string) || undefined,
|
||||
linkPath: constructedPath,
|
||||
linkWid: (link.wid as string) || (bomBasePath ? "servicePartsItemsTable" : undefined),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1081,7 +1120,7 @@ export class PL24Service {
|
||||
if (standardMatch) return standardMatch[1];
|
||||
|
||||
const tiffMatch = imageUrl.match(
|
||||
/\/tiffimages\/[^/]+\/[^/]+\/([a-zA-Z0-9]+)\.\w+/,
|
||||
/\/tiffimages\/(?:[^/]+\/)+([a-zA-Z0-9_-]+)\.\w+/,
|
||||
);
|
||||
if (tiffMatch) return tiffMatch[1];
|
||||
|
||||
@@ -1094,21 +1133,35 @@ export class PL24Service {
|
||||
// ==================== PRIVATE: Brand-specific flows ====================
|
||||
|
||||
/**
|
||||
* Convert partinfo links to bom links.
|
||||
* Convert partinfo links to bom/bomdetails links.
|
||||
* partinfo returns single part detail; bom returns full illustration + all parts.
|
||||
*
|
||||
* Standard brands: /extern/partinfo/vin → /extern/bom/vin
|
||||
* Suzuki-style: /extern/partinfo/vin → /extern/details/vin/bomdetails
|
||||
*/
|
||||
private convertPartInfoToBom(linkPath: string): string {
|
||||
if (!linkPath.includes("/partinfo/")) return linkPath;
|
||||
|
||||
const bomPath = linkPath.replace("/partinfo/", "/bom/");
|
||||
// Suzuki (and similar) uses /details/vin/bomdetails instead of /bom/
|
||||
const isSuzukiStyle = linkPath.includes("/p5suzuki/");
|
||||
const bomPath = isSuzukiStyle
|
||||
? linkPath.replace("/partinfo/vin", "/details/vin/bomdetails")
|
||||
: linkPath.replace("/partinfo/", "/bom/");
|
||||
|
||||
const url = new URL(bomPath, "http://placeholder");
|
||||
// Remove partinfo-specific params, but keep illustration info for correct BOM context
|
||||
// Remove partinfo-specific params
|
||||
url.searchParams.delete("fiValidity");
|
||||
url.searchParams.delete("position");
|
||||
url.searchParams.delete("positionId");
|
||||
url.searchParams.delete("partno");
|
||||
url.searchParams.delete("pos");
|
||||
return `${url.pathname}?${url.searchParams.toString()}`;
|
||||
}
|
||||
|
||||
private isFordLegacyPath(linkPath: string): boolean {
|
||||
return linkPath.includes("/ford/") && linkPath.includes(".action");
|
||||
}
|
||||
|
||||
private isDaimlerService(serviceName: string): boolean {
|
||||
return (
|
||||
serviceName.startsWith("mercedes") || serviceName === "smart_parts"
|
||||
|
||||
@@ -286,6 +286,13 @@ export const PL24_SERVICE_CATALOGS: Record<string, PL24CatalogConfig> = {
|
||||
apiPath: "/p5suzuki",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
|
||||
// Ford
|
||||
fordt_parts: {
|
||||
basePath: "/ford/fordt_parts",
|
||||
apiPath: "/ford/fordt_parts",
|
||||
architecture: "LEGACY_FORD",
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== HELPER FUNCTIONS ====================
|
||||
@@ -428,6 +435,12 @@ export const PL24_WMI_SERVICE_MAP: Record<string, string> = {
|
||||
TSM: "suzuki_parts",
|
||||
MA3: "suzuki_parts",
|
||||
MBH: "suzuki_parts",
|
||||
|
||||
// Ford
|
||||
NM0: "fordt_parts",
|
||||
WF0: "fordt_parts",
|
||||
"1FA": "fordt_parts",
|
||||
"3FA": "fordt_parts",
|
||||
};
|
||||
|
||||
// ==================== VEHICLE TYPES ====================
|
||||
@@ -604,4 +617,5 @@ export const SERVICE_TO_BRAND: Record<string, string> = {
|
||||
man_parts: "MAN",
|
||||
mmc_parts: "Mitsubishi",
|
||||
suzuki_parts: "Suzuki",
|
||||
fordt_parts: "Ford",
|
||||
};
|
||||
|
||||
@@ -63,7 +63,10 @@ export class PartsService {
|
||||
description: p.description || null,
|
||||
quantity: p.quantity || null,
|
||||
position: p.positionCode || null,
|
||||
hotspotIndex: p.hotspotId ? parseInt(p.hotspotId, 10) || null : null,
|
||||
hotspotIndex: p.hotspotId ? (() => {
|
||||
const val = parseInt(p.hotspotId!, 10);
|
||||
return (val > 0 && val <= 2147483647) ? val : null;
|
||||
})() : null,
|
||||
source: "pl24" as const,
|
||||
}));
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
import { DATABASE, Database } from "../database/database.provider";
|
||||
import { vehicles, queryLogs, brands, userBrands, userSubscriptions } from "../database/schema/core";
|
||||
import { vehicles, queryLogs, brands, userBrands, userSubscriptions, plans } from "../database/schema/core";
|
||||
import { CorgiService } from "../integrations/corgi/corgi.service";
|
||||
import { PL24Service } from "../integrations/pl24/pl24.service";
|
||||
import { VinApiService } from "../integrations/vin-api/vin-api.service";
|
||||
@@ -53,32 +53,50 @@ export class VehiclesService {
|
||||
|
||||
// 2. Corgi decode (offline)
|
||||
const corgiResult = this.corgiService.decodeVin(vin);
|
||||
if (!corgiResult || !corgiResult.isKnown) {
|
||||
await this.logQuery(userId, vin, null, "corgi", false, Date.now() - startTime, "Unknown VIN/brand");
|
||||
throw new BadRequestException("VIN not recognized. Brand not supported.");
|
||||
const corgiKnown = corgiResult && corgiResult.isKnown;
|
||||
|
||||
// 3. Brand access check (only if Corgi recognized the brand)
|
||||
let brandId: string | null = null;
|
||||
let brandName: string | null = null;
|
||||
if (corgiKnown) {
|
||||
const brand = await this.db
|
||||
.select()
|
||||
.from(brands)
|
||||
.where(eq(brands.name, corgiResult.brandName))
|
||||
.limit(1);
|
||||
|
||||
if (brand.length > 0) {
|
||||
brandId = brand[0].id;
|
||||
brandName = corgiResult.brandName;
|
||||
await this.checkBrandAccess(userId, brandId);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Brand access check
|
||||
const brand = await this.db
|
||||
.select()
|
||||
.from(brands)
|
||||
.where(eq(brands.name, corgiResult.brandName))
|
||||
.limit(1);
|
||||
|
||||
if (brand.length === 0) {
|
||||
throw new BadRequestException(`Brand not supported: ${corgiResult.brandName}`);
|
||||
}
|
||||
|
||||
const brandId = brand[0].id;
|
||||
await this.checkBrandAccess(userId, brandId);
|
||||
|
||||
// 4. PL24 decode (real API)
|
||||
// 4. PL24 decode (real API) — always attempt, PL24 has its own WMI map
|
||||
let source = "corgi";
|
||||
let pl24Vehicle = null;
|
||||
try {
|
||||
pl24Vehicle = await this.pl24Service.decodeVin(vin);
|
||||
} catch (err) {
|
||||
this.logger.warn(`PL24 decode failed for ${vin}: ${(err as Error).message}`);
|
||||
if (this.pl24Service.isSupported(vin)) {
|
||||
try {
|
||||
pl24Vehicle = await this.pl24Service.decodeVin(vin);
|
||||
// If Corgi didn't know the brand, resolve it from PL24's WMI map
|
||||
if (!brandId && pl24Vehicle) {
|
||||
const pl24Brand = this.pl24Service.getBrandName(vin);
|
||||
if (pl24Brand) {
|
||||
const brand = await this.db
|
||||
.select()
|
||||
.from(brands)
|
||||
.where(eq(brands.name, pl24Brand))
|
||||
.limit(1);
|
||||
if (brand.length > 0) {
|
||||
brandId = brand[0].id;
|
||||
brandName = pl24Brand;
|
||||
await this.checkBrandAccess(userId, brandId);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`PL24 decode failed for ${vin}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Fallback to EMEX if PL24 not available
|
||||
@@ -86,10 +104,19 @@ export class VehiclesService {
|
||||
if (!pl24Vehicle) {
|
||||
this.logger.log(`PL24 returned no data for ${vin}, trying EMEX fallback`);
|
||||
try {
|
||||
if (this.emexService.isSupported(vin)) {
|
||||
const emexResult = await this.emexService.decodeVin(vin);
|
||||
if (emexResult && emexResult.brand !== 'UNKNOWN') {
|
||||
emexVehicle = emexResult;
|
||||
const emexResult = await this.emexService.decodeVin(vin);
|
||||
if (emexResult && emexResult.brand !== 'UNKNOWN') {
|
||||
emexVehicle = emexResult;
|
||||
if (!brandId && emexResult.brand) {
|
||||
const emexBrand = await this.db
|
||||
.select()
|
||||
.from(brands)
|
||||
.where(eq(brands.name, emexResult.brand))
|
||||
.limit(1);
|
||||
if (emexBrand.length > 0) {
|
||||
brandId = emexBrand[0].id;
|
||||
brandName = emexResult.brand;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (emexError) {
|
||||
@@ -97,6 +124,12 @@ export class VehiclesService {
|
||||
}
|
||||
}
|
||||
|
||||
// If nothing recognized this VIN at all, give up
|
||||
if (!pl24Vehicle && !emexVehicle && !corgiKnown) {
|
||||
await this.logQuery(userId, vin, null, "corgi", false, Date.now() - startTime, "Unknown VIN/brand");
|
||||
throw new BadRequestException("VIN not recognized. Brand not supported.");
|
||||
}
|
||||
|
||||
// 6. Fallback to VIN API if PL24 and EMEX not available
|
||||
let vinApiData: any = null;
|
||||
if (!pl24Vehicle && !emexVehicle) {
|
||||
@@ -113,9 +146,9 @@ export class VehiclesService {
|
||||
userId,
|
||||
vin,
|
||||
brandId,
|
||||
brandName: corgiResult.brandName,
|
||||
brandName: brandName || corgiResult?.brandName || emexVehicle?.brand || null,
|
||||
model: pl24Vehicle?.model || emexVehicle?.model || vinApiData?.model || null,
|
||||
year: pl24Vehicle?.year || emexVehicle?.year || corgiResult.modelYear || (vinApiData ? parseInt(vinApiData.modelYear) : null),
|
||||
year: pl24Vehicle?.year || emexVehicle?.year || corgiResult?.modelYear || (vinApiData ? parseInt(vinApiData.modelYear) : null),
|
||||
engine: pl24Vehicle?.engineType || pl24Vehicle?.engineCode || emexVehicle?.engineCode || emexVehicle?.engineType || vinApiData?.engineModel || null,
|
||||
transmission: pl24Vehicle?.transmission || emexVehicle?.transmission || vinApiData?.transmissionStyle || null,
|
||||
bodyType: pl24Vehicle?.bodyType || emexVehicle?.bodyType || vinApiData?.bodyClass || null,
|
||||
@@ -177,8 +210,12 @@ export class VehiclesService {
|
||||
|
||||
private async checkBrandAccess(userId: string, brandId: string) {
|
||||
const [sub] = await this.db
|
||||
.select()
|
||||
.select({
|
||||
id: userSubscriptions.id,
|
||||
brandCount: plans.brandCount,
|
||||
})
|
||||
.from(userSubscriptions)
|
||||
.innerJoin(plans, eq(userSubscriptions.planId, plans.id))
|
||||
.where(and(eq(userSubscriptions.userId, userId), eq(userSubscriptions.status, "active")))
|
||||
.limit(1);
|
||||
|
||||
@@ -186,6 +223,9 @@ export class VehiclesService {
|
||||
throw new ForbiddenException("No active subscription. Please subscribe to access vehicle data.");
|
||||
}
|
||||
|
||||
// brandCount === 0 means unlimited (Full Paket) — skip per-brand check
|
||||
if (sub.brandCount === 0) return;
|
||||
|
||||
const [access] = await this.db
|
||||
.select()
|
||||
.from(userBrands)
|
||||
|
||||
@@ -2,10 +2,12 @@ const STORAGE_KEY = "userSettings";
|
||||
|
||||
interface UserSettings {
|
||||
categoryViewMode?: "grid" | "tree";
|
||||
sidebarCollapsed?: boolean;
|
||||
}
|
||||
|
||||
const defaults: UserSettings = {
|
||||
categoryViewMode: "grid",
|
||||
sidebarCollapsed: false,
|
||||
};
|
||||
|
||||
export function getUserSettings(): UserSettings {
|
||||
|
||||
@@ -17,8 +17,11 @@ import {
|
||||
Menu,
|
||||
X,
|
||||
LogOut,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
||||
|
||||
export const Route = createFileRoute("/dashboard")({
|
||||
component: DashboardLayout,
|
||||
@@ -45,6 +48,13 @@ function DashboardLayout() {
|
||||
const { user, isLoading, signOut, isAdmin } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [collapsed, setCollapsed] = useState(() => getUserSettings().sidebarCollapsed ?? false);
|
||||
|
||||
const toggleCollapsed = () => {
|
||||
const next = !collapsed;
|
||||
setCollapsed(next);
|
||||
setUserSetting("sidebarCollapsed", next);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -70,25 +80,41 @@ function DashboardLayout() {
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
{/* Desktop Sidebar */}
|
||||
<aside className="hidden w-64 flex-shrink-0 border-r lg:block">
|
||||
<aside
|
||||
className={`hidden flex-shrink-0 border-r transition-[width] duration-200 lg:block ${collapsed ? "w-16" : "w-64"}`}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex h-16 items-center border-b px-6">
|
||||
<Link to="/" className="text-xl font-bold">
|
||||
Sase.tr
|
||||
</Link>
|
||||
<div className={`flex h-16 items-center border-b ${collapsed ? "justify-center px-2" : "justify-between px-4"}`}>
|
||||
{!collapsed && (
|
||||
<Link to="/" className="text-xl font-bold">
|
||||
Sase.tr
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleCollapsed}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{collapsed ? (
|
||||
<PanelLeftOpen className="h-5 w-5" />
|
||||
) : (
|
||||
<PanelLeftClose className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex-1 space-y-1 p-4">
|
||||
<nav className={`flex-1 space-y-1 overflow-y-auto ${collapsed ? "p-2" : "p-4"}`}>
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors hover:bg-accent [&.active]:bg-accent [&.active]:text-accent-foreground"
|
||||
title={collapsed ? t(item.label) : undefined}
|
||||
className={`flex items-center rounded-lg text-sm font-medium transition-colors hover:bg-accent [&.active]:bg-accent [&.active]:text-accent-foreground ${collapsed ? "justify-center p-2" : "gap-3 px-3 py-2"}`}
|
||||
activeProps={{ className: "active" }}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{t(item.label)}
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{!collapsed && <span>{t(item.label)}</span>}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
@@ -99,37 +125,52 @@ function DashboardLayout() {
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors hover:bg-accent [&.active]:bg-accent [&.active]:text-accent-foreground"
|
||||
title={collapsed ? item.label : undefined}
|
||||
className={`flex items-center rounded-lg text-sm font-medium transition-colors hover:bg-accent [&.active]:bg-accent [&.active]:text-accent-foreground ${collapsed ? "justify-center p-2" : "gap-3 px-3 py-2"}`}
|
||||
activeProps={{ className: "active" }}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
{/* User section - bottom */}
|
||||
<div className={`border-t ${collapsed ? "p-2" : "p-3"}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => signOut()}
|
||||
title={collapsed ? user.name ?? "Çıkış" : undefined}
|
||||
className={`flex w-full items-center rounded-lg text-left transition-colors hover:bg-accent ${collapsed ? "justify-center p-2" : "gap-3 px-3 py-2"}`}
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground">
|
||||
{user.name?.charAt(0).toUpperCase() ?? "?"}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{user.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
<LogOut className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex flex-1 flex-col">
|
||||
{/* Header */}
|
||||
<header className="flex h-16 items-center justify-between border-b px-6">
|
||||
<header className="flex h-16 items-center border-b px-6 lg:hidden">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="lg:hidden"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground">{user.name}</span>
|
||||
<Button variant="ghost" size="icon" onClick={() => signOut()}>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page Content */}
|
||||
@@ -148,14 +189,14 @@ function DashboardLayout() {
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<aside className="absolute left-0 top-0 h-full w-64 bg-background shadow-lg">
|
||||
<aside className="absolute left-0 top-0 flex h-full w-64 flex-col bg-background shadow-lg">
|
||||
<div className="flex h-16 items-center justify-between border-b px-6">
|
||||
<span className="text-xl font-bold">Sase.tr</span>
|
||||
<Button variant="ghost" size="icon" onClick={() => setMobileOpen(false)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<nav className="space-y-1 p-4">
|
||||
<nav className="flex-1 space-y-1 overflow-y-auto p-4">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
@@ -188,6 +229,26 @@ function DashboardLayout() {
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
{/* User section - bottom */}
|
||||
<div className="border-t p-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
signOut();
|
||||
setMobileOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors hover:bg-accent"
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground">
|
||||
{user.name?.charAt(0).toUpperCase() ?? "?"}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{user.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
<LogOut className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Input } from "@sase/ui";
|
||||
import { Card, CardContent } from "@sase/ui";
|
||||
@@ -22,6 +23,12 @@ function SearchPage() {
|
||||
const [vin, setVin] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
|
||||
const { data: history } = useQuery({
|
||||
queryKey: ["vehicles", "history"],
|
||||
queryFn: () => api.get<any[]>("/vehicles/history?limit=20"),
|
||||
});
|
||||
|
||||
async function handleSearch(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -61,16 +68,40 @@ function SearchPage() {
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<form onSubmit={handleSearch} className="flex gap-3">
|
||||
<Input
|
||||
placeholder="VIN numarasını girin (17 karakter)"
|
||||
value={vin}
|
||||
onChange={(e) => {
|
||||
setVin(e.target.value.toUpperCase());
|
||||
setError(null);
|
||||
}}
|
||||
maxLength={17}
|
||||
className="font-mono text-lg tracking-wider"
|
||||
/>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
placeholder="VIN numarasını girin (17 karakter)"
|
||||
value={vin}
|
||||
onChange={(e) => {
|
||||
setVin(e.target.value.toUpperCase());
|
||||
setError(null);
|
||||
}}
|
||||
onFocus={() => setShowHistory(true)}
|
||||
onBlur={() => setShowHistory(false)}
|
||||
maxLength={17}
|
||||
className="font-mono text-lg tracking-wider"
|
||||
/>
|
||||
{showHistory && vin.length === 0 && history && history.length > 0 && (
|
||||
<div className="absolute z-50 mt-1 max-h-80 w-full overflow-y-auto rounded-md border bg-popover shadow-md">
|
||||
{history.map((v: any) => (
|
||||
<div
|
||||
key={v.id}
|
||||
className="flex cursor-pointer items-center justify-between px-3 py-2 hover:bg-accent"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
setVin(v.vin);
|
||||
setShowHistory(false);
|
||||
}}
|
||||
>
|
||||
<span className="font-mono text-sm">{v.vin}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{v.brandName} {v.model} {v.year}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" disabled={loading || vin.length !== 17}>
|
||||
{loading ? (
|
||||
<span className="animate-spin">...</span>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -3,11 +3,11 @@ module.exports = {
|
||||
{
|
||||
name: "sase-api",
|
||||
cwd: "./apps/api",
|
||||
script: "dist/main.js",
|
||||
instances: 2,
|
||||
exec_mode: "cluster",
|
||||
script: "pnpm",
|
||||
args: "dev",
|
||||
exec_mode: "fork",
|
||||
env: {
|
||||
NODE_ENV: "production",
|
||||
NODE_ENV: "development",
|
||||
PORT: 4000,
|
||||
},
|
||||
max_memory_restart: "512M",
|
||||
@@ -19,12 +19,11 @@ module.exports = {
|
||||
{
|
||||
name: "sase-web",
|
||||
cwd: "./apps/web",
|
||||
script: "node_modules/.bin/next",
|
||||
args: "start",
|
||||
instances: 2,
|
||||
exec_mode: "cluster",
|
||||
script: "pnpm",
|
||||
args: "dev",
|
||||
exec_mode: "fork",
|
||||
env: {
|
||||
NODE_ENV: "production",
|
||||
NODE_ENV: "development",
|
||||
PORT: 3000,
|
||||
},
|
||||
max_memory_restart: "512M",
|
||||
|
||||
@@ -19,11 +19,10 @@
|
||||
"@nestjs/core",
|
||||
"esbuild",
|
||||
"msgpackr-extract",
|
||||
"puppeteer",
|
||||
"sharp"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"puppeteer": "^23.11.1"
|
||||
"playwright": "^1.50.0"
|
||||
}
|
||||
}
|
||||
|
||||
556
pnpm-lock.yaml
generated
556
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
BIN
scripts/categories-page.png
Normal file
BIN
scripts/categories-page.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 113 KiB |
43
scripts/emex-debug-dims.js
Normal file
43
scripts/emex-debug-dims.js
Normal file
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env node
|
||||
const puppeteer = require('puppeteer');
|
||||
const QUICK_URL = 'https://emexdwc.ae/QuickDetails.aspx?c=SUBARU201802&gid=11884&vid=0&ssd=$*KwGdqbit1suR0ubYx-jFj8XR8fbomJ-emYinlNza6f7m4Nf0houS7-TvmejqkOewy9Ponp-Rlp6c1YC4n43Hi5KN5O-asbzd4-ian-ien5LPyNGNgovNjZSYmsXOyI3Xi5KN6IvWAAAAAHBDYDE=$';
|
||||
|
||||
(async () => {
|
||||
const browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox'] });
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1400, height: 900 });
|
||||
await page.goto(QUICK_URL, { waitUntil: 'networkidle2', timeout: 30000 });
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
|
||||
const unitUrl = await page.evaluate(() => {
|
||||
const link = document.querySelector('a[href*="Unit.aspx"]');
|
||||
return link ? link.href : null;
|
||||
});
|
||||
await page.goto(unitUrl, { waitUntil: 'networkidle2', timeout: 30000 });
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
|
||||
const info = await page.evaluate(() => {
|
||||
const img = document.querySelector('img.dragger[src*="laximo"]');
|
||||
if (!img) return { error: 'no dragger img' };
|
||||
|
||||
return {
|
||||
naturalWidth: img.naturalWidth,
|
||||
naturalHeight: img.naturalHeight,
|
||||
offsetWidth: img.offsetWidth,
|
||||
offsetHeight: img.offsetHeight,
|
||||
clientWidth: img.clientWidth,
|
||||
clientHeight: img.clientHeight,
|
||||
// Also check the image container
|
||||
parentTag: img.parentElement?.tagName,
|
||||
parentClass: img.parentElement?.className,
|
||||
parentWidth: img.parentElement?.offsetWidth,
|
||||
parentHeight: img.parentElement?.offsetHeight,
|
||||
// Scale factor
|
||||
scaleX: img.naturalWidth / img.offsetWidth,
|
||||
scaleY: img.naturalHeight / img.offsetHeight,
|
||||
};
|
||||
});
|
||||
|
||||
console.log(JSON.stringify(info, null, 2));
|
||||
await browser.close();
|
||||
})();
|
||||
111
scripts/emex-debug-page.js
Normal file
111
scripts/emex-debug-page.js
Normal file
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Debug script: dump actual DOM structure of an EMEX QuickDetails page
|
||||
*/
|
||||
const puppeteer = require('puppeteer');
|
||||
|
||||
const URL = process.argv[2] || 'https://emexdwc.ae/QuickDetails.aspx?c=SUBARU201802&gid=11884&vid=0&ssd=$*KwGdqbit1suR0ubYx-jFj8XR8fbomJ-emYinlNza6f7m4Nf0houS7-TvmejqkOewy9Ponp-Rlp6c1YC4n43Hi5KN5O-asbzd4-ian-ien5LPyNGNgovNjZSYmsXOyI3Xi5KN6IvWAAAAAHBDYDE=$';
|
||||
|
||||
(async () => {
|
||||
const browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox'] });
|
||||
const page = await browser.newPage();
|
||||
await page.goto(URL, { waitUntil: 'networkidle2', timeout: 30000 });
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
|
||||
const info = await page.evaluate(() => {
|
||||
const result = {};
|
||||
|
||||
// 1. Check for hotspot divs
|
||||
const hotspotDivs = document.querySelectorAll('div.dragger.g_highlight');
|
||||
result.hotspotDivsCount = hotspotDivs.length;
|
||||
result.hotspotDivsSample = Array.from(hotspotDivs).slice(0, 3).map(d => ({
|
||||
name: d.getAttribute('name'),
|
||||
className: d.className,
|
||||
style: d.getAttribute('style'),
|
||||
outerHTML: d.outerHTML.substring(0, 200),
|
||||
}));
|
||||
|
||||
// 2. Check for any divs with name attr and dragger class
|
||||
const allDraggerDivs = document.querySelectorAll('div.dragger');
|
||||
result.allDraggerDivsCount = allDraggerDivs.length;
|
||||
result.allDraggerSample = Array.from(allDraggerDivs).slice(0, 5).map(d => ({
|
||||
name: d.getAttribute('name'),
|
||||
className: d.className,
|
||||
tagName: d.tagName,
|
||||
outerHTML: d.outerHTML.substring(0, 200),
|
||||
}));
|
||||
|
||||
// 3. Check for named divs generally
|
||||
const namedDivs = document.querySelectorAll('div[name]');
|
||||
result.namedDivsCount = namedDivs.length;
|
||||
result.namedDivsSample = Array.from(namedDivs).slice(0, 5).map(d => ({
|
||||
name: d.getAttribute('name'),
|
||||
className: d.className,
|
||||
style: d.getAttribute('style')?.substring(0, 100),
|
||||
}));
|
||||
|
||||
// 4. Check for named table rows
|
||||
const namedTRs = document.querySelectorAll('tr[name]');
|
||||
result.namedTRsCount = namedTRs.length;
|
||||
result.namedTRsSample = Array.from(namedTRs).slice(0, 3).map(tr => ({
|
||||
name: tr.getAttribute('name'),
|
||||
className: tr.className,
|
||||
innerHTML: tr.innerHTML.substring(0, 300),
|
||||
}));
|
||||
|
||||
// 5. Check for td with name attributes (c_pnc, c_oem, etc.)
|
||||
const namedTDs = document.querySelectorAll('td[name]');
|
||||
result.namedTDsCount = namedTDs.length;
|
||||
result.namedTDsSample = Array.from(namedTDs).slice(0, 10).map(td => ({
|
||||
name: td.getAttribute('name'),
|
||||
text: td.textContent.trim().substring(0, 50),
|
||||
display: td.style.display,
|
||||
}));
|
||||
|
||||
// 6. Check images
|
||||
const imgs = document.querySelectorAll('img');
|
||||
result.allImgsCount = imgs.length;
|
||||
result.laximoImgs = Array.from(imgs)
|
||||
.filter(i => (i.src || '').includes('laximo'))
|
||||
.map(i => ({
|
||||
src: i.src,
|
||||
className: i.className,
|
||||
naturalWidth: i.naturalWidth,
|
||||
naturalHeight: i.naturalHeight,
|
||||
}));
|
||||
|
||||
// 7. Check for iframes
|
||||
const iframes = document.querySelectorAll('iframe');
|
||||
result.iframeCount = iframes.length;
|
||||
result.iframeSrcs = Array.from(iframes).map(f => f.src?.substring(0, 200));
|
||||
|
||||
// 8. All table rows summary
|
||||
const allTRs = document.querySelectorAll('table tr');
|
||||
result.allTRsCount = allTRs.length;
|
||||
// Show first few with >= 2 cells
|
||||
result.sampleTRs = Array.from(allTRs)
|
||||
.filter(tr => tr.querySelectorAll('td').length >= 2)
|
||||
.slice(0, 3)
|
||||
.map(tr => ({
|
||||
name: tr.getAttribute('name'),
|
||||
className: tr.className,
|
||||
onmouseover: tr.getAttribute('onmouseover'),
|
||||
cellTexts: Array.from(tr.querySelectorAll('td')).map(td => td.textContent.trim().substring(0, 50)),
|
||||
}));
|
||||
|
||||
// 9. Check g_highlight class on any element
|
||||
const gHighlight = document.querySelectorAll('.g_highlight');
|
||||
result.gHighlightCount = gHighlight.length;
|
||||
result.gHighlightSample = Array.from(gHighlight).slice(0, 5).map(el => ({
|
||||
tagName: el.tagName,
|
||||
name: el.getAttribute('name'),
|
||||
className: el.className,
|
||||
}));
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
console.log(JSON.stringify(info, null, 2));
|
||||
|
||||
await browser.close();
|
||||
})();
|
||||
104
scripts/emex-debug-page2.js
Normal file
104
scripts/emex-debug-page2.js
Normal file
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Debug: try clicking the image to expand it and check for hotspot divs
|
||||
*/
|
||||
const puppeteer = require('puppeteer');
|
||||
|
||||
const URL = process.argv[2] || 'https://emexdwc.ae/QuickDetails.aspx?c=SUBARU201802&gid=11884&vid=0&ssd=$*KwGdqbit1suR0ubYx-jFj8XR8fbomJ-emYinlNza6f7m4Nf0houS7-TvmejqkOewy9Ponp-Rlp6c1YC4n43Hi5KN5O-asbzd4-ian-ien5LPyNGNgovNjZSYmsXOyI3Xi5KN6IvWAAAAAHBDYDE=$';
|
||||
|
||||
(async () => {
|
||||
const browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox'] });
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1400, height: 900 });
|
||||
await page.goto(URL, { waitUntil: 'networkidle2', timeout: 30000 });
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
|
||||
// Check initial state
|
||||
let info = await page.evaluate(() => ({
|
||||
hotspotDivs: document.querySelectorAll('div.dragger.g_highlight').length,
|
||||
allDivWithName: document.querySelectorAll('div[name]').length,
|
||||
imgSrc: document.querySelector('img[src*="laximo"]')?.src,
|
||||
imgClass: document.querySelector('img[src*="laximo"]')?.className,
|
||||
imgParent: document.querySelector('img[src*="laximo"]')?.parentElement?.tagName,
|
||||
imgParentClass: document.querySelector('img[src*="laximo"]')?.parentElement?.className,
|
||||
// Check for any onclick or expandable behavior
|
||||
imgOnClick: document.querySelector('img[src*="laximo"]')?.getAttribute('onclick'),
|
||||
imgParentOnClick: document.querySelector('img[src*="laximo"]')?.parentElement?.getAttribute('onclick'),
|
||||
// Look for collapsed/expandable sections
|
||||
gCollapsed: document.querySelectorAll('.g_collapsed').length,
|
||||
// Look for "openedimage" variable
|
||||
bodyScripts: Array.from(document.querySelectorAll('script')).map(s => s.textContent?.substring(0, 200)).filter(t => t && t.includes('open')),
|
||||
// Check for a[href] around image
|
||||
imgAncestor: document.querySelector('img[src*="laximo"]')?.closest('a')?.href,
|
||||
}));
|
||||
console.log('=== INITIAL STATE ===');
|
||||
console.log(JSON.stringify(info, null, 2));
|
||||
|
||||
// Try to find and click the image or its container
|
||||
const img = await page.$('img[src*="laximo"]');
|
||||
if (img) {
|
||||
console.log('\n=== CLICKING IMAGE ===');
|
||||
await img.click();
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
|
||||
info = await page.evaluate(() => ({
|
||||
hotspotDivs: document.querySelectorAll('div.dragger.g_highlight').length,
|
||||
allDivWithName: document.querySelectorAll('div[name]').length,
|
||||
namedDivSample: Array.from(document.querySelectorAll('div[name]')).slice(0, 3).map(d => ({
|
||||
name: d.getAttribute('name'),
|
||||
className: d.className,
|
||||
style: d.getAttribute('style')?.substring(0, 150),
|
||||
tagName: d.tagName,
|
||||
})),
|
||||
// Check for new images
|
||||
laximoImgs: Array.from(document.querySelectorAll('img[src*="laximo"]')).map(i => ({
|
||||
src: i.src,
|
||||
naturalWidth: i.naturalWidth,
|
||||
naturalHeight: i.naturalHeight,
|
||||
className: i.className,
|
||||
})),
|
||||
// Any position:absolute divs
|
||||
absoluteDivs: Array.from(document.querySelectorAll('div[style*="position:absolute"], div[style*="position: absolute"]')).slice(0, 5).map(d => ({
|
||||
name: d.getAttribute('name'),
|
||||
className: d.className,
|
||||
style: d.getAttribute('style')?.substring(0, 200),
|
||||
})),
|
||||
gHighlightAll: Array.from(document.querySelectorAll('.g_highlight')).map(el => ({
|
||||
tagName: el.tagName,
|
||||
name: el.getAttribute('name'),
|
||||
className: el.className,
|
||||
})),
|
||||
}));
|
||||
console.log('=== AFTER CLICK ===');
|
||||
console.log(JSON.stringify(info, null, 2));
|
||||
}
|
||||
|
||||
// Also try the "expand" link that EMEX pages sometimes have
|
||||
const expandLink = await page.$('a[href*="openimage"], .expand, [onclick*="openimage"], [onclick*="open_image"]');
|
||||
if (expandLink) {
|
||||
console.log('\n=== CLICKING EXPAND LINK ===');
|
||||
await expandLink.click();
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
}
|
||||
|
||||
// Screenshot after interactions
|
||||
await page.screenshot({ path: '/tmp/emex-debug-after-click.png', fullPage: true });
|
||||
console.log('\nScreenshot saved to /tmp/emex-debug-after-click.png');
|
||||
|
||||
// Get full page HTML snippet around the image area
|
||||
const imageAreaHTML = await page.evaluate(() => {
|
||||
const img = document.querySelector('img[src*="laximo"]');
|
||||
if (!img) return 'NO IMAGE FOUND';
|
||||
// Get the containing div/table
|
||||
let container = img.parentElement;
|
||||
for (let i = 0; i < 5 && container; i++) {
|
||||
if (container.querySelectorAll('div[name], .g_highlight').length > 0) break;
|
||||
container = container.parentElement;
|
||||
}
|
||||
return container?.innerHTML?.substring(0, 2000) || 'NO CONTAINER';
|
||||
});
|
||||
console.log('\n=== IMAGE AREA HTML ===');
|
||||
console.log(imageAreaHTML);
|
||||
|
||||
await browser.close();
|
||||
})();
|
||||
48
scripts/emex-debug-rect.js
Normal file
48
scripts/emex-debug-rect.js
Normal file
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env node
|
||||
const puppeteer = require('puppeteer');
|
||||
const QUICK_URL = 'https://emexdwc.ae/QuickDetails.aspx?c=SUBARU201802&gid=11884&vid=0&ssd=$*KwGdqbit1suR0ubYx-jFj8XR8fbomJ-emYinlNza6f7m4Nf0houS7-TvmejqkOewy9Ponp-Rlp6c1YC4n43Hi5KN5O-asbzd4-ian-ien5LPyNGNgovNjZSYmsXOyI3Xi5KN6IvWAAAAAHBDYDE=$';
|
||||
|
||||
(async () => {
|
||||
const browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox'] });
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1400, height: 900 });
|
||||
await page.goto(QUICK_URL, { waitUntil: 'networkidle2', timeout: 30000 });
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
const unitUrl = await page.evaluate(() => document.querySelector('a[href*="Unit.aspx"]')?.href);
|
||||
await page.goto(unitUrl, { waitUntil: 'networkidle2', timeout: 30000 });
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
|
||||
const info = await page.evaluate(() => {
|
||||
const img = document.querySelector('img.dragger[src*="laximo"]');
|
||||
if (!img) return { error: 'no img' };
|
||||
const imgRect = img.getBoundingClientRect();
|
||||
const scaleX = img.naturalWidth / imgRect.width;
|
||||
const scaleY = img.naturalHeight / imgRect.height;
|
||||
|
||||
const hotspots = [];
|
||||
for (const div of document.querySelectorAll('div.dragger.g_highlight[name]')) {
|
||||
const r = div.getBoundingClientRect();
|
||||
hotspots.push({
|
||||
name: div.getAttribute('name'),
|
||||
// Raw rendered positions relative to image
|
||||
relLeft: Math.round((r.left - imgRect.left) * 100) / 100,
|
||||
relTop: Math.round((r.top - imgRect.top) * 100) / 100,
|
||||
relW: Math.round(r.width * 100) / 100,
|
||||
relH: Math.round(r.height * 100) / 100,
|
||||
// Scaled to natural coords
|
||||
natLeft: Math.round((r.left - imgRect.left) * scaleX),
|
||||
natTop: Math.round((r.top - imgRect.top) * scaleY),
|
||||
natW: Math.round(r.width * scaleX),
|
||||
natH: Math.round(r.height * scaleY),
|
||||
});
|
||||
}
|
||||
return {
|
||||
imgNatural: { w: img.naturalWidth, h: img.naturalHeight },
|
||||
imgRendered: { w: imgRect.width, h: imgRect.height, left: imgRect.left, top: imgRect.top },
|
||||
scale: { x: Math.round(scaleX * 100) / 100, y: Math.round(scaleY * 100) / 100 },
|
||||
hotspots,
|
||||
};
|
||||
});
|
||||
console.log(JSON.stringify(info, null, 2));
|
||||
await browser.close();
|
||||
})();
|
||||
116
scripts/emex-debug-unit.js
Normal file
116
scripts/emex-debug-unit.js
Normal file
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Debug: check Unit.aspx for hotspot overlays
|
||||
*/
|
||||
const puppeteer = require('puppeteer');
|
||||
|
||||
const QUICK_URL = process.argv[2] || 'https://emexdwc.ae/QuickDetails.aspx?c=SUBARU201802&gid=11884&vid=0&ssd=$*KwGdqbit1suR0ubYx-jFj8XR8fbomJ-emYinlNza6f7m4Nf0houS7-TvmejqkOewy9Ponp-Rlp6c1YC4n43Hi5KN5O-asbzd4-ian-ien5LPyNGNgovNjZSYmsXOyI3Xi5KN6IvWAAAAAHBDYDE=$';
|
||||
|
||||
(async () => {
|
||||
const browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox'] });
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1400, height: 900 });
|
||||
|
||||
// Step 1: go to QuickDetails to extract Unit.aspx link
|
||||
console.log('=== Loading QuickDetails ===');
|
||||
await page.goto(QUICK_URL, { waitUntil: 'networkidle2', timeout: 30000 });
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
|
||||
const unitUrl = await page.evaluate(() => {
|
||||
// Find Unit.aspx link
|
||||
const links = document.querySelectorAll('a[href*="Unit.aspx"]');
|
||||
if (links.length > 0) {
|
||||
return links[0].href;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
console.log('Unit.aspx URL:', unitUrl);
|
||||
|
||||
if (!unitUrl) {
|
||||
console.log('No Unit.aspx link found!');
|
||||
await browser.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: navigate to Unit.aspx
|
||||
console.log('\n=== Loading Unit.aspx ===');
|
||||
await page.goto(unitUrl, { waitUntil: 'networkidle2', timeout: 30000 });
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
|
||||
const info = await page.evaluate(() => {
|
||||
const result = {};
|
||||
|
||||
// Hotspot divs
|
||||
const hotspotDivs = document.querySelectorAll('div.dragger.g_highlight');
|
||||
result.hotspotDivsCount = hotspotDivs.length;
|
||||
result.hotspotDivsSample = Array.from(hotspotDivs).slice(0, 5).map(d => ({
|
||||
name: d.getAttribute('name'),
|
||||
className: d.className,
|
||||
style: d.getAttribute('style'),
|
||||
}));
|
||||
|
||||
// Any div with name + position:absolute
|
||||
const absDivs = document.querySelectorAll('div[name][style*="absolute"]');
|
||||
result.absoluteNamedDivs = absDivs.length;
|
||||
result.absoluteNamedDivsSample = Array.from(absDivs).slice(0, 5).map(d => ({
|
||||
name: d.getAttribute('name'),
|
||||
className: d.className,
|
||||
style: d.getAttribute('style'),
|
||||
}));
|
||||
|
||||
// Named divs
|
||||
const namedDivs = document.querySelectorAll('div[name]');
|
||||
result.namedDivsCount = namedDivs.length;
|
||||
result.namedDivsSample = Array.from(namedDivs).slice(0, 5).map(d => ({
|
||||
name: d.getAttribute('name'),
|
||||
className: d.className,
|
||||
style: d.getAttribute('style')?.substring(0, 150),
|
||||
}));
|
||||
|
||||
// Images
|
||||
result.laximoImgs = Array.from(document.querySelectorAll('img[src*="laximo"]')).map(i => ({
|
||||
src: i.src,
|
||||
className: i.className,
|
||||
naturalWidth: i.naturalWidth,
|
||||
naturalHeight: i.naturalHeight,
|
||||
}));
|
||||
|
||||
// Named TRs
|
||||
const namedTRs = document.querySelectorAll('tr[name]');
|
||||
result.namedTRsCount = namedTRs.length;
|
||||
result.namedTRsSample = Array.from(namedTRs).slice(0, 3).map(tr => ({
|
||||
name: tr.getAttribute('name'),
|
||||
className: tr.className,
|
||||
}));
|
||||
|
||||
// Named TDs
|
||||
const pncCells = document.querySelectorAll('td[name="c_pnc"]');
|
||||
result.pncCellsCount = pncCells.length;
|
||||
result.pncValues = Array.from(pncCells).map(td => td.textContent.trim());
|
||||
|
||||
// img.dragger specifically
|
||||
const draggerImgs = document.querySelectorAll('img.dragger');
|
||||
result.draggerImgsCount = draggerImgs.length;
|
||||
result.draggerImgs = Array.from(draggerImgs).map(i => ({
|
||||
src: i.src?.substring(0, 100),
|
||||
className: i.className,
|
||||
naturalWidth: i.naturalWidth,
|
||||
naturalHeight: i.naturalHeight,
|
||||
}));
|
||||
|
||||
// g_highlight elements
|
||||
result.gHighlightAll = Array.from(document.querySelectorAll('.g_highlight')).slice(0, 10).map(el => ({
|
||||
tagName: el.tagName,
|
||||
name: el.getAttribute('name'),
|
||||
className: el.className,
|
||||
}));
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
console.log(JSON.stringify(info, null, 2));
|
||||
await page.screenshot({ path: '/tmp/emex-unit-page.png', fullPage: true });
|
||||
console.log('\nScreenshot saved to /tmp/emex-unit-page.png');
|
||||
|
||||
await browser.close();
|
||||
})();
|
||||
1129
scripts/emex-vin-scraper.js
Normal file
1129
scripts/emex-vin-scraper.js
Normal file
File diff suppressed because it is too large
Load Diff
BIN
scripts/parts-page.png
Normal file
BIN
scripts/parts-page.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
BIN
scripts/quickgroups-tree.png
Normal file
BIN
scripts/quickgroups-tree.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
BIN
scripts/vehicles-page.png
Normal file
BIN
scripts/vehicles-page.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 332 KiB |
263
scripts/vin-e2e-report-1770994628849.json
Normal file
263
scripts/vin-e2e-report-1770994628849.json
Normal file
@@ -0,0 +1,263 @@
|
||||
{
|
||||
"startedAt": "2026-02-13T14:53:11.422Z",
|
||||
"config": {
|
||||
"headless": true,
|
||||
"delayBetweenVins": 8000,
|
||||
"maxCategoriesToTest": 3
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"index": 1,
|
||||
"brand": "Renault",
|
||||
"vin": "VF1C066MC19290416",
|
||||
"resolved": true,
|
||||
"vehicleInfo": {},
|
||||
"categoriesLoaded": false,
|
||||
"categoryCount": 0,
|
||||
"categoryTests": [],
|
||||
"dbChecks": {},
|
||||
"errors": [],
|
||||
"timing": {
|
||||
"decodeMs": 60
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"brand": "Porsche",
|
||||
"vin": "WP1ZZZ92ZCLA29834",
|
||||
"resolved": true,
|
||||
"vehicleInfo": {},
|
||||
"categoriesLoaded": false,
|
||||
"categoryCount": 0,
|
||||
"categoryTests": [],
|
||||
"dbChecks": {},
|
||||
"errors": [],
|
||||
"timing": {
|
||||
"decodeMs": 56
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"brand": "Subaru",
|
||||
"vin": "JF1GD9LF37G069905",
|
||||
"resolved": true,
|
||||
"vehicleInfo": {},
|
||||
"categoriesLoaded": false,
|
||||
"categoryCount": 0,
|
||||
"categoryTests": [],
|
||||
"dbChecks": {},
|
||||
"errors": [],
|
||||
"timing": {
|
||||
"decodeMs": 60
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"brand": "Mercedes",
|
||||
"vin": "WDB2020181A148652",
|
||||
"resolved": true,
|
||||
"vehicleInfo": {},
|
||||
"categoriesLoaded": false,
|
||||
"categoryCount": 0,
|
||||
"categoryTests": [],
|
||||
"dbChecks": {},
|
||||
"errors": [],
|
||||
"timing": {
|
||||
"decodeMs": 50
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 5,
|
||||
"brand": "Honda",
|
||||
"vin": "NLAFC5650HW030691",
|
||||
"resolved": false,
|
||||
"platform": null,
|
||||
"vehicleInfo": null,
|
||||
"categoriesLoaded": false,
|
||||
"categoryCount": 0,
|
||||
"categoryTests": [],
|
||||
"dbChecks": {},
|
||||
"errors": [
|
||||
"Decode HTTP 400: {\"success\":false,\"error\":{\"code\":\"GEN_002\",\"message\":\"VIN not recognized. Brand not supported.\"}}"
|
||||
],
|
||||
"timing": {
|
||||
"decodeMs": 6587
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 6,
|
||||
"brand": "Mazda",
|
||||
"vin": "JMZDKFWSA10182289",
|
||||
"resolved": true,
|
||||
"vehicleInfo": {},
|
||||
"categoriesLoaded": false,
|
||||
"categoryCount": 0,
|
||||
"categoryTests": [],
|
||||
"dbChecks": {},
|
||||
"errors": [],
|
||||
"timing": {
|
||||
"decodeMs": 65
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 7,
|
||||
"brand": "Ford",
|
||||
"vin": "NM0GXXTTPGAG07617",
|
||||
"resolved": true,
|
||||
"vehicleInfo": {},
|
||||
"categoriesLoaded": false,
|
||||
"categoryCount": 0,
|
||||
"categoryTests": [],
|
||||
"dbChecks": {},
|
||||
"errors": [],
|
||||
"timing": {
|
||||
"decodeMs": 74
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 8,
|
||||
"brand": "Volvo",
|
||||
"vin": "YV1AS985691096540",
|
||||
"resolved": true,
|
||||
"vehicleInfo": {},
|
||||
"categoriesLoaded": false,
|
||||
"categoryCount": 0,
|
||||
"categoryTests": [],
|
||||
"dbChecks": {},
|
||||
"errors": [],
|
||||
"timing": {
|
||||
"decodeMs": 13611
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 9,
|
||||
"brand": "Kia",
|
||||
"vin": "TMAJ3812HGJ226161",
|
||||
"resolved": true,
|
||||
"vehicleInfo": {},
|
||||
"categoriesLoaded": false,
|
||||
"categoryCount": 0,
|
||||
"categoryTests": [],
|
||||
"dbChecks": {},
|
||||
"errors": [],
|
||||
"timing": {
|
||||
"decodeMs": 13477
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 10,
|
||||
"brand": "BMW",
|
||||
"vin": "WBALZ72090DY98841",
|
||||
"resolved": true,
|
||||
"vehicleInfo": {},
|
||||
"categoriesLoaded": false,
|
||||
"categoryCount": 0,
|
||||
"categoryTests": [],
|
||||
"dbChecks": {},
|
||||
"errors": [],
|
||||
"timing": {
|
||||
"decodeMs": 46
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 11,
|
||||
"brand": "Mitsubishi",
|
||||
"vin": "JMBSNCS3A4U004435",
|
||||
"resolved": true,
|
||||
"vehicleInfo": {},
|
||||
"categoriesLoaded": false,
|
||||
"categoryCount": 0,
|
||||
"categoryTests": [],
|
||||
"dbChecks": {},
|
||||
"errors": [],
|
||||
"timing": {
|
||||
"decodeMs": 14266
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 12,
|
||||
"brand": "Suzuki",
|
||||
"vin": "TSMLYD21S00268458",
|
||||
"resolved": true,
|
||||
"vehicleInfo": {},
|
||||
"categoriesLoaded": false,
|
||||
"categoryCount": 0,
|
||||
"categoryTests": [],
|
||||
"dbChecks": {},
|
||||
"errors": [],
|
||||
"timing": {
|
||||
"decodeMs": 440
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 13,
|
||||
"brand": "Scania",
|
||||
"vin": "XLER4X20005217616",
|
||||
"resolved": false,
|
||||
"platform": null,
|
||||
"vehicleInfo": null,
|
||||
"categoriesLoaded": false,
|
||||
"categoryCount": 0,
|
||||
"categoryTests": [],
|
||||
"dbChecks": {},
|
||||
"errors": [
|
||||
"Decode HTTP 400: {\"success\":false,\"error\":{\"code\":\"GEN_002\",\"message\":\"VIN not recognized. Brand not supported.\"}}"
|
||||
],
|
||||
"timing": {
|
||||
"decodeMs": 7832
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 14,
|
||||
"brand": "Nissan",
|
||||
"vin": "SJNFCAJ10U1242901",
|
||||
"resolved": true,
|
||||
"vehicleInfo": {},
|
||||
"categoriesLoaded": false,
|
||||
"categoryCount": 0,
|
||||
"categoryTests": [],
|
||||
"dbChecks": {},
|
||||
"errors": [],
|
||||
"timing": {
|
||||
"decodeMs": 14592
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 15,
|
||||
"brand": "MAN",
|
||||
"vin": "WMAH12ZZ04M393187",
|
||||
"resolved": true,
|
||||
"vehicleInfo": {},
|
||||
"categoriesLoaded": false,
|
||||
"categoryCount": 0,
|
||||
"categoryTests": [],
|
||||
"dbChecks": {},
|
||||
"errors": [],
|
||||
"timing": {
|
||||
"decodeMs": 891
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 16,
|
||||
"brand": "Land Rover",
|
||||
"vin": "SALLNABA8YA563459",
|
||||
"resolved": true,
|
||||
"vehicleInfo": {},
|
||||
"categoriesLoaded": false,
|
||||
"categoryCount": 0,
|
||||
"categoryTests": [],
|
||||
"dbChecks": {},
|
||||
"errors": [],
|
||||
"timing": {
|
||||
"decodeMs": 657
|
||||
}
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"total": 16,
|
||||
"resolved": 14,
|
||||
"failed": 0,
|
||||
"errors": 2
|
||||
},
|
||||
"finishedAt": "2026-02-13T14:57:08.848Z"
|
||||
}
|
||||
43
scripts/vin-e2e-report-1770994628850.md
Normal file
43
scripts/vin-e2e-report-1770994628850.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# VIN E2E Test Report
|
||||
|
||||
**Date:** 2026-02-13T14:53:11.422Z
|
||||
**Total:** 16 | **Resolved:** 14 | **Failed:** 0 | **Errors:** 2
|
||||
|
||||
## Results
|
||||
|
||||
| # | Brand | VIN | Status | Platform | Categories | Parts | Schema | DB | MinIO |
|
||||
|---|-------|-----|--------|----------|------------|-------|--------|----|-------|
|
||||
| 1 | Renault | `VF1C066MC19290416` | OK | - | 0 | 0 | NO | - | - |
|
||||
| 2 | Porsche | `WP1ZZZ92ZCLA29834` | OK | - | 0 | 0 | NO | - | - |
|
||||
| 3 | Subaru | `JF1GD9LF37G069905` | OK | - | 0 | 0 | NO | - | - |
|
||||
| 4 | Mercedes | `WDB2020181A148652` | OK | - | 0 | 0 | NO | - | - |
|
||||
| 5 | Honda | `NLAFC5650HW030691` | FAIL | - | 0 | 0 | NO | - | - |
|
||||
| 6 | Mazda | `JMZDKFWSA10182289` | OK | - | 0 | 0 | NO | - | - |
|
||||
| 7 | Ford | `NM0GXXTTPGAG07617` | OK | - | 0 | 0 | NO | - | - |
|
||||
| 8 | Volvo | `YV1AS985691096540` | OK | - | 0 | 0 | NO | - | - |
|
||||
| 9 | Kia | `TMAJ3812HGJ226161` | OK | - | 0 | 0 | NO | - | - |
|
||||
| 10 | BMW | `WBALZ72090DY98841` | OK | - | 0 | 0 | NO | - | - |
|
||||
| 11 | Mitsubishi | `JMBSNCS3A4U004435` | OK | - | 0 | 0 | NO | - | - |
|
||||
| 12 | Suzuki | `TSMLYD21S00268458` | OK | - | 0 | 0 | NO | - | - |
|
||||
| 13 | Scania | `XLER4X20005217616` | FAIL | - | 0 | 0 | NO | - | - |
|
||||
| 14 | Nissan | `SJNFCAJ10U1242901` | OK | - | 0 | 0 | NO | - | - |
|
||||
| 15 | MAN | `WMAH12ZZ04M393187` | OK | - | 0 | 0 | NO | - | - |
|
||||
| 16 | Land Rover | `SALLNABA8YA563459` | OK | - | 0 | 0 | NO | - | - |
|
||||
|
||||
## Errors
|
||||
|
||||
### Honda (`NLAFC5650HW030691`)
|
||||
- Decode HTTP 400: {"success":false,"error":{"code":"GEN_002","message":"VIN not recognized. Brand not supported."}}
|
||||
|
||||
### Scania (`XLER4X20005217616`)
|
||||
- Decode HTTP 400: {"success":false,"error":{"code":"GEN_002","message":"VIN not recognized. Brand not supported."}}
|
||||
|
||||
## Platform Breakdown
|
||||
|
||||
- **unresolved**: 16
|
||||
|
||||
## DB Storage
|
||||
|
||||
- Categories: 0
|
||||
- Parts: 0
|
||||
- Schema images: 0
|
||||
1987
scripts/vin-e2e-report-1770995420401.json
Normal file
1987
scripts/vin-e2e-report-1770995420401.json
Normal file
File diff suppressed because it is too large
Load Diff
45
scripts/vin-e2e-report-1770995420402.md
Normal file
45
scripts/vin-e2e-report-1770995420402.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# VIN E2E Test Report
|
||||
|
||||
**Date:** 2026-02-13T14:58:14.977Z
|
||||
**Total:** 16 | **Resolved:** 14 | **Failed:** 0 | **Errors:** 2
|
||||
|
||||
## Results
|
||||
|
||||
| # | Brand | VIN | Status | Platform | Categories | Parts | Schema | DB | MinIO |
|
||||
|---|-------|-----|--------|----------|------------|-------|--------|----|-------|
|
||||
| 1 | Renault | `VF1C066MC19290416` | OK | emex | 30 | 0 | NO | OK | - |
|
||||
| 2 | Porsche | `WP1ZZZ92ZCLA29834` | OK | pl24 | 15 | 129 | YES | OK | - |
|
||||
| 3 | Subaru | `JF1GD9LF37G069905` | OK | emex | 244 | 0 | NO | OK | - |
|
||||
| 4 | Mercedes | `WDB2020181A148652` | OK | pl24 | 42 | 73 | YES | OK | - |
|
||||
| 5 | Honda | `NLAFC5650HW030691` | FAIL | - | 0 | 0 | NO | - | - |
|
||||
| 6 | Mazda | `JMZDKFWSA10182289` | OK | emex | 27 | 0 | NO | OK | - |
|
||||
| 7 | Ford | `NM0GXXTTPGAG07617` | OK | emex | 29 | 0 | NO | OK | - |
|
||||
| 8 | Volvo | `YV1AS985691096540` | OK | emex | 31 | 0 | NO | OK | - |
|
||||
| 9 | Kia | `TMAJ3812HGJ226161` | OK | emex | 28 | 0 | NO | OK | - |
|
||||
| 10 | BMW | `WBALZ72090DY98841` | OK | pl24 | 38 | 7 | YES | OK | - |
|
||||
| 11 | Mitsubishi | `JMBSNCS3A4U004435` | OK | emex | 29 | 0 | NO | OK | - |
|
||||
| 12 | Suzuki | `TSMLYD21S00268458` | OK | pl24 | 7 | 0 | NO | OK | - |
|
||||
| 13 | Scania | `XLER4X20005217616` | FAIL | - | 0 | 0 | NO | - | - |
|
||||
| 14 | Nissan | `SJNFCAJ10U1242901` | OK | emex | 28 | 0 | NO | OK | - |
|
||||
| 15 | MAN | `WMAH12ZZ04M393187` | OK | pl24 | 11 | 0 | NO | OK | - |
|
||||
| 16 | Land Rover | `SALLNABA8YA563459` | OK | pl24 | 19 | 0 | NO | OK | - |
|
||||
|
||||
## Errors
|
||||
|
||||
### Honda (`NLAFC5650HW030691`)
|
||||
- Decode HTTP 400: {"success":false,"error":{"code":"GEN_002","message":"VIN not recognized. Brand not supported."}}
|
||||
|
||||
### Scania (`XLER4X20005217616`)
|
||||
- Decode HTTP 400: {"success":false,"error":{"code":"GEN_002","message":"VIN not recognized. Brand not supported."}}
|
||||
|
||||
## Platform Breakdown
|
||||
|
||||
- **emex**: 8
|
||||
- **pl24**: 6
|
||||
- **unresolved**: 2
|
||||
|
||||
## DB Storage
|
||||
|
||||
- Categories: 3287
|
||||
- Parts: 3166
|
||||
- Schema images: 5
|
||||
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);
|
||||
});
|
||||
1562
scripts/vin-result-JF1GD9LF37G069905.json
Normal file
1562
scripts/vin-result-JF1GD9LF37G069905.json
Normal file
File diff suppressed because it is too large
Load Diff
2380
scripts/vin-result-NM0GXXTTPGAG07617.json
Normal file
2380
scripts/vin-result-NM0GXXTTPGAG07617.json
Normal file
File diff suppressed because it is too large
Load Diff
1859
scripts/vin-result-WP1ZZZ92ZCLA29834.json
Normal file
1859
scripts/vin-result-WP1ZZZ92ZCLA29834.json
Normal file
File diff suppressed because it is too large
Load Diff
2700
scripts/vin-result-WVWZZZ1JZ3W597935.json
Normal file
2700
scripts/vin-result-WVWZZZ1JZ3W597935.json
Normal file
File diff suppressed because it is too large
Load Diff
208
scripts/vin-test-list.md
Normal file
208
scripts/vin-test-list.md
Normal file
@@ -0,0 +1,208 @@
|
||||
# VIN Test List
|
||||
|
||||
## Known Brand VINs (for testing)
|
||||
|
||||
| Brand | VIN |
|
||||
|-------|-----|
|
||||
| Renault | VF1C066MC19290416 |
|
||||
| Porsche | WP1ZZZ92ZCLA29834 |
|
||||
| Subaru | JF1GD9LF37G069905 |
|
||||
| Mercedes | WDB2020181A148652 |
|
||||
| Honda | NLAFC5650HW030691 |
|
||||
| Mazda | JMZDKFWSA10182289 |
|
||||
| Ford | NM0GXXTTPGAG07617 |
|
||||
| Volvo | YV1AS985691096540 |
|
||||
| Kia | TMAJ3812HGJ226161 |
|
||||
| BMW | WBALZ72090DY98841 |
|
||||
| Mitsubishi | JMBSNCS3A4U004435 |
|
||||
| Suzuki | TSMLYD21S00268458 |
|
||||
| Scania | XLER4X20005217616 |
|
||||
| Nissan | SJNFCAJ10U1242901 |
|
||||
| MAN | WMAH12ZZ04M393187 |
|
||||
| Land Rover | SALLNABA8YA563459 |
|
||||
|
||||
## Full VIN List (all)
|
||||
|
||||
```
|
||||
W0LPF6EA7BG121795
|
||||
VF1C066MC19290416
|
||||
JMBSRCY1ABU000224
|
||||
SB1BE76L10E029762
|
||||
WV1ZZZ2KZDX108924
|
||||
NM432300003094127
|
||||
VF1LA0S05TR601766
|
||||
W0L000087TZ000665
|
||||
W0L0TGF484G034387
|
||||
VSSZZZ6KZYR001992
|
||||
WP1ZZZ92ZCLA29834
|
||||
VFIB57J0516802497
|
||||
VE7RD9HZCAL519062
|
||||
WOLOTGF6915046898
|
||||
UU1HSDJ9P58251699
|
||||
VF15R040H55793696
|
||||
JMYJNP15V1A001419
|
||||
NM422300007066210
|
||||
VF1LM1A0H37603339
|
||||
WFOMXXGCDM4K6972P
|
||||
VF32AKFWF41658774
|
||||
WVGZZZ7LZ4D069535
|
||||
WF0PXXWPDPAG58896
|
||||
JF1GD9LF37G069905
|
||||
WVWZZZ1KZCP003443
|
||||
WOLOJBF6817006512
|
||||
WDB2020181A148652
|
||||
ZEA19900005312547
|
||||
ZFA19900005312547
|
||||
KL1SF48TJ8B042483
|
||||
WDF44770513186937
|
||||
NM417800006308304
|
||||
NMB37515112175264
|
||||
NM418200001500727
|
||||
UU1JSDBC651778807
|
||||
WVWZZZ3BZWP410811
|
||||
WDB1260241A399346
|
||||
NM417800006342253
|
||||
WDD1760421V137871
|
||||
VF1BZAL0E47650960
|
||||
W0L000036V1949501
|
||||
W0L000087NZ008763
|
||||
ZAR94000007364914
|
||||
JTNBC56E902005424
|
||||
WVGZZZ5NZHW815538
|
||||
WMA06XZZ1FP067766
|
||||
VF7YDBMFC11969992
|
||||
WVWZZZ1FZ7V010573
|
||||
NLAFC5650HW030691
|
||||
WV2ZZZ7HZ7X013984
|
||||
WDC1641221A401661
|
||||
NM417800006360859
|
||||
WDD2130851A528136
|
||||
JTNBV56E90J113851
|
||||
TMBAH45JX83181964
|
||||
VF1LB17C532990655
|
||||
JMZDKFWSA10182289
|
||||
TMBHK11U418432789
|
||||
VF1LZLV0E55748319
|
||||
WDB2100651A794807
|
||||
WVWZZZ3DZ58003544
|
||||
WVWZZZ13ZAV428915
|
||||
VF1B53A75TR500769
|
||||
JHMED93700S302540
|
||||
NM417800006215999
|
||||
NM0GXXTTPGAG07617
|
||||
VF34C5FWF55363877
|
||||
WBA3D3108CJ266380
|
||||
WVWZZZ6RZFY267326
|
||||
ZFA22300005542726
|
||||
ZLA83800002094280
|
||||
VSSZZZ1PZ6R014968
|
||||
YV1AS985691096540
|
||||
WV2ZZZ7HZ7H099052
|
||||
VF31AKFXLXM000535
|
||||
YV1AS84ABE1177595
|
||||
TMAJ3812HGJ226161
|
||||
NM414600008523582
|
||||
WAUZZZGY7MA099278
|
||||
NM435600006B82571
|
||||
KNEFB227245296811
|
||||
WDD1681331K008662
|
||||
WBALZ72090DY98841
|
||||
WVWZZZ6NZYY646901
|
||||
WV1ZZZ2DZ7H001265
|
||||
VF1KC1RBF35465734
|
||||
WBANA31020B160448
|
||||
NMOLXXTTFL5BO8739
|
||||
WVWZZZ1JZ4W190853
|
||||
KL1CD26RJBB038346
|
||||
VF38BRFNR81380306
|
||||
NLHPN81CP9Z051027
|
||||
WV2ZZZ7HZ7H029096
|
||||
WVWZZZ9NZ7Y124956
|
||||
JMBSNCS3A4U004435
|
||||
VF7CHRHYB39309060
|
||||
VF37ANFZWYP001138
|
||||
WBAAX71050PE61337
|
||||
VF32AKFXPYW007484
|
||||
VF32CNFZE1W010291
|
||||
NLHCM41VP9Z154507
|
||||
XMCLNDA5A3F041925
|
||||
SARRFYWZXAD082706
|
||||
VSSZZZ1PZ9R005451
|
||||
KNEUP751366777581
|
||||
KL1CA26Y9CB067082
|
||||
WVWZZZ1KZ8M095922
|
||||
WVWZZZAUZGW181481
|
||||
TSMLYD21S00268458
|
||||
VF32AKFWR43578479
|
||||
JF1GH3LS58G024408
|
||||
XLER4X20005217616
|
||||
SJNFCAJ10U1242901
|
||||
ZFA19800004118621
|
||||
WBAAL71080CD25352
|
||||
NM417800006250635
|
||||
JMZGF12S201455499
|
||||
JTNBC58E702012515
|
||||
WMAH12ZZ04M393187
|
||||
VF7GJ9HXC8J080892
|
||||
WVWZZZ1JZ1W482540
|
||||
XMCLNDG3A4F030252
|
||||
W0L0AHL48B2028333
|
||||
WF0RXXGCDRBJ26853
|
||||
NM0TXXTTPT4L38795
|
||||
WDB2020181F444556
|
||||
VF1RFE00057446178
|
||||
WVWZZZ3CZ5P000272
|
||||
WVWZZZ3BZ5P017387
|
||||
WVGZZZ5NZAW065481
|
||||
NM417800006410193
|
||||
WBAAL91020FS02895
|
||||
SJNFAAJ11U2930839
|
||||
ZFA25000001988058
|
||||
NMTBM22E80R001863
|
||||
WVWZZZ6RZDY019763
|
||||
SJNFCAJ10U2074211
|
||||
WBAPP3103BA981705
|
||||
WBAVC31017VC75600
|
||||
WF0JXXWPBJEL32782
|
||||
WOLOTGF071B005324
|
||||
WOLOXCF0814037348
|
||||
WVGLG77L25D005147
|
||||
VF1KMS40636712238
|
||||
VF1FDCUL631521726
|
||||
WVWZZZ7MZXV014477
|
||||
XWB4A11CDBA525214
|
||||
VF1KW98C558886403
|
||||
VFILMIBOH33693387
|
||||
VF1453K0500710941
|
||||
WVWZZZ1JZ1W357075
|
||||
NLHBA51RABZ049229
|
||||
SB153SBK10E048134
|
||||
JF1GD5LJ33G047702
|
||||
KLANF48614K020607
|
||||
WBADD11030BN05414
|
||||
SHHFK7805HU013915
|
||||
VFILB240534665147
|
||||
VF1LA0N0521543937
|
||||
VF32MKFWABY001984
|
||||
SJNJCAJ10U7191173
|
||||
WDD1173431N193569
|
||||
WMWSU3105BT204823
|
||||
NP9AKXXL006003297
|
||||
WVWZZZ6RZDY111184
|
||||
JN1BEAN14U0490130
|
||||
KMHSJ81XBCU871763
|
||||
VF1LBNK0540995585
|
||||
NM422300007065414
|
||||
VF1LB17C529008235
|
||||
NMB37512212106877
|
||||
VF644AEA000005529
|
||||
SALLNABA8YA563459
|
||||
```
|
||||
|
||||
## Invalid/Non-VIN Strings (filtered out)
|
||||
- `yapamıyorum.Kargo` - not a VIN
|
||||
- `gönderilmektedir.` - not a VIN
|
||||
- `öğrenebilirmiyim?` - not a VIN
|
||||
- `ediyorumu.Kontrol` - not a VIN
|
||||
- `×LEG4×20005263438` - contains invalid characters (×)
|
||||
- `WFOMXXGCDM4K6972P` - possibly invalid (unusual pattern)
|
||||
Reference in New Issue
Block a user