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
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:
@@ -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 () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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ı" /
|
||||
|
||||
Reference in New Issue
Block a user