Skip to content

test(e2e): regression suite + form-state refactor (fixes #3293, #3342, #3344, #3370)#3391

Open
moonming wants to merge 3 commits into
masterfrom
feat/e2e-regression-suite-and-fixes
Open

test(e2e): regression suite + form-state refactor (fixes #3293, #3342, #3344, #3370)#3391
moonming wants to merge 3 commits into
masterfrom
feat/e2e-regression-suite-and-fixes

Conversation

@moonming
Copy link
Copy Markdown
Member

@moonming moonming commented May 21, 2026

Why

Two motivations bundled into one PR because they're tightly coupled:

  1. Build a regression-grade E2E test suite so known bugs stop coming back, and so future contributors have a fast feedback loop on the form layer.
  2. While writing those tests, several bugs surfaced. Three of them turned out to be the same architectural anti-pattern in three different components — fixing only one would have left the others as latent #3293s. Better to do the cleanup in one pass with the tests that catch the regression.

What's in this PR

1. New Playwright E2E tests (52 tests)

Directory Tests Coverage
e2e/tests/regression/ 20 Pinned regressions for known issues — plugin delete confirm, cancel-discard warn, empty-plugin config preserved, labels persistence, crash guards, validation paths, node-sync-on-save (#3293)
e2e/tests/edge/ 10 Boundary inputs — XSS, CJK + emoji, long names, port 0/65535/65536, special URI patterns, large plugin config round-trip
e2e/tests/integration/ 3 Cross-resource flows — empty state i18n across 9 resources, language switch crash guard, delete-referenced-upstream propagation
e2e/tests/bulk/ 2 Performance — 100-row pagination, 1 000-row search responsiveness + request-volume bounds

All 52 pass locally with --workers=1 (CI configuration).

2. Bug fixes

#3293 — Upstream nodes lost on submit-without-blur

Symptom: user types a host/port/weight, clicks Save without clicking anywhere else first, the typed values are discarded and the previous (or empty) values are submitted to the Admin API.

Root cause: FormItemNodes.tsx mirrored the form value into a MobX useLocalObservable.__map, with a useEffect([ob, value]) syncing down and ob.save() → fOnChange syncing up. The bidirectional loop raced any update faster than ~100 ms; meanwhile ant-design's valueType: 'digit' cells only commit typed values to Antd Form on blur. Submit-without-blur missed both gates.

Fix (see commit e93f780c):

  • RHF is now the single source of truth. The MobX layer is gone. React useState only holds display rows, initialized once from the form value.
  • Every onValuesChange / add / remove pushes UP synchronously via fOnChange. No useEffect pushes value DOWN — no feedback loop.
  • Belt-and-suspenders: a document-level mousedown capture-phase listener flushes DOM input values to RHF whenever mousedown lands outside the table. Because mousedown fires before the Submit button's click handler, RHF sees the user's typed-but-not-blurred values regardless of Antd Form's commit timing.

Unblocks e2e/tests/regression/upstreams.node-sync-on-save.spec.ts (was .fixme, now stable 5/5 repeats in headless).

#3293 latent variants — same anti-pattern, two more components

The independent audit on this PR flagged that FormItemPlugins/index.tsx and page-slice/plugin_metadata/PluginMetadata.tsx used the same useLocalObservable.__map + useEffect([..., value]) + save → fOnChange shape. FormItemPlugins had the same submit-time data-loss window (a parallel re-render could re-initialize __map from a stale rawObject and overwrite a just-saved plugin config). PluginMetadata had a smaller mutation-vs-refetch window but the structural pattern was identical.

Both refactored to the same single-source-of-truth shape in e93f780c:

  • FormItemPlugins: selected = Object.keys(rawObject) directly. handleUpdate / handleDelete construct the new plugins object inline and push via fOnChange. UI state (curPluginName, drawer flags, search) moves to useState.
  • PluginMetadata: reads pluginInfoMap and hasConfigNames directly from the usePluginMetadataList() query. curPlugin and curPluginSchema are derived per-render.

#3342 — Plugin delete confirmation

PluginCard.tsx: wraps the destructive onDelete in a Mantine openConfirmModal. Regression test asserts BOTH the dismiss path (Cancel keeps the plugin) AND the confirm path (Delete removes it).

#3344 — Cancel on Edit form warns before discarding

New useEditCancelGuard hook applied to all 11 resource detail pages (routes, upstreams, services, consumers, consumer_groups, credentials, global_rules, plugin_configs, protos, secrets, ssls, stream_routes).

The hook always shows the modal — even on a visually-clean form. This is a deliberate tradeoff documented in the hook source: the pattern useForm({ disabled: readOnly }) + useEffect(() => form.reset(...)) makes formState.isDirty unreliable (toggling disabled fires spurious dirty events; the useEffect-driven reset wipes the flag mid-session). One extra click is a far better failure mode than silently losing an edit. Adding a reliable "skip-on-clean" path requires restructuring the form lifecycle and is filed as follow-up cleanup.

#3370 — Credentials list crash on undefined network response

src/apis/credentials.ts: guards against e.response === undefined (aborted/timed-out request) so the consumer detail page doesn't crash into the global error boundary. Regression test uses page.route to abort the credentials request and asserts no crash.

Whitespace-only name validation

src/types/schema/apisix/common.ts: a Zod .refine() rejects names consisting only of whitespace. Pre-existing names (including non-whitespace-only with leading/trailing spaces) continue to validate — only \" \" and similar are blocked.

3. Test infrastructure improvements

These are reusable utilities that earlier PRs (#3377 etc.) would have benefited from:

  • deleteAllStreamRoutes doesn't bring down the cleanup chain when APISIX is deployed without proxy_mode: http&stream. The 400 "stream mode is disabled" response is now swallowed (gated by both status code 4xx AND message text containing "stream mode"). Lets deleteAllServices (which transitively cleans stream routes) work in non-stream environments.
  • uiFillMonacoEditor uses setValue() instead of fill() to avoid Monaco's auto-bracket pairing corrupting JSON inputs.
  • uiFillUpstreamRequiredFields derives Page from the Locator context and adds explicit settle waits between rapid Add Node clicks, eliminating a race that broke multiple specs.
  • __root.tsx hides devtools in test mode to keep them out of the way of Playwright queries.
  • New utilities: safeClean() (e2e/utils/clean.ts), bulkCreate* / bulkDelete* (e2e/utils/bulk.ts), watchForCrashes() (e2e/utils/ui/crash.ts).

Local verification

  • All 52 new tests pass with --workers=1 (CI mode)
  • All affected existing tests pass: routes / upstreams / services / consumers / plugin_metadata / plugin_configs CRUD-all and CRUD-required, plugin editor drawer, hot-path
  • pnpm lint clean with --max-warnings=0
  • pnpm tsc --noEmit clean
  • #3293 regression test repeats green 5/5 in headless mode

What to watch in CI

  • The full test suite runs serially (workers: 1) in CI per playwright.config.ts, so the local serial results should hold.
  • Tests under e2e/tests/stream_routes.* require apisix.proxy_mode: http&stream. The dashboard's own e2e/server/docker-compose.yml configures this; non-stream APISIX deployments will skip these via the new error-swallowing in deleteAllStreamRoutes.
  • secrets.crud-all-fields expects a \"Secret Manager\" label that the UI doesn't currently render — pre-existing test gap, unrelated to this PR. Recommend tracking as a separate issue.

Audit history

After the initial push, an independent audit agent reviewed the diff cold (no shared context). Findings addressed:

Severity Finding Resolution Commit
HIGH 5.1 Plugin delete confirmation test didn't cover dismiss path Added 9c4355aa
HIGH 1.1 useEditCancelGuard always shows modal — UX concern Documented as deliberate tradeoff with clear path forward 9c4355aa
MEDIUM 1.2 deleteAllStreamRoutes regex-only guard could swallow unrelated errors Tightened to require both 4xx status AND "stream mode" text 9c4355aa
LOW 3.2 apisix_conf.yml lost its ASF license header Restored 9c4355aa
Architectural FormItemPlugins and PluginMetadata use the same __map anti-pattern as FormItemNodes All three refactored together; #3293 .fixme test unblocked e93f780c

Commit map

e93f780c  collapse three-layer form state, fix #3293 node sync on submit
9c4355aa  address PR audit findings
a665081f  (prior pushes — initial test suite + 4 bug fixes)

## New E2E test specs (51 tests across 4 directories)

### regression/ (19 specs)
- consumers.configure-next-no-crash — opening API-created consumer detail doesn't crash
- consumers.hyphen-username — consumer username with hyphens accepted (APISIX 3.13+)
- consumers.labels-displayed — consumer labels preserved after save
- credentials.list-no-crash-on-network-error — no crash when request is aborted (#3370)
- form.add-save-button-on-overflow — Add button reachable after scrolling
- form.cancel-unsaved-warning — Edit→Cancel warns on dirty form (#3344)
- form.mutation-failure-error — Admin API 5xx surfaces a visible error toast
- form.plugin-delete-confirmation — plugin delete requires confirmation (#3342)
- form.plugin-drawer-close-warns — closing Add Plugin drawer with edits warns
- plugins.editor-schema-fetch-failure — plugin editor recovers from schema fetch error
- routes.empty-plugin-config — plugins with {} config survive a no-op save (#3269)
- routes.upstream-id-only — route with upstream_id reference (no inline nodes)
- routes.vars-editor — vars field is optional; API-seeded vars render on detail
- ssls.labels-after-create — SSL labels visible after creation
- upstreams.discovery-args — discovery_type/service_name/discovery_args persist (#3376)
- upstreams.node-sync-on-save — [fixme] typed node values reach API without blur (#3293)
- upstreams.percent-undefined-no-crash — zero-weight upstream renders without crash (#3381)
- validation.multi-auth-schema — multi-auth plugin with valid config accepted (#3289)
- validation.zOneOf-single-field — service with inline-upstream-only saves cleanly

### edge/ (10 specs)
- plugins.invalid-json-monaco — invalid JSON cannot be saved
- plugins.large-config-roundtrip — 10 KB plugin config survives a UI round trip
- routes.long-name — 100-char name accepted; 1000-char rejected
- routes.nonexistent-service-id — non-existent service_id rejected with error
- routes.same-uri-different-method — two routes share URI with different methods
- routes.special-uri-chars — wildcard and path-parameter URIs accepted
- routes.unicode-emoji-name — CJK + emoji + RTL name persists exactly
- routes.whitespace-only-name — whitespace-only name rejected
- routes.xss-name-payload — XSS payload rendered as text, not executed
- upstreams.port-boundaries — port 65535 accepted; 0 and 65536 rejected

### integration/ (3 specs)
- empty-state.all-resources — empty-state pages show no raw i18n keys
- i18n.lang-switch-no-crash — switching every offered language doesn't crash
- lifecycle.delete-referenced-upstream — deleting in-use upstream surfaces API error

### bulk/ (2 specs)
- routes.bulk-100.list-render — pagination correct with 100 rows
- routes.bulk-1000.list-search — first page renders within 5 s with 1 000 rows

## New test utilities
- e2e/utils/clean.ts — safeClean() swallows stream-mode-disabled 400s
- e2e/utils/bulk.ts — bulk seed / delete helpers for Admin API
- e2e/utils/ui/crash.ts — watchForCrashes() captures pageerror events

## Product bug fixes

### Fix #3342 — plugin delete requires confirmation
- src/components/form-slice/FormItemPlugins/PluginCard.tsx
  Added Mantine confirmModal before firing onDelete.
- Updated tests: routes.crud-all-fields, plugin_metadata.crud-required/all-fields,
  consumer_groups.crud-required-fields, ssls.crud-all/required-fields

### Fix #3344 — Cancel on dirty detail form warns before discarding
- src/hooks/useEditCancelGuard.tsx (new hook)
- Applied to all 11 resource detail pages: routes, upstreams, services, consumers,
  consumer_groups, credentials, global_rules, plugin_configs, protos, secrets,
  ssls, stream_routes

### Fix #3370 — credentials list crash on undefined response
- src/apis/credentials.ts — guard for e.response === undefined

### Fix whitespace-only name validation
- src/types/schema/apisix/common.ts — .trim().min(1) on name fields

## Infrastructure / test-helper fixes
- src/apis/stream_routes.ts — deleteAllStreamRoutes swallows "stream mode disabled"
  400s so deleteAllServices works in non-stream deployments
- src/routes/__root.tsx — hide devtools in test mode (avoids UI interference)
- e2e/utils/ui/index.ts — uiFillMonacoEditor uses setValue() instead of fill()
  to avoid auto-bracket corruption by Monaco
- e2e/utils/ui/upstreams.ts — uiFillUpstreamRequiredFields: derive page from ctx
  always (was conditional on optional 3rd arg), add force:true + 300 ms settle
  between rapid Add Node clicks; remove the now-unused _page parameter
- e2e/server/apisix_conf.yml — strip redundant comments (cosmetic)
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

license-eye has checked 331 files.

Valid Invalid Ignored Fixed
283 1 47 0
Click to see the invalid file list
  • e2e/server/apisix_conf.yml
Use this command to fix any missing license headers
```bash

docker run -it --rm -v $(pwd):/github/workspace apache/skywalking-eyes header fix

</details>

# limitations under the License.
#

apisix:
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
apisix:
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
apisix:

Addresses HIGH/MEDIUM/LOW findings from the independent audit on PR #3391.

- **5.1 HIGH** — Plugin delete confirmation test now covers the dismiss
  path: clicking the dialog's Cancel button must leave the plugin intact.
  A future regression that wires Cancel to the delete handler would slip
  past the original "modal appeared" check.

- **1.1 HIGH** — Documented the deliberate "always show modal" tradeoff
  in useEditCancelGuard. Tried implementing the audit's "skip on clean
  form" suggestion but it's not reliable with the current form lifecycle:
  toggling `disabled` on Edit re-renders controlled inputs and fires
  spurious dirty events, and the useEffect-driven `form.reset(...)` wipes
  the dirty flag mid-session. The proper fix requires restructuring the
  form lifecycle (tracked as a follow-up). For now, one extra click is a
  far better failure mode than silently losing an edit.

  The corresponding test (test 2) now pins the dismiss path instead of
  asserting the not-yet-implemented skip-on-clean behavior.

- **1.2 MEDIUM** — Tightened `deleteAllStreamRoutes` guard to require
  both a 4xx status (or no response) AND the "stream mode" text. The
  prior version would have swallowed any error whose message happened to
  contain "stream mode" regardless of status code.

- **3.2 LOW** — Restored the ASF license header to apisix_conf.yml.
The three components below all shared the same architectural anti-pattern
that caused #3293: a MobX `useLocalObservable.__map` cache mirroring the
form/query value, plus a `useEffect([..., value])` pushing value DOWN
into the cache, plus a `save()` pushing cache UP via fOnChange. The
bidirectional sync raced on every fast update — typing into a cell and
clicking Save without blurring first could lose data; an in-flight form
update could reinitialize the mirror from a stale snapshot and overwrite
a just-saved plugin config.

This change collapses all three to a single source of truth.

**FormItemPlugins/index.tsx**
- Removed `useLocalObservable.__map` and the `useEffect([..., rawObject])`
  that re-derived it
- `selected` now reads `Object.keys(rawObject)` directly from the RHF
  controller value
- `handleUpdate` / `handleDelete` construct the new plugins object inline
  and push via `fOnChange` — no intermediate cache to go stale
- Only transient UI state (curPluginName, mode, drawer open flags,
  search) stays in component state via useState

**PluginMetadata.tsx**
- Removed `__map` / `__schemaMap` and the `useDeepCompareEffect` that
  reinitialized them — the page now reads `pluginInfoMap` and
  `hasConfigNames` directly from the `usePluginMetadataList()` query
- `curPlugin` and `curPluginSchema` are derived per-render from
  `pluginInfoMap.get(curPluginName)`
- mutations + refetch flow is unchanged

**FormPartUpstream/FormItemNodes.tsx (fixes #3293)**
- Removed `useLocalObservable.ob` and the `useEffect([ob, value])` that
  re-derived row ids and disrupted the EditableProTable's editable
  lifecycle (the failure mode of the prior `useDebouncedCallback` attempt)
- React `useState` holds the table's display rows, initialized once from
  the form value; subsequent external value changes are handled by the
  parent re-keying this component, which is the normal RHF detail-page
  reset flow
- Every onValuesChange / add / remove pushes UP to RHF synchronously via
  `fOnChange`. No down-sync, so no feedback loop
- Last-resort DOM flush: a document-level `mousedown` capture-phase
  listener walks the cell inputs and pushes their CURRENT visible values
  to RHF whenever a mousedown lands OUTSIDE the table. This is what makes
  the #3293 regression test pass deterministically — ant-design's
  `valueType: 'digit'` cells don't commit typed values to Antd Form's
  internal state until blur, but `mousedown` fires BEFORE the click
  handler on the Submit button, so RHF sees the correct values

Unblocks `e2e/tests/regression/upstreams.node-sync-on-save.spec.ts`,
which was previously `.fixme`'d.

All affected CRUD specs (routes/services/upstreams/plugin_metadata/
consumers/plugin_configs) plus the full regression / edge / integration /
bulk suites pass.
@moonming moonming changed the title test(e2e): regression/edge/integration suite + 4 bug fixes test(e2e): regression suite + form-state refactor (fixes #3293, #3342, #3344, #3370) May 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants