fix(vinpin): cheap in-session grid reset on ambiguous Renault retry (no relaunch)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

An AMBIGUOUS Renault outcome is transient/state-dependent, but decodeRenaultLocked
retried it with a full browser teardown (this.close()). The next attempt then
re-launched chromium + re-did the web login + re-established from scratch
(~60-90s each), and each re-establish re-hit the seat's dirty-resume ("catalog
window resumed open" -> closeStrayRunningApps), so 3 attempts blew the 180s budget
-> not_found. Proven live: VF1RFE00653633190 decoded cleanly to KADJAR earlier
when the desktop state was favorable, then thrashed to not_found on relaunch.

Fix: on the ambiguous path, reset to a clean VinPower brand grid on the SAME live
session via ensureBrandGrid (closeStrayRunningApps DOM recovery first, canvas
tab-X fallback) instead of tearing the browser down. Keep the browser + authed so
the next iteration's ensureAuthenticated is a no-op (no relaunch, no web login),
and re-run runRenaultFlow from the clean grid (~30-40s). Graduated safety: if the
cheap reset can't confirm a clean grid or the session is broken (page
closed/disconnected), fall back to the old close() + cold re-establish. The reset
runs under the wall-clock deadline so an overrun still routes to the existing
VinpinBudgetError teardown+poison path. never-throw + budget/poison paths
unchanged; maxAttempts semantics unchanged.

Tests: +3 (ambiguous -> in-session ensureBrandGrid reset re-runs runRenaultFlow
with NO close(); graduated fallback close()s when the reset can't reach a grid;
broken session skips straight to close()). 110 vinpin tests green; tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 10:27:17 +03:00
parent cf36da07af
commit 9cb58c583c
2 changed files with 203 additions and 2 deletions

View File

@@ -334,3 +334,138 @@ describe("VinpinDriverService — Rpartstore-down cooldown routes Renault → Di
expect(inCooldown(driver)).toBe(false); // but NOT cooled down → next VIN retries Rpartstore
});
});
/**
* Ambiguous-retry cheap in-session reset (the dirty-resume relaunch-thrash fix).
* An AMBIGUOUS Renault outcome is transient/state-dependent, so the retry must NOT
* tear the browser down (a full teardown forces a ~6090s relaunch + re-login that
* re-hits the seat's "catalog window resumed open" dirty-resume every attempt →
* 3 attempts blow the 180s budget → not_found). Instead it returns to a clean
* VinPower brand grid on the SAME live session via ensureBrandGrid (which runs the
* closeStrayRunningApps DOM recovery first), and re-runs runRenaultFlow — no
* close()/relaunch. GRADUATED safety: only when that cheap reset can't reach a clean
* grid does it fall back to close() + cold re-establish. OCR is mocked so the grid
* confirmation is controllable.
*/
describe("VinpinDriverService — ambiguous Renault retry is a CHEAP in-session grid reset (no relaunch)", () => {
const savedEnv = { ...process.env };
const FAR_DEADLINE = () => Date.now() + 5 * 60_000;
const RENAULT_VIN = "VF1RFE00653633190"; // the live-trace VIN that thrashed on relaunch
beforeEach(() => {
process.env.VINPIN_ENABLED = "true";
process.env.VINPIN_USER = "user";
process.env.VINPIN_PASS = "pass";
mockOcr.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
process.env = { ...savedEnv };
});
// Present the driver as a live, authenticated session so the cheap reset's
// precheck (browser connected + context + open page + authed) passes and it never
// short-circuits to a cold re-establish.
function liveSession(driver: VinpinDriverService): AnyDriver {
const any = driver as unknown as AnyDriver;
any.browser = { isConnected: () => true };
any.context = {};
any.page = fakePage();
any.authed = true;
return any;
}
const decodeRenault = (driver: VinpinDriverService, vin: string) =>
(
(driver as unknown as AnyDriver).decodeRenaultLocked as (
v: string,
c: unknown,
d: number,
) => Promise<unknown>
).call(driver, vin, { maxAttempts: 3, budgetMs: 5 * 60_000 }, FAR_DEADLINE());
it("first ambiguous retry returns to the grid IN-SESSION (ensureBrandGrid) and re-runs runRenaultFlow WITHOUT close()/relaunch", async () => {
const driver = new VinpinDriverService();
const any = liveSession(driver);
// ensureAuthenticated is a no-op (session already up → NO relaunch, NO web login).
vi.spyOn(any as never, "ensureAuthenticated").mockResolvedValue(undefined as never);
// The cheap reset reaches a clean grid: ensureBrandGrid runs on the SAME session
// and the OCR read confirms the brand grid.
const ensureGrid = vi
.spyOn(any as never, "ensureBrandGrid")
.mockResolvedValue(undefined as never);
mockOcr.mockResolvedValue("Volkswagen Mitsubishi TecDoc"); // matches VINPIN_OCR.brandGrid
const close = vi.spyOn(any as never, "close").mockResolvedValue(undefined as never);
// attempt 1 → ambiguous (transient); attempt 2, same live session → found.
const runFlow = vi
.spyOn(any as never, "runRenaultFlow")
.mockResolvedValueOnce({ status: "ambiguous" } as never)
.mockResolvedValueOnce({
status: "found",
parsed: { brand: "RENAULT", model: "KADJAR", modelYear: null },
rawText: "RENAULT Kadjar",
via: "dialogys",
} as never);
const result = await decodeRenault(driver, RENAULT_VIN);
expect(result).toMatchObject({ brand: "RENAULT", model: "KADJAR" });
expect(runFlow).toHaveBeenCalledTimes(2); // ambiguous, then a cheap in-session re-run
expect(ensureGrid).toHaveBeenCalled(); // the in-session grid reset ran (closeStrayRunningApps path)
expect(close).not.toHaveBeenCalled(); // NO teardown/relaunch on the cheap first retry
});
it("GRADUATED fallback: when the cheap grid reset can't confirm a clean grid, it close()s + cold re-establishes", async () => {
const driver = new VinpinDriverService();
const any = liveSession(driver);
vi.spyOn(any as never, "ensureAuthenticated").mockResolvedValue(undefined as never);
// ensureBrandGrid runs but the grid is NEVER confirmed (OCR reads no grid) → the
// cheap reset returns false → the graduated hard reset (close) must fire.
const ensureGrid = vi
.spyOn(any as never, "ensureBrandGrid")
.mockResolvedValue(undefined as never);
mockOcr.mockResolvedValue(""); // no brand grid → cheap reset fails
const close = vi.spyOn(any as never, "close").mockResolvedValue(undefined as never);
const runFlow = vi
.spyOn(any as never, "runRenaultFlow")
.mockResolvedValueOnce({ status: "ambiguous" } as never)
.mockResolvedValueOnce({
status: "found",
parsed: { brand: "RENAULT", model: "MEGANE", modelYear: null },
rawText: "Megane",
via: "dialogys",
} as never);
const result = await decodeRenault(driver, RENAULT_VIN);
expect(result).toMatchObject({ brand: "RENAULT", model: "MEGANE" });
expect(ensureGrid).toHaveBeenCalled(); // the CHEAP reset was attempted first…
expect(close).toHaveBeenCalledTimes(1); // …then the graduated hard reset fired exactly once
expect(runFlow).toHaveBeenCalledTimes(2);
});
it("does NOT cheap-reset when the session is already gone — falls straight to close() (graduated)", async () => {
const driver = new VinpinDriverService();
const any = liveSession(driver);
// Session looks broken: the page is closed → the cheap reset's precheck fails and
// it returns false without touching ensureBrandGrid, so the hard reset runs.
(any.page as { isClosed: () => boolean }).isClosed = () => true;
vi.spyOn(any as never, "ensureAuthenticated").mockResolvedValue(undefined as never);
const ensureGrid = vi
.spyOn(any as never, "ensureBrandGrid")
.mockResolvedValue(undefined as never);
const close = vi.spyOn(any as never, "close").mockResolvedValue(undefined as never);
vi.spyOn(any as never, "runRenaultFlow")
.mockResolvedValueOnce({ status: "ambiguous" } as never)
.mockResolvedValueOnce({ status: "not_found" } as never);
const result = await decodeRenault(driver, RENAULT_VIN);
expect(result).toBeNull(); // 2nd attempt not_found → null
expect(ensureGrid).not.toHaveBeenCalled(); // broken session → no in-session grid work
expect(close).toHaveBeenCalledTimes(1); // graduated hard reset on the broken session
});
});

View File

@@ -895,11 +895,25 @@ export class VinpinDriverService implements OnModuleDestroy {
this.logger.log(`${vin} → not found in Rpartstore nor Dialogys (attempt ${attempt})`);
return null;
}
// ambiguous → re-establish and retry.
// ambiguous → retry. Prefer a CHEAP in-session reset (keep the live browser
// + authenticated Horizon/VinPower session and return to a clean brand grid
// via the closeStrayRunningApps DOM recovery) so the next iteration re-runs
// runRenaultFlow WITHOUT relaunching chromium or re-doing the web login. The
// ambiguity is transient/state-dependent, so a cheap grid-reset commonly
// resolves it — where a full teardown + re-establish just re-hits the seat's
// dirty-resume ("catalog window resumed open") and burns the budget across
// 3 relaunches → not_found. GRADUATED fallback: only when the cheap reset
// can't reach a clean grid (or the session is broken) do we pay for the full
// close() + cold re-establish, so a truly-wedged seat still gets a hard reset.
this.logger.warn(
`attempt ${attempt}/${cfg.maxAttempts}: ambiguous Renault decode for ${vin} — retrying`,
);
await this.close();
if (!(await this.resetRenaultToGridInSession(cfg, deadline))) {
this.logger.warn(
`${vin}: in-session grid reset failed — full close() + cold re-establish for the next attempt`,
);
await this.close();
}
} catch (err) {
if (err instanceof VinpinBudgetError) {
this.logger.warn(
@@ -920,6 +934,58 @@ export class VinpinDriverService implements OnModuleDestroy {
return null;
}
/**
* CHEAP in-session reset for an AMBIGUOUS Renault retry — the fix for the
* dirty-resume relaunch thrash. Instead of a full browser teardown (which forces
* the next attempt to re-launch chromium + re-do the web login + re-establish from
* scratch, ~6090s each, each re-hitting the seat's "catalog window resumed open"
* dirty-resume → 3 attempts blow the 180s budget → not_found), keep the live
* Chromium + authenticated Horizon/VinPower session and return to a clean VinPower
* brand grid via `ensureBrandGrid` — which runs the state-agnostic Horizon
* "Running"-panel DOM recovery (`closeStrayRunningApps`) FIRST, then the canvas
* tab-✕ fallback — so a stray "Loading application…" / leftover catalog is cleaned
* WITHOUT relaunching. The next loop iteration then re-runs `runRenaultFlow` from a
* known-clean grid: `ensureAuthenticated` stays a no-op (browser alive + `authed`),
* so no relaunch and no web login (~3040s total instead of ~6090s).
*
* Runs under the decode `deadline` so a wedged reset still honours the wall-clock
* budget: a `VinpinBudgetError` PROPAGATES to the loop's budget path (teardown +
* poison), exactly like the flow steps. Returns true when a clean grid is
* OCR-confirmed on the SAME session; false when the session is gone/broken OR the
* grid couldn't be reached — the caller then does the graduated fallback (`close()`
* + cold re-establish) so a truly-wedged seat still hard-resets. Never throws
* except the budget abort (which the caller's existing catch handles).
*/
private async resetRenaultToGridInSession(cfg: VinpinConfig, deadline: number): Promise<boolean> {
// Session must be live AND past login — otherwise there's nothing cheap to
// reuse; let the caller cold re-establish (graduated fallback).
if (!this.browser?.isConnected() || !this.context || !this.page || this.page.isClosed()) {
return false;
}
if (!this.authed) return false;
const page = this.page;
try {
return await this.withDeadline(deadline, "renault-grid-reset", async () => {
// Return to a clean brand grid on the SAME live seat (DOM recovery first,
// canvas tab-✕ fallback — both inside ensureBrandGrid).
await this.ensureBrandGrid(page, cfg);
if (!VINPIN_OCR.brandGrid.test(await ocrRegion(page).catch(() => ""))) {
return false; // couldn't confirm a clean grid → caller hard-resets
}
// Clean grid reached. Keep the browser + `authed` so the next iteration's
// ensureAuthenticated is a no-op (no relaunch, no web login); clear the
// catalog-flow flags so runRenaultFlow opens fresh from the grid.
this.loggedIn = false;
this.establishedFlow = "renault";
return true;
});
} catch (err) {
if (err instanceof VinpinBudgetError) throw err; // budget → loop's poison path
this.logger.warn(`in-session Renault grid reset threw: ${(err as Error).message}`);
return false;
}
}
private toResult(parsed: VinpinParsed, rawText: string | null): VinpinDecodeResult {
return {
brand: "Fiat",