diff --git a/docs/LIVE_FILED_RETURNS_SPIKE.md b/docs/LIVE_FILED_RETURNS_SPIKE.md index d0ec66db..9078dade 100644 --- a/docs/LIVE_FILED_RETURNS_SPIKE.md +++ b/docs/LIVE_FILED_RETURNS_SPIKE.md @@ -494,11 +494,19 @@ safe-result inspection found one GSTR-1/IFF result row for May and no `/returns/auth/gstr1`, with safe signals for the GSTR-1 detail route, GSTR-1 heading and filed status. -The live PDF path is different from GSTR-3B: the filed GSTR-1 detail page does -not expose the final summary PDF download directly. It exposes a portal -`View Summary` action whose live merged label appeared as -`VIEW SUMMARY PROCEED TO FILE/SUMMARY VIEW SUMMARY`; that action opens a -separate summary page with the PDF download control near the bottom. +> **Superseded in part on 2026-09-10.** The paragraph below records what this +> spike saw, and it is accurate for that capture. It is not the general rule it +> was read as: a later capture found the filed-PDF control on the detail route +> itself, with no `View Summary` control on the page at all. The detail route +> has **two** shapes, and a flow written from this paragraph alone waits out its +> step budget on the other one. See "Filed GSTR-1 detail route can be the +> download surface itself" in `PORTAL_INTEGRATION_FINDINGS.md`. + +The live PDF path is different from GSTR-3B: on the page captured here, the +filed GSTR-1 detail page does not expose the final summary PDF download +directly. It exposes a portal `View Summary` action whose live merged label +appeared as `VIEW SUMMARY PROCEED TO FILE/SUMMARY VIEW SUMMARY`; that action +opens a separate summary page with the PDF download control near the bottom. Implementation changes from this evidence: diff --git a/docs/PORTAL_INTEGRATION_FINDINGS.md b/docs/PORTAL_INTEGRATION_FINDINGS.md index 6a32c995..127cdbcd 100644 --- a/docs/PORTAL_INTEGRATION_FINDINGS.md +++ b/docs/PORTAL_INTEGRATION_FINDINGS.md @@ -412,3 +412,36 @@ read-only live probe confirms a stable, scope-bound source for the necessary fac cadences and registration states. Until then, the evidence supports neither replacing the calendar threshold nor suppressing a reader-selected period; record a bounded, user-visible not-filed or unresolved outcome when the acquisition flow establishes one. + +## Filed GSTR-1 detail route can be the download surface itself (2026-09-10) + +Captured live, authenticated, source-surfaces build v0.6.0, on the GSTR-1 detail route +(`/returns/auth/gstr1`). No value read from the page is reproduced here: this file records what the +portal's surfaces do, not what any return says. + +**The page carried the filed-PDF control and no navigation step.** Its control row offered a back +action, an e-invoice details Excel download, a disabled reset, and a filed-PDF download labelled +`DOWNLOAD FILED (PDF)`. There was no `VIEW SUMMARY` control anywhere on it — confirmed against the +full page, header band through footer. + +**What this falsifies.** `LIVE_FILED_RETURNS_SPIKE.md` recorded the opposite from an earlier +capture: that the detail page never exposes the PDF and must be navigated through `View Summary`. +That paragraph is accurate for the page it saw and wrong as a general rule. The detail route has two +shapes. `clickFiledGstr1SummaryForPdf` searched for a control that does not exist on this one and +re-emitted `filed-gstr1-summary-view-pending` every step until the flow step limit, so the run +reported `blocked` / `user-action-required` with no action a user could take. Observed signals: +`gstr-1-detail-route`, `filed-gstr1-summary-view-pending`, `flow-step-limit-reached`. + +**Why both layers missed the control.** The label is `DOWNLOAD FILED (PDF)` — no return type after +`FILED`. `explicitDownloadPattern` for GSTR-1 required `download filed gstr1`, and +`secondaryDownloadPattern` required `download` immediately before `pdf`, so the intervening `filed` +defeated it. The observer carried its own inline copy of that second pattern rather than reading the +descriptor, so the same fact had to be wrong in two places at once. + +**The fix keys off the control, not the shape.** Whether this variant belongs to a particular filing +cadence is unestablished — one page was captured — so the flow asks whether a filed-PDF control is +present and clickable rather than inferring it from the route or from page text. A label appearing +in page copy is not a control: decoy or non-actionable text carrying the same words would otherwise +suppress a real `View Summary` step and strand a target whose PDF was reachable. + +**Not yet established.** Which periods render which shape, and whether a page can offer both. diff --git a/src/background/background-failure-fingerprint.ts b/src/background/background-failure-fingerprint.ts new file mode 100644 index 00000000..2c54541e --- /dev/null +++ b/src/background/background-failure-fingerprint.ts @@ -0,0 +1,43 @@ +// Enough to name a thrown failure, and nothing that could carry portal text. +// +// An error's message is not safe to render: it can quote a page, a URL, or a field value. Its +// class name and the innermost stack symbol can name where a failure happened without repeating +// anything the portal said -- but only if each is established to be this bundle's, rather than +// merely made to look harmless. +// +// Stripping punctuation was the earlier approach and it was worse than no filter: a frame pointing +// at a portal URL came back with its slashes deleted, so the value *looked* like a symbol +// precisely because the characters that would have exposed it were gone. A filter that launders +// its input is not a guard. +// +// This exists because a discarded error cost three separate round trips to locate, each needing a +// build and a live authenticated run. + +// A frame names a place in this bundle only when the file it points at is this bundle's. The +// symbol is what is kept; the URL is what proves the symbol is ours, and it is never kept. An +// anonymous frame has no symbol to take, and a frame from anywhere else does not match at all. +const BUNDLE_FRAME = /^\s*at\s+(?:async\s+)?([A-Za-z_$][\w$.]{0,59})\s+\(chrome-extension:\/\//u; + +// Every error class in this bundle ends in `Error`, as do the platform's own; `DOMException` is +// the one exception the platform makes. Letters and that suffix cannot spell a GSTIN, an ARN, or +// a URL, and a name that fails the shape degrades to `Error` rather than being laundered into one. +const BUNDLE_ERROR_NAME = /^[A-Za-z]{1,40}Error$/u; + +function safeErrorName(name: string): string { + if (name === "DOMException") return name; + return BUNDLE_ERROR_NAME.test(name) ? name : "Error"; +} + +export function backgroundFailureFingerprint(error: unknown): string { + if (!(error instanceof Error)) return "NonError"; + const name = safeErrorName(error.name || "Error"); + const symbol = + typeof error.stack === "string" + ? (error.stack + .split("\n") + .slice(1) + .map((line) => BUNDLE_FRAME.exec(line)?.[1]) + .find(Boolean) ?? "") + : ""; + return symbol ? `${name} at ${symbol}` : name; +} diff --git a/src/background/filed-returns-download-trigger.ts b/src/background/filed-returns-download-trigger.ts index a13862ba..8775832e 100644 --- a/src/background/filed-returns-download-trigger.ts +++ b/src/background/filed-returns-download-trigger.ts @@ -40,9 +40,14 @@ import { gstr3bFullFiscalYearAcquisitionNotWiredStep, isGstr3bFullFiscalYearAcquisitionScope, } from "./gstr3b-artifact-acquisition-block"; +import { DECLINED_ARTIFACT_SIGNALS } from "../connectors/gst/filed-returns-acquisition-diagnostics"; type FlowStepResponse = Extract; +// Only the portal's own declined-artifact answers are adopted from a post-click inspection; any +// other page state leaves the original acquisition failure standing with its reason intact. +const DECLINED_ARTIFACT_SIGNAL_SET = new Set(DECLINED_ARTIFACT_SIGNALS); + async function persistSingleArtifactRecoveryIntent( scope: FiledReturnsDownloadScope, artifactType: FiledReturnsConcreteArtifactType, @@ -672,34 +677,53 @@ async function triggerPageGeneratedSinglePeriodArtifact( checkpointHasDownloadId, externallyVisibleActionMayHaveOccurred, })); - return acquired.ok - ? { - ok: true, - flowStep: { - connectorId: "gst", - scopeId: filedReturnScopeId(returnType), - state: "downloaded", - safeSignals: [...artifact.safeSignals, ...acquired.safeSignals], - safeMessage: acquired.safeMessage ?? artifactSuccessMessage(returnType, artifactType), - ...(hasDownloadDiagnostic(acquired) && acquired.downloadDiagnostic - ? { downloadDiagnostic: acquired.downloadDiagnostic } - : {}), - }, - } - : { - ok: true, - flowStep: { - connectorId: "gst", - scopeId: filedReturnScopeId(returnType), - state: "blocked", - safeSignals: [ - "artifact-acquisition-failed", - `artifact-${acquired.reason}`, - ...acquired.safeSignals, - ], - safeMessage: acquired.safeMessage ?? artifactFailureMessageForDelivery(acquired.reason), - }, - }; + if (acquired.ok) { + return { + ok: true, + flowStep: { + connectorId: "gst", + scopeId: filedReturnScopeId(returnType), + state: "downloaded", + safeSignals: [...artifact.safeSignals, ...acquired.safeSignals], + safeMessage: acquired.safeMessage ?? artifactSuccessMessage(returnType, artifactType), + ...(hasDownloadDiagnostic(acquired) && acquired.downloadDiagnostic + ? { downloadDiagnostic: acquired.downloadDiagnostic } + : {}), + }, + }; + } + + const declined = await postClickBlockedStep({ + artifactType, + deps, + requestId, + returnType, + scope, + tabId, + }); + if (declined) { + // The retain decision above was made about an acquisition failure. This is not one: the + // portal has established that no download exists for this target, so there is nothing for a + // retry to reconcile. Leaving the intent checkpoint standing would block the next attempt as + // `artifact-acquisition-start-unreconciled` -- refusing the retry this very result offers. + retainCheckpointForRecovery = false; + return declined; + } + + return { + ok: true, + flowStep: { + connectorId: "gst", + scopeId: filedReturnScopeId(returnType), + state: "blocked", + safeSignals: [ + "artifact-acquisition-failed", + `artifact-${acquired.reason}`, + ...acquired.safeSignals, + ], + safeMessage: acquired.safeMessage ?? artifactFailureMessageForDelivery(acquired.reason), + }, + }; } finally { if (tracksBrowserDownload && !retainCheckpointForRecovery) { await clearArtifactAcquisitionCheckpoint(checkpointTarget, requestId); @@ -707,6 +731,67 @@ async function triggerPageGeneratedSinglePeriodArtifact( } } +// The portal answers a filed GSTR-1 e-invoice Excel request with an information dialog when the +// taxpayer has no e-invoices to report. That is an answer, not a failure: there is nothing to +// download and retrying cannot change it. +// +// The content script has always been able to recognise that dialog, and the ledger has always +// known how to record the artifact as unavailable and carry the run on. Nothing sent the message +// between them, so the recognition never ran, and the run stopped on a generic failure whose only +// offered remedy was to retry something that cannot succeed. +// +// This asks, and only when an acquisition has already failed. It cannot turn a failure into a +// download: the step it returns is still `blocked`, and completion still requires correlated +// download evidence. +async function postClickBlockedStep({ + artifactType, + deps, + requestId, + returnType, + scope, + tabId, +}: { + artifactType: FiledReturnsConcreteArtifactType; + deps: FiledReturnsFlowMessagingDeps; + requestId: string; + returnType: "GSTR-1" | "GSTR-2B"; + scope: FiledReturnsDownloadScope; + tabId: number; +}): Promise { + const declinable = + (returnType === "GSTR-1" && artifactType === "EXCEL") || returnType === "GSTR-2B"; + if (!declinable) return null; + // This runs after an acquisition has already failed, and it can only refine that failure. If the + // tab has closed, navigated, or refuses injection, the answer is simply that the failure cannot + // be refined -- so the original reason stands. Throwing here would replace a specific, actionable + // failure with the generic background error and lose the terminal summary with it. + let raw: unknown; + try { + raw = await deps.sendMessageToTabWithInjection(tabId, { + type: "PACK_CONTENT_INSPECT_FILED_RETURN_POST_CLICK_V3", + payload: { + actionId: requestId, + artifactType, + financialYear: scope.financialYear, + period: scope.period, + returnType, + }, + }); + } catch { + return null; + } + const response = normaliseContentScriptMessageResponse( + raw, + "PACK_CONTENT_INSPECT_FILED_RETURN_POST_CLICK_V3", + ); + if (!response.ok || !("flowStep" in response)) return null; + // Only the recognised no-details answer is adopted. Any other post-click state leaves the + // original acquisition failure standing, reason intact. + return response.flowStep.safeSignals.some((signal) => DECLINED_ARTIFACT_SIGNAL_SET.has(signal)) + ? { ok: true, flowStep: response.flowStep } + : null; +} + async function deliverValidatedArtifact({ artifactType, base64, diff --git a/src/background/filed-returns-single-period-flow.ts b/src/background/filed-returns-single-period-flow.ts index 4379aa66..3604df03 100644 --- a/src/background/filed-returns-single-period-flow.ts +++ b/src/background/filed-returns-single-period-flow.ts @@ -50,6 +50,7 @@ import { type Gstr1PeriodMismatchRecovery, type ReturnTypeMismatchRecovery, } from "./filed-returns-gstr1-period-mismatch-recovery"; +import type { ReturnsDashboardAnchorFailureReason } from "../connectors/gst/filed-returns-durable-signals"; // The deadline is the product bound. This only stops a broken zero-delay response loop. const ZERO_DELAY_RUNAWAY_STEP_LIMIT = 10_000; @@ -307,7 +308,7 @@ async function blockedWrongOriginResponse( scope: FiledReturnsDownloadScope, deps: FiledReturnsFlowRunnerDeps, shouldPersistSinglePeriodSummary: boolean, - reason: "ambiguous" | "not-found" | "timeout" | "unavailable", + reason: ReturnsDashboardAnchorFailureReason, ): Promise { return withPersistedSinglePeriodSummary( scope, diff --git a/src/background/gstr2b-artifact-acquisition.ts b/src/background/gstr2b-artifact-acquisition.ts index 09336eca..68697132 100644 --- a/src/background/gstr2b-artifact-acquisition.ts +++ b/src/background/gstr2b-artifact-acquisition.ts @@ -38,7 +38,10 @@ export async function acquirePageGeneratedArtifact(input: { controlSelector: `[data-pack-artifact-request="${input.requestId}"]`, ...(input.returnType === "GSTR-2B" ? { - expectedControlText: artifact.controlText, + // Read straight from the GSTR-2B descriptor: only that return type pins the + // control text here, and GSTR-1 has more than one label for the same artifact. + expectedControlText: + GSTR2B_PAGE_GENERATED_ARTIFACTS[input.artifactType].controlText, expectedPeriodTexts: acceptedFiledReturnsMonthTexts(input.period), } : {}), diff --git a/src/connectors/gst/artifact-source.ts b/src/connectors/gst/artifact-source.ts index f2ab2da6..be697c5b 100644 --- a/src/connectors/gst/artifact-source.ts +++ b/src/connectors/gst/artifact-source.ts @@ -1,15 +1,14 @@ -import { validateArtifactBytes } from "./artifact-validation"; +import { describeJsonArtifactRejection, validateArtifactBytes } from "./artifact-validation"; import { GSTR1_DETAIL_PATH, GSTR1_PAGE_GENERATED_ARTIFACTS, - GSTR1_SUMMARY_PATH, GSTR1_SUMMARY_PREFLIGHT_PATH, GSTR2B_JSON_PATH, GSTR2B_ORIGIN, GSTR2B_PAGE_GENERATED_ARTIFACTS, GSTR2B_SUMMARY_PATH, + findPageArtifactControls, } from "./portal-artifact-endpoints"; -import { getClickableElements, normaliseText } from "./filed-returns-dom"; import { extractScopedFiledReturnsDetailIdentity } from "./filed-returns-detail-identity"; import { filedReturnDetailIdentityMatchesScope } from "./filed-returns-detail-navigation"; import { resolveVisibleFiledReturnDownloadCandidates } from "./filed-returns-download-candidates"; @@ -224,14 +223,38 @@ async function acquireGstr1Artifact( const preflightBytes = new Uint8Array(await response.arrayBuffer()); if (isHtmlResponse(preflightBytes)) return failed(request, "preflight-failed"); const preflight = validateArtifactBytes(preflightBytes, "JSON", request.returnPeriod, "GSTR-1"); - if (!preflight.ok) return failed(request, preflight.reason); - const expectedPath = request.artifactType === "PDF" ? GSTR1_SUMMARY_PATH : GSTR1_DETAIL_PATH; - if (view.location.pathname !== expectedPath) - return failed(request, "wrong-page", ["target-period-verified"]); + if (!preflight.ok) { + return failed(request, preflight.reason, [ + "gstr1-summary-preflight-rejected", + ...describeJsonArtifactRejection(preflightBytes, request.returnPeriod, "GSTR-1"), + ]); + } const descriptor = GSTR1_PAGE_GENERATED_ARTIFACTS[request.artifactType]; - const controls = resolvePageArtifactControls(documentRef, descriptor.controlText); - if (controls.length !== 1 || !controls[0]) - return failed(request, "control-not-found", ["target-period-verified"]); + // The page decides which control to look for, because the same artifact is labelled differently + // on each surface that offers it. A page that is not one of those surfaces is refused. + const surface = descriptor.surfaces.find( + (candidate) => candidate.path === view.location.pathname, + ); + if (!surface) { + // Named symbolically rather than by path, so the reason is diagnosable without a portal URL + // reaching a signal, a log, or an issue. + return failed(request, "wrong-page", [ + "target-period-verified", + request.artifactType === "PDF" + ? "gstr1-pdf-expects-summary-page" + : "gstr1-excel-expects-detail-page", + view.location.pathname === GSTR1_DETAIL_PATH ? "gstr1-on-detail-page" : "gstr1-on-other-page", + ]); + } + const controls = findPageArtifactControls(documentRef, surface.controlText); + if (controls.length !== 1 || !controls[0]) { + // How many matched matters: none means the label is wrong for this page shape, several means + // the label is ambiguous and binding to one of them would be a guess. + return failed(request, "control-not-found", [ + "target-period-verified", + controls.length === 0 ? "gstr1-control-label-unmatched" : "gstr1-control-label-ambiguous", + ]); + } const pageTargetMismatchSignals = gstr1PageTargetMismatchSignals(controls[0], request); if (pageTargetMismatchSignals.length > 0) { return failed(request, "page-period-mismatch", [ @@ -309,7 +332,7 @@ async function acquireGstr2bArtifact( if (view.location.pathname !== GSTR2B_SUMMARY_PATH) return failed(request, "wrong-page", ["target-period-verified"]); const descriptor = GSTR2B_PAGE_GENERATED_ARTIFACTS[request.artifactType]; - const controls = resolvePageArtifactControls(documentRef, descriptor.controlText); + const controls = findPageArtifactControls(documentRef, descriptor.controlText); if (controls.length !== 1 || !controls[0]) return failed(request, "control-not-found", ["target-period-verified"]); // The preflight above validated the fetched JSON, not the page. The summary @@ -339,15 +362,6 @@ async function acquireGstr2bArtifact( }; } -function resolvePageArtifactControls(documentRef: Document, canonicalLabel: string): HTMLElement[] { - const normalisedLabel = normaliseText(canonicalLabel); - return getClickableElements(documentRef).filter( - (element) => - getClickableElements(element).length === 0 && - normaliseText(element.textContent || "").includes(normalisedLabel), - ); -} - function failed( request: ArtifactRequest, reason: ArtifactFailureReason, diff --git a/src/connectors/gst/artifact-validation.ts b/src/connectors/gst/artifact-validation.ts index beaf7ac9..225b03fb 100644 --- a/src/connectors/gst/artifact-validation.ts +++ b/src/connectors/gst/artifact-validation.ts @@ -1,4 +1,5 @@ import type { FiledReturnsReturnType } from "./filed-returns-return-types"; +import type { JsonArtifactRejectionSignal } from "./filed-returns-acquisition-diagnostics"; export type ArtifactValidationResult = | { @@ -11,7 +12,6 @@ export type ArtifactValidationResult = | { ok: false; reason: "empty" | "too-large" | "unexpected-content" | "target-period-mismatch" }; const MIN_PDF_BYTES = 1024; -const MIN_JSON_BYTES = 100; export const MAX_ARTIFACT_BYTES = 25 * 1024 * 1024; const PDF_MAGIC = [0x25, 0x50, 0x44, 0x46, 0x2d]; const XLSX_MAGIC = [0x50, 0x4b]; @@ -65,7 +65,12 @@ export function validateArtifactBytes( mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", }; } - if (bytes.byteLength < MIN_JSON_BYTES) return { ok: false, reason: "unexpected-content" }; + // No size floor. A filed return with nothing in it has a legitimately compact summary envelope, + // and a byte count cannot tell that apart from a truncated response -- captured live on + // 2026-09-10, where a valid April envelope under 100 bytes was refused before anything read it. + // Every case the floor was standing in for is caught below and caught better: an empty body is + // rejected above, a non-JSON body fails to parse, and a body too small to hold the envelope + // fails the envelope and period checks that follow. try { const parsed = JSON.parse(new TextDecoder().decode(bytes)) as unknown; const contract = filedReturnsJsonDocumentContract(returnType); @@ -83,6 +88,52 @@ export function validateArtifactBytes( } } +// Why a JSON artifact was refused, as signal fragments safe to show and to persist. +// +// `validateArtifactBytes` returns a category, and two different failures share the +// `unexpected-content` category: a body that is not the expected envelope, and one whose period +// field is absent. Diagnosing a live refusal from outside the browser needs the difference, and a +// guard that cannot say which condition fired costs a round trip every time it fires. +// +// Everything here is a shape fact -- byte count band, field presence, parse success. No field +// value is included, so nothing taxpayer-identifying can reach a signal. +export function describeJsonArtifactRejection( + bytes: Uint8Array, + expectedReturnPeriod: string, + returnType: FiledReturnsReturnType, +): JsonArtifactRejectionSignal[] { + if (bytes.byteLength === 0) return ["json-body-empty"]; + // The size cap is a processing bound, not only a verdict. Decoding and parsing a body this + // function has already been told is too large spends exactly the work the cap exists to refuse, + // on the one path where the input is known to be unreasonable. The band is the whole diagnostic + // here: nothing inside an oversized body would change what a reader does about it. + if (bytes.byteLength > MAX_ARTIFACT_BYTES) return ["json-body-oversized"]; + // Report the size band and keep going. Stopping here says only that the body is small, which + // cannot distinguish a legitimately compact envelope from a truncated or unrelated response -- + // and that distinction is the whole question when a return has nothing in it. + const signals: JsonArtifactRejectionSignal[] = []; + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder().decode(bytes)) as unknown; + } catch { + return [...signals, "json-parse-failed"]; + } + const contract = filedReturnsJsonDocumentContract(returnType); + if (contract.requiredStatus !== undefined) { + const statusMatches = isJsonObject(parsed) && parsed.status === contract.requiredStatus; + if (!statusMatches) return [...signals, "json-status-unexpected"]; + } + const document = jsonObjectAtPath(parsed, contract.envelopePath); + if (!document) return [...signals, "json-envelope-missing"]; + const actualPeriod = document[contract.returnPeriodKey]; + if (typeof actualPeriod !== "string") return [...signals, "json-period-field-missing"]; + // A small body that still satisfies the contract is the case the size floor gets wrong, and + // this signal is what says so. + return actualPeriod === expectedReturnPeriod + ? [...signals, "json-contract-satisfied"] + : [...signals, "json-period-mismatch"]; +} + function jsonObjectAtPath(input: unknown, path: readonly string[]): Record | null { let current = input; for (const segment of path) { diff --git a/src/connectors/gst/filed-returns-acquisition-diagnostics.ts b/src/connectors/gst/filed-returns-acquisition-diagnostics.ts new file mode 100644 index 00000000..61b28f3e --- /dev/null +++ b/src/connectors/gst/filed-returns-acquisition-diagnostics.ts @@ -0,0 +1,75 @@ +// The signals that say why an artifact acquisition was refused. +// +// A leaf module with no imports, because two places need the same list: the code that emits each +// signal, and the durable allowlist that decides whether a refusal can be persisted. +// +// That coupling is the point. An unregistered signal does not degrade gracefully -- it rejects the +// whole durable array, and the run then halts on non-canonical recovery metadata instead of +// recording the refusal the signal was describing. Emitting and persisting therefore read from one +// list rather than two that can drift, which is how the first version of these diagnostics broke +// the full-year run it was built to explain. +// +// The coupling is enforced by the types below rather than by asking each emitter to import a +// constant: an emitter declares it returns one of these, so a literal that is not on the list +// fails to compile. That is the level the last failure of this kind needed -- the list was right +// and the emitter simply wrote a string nobody had registered. +// +// Every entry is a Pack-owned constant. None carries portal text, a field value, or a path, so +// nothing portal-derived reaches durable state through them. +export const JSON_ARTIFACT_REJECTION_SIGNALS = [ + // No longer emitted: the size floor it described was removed once a live capture showed a valid + // sub-100-byte envelope. It stays registered because durable state written by an earlier build + // can still contain it, and an unregistered token rejects the whole array it travels in. + "json-body-under-minimum", + "json-body-empty", + "json-body-oversized", + "json-parse-failed", + "json-status-unexpected", + "json-envelope-missing", + "json-period-field-missing", + "json-period-mismatch", + "json-contract-satisfied", +] as const; + +export const GSTR1_ACQUISITION_DIAGNOSTIC_SIGNALS = [ + "gstr1-summary-preflight-rejected", + "gstr1-pdf-expects-summary-page", + "gstr1-excel-expects-detail-page", + "gstr1-on-detail-page", + "gstr1-on-other-page", + "gstr1-control-label-unmatched", + "gstr1-control-label-ambiguous", +] as const; + +export const ARTIFACT_ACQUISITION_DIAGNOSTIC_SIGNALS = [ + ...JSON_ARTIFACT_REJECTION_SIGNALS, + ...GSTR1_ACQUISITION_DIAGNOSTIC_SIGNALS, +] as const; + +export type JsonArtifactRejectionSignal = (typeof JSON_ARTIFACT_REJECTION_SIGNALS)[number]; +export type Gstr1AcquisitionDiagnosticSignal = + (typeof GSTR1_ACQUISITION_DIAGNOSTIC_SIGNALS)[number]; + +// The portal's own refusals to produce an artifact: a filed GSTR-1 with no e-invoices to report, +// and a GSTR-2B the portal did not draft. Both are answers rather than faults, and a run records +// the artifact as unavailable and carries on. +// +// Three structures held this pair before -- a signal set, a reason set, and a map between them -- +// in two modules, with the `artifact-` prefix that relates them stated nowhere. Adding the second +// refusal meant editing all three, and the relationship was only ever visible by reading them side +// by side. +export const DECLINED_ARTIFACT_SIGNALS = [ + "filed-gstr1-excel-no-details-available", + "filed-gstr2b-not-generated", +] as const; + +export type DeclinedArtifactSignal = (typeof DECLINED_ARTIFACT_SIGNALS)[number]; + +/** The reason recorded against the artifact, derived from the flow signal that carried it. */ +export function declinedArtifactReason( + signal: DeclinedArtifactSignal, +): `artifact-${DeclinedArtifactSignal}` { + return `artifact-${signal}`; +} + +export const DECLINED_ARTIFACT_REASONS = DECLINED_ARTIFACT_SIGNALS.map(declinedArtifactReason); diff --git a/src/connectors/gst/filed-returns-detail-navigation.ts b/src/connectors/gst/filed-returns-detail-navigation.ts index 13f4cc52..45572cb7 100644 --- a/src/connectors/gst/filed-returns-detail-navigation.ts +++ b/src/connectors/gst/filed-returns-detail-navigation.ts @@ -8,6 +8,7 @@ import { import type { extractFiledReturnsDetailIdentity } from "./filed-returns-detail-identity"; import { navigateToReturnDashboardPage } from "./filed-returns-navigator"; import { filedReturnDescriptor, filedReturnScopeId } from "./filed-returns-return-descriptors"; +import { offersFiledGstr1DetailPdf } from "./portal-artifact-endpoints"; export function shouldReturnFromMismatchedDetail( detailIdentity: ReturnType, @@ -150,6 +151,15 @@ export function clickFiledGstr1SummaryForPdf( if (scope.returnType !== "GSTR-1") return null; if (!scopeIncludesPdfArtifact(scope)) return null; if (safeSignals.includes("download-pdf-gstr-1")) return null; + // The detail route can carry the filed-PDF control itself, captured 2026-09-10 as + // `DOWNLOAD FILED (PDF)` with no View Summary control anywhere on the page. Navigating away from + // a page that already offers the download is what left this flow waiting until its step limit. + // + // Asked of the page's controls, not of a signal derived from its text. `download-filed-gstr-1` + // only says the label appears somewhere, which decoy or non-actionable copy also does -- and + // skipping a real View Summary control on that basis strands a target whose PDF was reachable + // all along. The question is whether this page offers something to click, so the page is asked. + if (offersFiledGstr1DetailPdf(documentRef)) return null; if (isGstr1SummaryRoute(documentRef)) return null; const summaryControl = findGstr1ViewSummaryControl(documentRef); diff --git a/src/connectors/gst/filed-returns-durable-signals.ts b/src/connectors/gst/filed-returns-durable-signals.ts index 08dca878..eca34326 100644 --- a/src/connectors/gst/filed-returns-durable-signals.ts +++ b/src/connectors/gst/filed-returns-durable-signals.ts @@ -25,6 +25,25 @@ import { FILED_RETURNS_TARGET_REVIEW_CLEAR_FAILURE_STAGES, filedReturnsTargetReviewClearFailureSignal, } from "./filed-returns-target-review-clear"; +import { + ARTIFACT_ACQUISITION_DIAGNOSTIC_SIGNALS, + DECLINED_ARTIFACT_REASONS, + DECLINED_ARTIFACT_SIGNALS, +} from "./filed-returns-acquisition-diagnostics"; + +// The reasons and methods those templates can produce. Enumerated here beside the allowlist so a +// new one is a compile-time change in one place rather than a signal that silently fails to +// persist. +export const RETURNS_DASHBOARD_ANCHOR_FAILURE_REASONS = [ + "ambiguous", + "not-found", + "timeout", + "unavailable", +] as const; +export const PORTAL_BLOB_SHIM_SUPPRESSION_METHODS = ["dispatchEvent", "click"] as const; + +export type ReturnsDashboardAnchorFailureReason = + (typeof RETURNS_DASHBOARD_ANCHOR_FAILURE_REASONS)[number]; const MAX_DURABLE_SIGNAL_COUNT = 32; @@ -132,7 +151,9 @@ const EXACT_DURABLE_SIGNALS = new Set([ "filed-gstr1-download-status-not-filed", "filed-gstr1-download-trigger-ambiguous", "filed-gstr1-excel-control-pending", - "filed-gstr1-excel-no-details-available", + // Both portal refusals, from the list that defines them, so registering a new one is not a + // separate step someone can forget -- which is how the last one halted a run. + ...DECLINED_ARTIFACT_SIGNALS, GSTR1_PERIOD_MISMATCH_RECOVERY_STOPPED_SIGNAL, "filed-gstr1-result-view-auto-attempt-failed", "filed-gstr1-result-view-auto-clicked", @@ -345,6 +366,18 @@ const EXACT_DURABLE_SIGNALS = new Set([ "gstr1-artifact-response-missing", "gstr1-artifact-state-invalid", "gstr2b-detail-heading", + // Page-identity evidence for the GSTR-2B summary route. These were transient while the only + // step that carried them was the "ready" hand-off to acquisition, whose signals never reach + // durable state. Recording the portal's refusal made them terminal, and one unregistered token + // rejects the whole array -- which blocked every period the portal declined to generate. + "gstr2b-visible-period-verified", + "gstr2b-visible-period-mismatch", + "gstr2b-labelled-period-evidence-missing", + "gstr2b-server-period-mismatch", + "gstr2b-server-visible-period-conflict", + "gstr2b-summary-period-mismatch", + "gstr2b-summary-dashboard-back-clicked", + "gstr2b-summary-back-clicked", "gstr2b-detail-route", "gstr2b-dashboard-period-select-found", "gstr2b-dashboard-period-select-missing", @@ -387,10 +420,7 @@ const EXACT_DURABLE_SIGNALS = new Set([ "return-dashboard-after-returns-menu", "return-dashboard-after-services-menu", "return-dashboard-initial-scan", - "returns-dashboard-anchor-ambiguous", - "returns-dashboard-anchor-not-found", - "returns-dashboard-anchor-timeout", - "returns-dashboard-anchor-unavailable", + ...RETURNS_DASHBOARD_ANCHOR_FAILURE_REASONS.map((reason) => `returns-dashboard-anchor-${reason}`), "return-filing-period-left-unselected", "return-type-selected", "safe-dialog-dismissed", @@ -634,7 +664,7 @@ const SCOPED_RETURN_SIGNAL_SUFFIXES = new Set([ ]); const ARTIFACT_FAILURE_SIGNALS = new Set([ "artifact-acquisition-failed", - "artifact-filed-gstr1-excel-no-details-available", + ...DECLINED_ARTIFACT_REASONS, // Artifact-acquisition recovery exists to survive service-worker death, so // its outcomes must be persistable. Without these the blocked summary that // routes an interrupted acquisition to review is rejected by @@ -662,6 +692,20 @@ const ARTIFACT_FAILURE_SIGNALS = new Set([ "artifact-acquisition-download-completed-unpersisted", "artifact-acquisition-download-reconciled", ...Object.keys(ARTIFACT_FAILURE_MESSAGES).map((reason) => `artifact-${reason}`), + // Why an acquisition was refused, not just that it was. Spread from the same list the emitters + // read, so a new diagnostic cannot be added without becoming persistable -- an unregistered one + // rejects the whole array and halts the run on non-canonical recovery metadata. + ...ARTIFACT_ACQUISITION_DIAGNOSTIC_SIGNALS, + // Built by template rather than written as literals, which is why a scan for unregistered + // signal strings never found them. Each accompanies a blocked step that has to persist, and an + // unregistered one rejects the whole array -- the run then halts on non-canonical recovery + // metadata rather than on the navigation problem it was describing. + ...PORTAL_BLOB_SHIM_SUPPRESSION_METHODS.map( + (method) => `portal-blob-shim-suppressed-via-${method}`, + ), + // Passed as the step-limit signal for the GSTR-1 Excel detail wait. Its `gstr1` prefix puts it + // in the navigation rejection category, so an unregistered one rejects the whole array. + "gstr1-excel-detail-step-limit-reached", ]); export function parseDurableFiledReturnsSignals(input: unknown): string[] | null { @@ -686,6 +730,13 @@ export function durableFiledReturnsSignalRejectionReason( // persisted or rendered. It distinguishes Pack-owned producer families during // live recovery without admitting portal-derived text into durable state. function durableUnknownSignalCategory(signal: string): DurableFiledReturnsSignalRejectionReason { + // The token itself is never logged. This boundary exists precisely because it cannot know what + // it has been handed -- a legacy or malformed entry read back from storage is exactly the input + // it is here to refuse -- so repeating it in a console sink undoes the refusal it just made. + // + // The diagnostic this replaces was written when an unregistered signal could only be found at + // runtime. It no longer can: the signal lists are now types, and a producer emitting a token the + // allowlist does not carry fails to compile. A category is what remains useful at runtime. if (signal.startsWith("filed-return-detail-")) return "unknown-detail-identity"; if ( /^(?:artifact-|filed-gstr|page-|browser-download|full-fiscal-year-opfs|single-period-opfs)/.test( diff --git a/src/connectors/gst/filed-returns-observer-signals.ts b/src/connectors/gst/filed-returns-observer-signals.ts index 7b71ce26..f047d300 100644 --- a/src/connectors/gst/filed-returns-observer-signals.ts +++ b/src/connectors/gst/filed-returns-observer-signals.ts @@ -1,6 +1,7 @@ import { filedReturnScopedSignal } from "./filed-returns-return-descriptors"; import type { FiledReturnsReturnType } from "./filed-returns-return-types"; import type { FiledReturnsObservationHints } from "./filed-returns-observer-types"; +import { filedReturnDescriptor } from "./filed-returns-return-descriptors"; // Fixed classifier vocabulary only. These tokens carry no portal text or taxpayer // data and are shared by the observation and durable-recovery boundaries. @@ -86,11 +87,18 @@ function addReturnTextSignals(text: string, signals: string[]): void { } function addDownloadControlSignals(text: string, signals: string[]): void { - if (/download filed gstr[\s-]?3b/.test(text)) signals.push("download-filed-gstr-3b"); - if (/download filed gstr[\s-]?1\b/.test(text)) signals.push("download-filed-gstr-1"); + // Read the control patterns from the descriptors rather than restating them. This file used to + // carry its own copies, so a portal label the descriptor learned about stayed unrecognised here + // -- which is how `DOWNLOAD FILED (PDF)` was missed by both layers at once. + const gstr1 = filedReturnDescriptor("GSTR-1"); + if (filedReturnDescriptor("GSTR-3B").explicitDownloadPattern.test(text)) { + signals.push("download-filed-gstr-3b"); + } + if (gstr1.explicitDownloadPattern.test(text)) signals.push("download-filed-gstr-1"); if ( signals.includes("gstr-1-summary-route") && - (/\bdownload\s*\(?\s*pdf\s*\)?\b/.test(text) || /\bdownload\b.*\bsummary\b.*\bpdf\b/.test(text)) + ((gstr1.secondaryDownloadPattern?.test(text) ?? false) || + /\bdownload\b.*\bsummary\b.*\bpdf\b/.test(text)) ) { signals.push("download-pdf-gstr-1"); } diff --git a/src/connectors/gst/filed-returns-post-click-blocked-state.ts b/src/connectors/gst/filed-returns-post-click-blocked-state.ts index c2bd3f02..95d4295d 100644 --- a/src/connectors/gst/filed-returns-post-click-blocked-state.ts +++ b/src/connectors/gst/filed-returns-post-click-blocked-state.ts @@ -2,6 +2,7 @@ import type { PortalDownloadTriggerResult } from "../../core/contracts"; import { delay } from "../../core/time"; import type { FiledReturnsDownloadTarget } from "./filed-returns-contracts"; import { filedReturnScopeId } from "./filed-returns-return-descriptors"; +import { verifyFiledReturnsDownloadTarget } from "./filed-returns-download-target"; const GSTR1_EXCEL_POST_CLICK_BLOCKED_WAIT_MS = 800; const GSTR1_EXCEL_POST_CLICK_BLOCKED_POLL_MS = 100; @@ -39,6 +40,17 @@ export function detectPostClickBlockedState( return null; } + // A dialog on screen is not bound to this target by being on screen. The detail route does not + // change per period, and a dialog left standing by an earlier target would otherwise mark this + // artifact unavailable -- letting a composite or full-year run carry on having silently omitted + // an artifact the portal never declined for it. + // + // This is the same guard that binds a download click, asked the same question: the visible page + // must be this return type, this period, this financial year. Recording a refusal resolves the + // target outright and no artifact follows to corroborate it, so it is held to the same bar. It + // fails closed -- an unreadable detail header is "could not determine", never "matches". + if (verifyFiledReturnsDownloadTarget(documentRef, target, [])) return null; + return { connectorId: "gst", scopeId: filedReturnScopeId(target.returnType), diff --git a/src/connectors/gst/filed-returns-return-descriptors.ts b/src/connectors/gst/filed-returns-return-descriptors.ts index a34d7732..985f0443 100644 --- a/src/connectors/gst/filed-returns-return-descriptors.ts +++ b/src/connectors/gst/filed-returns-return-descriptors.ts @@ -31,7 +31,11 @@ const FILED_RETURN_MECHANICS: Record = { - PDF: { controlText: "DOWNLOAD SUMMARY (PDF)", expectedMime: "application/pdf" }, + PDF: { + surfaces: [ + { path: GSTR1_SUMMARY_PATH, controlText: "DOWNLOAD SUMMARY (PDF)" }, + { path: GSTR1_DETAIL_PATH, controlText: "DOWNLOAD FILED (PDF)" }, + ], + expectedMime: "application/pdf", + }, EXCEL: { - controlText: "DOWNLOAD DETAILS FROM E-INVOICES (EXCEL)", + surfaces: [ + { path: GSTR1_DETAIL_PATH, controlText: "DOWNLOAD DETAILS FROM E-INVOICES (EXCEL)" }, + ], expectedMime: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", }, }; + +/** + * The controls on this page that a descriptor's `controlText` names, and that can be clicked. + * + * A leaf clickable whose text carries the label -- not any element mentioning it. Whether a page + * offers an artifact is a question about controls, and answering it from page text lets decoy or + * non-actionable copy stand in for a button. It lives beside the descriptor so that the label and + * the search for it cannot drift apart. + */ +export function findPageArtifactControls( + documentRef: Document, + canonicalLabel: string, +): HTMLElement[] { + const normalisedLabel = normaliseText(canonicalLabel); + return getClickableElements(documentRef).filter( + (element) => + getClickableElements(element).length === 0 && + normaliseText(element.textContent || "").includes(normalisedLabel), + ); +} + +/** Whether the filed GSTR-1 detail route is itself offering the filed PDF, as captured live. */ +export function offersFiledGstr1DetailPdf(documentRef: Document): boolean { + const surface = GSTR1_PAGE_GENERATED_ARTIFACTS.PDF.surfaces.find( + (candidate) => candidate.path === GSTR1_DETAIL_PATH, + ); + return surface ? findPageArtifactControls(documentRef, surface.controlText).length > 0 : false; +} diff --git a/src/entrypoints/background.ts b/src/entrypoints/background.ts index 8d7c884d..45a6c568 100644 --- a/src/entrypoints/background.ts +++ b/src/entrypoints/background.ts @@ -76,6 +76,7 @@ export { rememberGstTabIfSupported, sendMessageToTabWithInjection, } from "../background/gst-tab-context"; +import { backgroundFailureFingerprint } from "../background/background-failure-fingerprint"; const OFFICIAL_URL = "https://pack.complyeaze.com"; @@ -159,14 +160,22 @@ export default defineBackground(() => { void handleMessage(message, sender) .then((response) => sendResponse(response)) - .catch(() => + .catch((error: unknown) => { + // The fingerprint, not the error. A handler that fails without saying why costs a build + // and a live run to locate, so the reason is named rather than discarded -- but an error + // raised by storage, scripting, or downloads can quote a URL, a path, or a response body, + // and "the console is not persisted" is not a reason to log those. The fingerprint carries + // what a reader needs and is established to be this bundle's own. + console.error( + `Pack background handler failed for ${backgroundMessageSource(message)}: ${backgroundFailureFingerprint(error)}`, + ); sendResponse({ ok: false, error: "BACKGROUND_MESSAGE_HANDLER_FAILED", - safeMessage: `Pack stopped while handling ${backgroundMessageSource(message)}. Try the action again.`, + safeMessage: `Pack stopped while handling ${backgroundMessageSource(message)}. Try the action again. (${backgroundFailureFingerprint(error)})`, safeSite: backgroundMessageHandlerSite(message), - } satisfies PackMessageResponse), - ); + } satisfies PackMessageResponse); + }); return true; }); }); diff --git a/tests/background/background-download-default.test.ts b/tests/background/background-download-default.test.ts index 4a7e8d54..34c8bfc4 100644 --- a/tests/background/background-download-default.test.ts +++ b/tests/background/background-download-default.test.ts @@ -827,15 +827,21 @@ describe("background filed returns download defaults", () => { const response = await sendBackgroundMessage({ type: "PACK_GET_LAST_MANIFEST" }); - expect(response).toEqual({ + expect(response).toMatchObject({ ok: false, error: "BACKGROUND_MESSAGE_HANDLER_FAILED", - safeMessage: "Pack stopped while handling the local manifest request. Try the action again.", safeSite: "background-message-handler:last-manifest", }); if (response.ok) throw new Error("expected a safe background failure"); + // The reply names where it failed and appends a fingerprint of the throw. The fingerprint is + // the error class and the innermost symbol from Pack's own bundle -- never the error message, + // which is what could carry a portal URL or a local path. + expect(response.safeMessage).toContain( + "Pack stopped while handling the local manifest request. Try the action again.", + ); expect(response.safeMessage).not.toContain("portal URL"); expect(response.safeMessage).not.toContain("local path"); + expect(response.safeMessage).not.toContain("must not escape"); }); it("persists and returns a terminal GSTR-2B mismatch summary to the popup", async () => { diff --git a/tests/background/background-failure-fingerprint.test.ts b/tests/background/background-failure-fingerprint.test.ts new file mode 100644 index 00000000..18e52264 --- /dev/null +++ b/tests/background/background-failure-fingerprint.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { backgroundFailureFingerprint } from "../../src/background/background-failure-fingerprint"; + +// Frames as the service worker actually produces them: every line of a background stack points at +// a file served from the extension's own origin. A frame pointing anywhere else did not come from +// this bundle, and is the case the fingerprint must not repeat. +const BUNDLE = "chrome-extension://abcdefghijklmnopabcdefghijklmnop/background.js"; + +describe("background failure fingerprint", () => { + it("names the error class and the innermost symbol", () => { + const error = new TypeError("boom"); + error.stack = `TypeError: boom\n at parseDurableSummary (${BUNDLE}:1:1)\n at next (${BUNDLE}:2:1)`; + expect(backgroundFailureFingerprint(error)).toBe("TypeError at parseDurableSummary"); + }); + + it("never repeats the error message", () => { + const error = new Error("GSTIN 00AAAAA0000A1Z0 rejected at https://portal.example/x"); + error.stack = `${error.message}\n at readLedger (${BUNDLE}:1:1)`; + const fingerprint = backgroundFailureFingerprint(error); + expect(fingerprint).toBe("Error at readLedger"); + }); + + it("cannot be smuggled through by a thrown non-Error", () => { + expect(backgroundFailureFingerprint("GSTIN 00AAAAA0000A1Z0")).toBe("NonError"); + expect(backgroundFailureFingerprint({ name: "x", stack: "at leak" })).toBe("NonError"); + }); + + it("takes no symbol from a frame outside this bundle", () => { + // The earlier filter deleted punctuation, so this frame came back as a plausible-looking + // symbol -- laundered into safety by removing exactly the characters that exposed it. Origin + // is what makes a symbol ours; a character class never could. + const error = new Error("x"); + error.stack = `Error: x\n at https://www.gst.gov.in/returns/auth/gstr1 (${BUNDLE}:1:1)`; + const fingerprint = backgroundFailureFingerprint(error); + expect(fingerprint).toBe("Error"); + expect(fingerprint).not.toContain("gst"); + }); + + it("takes no symbol from an anonymous frame", () => { + const error = new Error("x"); + error.stack = `Error: x\n at ${BUNDLE}:1:1`; + expect(backgroundFailureFingerprint(error)).toBe("Error"); + }); + + it("refuses a name that is not shaped like an error class", () => { + // Every error class in this bundle ends in `Error`, and so do the platform's. A name that does + // not degrades to `Error` rather than being edited until it passes. + const error = new Error("x"); + error.name = "GSTIN00AAAAA0000A1Z0"; + error.stack = `x\n at readLedger (${BUNDLE}:1:1)`; + const fingerprint = backgroundFailureFingerprint(error); + expect(fingerprint).toBe("Error at readLedger"); + expect(fingerprint).not.toContain("AAAAA"); + }); + + it("keeps the bundle's own error classes", () => { + const error = new Error("x"); + error.name = "XlsxSizeLimitError"; + error.stack = `x\n at writeWorkbook (${BUNDLE}:1:1)`; + expect(backgroundFailureFingerprint(error)).toBe("XlsxSizeLimitError at writeWorkbook"); + }); +}); diff --git a/tests/background/filed-returns-download-trigger-acquisition.test.ts b/tests/background/filed-returns-download-trigger-acquisition.test.ts index 7e1d712e..fa0abb33 100644 --- a/tests/background/filed-returns-download-trigger-acquisition.test.ts +++ b/tests/background/filed-returns-download-trigger-acquisition.test.ts @@ -1030,3 +1030,143 @@ function acquiredJson(): PackMessageResponse { }, }; } + +describe("filed GSTR-1 e-invoice Excel with no details to download", () => { + // The portal answers this request with an information dialog when the taxpayer reports no + // e-invoices. The content script has always recognised it and the ledger has always known how to + // record the artifact as unavailable -- but nothing sent the message between them, so the + // recognition never ran and the run stalled offering a retry that cannot succeed. + // + // The message contract was tested; that it is ever sent was not. This pins the send, on the + // path that matters: the control was armed and clicked, and the click produced no download. + function messagingDeps(postClick: PackMessageResponse) { + return vi.fn(async (_tabId: number, message: { type: string }) => + message.type === "PACK_CONTENT_INSPECT_FILED_RETURN_POST_CLICK_V3" + ? postClick + : ({ + ok: true, + artifact: { + ok: true, + state: "ready", + requestId: "synthetic-request", + safeSignals: ["target-period-verified"], + }, + } as PackMessageResponse), + ); + } + + const noDetailsStep = { + ok: true, + flowStep: { + connectorId: "gst", + scopeId: "filed-returns:gstr-1", + state: "blocked", + safeSignals: ["filed-gstr1-excel-no-details-available"], + safeMessage: "The GST Portal reported that no e-invoice details are available.", + }, + } as PackMessageResponse; + + function armClickWithoutDownload() { + captureMocks.acquirePageGeneratedArtifact.mockResolvedValueOnce({ + ok: false as const, + reason: "generation-timeout", + safeSignals: [] as string[], + } as never); + } + + it("asks the page why, and adopts the portal's no-details answer", async () => { + armClickWithoutDownload(); + const sendMessageToTabWithInjection = messagingDeps(noDetailsStep); + + const response = await triggerAndObserveFiledReturnDownload({ + activePeriod: "April", + artifactType: "EXCEL", + deps: { sendMessageToTabWithInjection, storageKeys: {} }, + scope: { financialYear: "2025-26", period: "April", returnType: "GSTR-1" }, + tabId: 17, + }); + + expect(sendMessageToTabWithInjection).toHaveBeenCalledWith( + 17, + expect.objectContaining({ type: "PACK_CONTENT_INSPECT_FILED_RETURN_POST_CLICK_V3" }), + ); + expect(response).toMatchObject({ + flowStep: { + safeSignals: expect.arrayContaining(["filed-gstr1-excel-no-details-available"]), + // Still blocked. This records an absence; it never turns a click into a download. + state: "blocked", + }, + }); + }); + + it("keeps the original failure when the page cannot be asked at all", async () => { + // This runs after an acquisition has already failed and can only refine that failure. A tab + // that has closed, navigated, or refuses injection means the failure cannot be refined -- not + // that a new one happened. Throwing here would replace a specific, actionable reason with the + // generic background error and lose the terminal summary with it. + armClickWithoutDownload(); + const sendMessageToTabWithInjection = vi.fn( + async (_tabId: number, message: { type: string }) => { + if (message.type === "PACK_CONTENT_INSPECT_FILED_RETURN_POST_CLICK_V3") { + throw new Error("Could not establish connection. Receiving end does not exist."); + } + return { + ok: true, + artifact: { + ok: true, + state: "ready", + requestId: "synthetic-request", + safeSignals: ["target-period-verified"], + }, + } as PackMessageResponse; + }, + ); + + const response = await triggerAndObserveFiledReturnDownload({ + activePeriod: "April", + artifactType: "EXCEL", + deps: { sendMessageToTabWithInjection, storageKeys: {} }, + scope: { financialYear: "2025-26", period: "April", returnType: "GSTR-1" }, + tabId: 17, + }); + + expect(response).toMatchObject({ + flowStep: { + safeSignals: expect.arrayContaining([ + "artifact-acquisition-failed", + "artifact-generation-timeout", + ]), + state: "blocked", + }, + }); + }); + + it("keeps the original failure when the page reports no recognised block", async () => { + armClickWithoutDownload(); + const sendMessageToTabWithInjection = messagingDeps({ + ok: true, + flowStep: { + connectorId: "gst", + scopeId: "filed-returns:gstr-1", + state: "candidate-not-found", + safeSignals: ["filed-return-post-click-blocked-state-not-found"], + safeMessage: "Pack did not find a recognized post-click portal block.", + }, + } as PackMessageResponse); + + const response = await triggerAndObserveFiledReturnDownload({ + activePeriod: "April", + artifactType: "EXCEL", + deps: { sendMessageToTabWithInjection, storageKeys: {} }, + scope: { financialYear: "2025-26", period: "April", returnType: "GSTR-1" }, + tabId: 17, + }); + + expect(response).toMatchObject({ + flowStep: { + safeSignals: expect.arrayContaining(["artifact-acquisition-failed"]), + state: "blocked", + }, + }); + }); +}); diff --git a/tests/background/gst-tab-selection.test.ts b/tests/background/gst-tab-selection.test.ts index 21b9a2f4..6773a846 100644 --- a/tests/background/gst-tab-selection.test.ts +++ b/tests/background/gst-tab-selection.test.ts @@ -449,14 +449,19 @@ describe("Pack GST tab selection", () => { listener?.({ type: "PACK_GET_CONTEXT" }, { id: "pack-test-extension" }, resolve); }); - expect(response).toEqual({ + expect(response).toMatchObject({ ok: false, error: "BACKGROUND_MESSAGE_HANDLER_FAILED", - safeMessage: - "Pack stopped while handling the current GST Portal state. Try the action again.", safeSite: "background-message-handler:gst-context", }); + const { safeMessage } = response as { safeMessage: string }; + expect(safeMessage).toContain( + "Pack stopped while handling the current GST Portal state. Try the action again.", + ); + // The rejected detail is the error's message, and it never reaches the reply. The appended + // fingerprint carries only the error class and a symbol from Pack's own bundle. expect(JSON.stringify(response)).not.toContain("sensitive portal detail"); + expect(safeMessage).not.toContain("must not be exposed"); }); it("infers a GSTR-2B observation from the active summary route instead of returning stale wrong-page", async () => { diff --git a/tests/connectors/artifact-source.test.ts b/tests/connectors/artifact-source.test.ts index d3c648e2..65e7da16 100644 --- a/tests/connectors/artifact-source.test.ts +++ b/tests/connectors/artifact-source.test.ts @@ -432,7 +432,7 @@ describe("GSTR-1 artifact acquisition", () => { ok: false, reason: "control-not-found", requestId: request.requestId, - safeSignals: ["target-period-verified"], + safeSignals: ["target-period-verified", "gstr1-control-label-unmatched"], }); expect(documentRef.querySelectorAll("[data-pack-artifact-request]").length).toBe(0); }); @@ -468,7 +468,7 @@ describe("GSTR-1 artifact acquisition", () => { ok: false, reason: "control-not-found", requestId: request.requestId, - safeSignals: ["target-period-verified"], + safeSignals: ["target-period-verified", "gstr1-control-label-unmatched"], }); expect(documentRef.querySelectorAll("[data-pack-artifact-request]").length).toBe(0); }); @@ -483,7 +483,7 @@ describe("GSTR-1 artifact acquisition", () => { ok: false, reason: "control-not-found", requestId: request.requestId, - safeSignals: ["target-period-verified"], + safeSignals: ["target-period-verified", "gstr1-control-label-ambiguous"], }); expect(documentRef.querySelectorAll("[data-pack-artifact-request]").length).toBe(0); }); diff --git a/tests/connectors/artifact-validation.test.ts b/tests/connectors/artifact-validation.test.ts index 93332c18..3ed6b862 100644 --- a/tests/connectors/artifact-validation.test.ts +++ b/tests/connectors/artifact-validation.test.ts @@ -1,8 +1,16 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { + MAX_ARTIFACT_BYTES, + describeJsonArtifactRejection, filedReturnsJsonDocumentContract, validateArtifactBytes, } from "../../src/connectors/gst/artifact-validation"; +import { ARTIFACT_ACQUISITION_DIAGNOSTIC_SIGNALS } from "../../src/connectors/gst/filed-returns-acquisition-diagnostics"; +import { + PORTAL_BLOB_SHIM_SUPPRESSION_METHODS, + RETURNS_DASHBOARD_ANCHOR_FAILURE_REASONS, + isDurableFiledReturnsSignal, +} from "../../src/connectors/gst/filed-returns-durable-signals"; const encoder = new TextEncoder(); @@ -94,3 +102,115 @@ describe("validateArtifactBytes", () => { }); }); }); + +describe("compact filed-return summary envelopes", () => { + // Captured live on 2026-09-10: a filed GSTR-1 period with nothing in it answers the summary + // preflight with a valid envelope well under 100 bytes. A size floor refused it before anything + // read it, and the run blocked on a return that was filed and downloadable. + const compactGstr1Envelope = encoder.encode(JSON.stringify({ data: { ret_period: "042025" } })); + + it("accepts a valid summary envelope that is smaller than a hundred bytes", () => { + expect(compactGstr1Envelope.byteLength).toBeLessThan(100); + expect(validateArtifactBytes(compactGstr1Envelope, "JSON", "042025", "GSTR-1")).toEqual({ + ok: true, + mimeType: "application/json", + }); + }); + + // What the floor was standing in for, each caught by the contract instead and caught better. + it("still refuses compact bodies the contract rejects", () => { + const cases: Array<[string, Uint8Array, string]> = [ + ["not JSON at all", encoder.encode("error"), "unexpected-content"], + ["no envelope", encoder.encode(JSON.stringify({ status: 1 })), "unexpected-content"], + [ + "no period field", + encoder.encode(JSON.stringify({ data: { other: "x" } })), + "unexpected-content", + ], + [ + "another period", + encoder.encode(JSON.stringify({ data: { ret_period: "052025" } })), + "target-period-mismatch", + ], + ]; + for (const [label, bytes, reason] of cases) { + expect(bytes.byteLength, label).toBeLessThan(100); + expect(validateArtifactBytes(bytes, "JSON", "042025", "GSTR-1"), label).toEqual({ + ok: false, + reason, + }); + } + }); + + it("still refuses an empty body", () => { + expect(validateArtifactBytes(new Uint8Array(), "JSON", "042025", "GSTR-1")).toEqual({ + ok: false, + reason: "empty", + }); + }); +}); + +describe("acquisition diagnostics are persistable", () => { + // The defect this pins: a diagnostic signal that is emitted but not registered rejects the + // entire durable signal array, so the run halts on non-canonical recovery metadata instead of + // recording the refusal the signal was there to explain. Emitting a new one without registering + // it is worse than not emitting it at all. + it("registers every acquisition diagnostic as a durable signal", () => { + const unregistered = ARTIFACT_ACQUISITION_DIAGNOSTIC_SIGNALS.filter( + (signal) => !isDurableFiledReturnsSignal(signal), + ); + expect(unregistered).toEqual([]); + }); + + it("emits only registered signals when a compact body fails the contract", () => { + const bodies = [ + encoder.encode(""), + encoder.encode(JSON.stringify({ status: 1 })), + encoder.encode(JSON.stringify({ data: { other: "x" } })), + encoder.encode(JSON.stringify({ data: { ret_period: "052025" } })), + new Uint8Array(), + ]; + for (const bytes of bodies) { + for (const signal of describeJsonArtifactRejection(bytes, "042025", "GSTR-1")) { + expect(isDurableFiledReturnsSignal(signal), signal).toBe(true); + } + } + }); +}); + +describe("template-built signals are persistable", () => { + // These are assembled from a prefix and a variable, so a scan for signal string literals never + // sees them. Three reached production unregistered, and each one rejected the entire durable + // array it travelled in -- halting the run on non-canonical recovery metadata instead of on the + // navigation problem the signal described. + it("registers every returns-dashboard anchor failure and blob-shim suppression", () => { + const built = [ + ...RETURNS_DASHBOARD_ANCHOR_FAILURE_REASONS.map( + (reason) => `returns-dashboard-anchor-${reason}`, + ), + ...PORTAL_BLOB_SHIM_SUPPRESSION_METHODS.map( + (method) => `portal-blob-shim-suppressed-via-${method}`, + ), + ]; + expect(built.filter((signal) => !isDurableFiledReturnsSignal(signal))).toEqual([]); + }); + + it("names an oversized body without decoding or parsing it", () => { + // The cap is a processing bound, not only a verdict. Decoding and parsing a body already known + // to be too large spends exactly the work the cap refuses, on the path where the input is known + // to be unreasonable. + const oversized = new Uint8Array(MAX_ARTIFACT_BYTES + 1); + const decode = vi.spyOn(TextDecoder.prototype, "decode"); + const parse = vi.spyOn(JSON, "parse"); + try { + expect(describeJsonArtifactRejection(oversized, "052025", "GSTR-1")).toEqual([ + "json-body-oversized", + ]); + expect(decode).not.toHaveBeenCalled(); + expect(parse).not.toHaveBeenCalled(); + } finally { + decode.mockRestore(); + parse.mockRestore(); + } + }); +}); diff --git a/tests/connectors/filed-returns-durable-signals.test.ts b/tests/connectors/filed-returns-durable-signals.test.ts index f84741ae..f777277e 100644 --- a/tests/connectors/filed-returns-durable-signals.test.ts +++ b/tests/connectors/filed-returns-durable-signals.test.ts @@ -1,5 +1,5 @@ import { FILED_RETURNS_WORKBOOK_ABSENCE_OUTCOMES } from "../../src/connectors/gst/offscreen-blob-url"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { durableFiledReturnsSignalRejectionReason, isDurableFiledReturnsSignal, @@ -537,4 +537,27 @@ describe("filed-return durable signal contract", () => { expect(parseDurableFiledReturnsSignals([signals[0], signal])).toBeNull(); } }); + + it("never repeats a rejected token to a console sink", () => { + // This boundary exists because it cannot know what it has been handed -- a legacy or malformed + // entry read back from storage is exactly the input it refuses. Naming that entry in a log + // undoes the refusal it just made. + const sinks = [ + vi.spyOn(console, "warn").mockImplementation(() => undefined), + vi.spyOn(console, "error").mockImplementation(() => undefined), + vi.spyOn(console, "log").mockImplementation(() => undefined), + ]; + try { + const rejected = parseDurableFiledReturnsSignals([ + "gstr2b-for-00AAAAA0000A1Z0-at-https://portal.example/x", + ]); + + expect(rejected).toBeNull(); + const written = sinks.flatMap((sink) => sink.mock.calls.flat()).join(" "); + expect(written).not.toContain("00AAAAA0000A1Z0"); + expect(written).not.toContain("portal.example"); + } finally { + for (const sink of sinks) sink.mockRestore(); + } + }); }); diff --git a/tests/connectors/filed-returns-flow-gstr1-acquisition.test.ts b/tests/connectors/filed-returns-flow-gstr1-acquisition.test.ts index 0df300c4..7eeeee5d 100644 --- a/tests/connectors/filed-returns-flow-gstr1-acquisition.test.ts +++ b/tests/connectors/filed-returns-flow-gstr1-acquisition.test.ts @@ -468,6 +468,71 @@ describe("filed returns flow — GSTR-1 artifact acquisition", () => { expect(back).not.toHaveBeenCalled(); }); + it("still uses View Summary when the filed-PDF label is only text", async () => { + // The label appearing on the page is not the page offering the download. Skipping the real + // View Summary control because some non-actionable copy carries the words strands a target + // whose PDF was reachable: acquisition then finds no control to click and blocks it. + const documentRef = createGstDocument( + ` +
+

GSTR-1

+
Status - Filed
+
Financial Year - 2025-26
+
Return Period - April
+

Use DOWNLOAD FILED (PDF) once the summary has been generated.

+ +
+ `, + "https://return.gst.gov.in/returns/auth/gstr1", + ); + makeLayoutVisible(documentRef); + let summaryClicked = 0; + documentRef.querySelector("[data-summary]")?.addEventListener("click", () => { + summaryClicked += 1; + }); + + const result = await runFiledReturnsDownloadStep(documentRef, { + artifactType: "PDF", + financialYear: "2025-26", + period: "April", + returnType: "GSTR-1", + }); + + expect(summaryClicked).toBe(1); + expect(result.safeSignals).toContain("filed-gstr1-summary-view-clicked"); + }); + + it("skips View Summary when the detail route offers a filed-PDF control to click", async () => { + const documentRef = createGstDocument( + ` +
+

GSTR-1

+
Status - Filed
+
Financial Year - 2025-26
+
Return Period - April
+ + +
+ `, + "https://return.gst.gov.in/returns/auth/gstr1", + ); + makeLayoutVisible(documentRef); + let summaryClicked = 0; + documentRef.querySelector("[data-summary]")?.addEventListener("click", () => { + summaryClicked += 1; + }); + + const result = await runFiledReturnsDownloadStep(documentRef, { + artifactType: "PDF", + financialYear: "2025-26", + period: "April", + returnType: "GSTR-1", + }); + + expect(summaryClicked).toBe(0); + expect(result.safeSignals).not.toContain("filed-gstr1-summary-view-clicked"); + }); + it("does not leave a filed GSTR-1 summary when its visible scope is incomplete", async () => { const documentRef = createGstDocument( ` @@ -546,6 +611,69 @@ describe("filed returns flow — GSTR-1 artifact acquisition", () => { expect(excelClicked).toBe(1); }); + it("refuses the no-details dialog for a target the visible page is not about", async () => { + // The detail route does not change per period and a dialog outlives the target that raised it. + // Answering from body text alone lets one period's refusal mark another period's artifact + // unavailable, and a composite or full-year run then carries on having omitted an artifact the + // portal never declined for it. + const documentRef = createDocument(` +
+ +

GSTR-1

+
Status - Filed
+
Financial Year - 2025-26
+
Return Period - May
+
+

Information

+

No details available for download (This is relevant only if you have reported e-invoices).

+ +
+
+ `); + makeLayoutVisible(documentRef); + + const result = detectPostClickBlockedState( + documentRef, + { + actionId: "test-action", + artifactType: "EXCEL" as const, + financialYear: "2025-26", + period: "June", + returnType: "GSTR-1" as const, + }, + [], + ); + + expect(result).toBeNull(); + }); + + it("refuses the no-details dialog when the detail header cannot be read", async () => { + // Fail closed: a page that does not name its period is "could not determine", never "matches". + const documentRef = createDocument(` +
+
+

Information

+

No details available for download (This is relevant only if you have reported e-invoices).

+
+
+ `); + makeLayoutVisible(documentRef); + + const result = detectPostClickBlockedState( + documentRef, + { + actionId: "test-action", + artifactType: "EXCEL" as const, + financialYear: "2025-26", + period: "May", + returnType: "GSTR-1" as const, + }, + [], + ); + + expect(result).toBeNull(); + }); + it("returns from a mismatched detail page before running the requested exact period", async () => { const documentRef = createDocument(`