Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/background/artifact-download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ export async function downloadAcquiredArtifact(

export function installPortalBlobDownloadSafetyNet(tabId: number): {
bind(blobUrl: unknown): Promise<void>;
/** Whether the browser created any blob download from this tab before a capture was bound. */
sawDownload(): boolean;
remove(): void;
} {
let expectedFingerprint: string | null = null;
Expand All @@ -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),
};
}
Expand Down
39 changes: 36 additions & 3 deletions src/background/gstr3b-artifact-acquisition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -31,8 +38,7 @@ export async function acquireGstr3bPdfAfterPreflight(input: {
}): Promise<Gstr3bPdfDeliveryResult> {
const safetyNet = installPortalBlobDownloadSafetyNet(input.tabId);
try {
let captured: PortalBlobShimResult | undefined;
try {
const capture = async (timeoutMs: number): Promise<PortalBlobShimResult | undefined> => {
const [injection] = await browser.scripting.executeScript({
args: [
{
Expand All @@ -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,
Expand Down
91 changes: 89 additions & 2 deletions tests/background/gstr3b-artifact-acquisition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,28 @@ 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) => ({
...(await importOriginal<typeof ArtifactDownloadModule>()),
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,
Expand Down Expand Up @@ -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([]);

Expand Down
Loading