This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch v3-4-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-4-test by this push:
     new 0f0dd7e83b7 [v3-4-test] Stop UI unit tests from leaking requests to 
the network (#75084) (#75139)
0f0dd7e83b7 is described below

commit 0f0dd7e83b7e543dfedc19df93c66ba1249a4527
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Tue Oct 20 00:48:41 2026 +0200

    [v3-4-test] Stop UI unit tests from leaking requests to the network 
(#75084) (#75139)
    
    Every run of the React UI unit tests printed about 13,000 lines of
    "AggregateError: connect ECONNREFUSED ::1:3000 / 127.0.0.1:3000". The
    shared msw server bypassed unhandled requests to localhost:3000, where
    nothing listens, so the noise buried real failures and some tests
    quietly exercised the "request failed" path instead of the one they
    meant to.
    
    The requests came from three places:
    
    - Endpoints with no handler: the page shell (auth/me, auth/menus,
      version, plugins, import errors, Dag warnings), the i18n bundles,
      generic Dag, Dag run, task and task instance lookups, the grid and
      graph structure, the Admin/Browse list pages, Dag list state counts
      and teams, and the asset state store entry.
    - Test files that started a second msw server next to the one in
      testsSetup.ts. Two servers share msw's interception, so the global
      one shadowed the file's handlers and closing either released
      requests to the network.
    - Requests sent while modules were still being imported (src/i18n/config
      asks for the version and languages at module scope), before the
      server started listening in beforeAll.
    
    Add realistic default handlers, export one shared server that tests
    extend with server.use(), start it at import time, and leave it open
    since each test file runs in its own worker. Unhandled requests now
    use the "error" strategy and fail the test that sent them. Two tests
    needed adjusting: NotePreview no longer follows an external link to
    airflow.apache.org, and Callbacks looks its button up again after the
    translation bundles re-render the rows. The DagBreadcrumb loading-state
    test now holds the run request pending explicitly instead of relying
    on it failing.
    (cherry picked from commit 941316b4640a58c1b71858218080a6c936375353)
    
    
    Generated-by: Claude Opus 5
    
    Co-authored-by: Jarek Potiuk <[email protected]>
---
 .../TaskInstance/ClearTaskInstanceDialog.test.tsx  |  15 +---
 .../airflow/ui/src/components/NotePreview.test.tsx |   8 +-
 .../ui/src/layouts/Details/DagBreadcrumb.test.tsx  |   4 +
 .../ui/src/mocks/handlers/{assets.ts => admin.ts}  |  38 +++-----
 .../src/airflow/ui/src/mocks/handlers/app.ts       |  57 ++++++++++++
 .../src/airflow/ui/src/mocks/handlers/assets.ts    |   8 ++
 .../src/airflow/ui/src/mocks/handlers/dag.ts       |  53 +++++++++++
 .../src/airflow/ui/src/mocks/handlers/dag_runs.ts  |  23 +++++
 .../src/airflow/ui/src/mocks/handlers/dags.ts      |   3 +
 .../src/airflow/ui/src/mocks/handlers/index.ts     |   6 ++
 .../ui/src/mocks/handlers/task_instances.test.ts   |  43 +++++++++
 .../ui/src/mocks/handlers/task_instances.ts        | 100 +++++++++++++++++++++
 .../ui/src/mocks/{handlers/index.ts => server.ts}  |  24 ++---
 .../ui/src/pages/AssetsList/AssetsList.test.tsx    |  15 +---
 .../airflow/ui/src/pages/Dag/DagHeader.test.tsx    |  14 +--
 .../ui/src/pages/Dag/RequiredActionsRoute.test.tsx |  14 +--
 .../airflow/ui/src/pages/Dag/Tasks/Tasks.test.tsx  |  12 +--
 .../ui/src/pages/DagBundles/DagBundles.test.tsx    |  15 +---
 .../DagsList/DagsFilters/DagsFilters.test.tsx      |  13 +--
 .../ui/src/pages/DagsList/DagsList.test.tsx        |  14 +--
 .../airflow/ui/src/pages/Run/Callbacks.test.tsx    |   5 +-
 .../src/airflow/ui/src/pages/XCom/XCom.test.tsx    |  14 +--
 .../airflow/ui/src/pages/XCom/XComEntry.test.tsx   |   8 +-
 .../src/airflow/ui/src/queries/useDags.test.tsx    |   9 +-
 .../airflow/ui/src/queries/useTogglePause.test.tsx |  10 +--
 airflow-core/src/airflow/ui/testsSetup.ts          |  37 ++++++--
 26 files changed, 385 insertions(+), 177 deletions(-)

diff --git 
a/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceDialog.test.tsx
 
b/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceDialog.test.tsx
index ccf766bb46a..0d5135cedf5 100644
--- 
a/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceDialog.test.tsx
+++ 
b/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceDialog.test.tsx
@@ -19,8 +19,7 @@
 import "@testing-library/jest-dom/vitest";
 import { fireEvent, render, screen, waitFor, within } from 
"@testing-library/react";
 import { http, HttpResponse } from "msw";
-import { setupServer, type SetupServer } from "msw/node";
-import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 
"vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
 
 import type { TaskInstanceResponse } from "openapi/requests/types.gen";
 
@@ -31,7 +30,7 @@ import {
   CLEAR_PREVENT_RUNNING_TASK_KEY,
   CLEAR_TASK_INSTANCE_DEFAULT_OPTIONS_KEY,
 } from "src/constants/localStorage";
-import { handlers } from "src/mocks/handlers";
+import { server } from "src/mocks/server";
 import { Wrapper } from "src/utils/Wrapper";
 
 import { ClearTaskInstanceButton } from "./ClearTaskInstanceButton";
@@ -118,25 +117,19 @@ vi.mock("src/queries/useClearTaskInstancesDryRun", () => 
({
   },
 }));
 
-let server: SetupServer;
-
-beforeAll(() => {
-  server = setupServer(
-    ...handlers,
+beforeEach(() => {
+  server.use(
     http.get(`/api/v2/dags/${DAG_ID}/details`, () => HttpResponse.json({})),
     http.get(`/api/v2/dags/${DAG_ID}/dagRuns/${DAG_RUN_ID}`, () => 
HttpResponse.json({ dag_versions: [] })),
   );
-  server.listen({ onUnhandledFrame: "bypass" });
 });
 afterEach(() => {
   affectedTasks.task_instances = [taskInstance];
   confirmationDryRun.isFetching = false;
   mutateMock.mockClear();
   dryRunsMock.mockClear();
-  server.resetHandlers();
   localStorage.clear();
 });
-afterAll(() => server.close());
 
 describe("ClearTaskInstanceDialog", () => {
   it("seeds the keep-task-state checkbox from the stored default and sends it 
on confirm", async () => {
diff --git a/airflow-core/src/airflow/ui/src/components/NotePreview.test.tsx 
b/airflow-core/src/airflow/ui/src/components/NotePreview.test.tsx
index fa5c5e50f49..acda9796410 100644
--- a/airflow-core/src/airflow/ui/src/components/NotePreview.test.tsx
+++ b/airflow-core/src/airflow/ui/src/components/NotePreview.test.tsx
@@ -33,6 +33,8 @@ const defaultProps = {
   setNote: vi.fn(),
 };
 
+const preventDefault = (event: Event) => event.preventDefault();
+
 const renderNote = (props: Partial<typeof defaultProps> = {}) =>
   render(<NotePreview {...defaultProps} {...props} />, { wrapper: Wrapper });
 
@@ -100,7 +102,11 @@ describe("NotePreview", () => {
 
       renderNote({ note: "see [the docs](https://airflow.apache.org)", onOpen 
});
 
-      fireEvent.click(screen.getByRole("link", { name: "the docs" }));
+      const link = screen.getByRole("link", { name: "the docs" });
+
+      // Keep happy-dom from following the link to the real site; the row only 
looks at the target.
+      link.addEventListener("click", preventDefault);
+      fireEvent.click(link);
 
       expect(onOpen).not.toHaveBeenCalled();
       expect(screen.queryByTestId("edit-markdown")).toBeNull();
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/DagBreadcrumb.test.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/DagBreadcrumb.test.tsx
index 9b03304972a..c9bf3561994 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/DagBreadcrumb.test.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/DagBreadcrumb.test.tsx
@@ -106,6 +106,10 @@ describe("DagBreadcrumb", () => {
   });
 
   it("keeps the state badge's room while the state is still loading", async () 
=> {
+    vi.spyOn(DagRunService, "getDagRun").mockReturnValue(
+      new Promise(() => undefined) as ReturnType<typeof 
DagRunService.getDagRun>,
+    );
+
     render(<DagBreadcrumb />, {
       wrapper: createWrapper(`/dags/${DAG_ID}/runs/run_1`, 
"/dags/:dagId/runs/:runId"),
     });
diff --git a/airflow-core/src/airflow/ui/src/mocks/handlers/assets.ts 
b/airflow-core/src/airflow/ui/src/mocks/handlers/admin.ts
similarity index 50%
copy from airflow-core/src/airflow/ui/src/mocks/handlers/assets.ts
copy to airflow-core/src/airflow/ui/src/mocks/handlers/admin.ts
index cf668954abd..857170e6fc7 100644
--- a/airflow-core/src/airflow/ui/src/mocks/handlers/assets.ts
+++ b/airflow-core/src/airflow/ui/src/mocks/handlers/admin.ts
@@ -18,33 +18,15 @@
  */
 import { http, HttpResponse, type HttpHandler } from "msw";
 
-const assetWithDependencies = {
-  aliases: [{ group: "alias_group", id: 1, name: "my_alias" }],
-  consuming_tasks: [{ created_at: "2025-01-01T00:00:00Z", dag_id: 
"consumer_dag", task_id: "consumer_task" }],
-  created_at: "2025-01-01T00:00:00Z",
-  extra: { owner: "data_team" },
-  group: "asset_group",
-  id: 1,
-  last_asset_event: { id: 10, timestamp: "2025-01-15T00:00:00Z" },
-  name: "asset_with_dependencies",
-  producing_tasks: [],
-  scheduled_dags: [],
-  updated_at: "2025-01-02T00:00:00Z",
-  uri: "s3://bucket/asset",
-  watchers: [{ created_date: "2025-01-01T00:00:00Z", name: "my_watcher", 
trigger_id: 5 }],
-};
-
-const plainAsset = {
-  ...assetWithDependencies,
-  aliases: [],
-  id: 2,
-  name: "plain_asset",
-  watchers: [],
-};
-
+// The Admin and Browse list pages, empty by default.
 export const handlers: Array<HttpHandler> = [
-  http.get("/ui/assets", () => HttpResponse.json({ assets: 
[assetWithDependencies], total_entries: 1 })),
-  http.get("/api/v2/assets/:assetId", ({ params }) =>
-    HttpResponse.json(params.assetId === "2" ? plainAsset : 
assetWithDependencies),
-  ),
+  http.get("/api/v2/config", () => HttpResponse.json({ sections: [] })),
+  http.get("/api/v2/connections", () => HttpResponse.json({ connections: [], 
total_entries: 0 })),
+  http.get("/ui/connections/hook_meta", () => HttpResponse.json([])),
+  http.get("/api/v2/dagBundles", () => HttpResponse.json({ dag_bundles: [], 
total_entries: 0 })),
+  http.get("/api/v2/eventLogs", () => HttpResponse.json({ event_logs: [], 
total_entries: 0 })),
+  http.get("/api/v2/jobs", () => HttpResponse.json({ jobs: [], total_entries: 
0 })),
+  http.get("/api/v2/pools", () => HttpResponse.json({ pools: [], 
total_entries: 0 })),
+  http.get("/api/v2/providers", () => HttpResponse.json({ providers: [], 
total_entries: 0 })),
+  http.get("/api/v2/variables", () => HttpResponse.json({ total_entries: 0, 
variables: [] })),
 ];
diff --git a/airflow-core/src/airflow/ui/src/mocks/handlers/app.ts 
b/airflow-core/src/airflow/ui/src/mocks/handlers/app.ts
new file mode 100644
index 00000000000..7a261dfd9e7
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/mocks/handlers/app.ts
@@ -0,0 +1,57 @@
+/*!
+ * 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 { http, HttpResponse, type HttpHandler } from "msw";
+
+import type { MenuItem } from "openapi/requests/types.gen";
+
+const ALL_MENU_ITEMS: Array<MenuItem> = [
+  "Required Actions",
+  "Assets",
+  "Audit Log",
+  "Config",
+  "Connections",
+  "Dags",
+  "Dag Bundles",
+  "Deadlines",
+  "Docs",
+  "Jobs",
+  "Plugins",
+  "Pools",
+  "Providers",
+  "Variables",
+  "XComs",
+];
+
+// Requests every page shell makes (navigation, user menu, version, banners) 
plus the i18n bundles.
+export const handlers: Array<HttpHandler> = [
+  http.get("/ui/auth/me", () => HttpResponse.json({ id: "1", teams: null, 
username: "admin" })),
+  http.get("/ui/auth/menus", () =>
+    HttpResponse.json({ authorized_menu_items: ALL_MENU_ITEMS, 
extra_menu_items: [] }),
+  ),
+  http.get("/api/v2/version", () => HttpResponse.json({ git_version: null, 
version: "3.2.0" })),
+  http.get("/api/v2/plugins", () => HttpResponse.json({ plugins: [], 
total_entries: 0 })),
+  http.get("/api/v2/plugins/importErrors", () => HttpResponse.json({ 
import_errors: [], total_entries: 0 })),
+  http.get("/api/v2/importErrors", () => HttpResponse.json({ import_errors: 
[], total_entries: 0 })),
+  http.get("/api/v2/dagWarnings", () => HttpResponse.json({ dag_warnings: [], 
total_entries: 0 })),
+  // Matched under any base path, since src/i18n/config prefixes these with 
the app's `<base href>`.
+  http.get("*/static/i18n/languages.json", () => HttpResponse.json({ 
languages: [] })),
+  // Tests render translation keys rather than English text: serve empty 
bundles so i18next settles
+  // on the keys. A test that needs real strings adds the bundle itself with 
`addResourceBundle`.
+  http.get("*/static/i18n/locales/:lng/:ns", () => HttpResponse.json({})),
+];
diff --git a/airflow-core/src/airflow/ui/src/mocks/handlers/assets.ts 
b/airflow-core/src/airflow/ui/src/mocks/handlers/assets.ts
index cf668954abd..4bbae3e92cf 100644
--- a/airflow-core/src/airflow/ui/src/mocks/handlers/assets.ts
+++ b/airflow-core/src/airflow/ui/src/mocks/handlers/assets.ts
@@ -47,4 +47,12 @@ export const handlers: Array<HttpHandler> = [
   http.get("/api/v2/assets/:assetId", ({ params }) =>
     HttpResponse.json(params.assetId === "2" ? plainAsset : 
assetWithDependencies),
   ),
+  http.get("/api/v2/assets/:assetId/state-store/:key", ({ params }) =>
+    HttpResponse.json({
+      key: String(params.key),
+      last_updated_by: null,
+      updated_at: "2025-01-15T00:00:00Z",
+      value: "ready",
+    }),
+  ),
 ];
diff --git a/airflow-core/src/airflow/ui/src/mocks/handlers/dag.ts 
b/airflow-core/src/airflow/ui/src/mocks/handlers/dag.ts
index 7fbe561e454..902a2a6d18a 100644
--- a/airflow-core/src/airflow/ui/src/mocks/handlers/dag.ts
+++ b/airflow-core/src/airflow/ui/src/mocks/handlers/dag.ts
@@ -58,6 +58,59 @@ export const MOCK_DAG = {
   timezone: "UTC",
 };
 
+const buildDag = (dagId: string) => ({ ...MOCK_DAG, dag_display_name: dagId, 
dag_id: dagId, tags: [] });
+
+const buildTask = (taskId: string) => ({
+  class_ref: null,
+  depends_on_past: false,
+  doc_md: null,
+  downstream_task_ids: [],
+  end_date: null,
+  execution_timeout: null,
+  extra_links: [],
+  is_mapped: false,
+  operator_name: "PythonOperator",
+  owner: "airflow",
+  params: {},
+  pool: "default_pool",
+  pool_slots: 1,
+  priority_weight: 1,
+  queue: "default",
+  retries: 0,
+  retry_delay: null,
+  retry_exponential_backoff: 0,
+  start_date: "2021-01-01T00:00:00Z",
+  task_display_name: taskId,
+  task_id: taskId,
+  template_fields: [],
+  trigger_rule: "all_success",
+  ui_color: "#ffefeb",
+  ui_fgcolor: "#000",
+  wait_for_downstream: false,
+  weight_rule: "downstream",
+});
+
 export const handlers: Array<HttpHandler> = [
   http.get("/api/v2/dags/tutorial_taskflow_api/details", () => 
HttpResponse.json(MOCK_DAG)),
+  http.get("/api/v2/dags/:dagId/details", ({ params }) => 
HttpResponse.json(buildDag(String(params.dagId)))),
+  http.get("/api/v2/dags/:dagId", ({ params }) => 
HttpResponse.json(buildDag(String(params.dagId)))),
+  http.get("/api/v2/dags/:dagId/tasks/:taskId", ({ params }) =>
+    HttpResponse.json(buildTask(String(params.taskId))),
+  ),
+  http.get("/api/v2/dagTags", () =>
+    HttpResponse.json({ tags: ["example", "example_tag", "other_tag"], 
total_entries: 3 }),
+  ),
+  http.get("/ui/dags/folders", () => HttpResponse.json({ folders: [], 
total_entries: 0 })),
+  http.get("/ui/dags/:dagId/latest_run", () => HttpResponse.json(null)),
+  http.get("/ui/dags/:dagId/deadlineAlerts", () =>
+    HttpResponse.json({ deadline_alerts: [], total_entries: 0 }),
+  ),
+  // An empty grid: no runs, so the TI summaries stream is never opened by the 
grid itself.
+  http.get("/ui/grid/runs/:dagId", () => HttpResponse.json([])),
+  http.get("/ui/grid/structure/:dagId", () => HttpResponse.json([])),
+  http.get(
+    "/ui/grid/ti_summaries/:dagId",
+    () => new HttpResponse("", { headers: { "Content-Type": 
"application/x-ndjson" } }),
+  ),
+  http.get("/ui/structure/structure_data", () => HttpResponse.json({ edges: 
[], nodes: [] })),
 ];
diff --git a/airflow-core/src/airflow/ui/src/mocks/handlers/dag_runs.ts 
b/airflow-core/src/airflow/ui/src/mocks/handlers/dag_runs.ts
index 2854fff56d4..2cc4a5dc97f 100644
--- a/airflow-core/src/airflow/ui/src/mocks/handlers/dag_runs.ts
+++ b/airflow-core/src/airflow/ui/src/mocks/handlers/dag_runs.ts
@@ -90,7 +90,30 @@ const dagIdTags: Record<string, Array<string>> = {
   test_dag: [],
 };
 
+const buildDagRun = (dagId: string, dagRunId: string) => ({
+  ...dagRunInRange,
+  bundle_version: null,
+  conf: null,
+  dag_display_name: dagId,
+  dag_id: dagId,
+  dag_run_id: dagRunId,
+  last_scheduling_decision: null,
+  note: null,
+  partition_date: null,
+  queued_at: dagRunInRange.start_date,
+  triggered_by: "ui",
+});
+
 export const handlers: Array<HttpHandler> = [
+  http.get("/api/v2/dags/:dagId/dagRuns/:dagRunId", ({ params }) =>
+    HttpResponse.json(buildDagRun(String(params.dagId), 
String(params.dagRunId))),
+  ),
+  http.get("/api/v2/dags/:dagId/dagRuns/:dagRunId/hitlDetails", () =>
+    HttpResponse.json({ hitl_details: [], total_entries: 0 }),
+  ),
+  http.get("/ui/dags/:dagId/dagRuns/:dagRunId/deadlines", () =>
+    HttpResponse.json({ deadlines: [], total_entries: 0 }),
+  ),
   http.get("/api/v2/dags/:dagId/dagRuns", ({ request }) => {
     const url = new URL(request.url);
     const logicalDateGte = url.searchParams.get("logical_date_gte");
diff --git a/airflow-core/src/airflow/ui/src/mocks/handlers/dags.ts 
b/airflow-core/src/airflow/ui/src/mocks/handlers/dags.ts
index 64b78391d8f..a9f3570e6c4 100644
--- a/airflow-core/src/airflow/ui/src/mocks/handlers/dags.ts
+++ b/airflow-core/src/airflow/ui/src/mocks/handlers/dags.ts
@@ -231,6 +231,9 @@ export const handlers: Array<HttpHandler> = [
     });
   }),
   dagsListHandler(),
+  http.get("/ui/dags/run_state_counts", () => HttpResponse.json({ dags: [], 
state_count_limit: 1000 })),
+  http.get("/ui/dags/recent_task_instance_state_counts", () => 
HttpResponse.json({ dags: [] })),
+  http.get("/ui/teams", () => HttpResponse.json({ teams: [], total_entries: 0 
})),
   http.get("/api/v2/dags", ({ request }) => {
     const url = new URL(request.url);
     const lastDagRunState = url.searchParams.get("last_dag_run_state");
diff --git a/airflow-core/src/airflow/ui/src/mocks/handlers/index.ts 
b/airflow-core/src/airflow/ui/src/mocks/handlers/index.ts
index 57f95730c1b..eaf269e09de 100644
--- a/airflow-core/src/airflow/ui/src/mocks/handlers/index.ts
+++ b/airflow-core/src/airflow/ui/src/mocks/handlers/index.ts
@@ -16,6 +16,8 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+import { handlers as adminHandlers } from "./admin";
+import { handlers as appHandlers } from "./app";
 import { handlers as assetsHandlers } from "./assets";
 import { handlers as backfillsHandlers } from "./backfills";
 import { handlers as configHandlers } from "./config";
@@ -23,6 +25,7 @@ import { handlers as dagHandlers } from "./dag";
 import { handlers as dagRunsHandlers } from "./dag_runs";
 import { handlers as dagsHandlers } from "./dags";
 import { handlers as logHandlers } from "./log";
+import { handlers as taskInstancesHandlers } from "./task_instances";
 
 export const handlers = [
   ...backfillsHandlers,
@@ -32,4 +35,7 @@ export const handlers = [
   ...dagRunsHandlers,
   ...dagsHandlers,
   ...logHandlers,
+  ...adminHandlers,
+  ...appHandlers,
+  ...taskInstancesHandlers,
 ];
diff --git 
a/airflow-core/src/airflow/ui/src/mocks/handlers/task_instances.test.ts 
b/airflow-core/src/airflow/ui/src/mocks/handlers/task_instances.test.ts
new file mode 100644
index 00000000000..dd7b536e0f4
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/mocks/handlers/task_instances.test.ts
@@ -0,0 +1,43 @@
+/*!
+ * 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 { describe, expect, it } from "vitest";
+
+import { handlers } from "./task_instances";
+
+const TASK_INSTANCE_URL = 
"http://localhost:3000/api/v2/dags/dag_1/dagRuns/run_1/taskInstances/task_1";;
+
+const findMatchingHandlers = async (url: string) => {
+  const matches = await Promise.all(
+    handlers.map((handler) => handler.test({ request: new Request(url, { 
method: "GET" }) })),
+  );
+
+  return handlers.filter((_, index) => matches[index]);
+};
+
+describe("task instance mock handlers", () => {
+  it("answer a mapped task instance by its map index", async () => {
+    expect(await 
findMatchingHandlers(`${TASK_INSTANCE_URL}/0`)).toHaveLength(1);
+  });
+
+  // A matching handler that returns nothing passes the request through to the 
network without
+  // msw reporting it as unhandled, so sub-resources must not match at all.
+  it.each(["links", "tries"])("leave the %s sub-resource unmatched", async 
(subResource) => {
+    expect(await 
findMatchingHandlers(`${TASK_INSTANCE_URL}/${subResource}`)).toHaveLength(0);
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/mocks/handlers/task_instances.ts 
b/airflow-core/src/airflow/ui/src/mocks/handlers/task_instances.ts
new file mode 100644
index 00000000000..d7675b23f1e
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/mocks/handlers/task_instances.ts
@@ -0,0 +1,100 @@
+/*!
+ * 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 { http, HttpResponse, type HttpHandler, type PathParams } from "msw";
+
+const buildTaskInstance = (params: PathParams, mapIndex: number) => {
+  const dagId = String(params.dagId);
+  const taskId = String(params.taskId);
+
+  return {
+    dag_display_name: dagId,
+    dag_id: dagId,
+    dag_run_id: String(params.dagRunId),
+    dag_version: null,
+    duration: 0.2,
+    end_date: "2025-01-15T00:00:01Z",
+    executor: null,
+    executor_config: "{}",
+    hostname: "worker",
+    id: "01951900-16f6-7c1c-ae66-91bdfe9e0cfd",
+    in_loop: false,
+    logical_date: "2025-01-15T00:00:00Z",
+    map_index: mapIndex,
+    max_tries: 0,
+    note: null,
+    operator: "PythonOperator",
+    operator_name: "PythonOperator",
+    pid: 1234,
+    pool: "default_pool",
+    pool_slots: 1,
+    priority_weight: 1,
+    queue: "default",
+    queued_when: "2025-01-15T00:00:00Z",
+    rendered_fields: {},
+    rendered_map_index: null,
+    run_after: "2025-01-15T00:00:00Z",
+    scheduled_when: "2025-01-15T00:00:00Z",
+    start_date: "2025-01-15T00:00:00Z",
+    state: "success",
+    task_display_name: taskId,
+    task_id: taskId,
+    trigger: null,
+    triggerer_job: null,
+    try_number: 1,
+    unixname: "airflow",
+  };
+};
+
+const MAPPED_TASK_INSTANCE_PATH =
+  
/\/api\/v2\/dags\/([^/]+)\/dagRuns\/([^/]+)\/taskInstances\/([^/]+)\/(-?\d+)$/u;
+
+// Registered after the more specific task instance handlers (e.g. 
src/mocks/handlers/log.ts),
+// so these only answer what no fixture claims.
+export const handlers: Array<HttpHandler> = [
+  http.get("/api/v2/dags/:dagId/dagRuns/:dagRunId/taskInstances", () =>
+    HttpResponse.json({ task_instances: [], total_entries: 0 }),
+  ),
+  http.get("/api/v2/dags/:dagId/dagRuns/:dagRunId/taskInstances/:taskId", ({ 
params }) =>
+    HttpResponse.json(buildTaskInstance(params, -1)),
+  ),
+  
http.get("/api/v2/dags/:dagId/dagRuns/:dagRunId/taskInstances/:taskId/listMapped",
 () =>
+    HttpResponse.json({ task_instances: [], total_entries: 0 }),
+  ),
+  
http.get("/api/v2/dags/:dagId/dagRuns/:dagRunId/taskInstances/:taskId/xcomEntries",
 () =>
+    HttpResponse.json({ total_entries: 0, xcom_entries: [] }),
+  ),
+  // Matches integer map indexes only: a handler that declines with 
`undefined` lets msw pass the
+  // request through to the network without reporting it as unhandled, so 
sub-resources such as
+  // `/tries` or `/links` must not match here at all.
+  http.get(MAPPED_TASK_INSTANCE_PATH, ({ request }) => {
+    const [, dagId, dagRunId, taskId, mapIndex] =
+      MAPPED_TASK_INSTANCE_PATH.exec(new URL(request.url).pathname) ?? [];
+
+    return HttpResponse.json(
+      buildTaskInstance(
+        {
+          dagId: decodeURIComponent(dagId ?? ""),
+          dagRunId: decodeURIComponent(dagRunId ?? ""),
+          taskId: decodeURIComponent(taskId ?? ""),
+        },
+        Number(mapIndex),
+      ),
+    );
+  }),
+];
diff --git a/airflow-core/src/airflow/ui/src/mocks/handlers/index.ts 
b/airflow-core/src/airflow/ui/src/mocks/server.ts
similarity index 59%
copy from airflow-core/src/airflow/ui/src/mocks/handlers/index.ts
copy to airflow-core/src/airflow/ui/src/mocks/server.ts
index 57f95730c1b..512505a4d36 100644
--- a/airflow-core/src/airflow/ui/src/mocks/handlers/index.ts
+++ b/airflow-core/src/airflow/ui/src/mocks/server.ts
@@ -16,20 +16,12 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { handlers as assetsHandlers } from "./assets";
-import { handlers as backfillsHandlers } from "./backfills";
-import { handlers as configHandlers } from "./config";
-import { handlers as dagHandlers } from "./dag";
-import { handlers as dagRunsHandlers } from "./dag_runs";
-import { handlers as dagsHandlers } from "./dags";
-import { handlers as logHandlers } from "./log";
+import { setupServer, type SetupServer } from "msw/node";
 
-export const handlers = [
-  ...backfillsHandlers,
-  ...assetsHandlers,
-  ...configHandlers,
-  ...dagHandlers,
-  ...dagRunsHandlers,
-  ...dagsHandlers,
-  ...logHandlers,
-];
+import { handlers } from "src/mocks/handlers";
+
+// The one msw server of each test file, started by testsSetup.ts. Tests add 
their own handlers with
+// `server.use(...)` instead of starting another server: concurrent servers 
share msw's request
+// interception, so the first one consulted shadows the other's handlers, and 
closing either one
+// lets the requests still in flight escape to the network.
+export const server: SetupServer = setupServer(...handlers);
diff --git 
a/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.test.tsx
index 77d693a1839..5a8438214f3 100644
--- a/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.test.tsx
@@ -19,22 +19,11 @@
 import "@testing-library/jest-dom/vitest";
 import { fireEvent, render, screen, waitFor, within } from 
"@testing-library/react";
 import { delay, http, HttpResponse } from "msw";
-import { setupServer, type SetupServer } from "msw/node";
-import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
+import { describe, expect, it } from "vitest";
 
-import { handlers } from "src/mocks/handlers";
+import { server } from "src/mocks/server";
 import { AppWrapper } from "src/utils/AppWrapper";
 
-let server: SetupServer;
-
-beforeAll(() => {
-  server = setupServer(...handlers);
-  server.listen({ onUnhandledFrame: "bypass" });
-});
-
-afterEach(() => server.resetHandlers());
-afterAll(() => server.close());
-
 // The assets mock handler (see src/mocks/handlers/assets.ts) returns a single 
asset
 // with one consuming task, one alias and one watcher.
 describe("AssetsList columns", () => {
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/DagHeader.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/Dag/DagHeader.test.tsx
index 9e8d58513a7..ca9eb639ee8 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/DagHeader.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/DagHeader.test.tsx
@@ -18,27 +18,15 @@
  */
 import "@testing-library/jest-dom/vitest";
 import { render, screen, waitFor } from "@testing-library/react";
-import { setupServer, type SetupServer } from "msw/node";
-import { afterEach, describe, it, expect, beforeAll, afterAll } from "vitest";
+import { describe, it, expect } from "vitest";
 
 import type { DAGDetailsResponse } from "openapi/requests/types.gen";
 
-import { handlers } from "src/mocks/handlers";
 import { MOCK_DAG } from "src/mocks/handlers/dag";
 import { Wrapper } from "src/utils/Wrapper";
 
 import { Header } from "./Header";
 
-let server: SetupServer;
-
-beforeAll(() => {
-  server = setupServer(...handlers);
-  server.listen({ onUnhandledFrame: "bypass" });
-});
-
-afterEach(() => server.resetHandlers());
-afterAll(() => server.close());
-
 describe("Dag Documentation Modal", () => {
   it("Display documentation button when doc_md is present", async () => {
     render(
diff --git 
a/airflow-core/src/airflow/ui/src/pages/Dag/RequiredActionsRoute.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/Dag/RequiredActionsRoute.test.tsx
index 3051799786c..677a0b68db0 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/RequiredActionsRoute.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/RequiredActionsRoute.test.tsx
@@ -19,28 +19,22 @@
 import "@testing-library/jest-dom/vitest";
 import { render, screen, waitFor } from "@testing-library/react";
 import { http, HttpResponse } from "msw";
-import { setupServer, type SetupServer } from "msw/node";
-import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { beforeEach, describe, expect, it } from "vitest";
 
-import { handlers } from "src/mocks/handlers";
+import { server } from "src/mocks/server";
 import { AppWrapper } from "src/utils/AppWrapper";
 
 const hitlRequests: Array<{ dagId: string; dagRunId: string }> = [];
 
-let server: SetupServer;
-
-beforeAll(() => {
-  server = setupServer(
-    ...handlers,
+beforeEach(() => {
+  server.use(
     http.get("/api/v2/dags/:dagId/dagRuns/:dagRunId/hitlDetails", ({ params }) 
=> {
       hitlRequests.push({ dagId: String(params.dagId), dagRunId: 
String(params.dagRunId) });
 
       return HttpResponse.json({ hitl_details: [], total_entries: 0 });
     }),
   );
-  server.listen({ onUnhandledFrame: "bypass" });
 });
-afterAll(() => server.close());
 
 describe("Dag required actions route", () => {
   it("opens the HITL review modal scoped to the Dag", async () => {
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Tasks/Tasks.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/Dag/Tasks/Tasks.test.tsx
index d602fa93f86..897e1e831fb 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Tasks/Tasks.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Tasks/Tasks.test.tsx
@@ -19,10 +19,10 @@
 import "@testing-library/jest-dom/vitest";
 import { fireEvent, render, screen, waitFor, within } from 
"@testing-library/react";
 import { http, HttpResponse } from "msw";
-import { setupServer, type SetupServer } from "msw/node";
 import { MemoryRouter, Route, Routes } from "react-router-dom";
-import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { beforeEach, describe, expect, it } from "vitest";
 
+import { server } from "src/mocks/server";
 import { BaseWrapper } from "src/utils/Wrapper";
 
 import { Tasks } from "./Tasks";
@@ -51,15 +51,11 @@ const tasks = [
   },
 ].map((task) => ({ ...task, task_display_name: task.task_id }));
 
-let server: SetupServer;
-
-beforeAll(() => {
-  server = setupServer(
+beforeEach(() => {
+  server.use(
     http.get("/api/v2/dags/:dagId/tasks", () => HttpResponse.json({ tasks, 
total_entries: tasks.length })),
   );
-  server.listen({ onUnhandledFrame: "bypass" });
 });
-afterAll(() => server.close());
 
 const getTaskNames = () =>
   within(screen.getByTestId("table-list"))
diff --git 
a/airflow-core/src/airflow/ui/src/pages/DagBundles/DagBundles.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/DagBundles/DagBundles.test.tsx
index f8ddd80b161..4e0c92e591d 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagBundles/DagBundles.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagBundles/DagBundles.test.tsx
@@ -19,14 +19,11 @@
 import "@testing-library/jest-dom/vitest";
 import { act, render, screen, waitFor } from "@testing-library/react";
 import { http, HttpResponse } from "msw";
-import { setupServer, type SetupServer } from "msw/node";
-import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi 
} from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
 
-import { handlers } from "src/mocks/handlers";
+import { server } from "src/mocks/server";
 import { AppWrapper } from "src/utils/AppWrapper";
 
-let server: SetupServer;
-
 /** Requests the page has made to the bundles endpoint since the last reset. */
 let requestCount = 0;
 
@@ -102,11 +99,6 @@ const renderSettled = async (): Promise<number> => {
   return requestCount;
 };
 
-beforeAll(() => {
-  server = setupServer(...handlers);
-  server.listen({ onUnhandledFrame: "bypass" });
-});
-
 beforeEach(() => {
   requestCount = 0;
   vi.useFakeTimers({ shouldAdvanceTime: true });
@@ -114,12 +106,9 @@ beforeEach(() => {
 
 afterEach(() => {
   vi.useRealTimers();
-  server.resetHandlers();
   localStorage.clear();
 });
 
-afterAll(() => server.close());
-
 describe("Dag Bundles auto-refresh", () => {
   it("floors a short configured interval at ten seconds", async () => {
     // 3s is the shipped default, tuned for Grid/Graph run state. A bundle row 
changes at most
diff --git 
a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/DagsFilters.test.tsx
 
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/DagsFilters.test.tsx
index 20ee06d9fd0..25aab51084c 100644
--- 
a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/DagsFilters.test.tsx
+++ 
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/DagsFilters.test.tsx
@@ -18,11 +18,10 @@
  */
 import "@testing-library/jest-dom/vitest";
 import { fireEvent, render, screen, waitFor, within } from 
"@testing-library/react";
-import { setupServer, type SetupServer } from "msw/node";
-import { afterAll, afterEach, beforeAll, describe, it, expect, vi } from 
"vitest";
+import { afterEach, describe, it, expect, vi } from "vitest";
 
-import { handlers } from "src/mocks/handlers";
 import { dagsListHandler, defaultDags, drainingDag } from 
"src/mocks/handlers/dags";
+import { server } from "src/mocks/server";
 import { AppWrapper } from "src/utils/AppWrapper";
 
 const mockConfig: Record<string, unknown> = {
@@ -43,14 +42,6 @@ vi.mock("src/queries/useConfig", () => ({
 
 // Only the draining assertions need a fourth Dag, and every extra row is 
re-rendered by each
 // test that leans on the default fixture, so it is served per-test rather 
than globally.
-let server: SetupServer;
-
-beforeAll(() => {
-  server = setupServer(...handlers);
-  server.listen({ onUnhandledFrame: "bypass" });
-});
-afterEach(() => server.resetHandlers());
-afterAll(() => server.close());
 
 const withDrainingDag = () => server.use(dagsListHandler([...defaultDags, 
drainingDag]));
 
diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.test.tsx
index 1c2e0c61986..68da78a137a 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.test.tsx
@@ -19,28 +19,18 @@
 import "@testing-library/jest-dom/vitest";
 import { fireEvent, render, screen, waitFor, within } from 
"@testing-library/react";
 import { delay, http, HttpResponse } from "msw";
-import { setupServer, type SetupServer } from "msw/node";
-import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
+import { afterEach, describe, expect, it } from "vitest";
 
 import type { DagSchedulingState } from "openapi/requests/types.gen";
 
 import { DAGS_LIST_DISPLAY_KEY, DAGS_LIST_SHOW_RECENT_TASKS_KEY } from 
"src/constants/localStorage";
-import { handlers } from "src/mocks/handlers";
 import { failedDag, pausedDag, successDag } from "src/mocks/handlers/dags";
+import { server } from "src/mocks/server";
 import { AppWrapper } from "src/utils/AppWrapper";
 
-let server: SetupServer;
-
-beforeAll(() => {
-  server = setupServer(...handlers);
-  server.listen({ onUnhandledFrame: "bypass" });
-});
-
 afterEach(() => {
-  server.resetHandlers();
   localStorage.clear();
 });
-afterAll(() => server.close());
 
 describe("Dag Filters", () => {
   it("passes the draining scheduling state from the URL to the API", async () 
=> {
diff --git a/airflow-core/src/airflow/ui/src/pages/Run/Callbacks.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/Run/Callbacks.test.tsx
index 21db4f5ccb7..ed98cc88875 100644
--- a/airflow-core/src/airflow/ui/src/pages/Run/Callbacks.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Run/Callbacks.test.tsx
@@ -132,7 +132,10 @@ describe("Callbacks", () => {
     });
     await waitFor(() => expect(screen.getByText(/Deadline miss callback 
started/u)).toBeInTheDocument());
 
-    fireEvent.click(firstLogsButton as HTMLElement);
+    // Look the button up again: the rows re-render once the translation 
bundles have loaded.
+    const [expandedLogsButton] = screen.getAllByRole("button", { name: 
i18n.t("dag:tabs.logs") });
+
+    fireEvent.click(expandedLogsButton as HTMLElement);
 
     expect(screen.queryByTestId("table-sub-row")).not.toBeInTheDocument();
   });
diff --git a/airflow-core/src/airflow/ui/src/pages/XCom/XCom.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/XCom/XCom.test.tsx
index 82532ba9c46..1cf19e43b00 100644
--- a/airflow-core/src/airflow/ui/src/pages/XCom/XCom.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/XCom/XCom.test.tsx
@@ -19,11 +19,10 @@
 import "@testing-library/jest-dom/vitest";
 import { fireEvent, render, screen, waitFor } from "@testing-library/react";
 import { http, HttpResponse } from "msw";
-import { setupServer, type SetupServer } from "msw/node";
-import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
+import { beforeEach, describe, expect, it, vi } from "vitest";
 
 import type * as ColorMode from "src/context/colorMode";
-import { handlers } from "src/mocks/handlers";
+import { server } from "src/mocks/server";
 import { AppWrapper } from "src/utils/AppWrapper";
 
 vi.mock("src/components/MonacoEditor", () => ({
@@ -47,11 +46,8 @@ const xcomEntry = {
   timestamp: "2025-01-01T00:00:00Z",
 };
 
-let server: SetupServer;
-
-beforeAll(() => {
-  server = setupServer(
-    ...handlers,
+beforeEach(() => {
+  server.use(
     http.get("/api/v2/dags/~/dagRuns/~/taskInstances/~/xcomEntries", () =>
       HttpResponse.json({ total_entries: 1, xcom_entries: [xcomEntry] }),
     ),
@@ -59,9 +55,7 @@ beforeAll(() => {
       HttpResponse.json({ ...xcomEntry, value: { nested: { answer: 42 } } }),
     ),
   );
-  server.listen({ onUnhandledFrame: "bypass" });
 });
-afterAll(() => server.close());
 
 describe("XCom list expand/collapse", () => {
   it("expands and collapses XCom values via the expand/collapse all buttons", 
async () => {
diff --git a/airflow-core/src/airflow/ui/src/pages/XCom/XComEntry.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/XCom/XComEntry.test.tsx
index a7ba13af8c1..0efe755edc9 100644
--- a/airflow-core/src/airflow/ui/src/pages/XCom/XComEntry.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/XCom/XComEntry.test.tsx
@@ -20,25 +20,21 @@ import { QueryClient, QueryClientProvider } from 
"@tanstack/react-query";
 import "@testing-library/jest-dom/vitest";
 import { act, fireEvent, render, screen, waitFor, within } from 
"@testing-library/react";
 import { http, HttpResponse } from "msw";
-import { setupServer } from "msw/node";
-import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 
"vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
 
 import { XcomService } from "openapi/requests";
 
+import { server } from "src/mocks/server";
 import { Wrapper } from "src/utils/Wrapper";
 
 import { XComEntry } from "./XComEntry";
 
-const server = setupServer();
 const entryUrl = 
"/api/v2/dags/test_dag/dagRuns/test_run/taskInstances/test_task/xcomEntries/:xcomKey";
 const entryProps = { dagId: "test_dag", mapIndex: -1, runId: "test_run", 
taskId: "test_task" };
 
 // i18n resources are not loaded in unit tests, so translated text renders as 
its key.
 const errorTitle = "error.title";
 
-beforeAll(() => server.listen({ onUnhandledFrame: "bypass" }));
-afterEach(() => server.resetHandlers());
-afterAll(() => server.close());
 afterEach(() => vi.restoreAllMocks());
 
 const renderValue = (value: string) => {
diff --git a/airflow-core/src/airflow/ui/src/queries/useDags.test.tsx 
b/airflow-core/src/airflow/ui/src/queries/useDags.test.tsx
index ed98797b8ed..0bd762f95b2 100644
--- a/airflow-core/src/airflow/ui/src/queries/useDags.test.tsx
+++ b/airflow-core/src/airflow/ui/src/queries/useDags.test.tsx
@@ -21,23 +21,18 @@ import type { PropsWithChildren } from "react";
 import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
 import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
 import { http, HttpResponse } from "msw";
-import { setupServer } from "msw/node";
-import { afterAll, afterEach, beforeAll, expect, it, vi } from "vitest";
+import { afterEach, expect, it, vi } from "vitest";
 
 import { successDag } from "src/mocks/handlers/dags";
+import { server } from "src/mocks/server";
 
 import { useDagRunStateCounts } from "./useDagRunStateCounts";
 import { useDags } from "./useDags";
 
-const server = setupServer();
-
-beforeAll(() => server.listen({ onUnhandledFrame: "error" }));
 afterEach(() => {
   cleanup();
-  server.resetHandlers();
   vi.useRealTimers();
 });
-afterAll(() => server.close());
 
 it.each(["latest run", "run counts"])("refreshes %s when no runs are pending", 
async (target) => {
   vi.useFakeTimers({ shouldAdvanceTime: true });
diff --git a/airflow-core/src/airflow/ui/src/queries/useTogglePause.test.tsx 
b/airflow-core/src/airflow/ui/src/queries/useTogglePause.test.tsx
index 732c795d97a..dc825dd46b3 100644
--- a/airflow-core/src/airflow/ui/src/queries/useTogglePause.test.tsx
+++ b/airflow-core/src/airflow/ui/src/queries/useTogglePause.test.tsx
@@ -21,12 +21,12 @@ import type { ReactNode } from "react";
 import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
 import { renderHook, waitFor } from "@testing-library/react";
 import { http, HttpResponse } from "msw";
-import { setupServer } from "msw/node";
-import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
+import { describe, expect, it } from "vitest";
 
 import { UseDagServiceGetDagsUiKeyFn } from "openapi/queries";
 import type { DAGWithLatestDagRunsCollectionResponse } from 
"openapi/requests/types.gen";
 
+import { server } from "src/mocks/server";
 import { useTogglePause } from "src/queries/useTogglePause";
 
 const DAG_ID = "dag_under_test";
@@ -71,8 +71,6 @@ const buildDagsList = (isPaused: boolean): 
DAGWithLatestDagRunsCollectionRespons
   total_entries: 1,
 });
 
-const server = setupServer();
-
 const createWrapper =
   (queryClient: QueryClient) =>
   ({ children }: { readonly children: ReactNode }) => (
@@ -103,10 +101,6 @@ const seedClient = (initialIsPaused: boolean) => {
   return { dagsListKey, filteredListKey, queryClient };
 };
 
-beforeAll(() => server.listen({ onUnhandledFrame: "error" }));
-afterEach(() => server.resetHandlers());
-afterAll(() => server.close());
-
 describe("useTogglePause", () => {
   it("optimistically flips is_paused in the dags list cache as soon as 
mutate() is called", async () => {
     server.use(
diff --git a/airflow-core/src/airflow/ui/testsSetup.ts 
b/airflow-core/src/airflow/ui/testsSetup.ts
index fece760f6df..c0373e2bf12 100644
--- a/airflow-core/src/airflow/ui/testsSetup.ts
+++ b/airflow-core/src/airflow/ui/testsSetup.ts
@@ -18,11 +18,13 @@
  */
 import "@testing-library/jest-dom/vitest";
 import { cleanup } from "@testing-library/react";
-import type { HttpHandler } from "msw";
-import { setupServer, type SetupServer } from "msw/node";
-import { beforeEach, beforeAll, afterAll, afterEach, vi } from "vitest";
+import type { SetupServer } from "msw/node";
+import { beforeEach, afterEach, vi } from "vitest";
 
-import { handlers } from "src/mocks/handlers";
+import { server as sharedServer } from "src/mocks/server";
+
+// tsconfig.dev.json, which covers this file, has no `src/*` path alias, so 
the import is untyped here.
+const server = sharedServer as SetupServer;
 
 // ELK creates a Worker at module-load time, which is not available in
 // happy-dom. Mock useGraphLayout so the Worker is never constructed. Any
@@ -71,15 +73,32 @@ vi.mock("chart.js", () => ({
   Tooltip: vi.fn(),
 }));
 
-let server: SetupServer;
+// Listen at import time rather than in beforeAll: modules such as 
src/i18n/config send requests
+// while the test file is still being imported.
+//
+// The server is deliberately never closed. Vitest runs each test file in its 
own worker process,
+// which takes the interception down with it, whereas closing in afterAll 
releases sockets of
+// mocked requests that are still open to the real network, where they fail 
with ECONNREFUSED or
+// "socket hang up".
+server.listen({ onUnhandledFrame: "error" });
+
+// The "error" strategy only rejects the request, which the app under test may 
well swallow, so
+// also fail the test that sent it.
+const unhandledRequests: Array<string> = [];
 
-beforeAll(() => {
-  server = setupServer(...(handlers as Array<HttpHandler>));
-  server.listen({ onUnhandledFrame: "bypass" });
+server.events.on("request:unhandled", ({ request }) => {
+  unhandledRequests.push(`${request.method} ${request.url}`);
 });
 
 beforeEach(() => server.resetHandlers());
 afterEach(() => {
   cleanup();
+
+  const requests = unhandledRequests.splice(0);
+
+  if (requests.length > 0) {
+    throw new Error(
+      `Requests without an msw handler (add one to src/mocks/handlers or with 
server.use()):\n${requests.join("\n")}`,
+    );
+  }
 });
-afterAll(() => server.close());

Reply via email to