From 456cc56db4d935e2123a4cf8b8c5fe824cc23b08 Mon Sep 17 00:00:00 2001 From: Xinyuan Lin Date: Tue, 27 Oct 2026 16:20:15 -0700 Subject: [PATCH 1/2] fix(frontend): stop unit-test memory growth that times out the macOS leg The unit-test builder runs Vitest with isolate: false, so each worker runs its spec files against one jsdom document and one copy of Vitest. Several worker-wide holders kept every finished test's TestBed graph reachable, and on the 7 GB macOS runner the resulting growth (about 2.4 GB per worker) swapped the VM into stalls that timed out whichever test was running. - Release what root services start: WorkflowActionService destroys its shared model, WorkflowWebsocketService stops its heartbeat and closes the socket, JupyterPanelService removes its window listener, and SharedModel.destroy() destroys its y-websocket provider rather than only disconnecting it when connected. - Empty jsdom's selector cache between tests (new setup file). - Backport Vitest 5's weak mock registry into @vitest/spy 4.1.11 with a Yarn patch, guarded by worker-memory.spec.ts. Closes #9197 Co-Authored-By: Claude Opus 5.5 --- .github/renovate.json5 | 10 +++ LICENSE | 7 ++ .../@vitest-spy-npm-4.1.11-ce87517a53.patch | 41 ++++++++++ frontend/TESTING.md | 22 +++++- frontend/package.json | 3 +- .../common/testing/jsdom-selector-cache.ts | 52 +++++++++++++ .../app/common/testing/worker-memory.spec.ts | 74 +++++++++++++++++++ .../jupyter-panel.service.spec.ts | 9 +++ .../jupyter-panel/jupyter-panel.service.ts | 8 +- .../workflow-graph/model/shared-model.spec.ts | 44 +++++------ .../workflow-graph/model/shared-model.ts | 6 +- .../model/workflow-action.service.spec.ts | 16 ++++ .../model/workflow-action.service.ts | 10 ++- .../workflow-websocket.service.spec.ts | 22 +++++- .../workflow-websocket.service.ts | 12 ++- frontend/src/jsdom-selector-cache-setup.ts | 37 ++++++++++ frontend/vitest.config.ts | 7 +- frontend/yarn.lock | 7 ++ licenses-3rd-party-code/vitest.txt | 21 ++++++ 19 files changed, 367 insertions(+), 41 deletions(-) create mode 100644 frontend/.yarn/patches/@vitest-spy-npm-4.1.11-ce87517a53.patch create mode 100644 frontend/src/app/common/testing/jsdom-selector-cache.ts create mode 100644 frontend/src/app/common/testing/worker-memory.spec.ts create mode 100644 frontend/src/jsdom-selector-cache-setup.ts create mode 100644 licenses-3rd-party-code/vitest.txt diff --git a/.github/renovate.json5 b/.github/renovate.json5 index c6fe7f387a8..d00936c7f88 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -177,6 +177,16 @@ enabled: false, }, + // frontend/.yarn/patches backports a fix into @vitest/spy under a `resolutions` key that names + // the exact version, so every Vitest bump drops the patch and fails worker-memory.spec.ts until + // it is regenerated (frontend/TESTING.md, "Memory in shared test workers"). Keep those bumps + // in their own PR so they do not hold up the grouped frontend updates. + { + matchFileNames: ['frontend/**'], + matchPackageNames: ['vitest', '@vitest/*'], + groupName: 'frontend vitest updates', + }, + // config:recommended pulls in :semanticPrefixFixDepsChoreOthers, // which types runtime-dependency bumps as fix(...). We want the // opposite: every routine bump is chore(...), and only diff --git a/LICENSE b/LICENSE index a3ec98ddeb9..8d2b1ab4737 100644 --- a/LICENSE +++ b/LICENSE @@ -238,6 +238,13 @@ This product bundles code derived from inner-class: Source: https://github.com/sebkeim/inner-class License: MIT License (licenses-3rd-party-code/sebkeim-inner-class.txt) +This product bundles a patch to Vitest's @vitest/spy package, applied by Yarn to the +copy the frontend unit tests install: + - frontend/.yarn/patches/@vitest-spy-npm-4.1.11-ce87517a53.patch + Copyright (c) 2021-Present VoidZero Inc. and Vitest contributors + Source: https://github.com/vitest-dev/vitest + License: MIT License (licenses-3rd-party-code/vitest.txt) + This product bundles the ScalaPB options definition: - common/workflow-core/src/main/protobuf/scalapb/scalapb.proto Source: https://github.com/scalapb/ScalaPB diff --git a/frontend/.yarn/patches/@vitest-spy-npm-4.1.11-ce87517a53.patch b/frontend/.yarn/patches/@vitest-spy-npm-4.1.11-ce87517a53.patch new file mode 100644 index 00000000000..a1a0a8cd0a7 --- /dev/null +++ b/frontend/.yarn/patches/@vitest-spy-npm-4.1.11-ce87517a53.patch @@ -0,0 +1,41 @@ +diff --git a/dist/index.js b/dist/index.js +index 6d907fc02f65ca9b3959f75c623e83ca866a457c..18e151cb509b2b34ea136a8a1a30e57d06dd4c32 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -5,7 +5,12 @@ const MOCK_RESTORE = new Set(); + // Jest keeps the state in a separate WeakMap which is good for memory, + // but it makes the state slower to access and return different values + // if you stored it before calling `mockClear` where it will be recreated ++// Patched (Texera): held through WeakRefs, as Vitest 5 does, so a mock nothing references can be ++// collected along with everything it recorded or spied on. See "Memory in shared test workers" in frontend/TESTING.md. + const REGISTERED_MOCKS = new Set(); ++const MOCK_FINALIZER = new FinalizationRegistry((ref) => { ++ REGISTERED_MOCKS.delete(ref); ++}); + const MOCK_CONFIGS = new WeakMap(); + function createMockInstance(options = {}) { + const { originalImplementation, restore, mockImplementation, resetToMockImplementation, resetToMockName } = options; +@@ -33,7 +38,9 @@ function createMockInstance(options = {}) { + config.mockName = mock.name || "vi.fn()"; + } + MOCK_CONFIGS.set(mock, config); +- REGISTERED_MOCKS.add(mock); ++ const ref = new WeakRef(mock); ++ REGISTERED_MOCKS.add(ref); ++ MOCK_FINALIZER.register(mock, ref); + mock._isMockFunction = true; + mock.getMockImplementation = () => { + // Jest only returns `config.mockImplementation` here, +@@ -471,10 +478,10 @@ function restoreAllMocks() { + MOCK_RESTORE.clear(); + } + function clearAllMocks() { +- REGISTERED_MOCKS.forEach((mock) => mock.mockClear()); ++ REGISTERED_MOCKS.forEach((ref) => ref.deref()?.mockClear()); + } + function resetAllMocks() { +- REGISTERED_MOCKS.forEach((mock) => mock.mockReset()); ++ REGISTERED_MOCKS.forEach((ref) => ref.deref()?.mockReset()); + } + function throwConstructorError(shorthand) { + throw new TypeError(`Cannot use \`${shorthand}\` when called with \`new\`. Use \`mockImplementation\` with a \`class\` keyword instead. See https://vitest.dev/api/mock#class-support for more information.`); diff --git a/frontend/TESTING.md b/frontend/TESTING.md index 7adea47e4bd..d48044bbc6a 100644 --- a/frontend/TESTING.md +++ b/frontend/TESTING.md @@ -31,10 +31,11 @@ For repo-wide testing philosophy (TDD, characterization tests, "every test must 4. [Recipes](#recipes) 5. [Standalone components](#standalone-components) 6. [jsdom vs browser mode](#jsdom-vs-browser-mode) -7. [Mocking](#mocking) -8. [Anti-patterns](#anti-patterns) -9. [Coverage troubleshooting](#coverage-troubleshooting) -10. [References](#references) +7. [Memory in shared test workers](#memory-in-shared-test-workers) +8. [Mocking](#mocking) +9. [Anti-patterns](#anti-patterns) +10. [Coverage troubleshooting](#coverage-troubleshooting) +11. [References](#references) ## The stack @@ -302,6 +303,19 @@ Routing rules: per-spec inclusion / exclusion is set in `angular.json`, not in ` Working reference (browser mode): `src/app/workspace/component/workflow-editor/workflow-editor.component.spec.ts`. See [#5017](https://github.com/apache/texera/pull/5017) for the rationale behind the two-target split. +## Memory in shared test workers + +The `unit-test` builder runs Vitest with `isolate: false`. Each worker process runs its share of the spec files one after another (about 120 each on the 2-worker macOS leg, about 80 on the 3-worker Linux and Windows legs), against one jsdom document and one copy of every `node_modules` module. A test's TestBed graph (the component tree, the root injector, the NgZone and every service) is freed once the test ends, but only if nothing worker-wide still points into it. A single stray reference keeps the whole graph, a few MB, alive until the worker exits. On the macOS leg that used to add up to about 2.4 GB per worker. On the 7 GB macOS runners the machine then swapped hard enough to stall for tens of seconds at a time, and whichever test was running when it stalled timed out. + +What you need to do: a `providedIn: "root"` service that registers anything on a worker-wide object, such as a `window` or `document` listener, a `setInterval` / `interval()`, a WebSocket or y-websocket provider, or a `BroadcastChannel` subscription, must release it in `ngOnDestroy`. TestBed destroys the root injector after every test, which calls that hook. Without it, the listener or timer keeps the service alive, and through the service the test's whole graph. `WorkflowWebsocketService`, `JupyterPanelService` and `WorkflowActionService` show the pattern. + +Two worker-wide holders outside our code are handled in test setup: + +- **jsdom's selector cache.** It memoizes every selector with the nodes it last matched, including each fixture's `#root`. `src/jsdom-selector-cache-setup.ts` empties it between tests. +- **Vitest 4's mock registry.** It is a strong `Set` that is never pruned, so every `vi.fn()` / `vi.spyOn()` of the run stays alive with everything it recorded or spied on. `.yarn/patches/@vitest-spy-npm-*.patch` backports Vitest 5's weak registry. The `resolutions` key names the exact version, so every Vitest bump drops the patch, even when the patched lines are unchanged, and `worker-memory.spec.ts` then fails (Renovate keeps those bumps in their own PR). To carry it over: run `yarn patch @vitest/spy`, redo the change in the printed folder's `dist/index.js`, and run `yarn patch-commit -s `. Then delete the old patch file and its `resolutions` entry, and update the patch path in the root `LICENSE`. Delete the patch for good once Angular's unit-test builder supports Vitest 5. + +`src/app/common/testing/worker-memory.spec.ts` fails if either of those stops releasing memory. + ## Mocking ### Functions / spies diff --git a/frontend/package.json b/frontend/package.json index a3d30139cd4..7b9dc4619f0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -84,7 +84,8 @@ "jschardet": "portal:./tools/jschardet-stub", "@angular-devkit/build-angular": "21.2.19", "@angular/build": "21.2.19", - "@ngtools/webpack": "21.2.19" + "@ngtools/webpack": "21.2.19", + "@vitest/spy@npm:4.1.11": "patch:@vitest/spy@npm%3A4.1.11#~/.yarn/patches/@vitest-spy-npm-4.1.11-ce87517a53.patch" }, "devDependencies": { "@angular-builders/custom-webpack": "21.1.0", diff --git a/frontend/src/app/common/testing/jsdom-selector-cache.ts b/frontend/src/app/common/testing/jsdom-selector-cache.ts new file mode 100644 index 00000000000..467eaf5ec4e --- /dev/null +++ b/frontend/src/app/common/testing/jsdom-selector-cache.ts @@ -0,0 +1,52 @@ +/** + * 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. + */ + +/** The two memo tables nwsapi exports on its engine object. */ +type NwsapiCaches = { + selectResolvers: Record; + matchResolvers: Record; +}; + +/** + * Empties jsdom's CSS-selector cache. + * + * jsdom answers `querySelector` and `matches` through nwsapi, which memoizes every selector it + * compiles together with the nodes that selector last matched, for the lifetime of the document. + * The unit-test builder runs every spec file a worker picks up against one document, and TestBed + * queries a fresh `#root` selector for each fixture it creates. So every fixture's root element + * stays cached, and with it, through the element's `__ngContext__`, the component tree, its + * injector and its NgZone, until the worker exits. + * + * The tables are emptied in place: jsdom's engine reads the exported objects, so replacing them + * would release nothing. Returns false when the cache is not there to empty: before the document's + * first query, or if jsdom stops using nwsapi. worker-memory.spec.ts fails in the latter case, so + * this gets revisited instead of silently turning into a no-op. + */ +export function resetJsdomSelectorCache(): boolean { + const document = globalThis.document as unknown as Record; + if (!document) return false; + // jsdom keeps its implementation object behind the DOM wrapper, under a symbol it does not export. + const implSymbol = Object.getOwnPropertySymbols(document).find(symbol => symbol.description === "impl"); + const nwsapi = implSymbol && document[implSymbol]?._nwsapi; + if (!nwsapi) return false; + for (const cache of [nwsapi.selectResolvers, nwsapi.matchResolvers]) { + for (const selector of Object.keys(cache)) delete cache[selector]; + } + return true; +} diff --git a/frontend/src/app/common/testing/worker-memory.spec.ts b/frontend/src/app/common/testing/worker-memory.spec.ts new file mode 100644 index 00000000000..dcfc946084c --- /dev/null +++ b/frontend/src/app/common/testing/worker-memory.spec.ts @@ -0,0 +1,74 @@ +/** + * 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 { setFlagsFromString } from "node:v8"; +import { runInNewContext } from "node:vm"; +import { resetJsdomSelectorCache } from "./jsdom-selector-cache"; + +/** + * The unit-test builder runs every spec file a worker picks up in one process, against one jsdom + * document and one copy of Vitest. Anything worker-wide that holds on to a test's objects keeps + * that test's whole TestBed graph alive until the worker exits. On the 7 GB macOS runners that + * growth stalled the VM long enough to time unrelated tests out. These pin the two worker-wide + * holders the suite depends on being released. + */ + +// Workers run without --expose-gc, but V8 still hands `gc` to contexts created after the flag is set. +setFlagsFromString("--expose-gc"); +const gc = runInNewContext("gc") as () => void; + +/** A WeakRef keeps its target alive until the task that created it ends, so collect in a later one. */ +async function collectGarbage(): Promise { + await new Promise(resolve => setTimeout(resolve, 0)); + gc(); +} + +describe("memory shared by the spec files of a test worker", () => { + it("a mock does not keep what it was called with alive once nothing references the mock", async () => { + const argumentRef = (() => { + const argument = {}; + vi.fn()(argument); + return new WeakRef(argument); + })(); + + await collectGarbage(); + + // Vitest 4's mock registry is a strong Set that is never pruned. The patch in + // .yarn/patches backports Vitest 5's weak one; this fails if a version bump drops the patch. + expect(argumentRef.deref()).toBeUndefined(); + }); + + it("jsdom releases a queried element once its selector cache is reset", async () => { + const elementRef = (() => { + const element = document.body.appendChild(document.createElement("div")); + element.id = "worker-memory-probe"; + expect(document.querySelector("#worker-memory-probe")).toBe(element); + element.remove(); + return new WeakRef(element); + })(); + + await collectGarbage(); + // still held by the cached query, which is why the reset exists + expect(elementRef.deref()).toBeDefined(); + + expect(resetJsdomSelectorCache()).toBe(true); + await collectGarbage(); + expect(elementRef.deref()).toBeUndefined(); + }); +}); diff --git a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts index 3abaddd342f..b1aaef76363 100644 --- a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts +++ b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts @@ -420,6 +420,15 @@ describe("JupyterPanelService", () => { expect(mockWorkflow.highlightLinks).not.toHaveBeenCalled(); }); + it("stops listening for notebook messages when its injector is destroyed", () => { + const removeListener = vi.spyOn(window, "removeEventListener"); + + TestBed.resetTestingModule(); + + // window outlives the service, so a listener left behind would keep it reachable + expect(removeListener).toHaveBeenCalledWith("message", (service as any).handleNotebookMessage); + }); + // handleNotebookMessage must only act on cellClicked messages that come from // our own iframe (event.source) AND carry the Jupyter origin. it("handleNotebookMessage highlights only for messages from the iframe at the Jupyter origin", async () => { diff --git a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts index 7b56bca5323..02a99923feb 100644 --- a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts +++ b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts @@ -17,7 +17,7 @@ * under the License. */ -import { Injectable } from "@angular/core"; +import { Injectable, OnDestroy } from "@angular/core"; import { BehaviorSubject, catchError, map, of } from "rxjs"; import { WorkflowActionService } from "../workflow-graph/model/workflow-action.service"; import { OperatorLink } from "../../types/workflow-common.interface"; @@ -35,7 +35,7 @@ import { GuiConfigService } from "../../../common/service/gui-config.service"; @Injectable({ providedIn: "root", }) -export class JupyterPanelService { +export class JupyterPanelService implements OnDestroy { private jupyterNotebookPanelVisible = new BehaviorSubject(false); public jupyterNotebookPanelVisible$ = this.jupyterNotebookPanelVisible.asObservable(); @@ -64,6 +64,10 @@ export class JupyterPanelService { window.addEventListener("message", this.handleNotebookMessage); } + ngOnDestroy(): void { + window.removeEventListener("message", this.handleNotebookMessage); + } + private get enabled(): boolean { return this.config.env.pythonNotebookMigrationEnabled; } diff --git a/frontend/src/app/workspace/service/workflow-graph/model/shared-model.spec.ts b/frontend/src/app/workspace/service/workflow-graph/model/shared-model.spec.ts index b6cf119b0dd..c65bc2096f7 100644 --- a/frontend/src/app/workspace/service/workflow-graph/model/shared-model.spec.ts +++ b/frontend/src/app/workspace/service/workflow-graph/model/shared-model.spec.ts @@ -110,28 +110,28 @@ describe("SharedModel", () => { }); describe("destroy", () => { - // `destroy` only disconnects when the provider both wants to connect and is connected. - const cases: { shouldConnect: boolean; wsconnected: boolean; disconnects: boolean }[] = [ - { shouldConnect: true, wsconnected: true, disconnects: true }, - { shouldConnect: false, wsconnected: true, disconnects: false }, - { shouldConnect: true, wsconnected: false, disconnects: false }, - ]; - - cases.forEach(({ shouldConnect, wsconnected, disconnects }) => { - it(`${disconnects ? "disconnects" : "does not disconnect"} when shouldConnect=${shouldConnect} and wsconnected=${wsconnected}`, () => { - const sharedModel = build(1, user); - const provider = sharedModel.wsProvider; - // spied rather than called for real: the flags below are set by hand, so an - // actual disconnect would run against a socket that was never opened - const disconnect = vi.spyOn(provider, "disconnect").mockImplementation(() => {}); - provider.shouldConnect = shouldConnect; - provider.wsconnected = wsconnected; - - sharedModel.destroy(); - model = undefined; // already destroyed; keep afterEach from destroying twice - - expect(disconnect).toHaveBeenCalledTimes(disconnects ? 1 : 0); - }); + it("destroys the provider, not just disconnects it", () => { + const sharedModel = build(1, user); + const destroy = vi.spyOn(sharedModel.wsProvider, "destroy"); + + sharedModel.destroy(); + model = undefined; // already destroyed; keep afterEach from destroying twice + + // disconnect() alone leaves the provider's connection-check interval running, and that + // interval keeps the provider and its doc alive for as long as the page is open + expect(destroy).toHaveBeenCalledTimes(1); + }); + + it("stops a provider that never connected from retrying", () => { + const sharedModel = build(1, user); + const provider = sharedModel.wsProvider; + // the server was unreachable: the provider is still trying to connect + provider.wsconnected = false; + + sharedModel.destroy(); + model = undefined; // already destroyed; keep afterEach from destroying twice + + expect(provider.shouldConnect).toBe(false); }); }); }); diff --git a/frontend/src/app/workspace/service/workflow-graph/model/shared-model.ts b/frontend/src/app/workspace/service/workflow-graph/model/shared-model.ts index c3179d6d5ae..010caee0595 100644 --- a/frontend/src/app/workspace/service/workflow-graph/model/shared-model.ts +++ b/frontend/src/app/workspace/service/workflow-graph/model/shared-model.ts @@ -151,7 +151,11 @@ export class SharedModel { public destroy(): void { this.awareness.destroy(); try { - if (this.wsProvider.shouldConnect && this.wsProvider.wsconnected) this.wsProvider.disconnect(); + // destroy(), not just disconnect(): the provider also runs a connection-check interval, holds + // a BroadcastChannel subscription and listens on the doc and awareness, and only destroy() + // releases those. It disconnects too, which also stops a never-connected provider from + // retrying forever. + this.wsProvider.destroy(); } catch (e) {} this.yDoc.destroy(); } diff --git a/frontend/src/app/workspace/service/workflow-graph/model/workflow-action.service.spec.ts b/frontend/src/app/workspace/service/workflow-graph/model/workflow-action.service.spec.ts index 984ff04ff9c..437ffa31e86 100644 --- a/frontend/src/app/workspace/service/workflow-graph/model/workflow-action.service.spec.ts +++ b/frontend/src/app/workspace/service/workflow-graph/model/workflow-action.service.spec.ts @@ -78,6 +78,13 @@ describe("WorkflowActionService", () => { jointGraph = (service as any).jointGraph; }); + // Some tests stub the provider's connect/disconnect. Put the real ones back before TestBed + // teardown destroys the shared model (this hook runs before the file-level teardown), or the + // provider is never closed, keeps redialling, and keeps this test's service alive with it. + afterEach(() => { + vi.restoreAllMocks(); + }); + /** * The shared document has finished its first sync with the room. A seed of the workflow's * settings or form definition waits for this, so that a co-editor's newer value is already in @@ -94,6 +101,15 @@ describe("WorkflowActionService", () => { expect(injectedService).toBeTruthy(); })); + it("destroys the shared model when its injector is destroyed", () => { + const destroy = vi.spyOn(texeraGraph.sharedModel, "destroy"); + + TestBed.resetTestingModule(); + + // the shared model's provider runs timers that would otherwise keep this service alive + expect(destroy).toHaveBeenCalledTimes(1); + }); + // The operator canvas and the Form View hand one open workflow between them; the arriving view // asks this before loading, because seeding a second document for the same workflow would leave // the co-editing room and rejoin it. diff --git a/frontend/src/app/workspace/service/workflow-graph/model/workflow-action.service.ts b/frontend/src/app/workspace/service/workflow-graph/model/workflow-action.service.ts index f73b97c89af..2aa8540be82 100644 --- a/frontend/src/app/workspace/service/workflow-graph/model/workflow-action.service.ts +++ b/frontend/src/app/workspace/service/workflow-graph/model/workflow-action.service.ts @@ -17,7 +17,7 @@ * under the License. */ -import { Injectable } from "@angular/core"; +import { Injectable, OnDestroy } from "@angular/core"; import * as joint from "jointjs"; import * as Y from "yjs"; @@ -93,7 +93,7 @@ export const DEFAULT_WORKFLOW = { @Injectable({ providedIn: "root", }) -export class WorkflowActionService { +export class WorkflowActionService implements OnDestroy { private readonly texeraGraph: WorkflowGraph; private readonly jointGraph: joint.dia.Graph; private readonly jointGraphWrapper: JointGraphWrapper; @@ -174,6 +174,12 @@ export class WorkflowActionService { this.handleJointElementDrag(); } + // A root service is only destroyed with its injector: on app teardown, and after every TestBed test. + // Left open, the shared model's provider timers keep this service, and everything it reaches, alive. + ngOnDestroy(): void { + this.destroySharedModel(); + } + private observeContentMeta(): void { // Detach the observer from the previous shared model before attaching to the new one, so // re-attaching on each opened workflow does not stack listeners on the same map. diff --git a/frontend/src/app/workspace/service/workflow-websocket/workflow-websocket.service.spec.ts b/frontend/src/app/workspace/service/workflow-websocket/workflow-websocket.service.spec.ts index 30bfe8fe02d..ce05c3abf12 100644 --- a/frontend/src/app/workspace/service/workflow-websocket/workflow-websocket.service.spec.ts +++ b/frontend/src/app/workspace/service/workflow-websocket/workflow-websocket.service.spec.ts @@ -17,7 +17,7 @@ * under the License. */ -import { discardPeriodicTasks, fakeAsync, flushMicrotasks, TestBed, tick } from "@angular/core/testing"; +import { fakeAsync, flushMicrotasks, TestBed, tick } from "@angular/core/testing"; import { Subscription } from "rxjs"; import { WorkflowWebsocketService, @@ -317,12 +317,26 @@ describe("WorkflowWebsocketService", () => { tick(WS_HEARTBEAT_INTERVAL_MS); expect(heartbeats()).toBe(2); } finally { - heartbeatService.closeWebsocket(); - // the constructor's interval is never unsubscribed, so drain it or fakeAsync fails the test - discardPeriodicTasks(); + heartbeatService.ngOnDestroy(); } })); + it("stops the heartbeat and closes the socket when destroyed", fakeAsync(() => { + // built inside the fake zone for the same reason as above + const heartbeatService = new WorkflowWebsocketService(TestBed.inject(GuiConfigService)); + heartbeatService.openWebsocket(1, 1, 1); + flushMicrotasks(); + const send = vi.spyOn(heartbeatService, "send"); + + heartbeatService.ngOnDestroy(); + tick(WS_HEARTBEAT_INTERVAL_MS * 2); + + // A heartbeat to a closed socket is dropped without a trace, so watch the attempt: a timer + // still firing after destroy keeps the service, and everything it reaches, alive. + expect(send).not.toHaveBeenCalled(); + expect(openedSockets[0].readyState).toBe(FakeWebSocket.CLOSED); + })); + it("adopts the worker count carried by a ClusterStatusUpdateEvent", async () => { service.openWebsocket(1, 1, 1); await Promise.resolve(); diff --git a/frontend/src/app/workspace/service/workflow-websocket/workflow-websocket.service.ts b/frontend/src/app/workspace/service/workflow-websocket/workflow-websocket.service.ts index 4e52d3fa09e..7431fc015bb 100644 --- a/frontend/src/app/workspace/service/workflow-websocket/workflow-websocket.service.ts +++ b/frontend/src/app/workspace/service/workflow-websocket/workflow-websocket.service.ts @@ -17,7 +17,7 @@ * under the License. */ -import { Injectable } from "@angular/core"; +import { Injectable, OnDestroy } from "@angular/core"; import { BehaviorSubject, interval, Observable, Subject, Subscription, timer } from "rxjs"; import { webSocket, WebSocketSubject } from "rxjs/webSocket"; import { @@ -40,7 +40,7 @@ export const WS_RECONNECT_INTERVAL_MS = 3000; @Injectable({ providedIn: "root", }) -export class WorkflowWebsocketService { +export class WorkflowWebsocketService implements OnDestroy { private static readonly TEXERA_WEBSOCKET_ENDPOINT = "wsapi/workflow-websocket"; public numWorkers: number = -1; @@ -50,10 +50,16 @@ export class WorkflowWebsocketService { private statusUpdateSubscription?: Subscription; private readonly webSocketResponseSubject: Subject = new Subject(); private readonly connectionStatusSubject = new BehaviorSubject(false); + private readonly heartbeatSubscription: Subscription; constructor(private config: GuiConfigService) { // setup heartbeat - interval(WS_HEARTBEAT_INTERVAL_MS).subscribe(_ => this.send("HeartBeatRequest", {})); + this.heartbeatSubscription = interval(WS_HEARTBEAT_INTERVAL_MS).subscribe(_ => this.send("HeartBeatRequest", {})); + } + + ngOnDestroy(): void { + this.heartbeatSubscription.unsubscribe(); + this.closeWebsocket(); } /** Emit `true` when the socket is up, `false` when it is closed or lost. */ diff --git a/frontend/src/jsdom-selector-cache-setup.ts b/frontend/src/jsdom-selector-cache-setup.ts new file mode 100644 index 00000000000..b3d0ac77d59 --- /dev/null +++ b/frontend/src/jsdom-selector-cache-setup.ts @@ -0,0 +1,37 @@ +/** + * 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. + */ + +/** + * Drops jsdom's selector cache between tests, so the cached `#root` query of a finished test + * does not keep its whole TestBed graph alive for the rest of the worker (see + * resetJsdomSelectorCache). In `beforeEach` rather than `afterEach` so it runs after the previous + * test's TestBed teardown, whose own queries would otherwise be cached again; `afterAll` covers + * the file's last test. + * + * This is a setupFile (referenced from `vitest.config.ts`), so it executes once per test file. + */ +import { resetJsdomSelectorCache } from "./app/common/testing/jsdom-selector-cache"; + +beforeEach(() => { + resetJsdomSelectorCache(); +}); + +afterAll(() => { + resetJsdomSelectorCache(); +}); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 5498e3043c0..12a66fcef52 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -32,8 +32,11 @@ export default defineConfig({ globals: true, // Wrap `it`/`test` so each spec body runs inside an Angular ProxyZone, // which Angular's `fakeAsync` requires. Karma+Jasmine installed this - // implicitly; the @angular/build:unit-test path doesn't. - setupFiles: ["src/test-zone-setup.ts"], + // implicitly; the @angular/build:unit-test path doesn't. Then drop + // jsdom's selector cache between tests: the builder runs a worker's spec + // files against one document (`isolate: false`), and the cache otherwise + // keeps every finished fixture's TestBed graph alive until the worker exits. + setupFiles: ["src/test-zone-setup.ts", "src/jsdom-selector-cache-setup.ts"], // Headroom over Vitest's defaults (5s test / 10s hook) for the shared // macos-latest runners, whose wall time swings ~2x run to run: the same // test that takes ~400ms on ubuntu has been observed at 11s+ in a diff --git a/frontend/yarn.lock b/frontend/yarn.lock index a5d8706683f..8e72228e239 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -6977,6 +6977,13 @@ __metadata: languageName: node linkType: hard +"@vitest/spy@patch:@vitest/spy@npm%3A4.1.11#~/.yarn/patches/@vitest-spy-npm-4.1.11-ce87517a53.patch": + version: 4.1.11 + resolution: "@vitest/spy@patch:@vitest/spy@npm%3A4.1.11#~/.yarn/patches/@vitest-spy-npm-4.1.11-ce87517a53.patch::version=4.1.11&hash=b0676c" + checksum: 10c0/a6f444eaa3d2f8c3e1f9e66a4e2b7d08298f2600880a83b09c0354cdc51b8deed40a3e3fcb8a25dd8f7c824496c93a1e15fc4220ed4640697a2105c3d48a0e29 + languageName: node + linkType: hard + "@vitest/utils@npm:4.1.11": version: 4.1.11 resolution: "@vitest/utils@npm:4.1.11" diff --git a/licenses-3rd-party-code/vitest.txt b/licenses-3rd-party-code/vitest.txt new file mode 100644 index 00000000000..0e5771dddf4 --- /dev/null +++ b/licenses-3rd-party-code/vitest.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021-Present VoidZero Inc. and Vitest contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 6f85798ced4eec969b2836f97ccb9c631e786acc Mon Sep 17 00:00:00 2001 From: Xinyuan Lin Date: Tue, 27 Oct 2026 16:40:42 -0700 Subject: [PATCH 2/2] refactor(frontend): trim the unit-test memory fix to what it needs Drop the GC guard spec, the Renovate grouping rule and the TESTING.md section, fold the selector-cache helper into its setup file, and make the @vitest/spy patch comment self-contained. Co-Authored-By: Claude Opus 5.5 --- .github/renovate.json5 | 10 --- .../@vitest-spy-npm-4.1.11-ce87517a53.patch | 6 +- frontend/TESTING.md | 22 +----- .../common/testing/jsdom-selector-cache.ts | 52 ------------- .../app/common/testing/worker-memory.spec.ts | 74 ------------------- frontend/src/jsdom-selector-cache-setup.ts | 36 +++++---- frontend/yarn.lock | 4 +- 7 files changed, 32 insertions(+), 172 deletions(-) delete mode 100644 frontend/src/app/common/testing/jsdom-selector-cache.ts delete mode 100644 frontend/src/app/common/testing/worker-memory.spec.ts diff --git a/.github/renovate.json5 b/.github/renovate.json5 index d00936c7f88..c6fe7f387a8 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -177,16 +177,6 @@ enabled: false, }, - // frontend/.yarn/patches backports a fix into @vitest/spy under a `resolutions` key that names - // the exact version, so every Vitest bump drops the patch and fails worker-memory.spec.ts until - // it is regenerated (frontend/TESTING.md, "Memory in shared test workers"). Keep those bumps - // in their own PR so they do not hold up the grouped frontend updates. - { - matchFileNames: ['frontend/**'], - matchPackageNames: ['vitest', '@vitest/*'], - groupName: 'frontend vitest updates', - }, - // config:recommended pulls in :semanticPrefixFixDepsChoreOthers, // which types runtime-dependency bumps as fix(...). We want the // opposite: every routine bump is chore(...), and only diff --git a/frontend/.yarn/patches/@vitest-spy-npm-4.1.11-ce87517a53.patch b/frontend/.yarn/patches/@vitest-spy-npm-4.1.11-ce87517a53.patch index a1a0a8cd0a7..6db737a6702 100644 --- a/frontend/.yarn/patches/@vitest-spy-npm-4.1.11-ce87517a53.patch +++ b/frontend/.yarn/patches/@vitest-spy-npm-4.1.11-ce87517a53.patch @@ -1,13 +1,13 @@ diff --git a/dist/index.js b/dist/index.js -index 6d907fc02f65ca9b3959f75c623e83ca866a457c..18e151cb509b2b34ea136a8a1a30e57d06dd4c32 100644 +index 6d907fc02f65ca9b3959f75c623e83ca866a457c..d29a2b2b310b19ffb13bbe82d99877e7317c919c 100644 --- a/dist/index.js +++ b/dist/index.js @@ -5,7 +5,12 @@ const MOCK_RESTORE = new Set(); // Jest keeps the state in a separate WeakMap which is good for memory, // but it makes the state slower to access and return different values // if you stored it before calling `mockClear` where it will be recreated -+// Patched (Texera): held through WeakRefs, as Vitest 5 does, so a mock nothing references can be -+// collected along with everything it recorded or spied on. See "Memory in shared test workers" in frontend/TESTING.md. ++// Patched (Texera): mocks are held through WeakRefs, as in Vitest 5, so a mock nothing else references ++// is collected along with what it recorded or spied on. Drop once Angular's builder allows Vitest 5. const REGISTERED_MOCKS = new Set(); +const MOCK_FINALIZER = new FinalizationRegistry((ref) => { + REGISTERED_MOCKS.delete(ref); diff --git a/frontend/TESTING.md b/frontend/TESTING.md index d48044bbc6a..7adea47e4bd 100644 --- a/frontend/TESTING.md +++ b/frontend/TESTING.md @@ -31,11 +31,10 @@ For repo-wide testing philosophy (TDD, characterization tests, "every test must 4. [Recipes](#recipes) 5. [Standalone components](#standalone-components) 6. [jsdom vs browser mode](#jsdom-vs-browser-mode) -7. [Memory in shared test workers](#memory-in-shared-test-workers) -8. [Mocking](#mocking) -9. [Anti-patterns](#anti-patterns) -10. [Coverage troubleshooting](#coverage-troubleshooting) -11. [References](#references) +7. [Mocking](#mocking) +8. [Anti-patterns](#anti-patterns) +9. [Coverage troubleshooting](#coverage-troubleshooting) +10. [References](#references) ## The stack @@ -303,19 +302,6 @@ Routing rules: per-spec inclusion / exclusion is set in `angular.json`, not in ` Working reference (browser mode): `src/app/workspace/component/workflow-editor/workflow-editor.component.spec.ts`. See [#5017](https://github.com/apache/texera/pull/5017) for the rationale behind the two-target split. -## Memory in shared test workers - -The `unit-test` builder runs Vitest with `isolate: false`. Each worker process runs its share of the spec files one after another (about 120 each on the 2-worker macOS leg, about 80 on the 3-worker Linux and Windows legs), against one jsdom document and one copy of every `node_modules` module. A test's TestBed graph (the component tree, the root injector, the NgZone and every service) is freed once the test ends, but only if nothing worker-wide still points into it. A single stray reference keeps the whole graph, a few MB, alive until the worker exits. On the macOS leg that used to add up to about 2.4 GB per worker. On the 7 GB macOS runners the machine then swapped hard enough to stall for tens of seconds at a time, and whichever test was running when it stalled timed out. - -What you need to do: a `providedIn: "root"` service that registers anything on a worker-wide object, such as a `window` or `document` listener, a `setInterval` / `interval()`, a WebSocket or y-websocket provider, or a `BroadcastChannel` subscription, must release it in `ngOnDestroy`. TestBed destroys the root injector after every test, which calls that hook. Without it, the listener or timer keeps the service alive, and through the service the test's whole graph. `WorkflowWebsocketService`, `JupyterPanelService` and `WorkflowActionService` show the pattern. - -Two worker-wide holders outside our code are handled in test setup: - -- **jsdom's selector cache.** It memoizes every selector with the nodes it last matched, including each fixture's `#root`. `src/jsdom-selector-cache-setup.ts` empties it between tests. -- **Vitest 4's mock registry.** It is a strong `Set` that is never pruned, so every `vi.fn()` / `vi.spyOn()` of the run stays alive with everything it recorded or spied on. `.yarn/patches/@vitest-spy-npm-*.patch` backports Vitest 5's weak registry. The `resolutions` key names the exact version, so every Vitest bump drops the patch, even when the patched lines are unchanged, and `worker-memory.spec.ts` then fails (Renovate keeps those bumps in their own PR). To carry it over: run `yarn patch @vitest/spy`, redo the change in the printed folder's `dist/index.js`, and run `yarn patch-commit -s `. Then delete the old patch file and its `resolutions` entry, and update the patch path in the root `LICENSE`. Delete the patch for good once Angular's unit-test builder supports Vitest 5. - -`src/app/common/testing/worker-memory.spec.ts` fails if either of those stops releasing memory. - ## Mocking ### Functions / spies diff --git a/frontend/src/app/common/testing/jsdom-selector-cache.ts b/frontend/src/app/common/testing/jsdom-selector-cache.ts deleted file mode 100644 index 467eaf5ec4e..00000000000 --- a/frontend/src/app/common/testing/jsdom-selector-cache.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * 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. - */ - -/** The two memo tables nwsapi exports on its engine object. */ -type NwsapiCaches = { - selectResolvers: Record; - matchResolvers: Record; -}; - -/** - * Empties jsdom's CSS-selector cache. - * - * jsdom answers `querySelector` and `matches` through nwsapi, which memoizes every selector it - * compiles together with the nodes that selector last matched, for the lifetime of the document. - * The unit-test builder runs every spec file a worker picks up against one document, and TestBed - * queries a fresh `#root` selector for each fixture it creates. So every fixture's root element - * stays cached, and with it, through the element's `__ngContext__`, the component tree, its - * injector and its NgZone, until the worker exits. - * - * The tables are emptied in place: jsdom's engine reads the exported objects, so replacing them - * would release nothing. Returns false when the cache is not there to empty: before the document's - * first query, or if jsdom stops using nwsapi. worker-memory.spec.ts fails in the latter case, so - * this gets revisited instead of silently turning into a no-op. - */ -export function resetJsdomSelectorCache(): boolean { - const document = globalThis.document as unknown as Record; - if (!document) return false; - // jsdom keeps its implementation object behind the DOM wrapper, under a symbol it does not export. - const implSymbol = Object.getOwnPropertySymbols(document).find(symbol => symbol.description === "impl"); - const nwsapi = implSymbol && document[implSymbol]?._nwsapi; - if (!nwsapi) return false; - for (const cache of [nwsapi.selectResolvers, nwsapi.matchResolvers]) { - for (const selector of Object.keys(cache)) delete cache[selector]; - } - return true; -} diff --git a/frontend/src/app/common/testing/worker-memory.spec.ts b/frontend/src/app/common/testing/worker-memory.spec.ts deleted file mode 100644 index dcfc946084c..00000000000 --- a/frontend/src/app/common/testing/worker-memory.spec.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * 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 { setFlagsFromString } from "node:v8"; -import { runInNewContext } from "node:vm"; -import { resetJsdomSelectorCache } from "./jsdom-selector-cache"; - -/** - * The unit-test builder runs every spec file a worker picks up in one process, against one jsdom - * document and one copy of Vitest. Anything worker-wide that holds on to a test's objects keeps - * that test's whole TestBed graph alive until the worker exits. On the 7 GB macOS runners that - * growth stalled the VM long enough to time unrelated tests out. These pin the two worker-wide - * holders the suite depends on being released. - */ - -// Workers run without --expose-gc, but V8 still hands `gc` to contexts created after the flag is set. -setFlagsFromString("--expose-gc"); -const gc = runInNewContext("gc") as () => void; - -/** A WeakRef keeps its target alive until the task that created it ends, so collect in a later one. */ -async function collectGarbage(): Promise { - await new Promise(resolve => setTimeout(resolve, 0)); - gc(); -} - -describe("memory shared by the spec files of a test worker", () => { - it("a mock does not keep what it was called with alive once nothing references the mock", async () => { - const argumentRef = (() => { - const argument = {}; - vi.fn()(argument); - return new WeakRef(argument); - })(); - - await collectGarbage(); - - // Vitest 4's mock registry is a strong Set that is never pruned. The patch in - // .yarn/patches backports Vitest 5's weak one; this fails if a version bump drops the patch. - expect(argumentRef.deref()).toBeUndefined(); - }); - - it("jsdom releases a queried element once its selector cache is reset", async () => { - const elementRef = (() => { - const element = document.body.appendChild(document.createElement("div")); - element.id = "worker-memory-probe"; - expect(document.querySelector("#worker-memory-probe")).toBe(element); - element.remove(); - return new WeakRef(element); - })(); - - await collectGarbage(); - // still held by the cached query, which is why the reset exists - expect(elementRef.deref()).toBeDefined(); - - expect(resetJsdomSelectorCache()).toBe(true); - await collectGarbage(); - expect(elementRef.deref()).toBeUndefined(); - }); -}); diff --git a/frontend/src/jsdom-selector-cache-setup.ts b/frontend/src/jsdom-selector-cache-setup.ts index b3d0ac77d59..d917d608e5c 100644 --- a/frontend/src/jsdom-selector-cache-setup.ts +++ b/frontend/src/jsdom-selector-cache-setup.ts @@ -18,20 +18,30 @@ */ /** - * Drops jsdom's selector cache between tests, so the cached `#root` query of a finished test - * does not keep its whole TestBed graph alive for the rest of the worker (see - * resetJsdomSelectorCache). In `beforeEach` rather than `afterEach` so it runs after the previous - * test's TestBed teardown, whose own queries would otherwise be cached again; `afterAll` covers - * the file's last test. + * Empties jsdom's CSS-selector cache between tests. * - * This is a setupFile (referenced from `vitest.config.ts`), so it executes once per test file. + * jsdom answers `querySelector` through nwsapi, which memoizes every selector together with the + * nodes it last matched, for the lifetime of the document. The unit-test builder runs a worker's + * spec files against one document, and TestBed queries a fresh `#root` per fixture, so every + * finished fixture, and through its `__ngContext__` the whole TestBed graph, stays cached until the + * worker exits. The tables are emptied in place because jsdom's engine reads the exported objects. + * + * `beforeEach` runs after the previous test's TestBed teardown, whose own queries would otherwise be + * cached again; `afterAll` covers the file's last test. This is a setupFile (see `vitest.config.ts`). */ -import { resetJsdomSelectorCache } from "./app/common/testing/jsdom-selector-cache"; +type NwsapiCaches = { selectResolvers: Record; matchResolvers: Record }; -beforeEach(() => { - resetJsdomSelectorCache(); -}); +function resetSelectorCache(): void { + const document = globalThis.document as unknown as Record; + if (!document) return; + // jsdom keeps its implementation object behind the DOM wrapper, under a symbol it does not export. + const implSymbol = Object.getOwnPropertySymbols(document).find(symbol => symbol.description === "impl"); + const nwsapi = implSymbol && document[implSymbol]?._nwsapi; + if (!nwsapi) return; + for (const cache of [nwsapi.selectResolvers, nwsapi.matchResolvers]) { + for (const selector of Object.keys(cache)) delete cache[selector]; + } +} -afterAll(() => { - resetJsdomSelectorCache(); -}); +beforeEach(resetSelectorCache); +afterAll(resetSelectorCache); diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 66f88549983..318efaaffc9 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -6982,8 +6982,8 @@ __metadata: "@vitest/spy@patch:@vitest/spy@npm%3A4.1.11#~/.yarn/patches/@vitest-spy-npm-4.1.11-ce87517a53.patch": version: 4.1.11 - resolution: "@vitest/spy@patch:@vitest/spy@npm%3A4.1.11#~/.yarn/patches/@vitest-spy-npm-4.1.11-ce87517a53.patch::version=4.1.11&hash=b0676c" - checksum: 10c0/a6f444eaa3d2f8c3e1f9e66a4e2b7d08298f2600880a83b09c0354cdc51b8deed40a3e3fcb8a25dd8f7c824496c93a1e15fc4220ed4640697a2105c3d48a0e29 + resolution: "@vitest/spy@patch:@vitest/spy@npm%3A4.1.11#~/.yarn/patches/@vitest-spy-npm-4.1.11-ce87517a53.patch::version=4.1.11&hash=f495f3" + checksum: 10c0/8dfbfa67a088a45304dfc5615103e429d58014869bd598db40c4f3f537ab751e82ea215b995fbd6a7447f873efd36fb3192580c3664eee560f7a957276976e41 languageName: node linkType: hard