fix(vinpin): don't cool down Rpartstore on transient born-stuck streak; don't relaunch on clean-logout launcher

Two review follow-ups to 281c54a:

1. born-stuck cooldown regression: acquireLoadedRpartstoreInner set the 10m
   down-cooldown when maxOpens was exhausted by a born-stuck-spinner streak — a
   TRANSIENT, reopen-recoverable blip, not a server outage. Because the cooldown
   can only self-clear from INSIDE the acquire loop (skipped while cooling down),
   one spinner streak suppressed the richer Rpartstore catalog for every Renault
   decode for 10m. Reserve the cooldown for the confirmed launch-error DOWN signal
   (unchanged at the two launch-error sites); the born-stuck give-up now just falls
   back to Dialogys for that one VIN and retries Rpartstore fresh next VIN.

2. clean-logout false disconnect-recovery: cleanTeardown's disconnect recovery
   gated on VINPIN_OCR.sessionDropped, whose broad "HTML Access" token also matches
   the clean-logout Horizon HTML-Access launcher. A clean log-off could then click
   disconnectedClose + RELAUNCH VinPower right before close(), leaving the exact
   dirty resumed session the teardown prevents (+~17s wasted). Veto the recovery
   with !VINPIN_OCR.launcher so it fires only on a real Disconnected drop.

Keeps never-throw, budget, spinner-guard, Fiat/Dialogys fallbacks intact. Adds a
born-stuck-no-cooldown test and a clean-logout-launcher-no-relaunch test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 08:41:35 +03:00
parent 281c54a423
commit c8d9e45207
3 changed files with 59 additions and 3 deletions

View File

@@ -260,4 +260,27 @@ describe("VinpinDriverService — Rpartstore-down cooldown routes Renault → Di
expect(await acquire(driver, page, true)).toBe(true);
expect(inCooldown(driver)).toBe(false); // healthy again → Rpartstore resumes as primary
});
it("a transient born-stuck-spinner streak (maxOpens exhausted) does NOT set the cooldown", async () => {
// A born-stuck spinner is a TRANSIENT reopen-recoverable blip, not a server
// outage — exhausting maxOpens must fall back to Dialogys for THIS vin only and
// leave Rpartstore healthy for the next one, NOT suppress it for the full
// cooldown (which can't self-clear while it's being skipped).
const driver = new VinpinDriverService();
const any = driver as unknown as AnyDriver;
vi.spyOn(any as never, "raiseWarmWindow").mockResolvedValue(true as never);
vi.spyOn(any as never, "reopenFreshRpartstore").mockResolvedValue(true as never);
vi.spyOn(any as never, "closeRpartstoreTab").mockResolvedValue(undefined as never);
// Every poll = still spinning (never loaded, no launch error) → born-stuck path.
vi.spyOn(any as never, "pollRpartstoreState").mockResolvedValue({
loaded: false,
launchError: false,
text: "",
} as never);
const page = fakePage();
expect(inCooldown(driver)).toBe(false);
expect(await acquire(driver, page, true)).toBe(false); // gave up → Dialogys
expect(inCooldown(driver)).toBe(false); // but NOT cooled down → next VIN retries Rpartstore
});
});

View File

@@ -422,7 +422,12 @@ export class VinpinDriverService implements OnModuleDestroy {
// the HTML-Access launcher's "No Running Items" screen, and relaunch the VINPIN
// app so the seat lands on a FRESH VinPower grid. Bounded + never-throw.
const afterLogout = await ocrRegion(page).catch(() => "");
if (VINPIN_OCR.sessionDropped.test(afterLogout)) {
// Only recover a REAL Disconnected drop. `sessionDropped` includes the shared
// "HTML Access" Horizon branding, which also appears on the CLEAN-logout
// HTML-Access launcher — so gate the recovery on `!launcher` to avoid firing on
// a clean log-off (which would spuriously RELAUNCH VinPower and leave the exact
// dirty resumed session this teardown exists to prevent).
if (VINPIN_OCR.sessionDropped.test(afterLogout) && !VINPIN_OCR.launcher.test(afterLogout)) {
this.logger.warn(
"logout produced a Disconnected dialog — closing it + relaunching VINPIN for a fresh grid",
);
@@ -1582,10 +1587,16 @@ export class VinpinDriverService implements OnModuleDestroy {
);
await this.closeRpartstoreTab(page);
}
// Born-stuck spinner streak is a TRANSIENT failure mode (the reopen-guard is
// designed to recover it), NOT the persistent server outage the launch-error
// modal marks — so do NOT set the down-cooldown here. Setting it would suppress
// a healthy Rpartstore for the full cooldown (the cooldown can only self-clear
// from INSIDE this loop, which is skipped while cooling down). Just fall back to
// Dialogys for THIS vin and retry Rpartstore fresh on the next one (pre-fix
// behavior). The cooldown is reserved for the confirmed launch-error DOWN signal.
this.logger.warn(
`Rpartstore never loaded after ${VINPIN_RPARTSTORE.maxOpens} open attempts — giving up on it`,
`Rpartstore never loaded after ${VINPIN_RPARTSTORE.maxOpens} open attempts — falling back to Dialogys for this VIN (no cooldown; transient born-stuck)`,
);
this.setRpartstoreCooldown(); // repeated load-failure = effectively down → skip it for a while
return false;
}

View File

@@ -128,6 +128,28 @@ describe("VinpinDriverService — clean teardown", () => {
expect(clicks).not.toContainEqual([40, 256]); // VINPIN not relaunched
});
it("does NOT relaunch VINPIN when logout lands on the HTML-Access launcher (clean log-off, not a drop)", async () => {
// The clean-logout launcher OCRs its "HTML Access" branding, which the broad
// sessionDropped token matches — but the launcher tokens must veto the recovery
// so a clean log-off is never mistaken for a Disconnected drop (would spuriously
// relaunch VinPower and leave a dirty resumed session).
const driver = new VinpinDriverService();
const any = driver as unknown as AnyDriver;
const clicks: Array<[number, number]> = [];
any.browser = { isConnected: () => true };
any.context = {};
any.page = fakePage((x, y) => clicks.push([x, y]));
mockOcr
.mockResolvedValueOnce("") // close-loop: nothing open
.mockResolvedValueOnce("VMware Horizon HTML Access — No Running Items Available"); // clean launcher after logout
await (any.cleanTeardown as () => Promise<void>).call(driver);
expect(clicks).toContainEqual([1543, 877]); // logged out
expect(clicks).not.toContainEqual([953, 505]); // NOT treated as a disconnect
expect(clicks).not.toContainEqual([40, 256]); // VINPIN NOT relaunched
});
it("clean-teardown is a no-op when the browser/page is already gone", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as AnyDriver;