fix(vinpin): Rpartstore acquire-with-spinner-guard for Renault decode

A freshly-opened Rpartstore instance sometimes gets "born stuck" on an
infinite spinner (survives raise/maximize). The only reliable fix, proven
in a live spike, is to CLOSE the stuck instance and reopen a FRESH one.

Replace the old "raise Rpartstore → if not focusable reopen → else fall to
Dialogys" with a bounded load-verify-else-reopen loop applied to BOTH the
warm-daemon Renault path (warmRenaultDecode) and the cold per-decode path
(runRenaultFlow):

- acquireLoadedRpartstore(): bring a Rpartstore window forward, OCR-verify it
  actually rendered its search-home landing markers (new rpartstoreLoaded set —
  content tokens the spinner lacks), and if still spinning close the tab and
  reopen a fresh instance. Retries up to VINPIN_RPARTSTORE.maxOpens (3) times.
- Bounded inside the decode wall-clock budget AND a tighter acquireBudgetMs
  (90s) sub-cap, so a permanently-stuck Rpartstore still leaves headroom to
  fall back to Dialogys — never a livelock.
- Rpartstore stays PRIMARY (richer Turkish catalog); Dialogys only as a
  last resort once reopen attempts are exhausted or it genuinely misses.
- Fiat ePER path unchanged; sessionPoisoned / never-throw contract preserved.

Adds unit tests for reuse / spinner→reopen→loaded / exhausted→false /
budget-bail / Rpartstore-primary-on-hit / exhausted→Dialogys-fallback.

Could not live-test (single Vinpin seat is held on prod) — needs prod
validation after promote.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 15:29:12 +03:00
parent edde2dc3b4
commit fcc02984dd
3 changed files with 331 additions and 19 deletions

View File

@@ -215,3 +215,154 @@ describe("VinpinDriverService — warm session", () => {
expect(any.warm).toBe(false); // dropped seat marked not-warm → cold path next time
});
});
/**
* Rpartstore acquire-with-spinner-guard. A freshly-opened Rpartstore instance
* sometimes gets "born stuck" on an infinite spinner; the fix is to close the
* 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}).
*/
describe("VinpinDriverService — Rpartstore spinner guard", () => {
const savedEnv = { ...process.env };
const FAR_DEADLINE = () => Date.now() + 5 * 60_000;
beforeEach(() => {
process.env.VINPIN_ENABLED = "true";
process.env.VINPIN_USER = "user";
process.env.VINPIN_PASS = "pass";
});
afterEach(() => {
vi.restoreAllMocks();
process.env = { ...savedEnv };
});
function acquire(driver: VinpinDriverService, warm: boolean, deadline: number): Promise<boolean> {
const any = driver as unknown as Record<string, unknown>;
return (
any.acquireLoadedRpartstore as (
page: unknown,
cfg: unknown,
deadline: number,
warm: boolean,
) => Promise<boolean>
).call(driver, {}, {}, deadline, warm);
}
it("reuses Rpartstore when the FIRST open already loaded (no reopen, no close)", 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");
const close = vi.spyOn(any as never, "closeRpartstoreTab");
vi.spyOn(any as never, "pollForState").mockResolvedValue({
matched: true,
text: "Şasi no ile arama",
} as never);
expect(await acquire(driver, true, FAR_DEADLINE())).toBe(true);
expect(reopen).not.toHaveBeenCalled();
expect(close).not.toHaveBeenCalled();
});
it("closes a born-stuck spinner and reopens a FRESH instance that loads (warm)", 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").mockResolvedValue(true as never);
const close = vi
.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);
expect(await acquire(driver, true, FAR_DEADLINE())).toBe(true);
expect(raise).toHaveBeenCalledTimes(1); // only the first attempt raises
expect(close).toHaveBeenCalledTimes(1); // the stuck instance was closed
expect(reopen).toHaveBeenCalledTimes(1); // one fresh reopen, which loaded
});
it("gives up (false) after maxOpens reopen attempts all spinner → caller falls to Dialogys", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as Record<string, unknown>;
vi.spyOn(any as never, "ensureRpartstore").mockResolvedValue(true as never); // cold open path
const reopen = vi.spyOn(any as never, "reopenFreshRpartstore").mockResolvedValue(true as never);
const close = vi
.spyOn(any as never, "closeRpartstoreTab")
.mockResolvedValue(undefined as never);
vi.spyOn(any as never, "pollForState").mockResolvedValue({
matched: false,
text: "spinner",
} as never); // never loads
expect(await acquire(driver, false, FAR_DEADLINE())).toBe(false);
// maxOpens = 3: open 1 (ensureRpartstore) + 2 reopens; a close after each miss.
expect(reopen).toHaveBeenCalledTimes(2);
expect(close).toHaveBeenCalledTimes(3);
});
it("bails immediately (false) without opening when the budget is already spent (no livelock)", 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 poll = vi.spyOn(any as never, "pollForState");
expect(await acquire(driver, true, Date.now() - 1)).toBe(false);
expect(raise).not.toHaveBeenCalled();
expect(poll).not.toHaveBeenCalled();
});
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>;
vi.spyOn(any as never, "acquireLoadedRpartstore").mockResolvedValue(true as never);
vi.spyOn(any as never, "runRpartstore").mockResolvedValue({
status: "found",
parsed: { brand: "RENAULT", model: "KADJAR", modelYear: null },
rawText: "RENAULT Kadjar",
via: "rpartstore",
} as never);
const dialogys = vi.spyOn(any as never, "ensureWarmWindow");
const page = { keyboard: { press: vi.fn(async () => undefined) } };
const result = await (
any.warmRenaultDecode as (p: unknown, v: string, c: unknown, d: number) => Promise<unknown>
).call(driver, page, "VF1RFE00653633190", {}, FAR_DEADLINE());
expect(result).toMatchObject({
brand: "RENAULT",
model: "KADJAR",
raw: { source: "vinpin-rpartstore" },
});
expect(dialogys).not.toHaveBeenCalled(); // Rpartstore hit → Dialogys never consulted
});
it("warm Renault decode: exhausted Rpartstore → LAST-RESORT Dialogys fallback still decodes", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as Record<string, unknown>;
vi.spyOn(any as never, "acquireLoadedRpartstore").mockResolvedValue(false as never); // never loaded
const runRpartstore = vi.spyOn(any as never, "runRpartstore");
vi.spyOn(any as never, "ensureWarmWindow").mockResolvedValue(true as never);
vi.spyOn(any as never, "runDialogysSearch").mockResolvedValue({
status: "found",
parsed: { brand: "RENAULT", model: "MEGANE", modelYear: null },
rawText: "Megane II Classic",
via: "dialogys",
} as never);
const page = { keyboard: { press: vi.fn(async () => undefined) } };
const result = await (
any.warmRenaultDecode as (p: unknown, v: string, c: unknown, d: number) => Promise<unknown>
).call(driver, page, "VF1LM1B0A37829019", {}, FAR_DEADLINE());
expect(runRpartstore).not.toHaveBeenCalled(); // never ran the search on a stuck Rpartstore
expect(result).toMatchObject({
brand: "RENAULT",
model: "MEGANE",
raw: { source: "vinpin-dialogys" },
});
});
});

View File

@@ -36,6 +36,7 @@ import {
VINPIN_MODAL_REGION,
VINPIN_OCR,
VINPIN_RENAULT_HEADER_REGION,
VINPIN_RPARTSTORE,
VINPIN_TASKBAR_REGION,
VINPIN_TASKBAR_SLOTS,
VINPIN_TYPE_DELAY_MS,
@@ -514,32 +515,42 @@ export class VinpinDriverService implements OnModuleDestroy {
}
return null;
}
return this.warmRenaultDecode(page, vin, cfg);
return this.warmRenaultDecode(page, vin, cfg, deadline);
});
}
/**
* 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).
* Warm Renault decode: acquire a LOADED Rpartstore window (spinner-guarded —
* a born-stuck instance is closed and reopened fresh) and run the proven
* Rpartstore flow; only when Rpartstore can't be made to load OR genuinely
* misses does it fall back to the warm Dialogys window. Both stay open across
* decodes (no reopen cost on the healthy path).
*/
private async warmRenaultDecode(
page: Page,
vin: string,
cfg: VinpinConfig,
deadline: number,
): 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)`,
// PRIMARY: Rpartstore, guarded against the "born-stuck spinner" (close +
// reopen fresh, bounded, inside the wall-clock budget). Only if it never loads
// do we skip straight to Dialogys — Rpartstore is the richer catalog.
let primary: RenaultOutcome = { status: "ambiguous" };
if (await this.acquireLoadedRpartstore(page, cfg, deadline, true)) {
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);
}
} else {
this.logger.warn(
`warm ${vin}: Rpartstore never loaded after reopen attempts — falling back to Dialogys`,
);
return this.renaultToResult(primary.parsed, primary.rawText, primary.via);
}
// Dialogys fallback on the warm Dialogys window (search-only — keeps it open).
// LAST-RESORT fallback: Dialogys on the warm 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,
@@ -767,7 +778,7 @@ export class VinpinDriverService implements OnModuleDestroy {
// Rpartstore/Dialogys windows).
this.loggedIn = false;
this.establishedFlow = "renault";
return this.runRenaultFlow(page, vin);
return this.runRenaultFlow(page, vin, cfg, deadline);
});
if (outcome.status === "found") {
const p = outcome.parsed;
@@ -1226,11 +1237,26 @@ export class VinpinDriverService implements OnModuleDestroy {
* State-tolerant: the seat commonly RESUMES with Rpartstore already open, so
* the flow detects the current screen rather than forcing a grid round-trip.
*/
private async runRenaultFlow(page: Page, vin: string): Promise<RenaultOutcome> {
const primary = await this.runRpartstore(page, vin);
if (primary.status === "found") return primary;
private async runRenaultFlow(
page: Page,
vin: string,
cfg: VinpinConfig,
deadline: number,
): Promise<RenaultOutcome> {
// PRIMARY: acquire a LOADED Rpartstore (spinner-guarded — a born-stuck instance
// is closed and reopened fresh, bounded, inside the wall-clock budget), then run
// its VIN search. Only if Rpartstore never loads do we go straight to Dialogys.
let primary: RenaultOutcome = { status: "ambiguous" };
if (await this.acquireLoadedRpartstore(page, cfg, deadline, false)) {
primary = await this.runRpartstore(page, vin);
if (primary.status === "found") return primary;
} else {
this.logger.warn(
`${vin}: Rpartstore never loaded after reopen attempts — falling back to Dialogys`,
);
}
// Fall back to Dialogys (best-effort — same hit set per the benchmark).
// LAST-RESORT fallback: Dialogys (best-effort — same hit set per the benchmark).
const fallback = await this.runDialogys(page, vin).catch(
() => ({ status: "ambiguous" }) as RenaultOutcome,
);
@@ -1243,6 +1269,115 @@ export class VinpinDriverService implements OnModuleDestroy {
return { status: "ambiguous" };
}
/**
* Acquire a LOADED Rpartstore window, guarding against the "born-stuck spinner"
* failure mode: a freshly-opened Rpartstore instance sometimes hangs on an
* infinite spinner that survives raise/maximize, and the only reliable fix is to
* CLOSE the stuck instance and reopen a FRESH one (proven live in a spike). So:
* 1. bring a Rpartstore window forward (warm → taskbar-raise the bound window;
* cold → open it from the grid via the Renault tile → Rpartstore flyout),
* 2. OCR-verify it actually RENDERED its search-home landing markers within a
* short poll budget (rpartstoreLoaded — content tokens the spinner lacks),
* 3. if it's still spinning, close the tab and reopen a FRESH instance,
* retrying up to `VINPIN_RPARTSTORE.maxOpens` times. The whole loop lives inside
* BOTH the caller's wall-clock `deadline` AND a tighter `acquireBudgetMs` sub-cap
* so a permanently-stuck Rpartstore still leaves headroom to fall back to
* Dialogys (never a livelock). Returns true once a loaded Rpartstore is
* confirmed; false when every reopen attempt is exhausted (→ Dialogys).
*/
private async acquireLoadedRpartstore(
page: Page,
cfg: VinpinConfig,
deadline: number,
warm: boolean,
): Promise<boolean> {
// Sub-cap the acquire loop below the full decode deadline so a hopeless
// Rpartstore leaves wall-clock room (within the budget) to reach Dialogys.
const acquireDeadline = Math.min(deadline, Date.now() + VINPIN_RPARTSTORE.acquireBudgetMs);
for (let open = 1; open <= VINPIN_RPARTSTORE.maxOpens; open++) {
if (Date.now() >= acquireDeadline) {
this.logger.warn(
`Rpartstore acquire: budget exhausted before it loaded (after ${open - 1} open attempt(s))`,
);
return false;
}
// Step 1 — bring a Rpartstore window forward. First attempt reuses the
// existing window (warm: taskbar-raise; cold: open-from-grid); subsequent
// attempts always reopen a FRESH instance (the prior one was born stuck).
let present: boolean;
if (open === 1) {
present = warm
? await this.raiseWarmWindow(page, "rpartstore")
: await this.ensureRpartstore(page);
} else {
present = await this.reopenFreshRpartstore(page);
}
if (!present) {
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), short budget.
const { matched } = await this.pollForState(
page,
(t) => VINPIN_OCR.rpartstoreLoaded.test(t),
VINPIN_WAITS.afterRpartstoreLoad,
);
if (matched) {
if (open > 1) this.logger.log(`Rpartstore loaded after ${open} open attempt(s)`);
return true;
}
// Step 3 — still spinning (born stuck) → close the tab so the next iteration
// reopens a FRESH instance.
this.logger.warn(
`Rpartstore born-stuck (spinner) on open ${open}/${VINPIN_RPARTSTORE.maxOpens} — closing + reopening fresh`,
);
await this.closeRpartstoreTab(page);
}
this.logger.warn(
`Rpartstore never loaded after ${VINPIN_RPARTSTORE.maxOpens} open attempts — giving up on it`,
);
return false;
}
/**
* 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.
* Best-effort — errors are swallowed (the acquire loop reopens fresh regardless).
*/
private async closeRpartstoreTab(page: Page): Promise<void> {
await page.mouse
.click(VINPIN_COORDS.catalogTabClose.x, VINPIN_COORDS.catalogTabClose.y)
.catch(() => undefined);
await page.waitForTimeout(VINPIN_WAITS.afterWindowClose);
// A confirm/language dialog may pop on close.
await page.keyboard.press("Enter").catch(() => undefined);
if (VINPIN_OCR.rpartstoreOpen.test(await ocrRegion(page))) {
await page.mouse
.click(VINPIN_COORDS.windowClose.x, VINPIN_COORDS.windowClose.y)
.catch(() => undefined);
await page.waitForTimeout(VINPIN_WAITS.afterWindowClose);
await page.keyboard.press("Enter").catch(() => undefined);
}
}
/**
* Reopen a FRESH Rpartstore instance after closing a born-stuck one: raise the
* VinPower grid, then open Rpartstore via the Renault tile → Rpartstore flyout
* (ensureRpartstore, which also dismisses the Türkiye/OK dialog). When warm,
* re-bind the taskbar coords to the fresh window. Returns true when Rpartstore
* CHROME is back on screen — its actual LOAD is verified by the acquire loop.
*/
private async reopenFreshRpartstore(page: Page): Promise<boolean> {
await this.raiseGridViaTaskbar(page);
const opened = await this.ensureRpartstore(page);
if (opened && this.warm) await this.bindTaskbarCoords(page);
return opened;
}
/**
* Ensure the Rpartstore app window is open & interactive. Handles every state
* the seat can be in: already-open (resumed), submenu showing, brand grid, or a

View File

@@ -143,6 +143,14 @@ export const VINPIN_OCR = {
rpartstoreOpen: /RPartStore|Rpartstore|Güncel\s*ara|Grup\s*sipariş|arıyor|Şasi|Sasi/i,
/** Rpartstore home/search page (the VIN search field is up). */
rpartstoreReady: /Güncel\s*ara|arıyor|Grup\s*sipariş|Şasi\s*no|Sasi\s*no|Ara\b/i,
/** Rpartstore search-home actually RENDERED its landing markers ("Şasi no ile
* arama" / "Ne arıyorsunuz" / "Güncel araçlar" / "Grup siparişi"). This is the
* spinner-guard signal: it distinguishes a LOADED home from the "born-stuck"
* infinite spinner, which shows only the window CHROME ("RPartStore" title —
* matched by rpartstoreOpen) with no search-home content. Kept strictly to
* 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 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ı" /
@@ -299,6 +307,10 @@ export const VINPIN_WAITS = {
afterRenaultTile: 2_500,
/** After picking Rpartstore/Dialogys from the submenu (catalog window opens). */
afterRenaultCatalogOpen: 12_000,
/** After a Rpartstore (re)open — poll for the search-home landing markers to
* confirm it actually LOADED (vs the "born-stuck" infinite spinner). Short so
* a stuck instance is detected fast and reopened fresh within the budget. */
afterRpartstoreLoad: 12_000,
/** After submitting the Rpartstore VIN (Enter) — wait for the vehicle page. */
afterRpartstoreSubmit: 8_000,
/** After submitting the Dialogys VIN (ПОИСК) — wait for the vehicle page. */
@@ -316,6 +328,20 @@ export const VINPIN_WAITS = {
* genuine stall. Override with VINPIN_DECODE_BUDGET_MS. */
export const VINPIN_DECODE_BUDGET_MS = 150_000;
/**
* Rpartstore acquire-with-spinner-guard tunables. A freshly-opened Rpartstore
* instance sometimes gets "born stuck" on an infinite spinner (survives
* raise/maximize); the only reliable fix is to CLOSE the stuck instance and
* reopen a FRESH one. `maxOpens` bounds the initial-open-plus-reopen attempts;
* `acquireBudgetMs` sub-caps the whole acquire loop so a hopeless Rpartstore
* still leaves wall-clock headroom (inside VINPIN_DECODE_BUDGET_MS) to fall back
* to Dialogys rather than burning the entire decode budget on reopens.
*/
export const VINPIN_RPARTSTORE = {
maxOpens: 3,
acquireBudgetMs: 90_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). */