132 lines · 5.4 KB · TypeScript
RawDownload
// Cloud (browser): a failed API request is reported as a titled error.//// The console reports handled UI failures to the crash reporter, and every// producer of one starts from an Effect `Cause` — a plain object with no name,// message or stack. Handed that directly, the reporter has nothing to title// the report with, so it files a message-less one and groups it on the// reporting frame: unrelated frontend failures all land in a single nameless// bucket that says only which function did the reporting, never what broke.//// The report is the product surface here, so this scenario reads it the way// the outside world does. The browser SDK is configured to POST its envelopes// same-origin (`tunnel`), so the suite intercepts that request and asserts on// the payload the page actually tried to send.import { expect } from "@effect/vitest";import { Effect } from "effect"; import { scenario } from "../src/scenario";import { Browser, Target } from "../src/services";import { revisit, visit } from "../src/surfaces/browser"; type ReportedException = {  readonly type?: string;  readonly value?: string;  // The reporter marks an exception `synthetic` when it was handed something  // that was not a real error and had to invent a stack for it — the stack of  // whatever frame did the reporting.  readonly mechanism?: { readonly synthetic?: boolean };}; type ReportedEvent = {  readonly tags?: Record<string, string>;  readonly exception?: { readonly values?: ReadonlyArray<ReportedException> };}; /** * An envelope is newline-delimited JSON — a header, then `{type}` / payload * pairs. Only the error payloads matter here and they are the ones carrying * `exception`, so pick those out rather than modelling the whole format. */const errorEventsIn = (body: string): ReadonlyArray<ReportedEvent> =>  body    .split("\n")    .filter((line) => line.trim().startsWith("{"))    .flatMap((line) => {      try {        const parsed = JSON.parse(line) as ReportedEvent;        return parsed.exception ? [parsed] : [];      } catch {        return [];      }    }); scenario(  "Frontend errors · a failed API request is reported with a real message",  { timeout: 120_000 },  Effect.gen(function* () {    const browser = yield* Browser;    const target = yield* Target;    const identity = yield* target.newIdentity();     yield* browser.session(identity, async ({ page, step }) => {      const reports: Array<ReportedEvent> = [];      await page.route("**/api/sentry-tunnel*", async (route) => {        reports.push(...errorEventsIn(route.request().postData() ?? ""));        await route.fulfill({ status: 200, body: "" });      });       // Everything the UI reports about a request it made itself.      const reportedFailures = (): ReadonlyArray<ReportedException> =>        reports          .filter((event) => event.tags?.["executor.ui.surface"] === "api_client")          .flatMap((event) => event.exception?.values ?? []);       await step("Open the integrations console", async () => {        await visit(page, "/integrations");        await page.getByRole("button", { name: "Connect" }).first().waitFor();      });       let faulted = 0;      await step("Reload it with the integrations API failing", async () => {        await page.route("**/api/integrations", async (route) => {          if (route.request().method() !== "GET") {            await route.continue();            return;          }          faulted += 1;          await route.fulfill({            status: 500,            contentType: "text/plain",            body: "upstream exploded",          });        });        await revisit(page);      });       expect(faulted, "the integrations request really did fail").toBeGreaterThan(0);       // The report must name the failure. Reports are sent as the page      // notices failures, so wait for one rather than sleeping.      await expect        .poll(() => reportedFailures().map((failure) => failure.value ?? ""), {          message: "the reported failure says which request failed, and how",          timeout: 20_000,        })        .toContainEqual(expect.stringMatching(/500 .*\/api\/integrations/));       for (const failure of reportedFailures()) {        // A report with no message is the bug: it cannot be titled, so it        // groups on the reporting frame and swallows every other failure.        expect(failure.value ?? "", "every report carries a message").not.toBe("");        expect(failure.type ?? "", "every report carries an error name").not.toBe("");        // What a reporter falls back to when it is handed something that is        // not an error at all — the shape every message-less report had.        expect(failure.value ?? "", "no report is a bag of keys").not.toMatch(          /captured as exception with keys/,        );        // The other half of the bug, and the half a readable message can hide:        // handed a non-error, the reporter still has no stack of its own to        // group on and invents one from the reporting frame, so unrelated        // failures keep merging into a single bucket. Only a real error clears        // this flag.        expect(          failure.mechanism?.synthetic ?? false,          "the report carries the failure's own stack, not the reporter's frame",        ).toBe(false);      }       await page.unroute("**/api/integrations");      await page.unroute("**/api/sentry-tunnel*");    });  }),);