feat(system): Maintenance UI (System S7) (#322)

* fix(gallery): clear only Prompt Manager cache entries (#222)

The legacy gallery's Clear cache deleted 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, which on ComfyUI's shared origin can belong to another tool.

web/js/cache-ownership.js names what a clear may remove from an inventory
of the real writers: every key Prompt Manager writes is a preference and no
page opens CacheStorage or IndexedDB, so nothing is removed and the toast
says so. A test fails when a new storage writer is not classified. The
server thumbnail clear stays its own request; a refused or unreachable
server changes nothing and is reported, and refused browser storage is
reported as such.

* feat(system): run reviewed maintenance natively

System › Maintenance is native: the concept's notice and its Database and
Images & cache surfaces replace the hand-off.

- Integrity & relationship checks runs the health check; the report's
  Review missing links reviews a fresh Missing-file links plan.
- Review plan shows the server's read-only plan (D100): scope, protected,
  backup policy and media lines, the listed records, how long the review
  holds and the backup box; Apply reviewed operation sends only its token
  and opens the job in Activity. busy names the running job, plan-used links
  the job that applied it, an expired or replaced review is reviewed again,
  and a lost answer says Outcome not confirmed and points to Activity.
- Find duplicates starts the search; its view offers Review groups (D101):
  ten groups at a time, Keep radios per copy, each keeper sent as
  {group_id, root_index, path}; the dialog says History keeps the removed
  paths in its run records.
- Thumbnail cache opens the thumbnail dialog; Metadata cache is disabled
  with its reason while nothing is owned (D102, Q5).
- Activity descriptors for maintenance, duplicates and duplicate_cleanup.
- The legacy duplicates dialog's View Full names its source (?root=).

* test(system): compare Maintenance with the concept

system-maintenance-concept.spec compares System › Maintenance, the plan
dialog of each operation and the duplicates dialog with the concept in both
themes and both viewports, mapped only by D100–D102, and proves a mutated
row, label, danger mark or token is noticed. The duplicates dialog's Review
duplicate cleanup is the concept's plain button.

* test(system): Maintenance no longer hands off; raise coverage floors

The System shell test expected Maintenance's D94 hand-off; the section is
native now. coverage:update sets the new modules' floors from the
measured run (cache-ownership 94, maintenance-review 94,
system-maintenance 92 lines) and raises the floors other files passed.

* test(admin): close the preview worker after the last job in teardown

A scan publishes its last event, which ends the stream the test reads,
before it hands its missing previews to the automatic worker. That
hand-off can run after tearDownAsync closed the worker, so it starts a new
one whose .part files were still open when the final drain checked for
open handles (py3.10 on ubuntu, test_scan_reads_metadata_on_several_threads).
The drain now closes the worker for good once every job has ended.

* fix(system): address the S7 final review

- I1: an apply answer is a refusal only when our server sent it (a known
  code, or a 4xx whose body says success:false); a proxy's 5xx, an
  unreadable 2xx or a dropped connection says Outcome not confirmed and
  reads Activity again. ApiError gains 'refused'; the server's own
  pre-start failure is coded 'apply-failed'.
- M1: the lost-response test lets the apply run, then drops its answer.
- M2: a failed apply records its code in the job's scope; Activity offers
  Review again for an overtaken plan (Maintenance for a duplicate cleanup
  whose sources changed).
- M3: a failed duplicate cleanup toasts the server's reason.
- M4: a review button holds while its preview is read (one preview per
  double press).
- M5: the storage guard also catches bracket and property assignment and
  CacheStorage/IndexedDB opens; why the Toolkit build is skipped is
  written down.
- M6: a refused storage listing says so on the Metadata cache row.
- M7, M8: D100 text corrected; workflow_storage has its title.
- M9: a busy start names the job the server named.

* test: follow #319 in the Maintenance tests

- The thumbnail dialog has only Scope since #319.
- The admin teardown refuses new preview workers on its own API instead
  of closing with final=True, which since #319 also shuts the process-wide
  encoders that later test modules use.

* fix(system): keep the server's search-again line on a duplicate review

A duplicate cleanup review the server refuses with 409 sources-changed
showed the generic "could not be reviewed" message; it now shows the
server's "The sources changed since the search. Search for duplicates
again."

* fix(system): a plan with nothing to do offers only Close

A reviewed maintenance plan whose scope is 0 records ("No duplicate
prompt text") offered Apply reviewed operation, which started an empty
job. It now shows its rows, "There is nothing to apply; nothing was
changed." and a Close button.

* fix(system): wrap long copy paths in the duplicates review

A Keep label is the copy's source and path, and dataset folders often have
long names with no space to break at, so the review scrolled sideways.
The label now wraps anywhere inside its compare item (D101).
This commit is contained in:
Vito
2026-10-25 05:51:57 -07:00
committed by GitHub
parent 2964a3b632
commit 9ab25f9aaa
30 changed files with 2626 additions and 120 deletions
+126 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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([]);
});
+28
View File
@@ -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();
});
+6 -3
View File
@@ -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();
+5 -6
View File
@@ -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');
}
+90
View File
@@ -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()
+24
View File
@@ -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);
}
});
+189
View File
@@ -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.');
});
+61 -7
View File
@@ -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 () => {
+80 -1
View File
@@ -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');
});
+14 -1
View File
@@ -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('&amp;', '&')));
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('&amp;', '&'), ...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.');
});
+52
View File
@@ -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")
+31
View File
@@ -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
View File
@@ -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,
+1
View File
@@ -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>
+12
View File
@@ -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) {
+119
View File
@@ -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
View File
@@ -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() {
+46
View File
@@ -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');
+60 -1
View File
@@ -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. */
+2 -1
View File
@@ -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 };
}
},
/**
+390
View File
@@ -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 });
}
+161
View File
@@ -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];
}
+39 -25
View File
@@ -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;
}
+6 -7
View File
@@ -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,
});
/**
+3
View File
@@ -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 */ }