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:
@@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user