perf(vinpin): in-session Renault→Fiat swap + OCR-poll waits + input micro-trims
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
Latency optimizations in the flag-gated (VINPIN_ENABLED) Vinpin decode driver. Every change keeps the existing fixed-wait value as a fallback cap, so worst-case behaviour and robustness are unchanged — only the common case is faster. No live seat session was run (prod holds the single Horizon seat). #2 Renault→Fiat symmetric swap: ensureReady no longer tears the whole browser down on a Renault→Fiat flow switch (was a ~60-80s cold restart). It now keeps the authenticated Horizon/VinPower session, returns to the brand grid via the existing ensureBrandGrid (which closes a resumed foreign catalog window) and reopens Fiat via the normal tile flow — symmetric with the Fiat→Renault direction. Guarded fallback: if the in-session swap can't reach the grid / throws, it falls back to close()+cold re-establish. #3 OCR-poll-until-ready: replaced big fixed post-action sleeps whose completion is OCR-detectable with a capped poll (screenshot→ocrRegion→regex every ~700ms, return on match, cap == old fixed wait). Converted: afterFiatOpen(12s), afterVinSubmit(7s, Fiat modal), afterRpartstoreSubmit(8s), afterRenaultCatalogOpen(12s, ×2), plus cold-path afterLogin(20s) and afterVinPowerLogin(18s). Left afterVinpinLaunch(14s) fixed — the VinPower login dialog has no reliable OCR marker (detected via DOM), and it doubles as a generic connecting-screen settle. Left afterDialogysSubmit fixed (out of scope). #4 Micro-trims: field-clear Backspace burst 40→5 (VINPIN_FIELD_CLEAR_BACKSPACES), key-type delay 45-50ms→20ms (VINPIN_TYPE_DELAY_MS, 17-char VIN ~850→~340ms), afterAlertDismiss 700→250ms. New pure/injectable pollForText helper in vinpin.ocr.ts (unit-tested: early-return, cap-never-exceeded, first-read match). typecheck + biome clean; 31 vinpin unit tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -31,14 +31,16 @@ import { Injectable, Logger, type OnModuleDestroy } from "@nestjs/common";
|
||||
import type { Browser, BrowserContext, Page } from "playwright";
|
||||
import {
|
||||
VINPIN_COORDS,
|
||||
VINPIN_FIELD_CLEAR_BACKSPACES,
|
||||
VINPIN_MODAL_REGION,
|
||||
VINPIN_OCR,
|
||||
VINPIN_RENAULT_HEADER_REGION,
|
||||
VINPIN_TYPE_DELAY_MS,
|
||||
VINPIN_VIEWPORT,
|
||||
VINPIN_WAITS,
|
||||
selectVinpinBrandFlow,
|
||||
} from "./vinpin.constants";
|
||||
import { ocrRegion, terminateOcr } from "./vinpin.ocr";
|
||||
import { type OcrClip, ocrRegion, pollForText, terminateOcr } from "./vinpin.ocr";
|
||||
import {
|
||||
type VinpinParsed,
|
||||
type VinpinRenaultParsed,
|
||||
@@ -146,6 +148,28 @@ export class VinpinDriverService implements OnModuleDestroy {
|
||||
|
||||
// ─── Private ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* OCR-poll wrapper: screenshot → `ocrRegion` every `pollIntervalMs` and return
|
||||
* as soon as `predicate` matches. CAPPED at `capMs` (== the old fixed wait) so
|
||||
* if OCR never catches the transition the total wait is unchanged — this only
|
||||
* makes the common case faster, never the worst case slower. `clip` undefined =
|
||||
* OCR the whole frame (state detection).
|
||||
*/
|
||||
private pollForState(
|
||||
page: Page,
|
||||
predicate: (text: string) => boolean,
|
||||
capMs: number,
|
||||
clip?: OcrClip,
|
||||
): Promise<{ matched: boolean; text: string }> {
|
||||
return pollForText({
|
||||
read: () => ocrRegion(page, clip),
|
||||
predicate,
|
||||
capMs,
|
||||
intervalMs: VINPIN_WAITS.pollIntervalMs,
|
||||
sleep: (ms) => page.waitForTimeout(ms),
|
||||
});
|
||||
}
|
||||
|
||||
private async decodeLocked(vin: string, cfg: VinpinConfig): Promise<VinpinDecodeResult | null> {
|
||||
const flow = selectVinpinBrandFlow(vin);
|
||||
if (flow === "renault") return this.decodeRenaultLocked(vin, cfg);
|
||||
@@ -271,9 +295,23 @@ export class VinpinDriverService implements OnModuleDestroy {
|
||||
* the Fiat ePER search page. (Re)establishes whatever is missing.
|
||||
*/
|
||||
private async ensureReady(cfg: VinpinConfig): Promise<void> {
|
||||
// A Renault catalog window from a prior decode leaves the session on the
|
||||
// wrong flow — tear down for a clean Fiat re-establish.
|
||||
if (this.establishedFlow === "renault") await this.close();
|
||||
// A Renault catalog window from a prior decode leaves the session on the wrong
|
||||
// flow. Symmetric with the Fiat→Renault direction (which swaps catalog windows
|
||||
// in-session via ensureBrandGrid, NOT a teardown): try to keep the warm Horizon
|
||||
// /VinPower session and swap Renault→Fiat in-session (~20-40s) instead of a full
|
||||
// browser teardown + cold restart (~60-80s). Fall back to close()+cold
|
||||
// re-establish only if the in-session swap can't reach the grid — so robustness
|
||||
// and worst-case behaviour are unchanged.
|
||||
if (this.establishedFlow === "renault") {
|
||||
const swapped = await this.swapToFiatInSession(cfg).catch((err) => {
|
||||
this.logger.warn(`in-session Renault→Fiat swap threw: ${(err as Error).message}`);
|
||||
return false;
|
||||
});
|
||||
if (!swapped) {
|
||||
this.logger.warn("in-session Renault→Fiat swap failed — cold re-establish fallback");
|
||||
await this.close();
|
||||
}
|
||||
}
|
||||
if (!this.browser?.isConnected() || !this.context || !this.page || this.page.isClosed()) {
|
||||
await this.launch(cfg);
|
||||
}
|
||||
@@ -282,6 +320,33 @@ export class VinpinDriverService implements OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap a warm Renault-flow session over to Fiat WITHOUT tearing the browser
|
||||
* down: keep the authenticated Horizon/VinPower session, return to the VinPower
|
||||
* brand grid (reusing `ensureBrandGrid`, which closes a resumed foreign catalog
|
||||
* window), then open Fiat ePER via the normal tile flow (`establishSession`).
|
||||
* Returns true when the Fiat Spare-Parts catalogue is confirmed; false if the
|
||||
* warm session is gone or the grid was never reached (the caller then falls back
|
||||
* to a cold re-establish, so robustness is unchanged).
|
||||
*/
|
||||
private async swapToFiatInSession(cfg: VinpinConfig): Promise<boolean> {
|
||||
if (!this.browser?.isConnected() || !this.context || !this.page || this.page.isClosed()) {
|
||||
return false; // nothing warm to reuse → let the cold path handle it
|
||||
}
|
||||
if (!this.authed) return false; // never got past login → cold path is simpler
|
||||
const page = this.page;
|
||||
// Return to the brand grid, closing the resumed Renault catalog window.
|
||||
await this.returnToBrandGrid(page, cfg);
|
||||
if (!VINPIN_OCR.brandGrid.test(await ocrRegion(page))) return false;
|
||||
// On the grid & still authenticated → run the Fiat-open flow. Clearing these
|
||||
// makes establishSession run its Fiat-tile steps (ensureAuthenticated is a
|
||||
// no-op while authed, so the Horizon/VinPower session is preserved).
|
||||
this.loggedIn = false;
|
||||
this.establishedFlow = null;
|
||||
await this.establishSession(cfg);
|
||||
return this.loggedIn && this.establishedFlow === "fiat";
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring the session up to the VinPower brand grid only (shared by both flows):
|
||||
* launch if the browser is down, log in via the DOM form if not authenticated,
|
||||
@@ -306,7 +371,13 @@ export class VinpinDriverService implements OnModuleDestroy {
|
||||
.fill(cfg.pass ?? "");
|
||||
const submit = page.locator('button:has-text("Oturum"), [type="submit"]').first();
|
||||
await submit.click();
|
||||
await page.waitForTimeout(VINPIN_WAITS.afterLogin);
|
||||
// Poll for the post-login screen: the Horizon app-launcher (permanent seat) or
|
||||
// the VinPower brand grid (trial auto-launch). Cap = afterLogin fallback.
|
||||
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);
|
||||
@@ -399,9 +470,13 @@ export class VinpinDriverService implements OnModuleDestroy {
|
||||
await page.mouse.down();
|
||||
await page.waitForTimeout(80);
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(VINPIN_WAITS.afterFiatOpen);
|
||||
const t = await ocrRegion(page);
|
||||
opened = VINPIN_OCR.eperOpen.test(t);
|
||||
// Poll for the ePER window to open (cap = afterFiatOpen fallback).
|
||||
const { matched } = await this.pollForState(
|
||||
page,
|
||||
(t) => VINPIN_OCR.eperOpen.test(t),
|
||||
VINPIN_WAITS.afterFiatOpen,
|
||||
);
|
||||
opened = matched;
|
||||
if (!opened) this.logger.warn(`Fiat ePER open attempt ${i + 1}/4 — not yet (retrying)`);
|
||||
}
|
||||
if (!opened) throw new Error("Fiat ePER did not open after retries");
|
||||
@@ -449,7 +524,13 @@ export class VinpinDriverService implements OnModuleDestroy {
|
||||
// If VinPower's own login dialog is up, satisfy it (never re-click the app
|
||||
// tile mid-login — that would double-launch).
|
||||
if (await this.submitVinPowerLogin(page, cfg)) {
|
||||
await page.waitForTimeout(VINPIN_WAITS.afterVinPowerLogin);
|
||||
// Poll for the brand grid to render (cap = afterVinPowerLogin fallback).
|
||||
// The loop re-verifies at the top, so this only shortens the dead wait.
|
||||
await this.pollForState(
|
||||
page,
|
||||
(t) => VINPIN_OCR.brandGrid.test(t),
|
||||
VINPIN_WAITS.afterVinPowerLogin,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// On the launcher → click the VINPIN app to launch VinPower.
|
||||
@@ -539,14 +620,21 @@ export class VinpinDriverService implements OnModuleDestroy {
|
||||
await page.keyboard.press("Control+A").catch(() => {});
|
||||
await page.keyboard.press("Delete").catch(() => {});
|
||||
await page.waitForTimeout(200);
|
||||
await page.keyboard.type(vin, { delay: 50 });
|
||||
await page.keyboard.type(vin, { delay: VINPIN_TYPE_DELAY_MS });
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
// Submit (arrow) and wait for the decode modal.
|
||||
// Submit (arrow) and poll the modal region until it carries a result or the
|
||||
// "not found" alert (cap = afterVinSubmit fallback). The Blast canvas exposes
|
||||
// no DOM text and the clipboard is unreliable, so the screenshot OCR of the
|
||||
// modal region is the only dependable read channel (see vinpin.ocr).
|
||||
await page.mouse.click(VINPIN_COORDS.vinSubmitArrow.x, VINPIN_COORDS.vinSubmitArrow.y);
|
||||
await page.waitForTimeout(VINPIN_WAITS.afterVinSubmit);
|
||||
|
||||
const text = await this.extractModalText(page);
|
||||
const { text: modalText } = await this.pollForState(
|
||||
page,
|
||||
(t) => VINPIN_OCR.modalHit.test(t) || VINPIN_OCR.notFound.test(t),
|
||||
VINPIN_WAITS.afterVinSubmit,
|
||||
VINPIN_MODAL_REGION,
|
||||
);
|
||||
const text = modalText.length > 0 ? modalText : null;
|
||||
|
||||
// Genuine not-found: ePER shows a "vehicles not found" alert over the bare
|
||||
// catalogue (no model/prod-date). Dismiss it and report null cleanly.
|
||||
@@ -563,16 +651,6 @@ export class VinpinDriverService implements OnModuleDestroy {
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the decode-modal text by OCR'ing a screenshot of the modal region. The
|
||||
* Blast canvas exposes no DOM text and the clipboard is unreliable, so the
|
||||
* screenshot is the only dependable read channel (see vinpin.ocr).
|
||||
*/
|
||||
private async extractModalText(page: Page): Promise<string | null> {
|
||||
const text = await ocrRegion(page, VINPIN_MODAL_REGION);
|
||||
return text.length > 0 ? text : null;
|
||||
}
|
||||
|
||||
// ─── Renault flow (Rpartstore primary + Dialogys fallback) ───
|
||||
|
||||
/**
|
||||
@@ -617,7 +695,14 @@ export class VinpinDriverService implements OnModuleDestroy {
|
||||
VINPIN_COORDS.renaultRpartstore.x,
|
||||
VINPIN_COORDS.renaultRpartstore.y,
|
||||
);
|
||||
await page.waitForTimeout(VINPIN_WAITS.afterRenaultCatalogOpen);
|
||||
// Poll for the catalog window / its Russian-language dialog to appear (the
|
||||
// Cyrillic/ePER chrome is absent from the Latin submenu, so no false early
|
||||
// match). Cap = afterRenaultCatalogOpen fallback.
|
||||
await this.pollForState(
|
||||
page,
|
||||
(t) => VINPIN_OCR.eperOpen.test(t),
|
||||
VINPIN_WAITS.afterRenaultCatalogOpen,
|
||||
);
|
||||
// Both catalogs pop a Russian-language dialog on open → OK.
|
||||
await page.mouse.click(VINPIN_COORDS.renaultLangOk.x, VINPIN_COORDS.renaultLangOk.y);
|
||||
await page.waitForTimeout(VINPIN_WAITS.afterLangDismiss);
|
||||
@@ -665,13 +750,20 @@ export class VinpinDriverService implements OnModuleDestroy {
|
||||
await page.waitForTimeout(350);
|
||||
await page.keyboard.press("Control+A").catch(() => {});
|
||||
await page.keyboard.press("Delete").catch(() => {});
|
||||
for (let i = 0; i < 40; i++) await page.keyboard.press("Backspace").catch(() => {});
|
||||
for (let i = 0; i < VINPIN_FIELD_CLEAR_BACKSPACES; i++)
|
||||
await page.keyboard.press("Backspace").catch(() => {});
|
||||
await page.waitForTimeout(200);
|
||||
await page.keyboard.type(vin, { delay: 45 });
|
||||
await page.keyboard.type(vin, { delay: VINPIN_TYPE_DELAY_MS });
|
||||
await page.waitForTimeout(400);
|
||||
// Submit with Enter (the yellow button moves — do NOT click it).
|
||||
await page.keyboard.press("Enter").catch(() => {});
|
||||
await page.waitForTimeout(VINPIN_WAITS.afterRpartstoreSubmit);
|
||||
// Poll for the vehicle header (hit) or the not-found error card to render
|
||||
// (cap = afterRpartstoreSubmit fallback).
|
||||
await this.pollForState(
|
||||
page,
|
||||
(t) => VINPIN_OCR.rpartstoreHit.test(t) || VINPIN_OCR.rpartstoreNotFound.test(t),
|
||||
VINPIN_WAITS.afterRpartstoreSubmit,
|
||||
);
|
||||
|
||||
// The vehicle header renders in the clip region; re-check both the clip and a
|
||||
// full frame so a slow render or a mispositioned header still reads.
|
||||
@@ -713,7 +805,14 @@ export class VinpinDriverService implements OnModuleDestroy {
|
||||
let ready = false;
|
||||
for (let i = 0; i < 3 && !ready; i++) {
|
||||
await page.mouse.click(VINPIN_COORDS.renaultDialogys.x, VINPIN_COORDS.renaultDialogys.y);
|
||||
await page.waitForTimeout(VINPIN_WAITS.afterRenaultCatalogOpen);
|
||||
// Poll for the catalog window / its Russian-language dialog to appear (the
|
||||
// Cyrillic/ePER chrome is absent from the Latin submenu). Cap =
|
||||
// afterRenaultCatalogOpen fallback.
|
||||
await this.pollForState(
|
||||
page,
|
||||
(t) => VINPIN_OCR.eperOpen.test(t),
|
||||
VINPIN_WAITS.afterRenaultCatalogOpen,
|
||||
);
|
||||
await page.mouse.click(VINPIN_COORDS.renaultLangOk.x, VINPIN_COORDS.renaultLangOk.y);
|
||||
await page.waitForTimeout(VINPIN_WAITS.afterLangDismiss);
|
||||
ready = VINPIN_OCR.dialogysReady.test(await ocrRegion(page));
|
||||
@@ -724,9 +823,10 @@ export class VinpinDriverService implements OnModuleDestroy {
|
||||
await page.waitForTimeout(350);
|
||||
await page.keyboard.press("Control+A").catch(() => {});
|
||||
await page.keyboard.press("Delete").catch(() => {});
|
||||
for (let i = 0; i < 40; i++) await page.keyboard.press("Backspace").catch(() => {});
|
||||
for (let i = 0; i < VINPIN_FIELD_CLEAR_BACKSPACES; i++)
|
||||
await page.keyboard.press("Backspace").catch(() => {});
|
||||
await page.waitForTimeout(200);
|
||||
await page.keyboard.type(vin, { delay: 45 });
|
||||
await page.keyboard.type(vin, { delay: VINPIN_TYPE_DELAY_MS });
|
||||
await page.waitForTimeout(400);
|
||||
await page.mouse.click(VINPIN_COORDS.dialogysSearch.x, VINPIN_COORDS.dialogysSearch.y);
|
||||
await page.waitForTimeout(VINPIN_WAITS.afterDialogysSubmit);
|
||||
|
||||
@@ -164,7 +164,12 @@ export const VINPIN_WAITS = {
|
||||
afterLangDismiss: 2_500,
|
||||
afterSearchOpen: 5_000,
|
||||
afterVinSubmit: 7_000,
|
||||
afterAlertDismiss: 700,
|
||||
afterAlertDismiss: 250,
|
||||
/** 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
|
||||
* worst-case behaviour is unchanged. */
|
||||
pollIntervalMs: 700,
|
||||
// ─── Renault flow waits ───
|
||||
/** After clicking the window-close (X) of an open catalog window. */
|
||||
afterWindowClose: 3_500,
|
||||
@@ -178,6 +183,15 @@ export const VINPIN_WAITS = {
|
||||
afterDialogysSubmit: 8_000,
|
||||
} as const;
|
||||
|
||||
/** Per-key delay (ms) when typing a VIN into a focused canvas field. A focused
|
||||
* field keeps up at ~20ms; the old 45-50ms was conservative padding (17-char VIN
|
||||
* ≈ 340ms vs ≈ 850ms). */
|
||||
export const VINPIN_TYPE_DELAY_MS = 20;
|
||||
|
||||
/** Backspaces used to clear a VIN field after Ctrl+A + Delete. A few for
|
||||
* insurance on a focused field — the old 40 was overkill. */
|
||||
export const VINPIN_FIELD_CLEAR_BACKSPACES = 5;
|
||||
|
||||
/**
|
||||
* Known Fiat model tokens used to extract the model name from the decode modal
|
||||
* text. The modal interleaves Cyrillic boilerplate, a platform code, the model
|
||||
|
||||
76
apps/api/src/integrations/vinpin/vinpin.ocr.spec.ts
Normal file
76
apps/api/src/integrations/vinpin/vinpin.ocr.spec.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { pollForText } from "./vinpin.ocr";
|
||||
|
||||
/** Deterministic fake clock: `sleep` advances virtual time; `now` reads it. */
|
||||
function makeClock() {
|
||||
let t = 0;
|
||||
const slept: number[] = [];
|
||||
return {
|
||||
now: () => t,
|
||||
sleep: async (ms: number) => {
|
||||
slept.push(ms);
|
||||
t += ms;
|
||||
},
|
||||
slept,
|
||||
total: () => slept.reduce((a, b) => a + b, 0),
|
||||
};
|
||||
}
|
||||
|
||||
describe("pollForText", () => {
|
||||
it("returns early as soon as the predicate matches (fast path)", async () => {
|
||||
const clock = makeClock();
|
||||
const reads = ["no", "no", "hit"];
|
||||
let i = 0;
|
||||
const res = await pollForText({
|
||||
read: async () => reads[i++] ?? "",
|
||||
predicate: (t) => t === "hit",
|
||||
capMs: 10_000,
|
||||
intervalMs: 700,
|
||||
sleep: clock.sleep,
|
||||
now: clock.now,
|
||||
});
|
||||
expect(res.matched).toBe(true);
|
||||
expect(res.text).toBe("hit");
|
||||
// Stopped at the 3rd read — well under the cap.
|
||||
expect(i).toBe(3);
|
||||
expect(clock.total()).toBe(2100);
|
||||
expect(clock.total()).toBeLessThan(10_000);
|
||||
});
|
||||
|
||||
it("caps total sleep at capMs when the predicate never matches (fallback == old fixed wait)", async () => {
|
||||
const clock = makeClock();
|
||||
let reads = 0;
|
||||
const res = await pollForText({
|
||||
read: async () => {
|
||||
reads++;
|
||||
return "no";
|
||||
},
|
||||
predicate: (t) => t === "hit",
|
||||
capMs: 2_000,
|
||||
intervalMs: 700,
|
||||
sleep: clock.sleep,
|
||||
now: clock.now,
|
||||
});
|
||||
expect(res.matched).toBe(false);
|
||||
expect(res.text).toBe("no");
|
||||
// Total sleep is exactly the cap — never longer than the old fixed wait.
|
||||
expect(clock.total()).toBe(2_000);
|
||||
// The final interval was clamped to the remaining budget (no overshoot).
|
||||
expect(Math.max(...clock.slept)).toBeLessThanOrEqual(700);
|
||||
expect(reads).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("matches on the very first read without over-sleeping", async () => {
|
||||
const clock = makeClock();
|
||||
const res = await pollForText({
|
||||
read: async () => "ready",
|
||||
predicate: (t) => t.includes("ready"),
|
||||
capMs: 12_000,
|
||||
intervalMs: 750,
|
||||
sleep: clock.sleep,
|
||||
now: clock.now,
|
||||
});
|
||||
expect(res.matched).toBe(true);
|
||||
expect(clock.total()).toBe(750); // one interval, then matched
|
||||
});
|
||||
});
|
||||
@@ -133,6 +133,43 @@ export async function ocrRegion(page: Page, clip?: OcrClip, scale = 3): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll a text `read` until `predicate(text)` holds, or the `capMs` budget is
|
||||
* exhausted. Returns as soon as the predicate matches (the common, fast case);
|
||||
* otherwise the total elapsed sleep equals `capMs` — the SAME fallback wait the
|
||||
* old fixed `waitForTimeout(capMs)` used, so worst-case behaviour is unchanged.
|
||||
*
|
||||
* Pure/injectable (no Playwright/OCR deps) so it can be unit-tested directly:
|
||||
* pass a fake `read`, `sleep` and `now`. The driver wraps it with an OCR read.
|
||||
*/
|
||||
export interface PollForTextOptions {
|
||||
read: () => Promise<string>;
|
||||
predicate: (text: string) => boolean;
|
||||
/** Fallback cap — total sleep never exceeds this (== the old fixed wait). */
|
||||
capMs: number;
|
||||
/** Cadence between reads. */
|
||||
intervalMs: number;
|
||||
sleep: (ms: number) => Promise<void>;
|
||||
/** Injectable clock (defaults to Date.now) — for deterministic tests. */
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export async function pollForText(
|
||||
opts: PollForTextOptions,
|
||||
): Promise<{ matched: boolean; text: string }> {
|
||||
const now = opts.now ?? (() => Date.now());
|
||||
const interval = Math.max(1, opts.intervalMs);
|
||||
const deadline = now() + opts.capMs;
|
||||
let text = "";
|
||||
while (now() < deadline) {
|
||||
const remaining = deadline - now();
|
||||
await opts.sleep(Math.min(interval, remaining));
|
||||
text = await opts.read();
|
||||
if (opts.predicate(text)) return { matched: true, text };
|
||||
}
|
||||
return { matched: false, text };
|
||||
}
|
||||
|
||||
/** Tear down the shared tesseract worker (best-effort). */
|
||||
export async function terminateOcr(): Promise<void> {
|
||||
if (!workerPromise) return;
|
||||
|
||||
Reference in New Issue
Block a user