fix(vinpin): DOM-based dirty-resume recovery via Horizon Running panel

On login the RDS seat resumes DIRTY (e.g. a Renault Rpartstore launch-error
modal + its taskbar window over the brand grid). The old recovery failed: the
canvas tab-✕ (93,45) only hits a TABBED catalog window's ✕, which a resumed
stray doesn't have, so 6 tries did nothing and escalated to logout+relogin —
counterproductive, since the seat publishes apps-only and the RDS session ends
only on server-side idle timeout, so a Connection-Server logout+relogin PROVABLY
resumes the same dirty window.

New state-agnostic recovery `closeStrayRunningApps`: reveal the Horizon sidebar
(#sidebar-toggler), enumerate ul.running-app rows, and terminate every app whose
name != VinPower via its per-app ✕ (li.icon-close-app-image) — real DOM outside
the Blast canvas, so it closes a window regardless of its modal/spinner/loading
state. Collapse the sidebar, OCR-confirm the brand grid; relaunch VinPower via
#available-VINPIN (or the vinpinApp canvas coord) if the app itself was gone.

Wired as the PRIMARY recovery in ensureBrandGrid — both the resumed-catalog
branch (before the canvas tab-✕ fallback) and the end-of-loop escalation, which
NO LONGER calls logout+relogin (method retired). Every DOM op is guarded
(try/catch + presence check) so a missing selector / canvas-only render degrades
gracefully to the existing dismissBlockingModal + tab-✕ / OCR path instead of
throwing. Bounded loop; the launch-error modal dismissal (OK 868,530 / Escape)
is kept as a fast pre-step and fallback.

Preserves the never-throw contract, 180s budget/poison, spinner-guard, acquire
cap, launch-error cooldown, ensureRpartstore fast-bail, and the Fiat ePER path.
tsc clean; 107 vinpin tests green (adds closeStrayRunningApps close/degrade tests
and the ensureBrandGrid-uses-closeStrayRunningApps escalation tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 09:43:53 +03:00
parent 8137845198
commit 814d11d03e
4 changed files with 385 additions and 58 deletions

View File

@@ -549,15 +549,19 @@ describe("VinpinDriverService — Rpartstore spinner guard", () => {
);
});
it("caps the logout+relogin escalation to ONE attempt per decode (no close+relaunch thrash)", async () => {
it("caps the resumed-desktop escalation (closeStrayRunningApps, NOT logout+relogin) to ONE attempt per decode (no thrash)", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as Record<string, unknown>;
// Fresh decode → relogin budget starts unused.
// Fresh decode → escalation budget starts unused.
any.reloginUsedThisDecode = false;
vi.spyOn(any as never, "submitVinPowerLogin").mockResolvedValue(false as never);
vi.spyOn(any as never, "dismissDisconnected").mockResolvedValue(undefined as never);
// Relogin itself fails (does NOT relaunch here)the point is it's only TRIED once.
const relogin = vi.spyOn(any as never, "logoutAndRelogin").mockResolvedValue(false as never);
// logout+relogin is retiredit must not exist as a method any more.
expect(any.logoutAndRelogin).toBeUndefined();
// The escalation itself fails — the point is it's only TRIED once across two returns.
const escalate = vi
.spyOn(any as never, "closeStrayRunningApps")
.mockResolvedValue(false as never);
const page = {
waitForTimeout: vi.fn(async () => undefined),
keyboard: { press: vi.fn(async () => undefined) },
@@ -572,11 +576,11 @@ describe("VinpinDriverService — Rpartstore spinner guard", () => {
);
// Two grid-returns inside ONE decode (as returnToBrandGrid's loop would do). The
// 2nd must NOT re-escalate → no repeated close()+launch() "browser disconnected" loop.
// 2nd must NOT re-escalate → no repeated recovery thrash.
await ensureGrid(page, {}, true);
await ensureGrid(page, {}, true);
expect(relogin).toHaveBeenCalledTimes(1); // one relogin only — the 2nd is capped
expect(escalate).toHaveBeenCalledTimes(1); // one escalation only — the 2nd is capped
expect(any.reloginUsedThisDecode).toBe(true);
expect(any.sessionPoisoned).toBe(true); // capped path poisons for a clean cold restart
});

View File

@@ -28,10 +28,11 @@
*/
import { Injectable, Logger, type OnModuleDestroy } from "@nestjs/common";
import type { Browser, BrowserContext, Page } from "playwright";
import type { Browser, BrowserContext, ElementHandle, Page } from "playwright";
import {
VINPIN_COORDS,
VINPIN_DECODE_BUDGET_MS,
VINPIN_DOM,
VINPIN_FIELD_CLEAR_BACKSPACES,
VINPIN_MODAL_REGION,
VINPIN_OCR,
@@ -150,12 +151,12 @@ export class VinpinDriverService implements OnModuleDestroy {
* reusing/reconnecting to the poisoned warm session. Survives close() on
* purpose (close() resets the session flags, not the poison marker). */
private sessionPoisoned = false;
/** Guards the `ensureBrandGrid → logoutAndRelogin` escalation to AT MOST ONE
* relogin (close+relaunch of the browser) per decode/warm-up. Without it, the
/** Guards the `ensureBrandGrid` resumed-desktop ESCALATION (now the state-agnostic
* Horizon "Running"-panel recovery `closeStrayRunningApps`, NOT the retired
* logout+relogin) to AT MOST ONE attempt per decode/warm-up. Without it, the
* grid-return runs `ensureBrandGrid` on every iteration and each exhausted
* attempt re-escalates → close()+launch() thrash ("browser disconnected — will
* relaunch" repeatedly) until the wall-clock budget. Reset at the top of each
* decode and each warm-up; set the first time a relogin is attempted. */
* attempt re-escalates → thrash until the wall-clock budget. Reset at the top of
* each decode and each warm-up; set the first time the escalation is attempted. */
private reloginUsedThisDecode = false;
/** Epoch-ms until which the Rpartstore catalog is considered DOWN and Renault
* decodes route STRAIGHT to Dialogys without opening Rpartstore. Set on a hard
@@ -1050,26 +1051,188 @@ export class VinpinDriverService implements OnModuleDestroy {
}
/**
* 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.
* PRIMARY state-agnostic recovery: close stray RDS app windows via the Horizon
* "Running" panel's REAL DOM (outside the Blast canvas), which TERMINATES each
* app window regardless of its internal modal/spinner/loading state. This is the
* root fix for the dirty-resume failure: on login the RDS session resumes with a
* stray window (e.g. a "Renault Rpartstore" launch-error modal + its taskbar
* window) over the brand grid, and that window has NO tabbed catalog ✕ for the
* canvas coord (93,45) to hit — so the old tab-✕ loop did nothing and escalated to
* logout+relogin, which is COUNTERPRODUCTIVE (the seat publishes apps-only, so the
* RDS session ends ONLY on server-side idle timeout — a Connection-Server logout
* +relogin provably RESUMES the same dirty window). The Running-panel ✕ closes the
* stray cleanly, returning to a pristine grid.
*
* Returns true when it reached a state where only VinPower remains (grid
* OCR-confirmed). Never-throws: every DOM op is guarded so a missing selector /
* already-clean state / canvas-only render can't throw — if the sidebar/DOM chrome
* isn't present it returns false and the caller falls back to the canvas tab-✕ /
* OCR path. Bounded loop.
*/
private async logoutAndRelogin(cfg: VinpinConfig): Promise<boolean> {
private async closeStrayRunningApps(page: Page, 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}`);
// Fast pre-step: clear a canvas launch-error/OK modal (OK 868,530 / Escape) so
// it can't ride over the DOM path. Harmless when nothing is up; never throws.
await this.dismissBlockingModal(page);
// 1. Reveal the Horizon sidebar (real DOM chrome, hidden until opened). If the
// toggler isn't in the DOM, the client chrome isn't the expected shape →
// degrade to the caller's canvas/OCR fallback (never throw).
if (!(await this.queryDom(page, VINPIN_DOM.sidebarToggler))) return false;
await this.clickDom(page, VINPIN_DOM.sidebarToggler);
await this.wait(page, VINPIN_WAITS.afterSidebarToggle);
// 2-3. Close every running app whose name is NOT VinPower, re-enumerating each
// pass (rows shift as apps close — never hardcode a row/y). Bounded.
let sawVinPower = false;
let sawAnyApp = false;
for (let pass = 0; pass < VINPIN_DOM.maxClosePasses; pass++) {
const apps = await this.enumerateRunningApps(page);
if (apps.length === 0) break;
sawAnyApp = true;
const named = await Promise.all(
apps.map(async (handle) => ({ handle, name: await this.runningAppName(handle) })),
);
if (named.some((n) => VINPIN_DOM.vinPowerAppName.test(n.name))) sawVinPower = true;
const stray = named.find((n) => !VINPIN_DOM.vinPowerAppName.test(n.name));
if (!stray) break; // only VinPower (or nothing) remains
this.logger.log(
`closing stray RDS app "${stray.name || "(unnamed)"}" via the Running panel ✕`,
);
await this.clickAppClose(stray.handle);
await this.wait(page, VINPIN_WAITS.afterRunningAppClose);
}
// 4. Collapse the sidebar so it doesn't cover the canvas for the OCR grid check.
await this.clickDom(page, VINPIN_DOM.sidebarToggler);
await this.wait(page, VINPIN_WAITS.afterSidebarToggle);
// 5. Verify via OCR the canvas is back on the brand grid (state-agnostic truth).
if (VINPIN_OCR.brandGrid.test(await ocrRegion(page).catch(() => ""))) {
this.sessionPoisoned = false; // reached a confirmed-clean grid
return true;
}
// 6. Grid not up. If we positively saw running apps but NONE was VinPower, the
// app itself is gone (resumed with only a stray, or it got closed) → relaunch
// it via the Available VINPIN tile (DOM) / vinpinApp canvas fallback, then
// re-confirm the grid. (Never relaunch on an ambiguous empty enumeration.)
if (sawAnyApp && !sawVinPower) {
await this.relaunchVinPower(page, cfg);
if (VINPIN_OCR.brandGrid.test(await ocrRegion(page).catch(() => ""))) {
this.sessionPoisoned = false;
return true;
}
}
return false;
} catch (err) {
this.logger.warn(`closeStrayRunningApps best-effort failure: ${(err as Error).message}`);
return false;
}
}
/**
* Relaunch VinPower after the stray-close closed (or resumed without) it: prefer
* the Horizon "Available → VINPIN" tile (real DOM), fall back to the canvas
* vinpinApp coord (40,256), then satisfy VinPower's own login dialog and wait for
* the brand grid. Bounded + never-throw. Does NOT recurse into ensureBrandGrid.
*/
private async relaunchVinPower(page: Page, cfg: VinpinConfig): Promise<void> {
try {
if (await this.queryDom(page, VINPIN_DOM.availableVinpin)) {
await this.clickDom(page, VINPIN_DOM.availableVinpin);
} else {
await page.mouse
?.click(VINPIN_COORDS.vinpinApp.x, VINPIN_COORDS.vinpinApp.y)
.catch(() => undefined);
}
await this.wait(page, VINPIN_WAITS.afterVinpinLaunch);
for (let i = 0; i < 3; i++) {
if (VINPIN_OCR.brandGrid.test(await ocrRegion(page).catch(() => ""))) return;
if (await this.submitVinPowerLogin(page, cfg)) {
await this.pollForState(
page,
(t) => VINPIN_OCR.brandGrid.test(t),
VINPIN_WAITS.afterVinPowerLogin,
);
continue;
}
await this.wait(page, VINPIN_WAITS.afterVinpinLaunch);
}
} catch (err) {
this.logger.warn(`relaunchVinPower best-effort failure: ${(err as Error).message}`);
}
}
/**
* Guarded DOM query: `page.$(selector)` with a presence check + full swallow, so a
* mock/canvas page without `$` (or a missing element) yields null instead of
* throwing. Returns the element handle or null.
*/
private async queryDom(page: Page, selector: string): Promise<ElementHandle | null> {
try {
if (typeof page.$ !== "function") return null;
return await page.$(selector);
} catch {
return null;
}
}
/** Guarded `page.click(selector)` — swallows a missing method / element. */
private async clickDom(page: Page, selector: string): Promise<void> {
try {
if (typeof page.click !== "function") return;
await page.click(selector).catch(() => undefined);
} catch {
// best-effort — a missing selector must never break the recovery
}
}
/** Guarded `page.waitForTimeout(ms)` — swallows a missing method. */
private async wait(page: Page, ms: number): Promise<void> {
try {
await page.waitForTimeout?.(ms).catch(() => undefined);
} catch {
// best-effort
}
}
/**
* Enumerate the Horizon "Running" panel app rows (`ul.running-app`). Guarded: a
* page without `$$` (canvas-only render / mock) or a wrong selector yields [].
*/
private async enumerateRunningApps(page: Page): Promise<ElementHandle[]> {
try {
if (typeof page.$$ !== "function") return [];
const handles = await page.$$(VINPIN_DOM.runningApp);
return Array.isArray(handles) ? handles : [];
} catch {
return [];
}
}
/** Read a running-app row's display name (empty string when unreadable). */
private async runningAppName(app: ElementHandle): Promise<string> {
try {
if (!app || typeof app.$ !== "function") return "";
const nameEl = await app.$(VINPIN_DOM.runningAppName);
if (!nameEl || typeof nameEl.textContent !== "function") return "";
const txt = await nameEl.textContent();
return (txt ?? "").trim();
} catch {
return "";
}
}
/** Click a running-app row's per-app close ✕ (terminates that app window). */
private async clickAppClose(app: ElementHandle): Promise<void> {
try {
if (!app || typeof app.$ !== "function") return;
const closeEl = await app.$(VINPIN_DOM.appCloseImage);
if (closeEl && typeof closeEl.click === "function") {
await closeEl.click().catch(() => undefined);
}
} catch {
// best-effort — a missing ✕ must never break the recovery
}
}
@@ -1259,12 +1422,18 @@ 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)`);
// A centered launch-error/confirm dialog can ride OVER the resumed catalog and
// survive the tab-✕ (it's not the window chrome) — clear it first so it can't
// wedge all 6 tries and force the relogin escalation. Best-effort, harmless.
await this.dismissBlockingModal(page);
// 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.
// PRIMARY (state-agnostic): terminate the stray app window via the Horizon
// "Running" panel REAL DOM, which closes it regardless of its internal
// modal/spinner/loading state. A resumed Rpartstore-error (or many resume
// states) has NO tabbed catalog ✕ for the canvas coord (93,45) to hit, so the
// canvas tab-alone did nothing → 6 futile tries + a counterproductive
// logout+relogin. closeStrayRunningApps also runs the launch-error modal
// dismissal as its fast pre-step. If it reaches a clean grid, the loop's top
// OCR check confirms it next pass.
if (await this.closeStrayRunningApps(page, cfg)) continue;
// FALLBACK (only a real TABBED catalog window has this ✕): 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);
// Dismiss an accidentally-opened dropdown/confirm before re-OCR.
@@ -1291,28 +1460,33 @@ 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). 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.
// catalog/stray window we couldn't close (the seat-livelock trigger). Escalate
// ONCE to the state-agnostic Horizon "Running"-panel recovery
// (`closeStrayRunningApps`), which terminates the stray via real DOM. Deliberately
// NOT logout+relogin: the seat publishes apps-only, so a Connection-Server logout
// +relogin provably RESUMES the exact same dirty window (PROVEN live) — the RDS
// session only ends on server-side idle timeout, which nothing we can click
// forces. So don't thrash: if the Running-panel recovery can't reach a clean grid,
// poison the seat and let the decode end via the existing budget/poison path.
//
// CAP: at most ONE relogin per decode/warm-up (`reloginUsedThisDecode`). This
// CAP: at most ONE escalation per decode/warm-up (`reloginUsedThisDecode`). This
// method is re-entered on every returnToBrandGrid iteration; without the cap a
// modal-blocked grid re-escalates each time → close()+launch() thrash ("browser
// disconnected — will relaunch" in a loop) until the wall-clock budget → not_found.
// Marked used BEFORE the attempt so a failed relogin can't be re-tried either.
// wedged grid re-escalates each time → thrash until the wall-clock budget →
// not_found. Marked used BEFORE the attempt so a failed escalation isn't re-tried.
if (allowRelogin && !this.reloginUsedThisDecode) {
this.reloginUsedThisDecode = true;
if (await this.logoutAndRelogin(cfg)) {
this.sessionPoisoned = false; // relogin reached a fresh, confirmed-clean grid
if (await this.closeStrayRunningApps(page, cfg)) {
this.sessionPoisoned = false; // recovery reached a confirmed-clean grid
return;
}
}
// Relogin disabled, already used this decode, or it too failed → poison the seat
// Escalation disabled, already used this decode, or it too failed → poison the seat
// so the next decode cold re-establishes rather than reusing this state (and, when
// the cap is hit, the caller's own budget path ends cleanly instead of thrashing).
this.sessionPoisoned = true;
this.logger.warn("VinPower brand grid not confirmed (relogin exhausted) — poisoning seat");
this.logger.warn(
"VinPower brand grid not confirmed (running-panel recovery exhausted) — poisoning seat",
);
}
/**

View File

@@ -162,7 +162,60 @@ describe("VinpinDriverService — clean teardown", () => {
});
});
describe("VinpinDriverService — ensureBrandGrid tab-✕ + relogin escalation", () => {
/**
* A running-app row handle for the Horizon "Running" panel. `$` resolves the
* name element (textContent) and the per-app close ✕; clicking the ✕ removes the
* app from the shared running list (models a real window teardown).
*/
function fakeRunningApp(name: string, onClose: (n: string) => void) {
return {
$: async (sel: string) => {
if (/running-app-name|focused-app-name/.test(sel)) {
return { textContent: async () => name };
}
if (/icon-close-app-image/.test(sel)) {
return { click: async () => onClose(name) };
}
return null;
},
};
}
/**
* A fake page that also exposes the Horizon client-chrome DOM (`$`, `$$`,
* `page.click`) used by closeStrayRunningApps. `apps` is the mutable running list;
* closing a stray removes it. `closed` records the order strays were terminated.
*/
function fakeDomPage(apps: string[], sink?: (x: number, y: number) => void) {
const running = [...apps];
const closed: string[] = [];
const domClicks: string[] = [];
const page = {
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),
$: async (sel: string) =>
sel === "#sidebar-toggler" || sel === "#available-VINPIN" ? {} : null,
$$: async (sel: string) =>
sel === "ul.running-app"
? running.map((n) =>
fakeRunningApp(n, (name) => {
closed.push(name);
const idx = running.indexOf(name);
if (idx >= 0) running.splice(idx, 1);
}),
)
: [],
click: vi.fn(async (sel: string) => {
domClicks.push(sel);
}),
};
return { page, running, closed, domClicks };
}
describe("VinpinDriverService — ensureBrandGrid stray-app (Running-panel) recovery", () => {
const savedEnv = { ...process.env };
const cfg = { url: "https://vinpin.test", user: "user", pass: "pass" };
beforeEach(() => {
@@ -174,14 +227,54 @@ describe("VinpinDriverService — ensureBrandGrid tab-✕ + relogin escalation",
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 () => {
it("closeStrayRunningApps closes every non-VinPower app via the DOM ✕ and leaves only VinPower", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as AnyDriver;
const { page, running, closed } = fakeDomPage([
"VinPower",
"Renault Rpartstore",
"Loading application...",
]);
// After the strays close + the sidebar collapses, the canvas is on the brand grid.
mockOcr.mockResolvedValue("Volkswagen SsangYong TecDoc");
const ok = await (
any.closeStrayRunningApps as (p: unknown, c: unknown) => Promise<boolean>
).call(driver, page, cfg);
expect(ok).toBe(true);
// Both non-VinPower apps terminated via their per-app ✕, in row order.
expect(closed).toEqual(["Renault Rpartstore", "Loading application..."]);
expect(running).toEqual(["VinPower"]); // VinPower kept
expect(any.sessionPoisoned).toBe(false); // reached a confirmed-clean grid
});
it("closeStrayRunningApps degrades gracefully (false, no throw) when the sidebar DOM is absent", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as AnyDriver;
const page = fakePage(() => undefined); // no $ / $$ / page.click — canvas-only render
mockOcr.mockResolvedValue("");
await expect(
(any.closeStrayRunningApps as (p: unknown, c: unknown) => Promise<boolean>).call(
driver,
page,
cfg,
),
).resolves.toBe(false); // → caller falls back to the canvas tab-✕ / OCR path
});
it("ensureBrandGrid escalates via closeStrayRunningApps (NOT logout+relogin) + falls back to the tab-✕ (93,45), never the language-selector (1298,14)", 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.
// Always a catalog window open → grid never reached → in-branch recovery + escalation.
mockOcr.mockResolvedValue("RPartStore catalog open");
const relogin = vi.spyOn(any as never, "logoutAndRelogin").mockResolvedValue(true as never);
// logout+relogin is retired — the method must not exist any more.
expect((any as Record<string, unknown>).logoutAndRelogin).toBeUndefined();
// DOM absent → the primary Running-panel recovery returns false → tab-✕ fallback.
const escalate = vi.spyOn(any as never, "closeStrayRunningApps");
await (any.ensureBrandGrid as (p: unknown, c: unknown, allow?: boolean) => Promise<void>).call(
driver,
@@ -190,18 +283,38 @@ describe("VinpinDriverService — ensureBrandGrid tab-✕ + relogin escalation",
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
expect(escalate).toHaveBeenCalled(); // primary + escalation is the Running-panel recovery
expect(clicks).toContainEqual([93, 45]); // tab-✕ fallback still used
expect(clicks).not.toContainEqual([1298, 14]); // language-selector coord NEVER clicked
expect(any.sessionPoisoned).toBe(true); // recovery failed (DOM absent) → poison, no relogin
});
it("with allowRelogin=false it poisons the seat instead of recursing (one-shot, no livelock)", async () => {
it("ensureBrandGrid clears the poison when closeStrayRunningApps reaches a clean grid", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as AnyDriver;
const page = fakePage(() => undefined);
mockOcr.mockResolvedValue("RPartStore catalog open"); // never reaches grid by OCR
// The escalation succeeds (Running-panel recovery reached a fresh grid).
const escalate = vi
.spyOn(any as never, "closeStrayRunningApps")
.mockResolvedValue(true as never);
await (any.ensureBrandGrid as (p: unknown, c: unknown, allow?: boolean) => Promise<void>).call(
driver,
page,
cfg,
true,
);
expect(escalate).toHaveBeenCalled();
expect(any.sessionPoisoned).toBe(false); // recovery reached a confirmed-clean grid
});
it("with allowRelogin=false it does NOT run the end-of-loop escalation and poisons the seat (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,
@@ -210,7 +323,6 @@ describe("VinpinDriverService — ensureBrandGrid tab-✕ + relogin escalation",
false,
);
expect(relogin).not.toHaveBeenCalled(); // no recursion
expect(any.sessionPoisoned).toBe(true); // poisoned → next decode cold-restarts
});
});

View File

@@ -253,6 +253,39 @@ export const VINPIN_WINDOW_FOREGROUND: Record<"fiat" | "rpartstore" | "dialogys"
dialogys: /ПОИСК|ПОИC|Dialogys|ИЗМЕНИТЬ/i,
};
/**
* Horizon HTML-Access client-chrome DOM selectors (REAL DOM, outside the Blast
* canvas — hidden until the sidebar is opened). Used by the state-agnostic
* stray-app recovery (`closeStrayRunningApps`): the sidebar "Running" panel lists
* each RDS app with a per-app close ✕ that TERMINATES that specific app window
* regardless of its internal modal/spinner/loading state. Proven live to cleanly
* close both a Fiat "Fiat Dealer" window and a Renault Rpartstore launch-error
* resume, each returning to a pristine brand grid — the root fix for the
* dirty-resume failure the canvas tab-✕ (only hits a TABBED window's ✕) could not
* recover. Treated as GIVEN from the live probe; every use is guarded (try/catch +
* presence check) so a wrong/absent selector degrades to the canvas/OCR fallback
* rather than throwing. ⚠️ A Connection-Server logout+relogin is NOT a recovery
* here: the seat publishes apps-only (no desktop → no Start-menu Log Off), so the
* RDS session ends ONLY on server-side idle timeout — logout+relogin provably
* RESUMES the same dirty window. This DOM path is the recovery instead. */
export const VINPIN_DOM = {
/** Left-edge grip (~10,450) that reveals/collapses the Horizon sidebar. */
sidebarToggler: "#sidebar-toggler",
/** One <ul> per running RDS app in the sidebar "Running" panel. */
runningApp: "ul.running-app",
/** The app's display name inside a running-app row ("VinPower", "Fiat Dealer",
* "Renault Rpartstore", "Dialogys", "Loading application..."). */
runningAppName: "li.running-app-name, li.focused-app-name",
/** Per-app close ✕ inside a running-app row — terminates THAT app window. */
appCloseImage: "li.icon-close-app-image",
/** "Available → VINPIN" launch tile (relaunch VinPower if it was closed). */
availableVinpin: "#available-VINPIN",
/** App name to KEEP — every OTHER running app is a stray to terminate. */
vinPowerAppName: /VinPower/i,
/** Bounded passes over the Running panel (rows shift as apps close). */
maxClosePasses: 6,
} as const;
/** Bottom Horizon/RDS taskbar clip (px), fed to OCR to read the open windows'
* button labels left→right. TUNE against the permanent seat @ 1600x900. */
export const VINPIN_TASKBAR_REGION = { x: 0, y: 866, width: 1600, height: 34 } as const;
@@ -360,6 +393,10 @@ export const VINPIN_WAITS = {
afterSearchOpen: 5_000,
afterVinSubmit: 7_000,
afterAlertDismiss: 250,
/** After toggling the Horizon sidebar open/closed (real DOM chrome reveal). */
afterSidebarToggle: 1_000,
/** Between per-app closes in the Horizon "Running" panel (window teardown settles). */
afterRunningAppClose: 2_000,
/** OCR poll cadence when waiting for a detectable end-state. Each poll is
* CAPPED at the corresponding fixed-wait budget above (kept as a fallback), so
* if OCR never catches the transition the total wait == the old fixed sleep and