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:
@@ -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 retired — it 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
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user