feat(vinpin): persistent warm-session daemon (taskbar-raise decode) with cold fallback
Replace the per-decode "launch browser + login + open catalog" model with a persistent warm Vinpin seat that eliminates cold-start, brand-switch cost and the seat livelock. Additive + fail-safe: every warm operation degrades to the proven cold per-decode path, so behaviour never regresses. Warm daemon (VinpinDaemonService, worker-process singleton): - Scheduler warms the seat at 08:00 and tears it down at 21:00 Europe/Istanbul (proper TZ via Intl, no hardcoded offset); warms on worker start if inside hours; reconcile() every 60s with a reentrancy guard. - Keepalive nudges the RDS session (mouse.move) every ~75s while warm+idle; it SKIPS during any active seat op (busy flag) and never takes a lock that blocks a decode. - decode(vin): inside hours ensure warm (warm-on-demand once) then delegate to the driver; off-hours delegate straight to the cold path. Never throws. Driver warm path (VinpinDriverService): - warmUp() launches+logs in ONCE and opens Fiat ePER + Renault Rpartstore + Renault Dialogys windows without closing each other, then OCR-binds each taskbar button (order read via OCR, not hardcoded; raise self-heals by probing slots + OCR verify). isWarm()/teardownWarm()/keepalivePing() added. - warmDecode(): taskbar-raise the brand window (Fiat→ePER, Renault→Rpartstore w/ Dialogys fallback), run the EXISTING in-catalog decode on the warm window, OCR the modal, parse, then Escape to ready the field for the next VIN. Runs under the wall-clock budget; a hang still aborts. - Health-recovery: a dropped seat (Disconnected/no-free-sessions OCR marker) → teardown + re-warm ONCE, then retry the decode once. - Refactored runDialogys into openDialogysSubmenu + runDialogysSearch so the warm path searches without a window-closing reopen; cold Dialogys flow unchanged. Safety nets retained: VINPIN_DECODE_BUDGET_MS + sessionPoisoned breaker (warm budget abort → teardown+poison+null), single-seat serialization (runExclusive), decode() never throws. Gated by VINPIN_ENABLED; VINPIN_WARM_DAEMON=false forces the legacy cold path (kill-switch). Widened VINPIN_MODAL_REGION to ~900px. Wiring: processor calls getVinpinDaemon().decode(); worker starts the daemon on boot and stops it (releasing the seat) on shutdown. BullMQ concurrency 1 + attempts:1 unchanged. Tests: business-hours warm/teardown scheduling (injected clock/TZ), brand→taskbar routing, taskbar OCR-order binding, keepalive-skips-during-decode, and session-drop→re-warm→retry recovery. All existing vinpin/queue tests stay green. NOTE: un-dev-testable (prod holds the single seat) — pixel/taskbar coords are OCR-verified + marked TUNE and need live prod validation; warm falls back to cold until confirmed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
147
apps/api/src/integrations/vinpin/vinpin-daemon.service.spec.ts
Normal file
147
apps/api/src/integrations/vinpin/vinpin-daemon.service.spec.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { VinpinDaemonService } from "./vinpin-daemon.service";
|
||||
import type { VinpinDriverService } from "./vinpin-driver.service";
|
||||
|
||||
/**
|
||||
* Unit tests for the Vinpin warm-session daemon lifecycle: the business-hours
|
||||
* scheduler (warm/teardown reconciliation with an injected clock), warm-on-demand
|
||||
* decode routing, and the reentrancy guard. The driver is a fake — the real seat
|
||||
* lives on prod and can't be driven from a test.
|
||||
*/
|
||||
|
||||
interface FakeDriver {
|
||||
isWarm: ReturnType<typeof vi.fn>;
|
||||
warmUp: ReturnType<typeof vi.fn>;
|
||||
teardownWarm: ReturnType<typeof vi.fn>;
|
||||
decode: ReturnType<typeof vi.fn>;
|
||||
keepalivePing: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function makeDriver(warm = false): FakeDriver {
|
||||
return {
|
||||
isWarm: vi.fn(() => warm),
|
||||
warmUp: vi.fn(async () => true),
|
||||
teardownWarm: vi.fn(async () => undefined),
|
||||
decode: vi.fn(async () => null),
|
||||
keepalivePing: vi.fn(async () => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
function daemon(driver: FakeDriver, opts: { hours: boolean; enabled?: boolean }) {
|
||||
return new VinpinDaemonService({
|
||||
driver: driver as unknown as VinpinDriverService,
|
||||
isBusinessHours: () => opts.hours,
|
||||
isEnabled: () => opts.enabled ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
describe("VinpinDaemonService — scheduler reconcile", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("warms up inside business hours when enabled and not already warm", async () => {
|
||||
const driver = makeDriver(false);
|
||||
await daemon(driver, { hours: true }).reconcile();
|
||||
expect(driver.warmUp).toHaveBeenCalledTimes(1);
|
||||
expect(driver.teardownWarm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT re-warm when already warm inside hours", async () => {
|
||||
const driver = makeDriver(true);
|
||||
await daemon(driver, { hours: true }).reconcile();
|
||||
expect(driver.warmUp).not.toHaveBeenCalled();
|
||||
expect(driver.teardownWarm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tears down the warm seat outside business hours", async () => {
|
||||
const driver = makeDriver(true);
|
||||
await daemon(driver, { hours: false }).reconcile();
|
||||
expect(driver.teardownWarm).toHaveBeenCalledTimes(1);
|
||||
expect(driver.warmUp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tears down a warm seat when the daemon is disabled (kill-switch)", async () => {
|
||||
const driver = makeDriver(true);
|
||||
await daemon(driver, { hours: true, enabled: false }).reconcile();
|
||||
expect(driver.teardownWarm).toHaveBeenCalledTimes(1);
|
||||
expect(driver.warmUp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when disabled and already cold", async () => {
|
||||
const driver = makeDriver(false);
|
||||
await daemon(driver, { hours: true, enabled: false }).reconcile();
|
||||
expect(driver.warmUp).not.toHaveBeenCalled();
|
||||
expect(driver.teardownWarm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not stack overlapping reconciles (reentrancy guard)", async () => {
|
||||
const driver = makeDriver(false);
|
||||
let release!: () => void;
|
||||
driver.warmUp.mockImplementation(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
release = () => resolve(true);
|
||||
}),
|
||||
);
|
||||
const d = daemon(driver, { hours: true });
|
||||
const first = d.reconcile();
|
||||
const second = d.reconcile(); // should early-return while the first is in flight
|
||||
release();
|
||||
await Promise.all([first, second]);
|
||||
expect(driver.warmUp).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("VinpinDaemonService — decode routing", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("warms on-demand then delegates when inside hours and cold", async () => {
|
||||
const driver = makeDriver(false);
|
||||
driver.decode.mockResolvedValue({ brand: "Fiat", model: "EGEA" });
|
||||
const result = await daemon(driver, { hours: true }).decode("NM435600006H43436");
|
||||
expect(driver.warmUp).toHaveBeenCalledTimes(1);
|
||||
expect(driver.decode).toHaveBeenCalledWith("NM435600006H43436");
|
||||
expect(result).toEqual({ brand: "Fiat", model: "EGEA" });
|
||||
});
|
||||
|
||||
it("does NOT warm on-demand when already warm — just delegates", async () => {
|
||||
const driver = makeDriver(true);
|
||||
await daemon(driver, { hours: true }).decode("NM435600006H43436");
|
||||
expect(driver.warmUp).not.toHaveBeenCalled();
|
||||
expect(driver.decode).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("off-hours delegates straight to the cold path (never holds the seat)", async () => {
|
||||
const driver = makeDriver(false);
|
||||
await daemon(driver, { hours: false }).decode("NM435600006H43436");
|
||||
expect(driver.warmUp).not.toHaveBeenCalled();
|
||||
expect(driver.decode).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns null (never throws) if the driver decode rejects", async () => {
|
||||
const driver = makeDriver(true);
|
||||
driver.decode.mockRejectedValue(new Error("boom"));
|
||||
const result = await daemon(driver, { hours: true }).decode("NM435600006H43436");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("VinpinDaemonService — start/stop lifecycle", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("reconciles on start and drives keepalive on the interval; stop tears down", async () => {
|
||||
const driver = makeDriver(false);
|
||||
const d = daemon(driver, { hours: true });
|
||||
d.start();
|
||||
// Immediate reconcile → warm.
|
||||
await vi.waitFor(() => expect(driver.warmUp).toHaveBeenCalledTimes(1));
|
||||
// Advance past a keepalive interval → a ping fires.
|
||||
await vi.advanceTimersByTimeAsync(80_000);
|
||||
expect(driver.keepalivePing).toHaveBeenCalled();
|
||||
await d.stop();
|
||||
expect(driver.teardownWarm).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
140
apps/api/src/integrations/vinpin/vinpin-daemon.service.ts
Normal file
140
apps/api/src/integrations/vinpin/vinpin-daemon.service.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Vinpin warm-session daemon (worker-process singleton).
|
||||
*
|
||||
* Owns the LIFECYCLE of the persistent warm Vinpin seat, on top of the browser
|
||||
* mechanics in VinpinDriverService:
|
||||
* - a scheduler that warms the seat at 08:00 and tears it down at 21:00
|
||||
* Europe/Istanbul (and warms on worker start if already inside the window),
|
||||
* - an idle keepalive that nudges the RDS session every ~75s so it never drops,
|
||||
* - `decode(vin)` — the entry the vinpin-decode processor calls. Inside business
|
||||
* hours it makes sure the seat is warm (warming on-demand once), then delegates
|
||||
* to the driver, which self-routes to the HOT warm path when warm and the
|
||||
* proven COLD per-decode path otherwise. Outside hours it never holds the seat
|
||||
* — the driver's cold path handles the decode and is torn down after.
|
||||
*
|
||||
* ADDITIVE + fail-safe: every warm operation degrades to the cold path, and
|
||||
* `decode()` never throws (the driver returns null on any failure). Gated by
|
||||
* VINPIN_ENABLED (feature flag) and VINPIN_WARM_DAEMON (kill-switch: set to
|
||||
* "false" to force the legacy per-decode cold path with the daemon inert).
|
||||
*/
|
||||
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { getVinpinDriver } from "./vinpin-driver.service";
|
||||
import type { VinpinDecodeResult, VinpinDriverService } from "./vinpin-driver.service";
|
||||
import { VINPIN_WARM, isVinpinBusinessHours } from "./vinpin.constants";
|
||||
|
||||
/** Whether the warm daemon is allowed to run (feature flag + kill-switch). */
|
||||
export function isVinpinWarmDaemonEnabled(): boolean {
|
||||
return process.env.VINPIN_ENABLED === "true" && process.env.VINPIN_WARM_DAEMON !== "false";
|
||||
}
|
||||
|
||||
export interface VinpinDaemonDeps {
|
||||
/** Driver singleton (injectable for tests). Defaults to the process-wide one. */
|
||||
driver?: VinpinDriverService;
|
||||
/** Business-hours predicate (injectable so tests can drive the clock). */
|
||||
isBusinessHours?: () => boolean;
|
||||
/** Warm-daemon enabled predicate (injectable for tests). */
|
||||
isEnabled?: () => boolean;
|
||||
}
|
||||
|
||||
export class VinpinDaemonService {
|
||||
private readonly logger = new Logger(VinpinDaemonService.name);
|
||||
private readonly driver: VinpinDriverService;
|
||||
private readonly isBusinessHours: () => boolean;
|
||||
private readonly isEnabled: () => boolean;
|
||||
|
||||
private schedulerTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private keepaliveTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private reconciling = false;
|
||||
private started = false;
|
||||
|
||||
constructor(deps: VinpinDaemonDeps = {}) {
|
||||
this.driver = deps.driver ?? getVinpinDriver();
|
||||
this.isBusinessHours = deps.isBusinessHours ?? (() => isVinpinBusinessHours());
|
||||
this.isEnabled = deps.isEnabled ?? isVinpinWarmDaemonEnabled;
|
||||
}
|
||||
|
||||
/** Start the scheduler + keepalive loops (idempotent). */
|
||||
start(): void {
|
||||
if (this.started) return;
|
||||
this.started = true;
|
||||
// Reconcile once now (warm immediately if the worker booted inside hours).
|
||||
void this.reconcile();
|
||||
this.schedulerTimer = setInterval(() => {
|
||||
void this.reconcile();
|
||||
}, VINPIN_WARM.schedulerIntervalMs);
|
||||
this.keepaliveTimer = setInterval(() => {
|
||||
void this.driver.keepalivePing();
|
||||
}, VINPIN_WARM.keepaliveIntervalMs);
|
||||
// Don't keep the event loop alive just for these timers.
|
||||
this.schedulerTimer.unref?.();
|
||||
this.keepaliveTimer.unref?.();
|
||||
this.logger.log(
|
||||
`Vinpin warm daemon started (enabled=${this.isEnabled()}, hours ${VINPIN_WARM.businessStartHour}:00–${VINPIN_WARM.businessEndHour}:00 Europe/Istanbul)`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Stop the loops and tear the warm seat down (worker shutdown). */
|
||||
async stop(): Promise<void> {
|
||||
if (this.schedulerTimer) clearInterval(this.schedulerTimer);
|
||||
if (this.keepaliveTimer) clearInterval(this.keepaliveTimer);
|
||||
this.schedulerTimer = null;
|
||||
this.keepaliveTimer = null;
|
||||
this.started = false;
|
||||
await this.driver.teardownWarm().catch(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile the warm seat against the schedule: warm up when enabled + inside
|
||||
* hours + not already warm; tear down when warm but disabled or outside hours.
|
||||
* Guards against overlapping runs (a slow warmUp must not stack). Never throws.
|
||||
*/
|
||||
async reconcile(): Promise<void> {
|
||||
if (this.reconciling) return;
|
||||
this.reconciling = true;
|
||||
try {
|
||||
const enabled = this.isEnabled();
|
||||
const inHours = this.isBusinessHours();
|
||||
if (enabled && inHours && !this.driver.isWarm()) {
|
||||
this.logger.log("scheduler: inside business hours — warming the seat");
|
||||
await this.driver.warmUp();
|
||||
} else if (this.driver.isWarm() && (!enabled || !inHours)) {
|
||||
this.logger.log("scheduler: outside business hours / disabled — tearing the seat down");
|
||||
await this.driver.teardownWarm();
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`reconcile failed: ${(err as Error).message}`);
|
||||
} finally {
|
||||
this.reconciling = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode entry the processor calls. Inside hours, ensure the seat is warm (warm
|
||||
* on-demand once), then delegate to the driver — which uses the hot warm path
|
||||
* when warm, else the cold per-decode path. Off-hours, delegate straight to the
|
||||
* driver's cold path (no seat held). Never throws — returns null on any failure.
|
||||
*/
|
||||
async decode(vin: string): Promise<VinpinDecodeResult | null> {
|
||||
try {
|
||||
if (this.isEnabled() && this.isBusinessHours() && !this.driver.isWarm()) {
|
||||
// Warm-on-demand: a decode arrived inside hours before the scheduler warmed
|
||||
// (e.g. right after 08:00, or after a drop). Best-effort — if it fails the
|
||||
// driver silently runs the cold path for this decode.
|
||||
await this.driver.warmUp().catch(() => false);
|
||||
}
|
||||
return await this.driver.decode(vin);
|
||||
} catch (err) {
|
||||
// Defensive: driver.decode never throws, but guarantee the processor a null.
|
||||
this.logger.warn(`decode(${vin}) unexpected error: ${(err as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Worker singleton ────────────────────────────────────────
|
||||
let daemonSingleton: VinpinDaemonService | null = null;
|
||||
export function getVinpinDaemon(): VinpinDaemonService {
|
||||
if (!daemonSingleton) daemonSingleton = new VinpinDaemonService();
|
||||
return daemonSingleton;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { VinpinDriverService } from "./vinpin-driver.service";
|
||||
import { VinpinDriverService, VinpinSessionDroppedError } from "./vinpin-driver.service";
|
||||
|
||||
/**
|
||||
* Livelock-breaker unit tests for the Vinpin decode driver. These exercise the
|
||||
@@ -107,3 +107,111 @@ describe("VinpinDriverService — livelock breakers", () => {
|
||||
expect(fiatSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Warm-session daemon behaviours on the driver: warm-vs-cold routing, the
|
||||
* keepalive that must SKIP while a decode holds the seat, and health-recovery
|
||||
* (session-drop → teardown + re-warm + single retry). The browser-driving privates
|
||||
* are stubbed — the real seat lives on prod.
|
||||
*/
|
||||
describe("VinpinDriverService — warm session", () => {
|
||||
const savedEnv = { ...process.env };
|
||||
const VALID_FIAT_VIN = "NM435600006H43436";
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.VINPIN_ENABLED = "true";
|
||||
process.env.VINPIN_USER = "user";
|
||||
process.env.VINPIN_PASS = "pass";
|
||||
process.env.VINPIN_WARM_DAEMON = "true";
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
process.env = { ...savedEnv };
|
||||
});
|
||||
|
||||
function makeWarm(driver: VinpinDriverService) {
|
||||
const any = driver as unknown as Record<string, unknown>;
|
||||
any.warm = true;
|
||||
any.browser = { isConnected: () => true };
|
||||
any.page = { isClosed: () => false, mouse: { move: vi.fn(async () => undefined) } };
|
||||
return any;
|
||||
}
|
||||
|
||||
it("routes a decode to the WARM path when a healthy warm session is up", async () => {
|
||||
const driver = new VinpinDriverService();
|
||||
const any = makeWarm(driver);
|
||||
const warmSpy = vi
|
||||
.spyOn(any as never, "warmDecodeWithRecovery")
|
||||
.mockResolvedValue({ brand: "Fiat", model: "EGEA" } as never);
|
||||
const coldSpy = vi.spyOn(any as never, "decodeFiatLocked");
|
||||
|
||||
const result = await driver.decode(VALID_FIAT_VIN);
|
||||
|
||||
expect(warmSpy).toHaveBeenCalledTimes(1);
|
||||
expect(coldSpy).not.toHaveBeenCalled(); // cold path bypassed while warm
|
||||
expect(result).toEqual({ brand: "Fiat", model: "EGEA" });
|
||||
});
|
||||
|
||||
it("falls back to the COLD path when VINPIN_WARM_DAEMON=false even if warm is set", async () => {
|
||||
process.env.VINPIN_WARM_DAEMON = "false";
|
||||
const driver = new VinpinDriverService();
|
||||
const any = makeWarm(driver);
|
||||
const warmSpy = vi.spyOn(any as never, "warmDecodeWithRecovery");
|
||||
const coldSpy = vi
|
||||
.spyOn(any as never, "decodeFiatLocked")
|
||||
.mockResolvedValue({ brand: "Fiat", model: "EGEA" } as never);
|
||||
|
||||
await driver.decode(VALID_FIAT_VIN);
|
||||
|
||||
expect(warmSpy).not.toHaveBeenCalled();
|
||||
expect(coldSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keepalive SKIPS while a decode holds the seat (busy), and nudges when idle", async () => {
|
||||
const driver = new VinpinDriverService();
|
||||
const any = makeWarm(driver);
|
||||
const move = (any.page as { mouse: { move: ReturnType<typeof vi.fn> } }).mouse.move;
|
||||
|
||||
// Busy (a decode is in flight) → no nudge, no lock contention.
|
||||
any.busy = true;
|
||||
await driver.keepalivePing();
|
||||
expect(move).not.toHaveBeenCalled();
|
||||
|
||||
// Idle → the keepalive nudges the harmless in-window point.
|
||||
any.busy = false;
|
||||
await driver.keepalivePing();
|
||||
expect(move).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("on a dropped warm session, tears down + re-warms ONCE, then retries the decode once", async () => {
|
||||
const driver = new VinpinDriverService();
|
||||
const any = makeWarm(driver);
|
||||
|
||||
const warmDecode = vi
|
||||
.spyOn(any as never, "warmDecode")
|
||||
.mockRejectedValueOnce(new VinpinSessionDroppedError("dropped") as never)
|
||||
.mockResolvedValueOnce({ brand: "Fiat", model: "EGEA" } as never);
|
||||
const rewarm = vi.spyOn(any as never, "_warmUp").mockResolvedValue(true as never);
|
||||
|
||||
const result = await driver.decode(VALID_FIAT_VIN);
|
||||
|
||||
expect(warmDecode).toHaveBeenCalledTimes(2); // initial + one retry
|
||||
expect(rewarm).toHaveBeenCalledTimes(1); // re-warmed exactly once
|
||||
expect(result).toEqual({ brand: "Fiat", model: "EGEA" });
|
||||
});
|
||||
|
||||
it("session-drop recovery gives up (null) when the re-warm fails — never throws", async () => {
|
||||
const driver = new VinpinDriverService();
|
||||
const any = makeWarm(driver);
|
||||
|
||||
vi.spyOn(any as never, "warmDecode").mockRejectedValue(
|
||||
new VinpinSessionDroppedError("dropped") as never,
|
||||
);
|
||||
vi.spyOn(any as never, "_warmUp").mockResolvedValue(false as never);
|
||||
|
||||
const result = await driver.decode(VALID_FIAT_VIN);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(any.warm).toBe(false); // dropped seat marked not-warm → cold path next time
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,11 +36,20 @@ import {
|
||||
VINPIN_MODAL_REGION,
|
||||
VINPIN_OCR,
|
||||
VINPIN_RENAULT_HEADER_REGION,
|
||||
VINPIN_TASKBAR_REGION,
|
||||
VINPIN_TASKBAR_SLOTS,
|
||||
VINPIN_TYPE_DELAY_MS,
|
||||
VINPIN_VIEWPORT,
|
||||
VINPIN_WAITS,
|
||||
VINPIN_WARM,
|
||||
VINPIN_WINDOW_FOREGROUND,
|
||||
orderTaskbarWindows,
|
||||
selectVinpinBrandFlow,
|
||||
selectVinpinWarmWindow,
|
||||
} from "./vinpin.constants";
|
||||
|
||||
/** Warm catalog windows the daemon keeps open + can taskbar-raise. */
|
||||
type WarmWindow = "fiat" | "rpartstore" | "dialogys";
|
||||
import { type OcrClip, ocrRegion, pollForText, terminateOcr } from "./vinpin.ocr";
|
||||
import {
|
||||
type VinpinParsed,
|
||||
@@ -83,6 +92,9 @@ interface VinpinConfig {
|
||||
headful: boolean;
|
||||
/** Hard wall-clock cap (ms) for a single decode(vin), across all attempts. */
|
||||
budgetMs: number;
|
||||
/** Whether the warm-session daemon path is allowed (kill-switch: set
|
||||
* VINPIN_WARM_DAEMON=false to force the legacy per-decode cold path in prod). */
|
||||
warmDaemon: boolean;
|
||||
}
|
||||
|
||||
function readConfig(): VinpinConfig {
|
||||
@@ -94,6 +106,7 @@ function readConfig(): VinpinConfig {
|
||||
maxAttempts: Number(process.env.VINPIN_DECODE_MAX_ATTEMPTS) || 3,
|
||||
headful: process.env.VINPIN_HEADFUL === "true",
|
||||
budgetMs: Number(process.env.VINPIN_DECODE_BUDGET_MS) || VINPIN_DECODE_BUDGET_MS,
|
||||
warmDaemon: process.env.VINPIN_WARM_DAEMON !== "false",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -104,6 +117,15 @@ function readConfig(): VinpinConfig {
|
||||
*/
|
||||
class VinpinBudgetError extends Error {}
|
||||
|
||||
/**
|
||||
* Thrown by the warm path when a decode can't focus/decode because the RDS
|
||||
* session dropped (a "Disconnected"/no-free-sessions dialog, or the status panel
|
||||
* lost its HORIZON-RDST01/trvinpin seat markers). Distinct type so the warm loop
|
||||
* can tear down + re-warm + retry ONCE, apart from a genuine not-found (null) or a
|
||||
* budget abort (VinpinBudgetError).
|
||||
*/
|
||||
export class VinpinSessionDroppedError extends Error {}
|
||||
|
||||
@Injectable()
|
||||
export class VinpinDriverService implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(VinpinDriverService.name);
|
||||
@@ -127,14 +149,52 @@ export class VinpinDriverService implements OnModuleDestroy {
|
||||
* purpose (close() resets the session flags, not the poison marker). */
|
||||
private sessionPoisoned = false;
|
||||
|
||||
// ─── Warm-session daemon state ───────────────────────────
|
||||
/** True once warmUp() has launched + logged in + opened the Fiat ePER, Renault
|
||||
* Rpartstore and Renault Dialogys catalog windows and OCR-bound their taskbar
|
||||
* buttons. While true, decode() takes the hot warm path (taskbar-raise +
|
||||
* in-catalog decode) instead of the cold per-decode open/login/open path. */
|
||||
private warm = false;
|
||||
/** brand-window → bound taskbar button coordinate, resolved by OCR at warmUp
|
||||
* (order depends on the open sequence, so it's read, not hardcoded). */
|
||||
private taskbarCoords: Partial<Record<WarmWindow, { x: number; y: number }>> = {};
|
||||
/** Set while ANY exclusive seat operation runs (decode / warmUp / teardown /
|
||||
* keepalive). The keepalive loop reads this to SKIP a nudge during a decode —
|
||||
* it never takes a lock that could block a decode. */
|
||||
private busy = false;
|
||||
|
||||
// Single Vinpin seat → serialize all decodes through one promise chain.
|
||||
private lock: Promise<unknown> = Promise.resolve();
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.close();
|
||||
this.warm = false;
|
||||
this.taskbarCoords = {};
|
||||
await terminateOcr();
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a seat operation behind the single-seat lock AND flag `busy` for its
|
||||
* duration (so the keepalive skips). Every warm-path entry point routes through
|
||||
* this so warmUp / teardown / warmDecode / keepalive never collide on the one
|
||||
* browser. Keeps the chain alive even if `fn` rejects (defensive).
|
||||
*/
|
||||
private runExclusive<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const run = this.lock.then(async () => {
|
||||
this.busy = true;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
});
|
||||
this.lock = run.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return run;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a VIN through Vinpin ePER. Returns the parsed vehicle, or null when
|
||||
* disabled / not found / on any failure (never throws to the caller).
|
||||
@@ -158,21 +218,27 @@ export class VinpinDriverService implements OnModuleDestroy {
|
||||
}
|
||||
// Hard wall-clock deadline for THIS decode, spanning every internal attempt.
|
||||
const deadline = Date.now() + cfg.budgetMs;
|
||||
// Serialize behind the single-seat lock.
|
||||
const run = this.lock.then(() => this.decodeLocked(vin, cfg, deadline));
|
||||
// Keep the chain alive even if this run rejects (it shouldn't — defensive).
|
||||
this.lock = run.catch(() => undefined);
|
||||
return run;
|
||||
// Serialize behind the single-seat lock (also flags `busy` so the idle
|
||||
// keepalive skips for the duration).
|
||||
return this.runExclusive(() => this.decodeLocked(vin, cfg, deadline));
|
||||
}
|
||||
|
||||
/** Health snapshot for monitoring. */
|
||||
healthCheck(): { browserConnected: boolean; loggedIn: boolean } {
|
||||
healthCheck(): { browserConnected: boolean; loggedIn: boolean; warm: boolean } {
|
||||
return {
|
||||
browserConnected: this.browser?.isConnected() ?? false,
|
||||
loggedIn: this.loggedIn,
|
||||
warm: this.warm,
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether the warm multi-window session is up (daemon uses this to route). */
|
||||
isWarm(): boolean {
|
||||
return (
|
||||
this.warm && (this.browser?.isConnected() ?? false) && !!this.page && !this.page.isClosed()
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Private ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -207,9 +273,18 @@ export class VinpinDriverService implements OnModuleDestroy {
|
||||
if (this.sessionPoisoned) {
|
||||
this.logger.warn("seat poisoned by a prior decode — forcing a full cold re-establish");
|
||||
this.sessionPoisoned = false;
|
||||
this.warm = false; // a poisoned seat is no longer a trusted warm session
|
||||
await this.close();
|
||||
}
|
||||
|
||||
// Hot path: a healthy warm multi-window session is up → decode on it
|
||||
// (taskbar-raise + in-catalog decode). Its own cleanup keeps the windows open;
|
||||
// it never runs the cold cleanupAfterFailure (which CLOSES windows and would
|
||||
// destroy the warm session). Falls back to the cold path if warm is disabled/down.
|
||||
if (cfg.warmDaemon && this.isWarm()) {
|
||||
return this.warmDecodeWithRecovery(vin, cfg, deadline);
|
||||
}
|
||||
|
||||
const flow = selectVinpinBrandFlow(vin);
|
||||
let result: VinpinDecodeResult | null = null;
|
||||
try {
|
||||
@@ -255,6 +330,333 @@ export class VinpinDriverService implements OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Warm-session daemon ─────────────────────────────────
|
||||
// Persistent multi-window seat: launch+login ONCE, keep Fiat ePER + Renault
|
||||
// Rpartstore + Renault Dialogys windows OPEN, and decode by taskbar-raising the
|
||||
// right one and running the EXISTING in-catalog flow on it (no per-decode
|
||||
// launch/login/open). ADDITIVE: when warm can't be established/confirmed the
|
||||
// driver silently uses the proven cold per-decode path, so behaviour never
|
||||
// regresses. All entry points serialize through runExclusive (single seat).
|
||||
|
||||
/**
|
||||
* Establish the warm multi-window session (called by the daemon at 08:00
|
||||
* Istanbul, on worker start during hours, and on-demand). Serialized on the
|
||||
* single-seat lock. Returns true when at least one catalog window is confirmed;
|
||||
* false → the cold per-decode path stays in effect (no regression).
|
||||
*/
|
||||
async warmUp(): Promise<boolean> {
|
||||
return this.runExclusive(() => this._warmUp(readConfig())).catch(() => false);
|
||||
}
|
||||
|
||||
/** Tear the warm session down (daemon at 21:00 Istanbul / worker shutdown). */
|
||||
async teardownWarm(): Promise<void> {
|
||||
await this.runExclusive(async () => {
|
||||
this.warm = false;
|
||||
this.taskbarCoords = {};
|
||||
await this.close();
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Idle keepalive nudge (daemon calls this every ~75s). A `mouse.move` to a
|
||||
* harmless in-window point holds the RDS session (proven: 12.6 min, zero
|
||||
* disconnect). SKIPS during any active seat op (`busy`) so it never collides
|
||||
* with — or blocks — a decode; it only takes the lock when the seat is idle,
|
||||
* and only for a sub-millisecond move.
|
||||
*/
|
||||
async keepalivePing(): Promise<void> {
|
||||
if (!this.warm || this.busy) return; // never run during a decode / warmUp
|
||||
if (!this.browser?.isConnected() || !this.page || this.page.isClosed()) return;
|
||||
await this.runExclusive(async () => {
|
||||
// Re-check inside the lock — the seat may have changed since the pre-check.
|
||||
const page = this.page;
|
||||
if (!this.warm || !page || page.isClosed()) return;
|
||||
await page.mouse
|
||||
.move(VINPIN_WARM.keepalivePoint.x, VINPIN_WARM.keepalivePoint.y)
|
||||
.catch(() => undefined);
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Actual warm-up choreography (already holding the seat lock). Launch → login →
|
||||
* open Fiat ePER, Renault Rpartstore, Renault Dialogys — each WITHOUT closing the
|
||||
* previous window (the RDS taskbar keeps them all open) — then OCR-bind the
|
||||
* taskbar buttons. Never throws; on any failure it tears down and returns false.
|
||||
*/
|
||||
private async _warmUp(cfg: VinpinConfig): Promise<boolean> {
|
||||
if (!cfg.enabled || !cfg.warmDaemon || !cfg.user || !cfg.pass) return false;
|
||||
this.warm = false;
|
||||
this.taskbarCoords = {};
|
||||
try {
|
||||
await this.close(); // always warm from a fresh browser
|
||||
await this.launch(cfg);
|
||||
await this.ensureAuthenticated(cfg); // login → VinPower brand grid
|
||||
const page = this.page;
|
||||
if (!page) {
|
||||
await this.close();
|
||||
return false;
|
||||
}
|
||||
// Window 1: Fiat ePER (opened from the grid via the proven tile flow).
|
||||
await this.establishSession(cfg);
|
||||
const fiatUp = this.establishedFlow === "fiat";
|
||||
// Window 2: Renault Rpartstore — background ePER by raising the grid, then
|
||||
// open (ensureRpartstore opens from the grid and does NOT close ePER).
|
||||
let okR = false;
|
||||
if (await this.raiseGridViaTaskbar(page)) okR = await this.ensureRpartstore(page);
|
||||
// Window 3: Renault Dialogys — background Rpartstore via the grid, then open
|
||||
// (submenu-only opener, no window-closing returnToBrandGrid).
|
||||
let okD = false;
|
||||
if (await this.raiseGridViaTaskbar(page)) okD = await this.openDialogysSubmenu(page);
|
||||
// Bind taskbar buttons by OCR'd left→right order (raiseWarmWindow re-verifies
|
||||
// and re-binds by probing, so this is only a fast-path hint).
|
||||
await this.bindTaskbarCoords(page);
|
||||
|
||||
if (!fiatUp && !okR && !okD) {
|
||||
// Nothing opened → don't claim warm; leave the cold path in effect.
|
||||
await this.close();
|
||||
return false;
|
||||
}
|
||||
this.warm = true;
|
||||
// Warm routing is keyed on `this.warm`, not establishedFlow — clear it so a
|
||||
// stray cold-path check never assumes a single warm catalog.
|
||||
this.establishedFlow = null;
|
||||
this.logger.log(
|
||||
`Vinpin WARM session up (fiat=${fiatUp}, rpartstore=${okR}, dialogys=${okD}, bound=[${Object.keys(this.taskbarCoords).join(",")}])`,
|
||||
);
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`warmUp failed: ${(err as Error).message} — falling back to cold per-decode path`,
|
||||
);
|
||||
await this.close().catch(() => undefined);
|
||||
this.warm = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hot-path decode on the warm session with health-recovery. Budget abort →
|
||||
* teardown + poison + null (unchanged safety net). Session-drop → teardown +
|
||||
* re-warm ONCE, then retry the decode ONCE. Any other failure → null (never
|
||||
* throws to the caller).
|
||||
*/
|
||||
private async warmDecodeWithRecovery(
|
||||
vin: string,
|
||||
cfg: VinpinConfig,
|
||||
deadline: number,
|
||||
): Promise<VinpinDecodeResult | null> {
|
||||
try {
|
||||
return await this.warmDecode(vin, cfg, deadline);
|
||||
} catch (err) {
|
||||
if (err instanceof VinpinBudgetError) {
|
||||
this.logger.warn(
|
||||
`warm decode budget (${cfg.budgetMs}ms) exceeded for ${vin} — teardown + poison seat`,
|
||||
);
|
||||
this.sessionPoisoned = true;
|
||||
this.warm = false;
|
||||
await this.close().catch(() => undefined);
|
||||
return null;
|
||||
}
|
||||
if (err instanceof VinpinSessionDroppedError) {
|
||||
this.logger.warn(`warm session dropped on ${vin} (${err.message}) — re-warming once`);
|
||||
const rewarmed = await this._warmUp(cfg).catch(() => false);
|
||||
if (!rewarmed || Date.now() >= deadline) {
|
||||
this.warm = false;
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return await this.warmDecode(vin, cfg, deadline);
|
||||
} catch (err2) {
|
||||
if (err2 instanceof VinpinBudgetError) this.sessionPoisoned = true;
|
||||
this.logger.warn(
|
||||
`warm decode retry failed for ${vin} after re-warm: ${(err2 as Error).message}`,
|
||||
);
|
||||
this.warm = false;
|
||||
await this.close().catch(() => undefined);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
this.logger.warn(`warm decode failed for ${vin}: ${(err as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One decode on the warm session: assert the RDS seat is alive, raise the right
|
||||
* brand window via its taskbar button, and run the EXISTING in-catalog decode on
|
||||
* it (Fiat ePER field / Renault Rpartstore+Dialogys). Runs under the wall-clock
|
||||
* `deadline` (budget breaker). Throws VinpinSessionDroppedError when it can't
|
||||
* focus/decode because the seat dropped; returns null on a genuine not-found.
|
||||
*/
|
||||
private warmDecode(
|
||||
vin: string,
|
||||
cfg: VinpinConfig,
|
||||
deadline: number,
|
||||
): Promise<VinpinDecodeResult | null> {
|
||||
return this.withDeadline(deadline, `warm-decode ${vin}`, async () => {
|
||||
const page = this.page;
|
||||
if (!page || page.isClosed()) throw new VinpinSessionDroppedError("warm page gone");
|
||||
await this.assertSessionAlive(page);
|
||||
const win = selectVinpinWarmWindow(vin); // "fiat" | "rpartstore"
|
||||
if (win === "fiat") {
|
||||
if (!(await this.ensureWarmWindow(page, "fiat", cfg))) {
|
||||
throw new VinpinSessionDroppedError("could not focus the Fiat ePER window");
|
||||
}
|
||||
const text = await this.runVinFlow(page, vin);
|
||||
// Dismiss the modal so the field is ready for the NEXT VIN in this session.
|
||||
await page.keyboard.press("Escape").catch(() => undefined);
|
||||
const parsed = parseVinpinModal(text);
|
||||
if (isUsableParse(parsed)) {
|
||||
this.logger.log(
|
||||
`warm-decoded ${vin} → model="${parsed.model}" sincom="${parsed.sincom}"`,
|
||||
);
|
||||
return this.toResult(parsed, text);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return this.warmRenaultDecode(page, vin, cfg);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Warm Renault decode: raise the Rpartstore window and run the proven Rpartstore
|
||||
* flow (reset-to-home, no window close); on a miss, raise the Dialogys window and
|
||||
* run its search-only tail. Both stay open across decodes (no reopen cost).
|
||||
*/
|
||||
private async warmRenaultDecode(
|
||||
page: Page,
|
||||
vin: string,
|
||||
cfg: VinpinConfig,
|
||||
): Promise<VinpinDecodeResult | null> {
|
||||
if (!(await this.ensureWarmWindow(page, "rpartstore", cfg))) {
|
||||
throw new VinpinSessionDroppedError("could not focus the Rpartstore window");
|
||||
}
|
||||
const primary = await this.runRpartstore(page, vin);
|
||||
await page.keyboard.press("Escape").catch(() => undefined);
|
||||
if (primary.status === "found") {
|
||||
this.logger.log(
|
||||
`warm-decoded ${vin} → ${primary.parsed.brand} ${primary.parsed.model} (rpartstore)`,
|
||||
);
|
||||
return this.renaultToResult(primary.parsed, primary.rawText, primary.via);
|
||||
}
|
||||
// Dialogys fallback on the warm Dialogys window (search-only — keeps it open).
|
||||
if (await this.ensureWarmWindow(page, "dialogys", cfg)) {
|
||||
const fb = await this.runDialogysSearch(page, vin).catch(
|
||||
() => ({ status: "ambiguous" }) as RenaultOutcome,
|
||||
);
|
||||
await page.keyboard.press("Escape").catch(() => undefined);
|
||||
if (fb.status === "found") {
|
||||
this.logger.log(`warm-decoded ${vin} → ${fb.parsed.brand} ${fb.parsed.model} (dialogys)`);
|
||||
return this.renaultToResult(fb.parsed, fb.rawText, fb.via);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw VinpinSessionDroppedError if the RDS session shows an explicit
|
||||
* "Disconnected"/no-free-sessions marker. Only hard-fails on an explicit dropped
|
||||
* marker (absence of the HORIZON-RDST01/trvinpin panel is a weak signal — the
|
||||
* panel isn't always on screen — so it would false-positive).
|
||||
*/
|
||||
private async assertSessionAlive(page: Page): Promise<void> {
|
||||
if (VINPIN_OCR.sessionDropped.test(await ocrRegion(page))) {
|
||||
throw new VinpinSessionDroppedError("RDS session dropped (Disconnected / no free sessions)");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the given warm catalog window is in the FOREGROUND. Fast path: it's
|
||||
* already forward, or a taskbar-raise brings it forward (OCR-verified). If it's
|
||||
* gone (closed), reopen it from the grid and re-bind. Returns false only when the
|
||||
* window can't be focused nor reopened (→ caller treats it as a session drop).
|
||||
*/
|
||||
private async ensureWarmWindow(page: Page, key: WarmWindow, cfg: VinpinConfig): Promise<boolean> {
|
||||
if (await this.raiseWarmWindow(page, key)) return true;
|
||||
this.logger.warn(`warm ${key} window not focusable — reopening from the grid`);
|
||||
if (!(await this.raiseGridViaTaskbar(page))) return false;
|
||||
let reopened = false;
|
||||
try {
|
||||
if (key === "fiat") {
|
||||
await this.establishSession(cfg);
|
||||
reopened = this.establishedFlow === "fiat";
|
||||
this.establishedFlow = null;
|
||||
} else if (key === "rpartstore") {
|
||||
reopened = await this.ensureRpartstore(page);
|
||||
} else {
|
||||
reopened = await this.openDialogysSubmenu(page);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`reopening warm ${key} window threw: ${(err as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
if (!reopened) return false;
|
||||
await this.bindTaskbarCoords(page);
|
||||
return this.raiseWarmWindow(page, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Raise a warm window via its taskbar button. Tries: already-foreground → the
|
||||
* bound coord → probe every taskbar slot (binding the one that works). Verified
|
||||
* by OCR of the window's foreground chrome, so coordinate drift self-heals.
|
||||
*/
|
||||
private async raiseWarmWindow(page: Page, key: WarmWindow): Promise<boolean> {
|
||||
const fg = VINPIN_WINDOW_FOREGROUND[key];
|
||||
if (fg.test(await ocrRegion(page))) return true;
|
||||
const bound = this.taskbarCoords[key];
|
||||
if (bound) {
|
||||
await page.mouse.click(bound.x, bound.y);
|
||||
await page.waitForTimeout(VINPIN_WARM.afterRaiseMs);
|
||||
if (fg.test(await ocrRegion(page))) return true;
|
||||
}
|
||||
for (const slot of VINPIN_TASKBAR_SLOTS) {
|
||||
if (bound && slot.x === bound.x && slot.y === bound.y) continue; // already tried
|
||||
await page.mouse.click(slot.x, slot.y);
|
||||
await page.waitForTimeout(VINPIN_WARM.afterRaiseMs);
|
||||
if (fg.test(await ocrRegion(page))) {
|
||||
this.taskbarCoords[key] = { x: slot.x, y: slot.y };
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raise the VinPower brand grid via its taskbar button WITHOUT closing any open
|
||||
* catalog window (backgrounds the current one). Already-on-grid → no-op; else
|
||||
* probe taskbar slots until the grid comes forward.
|
||||
*/
|
||||
private async raiseGridViaTaskbar(page: Page): Promise<boolean> {
|
||||
if (VINPIN_OCR.brandGrid.test(await ocrRegion(page))) return true;
|
||||
for (const slot of VINPIN_TASKBAR_SLOTS) {
|
||||
await page.mouse.click(slot.x, slot.y);
|
||||
await page.waitForTimeout(VINPIN_WARM.afterRaiseMs);
|
||||
if (VINPIN_OCR.brandGrid.test(await ocrRegion(page))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* OCR the bottom taskbar and bind each open catalog window to a slot by its
|
||||
* left→right label order. Best-effort hint only — raiseWarmWindow re-verifies and
|
||||
* re-binds by probing, so a wrong/missing read self-corrects on first raise.
|
||||
*/
|
||||
private async bindTaskbarCoords(page: Page): Promise<void> {
|
||||
try {
|
||||
const bar = await ocrRegion(page, VINPIN_TASKBAR_REGION);
|
||||
const order = orderTaskbarWindows(bar);
|
||||
order.forEach((k, i) => {
|
||||
const slot = VINPIN_TASKBAR_SLOTS[i];
|
||||
if (slot) this.taskbarCoords[k] = { x: slot.x, y: slot.y };
|
||||
});
|
||||
if (order.length) this.logger.log(`taskbar order (OCR): [${order.join(", ")}]`);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`bindTaskbarCoords failed (raise-probe will bind lazily): ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the shared seat to a clean state after a failed/not-found decode so it
|
||||
* can't poison the next one. If the browser is already gone (the Fiat null path
|
||||
@@ -588,6 +990,11 @@ export class VinpinDriverService implements OnModuleDestroy {
|
||||
this.loggedIn = false;
|
||||
this.authed = false;
|
||||
this.establishedFlow = null;
|
||||
// A crashed browser is no longer a warm session — the daemon/decode will
|
||||
// re-warm (or fall back to the cold path). isWarm() also gates on the
|
||||
// browser being connected, so this is belt-and-suspenders.
|
||||
this.warm = false;
|
||||
this.taskbarCoords = {};
|
||||
});
|
||||
this.logger.log(`Vinpin browser launched (headless=${!cfg.headful})`);
|
||||
}
|
||||
@@ -945,9 +1352,20 @@ export class VinpinDriverService implements OnModuleDestroy {
|
||||
* model → not_found.
|
||||
*/
|
||||
private async runDialogys(page: Page, vin: string): Promise<RenaultOutcome> {
|
||||
// Reach the brand grid (close the open Rpartstore window), open the Renault
|
||||
// submenu, pick Dialogys, dismiss its Russian-language dialog.
|
||||
// Reach the brand grid (closes the open Rpartstore window), then open Dialogys.
|
||||
await this.returnToBrandGrid(page, readConfig());
|
||||
if (!(await this.openDialogysSubmenu(page))) return { status: "ambiguous" };
|
||||
return this.runDialogysSearch(page, vin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the Renault Dialogys catalog from the VinPower grid via the submenu, WITHOUT
|
||||
* closing any other open window (no returnToBrandGrid). Assumes the grid is
|
||||
* reachable/foreground. Returns true when the Dialogys form is confirmed ready.
|
||||
* Shared by the cold runDialogys (after its grid round-trip), the warm-up opener,
|
||||
* and the warm reopen-if-closed path.
|
||||
*/
|
||||
private async openDialogysSubmenu(page: Page): Promise<boolean> {
|
||||
let submenu = false;
|
||||
for (let i = 0; i < 3 && !submenu; i++) {
|
||||
await page.mouse.move(VINPIN_COORDS.renaultBrand.x, VINPIN_COORDS.renaultBrand.y);
|
||||
@@ -958,7 +1376,7 @@ export class VinpinDriverService implements OnModuleDestroy {
|
||||
await page.waitForTimeout(VINPIN_WAITS.afterRenaultTile);
|
||||
submenu = VINPIN_OCR.renaultSubmenu.test(await ocrRegion(page));
|
||||
}
|
||||
if (!submenu) return { status: "ambiguous" };
|
||||
if (!submenu) return false;
|
||||
let ready = false;
|
||||
for (let i = 0; i < 3 && !ready; i++) {
|
||||
await page.mouse.click(VINPIN_COORDS.renaultDialogys.x, VINPIN_COORDS.renaultDialogys.y);
|
||||
@@ -974,8 +1392,15 @@ export class VinpinDriverService implements OnModuleDestroy {
|
||||
await page.waitForTimeout(VINPIN_WAITS.afterLangDismiss);
|
||||
ready = VINPIN_OCR.dialogysReady.test(await ocrRegion(page));
|
||||
}
|
||||
if (!ready) return { status: "ambiguous" };
|
||||
return ready;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search a VIN on an ALREADY-OPEN Dialogys form: clear+type VIN → ПОИСК → OCR the
|
||||
* header. Success → "<Model> (<platform>), <engine>" (brand defaulted to RENAULT);
|
||||
* empty/no model → not_found. Shared by the cold flow and the warm Dialogys window.
|
||||
*/
|
||||
private async runDialogysSearch(page: Page, vin: string): Promise<RenaultOutcome> {
|
||||
await page.mouse.click(VINPIN_COORDS.dialogysVinField.x, VINPIN_COORDS.dialogysVinField.y);
|
||||
await page.waitForTimeout(350);
|
||||
await page.keyboard.press("Control+A").catch(() => {});
|
||||
|
||||
@@ -100,8 +100,9 @@ export const VINPIN_RENAULT_HEADER_REGION = {
|
||||
|
||||
/** Screenshot clip (px) of the decode-result modal body, fed to OCR. Captures
|
||||
* the model line ("6J - TIPO - EGEA (2015-2021)"), SINCOM, trim, prod-date and
|
||||
* the echoed VIN. Verified live. */
|
||||
export const VINPIN_MODAL_REGION = { x: 250, y: 120, width: 820, height: 480 } as const;
|
||||
* the echoed VIN. Verified live. Widened to ~900px for the warm-session daemon
|
||||
* (some decode modals render wider on the permanent seat). */
|
||||
export const VINPIN_MODAL_REGION = { x: 250, y: 120, width: 900, height: 500 } as const;
|
||||
|
||||
/** OCR keyword sets for state detection (case-insensitive). */
|
||||
export const VINPIN_OCR = {
|
||||
@@ -149,8 +150,129 @@ export const VINPIN_OCR = {
|
||||
rpartstoreNotFound: /bulunamad|İlişikli|Ilisikli|araç\s*bulun|seçilmedi|secilmedi/i,
|
||||
/** Dialogys form/vehicle page reached (Cyrillic UI, ПОИСК / ИЗМЕНИТЬ). */
|
||||
dialogysReady: /ПОИСК|ПОИC|Dialogys|VIN|ИЗМЕНИТЬ/i,
|
||||
|
||||
// ─── Warm-session daemon OCR sets ───
|
||||
/** The RDS/Horizon session is still ALIVE — the desktop status panel shows the
|
||||
* host/seat identifiers ("HORIZON-RDST01" / "trvinpin41080"). Their ABSENCE
|
||||
* combined with a `sessionDropped` marker means the remote session dropped. */
|
||||
sessionAlive: /HORIZON[- ]?RDST01|RDST01|trvinpin\d*/i,
|
||||
/** The RDS/Horizon session DROPPED — a "Disconnected"/reconnect dialog or the
|
||||
* "no free sessions" gate. Triggers a teardown + re-warm + single retry. */
|
||||
sessionDropped:
|
||||
/Disconnected|nofreesession|no\s*free\s*session|reconnect|Session\s*(has\s*)?(been\s*)?(disconnected|ended|expired|timed\s*out)|HTML\s*Access/i,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Which taskbar button belongs to which catalog window. The warm daemon OCRs the
|
||||
* bottom taskbar and matches these labels to bind brand→coordinate at runtime
|
||||
* (the button ORDER depends on the open sequence, so we never hardcode the
|
||||
* mapping — see `orderTaskbarWindows`). `grid` matches the VinPower brand-grid
|
||||
* button used to open the next catalog without closing the current one.
|
||||
*/
|
||||
export const VINPIN_WINDOW_LABELS = {
|
||||
fiat: /ePER|Dealer|Fiat/i,
|
||||
rpartstore: /RPartStore|Rpart/i,
|
||||
dialogys: /Dialogys|Dialog/i,
|
||||
grid: /VinPower|VINPIN|Marka|Brand/i,
|
||||
} as const;
|
||||
|
||||
export type VinpinWindowKey = keyof typeof VINPIN_WINDOW_LABELS;
|
||||
|
||||
/**
|
||||
* OCR keyword that confirms a given catalog window is now in the FOREGROUND after
|
||||
* a taskbar raise (reuses the existing state sets). Used to verify a raise landed
|
||||
* on the right window before running a decode on it.
|
||||
*/
|
||||
export const VINPIN_WINDOW_FOREGROUND: Record<"fiat" | "rpartstore" | "dialogys", RegExp> = {
|
||||
fiat: /Spare\s*Parts|GRANDE\s*PANDA|TIPO|EGEA|DOBLO|Dealer|ePER|Поиск/i,
|
||||
rpartstore: /RPartStore|Rpartstore|Güncel\s*ara|Grup\s*sipariş|Şasi|Sasi/i,
|
||||
dialogys: /ПОИСК|ПОИC|Dialogys|ИЗМЕНИТЬ/i,
|
||||
};
|
||||
|
||||
/** Bottom Horizon/RDS taskbar clip (px), fed to OCR to read the open windows'
|
||||
* button labels left→right. TUNE against the permanent seat @ 1600x900. */
|
||||
export const VINPIN_TASKBAR_REGION = { x: 0, y: 866, width: 1600, height: 34 } as const;
|
||||
|
||||
/** Candidate taskbar button CENTERS along the bottom bar, left→right. The daemon
|
||||
* binds each open window to a slot by the OCR'd label order. Extra slots give
|
||||
* headroom for drift; a raise is OCR-verified and re-tried on the next slot.
|
||||
* TUNE against the permanent seat @ 1600x900. */
|
||||
export const VINPIN_TASKBAR_SLOTS = [
|
||||
{ x: 220, y: 884 },
|
||||
{ x: 360, y: 884 },
|
||||
{ x: 500, y: 884 },
|
||||
{ x: 640, y: 884 },
|
||||
{ x: 780, y: 884 },
|
||||
] as const;
|
||||
|
||||
/** Global "VIN veya katalog ara" router box on the VinPower grid (~1455,96). Only
|
||||
* used to OPEN a brand's window the first time (it routes, it does NOT decode and
|
||||
* the VIN does not carry into the catalog). TUNE. */
|
||||
export const VINPIN_ROUTER_BOX = { x: 1455, y: 96 } as const;
|
||||
|
||||
/** Warm-session daemon tunables. */
|
||||
export const VINPIN_WARM = {
|
||||
/** Business-hours window (Europe/Istanbul) the seat is held warm. */
|
||||
businessStartHour: 8,
|
||||
businessEndHour: 21,
|
||||
/** Idle keepalive cadence — a `mouse.move` every ~75s held the RDS session
|
||||
* 12.6 min with zero disconnect in the live proof. */
|
||||
keepaliveIntervalMs: 75_000,
|
||||
/** Harmless in-window point the keepalive nudges the cursor to. TUNE. */
|
||||
keepalivePoint: { x: 800, y: 500 },
|
||||
/** How often the scheduler reconciles warm-vs-hours. */
|
||||
schedulerIntervalMs: 60_000,
|
||||
/** Retries when verifying/re-trying a taskbar raise across slots. */
|
||||
raiseVerifyRetries: 3,
|
||||
/** After a taskbar-raise click, wait for the window to come forward. */
|
||||
afterRaiseMs: 1_200,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Given the OCR'd taskbar text and the window labels, return the windows in the
|
||||
* left→right order they appear in the bar. Pure + injectable so the brand→slot
|
||||
* binding is unit-testable without a live seat. Windows whose label isn't found
|
||||
* are omitted (the daemon then falls back to open-order for the missing ones).
|
||||
*/
|
||||
export function orderTaskbarWindows(
|
||||
taskbarText: string,
|
||||
keys: readonly ("fiat" | "rpartstore" | "dialogys")[] = ["fiat", "rpartstore", "dialogys"],
|
||||
): ("fiat" | "rpartstore" | "dialogys")[] {
|
||||
const found: { key: "fiat" | "rpartstore" | "dialogys"; idx: number }[] = [];
|
||||
for (const key of keys) {
|
||||
const label = VINPIN_WINDOW_LABELS[key];
|
||||
const m = label.exec(taskbarText);
|
||||
if (m) found.push({ key, idx: m.index });
|
||||
}
|
||||
return found.sort((a, b) => a.idx - b.idx).map((f) => f.key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick which warm catalog WINDOW a VIN should be decoded on. Mirrors
|
||||
* `selectVinpinBrandFlow`: Renault/Dacia → the Rpartstore window (Dialogys is the
|
||||
* in-flow fallback), everything else (incl. Fiat) → the ePER window. Pure/testable.
|
||||
*/
|
||||
export function selectVinpinWarmWindow(vin: string): "fiat" | "rpartstore" {
|
||||
return selectVinpinBrandFlow(vin) === "renault" ? "rpartstore" : "fiat";
|
||||
}
|
||||
|
||||
/** Current hour (0–23) in Europe/Istanbul. Injectable clock for tests. */
|
||||
export function vinpinIstanbulHour(now: Date = new Date()): number {
|
||||
const hourStr = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "Europe/Istanbul",
|
||||
hour: "numeric",
|
||||
hour12: false,
|
||||
}).format(now);
|
||||
// Some runtimes emit "24" for midnight with hour12:false — normalise to 0–23.
|
||||
return Number.parseInt(hourStr, 10) % 24;
|
||||
}
|
||||
|
||||
/** Whether the seat should be held warm right now (08:00–21:00 Europe/Istanbul). */
|
||||
export function isVinpinBusinessHours(now: Date = new Date()): boolean {
|
||||
const h = vinpinIstanbulHour(now);
|
||||
return h >= VINPIN_WARM.businessStartHour && h < VINPIN_WARM.businessEndHour;
|
||||
}
|
||||
|
||||
/** Wait budgets (ms) for each step. Verified live @ 1600x900. */
|
||||
export const VINPIN_WAITS = {
|
||||
afterGoto: 7_000,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isVinpinBrandAllowed, selectVinpinBrandFlow } from "./vinpin.constants";
|
||||
import {
|
||||
isVinpinBrandAllowed,
|
||||
isVinpinBusinessHours,
|
||||
orderTaskbarWindows,
|
||||
selectVinpinBrandFlow,
|
||||
selectVinpinWarmWindow,
|
||||
} from "./vinpin.constants";
|
||||
|
||||
describe("selectVinpinBrandFlow", () => {
|
||||
it("routes Renault WMIs (VF1/VF2) to the renault flow", () => {
|
||||
@@ -42,3 +48,47 @@ describe("isVinpinBrandAllowed", () => {
|
||||
expect(isVinpinBrandAllowed(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectVinpinWarmWindow — brand → taskbar window routing", () => {
|
||||
it("routes Renault/Dacia VINs to the Rpartstore window (Dialogys is the in-flow fallback)", () => {
|
||||
expect(selectVinpinWarmWindow("VF1RFE00653633190")).toBe("rpartstore"); // Renault Kadjar
|
||||
expect(selectVinpinWarmWindow("UU1ABCDEFGH123456")).toBe("rpartstore"); // Dacia
|
||||
});
|
||||
|
||||
it("routes Fiat (and everything else) to the ePER window", () => {
|
||||
expect(selectVinpinWarmWindow("NM435600006H43436")).toBe("fiat"); // Tofaş Egea
|
||||
expect(selectVinpinWarmWindow("WVWZZZ1JZ3W597935")).toBe("fiat"); // VW → fiat default
|
||||
});
|
||||
});
|
||||
|
||||
describe("orderTaskbarWindows — OCR-order taskbar binding", () => {
|
||||
it("returns the windows in their left→right OCR order", () => {
|
||||
const bar = "VinPower Fiat Dealer ePER Renault RPartStore Renault Dialogys";
|
||||
expect(orderTaskbarWindows(bar)).toEqual(["fiat", "rpartstore", "dialogys"]);
|
||||
});
|
||||
|
||||
it("respects a different open order (order is read, not hardcoded)", () => {
|
||||
const bar = "Dialogys | RPartStore | ePER";
|
||||
expect(orderTaskbarWindows(bar)).toEqual(["dialogys", "rpartstore", "fiat"]);
|
||||
});
|
||||
|
||||
it("omits windows whose label isn't on the bar", () => {
|
||||
const bar = "some noise ePER more noise RPartStore";
|
||||
expect(orderTaskbarWindows(bar)).toEqual(["fiat", "rpartstore"]);
|
||||
expect(orderTaskbarWindows("nothing recognizable")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isVinpinBusinessHours — 08:00–21:00 Europe/Istanbul (UTC+3, no DST)", () => {
|
||||
it("is true from 08:00 up to (not including) 21:00 local", () => {
|
||||
expect(isVinpinBusinessHours(new Date("2026-07-14T05:00:00Z"))).toBe(true); // 08:00
|
||||
expect(isVinpinBusinessHours(new Date("2026-07-14T12:00:00Z"))).toBe(true); // 15:00
|
||||
expect(isVinpinBusinessHours(new Date("2026-07-14T17:59:00Z"))).toBe(true); // 20:59
|
||||
});
|
||||
|
||||
it("is false before 08:00 and at/after 21:00 local", () => {
|
||||
expect(isVinpinBusinessHours(new Date("2026-07-14T04:59:00Z"))).toBe(false); // 07:59
|
||||
expect(isVinpinBusinessHours(new Date("2026-07-14T18:00:00Z"))).toBe(false); // 21:00
|
||||
expect(isVinpinBusinessHours(new Date("2026-07-14T22:00:00Z"))).toBe(false); // 01:00
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user