fix(vinpin): reliable warm-daemon establish (clean teardown + tab-close + backoff)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

Root cause of warm-establish failures was resume-into-dirty-session +
broken window-close + no backoff, not OCR/detection. Four composing fixes:

A. Clean teardown (cleanTeardown): before the browser close(), close each
   open catalog window via the corrected tab-✕ then click "Çıkış yap" logout
   to END the RDS session, so the next warm-up starts from a fresh login/grid
   instead of resuming into the last-open 3-window desktop. Wired into
   teardownWarm() and both failed-warmUp exits. Bounded + never-throw.

B. Fix close coords + tab-✕ primary. catalogTabClose {135,45}→{93,45}
   (validated live). In ensureBrandGrid/returnToBrandGrid the tab-✕ is now the
   PRIMARY close; the windowClose {1298,14} click (which opens the HTML-Access
   language dropdown) is no longer used there. Escape pressed after each close
   to dismiss an accidental dropdown before re-OCR. After N failed closes,
   ensureBrandGrid escalates to logout+relogin (one-shot, no recursion) instead
   of limping into the ePER-open loop.

C. Realistic grid wait. afterLogin 20_000→45_000 (real grid render ~32-46s).

D. Backoff between re-warm attempts. A failed warmUp sets a cooldown (60s,
   exponential to 5min) that BOTH reconcile() and decode()'s warm-on-demand
   honour; a successful warm resets it — so a failing seat is no longer
   hammered every ~60s leaving fresh dirty windows.

Safety nets preserved: never-throw contract, VINPIN_DECODE_BUDGET_MS,
sessionPoisoned, cold/Dialogys fallbacks; fcc0298 Rpartstore spinner-guard,
Fiat ePER path, and Russian-dialog dismissal (746,454) untouched. Cannot be
exercised in dev (single seat on prod) — needs prod validation on a rested seat.

Tests: +8 unit tests (clean-teardown ordering, tab-✕ primary + relogin
escalation, warm-up backoff respected by reconcile + warm-on-demand + reset).
81 vinpin tests green; tsc + biome clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 19:11:21 +03:00
parent 8e10ebc883
commit 61b5769e48
5 changed files with 415 additions and 32 deletions

View File

@@ -125,6 +125,72 @@ describe("VinpinDaemonService — decode routing", () => {
});
});
describe("VinpinDaemonService — warm-up backoff", () => {
afterEach(() => vi.restoreAllMocks());
function backoffDaemon(driver: FakeDriver, clock: { t: number }) {
return new VinpinDaemonService({
driver: driver as unknown as VinpinDriverService,
isBusinessHours: () => true,
isEnabled: () => true,
now: () => clock.t,
});
}
it("a failed warmUp sets a cooldown that reconcile() respects (no immediate re-warm)", async () => {
const driver = makeDriver(false);
driver.warmUp.mockResolvedValue(false); // warm-up keeps failing
const clock = { t: 0 };
const d = backoffDaemon(driver, clock);
await d.reconcile();
expect(driver.warmUp).toHaveBeenCalledTimes(1); // first attempt ran
// Still cold + in hours, but inside the cooldown → no second attempt.
clock.t = 30_000;
await d.reconcile();
expect(driver.warmUp).toHaveBeenCalledTimes(1);
// After the base cooldown (60s) elapses → it tries again.
clock.t = 61_000;
await d.reconcile();
expect(driver.warmUp).toHaveBeenCalledTimes(2);
});
it("warm-on-demand decode ALSO respects the cooldown, but still delegates the cold decode", async () => {
const driver = makeDriver(false);
driver.warmUp.mockResolvedValue(false);
const clock = { t: 0 };
const d = backoffDaemon(driver, clock);
await d.reconcile(); // fails → cooldown until 60_000
expect(driver.warmUp).toHaveBeenCalledTimes(1);
clock.t = 20_000;
await d.decode("NM435600006H43436"); // in cooldown → no warm-on-demand
expect(driver.warmUp).toHaveBeenCalledTimes(1);
expect(driver.decode).toHaveBeenCalledTimes(1); // still decodes via the cold path
});
it("a successful warm resets the backoff (next attempt is not blocked)", async () => {
const driver = makeDriver(false);
driver.warmUp.mockResolvedValueOnce(false).mockResolvedValueOnce(true).mockResolvedValue(false);
const clock = { t: 0 };
const d = backoffDaemon(driver, clock);
await d.reconcile(); // fail → cooldown until 60_000
clock.t = 61_000;
await d.reconcile(); // success → cooldown reset
expect(driver.warmUp).toHaveBeenCalledTimes(2);
// isWarm() is still false in the fake, so reconcile would warm again — and with
// the cooldown reset it may attempt immediately (no leftover backoff window).
clock.t = 61_500;
await d.reconcile();
expect(driver.warmUp).toHaveBeenCalledTimes(3);
});
});
describe("VinpinDaemonService — start/stop lifecycle", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => {

View File

@@ -35,6 +35,8 @@ export interface VinpinDaemonDeps {
isBusinessHours?: () => boolean;
/** Warm-daemon enabled predicate (injectable for tests). */
isEnabled?: () => boolean;
/** Monotonic-ish clock (injectable so tests can drive the warm-up backoff). */
now?: () => number;
}
export class VinpinDaemonService {
@@ -42,16 +44,55 @@ export class VinpinDaemonService {
private readonly driver: VinpinDriverService;
private readonly isBusinessHours: () => boolean;
private readonly isEnabled: () => boolean;
private readonly now: () => number;
private schedulerTimer: ReturnType<typeof setInterval> | null = null;
private keepaliveTimer: ReturnType<typeof setInterval> | null = null;
private reconciling = false;
private started = false;
// ─── Warm-up backoff ─────────────────────────────────────
/** Wall-clock time until which a re-warm is suppressed after a failed warmUp.
* Both reconcile() and decode()'s warm-on-demand honour this so a failing seat
* isn't hammered every ~60s (each failed attempt would leave a fresh dirty
* window). A successful warm resets it. */
private warmCooldownUntil = 0;
/** Current backoff span (ms): 0 when healthy, else base…max, doubling per
* consecutive failure. */
private warmBackoffMs = 0;
constructor(deps: VinpinDaemonDeps = {}) {
this.driver = deps.driver ?? getVinpinDriver();
this.isBusinessHours = deps.isBusinessHours ?? (() => isVinpinBusinessHours());
this.isEnabled = deps.isEnabled ?? isVinpinWarmDaemonEnabled;
this.now = deps.now ?? (() => Date.now());
}
/** True while a failed warmUp's cooldown is still in effect. */
private inWarmCooldown(): boolean {
return this.now() < this.warmCooldownUntil;
}
/**
* Record a warm-up outcome and update the backoff. Success clears the cooldown;
* failure sets/extends it (base, then exponential up to max). Returns `ok` so
* callers can chain.
*/
private noteWarmResult(ok: boolean): boolean {
if (ok) {
this.warmBackoffMs = 0;
this.warmCooldownUntil = 0;
} else {
this.warmBackoffMs =
this.warmBackoffMs === 0
? VINPIN_WARM.warmBackoffBaseMs
: Math.min(this.warmBackoffMs * 2, VINPIN_WARM.warmBackoffMaxMs);
this.warmCooldownUntil = this.now() + this.warmBackoffMs;
this.logger.warn(
`warmUp failed — backing off ${Math.round(this.warmBackoffMs / 1000)}s before the next attempt`,
);
}
return ok;
}
/** Start the scheduler + keepalive loops (idempotent). */
@@ -96,11 +137,19 @@ export class VinpinDaemonService {
const enabled = this.isEnabled();
const inHours = this.isBusinessHours();
if (enabled && inHours && !this.driver.isWarm()) {
this.logger.log("scheduler: inside business hours — warming the seat");
await this.driver.warmUp();
if (this.inWarmCooldown()) {
this.logger.debug("scheduler: in warm-up backoff — skipping this cycle");
} else {
this.logger.log("scheduler: inside business hours — warming the seat");
this.noteWarmResult(await this.driver.warmUp());
}
} else if (this.driver.isWarm() && (!enabled || !inHours)) {
this.logger.log("scheduler: outside business hours / disabled — tearing the seat down");
await this.driver.teardownWarm();
// Intentional teardown → clear any stale warm-up backoff so the next window
// (e.g. tomorrow 08:00) isn't blocked by a leftover cooldown.
this.warmBackoffMs = 0;
this.warmCooldownUntil = 0;
}
} catch (err) {
this.logger.warn(`reconcile failed: ${(err as Error).message}`);
@@ -117,11 +166,17 @@ export class VinpinDaemonService {
*/
async decode(vin: string): Promise<VinpinDecodeResult | null> {
try {
if (this.isEnabled() && this.isBusinessHours() && !this.driver.isWarm()) {
if (
this.isEnabled() &&
this.isBusinessHours() &&
!this.driver.isWarm() &&
!this.inWarmCooldown()
) {
// Warm-on-demand: a decode arrived inside hours before the scheduler warmed
// (e.g. right after 08:00, or after a drop). Best-effort — if it fails the
// driver silently runs the cold path for this decode.
await this.driver.warmUp().catch(() => false);
// driver silently runs the cold path for this decode, and the backoff spaces
// out the next warm attempt (respected by both this check and reconcile()).
this.noteWarmResult(await this.driver.warmUp().catch(() => false));
}
return await this.driver.decode(vin);
} catch (err) {

View File

@@ -354,10 +354,53 @@ export class VinpinDriverService implements OnModuleDestroy {
await this.runExclusive(async () => {
this.warm = false;
this.taskbarCoords = {};
// Clean the RDS SEAT (close catalog windows + log out) BEFORE dropping the
// browser, so the next warm-up starts from a fresh login/grid rather than
// resuming into the last-open dirty catalog desktop.
await this.cleanTeardown();
await this.close();
}).catch(() => undefined);
}
/**
* Clean-teardown routine (never-throw, bounded). Leaves the RDS SEAT itself clean
* before the browser is closed, which is the root fix for warm-establish
* reliability: closing the Playwright browser alone does NOT end the remote
* session, so Horizon otherwise resumes into whatever catalog windows were left
* open. This (1) closes every open catalog window via the corrected tab-✕, then
* (2) clicks the "Çıkış yap" logout button to end the RDS session so the next
* warm-up gets a fresh login/grid. If the logout click misses, step (1) at least
* guarantees resume lands on the brand grid, not inside a catalog. Best-effort:
* a page that's already gone is a no-op; every step swallows its own errors.
*/
private async cleanTeardown(): Promise<void> {
try {
const page = this.page;
if (!this.browser?.isConnected() || !this.context || !page || page.isClosed()) return;
// (1) Close any open catalog windows (bounded) so resume can't land inside a
// catalog. The warm daemon keeps up to 3 windows open, so allow a few passes.
for (let i = 0; i < 6; i++) {
const t = await ocrRegion(page).catch(() => "");
if (!VINPIN_OCR.catalogWindowOpen.test(t)) break;
await page.mouse
.click(VINPIN_COORDS.catalogTabClose.x, VINPIN_COORDS.catalogTabClose.y)
.catch(() => undefined);
await page.waitForTimeout(VINPIN_WAITS.afterWindowClose);
// A close may pop an accidental dropdown/confirm — dismiss it before re-OCR.
await page.keyboard.press("Escape").catch(() => undefined);
}
// (2) End the RDS session via the "Çıkış yap" logout button so the next
// warm-up gets a fresh login/grid, not a resumed desktop.
await page.mouse
.click(VINPIN_COORDS.logoutButton.x, VINPIN_COORDS.logoutButton.y)
.catch(() => undefined);
await page.waitForTimeout(VINPIN_WAITS.afterWindowClose);
await page.keyboard.press("Enter").catch(() => undefined); // confirm-logout dialog
} catch (err) {
this.logger.warn(`cleanTeardown best-effort failure: ${(err as Error).message}`);
}
}
/**
* Idle keepalive nudge (daemon calls this every ~75s). A `mouse.move` to a
* harmless in-window point holds the RDS session (proven: 12.6 min, zero
@@ -413,7 +456,10 @@ export class VinpinDriverService implements OnModuleDestroy {
await this.bindTaskbarCoords(page);
if (!fiatUp && !okR && !okD) {
// Nothing opened → don't claim warm; leave the cold path in effect.
// Nothing opened → don't claim warm; leave the cold path in effect. Clean
// the seat (log out) first so the next warm-up isn't sabotaged by a dirty
// resumed desktop from this failed attempt.
await this.cleanTeardown();
await this.close();
return false;
}
@@ -429,6 +475,9 @@ export class VinpinDriverService implements OnModuleDestroy {
this.logger.warn(
`warmUp failed: ${(err as Error).message} — falling back to cold per-decode path`,
);
// Clean the RDS seat (close windows + log out) before dropping the browser so
// this failed attempt doesn't leave a dirty desktop for the next warm-up.
await this.cleanTeardown().catch(() => undefined);
await this.close().catch(() => undefined);
this.warm = false;
return false;
@@ -914,7 +963,19 @@ export class VinpinDriverService implements OnModuleDestroy {
if (!page) throw new Error("Vinpin page not initialized");
if (this.authed) return;
// Login (reliable DOM path).
await this.performWebLogin(page, cfg);
// Bring VinPower up (permanent seat needs the app clicked; trial auto-launches).
await this.ensureBrandGrid(page, cfg);
this.authed = true;
}
/**
* Submit the web login form (reliable DOM path) and wait for the post-login
* screen — the Horizon app-launcher (permanent seat) or the VinPower brand grid
* (trial auto-launch). Factored out so both the normal auth path and the
* logout→relogin escalation share one implementation.
*/
private async performWebLogin(page: Page, cfg: VinpinConfig): Promise<void> {
await page.goto(cfg.url, { waitUntil: "domcontentloaded", timeout: 60_000 });
await page.waitForTimeout(VINPIN_WAITS.afterGoto);
const userInput = page.locator('input[type="text"]:visible, input:not([type]):visible').first();
@@ -925,17 +986,37 @@ export class VinpinDriverService implements OnModuleDestroy {
.fill(cfg.pass ?? "");
const submit = page.locator('button:has-text("Oturum"), [type="submit"]').first();
await submit.click();
// Poll for the post-login screen: the Horizon app-launcher (permanent seat) or
// the VinPower brand grid (trial auto-launch). Cap = afterLogin fallback.
// Poll for the post-login screen. Cap = afterLogin fallback (the real grid
// render measured ~3246s, so the cap is now 45s — see VINPIN_WAITS.afterLogin).
await this.pollForState(
page,
(t) => VINPIN_OCR.launcher.test(t) || VINPIN_OCR.brandGrid.test(t),
VINPIN_WAITS.afterLogin,
);
}
// Bring VinPower up (permanent seat needs the app clicked; trial auto-launches).
await this.ensureBrandGrid(page, cfg);
this.authed = true;
/**
* Last-ditch recovery when the brand grid can't be reached by closing windows: end
* the dirty RDS session (clean-teardown → logout), cold-relaunch the browser, and
* log back in from scratch to a fresh grid. One-shot — the inner ensureBrandGrid is
* called with allowRelogin=false so this can never recurse into a livelock. Returns
* true when the brand grid is confirmed after relogin. Never throws.
*/
private async logoutAndRelogin(cfg: VinpinConfig): Promise<boolean> {
try {
this.logger.warn("escalating to logout + relogin to clear a stuck resumed desktop");
await this.cleanTeardown();
await this.close();
await this.launch(cfg);
const page = this.page;
if (!page) return false;
await this.performWebLogin(page, cfg);
await this.ensureBrandGrid(page, cfg, false); // one-shot: no recursive relogin
return VINPIN_OCR.brandGrid.test(await ocrRegion(page));
} catch (err) {
this.logger.warn(`logoutAndRelogin failed: ${(err as Error).message}`);
return false;
}
}
/**
@@ -947,12 +1028,14 @@ export class VinpinDriverService implements OnModuleDestroy {
for (let i = 0; i < 4; i++) {
const t = await ocrRegion(page);
if (VINPIN_OCR.brandGrid.test(t)) return;
// A catalog/ePER window is open → close it to fall back to the grid.
// A catalog/ePER window is open → close it to fall back to the grid. PRIMARY:
// the browser-tab ✕ (93,45). The old windowClose (1298,14) actually hits the
// HTML-Access language selector (opens a dropdown), so it's no longer used here.
this.logger.warn(`not on brand grid (try ${i + 1}/4) — closing open window`);
await page.mouse.click(VINPIN_COORDS.windowClose.x, VINPIN_COORDS.windowClose.y);
await page.mouse.click(VINPIN_COORDS.catalogTabClose.x, VINPIN_COORDS.catalogTabClose.y);
await page.waitForTimeout(VINPIN_WAITS.afterWindowClose);
// A confirm/language dialog may pop on close.
await page.keyboard.press("Enter").catch(() => {});
// Dismiss an accidentally-opened dropdown/confirm before re-OCR.
await page.keyboard.press("Escape").catch(() => {});
await this.ensureBrandGrid(page, cfg);
}
}
@@ -1084,7 +1167,7 @@ export class VinpinDriverService implements OnModuleDestroy {
* OCR-gated with a small retry budget. Never throws — if the grid can't be
* confirmed, the caller's own ePER-open retry loop still runs.
*/
private async ensureBrandGrid(page: Page, cfg: VinpinConfig): Promise<void> {
private async ensureBrandGrid(page: Page, cfg: VinpinConfig, allowRelogin = true): Promise<void> {
for (let i = 0; i < 6; i++) {
const t = await ocrRegion(page);
if (VINPIN_OCR.brandGrid.test(t)) {
@@ -1117,13 +1200,12 @@ export class VinpinDriverService implements OnModuleDestroy {
// the grid. Window-close first, then the browser-tab ✕ as a fallback.
if (VINPIN_OCR.catalogWindowOpen.test(t)) {
this.logger.log(`catalog window resumed open — closing to reach grid (try ${i + 1}/6)`);
await page.mouse.click(VINPIN_COORDS.windowClose.x, VINPIN_COORDS.windowClose.y);
// PRIMARY: the browser-tab ✕ (93,45). The window-close (1298,14) actually
// opens the HTML-Access language dropdown, so it's no longer clicked here.
await page.mouse.click(VINPIN_COORDS.catalogTabClose.x, VINPIN_COORDS.catalogTabClose.y);
await page.waitForTimeout(VINPIN_WAITS.afterWindowClose);
await page.keyboard.press("Enter").catch(() => {});
if (!VINPIN_OCR.brandGrid.test(await ocrRegion(page))) {
await page.mouse.click(VINPIN_COORDS.catalogTabClose.x, VINPIN_COORDS.catalogTabClose.y);
await page.waitForTimeout(VINPIN_WAITS.afterWindowClose);
}
// Dismiss an accidentally-opened dropdown/confirm before re-OCR.
await page.keyboard.press("Escape").catch(() => {});
continue;
}
// Neither grid, login, nor launcher — a "Disconnected" dialog or a blank
@@ -1132,10 +1214,18 @@ export class VinpinDriverService implements OnModuleDestroy {
await page.waitForTimeout(VINPIN_WAITS.afterVinpinLaunch);
}
// Bounded tries exhausted without confirming the grid — most often a resumed
// catalog window we couldn't close (the seat-livelock trigger). Poison the seat
// so the next decode cold re-establishes rather than reconnecting to this state.
// catalog window we couldn't close (the seat-livelock trigger). Rather than limp
// into the ePER-open loop on a dirty desktop, escalate ONCE to logout + relogin
// (end the RDS session and log back in to a fresh grid). One-shot: the inner
// relogin runs ensureBrandGrid with allowRelogin=false, so this can't recurse.
if (allowRelogin && (await this.logoutAndRelogin(cfg))) {
this.sessionPoisoned = false; // relogin reached a fresh, confirmed-clean grid
return;
}
// Relogin disabled (already the one-shot attempt) or it too failed → poison the
// seat so the next decode cold re-establishes rather than reusing this state.
this.sessionPoisoned = true;
this.logger.warn("VinPower brand grid not confirmed — letting the ePER-open loop try anyway");
this.logger.warn("VinPower brand grid not confirmed (relogin exhausted) — poisoning seat");
}
/**

View File

@@ -0,0 +1,153 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/**
* Clean-teardown + grid-return reliability tests for the Vinpin driver. These
* exercise the warm-establish fix: the clean-teardown routine (close resumed
* catalog windows via the corrected tab-✕, then log out of the RDS session before
* dropping the browser) and ensureBrandGrid's escalation to logout+relogin. OCR is
* mocked so the screen-state sequence is fully controllable — the real seat lives
* on prod and can't be driven from a test.
*/
vi.mock("./vinpin.ocr", () => ({
ocrRegion: vi.fn(async () => ""),
pollForText: vi.fn(async () => ({ matched: false, text: "" })),
terminateOcr: vi.fn(async () => undefined),
}));
import { VinpinDriverService } from "./vinpin-driver.service";
import { ocrRegion } from "./vinpin.ocr";
const mockOcr = vi.mocked(ocrRegion);
type AnyDriver = Record<string, unknown>;
function fakePage(sink: (x: number, y: number) => void) {
return {
isClosed: () => false,
frames: () => [] as unknown[],
mouse: { click: vi.fn(async (x: number, y: number) => sink(x, y)) },
keyboard: { press: vi.fn(async () => undefined) },
waitForTimeout: vi.fn(async () => undefined),
};
}
describe("VinpinDriverService — clean teardown", () => {
const savedEnv = { ...process.env };
beforeEach(() => {
process.env.VINPIN_ENABLED = "true";
process.env.VINPIN_USER = "user";
process.env.VINPIN_PASS = "pass";
process.env.VINPIN_WARM_DAEMON = "true";
mockOcr.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
process.env = { ...savedEnv };
});
it("teardownWarm closes each resumed catalog window (tab-✕) then logs out BEFORE closing the browser", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as AnyDriver;
const events: string[] = [];
vi.spyOn(any as never, "close").mockImplementation((async () => {
events.push("close");
}) as never);
any.browser = { isConnected: () => true };
any.context = {};
any.warm = true;
any.page = fakePage((x, y) => {
if (x === 93 && y === 45) events.push("tabClose");
if (x === 1543 && y === 877) events.push("logout");
});
// Two catalog windows open, then a clear desktop.
mockOcr
.mockResolvedValueOnce("RPartStore")
.mockResolvedValueOnce("ePER Dealer")
.mockResolvedValue("");
await driver.teardownWarm();
// Both windows closed via the corrected tab-✕, then logout, then browser close.
expect(events).toEqual(["tabClose", "tabClose", "logout", "close"]);
expect(any.warm).toBe(false);
});
it("clean-teardown still logs out when no catalog window is open (bounded, no-op close loop)", async () => {
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.mockResolvedValue(""); // desktop already clear
await (any.cleanTeardown as () => Promise<void>).call(driver);
expect(clicks.filter(([x, y]) => x === 93 && y === 45)).toHaveLength(0); // nothing to close
expect(clicks).toContainEqual([1543, 877]); // still logs out to end the RDS session
});
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;
any.browser = null;
any.context = null;
any.page = null;
// Must not throw and must not touch OCR.
await expect((any.cleanTeardown as () => Promise<void>).call(driver)).resolves.toBeUndefined();
expect(mockOcr).not.toHaveBeenCalled();
});
});
describe("VinpinDriverService — ensureBrandGrid tab-✕ + relogin escalation", () => {
const savedEnv = { ...process.env };
const cfg = { url: "https://vinpin.test", user: "user", pass: "pass" };
beforeEach(() => {
process.env.VINPIN_ENABLED = "true";
mockOcr.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
process.env = { ...savedEnv };
});
it("closes a resumed catalog via the tab-✕ (93,45) — never the language-selector coord (1298,14) — and escalates to relogin after N failures", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as AnyDriver;
const clicks: Array<[number, number]> = [];
const page = fakePage((x, y) => clicks.push([x, y]));
// Always a catalog window open → grid never reached → forces the escalation.
mockOcr.mockResolvedValue("RPartStore catalog open");
const relogin = vi.spyOn(any as never, "logoutAndRelogin").mockResolvedValue(true as never);
await (any.ensureBrandGrid as (p: unknown, c: unknown, allow?: boolean) => Promise<void>).call(
driver,
page,
cfg,
true,
);
expect(clicks).toContainEqual([93, 45]); // primary tab-✕ used
expect(clicks).not.toContainEqual([1298, 14]); // language-selector coord NOT clicked
expect(relogin).toHaveBeenCalledTimes(1); // escalated once
expect(any.sessionPoisoned).toBe(false); // relogin reached a fresh grid
});
it("with allowRelogin=false it poisons the seat instead of recursing (one-shot, no livelock)", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as AnyDriver;
const page = fakePage(() => undefined);
mockOcr.mockResolvedValue("RPartStore catalog open"); // never reaches grid
const relogin = vi.spyOn(any as never, "logoutAndRelogin");
await (any.ensureBrandGrid as (p: unknown, c: unknown, allow?: boolean) => Promise<void>).call(
driver,
page,
cfg,
false,
);
expect(relogin).not.toHaveBeenCalled(); // no recursion
expect(any.sessionPoisoned).toBe(true); // poisoned → next decode cold-restarts
});
});

View File

@@ -50,12 +50,22 @@ export const VINPIN_COORDS = {
// permanent seat (trvinpin41080). They are OCR-verified+retried at runtime like
// the Fiat flow, so minor drift self-heals — but re-verify if layout changes.
// TUNE.
/** Window-close (X) button of an open catalog window — returns to the brand
* grid when the seat resumed a previously-open catalog. TUNE. */
/** Window-close (X) button of an open catalog window. ⚠️ On the HTML-Access
* desktop this coord actually hits the language selector (it opens a dropdown),
* so it is NO LONGER used by the grid-return paths — the browser-tab ✕ below is
* the primary close. Kept only as the last-ditch fallback inside the Rpartstore
* spinner-guard (closeRpartstoreTab), where it's tried after the tab ✕. TUNE. */
windowClose: { x: 1298, y: 14 },
/** Browser-tab ✕ of an open catalog app (fallback for windowClose) — the tab
* sits just under the window title bar, e.g. "Renault Rpartstore ✕". TUNE. */
catalogTabClose: { x: 135, y: 45 },
/** Browser-tab ✕ of an open catalog app — the PRIMARY window-close action. The
* tab sits just under the window title bar, e.g. "Renault Rpartstore ✕".
* Validated live: clicking (93,45) closed the catalog and returned to the clean
* brand grid (the old {135,45} missed the ✕). TUNE. */
catalogTabClose: { x: 93, y: 45 },
/** "Çıkış yap" logout button (bottom-right of the RDS/Horizon desktop). Ends the
* remote session so the NEXT warm-up starts from a fresh login/grid instead of
* resuming into the last-open dirty catalog desktop. Used by the clean-teardown
* routine. TUNE. */
logoutButton: { x: 1543, y: 877 },
/** Renault brand tile on the VinPower grid (opens the Rpartstore/Dialogys
* submenu). mousedown/up like the Fiat tile. TUNE. */
renaultBrand: { x: 450, y: 707 },
@@ -234,6 +244,12 @@ export const VINPIN_WARM = {
raiseVerifyRetries: 3,
/** After a taskbar-raise click, wait for the window to come forward. */
afterRaiseMs: 1_200,
/** Backoff after a FAILED warmUp: reconcile() and decode()'s warm-on-demand both
* skip re-warming until the cooldown elapses, so a failing seat is not hammered
* every ~60s (which leaves a fresh dirty window each attempt). Starts at the base
* and doubles per consecutive failure up to the max; a successful warm resets it. */
warmBackoffBaseMs: 60_000,
warmBackoffMaxMs: 300_000,
} as const;
/**
@@ -284,7 +300,10 @@ export function isVinpinBusinessHours(now: Date = new Date()): boolean {
/** Wait budgets (ms) for each step. Verified live @ 1600x900. */
export const VINPIN_WAITS = {
afterGoto: 7_000,
afterLogin: 20_000,
/** After submitting the web login: wait for the post-login screen (Horizon
* launcher or VinPower brand grid) to render. The real grid render measured
* ~3246s on the permanent seat, so the old 20s always timed out (noise). */
afterLogin: 45_000,
/** After clicking the launcher's VINPIN app: wait for VinPower to connect and
* raise its login dialog (permanent seat only). */
afterVinpinLaunch: 14_000,