Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dda170078a | ||
|
|
3bf2d81c1a | ||
|
|
f3a2912aa1 | ||
|
|
1b9c6ce13c | ||
|
|
a6f7c46943 | ||
|
|
a99a311e82 | ||
|
|
4cc6ee767a | ||
|
|
3c3b981275 | ||
|
|
dc17726800 | ||
|
|
46136e2ab7 | ||
|
|
14b575b559 | ||
|
|
eaf4b112f7 |
+126
-6
@@ -2399,7 +2399,7 @@ the connection's `total_changes` is 0 at close and the data reads back unchanged
|
||||
`-shm` bytes are not compared: every WAL reader rewrites its read marks there, so no
|
||||
reader can keep it byte-identical (`test_health_check_never_writes`).
|
||||
|
||||
Recorded deviations (plan D60–D67, D79–D81 renumbered D92–D99 and D111–D113; D121–D131 assigned):
|
||||
Recorded deviations (plan D60–D70, D79–D81 renumbered D92–D102 and D111–D113; D121–D131 assigned):
|
||||
|
||||
- **D92** (shell): no "Sample configuration" chip. The Save status is truthful: "No
|
||||
unsaved changes.", "Unsaved changes.", "Saved in this browser." / "Saved to the server
|
||||
@@ -2442,7 +2442,8 @@ Recorded deviations (plan D60–D67, D79–D81 renumbered D92–D99 and D111–D
|
||||
available" (an unavailable source), "Not available yet" (Ambiguous metadata, Q6) or
|
||||
"Could not check". A `p.small` after the list says when it was checked and whether
|
||||
every check passed, some could not run, or issues need review. Close / Review missing
|
||||
links (to Maintenance, its hand-off until S7). Nothing is changed by a check.
|
||||
links, which reviews a fresh Missing-file links plan (D100, S7); it never cleans up.
|
||||
Nothing is changed by a check.
|
||||
- **D96** (Statistics): "Usage statistics" without the sample note: the concept's
|
||||
"Runs by category" table (Category / Runs / Avg. rating) with the real runs and
|
||||
average rating of eligible parents per category ("Uncategorized", "Not rated"),
|
||||
@@ -2458,9 +2459,9 @@ Recorded deviations (plan D60–D67, D79–D81 renumbered D92–D99 and D111–D
|
||||
- **D122** (shell): the section nav's `aria-label` is "System sections"; the concept's
|
||||
literal label is its prototype action name "system-tab sections".
|
||||
- **D123** (Maintenance): the concept's Maintenance notice
|
||||
("Check first. Review changes. Then apply." + Run health checks) is delivered ahead of
|
||||
the rest of Maintenance, above its D94 hand-off, as the native place that runs the
|
||||
health check. A press shows "Checking…" (`aria-disabled`) and opens the report; a
|
||||
("Check first. Review changes. Then apply." + Run health checks) was delivered ahead of
|
||||
the rest of Maintenance (S2), as the native place that runs the health check; since S7
|
||||
the section's two surfaces follow it and its D94 hand-off is gone. A press shows "Checking…" (`aria-disabled`) and opens the report; a
|
||||
failed check toasts "The health check could not finish. Nothing was changed."
|
||||
- **D97** (Appearance): added rows for the legacy gallery settings the concept leaves
|
||||
out, after its own rows: "Show image details on cards" (`showImageInfo`; off hides a
|
||||
@@ -2497,7 +2498,7 @@ Recorded deviations (plan D60–D67, D79–D81 renumbered D92–D99 and D111–D
|
||||
dialog, since size and quality change no count. The primary is "Start" (the
|
||||
concept's "Simulate thumbnail job"). Overview's Review thumbnails opens the same
|
||||
dialog (the concept's row starts a sample "Thumbnail check" job directly). Maintenance's
|
||||
Thumbnail cache row takes the dialog with S7. `thumbnail dialog matches the concept
|
||||
Thumbnail cache → Review options opens it too (S7). `thumbnail dialog matches the concept
|
||||
from sources|overview` (system-concept.spec, both themes and sizes) maps the line and
|
||||
"Start"; `thumbnail dialog comparison detects mutations` proves they are the only
|
||||
difference and catches a renamed option, swapped options, swapped actions and a
|
||||
@@ -2519,11 +2520,101 @@ Recorded deviations (plan D60–D67, D79–D81 renumbered D92–D99 and D111–D
|
||||
`tests/js/workspace-activity-kinds.test.js`, and in `workspace-activity.spec.mjs` "a
|
||||
thumbnail job shows Generated Skipped Errors, its pill verb and its toasts" and "a held
|
||||
legacy job and the health check show no tiles, no Results and no made-up percentage".
|
||||
- **D111, S7 kinds**: a maintenance apply (`maintenance`, titled after its operation,
|
||||
not cancellable except "Store each workflow once") reads its title in the pill (no
|
||||
total, so no percentage), counts Changed (green), Protected and Errors, keeps
|
||||
"Maintenance log", and toasts "<title> finished · N changed" or, when it failed,
|
||||
"<title> failed · <the server's status line>" (a plan-changed apply says "The library
|
||||
changed since the review; nothing was changed. Review the plan again."). The duplicate
|
||||
search (`duplicates`) reads "Finding duplicates · N% · a / b", counts Groups found
|
||||
(rows open their first copy), says "Duplicate search started." and "Duplicate search
|
||||
finished · N groups found", with "Search log" and "Cancel search". The duplicate
|
||||
cleanup (`duplicate_cleanup`, not cancellable) reads "Removing duplicate files", counts
|
||||
Removed (green), Kept and Errors (Kept rows open the viewer), and toasts "Duplicate
|
||||
cleanup finished · N removed · M errors" or, when it failed, "Duplicate cleanup failed ·
|
||||
<the server's status line>". A refused start names the job the server named, not the
|
||||
first running one. Every job keeps Download report. Tests: the
|
||||
three S7 cases in `tests/js/workspace-activity-kinds.test.js`.
|
||||
- **D112** (Activity, S5): a legacy-page operation held on the registry (not
|
||||
cancellable, no outcome groups) reads as its server title ("Database restore (admin
|
||||
page)", "Thumbnail clear (gallery page)"), with no tiles, no Results, no Cancel, an
|
||||
indeterminate bar while it runs and "<title> finished." when it ends, whatever its
|
||||
kind (the gallery's clear is a `thumbnails` hold).
|
||||
- **D100** (Maintenance, S7): the concept's `maintenancePlan` dialog ("Review maintenance
|
||||
plan") on the server's read-only plan (`POST /maintenance/preview`), opened by each
|
||||
Review plan and by the health report's Review missing links. No sample note. The `h3`
|
||||
is the operation's row title (the concept prints its `data-plan` name, "duplicate
|
||||
prompts"); the `dl.meta-grid` keeps Scope / Protected / Backup /
|
||||
Media on disk with the plan's values: its scope label, its protected reasons joined
|
||||
with " · " ("Nothing is protected" when none), "Create and verify before changes
|
||||
(<db>.backup_<date and time>)" or "No safety backup (the policy is off)", and its
|
||||
media line. (A `workflow_storage` plan would read "Store each workflow once" and
|
||||
"Always created and verified first (…)", but Maintenance has no row for it: the
|
||||
concept has none, so the migration is reviewed and applied through the API and only
|
||||
its jobs appear, in Activity.) A
|
||||
`details` "Listed records (N)" ("(N of M)" past the server's 50) holds the records the
|
||||
plan names in a `pre`. The `p.small` says "Nothing changes until you apply. This
|
||||
review holds for N minutes." (from `expires_at`), plus "Not enough free disk space:
|
||||
about X MB is needed and Y MB is free." when the plan's disk check is short. Then the
|
||||
D55 backup box ("Back up the database first (recommended)", "Back up now") and Cancel /
|
||||
"Apply reviewed operation" (the concept's "Simulate reviewed operation"). A plan with
|
||||
nothing to do (scope 0, "No duplicate prompt text") shows its rows, "There is nothing to
|
||||
apply; nothing was changed." and only Close, never an Apply that would start an empty
|
||||
job (`a plan with nothing to do shows only Close, never Apply`). Apply sends
|
||||
only the token; the dialog closes, "<title> started." is toasted and the job opens in
|
||||
Activity. A refused apply stays and says why: "Another job is running: <title>."
|
||||
(D57), "This plan was already applied: <job>." (`plan-used`, linking the job), the
|
||||
server's line for an expired or replaced review (then the plan is reviewed again at
|
||||
once), for a short disk, for nothing to do and for the server's own pre-start failure
|
||||
(`apply-failed`). Only our server's refusal is definitive: a known code, or a 4xx whose
|
||||
body says `success:false`. A dropped connection, a proxy's 5xx (no code) or a 2xx whose
|
||||
body cannot be read may follow a started job, so they say "Outcome not confirmed. Check
|
||||
Activity before applying it again." and read the Activity list again
|
||||
(`lost apply response…` runs the apply and then drops its answer; `a proxy error or an
|
||||
unreadable answer is not a refusal` answers a 504 after the server started the job). A
|
||||
review button holds ("Reviewing…", `aria-disabled`) while its preview is read, so a
|
||||
double press sends one preview (`a review button pressed twice sends one preview`). A
|
||||
failed apply records its code as the job's `scope.failure`; when the plan was overtaken
|
||||
(`plan-changed`, `storage-changed`, `plan-expired`), its Activity view offers "Review
|
||||
again", which reviews the same operation afresh (a duplicate cleanup whose sources
|
||||
changed goes back to Maintenance to search again).
|
||||
Cancel and Escape write nothing (`Cancel writes nothing`: a read-only dump of the rows
|
||||
and the media hashes, `GET /__test__/system/digest`). `plan dialogs match the concept`
|
||||
(system-maintenance-concept.spec, both themes and sizes) compares each of the four
|
||||
operations' dialogs with the concept's, mapped by these values only.
|
||||
- **D101** (Maintenance, S7): Duplicate image files → Find duplicates starts the
|
||||
duplicate search (Activity, D111); its job view adds "Review groups" after Download
|
||||
report once it finished with groups. The concept's `duplicates` dialog ("Review
|
||||
duplicate image files") shows the real groups ten at a time: a `p.small[role=status]`
|
||||
"Groups 1–10 of N", then per group a `p.small` "N identical files · S each" and one
|
||||
`.compare-grid` with a `.compare-item` per copy (its preview as the concept's
|
||||
`img.sample-fluid`, and a radio "Keep <source>/<path>", the oldest copy checked, its
|
||||
label wrapping anywhere (CSS under D101) so long dataset paths never make the review
|
||||
scroll sideways (`long copy paths wrap; the duplicates review never scrolls sideways`,
|
||||
desktop and phone); a copy
|
||||
gone since the search is disabled and says so), "Previous groups" / "Next groups" when
|
||||
there are more than ten, the `p.small` "Removing a copy deletes its file from disk;
|
||||
its database links move to the kept copy. History keeps the removed paths in its run
|
||||
records. Nothing is deleted until you review and apply the cleanup.", and Cancel /
|
||||
"Review duplicate cleanup". Choices survive paging; the review sends every group shown
|
||||
so far as `{group_id, root_index, path}` (never a bare path: two sources may hold a
|
||||
file at the same path) and opens the D100 plan for "Duplicate image files". The
|
||||
primary stays the concept's plain button; the plan it opens has the primary.
|
||||
`duplicates dialog matches the concept` and `duplicates comparison detects mutations`
|
||||
(system-maintenance-concept.spec) pin the mapping.
|
||||
- **D102** (Maintenance, S7, Q5, #222): Metadata cache clears only the browser entries
|
||||
Prompt Manager owns as a cache (`web/js/cache-ownership.js`). The inventory of every
|
||||
storage writer has none (see "Browser cache ownership" below), so the row's line adds
|
||||
"Nothing is cached in this browser." and its Review plan is `aria-disabled` and opens
|
||||
nothing; a browser that refused to list its storage says "This browser did not allow
|
||||
checking its storage." instead. With owned entries (a test names one through the module's inventory), Review
|
||||
plan opens "Review maintenance plan" › "Metadata cache" (Scope "N cached entries in
|
||||
this browser", Protected "Preferences and other tools’ data in this browser", Backup
|
||||
"Not needed: the database is not changed", Media "No files are removed") and "Clear
|
||||
browser cache" clears exactly those and toasts what it cleared. The row's dimmed button
|
||||
is D93's style. `maintenance matches the concept` (both themes and sizes) maps only this
|
||||
row; `maintenance comparison detects mutations` proves it is the only difference and
|
||||
catches a removed row, a renamed operation, a lost danger mark and a swapped token.
|
||||
- **D113** (Sources): the Worker threads range ends at the server's `cpu_count` (the
|
||||
concept's fixed `max=16`) and shows the server's real `worker_threads` (the concept's
|
||||
sample 4).
|
||||
@@ -2961,6 +3052,35 @@ Restoring a deduped backup on a version before this one is not supported: that
|
||||
version's restore accepts the file and then reads rewritten images without their
|
||||
workflow (README changelog).
|
||||
|
||||
### Browser cache ownership (#222)
|
||||
|
||||
The legacy gallery's Clear cache used to delete every localStorage key starting `pm_` or
|
||||
`promptManager_` (the workspace theme and the dismissed update notice among them) and
|
||||
every CacheStorage cache whose name held "gallery" or "thumbnail" (another tool's, on
|
||||
ComfyUI's shared origin). `web/js/cache-ownership.js` names what a clear may remove
|
||||
from an inventory of the real writers (2026-10-24): every key a Prompt Manager page
|
||||
writes is a preference (`gallerySettings`, `pm_workspace_theme`,
|
||||
`pm_update_notice_dismissed`) and no page opens CacheStorage or IndexedDB (the
|
||||
`web/sw.js` service worker that did never shipped). So nothing is removed, and the
|
||||
toast says "Nothing of Prompt Manager's is cached in this browser." `the inventory covers
|
||||
every browser storage writer in web/` (`tests/js/cache-ownership.test.js`) fails when a
|
||||
page writes a key that is neither a preference nor inventoried, writes one it cannot name
|
||||
(a computed `setItem` key, a bracket or property assignment on a Storage), or opens
|
||||
CacheStorage or IndexedDB; `the storage scan reports every way of writing browser
|
||||
storage` pins those patterns. It skips `web/lib` (third-party builds) and the pinned
|
||||
Toolkit build (`web/js/shared/toolkit`, never hand-edited, verified by `npm run check`;
|
||||
its storage belongs to the Toolkit repository and a new pin is reviewed on its own). The
|
||||
gallery's server thumbnail clear stays its own request and runs first: refused or
|
||||
unreachable, it changes nothing and says so (the old code toasted success when the
|
||||
request failed); refused browser storage is reported, never hidden
|
||||
(`tests/js/legacy-refusals.test.js`; the browser case `the gallery cache clear keeps
|
||||
preferences and other tools caches` in `workflows.spec.mjs`).
|
||||
|
||||
The legacy duplicates dialog's View Full now names its source (`?root=<index>`, with the
|
||||
path URL-encoded) when the gallery's output folder is a listed source; before, the serve
|
||||
route opened the first source holding a file at that path
|
||||
(`test_view_full_names_the_gallery_folder_source`).
|
||||
|
||||
### Library location contract (#249 System S6b)
|
||||
|
||||
`utils/library_location.py` is the one resolver of every data path: the database
|
||||
|
||||
+28
-5
@@ -13,6 +13,7 @@ import sqlite3
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
@@ -734,9 +735,20 @@ class AdminRoutesMixin:
|
||||
|
||||
Only the files under ``output_dir`` count (paths are relative to it);
|
||||
a group needs two of them. A file that vanished since it was hashed
|
||||
is left out.
|
||||
is left out. Each ``url`` names the source ``output_dir`` is
|
||||
(``?root=``) when it is one, so View Full never opens another
|
||||
source's file at the same path.
|
||||
"""
|
||||
output_path = Path(os.path.abspath(output_dir))
|
||||
identity = os.path.normcase(os.path.realpath(str(output_path)))
|
||||
root_index = next(
|
||||
(
|
||||
index
|
||||
for index, root in enumerate(self._get_all_output_dirs())
|
||||
if os.path.normcase(os.path.realpath(str(root))) == identity
|
||||
),
|
||||
None,
|
||||
)
|
||||
base = os.path.normcase(str(output_path)).rstrip("\\/") + os.sep
|
||||
duplicates = []
|
||||
for group in groups:
|
||||
@@ -747,7 +759,10 @@ class AdminRoutesMixin:
|
||||
try:
|
||||
images.append(
|
||||
self._describe_media_file(
|
||||
Path(record["abs"]), output_path, file_hash=group["hash"]
|
||||
Path(record["abs"]),
|
||||
output_path,
|
||||
file_hash=group["hash"],
|
||||
root_index=root_index,
|
||||
)
|
||||
)
|
||||
except (OSError, ValueError) as e:
|
||||
@@ -847,9 +862,12 @@ class AdminRoutesMixin:
|
||||
)
|
||||
return groups
|
||||
|
||||
def _describe_media_file(self, media_path, output_path, file_hash=None):
|
||||
def _describe_media_file(
|
||||
self, media_path, output_path, file_hash=None, root_index=None
|
||||
):
|
||||
"""Describe one media file for the duplicates response (hashing it
|
||||
unless ``file_hash`` is given)."""
|
||||
unless ``file_hash`` is given). With ``root_index`` the ``url`` names
|
||||
that source and its path is URL-encoded."""
|
||||
stat = media_path.stat()
|
||||
rel_path = media_path.relative_to(output_path)
|
||||
extension = media_path.suffix.lower()
|
||||
@@ -862,13 +880,18 @@ class AdminRoutesMixin:
|
||||
thumbnail_settings.thumbnail_stem_rel(rel_path) + existing.suffix
|
||||
)
|
||||
thumbnail_url = thumbnail_url_for(thumb_rel, existing)
|
||||
if root_index is None:
|
||||
url = f"/prompt_manager/images/serve/{rel_path.as_posix()}"
|
||||
else:
|
||||
encoded = urllib.parse.quote(rel_path.as_posix(), safe="/")
|
||||
url = f"/prompt_manager/images/serve/{encoded}?root={root_index}"
|
||||
|
||||
return {
|
||||
"id": str(hash(str(media_path))),
|
||||
"filename": media_path.name,
|
||||
"path": str(rel_path),
|
||||
"relative_path": str(rel_path),
|
||||
"url": f"/prompt_manager/images/serve/{rel_path.as_posix()}",
|
||||
"url": url,
|
||||
"thumbnail_url": thumbnail_url,
|
||||
"size": stat.st_size,
|
||||
"modified_time": stat.st_mtime,
|
||||
|
||||
+12
-3
@@ -37,7 +37,8 @@ runner runs); a refused start keeps it. The runner:
|
||||
|
||||
The job log names the backup by its file name only, and the job says "No
|
||||
safety backup" when none was taken. Apply errors: ``invalid-request`` (400),
|
||||
``plan-expired`` (410: unknown, expired or forgotten after a restart),
|
||||
``apply-failed`` (500: nothing started), ``plan-expired`` (410: unknown,
|
||||
expired or forgotten after a restart),
|
||||
``plan-used`` (409, with the job that applied it), ``storage-changed`` (409:
|
||||
a restore replaced the database since the review) and ``busy`` (409).
|
||||
"""
|
||||
@@ -644,7 +645,9 @@ class MaintenanceRoutesMixin:
|
||||
)
|
||||
except (OSError, sqlite3.Error):
|
||||
self.logger.exception("Could not measure the disk space for an apply")
|
||||
return _failure(APPLY_FAILED, 500)
|
||||
# Coded: the workspace tells the server's own refusal (nothing
|
||||
# started) from a proxy's 5xx (the job may have started).
|
||||
return _failure(APPLY_FAILED, 500, "apply-failed")
|
||||
if short is not None:
|
||||
# Refused before any job starts: nothing fails halfway for space.
|
||||
return _failure(short_space_message(short), 507, "insufficient-space")
|
||||
@@ -808,7 +811,13 @@ def backup_note(backup):
|
||||
|
||||
|
||||
def _fail_job(job, code, message):
|
||||
"""End ``job`` failed with ``message`` (one error row) and its code."""
|
||||
"""End ``job`` failed with ``message`` (one error row) and its code.
|
||||
|
||||
The code is also the job's ``scope["failure"]``, so Activity can offer
|
||||
Review again for a plan the library overtook.
|
||||
"""
|
||||
if code:
|
||||
job.scope["failure"] = code
|
||||
job.record("errors", {"path": "", "detail": message}, f"! {message}")
|
||||
job.update(status_line=message)
|
||||
job.publish({"type": "error", "code": code, "message": message})
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { SIZES, THEMES, seedConcept, openConcept, openProduction, diffInventory, mutate } from './concept-inventory.mjs';
|
||||
|
||||
// #249 System S7 Task 20: System › Maintenance (app.js renderSystem 688–690), its plan dialog (`maintenancePlan`,
|
||||
// 953–958) for each operation and its duplicates dialog (`duplicates`, 1457–1461) against the immutable concept, on
|
||||
// the concept-shaped real fixture. Each concept measurement is mapped only by the recorded deviations (D100, D101,
|
||||
// D102; the health report is D95, compared in system-concept.spec), each commented where it applies; after that the
|
||||
// difference must be empty, and a mutated label, row or token must be noticed.
|
||||
test.use({ locale: 'en-US', timezoneId: 'UTC' });
|
||||
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
await seedConcept(request);
|
||||
await expect(await request.post('/__test__/system/reset')).toBeOK();
|
||||
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
|
||||
page.conceptErrors = [];
|
||||
page.on('pageerror', error => page.conceptErrors.push(error.message));
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page, request, browserName }) => {
|
||||
await saveCoverage(page, browserName);
|
||||
await request.post('/__test__/jobs/restart');
|
||||
await request.post('/__test__/system/reset');
|
||||
expect(page.conceptErrors).toEqual([]);
|
||||
});
|
||||
|
||||
const PREVIEW_URL = '/prompt_manager/maintenance/preview';
|
||||
|
||||
/** Runs in the page: Maintenance's notice and surfaces under `mainSelector`, with the concept's geometry and tokens. */
|
||||
function measureMaintenance(mainSelector) {
|
||||
const layout = document.querySelector(`${mainSelector} .settings-layout`);
|
||||
const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null;
|
||||
const name = node => [node.localName, ...[...node.classList].sort()].join('.');
|
||||
const text = node => node?.textContent.trim() ?? null;
|
||||
const button = node => ({ text: text(node), className: node.className,
|
||||
...css(node, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft', 'borderTopColor', 'opacity', 'cursor') });
|
||||
const box = node => {
|
||||
const rect = node.getBoundingClientRect(), base = layout.getBoundingClientRect();
|
||||
return { left: Math.round(rect.left - base.left), width: Math.round(rect.width) };
|
||||
};
|
||||
const notice = layout.querySelector(':scope > .notice');
|
||||
const surfaces = [...layout.querySelectorAll(':scope > section.surface')];
|
||||
const operation = surfaces[0].querySelector('.operation');
|
||||
return {
|
||||
layout: [...layout.children].map(name),
|
||||
notice: { tree: [...notice.children].map(name), text: [...notice.querySelectorAll(':scope > div > *')].map(node => [node.localName, text(node)]),
|
||||
run: button(notice.querySelector(':scope > button')),
|
||||
...css(notice, 'display', 'alignItems', 'justifyContent', 'columnGap', 'borderTopColor', 'backgroundColor', 'paddingTop', 'marginBottom') },
|
||||
surfaces: surfaces.map(surface => ({ tree: [...surface.children].map(name), heading: text(surface.querySelector(':scope > h2')),
|
||||
box: box(surface),
|
||||
rows: [...surface.querySelectorAll(':scope > .operation')].map(row => ({ tree: [...row.children].map(name),
|
||||
title: text(row.querySelector('h3')), description: text(row.querySelector('p')),
|
||||
actions: [...row.querySelectorAll('.row-actions > button')].map(button) })) })),
|
||||
surface: css(surfaces[0], 'paddingTop', 'paddingLeft', 'backgroundColor', 'borderTopWidth', 'borderTopColor', 'borderTopLeftRadius', 'marginTop'),
|
||||
h2: css(surfaces[0].querySelector('h2'), 'fontSize', 'fontWeight', 'color', 'marginBottom'),
|
||||
operation: css(operation, 'display', 'alignItems', 'justifyContent', 'columnGap', 'paddingTop', 'paddingBottom',
|
||||
'borderTopWidth', 'borderTopColor'),
|
||||
h3: css(operation.querySelector('h3'), 'fontSize', 'fontWeight', 'color'),
|
||||
p: css(operation.querySelector('p'), 'fontSize', 'color', 'marginTop'),
|
||||
actions: css(operation.querySelector('.row-actions'), 'display', 'columnGap', 'flexShrink'),
|
||||
pageScroll: document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
||||
};
|
||||
}
|
||||
|
||||
/** D102: the Metadata cache row says why its review is unavailable and its button is aria-disabled (D93's dimmed style). */
|
||||
function mapMaintenanceConcept(reference) {
|
||||
return {
|
||||
...reference,
|
||||
surfaces: reference.surfaces.map(surface => ({ ...surface, rows: surface.rows.map(row => row.title !== 'Metadata cache' ? row : {
|
||||
...row, description: `${row.description} Nothing is cached in this browser.`,
|
||||
actions: row.actions.map(action => ({ ...action, opacity: '0.55', cursor: 'not-allowed' })) }) })),
|
||||
};
|
||||
}
|
||||
|
||||
async function conceptMaintenance(page, theme) {
|
||||
await openConcept(page, 'system', theme);
|
||||
await page.locator('#main .section-nav').getByRole('button', { name: 'Maintenance', exact: true }).click();
|
||||
await expect(page.locator('#main .settings-layout > section.surface')).toHaveCount(2);
|
||||
await page.mouse.move(0, 0);
|
||||
return page.evaluate(measureMaintenance, '#main');
|
||||
}
|
||||
|
||||
async function productionMaintenance(page, theme) {
|
||||
await openProduction(page, 'system/maintenance', theme);
|
||||
await expect(page.locator('#workspace-main .settings-layout > section.surface')).toHaveCount(2);
|
||||
// The Metadata cache row reads this browser's storage once: compare after it answered.
|
||||
await expect(page.locator('#workspace-main .operation').filter({ hasText: 'Metadata cache' }).locator('p'))
|
||||
.toHaveText(/Nothing is cached in this browser\.$/);
|
||||
await page.mouse.move(0, 0);
|
||||
return page.evaluate(measureMaintenance, '#workspace-main');
|
||||
}
|
||||
|
||||
for (const theme of THEMES) for (const [width, height] of SIZES) {
|
||||
test(`maintenance matches the concept ${theme} ${width}x${height}`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width, height });
|
||||
const concept = await conceptMaintenance(page, theme);
|
||||
await page.screenshot({ path: testInfo.outputPath(`concept-maintenance-${theme}-${width}.png`), fullPage: true });
|
||||
const production = await productionMaintenance(page, theme);
|
||||
await page.screenshot({ path: testInfo.outputPath(`production-maintenance-${theme}-${width}.png`), fullPage: true });
|
||||
expect(diffInventory(mapMaintenanceConcept(concept), production)).toEqual([]);
|
||||
expect(production.pageScroll).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
/** Runs in the page: the open review dialog (plan or duplicates): its title, body and the concept's tokens. */
|
||||
function measureReview() {
|
||||
const dialog = document.querySelector('dialog#modal[open]');
|
||||
const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null;
|
||||
const name = node => [node.localName, ...[...node.classList].sort()].join('.');
|
||||
const text = node => node?.textContent.trim() ?? null;
|
||||
const body = dialog.querySelector('#modal-body');
|
||||
const grid = body.querySelector(':scope > dl.meta-grid');
|
||||
const compare = body.querySelector('.compare-grid');
|
||||
const item = compare?.querySelector('.compare-item');
|
||||
const notes = [...body.querySelectorAll(':scope > p.small')];
|
||||
const note = notes.at(-1);
|
||||
const actions = body.querySelector(':scope > .dialog-actions');
|
||||
return {
|
||||
title: text(dialog.querySelector('#modal-title')),
|
||||
tree: [...body.children].map(name),
|
||||
heading: body.querySelector(':scope > h3') && { text: text(body.querySelector(':scope > h3')),
|
||||
...css(body.querySelector(':scope > h3'), 'fontSize', 'fontWeight', 'color', 'marginBottom') },
|
||||
grid: grid && { rows: [...grid.children].map(row => [text(row.querySelector('dt')), text(row.querySelector('dd'))]),
|
||||
...css(grid, 'display', 'gridTemplateColumns', 'columnGap', 'rowGap'),
|
||||
dt: css(grid.querySelector('dt'), 'fontSize', 'color', 'textTransform'), dd: css(grid.querySelector('dd'), 'fontSize', 'color') },
|
||||
details: body.querySelector(':scope > details') && { summary: text(body.querySelector(':scope > details > summary')),
|
||||
tree: [...body.querySelector(':scope > details').children].map(name) },
|
||||
backup: body.querySelector(':scope > .notice') && { tree: [...body.querySelector(':scope > .notice').children].map(name),
|
||||
text: [...body.querySelectorAll(':scope > .notice > div:first-child > *')].map(node => [node.localName, text(node)]),
|
||||
buttons: [...body.querySelectorAll(':scope > .notice button')].map(text) },
|
||||
compare: compare && { tree: [...compare.children].map(name), ...css(compare, 'display', 'gridTemplateColumns', 'columnGap'),
|
||||
items: [...compare.children].map(entry => ({ tree: [...entry.children].map(name), label: text(entry.querySelector('label')),
|
||||
checked: entry.querySelector('input[type=radio]').checked, image: entry.querySelector('img')?.className ?? null })),
|
||||
item: css(item, 'paddingTop', 'paddingLeft', 'backgroundColor', 'borderTopWidth', 'borderTopColor', 'borderTopLeftRadius'),
|
||||
image: css(item.querySelector('img'), 'width', 'objectFit'),
|
||||
label: css(item.querySelector('label'), 'fontSize', 'color'),
|
||||
radio: { name: item.querySelector('input[type=radio]').name.startsWith('keep-') ? 'keep-*' : 'other',
|
||||
type: item.querySelector('input').type } },
|
||||
note: note && { text: text(note), ...css(note, 'fontSize', 'color', 'marginTop') },
|
||||
actions: { tree: [...actions.children].map(name), ...css(actions, 'display', 'justifyContent', 'columnGap', 'marginTop'),
|
||||
buttons: [...actions.children].map(item => ({ text: text(item), className: item.className,
|
||||
...css(item, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft', 'borderTopColor') })) },
|
||||
};
|
||||
}
|
||||
|
||||
// The concept's plan names (`data-plan`) and production's operation, row and button for each.
|
||||
const PLANS = Object.freeze([
|
||||
['duplicate prompts', 'duplicate_prompts', 'Duplicate prompt text'],
|
||||
['database optimization', 'optimize', 'Optimize database'],
|
||||
['unused prompt components', 'unused_components', 'Unused prompt components'],
|
||||
['missing-file links', 'missing_links', 'Missing-file links'],
|
||||
]);
|
||||
const BACKUP_BOX = Object.freeze({ tree: ['div', 'div.row-actions'], text: [['h3', 'Back up the database first (recommended)'],
|
||||
['p', 'Downloads a copy of the prompts database before this operation changes any records.']], buttons: ['Back up now'] });
|
||||
|
||||
/**
|
||||
* D100: the server's plan in the concept's dialog: the operation's row title as its `h3` (the concept prints its
|
||||
* `data-plan` name), the four rows with the plan's values, the listed records (`details`), the hold line in the
|
||||
* concept's `p.small`, the D55 backup box before the actions, and "Apply reviewed operation". No sample note.
|
||||
*/
|
||||
function mapPlanConcept(reference, title, plan) {
|
||||
const backup = plan.backup.policy === 'off' ? 'No safety backup (the policy is off)'
|
||||
: `Create and verify before changes (${plan.backup.destination_label})`;
|
||||
const tree = ['h3', 'dl.meta-grid', ...(plan.details.length ? ['details'] : []), 'p.small', 'div.notice', 'div.dialog-actions'];
|
||||
const [cancel, apply] = reference.actions.buttons;
|
||||
return {
|
||||
...reference,
|
||||
tree,
|
||||
heading: { ...reference.heading, text: title },
|
||||
grid: { ...reference.grid, rows: [['Scope', plan.scope.label],
|
||||
['Protected', plan.protected.reasons.length ? plan.protected.reasons.join(' · ') : 'Nothing is protected'],
|
||||
['Backup', backup], ['Media on disk', plan.media.label]] },
|
||||
details: plan.details.length ? { summary: `Listed records (${plan.details.length})`, tree: ['summary', 'pre'] } : null,
|
||||
backup: BACKUP_BOX,
|
||||
note: { ...reference.note, text: 'Nothing changes until you apply. This review holds for 15 minutes.' },
|
||||
actions: { ...reference.actions, buttons: [cancel, { ...apply, text: 'Apply reviewed operation' }] },
|
||||
};
|
||||
}
|
||||
|
||||
async function conceptPlan(page, theme, name) {
|
||||
await conceptMaintenance(page, theme);
|
||||
await page.locator(`#main [data-plan="${name}"]`).click();
|
||||
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Review maintenance plan');
|
||||
await page.mouse.move(0, 0);
|
||||
return page.evaluate(measureReview);
|
||||
}
|
||||
|
||||
/** Production's plan dialog for the row `title`, with the server's answer its review read. */
|
||||
async function productionPlan(page, title) {
|
||||
const answer = page.waitForResponse(response => response.url().endsWith(PREVIEW_URL));
|
||||
await page.locator('#workspace-main .operation').filter({ has: page.locator('h3', { hasText: title }) })
|
||||
.getByRole('button', { name: 'Review plan', exact: true }).click();
|
||||
const plan = await (await answer).json();
|
||||
await expect(page.locator('dialog#modal[open] #modal-body > h3')).toHaveText(title);
|
||||
await page.mouse.move(0, 0);
|
||||
return { plan, production: await page.evaluate(measureReview) };
|
||||
}
|
||||
|
||||
for (const theme of THEMES) for (const [width, height] of SIZES) {
|
||||
test(`plan dialogs match the concept ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
|
||||
await page.setViewportSize({ width, height });
|
||||
// Something to merge, and a missing-file link, so no plan is empty.
|
||||
await expect(await request.post('/__test__/system/duplicate-prompts')).toBeOK();
|
||||
await expect(await request.post('/__test__/system/missing-file')).toBeOK();
|
||||
const concepts = [];
|
||||
for (const [name] of PLANS) {
|
||||
concepts.push(await conceptPlan(page, theme, name));
|
||||
await page.screenshot({ path: testInfo.outputPath(`concept-plan-${name.replace(/\W+/g, '-')}-${theme}-${width}.png`) });
|
||||
}
|
||||
expect(concepts[0].tree).toEqual(['p.demo-note', 'h3', 'dl.meta-grid', 'p.small', 'div.dialog-actions']);
|
||||
await productionMaintenance(page, theme);
|
||||
for (const [index, [, operation, title]] of PLANS.entries()) {
|
||||
const { plan, production } = await productionPlan(page, title);
|
||||
expect(plan.operation).toBe(operation);
|
||||
await page.screenshot({ path: testInfo.outputPath(`production-plan-${operation}-${theme}-${width}.png`) });
|
||||
expect(diffInventory(mapPlanConcept(concepts[index], title, plan), production)).toEqual([]);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('dialog#modal[open]')).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* D101: the real group in the concept's markup: the status line (the concept's sample note's place), a group's
|
||||
* `p.small` and `.compare-grid` in one wrapper, a "Keep <source>/<path>" radio per copy, the oldest checked, and the
|
||||
* production note in the concept's `p.small`.
|
||||
*/
|
||||
function mapDuplicatesConcept(reference) {
|
||||
const items = reference.compare.items.map((item, index) => ({ ...item,
|
||||
label: `Keep output/s7-dupes/group_00_${index ? 'b' : 'a'}.png` }));
|
||||
return {
|
||||
...reference,
|
||||
tree: ['p.small', 'div.duplicate-groups', 'p.small', 'div.dialog-actions'],
|
||||
compare: { ...reference.compare, items },
|
||||
note: { ...reference.note, text: 'Removing a copy deletes its file from disk; its database links move to the kept copy. '
|
||||
+ 'History keeps the removed paths in its run records. Nothing is deleted until you review and apply the cleanup.' },
|
||||
};
|
||||
}
|
||||
|
||||
async function conceptDuplicates(page, theme) {
|
||||
await conceptMaintenance(page, theme);
|
||||
await page.locator('#main').getByRole('button', { name: 'Find duplicates', exact: true }).click();
|
||||
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Review duplicate image files');
|
||||
await page.mouse.move(0, 0);
|
||||
return page.evaluate(measureReview);
|
||||
}
|
||||
|
||||
async function productionDuplicates(page, theme) {
|
||||
await productionMaintenance(page, theme);
|
||||
await page.locator('#workspace-main').getByRole('button', { name: 'Find duplicates', exact: true }).click();
|
||||
await page.locator('#workspace-main').getByRole('button', { name: 'Review groups', exact: true }).click();
|
||||
await expect(page.locator('dialog#modal[open] .compare-item img')).toHaveCount(2);
|
||||
await page.locator('dialog#modal[open] .compare-item img').evaluateAll(images => Promise.all(images.map(image => image.decode())));
|
||||
await page.mouse.move(0, 0);
|
||||
return page.evaluate(measureReview);
|
||||
}
|
||||
|
||||
for (const theme of THEMES) for (const [width, height] of SIZES) {
|
||||
test(`duplicates dialog matches the concept ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
|
||||
await page.setViewportSize({ width, height });
|
||||
await expect(await request.post('/__test__/system/duplicate-files', { data: { groups: 1, trio: false } })).toBeOK();
|
||||
const concept = await conceptDuplicates(page, theme);
|
||||
expect(concept.tree).toEqual(['p.demo-note', 'div.compare-grid', 'p.small', 'div.dialog-actions']);
|
||||
await page.screenshot({ path: testInfo.outputPath(`concept-duplicates-${theme}-${width}.png`) });
|
||||
const production = await productionDuplicates(page, theme);
|
||||
await page.screenshot({ path: testInfo.outputPath(`production-duplicates-${theme}-${width}.png`) });
|
||||
expect(diffInventory(mapDuplicatesConcept(concept), production)).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test('maintenance comparison detects mutations', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const concept = await conceptMaintenance(page, 'dark');
|
||||
const expected = mapMaintenanceConcept(concept);
|
||||
const production = await productionMaintenance(page, 'dark');
|
||||
expect(diffInventory(expected, production)).toEqual([]);
|
||||
// Unmapped, the section differs only on the D102 row.
|
||||
expect(diffInventory(concept, production).map(item => item.path).sort()).toEqual([
|
||||
'surfaces.1.rows.3.actions.0.cursor', 'surfaces.1.rows.3.actions.0.opacity', 'surfaces.1.rows.3.description']);
|
||||
const measure = () => page.evaluate(measureMaintenance, '#workspace-main');
|
||||
const paths = async () => diffInventory(expected, await measure()).map(item => item.path);
|
||||
// A removed row, a changed label, a lost danger mark and a swapped token.
|
||||
const undoRow = await mutate(page, { remove: ['#workspace-main section.surface:nth-of-type(1) > .operation:nth-of-type(2)'] });
|
||||
expect(await paths()).toEqual(expect.arrayContaining(['surfaces.0.rows.length', 'surfaces.0.tree.length']));
|
||||
await undoRow();
|
||||
await page.evaluate(() => { document.querySelector('#workspace-main section.surface h3').textContent = 'Integrity checks'; });
|
||||
expect(await paths()).toEqual(['surfaces.0.rows.0.title']);
|
||||
await page.evaluate(() => { document.querySelector('#workspace-main section.surface h3').textContent = 'Integrity & relationship checks'; });
|
||||
await page.evaluate(() => { document.querySelector('#workspace-main button.danger').classList.remove('danger'); });
|
||||
expect(await paths()).toEqual(expect.arrayContaining(['surfaces.0.rows.3.actions.0.className']));
|
||||
await page.evaluate(() => { document.querySelectorAll('#workspace-main section.surface')[0].querySelectorAll('.row-actions > button')[3].classList.add('danger'); });
|
||||
const undoToken = await mutate(page, { css: '#workspace-main .settings-layout { --line: rgb(255, 0, 0); }' });
|
||||
expect(await paths()).toEqual(expect.arrayContaining(['operation.borderTopColor']));
|
||||
await undoToken();
|
||||
expect(diffInventory(expected, await measure())).toEqual([]);
|
||||
|
||||
// The plan dialog: a changed grid term and the duplicates dialog's grid token are noticed.
|
||||
await expect(await request.post('/__test__/system/duplicate-prompts')).toBeOK();
|
||||
const conceptPlanDialog = await conceptPlan(page, 'dark', 'duplicate prompts');
|
||||
await productionMaintenance(page, 'dark');
|
||||
const { plan } = await productionPlan(page, 'Duplicate prompt text');
|
||||
const expectedPlan = mapPlanConcept(conceptPlanDialog, 'Duplicate prompt text', plan);
|
||||
const planPaths = async () => diffInventory(expectedPlan, await page.evaluate(measureReview)).map(item => item.path);
|
||||
expect(await planPaths()).toEqual([]);
|
||||
await page.evaluate(() => { document.querySelector('dialog#modal[open] dl.meta-grid dt').textContent = 'Impact'; });
|
||||
expect(await planPaths()).toEqual(['grid.rows.0.0']);
|
||||
await page.evaluate(() => { document.querySelector('dialog#modal[open] dl.meta-grid dt').textContent = 'Scope'; });
|
||||
const undoGrid = await mutate(page, { css: 'dialog#modal dl.meta-grid { column-gap: 3px; }' });
|
||||
expect(await planPaths()).toEqual(expect.arrayContaining(['grid.columnGap']));
|
||||
await undoGrid();
|
||||
expect(await planPaths()).toEqual([]);
|
||||
});
|
||||
|
||||
test('duplicates comparison detects mutations', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await expect(await request.post('/__test__/system/duplicate-files', { data: { groups: 1, trio: false } })).toBeOK();
|
||||
const expected = mapDuplicatesConcept(await conceptDuplicates(page, 'dark'));
|
||||
await productionDuplicates(page, 'dark');
|
||||
const paths = async () => diffInventory(expected, await page.evaluate(measureReview)).map(item => item.path);
|
||||
expect(await paths()).toEqual([]);
|
||||
const undoToken = await mutate(page, { css: 'dialog#modal .compare-item { background: rgb(255, 0, 0); }' });
|
||||
expect(await paths()).toEqual(['compare.item.backgroundColor']);
|
||||
await undoToken();
|
||||
await page.evaluate(() => { document.querySelector('dialog#modal[open] .dialog-actions button:last-child').classList.add('ca-primary'); });
|
||||
expect(await paths()).toEqual(expect.arrayContaining(['actions.buttons.1.className']));
|
||||
await page.evaluate(() => { document.querySelector('dialog#modal[open] .dialog-actions button:last-child').classList.remove('ca-primary'); });
|
||||
const undoItem = await mutate(page, { remove: ['dialog#modal[open] .compare-item:last-child'] });
|
||||
expect(await paths()).toEqual(expect.arrayContaining(['compare.items.length']));
|
||||
await undoItem();
|
||||
expect(await paths()).toEqual([]);
|
||||
});
|
||||
@@ -99,6 +99,34 @@ for (const leave of ['beforeunload', 'pagehide']) {
|
||||
});
|
||||
}
|
||||
|
||||
// #222: the gallery's Clear cache clears the server's thumbnails by its own request and keeps this browser's
|
||||
// preferences and other tools' storage; nothing of Prompt Manager's is cached in a browser, and the toast says so.
|
||||
test('the gallery cache clear keeps preferences and other tools caches', async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
if (sessionStorage.getItem('seeded')) return;
|
||||
sessionStorage.setItem('seeded', '1');
|
||||
localStorage.setItem('pm_workspace_theme', 'light');
|
||||
localStorage.setItem('other-tool-setting', 'keep');
|
||||
localStorage.setItem('pm_other_tool', 'keep');
|
||||
});
|
||||
await page.goto('/prompt_manager/gallery');
|
||||
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(2);
|
||||
await page.evaluate(async () => { await (await caches.open('other-tool-gallery-thumbnails')).put('/x', new Response('x')); });
|
||||
const clears = [];
|
||||
page.on('request', req => { if (req.url().includes('/clear-thumbnails')) clears.push(req.method()); });
|
||||
page.once('dialog', dialog => dialog.accept());
|
||||
await page.locator('#settingsBtn').click();
|
||||
await page.locator('#clearCacheBtn').click();
|
||||
await expect(page.getByText("Thumbnails cleared; original images untouched. Nothing of Prompt Manager's is cached in this browser."))
|
||||
.toBeVisible();
|
||||
expect(clears).toEqual(['POST']);
|
||||
expect(await page.evaluate(async () => ({
|
||||
theme: localStorage.getItem('pm_workspace_theme'), other: localStorage.getItem('other-tool-setting'),
|
||||
prefixed: localStorage.getItem('pm_other_tool'), notice: localStorage.getItem('pm_update_notice_dismissed'),
|
||||
caches: await caches.keys(),
|
||||
}))).toEqual({ theme: 'light', other: 'keep', prefixed: 'keep', notice: '3.2.0', caches: ['other-tool-gallery-thumbnails'] });
|
||||
});
|
||||
|
||||
test('actual gallery review generates and persists tags for the selected root', async ({ page, request }) => {
|
||||
await page.goto('/prompt_manager/gallery');
|
||||
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(2);
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { seedConcept } from './concept-inventory.mjs';
|
||||
|
||||
// System › Maintenance (#249 System S7 Task 19) on the concept-shaped real fixture and the S6 backend: the concept's
|
||||
// rows, the read-only health check, the D100 plan review applied as an Activity job, the D101 duplicate search and
|
||||
// review, the S5 thumbnail dialog and the D102 metadata cache row. The hooks are workspace_system_fixture.py's.
|
||||
const workspace = '/prompt_manager/workspace';
|
||||
const PREVIEW_URL = '/prompt_manager/maintenance/preview';
|
||||
const APPLY_URL = '/prompt_manager/maintenance/apply';
|
||||
const CHANGED = 'The library changed since the review; nothing was changed. Review the plan again.';
|
||||
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
await seedConcept(request);
|
||||
await expect(await request.post('/__test__/system/reset')).toBeOK();
|
||||
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
|
||||
page.systemErrors = [];
|
||||
page.on('pageerror', error => page.systemErrors.push(error.message));
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page, request, browserName }) => {
|
||||
await saveCoverage(page, browserName);
|
||||
await request.post('/__test__/release');
|
||||
await request.post('/__test__/jobs/restart');
|
||||
await request.post('/__test__/system/reset');
|
||||
expect(page.systemErrors).toEqual([]);
|
||||
});
|
||||
|
||||
const toast = page => page.locator('#workspace-toast');
|
||||
const layoutOf = page => page.locator('#workspace-main .settings-layout');
|
||||
const dialog = page => page.locator('dialog#modal[open]');
|
||||
const row = (page, title) => layoutOf(page).locator('.operation').filter({ has: page.locator('h3', { hasText: title }) });
|
||||
|
||||
async function serverJson(request, url) {
|
||||
const response = await request.get(url);
|
||||
await expect(response).toBeOK();
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function openMaintenance(page) {
|
||||
await page.goto(`${workspace}#/system/maintenance`);
|
||||
await expect(layoutOf(page).locator(':scope > section.surface')).toHaveCount(2);
|
||||
return layoutOf(page);
|
||||
}
|
||||
|
||||
/** The open plan dialog's `[dt, dd]` rows. */
|
||||
const gridOf = page => dialog(page).locator('dl.meta-grid > div').evaluateAll(items => items.map(item =>
|
||||
[item.querySelector('dt').textContent, item.querySelector('dd').textContent]));
|
||||
|
||||
/** Wait until the job `id` ended on the server; answers its summary. */
|
||||
async function ended(request, id) {
|
||||
await expect.poll(async () => (await serverJson(request, `/prompt_manager/jobs/${id}`)).job.state).not.toBe('running');
|
||||
return (await serverJson(request, `/prompt_manager/jobs/${id}`)).job;
|
||||
}
|
||||
|
||||
test('the section shows the concept rows; Metadata cache follows Q5 (D102)', async ({ page }) => {
|
||||
const layout = await openMaintenance(page);
|
||||
await expect(layout.locator(':scope > .notice h2')).toHaveText('Check first. Review changes. Then apply.');
|
||||
await expect(layout.locator(':scope > section.handoff')).toHaveCount(0);
|
||||
expect(await layout.locator(':scope > section.surface').evaluateAll(surfaces => surfaces.map(surface => [
|
||||
surface.querySelector('h2').textContent,
|
||||
[...surface.querySelectorAll('.operation')].map(item => [item.querySelector('h3').textContent,
|
||||
item.querySelector('.row-actions > button').textContent, item.querySelector('.row-actions > button').className]),
|
||||
]))).toEqual([
|
||||
['Database', [['Integrity & relationship checks', 'Check', ''], ['Duplicate prompt text', 'Review plan', ''],
|
||||
['Optimize database', 'Review plan', ''], ['Unused prompt components', 'Review plan', 'danger']]],
|
||||
['Images & cache', [['Missing-file links', 'Review plan', 'danger'], ['Duplicate image files', 'Find duplicates', ''],
|
||||
['Thumbnail cache', 'Review options', ''], ['Metadata cache', 'Review plan', '']]],
|
||||
]);
|
||||
// Nothing of Prompt Manager's is cached in a browser (#222): the row says so and its review is unavailable.
|
||||
const cache = row(page, 'Metadata cache');
|
||||
await expect(cache.locator('p')).toHaveText('Clear only Prompt Manager metadata cached by this browser. Nothing is cached in this browser.');
|
||||
const review = cache.getByRole('button', { name: 'Review plan', exact: true });
|
||||
await expect(review).toHaveAttribute('aria-disabled', 'true');
|
||||
// An aria-disabled button stays focusable and clickable; its press opens nothing.
|
||||
await review.dispatchEvent('click');
|
||||
await expect(review).toHaveAttribute('aria-disabled', 'true');
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Metadata cache reviews and clears only owned browser entries when there are some (D102)', async ({ page }) => {
|
||||
// The shipped inventory is empty (#222); this test names one owned entry through the module's inventory seam.
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('pm-owned-test-entry', 'cached');
|
||||
localStorage.setItem('pm_workspace_theme', 'dark');
|
||||
let wrapped;
|
||||
Object.defineProperty(window, 'CacheOwnership', { configurable: true, get: () => wrapped, set(api) {
|
||||
const owned = { storageKeys: ['pm-owned-test-entry'], cacheNames: [] };
|
||||
wrapped = { ...api, ownedEntries: options => api.ownedEntries({ ...options, owned }),
|
||||
clearOwned: options => api.clearOwned({ ...options, owned }) };
|
||||
} });
|
||||
});
|
||||
await openMaintenance(page);
|
||||
const cache = row(page, 'Metadata cache');
|
||||
const review = cache.getByRole('button', { name: 'Review plan', exact: true });
|
||||
await expect(review).not.toHaveAttribute('aria-disabled', 'true');
|
||||
await expect(cache.locator('p')).toHaveText('Clear only Prompt Manager metadata cached by this browser.');
|
||||
await review.click();
|
||||
await expect(dialog(page).locator('#modal-body > h3')).toHaveText('Metadata cache');
|
||||
expect(await gridOf(page)).toEqual([['Scope', '1 cached entry in this browser'],
|
||||
['Protected', 'Preferences and other tools’ data in this browser'], ['Backup', 'Not needed: the database is not changed'],
|
||||
['Media on disk', 'No files are removed']]);
|
||||
await dialog(page).getByRole('button', { name: 'Clear browser cache', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Cleared 1 cached entry from this browser.');
|
||||
await expect(review).toHaveAttribute('aria-disabled', 'true');
|
||||
expect(await page.evaluate(() => [localStorage.getItem('pm-owned-test-entry'), localStorage.getItem('pm_workspace_theme')]))
|
||||
.toEqual([null, 'dark']);
|
||||
});
|
||||
|
||||
test('Run health checks and Check open the Health report; Review missing links starts a fresh preview, never a cleanup',
|
||||
async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/system/missing-file')).toBeOK();
|
||||
const before = await serverJson(request, '/__test__/system/digest');
|
||||
const previews = [];
|
||||
page.on('request', item => { if (item.url().endsWith(PREVIEW_URL)) previews.push(item.postDataJSON()); });
|
||||
await openMaintenance(page);
|
||||
const check = row(page, 'Integrity & relationship checks').getByRole('button', { name: 'Check', exact: true });
|
||||
await check.click();
|
||||
await expect(dialog(page).locator('#modal-title')).toHaveText('Health report');
|
||||
await expect(dialog(page).locator('.operation').filter({ hasText: 'Missing files · output' }).locator('.chip'))
|
||||
.toHaveText('1 link needs review');
|
||||
await expect(check).toHaveText('Check');
|
||||
await dialog(page).getByRole('button', { name: 'Review missing links', exact: true }).click();
|
||||
await expect(dialog(page).locator('#modal-title')).toHaveText('Review maintenance plan');
|
||||
await expect(dialog(page).locator('#modal-body > h3')).toHaveText('Missing-file links');
|
||||
expect(previews).toEqual([{ operation: 'missing_links', options: {} }]);
|
||||
expect((await gridOf(page))[0]).toEqual(['Scope', '1 link to a missing file']);
|
||||
await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
await expect(check).toBeFocused();
|
||||
// The notice's Run health checks opens the same report.
|
||||
await layoutOf(page).getByRole('button', { name: 'Run health checks', exact: true }).click();
|
||||
await expect(dialog(page).locator('#modal-title')).toHaveText('Health report');
|
||||
expect(await serverJson(request, '/__test__/system/digest')).toEqual(before);
|
||||
});
|
||||
|
||||
test("plan dialog shows the server's scope, protected, backup policy and media lines with the backup box (D100); Cancel writes nothing",
|
||||
async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/system/duplicate-prompts')).toBeOK();
|
||||
const before = await serverJson(request, '/__test__/system/digest');
|
||||
const answers = [];
|
||||
page.on('response', response => { if (response.url().endsWith(PREVIEW_URL)) answers.push(response.json()); });
|
||||
await openMaintenance(page);
|
||||
const reviewButton = row(page, 'Duplicate prompt text').getByRole('button', { name: 'Review plan', exact: true });
|
||||
await reviewButton.click();
|
||||
const plan = dialog(page);
|
||||
await expect(plan.locator('#modal-title')).toHaveText('Review maintenance plan');
|
||||
const answer = await answers[0];
|
||||
expect(answer.scope.count).toBe(1);
|
||||
await expect(plan.locator('#modal-body > h3')).toHaveText('Duplicate prompt text');
|
||||
expect(await gridOf(page)).toEqual([
|
||||
['Scope', answer.scope.label],
|
||||
['Protected', answer.protected.reasons.length ? answer.protected.reasons.join(' · ') : 'Nothing is protected'],
|
||||
['Backup', `Create and verify before changes (${answer.backup.destination_label})`],
|
||||
['Media on disk', answer.media.label],
|
||||
]);
|
||||
expect(await plan.locator('#modal-body').evaluate(body => [...body.children].map(node =>
|
||||
[node.localName, ...node.classList].join('.')))).toEqual(['h3', 'dl.meta-grid', 'details', 'p.small', 'div.notice',
|
||||
'div.dialog-actions']);
|
||||
await expect(plan.locator('details > summary')).toHaveText('Listed records (1)');
|
||||
await expect(plan.locator('#modal-body > p.small')).toHaveText(/^Nothing changes until you apply\. This review holds for 1[45] minutes\.$/);
|
||||
await expect(plan.locator('.notice h3')).toHaveText('Back up the database first (recommended)');
|
||||
await expect(plan.locator('.dialog-actions button')).toHaveText(['Cancel', 'Apply reviewed operation']);
|
||||
await plan.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
await expect(reviewButton).toBeFocused();
|
||||
// Optimize and Unused prompt components are reviewed the same way.
|
||||
for (const [title, scope] of [['Optimize database', 'The whole database'], ['Unused prompt components', null]]) {
|
||||
await row(page, title).getByRole('button', { name: 'Review plan', exact: true }).click();
|
||||
await expect(dialog(page).locator('#modal-body > h3')).toHaveText(title);
|
||||
const value = (await answers.at(-1)).scope.label;
|
||||
expect((await gridOf(page))[0]).toEqual(['Scope', scope ?? value]);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
}
|
||||
expect(await serverJson(request, '/__test__/system/digest')).toEqual(before);
|
||||
});
|
||||
|
||||
test('a plan with nothing to do shows only Close, never Apply', async ({ page, request }) => {
|
||||
const applies = [];
|
||||
page.on('request', item => { if (item.url().endsWith(APPLY_URL)) applies.push(item.url()); });
|
||||
await openMaintenance(page);
|
||||
// The concept fixture has no duplicate prompt text.
|
||||
const reviewButton = row(page, 'Duplicate prompt text').getByRole('button', { name: 'Review plan', exact: true });
|
||||
await reviewButton.click();
|
||||
await expect(dialog(page).locator('#modal-body > h3')).toHaveText('Duplicate prompt text');
|
||||
expect((await gridOf(page))[0]).toEqual(['Scope', 'No duplicate prompt text']);
|
||||
await expect(dialog(page).locator('#modal-body > p.small')).toHaveText('There is nothing to apply; nothing was changed.');
|
||||
await expect(dialog(page).locator('.dialog-actions button')).toHaveText(['Close']);
|
||||
await expect(dialog(page).locator('.notice')).toHaveCount(0);
|
||||
await dialog(page).getByRole('button', { name: 'Close', exact: true }).click();
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
await expect(reviewButton).toBeFocused();
|
||||
expect(applies).toEqual([]);
|
||||
});
|
||||
|
||||
test('Apply reviewed operation closes, toasts, opens the job in Activity', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/system/duplicate-prompts')).toBeOK();
|
||||
const applies = [];
|
||||
page.on('request', item => { if (item.url().endsWith(APPLY_URL)) applies.push(item.postDataJSON()); });
|
||||
await openMaintenance(page);
|
||||
await row(page, 'Duplicate prompt text').getByRole('button', { name: 'Review plan', exact: true }).click();
|
||||
await dialog(page).getByRole('button', { name: 'Apply reviewed operation', exact: true }).click();
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/);
|
||||
await expect(toast(page)).toHaveText(/Duplicate prompt text (started|finished · 1 changed)/);
|
||||
expect(applies).toHaveLength(1);
|
||||
expect(Object.keys(applies[0])).toEqual(['token']);
|
||||
const job = await ended(request, page.url().split('/').pop());
|
||||
expect([job.kind, job.state, job.counters.changed]).toEqual(['maintenance', 'finished', 1]);
|
||||
const main = page.locator('#workspace-main');
|
||||
await expect(main.locator('h1')).toHaveText('Duplicate prompt text');
|
||||
await expect(main.locator('.stat-row .stat dt')).toHaveText(['Changed', 'Protected', 'Errors']);
|
||||
await expect(main.locator('.stat-row .stat dd').first()).toHaveText('1');
|
||||
// Not cancellable: no Cancel, a report like every job.
|
||||
await expect(main.getByRole('button', { name: 'Cancel job', exact: true })).toHaveCount(0);
|
||||
await expect(main.getByRole('button', { name: 'Download report', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('plan-changed asks for a new review', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/system/duplicate-prompts')).toBeOK();
|
||||
await openMaintenance(page);
|
||||
await row(page, 'Duplicate prompt text').getByRole('button', { name: 'Review plan', exact: true }).click();
|
||||
await expect(dialog(page).locator('#modal-body > h3')).toHaveText('Duplicate prompt text');
|
||||
// The library changes after the review: the apply's recheck refuses it and nothing is changed.
|
||||
await expect(await request.post('/__test__/system/duplicate-prompts', { data: { again: true } })).toBeOK();
|
||||
const before = await serverJson(request, '/__test__/system/digest');
|
||||
await dialog(page).getByRole('button', { name: 'Apply reviewed operation', exact: true }).click();
|
||||
await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/);
|
||||
await expect(toast(page)).toHaveText(`Duplicate prompt text failed · ${CHANGED}`);
|
||||
await expect(page.locator('#workspace-main')).toContainText(CHANGED);
|
||||
expect((await serverJson(request, '/__test__/system/digest')).rows).toEqual(before.rows);
|
||||
// S7 review M2: the failed job offers Review again, which reviews the same operation afresh.
|
||||
const previews = [];
|
||||
page.on('request', item => { if (item.url().endsWith(PREVIEW_URL)) previews.push(item.postDataJSON()); });
|
||||
await page.locator('#workspace-main').getByRole('button', { name: 'Review again', exact: true }).click();
|
||||
await expect(dialog(page).locator('#modal-title')).toHaveText('Review maintenance plan');
|
||||
await expect(dialog(page).locator('#modal-body > h3')).toHaveText('Duplicate prompt text');
|
||||
expect(previews).toEqual([{ operation: 'duplicate_prompts', options: {} }]);
|
||||
});
|
||||
|
||||
test('a review button pressed twice sends one preview (S7 review M4)', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/system/missing-file')).toBeOK();
|
||||
await expect(await request.post('/__test__/system/duplicate-files')).toBeOK();
|
||||
const previews = [];
|
||||
page.on('request', item => { if (item.url().endsWith(PREVIEW_URL)) previews.push(item.postDataJSON().operation); });
|
||||
await openMaintenance(page);
|
||||
await row(page, 'Integrity & relationship checks').getByRole('button', { name: 'Check', exact: true }).click();
|
||||
// Hold the preview at the server so the second press lands while it is read.
|
||||
await expect(await request.post('/__test__/hold', { data: { path: PREVIEW_URL } })).toBeOK();
|
||||
// The pressed button reads "Reviewing…" while held, so it is found by its place, not its name.
|
||||
const missing = dialog(page).locator('.dialog-actions > button').last();
|
||||
await expect(missing).toHaveText('Review missing links');
|
||||
await missing.click();
|
||||
await expect(missing).toHaveAttribute('aria-disabled', 'true');
|
||||
await expect(missing).toHaveText('Reviewing…');
|
||||
await missing.dispatchEvent('click');
|
||||
await expect.poll(async () => (await serverJson(request, '/__test__/hold')).held[PREVIEW_URL]).toBe(1);
|
||||
await expect(await request.post('/__test__/release')).toBeOK();
|
||||
await expect(dialog(page).locator('#modal-body > h3')).toHaveText('Missing-file links');
|
||||
expect(previews).toEqual(['missing_links']);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await row(page, 'Duplicate image files').getByRole('button', { name: 'Find duplicates', exact: true }).click();
|
||||
await page.locator('#workspace-main').getByRole('button', { name: 'Review groups', exact: true }).click();
|
||||
await expect(await request.post('/__test__/hold', { data: { path: PREVIEW_URL } })).toBeOK();
|
||||
const cleanup = dialog(page).locator('.dialog-actions > button').last();
|
||||
await expect(cleanup).toHaveText('Review duplicate cleanup');
|
||||
await cleanup.click();
|
||||
await expect(cleanup).toHaveAttribute('aria-disabled', 'true');
|
||||
await cleanup.dispatchEvent('click');
|
||||
await expect.poll(async () => (await serverJson(request, '/__test__/hold')).held[PREVIEW_URL]).toBe(1);
|
||||
await expect(await request.post('/__test__/release')).toBeOK();
|
||||
await expect(dialog(page).locator('#modal-body > h3')).toHaveText('Duplicate image files');
|
||||
expect(previews).toEqual(['missing_links', 'duplicate_files']);
|
||||
});
|
||||
|
||||
test('plan-used links the job that applied it', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/system/duplicate-prompts')).toBeOK();
|
||||
// A double-submitted apply: the first request applies the plan, the second one's answer reaches the page.
|
||||
await page.route(`**${APPLY_URL}`, async route => {
|
||||
await route.fetch();
|
||||
await route.fulfill({ response: await route.fetch() });
|
||||
});
|
||||
await openMaintenance(page);
|
||||
await row(page, 'Duplicate prompt text').getByRole('button', { name: 'Review plan', exact: true }).click();
|
||||
await dialog(page).getByRole('button', { name: 'Apply reviewed operation', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('This plan was already applied: Duplicate prompt text.');
|
||||
const link = toast(page).getByRole('link', { name: 'Duplicate prompt text', exact: true });
|
||||
const { jobs } = await serverJson(request, '/prompt_manager/jobs');
|
||||
const applied = jobs.find(job => job.kind === 'maintenance');
|
||||
await expect(link).toHaveAttribute('href', `#/activity/${applied.id}`);
|
||||
await expect(page).toHaveURL(`${workspace}#/system/maintenance`);
|
||||
});
|
||||
|
||||
/**
|
||||
* Record every toast the page shows (`[text, [link hrefs]]`): a job's finish toast may replace an earlier one before a
|
||||
* test reads it.
|
||||
*/
|
||||
async function recordToasts(page) {
|
||||
await page.addInitScript(() => {
|
||||
window.toastLog = [];
|
||||
const record = node => window.toastLog.push([node.textContent, [...node.querySelectorAll('a')].map(a => a.getAttribute('href'))]);
|
||||
new MutationObserver(() => {
|
||||
const node = document.getElementById('workspace-toast');
|
||||
const last = window.toastLog.at(-1);
|
||||
if (node && node.textContent && last?.[0] !== node.textContent) record(node);
|
||||
}).observe(document, { subtree: true, childList: true, characterData: true });
|
||||
});
|
||||
return () => page.evaluate(() => window.toastLog);
|
||||
}
|
||||
|
||||
const NOT_CONFIRMED = ['Outcome not confirmed. Check Activity before applying it again.', ['#/activity']];
|
||||
|
||||
/** The maintenance jobs the server lists (the apply ran even though its answer was lost). */
|
||||
async function maintenanceJobs(request) {
|
||||
return (await serverJson(request, '/prompt_manager/jobs')).jobs.filter(job => job.kind === 'maintenance');
|
||||
}
|
||||
|
||||
test('lost apply response says Outcome not confirmed and points to Activity', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/system/duplicate-prompts')).toBeOK();
|
||||
// The server applies the plan; its answer never reaches the page.
|
||||
const reads = [];
|
||||
await page.route(`**${APPLY_URL}`, async route => {
|
||||
await route.fetch();
|
||||
page.on('request', item => { if (item.url().endsWith('/prompt_manager/jobs')) reads.push(item.url()); });
|
||||
await route.abort('failed');
|
||||
});
|
||||
const toasts = await recordToasts(page);
|
||||
await openMaintenance(page);
|
||||
await row(page, 'Duplicate prompt text').getByRole('button', { name: 'Review plan', exact: true }).click();
|
||||
await dialog(page).getByRole('button', { name: 'Apply reviewed operation', exact: true }).click();
|
||||
await expect.poll(toasts).toContainEqual(NOT_CONFIRMED);
|
||||
await expect(page).toHaveURL(`${workspace}#/system/maintenance`);
|
||||
// The Activity list is read again at once, and the job the lost answer started is in it.
|
||||
await expect.poll(() => reads.length).toBeGreaterThan(0);
|
||||
expect((await maintenanceJobs(request)).map(job => job.title)).toEqual(['Duplicate prompt text']);
|
||||
await page.evaluate(() => { location.hash = '#/activity'; });
|
||||
await expect(page.locator('#workspace-main')).toContainText('Duplicate prompt text');
|
||||
});
|
||||
|
||||
test('a proxy error or an unreadable answer is not a refusal: Outcome not confirmed (S7 review I1)', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/system/duplicate-prompts')).toBeOK();
|
||||
const reads = [];
|
||||
// A reverse proxy times out after the server started the job.
|
||||
await page.route(`**${APPLY_URL}`, async route => {
|
||||
await route.fetch();
|
||||
page.on('request', item => { if (item.url().endsWith('/prompt_manager/jobs')) reads.push(item.url()); });
|
||||
await route.fulfill({ status: 504, contentType: 'text/html', body: '<html><body>Gateway Timeout</body></html>' });
|
||||
});
|
||||
const toasts = await recordToasts(page);
|
||||
await openMaintenance(page);
|
||||
await row(page, 'Duplicate prompt text').getByRole('button', { name: 'Review plan', exact: true }).click();
|
||||
await dialog(page).getByRole('button', { name: 'Apply reviewed operation', exact: true }).click();
|
||||
await expect.poll(toasts).toContainEqual(NOT_CONFIRMED);
|
||||
await expect.poll(() => reads.length).toBeGreaterThan(0);
|
||||
expect(await maintenanceJobs(request)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('a refused apply names the running job', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/system/duplicate-prompts')).toBeOK();
|
||||
await openMaintenance(page);
|
||||
await row(page, 'Duplicate prompt text').getByRole('button', { name: 'Review plan', exact: true }).click();
|
||||
await expect(dialog(page).locator('#modal-body > h3')).toHaveText('Duplicate prompt text');
|
||||
const hold = await (await request.post('/__test__/jobs/hold', { data: { title: 'Database restore (admin page)' } })).json();
|
||||
await dialog(page).getByRole('button', { name: 'Apply reviewed operation', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Another job is running: Database restore (admin page).');
|
||||
await expect(toast(page).getByRole('link')).toHaveAttribute('href', `#/activity/${hold.job.id}`);
|
||||
await expect(page).toHaveURL(`${workspace}#/system/maintenance`);
|
||||
});
|
||||
|
||||
test('Find duplicates starts a job; its view links Review groups; Keep radios build a duplicate_files plan (D101)',
|
||||
async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/system/duplicate-files')).toBeOK();
|
||||
const previews = [];
|
||||
page.on('request', item => { if (item.url().endsWith(PREVIEW_URL)) previews.push(item.postDataJSON()); });
|
||||
await openMaintenance(page);
|
||||
await row(page, 'Duplicate image files').getByRole('button', { name: 'Find duplicates', exact: true }).click();
|
||||
await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/);
|
||||
const jobId = page.url().split('/').pop();
|
||||
const main = page.locator('#workspace-main');
|
||||
await expect(main.locator('h1')).toHaveText('Find duplicate image files');
|
||||
const reviewGroups = main.getByRole('button', { name: 'Review groups', exact: true });
|
||||
await expect(reviewGroups).toBeVisible();
|
||||
await expect(main.locator('.stat-row .stat dt')).toHaveText(['Groups found']);
|
||||
await reviewGroups.click();
|
||||
const review = dialog(page);
|
||||
await expect(review.locator('#modal-title')).toHaveText('Review duplicate image files');
|
||||
const group = review.locator('.compare-grid').filter({ hasText: 's7-dupes/copy.png' });
|
||||
await expect(group.locator('.compare-item img')).toHaveCount(3);
|
||||
const labels = (await group.locator('.compare-item label').allTextContents()).map(text => text.trim());
|
||||
expect([...labels].sort()).toEqual(['Keep input/s7-dupes/same.png', 'Keep output/s7-dupes/copy.png',
|
||||
'Keep output/s7-dupes/same.png']);
|
||||
await expect(review.locator('#modal-body > p.small').last())
|
||||
.toHaveText(/History keeps the removed paths in its run records\./);
|
||||
// Keep the input copy: the same relative path as the output copy, told apart by its source index.
|
||||
await group.locator('.compare-item').filter({ hasText: 'input/s7-dupes/same.png' }).getByRole('radio').check();
|
||||
await review.getByRole('button', { name: 'Review duplicate cleanup', exact: true }).click();
|
||||
await expect(dialog(page).locator('#modal-title')).toHaveText('Review maintenance plan');
|
||||
await expect(dialog(page).locator('#modal-body > h3')).toHaveText('Duplicate image files');
|
||||
expect(previews).toHaveLength(1);
|
||||
expect(previews[0].operation).toBe('duplicate_files');
|
||||
expect(previews[0].options.job_id).toBe(jobId);
|
||||
expect(previews[0].options.keep).toContainEqual(expect.objectContaining({ root_index: 1, path: 's7-dupes/same.png' }));
|
||||
for (const entry of previews[0].options.keep) expect(Object.keys(entry).sort()).toEqual(['group_id', 'path', 'root_index']);
|
||||
expect((await gridOf(page))[3][1]).toMatch(/^Deletes 2 files \(.+\) from disk; the kept copies stay\./);
|
||||
await dialog(page).getByRole('button', { name: 'Apply reviewed operation', exact: true }).click();
|
||||
await expect(page).not.toHaveURL(new RegExp(jobId));
|
||||
await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/);
|
||||
const cleanup = await ended(request, page.url().split('/').pop());
|
||||
expect([cleanup.kind, cleanup.state, cleanup.counters.removed]).toEqual(['duplicate_cleanup', 'finished', 2]);
|
||||
await expect(main.locator('.stat-row .stat dt')).toHaveText(['Removed', 'Kept', 'Errors']);
|
||||
expect((await request.get('/prompt_manager/images/serve/s7-dupes/same.png?root=1')).status()).toBe(200);
|
||||
expect((await request.get('/prompt_manager/images/serve/s7-dupes/same.png?root=0')).status()).toBe(404);
|
||||
expect((await request.get('/prompt_manager/images/serve/s7-dupes/copy.png?root=0')).status()).toBe(404);
|
||||
});
|
||||
|
||||
test('the duplicates review pages ten groups at a time and keeps each choice (D101)', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/system/duplicate-files', { data: { groups: 11 } })).toBeOK();
|
||||
const groupReads = [];
|
||||
page.on('request', item => { if (item.url().includes('/maintenance/duplicates/')) groupReads.push(new URL(item.url()).search); });
|
||||
const previews = [];
|
||||
page.on('request', item => { if (item.url().endsWith(PREVIEW_URL)) previews.push(item.postDataJSON()); });
|
||||
await openMaintenance(page);
|
||||
await row(page, 'Duplicate image files').getByRole('button', { name: 'Find duplicates', exact: true }).click();
|
||||
const main = page.locator('#workspace-main');
|
||||
await main.getByRole('button', { name: 'Review groups', exact: true }).click();
|
||||
const review = dialog(page);
|
||||
const status = review.locator('#modal-body > p.small[role=status]');
|
||||
await expect(status).toHaveText('Groups 1–10 of 12');
|
||||
await expect(review.locator('.compare-grid')).toHaveCount(10);
|
||||
await expect(review.getByRole('button', { name: 'Previous groups', exact: true })).toBeDisabled();
|
||||
// The three-copy group sorts first (the most bytes to free): keep its input copy.
|
||||
const first = review.locator('.compare-grid').first();
|
||||
await first.locator('.compare-item').filter({ hasText: 'input/s7-dupes/same.png' }).getByRole('radio').check();
|
||||
await review.getByRole('button', { name: 'Next groups', exact: true }).click();
|
||||
await expect(status).toHaveText('Groups 11–12 of 12');
|
||||
await expect(review.locator('.compare-grid')).toHaveCount(2);
|
||||
await expect(review.getByRole('button', { name: 'Next groups', exact: true })).toBeDisabled();
|
||||
await review.getByRole('button', { name: 'Previous groups', exact: true }).click();
|
||||
await expect(status).toHaveText('Groups 1–10 of 12');
|
||||
await expect(first.locator('.compare-item').filter({ hasText: 'input/s7-dupes/same.png' }).getByRole('radio')).toBeChecked();
|
||||
expect(groupReads).toEqual(['?offset=0&limit=10', '?offset=10&limit=10', '?offset=0&limit=10']);
|
||||
await review.getByRole('button', { name: 'Review duplicate cleanup', exact: true }).click();
|
||||
await expect(dialog(page).locator('#modal-body > h3')).toHaveText('Duplicate image files');
|
||||
// Every group shown so far is reviewed, each with its chosen copy.
|
||||
expect(previews[0].options.keep).toHaveLength(12);
|
||||
expect(previews[0].options.keep).toContainEqual(expect.objectContaining({ root_index: 1, path: 's7-dupes/same.png' }));
|
||||
await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
for (const [width, height] of [[1600, 1100], [390, 844]]) {
|
||||
test(`long copy paths wrap; the duplicates review never scrolls sideways ${width}x${height}`, async ({ page, request }) => {
|
||||
await page.setViewportSize({ width, height });
|
||||
await expect(await request.post('/__test__/system/duplicate-files', { data: { long: true, trio: false } })).toBeOK();
|
||||
await openMaintenance(page);
|
||||
await row(page, 'Duplicate image files').getByRole('button', { name: 'Find duplicates', exact: true }).click();
|
||||
await page.locator('#workspace-main').getByRole('button', { name: 'Review groups', exact: true }).click();
|
||||
const review = dialog(page);
|
||||
await expect(review.locator('.compare-item')).toHaveCount(2);
|
||||
const overflow = await review.evaluate(node => {
|
||||
const body = node.querySelector('#modal-body');
|
||||
// An item wider than its box, or a label (inline: measured by its boxes) past its item's right edge.
|
||||
const wide = [...node.querySelectorAll('.compare-item')].filter(item => item.scrollWidth > item.clientWidth + 1
|
||||
|| [...item.querySelector('label').getClientRects()].some(rect => rect.right > item.getBoundingClientRect().right + 1)).length;
|
||||
return { dialog: node.scrollWidth > node.clientWidth + 1, body: body.scrollWidth > body.clientWidth + 1, wide,
|
||||
page: document.documentElement.scrollWidth > document.documentElement.clientWidth };
|
||||
});
|
||||
expect(overflow).toEqual({ dialog: false, body: false, wide: 0, page: false });
|
||||
});
|
||||
}
|
||||
|
||||
test('Thumbnail cache opens the S5 dialog', async ({ page }) => {
|
||||
await openMaintenance(page);
|
||||
const options = row(page, 'Thumbnail cache').getByRole('button', { name: 'Review options', exact: true });
|
||||
await options.click();
|
||||
await expect(dialog(page).locator('#modal-title')).toHaveText('Thumbnail maintenance');
|
||||
await expect(dialog(page).locator('.field > label')).toHaveText(['Scope']);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(options).toBeFocused();
|
||||
});
|
||||
@@ -173,8 +173,8 @@ test('Run health checks opens Health report and updates metric 4', async ({ page
|
||||
await page.goto(`${workspace}#/system/maintenance`);
|
||||
const layout = layoutOf(page);
|
||||
await expect(layout.locator(':scope > .notice h2')).toHaveText('Check first. Review changes. Then apply.');
|
||||
// The rest of Maintenance is still the existing tools' (D94).
|
||||
await expect(layout.locator(':scope > section.handoff h2')).toHaveText('Your current tools are ready');
|
||||
// Maintenance is native (S7): its two surfaces follow the notice.
|
||||
await expect(layout.locator(':scope > section.surface > h2')).toHaveText(['Database', 'Images & cache']);
|
||||
const run = layout.getByRole('button', { name: 'Run health checks', exact: true });
|
||||
await run.click();
|
||||
const dialog = page.locator('dialog#modal[open]');
|
||||
@@ -204,8 +204,11 @@ test('Run health checks opens Health report and updates metric 4', async ({ page
|
||||
.toHaveText(['1 link needs review', 'Passed', 'Not available']);
|
||||
await expect(dialog.locator('.operation h3').filter({ hasText: 'Missing files' }))
|
||||
.toHaveText(['Missing files · output', 'Missing files · input', 'Missing files · unmounted-drive']);
|
||||
// Review missing links goes to Maintenance, where the cleanup's existing tool is linked.
|
||||
// Review missing links reviews a fresh Missing-file links plan (S7); nothing is cleaned up.
|
||||
await dialog.getByRole('button', { name: 'Review missing links', exact: true }).click();
|
||||
await expect(dialog.locator('#modal-title')).toHaveText('Review maintenance plan');
|
||||
await expect(dialog.locator('#modal-body > h3')).toHaveText('Missing-file links');
|
||||
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(page.locator('dialog#modal[open]')).toHaveCount(0);
|
||||
await expect(page).toHaveURL(`${workspace}#/system/maintenance`);
|
||||
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Overview', exact: true }).click();
|
||||
|
||||
@@ -427,7 +427,6 @@ for (const width of [1600, 390]) test(`overview and handoff render with concept
|
||||
const SYSTEM_SECTION_LABELS = ['Overview', 'Appearance', 'Sources & scans', 'Integrations', 'Auto-tagging', 'Maintenance',
|
||||
'Backup & export', 'Diagnostics & logs'];
|
||||
const SYSTEM_HASHES = ['overview', 'appearance', 'sources', 'integrations', 'tagging', 'maintenance', 'backups', 'diagnostics'];
|
||||
const SYSTEM_HANDOFFS = { maintenance: ['/prompt_manager/admin', '/prompt_manager/gallery'] };
|
||||
|
||||
test('System is native: the concept shell, section navigation, sections, hand-offs and the Save row', async ({ page }) => {
|
||||
const mutations = [];
|
||||
@@ -464,20 +463,20 @@ test('System is native: the concept shell, section navigation, sections, hand-of
|
||||
// The section changes in place: focus stays on the pressed section, not the page title.
|
||||
await expect(button).toBeFocused();
|
||||
// S2 Task 6: Overview is native (workspace-system.spec.mjs), and so are Appearance (S3 Task 8) and Sources & scans
|
||||
// (S4 Task 11, workspace-system-sources.spec.mjs, its update notice shown in a fresh browser); Maintenance shows
|
||||
// its health-check notice above the hand-off (D123); every other section hands off.
|
||||
// (S4 Task 11, workspace-system-sources.spec.mjs, its update notice shown in a fresh browser) and Maintenance (S7
|
||||
// Task 19, workspace-system-maintenance.spec.mjs); every other section hands off.
|
||||
if (hash === 'overview') await expect(layout.locator(':scope > .summary-metrics')).toBeVisible();
|
||||
if (hash === 'sources') await expect(layout.locator('#source-list .operation')).toHaveCount(2);
|
||||
expect(await layout.evaluate(node => [...node.children].map(child => `${child.localName}.${child.className}`)))
|
||||
.toEqual({ overview: ['div.summary-metrics', 'div.two-col', 'div.save-row'], appearance: ['section.surface', 'section.surface', 'div.save-row'],
|
||||
sources: ['div.notice', 'section.surface', 'div.save-row'],
|
||||
maintenance: ['div.notice', 'section.surface handoff', 'div.save-row'] }[hash]
|
||||
maintenance: ['div.notice', 'section.surface', 'section.surface', 'div.save-row'] }[hash]
|
||||
?? ['section.surface handoff', 'div.save-row']);
|
||||
if (['overview', 'appearance', 'sources'].includes(hash)) continue;
|
||||
if (['overview', 'appearance', 'sources', 'maintenance'].includes(hash)) continue;
|
||||
const handoff = layout.locator('section.handoff');
|
||||
await expect(handoff.locator('h2')).toHaveText('Your current tools are ready');
|
||||
expect(await handoff.locator('a.workspace-link').evaluateAll(links => links.map(link => link.getAttribute('href'))))
|
||||
.toEqual(SYSTEM_HANDOFFS[hash] ?? ['/prompt_manager/admin']);
|
||||
.toEqual(['/prompt_manager/admin']);
|
||||
await expect(layout.locator('#settings-status')).toHaveText('No unsaved changes.');
|
||||
await expect(page.locator('aside.sidebar a[href="#/system"]')).toHaveAttribute('aria-current', 'page');
|
||||
}
|
||||
|
||||
@@ -37,10 +37,27 @@ read. These per-server hooks only change what a System test needs on top:
|
||||
server read as having (``cjxl``) or lacking a JPEG XL encoder without
|
||||
probing; reset restores the thumbnail settings and the real detection.
|
||||
|
||||
- ``POST /__test__/system/duplicate-prompts`` (S7) adds two prompts whose
|
||||
text differs only in case and spacing (a duplicate prompt text group);
|
||||
``{"again": true}`` adds a third, so a reviewed plan no longer matches;
|
||||
- ``POST /__test__/system/duplicate-files`` (S7) writes one PNG's bytes three
|
||||
times: ``output/s7-dupes/same.png``, ``input/s7-dupes/same.png`` (the same
|
||||
relative path in the other source) and ``output/s7-dupes/copy.png``;
|
||||
``{"groups": n}`` adds ``n`` more groups of two copies each, and
|
||||
``{"trio": false}`` leaves the three ``same.png``/``copy.png`` copies out,
|
||||
and ``{"long": true}`` adds a group under long names with no spaces;
|
||||
- ``GET /__test__/system/digest`` (S7) answers the sha256 of the database's
|
||||
rows (a read-only dump) and of every media file under ``output`` and
|
||||
``input`` (not the automatic previews in ``thumbnails``), so a test proves
|
||||
a Cancel wrote nothing.
|
||||
|
||||
Delays use the shared request holds (``request_hold.py``).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
@@ -102,8 +119,12 @@ def register_system_commands(app, routes, api, root: Path):
|
||||
|
||||
unplugged = root / "output" / "unplugged"
|
||||
|
||||
dupes = [root / "output" / "s7-dupes", root / "input" / "s7-dupes"]
|
||||
|
||||
def reset():
|
||||
failures.clear()
|
||||
for folder in dupes:
|
||||
shutil.rmtree(folder, ignore_errors=True)
|
||||
if unplugged.is_dir():
|
||||
unplugged.rmdir()
|
||||
GalleryConfig.MONITORING_DIRECTORIES = list(configured)
|
||||
@@ -254,6 +275,75 @@ def register_system_commands(app, routes, api, root: Path):
|
||||
)
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.post("/__test__/system/duplicate-prompts")
|
||||
async def duplicate_prompts(request):
|
||||
data = await request.json() if request.can_read_body else {}
|
||||
texts = [("S7 duplicate prompt", "2001-01-01 00:00:00")]
|
||||
texts.append((" s7 DUPLICATE prompt", "2002-01-01 00:00:00"))
|
||||
if data.get("again") is True:
|
||||
texts = [("S7 Duplicate Prompt ", "2003-01-01 00:00:00")]
|
||||
with api.db.model.get_connection() as conn:
|
||||
conn.executemany(
|
||||
"INSERT INTO prompts(text, created_at) VALUES(?, ?)", texts
|
||||
)
|
||||
conn.commit()
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.post("/__test__/system/duplicate-files")
|
||||
async def duplicate_files(request):
|
||||
body = await request.json() if request.can_read_body else {}
|
||||
for folder in dupes:
|
||||
folder.mkdir(exist_ok=True)
|
||||
extra = body.get("groups", 0)
|
||||
if (
|
||||
isinstance(extra, bool)
|
||||
or not isinstance(extra, int)
|
||||
or not 0 <= extra <= 20
|
||||
):
|
||||
raise web.HTTPBadRequest()
|
||||
for number in range(extra):
|
||||
# One more group each: two copies of a picture no other file has.
|
||||
with Image.new("RGB", (16, 16), (number * 9, 40, 90)) as image:
|
||||
image.save(dupes[0] / f"group_{number:02}_a.png")
|
||||
copy = (dupes[0] / f"group_{number:02}_a.png").read_bytes()
|
||||
(dupes[0] / f"group_{number:02}_b.png").write_bytes(copy)
|
||||
if body.get("long") is True:
|
||||
# One group whose copies sit under long folder and file names with
|
||||
# no space to break at, as real dataset folders do.
|
||||
deep = dupes[0] / ("mabes9sans_DATASET_ideogram_v1_" * 3).rstrip("_")
|
||||
deep.mkdir(exist_ok=True)
|
||||
with Image.new("RGB", (16, 16), "#204060") as image:
|
||||
image.save(deep / ("mabes9sans_DATASET072_" * 4 + "a.png"))
|
||||
copy = (deep / ("mabes9sans_DATASET072_" * 4 + "a.png")).read_bytes()
|
||||
(deep / ("mabes9sans_DATASET072_" * 4 + "b.png")).write_bytes(copy)
|
||||
if body.get("trio", True) is not False:
|
||||
with Image.new("RGB", (48, 48), "#7a3b2e") as image:
|
||||
image.save(dupes[0] / "same.png")
|
||||
data = (dupes[0] / "same.png").read_bytes()
|
||||
(dupes[1] / "same.png").write_bytes(data)
|
||||
(dupes[0] / "copy.png").write_bytes(data)
|
||||
api.invalidate_gallery_cache()
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.get("/__test__/system/digest")
|
||||
async def digest(request):
|
||||
uri = Path(api.db.model.db_path).as_uri() + "?mode=ro"
|
||||
rows = hashlib.sha256()
|
||||
conn = sqlite3.connect(uri, uri=True)
|
||||
try:
|
||||
for line in conn.iterdump():
|
||||
rows.update(line.encode("utf-8") + b"\n")
|
||||
finally:
|
||||
conn.close()
|
||||
media = hashlib.sha256()
|
||||
for name in ("output", "input"):
|
||||
for path in sorted((root / name).rglob("*")):
|
||||
# Automatic previews are written in the background (#316): media only.
|
||||
if path.is_file() and "thumbnails" not in path.relative_to(root).parts:
|
||||
media.update(str(path.relative_to(root)).encode("utf-8"))
|
||||
media.update(hashlib.sha256(path.read_bytes()).digest())
|
||||
return web.json_response({"rows": rows.hexdigest(), "media": media.hexdigest()})
|
||||
|
||||
@routes.post("/__test__/system/library")
|
||||
async def library_state(request):
|
||||
data = await request.json()
|
||||
|
||||
@@ -356,6 +356,21 @@ test("keepalive is forwarded only when asked, so a request can outlive its page"
|
||||
await Promise.all(writes);
|
||||
});
|
||||
|
||||
test("ApiError keeps the job that applied a used plan (plan-used, System S7)", () => {
|
||||
const body = { job: { id: "b".repeat(32), title: "Optimize database" } };
|
||||
const used = new ApiError(409, "This plan was already applied.", "plan-used", null, body);
|
||||
assert.deepEqual(used.job, { id: "b".repeat(32), title: "Optimize database" });
|
||||
assert.notEqual(used.job, body.job, "server objects must be copied");
|
||||
// The applying job may already be pruned: its title is null, its id still links Activity.
|
||||
assert.deepEqual(new ApiError(409, "used", "plan-used", null, { job: { id: "c".repeat(32), title: null } }).job,
|
||||
{ id: "c".repeat(32), title: null });
|
||||
assert.equal(new ApiError(409, "busy", "busy", null, body).job, null);
|
||||
for (const job of [null, "x", { id: 1, title: "t" }, { id: "a b", title: "t" }, { id: "a", title: 7 },
|
||||
{ id: "a", title: "x".repeat(201) }, { id: "a".repeat(65), title: "t" }]) {
|
||||
assert.equal(new ApiError(409, "used", "plan-used", null, { job }).job, null);
|
||||
}
|
||||
});
|
||||
|
||||
test("ApiError keeps a validated running job on busy and a validated source on invalid-source", () => {
|
||||
const body = { running: { id: "a".repeat(32), title: "Scan for metadata" }, source: "output/gone" };
|
||||
const busy = new ApiError(409, "busy", "busy", null, body);
|
||||
@@ -372,3 +387,12 @@ test("ApiError keeps a validated running job on busy and a validated source on i
|
||||
}
|
||||
assert.deepEqual([new ApiError(409, "old caller").running, new ApiError(409, "old caller").source], [null, null]);
|
||||
});
|
||||
|
||||
test("ApiError says whether the server itself refused (a parsed success:false body; S7 review I1)", () => {
|
||||
assert.equal(new ApiError(400, "bad", null, null, { success: false, error: "bad" }).refused, true);
|
||||
assert.equal(new ApiError(409, "busy", "busy", null, { success: false, code: "busy" }).refused, true);
|
||||
// A proxy's page, a body that was not JSON, or no body at all: nothing says the server refused.
|
||||
for (const body of [null, undefined, "<html>", { success: true }, [], { error: "x" }]) {
|
||||
assert.equal(new ApiError(504, "Gateway Timeout", null, null, body).refused, false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
// #222: what "clear the cache" may remove from this browser. Only entries Prompt Manager itself writes as a cache count,
|
||||
// from an inventory of the real writers (never a guessed prefix): preferences (the workspace theme, the update notice,
|
||||
// the display defaults) and other tools' storage and caches on the shared ComfyUI origin always stay.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const CacheOwnership = require('../../web/js/cache-ownership.js');
|
||||
|
||||
const WEB = path.join(__dirname, '../../web');
|
||||
|
||||
function memoryStorage(entries) {
|
||||
const map = new Map(Object.entries(entries));
|
||||
return {
|
||||
map,
|
||||
get length() { return map.size; },
|
||||
key: index => [...map.keys()][index] ?? null,
|
||||
getItem: key => (map.has(key) ? map.get(key) : null),
|
||||
setItem: (key, value) => map.set(key, String(value)),
|
||||
removeItem: key => map.delete(key),
|
||||
};
|
||||
}
|
||||
|
||||
function memoryCaches(names, { failDelete = new Set() } = {}) {
|
||||
const set = new Set(names);
|
||||
return {
|
||||
set,
|
||||
keys: async () => [...set],
|
||||
delete: async name => {
|
||||
if (failDelete.has(name)) throw new Error('denied');
|
||||
return set.delete(name);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const PREFERENCES = { gallerySettings: '{"viewMode":"grid"}', pm_workspace_theme: 'light', pm_update_notice_dismissed: '3.3.0' };
|
||||
const OWNED = Object.freeze({ storageKeys: ['pm-owned-test-entry'], cacheNames: ['pm-owned-test-cache'] });
|
||||
|
||||
test('keeps pm_workspace_theme, pm_update_notice_dismissed and gallerySettings', async () => {
|
||||
const storage = memoryStorage({ ...PREFERENCES, 'pm-owned-test-entry': 'x', pm_cached: '1', promptManager_x: '1' });
|
||||
const result = await CacheOwnership.clearOwned({ storage, cacheStorage: null, owned: OWNED });
|
||||
assert.deepEqual(result.removed, ['pm-owned-test-entry']);
|
||||
for (const [key, value] of Object.entries(PREFERENCES)) assert.equal(storage.getItem(key), value, key);
|
||||
// The old prefix rule's matches are not Prompt Manager caches either: nothing writes them.
|
||||
assert.equal(storage.getItem('pm_cached'), '1');
|
||||
assert.equal(storage.getItem('promptManager_x'), '1');
|
||||
for (const key of Object.keys(PREFERENCES)) assert.ok(CacheOwnership.PREFERENCE_KEYS.includes(key), key);
|
||||
});
|
||||
|
||||
test("keeps other tools' CacheStorage named gallery/thumbnail", async () => {
|
||||
const caches = memoryCaches(['other-gallery', 'thumbnail-cache', 'my-gallery-thumbnails', 'pm-owned-test-cache']);
|
||||
const result = await CacheOwnership.clearOwned({ storage: memoryStorage({}), cacheStorage: caches, owned: OWNED });
|
||||
assert.deepEqual([...caches.set].sort(), ['my-gallery-thumbnails', 'other-gallery', 'thumbnail-cache']);
|
||||
assert.deepEqual(result.removed, ['pm-owned-test-cache']);
|
||||
});
|
||||
|
||||
test('removes only inventoried owned keys', async () => {
|
||||
// The shipped inventory: no Prompt Manager writer stores a cache entry in this browser.
|
||||
assert.deepEqual(CacheOwnership.OWNED, { storageKeys: [], cacheNames: [] });
|
||||
assert.ok(Object.isFrozen(CacheOwnership.OWNED.storageKeys) && Object.isFrozen(CacheOwnership.OWNED.cacheNames));
|
||||
const storage = memoryStorage({ ...PREFERENCES, gallery_cache_1: 'a', metadata_cache_2: 'b', other: 'c' });
|
||||
const caches = memoryCaches(['gallery', 'thumbnail']);
|
||||
const result = await CacheOwnership.clearOwned({ storage, cacheStorage: caches });
|
||||
assert.deepEqual(result, { removed: [], failed: [], denied: false, partial: false });
|
||||
assert.equal(storage.map.size, 6);
|
||||
assert.equal(caches.set.size, 2);
|
||||
assert.equal(CacheOwnership.clearMessage(result), CacheOwnership.NOTHING_CACHED);
|
||||
});
|
||||
|
||||
/**
|
||||
* Every browser-storage write in one file's text: `{keys}` (setItem keys, as written) and `problems` (writes no key
|
||||
* can be read from: a bracket or property assignment on a Storage, a computed setItem key, an IndexedDB or
|
||||
* CacheStorage open or write). The scan is textual, so it errs on the side of reporting.
|
||||
*/
|
||||
function storageWrites(text) {
|
||||
const keys = [];
|
||||
const problems = [];
|
||||
for (const match of text.matchAll(/\.setItem\(\s*([^,)]+)/g)) {
|
||||
const key = match[1].trim();
|
||||
const literal = /^(?:'([^']+)'|"([^"]+)")$/.exec(key);
|
||||
if (literal) keys.push(literal[1] ?? literal[2]);
|
||||
else if (/^[A-Z_]+_KEY$/.test(key)) keys.push(`$${key}`);
|
||||
else problems.push(`a computed setItem key: ${key}`);
|
||||
}
|
||||
if (/\b\w*[sS]torage\s*\[[^\]]+\]\s*=(?!=)/.test(text)) problems.push('a bracket assignment on a Storage');
|
||||
if (/\b(?:local|session)Storage\s*\.\s*(?!getItem|setItem|removeItem|key\b|length\b|clear\b)\w+\s*=(?!=)/.test(text)) {
|
||||
problems.push('a property assignment on a Storage');
|
||||
}
|
||||
if (/\bcaches\s*\.\s*open\s*\(/.test(text)) problems.push('opens a CacheStorage cache');
|
||||
if (/\bindexedDB\s*\.\s*open\s*\(/.test(text)) problems.push('opens IndexedDB');
|
||||
return { keys, problems };
|
||||
}
|
||||
|
||||
test('the storage scan reports every way of writing browser storage (S7 review M5)', () => {
|
||||
assert.deepEqual(storageWrites("localStorage.setItem('gallerySettings', x); storage.setItem(NOTICE_KEY, v);").keys,
|
||||
['gallerySettings', '$NOTICE_KEY']);
|
||||
for (const text of ["localStorage['pm_cache'] = 1;", 'sessionStorage[key] = value;', 'storage[name] = x;',
|
||||
'localStorage.pm_cache = 1;', 'sessionStorage.someKey = "v";', 'localStorage.setItem(key, 1);',
|
||||
"caches.open('pm').then(cache => cache.put(url, response));", "indexedDB.open('pm-cache', 1);"]) {
|
||||
assert.equal(storageWrites(text).problems.length, 1, text);
|
||||
}
|
||||
for (const text of ["if (localStorage['pm'] === x) {}", 'const value = localStorage.getItem(KEY);',
|
||||
'localStorage.removeItem(KEY);', 'const n = localStorage.length;']) {
|
||||
assert.deepEqual(storageWrites(text), { keys: [], problems: [] }, text);
|
||||
}
|
||||
});
|
||||
|
||||
test('the inventory covers every browser storage writer in web/', () => {
|
||||
// Every key a Prompt Manager page writes is a preference or an inventoried cache entry; a new writer must be
|
||||
// classified here before a cache clear may remove it. Skipped: web/lib (third-party Viewer.js and Tailwind
|
||||
// output) and web/js/shared/toolkit (the pinned ComfyAssets Toolkit build, never hand-edited here and verified by
|
||||
// `npm run --prefix tools/vendor check`). Neither writes browser storage today; the Toolkit's own repository owns
|
||||
// its storage, and a new pin is reviewed as its own change.
|
||||
const files = [];
|
||||
const walk = dir => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) { if (!['lib', 'toolkit'].includes(entry.name)) walk(full); }
|
||||
else if (/\.(m?js|html)$/.test(entry.name)) files.push(full);
|
||||
}
|
||||
};
|
||||
walk(WEB);
|
||||
const written = new Set();
|
||||
const constants = {};
|
||||
for (const file of files) {
|
||||
const text = fs.readFileSync(file, 'utf8');
|
||||
for (const match of text.matchAll(/(?:const|export const)\s+([A-Z_]+_KEY)\s*=\s*'([^']+)'/g)) constants[match[1]] = match[2];
|
||||
const { keys, problems } = storageWrites(text);
|
||||
assert.deepEqual(problems, [], path.relative(WEB, file));
|
||||
for (const key of keys) written.add(key);
|
||||
}
|
||||
const keys = [...written].map(key => (key.startsWith('$') ? constants[key.slice(1)] : key));
|
||||
assert.ok(keys.length >= 3, 'the writers were found');
|
||||
for (const key of keys) {
|
||||
assert.ok(key, 'every written key resolves');
|
||||
assert.ok(CacheOwnership.PREFERENCE_KEYS.includes(key) || CacheOwnership.OWNED.storageKeys.includes(key), key);
|
||||
}
|
||||
});
|
||||
|
||||
test('storage denial reports partial', async () => {
|
||||
const denied = { get length() { throw new Error('SecurityError'); }, key() { throw new Error('SecurityError'); },
|
||||
getItem() { throw new Error('SecurityError'); }, removeItem() { throw new Error('SecurityError'); } };
|
||||
const result = await CacheOwnership.clearOwned({ storage: denied, cacheStorage: null, owned: OWNED });
|
||||
assert.equal(result.denied, true);
|
||||
assert.equal(result.partial, true);
|
||||
assert.equal(CacheOwnership.clearMessage(result), CacheOwnership.STORAGE_DENIED);
|
||||
|
||||
const caches = memoryCaches(['pm-owned-test-cache'], { failDelete: new Set(['pm-owned-test-cache']) });
|
||||
const storage = memoryStorage({ 'pm-owned-test-entry': 'x' });
|
||||
const partial = await CacheOwnership.clearOwned({ storage, cacheStorage: caches, owned: OWNED });
|
||||
assert.deepEqual(partial.removed, ['pm-owned-test-entry']);
|
||||
assert.deepEqual(partial.failed, ['pm-owned-test-cache']);
|
||||
assert.equal(partial.partial, true);
|
||||
assert.equal(CacheOwnership.clearMessage(partial), 'Cleared 1 of 2 cached entries; this browser refused the rest.');
|
||||
|
||||
const keysDenied = { keys: async () => { throw new Error('denied'); } };
|
||||
const unreadable = await CacheOwnership.clearOwned({ storage: memoryStorage({}), cacheStorage: keysDenied, owned: OWNED });
|
||||
assert.equal(unreadable.denied, true);
|
||||
});
|
||||
|
||||
test('server thumbnail clear stays a separate explicit call', async () => {
|
||||
const calls = [];
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = async (...args) => { calls.push(args); return { ok: true }; };
|
||||
try {
|
||||
const storage = memoryStorage({ 'pm-owned-test-entry': 'x' });
|
||||
const result = await CacheOwnership.clearOwned({ storage, cacheStorage: memoryCaches([]), owned: OWNED });
|
||||
assert.deepEqual(result.removed, ['pm-owned-test-entry']);
|
||||
assert.equal(CacheOwnership.clearMessage(result), 'Cleared 1 cached entry from this browser.');
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
assert.deepEqual(calls, []);
|
||||
assert.equal(/fetch|clear-thumbnails|ApiClient/.test(fs.readFileSync(path.join(WEB, 'js/cache-ownership.js'), 'utf8')), false);
|
||||
});
|
||||
|
||||
test('ownedEntries lists what a clear would remove, reading nothing it cannot', async () => {
|
||||
const storage = memoryStorage({ ...PREFERENCES, 'pm-owned-test-entry': 'x' });
|
||||
const caches = memoryCaches(['pm-owned-test-cache', 'gallery']);
|
||||
assert.deepEqual(await CacheOwnership.ownedEntries({ storage, cacheStorage: caches, owned: OWNED }),
|
||||
{ keys: ['pm-owned-test-entry'], caches: ['pm-owned-test-cache'], denied: false });
|
||||
assert.deepEqual(await CacheOwnership.ownedEntries({ storage, cacheStorage: caches }),
|
||||
{ keys: [], caches: [], denied: false });
|
||||
assert.deepEqual(await CacheOwnership.ownedEntries({ storage: null, cacheStorage: undefined, owned: OWNED }),
|
||||
{ keys: [], caches: [], denied: false });
|
||||
assert.equal(CacheOwnership.clearMessage({ removed: ['a', 'b'], failed: [], denied: false, partial: false }),
|
||||
'Cleared 2 cached entries from this browser.');
|
||||
});
|
||||
@@ -32,8 +32,11 @@ function sseResponse(frames) {
|
||||
function load(file, className, fetchUrl, extra = {}, dom = elements()) {
|
||||
const timers = [];
|
||||
const notifications = [];
|
||||
const storage = new Map([['pm_cached', '1'], ['userData', 'keep']]);
|
||||
const storage = new Map([['pm_cached', '1'], ['userData', 'keep'], ['pm_workspace_theme', 'light'],
|
||||
['pm_update_notice_dismissed', '3.3.0']]);
|
||||
const localStorage = {
|
||||
get length() { return storage.size; },
|
||||
key: index => [...storage.keys()][index] ?? null,
|
||||
getItem: key => storage.get(key) ?? null,
|
||||
setItem: (key, value) => storage.set(key, String(value)),
|
||||
removeItem: key => storage.delete(key),
|
||||
@@ -45,11 +48,12 @@ function load(file, className, fetchUrl, extra = {}, dom = elements()) {
|
||||
setTimeout: callback => timers.push(callback),
|
||||
confirm: () => true,
|
||||
localStorage,
|
||||
Object: { ...Object, keys: target => (target === localStorage ? [...storage.keys()] : Object.keys(target)) },
|
||||
TextDecoder, TextEncoder, JSON,
|
||||
ApiClient: { fetchUrl },
|
||||
...extra,
|
||||
});
|
||||
// gallery.html loads cache-ownership.js before gallery.js (#222).
|
||||
vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/cache-ownership.js'), 'utf8'), context);
|
||||
const source = fs.readFileSync(path.join(__dirname, `../../web/js/${file}`), 'utf8');
|
||||
const Page = vm.runInContext(`${source}\n${className};`, context, { filename: path.join(__dirname, `../../web/js/${file}`) });
|
||||
const page = Object.create(Page.prototype);
|
||||
@@ -78,18 +82,68 @@ test('gallery cache clear reports a refused server clear and never claims succes
|
||||
assert.equal(storage.get('gallerySettings'), undefined); // thumbnailsGenerated is not reset
|
||||
});
|
||||
|
||||
test('gallery cache clear still says so when the server clears', async () => {
|
||||
const { page, dom, notifications, storage } = load('gallery.js', 'GalleryManager',
|
||||
async () => jsonResponse(200, { success: true, cleared_count: 3 }));
|
||||
test('gallery cache clear says what it cleared and keeps preferences and other data (#222)', async () => {
|
||||
const calls = [];
|
||||
const { page, dom, notifications, storage } = load('gallery.js', 'GalleryManager', async url => {
|
||||
calls.push(url);
|
||||
return jsonResponse(200, { success: true, cleared_count: 3 });
|
||||
});
|
||||
page.getSettings = () => ({ thumbnailsGenerated: true });
|
||||
|
||||
await page.clearCache();
|
||||
|
||||
assert.deepEqual(notifications, [['success', 'PromptManager cache cleared successfully! Original images untouched.']]);
|
||||
// The server's thumbnails are cleared by their own explicit call; nothing Prompt Manager owns is cached in this
|
||||
// browser, so no browser entry is removed and the toast says exactly that.
|
||||
assert.deepEqual(calls, ['/prompt_manager/images/clear-thumbnails']);
|
||||
assert.deepEqual(notifications, [['success',
|
||||
"Thumbnails cleared; original images untouched. Nothing of Prompt Manager's is cached in this browser."]]);
|
||||
assert.equal(dom.nodes.thumbnailStatus.textContent, 'Not generated');
|
||||
assert.equal(JSON.parse(storage.get('gallerySettings')).thumbnailsGenerated, false);
|
||||
assert.equal(storage.get('userData'), 'keep');
|
||||
assert.equal(storage.has('pm_cached'), false);
|
||||
assert.equal(storage.get('pm_cached'), '1');
|
||||
assert.equal(storage.get('pm_workspace_theme'), 'light');
|
||||
assert.equal(storage.get('pm_update_notice_dismissed'), '3.3.0');
|
||||
});
|
||||
|
||||
test("gallery cache clear never deletes another tool's gallery or thumbnail caches (#222)", async () => {
|
||||
const names = new Set(['comfy-gallery', 'thumbnail-cache']);
|
||||
const caches = { keys: async () => [...names], delete: async name => names.delete(name) };
|
||||
const { page, notifications } = load('gallery.js', 'GalleryManager',
|
||||
async () => jsonResponse(200, { success: true }), { caches, window: { caches } });
|
||||
page.getSettings = () => ({});
|
||||
|
||||
await page.clearCache();
|
||||
|
||||
assert.deepEqual([...names], ['comfy-gallery', 'thumbnail-cache']);
|
||||
assert.equal(notifications.at(-1)[0], 'success');
|
||||
});
|
||||
|
||||
test('gallery cache clear reports a lost server clear instead of success', async () => {
|
||||
const { page, dom, notifications, storage } = load('gallery.js', 'GalleryManager', async () => {
|
||||
throw new TypeError('Failed to fetch');
|
||||
});
|
||||
page.getSettings = () => ({ thumbnailsGenerated: true });
|
||||
dom.getElementById('thumbnailStatus').textContent = 'Generated';
|
||||
|
||||
await page.clearCache();
|
||||
|
||||
assert.deepEqual(notifications, [['error', 'Thumbnails were not cleared: the server could not be reached.']]);
|
||||
assert.equal(dom.nodes.thumbnailStatus.textContent, 'Generated');
|
||||
assert.equal(storage.get('gallerySettings'), undefined);
|
||||
});
|
||||
|
||||
test('gallery cache clear says so when browser storage is denied', async () => {
|
||||
const denied = { get length() { throw new Error('SecurityError'); }, key() { throw new Error('SecurityError'); },
|
||||
getItem() { throw new Error('SecurityError'); }, setItem() { throw new Error('SecurityError'); },
|
||||
removeItem() { throw new Error('SecurityError'); } };
|
||||
const { page, notifications } = load('gallery.js', 'GalleryManager',
|
||||
async () => jsonResponse(200, { success: true }), { localStorage: denied });
|
||||
page.getSettings = () => ({});
|
||||
|
||||
await page.clearCache();
|
||||
|
||||
assert.deepEqual(notifications, [['warning',
|
||||
'Thumbnails cleared; original images untouched. This browser refused access to its storage, so nothing there was cleared.']]);
|
||||
});
|
||||
|
||||
test('admin LoRA import shows a refusal frame and closes its modal', async () => {
|
||||
|
||||
@@ -126,7 +126,7 @@ test('held legacy jobs read "(admin page)" with no outcomes', async () => {
|
||||
|
||||
test('unknown and generic kinds show no tiles of another kind', async () => {
|
||||
const { kindOf, viewOf } = await source;
|
||||
for (const kind of ['maintenance', '', 'constructor', '__proto__', 'health']) {
|
||||
for (const kind of ['restore', '', 'constructor', '__proto__', 'health']) {
|
||||
const view = kindOf(kind);
|
||||
assert.deepEqual([view.tiles, view.outcomes, [...view.openable]], [[], [], []], kind);
|
||||
assert.deepEqual([view.log, view.cancelLabel], ['Job log', 'Cancel job']);
|
||||
@@ -146,3 +146,82 @@ test('unknown and generic kinds show no tiles of another kind', async () => {
|
||||
assert.deepEqual([...partial.openable], ['added']);
|
||||
assert.equal(partial.log, 'Scan log');
|
||||
});
|
||||
|
||||
// System S7 (carry-over from S6): the maintenance jobs read as themselves in Activity, with their own counters, a
|
||||
// report like every job, and Cancel only when the server can stop them.
|
||||
test('a maintenance apply shows its title, changed / protected / errors and no Cancel', async () => {
|
||||
const { kindOf, viewOf, canCancel } = await source;
|
||||
const { finishToast, startToast, pillParts, outcomeLine } = await store;
|
||||
const apply = job({ kind: 'maintenance', title: 'Duplicate prompt text', progress: 0, processed: 0, total: 0,
|
||||
counters: { changed: 3, protected: 1, errors: 0 }, scope: { operation: 'duplicate_prompts' }, cancellable: false });
|
||||
const view = kindOf('maintenance');
|
||||
assert.equal(viewOf(apply), view);
|
||||
assert.deepEqual(view.tiles, [['changed', 'Changed', 'added'], ['protected', 'Protected', ''], ['errors', 'Errors', 'bad']]);
|
||||
assert.deepEqual(view.outcomes, [['changed', 'Changed'], ['errors', 'Errors'], ['protected', 'Protected']]);
|
||||
assert.deepEqual([...view.openable], []);
|
||||
assert.deepEqual([view.log, view.cancelLabel], ['Maintenance log', 'Cancel job']);
|
||||
assert.equal(canCancel(apply), false);
|
||||
// Indeterminate while it runs: the title alone, never a made-up percentage.
|
||||
assert.equal(pillParts(apply).text, 'Duplicate prompt text');
|
||||
assert.equal(startToast(apply), 'Duplicate prompt text started.');
|
||||
assert.equal(outcomeLine(apply), '3 changed · 0 errors');
|
||||
assert.equal(finishToast({ ...apply, state: 'finished' }), 'Duplicate prompt text finished · 3 changed');
|
||||
// A failed apply says why, so plan-changed asks for a new review.
|
||||
const changed = 'The library changed since the review; nothing was changed. Review the plan again.';
|
||||
assert.equal(finishToast({ ...apply, state: 'failed', status_line: changed, counters: { changed: 0, protected: 1, errors: 1 } }),
|
||||
`Duplicate prompt text failed · ${changed}`);
|
||||
assert.equal(finishToast({ ...apply, state: 'failed', status_line: '' }), 'Duplicate prompt text failed · 0 errors');
|
||||
// Store each workflow once is a cancellable maintenance job: Cancel shows while it runs.
|
||||
const migration = { ...apply, title: 'Store each workflow once', cancellable: true };
|
||||
assert.equal(canCancel(migration), true);
|
||||
assert.equal(finishToast({ ...migration, state: 'cancelled' }), 'Store each workflow once cancelled · 3 changed');
|
||||
});
|
||||
|
||||
test('a duplicate search counts groups, opens their first copy and cancels as a search', async () => {
|
||||
const { kindOf, viewOf, canCancel } = await source;
|
||||
const { finishToast, startToast, pillParts, outcomeLine } = await store;
|
||||
const find = job({ kind: 'duplicates', title: 'Find duplicate image files', processed: 5, total: 10, progress: 50,
|
||||
counters: { groups_found: 0 }, scope: { output_folders: 2 } });
|
||||
const view = kindOf('duplicates');
|
||||
assert.equal(viewOf(find), view);
|
||||
assert.deepEqual(view.tiles, [['groups_found', 'Groups found', '']]);
|
||||
assert.deepEqual(view.outcomes, [['groups_found', 'Groups found']]);
|
||||
assert.deepEqual([...view.openable], ['groups_found']);
|
||||
assert.deepEqual([view.log, view.cancelLabel], ['Search log', 'Cancel search']);
|
||||
assert.equal(canCancel(find), true);
|
||||
assert.equal(pillParts(find).text, 'Finding duplicates · 50% · 5 / 10');
|
||||
assert.equal(startToast(find), 'Duplicate search started.');
|
||||
assert.equal(view.scope(find), '2 output folders');
|
||||
const found = { ...find, state: 'finished', counters: { groups_found: 12 } };
|
||||
assert.equal(outcomeLine(found), '12 groups found');
|
||||
assert.equal(finishToast(found), 'Duplicate search finished · 12 groups found');
|
||||
assert.equal(finishToast({ ...find, state: 'cancelled' }), 'Duplicate search cancelled · 0 groups found');
|
||||
assert.equal(finishToast({ ...found, counters: { groups_found: 1 } }), 'Duplicate search finished · 1 group found');
|
||||
});
|
||||
|
||||
test('a duplicate cleanup counts removed, kept and errors and cannot be cancelled', async () => {
|
||||
const { kindOf, viewOf, canCancel } = await source;
|
||||
const { finishToast, pillParts, outcomeLine } = await store;
|
||||
const cleanup = job({ kind: 'duplicate_cleanup', title: 'Remove duplicate image files', processed: 1, total: 4,
|
||||
progress: 25, counters: { removed: 1, kept: 0, errors: 0 }, cancellable: false });
|
||||
const view = kindOf('duplicate_cleanup');
|
||||
assert.equal(viewOf(cleanup), view);
|
||||
assert.deepEqual(view.tiles, [['removed', 'Removed', 'added'], ['kept', 'Kept', ''], ['errors', 'Errors', 'bad']]);
|
||||
assert.deepEqual(view.outcomes, [['removed', 'Removed'], ['errors', 'Errors'], ['kept', 'Kept']]);
|
||||
assert.deepEqual([...view.openable], ['kept']);
|
||||
assert.deepEqual([view.log, view.cancelLabel], ['Cleanup log', 'Cancel job']);
|
||||
assert.equal(canCancel(cleanup), false);
|
||||
assert.equal(pillParts(cleanup).text, 'Removing duplicate files · 25% · 1 / 4');
|
||||
assert.equal(outcomeLine(cleanup), '1 removed · 0 errors');
|
||||
assert.equal(finishToast({ ...cleanup, state: 'finished', counters: { removed: 3, kept: 1, errors: 1 } }),
|
||||
'Duplicate cleanup finished · 3 removed · 1 error');
|
||||
});
|
||||
|
||||
test('a failed duplicate cleanup says the server reason (S7 review M3)', async () => {
|
||||
const { finishToast } = await store;
|
||||
const reason = 'The sources changed since the search. Search for duplicates again.';
|
||||
const failed = job({ kind: 'duplicate_cleanup', title: 'Remove duplicate image files', state: 'failed', status_line: reason,
|
||||
counters: { removed: 0, kept: 0, errors: 1 }, cancellable: false });
|
||||
assert.equal(finishToast(failed), `Duplicate cleanup failed · ${reason}`);
|
||||
assert.equal(finishToast({ ...failed, status_line: '' }), 'Duplicate cleanup failed · 0 removed · 1 error');
|
||||
});
|
||||
|
||||
@@ -344,7 +344,7 @@ test('unknown kinds get the generic view without the scan tiles', async () => {
|
||||
['no_metadata', 'No usable metadata'], ['known', 'Already known']]);
|
||||
assert.deepEqual([...scan.openable].sort(), ['added', 'known']);
|
||||
assert.deepEqual([scan.log, scan.cancel], ['Scan log', 'Cancel scan']);
|
||||
for (const kind of ['maintenance', '', 'constructor', '__proto__']) {
|
||||
for (const kind of ['restore', '', 'constructor', '__proto__']) {
|
||||
const view = jobView(kind);
|
||||
assert.deepEqual([view.tiles, view.outcomes, [...view.openable]], [[], [], []]);
|
||||
assert.deepEqual([view.log, view.cancel], ['Job log', 'Cancel job']);
|
||||
@@ -437,3 +437,16 @@ test('numbers group the way the server writes them, whatever the browser locale'
|
||||
Number.prototype.toLocaleString = original;
|
||||
}
|
||||
});
|
||||
|
||||
test('a busy start names the job the server named, not the first running one (S7 review M9)', async () => {
|
||||
const { createActivityStore } = await source;
|
||||
const { world, ...deps } = harness();
|
||||
const store = createActivityStore(deps);
|
||||
const holder = 'b'.repeat(32);
|
||||
// Two read jobs run side by side; the server says the second one holds the slot.
|
||||
world.list = [summary(), summary({ id: holder, kind: 'duplicates', title: 'Find duplicate image files' })];
|
||||
world.postError = Object.assign(apiError(409, 'busy', 'busy'),
|
||||
{ running: { id: holder, title: 'Find duplicate image files' } });
|
||||
const busy = await store.start('duplicates');
|
||||
assert.deepEqual([busy.busy.id, busy.busy.title], [holder, 'Find duplicate image files']);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
// System › Maintenance (#249 System S7 Task 19): the concept's rows (app.js 688–690), the D100 plan review built from
|
||||
// the server's Plan, the D101 duplicates review's keep entries and copy, and the D102 metadata cache row.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const maintenance = import('../../web/js/workspace/system-maintenance.mjs');
|
||||
const review = import('../../web/js/workspace/maintenance-review.mjs');
|
||||
const jobView = import('../../web/js/workspace/activity-job-view.mjs');
|
||||
|
||||
const CONCEPT = fs.readFileSync(path.join(__dirname, '../../docs/concepts/workspace/app.js'), 'utf8');
|
||||
|
||||
test('the section lists the concept rows in its order, labels and danger marks', async () => {
|
||||
const { MAINTENANCE_SURFACES } = await maintenance;
|
||||
const start = CONCEPT.indexOf('case "maintenance":');
|
||||
const body = CONCEPT.slice(start, CONCEPT.indexOf('break;', start));
|
||||
const concept = [...body.matchAll(/operation\("([^"]+)", "([^"]+)", "[^"]+", "([^"]+)"(?:, '[^']*'|, "")?(, true)?\)/g)]
|
||||
.map(([, title, description, label, danger]) => [title, description, label, Boolean(danger)]);
|
||||
const surfaces = [...body.matchAll(/<section class="surface"><h2>([^<]+)<\/h2>/g)].map(match => match[1]);
|
||||
assert.deepEqual(MAINTENANCE_SURFACES.map(([heading]) => heading), surfaces.map(text => text.replace('&', '&')));
|
||||
const production = MAINTENANCE_SURFACES.flatMap(([, rows]) => rows.map(([title, description, label, , danger = false]) =>
|
||||
[title, description, label, danger]));
|
||||
assert.deepEqual(production, concept.map(([title, ...rest]) => [title.replace('&', '&'), ...rest]));
|
||||
assert.equal(production.length, 8);
|
||||
});
|
||||
|
||||
const PLAN = Object.freeze({
|
||||
success: true, token: 't'.repeat(43), operation: 'duplicate_prompts', expires_at: '2026-10-24T12:15:00+00:00',
|
||||
scope: { count: 3, label: '3 duplicate prompts in 2 groups' },
|
||||
protected: { count: 1, reasons: ['1 group belongs to a saved pair'] },
|
||||
backup: { policy: 'on', destination_label: 'prompts.db.backup_<date and time>', expected_bytes: 4096 },
|
||||
media: { deletes_files: false, label: 'No files are deleted' },
|
||||
disk: { free_bytes: 2 ** 30, required_bytes: 2 ** 24, sufficient: true },
|
||||
details: [{ label: 'a cat', detail: '2 copies merged into the oldest' }, { label: 'a dog', detail: '' }],
|
||||
});
|
||||
|
||||
test("plan dialog shows the server's scope, protected, backup policy and media lines (D100)", async () => {
|
||||
const { planGrid, planNote, detailLines, backupText, protectedText, OPERATION_TITLES } = await review;
|
||||
assert.deepEqual(planGrid(PLAN), [
|
||||
['Scope', '3 duplicate prompts in 2 groups'],
|
||||
['Protected', '1 group belongs to a saved pair'],
|
||||
['Backup', 'Create and verify before changes (prompts.db.backup_<date and time>)'],
|
||||
['Media on disk', 'No files are deleted'],
|
||||
]);
|
||||
assert.equal(backupText({ policy: 'off', destination_label: 'x' }), 'No safety backup (the policy is off)');
|
||||
assert.equal(backupText({ policy: 'required', destination_label: 'prompts.db.pre-workflow-storage_<date and time>' }),
|
||||
'Always created and verified first (prompts.db.pre-workflow-storage_<date and time>)');
|
||||
assert.equal(backupText(undefined), 'Create and verify before changes');
|
||||
assert.equal(protectedText({ count: 0, reasons: [] }), 'Nothing is protected');
|
||||
assert.equal(protectedText({ count: 2, reasons: [] }), '2 records kept');
|
||||
assert.equal(protectedText({ count: 3, reasons: ['1 saved pair', '2 titled parents'] }), '1 saved pair · 2 titled parents');
|
||||
assert.deepEqual(planGrid({ scope: { count: 1 } })[0], ['Scope', '1 record']);
|
||||
assert.deepEqual(planGrid({})[3], ['Media on disk', 'No files are removed']);
|
||||
const now = Date.parse('2026-10-24T12:00:00+00:00');
|
||||
assert.equal(planNote(PLAN, { now }), 'Nothing changes until you apply. This review holds for 15 minutes.');
|
||||
assert.equal(planNote({ ...PLAN, expires_at: 'soon' }, { now }), 'Nothing changes until you apply.');
|
||||
assert.equal(planNote({ ...PLAN, expires_at: '2026-10-24T12:00:20+00:00', disk: { free_bytes: 2 ** 20, required_bytes: 3 * 2 ** 20,
|
||||
sufficient: false } }, { now }), 'Nothing changes until you apply. This review holds for 1 minute. ' +
|
||||
'Not enough free disk space: about 3.0 MB is needed and 1.0 MB is free.');
|
||||
assert.deepEqual(detailLines(PLAN), ['a cat · 2 copies merged into the oldest', 'a dog']);
|
||||
assert.deepEqual(detailLines({ details: [null, { label: 7 }] }), []);
|
||||
assert.equal(OPERATION_TITLES.missing_links, 'Missing-file links');
|
||||
// S7 review M8: the workflow migration's plan reads its own title (its job is started outside Maintenance).
|
||||
assert.equal(OPERATION_TITLES.workflow_storage, 'Store each workflow once');
|
||||
});
|
||||
|
||||
test('a refused apply names its reason; a lost answer is not confirmed', async () => {
|
||||
const { applyRefusal, reviewRefusal, NOT_CONFIRMED, APPLY_FAILED, REVIEW_FAILED } = await review;
|
||||
const error = (status, code, message, extra = {}) => Object.assign(new Error(message), { status, code, ...extra });
|
||||
assert.deepEqual(applyRefusal(new TypeError('Failed to fetch')), { toast: NOT_CONFIRMED, lost: true });
|
||||
assert.deepEqual(NOT_CONFIRMED, ['Outcome not confirmed. ', { text: 'Check Activity', href: '#/activity' },
|
||||
' before applying it again.']);
|
||||
const busy = applyRefusal(error(409, 'busy', 'busy', { running: { id: 'a'.repeat(32), title: 'Scan for metadata' } }));
|
||||
assert.deepEqual(busy.toast, ['Another job is running: ', { text: 'Scan for metadata', href: `#/activity/${'a'.repeat(32)}` }, '.']);
|
||||
assert.deepEqual(applyRefusal(error(409, 'plan-used', 'used', { job: { id: 'b'.repeat(32), title: 'Optimize database' } })).toast,
|
||||
['This plan was already applied: ', { text: 'Optimize database', href: `#/activity/${'b'.repeat(32)}` }, '.']);
|
||||
assert.deepEqual(applyRefusal(error(409, 'plan-used', 'used', { job: { id: 'b'.repeat(32), title: null } })).toast[1].text, 'its job');
|
||||
assert.deepEqual(applyRefusal(error(409, 'plan-used', 'used', { job: null })), { toast: 'This plan was already applied.' });
|
||||
assert.deepEqual(applyRefusal(error(410, 'plan-expired', 'This review expired. Review the plan again.')),
|
||||
{ toast: 'This review expired. Review the plan again.', review: true });
|
||||
assert.deepEqual(applyRefusal(error(409, 'storage-changed', 'Replaced.')), { toast: 'Replaced.', review: true });
|
||||
assert.deepEqual(applyRefusal(error(507, 'insufficient-space', 'Not enough free disk space.')), { toast: 'Not enough free disk space.' });
|
||||
// S7 review I1: only our server's own refusal is definitive. A proxy's 5xx, a 2xx whose body could not be read,
|
||||
// or any answer without the server's refusal may have followed a started job: not confirmed, read Activity again.
|
||||
assert.deepEqual(applyRefusal(error(504, null, 'Gateway Timeout')), { toast: NOT_CONFIRMED, lost: true });
|
||||
assert.deepEqual(applyRefusal(error(500, null, 'Internal Server Error')), { toast: NOT_CONFIRMED, lost: true });
|
||||
assert.deepEqual(applyRefusal(error(202, null, 'Invalid JSON response')), { toast: NOT_CONFIRMED, lost: true });
|
||||
assert.deepEqual(applyRefusal(error(502, null, 'Bad Gateway', { refused: false })), { toast: NOT_CONFIRMED, lost: true });
|
||||
assert.deepEqual(applyRefusal(error(400, null, 'token must be the plan token', { refused: true })), { toast: APPLY_FAILED });
|
||||
assert.deepEqual(applyRefusal(error(500, 'apply-failed', 'Maintenance failed; nothing was changed.', { refused: true })),
|
||||
{ toast: 'Maintenance failed; nothing was changed.' });
|
||||
assert.equal(reviewRefusal(error(413, 'too-large', 'This plan is too large to review at once.')),
|
||||
'This plan is too large to review at once.');
|
||||
assert.equal(reviewRefusal(error(500, null, 'boom')), REVIEW_FAILED);
|
||||
// S7 re-review N1: a duplicate review whose sources changed since the search keeps the server's "search again".
|
||||
const changed = 'The sources changed since the search. Search for duplicates again.';
|
||||
assert.equal(reviewRefusal(error(409, 'sources-changed', changed)), changed);
|
||||
assert.equal(reviewRefusal(new TypeError('x')), REVIEW_FAILED);
|
||||
});
|
||||
|
||||
test('keep entries name each copy by group, source index and path, never a bare path', async () => {
|
||||
const { keepEntries } = await review;
|
||||
// Two sources hold a file at the same relative path: only the source index tells them apart.
|
||||
const output = { path: 'same.png', root: 'output', root_index: 0 };
|
||||
const input = { path: 'same.png', root: 'input', root_index: 1 };
|
||||
const chosen = new Map([['hash-a', input], ['hash-b', output]]);
|
||||
assert.deepEqual(keepEntries(chosen), [
|
||||
{ group_id: 'hash-a', root_index: 1, path: 'same.png' },
|
||||
{ group_id: 'hash-b', root_index: 0, path: 'same.png' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('the duplicates dialog says what removing a copy does, History included', async () => {
|
||||
const { DUPLICATES_NOTE, pageText, groupText, copyLabel } = await review;
|
||||
assert.match(DUPLICATES_NOTE, /History keeps the removed paths in its run records\./);
|
||||
assert.match(DUPLICATES_NOTE, /deletes its file from disk/);
|
||||
assert.equal(pageText(0, 10, 24), 'Groups 1–10 of 24');
|
||||
assert.equal(pageText(20, 4, 24), 'Groups 21–24 of 24');
|
||||
assert.equal(pageText(0, 0, 0), 'No duplicate files were found.');
|
||||
assert.equal(groupText({ count: 3, size: 120 * 1024, files: [] }), '3 identical files · 120 KB each');
|
||||
assert.equal(groupText({ size: 3 * 2 ** 20, files: [{}, {}] }), '2 identical files · 3.0 MB each');
|
||||
assert.equal(groupText({ count: 2, size: 10, files: [] }), '2 identical files · 1 KB each');
|
||||
assert.equal(copyLabel({ root: 'output', path: 'a/b.png' }), 'Keep output/a/b.png');
|
||||
assert.equal(copyLabel({ root: '', path: 'b.png', missing: true }), 'Keep b.png (no longer on disk)');
|
||||
});
|
||||
|
||||
test('Metadata cache follows Q5: disabled with its reason while nothing is owned (D102)', async () => {
|
||||
const { metadataCacheDescription, METADATA_CACHE_DESCRIPTION, NOTHING_CACHED_REASON } = await maintenance;
|
||||
assert.equal(METADATA_CACHE_DESCRIPTION, 'Clear only Prompt Manager metadata cached by this browser.');
|
||||
assert.equal(metadataCacheDescription({ keys: [], caches: [] }), `${METADATA_CACHE_DESCRIPTION} ${NOTHING_CACHED_REASON}`);
|
||||
assert.equal(metadataCacheDescription({ keys: ['k'], caches: [] }), METADATA_CACHE_DESCRIPTION);
|
||||
// S7 review M6: a browser that refused the listing is not claimed empty.
|
||||
assert.equal(metadataCacheDescription({ keys: [], caches: [], denied: true }),
|
||||
`${METADATA_CACHE_DESCRIPTION} This browser did not allow checking its storage.`);
|
||||
});
|
||||
|
||||
test('only a finished duplicate search with groups offers Review groups', async () => {
|
||||
const { hasDuplicateGroups } = await jobView;
|
||||
const job = (state, groups, kind = 'duplicates') => ({ kind, state, counters: { groups_found: groups } });
|
||||
assert.equal(hasDuplicateGroups(job('finished', 2)), true);
|
||||
assert.equal(hasDuplicateGroups(job('finished', 0)), false);
|
||||
assert.equal(hasDuplicateGroups(job('running', 2)), false);
|
||||
assert.equal(hasDuplicateGroups(job('finished', 2, 'scan')), false);
|
||||
assert.equal(hasDuplicateGroups({ kind: 'duplicates', state: 'finished', counters: {} }), false);
|
||||
});
|
||||
|
||||
test('a failed apply the library overtook offers Review again, back to its review (S7 review M2)', async () => {
|
||||
const { reviewAgainOf } = await jobView;
|
||||
const failed = (kind, failure, operation) => ({ kind, state: 'failed', scope: { operation, failure } });
|
||||
assert.deepEqual(reviewAgainOf(failed('maintenance', 'plan-changed', 'duplicate_prompts')),
|
||||
{ operation: 'duplicate_prompts' });
|
||||
assert.deepEqual(reviewAgainOf(failed('maintenance', 'storage-changed', 'optimize')), { operation: 'optimize' });
|
||||
// A duplicate cleanup reviewed against a search the sources overtook: search again in Maintenance.
|
||||
assert.deepEqual(reviewAgainOf(failed('duplicate_cleanup', 'sources-changed', 'duplicate_files')),
|
||||
{ section: '#/system/maintenance' });
|
||||
assert.equal(reviewAgainOf(failed('maintenance', 'backup-failed', 'optimize')), null);
|
||||
assert.equal(reviewAgainOf(failed('maintenance', 'plan-changed', 'not_an_operation')), null);
|
||||
assert.equal(reviewAgainOf({ ...failed('maintenance', 'plan-changed', 'optimize'), state: 'finished' }), null);
|
||||
assert.equal(reviewAgainOf(failed('scan', 'plan-changed', 'optimize')), null);
|
||||
});
|
||||
|
||||
test('a plan with nothing to do offers no Apply (S7 live test)', async () => {
|
||||
const { hasWork, NOTHING_TO_APPLY } = await review;
|
||||
assert.equal(hasWork(PLAN), true);
|
||||
assert.equal(hasWork({ ...PLAN, scope: { count: 0, label: 'No duplicate prompt text' } }), false);
|
||||
assert.equal(hasWork({ ...PLAN, operation: 'optimize', scope: { count: 1, label: 'The whole database' } }), true);
|
||||
// A plan whose count is unknown is not assumed empty: the server decides at apply.
|
||||
assert.equal(hasWork({ ...PLAN, scope: {} }), true);
|
||||
assert.equal(NOTHING_TO_APPLY, 'There is nothing to apply; nothing was changed.');
|
||||
});
|
||||
@@ -112,6 +112,16 @@ class AdminAPITestCase(AioHTTPTestCase):
|
||||
if api is not None:
|
||||
# A scan of an ambiguous file captures it for Needs review.
|
||||
api.close_annotation_captures()
|
||||
# A scan's job hands its missing previews to the automatic worker
|
||||
# as it ends, which can be after the test read the scan's last
|
||||
# event and after tearDownAsync closed the worker: that hand-off
|
||||
# starts a new one. Close it for good now that no job runs, so
|
||||
# no preview write (an open ``.part`` file) outlives the test:
|
||||
# this API refuses new workers, and the close joins the worker.
|
||||
# Not ``final=True``, which also shuts the process-wide encoders
|
||||
# (#319) that later tests use.
|
||||
api._auto_thumbnails_shut = True
|
||||
api.close_auto_thumbnails()
|
||||
PromptModel.close_all_instances() # restore may have swapped api.db
|
||||
assert_closed(self, self.tmpdir)
|
||||
shutil.rmtree(self.tmpdir, ignore_errors=True)
|
||||
@@ -548,6 +558,48 @@ class TestDuplicateScanSyncBody(AdminAPITestCase):
|
||||
)
|
||||
self.assertNotIn(self.tmpdir, json.dumps(groups))
|
||||
|
||||
def test_view_full_names_the_gallery_folder_source(self):
|
||||
"""Carry-over (System S7): a group's View Full opens the copy in the gallery's
|
||||
own source; without ``?root=`` the serve route took the first source that
|
||||
has a file at that path, another source's file when two hold one."""
|
||||
renders = self.output_dir / "renders"
|
||||
renders.mkdir()
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [
|
||||
self._root(renders),
|
||||
self._root(self.output_dir),
|
||||
]
|
||||
(renders / "same.png").write_bytes(b"other content")
|
||||
(self.output_dir / "same.png").write_bytes(b"same")
|
||||
(self.output_dir / "a#1.png").write_bytes(b"same")
|
||||
|
||||
groups = self._scan()
|
||||
|
||||
self.assertEqual(len(groups), 1)
|
||||
self.assertEqual(
|
||||
sorted(img["url"] for img in groups[0]["images"]),
|
||||
[
|
||||
"/prompt_manager/images/serve/a%231.png?root=1",
|
||||
"/prompt_manager/images/serve/same.png?root=1",
|
||||
],
|
||||
)
|
||||
|
||||
def test_view_full_without_a_listed_source_keeps_its_plain_url(self):
|
||||
renders = self.output_dir / "renders"
|
||||
renders.mkdir()
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [self._root(renders)]
|
||||
(self.output_dir / "a.png").write_bytes(b"same")
|
||||
(self.output_dir / "b.png").write_bytes(b"same")
|
||||
|
||||
groups = self._scan()
|
||||
|
||||
self.assertEqual(
|
||||
sorted(img["url"] for img in groups[0]["images"]),
|
||||
[
|
||||
"/prompt_manager/images/serve/a.png",
|
||||
"/prompt_manager/images/serve/b.png",
|
||||
],
|
||||
)
|
||||
|
||||
def test_thumbnails_dir_is_skipped_and_thumbnail_url_reported(self):
|
||||
(self.output_dir / "a.png").write_bytes(b"same")
|
||||
(self.output_dir / "b.png").write_bytes(b"same")
|
||||
|
||||
@@ -949,6 +949,37 @@ class TestFinalReviewFixes(ApplyTestCase):
|
||||
self.assertEqual(reply["code"], "plan-used")
|
||||
self.assertEqual(self.api._jobs.list(), [])
|
||||
|
||||
async def test_an_apply_that_never_started_says_so_with_its_code(self):
|
||||
"""S7 review I1: the server's own pre-start failure is definitive, so
|
||||
it carries a code the workspace can tell from a proxy's 5xx."""
|
||||
from py.api import maintenance
|
||||
|
||||
self.save("Orphan")
|
||||
plan = await self.preview("unused_components")
|
||||
with patch.object(maintenance, "disk_plan", side_effect=OSError("disk")):
|
||||
reply = await self.apply(plan["token"], 500)
|
||||
self.assertEqual(reply["code"], "apply-failed")
|
||||
self.assertEqual(reply["error"], maintenance.APPLY_FAILED)
|
||||
self.assertEqual(self.api._jobs.list(), [])
|
||||
|
||||
async def test_a_failed_apply_names_its_failure_in_its_scope(self):
|
||||
"""S7 review M2: Activity offers Review again from the job's own record."""
|
||||
orphan, soon = self.save("Orphan"), self.save("Soon a recipe")
|
||||
plan = await self.preview("unused_components")
|
||||
with self.api.db.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO prompt_library_state(prompt_id,title) VALUES(?,?)",
|
||||
(soon, "Recipe"),
|
||||
)
|
||||
body = await self.apply(plan["token"])
|
||||
job = self.api._jobs.get(body["job"]["id"])
|
||||
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
|
||||
self.assertEqual(
|
||||
job.summary()["scope"],
|
||||
{"operation": "unused_components", "failure": "plan-changed"},
|
||||
)
|
||||
self.assertIsNotNone(self.api.db.get_prompt_by_id(orphan))
|
||||
|
||||
async def test_the_backup_decision_is_frozen_when_the_job_starts(self):
|
||||
PromptManagerConfig.SAFETY_BACKUP = False
|
||||
self.save("Orphan")
|
||||
|
||||
+19
-1
@@ -11,6 +11,12 @@
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"cache-ownership.js": {
|
||||
"lines": 94,
|
||||
"branches": 89,
|
||||
"functions": 98,
|
||||
"statements": 94
|
||||
},
|
||||
"classic-node.js": {
|
||||
"lines": 98,
|
||||
"branches": 79,
|
||||
@@ -43,7 +49,7 @@
|
||||
},
|
||||
"gallery.js": {
|
||||
"lines": 45,
|
||||
"branches": 64,
|
||||
"branches": 66,
|
||||
"functions": 48,
|
||||
"statements": 45
|
||||
},
|
||||
@@ -407,6 +413,12 @@
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/maintenance-review.mjs": {
|
||||
"lines": 94,
|
||||
"branches": 81,
|
||||
"functions": 90,
|
||||
"statements": 94
|
||||
},
|
||||
"workspace/media-viewer.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 93,
|
||||
@@ -491,6 +503,12 @@
|
||||
"functions": 89,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/system-maintenance.mjs": {
|
||||
"lines": 92,
|
||||
"branches": 80,
|
||||
"functions": 92,
|
||||
"statements": 92
|
||||
},
|
||||
"workspace/system-overview.mjs": {
|
||||
"lines": 96,
|
||||
"branches": 92,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<script src="js/html-escape.js"></script>
|
||||
<script src="js/image-helpers.js"></script>
|
||||
<script src="js/gallery-settings.js"></script>
|
||||
<script src="js/cache-ownership.js"></script>
|
||||
<script src="js/scan-progress.js"></script>
|
||||
<script src="js/api-client.js"></script>
|
||||
<script src="js/png-metadata.js"></script>
|
||||
|
||||
@@ -50,6 +50,18 @@
|
||||
running.title.length > 0 && running.title.length <= 200) {
|
||||
this.running = { id: running.id, title: running.title };
|
||||
}
|
||||
// Whether our server itself refused: its body parsed and says success:false. A proxy's error page or an
|
||||
// unreadable body is not a refusal (the request may have done its work; System S7 review I1).
|
||||
this.refused = Boolean(current && typeof current === "object" && !Array.isArray(current) &&
|
||||
current.success === false);
|
||||
// A used plan names the job that applied it (System S7); its title is null once that job was pruned.
|
||||
this.job = null;
|
||||
const job = current && current.job;
|
||||
if (this.code === "plan-used" && job && typeof job.id === "string" &&
|
||||
/^[A-Za-z0-9_-]{1,64}$/.test(job.id) && (job.title === null ||
|
||||
(typeof job.title === "string" && job.title.length > 0 && job.title.length <= 200))) {
|
||||
this.job = { id: job.id, title: job.title };
|
||||
}
|
||||
this.source = null;
|
||||
if (this.code === "invalid-source" && current && typeof current.source === "string" &&
|
||||
current.source.length > 0 && current.source.length <= 4096) {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* What "clear the cache" may remove from this browser (#222).
|
||||
*
|
||||
* ComfyUI serves every custom node from one origin, so a browser's localStorage and CacheStorage are shared with other
|
||||
* tools. A cache clear removes only the entries Prompt Manager itself writes as a cache, listed by name in `OWNED` from
|
||||
* an inventory of the real writers, never by a guessed prefix. The inventory (2026-10-24, enforced by
|
||||
* tests/js/cache-ownership.test.js): every key a Prompt Manager page writes is a preference (`PREFERENCE_KEYS`: the
|
||||
* display defaults, the workspace theme, the dismissed update notice), and no page opens a CacheStorage cache or
|
||||
* IndexedDB. So `OWNED` is empty: nothing of Prompt Manager's is cached in this browser, and a clear says so instead of
|
||||
* claiming it removed something.
|
||||
*
|
||||
* Server thumbnails are not browser data: clearing them is the caller's own, separate request. This module never makes
|
||||
* a request.
|
||||
*
|
||||
* Loadable in the browser (window.CacheOwnership) and in Node for tests.
|
||||
*/
|
||||
(function (root) {
|
||||
"use strict";
|
||||
|
||||
const PREFERENCE_KEYS = Object.freeze(["gallerySettings", "pm_workspace_theme", "pm_update_notice_dismissed"]);
|
||||
const OWNED = Object.freeze({ storageKeys: Object.freeze([]), cacheNames: Object.freeze([]) });
|
||||
const NOTHING_CACHED = "Nothing of Prompt Manager's is cached in this browser.";
|
||||
const STORAGE_DENIED = "This browser refused access to its storage, so nothing there was cleared.";
|
||||
|
||||
// What a storage getter that threw (the browser refused storage) resolves to.
|
||||
const REFUSED = Object.freeze({});
|
||||
|
||||
/** `storage`, or what its getter returns; `REFUSED` when the getter throws. */
|
||||
function resolveStorage(storage) {
|
||||
if (typeof storage !== "function") return storage;
|
||||
try {
|
||||
return storage() ?? null;
|
||||
} catch {
|
||||
return REFUSED;
|
||||
}
|
||||
}
|
||||
|
||||
/** The keys `storage` holds, or null when the browser refuses to say. */
|
||||
function storageKeys(storage) {
|
||||
if (storage === REFUSED) return null;
|
||||
if (!storage) return [];
|
||||
try {
|
||||
const keys = [];
|
||||
for (let index = 0; index < storage.length; index += 1) keys.push(storage.key(index));
|
||||
return keys;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function cacheNames(cacheStorage) {
|
||||
if (!cacheStorage || typeof cacheStorage.keys !== "function") return [];
|
||||
try {
|
||||
return await cacheStorage.keys();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The owned entries present now: `{keys, caches, denied}`; `denied` when the browser refused to list its storage or
|
||||
* caches.
|
||||
*/
|
||||
async function ownedEntries({ storage, cacheStorage, owned = OWNED } = {}) {
|
||||
const present = storageKeys(resolveStorage(storage));
|
||||
const names = await cacheNames(cacheStorage);
|
||||
return {
|
||||
keys: (present ?? []).filter(key => owned.storageKeys.includes(key)),
|
||||
caches: (names ?? []).filter(name => owned.cacheNames.includes(name)),
|
||||
denied: present === null || names === null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the owned entries present now. `storage` is a Storage or a getter for one (`() => localStorage`, which a
|
||||
* browser refusing storage makes throw). Returns `{removed, failed, denied, partial}`: what was removed and what the
|
||||
* browser refused to remove, by name; `partial` when anything was refused, including listing the storage.
|
||||
*/
|
||||
async function clearOwned({ storage, cacheStorage, owned = OWNED } = {}) {
|
||||
storage = resolveStorage(storage);
|
||||
const { keys, caches, denied } = await ownedEntries({ storage, cacheStorage, owned });
|
||||
const removed = [];
|
||||
const failed = [];
|
||||
for (const key of keys) {
|
||||
try {
|
||||
storage.removeItem(key);
|
||||
removed.push(key);
|
||||
} catch {
|
||||
failed.push(key);
|
||||
}
|
||||
}
|
||||
for (const name of caches) {
|
||||
try {
|
||||
await cacheStorage.delete(name);
|
||||
removed.push(name);
|
||||
} catch {
|
||||
failed.push(name);
|
||||
}
|
||||
}
|
||||
return { removed, failed, denied, partial: denied || failed.length > 0 };
|
||||
}
|
||||
|
||||
const entries = count => `${count.toLocaleString("en-US")} cached ${count === 1 ? "entry" : "entries"}`;
|
||||
|
||||
/** What a clear's result says, truthfully. */
|
||||
function clearMessage(result) {
|
||||
const total = result.removed.length + result.failed.length;
|
||||
if (result.failed.length) {
|
||||
return `Cleared ${result.removed.length.toLocaleString("en-US")} of ${entries(total)}; this browser refused the rest.`;
|
||||
}
|
||||
if (result.denied) return STORAGE_DENIED;
|
||||
if (!total) return NOTHING_CACHED;
|
||||
return `Cleared ${entries(total)} from this browser.`;
|
||||
}
|
||||
|
||||
const api = { PREFERENCE_KEYS, OWNED, NOTHING_CACHED, STORAGE_DENIED, ownedEntries, clearOwned, clearMessage };
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
else root.CacheOwnership = api;
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
+36
-53
@@ -2166,61 +2166,44 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Clear cache (#222): the server's thumbnails by their own request, then only the browser entries Prompt
|
||||
* Manager owns as a cache (cache-ownership.js; preferences and other tools' storage stay). Nothing is
|
||||
* claimed that did not happen: a refused or lost server clear changes nothing here, and the toast says what
|
||||
* the browser part removed, or that nothing of Prompt Manager's is cached, or that storage was refused.
|
||||
*/
|
||||
async clearCache() {
|
||||
if (confirm('Clear PromptManager cache and thumbnails? This will NOT affect your original images.')) {
|
||||
try {
|
||||
// Clear only PromptManager-related browser caches (never touch user images)
|
||||
if ('caches' in window) {
|
||||
const cacheNames = await caches.keys();
|
||||
// Only clear caches that are specifically ours
|
||||
const ourCaches = cacheNames.filter(name =>
|
||||
name.includes('prompt-manager') ||
|
||||
name.includes('gallery') ||
|
||||
name.includes('thumbnail')
|
||||
);
|
||||
await Promise.all(ourCaches.map(name => caches.delete(name)));
|
||||
}
|
||||
|
||||
// Clear only our localStorage entries (never touch user data)
|
||||
Object.keys(localStorage).forEach(key => {
|
||||
if (key.startsWith('gallery_cache_') ||
|
||||
key.startsWith('metadata_cache_') ||
|
||||
key.startsWith('promptManager_') ||
|
||||
key.startsWith('pm_')) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
|
||||
// Call backend to clear server-side thumbnails (only our generated thumbnails)
|
||||
try {
|
||||
const response = await ApiClient.fetchUrl('/prompt_manager/images/clear-thumbnails', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Refused (a running job holds the thumbnails): say why and claim nothing.
|
||||
const data = await response.json().catch(() => ({}));
|
||||
this.showNotification(`Thumbnails were not cleared: ${data.error || `HTTP ${response.status}`}`, 'error');
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Could not clear server thumbnails:', e);
|
||||
// Not a critical error - continue with local cleanup
|
||||
}
|
||||
|
||||
// Update thumbnail status
|
||||
const settings = this.getSettings();
|
||||
settings.thumbnailsGenerated = false;
|
||||
localStorage.setItem('gallerySettings', JSON.stringify(settings));
|
||||
document.getElementById('thumbnailStatus').textContent = 'Not generated';
|
||||
|
||||
this.showNotification('PromptManager cache cleared successfully! Original images untouched.', 'success');
|
||||
} catch (error) {
|
||||
console.error('Clear cache error:', error);
|
||||
this.showNotification('Failed to clear cache', 'error');
|
||||
}
|
||||
if (!confirm('Clear PromptManager cache and thumbnails? This will NOT affect your original images.')) return;
|
||||
let response;
|
||||
try {
|
||||
response = await ApiClient.fetchUrl('/prompt_manager/images/clear-thumbnails', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Could not clear server thumbnails:', error);
|
||||
this.showNotification('Thumbnails were not cleared: the server could not be reached.', 'error');
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
// Refused (a running job holds the thumbnails): say why and claim nothing.
|
||||
const data = await response.json().catch(() => ({}));
|
||||
this.showNotification(`Thumbnails were not cleared: ${data.error || `HTTP ${response.status}`}`, 'error');
|
||||
return;
|
||||
}
|
||||
// Read through a getter: a browser that refuses storage throws on `localStorage` itself.
|
||||
const browser = await window.CacheOwnership.clearOwned({ storage: () => localStorage,
|
||||
cacheStorage: 'caches' in window ? window.caches : null });
|
||||
try {
|
||||
const settings = this.getSettings();
|
||||
settings.thumbnailsGenerated = false;
|
||||
localStorage.setItem('gallerySettings', JSON.stringify(settings));
|
||||
} catch (error) {
|
||||
console.warn('Could not record the cleared thumbnails:', error);
|
||||
}
|
||||
document.getElementById('thumbnailStatus').textContent = 'Not generated';
|
||||
this.showNotification(`Thumbnails cleared; original images untouched. ${window.CacheOwnership.clearMessage(browser)}`,
|
||||
browser.partial ? 'warning' : 'success');
|
||||
}
|
||||
|
||||
async rescanFolder() {
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
* - D89: the tiles, the outcome list, the openable rows and the log and cancel labels are the job kind's (`jobView`):
|
||||
* an image tagging job shows Tagged, Unchanged, Skipped and Errors, opens Tagged and Unchanged rows, and reads
|
||||
* "Tagging log" and "Cancel tagging".
|
||||
* - D101 (System S7): a finished duplicate search that found groups adds **Review groups** after Download report; it
|
||||
* opens "Review duplicate image files" for this search.
|
||||
* - D111: the kind's descriptor (activity-kinds.mjs `viewOf`) decides them for every kind: a thumbnail job counts
|
||||
* Generated, Skipped and Errors; a kind without groups (the health check, a held legacy-page operation) shows no
|
||||
* tiles and no Results, never another kind's zeros. A job without a known total has an indeterminate `<progress>`;
|
||||
@@ -35,6 +37,8 @@ import { createMediaViewer } from './media-viewer.mjs';
|
||||
import { createInspectionController } from './inspection-state.mjs';
|
||||
import { createPromptSender } from './prompt-sender.mjs';
|
||||
import { imageKey } from './images-state.mjs';
|
||||
import { OPERATION_TITLES, openDuplicatesReview, reviewPlan } from './maintenance-review.mjs';
|
||||
import { navigateTo } from './state.mjs';
|
||||
|
||||
export const DETAIL_INTERVAL_MS = 1000;
|
||||
export const NOT_FOUND = 'This job is not in this session’s Activity. Jobs are kept until ComfyUI restarts.';
|
||||
@@ -73,6 +77,31 @@ export function parseDetail(raw) {
|
||||
return { job, outcomes, log };
|
||||
}
|
||||
|
||||
// Failures that mean the reviewed plan no longer matches: the review must be done again (S7 review M2).
|
||||
const OVERTAKEN = new Set(['plan-changed', 'storage-changed', 'plan-expired']);
|
||||
|
||||
/**
|
||||
* Where a failed apply's Review again goes (S7 review M2): a maintenance plan the library overtook is reviewed again
|
||||
* (`{operation}`); a duplicate cleanup whose sources or plan changed needs a new search, in Maintenance (`{section}`);
|
||||
* otherwise null.
|
||||
*/
|
||||
export function reviewAgainOf(job) {
|
||||
if (job.state !== 'failed') return null;
|
||||
const failure = job.scope?.failure;
|
||||
if (job.kind === 'maintenance' && OVERTAKEN.has(failure) && Object.hasOwn(OPERATION_TITLES, job.scope?.operation)) {
|
||||
return { operation: job.scope.operation };
|
||||
}
|
||||
if (job.kind === 'duplicate_cleanup' && (OVERTAKEN.has(failure) || failure === 'sources-changed')) {
|
||||
return { section: '#/system/maintenance' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** D101: a duplicate search that finished with groups to review. */
|
||||
export function hasDuplicateGroups(job) {
|
||||
return job.kind === 'duplicates' && job.state === 'finished' && (job.counters.groups_found ?? 0) > 0;
|
||||
}
|
||||
|
||||
/** The progress line: the server's while running, then what the job got through. */
|
||||
export function progressLine(job) {
|
||||
const files = `${number(job.processed)} of ${number(job.total)} ${noun(job.total, 'file')}`;
|
||||
@@ -125,6 +154,10 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) {
|
||||
const download = createButton('Download report');
|
||||
const cancel = createButton('Cancel scan', 'danger');
|
||||
const review = createReviewMetadataButton(scope); // D87
|
||||
// System S7 (D101): a finished duplicate search that found groups reviews them.
|
||||
const reviewGroups = createButton('Review groups', 'ca-primary');
|
||||
// S7 review M2: a failed apply whose plan the library overtook goes back to its review.
|
||||
const reviewAgain = createButton('Review again', 'ca-primary');
|
||||
|
||||
const progressPart = section('Progress', 'job-progress-title');
|
||||
const status = element('span', '', 'small');
|
||||
@@ -226,6 +259,8 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) {
|
||||
mounted = false;
|
||||
const focused = main.contains(document.activeElement) && document.activeElement !== parts.h1;
|
||||
download.remove();
|
||||
reviewGroups.remove();
|
||||
reviewAgain.remove();
|
||||
cancel.remove();
|
||||
progressPart.node.remove();
|
||||
resultsPart.node.remove();
|
||||
@@ -289,6 +324,10 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) {
|
||||
download.disabled = lost;
|
||||
if (hasReviewItems(job)) { if (!review.isConnected) download.after(review); } // D87
|
||||
else review.remove();
|
||||
if (hasDuplicateGroups(job)) { if (!reviewGroups.isConnected) download.after(reviewGroups); } // D101
|
||||
else reviewGroups.remove();
|
||||
if (reviewAgainOf(job)) { if (!reviewAgain.isConnected) download.after(reviewAgain); }
|
||||
else reviewAgain.remove();
|
||||
// Truth rule: a job that cannot be cancelled (a held legacy-page job) shows no Cancel.
|
||||
if (canCancel(job)) {
|
||||
if (!cancel.isConnected) parts.actions.append(cancel);
|
||||
@@ -390,6 +429,13 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) {
|
||||
download.removeAttribute('aria-disabled');
|
||||
}
|
||||
});
|
||||
scope.listen(reviewGroups, 'click', () => openDuplicatesReview({ api, store, scope, jobId, trigger: reviewGroups }));
|
||||
scope.listen(reviewAgain, 'click', () => {
|
||||
const job = current();
|
||||
const target = job ? reviewAgainOf(job) : null;
|
||||
if (target?.operation) reviewPlan({ api, store, scope, operation: target.operation, trigger: reviewAgain });
|
||||
else if (target?.section) navigateTo(target.section);
|
||||
});
|
||||
scope.listen(cancel, 'click', async () => {
|
||||
if (cancel.getAttribute('aria-disabled') === 'true') return;
|
||||
cancel.setAttribute('aria-disabled', 'true');
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
* - `log` and `cancelLabel` (`cancel` is the same label under the job view's older name).
|
||||
*
|
||||
* `scan` is exactly the D56 view and `image-tags` the D89 one. `thumbnails` counts Generated (green), Skipped and
|
||||
* Errors (red). Any other kind (the health check, a held legacy-page operation, a kind this page does not know) gets
|
||||
* Errors (red). System S7: `maintenance` (a reviewed plan's apply) counts Changed, Protected and Errors and, when it
|
||||
* failed, ends with the server's reason; `duplicates` (the search) counts Groups found; `duplicate_cleanup` counts
|
||||
* Removed, Kept and Errors. Any other kind (the health check, a held legacy-page operation, a kind this page does not know) gets
|
||||
* the generic descriptor: title-based copy and no tiles or outcomes, so it never shows another kind's zeros (S1 review
|
||||
* M6, S2 review M3). `viewOf(job)` keeps only the groups the job itself counts, so a hold (no groups) has none either.
|
||||
*
|
||||
@@ -35,6 +37,15 @@ export const addedOf = job => counted(job, 'added', 'added');
|
||||
export const taggedOf = job => counted(job, 'tagged', 'tagged');
|
||||
/** "3 generated" (D111). */
|
||||
export const generatedOf = job => counted(job, 'generated', 'generated');
|
||||
/** "3 changed" (System S7: a maintenance apply). */
|
||||
export const changedOf = job => counted(job, 'changed', 'changed');
|
||||
/** "12 groups found" (System S7: a duplicate search). */
|
||||
export const groupsOf = job => {
|
||||
const groups = job.counters?.groups_found ?? 0;
|
||||
return `${number(groups)} ${noun(groups, 'group')} found`;
|
||||
};
|
||||
/** "3 removed" (System S7: a duplicate cleanup). */
|
||||
export const removedOf = job => counted(job, 'removed', 'removed');
|
||||
|
||||
/** "2 output folders": what a scan or a thumbnail job walks. */
|
||||
function foldersOf(job) {
|
||||
@@ -72,6 +83,15 @@ const GENERIC = Object.freeze({
|
||||
const scanLine = job => `${addedOf(job)} · ${errorsOf(job)}`;
|
||||
const tagLine = job => `${taggedOf(job)} · ${errorsOf(job)}`;
|
||||
const thumbnailLine = job => `${generatedOf(job)} · ${errorsOf(job)}`;
|
||||
const changedLine = job => `${changedOf(job)} · ${errorsOf(job)}`;
|
||||
const cleanupLine = job => `${removedOf(job)} · ${errorsOf(job)}`;
|
||||
|
||||
/** A maintenance apply ends with its counters, or, when it failed, the server's reason (plan-changed: review again). */
|
||||
function maintenanceFinish(job) {
|
||||
if (job.state === 'cancelled') return `${job.title} cancelled · ${changedOf(job)}`;
|
||||
if (job.state === 'failed') return `${job.title} failed · ${job.status_line || errorsOf(job)}`;
|
||||
return `${job.title} finished · ${changedOf(job)}`;
|
||||
}
|
||||
// D85: an image tagging job says which of its two modes started.
|
||||
const IMAGE_TAG_START_TOASTS = Object.freeze({ add: 'Tagging started.', auto: 'Auto-tagging started.' });
|
||||
|
||||
@@ -123,6 +143,45 @@ export const KINDS = Object.freeze({
|
||||
outcomes: tiles([['generated', 'Generated'], ['errors', 'Errors'], ['skipped', 'Skipped']]),
|
||||
openable: groups('generated', 'skipped'),
|
||||
}),
|
||||
// System S7: a reviewed maintenance plan's apply, titled after its operation; it runs without a known total.
|
||||
maintenance: Object.freeze({
|
||||
...GENERIC,
|
||||
finishToast: maintenanceFinish,
|
||||
line: changedLine,
|
||||
kept: changedOf,
|
||||
tiles: tiles([['changed', 'Changed', 'added'], ['protected', 'Protected', ''], ['errors', 'Errors', 'bad']]),
|
||||
outcomes: tiles([['changed', 'Changed'], ['errors', 'Errors'], ['protected', 'Protected']]),
|
||||
log: 'Maintenance log',
|
||||
}),
|
||||
// System S7: the duplicate image search; each found group's row opens its first copy.
|
||||
duplicates: Object.freeze({
|
||||
...GENERIC,
|
||||
pillVerb: 'Finding duplicates',
|
||||
startToast: () => 'Duplicate search started.',
|
||||
finishToast: namedFinish(() => 'Duplicate search', groupsOf, groupsOf),
|
||||
line: groupsOf,
|
||||
kept: groupsOf,
|
||||
tiles: tiles([['groups_found', 'Groups found', '']]),
|
||||
outcomes: tiles([['groups_found', 'Groups found']]),
|
||||
openable: groups('groups_found'),
|
||||
log: 'Search log',
|
||||
cancelLabel: 'Cancel search',
|
||||
cancel: 'Cancel search',
|
||||
}),
|
||||
// System S7: the reviewed duplicate cleanup; the kept copies open the viewer, the removed ones are gone.
|
||||
duplicate_cleanup: Object.freeze({
|
||||
...GENERIC,
|
||||
pillVerb: 'Removing duplicate files',
|
||||
// A failed cleanup says the server's reason (sources changed, no safety backup), as a maintenance apply does.
|
||||
finishToast: job => (job.state === 'failed' && job.status_line ? `Duplicate cleanup failed · ${job.status_line}`
|
||||
: namedFinish(() => 'Duplicate cleanup', removedOf, cleanupLine)(job)),
|
||||
line: cleanupLine,
|
||||
kept: removedOf,
|
||||
tiles: tiles([['removed', 'Removed', 'added'], ['kept', 'Kept', ''], ['errors', 'Errors', 'bad']]),
|
||||
outcomes: tiles([['removed', 'Removed'], ['errors', 'Errors'], ['kept', 'Kept']]),
|
||||
openable: groups('kept'),
|
||||
log: 'Cleanup log',
|
||||
}),
|
||||
});
|
||||
|
||||
/** The descriptor of a job `kind`; any kind without one gets the generic descriptor. */
|
||||
|
||||
@@ -246,7 +246,8 @@ export function createActivityStore({ api, scope, connect = defaultConnect, noti
|
||||
} catch (error) {
|
||||
if (error?.status !== 409 || error?.code !== 'busy') throw error;
|
||||
await load();
|
||||
return { busy: store.running()[0] ?? null, message: error.message };
|
||||
// The job the server named holds the slot; read jobs run side by side, so the first running one may not.
|
||||
return { busy: error.running ?? store.running()[0] ?? null, message: error.message };
|
||||
}
|
||||
},
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* Maintenance reviews (#249 System S7 Task 19): the concept's "Review maintenance plan" (`maintenancePlan`, app.js
|
||||
* 953–958) and "Review duplicate image files" (`duplicates`, 1457–1461) on the S6 backend.
|
||||
*
|
||||
* - D100 plan dialog: Review plan reads `POST /prompt_manager/maintenance/preview` (read-only) and shows the server's
|
||||
* plan in the shared review (`openReviewDialog`): the operation's `h3`, the concept's `dl.meta-grid` Scope /
|
||||
* Protected / Backup / Media on disk with the plan's own values, a `details` list of the records it names, the
|
||||
* `p.small` line saying until when the review holds, the D55 backup box ("Back up now") and Cancel / "Apply reviewed
|
||||
* operation" (the concept's "Simulate reviewed operation"). Cancel writes nothing. A plan with nothing to do (a scope
|
||||
* of 0) shows its rows, "There is nothing to apply; nothing was changed." and only Close.
|
||||
* - Apply sends the plan's token (`POST /maintenance/apply`): the dialog closes, the job is followed in the Activity
|
||||
* store, which toasts that it started, and its view opens. A refused apply says why: `busy` names the running job
|
||||
* (D57), `plan-used` links the job that applied it, an expired or replaced review is reviewed again at once, and an
|
||||
* apply whose answer was lost says "Outcome not confirmed" and points to Activity (the job may be running).
|
||||
* - D101 duplicates dialog: a finished duplicate search's groups, ten at a time (`GET /maintenance/duplicates/<job>`),
|
||||
* one `.compare-grid` per group with a `.compare-item` per copy (its preview and a Keep radio; the oldest copy is
|
||||
* kept unless another is chosen). Each choice names its copy by `{group_id, root_index, path}`, so equal paths in two
|
||||
* sources are never confused. The `p.small` says what removing a copy does, History keeping the removed paths in its
|
||||
* run records. "Review duplicate cleanup" reviews the groups shown so far as a `duplicate_files` plan.
|
||||
*
|
||||
* Every request goes through the page's api client; every listener sits on the section's `scope`.
|
||||
*/
|
||||
import { createButton } from '../shared/toolkit/core.js';
|
||||
import { closeWorkspaceDialog, openContentDialog, openReviewDialog } from './workspace-dialogs.mjs';
|
||||
import { busyToast, downloadDatabaseBackup } from './activity-scan.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { navigateTo } from './state.mjs';
|
||||
import { noun } from './plural.mjs';
|
||||
|
||||
export const PREVIEW_URL = '/prompt_manager/maintenance/preview';
|
||||
export const APPLY_URL = '/prompt_manager/maintenance/apply';
|
||||
export const GROUPS_URL = '/prompt_manager/maintenance/duplicates';
|
||||
export const GROUP_PAGE = 10;
|
||||
export const PLAN_TITLE = 'Review maintenance plan';
|
||||
export const DUPLICATES_TITLE = 'Review duplicate image files';
|
||||
export const APPLY_LABEL = 'Apply reviewed operation';
|
||||
export const NOTHING_TO_APPLY = 'There is nothing to apply; nothing was changed.';
|
||||
export const REVIEW_FAILED = 'The plan could not be reviewed. Nothing was changed.';
|
||||
export const APPLY_FAILED = 'The operation could not start. Nothing was changed.';
|
||||
export const GROUPS_FAILED = 'The duplicate groups could not be read. Nothing was changed.';
|
||||
export const NOT_CONFIRMED = ['Outcome not confirmed. ', { text: 'Check Activity', href: '#/activity' },
|
||||
' before applying it again.'];
|
||||
export const DUPLICATES_NOTE = 'Removing a copy deletes its file from disk; its database links move to the kept copy. ' +
|
||||
'History keeps the removed paths in its run records. Nothing is deleted until you review and apply the cleanup.';
|
||||
export const BACKUP_COPY = Object.freeze({ heading: 'Back up the database first (recommended)',
|
||||
text: 'Downloads a copy of the prompts database before this operation changes any records.' });
|
||||
/** The concept's operation names, which head the plan dialog (and title the server's job). */
|
||||
export const OPERATION_TITLES = Object.freeze({
|
||||
duplicate_prompts: 'Duplicate prompt text',
|
||||
optimize: 'Optimize database',
|
||||
unused_components: 'Unused prompt components',
|
||||
missing_links: 'Missing-file links',
|
||||
duplicate_files: 'Duplicate image files',
|
||||
// Reviewed and applied through the API and seen in Activity; Maintenance has no row for it (the concept has none).
|
||||
workflow_storage: 'Store each workflow once',
|
||||
});
|
||||
// The review is gone or no longer matches the database: review it again.
|
||||
const REVIEW_AGAIN = new Set(['plan-expired', 'storage-changed']);
|
||||
// Refusals whose server message is the whole truth.
|
||||
const SERVER_SAYS = new Set(['insufficient-space', 'nothing-to-do', 'too-large', 'invalid-request', 'not-found',
|
||||
'job-running', 'no-groups', 'apply-failed', 'sources-changed']);
|
||||
|
||||
const number = value => value.toLocaleString('en-US');
|
||||
|
||||
function el(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
if (className) node.className = className;
|
||||
return node;
|
||||
}
|
||||
|
||||
/** The Backup value: what the apply will do before it changes anything. */
|
||||
export function backupText(backup) {
|
||||
if (backup?.policy === 'off') return 'No safety backup (the policy is off)';
|
||||
const where = backup?.destination_label ? ` (${backup.destination_label})` : '';
|
||||
if (backup?.policy === 'required') return `Always created and verified first${where}`;
|
||||
return `Create and verify before changes${where}`;
|
||||
}
|
||||
|
||||
/** The Protected value: the server's reasons, or that nothing is held back. */
|
||||
export function protectedText(held) {
|
||||
const reasons = Array.isArray(held?.reasons) ? held.reasons.filter(reason => typeof reason === 'string' && reason) : [];
|
||||
if (reasons.length) return reasons.join(' · ');
|
||||
const count = Number.isSafeInteger(held?.count) ? held.count : 0;
|
||||
return count ? `${number(count)} ${noun(count, 'record')} kept` : 'Nothing is protected';
|
||||
}
|
||||
|
||||
/** D100: the concept's four rows with the plan's values. */
|
||||
export function planGrid(plan) {
|
||||
return [
|
||||
['Scope', plan.scope?.label || `${number(plan.scope?.count ?? 0)} ${noun(plan.scope?.count ?? 0, 'record')}`],
|
||||
['Protected', protectedText(plan.protected)],
|
||||
['Backup', backupText(plan.backup)],
|
||||
['Media on disk', plan.media?.label || 'No files are removed'],
|
||||
];
|
||||
}
|
||||
|
||||
const megabytes = bytes => `${(bytes / 2 ** 20).toLocaleString('en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 })} MB`;
|
||||
|
||||
/** D100: the `p.small` line: until when the review holds, and a disk without room for the apply's writes. */
|
||||
export function planNote(plan, { now = Date.now() } = {}) {
|
||||
const parts = [];
|
||||
const expires = new Date(plan.expires_at);
|
||||
if (!Number.isNaN(expires.getTime())) {
|
||||
const minutes = Math.max(1, Math.round((expires.getTime() - now) / 60000));
|
||||
parts.push(`Nothing changes until you apply. This review holds for ${number(minutes)} ${noun(minutes, 'minute')}.`);
|
||||
} else {
|
||||
parts.push('Nothing changes until you apply.');
|
||||
}
|
||||
const disk = plan.disk;
|
||||
if (disk && disk.sufficient === false && Number.isFinite(disk.required_bytes) && Number.isFinite(disk.free_bytes)) {
|
||||
parts.push(`Not enough free disk space: about ${megabytes(disk.required_bytes)} is needed and ${megabytes(disk.free_bytes)} is free.`);
|
||||
}
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/** Whether the plan changes anything: a scope of 0 records has nothing to apply (an unknown count is not assumed empty). */
|
||||
export function hasWork(plan) {
|
||||
return !(Number.isSafeInteger(plan?.scope?.count) && plan.scope.count === 0);
|
||||
}
|
||||
|
||||
/** The records a plan names (at most 50), as `label · detail` lines. */
|
||||
export function detailLines(plan) {
|
||||
return (Array.isArray(plan.details) ? plan.details : [])
|
||||
.filter(row => row && typeof row.label === 'string')
|
||||
.map(row => (row.detail ? `${row.label} · ${row.detail}` : row.label));
|
||||
}
|
||||
|
||||
/**
|
||||
* The toast for a refused or failed apply; `review` when the plan must be reviewed again, `lost` when the outcome is
|
||||
* not known. Only our server's own refusal is definitive (a known code, or a 4xx whose body says success:false): a
|
||||
* dropped connection, a proxy's 5xx or a 2xx whose body could not be read may follow a started job.
|
||||
*/
|
||||
export function applyRefusal(error) {
|
||||
const status = error?.status;
|
||||
const known = error?.code && (error.code === 'busy' || error.code === 'plan-used' || REVIEW_AGAIN.has(error.code)
|
||||
|| SERVER_SAYS.has(error.code));
|
||||
const refused = Number.isInteger(status) && status >= 400 && status < 500 && error.refused === true;
|
||||
if (!known && !refused) return { toast: NOT_CONFIRMED, lost: true };
|
||||
if (error.code === 'busy') return { toast: busyToast(error.running) };
|
||||
if (error.code === 'plan-used') {
|
||||
const job = error.job;
|
||||
if (!job) return { toast: 'This plan was already applied.' };
|
||||
return { toast: ['This plan was already applied: ', { text: job.title ?? 'its job', href: `#/activity/${job.id}` }, '.'] };
|
||||
}
|
||||
if (REVIEW_AGAIN.has(error.code)) return { toast: error.message, review: true };
|
||||
if (SERVER_SAYS.has(error.code) && error.message) return { toast: error.message };
|
||||
return { toast: APPLY_FAILED };
|
||||
}
|
||||
|
||||
/** The toast for a review that could not be read. */
|
||||
export function reviewRefusal(error) {
|
||||
if ((REVIEW_AGAIN.has(error?.code) || SERVER_SAYS.has(error?.code)) && error.message) return error.message;
|
||||
return REVIEW_FAILED;
|
||||
}
|
||||
|
||||
/** Hold `trigger` aria-disabled with `busyText` while `work()` runs; a second press is ignored. */
|
||||
async function holding(trigger, busyText, work) {
|
||||
if (trigger?.getAttribute('aria-disabled') === 'true') return;
|
||||
const text = trigger?.textContent;
|
||||
trigger?.setAttribute('aria-disabled', 'true');
|
||||
if (trigger && busyText) trigger.textContent = busyText;
|
||||
try {
|
||||
await work();
|
||||
} finally {
|
||||
trigger?.removeAttribute('aria-disabled');
|
||||
if (trigger && busyText) trigger.textContent = text;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a reviewed plan's `token`; the job opens in Activity. `reviewAgain()` reviews the operation once more when the
|
||||
* server says the review expired or the database was replaced.
|
||||
*/
|
||||
export async function applyPlan({ api, store, scope, token, reviewAgain, notify = showWorkspaceToast, navigate = navigateTo }) {
|
||||
let result;
|
||||
try {
|
||||
result = await api.post(APPLY_URL, { token });
|
||||
} catch (error) {
|
||||
if (scope.signal.aborted) return;
|
||||
const refusal = applyRefusal(error);
|
||||
notify(refusal.toast);
|
||||
if (refusal.lost) store.load();
|
||||
if (refusal.review) reviewAgain?.();
|
||||
return;
|
||||
}
|
||||
if (scope.signal.aborted) return;
|
||||
let job;
|
||||
try {
|
||||
job = store.track(result?.job);
|
||||
} catch {
|
||||
notify(NOT_CONFIRMED);
|
||||
store.load();
|
||||
return;
|
||||
}
|
||||
notify(`${job.title} started.`);
|
||||
navigate(`#/activity/${job.id}`);
|
||||
}
|
||||
|
||||
/** Show the reviewed `plan` for `operation`; Apply applies exactly this token. */
|
||||
export function showPlan(plan, { api, store, scope, trigger = null, reviewAgain, notify = showWorkspaceToast,
|
||||
navigate = navigateTo, backup = () => downloadDatabaseBackup() }) {
|
||||
const options = [];
|
||||
const lines = detailLines(plan);
|
||||
if (lines.length) {
|
||||
const list = el('details');
|
||||
const count = plan.scope?.count ?? lines.length;
|
||||
list.append(el('summary', lines.length < count ? `Listed records (${number(lines.length)} of ${number(count)})`
|
||||
: `Listed records (${number(lines.length)})`));
|
||||
const body = el('pre', lines.join('\n'));
|
||||
body.tabIndex = 0;
|
||||
list.append(body);
|
||||
options.push(list);
|
||||
}
|
||||
const heading = OPERATION_TITLES[plan.operation] ?? plan.operation;
|
||||
if (!hasWork(plan)) {
|
||||
// Nothing to apply: the review's own rows and a Close, never an Apply that would start an empty job.
|
||||
const grid = el('dl', undefined, 'meta-grid');
|
||||
for (const [term, value] of planGrid(plan)) {
|
||||
const entry = el('div');
|
||||
entry.append(el('dt', term), el('dd', value));
|
||||
grid.append(entry);
|
||||
}
|
||||
const close = createButton('Close');
|
||||
close.addEventListener('click', () => closeWorkspaceDialog());
|
||||
const actions = el('div', undefined, 'dialog-actions');
|
||||
actions.append(close);
|
||||
openContentDialog({ scope, trigger, title: PLAN_TITLE,
|
||||
nodes: [el('h3', heading), grid, ...options, el('p', NOTHING_TO_APPLY, 'small'), actions] });
|
||||
return;
|
||||
}
|
||||
openReviewDialog({ scope, trigger, title: PLAN_TITLE, heading,
|
||||
grid: planGrid(plan), options, sentence: planNote(plan), backup: { copy: BACKUP_COPY, onBackup: backup },
|
||||
cancelLabel: 'Cancel', primary: { label: APPLY_LABEL },
|
||||
onStart: () => applyPlan({ api, store, scope, token: plan.token, reviewAgain, notify, navigate }) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Review `operation` (`POST /maintenance/preview`) and show its plan. `options` are the preview's (duplicate_files:
|
||||
* `{job_id, keep}`). `hold` (default `trigger`, which gets focus back) is the pressed button: it reads "Reviewing…"
|
||||
* and ignores presses until the review answered, so a double press sends one preview. Resolves once the plan is shown
|
||||
* or the refusal toasted.
|
||||
*/
|
||||
export async function reviewPlan({ api, store, scope, operation, options = {}, trigger = null, hold = trigger,
|
||||
notify = showWorkspaceToast, navigate = navigateTo }) {
|
||||
const again = () => reviewPlan({ api, store, scope, operation, options, trigger, notify, navigate });
|
||||
await holding(hold, 'Reviewing…', async () => {
|
||||
let plan;
|
||||
try {
|
||||
plan = await api.post(PREVIEW_URL, { operation, options }, { signal: scope.signal });
|
||||
} catch (error) {
|
||||
if (!scope.signal.aborted) notify(reviewRefusal(error));
|
||||
return;
|
||||
}
|
||||
if (scope.signal.aborted) return;
|
||||
showPlan(plan, { api, store, scope, trigger, notify, navigate, reviewAgain: again });
|
||||
});
|
||||
}
|
||||
|
||||
// ── duplicate image files ───────────────────────────────────────────────────
|
||||
|
||||
/** The keep entries for the chosen copies: `{group_id, root_index, path}`, never a bare path (equal paths in two sources). */
|
||||
export function keepEntries(chosen) {
|
||||
return [...chosen].map(([groupId, file]) => ({ group_id: groupId, root_index: file.root_index, path: file.path }));
|
||||
}
|
||||
|
||||
/** "Groups 1–10 of 24". */
|
||||
export function pageText(offset, shown, total) {
|
||||
if (!total) return 'No duplicate files were found.';
|
||||
return `${noun(total, 'Group')} ${number(offset + 1)}–${number(offset + shown)} of ${number(total)}`;
|
||||
}
|
||||
|
||||
const kilobytes = bytes => (bytes >= 2 ** 20 ? megabytes(bytes) : `${number(Math.max(1, Math.round(bytes / 1024)))} KB`);
|
||||
|
||||
/** A group's line: "3 identical files · 120 KB each". */
|
||||
export function groupText(group) {
|
||||
const count = Number.isSafeInteger(group.count) ? group.count : group.files.length;
|
||||
return `${number(count)} identical ${noun(count, 'file')} · ${kilobytes(group.size ?? 0)} each`;
|
||||
}
|
||||
|
||||
/** A copy's Keep label: its source and path; a file gone since the search says so. */
|
||||
export function copyLabel(file) {
|
||||
const where = file.root ? `${file.root}/${file.path}` : file.path;
|
||||
return file.missing ? `Keep ${where} (no longer on disk)` : `Keep ${where}`;
|
||||
}
|
||||
|
||||
function preview(file) {
|
||||
const url = file.thumbnail_url ?? (file.media_type === 'video' ? null : file.url);
|
||||
if (!url) return el('div', file.media_type === 'video' ? 'Video' : 'No preview', 'empty-state');
|
||||
const image = el('img', undefined, 'sample-fluid');
|
||||
image.src = window.ApiClient.resolveUrl(url);
|
||||
image.alt = file.filename ?? file.path;
|
||||
image.loading = 'lazy';
|
||||
return image;
|
||||
}
|
||||
|
||||
function copyItem(group, file, index, chosen, scope) {
|
||||
const item = el('div', undefined, 'compare-item');
|
||||
const label = el('label');
|
||||
const radio = el('input');
|
||||
radio.type = 'radio';
|
||||
radio.name = `keep-${group.group_id}`;
|
||||
radio.value = String(index);
|
||||
radio.disabled = file.missing === true;
|
||||
const current = chosen.get(group.group_id);
|
||||
radio.checked = current === file;
|
||||
scope.listen(radio, 'change', () => { if (radio.checked) chosen.set(group.group_id, file); });
|
||||
label.append(radio, document.createTextNode(` ${copyLabel(file)}`));
|
||||
item.append(preview(file), label);
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open "Review duplicate image files" for the finished search `jobId`, at its first page. Choices are kept across pages;
|
||||
* the groups shown so far go into the cleanup review.
|
||||
*/
|
||||
export async function openDuplicatesReview({ api, store, scope, jobId, trigger = null, notify = showWorkspaceToast,
|
||||
navigate = navigateTo }) {
|
||||
const chosen = new Map();
|
||||
const status = el('p', '', 'small');
|
||||
status.setAttribute('role', 'status');
|
||||
const groupsNode = el('div', undefined, 'duplicate-groups');
|
||||
const previous = createButton('Previous groups');
|
||||
const next = createButton('Next groups');
|
||||
const pager = el('div', undefined, 'row-actions');
|
||||
pager.append(previous, next);
|
||||
const cancel = createButton('Cancel');
|
||||
// The concept's `duplicates` dialog: a plain button; the plan it opens has the primary.
|
||||
const review = createButton('Review duplicate cleanup');
|
||||
const actions = el('div', undefined, 'dialog-actions');
|
||||
actions.append(cancel, review);
|
||||
let offset = 0;
|
||||
let total = 0;
|
||||
|
||||
async function load(at) {
|
||||
const query = new URLSearchParams({ offset: String(at), limit: String(GROUP_PAGE) });
|
||||
const page = await api.get(`${GROUPS_URL}/${encodeURIComponent(jobId)}?${query}`, { signal: scope.signal, cache: 'no-store' });
|
||||
const groups = Array.isArray(page?.groups) ? page.groups : [];
|
||||
offset = at;
|
||||
total = Number.isSafeInteger(page?.total) ? page.total : groups.length;
|
||||
const nodes = [];
|
||||
for (const group of groups) {
|
||||
const files = Array.isArray(group.files) ? group.files : [];
|
||||
if (!chosen.has(group.group_id)) {
|
||||
const keep = files.find(file => !file.missing);
|
||||
if (keep) chosen.set(group.group_id, keep);
|
||||
} else {
|
||||
// A page read again: the same copy, by its identity, stays chosen.
|
||||
const was = chosen.get(group.group_id);
|
||||
const same = files.find(file => file.root_index === was.root_index && file.path === was.path);
|
||||
if (same) chosen.set(group.group_id, same);
|
||||
}
|
||||
const grid = el('div', undefined, 'compare-grid');
|
||||
grid.append(...files.map((file, index) => copyItem(group, file, index, chosen, scope)));
|
||||
nodes.push(el('p', groupText({ ...group, files }), 'small'), grid);
|
||||
}
|
||||
groupsNode.replaceChildren(...nodes);
|
||||
status.textContent = pageText(offset, groups.length, total);
|
||||
previous.disabled = offset === 0;
|
||||
next.disabled = offset + groups.length >= total;
|
||||
review.disabled = chosen.size === 0;
|
||||
}
|
||||
|
||||
try {
|
||||
await load(0);
|
||||
} catch (error) {
|
||||
if (!scope.signal.aborted) notify(reviewRefusal(error) === REVIEW_FAILED ? GROUPS_FAILED : error.message);
|
||||
return;
|
||||
}
|
||||
if (scope.signal.aborted) return;
|
||||
const turn = async at => {
|
||||
try {
|
||||
await load(at);
|
||||
} catch (error) {
|
||||
if (!scope.signal.aborted) notify(GROUPS_FAILED);
|
||||
}
|
||||
};
|
||||
scope.listen(previous, 'click', () => turn(Math.max(0, offset - GROUP_PAGE)));
|
||||
scope.listen(next, 'click', () => turn(offset + GROUP_PAGE));
|
||||
scope.listen(cancel, 'click', () => closeWorkspaceDialog());
|
||||
scope.listen(review, 'click', () => {
|
||||
if (review.disabled) return;
|
||||
reviewPlan({ api, store, scope, operation: 'duplicate_files', trigger, hold: review,
|
||||
options: { job_id: jobId, keep: keepEntries(chosen) }, notify, navigate });
|
||||
});
|
||||
const nodes = [status, groupsNode];
|
||||
if (total > GROUP_PAGE) nodes.push(pager);
|
||||
nodes.push(el('p', DUPLICATES_NOTE, 'small'), actions);
|
||||
openContentDialog({ scope, title: DUPLICATES_TITLE, trigger, nodes });
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* System › Maintenance (#249 System S7 Task 19): the concept's `renderSystem` "maintenance" body (app.js 688–690).
|
||||
*
|
||||
* - The notice "Check first. Review changes. Then apply." with "Run health checks" (D123), then the surfaces
|
||||
* "Database" and "Images & cache" with the concept's eight operations, in its order, labels and danger marks.
|
||||
* - Integrity & relationship checks → Check runs the same read-only health check and opens the Health report (D95);
|
||||
* its "Review missing links" reviews a fresh Missing-file links plan, never a cleanup.
|
||||
* - Duplicate prompt text, Optimize database, Unused prompt components and Missing-file links → Review plan open the
|
||||
* D100 plan review (maintenance-review.mjs), which applies only what it showed, as an Activity job.
|
||||
* - Duplicate image files → Find duplicates starts the duplicate search as an Activity job and opens it; its view
|
||||
* offers Review groups (D101) once it found some.
|
||||
* - Thumbnail cache → Review options opens the S5 "Thumbnail maintenance" dialog (D99).
|
||||
* - D102 (Q5): Metadata cache clears only the browser entries Prompt Manager owns as a cache (cache-ownership.js,
|
||||
* #222). The inventory has none, so the row says "Nothing is cached in this browser." and its Review plan is
|
||||
* aria-disabled; with owned entries it reviews and clears exactly those.
|
||||
*/
|
||||
import '../cache-ownership.js';
|
||||
import { navigateTo } from './state.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { operationRow } from './system-controls.mjs';
|
||||
import { healthNotice, runHealthAction } from './system-overview.mjs';
|
||||
import { openThumbnailReview } from './thumbnail-review.mjs';
|
||||
import { busyToast } from './activity-scan.mjs';
|
||||
import { openReviewDialog } from './workspace-dialogs.mjs';
|
||||
import { reviewPlan } from './maintenance-review.mjs';
|
||||
import { noun } from './plural.mjs';
|
||||
|
||||
export const DUPLICATES_KIND = 'duplicates';
|
||||
export const FIND_FAILED = 'The duplicate search could not start. Try again.';
|
||||
export const NOTHING_CACHED_REASON = 'Nothing is cached in this browser.';
|
||||
export const STORAGE_UNCHECKED_REASON = 'This browser did not allow checking its storage.';
|
||||
export const METADATA_CACHE_DESCRIPTION = 'Clear only Prompt Manager metadata cached by this browser.';
|
||||
|
||||
/** The concept's two surfaces and their rows: `[title, description, label, action, danger]`. */
|
||||
export const MAINTENANCE_SURFACES = Object.freeze([
|
||||
['Database', [
|
||||
['Integrity & relationship checks', 'Validate foreign keys, text hashes, pair uniqueness and image associations.', 'Check', 'health'],
|
||||
['Duplicate prompt text', 'Review canonical text reuse and dependency-safe merging.', 'Review plan', 'duplicate_prompts'],
|
||||
['Optimize database', 'Reclaim unused SQLite space. This does not delete prompts or media.', 'Review plan', 'optimize'],
|
||||
['Unused prompt components', 'Preview unreferenced components. Pair references and history remain protected.', 'Review plan',
|
||||
'unused_components', true],
|
||||
]],
|
||||
['Images & cache', [
|
||||
['Missing-file links', 'Remove stale database links only. Files on disk are never deleted by this operation.', 'Review plan',
|
||||
'missing_links', true],
|
||||
['Duplicate image files', 'Find matching file content, review groups and choose what to keep. Separate from duplicate prompt text.',
|
||||
'Find duplicates', 'duplicates'],
|
||||
['Thumbnail cache', 'Generate missing thumbnails or explicitly rebuild all.', 'Review options', 'thumbnails'],
|
||||
['Metadata cache', METADATA_CACHE_DESCRIPTION, 'Review plan', 'metadata_cache'],
|
||||
]],
|
||||
]);
|
||||
|
||||
function el(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
if (className) node.className = className;
|
||||
return node;
|
||||
}
|
||||
|
||||
const ownership = () => window.CacheOwnership;
|
||||
const browserStorage = () => window.localStorage;
|
||||
const cacheStorage = () => ('caches' in window ? window.caches : null);
|
||||
|
||||
/** Start the duplicate search and open its job; a refused or failed start says why and stays. */
|
||||
export async function findDuplicates({ store, scope, notify = showWorkspaceToast, navigate = navigateTo }) {
|
||||
try {
|
||||
const result = await store.start(DUPLICATES_KIND);
|
||||
if (scope.signal.aborted) return;
|
||||
if (result.busy !== undefined) {
|
||||
notify(busyToast(result.busy));
|
||||
return;
|
||||
}
|
||||
navigate(`#/activity/${result.job.id}`);
|
||||
} catch {
|
||||
if (!scope.signal.aborted) notify(FIND_FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* D102: the Metadata cache row's description: the concept's, plus the reason its review is unavailable. A browser that
|
||||
* refused to list its storage is never claimed empty.
|
||||
*/
|
||||
export function metadataCacheDescription(entries) {
|
||||
if (entries.keys.length + entries.caches.length > 0) return METADATA_CACHE_DESCRIPTION;
|
||||
return `${METADATA_CACHE_DESCRIPTION} ${entries.denied ? STORAGE_UNCHECKED_REASON : NOTHING_CACHED_REASON}`;
|
||||
}
|
||||
|
||||
/** D102: review the owned browser entries, then clear exactly those and say what happened. */
|
||||
export function openMetadataCacheReview({ scope, entries, trigger = null, notify = showWorkspaceToast,
|
||||
cache = ownership(), onCleared = () => {} }) {
|
||||
const count = entries.keys.length + entries.caches.length;
|
||||
openReviewDialog({ scope, trigger, title: 'Review maintenance plan', heading: 'Metadata cache',
|
||||
grid: [['Scope', `${count.toLocaleString('en-US')} cached ${noun(count, 'entry', 'entries')} in this browser`],
|
||||
['Protected', 'Preferences and other tools’ data in this browser'],
|
||||
['Backup', 'Not needed: the database is not changed'],
|
||||
['Media on disk', 'No files are removed']],
|
||||
sentence: [...entries.keys, ...entries.caches].join(' · '),
|
||||
cancelLabel: 'Cancel', primary: { label: 'Clear browser cache' },
|
||||
onStart: async () => {
|
||||
const result = await cache.clearOwned({ storage: browserStorage, cacheStorage: cacheStorage() });
|
||||
if (scope.signal.aborted) return;
|
||||
notify(cache.clearMessage(result));
|
||||
onCleared();
|
||||
} });
|
||||
}
|
||||
|
||||
/** The Metadata cache row: aria-disabled with its reason while nothing is owned (D102). */
|
||||
function metadataCacheRow({ scope, notify, cache }) {
|
||||
const row = operationRow({ scope, title: 'Metadata cache', description: METADATA_CACHE_DESCRIPTION, label: 'Review plan',
|
||||
onClick: async button => {
|
||||
if (button.getAttribute('aria-disabled') === 'true') return;
|
||||
const entries = await cache.ownedEntries({ storage: browserStorage, cacheStorage: cacheStorage() });
|
||||
if (scope.signal.aborted) return;
|
||||
if (entries.keys.length + entries.caches.length === 0) {
|
||||
paint(entries);
|
||||
return;
|
||||
}
|
||||
openMetadataCacheReview({ scope, entries, trigger: button, notify, cache, onCleared: refresh });
|
||||
} });
|
||||
const button = row.querySelector('.row-actions > button');
|
||||
const description = row.querySelector('p');
|
||||
function paint(entries) {
|
||||
description.textContent = metadataCacheDescription(entries);
|
||||
if (entries.keys.length + entries.caches.length > 0) button.removeAttribute('aria-disabled');
|
||||
else button.setAttribute('aria-disabled', 'true');
|
||||
}
|
||||
function refresh() {
|
||||
cache.ownedEntries({ storage: browserStorage, cacheStorage: cacheStorage() }).then(entries => {
|
||||
if (!scope.signal.aborted) paint(entries);
|
||||
});
|
||||
}
|
||||
paint({ keys: [], caches: [] });
|
||||
refresh();
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Maintenance section's mount (`mountSystem`'s `sections.maintenance`): the notice and the two surfaces. `activity`
|
||||
* is the page's Activity store.
|
||||
*/
|
||||
export function mountMaintenance(container, { api, activity, scope, navigate = navigateTo, notify = showWorkspaceToast,
|
||||
cache = ownership() }) {
|
||||
const reviewMissing = (trigger, pressed = trigger) => reviewPlan({ api, store: activity, scope, operation: 'missing_links',
|
||||
trigger, hold: pressed, notify, navigate });
|
||||
const actions = {
|
||||
health: button => runHealthAction({ api, activity, scope, button, onReviewMissing: reviewMissing, notify }),
|
||||
duplicates: () => findDuplicates({ store: activity, scope, notify, navigate }),
|
||||
thumbnails: button => openThumbnailReview({ api, store: activity, scope, trigger: button, navigate }),
|
||||
};
|
||||
const surfaces = MAINTENANCE_SURFACES.map(([heading, rows]) => {
|
||||
const surface = el('section', undefined, 'surface');
|
||||
surface.append(el('h2', heading), ...rows.map(([title, description, label, action, danger = false]) => {
|
||||
if (action === 'metadata_cache') return metadataCacheRow({ scope, notify, cache });
|
||||
const onClick = actions[action]
|
||||
?? (button => reviewPlan({ api, store: activity, scope, operation: action, trigger: button, notify, navigate }));
|
||||
return operationRow({ scope, title, description, label, danger, onClick });
|
||||
}));
|
||||
return surface;
|
||||
});
|
||||
return [healthNotice({ api, activity, scope, navigate, notify, onReviewMissing: reviewMissing }), ...surfaces];
|
||||
}
|
||||
@@ -235,7 +235,7 @@ export function healthNodes(report, { scope, onReviewMissing }) {
|
||||
const close = createButton('Close');
|
||||
const review = createButton('Review missing links', 'ca-primary');
|
||||
scope.listen(close, 'click', () => closeWorkspaceDialog());
|
||||
scope.listen(review, 'click', () => onReviewMissing());
|
||||
scope.listen(review, 'click', () => onReviewMissing(review));
|
||||
const actions = el('div', undefined, 'dialog-actions');
|
||||
actions.append(close, review);
|
||||
return [list, el('p', healthSummary(report), 'small'), actions];
|
||||
@@ -288,41 +288,55 @@ export async function runHealthCheck(api, { activity, signal } = {}) {
|
||||
return report;
|
||||
}
|
||||
|
||||
/** Open the Health report for `report` (the Maintenance notice's and, from S7, its Check action's dialog). */
|
||||
export function openHealthReport(report, { scope, trigger = null, navigate = navigateTo }) {
|
||||
/**
|
||||
* Open the Health report for `report` (Maintenance's notice and its Check action). Its "Review missing links" runs
|
||||
* `onReviewMissing(trigger, pressed)`: Maintenance reviews a fresh Missing-file links plan (S7), never a cleanup,
|
||||
* holding the pressed button while it is read; without one it goes to Maintenance.
|
||||
*/
|
||||
export function openHealthReport(report, { scope, trigger = null, navigate = navigateTo, onReviewMissing = null }) {
|
||||
openContentDialog({ scope, title: 'Health report', trigger, nodes: healthNodes(report, { scope,
|
||||
// Missing-link cleanup lives in Maintenance (its D94 hand-off until S7).
|
||||
onReviewMissing: () => { closeWorkspaceDialog(); navigate('#/system/maintenance'); } }) });
|
||||
onReviewMissing: pressed => {
|
||||
if (onReviewMissing) onReviewMissing(trigger, pressed);
|
||||
else { closeWorkspaceDialog(); navigate('#/system/maintenance'); }
|
||||
} }) });
|
||||
}
|
||||
|
||||
/**
|
||||
* The concept's Maintenance notice ("Check first. Review changes. Then apply." + "Run health checks", a689), which
|
||||
* S2 delivers ahead of the rest of Maintenance (D123): the press runs the read-only check, then opens the report.
|
||||
* Run the read-only health check from `button` and open its report: the button reads "Checking…" and is aria-disabled
|
||||
* until the check ended (a second press is ignored), then reads `label` again.
|
||||
*/
|
||||
export function healthNotice({ api, activity, scope, navigate = navigateTo, notify = showWorkspaceToast }) {
|
||||
export async function runHealthAction({ api, activity, scope, button, navigate = navigateTo, notify = showWorkspaceToast,
|
||||
onReviewMissing = null }) {
|
||||
if (button.getAttribute('aria-disabled') === 'true') return;
|
||||
const label = button.textContent;
|
||||
button.setAttribute('aria-disabled', 'true');
|
||||
button.textContent = 'Checking…';
|
||||
try {
|
||||
const report = await runHealthCheck(api, { activity, signal: scope.signal });
|
||||
if (scope.signal.aborted) return;
|
||||
openHealthReport(report, { scope, trigger: button, navigate, onReviewMissing });
|
||||
} catch (error) {
|
||||
if (!scope.signal.aborted) notify(HEALTH_FAILED_TEXT);
|
||||
} finally {
|
||||
if (!scope.signal.aborted) {
|
||||
button.removeAttribute('aria-disabled');
|
||||
button.textContent = label;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The concept's Maintenance notice ("Check first. Review changes. Then apply." + "Run health checks", a689), D123: the
|
||||
* press runs the read-only check, then opens the report.
|
||||
*/
|
||||
export function healthNotice({ api, activity, scope, navigate = navigateTo, notify = showWorkspaceToast, onReviewMissing = null }) {
|
||||
const notice = el('div', undefined, 'notice');
|
||||
const text = el('div');
|
||||
text.append(el('h2', 'Check first. Review changes. Then apply.'),
|
||||
el('p', 'One home for database and media maintenance. Scanning is separate from changing or removing records.'));
|
||||
const run = createButton('Run health checks', 'ca-primary');
|
||||
notice.append(text, run);
|
||||
scope.listen(run, 'click', async () => {
|
||||
if (run.getAttribute('aria-disabled') === 'true') return;
|
||||
run.setAttribute('aria-disabled', 'true');
|
||||
run.textContent = 'Checking…';
|
||||
try {
|
||||
const report = await runHealthCheck(api, { activity, signal: scope.signal });
|
||||
if (scope.signal.aborted) return;
|
||||
openHealthReport(report, { scope, trigger: run, navigate });
|
||||
} catch (error) {
|
||||
if (!scope.signal.aborted) notify(HEALTH_FAILED_TEXT);
|
||||
} finally {
|
||||
if (!scope.signal.aborted) {
|
||||
run.removeAttribute('aria-disabled');
|
||||
run.textContent = 'Run health checks';
|
||||
}
|
||||
}
|
||||
});
|
||||
scope.listen(run, 'click', () => runHealthAction({ api, activity, scope, button: run, navigate, notify, onReviewMissing }));
|
||||
return notice;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* (`span.small#settings-status` + "Save preferences"). D92: no "Sample configuration" chip, the status copy is
|
||||
* truthful and "Save preferences" is aria-disabled while nothing is unsaved. A section not delivered yet shows the
|
||||
* per-section hand-off to its legacy tool (D94), never mock placeholders. Overview (system-overview.mjs),
|
||||
* Appearance (system-appearance.mjs) and Sources & scans (system-sources.mjs) are native.
|
||||
* Appearance (system-appearance.mjs), Sources & scans (system-sources.mjs) and Maintenance (system-maintenance.mjs) are native.
|
||||
*
|
||||
* Switching sections keeps the shell (and focus on the pressed button) and disposes the old section's scope, so its
|
||||
* listeners and timers never outlive it. The drafts are module-level (Q10): they live for the page session.
|
||||
@@ -14,16 +14,16 @@ import { createButton, createScope } from '../shared/toolkit/core.js';
|
||||
import { SYSTEM_SECTIONS, navigateTo } from './state.mjs';
|
||||
import { createSystemDrafts, DRAFT_DOMAINS } from './system-state.mjs';
|
||||
import { sectionNav } from './system-controls.mjs';
|
||||
import { healthNotice, mountOverview } from './system-overview.mjs';
|
||||
import { mountOverview } from './system-overview.mjs';
|
||||
import { mountAppearance } from './system-appearance.mjs';
|
||||
import { mountSources } from './system-sources.mjs';
|
||||
import { mountMaintenance } from './system-maintenance.mjs';
|
||||
import { busyToast } from './activity-scan.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { displayPreferences, readPreferences, reconcilePreferences, subscribePreferences, writePreferences } from './preferences.mjs';
|
||||
|
||||
const SETTINGS_URL = '/prompt_manager/settings';
|
||||
const ADMIN = Object.freeze({ href: '/prompt_manager/admin', label: 'Open existing administration' });
|
||||
const GALLERY = Object.freeze({ href: '/prompt_manager/gallery', label: 'Open existing image gallery' });
|
||||
/**
|
||||
* D94: where each section's capabilities live until its PR (the plan's legacy capability coverage): the admin page's
|
||||
* dashboard, Settings, Maintenance, Backup, Restore, Scan, Logs, Diagnostics and Auto-tag tools, and the gallery's
|
||||
@@ -32,7 +32,6 @@ const GALLERY = Object.freeze({ href: '/prompt_manager/gallery', label: 'Open ex
|
||||
export const SECTION_HANDOFFS = Object.freeze({
|
||||
integrations: [ADMIN],
|
||||
tagging: [ADMIN],
|
||||
maintenance: [ADMIN, GALLERY],
|
||||
backups: [ADMIN],
|
||||
diagnostics: [ADMIN],
|
||||
});
|
||||
@@ -120,14 +119,14 @@ function markFailed(layout, store) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The sections delivered so far: Overview (S2 Task 6), Appearance (S3 Task 8), Sources & scans (S4 Task 11), and Maintenance's notice with "Run health checks" above the
|
||||
* hand-off to the maintenance tools that are not native yet (D123).
|
||||
* The sections delivered so far: Overview (S2 Task 6), Appearance (S3 Task 8), Sources & scans (S4 Task 11) and
|
||||
* Maintenance (S7 Task 19).
|
||||
*/
|
||||
export const DELIVERED_SECTIONS = Object.freeze({
|
||||
overview: mountOverview,
|
||||
appearance: mountAppearance,
|
||||
sources: mountSources,
|
||||
maintenance: (container, { api, activity, scope }) => [healthNotice({ api, activity, scope }), ...handoff('maintenance')],
|
||||
maintenance: mountMaintenance,
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -420,3 +420,6 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
|
||||
.library-banner > .notice { margin-bottom: 0; /* D129 */ }
|
||||
@media (max-width: 1250px) { .library-banner { padding: 24px 24px 0; /* D129 */ } }
|
||||
@media (max-width: 700px) { .library-banner { padding: 20px 14px 0; /* D129 */ } }
|
||||
/* D101: a duplicate copy's Keep label is its source and path, often long names with no space to break at; it wraps
|
||||
inside its `.compare-item` so the review never scrolls sideways. */
|
||||
.duplicate-groups .compare-item label { overflow-wrap: anywhere; /* D101 */ }
|
||||
|
||||
Reference in New Issue
Block a user