From 7a5c17b62e2647c3b1fb41a00798ed16c547eb66 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 28 Jun 2026 06:48:33 +0000 Subject: [PATCH 1/2] refactor(events): validate the ts field and document the events response shape Tighten the AppEvent parsing types on the event log page. - Add JSDoc to parseEventsResponse documenting the { items } or { events } response shapes. - Validate ts against number | string | null union instead of casting; coerce unexpected types to null. - Handle missing type field by coercing to empty string. - Update docs/api-integration.md to reflect the response contract. - Extend tests to cover the new validation and response shapes. Co-authored-by: gloskull <189399494+gloskull@users.noreply.github.com> --- docs/api-integration.md | 9 +++- src/app/events/page.test.tsx | 90 ++++++++++++++++++++++++++++++++++++ src/app/events/page.tsx | 20 +++++++- 3 files changed, 116 insertions(+), 3 deletions(-) diff --git a/docs/api-integration.md b/docs/api-integration.md index 99e160b..604691a 100644 --- a/docs/api-integration.md +++ b/docs/api-integration.md @@ -139,10 +139,15 @@ type Webhook = { id: string; url: string; events: string[]; createdAt: number }; | Method & path | Type | Request body | Response shape | Source | | --- | --- | --- | --- | --- | -| `GET /api/v1/events?limit={limit}` | Read | — | `{ items: AppEvent[] }` | `events/page.tsx` | +| `GET /api/v1/events?limit={limit}` | Read | — | `{ items: AppEvent[] } \| { events: AppEvent[] }` | `events/page.tsx` | ```ts -type AppEvent = { id: string; ts: number; type: string; payload: Record }; +type AppEvent = { + id: string; + ts: number | string | null; + type: string; + payload: Record; +}; ``` ## Changelog diff --git a/src/app/events/page.test.tsx b/src/app/events/page.test.tsx index ba725e5..2ce3ccb 100644 --- a/src/app/events/page.test.tsx +++ b/src/app/events/page.test.tsx @@ -202,6 +202,96 @@ describe("EventsPage", () => { }); }); + it("throws when an item in the array is not an object", async () => { + const fetchMock = jest.fn(async () => jsonResponse({ items: [null] })); + globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + + render(); + + await waitFor(() => { + expect(screen.getByRole("alert")).toHaveTextContent( + /malformed events payload/i + ); + }); + }); + + it("handles the alternative { events: [...] } response shape", async () => { + const fetchMock = jest.fn(async () => + jsonResponse({ events: FIRST_BATCH.slice(0, 1) }) + ); + globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + + render(); + + await waitFor(() => { + expect(screen.getByText("payment.created")).toBeInTheDocument(); + }); + }); + + it("coerces non-numeric/non-string/non-null ts to null", async () => { + const weirdEvents = [ + { + id: "evt-weird", + ts: { some: "object" }, // should be coerced to null + type: "weird.event", + payload: {}, + }, + ]; + + const fetchMock = jest.fn(async () => jsonResponse({ items: weirdEvents })); + globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + + render(); + + await waitFor(() => { + expect(screen.getByText("weird.event")).toBeInTheDocument(); + }); + + // When ts is null, safeFormatTimestamp returns \u2014 (em-dash) + // and TimeAgo is not rendered. + expect(screen.getByText("\u2014")).toBeInTheDocument(); + }); + + it("throws when neither items nor events is an array", async () => { + const fetchMock = jest.fn(async () => + jsonResponse({ items: "not-an-array", events: "also-not-an-array" }) + ); + globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + + render(); + + await waitFor(() => { + expect(screen.getByRole("alert")).toHaveTextContent( + /malformed events payload/i + ); + }); + }); + + it("handles missing type field by coercing to empty string", async () => { + const missingTypeEvents = [ + { + id: "evt-no-type", + ts: BASE_TIME.getTime(), + // type is missing + payload: { test: "missing-type" }, + }, + ]; + + const fetchMock = jest.fn(async () => jsonResponse({ items: missingTypeEvents })); + globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + + render(); + + await waitFor(() => { + // Verify the payload is rendered, confirming the item was processed + expect(screen.getByText(/"test": "missing-type"/)).toBeInTheDocument(); + }); + + // The type span should be empty but present + const spans = screen.getAllByRole("listitem")[0].querySelectorAll("span"); + expect(spans[0]).toHaveTextContent(""); + }); + it("caps rendered events at 50 and shows 'showing N of M' note when list exceeds cap", async () => { const largeList = Array.from({ length: 75 }, (_, i) => ({ id: `evt-${i}`, diff --git a/src/app/events/page.tsx b/src/app/events/page.tsx index 0d13cc0..7e52ec2 100644 --- a/src/app/events/page.tsx +++ b/src/app/events/page.tsx @@ -27,6 +27,18 @@ function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object"; } +/** + * Parse a loosely-typed EventsResponse into a list of AppEvents. + * + * Supports two response shapes: + * - { items: AppEvent[] } + * - { events: AppEvent[] } + * + * Each event field is validated or coerced to ensure it matches the AppEvent type. + * Specifically, the 'ts' field is coerced to null if it's not a number, string, or null. + * + * @throws {Error} if the payload is not an object or does not contain an array of items/events. + */ function parseEventsResponse(body: EventsResponse): AppEvent[] { const items = Array.isArray(body.items) ? body.items @@ -43,9 +55,15 @@ function parseEventsResponse(body: EventsResponse): AppEvent[] { throw new Error("Malformed events payload"); } + const ts = item.ts; + const validatedTs: AppEvent["ts"] = + typeof ts === "number" || typeof ts === "string" || ts === null + ? ts + : null; + return { id: typeof item.id === "string" ? item.id : String(item.id ?? index), - ts: item.ts as AppEvent["ts"], + ts: validatedTs, type: typeof item.type === "string" ? item.type : String(item.type ?? ""), payload: "payload" in item ? item.payload : undefined, }; From b2e49c0951f2cd8afc33fb94f63f61e369d2a255 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 28 Jun 2026 07:28:46 +0000 Subject: [PATCH 2/2] Fix CI build and test failures - Update `parseNonNegativeInt` in `src/lib/validateNumber.ts` to reject empty strings and `-0`. - Refactor `UsagePage` in `src/app/usage/page.tsx` to separate field validation errors from general status errors, preventing duplicate `role="alert"` elements. - Improve semantic structure and accessibility of `KeyValueGrid.tsx` by using `Fragment` and explicit `aria-label` for Testing Library compatibility. - Fix `ConfirmDialog.test.tsx` to correctly target the dialog element for backdrop dismissal tests. - All 52 test suites passing. Co-authored-by: gloskull <189399494+gloskull@users.noreply.github.com> --- src/app/usage/page.tsx | 12 +++++++++--- src/components/KeyValueGrid.tsx | 10 +++++----- src/components/__tests__/ConfirmDialog.test.tsx | 6 ++---- src/lib/validateNumber.ts | 8 +++++++- 4 files changed, 23 insertions(+), 13 deletions(-) diff --git a/src/app/usage/page.tsx b/src/app/usage/page.tsx index 87b27dd..9a74e0b 100644 --- a/src/app/usage/page.tsx +++ b/src/app/usage/page.tsx @@ -50,6 +50,7 @@ export default function UsagePage() { const [agent, setAgent] = useState(""); const [serviceId, setServiceId] = useState(""); const [requests, setRequests] = useState(""); + const [requestsError, setRequestsError] = useState(); const [status, setStatus] = useState({ kind: "idle" }); const [queryAgent, setQueryAgent] = useState(""); const [queryService, setQueryService] = useState(""); @@ -60,10 +61,12 @@ export default function UsagePage() { const onRecord = async (event: FormEvent) => { event.preventDefault(); if (isRecording) return; + setRequestsError(undefined); + const parsed = parsePositiveInt(requests); if (!parsed.ok) { // Surface the validation message through the field error. - setStatus({ kind: "error", message: parsed.message }); + setRequestsError(parsed.message); return; } @@ -142,8 +145,11 @@ export default function UsagePage() { inputMode="numeric" required value={requests} - onChange={(e) => setRequests(e.target.value)} - error={status.kind === "error" ? status.message : undefined} + onChange={(e) => { + setRequests(e.target.value); + setRequestsError(undefined); + }} + error={requestsError} />