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
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:
@@ -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(() => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 ~32–46s, 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");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
153
apps/api/src/integrations/vinpin/vinpin-driver.teardown.spec.ts
Normal file
153
apps/api/src/integrations/vinpin/vinpin-driver.teardown.spec.ts
Normal 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
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
* ~32–46s 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,
|
||||
|
||||
Reference in New Issue
Block a user