Files
portal/app/playwright/refresh-session.js
T
Дмитрий f298984055 feat(supplier): Plan 3 Task 5 — RefreshSupplierSessionJob + PlaywrightBridge
Компоненты:
- app/playwright/{package.json, refresh-session.js} — изолированный Node.js
  + Playwright chromium subprocess для headless логина
- PlaywrightProcessHandle interface + SymfonyPlaywrightProcessHandle (prod) +
  StubPlaywrightProcessHandle (test) для DI без extending Symfony Process
- ProcessFactory + SymfonyProcessFactory
- PlaywrightBridge: PHP-обёртка, timeout 75s, JSON contract, exit code
  → SupplierAuthException
- RefreshSupplierSessionJob: stub → real (tries=3, backoff [2m/10m/30m],
  Cache::lock concurrent guard, Redis TTL 6h)
- supplier:session:refresh Console command
- AppServiceProvider binds ProcessFactory → SymfonyProcessFactory

+7 tests (4 PlaywrightBridge + 2 Job + 1 Command).

NOTE: DOM-селекторы placeholder — финализация после Task 1 discovery.
NOTE: app/playwright/node_modules в .gitignore.

Quirks resolved:
- Mockery::mock(Process::class) + laravel/pao = stream_filter_remove fatal.
  Решение: handle interface, pure-PHP test stub без extends Process.
- PHPStan Mockery union types — baseline entries (known Mockery+PHPStan compat).

KNOWN LIMITATION: на этой Windows машине pao stream filter conflict при
serial run SupplierPortalClient+RefreshSupplierSessionJob combo.
Tests pass individually + парами. Production Linux CI не affected.
2026-05-11 06:46:13 +03:00

93 lines
2.9 KiB
JavaScript

#!/usr/bin/env node
/**
* Headless Playwright login на crm.bp-gr.ru.
*
* Input (JSON через stdin):
* {login, password, url}
*
* Output (JSON через stdout):
* {phpsessid, csrf, refreshed_at}
*
* Exit codes:
* 0 — success
* 1 — auth failed (login/password rejected, или session cookie missing)
* 2 — DOM не найден (CSRF token не найден)
* 3 — timeout (60s)
* 4 — invalid input или другая ошибка
*/
const { chromium } = require('playwright');
const TIMEOUT_MS = 60_000;
async function refresh(args) {
const browser = await chromium.launch({ headless: true });
try {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(args.url, { waitUntil: 'load', timeout: TIMEOUT_MS });
// DOM-селекторы — placeholder до Task 1 discovery
const loginSelector = 'input[name=login]';
const passwordSelector = 'input[name=password]';
const submitSelector = 'button[type=submit]';
await page.fill(loginSelector, args.login);
await page.fill(passwordSelector, args.password);
await Promise.all([
page.waitForLoadState('networkidle', { timeout: TIMEOUT_MS }),
page.click(submitSelector),
]);
let csrf = null;
try {
csrf = await page.locator('meta[name=csrf-token]').first().getAttribute('content', { timeout: 5000 });
} catch (e) {
// CSRF meta tag not found — try other patterns в Task 1 discovery
}
const cookies = await context.cookies();
const sessionCookie = cookies.find(c => c.name === 'PHPSESSID' || c.name === 'JSESSIONID');
if (!sessionCookie) {
process.stderr.write(JSON.stringify({ error: 'session cookie not found in response' }));
process.exit(1);
}
if (!csrf) {
process.stderr.write(JSON.stringify({ error: 'CSRF token not found in DOM' }));
process.exit(2);
}
process.stdout.write(JSON.stringify({
phpsessid: sessionCookie.value,
csrf: csrf,
refreshed_at: new Date().toISOString(),
}));
process.exit(0);
} catch (err) {
process.stderr.write(JSON.stringify({ error: err.message }));
process.exit(err.message.includes('Timeout') ? 3 : 4);
} finally {
await browser.close();
}
}
// Read stdin
let input = '';
process.stdin.on('data', chunk => { input += chunk; });
process.stdin.on('end', () => {
let args;
try {
args = JSON.parse(input);
} catch (e) {
process.stderr.write(JSON.stringify({ error: 'invalid JSON on stdin' }));
process.exit(4);
}
if (!args.login || !args.password || !args.url) {
process.stderr.write(JSON.stringify({ error: 'missing required keys: login, password, url' }));
process.exit(4);
}
refresh(args);
});