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:
Sase Dev
2026-02-14 22:25:06 +00:00
parent 7b024df4d5
commit 4a06ba5fdb
53 changed files with 17053 additions and 682 deletions

View 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}`);
})();

View 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)

View 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

View 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
};

View 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"
}
}
}
}

View 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"
}

View 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);
});

View File

@@ -0,0 +1 @@
../../.agents/skills/playwright-skill

View File

@@ -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"

View File

@@ -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,

View File

@@ -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)

View File

@@ -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 = [

View File

@@ -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> = {

View 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
);
}
}

View File

@@ -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 {}

View File

@@ -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}`);
}
}
}

View File

@@ -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' },
};

View File

@@ -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();

View 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;
}
}

View 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;

View File

@@ -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;

View File

@@ -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 {}

View File

@@ -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"

View File

@@ -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",
};

View File

@@ -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,
}));

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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>
)}

View File

@@ -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

View File

@@ -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",

View File

@@ -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

File diff suppressed because it is too large Load Diff

BIN
scripts/categories-page.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

View 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
View 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
View 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();
})();

View 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
View 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

File diff suppressed because it is too large Load Diff

BIN
scripts/parts-page.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

BIN
scripts/vehicles-page.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

View 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"
}

View 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

File diff suppressed because it is too large Load Diff

View 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
View 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);
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

208
scripts/vin-test-list.md Normal file
View 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)