Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4afb042b07 |
@@ -26,9 +26,6 @@ env:
|
||||
# Measured 2026-10-29 on main runs 40935337099 (dd247e3), 40938407252 (dd247e3), 40941240624 (dd247e3):
|
||||
# slowest leg: 7m24s (py3.12 windows) / 7m31s (py3.13 windows) / 7m32s (py3.14 windows) -> 15 min
|
||||
# (windows 4m56s-7m32s, macos 4m39s-6m14s)
|
||||
# native-trash (#499) has no green main runs yet: its probe, run 40949370943 (2026-10-29), took
|
||||
# about 1 minute per macOS leg, so ceil(1.5 x 2) + 3 gives it 6 minutes (step 4). Recalibrate
|
||||
# after three green runs.
|
||||
jobs:
|
||||
test:
|
||||
name: py${{ matrix.python-version }} on ${{ matrix.os }}
|
||||
@@ -79,28 +76,3 @@ jobs:
|
||||
env:
|
||||
PM_TEST_SLOWEST: "40"
|
||||
run: python -m tests.run_suite -v
|
||||
|
||||
# The real Trash on both macOS CPUs (#499 design §10.2). It runs apart from the suite, so a red
|
||||
# suite never skips the native proof.
|
||||
native-trash:
|
||||
name: native trash on ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 6
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos-latest, macos-15-intel]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Native trash proof
|
||||
timeout-minutes: 4
|
||||
env:
|
||||
PM_NATIVE_TRASH: macos
|
||||
run: python -m unittest tests.test_trash_macos_native tests.test_trash_macos -v
|
||||
|
||||
+8
-92
@@ -53,14 +53,6 @@ stdin open and the server (`--exit-on-stdin-eof`) shuts down cleanly, removing i
|
||||
temp root, when that pipe closes, so a worker killed without teardown leaves no
|
||||
server behind. A test that fails gets the stderr its server wrote during the test
|
||||
attached to the report as `server-stderr` (an aiohttp traceback behind a 500).
|
||||
Each worker also records its browser's own log (#513, `tools/vendor/browser-logs.mjs`):
|
||||
Playwright's `pw:browser` channel, kept as a 300-line tail without printing it
|
||||
unless `DEBUG` asks for it. A browser that exits abnormally writes one
|
||||
`pm-browser-exit| browser pid=<n> exited: exitCode=<c>, signal=<s>` line to the job
|
||||
log, and a test that fails on a closed or crashed target ("Target page, context or
|
||||
browser has been closed") gets the tail attached as `browser-stderr.txt`, which the
|
||||
uploaded test-results carry. `tests/browser/browser-logs.spec.mjs` kills a browser of
|
||||
its own on purpose, so each browser job logs that line once for it.
|
||||
A `/__test__/.../reset` still running after 2 s writes a report to the job log,
|
||||
passing test or not: every thread's stack taken while it waits, the default
|
||||
executor's threads and queue, the open connections and WAL size, then the total
|
||||
@@ -2495,30 +2487,14 @@ files to the system trash. **No code path unlinks or overwrites a media file.**
|
||||
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`). macOS
|
||||
(#499, `utils/trash_macos.py`) calls `-[NSFileManager trashItemAtURL:resultingItemURL:error:]`
|
||||
through `ctypes` (ten fixed `objc_msgSend` prototypes, nil checks before every send, one
|
||||
autorelease pool per call, loaded on first use). It answers in the review dialog, without
|
||||
writing: `network` (not a local volume), `no-trash` (read-only, or no trash folder
|
||||
macOS would use), `cloud-sync` (iCloud Drive, `~/Library/CloudStorage`, or ubiquitous),
|
||||
`trash-in-root` (the trash folder is inside an output root, compared by `(st_dev,
|
||||
st_ino)` of every ancestor) and `no-permission` (folder not writable, an immutable or
|
||||
append flag, a foreign file in a sticky folder). A fresh volume's `.Trashes/<uid>` is
|
||||
predicted (`URLForDirectory` answers 3328/`ENOENT` until macOS makes it). A refused move
|
||||
keeps the file and logs the `NSError`; a move macOS reports done but whose place cannot be
|
||||
shown (no result URL, or one inside an output root) goes under `errors` as
|
||||
`trash-unconfirmed`, keeps its records and stops the job. Windows
|
||||
a trash inside an output root the move is refused (`no-trash`, `trash-in-root`). Windows
|
||||
never uses the Recycle Bin (#500, maintainer ruling (a)): files larger than the bin's
|
||||
capacity are deleted permanently and IFileOperation's `PreDeleteItem` guard cannot detect
|
||||
it (native probe runs 40951037575, 40953347904), so every file answers `windows-recycle`
|
||||
("Moving files to the Recycle Bin isn't available on Windows: files larger than the bin's
|
||||
capacity would be deleted permanently."). Library-only removal works everywhere. Tests use `tests/fake_trash.py`; the
|
||||
browser test server installs it, so no test touches a real trash. The native proof,
|
||||
`tests/test_trash_macos_native.py`, runs only with `PM_NATIVE_TRASH=macos` in
|
||||
portability.yml's `native-trash` job (`macos-latest` and `macos-15-intel`): the startup
|
||||
volume, a name clash, fresh APFS and FAT32 disk images, an image used as an output root,
|
||||
a read-only image and an unwritable folder (design and probe:
|
||||
`docs/egm/499-macos-trash/`, local).
|
||||
capacity would be deleted permanently."). macOS reports `no-trash` until #499 lands;
|
||||
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)
|
||||
|
||||
@@ -2783,15 +2759,14 @@ rulings of 2026-10-28):
|
||||
504: "The server did not confirm the change. The Library was reloaded: check the shelf and
|
||||
Show archived prompts."; other refusals: "The selected prompts could not be changed. Try
|
||||
again.", the selection kept.
|
||||
- **Show archived prompts (N)** (heading actions, before Export, a plain button; N the
|
||||
- **Show archived prompts (N)** (heading actions, before Export, `aria-pressed`; N the
|
||||
archived prompts matching the filters, `archived_total`; the shelf counterpart of #235's
|
||||
Show archived) lists only archived prompts (`GET /prompt_manager/library?shelf=archived`,
|
||||
a cursor never crosses shelves), each card with an "Archived" `.chip`, the caption "N
|
||||
archived prompts · M negative variants hidden with them", the bar "N selected", Restore,
|
||||
Clear, and "No archived prompts." / "No archived prompts match." when empty. On the archived
|
||||
shelf the button reads the concept's own way back, "← Prompt library" (app.js 594; #482), with
|
||||
no `aria-pressed` (its label names where it goes). The shelf stays with the shelf visit, never
|
||||
in a saved view; Browse and a saved view open the active shelf. Restore posts `{prompt_ids, expected_epoch}` to `POST
|
||||
Clear, and "No archived prompts." / "No archived prompts match." when empty. The pressed
|
||||
state stays with the shelf visit, never in a saved view; Browse and a saved view open the
|
||||
shelf. Restore posts `{prompt_ids, expected_epoch}` to `POST
|
||||
/prompt_manager/library/restore` (every requested archived id, protected or not): "Restored
|
||||
N prompts to the Prompt library."
|
||||
- An archived prompt's page shows the concept's `.notice` ("Archived" / "This prompt is archived
|
||||
@@ -2819,65 +2794,6 @@ failed backup, busy, unconfirmed, restore fence); `tests/browser/library-delete-
|
||||
`tests/test_prompt_archive_schema.py`, `tests/test_bulk_library_delete.py`,
|
||||
`tests/test_library_delete_routes.py`. A Python change: ComfyUI needs a restart.
|
||||
|
||||
### Deleting a prompt in a saved pair permanently (#482)
|
||||
|
||||
**D160** (beyond legacy, maintainer ruling 2026-10-29; follow-up to D152): when the Delete… selection
|
||||
holds prompts in a saved pair that no other positive uses as its negative ("deletable"), the review
|
||||
asks, in a D90 radio row "Prompts in a saved pair" after the grid: **Archive (hidden, restorable)**
|
||||
(checked on every open, never remembered, so the review is D152's) or **Delete permanently, with
|
||||
their saved pairs**. Choosing it rewrites the review in place (`openReviewDialog` `setRows`,
|
||||
`setPrimary`):
|
||||
- Delete permanently "D prompts: X not in a saved pair, Y in a saved pair" (or "Y prompts in a saved
|
||||
pair"), Saved pairs removed "S saved pairs (V with a negative prompt, A archived)", Negatives kept
|
||||
"K negative prompts are kept", Archive "R prompts used as another prompt's negative: hidden,
|
||||
restorable", then D152's protected, already archived, no longer exist, Image links removed (images
|
||||
of every deleted prompt) and Backup rows.
|
||||
- Sentences: the pairs go with their titles, categories, ratings, notes, tags, run counts and image
|
||||
links; their negative prompts stay; recorded runs stay in History and Activity by their text; D152's
|
||||
irreversibility (per backup policy) and re-creation sentences, plus "A pair node run with the same
|
||||
text saves a new pair."; and, when R > 0, why those are archived.
|
||||
- Primary (`danger`): "Delete D prompts" / "Delete D and archive R prompts"; a short disk keeps it
|
||||
unavailable (`aria-disabled`) with the disk sentence, while Archive still starts.
|
||||
|
||||
The preview adds `deletable` (a subset of `archive`, which keeps every archive id for D152 clients),
|
||||
`review_digests` (sha256 of each deletable prompt's row and its pairs: negative, archived flag,
|
||||
metadata, usage, tags, image-pair links), `paired_counts` and `paired_image_links`, and plans the
|
||||
disk whenever anything could be deleted. The write takes optional `delete_paired_ids` with their
|
||||
`paired_digests` (both or neither; ids must be reviewed archive ids). In one transaction, after the
|
||||
archives, each chosen prompt whose digest is unchanged loses its image-pair links, then its own saved
|
||||
pairs (pair tags cascade; review decisions keep their rows with `pair_id` cleared), then itself exactly
|
||||
as D152 deletes. Any drift (pair added or edited, new link, pairs gone, now another prompt's negative)
|
||||
archives it and counts it `changed`. A paired delete is backed up and disk-checked as a delete. The
|
||||
toast adds "Removed S saved pairs."; "N changed since the review and was not deleted." replaces "…was
|
||||
kept.". Negatives are never deleted: one no remaining pair uses is listed in the Library afterwards (as
|
||||
after the legacy pair delete). Legacy still refuses paired prompts (409).
|
||||
|
||||
Tests: `tests/test_bulk_library_delete.py` `TestPairedDelete` (classes and counts, the 7896 empty
|
||||
negative, the row diff against the legacy pair deletes then prompt delete, archive-only, a positive
|
||||
with its own negative, a self-pair, each drift, validation); `tests/test_library_delete_routes.py`
|
||||
`TestPairedDeleteRoutes` (preview, paired-only backup, a D152 body, validation, short disk); units
|
||||
`tests/js/workspace-library-delete.test.js`, `tests/js/workspace-toolbar.test.js` (`setRows`,
|
||||
`setPrimary`); browser `tests/browser/workspace-library-delete.spec.mjs` (the choice, its grid and
|
||||
copy, the server after, archive-only, reset to Archive) and `library-delete-concept.spec.mjs`. A Python
|
||||
change: ComfyUI needs a restart.
|
||||
|
||||
### Delete on a prompt's own page (#481)
|
||||
|
||||
**D161** (beyond the concept, coordinator ruling (A) 2026-10-29, open to the maintainer at the live test): the recipe
|
||||
editor (Edit metadata, the concept's app.js 976 row "Create text revision", "Archive…", Cancel, Save) reads "Create
|
||||
text revision", **"Archive variant…"** (the selected negative variant's archive, #235, which used the concept's
|
||||
"Archive…" label) and **"Delete prompt…"** (danger), then Cancel and Save. "Delete prompt…" closes the editor and runs
|
||||
D152/D160's Delete… review for that one prompt (`reviewPromptDelete`: the same preview, review with the saved-pair
|
||||
choice, backup and write, fenced by the page's `library_epoch`). Cancel changes nothing and returns focus to Edit
|
||||
metadata. A delete toasts and opens the Prompt library; an archive (or a drift) toasts and reads the page again, so an
|
||||
archived prompt shows its Archived notice. The button is disabled while the editor saves or after a restore.
|
||||
|
||||
The concept comparison applies D161 in `mapConcept` (`tests/browser/library-editor-canonical-comparison.mjs`): the
|
||||
concept's danger "Archive…" maps to "Archive variant…" followed by a "Delete prompt…" with the same geometry and tokens.
|
||||
|
||||
Tests: `tests/browser/workspace-library-delete.spec.mjs` (#481 cases), and `workspace-library-editor.spec.mjs` (the
|
||||
hand-off and the comparisons) and `library-editor-visual.spec.mjs` with the renamed button. JS only: no restart.
|
||||
|
||||
### No 1–5 prompt rating in the workspace (#447)
|
||||
|
||||
**D153** (maintainer ruling 2026-10-28: the star is only for favorites; the concept keeps
|
||||
|
||||
+7
-155
@@ -13,9 +13,6 @@ and delete lists, and deletes only reviewed deletes the route backed up
|
||||
(``candidates``) whose fingerprint is unchanged since that backup.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Iterable
|
||||
|
||||
@@ -42,15 +39,8 @@ _PAIRED = (
|
||||
"EXISTS(SELECT 1 FROM prompt_pairs pp "
|
||||
"WHERE pp.positive_prompt_id=p.id OR pp.negative_prompt_id=p.id)"
|
||||
)
|
||||
# #482: used as the negative of another positive's pair (archived or not):
|
||||
# never deleted, so that positive's variant stays (archive only).
|
||||
_NEGATIVE_OF_OTHER = (
|
||||
"EXISTS(SELECT 1 FROM prompt_pairs op "
|
||||
"WHERE op.negative_prompt_id=p.id AND op.positive_prompt_id!=p.id)"
|
||||
)
|
||||
# Classes in the order a prompt is tested; the first that holds wins.
|
||||
CLASSES = ("not_found", "protected", "already_archived", "archive", "delete")
|
||||
_DIGEST = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
def _batches(ids: list) -> Iterable[list]:
|
||||
@@ -88,31 +78,24 @@ class LibraryArchiveOperations:
|
||||
def _classify_library_delete(
|
||||
self, conn: sqlite3.Connection, prompt_ids: list[int]
|
||||
) -> dict:
|
||||
found, deletable = {}, set()
|
||||
found = {}
|
||||
for chunk in _batches(prompt_ids):
|
||||
marks = ",".join("?" for _ in chunk)
|
||||
for row in conn.execute(
|
||||
f"SELECT p.id,{_PROTECTED},{_ARCHIVED},{_PAIRED}," # nosec B608
|
||||
f"{_NEGATIVE_OF_OTHER} "
|
||||
f"SELECT p.id,{_PROTECTED},{_ARCHIVED},{_PAIRED} " # nosec B608
|
||||
f"FROM prompts p WHERE p.id IN ({marks}) AND {_ELIGIBLE}",
|
||||
chunk,
|
||||
):
|
||||
protected, archived, paired, negative_of_other = row[1:5]
|
||||
protected, archived, paired = row[1], row[2], row[3]
|
||||
if protected:
|
||||
found[row[0]] = "protected"
|
||||
elif archived:
|
||||
found[row[0]] = "already_archived"
|
||||
elif paired:
|
||||
found[row[0]] = "archive"
|
||||
if not negative_of_other:
|
||||
deletable.add(row[0])
|
||||
else:
|
||||
found[row[0]] = "delete"
|
||||
found[row[0]] = "archive" if paired else "delete"
|
||||
classes = {name: [] for name in CLASSES}
|
||||
for pid in prompt_ids:
|
||||
classes[found.get(pid, "not_found")].append(pid)
|
||||
# #482: the archive ids Delete permanently may delete with their pairs.
|
||||
classes["deletable"] = [pid for pid in classes["archive"] if pid in deletable]
|
||||
return classes
|
||||
|
||||
@staticmethod
|
||||
@@ -154,82 +137,6 @@ class LibraryArchiveOperations:
|
||||
]
|
||||
return prints
|
||||
|
||||
@staticmethod
|
||||
def _distinct_images(conn: sqlite3.Connection, prompt_ids: list[int]) -> int:
|
||||
"""Image rows of ``prompt_ids`` (each image once; #482 deletes them)."""
|
||||
return LibraryArchiveOperations._image_links(conn, prompt_ids)
|
||||
|
||||
@staticmethod
|
||||
def _pair_prints(conn: sqlite3.Connection, prompt_ids: list[int]) -> dict:
|
||||
"""#482: each prompt's own saved pairs, as the review saw them.
|
||||
|
||||
Per pair (by id): its negative, archived flag, metadata, usage, ordered
|
||||
tag ids and its image-pair links (count and newest id).
|
||||
"""
|
||||
prints = {pid: [] for pid in prompt_ids}
|
||||
for chunk in _batches(prompt_ids):
|
||||
marks = ",".join("?" for _ in chunk)
|
||||
for row in conn.execute(
|
||||
"SELECT pp.positive_prompt_id,pp.id,pp.negative_prompt_id," # nosec
|
||||
"pp.archived,pp.title,pp.category,pp.rating,pp.notes,pp.run_count,"
|
||||
"pp.last_used_at,pp.updated_at,"
|
||||
"(SELECT group_concat(tag_id) FROM (SELECT tag_id FROM "
|
||||
"prompt_pair_tags WHERE pair_id=pp.id ORDER BY tag_id)),"
|
||||
"(SELECT COUNT(*) FROM image_prompt_pairs WHERE pair_id=pp.id),"
|
||||
"(SELECT MAX(id) FROM image_prompt_pairs WHERE pair_id=pp.id) "
|
||||
f"FROM prompt_pairs pp WHERE pp.positive_prompt_id IN ({marks}) "
|
||||
"ORDER BY pp.id",
|
||||
chunk,
|
||||
):
|
||||
prints[row[0]].append(
|
||||
[
|
||||
v if isinstance(v, (int, str, type(None))) else repr(v)
|
||||
for v in tuple(row)[1:]
|
||||
]
|
||||
)
|
||||
return prints
|
||||
|
||||
@classmethod
|
||||
def _review_digests(cls, conn: sqlite3.Connection, prompt_ids: list[int]) -> dict:
|
||||
"""#482: one digest per deletable prompt of its row and its pairs."""
|
||||
prompts = cls._fingerprints(conn, prompt_ids)
|
||||
pairs = cls._pair_prints(conn, prompt_ids)
|
||||
return {
|
||||
pid: hashlib.sha256(
|
||||
json.dumps(
|
||||
[prompts.get(pid), pairs[pid]], separators=(",", ":")
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
for pid in prompt_ids
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _paired_counts(
|
||||
conn: sqlite3.Connection, prompt_ids: list[int], selected: list[int]
|
||||
) -> dict:
|
||||
"""#482: what Delete permanently would remove with ``prompt_ids``."""
|
||||
pairs = archived = variants = 0
|
||||
negatives = set()
|
||||
for chunk in _batches(prompt_ids):
|
||||
marks = ",".join("?" for _ in chunk)
|
||||
for archived_flag, negative, positive in conn.execute(
|
||||
"SELECT archived,negative_prompt_id,positive_prompt_id " # nosec
|
||||
f"FROM prompt_pairs WHERE positive_prompt_id IN ({marks})",
|
||||
chunk,
|
||||
):
|
||||
pairs += 1
|
||||
archived += 1 if archived_flag else 0
|
||||
if negative is not None:
|
||||
variants += 1
|
||||
if negative != positive:
|
||||
negatives.add(negative)
|
||||
return {
|
||||
"pairs": pairs,
|
||||
"archived_pairs": archived,
|
||||
"variants": variants,
|
||||
"kept_negatives": len(negatives - set(selected)),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _checked_ids(prompt_ids: list[int], expected_epoch: str) -> list[int]:
|
||||
_validate_epoch(expected_epoch)
|
||||
@@ -249,16 +156,11 @@ class LibraryArchiveOperations:
|
||||
with self.library_snapshot() as (conn, _epoch, _ceiling):
|
||||
epoch = self._library_epoch(conn, expected_epoch)
|
||||
classes = self._classify_library_delete(conn, prompt_ids)
|
||||
deletable = classes["deletable"]
|
||||
return {
|
||||
"library_epoch": epoch,
|
||||
**classes,
|
||||
"image_links": self._image_links(conn, classes["delete"]),
|
||||
"fingerprints": self._fingerprints(conn, classes["delete"]),
|
||||
# #482: Delete permanently, with the deletable prompts' pairs.
|
||||
"review_digests": self._review_digests(conn, deletable),
|
||||
"paired_counts": self._paired_counts(conn, deletable, prompt_ids),
|
||||
"paired_image_links": self._distinct_images(conn, deletable),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -289,7 +191,6 @@ class LibraryArchiveOperations:
|
||||
*,
|
||||
expected_epoch: str,
|
||||
candidates: dict | None,
|
||||
delete_paired: dict | None = None,
|
||||
) -> dict:
|
||||
"""Archive and delete what the user reviewed, all or nothing.
|
||||
|
||||
@@ -300,11 +201,6 @@ class LibraryArchiveOperations:
|
||||
still classifies ``delete`` and its fingerprint is unchanged; one that
|
||||
is now paired, and a reviewed archive now unpaired, are archived (the
|
||||
restorable direction); anything else is kept and counted.
|
||||
|
||||
``delete_paired`` (#482) maps reviewed archive ids the user chose to
|
||||
Delete permanently to their review digest. Each is deleted with its
|
||||
own saved pairs only while it is still deletable and its digest is
|
||||
unchanged; otherwise it is archived and counted ``changed``.
|
||||
"""
|
||||
_validate_epoch(expected_epoch)
|
||||
for ids in (archive_ids, delete_ids):
|
||||
@@ -313,16 +209,6 @@ class LibraryArchiveOperations:
|
||||
):
|
||||
raise ValueError("prompt ids must be lists of positive integers")
|
||||
reviewed_delete = set(delete_ids)
|
||||
delete_paired = {} if delete_paired is None else delete_paired
|
||||
if not isinstance(delete_paired, dict) or any(
|
||||
type(pid) is not int
|
||||
or not isinstance(digest, str)
|
||||
or not _DIGEST.match(digest)
|
||||
for pid, digest in delete_paired.items()
|
||||
):
|
||||
raise ValueError("delete_paired must map prompt ids to review digests")
|
||||
if not set(delete_paired) <= set(archive_ids):
|
||||
raise ValueError("A permanent delete must be a reviewed archive id")
|
||||
if not archive_ids and not delete_ids:
|
||||
raise ValueError("Nothing to delete or archive")
|
||||
if reviewed_delete & set(archive_ids):
|
||||
@@ -332,31 +218,16 @@ class LibraryArchiveOperations:
|
||||
epoch = self._library_epoch(conn, expected_epoch)
|
||||
classes = self._classify_library_delete(conn, prompt_ids)
|
||||
now_prints = self._fingerprints(conn, classes["delete"])
|
||||
deletes, archives, changed = [], [], 0
|
||||
paired_now = set(classes["deletable"])
|
||||
chosen = [pid for pid in classes["archive"] if pid in delete_paired]
|
||||
digests = self._review_digests(
|
||||
conn, [pid for pid in chosen if pid in paired_now]
|
||||
)
|
||||
paired_deletes = []
|
||||
for pid in classes["archive"]:
|
||||
if pid not in delete_paired:
|
||||
archives.append(pid)
|
||||
elif digests.get(pid) == delete_paired[pid]:
|
||||
paired_deletes.append(pid)
|
||||
else:
|
||||
archives.append(pid) # changed since the review: restorable
|
||||
changed += 1
|
||||
deletes, archives, changed = [], list(classes["archive"]), 0
|
||||
for pid in classes["delete"]:
|
||||
if pid not in reviewed_delete:
|
||||
archives.append(pid) # reviewed as archive, now unpaired
|
||||
changed += 1 if pid in delete_paired else 0
|
||||
elif candidates is None or candidates.get(pid) == now_prints[pid]:
|
||||
deletes.append(pid)
|
||||
else:
|
||||
changed += 1
|
||||
changed += len(classes["protected"])
|
||||
image_links = self._image_links(conn, [*deletes, *paired_deletes])
|
||||
image_links = self._image_links(conn, deletes)
|
||||
now = utc_now_iso()
|
||||
for pid in archives:
|
||||
self._repair_hash(conn, pid)
|
||||
@@ -369,25 +240,7 @@ class LibraryArchiveOperations:
|
||||
"UPDATE prompts SET updated_at=? WHERE id=?",
|
||||
[(now, pid) for pid in archives],
|
||||
)
|
||||
# #482: the image-pair links (RESTRICT) go first, then the pairs
|
||||
# (pair tags CASCADE, review decisions SET NULL), then the prompts.
|
||||
pairs_removed = 0
|
||||
for chunk in _batches(paired_deletes):
|
||||
marks = ",".join("?" for _ in chunk)
|
||||
pairs_of = (
|
||||
"SELECT id FROM prompt_pairs " # nosec B608
|
||||
f"WHERE positive_prompt_id IN ({marks})"
|
||||
)
|
||||
conn.execute(
|
||||
f"DELETE FROM image_prompt_pairs WHERE pair_id IN ({pairs_of})",
|
||||
chunk,
|
||||
)
|
||||
pairs_removed += conn.execute(
|
||||
"DELETE FROM prompt_pairs " # nosec B608
|
||||
f"WHERE positive_prompt_id IN ({marks})",
|
||||
chunk,
|
||||
).rowcount
|
||||
deleted = delete_prompt_rows(conn, [*deletes, *paired_deletes])
|
||||
deleted = delete_prompt_rows(conn, deletes)
|
||||
return {
|
||||
"library_epoch": epoch,
|
||||
"deleted": deleted,
|
||||
@@ -396,7 +249,6 @@ class LibraryArchiveOperations:
|
||||
"not_found": len(classes["not_found"]),
|
||||
"changed": changed,
|
||||
"image_links": image_links,
|
||||
"pairs_removed": pairs_removed,
|
||||
}
|
||||
|
||||
def restore_library_prompts(
|
||||
|
||||
+6
-35
@@ -65,9 +65,6 @@ TITLES = {
|
||||
"trash": "Delete images (move to the trash)",
|
||||
}
|
||||
MAX_DETAIL_ROWS = 50
|
||||
# The system moved the file but its place in a trash could not be shown (#499).
|
||||
TRASH_UNCONFIRMED = "trash-unconfirmed"
|
||||
RECORDS_FAILED_CODE = "records-failed"
|
||||
|
||||
# Reason codes (design §4.4) and the sentence each shows.
|
||||
REASONS = {
|
||||
@@ -86,17 +83,6 @@ REASONS = {
|
||||
trash.TRASH_IN_ROOT: "This drive's trash is inside an output folder.",
|
||||
trash.LOCKED: "The file is open in another program. Close it and try again.",
|
||||
trash.FAILED: "The file could not be moved to the trash.",
|
||||
trash.NO_PERMISSION: (
|
||||
"PromptManager isn't allowed to move this file (no permission, or it is "
|
||||
"locked in Finder)."
|
||||
),
|
||||
trash.CLOUD_SYNC: (
|
||||
"Files in iCloud Drive or a cloud-synced folder can't be moved to the "
|
||||
"trash here."
|
||||
),
|
||||
TRASH_UNCONFIRMED: (
|
||||
"Moved by macOS, but its place in the trash could not be confirmed."
|
||||
),
|
||||
trash.WINDOWS_RECYCLE: (
|
||||
"Moving files to the Recycle Bin isn't available on Windows: files larger "
|
||||
"than the bin's capacity would be deleted permanently."
|
||||
@@ -341,7 +327,7 @@ class ImageDeleteMixin:
|
||||
job.update(total=len(targets), status_line=TITLES[mode])
|
||||
roots = [str(root) for root in self._get_all_output_dirs()]
|
||||
done_group = "trashed" if mode == "trash" else "removed"
|
||||
changed, failed = 0, None
|
||||
changed, failed = 0, False
|
||||
for done, target in enumerate(targets, start=1):
|
||||
if failed:
|
||||
self._record_delete(job, "not_changed", target, "not-attempted")
|
||||
@@ -350,8 +336,7 @@ class ImageDeleteMixin:
|
||||
self._delete_one_image, db, target, mode, roots
|
||||
)
|
||||
if group == "errors":
|
||||
failed = code or RECORDS_FAILED_CODE
|
||||
text = REASONS[code] if code else RECORDS_FAILED
|
||||
failed = True
|
||||
job.record(
|
||||
"errors",
|
||||
{
|
||||
@@ -359,9 +344,9 @@ class ImageDeleteMixin:
|
||||
"root": target["root_label"],
|
||||
"root_index": target["root_index"],
|
||||
"root_scope": target["root_scope"],
|
||||
"detail": text,
|
||||
"detail": RECORDS_FAILED,
|
||||
},
|
||||
f"! {target['root_label']}/{target['rel']} · {text}",
|
||||
f"! {target['root_label']}/{target['rel']} · {RECORDS_FAILED}",
|
||||
)
|
||||
elif group == done_group:
|
||||
changed += 1
|
||||
@@ -380,10 +365,8 @@ class ImageDeleteMixin:
|
||||
job.log_line(line)
|
||||
job.update(progress=100, status_line=f"{line} · {backup_note(backup)}")
|
||||
if failed:
|
||||
# "records-failed" names every stop at an errors row (the page's
|
||||
# "Stopped after N of M" toast); the row says which error it was.
|
||||
job.scope["failure"] = RECORDS_FAILED_CODE
|
||||
job.publish({"type": "error", "code": failed, "message": line})
|
||||
job.scope["failure"] = "records-failed"
|
||||
job.publish({"type": "error", "code": "records-failed", "message": line})
|
||||
job.finish("failed")
|
||||
|
||||
@staticmethod
|
||||
@@ -422,19 +405,7 @@ class ImageDeleteMixin:
|
||||
label = trash.move_to_trash(target["abs"], roots)
|
||||
except trash.TrashRefused as refused:
|
||||
code = refused.code if refused.code in REASONS else trash.FAILED
|
||||
self.logger.info(
|
||||
"Not moved to the trash (%s): %s %s",
|
||||
code,
|
||||
target["rel"],
|
||||
refused.detail,
|
||||
)
|
||||
return "not_changed", code, None
|
||||
except trash.TrashUnconfirmed as unconfirmed:
|
||||
# The file left its folder: keep its records, stop the job.
|
||||
self.logger.warning(
|
||||
"Trash move not confirmed: %s %s", target["rel"], unconfirmed.detail
|
||||
)
|
||||
return "errors", TRASH_UNCONFIRMED, None
|
||||
mark = None
|
||||
else:
|
||||
mark = (target["size"], target["mtime_ns"])
|
||||
|
||||
+10
-44
@@ -47,8 +47,6 @@ RESTORE_URL = "/prompt_manager/library/restore"
|
||||
DELETE_TITLE = "Delete prompts (Library)"
|
||||
_SELECTION_KEYS = frozenset({"prompt_ids", "expected_epoch"})
|
||||
_DELETE_KEYS = frozenset({"archive_ids", "delete_ids", "expected_epoch", "backup"})
|
||||
# #482: Delete permanently, with its digests; both or neither (a #443 body).
|
||||
_PAIRED_KEYS = frozenset({"delete_paired_ids", "paired_digests"})
|
||||
STORAGE_CHANGED = "Prompt storage was restored; refresh the library"
|
||||
|
||||
|
||||
@@ -66,39 +64,19 @@ def _storage_changed(exc):
|
||||
return _failure(409, exc.code, STORAGE_CHANGED, library_epoch=exc.epoch)
|
||||
|
||||
|
||||
async def _body(request, keys, optional=frozenset()):
|
||||
"""The JSON object with exactly ``keys`` (plus all or none of ``optional``)."""
|
||||
async def _body(request, keys):
|
||||
"""The JSON object with exactly ``keys``, or raise ValueError."""
|
||||
try:
|
||||
data = await request.json()
|
||||
except ValueError:
|
||||
raise ValueError("Expected a JSON object") from None
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Expected a JSON object")
|
||||
extra = set(data) - keys
|
||||
if not keys <= set(data) or (extra and extra != optional):
|
||||
if set(data) != keys:
|
||||
raise ValueError("Request must contain exactly " + ", ".join(sorted(keys)))
|
||||
return data
|
||||
|
||||
|
||||
def _paired_delete(data, archive_ids):
|
||||
"""#482: the reviewed archive ids to delete with their pairs -> digest."""
|
||||
if "delete_paired_ids" not in data:
|
||||
return {}
|
||||
ids = _reviewed_ids(data["delete_paired_ids"], "delete_paired_ids")
|
||||
digests = data["paired_digests"]
|
||||
if not isinstance(digests, dict):
|
||||
raise ValueError("paired_digests must map prompt ids to digests")
|
||||
if not set(ids) <= set(archive_ids):
|
||||
raise ValueError("delete_paired_ids must be reviewed archive ids")
|
||||
chosen = {}
|
||||
for pid in ids:
|
||||
digest = digests.get(str(pid))
|
||||
if not isinstance(digest, str) or len(digest) != 64:
|
||||
raise ValueError("Every delete_paired_ids entry needs its review digest")
|
||||
chosen[pid] = digest
|
||||
return chosen
|
||||
|
||||
|
||||
def _reviewed_ids(value, name):
|
||||
"""A reviewed id list: possibly empty, else as ``parse_bulk_prompt_ids``."""
|
||||
if value == []:
|
||||
@@ -133,7 +111,7 @@ class LibraryDeleteRoutesMixin:
|
||||
expected_epoch=data["expected_epoch"],
|
||||
)
|
||||
disk = None
|
||||
if (review["delete"] or review["deletable"]) and policy == "on":
|
||||
if review["delete"] and policy == "on":
|
||||
public, short, _size = await self._run_in_executor(
|
||||
_maintenance.disk_plan,
|
||||
db.model.db_path,
|
||||
@@ -167,13 +145,6 @@ class LibraryDeleteRoutesMixin:
|
||||
"already_archived": len(review["already_archived"]),
|
||||
"not_found": len(review["not_found"]),
|
||||
"image_links": review["image_links"],
|
||||
# #482: the archive ids Delete permanently may delete with pairs.
|
||||
"deletable": review["deletable"],
|
||||
"review_digests": {
|
||||
str(pid): digest for pid, digest in review["review_digests"].items()
|
||||
},
|
||||
"paired_counts": review["paired_counts"],
|
||||
"paired_image_links": review["paired_image_links"],
|
||||
"backup": policy,
|
||||
"disk": disk,
|
||||
}
|
||||
@@ -181,7 +152,7 @@ class LibraryDeleteRoutesMixin:
|
||||
|
||||
async def delete_library_prompts(self, request):
|
||||
try:
|
||||
data = await _body(request, _DELETE_KEYS, _PAIRED_KEYS)
|
||||
data = await _body(request, _DELETE_KEYS)
|
||||
archive_ids = _reviewed_ids(data["archive_ids"], "archive_ids")
|
||||
delete_ids = _reviewed_ids(data["delete_ids"], "delete_ids")
|
||||
if not archive_ids and not delete_ids:
|
||||
@@ -192,7 +163,6 @@ class LibraryDeleteRoutesMixin:
|
||||
raise ValueError("A prompt cannot be both archived and deleted")
|
||||
if data["backup"] not in ("on", "off"):
|
||||
raise ValueError("backup must be on or off")
|
||||
paired = _paired_delete(data, archive_ids)
|
||||
except ValueError as exc:
|
||||
return _invalid(str(exc))
|
||||
busy = self._jobs.blocker(PROMPT_DELETE_JOB_KIND, admission="records")
|
||||
@@ -203,7 +173,7 @@ class LibraryDeleteRoutesMixin:
|
||||
PROMPT_DELETE_JOB_KIND, DELETE_TITLE, admission="records"
|
||||
) as job:
|
||||
response = await self._held_library_delete(
|
||||
self.db, data, archive_ids, delete_ids, paired
|
||||
self.db, data, archive_ids, delete_ids
|
||||
)
|
||||
if response.status >= 400:
|
||||
job.finish("failed")
|
||||
@@ -211,21 +181,18 @@ class LibraryDeleteRoutesMixin:
|
||||
except JobBusy as busy:
|
||||
return busy_response(busy)
|
||||
|
||||
async def _held_library_delete(self, db, data, archive_ids, delete_ids, paired):
|
||||
async def _held_library_delete(self, db, data, archive_ids, delete_ids):
|
||||
epoch = data["expected_epoch"]
|
||||
promised = data["backup"] == "on" or self._safety_backup_policy() == "on"
|
||||
backup = None
|
||||
try:
|
||||
candidates = None
|
||||
if delete_ids or paired:
|
||||
if delete_ids:
|
||||
review = await self._run_in_executor(
|
||||
db.classify_library_delete,
|
||||
[*delete_ids, *paired],
|
||||
expected_epoch=epoch,
|
||||
db.classify_library_delete, delete_ids, expected_epoch=epoch
|
||||
)
|
||||
candidates = review["fingerprints"]
|
||||
# #482: a permanent delete of a prompt in a saved pair is backed up too.
|
||||
if (candidates or review["deletable"]) and promised:
|
||||
if candidates and promised:
|
||||
_disk, short, _size = await self._run_in_executor(
|
||||
_maintenance.disk_plan,
|
||||
db.model.db_path,
|
||||
@@ -247,7 +214,6 @@ class LibraryDeleteRoutesMixin:
|
||||
delete_ids,
|
||||
expected_epoch=epoch,
|
||||
candidates=candidates if candidates is not None else {},
|
||||
delete_paired=paired,
|
||||
)
|
||||
except LibraryCommandError as exc:
|
||||
return _storage_changed(exc)
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
// #513: a worker records its browser's own log, so a browser that dies between two tests leaves its exit and stderr
|
||||
// behind instead of only "Target page, context or browser has been closed" on the next test.
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { isTargetGone } from '../../tools/vendor/browser-logs.mjs';
|
||||
|
||||
test('the worker records the launch of the browser its tests use', async ({ browser, browserLogs }) => {
|
||||
expect(browser.isConnected()).toBe(true);
|
||||
expect(browserLogs.launches().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('a killed browser leaves its signal and stderr in the worker log', async ({ playwright, browserName, browserLogs }) => {
|
||||
const before = browserLogs.launches();
|
||||
const victim = await playwright[browserName].launch();
|
||||
const pid = browserLogs.launches().find(item => !before.includes(item));
|
||||
expect(pid, 'the new browser\'s launch was recorded').toBeGreaterThan(0);
|
||||
const disconnected = new Promise(resolve => victim.once('disconnected', resolve));
|
||||
// Playwright starts a browser in a process group of its own; WebKit's launched pid is a wrapper script.
|
||||
process.kill(-pid, 'SIGKILL');
|
||||
await disconnected;
|
||||
const error = await victim.newContext().then(() => null, item => item);
|
||||
expect(error, 'a dead browser refuses a new context').not.toBeNull();
|
||||
expect(isTargetGone(error.message)).toBe(true);
|
||||
// The pipe can close before the launcher has logged the exit.
|
||||
await expect.poll(() => browserLogs.exits()).toContainEqual({ pid, exitCode: null, signal: 'SIGKILL' });
|
||||
expect(browserLogs.tail()).toContain(`[pid=${pid}] <process did exit: exitCode=null, signal=SIGKILL>`);
|
||||
});
|
||||
@@ -4,29 +4,13 @@
|
||||
// points at that worker's server. A server that dies fails the running test
|
||||
// with its stderr tail, and the next test in the worker gets a fresh server.
|
||||
// A test that fails gets the stderr its server wrote during it attached as
|
||||
// `server-stderr`. The worker also records its browser's own log (#513): a
|
||||
// browser that exits abnormally prints one `pm-browser-exit|` line, and a test
|
||||
// that fails on a closed or crashed target gets that log attached as
|
||||
// `browser-stderr`.
|
||||
// `server-stderr`.
|
||||
import { test as base, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import { attachServerStderr, createServerSupervisor, startBrowserServer } from '../../tools/vendor/start-browser-server.mjs';
|
||||
import { attachBrowserLogs, createBrowserLogRecorder } from '../../tools/vendor/browser-logs.mjs';
|
||||
|
||||
const SERVER_FIXTURE_TIMEOUT_MS = 90_000;
|
||||
|
||||
export const test = base.extend({
|
||||
browserLogs: [async ({}, use) => {
|
||||
const recorder = createBrowserLogRecorder();
|
||||
try {
|
||||
await use(recorder);
|
||||
} finally {
|
||||
recorder.uninstall();
|
||||
}
|
||||
}, { scope: 'worker', auto: true }],
|
||||
browserLogsOnFailure: [async ({ browserLogs }, use, testInfo) => {
|
||||
await use();
|
||||
await attachBrowserLogs(testInfo, browserLogs);
|
||||
}, { auto: true }],
|
||||
testServer: [async ({}, use) => {
|
||||
const supervisor = createServerSupervisor({ start: () => startBrowserServer() });
|
||||
try {
|
||||
|
||||
@@ -88,8 +88,7 @@ function mapDeleteConcept({ danger, review }) {
|
||||
return {
|
||||
...review,
|
||||
title: 'Delete or archive selected prompts',
|
||||
// #482: the paired prompt is deletable, so the review asks Archive or Delete permanently (a D90 radio row).
|
||||
tree: ['dl.meta-grid', 'div.field', 'p.small', 'p.small', 'div.dialog-actions'],
|
||||
tree: ['dl.meta-grid', 'p.small', 'p.small', 'div.dialog-actions'],
|
||||
grid: { ...review.grid, rows: [['Delete permanently', '1 prompt not in a saved pair'],
|
||||
['Archive', '1 prompt in a saved pair: hidden, restorable'],
|
||||
['Image links removed', '0 images will no longer show these prompts (files stay on disk)'],
|
||||
|
||||
@@ -26,15 +26,9 @@ export function inventoryOf(dialog) {
|
||||
};
|
||||
}
|
||||
// Documented differences only: prototype Save label, prototype demo note, data-dependent values, and D153 (#447): the
|
||||
// concept's "Rating" select is not drawn. D161 (#481): the concept's one danger "Archive…" becomes "Archive variant…" and
|
||||
// is followed by the prompt's own "Delete prompt…", drawn as the same danger button.
|
||||
function mapButton(item) {
|
||||
if(item.text==='Save in prototype') return [{...item,text:'Save'}];
|
||||
if(item.text==='Archive…') return [{...item,text:'Archive variant…'},{...item,text:'Delete prompt…'}];
|
||||
return [item];
|
||||
}
|
||||
// concept's "Rating" select is not drawn.
|
||||
export function mapConcept(reference) {
|
||||
return {...reference,demo:false,fields:reference.fields.filter(item=>item.label!=='Rating'),actions:{...reference.actions,buttons:reference.actions.buttons.flatMap(mapButton)}};
|
||||
return {...reference,demo:false,fields:reference.fields.filter(item=>item.label!=='Rating'),actions:{...reference.actions,buttons:reference.actions.buttons.map(item=>item.text==='Save in prototype'?{...item,text:'Save'}:item)}};
|
||||
}
|
||||
|
||||
// Flatten an inventory to path->value so a comparison reports exactly which leaves differ.
|
||||
|
||||
@@ -24,7 +24,7 @@ for(const [width,height,theme] of [[1600,1100,'dark'],[390,844,'light']])test(`e
|
||||
await variants(page).getByRole('button',{name:'Select negative variant 2',exact:true}).click();
|
||||
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();await expect(editor(page)).toBeVisible();
|
||||
await expect(editor(page).getByRole('button',{name:'Save',exact:true})).toBeEnabled();
|
||||
await editor(page).getByRole('button',{name:'Archive variant…',exact:true}).click();
|
||||
await editor(page).getByRole('button',{name:'Archive…',exact:true}).click();
|
||||
await expect(editor(page).getByRole('button',{name:'Archive',exact:true})).toBeEnabled();await shot('editor-archive');
|
||||
|
||||
let release;const held=new Promise(resolve=>{release=resolve;});
|
||||
|
||||
@@ -5,7 +5,6 @@ import { test, expect } from './fixtures.mjs';
|
||||
// page `#/collection/<id>` with its Edit (D137, concept render a1186–1189) and saved views on a collection (D140).
|
||||
const workspace = '/prompt_manager/workspace';
|
||||
const API = '/prompt_manager/collections';
|
||||
const RESTORE_APPLY_URL = '/prompt_manager/restore/apply';
|
||||
const main = page => page.locator('#workspace-main');
|
||||
const heading = page => main(page).getByRole('heading', { level: 1 });
|
||||
const breadcrumb = page => page.locator('#workspace-breadcrumb');
|
||||
@@ -306,12 +305,7 @@ test('a finished restore reads the collections again', async ({ page, request })
|
||||
const chooser = page.waitForEvent('filechooser');
|
||||
await page.getByRole('button', { name: 'Review restore…', exact: true }).click();
|
||||
await (await chooser).setFiles({ name: 'prompts_backup_20261027_093000.db', mimeType: 'application/octet-stream', buffer: bytes });
|
||||
// Wait for the restore job itself, as workspace-system-backups.spec.mjs does: under load a restore outlasts the
|
||||
// 5 s expect default, and a restore still running after this test replaces the next tests' database (#520).
|
||||
const applied = page.waitForResponse(response => response.url().endsWith(RESTORE_APPLY_URL));
|
||||
await page.locator('dialog#modal[open]').getByRole('button', { name: 'Restore', exact: true }).click();
|
||||
const { job } = await (await applied).json();
|
||||
await expect(await request.get(`/prompt_manager/jobs/${job.id}/events`)).toBeOK();
|
||||
await expect(toast(page)).toHaveText(/^Database restore finished · /);
|
||||
// The list was read again without a reload.
|
||||
await expect(rows(page)).toHaveCount(2);
|
||||
@@ -486,7 +480,7 @@ test('a rename that settles after its dialog closed leaves the next dialog open
|
||||
});
|
||||
|
||||
test('a page that says a collection or view could not be opened takes focus after a route change (review L3)', async ({ page, request }) => {
|
||||
const manifest = await seed(request);
|
||||
await seed(request);
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(heading(page)).toHaveText('Images');
|
||||
await page.evaluate(() => { window.location.hash = '#/collection/999999'; });
|
||||
@@ -505,8 +499,7 @@ test('a page that says a collection or view could not be opened takes focus afte
|
||||
await page.route('**/prompt_manager/saved-views', route => route.request().method() === 'GET'
|
||||
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"x"}' })
|
||||
: route.fallback());
|
||||
// An id the seed never made (its one view is manifest.saved_view), whatever ids earlier tests used (#520).
|
||||
await page.evaluate(id => { window.location.hash = `#/saved/${id}`; }, manifest.saved_view.id + 1000);
|
||||
await page.evaluate(() => { window.location.hash = '#/saved/5'; });
|
||||
await expect(description(page)).toHaveText('Saved views could not be loaded. Your saved data has not changed.');
|
||||
await expect(heading(page)).toBeFocused();
|
||||
});
|
||||
|
||||
@@ -13,8 +13,6 @@ const bar=page=>page.locator('#library-bulkbar > .bulkbar');
|
||||
const toast=page=>page.locator('.toast');
|
||||
const review=page=>page.getByRole('dialog');
|
||||
const showArchived=page=>page.locator('#workspace-main .heading-actions').getByRole('button',{name:/^Show archived prompts/});
|
||||
// #482: on the archived shelf the same button reads the concept's way back (app.js 594 "← Prompt library").
|
||||
const backToLibrary=page=>page.locator('#workspace-main .heading-actions').getByRole('button',{name:'← Prompt library',exact:true});
|
||||
|
||||
async function items(request,shelf='active') {
|
||||
const query=shelf==='archived'?'&shelf=archived':'';
|
||||
@@ -85,11 +83,9 @@ test('Show archived prompts lists archived prompts, and Restore on its bar retur
|
||||
await review(page).getByRole('button',{name:'Archive 2 prompts',exact:true}).click();
|
||||
await expect(toast(page)).toHaveText('Archived 2 prompts.');
|
||||
await expect(cards(page)).toHaveCount(before.length-2);
|
||||
// #482: a plain button that names where it goes (no pressed state that its changing label would contradict).
|
||||
await expect(showArchived(page)).not.toHaveAttribute('aria-pressed');
|
||||
await expect(showArchived(page)).toHaveAttribute('aria-pressed','false');
|
||||
await showArchived(page).click();
|
||||
await expect(backToLibrary(page)).toBeVisible();await expect(backToLibrary(page)).not.toHaveAttribute('aria-pressed');
|
||||
await expect(showArchived(page)).toHaveCount(0);
|
||||
await expect(showArchived(page)).toHaveAttribute('aria-pressed','true');
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
await expect(page.locator('.section-caption > span').first()).toHaveText(/^2 archived prompts · \d+ negative variants? hidden with them$/);
|
||||
await expect(card(page,cat.id).locator('.card-title .chip',{hasText:'Archived'})).toHaveCount(1);
|
||||
@@ -98,8 +94,7 @@ test('Show archived prompts lists archived prompts, and Restore on its bar retur
|
||||
await bar(page).getByRole('button',{name:'Restore',exact:true}).click();
|
||||
await expect(toast(page)).toHaveText('Restored 1 prompt to the Prompt library.');
|
||||
await expect(cards(page)).toHaveCount(1);
|
||||
await backToLibrary(page).click();
|
||||
await expect(showArchived(page)).toHaveText('Show archived prompts (1)');
|
||||
await showArchived(page).click();
|
||||
await expect(cards(page)).toHaveCount(before.length-1);
|
||||
await expect(card(page,cat.id)).toHaveCount(1);
|
||||
expect((await items(request,'archived')).map(item=>item.id)).toEqual([dual.id]);
|
||||
@@ -109,190 +104,6 @@ test('Show archived prompts lists archived prompts, and Restore on its bar retur
|
||||
await expect(page.locator('.library-status')).toHaveText('No archived prompts.');
|
||||
});
|
||||
|
||||
// #482 (D154): a prompt in a saved pair can be deleted permanently, with its own saved pairs; Archive stays the default.
|
||||
const choice=page=>review(page).getByRole('radiogroup',{name:'Prompts in a saved pair'});
|
||||
const row=(page,term)=>review(page).locator('dl.meta-grid > div').filter({has:page.locator('dt',{hasText:new RegExp(`^${term}$`)})}).locator('dd');
|
||||
test('Delete permanently removes a paired prompt with its own saved pair; its negative stays (#482)',async({page,request})=>{
|
||||
const before=await items(request);const dual=byText(before,'Dual role portrait'),cat=byText(before,'Cat portrait');
|
||||
const writes=recordPosts(page,DELETE);
|
||||
await page.goto(route);await expect(cards(page)).toHaveCount(before.length);
|
||||
await box(page,dual.id).check();await openReview(page);
|
||||
// Archive is checked: the review is #443's.
|
||||
await expect(choice(page).getByRole('radio',{name:'Archive (hidden, restorable)'})).toBeChecked();
|
||||
// The D90 radio row (the auto-tag "How to apply"): each radio keeps its own size beside its label.
|
||||
expect(await choice(page).evaluate(group=>{const label=group.querySelector('label'),radio=label.querySelector('input');
|
||||
return {group:group.className,display:getComputedStyle(label).display,gap:getComputedStyle(label).columnGap,
|
||||
margin:getComputedStyle(radio).marginLeft,full:radio.getBoundingClientRect().width>=label.getBoundingClientRect().width/2};}))
|
||||
.toEqual({group:'field',display:'flex',gap:'8px',margin:'0px',full:false});
|
||||
await expect(review(page).getByRole('button',{name:'Archive 1 prompt',exact:true})).toBeVisible();
|
||||
await choice(page).getByRole('radio',{name:'Delete permanently, with their saved pairs'}).check();
|
||||
await expect(review(page)).toHaveAccessibleName('Delete selected prompts');
|
||||
await expect(row(page,'Delete permanently')).toHaveText('1 prompt in a saved pair');
|
||||
await expect(row(page,'Saved pairs removed')).toHaveText('1 saved pair (1 with a negative prompt)');
|
||||
await expect(row(page,'Negatives kept')).toHaveText('1 negative prompt is kept');
|
||||
await expect(row(page,'Backup')).toHaveText('Create and verify before deleting');
|
||||
await expect(review(page).locator('p.small').first()).toHaveText(/^Deleting a prompt in a saved pair also removes its saved pairs/);
|
||||
// Back to Archive: the review is #443's again.
|
||||
await choice(page).getByRole('radio',{name:'Archive (hidden, restorable)'}).check();
|
||||
await expect(row(page,'Saved pairs removed')).toHaveCount(0);
|
||||
await expect(review(page).getByRole('button',{name:'Archive 1 prompt',exact:true})).toBeVisible();
|
||||
await choice(page).getByRole('radio',{name:'Delete permanently, with their saved pairs'}).check();
|
||||
await review(page).getByRole('button',{name:'Delete 1 prompt',exact:true}).click();
|
||||
await expect(toast(page)).toHaveText(/^Deleted 1 prompt\. Removed 1 saved pair\. Safety backup saved: /);
|
||||
await expect(card(page,dual.id)).toHaveCount(0);
|
||||
expect(writes).toHaveLength(1);
|
||||
expect(writes[0].delete_paired_ids).toEqual([dual.id]);
|
||||
expect(Object.keys(writes[0].paired_digests)).toEqual([String(dual.id)]);
|
||||
const after=await items(request);
|
||||
expect(byText(after,'Dual role portrait')).toBeUndefined();
|
||||
// Its negative (Cat portrait) is kept, with its own variants.
|
||||
expect(byText(after,'Cat portrait')?.id).toBe(cat.id);
|
||||
expect((await items(request,'archived')).map(item=>item.id)).toEqual([]);
|
||||
});
|
||||
|
||||
test('a prompt used as another prompt\'s negative is archived even when Delete permanently is chosen (#482)',async({page,request})=>{
|
||||
const before=await items(request);const dual=byText(before,'Dual role portrait'),cat=byText(before,'Cat portrait');
|
||||
await page.goto(route);await expect(cards(page)).toHaveCount(before.length);
|
||||
await box(page,cat.id).check();await box(page,dual.id).check();await openReview(page);
|
||||
await choice(page).getByRole('radio',{name:'Delete permanently, with their saved pairs'}).check();
|
||||
await expect(row(page,'Archive')).toHaveText('1 prompt used as another prompt\'s negative: hidden, restorable');
|
||||
await expect(review(page).locator('p.small',{hasText:'archived instead of deleted'})).toHaveCount(1);
|
||||
await review(page).getByRole('button',{name:'Delete 1 and archive 1 prompt',exact:true}).click();
|
||||
await expect(toast(page)).toHaveText(/^Deleted 1 prompt\. Removed 1 saved pair\. Archived 1 prompt\./);
|
||||
expect((await items(request,'archived')).map(item=>item.id)).toEqual([cat.id]);
|
||||
});
|
||||
|
||||
test('the choice resets to Archive on every Delete…, and a selection with no paired prompt has none (#482)',async({page,request})=>{
|
||||
const before=await items(request);const dual=byText(before,'Dual role portrait'),dog=byText(before,'Dog landscape');
|
||||
await page.goto(route);await box(page,dual.id).check();await openReview(page);
|
||||
await choice(page).getByRole('radio',{name:'Delete permanently, with their saved pairs'}).check();
|
||||
await review(page).getByRole('button',{name:'Cancel',exact:true}).click();
|
||||
await openReview(page);
|
||||
await expect(choice(page).getByRole('radio',{name:'Archive (hidden, restorable)'})).toBeChecked();
|
||||
await review(page).getByRole('button',{name:'Cancel',exact:true}).click();
|
||||
await bar(page).getByRole('button',{name:'Clear',exact:true}).click();
|
||||
await box(page,dog.id).check();await openReview(page);
|
||||
await expect(choice(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
// #481 (D161): a prompt's own page deletes it from Edit metadata (the concept's recipe editor, app.js 976), through the
|
||||
// same review: "Delete prompt…" beside "Archive variant…" (the variant's archive, renamed so the two cannot be confused).
|
||||
const editMetadata=page=>page.locator('#workspace-main .heading-actions').getByRole('button',{name:'Edit metadata',exact:true});
|
||||
const editor=page=>page.locator('dialog.library-editor[open]');
|
||||
async function deleteFromPage(page,id){
|
||||
await page.goto(`/prompt_manager/workspace#/library/${id}`);
|
||||
await editMetadata(page).click();
|
||||
await expect(editor(page).locator('.dialog-actions button')).toHaveText(['Create text revision','Archive variant…','Delete prompt…','Cancel','Save']);
|
||||
await expect(editor(page).getByRole('button',{name:'Delete prompt…',exact:true})).toHaveClass(/\bdanger\b/);
|
||||
await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click();
|
||||
await expect(editor(page)).toHaveCount(0);await expect(review(page)).toBeVisible();
|
||||
}
|
||||
test('Delete prompt… on a prompt\'s page archives it by default, and deletes it permanently when chosen (#481)',async({page,request})=>{
|
||||
const dual=byText(await items(request),'Dual role portrait');
|
||||
const previews=recordPosts(page,PREVIEW);
|
||||
await deleteFromPage(page,dual.id);
|
||||
expect(previews.map(body=>body.prompt_ids)).toEqual([[dual.id]]);
|
||||
await expect(choice(page).getByRole('radio',{name:'Archive (hidden, restorable)'})).toBeChecked();
|
||||
await review(page).getByRole('button',{name:'Archive 1 prompt',exact:true}).click();
|
||||
await expect(toast(page)).toHaveText('Archived 1 prompt.');
|
||||
// The page stays and shows its Archived notice.
|
||||
await expect(page.locator('.library-archived-notice h2')).toHaveText('Archived');
|
||||
await expect(page.locator('#workspace-main h1')).toBeFocused();
|
||||
await page.locator('.library-archived-notice').getByRole('button',{name:'Restore this prompt to the Prompt library'}).click();
|
||||
await expect(toast(page)).toHaveText('Restored 1 prompt to the Prompt library.');
|
||||
// Delete permanently: back to the Prompt library, the prompt gone.
|
||||
await editMetadata(page).click();await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click();
|
||||
await choice(page).getByRole('radio',{name:'Delete permanently, with their saved pairs'}).check();
|
||||
await review(page).getByRole('button',{name:'Delete 1 prompt',exact:true}).click();
|
||||
await expect(toast(page)).toHaveText(/^Deleted 1 prompt\. Removed 1 saved pair\. Safety backup saved: /);
|
||||
await expect(page).toHaveURL(/#\/library$/);
|
||||
await expect(card(page,dual.id)).toHaveCount(0);
|
||||
expect(byText(await items(request),'Dual role portrait')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('Cancel on the page\'s review changes nothing and returns focus to Edit metadata (#481)',async({page,request})=>{
|
||||
const dual=byText(await items(request),'Dual role portrait');
|
||||
const writes=recordPosts(page,DELETE);
|
||||
await deleteFromPage(page,dual.id);
|
||||
await review(page).getByRole('button',{name:'Cancel',exact:true}).click();
|
||||
await expect(review(page)).toHaveCount(0);await expect(editMetadata(page)).toBeFocused();
|
||||
expect(writes).toEqual([]);
|
||||
expect(byText(await items(request),'Dual role portrait')?.id).toBe(dual.id);
|
||||
});
|
||||
|
||||
test('the page\'s delete holds one review at a time, Escape returns focus, and an archived page\'s review is close-only (#481)',async({page,request})=>{
|
||||
const dual=byText(await items(request),'Dual role portrait');
|
||||
const previews=recordPosts(page,PREVIEW);
|
||||
let release;const held=new Promise(resolve=>{release=resolve;});
|
||||
await page.route(`**${PREVIEW}`,async item=>{await held;await item.fallback();});
|
||||
await page.goto(`/prompt_manager/workspace#/library/${dual.id}`);
|
||||
await editMetadata(page).click();await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click();
|
||||
// While the preview is held, Edit metadata is held too: no second review and no save can race the delete.
|
||||
await expect(editMetadata(page)).toHaveAttribute('aria-disabled','true');
|
||||
await editMetadata(page).click({force:true});await expect(editor(page)).toHaveCount(0);
|
||||
release();
|
||||
await expect(review(page)).toBeVisible();
|
||||
expect(previews).toHaveLength(1);
|
||||
expect(previews[0].expected_epoch).toMatch(/\S/);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(review(page)).toHaveCount(0);await expect(editMetadata(page)).toBeFocused();
|
||||
await expect(editMetadata(page)).not.toHaveAttribute('aria-disabled');
|
||||
await page.unroute(`**${PREVIEW}`);
|
||||
// Archived: its review has nothing to change and only closes.
|
||||
await editMetadata(page).click();await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click();
|
||||
await review(page).getByRole('button',{name:'Archive 1 prompt',exact:true}).click();
|
||||
await expect(page.locator('.library-archived-notice')).toBeVisible();
|
||||
await editMetadata(page).click();await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click();
|
||||
await expect(review(page).locator('p.small').last()).toHaveText('None of the selected prompts can be deleted or archived.');
|
||||
await expect(review(page).getByRole('button',{name:/^(Delete|Archive)/})).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('a failed review on the prompt\'s page says so, changes nothing and releases Edit metadata (#481)',async({page,request})=>{
|
||||
const dual=byText(await items(request),'Dual role portrait');
|
||||
const writes=recordPosts(page,DELETE);
|
||||
await page.route(`**${PREVIEW}`,item=>item.fulfill({status:500,contentType:'application/json',body:JSON.stringify({success:false,code:'review-failed',error:'x'})}),{times:1});
|
||||
await page.goto(`/prompt_manager/workspace#/library/${dual.id}`);
|
||||
await editMetadata(page).click();await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click();
|
||||
await expect(toast(page)).toHaveText('The selected prompts could not be reviewed. Try again.');
|
||||
await expect(review(page)).toHaveCount(0);await expect(editMetadata(page)).not.toHaveAttribute('aria-disabled');
|
||||
expect(writes).toEqual([]);
|
||||
await editMetadata(page).click();await expect(editor(page)).toBeVisible();
|
||||
});
|
||||
|
||||
test('a delete answered after the page was left invalidates the shelf without a toast or a jump (#481)',async({page,request})=>{
|
||||
const dual=byText(await items(request),'Dual role portrait');
|
||||
let release;const held=new Promise(resolve=>{release=resolve;});
|
||||
let answered;const done=new Promise(resolve=>{answered=resolve;});
|
||||
await page.route(`**${DELETE}`,async item=>{await held;const response=await item.fetch();await item.fulfill({response});answered();});
|
||||
await deleteFromPage(page,dual.id);
|
||||
await review(page).getByRole('button',{name:'Archive 1 prompt',exact:true}).click();
|
||||
await page.evaluate(()=>{window.location.hash='#/overview';});
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
// Every toast text shown from here on is recorded, so one shown and hidden again still counts.
|
||||
await page.evaluate(()=>{window.seenToasts=[];new MutationObserver(()=>{for(const node of document.querySelectorAll('.toast'))
|
||||
if(node.textContent.trim())window.seenToasts.push(node.textContent.trim());}).observe(document.body,{subtree:true,childList:true,characterData:true});});
|
||||
release();await done;
|
||||
// The answer lands on Overview: no navigation; the shelf reads again when opened; and no toast was ever shown.
|
||||
await page.evaluate(()=>{window.location.hash='#/library';});
|
||||
await expect(card(page,dual.id)).toHaveCount(0);
|
||||
expect((await items(request,'archived')).map(item=>item.id)).toEqual([dual.id]);
|
||||
expect(await page.evaluate(()=>window.seenToasts)).toEqual([]);
|
||||
});
|
||||
|
||||
test('a restore since the prompt\'s page loaded is refused and the page reads the Library again (#481)',async({page,request})=>{
|
||||
const dual=byText(await items(request),'Dual role portrait');
|
||||
await armUnseenRestores(page);
|
||||
await page.goto(`/prompt_manager/workspace#/library/${dual.id}`);
|
||||
await editMetadata(page).click();await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click();
|
||||
const backup=await request.get('/prompt_manager/backup');await expect(backup).toBeOK();const buffer=await backup.body();
|
||||
const restore=await unseenRestore(page,()=>request.post('/prompt_manager/restore',{multipart:{database_file:{name:'library.db',mimeType:'application/octet-stream',buffer}}}));
|
||||
await expect(restore).toBeOK();
|
||||
await review(page).getByRole('button',{name:'Archive 1 prompt',exact:true}).click();
|
||||
await expect(toast(page)).toHaveText('The library was restored or moved since this page loaded. Nothing was deleted or archived.');
|
||||
await expect(page.locator('#workspace-main h1')).toBeFocused();
|
||||
expect((await items(request,'archived')).map(item=>item.id)).toEqual([]);
|
||||
});
|
||||
|
||||
test('an archived prompt\'s page says so and restores it from its notice',async({page,request})=>{
|
||||
const cat=byText(await items(request),'Cat portrait');
|
||||
const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch;
|
||||
|
||||
@@ -8,7 +8,7 @@ import {inventoryOf,mapConcept,diffInventory} from './library-editor-canonical-c
|
||||
// Dialog-level contracts mount the editor through a page import; the user flows below use the real entry actions.
|
||||
test.use({locale:'en-US',timezoneId:'UTC',reducedMotion:'reduce'});
|
||||
const workspace='/prompt_manager/workspace';
|
||||
const ACTIONS={new:['Cancel','Save'],metadata:['Create text revision','Archive variant…','Delete prompt…','Cancel','Save'],revision:['Cancel','Save'],negative:['Cancel','Save']};
|
||||
const ACTIONS={new:['Cancel','Save'],metadata:['Create text revision','Archive…','Cancel','Save'],revision:['Cancel','Save'],negative:['Cancel','Save']};
|
||||
// D153 (#447): no Rating field; the fixture's positive stays stored with rating 4 through every workspace save.
|
||||
const LABELS=['Title','Positive prompt','Negative prompt (optional)','Category','Tags, separated by commas','Notes'];
|
||||
const UNAPPLIED='Saved as a new negative variant of an existing prompt. The title, category, tags and notes you entered were not applied.';
|
||||
@@ -27,15 +27,13 @@ async function mountEditor(page,theme) {
|
||||
if(theme)await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
await page.evaluate(async()=>{
|
||||
const trigger=document.createElement('button');trigger.type='button';trigger.id='editor-trigger';trigger.textContent='Open editor';document.body.append(trigger);
|
||||
window.editorEvents={committed:[],reconciled:[],deletePrompt:[]};
|
||||
window.editorEvents={committed:[],reconciled:[]};
|
||||
try {
|
||||
const {createLibraryEditor,showWorkspaceToast}=await import('/prompt_manager/js/workspace/library-editor.mjs');
|
||||
const {createScope}=await import('/prompt_manager/js/shared/toolkit/core.js');
|
||||
const scope=createScope();
|
||||
const editor=createLibraryEditor({api:window.ApiClient.createApiClient(),parentScope:scope,
|
||||
onCommitted:commit=>window.editorEvents.committed.push(commit),onReconciled:result=>window.editorEvents.reconciled.push(result),
|
||||
// As the prompt's page wires it (#481); the review itself is covered in workspace-library-delete.spec.mjs.
|
||||
onDeletePrompt:request=>window.editorEvents.deletePrompt.push(request.positiveId)});
|
||||
onCommitted:commit=>window.editorEvents.committed.push(commit),onReconciled:result=>window.editorEvents.reconciled.push(result)});
|
||||
window.editorHarness={scope,editor,showWorkspaceToast};
|
||||
} catch(error) {window.editorHarness={missing:String(error)};}
|
||||
});
|
||||
@@ -54,7 +52,7 @@ const pairRead=async(request,id)=>(await(await request.get(`/prompt_manager/pair
|
||||
|
||||
test('edit recipe metadata shows the concept fields with the selected saved negative read-only',async({page})=>{
|
||||
const f=page.fixture;await mountEditor(page);
|
||||
let dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});
|
||||
const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});
|
||||
await expect(dialog.getByRole('heading',{name:'Edit recipe metadata',exact:true})).toBeVisible();
|
||||
expect(await dialog.evaluate(node=>node.matches(':modal'))).toBe(true);
|
||||
await expect(button(dialog,'Close dialog')).toBeFocused();await ready(dialog);
|
||||
@@ -75,14 +73,9 @@ test('edit recipe metadata shows the concept fields with the selected saved nega
|
||||
await expect(dialog.locator('[placeholder]')).toHaveCount(0);
|
||||
await expect(dialog.locator('.demo-note')).toHaveCount(0);
|
||||
expect(await actionNames(dialog)).toEqual(ACTIONS.metadata);
|
||||
await expect(button(dialog,'Archive variant…')).toHaveClass(/\bdanger\b/);await expect(button(dialog,'Save')).toHaveClass(/\bca-primary\b/);
|
||||
await expect(button(dialog,'Archive…')).toHaveClass(/\bdanger\b/);await expect(button(dialog,'Save')).toHaveClass(/\bca-primary\b/);
|
||||
await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0);
|
||||
await expect(page.locator('#editor-trigger')).toBeFocused();expect(page.writes).toHaveLength(0);
|
||||
// D161 (#481): Delete prompt… closes the editor first and hands the prompt to the page's delete review.
|
||||
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
|
||||
await expect(button(dialog,'Delete prompt…')).toHaveClass(/\bdanger\b/);await button(dialog,'Delete prompt…').click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
expect(await page.evaluate(()=>window.editorEvents.deletePrompt)).toEqual([f.positive_id]);expect(page.writes).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('editor modes persist through reload with selected saved negative',async({page,request})=>{
|
||||
@@ -151,13 +144,13 @@ test('editor modes persist through reload with selected saved negative',async({p
|
||||
test('archive confirms the selected pair with fresh counts and writes only on confirm',async({page,request})=>{
|
||||
const f=page.fixture;await mountEditor(page);
|
||||
let dialog=await openEditor(page,{mode:'metadata',positiveId:f.standalone_id,pairId:null});await ready(dialog);
|
||||
const archive=button(dialog,'Archive variant…');await expect(archive).toBeDisabled();
|
||||
const archive=button(dialog,'Archive…');await expect(archive).toBeDisabled();
|
||||
await expect(archive).toHaveAccessibleDescription('Archive needs a selected saved negative variant.');
|
||||
await expect(note(dialog)).toHaveText('Archive needs a selected saved negative variant.');
|
||||
await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0);await expect(page.locator('#editor-trigger')).toBeFocused();
|
||||
|
||||
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
|
||||
await button(dialog,'Archive variant…').click();
|
||||
await button(dialog,'Archive…').click();
|
||||
await expect(dialog.getByRole('heading',{name:'Archive negative variant',exact:true})).toBeVisible();
|
||||
await expect(button(dialog,'Close dialog')).toBeFocused();
|
||||
const block=dialog.getByRole('region',{name:'Negative prompt',exact:true});
|
||||
@@ -170,7 +163,7 @@ test('archive confirms the selected pair with fresh counts and writes only on co
|
||||
expect((await pairRead(request,f.pair_id)).archived).toBe(false);
|
||||
|
||||
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
|
||||
await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled();
|
||||
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();
|
||||
await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
|
||||
expect(page.writes).toEqual([{method:'PATCH',url:`/prompt_manager/pairs/${f.pair_id}`,body:{archived:true}}]);
|
||||
expect((await pairRead(request,f.pair_id)).archived).toBe(true);
|
||||
@@ -185,7 +178,7 @@ test('a failed archive keeps focus on the Archive confirmation inside the dialog
|
||||
failed=true;return route.fulfill({status:400,contentType:'application/json',body:'{"success":false,"error":"Cannot archive.","code":null}'});
|
||||
});
|
||||
const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
|
||||
await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled();
|
||||
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();
|
||||
await button(dialog,'Archive').click();
|
||||
await expect(note(dialog)).toContainText('Cannot archive.');expect(page.writes).toHaveLength(1);
|
||||
await expect(button(dialog,'Archive')).toBeFocused();
|
||||
@@ -199,7 +192,7 @@ test('an unconfirmed archive moves focus to Retry instead of dropping it out of
|
||||
await route.continue();
|
||||
});
|
||||
const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
|
||||
await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled();
|
||||
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();
|
||||
await button(dialog,'Archive').click();
|
||||
await expect(note(dialog)).toHaveText('Archive could not be confirmed. Retry sends the same archive once.');
|
||||
await expect(button(dialog,'Archive')).toBeDisabled();await expect(button(dialog,'Retry')).toBeFocused();
|
||||
@@ -216,7 +209,7 @@ test('an archive conflict offers an explicit Reload that re-reads and never repl
|
||||
conflicted=true;return route.fulfill({status:409,contentType:'application/json',body:'{"success":false,"error":"changed","code":"metadata-conflict"}'});
|
||||
});
|
||||
const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
|
||||
await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled();
|
||||
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();
|
||||
await button(dialog,'Archive').click();
|
||||
await expect(note(dialog)).toHaveText('This negative variant changed since it was loaded. Reload it and review it before archiving.');
|
||||
await expect(button(dialog,'Archive')).toBeDisabled();expect(page.writes).toHaveLength(1);
|
||||
@@ -244,7 +237,7 @@ test('cancel and busy submit have honest write counts',async({page})=>{
|
||||
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
|
||||
await field(dialog,'Notes').fill('busy save');await button(dialog,'Save').click();
|
||||
await expect(note(dialog)).toHaveText('Saving… The dialog stays open until the server answers.');
|
||||
for(const name of ['Save','Cancel','Close dialog','Create text revision','Archive variant…','Delete prompt…'])await expect(button(dialog,name)).toBeDisabled();
|
||||
for(const name of ['Save','Cancel','Close dialog','Create text revision','Archive…'])await expect(button(dialog,name)).toBeDisabled();
|
||||
await field(dialog,'Title').press('Enter');await page.keyboard.press('Escape');
|
||||
await expect(dialog).toBeVisible();expect(page.writes).toHaveLength(1);
|
||||
release();await expect(dialog).toHaveCount(0);expect(page.writes).toHaveLength(1);
|
||||
@@ -326,15 +319,15 @@ test('edge states keep drafts, explain disabled archive and clean up on dispose'
|
||||
|
||||
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.archived_pair_id});await ready(dialog);
|
||||
await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(f.archived_negative);
|
||||
await expect(button(dialog,'Archive variant…')).toBeDisabled();await expect(note(dialog)).toHaveText('This negative variant is already archived.');
|
||||
await expect(button(dialog,'Archive variant…')).toHaveAccessibleDescription('This negative variant is already archived.');
|
||||
await expect(button(dialog,'Archive…')).toBeDisabled();await expect(note(dialog)).toHaveText('This negative variant is already archived.');
|
||||
await expect(button(dialog,'Archive…')).toHaveAccessibleDescription('This negative variant is already archived.');
|
||||
await button(dialog,'Cancel').click();
|
||||
|
||||
let failed=true;
|
||||
await page.route(`**/prompt_manager/pairs/${f.empty_pair_id}`,route=>{if(!failed&&route.request().method()==='GET'){failed=true;return route.fulfill({status:500,contentType:'application/json',body:'{"success":false,"error":"down"}'});}return route.continue();});
|
||||
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.empty_pair_id});await ready(dialog);
|
||||
await expect(field(dialog,'Negative prompt (optional)')).toHaveValue('');
|
||||
failed=false;await button(dialog,'Archive variant…').click();
|
||||
failed=false;await button(dialog,'Archive…').click();
|
||||
await expect(note(dialog)).toHaveText('Linked images and recorded runs could not be read. Nothing was archived.');
|
||||
await expect(button(dialog,'Archive')).toBeDisabled();
|
||||
await expect(dialog.getByRole('region',{name:'Negative prompt',exact:true})).toContainText('Empty negative prompt');
|
||||
@@ -354,12 +347,12 @@ test('the archive confirmation does not inherit a failed or invalid Edit save me
|
||||
let dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
|
||||
await field(dialog,'Notes').fill('Rejected notes');await button(dialog,'Save').click();
|
||||
await expect(note(dialog)).toHaveText('Not saved: Notes are too long. Your entries are kept.');
|
||||
await button(dialog,'Archive variant…').click();await expect(dialog).toContainText('1 image and 2 recorded runs stay linked.');
|
||||
await button(dialog,'Archive…').click();await expect(dialog).toContainText('1 image and 2 recorded runs stay linked.');
|
||||
await expect(note(dialog)).toBeHidden();await expect(button(dialog,'Archive')).toBeEnabled();
|
||||
await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0);
|
||||
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
|
||||
await field(dialog,'Title').fill(' ');await button(dialog,'Save').click();await expect(note(dialog)).toHaveText('Title is required');
|
||||
await button(dialog,'Archive variant…').click();await expect(dialog).toContainText('1 image and 2 recorded runs stay linked.');
|
||||
await button(dialog,'Archive…').click();await expect(dialog).toContainText('1 image and 2 recorded runs stay linked.');
|
||||
await expect(note(dialog)).toBeHidden();await expect(button(dialog,'Archive')).toBeEnabled();
|
||||
expect(page.writes).toHaveLength(1);
|
||||
});
|
||||
@@ -500,10 +493,9 @@ test('a restore seen finishing keeps New prompt open and reloads the Library aft
|
||||
test('library heading offers Export then + New prompt, and New opens the saved prompt history',async({page,request})=>{
|
||||
await page.goto(`${workspace}#/library`);await expect(page.locator('article[data-prompt-id]').first()).toBeVisible();
|
||||
const actions=headingActions(page).locator('button');
|
||||
// D152 (#443): Show archived prompts (N) comes first, a plain button (#482: no pressed state; on the archived shelf it
|
||||
// reads "← Prompt library").
|
||||
// D152 (#443): Show archived prompts (N) comes first, a plain toggle.
|
||||
expect(await visibleNames(actions)).toEqual(['Show archived prompts (0)','Export','+ New prompt']);
|
||||
await expect(actions.nth(0)).not.toHaveAttribute('aria-pressed');await expect(actions.nth(0)).not.toHaveClass(/ca-primary/);
|
||||
await expect(actions.nth(0)).toHaveAttribute('aria-pressed','false');await expect(actions.nth(0)).not.toHaveClass(/ca-primary/);
|
||||
// One image is counted in the singular.
|
||||
const f=page.fixture;await expect(page.locator(`article[data-prompt-id="${f.positive_id}"]`).getByText('1 image',{exact:true})).toHaveCount(1);
|
||||
await expect(actions.nth(1)).toHaveAccessibleName('Export full Library JSON');await expect(actions.nth(1)).not.toHaveClass(/ca-primary/);
|
||||
@@ -649,14 +641,14 @@ test('restored storage after a confirmed save or before an archive offers an exp
|
||||
const saved=page.waitForResponse(item=>item.request().method()==='PATCH'&&new URL(item.url()).pathname===`/prompt_manager/library/${f.positive_id}`);
|
||||
await button(dialog,'Save').click();await saved;
|
||||
await expect(note(dialog)).toHaveText('Saved; Library refresh failed because prompt storage was restored. Reload the Library to see current data.');
|
||||
expect(await actionNames(dialog)).toEqual(['Reload Library','Create text revision','Archive variant…','Delete prompt…','Cancel','Save']);
|
||||
for(const name of ['Create text revision','Archive variant…','Delete prompt…'])await expect(button(dialog,name)).toBeDisabled();
|
||||
expect(await actionNames(dialog)).toEqual(['Reload Library','Create text revision','Archive…','Cancel','Save']);
|
||||
for(const name of ['Create text revision','Archive…'])await expect(button(dialog,name)).toBeDisabled();
|
||||
await button(dialog,'Reload Library').click();await expect(dialog).toHaveCount(0);
|
||||
await expect(variantsPanel(page)).toBeVisible();await expect(page.locator('#workspace-main h1')).toHaveText(f.metadata.title);
|
||||
await page.unroute(`**/prompt_manager/library/${f.positive_id}`);expect(page.writes).toHaveLength(1);
|
||||
await selectPair(page,'Select negative variant 2');
|
||||
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
|
||||
await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled();
|
||||
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();
|
||||
await expect(await restore()).toBeOK();await button(dialog,'Archive').click();
|
||||
await expect(note(dialog)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was archived.');
|
||||
expect(await actionNames(dialog)).toEqual(['Reload Library','Cancel','Archive']);expect(page.writes).toHaveLength(1);
|
||||
@@ -673,10 +665,10 @@ test('restored storage before an Edit or Add negative save offers only the Libra
|
||||
await expect(await restore()).toBeOK();
|
||||
await field(dialog,'Notes').fill('Typed before the restore');await button(dialog,'Save').click();
|
||||
await expect(note(dialog)).toHaveText(`Prompt storage was restored since this dialog opened. Nothing was saved. ${DISCARDS}`);
|
||||
expect(await actionNames(dialog)).toEqual(['Reload Library','Create text revision','Archive variant…','Delete prompt…','Cancel','Save']);
|
||||
expect(await actionNames(dialog)).toEqual(['Reload Library','Create text revision','Archive…','Cancel','Save']);
|
||||
await expect(button(dialog,'Reload Library state')).toHaveCount(0);await expect(button(dialog,'Save')).toBeDisabled();
|
||||
// Revise and archive would re-read the old numeric IDs, which may now name another prompt.
|
||||
for(const name of ['Create text revision','Archive variant…','Delete prompt…'])await expect(button(dialog,name)).toBeDisabled();
|
||||
for(const name of ['Create text revision','Archive…'])await expect(button(dialog,name)).toBeDisabled();
|
||||
await expect(button(dialog,'Reload Library')).toBeFocused();
|
||||
expect(page.writes).toHaveLength(1);// the refused save
|
||||
await button(dialog,'Reload Library').click();await expect(dialog).toHaveCount(0);await expect(variantsPanel(page)).toBeVisible();
|
||||
@@ -743,7 +735,7 @@ test('archive hides a pair, Show archived finds it and Restore keeps it selected
|
||||
const before=await evidence();expect(before.counts).toEqual({images:1,runs:2});
|
||||
await openHistory(page,f.positive_id);await selectPair(page,'Select negative variant 2');
|
||||
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();const dialog=editorDialog(page);await ready(dialog);
|
||||
await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
|
||||
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
|
||||
await expect(toast(page)).toHaveText('Archived. Find it under Show archived in Negative variants.');await expect(page.locator('#workspace-main h1')).toBeFocused();
|
||||
await expect(variantsPanel(page).locator('.variant-lane')).toHaveCount(2);await expect(pressedVariant(page)).toHaveCount(1);
|
||||
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true})).not.toContainText(f.selected_negative);
|
||||
@@ -886,7 +878,7 @@ test('turning Show archived on again after a hidden archive shows the newly arch
|
||||
await toggle().click();await expect(toggle()).toHaveAttribute('aria-pressed','false');await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(0);
|
||||
await selectPair(page,'Select negative variant 2');
|
||||
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();const dialog=editorDialog(page);await ready(dialog);
|
||||
await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
|
||||
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
|
||||
await expect(toggle()).toHaveText('Show archived (2)');await toggle().click();
|
||||
await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(2);
|
||||
await expect(laneLabel(page,'· 2 runs · Archived')).toHaveCount(1);await uniqueDotNames(page);
|
||||
|
||||
@@ -221,33 +221,6 @@ test('tag image from review saves tags and the item remains pending', async ({ p
|
||||
await expect(dialog).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('#515: tags typed before the image observation answers are saved', async ({ page, request }) => {
|
||||
await seed(request, 1);
|
||||
await open(page);
|
||||
// Hold the dialog's observation so the typing lands while it is pending (a slow host or WebKit in CI).
|
||||
let release;
|
||||
const held = new Promise(resolve => { release = resolve; });
|
||||
const matcher = '**/prompt_manager/image-annotations/observe';
|
||||
let reachedObserve;
|
||||
const reached = new Promise(resolve => { reachedObserve = resolve; });
|
||||
await page.route(matcher, async route => { reachedObserve(); await held; await route.continue(); });
|
||||
await panel(page).getByRole('button', { name: 'Tag image', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Tag image', exact: true });
|
||||
await reached;
|
||||
const field = dialog.getByRole('textbox', { name: 'Image tags · edit manually', exact: true });
|
||||
await field.fill('dunes');
|
||||
const save = dialog.getByRole('button', { name: 'Save manual tags', exact: true });
|
||||
await expect(save).toHaveAttribute('aria-disabled', 'true');
|
||||
const answered = page.waitForResponse(item => item.url().endsWith('/image-annotations/observe'));
|
||||
release();
|
||||
await answered;
|
||||
await expect(save).not.toHaveAttribute('aria-disabled', 'true');
|
||||
await expect(field).toHaveValue('dunes');
|
||||
await save.click();
|
||||
await expect(toast(page)).toHaveText('1 tags saved for review_0000.png. Prompt association still awaits review.');
|
||||
await page.unroute(matcher);
|
||||
});
|
||||
|
||||
test('an empty queue shows the empty notice only', async ({ page, request }) => {
|
||||
await seed(request, 0);
|
||||
await page.goto(review);
|
||||
|
||||
@@ -876,63 +876,3 @@ test('retry after a changed storage, file or expired capture observes again and
|
||||
await settle();
|
||||
assert.equal(c.state.manual, 'Minimal, warm_light');
|
||||
});
|
||||
|
||||
test('#515: text typed while the first observation is pending is kept, after the saved tags', async () => {
|
||||
const { createImageTaggingController } = await load();
|
||||
const answers = [deferred(), deferred()];
|
||||
let n = 0;
|
||||
const h = harness(defaultRoutes({ [`POST ${OBSERVE}`]: () => answers[n++].promise }));
|
||||
// An untagged file: the typed text is the whole draft and Save sends it.
|
||||
const bare = createImageTaggingController({ api: h.api, target: TARGET, source: 'review' });
|
||||
const first = bare.open();
|
||||
bare.setManual('dunes');
|
||||
answers[0].resolve(response(200, observed({ tags: [], tag_revision: 0 })));
|
||||
await first;
|
||||
await settle();
|
||||
assert.equal(bare.state.phase, 'ready');
|
||||
assert.equal(bare.state.manual, 'dunes');
|
||||
assert.equal(bare.state.statusText, '', 'nothing changed elsewhere');
|
||||
await bare.save();
|
||||
await settle();
|
||||
assert.deepEqual(h.of('PUT', TAGS).at(-1).body.tags, ['dunes']);
|
||||
// A tagged file: its saved tags lead, so a Save never drops tags the user had not seen yet.
|
||||
const tagged = createImageTaggingController({ api: h.api, target: TARGET });
|
||||
const second = tagged.open();
|
||||
tagged.setManual('dunes, minimal');
|
||||
answers[1].resolve(response(200, observed()));
|
||||
await second;
|
||||
await settle();
|
||||
assert.equal(tagged.state.manual, 'Minimal, warm_light, dunes');
|
||||
assert.equal(tagged.state.statusText, '');
|
||||
});
|
||||
|
||||
test('#515: a kept draft takes the text typed while the reopen observes', async () => {
|
||||
const { c } = await opened({ [`PUT ${TAGS}`]: () => fail(409, 'file-changed', 'The file changed.') });
|
||||
c.setManual('kept draft');
|
||||
await c.save();
|
||||
await settle();
|
||||
const reopened = c.retry();
|
||||
c.setManual('kept draft, later');
|
||||
await reopened;
|
||||
await settle();
|
||||
assert.equal(c.state.phase, 'ready');
|
||||
assert.equal(c.state.manual, 'kept draft, later');
|
||||
});
|
||||
|
||||
test('#515: text typed before Retry after a failed first observation follows the saved tags', async () => {
|
||||
const { createImageTaggingController } = await load();
|
||||
let n = 0;
|
||||
const h = harness(defaultRoutes({
|
||||
[`POST ${OBSERVE}`]: () => (n++ === 0 ? fail(500, 'internal', 'boom') : response(200, observed())),
|
||||
}));
|
||||
const c = createImageTaggingController({ api: h.api, target: TARGET });
|
||||
await c.open();
|
||||
await settle();
|
||||
assert.equal(c.state.phase, 'observe-error');
|
||||
c.setManual('dunes');
|
||||
await c.retry();
|
||||
await settle();
|
||||
assert.equal(c.state.phase, 'ready');
|
||||
assert.equal(c.state.manual, 'Minimal, warm_light, dunes');
|
||||
assert.equal(c.state.statusText, '');
|
||||
});
|
||||
|
||||
@@ -91,10 +91,10 @@ test('a delete outcome counts each part, singular and plural, and names the back
|
||||
toast: 'Deleted 2 prompts. Archived 3 prompts. 1 protected prompt was kept. Safety backup saved: prompts.db.backup_x (System › Backup & export).'});
|
||||
assert.equal(deleteOutcome({success: true, deleted: 1, archived: 0, already_archived: 1, not_found: 1, changed: 1,
|
||||
backup: null}, null, reviewOf(preview())).toast,
|
||||
'Deleted 1 prompt. 1 was already archived. 1 no longer exists. 1 changed since the review and was not deleted.');
|
||||
'Deleted 1 prompt. 1 was already archived. 1 no longer exists. 1 changed since the review and was kept.');
|
||||
assert.equal(deleteOutcome({success: true, deleted: 0, archived: 0, already_archived: 2, not_found: 3, changed: 2,
|
||||
backup: null}, null, reviewOf(preview({protected: [1, 2]}))).toast,
|
||||
'Nothing was deleted or archived. 2 were already archived. 3 no longer exist. 2 changed since the review and were not deleted. 2 protected prompts were kept. The Library was reloaded.');
|
||||
'Nothing was deleted or archived. 2 were already archived. 3 no longer exist. 2 changed since the review and were kept. 2 protected prompts were kept. The Library was reloaded.');
|
||||
assert.equal(deleteOutcome({success: true, deleted: 0, archived: 0}, null, reviewOf(preview())).committed, false);
|
||||
});
|
||||
|
||||
@@ -134,8 +134,7 @@ test('reviewOf keeps only well-formed ids and counts from an answer', async () =
|
||||
const review = reviewOf({library_epoch: 3, delete: [1, 'x', -2, 2], archive: null, protected: [1.5], already_archived: -1,
|
||||
not_found: 'x', image_links: 2, backup: 'maybe'});
|
||||
assert.deepEqual(review, {epoch: null, delete: [1, 2], archive: [], protected: [], alreadyArchived: 0, notFound: 0,
|
||||
imageLinks: 2, backup: 'on', disk: null, deletable: [], digests: {},
|
||||
pairedCounts: {pairs: 0, archivedPairs: 0, variants: 0, keptNegatives: 0}, pairedImageLinks: 0, choice: 'archive'});
|
||||
imageLinks: 2, backup: 'on', disk: null});
|
||||
});
|
||||
|
||||
test('the archived notice says why (the saved pair) and names negative variants only when there are any', async () => {
|
||||
@@ -149,83 +148,3 @@ test('the archived notice says why (the saved pair) and names negative variants
|
||||
// An unknown count (an older server) never claims variants.
|
||||
assert.equal(archivedNoticeText(undefined), archivedNoticeText(0));
|
||||
});
|
||||
|
||||
// #482 (D160): prompts in a saved pair can be deleted permanently, with their own saved pairs.
|
||||
const D1 = 'a'.repeat(64), D2 = 'b'.repeat(64);
|
||||
const paired = (patch = {}) => preview({archive: [3, 4, 5], deletable: [3, 4], review_digests: {3: D1, 4: D2},
|
||||
paired_counts: {pairs: 3, archived_pairs: 1, variants: 2, kept_negatives: 2}, paired_image_links: 4, ...patch});
|
||||
|
||||
test('reviewOf reads the deletable ids, their digests and the pair counts; the choice starts at Archive', async () => {
|
||||
const {reviewOf, hasPairedChoice} = await source;
|
||||
const review = reviewOf(paired({deletable: [3, 4, 99], review_digests: {3: D1, 4: 'short', 99: D1}}));
|
||||
// Only reviewed archive ids with a well-formed digest can be deleted permanently.
|
||||
assert.deepEqual(review.deletable, [3]);
|
||||
assert.deepEqual(review.digests, {3: D1});
|
||||
assert.deepEqual(review.pairedCounts, {pairs: 3, archivedPairs: 1, variants: 2, keptNegatives: 2});
|
||||
assert.equal(review.pairedImageLinks, 4);
|
||||
assert.equal(review.choice, 'archive');
|
||||
assert.equal(hasPairedChoice(review), true);
|
||||
assert.equal(hasPairedChoice(reviewOf(preview({archive: [5]}))), false);
|
||||
});
|
||||
|
||||
test('Archive chosen keeps the #443 review; Delete permanently names the pairs, the kept negatives and archive-only', async () => {
|
||||
const {reviewOf, withChoice, reviewGrid, reviewTitle, primaryLabel, deleteBody} = await source;
|
||||
const review = reviewOf(paired({delete: [1], image_links: 2}));
|
||||
assert.deepEqual(reviewGrid(review).map(([term]) => term), ['Delete permanently', 'Archive', 'Image links removed', 'Backup']);
|
||||
assert.equal(primaryLabel(review), 'Delete 1 and archive 3 prompts');
|
||||
assert.deepEqual(deleteBody(review), {archive_ids: [3, 4, 5], delete_ids: [1], expected_epoch: 'e', backup: 'on'});
|
||||
const permanent = withChoice(review, 'delete');
|
||||
assert.notEqual(permanent, review);
|
||||
assert.equal(review.choice, 'archive');
|
||||
assert.deepEqual(reviewGrid(permanent), [
|
||||
['Delete permanently', '3 prompts: 1 not in a saved pair, 2 in a saved pair'],
|
||||
['Saved pairs removed', '3 saved pairs (2 with a negative prompt, 1 archived)'],
|
||||
['Negatives kept', '2 negative prompts are kept'],
|
||||
['Archive', '1 prompt used as another prompt\'s negative: hidden, restorable'],
|
||||
['Image links removed', '6 images will no longer show these prompts (files stay on disk)'],
|
||||
['Backup', 'Create and verify before deleting'],
|
||||
]);
|
||||
assert.equal(reviewTitle(permanent), 'Delete or archive selected prompts');
|
||||
assert.equal(primaryLabel(permanent), 'Delete 3 and archive 1 prompt');
|
||||
assert.deepEqual(deleteBody(permanent), {archive_ids: [3, 4, 5], delete_ids: [1], expected_epoch: 'e', backup: 'on',
|
||||
delete_paired_ids: [3, 4], paired_digests: {3: D1, 4: D2}});
|
||||
});
|
||||
|
||||
test('edge counts: one prompt, no negative, nothing archive-only', async () => {
|
||||
const {reviewOf, withChoice, reviewGrid, reviewTitle, primaryLabel} = await source;
|
||||
// Live prompt 7896: one pair with an explicitly empty negative.
|
||||
const one = withChoice(reviewOf(preview({archive: [7], deletable: [7], review_digests: {7: D1},
|
||||
paired_counts: {pairs: 1, archived_pairs: 0, variants: 0, kept_negatives: 0}, paired_image_links: 0})), 'delete');
|
||||
assert.deepEqual(reviewGrid(one), [
|
||||
['Delete permanently', '1 prompt in a saved pair'],
|
||||
['Saved pairs removed', '1 saved pair'],
|
||||
['Image links removed', '0 images will no longer show these prompts (files stay on disk)'],
|
||||
['Backup', 'Create and verify before deleting'],
|
||||
]);
|
||||
assert.equal(reviewTitle(one), 'Delete selected prompts');
|
||||
assert.equal(primaryLabel(one), 'Delete 1 prompt');
|
||||
});
|
||||
|
||||
test('Delete permanently sentences: what goes with the pairs, what stays, and how it comes back', async () => {
|
||||
const {reviewOf, withChoice, reviewSentences} = await source;
|
||||
const [pairs, remove, archive] = reviewSentences(withChoice(reviewOf(paired()), 'delete'));
|
||||
assert.equal(pairs, 'Deleting a prompt in a saved pair also removes its saved pairs, with their titles, categories, ' +
|
||||
'ratings, notes, tags, run counts and image links. Their negative prompts are kept. Recorded runs stay in History ' +
|
||||
'and Activity, listed by their text.');
|
||||
assert.match(remove, /the only way back is to restore the safety backup/);
|
||||
assert.match(remove, /A pair node run with the same text saves a new pair\.$/);
|
||||
assert.equal(archive, 'Prompts used as another prompt\'s negative are archived instead of deleted, so that ' +
|
||||
'prompt\'s variant stays.');
|
||||
const [, off] = reviewSentences(withChoice(reviewOf(paired({backup: 'off'})), 'delete'));
|
||||
assert.match(off, /Deleted prompts cannot be brought back\./);
|
||||
});
|
||||
|
||||
test('a short disk blocks only the permanent delete; the outcome names removed pairs', async () => {
|
||||
const {reviewOf, withChoice, blockedReason, deleteOutcome} = await source;
|
||||
const short = reviewOf(paired({disk: {sufficient: false, message: 'Not enough space. Nothing was changed.'}}));
|
||||
assert.equal(blockedReason(short), null);
|
||||
assert.equal(blockedReason(withChoice(short, 'delete')), 'Not enough space. Nothing was changed.');
|
||||
const {toast} = deleteOutcome({success: true, deleted: 2, archived: 1, pairs_removed: 3, changed: 1}, null, short);
|
||||
assert.equal(toast, 'Deleted 2 prompts. Removed 3 saved pairs. Archived 1 prompt. 1 changed since the review and ' +
|
||||
'was not deleted.');
|
||||
});
|
||||
|
||||
@@ -859,42 +859,6 @@ test('backupNotice is the concept .notice with its copy and one Back up now acti
|
||||
}
|
||||
});
|
||||
|
||||
test('openReviewDialog replaces its rows and its primary in place (#482)', async () => {
|
||||
const { openReviewDialog, closeWorkspaceDialog } = await dialogsModule;
|
||||
const { createScope } = await coreModule;
|
||||
const dom = fakeDOM();
|
||||
const scope = createScope();
|
||||
try {
|
||||
const started = [];
|
||||
const review = openReviewDialog({ scope, title: 'Delete or archive', grid: [['Archive', '2 prompts']],
|
||||
cancelLabel: 'Cancel', primary: { label: 'Archive 2 prompts', danger: true }, onStart: () => started.push('start') });
|
||||
review.setRows([['Delete permanently', '2 prompts'], ['Saved pairs removed', '3 saved pairs']]);
|
||||
const grid = byId(dom, 'modal-body').children[0];
|
||||
assert.deepEqual(grid.children.map(entry => [entry.children[0].textContent, entry.children[1].textContent]),
|
||||
[['Delete permanently', '2 prompts'], ['Saved pairs removed', '3 saved pairs']]);
|
||||
// setValue reaches the new rows only.
|
||||
review.setValue('Saved pairs removed', '4 saved pairs');
|
||||
review.setValue('Archive', 'x');
|
||||
assert.equal(grid.children[1].children[1].textContent, '4 saved pairs');
|
||||
const start = button(byId(dom, 'modal-body'), 'Archive 2 prompts');
|
||||
review.setPrimary({ label: 'Delete 2 prompts', disabled: true });
|
||||
assert.equal(start.textContent, 'Delete 2 prompts');
|
||||
assert.equal(start.getAttribute('aria-disabled'), 'true');
|
||||
start.click();
|
||||
assert.deepEqual(started, []);
|
||||
assert.equal(modal(dom).open, true);
|
||||
review.setPrimary({ label: 'Delete 2 prompts', disabled: false });
|
||||
assert.equal(start.getAttribute('aria-disabled'), 'false');
|
||||
start.click();
|
||||
assert.deepEqual(started, ['start']);
|
||||
assert.throws(() => review.setRows([['only a term']]), TypeError);
|
||||
} finally {
|
||||
scope.dispose();
|
||||
closeWorkspaceDialog();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('openReviewDialog lays out heading, grid, options, sentence, backup box and actions', async () => {
|
||||
const { openReviewDialog, closeWorkspaceDialog } = await dialogsModule;
|
||||
const { createScope } = await coreModule;
|
||||
|
||||
@@ -62,8 +62,6 @@ def table_dump(db):
|
||||
|
||||
|
||||
class LibraryDeleteCase(unittest.TestCase):
|
||||
maxDiff = None
|
||||
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
# Cleanups run last-in first-out and after every cleanup a test adds,
|
||||
@@ -140,23 +138,9 @@ class TestClassifyAndApply(LibraryDeleteCase):
|
||||
"not_found": [negative_only, 99999],
|
||||
"image_links": 1,
|
||||
"fingerprints": result["fingerprints"],
|
||||
# #482: the archive ids Delete permanently could remove (with pairs).
|
||||
"deletable": [positive, archived_pair_positive],
|
||||
"review_digests": result["review_digests"],
|
||||
"paired_counts": {
|
||||
"pairs": 2,
|
||||
"archived_pairs": 1,
|
||||
"variants": 2,
|
||||
# "only negative" is itself selected; only "blur" is kept.
|
||||
"kept_negatives": 1,
|
||||
},
|
||||
"paired_image_links": 0,
|
||||
},
|
||||
)
|
||||
self.assertEqual(list(result["fingerprints"]), [plain])
|
||||
self.assertEqual(
|
||||
list(result["review_digests"]), [positive, archived_pair_positive]
|
||||
)
|
||||
|
||||
def test_apply_archives_paired_deletes_unpaired_and_skips_protected(self):
|
||||
plain = save(self.db, "Plain", tags=["red"])
|
||||
@@ -176,7 +160,6 @@ class TestClassifyAndApply(LibraryDeleteCase):
|
||||
"not_found": 0,
|
||||
"changed": 0,
|
||||
"image_links": 1,
|
||||
"pairs_removed": 0,
|
||||
},
|
||||
)
|
||||
self.assertEqual(prompt_ids(self.db), sorted([protected, positive, negative]))
|
||||
@@ -541,188 +524,3 @@ class TestArchivedReaders(LibraryDeleteCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class TestPairedDelete(LibraryDeleteCase):
|
||||
"""#482 (D160): Delete permanently also removes a prompt's own saved pairs."""
|
||||
|
||||
def pair_rows(self, positive):
|
||||
return rows(
|
||||
self.db,
|
||||
"SELECT id FROM prompt_pairs WHERE positive_prompt_id=? ORDER BY id",
|
||||
(positive,),
|
||||
)
|
||||
|
||||
def pair_link(self, pair, image):
|
||||
return self.db.link_image_to_pair(pair, image, {"resolver_version": 1})
|
||||
|
||||
def delete_paired(self, ids, review, archive=None, epoch=None):
|
||||
"""Write ``ids`` as Delete permanently, with the digests ``review`` gave."""
|
||||
archive = review["archive"] if archive is None else archive
|
||||
return self.db.archive_or_delete_library_prompts(
|
||||
archive,
|
||||
review["delete"],
|
||||
expected_epoch=epoch or self.epoch,
|
||||
candidates=review["fingerprints"],
|
||||
delete_paired={pid: review["review_digests"][pid] for pid in ids},
|
||||
)
|
||||
|
||||
def test_classify_marks_deletable_and_archive_only_with_pair_counts(self):
|
||||
cat, cat_negative, _pair = self.paired("Cat", "blur")
|
||||
self.db.save_prompt_pair("Cat", "grain")
|
||||
archived_pair = self.db.save_prompt_pair("Cat", "noise")
|
||||
self.db.archive_prompt_pair(archived_pair, True)
|
||||
shared = save(self.db, "Shared")
|
||||
self.image(shared, "shared.png")
|
||||
self.db.save_prompt_pair("Dog", "Shared") # used as another positive's negative
|
||||
review = self.classify([cat, shared])
|
||||
self.assertEqual(review["archive"], [cat, shared])
|
||||
self.assertEqual(review["deletable"], [cat])
|
||||
self.assertEqual(list(review["review_digests"]), [cat])
|
||||
self.assertRegex(review["review_digests"][cat], r"^[0-9a-f]{64}$")
|
||||
self.assertEqual(
|
||||
review["paired_counts"],
|
||||
{"pairs": 3, "archived_pairs": 1, "variants": 3, "kept_negatives": 3},
|
||||
)
|
||||
self.assertEqual(review["paired_image_links"], 0)
|
||||
|
||||
def test_an_empty_negative_pair_is_deleted_with_its_prompt(self):
|
||||
# Live prompt 7896: an auto-created pair with an explicitly empty negative.
|
||||
pair = self.db.save_prompt_pair("été", "")
|
||||
prompt = self.db.get_prompt_pair(pair)["positive_prompt_id"]
|
||||
review = self.classify([prompt])
|
||||
self.assertEqual(review["deletable"], [prompt])
|
||||
self.assertEqual(
|
||||
review["paired_counts"],
|
||||
{"pairs": 1, "archived_pairs": 0, "variants": 0, "kept_negatives": 0},
|
||||
)
|
||||
result = self.delete_paired([prompt], review)
|
||||
self.assertEqual((result["deleted"], result["archived"]), (1, 0))
|
||||
self.assertEqual(result["pairs_removed"], 1)
|
||||
self.assertNotIn(prompt, prompt_ids(self.db))
|
||||
self.assertEqual(self.pair_rows(prompt), [])
|
||||
self.assertEqual(rows(self.db, "PRAGMA foreign_key_check"), [])
|
||||
|
||||
def test_rows_removed_are_the_legacy_pair_deletes_then_the_prompt_delete(self):
|
||||
twin = PromptDatabase(str(self.root / "twin.db"))
|
||||
self.addCleanup(twin.close_all)
|
||||
twin_epoch = twin.get_library_options()["library_epoch"]
|
||||
made = {}
|
||||
for db in (self.db, twin):
|
||||
plain = db.save_prompt_pair("Owl", "", tags=["night"], title="Owl")
|
||||
with_negative = db.save_prompt_pair("Owl", "dark", rating=4)
|
||||
hidden = db.save_prompt_pair("Owl", "fog")
|
||||
db.archive_prompt_pair(hidden, True)
|
||||
owl = db.get_prompt_pair(plain)["positive_prompt_id"]
|
||||
path = self.root / "owl.png"
|
||||
path.write_bytes(b"png")
|
||||
image = db.link_image_to_prompt(owl, str(path), {"workflow": {"nodes": []}})
|
||||
db.link_image_to_pair(with_negative, image, {"resolver_version": 1})
|
||||
save(db, "Bystander", tags=["night"])
|
||||
made[id(db)] = (owl, [plain, with_negative, hidden])
|
||||
owl, pairs = made[id(self.db)]
|
||||
review = self.classify([owl])
|
||||
self.delete_paired([owl], review)
|
||||
twin_owl, twin_pairs = made[id(twin)]
|
||||
with twin.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM image_prompt_pairs WHERE pair_id IN (SELECT id FROM "
|
||||
"prompt_pairs WHERE positive_prompt_id=?)",
|
||||
(twin_owl,),
|
||||
)
|
||||
conn.commit()
|
||||
for pair in twin_pairs:
|
||||
self.assertTrue(twin.delete_prompt_pair(pair))
|
||||
twin.archive_or_delete_library_prompts(
|
||||
[], [twin_owl], expected_epoch=twin_epoch, candidates=None
|
||||
)
|
||||
self.assertEqual(table_dump(self.db), table_dump(twin))
|
||||
# The negatives stay, and the image file stays on disk.
|
||||
self.assertTrue((self.root / "owl.png").exists())
|
||||
texts = [row[0] for row in rows(self.db, "SELECT text FROM prompts")]
|
||||
self.assertTrue({"dark", "fog", "Bystander"} <= set(texts))
|
||||
|
||||
def test_a_negative_used_by_another_positive_is_archived_never_deleted(self):
|
||||
shared = save(self.db, "Shared")
|
||||
self.image(shared, "s.png")
|
||||
self.db.save_prompt_pair("Dog", "Shared")
|
||||
review = self.classify([shared])
|
||||
self.assertEqual(review["deletable"], [])
|
||||
result = self.db.archive_or_delete_library_prompts(
|
||||
[shared],
|
||||
[],
|
||||
expected_epoch=self.epoch,
|
||||
candidates={},
|
||||
delete_paired={shared: "0" * 64},
|
||||
)
|
||||
self.assertEqual((result["deleted"], result["archived"]), (0, 1))
|
||||
self.assertEqual(result["changed"], 1)
|
||||
self.assertEqual(archived_ids(self.db), [shared])
|
||||
|
||||
def test_a_positive_and_its_own_negative_selected_together(self):
|
||||
cat, blur, _pair = self.paired("Cat", "blur")
|
||||
self.image(blur, "blur.png") # the negative is a Library prompt too
|
||||
review = self.classify([cat, blur])
|
||||
self.assertEqual(review["deletable"], [cat])
|
||||
result = self.delete_paired([cat], review)
|
||||
self.assertEqual((result["deleted"], result["archived"]), (1, 1))
|
||||
self.assertEqual(archived_ids(self.db), [blur])
|
||||
self.assertNotIn(cat, prompt_ids(self.db))
|
||||
|
||||
def test_a_self_pair_is_deleted(self):
|
||||
pair = self.db.save_prompt_pair("Same", "Same")
|
||||
same = self.db.get_prompt_pair(pair)["positive_prompt_id"]
|
||||
review = self.classify([same])
|
||||
self.assertEqual(review["deletable"], [same])
|
||||
self.assertEqual(review["paired_counts"]["kept_negatives"], 0)
|
||||
self.assertEqual(self.delete_paired([same], review)["deleted"], 1)
|
||||
self.assertNotIn(same, prompt_ids(self.db))
|
||||
|
||||
def test_drift_after_the_review_archives_and_counts_changed(self):
|
||||
cases = {
|
||||
"pair added": lambda p: self.db.save_prompt_pair("Fox", "new"),
|
||||
"pair edited": lambda p: self.db.update_prompt_pair_metadata(
|
||||
self.pair_rows(p)[0][0], title="Renamed"
|
||||
),
|
||||
"pairs gone": lambda p: self.db.delete_prompt_pair(self.pair_rows(p)[0][0]),
|
||||
"now another's negative": lambda p: self.db.save_prompt_pair("Hen", "Fox"),
|
||||
}
|
||||
for name, change in cases.items():
|
||||
with self.subTest(name):
|
||||
fox, _neg, _pair = self.paired("Fox", "mist " + name)
|
||||
review = self.classify([fox])
|
||||
self.assertEqual(review["deletable"], [fox])
|
||||
change(fox)
|
||||
result = self.delete_paired([fox], review)
|
||||
self.assertEqual(
|
||||
(result["deleted"], result["archived"], result["changed"]),
|
||||
(0, 1, 1),
|
||||
)
|
||||
self.assertIn(fox, archived_ids(self.db))
|
||||
self.restore([fox])
|
||||
with self.db.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM prompt_pairs WHERE positive_prompt_id IN "
|
||||
"(SELECT id FROM prompts WHERE text IN ('Hen','Fox'))"
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def test_delete_paired_must_name_reviewed_archive_ids(self):
|
||||
fox, _neg, _pair = self.paired("Fox")
|
||||
review = self.classify([fox])
|
||||
with self.assertRaises(ValueError):
|
||||
self.db.archive_or_delete_library_prompts(
|
||||
[],
|
||||
[],
|
||||
expected_epoch=self.epoch,
|
||||
candidates={},
|
||||
delete_paired={fox: review["review_digests"][fox]},
|
||||
)
|
||||
with self.assertRaises(ValueError):
|
||||
self.db.archive_or_delete_library_prompts(
|
||||
[fox],
|
||||
[],
|
||||
expected_epoch=self.epoch,
|
||||
candidates={},
|
||||
delete_paired={fox: "not a digest"},
|
||||
)
|
||||
|
||||
@@ -320,86 +320,19 @@ class TestFileCheck(ImageDeleteTestCase):
|
||||
self.assertIn(code, image_delete.REASONS)
|
||||
|
||||
|
||||
class TestMacOSOutcomes(ImageDeleteTestCase):
|
||||
"""The codes and the unconfirmed outcome the macOS backend adds (#499)."""
|
||||
|
||||
async def test_permission_and_cloud_refusals_show_their_sentences(self):
|
||||
self.write("a.png", b"aaa")
|
||||
self.write("b.png", b"bbb")
|
||||
self.fake.refuse[str(self.output_dir / "a.png")] = "no-permission"
|
||||
self.fake.refuse[str(self.output_dir / "b.png")] = "cloud-sync"
|
||||
plan = await self.review(["a.png", "b.png"])
|
||||
self.assertEqual(plan["trash"], {"count": 0})
|
||||
self.assertEqual(
|
||||
plan["trash_refused"]["counts"], {"no-permission": 1, "cloud-sync": 1}
|
||||
)
|
||||
details = {row["detail"] for row in plan["trash_refused"]["rows"]}
|
||||
self.assertEqual(
|
||||
details,
|
||||
{image_delete.REASONS["no-permission"], image_delete.REASONS["cloud-sync"]},
|
||||
)
|
||||
|
||||
async def test_an_unconfirmed_move_keeps_records_and_stops_the_job(self):
|
||||
from utils import trash
|
||||
|
||||
self.write("a.png", b"aaa", mtime=1_000_000)
|
||||
self.write("b.png", b"bbb", mtime=2_000_000)
|
||||
self.link("a.png")
|
||||
original = self.fake.move
|
||||
|
||||
def move(path, roots):
|
||||
if path.endswith("a.png"):
|
||||
original(path, roots) # the file did leave its folder
|
||||
raise trash.TrashUnconfirmed("resultingItemURL=None")
|
||||
return original(path, roots)
|
||||
|
||||
self.fake.move = move
|
||||
_plan, job = await self.delete(["a.png", "b.png"], "trash")
|
||||
self.assertEqual(job.state, "failed")
|
||||
self.assertEqual(job.scope.get("failure"), "records-failed")
|
||||
self.assertEqual(
|
||||
self.details(job, "errors"), [image_delete.REASONS["trash-unconfirmed"]]
|
||||
)
|
||||
self.assertEqual(self.count("SELECT COUNT(*) FROM generated_images"), 1)
|
||||
self.assertEqual(
|
||||
self.details(job, "not_changed"), [image_delete.REASONS["not-attempted"]]
|
||||
)
|
||||
self.assertTrue((self.output_dir / "b.png").exists())
|
||||
|
||||
async def test_a_refusal_detail_is_logged(self):
|
||||
from utils import trash
|
||||
|
||||
self.write("a.png", b"aaa")
|
||||
|
||||
def move(path, roots):
|
||||
raise trash.TrashRefused("no-permission", "NSCocoaErrorDomain 513")
|
||||
|
||||
self.fake.move = move
|
||||
with self.assertLogs("prompt_manager.api", level="INFO") as logs:
|
||||
_plan, job = await self.delete(["a.png"], "trash")
|
||||
self.assertEqual(
|
||||
self.details(job, "not_changed"), [image_delete.REASONS["no-permission"]]
|
||||
)
|
||||
self.assertTrue(any("NSCocoaErrorDomain 513" in line for line in logs.output))
|
||||
self.assertTrue((self.output_dir / "a.png").exists())
|
||||
|
||||
|
||||
class TestNativeBackend(ImageDeleteTestCase):
|
||||
"""The real backend of this OS: Windows has no trash yet (#500).
|
||||
|
||||
Only library mode is applied, so even on macOS and Linux no file reaches
|
||||
a real trash.
|
||||
"""
|
||||
"""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 != "win32"
|
||||
native_trash = sys.platform not in ("win32", "darwin")
|
||||
if not native_trash:
|
||||
self.assertEqual(plan["trash"], {"count": 0})
|
||||
self.assertEqual(plan["trash_refused"]["counts"], {"windows-recycle": 1})
|
||||
code = "windows-recycle" if sys.platform == "win32" else "no-trash"
|
||||
self.assertEqual(plan["trash_refused"]["counts"], {code: 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)
|
||||
|
||||
@@ -130,13 +130,8 @@ class TestLibraryDeleteRoutes(LibraryDeleteRouteCase):
|
||||
async def test_archive_only_takes_no_backup(self):
|
||||
self.policy(True)
|
||||
positive = self.paired("Cat")
|
||||
shared = self.save("Shared")
|
||||
self.api.db.save_prompt_pair("Dog", "Shared") # archive-only (#482)
|
||||
self.api.db.link_image_to_prompt(shared, "/tmp/shared-443.png")
|
||||
# Nothing could be deleted: no disk plan. (#482: a deletable prompt has one.)
|
||||
self.assertIsNone((await self.review([shared]))["disk"])
|
||||
review = await self.review([positive])
|
||||
self.assertIsNotNone(review["disk"])
|
||||
self.assertIsNone(review["disk"])
|
||||
status, body = await self.confirm(review)
|
||||
self.assertEqual((status, body["archived"], body["backup"]), (200, 1, None))
|
||||
self.assertEqual(self.backups(), [])
|
||||
@@ -314,94 +309,6 @@ class TestLibraryDeleteRoutes(LibraryDeleteRouteCase):
|
||||
self.assertEqual(response.status, 400)
|
||||
|
||||
|
||||
class TestPairedDeleteRoutes(LibraryDeleteRouteCase):
|
||||
"""#482 (D160): Delete permanently with a prompt's own saved pairs."""
|
||||
|
||||
async def confirm_paired(self, review, ids, backup=None, digests=None):
|
||||
return await self.post(
|
||||
DELETE,
|
||||
{
|
||||
"archive_ids": review["archive"],
|
||||
"delete_ids": review["delete"],
|
||||
"expected_epoch": review["library_epoch"],
|
||||
"backup": backup or review["backup"],
|
||||
"delete_paired_ids": ids,
|
||||
"paired_digests": digests
|
||||
or {str(pid): review["review_digests"][str(pid)] for pid in ids},
|
||||
},
|
||||
)
|
||||
|
||||
async def test_preview_reports_deletable_and_a_paired_only_delete_backs_up(self):
|
||||
self.policy(True)
|
||||
cat = self.paired("Cat")
|
||||
shared = self.save("Shared")
|
||||
self.api.db.save_prompt_pair("Dog", "Shared")
|
||||
self.api.db.link_image_to_prompt(shared, "/tmp/shared-482.png")
|
||||
review = await self.review([cat, shared])
|
||||
self.assertEqual(review["archive"], [cat, shared])
|
||||
self.assertEqual(review["deletable"], [cat])
|
||||
self.assertEqual(list(review["review_digests"]), [str(cat)])
|
||||
self.assertEqual(
|
||||
review["paired_counts"],
|
||||
{"pairs": 1, "archived_pairs": 0, "variants": 1, "kept_negatives": 1},
|
||||
)
|
||||
self.assertEqual(review["paired_image_links"], 0)
|
||||
# A selection with nothing in the delete class still checks the disk.
|
||||
self.assertTrue(review["disk"]["sufficient"])
|
||||
status, body = await self.confirm_paired(review, [cat])
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(
|
||||
(body["deleted"], body["archived"], body["pairs_removed"]), (1, 1, 1)
|
||||
)
|
||||
self.assertEqual(self.backups(), [body["backup"]])
|
||||
self.assertNotIn(cat, self.ids())
|
||||
self.assertEqual(self.archived(), [shared])
|
||||
|
||||
async def test_a_body_without_the_new_keys_is_the_443_delete(self):
|
||||
cat = self.paired("Cat")
|
||||
review = await self.review([cat])
|
||||
status, body = await self.confirm(review)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual((body["deleted"], body["archived"]), (0, 1))
|
||||
self.assertIn(cat, self.ids())
|
||||
|
||||
async def test_paired_bodies_are_validated(self):
|
||||
cat = self.paired("Cat")
|
||||
plain = self.save("Plain")
|
||||
review = await self.review([cat, plain])
|
||||
digest = review["review_digests"][str(cat)]
|
||||
base = {
|
||||
"archive_ids": review["archive"],
|
||||
"delete_ids": review["delete"],
|
||||
"expected_epoch": review["library_epoch"],
|
||||
"backup": "off",
|
||||
}
|
||||
cases = [
|
||||
{"delete_paired_ids": [cat]}, # without its digests
|
||||
{"delete_paired_ids": [plain], "paired_digests": {str(plain): digest}},
|
||||
{"delete_paired_ids": [cat], "paired_digests": {}},
|
||||
{"delete_paired_ids": [cat], "paired_digests": {str(cat): "x"}},
|
||||
{"delete_paired_ids": "all", "paired_digests": {}},
|
||||
]
|
||||
for extra in cases:
|
||||
with self.subTest(extra=extra):
|
||||
status, body = await self.post(DELETE, {**base, **extra})
|
||||
self.assertEqual((status, body["code"]), (400, "invalid-request"))
|
||||
self.assertIn(cat, self.ids())
|
||||
|
||||
async def test_a_short_disk_refuses_a_paired_only_delete(self):
|
||||
self.policy(True)
|
||||
cat = self.paired("Cat")
|
||||
from py.api import maintenance
|
||||
|
||||
with patch.object(maintenance, "_free_bytes", return_value=0):
|
||||
review = await self.review([cat])
|
||||
self.assertFalse(review["disk"]["sufficient"])
|
||||
status, body = await self.confirm_paired(review, [cat])
|
||||
self.assertEqual((status, body["code"]), (507, "insufficient-space"))
|
||||
self.assertIn(cat, self.ids())
|
||||
|
||||
|
||||
class TestIngestionAfterArchive(LibraryDeleteRouteCase):
|
||||
def scan(self, name, positive, negative="blur"):
|
||||
meta = {"prompt": json.dumps(graph(positive, negative))}
|
||||
|
||||
@@ -490,25 +490,16 @@ class TestThumbnailJob(ThumbnailJobCase):
|
||||
real_write = images_module._write_image_thumbnail
|
||||
job_ids = []
|
||||
written = []
|
||||
# #504: the worker can write its first preview before the response
|
||||
# carrying the job id is read. The first write waits until the test
|
||||
# has stored the id, so the cancel always lands after exactly one write.
|
||||
first_written, id_stored = threading.Event(), threading.Event()
|
||||
self.addCleanup(id_stored.set)
|
||||
|
||||
def counting(src, dst, *args, **kwargs):
|
||||
real_write(src, dst, *args, **kwargs)
|
||||
written.append(dst)
|
||||
if len(written) == 1:
|
||||
first_written.set()
|
||||
id_stored.wait(10)
|
||||
self.api._jobs.cancel(job_ids[0])
|
||||
|
||||
with patch.object(images_module, "_write_image_thumbnail", counting):
|
||||
_, body = await self._start()
|
||||
await self._wait(first_written)
|
||||
job_ids.append(body["job"]["id"])
|
||||
id_stored.set()
|
||||
job = await self._finished(body["job"]["id"])
|
||||
self.assertEqual(job.state, "cancelled")
|
||||
self.assertEqual(job.status_line, "Cancelled · 1 preview written")
|
||||
|
||||
@@ -277,13 +277,12 @@ class UnavailableBackendTests(unittest.TestCase):
|
||||
self.assertEqual(caught.exception.code, trash.NO_TRASH)
|
||||
self.assertTrue(os.path.exists(path))
|
||||
|
||||
def test_each_platform_gets_its_backend(self):
|
||||
from utils import trash_macos
|
||||
|
||||
self.assertIsInstance(trash.default_backend("win32"), trash.UnavailableBackend)
|
||||
self.assertIsInstance(
|
||||
trash.default_backend("darwin"), trash_macos.MacTrashBackend
|
||||
)
|
||||
def test_windows_and_macos_have_no_trash_yet(self):
|
||||
for platform in ("win32", "darwin"):
|
||||
with self.subTest(platform=platform):
|
||||
self.assertIsInstance(
|
||||
trash.default_backend(platform), trash.UnavailableBackend
|
||||
)
|
||||
self.assertIsInstance(trash.default_backend("linux"), trash.FreedesktopBackend)
|
||||
|
||||
def test_windows_says_why_the_recycle_bin_is_not_used(self):
|
||||
@@ -296,6 +295,9 @@ class UnavailableBackendTests(unittest.TestCase):
|
||||
backend.move("C:/x/a.png", ["C:/x"])
|
||||
self.assertEqual(caught.exception.code, trash.WINDOWS_RECYCLE)
|
||||
self.assertIn(trash.WINDOWS_RECYCLE, trash.CODES)
|
||||
self.assertEqual(
|
||||
trash.default_backend("darwin").available("/x", ["/"]), trash.NO_TRASH
|
||||
)
|
||||
|
||||
def test_module_functions_use_the_replaceable_backend(self):
|
||||
fake = mock.Mock()
|
||||
|
||||
@@ -1,360 +0,0 @@
|
||||
"""The macOS trash backend's decisions (#499), with a fake Foundation bridge.
|
||||
|
||||
No Objective-C is involved: the bridge is faked, ``uid``/``home``/``lstat``/
|
||||
``access`` are injected, and the only real files are temp files used for the
|
||||
"source still exists" and output-root identity checks. Runs on every OS.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from utils import trash, trash_macos # noqa: E402
|
||||
from utils.trash_macos import Facts, MacTrashBackend, NSErr # noqa: E402
|
||||
|
||||
LOCAL = Facts(local=True, read_only=False, root_fs=True, volume="/", ubiquitous=False)
|
||||
COCOA = "NSCocoaErrorDomain"
|
||||
NO_TRASH_DIR = NSErr(COCOA, 3328, "NSPOSIXErrorDomain", 2)
|
||||
|
||||
|
||||
class FakeBridge:
|
||||
"""The §6.3 bridge with canned answers; ``trash`` moves nothing by default."""
|
||||
|
||||
def __init__(self, facts=LOCAL, trash_dir=("/Users/me/.Trash", None), result=None):
|
||||
self.facts_value = facts
|
||||
self.trash_dir_value = trash_dir
|
||||
self.result = result or (True, None, None)
|
||||
self.calls = []
|
||||
|
||||
def facts(self, path):
|
||||
self.calls.append(("facts", path))
|
||||
return self.facts_value
|
||||
|
||||
def trash_dir(self, path):
|
||||
self.calls.append(("trash_dir", path))
|
||||
return self.trash_dir_value
|
||||
|
||||
def trash(self, path):
|
||||
self.calls.append(("trash", path))
|
||||
result = self.result
|
||||
return result(path) if callable(result) else result
|
||||
|
||||
|
||||
def _stat(mode=stat.S_IFREG | 0o644, uid=501, flags=0):
|
||||
return SimpleNamespace(st_mode=mode, st_uid=uid, st_flags=flags)
|
||||
|
||||
|
||||
class MacBackendTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# realpath: the backend resolves paths (/var -> /private/var on macOS, 8.3
|
||||
# short names on Windows), so the lstat fakes must use the same spelling.
|
||||
self.tmp = os.path.realpath(tempfile.mkdtemp(prefix="pm-mac-trash-"))
|
||||
self.addCleanup(shutil.rmtree, self.tmp, True)
|
||||
self.home = os.path.join(self.tmp, "home")
|
||||
self.root = os.path.join(self.tmp, "output")
|
||||
os.makedirs(os.path.join(self.home, ".Trash"))
|
||||
os.makedirs(self.root)
|
||||
self.file = os.path.join(self.root, "a.png")
|
||||
with open(self.file, "wb") as handle:
|
||||
handle.write(b"png")
|
||||
self.home_trash = os.path.join(self.home, ".Trash")
|
||||
self.stats = {}
|
||||
self.access_ok = True
|
||||
|
||||
def _lstat(self, path):
|
||||
if path in self.stats:
|
||||
value = self.stats[path]
|
||||
if isinstance(value, Exception):
|
||||
raise value
|
||||
return value
|
||||
return os.lstat(path)
|
||||
|
||||
def backend(self, **bridge_args):
|
||||
bridge_args.setdefault("trash_dir", (self.home_trash, None))
|
||||
self.bridge = FakeBridge(**bridge_args)
|
||||
return MacTrashBackend(
|
||||
bridge=self.bridge,
|
||||
uid=501,
|
||||
home=self.home,
|
||||
lstat=self._lstat,
|
||||
access=lambda path, mode: self.access_ok,
|
||||
)
|
||||
|
||||
def moved_to(self, destination):
|
||||
"""A ``trash`` result that really renames the file to *destination*."""
|
||||
|
||||
def run(path):
|
||||
os.makedirs(os.path.dirname(destination), exist_ok=True)
|
||||
os.rename(path, destination)
|
||||
return True, destination, None
|
||||
|
||||
return run
|
||||
|
||||
def assert_refused(self, backend, code):
|
||||
self.assertEqual(backend.available(self.file, [self.root]), code)
|
||||
with self.assertRaises(trash.TrashRefused) as caught:
|
||||
backend.move(self.file, [self.root])
|
||||
self.assertEqual(caught.exception.code, code)
|
||||
self.assertNotIn("trash", [name for name, _ in self.bridge.calls])
|
||||
self.assertTrue(os.path.exists(self.file))
|
||||
return caught.exception
|
||||
|
||||
|
||||
class AvailableOrderTests(MacBackendTestCase):
|
||||
def test_a_writable_local_file_is_available(self):
|
||||
self.assertIsNone(self.backend().available(self.file, [self.root]))
|
||||
|
||||
def test_network_volume_is_refused_without_trying(self):
|
||||
facts = Facts(False, False, False, "/Volumes/nas", False)
|
||||
self.assert_refused(self.backend(facts=facts), trash.NETWORK)
|
||||
|
||||
def test_unknown_locality_counts_as_network(self):
|
||||
facts = Facts(None, False, True, "/", False)
|
||||
self.assert_refused(self.backend(facts=facts), trash.NETWORK)
|
||||
|
||||
def test_read_only_volume_or_unknown_is_no_trash(self):
|
||||
for read_only in (True, None):
|
||||
with self.subTest(read_only=read_only):
|
||||
facts = Facts(True, read_only, False, "/Volumes/ro", False)
|
||||
self.assert_refused(self.backend(facts=facts), trash.NO_TRASH)
|
||||
|
||||
def test_ubiquitous_item_is_cloud_sync(self):
|
||||
facts = Facts(True, False, True, "/", True)
|
||||
self.assert_refused(self.backend(facts=facts), trash.CLOUD_SYNC)
|
||||
|
||||
def test_icloud_and_file_provider_folders_are_cloud_sync(self):
|
||||
for parts in (("Library", "Mobile Documents"), ("Library", "CloudStorage")):
|
||||
with self.subTest(parts=parts):
|
||||
folder = os.path.join(self.home, *parts, "Box")
|
||||
os.makedirs(folder)
|
||||
path = os.path.join(folder, "b.png")
|
||||
with open(path, "wb") as handle:
|
||||
handle.write(b"x")
|
||||
backend = self.backend()
|
||||
self.assertEqual(backend.available(path, [folder]), trash.CLOUD_SYNC)
|
||||
with self.assertRaises(trash.TrashRefused):
|
||||
backend.move(path, [folder])
|
||||
self.assertTrue(os.path.exists(path))
|
||||
|
||||
def test_found_trash_folder_inside_an_output_root_is_trash_in_root(self):
|
||||
self.assertEqual(
|
||||
self.backend().available(self.file, [self.home]), trash.TRASH_IN_ROOT
|
||||
)
|
||||
|
||||
def test_trash_dir_error_other_than_missing_is_no_trash(self):
|
||||
backend = self.backend(
|
||||
trash_dir=(None, NSErr(COCOA, 3328, "NSPOSIXErrorDomain", 30))
|
||||
)
|
||||
self.assert_refused(backend, trash.NO_TRASH)
|
||||
|
||||
def test_missing_trash_on_the_startup_volume_is_no_trash(self):
|
||||
self.assert_refused(
|
||||
self.backend(trash_dir=(None, NO_TRASH_DIR)), trash.NO_TRASH
|
||||
)
|
||||
|
||||
|
||||
class FreshVolumeTests(MacBackendTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.volume = os.path.join(self.tmp, "vol")
|
||||
self.root = os.path.join(self.volume, "output")
|
||||
os.makedirs(self.root)
|
||||
self.file = os.path.join(self.root, "a.png")
|
||||
with open(self.file, "wb") as handle:
|
||||
handle.write(b"png")
|
||||
self.facts = Facts(True, False, False, self.volume, False)
|
||||
|
||||
def test_missing_volume_trash_is_predicted(self):
|
||||
backend = self.backend(facts=self.facts, trash_dir=(None, NO_TRASH_DIR))
|
||||
self.assertIsNone(backend.available(self.file, [self.root]))
|
||||
|
||||
def test_prediction_refused_when_trashes_is_a_link(self):
|
||||
os.symlink(self.tmp, os.path.join(self.volume, ".Trashes"))
|
||||
backend = self.backend(facts=self.facts, trash_dir=(None, NO_TRASH_DIR))
|
||||
self.assert_refused(backend, trash.NO_TRASH)
|
||||
|
||||
def test_predicted_trash_inside_the_volume_root_is_trash_in_root(self):
|
||||
backend = self.backend(facts=self.facts, trash_dir=(None, NO_TRASH_DIR))
|
||||
self.assert_refused_root = backend.available(self.file, [self.volume])
|
||||
self.assertEqual(self.assert_refused_root, trash.TRASH_IN_ROOT)
|
||||
|
||||
def test_move_to_a_predicted_trash_returns_the_volume_label(self):
|
||||
destination = os.path.join(self.volume, ".Trashes", "501", "a.png")
|
||||
backend = self.backend(
|
||||
facts=self.facts,
|
||||
trash_dir=(None, NO_TRASH_DIR),
|
||||
result=self.moved_to(destination),
|
||||
)
|
||||
self.assertEqual(
|
||||
backend.move(self.file, [self.root]), ".Trashes/501 on its drive"
|
||||
)
|
||||
self.assertFalse(os.path.exists(self.file))
|
||||
|
||||
|
||||
class PermissionTests(MacBackendTestCase):
|
||||
def test_parent_not_writable_is_no_permission(self):
|
||||
self.access_ok = False
|
||||
self.assert_refused(self.backend(), trash.NO_PERMISSION)
|
||||
|
||||
def test_immutable_or_append_flags_on_file_or_parent(self):
|
||||
for flag in ("UF_IMMUTABLE", "SF_IMMUTABLE", "UF_APPEND", "SF_APPEND"):
|
||||
for target in (self.file, self.root):
|
||||
with self.subTest(flag=flag, target=target):
|
||||
mode = (
|
||||
stat.S_IFDIR | 0o755
|
||||
if target == self.root
|
||||
else stat.S_IFREG | 0o644
|
||||
)
|
||||
self.stats = {target: _stat(mode=mode, flags=getattr(stat, flag))}
|
||||
self.assert_refused(self.backend(), trash.NO_PERMISSION)
|
||||
|
||||
def test_sticky_parent_and_foreign_owner(self):
|
||||
self.stats = {
|
||||
self.root: _stat(mode=stat.S_IFDIR | 0o1777),
|
||||
self.file: _stat(uid=502),
|
||||
}
|
||||
self.assert_refused(self.backend(), trash.NO_PERMISSION)
|
||||
|
||||
def test_sticky_parent_with_own_file_is_allowed(self):
|
||||
self.stats = {self.root: _stat(mode=stat.S_IFDIR | 0o1777), self.file: _stat()}
|
||||
self.assertIsNone(self.backend().available(self.file, [self.root]))
|
||||
|
||||
|
||||
class MoveOutcomeTests(MacBackendTestCase):
|
||||
def test_success_into_the_home_trash(self):
|
||||
destination = os.path.join(self.home_trash, "a.png")
|
||||
backend = self.backend(result=self.moved_to(destination))
|
||||
self.assertEqual(backend.move(self.file, [self.root]), trash.HOME_TRASH_LABEL)
|
||||
self.assertTrue(os.path.exists(destination))
|
||||
|
||||
def test_cocoa_errors_map_to_codes_with_detail(self):
|
||||
cases = {
|
||||
3328: trash.NO_TRASH,
|
||||
642: trash.NO_TRASH,
|
||||
513: trash.NO_PERMISSION,
|
||||
257: trash.NO_PERMISSION,
|
||||
4: trash.FAILED,
|
||||
640: trash.FAILED,
|
||||
}
|
||||
for code, expected in cases.items():
|
||||
with self.subTest(code=code):
|
||||
error = NSErr(COCOA, code, "NSOSStatusErrorDomain", -5000)
|
||||
backend = self.backend(result=(False, None, error))
|
||||
with self.assertRaises(trash.TrashRefused) as caught:
|
||||
backend.move(self.file, [self.root])
|
||||
self.assertEqual(caught.exception.code, expected)
|
||||
self.assertEqual(
|
||||
caught.exception.detail,
|
||||
f"NSCocoaErrorDomain {code} (NSOSStatusErrorDomain -5000)",
|
||||
)
|
||||
self.assertTrue(os.path.exists(self.file))
|
||||
|
||||
def test_other_domain_or_missing_error_is_failed(self):
|
||||
for error in (NSErr("NSPOSIXErrorDomain", 13), None):
|
||||
with self.subTest(error=error):
|
||||
backend = self.backend(result=(False, None, error))
|
||||
with self.assertRaises(trash.TrashRefused) as caught:
|
||||
backend.move(self.file, [self.root])
|
||||
self.assertEqual(caught.exception.code, trash.FAILED)
|
||||
|
||||
def test_yes_with_the_source_still_present_is_failed(self):
|
||||
backend = self.backend(
|
||||
result=(True, os.path.join(self.home_trash, "a.png"), None)
|
||||
)
|
||||
with self.assertRaises(trash.TrashRefused) as caught:
|
||||
backend.move(self.file, [self.root])
|
||||
self.assertEqual(caught.exception.code, trash.FAILED)
|
||||
self.assertTrue(os.path.exists(self.file))
|
||||
|
||||
def test_yes_with_no_result_path_is_unconfirmed(self):
|
||||
def vanished(path):
|
||||
os.rename(path, os.path.join(self.tmp, "elsewhere.png"))
|
||||
return True, None, None
|
||||
|
||||
backend = self.backend(result=vanished)
|
||||
with self.assertRaises(trash.TrashUnconfirmed):
|
||||
backend.move(self.file, [self.root])
|
||||
|
||||
def test_yes_into_an_output_root_is_unconfirmed(self):
|
||||
backend = self.backend(
|
||||
result=self.moved_to(os.path.join(self.root, "x", "a.png"))
|
||||
)
|
||||
with self.assertRaises(trash.TrashUnconfirmed):
|
||||
backend.move(self.file, [self.root])
|
||||
|
||||
def test_yes_into_an_unplanned_folder_is_trashed_and_logged(self):
|
||||
other = os.path.join(self.tmp, "other-trash", "a.png")
|
||||
backend = self.backend(result=self.moved_to(other))
|
||||
with self.assertLogs("prompt_manager.trash", level="WARNING") as logs:
|
||||
label = backend.move(self.file, [self.root])
|
||||
self.assertEqual(label, os.path.realpath(os.path.dirname(other)))
|
||||
self.assertIn(other, "\n".join(logs.output))
|
||||
|
||||
|
||||
class IdentityTests(MacBackendTestCase):
|
||||
@unittest.skipUnless(hasattr(os, "symlink") and os.name == "posix", "symlinks")
|
||||
def test_root_given_through_a_symlink_still_contains_its_trash(self):
|
||||
alias = os.path.join(self.tmp, "home-alias")
|
||||
os.symlink(self.home, alias)
|
||||
self.assertEqual(
|
||||
self.backend().available(self.file, [alias]), trash.TRASH_IN_ROOT
|
||||
)
|
||||
|
||||
def test_missing_roots_are_ignored(self):
|
||||
missing = os.path.join(self.tmp, "nope")
|
||||
self.assertIsNone(self.backend().available(self.file, [missing, self.root]))
|
||||
|
||||
|
||||
class LoadingTests(unittest.TestCase):
|
||||
def test_default_backend_for_darwin_does_not_load_the_bridge(self):
|
||||
with mock.patch.object(trash_macos, "load_bridge") as loader:
|
||||
backend = trash.default_backend("darwin")
|
||||
self.assertIsInstance(backend, MacTrashBackend)
|
||||
loader.assert_not_called()
|
||||
|
||||
def test_failed_load_answers_no_trash_and_is_cached_and_logged_once(self):
|
||||
backend = MacTrashBackend(uid=501)
|
||||
with (
|
||||
mock.patch.object(
|
||||
trash_macos, "load_bridge", side_effect=OSError("no Foundation")
|
||||
) as loader,
|
||||
self.assertLogs("prompt_manager.trash", level="WARNING") as logs,
|
||||
):
|
||||
self.assertEqual(backend.available("/x/a.png", ["/x"]), trash.NO_TRASH)
|
||||
with self.assertRaises(trash.TrashRefused) as caught:
|
||||
backend.move("/x/a.png", ["/x"])
|
||||
self.assertEqual(caught.exception.code, trash.NO_TRASH)
|
||||
self.assertEqual(loader.call_count, 1)
|
||||
self.assertEqual(len(logs.output), 1)
|
||||
|
||||
def test_load_bridge_off_macos_raises(self):
|
||||
if sys.platform == "darwin":
|
||||
self.skipTest("the real bridge loads on macOS (native test covers it)")
|
||||
with self.assertRaises(OSError):
|
||||
trash_macos.load_bridge()
|
||||
|
||||
|
||||
class ErrorTextTests(unittest.TestCase):
|
||||
def test_detail_without_underlying(self):
|
||||
self.assertEqual(
|
||||
trash_macos.error_detail(NSErr(COCOA, 4)), "NSCocoaErrorDomain 4"
|
||||
)
|
||||
self.assertEqual(trash_macos.error_detail(None), "no NSError")
|
||||
|
||||
def test_refusal_keeps_detail(self):
|
||||
refused = trash.TrashRefused(trash.FAILED, "why")
|
||||
self.assertEqual(
|
||||
(refused.code, refused.detail, str(refused)), ("failed", "why", "why")
|
||||
)
|
||||
self.assertEqual(trash.TrashRefused(trash.FAILED).detail, "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,238 +0,0 @@
|
||||
"""Native proof of the macOS trash backend (#499 design §10.2).
|
||||
|
||||
Runs only on macOS with ``PM_NATIVE_TRASH=macos`` (the ``native-trash`` job in
|
||||
portability.yml), because it puts files into the real Trash of the user
|
||||
running it. With the variable set, a bridge that cannot load fails the test
|
||||
rather than skipping it. Files moved into a trash folder are left there: the
|
||||
runner is disposable and this test never deletes from a trash folder.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from utils import trash # noqa: E402
|
||||
|
||||
NATIVE = sys.platform == "darwin" and os.environ.get("PM_NATIVE_TRASH") == "macos"
|
||||
HDIUTIL_SECONDS = 60
|
||||
# Measured: macos-15-intel kept a fresh image busy after a trash (run 40954618922).
|
||||
DETACH_WAIT_SECONDS = 30
|
||||
|
||||
|
||||
class SpyBridge:
|
||||
"""The real bridge, recording every ``trash`` result."""
|
||||
|
||||
def __init__(self, real):
|
||||
self.real = real
|
||||
self.results = []
|
||||
|
||||
def facts(self, path):
|
||||
return self.real.facts(path)
|
||||
|
||||
def trash_dir(self, path):
|
||||
return self.real.trash_dir(path)
|
||||
|
||||
def trash(self, path):
|
||||
result = self.real.trash(path)
|
||||
self.results.append(result)
|
||||
return result
|
||||
|
||||
|
||||
def _hdiutil(*args):
|
||||
return subprocess.run(
|
||||
["hdiutil", *args], capture_output=True, text=True, timeout=HDIUTIL_SECONDS
|
||||
)
|
||||
|
||||
|
||||
def _our_open_files(mount):
|
||||
"""Files this process holds open under *mount* (``lsof -p``): the handle proof."""
|
||||
listed = subprocess.run(
|
||||
["lsof", "-Fn", "-p", str(os.getpid())],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=HDIUTIL_SECONDS,
|
||||
)
|
||||
prefix = os.path.realpath(mount) + os.sep
|
||||
return [
|
||||
line[1:]
|
||||
for line in listed.stdout.splitlines()
|
||||
if line.startswith("n") and os.path.realpath(line[1:]).startswith(prefix)
|
||||
]
|
||||
|
||||
|
||||
def _detach(mount):
|
||||
"""Detach *mount*; None on success, else why (after a forced detach).
|
||||
|
||||
This process must hold nothing under it. A volume macOS has just written a
|
||||
trash folder to can stay busy for a moment (Spotlight, fseventsd), so the
|
||||
plain detach is retried until the system releases it, within a bound.
|
||||
"""
|
||||
ours = _our_open_files(mount)
|
||||
if ours:
|
||||
_hdiutil("detach", mount, "-force")
|
||||
return f"this test still held files open under {mount}: {ours}"
|
||||
deadline = time.monotonic() + DETACH_WAIT_SECONDS
|
||||
while True:
|
||||
if not os.path.ismount(mount):
|
||||
return None # a "busy" detach can still finish after it returns
|
||||
done = _hdiutil("detach", mount)
|
||||
if done.returncode == 0:
|
||||
return None
|
||||
if time.monotonic() > deadline:
|
||||
holders = subprocess.run(
|
||||
["lsof", "+f", "--", mount],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=HDIUTIL_SECONDS,
|
||||
).stdout
|
||||
_hdiutil("detach", mount, "-force")
|
||||
return f"detach {mount}: {done.stderr.strip()}; held by: {holders!r}"
|
||||
time.sleep(1) # poll the volume's release (a state), never a fixed wait
|
||||
|
||||
|
||||
@unittest.skipUnless(NATIVE, "macOS native trash proof (PM_NATIVE_TRASH=macos)")
|
||||
class MacTrashNativeTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
from utils import trash_macos
|
||||
|
||||
self.trash_macos = trash_macos
|
||||
self.spy = SpyBridge(trash_macos.load_bridge()) # fails, never skips
|
||||
self.backend = trash_macos.MacTrashBackend(bridge=self.spy)
|
||||
self._tmp = tempfile.TemporaryDirectory(prefix="pm-native-trash-")
|
||||
self.tmp = os.path.realpath(self._tmp.name)
|
||||
self._mounts = []
|
||||
self._modes = []
|
||||
|
||||
def tearDown(self):
|
||||
failures = []
|
||||
try:
|
||||
for path, mode in reversed(self._modes):
|
||||
os.chmod(path, mode)
|
||||
for mount in reversed(self._mounts):
|
||||
problem = _detach(mount)
|
||||
if problem:
|
||||
failures.append(problem)
|
||||
finally:
|
||||
self._tmp.cleanup()
|
||||
self.assertEqual(failures, [])
|
||||
|
||||
def _file(self, folder, name="a.png", content=None):
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
path = os.path.join(folder, name)
|
||||
with open(path, "wb") as handle:
|
||||
handle.write(content or os.urandom(64))
|
||||
return path
|
||||
|
||||
def _image(self, name, fs="APFS"):
|
||||
image = os.path.join(self.tmp, name + ".dmg")
|
||||
mount = os.path.join(self.tmp, name + "-mnt")
|
||||
os.makedirs(mount)
|
||||
made = _hdiutil("create", "-size", "40m", "-fs", fs, "-volname", name, image)
|
||||
self.assertEqual(made.returncode, 0, made.stderr)
|
||||
attached = _hdiutil("attach", image, "-nobrowse", "-mountpoint", mount)
|
||||
self.assertEqual(attached.returncode, 0, attached.stderr)
|
||||
self._mounts.append(mount)
|
||||
return image, mount
|
||||
|
||||
def _assert_trashed(self, source, content, info, folder):
|
||||
self.assertFalse(os.path.lexists(source))
|
||||
ok, resulting, error = self.spy.results[-1]
|
||||
self.assertTrue(ok, error)
|
||||
self.assertEqual(os.path.realpath(os.path.dirname(resulting)), folder)
|
||||
moved = os.lstat(resulting)
|
||||
self.assertEqual((moved.st_dev, moved.st_ino), (info.st_dev, info.st_ino))
|
||||
with open(resulting, "rb") as handle:
|
||||
self.assertEqual(handle.read(), content)
|
||||
return resulting
|
||||
|
||||
def _assert_kept(self, path, content):
|
||||
with open(path, "rb") as handle:
|
||||
self.assertEqual(handle.read(), content)
|
||||
|
||||
def _assert_refused(self, path, roots, code):
|
||||
with open(path, "rb") as handle:
|
||||
content = handle.read()
|
||||
self.assertEqual(self.backend.available(path, roots), code)
|
||||
with self.assertRaises(trash.TrashRefused) as caught:
|
||||
self.backend.move(path, roots)
|
||||
self.assertEqual(caught.exception.code, code)
|
||||
self._assert_kept(path, content)
|
||||
|
||||
def test_n1_startup_volume_goes_to_the_home_trash(self):
|
||||
root = os.path.join(self.tmp, "output")
|
||||
source = self._file(root)
|
||||
with open(source, "rb") as handle:
|
||||
content = handle.read()
|
||||
info = os.lstat(source)
|
||||
self.assertIsNone(self.backend.available(source, [root]))
|
||||
self.assertEqual(self.backend.move(source, [root]), trash.HOME_TRASH_LABEL)
|
||||
home_trash = os.path.realpath(os.path.expanduser("~/.Trash"))
|
||||
self._assert_trashed(source, content, info, home_trash)
|
||||
|
||||
def test_n2_a_name_clash_never_replaces(self):
|
||||
first_root = os.path.join(self.tmp, "one")
|
||||
second_root = os.path.join(self.tmp, "two")
|
||||
first = self._file(first_root, "same.png", b"first")
|
||||
second = self._file(second_root, "same.png", b"second")
|
||||
self.backend.move(first, [first_root])
|
||||
self.backend.move(second, [second_root])
|
||||
(_, one, _), (_, two, _) = self.spy.results
|
||||
self.assertNotEqual(one, two)
|
||||
self._assert_kept(one, b"first")
|
||||
self._assert_kept(two, b"second")
|
||||
|
||||
def test_n3_fresh_external_volume_uses_its_own_trash(self):
|
||||
for fs in ("APFS", "MS-DOS FAT32"):
|
||||
with self.subTest(fs=fs):
|
||||
_image, mount = self._image("fresh" + fs.split()[0], fs)
|
||||
root = os.path.join(mount, "output")
|
||||
source = self._file(root)
|
||||
with open(source, "rb") as handle:
|
||||
content = handle.read()
|
||||
info = os.lstat(source)
|
||||
self.assertFalse(os.path.lexists(os.path.join(mount, ".Trashes")))
|
||||
self.assertIsNone(self.backend.available(source, [root]))
|
||||
label = self.backend.move(source, [root])
|
||||
uid = str(os.getuid())
|
||||
self.assertEqual(label, f".Trashes/{uid} on its drive")
|
||||
folder = os.path.realpath(os.path.join(mount, ".Trashes", uid))
|
||||
self._assert_trashed(source, content, info, folder)
|
||||
|
||||
def test_n4_volume_root_as_output_root_is_trash_in_root(self):
|
||||
_image, mount = self._image("rooted")
|
||||
source = self._file(os.path.join(mount, "output"))
|
||||
self._assert_refused(source, [mount], trash.TRASH_IN_ROOT)
|
||||
self.assertEqual(self.spy.results, [])
|
||||
|
||||
def test_n5_read_only_volume_is_no_trash(self):
|
||||
image, mount = self._image("readonly")
|
||||
source = self._file(mount, "ro.png", b"read-only bytes")
|
||||
self.assertIsNone(_detach(mount))
|
||||
self._mounts.remove(mount)
|
||||
attached = _hdiutil(
|
||||
"attach", image, "-nobrowse", "-readonly", "-mountpoint", mount
|
||||
)
|
||||
self.assertEqual(attached.returncode, 0, attached.stderr)
|
||||
self._mounts.append(mount)
|
||||
self._assert_refused(source, [os.path.join(mount, "output")], trash.NO_TRASH)
|
||||
|
||||
def test_n6_unwritable_folder_is_no_permission_and_macos_agrees(self):
|
||||
root = os.path.join(self.tmp, "locked")
|
||||
source = self._file(root, "p.png", b"kept")
|
||||
self._modes.append((root, 0o755))
|
||||
os.chmod(root, 0o555)
|
||||
self._assert_refused(source, [self.tmp], trash.NO_PERMISSION)
|
||||
self.assertEqual(self.spy.results, [])
|
||||
ok, _resulting, error = self.spy.real.trash(source)
|
||||
self.assertFalse(ok)
|
||||
self.assertEqual((error.domain, error.code), ("NSCocoaErrorDomain", 513))
|
||||
self._assert_kept(source, b"kept")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Vendored
-127
@@ -1,127 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { attachBrowserLogs, BROWSER_EXIT_PREFIX, createBrowserLogRecorder, isAbnormalExit, isTargetGone, parseExitLine,
|
||||
playwrightDebug } from '../../tools/vendor/browser-logs.mjs';
|
||||
|
||||
const debug = playwrightDebug();
|
||||
|
||||
/** Runs `body` with DEBUG set to `namespaces` and restores the caller's setting afterwards. */
|
||||
function withDebug(namespaces, body) {
|
||||
const saved = debug.disable();
|
||||
const savedLog = debug.log;
|
||||
if (namespaces) debug.enable(namespaces);
|
||||
try {
|
||||
return body();
|
||||
} finally {
|
||||
debug.log = savedLog;
|
||||
debug.disable();
|
||||
if (saved) debug.enable(saved);
|
||||
}
|
||||
}
|
||||
|
||||
const outputDir = mkdtempSync(join(tmpdir(), 'pm-browser-logs-'));
|
||||
test.after(() => rmSync(outputDir, { recursive: true, force: true }));
|
||||
|
||||
function testInfo({ status = 'failed', errors = [] } = {}) {
|
||||
const attachments = [];
|
||||
const dir = mkdtempSync(join(outputDir, 'test-'));
|
||||
return { status, expectedStatus: 'passed', errors, attachments, outputPath: name => join(dir, name),
|
||||
attach: async (name, options) => { attachments.push({ name, ...options }); } };
|
||||
}
|
||||
|
||||
test('parses the exit line Playwright logs for a browser process', () => {
|
||||
assert.deepEqual(parseExitLine('[pid=7124] <process did exit: exitCode=null, signal=SIGKILL> +21ms'),
|
||||
{ pid: 7124, exitCode: null, signal: 'SIGKILL' });
|
||||
assert.deepEqual(parseExitLine('[pid=9] <process did exit: exitCode=0, signal=null>'), { pid: 9, exitCode: 0, signal: null });
|
||||
assert.deepEqual(parseExitLine('[pid=9] <process did exit: exitCode=11, signal=null>'), { pid: 9, exitCode: 11, signal: null });
|
||||
assert.equal(parseExitLine('[pid=9][err] JavaScript warning'), null);
|
||||
assert.equal(isAbnormalExit({ pid: 9, exitCode: 0, signal: null }), false);
|
||||
assert.equal(isAbnormalExit({ pid: 9, exitCode: null, signal: 'SIGKILL' }), true);
|
||||
assert.equal(isAbnormalExit({ pid: 9, exitCode: 139, signal: null }), true);
|
||||
});
|
||||
|
||||
test('recognises the errors of a page, context or browser that went away', () => {
|
||||
for (const message of ['Error: browser.newContext: Target page, context or browser has been closed',
|
||||
'Protocol error (Page.navigate): Target closed', 'page.goto: Page crashed', 'Target crashed']) {
|
||||
assert.equal(isTargetGone(message), true, message);
|
||||
}
|
||||
for (const message of ['expect(locator).toBeVisible() failed', undefined, 'Test timeout of 30000ms exceeded.']) {
|
||||
assert.equal(isTargetGone(message), false, String(message));
|
||||
}
|
||||
});
|
||||
|
||||
test('records the browser channel into a bounded tail without printing it', () => withDebug('', () => {
|
||||
const printed = [];
|
||||
debug.log = (...args) => printed.push(args.join(' '));
|
||||
const written = [];
|
||||
const recorder = createBrowserLogRecorder({ debug, limit: 3, write: text => written.push(text) });
|
||||
const browser = debug('pw:browser');
|
||||
browser('<launched> pid=5');
|
||||
for (const n of [1, 2, 3, 4]) browser(`[pid=5][err] line ${n}`);
|
||||
assert.deepEqual(printed, [], 'the browser channel stays quiet when DEBUG did not ask for it');
|
||||
assert.match(recorder.tail(), /line 2[\s\S]*line 3[\s\S]*line 4/);
|
||||
assert.doesNotMatch(recorder.tail(), /line 1/);
|
||||
assert.deepEqual(written, []);
|
||||
assert.deepEqual(recorder.launches(), [5], 'launches outlive the bounded tail');
|
||||
browser('[pid=5] <process did exit: exitCode=null, signal=SIGKILL>');
|
||||
assert.deepEqual(recorder.exits(), [{ pid: 5, exitCode: null, signal: 'SIGKILL' }]);
|
||||
assert.deepEqual(written, [`${BROWSER_EXIT_PREFIX}browser pid=5 exited: exitCode=null, signal=SIGKILL\n`]);
|
||||
browser('[pid=6] <process did exit: exitCode=0, signal=null>');
|
||||
assert.equal(written.length, 1, 'a clean exit prints nothing');
|
||||
recorder.uninstall();
|
||||
assert.equal(debug.enabled('pw:browser'), false, 'uninstall restores the caller\'s DEBUG');
|
||||
}));
|
||||
|
||||
test('keeps printing the channels the user enabled, the browser one included', () => withDebug('pw:api,pw:browser', () => {
|
||||
const printed = [];
|
||||
debug.log = function (...args) { printed.push(`${this.namespace} ${args.join(' ')}`); };
|
||||
const recorder = createBrowserLogRecorder({ debug, write: () => {} });
|
||||
debug('pw:api')('call');
|
||||
debug('pw:browser')('[pid=1][out] hello');
|
||||
debug('pw:protocol')('not enabled');
|
||||
assert.equal(printed.length, 2);
|
||||
assert.match(printed[0], /^pw:api /);
|
||||
assert.match(printed[1], /^pw:browser /);
|
||||
assert.doesNotMatch(recorder.tail(), /call/);
|
||||
recorder.uninstall();
|
||||
assert.equal(debug.enabled('pw:api'), true);
|
||||
assert.equal(debug.enabled('pw:browser'), true);
|
||||
}));
|
||||
|
||||
test('attaches the tail and the exit only to a test whose target went away', async () => withDebug('', async () => {
|
||||
debug.log = () => {};
|
||||
const recorder = createBrowserLogRecorder({ debug, write: () => {} });
|
||||
const browser = debug('pw:browser');
|
||||
browser('\u001b[30m[pid=8][err] Crash Annotation GraphicsCriticalError\u001b[0m');
|
||||
browser('[pid=8] <process did exit: exitCode=null, signal=SIGKILL>');
|
||||
|
||||
const passed = testInfo({ status: 'passed' });
|
||||
assert.equal(await attachBrowserLogs(passed, recorder), false);
|
||||
const assertion = testInfo({ errors: [{ message: 'expect(locator).toBeVisible() failed' }] });
|
||||
assert.equal(await attachBrowserLogs(assertion, recorder), false);
|
||||
assert.deepEqual(assertion.attachments, []);
|
||||
|
||||
const closed = testInfo({ errors: [{ message: 'Error: browser.newContext: Target page, context or browser has been closed' }] });
|
||||
assert.equal(await attachBrowserLogs(closed, recorder), true);
|
||||
assert.equal(closed.attachments.length, 1);
|
||||
const [{ name, path, contentType }] = closed.attachments;
|
||||
assert.equal(name, 'browser-stderr');
|
||||
assert.equal(contentType, 'text/plain');
|
||||
const body = readFileSync(path, 'utf8');
|
||||
assert.match(body, /^browser pid=8 exited: exitCode=null, signal=SIGKILL\n/);
|
||||
assert.match(body, /\[pid=8\]\[err\] Crash Annotation GraphicsCriticalError/);
|
||||
assert.doesNotMatch(body, /\u001b/, 'the attached log has no terminal colours');
|
||||
recorder.uninstall();
|
||||
}));
|
||||
|
||||
test('says so when the worker logged no abnormal exit', async () => withDebug('', async () => {
|
||||
debug.log = () => {};
|
||||
const recorder = createBrowserLogRecorder({ debug, write: () => {} });
|
||||
const closed = testInfo({ errors: [{ message: 'x', stack: 'Error: Target closed\n at y' }] });
|
||||
assert.equal(await attachBrowserLogs(closed, recorder), true);
|
||||
assert.match(readFileSync(closed.attachments[0].path, 'utf8'), /^no abnormal browser exit was logged in this worker\n/);
|
||||
recorder.uninstall();
|
||||
}));
|
||||
Vendored
-14
@@ -353,20 +353,6 @@ test('Windows and macOS run 3.12-3.14 on main, nightly and on demand, never on a
|
||||
assert.doesNotMatch(text, /cancel-in-progress: true|cancel-in-progress: \$/, 'no portability run is cancelled');
|
||||
});
|
||||
|
||||
// #499: the native trash proof runs on both macOS CPUs, apart from the suite so a red suite never skips it.
|
||||
test('portability runs the native trash proof on arm64 and Intel macOS', () => {
|
||||
const body = jobs(readWorkflow('portability.yml')).get('native-trash');
|
||||
assert.ok(body, 'portability.yml has a native-trash job');
|
||||
assert.deepEqual(matrixList(body, 'os'), ['macos-latest', 'macos-15-intel']);
|
||||
assert.match(body, /^ {6}fail-fast: false$/m);
|
||||
assert.doesNotMatch(body, /^ {4}needs:/m, 'it does not wait for the suite job');
|
||||
assert.match(body, /^ {10}PM_NATIVE_TRASH: macos$/m);
|
||||
assert.match(body, /^ {8}run: python -m unittest tests\.test_trash_macos_native tests\.test_trash_macos -v$/m);
|
||||
const jobMinutes = Number(/^ {4}timeout-minutes: (\d+)$/m.exec(body)[1]);
|
||||
const stepMinutes = Number(/^ {8}timeout-minutes: (\d+)$/m.exec(body)?.[1]);
|
||||
assert.ok(stepMinutes > 0 && jobMinutes - stepMinutes >= 2, 'the proof step stops before the job limit');
|
||||
});
|
||||
|
||||
// #493: the portability limit follows the rule in its comment: ceil(1.5 x the slowest of three
|
||||
// green main runs) + 3 minutes, never under 5, with those runs and times written above jobs:.
|
||||
// The times are whole-job wall times. That the runs were green and on main is the author's word.
|
||||
|
||||
Vendored
-91
@@ -1,91 +0,0 @@
|
||||
// What a browser printed before it died (#513). Playwright writes a browser's stderr, its launch and its exit
|
||||
// (`<process did exit: exitCode=…, signal=…>`) only to its `pw:browser` debug channel, which is off unless DEBUG is
|
||||
// set, so a browser that dies between two tests leaves the next test only "Target page, context or browser has been
|
||||
// closed". A worker records that channel into a bounded tail, prints one line when a browser exits abnormally and
|
||||
// attaches the tail to a test that fails on a closed or crashed target. Nothing is retried or hidden.
|
||||
import { createRequire } from 'node:module';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { format, stripVTControlCharacters } from 'node:util';
|
||||
|
||||
const BROWSER_NAMESPACE = 'pw:browser';
|
||||
export const BROWSER_LOG_LIMIT = 300;
|
||||
export const BROWSER_EXIT_PREFIX = 'pm-browser-exit| ';
|
||||
const LAUNCH_LINE = /<launched> pid=(\d+)/;
|
||||
const EXIT_LINE = /\[pid=(\d+)\] <process did exit: exitCode=([^,]+), signal=([^>]+)>/;
|
||||
// The client's TargetClosedError, a protocol error of a closed or crashed target, and a crashed page.
|
||||
const TARGET_GONE = /Target page, context or browser has been closed|Target (?:closed|crashed)|Browser closed|browser has disconnected|Page crashed/i;
|
||||
|
||||
/** The `debug` module Playwright logs through (the instance this worker's tests launch browsers with). */
|
||||
export function playwrightDebug() {
|
||||
return createRequire(import.meta.url)('playwright-core/lib/utilsBundle').debug;
|
||||
}
|
||||
|
||||
/** The pid, exit code and signal of a `process did exit` line, or null. */
|
||||
export function parseExitLine(line) {
|
||||
const match = EXIT_LINE.exec(line);
|
||||
if (!match) return null;
|
||||
const value = text => (text === 'null' ? null : text);
|
||||
const code = value(match[2]);
|
||||
return { pid: Number(match[1]), exitCode: code === null ? null : Number(code), signal: value(match[3]) };
|
||||
}
|
||||
|
||||
export const isAbnormalExit = exit => exit !== null && (exit.exitCode !== 0 || exit.signal !== null);
|
||||
|
||||
/** True for an error message that says the page, context or browser under the test went away. */
|
||||
export const isTargetGone = message => TARGET_GONE.test(String(message ?? ''));
|
||||
|
||||
/**
|
||||
* Records `pw:browser` into a tail of at most `limit` lines. Other debug channels keep printing as before; the
|
||||
* browser channel prints only when the user's own DEBUG asked for it. `write` gets one line per abnormal exit.
|
||||
*/
|
||||
export function createBrowserLogRecorder({ debug = playwrightDebug(), limit = BROWSER_LOG_LIMIT,
|
||||
write = text => process.stderr.write(text) } = {}) {
|
||||
const lines = [];
|
||||
const launches = [];
|
||||
const exits = [];
|
||||
const userWantsBrowser = debug.enabled(BROWSER_NAMESPACE);
|
||||
const previous = debug.disable();
|
||||
const original = debug.log;
|
||||
debug.log = function record(...args) {
|
||||
if (this?.namespace !== BROWSER_NAMESPACE || userWantsBrowser) {
|
||||
(original ?? (() => {})).apply(this, args);
|
||||
}
|
||||
if (this?.namespace !== BROWSER_NAMESPACE) return;
|
||||
const line = stripVTControlCharacters(format(...args));
|
||||
lines.push(line);
|
||||
if (lines.length > limit) lines.splice(0, lines.length - limit);
|
||||
const launched = LAUNCH_LINE.exec(line);
|
||||
if (launched) launches.push(Number(launched[1]));
|
||||
const exit = parseExitLine(line);
|
||||
if (!exit) return;
|
||||
exits.push(exit);
|
||||
if (isAbnormalExit(exit)) {
|
||||
write(`${BROWSER_EXIT_PREFIX}browser pid=${exit.pid} exited: exitCode=${exit.exitCode}, signal=${exit.signal}\n`);
|
||||
}
|
||||
};
|
||||
debug.enable([previous, BROWSER_NAMESPACE].filter(Boolean).join(','));
|
||||
return {
|
||||
tail: () => lines.join('\n'),
|
||||
launches: () => [...launches],
|
||||
exits: () => [...exits],
|
||||
uninstall() {
|
||||
debug.log = original;
|
||||
debug.disable();
|
||||
if (previous) debug.enable(previous);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Attaches the browser log tail to a test that failed because its page, context or browser went away. */
|
||||
export async function attachBrowserLogs(testInfo, recorder) {
|
||||
if (testInfo.status === testInfo.expectedStatus) return false;
|
||||
if (!testInfo.errors.some(error => isTargetGone(error.message) || isTargetGone(error.stack))) return false;
|
||||
const exits = recorder.exits().filter(isAbnormalExit)
|
||||
.map(exit => `browser pid=${exit.pid} exited: exitCode=${exit.exitCode}, signal=${exit.signal}`);
|
||||
const summary = exits.length ? exits.join('\n') : 'no abnormal browser exit was logged in this worker';
|
||||
// A file, so the log travels with the uploaded test-results (the list reporter shortens a long body).
|
||||
const path = testInfo.outputPath('browser-stderr.txt');
|
||||
await writeFile(path, `${summary}\n--- last ${BROWSER_LOG_LIMIT} pw:browser lines ---\n${recorder.tail()}\n`);
|
||||
await testInfo.attach('browser-stderr', { path, contentType: 'text/plain' });
|
||||
return true;
|
||||
}
|
||||
+7
-41
@@ -14,11 +14,9 @@ same mount, else the file's mount's ``.Trash/$uid`` (only under a real, sticky
|
||||
``.Trash``) or ``.Trash-$uid``. Names are reserved by creating the
|
||||
``.trashinfo`` with ``O_EXCL`` and the file is moved with
|
||||
``renameat2(RENAME_NOREPLACE)``; without that call the move is refused.
|
||||
macOS uses :class:`utils.trash_macos.MacTrashBackend` (#499): NSFileManager's
|
||||
``trashItemAtURL`` through ctypes, after refusing what it cannot show to be
|
||||
recoverable. Windows never uses the Recycle Bin (#500: files over its capacity
|
||||
are deleted permanently, undetectably), so it answers ``windows-recycle``
|
||||
through :class:`UnavailableBackend`.
|
||||
Windows never uses the Recycle Bin (#500: files over its capacity are deleted
|
||||
permanently, undetectably), so it answers ``windows-recycle``; macOS has no
|
||||
backend yet (#499). Both use :class:`UnavailableBackend`.
|
||||
|
||||
``BACKEND`` is the one replaceable seam; tests install a fake.
|
||||
"""
|
||||
@@ -39,22 +37,10 @@ TRASH_IN_ROOT = "trash-in-root"
|
||||
PATH_TOO_LONG = "path-too-long"
|
||||
LOCKED = "locked"
|
||||
FAILED = "failed"
|
||||
NO_PERMISSION = "no-permission"
|
||||
CLOUD_SYNC = "cloud-sync"
|
||||
# Windows never uses the Recycle Bin (#500): it can delete permanently.
|
||||
WINDOWS_RECYCLE = "windows-recycle"
|
||||
CODES = frozenset(
|
||||
{
|
||||
NO_TRASH,
|
||||
NETWORK,
|
||||
TRASH_IN_ROOT,
|
||||
PATH_TOO_LONG,
|
||||
LOCKED,
|
||||
FAILED,
|
||||
NO_PERMISSION,
|
||||
CLOUD_SYNC,
|
||||
WINDOWS_RECYCLE,
|
||||
}
|
||||
{NO_TRASH, NETWORK, TRASH_IN_ROOT, PATH_TOO_LONG, LOCKED, FAILED, WINDOWS_RECYCLE}
|
||||
)
|
||||
|
||||
HOME_TRASH_LABEL = "home trash"
|
||||
@@ -80,19 +66,6 @@ class TrashRefused(Exception):
|
||||
def __init__(self, code: str, detail: str = ""):
|
||||
super().__init__(detail or code)
|
||||
self.code = code
|
||||
self.detail = detail
|
||||
|
||||
|
||||
class TrashUnconfirmed(Exception):
|
||||
"""The system reported the file moved, but not to a place shown to be a trash.
|
||||
|
||||
The file is no longer where it was, so this is not a refusal: the caller
|
||||
keeps the file's records and stops (#499 design §7.4).
|
||||
"""
|
||||
|
||||
def __init__(self, detail: str):
|
||||
super().__init__(detail)
|
||||
self.detail = detail
|
||||
|
||||
|
||||
def parse_mountinfo(text: str) -> list[str]:
|
||||
@@ -380,21 +353,14 @@ class FreedesktopBackend:
|
||||
|
||||
|
||||
def default_backend(platform: str = sys.platform):
|
||||
"""The backend for *platform* (#480, #499, #500).
|
||||
|
||||
macOS gets :class:`utils.trash_macos.MacTrashBackend`, which loads its
|
||||
Foundation bridge only on first use (#499). Windows never uses the Recycle
|
||||
Bin (#500).
|
||||
"""
|
||||
if platform == "darwin":
|
||||
from . import trash_macos
|
||||
|
||||
return trash_macos.MacTrashBackend()
|
||||
"""The backend for *platform*; Windows and macOS have none yet (#480)."""
|
||||
if platform == "win32" or platform.startswith("cygwin"):
|
||||
# #500, ruling (a): Windows deletes files larger than the Recycle Bin's
|
||||
# capacity permanently, and IFileOperation's PreDeleteItem guard cannot
|
||||
# tell (native probe runs 40951037575, 40953347904).
|
||||
return UnavailableBackend(WINDOWS_RECYCLE)
|
||||
if platform == "darwin":
|
||||
return UnavailableBackend()
|
||||
return FreedesktopBackend()
|
||||
|
||||
|
||||
|
||||
@@ -1,489 +0,0 @@
|
||||
"""The macOS Trash backend (#499): NSFileManager through ctypes, or refuse.
|
||||
|
||||
``-[NSFileManager trashItemAtURL:resultingItemURL:error:]`` moves a file into
|
||||
its volume's trash folder (``~/.Trash`` on the startup volume,
|
||||
``<volume>/.Trashes/<uid>`` elsewhere) or fails with an ``NSError``; it renames
|
||||
name clashes itself and never replaces a file. Before calling it,
|
||||
:class:`MacTrashBackend` refuses what it cannot show to be recoverable:
|
||||
network volumes, read-only volumes, iCloud Drive and File Provider sync
|
||||
folders, a trash folder inside an output root, and files macOS would refuse
|
||||
for permission. Nothing here unlinks, copies or overwrites a file.
|
||||
|
||||
The :class:`Bridge` is the only code that touches the Objective-C runtime. It
|
||||
sends ten fixed messages, each through ``objc_msgSend`` cast to its exact
|
||||
prototype (required on arm64), checks every object for nil before a send (a
|
||||
nil can raise an Objective-C exception, which would abort the process), and
|
||||
runs each call inside its own autorelease pool. It is loaded lazily, on the
|
||||
first ``available``/``move``; when it cannot load, every file answers
|
||||
``no-trash`` as before. The facts behind these rules were measured on macOS
|
||||
14, 15 (x86_64) and 26 (docs/egm/499-macos-trash/design.md §4).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import logging
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
|
||||
from . import trash
|
||||
|
||||
logger = logging.getLogger("prompt_manager.trash")
|
||||
|
||||
COCOA = "NSCocoaErrorDomain"
|
||||
POSIX = "NSPOSIXErrorDomain"
|
||||
FEATURE_UNSUPPORTED = 3328
|
||||
_ENOENT = 2
|
||||
# Top-level Cocoa codes of a refused move (design §7.5); anything else is "failed".
|
||||
_MOVE_CODES = {
|
||||
FEATURE_UNSUPPORTED: trash.NO_TRASH,
|
||||
642: trash.NO_TRASH, # NSFileWriteVolumeReadOnlyError
|
||||
513: trash.NO_PERMISSION, # NSFileWriteNoPermissionError
|
||||
257: trash.NO_PERMISSION, # NSFileReadNoPermissionError
|
||||
}
|
||||
_LOCK_FLAGS = stat.UF_IMMUTABLE | stat.SF_IMMUTABLE | stat.UF_APPEND | stat.SF_APPEND
|
||||
_CLOUD_FOLDERS = (("Library", "Mobile Documents"), ("Library", "CloudStorage"))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NSErr:
|
||||
domain: str
|
||||
code: int
|
||||
under_domain: str | None = None
|
||||
under_code: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Facts:
|
||||
"""Resource values of a file's URL; ``None`` means the lookup failed or was nil."""
|
||||
|
||||
local: bool | None
|
||||
read_only: bool | None
|
||||
root_fs: bool | None
|
||||
volume: str | None
|
||||
ubiquitous: bool | None
|
||||
|
||||
|
||||
def error_detail(error: NSErr | None) -> str:
|
||||
"""``"<domain> <code> (<under_domain> <under_code>)"`` for the log."""
|
||||
if error is None:
|
||||
return "no NSError"
|
||||
text = f"{error.domain} {error.code}"
|
||||
if error.under_domain is not None:
|
||||
text += f" ({error.under_domain} {error.under_code})"
|
||||
return text
|
||||
|
||||
|
||||
def refusal_code(error: NSErr | None) -> str:
|
||||
if error is None or error.domain != COCOA:
|
||||
return trash.FAILED
|
||||
return _MOVE_CODES.get(error.code, trash.FAILED)
|
||||
|
||||
|
||||
def _within(path: str, folder: str) -> bool:
|
||||
path = os.path.normcase(path)
|
||||
folder = os.path.normcase(folder)
|
||||
trimmed = folder.rstrip("\\/") or folder
|
||||
return path == trimmed or path.startswith(trimmed.rstrip(os.sep) + os.sep)
|
||||
|
||||
|
||||
def _identity(path: str):
|
||||
info = os.stat(path)
|
||||
return info.st_dev, info.st_ino
|
||||
|
||||
|
||||
def inside_any_root(path: str, roots) -> bool:
|
||||
"""Whether *path* (existing or not) lies in an output root, by file identity.
|
||||
|
||||
Every existing folder from *path* up to the top is compared with the
|
||||
roots' ``(st_dev, st_ino)``, so case, NFC/NFD and symlinked spellings of a
|
||||
root all match.
|
||||
"""
|
||||
identities = set()
|
||||
for root in roots:
|
||||
try:
|
||||
identities.add(_identity(str(root)))
|
||||
except OSError:
|
||||
continue
|
||||
current = os.path.abspath(path)
|
||||
while True:
|
||||
try:
|
||||
if _identity(current) in identities:
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
parent = os.path.dirname(current)
|
||||
if parent == current:
|
||||
return False
|
||||
current = parent
|
||||
|
||||
|
||||
def _same_folder(left: str, right: str) -> bool:
|
||||
try:
|
||||
return _identity(left) == _identity(right)
|
||||
except OSError:
|
||||
return os.path.realpath(left) == os.path.realpath(right)
|
||||
|
||||
|
||||
class MacTrashBackend:
|
||||
"""``available``/``move`` for macOS; the bridge and file checks are seams."""
|
||||
|
||||
def __init__(
|
||||
self, bridge=None, uid=None, home=None, lstat=os.lstat, access=os.access
|
||||
):
|
||||
self._bridge = bridge
|
||||
self._load_failed = False
|
||||
self._lock = threading.Lock()
|
||||
if uid is None:
|
||||
uid = os.getuid() if hasattr(os, "getuid") else -1
|
||||
self._uid = uid
|
||||
self._home = home
|
||||
self._lstat = lstat
|
||||
self._access = access
|
||||
|
||||
def _get_bridge(self):
|
||||
if self._bridge is None and not self._load_failed:
|
||||
with self._lock:
|
||||
if self._bridge is None and not self._load_failed:
|
||||
try:
|
||||
self._bridge = load_bridge()
|
||||
except (OSError, AttributeError, ValueError) as error:
|
||||
self._load_failed = True
|
||||
logger.warning("The macOS trash is unavailable: %s", error)
|
||||
if self._bridge is None:
|
||||
raise trash.TrashRefused(trash.NO_TRASH, "Foundation bridge not loaded")
|
||||
return self._bridge
|
||||
|
||||
def _home_dir(self) -> str:
|
||||
return os.path.realpath(self._home or os.path.expanduser("~"))
|
||||
|
||||
def _plan(self, path: str, roots):
|
||||
"""``(trash folder, facts)`` for *path*; raises TrashRefused (design §7.2)."""
|
||||
bridge = self._get_bridge()
|
||||
real = os.path.realpath(path)
|
||||
facts = bridge.facts(real)
|
||||
if facts.local is not True:
|
||||
raise trash.TrashRefused(trash.NETWORK)
|
||||
if facts.read_only is not False:
|
||||
raise trash.TrashRefused(trash.NO_TRASH, "read-only volume")
|
||||
home = self._home_dir()
|
||||
if facts.ubiquitous is True or any(
|
||||
_within(real, os.path.join(home, *parts)) for parts in _CLOUD_FOLDERS
|
||||
):
|
||||
raise trash.TrashRefused(trash.CLOUD_SYNC)
|
||||
folder, error = bridge.trash_dir(real)
|
||||
if folder is None:
|
||||
folder = self._predict(facts, error)
|
||||
if inside_any_root(folder, roots):
|
||||
raise trash.TrashRefused(trash.TRASH_IN_ROOT)
|
||||
self._check_permission(real)
|
||||
return folder, facts
|
||||
|
||||
def _predict(self, facts: Facts, error: NSErr | None) -> str:
|
||||
"""``<volume>/.Trashes/<uid>`` for a volume whose trash is not made yet."""
|
||||
missing = (
|
||||
error is not None
|
||||
and error.domain == COCOA
|
||||
and error.code == FEATURE_UNSUPPORTED
|
||||
and error.under_domain == POSIX
|
||||
and error.under_code == _ENOENT
|
||||
)
|
||||
if not missing or facts.root_fs is not False or not facts.volume:
|
||||
raise trash.TrashRefused(trash.NO_TRASH, error_detail(error))
|
||||
trashes = os.path.join(facts.volume, ".Trashes")
|
||||
try:
|
||||
info = self._lstat(trashes)
|
||||
except FileNotFoundError:
|
||||
info = None
|
||||
except OSError as failure:
|
||||
raise trash.TrashRefused(trash.NO_TRASH, str(failure)) from failure
|
||||
if info is not None and not stat.S_ISDIR(info.st_mode):
|
||||
raise trash.TrashRefused(trash.NO_TRASH, ".Trashes is not a folder")
|
||||
return os.path.join(trashes, str(self._uid))
|
||||
|
||||
def _check_permission(self, real: str) -> None:
|
||||
parent = os.path.dirname(real)
|
||||
if not self._access(parent, os.W_OK | os.X_OK):
|
||||
raise trash.TrashRefused(trash.NO_PERMISSION, "folder not writable")
|
||||
try:
|
||||
file_info = self._lstat(real)
|
||||
parent_info = self._lstat(parent)
|
||||
except OSError as failure:
|
||||
raise trash.TrashRefused(trash.FAILED, str(failure)) from failure
|
||||
for info in (file_info, parent_info):
|
||||
if getattr(info, "st_flags", 0) & _LOCK_FLAGS:
|
||||
raise trash.TrashRefused(trash.NO_PERMISSION, "immutable flag")
|
||||
if parent_info.st_mode & stat.S_ISVTX and file_info.st_uid != self._uid:
|
||||
raise trash.TrashRefused(trash.NO_PERMISSION, "sticky folder, foreign file")
|
||||
|
||||
def _label(self, folder: str, facts: Facts) -> str:
|
||||
if _same_folder(folder, os.path.join(self._home_dir(), ".Trash")):
|
||||
return trash.HOME_TRASH_LABEL
|
||||
real = os.path.realpath(folder)
|
||||
volume = os.path.realpath(facts.volume) if facts.volume else None
|
||||
if volume and volume != os.path.realpath(os.sep) and _within(real, volume):
|
||||
relative = os.path.relpath(real, volume).replace(os.sep, "/")
|
||||
return f"{relative} on its drive"
|
||||
return real
|
||||
|
||||
def available(self, path: str, roots) -> str | None:
|
||||
"""None when *path* can be trashed, else the refusal code (no writes)."""
|
||||
try:
|
||||
self._plan(path, roots)
|
||||
except trash.TrashRefused as refused:
|
||||
return refused.code
|
||||
except OSError:
|
||||
return trash.NO_TRASH
|
||||
return None
|
||||
|
||||
def move(self, path: str, roots) -> str:
|
||||
"""Move *path* into its trash folder and return the label (design §7.4)."""
|
||||
try:
|
||||
planned, facts = self._plan(path, roots)
|
||||
except OSError as failure:
|
||||
raise trash.TrashRefused(trash.NO_TRASH, str(failure)) from failure
|
||||
real = os.path.realpath(path)
|
||||
ok, resulting, error = self._get_bridge().trash(real)
|
||||
if not ok:
|
||||
raise trash.TrashRefused(refusal_code(error), error_detail(error))
|
||||
if os.path.lexists(real):
|
||||
raise trash.TrashRefused(trash.FAILED, "reported moved, still in place")
|
||||
if not resulting or inside_any_root(os.path.dirname(resulting), roots):
|
||||
raise trash.TrashUnconfirmed(f"{real} -> {resulting!r}")
|
||||
folder = os.path.dirname(resulting)
|
||||
if not _same_folder(folder, planned):
|
||||
logger.warning(
|
||||
"macOS trashed %s to %s, not the planned %s", real, resulting, planned
|
||||
)
|
||||
return self._label(folder, facts)
|
||||
|
||||
|
||||
# ── the Foundation bridge ────────────────────────────────────────────────
|
||||
|
||||
_NS_TRASH_DIRECTORY = 102
|
||||
_NS_USER_DOMAIN_MASK = 1
|
||||
_LIBOBJC = "/usr/lib/libobjc.A.dylib"
|
||||
_FOUNDATION = "/System/Library/Frameworks/Foundation.framework/Foundation"
|
||||
_KEYS = (
|
||||
"NSURLVolumeIsLocalKey",
|
||||
"NSURLVolumeIsReadOnlyKey",
|
||||
"NSURLVolumeIsRootFileSystemKey",
|
||||
"NSURLVolumeURLKey",
|
||||
"NSURLIsUbiquitousItemKey",
|
||||
"NSUnderlyingErrorKey",
|
||||
)
|
||||
_REQUIRED_SELECTORS = (
|
||||
("NSURL", "fileURLWithFileSystemRepresentation:isDirectory:relativeToURL:"),
|
||||
("NSURL", "getResourceValue:forKey:error:"),
|
||||
("NSFileManager", "URLForDirectory:inDomain:appropriateForURL:create:error:"),
|
||||
("NSFileManager", "trashItemAtURL:resultingItemURL:error:"),
|
||||
)
|
||||
|
||||
|
||||
class _Nil(Exception):
|
||||
"""An Objective-C object was nil where a send needs one."""
|
||||
|
||||
|
||||
class Bridge: # pragma: no cover - macOS only; the native-trash job runs it
|
||||
"""Foundation through ctypes: ``facts``, ``trash_dir`` and ``trash`` (design §6)."""
|
||||
|
||||
def __init__(self):
|
||||
if sys.platform != "darwin":
|
||||
raise OSError("the Foundation bridge exists only on macOS")
|
||||
objc = ctypes.CDLL(_LIBOBJC)
|
||||
foundation = ctypes.CDLL(_FOUNDATION)
|
||||
void_p, char_p, byte = ctypes.c_void_p, ctypes.c_char_p, ctypes.c_byte
|
||||
objc.objc_getClass.argtypes = [char_p]
|
||||
objc.objc_getClass.restype = void_p
|
||||
objc.sel_registerName.argtypes = [char_p]
|
||||
objc.sel_registerName.restype = void_p
|
||||
objc.objc_autoreleasePoolPush.argtypes = []
|
||||
objc.objc_autoreleasePoolPush.restype = void_p
|
||||
objc.objc_autoreleasePoolPop.argtypes = [void_p]
|
||||
objc.objc_autoreleasePoolPop.restype = None
|
||||
self._objc = objc
|
||||
address = ctypes.cast(objc.objc_msgSend, void_p).value
|
||||
|
||||
def send(restype, *argtypes):
|
||||
return ctypes.CFUNCTYPE(restype, void_p, void_p, *argtypes)(address)
|
||||
|
||||
out = ctypes.POINTER(void_p)
|
||||
self._send_id = send(void_p)
|
||||
self._send_responds = send(byte, void_p)
|
||||
self._send_url = send(void_p, char_p, byte, void_p)
|
||||
self._send_resource = send(byte, out, void_p, out)
|
||||
self._send_bool = send(byte)
|
||||
self._send_dir = send(void_p, ctypes.c_ulong, ctypes.c_ulong, void_p, byte, out)
|
||||
self._send_trash = send(byte, void_p, out, out)
|
||||
self._send_cstr = send(char_p)
|
||||
self._send_long = send(ctypes.c_long)
|
||||
self._send_object = send(void_p, void_p)
|
||||
self._keys = {}
|
||||
for name in _KEYS:
|
||||
value = void_p.in_dll(foundation, name).value
|
||||
if not value:
|
||||
raise ValueError(f"Foundation constant {name} is nil")
|
||||
self._keys[name] = value
|
||||
for class_name, selector in _REQUIRED_SELECTORS:
|
||||
if not self._send_responds(
|
||||
self._class(class_name),
|
||||
self._sel("respondsToSelector:"),
|
||||
self._sel(selector),
|
||||
) and not self._instances_respond(class_name, selector):
|
||||
raise ValueError(f"{class_name} does not respond to {selector}")
|
||||
|
||||
# helpers (call inside a pool) ------------------------------------------------
|
||||
|
||||
def _class(self, name: str):
|
||||
value = self._objc.objc_getClass(name.encode())
|
||||
if not value:
|
||||
raise ValueError(f"no Objective-C class {name}")
|
||||
return value
|
||||
|
||||
def _sel(self, name: str):
|
||||
return self._objc.sel_registerName(name.encode())
|
||||
|
||||
def _instances_respond(self, class_name: str, selector: str) -> bool:
|
||||
return (
|
||||
self._send_responds(
|
||||
self._class(class_name),
|
||||
self._sel("instancesRespondToSelector:"),
|
||||
self._sel(selector),
|
||||
)
|
||||
!= 0
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _need(value):
|
||||
if not value:
|
||||
raise _Nil()
|
||||
return value
|
||||
|
||||
def _manager(self):
|
||||
return self._need(
|
||||
self._send_id(self._class("NSFileManager"), self._sel("defaultManager"))
|
||||
)
|
||||
|
||||
def _url(self, path: str):
|
||||
return self._need(
|
||||
self._send_url(
|
||||
self._class("NSURL"),
|
||||
self._sel(
|
||||
"fileURLWithFileSystemRepresentation:isDirectory:relativeToURL:"
|
||||
),
|
||||
os.fsencode(path),
|
||||
0,
|
||||
None,
|
||||
)
|
||||
)
|
||||
|
||||
def _path(self, url) -> str | None:
|
||||
if not url:
|
||||
return None
|
||||
raw = self._send_cstr(url, self._sel("fileSystemRepresentation"))
|
||||
return os.fsdecode(raw) if raw else None
|
||||
|
||||
def _text(self, obj) -> str | None:
|
||||
if not obj:
|
||||
return None
|
||||
raw = self._send_cstr(obj, self._sel("UTF8String"))
|
||||
return raw.decode("utf-8", "replace") if raw else None
|
||||
|
||||
def _error(self, error) -> NSErr | None:
|
||||
if not error:
|
||||
return None
|
||||
domain = self._text(self._send_id(error, self._sel("domain"))) or "?"
|
||||
code = self._send_long(error, self._sel("code"))
|
||||
under_domain = under_code = None
|
||||
info = self._send_id(error, self._sel("userInfo"))
|
||||
if info:
|
||||
under = self._send_object(
|
||||
info, self._sel("objectForKey:"), self._keys["NSUnderlyingErrorKey"]
|
||||
)
|
||||
if under:
|
||||
under_domain = self._text(self._send_id(under, self._sel("domain")))
|
||||
under_code = self._send_long(under, self._sel("code"))
|
||||
return NSErr(domain, code, under_domain, under_code)
|
||||
|
||||
def _resource(self, url, key_name: str):
|
||||
value, error = ctypes.c_void_p(), ctypes.c_void_p()
|
||||
ok = self._send_resource(
|
||||
url,
|
||||
self._sel("getResourceValue:forKey:error:"),
|
||||
ctypes.byref(value),
|
||||
self._keys[key_name],
|
||||
ctypes.byref(error),
|
||||
)
|
||||
return value.value if ok != 0 and value.value else None
|
||||
|
||||
def _flag(self, url, key_name: str) -> bool | None:
|
||||
value = self._resource(url, key_name)
|
||||
if value is None:
|
||||
return None
|
||||
return self._send_bool(value, self._sel("boolValue")) != 0
|
||||
|
||||
def _pooled(self, work):
|
||||
pool = self._objc.objc_autoreleasePoolPush()
|
||||
try:
|
||||
return work()
|
||||
except _Nil as nil:
|
||||
raise trash.TrashRefused(trash.NO_TRASH, "nil Objective-C object") from nil
|
||||
finally:
|
||||
self._objc.objc_autoreleasePoolPop(pool)
|
||||
|
||||
# the bridge API (design §6.3) ----------------------------------------------
|
||||
|
||||
def facts(self, path: str) -> Facts:
|
||||
def work():
|
||||
url = self._url(path)
|
||||
return Facts(
|
||||
local=self._flag(url, "NSURLVolumeIsLocalKey"),
|
||||
read_only=self._flag(url, "NSURLVolumeIsReadOnlyKey"),
|
||||
root_fs=self._flag(url, "NSURLVolumeIsRootFileSystemKey"),
|
||||
volume=self._path(self._resource(url, "NSURLVolumeURLKey")),
|
||||
ubiquitous=self._flag(url, "NSURLIsUbiquitousItemKey"),
|
||||
)
|
||||
|
||||
return self._pooled(work)
|
||||
|
||||
def trash_dir(self, path: str):
|
||||
def work():
|
||||
error = ctypes.c_void_p()
|
||||
folder = self._send_dir(
|
||||
self._manager(),
|
||||
self._sel("URLForDirectory:inDomain:appropriateForURL:create:error:"),
|
||||
_NS_TRASH_DIRECTORY,
|
||||
_NS_USER_DOMAIN_MASK,
|
||||
self._url(path),
|
||||
0,
|
||||
ctypes.byref(error),
|
||||
)
|
||||
if folder:
|
||||
return self._path(folder), None
|
||||
return None, self._error(error.value)
|
||||
|
||||
return self._pooled(work)
|
||||
|
||||
def trash(self, path: str):
|
||||
def work():
|
||||
resulting, error = ctypes.c_void_p(), ctypes.c_void_p()
|
||||
ok = self._send_trash(
|
||||
self._manager(),
|
||||
self._sel("trashItemAtURL:resultingItemURL:error:"),
|
||||
self._url(path),
|
||||
ctypes.byref(resulting),
|
||||
ctypes.byref(error),
|
||||
)
|
||||
if ok != 0:
|
||||
return True, self._path(resulting.value), None
|
||||
return False, None, self._error(error.value)
|
||||
|
||||
return self._pooled(work)
|
||||
|
||||
|
||||
def load_bridge() -> Bridge:
|
||||
"""The real Foundation bridge; raises OSError/ValueError where it cannot load."""
|
||||
return Bridge()
|
||||
@@ -317,9 +317,6 @@ export function createImageTaggingController({api, target, source = 'images', on
|
||||
// A save in flight: closing hands the capture release to its completion.
|
||||
let saving = false;
|
||||
let observedOnce = false;
|
||||
// #515: the user typed since the last observation started (or before the first one answered), so the answer
|
||||
// keeps that text.
|
||||
let typed = false;
|
||||
|
||||
function set(patch) {
|
||||
if (closed) return;
|
||||
@@ -348,8 +345,7 @@ export function createImageTaggingController({api, target, source = 'images', on
|
||||
async function open({keepDraft = false} = {}) {
|
||||
if (closed) return;
|
||||
const serial = ++openSerial;
|
||||
// A reopen after an observation starts from its answer; text typed before any observation is kept (#515).
|
||||
if (observedOnce) typed = false;
|
||||
const draft = keepDraft ? state.manual : null;
|
||||
const seenRevision = state.tagRevision;
|
||||
stopGenerating();
|
||||
releaseCapture();
|
||||
@@ -389,19 +385,15 @@ export function createImageTaggingController({api, target, source = 'images', on
|
||||
const savedModel = !chosen && !modelPicked && read.model ? {model: read.model} : {};
|
||||
observedOnce = true;
|
||||
const {width, height, media_type: mediaType, preview_url: previewUrl, generate} = value.capture;
|
||||
// The draft is read now, so text typed while observing is kept (#515). A kept draft stays as typed unless
|
||||
// another writer changed the set meanwhile: then the set leads it (I2). Text typed before the user saw any
|
||||
// saved tags follows them, so a Save never drops tags it never showed.
|
||||
const draft = keepDraft || typed ? state.manual : null;
|
||||
const changed = keepDraft && value.tag_revision !== seenRevision;
|
||||
const unseen = draft !== null && !keepDraft;
|
||||
// A kept draft stays as typed unless another writer changed the set meanwhile: then the set leads it (I2).
|
||||
const changed = draft !== null && value.tag_revision !== seenRevision;
|
||||
const reason = generate.supported ? '' : (generate.reason || '');
|
||||
set({
|
||||
phase: 'ready',
|
||||
image: {filename: value.filename, title: value.title, width, height, mediaType, previewUrl,
|
||||
dimensions: width && height ? `${width} × ${height}` : null},
|
||||
savedTags: value.tags.slice(), tagRevision: value.tag_revision,
|
||||
manual: draft === null ? value.tags.join(', ') : changed || unseen ? mergeCurrent(value.tags, draft) : draft,
|
||||
manual: draft === null ? value.tags.join(', ') : changed ? mergeCurrent(value.tags, draft) : draft,
|
||||
draftRevision: state.draftRevision + 1,
|
||||
...savedModel, options: {...defaults, ...chosen?.options}, optionsRevision: state.optionsRevision + 1,
|
||||
generateSupported: generate.supported, generateReason: generate.reason,
|
||||
@@ -410,7 +402,6 @@ export function createImageTaggingController({api, target, source = 'images', on
|
||||
}
|
||||
|
||||
function setManual(text) {
|
||||
typed = true;
|
||||
set({manual: typeof text === 'string' ? text : ''});
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,6 @@ export const COPY = Object.freeze({
|
||||
const count = value => (Number.isSafeInteger(value) && value >= 0 ? value : 0);
|
||||
const ids = value => (Array.isArray(value) ? value.filter(id => Number.isSafeInteger(id) && id > 0) : []);
|
||||
const were = (value, singular, plural) => `${value.toLocaleString('en-US')} ${value === 1 ? singular : plural}`;
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
|
||||
/** The preview's classes as counts and id lists, whatever else the answer holds. */
|
||||
export function reviewOf(preview) {
|
||||
@@ -44,43 +43,12 @@ export function reviewOf(preview) {
|
||||
alreadyArchived: count(preview?.already_archived), notFound: count(preview?.not_found),
|
||||
imageLinks: count(preview?.image_links), backup: preview?.backup === 'off' ? 'off' : 'on',
|
||||
disk: preview?.disk && typeof preview.disk === 'object' ? preview.disk : null,
|
||||
...pairedOf(preview),
|
||||
};
|
||||
}
|
||||
|
||||
/** #482: the archive ids that may be deleted permanently (each with its review digest), and the pair counts. */
|
||||
function pairedOf(preview) {
|
||||
const archive = new Set(ids(preview?.archive)), given = preview?.review_digests ?? {};
|
||||
const deletable = ids(preview?.deletable).filter(id => archive.has(id) && DIGEST.test(String(given?.[id] ?? '')));
|
||||
const counts = preview?.paired_counts ?? {};
|
||||
return {
|
||||
deletable, digests: Object.fromEntries(deletable.map(id => [id, given[id]])),
|
||||
pairedCounts: { pairs: count(counts.pairs), archivedPairs: count(counts.archived_pairs),
|
||||
variants: count(counts.variants), keptNegatives: count(counts.kept_negatives) },
|
||||
pairedImageLinks: count(preview?.paired_image_links), choice: 'archive',
|
||||
};
|
||||
}
|
||||
|
||||
/** #482: whether the review asks what to do with prompts in a saved pair. */
|
||||
export const hasPairedChoice = review => review.deletable.length > 0;
|
||||
|
||||
/** #482: the review with its saved-pair choice (`archive`, the default, or `delete`); the original is unchanged. */
|
||||
export function withChoice(review, choice) {
|
||||
return { ...review, choice: choice === 'delete' && hasPairedChoice(review) ? 'delete' : 'archive' };
|
||||
}
|
||||
|
||||
const permanent = review => review.choice === 'delete';
|
||||
/** What is deleted and archived: Delete permanently moves the deletable ids from archive to delete. */
|
||||
function plan(review) {
|
||||
if (!permanent(review)) return { plain: review.delete, paired: [], archive: review.archive };
|
||||
const chosen = new Set(review.deletable);
|
||||
return { plain: review.delete, paired: review.deletable, archive: review.archive.filter(id => !chosen.has(id)) };
|
||||
}
|
||||
|
||||
export function reviewTitle(review) {
|
||||
const { plain, paired, archive } = plan(review), deletes = plain.length + paired.length;
|
||||
if (!archive.length) return 'Delete selected prompts';
|
||||
if (!deletes) return 'Archive selected prompts';
|
||||
if (!review.archive.length) return 'Delete selected prompts';
|
||||
if (!review.delete.length) return 'Archive selected prompts';
|
||||
return 'Delete or archive selected prompts';
|
||||
}
|
||||
|
||||
@@ -102,90 +70,49 @@ export function archivedNoticeText(variantCount) {
|
||||
|
||||
/** The review's `[term, value]` rows (design §7.2); rows that do not apply are left out. */
|
||||
export function reviewGrid(review, titleOf = () => null) {
|
||||
if (permanent(review)) return permanentGrid(review, titleOf);
|
||||
const rows = [
|
||||
['Delete permanently', `${countOf(review.delete.length, 'prompt')} not in a saved pair`],
|
||||
// The archive reason is the saved pair (a pair's positive or negative), with or without negative variants.
|
||||
['Archive', `${countOf(review.archive.length, 'prompt')} in a saved pair: hidden, restorable`],
|
||||
];
|
||||
keptRows(rows, review, titleOf);
|
||||
if (review.delete.length) deleteRows(rows, review, review.imageLinks);
|
||||
return rows;
|
||||
}
|
||||
|
||||
function keptRows(rows, review, titleOf) {
|
||||
if (review.protected.length) {
|
||||
const names = protectedNames(review, titleOf);
|
||||
rows.push(['Protected (kept)', `${review.protected.length.toLocaleString('en-US')}${names ? `: ${names}` : ''}`]);
|
||||
}
|
||||
if (review.alreadyArchived) rows.push(['Already archived', review.alreadyArchived.toLocaleString('en-US')]);
|
||||
if (review.notFound) rows.push(['No longer exist', review.notFound.toLocaleString('en-US')]);
|
||||
}
|
||||
|
||||
function deleteRows(rows, review, images) {
|
||||
rows.push(['Image links removed', `${countOf(images, 'image')} will no longer show these prompts (files stay on disk)`]);
|
||||
rows.push(['Backup', review.backup === 'on' ? 'Create and verify before deleting'
|
||||
: 'Off: no safety backup (System › Backup & export › Recovery policy)']);
|
||||
}
|
||||
|
||||
/** #482: the grid when Delete permanently is chosen. */
|
||||
function permanentGrid(review, titleOf) {
|
||||
const { plain, paired, archive } = plan(review), counts = review.pairedCounts;
|
||||
const parts = [plain.length && `${plain.length.toLocaleString('en-US')} not in a saved pair`,
|
||||
`${paired.length.toLocaleString('en-US')} in a saved pair`].filter(Boolean);
|
||||
const total = plain.length + paired.length;
|
||||
const deleted = plain.length ? `${countOf(total, 'prompt')}: ${parts.join(', ')}` : `${countOf(total, 'prompt')} in a saved pair`;
|
||||
const details = [counts.variants && `${counts.variants.toLocaleString('en-US')} with a negative prompt`,
|
||||
counts.archivedPairs && `${counts.archivedPairs.toLocaleString('en-US')} archived`].filter(Boolean);
|
||||
const rows = [['Delete permanently', deleted],
|
||||
['Saved pairs removed', `${countOf(counts.pairs, 'saved pair')}${details.length ? ` (${details.join(', ')})` : ''}`]];
|
||||
if (counts.keptNegatives) rows.push(['Negatives kept', `${countOf(counts.keptNegatives, 'negative prompt')} ${counts.keptNegatives === 1 ? 'is' : 'are'} kept`]);
|
||||
if (archive.length) rows.push(['Archive', `${countOf(archive.length, 'prompt')} used as another prompt's negative: hidden, restorable`]);
|
||||
keptRows(rows, review, titleOf);
|
||||
deleteRows(rows, review, review.imageLinks + review.pairedImageLinks);
|
||||
if (review.delete.length) {
|
||||
rows.push(['Image links removed', `${countOf(review.imageLinks, 'image')} will no longer show these prompts (files stay on disk)`]);
|
||||
rows.push(['Backup', review.backup === 'on' ? 'Create and verify before deleting'
|
||||
: 'Off: no safety backup (System › Backup & export › Recovery policy)']);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** The review's sentences, in order, each when it applies. */
|
||||
export function reviewSentences(review) {
|
||||
if (permanent(review)) return permanentSentences(review);
|
||||
const sentences = [];
|
||||
if (review.archive.length) {
|
||||
sentences.push('Prompts in a saved pair, as its positive or its negative, are archived instead of deleted, so the ' +
|
||||
'pair and its runs stay intact. Archived prompts cannot be deleted. To bring them back, press Show archived prompts, ' +
|
||||
'select them and press Restore.');
|
||||
}
|
||||
if (review.delete.length) sentences.push(removeSentence(review));
|
||||
if (review.protected.length) sentences.push(PROTECTED_SENTENCE);
|
||||
return sentences;
|
||||
}
|
||||
|
||||
const PROTECTED_SENTENCE = 'Protected prompts are tagged __protected__; remove the tag to delete them.';
|
||||
function removeSentence(review, pairs = false) {
|
||||
const undo = review.backup === 'on'
|
||||
? ' Deleted prompts cannot be brought back one by one: the only way back is to restore the safety backup taken ' +
|
||||
'first (System › Backup & export), which returns the whole library to that moment. It is kept among the last ' +
|
||||
'10 safety backups.'
|
||||
: ' Deleted prompts cannot be brought back.';
|
||||
return "Deleting removes each prompt's text, title, category, tags, rating, notes, run count, last use and " +
|
||||
`image links.${undo} Image files, favorites, collections and recorded runs stay. A later scan, a new image or a ` +
|
||||
`node run with the same text adds it again as a new prompt.${pairs ? ' A pair node run with the same text saves a new pair.' : ''}`;
|
||||
}
|
||||
|
||||
/** #482: the sentences when Delete permanently is chosen. */
|
||||
function permanentSentences(review) {
|
||||
const sentences = ['Deleting a prompt in a saved pair also removes its saved pairs, with their titles, categories, ' +
|
||||
'ratings, notes, tags, run counts and image links. Their negative prompts are kept. Recorded runs stay in History ' +
|
||||
'and Activity, listed by their text.', removeSentence(review, true)];
|
||||
if (plan(review).archive.length) sentences.push("Prompts used as another prompt's negative are archived instead of deleted, " +
|
||||
"so that prompt's variant stays.");
|
||||
if (review.protected.length) sentences.push(PROTECTED_SENTENCE);
|
||||
if (review.delete.length) {
|
||||
const undo = review.backup === 'on'
|
||||
? ' Deleted prompts cannot be brought back one by one: the only way back is to restore the safety backup taken ' +
|
||||
'first (System › Backup & export), which returns the whole library to that moment. It is kept among the last ' +
|
||||
'10 safety backups.'
|
||||
: ' Deleted prompts cannot be brought back.';
|
||||
sentences.push("Deleting removes each prompt's text, title, category, tags, rating, notes, run count, last use and " +
|
||||
`image links.${undo} Image files, favorites, collections and recorded runs stay. A later scan, a new image or a ` +
|
||||
'node run with the same text adds it again as a new prompt.');
|
||||
}
|
||||
if (review.protected.length) sentences.push('Protected prompts are tagged __protected__; remove the tag to delete them.');
|
||||
return sentences;
|
||||
}
|
||||
|
||||
export function primaryLabel(review) {
|
||||
const { plain, paired, archive } = plan(review);
|
||||
const deletes = plain.length + paired.length, archives = archive.length;
|
||||
const deletes = review.delete.length, archives = review.archive.length;
|
||||
if (deletes && archives) return `Delete ${deletes.toLocaleString('en-US')} and archive ${countOf(archives, 'prompt')}`;
|
||||
return deletes ? `Delete ${countOf(deletes, 'prompt')}` : `Archive ${countOf(archives, 'prompt')}`;
|
||||
}
|
||||
@@ -193,8 +120,7 @@ export function primaryLabel(review) {
|
||||
/** Why a review cannot start, or null: nothing to change, or no room for the promised backup. */
|
||||
export function blockedReason(review) {
|
||||
if (!review.delete.length && !review.archive.length) return COPY.none;
|
||||
const deletes = review.delete.length + plan(review).paired.length;
|
||||
if (deletes && review.disk && review.disk.sufficient === false) {
|
||||
if (review.delete.length && review.disk && review.disk.sufficient === false) {
|
||||
return typeof review.disk.message === 'string' && review.disk.message
|
||||
? review.disk.message : 'There is not enough free space for the safety backup. Nothing was changed.';
|
||||
}
|
||||
@@ -207,69 +133,21 @@ export function blockedReason(review) {
|
||||
* close `onDismiss()`.
|
||||
*/
|
||||
export function openDeleteReview({ scope, trigger, review, titleOf, onStart, onDismiss }) {
|
||||
let current = withChoice(review, 'archive');
|
||||
const paragraphs = target => reviewSentences(target).map(text => paragraph(text));
|
||||
const title = reviewTitle(current), grid = reviewGrid(current, titleOf);
|
||||
let sentences = paragraphs(current);
|
||||
const blocked = blockedReason(current);
|
||||
const title = reviewTitle(review), grid = reviewGrid(review, titleOf);
|
||||
const options = reviewSentences(review).map(text => Object.assign(document.createElement('p'), { className: 'small', textContent: text }));
|
||||
const blocked = blockedReason(review);
|
||||
if (blocked) {
|
||||
onDismiss();
|
||||
openCloseOnlyReview({ scope, trigger, title, grid, options: sentences, sentence: blocked });
|
||||
openCloseOnlyReview({ scope, trigger, title, grid, options, sentence: blocked });
|
||||
return;
|
||||
}
|
||||
// #482: prompts in a saved pair ask Archive (the default, as #443) or Delete permanently, with their pairs.
|
||||
const choice = hasPairedChoice(review) ? pairedChoice(value => update(value)) : null;
|
||||
const dialog = openReviewDialog({ scope, trigger, title, grid, options: choice ? [choice, ...sentences] : sentences,
|
||||
cancelLabel: 'Cancel', primary: { label: primaryLabel(current), danger: true },
|
||||
onStart: () => onStart(current), onDismiss });
|
||||
function update(value) {
|
||||
current = withChoice(review, value);
|
||||
dialog.setRows(reviewGrid(current, titleOf));
|
||||
const reason = blockedReason(current);
|
||||
const next = paragraphs(current);
|
||||
if (reason) next.push(paragraph(reason));
|
||||
for (const node of sentences) node.remove();
|
||||
choice.after(...next);
|
||||
sentences = next;
|
||||
const heading = choice.closest('dialog')?.querySelector('#modal-title');
|
||||
if (heading) heading.textContent = reviewTitle(current);
|
||||
dialog.setPrimary({ label: primaryLabel(current), disabled: Boolean(reason) });
|
||||
}
|
||||
}
|
||||
|
||||
const paragraph = text => Object.assign(document.createElement('p'), { className: 'small', textContent: text });
|
||||
export const PAIRED_CHOICES = Object.freeze([
|
||||
Object.freeze({ value: 'archive', label: 'Archive (hidden, restorable)' }),
|
||||
Object.freeze({ value: 'delete', label: 'Delete permanently, with their saved pairs' }),
|
||||
]);
|
||||
|
||||
/** #482: the D90 radio row ("Prompts in a saved pair"), Archive checked; it is built fresh for every review. */
|
||||
function pairedChoice(onChange) {
|
||||
const group = document.createElement('div');
|
||||
group.className = 'field';
|
||||
group.setAttribute('role', 'radiogroup');
|
||||
const caption = document.createElement('span');
|
||||
caption.textContent = 'Prompts in a saved pair';
|
||||
caption.id = 'library-delete-choice-label';
|
||||
group.setAttribute('aria-labelledby', caption.id);
|
||||
group.append(caption);
|
||||
for (const choice of PAIRED_CHOICES) {
|
||||
const label = document.createElement('label');
|
||||
const input = document.createElement('input');
|
||||
Object.assign(input, { type: 'radio', name: 'library-delete-choice', value: choice.value, checked: choice.value === 'archive' });
|
||||
input.addEventListener('change', () => { if (input.checked) onChange(choice.value); });
|
||||
label.append(input, ` ${choice.label}`);
|
||||
group.append(label);
|
||||
}
|
||||
return group;
|
||||
openReviewDialog({ scope, trigger, title, grid, options, cancelLabel: 'Cancel',
|
||||
primary: { label: primaryLabel(review), danger: true }, onStart, onDismiss });
|
||||
}
|
||||
|
||||
/** The write's body: the reviewed lists, the epoch and the reviewed backup promise. */
|
||||
export function deleteBody(review) {
|
||||
const body = { archive_ids: review.archive, delete_ids: review.delete, expected_epoch: review.epoch, backup: review.backup };
|
||||
// #482: the archive ids to delete permanently, each with the digest of what the review showed.
|
||||
if (permanent(review)) Object.assign(body, { delete_paired_ids: review.deletable, paired_digests: review.digests });
|
||||
return body;
|
||||
return { archive_ids: review.archive, delete_ids: review.delete, expected_epoch: review.epoch, backup: review.backup };
|
||||
}
|
||||
|
||||
function failureOutcome(failure, copy) {
|
||||
@@ -294,15 +172,14 @@ export function deleteOutcome(result, failure, review) {
|
||||
return failureOutcome(failure, { storage: COPY.storage, unconfirmed: COPY.unconfirmed, failed: COPY.failed });
|
||||
}
|
||||
const deleted = count(result.deleted), archived = count(result.archived);
|
||||
const parts = [], pairs = count(result.pairs_removed);
|
||||
const parts = [];
|
||||
if (deleted) parts.push(`Deleted ${countOf(deleted, 'prompt')}.`);
|
||||
if (pairs) parts.push(`Removed ${countOf(pairs, 'saved pair')}.`);
|
||||
if (archived) parts.push(`Archived ${countOf(archived, 'prompt')}.`);
|
||||
if (!parts.length) parts.push(NOTHING);
|
||||
const already = count(result.already_archived), missing = count(result.not_found), changed = count(result.changed);
|
||||
if (already) parts.push(`${were(already, 'was', 'were')} already archived.`);
|
||||
if (missing) parts.push(`${missing.toLocaleString('en-US')} no longer ${missing === 1 ? 'exists' : 'exist'}.`);
|
||||
if (changed) parts.push(`${changed.toLocaleString('en-US')} changed since the review and ${changed === 1 ? 'was' : 'were'} not deleted.`);
|
||||
if (changed) parts.push(`${changed.toLocaleString('en-US')} changed since the review and ${changed === 1 ? 'was' : 'were'} kept.`);
|
||||
const kept = review?.protected?.length || 0;
|
||||
if (kept) parts.push(`${countOf(kept, 'protected prompt')} ${kept === 1 ? 'was' : 'were'} kept.`);
|
||||
if (typeof result.backup === 'string' && result.backup) parts.push(`Safety backup saved: ${result.backup} (System › Backup & export).`);
|
||||
@@ -328,31 +205,3 @@ export function previewOutcome(failure) {
|
||||
if (failure?.code === 'busy') return failureOutcome(failure, {});
|
||||
return { toast: COPY.reviewFailed, committed: false, reload: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* #481: review and run a delete of `ids` outside the selection bar (a prompt's own page): the same preview, review
|
||||
* (#482's choice included), backup and write. `onSettled(outcome, result, failure)` runs once the write has answered
|
||||
* (or the preview failed: `result` null); a dismissed review calls `onDismiss()`. The toast is the caller's.
|
||||
*/
|
||||
export async function reviewPromptDelete({ api, scope, trigger, ids: promptIds, epoch, titleOf, onSettled, onDismiss = () => {} }) {
|
||||
const body = { prompt_ids: promptIds, expected_epoch: epoch };
|
||||
let preview = null, failure = null;
|
||||
try {
|
||||
preview = await api.post(PREVIEW_URL, body, { signal: scope.signal });
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
if (scope.signal.aborted) return;
|
||||
if (failure !== null || !preview?.success) { onSettled(previewOutcome(failure), null, failure); return; }
|
||||
const review = { ...reviewOf(preview), epoch };
|
||||
openDeleteReview({ scope, trigger, review, titleOf, onDismiss,
|
||||
onStart: async chosen => {
|
||||
let result = null, error = null;
|
||||
try {
|
||||
result = await api.post(DELETE_URL, deleteBody(chosen));
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
onSettled(deleteOutcome(result, error, chosen), result?.success ? result : null, error);
|
||||
} });
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ function datalist(id, values) {
|
||||
* exit when prompt storage was restored; the dialog closes first.
|
||||
*/
|
||||
export function createLibraryEditor({api, parentScope, onCommitted = () => {}, onReconciled = () => {},
|
||||
onRefreshRequired = () => window.location.reload(), onDeletePrompt = null}) {
|
||||
onRefreshRequired = () => window.location.reload()}) {
|
||||
let session = null, disposed = false;
|
||||
const dialog = el('dialog', undefined, 'library-editor');
|
||||
dialog.setAttribute('aria-labelledby', 'library-editor-title');
|
||||
@@ -222,10 +222,7 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o
|
||||
} else form.append(el('p', NEGATIVE_HELP, 'small'));
|
||||
const line = statusLine(), recovery = new Map();
|
||||
const actions = el('div', undefined, 'dialog-actions');
|
||||
// D161 (#481): the variant's archive is named for it, beside the prompt's own Delete prompt… (the concept's one
|
||||
// "Archive…" in this row archives the prompt, app.js 976).
|
||||
const revise = createButton('Create text revision'), archive = createButton('Archive variant…', 'danger');
|
||||
const removePrompt = createButton('Delete prompt…', 'danger');
|
||||
const revise = createButton('Create text revision'), archive = createButton('Archive…', 'danger');
|
||||
const cancel = createButton('Cancel'), save = createButton('Save', 'ca-primary');
|
||||
save.type = 'submit';
|
||||
form.append(line.note, actions);
|
||||
@@ -240,12 +237,6 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o
|
||||
cancel.addEventListener('click', () => finish(), {signal: views.signal});
|
||||
revise.addEventListener('click', () => open({mode: 'revision', positiveId, pairId, trigger: session.trigger}), {signal: views.signal});
|
||||
archive.addEventListener('click', () => archiveView(), {signal: views.signal});
|
||||
// #481: the prompt's own delete review (#443/#482) replaces this dialog; focus returns to its trigger.
|
||||
removePrompt.addEventListener('click', () => {
|
||||
const {trigger} = session;
|
||||
finish();
|
||||
onDeletePrompt?.({positiveId, trigger});
|
||||
}, {signal: views.signal});
|
||||
if (!negativeOnly) loadVocabulary(tags, views.signal);
|
||||
|
||||
let categoryKey = null, refocus = null;
|
||||
@@ -265,7 +256,6 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o
|
||||
cancel.disabled = close.disabled = saving;
|
||||
// After a restore the numeric IDs may name another prompt; only the Library reload exit stays.
|
||||
revise.disabled = saving || restoredStorage(state);
|
||||
removePrompt.disabled = saving || restoredStorage(state);
|
||||
const pairProblem = readOnly && !state.pair && pairId === null ? NO_PAIR : state.pair?.archived ? ALREADY_ARCHIVED : null;
|
||||
archive.disabled = !SUBMITTABLE.has(state.phase) || !state.pair || state.pair.archived;
|
||||
const status = statusOf(state, false, mode);
|
||||
@@ -273,8 +263,7 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o
|
||||
if (!status && pairProblem) archive.setAttribute('aria-describedby', line.note.id);
|
||||
else archive.removeAttribute('aria-describedby');
|
||||
const retry = recoveryButton(recovery, status?.action, views.signal);
|
||||
const prompt = readOnly && onDeletePrompt ? [removePrompt] : [];
|
||||
setChildren(actions, [...(retry ? [retry] : []), ...(readOnly ? [revise, archive, ...prompt] : []), cancel, save]);
|
||||
setChildren(actions, [...(retry ? [retry] : []), ...(readOnly ? [revise, archive] : []), cancel, save]);
|
||||
refocus = restoreFocus(refocus, state, [retry, save]);
|
||||
if (state.phase === 'complete') finish();
|
||||
}
|
||||
|
||||
@@ -291,9 +291,8 @@ export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, ge
|
||||
deleteButton.textContent = DELETE_LABEL;
|
||||
openDeleteReview({ scope, trigger: deleteButton, review, titleOf: nameOf,
|
||||
onDismiss: unlock,
|
||||
// #482: the review as chosen (Archive, or Delete permanently with the prompts' saved pairs).
|
||||
onStart: chosen => write(DELETE_URL, deleteBody(chosen), 'delete', (result, error) => deleteOutcome(result, error, chosen),
|
||||
chosen.delete.length || chosen.choice === 'delete' ? 'Deleting…' : 'Archiving…') });
|
||||
onStart: () => write(DELETE_URL, deleteBody(review), 'delete', (result, error) => deleteOutcome(result, error, review),
|
||||
review.delete.length ? 'Deleting…' : 'Archiving…') });
|
||||
}
|
||||
/** Restore (#443): the selected archived prompts go back on the shelf. */
|
||||
function restoreSelected() {
|
||||
|
||||
@@ -17,7 +17,7 @@ import {libraryFilters,libraryViewState} from './saved-views.mjs';
|
||||
import {openRefineDialog,openContentDialog} from './workspace-dialogs.mjs';
|
||||
import {mountLibrarySelection} from './library-selection.mjs';
|
||||
import {fallBackToOriginal,replaceOnError,showsThumbnail,thumbnailSource} from './thumbnail-support.mjs';
|
||||
import {COPY as DELETE_COPY,RESTORE_URL,archivedNoticeText,restoreOutcome,reviewPromptDelete} from './library-delete.mjs';
|
||||
import {COPY as DELETE_COPY,RESTORE_URL,archivedNoticeText,restoreOutcome} from './library-delete.mjs';
|
||||
|
||||
let shelfVisit=null;
|
||||
// Every confirmed receipt advances this; a shelf mounted before it never restores its cached stream.
|
||||
@@ -194,15 +194,12 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
|
||||
const more=createButton('Load more prompts');const retry=createButton('Retry prompt library');retry.hidden=true;
|
||||
const actions=headingActions(main,section);
|
||||
const exportButton=createButton('Export');exportButton.setAttribute('aria-label','Export full Library JSON');
|
||||
// #443 D152: the shelf's counterpart of Negative variants' Show archived (#235); N follows the filters. #482: on
|
||||
// the archived shelf it reads the concept's own way back ("← Prompt library", app.js 594); a plain button whose
|
||||
// label names where it goes, so it carries no pressed state its changing label would contradict.
|
||||
const BACK_TO_LIBRARY='← Prompt library';
|
||||
const showArchived=createButton(filters.shelf==='archived'?BACK_TO_LIBRARY:'Show archived prompts');
|
||||
// #443 D152: the shelf's counterpart of Negative variants' Show archived (#235); N follows the filters.
|
||||
const showArchived=createButton('Show archived prompts');showArchived.setAttribute('aria-pressed',String(filters.shelf==='archived'));
|
||||
const newPrompt=createButton('+ New prompt','ca-primary');actions.append(showArchived,exportButton,newPrompt);
|
||||
scope.listen(showArchived,'click',()=>{
|
||||
if(selection.writing()) return;
|
||||
filters.shelf=filters.shelf==='archived'?'active':'archived';
|
||||
filters.shelf=filters.shelf==='archived'?'active':'archived';showArchived.setAttribute('aria-pressed',String(filters.shelf==='archived'));
|
||||
reset();selection.shelfChanged();
|
||||
});
|
||||
const exportStatus=el('p','','library-options-status');exportStatus.hidden=true;exportStatus.setAttribute('role','alert');
|
||||
@@ -411,8 +408,7 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
|
||||
:`${countOf(next.total,'positive prompt')} · ${countOf(next.variantTotal,'negative variant')}`;
|
||||
if(captionCount.textContent!==count) captionCount.textContent=count;caption.hidden=false;
|
||||
}
|
||||
const archivedLabel=filters.shelf==='archived'?BACK_TO_LIBRARY
|
||||
:next.archivedTotal===null?'Show archived prompts':`Show archived prompts (${next.archivedTotal.toLocaleString('en-US')})`;
|
||||
const archivedLabel=next.archivedTotal===null?'Show archived prompts':`Show archived prompts (${next.archivedTotal.toLocaleString('en-US')})`;
|
||||
if(showArchived.textContent!==archivedLabel) showArchived.textContent=archivedLabel;
|
||||
const filtered=Boolean(filters.text || filters.category || filters.tags.length || filters.folder || filters.tagMode!=='all');
|
||||
const empty=filters.shelf==='archived'?(filtered?'No archived prompts match.':'No archived prompts.'):'No matching prompts. Try changing the filters.';
|
||||
@@ -496,37 +492,9 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
|
||||
let pendingSelection=takeSavedSelection(positiveId);
|
||||
if(selectedPairs.get(positiveId)?.id!==pendingSelection) selectedPairs.delete(positiveId);
|
||||
const loader=createLibraryDetail({api,onChange:render});scope.cleanup(()=>loader.dispose());
|
||||
const editor=createLibraryEditor({api,parentScope:scope,onCommitted:invalidateLibraryAfterWrite,onReconciled:reconciled,onRefreshRequired:reloadView,
|
||||
onDeletePrompt:()=>deletePrompt()});
|
||||
/** #481: this prompt through the Library's Delete… review; a delete returns to the shelf, an archive shows its notice. */
|
||||
let deleting=false;
|
||||
// While the review or its write runs, Edit metadata is held (as the selection bar holds its actions), so no save
|
||||
// can race the delete; aria-disabled keeps it focusable for the return.
|
||||
function holdEdit(held) {
|
||||
deleting=held;
|
||||
if(held) edit.setAttribute('aria-disabled','true');else edit.removeAttribute('aria-disabled');
|
||||
}
|
||||
function deletePrompt() {
|
||||
const shown=detail;if(deleting || !shown?.positive) return;
|
||||
// One review at a time, as the selection bar holds itself (a second press during the preview starts nothing).
|
||||
holdEdit(true);
|
||||
reviewPromptDelete({api,scope,trigger:edit,ids:[shown.positive.id],epoch:shown.library_epoch,
|
||||
titleOf:()=>displayTitle(shown.positive),onDismiss:()=>{holdEdit(false);if(edit.isConnected) edit.focus({preventScroll:true});},
|
||||
onSettled:(outcome,result,failure)=>{
|
||||
holdEdit(false);
|
||||
if(outcome.committed) invalidateLibraryAfterWrite({kind:'bulk-delete',receipt:result ?? {phase:'unconfirmed'}});
|
||||
// A left page still invalidates what it cached, but says nothing (as the bar's write).
|
||||
if(scope.signal.aborted) return;
|
||||
showWorkspaceToast(outcome.toast);
|
||||
// Restored storage: the whole Library view reads again, as the editor's own restore path.
|
||||
if(failure?.code==='storage-changed') {reloadView();return;}
|
||||
if(result && Number.isSafeInteger(result.deleted) && result.deleted>0) {focusHeadingOnMount=true;window.location.hash='#/library';return;}
|
||||
if(outcome.reload) {detail=null;loader.load(positiveId);focusHeading(main);}
|
||||
}});
|
||||
}
|
||||
const editor=createLibraryEditor({api,parentScope:scope,onCommitted:invalidateLibraryAfterWrite,onReconciled:reconciled,onRefreshRequired:reloadView});
|
||||
scope.listen(back,'click',()=>{window.location.hash='#/library';});
|
||||
scope.listen(edit,'click',()=>{
|
||||
if(deleting) return;
|
||||
const draft=metadataDrafts.get(positiveId);metadataDrafts.delete(positiveId);
|
||||
editor.open({mode:'metadata',positiveId,pairId:current?.activePairId() ?? null,trigger:edit,draft:draft || null});
|
||||
});
|
||||
|
||||
@@ -491,17 +491,13 @@ export function openReviewDialog({ scope, title, heading = null, grid, options =
|
||||
if (heading) nodes.push(el('h3', heading));
|
||||
const list = el('dl', undefined, 'meta-grid');
|
||||
const values = new Map();
|
||||
const fill = rows => {
|
||||
values.clear();
|
||||
list.replaceChildren(...rows.map(([term, value]) => {
|
||||
const entry = el('div');
|
||||
const dd = el('dd', value);
|
||||
values.set(term, dd);
|
||||
entry.append(el('dt', term), dd);
|
||||
return entry;
|
||||
}));
|
||||
};
|
||||
fill(grid);
|
||||
for (const [term, value] of grid) {
|
||||
const entry = el('div');
|
||||
const dd = el('dd', value);
|
||||
values.set(term, dd);
|
||||
entry.append(el('dt', term), dd);
|
||||
list.append(entry);
|
||||
}
|
||||
nodes.push(list, ...options);
|
||||
if (sentence) nodes.push(el('p', sentence, 'small'));
|
||||
if (backup) nodes.push(backupNotice(backup));
|
||||
@@ -510,7 +506,6 @@ export function openReviewDialog({ scope, title, heading = null, grid, options =
|
||||
let started = false;
|
||||
cancel.addEventListener('click', () => finish());
|
||||
start.addEventListener('click', () => {
|
||||
if (start.getAttribute('aria-disabled') === 'true') return;
|
||||
started = true;
|
||||
finish();
|
||||
onStart();
|
||||
@@ -529,17 +524,6 @@ export function openReviewDialog({ scope, title, heading = null, grid, options =
|
||||
const dd = values.get(term);
|
||||
if (dd) dd.title = text;
|
||||
},
|
||||
/** #482: the rows a choice in the review changes (`[term, value]` pairs, as `grid`). */
|
||||
setRows(rows) {
|
||||
requireGrid(rows);
|
||||
fill(rows);
|
||||
},
|
||||
/** #482: the primary's label, and whether it can start (an unavailable start stays in place, aria-disabled). */
|
||||
setPrimary({ label, disabled = false }) {
|
||||
if (typeof label !== 'string' || !label) throw new TypeError('The review needs its label');
|
||||
start.textContent = label;
|
||||
start.setAttribute('aria-disabled', String(Boolean(disabled)));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user