fix(vinpin): detect Rpartstore launch-error via upscaled modal crop, not full 1x frame
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

The prod Rpartstore→Dialogys bail regressed: a DOWN Rpartstore was still
misclassified as a "born-stuck spinner", burning 3 reopens (~54s) and pushing
the decode past the 180s budget → not_found. The rpartstoreLaunchError regex
never matched because acquireLoadedRpartstore's load-poll OCR'd the FULL
1600x900 frame at 1x — at which the small centered Cyrillic modal ("Ошибка
запуска каталога") is illegible to tesseract (it returns the surrounding
brand-grid tiles and drops the modal text). The regex text was actually fine;
the modal was never fed to it.

Root cause (verified live 2026-07-15, seat trvinpin47828): wrong OCR
resolution/region, not wrong regex.

Fix:
- New VINPIN_RPARTSTORE_ERROR_REGION (centered modal crop); OCR it UPSCALED (3x)
  so "Ошибка запуска каталога" reads as "Owwu6ka 3anycka KaTanora" and matches.
- pollRpartstoreState(): each poll reads LOADED off the full frame (large Latin
  text, unchanged) AND the launch-error off the upscaled modal crop → bail on the
  FIRST open, no wasted reopens. Genuine-spinner reopen path preserved.
- Also catch the launch error when ensureRpartstore/raiseWarmWindow can't confirm
  a window (modal is over the grid, no catalog chrome) → dismiss + bail.
- rpartstoreLaunchError regex: add the verbatim live transliterations
  (Owwu6ka/OwwbKa); 3anycka+KaTanora remain the stable anchors.

Live verification (seat trvinpin47828, exclusive night access):
- Rpartstore is DOWN server-side (hard launch error, NOT a spinner).
- New detection returns launchError on open 1 → bail, no reopens.
- Dialogys fallback decoded VF1RFE00653633190 → RENAULT Kadjar (HFE) in 52.2s
  (well under the 180s budget).

Budget/poison/livelock/Fiat paths untouched. 85 vinpin unit tests green;
typecheck + biome clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 05:53:22 +03:00
parent 0ad3f114f3
commit 542ab0cb2b
3 changed files with 176 additions and 32 deletions

View File

@@ -223,7 +223,9 @@ describe("VinpinDriverService — warm session", () => {
* stuck instance and reopen a FRESH one, up to N times, inside the wall-clock
* budget, and only then fall back to Dialogys. These stub the browser-driving
* privates (real seat lives on prod) and drive the OCR-load verdict via a mocked
* pollForState (spinner = {matched:false}, loaded = {matched:true}).
* pollRpartstoreState, which reads LOADED off the full frame and the HARD
* launch-error modal off an UPSCALED crop (spinner = {loaded:false,launchError:
* false}, loaded = {loaded:true}, DOWN = {launchError:true}).
*/
describe("VinpinDriverService — Rpartstore spinner guard", () => {
const savedEnv = { ...process.env };
@@ -257,8 +259,9 @@ describe("VinpinDriverService — Rpartstore spinner guard", () => {
vi.spyOn(any as never, "raiseWarmWindow").mockResolvedValue(true as never);
const reopen = vi.spyOn(any as never, "reopenFreshRpartstore");
const close = vi.spyOn(any as never, "closeRpartstoreTab");
vi.spyOn(any as never, "pollForState").mockResolvedValue({
matched: true,
vi.spyOn(any as never, "pollRpartstoreState").mockResolvedValue({
loaded: true,
launchError: false,
text: "Şasi no ile arama",
} as never);
@@ -276,9 +279,9 @@ describe("VinpinDriverService — Rpartstore spinner guard", () => {
.spyOn(any as never, "closeRpartstoreTab")
.mockResolvedValue(undefined as never);
// open 1 → spinner (not loaded); open 2 (fresh) → loaded.
vi.spyOn(any as never, "pollForState")
.mockResolvedValueOnce({ matched: false, text: "spinner" } as never)
.mockResolvedValueOnce({ matched: true, text: "Ne arıyorsunuz" } as never);
vi.spyOn(any as never, "pollRpartstoreState")
.mockResolvedValueOnce({ loaded: false, launchError: false, text: "spinner" } as never)
.mockResolvedValueOnce({ loaded: true, launchError: false, text: "Ne arıyorsunuz" } as never);
expect(await acquire(driver, true, FAR_DEADLINE())).toBe(true);
expect(raise).toHaveBeenCalledTimes(1); // only the first attempt raises
@@ -294,8 +297,9 @@ describe("VinpinDriverService — Rpartstore spinner guard", () => {
const close = vi
.spyOn(any as never, "closeRpartstoreTab")
.mockResolvedValue(undefined as never);
vi.spyOn(any as never, "pollForState").mockResolvedValue({
matched: false,
vi.spyOn(any as never, "pollRpartstoreState").mockResolvedValue({
loaded: false,
launchError: false,
text: "spinner",
} as never); // never loads
@@ -309,7 +313,7 @@ describe("VinpinDriverService — Rpartstore spinner guard", () => {
const driver = new VinpinDriverService();
const any = driver as unknown as Record<string, unknown>;
const raise = vi.spyOn(any as never, "raiseWarmWindow").mockResolvedValue(true as never);
const poll = vi.spyOn(any as never, "pollForState");
const poll = vi.spyOn(any as never, "pollRpartstoreState");
expect(await acquire(driver, true, Date.now() - 1)).toBe(false);
expect(raise).not.toHaveBeenCalled();
@@ -322,10 +326,12 @@ describe("VinpinDriverService — Rpartstore spinner guard", () => {
const raise = vi.spyOn(any as never, "raiseWarmWindow").mockResolvedValue(true as never);
const reopen = vi.spyOn(any as never, "reopenFreshRpartstore");
const close = vi.spyOn(any as never, "closeRpartstoreTab");
// OCR of the Russian "Ошибка запуска каталога" transliterates to this eng string.
vi.spyOn(any as never, "pollForState").mockResolvedValue({
matched: true,
text: "Renault Rpartstore Owwnbka 3anycka KaTanora",
// Upscaled modal-crop OCR of "Ошибка запуска каталога" — the VERBATIM string
// captured live 2026-07-15 on seat trvinpin47828 (see rpartstoreLaunchError).
vi.spyOn(any as never, "pollRpartstoreState").mockResolvedValue({
loaded: false,
launchError: true,
text: "Rpartstore X Owwu6ka 3anycka KaTanora",
} as never);
const page = {
mouse: { click: vi.fn(async () => undefined) },
@@ -361,20 +367,66 @@ describe("VinpinDriverService — Rpartstore spinner guard", () => {
.spyOn(any as never, "closeRpartstoreTab")
.mockResolvedValue(undefined as never);
// Bare chrome, no content AND no launch-error → born-stuck spinner. Reopen loop.
vi.spyOn(any as never, "pollForState")
.mockResolvedValueOnce({ matched: false, text: "RPartStore" } as never)
.mockResolvedValueOnce({ matched: true, text: "Ne arıyorsunuz" } as never);
vi.spyOn(any as never, "pollRpartstoreState")
.mockResolvedValueOnce({ loaded: false, launchError: false, text: "RPartStore" } as never)
.mockResolvedValueOnce({ loaded: true, launchError: false, text: "Ne arıyorsunuz" } as never);
expect(await acquire(driver, true, FAR_DEADLINE())).toBe(true);
expect(close).toHaveBeenCalledTimes(1); // stuck instance closed (spinner path intact)
expect(reopen).toHaveBeenCalledTimes(1); // one fresh reopen, which loaded
});
it("launch-error OCR pattern matches both the real Cyrillic and its eng-OCR transliteration", () => {
it("launch-error OCR pattern matches the real Cyrillic and its live-captured eng-OCR transliterations", () => {
expect(VINPIN_OCR.rpartstoreLaunchError.test("Ошибка запуска каталога")).toBe(true);
// VERBATIM upscaled-crop OCR captured live 2026-07-15 (seat trvinpin47828): the
// "Ошибка" head varies (Owwu6ka/OwwbKa) but "3anycka"+"KaTanora" are stable.
expect(VINPIN_OCR.rpartstoreLaunchError.test("Rpartstore X Owwu6ka 3anycka KaTanora")).toBe(
true,
);
expect(VINPIN_OCR.rpartstoreLaunchError.test("Volvo (X) OwwbKa 3anycka KaTanora VY")).toBe(
true,
);
expect(VINPIN_OCR.rpartstoreLaunchError.test("Owwnbka 3anycka KaTanora")).toBe(true);
// Must NOT fire on a healthy loaded Rpartstore home (no false short-circuit).
// Must NOT fire on a healthy loaded Rpartstore home (no false short-circuit)
// this is the exact full-frame text a LOADED home shows.
expect(VINPIN_OCR.rpartstoreLaunchError.test("Şasi no ile arama Güncel araçlar")).toBe(false);
// Nor on the brand grid alone (the tiles behind the modal, read on a 1× frame).
expect(
VINPIN_OCR.rpartstoreLaunchError.test("Audi Fiat Renault KIA SEAT Volkswagen Toyota"),
).toBe(false);
});
it("launch error over the grid (ensureRpartstore couldn't confirm a window) → bail to Dialogys, NO reopens", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as Record<string, unknown>;
// Cold path: ensureRpartstore returns false because the DOWN Rpartstore shows
// only the centered launch-error modal over the grid (no catalog chrome).
vi.spyOn(any as never, "ensureRpartstore").mockResolvedValue(false as never);
const reopen = vi.spyOn(any as never, "reopenFreshRpartstore");
const close = vi.spyOn(any as never, "closeRpartstoreTab");
// The upscaled modal crop DOES read the launch error → dismiss + bail.
vi.spyOn(any as never, "rpartstoreLaunchErrorPresent").mockResolvedValue(true as never);
const page = {
mouse: { click: vi.fn(async () => undefined) },
waitForTimeout: vi.fn(async () => undefined),
};
const result = await (
any.acquireLoadedRpartstore as (
p: unknown,
c: unknown,
d: number,
w: boolean,
) => Promise<boolean>
).call(driver, page, {}, FAR_DEADLINE(), false);
expect(result).toBe(false); // → straight to Dialogys
expect(reopen).not.toHaveBeenCalled(); // no reopens burned
expect(close).not.toHaveBeenCalled();
expect(page.mouse.click).toHaveBeenCalledWith(
VINPIN_COORDS.rpartstoreLaunchErrorOk.x,
VINPIN_COORDS.rpartstoreLaunchErrorOk.y,
);
});
it("warm Renault decode: loaded Rpartstore is PRIMARY — Dialogys is not touched on a hit", async () => {

View File

@@ -37,6 +37,7 @@ import {
VINPIN_OCR,
VINPIN_RENAULT_HEADER_REGION,
VINPIN_RPARTSTORE,
VINPIN_RPARTSTORE_ERROR_REGION,
VINPIN_TASKBAR_REGION,
VINPIN_TASKBAR_SLOTS,
VINPIN_TYPE_DELAY_MS,
@@ -1403,30 +1404,36 @@ export class VinpinDriverService implements OnModuleDestroy {
present = await this.reopenFreshRpartstore(page);
}
if (!present) {
// A DOWN Rpartstore renders the hard launch-error modal OVER the brand grid
// (no catalog window/chrome), so ensureRpartstore/raiseWarmWindow can't
// confirm a window and returns false. Before treating that as "reopen and
// retry", check the (upscaled) modal crop for the launch error and bail
// straight to Dialogys — otherwise a DOWN Rpartstore still burns reopens.
if (await this.dismissRpartstoreLaunchError(page)) return false;
this.logger.warn(
`Rpartstore acquire: could not bring a window forward (open ${open}/${VINPIN_RPARTSTORE.maxOpens})`,
);
continue; // next iteration reopens fresh
}
// Step 2 — OCR-verify it actually LOADED (search-home markers) OR caught the
// HARD launch-error modal. Short budget, capped exactly as before. The poll
// stops on EITHER signal; the returned `text` then classifies loaded-vs-error
// (the predicate matches both, so `matched` alone can't distinguish them).
const { matched, text } = await this.pollForState(
// Step 2 — verify it actually LOADED (search-home markers, read on the full
// frame) OR caught the HARD launch-error modal (read on an UPSCALED crop of
// the centered modal — it is ILLEGIBLE on a full 1× frame, which is exactly
// why the old full-frame poll misclassified a DOWN Rpartstore as a spinner and
// burned ~54s of reopens). Short budget, capped exactly as before.
const { loaded, launchError } = await this.pollRpartstoreState(
page,
(t) => VINPIN_OCR.rpartstoreLoaded.test(t) || VINPIN_OCR.rpartstoreLaunchError.test(t),
VINPIN_WAITS.afterRpartstoreLoad,
);
if (matched && VINPIN_OCR.rpartstoreLoaded.test(text)) {
if (loaded) {
if (open > 1) this.logger.log(`Rpartstore loaded after ${open} open attempt(s)`);
return true;
}
// HARD launch error ("Ошибка запуска каталога") → Rpartstore is DOWN
// server-side (entitlement/backend failure); reopening NEVER helps. Dismiss
// the modal and bail STRAIGHT to Dialogys instead of burning the remaining
// reopens (~54s) on a spinner it can never be. This is the whole fix.
if (VINPIN_OCR.rpartstoreLaunchError.test(text)) {
// reopens (~54s). This is the whole fix.
if (launchError) {
this.logger.warn(
"Rpartstore launch error (Ошибка запуска каталога) — Rpartstore is DOWN, dismissing + falling straight back to Dialogys",
);
@@ -1450,6 +1457,67 @@ export class VinpinDriverService implements OnModuleDestroy {
return false;
}
/**
* Detect the Rpartstore HARD launch-error modal by OCR'ing an UPSCALED crop of
* the centered modal region (`VINPIN_RPARTSTORE_ERROR_REGION`, 3× via ocrRegion's
* default). The modal is a small centered Cyrillic dialog that is ILLEGIBLE on a
* full 1600×900 frame at 1× — reading the whole frame returns the surrounding
* brand-grid tiles and drops the tiny modal text, which is why the old full-frame
* poll never matched `rpartstoreLaunchError` and misclassified a DOWN Rpartstore
* as a spinner. Cropping + upscaling makes "Ошибка запуска каталога" legible.
*/
private async rpartstoreLaunchErrorPresent(page: Page): Promise<boolean> {
const modal = await ocrRegion(page, VINPIN_RPARTSTORE_ERROR_REGION);
return VINPIN_OCR.rpartstoreLaunchError.test(modal);
}
/**
* If the Rpartstore launch-error modal is up, dismiss it (click its OK) and
* return true (→ caller bails straight to Dialogys). Returns false when no launch
* error is on screen. Best-effort — click errors are swallowed.
*/
private async dismissRpartstoreLaunchError(page: Page): Promise<boolean> {
if (!(await this.rpartstoreLaunchErrorPresent(page))) return false;
this.logger.warn(
"Rpartstore launch error (Ошибка запуска каталога) detected over the grid — Rpartstore is DOWN, dismissing + falling straight back to Dialogys",
);
await page.mouse
.click(VINPIN_COORDS.rpartstoreLaunchErrorOk.x, VINPIN_COORDS.rpartstoreLaunchErrorOk.y)
.catch(() => undefined);
await page.waitForTimeout(VINPIN_WAITS.afterAlertDismiss);
return true;
}
/**
* Poll the freshly-opened Rpartstore for its load outcome, capped at `capMs`
* (== the old fixed wait, so worst-case timing is unchanged). Each iteration:
* - LOADED → the search-home markers (`rpartstoreLoaded`) read cleanly off the
* FULL frame (large Latin text), OR
* - ERROR → the HARD launch-error modal, read off an UPSCALED crop of the
* centered modal region (illegible on a full 1× frame — the crux of
* the misclassification bug).
* Returns as soon as either is seen; otherwise {loaded:false,launchError:false}
* (→ genuine born-stuck spinner, existing close+reopen path).
*/
private async pollRpartstoreState(
page: Page,
capMs: number,
): Promise<{ loaded: boolean; launchError: boolean; text: string }> {
const interval = Math.max(1, VINPIN_WAITS.pollIntervalMs);
const deadline = Date.now() + capMs;
let text = "";
while (Date.now() < deadline) {
await page.waitForTimeout(Math.min(interval, deadline - Date.now()));
const full = await ocrRegion(page);
text = full;
if (VINPIN_OCR.rpartstoreLoaded.test(full)) return { loaded: true, launchError: false, text };
if (await this.rpartstoreLaunchErrorPresent(page)) {
return { loaded: false, launchError: true, text };
}
}
return { loaded: false, launchError: false, text };
}
/**
* Close a (possibly born-stuck) Rpartstore window/tab: the catalog tab ✕ first,
* then the window-close ✕ as a fallback if Rpartstore chrome is still on screen.

View File

@@ -119,6 +119,25 @@ export const VINPIN_RENAULT_HEADER_REGION = {
* (some decode modals render wider on the permanent seat). */
export const VINPIN_MODAL_REGION = { x: 250, y: 120, width: 900, height: 500 } as const;
/** Screenshot clip (px) of the CENTERED Rpartstore hard launch-error modal
* ("Renault Rpartstore" / "Ошибка запуска каталога", OK ~868,530), fed to OCR
* UPSCALED (ocrRegion's default 3×). ⚠️ ROOT-CAUSE NOTE (verified live 2026-07-15
* on seat trvinpin47828): the modal is a small (~220×140px) centered Cyrillic
* dialog. OCR'ing the FULL 1600×900 frame at 1× (what the old load-poll did)
* CANNOT read it — tesseract returns the surrounding brand-grid tiles and drops
* the tiny modal text, so `rpartstoreLaunchError` never matched → the DOWN
* Rpartstore was misclassified as a "born-stuck spinner" and 3 reopens (~54s) were
* burned before Dialogys. Cropping to THIS region + 3× upscale makes the string
* legible ("Owwu6ka 3anycka KaTanora"), so the launch error is detected on the
* FIRST open and the flow bails straight to Dialogys. Sized generously around the
* screen-centered dialog to tolerate drift while excluding the right sidebar. */
export const VINPIN_RPARTSTORE_ERROR_REGION = {
x: 540,
y: 370,
width: 560,
height: 230,
} as const;
/** OCR keyword sets for state detection (case-insensitive). */
export const VINPIN_OCR = {
/** Horizon HTML-Access app launcher (permanent seat, no auto-launch). Its
@@ -170,11 +189,16 @@ export const VINPIN_OCR = {
* (Catalog launch error) dialog that fires within ~8-10s of opening when
* Rpartstore is DOWN server-side (entitlement/backend failure). Reopening never
* helps, so detecting this must SHORT-CIRCUIT straight to Dialogys instead of
* burning the reopen budget. The eng-OCR of the Cyrillic string is stable as
* "Owwnbka 3anycka KaTanora", so we match BOTH the real Cyrillic and its OCR
* transliteration. Its title alone ("Renault Rpartstore") false-matches
* rpartstoreOpen — this content string is what disambiguates the error. */
rpartstoreLaunchError: /запуска\s*катал|Ошибка\s*запуск|3anycka|KaTanora|Owwnbka/i,
* burning the reopen budget. ⚠️ This must be read from an UPSCALED crop of
* `VINPIN_RPARTSTORE_ERROR_REGION` — on a full 1×frame the modal is illegible
* (see that region's note). The eng-OCR of the Cyrillic "Ошибка запуска
* каталога" transliterates to "Owwu6ka 3anycka KaTanora" / "OwwbKa 3anycka
* KaTanora" (captured verbatim live 2026-07-15). The `3anycka`+`KaTanora` tokens
* are the stable anchors; the "Ошибка" head transliterates variably
* (Owwu6ka/OwwbKa/Owwnbka) so all seen forms are listed. Its title alone
* ("Renault Rpartstore") false-matches rpartstoreOpen — this content string is
* what disambiguates the error. */
rpartstoreLaunchError: /запуска\s*катал|Ошибка\s*запуск|3anycka|KaTanora|Owwu6ka|OwwbKa|Owwnbka/i,
/** Rpartstore decoded a vehicle — header shows RENAULT/DACIA <model> + Şasi. */
rpartstoreHit: /RENAULT|DACIA|Şasi\s*:|Sasi\s*:/i,
/** Rpartstore genuine not-found — the error card ("İlişikli araç bulunamadı" /