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
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:
@@ -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 ~60–90s 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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, ~60–90s 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 (~30–40s total instead of ~60–90s).
|
||||
*
|
||||
* 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",
|
||||
|
||||
Reference in New Issue
Block a user