fix(vinpin): detect Rpartstore hard launch-error → bail straight to Dialogys
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

Rpartstore currently throws an immediate hard launch-error modal ("Ошибка
запуска каталога" / title "Renault Rpartstore") within ~8-10s of every open —
a server-side/entitlement failure that reopening never fixes. The old code
misclassified it: the error dialog's title matched rpartstoreOpen so
ensureRpartstore returned true, but it lacked the rpartstoreLoaded content
markers, so acquireLoadedRpartstore judged it a born-stuck spinner and burned
all 3 reopens (~54s) before falling back to Dialogys — which then ran out of
the 150s decode budget → not_found. Dialogys itself decodes correctly (~25s).

Fix (detect-and-bail on the FIRST open, no wasted reopens):
- constants: add VINPIN_COORDS.rpartstoreLaunchErrorOk (868,530) + the
  VINPIN_OCR.rpartstoreLaunchError pattern (matches the real Cyrillic AND its
  stable eng-OCR transliteration "Owwnbka 3anycka KaTanora").
- acquireLoadedRpartstore step 2: poll now stops on loaded OR launch-error and
  classifies via the returned OCR text; a launch error dismisses the modal and
  returns false immediately → straight to Dialogys (reopen loop untouched for
  genuine spinners).
- ensureRpartstore: submenu-open poll also stops fast on the launch error
  instead of dead-waiting afterRenaultCatalogOpen.
- bump VINPIN_DECODE_BUDGET_MS 150s→180s (cheap safety margin).
- tests: launch-error → false after ONE open (no reopens, dismiss clicked);
  genuine spinner still reopens; OCR-pattern matches Cyrillic + transliteration.

Never-throw contract, sessionPoisoned, budget teardown, cold/Dialogys
fallbacks, the fcc0298 spinner-guard, the 61b5769 establish/teardown fix and
the Fiat ePER path are all intact. Skipped the warm-path Dialogys field-clear
tweak — runDialogysSearch is shared with the cold path and switching its clear
step would change cold behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 04:24:02 +03:00
parent 61b5769e48
commit 0ad3f114f3
3 changed files with 110 additions and 10 deletions

View File

@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { VinpinDriverService, VinpinSessionDroppedError } from "./vinpin-driver.service";
import { VINPIN_COORDS, VINPIN_OCR } from "./vinpin.constants";
/**
* Livelock-breaker unit tests for the Vinpin decode driver. These exercise the
@@ -315,6 +316,67 @@ describe("VinpinDriverService — Rpartstore spinner guard", () => {
expect(poll).not.toHaveBeenCalled();
});
it("HARD launch error (Rpartstore DOWN) → returns false after ONE open, NO reopens, dismiss clicked → caller falls to Dialogys", async () => {
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 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",
} 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(), true);
expect(result).toBe(false); // → warmRenaultDecode/runRenaultFlow go straight to Dialogys
expect(raise).toHaveBeenCalledTimes(1); // opened exactly ONCE
expect(reopen).not.toHaveBeenCalled(); // NO reopens burned (~54s saved)
expect(close).not.toHaveBeenCalled(); // not treated as a spinner
// The launch-error modal's OK button was clicked to dismiss it.
expect(page.mouse.click).toHaveBeenCalledWith(
VINPIN_COORDS.rpartstoreLaunchErrorOk.x,
VINPIN_COORDS.rpartstoreLaunchErrorOk.y,
);
});
it("genuine spinner (no launch-error string) STILL triggers the close+reopen loop (unchanged)", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as Record<string, unknown>;
vi.spyOn(any as never, "raiseWarmWindow").mockResolvedValue(true as never);
const reopen = vi.spyOn(any as never, "reopenFreshRpartstore").mockResolvedValue(true as never);
const close = vi
.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);
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", () => {
expect(VINPIN_OCR.rpartstoreLaunchError.test("Ошибка запуска каталога")).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).
expect(VINPIN_OCR.rpartstoreLaunchError.test("Şasi no ile arama Güncel araçlar")).toBe(false);
});
it("warm Renault decode: loaded Rpartstore is PRIMARY — Dialogys is not touched on a hit", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as Record<string, unknown>;

View File

@@ -1409,19 +1409,36 @@ export class VinpinDriverService implements OnModuleDestroy {
continue; // next iteration reopens fresh
}
// Step 2 — OCR-verify it actually LOADED (search-home markers), short budget.
const { matched } = await this.pollForState(
// 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(
page,
(t) => VINPIN_OCR.rpartstoreLoaded.test(t),
(t) => VINPIN_OCR.rpartstoreLoaded.test(t) || VINPIN_OCR.rpartstoreLaunchError.test(t),
VINPIN_WAITS.afterRpartstoreLoad,
);
if (matched) {
if (matched && VINPIN_OCR.rpartstoreLoaded.test(text)) {
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)) {
this.logger.warn(
"Rpartstore launch error (Ошибка запуска каталога) — 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 false; // straight to Dialogys, no reopen loop
}
// Step 3 — still spinning (born stuck) → close the tab so the next iteration
// reopens a FRESH instance.
// Step 3 — genuinely still spinning (born stuck) → close the tab so the next
// iteration reopens a FRESH instance (existing recovery path, unchanged).
this.logger.warn(
`Rpartstore born-stuck (spinner) on open ${open}/${VINPIN_RPARTSTORE.maxOpens} — closing + reopening fresh`,
);
@@ -1486,10 +1503,13 @@ export class VinpinDriverService implements OnModuleDestroy {
);
// 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.
// match). Also stop fast on the HARD launch-error modal so a DOWN Rpartstore
// doesn't dead-wait the full afterRenaultCatalogOpen budget before the
// acquire loop's launch-error short-circuit takes over. Cap =
// afterRenaultCatalogOpen fallback.
await this.pollForState(
page,
(t) => VINPIN_OCR.eperOpen.test(t),
(t) => VINPIN_OCR.eperOpen.test(t) || VINPIN_OCR.rpartstoreLaunchError.test(t),
VINPIN_WAITS.afterRenaultCatalogOpen,
);
// Both catalogs pop a Russian-language dialog on open → OK.

View File

@@ -86,6 +86,11 @@ export const VINPIN_COORDS = {
rpartstoreErrorClose: { x: 928, y: 167 },
/** Rpartstore yellow search button (fallback to Enter submit). TUNE. */
rpartstoreSearchBtn: { x: 975, y: 195 },
/** "OK" button of the Rpartstore hard launch-error modal ("Ошибка запуска
* каталога" / title "Renault Rpartstore") that fires when Rpartstore is DOWN
* server-side. Verified live @ 1600x900 (viewport frame) — dismisses the modal
* so the driver can fall straight back to Dialogys. TUNE. */
rpartstoreLaunchErrorOk: { x: 868, y: 530 },
/** Dialogys VIN field. TUNE. */
dialogysVinField: { x: 457, y: 258 },
/** Dialogys ПОИСК (search) button. TUNE. */
@@ -161,6 +166,15 @@ export const VINPIN_OCR = {
* content-only tokens so a spinning-but-titled window never false-passes. */
rpartstoreLoaded:
/Şasi\s*no\s*ile|Sasi\s*no\s*ile|Ne\s*arıyor|Ne\s*ariyor|Güncel\s*ara(ç|c)|Guncel\s*ara(ç|c)|Grup\s*sipariş|arıyorsunuz/i,
/** Rpartstore HARD launch-error modal — the Russian "Ошибка запуска каталога"
* (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,
/** 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ı" /
@@ -344,8 +358,12 @@ export const VINPIN_WAITS = {
* tears the browser down, poisons the seat (forcing the next decode to cold
* re-establish) and returns null. Kept well above the healthy p90 (a warm decode
* is seconds; a full cold re-establish is ~60-80s) so it only ever fires on a
* genuine stall. Override with VINPIN_DECODE_BUDGET_MS. */
export const VINPIN_DECODE_BUDGET_MS = 150_000;
* genuine stall. Bumped 150s→180s to leave a cheap safety margin for a slow cold
* establish stacking on the Rpartstore launch-error probe + the Dialogys fallback
* decode (~25s) — the Rpartstore launch-error short-circuit means we no longer
* waste ~54s of reopens, but the extra headroom covers a cold-establish edge.
* Override with VINPIN_DECODE_BUDGET_MS. */
export const VINPIN_DECODE_BUDGET_MS = 180_000;
/**
* Rpartstore acquire-with-spinner-guard tunables. A freshly-opened Rpartstore