Skip to content

test(plugin-detail): serve the record-level explain probe from a double, not the network - #4105

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-3339-plugin-detail-msw-escape
Aug 10, 2026
Merged

test(plugin-detail): serve the record-level explain probe from a double, not the network#4105
yinlianghui merged 1 commit into
mainfrom
claude/issue-3339-plugin-detail-msw-escape

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#3339

Test-only. Product code is untouched.

Premise: reproduced, still valid

The card was filed @ a41568462 and explicitly asked whether the escape had moved or been fixed. It has not. On origin/main @ c29ceffb8, the card's own repro command still produces the noise:

pnpm exec vitest run packages/plugin-detail \
packages/fields/src/__tests__/field-carrier-sdui.test.tsx \
packages/components/src/renderers/form/__tests__/form-field-carrier.test.tsx \
--maxWorkers=2
Test Files 68 passed (68)
Tests 535 passed (535)
$ grep -c ECONNREFUSED stderr => 72

The bisect: one call site, two files, 100% of the noise

Rather than bisect file-by-file, I instrumented fetch / XMLHttpRequest / EventSource in the root setup files and attributed every escape to its test file. Result — the escapes are not scattered, they are a single call site:

escaping filelive requests
packages/plugin-detail/src/__tests__/DetailView.test.tsx28
packages/plugin-detail/src/__tests__/DetailView.invalidation.test.tsx8
total36

All 36 are POST /api/v1/security/explain; 36 requests x 2 = the 72 ECONNREFUSED lines exactly, so these two files account for all of it. The two fields/components files named in the repro command escape nothing — they are observation context, as the card said.

Every escape comes from packages/plugin-detail/src/useRecordEditable.ts:74:

constdoFetch=apiFetch??fetch;constres=awaitdoFetch('/api/v1/security/explain',{ ... });

DetailView mounts this hook twice (update + delete) for any schema carrying objectName + resourceId. With no SchemaRendererProvider in the tree there is no host apiFetch, so it falls back to the global fetch — which under happy-dom is a real request to the default origin http://localhost:3000. The hook swallows the rejection in order to fail open, which is exactly why the suite stayed green while stderr filled up: fire-and-forget, as the card described.

Why the fix is test-side

The global-fetch fallback is deliberate product behaviour, documented in the hook's header ("a standalone detail:view embed has no host fetch and must still degrade to the global one") and pinned by an existing test — falls back to the global fetch in a standalone embed (no provider). Changing it to silence test noise would be changing the product to suit the tests, so the fix injects the dependency at the two call sites instead.

Route chosen: injected test double, not MSW. The card allowed either. MSW is a declared devDependency of plugin-form / plugin-grid but is imported nowhere in this repo — there is no setupServer harness at all, so the MSW route would mean standing up the repo's first MSW test infrastructure inside a hygiene fix. The double also matches what useRecordEditable.test.tsx already does one level down.

visible: true reproduces the old observable behaviour exactly — a failed request already failed open — so no existing assertion changes meaning.

No global error swallowing, per the card's explicit ruling and the triage comment that reinforced it. The double is scoped to the two files, answers only the explain endpoint, and records every URL it is handed, so an escape to some other endpoint becomes a failing assertion rather than silence.

Keeping the assertions meaningful

The escaped request was asserted by nobody, so its shape was invisible. Per the card, it is now pinned — and this is genuinely new coverage, being DetailView's own wiring rather than the hook's: that the recordId comes from resourceId, and that both the update and the delete CTA are gated, one probe each. useRecordEditable.test.tsx cannot see any of that.

Verification

Acceptance bar — the card's full repro command, stderr grepped:

Test Files 68 passed (68)
Tests 536 passed (536) # 535 + the new assertion
$ grep -c ECONNREFUSED stderr => 0
$ wc -l stderr => 0 # stderr entirely empty

Reverse verification, both directions predicted before running:

  1. Fix removed (git checkout origin/main -- on both test files) — noise returns: 72ECONNREFUSED, and the suite is still green at 34 tests. Confirms the double is what stops it, and that the escape never had a test watching it.
  2. New assertion is load-bearing — breaking the product (making DetailView's delete gate probe 'update') turns it red with a precise diff:
 {
"object": "contact",
- "operation": "delete",
+ "operation": "update",

Restored afterwards; git diff origin/main -- packages/plugin-detail/src/DetailView.tsx is empty.

Gates: pnpm --filter @object-ui/plugin-detail type-check green (after pnpm --filter '@object-ui/plugin-detail^...' build — the fresh-worktree dependency closure, without which every @object-ui/* import reads as TS2307), eslint on both files 0 errors, control-byte self-scan clean. scripts/check-changeset-presence.mjs arbitrated and required the empty-frontmatter changeset for a test-only change ("declared as releasing nothing" — its sanctioned pass, not a workaround); check-changeset-no-major.mjs green.

Deliberately not in this PR

  • plugin-charts / plugin-dashboard show the same noise per the issue's 08-09 comment, but they have a different root cause — neither package reaches useRecordEditable or DetailView at all. Their escape still needs its own bisect and is out of this card's plugin-detail scope.
  • app-shell's RecordDetailView also calls useRecordEditable, so I checked rather than assumed: RecordDetailView.headerRefresh.test.tsx runs with 0ECONNREFUSED. Nothing to do there.
  • A package-level network guard was considered per the card's "cheap seam" clause and rejected as theatre: packages/plugin-detail/vitest.setup.tsnever runs under the canonical repo-root invocation (the root config's dom project uses vitest.setup.dom-light.tsx), so a guard added there would protect nothing. That dead-config finding is attached to the card that owns it, objectui#3240 — it turned out to be a concrete hole in assertCanonicalVitestInvocation, whose docstring claims a coverage property that 11 packages break.

Generated by Claude Code

…le, not the network (#3339)
DetailView mounts `useRecordEditable` twice (update + delete) for any schema
carrying `objectName` + `resourceId`. With no `SchemaRendererProvider` in the
tree the hook has no host `apiFetch` and falls back to the GLOBAL fetch — by
design, for standalone embeds. Under happy-dom that global fetch is a REAL
request to the default origin, so the suite fired 36 live
`POST http://localhost:3000/api/v1/security/explain` calls per run.
They failed fire-and-forget: the hook swallows the rejection in order to fail
open, so the suite stayed green while stderr filled with 72 lines of
`connect ECONNREFUSED 127.0.0.1:3000` (two per escaped request).
Answer the probe from an injected double in the two files that escape —
`DetailView.test.tsx` (28 calls) and `DetailView.invalidation.test.tsx` (8).
`visible: true` reproduces the old observable behaviour exactly, since a failed
request already failed open, so no existing assertion changes meaning.
Product code is untouched: the global-fetch fallback is deliberate, documented
and pinned by `useRecordEditable.test.tsx`.
The escaped request was previously asserted by nobody, so its shape was
invisible. Pin it: DetailView's own wiring — recordId sourced from
`resourceId`, and one probe each for the update and the delete CTA — which
`useRecordEditable.test.tsx` cannot see. The double records every URL it is
handed, so an escape to another endpoint fails that assertion rather than
vanishing into a swallowed rejection. No global error swallowing.
Fixes#3339
@vercel

vercelBot commented Aug 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 10, 2026 9:46am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)28.3 KB350 KB
Entry fileindex-CF5C3utx.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)8.66KB3.13KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)7.57KB2.97KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)22.10KB4.37KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)35.76KB9.11KB
auth (createAuthenticatedFetch.js)4.37KB1.69KB
auth (index.js)2.35KB1.07KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)4.91KB0.87KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)484.15KB106.78KB
core (index.js)3.04KB1.15KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)140.66KB36.25KB
fields (index.js)229.40KB56.93KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)2.65KB1.06KB
i18n (pickLocalized.js)1.70KB0.83KB
i18n (provider.js)9.48KB3.27KB
i18n (useObjectLabel.js)27.59KB6.63KB
i18n (useSafeTranslation.js)4.52KB1.96KB
layout (index.js)38.87KB10.80KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)8.75KB3.06KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)3.67KB1.12KB
permissions (evaluator.js)4.41KB1.44KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.55KB0.71KB
plugin-ai (index.js)15.71KB3.79KB
plugin-calendar (index.js)45.23KB12.45KB
plugin-charts (index.js)61.49KB17.48KB
plugin-chatbot (index.js)180.33KB42.79KB
plugin-dashboard (index.js)118.50KB30.66KB
plugin-designer (index.js)210.51KB42.51KB
plugin-detail (index.js)237.80KB59.48KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)114.58KB27.68KB
plugin-gantt (index.js)162.81KB39.67KB
plugin-grid (index.js)187.85KB49.83KB
plugin-kanban (index.js)48.60KB13.41KB
plugin-list (index.js)109.96KB26.64KB
plugin-map (index.js)17.00KB5.32KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)40.58KB10.58KB
plugin-timeline (index.js)26.21KB7.52KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.03KB20.55KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.67KB2.37KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)23.71KB7.95KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.23KB0.66KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)4.47KB2.03KB
sdui-parser (parse.js)10.04KB2.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)4.69KB1.48KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)2.71KB1.34KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@yinlianghui
yinlianghui marked this pull request as ready for review August 10, 2026 09:57
@yinlianghui
yinlianghui added this pull request to the merge queueAug 10, 2026
Merged via the queue into main with commit fc2cc3bAug 10, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-3339-plugin-detail-msw-escape branch August 10, 2026 09:57
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

plugin-detail 测试套件里有测试向 127.0.0.1:3000 发起真实网络请求(ECONNREFUSED 噪音,未走 MSW)

2 participants

@yinlianghui@claude