diff --git a/e2e/server/apisix_conf.yml b/e2e/server/apisix_conf.yml
index 7d75f94368..f0109e4ebf 100644
--- a/e2e/server/apisix_conf.yml
+++ b/e2e/server/apisix_conf.yml
@@ -16,7 +16,7 @@
#
apisix:
- node_listen: 9080 # APISIX listening port
+ node_listen: 9080
enable_ipv6: false
proxy_mode: http&stream
stream_proxy:
@@ -24,24 +24,20 @@ apisix:
- 9100
udp:
- 9200
-
deployment:
admin:
- allow_admin: # https://nginx.org/en/docs/http/ngx_http_access_module.html#allow
- - 0.0.0.0/0 # We need to restrict ip access rules for security. 0.0.0.0/0 is for test.
-
+ allow_admin:
+ - 0.0.0.0/0
admin_key:
- - name: "admin"
+ - name: admin
key: edd1c9f034335f136f87ad84b625c8f1
- role: admin # admin: manage all configuration data
-
+ role: admin
etcd:
- host: # it's possible to define multiple etcd hosts addresses of the same etcd cluster.
- - "http://etcd:2379" # multiple etcd address
- prefix: "/apisix" # apisix configurations prefix
- timeout: 30 # 30 seconds
-
+ host:
+ - http://etcd:2379
+ prefix: /apisix
+ timeout: 30
discovery:
nacos:
host:
- - "http://nacos:8848"
+ - http://nacos:8848
diff --git a/e2e/tests/bulk/routes.bulk-100.list-render.spec.ts b/e2e/tests/bulk/routes.bulk-100.list-render.spec.ts
new file mode 100644
index 0000000000..117b2fa616
--- /dev/null
+++ b/e2e/tests/bulk/routes.bulk-100.list-render.spec.ts
@@ -0,0 +1,76 @@
+/**
+ * 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.
+ */
+
+// Bulk D-01: routes list page must render correctly with 100 rows.
+// Data is seeded directly via the Admin API to keep the bulk-render
+// assertion decoupled from the add-form behavior.
+
+import { routesPom } from '@e2e/pom/routes';
+import {
+ bulkCreateRoutes,
+ bulkDeleteRoutesByPrefix,
+} from '@e2e/utils/bulk';
+import { test } from '@e2e/utils/test';
+import { expect } from '@playwright/test';
+
+const PREFIX = 'bulk100';
+const COUNT = 100;
+
+test.describe.configure({ mode: 'serial', timeout: 120_000 });
+
+test.beforeAll(async () => {
+ await bulkDeleteRoutesByPrefix(PREFIX);
+ await bulkCreateRoutes({ count: COUNT, prefix: PREFIX });
+});
+
+test.afterAll(async () => {
+ await bulkDeleteRoutesByPrefix(PREFIX);
+});
+
+test('routes list shows correct total and renders default page', async ({
+ page,
+}) => {
+ const t0 = Date.now();
+ await routesPom.toIndex(page);
+ await routesPom.isIndexPage(page);
+
+ // Routes are listed in server-defined order (not insertion order), so
+ // bulk100-0 isn't guaranteed to be on page 1. Just require *some*
+ // seeded row to render.
+ const someSeededRow = page
+ .getByRole('row', { name: new RegExp(`${PREFIX}-\\d+`) })
+ .first();
+ await expect(someSeededRow).toBeVisible({ timeout: 5000 });
+ const renderMs = Date.now() - t0;
+ test
+ .info()
+ .annotations.push({ type: 'perf', description: `LCP-ish: ${renderMs}ms` });
+ expect(renderMs, 'list page should render in < 5s with 100 rows').toBeLessThan(
+ 5000
+ );
+});
+
+test('pagination shows ≥ 10 pages at default page_size=10', async ({
+ page,
+}) => {
+ await routesPom.toIndex(page);
+ await routesPom.isIndexPage(page);
+
+ // page=10 must be reachable (100 rows / 10 per page = 10 pages).
+ const page10 = page.getByRole('listitem', { name: '10' });
+ await expect(page10).toBeVisible();
+});
diff --git a/e2e/tests/bulk/routes.bulk-1000.list-search.spec.ts b/e2e/tests/bulk/routes.bulk-1000.list-search.spec.ts
new file mode 100644
index 0000000000..8018961b9b
--- /dev/null
+++ b/e2e/tests/bulk/routes.bulk-1000.list-search.spec.ts
@@ -0,0 +1,95 @@
+/**
+ * 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.
+ */
+/* eslint-disable playwright/no-wait-for-timeout -- regression test stabilization */
+
+// Bulk D-03: with 1000 routes the list page must:
+// - render the first page within a reasonable budget
+// - allow URL-driven jump to a far page (e.g. ?page=50)
+// - not flood the Admin API with per-row requests
+//
+// Marked as `bulk` — exclude from default CI runs.
+
+import { routesPom } from '@e2e/pom/routes';
+import {
+ bulkCreateRoutes,
+ bulkDeleteRoutesByPrefix,
+} from '@e2e/utils/bulk';
+import { test } from '@e2e/utils/test';
+import { expect } from '@playwright/test';
+
+const PREFIX = 'bulk1k';
+const COUNT = 1000;
+
+test.describe.configure({ mode: 'serial', timeout: 300_000 });
+
+test.beforeAll(async () => {
+ await bulkDeleteRoutesByPrefix(PREFIX);
+ await bulkCreateRoutes({ count: COUNT, prefix: PREFIX });
+});
+
+test.afterAll(async () => {
+ await bulkDeleteRoutesByPrefix(PREFIX);
+});
+
+test('first page renders within 5s with 1000 rows in etcd', async ({
+ page,
+}) => {
+ const t0 = Date.now();
+ await routesPom.toIndex(page);
+ await routesPom.isIndexPage(page);
+
+ await expect(
+ page
+ .getByRole('row', { name: new RegExp(`${PREFIX}-\\d+`) })
+ .first()
+ ).toBeVisible({ timeout: 8000 });
+ const ms = Date.now() - t0;
+ test
+ .info()
+ .annotations.push({ type: 'perf', description: `first-page render: ${ms}ms` });
+ expect(ms).toBeLessThan(8000);
+});
+
+test('admin API call volume on list page load is bounded (≤ 5 GETs)', async ({
+ page,
+}) => {
+ const adminCalls: string[] = [];
+ page.on('request', (req) => {
+ if (req.method() === 'GET' && req.url().includes('/apisix/admin/routes')) {
+ adminCalls.push(req.url());
+ }
+ });
+
+ await routesPom.toIndex(page);
+ await routesPom.isIndexPage(page);
+ await expect(
+ page
+ .getByRole('row', { name: new RegExp(`${PREFIX}-\\d+`) })
+ .first()
+ ).toBeVisible({ timeout: 8000 });
+ // Settle for any deferred queries.
+ await page.waitForTimeout(800);
+
+ test.info().annotations.push({
+ type: 'perf',
+ description: `admin /routes GETs: ${adminCalls.length}`,
+ });
+ expect(
+ adminCalls.length,
+ 'list page should not issue per-row GETs (≤ 5 total)'
+ ).toBeLessThanOrEqual(5);
+});
diff --git a/e2e/tests/consumer_groups.crud-required-fields.spec.ts b/e2e/tests/consumer_groups.crud-required-fields.spec.ts
index ade5f7d4a7..5f03309095 100644
--- a/e2e/tests/consumer_groups.crud-required-fields.spec.ts
+++ b/e2e/tests/consumer_groups.crud-required-fields.spec.ts
@@ -117,8 +117,13 @@ test('should CRUD Consumer Group with required fields', async ({ page }) => {
const idField = page.getByRole('textbox', { name: 'ID', exact: true });
await expect(idField).toBeDisabled();
- // Cancel without making changes
+ // Cancel without making changes. The Edit→Cancel guard now always
+ // confirms before discarding (see src/hooks/useEditCancelGuard.tsx).
await page.getByRole('button', { name: 'Cancel' }).click();
+ await page
+ .getByRole('dialog')
+ .getByRole('button', { name: 'Discard Changes' })
+ .click();
// Verify we're back in detail view
await consumerGroupsPom.isDetailPage(page);
diff --git a/e2e/tests/edge/plugins.invalid-json-monaco.spec.ts b/e2e/tests/edge/plugins.invalid-json-monaco.spec.ts
new file mode 100644
index 0000000000..6ccf7472cc
--- /dev/null
+++ b/e2e/tests/edge/plugins.invalid-json-monaco.spec.ts
@@ -0,0 +1,80 @@
+/**
+ * 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.
+ */
+/* eslint-disable playwright/no-wait-for-timeout -- regression test stabilization */
+
+// Edge: typing syntactically-invalid JSON in the plugin config editor must
+// either prevent submission or surface a visible error — never round-trip a
+// broken config to the Admin API.
+
+import { routesPom } from '@e2e/pom/routes';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import {
+ uiFillMonacoEditor,
+ uiGetMonacoEditor,
+} from '@e2e/utils/ui';
+import { expect } from '@playwright/test';
+
+import { deleteAllRoutes } from '@/apis/routes';
+
+const pluginName = 'cors';
+const invalidJson = '{ "allow_origins": "*", '; // missing closing brace + trailing comma
+
+test.beforeAll(async () => {
+ await deleteAllRoutes(e2eReq);
+});
+
+test.afterAll(async () => {
+ await deleteAllRoutes(e2eReq);
+});
+
+test('invalid JSON in plugin editor cannot be saved', async ({ page }) => {
+ await routesPom.toAdd(page);
+ await routesPom.isAddPage(page);
+
+ await page.getByRole('button', { name: 'Select Plugins' }).click();
+ const selectDialog = page.getByRole('dialog', { name: 'Select Plugins' });
+ await selectDialog.getByPlaceholder('Search').fill(pluginName);
+ await selectDialog
+ .getByTestId(`plugin-${pluginName}`)
+ .getByRole('button', { name: 'Add' })
+ .click();
+
+ const addPluginDialog = page.getByRole('dialog', { name: 'Add Plugin' });
+ const editor = await uiGetMonacoEditor(page, addPluginDialog, false);
+ await uiFillMonacoEditor(page, editor, invalidJson);
+
+ await addPluginDialog.getByRole('button', { name: 'Add' }).click();
+
+ // The dialog must remain open OR an error must surface — what we forbid
+ // is silently accepting invalid JSON and closing the dialog as if it
+ // were valid.
+ await page.waitForTimeout(1000);
+ const dialogStillOpen = await addPluginDialog
+ .isVisible()
+ .catch(() => false);
+ const errorVisible = await page
+ .getByText(/(invalid|json|parse|syntax)/i)
+ .first()
+ .isVisible()
+ .catch(() => false);
+
+ expect(
+ dialogStillOpen || errorVisible,
+ 'invalid JSON must NOT silently close the editor as if accepted'
+ ).toBe(true);
+});
diff --git a/e2e/tests/edge/plugins.large-config-roundtrip.spec.ts b/e2e/tests/edge/plugins.large-config-roundtrip.spec.ts
new file mode 100644
index 0000000000..71cd0647c9
--- /dev/null
+++ b/e2e/tests/edge/plugins.large-config-roundtrip.spec.ts
@@ -0,0 +1,106 @@
+/**
+ * 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.
+ */
+
+// Edge: a multi-KB plugin config must round-trip through Monaco + Admin API
+// without truncation. We use serverless-pre-function which accepts an
+// arbitrary phase / functions structure, allowing us to compose a payload
+// that is provably ~10KB on the wire.
+
+import { routesPom } from '@e2e/pom/routes';
+import { randomId } from '@e2e/utils/common';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import {
+ uiFillMonacoEditor,
+ uiGetMonacoEditor,
+ uiHasToastMsg,
+} from '@e2e/utils/ui';
+import { uiFillUpstreamRequiredFields } from '@e2e/utils/ui/upstreams';
+import { expect } from '@playwright/test';
+
+import { deleteAllRoutes, getRouteReq } from '@/apis/routes';
+import type { APISIXType } from '@/types/schema/apisix';
+
+const nodes: APISIXType['UpstreamNode'][] = [
+ { host: 'large-cfg-a.local', port: 80, weight: 100 },
+ { host: 'large-cfg-b.local', port: 80, weight: 100 },
+];
+
+test.beforeAll(async () => {
+ await deleteAllRoutes(e2eReq);
+});
+
+test.afterAll(async () => {
+ await deleteAllRoutes(e2eReq);
+});
+
+test('10KB plugin config survives a UI round trip', async ({ page }) => {
+ // Build a CORS allow_origins value padded to ~10KB. CORS plugin tolerates
+ // any string for allow_origins, so the test is purely about payload size,
+ // not semantics.
+ const padding = 'x'.repeat(10240);
+ const pluginConfig = { allow_origins: `https://${padding}.local` };
+
+ await routesPom.toAdd(page);
+ await routesPom.isAddPage(page);
+
+ await page
+ .getByLabel('Name', { exact: true })
+ .first()
+ .fill(randomId('edge-large-cfg'));
+ await page.getByLabel('URI', { exact: true }).fill('/edge/large-cfg');
+ await page.getByRole('textbox', { name: 'HTTP Methods' }).click();
+ await page.getByRole('option', { name: 'GET' }).click();
+ await page.keyboard.press('Escape');
+
+ const upstreamSection = page.getByRole('group', {
+ name: 'Upstream',
+ exact: true,
+ });
+ await uiFillUpstreamRequiredFields(
+ upstreamSection,
+ { nodes, name: randomId('edge-up'), desc: 'edge' }
+ );
+
+ await page.getByRole('button', { name: 'Select Plugins' }).click();
+ const selectDialog = page.getByRole('dialog', { name: 'Select Plugins' });
+ await selectDialog.getByPlaceholder('Search').fill('cors');
+ await selectDialog
+ .getByTestId('plugin-cors')
+ .getByRole('button', { name: 'Add' })
+ .click();
+
+ const addPluginDialog = page.getByRole('dialog', { name: 'Add Plugin' });
+ const editor = await uiGetMonacoEditor(page, addPluginDialog, false);
+ await uiFillMonacoEditor(page, editor, JSON.stringify(pluginConfig));
+ await addPluginDialog
+ .getByRole('button', { name: 'Add' })
+ .click();
+ await expect(addPluginDialog).toBeHidden({ timeout: 10000 });
+
+ await routesPom.getAddBtn(page).click();
+ await uiHasToastMsg(page, { hasText: 'Add Route Successfully' });
+ await routesPom.isDetailPage(page);
+
+ const routeId = page.url().split('/').pop()!;
+ const route = (await getRouteReq(e2eReq, routeId)).value;
+ expect(route.plugins).toBeDefined();
+ expect(route.plugins!.cors).toBeDefined();
+ expect(
+ (route.plugins!.cors as { allow_origins: string }).allow_origins
+ ).toBe(pluginConfig.allow_origins);
+});
diff --git a/e2e/tests/edge/routes.long-name.spec.ts b/e2e/tests/edge/routes.long-name.spec.ts
new file mode 100644
index 0000000000..a865ee7d06
--- /dev/null
+++ b/e2e/tests/edge/routes.long-name.spec.ts
@@ -0,0 +1,122 @@
+/**
+ * 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.
+ */
+
+// Edge: long route names. The Admin API has its own length limits — the
+// dashboard must surface those clearly. Two boundary cases:
+// 1. 100 chars (typical APISIX max) → must accept
+// 2. 1000 chars (way over) → must reject with a visible error
+
+import { routesPom } from '@e2e/pom/routes';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiHasToastMsg } from '@e2e/utils/ui';
+import { uiFillUpstreamRequiredFields } from '@e2e/utils/ui/upstreams';
+import { expect } from '@playwright/test';
+
+import { deleteAllRoutes, getRouteReq } from '@/apis/routes';
+import type { APISIXType } from '@/types/schema/apisix';
+
+const nodes: APISIXType['UpstreamNode'][] = [
+ { host: 'long-a.local', port: 80, weight: 100 },
+ { host: 'long-b.local', port: 80, weight: 100 },
+];
+
+test.beforeAll(async () => {
+ await deleteAllRoutes(e2eReq);
+});
+
+test.afterAll(async () => {
+ await deleteAllRoutes(e2eReq);
+});
+
+test('100-char route name is accepted', async ({ page }) => {
+ const longName = 'a'.repeat(100);
+ await routesPom.toAdd(page);
+ await routesPom.isAddPage(page);
+
+ await page.getByLabel('Name', { exact: true }).first().fill(longName);
+ await page.getByLabel('URI', { exact: true }).fill('/edge/long/100');
+ await page.getByRole('textbox', { name: 'HTTP Methods' }).click();
+ await page.getByRole('option', { name: 'GET' }).click();
+ await page.keyboard.press('Escape');
+
+ const upstreamSection = page.getByRole('group', {
+ name: 'Upstream',
+ exact: true,
+ });
+ await uiFillUpstreamRequiredFields(
+ upstreamSection,
+ { nodes, name: 'edge-long-up-100', desc: 'edge' }
+ );
+
+ await routesPom.getAddBtn(page).click();
+ await uiHasToastMsg(page, { hasText: 'Add Route Successfully' });
+ await routesPom.isDetailPage(page);
+
+ const routeId = page.url().split('/').pop()!;
+ const route = (await getRouteReq(e2eReq, routeId)).value;
+ expect(route.name).toBe(longName);
+});
+
+test('1000-char route name is rejected with visible feedback', async ({
+ page,
+}) => {
+ const tooLong = 'b'.repeat(1000);
+ await routesPom.toAdd(page);
+ await routesPom.isAddPage(page);
+
+ await page.getByLabel('Name', { exact: true }).first().fill(tooLong);
+ await page.getByLabel('URI', { exact: true }).fill('/edge/long/1000');
+ await page.getByRole('textbox', { name: 'HTTP Methods' }).click();
+ await page.getByRole('option', { name: 'GET' }).click();
+ await page.keyboard.press('Escape');
+
+ const upstreamSection = page.getByRole('group', {
+ name: 'Upstream',
+ exact: true,
+ });
+ await uiFillUpstreamRequiredFields(
+ upstreamSection,
+ { nodes, name: 'edge-long-up-1000', desc: 'edge' }
+ );
+
+ await routesPom.getAddBtn(page).click();
+
+ // Either the form's inline validation prevents submit OR the API rejects
+ // and a toast surfaces the error. We require at least one of:
+ // - we never reach the detail page
+ // - an alert toast appears
+ const successToast = page
+ .getByRole('alert')
+ .filter({ hasText: 'Add Route Successfully' });
+ const errorToast = page
+ .getByRole('alert')
+ .filter({ hasText: /(fail|error|invalid|too long|exceed|length)/i });
+
+ const result = await Promise.race([
+ successToast.first().waitFor({ state: 'visible', timeout: 10000 })
+ .then(() => 'success' as const)
+ .catch(() => 'no-success' as const),
+ errorToast.first().waitFor({ state: 'visible', timeout: 10000 })
+ .then(() => 'error' as const)
+ .catch(() => 'no-error' as const),
+ ]);
+
+ expect(result, 'a 1000-char name must NOT silently succeed').not.toBe(
+ 'success'
+ );
+});
diff --git a/e2e/tests/edge/routes.nonexistent-service-id.spec.ts b/e2e/tests/edge/routes.nonexistent-service-id.spec.ts
new file mode 100644
index 0000000000..e037816a9a
--- /dev/null
+++ b/e2e/tests/edge/routes.nonexistent-service-id.spec.ts
@@ -0,0 +1,88 @@
+/**
+ * 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.
+ */
+/* eslint-disable playwright/no-conditional-in-test, playwright/no-conditional-expect -- regression test stabilization */
+
+// Edge: creating a route that references a non-existent service_id must
+// surface an error — never silently appear to succeed.
+
+import { routesPom } from '@e2e/pom/routes';
+import { safeClean } from '@e2e/utils/clean';
+import { randomId } from '@e2e/utils/common';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { expect } from '@playwright/test';
+
+import { deleteAllRoutes } from '@/apis/routes';
+import { deleteAllServices } from '@/apis/services';
+
+const FAKE_SERVICE_ID = 'nonexistent-service-id-edge-001';
+
+test.beforeAll(async () => {
+ await safeClean(
+ () => deleteAllRoutes(e2eReq),
+ () => deleteAllServices(e2eReq)
+ );
+});
+
+test.afterAll(async () => {
+ await safeClean(() => deleteAllRoutes(e2eReq));
+});
+
+test('route with non-existent service_id is rejected with a visible error', async ({
+ page,
+}) => {
+ await routesPom.toAdd(page);
+ await routesPom.isAddPage(page);
+
+ await page
+ .getByLabel('Name', { exact: true })
+ .first()
+ .fill(randomId('edge-bad-sid'));
+ await page.getByLabel('URI', { exact: true }).fill('/edge/bad-service-id');
+ await page.getByRole('textbox', { name: 'HTTP Methods' }).click();
+ await page.getByRole('option', { name: 'GET' }).click();
+ await page.keyboard.press('Escape');
+
+ const serviceSection = page.getByRole('group', { name: 'Service' });
+ await serviceSection.locator('input[name="service_id"]').fill(FAKE_SERVICE_ID);
+
+ await routesPom.getAddBtn(page).click();
+
+ // Must NOT show success
+ const successToast = page
+ .getByRole('alert')
+ .filter({ hasText: 'Add Route Successfully' });
+ const accepted = await successToast
+ .first()
+ .waitFor({ state: 'visible', timeout: 5000 })
+ .then(() => true)
+ .catch(() => false);
+ expect(
+ accepted,
+ 'non-existent service_id must NOT silently produce a working route'
+ ).toBe(false);
+
+ // Either an error toast surfaces or the form stays on add page with
+ // inline validation. Both are acceptable; silent success is not.
+ const stillOnAdd = page.url().includes('/routes/add');
+ if (!stillOnAdd) {
+ const errorToast = page
+ .getByRole('alert')
+ .filter({ hasText: /(fail|error|invalid|not found|404)/i });
+ await expect(errorToast.first()).toBeVisible({ timeout: 5000 });
+ }
+});
diff --git a/e2e/tests/edge/routes.same-uri-different-method.spec.ts b/e2e/tests/edge/routes.same-uri-different-method.spec.ts
new file mode 100644
index 0000000000..d724ca224e
--- /dev/null
+++ b/e2e/tests/edge/routes.same-uri-different-method.spec.ts
@@ -0,0 +1,91 @@
+/**
+ * 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.
+ */
+
+// Edge: APISIX legitimately allows two routes that share a URI as long as
+// their methods differ. The dashboard must not pre-block this.
+
+import { routesPom } from '@e2e/pom/routes';
+import { randomId } from '@e2e/utils/common';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiHasToastMsg } from '@e2e/utils/ui';
+import { uiFillUpstreamRequiredFields } from '@e2e/utils/ui/upstreams';
+import { expect } from '@playwright/test';
+
+import { deleteAllRoutes, getRouteListReq } from '@/apis/routes';
+import type { APISIXType } from '@/types/schema/apisix';
+
+const nodes: APISIXType['UpstreamNode'][] = [
+ { host: 'same-uri-a.local', port: 80, weight: 100 },
+ { host: 'same-uri-b.local', port: 80, weight: 100 },
+];
+const sharedUri = '/edge/same-uri';
+
+test.describe('routes sharing URI with different methods', () => {
+ test.describe.configure({ mode: 'serial' });
+
+ test.beforeAll(async () => {
+ await deleteAllRoutes(e2eReq);
+ });
+
+ test.afterAll(async () => {
+ await deleteAllRoutes(e2eReq);
+ });
+
+ const addRoute = async (
+ page: import('@playwright/test').Page,
+ name: string,
+ method: string
+ ) => {
+ await routesPom.toAdd(page);
+ await routesPom.isAddPage(page);
+ await page.getByLabel('Name', { exact: true }).first().fill(name);
+ await page.getByLabel('URI', { exact: true }).fill(sharedUri);
+ await page.getByRole('textbox', { name: 'HTTP Methods' }).click();
+ await page.getByRole('option', { name: method }).click();
+ await page.keyboard.press('Escape');
+
+ const upstreamSection = page.getByRole('group', {
+ name: 'Upstream',
+ exact: true,
+ });
+ await uiFillUpstreamRequiredFields(
+ upstreamSection,
+ { nodes, name: randomId('edge-up'), desc: 'edge' }
+ );
+
+ await routesPom.getAddBtn(page).click();
+ await uiHasToastMsg(page, { hasText: 'Add Route Successfully' });
+ await routesPom.isDetailPage(page);
+ };
+
+ test('two routes with same URI but different methods both save', async ({
+ page,
+ }) => {
+ await addRoute(page, randomId('edge-same-uri-get'), 'GET');
+ await addRoute(page, randomId('edge-same-uri-post'), 'POST');
+
+ const list = await getRouteListReq(e2eReq, {
+ page: 1,
+ page_size: 50,
+ });
+ const sameUriRoutes = list.list.filter(
+ (r) => (r.value as APISIXType['Route']).uri === sharedUri
+ );
+ expect(sameUriRoutes).toHaveLength(2);
+ });
+});
diff --git a/e2e/tests/edge/routes.special-uri-chars.spec.ts b/e2e/tests/edge/routes.special-uri-chars.spec.ts
new file mode 100644
index 0000000000..b02a9e7761
--- /dev/null
+++ b/e2e/tests/edge/routes.special-uri-chars.spec.ts
@@ -0,0 +1,110 @@
+/**
+ * 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.
+ */
+
+// Edge: URI patterns containing characters with special meaning in URI
+// space. APISIX accepts trailing-wildcard `/foo/*` and most printable
+// chars except whitespace.
+
+import { routesPom } from '@e2e/pom/routes';
+import { randomId } from '@e2e/utils/common';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiHasToastMsg } from '@e2e/utils/ui';
+import { uiFillUpstreamRequiredFields } from '@e2e/utils/ui/upstreams';
+import { expect } from '@playwright/test';
+
+import { deleteAllRoutes, getRouteReq } from '@/apis/routes';
+import type { APISIXType } from '@/types/schema/apisix';
+
+const nodes: APISIXType['UpstreamNode'][] = [
+ { host: 'special-a.local', port: 80, weight: 100 },
+ { host: 'special-b.local', port: 80, weight: 100 },
+];
+
+test.beforeAll(async () => {
+ await deleteAllRoutes(e2eReq);
+});
+
+test.afterAll(async () => {
+ await deleteAllRoutes(e2eReq);
+});
+
+test('route URI with trailing wildcard /api/* is accepted', async ({
+ page,
+}) => {
+ const routeName = randomId('edge-uri-wildcard');
+ const routeUri = '/edge/api/*';
+
+ await routesPom.toAdd(page);
+ await routesPom.isAddPage(page);
+
+ await page.getByLabel('Name', { exact: true }).first().fill(routeName);
+ await page.getByLabel('URI', { exact: true }).fill(routeUri);
+ await page.getByRole('textbox', { name: 'HTTP Methods' }).click();
+ await page.getByRole('option', { name: 'GET' }).click();
+ await page.keyboard.press('Escape');
+
+ const upstreamSection = page.getByRole('group', {
+ name: 'Upstream',
+ exact: true,
+ });
+ await uiFillUpstreamRequiredFields(
+ upstreamSection,
+ { nodes, name: randomId('edge-up'), desc: 'edge' }
+ );
+
+ await routesPom.getAddBtn(page).click();
+ await uiHasToastMsg(page, { hasText: 'Add Route Successfully' });
+ await routesPom.isDetailPage(page);
+
+ const routeId = page.url().split('/').pop()!;
+ const route = (await getRouteReq(e2eReq, routeId)).value;
+ expect(route.uri).toBe(routeUri);
+});
+
+test('route URI with embedded path-parameter pattern /:id is accepted', async ({
+ page,
+}) => {
+ const routeName = randomId('edge-uri-param');
+ const routeUri = '/edge/users/:id';
+
+ await routesPom.toAdd(page);
+ await routesPom.isAddPage(page);
+
+ await page.getByLabel('Name', { exact: true }).first().fill(routeName);
+ await page.getByLabel('URI', { exact: true }).fill(routeUri);
+ await page.getByRole('textbox', { name: 'HTTP Methods' }).click();
+ await page.getByRole('option', { name: 'GET' }).click();
+ await page.keyboard.press('Escape');
+
+ const upstreamSection = page.getByRole('group', {
+ name: 'Upstream',
+ exact: true,
+ });
+ await uiFillUpstreamRequiredFields(
+ upstreamSection,
+ { nodes, name: randomId('edge-up'), desc: 'edge' }
+ );
+
+ await routesPom.getAddBtn(page).click();
+ // APISIX may or may not honour `:param` syntax; behavior must be either
+ // success or a clear error toast, never a silent submit.
+ const toast = page
+ .getByRole('alert')
+ .filter({ hasText: /(success|fail|error|invalid)/i });
+ await expect(toast.first()).toBeVisible({ timeout: 10000 });
+});
diff --git a/e2e/tests/edge/routes.unicode-emoji-name.spec.ts b/e2e/tests/edge/routes.unicode-emoji-name.spec.ts
new file mode 100644
index 0000000000..a5bf5f185c
--- /dev/null
+++ b/e2e/tests/edge/routes.unicode-emoji-name.spec.ts
@@ -0,0 +1,78 @@
+/**
+ * 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.
+ */
+
+// Edge: route name composed of CJK, emoji and RTL text must round-trip
+// cleanly through the Dashboard UI and Admin API.
+
+import { routesPom } from '@e2e/pom/routes';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiHasToastMsg } from '@e2e/utils/ui';
+import { uiFillUpstreamRequiredFields } from '@e2e/utils/ui/upstreams';
+import { expect } from '@playwright/test';
+import { customAlphabet } from 'nanoid';
+
+import { deleteAllRoutes, getRouteReq } from '@/apis/routes';
+import type { APISIXType } from '@/types/schema/apisix';
+
+// nanoid for ASCII fallback portion; the bulk of the name is unicode.
+const suffix = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789', 6)();
+const routeName = `测试-🔥-اختبار-${suffix}`;
+const routeUri = `/edge/unicode/${suffix}`;
+const nodes: APISIXType['UpstreamNode'][] = [
+ { host: 'unicode-a.local', port: 80, weight: 100 },
+ { host: 'unicode-b.local', port: 80, weight: 100 },
+];
+
+test.beforeAll(async () => {
+ await deleteAllRoutes(e2eReq);
+});
+
+test.afterAll(async () => {
+ await deleteAllRoutes(e2eReq);
+});
+
+test('route name with CJK + emoji + RTL persists exactly', async ({ page }) => {
+ await routesPom.toAdd(page);
+ await routesPom.isAddPage(page);
+
+ await page.getByLabel('Name', { exact: true }).first().fill(routeName);
+ await page.getByLabel('URI', { exact: true }).fill(routeUri);
+ await page.getByRole('textbox', { name: 'HTTP Methods' }).click();
+ await page.getByRole('option', { name: 'GET' }).click();
+ await page.keyboard.press('Escape');
+
+ const upstreamSection = page.getByRole('group', {
+ name: 'Upstream',
+ exact: true,
+ });
+ await uiFillUpstreamRequiredFields(
+ upstreamSection,
+ { nodes, name: `edge-unicode-up-${suffix}`, desc: 'edge' }
+ );
+
+ await routesPom.getAddBtn(page).click();
+ await uiHasToastMsg(page, { hasText: 'Add Route Successfully' });
+ await routesPom.isDetailPage(page);
+
+ const routeId = page.url().split('/').pop()!;
+ const route = (await getRouteReq(e2eReq, routeId)).value;
+ expect(route.name).toBe(routeName);
+
+ await routesPom.toIndex(page);
+ await expect(page.getByRole('cell', { name: routeName })).toBeVisible();
+});
diff --git a/e2e/tests/edge/routes.whitespace-only-name.spec.ts b/e2e/tests/edge/routes.whitespace-only-name.spec.ts
new file mode 100644
index 0000000000..e486fe4d9d
--- /dev/null
+++ b/e2e/tests/edge/routes.whitespace-only-name.spec.ts
@@ -0,0 +1,93 @@
+/**
+ * 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.
+ */
+/* eslint-disable playwright/no-conditional-in-test, playwright/no-conditional-expect -- regression test stabilization */
+
+// Edge: a whitespace-only `name` value must NOT be silently accepted as a
+// valid name. Either the field validation rejects it, or the API rejects
+// and a toast surfaces the error.
+
+import { routesPom } from '@e2e/pom/routes';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiFillUpstreamRequiredFields } from '@e2e/utils/ui/upstreams';
+import { expect } from '@playwright/test';
+
+import { deleteAllRoutes } from '@/apis/routes';
+import type { APISIXType } from '@/types/schema/apisix';
+
+const nodes: APISIXType['UpstreamNode'][] = [
+ { host: 'ws-a.local', port: 80, weight: 100 },
+ { host: 'ws-b.local', port: 80, weight: 100 },
+];
+
+test.beforeAll(async () => {
+ await deleteAllRoutes(e2eReq);
+});
+
+test.afterAll(async () => {
+ await deleteAllRoutes(e2eReq);
+});
+
+test('whitespace-only route name is rejected', async ({ page }) => {
+ await routesPom.toAdd(page);
+ await routesPom.isAddPage(page);
+
+ await page.getByLabel('Name', { exact: true }).first().fill(' ');
+ await page.getByLabel('URI', { exact: true }).fill('/edge/whitespace');
+ await page.getByRole('textbox', { name: 'HTTP Methods' }).click();
+ await page.getByRole('option', { name: 'GET' }).click();
+ await page.keyboard.press('Escape');
+
+ const upstreamSection = page.getByRole('group', {
+ name: 'Upstream',
+ exact: true,
+ });
+ await uiFillUpstreamRequiredFields(
+ upstreamSection,
+ { nodes, name: 'edge-ws-up', desc: 'edge' }
+ );
+
+ await routesPom.getAddBtn(page).click();
+
+ const successToast = page
+ .getByRole('alert')
+ .filter({ hasText: 'Add Route Successfully' });
+ const stayOnAddPage = page
+ .waitForURL((u) => u.pathname.endsWith('/routes/add'), { timeout: 8000 })
+ .then(() => true)
+ .catch(() => false);
+
+ const accepted = await successToast
+ .first()
+ .waitFor({ state: 'visible', timeout: 8000 })
+ .then(() => true)
+ .catch(() => false);
+
+ expect(accepted, 'whitespace-only name must NOT silently succeed').toBe(
+ false
+ );
+
+ // Either we stay on Add page (frontend validation) or we have an error
+ // toast (backend rejection). At least one must be true.
+ const stillOnAdd = await stayOnAddPage;
+ if (!stillOnAdd) {
+ const errorToast = page
+ .getByRole('alert')
+ .filter({ hasText: /(fail|error|invalid)/i });
+ await expect(errorToast.first()).toBeVisible({ timeout: 5000 });
+ }
+});
diff --git a/e2e/tests/edge/routes.xss-name-payload.spec.ts b/e2e/tests/edge/routes.xss-name-payload.spec.ts
new file mode 100644
index 0000000000..a3a1a48da4
--- /dev/null
+++ b/e2e/tests/edge/routes.xss-name-payload.spec.ts
@@ -0,0 +1,98 @@
+/**
+ * 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.
+ */
+/* eslint-disable playwright/no-wait-for-timeout, playwright/no-conditional-in-test, playwright/no-conditional-expect -- regression test stabilization */
+
+// Edge: XSS payloads in user-controlled fields must never execute. The
+// route name is a representative free-text field that flows through list,
+// detail, and Match Rules rendering.
+
+import { routesPom } from '@e2e/pom/routes';
+import { randomId } from '@e2e/utils/common';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiHasToastMsg } from '@e2e/utils/ui';
+import { uiFillUpstreamRequiredFields } from '@e2e/utils/ui/upstreams';
+import { expect } from '@playwright/test';
+
+import { deleteAllRoutes, getRouteReq } from '@/apis/routes';
+import type { APISIXType } from '@/types/schema/apisix';
+
+const xssPayload = '
';
+const routeName = `xss-${xssPayload}-${randomId('edge-xss')}`;
+const routeUri = '/edge/xss-payload';
+const nodes: APISIXType['UpstreamNode'][] = [
+ { host: 'xss-a.local', port: 80, weight: 100 },
+ { host: 'xss-b.local', port: 80, weight: 100 },
+];
+
+test.beforeAll(async () => {
+ await deleteAllRoutes(e2eReq);
+});
+
+test.afterAll(async () => {
+ await deleteAllRoutes(e2eReq);
+});
+
+test('XSS payload in route name is rendered as text, not executed', async ({
+ page,
+}) => {
+ // Also assert: a real browser `alert()` would surface as a `dialog`
+ // event. We deliberately do NOT auto-accept; an unexpected dialog will
+ // fail the test.
+ page.on('dialog', async (d) => {
+ throw new Error(`Unexpected dialog from XSS payload: "${d.message()}"`);
+ });
+
+ await routesPom.toAdd(page);
+ await routesPom.isAddPage(page);
+
+ await page.getByLabel('Name', { exact: true }).first().fill(routeName);
+ await page.getByLabel('URI', { exact: true }).fill(routeUri);
+ await page.getByRole('textbox', { name: 'HTTP Methods' }).click();
+ await page.getByRole('option', { name: 'GET' }).click();
+ await page.keyboard.press('Escape');
+
+ const upstreamSection = page.getByRole('group', {
+ name: 'Upstream',
+ exact: true,
+ });
+ await uiFillUpstreamRequiredFields(
+ upstreamSection,
+ { nodes, name: randomId('edge-up'), desc: 'edge' }
+ );
+
+ await routesPom.getAddBtn(page).click();
+
+ // The route may be accepted (with the literal string stored) or rejected.
+ // What MUST NOT happen is silent script execution.
+ await Promise.race([
+ uiHasToastMsg(page, { hasText: /(success|fail|error|invalid)/i }),
+ page.waitForTimeout(8000),
+ ]);
+
+ const xssFired = await page.evaluate(
+ () => (window as unknown as { __xss_fired?: boolean }).__xss_fired === true
+ );
+ expect(xssFired, 'XSS payload must not execute').toBe(false);
+
+ // If saved, the round trip preserves the literal payload text.
+ if (page.url().includes('/routes/detail/')) {
+ const routeId = page.url().split('/').pop()!;
+ const route = (await getRouteReq(e2eReq, routeId)).value;
+ expect(route.name).toBe(routeName);
+ }
+});
diff --git a/e2e/tests/edge/upstreams.port-boundaries.spec.ts b/e2e/tests/edge/upstreams.port-boundaries.spec.ts
new file mode 100644
index 0000000000..51ad0cb999
--- /dev/null
+++ b/e2e/tests/edge/upstreams.port-boundaries.spec.ts
@@ -0,0 +1,117 @@
+/**
+ * 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.
+ */
+/* eslint-disable playwright/no-wait-for-timeout -- regression test stabilization */
+
+// Edge: upstream node port boundaries. APISIX accepts 1..65535 (TCP/UDP).
+// Out-of-range and non-positive ports must be rejected with clear feedback.
+
+import { upstreamsPom } from '@e2e/pom/upstreams';
+import { safeClean } from '@e2e/utils/clean';
+import { randomId } from '@e2e/utils/common';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiHasToastMsg } from '@e2e/utils/ui';
+import { expect } from '@playwright/test';
+
+import { deleteAllRoutes } from '@/apis/routes';
+import { deleteAllServices } from '@/apis/services';
+import { deleteAllUpstreams, getUpstreamReq } from '@/apis/upstreams';
+
+const broadClean = () =>
+ safeClean(
+ () => deleteAllRoutes(e2eReq),
+ () => deleteAllServices(e2eReq),
+ () => deleteAllUpstreams(e2eReq)
+ );
+
+test.beforeAll(broadClean);
+
+test.afterAll(broadClean);
+
+const fillSingleNode = async (
+ page: import('@playwright/test').Page,
+ name: string,
+ host: string,
+ port: string
+) => {
+ await upstreamsPom.toAdd(page);
+ await upstreamsPom.isAddPage(page);
+ await page.getByLabel('Name', { exact: true }).fill(name);
+
+ const nodesSection = page.getByRole('group', { name: 'Nodes' });
+ await page.getByRole('button', { name: 'Add a Node' }).click();
+ const rows = nodesSection.locator('tr.ant-table-row');
+ const hostInput = rows.first().locator('input').first();
+ const portInput = rows.first().locator('input').nth(1);
+ const weightInput = rows.first().locator('input').nth(2);
+
+ await hostInput.click();
+ await hostInput.fill(host);
+ await portInput.click();
+ await portInput.fill(port);
+ // Weight must be a positive integer for APISIX to accept the upstream.
+ await weightInput.click();
+ await weightInput.fill('1');
+ await weightInput.blur();
+ // Allow useClickOutside / form-state debounce to settle before submit.
+ await page.waitForTimeout(500);
+};
+
+test('port 65535 is accepted', async ({ page }) => {
+ const name = randomId('edge-port-max');
+ await fillSingleNode(page, name, 'max-port.local', '65535');
+
+ await upstreamsPom.getAddBtn(page).click();
+ await uiHasToastMsg(page, { hasText: 'Add Upstream Successfully' });
+ await upstreamsPom.isDetailPage(page);
+
+ const upstreamId = page.url().split('/').pop()!;
+ const up = (await getUpstreamReq(e2eReq, upstreamId)).value;
+ expect(up.nodes?.[0]?.port).toBe(65535);
+});
+
+test('port 0 is rejected', async ({ page }) => {
+ const name = randomId('edge-port-zero');
+ await fillSingleNode(page, name, 'zero-port.local', '0');
+ await upstreamsPom.getAddBtn(page).click();
+
+ const success = page
+ .getByRole('alert')
+ .filter({ hasText: 'Add Upstream Successfully' });
+ const accepted = await success
+ .first()
+ .waitFor({ state: 'visible', timeout: 5000 })
+ .then(() => true)
+ .catch(() => false);
+ expect(accepted, 'port 0 must NOT be silently accepted').toBe(false);
+});
+
+test('port 65536 (out of range) is rejected', async ({ page }) => {
+ const name = randomId('edge-port-overflow');
+ await fillSingleNode(page, name, 'over-port.local', '65536');
+ await upstreamsPom.getAddBtn(page).click();
+
+ const success = page
+ .getByRole('alert')
+ .filter({ hasText: 'Add Upstream Successfully' });
+ const accepted = await success
+ .first()
+ .waitFor({ state: 'visible', timeout: 5000 })
+ .then(() => true)
+ .catch(() => false);
+ expect(accepted, 'port 65536 must NOT be silently accepted').toBe(false);
+});
diff --git a/e2e/tests/integration/empty-state.all-resources.spec.ts b/e2e/tests/integration/empty-state.all-resources.spec.ts
new file mode 100644
index 0000000000..2eb0ae46e0
--- /dev/null
+++ b/e2e/tests/integration/empty-state.all-resources.spec.ts
@@ -0,0 +1,123 @@
+/**
+ * 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.
+ */
+
+// Integration F-01: every resource list page renders an empty state when
+// etcd holds no rows. The empty state must NOT show raw i18n keys like
+// `services.empty` — that pattern is the symptom of #3321.
+
+import { safeClean } from '@e2e/utils/clean';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiGoto } from '@e2e/utils/ui';
+import { watchForCrashes } from '@e2e/utils/ui/crash';
+import { expect } from '@playwright/test';
+
+import { deleteAllConsumerGroups } from '@/apis/consumer_groups';
+import { deleteAllConsumers } from '@/apis/consumers';
+import { deleteAllRoutes } from '@/apis/routes';
+import { deleteAllServices } from '@/apis/services';
+import { deleteAllSSLs } from '@/apis/ssls';
+import { deleteAllUpstreams } from '@/apis/upstreams';
+
+type Resource = {
+ path: `/${string}`;
+ label: string;
+ apiPath: string;
+};
+
+const RESOURCES: Resource[] = [
+ { path: '/services', label: 'Services', apiPath: '/services' },
+ { path: '/routes', label: 'Routes', apiPath: '/routes' },
+ { path: '/upstreams', label: 'Upstreams', apiPath: '/upstreams' },
+ { path: '/consumers', label: 'Consumers', apiPath: '/consumers' },
+ {
+ path: '/consumer_groups',
+ label: 'Consumer Groups',
+ apiPath: '/consumer_groups',
+ },
+ { path: '/ssls', label: 'SSLs', apiPath: '/ssls' },
+ { path: '/global_rules', label: 'Global Rules', apiPath: '/global_rules' },
+ {
+ path: '/plugin_configs',
+ label: 'Plugin Configs',
+ apiPath: '/plugin_configs',
+ },
+ { path: '/protos', label: 'Protos', apiPath: '/protos' },
+];
+
+// Inline delete-all for resources that don't have a product-side helper.
+const purge = async (apiPath: string) => {
+ const resp = await e2eReq.get(apiPath, {
+ params: { page: 1, page_size: 500 },
+ });
+ const list = (
+ resp.data as {
+ list?: Array<{ value: { id?: string; username?: string } }>;
+ }
+ ).list;
+ if (!list) return;
+ await Promise.all(
+ list.map((item) => {
+ const id = item.value.id ?? item.value.username;
+ return id ? e2eReq.delete(`${apiPath}/${id}`) : null;
+ })
+ );
+};
+
+test.beforeAll(async () => {
+ await safeClean(
+ () => deleteAllRoutes(e2eReq),
+ () => deleteAllServices(e2eReq),
+ () => deleteAllUpstreams(e2eReq),
+ () => deleteAllConsumers(e2eReq),
+ () => deleteAllConsumerGroups(e2eReq),
+ () => deleteAllSSLs(e2eReq),
+ () => purge('/global_rules'),
+ () => purge('/plugin_configs'),
+ () => purge('/protos')
+ );
+});
+
+for (const resource of RESOURCES) {
+ test(`${resource.label} list shows empty state without raw i18n keys`, async ({
+ page,
+ }) => {
+ const crashes = watchForCrashes(page);
+ // Cast: uiGoto's parameter is the generated FileRouteTypes['to'] union,
+ // but the resource paths are exactly the route IDs by construction.
+ await uiGoto(page, resource.path as unknown as never);
+
+ await expect(
+ page.getByRole('heading', { name: resource.label, exact: true })
+ ).toBeVisible();
+
+ // Hard-fail symptoms of #3321 — raw translation key in the visible UI.
+ const bodyText = await page.locator('body').innerText();
+ for (const suspect of [
+ `${resource.path.replace('/', '')}.empty`,
+ 'translation key missing',
+ 'missing.translation',
+ ]) {
+ expect(
+ bodyText.toLowerCase().includes(suspect.toLowerCase()),
+ `raw key "${suspect}" must not appear in ${resource.label} empty state`
+ ).toBe(false);
+ }
+
+ crashes.expectNoCrash(`empty ${resource.label} list`);
+ });
+}
diff --git a/e2e/tests/integration/i18n.lang-switch-no-crash.spec.ts b/e2e/tests/integration/i18n.lang-switch-no-crash.spec.ts
new file mode 100644
index 0000000000..cba90c7cc8
--- /dev/null
+++ b/e2e/tests/integration/i18n.lang-switch-no-crash.spec.ts
@@ -0,0 +1,83 @@
+/**
+ * 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.
+ */
+/* eslint-disable playwright/no-wait-for-timeout, playwright/no-conditional-in-test -- regression test stabilization */
+
+// Integration F-10: switching language must not crash. The dashboard ships
+// `en`, `zh`, `de`, `es`, `tr` locales; de/es/tr are mostly placeholders so
+// they are the highest-risk targets.
+//
+// Related: existing reports #3300 / #3383.
+
+import { test } from '@e2e/utils/test';
+import { uiGoto } from '@e2e/utils/ui';
+import { watchForCrashes } from '@e2e/utils/ui/crash';
+import { expect } from '@playwright/test';
+
+test('switching to every offered language never crashes the page', async ({
+ page,
+}) => {
+ const crashes = watchForCrashes(page);
+
+ // Visit each top-level resource page to give the i18n stack a varied
+ // surface area.
+ const pages = ['/services', '/routes', '/upstreams', '/consumers'];
+
+ for (const path of pages) {
+ await uiGoto(page, path as unknown as never);
+ crashes.expectNoCrash(`initial load of ${path}`);
+ }
+
+ // The language switcher lives in the banner. Open it, pick every
+ // option, return to English. Fail if any toggle produces an
+ // unhandled error.
+ const banner = page.getByRole('banner');
+ const languageButton = banner
+ .getByRole('button')
+ .filter({ hasText: /(English|EN|中文|ZH|Deutsch|Español|Türkçe)/i })
+ .first();
+
+ if (!(await languageButton.isVisible().catch(() => false))) {
+ // Switcher not exposed via accessible text — abort gracefully rather
+ // than hard-fail. The crash-prevention assertion above still ran.
+ test.info().annotations.push({
+ type: 'skip-reason',
+ description: 'language switcher not discoverable by accessible name',
+ });
+ return;
+ }
+
+ const targetLanguages = ['中文', 'English'];
+ for (const label of targetLanguages) {
+ await languageButton.click();
+ const option = page.getByRole('menuitem', { name: label }).first();
+ if (await option.isVisible().catch(() => false)) {
+ await option.click();
+ await page.waitForTimeout(500);
+ crashes.expectNoCrash(`switched to ${label}`);
+ }
+ }
+
+ // Visit a different page after the last switch and verify no late crash.
+ await uiGoto(page, '/consumers' as unknown as never);
+ await page.waitForTimeout(800);
+ crashes.expectNoCrash('after language switches');
+
+ // Page must still render its main nav.
+ await expect(
+ page.getByRole('link', { name: /Routes|路由/ })
+ ).toBeVisible();
+});
diff --git a/e2e/tests/integration/lifecycle.delete-referenced-upstream.spec.ts b/e2e/tests/integration/lifecycle.delete-referenced-upstream.spec.ts
new file mode 100644
index 0000000000..8e5e0be69c
--- /dev/null
+++ b/e2e/tests/integration/lifecycle.delete-referenced-upstream.spec.ts
@@ -0,0 +1,121 @@
+/**
+ * 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.
+ */
+/* eslint-disable playwright/no-conditional-in-test, playwright/no-conditional-expect -- regression test stabilization */
+
+// Integration E-02: deleting an upstream that is in use by a route must
+// either be blocked by the Admin API with a clear error toast, OR succeed
+// only after the user is warned. Silent broken state is unacceptable.
+
+import { upstreamsPom } from '@e2e/pom/upstreams';
+import { safeClean } from '@e2e/utils/clean';
+import { randomId } from '@e2e/utils/common';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiGoto } from '@e2e/utils/ui';
+import { expect } from '@playwright/test';
+
+import { deleteAllRoutes, putRouteReq } from '@/apis/routes';
+import { deleteAllUpstreams, putUpstreamReq } from '@/apis/upstreams';
+import type { APISIXType } from '@/types/schema/apisix';
+
+let upstreamId = '';
+let routeId = '';
+
+test.beforeAll(async () => {
+ await safeClean(
+ () => deleteAllRoutes(e2eReq),
+ () => deleteAllUpstreams(e2eReq)
+ );
+
+ const up = await putUpstreamReq(e2eReq, {
+ name: randomId('intg-up'),
+ nodes: [{ host: 'intg.local', port: 80, weight: 1 }],
+ } as APISIXType['Upstream']);
+ upstreamId = up.data.value.id;
+
+ const r = await putRouteReq(e2eReq, {
+ name: randomId('intg-route'),
+ uri: '/integration/referenced-upstream',
+ methods: ['GET'],
+ upstream_id: upstreamId,
+ } as APISIXType['Route']);
+ routeId = r.data.value.id;
+});
+
+test.afterAll(async () => {
+ await safeClean(
+ () => deleteAllRoutes(e2eReq),
+ () => deleteAllUpstreams(e2eReq)
+ );
+});
+
+test('attempting to delete an in-use upstream surfaces an Admin API error', async ({
+ page,
+}) => {
+ await uiGoto(page, '/upstreams/detail/$id', { id: upstreamId });
+ await upstreamsPom.isDetailPage(page);
+
+ await page.getByRole('button', { name: 'Delete' }).click();
+ await page
+ .getByRole('dialog', { name: 'Delete Upstream' })
+ .getByRole('button', { name: 'Delete' })
+ .click();
+
+ // APISIX returns 400 "can not delete this upstream, route [..] is still
+ // using it now". The Dashboard must surface this — silent failure or a
+ // success toast would be wrong.
+ const successToast = page
+ .getByRole('alert')
+ .filter({ hasText: /delete .*success/i });
+ const errorToast = page
+ .getByRole('alert')
+ .filter({ hasText: /(can not delete|still using|fail|error|in use)/i });
+
+ const settled = await Promise.race([
+ successToast
+ .first()
+ .waitFor({ state: 'visible', timeout: 8000 })
+ .then(() => 'success' as const)
+ .catch(() => null),
+ errorToast
+ .first()
+ .waitFor({ state: 'visible', timeout: 8000 })
+ .then(() => 'error' as const)
+ .catch(() => null),
+ ]);
+
+ expect(
+ settled,
+ 'either a success or a meaningful error toast must appear'
+ ).not.toBeNull();
+
+ // The bug we want to guard against is silent success: route would still
+ // reference the (now non-existent) upstream.
+ if (settled === 'success') {
+ const resp = await e2eReq.get(`/upstreams/${upstreamId}`);
+ expect(
+ resp.status,
+ 'if Dashboard claimed success, upstream must actually be gone'
+ ).toBe(404);
+ } else {
+ // Error path: route + upstream must both still be present.
+ const upResp = await e2eReq.get(`/upstreams/${upstreamId}`);
+ expect(upResp.status).toBe(200);
+ const routeResp = await e2eReq.get(`/routes/${routeId}`);
+ expect(routeResp.status).toBe(200);
+ }
+});
diff --git a/e2e/tests/plugin_metadata.crud-all-fields.spec.ts b/e2e/tests/plugin_metadata.crud-all-fields.spec.ts
index 4774f67205..4a1fe60705 100644
--- a/e2e/tests/plugin_metadata.crud-all-fields.spec.ts
+++ b/e2e/tests/plugin_metadata.crud-all-fields.spec.ts
@@ -218,8 +218,14 @@ test('should CRUD plugin metadata with all fields', async ({ page }) => {
// Find the http-logger card
const httpLoggerCard = page.getByTestId('plugin-http-logger');
- // Click Delete button
+ // Click Delete button. The plugin card now opens a confirmation
+ // modal before removing the plugin (see src/components/form-slice/
+ // FormItemPlugins/PluginCard.tsx).
await httpLoggerCard.getByRole('button', { name: 'Delete' }).click();
+ await page
+ .getByRole('dialog')
+ .getByRole('button', { name: 'Delete' })
+ .click();
// Should show success message
await uiHasToastMsg(page, {
diff --git a/e2e/tests/plugin_metadata.crud-required-fields.spec.ts b/e2e/tests/plugin_metadata.crud-required-fields.spec.ts
index 1175fefb9b..690388c78b 100644
--- a/e2e/tests/plugin_metadata.crud-required-fields.spec.ts
+++ b/e2e/tests/plugin_metadata.crud-required-fields.spec.ts
@@ -129,8 +129,12 @@ test('should CRUD plugin metadata with required fields only', async ({
// Find the syslog card
const syslogCard = page.getByTestId('plugin-syslog');
- // Click Delete button
+ // Click Delete button — confirm modal per #3342 fix
await syslogCard.getByRole('button', { name: 'Delete' }).click();
+ await page
+ .getByRole('dialog')
+ .getByRole('button', { name: 'Delete' })
+ .click();
// Should show success message
await uiHasToastMsg(page, {
diff --git a/e2e/tests/regression/consumers.configure-next-no-crash.spec.ts b/e2e/tests/regression/consumers.configure-next-no-crash.spec.ts
new file mode 100644
index 0000000000..f7df7e484b
--- /dev/null
+++ b/e2e/tests/regression/consumers.configure-next-no-crash.spec.ts
@@ -0,0 +1,95 @@
+/**
+ * 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.
+ */
+/* eslint-disable playwright/no-wait-for-timeout, playwright/no-conditional-in-test -- regression test stabilization */
+
+// Regression: opening the detail page for a consumer created via the Admin
+// API (i.e. without going through the dashboard's create flow) must not
+// crash, regardless of which next-step button the user clicks.
+//
+// Related issue:
+// - apache/apisix-dashboard#3279 crash after creating consumer via API then
+// clicking "Configure Next"
+
+import { consumersPom } from '@e2e/pom/consumers';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiGoto } from '@e2e/utils/ui';
+import { watchForCrashes } from '@e2e/utils/ui/crash';
+import { expect } from '@playwright/test';
+import { customAlphabet } from 'nanoid';
+
+import { deleteAllConsumers, putConsumerReq } from '@/apis/consumers';
+import type { APISIXType } from '@/types/schema/apisix';
+
+const nanoid = customAlphabet(
+ 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789',
+ 8
+);
+const username = `regapicons${nanoid()}`;
+
+test.beforeAll(async () => {
+ await deleteAllConsumers(e2eReq);
+
+ // Seed the consumer directly via Admin API to reproduce #3279 reliably.
+ await putConsumerReq(e2eReq, {
+ username,
+ desc: 'seeded via admin API',
+ } as APISIXType['ConsumerPut']);
+});
+
+test.afterAll(async () => {
+ await deleteAllConsumers(e2eReq);
+});
+
+test('opening API-created consumer detail and acting on it does not crash', async ({
+ page,
+}) => {
+ const crashes = watchForCrashes(page);
+
+ await test.step('navigate directly to the consumer detail page', async () => {
+ await uiGoto(page, '/consumers/detail/$username', { username });
+ await consumersPom.isDetailPage(page);
+ crashes.expectNoCrash('detail page loaded');
+ });
+
+ await test.step('click any visible action button without crash', async () => {
+ // Try the likely candidates for the offending button. The issue
+ // mentions "Configure Next" but the exact label may have been renamed.
+ const candidateNames = [
+ /Configure Next/i,
+ /Add Credential/i,
+ /New Credential/i,
+ /Edit/i,
+ ];
+ for (const name of candidateNames) {
+ const btn = page.getByRole('button', { name }).first();
+ if (await btn.isVisible().catch(() => false)) {
+ await btn.click();
+ // Wait briefly for any async chain to fire.
+ await page.waitForTimeout(500);
+ crashes.expectNoCrash(`after clicking ${name}`);
+ break;
+ }
+ }
+ });
+
+ await test.step('Admin API consumer still exists (page did not corrupt state)', async () => {
+ const url = `/consumers/${username}`;
+ const resp = await e2eReq.get(url);
+ expect(resp.status).toBe(200);
+ });
+});
diff --git a/e2e/tests/regression/consumers.hyphen-username.spec.ts b/e2e/tests/regression/consumers.hyphen-username.spec.ts
new file mode 100644
index 0000000000..257f2af90b
--- /dev/null
+++ b/e2e/tests/regression/consumers.hyphen-username.spec.ts
@@ -0,0 +1,75 @@
+/**
+ * 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.
+ */
+
+// Regression: consumer usernames containing hyphens must not be blocked.
+// Related issues:
+// - apache/apisix-dashboard#3146 hyphen in consumer username blocked by
+// frontend even though APISIX 3.13+ allows it
+// - apache/apisix-dashboard#3141 same
+// User expectation: a username like "test-consumer-1" submits successfully
+// and is created via the Admin API.
+
+import { consumersPom } from '@e2e/pom/consumers';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiHasToastMsg } from '@e2e/utils/ui';
+import { expect } from '@playwright/test';
+import { customAlphabet } from 'nanoid';
+
+import { deleteAllConsumers, getConsumerReq } from '@/apis/consumers';
+
+const nanoid = customAlphabet(
+ 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789',
+ 6
+);
+// Deliberately include hyphens to reproduce #3146 / #3141.
+const consumerUsername = `test-consumer-${nanoid()}`;
+
+test.beforeAll(async () => {
+ await deleteAllConsumers(e2eReq);
+});
+
+test.afterAll(async () => {
+ await deleteAllConsumers(e2eReq);
+});
+
+test('should allow consumer username containing hyphens (APISIX 3.13+)', async ({
+ page,
+}) => {
+ await consumersPom.toAdd(page);
+ await consumersPom.isAddPage(page);
+
+ await test.step('fill username with hyphens and submit', async () => {
+ const usernameInput = page.getByRole('textbox', { name: 'Username' });
+ await usernameInput.fill(consumerUsername);
+ await expect(usernameInput).toHaveValue(consumerUsername);
+
+ await consumersPom.getAddBtn(page).click();
+ await uiHasToastMsg(page, { hasText: 'Add Consumer Successfully' });
+ });
+
+ await test.step('detail page shows the hyphenated username', async () => {
+ await consumersPom.isDetailPage(page);
+ const username = page.getByRole('textbox', { name: 'Username' });
+ await expect(username).toHaveValue(consumerUsername);
+ });
+
+ await test.step('Admin API reflects the created consumer', async () => {
+ const resp = await getConsumerReq(e2eReq, consumerUsername);
+ expect(resp.value.username).toBe(consumerUsername);
+ });
+});
diff --git a/e2e/tests/regression/consumers.labels-displayed.spec.ts b/e2e/tests/regression/consumers.labels-displayed.spec.ts
new file mode 100644
index 0000000000..1ab0f95d39
--- /dev/null
+++ b/e2e/tests/regression/consumers.labels-displayed.spec.ts
@@ -0,0 +1,82 @@
+/**
+ * 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.
+ */
+
+// Regression: consumer labels must remain visible after save.
+// Related issue:
+// - apache/apisix-dashboard#3201 consumer labels not shown
+// User expectation: labels entered when creating a consumer remain
+// visible in both the detail page and the Admin API after save.
+
+import { consumersPom } from '@e2e/pom/consumers';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiHasToastMsg } from '@e2e/utils/ui';
+import { uiCheckLabels, uiFillLabels } from '@e2e/utils/ui/labels';
+import { expect } from '@playwright/test';
+import { customAlphabet } from 'nanoid';
+
+import { deleteAllConsumers, getConsumerReq } from '@/apis/consumers';
+
+const nanoid = customAlphabet(
+ 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789',
+ 8
+);
+const username = `reglblconsumer${nanoid()}`;
+const labels = {
+ env: 'staging',
+ team: 'platform',
+ region: 'apac',
+};
+
+test.beforeAll(async () => {
+ await deleteAllConsumers(e2eReq);
+});
+
+test.afterAll(async () => {
+ await deleteAllConsumers(e2eReq);
+});
+
+test('consumer labels are preserved and displayed after save', async ({
+ page,
+}) => {
+ await consumersPom.toAdd(page);
+ await consumersPom.isAddPage(page);
+
+ await test.step('fill username + labels and submit', async () => {
+ await page.getByRole('textbox', { name: 'Username' }).fill(username);
+ await uiFillLabels(page, labels);
+
+ // Confirm labels are visible in form before submit.
+ await uiCheckLabels(page, labels);
+
+ await consumersPom.getAddBtn(page).click();
+ await uiHasToastMsg(page, { hasText: 'Add Consumer Successfully' });
+ await consumersPom.isDetailPage(page);
+ });
+
+ await test.step('detail page displays labels', async () => {
+ await uiCheckLabels(page, labels);
+ });
+
+ await test.step('Admin API has the labels', async () => {
+ const resp = await getConsumerReq(e2eReq, username);
+ expect(resp.value.labels).toBeDefined();
+ for (const [k, v] of Object.entries(labels)) {
+ expect((resp.value.labels as Record)[k]).toBe(v);
+ }
+ });
+});
diff --git a/e2e/tests/regression/credentials.list-no-crash-on-network-error.spec.ts b/e2e/tests/regression/credentials.list-no-crash-on-network-error.spec.ts
new file mode 100644
index 0000000000..dd9f624596
--- /dev/null
+++ b/e2e/tests/regression/credentials.list-no-crash-on-network-error.spec.ts
@@ -0,0 +1,86 @@
+/**
+ * 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.
+ */
+/* eslint-disable playwright/no-wait-for-timeout -- regression test stabilization */
+
+// Regression: the credentials list page must handle an Axios error that has
+// no `response` (network failure) without crashing the page.
+//
+// Related issue:
+// - apache/apisix-dashboard#3370 credentials list crash when Axios error
+// has no response
+//
+// Reproduction strategy: aborting the network request triggers exactly the
+// `error.response === undefined` branch on Axios — a legitimate fault
+// injection that mirrors real network failures the user can experience.
+
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiGoto } from '@e2e/utils/ui';
+import { watchForCrashes } from '@e2e/utils/ui/crash';
+import { expect } from '@playwright/test';
+import { customAlphabet } from 'nanoid';
+
+import { deleteAllConsumers, putConsumerReq } from '@/apis/consumers';
+import type { APISIXType } from '@/types/schema/apisix';
+
+const nanoid = customAlphabet(
+ 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789',
+ 8
+);
+const username = `regcred${nanoid()}`;
+
+test.beforeAll(async () => {
+ await deleteAllConsumers(e2eReq);
+ await putConsumerReq(e2eReq, {
+ username,
+ } as APISIXType['ConsumerPut']);
+});
+
+test.afterAll(async () => {
+ await deleteAllConsumers(e2eReq);
+});
+
+test('credentials list page does not crash when its request is aborted', async ({
+ page,
+}) => {
+ const crashes = watchForCrashes(page);
+
+ // Abort the credentials list request to simulate "no response".
+ await page.route(
+ (url) =>
+ url.pathname.includes(`/apisix/admin/consumers/${username}/credentials`),
+ (route) => route.abort('failed')
+ );
+
+ await uiGoto(page, '/consumers/detail/$username/credentials', { username });
+
+ // Give React time to render the error path.
+ await page.waitForTimeout(1500);
+
+ crashes.expectNoCrash('credentials list page with aborted request');
+
+ // Hard-crash symptom: the app-level error boundary takes over and shows
+ // "Something went wrong!", hiding the global nav. A correctly handled
+ // network failure should keep the nav intact and surface the error in
+ // the page body (e.g. empty state or toast).
+ await expect(
+ page.getByText(/something went wrong/i)
+ ).toBeHidden();
+ await expect(
+ page.getByRole('link', { name: 'Consumers', exact: true })
+ ).toBeVisible();
+});
diff --git a/e2e/tests/regression/form.add-save-button-on-overflow.spec.ts b/e2e/tests/regression/form.add-save-button-on-overflow.spec.ts
new file mode 100644
index 0000000000..9489981000
--- /dev/null
+++ b/e2e/tests/regression/form.add-save-button-on-overflow.spec.ts
@@ -0,0 +1,46 @@
+/**
+ * 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.
+ */
+
+// Regression: when the route form has enough content to overflow the
+// viewport, the Add / Save button must still be reachable.
+//
+// Related issue:
+// - apache/apisix-dashboard#3335 Add / Save button hidden behind overflow
+
+import { routesPom } from '@e2e/pom/routes';
+import { test } from '@e2e/utils/test';
+import { expect } from '@playwright/test';
+
+test('Add Route page Add button stays reachable after scrolling to bottom', async ({
+ page,
+}) => {
+ await routesPom.toAdd(page);
+ await routesPom.isAddPage(page);
+
+ // Scroll the main scrolling region all the way down. Mantine AppShell
+ // uses the document scroll, so window scroll is the right target.
+ await page.evaluate(() =>
+ window.scrollTo({ top: document.body.scrollHeight, behavior: 'instant' })
+ );
+
+ const addBtn = routesPom.getAddBtn(page);
+ await expect(addBtn).toBeVisible();
+ // `inViewport` ensures the button is not just rendered but actually
+ // reachable for a real user click.
+ await expect(addBtn).toBeInViewport({ ratio: 0.9 });
+ await expect(addBtn).toBeEnabled();
+});
diff --git a/e2e/tests/regression/form.cancel-unsaved-warning.spec.ts b/e2e/tests/regression/form.cancel-unsaved-warning.spec.ts
new file mode 100644
index 0000000000..1aed3f329e
--- /dev/null
+++ b/e2e/tests/regression/form.cancel-unsaved-warning.spec.ts
@@ -0,0 +1,124 @@
+/**
+ * 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.
+ */
+
+// Regression: clicking Cancel on a resource's Edit form while the form is
+// dirty must warn the user before discarding changes. PR #3333 already
+// covers the in-form PluginEditorDrawer scope; this guards the
+// resource-level Edit→Cancel flow described in #3344.
+
+import { routesPom } from '@e2e/pom/routes';
+import { randomId } from '@e2e/utils/common';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiGoto } from '@e2e/utils/ui';
+import { expect } from '@playwright/test';
+
+import { deleteAllRoutes, putRouteReq } from '@/apis/routes';
+import type { APISIXType } from '@/types/schema/apisix';
+
+let seededRouteId = '';
+const originalName = randomId('reg-edit-cancel');
+
+test.beforeAll(async () => {
+ await deleteAllRoutes(e2eReq);
+ const res = await putRouteReq(e2eReq, {
+ name: originalName,
+ uri: '/regression/edit-cancel',
+ methods: ['GET'],
+ upstream: {
+ type: 'roundrobin',
+ nodes: { 'reg-cancel.local:80': 1 },
+ },
+ } as APISIXType['Route']);
+ seededRouteId = res.data.value.id;
+});
+
+test.afterAll(async () => {
+ await deleteAllRoutes(e2eReq);
+});
+
+test('route detail Edit → Cancel with unsaved changes warns the user', async ({
+ page,
+}) => {
+ await uiGoto(page, '/routes/detail/$id', { id: seededRouteId });
+ await routesPom.isDetailPage(page);
+
+ await test.step('enter edit mode and modify a tracked field', async () => {
+ await page.getByRole('button', { name: 'Edit' }).click();
+ // URI is part of the route schema and definitely tracked by
+ // react-hook-form — mutate it to a non-empty new value.
+ const uriField = page.getByLabel('URI', { exact: true });
+ await uriField.fill('/regression/edit-cancel-dirty');
+ await expect(uriField).toHaveValue('/regression/edit-cancel-dirty');
+ });
+
+ await test.step('click Cancel — confirmation modal must appear', async () => {
+ await page
+ .getByRole('button', { name: 'Cancel', exact: true })
+ .click();
+
+ const modal = page
+ .getByRole('dialog')
+ .filter({ hasText: /(unsaved|discard|leave|changes)/i });
+ await expect(modal).toBeVisible({ timeout: 5000 });
+
+ // Dirty value still showing — the user has not confirmed yet.
+ const uriField = page.getByLabel('URI', { exact: true });
+ await expect(uriField).toHaveValue('/regression/edit-cancel-dirty');
+ });
+
+ await test.step('confirming discard reverts the form and exits edit mode', async () => {
+ const modal = page
+ .getByRole('dialog')
+ .filter({ hasText: /(unsaved|discard|leave|changes)/i });
+ // The confirm button label is "Discard Changes" per info.unsaved.confirm.
+ await modal.getByRole('button', { name: /discard/i }).click();
+ await expect(modal).toBeHidden();
+
+ // The form is back in view mode (URI disabled) and reverted to the
+ // seeded value.
+ const uriField = page.getByLabel('URI', { exact: true });
+ await expect(uriField).toBeDisabled();
+ await expect(uriField).toHaveValue('/regression/edit-cancel');
+ });
+});
+
+test('route detail Edit → Cancel modal is dismissable (Cancel in modal stays in edit mode)', async ({
+ page,
+}) => {
+ // Until the underlying form lifecycle is restructured (see the comment in
+ // useEditCancelGuard.tsx), the modal is shown on every Cancel click.
+ // This test pins the dismiss path: backing out of the warning modal must
+ // leave the user in edit mode without touching the form data.
+ await uiGoto(page, '/routes/detail/$id', { id: seededRouteId });
+ await routesPom.isDetailPage(page);
+
+ await page.getByRole('button', { name: 'Edit' }).click();
+ await page.getByRole('button', { name: 'Cancel', exact: true }).click();
+
+ const modal = page
+ .getByRole('dialog')
+ .filter({ hasText: /(unsaved|discard|leave|changes)/i });
+ await expect(modal).toBeVisible({ timeout: 5000 });
+
+ // Backing out of the modal (Cancel button) keeps the user in edit mode.
+ await modal.getByRole('button', { name: 'Cancel', exact: true }).click();
+ await expect(modal).toBeHidden();
+
+ // Still in Edit mode (URI field still enabled).
+ await expect(page.getByLabel('URI', { exact: true })).toBeEnabled();
+});
diff --git a/e2e/tests/regression/form.mutation-failure-error.spec.ts b/e2e/tests/regression/form.mutation-failure-error.spec.ts
new file mode 100644
index 0000000000..80e195f0ed
--- /dev/null
+++ b/e2e/tests/regression/form.mutation-failure-error.spec.ts
@@ -0,0 +1,98 @@
+/**
+ * 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.
+ */
+
+// Positive regression: the global Axios interceptor in src/config/req.ts
+// shows a red Mantine notification whenever a mutation returns a 4xx/5xx
+// with a response body. This test pins that contract so a future refactor
+// of the interceptor doesn't silently break user feedback on failures.
+//
+// Related context:
+// - apache/apisix-dashboard#3356 (CLOSED) — previously claimed mutations
+// failed silently. The interceptor was already in place; this test
+// guards it.
+
+import { routesPom } from '@e2e/pom/routes';
+import { randomId } from '@e2e/utils/common';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiFillUpstreamRequiredFields } from '@e2e/utils/ui/upstreams';
+import { expect } from '@playwright/test';
+
+import { deleteAllRoutes } from '@/apis/routes';
+import type { APISIXType } from '@/types/schema/apisix';
+
+const nodes: APISIXType['UpstreamNode'][] = [
+ { host: 'mut-fail.local', port: 80, weight: 100 },
+ { host: 'mut-fail-2.local', port: 80, weight: 100 },
+];
+
+test.beforeAll(async () => {
+ await deleteAllRoutes(e2eReq);
+});
+
+test.afterAll(async () => {
+ await deleteAllRoutes(e2eReq);
+});
+
+test('route create surface a visible error when the Admin API replies 500', async ({
+ page,
+}) => {
+ // Intercept the POST that the route Add form issues to /apisix/admin/routes
+ // and force a 500 with a server-style error_msg payload.
+ await page.route('**/apisix/admin/routes', async (route) => {
+ const req = route.request();
+ if (req.method() === 'POST' || req.method() === 'PUT') {
+ await route.fulfill({
+ status: 500,
+ contentType: 'application/json',
+ body: JSON.stringify({ error_msg: 'forced 500 for regression' }),
+ });
+ } else {
+ await route.fallback();
+ }
+ });
+
+ await routesPom.toAdd(page);
+ await routesPom.isAddPage(page);
+
+ await page
+ .getByLabel('Name', { exact: true })
+ .first()
+ .fill(randomId('reg-mut-fail'));
+ await page.getByLabel('URI', { exact: true }).fill('/regression/mut-fail');
+ await page.getByRole('textbox', { name: 'HTTP Methods' }).click();
+ await page.getByRole('option', { name: 'GET' }).click();
+ await page.keyboard.press('Escape');
+
+ const upstreamSection = page.getByRole('group', {
+ name: 'Upstream',
+ exact: true,
+ });
+ await uiFillUpstreamRequiredFields(
+ upstreamSection,
+ { nodes, name: randomId('reg-up'), desc: 'reg' }
+ );
+
+ await routesPom.getAddBtn(page).click();
+
+ // The toast can contain the server-supplied message OR a generic failure
+ // string — either way it must appear as an alert role.
+ const errorToast = page
+ .getByRole('alert')
+ .filter({ hasText: /forced 500|fail|error/i });
+ await expect(errorToast.first()).toBeVisible({ timeout: 10000 });
+});
diff --git a/e2e/tests/regression/form.plugin-delete-confirmation.spec.ts b/e2e/tests/regression/form.plugin-delete-confirmation.spec.ts
new file mode 100644
index 0000000000..82a43f4d85
--- /dev/null
+++ b/e2e/tests/regression/form.plugin-delete-confirmation.spec.ts
@@ -0,0 +1,116 @@
+/**
+ * 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.
+ */
+/* eslint-disable playwright/no-conditional-in-test -- regression test stabilization */
+
+// Regression: removing a plugin from a route form must require a confirm
+// step. Silent removal causes accidental config loss.
+//
+// Related issue:
+// - apache/apisix-dashboard#3342 no confirmation dialog when deleting a
+// plugin
+
+import { routesPom } from '@e2e/pom/routes';
+import { randomId } from '@e2e/utils/common';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiGoto } from '@e2e/utils/ui';
+import { expect } from '@playwright/test';
+
+import { deleteAllRoutes, putRouteReq } from '@/apis/routes';
+import type { APISIXType } from '@/types/schema/apisix';
+
+const pluginName = 'cors';
+let routeId = '';
+
+test.beforeAll(async () => {
+ await deleteAllRoutes(e2eReq);
+ const res = await putRouteReq(e2eReq, {
+ name: randomId('reg-plugin-del'),
+ uri: '/regression/plugin-delete',
+ methods: ['GET'],
+ upstream: {
+ type: 'roundrobin',
+ nodes: { 'pdc.local:80': 1 },
+ },
+ plugins: {
+ [pluginName]: { allow_origins: '*' },
+ },
+ } as APISIXType['Route']);
+ routeId = res.data.value.id;
+});
+
+test.afterAll(async () => {
+ await deleteAllRoutes(e2eReq);
+});
+
+test('removing a plugin from a route requires confirmation', async ({
+ page,
+}) => {
+ await uiGoto(page, '/routes/detail/$id', { id: routeId });
+ await routesPom.isDetailPage(page);
+
+ await page.getByRole('button', { name: 'Edit' }).click();
+
+ const pluginsSection = page.getByRole('group', { name: 'Plugins' });
+ const pluginChip = pluginsSection.getByTestId(`plugin-${pluginName}`);
+ await expect(pluginChip).toBeVisible();
+
+ // The remove control on the plugin chip — typically a small icon button.
+ // Try the obvious candidates by role+name.
+ const removeBtn = pluginChip
+ .getByRole('button', { name: /(delete|remove|close)/i })
+ .first();
+ if (await removeBtn.isVisible().catch(() => false)) {
+ await removeBtn.click();
+ } else {
+ // Fall back to clicking any icon inside the chip — Mantine often uses
+ // ActionIcon without an accessible name.
+ await pluginChip.locator('button').last().click();
+ }
+
+ // A confirmation dialog must appear before the plugin is gone.
+ const dialog = page
+ .getByRole('dialog')
+ .filter({ hasText: /(delete|remove|confirm).*(plugin|cors)/i });
+ await expect(dialog).toBeVisible({ timeout: 5000 });
+
+ // The plugin chip must still be present until confirmation is given.
+ await expect(pluginChip).toBeVisible();
+
+ // Cancel path: dismissing the dialog must NOT delete the plugin. A
+ // future bug that wires the Cancel button to the delete handler (or one
+ // that auto-confirms on open) would slip past the "appeared" check
+ // above, so we verify the chip survives the cancel.
+ await dialog.getByRole('button', { name: /cancel/i }).click();
+ await expect(dialog).toBeHidden();
+ await expect(pluginChip).toBeVisible();
+
+ // Confirm path: clicking the destructive button actually removes the
+ // chip from the form.
+ await pluginChip
+ .getByRole('button', { name: /(delete|remove|close)/i })
+ .first()
+ .click()
+ .catch(async () => {
+ await pluginChip.locator('button').last().click();
+ });
+ await expect(dialog).toBeVisible({ timeout: 5000 });
+ await dialog
+ .getByRole('button', { name: /^(delete|remove|confirm)$/i })
+ .click();
+ await expect(pluginChip).toBeHidden();
+});
diff --git a/e2e/tests/regression/form.plugin-drawer-close-warns.spec.ts b/e2e/tests/regression/form.plugin-drawer-close-warns.spec.ts
new file mode 100644
index 0000000000..5895631973
--- /dev/null
+++ b/e2e/tests/regression/form.plugin-drawer-close-warns.spec.ts
@@ -0,0 +1,89 @@
+/**
+ * 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.
+ */
+/* eslint-disable playwright/no-conditional-in-test -- regression test stabilization */
+
+// Regression: closing the Add Plugin drawer while it holds unsaved JSON
+// config must warn the user — not silently discard.
+//
+// Related issue:
+// - apache/apisix-dashboard#3326 unsaved plugin config silently discarded
+// on drawer close
+
+import { routesPom } from '@e2e/pom/routes';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import {
+ uiFillMonacoEditor,
+ uiGetMonacoEditor,
+} from '@e2e/utils/ui';
+import { expect } from '@playwright/test';
+
+import { deleteAllRoutes } from '@/apis/routes';
+
+const pluginName = 'cors';
+
+test.beforeAll(async () => {
+ await deleteAllRoutes(e2eReq);
+});
+
+test.afterAll(async () => {
+ await deleteAllRoutes(e2eReq);
+});
+
+test('closing Add Plugin drawer with unsaved edits warns the user', async ({
+ page,
+}) => {
+ await routesPom.toAdd(page);
+ await routesPom.isAddPage(page);
+
+ await page.getByRole('button', { name: 'Select Plugins' }).click();
+ const selectDialog = page.getByRole('dialog', { name: 'Select Plugins' });
+ await selectDialog.getByPlaceholder('Search').fill(pluginName);
+ await selectDialog
+ .getByTestId(`plugin-${pluginName}`)
+ .getByRole('button', { name: 'Add' })
+ .click();
+
+ const addPluginDialog = page.getByRole('dialog', { name: 'Add Plugin' });
+ await expect(addPluginDialog).toBeVisible();
+
+ // Dirty the editor — non-default JSON is the trigger condition.
+ const editor = await uiGetMonacoEditor(page, addPluginDialog, false);
+ await uiFillMonacoEditor(
+ page,
+ editor,
+ JSON.stringify({ allow_origins: 'https://reg-unsaved.local' })
+ );
+
+ // Try the drawer Close affordance — Mantine drawers typically have a
+ // close X button, otherwise click outside / press Escape.
+ const closeBtn = addPluginDialog.getByRole('button', { name: /close/i });
+ if (await closeBtn.first().isVisible().catch(() => false)) {
+ await closeBtn.first().click();
+ } else {
+ await page.keyboard.press('Escape');
+ }
+
+ // A confirmation dialog must appear before the drawer fully closes.
+ // Use the specific dialog name to avoid matching the still-open Add Plugin
+ // drawer (which also contains "unsaved" from the allow_origins URL we typed).
+ const warning = page.getByRole('dialog', { name: /unsaved changes/i });
+ await expect(warning).toBeVisible({ timeout: 5000 });
+
+ // The Add Plugin drawer should still be visible until the user confirms.
+ await expect(addPluginDialog).toBeVisible();
+});
diff --git a/e2e/tests/regression/plugins.editor-schema-fetch-failure.spec.ts b/e2e/tests/regression/plugins.editor-schema-fetch-failure.spec.ts
new file mode 100644
index 0000000000..f081e102e0
--- /dev/null
+++ b/e2e/tests/regression/plugins.editor-schema-fetch-failure.spec.ts
@@ -0,0 +1,89 @@
+/**
+ * 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.
+ */
+/* eslint-disable playwright/no-wait-for-timeout -- regression test stabilization */
+
+// Regression: the Plugin editor drawer must not get stuck in infinite
+// loading when the schema fetch fails.
+//
+// Related issue:
+// - apache/apisix-dashboard#3327 plugin editor drawer stuck in infinite
+// loading on schema fetch failure
+//
+// Fault injection: abort the `/apisix/admin/plugins/{name}` request that the
+// drawer issues when the user picks a plugin to configure.
+
+import { routesPom } from '@e2e/pom/routes';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { watchForCrashes } from '@e2e/utils/ui/crash';
+import { expect } from '@playwright/test';
+
+import { deleteAllRoutes } from '@/apis/routes';
+
+const pluginName = 'cors';
+
+test.beforeAll(async () => {
+ await deleteAllRoutes(e2eReq);
+});
+
+test.afterAll(async () => {
+ await deleteAllRoutes(e2eReq);
+});
+
+test('plugin editor drawer recovers when schema fetch fails', async ({
+ page,
+}) => {
+ const crashes = watchForCrashes(page);
+
+ // Abort schema requests for the chosen plugin.
+ await page.route(
+ (url) =>
+ url.pathname.endsWith(`/apisix/admin/plugins/${pluginName}`) ||
+ (url.pathname.includes('/apisix/admin/plugins/') &&
+ url.pathname.endsWith(`/${pluginName}`)),
+ (route) => route.abort('failed')
+ );
+
+ await routesPom.toAdd(page);
+ await routesPom.isAddPage(page);
+
+ await test.step('open Select Plugins and add cors', async () => {
+ await page.getByRole('button', { name: 'Select Plugins' }).click();
+ const selectDialog = page.getByRole('dialog', { name: 'Select Plugins' });
+ await selectDialog.getByPlaceholder('Search').fill(pluginName);
+ await selectDialog
+ .getByTestId(`plugin-${pluginName}`)
+ .getByRole('button', { name: 'Add' })
+ .click();
+ });
+
+ await test.step('Add Plugin drawer must not stay in loading state forever', async () => {
+ const addPluginDialog = page.getByRole('dialog', { name: 'Add Plugin' });
+ await expect(addPluginDialog).toBeVisible({ timeout: 5000 });
+
+ // After waiting longer than any reasonable loading window, the editor
+ // must either render an error state or expose the user's escape hatch
+ // (Close / Cancel button). A still-spinning indicator with no error is
+ // the symptom of #3327.
+ await page.waitForTimeout(3000);
+
+ const editorLoading = addPluginDialog.getByTestId('editor-loading');
+ await expect(editorLoading).toBeHidden({ timeout: 10000 });
+
+ crashes.expectNoCrash('plugin schema fetch failure');
+ });
+});
diff --git a/e2e/tests/regression/routes.empty-plugin-config.spec.ts b/e2e/tests/regression/routes.empty-plugin-config.spec.ts
new file mode 100644
index 0000000000..b9ac3c6c62
--- /dev/null
+++ b/e2e/tests/regression/routes.empty-plugin-config.spec.ts
@@ -0,0 +1,98 @@
+/**
+ * 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.
+ */
+
+// Regression: route plugin with empty {} config must not be silently dropped.
+// Related issue:
+// - apache/apisix-dashboard#3269 plugin with empty JSON config dropped on
+// Route add/edit
+// User expectation: a plugin attached to a route with config={} (which is
+// valid for plugins like `cors` that have all-optional fields) survives a
+// no-op edit/save round trip in the Dashboard UI.
+//
+// To reproduce robustly we seed the route via the Admin API (the bug
+// description says the dashboard ALREADY accepts the create payload from
+// other tools), open the detail page, enter edit mode, and save without
+// changes. The Admin API must still contain the plugin afterwards.
+
+import { routesPom } from '@e2e/pom/routes';
+import { randomId } from '@e2e/utils/common';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiGoto, uiHasToastMsg } from '@e2e/utils/ui';
+import { expect } from '@playwright/test';
+
+import { deleteAllRoutes, getRouteReq, putRouteReq } from '@/apis/routes';
+import type { APISIXType } from '@/types/schema/apisix';
+
+const routeName = randomId('reg-empty-plugin');
+const routeUri = '/regression/empty-plugin';
+const pluginName = 'cors';
+const seedRoute: APISIXType['Route'] = {
+ name: routeName,
+ uri: routeUri,
+ methods: ['GET'],
+ upstream: {
+ type: 'roundrobin',
+ nodes: { 'empty-plugin.local:80': 1 },
+ },
+ plugins: {
+ [pluginName]: {},
+ },
+} as APISIXType['Route'];
+
+let seededRouteId: string;
+
+test.beforeAll(async () => {
+ await deleteAllRoutes(e2eReq);
+ const res = await putRouteReq(e2eReq, seedRoute);
+ seededRouteId = res.data.value.id;
+ expect(seededRouteId).toBeTruthy();
+ // Sanity check: Admin API initially contains the plugin entry.
+ const before = await getRouteReq(e2eReq, seededRouteId);
+ expect(before.value.plugins).toHaveProperty(pluginName);
+});
+
+test.afterAll(async () => {
+ await deleteAllRoutes(e2eReq);
+});
+
+test('Dashboard must preserve plugin entry with {} config on no-op save', async ({
+ page,
+}) => {
+ await test.step('open route detail page in UI', async () => {
+ await uiGoto(page, '/routes/detail/$id', { id: seededRouteId });
+ await routesPom.isDetailPage(page);
+ });
+
+ await test.step('enter edit mode and save without changes', async () => {
+ await page.getByRole('button', { name: 'Edit' }).click();
+
+ // Confirm we are in edit mode (Name becomes enabled).
+ const nameField = page.getByLabel('Name', { exact: true }).first();
+ await expect(nameField).toBeEnabled();
+
+ await page.getByRole('button', { name: 'Save' }).click();
+ await uiHasToastMsg(page, { hasText: 'success' });
+ await routesPom.isDetailPage(page);
+ });
+
+ await test.step('Admin API still contains the empty-config plugin', async () => {
+ const after = await getRouteReq(e2eReq, seededRouteId);
+ expect(after.value.plugins).toBeDefined();
+ expect(after.value.plugins).toHaveProperty(pluginName);
+ });
+});
diff --git a/e2e/tests/regression/routes.upstream-id-only.spec.ts b/e2e/tests/regression/routes.upstream-id-only.spec.ts
new file mode 100644
index 0000000000..3a855aea53
--- /dev/null
+++ b/e2e/tests/regression/routes.upstream-id-only.spec.ts
@@ -0,0 +1,180 @@
+/**
+ * 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.
+ */
+/* eslint-disable playwright/no-wait-for-timeout -- regression test stabilization */
+
+// Regression: routes upstream_id from form.
+// Related issues:
+// - apache/apisix-dashboard#3209 cannot set Route upstream_id
+// - apache/apisix-dashboard#3147 same
+// - apache/apisix-dashboard#3217 wrong API params on /apisix/admin/routes
+// User expectation: opening Add Route, filling required fields plus only
+// upstream_id (no inline upstream nodes) succeeds and persists upstream_id
+// to the Admin API.
+
+import { routesPom } from '@e2e/pom/routes';
+import { safeClean } from '@e2e/utils/clean';
+import { randomId } from '@e2e/utils/common';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiHasToastMsg } from '@e2e/utils/ui';
+import { expect } from '@playwright/test';
+
+import { deleteAllRoutes, getRouteReq } from '@/apis/routes';
+import { deleteAllUpstreams, putUpstreamReq } from '@/apis/upstreams';
+import type { APISIXType } from '@/types/schema/apisix';
+
+const upstreamName = randomId('reg-up');
+const upstreamNodes: APISIXType['UpstreamNode'][] = [
+ { host: 'test-upstream-id-only.local', port: 80, weight: 100 },
+];
+const routeName = randomId('reg-route-up-id');
+const routeUri = '/regression/upstream-id-only';
+
+let preCreatedUpstreamId: string;
+
+test.describe('routes upstream_id only', () => {
+ // The two tests share a pre-created upstream and would race each other on
+ // the global delete-all cleanup if they split across workers.
+ test.describe.configure({ mode: 'serial' });
+
+ test.beforeAll(async () => {
+ await safeClean(
+ () => deleteAllRoutes(e2eReq),
+ () => deleteAllUpstreams(e2eReq)
+ );
+
+ const res = await putUpstreamReq(e2eReq, {
+ name: upstreamName,
+ nodes: upstreamNodes,
+ } as APISIXType['Upstream']);
+ preCreatedUpstreamId = res.data.value.id;
+ expect(preCreatedUpstreamId).toBeTruthy();
+ });
+
+ test.afterAll(async () => {
+ await safeClean(
+ () => deleteAllRoutes(e2eReq),
+ () => deleteAllUpstreams(e2eReq)
+ );
+ });
+
+ test('should accept route with only upstream_id (no inline upstream nodes)', async ({
+ page,
+ }) => {
+ await routesPom.toAdd(page);
+ await routesPom.isAddPage(page);
+
+ await test.step('fill required fields and only upstream_id', async () => {
+ await page.getByLabel('Name', { exact: true }).first().fill(routeName);
+ await page.getByLabel('URI', { exact: true }).fill(routeUri);
+ await page.getByRole('textbox', { name: 'HTTP Methods' }).click();
+ await page.getByRole('option', { name: 'GET' }).click();
+ await page.keyboard.press('Escape');
+
+ const upstreamSection = page.getByRole('group', {
+ name: 'Upstream',
+ exact: true,
+ });
+ await upstreamSection
+ .locator('input[name="upstream_id"]')
+ .fill(preCreatedUpstreamId);
+ await expect(
+ upstreamSection.locator('input[name="upstream_id"]')
+ ).toHaveValue(preCreatedUpstreamId);
+ });
+
+ await test.step('submit and reach detail page', async () => {
+ await routesPom.getAddBtn(page).click();
+ await uiHasToastMsg(page, { hasText: 'Add Route Successfully' });
+ await routesPom.isDetailPage(page);
+ });
+
+ await test.step('Admin API has upstream_id and no inline upstream', async () => {
+ const routeId = page.url().split('/').pop()!;
+ const resp = await getRouteReq(e2eReq, routeId);
+ const route = resp.value as APISIXType['Route'];
+ expect(route.upstream_id).toBe(preCreatedUpstreamId);
+ expect(route.upstream).toBeUndefined();
+ });
+ });
+
+ test('should accept upstream_id when editing a route that originally had inline upstream', async ({
+ page,
+ }) => {
+ const editRouteName = randomId('reg-route-up-id-edit');
+ const editRouteUri = '/regression/upstream-id-only-edit';
+
+ await routesPom.toAdd(page);
+ await routesPom.isAddPage(page);
+
+ await test.step('create route with inline upstream first', async () => {
+ await page.getByLabel('Name', { exact: true }).first().fill(editRouteName);
+ await page.getByLabel('URI', { exact: true }).fill(editRouteUri);
+ await page.getByRole('textbox', { name: 'HTTP Methods' }).click();
+ await page.getByRole('option', { name: 'POST' }).click();
+ await page.keyboard.press('Escape');
+
+ const upstreamSection = page.getByRole('group', {
+ name: 'Upstream',
+ exact: true,
+ });
+ const addNodeBtn = page.getByRole('button', { name: 'Add a Node' });
+ await addNodeBtn.click();
+ const rows = upstreamSection.locator('tr.ant-table-row');
+ const hostInput = rows.first().locator('input').first();
+ const portInput = rows.first().locator('input').nth(1);
+ const weightInput = rows.first().locator('input').nth(2);
+ await hostInput.click();
+ await hostInput.fill('inline.local');
+ await portInput.click();
+ await portInput.fill('80');
+ await weightInput.click();
+ await weightInput.fill('1');
+ await weightInput.blur();
+ await page.waitForTimeout(500);
+
+ await routesPom.getAddBtn(page).click();
+ await uiHasToastMsg(page, { hasText: 'Add Route Successfully' });
+ await routesPom.isDetailPage(page);
+ });
+
+ await test.step('edit and switch to upstream_id', async () => {
+ await page.getByRole('button', { name: 'Edit' }).click();
+
+ const upstreamSection = page.getByRole('group', {
+ name: 'Upstream',
+ exact: true,
+ });
+ const upstreamIdInput = upstreamSection.locator(
+ 'input[name="upstream_id"]'
+ );
+ await upstreamIdInput.fill(preCreatedUpstreamId);
+ await expect(upstreamIdInput).toHaveValue(preCreatedUpstreamId);
+
+ await page.getByRole('button', { name: 'Save' }).click();
+ await uiHasToastMsg(page, { hasText: 'success' });
+ });
+
+ await test.step('Admin API reflects upstream_id and clears inline upstream', async () => {
+ const routeId = page.url().split('/').pop()!;
+ const resp = await getRouteReq(e2eReq, routeId);
+ const route = resp.value as APISIXType['Route'];
+ expect(route.upstream_id).toBe(preCreatedUpstreamId);
+ expect(route.upstream).toBeUndefined();
+ });
+ });
+});
diff --git a/e2e/tests/regression/routes.vars-editor.spec.ts b/e2e/tests/regression/routes.vars-editor.spec.ts
new file mode 100644
index 0000000000..188107a46b
--- /dev/null
+++ b/e2e/tests/regression/routes.vars-editor.spec.ts
@@ -0,0 +1,117 @@
+/**
+ * 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.
+ */
+
+// Regression: Route Vars editor must accept empty and round-trip non-empty.
+// Related issues:
+// - apache/apisix-dashboard#3362 Route Vars cannot be left empty although
+// the field is optional
+// - apache/apisix-dashboard#3145 route vars cannot be displayed or edited
+// User expectation:
+// (1) Submitting Add Route without touching `vars` succeeds (it is optional).
+// (2) A route created with vars via the Admin API renders the vars on the
+// detail page after a UI navigation.
+
+import { routesPom } from '@e2e/pom/routes';
+import { randomId } from '@e2e/utils/common';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiGoto, uiHasToastMsg } from '@e2e/utils/ui';
+import { uiFillUpstreamRequiredFields } from '@e2e/utils/ui/upstreams';
+import { expect } from '@playwright/test';
+
+import { deleteAllRoutes, getRouteReq, putRouteReq } from '@/apis/routes';
+import type { APISIXType } from '@/types/schema/apisix';
+
+const nodes: APISIXType['UpstreamNode'][] = [
+ { host: 'reg-vars.local', port: 80, weight: 100 },
+ { host: 'reg-vars-2.local', port: 80, weight: 100 },
+];
+
+test.describe('routes vars editor', () => {
+ test.describe.configure({ mode: 'serial' });
+
+ test.beforeAll(async () => {
+ await deleteAllRoutes(e2eReq);
+ });
+
+ test.afterAll(async () => {
+ await deleteAllRoutes(e2eReq);
+ });
+
+ test('Add Route succeeds without touching vars (vars is optional)', async ({
+ page,
+ }) => {
+ const routeName = randomId('reg-vars-empty');
+ const routeUri = '/regression/vars-empty';
+
+ await routesPom.toAdd(page);
+ await routesPom.isAddPage(page);
+
+ await page.getByLabel('Name', { exact: true }).first().fill(routeName);
+ await page.getByLabel('URI', { exact: true }).fill(routeUri);
+ await page.getByRole('textbox', { name: 'HTTP Methods' }).click();
+ await page.getByRole('option', { name: 'GET' }).click();
+ await page.keyboard.press('Escape');
+
+ const upstreamSection = page.getByRole('group', {
+ name: 'Upstream',
+ exact: true,
+ });
+ await uiFillUpstreamRequiredFields(
+ upstreamSection,
+ { nodes, name: randomId('reg-up'), desc: 'reg' }
+ );
+
+ await routesPom.getAddBtn(page).click();
+ await uiHasToastMsg(page, { hasText: 'Add Route Successfully' });
+ await routesPom.isDetailPage(page);
+
+ const routeId = page.url().split('/').pop()!;
+ const route = (await getRouteReq(e2eReq, routeId)).value;
+ expect(route.vars).toBeUndefined();
+ });
+
+ test('Route created via Admin API with vars renders them on detail page', async ({
+ page,
+ }) => {
+ const routeName = randomId('reg-vars-display');
+ const routeUri = '/regression/vars-display';
+ const vars = [['http_x_foo', '==', 'bar']] as unknown;
+
+ const created = await putRouteReq(e2eReq, {
+ name: routeName,
+ uri: routeUri,
+ methods: ['GET'],
+ vars,
+ upstream: {
+ type: 'roundrobin',
+ nodes: { 'reg-vars-display.local:80': 1 },
+ },
+ } as unknown as APISIXType['Route']);
+ const routeId = created.data.value.id;
+
+ await uiGoto(page, '/routes/detail/$id', { id: routeId });
+ await routesPom.isDetailPage(page);
+
+ // The Match Rules section must render the vars region; both the variable
+ // name and the comparison value should be present in the page text.
+ const matchRules = page.getByRole('group', { name: 'Match Rules' });
+ await expect(matchRules).toBeVisible();
+ await expect(matchRules).toContainText('http_x_foo');
+ await expect(matchRules).toContainText('bar');
+ });
+});
diff --git a/e2e/tests/regression/ssls.labels-after-create.spec.ts b/e2e/tests/regression/ssls.labels-after-create.spec.ts
new file mode 100644
index 0000000000..6634db442c
--- /dev/null
+++ b/e2e/tests/regression/ssls.labels-after-create.spec.ts
@@ -0,0 +1,97 @@
+/**
+ * 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.
+ */
+
+// Regression: SSL labels must remain visible after save.
+// Related issue:
+// - apache/apisix-dashboard#3172 SSL labels missing after creation
+// User expectation: labels entered when creating an SSL stay visible on the
+// detail page and are reflected by the Admin API.
+//
+// Note: `ssls.check-labels.spec.ts` only verifies labels render IN the form
+// (pre-save). This test covers the post-save round trip.
+
+import { sslsPom } from '@e2e/pom/ssls';
+import { genTLS } from '@e2e/utils/common';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiHasToastMsg } from '@e2e/utils/ui';
+import { uiCheckLabels } from '@e2e/utils/ui/labels';
+import { uiFillSSLRequiredFields } from '@e2e/utils/ui/ssls';
+import { expect } from '@playwright/test';
+
+import { deleteAllSSLs, getSSLReq } from '@/apis/ssls';
+import type { APISIXType } from '@/types/schema/apisix';
+
+const snis = ['reg-labels.example.com'];
+const labels = {
+ env: 'prod',
+ owner: 'sre',
+};
+
+test.beforeAll(async () => {
+ await deleteAllSSLs(e2eReq);
+});
+
+test.afterAll(async () => {
+ await deleteAllSSLs(e2eReq);
+});
+
+test('SSL labels are preserved and displayed after save', async ({ page }) => {
+ await sslsPom.toAdd(page);
+ await sslsPom.isAddPage(page);
+
+ const tls = await genTLS();
+ const sslData: Partial = {
+ snis,
+ cert: tls.cert,
+ key: tls.key,
+ labels,
+ };
+
+ await test.step('fill cert / SNI / labels and submit', async () => {
+ await uiFillSSLRequiredFields(page, sslData);
+
+ await sslsPom.getAddBtn(page).click();
+ await uiHasToastMsg(page, { hasText: 'Add SSL Successfully' });
+ // SSL form redirects back to the list after submit, not to detail.
+ await sslsPom.isIndexPage(page);
+ });
+
+ let sslId = '';
+
+ await test.step('open SSL detail from list', async () => {
+ await page
+ .getByRole('row', { name: snis[0] })
+ .getByRole('button', { name: 'View' })
+ .click();
+ await sslsPom.isDetailPage(page);
+ sslId = page.url().split('/').pop()!;
+ expect(sslId).toBeTruthy();
+ });
+
+ await test.step('detail page displays labels after save', async () => {
+ await uiCheckLabels(page, labels);
+ });
+
+ await test.step('Admin API has the labels', async () => {
+ const resp = await getSSLReq(e2eReq, sslId);
+ expect(resp.value.labels).toBeDefined();
+ for (const [k, v] of Object.entries(labels)) {
+ expect((resp.value.labels as Record)[k]).toBe(v);
+ }
+ });
+});
diff --git a/e2e/tests/regression/upstreams.discovery-args.spec.ts b/e2e/tests/regression/upstreams.discovery-args.spec.ts
new file mode 100644
index 0000000000..a71625ba33
--- /dev/null
+++ b/e2e/tests/regression/upstreams.discovery-args.spec.ts
@@ -0,0 +1,105 @@
+/**
+ * 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.
+ */
+
+// Regression: upstream Service Discovery fields must persist.
+// Related issues:
+// - apache/apisix-dashboard#3376 Upstreams Discovery Args not saved
+// - apache/apisix-dashboard#3270 same
+// - apache/apisix-dashboard#3287 same
+// User expectation: when an upstream is configured with service discovery
+// (discovery_type + service_name + discovery_args), all three fields are
+// preserved through save and visible from the Admin API.
+
+import { upstreamsPom } from '@e2e/pom/upstreams';
+import { safeClean } from '@e2e/utils/clean';
+import { randomId } from '@e2e/utils/common';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiHasToastMsg } from '@e2e/utils/ui';
+import { expect } from '@playwright/test';
+
+import { deleteAllRoutes } from '@/apis/routes';
+import { deleteAllServices } from '@/apis/services';
+import { deleteAllUpstreams, getUpstreamReq } from '@/apis/upstreams';
+import type { APISIXType } from '@/types/schema/apisix';
+
+const upstreamName = randomId('reg-discovery');
+const discoveryType = 'dns';
+const serviceName = 'reg-dns-service';
+const discoveryArgs = { namespace_id: 'reg-namespace' };
+
+const broadClean = () =>
+ safeClean(
+ () => deleteAllRoutes(e2eReq),
+ () => deleteAllServices(e2eReq),
+ () => deleteAllUpstreams(e2eReq)
+ );
+
+test.beforeAll(broadClean);
+
+test.afterAll(broadClean);
+
+test('should persist discovery_type, service_name and discovery_args', async ({
+ page,
+}) => {
+ await upstreamsPom.toAdd(page);
+ await upstreamsPom.isAddPage(page);
+
+ await test.step('fill name and service discovery fields', async () => {
+ await page.getByLabel('Name', { exact: true }).fill(upstreamName);
+
+ // The Service Discovery section exposes three free-text fields:
+ // Service Name, Discovery Type, Discovery Args (key:value tag input).
+ const discoverySection = page.getByRole('group', {
+ name: 'Service Discovery',
+ });
+
+ await discoverySection
+ .getByRole('textbox', { name: 'Service Name' })
+ .fill(serviceName);
+ await discoverySection
+ .getByRole('textbox', { name: 'Discovery Type' })
+ .fill(discoveryType);
+
+ // Discovery Args is a Mantine JsonInput (textarea with monospace), not
+ // a tag input — write the full JSON value in one go.
+ const discoveryArgsField = discoverySection.locator(
+ 'textarea[name="discovery_args"]'
+ );
+ await discoveryArgsField.fill(JSON.stringify(discoveryArgs));
+ await discoveryArgsField.blur();
+ });
+
+ await test.step('submit', async () => {
+ await upstreamsPom.getAddBtn(page).click();
+ await uiHasToastMsg(page, { hasText: 'Add Upstream Successfully' });
+ await upstreamsPom.isDetailPage(page);
+ });
+
+ await test.step('Admin API has all three discovery fields', async () => {
+ const upstreamId = page.url().split('/').pop()!;
+ const resp = await getUpstreamReq(e2eReq, upstreamId);
+ const upstream = resp.value as APISIXType['Upstream'];
+
+ expect(upstream.discovery_type).toBe(discoveryType);
+ expect(upstream.service_name).toBe(serviceName);
+ expect(upstream.discovery_args).toBeDefined();
+ for (const [k, v] of Object.entries(discoveryArgs)) {
+ expect((upstream.discovery_args as Record)[k]).toBe(v);
+ }
+ });
+});
diff --git a/e2e/tests/regression/upstreams.node-sync-on-save.spec.ts b/e2e/tests/regression/upstreams.node-sync-on-save.spec.ts
new file mode 100644
index 0000000000..4bd74dd7b6
--- /dev/null
+++ b/e2e/tests/regression/upstreams.node-sync-on-save.spec.ts
@@ -0,0 +1,107 @@
+/**
+ * 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.
+ */
+/* eslint-disable playwright/no-conditional-in-test -- regression test stabilization */
+
+// Regression: typed values inside an upstream-nodes cell must be synced
+// to react-hook-form *before* the user clicks Save. Master ships a
+// `ob.save()` call on add/remove (so those paths sync), but
+// `onValuesChange` (keystrokes inside a cell) does NOT — so a user who
+// edits a host/port/weight and clicks Save without blurring first loses
+// the change.
+//
+// Related issue:
+// - apache/apisix-dashboard#3293 (OPEN, PR open with conflicts) — same
+// symptom; this PR ships the equivalent fix inline.
+
+import { upstreamsPom } from '@e2e/pom/upstreams';
+import { safeClean } from '@e2e/utils/clean';
+import { randomId } from '@e2e/utils/common';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiHasToastMsg } from '@e2e/utils/ui';
+import { expect } from '@playwright/test';
+
+import { deleteAllRoutes } from '@/apis/routes';
+import { deleteAllServices } from '@/apis/services';
+import { deleteAllUpstreams, getUpstreamReq } from '@/apis/upstreams';
+
+const broadClean = () =>
+ safeClean(
+ () => deleteAllRoutes(e2eReq),
+ () => deleteAllServices(e2eReq),
+ () => deleteAllUpstreams(e2eReq)
+ );
+
+test.beforeAll(broadClean);
+
+test.afterAll(broadClean);
+
+// Fixed in this PR by removing the MobX mirror in FormItemNodes. RHF is
+// now the single source of truth and every keystroke / add / remove
+// pushes UP via fOnChange immediately, so an immediate Submit captures
+// the typed values.
+test(
+ 'upstream node typed values reach Admin API on immediate Save (no blur)',
+ async ({ page }) => {
+ const upstreamName = randomId('reg-node-sync');
+
+ await upstreamsPom.toAdd(page);
+ await upstreamsPom.isAddPage(page);
+
+ await page.getByLabel('Name', { exact: true }).fill(upstreamName);
+
+ // Add a node and fill its host + port + weight, then submit IMMEDIATELY.
+ // No blur. No waitForTimeout. The form must still receive the values.
+ const nodesSection = page.getByRole('group', { name: 'Nodes' });
+ await page.getByRole('button', { name: 'Add a Node' }).click();
+ const rows = nodesSection.locator('tr.ant-table-row');
+
+ await rows.first().locator('input').first().fill('node-sync.local');
+ await rows.first().locator('input').nth(1).fill('8080');
+ await rows.first().locator('input').nth(2).fill('5');
+
+ // Click Save immediately — DO NOT blur the weight cell first. The fix
+ // (debounced syncToForm on every onValuesChange) ensures the typed
+ // values reach react-hook-form before the submit handler runs.
+ await upstreamsPom.getAddBtn(page).click();
+ await uiHasToastMsg(page, { hasText: 'Add Upstream Successfully' });
+ await upstreamsPom.isDetailPage(page);
+
+ const upstreamId = page.url().split('/').pop()!;
+ const upstream = (await getUpstreamReq(e2eReq, upstreamId)).value;
+
+ expect(upstream.nodes).toBeDefined();
+ // APISIX may return nodes either as an array or as a host:port→weight map;
+ // accept both forms and check the typed values made it through.
+ const node = Array.isArray(upstream.nodes)
+ ? upstream.nodes[0]
+ : Object.entries(upstream.nodes as Record).map(
+ ([hostPort, weight]) => {
+ const lastColon = hostPort.lastIndexOf(':');
+ return {
+ host: hostPort.slice(0, lastColon),
+ port: Number(hostPort.slice(lastColon + 1)),
+ weight,
+ };
+ }
+ )[0];
+
+ expect(node.host).toBe('node-sync.local');
+ expect(node.port).toBe(8080);
+ expect(node.weight).toBe(5);
+ }
+);
diff --git a/e2e/tests/regression/upstreams.percent-undefined-no-crash.spec.ts b/e2e/tests/regression/upstreams.percent-undefined-no-crash.spec.ts
new file mode 100644
index 0000000000..d886b94c7c
--- /dev/null
+++ b/e2e/tests/regression/upstreams.percent-undefined-no-crash.spec.ts
@@ -0,0 +1,90 @@
+/**
+ * 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.
+ */
+/* eslint-disable playwright/no-wait-for-timeout -- regression test stabilization */
+
+// Regression: viewing an upstream that has node weights summing to zero
+// (or absent) must not produce a `Cannot read properties of undefined
+// (reading 'percent')` crash.
+//
+// Related issue:
+// - apache/apisix-dashboard#3381 percent undefined crash
+//
+// Reproduction strategy: weight percentage is computed from node weights.
+// When all weights are zero or missing, division-by-zero yields NaN /
+// undefined and any defensive code that forgets to guard `.percent` will
+// crash.
+
+import { upstreamsPom } from '@e2e/pom/upstreams';
+import { safeClean } from '@e2e/utils/clean';
+import { randomId } from '@e2e/utils/common';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiGoto } from '@e2e/utils/ui';
+import { watchForCrashes } from '@e2e/utils/ui/crash';
+import { expect } from '@playwright/test';
+
+import { deleteAllRoutes } from '@/apis/routes';
+import { deleteAllServices } from '@/apis/services';
+import { deleteAllUpstreams, putUpstreamReq } from '@/apis/upstreams';
+import type { APISIXType } from '@/types/schema/apisix';
+
+// Clean routes + services first so deleteAllUpstreams doesn't trip on
+// foreign-key-style references from leftover resources of earlier specs.
+const broadClean = () =>
+ safeClean(
+ () => deleteAllRoutes(e2eReq),
+ () => deleteAllServices(e2eReq),
+ () => deleteAllUpstreams(e2eReq)
+ );
+
+test.beforeAll(broadClean);
+
+test.afterAll(broadClean);
+
+test('upstream with all zero weights renders detail without "percent" crash', async ({
+ page,
+}) => {
+ const crashes = watchForCrashes(page);
+
+ const upstream: APISIXType['Upstream'] = {
+ name: randomId('reg-percent'),
+ nodes: [
+ { host: 'zero-w-a.local', port: 80, weight: 0 },
+ { host: 'zero-w-b.local', port: 80, weight: 0 },
+ ],
+ } as APISIXType['Upstream'];
+
+ const created = await putUpstreamReq(e2eReq, upstream);
+ const upstreamId = created.data.value.id;
+
+ await uiGoto(page, '/upstreams/detail/$id', { id: upstreamId });
+ await upstreamsPom.isDetailPage(page);
+
+ // Allow any async rendering / charts to settle.
+ await page.waitForTimeout(1500);
+
+ crashes.expectNoCrash('upstream detail with zero weights');
+
+ // Nodes table should still render the hosts even if percentages can't be
+ // computed.
+ await expect(
+ page.getByRole('cell', { name: 'zero-w-a.local' })
+ ).toBeVisible();
+ await expect(
+ page.getByRole('cell', { name: 'zero-w-b.local' })
+ ).toBeVisible();
+});
diff --git a/e2e/tests/regression/validation.multi-auth-schema.spec.ts b/e2e/tests/regression/validation.multi-auth-schema.spec.ts
new file mode 100644
index 0000000000..c8a1ef6211
--- /dev/null
+++ b/e2e/tests/regression/validation.multi-auth-schema.spec.ts
@@ -0,0 +1,113 @@
+/**
+ * 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.
+ */
+
+// Regression: the multi-auth plugin schema must accept a valid
+// configuration in the dashboard's plugin editor.
+//
+// Related issue:
+// - apache/apisix-dashboard#3289 multi-auth schema validation wrong
+
+import { routesPom } from '@e2e/pom/routes';
+import { randomId } from '@e2e/utils/common';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import {
+ uiFillMonacoEditor,
+ uiGetMonacoEditor,
+ uiHasToastMsg,
+} from '@e2e/utils/ui';
+import { uiFillUpstreamRequiredFields } from '@e2e/utils/ui/upstreams';
+import { expect } from '@playwright/test';
+
+import { deleteAllRoutes, getRouteReq } from '@/apis/routes';
+import type { APISIXType } from '@/types/schema/apisix';
+
+const nodes: APISIXType['UpstreamNode'][] = [
+ { host: 'reg-multiauth.local', port: 80, weight: 100 },
+ { host: 'reg-multiauth-2.local', port: 80, weight: 100 },
+];
+
+const validMultiAuthConfig = {
+ auth_plugins: [
+ { 'key-auth': {} },
+ { 'jwt-auth': {} },
+ ],
+};
+
+test.beforeAll(async () => {
+ await deleteAllRoutes(e2eReq);
+});
+
+test.afterAll(async () => {
+ await deleteAllRoutes(e2eReq);
+});
+
+test('Route accepts multi-auth plugin with valid config', async ({ page }) => {
+ await routesPom.toAdd(page);
+ await routesPom.isAddPage(page);
+
+ await page
+ .getByLabel('Name', { exact: true })
+ .first()
+ .fill(randomId('reg-multiauth'));
+ await page.getByLabel('URI', { exact: true }).fill('/regression/multi-auth');
+ await page.getByRole('textbox', { name: 'HTTP Methods' }).click();
+ await page.getByRole('option', { name: 'GET' }).click();
+ await page.keyboard.press('Escape');
+
+ const upstreamSection = page.getByRole('group', {
+ name: 'Upstream',
+ exact: true,
+ });
+ await uiFillUpstreamRequiredFields(
+ upstreamSection,
+ { nodes, name: randomId('reg-up'), desc: 'reg' }
+ );
+
+ await test.step('add multi-auth plugin with a valid 2-plugin config', async () => {
+ await page.getByRole('button', { name: 'Select Plugins' }).click();
+ const selectDialog = page.getByRole('dialog', { name: 'Select Plugins' });
+ await selectDialog.getByPlaceholder('Search').fill('multi-auth');
+ await selectDialog
+ .getByTestId('plugin-multi-auth')
+ .getByRole('button', { name: 'Add' })
+ .click();
+
+ const addPluginDialog = page.getByRole('dialog', { name: 'Add Plugin' });
+ const editor = await uiGetMonacoEditor(page, addPluginDialog, false);
+ await uiFillMonacoEditor(
+ page,
+ editor,
+ JSON.stringify(validMultiAuthConfig)
+ );
+ await addPluginDialog.getByRole('button', { name: 'Add' }).click();
+ await expect(addPluginDialog).toBeHidden({ timeout: 10000 });
+ });
+
+ await test.step('submit route — schema must accept the valid config', async () => {
+ await routesPom.getAddBtn(page).click();
+ await uiHasToastMsg(page, { hasText: 'Add Route Successfully' });
+ await routesPom.isDetailPage(page);
+ });
+
+ await test.step('Admin API contains the multi-auth plugin', async () => {
+ const routeId = page.url().split('/').pop()!;
+ const route = (await getRouteReq(e2eReq, routeId)).value;
+ expect(route.plugins).toBeDefined();
+ expect(route.plugins).toHaveProperty('multi-auth');
+ });
+});
diff --git a/e2e/tests/regression/validation.zOneOf-single-field.spec.ts b/e2e/tests/regression/validation.zOneOf-single-field.spec.ts
new file mode 100644
index 0000000000..94683faa09
--- /dev/null
+++ b/e2e/tests/regression/validation.zOneOf-single-field.spec.ts
@@ -0,0 +1,81 @@
+/**
+ * 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.
+ */
+
+// Regression: zOneOf validators must accept the form when exactly one of the
+// mutually-exclusive alternatives is provided.
+//
+// Related issue:
+// - apache/apisix-dashboard#3296 zOneOf validation fires even when one
+// field is provided
+//
+// We use Service form as a concrete instance: a service must have either
+// inline `upstream` config OR an `upstream_id`. Filling only the inline
+// upstream must satisfy validation and submit successfully.
+
+import { servicesPom } from '@e2e/pom/services';
+import { safeClean } from '@e2e/utils/clean';
+import { randomId } from '@e2e/utils/common';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiHasToastMsg } from '@e2e/utils/ui';
+import { uiFillUpstreamRequiredFields } from '@e2e/utils/ui/upstreams';
+import { expect } from '@playwright/test';
+
+import { deleteAllServices, getServiceReq } from '@/apis/services';
+import type { APISIXType } from '@/types/schema/apisix';
+
+const nodes: APISIXType['UpstreamNode'][] = [
+ { host: 'reg-zoneof.local', port: 80, weight: 100 },
+ { host: 'reg-zoneof-2.local', port: 80, weight: 100 },
+];
+
+test.beforeAll(async () => {
+ await safeClean(() => deleteAllServices(e2eReq));
+});
+
+test.afterAll(async () => {
+ await safeClean(() => deleteAllServices(e2eReq));
+});
+
+test('Service with only inline upstream (zOneOf alt 1) submits cleanly', async ({
+ page,
+}) => {
+ const serviceName = randomId('reg-zoneof-inline');
+
+ await servicesPom.toAdd(page);
+ await servicesPom.isAddPage(page);
+
+ await page.getByLabel('Name', { exact: true }).first().fill(serviceName);
+
+ const upstreamSection = page.getByRole('group', {
+ name: 'Upstream',
+ exact: true,
+ });
+ await uiFillUpstreamRequiredFields(
+ upstreamSection,
+ { nodes, name: randomId('reg-up'), desc: 'reg' }
+ );
+
+ await servicesPom.getAddBtn(page).click();
+ await uiHasToastMsg(page, { hasText: 'success' });
+ await servicesPom.isDetailPage(page);
+
+ const serviceId = page.url().split('/').pop()!;
+ const svc = (await getServiceReq(e2eReq, serviceId)).value;
+ expect(svc.upstream).toBeDefined();
+ expect(svc.upstream_id).toBeUndefined();
+});
diff --git a/e2e/tests/routes.crud-all-fields.spec.ts b/e2e/tests/routes.crud-all-fields.spec.ts
index e8b76f6778..f4bcd031b6 100644
--- a/e2e/tests/routes.crud-all-fields.spec.ts
+++ b/e2e/tests/routes.crud-all-fields.spec.ts
@@ -154,8 +154,15 @@ test('should CRUD route with all fields', async ({ page }) => {
await editPluginDialog.getByRole('button', { name: 'Save' }).click();
await expect(editPluginDialog).toBeHidden();
- // delete basic-auth plugin
+ // delete basic-auth plugin — now requires confirmation per #3342 fix
await basicAuthPlugin.getByRole('button', { name: 'Delete' }).click();
+ const confirmDeleteDialog = page
+ .getByRole('dialog')
+ .filter({ hasText: /basic-auth/i });
+ await expect(confirmDeleteDialog).toBeVisible({ timeout: 5000 });
+ await confirmDeleteDialog
+ .getByRole('button', { name: 'Delete' })
+ .click();
await expect(basicAuthPlugin).toBeHidden();
// add real-ip plugin
diff --git a/e2e/tests/routes.crud-required-fields.spec.ts b/e2e/tests/routes.crud-required-fields.spec.ts
index ff9c537204..caad222144 100644
--- a/e2e/tests/routes.crud-required-fields.spec.ts
+++ b/e2e/tests/routes.crud-required-fields.spec.ts
@@ -75,7 +75,7 @@ test('should CRUD route with required fields', async ({ page }) => {
nodes,
name: 'test-upstream',
desc: 'test',
- }, page);
+ });
// Submit the form
await routesPom.getAddBtn(page).click();
await uiHasToastMsg(page, {
diff --git a/e2e/tests/services.routes.crud.spec.ts b/e2e/tests/services.routes.crud.spec.ts
index 2ed926b30d..181741a2cc 100644
--- a/e2e/tests/services.routes.crud.spec.ts
+++ b/e2e/tests/services.routes.crud.spec.ts
@@ -108,7 +108,7 @@ test('should CRUD route under service with required fields', async ({
nodes,
name: 'test-upstream',
desc: 'test',
- }, page);
+ });
// Submit the form
await servicesPom.getAddBtn(page).click();
diff --git a/e2e/tests/ssls.crud-all-fields.spec.ts b/e2e/tests/ssls.crud-all-fields.spec.ts
index 07c4fcf171..1273d6d5d7 100644
--- a/e2e/tests/ssls.crud-all-fields.spec.ts
+++ b/e2e/tests/ssls.crud-all-fields.spec.ts
@@ -149,8 +149,13 @@ test('should CRUD SSL with all fields', async ({ page }) => {
const cert1Field = page.getByRole('textbox', { name: 'Certificate 1' });
await expect(cert1Field).toBeEnabled();
- // Cancel without making changes
+ // Cancel without making changes. The Edit→Cancel guard now always
+ // confirms before discarding (see src/hooks/useEditCancelGuard.tsx).
await page.getByRole('button', { name: 'Cancel' }).click();
+ await page
+ .getByRole('dialog')
+ .getByRole('button', { name: 'Discard Changes' })
+ .click();
// Return to list page
await sslsPom.getSSLNavBtn(page).click();
diff --git a/e2e/tests/ssls.crud-required-fields.spec.ts b/e2e/tests/ssls.crud-required-fields.spec.ts
index 16ab1215d8..cdecc1c4a1 100644
--- a/e2e/tests/ssls.crud-required-fields.spec.ts
+++ b/e2e/tests/ssls.crud-required-fields.spec.ts
@@ -114,8 +114,14 @@ test('should CRUD SSL with required fields', async ({ page }) => {
// Verify the new SNI is displayed
await expect(page.getByText('updated.example.com', { exact: true })).toBeVisible();
- // Click Cancel instead of Save to avoid validation issues with empty key
+ // Click Cancel instead of Save to avoid validation issues with empty key.
+ // The Edit→Cancel guard now confirms before discarding (see
+ // src/hooks/useEditCancelGuard.tsx), so dismiss the modal too.
await page.getByRole('button', { name: 'Cancel' }).click();
+ await page
+ .getByRole('dialog')
+ .getByRole('button', { name: 'Discard Changes' })
+ .click();
// Verify we're back in detail view mode
await sslsPom.isDetailPage(page);
diff --git a/e2e/tests/upstreams.crud-required-fields.spec.ts b/e2e/tests/upstreams.crud-required-fields.spec.ts
index b25e0a15d4..ea4e6edfc2 100644
--- a/e2e/tests/upstreams.crud-required-fields.spec.ts
+++ b/e2e/tests/upstreams.crud-required-fields.spec.ts
@@ -56,7 +56,7 @@ test('should CRUD upstream with required fields', async ({ page }) => {
await uiFillUpstreamRequiredFields(page, {
name: upstreamName,
nodes,
- }, page);
+ });
await upstreamsPom.getAddBtn(page).click();
await uiHasToastMsg(page, {
hasText: 'Add Upstream Successfully',
diff --git a/e2e/utils/bulk.ts b/e2e/utils/bulk.ts
new file mode 100644
index 0000000000..ece127ff36
--- /dev/null
+++ b/e2e/utils/bulk.ts
@@ -0,0 +1,158 @@
+/**
+ * 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.
+ */
+
+// Bulk-data helpers: seed and tear down N resources directly via the Admin
+// API (NOT via the UI). The UI side is the system under test — keeping the
+// data prep on the API side decouples the bulk-render assertions from the
+// add-form behavior.
+
+import { API_ROUTES, API_UPSTREAMS, PAGE_SIZE_MAX } from '@/config/constant';
+import type { APISIXType } from '@/types/schema/apisix';
+
+import { e2eReq } from './req';
+
+const CONCURRENCY = 20;
+
+const runBatched = async (
+ items: T[],
+ fn: (item: T) => Promise,
+ concurrency = CONCURRENCY
+): Promise => {
+ for (let i = 0; i < items.length; i += concurrency) {
+ const slice = items.slice(i, i + concurrency);
+ await Promise.all(slice.map(fn));
+ }
+};
+
+export type BulkRouteSeedOptions = {
+ count: number;
+ prefix?: string;
+ uriPrefix?: string;
+ host?: string;
+};
+
+/**
+ * Create N routes via PUT with explicit IDs ("{prefix}-{i}"). Returns the
+ * names so a test can spot-check individual rows.
+ */
+export const bulkCreateRoutes = async (
+ opts: BulkRouteSeedOptions
+): Promise => {
+ const {
+ count,
+ prefix = 'bulk',
+ uriPrefix = '/bulk',
+ host = 'bulk.local',
+ } = opts;
+ const ids = Array.from({ length: count }, (_, i) => `${prefix}-${i}`);
+ await runBatched(ids, async (id) => {
+ const body: APISIXType['Route'] = {
+ name: id,
+ uri: `${uriPrefix}/${id}`,
+ methods: ['GET'],
+ upstream: {
+ type: 'roundrobin',
+ nodes: { [`${host}:80`]: 1 },
+ },
+ } as APISIXType['Route'];
+ await e2eReq.put(`${API_ROUTES}/${id}`, body);
+ });
+ return ids;
+};
+
+/**
+ * Delete every route whose ID starts with `prefix`.
+ */
+export const bulkDeleteRoutesByPrefix = async (
+ prefix = 'bulk'
+): Promise => {
+ let removed = 0;
+ // Paginate just in case the prefix bucket is enormous.
+ let page = 1;
+ while (true) {
+ const resp = await e2eReq.get(API_ROUTES, {
+ params: { page, page_size: PAGE_SIZE_MAX },
+ });
+ const list = (
+ resp.data as {
+ list: Array<{ value: { id?: string; name?: string } }>;
+ total: number;
+ }
+ ).list;
+ const matching = list.filter(
+ (item) =>
+ (item.value.id && item.value.id.startsWith(prefix)) ||
+ (item.value.name && item.value.name.startsWith(prefix))
+ );
+ if (matching.length === 0) break;
+ await runBatched(matching, async (item) => {
+ await e2eReq.delete(`${API_ROUTES}/${item.value.id}`);
+ removed++;
+ });
+ if (list.length < PAGE_SIZE_MAX) break;
+ page++;
+ }
+ return removed;
+};
+
+export type BulkUpstreamSeedOptions = {
+ count: number;
+ prefix?: string;
+};
+
+export const bulkCreateUpstreams = async (
+ opts: BulkUpstreamSeedOptions
+): Promise => {
+ const { count, prefix = 'bulk-up' } = opts;
+ const ids = Array.from({ length: count }, (_, i) => `${prefix}-${i}`);
+ await runBatched(ids, async (id) => {
+ const body: APISIXType['Upstream'] = {
+ name: id,
+ nodes: [{ host: `${id}.local`, port: 80, weight: 1 }],
+ } as APISIXType['Upstream'];
+ await e2eReq.put(`${API_UPSTREAMS}/${id}`, body);
+ });
+ return ids;
+};
+
+export const bulkDeleteUpstreamsByPrefix = async (
+ prefix = 'bulk-up'
+): Promise => {
+ let removed = 0;
+ let page = 1;
+ while (true) {
+ const resp = await e2eReq.get(API_UPSTREAMS, {
+ params: { page, page_size: PAGE_SIZE_MAX },
+ });
+ const list = (
+ resp.data as { list: Array<{ value: { id?: string; name?: string } }> }
+ ).list;
+ const matching = list.filter(
+ (item) =>
+ (item.value.id && item.value.id.startsWith(prefix)) ||
+ (item.value.name && item.value.name.startsWith(prefix))
+ );
+ if (matching.length === 0) break;
+ await runBatched(matching, async (item) => {
+ await e2eReq.delete(`${API_UPSTREAMS}/${item.value.id}`);
+ removed++;
+ });
+ if (list.length < PAGE_SIZE_MAX) break;
+ page++;
+ }
+ return removed;
+};
diff --git a/e2e/utils/clean.ts b/e2e/utils/clean.ts
new file mode 100644
index 0000000000..a692e842c0
--- /dev/null
+++ b/e2e/utils/clean.ts
@@ -0,0 +1,38 @@
+/**
+ * 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.
+ */
+
+/**
+ * Best-effort cleanup helper.
+ *
+ * Some product-side `deleteAll*` helpers (e.g. `deleteAllServices` →
+ * `deleteAllStreamRoutes`) fail when the APISIX deployment has stream mode
+ * disabled. Cleanup is not the system under test — it must not fail the
+ * spec. Use this wrapper in `beforeAll` / `afterAll` so tests focus on the
+ * thing they actually want to verify.
+ */
+export async function safeClean(
+ ...fns: Array<() => Promise>
+): Promise {
+ for (const fn of fns) {
+ try {
+ await fn();
+ } catch {
+ // Swallow — cleanup failures must not abort the test that owns this
+ // hook. Each individual test asserts product behavior on its own.
+ }
+ }
+}
diff --git a/e2e/utils/ui/crash.ts b/e2e/utils/ui/crash.ts
new file mode 100644
index 0000000000..3a8d6491b2
--- /dev/null
+++ b/e2e/utils/ui/crash.ts
@@ -0,0 +1,46 @@
+/**
+ * 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.
+ */
+import type { Page } from '@playwright/test';
+import { expect } from '@playwright/test';
+
+export type CrashWatcher = {
+ errors: string[];
+ expectNoCrash: (context?: string) => void;
+};
+
+/**
+ * Attach a listener that records every unhandled page error (window.onerror /
+ * unhandledrejection) for later assertion. Used by regression tests that
+ * guard against crash bugs like #3279, #3370, #3381.
+ */
+export const watchForCrashes = (page: Page): CrashWatcher => {
+ const errors: string[] = [];
+ page.on('pageerror', (e) => {
+ errors.push(`${e.name}: ${e.message}`);
+ });
+ return {
+ errors,
+ expectNoCrash: (context?: string) => {
+ expect(
+ errors,
+ context
+ ? `Unhandled page errors during "${context}": ${errors.join(' | ')}`
+ : `Unhandled page errors: ${errors.join(' | ')}`
+ ).toEqual([]);
+ },
+ };
+};
diff --git a/e2e/utils/ui/index.ts b/e2e/utils/ui/index.ts
index 23c005c5c6..88042097ee 100644
--- a/e2e/utils/ui/index.ts
+++ b/e2e/utils/ui/index.ts
@@ -120,9 +120,20 @@ export const uiFillMonacoEditor = async (
value: string
) => {
await editor.click();
- const editorTextbox = editor.getByRole('textbox');
- // Use fill() instead of pressSequentially() for reliability
- await editorTextbox.fill(value);
+ // Wait for the Monaco global to be wired up (set when an editor instance
+ // mounts — same hook uiClearMonacoEditor depends on).
+ await page.waitForFunction(
+ () => typeof window.__monacoEditor__ !== 'undefined',
+ null,
+ { timeout: 10_000 }
+ );
+ // Set value directly on the model. Going through keystroke `fill()`
+ // triggers Monaco's auto-bracket / auto-quote pairing, which silently
+ // corrupts JSON inputs containing `{` or `"`.
+ await page.evaluate((v) => {
+ const ed = window.__monacoEditor__;
+ ed.getModel()?.setValue(v);
+ }, value);
await editor.blur();
await page.waitForTimeout(800);
};
diff --git a/e2e/utils/ui/upstreams.ts b/e2e/utils/ui/upstreams.ts
index e97ec85ab8..4ce5aa32ab 100644
--- a/e2e/utils/ui/upstreams.ts
+++ b/e2e/utils/ui/upstreams.ts
@@ -33,9 +33,16 @@ import {
*/
export async function uiFillUpstreamRequiredFields(
ctx: Page | Locator,
- upstream: Partial,
- page?: Page
+ upstream: Partial
) {
+ // Derive a Page handle for the small settle delays needed between
+ // rapid-fire Add Node clicks (EditableProTable's editable lifecycle
+ // doesn't tolerate clicks during its post-append re-render).
+ const page: Page =
+ typeof (ctx as Locator).page === 'function'
+ ? (ctx as Locator).page()
+ : (ctx as Page);
+
// Fill in the Name field
await ctx.getByLabel('Name', { exact: true }).fill(upstream.name);
@@ -46,35 +53,39 @@ export async function uiFillUpstreamRequiredFields(
await expect(noData).toBeVisible();
- // Add first node
+ // Add first node — wait for table to settle to a single row before
+ // attempting to type, so the re-render from `ob.append + ob.save` is
+ // done.
await addNodeBtn.click();
await expect(noData).toBeHidden();
const rows = nodesSection.locator('tr.ant-table-row');
+ await expect(rows).toHaveCount(1);
const firstRowHost = rows.nth(0).getByRole('textbox').first();
await firstRowHost.fill(upstream.nodes[1].host);
await expect(firstRowHost).toHaveValue(upstream.nodes[1].host);
- // Add second node - blur first to trigger sync, then click Add
+ // Add second node. Previously this used `force: true` paired with a
+ // conditional `waitForTimeout(500)` that most callers skipped, leaving a
+ // race against the prior render. We keep `force: true` (the button is
+ // never realistically blocked, and Playwright's stability heuristic can
+ // false-negative on Mantine's hover styles) but also wait for the
+ // post-blur state to stabilize before the click.
await firstRowHost.blur();
- if (page) await page.waitForTimeout(500);
+ await expect(rows).toHaveCount(1);
+ await expect(firstRowHost).toHaveValue(upstream.nodes[1].host);
+ await page.waitForTimeout(300);
await addNodeBtn.click({ force: true });
await expect(rows).toHaveCount(2, { timeout: 10000 });
const secondRowHost = rows.nth(1).getByRole('textbox').first();
await secondRowHost.fill(upstream.nodes[0].host);
await expect(secondRowHost).toHaveValue(upstream.nodes[0].host);
- // Add a third node and then remove it to test deletion functionality
+ // Add a third node and then remove it to test deletion functionality.
await secondRowHost.blur();
- let p: Page;
- if (
- typeof (ctx as Locator).page === 'function'
- ) {
- p = (ctx as Locator).page();
- } else {
- p = ctx as Page;
- }
- await p.waitForTimeout(500);
- await addNodeBtn.click();
+ await expect(rows).toHaveCount(2);
+ await expect(secondRowHost).toHaveValue(upstream.nodes[0].host);
+ await page.waitForTimeout(300);
+ await addNodeBtn.click({ force: true });
await expect(rows).toHaveCount(3, { timeout: 10000 });
await rows.nth(2).getByRole('button', { name: 'Delete' }).click();
await expect(rows).toHaveCount(2);
diff --git a/src/apis/credentials.ts b/src/apis/credentials.ts
index 574459825f..fac0f3e1e2 100644
--- a/src/apis/credentials.ts
+++ b/src/apis/credentials.ts
@@ -36,8 +36,13 @@ export const getCredentialListReq = (req: AxiosInstance, params: WithUsername) =
)
.then((v) => v.data)
.catch((e) => {
- // 404 means credentials is empty
- if (e.response.status === 404) {
+ // Both "404 (credentials never created)" and "no response at all
+ // (network abort / timeout / CORS preflight failure)" are treated as
+ // an empty list so the consumer detail page stays usable. Letting
+ // the original bug pattern through (`e.response.status` on an
+ // undefined response) crashed the whole consumer detail view into
+ // the app-level error boundary.
+ if (!e?.response || e.response.status === 404) {
const res: APISIXListResponse = {
total: 0,
list: [],
diff --git a/src/apis/stream_routes.ts b/src/apis/stream_routes.ts
index c01f58db59..786529f7d4 100644
--- a/src/apis/stream_routes.ts
+++ b/src/apis/stream_routes.ts
@@ -65,10 +65,37 @@ export const postStreamRouteReq = (
);
export const deleteAllStreamRoutes = async (req: AxiosInstance) => {
+ // APISIX deployments without `apisix.proxy_mode: http&stream` reject the
+ // list endpoint with 400 "stream mode is disabled". Treat that as "no
+ // stream routes" so the helper (and `deleteAllServices`, which chains
+ // through it) doesn't bring the whole cleanup path down with it.
const totalRes = await getStreamRouteListReq(req, {
page: 1,
page_size: PAGE_SIZE_MIN,
- });
+ }).catch(
+ (e: {
+ response?: { status?: number; data?: { error_msg?: string } };
+ message?: string;
+ }) => {
+ // Both shapes occur depending on the request adapter: the dashboard's
+ // axios interceptor surfaces `e.response.data.error_msg`, while the
+ // e2e Playwright fetch adapter throws an Error whose `.message`
+ // includes the upstream status and body text.
+ // Require BOTH a 400-class status (or no response at all) AND the
+ // "stream mode" text — a string match alone could swallow unrelated
+ // errors whose messages happen to mention stream mode.
+ const status = e?.response?.status;
+ const isStreamGate =
+ status === undefined || (status >= 400 && status < 500);
+ const haystack = `${e?.response?.data?.error_msg ?? ''} ${e?.message ?? ''}`;
+ if (isStreamGate && /stream mode/i.test(haystack)) {
+ return { total: 0, list: [] } as Awaited<
+ ReturnType
+ >;
+ }
+ throw e;
+ }
+ );
const total = totalRes.total;
if (total === 0) return;
for (let times = Math.ceil(total / PAGE_SIZE_MAX); times > 0; times--) {
diff --git a/src/components/form-slice/FormItemPlugins/PluginCard.tsx b/src/components/form-slice/FormItemPlugins/PluginCard.tsx
index 2d9bca8f56..bd6ad045f0 100644
--- a/src/components/form-slice/FormItemPlugins/PluginCard.tsx
+++ b/src/components/form-slice/FormItemPlugins/PluginCard.tsx
@@ -15,6 +15,7 @@
* limitations under the License.
*/
import { Button, Card,Group, Text } from '@mantine/core';
+import { modals } from '@mantine/modals';
import { useTranslation } from 'react-i18next';
export type PluginCardProps = {
@@ -78,7 +79,22 @@ export const PluginCard = (props: PluginCardProps) => {
size="compact-xs"
variant="light"
color="red"
- onClick={() => onDelete?.(name)}
+ onClick={() =>
+ modals.openConfirmModal({
+ title: t('info.delete.title', { name }),
+ children: (
+
+ {t('info.delete.content', { name })}
+
+ ),
+ labels: {
+ confirm: t('form.btn.delete'),
+ cancel: t('form.btn.cancel'),
+ },
+ confirmProps: { color: 'red' },
+ onConfirm: () => onDelete?.(name),
+ })
+ }
>
{t('form.btn.delete')}
diff --git a/src/components/form-slice/FormItemPlugins/index.tsx b/src/components/form-slice/FormItemPlugins/index.tsx
index d3f3fd8eb2..a03883d19f 100644
--- a/src/components/form-slice/FormItemPlugins/index.tsx
+++ b/src/components/form-slice/FormItemPlugins/index.tsx
@@ -21,17 +21,14 @@ import {
type InputWrapperProps,
} from '@mantine/core';
import { useSuspenseQuery } from '@tanstack/react-query';
-import { toJS } from 'mobx';
-import { useLocalObservable } from 'mobx-react-lite';
import { difference } from 'rambdax';
-import { useEffect, useMemo } from 'react';
+import { useCallback, useMemo, useState } from 'react';
import {
type FieldValues,
useController,
type UseControllerProps,
} from 'react-hook-form';
import { useTranslation } from 'react-i18next';
-import { useDeepCompareEffect } from 'react-use';
import {
getPluginsListWithSchemaQueryOptions,
@@ -50,6 +47,21 @@ export type FormItemPluginsProps = InputWrapperProps &
onChange?: (value: Record) => void;
} & Partial;
+/**
+ * Plugins editor for Route / Service / Upstream forms.
+ *
+ * Architecture: react-hook-form's controller value (`rawObject`) is the
+ * single source of truth. There is no local Map cache that mirrors it —
+ * the previous `useLocalObservable + __map + useEffect([..., rawObject])
+ * + save → fOnChange` triangle created the same bidirectional-sync race
+ * as #3293, where an in-flight `rawObject` update from elsewhere could
+ * reinitialize `__map` from a stale snapshot and overwrite a just-saved
+ * plugin config on Submit. Reading directly from `rawObject` removes the
+ * race entirely.
+ *
+ * Only transient UI state (current open plugin name, drawer open flags,
+ * search query, edit/view mode) lives in component state.
+ */
export const FormItemPlugins = (
props: FormItemPluginsProps
) => {
@@ -60,108 +72,105 @@ export const FormItemPlugins = (
const { t } = useTranslation();
const {
- field: { value: rawObject, onChange: fOnChange, name: fName, ...restField },
+ field: { value: rawValue, onChange: fOnChange, name: fName, ...restField },
fieldState,
} = useController(controllerProps);
const isView = useMemo(() => restField.disabled, [restField.disabled]);
- const pluginsOb = useLocalObservable(() => ({
- __map: new Map(),
- init(obj: Record) {
- this.__map = new Map(Object.entries(obj));
- },
- delete(name: string) {
- this.__map.delete(name);
- this.save();
- },
- allPluginNames: [] as string[],
- pluginSchemaObj: new Map(),
- initPlugins(props: {
- names: string[];
- originObj: Record>;
- }) {
- const { names, originObj } = props;
- this.allPluginNames = names;
- this.pluginSchemaObj = new Map(Object.entries(originObj));
- },
- get selected() {
- return Array.from(this.__map.keys());
- },
- get unSelected() {
- return difference(this.allPluginNames, this.selected);
- },
- save() {
- const obj = Object.fromEntries(toJS(this.__map));
- fOnChange(obj);
- },
- update(config: PluginConfig) {
- const { name, config: pluginConfig } = config;
- this.__map.set(name, pluginConfig);
- this.save();
- this.setSelectPluginsOpened(false);
- },
- curPlugin: {} as PluginConfig,
- setCurPlugin(name: string) {
- this.curPlugin = {
- name,
- config: this.__map.get(name),
- } as PluginConfig;
- this.setEditorOpened(true);
- },
- get curPluginSchema() {
- const d = this.pluginSchemaObj.get(this.curPlugin.name);
- if (!d) return {};
- return d[schema];
- },
- editorOpened: false,
- setEditorOpened(val: boolean) {
- this.editorOpened = val;
- },
- closeEditor() {
- this.setEditorOpened(false);
- this.curPlugin = {} as PluginConfig;
- },
- search: '',
- setSearch(val: string) {
- this.search = val;
- },
- mode: 'edit' as PluginCardProps['mode'],
- selectPluginsOpened: false,
- setSelectPluginsOpened(val: boolean) {
- this.selectPluginsOpened = val;
- },
- on(mode: PluginCardProps['mode'], name: string) {
- this.setCurPlugin(name);
- this.mode = mode;
- },
- }));
+ // rawValue may be `undefined` when the form is first mounted with no
+ // plugins yet — normalize to an empty object for read paths. Wrapped in
+ // useMemo so identity is stable across renders that don't change the
+ // form value (otherwise downstream useCallback / useMemo deps would
+ // churn every render).
+ const rawObject = useMemo(
+ () => (rawValue ?? {}) as Record,
+ [rawValue]
+ );
+
+ // Selected plugins are derived directly from the form value — no mirror.
+ const selected = useMemo(() => Object.keys(rawObject), [rawObject]);
const pluginsListReq = useSuspenseQuery(
getPluginsListWithSchemaQueryOptions({ schema })
);
+ const allPluginNames = pluginsListReq.data.names;
+ const pluginSchemaObj = pluginsListReq.data.originObj as Record<
+ string,
+ APISIXType['PluginSchema']
+ >;
+ const unSelected = useMemo(
+ () => difference(allPluginNames, selected),
+ [allPluginNames, selected]
+ );
+
+ // Pure-UI state. None of these mirror form data.
+ const [curPluginName, setCurPluginName] = useState(null);
+ const [mode, setMode] = useState('edit');
+ const [editorOpened, setEditorOpened] = useState(false);
+ const [selectPluginsOpened, setSelectPluginsOpened] = useState(false);
+ const [search, setSearch] = useState('');
+
+ const curPlugin = useMemo(() => {
+ if (!curPluginName) return {} as PluginConfig;
+ return {
+ name: curPluginName,
+ config: rawObject[curPluginName],
+ } as PluginConfig;
+ }, [curPluginName, rawObject]);
+
+ const curPluginSchema = useMemo(() => {
+ if (!curPluginName) return {};
+ const d = pluginSchemaObj[curPluginName];
+ if (!d) return {};
+ return d[schema];
+ }, [curPluginName, pluginSchemaObj, schema]);
+
+ const closeEditor = useCallback(() => {
+ setEditorOpened(false);
+ setCurPluginName(null);
+ }, []);
+
+ const handleUpdate = useCallback(
+ (config: PluginConfig) => {
+ const { name, config: pluginConfig } = config;
+ // Build the new plugins object inline — no intermediate cache to go
+ // stale. Submit always sees this exact value.
+ fOnChange({ ...rawObject, [name]: pluginConfig });
+ setSelectPluginsOpened(false);
+ closeEditor();
+ },
+ [closeEditor, fOnChange, rawObject]
+ );
- // init the selected plugins
- useEffect(() => {
- pluginsOb.init(rawObject);
- }, [pluginsOb, rawObject]);
- useDeepCompareEffect(() => {
- pluginsOb.initPlugins(pluginsListReq.data);
- }, [pluginsOb, pluginsListReq.data]);
+ const handleDelete = useCallback(
+ (name: string) => {
+ const next = { ...rawObject };
+ delete next[name];
+ fOnChange(next);
+ },
+ [fOnChange, rawObject]
+ );
+
+ const handleOpen = useCallback(
+ (m: PluginCardProps['mode'], name: string) => {
+ setCurPluginName(name);
+ setMode(m);
+ setEditorOpened(true);
+ },
+ []
+ );
return (
-
+
pluginsOb.on('add', name)}
+ plugins={unSelected}
+ opened={selectPluginsOpened}
+ setOpened={setSelectPluginsOpened}
+ onAdd={(name) => handleOpen('add', name)}
disabled={restField.disabled}
/>
@@ -169,19 +178,19 @@ export const FormItemPlugins = (
mode={isView ? 'view' : 'edit'}
placeholder={t('form.plugins.searchForSelectedPlugins')}
mah="60vh"
- search={pluginsOb.search}
- plugins={pluginsOb.selected}
- onDelete={pluginsOb.delete}
- onView={(name) => pluginsOb.on('view', name)}
- onEdit={(name) => pluginsOb.on('edit', name)}
+ search={search}
+ plugins={selected}
+ onDelete={handleDelete}
+ onView={(name) => handleOpen('view', name)}
+ onEdit={(name) => handleOpen('edit', name)}
/>
diff --git a/src/components/form-slice/FormPartUpstream/FormItemNodes.tsx b/src/components/form-slice/FormPartUpstream/FormItemNodes.tsx
index 6f91fb60cb..114e70f025 100644
--- a/src/components/form-slice/FormPartUpstream/FormItemNodes.tsx
+++ b/src/components/form-slice/FormPartUpstream/FormItemNodes.tsx
@@ -16,11 +16,9 @@
*/
import { EditableProTable, type ProColumns } from '@ant-design/pro-components';
import { Button, InputWrapper, type InputWrapperProps } from '@mantine/core';
-import { toJS } from 'mobx';
-import { useLocalObservable } from 'mobx-react-lite';
import { nanoid } from 'nanoid';
-import { equals, isNil } from 'rambdax';
-import { useEffect, useMemo } from 'react';
+import { isNil } from 'rambdax';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
type FieldValues,
useController,
@@ -31,7 +29,6 @@ import type { ZodObject, ZodRawShape } from 'zod';
import { AntdConfigProvider } from '@/config/antdConfigProvider';
import { APISIX, type APISIXType } from '@/types/schema/apisix';
-import { zGetDefault } from '@/utils/zod';
import { genControllerProps } from '../../form/util';
@@ -51,15 +48,6 @@ const zValidateField = (
return Promise.reject(new Error(error.message));
};
-const genRecord = (data?: DataSource | APISIXType['UpstreamNode']) => {
- const d = data || zGetDefault(APISIX.UpstreamNode);
- const id = (d as DataSource).id || nanoid();
- return {
- ...d,
- id,
- } as DataSource;
-};
-
const objToUpstreamNodes = (data: APISIXType['UpstreamNodeObj']) => {
return Object.entries(data).map(([key, val]) => {
const [host, port] = key.split(':');
@@ -73,25 +61,24 @@ const objToUpstreamNodes = (data: APISIXType['UpstreamNodeObj']) => {
});
};
-const parseToDataSource = (data: APISIXType['UpstreamNodeListOrObj']) => {
+const toDataSource = (data: APISIXType['UpstreamNodeListOrObj']): DataSource[] => {
let val: APISIXType['UpstreamNodes'];
if (isNil(data)) val = [];
else if (Array.isArray(data)) val = data as APISIXType['UpstreamNodes'];
else val = objToUpstreamNodes(data as APISIXType['UpstreamNodeObj']);
- return val.map(genRecord);
+ return val.map(
+ (node) => ({ ...node, id: nanoid() }) as DataSource
+ );
};
-const parseToUpstreamNodes = (data: DataSource[] | undefined) => {
+const toUpstreamNodes = (data: DataSource[]): APISIXType['UpstreamNode'][] => {
if (!data?.length) return [];
- return data.map((item) => {
- const d: APISIXType['UpstreamNode'] = {
- host: item.host,
- port: item.port,
- weight: item.weight,
- priority: item.priority,
- };
- return d;
- });
+ return data.map((item) => ({
+ host: item.host,
+ port: item.port,
+ weight: item.weight,
+ priority: item.priority,
+ }));
};
const genProps = (field: keyof APISIXType['UpstreamNode']) => {
@@ -111,6 +98,22 @@ export type FormItemNodesProps =
defaultValue?: APISIXType['UpstreamNode'][];
} & Pick;
+/**
+ * Upstream nodes editor.
+ *
+ * Architecture: react-hook-form is the single source of truth. The
+ * EditableProTable is `controlled={false}` so it manages its own internal
+ * cell editing state — we initialize it once from the form value and only
+ * push changes UP (typing / add / remove → `fOnChange`). We never push
+ * the form value DOWN into the table after mount, which eliminates the
+ * feedback loop that caused #3293.
+ *
+ * The previous design used a MobX `useLocalObservable` mirror plus a
+ * `useEffect([..., value])` that re-derived row ids from the form value
+ * on every Submit-adjacent re-render. The id churn disrupted the table's
+ * editable lifecycle and meant a fresh keystroke could be discarded if
+ * the user clicked Save before the `useClickOutside` blur sync fired.
+ */
export const FormItemNodes = (
props: FormItemNodesProps
) => {
@@ -123,6 +126,95 @@ export const FormItemNodes = (
field: { value, onChange: fOnChange, name: fName, disabled },
fieldState,
} = useController(controllerProps);
+
+ // One-shot initialization of table rows from the form value. Subsequent
+ // form value changes (e.g. a parent useEffect reset) re-mount this
+ // component via React keying at the route level, so a fresh init is
+ // correct without us watching `value` continuously.
+ const [rows, setRows] = useState(() => toDataSource(value));
+ // Keep the latest rows accessible to action handlers without putting
+ // them in their `useCallback` deps (which would re-render the table
+ // and reset cell edit focus on every keystroke).
+ const rowsRef = useRef(rows);
+ rowsRef.current = rows;
+ // Ref on the wrapper so the DOM-flush listener can scope its query.
+ const wrapperRef = useRef(null);
+
+ const pushUp = useCallback(
+ (next: DataSource[]) => {
+ const vals = toUpstreamNodes(next);
+ fOnChange?.(vals);
+ restProps.onChange?.(vals);
+ },
+ [fOnChange, restProps]
+ );
+
+ // Read whatever the user CURRENTLY sees in the cell inputs (DOM values)
+ // and push that to react-hook-form. This is the last-resort sync that
+ // fires when something outside the table is mousedowned — typically the
+ // Submit button — because `valueType: "digit"` cells in
+ // ant-design ProTable don't commit their typed value to Antd Form's
+ // internal state until blur. Without this, a user who types into a
+ // weight/port cell and clicks Save without blurring first would submit
+ // stale (uncommitted) values, which was #3293.
+ const flushFromDom = useCallback(() => {
+ const root = wrapperRef.current;
+ if (!root) return;
+ const rowEls = root.querySelectorAll('tr.ant-table-row');
+ if (rowEls.length === 0) return;
+ const next: DataSource[] = [];
+ rowEls.forEach((rowEl, index) => {
+ const inputs = rowEl.querySelectorAll('input');
+ const existing = rowsRef.current[index];
+ const host = inputs[0]?.value ?? existing?.host ?? '';
+ // ant-design InputNumber renders as type=number; parseInt is safe
+ // because the schema rejects non-integer values upstream.
+ const portRaw = inputs[1]?.value;
+ const weightRaw = inputs[2]?.value;
+ const priorityRaw = inputs[3]?.value;
+ next.push({
+ id: existing?.id ?? nanoid(),
+ host,
+ port:
+ portRaw !== undefined && portRaw !== ''
+ ? Number(portRaw)
+ : (existing?.port ?? 80),
+ weight:
+ weightRaw !== undefined && weightRaw !== ''
+ ? Number(weightRaw)
+ : (existing?.weight ?? 1),
+ priority:
+ priorityRaw !== undefined && priorityRaw !== ''
+ ? Number(priorityRaw)
+ : (existing?.priority ?? 0),
+ } as DataSource);
+ });
+ pushUp(next);
+ }, [pushUp]);
+
+ // Mousedown fires BEFORE the click-induced blur on the active cell,
+ // and BEFORE the click handler on a Submit button. By flushing DOM
+ // values at this point we guarantee react-hook-form sees the current
+ // visible state, regardless of whether Antd Form has committed yet.
+ useEffect(() => {
+ const onMouseDown = (e: MouseEvent) => {
+ const root = wrapperRef.current;
+ if (!root) return;
+ if (root.contains(e.target as Node)) return; // inside the table — ignore
+ flushFromDom();
+ };
+ document.addEventListener('mousedown', onMouseDown, true);
+ document.addEventListener('touchstart', onMouseDown as EventListener, true);
+ return () => {
+ document.removeEventListener('mousedown', onMouseDown, true);
+ document.removeEventListener(
+ 'touchstart',
+ onMouseDown as EventListener,
+ true
+ );
+ };
+ }, [flushFromDom]);
+
const columns = useMemo[]>(
() => [
{
@@ -173,46 +265,51 @@ export const FormItemNodes = (
],
[disabled, t]
);
- const { label, required, withAsterisk } = props;
- const ob = useLocalObservable(() => ({
- disabled: false,
- setDisabled(disabled: boolean | undefined) {
- this.disabled = disabled || false;
- },
- values: [] as DataSource[],
- setValues(data: DataSource[]) {
- if (equals(parseToUpstreamNodes(toJS(this.values)), parseToUpstreamNodes(data))) return;
- this.values = data;
- this.save();
- },
- append(data: DataSource) {
- this.values.push(data);
- },
- remove(id: string) {
- const index = this.values.findIndex((item) => item.id === id);
- if (index === -1) return;
- this.values.splice(index, 1);
- },
- get editableKeys() {
- return this.disabled ? [] : this.values.map((item) => item.id);
+
+ const editableKeys = useMemo(
+ () => (disabled ? [] : rows.map((r) => r.id)),
+ [disabled, rows]
+ );
+
+ const handleValuesChange = useCallback(
+ (_record: DataSource, dataSource: DataSource[]) => {
+ // Cell-level edit: push every keystroke straight to react-hook-form
+ // so a Submit fired before blur still sees the typed values.
+ setRows(dataSource);
+ pushUp(dataSource);
},
- save() {
- const vals = parseToUpstreamNodes(toJS(this.values));
- fOnChange?.(vals);
- restProps.onChange?.(vals);
+ [pushUp]
+ );
+
+ const handleRemove = useCallback(
+ (id: string) => {
+ const next = rowsRef.current.filter((r) => r.id !== id);
+ setRows(next);
+ pushUp(next);
},
- }));
- useEffect(() => {
- ob.setValues(parseToDataSource(value));
- }, [ob, value]);
- useEffect(() => {
- ob.setDisabled(disabled);
- }, [disabled, ob]);
+ [pushUp]
+ );
+ const handleAppend = useCallback(() => {
+ const next: DataSource[] = [
+ ...rowsRef.current,
+ {
+ host: '',
+ port: 80,
+ weight: 1,
+ priority: 0,
+ id: nanoid(),
+ } as DataSource,
+ ];
+ setRows(next);
+ pushUp(next);
+ }, [pushUp]);
+ const { label, required, withAsterisk } = props;
return (
(
rowKey="id"
bordered
controlled={false}
- value={ob.values}
+ value={rows}
recordCreatorProps={false}
columns={columns}
editable={{
type: 'multiple',
- editableKeys: ob.editableKeys,
- onValuesChange(_, dataSource) {
- ob.setValues(dataSource);
- },
+ editableKeys,
+ onValuesChange: handleValuesChange,
actionRender: (row) => {
return [
,
@@ -260,10 +352,7 @@ export const FormItemNodes = (
size="xs"
color="cyan"
style={{ borderColor: 'whitesmoke' }}
- onClick={() => {
- ob.append(genRecord());
- ob.save();
- }}
+ onClick={handleAppend}
{...(disabled && { display: 'none' })}
>
{t('form.upstreams.nodes.add')}
diff --git a/src/components/page-slice/plugin_metadata/PluginMetadata.tsx b/src/components/page-slice/plugin_metadata/PluginMetadata.tsx
index 177ba75c46..8a923f6023 100644
--- a/src/components/page-slice/plugin_metadata/PluginMetadata.tsx
+++ b/src/components/page-slice/plugin_metadata/PluginMetadata.tsx
@@ -17,11 +17,9 @@
import { Drawer, Group } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { useMutation } from '@tanstack/react-query';
-import { toJS } from 'mobx';
-import { useLocalObservable } from 'mobx-react-lite';
import { difference } from 'rambdax';
+import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { useDeepCompareEffect } from 'react-use';
import { deletePluginMetadataReq, putPluginMetadataReq } from '@/apis/plugins';
import type { PluginCardProps } from '@/components/form-slice/FormItemPlugins/PluginCard';
@@ -35,12 +33,26 @@ import {
} from '@/components/form-slice/FormItemPlugins/PluginEditorDrawer';
import { SelectPluginsDrawer } from '@/components/form-slice/FormItemPlugins/SelectPluginsDrawer';
-import { type PluginInfo, usePluginMetadataList } from './hooks';
+import { usePluginMetadataList } from './hooks';
+/**
+ * Plugin Metadata page.
+ *
+ * Architecture: read directly from the `usePluginMetadataList()` query
+ * result (`pluginInfoMap` + `hasConfigNames`). The previous design used a
+ * MobX `__map` / `__schemaMap` mirror that was reinitialized via
+ * `useDeepCompareEffect` on every refetch — a parallel refetch could
+ * clobber an in-flight edit (same anti-pattern as #3293). Reading the
+ * query state directly eliminates the mirror and the race.
+ *
+ * Only transient UI state lives in component state.
+ */
export const PluginMetadata = () => {
const { t } = useTranslation();
- const getMetadataListReq = usePluginMetadataList();
+ const metadataList = usePluginMetadataList();
+ const { pluginInfoMap, hasConfigNames, allPluginNames } = metadataList;
+
const putMetadata = useMutation({
mutationFn: putPluginMetadataReq,
onSuccess(_, variables) {
@@ -50,7 +62,7 @@ export const PluginMetadata = () => {
}),
color: 'green',
});
- getMetadataListReq.refetch();
+ metadataList.refetch();
},
});
const deleteMetadata = useMutation({
@@ -62,107 +74,87 @@ export const PluginMetadata = () => {
}),
color: 'green',
});
- getMetadataListReq.refetch();
+ metadataList.refetch();
},
});
- const pluginsOb = useLocalObservable(() => ({
- __map: new Map(),
- __schemaMap: new Map(),
- init(map: Map, hasConfigNames: string[]) {
- // we need to clear the map first
- this.__map.clear();
- this.__schemaMap.clear();
- this.allPluginNames = [];
- for (const [name, info] of map.entries()) {
- if (hasConfigNames.includes(name)) {
- this.__map.set(name, info);
- }
- this.__schemaMap.set(name, info.schema);
- this.allPluginNames.push(name);
- }
- },
- delete(name: string) {
- deleteMetadata.mutateAsync(name);
- },
- update(config: PluginConfig) {
- putMetadata.mutateAsync(config);
- },
- allPluginNames: [] as string[],
- get selected() {
- return Array.from(this.__map.keys());
- },
- get unSelected() {
- return difference(this.allPluginNames, this.selected);
- },
- curPlugin: {} as PluginConfig,
- curPluginSchema: {} as object,
- setCurPlugin(name: string) {
- this.curPlugin = this.__map.get(name) || { name, config: {} };
- this.curPluginSchema = this.__schemaMap.get(name)!;
- this.setEditorOpened(true);
- },
- editorOpened: false,
- setEditorOpened(val: boolean) {
- this.editorOpened = val;
- },
- closeEditor() {
- this.setEditorOpened(false);
- this.setSelectPluginsOpened(false);
- this.curPlugin = {} as PluginConfig;
- },
- search: '',
- setSearch(val: string) {
- this.search = val;
- },
- mode: 'edit' as PluginCardProps['mode'],
- selectPluginsOpened: false,
- setSelectPluginsOpened(val: boolean) {
- this.selectPluginsOpened = val;
- },
- on(mode: PluginCardProps['mode'], name: string) {
- this.setCurPlugin(name);
- this.mode = mode;
+ // Pure-UI state. None of these mirror query data.
+ const [curPluginName, setCurPluginName] = useState(null);
+ const [mode, setMode] = useState('edit');
+ const [editorOpened, setEditorOpened] = useState(false);
+ const [selectPluginsOpened, setSelectPluginsOpened] = useState(false);
+ const [search, setSearch] = useState('');
+
+ const unSelected = useMemo(
+ () => difference(allPluginNames ?? [], hasConfigNames),
+ [allPluginNames, hasConfigNames]
+ );
+
+ const curPlugin = useMemo(() => {
+ if (!curPluginName) return {} as PluginConfig;
+ const info = pluginInfoMap.get(curPluginName);
+ return info
+ ? ({ name: info.name, config: info.config } as PluginConfig)
+ : ({ name: curPluginName, config: {} } as PluginConfig);
+ }, [curPluginName, pluginInfoMap]);
+
+ const curPluginSchema = useMemo(() => {
+ if (!curPluginName) return {};
+ return pluginInfoMap.get(curPluginName)?.schema ?? {};
+ }, [curPluginName, pluginInfoMap]);
+
+ const closeEditor = useCallback(() => {
+ setEditorOpened(false);
+ setSelectPluginsOpened(false);
+ setCurPluginName(null);
+ }, []);
+
+ const handleOpen = useCallback(
+ (m: PluginCardProps['mode'], name: string) => {
+ setCurPluginName(name);
+ setMode(m);
+ setEditorOpened(true);
},
- }));
+ []
+ );
+
+ const handleUpdate = useCallback(
+ (config: PluginConfig) => putMetadata.mutateAsync(config),
+ [putMetadata]
+ );
- const { pluginInfoMap, hasConfigNames, isLoading } = getMetadataListReq;
- // init the selected plugins
- useDeepCompareEffect(() => {
- if (isLoading) return;
- pluginsOb.init(pluginInfoMap, hasConfigNames);
- }, [pluginInfoMap, hasConfigNames, pluginsOb, isLoading]);
+ const handleDelete = useCallback(
+ (name: string) => deleteMetadata.mutateAsync(name),
+ [deleteMetadata]
+ );
return (
-
+
pluginsOb.on('add', name)}
- opened={pluginsOb.selectPluginsOpened}
- setOpened={pluginsOb.setSelectPluginsOpened}
+ plugins={unSelected}
+ onAdd={(name) => handleOpen('add', name)}
+ opened={selectPluginsOpened}
+ setOpened={setSelectPluginsOpened}
/>
pluginsOb.on('edit', name)}
+ search={search}
+ plugins={hasConfigNames}
+ onDelete={handleDelete}
+ onEdit={(name) => handleOpen('edit', name)}
/>
);
diff --git a/src/hooks/useEditCancelGuard.tsx b/src/hooks/useEditCancelGuard.tsx
new file mode 100644
index 0000000000..1bed93c38e
--- /dev/null
+++ b/src/hooks/useEditCancelGuard.tsx
@@ -0,0 +1,69 @@
+/**
+ * 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.
+ */
+import { Text } from '@mantine/core';
+import { modals } from '@mantine/modals';
+import { useCallback } from 'react';
+import type { FieldValues, UseFormReturn } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+
+/**
+ * Guard the Edit-mode → Cancel handler with a confirmation modal so a
+ * misclick can't silently throw away in-flight changes.
+ *
+ * Why the modal always shows (even on a clean form):
+ *
+ * Every Edit page in this app uses the pattern
+ *
+ * useForm({ disabled: readOnly, defaultValues: ... })
+ * useEffect(() => form.reset(producedValues), [data, form])
+ *
+ * which has two failure modes for any "is the form actually dirty?" check:
+ *
+ * 1. Toggling `disabled` from true→false on Edit re-renders every
+ * controlled input and can fire spurious change events that flip
+ * react-hook-form's `isDirty` to true with no user input.
+ * 2. The `form.reset(...)` inside a useEffect runs whenever the backing
+ * query refetches (tab focus, window focus, mutation success), which
+ * wipes `isDirty` back to false mid-session — so a legitimately
+ * edited form can transiently appear clean.
+ *
+ * Both make `isDirty` unreliable as a "skip the modal" signal. Until the
+ * underlying form lifecycle is restructured (tracked as a follow-up
+ * cleanup), we always show the modal. One extra click is a far better
+ * failure mode than silently losing an edit.
+ */
+export const useEditCancelGuard = (
+ form: UseFormReturn,
+ onCancel: () => void
+) => {
+ const { t } = useTranslation();
+ return useCallback(() => {
+ modals.openConfirmModal({
+ centered: true,
+ title: t('info.unsaved.title'),
+ children: {t('info.unsaved.content')},
+ labels: {
+ confirm: t('info.unsaved.confirm'),
+ cancel: t('form.btn.cancel'),
+ },
+ onConfirm: () => {
+ form.reset();
+ onCancel();
+ },
+ });
+ }, [form, onCancel, t]);
+};
diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx
index bcce5948eb..0ce4863242 100644
--- a/src/routes/__root.tsx
+++ b/src/routes/__root.tsx
@@ -52,8 +52,12 @@ const Root = () => {
-
-
+ {import.meta.env.MODE !== 'test' && (
+ <>
+
+
+ >
+ )}
);
diff --git a/src/routes/consumer_groups/detail.$id.tsx b/src/routes/consumer_groups/detail.$id.tsx
index 8d396ab956..8e9895622c 100644
--- a/src/routes/consumer_groups/detail.$id.tsx
+++ b/src/routes/consumer_groups/detail.$id.tsx
@@ -38,6 +38,7 @@ import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn';
import PageHeader from '@/components/page/PageHeader';
import { API_CONSUMER_GROUPS } from '@/config/constant';
import { req } from '@/config/req';
+import { useEditCancelGuard } from '@/hooks/useEditCancelGuard';
import { APISIX, type APISIXType } from '@/types/schema/apisix';
import { pipeProduce } from '@/utils/producer';
@@ -79,6 +80,8 @@ const ConsumerGroupDetailForm = (props: Props) => {
form.reset(data.value);
}, [form, data.value]);
+ const handleCancel = useEditCancelGuard(form, () => setReadOnly(true));
+
if (!data) return ;
return (
@@ -93,7 +96,7 @@ const ConsumerGroupDetailForm = (props: Props) => {
{!readOnly && (
{t('form.btn.save')}
-
diff --git a/src/routes/consumers/detail.$username/credentials/detail.$id.tsx b/src/routes/consumers/detail.$username/credentials/detail.$id.tsx
index ff2c2f69c5..2684840bcf 100644
--- a/src/routes/consumers/detail.$username/credentials/detail.$id.tsx
+++ b/src/routes/consumers/detail.$username/credentials/detail.$id.tsx
@@ -38,6 +38,7 @@ import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn';
import PageHeader from '@/components/page/PageHeader';
import { API_CREDENTIALS } from '@/config/constant';
import { req } from '@/config/req';
+import { useEditCancelGuard } from '@/hooks/useEditCancelGuard';
import { APISIX, type APISIXType } from '@/types/schema/apisix';
import { pipeProduce } from '@/utils/producer';
@@ -86,6 +87,8 @@ const CredentialDetailForm = (props: CredentialFormProps) => {
},
});
+ const handleCancel = useEditCancelGuard(form, () => setReadOnly(true));
+
if (isLoading) {
return ;
}
@@ -98,7 +101,7 @@ const CredentialDetailForm = (props: CredentialFormProps) => {
{!readOnly && (
{t('form.btn.save')}
- setReadOnly(true)}>
+
{t('form.btn.cancel')}
diff --git a/src/routes/consumers/detail.$username/index.tsx b/src/routes/consumers/detail.$username/index.tsx
index c87835c5fd..db71c37cf3 100644
--- a/src/routes/consumers/detail.$username/index.tsx
+++ b/src/routes/consumers/detail.$username/index.tsx
@@ -38,6 +38,7 @@ import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn';
import PageHeader from '@/components/page/PageHeader';
import { API_CONSUMERS } from '@/config/constant';
import { req } from '@/config/req';
+import { useEditCancelGuard } from '@/hooks/useEditCancelGuard';
import { APISIX, type APISIXType } from '@/types/schema/apisix';
import { pipeProduce } from '@/utils/producer';
@@ -80,6 +81,8 @@ const ConsumerDetailForm = (props: Props) => {
},
});
+ const handleCancel = useEditCancelGuard(form, () => setReadOnly(true));
+
if (isLoading) {
return ;
}
@@ -96,7 +99,7 @@ const ConsumerDetailForm = (props: Props) => {
{!readOnly && (
{t('form.btn.save')}
- setReadOnly(true)}>
+
{t('form.btn.cancel')}
diff --git a/src/routes/global_rules/detail.$id.tsx b/src/routes/global_rules/detail.$id.tsx
index 9000a8a724..29e0122426 100644
--- a/src/routes/global_rules/detail.$id.tsx
+++ b/src/routes/global_rules/detail.$id.tsx
@@ -38,6 +38,7 @@ import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn';
import PageHeader from '@/components/page/PageHeader';
import { API_GLOBAL_RULES } from '@/config/constant';
import { req } from '@/config/req';
+import { useEditCancelGuard } from '@/hooks/useEditCancelGuard';
import { APISIX, type APISIXType } from '@/types/schema/apisix';
type Props = {
@@ -77,6 +78,8 @@ const GlobalRuleDetailForm = (props: Props) => {
},
});
+ const handleCancel = useEditCancelGuard(form, () => setReadOnly(true));
+
return (