From a5125fd42f602fb5f51b9246127cefd97f09dead Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Thu, 29 Oct 2026 10:06:02 -0700 Subject: [PATCH] test(images): portable delete tests, the native no-trash proof, D159 in TESTING.md (#480) --- TESTING.md | 53 ++++++++++++++++++++ tests/test_image_delete_routes.py | 29 ++++++++++- tests/test_image_removal.py | 14 ++++-- tools/vendor/frontend-coverage-baseline.json | 6 +++ utils/trash.py | 8 ++- 5 files changed, 102 insertions(+), 8 deletions(-) diff --git a/TESTING.md b/TESTING.md index 7b1cb16d..593460ef 100644 --- a/TESTING.md +++ b/TESTING.md @@ -2399,6 +2399,59 @@ npm run --prefix tools/vendor test:workflows -- workspace-collections.spec.mjs c images-concept.spec.mjs inspector-concept.spec.mjs shell-concept.spec.mjs --project chromium --project touch ``` +### Deleting images (#480) + +Images › Delete… removes the checked images (or "Select all N matching", at most 500, or +the one file of the side panel or the viewer) from the library, and on request moves the +files to the system trash. **No code path unlinks or overwrites a media file.** + +- **D159** (#480, maintainer request): the concept's "Remove links…" (`danger`, app.js + 432) returns as **Delete…** in the same place (after Export metadata, before Clear), and + as the last action of the side panel and after Download in the Images viewer. Its + review is the concept's `maintenancePlan` (`dl.meta-grid`, Cancel and a primary) with a + radio group first, "What should happen to the files?", nothing checked (the ruling: ask + every time): "Remove from the library only" or "Also move the files to the trash". The + rows follow the chosen mode (Scope, Not changed, Prompt links, Image details, Backup, + Media on disk) and the primary is `danger`: "Remove N images from the library" / "Move + N images to the trash". `tests/browser/images-delete-concept.spec.mjs` compares the bar + button and the review's grid and actions with the concept. +- **Routes.** `POST /prompt_manager/images/delete/preview {items | filter}` reviews both + modes at once under the maintenance preview lease and answers a reviewed plan + (`_plan_response`, operation `delete_images`): `library.count`, `trash.count`, `refused` + and `trash_refused` (`{count, counts, rows ≤ 50}` with coded reasons), `records`, + `orphaned_prompts` and the backup. Each target is matched to its entry in the current + listing (`not-listed` otherwise). `POST /prompt_manager/maintenance/apply {token, mode}` + (`mode` required for this plan, refused for any other) starts job kind `image_delete` + (admission `records`, not cancellable; groups `trashed`, `removed`, `not_changed`, + `errors`): sources unchanged, the promised safety backup, then per file a recheck + (containment and fingerprint) and + - library mode: one transaction deletes the file's records and writes its removal mark; + - trash mode: the file moves first (`utils/trash.py`); a refused move changes nothing; + then its records go. A records failure stops the job (`records-failed`); later files + are "Not attempted". +- **What goes** (`database/image_removal.py`): image links under every spelling (pair + links by cascade, workflow documents released), image tags, auto-tag marks, pending + undecided Needs review items, the own category, collection memberships and the + favorite. A decided or revised review item keeps its decision and the annotation rows it + cites. Prompts, recorded runs and the vocabulary stay. Previews are removed. +- **Removal marks** (`image_removals`, `image_removals_meta`): a library-only removal + hides exactly that file (locator, size, `mtime_ns`) from the workspace listing, the + output scan, the duplicate find, Tag output images and linking. A modified or replaced + file shows again. An explicit tag, category, favorite or collection write clears the + mark. Older builds ignore the tables (removed files show there); restoring an older + backup shows every file again; restoring a pre-delete backup after trash mode brings + back records of files that are in the trash (Missing-file links cleans them up). The + legacy gallery lists every file on disk, removed or not. Hard links have one mark per + path, and on FAT/exFAT/SMB a same-size replacement can stay hidden. +- **Trash backends** (`utils/trash.py`, no dependency): Linux and BSD implement the + freedesktop Trash specification (home trash on the same mount, else the mount's + `.Trash/$uid` or `.Trash-$uid`), reserve the name with `O_EXCL` and move with + `renameat2(RENAME_NOREPLACE)`; without it, across devices, on a read-only mount or with + a trash inside an output root the move is refused (`no-trash`, `trash-in-root`). Windows + and macOS report `no-trash` until their own backends land; library-only removal works + everywhere. Tests use `tests/fake_trash.py`; the browser test server installs it, so no + test touches a real trash. + ### Prompt library cards and recorded deviations (#249 PR E) The shelf uses the shared concept toolbar and caption (E1) and the concept's diff --git a/tests/test_image_delete_routes.py b/tests/test_image_delete_routes.py index d88dc0b8..d199a511 100644 --- a/tests/test_image_delete_routes.py +++ b/tests/test_image_delete_routes.py @@ -8,6 +8,7 @@ first (trash mode, the fake trash here). No path unlinks a media file. import asyncio import os +import sys import sqlite3 from pathlib import Path from unittest.mock import patch @@ -257,8 +258,11 @@ class TestFileCheck(ImageDeleteTestCase): outside = Path(self.tmpdir) / "elsewhere" outside.mkdir() (outside / "x.png").write_bytes(b"x") - os.symlink(outside / "x.png", self.output_dir / "link.png") - os.symlink(outside, self.output_dir / "linked") + try: + os.symlink(outside / "x.png", self.output_dir / "link.png") + os.symlink(outside, self.output_dir / "linked", target_is_directory=True) + except OSError: + self.skipTest("symlinks are not permitted here") self.assertEqual( file_check(str(self.output_dir / "link.png"), root, "link.png")[0], "link" ) @@ -279,3 +283,24 @@ class TestFileCheck(ImageDeleteTestCase): for code in trash.CODES: self.assertIn(code, image_delete.REASONS) + + +class TestNativeBackend(ImageDeleteTestCase): + """The real backend of this OS: Windows and macOS have no trash yet (#480).""" + + async def test_library_only_works_and_the_trash_is_unavailable_here(self): + fake_trash.uninstall() + self.write("a.png", b"aaa") + self.link("a.png") + plan = await self.review(["a.png"]) + native_trash = sys.platform not in ("win32", "darwin") + if not native_trash: + self.assertEqual(plan["trash"], {"count": 0}) + self.assertEqual(plan["trash_refused"]["counts"], {"no-trash": 1}) + body = await self.post(APPLY, {"token": plan["token"], "mode": "library"}, 202) + job = self.api._jobs.get(body["job"]["id"]) + await asyncio.wait_for(asyncio.shield(job.task), DISK_BOUND_SECONDS) + self.assertEqual(job.counters["removed"], 1, job.summary()) + self.assertTrue((self.output_dir / "a.png").exists()) + self.assertEqual(self.count("SELECT COUNT(*) FROM generated_images"), 0) + self.assertEqual(await self.listed(), []) diff --git a/tests/test_image_removal.py b/tests/test_image_removal.py index 03046dcd..50bb393e 100644 --- a/tests/test_image_removal.py +++ b/tests/test_image_removal.py @@ -33,8 +33,13 @@ class _RemovalCase(unittest.TestCase): base = Path(self.temp.name) self.root = base / "output" self.root.mkdir() + # A second spelling of the folder (a symlink); Windows without the + # privilege has none, and the records then carry one spelling. self.alias = base / "alias" - self.alias.symlink_to(self.root, target_is_directory=True) + try: + self.alias.symlink_to(self.root, target_is_directory=True) + except OSError: + self.alias = None self.db = PromptDatabase(str(base / "prompts.db")) self.addCleanup(self.db.close_all) self.epoch = self.db.get_execution_epoch() @@ -47,7 +52,8 @@ class _RemovalCase(unittest.TestCase): def removal(self, path): root_key = os.path.normcase(os.path.realpath(str(self.root))) return { - "spellings": [str(path), str(self.alias / path.name)], + "spellings": [str(path)] + + ([str(self.alias / path.name)] if self.alias else []), "locator": _locator(path), "favorites": [(root_key, path.name.lower())], "category": (root_key, path.name.lower()), @@ -113,8 +119,8 @@ class ImageRemovalRecordsTests(_RemovalCase): orphan, _ = self.link(path, "only this image") shared, _ = self.link(path, "two images") self.db.link_image_to_prompt(str(shared), str(other)) - # A record under the symlinked folder spelling. - self.link(path, "aliased", spelled=self.alias / "a.png") + # A record under the symlinked folder spelling (or a third prompt). + self.link(path, "aliased", spelled=(self.alias or self.root) / "a.png") removal = self.decorate(path) self.assertTrue(self.queue(path)) diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index 965573d9..7c18907f 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -281,6 +281,12 @@ "functions": 98, "statements": 98 }, + "workspace/images-delete.mjs": { + "lines": 80, + "branches": 70, + "functions": 80, + "statements": 80 + }, "workspace/images-export.mjs": { "lines": 98, "branches": 98, diff --git a/utils/trash.py b/utils/trash.py index 10ce87fb..242236b5 100644 --- a/utils/trash.py +++ b/utils/trash.py @@ -115,10 +115,12 @@ def _ismount_walk(path: str) -> str: def _libc_renameat2(): + if os.name != "posix": + return None # Windows: no libc, and no freedesktop backend try: libc = ctypes.CDLL(None, use_errno=True) function = libc.renameat2 - except (OSError, AttributeError): + except (OSError, AttributeError, TypeError): return None function.argtypes = [ ctypes.c_int, @@ -191,7 +193,9 @@ class FreedesktopBackend: self._data_home = data_home self._mounts = mounts or _proc_mounts self._rename = rename or rename_noreplace - self._uid = os.getuid() if uid is None else uid + if uid is None: + uid = os.getuid() if hasattr(os, "getuid") else -1 + self._uid = uid self._now = now or datetime.datetime.now def _home_data(self) -> str: