perf(vinpin): in-session Renault→Fiat swap + OCR-poll waits + input micro-trims
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

Latency optimizations in the flag-gated (VINPIN_ENABLED) Vinpin decode driver.
Every change keeps the existing fixed-wait value as a fallback cap, so
worst-case behaviour and robustness are unchanged — only the common case is
faster. No live seat session was run (prod holds the single Horizon seat).

#2 Renault→Fiat symmetric swap: ensureReady no longer tears the whole browser
down on a Renault→Fiat flow switch (was a ~60-80s cold restart). It now keeps
the authenticated Horizon/VinPower session, returns to the brand grid via the
existing ensureBrandGrid (which closes a resumed foreign catalog window) and
reopens Fiat via the normal tile flow — symmetric with the Fiat→Renault
direction. Guarded fallback: if the in-session swap can't reach the grid /
throws, it falls back to close()+cold re-establish.

#3 OCR-poll-until-ready: replaced big fixed post-action sleeps whose completion
is OCR-detectable with a capped poll (screenshot→ocrRegion→regex every ~700ms,
return on match, cap == old fixed wait). Converted: afterFiatOpen(12s),
afterVinSubmit(7s, Fiat modal), afterRpartstoreSubmit(8s),
afterRenaultCatalogOpen(12s, ×2), plus cold-path afterLogin(20s) and
afterVinPowerLogin(18s). Left afterVinpinLaunch(14s) fixed — the VinPower login
dialog has no reliable OCR marker (detected via DOM), and it doubles as a
generic connecting-screen settle. Left afterDialogysSubmit fixed (out of scope).

#4 Micro-trims: field-clear Backspace burst 40→5 (VINPIN_FIELD_CLEAR_BACKSPACES),
key-type delay 45-50ms→20ms (VINPIN_TYPE_DELAY_MS, 17-char VIN ~850→~340ms),
afterAlertDismiss 700→250ms.

New pure/injectable pollForText helper in vinpin.ocr.ts (unit-tested:
early-return, cap-never-exceeded, first-read match). typecheck + biome clean;
31 vinpin unit tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 09:57:40 +03:00
parent 77c93af9a0
commit eaa500ddd5
4 changed files with 259 additions and 32 deletions

View File

@@ -31,14 +31,16 @@ import { Injectable, Logger, type OnModuleDestroy } from "@nestjs/common";
import type { Browser, BrowserContext, Page } from "playwright";
import {
VINPIN_COORDS,
VINPIN_FIELD_CLEAR_BACKSPACES,
VINPIN_MODAL_REGION,
VINPIN_OCR,
VINPIN_RENAULT_HEADER_REGION,
VINPIN_TYPE_DELAY_MS,
VINPIN_VIEWPORT,
VINPIN_WAITS,
selectVinpinBrandFlow,
} from "./vinpin.constants";
import { ocrRegion, terminateOcr } from "./vinpin.ocr";
import { type OcrClip, ocrRegion, pollForText, terminateOcr } from "./vinpin.ocr";
import {
type VinpinParsed,
type VinpinRenaultParsed,
@@ -146,6 +148,28 @@ export class VinpinDriverService implements OnModuleDestroy {
// ─── Private ─────────────────────────────────────────────
/**
* OCR-poll wrapper: screenshot → `ocrRegion` every `pollIntervalMs` and return
* as soon as `predicate` matches. CAPPED at `capMs` (== the old fixed wait) so
* if OCR never catches the transition the total wait is unchanged — this only
* makes the common case faster, never the worst case slower. `clip` undefined =
* OCR the whole frame (state detection).
*/
private pollForState(
page: Page,
predicate: (text: string) => boolean,
capMs: number,
clip?: OcrClip,
): Promise<{ matched: boolean; text: string }> {
return pollForText({
read: () => ocrRegion(page, clip),
predicate,
capMs,
intervalMs: VINPIN_WAITS.pollIntervalMs,
sleep: (ms) => page.waitForTimeout(ms),
});
}
private async decodeLocked(vin: string, cfg: VinpinConfig): Promise<VinpinDecodeResult | null> {
const flow = selectVinpinBrandFlow(vin);
if (flow === "renault") return this.decodeRenaultLocked(vin, cfg);
@@ -271,9 +295,23 @@ export class VinpinDriverService implements OnModuleDestroy {
* the Fiat ePER search page. (Re)establishes whatever is missing.
*/
private async ensureReady(cfg: VinpinConfig): Promise<void> {
// A Renault catalog window from a prior decode leaves the session on the
// wrong flow — tear down for a clean Fiat re-establish.
if (this.establishedFlow === "renault") await this.close();
// A Renault catalog window from a prior decode leaves the session on the wrong
// flow. Symmetric with the Fiat→Renault direction (which swaps catalog windows
// in-session via ensureBrandGrid, NOT a teardown): try to keep the warm Horizon
// /VinPower session and swap Renault→Fiat in-session (~20-40s) instead of a full
// browser teardown + cold restart (~60-80s). Fall back to close()+cold
// re-establish only if the in-session swap can't reach the grid — so robustness
// and worst-case behaviour are unchanged.
if (this.establishedFlow === "renault") {
const swapped = await this.swapToFiatInSession(cfg).catch((err) => {
this.logger.warn(`in-session Renault→Fiat swap threw: ${(err as Error).message}`);
return false;
});
if (!swapped) {
this.logger.warn("in-session Renault→Fiat swap failed — cold re-establish fallback");
await this.close();
}
}
if (!this.browser?.isConnected() || !this.context || !this.page || this.page.isClosed()) {
await this.launch(cfg);
}
@@ -282,6 +320,33 @@ export class VinpinDriverService implements OnModuleDestroy {
}
}
/**
* Swap a warm Renault-flow session over to Fiat WITHOUT tearing the browser
* down: keep the authenticated Horizon/VinPower session, return to the VinPower
* brand grid (reusing `ensureBrandGrid`, which closes a resumed foreign catalog
* window), then open Fiat ePER via the normal tile flow (`establishSession`).
* Returns true when the Fiat Spare-Parts catalogue is confirmed; false if the
* warm session is gone or the grid was never reached (the caller then falls back
* to a cold re-establish, so robustness is unchanged).
*/
private async swapToFiatInSession(cfg: VinpinConfig): Promise<boolean> {
if (!this.browser?.isConnected() || !this.context || !this.page || this.page.isClosed()) {
return false; // nothing warm to reuse → let the cold path handle it
}
if (!this.authed) return false; // never got past login → cold path is simpler
const page = this.page;
// Return to the brand grid, closing the resumed Renault catalog window.
await this.returnToBrandGrid(page, cfg);
if (!VINPIN_OCR.brandGrid.test(await ocrRegion(page))) return false;
// On the grid & still authenticated → run the Fiat-open flow. Clearing these
// makes establishSession run its Fiat-tile steps (ensureAuthenticated is a
// no-op while authed, so the Horizon/VinPower session is preserved).
this.loggedIn = false;
this.establishedFlow = null;
await this.establishSession(cfg);
return this.loggedIn && this.establishedFlow === "fiat";
}
/**
* Bring the session up to the VinPower brand grid only (shared by both flows):
* launch if the browser is down, log in via the DOM form if not authenticated,
@@ -306,7 +371,13 @@ export class VinpinDriverService implements OnModuleDestroy {
.fill(cfg.pass ?? "");
const submit = page.locator('button:has-text("Oturum"), [type="submit"]').first();
await submit.click();
await page.waitForTimeout(VINPIN_WAITS.afterLogin);
// Poll for the post-login screen: the Horizon app-launcher (permanent seat) or
// the VinPower brand grid (trial auto-launch). Cap = afterLogin fallback.
await this.pollForState(
page,
(t) => VINPIN_OCR.launcher.test(t) || VINPIN_OCR.brandGrid.test(t),
VINPIN_WAITS.afterLogin,
);
// Bring VinPower up (permanent seat needs the app clicked; trial auto-launches).
await this.ensureBrandGrid(page, cfg);
@@ -399,9 +470,13 @@ export class VinpinDriverService implements OnModuleDestroy {
await page.mouse.down();
await page.waitForTimeout(80);
await page.mouse.up();
await page.waitForTimeout(VINPIN_WAITS.afterFiatOpen);
const t = await ocrRegion(page);
opened = VINPIN_OCR.eperOpen.test(t);
// Poll for the ePER window to open (cap = afterFiatOpen fallback).
const { matched } = await this.pollForState(
page,
(t) => VINPIN_OCR.eperOpen.test(t),
VINPIN_WAITS.afterFiatOpen,
);
opened = matched;
if (!opened) this.logger.warn(`Fiat ePER open attempt ${i + 1}/4 — not yet (retrying)`);
}
if (!opened) throw new Error("Fiat ePER did not open after retries");
@@ -449,7 +524,13 @@ export class VinpinDriverService implements OnModuleDestroy {
// If VinPower's own login dialog is up, satisfy it (never re-click the app
// tile mid-login — that would double-launch).
if (await this.submitVinPowerLogin(page, cfg)) {
await page.waitForTimeout(VINPIN_WAITS.afterVinPowerLogin);
// Poll for the brand grid to render (cap = afterVinPowerLogin fallback).
// The loop re-verifies at the top, so this only shortens the dead wait.
await this.pollForState(
page,
(t) => VINPIN_OCR.brandGrid.test(t),
VINPIN_WAITS.afterVinPowerLogin,
);
continue;
}
// On the launcher → click the VINPIN app to launch VinPower.
@@ -539,14 +620,21 @@ export class VinpinDriverService implements OnModuleDestroy {
await page.keyboard.press("Control+A").catch(() => {});
await page.keyboard.press("Delete").catch(() => {});
await page.waitForTimeout(200);
await page.keyboard.type(vin, { delay: 50 });
await page.keyboard.type(vin, { delay: VINPIN_TYPE_DELAY_MS });
await page.waitForTimeout(400);
// Submit (arrow) and wait for the decode modal.
// Submit (arrow) and poll the modal region until it carries a result or the
// "not found" alert (cap = afterVinSubmit fallback). The Blast canvas exposes
// no DOM text and the clipboard is unreliable, so the screenshot OCR of the
// modal region is the only dependable read channel (see vinpin.ocr).
await page.mouse.click(VINPIN_COORDS.vinSubmitArrow.x, VINPIN_COORDS.vinSubmitArrow.y);
await page.waitForTimeout(VINPIN_WAITS.afterVinSubmit);
const text = await this.extractModalText(page);
const { text: modalText } = await this.pollForState(
page,
(t) => VINPIN_OCR.modalHit.test(t) || VINPIN_OCR.notFound.test(t),
VINPIN_WAITS.afterVinSubmit,
VINPIN_MODAL_REGION,
);
const text = modalText.length > 0 ? modalText : null;
// Genuine not-found: ePER shows a "vehicles not found" alert over the bare
// catalogue (no model/prod-date). Dismiss it and report null cleanly.
@@ -563,16 +651,6 @@ export class VinpinDriverService implements OnModuleDestroy {
return text;
}
/**
* Read the decode-modal text by OCR'ing a screenshot of the modal region. The
* Blast canvas exposes no DOM text and the clipboard is unreliable, so the
* screenshot is the only dependable read channel (see vinpin.ocr).
*/
private async extractModalText(page: Page): Promise<string | null> {
const text = await ocrRegion(page, VINPIN_MODAL_REGION);
return text.length > 0 ? text : null;
}
// ─── Renault flow (Rpartstore primary + Dialogys fallback) ───
/**
@@ -617,7 +695,14 @@ export class VinpinDriverService implements OnModuleDestroy {
VINPIN_COORDS.renaultRpartstore.x,
VINPIN_COORDS.renaultRpartstore.y,
);
await page.waitForTimeout(VINPIN_WAITS.afterRenaultCatalogOpen);
// Poll for the catalog window / its Russian-language dialog to appear (the
// Cyrillic/ePER chrome is absent from the Latin submenu, so no false early
// match). Cap = afterRenaultCatalogOpen fallback.
await this.pollForState(
page,
(t) => VINPIN_OCR.eperOpen.test(t),
VINPIN_WAITS.afterRenaultCatalogOpen,
);
// Both catalogs pop a Russian-language dialog on open → OK.
await page.mouse.click(VINPIN_COORDS.renaultLangOk.x, VINPIN_COORDS.renaultLangOk.y);
await page.waitForTimeout(VINPIN_WAITS.afterLangDismiss);
@@ -665,13 +750,20 @@ export class VinpinDriverService implements OnModuleDestroy {
await page.waitForTimeout(350);
await page.keyboard.press("Control+A").catch(() => {});
await page.keyboard.press("Delete").catch(() => {});
for (let i = 0; i < 40; i++) await page.keyboard.press("Backspace").catch(() => {});
for (let i = 0; i < VINPIN_FIELD_CLEAR_BACKSPACES; i++)
await page.keyboard.press("Backspace").catch(() => {});
await page.waitForTimeout(200);
await page.keyboard.type(vin, { delay: 45 });
await page.keyboard.type(vin, { delay: VINPIN_TYPE_DELAY_MS });
await page.waitForTimeout(400);
// Submit with Enter (the yellow button moves — do NOT click it).
await page.keyboard.press("Enter").catch(() => {});
await page.waitForTimeout(VINPIN_WAITS.afterRpartstoreSubmit);
// Poll for the vehicle header (hit) or the not-found error card to render
// (cap = afterRpartstoreSubmit fallback).
await this.pollForState(
page,
(t) => VINPIN_OCR.rpartstoreHit.test(t) || VINPIN_OCR.rpartstoreNotFound.test(t),
VINPIN_WAITS.afterRpartstoreSubmit,
);
// The vehicle header renders in the clip region; re-check both the clip and a
// full frame so a slow render or a mispositioned header still reads.
@@ -713,7 +805,14 @@ export class VinpinDriverService implements OnModuleDestroy {
let ready = false;
for (let i = 0; i < 3 && !ready; i++) {
await page.mouse.click(VINPIN_COORDS.renaultDialogys.x, VINPIN_COORDS.renaultDialogys.y);
await page.waitForTimeout(VINPIN_WAITS.afterRenaultCatalogOpen);
// Poll for the catalog window / its Russian-language dialog to appear (the
// Cyrillic/ePER chrome is absent from the Latin submenu). Cap =
// afterRenaultCatalogOpen fallback.
await this.pollForState(
page,
(t) => VINPIN_OCR.eperOpen.test(t),
VINPIN_WAITS.afterRenaultCatalogOpen,
);
await page.mouse.click(VINPIN_COORDS.renaultLangOk.x, VINPIN_COORDS.renaultLangOk.y);
await page.waitForTimeout(VINPIN_WAITS.afterLangDismiss);
ready = VINPIN_OCR.dialogysReady.test(await ocrRegion(page));
@@ -724,9 +823,10 @@ export class VinpinDriverService implements OnModuleDestroy {
await page.waitForTimeout(350);
await page.keyboard.press("Control+A").catch(() => {});
await page.keyboard.press("Delete").catch(() => {});
for (let i = 0; i < 40; i++) await page.keyboard.press("Backspace").catch(() => {});
for (let i = 0; i < VINPIN_FIELD_CLEAR_BACKSPACES; i++)
await page.keyboard.press("Backspace").catch(() => {});
await page.waitForTimeout(200);
await page.keyboard.type(vin, { delay: 45 });
await page.keyboard.type(vin, { delay: VINPIN_TYPE_DELAY_MS });
await page.waitForTimeout(400);
await page.mouse.click(VINPIN_COORDS.dialogysSearch.x, VINPIN_COORDS.dialogysSearch.y);
await page.waitForTimeout(VINPIN_WAITS.afterDialogysSubmit);

View File

@@ -164,7 +164,12 @@ export const VINPIN_WAITS = {
afterLangDismiss: 2_500,
afterSearchOpen: 5_000,
afterVinSubmit: 7_000,
afterAlertDismiss: 700,
afterAlertDismiss: 250,
/** OCR poll cadence when waiting for a detectable end-state. Each poll is
* CAPPED at the corresponding fixed-wait budget above (kept as a fallback), so
* if OCR never catches the transition the total wait == the old fixed sleep and
* worst-case behaviour is unchanged. */
pollIntervalMs: 700,
// ─── Renault flow waits ───
/** After clicking the window-close (X) of an open catalog window. */
afterWindowClose: 3_500,
@@ -178,6 +183,15 @@ export const VINPIN_WAITS = {
afterDialogysSubmit: 8_000,
} as const;
/** Per-key delay (ms) when typing a VIN into a focused canvas field. A focused
* field keeps up at ~20ms; the old 45-50ms was conservative padding (17-char VIN
* ≈ 340ms vs ≈ 850ms). */
export const VINPIN_TYPE_DELAY_MS = 20;
/** Backspaces used to clear a VIN field after Ctrl+A + Delete. A few for
* insurance on a focused field — the old 40 was overkill. */
export const VINPIN_FIELD_CLEAR_BACKSPACES = 5;
/**
* Known Fiat model tokens used to extract the model name from the decode modal
* text. The modal interleaves Cyrillic boilerplate, a platform code, the model

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import { pollForText } from "./vinpin.ocr";
/** Deterministic fake clock: `sleep` advances virtual time; `now` reads it. */
function makeClock() {
let t = 0;
const slept: number[] = [];
return {
now: () => t,
sleep: async (ms: number) => {
slept.push(ms);
t += ms;
},
slept,
total: () => slept.reduce((a, b) => a + b, 0),
};
}
describe("pollForText", () => {
it("returns early as soon as the predicate matches (fast path)", async () => {
const clock = makeClock();
const reads = ["no", "no", "hit"];
let i = 0;
const res = await pollForText({
read: async () => reads[i++] ?? "",
predicate: (t) => t === "hit",
capMs: 10_000,
intervalMs: 700,
sleep: clock.sleep,
now: clock.now,
});
expect(res.matched).toBe(true);
expect(res.text).toBe("hit");
// Stopped at the 3rd read — well under the cap.
expect(i).toBe(3);
expect(clock.total()).toBe(2100);
expect(clock.total()).toBeLessThan(10_000);
});
it("caps total sleep at capMs when the predicate never matches (fallback == old fixed wait)", async () => {
const clock = makeClock();
let reads = 0;
const res = await pollForText({
read: async () => {
reads++;
return "no";
},
predicate: (t) => t === "hit",
capMs: 2_000,
intervalMs: 700,
sleep: clock.sleep,
now: clock.now,
});
expect(res.matched).toBe(false);
expect(res.text).toBe("no");
// Total sleep is exactly the cap — never longer than the old fixed wait.
expect(clock.total()).toBe(2_000);
// The final interval was clamped to the remaining budget (no overshoot).
expect(Math.max(...clock.slept)).toBeLessThanOrEqual(700);
expect(reads).toBeGreaterThan(0);
});
it("matches on the very first read without over-sleeping", async () => {
const clock = makeClock();
const res = await pollForText({
read: async () => "ready",
predicate: (t) => t.includes("ready"),
capMs: 12_000,
intervalMs: 750,
sleep: clock.sleep,
now: clock.now,
});
expect(res.matched).toBe(true);
expect(clock.total()).toBe(750); // one interval, then matched
});
});

View File

@@ -133,6 +133,43 @@ export async function ocrRegion(page: Page, clip?: OcrClip, scale = 3): Promise<
}
}
/**
* Poll a text `read` until `predicate(text)` holds, or the `capMs` budget is
* exhausted. Returns as soon as the predicate matches (the common, fast case);
* otherwise the total elapsed sleep equals `capMs` — the SAME fallback wait the
* old fixed `waitForTimeout(capMs)` used, so worst-case behaviour is unchanged.
*
* Pure/injectable (no Playwright/OCR deps) so it can be unit-tested directly:
* pass a fake `read`, `sleep` and `now`. The driver wraps it with an OCR read.
*/
export interface PollForTextOptions {
read: () => Promise<string>;
predicate: (text: string) => boolean;
/** Fallback cap — total sleep never exceeds this (== the old fixed wait). */
capMs: number;
/** Cadence between reads. */
intervalMs: number;
sleep: (ms: number) => Promise<void>;
/** Injectable clock (defaults to Date.now) — for deterministic tests. */
now?: () => number;
}
export async function pollForText(
opts: PollForTextOptions,
): Promise<{ matched: boolean; text: string }> {
const now = opts.now ?? (() => Date.now());
const interval = Math.max(1, opts.intervalMs);
const deadline = now() + opts.capMs;
let text = "";
while (now() < deadline) {
const remaining = deadline - now();
await opts.sleep(Math.min(interval, remaining));
text = await opts.read();
if (opts.predicate(text)) return { matched: true, text };
}
return { matched: false, text };
}
/** Tear down the shared tesseract worker (best-effort). */
export async function terminateOcr(): Promise<void> {
if (!workerPromise) return;