mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 11:11:26 -03:00
fix(tests): deflake recipe modal resource item tests by disposing modal instances
RecipeModal instances keep fire-and-forget async chains (hydration re-renders, mark-hash-invalid re-renders, 500ms reconnect/restore re-renders) and deferred DOM wiring timers alive across tests. On slow CI runners these land in the next test's window and overwrite or re-wire the shared document.body with stale content and stale instance handlers, failing a different test on every run. Add a tracked-timer helper and a dispose() teardown hook to RecipeModal: pending deferred work is cancelled, in-flight async chains become no-ops after disposal, and the global click listener is detached. The test afterEach now disposes every modal instance, making the file hermetic.
This commit is contained in:
@@ -128,6 +128,9 @@ class RecipeModal {
|
|||||||
this.recipeHydrationRequestId = 0;
|
this.recipeHydrationRequestId = 0;
|
||||||
this.navigationKeyHandler = null;
|
this.navigationKeyHandler = null;
|
||||||
this.navigationInProgress = false;
|
this.navigationInProgress = false;
|
||||||
|
this._disposed = false;
|
||||||
|
this._deferredTimerIds = new Set();
|
||||||
|
this._documentClickHandler = null;
|
||||||
this.resetLocalEditState();
|
this.resetLocalEditState();
|
||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
@@ -197,7 +200,7 @@ class RecipeModal {
|
|||||||
this.setupDeleteControl();
|
this.setupDeleteControl();
|
||||||
|
|
||||||
// Set up document click handler to close edit fields
|
// Set up document click handler to close edit fields
|
||||||
document.addEventListener('click', (event) => {
|
this._documentClickHandler = (event) => {
|
||||||
const recipeModal = document.getElementById('recipeModal');
|
const recipeModal = document.getElementById('recipeModal');
|
||||||
if (recipeModal && recipeModal.style.display !== 'none') {
|
if (recipeModal && recipeModal.style.display !== 'none') {
|
||||||
const mediaEl = event.target.closest('.recipe-preview-media');
|
const mediaEl = event.target.closest('.recipe-preview-media');
|
||||||
@@ -235,7 +238,8 @@ class RecipeModal {
|
|||||||
this.hideReconnectInput(container);
|
this.hideReconnectInput(container);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
};
|
||||||
|
document.addEventListener('click', this._documentClickHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
setupNavigationControls() {
|
setupNavigationControls() {
|
||||||
@@ -299,6 +303,39 @@ class RecipeModal {
|
|||||||
this._destroyAllReconnectComboboxes();
|
this._destroyAllReconnectComboboxes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a callback on a tracked timer so it can be cancelled when the
|
||||||
|
* modal is disposed. Used for render/wiring work that must never touch
|
||||||
|
* the DOM after the modal has been torn down (test teardown, close).
|
||||||
|
*/
|
||||||
|
_scheduleDeferred(fn, delay) {
|
||||||
|
const timerId = setTimeout(() => {
|
||||||
|
this._deferredTimerIds.delete(timerId);
|
||||||
|
fn();
|
||||||
|
}, delay);
|
||||||
|
this._deferredTimerIds.add(timerId);
|
||||||
|
return timerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tear the modal down: mark it disposed (in-flight async chains and any
|
||||||
|
* later render/wiring become no-ops), cancel pending deferred work, and
|
||||||
|
* detach global listeners. Safe to call multiple times.
|
||||||
|
*/
|
||||||
|
dispose() {
|
||||||
|
if (this._disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._disposed = true;
|
||||||
|
this._deferredTimerIds.forEach(timerId => clearTimeout(timerId));
|
||||||
|
this._deferredTimerIds.clear();
|
||||||
|
if (this._documentClickHandler) {
|
||||||
|
document.removeEventListener('click', this._documentClickHandler);
|
||||||
|
this._documentClickHandler = null;
|
||||||
|
}
|
||||||
|
this.cleanupNavigationShortcuts();
|
||||||
|
}
|
||||||
|
|
||||||
setupNavigationShortcuts() {
|
setupNavigationShortcuts() {
|
||||||
const modalElement = document.getElementById('recipeModal');
|
const modalElement = document.getElementById('recipeModal');
|
||||||
if (!modalElement) return;
|
if (!modalElement) return;
|
||||||
@@ -353,6 +390,9 @@ class RecipeModal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
showRecipeDetails(recipe) {
|
showRecipeDetails(recipe) {
|
||||||
|
if (this._disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const hydratedRecipe = recipe || {};
|
const hydratedRecipe = recipe || {};
|
||||||
this.resetLocalEditState();
|
this.resetLocalEditState();
|
||||||
// Store the full recipe for editing
|
// Store the full recipe for editing
|
||||||
@@ -443,7 +483,7 @@ class RecipeModal {
|
|||||||
// Delay binding slightly so modal layout is stable, but skip if this render was torn down.
|
// Delay binding slightly so modal layout is stable, but skip if this render was torn down.
|
||||||
const sourceUrlContainerRef = sourceUrlContainer;
|
const sourceUrlContainerRef = sourceUrlContainer;
|
||||||
const sourceUrlEditorRef = sourceUrlEditor;
|
const sourceUrlEditorRef = sourceUrlEditor;
|
||||||
setTimeout(() => {
|
this._scheduleDeferred(() => {
|
||||||
if (!document.body.contains(sourceUrlContainerRef) || !document.body.contains(sourceUrlEditorRef)) {
|
if (!document.body.contains(sourceUrlContainerRef) || !document.body.contains(sourceUrlEditorRef)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -556,7 +596,7 @@ class RecipeModal {
|
|||||||
async hydrateRecipeDetails(recipeId, requestId, requestEditVersions = {}) {
|
async hydrateRecipeDetails(recipeId, requestId, requestEditVersions = {}) {
|
||||||
try {
|
try {
|
||||||
const fullRecipe = await fetchRecipeDetails(recipeId);
|
const fullRecipe = await fetchRecipeDetails(recipeId);
|
||||||
if (requestId !== this.recipeHydrationRequestId || !fullRecipe) {
|
if (this._disposed || requestId !== this.recipeHydrationRequestId || !fullRecipe) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -952,6 +992,9 @@ class RecipeModal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
syncResourcesSection(recipe = {}) {
|
syncResourcesSection(recipe = {}) {
|
||||||
|
if (this._disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const checkpointContainer = document.getElementById('recipeCheckpoint');
|
const checkpointContainer = document.getElementById('recipeCheckpoint');
|
||||||
const resourceDivider = document.getElementById('recipeResourceDivider');
|
const resourceDivider = document.getElementById('recipeResourceDivider');
|
||||||
const lorasListElement = document.getElementById('recipeLorasList');
|
const lorasListElement = document.getElementById('recipeLorasList');
|
||||||
@@ -1016,7 +1059,7 @@ class RecipeModal {
|
|||||||
missingStatus.addEventListener('click', () => this.showDownloadMissingLorasModal());
|
missingStatus.addEventListener('click', () => this.showDownloadMissingLorasModal());
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
this._scheduleDeferred(() => {
|
||||||
const viewRecipeLorasBtn = document.getElementById('viewRecipeLorasBtn');
|
const viewRecipeLorasBtn = document.getElementById('viewRecipeLorasBtn');
|
||||||
if (viewRecipeLorasBtn) {
|
if (viewRecipeLorasBtn) {
|
||||||
viewRecipeLorasBtn.addEventListener('click', () => this.navigateToLorasPage());
|
viewRecipeLorasBtn.addEventListener('click', () => this.navigateToLorasPage());
|
||||||
@@ -1157,7 +1200,7 @@ class RecipeModal {
|
|||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
setTimeout(() => {
|
this._scheduleDeferred(() => {
|
||||||
this.setupReconnectButtons();
|
this.setupReconnectButtons();
|
||||||
this.setupLoraItemActions();
|
this.setupLoraItemActions();
|
||||||
this.setupLoraItemsClickable();
|
this.setupLoraItemsClickable();
|
||||||
@@ -2113,13 +2156,13 @@ class RecipeModal {
|
|||||||
try {
|
try {
|
||||||
const suggestions = await this._fetchReconnectSuggestions(loraIndex);
|
const suggestions = await this._fetchReconnectSuggestions(loraIndex);
|
||||||
// Stale guard: panel closed or another item opened while fetching
|
// Stale guard: panel closed or another item opened while fetching
|
||||||
if (token !== this._reconnectSuggestionsToken || !container.classList.contains('active')) {
|
if (this._disposed || token !== this._reconnectSuggestionsToken || !container.classList.contains('active')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this._renderReconnectSuggestions(container, suggestions, loraIndex);
|
this._renderReconnectSuggestions(container, suggestions, loraIndex);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching reconnect suggestions:', error);
|
console.error('Error fetching reconnect suggestions:', error);
|
||||||
if (token !== this._reconnectSuggestionsToken || !container.classList.contains('active')) {
|
if (this._disposed || token !== this._reconnectSuggestionsToken || !container.classList.contains('active')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this._renderReconnectSuggestions(container, [], loraIndex);
|
this._renderReconnectSuggestions(container, [], loraIndex);
|
||||||
@@ -2231,6 +2274,9 @@ class RecipeModal {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
if (this._disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Hide the reconnect input
|
// Hide the reconnect input
|
||||||
@@ -2256,7 +2302,7 @@ class RecipeModal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Refresh modal to show updated content
|
// Refresh modal to show updated content
|
||||||
setTimeout(() => {
|
this._scheduleDeferred(() => {
|
||||||
this.showRecipeDetails(this.currentRecipe);
|
this.showRecipeDetails(this.currentRecipe);
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
@@ -2290,6 +2336,9 @@ class RecipeModal {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
if (this._disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Swap the entry back to its pre-reconnect state
|
// Swap the entry back to its pre-reconnect state
|
||||||
@@ -2297,7 +2346,7 @@ class RecipeModal {
|
|||||||
|
|
||||||
showToast('toast.recipes.loraRestored', {}, 'success');
|
showToast('toast.recipes.loraRestored', {}, 'success');
|
||||||
|
|
||||||
setTimeout(() => {
|
this._scheduleDeferred(() => {
|
||||||
this.showRecipeDetails(this.currentRecipe);
|
this.showRecipeDetails(this.currentRecipe);
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
@@ -2342,6 +2391,9 @@ class RecipeModal {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
if (this._disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Hide the reconnect input
|
// Hide the reconnect input
|
||||||
@@ -2367,7 +2419,7 @@ class RecipeModal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Refresh modal to show updated content
|
// Refresh modal to show updated content
|
||||||
setTimeout(() => {
|
this._scheduleDeferred(() => {
|
||||||
this.showRecipeDetails(this.currentRecipe);
|
this.showRecipeDetails(this.currentRecipe);
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
@@ -2400,6 +2452,9 @@ class RecipeModal {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
if (this._disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Swap the entry back to its pre-reconnect state
|
// Swap the entry back to its pre-reconnect state
|
||||||
@@ -2407,7 +2462,7 @@ class RecipeModal {
|
|||||||
|
|
||||||
showToast('toast.recipes.checkpointRestored', {}, 'success');
|
showToast('toast.recipes.checkpointRestored', {}, 'success');
|
||||||
|
|
||||||
setTimeout(() => {
|
this._scheduleDeferred(() => {
|
||||||
this.showRecipeDetails(this.currentRecipe);
|
this.showRecipeDetails(this.currentRecipe);
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
@@ -2442,6 +2497,9 @@ class RecipeModal {
|
|||||||
recipe_id: recipeId,
|
recipe_id: recipeId,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
if (this._disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.currentRecipe?.checkpoint) {
|
if (this.currentRecipe?.checkpoint) {
|
||||||
this.currentRecipe.checkpoint.hashInvalid = true;
|
this.currentRecipe.checkpoint.hashInvalid = true;
|
||||||
this.syncResourcesSection(this.currentRecipe);
|
this.syncResourcesSection(this.currentRecipe);
|
||||||
@@ -2975,6 +3033,9 @@ class RecipeModal {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const updated = await fetchRecipeDetails(recipeId);
|
const updated = await fetchRecipeDetails(recipeId);
|
||||||
|
if (this._disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3083,6 +3144,9 @@ class RecipeModal {
|
|||||||
lora_index: loraIndex,
|
lora_index: loraIndex,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
if (this._disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.currentRecipe?.loras?.[loraIndex]) {
|
if (this.currentRecipe?.loras?.[loraIndex]) {
|
||||||
this.currentRecipe.loras[loraIndex].hashInvalid = true;
|
this.currentRecipe.loras[loraIndex].hashInvalid = true;
|
||||||
this.syncResourcesSection(this.currentRecipe);
|
this.syncResourcesSection(this.currentRecipe);
|
||||||
|
|||||||
@@ -210,7 +210,11 @@ describe('RecipeModal resource item interactions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
createdModals.forEach(recipeModal => recipeModal.cleanupNavigationShortcuts());
|
// dispose() marks each modal instance dead: pending deferred timers
|
||||||
|
// (wiring, 500ms reconnect re-renders) are cancelled and in-flight
|
||||||
|
// async chains become no-ops, so nothing from this test can touch the
|
||||||
|
// DOM of the next one.
|
||||||
|
createdModals.forEach(recipeModal => recipeModal.dispose());
|
||||||
createdModals.length = 0;
|
createdModals.length = 0;
|
||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
delete global.modalManager;
|
delete global.modalManager;
|
||||||
|
|||||||
Reference in New Issue
Block a user