diff --git a/docs/user-guide/albums.md b/docs/user-guide/albums.md index 051fc344..623c42a8 100644 --- a/docs/user-guide/albums.md +++ b/docs/user-guide/albums.md @@ -50,6 +50,20 @@ When the indexing process is done, you will find the generated indexes stored in When you add or remove image files from an album's image directory, you will need to reindex the album. Navigate to the album in the Album Manager list and press the blue Update Index button. The update operation will only reindex the files that have been added or removed and will be much faster than the first comprehensive indexing operation. +### Rebuilding an index from scratch + +Underneath it sits a red Rebuild Index button, which throws the existing index away and builds a new one. It asks for confirmation first, and only appears for albums that already have an index. + +You need it because **Update Index cannot re-read a file it has already indexed**. An update compares the files on disk against the ones in the index and processes only what was added or removed; a file that is in both lists is left exactly as it was first recorded, whatever its modification time. So anything PhotoMapAI works out *while* indexing — an image's or video's embedded generation metadata, for instance — is fixed at that moment. + +Rebuild when: + +- an upgrade taught PhotoMapAI to read something it previously ignored, and you want existing files re-examined; +- files changed in place, keeping their names; +- an index looks wrong or incomplete and you would rather start clean. + +Rebuilding costs a full pass over the album, the same as the first index. Nothing else is lost: the semantic map, cluster labels and thumbnails are derived from the index and are regenerated automatically. + ### Skipping Small Images During the traversal phase, PhotoMapAI inspects each candidate image and skips any whose width *or* height is below a minimum pixel threshold. The default is **256 pixels** in either dimension. This filter is meant to exclude thumbnails, favicons, contact-sheet previews, and other tiny images that don't carry enough visual content for semantic search to work well on them. A summary of how many images were skipped on the last scan is written to the server log. diff --git a/photomap/backend/routers/index.py b/photomap/backend/routers/index.py index 813acf31..f2abd161 100644 --- a/photomap/backend/routers/index.py +++ b/photomap/backend/routers/index.py @@ -20,7 +20,12 @@ from .. import invokeai_client from ..config import get_config_manager -from ..embeddings import LAST_UPDATED_FILENAME, Embeddings, peek_encoder_spec +from ..embeddings import ( + LAST_UPDATED_FILENAME, + Embeddings, + _open_npz_file, + peek_encoder_spec, +) from ..media_types import is_video from ..progress import IndexingCancelled, progress_tracker from ..thumbnail_cache import discard as discard_tiles @@ -174,6 +179,15 @@ async def remove_index(album_key: str) -> JSONResponse: # Remove the index file index_path.unlink() + # ``_open_npz_file`` is an lru_cache keyed on the path, so without + # this the just-deleted index stays live in memory and the app keeps + # serving an album whose index is no longer on disk. Every other + # clear sits in a *write* path in ``embeddings.py``, which was enough + # while this endpoint only ran as a prelude to re-indexing — the + # rebuild's own write cleared it. It is not enough now that a user + # can reach it from a button and the rebuild behind it can fail or + # be cancelled. + _open_npz_file.cache_clear() logger.info(f"Removed index file: {index_path}") return JSONResponse( diff --git a/photomap/frontend/static/css/album-manager.css b/photomap/frontend/static/css/album-manager.css index 7cbf2586..77639e85 100644 --- a/photomap/frontend/static/css/album-manager.css +++ b/photomap/frontend/static/css/album-manager.css @@ -234,11 +234,27 @@ .index-controls { display: flex; - align-items: center; + /* flex-start, not center: the status text wraps to two or three lines, + and centring the buttons against it floated them down out of line with + Edit/Delete in the next grid cell (which sits at the top, per + .album-details' align-items: start). */ + align-items: flex-start; gap: 0.5em; margin-bottom: 0.5em; } +/* Mirrors .action-buttons.vertical — same direction, same gap, same + right-alignment — so Update Index lines up with Edit and Rebuild Index + with Delete. stretch rather than flex-end so the two buttons share the + column's width instead of ragging by the couple of pixels their labels + differ by. */ +.index-buttons { + display: flex; + flex-direction: column; + gap: 0.5em; + align-items: stretch; +} + .index-status { color: #aaa; font-size: 0.9em; @@ -450,6 +466,23 @@ white-space: nowrap; } +/* Red, like .btn-delete: this discards work the user waited for. */ +.btn-rebuild { + background: #f44336; + border: none; + color: #fff; + padding: 0.4em 0.8em; + border-radius: 4px; + cursor: pointer; + font-size: 0.85em; + white-space: nowrap; +} + +.btn-rebuild:disabled { + opacity: 0.5; + cursor: default; +} + .btn-cancel { display: none; background: #f44336; diff --git a/photomap/frontend/static/css/delete-modal.css b/photomap/frontend/static/css/delete-modal.css index e460b209..f0accf11 100644 --- a/photomap/frontend/static/css/delete-modal.css +++ b/photomap/frontend/static/css/delete-modal.css @@ -13,7 +13,19 @@ #confirmModal.modal { position: fixed; - z-index: 9999; + /* Above .modal-overlay (99999) and the file tree (100001), below the + spinner (110000) and toasts (120000). + + A confirmation dialog has to outrank whatever opened it, and at 9999 + this one did not: asked from inside Album Management it rendered + *behind* that overlay — the prompt was painted but its buttons were + not clickable, so the flow simply stopped. Raising it is safe for the + other callers, since a modal question should always be on top. + + Note #deleteConfirmModal above still sits at 9999. It is only ever + opened from the main UI, never over an overlay, so it does not hit + this — but it is the same shape and would, if that changed. */ + z-index: 100002; left: 0; top: 0; width: 100vw; diff --git a/photomap/frontend/static/javascript/album-manager.js b/photomap/frontend/static/javascript/album-manager.js index f28da170..fbe141f2 100644 --- a/photomap/frontend/static/javascript/album-manager.js +++ b/photomap/frontend/static/javascript/album-manager.js @@ -1,6 +1,7 @@ // album-management.js import { createSimpleDirectoryPicker } from "./filetree.js"; // Add this import import { getIndexMetadata, removeIndex, updateIndex } from "./index.js"; +import { showConfirmModal } from "./modal-utils.js"; import { collectSelectedBoardIds, fetchInvokeAIBoards, @@ -917,6 +918,8 @@ export class AlbumManager { progressContainer.style.display = "none"; // Show the Update Index button updateBtn.style.display = "inline-block"; + // An index exists now, by definition, so Rebuild comes back too. + this.setRebuildButtonVisible(cardElement, true); } createCompletionMessage() { @@ -1056,18 +1059,21 @@ export class AlbumManager { status.textContent = `Index updated ${modDate} (${fileCount} images)`; status.style.color = "green"; createBtn.textContent = "Update Index"; + this.setRebuildButtonVisible(cardElement, true); this._appendIndexWarningNote(status, album.key); } else { status.className = "index-status"; status.textContent = "No index present"; status.style.color = "red"; createBtn.textContent = "Create Index"; + this.setRebuildButtonVisible(cardElement, false); } } catch { status.className = "index-status"; status.textContent = "No index present"; status.style.color = "red"; createBtn.textContent = "Create Index"; + this.setRebuildButtonVisible(cardElement, false); } } @@ -1107,6 +1113,11 @@ export class AlbumManager { card.querySelector(".cancel-index-btn").addEventListener("click", () => { this.cancelIndexing(album.key, cardElement); }); + + // Rebuild index button + card.querySelector(".rebuild-index-btn").addEventListener("click", () => { + this.rebuildIndex(album.key, cardElement); + }); } async addAlbum() { @@ -1608,8 +1619,43 @@ export class AlbumManager { } } + // Discard an album's index and build it again from scratch. + // + // Distinct from Update Index, which is a set difference on paths: it adds + // files that appeared and drops files that vanished, and never re-reads a + // file already in the index. So anything derived *while* indexing — a + // video's generation metadata, say — stays as it was first recorded until + // the index is thrown away. That is what this is for, and it is why the + // two cannot be the same button. + async rebuildIndex(albumKey, cardElement) { + const confirmed = await showConfirmModal( + "This will delete your previous index and rebuild it from scratch. Proceed?", + "Yes", + "Cancel" + ); + if (!confirmed) { + return; + } + // Re-resolve the card. The confirmation above is an await of unbounded + // length — it sits there until the user decides — and loadAlbums() + // rebuilds the card list wholesale, so the element captured when the + // button was clicked may be detached by now. Painting progress onto a + // detached node leaves the on-screen card frozen while indexing runs. + await this.startIndexing(albumKey, this._liveCardFor(albumKey, cardElement), true); + } + + // Whether this card offers Rebuild Index. There is nothing to rebuild + // before an index exists, and while one is being built the card shows + // Cancel instead. + setRebuildButtonVisible(cardElement, visible) { + const rebuildBtn = cardElement.querySelector(".rebuild-index-btn"); + if (rebuildBtn) { + rebuildBtn.style.display = visible ? "inline-block" : "none"; + } + } + // Indexing functionality - async startIndexing(albumKey, cardElement, isCorrupted = false) { + async startIndexing(albumKey, cardElement, removeExistingIndex = false) { // Prevent duplicate indexing requests (local guard) if (this.progressPollers.has(albumKey)) { console.log(`Indexing already in progress for album: ${albumKey}`); @@ -1639,14 +1685,17 @@ export class AlbumManager { console.debug(`Could not check backend indexing status for album: ${albumKey}`); } - if (isCorrupted) { - console.log(`Starting indexing for corrupted album: ${albumKey}`); + if (removeExistingIndex) { + // Two callers want this: automatic recovery from a corrupted index, + // and the user pressing Rebuild Index. Both mean "throw the existing + // index away first", so the wording here stays neutral between them. + console.log(`Removing the existing index before indexing: ${albumKey}`); const response = await removeIndex(albumKey); console.log(`Remove index response:`, response); if (!response.success) { const album = await this.getAlbum(albumKey); alert( - `Failed to remove corrupted index for album: ${albumKey}.` + + `Failed to remove the existing index for album: ${albumKey}.` + ` Please remove the index file manually and try again.` + ` The path for the index file is: ${album.index}` ); @@ -1776,6 +1825,9 @@ export class AlbumManager { progressContainer.style.display = "block"; createBtn.style.display = "none"; cancelBtn.style.display = "inline-block"; + // Rebuild follows Update Index: a second rebuild mid-run would delete + // the index the running job is about to write. + this.setRebuildButtonVisible(cardElement, false); // Only set generic message if no progress data is provided if (!progress) { diff --git a/photomap/frontend/templates/modules/album-manager.html b/photomap/frontend/templates/modules/album-manager.html index b963b72c..8d716e72 100644 --- a/photomap/frontend/templates/modules/album-manager.html +++ b/photomap/frontend/templates/modules/album-manager.html @@ -143,8 +143,19 @@

Ready to index
- - + +
+ + + +
diff --git a/tests/backend/test_remove_index.py b/tests/backend/test_remove_index.py new file mode 100644 index 00000000..7aa50741 --- /dev/null +++ b/tests/backend/test_remove_index.py @@ -0,0 +1,109 @@ +"""``DELETE /remove_index/{album_key}``, which Rebuild Index runs first. + +The endpoint predates the button, but only ever ran as a prelude to +re-indexing, so the one thing it got away with not doing — dropping the +deleted index out of the process-wide ``lru_cache`` — was covered by the +rebuild's own write clearing it moments later. A user-facing button removes +that cover: the rebuild behind it can fail, be cancelled, or simply not be +reached, and the app would go on serving an album whose index is no longer +on disk. +""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +import numpy as np +import pytest +from fixtures import ( + ENCODER_SPEC, + _write_synthetic_index, + client, # noqa: F401 + media_fixture_path, +) + +from photomap.backend.embeddings import _open_npz_file + + +@pytest.fixture +def indexed_album(client, tmp_path): # noqa: F811 + """An album with a real index file on disk.""" + media_dir = tmp_path / "pics" + media_dir.mkdir() + photo = media_dir / "building1.jpeg" + shutil.copy( + media_fixture_path("../test_images/building1.jpeg").resolve(), photo + ) + + index_path = media_dir / "photomap_index" / "embeddings.npz" + _write_synthetic_index(index_path, [photo], [{"Make": "TestCam"}]) + + album = { + "key": "removable_album", + "name": "Removable", + "image_paths": [media_dir.as_posix()], + "index": index_path.as_posix(), + "umap_eps": 0.1, + "description": "", + "encoder_spec": ENCODER_SPEC, + } + try: + assert client.post("/add_album/", json=album).status_code == 201 + yield {**album, "index_path": index_path} + finally: + client.delete(f"/delete_album/{album['key']}") + + +def test_removing_an_index_deletes_the_file(client, indexed_album): # noqa: F811 + response = client.delete(f"/remove_index/{indexed_album['key']}") + + assert response.status_code == 200 + assert response.json()["success"] is True + assert not Path(indexed_album["index_path"]).exists() + + +def test_removing_an_index_drops_it_from_the_cache(client, indexed_album): # noqa: F811 + """Otherwise the app serves an index that is no longer on disk. + + The read below is what puts it in the cache — exactly as any request + touching the album would have. + """ + index_path = Path(indexed_album["index_path"]) + assert len(_open_npz_file(index_path)["filenames"]) == 1 + + client.delete(f"/remove_index/{indexed_album['key']}") + + with pytest.raises(FileNotFoundError): + _open_npz_file(index_path) + + +def test_removing_a_missing_index_is_a_404(client, indexed_album): # noqa: F811 + Path(indexed_album["index_path"]).unlink() + + response = client.delete(f"/remove_index/{indexed_album['key']}") + + assert response.status_code == 404 + + +def test_removing_the_index_of_an_unknown_album_is_a_404(client): # noqa: F811 + assert client.delete("/remove_index/no-such-album").status_code == 404 + + +def test_only_the_index_file_is_removed(client, indexed_album): # noqa: F811 + """The semantic map, cluster labels and thumbnails are derived and + self-invalidate by mtime against the index, so a rebuild regenerates + them — deleting them here would only throw away reusable work. + """ + index_path = Path(indexed_album["index_path"]) + umap = index_path.parent / "umap.npz" + np.savez(umap, umap_embeddings=np.zeros((1, 2), dtype=np.float32)) + thumbs = index_path.parent / "thumbnails" + thumbs.mkdir() + (thumbs / "0.webp").write_bytes(b"not really a webp") + + client.delete(f"/remove_index/{indexed_album['key']}") + + assert not index_path.exists() + assert umap.exists() + assert (thumbs / "0.webp").exists() diff --git a/tests/frontend/album-manager-rebuild.test.js b/tests/frontend/album-manager-rebuild.test.js new file mode 100644 index 00000000..6513be2f --- /dev/null +++ b/tests/frontend/album-manager-rebuild.test.js @@ -0,0 +1,300 @@ +/** + * Rebuild Index: the confirmation gate, the remove-then-update sequence, and + * when the button is offered at all. + * + * Rebuild exists because Update Index cannot do this job. An update is a set + * difference on paths — it adds files that appeared and drops files that + * vanished, and never re-reads a file already in the index — so anything + * derived at index time stays as first recorded until the index is discarded. + * That is why the destructive path is a separate, confirmed, red button + * rather than a flag on the existing one, and why these tests care about the + * *order* of removal and re-index rather than only that both happened. + * + * album-manager.js pulls in a large sibling graph whose modules touch the DOM + * at import time, so the direct imports are mocked and the module under test + * is loaded dynamically — the same pattern as album-manager-progress.test.js. + */ +import { afterEach, beforeAll, beforeEach, describe, expect, jest, test } from "@jest/globals"; + +const M = "../../photomap/frontend/static/javascript"; + +jest.unstable_mockModule(`${M}/filetree.js`, () => ({ + createSimpleDirectoryPicker: jest.fn(), +})); +jest.unstable_mockModule(`${M}/index.js`, () => ({ + getIndexMetadata: jest.fn(), + removeIndex: jest.fn(), + updateIndex: jest.fn(), +})); +jest.unstable_mockModule(`${M}/modal-utils.js`, () => ({ + showConfirmModal: jest.fn(), +})); +jest.unstable_mockModule(`${M}/search-ui.js`, () => ({ + exitSearchMode: jest.fn(), +})); +jest.unstable_mockModule(`${M}/settings.js`, () => ({ + closeSettingsModal: jest.fn(), + loadAvailableAlbums: jest.fn(), + openSettingsModal: jest.fn(), +})); +jest.unstable_mockModule(`${M}/state.js`, () => ({ + setAlbum: jest.fn(), + refreshActiveAlbumSearchSettings: jest.fn(() => Promise.resolve()), + state: {}, +})); +jest.unstable_mockModule(`${M}/utils.js`, () => ({ + fetchJson: jest.fn(() => Promise.resolve({})), + hideSpinner: jest.fn(), + showSpinner: jest.fn(), +})); + +const { getIndexMetadata } = await import(`${M}/index.js`); +const { showConfirmModal } = await import(`${M}/modal-utils.js`); + +let AlbumManager; + +beforeAll(async () => { + document.body.innerHTML = + `
` + + ["addAlbumBtn", "cancelAddAlbumBtn", "cancelAddAlbumBtn2", "closeAlbumManagementBtn", "showAddAlbumBtn"] + .map((id) => ``) + .join(""); + + ({ AlbumManager } = await import(`${M}/album-manager.js`)); +}); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +/** A card carrying the nodes these methods touch. */ +function makeCard() { + const card = document.createElement("div"); + card.innerHTML = + `
` + + `` + + `` + + `` + + `
`; + return card; +} + +const rebuildBtn = (card) => card.querySelector(".rebuild-index-btn"); + +describe("the confirmation gate", () => { + test("asks before destroying anything, with the agreed wording", async () => { + const card = makeCard(); + const self = { startIndexing: jest.fn() }; + showConfirmModal.mockResolvedValue(false); + + await AlbumManager.prototype.rebuildIndex.call(self, "album1", card); + + expect(showConfirmModal).toHaveBeenCalledWith( + "This will delete your previous index and rebuild it from scratch. Proceed?", + "Yes", + "Cancel" + ); + }); + + test("cancelling touches nothing", async () => { + const card = makeCard(); + const self = { startIndexing: jest.fn() }; + showConfirmModal.mockResolvedValue(false); + + await AlbumManager.prototype.rebuildIndex.call(self, "album1", card); + + expect(self.startIndexing).not.toHaveBeenCalled(); + }); + + test("confirming re-indexes with the remove-first flag set", async () => { + const card = makeCard(); + const self = { startIndexing: jest.fn(), _liveCardFor: (_key, fallback) => fallback }; + showConfirmModal.mockResolvedValue(true); + + await AlbumManager.prototype.rebuildIndex.call(self, "album1", card); + + expect(self.startIndexing).toHaveBeenCalledWith("album1", card, true); + }); + + test("uses the album's live card, not the one captured before the prompt", async () => { + // The confirmation is an await of unbounded length, and loadAlbums() + // rebuilds the card list wholesale, so the element captured when the + // button was clicked can be detached by the time the user answers. + // Progress painted onto a detached node leaves the visible card frozen. + const stale = makeCard(); + const live = makeCard(); + const self = { + startIndexing: jest.fn(), + _liveCardFor: jest.fn(() => live), + }; + showConfirmModal.mockResolvedValue(true); + + await AlbumManager.prototype.rebuildIndex.call(self, "album1", stale); + + expect(self._liveCardFor).toHaveBeenCalledWith("album1", stale); + expect(self.startIndexing).toHaveBeenCalledWith("album1", live, true); + }); +}); + +describe("when the button is offered", () => { + function statusSelf(card) { + return { + setRebuildButtonVisible: AlbumManager.prototype.setRebuildButtonVisible, + _appendIndexWarningNote: jest.fn(), + _card: card, + }; + } + + test("hidden when the album has no index — there is nothing to rebuild", async () => { + const card = makeCard(); + rebuildBtn(card).style.display = "inline-block"; + getIndexMetadata.mockResolvedValue(null); + + await AlbumManager.prototype.updateAlbumCardIndexStatus.call(statusSelf(card), card, { + key: "album1", + }); + + expect(rebuildBtn(card).style.display).toBe("none"); + }); + + test("shown once an index exists", async () => { + const card = makeCard(); + getIndexMetadata.mockResolvedValue({ last_modified: 1_700_000_000, filename_count: 7 }); + + await AlbumManager.prototype.updateAlbumCardIndexStatus.call(statusSelf(card), card, { + key: "album1", + }); + + expect(rebuildBtn(card).style.display).toBe("inline-block"); + }); + + test("hidden when the index metadata lookup fails", async () => { + const card = makeCard(); + rebuildBtn(card).style.display = "inline-block"; + getIndexMetadata.mockRejectedValue(new Error("boom")); + + await AlbumManager.prototype.updateAlbumCardIndexStatus.call(statusSelf(card), card, { + key: "album1", + }); + + expect(rebuildBtn(card).style.display).toBe("none"); + }); + + test("hidden while an index is being built", () => { + const card = makeCard(); + rebuildBtn(card).style.display = "inline-block"; + const self = { + setRebuildButtonVisible: AlbumManager.prototype.setRebuildButtonVisible, + updateProgress: jest.fn(), + }; + + AlbumManager.prototype.showProgressUIWithoutScroll.call(self, card, null); + + // A second rebuild mid-run would delete the index the running job is + // about to write. + expect(rebuildBtn(card).style.display).toBe("none"); + }); + + test("offered again once indexing completes", () => { + const card = makeCard(); + const self = { setRebuildButtonVisible: AlbumManager.prototype.setRebuildButtonVisible }; + + AlbumManager.prototype.showIndexingCompletedUI.call(self, card); + + expect(rebuildBtn(card).style.display).toBe("inline-block"); + }); + + test("tolerates a card rendered before the button existed", () => { + const card = makeCard(); + rebuildBtn(card).remove(); + + expect(() => AlbumManager.prototype.setRebuildButtonVisible.call({}, card, true)).not.toThrow(); + }); +}); + +describe("the remove-then-update sequence", () => { + function indexingSelf() { + return { + progressPollers: new Map(), + showProgressUIWithoutScroll: jest.fn(), + startProgressPolling: jest.fn(), + handleIndexingCompletion: jest.fn(), + getAlbum: jest.fn(() => Promise.resolve({ index: "/tmp/embeddings.npz" })), + }; + } + + test("the old index is removed before the new one is built", async () => { + const { removeIndex, updateIndex } = await import(`${M}/index.js`); + const calls = []; + removeIndex.mockImplementation(() => { + calls.push("remove"); + return Promise.resolve({ success: true }); + }); + updateIndex.mockImplementation(() => { + calls.push("update"); + return Promise.resolve({ status: "scanning" }); + }); + + await AlbumManager.prototype.startIndexing.call(indexingSelf(), "album1", makeCard(), true); + + // Order is the whole point: updating first would index against the very + // records the rebuild exists to discard. + expect(calls).toEqual(["remove", "update"]); + }); + + test("a plain update never removes the index", async () => { + const { removeIndex, updateIndex } = await import(`${M}/index.js`); + updateIndex.mockResolvedValue({ status: "scanning" }); + + await AlbumManager.prototype.startIndexing.call(indexingSelf(), "album1", makeCard(), false); + + expect(removeIndex).not.toHaveBeenCalled(); + expect(updateIndex).toHaveBeenCalledWith("album1"); + }); + + test("a failed removal does not go on to re-index", async () => { + const { removeIndex, updateIndex } = await import(`${M}/index.js`); + removeIndex.mockResolvedValue({ success: false }); + updateIndex.mockResolvedValue({ status: "scanning" }); + jest.spyOn(window, "alert").mockImplementation(() => {}); + + await AlbumManager.prototype.startIndexing.call(indexingSelf(), "album1", makeCard(), true); + + // Otherwise the card would sit in "indexing" over an index that is still + // the old one. + expect(updateIndex).not.toHaveBeenCalled(); + window.alert.mockRestore(); + }); +}); + +describe("the confirmation dialog stacks above the album manager", () => { + // jsdom does no layout and no cascade, so the bug this guards against is + // invisible to every other test here: the dialog was painted but sat + // *behind* the Album Management overlay, leaving its buttons unclickable + // and the flow dead. Asserting the declared z-indexes is crude, but it is + // the part that actually broke, and it is checkable without a browser. + const CSS = "../../photomap/frontend/static/css"; + + async function zIndexOf(file, selector) { + const { readFile } = await import("node:fs/promises"); + const { fileURLToPath } = await import("node:url"); + const path = fileURLToPath(new URL(`${CSS}/${file}`, import.meta.url)); + const text = await readFile(path, "utf8"); + const block = new RegExp(`${selector}\\s*\\{([^}]*)\\}`).exec(text); + expect(block).not.toBeNull(); + const z = /z-index:\s*(\d+)/.exec(block[1]); + expect(z).not.toBeNull(); + return Number(z[1]); + } + + test("#confirmModal outranks .modal-overlay", async () => { + const confirmZ = await zIndexOf("delete-modal.css", "#confirmModal\\.modal"); + const overlayZ = await zIndexOf("modal-base.css", "\\.modal-overlay"); + + expect(confirmZ).toBeGreaterThan(overlayZ); + }); +});