From be8cbe60722bb8d010e934d00d23503973135728 Mon Sep 17 00:00:00 2001 From: os-support-ai Date: Wed, 19 Aug 2026 00:13:41 +0000 Subject: [PATCH] test(plugin-report): serve the dimension-metadata probe from a double, not the network (#5225) `DatasetReportRenderer.test.tsx` made 8 real TCP connections per run to `http://localhost:3000` (16 ECONNREFUSED lines - two per attempt), while `packages/plugin-report/src` contains no `fetch(` and no literal `3000`. Traced with a `net.Socket.prototype.connect` probe, the escape is: DatasetReportRenderer (tabular / matrix / chart branches) -> useDatasetDimensionLabels re-export of @object-ui/react's hook -> useDatasetDimensionMeta packages/react/src/hooks/useDatasetDimensionLabels.ts:144 -> `const doFetch = apiFetch ?? fetch` -> loadDimensionFieldMeta packages/core/src/utils/chart-series.ts:1178 GET /api/v1/meta/object/:object With no `SchemaRendererProvider` in the tree the hook has no host `apiFetch` and degrades to the GLOBAL fetch - deliberate and pinned (objectui#4121). Under happy-dom that global fetch is a real HTTP client, and vitest's happy-dom environment defaults the document URL to `http://localhost:3000`, so the relative `/api/v1/...` resolved to a live request. Why the test's own mock never intercepted it: the metadata read is a SECOND data channel. `dataSource.queryDataset` serves the report ROWS; the dimension metadata never goes through `dataSource`. Only the 8 cases whose mocked result carries `object` reach it - that field is what the hook keys on. The read is best-effort (`catch {}`), which is why 42 tests stayed green. Answer it from a RECORDING double, the shape objectui#3339 / #4106 settled on and this package's own `DatasetReportRenderer.localSelectI18n.test.tsx` already uses. Not a blanket network stub: it records every URL, `afterEach` fails on any non-metadata route, and the probe's previously-unasserted shape is now pinned by three new tests. The default document declares no option-bearing fields, so `deriveDimensionLabelMaps` resolves nothing and the rows are returned by identity - byte-identical to what the failing request produced. All 42 pre-existing tests still pass unchanged. The double unmounts before restoring the real `fetch`: vitest runs `afterEach` in reverse registration order, so the setup file's RTL cleanup runs after this file's hook, and unstubbing first left the tree mounted with the real global back in place. Measured - one run in six leaked a single attempt that way. Product code is untouched. Same root cause as #4106 (whose fix landed only in `packages/plugin-charts`, before #4389 extracted the call site into the shared hook), not a fourth distinct one. `packages/plugin-dashboard`'s `DatasetWidget.test.tsx` still escapes through the same hook - filed as #5280, out of this card's scope. Fixes #5225 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RV6yuVCxymHYE16PL9vQkE --- .changeset/report-metadata-probe-double.md | 10 + .../__tests__/DatasetReportRenderer.test.tsx | 173 +++++++++++++++++- 2 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 .changeset/report-metadata-probe-double.md diff --git a/.changeset/report-metadata-probe-double.md b/.changeset/report-metadata-probe-double.md new file mode 100644 index 0000000000..7847995a68 --- /dev/null +++ b/.changeset/report-metadata-probe-double.md @@ -0,0 +1,10 @@ +--- +--- + +Test-only change to `@object-ui/plugin-report`'s `DatasetReportRenderer` suite +(objectui#5225): the dimension-metadata probe — `useDatasetDimensionMeta`'s +`GET /api/v1/meta/object/:object`, which falls back to the global `fetch` when no +`SchemaRendererProvider` is mounted — is now answered by a recording test double +instead of escaping to the real network on `127.0.0.1:3000`, and its +previously-unasserted request shape is pinned. Product code is untouched and no +published behaviour changes. diff --git a/packages/plugin-report/src/__tests__/DatasetReportRenderer.test.tsx b/packages/plugin-report/src/__tests__/DatasetReportRenderer.test.tsx index b52063e6a8..4e522c49e8 100644 --- a/packages/plugin-report/src/__tests__/DatasetReportRenderer.test.tsx +++ b/packages/plugin-report/src/__tests__/DatasetReportRenderer.test.tsx @@ -15,8 +15,8 @@ * - ordering (framework#3916): `report.order` / `blocks[].order` lowered onto * the selection, scoped per sub-selection, and part of the refetch key */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; import '@testing-library/jest-dom'; import { I18nProvider } from '@object-ui/i18n'; import { DatasetReportRenderer, isDatasetReport } from '../DatasetReportRenderer'; @@ -54,6 +54,99 @@ function makeSource(byDataset: Record) { }; } +/* ──────────────────────────────────────────────────────────────────────────── + * objectui#5225 — the metadata probe this file used to send to the REAL network + * + * Nothing in `packages/plugin-report/src` calls `fetch`, yet this file made 8 + * live TCP connections per run to `http://localhost:3000` (16 stderr lines — + * two per attempt). The path, traced with a `net.Socket.prototype.connect` + * probe: + * + * DatasetReportRenderer (tabular / matrix / chart branches) + * → useDatasetDimensionLabels (re-export of @object-ui/react's hook) + * → useDatasetDimensionMeta packages/react/src/hooks/useDatasetDimensionLabels.ts + * → `const doFetch = apiFetch ?? fetch` ← the escape + * → loadDimensionFieldMeta packages/core/src/utils/chart-series.ts + * GET /api/v1/meta/object/ + * + * The hook reads the host's AUTHENTICATED `apiFetch` off `SchemaRendererContext` + * and, with no `SchemaRendererProvider` in the tree, degrades to the GLOBAL + * `fetch` on purpose (objectui#4121 property 1 — a standalone embed must keep + * rendering, not crash). Under happy-dom that global `fetch` is a real HTTP + * client, and vitest's happy-dom environment defaults the document URL to + * `http://localhost:3000`, so the relative `/api/v1/...` resolved to a live + * request to whatever happens to own port 3000 in a shared container. + * + * Why the tests' own mock never intercepted it: this read is a SECOND data + * channel. `dataSource.queryDataset` (the prop double below) serves the report + * ROWS; the dimension-label metadata never goes through `dataSource` at all. + * Only the ~8 cases whose mock result carries `object` reach it — that field is + * what the hook keys on. + * + * The read is best-effort (`catch {}` leaves rows exactly as the server sent + * them), which is why 42 tests stayed green while the request always failed. + * + * Answer it from a RECORDING double, the shape objectui#3339 / #4106 settled on + * and this package's own `DatasetReportRenderer.localSelectI18n.test.tsx` + * already uses. It is deliberately NOT a blanket network stub: it records every + * URL it is handed, `afterEach` fails on any URL that is not the metadata route, + * and the probe's shape — previously asserted by nobody — is pinned below. + * + * The default document declares no option-bearing fields, so + * `deriveDimensionLabelMaps` resolves nothing and `relabelDimensions` returns + * the rows by identity: byte-identical to what the failing request produced. + * No pre-existing assertion changes meaning. + * ──────────────────────────────────────────────────────────────────────────── */ + +const META_OBJECT_ROUTE = /^\/api\/v1\/meta\/object\/(.+)$/; + +type MetaFieldDoc = { type?: string; options?: Array<{ value: string; label?: string }> }; +type MetaObjectDoc = { name: string; fields?: Record }; + +let metaCalls: Array<{ url: string; init?: { headers?: Record; credentials?: string } }> = []; +let metaDocs: Record = {}; + +/** Serve `/api/v1/meta/object/` from `metaDocs`; record everything. */ +function installMetaObjectDouble() { + metaCalls = []; + metaDocs = {}; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: unknown, init?: unknown) => { + const url = String( + input && typeof input === 'object' && 'url' in input ? (input as { url: unknown }).url : input, + ); + metaCalls.push({ url, init: init as { headers?: Record; credentials?: string } }); + const m = META_OBJECT_ROUTE.exec(url); + if (!m) return { ok: false, status: 404, json: async () => ({}) }; + const name = decodeURIComponent(m[1]); + const doc = metaDocs[name] ?? { name, fields: {} }; + return { ok: true, status: 200, json: async () => ({ item: doc }) }; + }), + ); +} + +/** The object names this render probed, in request order. */ +const probedObjects = () => + metaCalls.map((c) => META_OBJECT_ROUTE.exec(c.url)?.[1]).filter((n): n is string => Boolean(n)).map(decodeURIComponent); + +beforeEach(() => { + installMetaObjectDouble(); +}); + +afterEach(() => { + // The double is a router, not a sink: an escape to any OTHER endpoint fails + // here instead of vanishing into the hook's best-effort `catch`. + expect(metaCalls.filter((c) => !META_OBJECT_ROUTE.test(c.url)).map((c) => c.url)).toEqual([]); + // Unmount BEFORE restoring the real `fetch`. Vitest runs `afterEach` hooks in + // reverse registration order, so the setup file's RTL cleanup runs after this + // one: unstubbing first leaves the tree mounted with the real global back in + // place, and a metadata effect that settles in that window escapes again. + // Measured — one run in six leaked a single attempt that way. + cleanup(); + vi.unstubAllGlobals(); +}); + describe('isDatasetReport', () => { it('matches a report bound to a dataset', () => { expect(isDatasetReport({ name: 'r', dataset: 'task_metrics', values: ['c'] })).toBe(true); @@ -1007,3 +1100,79 @@ describe('DatasetReportRenderer — matrix bucket encoding (objectstack#5665)', expect(screen.getByTestId('matrix-grand-total')).toHaveTextContent('3'); }); }); + +/** + * objectui#5225 — the dimension-metadata probe's own shape. + * + * Before this file answered the probe from the double above, the request went + * to the real network and always failed, so nothing here had ever asserted it + * and its SUCCESS path had never once executed in this suite. These three pins + * state what the renderer's own wiring asks for — which object, how many times, + * and that a resolved document actually reaches the rendered cells — so a + * future widening of the read shows up as a red test rather than as a silent + * extra request to whatever owns port 3000. + * + * (The label-resolution RULES are `@object-ui/core`'s and are unit-tested + * there; the locale half is pinned in `DatasetReportRenderer.localSelectI18n.test.tsx`. + * What is new here is only this renderer's request wiring.) + */ +describe('DatasetReportRenderer — dimension metadata probe (objectui#5225)', () => { + it('probes exactly the result-declared object, once, over the metadata route', async () => { + const src = makeSource({ + task_metrics: { + rows: [{ status: 'Backlog', est_hours: 30 }], + object: 'task', + dimensionFields: { status: 'status' }, + }, + }); + render( + , + ); + await waitFor(() => expect(screen.getByText('Backlog')).toBeInTheDocument()); + await waitFor(() => expect(probedObjects()).toEqual(['task'])); + // The rows come from `dataSource`; the metadata is a separate channel that + // never touches it, which is why the prop double could not intercept this. + expect(metaCalls.map((c) => c.url)).toEqual(['/api/v1/meta/object/task']); + expect(metaCalls[0].init?.headers).toMatchObject({ accept: 'application/json' }); + }); + + it('issues no metadata probe when the result declares no object', async () => { + const src = makeSource({ task_metrics: [{ status: 'Backlog', est_hours: 30 }] }); + render( + , + ); + await waitFor(() => expect(screen.getByText('Backlog')).toBeInTheDocument()); + expect(metaCalls).toEqual([]); + }); + + it('applies the probed document: a select dimension renders its option label', async () => { + metaDocs.task = { + name: 'task', + fields: { status: { type: 'select', options: [{ value: 'in_progress', label: 'In Progress' }] } }, + }; + const src = makeSource({ + task_metrics: { + // The server sent the STORED value here; the probe is what turns it + // into the authored label — the path that never ran while the request + // was failing. + rows: [{ status: 'in_progress', est_hours: 30 }], + object: 'task', + dimensionFields: { status: 'status' }, + }, + }); + render( + , + ); + await waitFor(() => expect(screen.getByText('In Progress')).toBeInTheDocument()); + expect(screen.queryByText('in_progress')).not.toBeInTheDocument(); + }); +});