diff --git a/src/background/artifact-download.ts b/src/background/artifact-download.ts index 9f325653..00cac34f 100644 --- a/src/background/artifact-download.ts +++ b/src/background/artifact-download.ts @@ -119,6 +119,8 @@ export async function downloadAcquiredArtifact( export function installPortalBlobDownloadSafetyNet(tabId: number): { bind(blobUrl: unknown): Promise; + /** Whether the browser created any blob download from this tab before a capture was bound. */ + sawDownload(): boolean; remove(): void; } { let expectedFingerprint: string | null = null; @@ -144,6 +146,7 @@ export function installPortalBlobDownloadSafetyNet(tabId: number): { if (!expectedFingerprint) return; for (const candidate of candidates.values()) void handle(candidate).catch(() => undefined); }, + sawDownload: () => candidates.size > 0, remove: () => browser.downloads.onCreated.removeListener(listener), }; } diff --git a/src/background/gstr3b-artifact-acquisition.ts b/src/background/gstr3b-artifact-acquisition.ts index 8852f381..87d6100a 100644 --- a/src/background/gstr3b-artifact-acquisition.ts +++ b/src/background/gstr3b-artifact-acquisition.ts @@ -8,6 +8,13 @@ import { } from "../connectors/gst/portal-blob-shim"; import { downloadAcquiredArtifact, installPortalBlobDownloadSafetyNet } from "./artifact-download"; +/** + * Live, 38 GSTR-3B captures took 0.2-1.5 s and 8 produced nothing in 20 s (#386). The first wait is + * short; one re-click then gets the rest, so the total never exceeds the old 20 s limit. + */ +export const GSTR3B_FIRST_CAPTURE_WAIT_MS = 5_000; +export const GSTR3B_RECLICK_CAPTURE_WAIT_MS = 15_000; + type Gstr3bPdfDeliveryResult = | { ok: true; @@ -31,8 +38,7 @@ export async function acquireGstr3bPdfAfterPreflight(input: { }): Promise { const safetyNet = installPortalBlobDownloadSafetyNet(input.tabId); try { - let captured: PortalBlobShimResult | undefined; - try { + const capture = async (timeoutMs: number): Promise => { const [injection] = await browser.scripting.executeScript({ args: [ { @@ -44,16 +50,43 @@ export async function acquireGstr3bPdfAfterPreflight(input: { period: input.period, returnType: "GSTR-3B", }, + timeoutMs, }, ], func: capturePortalPdfBlob, target: { tabId: input.tabId }, world: "MAIN", }); - captured = injection?.result as PortalBlobShimResult | undefined; + return injection?.result as PortalBlobShimResult | undefined; + }; + let captured: PortalBlobShimResult | undefined; + let reclicked = false; + try { + captured = await capture(GSTR3B_FIRST_CAPTURE_WAIT_MS); + // The portal either answers a GSTR-3B click within about 1.5 s or not at all (#386). One more + // click is safe only when nothing reached the browser: the capture re-checks the page's target + // before clicking, the PDF is generated in the page with no server-side effect, and any + // download the portal did start keeps today's review path instead. + if ( + captured && + !captured.ok && + captured.reason === "generation-timeout" && + !safetyNet.sawDownload() + ) { + reclicked = true; + captured = await capture(GSTR3B_RECLICK_CAPTURE_WAIT_MS); + } } catch { return { ok: false, reason: "main-world-execution-failed", safeSignals: [] }; } + // Recorded on success and failure alike: two clicks reached the portal either way, and a review + // of a failed target must be able to see that. + if (captured && reclicked) { + captured = { + ...captured, + safeSignals: [...captured.safeSignals, "filed-gstr3b-capture-reclicked"], + }; + } if (!captured?.ok) { return { ok: false, diff --git a/tests/background/gstr3b-artifact-acquisition.test.ts b/tests/background/gstr3b-artifact-acquisition.test.ts index 7b73531e..851fe38a 100644 --- a/tests/background/gstr3b-artifact-acquisition.test.ts +++ b/tests/background/gstr3b-artifact-acquisition.test.ts @@ -2,13 +2,16 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type * as ArtifactDownloadModule from "../../src/background/artifact-download"; const mocks = vi.hoisted(() => ({ executeScript: vi.fn(), + addListener: vi.fn(), removeListener: vi.fn(), downloadAcquiredArtifact: vi.fn(), })); vi.mock("wxt/browser", () => ({ browser: { scripting: { executeScript: mocks.executeScript }, - downloads: { onCreated: { addListener: vi.fn(), removeListener: mocks.removeListener } }, + downloads: { + onCreated: { addListener: mocks.addListener, removeListener: mocks.removeListener }, + }, }, })); vi.mock("../../src/background/artifact-download", async (importOriginal) => ({ @@ -16,7 +19,11 @@ vi.mock("../../src/background/artifact-download", async (importOriginal) => ({ downloadAcquiredArtifact: mocks.downloadAcquiredArtifact, })); -import { acquireGstr3bPdfAfterPreflight } from "../../src/background/gstr3b-artifact-acquisition"; +import { + acquireGstr3bPdfAfterPreflight, + GSTR3B_FIRST_CAPTURE_WAIT_MS, + GSTR3B_RECLICK_CAPTURE_WAIT_MS, +} from "../../src/background/gstr3b-artifact-acquisition"; import { acquirePageGeneratedArtifact } from "../../src/background/gstr2b-artifact-acquisition"; import { GSTR1_EXCEL_NO_DETAILS_DIALOG_SELECTOR, @@ -139,6 +146,86 @@ describe("GSTR-3B page-generated acquisition", () => { expect(mocks.downloadAcquiredArtifact).not.toHaveBeenCalled(); }); + describe("one automatic re-click after a silent generation timeout (#386)", () => { + // Live, 2026-09-21: 38 GSTR-3B captures took 0.2-1.5 s and 8 more produced nothing for the full + // 20 s; a manual retry then succeeded each time. So the first wait is short, and a timeout with + // no browser download at all gets one more click, which the owner approved. Any download seen + // from the tab means the portal did act, and that stays the existing review path. + const pdf = () => { + const bytes = new Uint8Array(1024); + bytes.set(new TextEncoder().encode("%PDF-1.7")); + return Buffer.from(bytes).toString("base64"); + }; + const input = { + deliver: async () => ({ ok: true as const, safeSignals: [] }), + financialYear: "2024-25", + filename: "synthetic.pdf", + period: "April", + requestId: "reclick", + returnPeriod: "042024", + tabId: 17, + }; + const timeoutArgs = () => + mocks.executeScript.mock.calls.map( + (call) => (call[0] as { args: [{ timeoutMs?: number }] }).args[0].timeoutMs, + ); + + it("clicks once more after a short silent timeout and keeps the file", async () => { + mocks.executeScript + .mockResolvedValueOnce([ + { result: { ok: false, reason: "generation-timeout", safeSignals: [] } }, + ]) + .mockResolvedValueOnce([ + { result: { ok: true, base64: pdf(), blobUrl: "blob:synthetic/3b", safeSignals: [] } }, + ]); + + const result = await acquireGstr3bPdfAfterPreflight(input); + + expect(result).toMatchObject({ ok: true }); + expect(result.safeSignals).toContain("filed-gstr3b-capture-reclicked"); + expect(timeoutArgs()).toEqual([GSTR3B_FIRST_CAPTURE_WAIT_MS, GSTR3B_RECLICK_CAPTURE_WAIT_MS]); + expect(GSTR3B_FIRST_CAPTURE_WAIT_MS + GSTR3B_RECLICK_CAPTURE_WAIT_MS).toBeLessThanOrEqual( + 20_000, + ); + }); + + it("re-clicks at most once", async () => { + mocks.executeScript.mockResolvedValue([ + { result: { ok: false, reason: "generation-timeout", safeSignals: [] } }, + ]); + + const result = await acquireGstr3bPdfAfterPreflight(input); + expect(result).toMatchObject({ ok: false, reason: "generation-timeout" }); + // Two clicks reached the portal; a review of the failed target must be able to see that. + expect(result.safeSignals).toContain("filed-gstr3b-capture-reclicked"); + expect(mocks.executeScript).toHaveBeenCalledTimes(2); + }); + + it("does not re-click when the browser saw any download from the tab", async () => { + mocks.executeScript.mockImplementationOnce(async () => { + const listener = mocks.addListener.mock.calls.at(-1)?.[0] as + ((item: { id: number; tabId?: number; url?: string }) => void) | undefined; + listener?.({ id: 5, tabId: 17, url: "blob:https://return.gst.gov.in/late" }); + return [{ result: { ok: false, reason: "generation-timeout", safeSignals: [] } }]; + }); + + await expect(acquireGstr3bPdfAfterPreflight(input)).resolves.toMatchObject({ + ok: false, + reason: "generation-timeout", + }); + expect(mocks.executeScript).toHaveBeenCalledTimes(1); + }); + + it.each(["control-not-found", "page-period-mismatch", "too-large", "unexpected-content"])( + "does not re-click after %s", + async (reason) => { + mocks.executeScript.mockResolvedValue([{ result: { ok: false, reason, safeSignals: [] } }]); + await acquireGstr3bPdfAfterPreflight(input); + expect(mocks.executeScript).toHaveBeenCalledTimes(1); + }, + ); + }); + it("distinguishes an absent MAIN-world result from portal generation timeout", async () => { mocks.executeScript.mockResolvedValue([]);